@vex-chat/spire 2.3.4 → 2.5.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/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 +31 -3
- package/dist/ClientManager.js.map +1 -1
- package/dist/Database.d.ts +3 -2
- package/dist/Database.js +7 -1
- package/dist/Database.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.d.ts +2 -0
- package/dist/NotificationService.js +325 -4
- package/dist/NotificationService.js.map +1 -1
- package/dist/Spire.d.ts +1 -0
- package/dist/Spire.js +37 -6
- package/dist/Spire.js.map +1 -1
- package/dist/server/callWake.d.ts +13 -0
- package/dist/server/callWake.js +18 -0
- package/dist/server/callWake.js.map +1 -0
- package/package.json +6 -5
- package/src/CallManager.ts +370 -0
- package/src/ClientManager.ts +49 -10
- package/src/Database.ts +12 -3
- package/src/IceServers.ts +185 -0
- package/src/NotificationService.ts +430 -4
- package/src/Spire.ts +73 -21
- package/src/__tests__/CallManager.spec.ts +208 -0
- package/src/__tests__/IceServers.spec.ts +151 -0
- package/src/__tests__/notifyFanout.spec.ts +136 -0
- package/src/server/callWake.ts +30 -0
|
@@ -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
|
+
});
|
|
@@ -8,6 +8,8 @@ import type { ClientManager } from "../ClientManager.ts";
|
|
|
8
8
|
import type { Database, NotificationSubscription } from "../Database.ts";
|
|
9
9
|
import type { BaseMsg } from "@vex-chat/types";
|
|
10
10
|
|
|
11
|
+
import { generateKeyPairSync } from "node:crypto";
|
|
12
|
+
|
|
11
13
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
12
14
|
|
|
13
15
|
import { NotificationService } from "../NotificationService.ts";
|
|
@@ -535,6 +537,140 @@ describe("Spire notify fanout", () => {
|
|
|
535
537
|
});
|
|
536
538
|
});
|
|
537
539
|
|
|
540
|
+
it("sends Expo callWake pushes with opaque call data", async () => {
|
|
541
|
+
const callSubscription: NotificationSubscription = {
|
|
542
|
+
...subscription,
|
|
543
|
+
events: ["callWake"],
|
|
544
|
+
subscriptionID: "sub-call",
|
|
545
|
+
};
|
|
546
|
+
const fetchMock = vi.fn().mockResolvedValueOnce({
|
|
547
|
+
json: () =>
|
|
548
|
+
Promise.resolve({
|
|
549
|
+
data: [{ id: "receipt-a", status: "ok" }],
|
|
550
|
+
}),
|
|
551
|
+
ok: true,
|
|
552
|
+
});
|
|
553
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
554
|
+
|
|
555
|
+
const { db } = createSpireHarness([], [callSubscription]);
|
|
556
|
+
const service = new NotificationService(db, [], () => {});
|
|
557
|
+
|
|
558
|
+
await service["notifyPush"]({
|
|
559
|
+
data: {
|
|
560
|
+
callID: "call-1",
|
|
561
|
+
expiresAt: "2026-06-05T12:01:00.000Z",
|
|
562
|
+
mailID: "mail-1",
|
|
563
|
+
mailNonce: "abcd",
|
|
564
|
+
},
|
|
565
|
+
deviceID: callSubscription.deviceID,
|
|
566
|
+
event: "callWake",
|
|
567
|
+
transmissionID: "00000000-0000-0000-0000-000000000017",
|
|
568
|
+
userID: callSubscription.userID,
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
const init = fetchMock.mock.calls[0]?.[1] as
|
|
572
|
+
| undefined
|
|
573
|
+
| { body?: unknown };
|
|
574
|
+
const messages = JSON.parse(String(init?.body)) as Array<{
|
|
575
|
+
body?: string;
|
|
576
|
+
data?: Record<string, unknown>;
|
|
577
|
+
title?: string;
|
|
578
|
+
}>;
|
|
579
|
+
expect(messages[0]?.title).toBe("Incoming Vex call");
|
|
580
|
+
expect(messages[0]?.body).toBe("Incoming voice call.");
|
|
581
|
+
expect(messages[0]?.data).toMatchObject({
|
|
582
|
+
callID: "call-1",
|
|
583
|
+
event: "callWake",
|
|
584
|
+
expiresAt: "2026-06-05T12:01:00.000Z",
|
|
585
|
+
mailID: "mail-1",
|
|
586
|
+
mailNonce: "abcd",
|
|
587
|
+
transmissionID: "00000000-0000-0000-0000-000000000017",
|
|
588
|
+
});
|
|
589
|
+
expect(messages[0]?.data).not.toHaveProperty("callerUserID");
|
|
590
|
+
expect(messages[0]?.data).not.toHaveProperty("sdp");
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it("sends FCM call pushes as high-priority opaque data", async () => {
|
|
594
|
+
const { privateKey } = generateKeyPairSync("rsa", {
|
|
595
|
+
modulusLength: 2048,
|
|
596
|
+
});
|
|
597
|
+
const fcmSubscription: NotificationSubscription = {
|
|
598
|
+
...subscription,
|
|
599
|
+
channel: "fcmCall",
|
|
600
|
+
events: ["callWake"],
|
|
601
|
+
platform: "android",
|
|
602
|
+
subscriptionID: "sub-fcm",
|
|
603
|
+
token: "fcm-token",
|
|
604
|
+
};
|
|
605
|
+
const fetchMock = vi
|
|
606
|
+
.fn()
|
|
607
|
+
.mockResolvedValueOnce({
|
|
608
|
+
json: () => Promise.resolve({ access_token: "access-token" }),
|
|
609
|
+
ok: true,
|
|
610
|
+
})
|
|
611
|
+
.mockResolvedValueOnce({
|
|
612
|
+
ok: true,
|
|
613
|
+
});
|
|
614
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
615
|
+
|
|
616
|
+
process.env["SPIRE_FCM_CLIENT_EMAIL"] = "spire@example.invalid";
|
|
617
|
+
process.env["SPIRE_FCM_PRIVATE_KEY"] = privateKey.export({
|
|
618
|
+
format: "pem",
|
|
619
|
+
type: "pkcs8",
|
|
620
|
+
});
|
|
621
|
+
process.env["SPIRE_FCM_PROJECT_ID"] = "vex-test";
|
|
622
|
+
try {
|
|
623
|
+
const { db } = createSpireHarness([], [fcmSubscription]);
|
|
624
|
+
const service = new NotificationService(db, [], () => {});
|
|
625
|
+
|
|
626
|
+
await service["notifyPush"]({
|
|
627
|
+
data: {
|
|
628
|
+
callID: "call-fcm",
|
|
629
|
+
expiresAt: "2026-06-05T12:01:00.000Z",
|
|
630
|
+
mailID: "mail-fcm",
|
|
631
|
+
mailNonce: "beef",
|
|
632
|
+
},
|
|
633
|
+
deviceID: fcmSubscription.deviceID,
|
|
634
|
+
event: "callWake",
|
|
635
|
+
transmissionID: "00000000-0000-0000-0000-000000000018",
|
|
636
|
+
userID: fcmSubscription.userID,
|
|
637
|
+
});
|
|
638
|
+
} finally {
|
|
639
|
+
delete process.env["SPIRE_FCM_CLIENT_EMAIL"];
|
|
640
|
+
delete process.env["SPIRE_FCM_PRIVATE_KEY"];
|
|
641
|
+
delete process.env["SPIRE_FCM_PROJECT_ID"];
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
645
|
+
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
646
|
+
"https://oauth2.googleapis.com/token",
|
|
647
|
+
);
|
|
648
|
+
expect(fetchMock.mock.calls[1]?.[0]).toBe(
|
|
649
|
+
"https://fcm.googleapis.com/v1/projects/vex-test/messages:send",
|
|
650
|
+
);
|
|
651
|
+
const init = fetchMock.mock.calls[1]?.[1] as
|
|
652
|
+
| undefined
|
|
653
|
+
| { body?: unknown };
|
|
654
|
+
const body = JSON.parse(String(init?.body)) as {
|
|
655
|
+
message?: {
|
|
656
|
+
android?: { priority?: string; ttl?: string };
|
|
657
|
+
data?: Record<string, string>;
|
|
658
|
+
token?: string;
|
|
659
|
+
};
|
|
660
|
+
};
|
|
661
|
+
expect(body.message?.token).toBe("fcm-token");
|
|
662
|
+
expect(body.message?.android?.priority).toBe("HIGH");
|
|
663
|
+
expect(body.message?.data).toMatchObject({
|
|
664
|
+
callID: "call-fcm",
|
|
665
|
+
event: "callWake",
|
|
666
|
+
mailID: "mail-fcm",
|
|
667
|
+
mailNonce: "beef",
|
|
668
|
+
transmissionID: "00000000-0000-0000-0000-000000000018",
|
|
669
|
+
});
|
|
670
|
+
expect(body.message?.data).not.toHaveProperty("callerUserID");
|
|
671
|
+
expect(body.message?.data).not.toHaveProperty("sdp");
|
|
672
|
+
});
|
|
673
|
+
|
|
538
674
|
it("requests the default sound for iOS visible Expo pushes only", async () => {
|
|
539
675
|
const iosSubscription: NotificationSubscription = {
|
|
540
676
|
...subscription,
|
|
@@ -0,0 +1,30 @@
|
|
|
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 { MailWS } from "@vex-chat/types";
|
|
8
|
+
|
|
9
|
+
import { XUtils } from "@vex-chat/crypto";
|
|
10
|
+
|
|
11
|
+
export interface CallWakeDispatchData {
|
|
12
|
+
callID: string;
|
|
13
|
+
expiresAt?: string | undefined;
|
|
14
|
+
mailID: string;
|
|
15
|
+
mailNonce: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function callWakeDispatchData(
|
|
19
|
+
mail: MailWS,
|
|
20
|
+
): CallWakeDispatchData | null {
|
|
21
|
+
if (mail.notify?.event !== "callWake") {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
callID: mail.notify.callID,
|
|
26
|
+
...(mail.notify.expiresAt ? { expiresAt: mail.notify.expiresAt } : {}),
|
|
27
|
+
mailID: mail.mailID,
|
|
28
|
+
mailNonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
|
|
29
|
+
};
|
|
30
|
+
}
|