From 3988c5823ad638c18c21eff9c9fbadd3d29e8794 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:12:54 +0200 Subject: [PATCH 01/11] feat(shared): add no_recipient reason + undeliverable response field (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive protocol extension. No consumers yet — follow-up commits wire the new bucket into the relay endpoint, sendCallRelay, sendFederatedCallStart, and the toast copy. --- packages/shared/src/types.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index e5dae5cc..29faa8ec 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -362,7 +362,8 @@ export type DmCallUndeliverableReason = | 'peer_rejected' | 'peer_awaiting_approval' | 'peer_transient_failure' - | 'livekit_unavailable'; + | 'livekit_unavailable' + | 'no_recipient'; export type DmCallPhase = 'start' | 'accept' | 'reject' | 'end' | 'host_unreachable'; @@ -967,6 +968,14 @@ export interface FederationRelayRequest { export interface FederationRelayResponse { accepted: string[]; rejected: Array<{ messageId: string; reason: string }>; + /** + * Third classification (additive, v1.x): events that were processed cleanly + * but had no reachable recipient. Distinct from `rejected` (data/protocol + * refusal). Currently used only for `dm_call_start` — other event types + * keep accepted/rejected semantics unchanged. Omitted when empty for + * wire-size hygiene and byte-identical responses in the typical case. + */ + undeliverable?: Array<{ messageId: string; reason: string }>; maxUploadSize: number; } From 0057cb4d427961c9edd1fd079a783b4cddcdfe7a Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:17:27 +0200 Subject: [PATCH 02/11] refactor(server): thread undeliverable collector through processRelayEvents (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive plumbing. No behavior change — every existing event-type path continues to push to accepted/rejected only. Response serializes the new bucket only when non-empty (byte-identical responses in the normal case). Tasks 3-4 add actual undeliverable pushes for dm_call_start paths. --- packages/server/src/routes/federation.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 6ded4e4f..f2c450c3 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -1532,7 +1532,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { } // 3. Process each event - const { accepted, rejected } = await processRelayEvents(body.events, sourceInstance, peer.origin, db); + const { accepted, rejected, undeliverable } = await processRelayEvents(body.events, sourceInstance, peer.origin, db); // 4. Update peer status db.update(schema.federationPeers) @@ -1555,6 +1555,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { accepted, rejected, maxUploadSize: settings?.maxUploadSizeBytes ?? config.maxUploadSize, + ...(undeliverable.length > 0 ? { undeliverable } : {}), }; return reply.code(200).send(response); @@ -2066,9 +2067,14 @@ export async function processRelayEvents( sourceInstance: string, peerOrigin: string, db: ReturnType, -): Promise<{ accepted: string[]; rejected: Array<{ messageId: string; reason: string }> }> { +): Promise<{ + accepted: string[]; + rejected: Array<{ messageId: string; reason: string }>; + undeliverable: Array<{ messageId: string; reason: string }>; +}> { const accepted: string[] = []; const rejected: Array<{ messageId: string; reason: string }> = []; + const undeliverable: Array<{ messageId: string; reason: string }> = []; for (const event of events) { try { @@ -2116,7 +2122,7 @@ export async function processRelayEvents( processFileRejectedEvent(event, sourceInstance, db, accepted, rejected); break; case 'dm_call_start': - processDmCallStartEvent(event, sourceInstance, db, accepted, rejected); + processDmCallStartEvent(event, sourceInstance, db, accepted, rejected, undeliverable); break; case 'dm_call_accept': processDmCallAcceptEvent(event, sourceInstance, db, accepted, rejected); @@ -2156,7 +2162,7 @@ export async function processRelayEvents( } } - return { accepted, rejected }; + return { accepted, rejected, undeliverable }; } // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -4253,6 +4259,7 @@ function processDmCallStartEvent( db: ReturnType, accepted: string[], rejected: Array<{ messageId: string; reason: string }>, + undeliverable: Array<{ messageId: string; reason: string }>, ): void { if (!event.call?.caller || !event.call.livekitUrl || !event.call.tokens || !event.federatedId) { rejected.push({ messageId: event.messageId, reason: 'missing_call_payload' }); From 792634c2cf658607f92dc6416a31576dd22a8b6f Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:34:34 +0200 Subject: [PATCH 03/11] =?UTF-8?q?feat(server):=20Path=20B=20zero-match=20?= =?UTF-8?q?=E2=86=92=20undeliverable=20ack=20(#18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit processDmCallStartEvent Path B no longer silently accepts when no local participant is reachable. Pushes {messageId, reason: 'no_recipient'} to the undeliverable ack bucket so the caller can surface fast-fail. TDD — test asserts undeliverable push + no FederatedCallEntry. --- .../src/routes/federation.callStart.test.ts | 108 ++++++++++++++++++ packages/server/src/routes/federation.ts | 7 +- 2 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 packages/server/src/routes/federation.callStart.test.ts diff --git a/packages/server/src/routes/federation.callStart.test.ts b/packages/server/src/routes/federation.callStart.test.ts new file mode 100644 index 00000000..80e2b080 --- /dev/null +++ b/packages/server/src/routes/federation.callStart.test.ts @@ -0,0 +1,108 @@ +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'; +import { setWorkerId } from '../utils/snowflake.js'; + +setWorkerId(1); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +type TestDb = ReturnType>; +let testDb: TestDb; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + schema, +})); + +vi.mock('../utils/federationAuth.js', async () => { + const actual = await vi.importActual( + '../utils/federationAuth.js', + ); + return { + ...actual, + getOurOrigin: () => 'https://local.example', + }; +}); + +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); + } + } +} + +async function importSUT() { + return await import('./federation.js'); +} + +async function importManager() { + const mod = await import('../ws/handler.js'); + return mod.connectionManager; +} + +let sqlite: Database.Database; + +describe('processRelayEvents → processDmCallStartEvent', () => { + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + + const cm = await importManager(); + for (const [fedId] of cm.getAllFederatedCalls()) cm.clearFederatedCall(fedId); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sqlite.close(); + }); + + it('Path B: zero matches → undeliverable, no FederatedCallEntry created', async () => { + // Arrange: no local DM, no local user matching the participant list + const { processRelayEvents } = await importSUT(); + const cm = await importManager(); + + const federatedId = 'fed-call-pathB-empty'; + const event = { + eventType: 'dm_call_start' as const, + messageId: 'msg-1', + encryptionVersion: 0 as const, + timestamp: Date.now(), + federatedId, + call: { + livekitUrl: 'wss://lk.example', + tokens: { 'caller-home': 'tok-c', 'unknown-home': 'tok-u' }, + caller: { + homeUserId: 'caller-home', + homeInstance: 'https://remote.example', + displayName: 'Caller', + }, + participants: [ + { homeUserId: 'caller-home', homeInstance: 'https://remote.example', displayName: 'Caller' }, + { homeUserId: 'unknown-home', homeInstance: 'https://remote.example', displayName: 'Unknown' }, + ], + }, + }; + + // Act + const result = await processRelayEvents([event], 'https://remote.example', 'https://remote.example', testDb); + + // Assert + expect(result.accepted).toEqual([]); + expect(result.rejected).toEqual([]); + expect(result.undeliverable).toEqual([ + { messageId: 'msg-1', reason: 'no_recipient' }, + ]); + expect(cm.getFederatedCall(federatedId)).toBeUndefined(); + }); +}); diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index f2c450c3..499500a6 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -4402,8 +4402,11 @@ function processDmCallStartEvent( } if (ringedUserIds.length === 0) { - // No connected users found — silently accept (not an error) - accepted.push(event.messageId); + // No recipient reachable — signal to caller via third ack bucket (#18). + // The remote processed the event cleanly; this is not a data error, but + // the caller must learn that nobody was rung so it can tear down its + // local ring room instead of hanging 60s waiting for an accept. + undeliverable.push({ messageId: event.messageId, reason: 'no_recipient' }); return; } From 7533d8ca14718eee49984599de8765564182ac75 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 24 Apr 2026 21:01:19 +0200 Subject: [PATCH 04/11] feat(server): Path A connection gate + undeliverable on zero ringee (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit processDmCallStartEvent Path A now skips offline local members (matching Path B's pre-existing per-member check) and pushes undeliverable when no member could be rung, instead of creating a stranded FederatedCallEntry. TDD — two new tests cover zero-online and mixed-online cases. --- .../src/routes/federation.callStart.test.ts | 140 ++++++++++++++++++ packages/server/src/routes/federation.ts | 13 ++ 2 files changed, 153 insertions(+) diff --git a/packages/server/src/routes/federation.callStart.test.ts b/packages/server/src/routes/federation.callStart.test.ts index 80e2b080..9ba88114 100644 --- a/packages/server/src/routes/federation.callStart.test.ts +++ b/packages/server/src/routes/federation.callStart.test.ts @@ -4,6 +4,7 @@ import { drizzle } from 'drizzle-orm/better-sqlite3'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import type WebSocket from 'ws'; import * as schema from '../db/schema.js'; import { setWorkerId } from '../utils/snowflake.js'; @@ -52,6 +53,16 @@ async function importManager() { let sqlite: Database.Database; +/** Insert a minimal native user row so dmMembers FK constraints pass. */ +function seedUser(id: string): void { + testDb.insert(schema.users).values({ + id, + username: id, + passwordHash: '!test', + createdAt: Date.now(), + }).run(); +} + describe('processRelayEvents → processDmCallStartEvent', () => { beforeEach(async () => { sqlite = new Database(':memory:'); @@ -105,4 +116,133 @@ describe('processRelayEvents → processDmCallStartEvent', () => { ]); expect(cm.getFederatedCall(federatedId)).toBeUndefined(); }); + + it('Path A: zero connected local members → undeliverable, no entry created', async () => { + const { processRelayEvents } = await importSUT(); + const cm = await importManager(); + + // Arrange: local DM channel with federated_id matches; two local members, both offline + const federatedId = 'fed-call-pathA-all-offline'; + const dmChannelId = 'dm-1'; + seedUser('bob-local'); + seedUser('alice-local'); + testDb.insert(schema.dmChannels).values({ + id: dmChannelId, + ownerId: null, + federatedId, + createdAt: Date.now(), + }).run(); + testDb.insert(schema.dmMembers).values([ + { dmChannelId, userId: 'bob-local' }, + { dmChannelId, userId: 'alice-local' }, + ]).run(); + + // Stub: both users offline (zero WS connections) + vi.spyOn(cm, 'getUserConnections').mockImplementation(() => new Set()); + + const event = { + eventType: 'dm_call_start' as const, + messageId: 'msg-2', + encryptionVersion: 0 as const, + timestamp: Date.now(), + federatedId, + call: { + livekitUrl: 'wss://lk.example', + tokens: { + 'caller-home': 'tok-c', + 'bob-local': 'tok-b', + 'alice-local': 'tok-a', + }, + caller: { + homeUserId: 'caller-home', + homeInstance: 'https://remote.example', + displayName: 'Caller', + }, + participants: [], + }, + }; + + const sendToUserSpy = vi.spyOn(cm, 'sendToUser'); + + const result = await processRelayEvents([event], 'https://remote.example', 'https://remote.example', testDb); + + expect(result.accepted).toEqual([]); + expect(result.undeliverable).toEqual([{ messageId: 'msg-2', reason: 'no_recipient' }]); + // No dm_call_incoming dispatched to anyone + expect(sendToUserSpy).not.toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ type: 'dm_call_incoming' }), + ); + // No FederatedCallEntry + expect(cm.getFederatedCall(federatedId)).toBeUndefined(); + }); + + it('Path A: mixed online + offline rings only connected members', async () => { + const { processRelayEvents } = await importSUT(); + const cm = await importManager(); + + const federatedId = 'fed-call-pathA-mixed'; + const dmChannelId = 'dm-2'; + seedUser('bob-local'); + seedUser('carol-local'); + testDb.insert(schema.dmChannels).values({ + id: dmChannelId, + ownerId: null, + federatedId, + createdAt: Date.now(), + }).run(); + testDb.insert(schema.dmMembers).values([ + { dmChannelId, userId: 'bob-local' }, + { dmChannelId, userId: 'carol-local' }, + ]).run(); + + // Bob online, Carol offline + vi.spyOn(cm, 'getUserConnections').mockImplementation((uid: string) => + uid === 'bob-local' ? new Set(['fakews' as unknown as WebSocket]) : new Set(), + ); + + const sendToUserSpy = vi.spyOn(cm, 'sendToUser'); + + const event = { + eventType: 'dm_call_start' as const, + messageId: 'msg-3', + encryptionVersion: 0 as const, + timestamp: Date.now(), + federatedId, + call: { + livekitUrl: 'wss://lk.example', + tokens: { + 'caller-home': 'tok-c', + 'bob-local': 'tok-b', + 'carol-local': 'tok-car', + }, + caller: { + homeUserId: 'caller-home', + homeInstance: 'https://remote.example', + displayName: 'Caller', + }, + participants: [], + }, + }; + + const result = await processRelayEvents([event], 'https://remote.example', 'https://remote.example', testDb); + + expect(result.accepted).toEqual(['msg-3']); + expect(result.undeliverable).toEqual([]); + + // Bob was rung + expect(sendToUserSpy).toHaveBeenCalledWith( + 'bob-local', + expect.objectContaining({ type: 'dm_call_incoming', livekitToken: 'tok-b' }), + ); + // Carol was NOT rung (offline) + expect(sendToUserSpy).not.toHaveBeenCalledWith( + 'carol-local', + expect.anything(), + ); + + const entry = cm.getFederatedCall(federatedId); + expect(entry).toBeDefined(); + expect(entry!.ringedUserIds).toEqual(['bob-local']); + }); }); diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 499500a6..b14a9121 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -4312,6 +4312,11 @@ function processDmCallStartEvent( // Bug 1 fix: don't ring the caller on this instance if (homeUserId === event.call.caller.homeUserId) continue; + // #18: skip offline members. Entry-vs-no-entry decision uses the same + // connection-count signal Path B has always used — keeps the two paths + // symmetric in what counts as "ringed." + if (connectionManager.getUserConnections(member.userId).size === 0) continue; + const token = event.call!.tokens![homeUserId]; connectionManager.sendToUser(member.userId, { type: 'dm_call_incoming', @@ -4326,6 +4331,14 @@ function processDmCallStartEvent( ringedUserIds.push(member.userId); } + if (ringedUserIds.length === 0) { + // #18: no local member was reachable. Do not create a FederatedCallEntry + // (it would strand with no accept/reject path); surface to the caller + // via undeliverable so it can tear down its ring room instead of hanging. + undeliverable.push({ messageId: event.messageId, reason: 'no_recipient' }); + return; + } + const entry: FederatedCallEntry = { dmChannelId: localDmChannelId, federatedId: event.federatedId, From 7d2137b6d454f5936c67b0246be8899d46b704fc Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 24 Apr 2026 21:05:28 +0200 Subject: [PATCH 05/11] feat(server): sendCallRelay surfaces undeliverable messageIds (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CallRelayResult success arm gains undeliverable: string[]. sendCallRelay parses FederationRelayResponse.undeliverable (when present) and returns the messageIds so sendFederatedCallStart can reclassify per-peer results. Old peers that omit the field → empty array → today's behavior. TDD — three tests cover old-peer, new-peer-with-undeliverable, and 5xx paths. --- packages/server/src/utils/federationOutbox.ts | 23 ++- .../federationOutbox.undeliverable.test.ts | 133 ++++++++++++++++++ .../server/src/ws/events.dmCallRelay.test.ts | 6 +- 3 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 packages/server/src/utils/federationOutbox.undeliverable.test.ts 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 }, From 26a4925032cd15d5fc5d60c29510a6b17498aec9 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 24 Apr 2026 21:11:11 +0200 Subject: [PATCH 06/11] feat(server): reclassify undeliverable targeted-peer as no_recipient failure (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sendFederatedCallStart now treats a 200-with-undeliverable-messageId as a peer-failure instead of unconditional success. Feeds the existing failures[] array and terminal-determination machinery from #16. New sendFederatedCallStartForTest export mirrors the existing handleDm*ForTest pattern. TDD — three tests cover single-peer terminal no_recipient, group-DM mixed delivered+undeliverable non-terminal, and the happy-path (empty undeliverable → no event). Also hardens sendCallRelay's response parse: validates undeliverable is an Array and entries are well-shaped, logs protocol drift at warn/debug rather than silently falling back to old-peer semantics. --- packages/server/src/utils/federationOutbox.ts | 16 +- .../ws/events.callStartUndeliverable.test.ts | 259 ++++++++++++++++++ packages/server/src/ws/events.ts | 17 +- 3 files changed, 287 insertions(+), 5 deletions(-) create mode 100644 packages/server/src/ws/events.callStartUndeliverable.test.ts diff --git a/packages/server/src/utils/federationOutbox.ts b/packages/server/src/utils/federationOutbox.ts index ea5ca260..b078f438 100644 --- a/packages/server/src/utils/federationOutbox.ts +++ b/packages/server/src/utils/federationOutbox.ts @@ -644,10 +644,18 @@ export async function sendCallRelay( // 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. + const responseBody = (await res.json()) as { undeliverable?: unknown }; + if (Array.isArray(responseBody.undeliverable)) { + undeliverable = responseBody.undeliverable + .filter((u): u is { messageId: string } => + typeof u === 'object' && u !== null && typeof (u as { messageId?: unknown }).messageId === 'string', + ) + .map(u => u.messageId); + } else if (responseBody.undeliverable !== undefined) { + console.warn('[federation] sendCallRelay: peer returned non-array undeliverable, ignoring:', targetPeerOrigin); + } + } catch (err) { + console.debug('[federation] sendCallRelay: response body unparseable, treating as old-format:', targetPeerOrigin, err); } return { ok: true, undeliverable }; } diff --git a/packages/server/src/ws/events.callStartUndeliverable.test.ts b/packages/server/src/ws/events.callStartUndeliverable.test.ts new file mode 100644 index 00000000..4293a7c6 --- /dev/null +++ b/packages/server/src/ws/events.callStartUndeliverable.test.ts @@ -0,0 +1,259 @@ +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'; +import { setWorkerId } from '../utils/snowflake.js'; + +setWorkerId(1); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +type TestDb = ReturnType>; +let testDb: TestDb; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + schema, +})); + +vi.mock('../utils/federationAuth.js', async () => { + const actual = await vi.importActual( + '../utils/federationAuth.js', + ); + return { + ...actual, + getOurOrigin: () => 'https://local.example', + buildFederationHeaders: () => ({}), + generateFederatedCallToken: () => Promise.resolve('fake-token'), + }; +}); + +// Mock sendCallRelay so the test controls relay results per peer. The +// implementation captures the messageId each call was made with so tests +// can return { undeliverable: [messageId] } dynamically. +type RelayArgs = [string, Array<{ messageId: string }>]; +const sendCallRelayMock = vi.fn(); +vi.mock('../utils/federationOutbox.js', async () => { + const actual = await vi.importActual( + '../utils/federationOutbox.js', + ); + return { + ...actual, + sendCallRelay: (...args: RelayArgs) => sendCallRelayMock(...args), + }; +}); + +// Mock config to claim LiveKit is configured. +vi.mock('../config.js', async () => { + const actual = await vi.importActual('../config.js'); + return { + ...actual, + config: { + ...actual.config, + domain: 'local.example', + livekit: { + url: 'wss://local.example/livekit', + apiKey: 'key', + apiSecret: 'secret', + }, + }, + }; +}); + +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, instanceName: string): void { + testDb.insert(schema.federationPeers).values({ + id: `peer-${origin}`, + origin, + hmacSecret: 'secret', + status: 'active', + instanceName, + lastSyncedAt: 0, + createdAt: Date.now(), + }).run(); +} + +function seedLocalUser(id: string, opts: { homeUserId?: string | null; homeInstance?: string | null } = {}): void { + testDb.insert(schema.users).values({ + id, + username: id, + passwordHash: 'test', + homeUserId: opts.homeUserId ?? null, + homeInstance: opts.homeInstance ?? null, + createdAt: Date.now(), + }).run(); +} + +function seedDmChannel(id: string, federatedId: string, ownerId: string | null): void { + testDb.insert(schema.dmChannels).values({ + id, + ownerId, + federatedId, + createdAt: Date.now(), + }).run(); +} + +function seedDmMember(dmChannelId: string, userId: string): void { + testDb.insert(schema.dmMembers).values({ dmChannelId, userId }).run(); +} + +async function importSUT() { + return await import('./events.js'); +} + +async function importManager() { + return (await import('./handler.js')).connectionManager; +} + +let sqlite: Database.Database; + +describe('sendFederatedCallStart — undeliverable reclassification (#18)', () => { + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + + const cm = await importManager(); + // Reset federatedCalls + rooms between tests. + for (const [fedId] of cm.getAllFederatedCalls()) cm.clearFederatedCall(fedId); + sendCallRelayMock.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sqlite.close(); + }); + + it('single targeted peer returns undeliverable → terminal dm_call_undeliverable, room destroyed', async () => { + // 1-on-1 DM: Alice local, Bob remote on orbit. + const federatedId = 'fed-1on1'; + seedLocalUser('alice', { homeUserId: null, homeInstance: null }); + seedLocalUser('bob-stub', { homeUserId: 'bob-home', homeInstance: 'https://orbit.example' }); + seedDmChannel('dm-1', federatedId, null); + seedDmMember('dm-1', 'alice'); + seedDmMember('dm-1', 'bob-stub'); + seedActivePeer('https://orbit.example', 'Orbit'); + + const cm = await importManager(); + cm.createDmRoom('dm-1', 'alice'); // caller's local ring room (mirrors real flow) + + // Capture the messageId sendFederatedCallStart generates, return it as undeliverable. + sendCallRelayMock.mockImplementation(async (_origin: string, events: Array<{ messageId: string }>) => { + return { ok: true, undeliverable: [events[0]!.messageId] }; + }); + + const sendToUserSpy = vi.spyOn(cm, 'sendToUser'); + const destroyRoomSpy = vi.spyOn(cm, 'destroyRoom'); + + const { sendFederatedCallStartForTest } = await importSUT(); + await sendFederatedCallStartForTest('dm-1', 'alice', 'Alice'); + + // The caller (Alice) got a terminal dm_call_undeliverable with reason='no_recipient'. + const undelivCalls = sendToUserSpy.mock.calls.filter(([, ev]) => + (ev as { type: string }).type === 'dm_call_undeliverable', + ); + expect(undelivCalls).toHaveLength(1); + expect(undelivCalls[0]![0]).toBe('alice'); + + const ev = undelivCalls[0]![1] as { + terminal: boolean; + phase: string; + failures: Array<{ reason: string; peerLabel?: string; peerOrigin?: string }>; + }; + expect(ev.terminal).toBe(true); + expect(ev.phase).toBe('start'); + expect(ev.failures).toHaveLength(1); + expect(ev.failures[0]!.reason).toBe('no_recipient'); + expect(ev.failures[0]!.peerOrigin).toBe('https://orbit.example'); + expect(ev.failures[0]!.peerLabel).toBe('Orbit'); + + // Room was destroyed. + expect(destroyRoomSpy).toHaveBeenCalledWith('dm-1'); + }); + + it('group DM mixed delivered + undeliverable → non-terminal, failures lists only the undeliverable peer', async () => { + // Group DM: caller + one member on orbit (delivers) + one member on nova (undeliverable). + const federatedId = 'fed-group'; + seedLocalUser('alice', { homeUserId: null, homeInstance: null }); + seedLocalUser('bob-stub', { homeUserId: 'bob-home', homeInstance: 'https://orbit.example' }); + seedLocalUser('carol-stub', { homeUserId: 'carol-home', homeInstance: 'https://nova.example' }); + seedDmChannel('dm-group', federatedId, 'alice'); // group DM: ownerId non-null + seedDmMember('dm-group', 'alice'); + seedDmMember('dm-group', 'bob-stub'); + seedDmMember('dm-group', 'carol-stub'); + seedActivePeer('https://orbit.example', 'Orbit'); + seedActivePeer('https://nova.example', 'Nova'); + + const cm = await importManager(); + cm.createDmRoom('dm-group', 'alice'); + + // Orbit delivers (empty undeliverable), Nova returns messageId in undeliverable. + sendCallRelayMock.mockImplementation(async (origin: string, events: Array<{ messageId: string }>) => { + if (origin === 'https://nova.example') { + return { ok: true, undeliverable: [events[0]!.messageId] }; + } + return { ok: true, undeliverable: [] }; + }); + + const sendToUserSpy = vi.spyOn(cm, 'sendToUser'); + const destroyRoomSpy = vi.spyOn(cm, 'destroyRoom'); + + const { sendFederatedCallStartForTest } = await importSUT(); + await sendFederatedCallStartForTest('dm-group', 'alice', 'Alice'); + + // Room NOT destroyed (orbit delivered). + expect(destroyRoomSpy).not.toHaveBeenCalledWith('dm-group'); + + const undelivCalls = sendToUserSpy.mock.calls.filter(([uid, ev]) => + uid === 'alice' && (ev as { type: string }).type === 'dm_call_undeliverable', + ); + expect(undelivCalls).toHaveLength(1); + + const ev = undelivCalls[0]![1] as { + terminal: boolean; + failures: Array<{ reason: string; peerOrigin?: string }>; + }; + expect(ev.terminal).toBe(false); + expect(ev.failures).toHaveLength(1); + expect(ev.failures[0]!.reason).toBe('no_recipient'); + expect(ev.failures[0]!.peerOrigin).toBe('https://nova.example'); + }); + + it('single targeted peer delivers (empty undeliverable) → no undeliverable event', async () => { + const federatedId = 'fed-happy'; + seedLocalUser('alice', {}); + seedLocalUser('bob-stub', { homeUserId: 'bob-home', homeInstance: 'https://orbit.example' }); + seedDmChannel('dm-happy', federatedId, null); + seedDmMember('dm-happy', 'alice'); + seedDmMember('dm-happy', 'bob-stub'); + seedActivePeer('https://orbit.example', 'Orbit'); + + const cm = await importManager(); + cm.createDmRoom('dm-happy', 'alice'); + + sendCallRelayMock.mockResolvedValue({ ok: true, undeliverable: [] }); + + const sendToUserSpy = vi.spyOn(cm, 'sendToUser'); + const { sendFederatedCallStartForTest } = await importSUT(); + await sendFederatedCallStartForTest('dm-happy', 'alice', 'Alice'); + + const undelivCalls = sendToUserSpy.mock.calls.filter(([, ev]) => + (ev as { type: string }).type === 'dm_call_undeliverable', + ); + expect(undelivCalls).toHaveLength(0); + }); +}); diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index 61955143..6aaa859c 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -1925,10 +1925,24 @@ async function sendFederatedCallStart( } // ─── Targeted relay: fan out in parallel, await results ──────────────────── + // Each peer's result has THREE possible classifications: + // ok=true, messageId NOT in undeliverable → delivered + // ok=true, messageId IN undeliverable → new: no_recipient failure (#18) + // ok=false → existing failure reasons const targetedResults = await Promise.all( Array.from(targetedPeers.keys()).map(async peerOrigin => { - const result = await sendCallRelay(peerOrigin, [buildRelayEvent()]); + const relayEvent = buildRelayEvent(); + const result = await sendCallRelay(peerOrigin, [relayEvent]); if (result.ok) { + if (result.undeliverable.includes(relayEvent.messageId)) { + console.warn(`[federation] dm_call_start to ${peerOrigin}: remote had no recipient`); + return { + origin: peerOrigin, + ok: false as const, + reason: 'no_recipient' as const satisfies DmCallUndeliverableReason, + error: 'remote reported no_recipient', + }; + } return { origin: peerOrigin, ok: true as const }; } const reason = mapCallReasonToEventReason(result.reason); @@ -2483,3 +2497,4 @@ export function registerCallRelayHooks(): void { export const handleDmCallAcceptForTest = handleDmCallAccept; export const handleDmCallRejectForTest = handleDmCallReject; export const handleDmCallEndForTest = handleDmCallEnd; +export const sendFederatedCallStartForTest = sendFederatedCallStart; From b16ece93b86029b2adea69bb6e1d754fb8335a92 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 24 Apr 2026 21:15:21 +0200 Subject: [PATCH 07/11] feat(web): no_recipient toast copy arm (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildCallUndeliverableToast renders "{peerLabel} couldn't ring anyone." for the single-failure terminal case; multi-failure + non-terminal paths fall through to existing lines (which already fold the new reason in by peer label). TDD — four new assertions. --- .../useWebSocket.callUndeliverable.test.ts | 36 +++++++++++++++++++ .../web/src/utils/callUndeliverableToast.ts | 2 ++ 2 files changed, 38 insertions(+) diff --git a/packages/web/src/hooks/__tests__/useWebSocket.callUndeliverable.test.ts b/packages/web/src/hooks/__tests__/useWebSocket.callUndeliverable.test.ts index 2a3c9840..f6ef623e 100644 --- a/packages/web/src/hooks/__tests__/useWebSocket.callUndeliverable.test.ts +++ b/packages/web/src/hooks/__tests__/useWebSocket.callUndeliverable.test.ts @@ -54,4 +54,40 @@ describe('buildCallUndeliverableToast', () => { expect(msg.toLowerCase()).toContain('orbit'); expect(msg.toLowerCase()).toContain('peered'); }); + + it('renders no_recipient single-failure terminal copy', () => { + const fail = (peerLabel = 'Orbit') => ({ + reason: 'no_recipient', + peerOrigin: 'https://orbit.local', + peerLabel, + }); + expect(buildCallUndeliverableToast([fail()], true, 'start')) + .toBe("Orbit couldn't ring anyone."); + }); + + it('no_recipient falls back to origin when peerLabel missing', () => { + const fail = { + reason: 'no_recipient', + peerOrigin: 'https://orbit.local', + }; + expect(buildCallUndeliverableToast([fail], true, 'start')) + .toMatch(/orbit\.local couldn't ring anyone\./); + }); + + it('no_recipient in a multi-failure terminal falls back to multi-instance copy', () => { + const failures = [ + { reason: 'no_recipient', peerOrigin: 'https://orbit.local', peerLabel: 'Orbit' }, + { reason: 'peer_transient_failure', peerOrigin: 'https://nova.local', peerLabel: 'Nova' }, + ]; + expect(buildCallUndeliverableToast(failures, true, 'start')) + .toMatch(/Could not reach 2 instances: Orbit, Nova/); + }); + + it('no_recipient non-terminal uses the existing "Some participants" line', () => { + const failures = [ + { reason: 'no_recipient', peerOrigin: 'https://orbit.local', peerLabel: 'Orbit' }, + ]; + expect(buildCallUndeliverableToast(failures, false, 'start')) + .toMatch(/Some participants could not be reached: Orbit/); + }); }); diff --git a/packages/web/src/utils/callUndeliverableToast.ts b/packages/web/src/utils/callUndeliverableToast.ts index dd4aeefc..47060e78 100644 --- a/packages/web/src/utils/callUndeliverableToast.ts +++ b/packages/web/src/utils/callUndeliverableToast.ts @@ -76,6 +76,8 @@ export function buildCallUndeliverableToast( return `Could not reach ${label}. Try again in a moment.`; case 'livekit_unavailable': return 'Voice is not configured on this instance.'; + case 'no_recipient': + return `${label} couldn't ring anyone.`; default: return `Call to ${label} could not be placed.`; } From 6edf02cb33d88d24074b4cad6a84eb73d8fcb3e3 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 24 Apr 2026 21:19:34 +0200 Subject: [PATCH 08/11] docs(systems): document three-way ack classification + no_recipient (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit federation.md — new undeliverable bucket subsection with three-way classification table, Path A/B semantics, and wire backward-compat note. voice.md — no_recipient row in failure-surface table. websocket.md — DmCallUndeliverableReason union updated to include no_recipient. dm-system.md — cross-reference to voice.md for no_recipient reason. --- docs/systems/dm-system.md | 1 + docs/systems/federation.md | 32 ++++++++++++++++++++++++++++++++ docs/systems/voice.md | 1 + docs/systems/websocket.md | 2 +- 4 files changed, 35 insertions(+), 1 deletion(-) diff --git a/docs/systems/dm-system.md b/docs/systems/dm-system.md index 710d015b..fc53b79a 100644 --- a/docs/systems/dm-system.md +++ b/docs/systems/dm-system.md @@ -284,6 +284,7 @@ If a `member_add` federation event arrives for a soft-deleted channel (non-null **Request:** `{ content?: string, attachments?: string[], replyToId?: string }` **Cross-instance access:** Federated users (those with `homeInstance` set) can send messages on any DM channel where they are a member, regardless of which instance serves the request. The `requireLocalUser` gate that previously blocked federated users from DM write endpoints has been removed. DM calls work across federated instances. The caller's instance hosts the LiveKit room; remote clients connect directly. Call signaling is relayed to all active federation peers via synchronous HTTP POST (not the outbox worker). Relay failures at any call state transition emit `dm_call_undeliverable { phase, terminal, failures }` to the originator — see `docs/systems/voice.md` for the full call state machine and failure surface. +- Federated call-start to a remote instance with no reachable recipient surfaces as `dm_call_undeliverable` with reason `no_recipient` — see `voice.md` for the full failure-surface table. **Validation:** - Caller must be a member (`isDmMember`) diff --git a/docs/systems/federation.md b/docs/systems/federation.md index 8e69dc62..9f817d57 100644 --- a/docs/systems/federation.md +++ b/docs/systems/federation.md @@ -529,10 +529,42 @@ interface FederationRelayResponse { messageId: string; reason: string; // e.g., 'duplicate', 'unknown_message', 'missing_participants' }>; + undeliverable?: Array<{ // optional — omitted when empty; call-signaling only + messageId: string; + reason: string; // e.g., 'no_recipient' + }>; maxUploadSize: number; // This instance's max upload size in bytes } ``` +#### `undeliverable` bucket (call-signaling only) + +In addition to `accepted` and `rejected`, the relay response may include an +optional `undeliverable: Array<{messageId, reason}>`. Three-way classification, +non-overlapping: each messageId appears in exactly one of the three arrays. + +| Bucket | Meaning | Retry? | +|---|---|---| +| `accepted` | Processed cleanly, ≥1 recipient reached. | No | +| `rejected` | Refused at data/protocol layer (schema, attribution, channel-not-found, etc.). | Terminal. | +| `undeliverable` | Processed cleanly, zero recipients reachable. | No — call-signaling specific. | + +Currently used only for `dm_call_start`: +- **Path A** (local DM exists): if no local non-caller member has an active WS + connection, the event is pushed to `undeliverable` with reason `no_recipient` + instead of being silently accepted. No `FederatedCallEntry` is created. +- **Path B** (no local DM): the zero-participant-match early return pushes to + `undeliverable` rather than `accepted`. + +Other event types (messages, reactions, friend events, profile updates, etc.) +keep existing semantics — a message to an offline user is still `accepted`, since +messages persist and re-deliver on reconnect. + +The field is optional on the wire. Old peers omit it; new peers include it only +when non-empty. Caller-side `sendCallRelay` parses the field (defaulting to an +empty array when missing), so upgrade skew is a no-op until both sides are on +new code. + ### Inbound Relay Dispatch (`POST /api/federation/relay`) Body limit: 10 MB. Max 50 events per batch. Rate-limited to 90 requests/min per peer (sliding window, keyed by `peer.origin`). Returns 429 when exceeded. Raised from 30 after FED-009 reduced the outbox worker interval from 10s to 1s — a busy sender can now hit 60 req/min during sustained traffic. diff --git a/docs/systems/voice.md b/docs/systems/voice.md index 7d396a7c..8ee143b5 100644 --- a/docs/systems/voice.md +++ b/docs/systems/voice.md @@ -71,6 +71,7 @@ All `dm_call_*` signaling events (`start`, `accept`, `reject`, `end`) are relaye | `reject` | false | Rejector's relay to host failed OR host's fan-out after a local reject failed; state already cleared. | No state change; info toast. | | `end` | false | Ender's relay to host failed OR host's fan-out after a local end failed; state already cleared. | No state change; info toast. | | `host_unreachable` | true | A FederatedCallEntry's `federatedCallHost` peer transitions out of `active`, OR the 30s sentinel detects a non-active host for an existing entry. | Clear `activeDmCall` + `incomingCall`, disconnect LK, warning toast (*"Call ended — {label} became unreachable."*). | +| `no_recipient` | true | Remote returned 200 but had no reachable recipient (Path A: all members offline; Path B: zero participant matches). Caller fast-fails within the relay round-trip; ring room destroyed. | Clear `outgoingCall`, disconnect LK, warning toast (*"{peerLabel} couldn't ring anyone."*). Folds into multi-failure info copy when not the sole failure. | **Accept-rollback semantics.** `handleDmCallAccept` Path 2 transitions the `FederatedCallEntry` to active and broadcasts `dm_call_accepted` optimistically so the acceptor's UI flips immediately. If the B→host relay fails, the server clears the entry, fans `dm_call_undeliverable { phase: 'accept', terminal: true }` out to all ringed users on B (via `sendToFederatedCallUsers`), and the client tears its call state back down. diff --git a/docs/systems/websocket.md b/docs/systems/websocket.md index 0d40582f..b3e7ca13 100644 --- a/docs/systems/websocket.md +++ b/docs/systems/websocket.md @@ -170,7 +170,7 @@ reason: `'displaced'` (new tab) | `'session_closed'` | `dm_call_accepted` | dmChannelId?, federatedCallId? | DM members | | `dm_call_rejected` | dmChannelId?, federatedCallId? | DM members | | `dm_call_ended` | dmChannelId?, federatedCallId? | DM members | -| `dm_call_undeliverable` | Sent to the originator when a call relay (start / accept / reject / end) to one or more peers fails. Includes `phase: 'start' \| 'accept' \| 'reject' \| 'end'` identifying the action; `failures[]` enumerates failed peers with a `reason` (`peer_rejected` / `peer_awaiting_approval` / `peer_transient_failure` / `livekit_unavailable`). `terminal: true` means local call state should be (or has been) torn down; `terminal: false` is informational. See `docs/systems/voice.md` for the full phase × terminal matrix. | originator (caller / acceptor / rejector / ender) | +| `dm_call_undeliverable` | Sent to the originator when a call relay (start / accept / reject / end) to one or more peers fails. Includes `phase: 'start' \| 'accept' \| 'reject' \| 'end'` identifying the action; `failures[]` enumerates failed peers with a `reason` (`peer_rejected` / `peer_awaiting_approval` / `peer_transient_failure` / `livekit_unavailable` / `no_recipient`). `terminal: true` means local call state should be (or has been) torn down; `terminal: false` is informational. See `docs/systems/voice.md` for the full phase × terminal matrix. | originator (caller / acceptor / rejector / ender) | ### Social | type | fields | scope | From 34622a42909e400f6052e7c1d7945a21c818afbc Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 24 Apr 2026 21:22:21 +0200 Subject: [PATCH 09/11] =?UTF-8?q?docs(systems):=20#18=20review=20followups?= =?UTF-8?q?=20=E2=80=94=20bullet=20formatting=20+=20host=5Funreachable=20p?= =?UTF-8?q?hase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dm-system.md: fold the voice.md cross-reference into the paragraph so it renders as part of the Cross-instance access explanation instead of an orphaned bullet. websocket.md: add 'host_unreachable' to the dm_call_undeliverable phase union — stale since #32 was merged (docs drift noted in Task 8 review). --- docs/systems/dm-system.md | 3 +-- docs/systems/websocket.md | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/systems/dm-system.md b/docs/systems/dm-system.md index fc53b79a..ac9fda2c 100644 --- a/docs/systems/dm-system.md +++ b/docs/systems/dm-system.md @@ -283,8 +283,7 @@ If a `member_add` federation event arrives for a soft-deleted channel (non-null **Request:** `{ content?: string, attachments?: string[], replyToId?: string }` -**Cross-instance access:** Federated users (those with `homeInstance` set) can send messages on any DM channel where they are a member, regardless of which instance serves the request. The `requireLocalUser` gate that previously blocked federated users from DM write endpoints has been removed. DM calls work across federated instances. The caller's instance hosts the LiveKit room; remote clients connect directly. Call signaling is relayed to all active federation peers via synchronous HTTP POST (not the outbox worker). Relay failures at any call state transition emit `dm_call_undeliverable { phase, terminal, failures }` to the originator — see `docs/systems/voice.md` for the full call state machine and failure surface. -- Federated call-start to a remote instance with no reachable recipient surfaces as `dm_call_undeliverable` with reason `no_recipient` — see `voice.md` for the full failure-surface table. +**Cross-instance access:** Federated users (those with `homeInstance` set) can send messages on any DM channel where they are a member, regardless of which instance serves the request. The `requireLocalUser` gate that previously blocked federated users from DM write endpoints has been removed. DM calls work across federated instances. The caller's instance hosts the LiveKit room; remote clients connect directly. Call signaling is relayed to all active federation peers via synchronous HTTP POST (not the outbox worker). Relay failures at any call state transition emit `dm_call_undeliverable { phase, terminal, failures }` to the originator — see `docs/systems/voice.md` for the full call state machine and failure surface. Federated call-start to a remote instance with no reachable recipient surfaces as `dm_call_undeliverable` with reason `no_recipient` — see `voice.md` for the full failure-surface table. **Validation:** - Caller must be a member (`isDmMember`) diff --git a/docs/systems/websocket.md b/docs/systems/websocket.md index b3e7ca13..51dee891 100644 --- a/docs/systems/websocket.md +++ b/docs/systems/websocket.md @@ -170,7 +170,7 @@ reason: `'displaced'` (new tab) | `'session_closed'` | `dm_call_accepted` | dmChannelId?, federatedCallId? | DM members | | `dm_call_rejected` | dmChannelId?, federatedCallId? | DM members | | `dm_call_ended` | dmChannelId?, federatedCallId? | DM members | -| `dm_call_undeliverable` | Sent to the originator when a call relay (start / accept / reject / end) to one or more peers fails. Includes `phase: 'start' \| 'accept' \| 'reject' \| 'end'` identifying the action; `failures[]` enumerates failed peers with a `reason` (`peer_rejected` / `peer_awaiting_approval` / `peer_transient_failure` / `livekit_unavailable` / `no_recipient`). `terminal: true` means local call state should be (or has been) torn down; `terminal: false` is informational. See `docs/systems/voice.md` for the full phase × terminal matrix. | originator (caller / acceptor / rejector / ender) | +| `dm_call_undeliverable` | Sent to the originator when a call relay (start / accept / reject / end) to one or more peers fails. Includes `phase: 'start' \| 'accept' \| 'reject' \| 'end' \| 'host_unreachable'` identifying the action; `failures[]` enumerates failed peers with a `reason` (`peer_rejected` / `peer_awaiting_approval` / `peer_transient_failure` / `livekit_unavailable` / `no_recipient`). `terminal: true` means local call state should be (or has been) torn down; `terminal: false` is informational. See `docs/systems/voice.md` for the full phase × terminal matrix. | originator (caller / acceptor / rejector / ender) | ### Social | type | fields | scope | From 2dbd2b9b9f24be14adb14bea82df43414ea0300c Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 24 Apr 2026 21:24:18 +0200 Subject: [PATCH 10/11] test(server): add undeliverable:[] to processRelayEvents mock (#18) Follow-up from Task 2 code review. Keeps the test mock aligned with the widened return type even though vi.mock doesn't structurally typecheck the factory. --- packages/server/src/utils/federationPeerActivation.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/utils/federationPeerActivation.test.ts b/packages/server/src/utils/federationPeerActivation.test.ts index f8127a37..2b5747c4 100644 --- a/packages/server/src/utils/federationPeerActivation.test.ts +++ b/packages/server/src/utils/federationPeerActivation.test.ts @@ -32,7 +32,7 @@ vi.mock('../utils/federationAuth.js', () => ({ })); vi.mock('../routes/federation.js', () => ({ - processRelayEvents: vi.fn().mockResolvedValue({ accepted: [], rejected: [] }), + processRelayEvents: vi.fn().mockResolvedValue({ accepted: [], rejected: [], undeliverable: [] }), })); vi.mock('../ws/handler.js', () => ({ From f6de72d556f863b8db1fe0929a29479005f6f431 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 24 Apr 2026 21:41:34 +0200 Subject: [PATCH 11/11] =?UTF-8?q?chore(verify):=20#18=20live=20Pi=E2=86=94?= =?UTF-8?q?VM=20scenarios=20A-D=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harness: /tmp/scenario18-harness.mjs (WS-level assertion, pattern matches #17's /tmp/call-test-harness.mjs). - A (Pi→VM logged-out callee, Path A zero-ringee): terminal dm_call_undeliverable reason=no_recipient peerOrigin=VM elapsed 212ms (budget 2s, pre-fix 60s) - B (VM→Pi logged-out callee, symmetric): elapsed 155ms, peerOrigin=Pi - C (Path B, DM deleted on VM): relay hits Path B after DB delete, Bob offline, elapsed 140ms, reason=no_recipient - D (group DM with online member, non-regression): VM accepted (Bob rung), no toast on caller Bob's dm_call_incoming arrived in 144ms All payloads correct: terminal:true, phase:'start', failures[0].reason 'no_recipient', correct peerOrigin. peerLabel empty because both instances' federation_peers.instance_name is NULL — pre-existing state, toast code already falls back to origin hostname (not a #18 concern). Cannot merge from agent per plan Task 10 Step 7 — coordinator's call.