@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/dist/client.js CHANGED
@@ -50,8 +50,16 @@ export function resolvedAccountId(r) {
50
50
  return pinFromCertificate(doc).identityId;
51
51
  }
52
52
  export const DEFAULT_CATCH_UP = { maxRecords: 500, maxMs: 5_000 };
53
+ const emptyTimings = () => ({ resume: 0, bootstrap: 0, metadata: 0, preferences: 0, epoch: 0, outbox: 0, inbox: 0, maintenance: 0, total: 0, bytesSent: 0, bytesReceived: 0 });
54
+ /** A monotonic wall clock for durations; the protocol clock (`now`) is not used, since a test drives it by hand. */
55
+ const wall = () => {
56
+ const g = globalThis;
57
+ return typeof g.performance?.now === "function" ? g.performance.now() : Date.now();
58
+ };
53
59
  function syncError(operation, e, extra = {}) {
54
- return e instanceof KavachError ? { operation, code: e.code, stage: e.stage, retryable: e.retryable, ...extra } : { operation, code: "UNEXPECTED", stage: operation, retryable: false, ...extra };
60
+ return e instanceof KavachError
61
+ ? { operation, code: e.code, stage: e.stage, retryable: e.retryable, ...(e.retryAfterMs === undefined ? {} : { retryAfterMs: e.retryAfterMs }), ...extra }
62
+ : { operation, code: "UNEXPECTED", stage: operation, retryable: false, ...extra };
55
63
  }
56
64
  const RESET_LIST_RECHECK_MS = 86400000n;
57
65
  export async function open(options) {
@@ -84,6 +92,9 @@ class ClientImpl {
84
92
  selfPin;
85
93
  trust;
86
94
  syncing;
95
+ closed = false;
96
+ /** Every public operation in flight, so `close()` can wait for them. */
97
+ running = new Set();
87
98
  /** The current sync's reading budget: records, and the links of a succession chain, count against it. */
88
99
  syncBudget;
89
100
  backupChecked = false;
@@ -99,16 +110,45 @@ class ClientImpl {
99
110
  this.now = options.now ?? (() => BigInt(Date.now()));
100
111
  this.trust = new ResetTrust(options.resetTrust);
101
112
  this.circles = {
102
- create: () => this.createCircle(),
103
- requestJoin: (c, o, opts) => this.requestJoin(c, o, opts?.shareOnApproval ?? true),
104
- approve: (c, n) => this.approve(c, n),
105
- remove: (c, m) => this.changeMembership(c, { remove: m }),
106
- leave: (c) => this.leave(c),
107
- setEnabled: (c, e) => this.setEnabled(c, e),
108
- closeCircle: (c) => this.closeCircle(c),
113
+ create: () => this.track("createCircle", () => this.createCircle()),
114
+ requestJoin: (c, o, opts) => this.track("requestJoin", () => this.requestJoin(c, o, opts?.shareOnApproval ?? true)),
115
+ approve: (c, n) => this.track("approve", () => this.approve(c, n)),
116
+ remove: (c, m) => this.track("changeMembership", () => this.changeMembership(c, { remove: m })),
117
+ leave: (c) => this.track("leave", () => this.leave(c)),
118
+ setEnabled: (c, e) => this.track("setEnabled", () => this.setEnabled(c, e)),
119
+ closeCircle: (c) => this.track("closeCircle", () => this.closeCircle(c)),
109
120
  list: () => this.listCircles()
110
121
  };
111
122
  }
123
+ // Every public operation that writes enters through `track`, explicitly and typed, so `close()`
124
+ // refuses new ones and waits for those in flight. Reads stay untracked.
125
+ initializeAccount() {
126
+ return this.track("initializeAccount", () => this.initializeAccountNow());
127
+ }
128
+ restoreAccount(recoveryCode) {
129
+ return this.track("restoreAccount", () => this.restoreAccountNow(recoveryCode));
130
+ }
131
+ enqueue(kind, payload, options = {}) {
132
+ return this.track("enqueue", () => this.enqueueNow(kind, payload, options));
133
+ }
134
+ receive(wire) {
135
+ return this.track("receive", () => receiveRecord(this.inboxDeps(), wire));
136
+ }
137
+ prepareReplacement() {
138
+ return this.track("prepareReplacement", () => this.prepareReplacementNow());
139
+ }
140
+ completeReplacement(input) {
141
+ return this.track("completeReplacement", () => this.completeReplacementNow(input));
142
+ }
143
+ resetAccount(options) {
144
+ return this.track("resetAccount", () => this.resetAccountNow(options));
145
+ }
146
+ replaceDevice(options) {
147
+ return this.track("replaceDevice", () => this.replaceDeviceNow(options));
148
+ }
149
+ syncUntilDrained(options = {}) {
150
+ return this.track("syncUntilDrained", () => this.drainNow(options));
151
+ }
112
152
  // Loading ---------------------------------------------------------------------------------------
113
153
  async load() {
114
154
  await this.finishInterruptedReset();
@@ -143,8 +183,19 @@ class ClientImpl {
143
183
  retired: rows.retired
144
184
  });
145
185
  }
146
- /** Each listener on its own: a throwing subscriber affects neither the other listeners nor the protocol. */
186
+ /** Refuse after `close()`, and keep the operation's promise until it settles. */
187
+ track(stage, fn) {
188
+ if (this.closed)
189
+ return Promise.reject(new KavachError("CLOSED", stage, "the client is closed"));
190
+ const p = fn();
191
+ this.running.add(p);
192
+ void p.finally(() => this.running.delete(p)).catch(() => undefined);
193
+ return p;
194
+ }
195
+ /** Each listener on its own: a throwing subscriber affects neither the other listeners nor the protocol. Nothing after `close()`. */
147
196
  emit(event) {
197
+ if (this.closed)
198
+ return;
148
199
  for (const l of this.listeners) {
149
200
  try {
150
201
  l(event);
@@ -284,7 +335,7 @@ class ClientImpl {
284
335
  });
285
336
  }
286
337
  // Enrollment ------------------------------------------------------------------------------------
287
- async initializeAccount() {
338
+ async initializeAccountNow() {
288
339
  const stage = "initializeAccount";
289
340
  const opened = await openStore(this.store, this.secrets, this.accountName);
290
341
  if (opened.kind !== "fresh" && opened.kind !== "initializing")
@@ -351,7 +402,7 @@ class ClientImpl {
351
402
  throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", "recoveryCode");
352
403
  return encodeRecoveryCode(key);
353
404
  }
354
- async restoreAccount(recoveryCode) {
405
+ async restoreAccountNow(recoveryCode) {
355
406
  const stage = "restoreAccount";
356
407
  const opened = await openStore(this.store, this.secrets, this.accountName);
357
408
  if (opened.kind === "ACTIVE" || opened.kind === "RETIRED")
@@ -528,7 +579,7 @@ class ClientImpl {
528
579
  this.emit({ kind: "stateChanged", state: await this.getState() });
529
580
  }
530
581
  // Sending ---------------------------------------------------------------------------------------
531
- async enqueue(kind, payload, options = {}) {
582
+ async enqueueNow(kind, payload, options) {
532
583
  const deps = this.outboxDeps();
533
584
  // A key answered before anything that may need the network: the record it names is already
534
585
  // durable, so a retry offline, or with a rotation due, returns it. The outbox checks the key
@@ -658,20 +709,151 @@ class ClientImpl {
658
709
  async sync(options = {}) {
659
710
  if (this.syncing)
660
711
  return this.syncing;
661
- this.syncing = this.runSync(options).finally(() => {
712
+ if (this.closed)
713
+ throw new KavachError("CLOSED", "sync", "the client is closed");
714
+ this.syncing = this.track("sync", () => this.runSync(options)).finally(() => {
662
715
  this.syncing = undefined;
663
716
  });
664
717
  return this.syncing;
665
718
  }
719
+ async drainNow(options) {
720
+ const maxRounds = Math.max(1, Math.floor(options.maxRounds ?? 10));
721
+ const maxMs = Math.max(0, Math.floor(options.maxMs ?? 30_000));
722
+ const deadline = this.now() + BigInt(maxMs);
723
+ const merged = {
724
+ metadata: { certificates: 0, snapshots: 0, rotations: 0, quarantined: 0, resets: 0, successions: 0 },
725
+ epoch: "unchanged",
726
+ outbox: { sent: 0, accepted: 0, cancelled: 0, unknown: 0, retry: 0 },
727
+ inbox: { streams: 0, stored: 0, keyPending: 0, invalid: 0, acked: 0, hasMore: false },
728
+ purged: 0,
729
+ maintenance: options.skipMaintenance ? "skipped" : "done",
730
+ timings: emptyTimings(),
731
+ errors: []
732
+ };
733
+ let rounds = 0;
734
+ let previousErrors;
735
+ let stop = "drained";
736
+ let last;
737
+ // An inbox this device could not fully read this round: discovery or a stream failed, or metadata it
738
+ // depends on did. Such a round never establishes that the inbox is empty.
739
+ const inboxUncertain = (r) => r.errors.some((e) => e.operation === "listSubscriptions" || e.operation === "inbox" || e.operation === "metadata");
740
+ // Chains verified only partly: remaining work, and their cursors moving is progress.
741
+ const chains = () => this.store.read((tx) => new Map(tx.list("chainProgress").filter(([, c]) => !c.complete).map(([k, c]) => [k, c.nextGeneration])));
742
+ let previousChains = await chains();
743
+ for (;;) {
744
+ if (this.closed) {
745
+ stop = "closed";
746
+ break;
747
+ }
748
+ // The time left is this round's reading budget too, so a round never starts work the deadline
749
+ // does not cover; a transport call already in flight ends on its own timeout.
750
+ const remainingMs = Number(deadline - this.now());
751
+ if (rounds > 0 && remainingMs <= 0) {
752
+ stop = "maxMs";
753
+ break;
754
+ }
755
+ const roundMs = Math.max(0, Math.min(remainingMs, options.catchUp?.maxMs ?? DEFAULT_CATCH_UP.maxMs));
756
+ const r = await this.sync({ catchUp: { maxMs: roundMs, ...(options.catchUp?.maxRecords !== undefined ? { maxRecords: options.catchUp.maxRecords } : {}) }, ...(options.skipMaintenance ? { skipMaintenance: true } : {}) });
757
+ rounds++;
758
+ last = r;
759
+ // The round's work counts whether or not it was the last: what it accepted, stored and failed
760
+ // is merged first, and only then is a close that landed meanwhile reported.
761
+ for (const k of Object.keys(merged.metadata))
762
+ merged.metadata[k] += r.metadata[k];
763
+ for (const k of Object.keys(merged.outbox))
764
+ merged.outbox[k] += r.outbox[k];
765
+ merged.inbox.streams += r.inbox.streams;
766
+ merged.inbox.stored += r.inbox.stored;
767
+ merged.inbox.keyPending += r.inbox.keyPending;
768
+ merged.inbox.invalid += r.inbox.invalid;
769
+ merged.inbox.acked += r.inbox.acked;
770
+ merged.purged += r.purged;
771
+ for (const k of Object.keys(merged.timings))
772
+ merged.timings[k] += r.timings[k];
773
+ merged.epoch = r.epoch;
774
+ merged.errors.push(...r.errors);
775
+ if (r.bootstrap)
776
+ merged.bootstrap = r.bootstrap;
777
+ if (this.closed) {
778
+ // The round may have stopped at a boundary before its inbox: nothing it reports is complete.
779
+ stop = "closed";
780
+ break;
781
+ }
782
+ if (r.errors.some((e) => e.code === "RATE_LIMITED")) {
783
+ // The host asked for a pause: no further round now; the wait goes back to the product.
784
+ stop = "rateLimited";
785
+ break;
786
+ }
787
+ const bootstrapIncomplete = (await this.inspect()).bootstrap?.complete === false;
788
+ const currentChains = await chains();
789
+ const more = r.inbox.hasMore || bootstrapIncomplete || currentChains.size > 0;
790
+ const status = (await this.getState()).status;
791
+ if (status === "replacementPrepared" || status === "accountRecoveryRequired") {
792
+ stop = "authentication";
793
+ break;
794
+ }
795
+ if (!more) {
796
+ stop = inboxUncertain(r) ? "incomplete" : "drained";
797
+ break;
798
+ }
799
+ const signature = r.errors.map((e) => `${e.operation}:${e.code}`).sort().join(",");
800
+ const chainsAdvanced = [...currentChains].some(([k, next]) => previousChains.get(k) !== next) || [...previousChains.keys()].some((k) => !currentChains.has(k));
801
+ previousChains = currentChains;
802
+ const changed = chainsAdvanced || r.inbox.stored + r.metadata.certificates + r.metadata.snapshots + r.metadata.successions + (r.bootstrap?.circles ?? 0) + (r.bootstrap?.pending ?? 0) > 0;
803
+ if (!changed && signature !== "" && signature === previousErrors) {
804
+ stop = r.errors.some((e) => e.code === "METADATA_REQUIRED" || e.code === "RESET_KEYS_REQUIRED" || e.code === "RESET_CLOCK_UNTRUSTED") ? "metadata" : "noProgress";
805
+ break;
806
+ }
807
+ previousErrors = signature;
808
+ if (rounds >= maxRounds) {
809
+ stop = "maxRounds";
810
+ break;
811
+ }
812
+ if (this.now() >= deadline) {
813
+ stop = "maxMs";
814
+ break;
815
+ }
816
+ }
817
+ merged.inbox.hasMore = last?.inbox.hasMore ?? false;
818
+ const bootstrapIncomplete = this.closed ? false : (await this.inspect()).bootstrap?.complete === false;
819
+ const chainsIncomplete = this.closed ? previousChains.size : (await chains()).size;
820
+ const waitingFor = this.closed ? 0 : (await this.getState()).waitingFor.length;
821
+ const inboxHasMore = !last || inboxUncertain(last) || stop === "closed" || stop === "rateLimited" ? "unknown" : merged.inbox.hasMore;
822
+ const waits = (last?.errors ?? []).flatMap((e) => (e.code === "RATE_LIMITED" && e.retryAfterMs !== undefined ? [e.retryAfterMs] : []));
823
+ const retryAfterMs = waits.length ? Math.max(...waits) : undefined;
824
+ return { ...merged, rounds, stoppedBecause: stop, remaining: { inboxHasMore, bootstrapIncomplete, chainsIncomplete, waitingFor, ...(retryAfterMs === undefined ? {} : { retryAfterMs }) } };
825
+ }
666
826
  async runSync(options) {
827
+ const started = wall();
667
828
  const report = {
668
829
  metadata: { certificates: 0, snapshots: 0, rotations: 0, quarantined: 0, resets: 0, successions: 0 },
669
830
  epoch: "unchanged",
670
831
  outbox: { sent: 0, accepted: 0, cancelled: 0, unknown: 0, retry: 0 },
671
832
  inbox: { streams: 0, stored: 0, keyPending: 0, invalid: 0, acked: 0, hasMore: false },
672
833
  purged: 0,
834
+ maintenance: options.skipMaintenance ? "skipped" : "done",
835
+ timings: emptyTimings(),
673
836
  errors: []
674
837
  };
838
+ const maintain = !options.skipMaintenance;
839
+ let mark = started;
840
+ const phase = (name) => {
841
+ const now = wall();
842
+ report.timings[name] += now - mark;
843
+ mark = now;
844
+ };
845
+ const finish = () => {
846
+ report.timings.total = wall() - started;
847
+ return report;
848
+ };
849
+ try {
850
+ return await this.runSyncPhases(options, report, maintain, phase);
851
+ }
852
+ finally {
853
+ finish();
854
+ }
855
+ }
856
+ async runSyncPhases(options, report, maintain, phase) {
675
857
  this.syncBudget = {
676
858
  remainingRecords: Math.max(1, Math.floor(options.catchUp?.maxRecords ?? DEFAULT_CATCH_UP.maxRecords)),
677
859
  deadline: this.now() + BigInt(Math.max(0, Math.floor(options.catchUp?.maxMs ?? DEFAULT_CATCH_UP.maxMs)))
@@ -691,6 +873,7 @@ class ClientImpl {
691
873
  }
692
874
  }
693
875
  }
876
+ phase("resume");
694
877
  if (!this.account || !this.selfPin)
695
878
  return report;
696
879
  const meta = await this.store.read((tx) => tx.get("meta", META));
@@ -721,7 +904,10 @@ class ClientImpl {
721
904
  }
722
905
  else if (meta.state !== "ACTIVE")
723
906
  return report;
724
- await this.ensureBackup();
907
+ phase("resume");
908
+ if (maintain)
909
+ await this.ensureBackup();
910
+ phase("maintenance");
725
911
  if (meta.bootstrap && !meta.bootstrap.complete) {
726
912
  try {
727
913
  report.bootstrap = await this.bootstrap(report, meta.bootstrap);
@@ -730,20 +916,23 @@ class ClientImpl {
730
916
  report.errors.push(syncError("bootstrap", e));
731
917
  }
732
918
  }
919
+ phase("bootstrap");
733
920
  try {
734
- await this.syncMetadata(report);
921
+ await this.syncMetadata(report, maintain);
735
922
  }
736
923
  catch (e) {
737
924
  report.errors.push(syncError("metadata", e));
738
925
  }
926
+ phase("metadata");
739
927
  try {
740
928
  await this.pushPreferences(report);
741
929
  }
742
930
  catch (e) {
743
931
  report.errors.push(syncError("preference", e));
744
932
  }
933
+ phase("preferences");
745
934
  const after = await this.store.read((tx) => tx.get("meta", META));
746
- if (after?.state === "RETIRED")
935
+ if (after?.state === "RETIRED" || this.closed)
747
936
  return report;
748
937
  try {
749
938
  report.epoch = await this.ensureEpoch();
@@ -751,12 +940,22 @@ class ClientImpl {
751
940
  catch (e) {
752
941
  report.errors.push(syncError("epoch", e));
753
942
  }
943
+ phase("epoch");
944
+ if (this.closed)
945
+ return report;
754
946
  try {
755
- report.outbox = await drain(this.outboxDeps());
947
+ const drained = await drain(this.outboxDeps());
948
+ report.outbox = { sent: drained.sent, accepted: drained.accepted, cancelled: drained.cancelled, unknown: drained.unknown, retry: drained.retry };
949
+ report.timings.bytesSent += drained.bytes;
950
+ if (drained.blocked)
951
+ report.errors.push(syncError("outbox", drained.blocked)); // the records stay queued
756
952
  }
757
953
  catch (e) {
758
954
  report.errors.push(syncError("outbox", e));
759
955
  }
956
+ phase("outbox");
957
+ if (this.closed)
958
+ return report;
760
959
  let subs = [];
761
960
  try {
762
961
  subs = await this.transport.relay.listSubscriptions();
@@ -771,15 +970,23 @@ class ClientImpl {
771
970
  if (budget.remainingRecords < 1)
772
971
  budget.remainingRecords = 1; // every stream reads at least one page
773
972
  for (const s of subs) {
973
+ if (this.closed)
974
+ return report;
774
975
  try {
775
976
  const known = await this.store.read((tx) => tx.list("certificates").some(([, c]) => c.streamId === toHex(s.streamId)));
776
977
  if (!known)
777
978
  continue;
778
979
  const r = await syncStream(this.inboxDeps(), s.streamId, s.authorIdentityId, budget);
779
- report.inbox.streams++;
980
+ // What landed counts, whether or not the stream then failed; a failed stream is not synced.
780
981
  report.inbox.stored += r.stored;
781
982
  report.inbox.keyPending += r.keyPending;
782
983
  report.inbox.invalid += r.invalid;
984
+ report.timings.bytesReceived += r.bytes;
985
+ if (r.failure !== undefined) {
986
+ report.errors.push({ ...syncError("inbox", r.failure), streamId: s.streamId, authorIdentityId: s.authorIdentityId });
987
+ continue;
988
+ }
989
+ report.inbox.streams++;
783
990
  if (r.acked !== undefined)
784
991
  report.inbox.acked++;
785
992
  if (r.hasMore)
@@ -789,11 +996,14 @@ class ClientImpl {
789
996
  report.errors.push({ ...syncError("inbox", e), streamId: s.streamId, authorIdentityId: s.authorIdentityId });
790
997
  }
791
998
  }
792
- report.purged = (await purgeHistory(this.store, this.policy, this.now())) + (await purgeOutbox(this.store, this.policy, this.now()));
999
+ phase("inbox");
1000
+ if (maintain)
1001
+ report.purged = (await purgeHistory(this.store, this.policy, this.now())) + (await purgeOutbox(this.store, this.policy, this.now()));
1002
+ phase("maintenance");
793
1003
  await this.emitState();
794
1004
  return report;
795
1005
  }
796
- async syncMetadata(report) {
1006
+ async syncMetadata(report, maintain = true) {
797
1007
  const { pin } = this.requireAccount("sync");
798
1008
  // Own account: has this device been superseded?
799
1009
  const [me] = await this.transport.directory.resolveAccounts([this.accountName]);
@@ -816,9 +1026,9 @@ class ClientImpl {
816
1026
  return;
817
1027
  }
818
1028
  }
819
- // Reset-key list when due.
1029
+ // Reset-key list when due: always when stale (verification needs it), periodically unless maintenance is skipped.
820
1030
  const now = this.now();
821
- const due = !this.trust.fresh(now) || now - this.trust.state.timeHighWater >= RESET_LIST_RECHECK_MS;
1031
+ const due = !this.trust.fresh(now) || (maintain && now - this.trust.state.timeHighWater >= RESET_LIST_RECHECK_MS);
822
1032
  if (due) {
823
1033
  const wire = await this.transport.directory.getResetKeyList();
824
1034
  if (wire) {
@@ -1383,9 +1593,6 @@ class ClientImpl {
1383
1593
  await this.emitState();
1384
1594
  }
1385
1595
  // Receiving and reads ---------------------------------------------------------------------------
1386
- receive(wire) {
1387
- return receiveRecord(this.inboxDeps(), wire);
1388
- }
1389
1596
  history(query) {
1390
1597
  return history(this.store, this.policy, query);
1391
1598
  }
@@ -1578,7 +1785,7 @@ class ClientImpl {
1578
1785
  const [resolved] = await this.transport.directory.resolveAccounts([accountName]);
1579
1786
  if (!resolved)
1580
1787
  throw new KavachError("METADATA_REQUIRED", "approve", "unknown account");
1581
- const report = { 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: [] };
1788
+ const report = { 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: [] };
1582
1789
  await this.acceptMemberCertificate(resolved, report);
1583
1790
  const requester = resolvedAccountId(resolved);
1584
1791
  const pinned = await this.pinFor(requester);
@@ -1760,7 +1967,7 @@ class ClientImpl {
1760
1967
  await this.changeMembership(circleId, { close: true });
1761
1968
  }
1762
1969
  // Account recovery (amendment A1) ---------------------------------------------------------------
1763
- async prepareReplacement() {
1970
+ async prepareReplacementNow() {
1764
1971
  const stage = "prepareReplacement";
1765
1972
  if (!acceptsSuccession(this.policy))
1766
1973
  throw new KavachError("NOT_AUTHORIZED", stage, "this deployment does not use account recovery");
@@ -1808,7 +2015,7 @@ class ClientImpl {
1808
2015
  await this.emitState();
1809
2016
  return proposalOf(row);
1810
2017
  }
1811
- async completeReplacement(input) {
2018
+ async completeReplacementNow(input) {
1812
2019
  const stage = "completeReplacement";
1813
2020
  // Already activated (by an earlier call, or by a sync that found the commit on the chain): idempotent.
1814
2021
  if (this.selfPin && this.account && !this.retired)
@@ -2088,7 +2295,7 @@ class ClientImpl {
2088
2295
  return state.status === "requested" ? "pending" : "member";
2089
2296
  }
2090
2297
  // Reset -----------------------------------------------------------------------------------------
2091
- async resetAccount(options) {
2298
+ async resetAccountNow(options) {
2092
2299
  const stage = "resetAccount";
2093
2300
  if (!options.confirmed)
2094
2301
  throw new KavachError("NOT_AUTHORIZED", stage, "reset requires confirmation");
@@ -2177,7 +2384,55 @@ class ClientImpl {
2177
2384
  await this.finishReset("load", row, roots);
2178
2385
  }
2179
2386
  async close() {
2387
+ this.closed = true;
2388
+ // Everything in flight finishes its current transaction; a sync stops at its next boundary.
2389
+ while (this.running.size > 0)
2390
+ await Promise.allSettled([...this.running]);
2180
2391
  this.listeners.clear();
2181
2392
  }
2393
+ // The replacement helper ------------------------------------------------------------------------
2394
+ async replaceDeviceNow(options) {
2395
+ const maxAttempts = Math.max(1, Math.floor(options.maxAttempts ?? 3));
2396
+ const activated = () => (this.selfPin && this.account && !this.retired ? { status: "activated", identityId: this.selfPin.identityId, generation: this.selfPin.generation } : undefined);
2397
+ const done = activated();
2398
+ if (done)
2399
+ return done;
2400
+ // A proposal left by an earlier run may have committed: the chain settles it before anything else.
2401
+ const existing = await this.store.read((tx) => tx.get("replacement", "pending"));
2402
+ if (existing && !existing.stale) {
2403
+ await this.sync();
2404
+ const settled = activated();
2405
+ if (settled)
2406
+ return settled;
2407
+ }
2408
+ const proposal = await this.prepareReplacementNow();
2409
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
2410
+ const authorization = await options.authorize(proposal);
2411
+ if (authorization === "reauthenticate")
2412
+ return { status: "reauthenticate", proposal };
2413
+ for (;;) {
2414
+ try {
2415
+ const r = await this.completeReplacementNow({ successionId: proposal.successionId, authorization });
2416
+ return { status: "activated", identityId: r.identityId, generation: r.generation };
2417
+ }
2418
+ catch (e) {
2419
+ if (isKavachError(e, "SUCCESSION_PENDING")) {
2420
+ // The same call again with the same bytes; the host answers a committed id first.
2421
+ if (++attempt >= maxAttempts)
2422
+ return { status: "pending", proposal };
2423
+ continue;
2424
+ }
2425
+ if (isKavachError(e, "AUTHORIZATION_REQUIRED"))
2426
+ break; // a fresh token for the same proposal
2427
+ if (isKavachError(e, "REPLACEMENT_CONFLICT")) {
2428
+ const conflict = (await this.inspect()).replacement;
2429
+ return { status: "conflict", currentGeneration: conflict?.conflictGeneration };
2430
+ }
2431
+ throw e;
2432
+ }
2433
+ }
2434
+ }
2435
+ return { status: "pending", proposal };
2436
+ }
2182
2437
  }
2183
2438
  //# sourceMappingURL=client.js.map