diff --git a/docs/systems/dm-system.md b/docs/systems/dm-system.md index 710d015b..ac9fda2c 100644 --- a/docs/systems/dm-system.md +++ b/docs/systems/dm-system.md @@ -283,7 +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. +**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..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`). `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 | 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..9ba88114 --- /dev/null +++ b/packages/server/src/routes/federation.callStart.test.ts @@ -0,0 +1,248 @@ +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 type WebSocket from 'ws'; +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; + +/** 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:'); + 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(); + }); + + 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 6ded4e4f..b14a9121 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' }); @@ -4305,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', @@ -4319,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, @@ -4395,8 +4415,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; } diff --git a/packages/server/src/utils/federationOutbox.ts b/packages/server/src/utils/federationOutbox.ts index 57aad54d..b078f438 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,26 @@ 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 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 }; + } 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/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', () => ({ 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.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 }, 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; 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; } 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.`; }