@vex-chat/spire 2.4.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/BillingVerification.d.ts +40 -0
  2. package/dist/BillingVerification.js +535 -0
  3. package/dist/BillingVerification.js.map +1 -0
  4. package/dist/Database.d.ts +39 -1
  5. package/dist/Database.js +285 -2
  6. package/dist/Database.js.map +1 -1
  7. package/dist/db/schema.d.ts +50 -0
  8. package/dist/migrations/2026-06-24_account-entitlements.d.ts +8 -0
  9. package/dist/migrations/2026-06-24_account-entitlements.js +20 -0
  10. package/dist/migrations/2026-06-24_account-entitlements.js.map +1 -0
  11. package/dist/migrations/2026-07-02_store-subscriptions.d.ts +8 -0
  12. package/dist/migrations/2026-07-02_store-subscriptions.js +83 -0
  13. package/dist/migrations/2026-07-02_store-subscriptions.js.map +1 -0
  14. package/dist/server/billing.d.ts +14 -0
  15. package/dist/server/billing.js +234 -0
  16. package/dist/server/billing.js.map +1 -0
  17. package/dist/server/entitlements.d.ts +8 -0
  18. package/dist/server/entitlements.js +71 -0
  19. package/dist/server/entitlements.js.map +1 -0
  20. package/dist/server/index.js +6 -0
  21. package/dist/server/index.js.map +1 -1
  22. package/package.json +4 -4
  23. package/src/BillingVerification.ts +800 -0
  24. package/src/Database.ts +422 -2
  25. package/src/__tests__/Database.spec.ts +6 -3
  26. package/src/__tests__/billing.spec.ts +282 -0
  27. package/src/__tests__/entitlements.spec.ts +241 -0
  28. package/src/db/schema.ts +66 -1
  29. package/src/migrations/2026-06-24_account-entitlements.ts +23 -0
  30. package/src/migrations/2026-07-02_store-subscriptions.ts +92 -0
  31. package/src/server/billing.ts +361 -0
  32. package/src/server/entitlements.ts +104 -0
  33. package/src/server/index.ts +6 -0
@@ -0,0 +1,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
+ }
@@ -0,0 +1,241 @@
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 { buildAccountEntitlements } from "@vex-chat/types";
13
+
14
+ import { afterEach, describe, expect, it, vi } from "vitest";
15
+
16
+ import { Database } from "../Database.ts";
17
+ import {
18
+ devEntitlementRoutesEnabled,
19
+ getEntitlementRouter,
20
+ } from "../server/entitlements.ts";
21
+
22
+ const options: SpireOptions = {
23
+ dbType: "sqlite3mem",
24
+ };
25
+
26
+ const userID = "4e67b90f-cbf8-44bc-8ce3-d3b248f033f1";
27
+ const servers: Array<{ close: () => void }> = [];
28
+ const originalEnv = { ...process.env };
29
+
30
+ afterEach(() => {
31
+ for (const server of servers.splice(0)) {
32
+ server.close();
33
+ }
34
+ process.env = { ...originalEnv };
35
+ });
36
+
37
+ async function withDb<T>(fn: (db: Database) => Promise<T>): Promise<T> {
38
+ const provider = new Database(options);
39
+ return new Promise<T>((resolve, reject) => {
40
+ provider.once("ready", () => {
41
+ void (async () => {
42
+ try {
43
+ const result = await fn(provider);
44
+ await provider.close();
45
+ resolve(result);
46
+ } catch (e: unknown) {
47
+ await provider.close().catch(() => {
48
+ // best-effort cleanup; ignore close failures here
49
+ });
50
+ reject(e instanceof Error ? e : new Error(String(e)));
51
+ }
52
+ })();
53
+ });
54
+ });
55
+ }
56
+
57
+ describe("Database account entitlements", () => {
58
+ it("returns a computed free snapshot when the user has no row", async () => {
59
+ expect.assertions(7);
60
+ await withDb(async (db) => {
61
+ const entitlements = await db.retrieveAccountEntitlements(userID);
62
+
63
+ expect(entitlements.userID).toBe(userID);
64
+ expect(entitlements.tier).toBe("free");
65
+ expect(entitlements.source).toBe("default");
66
+ expect(entitlements.expiresAt).toBeNull();
67
+ expect(
68
+ entitlements.capabilities["attachments.encrypted_uploads"],
69
+ ).toBe(true);
70
+ expect(entitlements.capabilities["calls.relay_priority"]).toBe(
71
+ false,
72
+ );
73
+ expect(entitlements.limits["devices.max_trusted_devices"]).toBe(2);
74
+ });
75
+ });
76
+
77
+ it("persists a dev override tier and derives its capabilities", async () => {
78
+ expect.assertions(5);
79
+ await withDb(async (db) => {
80
+ const updated = await db.setAccountEntitlementTier(userID, "pro", {
81
+ source: "dev_override",
82
+ });
83
+ const retrieved = await db.retrieveAccountEntitlements(userID);
84
+
85
+ expect(updated.tier).toBe("pro");
86
+ expect(retrieved.tier).toBe("pro");
87
+ expect(retrieved.source).toBe("dev_override");
88
+ expect(retrieved.capabilities["calls.relay_priority"]).toBe(true);
89
+ expect(retrieved.limits["attachments.max_encrypted_bytes"]).toBe(
90
+ 500 * 1024 * 1024,
91
+ );
92
+ });
93
+ });
94
+ });
95
+
96
+ describe("dev entitlement route guard", () => {
97
+ it("is disabled by default and in production", () => {
98
+ expect(devEntitlementRoutesEnabled({})).toBe(false);
99
+ expect(
100
+ devEntitlementRoutesEnabled({
101
+ DEV_API_KEY: "local",
102
+ NODE_ENV: "production",
103
+ VEX_ENABLE_DEV_ENTITLEMENTS: "1",
104
+ }),
105
+ ).toBe(false);
106
+ });
107
+
108
+ it("requires an explicit flag and dev API key", () => {
109
+ expect(
110
+ devEntitlementRoutesEnabled({
111
+ DEV_API_KEY: "local",
112
+ NODE_ENV: "development",
113
+ VEX_ENABLE_DEV_ENTITLEMENTS: "1",
114
+ }),
115
+ ).toBe(true);
116
+ expect(
117
+ devEntitlementRoutesEnabled({
118
+ NODE_ENV: "development",
119
+ VEX_ENABLE_DEV_ENTITLEMENTS: "1",
120
+ }),
121
+ ).toBe(false);
122
+ });
123
+ });
124
+
125
+ describe("entitlement routes", () => {
126
+ it("returns the authenticated user's entitlement snapshot", async () => {
127
+ const retrieveAccountEntitlements = vi.fn((id: string) =>
128
+ Promise.resolve(
129
+ buildAccountEntitlements({ tier: "plus", userID: id }),
130
+ ),
131
+ );
132
+ const db = {
133
+ retrieveAccountEntitlements,
134
+ } as unknown as Database;
135
+ const { baseUrl } = mountEntitlementRouter(db);
136
+
137
+ const res = await fetch(
138
+ `${baseUrl}/user/${userID}/entitlements?format=json`,
139
+ );
140
+ const body = (await res.json()) as { tier?: unknown; userID?: unknown };
141
+
142
+ expect(res.status).toBe(200);
143
+ expect(body).toMatchObject({ tier: "plus", userID });
144
+ expect(retrieveAccountEntitlements).toHaveBeenCalledWith(userID);
145
+ });
146
+
147
+ it("does not mount the dev override route unless explicitly enabled", async () => {
148
+ const setAccountEntitlementTier = vi.fn();
149
+ const db = {
150
+ retrieveAccountEntitlements: vi.fn(),
151
+ setAccountEntitlementTier,
152
+ } as unknown as Database;
153
+ const { baseUrl } = mountEntitlementRouter(db);
154
+
155
+ const res = await fetch(
156
+ `${baseUrl}/__dev/user/${userID}/entitlements?format=json`,
157
+ {
158
+ body: JSON.stringify({ tier: "pro" }),
159
+ headers: { "Content-Type": "application/json" },
160
+ method: "PATCH",
161
+ },
162
+ );
163
+
164
+ expect(res.status).toBe(404);
165
+ expect(setAccountEntitlementTier).not.toHaveBeenCalled();
166
+ });
167
+
168
+ it("allows dev overrides only with the explicit flag and dev API key", async () => {
169
+ process.env["NODE_ENV"] = "development";
170
+ process.env["VEX_ENABLE_DEV_ENTITLEMENTS"] = "1";
171
+ process.env["DEV_API_KEY"] = "local-secret";
172
+
173
+ const setAccountEntitlementTier = vi.fn((id: string) =>
174
+ Promise.resolve(
175
+ buildAccountEntitlements({
176
+ source: "dev_override",
177
+ tier: "pro",
178
+ userID: id,
179
+ }),
180
+ ),
181
+ );
182
+ const db = {
183
+ retrieveAccountEntitlements: vi.fn(),
184
+ setAccountEntitlementTier,
185
+ } as unknown as Database;
186
+ const notify = vi.fn();
187
+ const { baseUrl } = mountEntitlementRouter(db, notify);
188
+
189
+ const res = await fetch(
190
+ `${baseUrl}/__dev/user/${userID}/entitlements?format=json`,
191
+ {
192
+ body: JSON.stringify({ tier: "pro" }),
193
+ headers: {
194
+ "Content-Type": "application/json",
195
+ "x-dev-api-key": "local-secret",
196
+ },
197
+ method: "PATCH",
198
+ },
199
+ );
200
+ const body = (await res.json()) as { source?: unknown; tier?: unknown };
201
+
202
+ expect(res.status).toBe(200);
203
+ expect(body).toMatchObject({ source: "dev_override", tier: "pro" });
204
+ expect(setAccountEntitlementTier).toHaveBeenCalledWith(userID, "pro", {
205
+ expiresAt: null,
206
+ source: "dev_override",
207
+ });
208
+ expect(notify).toHaveBeenCalledWith(
209
+ userID,
210
+ "accountEntitlementsChanged",
211
+ expect.any(String),
212
+ { tier: "pro" },
213
+ );
214
+ });
215
+ });
216
+
217
+ function mountEntitlementRouter(
218
+ db: Database,
219
+ notify = vi.fn(),
220
+ ): { baseUrl: string } {
221
+ const app = express();
222
+ app.use(express.json());
223
+ app.use((req, _res, next) => {
224
+ (
225
+ req as express.Request & {
226
+ user?: { lastSeen: string; userID: string; username: string };
227
+ }
228
+ ).user = {
229
+ lastSeen: new Date().toISOString(),
230
+ userID,
231
+ username: "alice",
232
+ };
233
+ next();
234
+ });
235
+ app.use(getEntitlementRouter(db, notify));
236
+
237
+ const server = app.listen(0);
238
+ servers.push(server);
239
+ const { port } = server.address() as AddressInfo;
240
+ return { baseUrl: `http://127.0.0.1:${String(port)}` };
241
+ }
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 NewChannel = Insertable<ChannelsTable>;
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
+ }