diff --git a/docs/systems/social.md b/docs/systems/social.md index 516c5bca..bcf52093 100644 --- a/docs/systems/social.md +++ b/docs/systems/social.md @@ -300,6 +300,7 @@ As of 2026-04-25, the sender's home server owns the entire federated friend-add - `'pending'` + peer row `awaiting_approval` (re-queried after the call) → 409 `peer_pending_approval` - `'rejected'` → 403 `peer_rejected` - `'failed'` → 503 `peer_unreachable` + - `'admin_required'` (gate fired locally) → 409 `peer_pending_local_admin` — your own admin must approve before we reach out 5. **lookupRemoteUser(peerOrigin, baseName)** — POSTs HMAC-signed `{ username }` to `peerOrigin/api/federation/users/lookup`. Result mapping: - `not_found` → 404 `user_not_found` - `unreachable` → 503 `peer_unreachable` diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 62c784df..bf24102d 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -798,14 +798,26 @@ export async function federationRoutes(app: FastifyInstance): Promise { } const { ensurePeered } = await import('../utils/federationPeering.js'); - const result = await ensurePeered(remoteOrigin); + // NOTE: /peer/ensure is currently only invoked from friend-add client paths + // (see packages/web/src/stores/instanceStore.ts ensurePeered references). + // The hardcoded reason here is correct TODAY but will become wrong when + // DM-to-stranger or space-join grow into the gate. When that happens, + // surface the reason and target through the request body instead. Do NOT + // silently leave the hardcoding in place when adding a new caller. + const result = await ensurePeered(remoteOrigin, { + kind: 'user_action', + userId: request.userId, + reason: 'friend_add', + target: remoteOrigin, + }); // NOTE: The internal EnsurePeeredResult status names differ from the client-facing // peeringStatus values. The mapping: - // 'active' → 'active' (peer is live) - // 'rejected' → 'rejected' (permanently blocked) - // 'pending' → 'awaiting_approval' (queued on remote, waiting for admin) - // 'failed' → 'pending' (transient error, will retry automatically) + // 'active' → 'active' (peer is live) + // 'rejected' → 'rejected' (permanently blocked) + // 'pending' → 'awaiting_approval' (queued on remote, waiting for admin) + // 'failed' → 'pending' (transient error, will retry automatically) + // 'admin_required' → 'admin_required' (local outbound gate fired — our admin must approve) // The internal 'pending' means "we got a 202 from the remote — admin hasn't acted yet", // while 'failed' means "network/timeout — the outbox worker will retry next tick". // The client sees 'awaiting_approval' (actionable info) vs 'pending' (transient, will resolve). @@ -818,6 +830,8 @@ export async function federationRoutes(app: FastifyInstance): Promise { return reply.code(200).send({ peeringStatus: 'awaiting_approval', error: result.error }); case 'failed': return reply.code(200).send({ peeringStatus: 'pending', error: result.error }); + case 'admin_required': + return reply.code(200).send({ peeringStatus: 'admin_required' }); default: return reply.code(200).send({ peeringStatus: 'pending', error: 'Unknown peering result' }); } diff --git a/packages/server/src/routes/social.ts b/packages/server/src/routes/social.ts index 2db95d7a..4bc66d59 100644 --- a/packages/server/src/routes/social.ts +++ b/packages/server/src/routes/social.ts @@ -178,7 +178,12 @@ async function handleFederatedFriendRequest( } // 2. ensurePeered — block until 'active', or surface peer status as error - const peering = await ensurePeered(peerOrigin); + const peering = await ensurePeered(peerOrigin, { + kind: 'user_action', + userId: sender.id, + reason: 'friend_add', + target: `${baseName}@${targetDomain}`, + }); if (peering.status === 'rejected') { return reply.code(403).send({ error: 'peer_rejected', statusCode: 403, domain: targetDomain }); } @@ -195,6 +200,13 @@ async function handleFederatedFriendRequest( } return reply.code(409).send({ error: 'peer_pending', statusCode: 409, domain: targetDomain }); } + if (peering.status === 'admin_required') { + return reply.code(409).send({ + error: 'peer_pending_local_admin', + statusCode: 409, + domain: targetDomain, + }); + } // peering.status === 'active' — continue // 3. Lookup diff --git a/packages/server/src/utils/federationOutbox.ts b/packages/server/src/utils/federationOutbox.ts index b078f438..08e8d33f 100644 --- a/packages/server/src/utils/federationOutbox.ts +++ b/packages/server/src/utils/federationOutbox.ts @@ -520,6 +520,7 @@ export const CALL_PEERING_TIMEOUT_MS = 3_000; export type CallRelayFailureReason = | 'peer_rejected' | 'peer_awaiting_approval' + | 'peer_admin_required' | 'peer_transient_failure' | 'post_failed'; @@ -547,6 +548,7 @@ export function mapCallReasonToEventReason(reason: CallRelayFailureReason): DmCa switch (reason) { case 'peer_rejected': return 'peer_rejected'; case 'peer_awaiting_approval': return 'peer_awaiting_approval'; + case 'peer_admin_required': return 'peer_transient_failure'; // gate-unreachable from system intent; defensive map case 'peer_transient_failure': return 'peer_transient_failure'; case 'post_failed': return 'peer_transient_failure'; // 4xx looks transient to users } @@ -583,14 +585,14 @@ export async function sendCallRelay( if (!peer) { // ─── Non-blocking mode (typing): warm up in background, do not POST ── if (timeoutMs === 0) { - ensurePeered(targetPeerOrigin).catch(err => { + ensurePeered(targetPeerOrigin, { kind: 'system' }).catch(err => { console.warn('[federation] typing-triggered background handshake:', targetPeerOrigin, err); }); return { ok: false, reason: 'peer_transient_failure', error: 'peer not active' }; } // ─── Race ensurePeered against the deadline ── - const raced = await racePeering(targetPeerOrigin, timeoutMs); + const raced = await racePeering(targetPeerOrigin, timeoutMs, { kind: 'system' }); switch (raced.status) { case 'active': @@ -607,6 +609,8 @@ export async function sendCallRelay( return { ok: false, reason: 'peer_rejected', error: raced.error }; case 'pending': return { ok: false, reason: 'peer_awaiting_approval', error: raced.error }; + case 'admin_required': + return { ok: false, reason: 'peer_admin_required', error: raced.error }; case 'failed': return { ok: false, reason: 'peer_transient_failure', error: raced.error }; case 'timeout': diff --git a/packages/server/src/utils/federationPeering.approvalToken.test.ts b/packages/server/src/utils/federationPeering.approvalToken.test.ts index a337738f..385fe076 100644 --- a/packages/server/src/utils/federationPeering.approvalToken.test.ts +++ b/packages/server/src/utils/federationPeering.approvalToken.test.ts @@ -101,7 +101,7 @@ describe('performHandshake — approval token capture & clear', () => { ); const { ensurePeered } = await import('./federationPeering.js'); - const result = await ensurePeered('https://remote.example'); + const result = await ensurePeered('https://remote.example', { kind: 'system' }); expect(result.status).toBe('pending'); @@ -120,7 +120,7 @@ describe('performHandshake — approval token capture & clear', () => { ); const { ensurePeered } = await import('./federationPeering.js'); - const result = await ensurePeered('https://legacy.example'); + const result = await ensurePeered('https://legacy.example', { kind: 'system' }); expect(result.status).toBe('pending'); const peer = testDb.select().from(schema.federationPeers) @@ -135,7 +135,7 @@ describe('performHandshake — approval token capture & clear', () => { ); const { ensurePeered } = await import('./federationPeering.js'); - const result = await ensurePeered('https://empty.example'); + const result = await ensurePeered('https://empty.example', { kind: 'system' }); expect(result.status).toBe('pending'); const peer = testDb.select().from(schema.federationPeers) @@ -161,7 +161,7 @@ describe('performHandshake — approval token capture & clear', () => { ); const { ensurePeered } = await import('./federationPeering.js'); - const result = await ensurePeered('https://remote.example'); + const result = await ensurePeered('https://remote.example', { kind: 'system' }); expect(result.status).toBe('active'); const peer = testDb.select().from(schema.federationPeers) diff --git a/packages/server/src/utils/federationPeering.instanceName.test.ts b/packages/server/src/utils/federationPeering.instanceName.test.ts index c19402d5..ca48bd33 100644 --- a/packages/server/src/utils/federationPeering.instanceName.test.ts +++ b/packages/server/src/utils/federationPeering.instanceName.test.ts @@ -98,7 +98,7 @@ describe('performHandshake — persist remote instanceName', () => { const { ensurePeered, _clearInFlightPeering } = await import('./federationPeering.js'); _clearInFlightPeering(); - const result = await ensurePeered('https://remote.example'); + const result = await ensurePeered('https://remote.example', { kind: 'system' }); expect(result.status).toBe('active'); const row = testDb.select().from(schema.federationPeers) @@ -117,7 +117,7 @@ describe('performHandshake — persist remote instanceName', () => { const { ensurePeered, _clearInFlightPeering } = await import('./federationPeering.js'); _clearInFlightPeering(); - const result = await ensurePeered('https://remote.example'); + const result = await ensurePeered('https://remote.example', { kind: 'system' }); expect(result.status).toBe('active'); const row = testDb.select().from(schema.federationPeers) @@ -136,7 +136,7 @@ describe('performHandshake — persist remote instanceName', () => { const { ensurePeered, _clearInFlightPeering } = await import('./federationPeering.js'); _clearInFlightPeering(); - const result = await ensurePeered('https://remote.example'); + const result = await ensurePeered('https://remote.example', { kind: 'system' }); expect(result.status).toBe('active'); const row = testDb.select().from(schema.federationPeers) diff --git a/packages/server/src/utils/federationPeering.outboundGate.test.ts b/packages/server/src/utils/federationPeering.outboundGate.test.ts index fcaac048..a189b190 100644 --- a/packages/server/src/utils/federationPeering.outboundGate.test.ts +++ b/packages/server/src/utils/federationPeering.outboundGate.test.ts @@ -134,8 +134,8 @@ describe('ensurePeered — outbound gate behavior across (autoAccept × peer-row // Mid-handshake, the pending peer row existed. expect(peerRowsDuringHandshake).toHaveLength(1); - expect(peerRowsDuringHandshake[0].status).toBe('pending'); - expect(peerRowsDuringHandshake[0].origin).toBe('https://orbit.example'); + expect(peerRowsDuringHandshake[0]!.status).toBe('pending'); + expect(peerRowsDuringHandshake[0]!.origin).toBe('https://orbit.example'); // No outbound queue rows were created. const parents = testDb.select().from(schema.peerApprovalRequests).all(); @@ -166,17 +166,17 @@ describe('ensurePeered — outbound gate behavior across (autoAccept × peer-row // Parent row created. const parents = testDb.select().from(schema.peerApprovalRequests).all(); expect(parents).toHaveLength(1); - expect(parents[0].direction).toBe('outbound'); - expect(parents[0].origin).toBe('https://orbit.example'); - expect(parents[0].hmacSecret).toBeNull(); + expect(parents[0]!.direction).toBe('outbound'); + expect(parents[0]!.origin).toBe('https://orbit.example'); + expect(parents[0]!.hmacSecret).toBeNull(); // Subscriber row created. const subs = testDb.select().from(schema.peerApprovalSubscribers).all(); expect(subs).toHaveLength(1); - expect(subs[0].userId).toBe('user1'); - expect(subs[0].triggerReason).toBe('friend_add'); - expect(subs[0].triggerTarget).toBe('bob@orbit.example'); - expect(subs[0].requestId).toBe(parents[0].id); + expect(subs[0]!.userId).toBe('user1'); + expect(subs[0]!.triggerReason).toBe('friend_add'); + expect(subs[0]!.triggerTarget).toBe('bob@orbit.example'); + expect(subs[0]!.requestId).toBe(parents[0]!.id); // No federation_peers row created; no outbound POST attempted. const peers = testDb.select().from(schema.federationPeers).all(); @@ -288,7 +288,7 @@ describe('ensurePeered — outbound gate behavior across (autoAccept × peer-row // Existing pending peer row still present (existingPeerId path doesn't delete on failure). const peers = testDb.select().from(schema.federationPeers).all(); expect(peers).toHaveLength(1); - expect(peers[0].id).toBe('peer-pending'); + expect(peers[0]!.id).toBe('peer-pending'); }); // ─── Test 6 ───────────────────────────────────────────────────────────── @@ -350,7 +350,7 @@ describe('ensurePeered — outbound gate behavior across (autoAccept × peer-row const subsAfterFirst = testDb.select().from(schema.peerApprovalSubscribers).all(); expect(subsAfterFirst).toHaveLength(1); - const firstCreatedAt = subsAfterFirst[0].createdAt; + const firstCreatedAt = subsAfterFirst[0]!.createdAt; // Wait a moment so the refreshed createdAt would differ. await new Promise(r => setTimeout(r, 5)); @@ -366,18 +366,18 @@ describe('ensurePeered — outbound gate behavior across (autoAccept × peer-row // Still exactly one parent row keyed on (origin, direction='outbound'). const parents = testDb.select().from(schema.peerApprovalRequests).all(); expect(parents).toHaveLength(1); - expect(parents[0].direction).toBe('outbound'); - expect(parents[0].origin).toBe('https://orbit.example'); + expect(parents[0]!.direction).toBe('outbound'); + expect(parents[0]!.origin).toBe('https://orbit.example'); // Still exactly one subscriber keyed on (request_id, user_id, reason, target). const subs = testDb.select().from(schema.peerApprovalSubscribers).all(); expect(subs).toHaveLength(1); - expect(subs[0].userId).toBe('user1'); - expect(subs[0].triggerReason).toBe('friend_add'); - expect(subs[0].triggerTarget).toBe('bob@orbit.example'); + expect(subs[0]!.userId).toBe('user1'); + expect(subs[0]!.triggerReason).toBe('friend_add'); + expect(subs[0]!.triggerTarget).toBe('bob@orbit.example'); // createdAt was refreshed on the second call. - expect(subs[0].createdAt).toBeGreaterThan(firstCreatedAt); + expect(subs[0]!.createdAt).toBeGreaterThan(firstCreatedAt); }); // ─── Test 8 ───────────────────────────────────────────────────────────── @@ -409,8 +409,8 @@ describe('ensurePeered — outbound gate behavior across (autoAccept × peer-row // Exactly one parent row. const parents = testDb.select().from(schema.peerApprovalRequests).all(); expect(parents).toHaveLength(1); - expect(parents[0].origin).toBe('https://orbit.example'); - expect(parents[0].direction).toBe('outbound'); + expect(parents[0]!.origin).toBe('https://orbit.example'); + expect(parents[0]!.direction).toBe('outbound'); // Two subscribers, both pointing at the same parent. const subs = testDb.select().from(schema.peerApprovalSubscribers).all(); @@ -418,7 +418,7 @@ describe('ensurePeered — outbound gate behavior across (autoAccept × peer-row const userIds = subs.map(s => s.userId).sort(); expect(userIds).toEqual(['user1', 'user2']); for (const sub of subs) { - expect(sub.requestId).toBe(parents[0].id); + expect(sub.requestId).toBe(parents[0]!.id); expect(sub.triggerReason).toBe('friend_add'); expect(sub.triggerTarget).toBe('bob@orbit.example'); } @@ -443,13 +443,13 @@ describe('ensurePeered — outbound gate behavior across (autoAccept × peer-row const subs = testDb.select().from(schema.peerApprovalSubscribers).all(); expect(subs).toHaveLength(1); - expect(subs[0].userId).toBe('user1'); - expect(subs[0].triggerReason).toBe('space_join'); - expect(subs[0].triggerTarget).toBe('space-abc-123'); + expect(subs[0]!.userId).toBe('user1'); + expect(subs[0]!.triggerReason).toBe('space_join'); + expect(subs[0]!.triggerTarget).toBe('space-abc-123'); const parents = testDb.select().from(schema.peerApprovalRequests).all(); expect(parents).toHaveLength(1); - expect(parents[0].direction).toBe('outbound'); - expect(parents[0].origin).toBe('https://orbit.example'); + expect(parents[0]!.direction).toBe('outbound'); + expect(parents[0]!.origin).toBe('https://orbit.example'); }); }); diff --git a/packages/server/src/utils/federationPeering.test.ts b/packages/server/src/utils/federationPeering.test.ts index 2b044795..1a438cf7 100644 --- a/packages/server/src/utils/federationPeering.test.ts +++ b/packages/server/src/utils/federationPeering.test.ts @@ -51,9 +51,9 @@ describe('racePeering', () => { status: 'active', peerId: 'peer-1', })); - const result = await racePeering('https://example.com', 1_000, stub); + const result = await racePeering('https://example.com', 1_000, { kind: 'system' }, stub); expect(result).toEqual({ status: 'active', peerId: 'peer-1' }); - expect(stub).toHaveBeenCalledWith('https://example.com'); + expect(stub).toHaveBeenCalledWith('https://example.com', { kind: 'system' }); }); it('returns timeout when ensurePeered takes longer than the deadline', async () => { @@ -61,7 +61,7 @@ describe('racePeering', () => { const stub = vi.fn((): Promise => new Promise(() => { // Never resolves — simulates a slow handshake. })); - const racePromise = racePeering('https://example.com', 50, stub); + const racePromise = racePeering('https://example.com', 50, { kind: 'system' }, stub); await vi.advanceTimersByTimeAsync(50); const result = await racePromise; expect(result).toEqual({ status: 'timeout' }); @@ -73,7 +73,7 @@ describe('racePeering', () => { status: 'rejected', error: 'peer denied', })); - const result = await racePeering('https://example.com', 1_000, stub); + const result = await racePeering('https://example.com', 1_000, { kind: 'system' }, stub); expect(result).toEqual({ status: 'rejected', error: 'peer denied' }); }); @@ -83,7 +83,7 @@ describe('racePeering', () => { const stub = vi.fn(() => new Promise((_, reject) => { setTimeout(() => reject(new Error('late failure')), 30); })); - const racePromise = racePeering('https://example.com', 10, stub); + const racePromise = racePeering('https://example.com', 10, { kind: 'system' }, stub); await vi.advanceTimersByTimeAsync(10); const result = await racePromise; expect(result).toEqual({ status: 'timeout' }); @@ -104,7 +104,7 @@ describe('racePeering', () => { const stub = vi.fn(async (): Promise => { throw new Error('immediate handshake failure'); }); - const result = await racePeering('https://example.com', 1_000, stub); + const result = await racePeering('https://example.com', 1_000, { kind: 'system' }, stub); expect(result).toEqual({ status: 'failed', error: 'immediate handshake failure' }); // The handshake rejection was the race winner — no background warn should fire. await Promise.resolve(); @@ -156,7 +156,7 @@ describe('ensurePeered needs_attention handling', () => { const { ensurePeered } = await import('./federationPeering.js'); const fetchSpy = vi.spyOn(globalThis, 'fetch'); - const result = await ensurePeered('https://remote.example'); + const result = await ensurePeered('https://remote.example', { kind: 'system' }); expect(result.status).toBe('rejected'); if (result.status === 'rejected') { diff --git a/packages/server/src/utils/federationPeering.trustGuard.test.ts b/packages/server/src/utils/federationPeering.trustGuard.test.ts index 56b5cfe9..4d1c4fa9 100644 --- a/packages/server/src/utils/federationPeering.trustGuard.test.ts +++ b/packages/server/src/utils/federationPeering.trustGuard.test.ts @@ -103,7 +103,7 @@ describe('ensurePeered — refuses when unresolved inbound approval-request exis const { ensurePeered, _clearInFlightPeering } = await import('./federationPeering.js'); _clearInFlightPeering(); - const result = await ensurePeered('https://orbit.test'); + const result = await ensurePeered('https://orbit.test', { kind: 'system' }); expect(result.status).toBe('rejected'); if (result.status === 'rejected') { @@ -126,7 +126,7 @@ describe('ensurePeered — refuses when unresolved inbound approval-request exis const { ensurePeered, _clearInFlightPeering } = await import('./federationPeering.js'); _clearInFlightPeering(); - const result = await ensurePeered('https://nopeer.test'); + const result = await ensurePeered('https://nopeer.test', { kind: 'system' }); // Reached performHandshake — failure mode is 'failed' (network), NOT // the pre-handshake 'rejected' from the new guard. diff --git a/packages/server/src/utils/federationWorker.ts b/packages/server/src/utils/federationWorker.ts index e2c89507..dc7cb3c7 100644 --- a/packages/server/src/utils/federationWorker.ts +++ b/packages/server/src/utils/federationWorker.ts @@ -496,7 +496,7 @@ async function resolvePendingPeers(): Promise { for (const { peerId, peerOrigin } of pendingWithEntries) { console.log(`[federation-worker] Attempting auto-peer with ${peerOrigin}...`); - const result = await ensurePeered(peerOrigin); + const result = await ensurePeered(peerOrigin, { kind: 'system' }); switch (result.status) { case 'active':