@plane31/blind-relay 0.2.0 → 0.4.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 (45) hide show
  1. package/README.md +28 -0
  2. package/dist/adapters/index.d.ts +1 -0
  3. package/dist/adapters/index.d.ts.map +1 -1
  4. package/dist/adapters/index.js +1 -0
  5. package/dist/adapters/index.js.map +1 -1
  6. package/dist/client.d.ts +113 -0
  7. package/dist/client.d.ts.map +1 -1
  8. package/dist/client.js +284 -29
  9. package/dist/client.js.map +1 -1
  10. package/dist/crypto/noble.d.ts.map +1 -1
  11. package/dist/crypto/noble.js +19 -1
  12. package/dist/crypto/noble.js.map +1 -1
  13. package/dist/errors.d.ts +6 -2
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js +5 -1
  16. package/dist/errors.js.map +1 -1
  17. package/dist/inbox.d.ts +4 -0
  18. package/dist/inbox.d.ts.map +1 -1
  19. package/dist/inbox.js +22 -14
  20. package/dist/inbox.js.map +1 -1
  21. package/dist/index.d.ts +1 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/outbox.d.ts +5 -0
  25. package/dist/outbox.d.ts.map +1 -1
  26. package/dist/outbox.js +6 -3
  27. package/dist/outbox.js.map +1 -1
  28. package/dist/store.d.ts +2 -0
  29. package/dist/store.d.ts.map +1 -1
  30. package/dist/store.js +2 -0
  31. package/dist/store.js.map +1 -1
  32. package/dist/testing/faults.d.ts +8 -1
  33. package/dist/testing/faults.d.ts.map +1 -1
  34. package/dist/testing/faults.js +16 -0
  35. package/dist/testing/faults.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/adapters/index.ts +1 -0
  38. package/src/client.ts +390 -30
  39. package/src/crypto/noble.ts +18 -1
  40. package/src/errors.ts +7 -1
  41. package/src/inbox.ts +24 -13
  42. package/src/index.ts +7 -1
  43. package/src/outbox.ts +9 -3
  44. package/src/store.ts +3 -0
  45. package/src/testing/faults.ts +22 -1
package/src/client.ts CHANGED
@@ -167,6 +167,22 @@ export interface Client {
167
167
  completeReplacement(input: { successionId: Uint8Array; authorization: Uint8Array }): Promise<{ identityId: Uint8Array; generation: bigint }>;
168
168
  /** The key changes this device accepted for its contacts, newest first, bounded. */
169
169
  keyChanges(options?: { accountId?: Uint8Array; limit?: number }): Promise<KeyChangeEntry[]>;
170
+ /**
171
+ * Account recovery as one call over `prepareReplacement` and `completeReplacement`, with the
172
+ * protocol's retry rules: an earlier proposal is settled through the chain first; the same
173
+ * proposal is kept through an unknown outcome and a refused authorization; a new proposal is
174
+ * made only after a definitive conflict, and then only by the next call. The product supplies
175
+ * `authorize`, which obtains the host's token for a proposal after its own fresh sign-in, or
176
+ * answers `"reauthenticate"` when it cannot. Attempts are bounded; `pending` means call again.
177
+ */
178
+ replaceDevice(options: ReplaceDeviceOptions): Promise<ReplaceDeviceResult>;
179
+ /**
180
+ * Synchronise in rounds until nothing is left that this device can do now: the relay has no
181
+ * more records within the budget, bootstrap is complete, or progress needs someone else (an
182
+ * authorization, another person, metadata the host does not serve). Bounded by rounds and by
183
+ * time; the result says why it stopped and what remains.
184
+ */
185
+ syncUntilDrained(options?: DrainOptions): Promise<DrainReport>;
170
186
  /** A durable outbound record of a declared kind; the payload is the product's bytes, never read by the SDK. */
171
187
  enqueue(kind: RecordKind, payload: Uint8Array, options?: EnqueueOptions): Promise<EnqueueResult>;
172
188
  /** One synchronisation; `options.catchUp` bounds how much of a backlog this call reads (`SyncReport.inbox.hasMore` says when to call again). */
@@ -192,9 +208,75 @@ export interface Client {
192
208
  list(): Promise<readonly CircleSummary[]>;
193
209
  };
194
210
  resetAccount(options: { confirmed: true; reauthProof?: Uint8Array }): Promise<{ identityId: Uint8Array }>;
211
+ /**
212
+ * Stop: no new operation is accepted (`CLOSED`), the operations in flight finish their current
213
+ * transaction and a running sync stops at its next phase boundary, then listeners are cleared
214
+ * so nothing is emitted afterwards. Closes nothing it did not open: the store and the secret
215
+ * store are the caller's. Idempotent.
216
+ */
195
217
  close(): Promise<void>;
196
218
  }
197
219
 
220
+ export interface ReplaceDeviceOptions {
221
+ /** Obtain the host's authorization for this proposal after the product's fresh sign-in, or say that a sign-in is needed first. */
222
+ readonly authorize: (proposal: ReplacementProposal) => Promise<Uint8Array | "reauthenticate">;
223
+ /** Commit attempts per call, over unknown outcomes and refused authorizations. Default 3. */
224
+ readonly maxAttempts?: number;
225
+ }
226
+
227
+ export type ReplaceDeviceResult =
228
+ | { readonly status: "activated"; readonly identityId: Uint8Array; readonly generation: bigint }
229
+ /** The outcome is still unknown or the attempts are spent: the proposal is kept; call again. */
230
+ | { readonly status: "pending"; readonly proposal: ReplacementProposal }
231
+ /** The account moved under the proposal, which is discarded; the next call prepares a new one after a fresh sign-in. */
232
+ | { readonly status: "conflict"; readonly currentGeneration: bigint | undefined }
233
+ /** The product's `authorize` asked for a fresh sign-in; the proposal is kept for the next call. */
234
+ | { readonly status: "reauthenticate"; readonly proposal: ReplacementProposal };
235
+
236
+ export interface DrainOptions {
237
+ readonly catchUp?: SyncOptions["catchUp"];
238
+ readonly skipMaintenance?: boolean;
239
+ /** Rounds at most. Default 10. */
240
+ readonly maxRounds?: number;
241
+ /** A soft bound in milliseconds across every round: no round starts past it and each round's reading budget is the time left, but a transport call already in flight ends on its own timeout. Default 30,000. */
242
+ readonly maxMs?: number;
243
+ }
244
+
245
+ export type DrainStop =
246
+ /** Nothing is left that this device can do now. */
247
+ | "drained"
248
+ | "maxRounds"
249
+ | "maxMs"
250
+ /** A round changed nothing and reported the same errors as the one before. */
251
+ | "noProgress"
252
+ /** Progress needs metadata the host does not serve yet (a chain, a certificate, the key list). */
253
+ | "metadata"
254
+ /** Progress needs an authorization or a sign-in (a prepared replacement, an account to recover). */
255
+ | "authentication"
256
+ /** Nothing more is reported, but a round could not read its inbox fully (discovery or a stream failed), so emptiness is not established. */
257
+ | "incomplete"
258
+ /** The host asked for a pause; `remaining.retryAfterMs` says how long, and the product schedules the next attempt. */
259
+ | "rateLimited"
260
+ | "closed";
261
+
262
+ /** The merged report of every round, with why the loop stopped and what remains. */
263
+ export interface DrainReport extends SyncReport {
264
+ readonly rounds: number;
265
+ readonly stoppedBecause: DrainStop;
266
+ readonly remaining: {
267
+ /** The relay still holds records this device has not read; `"unknown"` when the last round could not read its inbox fully. */
268
+ readonly inboxHasMore: boolean | "unknown";
269
+ /** Bootstrap after account recovery has circles left to adopt. */
270
+ readonly bootstrapIncomplete: boolean;
271
+ /** Contacts whose succession chain this device has verified only partly; the next rounds continue them. */
272
+ readonly chainsIncomplete: number;
273
+ /** Authors whose current epoch has not reached this device: nothing to do but wait for them. */
274
+ readonly waitingFor: number;
275
+ /** On `rateLimited`: the longest wait the host asked for in the last round, when it said one. */
276
+ readonly retryAfterMs?: number;
277
+ };
278
+ }
279
+
198
280
  export interface SyncOptions {
199
281
  /**
200
282
  * The inbox budget of this sync, shared by every stream: at most this many records
@@ -203,6 +285,12 @@ export interface SyncOptions {
203
285
  * at least one page.
204
286
  */
205
287
  readonly catchUp?: { readonly maxRecords?: number; readonly maxMs?: number };
288
+ /**
289
+ * Leave the housekeeping to a later sync: the retention purge, the periodic re-check of the
290
+ * reset-key list (a stale list is still fetched, since verification needs it) and the
291
+ * recovery-key re-save. For a short background window; the report says `maintenance: "skipped"`.
292
+ */
293
+ readonly skipMaintenance?: boolean;
206
294
  }
207
295
 
208
296
  export const DEFAULT_CATCH_UP = { maxRecords: 500, maxMs: 5_000 } as const;
@@ -214,11 +302,39 @@ export interface SyncReport {
214
302
  /** `hasMore`: a stream still has records on the relay that this sync's budget did not reach; sync again soon. */
215
303
  inbox: { streams: number; stored: number; keyPending: number; invalid: number; acked: number; hasMore: boolean };
216
304
  purged: number;
305
+ /** Whether this sync did its housekeeping (purge, list re-check, key re-save) or left it to a later one. */
306
+ maintenance: "done" | "skipped";
217
307
  /** Present while account recovery discovers circles through the host: pinned, pending and failed on this sync. */
218
308
  bootstrap?: { circles: number; pending: number; failed: number };
309
+ /** Wall-clock milliseconds per phase and record bytes moved, for diagnostics (section 14: counts and durations only). */
310
+ timings: SyncTimings;
219
311
  errors: SyncError[];
220
312
  }
221
313
 
314
+ export interface SyncTimings {
315
+ resume: number;
316
+ bootstrap: number;
317
+ metadata: number;
318
+ preferences: number;
319
+ epoch: number;
320
+ outbox: number;
321
+ inbox: number;
322
+ maintenance: number;
323
+ total: number;
324
+ /** Record bytes handed to the relay. */
325
+ bytesSent: number;
326
+ /** Record bytes read from the relay. */
327
+ bytesReceived: number;
328
+ }
329
+
330
+ const emptyTimings = (): SyncTimings => ({ resume: 0, bootstrap: 0, metadata: 0, preferences: 0, epoch: 0, outbox: 0, inbox: 0, maintenance: 0, total: 0, bytesSent: 0, bytesReceived: 0 });
331
+
332
+ /** A monotonic wall clock for durations; the protocol clock (`now`) is not used, since a test drives it by hand. */
333
+ const wall = (): number => {
334
+ const g = globalThis as { performance?: { now?: () => number } };
335
+ return typeof g.performance?.now === "function" ? g.performance.now() : Date.now();
336
+ };
337
+
222
338
  /** What `prepareReplacement()` returns: everything the product hands to the host for authorization. All public. */
223
339
  export interface ReplacementProposal {
224
340
  readonly successionId: Uint8Array;
@@ -256,10 +372,14 @@ export interface SyncError {
256
372
  readonly authorIdentityId?: Uint8Array;
257
373
  /** On a `bootstrap` entry: the circle that could not be adopted on this sync; it is retried by the next. */
258
374
  readonly circleId?: Uint8Array;
375
+ /** On a `RATE_LIMITED` entry: the wait the host asked for, when it said. */
376
+ readonly retryAfterMs?: number;
259
377
  }
260
378
 
261
379
  function syncError(operation: string, e: unknown, extra: Partial<Pick<SyncError, "circleId" | "streamId" | "authorIdentityId">> = {}): SyncError {
262
- return e instanceof KavachError ? { operation, code: e.code, stage: e.stage, retryable: e.retryable, ...extra } : { operation, code: "UNEXPECTED", stage: operation, retryable: false, ...extra };
380
+ return e instanceof KavachError
381
+ ? { operation, code: e.code, stage: e.stage, retryable: e.retryable, ...(e.retryAfterMs === undefined ? {} : { retryAfterMs: e.retryAfterMs }), ...extra }
382
+ : { operation, code: "UNEXPECTED", stage: operation, retryable: false, ...extra };
263
383
  }
264
384
 
265
385
  const RESET_LIST_RECHECK_MS = 86_400_000n;
@@ -292,6 +412,9 @@ class ClientImpl implements Client {
292
412
  private selfPin: AccountPin | undefined;
293
413
  private trust: ResetTrust;
294
414
  private syncing: Promise<SyncReport> | undefined;
415
+ private closed = false;
416
+ /** Every public operation in flight, so `close()` can wait for them. */
417
+ private readonly running = new Set<Promise<unknown>>();
295
418
  /** The current sync's reading budget: records, and the links of a succession chain, count against it. */
296
419
  private syncBudget: ReadBudget | undefined;
297
420
  private backupChecked = false;
@@ -309,17 +432,56 @@ class ClientImpl implements Client {
309
432
  this.now = options.now ?? ((): bigint => BigInt(Date.now()));
310
433
  this.trust = new ResetTrust(options.resetTrust);
311
434
  this.circles = {
312
- create: () => this.createCircle(),
313
- requestJoin: (c, o, opts) => this.requestJoin(c, o, opts?.shareOnApproval ?? true),
314
- approve: (c, n) => this.approve(c, n),
315
- remove: (c, m) => this.changeMembership(c, { remove: m }),
316
- leave: (c) => this.leave(c),
317
- setEnabled: (c, e) => this.setEnabled(c, e),
318
- closeCircle: (c) => this.closeCircle(c),
435
+ create: () => this.track("createCircle", () => this.createCircle()),
436
+ requestJoin: (c, o, opts) => this.track("requestJoin", () => this.requestJoin(c, o, opts?.shareOnApproval ?? true)),
437
+ approve: (c, n) => this.track("approve", () => this.approve(c, n)),
438
+ remove: (c, m) => this.track("changeMembership", () => this.changeMembership(c, { remove: m })),
439
+ leave: (c) => this.track("leave", () => this.leave(c)),
440
+ setEnabled: (c, e) => this.track("setEnabled", () => this.setEnabled(c, e)),
441
+ closeCircle: (c) => this.track("closeCircle", () => this.closeCircle(c)),
319
442
  list: () => this.listCircles()
320
443
  };
321
444
  }
322
445
 
446
+ // Every public operation that writes enters through `track`, explicitly and typed, so `close()`
447
+ // refuses new ones and waits for those in flight. Reads stay untracked.
448
+
449
+ initializeAccount(): Promise<{ identityId: Uint8Array }> {
450
+ return this.track("initializeAccount", () => this.initializeAccountNow());
451
+ }
452
+
453
+ restoreAccount(recoveryCode?: string): Promise<{ identityId: Uint8Array; generation: bigint }> {
454
+ return this.track("restoreAccount", () => this.restoreAccountNow(recoveryCode));
455
+ }
456
+
457
+ enqueue(kind: RecordKind, payload: Uint8Array, options: EnqueueOptions = {}): Promise<EnqueueResult> {
458
+ return this.track("enqueue", () => this.enqueueNow(kind, payload, options));
459
+ }
460
+
461
+ receive(wire: Uint8Array): Promise<ReceiveResult> {
462
+ return this.track("receive", () => receiveRecord(this.inboxDeps(), wire));
463
+ }
464
+
465
+ prepareReplacement(): Promise<ReplacementProposal> {
466
+ return this.track("prepareReplacement", () => this.prepareReplacementNow());
467
+ }
468
+
469
+ completeReplacement(input: { successionId: Uint8Array; authorization: Uint8Array }): Promise<{ identityId: Uint8Array; generation: bigint }> {
470
+ return this.track("completeReplacement", () => this.completeReplacementNow(input));
471
+ }
472
+
473
+ resetAccount(options: { confirmed: true; reauthProof?: Uint8Array }): Promise<{ identityId: Uint8Array }> {
474
+ return this.track("resetAccount", () => this.resetAccountNow(options));
475
+ }
476
+
477
+ replaceDevice(options: ReplaceDeviceOptions): Promise<ReplaceDeviceResult> {
478
+ return this.track("replaceDevice", () => this.replaceDeviceNow(options));
479
+ }
480
+
481
+ syncUntilDrained(options: DrainOptions = {}): Promise<DrainReport> {
482
+ return this.track("syncUntilDrained", () => this.drainNow(options));
483
+ }
484
+
323
485
  // Loading ---------------------------------------------------------------------------------------
324
486
 
325
487
  async load(): Promise<void> {
@@ -354,8 +516,18 @@ class ClientImpl implements Client {
354
516
  });
355
517
  }
356
518
 
357
- /** Each listener on its own: a throwing subscriber affects neither the other listeners nor the protocol. */
519
+ /** Refuse after `close()`, and keep the operation's promise until it settles. */
520
+ private track<T>(stage: string, fn: () => Promise<T>): Promise<T> {
521
+ if (this.closed) return Promise.reject(new KavachError("CLOSED", stage, "the client is closed"));
522
+ const p = fn();
523
+ this.running.add(p);
524
+ void p.finally(() => this.running.delete(p)).catch(() => undefined);
525
+ return p;
526
+ }
527
+
528
+ /** Each listener on its own: a throwing subscriber affects neither the other listeners nor the protocol. Nothing after `close()`. */
358
529
  private emit(event: ClientEvent): void {
530
+ if (this.closed) return;
359
531
  for (const l of this.listeners) {
360
532
  try {
361
533
  l(event);
@@ -489,7 +661,7 @@ class ClientImpl implements Client {
489
661
 
490
662
  // Enrollment ------------------------------------------------------------------------------------
491
663
 
492
- async initializeAccount(): Promise<{ identityId: Uint8Array }> {
664
+ private async initializeAccountNow(): Promise<{ identityId: Uint8Array }> {
493
665
  const stage = "initializeAccount";
494
666
  const opened = await openStore(this.store, this.secrets, this.accountName);
495
667
  if (opened.kind !== "fresh" && opened.kind !== "initializing") throw new KavachError("MALFORMED", stage, "an account already exists on this device");
@@ -551,7 +723,7 @@ class ClientImpl implements Client {
551
723
  return encodeRecoveryCode(key);
552
724
  }
553
725
 
554
- async restoreAccount(recoveryCode?: string): Promise<{ identityId: Uint8Array; generation: bigint }> {
726
+ private async restoreAccountNow(recoveryCode?: string): Promise<{ identityId: Uint8Array; generation: bigint }> {
555
727
  const stage = "restoreAccount";
556
728
  const opened = await openStore(this.store, this.secrets, this.accountName);
557
729
  if (opened.kind === "ACTIVE" || opened.kind === "RETIRED") throw new KavachError("MALFORMED", stage, "an account already exists on this device");
@@ -720,7 +892,7 @@ class ClientImpl implements Client {
720
892
 
721
893
  // Sending ---------------------------------------------------------------------------------------
722
894
 
723
- async enqueue(kind: RecordKind, payload: Uint8Array, options: EnqueueOptions = {}): Promise<EnqueueResult> {
895
+ private async enqueueNow(kind: RecordKind, payload: Uint8Array, options: EnqueueOptions): Promise<EnqueueResult> {
724
896
  const deps = this.outboxDeps();
725
897
  // A key answered before anything that may need the network: the record it names is already
726
898
  // durable, so a retry offline, or with a rotation due, returns it. The outbox checks the key
@@ -839,21 +1011,148 @@ class ClientImpl implements Client {
839
1011
 
840
1012
  async sync(options: SyncOptions = {}): Promise<SyncReport> {
841
1013
  if (this.syncing) return this.syncing;
842
- this.syncing = this.runSync(options).finally(() => {
1014
+ if (this.closed) throw new KavachError("CLOSED", "sync", "the client is closed");
1015
+ this.syncing = this.track("sync", () => this.runSync(options)).finally(() => {
843
1016
  this.syncing = undefined;
844
1017
  });
845
1018
  return this.syncing;
846
1019
  }
847
1020
 
1021
+ private async drainNow(options: DrainOptions): Promise<DrainReport> {
1022
+ const maxRounds = Math.max(1, Math.floor(options.maxRounds ?? 10));
1023
+ const maxMs = Math.max(0, Math.floor(options.maxMs ?? 30_000));
1024
+ const deadline = this.now() + BigInt(maxMs);
1025
+ const merged: SyncReport = {
1026
+ metadata: { certificates: 0, snapshots: 0, rotations: 0, quarantined: 0, resets: 0, successions: 0 },
1027
+ epoch: "unchanged",
1028
+ outbox: { sent: 0, accepted: 0, cancelled: 0, unknown: 0, retry: 0 },
1029
+ inbox: { streams: 0, stored: 0, keyPending: 0, invalid: 0, acked: 0, hasMore: false },
1030
+ purged: 0,
1031
+ maintenance: options.skipMaintenance ? "skipped" : "done",
1032
+ timings: emptyTimings(),
1033
+ errors: []
1034
+ };
1035
+ let rounds = 0;
1036
+ let previousErrors: string | undefined;
1037
+ let stop: DrainStop = "drained";
1038
+ let last: SyncReport | undefined;
1039
+ // An inbox this device could not fully read this round: discovery or a stream failed, or metadata it
1040
+ // depends on did. Such a round never establishes that the inbox is empty.
1041
+ const inboxUncertain = (r: SyncReport): boolean => r.errors.some((e) => e.operation === "listSubscriptions" || e.operation === "inbox" || e.operation === "metadata");
1042
+ // Chains verified only partly: remaining work, and their cursors moving is progress.
1043
+ const chains = (): Promise<Map<string, string>> => this.store.read((tx) => new Map(tx.list("chainProgress").filter(([, c]) => !c.complete).map(([k, c]) => [k, c.nextGeneration])));
1044
+ let previousChains = await chains();
1045
+ for (;;) {
1046
+ if (this.closed) {
1047
+ stop = "closed";
1048
+ break;
1049
+ }
1050
+ // The time left is this round's reading budget too, so a round never starts work the deadline
1051
+ // does not cover; a transport call already in flight ends on its own timeout.
1052
+ const remainingMs = Number(deadline - this.now());
1053
+ if (rounds > 0 && remainingMs <= 0) {
1054
+ stop = "maxMs";
1055
+ break;
1056
+ }
1057
+ const roundMs = Math.max(0, Math.min(remainingMs, options.catchUp?.maxMs ?? DEFAULT_CATCH_UP.maxMs));
1058
+ const r = await this.sync({ catchUp: { maxMs: roundMs, ...(options.catchUp?.maxRecords !== undefined ? { maxRecords: options.catchUp.maxRecords } : {}) }, ...(options.skipMaintenance ? { skipMaintenance: true } : {}) });
1059
+ rounds++;
1060
+ last = r;
1061
+ // The round's work counts whether or not it was the last: what it accepted, stored and failed
1062
+ // is merged first, and only then is a close that landed meanwhile reported.
1063
+ for (const k of Object.keys(merged.metadata) as (keyof SyncReport["metadata"])[]) merged.metadata[k] += r.metadata[k];
1064
+ for (const k of Object.keys(merged.outbox) as (keyof SyncReport["outbox"])[]) merged.outbox[k] += r.outbox[k];
1065
+ merged.inbox.streams += r.inbox.streams;
1066
+ merged.inbox.stored += r.inbox.stored;
1067
+ merged.inbox.keyPending += r.inbox.keyPending;
1068
+ merged.inbox.invalid += r.inbox.invalid;
1069
+ merged.inbox.acked += r.inbox.acked;
1070
+ merged.purged += r.purged;
1071
+ for (const k of Object.keys(merged.timings) as (keyof SyncTimings)[]) merged.timings[k] += r.timings[k];
1072
+ merged.epoch = r.epoch;
1073
+ merged.errors.push(...r.errors);
1074
+ if (r.bootstrap) merged.bootstrap = r.bootstrap;
1075
+ if (this.closed) {
1076
+ // The round may have stopped at a boundary before its inbox: nothing it reports is complete.
1077
+ stop = "closed";
1078
+ break;
1079
+ }
1080
+ if (r.errors.some((e) => e.code === "RATE_LIMITED")) {
1081
+ // The host asked for a pause: no further round now; the wait goes back to the product.
1082
+ stop = "rateLimited";
1083
+ break;
1084
+ }
1085
+ const bootstrapIncomplete = (await this.inspect()).bootstrap?.complete === false;
1086
+ const currentChains = await chains();
1087
+ const more = r.inbox.hasMore || bootstrapIncomplete || currentChains.size > 0;
1088
+ const status = (await this.getState()).status;
1089
+ if (status === "replacementPrepared" || status === "accountRecoveryRequired") {
1090
+ stop = "authentication";
1091
+ break;
1092
+ }
1093
+ if (!more) {
1094
+ stop = inboxUncertain(r) ? "incomplete" : "drained";
1095
+ break;
1096
+ }
1097
+ const signature = r.errors.map((e) => `${e.operation}:${e.code}`).sort().join(",");
1098
+ const chainsAdvanced = [...currentChains].some(([k, next]) => previousChains.get(k) !== next) || [...previousChains.keys()].some((k) => !currentChains.has(k));
1099
+ previousChains = currentChains;
1100
+ const changed = chainsAdvanced || r.inbox.stored + r.metadata.certificates + r.metadata.snapshots + r.metadata.successions + (r.bootstrap?.circles ?? 0) + (r.bootstrap?.pending ?? 0) > 0;
1101
+ if (!changed && signature !== "" && signature === previousErrors) {
1102
+ stop = r.errors.some((e) => e.code === "METADATA_REQUIRED" || e.code === "RESET_KEYS_REQUIRED" || e.code === "RESET_CLOCK_UNTRUSTED") ? "metadata" : "noProgress";
1103
+ break;
1104
+ }
1105
+ previousErrors = signature;
1106
+ if (rounds >= maxRounds) {
1107
+ stop = "maxRounds";
1108
+ break;
1109
+ }
1110
+ if (this.now() >= deadline) {
1111
+ stop = "maxMs";
1112
+ break;
1113
+ }
1114
+ }
1115
+ merged.inbox.hasMore = last?.inbox.hasMore ?? false;
1116
+ const bootstrapIncomplete = this.closed ? false : (await this.inspect()).bootstrap?.complete === false;
1117
+ const chainsIncomplete = this.closed ? previousChains.size : (await chains()).size;
1118
+ const waitingFor = this.closed ? 0 : (await this.getState()).waitingFor.length;
1119
+ const inboxHasMore = !last || inboxUncertain(last) || stop === "closed" || stop === "rateLimited" ? "unknown" : merged.inbox.hasMore;
1120
+ const waits = (last?.errors ?? []).flatMap((e) => (e.code === "RATE_LIMITED" && e.retryAfterMs !== undefined ? [e.retryAfterMs] : []));
1121
+ const retryAfterMs = waits.length ? Math.max(...waits) : undefined;
1122
+ return { ...merged, rounds, stoppedBecause: stop, remaining: { inboxHasMore, bootstrapIncomplete, chainsIncomplete, waitingFor, ...(retryAfterMs === undefined ? {} : { retryAfterMs }) } };
1123
+ }
1124
+
848
1125
  private async runSync(options: SyncOptions): Promise<SyncReport> {
1126
+ const started = wall();
849
1127
  const report: SyncReport = {
850
1128
  metadata: { certificates: 0, snapshots: 0, rotations: 0, quarantined: 0, resets: 0, successions: 0 },
851
1129
  epoch: "unchanged",
852
1130
  outbox: { sent: 0, accepted: 0, cancelled: 0, unknown: 0, retry: 0 },
853
1131
  inbox: { streams: 0, stored: 0, keyPending: 0, invalid: 0, acked: 0, hasMore: false },
854
1132
  purged: 0,
1133
+ maintenance: options.skipMaintenance ? "skipped" : "done",
1134
+ timings: emptyTimings(),
855
1135
  errors: []
856
1136
  };
1137
+ const maintain = !options.skipMaintenance;
1138
+ let mark = started;
1139
+ const phase = (name: keyof Omit<SyncTimings, "total" | "bytesSent" | "bytesReceived">): void => {
1140
+ const now = wall();
1141
+ report.timings[name] += now - mark;
1142
+ mark = now;
1143
+ };
1144
+ const finish = (): SyncReport => {
1145
+ report.timings.total = wall() - started;
1146
+ return report;
1147
+ };
1148
+ try {
1149
+ return await this.runSyncPhases(options, report, maintain, phase);
1150
+ } finally {
1151
+ finish();
1152
+ }
1153
+ }
1154
+
1155
+ private async runSyncPhases(options: SyncOptions, report: SyncReport, maintain: boolean, phase: (name: keyof Omit<SyncTimings, "total" | "bytesSent" | "bytesReceived">) => void): Promise<SyncReport> {
857
1156
  this.syncBudget = {
858
1157
  remainingRecords: Math.max(1, Math.floor(options.catchUp?.maxRecords ?? DEFAULT_CATCH_UP.maxRecords)),
859
1158
  deadline: this.now() + BigInt(Math.max(0, Math.floor(options.catchUp?.maxMs ?? DEFAULT_CATCH_UP.maxMs)))
@@ -871,6 +1170,7 @@ class ClientImpl implements Client {
871
1170
  }
872
1171
  }
873
1172
  }
1173
+ phase("resume");
874
1174
  if (!this.account || !this.selfPin) return report;
875
1175
  const meta = await this.store.read((tx) => tx.get("meta", META));
876
1176
  if (!meta || meta.state === "RETIRED") return report;
@@ -895,7 +1195,9 @@ class ClientImpl implements Client {
895
1195
  return report;
896
1196
  }
897
1197
  } else if (meta.state !== "ACTIVE") return report;
898
- await this.ensureBackup();
1198
+ phase("resume");
1199
+ if (maintain) await this.ensureBackup();
1200
+ phase("maintenance");
899
1201
  if (meta.bootstrap && !meta.bootstrap.complete) {
900
1202
  try {
901
1203
  report.bootstrap = await this.bootstrap(report, meta.bootstrap);
@@ -903,28 +1205,38 @@ class ClientImpl implements Client {
903
1205
  report.errors.push(syncError("bootstrap", e));
904
1206
  }
905
1207
  }
1208
+ phase("bootstrap");
906
1209
  try {
907
- await this.syncMetadata(report);
1210
+ await this.syncMetadata(report, maintain);
908
1211
  } catch (e) {
909
1212
  report.errors.push(syncError("metadata", e));
910
1213
  }
1214
+ phase("metadata");
911
1215
  try {
912
1216
  await this.pushPreferences(report);
913
1217
  } catch (e) {
914
1218
  report.errors.push(syncError("preference", e));
915
1219
  }
1220
+ phase("preferences");
916
1221
  const after = await this.store.read((tx) => tx.get("meta", META));
917
- if (after?.state === "RETIRED") return report;
1222
+ if (after?.state === "RETIRED" || this.closed) return report;
918
1223
  try {
919
1224
  report.epoch = await this.ensureEpoch();
920
1225
  } catch (e) {
921
1226
  report.errors.push(syncError("epoch", e));
922
1227
  }
1228
+ phase("epoch");
1229
+ if (this.closed) return report;
923
1230
  try {
924
- report.outbox = await drain(this.outboxDeps());
1231
+ const drained = await drain(this.outboxDeps());
1232
+ report.outbox = { sent: drained.sent, accepted: drained.accepted, cancelled: drained.cancelled, unknown: drained.unknown, retry: drained.retry };
1233
+ report.timings.bytesSent += drained.bytes;
1234
+ if (drained.blocked) report.errors.push(syncError("outbox", drained.blocked)); // the records stay queued
925
1235
  } catch (e) {
926
1236
  report.errors.push(syncError("outbox", e));
927
1237
  }
1238
+ phase("outbox");
1239
+ if (this.closed) return report;
928
1240
  let subs: Awaited<ReturnType<Relay["listSubscriptions"]>> = [];
929
1241
  try {
930
1242
  subs = await this.transport.relay.listSubscriptions();
@@ -937,26 +1249,35 @@ class ClientImpl implements Client {
937
1249
  const budget: ReadBudget = this.syncBudget;
938
1250
  if (budget.remainingRecords < 1) budget.remainingRecords = 1; // every stream reads at least one page
939
1251
  for (const s of subs) {
1252
+ if (this.closed) return report;
940
1253
  try {
941
1254
  const known = await this.store.read((tx) => tx.list("certificates").some(([, c]) => c.streamId === toHex(s.streamId)));
942
1255
  if (!known) continue;
943
1256
  const r = await syncStream(this.inboxDeps(), s.streamId, s.authorIdentityId, budget);
944
- report.inbox.streams++;
1257
+ // What landed counts, whether or not the stream then failed; a failed stream is not synced.
945
1258
  report.inbox.stored += r.stored;
946
1259
  report.inbox.keyPending += r.keyPending;
947
1260
  report.inbox.invalid += r.invalid;
1261
+ report.timings.bytesReceived += r.bytes;
1262
+ if (r.failure !== undefined) {
1263
+ report.errors.push({ ...syncError("inbox", r.failure), streamId: s.streamId, authorIdentityId: s.authorIdentityId });
1264
+ continue;
1265
+ }
1266
+ report.inbox.streams++;
948
1267
  if (r.acked !== undefined) report.inbox.acked++;
949
1268
  if (r.hasMore) report.inbox.hasMore = true;
950
1269
  } catch (e) {
951
1270
  report.errors.push({ ...syncError("inbox", e), streamId: s.streamId, authorIdentityId: s.authorIdentityId });
952
1271
  }
953
1272
  }
954
- report.purged = (await purgeHistory(this.store, this.policy, this.now())) + (await purgeOutbox(this.store, this.policy, this.now()));
1273
+ phase("inbox");
1274
+ if (maintain) report.purged = (await purgeHistory(this.store, this.policy, this.now())) + (await purgeOutbox(this.store, this.policy, this.now()));
1275
+ phase("maintenance");
955
1276
  await this.emitState();
956
1277
  return report;
957
1278
  }
958
1279
 
959
- private async syncMetadata(report: SyncReport): Promise<void> {
1280
+ private async syncMetadata(report: SyncReport, maintain = true): Promise<void> {
960
1281
  const { pin } = this.requireAccount("sync");
961
1282
  // Own account: has this device been superseded?
962
1283
  const [me] = await this.transport.directory.resolveAccounts([this.accountName]);
@@ -978,9 +1299,9 @@ class ClientImpl implements Client {
978
1299
  return;
979
1300
  }
980
1301
  }
981
- // Reset-key list when due.
1302
+ // Reset-key list when due: always when stale (verification needs it), periodically unless maintenance is skipped.
982
1303
  const now = this.now();
983
- const due = !this.trust.fresh(now) || now - this.trust.state.timeHighWater >= RESET_LIST_RECHECK_MS;
1304
+ const due = !this.trust.fresh(now) || (maintain && now - this.trust.state.timeHighWater >= RESET_LIST_RECHECK_MS);
984
1305
  if (due) {
985
1306
  const wire = await this.transport.directory.getResetKeyList();
986
1307
  if (wire) {
@@ -1480,10 +1801,6 @@ class ClientImpl implements Client {
1480
1801
 
1481
1802
  // Receiving and reads ---------------------------------------------------------------------------
1482
1803
 
1483
- receive(wire: Uint8Array): Promise<ReceiveResult> {
1484
- return receiveRecord(this.inboxDeps(), wire);
1485
- }
1486
-
1487
1804
  history(query?: HistoryQuery): Promise<HistoryEntry[]> {
1488
1805
  return history(this.store, this.policy, query);
1489
1806
  }
@@ -1678,7 +1995,7 @@ class ClientImpl implements Client {
1678
1995
  private async approve(circleId: Uint8Array, accountName: string): Promise<{ revision: bigint }> {
1679
1996
  const [resolved] = await this.transport.directory.resolveAccounts([accountName]);
1680
1997
  if (!resolved) throw new KavachError("METADATA_REQUIRED", "approve", "unknown account");
1681
- const report: SyncReport = { metadata: { certificates: 0, snapshots: 0, rotations: 0, quarantined: 0, resets: 0, successions: 0 }, epoch: "unchanged", outbox: { sent: 0, accepted: 0, cancelled: 0, unknown: 0, retry: 0 }, inbox: { streams: 0, stored: 0, keyPending: 0, invalid: 0, acked: 0, hasMore: false }, purged: 0, errors: [] };
1998
+ const report: SyncReport = { metadata: { certificates: 0, snapshots: 0, rotations: 0, quarantined: 0, resets: 0, successions: 0 }, epoch: "unchanged", outbox: { sent: 0, accepted: 0, cancelled: 0, unknown: 0, retry: 0 }, inbox: { streams: 0, stored: 0, keyPending: 0, invalid: 0, acked: 0, hasMore: false }, purged: 0, maintenance: "done", timings: emptyTimings(), errors: [] };
1682
1999
  await this.acceptMemberCertificate(resolved, report);
1683
2000
  const requester = resolvedAccountId(resolved);
1684
2001
  const pinned = await this.pinFor(requester);
@@ -1848,7 +2165,7 @@ class ClientImpl implements Client {
1848
2165
 
1849
2166
  // Account recovery (amendment A1) ---------------------------------------------------------------
1850
2167
 
1851
- async prepareReplacement(): Promise<ReplacementProposal> {
2168
+ private async prepareReplacementNow(): Promise<ReplacementProposal> {
1852
2169
  const stage = "prepareReplacement";
1853
2170
  if (!acceptsSuccession(this.policy)) throw new KavachError("NOT_AUTHORIZED", stage, "this deployment does not use account recovery");
1854
2171
  const opened = await openStore(this.store, this.secrets, this.accountName);
@@ -1893,7 +2210,7 @@ class ClientImpl implements Client {
1893
2210
  return proposalOf(row);
1894
2211
  }
1895
2212
 
1896
- async completeReplacement(input: { successionId: Uint8Array; authorization: Uint8Array }): Promise<{ identityId: Uint8Array; generation: bigint }> {
2213
+ private async completeReplacementNow(input: { successionId: Uint8Array; authorization: Uint8Array }): Promise<{ identityId: Uint8Array; generation: bigint }> {
1897
2214
  const stage = "completeReplacement";
1898
2215
  // Already activated (by an earlier call, or by a sync that found the commit on the chain): idempotent.
1899
2216
  if (this.selfPin && this.account && !this.retired) return { identityId: this.selfPin.identityId, generation: this.selfPin.generation };
@@ -2143,7 +2460,7 @@ class ClientImpl implements Client {
2143
2460
 
2144
2461
  // Reset -----------------------------------------------------------------------------------------
2145
2462
 
2146
- async resetAccount(options: { confirmed: true; reauthProof?: Uint8Array }): Promise<{ identityId: Uint8Array }> {
2463
+ private async resetAccountNow(options: { confirmed: true; reauthProof?: Uint8Array }): Promise<{ identityId: Uint8Array }> {
2147
2464
  const stage = "resetAccount";
2148
2465
  if (!options.confirmed) throw new KavachError("NOT_AUTHORIZED", stage, "reset requires confirmation");
2149
2466
  const { pin } = this.requireAccount(stage);
@@ -2227,7 +2544,50 @@ class ClientImpl implements Client {
2227
2544
  }
2228
2545
 
2229
2546
  async close(): Promise<void> {
2547
+ this.closed = true;
2548
+ // Everything in flight finishes its current transaction; a sync stops at its next boundary.
2549
+ while (this.running.size > 0) await Promise.allSettled([...this.running]);
2230
2550
  this.listeners.clear();
2231
2551
  }
2552
+
2553
+ // The replacement helper ------------------------------------------------------------------------
2554
+
2555
+ private async replaceDeviceNow(options: ReplaceDeviceOptions): Promise<ReplaceDeviceResult> {
2556
+ const maxAttempts = Math.max(1, Math.floor(options.maxAttempts ?? 3));
2557
+ const activated = (): ReplaceDeviceResult | undefined => (this.selfPin && this.account && !this.retired ? { status: "activated", identityId: this.selfPin.identityId, generation: this.selfPin.generation } : undefined);
2558
+ const done = activated();
2559
+ if (done) return done;
2560
+ // A proposal left by an earlier run may have committed: the chain settles it before anything else.
2561
+ const existing = await this.store.read((tx) => tx.get("replacement", "pending"));
2562
+ if (existing && !existing.stale) {
2563
+ await this.sync();
2564
+ const settled = activated();
2565
+ if (settled) return settled;
2566
+ }
2567
+ const proposal = await this.prepareReplacementNow();
2568
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
2569
+ const authorization = await options.authorize(proposal);
2570
+ if (authorization === "reauthenticate") return { status: "reauthenticate", proposal };
2571
+ for (;;) {
2572
+ try {
2573
+ const r = await this.completeReplacementNow({ successionId: proposal.successionId, authorization });
2574
+ return { status: "activated", identityId: r.identityId, generation: r.generation };
2575
+ } catch (e) {
2576
+ if (isKavachError(e, "SUCCESSION_PENDING")) {
2577
+ // The same call again with the same bytes; the host answers a committed id first.
2578
+ if (++attempt >= maxAttempts) return { status: "pending", proposal };
2579
+ continue;
2580
+ }
2581
+ if (isKavachError(e, "AUTHORIZATION_REQUIRED")) break; // a fresh token for the same proposal
2582
+ if (isKavachError(e, "REPLACEMENT_CONFLICT")) {
2583
+ const conflict = (await this.inspect()).replacement;
2584
+ return { status: "conflict", currentGeneration: conflict?.conflictGeneration };
2585
+ }
2586
+ throw e;
2587
+ }
2588
+ }
2589
+ }
2590
+ return { status: "pending", proposal };
2591
+ }
2232
2592
  }
2233
2593