@vex-chat/spire 2.4.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 (33) 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/Database.d.ts +39 -1
  5. package/dist/Database.js +285 -2
  6. package/dist/Database.js.map +1 -1
  7. package/dist/db/schema.d.ts +50 -0
  8. package/dist/migrations/2026-06-24_account-entitlements.d.ts +8 -0
  9. package/dist/migrations/2026-06-24_account-entitlements.js +20 -0
  10. package/dist/migrations/2026-06-24_account-entitlements.js.map +1 -0
  11. package/dist/migrations/2026-07-02_store-subscriptions.d.ts +8 -0
  12. package/dist/migrations/2026-07-02_store-subscriptions.js +83 -0
  13. package/dist/migrations/2026-07-02_store-subscriptions.js.map +1 -0
  14. package/dist/server/billing.d.ts +14 -0
  15. package/dist/server/billing.js +234 -0
  16. package/dist/server/billing.js.map +1 -0
  17. package/dist/server/entitlements.d.ts +8 -0
  18. package/dist/server/entitlements.js +71 -0
  19. package/dist/server/entitlements.js.map +1 -0
  20. package/dist/server/index.js +6 -0
  21. package/dist/server/index.js.map +1 -1
  22. package/package.json +4 -4
  23. package/src/BillingVerification.ts +800 -0
  24. package/src/Database.ts +422 -2
  25. package/src/__tests__/Database.spec.ts +6 -3
  26. package/src/__tests__/billing.spec.ts +282 -0
  27. package/src/__tests__/entitlements.spec.ts +241 -0
  28. package/src/db/schema.ts +66 -1
  29. package/src/migrations/2026-06-24_account-entitlements.ts +23 -0
  30. package/src/migrations/2026-07-02_store-subscriptions.ts +92 -0
  31. package/src/server/billing.ts +361 -0
  32. package/src/server/entitlements.ts +104 -0
  33. package/src/server/index.ts +6 -0
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
@@ -803,6 +842,78 @@ export class Database extends EventEmitter {
803
842
  .executeTakeFirst();
804
843
  }
805
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
+
806
917
  public async recoverDevice(
807
918
  owner: string,
808
919
  payload: DevicePayload,
@@ -945,6 +1056,29 @@ export class Database extends EventEmitter {
945
1056
  });
946
1057
  }
947
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
+
948
1082
  /**
949
1083
  * Retrives a list of users that should be notified when a specific resourceID
950
1084
  * experiences changes.
@@ -968,6 +1102,28 @@ export class Database extends EventEmitter {
968
1102
  return users;
969
1103
  }
970
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
+
971
1127
  public async retrieveChannel(channelID: string): Promise<Channel | null> {
972
1128
  const channels: Channel[] = await this.db
973
1129
  .selectFrom("channels")
@@ -1337,6 +1493,37 @@ export class Database extends EventEmitter {
1337
1493
  return rows.map(toServer);
1338
1494
  }
1339
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
+
1340
1527
  // The identifier is matched as either a userID (UUID branch) or a
1341
1528
  // username (string branch). Username comparison is case-folded so
1342
1529
  // `User` and `user` resolve to the same row regardless of how the
@@ -1499,6 +1686,147 @@ export class Database extends EventEmitter {
1499
1686
  }
1500
1687
  }
1501
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
+
1502
1830
  private async init(): Promise<void> {
1503
1831
  const migrator = new Migrator({
1504
1832
  db: this.db,
@@ -1588,6 +1916,54 @@ export async function verifyPassword(
1588
1916
  return { needsRehash: valid, valid };
1589
1917
  }
1590
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
+
1591
1967
  function decodeNotificationEvents(events: string): string[] {
1592
1968
  try {
1593
1969
  const parsed: unknown = JSON.parse(events);
@@ -1610,6 +1986,14 @@ function encodeNotificationEvents(events: string[]): string {
1610
1986
  return JSON.stringify(unique.length > 0 ? unique : ["mail"]);
1611
1987
  }
1612
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
+
1613
1997
  // Mirrors `Spire.normalizeRegistrationUsername` — kept in sync so a
1614
1998
  // caller invoking `createUser` directly (e.g. tests, future internal
1615
1999
  // flows) gets the same lowercase canonicalization the public
@@ -1627,6 +2011,42 @@ function normalizeRegistrationUsername(
1627
2011
  return `key_${seed}`;
1628
2012
  }
1629
2013
 
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
+ };
2048
+ }
2049
+
1630
2050
  function toDevice(row: {
1631
2051
  deleted: number;
1632
2052
  deviceID: string;
@@ -298,23 +298,26 @@ describe("Database", () => {
298
298
  sender: "sender-a",
299
299
  time,
300
300
  });
301
+ const baseTimeMs = Date.now() - 60_000;
302
+ const isoTime = (offsetMs: number) =>
303
+ new Date(baseTimeMs + offsetMs).toISOString();
301
304
 
302
305
  await provider["db"]
303
306
  .insertInto("mail")
304
307
  .values([
305
308
  mail(
306
309
  "00000000-0000-0000-0000-000000000003",
307
- "2026-05-07T12:00:02.000Z",
310
+ isoTime(2_000),
308
311
  "06",
309
312
  ),
310
313
  mail(
311
314
  "00000000-0000-0000-0000-000000000001",
312
- "2026-05-07T12:00:00.000Z",
315
+ isoTime(0),
313
316
  "04",
314
317
  ),
315
318
  mail(
316
319
  "00000000-0000-0000-0000-000000000002",
317
- "2026-05-07T12:00:01.000Z",
320
+ isoTime(1_000),
318
321
  "05",
319
322
  ),
320
323
  ])