@openlfcp/client 0.1.4 → 0.2.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/access-recovery.d.ts +69 -0
- package/dist/access-recovery.js +45 -0
- package/dist/batch-status.d.ts +104 -0
- package/dist/batch-status.js +171 -0
- package/dist/claim-journal.d.ts +54 -0
- package/dist/claim-journal.js +52 -0
- package/dist/index.d.ts +12 -5
- package/dist/index.js +11 -4
- package/dist/invite.d.ts +41 -2
- package/dist/invite.js +115 -2
- package/dist/outbound.d.ts +18 -3
- package/dist/outbound.js +7 -2
- package/dist/queue.d.ts +4 -0
- package/dist/queue.js +16 -10
- package/dist/receipts.d.ts +60 -0
- package/dist/receipts.js +118 -0
- package/dist/revoke.d.ts +71 -0
- package/dist/revoke.js +108 -0
- package/dist/section-status.d.ts +94 -0
- package/dist/section-status.js +16 -0
- package/dist/storage.d.ts +29 -0
- package/dist/storage.js +65 -0
- package/dist/sync-client.d.ts +148 -3
- package/dist/sync-client.js +736 -11
- package/dist/typing.d.ts +60 -0
- package/dist/typing.js +110 -0
- package/dist/write-access.d.ts +115 -0
- package/dist/write-access.js +87 -0
- package/package.json +6 -6
package/dist/sync-client.js
CHANGED
|
@@ -1,14 +1,51 @@
|
|
|
1
|
-
import { actorSequence, dataEpoch, hash32, LfcpError, toHex, } from "@openlfcp/core";
|
|
2
|
-
import { exportSecretKeyBytes, sha256 } from "@openlfcp/crypto";
|
|
1
|
+
import { actorSequence, bytesEqual, controlRecordId, dataEpoch, dataUnitId, fromHex, hash32, LfcpError, toHex, } from "@openlfcp/core";
|
|
2
|
+
import { exportSecretKeyBytes, sha256, } from "@openlfcp/crypto";
|
|
3
3
|
import { dekSecretRef, } from "@openlfcp/storage";
|
|
4
|
-
import { addRange, addSequence, batchDataRanges, canonicalFrontierToCbor, createMessage, ERROR_CODE, hasSequence, haveDifference, localControlOf, MESSAGE_TYPE, missingFrom, normalizeLiveHaves, parseControlRecord, parseDataUnit, parseKeyPackage, planControlSync, receiveKeyPackage, receiveSnapshot, validateControlChain, } from "@openlfcp/wire";
|
|
4
|
+
import { addRange, addSequence, batchDataRanges, canonicalFrontierToCbor, createMessage, decodeControlRecord, ERROR_CODE, hasSequence, haveDifference, localControlOf, MESSAGE_TYPE, missingFrom, normalizeLiveHaves, parseControlRecord, parseDataUnit, parseKeyPackage, planControlSync, receiveKeyPackage, receiveSnapshot, validateControlChain, } from "@openlfcp/wire";
|
|
5
5
|
import { encode } from "@openlfcp/wire/cbor";
|
|
6
|
+
import { afterAccepted, afterMismatch, RECOVERY_TRANSIENT_ATTEMPTS, RECOVERY_TRANSIENT_CODES, startRecovery, } from "./access-recovery.js";
|
|
7
|
+
import { ACCEPTANCE_DURABILITY, batchStatus, batchStatuses, recordEvidence, } from "./batch-status.js";
|
|
8
|
+
import { readClaimJournal } from "./claim-journal.js";
|
|
6
9
|
import { LfcpConnection } from "./connection.js";
|
|
7
10
|
import { EngineGuard, isEngineTrap, snapshotItem } from "./engine-guard.js";
|
|
8
11
|
import { resourceSyncState, snapshotFrontier } from "./outbound.js";
|
|
9
|
-
import { createQueuedSnapshot } from "./queue.js";
|
|
12
|
+
import { controlRecordWrites, createQueuedSnapshot, keyPackageWrites } from "./queue.js";
|
|
13
|
+
import { intentsHash, OperationIdReusedError, receiptOf } from "./receipts.js";
|
|
10
14
|
import { resourcePhaseTransition, } from "./resource-state.js";
|
|
11
|
-
import {
|
|
15
|
+
import { planRevocation, RevocationRefused, } from "./revoke.js";
|
|
16
|
+
import { receivedState, } from "./section-status.js";
|
|
17
|
+
import { adoptStoredDeks, commitOperation, dekResolver, loadControlChain, saveControlChain, saveControlConflict, } from "./storage.js";
|
|
18
|
+
import { accessState, NotWritableError, writeAccess, } from "./write-access.js";
|
|
19
|
+
/** Every status of a stored unit: any of them means the Resource has content. */
|
|
20
|
+
const STORED_STATUSES = [
|
|
21
|
+
"seen",
|
|
22
|
+
"merged",
|
|
23
|
+
"profile-pending",
|
|
24
|
+
"held",
|
|
25
|
+
"quarantined",
|
|
26
|
+
"equivocation",
|
|
27
|
+
"local-failure",
|
|
28
|
+
"profile-held",
|
|
29
|
+
"profile-rejected",
|
|
30
|
+
"profile-unsupported",
|
|
31
|
+
];
|
|
32
|
+
/**
|
|
33
|
+
* SDK-SECTIONS-INTEGRATION-01 §3.6: a batch the SDK refuses before the
|
|
34
|
+
* profile sees it, with the same codes and fields as the profile's
|
|
35
|
+
* refusals. Nothing is written.
|
|
36
|
+
*/
|
|
37
|
+
export class CommitRefusedError extends Error {
|
|
38
|
+
code;
|
|
39
|
+
intentIndex;
|
|
40
|
+
constructor(code, message, intentIndex) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.code = code;
|
|
43
|
+
this.intentIndex = intentIndex;
|
|
44
|
+
this.name = "CommitRefusedError";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Access as a comparable key, to report it only when it changed (verifiedAt aside). */
|
|
48
|
+
const accessKey = (a) => JSON.stringify({ ...a, verifiedAt: null }, (_, v) => typeof v === "bigint" ? `${v}` : v instanceof Uint8Array ? toHex(v) : v);
|
|
12
49
|
/** 1 s, doubling, at most 60 s, forever. */
|
|
13
50
|
export const defaultReconnect = (attempt) => Math.min(60_000, 1000 * 2 ** (attempt - 1));
|
|
14
51
|
/**
|
|
@@ -77,6 +114,10 @@ export class SyncClient {
|
|
|
77
114
|
#maxMessageBytes = 8 * 1024 * 1024;
|
|
78
115
|
/** Serializes message handling: each message is handled after the previous one finished. */
|
|
79
116
|
#queue = Promise.resolve();
|
|
117
|
+
/** READY's durability in this session; null without one (§37). */
|
|
118
|
+
#readyDurability = null;
|
|
119
|
+
/** The status stream's revision per Resource (hex), in this session (§5). */
|
|
120
|
+
#statusRevision = new Map();
|
|
80
121
|
constructor(options) {
|
|
81
122
|
this.#o = options;
|
|
82
123
|
this.#snapshots = new EngineGuard(options.storage, "snapshots");
|
|
@@ -105,6 +146,202 @@ export class SyncClient {
|
|
|
105
146
|
for (const l of this.#listeners)
|
|
106
147
|
l(event);
|
|
107
148
|
}
|
|
149
|
+
// -------------------------------------------------------------------------
|
|
150
|
+
// Status (SDK-SECTIONS-INTEGRATION-01 §4, §5; LFCP-02-026)
|
|
151
|
+
#emitStatus(resource, body) {
|
|
152
|
+
const key = toHex(resource);
|
|
153
|
+
const revision = (this.#statusRevision.get(key) ?? 0) + 1;
|
|
154
|
+
this.#statusRevision.set(key, revision);
|
|
155
|
+
this.#emit({
|
|
156
|
+
type: "status",
|
|
157
|
+
resourceId: resource,
|
|
158
|
+
event: Object.freeze({ ...body, revision }),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* §5: the complete status of `resource` at the stream's current revision:
|
|
163
|
+
* every unreleased batch, the received units that wait or were refused,
|
|
164
|
+
* the section state and write access. Events at or below its revision
|
|
165
|
+
* are already in it.
|
|
166
|
+
*/
|
|
167
|
+
async statusSnapshot(resource) {
|
|
168
|
+
const ctx = this.#resources.get(toHex(resource));
|
|
169
|
+
const batches = await batchStatuses(this.#o.storage, resource, this.#readyDurability, (ctx?.refusal ?? null) !== null);
|
|
170
|
+
const received = await receivedState(this.#o.storage, resource);
|
|
171
|
+
const access = await this.accessState(resource);
|
|
172
|
+
const section = ctx?.binding.commit?.section?.() ?? "unknown";
|
|
173
|
+
if (ctx !== undefined) {
|
|
174
|
+
for (const b of batches)
|
|
175
|
+
ctx.reported.batches.set(b.operationId, b.status);
|
|
176
|
+
ctx.reported.access = accessKey(access);
|
|
177
|
+
ctx.reported.section = section === "unknown" ? null : section;
|
|
178
|
+
ctx.reported.catchUp = this.#catchUp(ctx);
|
|
179
|
+
}
|
|
180
|
+
return Object.freeze({
|
|
181
|
+
revision: this.#statusRevision.get(toHex(resource)) ?? 0,
|
|
182
|
+
batches: Object.freeze(batches),
|
|
183
|
+
received,
|
|
184
|
+
section,
|
|
185
|
+
access,
|
|
186
|
+
catchUp: Object.freeze({
|
|
187
|
+
state: ctx === undefined ? "not-started" : this.#catchUp(ctx),
|
|
188
|
+
checkedAt: ctx?.liveAt ?? null,
|
|
189
|
+
}),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
/** The batches of `operations`, reported as they are now. */
|
|
193
|
+
async #reportBatches(resource, operations, finals = new Map()) {
|
|
194
|
+
const ctx = this.#resources.get(toHex(resource));
|
|
195
|
+
for (const op of operations) {
|
|
196
|
+
// A released batch forgotten at its final status: reported with that status.
|
|
197
|
+
const b = finals.get(op) ??
|
|
198
|
+
(await batchStatus(this.#o.storage, resource, op, this.#readyDurability, (ctx?.refusal ?? null) !== null));
|
|
199
|
+
if (b === undefined)
|
|
200
|
+
continue;
|
|
201
|
+
ctx?.reported.batches.set(op, b.status);
|
|
202
|
+
this.#emitStatus(resource, { kind: "batch", ...b });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/** §4.3: a received unit that is held, waits for a dependency, or was refused. */
|
|
206
|
+
#reportReceived(resource, outcome) {
|
|
207
|
+
switch (outcome.kind) {
|
|
208
|
+
case "profile-held":
|
|
209
|
+
this.#emitStatus(resource, { kind: "received", fact: "held", unitIds: [outcome.unitId] });
|
|
210
|
+
return;
|
|
211
|
+
case "profile-pending":
|
|
212
|
+
case "held":
|
|
213
|
+
this.#emitStatus(resource, {
|
|
214
|
+
kind: "received",
|
|
215
|
+
fact: "waiting",
|
|
216
|
+
unitIds: [outcome.unitId],
|
|
217
|
+
});
|
|
218
|
+
return;
|
|
219
|
+
case "profile-rejected":
|
|
220
|
+
this.#emitStatus(resource, {
|
|
221
|
+
kind: "received",
|
|
222
|
+
fact: "refused",
|
|
223
|
+
unitIds: [outcome.unitId],
|
|
224
|
+
diagnostic: outcome.message,
|
|
225
|
+
});
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/** Batches already reported whose status the session's durability changed (§4.1). */
|
|
230
|
+
async #refreshBatches() {
|
|
231
|
+
for (const ctx of this.#resources.values()) {
|
|
232
|
+
if (ctx.reported.batches.size === 0)
|
|
233
|
+
continue;
|
|
234
|
+
const R = ctx.binding.resourceId;
|
|
235
|
+
const changed = [];
|
|
236
|
+
for (const b of await batchStatuses(this.#o.storage, R, this.#readyDurability, ctx.refusal !== null)) {
|
|
237
|
+
const old = ctx.reported.batches.get(b.operationId);
|
|
238
|
+
if (old !== undefined && old !== b.status)
|
|
239
|
+
changed.push(b.operationId);
|
|
240
|
+
}
|
|
241
|
+
await this.#reportBatches(R, changed);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* LFCP-02-115: the server refuses this client the Resource in this
|
|
246
|
+
* session (AUTHORIZATION_FAILED on RESOURCE_OPEN, the recovery of 106
|
|
247
|
+
* not run or not enough). Null otherwise.
|
|
248
|
+
*/
|
|
249
|
+
#serverRefusal(ctx) {
|
|
250
|
+
if (ctx?.refusal?.code !== "AUTHORIZATION_FAILED")
|
|
251
|
+
return null;
|
|
252
|
+
return Object.freeze({ code: ctx.refusal.code, recovery: ctx.recoveryEnd });
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* After a refusal of the Resource, or an open that clears one: reports
|
|
256
|
+
* access, catch-up, and every batch whose status that changes (blocked
|
|
257
|
+
* while refused, pending again after).
|
|
258
|
+
*/
|
|
259
|
+
async #reportRefusal(ctx) {
|
|
260
|
+
const R = ctx.binding.resourceId;
|
|
261
|
+
await this.#refreshAccess(ctx);
|
|
262
|
+
this.#reportCatchUp(ctx);
|
|
263
|
+
const changed = [];
|
|
264
|
+
for (const b of await batchStatuses(this.#o.storage, R, this.#readyDurability, ctx.refusal !== null)) {
|
|
265
|
+
const old = ctx.reported.batches.get(b.operationId);
|
|
266
|
+
if (old !== b.status && (old !== undefined || b.status === "blocked"))
|
|
267
|
+
changed.push(b.operationId);
|
|
268
|
+
}
|
|
269
|
+
await this.#reportBatches(R, changed);
|
|
270
|
+
}
|
|
271
|
+
/** Reports write access when it changed (§5 `access`). */
|
|
272
|
+
async #refreshAccess(ctx) {
|
|
273
|
+
const R = ctx.binding.resourceId;
|
|
274
|
+
const access = await this.accessState(R);
|
|
275
|
+
const key = accessKey(access);
|
|
276
|
+
if (key === ctx.reported.access)
|
|
277
|
+
return;
|
|
278
|
+
ctx.reported.access = key;
|
|
279
|
+
this.#emitStatus(R, { kind: "access", access });
|
|
280
|
+
}
|
|
281
|
+
/** LFCP-02-028: the catch-up fact of a Resource from its phase. */
|
|
282
|
+
#catchUp(ctx) {
|
|
283
|
+
// Refused by the server in this session: how current we are is unknown.
|
|
284
|
+
if (ctx.refusal !== null)
|
|
285
|
+
return "unknown";
|
|
286
|
+
switch (ctx.state) {
|
|
287
|
+
case "LIVE":
|
|
288
|
+
return "current-at-checkpoint";
|
|
289
|
+
case "OPENING":
|
|
290
|
+
case "CONTROL_SYNC":
|
|
291
|
+
case "KEY_SYNC":
|
|
292
|
+
case "DATA_SYNC":
|
|
293
|
+
return "receiving";
|
|
294
|
+
case "KEY_BLOCKED":
|
|
295
|
+
case "CONTROL_CONFLICT":
|
|
296
|
+
return "unknown";
|
|
297
|
+
case "CLOSED":
|
|
298
|
+
return ctx.liveAt === null ? "not-started" : "current-at-checkpoint";
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/** Reports the catch-up fact when it changed. */
|
|
302
|
+
#reportCatchUp(ctx) {
|
|
303
|
+
const state = this.#catchUp(ctx);
|
|
304
|
+
if (state === ctx.reported.catchUp)
|
|
305
|
+
return;
|
|
306
|
+
ctx.reported.catchUp = state;
|
|
307
|
+
this.#emitStatus(ctx.binding.resourceId, {
|
|
308
|
+
kind: "catch-up",
|
|
309
|
+
state,
|
|
310
|
+
checkedAt: ctx.liveAt,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
/** Reports the section state when it changed (§5 `section-state`). */
|
|
314
|
+
#refreshSection(ctx) {
|
|
315
|
+
const state = ctx.binding.commit?.section?.();
|
|
316
|
+
if (state === undefined || state === ctx.reported.section)
|
|
317
|
+
return;
|
|
318
|
+
ctx.reported.section = state;
|
|
319
|
+
this.#emitStatus(ctx.binding.resourceId, { kind: "section-state", state });
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* §4.1: what an ACK establishes for units of `resource`: acceptance when
|
|
323
|
+
* it answers our message (correlated), says durable at a READY level of
|
|
324
|
+
* 2 or more, and comes from a route of the Resource's current route set.
|
|
325
|
+
*/
|
|
326
|
+
#ackEvidence(resource, durability, correlated) {
|
|
327
|
+
const route = this.#resources.get(toHex(resource))?.view?.state.route;
|
|
328
|
+
const onRoute = route !== undefined &&
|
|
329
|
+
(route.endpoints.some((e) => e.url === this.#o.url) || route.coordinatorUrl === this.#o.url);
|
|
330
|
+
return correlated && onRoute && durability >= ACCEPTANCE_DURABILITY
|
|
331
|
+
? { kind: "accepted" }
|
|
332
|
+
: { kind: "unconfirmed" };
|
|
333
|
+
}
|
|
334
|
+
/** Records `evidence` for units (hex) of `resource` and reports the batches it changed. */
|
|
335
|
+
async #evidence(resource, units, evidence) {
|
|
336
|
+
const { writes, changed, finals } = await recordEvidence(this.#o.storage, resource, units, evidence);
|
|
337
|
+
if (writes.length === 0)
|
|
338
|
+
return changed;
|
|
339
|
+
const r = await this.#o.storage.commit(writes);
|
|
340
|
+
if (!r.ok)
|
|
341
|
+
throw new Error(`the batch status was not stored: ${r.reason}`);
|
|
342
|
+
await this.#reportBatches(resource, changed.keys(), finals);
|
|
343
|
+
return changed;
|
|
344
|
+
}
|
|
108
345
|
#serial(fn) {
|
|
109
346
|
this.#queue = this.#queue
|
|
110
347
|
.then(() => (this.#trapped ? undefined : fn()))
|
|
@@ -171,6 +408,11 @@ export class SyncClient {
|
|
|
171
408
|
state: "CLOSED",
|
|
172
409
|
wanted: true,
|
|
173
410
|
view: null,
|
|
411
|
+
verified: null,
|
|
412
|
+
serverControlSeq: null,
|
|
413
|
+
liveAt: null,
|
|
414
|
+
lossReason: null,
|
|
415
|
+
reported: { catchUp: null, access: null, section: null, batches: new Map() },
|
|
174
416
|
fetched: [],
|
|
175
417
|
controlTarget: null,
|
|
176
418
|
remoteHave: [],
|
|
@@ -193,12 +435,19 @@ export class SyncClient {
|
|
|
193
435
|
offering: new Set(),
|
|
194
436
|
keysOffered: false,
|
|
195
437
|
rehosted: false,
|
|
438
|
+
recoveryTried: false,
|
|
439
|
+
recoveryAttempts: 0,
|
|
440
|
+
recoveryReopened: false,
|
|
441
|
+
recoveryEnd: null,
|
|
196
442
|
};
|
|
197
443
|
this.#resources.set(key, ctx);
|
|
198
444
|
}
|
|
199
445
|
ctx.wanted = true;
|
|
200
446
|
// An explicit open asks the server again after a refusal.
|
|
447
|
+
const wasRefused = ctx.refusal !== null;
|
|
201
448
|
ctx.refusal = null;
|
|
449
|
+
if (wasRefused)
|
|
450
|
+
this.#serial(() => this.#reportRefusal(ctx));
|
|
202
451
|
ctx.refusedAttempt = 0;
|
|
203
452
|
ctx.reopenAt = null;
|
|
204
453
|
if (this.#connection.state === "READY")
|
|
@@ -261,8 +510,13 @@ export class SyncClient {
|
|
|
261
510
|
ctx.wanted &&
|
|
262
511
|
this.#connection.state === "READY")
|
|
263
512
|
await this.#sendOpen(ctx);
|
|
264
|
-
|
|
513
|
+
// Periodic anti-entropy (§68.1), both planes: the server's answer
|
|
514
|
+
// to CONTROL_HAVE also re-offers Control Records it still lacks,
|
|
515
|
+
// e.g. after a refused or lost re-supply.
|
|
516
|
+
if (ctx.state === "LIVE" && now - ctx.lastHave >= this.#antiEntropyMs) {
|
|
265
517
|
this.#sendHave(ctx, now);
|
|
518
|
+
this.#refreshControl(ctx);
|
|
519
|
+
}
|
|
266
520
|
if (ctx.state === "KEY_BLOCKED" && now - ctx.lastKeyRequest >= this.#antiEntropyMs)
|
|
267
521
|
this.#requestKeys(ctx, now);
|
|
268
522
|
await this.#flush(ctx);
|
|
@@ -310,7 +564,9 @@ export class SyncClient {
|
|
|
310
564
|
ctx?.offering.delete(u.id);
|
|
311
565
|
continue;
|
|
312
566
|
}
|
|
313
|
-
if (request.kind === "offer-control" ||
|
|
567
|
+
if (request.kind === "offer-control" ||
|
|
568
|
+
request.kind === "offer-keys" ||
|
|
569
|
+
request.kind === "recover-control")
|
|
314
570
|
continue;
|
|
315
571
|
if ("resource" in request)
|
|
316
572
|
resend.set(`${request.kind}:${request.resource}`, request);
|
|
@@ -355,8 +611,13 @@ export class SyncClient {
|
|
|
355
611
|
if (next === "LIVE") {
|
|
356
612
|
ctx.refusedAttempt = 0;
|
|
357
613
|
ctx.rehosted = false;
|
|
614
|
+
ctx.recoveryTried = false;
|
|
615
|
+
ctx.recoveryAttempts = 0;
|
|
358
616
|
}
|
|
359
617
|
this.#emit({ type: "resource-state", resourceId: ctx.binding.resourceId, state: next });
|
|
618
|
+
if (next === "LIVE")
|
|
619
|
+
ctx.liveAt = this.#o.now();
|
|
620
|
+
this.#reportCatchUp(ctx);
|
|
360
621
|
if (next === "LIVE" && ctx.controlStale) {
|
|
361
622
|
ctx.controlStale = false;
|
|
362
623
|
this.#refreshControl(ctx);
|
|
@@ -383,12 +644,16 @@ export class SyncClient {
|
|
|
383
644
|
durability: ready.durability,
|
|
384
645
|
maxMessageBytes: ready.maxMessageBytes,
|
|
385
646
|
});
|
|
647
|
+
this.#readyDurability = ready.durability;
|
|
648
|
+
await this.#refreshBatches();
|
|
386
649
|
for (const ctx of this.#resources.values())
|
|
387
650
|
if (ctx.wanted)
|
|
388
651
|
await this.#sendOpen(ctx);
|
|
389
652
|
}
|
|
390
653
|
async #onClosed(reason) {
|
|
391
654
|
this.#emit({ type: "connection", state: "DISCONNECTED", reason });
|
|
655
|
+
this.#readyDurability = null;
|
|
656
|
+
await this.#refreshBatches();
|
|
392
657
|
for (const r of this.#requests.values())
|
|
393
658
|
if (r.kind === "host")
|
|
394
659
|
r.reject(new Error(`the connection closed before RESOURCE_HOSTED: ${reason}`));
|
|
@@ -398,6 +663,7 @@ export class SyncClient {
|
|
|
398
663
|
this.#move(ctx, "CLOSE"); // §65, G-SM1: every Resource closes with the connection
|
|
399
664
|
ctx.view = null;
|
|
400
665
|
ctx.offering.clear();
|
|
666
|
+
ctx.recoveryTried = false; // a new session may recover again (LFCP-02-106)
|
|
401
667
|
}
|
|
402
668
|
await this.#o.outbound.connectionLost(iso(this.#o.now()));
|
|
403
669
|
if (this.#stopped)
|
|
@@ -448,6 +714,8 @@ export class SyncClient {
|
|
|
448
714
|
url: this.#o.url,
|
|
449
715
|
outcome: "hosted",
|
|
450
716
|
});
|
|
717
|
+
ctx.lossReason ??= "rehost";
|
|
718
|
+
this.#emitStatus(ctx.binding.resourceId, { kind: "rehost", route: this.#o.url });
|
|
451
719
|
if (ctx.wanted)
|
|
452
720
|
await this.#sendOpen(ctx);
|
|
453
721
|
}
|
|
@@ -460,7 +728,12 @@ export class SyncClient {
|
|
|
460
728
|
ctx.remoteHave = normalizeLiveHaves(m.body.haves);
|
|
461
729
|
ctx.offered = m.body.snapshot ?? null;
|
|
462
730
|
await this.#markHosted(ctx.binding.resourceId);
|
|
731
|
+
if (ctx.recoveryReopened) {
|
|
732
|
+
ctx.recoveryReopened = false;
|
|
733
|
+
this.#recoveryEvent(ctx, "recovered");
|
|
734
|
+
}
|
|
463
735
|
this.#move(ctx, "OPENED");
|
|
736
|
+
await this.#noteServerHeads(ctx, m.body.heads);
|
|
464
737
|
// §68.1, §88 step 6: what the server lacks goes first, Control Records before units.
|
|
465
738
|
await this.#offerControl(ctx, m.body.heads);
|
|
466
739
|
await this.#offerData(ctx);
|
|
@@ -470,6 +743,8 @@ export class SyncClient {
|
|
|
470
743
|
case "CONTROL_HAVE": {
|
|
471
744
|
const ctx = this.#ctx(m.body.resourceId);
|
|
472
745
|
this.#done(m);
|
|
746
|
+
if (ctx !== undefined)
|
|
747
|
+
await this.#noteServerHeads(ctx, m.body.heads);
|
|
473
748
|
if (ctx !== undefined && ctx.state === "LIVE")
|
|
474
749
|
await this.#onControlHeads(ctx, m.body.heads);
|
|
475
750
|
return;
|
|
@@ -522,11 +797,25 @@ export class SyncClient {
|
|
|
522
797
|
return;
|
|
523
798
|
}
|
|
524
799
|
case "ACK":
|
|
800
|
+
if (request?.kind === "recover-control") {
|
|
801
|
+
this.#done(m);
|
|
802
|
+
await this.#recoveryAccepted(request);
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
525
805
|
if (request !== undefined) {
|
|
526
806
|
this.#done(m);
|
|
527
|
-
if (request.kind === "offer-data")
|
|
807
|
+
if (request.kind === "offer-data") {
|
|
808
|
+
const ctx = this.#resources.get(request.resource);
|
|
528
809
|
for (const u of request.units)
|
|
529
|
-
|
|
810
|
+
ctx?.offering.delete(u.id);
|
|
811
|
+
if (ctx !== undefined) {
|
|
812
|
+
// §4.2: a re-offered unit of ours is accepted again by this ACK.
|
|
813
|
+
const named = new Set((m.body.objectIds ?? []).map((id) => toHex(id)));
|
|
814
|
+
const durability = m.body.durable === true ? (this.#readyDurability ?? 0n) : 0n;
|
|
815
|
+
const R = ctx.binding.resourceId;
|
|
816
|
+
await this.#evidence(R, request.units.map((u) => u.id).filter((id) => named.has(id)), this.#ackEvidence(R, durability, true));
|
|
817
|
+
}
|
|
818
|
+
}
|
|
530
819
|
return;
|
|
531
820
|
}
|
|
532
821
|
await this.#onAck(m);
|
|
@@ -565,6 +854,20 @@ export class SyncClient {
|
|
|
565
854
|
if (ctx !== undefined && (await this.#rehost(ctx)))
|
|
566
855
|
return;
|
|
567
856
|
}
|
|
857
|
+
if (request.kind === "open" && code === "AUTHORIZATION_FAILED") {
|
|
858
|
+
const ctx = this.#resources.get(request.resource);
|
|
859
|
+
if (ctx?.recoveryReopened) {
|
|
860
|
+
// The server holds our chain and still refuses: not a loss.
|
|
861
|
+
ctx.recoveryReopened = false;
|
|
862
|
+
this.#recoveryEvent(ctx, "ended", "still-refused");
|
|
863
|
+
}
|
|
864
|
+
else if (ctx !== undefined && (await this.#recoverAccess(ctx)))
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
if (request.kind === "recover-control") {
|
|
868
|
+
await this.#recoveryRefused(request, code, m.body.details);
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
568
871
|
if (request.kind === "rehost") {
|
|
569
872
|
const ctx = this.#resources.get(request.resource);
|
|
570
873
|
if (ctx === undefined)
|
|
@@ -627,6 +930,8 @@ export class SyncClient {
|
|
|
627
930
|
this.#request({ kind: "close", resource: toHex(ctx.binding.resourceId) }, createMessage("RESOURCE_CLOSE", { resourceId: ctx.binding.resourceId }));
|
|
628
931
|
this.#move(ctx, "CLOSE");
|
|
629
932
|
this.#emit({ type: "resource-refused", resourceId: ctx.binding.resourceId, refusal });
|
|
933
|
+
// LFCP-02-115: the status stream says so too.
|
|
934
|
+
this.#serial(() => this.#reportRefusal(ctx));
|
|
630
935
|
}
|
|
631
936
|
/** CLOSED now, opened again after the ReconnectPolicy's delay for this Resource's attempt. */
|
|
632
937
|
#retryLater(ctx) {
|
|
@@ -736,9 +1041,11 @@ export class SyncClient {
|
|
|
736
1041
|
const R = ctx.binding.resourceId;
|
|
737
1042
|
const before = previous ?? ctx.view;
|
|
738
1043
|
ctx.view = chain;
|
|
1044
|
+
ctx.verified = { head: chain.state.head, at: this.#o.now() };
|
|
739
1045
|
// DEKs we already hold for epochs the saved chain now has (our own
|
|
740
1046
|
// rotation, or a package that came before the row): no request needed.
|
|
741
1047
|
await adoptStoredDeks(this.#o.storage, this.#o.secrets, chain);
|
|
1048
|
+
await this.#refreshAccess(ctx);
|
|
742
1049
|
const epochsChanged = before === null ||
|
|
743
1050
|
[...chain.state.epochs.values()].some((e) => {
|
|
744
1051
|
const old = before.state.epochs.get(String(e.epoch));
|
|
@@ -840,6 +1147,7 @@ export class SyncClient {
|
|
|
840
1147
|
}
|
|
841
1148
|
await this.#o.storage.commit(writes);
|
|
842
1149
|
}
|
|
1150
|
+
await this.#refreshAccess(ctx);
|
|
843
1151
|
const missing = await this.#epochsWithoutDek(ctx);
|
|
844
1152
|
const current = view.state.epoch.epoch;
|
|
845
1153
|
if (missing.some((e) => e === current)) {
|
|
@@ -988,6 +1296,247 @@ export class SyncClient {
|
|
|
988
1296
|
* that loads it never skips content the state does not hold. Queued and
|
|
989
1297
|
* sent like any object; returns its ID.
|
|
990
1298
|
*/
|
|
1299
|
+
/**
|
|
1300
|
+
* LFCP-02-060: removes `subject`'s access to `resource`. Every active
|
|
1301
|
+
* grant naming it that this client may revoke is revoked (§17.3); when it
|
|
1302
|
+
* has no read access left, the Data Epoch is rotated with a fresh DEK
|
|
1303
|
+
* (§19, member revoked) and the DEK goes in Key Packages to every other
|
|
1304
|
+
* remaining reader (§25), so it cannot read new data. Built on the
|
|
1305
|
+
* validated head and checked before anything is queued; the records, the
|
|
1306
|
+
* Key Packages and (first) the new DEK are written at once, then sent.
|
|
1307
|
+
* Refused, with nothing queued, unless the Resource is live and current
|
|
1308
|
+
* here and no Control Record of this client is pending.
|
|
1309
|
+
*/
|
|
1310
|
+
revokeAccess(resource, subject, options = {}) {
|
|
1311
|
+
return new Promise((resolve, reject) => this.#serial(async () => {
|
|
1312
|
+
try {
|
|
1313
|
+
resolve(await this.#revoke(resource, subject, options));
|
|
1314
|
+
}
|
|
1315
|
+
catch (e) {
|
|
1316
|
+
reject(e);
|
|
1317
|
+
}
|
|
1318
|
+
}));
|
|
1319
|
+
}
|
|
1320
|
+
async #revoke(resource, subject, options) {
|
|
1321
|
+
const refused = (reason, message) => Object.freeze({ kind: "refused", reason, message });
|
|
1322
|
+
const ctx = this.#resources.get(toHex(resource));
|
|
1323
|
+
const view = ctx?.view ?? null;
|
|
1324
|
+
if (ctx === undefined || ctx.state !== "LIVE" || view === null)
|
|
1325
|
+
return refused("offline", "the Resource is not live in this session");
|
|
1326
|
+
if (ctx.serverControlSeq !== null && view.state.seq < ctx.serverControlSeq)
|
|
1327
|
+
return refused("stale", "the validated Control Chain is behind the server's");
|
|
1328
|
+
const storage = this.#o.storage;
|
|
1329
|
+
if ((await storage.outbound.list(resource)).some((i) => i.kind === "control-record"))
|
|
1330
|
+
return refused("control-pending", "Control Records of this client are not committed yet");
|
|
1331
|
+
let plan;
|
|
1332
|
+
try {
|
|
1333
|
+
plan = await planRevocation({
|
|
1334
|
+
view,
|
|
1335
|
+
revoker: this.#o.signer,
|
|
1336
|
+
subject,
|
|
1337
|
+
frontier: (await resourceSyncState(storage, resource)).have,
|
|
1338
|
+
...(options.rotate === undefined ? {} : { rotate: options.rotate }),
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
catch (e) {
|
|
1342
|
+
if (e instanceof RevocationRefused)
|
|
1343
|
+
return refused(e.reason, e.message);
|
|
1344
|
+
throw e;
|
|
1345
|
+
}
|
|
1346
|
+
// The new DEK first (LFCP-034), then everything that names it, at once.
|
|
1347
|
+
if (plan.rotation !== null)
|
|
1348
|
+
await this.#o.secrets.put(dekSecretRef(resource, plan.rotation.epoch), exportSecretKeyBytes(plan.rotation.dek));
|
|
1349
|
+
const r = await storage.commit([
|
|
1350
|
+
...plan.records.flatMap((rec) => controlRecordWrites(rec.bytes)),
|
|
1351
|
+
...plan.keyPackages.flatMap(keyPackageWrites),
|
|
1352
|
+
]);
|
|
1353
|
+
if (!r.ok)
|
|
1354
|
+
throw new Error(`the revocation was not queued: ${r.reason}`);
|
|
1355
|
+
await this.#refreshAccess(ctx);
|
|
1356
|
+
await this.#flush(ctx);
|
|
1357
|
+
return Object.freeze({
|
|
1358
|
+
kind: "queued",
|
|
1359
|
+
revoked: plan.revoked,
|
|
1360
|
+
deactivated: plan.deactivated,
|
|
1361
|
+
recordIds: Object.freeze(plan.records.map((rec) => rec.recordId)),
|
|
1362
|
+
epoch: plan.rotation?.epoch ?? null,
|
|
1363
|
+
recipients: plan.recipients,
|
|
1364
|
+
remainingPaths: plan.remainingPaths,
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
/**
|
|
1368
|
+
* SDK-SECTIONS-INTEGRATION-01 §3.1: commits a batch of intents on an open
|
|
1369
|
+
* Resource and returns its receipt once the batch is durable: its Data
|
|
1370
|
+
* Units, outbound entries, the profile checkpoint and the receipt are
|
|
1371
|
+
* stored in one transaction, then the batch is sent. An operation that
|
|
1372
|
+
* has a receipt returns it for the same intents and writes nothing, and
|
|
1373
|
+
* throws OperationIdReusedError for different ones (§3.3). Runs between
|
|
1374
|
+
* received units, never during their merge.
|
|
1375
|
+
*/
|
|
1376
|
+
commit(resource, intents, options) {
|
|
1377
|
+
const ctx = this.#resources.get(toHex(resource));
|
|
1378
|
+
if (ctx === undefined)
|
|
1379
|
+
return Promise.reject(new LfcpError("UNSUPPORTED_VALUE", "the Resource is not open here"));
|
|
1380
|
+
const binding = ctx.binding.commit;
|
|
1381
|
+
if (binding === undefined)
|
|
1382
|
+
return Promise.reject(new LfcpError("UNSUPPORTED_VALUE", "the Resource has no commit binding"));
|
|
1383
|
+
return new Promise((resolve, reject) => this.#serial(async () => {
|
|
1384
|
+
try {
|
|
1385
|
+
resolve(await this.#commit(ctx, binding, intents, options.operationId));
|
|
1386
|
+
}
|
|
1387
|
+
catch (e) {
|
|
1388
|
+
reject(e);
|
|
1389
|
+
}
|
|
1390
|
+
}));
|
|
1391
|
+
}
|
|
1392
|
+
/**
|
|
1393
|
+
* SDK-SECTIONS-INTEGRATION-01 §6: whether this client may commit to
|
|
1394
|
+
* `resource`, from the validated Control state only: the chain of the
|
|
1395
|
+
* last Control sync, or the stored chain validated now. `verifiedAt` is
|
|
1396
|
+
* the local time of that validation (the last sync's when the stored
|
|
1397
|
+
* chain is still at its head). Unknown without a validated chain.
|
|
1398
|
+
*/
|
|
1399
|
+
async canWrite(resource) {
|
|
1400
|
+
const ctx = this.#resources.get(toHex(resource));
|
|
1401
|
+
const view = ctx?.view ?? (await loadControlChain(this.#o.storage, resource));
|
|
1402
|
+
return this.#access(resource, view, ctx?.verified ?? null, this.#serverRefusal(ctx));
|
|
1403
|
+
}
|
|
1404
|
+
/**
|
|
1405
|
+
* LFCP-02-027: write access with its evidence: the Control sequence it
|
|
1406
|
+
* was validated at against the highest one a server reported in this
|
|
1407
|
+
* session (a stale view is not current), the effective abilities and
|
|
1408
|
+
* the active grant paths giving them, and apart from them the
|
|
1409
|
+
* invitations we issued, our Control Records not yet committed (SI14)
|
|
1410
|
+
* and an unsettled invitation claim of ours.
|
|
1411
|
+
*/
|
|
1412
|
+
async accessState(resource) {
|
|
1413
|
+
const ctx = this.#resources.get(toHex(resource));
|
|
1414
|
+
const view = ctx?.view ?? (await loadControlChain(this.#o.storage, resource));
|
|
1415
|
+
const refusal = this.#serverRefusal(ctx);
|
|
1416
|
+
const write = await this.#access(resource, view, ctx?.verified ?? null, refusal);
|
|
1417
|
+
const pendingControl = [];
|
|
1418
|
+
for (const item of await this.#o.storage.outbound.list(resource)) {
|
|
1419
|
+
if (item.kind !== "control-record")
|
|
1420
|
+
continue;
|
|
1421
|
+
const record = decodeControlRecord(item.bytes);
|
|
1422
|
+
pendingControl.push(Object.freeze({ recordId: controlRecordId(record.signed.id), type: record.body.type }));
|
|
1423
|
+
}
|
|
1424
|
+
const dek = view?.kind === "linear" &&
|
|
1425
|
+
(await this.#currentDek(resource, view.state.epoch.epoch)) !== undefined;
|
|
1426
|
+
return accessState(view, this.#o.signer.descriptor.principalId, dek, write.verifiedAt ?? 0, {
|
|
1427
|
+
serverControlSeq: ctx?.serverControlSeq ?? null,
|
|
1428
|
+
pendingControl,
|
|
1429
|
+
pendingClaim: (await readClaimJournal(this.#o.storage, resource)) !== undefined,
|
|
1430
|
+
serverRefusal: refusal,
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
/** Records the highest Control sequence a server reported, and reports access if it changed. */
|
|
1434
|
+
async #noteServerHeads(ctx, heads) {
|
|
1435
|
+
const seq = heads.reduce((m, h) => (m === null || h.seq > m ? h.seq : m), null);
|
|
1436
|
+
if (seq === null || (ctx.serverControlSeq !== null && seq <= ctx.serverControlSeq))
|
|
1437
|
+
return;
|
|
1438
|
+
ctx.serverControlSeq = seq;
|
|
1439
|
+
await this.#refreshAccess(ctx);
|
|
1440
|
+
}
|
|
1441
|
+
/**
|
|
1442
|
+
* The DEK of `epoch`, or undefined when it is not held or the key store
|
|
1443
|
+
* cannot be read now (LFCP-02-030): an unreadable key store is
|
|
1444
|
+
* key-unavailable, never a reason to write without it or to reset state.
|
|
1445
|
+
*/
|
|
1446
|
+
async #currentDek(resource, epoch) {
|
|
1447
|
+
try {
|
|
1448
|
+
return await dekResolver(this.#o.storage, this.#o.secrets, resource)(epoch);
|
|
1449
|
+
}
|
|
1450
|
+
catch {
|
|
1451
|
+
return undefined;
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
async #access(resource, view, verified, refusal = null) {
|
|
1455
|
+
if (view?.kind !== "linear")
|
|
1456
|
+
return writeAccess(view, this.#o.signer.descriptor.principalId, false, 0);
|
|
1457
|
+
const dek = await this.#currentDek(resource, view.state.epoch.epoch);
|
|
1458
|
+
const at = verified !== null && bytesEqual(verified.head, view.state.head) ? verified.at : this.#o.now();
|
|
1459
|
+
const access = writeAccess(view, this.#o.signer.descriptor.principalId, dek !== undefined, at);
|
|
1460
|
+
// LFCP-02-115: the server refuses us the Resource; the local chain is not the last word.
|
|
1461
|
+
return refusal === null
|
|
1462
|
+
? access
|
|
1463
|
+
: Object.freeze({ ...access, allowed: false, reason: "server-refused" });
|
|
1464
|
+
}
|
|
1465
|
+
/**
|
|
1466
|
+
* SHARED-SECTIONS-PROFILE-01 §13, in depth behind the adapter's own
|
|
1467
|
+
* check: an incomplete load never becomes a new, empty section. A batch
|
|
1468
|
+
* that creates a section is refused (SECTION_EXISTS) when units of the
|
|
1469
|
+
* Resource are stored (content exists that the model may not hold yet),
|
|
1470
|
+
* or when this client is not the Resource's owner and the Resource was
|
|
1471
|
+
* not live in this session (it has not caught up). The owner creates its
|
|
1472
|
+
* new Resource's section offline.
|
|
1473
|
+
*/
|
|
1474
|
+
async #guardCreate(ctx, view, intents) {
|
|
1475
|
+
const index = intents.findIndex((i) => typeof i === "object" &&
|
|
1476
|
+
i !== null &&
|
|
1477
|
+
i.intent === "section.create");
|
|
1478
|
+
if (index < 0)
|
|
1479
|
+
return;
|
|
1480
|
+
const R = ctx.binding.resourceId;
|
|
1481
|
+
const stored = (await Promise.all(STORED_STATUSES.map((st) => this.#o.storage.dataUnits.withStatus(R, st)))).some((units) => units.length > 0);
|
|
1482
|
+
if (stored)
|
|
1483
|
+
throw new CommitRefusedError("SECTION_EXISTS", "units of this Resource are stored: it is not a new section (§13)", index);
|
|
1484
|
+
const owner = bytesEqual(view.state.owner.principalId, this.#o.signer.descriptor.principalId);
|
|
1485
|
+
if (!owner && ctx.liveAt === null)
|
|
1486
|
+
throw new CommitRefusedError("SECTION_EXISTS", "the Resource has not caught up in this session: its section may exist (§13)", index);
|
|
1487
|
+
}
|
|
1488
|
+
async #commit(ctx, binding, intents, operationId) {
|
|
1489
|
+
const R = ctx.binding.resourceId;
|
|
1490
|
+
const storage = this.#o.storage;
|
|
1491
|
+
// §3.3: a committed operation answers first; its intents may no longer apply.
|
|
1492
|
+
const known = await receiptOf(storage, R, operationId);
|
|
1493
|
+
if (known !== undefined) {
|
|
1494
|
+
if (known.intentsHash !== intentsHash(intents))
|
|
1495
|
+
throw new OperationIdReusedError(operationId);
|
|
1496
|
+
return known;
|
|
1497
|
+
}
|
|
1498
|
+
const view = ctx.view ?? (await loadControlChain(storage, R));
|
|
1499
|
+
// §3.6, §6: a batch submitted while writing is not allowed writes nothing.
|
|
1500
|
+
const access = await this.#access(R, view, ctx.verified, this.#serverRefusal(ctx));
|
|
1501
|
+
if (!access.allowed || view?.kind !== "linear")
|
|
1502
|
+
throw new NotWritableError(access);
|
|
1503
|
+
const dek = await this.#currentDek(R, view.state.epoch.epoch);
|
|
1504
|
+
if (dek === undefined)
|
|
1505
|
+
throw new NotWritableError({ ...access, allowed: false, reason: "key-unavailable" });
|
|
1506
|
+
await this.#guardCreate(ctx, view, intents);
|
|
1507
|
+
const staged = binding.stage(intents);
|
|
1508
|
+
staged?.apply();
|
|
1509
|
+
try {
|
|
1510
|
+
const { receipt, committed } = await commitOperation(storage, {
|
|
1511
|
+
view,
|
|
1512
|
+
controlHead: view.state.head,
|
|
1513
|
+
actor: this.#o.signer,
|
|
1514
|
+
dek,
|
|
1515
|
+
profile: binding.codec,
|
|
1516
|
+
operationId,
|
|
1517
|
+
intents,
|
|
1518
|
+
values: staged?.values ?? [],
|
|
1519
|
+
affectedNodeIds: staged?.affectedNodeIds ?? [],
|
|
1520
|
+
modelRevision: staged?.modelRevision ?? "",
|
|
1521
|
+
}, (units) => staged?.writes(units) ?? []);
|
|
1522
|
+
if (!committed)
|
|
1523
|
+
staged?.revert();
|
|
1524
|
+
else
|
|
1525
|
+
staged?.committed(receipt.unitIds);
|
|
1526
|
+
if (committed) {
|
|
1527
|
+
// Our own units grow the history a Snapshot covers, as received ones do.
|
|
1528
|
+
ctx.unitsSinceSnapshot += receipt.unitIds.length;
|
|
1529
|
+
await this.#reportBatches(R, [receipt.operationId]);
|
|
1530
|
+
this.#refreshSection(ctx);
|
|
1531
|
+
}
|
|
1532
|
+
await this.#flush(ctx);
|
|
1533
|
+
return receipt;
|
|
1534
|
+
}
|
|
1535
|
+
catch (e) {
|
|
1536
|
+
staged?.revert();
|
|
1537
|
+
throw e;
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
991
1540
|
async publishSnapshot(resource) {
|
|
992
1541
|
const ctx = this.#resources.get(toHex(resource));
|
|
993
1542
|
if (ctx === undefined)
|
|
@@ -1142,16 +1691,20 @@ export class SyncClient {
|
|
|
1142
1691
|
if (outcome.kind === "rejected" && outcome.wireCode === "MISSING_DEPENDENCY")
|
|
1143
1692
|
needControl = true;
|
|
1144
1693
|
this.#emit({ type: "unit", resourceId: R, outcome });
|
|
1694
|
+
this.#reportReceived(R, outcome);
|
|
1145
1695
|
// Held units this one released (§26.2): their outcomes too.
|
|
1146
1696
|
const released = "released" in outcome ? outcome.released : [];
|
|
1147
1697
|
for (const r of released) {
|
|
1148
1698
|
if (r.kind === "applied" || r.kind === "profile-pending")
|
|
1149
1699
|
changed = true;
|
|
1150
1700
|
this.#emit({ type: "unit", resourceId: R, outcome: r });
|
|
1701
|
+
this.#reportReceived(R, r);
|
|
1151
1702
|
}
|
|
1152
1703
|
}
|
|
1153
|
-
if (changed)
|
|
1704
|
+
if (changed) {
|
|
1154
1705
|
ctx.binding.checkpointer?.noteChange();
|
|
1706
|
+
this.#refreshSection(ctx);
|
|
1707
|
+
}
|
|
1155
1708
|
if (needControl)
|
|
1156
1709
|
this.#refreshControl(ctx);
|
|
1157
1710
|
if (ctx.state === "DATA_SYNC" &&
|
|
@@ -1189,7 +1742,32 @@ export class SyncClient {
|
|
|
1189
1742
|
this.#connection.sendEncoded(m.bytes);
|
|
1190
1743
|
}
|
|
1191
1744
|
async #onAck(m) {
|
|
1192
|
-
|
|
1745
|
+
if (m.body.requestType === MESSAGE_TYPE.CONTROL_PUT)
|
|
1746
|
+
await this.#adoptAcked(m);
|
|
1747
|
+
// §4.1: acceptance is recorded in the dequeue's transaction.
|
|
1748
|
+
const changed = [];
|
|
1749
|
+
const outcome = await this.#o.outbound.onAck(m, iso(this.#o.now()), async (items, ack) => {
|
|
1750
|
+
const byResource = new Map();
|
|
1751
|
+
for (const item of items) {
|
|
1752
|
+
if (item.kind !== "data-unit")
|
|
1753
|
+
continue;
|
|
1754
|
+
const key = toHex(item.resourceId);
|
|
1755
|
+
const entry = byResource.get(key) ?? { R: item.resourceId, units: [] };
|
|
1756
|
+
entry.units.push(toHex(item.itemId));
|
|
1757
|
+
byResource.set(key, entry);
|
|
1758
|
+
}
|
|
1759
|
+
const writes = [];
|
|
1760
|
+
for (const { R, units } of byResource.values()) {
|
|
1761
|
+
const evidence = this.#ackEvidence(R, ack.durability, ack.correlated);
|
|
1762
|
+
const r = await recordEvidence(this.#o.storage, R, units, evidence);
|
|
1763
|
+
writes.push(...r.writes);
|
|
1764
|
+
if (r.changed.size > 0)
|
|
1765
|
+
changed.push([R, r.changed, r.finals]);
|
|
1766
|
+
}
|
|
1767
|
+
return writes;
|
|
1768
|
+
});
|
|
1769
|
+
for (const [R, ops, finals] of changed)
|
|
1770
|
+
await this.#reportBatches(R, ops.keys(), finals);
|
|
1193
1771
|
this.#emit({ type: "ack", outcome });
|
|
1194
1772
|
// Our own Control Record was committed: catch up, since the server does not push it back to us.
|
|
1195
1773
|
if (m.body.requestType === MESSAGE_TYPE.CONTROL_PUT && outcome.acked.length > 0)
|
|
@@ -1199,8 +1777,57 @@ export class SyncClient {
|
|
|
1199
1777
|
else
|
|
1200
1778
|
ctx.controlStale = true;
|
|
1201
1779
|
}
|
|
1780
|
+
/**
|
|
1781
|
+
* An ACK of our CONTROL_PUT (§47) means the server committed the record
|
|
1782
|
+
* on the head it expected: our own previous record. The record joins the
|
|
1783
|
+
* stored chain the way a fetched one does, BEFORE it leaves the queue, so
|
|
1784
|
+
* it is never held by the server alone: a stop between the ACK and the
|
|
1785
|
+
* catch-up fetch, then a server restored from an older copy, would
|
|
1786
|
+
* otherwise lose it (LFCP-02-106). A crash before the dequeue only sends
|
|
1787
|
+
* it again, and a repeated ACK is idempotent. A record that does not
|
|
1788
|
+
* continue the stored chain is left to the catch-up fetch.
|
|
1789
|
+
*/
|
|
1790
|
+
async #adoptAcked(m) {
|
|
1791
|
+
for (const id of m.body.objectIds ?? []) {
|
|
1792
|
+
const item = await this.#o.storage.outbound.get(id);
|
|
1793
|
+
if (item?.kind !== "control-record")
|
|
1794
|
+
continue;
|
|
1795
|
+
const ctx = this.#ctx(item.resourceId);
|
|
1796
|
+
if (ctx === undefined)
|
|
1797
|
+
continue;
|
|
1798
|
+
await this.#onControlRecords(ctx, [item.bytes], false);
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1202
1801
|
async #onNack(m) {
|
|
1203
1802
|
const outcome = await this.#o.outbound.onNack(m, iso(this.#o.now()));
|
|
1803
|
+
// §4.1: a terminal NACK rejects the batch of the unit.
|
|
1804
|
+
if (outcome.kind === "rejected" ||
|
|
1805
|
+
outcome.kind === "stale" ||
|
|
1806
|
+
outcome.kind === "equivocation-alarm") {
|
|
1807
|
+
const code = outcome.kind === "rejected"
|
|
1808
|
+
? outcome.code
|
|
1809
|
+
: outcome.kind === "stale"
|
|
1810
|
+
? "STALE_DATA_EPOCH"
|
|
1811
|
+
: "ACTOR_EQUIVOCATION";
|
|
1812
|
+
const byResource = new Map();
|
|
1813
|
+
for (const item of outcome.items) {
|
|
1814
|
+
if (item.kind !== "data-unit")
|
|
1815
|
+
continue;
|
|
1816
|
+
const R = (await this.#o.storage.outbound.get(item.itemId))?.resourceId;
|
|
1817
|
+
if (R === undefined)
|
|
1818
|
+
continue;
|
|
1819
|
+
const entry = byResource.get(toHex(R)) ?? { R, units: [] };
|
|
1820
|
+
entry.units.push(toHex(item.itemId));
|
|
1821
|
+
byResource.set(toHex(R), entry);
|
|
1822
|
+
}
|
|
1823
|
+
for (const { R, units } of byResource.values())
|
|
1824
|
+
await this.#evidence(R, units, { kind: "rejected", code });
|
|
1825
|
+
}
|
|
1826
|
+
if (outcome.kind === "needs-offer") {
|
|
1827
|
+
const ctx = this.#ctx(outcome.resourceId);
|
|
1828
|
+
if (ctx !== undefined)
|
|
1829
|
+
ctx.lossReason ??= "unknown-previous";
|
|
1830
|
+
}
|
|
1204
1831
|
this.#emit({ type: "nack", outcome });
|
|
1205
1832
|
if (outcome.kind === "needs-control-sync")
|
|
1206
1833
|
for (const ctx of this.#resources.values())
|
|
@@ -1248,6 +1875,94 @@ export class SyncClient {
|
|
|
1248
1875
|
this.#request({ kind: "rehost", resource: toHex(R) }, createMessage("RESOURCE_HOST", { genesis: genesis.signed.bytes }));
|
|
1249
1876
|
return true;
|
|
1250
1877
|
}
|
|
1878
|
+
/**
|
|
1879
|
+
* LFCP-02-106: RESOURCE_OPEN was refused with AUTHORIZATION_FAILED. When
|
|
1880
|
+
* our validated chain grants us data/read, the server may have lost the
|
|
1881
|
+
* records that do (a restore): push our head, and follow the server's
|
|
1882
|
+
* answer (see access-recovery.ts). False when no recovery runs; the
|
|
1883
|
+
* refusal then stands as before.
|
|
1884
|
+
*/
|
|
1885
|
+
async #recoverAccess(ctx) {
|
|
1886
|
+
if (ctx.recoveryTried || this.#connection.state !== "READY")
|
|
1887
|
+
return false;
|
|
1888
|
+
const chain = await loadControlChain(this.#o.storage, ctx.binding.resourceId);
|
|
1889
|
+
if (chain?.kind !== "linear")
|
|
1890
|
+
return false;
|
|
1891
|
+
const step = startRecovery(chain.state, chain.records, this.#o.signer.descriptor.principalId);
|
|
1892
|
+
ctx.recoveryTried = true;
|
|
1893
|
+
if (step.kind !== "push") {
|
|
1894
|
+
this.#recoveryEvent(ctx, "ended", step.kind === "final" ? step.reason : undefined);
|
|
1895
|
+
return false;
|
|
1896
|
+
}
|
|
1897
|
+
this.#recoveryEvent(ctx, "started");
|
|
1898
|
+
this.#move(ctx, "CLOSE");
|
|
1899
|
+
await this.#recoveryStep(ctx, step);
|
|
1900
|
+
return true;
|
|
1901
|
+
}
|
|
1902
|
+
async #recoveryStep(ctx, step) {
|
|
1903
|
+
const R = ctx.binding.resourceId;
|
|
1904
|
+
if (step.kind === "reopen") {
|
|
1905
|
+
ctx.recoveryReopened = true;
|
|
1906
|
+
if (ctx.wanted)
|
|
1907
|
+
await this.#sendOpen(ctx);
|
|
1908
|
+
return;
|
|
1909
|
+
}
|
|
1910
|
+
if (step.kind === "final") {
|
|
1911
|
+
this.#recoveryEvent(ctx, "ended", step.reason);
|
|
1912
|
+
this.#refuse(ctx, { code: "AUTHORIZATION_FAILED", url: this.#o.url, request: "open" });
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
if (this.#connection.state !== "READY")
|
|
1916
|
+
return; // the next session opens and may recover again
|
|
1917
|
+
const chain = await loadControlChain(this.#o.storage, R);
|
|
1918
|
+
const record = chain?.kind === "linear" ? chain.records[step.index] : undefined;
|
|
1919
|
+
const previous = record?.payload.prevControlId ?? null;
|
|
1920
|
+
if (record === undefined || previous === null) {
|
|
1921
|
+
await this.#recoveryStep(ctx, { kind: "final", reason: "refused" });
|
|
1922
|
+
return;
|
|
1923
|
+
}
|
|
1924
|
+
this.#request({ kind: "recover-control", resource: toHex(R), index: step.index }, createMessage("CONTROL_PUT", {
|
|
1925
|
+
resourceId: R,
|
|
1926
|
+
expectedHead: previous,
|
|
1927
|
+
record: record.signed.bytes,
|
|
1928
|
+
}));
|
|
1929
|
+
}
|
|
1930
|
+
async #recoveryAccepted(request) {
|
|
1931
|
+
const ctx = this.#resources.get(request.resource);
|
|
1932
|
+
if (ctx === undefined)
|
|
1933
|
+
return;
|
|
1934
|
+
const chain = await loadControlChain(this.#o.storage, ctx.binding.resourceId);
|
|
1935
|
+
if (chain?.kind !== "linear")
|
|
1936
|
+
return;
|
|
1937
|
+
await this.#recoveryStep(ctx, afterAccepted(chain.records, request.index));
|
|
1938
|
+
}
|
|
1939
|
+
async #recoveryRefused(request, code, details) {
|
|
1940
|
+
const ctx = this.#resources.get(request.resource);
|
|
1941
|
+
if (ctx === undefined)
|
|
1942
|
+
return;
|
|
1943
|
+
if (RECOVERY_TRANSIENT_CODES.has(code) && ctx.recoveryAttempts < RECOVERY_TRANSIENT_ATTEMPTS) {
|
|
1944
|
+
// Open again after a backoff; the refused open recovers again.
|
|
1945
|
+
ctx.recoveryAttempts += 1;
|
|
1946
|
+
ctx.recoveryTried = false;
|
|
1947
|
+
this.#retryLater(ctx);
|
|
1948
|
+
return;
|
|
1949
|
+
}
|
|
1950
|
+
const chain = await loadControlChain(this.#o.storage, ctx.binding.resourceId);
|
|
1951
|
+
const step = code === "CONTROL_HEAD_MISMATCH" && chain?.kind === "linear"
|
|
1952
|
+
? afterMismatch(chain.records, details)
|
|
1953
|
+
: { kind: "final", reason: "refused" };
|
|
1954
|
+
await this.#recoveryStep(ctx, step);
|
|
1955
|
+
}
|
|
1956
|
+
#recoveryEvent(ctx, outcome, reason) {
|
|
1957
|
+
ctx.recoveryEnd = outcome === "ended" ? (reason ?? null) : null;
|
|
1958
|
+
this.#emit({
|
|
1959
|
+
type: "access-recovery",
|
|
1960
|
+
resourceId: ctx.binding.resourceId,
|
|
1961
|
+
url: this.#o.url,
|
|
1962
|
+
outcome,
|
|
1963
|
+
...(reason === undefined ? {} : { reason }),
|
|
1964
|
+
});
|
|
1965
|
+
}
|
|
1251
1966
|
/**
|
|
1252
1967
|
* §68.1: the server's Control Head is a record we hold below our own head:
|
|
1253
1968
|
* upload the records above it, one CONTROL_PUT each, in order, each
|
|
@@ -1302,9 +2017,19 @@ export class SyncClient {
|
|
|
1302
2017
|
!queued.has(toHex(u.unitId)) &&
|
|
1303
2018
|
!ctx.offering.has(toHex(u.unitId)));
|
|
1304
2019
|
sent += units.length;
|
|
2020
|
+
// §4.2: units of our batches the server lost are pending again.
|
|
2021
|
+
const lost = await this.#evidence(R, units.map((u) => toHex(u.unitId)), { kind: "reoffered" });
|
|
2022
|
+
const again = [...lost.values()].flat();
|
|
2023
|
+
if (again.length > 0)
|
|
2024
|
+
this.#emitStatus(R, {
|
|
2025
|
+
kind: "reoffered",
|
|
2026
|
+
unitIds: Object.freeze(again.map((u) => dataUnitId(fromHex(u)))),
|
|
2027
|
+
reason: ctx.lossReason ?? "have-gap",
|
|
2028
|
+
});
|
|
1305
2029
|
this.#offerUnits(ctx, units.map((u) => ({ id: toHex(u.unitId), bytes: u.bytes })));
|
|
1306
2030
|
}
|
|
1307
2031
|
await this.#o.outbound.offered(R);
|
|
2032
|
+
ctx.lossReason = null;
|
|
1308
2033
|
if (sent > 0)
|
|
1309
2034
|
await this.#offerKeys(ctx);
|
|
1310
2035
|
await this.#flush(ctx);
|