@vex-chat/spire 2.3.4 → 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.
@@ -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
+ });