@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,120 @@
|
|
|
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
|
+
import type { Device, User } from "@vex-chat/types";
|
|
9
|
+
import type { Server } from "node:http";
|
|
10
|
+
|
|
11
|
+
import express from "express";
|
|
12
|
+
|
|
13
|
+
import { xSignAsync, xSignKeyPair, XUtils } from "@vex-chat/crypto";
|
|
14
|
+
import { TokenScopes } from "@vex-chat/types";
|
|
15
|
+
|
|
16
|
+
import jwt from "jsonwebtoken";
|
|
17
|
+
import { parse as uuidParse } from "uuid";
|
|
18
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
19
|
+
|
|
20
|
+
import { initApp } from "../server/index.ts";
|
|
21
|
+
import { getJwtSecret } from "../utils/jwtSecret.ts";
|
|
22
|
+
import { msgpack } from "../utils/msgpack.ts";
|
|
23
|
+
|
|
24
|
+
const originalJwtSecret = process.env["JWT_SECRET"];
|
|
25
|
+
|
|
26
|
+
const user: User = {
|
|
27
|
+
lastSeen: new Date(0).toISOString(),
|
|
28
|
+
userID: "user-a",
|
|
29
|
+
username: "alice",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
describe("device connect auth", () => {
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
if (originalJwtSecret === undefined) {
|
|
35
|
+
delete process.env["JWT_SECRET"];
|
|
36
|
+
} else {
|
|
37
|
+
process.env["JWT_SECRET"] = originalJwtSecret;
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("allows a password-created account to connect before passkey enrollment", async () => {
|
|
42
|
+
process.env["JWT_SECRET"] = "test-jwt-secret";
|
|
43
|
+
const connectToken = "93ce482b-a0f2-4f6e-b1df-3aed61073552";
|
|
44
|
+
const signKeys = xSignKeyPair();
|
|
45
|
+
const device: Device = {
|
|
46
|
+
deleted: false,
|
|
47
|
+
deviceID: "device-a",
|
|
48
|
+
lastLogin: new Date(0).toISOString(),
|
|
49
|
+
name: "desktop",
|
|
50
|
+
owner: user.userID,
|
|
51
|
+
signKey: XUtils.encodeHex(signKeys.publicKey),
|
|
52
|
+
};
|
|
53
|
+
const db = {
|
|
54
|
+
retrieveDevice: (deviceID: string) =>
|
|
55
|
+
Promise.resolve(deviceID === device.deviceID ? device : null),
|
|
56
|
+
} as unknown as Database;
|
|
57
|
+
const app = express();
|
|
58
|
+
initApp(
|
|
59
|
+
app,
|
|
60
|
+
db,
|
|
61
|
+
(key, scope) =>
|
|
62
|
+
key === connectToken && scope === TokenScopes.Connect,
|
|
63
|
+
xSignKeyPair(),
|
|
64
|
+
() => {},
|
|
65
|
+
);
|
|
66
|
+
const server = await listen(app);
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const address = server.address();
|
|
70
|
+
if (!address || typeof address === "string") {
|
|
71
|
+
throw new Error("Expected TCP listener.");
|
|
72
|
+
}
|
|
73
|
+
const signed = await xSignAsync(
|
|
74
|
+
Uint8Array.from(uuidParse(connectToken)),
|
|
75
|
+
signKeys.secretKey,
|
|
76
|
+
);
|
|
77
|
+
const token = jwt.sign({ user }, getJwtSecret());
|
|
78
|
+
|
|
79
|
+
const res = await fetch(
|
|
80
|
+
`http://127.0.0.1:${String(address.port)}/device/${device.deviceID}/connect`,
|
|
81
|
+
{
|
|
82
|
+
body: msgpack.encode({ signed }),
|
|
83
|
+
headers: {
|
|
84
|
+
Authorization: `Bearer ${token}`,
|
|
85
|
+
"Content-Type": "application/msgpack",
|
|
86
|
+
},
|
|
87
|
+
method: "POST",
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
expect(res.status).toBe(200);
|
|
92
|
+
const body = msgpack.decode(
|
|
93
|
+
new Uint8Array(await res.arrayBuffer()),
|
|
94
|
+
) as { deviceToken?: unknown };
|
|
95
|
+
expect(typeof body.deviceToken).toBe("string");
|
|
96
|
+
} finally {
|
|
97
|
+
await close(server);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
function close(server: Server): Promise<void> {
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
server.close((err) => {
|
|
105
|
+
if (err) {
|
|
106
|
+
reject(err);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
resolve();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function listen(app: express.Application): Promise<Server> {
|
|
115
|
+
return new Promise((resolve) => {
|
|
116
|
+
const server = app.listen(0, "127.0.0.1", () => {
|
|
117
|
+
resolve(server);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -8,8 +8,6 @@ import type { ClientManager } from "../ClientManager.ts";
|
|
|
8
8
|
import type { Database, NotificationSubscription } from "../Database.ts";
|
|
9
9
|
import type { BaseMsg } from "@vex-chat/types";
|
|
10
10
|
|
|
11
|
-
import { generateKeyPairSync } from "node:crypto";
|
|
12
|
-
|
|
13
11
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
14
12
|
|
|
15
13
|
import { NotificationService } from "../NotificationService.ts";
|
|
@@ -537,140 +535,6 @@ describe("Spire notify fanout", () => {
|
|
|
537
535
|
});
|
|
538
536
|
});
|
|
539
537
|
|
|
540
|
-
it("sends Expo callWake pushes with opaque call data", async () => {
|
|
541
|
-
const callSubscription: NotificationSubscription = {
|
|
542
|
-
...subscription,
|
|
543
|
-
events: ["callWake"],
|
|
544
|
-
subscriptionID: "sub-call",
|
|
545
|
-
};
|
|
546
|
-
const fetchMock = vi.fn().mockResolvedValueOnce({
|
|
547
|
-
json: () =>
|
|
548
|
-
Promise.resolve({
|
|
549
|
-
data: [{ id: "receipt-a", status: "ok" }],
|
|
550
|
-
}),
|
|
551
|
-
ok: true,
|
|
552
|
-
});
|
|
553
|
-
vi.stubGlobal("fetch", fetchMock);
|
|
554
|
-
|
|
555
|
-
const { db } = createSpireHarness([], [callSubscription]);
|
|
556
|
-
const service = new NotificationService(db, [], () => {});
|
|
557
|
-
|
|
558
|
-
await service["notifyPush"]({
|
|
559
|
-
data: {
|
|
560
|
-
callID: "call-1",
|
|
561
|
-
expiresAt: "2026-06-05T12:01:00.000Z",
|
|
562
|
-
mailID: "mail-1",
|
|
563
|
-
mailNonce: "abcd",
|
|
564
|
-
},
|
|
565
|
-
deviceID: callSubscription.deviceID,
|
|
566
|
-
event: "callWake",
|
|
567
|
-
transmissionID: "00000000-0000-0000-0000-000000000017",
|
|
568
|
-
userID: callSubscription.userID,
|
|
569
|
-
});
|
|
570
|
-
|
|
571
|
-
const init = fetchMock.mock.calls[0]?.[1] as
|
|
572
|
-
| undefined
|
|
573
|
-
| { body?: unknown };
|
|
574
|
-
const messages = JSON.parse(String(init?.body)) as Array<{
|
|
575
|
-
body?: string;
|
|
576
|
-
data?: Record<string, unknown>;
|
|
577
|
-
title?: string;
|
|
578
|
-
}>;
|
|
579
|
-
expect(messages[0]?.title).toBe("Incoming Vex call");
|
|
580
|
-
expect(messages[0]?.body).toBe("Incoming voice call.");
|
|
581
|
-
expect(messages[0]?.data).toMatchObject({
|
|
582
|
-
callID: "call-1",
|
|
583
|
-
event: "callWake",
|
|
584
|
-
expiresAt: "2026-06-05T12:01:00.000Z",
|
|
585
|
-
mailID: "mail-1",
|
|
586
|
-
mailNonce: "abcd",
|
|
587
|
-
transmissionID: "00000000-0000-0000-0000-000000000017",
|
|
588
|
-
});
|
|
589
|
-
expect(messages[0]?.data).not.toHaveProperty("callerUserID");
|
|
590
|
-
expect(messages[0]?.data).not.toHaveProperty("sdp");
|
|
591
|
-
});
|
|
592
|
-
|
|
593
|
-
it("sends FCM call pushes as high-priority opaque data", async () => {
|
|
594
|
-
const { privateKey } = generateKeyPairSync("rsa", {
|
|
595
|
-
modulusLength: 2048,
|
|
596
|
-
});
|
|
597
|
-
const fcmSubscription: NotificationSubscription = {
|
|
598
|
-
...subscription,
|
|
599
|
-
channel: "fcmCall",
|
|
600
|
-
events: ["callWake"],
|
|
601
|
-
platform: "android",
|
|
602
|
-
subscriptionID: "sub-fcm",
|
|
603
|
-
token: "fcm-token",
|
|
604
|
-
};
|
|
605
|
-
const fetchMock = vi
|
|
606
|
-
.fn()
|
|
607
|
-
.mockResolvedValueOnce({
|
|
608
|
-
json: () => Promise.resolve({ access_token: "access-token" }),
|
|
609
|
-
ok: true,
|
|
610
|
-
})
|
|
611
|
-
.mockResolvedValueOnce({
|
|
612
|
-
ok: true,
|
|
613
|
-
});
|
|
614
|
-
vi.stubGlobal("fetch", fetchMock);
|
|
615
|
-
|
|
616
|
-
process.env["SPIRE_FCM_CLIENT_EMAIL"] = "spire@example.invalid";
|
|
617
|
-
process.env["SPIRE_FCM_PRIVATE_KEY"] = privateKey.export({
|
|
618
|
-
format: "pem",
|
|
619
|
-
type: "pkcs8",
|
|
620
|
-
});
|
|
621
|
-
process.env["SPIRE_FCM_PROJECT_ID"] = "vex-test";
|
|
622
|
-
try {
|
|
623
|
-
const { db } = createSpireHarness([], [fcmSubscription]);
|
|
624
|
-
const service = new NotificationService(db, [], () => {});
|
|
625
|
-
|
|
626
|
-
await service["notifyPush"]({
|
|
627
|
-
data: {
|
|
628
|
-
callID: "call-fcm",
|
|
629
|
-
expiresAt: "2026-06-05T12:01:00.000Z",
|
|
630
|
-
mailID: "mail-fcm",
|
|
631
|
-
mailNonce: "beef",
|
|
632
|
-
},
|
|
633
|
-
deviceID: fcmSubscription.deviceID,
|
|
634
|
-
event: "callWake",
|
|
635
|
-
transmissionID: "00000000-0000-0000-0000-000000000018",
|
|
636
|
-
userID: fcmSubscription.userID,
|
|
637
|
-
});
|
|
638
|
-
} finally {
|
|
639
|
-
delete process.env["SPIRE_FCM_CLIENT_EMAIL"];
|
|
640
|
-
delete process.env["SPIRE_FCM_PRIVATE_KEY"];
|
|
641
|
-
delete process.env["SPIRE_FCM_PROJECT_ID"];
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
645
|
-
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
646
|
-
"https://oauth2.googleapis.com/token",
|
|
647
|
-
);
|
|
648
|
-
expect(fetchMock.mock.calls[1]?.[0]).toBe(
|
|
649
|
-
"https://fcm.googleapis.com/v1/projects/vex-test/messages:send",
|
|
650
|
-
);
|
|
651
|
-
const init = fetchMock.mock.calls[1]?.[1] as
|
|
652
|
-
| undefined
|
|
653
|
-
| { body?: unknown };
|
|
654
|
-
const body = JSON.parse(String(init?.body)) as {
|
|
655
|
-
message?: {
|
|
656
|
-
android?: { priority?: string; ttl?: string };
|
|
657
|
-
data?: Record<string, string>;
|
|
658
|
-
token?: string;
|
|
659
|
-
};
|
|
660
|
-
};
|
|
661
|
-
expect(body.message?.token).toBe("fcm-token");
|
|
662
|
-
expect(body.message?.android?.priority).toBe("HIGH");
|
|
663
|
-
expect(body.message?.data).toMatchObject({
|
|
664
|
-
callID: "call-fcm",
|
|
665
|
-
event: "callWake",
|
|
666
|
-
mailID: "mail-fcm",
|
|
667
|
-
mailNonce: "beef",
|
|
668
|
-
transmissionID: "00000000-0000-0000-0000-000000000018",
|
|
669
|
-
});
|
|
670
|
-
expect(body.message?.data).not.toHaveProperty("callerUserID");
|
|
671
|
-
expect(body.message?.data).not.toHaveProperty("sdp");
|
|
672
|
-
});
|
|
673
|
-
|
|
674
538
|
it("requests the default sound for iOS visible Expo pushes only", async () => {
|
|
675
539
|
const iosSubscription: NotificationSubscription = {
|
|
676
540
|
...subscription,
|
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
|
+
}
|