@vex-chat/spire 2.4.0 → 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,7 +588,19 @@ 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.";
436
604
  if (event === "callInvite") return "Incoming voice call.";
437
605
  if (event === "mail") return undefined;
438
606
  if (event === "deviceRequest") return "Review the device request.";
@@ -440,6 +608,18 @@ function bodyForEvent(event: string): string | undefined {
440
608
  return "Open Vex for the latest update.";
441
609
  }
442
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
+
443
623
  function expoMessageForSubscription(
444
624
  subscription: NotificationSubscription,
445
625
  dispatch: NotificationDispatch,
@@ -462,6 +642,7 @@ function expoMessageForSubscription(
462
642
  const title = titleForEvent(dispatch.event);
463
643
  const body = bodyForEvent(dispatch.event);
464
644
  const data = {
645
+ ...notifyDataFields(dispatch.data),
465
646
  deviceID: dispatch.deviceID ?? null,
466
647
  event: dispatch.event,
467
648
  title,
@@ -493,6 +674,73 @@ function expoMessageForSubscription(
493
674
  return message;
494
675
  }
495
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
+
496
744
  async function fetchWithTimeout(
497
745
  input: string,
498
746
  init: RequestInit,
@@ -547,6 +795,24 @@ function normalizeExpoTickets(data: unknown): ExpoPushTicket[] {
547
795
  return tickets.filter(isExpoPushTicket);
548
796
  }
549
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
+
550
816
  function parseExpoPushResponse(raw: unknown): ParsedExpoPushResponse {
551
817
  if (!isRecord(raw)) {
552
818
  return { errors: [], tickets: [] };
@@ -578,6 +844,153 @@ function parseExpoReceipts(data: unknown): Record<string, ExpoPushReceipt> {
578
844
  return receipts;
579
845
  }
580
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
+
581
994
  function shouldDeferMailPush(
582
995
  dispatch: NotificationDispatch,
583
996
  websocketDeliveries: number,
@@ -600,7 +1013,18 @@ function shouldSendHeadlessPush(dispatch: NotificationDispatch): boolean {
600
1013
  );
601
1014
  }
602
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
+
603
1026
  function titleForEvent(event: string): string {
1027
+ if (event === "callWake") return "Incoming Vex call";
604
1028
  if (event === "callInvite") return "Incoming Vex call";
605
1029
  if (event === "mail") return "New Message";
606
1030
  if (event === "deviceRequest") return "Device approval request";
package/src/Spire.ts CHANGED
@@ -48,6 +48,7 @@ import { ClientManager } from "./ClientManager.ts";
48
48
  import { Database, hashPasswordArgon2, verifyPassword } from "./Database.ts";
49
49
  import { resolveIceServersFromEnv } from "./IceServers.ts";
50
50
  import { NotificationService } from "./NotificationService.ts";
51
+ import { callWakeDispatchData } from "./server/callWake.ts";
51
52
  import { initApp, protect } from "./server/index.ts";
52
53
  import {
53
54
  MailIngressValidationError,
@@ -133,8 +134,8 @@ interface ValidatedMailBatchEntry {
133
134
  }
134
135
 
135
136
  const notificationSubscribePayload = z.object({
136
- channel: z.literal("expo"),
137
- 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(),
138
139
  platform: z.enum(["android", "ios", "web"]).optional(),
139
140
  token: z.string().min(1),
140
141
  });
@@ -871,15 +872,28 @@ export class Spire extends EventEmitter {
871
872
  );
872
873
 
873
874
  res.sendStatus(200);
874
- this.notify(
875
- recipientDeviceDetails.owner,
876
- "mail",
877
- crypto.randomUUID(),
878
- null,
879
- mail.recipient,
880
- mail.authorID,
881
- mail.nonce,
882
- );
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
+ }
883
897
  });
884
898
 
885
899
  this.api.post("/mail/batch", protect, async (req, res) => {
@@ -989,15 +1003,28 @@ export class Spire extends EventEmitter {
989
1003
 
990
1004
  res.send(msgpack.encode({ results }));
991
1005
  for (const entry of deliveredEntries) {
992
- this.notify(
993
- entry.recipientDevice.owner,
994
- "mail",
995
- crypto.randomUUID(),
996
- null,
997
- entry.mail.recipient,
998
- entry.mail.authorID,
999
- entry.mail.nonce,
1000
- );
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
+ }
1001
1028
  }
1002
1029
  });
1003
1030
 
@@ -1043,7 +1070,11 @@ export class Spire extends EventEmitter {
1043
1070
  {
1044
1071
  channel: parsed.data.channel,
1045
1072
  deviceID: device.deviceID,
1046
- events: parsed.data.events,
1073
+ events:
1074
+ parsed.data.events ??
1075
+ (parsed.data.channel === "expo"
1076
+ ? ["mail"]
1077
+ : ["callWake"]),
1047
1078
  platform: parsed.data.platform ?? null,
1048
1079
  token: parsed.data.token,
1049
1080
  userID: getUser(req).userID,