@vex-chat/spire 2.5.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/BillingVerification.d.ts +40 -0
- package/dist/BillingVerification.js +535 -0
- package/dist/BillingVerification.js.map +1 -0
- package/dist/ClientManager.js +1 -8
- package/dist/ClientManager.js.map +1 -1
- package/dist/Database.d.ts +41 -4
- package/dist/Database.js +291 -14
- package/dist/Database.js.map +1 -1
- package/dist/NotificationService.d.ts +0 -2
- package/dist/NotificationService.js +4 -321
- package/dist/NotificationService.js.map +1 -1
- package/dist/Spire.js +43 -52
- package/dist/Spire.js.map +1 -1
- package/dist/db/schema.d.ts +50 -0
- package/dist/migrations/2026-06-24_account-entitlements.d.ts +8 -0
- package/dist/migrations/2026-06-24_account-entitlements.js +20 -0
- package/dist/migrations/2026-06-24_account-entitlements.js.map +1 -0
- package/dist/migrations/2026-07-02_store-subscriptions.d.ts +8 -0
- package/dist/migrations/2026-07-02_store-subscriptions.js +83 -0
- package/dist/migrations/2026-07-02_store-subscriptions.js.map +1 -0
- package/dist/server/billing.d.ts +14 -0
- package/dist/server/billing.js +234 -0
- package/dist/server/billing.js.map +1 -0
- package/dist/server/cliPasskeyPage.js +1 -1
- package/dist/server/entitlements.d.ts +8 -0
- package/dist/server/entitlements.js +71 -0
- package/dist/server/entitlements.js.map +1 -0
- package/dist/server/index.js +7 -11
- package/dist/server/index.js.map +1 -1
- package/dist/server/passkey.js +3 -4
- package/dist/server/passkey.js.map +1 -1
- package/package.json +4 -4
- package/src/BillingVerification.ts +800 -0
- package/src/ClientManager.ts +9 -23
- package/src/Database.ts +433 -20
- package/src/NotificationService.ts +4 -428
- package/src/Spire.ts +77 -103
- package/src/__tests__/Database.spec.ts +36 -3
- package/src/__tests__/billing.spec.ts +282 -0
- package/src/__tests__/connectAuth.spec.ts +120 -0
- package/src/__tests__/entitlements.spec.ts +241 -0
- package/src/__tests__/notifyFanout.spec.ts +0 -136
- package/src/db/schema.ts +66 -1
- package/src/migrations/2026-06-24_account-entitlements.ts +23 -0
- package/src/migrations/2026-07-02_store-subscriptions.ts +92 -0
- package/src/server/billing.ts +361 -0
- package/src/server/cliPasskeyPage.ts +1 -1
- package/src/server/entitlements.ts +104 -0
- package/src/server/index.ts +7 -16
- package/src/server/passkey.ts +3 -4
- package/dist/server/callWake.d.ts +0 -13
- package/dist/server/callWake.js +0 -18
- package/dist/server/callWake.js.map +0 -1
- package/src/server/callWake.ts +0 -30
package/src/Spire.ts
CHANGED
|
@@ -48,7 +48,6 @@ import { ClientManager } from "./ClientManager.ts";
|
|
|
48
48
|
import { Database, hashPasswordArgon2, verifyPassword } from "./Database.ts";
|
|
49
49
|
import { resolveIceServersFromEnv } from "./IceServers.ts";
|
|
50
50
|
import { NotificationService } from "./NotificationService.ts";
|
|
51
|
-
import { callWakeDispatchData } from "./server/callWake.ts";
|
|
52
51
|
import { initApp, protect } from "./server/index.ts";
|
|
53
52
|
import {
|
|
54
53
|
MailIngressValidationError,
|
|
@@ -134,8 +133,8 @@ interface ValidatedMailBatchEntry {
|
|
|
134
133
|
}
|
|
135
134
|
|
|
136
135
|
const notificationSubscribePayload = z.object({
|
|
137
|
-
channel: z.
|
|
138
|
-
events: z.array(z.string().min(1)).
|
|
136
|
+
channel: z.literal("expo"),
|
|
137
|
+
events: z.array(z.string().min(1)).default(["mail"]),
|
|
139
138
|
platform: z.enum(["android", "ios", "web"]).optional(),
|
|
140
139
|
token: z.string().min(1),
|
|
141
140
|
});
|
|
@@ -872,28 +871,15 @@ export class Spire extends EventEmitter {
|
|
|
872
871
|
);
|
|
873
872
|
|
|
874
873
|
res.sendStatus(200);
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
mail.nonce,
|
|
885
|
-
);
|
|
886
|
-
} else {
|
|
887
|
-
this.notify(
|
|
888
|
-
recipientDeviceDetails.owner,
|
|
889
|
-
"mail",
|
|
890
|
-
crypto.randomUUID(),
|
|
891
|
-
null,
|
|
892
|
-
mail.recipient,
|
|
893
|
-
mail.authorID,
|
|
894
|
-
mail.nonce,
|
|
895
|
-
);
|
|
896
|
-
}
|
|
874
|
+
this.notify(
|
|
875
|
+
recipientDeviceDetails.owner,
|
|
876
|
+
"mail",
|
|
877
|
+
crypto.randomUUID(),
|
|
878
|
+
null,
|
|
879
|
+
mail.recipient,
|
|
880
|
+
mail.authorID,
|
|
881
|
+
mail.nonce,
|
|
882
|
+
);
|
|
897
883
|
});
|
|
898
884
|
|
|
899
885
|
this.api.post("/mail/batch", protect, async (req, res) => {
|
|
@@ -1003,28 +989,15 @@ export class Spire extends EventEmitter {
|
|
|
1003
989
|
|
|
1004
990
|
res.send(msgpack.encode({ results }));
|
|
1005
991
|
for (const entry of deliveredEntries) {
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
entry.mail.nonce,
|
|
1016
|
-
);
|
|
1017
|
-
} else {
|
|
1018
|
-
this.notify(
|
|
1019
|
-
entry.recipientDevice.owner,
|
|
1020
|
-
"mail",
|
|
1021
|
-
crypto.randomUUID(),
|
|
1022
|
-
null,
|
|
1023
|
-
entry.mail.recipient,
|
|
1024
|
-
entry.mail.authorID,
|
|
1025
|
-
entry.mail.nonce,
|
|
1026
|
-
);
|
|
1027
|
-
}
|
|
992
|
+
this.notify(
|
|
993
|
+
entry.recipientDevice.owner,
|
|
994
|
+
"mail",
|
|
995
|
+
crypto.randomUUID(),
|
|
996
|
+
null,
|
|
997
|
+
entry.mail.recipient,
|
|
998
|
+
entry.mail.authorID,
|
|
999
|
+
entry.mail.nonce,
|
|
1000
|
+
);
|
|
1028
1001
|
}
|
|
1029
1002
|
});
|
|
1030
1003
|
|
|
@@ -1070,11 +1043,7 @@ export class Spire extends EventEmitter {
|
|
|
1070
1043
|
{
|
|
1071
1044
|
channel: parsed.data.channel,
|
|
1072
1045
|
deviceID: device.deviceID,
|
|
1073
|
-
events:
|
|
1074
|
-
parsed.data.events ??
|
|
1075
|
-
(parsed.data.channel === "expo"
|
|
1076
|
-
? ["mail"]
|
|
1077
|
-
: ["callWake"]),
|
|
1046
|
+
events: parsed.data.events,
|
|
1078
1047
|
platform: parsed.data.platform ?? null,
|
|
1079
1048
|
token: parsed.data.token,
|
|
1080
1049
|
userID: getUser(req).userID,
|
|
@@ -1212,6 +1181,59 @@ export class Spire extends EventEmitter {
|
|
|
1212
1181
|
TokenScopes.Register,
|
|
1213
1182
|
)
|
|
1214
1183
|
) {
|
|
1184
|
+
const existingUser = await this.db.retrieveUser(
|
|
1185
|
+
normalizedPayload.username,
|
|
1186
|
+
);
|
|
1187
|
+
if (existingUser) {
|
|
1188
|
+
const existingBySignKey = await this.db.retrieveDevice(
|
|
1189
|
+
normalizedPayload.signKey,
|
|
1190
|
+
);
|
|
1191
|
+
if (existingBySignKey) {
|
|
1192
|
+
res.status(400).send({
|
|
1193
|
+
error: "Public key is already registered.",
|
|
1194
|
+
});
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
let requesterPasskeyID: string | undefined;
|
|
1198
|
+
if (req.passkey?.passkeyID) {
|
|
1199
|
+
const passkeyError = await passkeySecondFactorError(
|
|
1200
|
+
this.db,
|
|
1201
|
+
existingUser.userID,
|
|
1202
|
+
req.passkey.passkeyID,
|
|
1203
|
+
"Passkey verification does not match this account.",
|
|
1204
|
+
);
|
|
1205
|
+
if (passkeyError) {
|
|
1206
|
+
res.status(403).send({
|
|
1207
|
+
error: passkeyError,
|
|
1208
|
+
});
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
requesterPasskeyID = req.passkey.passkeyID;
|
|
1212
|
+
}
|
|
1213
|
+
const pendingResponse =
|
|
1214
|
+
createPendingDeviceEnrollmentRequest(
|
|
1215
|
+
existingUser.userID,
|
|
1216
|
+
normalizedPayload,
|
|
1217
|
+
this.notify.bind(this),
|
|
1218
|
+
{
|
|
1219
|
+
deferOwnerNotification: true,
|
|
1220
|
+
...(requesterPasskeyID
|
|
1221
|
+
? { requesterPasskeyID }
|
|
1222
|
+
: {}),
|
|
1223
|
+
},
|
|
1224
|
+
);
|
|
1225
|
+
res.status(202).send(msgpack.encode(pendingResponse));
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
if (
|
|
1229
|
+
typeof normalizedPayload.password !== "string" ||
|
|
1230
|
+
normalizedPayload.password.trim().length === 0
|
|
1231
|
+
) {
|
|
1232
|
+
res.status(400).send({
|
|
1233
|
+
error: "Password is required to register a new account.",
|
|
1234
|
+
});
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1215
1237
|
const [user, err] = await this.db.createUser(
|
|
1216
1238
|
regKey,
|
|
1217
1239
|
normalizedPayload,
|
|
@@ -1233,57 +1255,9 @@ export class Spire extends EventEmitter {
|
|
|
1233
1255
|
errCode === "SQLITE_CONSTRAINT_UNIQUE" ||
|
|
1234
1256
|
errText.includes("UNIQUE constraint failed");
|
|
1235
1257
|
if (isUniqueConstraint && usernameConflict) {
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
);
|
|
1239
|
-
if (!existingUser) {
|
|
1240
|
-
res.status(400).send({
|
|
1241
|
-
error: "Username is already registered.",
|
|
1242
|
-
});
|
|
1243
|
-
return;
|
|
1244
|
-
}
|
|
1245
|
-
const existingBySignKey =
|
|
1246
|
-
await this.db.retrieveDevice(
|
|
1247
|
-
normalizedPayload.signKey,
|
|
1248
|
-
);
|
|
1249
|
-
if (existingBySignKey) {
|
|
1250
|
-
res.status(400).send({
|
|
1251
|
-
error: "Public key is already registered.",
|
|
1252
|
-
});
|
|
1253
|
-
return;
|
|
1254
|
-
}
|
|
1255
|
-
let requesterPasskeyID: string | undefined;
|
|
1256
|
-
if (req.passkey?.passkeyID) {
|
|
1257
|
-
const passkeyError =
|
|
1258
|
-
await passkeySecondFactorError(
|
|
1259
|
-
this.db,
|
|
1260
|
-
existingUser.userID,
|
|
1261
|
-
req.passkey.passkeyID,
|
|
1262
|
-
"Passkey verification does not match this account.",
|
|
1263
|
-
);
|
|
1264
|
-
if (passkeyError) {
|
|
1265
|
-
res.status(403).send({
|
|
1266
|
-
error: passkeyError,
|
|
1267
|
-
});
|
|
1268
|
-
return;
|
|
1269
|
-
}
|
|
1270
|
-
requesterPasskeyID = req.passkey.passkeyID;
|
|
1271
|
-
}
|
|
1272
|
-
const pendingResponse =
|
|
1273
|
-
createPendingDeviceEnrollmentRequest(
|
|
1274
|
-
existingUser.userID,
|
|
1275
|
-
normalizedPayload,
|
|
1276
|
-
this.notify.bind(this),
|
|
1277
|
-
{
|
|
1278
|
-
deferOwnerNotification: true,
|
|
1279
|
-
...(requesterPasskeyID
|
|
1280
|
-
? { requesterPasskeyID }
|
|
1281
|
-
: {}),
|
|
1282
|
-
},
|
|
1283
|
-
);
|
|
1284
|
-
res.status(202).send(
|
|
1285
|
-
msgpack.encode(pendingResponse),
|
|
1286
|
-
);
|
|
1258
|
+
res.status(400).send({
|
|
1259
|
+
error: "Username is already registered.",
|
|
1260
|
+
});
|
|
1287
1261
|
return;
|
|
1288
1262
|
}
|
|
1289
1263
|
if (isUniqueConstraint && signKeyConflict) {
|
|
@@ -81,6 +81,36 @@ describe("Database", () => {
|
|
|
81
81
|
dbType: "sqlite3mem",
|
|
82
82
|
};
|
|
83
83
|
|
|
84
|
+
describe("createUser", () => {
|
|
85
|
+
it("requires a password for new accounts", async () => {
|
|
86
|
+
expect.assertions(3);
|
|
87
|
+
const provider = new Database(options);
|
|
88
|
+
await new Promise<void>((resolve, reject) => {
|
|
89
|
+
provider.once("ready", () => {
|
|
90
|
+
void (async () => {
|
|
91
|
+
try {
|
|
92
|
+
const [user, err] = await provider.createUser(
|
|
93
|
+
new Uint8Array(16),
|
|
94
|
+
devicePayload("desktop", "b".repeat(64)),
|
|
95
|
+
);
|
|
96
|
+
expect(user).toBeNull();
|
|
97
|
+
expect(err).toBeInstanceOf(Error);
|
|
98
|
+
expect(err?.message).toBe(
|
|
99
|
+
"Password is required to register a new account.",
|
|
100
|
+
);
|
|
101
|
+
await provider.close();
|
|
102
|
+
resolve();
|
|
103
|
+
} catch (e: unknown) {
|
|
104
|
+
reject(
|
|
105
|
+
e instanceof Error ? e : new Error(String(e)),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
})();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
84
114
|
describe("saveOTK", () => {
|
|
85
115
|
it("takes a userId and one time key, adds a keyId and saves it to oneTimeKey table", async () => {
|
|
86
116
|
expect.assertions(1);
|
|
@@ -298,23 +328,26 @@ describe("Database", () => {
|
|
|
298
328
|
sender: "sender-a",
|
|
299
329
|
time,
|
|
300
330
|
});
|
|
331
|
+
const baseTimeMs = Date.now() - 60_000;
|
|
332
|
+
const isoTime = (offsetMs: number) =>
|
|
333
|
+
new Date(baseTimeMs + offsetMs).toISOString();
|
|
301
334
|
|
|
302
335
|
await provider["db"]
|
|
303
336
|
.insertInto("mail")
|
|
304
337
|
.values([
|
|
305
338
|
mail(
|
|
306
339
|
"00000000-0000-0000-0000-000000000003",
|
|
307
|
-
|
|
340
|
+
isoTime(2_000),
|
|
308
341
|
"06",
|
|
309
342
|
),
|
|
310
343
|
mail(
|
|
311
344
|
"00000000-0000-0000-0000-000000000001",
|
|
312
|
-
|
|
345
|
+
isoTime(0),
|
|
313
346
|
"04",
|
|
314
347
|
),
|
|
315
348
|
mail(
|
|
316
349
|
"00000000-0000-0000-0000-000000000002",
|
|
317
|
-
|
|
350
|
+
isoTime(1_000),
|
|
318
351
|
"05",
|
|
319
352
|
),
|
|
320
353
|
])
|
|
@@ -0,0 +1,282 @@
|
|
|
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 { SpireOptions } from "../Spire.ts";
|
|
8
|
+
import type { AddressInfo } from "node:net";
|
|
9
|
+
|
|
10
|
+
import express from "express";
|
|
11
|
+
|
|
12
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
13
|
+
|
|
14
|
+
import { Database } from "../Database.ts";
|
|
15
|
+
import {
|
|
16
|
+
devBillingGrantRoutesEnabled,
|
|
17
|
+
getBillingRouter,
|
|
18
|
+
} from "../server/billing.ts";
|
|
19
|
+
|
|
20
|
+
const options: SpireOptions = {
|
|
21
|
+
dbType: "sqlite3mem",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const userID = "4e67b90f-cbf8-44bc-8ce3-d3b248f033f1";
|
|
25
|
+
const servers: Array<{ close: () => void }> = [];
|
|
26
|
+
const originalEnv = { ...process.env };
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
for (const server of servers.splice(0)) {
|
|
30
|
+
server.close();
|
|
31
|
+
}
|
|
32
|
+
process.env = { ...originalEnv };
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("Database store subscriptions", () => {
|
|
36
|
+
it("derives store entitlements from active subscriptions", async () => {
|
|
37
|
+
expect.assertions(4);
|
|
38
|
+
await withDb(async (db) => {
|
|
39
|
+
await db.upsertStoreSubscription({
|
|
40
|
+
environment: "sandbox",
|
|
41
|
+
expiresAt: new Date(Date.now() + 86_400_000).toISOString(),
|
|
42
|
+
externalOriginalID: "orig-1",
|
|
43
|
+
externalTransactionID: "tx-1",
|
|
44
|
+
platform: "apple_app_store",
|
|
45
|
+
productID: "apple_plus_monthly",
|
|
46
|
+
rawPayload: { transactionId: "tx-1" },
|
|
47
|
+
status: "active",
|
|
48
|
+
storeProductID: "chat.vex.plus.monthly",
|
|
49
|
+
tier: "plus",
|
|
50
|
+
userID,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const entitlements = await db.recalculateStoreEntitlements(userID);
|
|
54
|
+
const state = await db.retrieveBillingAccountState(userID);
|
|
55
|
+
|
|
56
|
+
expect(entitlements.tier).toBe("plus");
|
|
57
|
+
expect(entitlements.source).toBe("store");
|
|
58
|
+
expect(state.subscriptions).toHaveLength(1);
|
|
59
|
+
expect(state.subscriptions[0]).toMatchObject({
|
|
60
|
+
platform: "apple_app_store",
|
|
61
|
+
status: "active",
|
|
62
|
+
tier: "plus",
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("downgrades expired store entitlements to free", async () => {
|
|
68
|
+
expect.assertions(2);
|
|
69
|
+
await withDb(async (db) => {
|
|
70
|
+
await db.setAccountEntitlementTier(userID, "pro", {
|
|
71
|
+
expiresAt: new Date(Date.now() - 1_000).toISOString(),
|
|
72
|
+
source: "store",
|
|
73
|
+
});
|
|
74
|
+
await db.upsertStoreSubscription({
|
|
75
|
+
environment: "sandbox",
|
|
76
|
+
expiresAt: new Date(Date.now() - 1_000).toISOString(),
|
|
77
|
+
externalOriginalID: "orig-2",
|
|
78
|
+
externalTransactionID: "tx-2",
|
|
79
|
+
platform: "apple_app_store",
|
|
80
|
+
productID: "apple_pro_monthly",
|
|
81
|
+
rawPayload: { transactionId: "tx-2" },
|
|
82
|
+
status: "expired",
|
|
83
|
+
storeProductID: "chat.vex.pro.monthly",
|
|
84
|
+
tier: "pro",
|
|
85
|
+
userID,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const entitlements = await db.recalculateStoreEntitlements(userID);
|
|
89
|
+
|
|
90
|
+
expect(entitlements.tier).toBe("free");
|
|
91
|
+
expect(entitlements.source).toBe("store");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("billing route guard", () => {
|
|
97
|
+
it("requires explicit non-production grant opt-in", () => {
|
|
98
|
+
expect(devBillingGrantRoutesEnabled({})).toBe(false);
|
|
99
|
+
expect(
|
|
100
|
+
devBillingGrantRoutesEnabled({
|
|
101
|
+
DEV_API_KEY: "local",
|
|
102
|
+
NODE_ENV: "production",
|
|
103
|
+
VEX_ENABLE_DEV_BILLING_GRANTS: "1",
|
|
104
|
+
}),
|
|
105
|
+
).toBe(false);
|
|
106
|
+
expect(
|
|
107
|
+
devBillingGrantRoutesEnabled({
|
|
108
|
+
DEV_API_KEY: "local",
|
|
109
|
+
NODE_ENV: "development",
|
|
110
|
+
VEX_ENABLE_DEV_BILLING_GRANTS: "1",
|
|
111
|
+
}),
|
|
112
|
+
).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("billing routes", () => {
|
|
117
|
+
it("returns configured billing products", async () => {
|
|
118
|
+
process.env["VEX_BILLING_PRODUCTS_JSON"] = JSON.stringify([
|
|
119
|
+
{
|
|
120
|
+
environment: "sandbox",
|
|
121
|
+
platform: "apple_app_store",
|
|
122
|
+
productID: "apple_plus_monthly",
|
|
123
|
+
storeProductID: "chat.vex.plus.monthly",
|
|
124
|
+
tier: "plus",
|
|
125
|
+
},
|
|
126
|
+
]);
|
|
127
|
+
const { baseUrl } = mountBillingRouter({} as Database);
|
|
128
|
+
|
|
129
|
+
const res = await fetch(`${baseUrl}/billing/products?format=json`);
|
|
130
|
+
const body = (await res.json()) as unknown[];
|
|
131
|
+
|
|
132
|
+
expect(res.status).toBe(200);
|
|
133
|
+
expect(body).toEqual([
|
|
134
|
+
{
|
|
135
|
+
environment: "sandbox",
|
|
136
|
+
platform: "apple_app_store",
|
|
137
|
+
productID: "apple_plus_monthly",
|
|
138
|
+
storeProductID: "chat.vex.plus.monthly",
|
|
139
|
+
tier: "plus",
|
|
140
|
+
},
|
|
141
|
+
]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("verifies a local Apple transaction payload and refreshes entitlements", async () => {
|
|
145
|
+
process.env["NODE_ENV"] = "development";
|
|
146
|
+
process.env["VEX_BILLING_ALLOW_LOCAL_STORE_PAYLOADS"] = "1";
|
|
147
|
+
process.env["VEX_BILLING_PRODUCTS_JSON"] = JSON.stringify([
|
|
148
|
+
{
|
|
149
|
+
environment: "sandbox",
|
|
150
|
+
platform: "apple_app_store",
|
|
151
|
+
productID: "apple_plus_monthly",
|
|
152
|
+
storeProductID: "chat.vex.plus.monthly",
|
|
153
|
+
tier: "plus",
|
|
154
|
+
},
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
await withDb(async (db) => {
|
|
158
|
+
const notify = vi.fn();
|
|
159
|
+
const { baseUrl } = mountBillingRouter(db, notify);
|
|
160
|
+
const res = await fetch(
|
|
161
|
+
`${baseUrl}/billing/apple/transactions?format=json`,
|
|
162
|
+
{
|
|
163
|
+
body: JSON.stringify({
|
|
164
|
+
environment: "sandbox",
|
|
165
|
+
signedTransactionInfo: encodedPayload({
|
|
166
|
+
environment: "Sandbox",
|
|
167
|
+
expiresDate: Date.now() + 86_400_000,
|
|
168
|
+
originalTransactionId: "orig-apple-1",
|
|
169
|
+
productId: "chat.vex.plus.monthly",
|
|
170
|
+
transactionId: "tx-apple-1",
|
|
171
|
+
}),
|
|
172
|
+
}),
|
|
173
|
+
headers: { "Content-Type": "application/json" },
|
|
174
|
+
method: "POST",
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
const body = (await res.json()) as {
|
|
178
|
+
entitlements?: { tier?: unknown };
|
|
179
|
+
subscriptions?: unknown[];
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
expect(res.status).toBe(200);
|
|
183
|
+
expect(body.entitlements?.tier).toBe("plus");
|
|
184
|
+
expect(body.subscriptions).toHaveLength(1);
|
|
185
|
+
expect(notify).toHaveBeenCalledWith(
|
|
186
|
+
userID,
|
|
187
|
+
"accountEntitlementsChanged",
|
|
188
|
+
expect.any(String),
|
|
189
|
+
{ tier: "plus" },
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("allows explicit dev billing grants with the dev key", async () => {
|
|
195
|
+
process.env["NODE_ENV"] = "development";
|
|
196
|
+
process.env["DEV_API_KEY"] = "local-secret";
|
|
197
|
+
process.env["VEX_ENABLE_DEV_BILLING_GRANTS"] = "1";
|
|
198
|
+
await withDb(async (db) => {
|
|
199
|
+
const notify = vi.fn();
|
|
200
|
+
const { baseUrl } = mountBillingRouter(db, notify);
|
|
201
|
+
|
|
202
|
+
const res = await fetch(
|
|
203
|
+
`${baseUrl}/__dev/billing/grants?format=json`,
|
|
204
|
+
{
|
|
205
|
+
body: JSON.stringify({ tier: "pro", userID }),
|
|
206
|
+
headers: {
|
|
207
|
+
"Content-Type": "application/json",
|
|
208
|
+
"x-dev-api-key": "local-secret",
|
|
209
|
+
},
|
|
210
|
+
method: "POST",
|
|
211
|
+
},
|
|
212
|
+
);
|
|
213
|
+
const body = (await res.json()) as {
|
|
214
|
+
source?: unknown;
|
|
215
|
+
tier?: unknown;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
expect(res.status).toBe(200);
|
|
219
|
+
expect(body).toMatchObject({ source: "store", tier: "pro" });
|
|
220
|
+
expect(notify).toHaveBeenCalledWith(
|
|
221
|
+
userID,
|
|
222
|
+
"accountEntitlementsChanged",
|
|
223
|
+
expect.any(String),
|
|
224
|
+
{ tier: "pro" },
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
function encodedPayload(payload: unknown): string {
|
|
231
|
+
return Buffer.from(JSON.stringify(payload))
|
|
232
|
+
.toString("base64")
|
|
233
|
+
.replace(/=/g, "")
|
|
234
|
+
.replace(/\+/g, "-")
|
|
235
|
+
.replace(/\//g, "_");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function mountBillingRouter(
|
|
239
|
+
db: Database,
|
|
240
|
+
notify = vi.fn(),
|
|
241
|
+
): { baseUrl: string } {
|
|
242
|
+
const app = express();
|
|
243
|
+
app.use(express.json());
|
|
244
|
+
app.use((req, _res, next) => {
|
|
245
|
+
(
|
|
246
|
+
req as express.Request & {
|
|
247
|
+
user?: { lastSeen: string; userID: string; username: string };
|
|
248
|
+
}
|
|
249
|
+
).user = {
|
|
250
|
+
lastSeen: new Date().toISOString(),
|
|
251
|
+
userID,
|
|
252
|
+
username: "alice",
|
|
253
|
+
};
|
|
254
|
+
next();
|
|
255
|
+
});
|
|
256
|
+
app.use(getBillingRouter(db, notify));
|
|
257
|
+
|
|
258
|
+
const server = app.listen(0);
|
|
259
|
+
servers.push(server);
|
|
260
|
+
const { port } = server.address() as AddressInfo;
|
|
261
|
+
return { baseUrl: `http://127.0.0.1:${String(port)}` };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function withDb<T>(fn: (db: Database) => Promise<T>): Promise<T> {
|
|
265
|
+
const provider = new Database(options);
|
|
266
|
+
return new Promise<T>((resolve, reject) => {
|
|
267
|
+
provider.once("ready", () => {
|
|
268
|
+
void (async () => {
|
|
269
|
+
try {
|
|
270
|
+
const result = await fn(provider);
|
|
271
|
+
await provider.close();
|
|
272
|
+
resolve(result);
|
|
273
|
+
} catch (e: unknown) {
|
|
274
|
+
await provider.close().catch(() => {
|
|
275
|
+
// best-effort cleanup; ignore close failures here
|
|
276
|
+
});
|
|
277
|
+
reject(e instanceof Error ? e : new Error(String(e)));
|
|
278
|
+
}
|
|
279
|
+
})();
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
}
|