@vex-chat/libvex 5.1.0 → 5.3.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/CLA.md +38 -0
- package/LICENSE-COMMERCIAL +10 -0
- package/LICENSING.md +15 -0
- package/README.md +8 -2
- package/dist/Client.d.ts +47 -3
- package/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +998 -496
- package/dist/Client.js.map +1 -1
- package/dist/Storage.d.ts +5 -0
- package/dist/Storage.d.ts.map +1 -1
- package/dist/Storage.js +5 -0
- package/dist/Storage.js.map +1 -1
- package/dist/__tests__/harness/memory-storage.d.ts +7 -2
- package/dist/__tests__/harness/memory-storage.d.ts.map +1 -1
- package/dist/__tests__/harness/memory-storage.js +44 -29
- package/dist/__tests__/harness/memory-storage.js.map +1 -1
- package/dist/codec.d.ts +9 -9
- package/dist/codec.d.ts.map +1 -1
- package/dist/codec.js +17 -19
- package/dist/codec.js.map +1 -1
- package/dist/codecs.d.ts +5 -0
- package/dist/codecs.d.ts.map +1 -1
- package/dist/codecs.js +5 -0
- package/dist/codecs.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/keystore/memory.d.ts +5 -0
- package/dist/keystore/memory.d.ts.map +1 -1
- package/dist/keystore/memory.js +5 -0
- package/dist/keystore/memory.js.map +1 -1
- package/dist/keystore/node.d.ts +5 -0
- package/dist/keystore/node.d.ts.map +1 -1
- package/dist/keystore/node.js +16 -8
- package/dist/keystore/node.js.map +1 -1
- package/dist/preset/common.d.ts +5 -0
- package/dist/preset/common.d.ts.map +1 -1
- package/dist/preset/common.js +5 -0
- package/dist/preset/common.js.map +1 -1
- package/dist/preset/node.d.ts +5 -0
- package/dist/preset/node.d.ts.map +1 -1
- package/dist/preset/node.js +9 -1
- package/dist/preset/node.js.map +1 -1
- package/dist/preset/test.d.ts +5 -0
- package/dist/preset/test.d.ts.map +1 -1
- package/dist/preset/test.js +9 -1
- package/dist/preset/test.js.map +1 -1
- package/dist/storage/node/http-agents.d.ts +5 -0
- package/dist/storage/node/http-agents.d.ts.map +1 -1
- package/dist/storage/node/http-agents.js +5 -0
- package/dist/storage/node/http-agents.js.map +1 -1
- package/dist/storage/node.d.ts +6 -1
- package/dist/storage/node.d.ts.map +1 -1
- package/dist/storage/node.js +7 -4
- package/dist/storage/node.js.map +1 -1
- package/dist/storage/schema.d.ts +5 -0
- package/dist/storage/schema.d.ts.map +1 -1
- package/dist/storage/schema.js +5 -0
- package/dist/storage/schema.js.map +1 -1
- package/dist/storage/sqlite.d.ts +22 -4
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +172 -98
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/transport/types.d.ts +5 -0
- package/dist/transport/types.d.ts.map +1 -1
- package/dist/transport/types.js +5 -0
- package/dist/transport/types.js.map +1 -1
- package/dist/transport/websocket.d.ts +5 -0
- package/dist/transport/websocket.d.ts.map +1 -1
- package/dist/transport/websocket.js +5 -0
- package/dist/transport/websocket.js.map +1 -1
- package/dist/types/crypto.d.ts +5 -0
- package/dist/types/crypto.d.ts.map +1 -1
- package/dist/types/crypto.js +3 -5
- package/dist/types/crypto.js.map +1 -1
- package/dist/types/identity.d.ts +5 -0
- package/dist/types/identity.d.ts.map +1 -1
- package/dist/types/identity.js +3 -2
- package/dist/types/identity.js.map +1 -1
- package/dist/types/index.d.ts +5 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +5 -0
- package/dist/types/index.js.map +1 -1
- package/dist/utils/capitalize.d.ts +5 -0
- package/dist/utils/capitalize.d.ts.map +1 -1
- package/dist/utils/capitalize.js +5 -0
- package/dist/utils/capitalize.js.map +1 -1
- package/dist/utils/fipsMailExtra.d.ts +30 -0
- package/dist/utils/fipsMailExtra.d.ts.map +1 -0
- package/dist/utils/fipsMailExtra.js +114 -0
- package/dist/utils/fipsMailExtra.js.map +1 -0
- package/dist/utils/formatBytes.d.ts +5 -0
- package/dist/utils/formatBytes.d.ts.map +1 -1
- package/dist/utils/formatBytes.js +5 -0
- package/dist/utils/formatBytes.js.map +1 -1
- package/dist/utils/resolveAtRestAesKey.d.ts +13 -0
- package/dist/utils/resolveAtRestAesKey.d.ts.map +1 -0
- package/dist/utils/resolveAtRestAesKey.js +26 -0
- package/dist/utils/resolveAtRestAesKey.js.map +1 -0
- package/dist/utils/sqlSessionToCrypto.d.ts +5 -0
- package/dist/utils/sqlSessionToCrypto.d.ts.map +1 -1
- package/dist/utils/sqlSessionToCrypto.js +5 -0
- package/dist/utils/sqlSessionToCrypto.js.map +1 -1
- package/dist/utils/uint8uuid.d.ts +5 -0
- package/dist/utils/uint8uuid.d.ts.map +1 -1
- package/dist/utils/uint8uuid.js +5 -0
- package/dist/utils/uint8uuid.js.map +1 -1
- package/package.json +10 -3
- package/src/Client.ts +1281 -642
- package/src/Storage.ts +6 -0
- package/src/__tests__/codec.test.ts +6 -0
- package/src/__tests__/harness/fixtures.ts +6 -0
- package/src/__tests__/harness/memory-storage.ts +72 -52
- package/src/__tests__/harness/platform-transports.ts +6 -0
- package/src/__tests__/harness/poison-node-imports.ts +6 -0
- package/src/__tests__/harness/shared-suite.ts +288 -124
- package/src/__tests__/platform-browser.test.ts +15 -1
- package/src/__tests__/platform-node.test.ts +17 -3
- package/src/codec.ts +21 -8
- package/src/codecs.ts +6 -0
- package/src/index.ts +6 -0
- package/src/keystore/memory.ts +6 -0
- package/src/keystore/node.ts +27 -13
- package/src/preset/common.ts +6 -0
- package/src/preset/node.ts +14 -1
- package/src/preset/test.ts +14 -1
- package/src/storage/node/http-agents.ts +6 -0
- package/src/storage/node.ts +11 -4
- package/src/storage/schema.ts +6 -0
- package/src/storage/sqlite.ts +208 -135
- package/src/transport/types.ts +6 -0
- package/src/transport/websocket.ts +6 -0
- package/src/types/crypto.ts +6 -0
- package/src/types/identity.ts +6 -0
- package/src/types/index.ts +6 -0
- package/src/utils/capitalize.ts +6 -0
- package/src/utils/fipsMailExtra.ts +164 -0
- package/src/utils/formatBytes.ts +6 -0
- package/src/utils/resolveAtRestAesKey.ts +39 -0
- package/src/utils/sqlSessionToCrypto.ts +6 -0
- package/src/utils/uint8uuid.ts +6 -0
|
@@ -1,17 +1,178 @@
|
|
|
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
|
+
|
|
1
7
|
/**
|
|
2
8
|
* Shared integration test body. Called by each platform entry file
|
|
3
9
|
* with a different adapter factory.
|
|
4
10
|
*
|
|
5
11
|
* Runs register → login → connect → send/receive DM against a real spire.
|
|
12
|
+
*
|
|
13
|
+
* **Vitest e2e only** — the published `Client` does not read the environment;
|
|
14
|
+
* app code passes `ClientOptions` (`host`, `devApiKey`, `cryptoProfile`, …).
|
|
15
|
+
* These `process.env` values are for running **this** repo’s platform tests
|
|
16
|
+
* (shell, CI, or your own env injection — not a “libvex .env” contract).
|
|
17
|
+
* - `API_URL` — Spire base, e.g. `http://127.0.0.1:16777` or `host:port` (http assumed).
|
|
18
|
+
* When set, `beforeAll` reads `GET …/status` `cryptoProfile` and **sets the
|
|
19
|
+
* test process to match** (so a FIPS Spire does not require a separate client flag).
|
|
20
|
+
* A post-check then fails if the client and server still disagree.
|
|
21
|
+
* - `DEV_API_KEY` — must match the Spire `DEV_API_KEY` so the client can send
|
|
22
|
+
* `x-dev-api-key` and avoid dev rate limits.
|
|
23
|
+
* - `LIBVEX_E2E_SKIP_STATUS_CHECK=1` — opt out of the /status profile read + check
|
|
24
|
+
* (e.g. older Spire). Then `LIBVEX_E2E_CRYPTO` or default tweetnacl is used.
|
|
25
|
+
* - `LIBVEX_E2E_CRYPTO` — optional override: `fips` | `tweetnacl` (wins over auto
|
|
26
|
+
* detect from /status; use to force a profile when you know what you need).
|
|
27
|
+
* - `LIBVEX_DEBUG_DM=1` — logs DM/X3dh paths in `Client` to stderr (remove / gate off when done).
|
|
6
28
|
*/
|
|
7
29
|
|
|
8
30
|
import type { ClientOptions, Message } from "../../index.js";
|
|
9
31
|
import type { Storage } from "../../Storage.js";
|
|
10
32
|
|
|
33
|
+
import { getCryptoProfile, setCryptoProfile } from "@vex-chat/crypto";
|
|
34
|
+
|
|
35
|
+
import { isAxiosError } from "axios";
|
|
36
|
+
|
|
11
37
|
import { Client } from "../../index.js";
|
|
12
38
|
|
|
13
39
|
import { testFile, testImage } from "./fixtures.js";
|
|
14
40
|
|
|
41
|
+
/**
|
|
42
|
+
* `GET` `{API_URL or http://}{host}/status` — used for crypto profile preflight
|
|
43
|
+
* (must match `getCryptoProfile()` when running e2e against a custom Spire).
|
|
44
|
+
*/
|
|
45
|
+
function spireStatusUrlFromEnv(): null | string {
|
|
46
|
+
const raw = process.env["API_URL"]?.trim();
|
|
47
|
+
if (raw === undefined || raw.length === 0) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
if (/^https?:\/\//i.test(raw)) {
|
|
51
|
+
const u = new URL(raw);
|
|
52
|
+
return `${u.protocol}//${u.host}/status`;
|
|
53
|
+
}
|
|
54
|
+
return `http://${raw}/status`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** `LIBVEX_E2E_CRYPTO` only — used when status auto-detect is skipped. */
|
|
58
|
+
function e2eCryptoProfileFromEnvOnly(): "fips" | "tweetnacl" {
|
|
59
|
+
const v = process.env["LIBVEX_E2E_CRYPTO"]?.trim().toLowerCase();
|
|
60
|
+
if (v === "fips" || v === "p-256" || v === "p256") {
|
|
61
|
+
return "fips";
|
|
62
|
+
}
|
|
63
|
+
if (v === "tweetnacl" || v === "nacl" || v === "ed25519") {
|
|
64
|
+
return "tweetnacl";
|
|
65
|
+
}
|
|
66
|
+
return "tweetnacl";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Picks the signing profile for the suite: optional env override, else `GET` Spire
|
|
71
|
+
* `/status` when `API_URL` is set, else tweetnacl.
|
|
72
|
+
*/
|
|
73
|
+
async function resolveE2eCryptoProfile(): Promise<"fips" | "tweetnacl"> {
|
|
74
|
+
if (process.env["LIBVEX_E2E_SKIP_STATUS_CHECK"] === "1") {
|
|
75
|
+
return e2eCryptoProfileFromEnvOnly();
|
|
76
|
+
}
|
|
77
|
+
const v = process.env["LIBVEX_E2E_CRYPTO"]?.trim().toLowerCase();
|
|
78
|
+
if (v === "fips" || v === "p-256" || v === "p256") {
|
|
79
|
+
return "fips";
|
|
80
|
+
}
|
|
81
|
+
if (v === "tweetnacl" || v === "nacl" || v === "ed25519") {
|
|
82
|
+
return "tweetnacl";
|
|
83
|
+
}
|
|
84
|
+
if (v !== undefined && v.length > 0) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`libvex e2e: invalid LIBVEX_E2E_CRYPTO=${JSON.stringify(v)}. Use fips, tweetnacl, or leave unset to auto-detect from Spire /status`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
const url = spireStatusUrlFromEnv();
|
|
90
|
+
if (url === null) {
|
|
91
|
+
return "tweetnacl";
|
|
92
|
+
}
|
|
93
|
+
let res: Response;
|
|
94
|
+
try {
|
|
95
|
+
res = await fetch(url, { method: "GET" });
|
|
96
|
+
} catch {
|
|
97
|
+
return "tweetnacl";
|
|
98
|
+
}
|
|
99
|
+
if (!res.ok) {
|
|
100
|
+
return "tweetnacl";
|
|
101
|
+
}
|
|
102
|
+
const data: unknown = await res.json();
|
|
103
|
+
if (
|
|
104
|
+
typeof data !== "object" ||
|
|
105
|
+
data === null ||
|
|
106
|
+
!("cryptoProfile" in data)
|
|
107
|
+
) {
|
|
108
|
+
return "tweetnacl";
|
|
109
|
+
}
|
|
110
|
+
const cp = (data as { cryptoProfile: unknown }).cryptoProfile;
|
|
111
|
+
if (cp === "fips" || cp === "tweetnacl") {
|
|
112
|
+
return cp;
|
|
113
|
+
}
|
|
114
|
+
return "tweetnacl";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function e2eClientOptionsBase(): ClientOptions {
|
|
118
|
+
return {
|
|
119
|
+
inMemoryDb: true,
|
|
120
|
+
...apiUrlOverrideFromEnv(),
|
|
121
|
+
cryptoProfile: getCryptoProfile(),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function e2eGenerateSecretKey(): Promise<string> {
|
|
126
|
+
return await Client.generateSecretKeyAsync();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function assertSpireCryptoProfileMatchesTest(): Promise<void> {
|
|
130
|
+
if (process.env["LIBVEX_E2E_SKIP_STATUS_CHECK"] === "1") {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const url = spireStatusUrlFromEnv();
|
|
134
|
+
if (url === null) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const want = getCryptoProfile();
|
|
138
|
+
let res: Response;
|
|
139
|
+
try {
|
|
140
|
+
res = await fetch(url, { method: "GET" });
|
|
141
|
+
} catch (e: unknown) {
|
|
142
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
143
|
+
throw new Error(
|
|
144
|
+
`libvex e2e: could not GET ${url} (check API_URL; is Spire running?): ${msg}`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (!res.ok) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`libvex e2e: ${url} returned HTTP ${String(res.status)} — Spire not reachable on this base URL?`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
const data: unknown = await res.json();
|
|
153
|
+
if (
|
|
154
|
+
typeof data !== "object" ||
|
|
155
|
+
data === null ||
|
|
156
|
+
!("cryptoProfile" in data) ||
|
|
157
|
+
typeof (data as { cryptoProfile: unknown }).cryptoProfile !== "string"
|
|
158
|
+
) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`libvex e2e: Spire /status is missing a string "cryptoProfile" (upgrade Spire) or set LIBVEX_E2E_SKIP_STATUS_CHECK=1 to skip this check`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
const gotStr = (data as { cryptoProfile: string }).cryptoProfile;
|
|
164
|
+
if (gotStr !== "fips" && gotStr !== "tweetnacl") {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`libvex e2e: Spire /status cryptoProfile is not fips|tweetnacl: ${gotStr}`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
if (gotStr !== want) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
`libvex e2e: Spire is cryptoProfile=${gotStr} (see SPIRE_FIPS + SPK) but this test has getCryptoProfile()=${want}. Use matching keys/scripts (gen-spk.js vs gen-spk-fips.js) and the same mode on client and server.`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
15
176
|
export function platformSuite(
|
|
16
177
|
platformName: string,
|
|
17
178
|
makeStorage: (SK: string, opts: ClientOptions) => Promise<Storage>,
|
|
@@ -22,14 +183,14 @@ export function platformSuite(
|
|
|
22
183
|
const password = "platform-test-pw";
|
|
23
184
|
|
|
24
185
|
beforeAll(async () => {
|
|
25
|
-
const
|
|
186
|
+
const profile = await resolveE2eCryptoProfile();
|
|
187
|
+
setCryptoProfile(profile);
|
|
188
|
+
const SK = await e2eGenerateSecretKey();
|
|
26
189
|
|
|
27
|
-
const opts: ClientOptions =
|
|
28
|
-
inMemoryDb: true,
|
|
29
|
-
...apiUrlOverrideFromEnv(),
|
|
30
|
-
};
|
|
190
|
+
const opts: ClientOptions = e2eClientOptionsBase();
|
|
31
191
|
const storage = await makeStorage(SK, opts);
|
|
32
192
|
client = await Client.create(SK, opts, storage);
|
|
193
|
+
await assertSpireCryptoProfileMatchesTest();
|
|
33
194
|
});
|
|
34
195
|
|
|
35
196
|
afterAll(async () => {
|
|
@@ -67,11 +228,8 @@ export function platformSuite(
|
|
|
67
228
|
});
|
|
68
229
|
|
|
69
230
|
test("two-user DM", async () => {
|
|
70
|
-
const SK2 =
|
|
71
|
-
const opts2: ClientOptions =
|
|
72
|
-
inMemoryDb: true,
|
|
73
|
-
...apiUrlOverrideFromEnv(),
|
|
74
|
-
};
|
|
231
|
+
const SK2 = await e2eGenerateSecretKey();
|
|
232
|
+
const opts2: ClientOptions = e2eClientOptionsBase();
|
|
75
233
|
const storage2 = await makeStorage(SK2, opts2);
|
|
76
234
|
const client2 = await Client.create(SK2, opts2, storage2);
|
|
77
235
|
const username2 = Client.randomUsername();
|
|
@@ -104,14 +262,12 @@ export function platformSuite(
|
|
|
104
262
|
});
|
|
105
263
|
|
|
106
264
|
test("group messaging in channel", async () => {
|
|
107
|
-
const SK2 =
|
|
108
|
-
const opts2: ClientOptions =
|
|
109
|
-
inMemoryDb: true,
|
|
110
|
-
...apiUrlOverrideFromEnv(),
|
|
111
|
-
};
|
|
265
|
+
const SK2 = await e2eGenerateSecretKey();
|
|
266
|
+
const opts2: ClientOptions = e2eClientOptionsBase();
|
|
112
267
|
const storage2 = await makeStorage(SK2, opts2);
|
|
113
268
|
const client2 = await Client.create(SK2, opts2, storage2);
|
|
114
269
|
const username2 = Client.randomUsername();
|
|
270
|
+
let serverIdForCleanup: string | undefined;
|
|
115
271
|
|
|
116
272
|
try {
|
|
117
273
|
// Register + login + connect user2
|
|
@@ -120,8 +276,11 @@ export function platformSuite(
|
|
|
120
276
|
await connectAndWait(client2, "client2");
|
|
121
277
|
|
|
122
278
|
// user1 creates server + channel
|
|
123
|
-
const server = await
|
|
279
|
+
const server = await withTransientRetry(() =>
|
|
280
|
+
client.servers.create("test-server"),
|
|
281
|
+
);
|
|
124
282
|
expect(server).toBeTruthy();
|
|
283
|
+
serverIdForCleanup = server.serverID;
|
|
125
284
|
const channels = await client.channels.retrieve(
|
|
126
285
|
server.serverID,
|
|
127
286
|
);
|
|
@@ -130,12 +289,13 @@ export function platformSuite(
|
|
|
130
289
|
if (!channel) throw new Error("No channel found");
|
|
131
290
|
|
|
132
291
|
// user1 creates invite, user2 redeems it
|
|
133
|
-
const invite = await
|
|
134
|
-
server.serverID,
|
|
135
|
-
"1h",
|
|
292
|
+
const invite = await withTransientRetry(() =>
|
|
293
|
+
client.invites.create(server.serverID, "1h"),
|
|
136
294
|
);
|
|
137
295
|
expect(invite).toBeTruthy();
|
|
138
|
-
await
|
|
296
|
+
await withTransientRetry(() =>
|
|
297
|
+
client2.invites.redeem(invite.inviteID),
|
|
298
|
+
);
|
|
139
299
|
|
|
140
300
|
// user1 sends group message, user2 receives it
|
|
141
301
|
const msgPromise = waitForMessage(
|
|
@@ -147,13 +307,21 @@ export function platformSuite(
|
|
|
147
307
|
"group message receive",
|
|
148
308
|
15_000,
|
|
149
309
|
);
|
|
150
|
-
|
|
310
|
+
await withTransientRetry(() =>
|
|
311
|
+
client.messages.group(channel.channelID, "hello channel"),
|
|
312
|
+
);
|
|
151
313
|
const msg = await msgPromise;
|
|
152
314
|
expect(msg.message).toBe("hello channel");
|
|
153
315
|
|
|
154
316
|
// Cleanup
|
|
155
317
|
await client.servers.delete(server.serverID);
|
|
318
|
+
serverIdForCleanup = undefined;
|
|
156
319
|
} finally {
|
|
320
|
+
if (serverIdForCleanup) {
|
|
321
|
+
await client.servers
|
|
322
|
+
.delete(serverIdForCleanup)
|
|
323
|
+
.catch(() => {});
|
|
324
|
+
}
|
|
157
325
|
await client2.close().catch(() => {});
|
|
158
326
|
}
|
|
159
327
|
});
|
|
@@ -163,10 +331,7 @@ export function platformSuite(
|
|
|
163
331
|
// device key, authenticate without password.
|
|
164
332
|
const deviceKey = client.getKeys().private;
|
|
165
333
|
const deviceID = client.me.device().deviceID;
|
|
166
|
-
const opts2: ClientOptions =
|
|
167
|
-
inMemoryDb: true,
|
|
168
|
-
...apiUrlOverrideFromEnv(),
|
|
169
|
-
};
|
|
334
|
+
const opts2: ClientOptions = e2eClientOptionsBase();
|
|
170
335
|
const storage2 = await makeStorage(deviceKey, opts2);
|
|
171
336
|
const client2 = await Client.create(deviceKey, opts2, storage2);
|
|
172
337
|
|
|
@@ -185,8 +350,8 @@ export function platformSuite(
|
|
|
185
350
|
});
|
|
186
351
|
|
|
187
352
|
test("server CRUD", async () => {
|
|
188
|
-
|
|
189
|
-
|
|
353
|
+
// Do not assert permissions start empty: earlier tests in this sequential
|
|
354
|
+
// suite (or a partially cleaned server from a flaky run) may leave rows.
|
|
190
355
|
|
|
191
356
|
const server = await client.servers.create("Test Server");
|
|
192
357
|
const serverList = await client.servers.retrieve();
|
|
@@ -226,13 +391,30 @@ export function platformSuite(
|
|
|
226
391
|
});
|
|
227
392
|
|
|
228
393
|
test("invite create + redeem", async () => {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
394
|
+
let serverIdForCleanup: string | undefined;
|
|
395
|
+
try {
|
|
396
|
+
const server = await withTransientRetry(() =>
|
|
397
|
+
client.servers.create("Invite Test Server"),
|
|
398
|
+
);
|
|
399
|
+
serverIdForCleanup = server.serverID;
|
|
400
|
+
const invite = await withTransientRetry(() =>
|
|
401
|
+
client.invites.create(server.serverID, "1h"),
|
|
402
|
+
);
|
|
403
|
+
expect(invite).toBeTruthy();
|
|
404
|
+
expect(invite.serverID).toBe(server.serverID);
|
|
233
405
|
|
|
234
|
-
|
|
235
|
-
|
|
406
|
+
await withTransientRetry(() =>
|
|
407
|
+
client.invites.redeem(invite.inviteID),
|
|
408
|
+
);
|
|
409
|
+
await client.servers.delete(server.serverID);
|
|
410
|
+
serverIdForCleanup = undefined;
|
|
411
|
+
} finally {
|
|
412
|
+
if (serverIdForCleanup) {
|
|
413
|
+
await client.servers
|
|
414
|
+
.delete(serverIdForCleanup)
|
|
415
|
+
.catch(() => {});
|
|
416
|
+
}
|
|
417
|
+
}
|
|
236
418
|
});
|
|
237
419
|
|
|
238
420
|
test("message history retrieve + delete", async () => {
|
|
@@ -255,91 +437,6 @@ export function platformSuite(
|
|
|
255
437
|
expect(afterDelete.length).toBe(0);
|
|
256
438
|
});
|
|
257
439
|
|
|
258
|
-
// TODO: multi-device fan-out requires sender to query fresh device
|
|
259
|
-
// list before sending. Currently the sender caches one device and
|
|
260
|
-
// the message only reaches device1.
|
|
261
|
-
test.todo("multi-device message sync", async () => {
|
|
262
|
-
const SK2 = Client.generateSecretKey();
|
|
263
|
-
const opts2: ClientOptions = {
|
|
264
|
-
inMemoryDb: true,
|
|
265
|
-
...apiUrlOverrideFromEnv(),
|
|
266
|
-
};
|
|
267
|
-
const storage2 = await makeStorage(SK2, opts2);
|
|
268
|
-
const device2 = await Client.create(SK2, opts2, storage2);
|
|
269
|
-
|
|
270
|
-
// Sender: separate user
|
|
271
|
-
const SK3 = Client.generateSecretKey();
|
|
272
|
-
const opts3: ClientOptions = {
|
|
273
|
-
inMemoryDb: true,
|
|
274
|
-
...apiUrlOverrideFromEnv(),
|
|
275
|
-
};
|
|
276
|
-
const storage3 = await makeStorage(SK3, opts3);
|
|
277
|
-
const sender = await Client.create(SK3, opts3, storage3);
|
|
278
|
-
const senderName = Client.randomUsername();
|
|
279
|
-
|
|
280
|
-
try {
|
|
281
|
-
// Register device2 under same account
|
|
282
|
-
await device2.login(username, password);
|
|
283
|
-
await connectAndWait(device2, "device2");
|
|
284
|
-
|
|
285
|
-
// Register + connect sender
|
|
286
|
-
await sender.register(senderName, "sender-pw");
|
|
287
|
-
await sender.login(senderName, "sender-pw");
|
|
288
|
-
await connectAndWait(sender, "sender");
|
|
289
|
-
|
|
290
|
-
const targetUserID = client.me.user().userID;
|
|
291
|
-
|
|
292
|
-
// Both devices listen for the incoming DM
|
|
293
|
-
const received = { device1: false, device2: false };
|
|
294
|
-
|
|
295
|
-
const waitForBoth = new Promise<void>((resolve, reject) => {
|
|
296
|
-
const timer = setTimeout(() => {
|
|
297
|
-
reject(
|
|
298
|
-
new Error(
|
|
299
|
-
`multi-device sync timed out (d1=${String(received.device1)}, d2=${String(received.device2)})`,
|
|
300
|
-
),
|
|
301
|
-
);
|
|
302
|
-
}, 15_000);
|
|
303
|
-
const check = () => {
|
|
304
|
-
if (received.device1 && received.device2) {
|
|
305
|
-
clearTimeout(timer);
|
|
306
|
-
resolve();
|
|
307
|
-
}
|
|
308
|
-
};
|
|
309
|
-
|
|
310
|
-
client.on("message", (msg: Message) => {
|
|
311
|
-
if (
|
|
312
|
-
msg.direction === "incoming" &&
|
|
313
|
-
msg.decrypted &&
|
|
314
|
-
msg.message === "sync-test"
|
|
315
|
-
) {
|
|
316
|
-
received.device1 = true;
|
|
317
|
-
check();
|
|
318
|
-
}
|
|
319
|
-
});
|
|
320
|
-
device2.on("message", (msg: Message) => {
|
|
321
|
-
if (
|
|
322
|
-
msg.direction === "incoming" &&
|
|
323
|
-
msg.decrypted &&
|
|
324
|
-
msg.message === "sync-test"
|
|
325
|
-
) {
|
|
326
|
-
received.device2 = true;
|
|
327
|
-
check();
|
|
328
|
-
}
|
|
329
|
-
});
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
void sender.messages.send(targetUserID, "sync-test");
|
|
333
|
-
await waitForBoth;
|
|
334
|
-
|
|
335
|
-
expect(received.device1).toBe(true);
|
|
336
|
-
expect(received.device2).toBe(true);
|
|
337
|
-
} finally {
|
|
338
|
-
await device2.close().catch(() => {});
|
|
339
|
-
await sender.close().catch(() => {});
|
|
340
|
-
}
|
|
341
|
-
});
|
|
342
|
-
|
|
343
440
|
test("file upload + download", async () => {
|
|
344
441
|
const [details, key] = await client.files.create(testFile);
|
|
345
442
|
expect(details.fileID).toBeTruthy();
|
|
@@ -372,16 +469,83 @@ export function platformSuite(
|
|
|
372
469
|
}
|
|
373
470
|
|
|
374
471
|
function apiUrlOverrideFromEnv():
|
|
375
|
-
| Pick<ClientOptions, "host" | "unsafeHttp">
|
|
472
|
+
| Pick<ClientOptions, "host" | "unsafeHttp" | "devApiKey">
|
|
376
473
|
| undefined {
|
|
377
474
|
const raw = process.env["API_URL"]?.trim();
|
|
378
|
-
|
|
379
|
-
if (
|
|
380
|
-
|
|
381
|
-
|
|
475
|
+
const devKey = process.env["DEV_API_KEY"]?.trim();
|
|
476
|
+
if (!raw && (devKey === undefined || devKey.length === 0)) {
|
|
477
|
+
return undefined;
|
|
478
|
+
}
|
|
479
|
+
const fromUrl = (s: string): Pick<ClientOptions, "host" | "unsafeHttp"> => {
|
|
480
|
+
if (/^https?:\/\//i.test(s)) {
|
|
481
|
+
const u = new URL(s);
|
|
482
|
+
return { host: u.host, unsafeHttp: u.protocol === "http:" };
|
|
483
|
+
}
|
|
484
|
+
return { host: s, unsafeHttp: true };
|
|
485
|
+
};
|
|
486
|
+
if (!raw) {
|
|
487
|
+
return devKey !== undefined && devKey.length > 0
|
|
488
|
+
? { devApiKey: devKey }
|
|
489
|
+
: undefined;
|
|
490
|
+
}
|
|
491
|
+
return {
|
|
492
|
+
...fromUrl(raw),
|
|
493
|
+
...(devKey !== undefined && devKey.length > 0
|
|
494
|
+
? { devApiKey: devKey }
|
|
495
|
+
: {}),
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Shared staging / CI proxies sometimes return 502; retry a few times. */
|
|
500
|
+
async function withTransientRetry<T>(fn: () => Promise<T>): Promise<T> {
|
|
501
|
+
const attempts = 4;
|
|
502
|
+
let last: unknown;
|
|
503
|
+
for (let i = 0; i < attempts; i++) {
|
|
504
|
+
try {
|
|
505
|
+
return await fn();
|
|
506
|
+
} catch (e) {
|
|
507
|
+
last = e;
|
|
508
|
+
const transient =
|
|
509
|
+
isAxiosError(e) &&
|
|
510
|
+
(e.response?.status === 502 || e.response?.status === 503);
|
|
511
|
+
if (transient && i < attempts - 1) {
|
|
512
|
+
await new Promise((r) => setTimeout(r, 400 * (i + 1)));
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
throw e;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
throw last;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/*
|
|
522
|
+
type ClientE2EInternals = { getMail(): Promise<void> };
|
|
523
|
+
type ClientE2EDeviceList = {
|
|
524
|
+
fetchUserDeviceListOnce(userID: string): Promise<{ deviceID: string }[]>;
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
async function e2eWaitForPeerDeviceCount(
|
|
528
|
+
c: Client,
|
|
529
|
+
userID: string,
|
|
530
|
+
min: number,
|
|
531
|
+
totalMs: number,
|
|
532
|
+
): Promise<void> {
|
|
533
|
+
const t0 = Date.now();
|
|
534
|
+
const f = c as unknown as ClientE2EDeviceList;
|
|
535
|
+
for (;;) {
|
|
536
|
+
if (Date.now() - t0 > totalMs) {
|
|
537
|
+
throw new Error(
|
|
538
|
+
`e2e: still fewer than ${String(min)} device(s) for user after ${String(totalMs)}ms`,
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
const list = await f.fetchUserDeviceListOnce(userID);
|
|
542
|
+
if (list.length >= min) {
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
382
546
|
}
|
|
383
|
-
return { host: raw, unsafeHttp: true };
|
|
384
547
|
}
|
|
548
|
+
*/
|
|
385
549
|
|
|
386
550
|
function connectAndWait(
|
|
387
551
|
c: Client,
|
|
@@ -1,5 +1,15 @@
|
|
|
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
|
+
|
|
1
7
|
import type { ClientOptions } from "../index.js";
|
|
2
8
|
|
|
9
|
+
import { getCryptoProfile } from "@vex-chat/crypto";
|
|
10
|
+
|
|
11
|
+
import { resolveAtRestAesKeyFromSignKeyHex } from "../utils/resolveAtRestAesKey.js";
|
|
12
|
+
|
|
3
13
|
import { MemoryStorage } from "./harness/memory-storage.js";
|
|
4
14
|
// Browser platform test — covers Tauri, Expo/RN, and web.
|
|
5
15
|
// Runs with the poison plugin (vitest.config.browser.ts) which catches
|
|
@@ -8,7 +18,11 @@ import { MemoryStorage } from "./harness/memory-storage.js";
|
|
|
8
18
|
import { platformSuite } from "./harness/shared-suite.js";
|
|
9
19
|
|
|
10
20
|
platformSuite("browser", async (SK: string, _opts: ClientOptions) => {
|
|
11
|
-
const
|
|
21
|
+
const atRest = await resolveAtRestAesKeyFromSignKeyHex(
|
|
22
|
+
SK,
|
|
23
|
+
getCryptoProfile(),
|
|
24
|
+
);
|
|
25
|
+
const storage = new MemoryStorage(atRest);
|
|
12
26
|
await storage.init();
|
|
13
27
|
return storage;
|
|
14
28
|
});
|
|
@@ -1,9 +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
|
+
|
|
1
7
|
import type { ClientOptions } from "../index.js";
|
|
2
8
|
|
|
9
|
+
import { getCryptoProfile } from "@vex-chat/crypto";
|
|
10
|
+
|
|
3
11
|
import { createNodeStorage } from "../storage/node.js";
|
|
12
|
+
import { resolveAtRestAesKeyFromSignKeyHex } from "../utils/resolveAtRestAesKey.js";
|
|
4
13
|
|
|
5
14
|
import { platformSuite } from "./harness/shared-suite.js";
|
|
6
15
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
16
|
+
// Profile is set in shared-suite `beforeAll` (see `LIBVEX_E2E_CRYPTO`).
|
|
17
|
+
platformSuite("node", async (SK: string, _opts: ClientOptions) => {
|
|
18
|
+
const atRest = await resolveAtRestAesKeyFromSignKeyHex(
|
|
19
|
+
SK,
|
|
20
|
+
getCryptoProfile(),
|
|
21
|
+
);
|
|
22
|
+
return createNodeStorage(":memory:", atRest);
|
|
23
|
+
});
|
package/src/codec.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
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
|
+
|
|
1
7
|
/**
|
|
2
8
|
* Type-safe codec factory for msgpack encode/decode with optional Zod validation.
|
|
3
9
|
*
|
|
@@ -28,24 +34,25 @@ export function createCodec<T extends z.ZodType>(schema: T) {
|
|
|
28
34
|
type Msg = z.infer<T>;
|
|
29
35
|
return {
|
|
30
36
|
/** Decode msgpack data and validate against the schema. */
|
|
31
|
-
decode: (data: Uint8Array): Msg =>
|
|
37
|
+
decode: (data: Uint8Array): Msg =>
|
|
38
|
+
schema.parse(msgpackDecode(data)) as Msg,
|
|
32
39
|
|
|
33
40
|
/** Alias for decode — both paths validate. Kept for API compat. */
|
|
34
41
|
decodeSafe: (data: Uint8Array): Msg =>
|
|
35
|
-
schema.parse(
|
|
42
|
+
schema.parse(msgpackDecode(data)) as Msg,
|
|
36
43
|
|
|
37
44
|
/** Encode to msgpack. */
|
|
38
|
-
encode: (msg: Msg): Uint8Array =>
|
|
45
|
+
encode: (msg: Msg): Uint8Array => msgpackEncode(msg),
|
|
39
46
|
|
|
40
47
|
/** Validate against the schema, then encode to msgpack. */
|
|
41
48
|
encodeSafe: (msg: Msg): Uint8Array => {
|
|
42
49
|
schema.parse(msg);
|
|
43
|
-
return
|
|
50
|
+
return msgpackEncode(msg);
|
|
44
51
|
},
|
|
45
52
|
};
|
|
46
53
|
}
|
|
47
54
|
|
|
48
|
-
function
|
|
55
|
+
function msgpackDecode(data: Uint8Array): unknown {
|
|
49
56
|
return _packr.decode(data) as unknown;
|
|
50
57
|
}
|
|
51
58
|
|
|
@@ -54,7 +61,7 @@ function decode(data: Uint8Array): unknown {
|
|
|
54
61
|
* (not a subarray of the internal pool buffer) to avoid browser
|
|
55
62
|
* XMLHttpRequest.send() corruption (axios issue #4068).
|
|
56
63
|
*/
|
|
57
|
-
function
|
|
64
|
+
function msgpackEncode(value: unknown): Uint8Array {
|
|
58
65
|
const packed = _packr.encode(value);
|
|
59
66
|
return new Uint8Array(
|
|
60
67
|
packed.buffer.slice(
|
|
@@ -64,5 +71,11 @@ function encode(value: unknown): Uint8Array {
|
|
|
64
71
|
);
|
|
65
72
|
}
|
|
66
73
|
|
|
67
|
-
/**
|
|
68
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Raw msgpack encode/decode without schema validation.
|
|
76
|
+
* (Wrappers avoid API Extractor ae-forgotten-export on internal helpers.)
|
|
77
|
+
*/
|
|
78
|
+
export const msgpack = {
|
|
79
|
+
decode: (data: Uint8Array): unknown => msgpackDecode(data),
|
|
80
|
+
encode: (value: unknown): Uint8Array => msgpackEncode(value),
|
|
81
|
+
};
|
package/src/codecs.ts
CHANGED
package/src/index.ts
CHANGED
package/src/keystore/memory.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
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
|
+
|
|
1
7
|
/**
|
|
2
8
|
* In-memory KeyStore for testing and ephemeral sessions.
|
|
3
9
|
* No persistence — credentials are lost when the process exits.
|