@vex-chat/spire 2.3.3 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/CallManager.d.ts +38 -0
- package/dist/CallManager.js +243 -0
- package/dist/CallManager.js.map +1 -0
- package/dist/ClientManager.d.ts +3 -1
- package/dist/ClientManager.js +23 -2
- package/dist/ClientManager.js.map +1 -1
- package/dist/IceServers.d.ts +7 -0
- package/dist/IceServers.js +143 -0
- package/dist/IceServers.js.map +1 -0
- package/dist/NotificationService.js +4 -0
- package/dist/NotificationService.js.map +1 -1
- package/dist/Spire.d.ts +1 -0
- package/dist/Spire.js +16 -1
- package/dist/Spire.js.map +1 -1
- package/dist/server/cliPasskeyPage.d.ts +7 -0
- package/dist/server/cliPasskeyPage.js +504 -0
- package/dist/server/cliPasskeyPage.js.map +1 -0
- package/dist/server/index.js +5 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/passkey.js +9 -9
- package/dist/server/passkey.js.map +1 -1
- package/dist/server/passkeyDevices.js +3 -3
- package/dist/server/passkeyDevices.js.map +1 -1
- package/dist/server/wireResponse.d.ts +12 -0
- package/dist/server/wireResponse.js +19 -0
- package/dist/server/wireResponse.js.map +1 -0
- package/package.json +5 -4
- package/src/CallManager.ts +370 -0
- package/src/ClientManager.ts +26 -1
- package/src/IceServers.ts +185 -0
- package/src/NotificationService.ts +2 -0
- package/src/Spire.ts +21 -0
- package/src/__tests__/CallManager.spec.ts +208 -0
- package/src/__tests__/IceServers.spec.ts +151 -0
- package/src/__tests__/cliPasskeyPage.spec.ts +53 -0
- package/src/__tests__/wireResponse.spec.ts +46 -0
- package/src/server/cliPasskeyPage.ts +510 -0
- package/src/server/index.ts +7 -0
- package/src/server/passkey.ts +16 -22
- package/src/server/passkeyDevices.ts +3 -4
- package/src/server/wireResponse.ts +26 -0
package/src/ClientManager.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Commercial licenses available at vex.wtf
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import type { CallManager } from "./CallManager.ts";
|
|
7
8
|
import type { Database } from "./Database.ts";
|
|
8
9
|
import type {
|
|
9
10
|
BaseMsg,
|
|
@@ -44,7 +45,9 @@ function emptyHeader() {
|
|
|
44
45
|
return new Uint8Array(32);
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
|
|
48
|
+
// WebRTC SDP offers/answers are commonly several KB before encryption/packing.
|
|
49
|
+
// Keep this well below HTTP body limits while allowing first-party call signals.
|
|
50
|
+
const MAX_MSG_SIZE = 64 * 1024;
|
|
48
51
|
|
|
49
52
|
// How many ping cycles in a row are allowed to go without a pong
|
|
50
53
|
// before we declare the connection dead. With a 5s ping interval
|
|
@@ -68,6 +71,7 @@ const PING_INTERVAL_MS = 5000;
|
|
|
68
71
|
export class ClientManager extends EventEmitter {
|
|
69
72
|
private alive: boolean = true;
|
|
70
73
|
private authed: boolean = false;
|
|
74
|
+
private callManager: CallManager;
|
|
71
75
|
private challengeID: Uint8Array = createUint8UUID();
|
|
72
76
|
private conn: WebSocket;
|
|
73
77
|
private db: Database;
|
|
@@ -89,6 +93,7 @@ export class ClientManager extends EventEmitter {
|
|
|
89
93
|
constructor(
|
|
90
94
|
ws: WebSocket,
|
|
91
95
|
db: Database,
|
|
96
|
+
callManager: CallManager,
|
|
92
97
|
notify: (
|
|
93
98
|
userID: string,
|
|
94
99
|
event: string,
|
|
@@ -103,6 +108,7 @@ export class ClientManager extends EventEmitter {
|
|
|
103
108
|
super();
|
|
104
109
|
this.conn = ws;
|
|
105
110
|
this.db = db;
|
|
111
|
+
this.callManager = callManager;
|
|
106
112
|
this.user = null;
|
|
107
113
|
this.userDetails = userDetails;
|
|
108
114
|
this.device = null;
|
|
@@ -280,6 +286,25 @@ export class ClientManager extends EventEmitter {
|
|
|
280
286
|
|
|
281
287
|
private async parseResourceMsg(msg: ResourceMsg, header: Uint8Array) {
|
|
282
288
|
switch (msg.resourceType) {
|
|
289
|
+
case "call":
|
|
290
|
+
try {
|
|
291
|
+
const event = await this.callManager.handleResource({
|
|
292
|
+
action: msg.action,
|
|
293
|
+
actor: {
|
|
294
|
+
device: this.getDevice(),
|
|
295
|
+
user: this.getUser(),
|
|
296
|
+
},
|
|
297
|
+
data: msg.data,
|
|
298
|
+
transmissionID: msg.transmissionID,
|
|
299
|
+
});
|
|
300
|
+
this.sendSuccess(msg.transmissionID, event);
|
|
301
|
+
} catch (err: unknown) {
|
|
302
|
+
this.sendErr(
|
|
303
|
+
msg.transmissionID,
|
|
304
|
+
err instanceof Error ? err.message : String(err),
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
break;
|
|
283
308
|
case "mail":
|
|
284
309
|
if (msg.action === "CREATE") {
|
|
285
310
|
const mailResult = MailWSSchema.safeParse(msg.data);
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2020-2026 Vex Heavy Industries LLC
|
|
3
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
4
|
+
* Commercial licenses available at vex.wtf
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { IceServerConfig } from "@vex-chat/types";
|
|
8
|
+
|
|
9
|
+
import { IceServerConfigSchema } from "@vex-chat/types";
|
|
10
|
+
|
|
11
|
+
import { z } from "zod/v4";
|
|
12
|
+
|
|
13
|
+
const CLOUDFLARE_TURN_ENDPOINT = "https://rtc.live.cloudflare.com/v1/turn/keys";
|
|
14
|
+
const DEFAULT_CLOUDFLARE_TURN_TIMEOUT_MS = 5_000;
|
|
15
|
+
const DEFAULT_CLOUDFLARE_TURN_TTL_SECONDS = 86_400;
|
|
16
|
+
|
|
17
|
+
const cloudflareIceServersResponseSchema = z.object({
|
|
18
|
+
iceServers: z.array(IceServerConfigSchema),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export async function resolveIceServersFromEnv(): Promise<IceServerConfig[]> {
|
|
22
|
+
const jsonOverride = readJsonIceServersFromEnv();
|
|
23
|
+
if (jsonOverride) {
|
|
24
|
+
return jsonOverride;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const staticServers = readStaticIceServersFromEnv();
|
|
28
|
+
const cloudflareServers = await readCloudflareIceServersFromEnv();
|
|
29
|
+
return [...staticServers, ...cloudflareServers];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function readCloudflareIceServersFromEnv(): Promise<IceServerConfig[]> {
|
|
33
|
+
const keyID = readEnv([
|
|
34
|
+
"SPIRE_CLOUDFLARE_TURN_KEY_ID",
|
|
35
|
+
"CLOUDFLARE_TURN_KEY_ID",
|
|
36
|
+
"TURN_KEY_ID",
|
|
37
|
+
]);
|
|
38
|
+
const apiToken = readEnv([
|
|
39
|
+
"SPIRE_CLOUDFLARE_TURN_API_TOKEN",
|
|
40
|
+
"CLOUDFLARE_TURN_API_TOKEN",
|
|
41
|
+
"TURN_KEY_API_TOKEN",
|
|
42
|
+
]);
|
|
43
|
+
const endpointOverride = readEnv(["SPIRE_CLOUDFLARE_TURN_ENDPOINT"]);
|
|
44
|
+
if (!keyID && !apiToken && !endpointOverride) {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!apiToken || (!keyID && !endpointOverride)) {
|
|
49
|
+
console.warn(
|
|
50
|
+
"[spire-calls] Cloudflare TURN is partially configured; set SPIRE_CLOUDFLARE_TURN_KEY_ID and SPIRE_CLOUDFLARE_TURN_API_TOKEN",
|
|
51
|
+
);
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let endpoint = endpointOverride;
|
|
56
|
+
if (!endpoint) {
|
|
57
|
+
if (!keyID) {
|
|
58
|
+
console.warn(
|
|
59
|
+
"[spire-calls] Cloudflare TURN is partially configured; set SPIRE_CLOUDFLARE_TURN_KEY_ID and SPIRE_CLOUDFLARE_TURN_API_TOKEN",
|
|
60
|
+
);
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
endpoint = `${CLOUDFLARE_TURN_ENDPOINT}/${encodeURIComponent(keyID)}/credentials/generate-ice-servers`;
|
|
64
|
+
}
|
|
65
|
+
const ttl = readPositiveIntegerEnv(
|
|
66
|
+
"SPIRE_CLOUDFLARE_TURN_TTL_SECONDS",
|
|
67
|
+
DEFAULT_CLOUDFLARE_TURN_TTL_SECONDS,
|
|
68
|
+
);
|
|
69
|
+
const timeoutMs = readPositiveIntegerEnv(
|
|
70
|
+
"SPIRE_CLOUDFLARE_TURN_TIMEOUT_MS",
|
|
71
|
+
DEFAULT_CLOUDFLARE_TURN_TIMEOUT_MS,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const controller = new AbortController();
|
|
75
|
+
const timeout = setTimeout(() => {
|
|
76
|
+
controller.abort();
|
|
77
|
+
}, timeoutMs);
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const response = await fetch(endpoint, {
|
|
81
|
+
body: JSON.stringify({ ttl }),
|
|
82
|
+
headers: {
|
|
83
|
+
Authorization: `Bearer ${apiToken}`,
|
|
84
|
+
"Content-Type": "application/json",
|
|
85
|
+
},
|
|
86
|
+
method: "POST",
|
|
87
|
+
signal: controller.signal,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (!response.ok) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`Cloudflare TURN credential request failed with ${response.status}`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const payload = await response.json();
|
|
97
|
+
return cloudflareIceServersResponseSchema.parse(payload).iceServers;
|
|
98
|
+
} catch (err: unknown) {
|
|
99
|
+
console.warn(
|
|
100
|
+
"[spire-calls] failed to fetch Cloudflare TURN credentials",
|
|
101
|
+
err instanceof Error ? err.message : String(err),
|
|
102
|
+
);
|
|
103
|
+
return [];
|
|
104
|
+
} finally {
|
|
105
|
+
clearTimeout(timeout);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function readEnv(names: string[]): string | undefined {
|
|
110
|
+
for (const name of names) {
|
|
111
|
+
const value = process.env[name]?.trim();
|
|
112
|
+
if (value) {
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function readJsonIceServersFromEnv(): IceServerConfig[] | null {
|
|
120
|
+
const json = process.env["SPIRE_ICE_SERVERS"]?.trim();
|
|
121
|
+
if (!json) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const parsed = JSON.parse(json) as unknown;
|
|
127
|
+
return z.array(IceServerConfigSchema).parse(parsed);
|
|
128
|
+
} catch (err: unknown) {
|
|
129
|
+
console.warn(
|
|
130
|
+
"[spire-calls] ignoring invalid SPIRE_ICE_SERVERS",
|
|
131
|
+
err instanceof Error ? err.message : String(err),
|
|
132
|
+
);
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function readPositiveIntegerEnv(name: string, fallback: number): number {
|
|
138
|
+
const raw = process.env[name]?.trim();
|
|
139
|
+
if (!raw) {
|
|
140
|
+
return fallback;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const parsed = Number.parseInt(raw, 10);
|
|
144
|
+
if (Number.isInteger(parsed) && parsed > 0) {
|
|
145
|
+
return parsed;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
console.warn(
|
|
149
|
+
`[spire-calls] ignoring invalid ${name}; expected a positive integer`,
|
|
150
|
+
);
|
|
151
|
+
return fallback;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function readStaticIceServersFromEnv(): IceServerConfig[] {
|
|
155
|
+
const servers: IceServerConfig[] = [];
|
|
156
|
+
for (const url of splitCsvEnv("SPIRE_STUN_URLS")) {
|
|
157
|
+
servers.push({ urls: url });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const turnUrls = splitCsvEnv("SPIRE_TURN_URLS");
|
|
161
|
+
if (turnUrls.length > 0) {
|
|
162
|
+
const username = process.env["SPIRE_TURN_USERNAME"]?.trim();
|
|
163
|
+
const credential = process.env["SPIRE_TURN_CREDENTIAL"]?.trim();
|
|
164
|
+
const [firstTurnUrl] = turnUrls;
|
|
165
|
+
if (!firstTurnUrl) {
|
|
166
|
+
return servers;
|
|
167
|
+
}
|
|
168
|
+
const urls: string | string[] =
|
|
169
|
+
turnUrls.length === 1 ? firstTurnUrl : turnUrls;
|
|
170
|
+
servers.push({
|
|
171
|
+
...(credential ? { credential } : {}),
|
|
172
|
+
urls,
|
|
173
|
+
...(username ? { username } : {}),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return servers;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function splitCsvEnv(name: string): string[] {
|
|
181
|
+
return (process.env[name] ?? "")
|
|
182
|
+
.split(",")
|
|
183
|
+
.map((value) => value.trim())
|
|
184
|
+
.filter((value) => value.length > 0);
|
|
185
|
+
}
|
|
@@ -433,6 +433,7 @@ export class NotificationService {
|
|
|
433
433
|
}
|
|
434
434
|
|
|
435
435
|
function bodyForEvent(event: string): string | undefined {
|
|
436
|
+
if (event === "callInvite") return "Incoming voice call.";
|
|
436
437
|
if (event === "mail") return undefined;
|
|
437
438
|
if (event === "deviceRequest") return "Review the device request.";
|
|
438
439
|
if (event === "deviceListChanged") return "Your device list changed.";
|
|
@@ -600,6 +601,7 @@ function shouldSendHeadlessPush(dispatch: NotificationDispatch): boolean {
|
|
|
600
601
|
}
|
|
601
602
|
|
|
602
603
|
function titleForEvent(event: string): string {
|
|
604
|
+
if (event === "callInvite") return "Incoming Vex call";
|
|
603
605
|
if (event === "mail") return "New Message";
|
|
604
606
|
if (event === "deviceRequest") return "Device approval request";
|
|
605
607
|
if (event === "deviceListChanged") return "Vex device update";
|
package/src/Spire.ts
CHANGED
|
@@ -43,8 +43,10 @@ import { stringify as uuidStringify } from "uuid";
|
|
|
43
43
|
import { WebSocketServer } from "ws";
|
|
44
44
|
import { z } from "zod/v4";
|
|
45
45
|
|
|
46
|
+
import { CallManager } from "./CallManager.ts";
|
|
46
47
|
import { ClientManager } from "./ClientManager.ts";
|
|
47
48
|
import { Database, hashPasswordArgon2, verifyPassword } from "./Database.ts";
|
|
49
|
+
import { resolveIceServersFromEnv } from "./IceServers.ts";
|
|
48
50
|
import { NotificationService } from "./NotificationService.ts";
|
|
49
51
|
import { initApp, protect } from "./server/index.ts";
|
|
50
52
|
import {
|
|
@@ -216,6 +218,7 @@ let pendingFipsKeyPair: KeyPair | null = null;
|
|
|
216
218
|
export class Spire extends EventEmitter {
|
|
217
219
|
private actionTokens: ActionToken[] = [];
|
|
218
220
|
private api = express();
|
|
221
|
+
private calls: CallManager;
|
|
219
222
|
private clients: ClientManager[] = [];
|
|
220
223
|
private readonly commitSha = getCommitSha();
|
|
221
224
|
private readonly cryptoProfile: "fips" | "tweetnacl";
|
|
@@ -264,8 +267,10 @@ export class Spire extends EventEmitter {
|
|
|
264
267
|
// that lets attackers spoof the header and bypass rate limiting.
|
|
265
268
|
// If spire is deployed without a proxy, set this to 0 instead.
|
|
266
269
|
this.api.set("trust proxy", 1);
|
|
270
|
+
this.api.disable("etag");
|
|
267
271
|
|
|
268
272
|
this.db = new Database(options);
|
|
273
|
+
this.calls = new CallManager(this.db, this.notify.bind(this));
|
|
269
274
|
this.notifications = new NotificationService(
|
|
270
275
|
this.db,
|
|
271
276
|
this.clients,
|
|
@@ -468,6 +473,7 @@ export class Spire extends EventEmitter {
|
|
|
468
473
|
const client = new ClientManager(
|
|
469
474
|
ws,
|
|
470
475
|
this.db,
|
|
476
|
+
this.calls,
|
|
471
477
|
this.notify.bind(this),
|
|
472
478
|
userDetails,
|
|
473
479
|
);
|
|
@@ -995,6 +1001,21 @@ export class Spire extends EventEmitter {
|
|
|
995
1001
|
}
|
|
996
1002
|
});
|
|
997
1003
|
|
|
1004
|
+
this.api.get("/calls/active", protect, (req, res) => {
|
|
1005
|
+
const user = getUser(req);
|
|
1006
|
+
res.setHeader(
|
|
1007
|
+
"Cache-Control",
|
|
1008
|
+
"no-store, no-cache, must-revalidate, proxy-revalidate",
|
|
1009
|
+
);
|
|
1010
|
+
res.setHeader("Pragma", "no-cache");
|
|
1011
|
+
res.setHeader("Expires", "0");
|
|
1012
|
+
res.json({ calls: this.calls.activeCallsForUser(user.userID) });
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
this.api.get("/calls/ice-servers", protect, async (_req, res) => {
|
|
1016
|
+
res.json({ iceServers: await resolveIceServersFromEnv() });
|
|
1017
|
+
});
|
|
1018
|
+
|
|
998
1019
|
this.api.post(
|
|
999
1020
|
"/device/:id/notifications/subscriptions",
|
|
1000
1021
|
protect,
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2020-2026 Vex Heavy Industries LLC
|
|
3
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
4
|
+
* Commercial licenses available at vex.wtf
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Database } from "../Database.ts";
|
|
8
|
+
import type { Device, User } from "@vex-chat/types";
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it, vi } from "vitest";
|
|
11
|
+
|
|
12
|
+
import { CallManager } from "../CallManager.ts";
|
|
13
|
+
|
|
14
|
+
const caller: User = {
|
|
15
|
+
lastSeen: "2026-06-01T00:00:00.000Z",
|
|
16
|
+
userID: "user-a",
|
|
17
|
+
username: "alice",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const recipient: User = {
|
|
21
|
+
lastSeen: "2026-06-01T00:00:00.000Z",
|
|
22
|
+
userID: "user-b",
|
|
23
|
+
username: "bob",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const callerDevice: Device = {
|
|
27
|
+
deleted: false,
|
|
28
|
+
deviceID: "device-a",
|
|
29
|
+
lastLogin: "2026-06-01T00:00:00.000Z",
|
|
30
|
+
name: "ios",
|
|
31
|
+
owner: caller.userID,
|
|
32
|
+
signKey: "aa",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const recipientDevice: Device = {
|
|
36
|
+
deleted: false,
|
|
37
|
+
deviceID: "device-b",
|
|
38
|
+
lastLogin: "2026-06-01T00:00:00.000Z",
|
|
39
|
+
name: "android",
|
|
40
|
+
owner: recipient.userID,
|
|
41
|
+
signKey: "bb",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function harness() {
|
|
45
|
+
const notify = vi.fn();
|
|
46
|
+
const db = {
|
|
47
|
+
retrieveUser: vi.fn((userID: string) =>
|
|
48
|
+
Promise.resolve(userID === recipient.userID ? recipient : null),
|
|
49
|
+
),
|
|
50
|
+
} as unknown as Database;
|
|
51
|
+
const calls = new CallManager(db, notify);
|
|
52
|
+
return { calls, notify };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("CallManager", () => {
|
|
56
|
+
it("creates a one-to-one call invite and notifies the recipient", async () => {
|
|
57
|
+
const { calls, notify } = harness();
|
|
58
|
+
|
|
59
|
+
const event = await calls.handleResource({
|
|
60
|
+
action: "INVITE",
|
|
61
|
+
actor: { device: callerDevice, user: caller },
|
|
62
|
+
data: {
|
|
63
|
+
conversationType: "dm",
|
|
64
|
+
recipientUserID: recipient.userID,
|
|
65
|
+
signal: {
|
|
66
|
+
description: { sdp: "offer", type: "offer" },
|
|
67
|
+
kind: "offer",
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
transmissionID: "tx-1",
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(event.action).toBe("invite");
|
|
74
|
+
expect(event.call.status).toBe("ringing");
|
|
75
|
+
expect(event.call.participants.map((p) => p.userID)).toEqual([
|
|
76
|
+
caller.userID,
|
|
77
|
+
recipient.userID,
|
|
78
|
+
]);
|
|
79
|
+
expect(notify).toHaveBeenCalledWith(
|
|
80
|
+
recipient.userID,
|
|
81
|
+
"callInvite",
|
|
82
|
+
"tx-1",
|
|
83
|
+
expect.objectContaining({ action: "invite" }),
|
|
84
|
+
);
|
|
85
|
+
expect(calls.activeCallsForUser(recipient.userID)).toHaveLength(1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("accepts a ringing call and notifies participants", async () => {
|
|
89
|
+
const { calls, notify } = harness();
|
|
90
|
+
const invite = await calls.handleResource({
|
|
91
|
+
action: "INVITE",
|
|
92
|
+
actor: { device: callerDevice, user: caller },
|
|
93
|
+
data: {
|
|
94
|
+
conversationType: "dm",
|
|
95
|
+
recipientUserID: recipient.userID,
|
|
96
|
+
},
|
|
97
|
+
transmissionID: "tx-1",
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const accepted = await calls.handleResource({
|
|
101
|
+
action: "ACCEPT",
|
|
102
|
+
actor: { device: recipientDevice, user: recipient },
|
|
103
|
+
data: {
|
|
104
|
+
callID: invite.call.callID,
|
|
105
|
+
signal: {
|
|
106
|
+
description: { sdp: "answer", type: "answer" },
|
|
107
|
+
kind: "answer",
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
transmissionID: "tx-2",
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(accepted.call.status).toBe("active");
|
|
114
|
+
expect(
|
|
115
|
+
accepted.call.participants.find(
|
|
116
|
+
(p) => p.userID === recipient.userID,
|
|
117
|
+
),
|
|
118
|
+
).toMatchObject({
|
|
119
|
+
deviceID: recipientDevice.deviceID,
|
|
120
|
+
state: "accepted",
|
|
121
|
+
});
|
|
122
|
+
expect(notify).toHaveBeenCalledWith(
|
|
123
|
+
caller.userID,
|
|
124
|
+
"call",
|
|
125
|
+
"tx-2",
|
|
126
|
+
expect.objectContaining({ action: "accept" }),
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("relays ICE candidates only for call participants", async () => {
|
|
131
|
+
const { calls, notify } = harness();
|
|
132
|
+
const invite = await calls.handleResource({
|
|
133
|
+
action: "INVITE",
|
|
134
|
+
actor: { device: callerDevice, user: caller },
|
|
135
|
+
data: {
|
|
136
|
+
conversationType: "dm",
|
|
137
|
+
recipientUserID: recipient.userID,
|
|
138
|
+
},
|
|
139
|
+
transmissionID: "tx-1",
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const ice = await calls.handleResource({
|
|
143
|
+
action: "ICE",
|
|
144
|
+
actor: { device: callerDevice, user: caller },
|
|
145
|
+
data: {
|
|
146
|
+
callID: invite.call.callID,
|
|
147
|
+
signal: {
|
|
148
|
+
candidate: { candidate: "candidate:1" },
|
|
149
|
+
kind: "ice",
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
transmissionID: "tx-3",
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(ice.action).toBe("ice");
|
|
156
|
+
expect(notify).toHaveBeenCalledWith(
|
|
157
|
+
recipient.userID,
|
|
158
|
+
"call",
|
|
159
|
+
"tx-3",
|
|
160
|
+
expect.objectContaining({
|
|
161
|
+
signal: { candidate: expect.anything(), kind: "ice" },
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
await expect(
|
|
166
|
+
calls.handleResource({
|
|
167
|
+
action: "ICE",
|
|
168
|
+
actor: {
|
|
169
|
+
device: { ...callerDevice, deviceID: "device-c" },
|
|
170
|
+
user: {
|
|
171
|
+
lastSeen: "2026-06-01T00:00:00.000Z",
|
|
172
|
+
userID: "user-c",
|
|
173
|
+
username: "charlie",
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
data: {
|
|
177
|
+
callID: invite.call.callID,
|
|
178
|
+
signal: { candidate: {}, kind: "ice" },
|
|
179
|
+
},
|
|
180
|
+
transmissionID: "tx-4",
|
|
181
|
+
}),
|
|
182
|
+
).rejects.toThrow("not a participant");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("ends a rejected call and removes it from active calls", async () => {
|
|
186
|
+
const { calls } = harness();
|
|
187
|
+
const invite = await calls.handleResource({
|
|
188
|
+
action: "INVITE",
|
|
189
|
+
actor: { device: callerDevice, user: caller },
|
|
190
|
+
data: {
|
|
191
|
+
conversationType: "dm",
|
|
192
|
+
recipientUserID: recipient.userID,
|
|
193
|
+
},
|
|
194
|
+
transmissionID: "tx-1",
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const rejected = await calls.handleResource({
|
|
198
|
+
action: "REJECT",
|
|
199
|
+
actor: { device: recipientDevice, user: recipient },
|
|
200
|
+
data: { callID: invite.call.callID },
|
|
201
|
+
transmissionID: "tx-5",
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
expect(rejected.call.status).toBe("ended");
|
|
205
|
+
expect(calls.activeCallsForUser(caller.userID)).toEqual([]);
|
|
206
|
+
expect(calls.activeCallsForUser(recipient.userID)).toEqual([]);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2020-2026 Vex Heavy Industries LLC
|
|
3
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
4
|
+
* Commercial licenses available at vex.wtf
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
8
|
+
|
|
9
|
+
import { resolveIceServersFromEnv } from "../IceServers.ts";
|
|
10
|
+
|
|
11
|
+
const originalFetch = globalThis.fetch;
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
delete process.env["CLOUDFLARE_TURN_API_TOKEN"];
|
|
15
|
+
delete process.env["CLOUDFLARE_TURN_KEY_ID"];
|
|
16
|
+
delete process.env["SPIRE_CLOUDFLARE_TURN_API_TOKEN"];
|
|
17
|
+
delete process.env["SPIRE_CLOUDFLARE_TURN_ENDPOINT"];
|
|
18
|
+
delete process.env["SPIRE_CLOUDFLARE_TURN_KEY_ID"];
|
|
19
|
+
delete process.env["SPIRE_CLOUDFLARE_TURN_TIMEOUT_MS"];
|
|
20
|
+
delete process.env["SPIRE_CLOUDFLARE_TURN_TTL_SECONDS"];
|
|
21
|
+
delete process.env["SPIRE_ICE_SERVERS"];
|
|
22
|
+
delete process.env["SPIRE_STUN_URLS"];
|
|
23
|
+
delete process.env["SPIRE_TURN_CREDENTIAL"];
|
|
24
|
+
delete process.env["SPIRE_TURN_URLS"];
|
|
25
|
+
delete process.env["SPIRE_TURN_USERNAME"];
|
|
26
|
+
delete process.env["TURN_KEY_API_TOKEN"];
|
|
27
|
+
delete process.env["TURN_KEY_ID"];
|
|
28
|
+
globalThis.fetch = originalFetch;
|
|
29
|
+
vi.restoreAllMocks();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("resolveIceServersFromEnv", () => {
|
|
33
|
+
it("uses SPIRE_ICE_SERVERS as a full override", async () => {
|
|
34
|
+
process.env["SPIRE_ICE_SERVERS"] = JSON.stringify([
|
|
35
|
+
{
|
|
36
|
+
urls: ["stun:override.example:3478"],
|
|
37
|
+
},
|
|
38
|
+
]);
|
|
39
|
+
process.env["SPIRE_STUN_URLS"] = "stun:ignored.example:3478";
|
|
40
|
+
process.env["SPIRE_CLOUDFLARE_TURN_KEY_ID"] = "turn-key";
|
|
41
|
+
process.env["SPIRE_CLOUDFLARE_TURN_API_TOKEN"] = "secret";
|
|
42
|
+
const fetchMock = vi.fn();
|
|
43
|
+
globalThis.fetch = fetchMock;
|
|
44
|
+
|
|
45
|
+
await expect(resolveIceServersFromEnv()).resolves.toEqual([
|
|
46
|
+
{
|
|
47
|
+
urls: ["stun:override.example:3478"],
|
|
48
|
+
},
|
|
49
|
+
]);
|
|
50
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("combines static STUN and TURN env vars", async () => {
|
|
54
|
+
process.env["SPIRE_STUN_URLS"] =
|
|
55
|
+
"stun:one.example:3478, stun:two.example:3478";
|
|
56
|
+
process.env["SPIRE_TURN_URLS"] =
|
|
57
|
+
"turn:turn.example:3478?transport=udp, turns:turn.example:443?transport=tcp";
|
|
58
|
+
process.env["SPIRE_TURN_USERNAME"] = "static-user";
|
|
59
|
+
process.env["SPIRE_TURN_CREDENTIAL"] = "static-secret";
|
|
60
|
+
|
|
61
|
+
await expect(resolveIceServersFromEnv()).resolves.toEqual([
|
|
62
|
+
{ urls: "stun:one.example:3478" },
|
|
63
|
+
{ urls: "stun:two.example:3478" },
|
|
64
|
+
{
|
|
65
|
+
credential: "static-secret",
|
|
66
|
+
urls: [
|
|
67
|
+
"turn:turn.example:3478?transport=udp",
|
|
68
|
+
"turns:turn.example:443?transport=tcp",
|
|
69
|
+
],
|
|
70
|
+
username: "static-user",
|
|
71
|
+
},
|
|
72
|
+
]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("fetches Cloudflare TURN credentials with a TTL", async () => {
|
|
76
|
+
process.env["SPIRE_CLOUDFLARE_TURN_KEY_ID"] = "turn-key";
|
|
77
|
+
process.env["SPIRE_CLOUDFLARE_TURN_API_TOKEN"] = "secret";
|
|
78
|
+
process.env["SPIRE_CLOUDFLARE_TURN_TTL_SECONDS"] = "3600";
|
|
79
|
+
|
|
80
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
81
|
+
json: () =>
|
|
82
|
+
Promise.resolve({
|
|
83
|
+
iceServers: [
|
|
84
|
+
{
|
|
85
|
+
urls: ["stun:stun.cloudflare.com:3478"],
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
credential: "generated-secret",
|
|
89
|
+
urls: [
|
|
90
|
+
"turn:turn.cloudflare.com:3478?transport=udp",
|
|
91
|
+
],
|
|
92
|
+
username: "generated-user",
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
}),
|
|
96
|
+
ok: true,
|
|
97
|
+
status: 201,
|
|
98
|
+
});
|
|
99
|
+
globalThis.fetch = fetchMock;
|
|
100
|
+
|
|
101
|
+
await expect(resolveIceServersFromEnv()).resolves.toEqual([
|
|
102
|
+
{ urls: ["stun:stun.cloudflare.com:3478"] },
|
|
103
|
+
{
|
|
104
|
+
credential: "generated-secret",
|
|
105
|
+
urls: ["turn:turn.cloudflare.com:3478?transport=udp"],
|
|
106
|
+
username: "generated-user",
|
|
107
|
+
},
|
|
108
|
+
]);
|
|
109
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
110
|
+
"https://rtc.live.cloudflare.com/v1/turn/keys/turn-key/credentials/generate-ice-servers",
|
|
111
|
+
expect.objectContaining({
|
|
112
|
+
body: JSON.stringify({ ttl: 3600 }),
|
|
113
|
+
headers: {
|
|
114
|
+
Authorization: "Bearer secret",
|
|
115
|
+
"Content-Type": "application/json",
|
|
116
|
+
},
|
|
117
|
+
method: "POST",
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("falls back to static ICE servers when Cloudflare fails", async () => {
|
|
123
|
+
process.env["SPIRE_STUN_URLS"] = "stun:local.example:3478";
|
|
124
|
+
process.env["SPIRE_CLOUDFLARE_TURN_KEY_ID"] = "turn-key";
|
|
125
|
+
process.env["SPIRE_CLOUDFLARE_TURN_API_TOKEN"] = "secret";
|
|
126
|
+
|
|
127
|
+
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
128
|
+
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
129
|
+
json: () => Promise.resolve({}),
|
|
130
|
+
ok: false,
|
|
131
|
+
status: 403,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
await expect(resolveIceServersFromEnv()).resolves.toEqual([
|
|
135
|
+
{ urls: "stun:local.example:3478" },
|
|
136
|
+
]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("ignores partial Cloudflare config", async () => {
|
|
140
|
+
process.env["SPIRE_STUN_URLS"] = "stun:local.example:3478";
|
|
141
|
+
process.env["SPIRE_CLOUDFLARE_TURN_KEY_ID"] = "turn-key";
|
|
142
|
+
const fetchMock = vi.fn();
|
|
143
|
+
globalThis.fetch = fetchMock;
|
|
144
|
+
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
145
|
+
|
|
146
|
+
await expect(resolveIceServersFromEnv()).resolves.toEqual([
|
|
147
|
+
{ urls: "stun:local.example:3478" },
|
|
148
|
+
]);
|
|
149
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
150
|
+
});
|
|
151
|
+
});
|