@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
@@ -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