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