@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.
@@ -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
- if (expoSubscriptions.length === 0) {
214
- console.info("[spire-notify] no Expo push subscriptions", {
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] Expo push fanout failed", {
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.literal("expo"),
135
- events: z.array(z.string().min(1)).default(["mail"]),
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
- this.notify(
869
- recipientDeviceDetails.owner,
870
- "mail",
871
- crypto.randomUUID(),
872
- null,
873
- mail.recipient,
874
- mail.authorID,
875
- mail.nonce,
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
- this.notify(
987
- entry.recipientDevice.owner,
988
- "mail",
989
- crypto.randomUUID(),
990
- null,
991
- entry.mail.recipient,
992
- entry.mail.authorID,
993
- entry.mail.nonce,
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: parsed.data.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,