@vex-chat/spire 2.5.0 → 3.0.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.
Files changed (54) hide show
  1. package/dist/BillingVerification.d.ts +40 -0
  2. package/dist/BillingVerification.js +535 -0
  3. package/dist/BillingVerification.js.map +1 -0
  4. package/dist/ClientManager.js +1 -8
  5. package/dist/ClientManager.js.map +1 -1
  6. package/dist/Database.d.ts +41 -4
  7. package/dist/Database.js +291 -14
  8. package/dist/Database.js.map +1 -1
  9. package/dist/NotificationService.d.ts +0 -2
  10. package/dist/NotificationService.js +4 -321
  11. package/dist/NotificationService.js.map +1 -1
  12. package/dist/Spire.js +43 -52
  13. package/dist/Spire.js.map +1 -1
  14. package/dist/db/schema.d.ts +50 -0
  15. package/dist/migrations/2026-06-24_account-entitlements.d.ts +8 -0
  16. package/dist/migrations/2026-06-24_account-entitlements.js +20 -0
  17. package/dist/migrations/2026-06-24_account-entitlements.js.map +1 -0
  18. package/dist/migrations/2026-07-02_store-subscriptions.d.ts +8 -0
  19. package/dist/migrations/2026-07-02_store-subscriptions.js +83 -0
  20. package/dist/migrations/2026-07-02_store-subscriptions.js.map +1 -0
  21. package/dist/server/billing.d.ts +14 -0
  22. package/dist/server/billing.js +234 -0
  23. package/dist/server/billing.js.map +1 -0
  24. package/dist/server/cliPasskeyPage.js +1 -1
  25. package/dist/server/entitlements.d.ts +8 -0
  26. package/dist/server/entitlements.js +71 -0
  27. package/dist/server/entitlements.js.map +1 -0
  28. package/dist/server/index.js +7 -11
  29. package/dist/server/index.js.map +1 -1
  30. package/dist/server/passkey.js +3 -4
  31. package/dist/server/passkey.js.map +1 -1
  32. package/package.json +4 -4
  33. package/src/BillingVerification.ts +800 -0
  34. package/src/ClientManager.ts +9 -23
  35. package/src/Database.ts +433 -20
  36. package/src/NotificationService.ts +4 -428
  37. package/src/Spire.ts +77 -103
  38. package/src/__tests__/Database.spec.ts +36 -3
  39. package/src/__tests__/billing.spec.ts +282 -0
  40. package/src/__tests__/connectAuth.spec.ts +120 -0
  41. package/src/__tests__/entitlements.spec.ts +241 -0
  42. package/src/__tests__/notifyFanout.spec.ts +0 -136
  43. package/src/db/schema.ts +66 -1
  44. package/src/migrations/2026-06-24_account-entitlements.ts +23 -0
  45. package/src/migrations/2026-07-02_store-subscriptions.ts +92 -0
  46. package/src/server/billing.ts +361 -0
  47. package/src/server/cliPasskeyPage.ts +1 -1
  48. package/src/server/entitlements.ts +104 -0
  49. package/src/server/index.ts +7 -16
  50. package/src/server/passkey.ts +3 -4
  51. package/dist/server/callWake.d.ts +0 -13
  52. package/dist/server/callWake.js +0 -18
  53. package/dist/server/callWake.js.map +0 -1
  54. package/src/server/callWake.ts +0 -30
@@ -8,14 +8,8 @@ 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
-
16
11
  const EXPO_PUSH_ENDPOINT = "https://exp.host/--/api/v2/push/send";
17
12
  const EXPO_RECEIPT_ENDPOINT = "https://exp.host/--/api/v2/push/getReceipts";
18
- const FCM_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
19
13
  const EXPO_BATCH_SIZE = 100;
20
14
  const EXPO_RECEIPT_DELAY_MS = 15 * 60 * 1000;
21
15
  const EXPO_REQUEST_TIMEOUT_MS = 10_000;
@@ -33,19 +27,6 @@ export interface NotificationDispatch {
33
27
  userID: string;
34
28
  }
35
29
 
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
-
49
30
  type ExpoErrorDetails = {
50
31
  [key: string]: unknown;
51
32
  error?: string;
@@ -72,12 +53,6 @@ type ExpoPushTicket =
72
53
  status: "ok";
73
54
  };
74
55
 
75
- interface FcmConfig {
76
- clientEmail: string;
77
- privateKey: string;
78
- projectID: string;
79
- }
80
-
81
56
  interface NotificationServiceOptions {
82
57
  receiptDelayMs?: number;
83
58
  }
@@ -234,21 +209,9 @@ export class NotificationService {
234
209
  ? { ...query, deviceID: dispatch.deviceID }
235
210
  : query,
236
211
  );
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", {
212
+ const expoSubscriptions = subscriptions;
213
+ if (expoSubscriptions.length === 0) {
214
+ console.info("[spire-notify] no Expo push subscriptions", {
252
215
  deviceScoped: Boolean(dispatch.deviceID),
253
216
  event: dispatch.event,
254
217
  transmissionID: dispatch.transmissionID,
@@ -261,14 +224,6 @@ export class NotificationService {
261
224
  const batch = expoSubscriptions.slice(i, i + EXPO_BATCH_SIZE);
262
225
  await this.sendExpoBatch(batch, dispatch);
263
226
  }
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
- }
272
227
  }
273
228
 
274
229
  private async notifyPushIfMailPending(
@@ -373,59 +328,6 @@ export class NotificationService {
373
328
  (timer as { unref?: () => void }).unref?.();
374
329
  }
375
330
 
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
-
429
331
  private async sendExpoBatch(
430
332
  subscriptions: NotificationSubscription[],
431
333
  dispatch: NotificationDispatch,
@@ -517,68 +419,10 @@ export class NotificationService {
517
419
  }
518
420
  }
519
421
 
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
-
578
422
  private startPush(dispatch: NotificationDispatch): void {
579
423
  void this.notifyPush(dispatch).catch((err: unknown) => {
580
424
  // Push is best-effort; websocket/inbox delivery remain authoritative.
581
- console.warn("[spire-notify] push fanout failed", {
425
+ console.warn("[spire-notify] Expo push fanout failed", {
582
426
  event: dispatch.event,
583
427
  message: err instanceof Error ? err.message : String(err),
584
428
  transmissionID: dispatch.transmissionID,
@@ -588,19 +432,7 @@ export class NotificationService {
588
432
  }
589
433
  }
590
434
 
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
-
602
435
  function bodyForEvent(event: string): string | undefined {
603
- if (event === "callWake") return "Incoming voice call.";
604
436
  if (event === "callInvite") return "Incoming voice call.";
605
437
  if (event === "mail") return undefined;
606
438
  if (event === "deviceRequest") return "Review the device request.";
@@ -608,18 +440,6 @@ function bodyForEvent(event: string): string | undefined {
608
440
  return "Open Vex for the latest update.";
609
441
  }
610
442
 
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
-
623
443
  function expoMessageForSubscription(
624
444
  subscription: NotificationSubscription,
625
445
  dispatch: NotificationDispatch,
@@ -642,7 +462,6 @@ function expoMessageForSubscription(
642
462
  const title = titleForEvent(dispatch.event);
643
463
  const body = bodyForEvent(dispatch.event);
644
464
  const data = {
645
- ...notifyDataFields(dispatch.data),
646
465
  deviceID: dispatch.deviceID ?? null,
647
466
  event: dispatch.event,
648
467
  title,
@@ -674,73 +493,6 @@ function expoMessageForSubscription(
674
493
  return message;
675
494
  }
676
495
 
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
-
744
496
  async function fetchWithTimeout(
745
497
  input: string,
746
498
  init: RequestInit,
@@ -795,24 +547,6 @@ function normalizeExpoTickets(data: unknown): ExpoPushTicket[] {
795
547
  return tickets.filter(isExpoPushTicket);
796
548
  }
797
549
 
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
-
816
550
  function parseExpoPushResponse(raw: unknown): ParsedExpoPushResponse {
817
551
  if (!isRecord(raw)) {
818
552
  return { errors: [], tickets: [] };
@@ -844,153 +578,6 @@ function parseExpoReceipts(data: unknown): Record<string, ExpoPushReceipt> {
844
578
  return receipts;
845
579
  }
846
580
 
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
-
994
581
  function shouldDeferMailPush(
995
582
  dispatch: NotificationDispatch,
996
583
  websocketDeliveries: number,
@@ -1013,18 +600,7 @@ function shouldSendHeadlessPush(dispatch: NotificationDispatch): boolean {
1013
600
  );
1014
601
  }
1015
602
 
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
-
1026
603
  function titleForEvent(event: string): string {
1027
- if (event === "callWake") return "Incoming Vex call";
1028
604
  if (event === "callInvite") return "Incoming Vex call";
1029
605
  if (event === "mail") return "New Message";
1030
606
  if (event === "deviceRequest") return "Device approval request";