@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/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
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|