@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
@@ -28,7 +28,6 @@ import { MailWSSchema, SocketAuthErrors } from "@vex-chat/types";
28
28
 
29
29
  import { parse as uuidParse, validate as uuidValidate } from "uuid";
30
30
 
31
- import { callWakeDispatchData } from "./server/callWake.ts";
32
31
  import { validateMailIngress } from "./server/mailIngress.ts";
33
32
  import { TOKEN_EXPIRY } from "./Spire.ts";
34
33
  import { createUint8UUID } from "./utils/createUint8UUID.ts";
@@ -334,28 +333,15 @@ export class ClientManager extends EventEmitter {
334
333
  );
335
334
 
336
335
  this.sendSuccess(msg.transmissionID, null);
337
- const callWake = callWakeDispatchData(mail);
338
- if (callWake) {
339
- this.notify(
340
- recipientDevice.owner,
341
- "callWake",
342
- msg.transmissionID,
343
- callWake,
344
- mail.recipient,
345
- undefined,
346
- mail.nonce,
347
- );
348
- } else {
349
- this.notify(
350
- recipientDevice.owner,
351
- "mail",
352
- msg.transmissionID,
353
- null,
354
- mail.recipient,
355
- mail.authorID,
356
- mail.nonce,
357
- );
358
- }
336
+ this.notify(
337
+ recipientDevice.owner,
338
+ "mail",
339
+ msg.transmissionID,
340
+ null,
341
+ mail.recipient,
342
+ mail.authorID,
343
+ mail.nonce,
344
+ );
359
345
  } catch (err: unknown) {
360
346
  this.sendErr(msg.transmissionID, String(err));
361
347
  }
package/src/Database.ts CHANGED
@@ -7,6 +7,14 @@
7
7
  import type { PasskeyRow, ServerDatabase } from "./db/schema.ts";
8
8
  import type { SpireOptions } from "./Spire.ts";
9
9
  import type {
10
+ AccountEntitlements,
11
+ AccountEntitlementSource,
12
+ AccountTier,
13
+ BillingAccountState,
14
+ BillingEnvironment,
15
+ BillingPlatform,
16
+ BillingSubscription,
17
+ BillingSubscriptionStatus,
10
18
  Channel,
11
19
  Device,
12
20
  DevicePayload,
@@ -27,7 +35,7 @@ import type {
27
35
  import type { Migration, MigrationProvider } from "kysely";
28
36
 
29
37
  import { EventEmitter } from "events";
30
- import { pbkdf2Sync } from "node:crypto";
38
+ import { createHash, pbkdf2Sync } from "node:crypto";
31
39
  import { statSync } from "node:fs";
32
40
  import * as fs from "node:fs/promises";
33
41
  import path from "node:path";
@@ -38,12 +46,43 @@ import {
38
46
  getCryptoProfile,
39
47
  XUtils,
40
48
  } from "@vex-chat/crypto";
41
- import { MailType } from "@vex-chat/types";
49
+ import {
50
+ AccountEntitlementSourceSchema,
51
+ AccountTierSchema,
52
+ BillingEnvironmentSchema,
53
+ BillingPlatformSchema,
54
+ BillingSubscriptionStatusSchema,
55
+ buildAccountEntitlements,
56
+ MailType,
57
+ } from "@vex-chat/types";
42
58
 
43
59
  import argon2 from "argon2";
44
60
 
45
61
  import { serverMailRetentionCutoffIso } from "./mailRetention.ts";
46
62
 
63
+ export interface StoreSubscriptionUpsertInput {
64
+ environment: BillingEnvironment;
65
+ expiresAt: null | string;
66
+ externalOriginalID?: null | string | undefined;
67
+ externalTransactionID?: null | string | undefined;
68
+ platform: BillingPlatform;
69
+ productID: string;
70
+ purchaseToken?: null | string | undefined;
71
+ rawPayload: unknown;
72
+ status: BillingSubscriptionStatus;
73
+ storeProductID: string;
74
+ tier: AccountTier;
75
+ userID: string;
76
+ }
77
+
78
+ export interface StoreTransactionRecordInput {
79
+ eventType: string;
80
+ externalTransactionID?: null | string | undefined;
81
+ rawPayload: unknown;
82
+ subscriptionID: string;
83
+ userID: string;
84
+ }
85
+
47
86
  /**
48
87
  * Narrow a plain integer from the `mailType` SQL column to the
49
88
  * `MailType` union (0 = initial, 1 = subsequent). Throws if the
@@ -130,10 +169,8 @@ export interface InternalUserRecord extends UserRecord {
130
169
  hashAlgo: string;
131
170
  }
132
171
 
133
- export type NotificationChannel = "apnsVoip" | "expo" | "fcmCall";
134
-
135
172
  export interface NotificationSubscription {
136
- channel: NotificationChannel;
173
+ channel: "expo";
137
174
  createdAt: string;
138
175
  deviceID: string;
139
176
  enabled: boolean;
@@ -146,7 +183,7 @@ export interface NotificationSubscription {
146
183
  }
147
184
 
148
185
  export interface SaveNotificationSubscriptionInput {
149
- channel: NotificationChannel;
186
+ channel: "expo";
150
187
  deviceID: string;
151
188
  events: string[];
152
189
  platform?: null | string;
@@ -420,13 +457,15 @@ export class Database extends EventEmitter {
420
457
  regPayload.username,
421
458
  userID,
422
459
  );
423
- const passwordHash =
424
- typeof regPayload.password === "string" &&
425
- regPayload.password.length > 0
426
- ? await hashPasswordArgon2(regPayload.password)
427
- : "";
428
- const hashAlgo =
429
- passwordHash.length > 0 ? "argon2id" : "keycluster";
460
+ if (
461
+ typeof regPayload.password !== "string" ||
462
+ regPayload.password.trim().length === 0
463
+ ) {
464
+ throw new Error(
465
+ "Password is required to register a new account.",
466
+ );
467
+ }
468
+ const passwordHash = await hashPasswordArgon2(regPayload.password);
430
469
 
431
470
  const user: UserRecord = {
432
471
  lastSeen: new Date().toISOString(),
@@ -440,7 +479,7 @@ export class Database extends EventEmitter {
440
479
  .insertInto("users")
441
480
  .values({
442
481
  ...user,
443
- hashAlgo,
482
+ hashAlgo: "argon2id",
444
483
  lastSeen: user.lastSeen,
445
484
  })
446
485
  .execute();
@@ -805,6 +844,78 @@ export class Database extends EventEmitter {
805
844
  .executeTakeFirst();
806
845
  }
807
846
 
847
+ public async recalculateStoreEntitlements(
848
+ userID: string,
849
+ ): Promise<AccountEntitlements> {
850
+ const subscriptions = await this.retrieveBillingSubscriptions(userID);
851
+ const nowMs = Date.now();
852
+ const active = subscriptions
853
+ .filter((subscription) =>
854
+ billingStatusCarriesEntitlement(subscription.status),
855
+ )
856
+ .filter((subscription) => {
857
+ if (!subscription.expiresAt) {
858
+ return true;
859
+ }
860
+ const expiresAtMs = Date.parse(subscription.expiresAt);
861
+ return Number.isFinite(expiresAtMs) && expiresAtMs > nowMs;
862
+ })
863
+ .sort((a, b) => {
864
+ const tierDelta =
865
+ accountTierRank(b.tier) - accountTierRank(a.tier);
866
+ if (tierDelta !== 0) {
867
+ return tierDelta;
868
+ }
869
+ return expiryRank(b.expiresAt) - expiryRank(a.expiresAt);
870
+ });
871
+
872
+ const best = active[0];
873
+ if (best) {
874
+ return this.setAccountEntitlementTier(userID, best.tier, {
875
+ expiresAt: best.expiresAt,
876
+ source: "store",
877
+ });
878
+ }
879
+
880
+ const current = await this.retrieveAccountEntitlements(userID);
881
+ if (current.source !== "store") {
882
+ return current;
883
+ }
884
+
885
+ return this.setAccountEntitlementTier(userID, "free", {
886
+ expiresAt: null,
887
+ source: "store",
888
+ });
889
+ }
890
+
891
+ public async recordStoreTransaction(
892
+ input: StoreTransactionRecordInput,
893
+ ): Promise<void> {
894
+ const subscription = await this.db
895
+ .selectFrom("billing_store_subscriptions")
896
+ .selectAll()
897
+ .where("subscriptionID", "=", input.subscriptionID)
898
+ .limit(1)
899
+ .executeTakeFirstOrThrow();
900
+
901
+ await this.db
902
+ .insertInto("billing_store_transactions")
903
+ .values({
904
+ environment: subscription.environment,
905
+ eventType: input.eventType,
906
+ externalTransactionID: input.externalTransactionID ?? null,
907
+ platform: subscription.platform,
908
+ processedAt: new Date().toISOString(),
909
+ purchaseTokenHash: subscription.purchaseTokenHash,
910
+ rawPayload: JSON.stringify(input.rawPayload),
911
+ storeProductID: subscription.storeProductID,
912
+ subscriptionID: input.subscriptionID,
913
+ transactionID: crypto.randomUUID(),
914
+ userID: input.userID,
915
+ })
916
+ .execute();
917
+ }
918
+
808
919
  public async recoverDevice(
809
920
  owner: string,
810
921
  payload: DevicePayload,
@@ -947,6 +1058,29 @@ export class Database extends EventEmitter {
947
1058
  });
948
1059
  }
949
1060
 
1061
+ public async retrieveAccountEntitlements(
1062
+ userID: string,
1063
+ ): Promise<AccountEntitlements> {
1064
+ const row = await this.db
1065
+ .selectFrom("account_entitlements")
1066
+ .selectAll()
1067
+ .where("userID", "=", userID)
1068
+ .limit(1)
1069
+ .executeTakeFirst();
1070
+
1071
+ if (!row) {
1072
+ return buildAccountEntitlements({ userID });
1073
+ }
1074
+
1075
+ return buildAccountEntitlements({
1076
+ expiresAt: row.expiresAt,
1077
+ refreshedAt: row.updatedAt,
1078
+ source: parseAccountEntitlementSource(row.source),
1079
+ tier: parseAccountTier(row.tier),
1080
+ userID,
1081
+ });
1082
+ }
1083
+
950
1084
  /**
951
1085
  * Retrives a list of users that should be notified when a specific resourceID
952
1086
  * experiences changes.
@@ -970,6 +1104,28 @@ export class Database extends EventEmitter {
970
1104
  return users;
971
1105
  }
972
1106
 
1107
+ public async retrieveBillingAccountState(
1108
+ userID: string,
1109
+ ): Promise<BillingAccountState> {
1110
+ return {
1111
+ entitlements: await this.retrieveAccountEntitlements(userID),
1112
+ subscriptions: await this.retrieveBillingSubscriptions(userID),
1113
+ };
1114
+ }
1115
+
1116
+ public async retrieveBillingSubscriptions(
1117
+ userID: string,
1118
+ ): Promise<BillingSubscription[]> {
1119
+ const rows = await this.db
1120
+ .selectFrom("billing_store_subscriptions")
1121
+ .selectAll()
1122
+ .where("userID", "=", userID)
1123
+ .orderBy("updatedAt", "desc")
1124
+ .execute();
1125
+
1126
+ return rows.map(toBillingSubscription);
1127
+ }
1128
+
973
1129
  public async retrieveChannel(channelID: string): Promise<Channel | null> {
974
1130
  const channels: Channel[] = await this.db
975
1131
  .selectFrom("channels")
@@ -1339,6 +1495,37 @@ export class Database extends EventEmitter {
1339
1495
  return rows.map(toServer);
1340
1496
  }
1341
1497
 
1498
+ public async retrieveStoreSubscriptionOwner(args: {
1499
+ environment: BillingEnvironment;
1500
+ externalOriginalID?: null | string | undefined;
1501
+ platform: BillingPlatform;
1502
+ purchaseToken?: null | string | undefined;
1503
+ }): Promise<null | string> {
1504
+ const purchaseTokenHash = args.purchaseToken
1505
+ ? billingPurchaseTokenHash(
1506
+ args.platform,
1507
+ args.environment,
1508
+ args.purchaseToken,
1509
+ )
1510
+ : null;
1511
+ const existing = await this.findExistingStoreSubscription({
1512
+ environment: args.environment,
1513
+ externalOriginalID: args.externalOriginalID ?? null,
1514
+ platform: args.platform,
1515
+ purchaseTokenHash,
1516
+ });
1517
+ if (!existing) {
1518
+ return null;
1519
+ }
1520
+ const row = await this.db
1521
+ .selectFrom("billing_store_subscriptions")
1522
+ .select(["userID"])
1523
+ .where("subscriptionID", "=", existing.subscriptionID)
1524
+ .limit(1)
1525
+ .executeTakeFirst();
1526
+ return row?.userID ?? null;
1527
+ }
1528
+
1342
1529
  // The identifier is matched as either a userID (UUID branch) or a
1343
1530
  // username (string branch). Username comparison is case-folded so
1344
1531
  // `User` and `user` resolve to the same row regardless of how the
@@ -1501,6 +1688,147 @@ export class Database extends EventEmitter {
1501
1688
  }
1502
1689
  }
1503
1690
 
1691
+ public async setAccountEntitlementTier(
1692
+ userID: string,
1693
+ tier: AccountTier,
1694
+ options?: {
1695
+ expiresAt?: null | string | undefined;
1696
+ source?: AccountEntitlementSource | undefined;
1697
+ },
1698
+ ): Promise<AccountEntitlements> {
1699
+ const parsedTier = AccountTierSchema.parse(tier);
1700
+ const source = AccountEntitlementSourceSchema.parse(
1701
+ options?.source ?? "dev_override",
1702
+ );
1703
+ const expiresAt = options?.expiresAt ?? null;
1704
+ const updatedAt = new Date().toISOString();
1705
+
1706
+ await this.db
1707
+ .insertInto("account_entitlements")
1708
+ .values({
1709
+ expiresAt,
1710
+ source,
1711
+ tier: parsedTier,
1712
+ updatedAt,
1713
+ userID,
1714
+ })
1715
+ .onConflict((oc) =>
1716
+ oc.column("userID").doUpdateSet({
1717
+ expiresAt,
1718
+ source,
1719
+ tier: parsedTier,
1720
+ updatedAt,
1721
+ }),
1722
+ )
1723
+ .execute();
1724
+
1725
+ return buildAccountEntitlements({
1726
+ expiresAt,
1727
+ refreshedAt: updatedAt,
1728
+ source,
1729
+ tier: parsedTier,
1730
+ userID,
1731
+ });
1732
+ }
1733
+
1734
+ public async upsertStoreSubscription(
1735
+ input: StoreSubscriptionUpsertInput,
1736
+ ): Promise<BillingSubscription> {
1737
+ const now = new Date().toISOString();
1738
+ const purchaseTokenHash = input.purchaseToken
1739
+ ? billingPurchaseTokenHash(
1740
+ input.platform,
1741
+ input.environment,
1742
+ input.purchaseToken,
1743
+ )
1744
+ : null;
1745
+ const existing = await this.findExistingStoreSubscription({
1746
+ environment: input.environment,
1747
+ externalOriginalID: input.externalOriginalID ?? null,
1748
+ platform: input.platform,
1749
+ purchaseTokenHash,
1750
+ });
1751
+ const subscriptionID = existing?.subscriptionID ?? crypto.randomUUID();
1752
+ const row = {
1753
+ environment: input.environment,
1754
+ expiresAt: input.expiresAt,
1755
+ externalOriginalID: input.externalOriginalID ?? null,
1756
+ externalTransactionID: input.externalTransactionID ?? null,
1757
+ platform: input.platform,
1758
+ productID: input.productID,
1759
+ purchaseToken: input.purchaseToken ?? null,
1760
+ purchaseTokenHash,
1761
+ rawPayload: JSON.stringify(input.rawPayload),
1762
+ status: input.status,
1763
+ storeProductID: input.storeProductID,
1764
+ tier: input.tier,
1765
+ updatedAt: now,
1766
+ userID: input.userID,
1767
+ };
1768
+
1769
+ if (existing) {
1770
+ await this.db
1771
+ .updateTable("billing_store_subscriptions")
1772
+ .set(row)
1773
+ .where("subscriptionID", "=", subscriptionID)
1774
+ .execute();
1775
+ } else {
1776
+ await this.db
1777
+ .insertInto("billing_store_subscriptions")
1778
+ .values({
1779
+ ...row,
1780
+ createdAt: now,
1781
+ subscriptionID,
1782
+ })
1783
+ .execute();
1784
+ }
1785
+
1786
+ const saved = await this.db
1787
+ .selectFrom("billing_store_subscriptions")
1788
+ .selectAll()
1789
+ .where("subscriptionID", "=", subscriptionID)
1790
+ .executeTakeFirstOrThrow();
1791
+
1792
+ return toBillingSubscription(saved);
1793
+ }
1794
+
1795
+ private async findExistingStoreSubscription(args: {
1796
+ environment: BillingEnvironment;
1797
+ externalOriginalID: null | string;
1798
+ platform: BillingPlatform;
1799
+ purchaseTokenHash: null | string;
1800
+ }): Promise<null | { subscriptionID: string }> {
1801
+ if (args.externalOriginalID) {
1802
+ const byOriginal = await this.db
1803
+ .selectFrom("billing_store_subscriptions")
1804
+ .select(["subscriptionID"])
1805
+ .where("platform", "=", args.platform)
1806
+ .where("environment", "=", args.environment)
1807
+ .where("externalOriginalID", "=", args.externalOriginalID)
1808
+ .limit(1)
1809
+ .executeTakeFirst();
1810
+ if (byOriginal) {
1811
+ return byOriginal;
1812
+ }
1813
+ }
1814
+
1815
+ if (args.purchaseTokenHash) {
1816
+ const byToken = await this.db
1817
+ .selectFrom("billing_store_subscriptions")
1818
+ .select(["subscriptionID"])
1819
+ .where("platform", "=", args.platform)
1820
+ .where("environment", "=", args.environment)
1821
+ .where("purchaseTokenHash", "=", args.purchaseTokenHash)
1822
+ .limit(1)
1823
+ .executeTakeFirst();
1824
+ if (byToken) {
1825
+ return byToken;
1826
+ }
1827
+ }
1828
+
1829
+ return null;
1830
+ }
1831
+
1504
1832
  private async init(): Promise<void> {
1505
1833
  const migrator = new Migrator({
1506
1834
  db: this.db,
@@ -1590,6 +1918,54 @@ export async function verifyPassword(
1590
1918
  return { needsRehash: valid, valid };
1591
1919
  }
1592
1920
 
1921
+ function accountTierRank(tier: AccountTier): number {
1922
+ switch (tier) {
1923
+ case "free":
1924
+ return 0;
1925
+ case "plus":
1926
+ return 1;
1927
+ case "pro":
1928
+ return 2;
1929
+ }
1930
+ }
1931
+
1932
+ function billingEnvironmentFromRow(environment: string): BillingEnvironment {
1933
+ const parsed = BillingEnvironmentSchema.safeParse(environment);
1934
+ return parsed.success ? parsed.data : "production";
1935
+ }
1936
+
1937
+ function billingPlatformFromRow(platform: string): BillingPlatform {
1938
+ const parsed = BillingPlatformSchema.safeParse(platform);
1939
+ return parsed.success ? parsed.data : "apple_app_store";
1940
+ }
1941
+
1942
+ function billingPurchaseTokenHash(
1943
+ platform: BillingPlatform,
1944
+ environment: BillingEnvironment,
1945
+ token: string,
1946
+ ): string {
1947
+ return createHash("sha256")
1948
+ .update(`${platform}:${environment}:${token}`)
1949
+ .digest("hex");
1950
+ }
1951
+
1952
+ function billingStatusCarriesEntitlement(
1953
+ status: BillingSubscriptionStatus,
1954
+ ): boolean {
1955
+ return (
1956
+ status === "active" ||
1957
+ status === "billing_retry" ||
1958
+ status === "grace_period"
1959
+ );
1960
+ }
1961
+
1962
+ function billingSubscriptionStatusFromRow(
1963
+ status: string,
1964
+ ): BillingSubscriptionStatus {
1965
+ const parsed = BillingSubscriptionStatusSchema.safeParse(status);
1966
+ return parsed.success ? parsed.data : "pending";
1967
+ }
1968
+
1593
1969
  function decodeNotificationEvents(events: string): string[] {
1594
1970
  try {
1595
1971
  const parsed: unknown = JSON.parse(events);
@@ -1612,6 +1988,14 @@ function encodeNotificationEvents(events: string[]): string {
1612
1988
  return JSON.stringify(unique.length > 0 ? unique : ["mail"]);
1613
1989
  }
1614
1990
 
1991
+ function expiryRank(expiresAt: null | string): number {
1992
+ if (!expiresAt) {
1993
+ return Number.MAX_SAFE_INTEGER;
1994
+ }
1995
+ const value = Date.parse(expiresAt);
1996
+ return Number.isFinite(value) ? value : 0;
1997
+ }
1998
+
1615
1999
  // Mirrors `Spire.normalizeRegistrationUsername` — kept in sync so a
1616
2000
  // caller invoking `createUser` directly (e.g. tests, future internal
1617
2001
  // flows) gets the same lowercase canonicalization the public
@@ -1629,11 +2013,40 @@ function normalizeRegistrationUsername(
1629
2013
  return `key_${seed}`;
1630
2014
  }
1631
2015
 
1632
- function parseNotificationChannel(channel: string): NotificationChannel {
1633
- if (channel === "apnsVoip" || channel === "expo" || channel === "fcmCall") {
1634
- return channel;
1635
- }
1636
- return "expo";
2016
+ function parseAccountEntitlementSource(
2017
+ source: string,
2018
+ ): AccountEntitlementSource {
2019
+ const parsed = AccountEntitlementSourceSchema.safeParse(source);
2020
+ return parsed.success ? parsed.data : "default";
2021
+ }
2022
+
2023
+ function parseAccountTier(tier: string): AccountTier {
2024
+ const parsed = AccountTierSchema.safeParse(tier);
2025
+ return parsed.success ? parsed.data : "free";
2026
+ }
2027
+
2028
+ function toBillingSubscription(row: {
2029
+ environment: string;
2030
+ expiresAt: null | string;
2031
+ platform: string;
2032
+ productID: string;
2033
+ status: string;
2034
+ storeProductID: string;
2035
+ subscriptionID: string;
2036
+ tier: string;
2037
+ updatedAt: string;
2038
+ }): BillingSubscription {
2039
+ return {
2040
+ environment: billingEnvironmentFromRow(row.environment),
2041
+ expiresAt: row.expiresAt,
2042
+ platform: billingPlatformFromRow(row.platform),
2043
+ productID: row.productID,
2044
+ status: billingSubscriptionStatusFromRow(row.status),
2045
+ storeProductID: row.storeProductID,
2046
+ subscriptionID: row.subscriptionID,
2047
+ tier: parseAccountTier(row.tier),
2048
+ updatedAt: row.updatedAt,
2049
+ };
1637
2050
  }
1638
2051
 
1639
2052
  function toDevice(row: {
@@ -1685,7 +2098,7 @@ function toNotificationSubscription(row: {
1685
2098
  }): NotificationSubscription {
1686
2099
  return {
1687
2100
  ...row,
1688
- channel: parseNotificationChannel(row.channel),
2101
+ channel: "expo",
1689
2102
  enabled: Boolean(row.enabled),
1690
2103
  events: decodeNotificationEvents(row.events),
1691
2104
  };