@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.
- package/dist/BillingVerification.d.ts +40 -0
- package/dist/BillingVerification.js +535 -0
- package/dist/BillingVerification.js.map +1 -0
- package/dist/ClientManager.js +1 -8
- package/dist/ClientManager.js.map +1 -1
- package/dist/Database.d.ts +41 -4
- package/dist/Database.js +285 -8
- package/dist/Database.js.map +1 -1
- package/dist/NotificationService.d.ts +0 -2
- package/dist/NotificationService.js +4 -321
- package/dist/NotificationService.js.map +1 -1
- package/dist/Spire.js +5 -21
- package/dist/Spire.js.map +1 -1
- package/dist/db/schema.d.ts +50 -0
- package/dist/migrations/2026-06-24_account-entitlements.d.ts +8 -0
- package/dist/migrations/2026-06-24_account-entitlements.js +20 -0
- package/dist/migrations/2026-06-24_account-entitlements.js.map +1 -0
- package/dist/migrations/2026-07-02_store-subscriptions.d.ts +8 -0
- package/dist/migrations/2026-07-02_store-subscriptions.js +83 -0
- package/dist/migrations/2026-07-02_store-subscriptions.js.map +1 -0
- package/dist/server/billing.d.ts +14 -0
- package/dist/server/billing.js +234 -0
- package/dist/server/billing.js.map +1 -0
- package/dist/server/entitlements.d.ts +8 -0
- package/dist/server/entitlements.js +71 -0
- package/dist/server/entitlements.js.map +1 -0
- package/dist/server/index.js +6 -0
- package/dist/server/index.js.map +1 -1
- package/package.json +4 -4
- package/src/BillingVerification.ts +800 -0
- package/src/ClientManager.ts +9 -23
- package/src/Database.ts +423 -12
- package/src/NotificationService.ts +4 -428
- package/src/Spire.ts +21 -52
- package/src/__tests__/Database.spec.ts +6 -3
- package/src/__tests__/billing.spec.ts +282 -0
- package/src/__tests__/entitlements.spec.ts +241 -0
- package/src/__tests__/notifyFanout.spec.ts +0 -136
- package/src/db/schema.ts +66 -1
- package/src/migrations/2026-06-24_account-entitlements.ts +23 -0
- package/src/migrations/2026-07-02_store-subscriptions.ts +92 -0
- package/src/server/billing.ts +361 -0
- package/src/server/entitlements.ts +104 -0
- package/src/server/index.ts +6 -0
- package/dist/server/callWake.d.ts +0 -13
- package/dist/server/callWake.js +0 -18
- package/dist/server/callWake.js.map +0 -1
- package/src/server/callWake.ts +0 -30
package/src/ClientManager.ts
CHANGED
|
@@ -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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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 {
|
|
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:
|
|
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:
|
|
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
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
return "
|
|
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:
|
|
2099
|
+
channel: "expo",
|
|
1689
2100
|
enabled: Boolean(row.enabled),
|
|
1690
2101
|
events: decodeNotificationEvents(row.events),
|
|
1691
2102
|
};
|