@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.
Files changed (42) hide show
  1. package/README.md +1 -1
  2. package/dist/CallManager.d.ts +38 -0
  3. package/dist/CallManager.js +243 -0
  4. package/dist/CallManager.js.map +1 -0
  5. package/dist/ClientManager.d.ts +3 -1
  6. package/dist/ClientManager.js +23 -2
  7. package/dist/ClientManager.js.map +1 -1
  8. package/dist/IceServers.d.ts +7 -0
  9. package/dist/IceServers.js +143 -0
  10. package/dist/IceServers.js.map +1 -0
  11. package/dist/NotificationService.js +4 -0
  12. package/dist/NotificationService.js.map +1 -1
  13. package/dist/Spire.d.ts +1 -0
  14. package/dist/Spire.js +16 -1
  15. package/dist/Spire.js.map +1 -1
  16. package/dist/server/cliPasskeyPage.d.ts +7 -0
  17. package/dist/server/cliPasskeyPage.js +504 -0
  18. package/dist/server/cliPasskeyPage.js.map +1 -0
  19. package/dist/server/index.js +5 -0
  20. package/dist/server/index.js.map +1 -1
  21. package/dist/server/passkey.js +9 -9
  22. package/dist/server/passkey.js.map +1 -1
  23. package/dist/server/passkeyDevices.js +3 -3
  24. package/dist/server/passkeyDevices.js.map +1 -1
  25. package/dist/server/wireResponse.d.ts +12 -0
  26. package/dist/server/wireResponse.js +19 -0
  27. package/dist/server/wireResponse.js.map +1 -0
  28. package/package.json +5 -4
  29. package/src/CallManager.ts +370 -0
  30. package/src/ClientManager.ts +26 -1
  31. package/src/IceServers.ts +185 -0
  32. package/src/NotificationService.ts +2 -0
  33. package/src/Spire.ts +21 -0
  34. package/src/__tests__/CallManager.spec.ts +208 -0
  35. package/src/__tests__/IceServers.spec.ts +151 -0
  36. package/src/__tests__/cliPasskeyPage.spec.ts +53 -0
  37. package/src/__tests__/wireResponse.spec.ts +46 -0
  38. package/src/server/cliPasskeyPage.ts +510 -0
  39. package/src/server/index.ts +7 -0
  40. package/src/server/passkey.ts +16 -22
  41. package/src/server/passkeyDevices.ts +3 -4
  42. package/src/server/wireResponse.ts +26 -0
@@ -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
- const MAX_MSG_SIZE = 2048;
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
+ });