@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.
Files changed (142) hide show
  1. package/CLA.md +38 -0
  2. package/LICENSE-COMMERCIAL +10 -0
  3. package/LICENSING.md +15 -0
  4. package/README.md +8 -2
  5. package/dist/Client.d.ts +47 -3
  6. package/dist/Client.d.ts.map +1 -1
  7. package/dist/Client.js +998 -496
  8. package/dist/Client.js.map +1 -1
  9. package/dist/Storage.d.ts +5 -0
  10. package/dist/Storage.d.ts.map +1 -1
  11. package/dist/Storage.js +5 -0
  12. package/dist/Storage.js.map +1 -1
  13. package/dist/__tests__/harness/memory-storage.d.ts +7 -2
  14. package/dist/__tests__/harness/memory-storage.d.ts.map +1 -1
  15. package/dist/__tests__/harness/memory-storage.js +44 -29
  16. package/dist/__tests__/harness/memory-storage.js.map +1 -1
  17. package/dist/codec.d.ts +9 -9
  18. package/dist/codec.d.ts.map +1 -1
  19. package/dist/codec.js +17 -19
  20. package/dist/codec.js.map +1 -1
  21. package/dist/codecs.d.ts +5 -0
  22. package/dist/codecs.d.ts.map +1 -1
  23. package/dist/codecs.js +5 -0
  24. package/dist/codecs.js.map +1 -1
  25. package/dist/index.d.ts +5 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +5 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/keystore/memory.d.ts +5 -0
  30. package/dist/keystore/memory.d.ts.map +1 -1
  31. package/dist/keystore/memory.js +5 -0
  32. package/dist/keystore/memory.js.map +1 -1
  33. package/dist/keystore/node.d.ts +5 -0
  34. package/dist/keystore/node.d.ts.map +1 -1
  35. package/dist/keystore/node.js +16 -8
  36. package/dist/keystore/node.js.map +1 -1
  37. package/dist/preset/common.d.ts +5 -0
  38. package/dist/preset/common.d.ts.map +1 -1
  39. package/dist/preset/common.js +5 -0
  40. package/dist/preset/common.js.map +1 -1
  41. package/dist/preset/node.d.ts +5 -0
  42. package/dist/preset/node.d.ts.map +1 -1
  43. package/dist/preset/node.js +9 -1
  44. package/dist/preset/node.js.map +1 -1
  45. package/dist/preset/test.d.ts +5 -0
  46. package/dist/preset/test.d.ts.map +1 -1
  47. package/dist/preset/test.js +9 -1
  48. package/dist/preset/test.js.map +1 -1
  49. package/dist/storage/node/http-agents.d.ts +5 -0
  50. package/dist/storage/node/http-agents.d.ts.map +1 -1
  51. package/dist/storage/node/http-agents.js +5 -0
  52. package/dist/storage/node/http-agents.js.map +1 -1
  53. package/dist/storage/node.d.ts +6 -1
  54. package/dist/storage/node.d.ts.map +1 -1
  55. package/dist/storage/node.js +7 -4
  56. package/dist/storage/node.js.map +1 -1
  57. package/dist/storage/schema.d.ts +5 -0
  58. package/dist/storage/schema.d.ts.map +1 -1
  59. package/dist/storage/schema.js +5 -0
  60. package/dist/storage/schema.js.map +1 -1
  61. package/dist/storage/sqlite.d.ts +22 -4
  62. package/dist/storage/sqlite.d.ts.map +1 -1
  63. package/dist/storage/sqlite.js +172 -98
  64. package/dist/storage/sqlite.js.map +1 -1
  65. package/dist/transport/types.d.ts +5 -0
  66. package/dist/transport/types.d.ts.map +1 -1
  67. package/dist/transport/types.js +5 -0
  68. package/dist/transport/types.js.map +1 -1
  69. package/dist/transport/websocket.d.ts +5 -0
  70. package/dist/transport/websocket.d.ts.map +1 -1
  71. package/dist/transport/websocket.js +5 -0
  72. package/dist/transport/websocket.js.map +1 -1
  73. package/dist/types/crypto.d.ts +5 -0
  74. package/dist/types/crypto.d.ts.map +1 -1
  75. package/dist/types/crypto.js +3 -5
  76. package/dist/types/crypto.js.map +1 -1
  77. package/dist/types/identity.d.ts +5 -0
  78. package/dist/types/identity.d.ts.map +1 -1
  79. package/dist/types/identity.js +3 -2
  80. package/dist/types/identity.js.map +1 -1
  81. package/dist/types/index.d.ts +5 -0
  82. package/dist/types/index.d.ts.map +1 -1
  83. package/dist/types/index.js +5 -0
  84. package/dist/types/index.js.map +1 -1
  85. package/dist/utils/capitalize.d.ts +5 -0
  86. package/dist/utils/capitalize.d.ts.map +1 -1
  87. package/dist/utils/capitalize.js +5 -0
  88. package/dist/utils/capitalize.js.map +1 -1
  89. package/dist/utils/fipsMailExtra.d.ts +30 -0
  90. package/dist/utils/fipsMailExtra.d.ts.map +1 -0
  91. package/dist/utils/fipsMailExtra.js +114 -0
  92. package/dist/utils/fipsMailExtra.js.map +1 -0
  93. package/dist/utils/formatBytes.d.ts +5 -0
  94. package/dist/utils/formatBytes.d.ts.map +1 -1
  95. package/dist/utils/formatBytes.js +5 -0
  96. package/dist/utils/formatBytes.js.map +1 -1
  97. package/dist/utils/resolveAtRestAesKey.d.ts +13 -0
  98. package/dist/utils/resolveAtRestAesKey.d.ts.map +1 -0
  99. package/dist/utils/resolveAtRestAesKey.js +26 -0
  100. package/dist/utils/resolveAtRestAesKey.js.map +1 -0
  101. package/dist/utils/sqlSessionToCrypto.d.ts +5 -0
  102. package/dist/utils/sqlSessionToCrypto.d.ts.map +1 -1
  103. package/dist/utils/sqlSessionToCrypto.js +5 -0
  104. package/dist/utils/sqlSessionToCrypto.js.map +1 -1
  105. package/dist/utils/uint8uuid.d.ts +5 -0
  106. package/dist/utils/uint8uuid.d.ts.map +1 -1
  107. package/dist/utils/uint8uuid.js +5 -0
  108. package/dist/utils/uint8uuid.js.map +1 -1
  109. package/package.json +10 -3
  110. package/src/Client.ts +1281 -642
  111. package/src/Storage.ts +6 -0
  112. package/src/__tests__/codec.test.ts +6 -0
  113. package/src/__tests__/harness/fixtures.ts +6 -0
  114. package/src/__tests__/harness/memory-storage.ts +72 -52
  115. package/src/__tests__/harness/platform-transports.ts +6 -0
  116. package/src/__tests__/harness/poison-node-imports.ts +6 -0
  117. package/src/__tests__/harness/shared-suite.ts +288 -124
  118. package/src/__tests__/platform-browser.test.ts +15 -1
  119. package/src/__tests__/platform-node.test.ts +17 -3
  120. package/src/codec.ts +21 -8
  121. package/src/codecs.ts +6 -0
  122. package/src/index.ts +6 -0
  123. package/src/keystore/memory.ts +6 -0
  124. package/src/keystore/node.ts +27 -13
  125. package/src/preset/common.ts +6 -0
  126. package/src/preset/node.ts +14 -1
  127. package/src/preset/test.ts +14 -1
  128. package/src/storage/node/http-agents.ts +6 -0
  129. package/src/storage/node.ts +11 -4
  130. package/src/storage/schema.ts +6 -0
  131. package/src/storage/sqlite.ts +208 -135
  132. package/src/transport/types.ts +6 -0
  133. package/src/transport/websocket.ts +6 -0
  134. package/src/types/crypto.ts +6 -0
  135. package/src/types/identity.ts +6 -0
  136. package/src/types/index.ts +6 -0
  137. package/src/utils/capitalize.ts +6 -0
  138. package/src/utils/fipsMailExtra.ts +164 -0
  139. package/src/utils/formatBytes.ts +6 -0
  140. package/src/utils/resolveAtRestAesKey.ts +39 -0
  141. package/src/utils/sqlSessionToCrypto.ts +6 -0
  142. 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 SK = Client.generateSecretKey();
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 = Client.generateSecretKey();
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 = Client.generateSecretKey();
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 client.servers.create("test-server");
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 client.invites.create(
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 client2.invites.redeem(invite.inviteID);
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
- void client.messages.group(channel.channelID, "hello channel");
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
- const permissions = await client.permissions.retrieve();
189
- expect(permissions).toEqual([]);
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
- const server = await client.servers.create("Invite Test Server");
230
- const invite = await client.invites.create(server.serverID, "1h");
231
- expect(invite).toBeTruthy();
232
- expect(invite.serverID).toBe(server.serverID);
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
- await client.invites.redeem(invite.inviteID);
235
- await client.servers.delete(server.serverID);
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
- if (!raw) return undefined;
379
- if (/^https?:\/\//i.test(raw)) {
380
- const u = new URL(raw);
381
- return { host: u.host, unsafeHttp: u.protocol === "http:" };
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 storage = new MemoryStorage(SK);
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
- platformSuite("node", (SK: string, _opts: ClientOptions) =>
8
- Promise.resolve(createNodeStorage(":memory:", SK)),
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 => schema.parse(decode(data)) as 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(decode(data)) as Msg,
42
+ schema.parse(msgpackDecode(data)) as Msg,
36
43
 
37
44
  /** Encode to msgpack. */
38
- encode: (msg: Msg): Uint8Array => encode(msg),
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 encode(msg);
50
+ return msgpackEncode(msg);
44
51
  },
45
52
  };
46
53
  }
47
54
 
48
- function decode(data: Uint8Array): unknown {
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 encode(value: unknown): Uint8Array {
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
- /** Raw msgpack encode/decode without schema validation. */
68
- export const msgpack = { decode, encode };
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
@@ -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
  * Pre-built codec instances for every HTTP response type.
3
9
  *
package/src/index.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
  export { Client } from "./Client.js";
2
8
  export type {
3
9
  Channel,
@@ -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.