diff --git a/docs/systems/federation.md b/docs/systems/federation.md index d7d1127a..ec9121d2 100644 --- a/docs/systems/federation.md +++ b/docs/systems/federation.md @@ -57,17 +57,19 @@ Backspace federation is peer-to-peer with no central authority. Each instance ma - Validates `sourceOrigin`, `challenge`, `hmacSecret`, and (optional) `instanceName` from body - Handles existing peers: active -> return 200 (idempotent), revoked -> return 403, pending -> update with new secret and activate - New peer: creates record with provided `hmacSecret`, sets `status='active'` -- Returns `{ accepted: true, instanceName: }` on success — see "Instance name exchange" below +- Returns `{ accepted: true, instanceName: , instanceId: }` on success — see "Instance name & epoch exchange" below -### Instance name exchange +### Instance name & epoch exchange -The handshake is bidirectional for the `instance_name` label rendered in the federation panel and in DM-call toasts (`peerLabel`): +The handshake is bidirectional for two pieces of metadata: the `instance_name` label rendered in the federation panel and in DM-call toasts (`peerLabel`), and the **instance epoch** (`instance_id`, this instance's persistent incarnation UUID minted by `ensureDefaults`, accessed via `getInstanceId()`). The epoch is the authenticated baseline used by the instance-epoch self-healing feature to detect a wipe-and-reinstall on the same domain (design: `docs/superpowers/specs/2026-07-01-federation-instance-epoch-self-healing-design.md`). -- **Initiator → responder:** the request body to `/peer/accept` carries `{ sourceOrigin, hmacSecret, instanceName }`. The responder reads `instanceName` and persists it to `federation_peers.instance_name` on every state-mutating activation path: `pending → active`, `awaiting_approval → active`, `rejected → active` (override), and new-peer create. The idempotent early-return path for already-`active` and `needs_attention` peers does NOT overwrite — same security posture that already refuses to overwrite `hmac_secret` on these paths from an unauthenticated request. +- **Initiator → responder:** the request body to `/peer/accept` carries `{ sourceOrigin, hmacSecret, instanceName, instanceId }`. The responder reads `instanceName` → `federation_peers.instance_name` and `instanceId` → `federation_peers.peer_instance_id` on every state-mutating activation path: `pending → active`, `awaiting_approval → active` (token-valid and autoAccept-fallback), `rejected → active` (override), and new-peer create. The idempotent early-return path for already-`active` and `needs_attention` peers does NOT overwrite — same security posture that already refuses to overwrite `hmac_secret` on these paths from an unauthenticated request. (The idempotent guard's *detection* of a changed epoch on that path is a later part of the self-healing feature; the handshake itself only writes the epoch on true activation.) -- **Responder → initiator:** the `/peer/accept` response body is `{ accepted: true, instanceName: }`. The initiator (`performHandshake` in `utils/federationPeering.ts` and `/peer/initiate` in `routes/federation.ts`) parses it and persists alongside the `status='active'` write. Older peers that omit the field are tolerated — the column stays `null`. Non-JSON bodies are tolerated defensively. +- **Responder → initiator:** the `/peer/accept` response body is `{ accepted: true, instanceName: , instanceId: }`. The initiator (`performHandshake` in `utils/federationPeering.ts`, `/peer/initiate`, and both `/approval-requests/:id/approve` handlers in `routes/federation.ts`) parses `instanceName` and `instanceId` and persists them alongside the `status='active'` write (`peer_instance_id`). Older peers that omit either field are tolerated — the respective column stays `null` (backstopped later by the deterministic epoch-refresh and relay-envelope population). Non-JSON bodies are tolerated defensively. -`instance_name` is cosmetic metadata, eventually-consistent. Anywhere `peerLabel` is rendered falls back to origin hostname when `instance_name IS NULL`. Instance renames do not currently re-broadcast — that's a separate, unimplemented feature. +All four outbound `/peer/accept` senders (`performHandshake`, `/peer/initiate`, and the inbound + outbound `/approve` handlers) include `instanceId: getInstanceId()` in the request body, so a peer learns our epoch regardless of which path activated the relationship. + +`instance_name` is cosmetic metadata, eventually-consistent. Anywhere `peerLabel` is rendered falls back to origin hostname when `instance_name IS NULL`. Instance renames do not currently re-broadcast — that's a separate, unimplemented feature. `peer_instance_id` is trust-consequential (only ever written from authenticated channels) — see the self-healing design spec for detection/heal semantics. ### Secret Storage & Rotation diff --git a/packages/server/src/routes/federation.approveOutbound.test.ts b/packages/server/src/routes/federation.approveOutbound.test.ts index 5683fb57..28e9c46e 100644 --- a/packages/server/src/routes/federation.approveOutbound.test.ts +++ b/packages/server/src/routes/federation.approveOutbound.test.ts @@ -81,6 +81,7 @@ function seedInstanceSettings(): void { testDb.insert(schema.instanceSettings).values({ id: 1, instanceName: 'Local Backspace', + instanceId: 'test-epoch-local', autoAcceptPeering: 0, registrationOpen: 1, updatedAt: Date.now(), diff --git a/packages/server/src/routes/federation.outboundApprove.test.ts b/packages/server/src/routes/federation.outboundApprove.test.ts index 80e38b63..868e8434 100644 --- a/packages/server/src/routes/federation.outboundApprove.test.ts +++ b/packages/server/src/routes/federation.outboundApprove.test.ts @@ -90,6 +90,7 @@ function seedInstanceSettings(): void { testDb.insert(schema.instanceSettings).values({ id: 1, instanceName: 'Local Backspace', + instanceId: 'test-epoch-local', autoAcceptPeering: 0, registrationOpen: 1, updatedAt: Date.now(), diff --git a/packages/server/src/routes/federation.outboundDeny.test.ts b/packages/server/src/routes/federation.outboundDeny.test.ts index 27f66bc1..6d566f7c 100644 --- a/packages/server/src/routes/federation.outboundDeny.test.ts +++ b/packages/server/src/routes/federation.outboundDeny.test.ts @@ -85,6 +85,7 @@ function seedInstanceSettings(): void { testDb.insert(schema.instanceSettings).values({ id: 1, instanceName: 'Local Backspace', + instanceId: 'test-epoch-local', autoAcceptPeering: 0, registrationOpen: 1, updatedAt: Date.now(), diff --git a/packages/server/src/routes/federation.peerAccept.test.ts b/packages/server/src/routes/federation.peerAccept.test.ts index 6ce1e1c3..0e9bad04 100644 --- a/packages/server/src/routes/federation.peerAccept.test.ts +++ b/packages/server/src/routes/federation.peerAccept.test.ts @@ -62,6 +62,7 @@ function seedInstanceSettings(name: string): void { testDb.insert(schema.instanceSettings).values({ id: 1, instanceName: name, + instanceId: 'test-epoch-local', autoAcceptPeering: 1, registrationOpen: 1, updatedAt: Date.now(), diff --git a/packages/server/src/routes/federation.peerAcceptApprovalToken.test.ts b/packages/server/src/routes/federation.peerAcceptApprovalToken.test.ts index 933741f4..e2a4262c 100644 --- a/packages/server/src/routes/federation.peerAcceptApprovalToken.test.ts +++ b/packages/server/src/routes/federation.peerAcceptApprovalToken.test.ts @@ -61,6 +61,7 @@ function seedInstanceSettings(autoAccept: 0 | 1): void { testDb.insert(schema.instanceSettings).values({ id: 1, instanceName: 'Local Backspace', + instanceId: 'test-epoch-local', autoAcceptPeering: autoAccept, registrationOpen: 1, updatedAt: Date.now(), diff --git a/packages/server/src/routes/federation.peerInitiateOutbound.test.ts b/packages/server/src/routes/federation.peerInitiateOutbound.test.ts index daf5e9a7..328caa23 100644 --- a/packages/server/src/routes/federation.peerInitiateOutbound.test.ts +++ b/packages/server/src/routes/federation.peerInitiateOutbound.test.ts @@ -81,6 +81,7 @@ function seedInstanceSettings(): void { testDb.insert(schema.instanceSettings).values({ id: 1, instanceName: 'Local Backspace', + instanceId: 'test-epoch-local', autoAcceptPeering: 0, registrationOpen: 1, updatedAt: Date.now(), diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 8f912f1c..378537a1 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -19,6 +19,7 @@ import { deleteAttachmentFiles, deleteUploadFile } from '../utils/fileCleanup.js import { tombstoneUser, collectDeletionBroadcastTargets, collectProfileBroadcastTargetIds } from '../utils/userDeletion.js'; import { computeFederatedId, getDmParticipants, sendCallRelay } from '../utils/federationOutbox.js'; import { onPeerActivated, onPeerDeactivated } from '../utils/federationPeerActivation.js'; +import { getInstanceId } from '../utils/federationEpoch.js'; import { probePeerReachable, markPeerRecovered } from '../utils/federationRecovery.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'; @@ -379,6 +380,7 @@ async function handleInboundApprove( sourceOrigin: localOrigin, hmacSecret, instanceName, + instanceId: getInstanceId(), // 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. @@ -430,21 +432,26 @@ async function handleInboundApprove( 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. + // Parse the remote's instanceName and instanceId (epoch) from the response + // body so the federation panel renders a friendly label and we record the + // peer's authenticated epoch baseline. Tolerate omission and non-JSON + // bodies — same pattern as performHandshake and /peer/initiate. let remoteInstanceName: string | null = null; + let remoteInstanceId: string | null = null; try { - const body = (await response.json()) as { instanceName?: string | null }; + const body = (await response.json()) as { instanceName?: string | null; instanceId?: string | null }; if (typeof body?.instanceName === 'string' && body.instanceName.length > 0) { remoteInstanceName = body.instanceName; } + if (typeof body?.instanceId === 'string' && body.instanceId.length > 0) { + remoteInstanceId = body.instanceId; + } } catch { // Non-JSON body — leave null. } db.update(schema.federationPeers) - .set({ status: 'active', lastSeenAt: now, instanceName: remoteInstanceName, approvalToken: null }) + .set({ status: 'active', lastSeenAt: now, instanceName: remoteInstanceName, peerInstanceId: remoteInstanceId, approvalToken: null }) .where(eq(schema.federationPeers.id, peerId)) .run(); @@ -532,6 +539,7 @@ async function handleOutboundApprove( sourceOrigin: localOrigin, hmacSecret, instanceName, + instanceId: getInstanceId(), // 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. @@ -612,13 +620,18 @@ async function handleOutboundApprove( }); } - // 200 — peer activated. Capture remote's instanceName for the friendly label. + // 200 — peer activated. Capture remote's instanceName for the friendly label + // and instanceId (epoch) for the authenticated baseline. let remoteInstanceName: string | null = approvalReq.instanceName; + let remoteInstanceId: string | null = null; try { - const body = (await response.json()) as { instanceName?: string | null }; + const body = (await response.json()) as { instanceName?: string | null; instanceId?: string | null }; if (typeof body?.instanceName === 'string' && body.instanceName.length > 0) { remoteInstanceName = body.instanceName; } + if (typeof body?.instanceId === 'string' && body.instanceId.length > 0) { + remoteInstanceId = body.instanceId; + } } catch { // Non-JSON body — keep approvalReq.instanceName (may be null). } @@ -628,6 +641,7 @@ async function handleOutboundApprove( status: 'active', lastSeenAt: now, instanceName: remoteInstanceName, + peerInstanceId: remoteInstanceId, approvalToken: null, }) .where(eq(schema.federationPeers.id, peerId)) @@ -883,6 +897,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { .from(schema.instanceSettings) .where(eq(schema.instanceSettings.id, 1)) .get()?.name ?? undefined, + instanceId: getInstanceId(), }), signal: AbortSignal.timeout(10_000), }); @@ -940,20 +955,25 @@ export async function federationRoutes(app: FastifyInstance): Promise { } // Remote accepted — activate the peer. Parse the remote's instanceName - // from the response body so the federation panel renders a friendly - // label. Tolerate omission and non-JSON bodies. + // and instanceId (epoch) from the response body so the federation panel + // renders a friendly label and we record the peer's authenticated + // epoch baseline. Tolerate omission and non-JSON bodies. let remoteInstanceName: string | null = null; + let remoteInstanceId: string | null = null; try { - const body = (await response.json()) as { instanceName?: string | null }; + const body = (await response.json()) as { instanceName?: string | null; instanceId?: string | null }; if (typeof body?.instanceName === 'string' && body.instanceName.length > 0) { remoteInstanceName = body.instanceName; } + if (typeof body?.instanceId === 'string' && body.instanceId.length > 0) { + remoteInstanceId = body.instanceId; + } } catch { // Non-JSON body — leave null. } db.update(schema.federationPeers) - .set({ status: 'active', lastSeenAt: Date.now(), instanceName: remoteInstanceName, approvalToken: null }) + .set({ status: 'active', lastSeenAt: Date.now(), instanceName: remoteInstanceName, peerInstanceId: remoteInstanceId, approvalToken: null }) .where(eq(schema.federationPeers.id, peerId)) .run(); connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); @@ -994,7 +1014,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { // ─── POST /api/federation/peer/accept ────────────────────────────────────── // Server-to-server: accept a peering request from a remote instance. // No JWT auth — this is first contact. Rate-limited by IP. - app.post<{ Body: { sourceOrigin: string; challenge?: string; hmacSecret: string; instanceName?: string; approvalToken?: string } }>( + app.post<{ Body: { sourceOrigin: string; challenge?: string; hmacSecret: string; instanceName?: string; instanceId?: string; approvalToken?: string } }>( '/api/federation/peer/accept', async (request, reply) => { const clientIp = request.ip; @@ -1005,7 +1025,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { }); } - const { sourceOrigin: rawOrigin, hmacSecret, instanceName: reqInstanceName, approvalToken: inboundToken } = request.body ?? {}; + const { sourceOrigin: rawOrigin, hmacSecret, instanceName: reqInstanceName, instanceId: reqInstanceId, approvalToken: inboundToken } = request.body ?? {}; if (!rawOrigin || typeof rawOrigin !== 'string') { return reply.code(400).send({ error: 'sourceOrigin is required', statusCode: 400 }); @@ -1031,6 +1051,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { .get(); const ourInstanceName = settings?.instanceName ?? null; + const ourInstanceId = getInstanceId(); const autoAccept = settings?.autoAcceptPeering ?? 1; // ── autoAcceptPeering gate ────────────────────────────────────────── @@ -1099,7 +1120,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { // Legitimate recovery path: local admin clicks "Reset peering" → // row is deleted → remote's /peer/accept then lands on a // non-existent row and the normal handshake path runs. - return reply.code(200).send({ accepted: true, instanceName: ourInstanceName }); + return reply.code(200).send({ accepted: true, instanceName: ourInstanceName, instanceId: ourInstanceId }); } if (existing.status === 'revoked') { return reply.code(403).send({ @@ -1114,6 +1135,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { .set({ hmacSecret, instanceName: reqInstanceName ?? null, + peerInstanceId: reqInstanceId ?? null, status: 'active', lastSeenAt: Date.now(), }) @@ -1133,7 +1155,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { console.error('[federation] onPeerActivated from /peer/accept (rejected override) failed:', err) ); - return reply.code(200).send({ accepted: true, instanceName: ourInstanceName }); + return reply.code(200).send({ accepted: true, instanceName: ourInstanceName, instanceId: ourInstanceId }); } if (existing.status === 'awaiting_approval') { // Spec §3.5: token verification gates the awaiting_approval → active @@ -1153,6 +1175,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { .set({ hmacSecret, instanceName: reqInstanceName ?? null, + peerInstanceId: reqInstanceId ?? null, status: 'active', lastSeenAt: Date.now(), approvalToken: null, @@ -1176,7 +1199,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { onPeerActivated(existing.id, 'accept_awaiting_approval').catch(err => console.error('[federation] onPeerActivated from /peer/accept (awaiting_approval) failed:', err) ); - return reply.code(200).send({ accepted: true, instanceName: ourInstanceName }); + return reply.code(200).send({ accepted: true, instanceName: ourInstanceName, instanceId: ourInstanceId }); } // Token absent or mismatched. Cannot prove mutual approval. @@ -1188,6 +1211,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { .set({ hmacSecret, instanceName: reqInstanceName ?? null, + peerInstanceId: reqInstanceId ?? null, status: 'active', lastSeenAt: Date.now(), approvalToken: null, @@ -1205,7 +1229,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { onPeerActivated(existing.id, 'accept_awaiting_approval_fallback').catch(err => console.error('[federation] onPeerActivated from /peer/accept (awaiting_approval fallback) failed:', err) ); - return reply.code(200).send({ accepted: true, instanceName: ourInstanceName }); + return reply.code(200).send({ accepted: true, instanceName: ourInstanceName, instanceId: ourInstanceId }); } // autoAccept=0 + unverifiable inbound → queue as new approval-request. @@ -1218,6 +1242,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { .set({ hmacSecret, instanceName: reqInstanceName ?? null, + peerInstanceId: reqInstanceId ?? null, status: 'active', lastSeenAt: Date.now(), }) @@ -1229,7 +1254,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { console.error('[federation] onPeerActivated from /peer/accept (pending) failed:', err) ); - return reply.code(200).send({ accepted: true, instanceName: ourInstanceName }); + return reply.code(200).send({ accepted: true, instanceName: ourInstanceName, instanceId: ourInstanceId }); } // New peer — create and activate @@ -1239,6 +1264,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { origin: sourceOrigin, hmacSecret, instanceName: reqInstanceName ?? null, + peerInstanceId: reqInstanceId ?? null, status: 'active', lastSeenAt: Date.now(), createdAt: Date.now(), @@ -1249,7 +1275,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { console.error('[federation] onPeerActivated from /peer/accept (new) failed:', err) ); - return reply.code(200).send({ accepted: true, instanceName: ourInstanceName }); + return reply.code(200).send({ accepted: true, instanceName: ourInstanceName, instanceId: ourInstanceId }); }, ); diff --git a/packages/server/src/utils/federationEpochHandshake.test.ts b/packages/server/src/utils/federationEpochHandshake.test.ts new file mode 100644 index 00000000..d51054af --- /dev/null +++ b/packages/server/src/utils/federationEpochHandshake.test.ts @@ -0,0 +1,301 @@ +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 './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('../utils/auth.js', () => ({ + authenticate: async (req: { userId?: string }) => { + req.userId = 'admin-user'; + }, + requireAdmin: async () => { + // peer/accept is unauthenticated anyway + }, +})); + +vi.mock('../ws/handler.js', () => ({ + connectionManager: { + sendToAdmins: vi.fn(), + getAllOnlineUserIds: () => [], + sendToUser: vi.fn(), + sendToDmMembers: vi.fn(), + }, +})); + +vi.mock('../utils/federationPeerActivation.js', () => ({ + onPeerActivated: vi.fn(async () => undefined), + onPeerDeactivated: vi.fn(async () => undefined), +})); + +const LOCAL_EPOCH = 'local-epoch-0000'; + +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', + instanceId: LOCAL_EPOCH, + autoAcceptPeering: 1, + registrationOpen: 1, + updatedAt: Date.now(), + }).run(); +} + +async function buildApp(): Promise { + const app = Fastify({ logger: false }); + const { federationRoutes } = await import('../routes/federation.js'); + await app.register(federationRoutes); + await app.ready(); + return app; +} + +describe('POST /api/federation/peer/accept — peer_instance_id (epoch) persistence', () => { + let app: FastifyInstance; + + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedInstanceSettings(); + const { __resetInstanceIdCacheForTest } = await import('./federationEpoch.js'); + __resetInstanceIdCacheForTest(); + app = await buildApp(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sqlite.close(); + }); + + it('writes peer_instance_id when activating an existing pending peer', async () => { + testDb.insert(schema.federationPeers).values({ + id: 'peer-pending', + origin: 'https://remote.example', + hmacSecret: 'old-secret', + status: 'pending', + createdAt: Date.now(), + }).run(); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peer/accept', + payload: { + sourceOrigin: 'https://remote.example', + hmacSecret: 'new-secret', + instanceName: 'Remote Backspace', + instanceId: 'epoch-A', + }, + }); + + expect(response.statusCode).toBe(200); + const row = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.id, 'peer-pending')).get(); + expect(row?.status).toBe('active'); + expect(row?.peerInstanceId).toBe('epoch-A'); + }); + + it('writes peer_instance_id when creating a brand-new peer', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peer/accept', + payload: { + sourceOrigin: 'https://remote.example', + hmacSecret: 'remote-secret', + instanceName: 'Remote Backspace', + instanceId: 'epoch-B', + }, + }); + + expect(response.statusCode).toBe(200); + const row = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://remote.example')).get(); + expect(row?.status).toBe('active'); + expect(row?.peerInstanceId).toBe('epoch-B'); + }); + + it('writes peer_instance_id when overriding rejected → active', async () => { + testDb.insert(schema.federationPeers).values({ + id: 'peer-rejected', + origin: 'https://remote.example', + hmacSecret: 'old-secret', + status: 'rejected', + createdAt: Date.now(), + }).run(); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peer/accept', + payload: { + sourceOrigin: 'https://remote.example', + hmacSecret: 'new-secret', + instanceId: 'epoch-C', + }, + }); + + expect(response.statusCode).toBe(200); + const row = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.id, 'peer-rejected')).get(); + expect(row?.status).toBe('active'); + expect(row?.peerInstanceId).toBe('epoch-C'); + }); + + it('writes peer_instance_id on the awaiting_approval autoAccept fallback path', async () => { + testDb.insert(schema.federationPeers).values({ + id: 'peer-await', + origin: 'https://remote.example', + hmacSecret: 'old-secret', + status: 'awaiting_approval', + createdAt: Date.now(), + }).run(); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peer/accept', + payload: { + sourceOrigin: 'https://remote.example', + hmacSecret: 'new-secret', + instanceId: 'epoch-D', + }, + }); + + expect(response.statusCode).toBe(200); + const row = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.id, 'peer-await')).get(); + expect(row?.status).toBe('active'); + expect(row?.peerInstanceId).toBe('epoch-D'); + }); + + it('writes null peer_instance_id when body omits instanceId (legacy peer)', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peer/accept', + payload: { + sourceOrigin: 'https://remote.example', + hmacSecret: 'remote-secret', + instanceName: 'Remote Backspace', + }, + }); + + expect(response.statusCode).toBe(200); + const row = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://remote.example')).get(); + expect(row?.status).toBe('active'); + expect(row?.peerInstanceId).toBeNull(); + }); + + it('returns our own instanceId in the response body', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peer/accept', + payload: { + sourceOrigin: 'https://remote.example', + hmacSecret: 'remote-secret', + instanceId: 'epoch-E', + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { accepted: boolean; instanceName?: string | null; instanceId?: string }; + expect(body.accepted).toBe(true); + expect(body.instanceId).toBe(LOCAL_EPOCH); + }); +}); + +describe('POST /api/federation/peer/initiate — persists remote epoch from handshake response', () => { + let app: FastifyInstance; + + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedInstanceSettings(); + const { __resetInstanceIdCacheForTest } = await import('./federationEpoch.js'); + __resetInstanceIdCacheForTest(); + app = await buildApp(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + sqlite.close(); + }); + + it('writes peer_instance_id from the remote /peer/accept response body', async () => { + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ accepted: true, instanceName: 'Remote', instanceId: 'remote-epoch-1' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peer/initiate', + payload: { remoteOrigin: 'https://remote.example' }, + }); + + expect(response.statusCode).toBe(200); + const row = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://remote.example')).get(); + expect(row?.status).toBe('active'); + expect(row?.peerInstanceId).toBe('remote-epoch-1'); + + // Our epoch must be sent in the outbound handshake body. + const call = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + const sentBody = JSON.parse(call[1].body as string) as { instanceId?: string }; + expect(sentBody.instanceId).toBe(LOCAL_EPOCH); + }); + + it('writes null peer_instance_id when the remote response omits instanceId', async () => { + vi.stubGlobal('fetch', vi.fn(async () => + new Response(JSON.stringify({ accepted: true, instanceName: 'Remote' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + )); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peer/initiate', + payload: { remoteOrigin: 'https://remote.example' }, + }); + + expect(response.statusCode).toBe(200); + const row = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://remote.example')).get(); + expect(row?.status).toBe('active'); + expect(row?.peerInstanceId).toBeNull(); + }); +}); diff --git a/packages/server/src/utils/federationPeering.approvalToken.test.ts b/packages/server/src/utils/federationPeering.approvalToken.test.ts index 385fe076..666a8150 100644 --- a/packages/server/src/utils/federationPeering.approvalToken.test.ts +++ b/packages/server/src/utils/federationPeering.approvalToken.test.ts @@ -70,6 +70,7 @@ function seedInstanceSettings(): void { testDb.insert(schema.instanceSettings).values({ id: 1, instanceName: 'Local Backspace', + instanceId: 'test-epoch-local', autoAcceptPeering: 1, registrationOpen: 1, updatedAt: Date.now(), diff --git a/packages/server/src/utils/federationPeering.instanceName.test.ts b/packages/server/src/utils/federationPeering.instanceName.test.ts index ca48bd33..46ac1e0c 100644 --- a/packages/server/src/utils/federationPeering.instanceName.test.ts +++ b/packages/server/src/utils/federationPeering.instanceName.test.ts @@ -69,6 +69,7 @@ function seedInstanceSettings(): void { testDb.insert(schema.instanceSettings).values({ id: 1, instanceName: 'Local Backspace', + instanceId: 'test-epoch-local', autoAcceptPeering: 1, registrationOpen: 1, updatedAt: Date.now(), diff --git a/packages/server/src/utils/federationPeering.outboundGate.test.ts b/packages/server/src/utils/federationPeering.outboundGate.test.ts index a189b190..7de0a61a 100644 --- a/packages/server/src/utils/federationPeering.outboundGate.test.ts +++ b/packages/server/src/utils/federationPeering.outboundGate.test.ts @@ -70,6 +70,7 @@ function seedInstanceSettings(autoAcceptPeering: 0 | 1): void { testDb.insert(schema.instanceSettings).values({ id: 1, instanceName: 'Local Backspace', + instanceId: 'test-epoch-local', autoAcceptPeering, registrationOpen: 1, updatedAt: Date.now(), diff --git a/packages/server/src/utils/federationPeering.ts b/packages/server/src/utils/federationPeering.ts index 4fdc8527..04da65cb 100644 --- a/packages/server/src/utils/federationPeering.ts +++ b/packages/server/src/utils/federationPeering.ts @@ -5,6 +5,7 @@ import { generateSnowflake } from './snowflake.js'; import { getOurOrigin, generateHmacSecret } from './federationAuth.js'; import { validateOrigin } from '../routes/federation.js'; import { onPeerActivated, onPeerDeactivated } from './federationPeerActivation.js'; +import { getInstanceId } from './federationEpoch.js'; import type { EnsurePeeredCallerIntent } from '@backspace/shared'; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -304,6 +305,7 @@ async function performHandshake( sourceOrigin: ourOrigin, hmacSecret, instanceName: getInstanceName(), + instanceId: getInstanceId(), }), signal: AbortSignal.timeout(10_000), }); @@ -333,21 +335,26 @@ async function performHandshake( } if (response.ok) { - // 200 = peer accepted and activated. Parse remote's instanceName from - // the response body so we can render a friendly label for the peer. + // 200 = peer accepted and activated. Parse remote's instanceName and + // instanceId (epoch) from the response body so we can render a friendly + // label for the peer and record its authenticated epoch baseline. // Tolerate omission (older peers) and non-JSON bodies (defensive). let remoteInstanceName: string | null = null; + let remoteInstanceId: string | null = null; try { - const body = (await response.json()) as { instanceName?: string | null }; + const body = (await response.json()) as { instanceName?: string | null; instanceId?: string | null }; if (typeof body?.instanceName === 'string' && body.instanceName.length > 0) { remoteInstanceName = body.instanceName; } + if (typeof body?.instanceId === 'string' && body.instanceId.length > 0) { + remoteInstanceId = body.instanceId; + } } catch { - // Non-JSON or empty body — leave remoteInstanceName as null. + // Non-JSON or empty body — leave remoteInstanceName/Id as null. } db.update(schema.federationPeers) - .set({ status: 'active', lastSeenAt: Date.now(), instanceName: remoteInstanceName, approvalToken: null }) + .set({ status: 'active', lastSeenAt: Date.now(), instanceName: remoteInstanceName, peerInstanceId: remoteInstanceId, approvalToken: null }) .where(eq(schema.federationPeers.id, peerId)) .run(); const { connectionManager } = await import('../ws/handler.js');