@vex-chat/spire 2.5.0 → 2.6.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 (48) 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 +285 -8
  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 +5 -21
  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/entitlements.d.ts +8 -0
  25. package/dist/server/entitlements.js +71 -0
  26. package/dist/server/entitlements.js.map +1 -0
  27. package/dist/server/index.js +6 -0
  28. package/dist/server/index.js.map +1 -1
  29. package/package.json +4 -4
  30. package/src/BillingVerification.ts +800 -0
  31. package/src/ClientManager.ts +9 -23
  32. package/src/Database.ts +423 -12
  33. package/src/NotificationService.ts +4 -428
  34. package/src/Spire.ts +21 -52
  35. package/src/__tests__/Database.spec.ts +6 -3
  36. package/src/__tests__/billing.spec.ts +282 -0
  37. package/src/__tests__/entitlements.spec.ts +241 -0
  38. package/src/__tests__/notifyFanout.spec.ts +0 -136
  39. package/src/db/schema.ts +66 -1
  40. package/src/migrations/2026-06-24_account-entitlements.ts +23 -0
  41. package/src/migrations/2026-07-02_store-subscriptions.ts +92 -0
  42. package/src/server/billing.ts +361 -0
  43. package/src/server/entitlements.ts +104 -0
  44. package/src/server/index.ts +6 -0
  45. package/dist/server/callWake.d.ts +0 -13
  46. package/dist/server/callWake.js +0 -18
  47. package/dist/server/callWake.js.map +0 -1
  48. 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;
@@ -805,6 +842,78 @@ export class Database extends EventEmitter {
805
842
  .executeTakeFirst();
806
843
  }
807
844
 
845
+ public async recalculateStoreEntitlements(
846
+ userID: string,
847
+ ): Promise<AccountEntitlements> {
848
+ const subscriptions = await this.retrieveBillingSubscriptions(userID);
849
+ const nowMs = Date.now();
850
+ const active = subscriptions
851
+ .filter((subscription) =>
852
+ billingStatusCarriesEntitlement(subscription.status),
853
+ )
854
+ .filter((subscription) => {
855
+ if (!subscription.expiresAt) {
856
+ return true;
857
+ }
858
+ const expiresAtMs = Date.parse(subscription.expiresAt);
859
+ return Number.isFinite(expiresAtMs) && expiresAtMs > nowMs;
860
+ })
861
+ .sort((a, b) => {
862
+ const tierDelta =
863
+ accountTierRank(b.tier) - accountTierRank(a.tier);
864
+ if (tierDelta !== 0) {
865
+ return tierDelta;
866
+ }
867
+ return expiryRank(b.expiresAt) - expiryRank(a.expiresAt);
868
+ });
869
+
870
+ const best = active[0];
871
+ if (best) {
872
+ return this.setAccountEntitlementTier(userID, best.tier, {
873
+ expiresAt: best.expiresAt,
874
+ source: "store",
875
+ });
876
+ }
877
+
878
+ const current = await this.retrieveAccountEntitlements(userID);
879
+ if (current.source !== "store") {
880
+ return current;
881
+ }
882
+
883
+ return this.setAccountEntitlementTier(userID, "free", {
884
+ expiresAt: null,
885
+ source: "store",
886
+ });
887
+ }
888
+
889
+ public async recordStoreTransaction(
890
+ input: StoreTransactionRecordInput,
891
+ ): Promise<void> {
892
+ const subscription = await this.db
893
+ .selectFrom("billing_store_subscriptions")
894
+ .selectAll()
895
+ .where("subscriptionID", "=", input.subscriptionID)
896
+ .limit(1)
897
+ .executeTakeFirstOrThrow();
898
+
899
+ await this.db
900
+ .insertInto("billing_store_transactions")
901
+ .values({
902
+ environment: subscription.environment,
903
+ eventType: input.eventType,
904
+ externalTransactionID: input.externalTransactionID ?? null,
905
+ platform: subscription.platform,
906
+ processedAt: new Date().toISOString(),
907
+ purchaseTokenHash: subscription.purchaseTokenHash,
908
+ rawPayload: JSON.stringify(input.rawPayload),
909
+ storeProductID: subscription.storeProductID,
910
+ subscriptionID: input.subscriptionID,
911
+ transactionID: crypto.randomUUID(),
912
+ userID: input.userID,
913
+ })
914
+ .execute();
915
+ }
916
+
808
917
  public async recoverDevice(
809
918
  owner: string,
810
919
  payload: DevicePayload,
@@ -947,6 +1056,29 @@ export class Database extends EventEmitter {
947
1056
  });
948
1057
  }
949
1058
 
1059
+ public async retrieveAccountEntitlements(
1060
+ userID: string,
1061
+ ): Promise<AccountEntitlements> {
1062
+ const row = await this.db
1063
+ .selectFrom("account_entitlements")
1064
+ .selectAll()
1065
+ .where("userID", "=", userID)
1066
+ .limit(1)
1067
+ .executeTakeFirst();
1068
+
1069
+ if (!row) {
1070
+ return buildAccountEntitlements({ userID });
1071
+ }
1072
+
1073
+ return buildAccountEntitlements({
1074
+ expiresAt: row.expiresAt,
1075
+ refreshedAt: row.updatedAt,
1076
+ source: parseAccountEntitlementSource(row.source),
1077
+ tier: parseAccountTier(row.tier),
1078
+ userID,
1079
+ });
1080
+ }
1081
+
950
1082
  /**
951
1083
  * Retrives a list of users that should be notified when a specific resourceID
952
1084
  * experiences changes.
@@ -970,6 +1102,28 @@ export class Database extends EventEmitter {
970
1102
  return users;
971
1103
  }
972
1104
 
1105
+ public async retrieveBillingAccountState(
1106
+ userID: string,
1107
+ ): Promise<BillingAccountState> {
1108
+ return {
1109
+ entitlements: await this.retrieveAccountEntitlements(userID),
1110
+ subscriptions: await this.retrieveBillingSubscriptions(userID),
1111
+ };
1112
+ }
1113
+
1114
+ public async retrieveBillingSubscriptions(
1115
+ userID: string,
1116
+ ): Promise<BillingSubscription[]> {
1117
+ const rows = await this.db
1118
+ .selectFrom("billing_store_subscriptions")
1119
+ .selectAll()
1120
+ .where("userID", "=", userID)
1121
+ .orderBy("updatedAt", "desc")
1122
+ .execute();
1123
+
1124
+ return rows.map(toBillingSubscription);
1125
+ }
1126
+
973
1127
  public async retrieveChannel(channelID: string): Promise<Channel | null> {
974
1128
  const channels: Channel[] = await this.db
975
1129
  .selectFrom("channels")
@@ -1339,6 +1493,37 @@ export class Database extends EventEmitter {
1339
1493
  return rows.map(toServer);
1340
1494
  }
1341
1495
 
1496
+ public async retrieveStoreSubscriptionOwner(args: {
1497
+ environment: BillingEnvironment;
1498
+ externalOriginalID?: null | string | undefined;
1499
+ platform: BillingPlatform;
1500
+ purchaseToken?: null | string | undefined;
1501
+ }): Promise<null | string> {
1502
+ const purchaseTokenHash = args.purchaseToken
1503
+ ? billingPurchaseTokenHash(
1504
+ args.platform,
1505
+ args.environment,
1506
+ args.purchaseToken,
1507
+ )
1508
+ : null;
1509
+ const existing = await this.findExistingStoreSubscription({
1510
+ environment: args.environment,
1511
+ externalOriginalID: args.externalOriginalID ?? null,
1512
+ platform: args.platform,
1513
+ purchaseTokenHash,
1514
+ });
1515
+ if (!existing) {
1516
+ return null;
1517
+ }
1518
+ const row = await this.db
1519
+ .selectFrom("billing_store_subscriptions")
1520
+ .select(["userID"])
1521
+ .where("subscriptionID", "=", existing.subscriptionID)
1522
+ .limit(1)
1523
+ .executeTakeFirst();
1524
+ return row?.userID ?? null;
1525
+ }
1526
+
1342
1527
  // The identifier is matched as either a userID (UUID branch) or a
1343
1528
  // username (string branch). Username comparison is case-folded so
1344
1529
  // `User` and `user` resolve to the same row regardless of how the
@@ -1501,6 +1686,147 @@ export class Database extends EventEmitter {
1501
1686
  }
1502
1687
  }
1503
1688
 
1689
+ public async setAccountEntitlementTier(
1690
+ userID: string,
1691
+ tier: AccountTier,
1692
+ options?: {
1693
+ expiresAt?: null | string | undefined;
1694
+ source?: AccountEntitlementSource | undefined;
1695
+ },
1696
+ ): Promise<AccountEntitlements> {
1697
+ const parsedTier = AccountTierSchema.parse(tier);
1698
+ const source = AccountEntitlementSourceSchema.parse(
1699
+ options?.source ?? "dev_override",
1700
+ );
1701
+ const expiresAt = options?.expiresAt ?? null;
1702
+ const updatedAt = new Date().toISOString();
1703
+
1704
+ await this.db
1705
+ .insertInto("account_entitlements")
1706
+ .values({
1707
+ expiresAt,
1708
+ source,
1709
+ tier: parsedTier,
1710
+ updatedAt,
1711
+ userID,
1712
+ })
1713
+ .onConflict((oc) =>
1714
+ oc.column("userID").doUpdateSet({
1715
+ expiresAt,
1716
+ source,
1717
+ tier: parsedTier,
1718
+ updatedAt,
1719
+ }),
1720
+ )
1721
+ .execute();
1722
+
1723
+ return buildAccountEntitlements({
1724
+ expiresAt,
1725
+ refreshedAt: updatedAt,
1726
+ source,
1727
+ tier: parsedTier,
1728
+ userID,
1729
+ });
1730
+ }
1731
+
1732
+ public async upsertStoreSubscription(
1733
+ input: StoreSubscriptionUpsertInput,
1734
+ ): Promise<BillingSubscription> {
1735
+ const now = new Date().toISOString();
1736
+ const purchaseTokenHash = input.purchaseToken
1737
+ ? billingPurchaseTokenHash(
1738
+ input.platform,
1739
+ input.environment,
1740
+ input.purchaseToken,
1741
+ )
1742
+ : null;
1743
+ const existing = await this.findExistingStoreSubscription({
1744
+ environment: input.environment,
1745
+ externalOriginalID: input.externalOriginalID ?? null,
1746
+ platform: input.platform,
1747
+ purchaseTokenHash,
1748
+ });
1749
+ const subscriptionID = existing?.subscriptionID ?? crypto.randomUUID();
1750
+ const row = {
1751
+ environment: input.environment,
1752
+ expiresAt: input.expiresAt,
1753
+ externalOriginalID: input.externalOriginalID ?? null,
1754
+ externalTransactionID: input.externalTransactionID ?? null,
1755
+ platform: input.platform,
1756
+ productID: input.productID,
1757
+ purchaseToken: input.purchaseToken ?? null,
1758
+ purchaseTokenHash,
1759
+ rawPayload: JSON.stringify(input.rawPayload),
1760
+ status: input.status,
1761
+ storeProductID: input.storeProductID,
1762
+ tier: input.tier,
1763
+ updatedAt: now,
1764
+ userID: input.userID,
1765
+ };
1766
+
1767
+ if (existing) {
1768
+ await this.db
1769
+ .updateTable("billing_store_subscriptions")
1770
+ .set(row)
1771
+ .where("subscriptionID", "=", subscriptionID)
1772
+ .execute();
1773
+ } else {
1774
+ await this.db
1775
+ .insertInto("billing_store_subscriptions")
1776
+ .values({
1777
+ ...row,
1778
+ createdAt: now,
1779
+ subscriptionID,
1780
+ })
1781
+ .execute();
1782
+ }
1783
+
1784
+ const saved = await this.db
1785
+ .selectFrom("billing_store_subscriptions")
1786
+ .selectAll()
1787
+ .where("subscriptionID", "=", subscriptionID)
1788
+ .executeTakeFirstOrThrow();
1789
+
1790
+ return toBillingSubscription(saved);
1791
+ }
1792
+
1793
+ private async findExistingStoreSubscription(args: {
1794
+ environment: BillingEnvironment;
1795
+ externalOriginalID: null | string;
1796
+ platform: BillingPlatform;
1797
+ purchaseTokenHash: null | string;
1798
+ }): Promise<null | { subscriptionID: string }> {
1799
+ if (args.externalOriginalID) {
1800
+ const byOriginal = await this.db
1801
+ .selectFrom("billing_store_subscriptions")
1802
+ .select(["subscriptionID"])
1803
+ .where("platform", "=", args.platform)
1804
+ .where("environment", "=", args.environment)
1805
+ .where("externalOriginalID", "=", args.externalOriginalID)
1806
+ .limit(1)
1807
+ .executeTakeFirst();
1808
+ if (byOriginal) {
1809
+ return byOriginal;
1810
+ }
1811
+ }
1812
+
1813
+ if (args.purchaseTokenHash) {
1814
+ const byToken = await this.db
1815
+ .selectFrom("billing_store_subscriptions")
1816
+ .select(["subscriptionID"])
1817
+ .where("platform", "=", args.platform)
1818
+ .where("environment", "=", args.environment)
1819
+ .where("purchaseTokenHash", "=", args.purchaseTokenHash)
1820
+ .limit(1)
1821
+ .executeTakeFirst();
1822
+ if (byToken) {
1823
+ return byToken;
1824
+ }
1825
+ }
1826
+
1827
+ return null;
1828
+ }
1829
+
1504
1830
  private async init(): Promise<void> {
1505
1831
  const migrator = new Migrator({
1506
1832
  db: this.db,
@@ -1590,6 +1916,54 @@ export async function verifyPassword(
1590
1916
  return { needsRehash: valid, valid };
1591
1917
  }
1592
1918
 
1919
+ function accountTierRank(tier: AccountTier): number {
1920
+ switch (tier) {
1921
+ case "free":
1922
+ return 0;
1923
+ case "plus":
1924
+ return 1;
1925
+ case "pro":
1926
+ return 2;
1927
+ }
1928
+ }
1929
+
1930
+ function billingEnvironmentFromRow(environment: string): BillingEnvironment {
1931
+ const parsed = BillingEnvironmentSchema.safeParse(environment);
1932
+ return parsed.success ? parsed.data : "production";
1933
+ }
1934
+
1935
+ function billingPlatformFromRow(platform: string): BillingPlatform {
1936
+ const parsed = BillingPlatformSchema.safeParse(platform);
1937
+ return parsed.success ? parsed.data : "apple_app_store";
1938
+ }
1939
+
1940
+ function billingPurchaseTokenHash(
1941
+ platform: BillingPlatform,
1942
+ environment: BillingEnvironment,
1943
+ token: string,
1944
+ ): string {
1945
+ return createHash("sha256")
1946
+ .update(`${platform}:${environment}:${token}`)
1947
+ .digest("hex");
1948
+ }
1949
+
1950
+ function billingStatusCarriesEntitlement(
1951
+ status: BillingSubscriptionStatus,
1952
+ ): boolean {
1953
+ return (
1954
+ status === "active" ||
1955
+ status === "billing_retry" ||
1956
+ status === "grace_period"
1957
+ );
1958
+ }
1959
+
1960
+ function billingSubscriptionStatusFromRow(
1961
+ status: string,
1962
+ ): BillingSubscriptionStatus {
1963
+ const parsed = BillingSubscriptionStatusSchema.safeParse(status);
1964
+ return parsed.success ? parsed.data : "pending";
1965
+ }
1966
+
1593
1967
  function decodeNotificationEvents(events: string): string[] {
1594
1968
  try {
1595
1969
  const parsed: unknown = JSON.parse(events);
@@ -1612,6 +1986,14 @@ function encodeNotificationEvents(events: string[]): string {
1612
1986
  return JSON.stringify(unique.length > 0 ? unique : ["mail"]);
1613
1987
  }
1614
1988
 
1989
+ function expiryRank(expiresAt: null | string): number {
1990
+ if (!expiresAt) {
1991
+ return Number.MAX_SAFE_INTEGER;
1992
+ }
1993
+ const value = Date.parse(expiresAt);
1994
+ return Number.isFinite(value) ? value : 0;
1995
+ }
1996
+
1615
1997
  // Mirrors `Spire.normalizeRegistrationUsername` — kept in sync so a
1616
1998
  // caller invoking `createUser` directly (e.g. tests, future internal
1617
1999
  // flows) gets the same lowercase canonicalization the public
@@ -1629,11 +2011,40 @@ function normalizeRegistrationUsername(
1629
2011
  return `key_${seed}`;
1630
2012
  }
1631
2013
 
1632
- function parseNotificationChannel(channel: string): NotificationChannel {
1633
- if (channel === "apnsVoip" || channel === "expo" || channel === "fcmCall") {
1634
- return channel;
1635
- }
1636
- return "expo";
2014
+ function parseAccountEntitlementSource(
2015
+ source: string,
2016
+ ): AccountEntitlementSource {
2017
+ const parsed = AccountEntitlementSourceSchema.safeParse(source);
2018
+ return parsed.success ? parsed.data : "default";
2019
+ }
2020
+
2021
+ function parseAccountTier(tier: string): AccountTier {
2022
+ const parsed = AccountTierSchema.safeParse(tier);
2023
+ return parsed.success ? parsed.data : "free";
2024
+ }
2025
+
2026
+ function toBillingSubscription(row: {
2027
+ environment: string;
2028
+ expiresAt: null | string;
2029
+ platform: string;
2030
+ productID: string;
2031
+ status: string;
2032
+ storeProductID: string;
2033
+ subscriptionID: string;
2034
+ tier: string;
2035
+ updatedAt: string;
2036
+ }): BillingSubscription {
2037
+ return {
2038
+ environment: billingEnvironmentFromRow(row.environment),
2039
+ expiresAt: row.expiresAt,
2040
+ platform: billingPlatformFromRow(row.platform),
2041
+ productID: row.productID,
2042
+ status: billingSubscriptionStatusFromRow(row.status),
2043
+ storeProductID: row.storeProductID,
2044
+ subscriptionID: row.subscriptionID,
2045
+ tier: parseAccountTier(row.tier),
2046
+ updatedAt: row.updatedAt,
2047
+ };
1637
2048
  }
1638
2049
 
1639
2050
  function toDevice(row: {
@@ -1685,7 +2096,7 @@ function toNotificationSubscription(row: {
1685
2096
  }): NotificationSubscription {
1686
2097
  return {
1687
2098
  ...row,
1688
- channel: parseNotificationChannel(row.channel),
2099
+ channel: "expo",
1689
2100
  enabled: Boolean(row.enabled),
1690
2101
  events: decodeNotificationEvents(row.events),
1691
2102
  };