@vex-chat/libvex 5.4.0 → 5.5.1

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 (40) hide show
  1. package/README.md +25 -23
  2. package/dist/Client.d.ts +113 -77
  3. package/dist/Client.d.ts.map +1 -1
  4. package/dist/Client.js +311 -234
  5. package/dist/Client.js.map +1 -1
  6. package/dist/__tests__/harness/memory-storage.d.ts +1 -1
  7. package/dist/__tests__/harness/memory-storage.d.ts.map +1 -1
  8. package/dist/__tests__/harness/memory-storage.js +1 -1
  9. package/dist/__tests__/harness/memory-storage.js.map +1 -1
  10. package/dist/codecs.d.ts +118 -0
  11. package/dist/codecs.d.ts.map +1 -1
  12. package/dist/codecs.js +41 -0
  13. package/dist/codecs.js.map +1 -1
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/storage/node/http-agents.d.ts +1 -1
  18. package/dist/storage/node/http-agents.d.ts.map +1 -1
  19. package/dist/storage/node/http-agents.js +4 -4
  20. package/dist/storage/node/http-agents.js.map +1 -1
  21. package/dist/storage/sqlite.d.ts +8 -8
  22. package/dist/storage/sqlite.d.ts.map +1 -1
  23. package/dist/storage/sqlite.js +16 -16
  24. package/dist/storage/sqlite.js.map +1 -1
  25. package/dist/utils/fipsMailExtra.d.ts +9 -9
  26. package/dist/utils/fipsMailExtra.d.ts.map +1 -1
  27. package/dist/utils/fipsMailExtra.js +47 -47
  28. package/dist/utils/fipsMailExtra.js.map +1 -1
  29. package/dist/utils/resolveAtRestAesKey.js +1 -1
  30. package/dist/utils/resolveAtRestAesKey.js.map +1 -1
  31. package/package.json +134 -152
  32. package/src/Client.ts +452 -306
  33. package/src/__tests__/harness/memory-storage.ts +1 -1
  34. package/src/__tests__/harness/shared-suite.ts +177 -177
  35. package/src/codecs.ts +52 -0
  36. package/src/index.ts +4 -0
  37. package/src/storage/node/http-agents.ts +7 -7
  38. package/src/storage/sqlite.ts +23 -23
  39. package/src/utils/fipsMailExtra.ts +80 -80
  40. package/src/utils/resolveAtRestAesKey.ts +1 -1
@@ -34,8 +34,8 @@ import { EventEmitter } from "eventemitter3";
34
34
 
35
35
  export class MemoryStorage extends EventEmitter implements Storage {
36
36
  public ready = false;
37
- private readonly devices: Device[] = [];
38
37
  private readonly atRestAesKey: Uint8Array;
38
+ private readonly devices: Device[] = [];
39
39
  private messages: Message[] = [];
40
40
  private nextOtkIndex = 1;
41
41
  private nextPreKeyIndex = 1;
@@ -38,141 +38,6 @@ import { Client } from "../../index.js";
38
38
 
39
39
  import { testFile, testImage } from "./fixtures.js";
40
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
-
176
41
  export function platformSuite(
177
42
  platformName: string,
178
43
  makeStorage: (SK: string, opts: ClientOptions) => Promise<Storage>,
@@ -469,7 +334,7 @@ export function platformSuite(
469
334
  }
470
335
 
471
336
  function apiUrlOverrideFromEnv():
472
- | Pick<ClientOptions, "host" | "unsafeHttp" | "devApiKey">
337
+ | Pick<ClientOptions, "devApiKey" | "host" | "unsafeHttp">
473
338
  | undefined {
474
339
  const raw = process.env["API_URL"]?.trim();
475
340
  const devKey = process.env["DEV_API_KEY"]?.trim();
@@ -496,26 +361,161 @@ function apiUrlOverrideFromEnv():
496
361
  };
497
362
  }
498
363
 
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
- }
364
+ async function assertSpireCryptoProfileMatchesTest(): Promise<void> {
365
+ if (process.env["LIBVEX_E2E_SKIP_STATUS_CHECK"] === "1") {
366
+ return;
517
367
  }
518
- throw last;
368
+ const url = spireStatusUrlFromEnv();
369
+ if (url === null) {
370
+ return;
371
+ }
372
+ const want = getCryptoProfile();
373
+ let res: Response;
374
+ try {
375
+ res = await fetch(url, { method: "GET" });
376
+ } catch (e: unknown) {
377
+ const msg = e instanceof Error ? e.message : String(e);
378
+ throw new Error(
379
+ `libvex e2e: could not GET ${url} (check API_URL; is Spire running?): ${msg}`,
380
+ );
381
+ }
382
+ if (!res.ok) {
383
+ throw new Error(
384
+ `libvex e2e: ${url} returned HTTP ${String(res.status)} — Spire not reachable on this base URL?`,
385
+ );
386
+ }
387
+ const data: unknown = await res.json();
388
+ if (
389
+ typeof data !== "object" ||
390
+ data === null ||
391
+ !("cryptoProfile" in data) ||
392
+ typeof (data as { cryptoProfile: unknown }).cryptoProfile !== "string"
393
+ ) {
394
+ throw new Error(
395
+ `libvex e2e: Spire /status is missing a string "cryptoProfile" (upgrade Spire) or set LIBVEX_E2E_SKIP_STATUS_CHECK=1 to skip this check`,
396
+ );
397
+ }
398
+ const gotStr = (data as { cryptoProfile: string }).cryptoProfile;
399
+ if (gotStr !== "fips" && gotStr !== "tweetnacl") {
400
+ throw new Error(
401
+ `libvex e2e: Spire /status cryptoProfile is not fips|tweetnacl: ${gotStr}`,
402
+ );
403
+ }
404
+ if (gotStr !== want) {
405
+ throw new Error(
406
+ `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.`,
407
+ );
408
+ }
409
+ }
410
+
411
+ function connectAndWait(
412
+ c: Client,
413
+ label: string,
414
+ timeout = 10_000,
415
+ ): Promise<void> {
416
+ return new Promise((resolve, reject) => {
417
+ const timer = setTimeout(() => {
418
+ reject(new Error(`${label} connect timed out`));
419
+ }, timeout);
420
+ const onConnected = () => {
421
+ clearTimeout(timer);
422
+ c.off("connected", onConnected);
423
+ resolve();
424
+ };
425
+ c.on("connected", onConnected);
426
+ c.connect().catch((err: unknown) => {
427
+ clearTimeout(timer);
428
+ reject(err instanceof Error ? err : new Error(String(err)));
429
+ });
430
+ });
431
+ }
432
+
433
+ function e2eClientOptionsBase(): ClientOptions {
434
+ return {
435
+ inMemoryDb: true,
436
+ ...apiUrlOverrideFromEnv(),
437
+ cryptoProfile: getCryptoProfile(),
438
+ };
439
+ }
440
+
441
+ /** `LIBVEX_E2E_CRYPTO` only — used when status auto-detect is skipped. */
442
+ function e2eCryptoProfileFromEnvOnly(): "fips" | "tweetnacl" {
443
+ const v = process.env["LIBVEX_E2E_CRYPTO"]?.trim().toLowerCase();
444
+ if (v === "fips" || v === "p-256" || v === "p256") {
445
+ return "fips";
446
+ }
447
+ if (v === "tweetnacl" || v === "nacl" || v === "ed25519") {
448
+ return "tweetnacl";
449
+ }
450
+ return "tweetnacl";
451
+ }
452
+
453
+ async function e2eGenerateSecretKey(): Promise<string> {
454
+ return await Client.generateSecretKeyAsync();
455
+ }
456
+
457
+ /**
458
+ * Picks the signing profile for the suite: optional env override, else `GET` Spire
459
+ * `/status` when `API_URL` is set, else tweetnacl.
460
+ */
461
+ async function resolveE2eCryptoProfile(): Promise<"fips" | "tweetnacl"> {
462
+ if (process.env["LIBVEX_E2E_SKIP_STATUS_CHECK"] === "1") {
463
+ return e2eCryptoProfileFromEnvOnly();
464
+ }
465
+ const v = process.env["LIBVEX_E2E_CRYPTO"]?.trim().toLowerCase();
466
+ if (v === "fips" || v === "p-256" || v === "p256") {
467
+ return "fips";
468
+ }
469
+ if (v === "tweetnacl" || v === "nacl" || v === "ed25519") {
470
+ return "tweetnacl";
471
+ }
472
+ if (v !== undefined && v.length > 0) {
473
+ throw new Error(
474
+ `libvex e2e: invalid LIBVEX_E2E_CRYPTO=${JSON.stringify(v)}. Use fips, tweetnacl, or leave unset to auto-detect from Spire /status`,
475
+ );
476
+ }
477
+ const url = spireStatusUrlFromEnv();
478
+ if (url === null) {
479
+ return "tweetnacl";
480
+ }
481
+ let res: Response;
482
+ try {
483
+ res = await fetch(url, { method: "GET" });
484
+ } catch {
485
+ return "tweetnacl";
486
+ }
487
+ if (!res.ok) {
488
+ return "tweetnacl";
489
+ }
490
+ const data: unknown = await res.json();
491
+ if (
492
+ typeof data !== "object" ||
493
+ data === null ||
494
+ !("cryptoProfile" in data)
495
+ ) {
496
+ return "tweetnacl";
497
+ }
498
+ const cp = (data as { cryptoProfile: unknown }).cryptoProfile;
499
+ if (cp === "fips" || cp === "tweetnacl") {
500
+ return cp;
501
+ }
502
+ return "tweetnacl";
503
+ }
504
+
505
+ /**
506
+ * `GET` `{API_URL or http://}{host}/status` — used for crypto profile preflight
507
+ * (must match `getCryptoProfile()` when running e2e against a custom Spire).
508
+ */
509
+ function spireStatusUrlFromEnv(): null | string {
510
+ const raw = process.env["API_URL"]?.trim();
511
+ if (raw === undefined || raw.length === 0) {
512
+ return null;
513
+ }
514
+ if (/^https?:\/\//i.test(raw)) {
515
+ const u = new URL(raw);
516
+ return `${u.protocol}//${u.host}/status`;
517
+ }
518
+ return `http://${raw}/status`;
519
519
  }
520
520
 
521
521
  /*
@@ -547,28 +547,6 @@ async function e2eWaitForPeerDeviceCount(
547
547
  }
548
548
  */
549
549
 
550
- function connectAndWait(
551
- c: Client,
552
- label: string,
553
- timeout = 10_000,
554
- ): Promise<void> {
555
- return new Promise((resolve, reject) => {
556
- const timer = setTimeout(() => {
557
- reject(new Error(`${label} connect timed out`));
558
- }, timeout);
559
- const onConnected = () => {
560
- clearTimeout(timer);
561
- c.off("connected", onConnected);
562
- resolve();
563
- };
564
- c.on("connected", onConnected);
565
- c.connect().catch((err: unknown) => {
566
- clearTimeout(timer);
567
- reject(err instanceof Error ? err : new Error(String(err)));
568
- });
569
- });
570
- }
571
-
572
550
  async function waitForMessage(
573
551
  c: Client,
574
552
  predicate: (m: Message) => boolean,
@@ -589,3 +567,25 @@ async function waitForMessage(
589
567
  c.on("message", onMsg);
590
568
  });
591
569
  }
570
+
571
+ /** Shared staging / CI proxies sometimes return 502; retry a few times. */
572
+ async function withTransientRetry<T>(fn: () => Promise<T>): Promise<T> {
573
+ const attempts = 4;
574
+ let last: unknown;
575
+ for (let i = 0; i < attempts; i++) {
576
+ try {
577
+ return await fn();
578
+ } catch (e) {
579
+ last = e;
580
+ const transient =
581
+ isAxiosError(e) &&
582
+ (e.response?.status === 502 || e.response?.status === 503);
583
+ if (transient && i < attempts - 1) {
584
+ await new Promise((r) => setTimeout(r, 400 * (i + 1)));
585
+ continue;
586
+ }
587
+ throw e;
588
+ }
589
+ }
590
+ throw last;
591
+ }
package/src/codecs.ts CHANGED
@@ -73,6 +73,58 @@ export const DeviceChallengeCodec = createCodec(
73
73
  }),
74
74
  );
75
75
 
76
+ export const DeviceRegistrationResultCodec = createCodec(
77
+ z.union([
78
+ DeviceSchema,
79
+ z.object({
80
+ challenge: z.string(),
81
+ expiresAt: z.string(),
82
+ requestID: z.string(),
83
+ status: z.literal("pending_approval"),
84
+ }),
85
+ ]),
86
+ );
87
+
88
+ export const PendingDeviceRequestCodec = createCodec(
89
+ z.object({
90
+ approvedDeviceID: z.string().optional(),
91
+ createdAt: z.string(),
92
+ deviceName: z.string(),
93
+ error: z.string().optional(),
94
+ expiresAt: z.string(),
95
+ requestID: z.string(),
96
+ signKey: z.string(),
97
+ status: z.union([
98
+ z.literal("approved"),
99
+ z.literal("expired"),
100
+ z.literal("pending"),
101
+ z.literal("rejected"),
102
+ ]),
103
+ username: z.string(),
104
+ }),
105
+ );
106
+
107
+ export const PendingDeviceRequestArrayCodec = createCodec(
108
+ z.array(
109
+ z.object({
110
+ approvedDeviceID: z.string().optional(),
111
+ createdAt: z.string(),
112
+ deviceName: z.string(),
113
+ error: z.string().optional(),
114
+ expiresAt: z.string(),
115
+ requestID: z.string(),
116
+ signKey: z.string(),
117
+ status: z.union([
118
+ z.literal("approved"),
119
+ z.literal("expired"),
120
+ z.literal("pending"),
121
+ z.literal("rejected"),
122
+ ]),
123
+ username: z.string(),
124
+ }),
125
+ ),
126
+ );
127
+
76
128
  export const WhoamiCodec = createCodec(
77
129
  z.object({
78
130
  exp: z.number(),
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ export type {
11
11
  ClientEvents,
12
12
  ClientOptions,
13
13
  Device,
14
+ DeviceRegistrationResult,
14
15
  Devices,
15
16
  Emojis,
16
17
  FileProgress,
@@ -22,6 +23,9 @@ export type {
22
23
  Message,
23
24
  Messages,
24
25
  Moderation,
26
+ PendingDeviceApprovalStatus,
27
+ PendingDeviceRegistration,
28
+ PendingDeviceRequest,
25
29
  Permission,
26
30
  Permissions,
27
31
  Server,
@@ -18,13 +18,6 @@ export interface NodeHttpAgentPair {
18
18
  readonly https: nodeHttps.Agent;
19
19
  }
20
20
 
21
- export function createNodeHttpAgents(): NodeHttpAgentPair {
22
- return {
23
- http: new nodeHttp.Agent({ keepAlive: true }),
24
- https: new nodeHttps.Agent({ keepAlive: true }),
25
- };
26
- }
27
-
28
21
  export function attachNodeAgentsToAxios(
29
22
  instance: AxiosInstance,
30
23
  agents: NodeHttpAgentPair,
@@ -33,6 +26,13 @@ export function attachNodeAgentsToAxios(
33
26
  instance.defaults.httpsAgent = agents.https;
34
27
  }
35
28
 
29
+ export function createNodeHttpAgents(): NodeHttpAgentPair {
30
+ return {
31
+ http: new nodeHttp.Agent({ keepAlive: true }),
32
+ https: new nodeHttps.Agent({ keepAlive: true }),
33
+ };
34
+ }
35
+
36
36
  export function destroyNodeHttpAgents(agents: NodeHttpAgentPair): void {
37
37
  agents.http.destroy();
38
38
  agents.https.destroy();
@@ -46,12 +46,12 @@ import { EventEmitter } from "eventemitter3";
46
46
 
47
47
  export class SqliteStorage extends EventEmitter implements Storage {
48
48
  public ready = false;
49
- private closing = false;
50
- /** Shared across concurrent `init()` callers; `close()` awaits it before `destroy()`. */
51
- private initInFlight: Promise<void> | null = null;
52
- private readonly db: Kysely<ClientDatabase>;
53
49
  /** 32-byte AES-256 (or nacl) key for local at-rest `secretbox` (see `XUtils.deriveLocalAtRestAesKey`). */
54
50
  private readonly atRestAesKey: Uint8Array;
51
+ private closing = false;
52
+ private readonly db: Kysely<ClientDatabase>;
53
+ /** Shared across concurrent `init()` callers; `close()` awaits it before `destroy()`. */
54
+ private initInFlight: null | Promise<void> = null;
55
55
 
56
56
  constructor(db: Kysely<ClientDatabase>, atRestAesKey: Uint8Array) {
57
57
  super();
@@ -64,14 +64,6 @@ export class SqliteStorage extends EventEmitter implements Storage {
64
64
 
65
65
  // ── Lifecycle ────────────────────────────────────────────────────────────
66
66
 
67
- /**
68
- * Read `closing` where TypeScript would incorrectly assume it cannot
69
- * become true after an earlier guard (e.g. across `await`).
70
- */
71
- private isClosingNow(): boolean {
72
- return this.closing;
73
- }
74
-
75
67
  async close(): Promise<void> {
76
68
  this.closing = true;
77
69
  const pending = this.initInFlight;
@@ -104,8 +96,6 @@ export class SqliteStorage extends EventEmitter implements Storage {
104
96
  .execute();
105
97
  }
106
98
 
107
- // ── Messages ─────────────────────────────────────────────────────────────
108
-
109
99
  async deleteMessage(mailID: string): Promise<void> {
110
100
  if (this.closing) {
111
101
  return;
@@ -116,6 +106,8 @@ export class SqliteStorage extends EventEmitter implements Storage {
116
106
  .execute();
117
107
  }
118
108
 
109
+ // ── Messages ─────────────────────────────────────────────────────────────
110
+
119
111
  async deleteOneTimeKey(index: number): Promise<void> {
120
112
  if (this.closing) {
121
113
  return;
@@ -196,8 +188,6 @@ export class SqliteStorage extends EventEmitter implements Storage {
196
188
  return this.decryptMessagesAsync(messages);
197
189
  }
198
190
 
199
- // ── Sessions ─────────────────────────────────────────────────────────────
200
-
201
191
  async getOneTimeKey(index: number): Promise<null | PreKeysCrypto> {
202
192
  await this.untilReady();
203
193
  if (this.closing) {
@@ -225,6 +215,8 @@ export class SqliteStorage extends EventEmitter implements Storage {
225
215
  };
226
216
  }
227
217
 
218
+ // ── Sessions ─────────────────────────────────────────────────────────────
219
+
228
220
  async getPreKeys(): Promise<null | PreKeysCrypto> {
229
221
  await this.untilReady();
230
222
  if (this.closing) {
@@ -394,8 +386,6 @@ export class SqliteStorage extends EventEmitter implements Storage {
394
386
  .execute();
395
387
  }
396
388
 
397
- // ── PreKeys / OneTimeKeys ────────────────────────────────────────────────
398
-
399
389
  async markSessionVerified(sessionID: string): Promise<void> {
400
390
  if (this.closing) {
401
391
  return;
@@ -407,6 +397,8 @@ export class SqliteStorage extends EventEmitter implements Storage {
407
397
  .execute();
408
398
  }
409
399
 
400
+ // ── PreKeys / OneTimeKeys ────────────────────────────────────────────────
401
+
410
402
  async purgeHistory(): Promise<void> {
411
403
  await this.db.deleteFrom("messages").execute();
412
404
  }
@@ -443,8 +435,6 @@ export class SqliteStorage extends EventEmitter implements Storage {
443
435
  }
444
436
  }
445
437
 
446
- // ── Devices ──────────────────────────────────────────────────────────────
447
-
448
438
  async saveMessage(message: Message): Promise<void> {
449
439
  if (this.isClosingNow()) {
450
440
  return;
@@ -497,6 +487,8 @@ export class SqliteStorage extends EventEmitter implements Storage {
497
487
  }
498
488
  }
499
489
 
490
+ // ── Devices ──────────────────────────────────────────────────────────────
491
+
500
492
  async savePreKeys(
501
493
  preKeys: UnsavedPreKey[],
502
494
  oneTime: boolean,
@@ -535,8 +527,6 @@ export class SqliteStorage extends EventEmitter implements Storage {
535
527
  return saved;
536
528
  }
537
529
 
538
- // ── Purge ────────────────────────────────────────────────────────────────
539
-
540
530
  async saveSession(session: SessionSQL): Promise<void> {
541
531
  if (this.closing) {
542
532
  return;
@@ -565,7 +555,7 @@ export class SqliteStorage extends EventEmitter implements Storage {
565
555
  }
566
556
  }
567
557
 
568
- // ── Private helpers ──────────────────────────────────────────────────────
558
+ // ── Purge ────────────────────────────────────────────────────────────────
569
559
 
570
560
  private async decryptMessagesAsync(
571
561
  messages: MessageRow[],
@@ -611,6 +601,8 @@ export class SqliteStorage extends EventEmitter implements Storage {
611
601
  return out;
612
602
  }
613
603
 
604
+ // ── Private helpers ──────────────────────────────────────────────────────
605
+
614
606
  private deviceRowToDevice(row: DeviceRow): Device {
615
607
  return {
616
608
  deleted: row.deleted !== 0,
@@ -622,6 +614,14 @@ export class SqliteStorage extends EventEmitter implements Storage {
622
614
  };
623
615
  }
624
616
 
617
+ /**
618
+ * Read `closing` where TypeScript would incorrectly assume it cannot
619
+ * become true after an earlier guard (e.g. across `await`).
620
+ */
621
+ private isClosingNow(): boolean {
622
+ return this.closing;
623
+ }
624
+
625
625
  private isDuplicateError(err: unknown): boolean {
626
626
  if (err instanceof Error) {
627
627
  return err.message.includes("UNIQUE");