@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
package/src/db/schema.ts CHANGED
@@ -8,6 +8,63 @@ import type { Insertable, Selectable, Updateable } from "kysely";
8
8
 
9
9
  // ── Table interfaces ────────────────────────────────────────────────────
10
10
 
11
+ export type AccountEntitlementRow = Selectable<AccountEntitlementsTable>;
12
+
13
+ export interface AccountEntitlementsTable {
14
+ expiresAt: null | string;
15
+ source: string;
16
+ tier: string;
17
+ updatedAt: string;
18
+ userID: string;
19
+ }
20
+
21
+ export type AccountEntitlementUpdate = Updateable<AccountEntitlementsTable>;
22
+
23
+ export type BillingStoreSubscriptionRow =
24
+ Selectable<BillingStoreSubscriptionsTable>;
25
+
26
+ export interface BillingStoreSubscriptionsTable {
27
+ createdAt: string;
28
+ environment: string;
29
+ expiresAt: null | string;
30
+ externalOriginalID: null | string;
31
+ externalTransactionID: null | string;
32
+ platform: string;
33
+ productID: string;
34
+ purchaseToken: null | string;
35
+ purchaseTokenHash: null | string;
36
+ rawPayload: string;
37
+ status: string;
38
+ storeProductID: string;
39
+ subscriptionID: string;
40
+ tier: string;
41
+ updatedAt: string;
42
+ userID: string;
43
+ }
44
+
45
+ export type BillingStoreSubscriptionUpdate =
46
+ Updateable<BillingStoreSubscriptionsTable>;
47
+
48
+ export type BillingStoreTransactionRow =
49
+ Selectable<BillingStoreTransactionsTable>;
50
+
51
+ export interface BillingStoreTransactionsTable {
52
+ environment: string;
53
+ eventType: string;
54
+ externalTransactionID: null | string;
55
+ platform: string;
56
+ processedAt: string;
57
+ purchaseTokenHash: null | string;
58
+ rawPayload: string;
59
+ storeProductID: string;
60
+ subscriptionID: string;
61
+ transactionID: string;
62
+ userID: string;
63
+ }
64
+
65
+ export type BillingStoreTransactionUpdate =
66
+ Updateable<BillingStoreTransactionsTable>;
67
+
11
68
  export type ChannelRow = Selectable<ChannelsTable>;
12
69
 
13
70
  export interface ChannelsTable {
@@ -92,8 +149,13 @@ export interface MailTable {
92
149
  time: string;
93
150
  }
94
151
  export type MailUpdate = Updateable<MailTable>;
95
- export type NewChannel = Insertable<ChannelsTable>;
152
+ export type NewAccountEntitlement = Insertable<AccountEntitlementsTable>;
153
+ export type NewBillingStoreSubscription =
154
+ Insertable<BillingStoreSubscriptionsTable>;
155
+ export type NewBillingStoreTransaction =
156
+ Insertable<BillingStoreTransactionsTable>;
96
157
 
158
+ export type NewChannel = Insertable<ChannelsTable>;
97
159
  export type NewDevice = Insertable<DevicesTable>;
98
160
  export type NewDevicePasskeyApproval = Insertable<DevicePasskeyApprovalsTable>;
99
161
  export type NewEmoji = Insertable<EmojisTable>;
@@ -177,6 +239,9 @@ export interface PreKeysTable {
177
239
  }
178
240
  export type PreKeyUpdate = Updateable<PreKeysTable>;
179
241
  export interface ServerDatabase {
242
+ account_entitlements: AccountEntitlementsTable;
243
+ billing_store_subscriptions: BillingStoreSubscriptionsTable;
244
+ billing_store_transactions: BillingStoreTransactionsTable;
180
245
  channels: ChannelsTable;
181
246
  device_passkey_approvals: DevicePasskeyApprovalsTable;
182
247
  devices: DevicesTable;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Copyright (c) 2020-2026 Vex Heavy Industries LLC
3
+ * Licensed under AGPL-3.0. See LICENSE for details.
4
+ * Commercial licenses available at vex.wtf
5
+ */
6
+
7
+ import type { Kysely } from "kysely";
8
+
9
+ export async function down(db: Kysely<unknown>): Promise<void> {
10
+ await db.schema.dropTable("account_entitlements").ifExists().execute();
11
+ }
12
+
13
+ export async function up(db: Kysely<unknown>): Promise<void> {
14
+ await db.schema
15
+ .createTable("account_entitlements")
16
+ .ifNotExists()
17
+ .addColumn("userID", "varchar(255)", (cb) => cb.primaryKey())
18
+ .addColumn("tier", "varchar(32)", (cb) => cb.notNull())
19
+ .addColumn("source", "varchar(32)", (cb) => cb.notNull())
20
+ .addColumn("expiresAt", "text")
21
+ .addColumn("updatedAt", "text", (cb) => cb.notNull())
22
+ .execute();
23
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Copyright (c) 2020-2026 Vex Heavy Industries LLC
3
+ * Licensed under AGPL-3.0. See LICENSE for details.
4
+ * Commercial licenses available at vex.wtf
5
+ */
6
+
7
+ import type { Kysely } from "kysely";
8
+
9
+ export async function down(db: Kysely<unknown>): Promise<void> {
10
+ await db.schema
11
+ .dropTable("billing_store_transactions")
12
+ .ifExists()
13
+ .execute();
14
+ await db.schema
15
+ .dropTable("billing_store_subscriptions")
16
+ .ifExists()
17
+ .execute();
18
+ }
19
+
20
+ export async function up(db: Kysely<unknown>): Promise<void> {
21
+ await db.schema
22
+ .createTable("billing_store_subscriptions")
23
+ .ifNotExists()
24
+ .addColumn("subscriptionID", "varchar(255)", (cb) => cb.primaryKey())
25
+ .addColumn("userID", "varchar(255)", (cb) => cb.notNull())
26
+ .addColumn("platform", "varchar(32)", (cb) => cb.notNull())
27
+ .addColumn("environment", "varchar(32)", (cb) => cb.notNull())
28
+ .addColumn("productID", "varchar(255)", (cb) => cb.notNull())
29
+ .addColumn("storeProductID", "varchar(255)", (cb) => cb.notNull())
30
+ .addColumn("tier", "varchar(32)", (cb) => cb.notNull())
31
+ .addColumn("status", "varchar(32)", (cb) => cb.notNull())
32
+ .addColumn("expiresAt", "text")
33
+ .addColumn("externalOriginalID", "varchar(255)")
34
+ .addColumn("externalTransactionID", "varchar(255)")
35
+ .addColumn("purchaseToken", "text")
36
+ .addColumn("purchaseTokenHash", "varchar(128)")
37
+ .addColumn("rawPayload", "text", (cb) => cb.notNull())
38
+ .addColumn("createdAt", "text", (cb) => cb.notNull())
39
+ .addColumn("updatedAt", "text", (cb) => cb.notNull())
40
+ .execute();
41
+
42
+ await db.schema
43
+ .createIndex("billing_store_subscriptions_user_idx")
44
+ .ifNotExists()
45
+ .on("billing_store_subscriptions")
46
+ .column("userID")
47
+ .execute();
48
+
49
+ await db.schema
50
+ .createIndex("billing_store_subscriptions_original_idx")
51
+ .ifNotExists()
52
+ .on("billing_store_subscriptions")
53
+ .columns(["platform", "environment", "externalOriginalID"])
54
+ .execute();
55
+
56
+ await db.schema
57
+ .createIndex("billing_store_subscriptions_token_hash_idx")
58
+ .ifNotExists()
59
+ .on("billing_store_subscriptions")
60
+ .columns(["platform", "environment", "purchaseTokenHash"])
61
+ .execute();
62
+
63
+ await db.schema
64
+ .createTable("billing_store_transactions")
65
+ .ifNotExists()
66
+ .addColumn("transactionID", "varchar(255)", (cb) => cb.primaryKey())
67
+ .addColumn("subscriptionID", "varchar(255)", (cb) => cb.notNull())
68
+ .addColumn("userID", "varchar(255)", (cb) => cb.notNull())
69
+ .addColumn("platform", "varchar(32)", (cb) => cb.notNull())
70
+ .addColumn("environment", "varchar(32)", (cb) => cb.notNull())
71
+ .addColumn("storeProductID", "varchar(255)", (cb) => cb.notNull())
72
+ .addColumn("eventType", "varchar(64)", (cb) => cb.notNull())
73
+ .addColumn("externalTransactionID", "varchar(255)")
74
+ .addColumn("purchaseTokenHash", "varchar(128)")
75
+ .addColumn("rawPayload", "text", (cb) => cb.notNull())
76
+ .addColumn("processedAt", "text", (cb) => cb.notNull())
77
+ .execute();
78
+
79
+ await db.schema
80
+ .createIndex("billing_store_transactions_subscription_idx")
81
+ .ifNotExists()
82
+ .on("billing_store_transactions")
83
+ .column("subscriptionID")
84
+ .execute();
85
+
86
+ await db.schema
87
+ .createIndex("billing_store_transactions_user_idx")
88
+ .ifNotExists()
89
+ .on("billing_store_transactions")
90
+ .column("userID")
91
+ .execute();
92
+ }
@@ -0,0 +1,361 @@
1
+ /**
2
+ * Copyright (c) 2020-2026 Vex Heavy Industries LLC
3
+ * Licensed under AGPL-3.0. See LICENSE for details.
4
+ * Commercial licenses available at vex.wtf
5
+ */
6
+
7
+ import type { Database, StoreSubscriptionUpsertInput } from "../Database.ts";
8
+ import type {
9
+ BillingAccountState,
10
+ BillingProduct,
11
+ BillingSubscription,
12
+ } from "@vex-chat/types";
13
+
14
+ import express from "express";
15
+
16
+ import {
17
+ AccountTierSchema,
18
+ AppleServerNotificationRequestSchema,
19
+ AppleTransactionVerificationRequestSchema,
20
+ datetime,
21
+ GooglePlayDeveloperNotificationRequestSchema,
22
+ GooglePurchaseVerificationRequestSchema,
23
+ } from "@vex-chat/types";
24
+
25
+ import { z } from "zod/v4";
26
+
27
+ import {
28
+ BillingVerificationError,
29
+ decodeAppleServerNotificationPayload,
30
+ decodeGooglePlayPubSubNotificationPayload,
31
+ getBillingProductCatalog,
32
+ resolveBillingProduct,
33
+ type VerifiedStorePurchase,
34
+ verifyAppleTransaction,
35
+ verifyGooglePurchase,
36
+ } from "../BillingVerification.ts";
37
+
38
+ import { devApiKeyMatches } from "./rateLimit.ts";
39
+ import { getUser } from "./utils.ts";
40
+ import { sendWireResponse } from "./wireResponse.ts";
41
+
42
+ import { protect } from "./index.ts";
43
+
44
+ const devGrantPayload = z.object({
45
+ expiresAt: datetime.nullable().optional(),
46
+ tier: AccountTierSchema,
47
+ userID: z.string().min(1),
48
+ });
49
+
50
+ export function devBillingGrantRoutesEnabled(
51
+ env: NodeJS.ProcessEnv = process.env,
52
+ ): boolean {
53
+ return (
54
+ env["NODE_ENV"] !== "production" &&
55
+ env["VEX_ENABLE_DEV_BILLING_GRANTS"] === "1" &&
56
+ (env["DEV_API_KEY"]?.trim().length ?? 0) > 0
57
+ );
58
+ }
59
+
60
+ export const getBillingRouter = (
61
+ db: Database,
62
+ notify: (
63
+ userID: string,
64
+ event: string,
65
+ transmissionID: string,
66
+ data?: unknown,
67
+ deviceID?: string,
68
+ ) => void,
69
+ ) => {
70
+ const router = express.Router();
71
+
72
+ router.get("/billing/products", protect, (_req, res) => {
73
+ sendWireResponse(_req, res, getBillingProductCatalog());
74
+ });
75
+
76
+ router.get("/billing/account", protect, async (req, res) => {
77
+ const userID = getUser(req).userID;
78
+ const state = await db.retrieveBillingAccountState(userID);
79
+ sendWireResponse(req, res, state);
80
+ });
81
+
82
+ router.post("/billing/apple/transactions", protect, async (req, res) => {
83
+ const parsed = AppleTransactionVerificationRequestSchema.safeParse(
84
+ req.body,
85
+ );
86
+ if (!parsed.success) {
87
+ res.status(400).json({
88
+ error: "Invalid Apple transaction payload",
89
+ issues: parsed.error.issues,
90
+ });
91
+ return;
92
+ }
93
+
94
+ await handleVerifiedPurchase(req, res, {
95
+ db,
96
+ eventType: "apple_transaction_verified",
97
+ notify,
98
+ purchase: await verifyAppleTransaction(parsed.data),
99
+ userID: getUser(req).userID,
100
+ });
101
+ });
102
+
103
+ router.post("/billing/google/purchases", protect, async (req, res) => {
104
+ const parsed = GooglePurchaseVerificationRequestSchema.safeParse(
105
+ req.body,
106
+ );
107
+ if (!parsed.success) {
108
+ res.status(400).json({
109
+ error: "Invalid Google Play purchase payload",
110
+ issues: parsed.error.issues,
111
+ });
112
+ return;
113
+ }
114
+
115
+ await handleVerifiedPurchase(req, res, {
116
+ db,
117
+ eventType: "google_purchase_verified",
118
+ notify,
119
+ purchase: await verifyGooglePurchase(parsed.data),
120
+ userID: getUser(req).userID,
121
+ });
122
+ });
123
+
124
+ router.post("/billing/webhooks/apple", async (req, res) => {
125
+ const parsed = AppleServerNotificationRequestSchema.safeParse(req.body);
126
+ if (!parsed.success) {
127
+ res.status(400).json({
128
+ error: "Invalid Apple server notification payload",
129
+ issues: parsed.error.issues,
130
+ });
131
+ return;
132
+ }
133
+
134
+ const notification = decodeAppleServerNotificationPayload(
135
+ parsed.data.signedPayload,
136
+ );
137
+ const signedTransactionInfo = notification.data?.signedTransactionInfo;
138
+ if (!signedTransactionInfo) {
139
+ res.sendStatus(202);
140
+ return;
141
+ }
142
+
143
+ const purchase = await verifyAppleTransaction({
144
+ signedTransactionInfo,
145
+ });
146
+ const userID = await db.retrieveStoreSubscriptionOwner({
147
+ environment: purchase.environment,
148
+ externalOriginalID: purchase.externalOriginalID,
149
+ platform: purchase.platform,
150
+ });
151
+ if (!userID) {
152
+ res.sendStatus(202);
153
+ return;
154
+ }
155
+
156
+ await handleVerifiedPurchase(req, res, {
157
+ db,
158
+ eventType: `apple_webhook_${notification.notificationType ?? "unknown"}`,
159
+ notify,
160
+ purchase,
161
+ userID,
162
+ });
163
+ });
164
+
165
+ router.post("/billing/webhooks/google", async (req, res) => {
166
+ const parsed = GooglePlayDeveloperNotificationRequestSchema.safeParse(
167
+ req.body,
168
+ );
169
+ if (!parsed.success) {
170
+ res.status(400).json({
171
+ error: "Invalid Google Play notification payload",
172
+ issues: parsed.error.issues,
173
+ });
174
+ return;
175
+ }
176
+ const notification = decodeGooglePlayPubSubNotificationPayload(
177
+ req.body,
178
+ );
179
+ const purchaseToken =
180
+ notification.subscriptionNotification?.purchaseToken;
181
+ if (!purchaseToken) {
182
+ res.sendStatus(202);
183
+ return;
184
+ }
185
+
186
+ const purchase = await verifyGooglePurchase({
187
+ packageName: notification.packageName,
188
+ productID: notification.subscriptionNotification?.subscriptionId,
189
+ purchaseToken,
190
+ });
191
+ const userID = await db.retrieveStoreSubscriptionOwner({
192
+ environment: purchase.environment,
193
+ platform: purchase.platform,
194
+ purchaseToken,
195
+ });
196
+ if (!userID) {
197
+ res.sendStatus(202);
198
+ return;
199
+ }
200
+
201
+ await handleVerifiedPurchase(req, res, {
202
+ db,
203
+ eventType: "google_webhook_subscription",
204
+ notify,
205
+ purchase,
206
+ userID,
207
+ });
208
+ });
209
+
210
+ if (devBillingGrantRoutesEnabled()) {
211
+ router.post("/__dev/billing/grants", async (req, res) => {
212
+ if (!devBillingGrantRoutesEnabled()) {
213
+ res.sendStatus(404);
214
+ return;
215
+ }
216
+ if (!devApiKeyMatches(req)) {
217
+ res.sendStatus(403);
218
+ return;
219
+ }
220
+ const parsed = devGrantPayload.safeParse(req.body);
221
+ if (!parsed.success) {
222
+ res.status(400).json({
223
+ error: "Invalid dev billing grant payload",
224
+ issues: parsed.error.issues,
225
+ });
226
+ return;
227
+ }
228
+
229
+ const entitlements = await db.setAccountEntitlementTier(
230
+ parsed.data.userID,
231
+ parsed.data.tier,
232
+ {
233
+ expiresAt: parsed.data.expiresAt ?? null,
234
+ source: "store",
235
+ },
236
+ );
237
+ notify(
238
+ parsed.data.userID,
239
+ "accountEntitlementsChanged",
240
+ crypto.randomUUID(),
241
+ { tier: entitlements.tier },
242
+ );
243
+ sendWireResponse(req, res, entitlements);
244
+ });
245
+ }
246
+
247
+ router.use(
248
+ (
249
+ err: unknown,
250
+ _req: express.Request,
251
+ res: express.Response,
252
+ next: express.NextFunction,
253
+ ) => {
254
+ if (err instanceof BillingVerificationError) {
255
+ res.status(err.status).json({ error: err.message });
256
+ return;
257
+ }
258
+ next(err);
259
+ },
260
+ );
261
+
262
+ return router;
263
+ };
264
+
265
+ export function billingAccountPath(): string {
266
+ return "/billing/account";
267
+ }
268
+
269
+ export function billingAppleTransactionPath(): string {
270
+ return "/billing/apple/transactions";
271
+ }
272
+
273
+ export function billingDevGrantPath(): string {
274
+ return "/__dev/billing/grants";
275
+ }
276
+
277
+ export function billingGooglePurchasePath(): string {
278
+ return "/billing/google/purchases";
279
+ }
280
+
281
+ export function billingProductsPath(): string {
282
+ return "/billing/products";
283
+ }
284
+
285
+ export function billingUserPath(userID: string): string {
286
+ return `/user/${encodeURIComponent(userID)}/billing`;
287
+ }
288
+
289
+ async function handleVerifiedPurchase(
290
+ req: express.Request,
291
+ res: express.Response,
292
+ args: {
293
+ db: Database;
294
+ eventType: string;
295
+ notify: (
296
+ userID: string,
297
+ event: string,
298
+ transmissionID: string,
299
+ data?: unknown,
300
+ deviceID?: string,
301
+ ) => void;
302
+ purchase: VerifiedStorePurchase;
303
+ userID: string;
304
+ },
305
+ ): Promise<void> {
306
+ const product = resolveBillingProduct(args.purchase);
307
+ const subscription = await persistVerifiedPurchase(args.db, {
308
+ product,
309
+ purchase: args.purchase,
310
+ userID: args.userID,
311
+ });
312
+ await args.db.recordStoreTransaction({
313
+ eventType: args.eventType,
314
+ externalTransactionID: args.purchase.externalTransactionID,
315
+ rawPayload: args.purchase.rawPayload,
316
+ subscriptionID: subscription.subscriptionID,
317
+ userID: args.userID,
318
+ });
319
+ const entitlements = await args.db.recalculateStoreEntitlements(
320
+ args.userID,
321
+ );
322
+ args.notify(
323
+ args.userID,
324
+ "accountEntitlementsChanged",
325
+ crypto.randomUUID(),
326
+ {
327
+ tier: entitlements.tier,
328
+ },
329
+ );
330
+
331
+ const state: BillingAccountState = {
332
+ entitlements,
333
+ subscriptions: await args.db.retrieveBillingSubscriptions(args.userID),
334
+ };
335
+ sendWireResponse(req, res, state);
336
+ }
337
+
338
+ async function persistVerifiedPurchase(
339
+ db: Database,
340
+ args: {
341
+ product: BillingProduct;
342
+ purchase: VerifiedStorePurchase;
343
+ userID: string;
344
+ },
345
+ ): Promise<BillingSubscription> {
346
+ const input: StoreSubscriptionUpsertInput = {
347
+ environment: args.purchase.environment,
348
+ expiresAt: args.purchase.expiresAt,
349
+ externalOriginalID: args.purchase.externalOriginalID,
350
+ externalTransactionID: args.purchase.externalTransactionID,
351
+ platform: args.purchase.platform,
352
+ productID: args.product.productID,
353
+ purchaseToken: args.purchase.purchaseToken,
354
+ rawPayload: args.purchase.rawPayload,
355
+ status: args.purchase.status,
356
+ storeProductID: args.product.storeProductID,
357
+ tier: args.product.tier,
358
+ userID: args.userID,
359
+ };
360
+ return db.upsertStoreSubscription(input);
361
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Copyright (c) 2020-2026 Vex Heavy Industries LLC
3
+ * Licensed under AGPL-3.0. See LICENSE for details.
4
+ * Commercial licenses available at vex.wtf
5
+ */
6
+
7
+ import type { Database } from "../Database.ts";
8
+
9
+ import express from "express";
10
+
11
+ import { AccountTierSchema, datetime } from "@vex-chat/types";
12
+
13
+ import { z } from "zod/v4";
14
+
15
+ import { devApiKeyMatches } from "./rateLimit.ts";
16
+ import { getParam, getUser } from "./utils.ts";
17
+ import { sendWireResponse } from "./wireResponse.ts";
18
+
19
+ import { protect } from "./index.ts";
20
+
21
+ const devEntitlementPatchPayload = z.object({
22
+ expiresAt: datetime.nullable().optional(),
23
+ tier: AccountTierSchema,
24
+ });
25
+
26
+ export function devEntitlementRoutesEnabled(
27
+ env: NodeJS.ProcessEnv = process.env,
28
+ ): boolean {
29
+ return (
30
+ env["NODE_ENV"] !== "production" &&
31
+ env["VEX_ENABLE_DEV_ENTITLEMENTS"] === "1" &&
32
+ (env["DEV_API_KEY"]?.trim().length ?? 0) > 0
33
+ );
34
+ }
35
+
36
+ export const getEntitlementRouter = (
37
+ db: Database,
38
+ notify: (
39
+ userID: string,
40
+ event: string,
41
+ transmissionID: string,
42
+ data?: unknown,
43
+ deviceID?: string,
44
+ ) => void,
45
+ ) => {
46
+ const router = express.Router();
47
+
48
+ router.get("/user/:id/entitlements", protect, async (req, res) => {
49
+ const userDetails = getUser(req);
50
+ const userID = getParam(req, "id");
51
+ if (userDetails.userID !== userID) {
52
+ res.sendStatus(401);
53
+ return;
54
+ }
55
+
56
+ const entitlements = await db.retrieveAccountEntitlements(userID);
57
+ sendWireResponse(req, res, entitlements);
58
+ });
59
+
60
+ if (!devEntitlementRoutesEnabled()) {
61
+ return router;
62
+ }
63
+
64
+ router.patch("/__dev/user/:id/entitlements", protect, async (req, res) => {
65
+ if (!devEntitlementRoutesEnabled()) {
66
+ res.sendStatus(404);
67
+ return;
68
+ }
69
+ const userDetails = getUser(req);
70
+ const userID = getParam(req, "id");
71
+ if (userDetails.userID !== userID) {
72
+ res.sendStatus(401);
73
+ return;
74
+ }
75
+ if (!devApiKeyMatches(req)) {
76
+ res.sendStatus(403);
77
+ return;
78
+ }
79
+
80
+ const parsed = devEntitlementPatchPayload.safeParse(req.body);
81
+ if (!parsed.success) {
82
+ res.status(400).json({
83
+ error: "Invalid entitlement payload",
84
+ issues: parsed.error.issues,
85
+ });
86
+ return;
87
+ }
88
+
89
+ const entitlements = await db.setAccountEntitlementTier(
90
+ userID,
91
+ parsed.data.tier,
92
+ {
93
+ expiresAt: parsed.data.expiresAt ?? null,
94
+ source: "dev_override",
95
+ },
96
+ );
97
+ notify(userID, "accountEntitlementsChanged", crypto.randomUUID(), {
98
+ tier: entitlements.tier,
99
+ });
100
+ sendWireResponse(req, res, entitlements);
101
+ });
102
+
103
+ return router;
104
+ };
@@ -32,7 +32,9 @@ import { verifyPreKeyWsSignature } from "../utils/preKeySignature.ts";
32
32
  import { spireXSignOpenAsync } from "../utils/spireXSignOpenAsync.ts";
33
33
 
34
34
  import { getAvatarRouter } from "./avatar.ts";
35
+ import { getBillingRouter } from "./billing.ts";
35
36
  import { getCliPasskeyPageRouter } from "./cliPasskeyPage.ts";
37
+ import { getEntitlementRouter } from "./entitlements.ts";
36
38
  import { errorHandler } from "./errors.ts";
37
39
  import { getFileRouter } from "./file.ts";
38
40
  import { getInviteRouter } from "./invite.ts";
@@ -255,6 +257,8 @@ export const initApp = (
255
257
  const userRouter = getUserRouter(db, tokenValidator, notify);
256
258
  const fileRouter = getFileRouter(db);
257
259
  const avatarRouter = getAvatarRouter();
260
+ const billingRouter = getBillingRouter(db, notify);
261
+ const entitlementRouter = getEntitlementRouter(db, notify);
258
262
  const inviteRouter = getInviteRouter(db, tokenValidator, notify);
259
263
  const passkeyRouter = getPasskeyRouter(db);
260
264
  const passkeyDeviceRouter = getPasskeyDeviceRouter(
@@ -1024,6 +1028,8 @@ export const initApp = (
1024
1028
  // an authenticated device) and `/auth/passkey/...` (public
1025
1029
  // sign-in). The router itself defines the full path on each
1026
1030
  // route handler.
1031
+ api.use(billingRouter);
1032
+ api.use(entitlementRouter);
1027
1033
  api.use(passkeyRouter);
1028
1034
  api.use(passkeyDeviceRouter);
1029
1035