@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.
- package/README.md +28 -0
- package/dist/adapters/index.d.ts +1 -0
- package/dist/adapters/index.d.ts.map +1 -1
- package/dist/adapters/index.js +1 -0
- package/dist/adapters/index.js.map +1 -1
- package/dist/client.d.ts +113 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +284 -29
- package/dist/client.js.map +1 -1
- package/dist/crypto/noble.d.ts.map +1 -1
- package/dist/crypto/noble.js +19 -1
- package/dist/crypto/noble.js.map +1 -1
- package/dist/errors.d.ts +6 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +5 -1
- package/dist/errors.js.map +1 -1
- package/dist/inbox.d.ts +4 -0
- package/dist/inbox.d.ts.map +1 -1
- package/dist/inbox.js +22 -14
- package/dist/inbox.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/outbox.d.ts +5 -0
- package/dist/outbox.d.ts.map +1 -1
- package/dist/outbox.js +6 -3
- package/dist/outbox.js.map +1 -1
- package/dist/store.d.ts +2 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +2 -0
- package/dist/store.js.map +1 -1
- package/dist/testing/faults.d.ts +8 -1
- package/dist/testing/faults.d.ts.map +1 -1
- package/dist/testing/faults.js +16 -0
- package/dist/testing/faults.js.map +1 -1
- package/package.json +1 -1
- package/src/adapters/index.ts +1 -0
- package/src/client.ts +390 -30
- package/src/crypto/noble.ts +18 -1
- package/src/errors.ts +7 -1
- package/src/inbox.ts +24 -13
- package/src/index.ts +7 -1
- package/src/outbox.ts +9 -3
- package/src/store.ts +3 -0
- 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
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|