@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.
- package/dist/BillingVerification.d.ts +40 -0
- package/dist/BillingVerification.js +535 -0
- package/dist/BillingVerification.js.map +1 -0
- package/dist/Database.d.ts +39 -1
- package/dist/Database.js +285 -2
- package/dist/Database.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/Database.ts +422 -2
- 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/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
|
@@ -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
|
+
};
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
|