@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.
- 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 +291 -14
- 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 +43 -52
- 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/cliPasskeyPage.js +1 -1
- 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 +7 -11
- package/dist/server/index.js.map +1 -1
- package/dist/server/passkey.js +3 -4
- package/dist/server/passkey.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 +433 -20
- package/src/NotificationService.ts +4 -428
- package/src/Spire.ts +77 -103
- package/src/__tests__/Database.spec.ts +36 -3
- package/src/__tests__/billing.spec.ts +282 -0
- package/src/__tests__/connectAuth.spec.ts +120 -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/cliPasskeyPage.ts +1 -1
- package/src/server/entitlements.ts +104 -0
- package/src/server/index.ts +7 -16
- package/src/server/passkey.ts +3 -4
- 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;
|
|
@@ -420,13 +457,15 @@ export class Database extends EventEmitter {
|
|
|
420
457
|
regPayload.username,
|
|
421
458
|
userID,
|
|
422
459
|
);
|
|
423
|
-
|
|
424
|
-
typeof regPayload.password
|
|
425
|
-
regPayload.password.length
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
return "
|
|
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:
|
|
2101
|
+
channel: "expo",
|
|
1689
2102
|
enabled: Boolean(row.enabled),
|
|
1690
2103
|
events: decodeNotificationEvents(row.events),
|
|
1691
2104
|
};
|