@openlfcp/client 0.1.5 → 0.2.0-beta.10
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/connection.d.ts +9 -0
- package/dist/connection.js +12 -1
- package/dist/data-unit.js +6 -4
- package/dist/index.d.ts +14 -6
- package/dist/index.js +13 -5
- package/dist/invite.d.ts +42 -3
- package/dist/invite.js +155 -10
- package/dist/outbound.d.ts +25 -3
- package/dist/outbound.js +38 -4
- 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/repropose.d.ts +81 -0
- package/dist/repropose.js +221 -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 +46 -0
- package/dist/storage.js +105 -4
- package/dist/sync-client.d.ts +161 -3
- package/dist/sync-client.js +807 -14
- 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,52 @@
|
|
|
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 {
|
|
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";
|
|
9
|
+
import { ConnectionNotReady, 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";
|
|
14
|
+
import { reproposeControlRecords } from "./repropose.js";
|
|
10
15
|
import { resourcePhaseTransition, } from "./resource-state.js";
|
|
11
|
-
import {
|
|
16
|
+
import { planRevocation, RevocationRefused, } from "./revoke.js";
|
|
17
|
+
import { receivedState, } from "./section-status.js";
|
|
18
|
+
import { adoptStoredDeks, commitOperation, dekResolver, loadControlChain, saveControlChain, saveControlConflict, } from "./storage.js";
|
|
19
|
+
import { accessState, NotWritableError, writeAccess, } from "./write-access.js";
|
|
20
|
+
/** Every status of a stored unit: any of them means the Resource has content. */
|
|
21
|
+
const STORED_STATUSES = [
|
|
22
|
+
"seen",
|
|
23
|
+
"merged",
|
|
24
|
+
"profile-pending",
|
|
25
|
+
"held",
|
|
26
|
+
"quarantined",
|
|
27
|
+
"equivocation",
|
|
28
|
+
"local-failure",
|
|
29
|
+
"profile-held",
|
|
30
|
+
"profile-rejected",
|
|
31
|
+
"profile-unsupported",
|
|
32
|
+
];
|
|
33
|
+
/**
|
|
34
|
+
* SDK-SECTIONS-INTEGRATION-01 §3.6: a batch the SDK refuses before the
|
|
35
|
+
* profile sees it, with the same codes and fields as the profile's
|
|
36
|
+
* refusals. Nothing is written.
|
|
37
|
+
*/
|
|
38
|
+
export class CommitRefusedError extends Error {
|
|
39
|
+
code;
|
|
40
|
+
intentIndex;
|
|
41
|
+
constructor(code, message, intentIndex) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.code = code;
|
|
44
|
+
this.intentIndex = intentIndex;
|
|
45
|
+
this.name = "CommitRefusedError";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Access as a comparable key, to report it only when it changed (verifiedAt aside). */
|
|
49
|
+
const accessKey = (a) => JSON.stringify({ ...a, verifiedAt: null }, (_, v) => typeof v === "bigint" ? `${v}` : v instanceof Uint8Array ? toHex(v) : v);
|
|
12
50
|
/** 1 s, doubling, at most 60 s, forever. */
|
|
13
51
|
export const defaultReconnect = (attempt) => Math.min(60_000, 1000 * 2 ** (attempt - 1));
|
|
14
52
|
/**
|
|
@@ -65,6 +103,8 @@ export class SyncClient {
|
|
|
65
103
|
#sentAt = new Map();
|
|
66
104
|
#requestTimeoutMs;
|
|
67
105
|
#listeners = new Set();
|
|
106
|
+
/** Re-proposal attempts per Control Record (item ID hex), bounded; cleared on ACK (repropose.ts). */
|
|
107
|
+
#reproposeAttempts = new Map();
|
|
68
108
|
#antiEntropyMs;
|
|
69
109
|
/** The crash-loop breaker for Snapshot loads (EngineGuard). */
|
|
70
110
|
#snapshots;
|
|
@@ -77,6 +117,10 @@ export class SyncClient {
|
|
|
77
117
|
#maxMessageBytes = 8 * 1024 * 1024;
|
|
78
118
|
/** Serializes message handling: each message is handled after the previous one finished. */
|
|
79
119
|
#queue = Promise.resolve();
|
|
120
|
+
/** READY's durability in this session; null without one (§37). */
|
|
121
|
+
#readyDurability = null;
|
|
122
|
+
/** The status stream's revision per Resource (hex), in this session (§5). */
|
|
123
|
+
#statusRevision = new Map();
|
|
80
124
|
constructor(options) {
|
|
81
125
|
this.#o = options;
|
|
82
126
|
this.#snapshots = new EngineGuard(options.storage, "snapshots");
|
|
@@ -105,10 +149,211 @@ export class SyncClient {
|
|
|
105
149
|
for (const l of this.#listeners)
|
|
106
150
|
l(event);
|
|
107
151
|
}
|
|
152
|
+
// -------------------------------------------------------------------------
|
|
153
|
+
// Status (SDK-SECTIONS-INTEGRATION-01 §4, §5; LFCP-02-026)
|
|
154
|
+
#emitStatus(resource, body) {
|
|
155
|
+
const key = toHex(resource);
|
|
156
|
+
const revision = (this.#statusRevision.get(key) ?? 0) + 1;
|
|
157
|
+
this.#statusRevision.set(key, revision);
|
|
158
|
+
this.#emit({
|
|
159
|
+
type: "status",
|
|
160
|
+
resourceId: resource,
|
|
161
|
+
event: Object.freeze({ ...body, revision }),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* §5: the complete status of `resource` at the stream's current revision:
|
|
166
|
+
* every unreleased batch, the received units that wait or were refused,
|
|
167
|
+
* the section state and write access. Events at or below its revision
|
|
168
|
+
* are already in it.
|
|
169
|
+
*/
|
|
170
|
+
async statusSnapshot(resource) {
|
|
171
|
+
const ctx = this.#resources.get(toHex(resource));
|
|
172
|
+
const batches = await batchStatuses(this.#o.storage, resource, this.#readyDurability, (ctx?.refusal ?? null) !== null);
|
|
173
|
+
const received = await receivedState(this.#o.storage, resource);
|
|
174
|
+
const access = await this.accessState(resource);
|
|
175
|
+
const section = ctx?.binding.commit?.section?.() ?? "unknown";
|
|
176
|
+
if (ctx !== undefined) {
|
|
177
|
+
for (const b of batches)
|
|
178
|
+
ctx.reported.batches.set(b.operationId, b.status);
|
|
179
|
+
ctx.reported.access = accessKey(access);
|
|
180
|
+
ctx.reported.section = section === "unknown" ? null : section;
|
|
181
|
+
ctx.reported.catchUp = this.#catchUp(ctx);
|
|
182
|
+
}
|
|
183
|
+
return Object.freeze({
|
|
184
|
+
revision: this.#statusRevision.get(toHex(resource)) ?? 0,
|
|
185
|
+
batches: Object.freeze(batches),
|
|
186
|
+
received,
|
|
187
|
+
section,
|
|
188
|
+
access,
|
|
189
|
+
catchUp: Object.freeze({
|
|
190
|
+
state: ctx === undefined ? "not-started" : this.#catchUp(ctx),
|
|
191
|
+
checkedAt: ctx?.liveAt ?? null,
|
|
192
|
+
}),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
/** The batches of `operations`, reported as they are now. */
|
|
196
|
+
async #reportBatches(resource, operations, finals = new Map()) {
|
|
197
|
+
const ctx = this.#resources.get(toHex(resource));
|
|
198
|
+
for (const op of operations) {
|
|
199
|
+
// A released batch forgotten at its final status: reported with that status.
|
|
200
|
+
const b = finals.get(op) ??
|
|
201
|
+
(await batchStatus(this.#o.storage, resource, op, this.#readyDurability, (ctx?.refusal ?? null) !== null));
|
|
202
|
+
if (b === undefined)
|
|
203
|
+
continue;
|
|
204
|
+
ctx?.reported.batches.set(op, b.status);
|
|
205
|
+
this.#emitStatus(resource, { kind: "batch", ...b });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/** §4.3: a received unit that is held, waits for a dependency, or was refused. */
|
|
209
|
+
#reportReceived(resource, outcome) {
|
|
210
|
+
switch (outcome.kind) {
|
|
211
|
+
case "profile-held":
|
|
212
|
+
this.#emitStatus(resource, { kind: "received", fact: "held", unitIds: [outcome.unitId] });
|
|
213
|
+
return;
|
|
214
|
+
case "profile-pending":
|
|
215
|
+
case "held":
|
|
216
|
+
this.#emitStatus(resource, {
|
|
217
|
+
kind: "received",
|
|
218
|
+
fact: "waiting",
|
|
219
|
+
unitIds: [outcome.unitId],
|
|
220
|
+
});
|
|
221
|
+
return;
|
|
222
|
+
case "profile-rejected":
|
|
223
|
+
this.#emitStatus(resource, {
|
|
224
|
+
kind: "received",
|
|
225
|
+
fact: "refused",
|
|
226
|
+
unitIds: [outcome.unitId],
|
|
227
|
+
diagnostic: outcome.message,
|
|
228
|
+
});
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** Batches already reported whose status the session's durability changed (§4.1). */
|
|
233
|
+
async #refreshBatches() {
|
|
234
|
+
for (const ctx of this.#resources.values()) {
|
|
235
|
+
if (ctx.reported.batches.size === 0)
|
|
236
|
+
continue;
|
|
237
|
+
const R = ctx.binding.resourceId;
|
|
238
|
+
const changed = [];
|
|
239
|
+
for (const b of await batchStatuses(this.#o.storage, R, this.#readyDurability, ctx.refusal !== null)) {
|
|
240
|
+
const old = ctx.reported.batches.get(b.operationId);
|
|
241
|
+
if (old !== undefined && old !== b.status)
|
|
242
|
+
changed.push(b.operationId);
|
|
243
|
+
}
|
|
244
|
+
await this.#reportBatches(R, changed);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* LFCP-02-115: the server refuses this client the Resource in this
|
|
249
|
+
* session (AUTHORIZATION_FAILED on RESOURCE_OPEN, the recovery of 106
|
|
250
|
+
* not run or not enough). Null otherwise.
|
|
251
|
+
*/
|
|
252
|
+
#serverRefusal(ctx) {
|
|
253
|
+
if (ctx?.refusal?.code !== "AUTHORIZATION_FAILED")
|
|
254
|
+
return null;
|
|
255
|
+
return Object.freeze({ code: ctx.refusal.code, recovery: ctx.recoveryEnd });
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* After a refusal of the Resource, or an open that clears one: reports
|
|
259
|
+
* access, catch-up, and every batch whose status that changes (blocked
|
|
260
|
+
* while refused, pending again after).
|
|
261
|
+
*/
|
|
262
|
+
async #reportRefusal(ctx) {
|
|
263
|
+
const R = ctx.binding.resourceId;
|
|
264
|
+
await this.#refreshAccess(ctx);
|
|
265
|
+
this.#reportCatchUp(ctx);
|
|
266
|
+
const changed = [];
|
|
267
|
+
for (const b of await batchStatuses(this.#o.storage, R, this.#readyDurability, ctx.refusal !== null)) {
|
|
268
|
+
const old = ctx.reported.batches.get(b.operationId);
|
|
269
|
+
if (old !== b.status && (old !== undefined || b.status === "blocked"))
|
|
270
|
+
changed.push(b.operationId);
|
|
271
|
+
}
|
|
272
|
+
await this.#reportBatches(R, changed);
|
|
273
|
+
}
|
|
274
|
+
/** Reports write access when it changed (§5 `access`). */
|
|
275
|
+
async #refreshAccess(ctx) {
|
|
276
|
+
const R = ctx.binding.resourceId;
|
|
277
|
+
const access = await this.accessState(R);
|
|
278
|
+
const key = accessKey(access);
|
|
279
|
+
if (key === ctx.reported.access)
|
|
280
|
+
return;
|
|
281
|
+
ctx.reported.access = key;
|
|
282
|
+
this.#emitStatus(R, { kind: "access", access });
|
|
283
|
+
}
|
|
284
|
+
/** LFCP-02-028: the catch-up fact of a Resource from its phase. */
|
|
285
|
+
#catchUp(ctx) {
|
|
286
|
+
// Refused by the server in this session: how current we are is unknown.
|
|
287
|
+
if (ctx.refusal !== null)
|
|
288
|
+
return "unknown";
|
|
289
|
+
switch (ctx.state) {
|
|
290
|
+
case "LIVE":
|
|
291
|
+
return "current-at-checkpoint";
|
|
292
|
+
case "OPENING":
|
|
293
|
+
case "CONTROL_SYNC":
|
|
294
|
+
case "KEY_SYNC":
|
|
295
|
+
case "DATA_SYNC":
|
|
296
|
+
return "receiving";
|
|
297
|
+
case "KEY_BLOCKED":
|
|
298
|
+
case "CONTROL_CONFLICT":
|
|
299
|
+
return "unknown";
|
|
300
|
+
case "CLOSED":
|
|
301
|
+
return ctx.liveAt === null ? "not-started" : "current-at-checkpoint";
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/** Reports the catch-up fact when it changed. */
|
|
305
|
+
#reportCatchUp(ctx) {
|
|
306
|
+
const state = this.#catchUp(ctx);
|
|
307
|
+
if (state === ctx.reported.catchUp)
|
|
308
|
+
return;
|
|
309
|
+
ctx.reported.catchUp = state;
|
|
310
|
+
this.#emitStatus(ctx.binding.resourceId, {
|
|
311
|
+
kind: "catch-up",
|
|
312
|
+
state,
|
|
313
|
+
checkedAt: ctx.liveAt,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
/** Reports the section state when it changed (§5 `section-state`). */
|
|
317
|
+
#refreshSection(ctx) {
|
|
318
|
+
const state = ctx.binding.commit?.section?.();
|
|
319
|
+
if (state === undefined || state === ctx.reported.section)
|
|
320
|
+
return;
|
|
321
|
+
ctx.reported.section = state;
|
|
322
|
+
this.#emitStatus(ctx.binding.resourceId, { kind: "section-state", state });
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* §4.1: what an ACK establishes for units of `resource`: acceptance when
|
|
326
|
+
* it answers our message (correlated), says durable at a READY level of
|
|
327
|
+
* 2 or more, and comes from a route of the Resource's current route set.
|
|
328
|
+
*/
|
|
329
|
+
#ackEvidence(resource, durability, correlated) {
|
|
330
|
+
const route = this.#resources.get(toHex(resource))?.view?.state.route;
|
|
331
|
+
const onRoute = route !== undefined &&
|
|
332
|
+
(route.endpoints.some((e) => e.url === this.#o.url) || route.coordinatorUrl === this.#o.url);
|
|
333
|
+
return correlated && onRoute && durability >= ACCEPTANCE_DURABILITY
|
|
334
|
+
? { kind: "accepted" }
|
|
335
|
+
: { kind: "unconfirmed" };
|
|
336
|
+
}
|
|
337
|
+
/** Records `evidence` for units (hex) of `resource` and reports the batches it changed. */
|
|
338
|
+
async #evidence(resource, units, evidence) {
|
|
339
|
+
const { writes, changed, finals } = await recordEvidence(this.#o.storage, resource, units, evidence);
|
|
340
|
+
if (writes.length === 0)
|
|
341
|
+
return changed;
|
|
342
|
+
const r = await this.#o.storage.commit(writes);
|
|
343
|
+
if (!r.ok)
|
|
344
|
+
throw new Error(`the batch status was not stored: ${r.reason}`);
|
|
345
|
+
await this.#reportBatches(resource, changed.keys(), finals);
|
|
346
|
+
return changed;
|
|
347
|
+
}
|
|
108
348
|
#serial(fn) {
|
|
109
349
|
this.#queue = this.#queue
|
|
110
350
|
.then(() => (this.#trapped ? undefined : fn()))
|
|
111
351
|
.catch((e) => {
|
|
352
|
+
// The connection closed while a handler worked: nothing was sent, and
|
|
353
|
+
// its closing (queued here) starts over at the next READY. An
|
|
354
|
+
// ordinary outcome, not an INTERNAL error.
|
|
355
|
+
if (e instanceof ConnectionNotReady)
|
|
356
|
+
return;
|
|
112
357
|
if (!isEngineTrap(e)) {
|
|
113
358
|
this.#emit({
|
|
114
359
|
type: "error",
|
|
@@ -171,6 +416,11 @@ export class SyncClient {
|
|
|
171
416
|
state: "CLOSED",
|
|
172
417
|
wanted: true,
|
|
173
418
|
view: null,
|
|
419
|
+
verified: null,
|
|
420
|
+
serverControlSeq: null,
|
|
421
|
+
liveAt: null,
|
|
422
|
+
lossReason: null,
|
|
423
|
+
reported: { catchUp: null, access: null, section: null, batches: new Map() },
|
|
174
424
|
fetched: [],
|
|
175
425
|
controlTarget: null,
|
|
176
426
|
remoteHave: [],
|
|
@@ -193,12 +443,19 @@ export class SyncClient {
|
|
|
193
443
|
offering: new Set(),
|
|
194
444
|
keysOffered: false,
|
|
195
445
|
rehosted: false,
|
|
446
|
+
recoveryTried: false,
|
|
447
|
+
recoveryAttempts: 0,
|
|
448
|
+
recoveryReopened: false,
|
|
449
|
+
recoveryEnd: null,
|
|
196
450
|
};
|
|
197
451
|
this.#resources.set(key, ctx);
|
|
198
452
|
}
|
|
199
453
|
ctx.wanted = true;
|
|
200
454
|
// An explicit open asks the server again after a refusal.
|
|
455
|
+
const wasRefused = ctx.refusal !== null;
|
|
201
456
|
ctx.refusal = null;
|
|
457
|
+
if (wasRefused)
|
|
458
|
+
this.#serial(() => this.#reportRefusal(ctx));
|
|
202
459
|
ctx.refusedAttempt = 0;
|
|
203
460
|
ctx.reopenAt = null;
|
|
204
461
|
if (this.#connection.state === "READY")
|
|
@@ -261,8 +518,13 @@ export class SyncClient {
|
|
|
261
518
|
ctx.wanted &&
|
|
262
519
|
this.#connection.state === "READY")
|
|
263
520
|
await this.#sendOpen(ctx);
|
|
264
|
-
|
|
521
|
+
// Periodic anti-entropy (§68.1), both planes: the server's answer
|
|
522
|
+
// to CONTROL_HAVE also re-offers Control Records it still lacks,
|
|
523
|
+
// e.g. after a refused or lost re-supply.
|
|
524
|
+
if (ctx.state === "LIVE" && now - ctx.lastHave >= this.#antiEntropyMs) {
|
|
265
525
|
this.#sendHave(ctx, now);
|
|
526
|
+
this.#refreshControl(ctx);
|
|
527
|
+
}
|
|
266
528
|
if (ctx.state === "KEY_BLOCKED" && now - ctx.lastKeyRequest >= this.#antiEntropyMs)
|
|
267
529
|
this.#requestKeys(ctx, now);
|
|
268
530
|
await this.#flush(ctx);
|
|
@@ -277,10 +539,19 @@ export class SyncClient {
|
|
|
277
539
|
// -------------------------------------------------------------------------
|
|
278
540
|
// plumbing
|
|
279
541
|
#request(request, message) {
|
|
542
|
+
try {
|
|
543
|
+
this.#connection.send(message);
|
|
544
|
+
}
|
|
545
|
+
catch (e) {
|
|
546
|
+
// Never waiting for an answer to what was not sent (the connection's
|
|
547
|
+
// closing already failed the requests it saw).
|
|
548
|
+
if (e instanceof ConnectionNotReady && request.kind === "host")
|
|
549
|
+
request.reject(new Error("the connection closed before RESOURCE_HOSTED: not READY"));
|
|
550
|
+
throw e;
|
|
551
|
+
}
|
|
280
552
|
const key = toHex(message.messageId);
|
|
281
553
|
this.#requests.set(key, request);
|
|
282
554
|
this.#sentAt.set(key, this.#o.now());
|
|
283
|
-
this.#connection.send(message);
|
|
284
555
|
}
|
|
285
556
|
/**
|
|
286
557
|
* Requests unanswered for requestTimeoutMs on a live connection (§70: a
|
|
@@ -310,7 +581,9 @@ export class SyncClient {
|
|
|
310
581
|
ctx?.offering.delete(u.id);
|
|
311
582
|
continue;
|
|
312
583
|
}
|
|
313
|
-
if (request.kind === "offer-control" ||
|
|
584
|
+
if (request.kind === "offer-control" ||
|
|
585
|
+
request.kind === "offer-keys" ||
|
|
586
|
+
request.kind === "recover-control")
|
|
314
587
|
continue;
|
|
315
588
|
if ("resource" in request)
|
|
316
589
|
resend.set(`${request.kind}:${request.resource}`, request);
|
|
@@ -355,8 +628,13 @@ export class SyncClient {
|
|
|
355
628
|
if (next === "LIVE") {
|
|
356
629
|
ctx.refusedAttempt = 0;
|
|
357
630
|
ctx.rehosted = false;
|
|
631
|
+
ctx.recoveryTried = false;
|
|
632
|
+
ctx.recoveryAttempts = 0;
|
|
358
633
|
}
|
|
359
634
|
this.#emit({ type: "resource-state", resourceId: ctx.binding.resourceId, state: next });
|
|
635
|
+
if (next === "LIVE")
|
|
636
|
+
ctx.liveAt = this.#o.now();
|
|
637
|
+
this.#reportCatchUp(ctx);
|
|
360
638
|
if (next === "LIVE" && ctx.controlStale) {
|
|
361
639
|
ctx.controlStale = false;
|
|
362
640
|
this.#refreshControl(ctx);
|
|
@@ -383,12 +661,16 @@ export class SyncClient {
|
|
|
383
661
|
durability: ready.durability,
|
|
384
662
|
maxMessageBytes: ready.maxMessageBytes,
|
|
385
663
|
});
|
|
664
|
+
this.#readyDurability = ready.durability;
|
|
665
|
+
await this.#refreshBatches();
|
|
386
666
|
for (const ctx of this.#resources.values())
|
|
387
667
|
if (ctx.wanted)
|
|
388
668
|
await this.#sendOpen(ctx);
|
|
389
669
|
}
|
|
390
670
|
async #onClosed(reason) {
|
|
391
671
|
this.#emit({ type: "connection", state: "DISCONNECTED", reason });
|
|
672
|
+
this.#readyDurability = null;
|
|
673
|
+
await this.#refreshBatches();
|
|
392
674
|
for (const r of this.#requests.values())
|
|
393
675
|
if (r.kind === "host")
|
|
394
676
|
r.reject(new Error(`the connection closed before RESOURCE_HOSTED: ${reason}`));
|
|
@@ -398,6 +680,7 @@ export class SyncClient {
|
|
|
398
680
|
this.#move(ctx, "CLOSE"); // §65, G-SM1: every Resource closes with the connection
|
|
399
681
|
ctx.view = null;
|
|
400
682
|
ctx.offering.clear();
|
|
683
|
+
ctx.recoveryTried = false; // a new session may recover again (LFCP-02-106)
|
|
401
684
|
}
|
|
402
685
|
await this.#o.outbound.connectionLost(iso(this.#o.now()));
|
|
403
686
|
if (this.#stopped)
|
|
@@ -448,6 +731,8 @@ export class SyncClient {
|
|
|
448
731
|
url: this.#o.url,
|
|
449
732
|
outcome: "hosted",
|
|
450
733
|
});
|
|
734
|
+
ctx.lossReason ??= "rehost";
|
|
735
|
+
this.#emitStatus(ctx.binding.resourceId, { kind: "rehost", route: this.#o.url });
|
|
451
736
|
if (ctx.wanted)
|
|
452
737
|
await this.#sendOpen(ctx);
|
|
453
738
|
}
|
|
@@ -460,7 +745,12 @@ export class SyncClient {
|
|
|
460
745
|
ctx.remoteHave = normalizeLiveHaves(m.body.haves);
|
|
461
746
|
ctx.offered = m.body.snapshot ?? null;
|
|
462
747
|
await this.#markHosted(ctx.binding.resourceId);
|
|
748
|
+
if (ctx.recoveryReopened) {
|
|
749
|
+
ctx.recoveryReopened = false;
|
|
750
|
+
this.#recoveryEvent(ctx, "recovered");
|
|
751
|
+
}
|
|
463
752
|
this.#move(ctx, "OPENED");
|
|
753
|
+
await this.#noteServerHeads(ctx, m.body.heads);
|
|
464
754
|
// §68.1, §88 step 6: what the server lacks goes first, Control Records before units.
|
|
465
755
|
await this.#offerControl(ctx, m.body.heads);
|
|
466
756
|
await this.#offerData(ctx);
|
|
@@ -470,6 +760,8 @@ export class SyncClient {
|
|
|
470
760
|
case "CONTROL_HAVE": {
|
|
471
761
|
const ctx = this.#ctx(m.body.resourceId);
|
|
472
762
|
this.#done(m);
|
|
763
|
+
if (ctx !== undefined)
|
|
764
|
+
await this.#noteServerHeads(ctx, m.body.heads);
|
|
473
765
|
if (ctx !== undefined && ctx.state === "LIVE")
|
|
474
766
|
await this.#onControlHeads(ctx, m.body.heads);
|
|
475
767
|
return;
|
|
@@ -522,11 +814,25 @@ export class SyncClient {
|
|
|
522
814
|
return;
|
|
523
815
|
}
|
|
524
816
|
case "ACK":
|
|
817
|
+
if (request?.kind === "recover-control") {
|
|
818
|
+
this.#done(m);
|
|
819
|
+
await this.#recoveryAccepted(request);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
525
822
|
if (request !== undefined) {
|
|
526
823
|
this.#done(m);
|
|
527
|
-
if (request.kind === "offer-data")
|
|
824
|
+
if (request.kind === "offer-data") {
|
|
825
|
+
const ctx = this.#resources.get(request.resource);
|
|
528
826
|
for (const u of request.units)
|
|
529
|
-
|
|
827
|
+
ctx?.offering.delete(u.id);
|
|
828
|
+
if (ctx !== undefined) {
|
|
829
|
+
// §4.2: a re-offered unit of ours is accepted again by this ACK.
|
|
830
|
+
const named = new Set((m.body.objectIds ?? []).map((id) => toHex(id)));
|
|
831
|
+
const durability = m.body.durable === true ? (this.#readyDurability ?? 0n) : 0n;
|
|
832
|
+
const R = ctx.binding.resourceId;
|
|
833
|
+
await this.#evidence(R, request.units.map((u) => u.id).filter((id) => named.has(id)), this.#ackEvidence(R, durability, true));
|
|
834
|
+
}
|
|
835
|
+
}
|
|
530
836
|
return;
|
|
531
837
|
}
|
|
532
838
|
await this.#onAck(m);
|
|
@@ -565,6 +871,20 @@ export class SyncClient {
|
|
|
565
871
|
if (ctx !== undefined && (await this.#rehost(ctx)))
|
|
566
872
|
return;
|
|
567
873
|
}
|
|
874
|
+
if (request.kind === "open" && code === "AUTHORIZATION_FAILED") {
|
|
875
|
+
const ctx = this.#resources.get(request.resource);
|
|
876
|
+
if (ctx?.recoveryReopened) {
|
|
877
|
+
// The server holds our chain and still refuses: not a loss.
|
|
878
|
+
ctx.recoveryReopened = false;
|
|
879
|
+
this.#recoveryEvent(ctx, "ended", "still-refused");
|
|
880
|
+
}
|
|
881
|
+
else if (ctx !== undefined && (await this.#recoverAccess(ctx)))
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
if (request.kind === "recover-control") {
|
|
885
|
+
await this.#recoveryRefused(request, code, m.body.details);
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
568
888
|
if (request.kind === "rehost") {
|
|
569
889
|
const ctx = this.#resources.get(request.resource);
|
|
570
890
|
if (ctx === undefined)
|
|
@@ -627,6 +947,8 @@ export class SyncClient {
|
|
|
627
947
|
this.#request({ kind: "close", resource: toHex(ctx.binding.resourceId) }, createMessage("RESOURCE_CLOSE", { resourceId: ctx.binding.resourceId }));
|
|
628
948
|
this.#move(ctx, "CLOSE");
|
|
629
949
|
this.#emit({ type: "resource-refused", resourceId: ctx.binding.resourceId, refusal });
|
|
950
|
+
// LFCP-02-115: the status stream says so too.
|
|
951
|
+
this.#serial(() => this.#reportRefusal(ctx));
|
|
630
952
|
}
|
|
631
953
|
/** CLOSED now, opened again after the ReconnectPolicy's delay for this Resource's attempt. */
|
|
632
954
|
#retryLater(ctx) {
|
|
@@ -736,9 +1058,11 @@ export class SyncClient {
|
|
|
736
1058
|
const R = ctx.binding.resourceId;
|
|
737
1059
|
const before = previous ?? ctx.view;
|
|
738
1060
|
ctx.view = chain;
|
|
1061
|
+
ctx.verified = { head: chain.state.head, at: this.#o.now() };
|
|
739
1062
|
// DEKs we already hold for epochs the saved chain now has (our own
|
|
740
1063
|
// rotation, or a package that came before the row): no request needed.
|
|
741
1064
|
await adoptStoredDeks(this.#o.storage, this.#o.secrets, chain);
|
|
1065
|
+
await this.#refreshAccess(ctx);
|
|
742
1066
|
const epochsChanged = before === null ||
|
|
743
1067
|
[...chain.state.epochs.values()].some((e) => {
|
|
744
1068
|
const old = before.state.epochs.get(String(e.epoch));
|
|
@@ -755,6 +1079,12 @@ export class SyncClient {
|
|
|
755
1079
|
}
|
|
756
1080
|
}
|
|
757
1081
|
await this.#o.outbound.controlSynced(R);
|
|
1082
|
+
// §47, §21: on the fresh head, re-propose our own Control Records that a
|
|
1083
|
+
// moved head refused (CONTROL_HEAD_MISMATCH) and recover any persisted
|
|
1084
|
+
// "repropose" items (a restart, or an older SDK). Runs here, under the
|
|
1085
|
+
// serial lock, on every control sync — so it covers both the start of an
|
|
1086
|
+
// open and a sync triggered by a repropose NACK; it is idempotent.
|
|
1087
|
+
await this.#reproposeControl(ctx);
|
|
758
1088
|
if (ctx.state === "CONTROL_SYNC") {
|
|
759
1089
|
this.#move(ctx, "CONTROL_COMPLETE");
|
|
760
1090
|
this.#requestKeys(ctx, this.#o.now());
|
|
@@ -763,6 +1093,29 @@ export class SyncClient {
|
|
|
763
1093
|
this.#requestKeys(ctx, this.#o.now());
|
|
764
1094
|
}
|
|
765
1095
|
}
|
|
1096
|
+
/**
|
|
1097
|
+
* Re-proposes this client's blocked "repropose" Control Records of `ctx`'s
|
|
1098
|
+
* Resource on the current local head, re-sealing their dependent Key
|
|
1099
|
+
* Packages, and flushes the re-authored objects. Bounded and idempotent
|
|
1100
|
+
* (repropose.ts); a dropped record (no longer authorized, or a dependent's
|
|
1101
|
+
* key is not held) is surfaced so the application can act.
|
|
1102
|
+
*/
|
|
1103
|
+
async #reproposeControl(ctx) {
|
|
1104
|
+
const R = ctx.binding.resourceId;
|
|
1105
|
+
const { outcomes } = await reproposeControlRecords({
|
|
1106
|
+
storage: this.#o.storage,
|
|
1107
|
+
secrets: this.#o.secrets,
|
|
1108
|
+
signer: this.#o.signer,
|
|
1109
|
+
resource: R,
|
|
1110
|
+
queue: this.#o.outbound,
|
|
1111
|
+
attempts: this.#reproposeAttempts,
|
|
1112
|
+
});
|
|
1113
|
+
if (outcomes.length === 0)
|
|
1114
|
+
return;
|
|
1115
|
+
this.#emit({ type: "repropose", resourceId: R, outcomes });
|
|
1116
|
+
if (outcomes.some((o) => o.kind === "reproposed"))
|
|
1117
|
+
await this.#flush(ctx);
|
|
1118
|
+
}
|
|
766
1119
|
// -------------------------------------------------------------------------
|
|
767
1120
|
// Keys (§52, §53, §66 step 2)
|
|
768
1121
|
async #epochsWithoutDek(ctx) {
|
|
@@ -840,6 +1193,7 @@ export class SyncClient {
|
|
|
840
1193
|
}
|
|
841
1194
|
await this.#o.storage.commit(writes);
|
|
842
1195
|
}
|
|
1196
|
+
await this.#refreshAccess(ctx);
|
|
843
1197
|
const missing = await this.#epochsWithoutDek(ctx);
|
|
844
1198
|
const current = view.state.epoch.epoch;
|
|
845
1199
|
if (missing.some((e) => e === current)) {
|
|
@@ -988,6 +1342,247 @@ export class SyncClient {
|
|
|
988
1342
|
* that loads it never skips content the state does not hold. Queued and
|
|
989
1343
|
* sent like any object; returns its ID.
|
|
990
1344
|
*/
|
|
1345
|
+
/**
|
|
1346
|
+
* LFCP-02-060: removes `subject`'s access to `resource`. Every active
|
|
1347
|
+
* grant naming it that this client may revoke is revoked (§17.3); when it
|
|
1348
|
+
* has no read access left, the Data Epoch is rotated with a fresh DEK
|
|
1349
|
+
* (§19, member revoked) and the DEK goes in Key Packages to every other
|
|
1350
|
+
* remaining reader (§25), so it cannot read new data. Built on the
|
|
1351
|
+
* validated head and checked before anything is queued; the records, the
|
|
1352
|
+
* Key Packages and (first) the new DEK are written at once, then sent.
|
|
1353
|
+
* Refused, with nothing queued, unless the Resource is live and current
|
|
1354
|
+
* here and no Control Record of this client is pending.
|
|
1355
|
+
*/
|
|
1356
|
+
revokeAccess(resource, subject, options = {}) {
|
|
1357
|
+
return new Promise((resolve, reject) => this.#serial(async () => {
|
|
1358
|
+
try {
|
|
1359
|
+
resolve(await this.#revoke(resource, subject, options));
|
|
1360
|
+
}
|
|
1361
|
+
catch (e) {
|
|
1362
|
+
reject(e);
|
|
1363
|
+
}
|
|
1364
|
+
}));
|
|
1365
|
+
}
|
|
1366
|
+
async #revoke(resource, subject, options) {
|
|
1367
|
+
const refused = (reason, message) => Object.freeze({ kind: "refused", reason, message });
|
|
1368
|
+
const ctx = this.#resources.get(toHex(resource));
|
|
1369
|
+
const view = ctx?.view ?? null;
|
|
1370
|
+
if (ctx === undefined || ctx.state !== "LIVE" || view === null)
|
|
1371
|
+
return refused("offline", "the Resource is not live in this session");
|
|
1372
|
+
if (ctx.serverControlSeq !== null && view.state.seq < ctx.serverControlSeq)
|
|
1373
|
+
return refused("stale", "the validated Control Chain is behind the server's");
|
|
1374
|
+
const storage = this.#o.storage;
|
|
1375
|
+
if ((await storage.outbound.list(resource)).some((i) => i.kind === "control-record"))
|
|
1376
|
+
return refused("control-pending", "Control Records of this client are not committed yet");
|
|
1377
|
+
let plan;
|
|
1378
|
+
try {
|
|
1379
|
+
plan = await planRevocation({
|
|
1380
|
+
view,
|
|
1381
|
+
revoker: this.#o.signer,
|
|
1382
|
+
subject,
|
|
1383
|
+
frontier: (await resourceSyncState(storage, resource)).have,
|
|
1384
|
+
...(options.rotate === undefined ? {} : { rotate: options.rotate }),
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
catch (e) {
|
|
1388
|
+
if (e instanceof RevocationRefused)
|
|
1389
|
+
return refused(e.reason, e.message);
|
|
1390
|
+
throw e;
|
|
1391
|
+
}
|
|
1392
|
+
// The new DEK first (LFCP-034), then everything that names it, at once.
|
|
1393
|
+
if (plan.rotation !== null)
|
|
1394
|
+
await this.#o.secrets.put(dekSecretRef(resource, plan.rotation.epoch), exportSecretKeyBytes(plan.rotation.dek));
|
|
1395
|
+
const r = await storage.commit([
|
|
1396
|
+
...plan.records.flatMap((rec) => controlRecordWrites(rec.bytes)),
|
|
1397
|
+
...plan.keyPackages.flatMap(keyPackageWrites),
|
|
1398
|
+
]);
|
|
1399
|
+
if (!r.ok)
|
|
1400
|
+
throw new Error(`the revocation was not queued: ${r.reason}`);
|
|
1401
|
+
await this.#refreshAccess(ctx);
|
|
1402
|
+
await this.#flush(ctx);
|
|
1403
|
+
return Object.freeze({
|
|
1404
|
+
kind: "queued",
|
|
1405
|
+
revoked: plan.revoked,
|
|
1406
|
+
deactivated: plan.deactivated,
|
|
1407
|
+
recordIds: Object.freeze(plan.records.map((rec) => rec.recordId)),
|
|
1408
|
+
epoch: plan.rotation?.epoch ?? null,
|
|
1409
|
+
recipients: plan.recipients,
|
|
1410
|
+
remainingPaths: plan.remainingPaths,
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
/**
|
|
1414
|
+
* SDK-SECTIONS-INTEGRATION-01 §3.1: commits a batch of intents on an open
|
|
1415
|
+
* Resource and returns its receipt once the batch is durable: its Data
|
|
1416
|
+
* Units, outbound entries, the profile checkpoint and the receipt are
|
|
1417
|
+
* stored in one transaction, then the batch is sent. An operation that
|
|
1418
|
+
* has a receipt returns it for the same intents and writes nothing, and
|
|
1419
|
+
* throws OperationIdReusedError for different ones (§3.3). Runs between
|
|
1420
|
+
* received units, never during their merge.
|
|
1421
|
+
*/
|
|
1422
|
+
commit(resource, intents, options) {
|
|
1423
|
+
const ctx = this.#resources.get(toHex(resource));
|
|
1424
|
+
if (ctx === undefined)
|
|
1425
|
+
return Promise.reject(new LfcpError("UNSUPPORTED_VALUE", "the Resource is not open here"));
|
|
1426
|
+
const binding = ctx.binding.commit;
|
|
1427
|
+
if (binding === undefined)
|
|
1428
|
+
return Promise.reject(new LfcpError("UNSUPPORTED_VALUE", "the Resource has no commit binding"));
|
|
1429
|
+
return new Promise((resolve, reject) => this.#serial(async () => {
|
|
1430
|
+
try {
|
|
1431
|
+
resolve(await this.#commit(ctx, binding, intents, options.operationId));
|
|
1432
|
+
}
|
|
1433
|
+
catch (e) {
|
|
1434
|
+
reject(e);
|
|
1435
|
+
}
|
|
1436
|
+
}));
|
|
1437
|
+
}
|
|
1438
|
+
/**
|
|
1439
|
+
* SDK-SECTIONS-INTEGRATION-01 §6: whether this client may commit to
|
|
1440
|
+
* `resource`, from the validated Control state only: the chain of the
|
|
1441
|
+
* last Control sync, or the stored chain validated now. `verifiedAt` is
|
|
1442
|
+
* the local time of that validation (the last sync's when the stored
|
|
1443
|
+
* chain is still at its head). Unknown without a validated chain.
|
|
1444
|
+
*/
|
|
1445
|
+
async canWrite(resource) {
|
|
1446
|
+
const ctx = this.#resources.get(toHex(resource));
|
|
1447
|
+
const view = ctx?.view ?? (await loadControlChain(this.#o.storage, resource));
|
|
1448
|
+
return this.#access(resource, view, ctx?.verified ?? null, this.#serverRefusal(ctx));
|
|
1449
|
+
}
|
|
1450
|
+
/**
|
|
1451
|
+
* LFCP-02-027: write access with its evidence: the Control sequence it
|
|
1452
|
+
* was validated at against the highest one a server reported in this
|
|
1453
|
+
* session (a stale view is not current), the effective abilities and
|
|
1454
|
+
* the active grant paths giving them, and apart from them the
|
|
1455
|
+
* invitations we issued, our Control Records not yet committed (SI14)
|
|
1456
|
+
* and an unsettled invitation claim of ours.
|
|
1457
|
+
*/
|
|
1458
|
+
async accessState(resource) {
|
|
1459
|
+
const ctx = this.#resources.get(toHex(resource));
|
|
1460
|
+
const view = ctx?.view ?? (await loadControlChain(this.#o.storage, resource));
|
|
1461
|
+
const refusal = this.#serverRefusal(ctx);
|
|
1462
|
+
const write = await this.#access(resource, view, ctx?.verified ?? null, refusal);
|
|
1463
|
+
const pendingControl = [];
|
|
1464
|
+
for (const item of await this.#o.storage.outbound.list(resource)) {
|
|
1465
|
+
if (item.kind !== "control-record")
|
|
1466
|
+
continue;
|
|
1467
|
+
const record = decodeControlRecord(item.bytes);
|
|
1468
|
+
pendingControl.push(Object.freeze({ recordId: controlRecordId(record.signed.id), type: record.body.type }));
|
|
1469
|
+
}
|
|
1470
|
+
const dek = view?.kind === "linear" &&
|
|
1471
|
+
(await this.#currentDek(resource, view.state.epoch.epoch)) !== undefined;
|
|
1472
|
+
return accessState(view, this.#o.signer.descriptor.principalId, dek, write.verifiedAt ?? 0, {
|
|
1473
|
+
serverControlSeq: ctx?.serverControlSeq ?? null,
|
|
1474
|
+
pendingControl,
|
|
1475
|
+
pendingClaim: (await readClaimJournal(this.#o.storage, resource)) !== undefined,
|
|
1476
|
+
serverRefusal: refusal,
|
|
1477
|
+
});
|
|
1478
|
+
}
|
|
1479
|
+
/** Records the highest Control sequence a server reported, and reports access if it changed. */
|
|
1480
|
+
async #noteServerHeads(ctx, heads) {
|
|
1481
|
+
const seq = heads.reduce((m, h) => (m === null || h.seq > m ? h.seq : m), null);
|
|
1482
|
+
if (seq === null || (ctx.serverControlSeq !== null && seq <= ctx.serverControlSeq))
|
|
1483
|
+
return;
|
|
1484
|
+
ctx.serverControlSeq = seq;
|
|
1485
|
+
await this.#refreshAccess(ctx);
|
|
1486
|
+
}
|
|
1487
|
+
/**
|
|
1488
|
+
* The DEK of `epoch`, or undefined when it is not held or the key store
|
|
1489
|
+
* cannot be read now (LFCP-02-030): an unreadable key store is
|
|
1490
|
+
* key-unavailable, never a reason to write without it or to reset state.
|
|
1491
|
+
*/
|
|
1492
|
+
async #currentDek(resource, epoch) {
|
|
1493
|
+
try {
|
|
1494
|
+
return await dekResolver(this.#o.storage, this.#o.secrets, resource)(epoch);
|
|
1495
|
+
}
|
|
1496
|
+
catch {
|
|
1497
|
+
return undefined;
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
async #access(resource, view, verified, refusal = null) {
|
|
1501
|
+
if (view?.kind !== "linear")
|
|
1502
|
+
return writeAccess(view, this.#o.signer.descriptor.principalId, false, 0);
|
|
1503
|
+
const dek = await this.#currentDek(resource, view.state.epoch.epoch);
|
|
1504
|
+
const at = verified !== null && bytesEqual(verified.head, view.state.head) ? verified.at : this.#o.now();
|
|
1505
|
+
const access = writeAccess(view, this.#o.signer.descriptor.principalId, dek !== undefined, at);
|
|
1506
|
+
// LFCP-02-115: the server refuses us the Resource; the local chain is not the last word.
|
|
1507
|
+
return refusal === null
|
|
1508
|
+
? access
|
|
1509
|
+
: Object.freeze({ ...access, allowed: false, reason: "server-refused" });
|
|
1510
|
+
}
|
|
1511
|
+
/**
|
|
1512
|
+
* SHARED-SECTIONS-PROFILE-01 §13, in depth behind the adapter's own
|
|
1513
|
+
* check: an incomplete load never becomes a new, empty section. A batch
|
|
1514
|
+
* that creates a section is refused (SECTION_EXISTS) when units of the
|
|
1515
|
+
* Resource are stored (content exists that the model may not hold yet),
|
|
1516
|
+
* or when this client is not the Resource's owner and the Resource was
|
|
1517
|
+
* not live in this session (it has not caught up). The owner creates its
|
|
1518
|
+
* new Resource's section offline.
|
|
1519
|
+
*/
|
|
1520
|
+
async #guardCreate(ctx, view, intents) {
|
|
1521
|
+
const index = intents.findIndex((i) => typeof i === "object" &&
|
|
1522
|
+
i !== null &&
|
|
1523
|
+
i.intent === "section.create");
|
|
1524
|
+
if (index < 0)
|
|
1525
|
+
return;
|
|
1526
|
+
const R = ctx.binding.resourceId;
|
|
1527
|
+
const stored = (await Promise.all(STORED_STATUSES.map((st) => this.#o.storage.dataUnits.withStatus(R, st)))).some((units) => units.length > 0);
|
|
1528
|
+
if (stored)
|
|
1529
|
+
throw new CommitRefusedError("SECTION_EXISTS", "units of this Resource are stored: it is not a new section (§13)", index);
|
|
1530
|
+
const owner = bytesEqual(view.state.owner.principalId, this.#o.signer.descriptor.principalId);
|
|
1531
|
+
if (!owner && ctx.liveAt === null)
|
|
1532
|
+
throw new CommitRefusedError("SECTION_EXISTS", "the Resource has not caught up in this session: its section may exist (§13)", index);
|
|
1533
|
+
}
|
|
1534
|
+
async #commit(ctx, binding, intents, operationId) {
|
|
1535
|
+
const R = ctx.binding.resourceId;
|
|
1536
|
+
const storage = this.#o.storage;
|
|
1537
|
+
// §3.3: a committed operation answers first; its intents may no longer apply.
|
|
1538
|
+
const known = await receiptOf(storage, R, operationId);
|
|
1539
|
+
if (known !== undefined) {
|
|
1540
|
+
if (known.intentsHash !== intentsHash(intents))
|
|
1541
|
+
throw new OperationIdReusedError(operationId);
|
|
1542
|
+
return known;
|
|
1543
|
+
}
|
|
1544
|
+
const view = ctx.view ?? (await loadControlChain(storage, R));
|
|
1545
|
+
// §3.6, §6: a batch submitted while writing is not allowed writes nothing.
|
|
1546
|
+
const access = await this.#access(R, view, ctx.verified, this.#serverRefusal(ctx));
|
|
1547
|
+
if (!access.allowed || view?.kind !== "linear")
|
|
1548
|
+
throw new NotWritableError(access);
|
|
1549
|
+
const dek = await this.#currentDek(R, view.state.epoch.epoch);
|
|
1550
|
+
if (dek === undefined)
|
|
1551
|
+
throw new NotWritableError({ ...access, allowed: false, reason: "key-unavailable" });
|
|
1552
|
+
await this.#guardCreate(ctx, view, intents);
|
|
1553
|
+
const staged = binding.stage(intents);
|
|
1554
|
+
staged?.apply();
|
|
1555
|
+
try {
|
|
1556
|
+
const { receipt, committed } = await commitOperation(storage, {
|
|
1557
|
+
view,
|
|
1558
|
+
controlHead: view.state.head,
|
|
1559
|
+
actor: this.#o.signer,
|
|
1560
|
+
dek,
|
|
1561
|
+
profile: binding.codec,
|
|
1562
|
+
operationId,
|
|
1563
|
+
intents,
|
|
1564
|
+
values: staged?.values ?? [],
|
|
1565
|
+
affectedNodeIds: staged?.affectedNodeIds ?? [],
|
|
1566
|
+
modelRevision: staged?.modelRevision ?? "",
|
|
1567
|
+
}, (units) => staged?.writes(units) ?? []);
|
|
1568
|
+
if (!committed)
|
|
1569
|
+
staged?.revert();
|
|
1570
|
+
else
|
|
1571
|
+
staged?.committed(receipt.unitIds);
|
|
1572
|
+
if (committed) {
|
|
1573
|
+
// Our own units grow the history a Snapshot covers, as received ones do.
|
|
1574
|
+
ctx.unitsSinceSnapshot += receipt.unitIds.length;
|
|
1575
|
+
await this.#reportBatches(R, [receipt.operationId]);
|
|
1576
|
+
this.#refreshSection(ctx);
|
|
1577
|
+
}
|
|
1578
|
+
await this.#flush(ctx);
|
|
1579
|
+
return receipt;
|
|
1580
|
+
}
|
|
1581
|
+
catch (e) {
|
|
1582
|
+
staged?.revert();
|
|
1583
|
+
throw e;
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
991
1586
|
async publishSnapshot(resource) {
|
|
992
1587
|
const ctx = this.#resources.get(toHex(resource));
|
|
993
1588
|
if (ctx === undefined)
|
|
@@ -1142,16 +1737,20 @@ export class SyncClient {
|
|
|
1142
1737
|
if (outcome.kind === "rejected" && outcome.wireCode === "MISSING_DEPENDENCY")
|
|
1143
1738
|
needControl = true;
|
|
1144
1739
|
this.#emit({ type: "unit", resourceId: R, outcome });
|
|
1740
|
+
this.#reportReceived(R, outcome);
|
|
1145
1741
|
// Held units this one released (§26.2): their outcomes too.
|
|
1146
1742
|
const released = "released" in outcome ? outcome.released : [];
|
|
1147
1743
|
for (const r of released) {
|
|
1148
1744
|
if (r.kind === "applied" || r.kind === "profile-pending")
|
|
1149
1745
|
changed = true;
|
|
1150
1746
|
this.#emit({ type: "unit", resourceId: R, outcome: r });
|
|
1747
|
+
this.#reportReceived(R, r);
|
|
1151
1748
|
}
|
|
1152
1749
|
}
|
|
1153
|
-
if (changed)
|
|
1750
|
+
if (changed) {
|
|
1154
1751
|
ctx.binding.checkpointer?.noteChange();
|
|
1752
|
+
this.#refreshSection(ctx);
|
|
1753
|
+
}
|
|
1155
1754
|
if (needControl)
|
|
1156
1755
|
this.#refreshControl(ctx);
|
|
1157
1756
|
if (ctx.state === "DATA_SYNC" &&
|
|
@@ -1189,23 +1788,119 @@ export class SyncClient {
|
|
|
1189
1788
|
this.#connection.sendEncoded(m.bytes);
|
|
1190
1789
|
}
|
|
1191
1790
|
async #onAck(m) {
|
|
1192
|
-
|
|
1791
|
+
if (m.body.requestType === MESSAGE_TYPE.CONTROL_PUT)
|
|
1792
|
+
await this.#adoptAcked(m);
|
|
1793
|
+
// §4.1: acceptance is recorded in the dequeue's transaction.
|
|
1794
|
+
const changed = [];
|
|
1795
|
+
const outcome = await this.#o.outbound.onAck(m, iso(this.#o.now()), async (items, ack) => {
|
|
1796
|
+
const byResource = new Map();
|
|
1797
|
+
for (const item of items) {
|
|
1798
|
+
if (item.kind !== "data-unit")
|
|
1799
|
+
continue;
|
|
1800
|
+
const key = toHex(item.resourceId);
|
|
1801
|
+
const entry = byResource.get(key) ?? { R: item.resourceId, units: [] };
|
|
1802
|
+
entry.units.push(toHex(item.itemId));
|
|
1803
|
+
byResource.set(key, entry);
|
|
1804
|
+
}
|
|
1805
|
+
const writes = [];
|
|
1806
|
+
for (const { R, units } of byResource.values()) {
|
|
1807
|
+
const evidence = this.#ackEvidence(R, ack.durability, ack.correlated);
|
|
1808
|
+
const r = await recordEvidence(this.#o.storage, R, units, evidence);
|
|
1809
|
+
writes.push(...r.writes);
|
|
1810
|
+
if (r.changed.size > 0)
|
|
1811
|
+
changed.push([R, r.changed, r.finals]);
|
|
1812
|
+
}
|
|
1813
|
+
return writes;
|
|
1814
|
+
});
|
|
1815
|
+
for (const [R, ops, finals] of changed)
|
|
1816
|
+
await this.#reportBatches(R, ops.keys(), finals);
|
|
1193
1817
|
this.#emit({ type: "ack", outcome });
|
|
1194
1818
|
// Our own Control Record was committed: catch up, since the server does not push it back to us.
|
|
1195
|
-
if (m.body.requestType === MESSAGE_TYPE.CONTROL_PUT && outcome.acked.length > 0)
|
|
1819
|
+
if (m.body.requestType === MESSAGE_TYPE.CONTROL_PUT && outcome.acked.length > 0) {
|
|
1820
|
+
// A committed record is settled: clear its bounded re-proposal count.
|
|
1821
|
+
for (const id of outcome.acked)
|
|
1822
|
+
this.#reproposeAttempts.delete(toHex(id));
|
|
1196
1823
|
for (const ctx of this.#resources.values())
|
|
1197
1824
|
if (ctx.state === "LIVE")
|
|
1198
1825
|
this.#refreshControl(ctx);
|
|
1199
1826
|
else
|
|
1200
1827
|
ctx.controlStale = true;
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
/**
|
|
1831
|
+
* An ACK of our CONTROL_PUT (§47) means the server committed the record
|
|
1832
|
+
* on the head it expected: our own previous record. The record joins the
|
|
1833
|
+
* stored chain the way a fetched one does, BEFORE it leaves the queue, so
|
|
1834
|
+
* it is never held by the server alone: a stop between the ACK and the
|
|
1835
|
+
* catch-up fetch, then a server restored from an older copy, would
|
|
1836
|
+
* otherwise lose it (LFCP-02-106). A crash before the dequeue only sends
|
|
1837
|
+
* it again, and a repeated ACK is idempotent. A record that does not
|
|
1838
|
+
* continue the stored chain is left to the catch-up fetch.
|
|
1839
|
+
*/
|
|
1840
|
+
async #adoptAcked(m) {
|
|
1841
|
+
for (const id of m.body.objectIds ?? []) {
|
|
1842
|
+
const item = await this.#o.storage.outbound.get(id);
|
|
1843
|
+
if (item?.kind !== "control-record")
|
|
1844
|
+
continue;
|
|
1845
|
+
const ctx = this.#ctx(item.resourceId);
|
|
1846
|
+
if (ctx === undefined)
|
|
1847
|
+
continue;
|
|
1848
|
+
await this.#onControlRecords(ctx, [item.bytes], false);
|
|
1849
|
+
}
|
|
1201
1850
|
}
|
|
1202
1851
|
async #onNack(m) {
|
|
1203
1852
|
const outcome = await this.#o.outbound.onNack(m, iso(this.#o.now()));
|
|
1853
|
+
// §4.1: a terminal NACK rejects the batch of the unit.
|
|
1854
|
+
if (outcome.kind === "rejected" ||
|
|
1855
|
+
outcome.kind === "stale" ||
|
|
1856
|
+
outcome.kind === "equivocation-alarm") {
|
|
1857
|
+
const code = outcome.kind === "rejected"
|
|
1858
|
+
? outcome.code
|
|
1859
|
+
: outcome.kind === "stale"
|
|
1860
|
+
? "STALE_DATA_EPOCH"
|
|
1861
|
+
: "ACTOR_EQUIVOCATION";
|
|
1862
|
+
const byResource = new Map();
|
|
1863
|
+
for (const item of outcome.items) {
|
|
1864
|
+
if (item.kind !== "data-unit")
|
|
1865
|
+
continue;
|
|
1866
|
+
const R = (await this.#o.storage.outbound.get(item.itemId))?.resourceId;
|
|
1867
|
+
if (R === undefined)
|
|
1868
|
+
continue;
|
|
1869
|
+
const entry = byResource.get(toHex(R)) ?? { R, units: [] };
|
|
1870
|
+
entry.units.push(toHex(item.itemId));
|
|
1871
|
+
byResource.set(toHex(R), entry);
|
|
1872
|
+
}
|
|
1873
|
+
for (const { R, units } of byResource.values())
|
|
1874
|
+
await this.#evidence(R, units, { kind: "rejected", code });
|
|
1875
|
+
}
|
|
1876
|
+
if (outcome.kind === "needs-offer") {
|
|
1877
|
+
const ctx = this.#ctx(outcome.resourceId);
|
|
1878
|
+
if (ctx !== undefined)
|
|
1879
|
+
ctx.lossReason ??= "unknown-previous";
|
|
1880
|
+
}
|
|
1204
1881
|
this.#emit({ type: "nack", outcome });
|
|
1882
|
+
// MISSING_DEPENDENCY: the server lacks our Control records; a sync releases the held puts.
|
|
1205
1883
|
if (outcome.kind === "needs-control-sync")
|
|
1206
1884
|
for (const ctx of this.#resources.values())
|
|
1207
1885
|
if (ctx.state === "LIVE")
|
|
1208
1886
|
this.#refreshControl(ctx);
|
|
1887
|
+
// CONTROL_HEAD_MISMATCH: re-author the refused record on the current head
|
|
1888
|
+
// (§47, §21). When we already hold that head (e.g. the winning record was
|
|
1889
|
+
// pushed to us live, §65), a CONTROL_GET would return nothing and never
|
|
1890
|
+
// reach #controlComplete, so re-propose here directly; otherwise sync
|
|
1891
|
+
// first and #controlComplete drives it.
|
|
1892
|
+
if (outcome.kind === "repropose")
|
|
1893
|
+
for (const ctx of this.#resources.values()) {
|
|
1894
|
+
if (ctx.state !== "LIVE")
|
|
1895
|
+
continue;
|
|
1896
|
+
const head = await this.#o.storage.control.head(ctx.binding.resourceId);
|
|
1897
|
+
if (outcome.currentHead !== null &&
|
|
1898
|
+
head !== undefined &&
|
|
1899
|
+
bytesEqual(head.head, outcome.currentHead))
|
|
1900
|
+
await this.#reproposeControl(ctx);
|
|
1901
|
+
else
|
|
1902
|
+
this.#refreshControl(ctx);
|
|
1903
|
+
}
|
|
1209
1904
|
// §51.1: the server lost what our unit names. Its DATA_HAVE answer
|
|
1210
1905
|
// starts the offer (§68.1), which releases the held items.
|
|
1211
1906
|
if (outcome.kind === "needs-offer") {
|
|
@@ -1248,6 +1943,94 @@ export class SyncClient {
|
|
|
1248
1943
|
this.#request({ kind: "rehost", resource: toHex(R) }, createMessage("RESOURCE_HOST", { genesis: genesis.signed.bytes }));
|
|
1249
1944
|
return true;
|
|
1250
1945
|
}
|
|
1946
|
+
/**
|
|
1947
|
+
* LFCP-02-106: RESOURCE_OPEN was refused with AUTHORIZATION_FAILED. When
|
|
1948
|
+
* our validated chain grants us data/read, the server may have lost the
|
|
1949
|
+
* records that do (a restore): push our head, and follow the server's
|
|
1950
|
+
* answer (see access-recovery.ts). False when no recovery runs; the
|
|
1951
|
+
* refusal then stands as before.
|
|
1952
|
+
*/
|
|
1953
|
+
async #recoverAccess(ctx) {
|
|
1954
|
+
if (ctx.recoveryTried || this.#connection.state !== "READY")
|
|
1955
|
+
return false;
|
|
1956
|
+
const chain = await loadControlChain(this.#o.storage, ctx.binding.resourceId);
|
|
1957
|
+
if (chain?.kind !== "linear")
|
|
1958
|
+
return false;
|
|
1959
|
+
const step = startRecovery(chain.state, chain.records, this.#o.signer.descriptor.principalId);
|
|
1960
|
+
ctx.recoveryTried = true;
|
|
1961
|
+
if (step.kind !== "push") {
|
|
1962
|
+
this.#recoveryEvent(ctx, "ended", step.kind === "final" ? step.reason : undefined);
|
|
1963
|
+
return false;
|
|
1964
|
+
}
|
|
1965
|
+
this.#recoveryEvent(ctx, "started");
|
|
1966
|
+
this.#move(ctx, "CLOSE");
|
|
1967
|
+
await this.#recoveryStep(ctx, step);
|
|
1968
|
+
return true;
|
|
1969
|
+
}
|
|
1970
|
+
async #recoveryStep(ctx, step) {
|
|
1971
|
+
const R = ctx.binding.resourceId;
|
|
1972
|
+
if (step.kind === "reopen") {
|
|
1973
|
+
ctx.recoveryReopened = true;
|
|
1974
|
+
if (ctx.wanted)
|
|
1975
|
+
await this.#sendOpen(ctx);
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
if (step.kind === "final") {
|
|
1979
|
+
this.#recoveryEvent(ctx, "ended", step.reason);
|
|
1980
|
+
this.#refuse(ctx, { code: "AUTHORIZATION_FAILED", url: this.#o.url, request: "open" });
|
|
1981
|
+
return;
|
|
1982
|
+
}
|
|
1983
|
+
if (this.#connection.state !== "READY")
|
|
1984
|
+
return; // the next session opens and may recover again
|
|
1985
|
+
const chain = await loadControlChain(this.#o.storage, R);
|
|
1986
|
+
const record = chain?.kind === "linear" ? chain.records[step.index] : undefined;
|
|
1987
|
+
const previous = record?.payload.prevControlId ?? null;
|
|
1988
|
+
if (record === undefined || previous === null) {
|
|
1989
|
+
await this.#recoveryStep(ctx, { kind: "final", reason: "refused" });
|
|
1990
|
+
return;
|
|
1991
|
+
}
|
|
1992
|
+
this.#request({ kind: "recover-control", resource: toHex(R), index: step.index }, createMessage("CONTROL_PUT", {
|
|
1993
|
+
resourceId: R,
|
|
1994
|
+
expectedHead: previous,
|
|
1995
|
+
record: record.signed.bytes,
|
|
1996
|
+
}));
|
|
1997
|
+
}
|
|
1998
|
+
async #recoveryAccepted(request) {
|
|
1999
|
+
const ctx = this.#resources.get(request.resource);
|
|
2000
|
+
if (ctx === undefined)
|
|
2001
|
+
return;
|
|
2002
|
+
const chain = await loadControlChain(this.#o.storage, ctx.binding.resourceId);
|
|
2003
|
+
if (chain?.kind !== "linear")
|
|
2004
|
+
return;
|
|
2005
|
+
await this.#recoveryStep(ctx, afterAccepted(chain.records, request.index));
|
|
2006
|
+
}
|
|
2007
|
+
async #recoveryRefused(request, code, details) {
|
|
2008
|
+
const ctx = this.#resources.get(request.resource);
|
|
2009
|
+
if (ctx === undefined)
|
|
2010
|
+
return;
|
|
2011
|
+
if (RECOVERY_TRANSIENT_CODES.has(code) && ctx.recoveryAttempts < RECOVERY_TRANSIENT_ATTEMPTS) {
|
|
2012
|
+
// Open again after a backoff; the refused open recovers again.
|
|
2013
|
+
ctx.recoveryAttempts += 1;
|
|
2014
|
+
ctx.recoveryTried = false;
|
|
2015
|
+
this.#retryLater(ctx);
|
|
2016
|
+
return;
|
|
2017
|
+
}
|
|
2018
|
+
const chain = await loadControlChain(this.#o.storage, ctx.binding.resourceId);
|
|
2019
|
+
const step = code === "CONTROL_HEAD_MISMATCH" && chain?.kind === "linear"
|
|
2020
|
+
? afterMismatch(chain.records, details)
|
|
2021
|
+
: { kind: "final", reason: "refused" };
|
|
2022
|
+
await this.#recoveryStep(ctx, step);
|
|
2023
|
+
}
|
|
2024
|
+
#recoveryEvent(ctx, outcome, reason) {
|
|
2025
|
+
ctx.recoveryEnd = outcome === "ended" ? (reason ?? null) : null;
|
|
2026
|
+
this.#emit({
|
|
2027
|
+
type: "access-recovery",
|
|
2028
|
+
resourceId: ctx.binding.resourceId,
|
|
2029
|
+
url: this.#o.url,
|
|
2030
|
+
outcome,
|
|
2031
|
+
...(reason === undefined ? {} : { reason }),
|
|
2032
|
+
});
|
|
2033
|
+
}
|
|
1251
2034
|
/**
|
|
1252
2035
|
* §68.1: the server's Control Head is a record we hold below our own head:
|
|
1253
2036
|
* upload the records above it, one CONTROL_PUT each, in order, each
|
|
@@ -1302,9 +2085,19 @@ export class SyncClient {
|
|
|
1302
2085
|
!queued.has(toHex(u.unitId)) &&
|
|
1303
2086
|
!ctx.offering.has(toHex(u.unitId)));
|
|
1304
2087
|
sent += units.length;
|
|
2088
|
+
// §4.2: units of our batches the server lost are pending again.
|
|
2089
|
+
const lost = await this.#evidence(R, units.map((u) => toHex(u.unitId)), { kind: "reoffered" });
|
|
2090
|
+
const again = [...lost.values()].flat();
|
|
2091
|
+
if (again.length > 0)
|
|
2092
|
+
this.#emitStatus(R, {
|
|
2093
|
+
kind: "reoffered",
|
|
2094
|
+
unitIds: Object.freeze(again.map((u) => dataUnitId(fromHex(u)))),
|
|
2095
|
+
reason: ctx.lossReason ?? "have-gap",
|
|
2096
|
+
});
|
|
1305
2097
|
this.#offerUnits(ctx, units.map((u) => ({ id: toHex(u.unitId), bytes: u.bytes })));
|
|
1306
2098
|
}
|
|
1307
2099
|
await this.#o.outbound.offered(R);
|
|
2100
|
+
ctx.lossReason = null;
|
|
1308
2101
|
if (sent > 0)
|
|
1309
2102
|
await this.#offerKeys(ctx);
|
|
1310
2103
|
await this.#flush(ctx);
|