Merge branch 'feat/remote-200-no-recipient'
This commit is contained in:
@@ -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 }`
|
**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:**
|
**Validation:**
|
||||||
- Caller must be a member (`isDmMember`)
|
- Caller must be a member (`isDmMember`)
|
||||||
|
|||||||
@@ -529,10 +529,42 @@ interface FederationRelayResponse {
|
|||||||
messageId: string;
|
messageId: string;
|
||||||
reason: string; // e.g., 'duplicate', 'unknown_message', 'missing_participants'
|
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
|
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`)
|
### 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.
|
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.
|
||||||
|
|||||||
@@ -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. |
|
| `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. |
|
| `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."*). |
|
| `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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ reason: `'displaced'` (new tab) | `'session_closed'`
|
|||||||
| `dm_call_accepted` | dmChannelId?, federatedCallId? | DM members |
|
| `dm_call_accepted` | dmChannelId?, federatedCallId? | DM members |
|
||||||
| `dm_call_rejected` | dmChannelId?, federatedCallId? | DM members |
|
| `dm_call_rejected` | dmChannelId?, federatedCallId? | DM members |
|
||||||
| `dm_call_ended` | 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
|
### Social
|
||||||
| type | fields | scope |
|
| type | fields | scope |
|
||||||
|
|||||||
@@ -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<typeof drizzle<typeof schema>>;
|
||||||
|
let testDb: TestDb;
|
||||||
|
|
||||||
|
vi.mock('../db/index.js', () => ({
|
||||||
|
getDb: () => testDb,
|
||||||
|
schema,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../utils/federationAuth.js', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('../utils/federationAuth.js')>(
|
||||||
|
'../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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1532,7 +1532,7 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Process each event
|
// 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
|
// 4. Update peer status
|
||||||
db.update(schema.federationPeers)
|
db.update(schema.federationPeers)
|
||||||
@@ -1555,6 +1555,7 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
accepted,
|
accepted,
|
||||||
rejected,
|
rejected,
|
||||||
maxUploadSize: settings?.maxUploadSizeBytes ?? config.maxUploadSize,
|
maxUploadSize: settings?.maxUploadSizeBytes ?? config.maxUploadSize,
|
||||||
|
...(undeliverable.length > 0 ? { undeliverable } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
return reply.code(200).send(response);
|
return reply.code(200).send(response);
|
||||||
@@ -2066,9 +2067,14 @@ export async function processRelayEvents(
|
|||||||
sourceInstance: string,
|
sourceInstance: string,
|
||||||
peerOrigin: string,
|
peerOrigin: string,
|
||||||
db: ReturnType<typeof getDb>,
|
db: ReturnType<typeof getDb>,
|
||||||
): 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 accepted: string[] = [];
|
||||||
const rejected: Array<{ messageId: string; reason: string }> = [];
|
const rejected: Array<{ messageId: string; reason: string }> = [];
|
||||||
|
const undeliverable: Array<{ messageId: string; reason: string }> = [];
|
||||||
|
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
try {
|
try {
|
||||||
@@ -2116,7 +2122,7 @@ export async function processRelayEvents(
|
|||||||
processFileRejectedEvent(event, sourceInstance, db, accepted, rejected);
|
processFileRejectedEvent(event, sourceInstance, db, accepted, rejected);
|
||||||
break;
|
break;
|
||||||
case 'dm_call_start':
|
case 'dm_call_start':
|
||||||
processDmCallStartEvent(event, sourceInstance, db, accepted, rejected);
|
processDmCallStartEvent(event, sourceInstance, db, accepted, rejected, undeliverable);
|
||||||
break;
|
break;
|
||||||
case 'dm_call_accept':
|
case 'dm_call_accept':
|
||||||
processDmCallAcceptEvent(event, sourceInstance, db, accepted, rejected);
|
processDmCallAcceptEvent(event, sourceInstance, db, accepted, rejected);
|
||||||
@@ -2156,7 +2162,7 @@ export async function processRelayEvents(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { accepted, rejected };
|
return { accepted, rejected, undeliverable };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
@@ -4253,6 +4259,7 @@ function processDmCallStartEvent(
|
|||||||
db: ReturnType<typeof getDb>,
|
db: ReturnType<typeof getDb>,
|
||||||
accepted: string[],
|
accepted: string[],
|
||||||
rejected: Array<{ messageId: string; reason: string }>,
|
rejected: Array<{ messageId: string; reason: string }>,
|
||||||
|
undeliverable: Array<{ messageId: string; reason: string }>,
|
||||||
): void {
|
): void {
|
||||||
if (!event.call?.caller || !event.call.livekitUrl || !event.call.tokens || !event.federatedId) {
|
if (!event.call?.caller || !event.call.livekitUrl || !event.call.tokens || !event.federatedId) {
|
||||||
rejected.push({ messageId: event.messageId, reason: 'missing_call_payload' });
|
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
|
// Bug 1 fix: don't ring the caller on this instance
|
||||||
if (homeUserId === event.call.caller.homeUserId) continue;
|
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];
|
const token = event.call!.tokens![homeUserId];
|
||||||
connectionManager.sendToUser(member.userId, {
|
connectionManager.sendToUser(member.userId, {
|
||||||
type: 'dm_call_incoming',
|
type: 'dm_call_incoming',
|
||||||
@@ -4319,6 +4331,14 @@ function processDmCallStartEvent(
|
|||||||
ringedUserIds.push(member.userId);
|
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 = {
|
const entry: FederatedCallEntry = {
|
||||||
dmChannelId: localDmChannelId,
|
dmChannelId: localDmChannelId,
|
||||||
federatedId: event.federatedId,
|
federatedId: event.federatedId,
|
||||||
@@ -4395,8 +4415,11 @@ function processDmCallStartEvent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (ringedUserIds.length === 0) {
|
if (ringedUserIds.length === 0) {
|
||||||
// No connected users found — silently accept (not an error)
|
// No recipient reachable — signal to caller via third ack bucket (#18).
|
||||||
accepted.push(event.messageId);
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -524,7 +524,15 @@ export type CallRelayFailureReason =
|
|||||||
| 'post_failed';
|
| 'post_failed';
|
||||||
|
|
||||||
export type CallRelayResult =
|
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 };
|
| { ok: false; reason: CallRelayFailureReason; error: string };
|
||||||
|
|
||||||
/** Per-peer failure record returned by Path-1 call fan-out helpers. */
|
/** 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),
|
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(() => '');
|
const text = await res.text().catch(() => '');
|
||||||
if (res.status >= 400 && res.status < 500) {
|
if (res.status >= 400 && res.status < 500) {
|
||||||
|
|||||||
@@ -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<typeof drizzle<typeof schema>>;
|
||||||
|
let testDb: TestDb;
|
||||||
|
|
||||||
|
vi.mock('../db/index.js', () => ({
|
||||||
|
getDb: () => testDb,
|
||||||
|
schema,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./federationAuth.js', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('./federationAuth.js')>('./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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,7 +32,7 @@ vi.mock('../utils/federationAuth.js', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../routes/federation.js', () => ({
|
vi.mock('../routes/federation.js', () => ({
|
||||||
processRelayEvents: vi.fn().mockResolvedValue({ accepted: [], rejected: [] }),
|
processRelayEvents: vi.fn().mockResolvedValue({ accepted: [], rejected: [], undeliverable: [] }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../ws/handler.js', () => ({
|
vi.mock('../ws/handler.js', () => ({
|
||||||
|
|||||||
@@ -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<typeof drizzle<typeof schema>>;
|
||||||
|
let testDb: TestDb;
|
||||||
|
|
||||||
|
vi.mock('../db/index.js', () => ({
|
||||||
|
getDb: () => testDb,
|
||||||
|
schema,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../utils/federationAuth.js', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('../utils/federationAuth.js')>(
|
||||||
|
'../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<typeof import('../utils/federationOutbox.js')>(
|
||||||
|
'../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<typeof import('../config.js')>('../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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -144,7 +144,7 @@ describe('handleDmCallEnd Path-2 relay failure', () => {
|
|||||||
const fedCall = makeFedCall({ state: 'active' });
|
const fedCall = makeFedCall({ state: 'active' });
|
||||||
connectionManager.createFederatedCall(fedCall);
|
connectionManager.createFederatedCall(fedCall);
|
||||||
const sendToUserSpy = vi.spyOn(connectionManager, 'sendToUser');
|
const sendToUserSpy = vi.spyOn(connectionManager, 'sendToUser');
|
||||||
sendCallRelayMock.mockResolvedValue({ ok: true });
|
sendCallRelayMock.mockResolvedValue({ ok: true, undeliverable: [] });
|
||||||
|
|
||||||
await handleDmCallEndForTest(
|
await handleDmCallEndForTest(
|
||||||
{ federatedCallId: fedCall.federatedId },
|
{ federatedCallId: fedCall.federatedId },
|
||||||
@@ -191,7 +191,7 @@ describe('handleDmCallReject Path-2 relay failure', () => {
|
|||||||
const fedCall = makeFedCall();
|
const fedCall = makeFedCall();
|
||||||
connectionManager.createFederatedCall(fedCall);
|
connectionManager.createFederatedCall(fedCall);
|
||||||
const sendToUserSpy = vi.spyOn(connectionManager, 'sendToUser');
|
const sendToUserSpy = vi.spyOn(connectionManager, 'sendToUser');
|
||||||
sendCallRelayMock.mockResolvedValue({ ok: true });
|
sendCallRelayMock.mockResolvedValue({ ok: true, undeliverable: [] });
|
||||||
|
|
||||||
await handleDmCallRejectForTest(
|
await handleDmCallRejectForTest(
|
||||||
{ federatedCallId: fedCall.federatedId },
|
{ federatedCallId: fedCall.federatedId },
|
||||||
@@ -282,7 +282,7 @@ describe('handleDmCallAccept Path-2 relay failure', () => {
|
|||||||
const fedCall = makeFedCall();
|
const fedCall = makeFedCall();
|
||||||
connectionManager.createFederatedCall(fedCall);
|
connectionManager.createFederatedCall(fedCall);
|
||||||
const sendToUserSpy = vi.spyOn(connectionManager, 'sendToUser');
|
const sendToUserSpy = vi.spyOn(connectionManager, 'sendToUser');
|
||||||
sendCallRelayMock.mockResolvedValue({ ok: true });
|
sendCallRelayMock.mockResolvedValue({ ok: true, undeliverable: [] });
|
||||||
|
|
||||||
await handleDmCallAcceptForTest(
|
await handleDmCallAcceptForTest(
|
||||||
{ federatedCallId: fedCall.federatedId },
|
{ federatedCallId: fedCall.federatedId },
|
||||||
|
|||||||
@@ -1925,10 +1925,24 @@ async function sendFederatedCallStart(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Targeted relay: fan out in parallel, await results ────────────────────
|
// ─── 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(
|
const targetedResults = await Promise.all(
|
||||||
Array.from(targetedPeers.keys()).map(async peerOrigin => {
|
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.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 };
|
return { origin: peerOrigin, ok: true as const };
|
||||||
}
|
}
|
||||||
const reason = mapCallReasonToEventReason(result.reason);
|
const reason = mapCallReasonToEventReason(result.reason);
|
||||||
@@ -2483,3 +2497,4 @@ export function registerCallRelayHooks(): void {
|
|||||||
export const handleDmCallAcceptForTest = handleDmCallAccept;
|
export const handleDmCallAcceptForTest = handleDmCallAccept;
|
||||||
export const handleDmCallRejectForTest = handleDmCallReject;
|
export const handleDmCallRejectForTest = handleDmCallReject;
|
||||||
export const handleDmCallEndForTest = handleDmCallEnd;
|
export const handleDmCallEndForTest = handleDmCallEnd;
|
||||||
|
export const sendFederatedCallStartForTest = sendFederatedCallStart;
|
||||||
|
|||||||
@@ -362,7 +362,8 @@ export type DmCallUndeliverableReason =
|
|||||||
| 'peer_rejected'
|
| 'peer_rejected'
|
||||||
| 'peer_awaiting_approval'
|
| 'peer_awaiting_approval'
|
||||||
| 'peer_transient_failure'
|
| 'peer_transient_failure'
|
||||||
| 'livekit_unavailable';
|
| 'livekit_unavailable'
|
||||||
|
| 'no_recipient';
|
||||||
|
|
||||||
export type DmCallPhase = 'start' | 'accept' | 'reject' | 'end' | 'host_unreachable';
|
export type DmCallPhase = 'start' | 'accept' | 'reject' | 'end' | 'host_unreachable';
|
||||||
|
|
||||||
@@ -967,6 +968,14 @@ export interface FederationRelayRequest {
|
|||||||
export interface FederationRelayResponse {
|
export interface FederationRelayResponse {
|
||||||
accepted: string[];
|
accepted: string[];
|
||||||
rejected: Array<{ messageId: string; reason: 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;
|
maxUploadSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,4 +54,40 @@ describe('buildCallUndeliverableToast', () => {
|
|||||||
expect(msg.toLowerCase()).toContain('orbit');
|
expect(msg.toLowerCase()).toContain('orbit');
|
||||||
expect(msg.toLowerCase()).toContain('peered');
|
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/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ export function buildCallUndeliverableToast(
|
|||||||
return `Could not reach ${label}. Try again in a moment.`;
|
return `Could not reach ${label}. Try again in a moment.`;
|
||||||
case 'livekit_unavailable':
|
case 'livekit_unavailable':
|
||||||
return 'Voice is not configured on this instance.';
|
return 'Voice is not configured on this instance.';
|
||||||
|
case 'no_recipient':
|
||||||
|
return `${label} couldn't ring anyone.`;
|
||||||
default:
|
default:
|
||||||
return `Call to ${label} could not be placed.`;
|
return `Call to ${label} could not be placed.`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user