diff --git a/packages/server/src/utils/federationOutbox.ts b/packages/server/src/utils/federationOutbox.ts index 57aad54d..ea5ca260 100644 --- a/packages/server/src/utils/federationOutbox.ts +++ b/packages/server/src/utils/federationOutbox.ts @@ -524,7 +524,15 @@ export type CallRelayFailureReason = | 'post_failed'; export type CallRelayResult = - | { ok: true } + | { + ok: true; + /** + * messageIds the remote reported as undeliverable (remote processed the + * event cleanly but had no reachable recipient). Empty array for old + * peers that don't set `undeliverable` on FederationRelayResponse. + */ + undeliverable: string[]; + } | { ok: false; reason: CallRelayFailureReason; error: string }; /** Per-peer failure record returned by Path-1 call fan-out helpers. */ @@ -631,7 +639,18 @@ export async function sendCallRelay( signal: AbortSignal.timeout(10_000), }); - if (res.ok) return { ok: true }; + if (res.ok) { + // Parse response body to surface the undeliverable bucket. Old peers + // omit the field; treat as empty. Body shape: FederationRelayResponse. + let undeliverable: string[] = []; + try { + const body = (await res.json()) as { undeliverable?: Array<{ messageId: string }> }; + undeliverable = body.undeliverable?.map(u => u.messageId) ?? []; + } catch { + // Body missing or unparseable — assume old-format response. + } + return { ok: true, undeliverable }; + } const text = await res.text().catch(() => ''); if (res.status >= 400 && res.status < 500) { diff --git a/packages/server/src/utils/federationOutbox.undeliverable.test.ts b/packages/server/src/utils/federationOutbox.undeliverable.test.ts new file mode 100644 index 00000000..8a3cdb4b --- /dev/null +++ b/packages/server/src/utils/federationOutbox.undeliverable.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +type TestDb = ReturnType>; +let testDb: TestDb; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + schema, +})); + +vi.mock('./federationAuth.js', async () => { + const actual = await vi.importActual('./federationAuth.js'); + return { + ...actual, + getOurOrigin: () => 'https://local.example', + buildFederationHeaders: () => ({}), + }; +}); + +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 sql = fs.readFileSync(path.join(migrationsDir, f), 'utf8'); + const statements = sql.split(/-->\s*statement-breakpoint/); + for (const stmt of statements) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +function seedActivePeer(origin: string): void { + testDb.insert(schema.federationPeers).values({ + id: `peer-${origin}`, + origin, + hmacSecret: 'secret', + status: 'active', + instanceName: 'Peer', + lastSyncedAt: 0, + createdAt: Date.now(), + }).run(); +} + +let sqlite: Database.Database; + +describe('sendCallRelay response shape', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedActivePeer('https://peer.example'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sqlite.close(); + }); + + const baseEvent = { + eventType: 'dm_call_start' as const, + messageId: 'msg-X', + encryptionVersion: 0 as const, + timestamp: Date.now(), + federatedId: 'fed-X', + call: { + livekitUrl: 'wss://lk.example', + tokens: {}, + caller: { homeUserId: 'c', homeInstance: 'https://local.example', displayName: 'C' }, + participants: [], + }, + }; + + it('returns {ok:true, undeliverable:[]} when remote omits the field', async () => { + vi.stubGlobal('fetch', vi.fn(async () => + new Response(JSON.stringify({ accepted: ['msg-X'], rejected: [], maxUploadSize: 1000 }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + )); + + const { sendCallRelay } = await import('./federationOutbox.js'); + const result = await sendCallRelay('https://peer.example', [baseEvent]); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.undeliverable).toEqual([]); + } + }); + + it('returns {ok:true, undeliverable:["msg-X"]} when remote lists it', async () => { + vi.stubGlobal('fetch', vi.fn(async () => + new Response(JSON.stringify({ + accepted: [], + rejected: [], + undeliverable: [{ messageId: 'msg-X', reason: 'no_recipient' }], + maxUploadSize: 1000, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + )); + + const { sendCallRelay } = await import('./federationOutbox.js'); + const result = await sendCallRelay('https://peer.example', [baseEvent]); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.undeliverable).toEqual(['msg-X']); + } + }); + + it('failure shape unchanged on HTTP 5xx', async () => { + vi.stubGlobal('fetch', vi.fn(async () => + new Response('server down', { status: 503 }), + )); + + const { sendCallRelay } = await import('./federationOutbox.js'); + const result = await sendCallRelay('https://peer.example', [baseEvent]); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe('peer_transient_failure'); + } + }); +}); diff --git a/packages/server/src/ws/events.dmCallRelay.test.ts b/packages/server/src/ws/events.dmCallRelay.test.ts index 26547070..aae10a6e 100644 --- a/packages/server/src/ws/events.dmCallRelay.test.ts +++ b/packages/server/src/ws/events.dmCallRelay.test.ts @@ -144,7 +144,7 @@ describe('handleDmCallEnd Path-2 relay failure', () => { const fedCall = makeFedCall({ state: 'active' }); connectionManager.createFederatedCall(fedCall); const sendToUserSpy = vi.spyOn(connectionManager, 'sendToUser'); - sendCallRelayMock.mockResolvedValue({ ok: true }); + sendCallRelayMock.mockResolvedValue({ ok: true, undeliverable: [] }); await handleDmCallEndForTest( { federatedCallId: fedCall.federatedId }, @@ -191,7 +191,7 @@ describe('handleDmCallReject Path-2 relay failure', () => { const fedCall = makeFedCall(); connectionManager.createFederatedCall(fedCall); const sendToUserSpy = vi.spyOn(connectionManager, 'sendToUser'); - sendCallRelayMock.mockResolvedValue({ ok: true }); + sendCallRelayMock.mockResolvedValue({ ok: true, undeliverable: [] }); await handleDmCallRejectForTest( { federatedCallId: fedCall.federatedId }, @@ -282,7 +282,7 @@ describe('handleDmCallAccept Path-2 relay failure', () => { const fedCall = makeFedCall(); connectionManager.createFederatedCall(fedCall); const sendToUserSpy = vi.spyOn(connectionManager, 'sendToUser'); - sendCallRelayMock.mockResolvedValue({ ok: true }); + sendCallRelayMock.mockResolvedValue({ ok: true, undeliverable: [] }); await handleDmCallAcceptForTest( { federatedCallId: fedCall.federatedId },