@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
|
@@ -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
|
+
}
|
|
@@ -167,7 +167,7 @@ const CLI_PASSKEY_PAGE = `<!doctype html>
|
|
|
167
167
|
|
|
168
168
|
if (mode === "register") {
|
|
169
169
|
titleEl.textContent = "Create a passkey for Vex.";
|
|
170
|
-
copyEl.textContent = "This adds
|
|
170
|
+
copyEl.textContent = "This adds a passkey to your Vex account.";
|
|
171
171
|
action.textContent = "Create passkey";
|
|
172
172
|
setStatus("Ready to create passkey.");
|
|
173
173
|
} else {
|
|
@@ -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";
|
|
@@ -44,11 +46,7 @@ import {
|
|
|
44
46
|
hasPermission,
|
|
45
47
|
userHasPermission,
|
|
46
48
|
} from "./permissions.ts";
|
|
47
|
-
import {
|
|
48
|
-
devApiKeyMatches,
|
|
49
|
-
globalLimiter,
|
|
50
|
-
keyBundleLimiter,
|
|
51
|
-
} from "./rateLimit.ts";
|
|
49
|
+
import { globalLimiter, keyBundleLimiter } from "./rateLimit.ts";
|
|
52
50
|
import { getUserRouter } from "./user.ts";
|
|
53
51
|
import { censorUser, getParam, getUser } from "./utils.ts";
|
|
54
52
|
import { getWellKnownRouter } from "./wellKnown.ts";
|
|
@@ -255,6 +253,8 @@ export const initApp = (
|
|
|
255
253
|
const userRouter = getUserRouter(db, tokenValidator, notify);
|
|
256
254
|
const fileRouter = getFileRouter(db);
|
|
257
255
|
const avatarRouter = getAvatarRouter();
|
|
256
|
+
const billingRouter = getBillingRouter(db, notify);
|
|
257
|
+
const entitlementRouter = getEntitlementRouter(db, notify);
|
|
258
258
|
const inviteRouter = getInviteRouter(db, tokenValidator, notify);
|
|
259
259
|
const passkeyRouter = getPasskeyRouter(db);
|
|
260
260
|
const passkeyDeviceRouter = getPasskeyDeviceRouter(
|
|
@@ -684,17 +684,6 @@ export const initApp = (
|
|
|
684
684
|
res.sendStatus(401);
|
|
685
685
|
return;
|
|
686
686
|
}
|
|
687
|
-
const passkeys = await db.retrievePasskeysByUser(device.owner);
|
|
688
|
-
// The CLI stress harness cannot perform a WebAuthn ceremony.
|
|
689
|
-
// Keep normal clients gated, but let the existing dev/load-test key
|
|
690
|
-
// exercise device connect on disposable accounts.
|
|
691
|
-
if (passkeys.length === 0 && !devApiKeyMatches(req)) {
|
|
692
|
-
res.status(403).send({
|
|
693
|
-
error: "A passkey must be registered before this device can connect.",
|
|
694
|
-
});
|
|
695
|
-
return;
|
|
696
|
-
}
|
|
697
|
-
|
|
698
687
|
const regKey = await spireXSignOpenAsync(
|
|
699
688
|
signed,
|
|
700
689
|
XUtils.decodeHex(device.signKey),
|
|
@@ -1024,6 +1013,8 @@ export const initApp = (
|
|
|
1024
1013
|
// an authenticated device) and `/auth/passkey/...` (public
|
|
1025
1014
|
// sign-in). The router itself defines the full path on each
|
|
1026
1015
|
// route handler.
|
|
1016
|
+
api.use(billingRouter);
|
|
1017
|
+
api.use(entitlementRouter);
|
|
1027
1018
|
api.use(passkeyRouter);
|
|
1028
1019
|
api.use(passkeyDeviceRouter);
|
|
1029
1020
|
|
package/src/server/passkey.ts
CHANGED
|
@@ -204,10 +204,9 @@ export const getPasskeyRouter = (db: Database) => {
|
|
|
204
204
|
}
|
|
205
205
|
|
|
206
206
|
const existing = await db.retrievePasskeysByUser(userID);
|
|
207
|
-
// A freshly registered account
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
// must come from an authenticated device session.
|
|
207
|
+
// A freshly registered account may add its first passkey with
|
|
208
|
+
// the account bearer before finishing device connect. Every later
|
|
209
|
+
// passkey addition must come from an authenticated device session.
|
|
211
210
|
if (!req.device && existing.length > 0) {
|
|
212
211
|
res.status(401).send({
|
|
213
212
|
error: "Adding another passkey requires an authenticated device.",
|
|
@@ -1,13 +0,0 @@
|
|
|
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
|
-
import type { MailWS } from "@vex-chat/types";
|
|
7
|
-
export interface CallWakeDispatchData {
|
|
8
|
-
callID: string;
|
|
9
|
-
expiresAt?: string | undefined;
|
|
10
|
-
mailID: string;
|
|
11
|
-
mailNonce: string;
|
|
12
|
-
}
|
|
13
|
-
export declare function callWakeDispatchData(mail: MailWS): CallWakeDispatchData | null;
|
package/dist/server/callWake.js
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
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
|
-
import { XUtils } from "@vex-chat/crypto";
|
|
7
|
-
export function callWakeDispatchData(mail) {
|
|
8
|
-
if (mail.notify?.event !== "callWake") {
|
|
9
|
-
return null;
|
|
10
|
-
}
|
|
11
|
-
return {
|
|
12
|
-
callID: mail.notify.callID,
|
|
13
|
-
...(mail.notify.expiresAt ? { expiresAt: mail.notify.expiresAt } : {}),
|
|
14
|
-
mailID: mail.mailID,
|
|
15
|
-
mailNonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
|
|
16
|
-
};
|
|
17
|
-
}
|
|
18
|
-
//# sourceMappingURL=callWake.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"callWake.js","sourceRoot":"","sources":["../../src/server/callWake.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAS1C,MAAM,UAAU,oBAAoB,CAChC,IAAY;IAEZ,IAAI,IAAI,CAAC,MAAM,EAAE,KAAK,KAAK,UAAU,EAAE,CAAC;QACpC,OAAO,IAAI,CAAC;IAChB,CAAC;IACD,OAAO;QACH,MAAM,EAAE,IAAI,CAAC,MAAM,CAAC,MAAM;QAC1B,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,IAAI,CAAC,MAAM,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACtE,MAAM,EAAE,IAAI,CAAC,MAAM;QACnB,SAAS,EAAE,MAAM,CAAC,SAAS,CAAC,IAAI,UAAU,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;KAC1D,CAAC;AACN,CAAC"}
|