From 42355ee8891cfe25ba34f4e2b727611fe3a4f5d8 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:01:14 +0200 Subject: [PATCH] feat(federation): direction-branched approve and deny for outbound queue - /approve on outbound: generates HMAC, sends /peer/accept to remote. 200 -> activate peer + onPeerActivated cleanup. 202 -> awaiting_approval, capture token, queue row + subscribers REMAIN. 4xx/5xx/network -> clean up peer row, leave queue for admin retry. - /deny on outbound: fans out kind='denied' notifications, cascade-deletes parent + subscribers, broadcasts admin event. No remote network call. - /approve and /deny on inbound: existing behavior preserved verbatim. - GET /approval-requests: response includes direction; outbound rows carry subscribers[] (joined with users.username, possibly empty). - Removes Task 1's temporary /deny scaffolding guard now that the direction-branched dispatcher handles outbound rows correctly. - Updates docs/systems/federation.md to describe direction-branched flow. --- docs/systems/federation.md | 34 +- .../routes/federation.outboundApprove.test.ts | 471 +++++++++++ .../routes/federation.outboundDeny.test.ts | 387 +++++++++ packages/server/src/routes/federation.ts | 741 +++++++++++++----- 4 files changed, 1412 insertions(+), 221 deletions(-) create mode 100644 packages/server/src/routes/federation.outboundApprove.test.ts create mode 100644 packages/server/src/routes/federation.outboundDeny.test.ts diff --git a/docs/systems/federation.md b/docs/systems/federation.md index ca4d2310..a418213c 100644 --- a/docs/systems/federation.md +++ b/docs/systems/federation.md @@ -145,17 +145,35 @@ When `autoAcceptPeering` is `false` and an instance calls `POST /api/federation/ - `requested_at` / `expires_at` — Epoch ms; expiry is `requested_at + 30 days` - `approval_token` — Single-use 64-hex-char random token issued in the 202 response and forwarded by `/approve`. See [Approval Token Verification](#approval-token-verification). -**Approval flow** — Admin approves via `POST /api/federation/approval-requests/:id/approve`: -1. A fresh `federationPeer` record is created (or existing `rejected`/`awaiting_approval` record is upserted) with status `pending` -2. A standard `peer/accept` handshake is sent to the requesting origin -3. On success the local peer becomes `active`; the `peer_approval_requests` row is deleted +**Approval flow** — Admin approves via `POST /api/federation/approval-requests/:id/approve`. The handler dispatches on `peer_approval_requests.direction`: -**Denial flow** — Admin denies via `POST /api/federation/approval-requests/:id/deny`: +*Inbound* (remote asked to peer with us): +1. A fresh `federationPeer` record is created (or existing `rejected`/`awaiting_approval` record is upserted) with status `pending` +2. A standard `peer/accept` handshake is sent to the requesting origin (forwarding the stored `approvalToken` per [Approval Token Verification](#approval-token-verification)) +3. On 200 the local peer becomes `active`; on 202 the peer transitions to `awaiting_approval` (mutual-gate); the `peer_approval_requests` row is deleted in both cases + +*Outbound* (local users asked us to peer with a remote — created by the gate in `ensurePeered`): +1. A fresh `federationPeer` row is created with status `pending` (HMAC generated at this moment — outbound queue rows store `hmac_secret = NULL`) +2. `peer/accept` is sent to the remote with no `approvalToken` (we are the initiator with no prior token from this remote) +3. On 200 the peer is promoted to `active`. `onPeerActivated` then fires `fanoutOutboundSubscribers` (see [Outbound peering gate](#outbound-peering-gate)) which writes `kind='approved'` notifications for each subscriber and cascade-deletes the parent `peer_approval_requests` row. The handler does NOT duplicate this cleanup. +4. On 202 the peer transitions to `awaiting_approval`, captures the returned `approvalToken`, and the queue row + subscribers are LEFT INTACT — they wait for the eventual remote-admin approval. `onPeerActivated` is NOT called on this path. +5. On 4xx/5xx/network error the peer row is deleted; the queue row is left intact so the admin can retry. Response status is `502`/`503`/`504` accordingly. +6. Response body shape: `{ success, peerStatus: 'active' | 'awaiting_approval', peer? }`. The `peerStatus` field is the outbound-only signal. + +**Denial flow** — Admin denies via `POST /api/federation/approval-requests/:id/deny`. The handler dispatches on direction: + +*Inbound*: 1. Server sends `POST {origin}/api/federation/peer/denied` signed with the requester's `hmac_secret` (from the approval request row) 2. Receiving instance transitions its local peer record from `awaiting_approval` → `rejected` 3. A local `federationPeer` record is upserted with status `rejected` to block future unsolicited requests from the same origin 4. The `peer_approval_requests` row is deleted +*Outbound*: +1. For each row in `peer_approval_subscribers`, a `kind='denied'` notification is inserted into `peer_approval_notifications` and a `peering_notification_received` WS event is sent to the user +2. The parent `peer_approval_requests` row is deleted (cascade clears subscribers) +3. No remote network call — the remote never knew we were considering this peer +4. `federation_peers_changed` is broadcast to admins so the queue UI refreshes + **Expiry** — The janitor (`federationJanitor.ts`) runs on its scheduled interval and deletes rows where `expires_at < now`. Expired requests do NOT create a `rejected` peer — the requesting instance can re-submit. Admin denial, by contrast, does create a `rejected` peer record, blocking re-requests until an admin clears it. **Pre-handshake guard (`ensurePeered`)** — Before any outbound handshake, `ensurePeered(origin)` in `federationPeering.ts` refuses with `{ status: 'rejected', error: 'Local admin must resolve…' }` if a `peer_approval_requests` row exists for that origin. This blocks the auto-reconnect trigger: without the guard, any code path calling `ensurePeered` (e.g., the silent reconnect in `stores/instanceStore.ts`) could initiate a fresh outbound handshake to a peer that has a pending inbound approval request. The legitimate approve flow (`POST /api/federation/approval-requests/:id/approve`) does NOT call `ensurePeered` — it deletes the approval-request row and does its own direct `fetch` to `/peer/accept` — so the guard does not block legitimate approvals. @@ -193,9 +211,9 @@ The pre-handshake guard above closes the most reliable trigger but cannot preven | `/api/federation/peers/:id/permanent` | DELETE | JWT + admin | Hard-delete revoked peer record | | `/api/federation/peers/:id/reset` | POST | JWT + admin | Delete peer record (cascade-deletes outbox). Only admissible in `needs_attention` state. | | `/api/federation/peers/:id/rotate` | POST | JWT + admin | Trigger immediate secret rotation | -| `/api/federation/approval-requests` | GET | JWT + admin | List pending peering approval requests | -| `/api/federation/approval-requests/:id/approve` | POST | JWT + admin | Approve request, initiate handshake | -| `/api/federation/approval-requests/:id/deny` | POST | JWT + admin | Deny request, notify requester | +| `/api/federation/approval-requests` | GET | JWT + admin | List pending peering approval requests (inbound + outbound). Outbound rows include `subscribers: ApprovalRequestSubscriberSummary[]` (possibly empty). Inbound rows omit `subscribers`. Each row carries `direction: 'inbound' \| 'outbound'`. | +| `/api/federation/approval-requests/:id/approve` | POST | JWT + admin | Approve request — direction-branched (see Approval flow above) | +| `/api/federation/approval-requests/:id/deny` | POST | JWT + admin | Deny request — direction-branched (see Denial flow above) | **`POST /api/federation/peer/ensure`** — Wraps `ensurePeered()`. Accepts `{ remoteOrigin: string }` in body. Returns `{ peeringStatus, peerId?, error? }` where `peeringStatus` is one of `active`, `pending`, `awaiting_approval`, `rejected`, `unreachable`, or `revoked`. diff --git a/packages/server/src/routes/federation.outboundApprove.test.ts b/packages/server/src/routes/federation.outboundApprove.test.ts new file mode 100644 index 00000000..80e38b63 --- /dev/null +++ b/packages/server/src/routes/federation.outboundApprove.test.ts @@ -0,0 +1,471 @@ +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 { eq } from 'drizzle-orm'; +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'; + +setWorkerId(1); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +vi.mock('../config.js', () => ({ + config: { + domain: 'local.example', + port: 3000, + host: '0.0.0.0', + jwtSecret: 'test-secret-12345678901234567890123456789012', + maxUploadSize: 100 * 1024 * 1024, + registrationOpen: true, + }, +})); + +vi.mock('../utils/auth.js', () => ({ + authenticate: async (req: { userId?: string }) => { + req.userId = 'admin-user'; + }, + requireAdmin: async () => {}, +})); + +vi.mock('../utils/federationAuth.js', async () => { + const actual = await vi.importActual('../utils/federationAuth.js'); + return { + ...actual, + getOurOrigin: () => 'https://local.example', + generateHmacSecret: () => 'mock-generated-secret', + }; +}); + +const sentToUser = vi.fn(); +const sentToAdmins = vi.fn(); + +vi.mock('../ws/handler.js', () => ({ + connectionManager: { + sendToAdmins: sentToAdmins, + getAllOnlineUserIds: () => [], + sendToUser: sentToUser, + sendToDmMembers: vi.fn(), + }, +})); + +// Mock the activation module entirely. The route handler is responsible for +// CALLING onPeerActivated on the 200 path; we assert that here. The fanout +// behavior itself is covered by federationPeerActivation.outboundFanout.test.ts +// (Task 6) — keeping that separation avoids running real network sync from +// inside a route test. +const onPeerActivatedMock = vi.fn(async () => undefined); +vi.mock('../utils/federationPeerActivation.js', () => ({ + onPeerActivated: onPeerActivatedMock, + onPeerDeactivated: vi.fn(async () => undefined), +})); + +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); + } + } +} + +function seedInstanceSettings(): void { + testDb.insert(schema.instanceSettings).values({ + id: 1, + instanceName: 'Local Backspace', + autoAcceptPeering: 0, + registrationOpen: 1, + updatedAt: Date.now(), + }).run(); +} + +function seedUser(id: string, username: string): void { + testDb.insert(schema.users).values({ + id, + username, + passwordHash: 'x', + displayName: username, + createdAt: Date.now(), + }).run(); +} + +function seedOutboundRequest(opts: { + id: string; + origin: string; + instanceName?: string | null; + subscribers: Array<{ userId: string; reason: 'friend_add' | 'space_join' | 'direct_message'; target: string }>; +}): void { + const now = Date.now(); + testDb.insert(schema.peerApprovalRequests).values({ + id: opts.id, + origin: opts.origin, + direction: 'outbound', + instanceName: opts.instanceName ?? null, + hmacSecret: null, + requestedAt: now, + expiresAt: now + 30 * 24 * 60 * 60 * 1000, + approvalToken: null, + }).run(); + for (const sub of opts.subscribers) { + testDb.insert(schema.peerApprovalSubscribers).values({ + id: `sub-${opts.id}-${sub.userId}`, + requestId: opts.id, + userId: sub.userId, + triggerReason: sub.reason, + triggerTarget: sub.target, + createdAt: now, + }).run(); + } +} + +async function buildApp(): Promise { + const app = Fastify({ logger: false }); + const { federationRoutes } = await import('./federation.js'); + await app.register(federationRoutes); + await app.ready(); + return app; +} + +describe('POST /api/federation/approval-requests/:id/approve — outbound direction', () => { + let app: FastifyInstance; + + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedInstanceSettings(); + seedUser('alice', 'alice'); + seedUser('bob', 'bob'); + sentToUser.mockClear(); + sentToAdmins.mockClear(); + onPeerActivatedMock.mockClear(); + app = await buildApp(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sqlite.close(); + }); + + // Test 13: 200 outbound activates peer + delegates fanout to onPeerActivated. + it('200 from remote → peer becomes active; delegates fanout to onPeerActivated; handler does NOT manually clean subscribers', async () => { + seedOutboundRequest({ + id: 'req-out-200', + origin: 'https://remote.example', + instanceName: 'Remote Inst', + subscribers: [ + { userId: 'alice', reason: 'friend_add', target: 'someone@remote.example' }, + { userId: 'bob', reason: 'friend_add', target: 'other@remote.example' }, + ], + }); + + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ accepted: true, instanceName: 'Remote Backspace' }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/approval-requests/req-out-200/approve', + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { success: boolean; peerStatus: string }; + expect(body.success).toBe(true); + expect(body.peerStatus).toBe('active'); + + const peer = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://remote.example')).get(); + expect(peer?.status).toBe('active'); + expect(peer?.instanceName).toBe('Remote Backspace'); + expect(peer?.approvalToken).toBeNull(); + + // Handler delegates fanout: onPeerActivated MUST be called with the new peer's id. + expect(onPeerActivatedMock).toHaveBeenCalledTimes(1); + expect(onPeerActivatedMock).toHaveBeenCalledWith(peer!.id, 'approval_handshake'); + + // The handler must NOT pre-emptively clean up subscribers — that's + // onPeerActivated's job. Since we mocked onPeerActivated, the parent + + // subscribers should still be present here. (In production, the real + // onPeerActivated then cascades them; covered by Task 6's fanout test.) + expect(testDb.select().from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, 'req-out-200')).get()).toBeDefined(); + expect(testDb.select().from(schema.peerApprovalSubscribers) + .where(eq(schema.peerApprovalSubscribers.requestId, 'req-out-200')).all()).toHaveLength(2); + + // No notifications written by the handler directly. + expect(testDb.select().from(schema.peerApprovalNotifications).all()).toHaveLength(0); + + // No peering_notification_received fired by the handler. + const peeringEvents = sentToUser.mock.calls.filter(call => { + const ev = call[1] as { type?: string }; + return ev?.type === 'peering_notification_received'; + }); + expect(peeringEvents).toHaveLength(0); + }); + + // Test 14: 202 outbound transitions to awaiting_approval and leaves queue intact. + it('202 from remote → peer becomes awaiting_approval, captures approvalToken, queue + subscribers REMAIN, onPeerActivated NOT called', async () => { + seedOutboundRequest({ + id: 'req-out-202', + origin: 'https://remote.example', + subscribers: [ + { userId: 'alice', reason: 'friend_add', target: 'someone@remote.example' }, + ], + }); + + const remoteToken = 'a'.repeat(64); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ queued: true, approvalToken: remoteToken }), + { status: 202, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/approval-requests/req-out-202/approve', + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { success: boolean; peerStatus: string }; + expect(body.success).toBe(true); + expect(body.peerStatus).toBe('awaiting_approval'); + + const peer = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://remote.example')).get(); + expect(peer?.status).toBe('awaiting_approval'); + expect(peer?.approvalToken).toBe(remoteToken); + + // Outbound queue row + subscribers REMAIN — they wait for full activation. + const stillQueued = testDb.select().from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, 'req-out-202')).get(); + expect(stillQueued).toBeDefined(); + expect(stillQueued?.direction).toBe('outbound'); + const stillSubscribed = testDb.select().from(schema.peerApprovalSubscribers) + .where(eq(schema.peerApprovalSubscribers.requestId, 'req-out-202')).all(); + expect(stillSubscribed).toHaveLength(1); + + // onPeerActivated must NOT be called — peer is not yet active. + expect(onPeerActivatedMock).not.toHaveBeenCalled(); + + // No notifications written. + expect(testDb.select().from(schema.peerApprovalNotifications).all()).toHaveLength(0); + + // No peering_notification_received fired. + const peeringEvents = sentToUser.mock.calls.filter(call => { + const ev = call[1] as { type?: string }; + return ev?.type === 'peering_notification_received'; + }); + expect(peeringEvents).toHaveLength(0); + }); + + // Test 15: network error returns 503; peer cleaned up; queue intact. + it('network error from remote → 503; peer row cleaned up; queue + subscribers REMAIN; onPeerActivated NOT called', async () => { + seedOutboundRequest({ + id: 'req-out-net', + origin: 'https://remote.example', + subscribers: [ + { userId: 'alice', reason: 'friend_add', target: 'someone@remote.example' }, + ], + }); + + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('fetch failed: connection refused')); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/approval-requests/req-out-net/approve', + }); + + expect(response.statusCode).toBe(503); + + // Peer row cleaned up. + expect(testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://remote.example')).get()).toBeUndefined(); + + // Queue + subscribers UNTOUCHED for admin retry. + expect(testDb.select().from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, 'req-out-net')).get()).toBeDefined(); + expect(testDb.select().from(schema.peerApprovalSubscribers) + .where(eq(schema.peerApprovalSubscribers.requestId, 'req-out-net')).all()).toHaveLength(1); + + expect(onPeerActivatedMock).not.toHaveBeenCalled(); + expect(testDb.select().from(schema.peerApprovalNotifications).all()).toHaveLength(0); + }); + + it('5xx from remote → 502; peer row cleaned up; queue + subscribers REMAIN', async () => { + seedOutboundRequest({ + id: 'req-out-500', + origin: 'https://remote.example', + subscribers: [ + { userId: 'alice', reason: 'friend_add', target: 'someone@remote.example' }, + ], + }); + + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ error: 'remote boom' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/approval-requests/req-out-500/approve', + }); + + expect(response.statusCode).toBe(502); + const body = response.json() as { remoteStatus?: number }; + expect(body.remoteStatus).toBe(500); + + expect(testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://remote.example')).get()).toBeUndefined(); + expect(testDb.select().from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, 'req-out-500')).get()).toBeDefined(); + expect(testDb.select().from(schema.peerApprovalSubscribers) + .where(eq(schema.peerApprovalSubscribers.requestId, 'req-out-500')).all()).toHaveLength(1); + }); + + it('does NOT forward approvalToken in outbound /peer/accept body (we hold no remote token)', async () => { + seedOutboundRequest({ + id: 'req-out-noforward', + origin: 'https://remote.example', + subscribers: [ + { userId: 'alice', reason: 'friend_add', target: 'x@remote.example' }, + ], + }); + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ accepted: true, instanceName: 'R' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + await app.inject({ + method: 'POST', + url: '/api/federation/approval-requests/req-out-noforward/approve', + }); + + const init = fetchSpy.mock.calls[0]?.[1]; + const body = JSON.parse(init?.body as string) as { approvalToken?: string; sourceOrigin?: string; hmacSecret?: string }; + expect(body.approvalToken).toBeUndefined(); + expect(body.sourceOrigin).toBe('https://local.example'); + expect(body.hmacSecret).toBe('mock-generated-secret'); + }); +}); + +describe('GET /api/federation/approval-requests — direction + outbound subscribers in response shape', () => { + let app: FastifyInstance; + + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedInstanceSettings(); + seedUser('alice', 'alice'); + seedUser('bob', 'bob'); + app = await buildApp(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sqlite.close(); + }); + + it('inbound rows have no subscribers field; outbound rows include subscriber summaries with username', async () => { + const now = Date.now(); + testDb.insert(schema.peerApprovalRequests).values({ + id: 'req-inbound', + origin: 'https://inbound.example', + direction: 'inbound', + instanceName: 'Inbound', + hmacSecret: 'their-secret', + requestedAt: now, + expiresAt: now + 30 * 24 * 60 * 60 * 1000, + }).run(); + seedOutboundRequest({ + id: 'req-outbound', + origin: 'https://outbound.example', + instanceName: null, + subscribers: [ + { userId: 'alice', reason: 'friend_add', target: 'a@outbound.example' }, + { userId: 'bob', reason: 'space_join', target: 'invite-code-xyz' }, + ], + }); + + const response = await app.inject({ + method: 'GET', + url: '/api/federation/approval-requests', + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { + requests: Array<{ + id: string; + direction: string; + subscribers?: Array<{ userId: string; username: string; triggerReason: string; triggerTarget: string }>; + }>; + }; + const inbound = body.requests.find(r => r.id === 'req-inbound'); + const outbound = body.requests.find(r => r.id === 'req-outbound'); + expect(inbound).toBeDefined(); + expect(inbound?.direction).toBe('inbound'); + expect(inbound?.subscribers).toBeUndefined(); + + expect(outbound).toBeDefined(); + expect(outbound?.direction).toBe('outbound'); + expect(outbound?.subscribers).toBeDefined(); + expect(outbound?.subscribers).toHaveLength(2); + const usernames = new Set(outbound!.subscribers!.map(s => s.username)); + expect(usernames).toEqual(new Set(['alice', 'bob'])); + const reasons = new Set(outbound!.subscribers!.map(s => s.triggerReason)); + expect(reasons).toEqual(new Set(['friend_add', 'space_join'])); + }); + + it('outbound row with zero subscribers returns subscribers: [] (not undefined)', async () => { + const now = Date.now(); + testDb.insert(schema.peerApprovalRequests).values({ + id: 'req-empty-out', + origin: 'https://lonely.example', + direction: 'outbound', + instanceName: null, + hmacSecret: null, + requestedAt: now, + expiresAt: now + 30 * 24 * 60 * 60 * 1000, + }).run(); + + const response = await app.inject({ + method: 'GET', + url: '/api/federation/approval-requests', + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { + requests: Array<{ id: string; direction: string; subscribers?: unknown }>; + }; + const row = body.requests.find(r => r.id === 'req-empty-out'); + expect(row?.direction).toBe('outbound'); + expect(Array.isArray(row?.subscribers)).toBe(true); + expect(row?.subscribers).toEqual([]); + }); +}); diff --git a/packages/server/src/routes/federation.outboundDeny.test.ts b/packages/server/src/routes/federation.outboundDeny.test.ts new file mode 100644 index 00000000..27f66bc1 --- /dev/null +++ b/packages/server/src/routes/federation.outboundDeny.test.ts @@ -0,0 +1,387 @@ +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 { eq } from 'drizzle-orm'; +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'; + +setWorkerId(1); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +vi.mock('../config.js', () => ({ + config: { + domain: 'local.example', + port: 3000, + host: '0.0.0.0', + jwtSecret: 'test-secret-12345678901234567890123456789012', + maxUploadSize: 100 * 1024 * 1024, + registrationOpen: true, + }, +})); + +vi.mock('../utils/auth.js', () => ({ + authenticate: async (req: { userId?: string }) => { + req.userId = 'admin-user'; + }, + requireAdmin: async () => {}, +})); + +vi.mock('../utils/federationAuth.js', async () => { + const actual = await vi.importActual('../utils/federationAuth.js'); + return { + ...actual, + getOurOrigin: () => 'https://local.example', + generateHmacSecret: () => 'mock-generated-secret', + }; +}); + +const sentToUser = vi.fn(); +const sentToAdmins = vi.fn(); + +vi.mock('../ws/handler.js', () => ({ + connectionManager: { + sendToAdmins: sentToAdmins, + getAllOnlineUserIds: () => [], + sendToUser: sentToUser, + sendToDmMembers: vi.fn(), + }, +})); + +const onPeerActivatedMock = vi.fn(async () => undefined); +vi.mock('../utils/federationPeerActivation.js', () => ({ + onPeerActivated: onPeerActivatedMock, + onPeerDeactivated: vi.fn(async () => undefined), +})); + +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); + } + } +} + +function seedInstanceSettings(): void { + testDb.insert(schema.instanceSettings).values({ + id: 1, + instanceName: 'Local Backspace', + autoAcceptPeering: 0, + registrationOpen: 1, + updatedAt: Date.now(), + }).run(); +} + +function seedUser(id: string, username: string): void { + testDb.insert(schema.users).values({ + id, + username, + passwordHash: 'x', + displayName: username, + createdAt: Date.now(), + }).run(); +} + +function seedOutboundRequest(opts: { + id: string; + origin: string; + subscribers: Array<{ userId: string; reason: 'friend_add' | 'space_join' | 'direct_message'; target: string }>; +}): void { + const now = Date.now(); + testDb.insert(schema.peerApprovalRequests).values({ + id: opts.id, + origin: opts.origin, + direction: 'outbound', + instanceName: null, + hmacSecret: null, + requestedAt: now, + expiresAt: now + 30 * 24 * 60 * 60 * 1000, + approvalToken: null, + }).run(); + for (const sub of opts.subscribers) { + testDb.insert(schema.peerApprovalSubscribers).values({ + id: `sub-${opts.id}-${sub.userId}`, + requestId: opts.id, + userId: sub.userId, + triggerReason: sub.reason, + triggerTarget: sub.target, + createdAt: now, + }).run(); + } +} + +async function buildApp(): Promise { + const app = Fastify({ logger: false }); + const { federationRoutes } = await import('./federation.js'); + await app.register(federationRoutes); + await app.ready(); + return app; +} + +describe('POST /api/federation/approval-requests/:id/deny — outbound direction', () => { + let app: FastifyInstance; + + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedInstanceSettings(); + seedUser('alice', 'alice'); + seedUser('bob', 'bob'); + sentToUser.mockClear(); + sentToAdmins.mockClear(); + onPeerActivatedMock.mockClear(); + app = await buildApp(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sqlite.close(); + }); + + // Test 16: outbound deny fans out denied notifications + cascade-deletes. + it('outbound deny → writes denied notifications for each subscriber, sends WS to each, cascade-deletes parent + subscribers, broadcasts admin event', async () => { + seedOutboundRequest({ + id: 'req-out-deny', + origin: 'https://remote.example', + subscribers: [ + { userId: 'alice', reason: 'friend_add', target: 'someone@remote.example' }, + { userId: 'bob', reason: 'space_join', target: 'invite-xyz' }, + ], + }); + + // No fetch should be invoked — outbound deny has no remote network call. + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/approval-requests/req-out-deny/deny', + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true }); + + // No remote network call. + expect(fetchSpy).not.toHaveBeenCalled(); + + // Two denied notifications written. + const notifications = testDb.select().from(schema.peerApprovalNotifications).all(); + expect(notifications).toHaveLength(2); + expect(notifications.every(n => n.kind === 'denied')).toBe(true); + expect(notifications.every(n => n.peerOrigin === 'https://remote.example')).toBe(true); + expect(notifications.every(n => n.readAt === null)).toBe(true); + const userIds = new Set(notifications.map(n => n.userId)); + expect(userIds).toEqual(new Set(['alice', 'bob'])); + // Trigger reason + target captured per row. + const aliceN = notifications.find(n => n.userId === 'alice'); + expect(aliceN?.triggerReason).toBe('friend_add'); + expect(aliceN?.triggerTarget).toBe('someone@remote.example'); + const bobN = notifications.find(n => n.userId === 'bob'); + expect(bobN?.triggerReason).toBe('space_join'); + expect(bobN?.triggerTarget).toBe('invite-xyz'); + + // Parent + subscribers gone (cascade). + expect(testDb.select().from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, 'req-out-deny')).get()).toBeUndefined(); + expect(testDb.select().from(schema.peerApprovalSubscribers) + .where(eq(schema.peerApprovalSubscribers.requestId, 'req-out-deny')).all()).toHaveLength(0); + + // Each subscriber received a peering_notification_received WS. + const peeringEvents = sentToUser.mock.calls.filter(call => { + const ev = call[1] as { type?: string; kind?: string }; + return ev?.type === 'peering_notification_received' && ev?.kind === 'denied'; + }); + expect(peeringEvents).toHaveLength(2); + + // Admins notified that queue changed. + const adminEvents = sentToAdmins.mock.calls.filter(call => { + const ev = call[0] as { type?: string }; + return ev?.type === 'federation_peers_changed'; + }); + expect(adminEvents.length).toBeGreaterThanOrEqual(1); + + // No federation_peers row created — outbound deny has no peer to mark rejected. + expect(testDb.select().from(schema.federationPeers).all()).toHaveLength(0); + }); + + it('outbound deny with zero subscribers still cascade-deletes parent and broadcasts admin event', async () => { + const now = Date.now(); + testDb.insert(schema.peerApprovalRequests).values({ + id: 'req-out-empty', + origin: 'https://lonely.example', + direction: 'outbound', + instanceName: null, + hmacSecret: null, + requestedAt: now, + expiresAt: now + 30 * 24 * 60 * 60 * 1000, + approvalToken: null, + }).run(); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/approval-requests/req-out-empty/deny', + }); + + expect(response.statusCode).toBe(200); + expect(testDb.select().from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, 'req-out-empty')).get()).toBeUndefined(); + expect(testDb.select().from(schema.peerApprovalNotifications).all()).toHaveLength(0); + + const adminEvents = sentToAdmins.mock.calls.filter(call => { + const ev = call[0] as { type?: string }; + return ev?.type === 'federation_peers_changed'; + }); + expect(adminEvents.length).toBeGreaterThanOrEqual(1); + }); + + it('non-existent id returns 404', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/federation/approval-requests/does-not-exist/deny', + }); + expect(response.statusCode).toBe(404); + }); +}); + +// Test 17: regression — inbound paths still behave as before the direction split. +describe('Inbound regression after direction split', () => { + let app: FastifyInstance; + + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedInstanceSettings(); + seedUser('alice', 'alice'); + sentToUser.mockClear(); + sentToAdmins.mockClear(); + onPeerActivatedMock.mockClear(); + app = await buildApp(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sqlite.close(); + }); + + function seedInboundRequest(id: string): void { + const now = Date.now(); + testDb.insert(schema.peerApprovalRequests).values({ + id, + origin: 'https://inbound.example', + direction: 'inbound', + instanceName: 'Inbound', + hmacSecret: 'their-secret', + requestedAt: now, + expiresAt: now + 30 * 24 * 60 * 60 * 1000, + }).run(); + } + + it('/approve on inbound row → existing 200 path activates peer (preserved verbatim)', async () => { + seedInboundRequest('req-in-approve'); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ accepted: true, instanceName: 'Inbound Backspace' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/approval-requests/req-in-approve/approve', + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { success: boolean; peer?: { status?: string } }; + expect(body.success).toBe(true); + // Inbound preserves the existing response shape: `peer` is included, + // `peerStatus` is NOT (that's outbound's signal). + expect(body.peer).toBeDefined(); + + const peer = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://inbound.example')).get(); + expect(peer?.status).toBe('active'); + expect(peer?.instanceName).toBe('Inbound Backspace'); + + // Inbound 200 path deletes the queue row directly (not via onPeerActivated). + expect(testDb.select().from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, 'req-in-approve')).get()).toBeUndefined(); + + // Still calls onPeerActivated for sync-pull / outbox reset. + expect(onPeerActivatedMock).toHaveBeenCalledWith(peer!.id, 'approval_handshake'); + }); + + it('/deny on inbound row → calls remote /peer/denied, marks peer rejected, deletes queue row', async () => { + seedInboundRequest('req-in-deny'); + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/approval-requests/req-in-deny/deny', + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true }); + + // Remote /peer/denied invoked. + expect(fetchSpy).toHaveBeenCalledTimes(1); + const url = fetchSpy.mock.calls[0]?.[0] as string; + expect(url).toBe('https://inbound.example/api/federation/peer/denied'); + + // Local peer row inserted as 'rejected'. + const peer = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://inbound.example')).get(); + expect(peer?.status).toBe('rejected'); + + // Queue row deleted. + expect(testDb.select().from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, 'req-in-deny')).get()).toBeUndefined(); + + // No subscriber notifications (those are outbound-only). + expect(testDb.select().from(schema.peerApprovalNotifications).all()).toHaveLength(0); + }); + + it('/deny on inbound row with unreachable remote returns 502 and leaves queue row pending', async () => { + seedInboundRequest('req-in-deny-fail'); + + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('connection refused')); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/approval-requests/req-in-deny-fail/deny', + }); + + expect(response.statusCode).toBe(502); + + // Queue row still pending — admin can retry. + expect(testDb.select().from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, 'req-in-deny-fail')).get()).toBeDefined(); + // No federation_peers row created because we couldn't deliver the denial. + expect(testDb.select().from(schema.federationPeers).all()).toHaveLength(0); + }); +}); diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index bf24102d..82ab9c48 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -20,7 +20,7 @@ import { tombstoneUser, collectDeletionBroadcastTargets, collectProfileBroadcast import { computeFederatedId, getDmParticipants, sendCallRelay } from '../utils/federationOutbox.js'; import { onPeerActivated, onPeerDeactivated } from '../utils/federationPeerActivation.js'; import { getDmMessageWithUser } from './dm.js'; -import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent, FederationRelayAttachment, FederationSyncRequest, FederationSyncResponse, DmMessageWithUser, DmChannel, FederationRelayProfileSnapshot, FederationIdentityDeleteS2SRequest, FederationProfileUpdatePayload, ServerEvent } from '@backspace/shared'; +import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent, FederationRelayAttachment, FederationSyncRequest, FederationSyncResponse, DmMessageWithUser, DmChannel, FederationRelayProfileSnapshot, FederationIdentityDeleteS2SRequest, FederationProfileUpdatePayload, ServerEvent, ApprovalRequestSubscriberSummary, PeeringTriggerReason } from '@backspace/shared'; /** Fields safe to expose to admin callers (everything except hmacSecret). */ interface SanitizedPeer { @@ -317,6 +317,483 @@ function queueApprovalRequest( }); } +/** + * Inbound approve — admin accepts a remote instance's peering request. + * Generates fresh HMAC, sends `/peer/accept` to the remote, and on success + * activates the peer locally. Preserves the historical behavior verbatim; + * extracted from the route handler so the dispatcher can branch on direction. + */ +async function handleInboundApprove( + approvalReq: typeof schema.peerApprovalRequests.$inferSelect, + localOrigin: string, + reply: FastifyReply, +): Promise { + const db = getDb(); + const id = approvalReq.id; + + const existingPeer = db + .select() + .from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, approvalReq.origin)) + .get(); + + if (existingPeer && existingPeer.status === 'active') { + db.delete(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, id)) + .run(); + return reply.code(200).send({ success: true, peer: sanitizePeer(existingPeer) }); + } + + if (existingPeer) { + db.delete(schema.federationPeers) + .where(eq(schema.federationPeers.id, existingPeer.id)) + .run(); + } + + const hmacSecret = generateHmacSecret(); + const peerId = generateSnowflake(); + const now = Date.now(); + + db.insert(schema.federationPeers).values({ + id: peerId, + origin: approvalReq.origin, + instanceName: approvalReq.instanceName, + hmacSecret, + status: 'pending', + createdAt: now, + }).run(); + + try { + const instanceName = db + .select({ name: schema.instanceSettings.instanceName }) + .from(schema.instanceSettings) + .where(eq(schema.instanceSettings.id, 1)) + .get()?.name ?? undefined; + + const response = await fetch(`${approvalReq.origin}/api/federation/peer/accept`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourceOrigin: localOrigin, + hmacSecret, + instanceName, + // Forward the stored token (issued in our 202 response when the + // remote first sent /peer/accept). Lets the remote verify mutual + // admin approval. Spec §3.7. + ...(approvalReq.approvalToken ? { approvalToken: approvalReq.approvalToken } : {}), + }), + signal: AbortSignal.timeout(10_000), + }); + + if (response.status === 202) { + // Remote instance also has autoAcceptPeering off — they queued our request. + // Don't activate our peer. Set to awaiting_approval until their admin also approves. + // Capture the approval token they returned so the next inbound + // /peer/accept (when their admin approves) can be verified. §3.7. + let returnedToken: string | null = null; + try { + const body = (await response.json()) as { approvalToken?: string }; + if (typeof body?.approvalToken === 'string' && body.approvalToken.length > 0) { + returnedToken = body.approvalToken; + } + } catch { + // Non-JSON / empty body — legacy peer. + } + + db.update(schema.federationPeers) + .set({ status: 'awaiting_approval', approvalToken: returnedToken }) + .where(eq(schema.federationPeers.id, peerId)) + .run(); + // Delete the approval request since we already acted on it + db.delete(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, id)) + .run(); + return reply.code(200).send({ + success: true, + awaitingRemoteApproval: true, + message: 'Remote instance also requires admin approval. Your request has been queued on their side.', + }); + } + + if (!response.ok) { + let errorMessage = `Remote instance rejected handshake (HTTP ${response.status})`; + try { + const body = await response.json() as { error?: string }; + if (body.error) errorMessage = body.error; + } catch { /* ignore */ } + + db.delete(schema.federationPeers) + .where(eq(schema.federationPeers.id, peerId)) + .run(); + return reply.code(502).send({ error: errorMessage, statusCode: 502 }); + } + + // Parse the remote's instanceName from the response body so the + // federation panel renders a friendly label. Tolerate omission and + // non-JSON bodies — same pattern as performHandshake and /peer/initiate. + let remoteInstanceName: string | null = null; + try { + const body = (await response.json()) as { instanceName?: string | null }; + if (typeof body?.instanceName === 'string' && body.instanceName.length > 0) { + remoteInstanceName = body.instanceName; + } + } catch { + // Non-JSON body — leave null. + } + + db.update(schema.federationPeers) + .set({ status: 'active', lastSeenAt: now, instanceName: remoteInstanceName, approvalToken: null }) + .where(eq(schema.federationPeers.id, peerId)) + .run(); + + db.delete(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, id)) + .run(); + + connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); + onPeerActivated(peerId, 'approval_handshake').catch(err => + console.error('[federation] onPeerActivated from /approval-requests/:id/approve failed:', err) + ); + + const peer = db + .select() + .from(schema.federationPeers) + .where(eq(schema.federationPeers.id, peerId)) + .get(); + + return reply.code(200).send({ success: true, peer: peer ? sanitizePeer(peer) : undefined }); + } catch (err: unknown) { + db.delete(schema.federationPeers) + .where(eq(schema.federationPeers.id, peerId)) + .run(); + + const message = err instanceof Error ? err.message : 'Unknown error'; + if (err instanceof DOMException && err.name === 'TimeoutError') { + return reply.code(504).send({ + error: 'Remote instance did not respond within 10 seconds', + statusCode: 504, + }); + } + return reply.code(502).send({ + error: `Failed to reach remote instance: ${message}`, + statusCode: 502, + }); + } +} + +/** + * Outbound approve — admin authorizes the local instance to peer with a + * remote that one or more of its users have requested. Generates fresh HMAC, + * sends `/peer/accept` to the remote, and: + * - 200 → activate peer; `onPeerActivated` runs and (per Task 6) fans out + * approved-notifications to outbound subscribers and cascade-deletes the + * queue row. The handler MUST NOT duplicate that cleanup. + * - 202 → remote also gates; transition to `awaiting_approval`, capture + * the returned token, leave the queue row + subscribers untouched (they + * wait for the remote admin to approve and the eventual full activation + * to fan out via `onPeerActivated`). + * - 4xx/5xx/network → clean up the peer row we created; leave the queue + * row alone so the admin can retry. + */ +async function handleOutboundApprove( + approvalReq: typeof schema.peerApprovalRequests.$inferSelect, + localOrigin: string, + reply: FastifyReply, +): Promise { + const db = getDb(); + const hmacSecret = generateHmacSecret(); + const peerId = generateSnowflake(); + const now = Date.now(); + + // Insert the peer row in 'pending' so failure paths roll back cleanly. + db.insert(schema.federationPeers).values({ + id: peerId, + origin: approvalReq.origin, + instanceName: approvalReq.instanceName, + hmacSecret, + status: 'pending', + createdAt: now, + }).run(); + + const instanceName = db + .select({ name: schema.instanceSettings.instanceName }) + .from(schema.instanceSettings) + .where(eq(schema.instanceSettings.id, 1)) + .get()?.name ?? undefined; + + let response: Response; + try { + response = await fetch(`${approvalReq.origin}/api/federation/peer/accept`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourceOrigin: localOrigin, + hmacSecret, + instanceName, + // No approvalToken — outbound rows are admin-initiated locally; we + // hold no prior token from the remote and rely on the remote's own + // autoAcceptPeering setting to decide 200 vs 202. + }), + signal: AbortSignal.timeout(10_000), + }); + } catch (err: unknown) { + db.delete(schema.federationPeers) + .where(eq(schema.federationPeers.id, peerId)) + .run(); + const message = err instanceof Error ? err.message : 'Unknown error'; + if (err instanceof DOMException && err.name === 'TimeoutError') { + return reply.code(504).send({ + error: 'Remote instance did not respond within 10 seconds', + statusCode: 504, + }); + } + return reply.code(503).send({ + error: `Remote instance unreachable: ${message}`, + statusCode: 503, + }); + } + + if (response.status === 202) { + // Remote also gates new peers. Capture the approval token they returned + // so the next inbound /peer/accept (when the remote admin approves) can + // verify mutual admin approval. The outbound queue row and its + // subscribers REMAIN — `onPeerActivated` is NOT called here; subscribers + // wait for the eventual activation (fanout happens then). + let returnedToken: string | null = null; + try { + const body = (await response.json()) as { approvalToken?: string }; + if (typeof body?.approvalToken === 'string' && body.approvalToken.length > 0) { + returnedToken = body.approvalToken; + } + } catch { + // Non-JSON / empty body — legacy peer with no token to capture. + } + + db.update(schema.federationPeers) + .set({ status: 'awaiting_approval', approvalToken: returnedToken }) + .where(eq(schema.federationPeers.id, peerId)) + .run(); + + connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); + + const peer = db + .select() + .from(schema.federationPeers) + .where(eq(schema.federationPeers.id, peerId)) + .get(); + + return reply.code(200).send({ + success: true, + peerStatus: 'awaiting_approval' as const, + awaitingRemoteApproval: true, + message: 'Remote instance also requires admin approval. Your request has been queued on their side.', + peer: peer ? sanitizePeer(peer) : undefined, + }); + } + + if (!response.ok) { + let errorMessage = `Remote instance rejected handshake (HTTP ${response.status})`; + try { + const body = await response.json() as { error?: string }; + if (body.error) errorMessage = body.error; + } catch { /* ignore */ } + + // Clean up the peer row we created. Leave the outbound queue row alone + // so the admin can retry without re-collecting subscribers. + db.delete(schema.federationPeers) + .where(eq(schema.federationPeers.id, peerId)) + .run(); + return reply.code(502).send({ + error: errorMessage, + statusCode: 502, + remoteStatus: response.status, + }); + } + + // 200 — peer activated. Capture remote's instanceName for the friendly label. + let remoteInstanceName: string | null = approvalReq.instanceName; + try { + const body = (await response.json()) as { instanceName?: string | null }; + if (typeof body?.instanceName === 'string' && body.instanceName.length > 0) { + remoteInstanceName = body.instanceName; + } + } catch { + // Non-JSON body — keep approvalReq.instanceName (may be null). + } + + db.update(schema.federationPeers) + .set({ + status: 'active', + lastSeenAt: now, + instanceName: remoteInstanceName, + approvalToken: null, + }) + .where(eq(schema.federationPeers.id, peerId)) + .run(); + + connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); + + // onPeerActivated runs fanoutOutboundSubscribers (Task 6) which: + // - inserts kind='approved' notifications for each subscriber, + // - sends `peering_notification_received` WS to each subscriber, + // - cascade-deletes the parent + subscriber rows. + // Do NOT duplicate any of that here — it would double-notify and corrupt + // the queue. + onPeerActivated(peerId, 'approval_handshake').catch(err => + console.error('[federation] onPeerActivated from outbound /approval-requests/:id/approve failed:', err) + ); + + const peer = db + .select() + .from(schema.federationPeers) + .where(eq(schema.federationPeers.id, peerId)) + .get(); + + return reply.code(200).send({ + success: true, + peerStatus: 'active' as const, + peer: peer ? sanitizePeer(peer) : undefined, + }); +} + +/** + * Inbound deny — admin rejects a remote instance's peering request. Fires + * the existing /peer/denied notification to the remote, marks any local + * peer row as `rejected`, and clears the queue row. Preserves historical + * behavior verbatim. + */ +async function handleInboundDeny( + approvalReq: typeof schema.peerApprovalRequests.$inferSelect, + reply: FastifyReply, +): Promise { + const db = getDb(); + const id = approvalReq.id; + + // Inbound rows always carry hmacSecret (CHECK constraint enforces this). + // If it's somehow null, we cannot sign /peer/denied — surface clearly. + if (!approvalReq.hmacSecret) { + return reply.code(500).send({ + error: 'Inbound approval request is missing hmacSecret — cannot deliver /peer/denied notification.', + statusCode: 500, + }); + } + + const ourOrigin = getOurOrigin(); + const denialBody = JSON.stringify({ + origin: ourOrigin, + reason: 'denied_by_admin' as const, + message: 'Request denied by admin', + }); + + const headers = buildFederationHeaders(denialBody, approvalReq.hmacSecret, ourOrigin); + + let notificationSent = false; + try { + const response = await fetch(`${approvalReq.origin}/api/federation/peer/denied`, { + method: 'POST', + headers, + body: denialBody, + signal: AbortSignal.timeout(10_000), + }); + notificationSent = response.ok; + } catch { + // Network error + } + + if (!notificationSent) { + return reply.code(502).send({ + error: 'Denial notification could not be delivered to the remote instance. The request is still pending — you can retry or wait for it to expire.', + statusCode: 502, + }); + } + + const existingPeer = db + .select() + .from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, approvalReq.origin)) + .get(); + + if (!existingPeer) { + db.insert(schema.federationPeers).values({ + id: generateSnowflake(), + origin: approvalReq.origin, + instanceName: approvalReq.instanceName, + hmacSecret: approvalReq.hmacSecret, + status: 'rejected', + createdAt: Date.now(), + }).run(); + } else if (existingPeer.status !== 'active') { + db.update(schema.federationPeers) + .set({ status: 'rejected' }) + .where(eq(schema.federationPeers.id, existingPeer.id)) + .run(); + } + + connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); + + db.delete(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, id)) + .run(); + + return reply.code(200).send({ success: true }); +} + +/** + * Outbound deny — admin refuses local users' peering request. Fans out + * `kind='denied'` notifications to each subscriber and cascade-deletes the + * parent (which clears subscribers via FK cascade). No remote network call + * — outbound rows have no /peer/denied counterpart on the wire (the remote + * never knew we were considering this). + */ +async function handleOutboundDeny( + approvalReq: typeof schema.peerApprovalRequests.$inferSelect, + reply: FastifyReply, +): Promise { + const db = getDb(); + const subscribers = db + .select() + .from(schema.peerApprovalSubscribers) + .where(eq(schema.peerApprovalSubscribers.requestId, approvalReq.id)) + .all(); + + const now = Date.now(); + for (const sub of subscribers) { + db.insert(schema.peerApprovalNotifications) + .values({ + id: generateSnowflake(), + userId: sub.userId, + kind: 'denied', + peerOrigin: approvalReq.origin, + triggerReason: sub.triggerReason, + triggerTarget: sub.triggerTarget, + createdAt: now, + readAt: null, + }) + .run(); + + connectionManager.sendToUser(sub.userId, { + type: 'peering_notification_received' as const, + kind: 'denied', + }); + } + + // Cascade-delete clears subscribers via onDelete: 'cascade'. + db.delete(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, approvalReq.id)) + .run(); + + // Tell admins the queue changed. + connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); + + if (subscribers.length > 0) { + console.log( + `[federation] handleOutboundDeny denied ${subscribers.length} subscriber notification${subscribers.length === 1 ? '' : 's'} for ${approvalReq.origin}`, + ); + } + + return reply.code(200).send({ success: true }); +} + export async function federationRoutes(app: FastifyInstance): Promise { // ─── POST /api/federation/peer/initiate ──────────────────────────────────── // Admin-only: start a peering handshake with a remote instance. @@ -1175,6 +1652,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { .select({ id: schema.peerApprovalRequests.id, origin: schema.peerApprovalRequests.origin, + direction: schema.peerApprovalRequests.direction, instanceName: schema.peerApprovalRequests.instanceName, requestedAt: schema.peerApprovalRequests.requestedAt, expiresAt: schema.peerApprovalRequests.expiresAt, @@ -1183,11 +1661,50 @@ export async function federationRoutes(app: FastifyInstance): Promise { .orderBy(desc(schema.peerApprovalRequests.requestedAt)) .all(); - return reply.code(200).send({ requests }); + // For outbound rows, fetch subscriber summaries (joined with users for username). + // Inbound rows have no subscriber concept; field is omitted in their response. + const outboundIds = requests.filter(r => r.direction === 'outbound').map(r => r.id); + const subscribersByRequestId = new Map(); + if (outboundIds.length > 0) { + const rows = db + .select({ + requestId: schema.peerApprovalSubscribers.requestId, + userId: schema.peerApprovalSubscribers.userId, + username: schema.users.username, + triggerReason: schema.peerApprovalSubscribers.triggerReason, + triggerTarget: schema.peerApprovalSubscribers.triggerTarget, + }) + .from(schema.peerApprovalSubscribers) + .innerJoin(schema.users, eq(schema.users.id, schema.peerApprovalSubscribers.userId)) + .where(inArray(schema.peerApprovalSubscribers.requestId, outboundIds)) + .all(); + for (const row of rows) { + const arr = subscribersByRequestId.get(row.requestId) ?? []; + arr.push({ + userId: row.userId, + username: row.username, + triggerReason: row.triggerReason as PeeringTriggerReason, + triggerTarget: row.triggerTarget, + }); + subscribersByRequestId.set(row.requestId, arr); + } + } + + return reply.code(200).send({ + requests: requests.map(r => + r.direction === 'outbound' + ? { ...r, subscribers: subscribersByRequestId.get(r.id) ?? [] } + : r, + ), + }); }, ); // ─── POST /api/federation/approval-requests/:id/approve ─────────────────── + // Direction-branched: inbound rows complete the existing accept-handshake + // path (preserved verbatim); outbound rows initiate /peer/accept against + // the remote, capturing 200/202 outcomes and leaving the queue intact on + // failure so the admin can retry. app.post<{ Params: { id: string } }>( '/api/federation/approval-requests/:id/approve', { preHandler: [authenticate, requireAdmin] }, @@ -1215,158 +1732,18 @@ export async function federationRoutes(app: FastifyInstance): Promise { }); } - const existingPeer = db - .select() - .from(schema.federationPeers) - .where(eq(schema.federationPeers.origin, approvalReq.origin)) - .get(); - - if (existingPeer && existingPeer.status === 'active') { - db.delete(schema.peerApprovalRequests) - .where(eq(schema.peerApprovalRequests.id, id)) - .run(); - return reply.code(200).send({ success: true, peer: sanitizePeer(existingPeer) }); + if (approvalReq.direction === 'outbound') { + return await handleOutboundApprove(approvalReq, localOrigin, reply); } - if (existingPeer) { - db.delete(schema.federationPeers) - .where(eq(schema.federationPeers.id, existingPeer.id)) - .run(); - } - - const hmacSecret = generateHmacSecret(); - const peerId = generateSnowflake(); - const now = Date.now(); - - db.insert(schema.federationPeers).values({ - id: peerId, - origin: approvalReq.origin, - instanceName: approvalReq.instanceName, - hmacSecret, - status: 'pending', - createdAt: now, - }).run(); - - try { - const instanceName = db - .select({ name: schema.instanceSettings.instanceName }) - .from(schema.instanceSettings) - .where(eq(schema.instanceSettings.id, 1)) - .get()?.name ?? undefined; - - const response = await fetch(`${approvalReq.origin}/api/federation/peer/accept`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sourceOrigin: localOrigin, - hmacSecret, - instanceName, - // Forward the stored token (issued in our 202 response when the - // remote first sent /peer/accept). Lets the remote verify mutual - // admin approval. Spec §3.7. - ...(approvalReq.approvalToken ? { approvalToken: approvalReq.approvalToken } : {}), - }), - signal: AbortSignal.timeout(10_000), - }); - - if (response.status === 202) { - // Remote instance also has autoAcceptPeering off — they queued our request. - // Don't activate our peer. Set to awaiting_approval until their admin also approves. - // Capture the approval token they returned so the next inbound - // /peer/accept (when their admin approves) can be verified. §3.7. - let returnedToken: string | null = null; - try { - const body = (await response.json()) as { approvalToken?: string }; - if (typeof body?.approvalToken === 'string' && body.approvalToken.length > 0) { - returnedToken = body.approvalToken; - } - } catch { - // Non-JSON / empty body — legacy peer. - } - - db.update(schema.federationPeers) - .set({ status: 'awaiting_approval', approvalToken: returnedToken }) - .where(eq(schema.federationPeers.id, peerId)) - .run(); - // Delete the approval request since we already acted on it - db.delete(schema.peerApprovalRequests) - .where(eq(schema.peerApprovalRequests.id, id)) - .run(); - return reply.code(200).send({ - success: true, - awaitingRemoteApproval: true, - message: 'Remote instance also requires admin approval. Your request has been queued on their side.', - }); - } - - if (!response.ok) { - let errorMessage = `Remote instance rejected handshake (HTTP ${response.status})`; - try { - const body = await response.json() as { error?: string }; - if (body.error) errorMessage = body.error; - } catch { /* ignore */ } - - db.delete(schema.federationPeers) - .where(eq(schema.federationPeers.id, peerId)) - .run(); - return reply.code(502).send({ error: errorMessage, statusCode: 502 }); - } - - // Parse the remote's instanceName from the response body so the - // federation panel renders a friendly label. Tolerate omission and - // non-JSON bodies — same pattern as performHandshake and /peer/initiate. - let remoteInstanceName: string | null = null; - try { - const body = (await response.json()) as { instanceName?: string | null }; - if (typeof body?.instanceName === 'string' && body.instanceName.length > 0) { - remoteInstanceName = body.instanceName; - } - } catch { - // Non-JSON body — leave null. - } - - db.update(schema.federationPeers) - .set({ status: 'active', lastSeenAt: now, instanceName: remoteInstanceName, approvalToken: null }) - .where(eq(schema.federationPeers.id, peerId)) - .run(); - - db.delete(schema.peerApprovalRequests) - .where(eq(schema.peerApprovalRequests.id, id)) - .run(); - - connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); - onPeerActivated(peerId, 'approval_handshake').catch(err => - console.error('[federation] onPeerActivated from /approval-requests/:id/approve failed:', err) - ); - - const peer = db - .select() - .from(schema.federationPeers) - .where(eq(schema.federationPeers.id, peerId)) - .get(); - - return reply.code(200).send({ success: true, peer: peer ? sanitizePeer(peer) : undefined }); - } catch (err: unknown) { - db.delete(schema.federationPeers) - .where(eq(schema.federationPeers.id, peerId)) - .run(); - - const message = err instanceof Error ? err.message : 'Unknown error'; - if (err instanceof DOMException && err.name === 'TimeoutError') { - return reply.code(504).send({ - error: 'Remote instance did not respond within 10 seconds', - statusCode: 504, - }); - } - return reply.code(502).send({ - error: `Failed to reach remote instance: ${message}`, - statusCode: 502, - }); - } + return await handleInboundApprove(approvalReq, localOrigin, reply); }, ); // ─── POST /api/federation/approval-requests/:id/deny ─────────────────────── + // Direction-branched: inbound rows hit the remote's /peer/denied endpoint + // (existing behavior preserved); outbound rows fan out denied notifications + // to subscribers and cascade-delete the queue row. app.post<{ Params: { id: string } }>( '/api/federation/approval-requests/:id/deny', { preHandler: [authenticate, requireAdmin] }, @@ -1384,73 +1761,11 @@ export async function federationRoutes(app: FastifyInstance): Promise { return reply.code(404).send({ error: 'Approval request not found', statusCode: 404 }); } - // Outbound rows have no hmac_secret and no remote /peer/denied endpoint to call. - // Direction-branched handling lands in Task 7; until then, inbound is the only path here. - if (!approvalReq.hmacSecret) { - return reply.code(400).send({ - error: 'Cannot deny an outbound peering request via this endpoint yet — outbound denial handling is not implemented.', - statusCode: 400, - }); + if (approvalReq.direction === 'outbound') { + return await handleOutboundDeny(approvalReq, reply); } - const ourOrigin = getOurOrigin(); - const denialBody = JSON.stringify({ - origin: ourOrigin, - reason: 'denied_by_admin' as const, - message: 'Request denied by admin', - }); - - const headers = buildFederationHeaders(denialBody, approvalReq.hmacSecret, ourOrigin); - - let notificationSent = false; - try { - const response = await fetch(`${approvalReq.origin}/api/federation/peer/denied`, { - method: 'POST', - headers, - body: denialBody, - signal: AbortSignal.timeout(10_000), - }); - notificationSent = response.ok; - } catch { - // Network error - } - - if (!notificationSent) { - return reply.code(502).send({ - error: 'Denial notification could not be delivered to the remote instance. The request is still pending — you can retry or wait for it to expire.', - statusCode: 502, - }); - } - - const existingPeer = db - .select() - .from(schema.federationPeers) - .where(eq(schema.federationPeers.origin, approvalReq.origin)) - .get(); - - if (!existingPeer) { - db.insert(schema.federationPeers).values({ - id: generateSnowflake(), - origin: approvalReq.origin, - instanceName: approvalReq.instanceName, - hmacSecret: approvalReq.hmacSecret, - status: 'rejected', - createdAt: Date.now(), - }).run(); - } else if (existingPeer.status !== 'active') { - db.update(schema.federationPeers) - .set({ status: 'rejected' }) - .where(eq(schema.federationPeers.id, existingPeer.id)) - .run(); - } - - connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); - - db.delete(schema.peerApprovalRequests) - .where(eq(schema.peerApprovalRequests.id, id)) - .run(); - - return reply.code(200).send({ success: true }); + return await handleInboundDeny(approvalReq, reply); }, );