From 290cd606c0e79fbf0f93ddaeb43668278dcecec1 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 2 Jul 2026 01:47:58 +0200 Subject: [PATCH] feat(federation): GET /reset-events admin endpoint + types --- packages/server/src/routes/federation.ts | 65 ++++++- .../src/routes/federationResetEvents.test.ts | 179 ++++++++++++++++++ packages/shared/src/types.ts | 37 ++++ packages/web/src/api/client.ts | 8 +- 4 files changed, 287 insertions(+), 2 deletions(-) create mode 100644 packages/server/src/routes/federationResetEvents.test.ts diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index cb5f9dfa..5b7d96d7 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -21,7 +21,7 @@ import { computeFederatedId, getDmParticipants, sendCallRelay } from '../utils/f import { onPeerActivated, onPeerDeactivated } from '../utils/federationPeerActivation.js'; import { getInstanceId } from '../utils/federationEpoch.js'; import { probePeerReachable, recoverOrDetectReset } from '../utils/federationRecovery.js'; -import { markPeerReset } from '../utils/federationReset.js'; +import { markPeerReset, homeInstanceMatch } from '../utils/federationReset.js'; import { getDmMessageWithUser } from './dm.js'; import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent, FederationRelayAttachment, FederationSyncRequest, FederationSyncResponse, DmMessageWithUser, DmChannel, FederationRelayProfileSnapshot, FederationIdentityDeleteS2SRequest, FederationProfileUpdatePayload, ServerEvent, ApprovalRequestSubscriberSummary, PeeringTriggerReason } from '@backspace/shared'; import { GROUP_DM_NAME_MIN_LENGTH, GROUP_DM_NAME_MAX_LENGTH } from '@backspace/shared/src/constants.js'; @@ -1528,6 +1528,69 @@ export async function federationRoutes(app: FastifyInstance): Promise { }, ); + // ─── GET /api/federation/reset-events ────────────────────────────────────── + // Admin-only: the durable reset journal + per-origin orphaned real accounts, + // for the "Reset cleanup" admin surface (instance-epoch self-healing §6.4). + // Read-only; disposition actions reuse the existing one-click Re-peer (reset + // + initiate) and the existing DELETE /api/admin/users/:id (full-purge Remove). + app.get( + '/api/federation/reset-events', + { preHandler: [authenticate, requireAdmin] }, + async (_request, reply) => { + const db = getDb(); + const events = db.select().from(schema.federationResetEvents).all(); + + const result = events.map((ev) => { + const accounts = db + .select({ + id: schema.users.id, + username: schema.users.username, + displayName: schema.users.displayName, + avatarColor: schema.users.avatarColor, + }) + .from(schema.users) + .where(and( + eq(schema.users.federationHomeOrphaned, 1), + eq(schema.users.isDeleted, 0), + homeInstanceMatch(ev.origin), + )) + .all(); + + const orphanedAccounts = accounts.map((a) => { + const ownedSpaces = db + .select({ id: schema.spaces.id, name: schema.spaces.name }) + .from(schema.spaces) + .where(eq(schema.spaces.ownerId, a.id)) + .all(); + const spaceMemberCount = db + .select({ n: sql`count(*)` }) + .from(schema.spaceMembers) + .where(eq(schema.spaceMembers.userId, a.id)) + .get()?.n ?? 0; + const messageCount = db + .select({ n: sql`count(*)` }) + .from(schema.messages) + .where(eq(schema.messages.userId, a.id)) + .get()?.n ?? 0; + return { ...a, ownedSpaces, spaceMemberCount, messageCount }; + }); + + return { + origin: ev.origin, + deadEpoch: ev.deadEpoch, + newEpoch: ev.newEpoch, + detectedAt: ev.detectedAt, + resolvedAt: ev.resolvedAt, + stubCount: ev.stubCount, + orphanedAccountCount: ev.orphanedAccountCount, + orphanedAccounts, + }; + }); + + return reply.code(200).send({ events: result }); + }, + ); + // ─── DELETE /api/federation/peers/:id ────────────────────────────────────── // Admin-only: revoke a federation peer and clean up its outbox. app.delete<{ Params: { id: string } }>( diff --git a/packages/server/src/routes/federationResetEvents.test.ts b/packages/server/src/routes/federationResetEvents.test.ts new file mode 100644 index 00000000..9e939418 --- /dev/null +++ b/packages/server/src/routes/federationResetEvents.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from '../utils/snowflake.js'; +import { signJwt } from '../utils/auth.js'; + +setWorkerId(12); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; +let app: FastifyInstance; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +function applyMigrations(db: Database.Database): void { + const migrationsDir = path.resolve(__dirname, '../../drizzle'); + const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort(); + for (const f of files) { + const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8'); + const statements = sqlText.split(/-->\s*statement-breakpoint/); + for (const stmt of statements) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +async function buildApp(): Promise { + const { federationRoutes } = await import('./federation.js'); + const f = Fastify(); + await f.register(federationRoutes); + return f; +} + +const ADMIN_ID = 'admin-1'; +const USER_ID = 'user-1'; +const ADMIN_USERNAME = 'admin'; +const USER_USERNAME = 'normie'; + +beforeEach(async () => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + + testDb.insert(schema.users).values([ + { + id: ADMIN_ID, + username: ADMIN_USERNAME, + passwordHash: 'x', + isAdmin: 1, + createdAt: Date.now(), + }, + { + id: USER_ID, + username: USER_USERNAME, + passwordHash: 'x', + isAdmin: 0, + createdAt: Date.now(), + }, + ]).run(); + + app = await buildApp(); +}); + +afterEach(async () => { + await app.close(); +}); + +function adminToken(): string { + return signJwt({ userId: ADMIN_ID, username: ADMIN_USERNAME }); +} + +function userToken(): string { + return signJwt({ userId: USER_ID, username: USER_USERNAME }); +} + +describe('GET /api/federation/reset-events', () => { + it('returns reset events with their orphaned real accounts and space info', async () => { + testDb.insert(schema.federationResetEvents).values({ + origin: 'orbit.ddns.net', + deadEpoch: 'E0', + newEpoch: 'E1', + detectedAt: 1000, + resolvedAt: 2000, + stubCount: 3, + orphanedAccountCount: 1, + }).run(); + + const uid = 'dave-1'; + testDb.insert(schema.users).values({ + id: uid, + username: 'dave@orbit.ddns.net', + displayName: 'Dave', + avatarColor: '#abc', + passwordHash: 'real-hash', + homeInstance: 'orbit.ddns.net', + homeUserId: 'h', + federationHomeOrphaned: 1, + isDeleted: 0, + createdAt: 1, + }).run(); + + // Space owned by the orphaned account. + testDb.insert(schema.spaces).values({ + id: 's1', + name: 'Dave HQ', + ownerId: uid, + createdAt: 1, + }).run(); + + // Membership rows (member of 2 spaces: his own + a second). + testDb.insert(schema.spaces).values({ + id: 's2', + name: 'Other Space', + ownerId: ADMIN_ID, + createdAt: 1, + }).run(); + testDb.insert(schema.spaceMembers).values([ + { spaceId: 's1', userId: uid, joinedAt: 1 }, + { spaceId: 's2', userId: uid, joinedAt: 1 }, + ]).run(); + + // Two channels + messages authored by the orphaned account. + testDb.insert(schema.channels).values({ + id: 'c1', spaceId: 's1', name: 'general', type: 'text', createdAt: 1, + }).run(); + testDb.insert(schema.messages).values([ + { id: 'm1', channelId: 'c1', userId: uid, content: 'hi', createdAt: 1 }, + { id: 'm2', channelId: 'c1', userId: uid, content: 'yo', createdAt: 2 }, + ]).run(); + + const res = await app.inject({ + method: 'GET', + url: '/api/federation/reset-events', + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.events).toHaveLength(1); + const ev = body.events[0]; + expect(ev.origin).toBe('orbit.ddns.net'); + expect(ev.deadEpoch).toBe('E0'); + expect(ev.newEpoch).toBe('E1'); + expect(ev.detectedAt).toBe(1000); + expect(ev.resolvedAt).toBe(2000); + expect(ev.stubCount).toBe(3); + expect(ev.orphanedAccountCount).toBe(1); + expect(ev.orphanedAccounts).toHaveLength(1); + const acct = ev.orphanedAccounts[0]; + expect(acct.username).toBe('dave@orbit.ddns.net'); + expect(acct.displayName).toBe('Dave'); + expect(acct.avatarColor).toBe('#abc'); + expect(acct.ownedSpaces).toEqual([{ id: 's1', name: 'Dave HQ' }]); + expect(acct.spaceMemberCount).toBe(2); + expect(acct.messageCount).toBe(2); + }); + + it('requires admin (401/403 for non-admin)', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/federation/reset-events', + headers: { authorization: `Bearer ${userToken()}` }, + }); + expect([401, 403]).toContain(res.statusCode); + }); +}); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 11e20997..6e965730 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -1191,6 +1191,43 @@ export interface FederationPeer { needsAttentionReason: 'auth_failures' | 'peer_reset_detected' | null; } +// ─── Reset-cleanup admin surface (instance-epoch self-healing §6.4) ────────── + +/** + * A real (non-stub) account whose home instance was reset — quarantined via + * `federation_home_orphaned = 1`. Surfaced to the admin "Reset cleanup" UI with + * enough context (owned spaces, membership/message counts) to decide Keep or + * Remove. + */ +export interface FederationOrphanedAccount { + id: string; + username: string; // '!orphaned:{uid}@domain' for freed handles; real for space owners + displayName: string | null; + avatarColor: string | null; + ownedSpaces: { id: string; name: string }[]; + spaceMemberCount: number; // # of spaces they're a member of + messageCount: number; // # of space messages they authored +} + +/** + * A durable row from the `federation_reset_events` journal, augmented with the + * origin's current orphaned real accounts for admin disposition. + */ +export interface FederationResetEvent { + origin: string; + deadEpoch: string; + newEpoch: string | null; + detectedAt: number; + resolvedAt: number | null; + stubCount: number; + orphanedAccountCount: number; + orphanedAccounts: FederationOrphanedAccount[]; +} + +export interface FederationResetEventsResponse { + events: FederationResetEvent[]; +} + // ─── Outbound peering gate ────────────────────────────────────────────────── /** diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index 8445bb75..3797c93d 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -53,6 +53,9 @@ import type { FederationIdentityDeleteRequest, FederationIdentityDeleteResponse, FederationPeer, + FederationOrphanedAccount, + FederationResetEvent, + FederationResetEventsResponse, ApprovalRequest, PeeringSubscription, PeeringNotification, @@ -68,7 +71,7 @@ import type { } from '@backspace/shared'; import { getApiForOrigin, getOwnerInstanceForDm } from '../utils/crossStoreResolvers'; -export type { FederationPeer, ApprovalRequest, PeeringSubscription, PeeringNotification }; +export type { FederationPeer, FederationOrphanedAccount, FederationResetEvent, FederationResetEventsResponse, ApprovalRequest, PeeringSubscription, PeeringNotification }; export class RateLimitError extends Error { readonly retryAfter: number; @@ -275,6 +278,7 @@ export class BackspaceApiClient { initiatePeering: (data: { remoteOrigin: string }) => Promise<{ peer: FederationPeer }>; ensurePeered: (data: { remoteOrigin: string }) => Promise<{ peeringStatus: string; peerId?: string; error?: string }>; peers: () => Promise<{ peers: FederationPeer[] }>; + resetEvents: () => Promise; revokePeer: (id: string) => Promise<{ success: boolean }>; resetPeer: (id: string) => Promise<{ success: boolean }>; recheckPeer: (id: string) => Promise<{ recovered: boolean; status: string }>; @@ -702,6 +706,8 @@ export class BackspaceApiClient { request<{ peers: FederationPeer[] }>( 'GET', '/federation/peers' ), + resetEvents: () => + request('GET', '/federation/reset-events'), revokePeer: (id: string) => request<{ success: boolean }>('DELETE', `/federation/peers/${id}`), resetPeer: (id: string) =>