@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
|
@@ -8,8 +8,14 @@ import type { ClientManager } from "./ClientManager.ts";
|
|
|
8
8
|
import type { Database, NotificationSubscription } from "./Database.ts";
|
|
9
9
|
import type { NotifyMsg } from "@vex-chat/types";
|
|
10
10
|
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import * as http2 from "node:http2";
|
|
13
|
+
|
|
14
|
+
import jwt from "jsonwebtoken";
|
|
15
|
+
|
|
11
16
|
const EXPO_PUSH_ENDPOINT = "https://exp.host/--/api/v2/push/send";
|
|
12
17
|
const EXPO_RECEIPT_ENDPOINT = "https://exp.host/--/api/v2/push/getReceipts";
|
|
18
|
+
const FCM_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
|
|
13
19
|
const EXPO_BATCH_SIZE = 100;
|
|
14
20
|
const EXPO_RECEIPT_DELAY_MS = 15 * 60 * 1000;
|
|
15
21
|
const EXPO_REQUEST_TIMEOUT_MS = 10_000;
|
|
@@ -27,6 +33,19 @@ export interface NotificationDispatch {
|
|
|
27
33
|
userID: string;
|
|
28
34
|
}
|
|
29
35
|
|
|
36
|
+
interface ApnsConfig {
|
|
37
|
+
environment: "production" | "sandbox";
|
|
38
|
+
keyID: string;
|
|
39
|
+
privateKey: string;
|
|
40
|
+
teamID: string;
|
|
41
|
+
topic: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ApnsResponse {
|
|
45
|
+
body: string;
|
|
46
|
+
status: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
30
49
|
type ExpoErrorDetails = {
|
|
31
50
|
[key: string]: unknown;
|
|
32
51
|
error?: string;
|
|
@@ -53,6 +72,12 @@ type ExpoPushTicket =
|
|
|
53
72
|
status: "ok";
|
|
54
73
|
};
|
|
55
74
|
|
|
75
|
+
interface FcmConfig {
|
|
76
|
+
clientEmail: string;
|
|
77
|
+
privateKey: string;
|
|
78
|
+
projectID: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
56
81
|
interface NotificationServiceOptions {
|
|
57
82
|
receiptDelayMs?: number;
|
|
58
83
|
}
|
|
@@ -209,9 +234,21 @@ export class NotificationService {
|
|
|
209
234
|
? { ...query, deviceID: dispatch.deviceID }
|
|
210
235
|
: query,
|
|
211
236
|
);
|
|
212
|
-
const expoSubscriptions = subscriptions
|
|
213
|
-
|
|
214
|
-
|
|
237
|
+
const expoSubscriptions = subscriptions.filter(
|
|
238
|
+
(sub) => sub.channel === "expo",
|
|
239
|
+
);
|
|
240
|
+
const apnsSubscriptions = subscriptions.filter(
|
|
241
|
+
(sub) => sub.channel === "apnsVoip",
|
|
242
|
+
);
|
|
243
|
+
const fcmSubscriptions = subscriptions.filter(
|
|
244
|
+
(sub) => sub.channel === "fcmCall",
|
|
245
|
+
);
|
|
246
|
+
if (
|
|
247
|
+
expoSubscriptions.length === 0 &&
|
|
248
|
+
apnsSubscriptions.length === 0 &&
|
|
249
|
+
fcmSubscriptions.length === 0
|
|
250
|
+
) {
|
|
251
|
+
console.info("[spire-notify] no push subscriptions", {
|
|
215
252
|
deviceScoped: Boolean(dispatch.deviceID),
|
|
216
253
|
event: dispatch.event,
|
|
217
254
|
transmissionID: dispatch.transmissionID,
|
|
@@ -224,6 +261,14 @@ export class NotificationService {
|
|
|
224
261
|
const batch = expoSubscriptions.slice(i, i + EXPO_BATCH_SIZE);
|
|
225
262
|
await this.sendExpoBatch(batch, dispatch);
|
|
226
263
|
}
|
|
264
|
+
if (dispatch.event === "callWake") {
|
|
265
|
+
if (apnsSubscriptions.length > 0) {
|
|
266
|
+
await this.sendApnsVoipBatch(apnsSubscriptions, dispatch);
|
|
267
|
+
}
|
|
268
|
+
if (fcmSubscriptions.length > 0) {
|
|
269
|
+
await this.sendFcmCallBatch(fcmSubscriptions, dispatch);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
227
272
|
}
|
|
228
273
|
|
|
229
274
|
private async notifyPushIfMailPending(
|
|
@@ -328,6 +373,59 @@ export class NotificationService {
|
|
|
328
373
|
(timer as { unref?: () => void }).unref?.();
|
|
329
374
|
}
|
|
330
375
|
|
|
376
|
+
private async sendApnsVoipBatch(
|
|
377
|
+
subscriptions: NotificationSubscription[],
|
|
378
|
+
dispatch: NotificationDispatch,
|
|
379
|
+
): Promise<void> {
|
|
380
|
+
const config = readApnsConfig();
|
|
381
|
+
if (!config) {
|
|
382
|
+
console.warn("[spire-notify] APNs VoIP env is not configured", {
|
|
383
|
+
event: dispatch.event,
|
|
384
|
+
size: subscriptions.length,
|
|
385
|
+
transmissionID: dispatch.transmissionID,
|
|
386
|
+
});
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const providerToken = jwt.sign({}, config.privateKey, {
|
|
391
|
+
algorithm: "ES256",
|
|
392
|
+
expiresIn: "50m",
|
|
393
|
+
header: { alg: "ES256", kid: config.keyID },
|
|
394
|
+
issuer: config.teamID,
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
for (const subscription of subscriptions) {
|
|
398
|
+
const response = await sendApnsVoipNotification(
|
|
399
|
+
config,
|
|
400
|
+
providerToken,
|
|
401
|
+
subscription,
|
|
402
|
+
dispatch,
|
|
403
|
+
);
|
|
404
|
+
if (response.status >= 200 && response.status < 300) {
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
console.warn("[spire-notify] APNs VoIP delivery failed", {
|
|
409
|
+
body: response.body.slice(0, 500),
|
|
410
|
+
status: response.status,
|
|
411
|
+
subscriptionID: subscription.subscriptionID,
|
|
412
|
+
transmissionID: dispatch.transmissionID,
|
|
413
|
+
});
|
|
414
|
+
if (
|
|
415
|
+
response.status === 400 ||
|
|
416
|
+
response.status === 410 ||
|
|
417
|
+
response.body.includes("BadDeviceToken") ||
|
|
418
|
+
response.body.includes("Unregistered")
|
|
419
|
+
) {
|
|
420
|
+
await this.db.removeNotificationSubscription({
|
|
421
|
+
deviceID: subscription.deviceID,
|
|
422
|
+
subscriptionID: subscription.subscriptionID,
|
|
423
|
+
userID: subscription.userID,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
331
429
|
private async sendExpoBatch(
|
|
332
430
|
subscriptions: NotificationSubscription[],
|
|
333
431
|
dispatch: NotificationDispatch,
|
|
@@ -419,10 +517,68 @@ export class NotificationService {
|
|
|
419
517
|
}
|
|
420
518
|
}
|
|
421
519
|
|
|
520
|
+
private async sendFcmCallBatch(
|
|
521
|
+
subscriptions: NotificationSubscription[],
|
|
522
|
+
dispatch: NotificationDispatch,
|
|
523
|
+
): Promise<void> {
|
|
524
|
+
const config = readFcmConfig();
|
|
525
|
+
if (!config) {
|
|
526
|
+
console.warn("[spire-notify] FCM call env is not configured", {
|
|
527
|
+
event: dispatch.event,
|
|
528
|
+
size: subscriptions.length,
|
|
529
|
+
transmissionID: dispatch.transmissionID,
|
|
530
|
+
});
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const accessToken = await fetchFcmAccessToken(config);
|
|
535
|
+
for (const subscription of subscriptions) {
|
|
536
|
+
const res = await fetchWithTimeout(
|
|
537
|
+
`https://fcm.googleapis.com/v1/projects/${encodeURIComponent(
|
|
538
|
+
config.projectID,
|
|
539
|
+
)}/messages:send`,
|
|
540
|
+
{
|
|
541
|
+
body: JSON.stringify(
|
|
542
|
+
fcmCallMessageForSubscription(subscription, dispatch),
|
|
543
|
+
),
|
|
544
|
+
headers: {
|
|
545
|
+
Authorization: `Bearer ${accessToken}`,
|
|
546
|
+
"Content-Type": "application/json",
|
|
547
|
+
},
|
|
548
|
+
method: "POST",
|
|
549
|
+
},
|
|
550
|
+
"FCM call push request",
|
|
551
|
+
);
|
|
552
|
+
if (res.ok) {
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const body = await res.text().catch(() => "");
|
|
557
|
+
console.warn("[spire-notify] FCM call delivery failed", {
|
|
558
|
+
body: body.slice(0, 500),
|
|
559
|
+
status: res.status,
|
|
560
|
+
subscriptionID: subscription.subscriptionID,
|
|
561
|
+
transmissionID: dispatch.transmissionID,
|
|
562
|
+
});
|
|
563
|
+
if (
|
|
564
|
+
res.status === 400 ||
|
|
565
|
+
res.status === 404 ||
|
|
566
|
+
body.includes("UNREGISTERED") ||
|
|
567
|
+
body.includes("registration-token-not-registered")
|
|
568
|
+
) {
|
|
569
|
+
await this.db.removeNotificationSubscription({
|
|
570
|
+
deviceID: subscription.deviceID,
|
|
571
|
+
subscriptionID: subscription.subscriptionID,
|
|
572
|
+
userID: subscription.userID,
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
422
578
|
private startPush(dispatch: NotificationDispatch): void {
|
|
423
579
|
void this.notifyPush(dispatch).catch((err: unknown) => {
|
|
424
580
|
// Push is best-effort; websocket/inbox delivery remain authoritative.
|
|
425
|
-
console.warn("[spire-notify]
|
|
581
|
+
console.warn("[spire-notify] push fanout failed", {
|
|
426
582
|
event: dispatch.event,
|
|
427
583
|
message: err instanceof Error ? err.message : String(err),
|
|
428
584
|
transmissionID: dispatch.transmissionID,
|
|
@@ -432,13 +588,38 @@ export class NotificationService {
|
|
|
432
588
|
}
|
|
433
589
|
}
|
|
434
590
|
|
|
591
|
+
function apnsExpiration(dispatch: NotificationDispatch): string {
|
|
592
|
+
const expiresAt = notifyDataFields(dispatch.data)["expiresAt"];
|
|
593
|
+
if (typeof expiresAt === "string") {
|
|
594
|
+
const epochSeconds = Math.floor(Date.parse(expiresAt) / 1000);
|
|
595
|
+
if (Number.isFinite(epochSeconds) && epochSeconds > 0) {
|
|
596
|
+
return String(epochSeconds);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return String(Math.floor((Date.now() + 60_000) / 1000));
|
|
600
|
+
}
|
|
601
|
+
|
|
435
602
|
function bodyForEvent(event: string): string | undefined {
|
|
603
|
+
if (event === "callWake") return "Incoming voice call.";
|
|
604
|
+
if (event === "callInvite") return "Incoming voice call.";
|
|
436
605
|
if (event === "mail") return undefined;
|
|
437
606
|
if (event === "deviceRequest") return "Review the device request.";
|
|
438
607
|
if (event === "deviceListChanged") return "Your device list changed.";
|
|
439
608
|
return "Open Vex for the latest update.";
|
|
440
609
|
}
|
|
441
610
|
|
|
611
|
+
function callWakeTtl(dispatch: NotificationDispatch): string {
|
|
612
|
+
const expiresAt = notifyDataFields(dispatch.data)["expiresAt"];
|
|
613
|
+
if (typeof expiresAt !== "string") {
|
|
614
|
+
return "60s";
|
|
615
|
+
}
|
|
616
|
+
const ms = Date.parse(expiresAt) - Date.now();
|
|
617
|
+
if (!Number.isFinite(ms) || ms <= 0) {
|
|
618
|
+
return "1s";
|
|
619
|
+
}
|
|
620
|
+
return `${Math.max(1, Math.ceil(ms / 1000)).toString()}s`;
|
|
621
|
+
}
|
|
622
|
+
|
|
442
623
|
function expoMessageForSubscription(
|
|
443
624
|
subscription: NotificationSubscription,
|
|
444
625
|
dispatch: NotificationDispatch,
|
|
@@ -461,6 +642,7 @@ function expoMessageForSubscription(
|
|
|
461
642
|
const title = titleForEvent(dispatch.event);
|
|
462
643
|
const body = bodyForEvent(dispatch.event);
|
|
463
644
|
const data = {
|
|
645
|
+
...notifyDataFields(dispatch.data),
|
|
464
646
|
deviceID: dispatch.deviceID ?? null,
|
|
465
647
|
event: dispatch.event,
|
|
466
648
|
title,
|
|
@@ -492,6 +674,73 @@ function expoMessageForSubscription(
|
|
|
492
674
|
return message;
|
|
493
675
|
}
|
|
494
676
|
|
|
677
|
+
function fcmCallMessageForSubscription(
|
|
678
|
+
subscription: NotificationSubscription,
|
|
679
|
+
dispatch: NotificationDispatch,
|
|
680
|
+
): Record<string, unknown> {
|
|
681
|
+
return {
|
|
682
|
+
message: {
|
|
683
|
+
android: {
|
|
684
|
+
priority: "HIGH",
|
|
685
|
+
ttl: callWakeTtl(dispatch),
|
|
686
|
+
},
|
|
687
|
+
data: stringRecord({
|
|
688
|
+
...notifyDataFields(dispatch.data),
|
|
689
|
+
deviceID: dispatch.deviceID ?? "",
|
|
690
|
+
event: dispatch.event,
|
|
691
|
+
transmissionID: dispatch.transmissionID,
|
|
692
|
+
}),
|
|
693
|
+
token: subscription.token,
|
|
694
|
+
},
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
async function fetchFcmAccessToken(config: FcmConfig): Promise<string> {
|
|
699
|
+
const assertion = jwt.sign(
|
|
700
|
+
{
|
|
701
|
+
scope: "https://www.googleapis.com/auth/firebase.messaging",
|
|
702
|
+
},
|
|
703
|
+
config.privateKey,
|
|
704
|
+
{
|
|
705
|
+
algorithm: "RS256",
|
|
706
|
+
audience: FCM_TOKEN_ENDPOINT,
|
|
707
|
+
expiresIn: "55m",
|
|
708
|
+
issuer: config.clientEmail,
|
|
709
|
+
subject: config.clientEmail,
|
|
710
|
+
},
|
|
711
|
+
);
|
|
712
|
+
const body = new URLSearchParams({
|
|
713
|
+
assertion,
|
|
714
|
+
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
715
|
+
});
|
|
716
|
+
const res = await fetchWithTimeout(
|
|
717
|
+
FCM_TOKEN_ENDPOINT,
|
|
718
|
+
{
|
|
719
|
+
body,
|
|
720
|
+
headers: {
|
|
721
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
722
|
+
},
|
|
723
|
+
method: "POST",
|
|
724
|
+
},
|
|
725
|
+
"FCM OAuth token request",
|
|
726
|
+
);
|
|
727
|
+
if (!res.ok) {
|
|
728
|
+
const responseBody = await res.text().catch(() => "");
|
|
729
|
+
throw new Error(
|
|
730
|
+
`FCM OAuth token request failed with status ${res.status.toString()}${
|
|
731
|
+
responseBody ? `: ${responseBody.slice(0, 500)}` : ""
|
|
732
|
+
}`,
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
const raw = await res.json();
|
|
736
|
+
if (!isRecord(raw) || typeof raw["access_token"] !== "string") {
|
|
737
|
+
throw new Error(
|
|
738
|
+
"FCM OAuth token response did not include access_token",
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
return raw["access_token"];
|
|
742
|
+
}
|
|
743
|
+
|
|
495
744
|
async function fetchWithTimeout(
|
|
496
745
|
input: string,
|
|
497
746
|
init: RequestInit,
|
|
@@ -546,6 +795,24 @@ function normalizeExpoTickets(data: unknown): ExpoPushTicket[] {
|
|
|
546
795
|
return tickets.filter(isExpoPushTicket);
|
|
547
796
|
}
|
|
548
797
|
|
|
798
|
+
function normalizePrivateKey(value: string): string {
|
|
799
|
+
return value.replaceAll("\\n", "\n");
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function notifyDataFields(data: unknown): Record<string, unknown> {
|
|
803
|
+
if (!isRecord(data)) {
|
|
804
|
+
return {};
|
|
805
|
+
}
|
|
806
|
+
const out: Record<string, unknown> = {};
|
|
807
|
+
for (const key of ["callID", "expiresAt", "mailID", "mailNonce"]) {
|
|
808
|
+
const value = data[key];
|
|
809
|
+
if (typeof value === "string") {
|
|
810
|
+
out[key] = value;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
return out;
|
|
814
|
+
}
|
|
815
|
+
|
|
549
816
|
function parseExpoPushResponse(raw: unknown): ParsedExpoPushResponse {
|
|
550
817
|
if (!isRecord(raw)) {
|
|
551
818
|
return { errors: [], tickets: [] };
|
|
@@ -577,6 +844,153 @@ function parseExpoReceipts(data: unknown): Record<string, ExpoPushReceipt> {
|
|
|
577
844
|
return receipts;
|
|
578
845
|
}
|
|
579
846
|
|
|
847
|
+
function readApnsConfig(): ApnsConfig | null {
|
|
848
|
+
const teamID = readEnv("SPIRE_APNS_TEAM_ID");
|
|
849
|
+
const keyID = readEnv("SPIRE_APNS_KEY_ID");
|
|
850
|
+
const bundleID = readEnv("SPIRE_APNS_BUNDLE_ID");
|
|
851
|
+
const topic = readEnv("SPIRE_APNS_TOPIC") ?? `${bundleID ?? ""}.voip`;
|
|
852
|
+
const privateKey = readPrivateKeyEnv(
|
|
853
|
+
"SPIRE_APNS_PRIVATE_KEY",
|
|
854
|
+
"SPIRE_APNS_PRIVATE_KEY_FILE",
|
|
855
|
+
);
|
|
856
|
+
if (!teamID || !keyID || !bundleID || !topic || !privateKey) {
|
|
857
|
+
return null;
|
|
858
|
+
}
|
|
859
|
+
const environment =
|
|
860
|
+
readEnv("SPIRE_APNS_ENV") === "sandbox" ? "sandbox" : "production";
|
|
861
|
+
return {
|
|
862
|
+
environment,
|
|
863
|
+
keyID,
|
|
864
|
+
privateKey,
|
|
865
|
+
teamID,
|
|
866
|
+
topic,
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function readEnv(name: string): null | string {
|
|
871
|
+
const value = process.env[name]?.trim();
|
|
872
|
+
return value && value.length > 0 ? value : null;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function readFcmConfig(): FcmConfig | null {
|
|
876
|
+
const json = readEnv("SPIRE_FCM_SERVICE_ACCOUNT_JSON");
|
|
877
|
+
if (json) {
|
|
878
|
+
try {
|
|
879
|
+
const raw = JSON.parse(json) as unknown;
|
|
880
|
+
if (
|
|
881
|
+
isRecord(raw) &&
|
|
882
|
+
typeof raw["client_email"] === "string" &&
|
|
883
|
+
typeof raw["private_key"] === "string" &&
|
|
884
|
+
typeof raw["project_id"] === "string"
|
|
885
|
+
) {
|
|
886
|
+
return {
|
|
887
|
+
clientEmail: raw["client_email"],
|
|
888
|
+
privateKey: normalizePrivateKey(raw["private_key"]),
|
|
889
|
+
projectID: raw["project_id"],
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
} catch {
|
|
893
|
+
return null;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const clientEmail = readEnv("SPIRE_FCM_CLIENT_EMAIL");
|
|
898
|
+
const projectID = readEnv("SPIRE_FCM_PROJECT_ID");
|
|
899
|
+
const privateKey = readPrivateKeyEnv(
|
|
900
|
+
"SPIRE_FCM_PRIVATE_KEY",
|
|
901
|
+
"SPIRE_FCM_PRIVATE_KEY_FILE",
|
|
902
|
+
);
|
|
903
|
+
if (!clientEmail || !projectID || !privateKey) {
|
|
904
|
+
return null;
|
|
905
|
+
}
|
|
906
|
+
return { clientEmail, privateKey, projectID };
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function readPrivateKeyEnv(valueName: string, fileName: string): null | string {
|
|
910
|
+
const inline = readEnv(valueName);
|
|
911
|
+
if (inline) {
|
|
912
|
+
return normalizePrivateKey(inline);
|
|
913
|
+
}
|
|
914
|
+
const file = readEnv(fileName);
|
|
915
|
+
if (!file) {
|
|
916
|
+
return null;
|
|
917
|
+
}
|
|
918
|
+
try {
|
|
919
|
+
return normalizePrivateKey(readFileSync(file, "utf8"));
|
|
920
|
+
} catch {
|
|
921
|
+
return null;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function sendApnsVoipNotification(
|
|
926
|
+
config: ApnsConfig,
|
|
927
|
+
providerToken: string,
|
|
928
|
+
subscription: NotificationSubscription,
|
|
929
|
+
dispatch: NotificationDispatch,
|
|
930
|
+
): Promise<ApnsResponse> {
|
|
931
|
+
const host =
|
|
932
|
+
config.environment === "sandbox"
|
|
933
|
+
? "api.sandbox.push.apple.com"
|
|
934
|
+
: "api.push.apple.com";
|
|
935
|
+
const payload = JSON.stringify({
|
|
936
|
+
aps: {
|
|
937
|
+
"content-available": 1,
|
|
938
|
+
},
|
|
939
|
+
...notifyDataFields(dispatch.data),
|
|
940
|
+
event: dispatch.event,
|
|
941
|
+
transmissionID: dispatch.transmissionID,
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
return new Promise<ApnsResponse>((resolve, reject) => {
|
|
945
|
+
const client = http2.connect(`https://${host}`);
|
|
946
|
+
const timer = setTimeout(() => {
|
|
947
|
+
client.close();
|
|
948
|
+
reject(
|
|
949
|
+
new Error(
|
|
950
|
+
`APNs VoIP request timed out after ${EXPO_REQUEST_TIMEOUT_MS.toString()}ms`,
|
|
951
|
+
),
|
|
952
|
+
);
|
|
953
|
+
}, EXPO_REQUEST_TIMEOUT_MS);
|
|
954
|
+
(timer as { unref?: () => void }).unref?.();
|
|
955
|
+
|
|
956
|
+
client.once("error", (err) => {
|
|
957
|
+
clearTimeout(timer);
|
|
958
|
+
reject(err);
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
const req = client.request({
|
|
962
|
+
":method": "POST",
|
|
963
|
+
":path": `/3/device/${subscription.token}`,
|
|
964
|
+
"apns-expiration": apnsExpiration(dispatch),
|
|
965
|
+
"apns-priority": "10",
|
|
966
|
+
"apns-push-type": "voip",
|
|
967
|
+
"apns-topic": config.topic,
|
|
968
|
+
authorization: `bearer ${providerToken}`,
|
|
969
|
+
});
|
|
970
|
+
let status = 0;
|
|
971
|
+
let body = "";
|
|
972
|
+
req.setEncoding("utf8");
|
|
973
|
+
req.on("response", (headers) => {
|
|
974
|
+
const rawStatus = headers[":status"];
|
|
975
|
+
status = typeof rawStatus === "number" ? rawStatus : 0;
|
|
976
|
+
});
|
|
977
|
+
req.on("data", (chunk: string) => {
|
|
978
|
+
body += chunk;
|
|
979
|
+
});
|
|
980
|
+
req.on("error", (err) => {
|
|
981
|
+
clearTimeout(timer);
|
|
982
|
+
client.close();
|
|
983
|
+
reject(err);
|
|
984
|
+
});
|
|
985
|
+
req.on("end", () => {
|
|
986
|
+
clearTimeout(timer);
|
|
987
|
+
client.close();
|
|
988
|
+
resolve({ body, status });
|
|
989
|
+
});
|
|
990
|
+
req.end(payload);
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
|
|
580
994
|
function shouldDeferMailPush(
|
|
581
995
|
dispatch: NotificationDispatch,
|
|
582
996
|
websocketDeliveries: number,
|
|
@@ -599,7 +1013,19 @@ function shouldSendHeadlessPush(dispatch: NotificationDispatch): boolean {
|
|
|
599
1013
|
);
|
|
600
1014
|
}
|
|
601
1015
|
|
|
1016
|
+
function stringRecord(input: Record<string, unknown>): Record<string, string> {
|
|
1017
|
+
const out: Record<string, string> = {};
|
|
1018
|
+
for (const [key, value] of Object.entries(input)) {
|
|
1019
|
+
if (typeof value === "string") {
|
|
1020
|
+
out[key] = value;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
return out;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
602
1026
|
function titleForEvent(event: string): string {
|
|
1027
|
+
if (event === "callWake") return "Incoming Vex call";
|
|
1028
|
+
if (event === "callInvite") return "Incoming Vex call";
|
|
603
1029
|
if (event === "mail") return "New Message";
|
|
604
1030
|
if (event === "deviceRequest") return "Device approval request";
|
|
605
1031
|
if (event === "deviceListChanged") return "Vex device update";
|
package/src/Spire.ts
CHANGED
|
@@ -43,9 +43,12 @@ 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";
|
|
51
|
+
import { callWakeDispatchData } from "./server/callWake.ts";
|
|
49
52
|
import { initApp, protect } from "./server/index.ts";
|
|
50
53
|
import {
|
|
51
54
|
MailIngressValidationError,
|
|
@@ -131,8 +134,8 @@ interface ValidatedMailBatchEntry {
|
|
|
131
134
|
}
|
|
132
135
|
|
|
133
136
|
const notificationSubscribePayload = z.object({
|
|
134
|
-
channel: z.
|
|
135
|
-
events: z.array(z.string().min(1)).
|
|
137
|
+
channel: z.enum(["apnsVoip", "expo", "fcmCall"]),
|
|
138
|
+
events: z.array(z.string().min(1)).optional(),
|
|
136
139
|
platform: z.enum(["android", "ios", "web"]).optional(),
|
|
137
140
|
token: z.string().min(1),
|
|
138
141
|
});
|
|
@@ -216,6 +219,7 @@ let pendingFipsKeyPair: KeyPair | null = null;
|
|
|
216
219
|
export class Spire extends EventEmitter {
|
|
217
220
|
private actionTokens: ActionToken[] = [];
|
|
218
221
|
private api = express();
|
|
222
|
+
private calls: CallManager;
|
|
219
223
|
private clients: ClientManager[] = [];
|
|
220
224
|
private readonly commitSha = getCommitSha();
|
|
221
225
|
private readonly cryptoProfile: "fips" | "tweetnacl";
|
|
@@ -264,8 +268,10 @@ export class Spire extends EventEmitter {
|
|
|
264
268
|
// that lets attackers spoof the header and bypass rate limiting.
|
|
265
269
|
// If spire is deployed without a proxy, set this to 0 instead.
|
|
266
270
|
this.api.set("trust proxy", 1);
|
|
271
|
+
this.api.disable("etag");
|
|
267
272
|
|
|
268
273
|
this.db = new Database(options);
|
|
274
|
+
this.calls = new CallManager(this.db, this.notify.bind(this));
|
|
269
275
|
this.notifications = new NotificationService(
|
|
270
276
|
this.db,
|
|
271
277
|
this.clients,
|
|
@@ -468,6 +474,7 @@ export class Spire extends EventEmitter {
|
|
|
468
474
|
const client = new ClientManager(
|
|
469
475
|
ws,
|
|
470
476
|
this.db,
|
|
477
|
+
this.calls,
|
|
471
478
|
this.notify.bind(this),
|
|
472
479
|
userDetails,
|
|
473
480
|
);
|
|
@@ -865,15 +872,28 @@ export class Spire extends EventEmitter {
|
|
|
865
872
|
);
|
|
866
873
|
|
|
867
874
|
res.sendStatus(200);
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
875
|
+
const callWake = callWakeDispatchData(mail);
|
|
876
|
+
if (callWake) {
|
|
877
|
+
this.notify(
|
|
878
|
+
recipientDeviceDetails.owner,
|
|
879
|
+
"callWake",
|
|
880
|
+
crypto.randomUUID(),
|
|
881
|
+
callWake,
|
|
882
|
+
mail.recipient,
|
|
883
|
+
undefined,
|
|
884
|
+
mail.nonce,
|
|
885
|
+
);
|
|
886
|
+
} else {
|
|
887
|
+
this.notify(
|
|
888
|
+
recipientDeviceDetails.owner,
|
|
889
|
+
"mail",
|
|
890
|
+
crypto.randomUUID(),
|
|
891
|
+
null,
|
|
892
|
+
mail.recipient,
|
|
893
|
+
mail.authorID,
|
|
894
|
+
mail.nonce,
|
|
895
|
+
);
|
|
896
|
+
}
|
|
877
897
|
});
|
|
878
898
|
|
|
879
899
|
this.api.post("/mail/batch", protect, async (req, res) => {
|
|
@@ -983,18 +1003,46 @@ export class Spire extends EventEmitter {
|
|
|
983
1003
|
|
|
984
1004
|
res.send(msgpack.encode({ results }));
|
|
985
1005
|
for (const entry of deliveredEntries) {
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
1006
|
+
const callWake = callWakeDispatchData(entry.mail);
|
|
1007
|
+
if (callWake) {
|
|
1008
|
+
this.notify(
|
|
1009
|
+
entry.recipientDevice.owner,
|
|
1010
|
+
"callWake",
|
|
1011
|
+
crypto.randomUUID(),
|
|
1012
|
+
callWake,
|
|
1013
|
+
entry.mail.recipient,
|
|
1014
|
+
undefined,
|
|
1015
|
+
entry.mail.nonce,
|
|
1016
|
+
);
|
|
1017
|
+
} else {
|
|
1018
|
+
this.notify(
|
|
1019
|
+
entry.recipientDevice.owner,
|
|
1020
|
+
"mail",
|
|
1021
|
+
crypto.randomUUID(),
|
|
1022
|
+
null,
|
|
1023
|
+
entry.mail.recipient,
|
|
1024
|
+
entry.mail.authorID,
|
|
1025
|
+
entry.mail.nonce,
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
995
1028
|
}
|
|
996
1029
|
});
|
|
997
1030
|
|
|
1031
|
+
this.api.get("/calls/active", protect, (req, res) => {
|
|
1032
|
+
const user = getUser(req);
|
|
1033
|
+
res.setHeader(
|
|
1034
|
+
"Cache-Control",
|
|
1035
|
+
"no-store, no-cache, must-revalidate, proxy-revalidate",
|
|
1036
|
+
);
|
|
1037
|
+
res.setHeader("Pragma", "no-cache");
|
|
1038
|
+
res.setHeader("Expires", "0");
|
|
1039
|
+
res.json({ calls: this.calls.activeCallsForUser(user.userID) });
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
this.api.get("/calls/ice-servers", protect, async (_req, res) => {
|
|
1043
|
+
res.json({ iceServers: await resolveIceServersFromEnv() });
|
|
1044
|
+
});
|
|
1045
|
+
|
|
998
1046
|
this.api.post(
|
|
999
1047
|
"/device/:id/notifications/subscriptions",
|
|
1000
1048
|
protect,
|
|
@@ -1022,7 +1070,11 @@ export class Spire extends EventEmitter {
|
|
|
1022
1070
|
{
|
|
1023
1071
|
channel: parsed.data.channel,
|
|
1024
1072
|
deviceID: device.deviceID,
|
|
1025
|
-
events:
|
|
1073
|
+
events:
|
|
1074
|
+
parsed.data.events ??
|
|
1075
|
+
(parsed.data.channel === "expo"
|
|
1076
|
+
? ["mail"]
|
|
1077
|
+
: ["callWake"]),
|
|
1026
1078
|
platform: parsed.data.platform ?? null,
|
|
1027
1079
|
token: parsed.data.token,
|
|
1028
1080
|
userID: getUser(req).userID,
|