@openlfcp/client 0.1.0-rc.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/LICENSE +201 -0
- package/README.md +27 -0
- package/dist/apply.d.ts +299 -0
- package/dist/apply.js +741 -0
- package/dist/checkpoint.d.ts +31 -0
- package/dist/checkpoint.js +50 -0
- package/dist/connection.d.ts +87 -0
- package/dist/connection.js +210 -0
- package/dist/data-unit.d.ts +50 -0
- package/dist/data-unit.js +39 -0
- package/dist/engine-guard.d.ts +58 -0
- package/dist/engine-guard.js +148 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +15 -0
- package/dist/invite.d.ts +154 -0
- package/dist/invite.js +391 -0
- package/dist/outbound.d.ts +227 -0
- package/dist/outbound.js +508 -0
- package/dist/queue.d.ts +38 -0
- package/dist/queue.js +116 -0
- package/dist/resource-state.d.ts +34 -0
- package/dist/resource-state.js +24 -0
- package/dist/snapshot.d.ts +42 -0
- package/dist/snapshot.js +40 -0
- package/dist/storage.d.ts +96 -0
- package/dist/storage.js +289 -0
- package/dist/sync-client.d.ts +203 -0
- package/dist/sync-client.js +1032 -0
- package/package.json +50 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The §65 per-Resource client sync state machine (LFCP-WIRE-01 §65) as a
|
|
3
|
+
* pure transition function: exactly the drawn edges, plus the close edge
|
|
4
|
+
* from every state but CLOSED (§65: "Every state moves to CLOSED on
|
|
5
|
+
* RESOURCE_CLOSE or when the connection is lost"; CLOSED → CLOSED is
|
|
6
|
+
* illegal). An event without an edge returns undefined.
|
|
7
|
+
*/
|
|
8
|
+
export type ResourcePhase = "CLOSED" | "OPENING" | "CONTROL_SYNC" | "CONTROL_CONFLICT" | "KEY_SYNC" | "KEY_BLOCKED" | "DATA_SYNC" | "LIVE";
|
|
9
|
+
export type ResourcePhaseEvent =
|
|
10
|
+
/** RESOURCE_OPEN sent */
|
|
11
|
+
"OPEN"
|
|
12
|
+
/** RESOURCE_OPENED received */
|
|
13
|
+
| "OPENED"
|
|
14
|
+
/** multiple valid Control Heads */
|
|
15
|
+
| "FORK"
|
|
16
|
+
/** Control Chain complete */
|
|
17
|
+
| "CONTROL_COMPLETE"
|
|
18
|
+
/** the required DEK is available */
|
|
19
|
+
| "DEK_AVAILABLE"
|
|
20
|
+
/** a required Key Package is unavailable */
|
|
21
|
+
| "KEY_UNAVAILABLE"
|
|
22
|
+
/** a Key Package arrived (KEY_BLOCKED → KEY_SYNC) */
|
|
23
|
+
| "PACKAGE_ARRIVED"
|
|
24
|
+
/** snapshot/replay reached the known frontier */
|
|
25
|
+
| "FRONTIER_REACHED"
|
|
26
|
+
/** missing ranges detected while LIVE */
|
|
27
|
+
| "MISSING_RANGES"
|
|
28
|
+
/** a new Control Record received while LIVE */
|
|
29
|
+
| "CONTROL_RECORD"
|
|
30
|
+
/** RESOURCE_CLOSE, a manual close, or the connection was lost */
|
|
31
|
+
| "CLOSE";
|
|
32
|
+
/** The §65 transition, or undefined when `event` has no edge from `state`. */
|
|
33
|
+
export declare function resourcePhaseTransition(state: ResourcePhase, event: ResourcePhaseEvent): ResourcePhase | undefined;
|
|
34
|
+
//# sourceMappingURL=resource-state.d.ts.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The §65 per-Resource client sync state machine (LFCP-WIRE-01 §65) as a
|
|
3
|
+
* pure transition function: exactly the drawn edges, plus the close edge
|
|
4
|
+
* from every state but CLOSED (§65: "Every state moves to CLOSED on
|
|
5
|
+
* RESOURCE_CLOSE or when the connection is lost"; CLOSED → CLOSED is
|
|
6
|
+
* illegal). An event without an edge returns undefined.
|
|
7
|
+
*/
|
|
8
|
+
const EDGES = {
|
|
9
|
+
CLOSED: { OPEN: "OPENING" },
|
|
10
|
+
OPENING: { OPENED: "CONTROL_SYNC" },
|
|
11
|
+
CONTROL_SYNC: { FORK: "CONTROL_CONFLICT", CONTROL_COMPLETE: "KEY_SYNC" },
|
|
12
|
+
CONTROL_CONFLICT: {},
|
|
13
|
+
KEY_SYNC: { DEK_AVAILABLE: "DATA_SYNC", KEY_UNAVAILABLE: "KEY_BLOCKED" },
|
|
14
|
+
KEY_BLOCKED: { PACKAGE_ARRIVED: "KEY_SYNC" },
|
|
15
|
+
DATA_SYNC: { FRONTIER_REACHED: "LIVE" },
|
|
16
|
+
LIVE: { MISSING_RANGES: "DATA_SYNC", CONTROL_RECORD: "CONTROL_SYNC" },
|
|
17
|
+
};
|
|
18
|
+
/** The §65 transition, or undefined when `event` has no edge from `state`. */
|
|
19
|
+
export function resourcePhaseTransition(state, event) {
|
|
20
|
+
if (event === "CLOSE")
|
|
21
|
+
return state === "CLOSED" ? undefined : "CLOSED";
|
|
22
|
+
return EDGES[state][event];
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=resource-state.js.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type DataEpoch, type Hash32 } from "@openlfcp/core";
|
|
2
|
+
import { type ResourceDEK } from "@openlfcp/crypto";
|
|
3
|
+
import type { SnapshotSequenceGuard, SnapshotSequenceReservation } from "@openlfcp/storage";
|
|
4
|
+
import { type ControlView, type DataProfileCodec, type LiveHaveEntry, type Signer } from "@openlfcp/wire";
|
|
5
|
+
/**
|
|
6
|
+
* Publishing an encrypted, signed Snapshot (LFCP-WIRE-01 §29). The Data
|
|
7
|
+
* Profile's Snapshot codec turns the application state into plaintext;
|
|
8
|
+
* LFCP encrypts and signs it. Only the opaque bytes are ever sent.
|
|
9
|
+
*/
|
|
10
|
+
export interface CreateSnapshotOptions<T> {
|
|
11
|
+
readonly view: ControlView;
|
|
12
|
+
/** The Control Head the Snapshot is authorized at (usually the latest one). */
|
|
13
|
+
readonly controlHead: Uint8Array;
|
|
14
|
+
readonly publisher: Signer;
|
|
15
|
+
/** The DEK of the current Data Epoch at `controlHead`. */
|
|
16
|
+
readonly dek: ResourceDEK;
|
|
17
|
+
/** The only source of Snapshot Sequences (§29): durable in production (LFCP-034, LFCP-035). */
|
|
18
|
+
readonly sequences: SnapshotSequenceReservation;
|
|
19
|
+
readonly guard?: SnapshotSequenceGuard;
|
|
20
|
+
/** The Data Plane holdings the Snapshot includes; normalized and canonicalized here (§28.2). */
|
|
21
|
+
readonly frontier: readonly LiveHaveEntry[];
|
|
22
|
+
/** The Data Profile's Snapshot codec. */
|
|
23
|
+
readonly profile: DataProfileCodec<T>;
|
|
24
|
+
readonly value: T;
|
|
25
|
+
}
|
|
26
|
+
export interface CreatedSnapshot {
|
|
27
|
+
/** The exact signed bytes: opaque, the only form sent to servers and peers. */
|
|
28
|
+
readonly bytes: Uint8Array;
|
|
29
|
+
readonly snapshotId: Hash32;
|
|
30
|
+
readonly seq: bigint;
|
|
31
|
+
readonly epoch: DataEpoch;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Creates a Snapshot. Before a sequence is reserved, it checks the head,
|
|
35
|
+
* the profile, snapshot/publish at the head (§29.2), the DEK against the
|
|
36
|
+
* head's current epoch, and that a closed epoch's Snapshot covers nothing
|
|
37
|
+
* beyond its final frontier (§29, G-EP4; refused locally with
|
|
38
|
+
* INVALID_STRUCTURE, the receiver's code is STALE_DATA_EPOCH). The frontier
|
|
39
|
+
* is normalized into canonical form before it is encrypted and signed.
|
|
40
|
+
*/
|
|
41
|
+
export declare function createSnapshot<T>(options: CreateSnapshotOptions<T>): Promise<CreatedSnapshot>;
|
|
42
|
+
//# sourceMappingURL=snapshot.d.ts.map
|
package/dist/snapshot.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { bytesEqual, LfcpError } from "@openlfcp/core";
|
|
2
|
+
import { dekCommitment } from "@openlfcp/crypto";
|
|
3
|
+
import { ABILITY, beyondCutoff, hasAbility, normalizeLiveHaves, sealSnapshot, } from "@openlfcp/wire";
|
|
4
|
+
/**
|
|
5
|
+
* Creates a Snapshot. Before a sequence is reserved, it checks the head,
|
|
6
|
+
* the profile, snapshot/publish at the head (§29.2), the DEK against the
|
|
7
|
+
* head's current epoch, and that a closed epoch's Snapshot covers nothing
|
|
8
|
+
* beyond its final frontier (§29, G-EP4; refused locally with
|
|
9
|
+
* INVALID_STRUCTURE, the receiver's code is STALE_DATA_EPOCH). The frontier
|
|
10
|
+
* is normalized into canonical form before it is encrypted and signed.
|
|
11
|
+
*/
|
|
12
|
+
export async function createSnapshot(options) {
|
|
13
|
+
const atHead = options.view.stateAt(options.controlHead);
|
|
14
|
+
if (atHead === undefined)
|
|
15
|
+
throw new LfcpError("MISSING_DEPENDENCY", "the Control Head is not on the validated chain");
|
|
16
|
+
if (options.profile.dataProfile !== atHead.dataProfile)
|
|
17
|
+
throw new LfcpError("DATA_PROFILE_MISMATCH", `the profile codec is for ${options.profile.dataProfile}, the Resource uses ${atHead.dataProfile}`);
|
|
18
|
+
const publisher = options.publisher.descriptor.principalId;
|
|
19
|
+
if (!hasAbility(atHead, publisher, ABILITY.SNAPSHOT_PUBLISH))
|
|
20
|
+
throw new LfcpError("AUTHORIZATION_FAILED", "the publisher does not hold snapshot/publish at the Control Head (§29.2)");
|
|
21
|
+
const epoch = atHead.epoch.epoch;
|
|
22
|
+
if (!bytesEqual(dekCommitment(atHead.resourceId, epoch, options.dek), atHead.epoch.dekCommitment))
|
|
23
|
+
throw new LfcpError("DEK_COMMITMENT_MISMATCH", `the DEK is not the one committed for epoch ${epoch} at the Control Head`);
|
|
24
|
+
const frontier = normalizeLiveHaves(options.frontier);
|
|
25
|
+
const beyond = beyondCutoff(options.view, epoch, frontier);
|
|
26
|
+
if (beyond !== undefined)
|
|
27
|
+
throw new LfcpError("INVALID_STRUCTURE", beyond);
|
|
28
|
+
const plaintext = options.profile.encode(options.value);
|
|
29
|
+
const seq = await options.sequences.reserveNext(atHead.resourceId, epoch, publisher);
|
|
30
|
+
options.guard?.claim(atHead.resourceId, epoch, publisher, seq);
|
|
31
|
+
const sealed = sealSnapshot({
|
|
32
|
+
resourceId: atHead.resourceId,
|
|
33
|
+
dataEpoch: epoch,
|
|
34
|
+
snapshotSeq: seq,
|
|
35
|
+
controlHead: atHead.head,
|
|
36
|
+
frontier,
|
|
37
|
+
}, plaintext, options.dek, options.publisher);
|
|
38
|
+
return Object.freeze({ bytes: sealed.bytes, snapshotId: sealed.snapshotId, seq, epoch });
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=snapshot.js.map
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { type ActorSequence, type ControlRecordId, type DataEpoch, type DataUnitId, type PrincipalId, type ResourceId } from "@openlfcp/core";
|
|
2
|
+
import { type ResourceDEK } from "@openlfcp/crypto";
|
|
3
|
+
import { type CommitResult, type DataUnitRow, type LfcpStorage, type SecretStore, type StorageWrite } from "@openlfcp/storage";
|
|
4
|
+
import { type ChainResult, type SeenRecord, type SeenUnits } from "@openlfcp/wire";
|
|
5
|
+
import { type CreateDataUnitOptions, type CreatedDataUnit } from "./data-unit.js";
|
|
6
|
+
/**
|
|
7
|
+
* The client code on top of the storage interfaces (LFCP-034), so that a
|
|
8
|
+
* durable adapter (LFCP-035) is all a platform has to write.
|
|
9
|
+
*/
|
|
10
|
+
/** A Data Unit's storage row: its exact bytes and header indexes. Throws for bytes that do not parse. */
|
|
11
|
+
export declare function dataUnitRow(bytes: Uint8Array): DataUnitRow;
|
|
12
|
+
/**
|
|
13
|
+
* The wire SeenUnits over LfcpStorage, durable and with un-accept
|
|
14
|
+
* (set-accepted false). A unit is stored only once its signature verifies:
|
|
15
|
+
* the receiver announces the row with expect() before verification, and
|
|
16
|
+
* recordSignatureValid stores it.
|
|
17
|
+
*/
|
|
18
|
+
export declare class StoredSeenUnits implements SeenUnits {
|
|
19
|
+
#private;
|
|
20
|
+
constructor(storage: Pick<LfcpStorage, "dataUnits" | "commit">);
|
|
21
|
+
/** The row of a unit about to be verified (kept in memory until it is). */
|
|
22
|
+
expect(row: DataUnitRow): void;
|
|
23
|
+
recordSignatureValid(_resource: ResourceId, _actor: PrincipalId, _seq: ActorSequence, unitId: DataUnitId): Promise<SeenRecord>;
|
|
24
|
+
acceptedAt(resource: ResourceId, actor: PrincipalId, seq: ActorSequence): Promise<DataUnitId | undefined>;
|
|
25
|
+
acceptedIn(resource: ResourceId, actor: PrincipalId, from: ActorSequence, to: ActorSequence): Promise<readonly {
|
|
26
|
+
readonly seq: ActorSequence;
|
|
27
|
+
readonly unitId: DataUnitId;
|
|
28
|
+
}[]>;
|
|
29
|
+
markAccepted(_resource: ResourceId, _actor: PrincipalId, _seq: ActorSequence, unitId: DataUnitId): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The stored Control Chain of `resource`, validated: the records from the
|
|
33
|
+
* stored head back to Genesis. Undefined when nothing is stored.
|
|
34
|
+
*/
|
|
35
|
+
export declare function loadControlChain(storage: Pick<LfcpStorage, "control">, resource: ResourceId): Promise<ChainResult | undefined>;
|
|
36
|
+
/**
|
|
37
|
+
* Persists a validated linear chain in one atomic batch: its exact records,
|
|
38
|
+
* the head (compare-and-set against `expected`), the epoch history (keeping
|
|
39
|
+
* known DEK references), the route, and no conflict. A head that moved
|
|
40
|
+
* meanwhile writes nothing and returns CONTROL_HEAD_MISMATCH.
|
|
41
|
+
*/
|
|
42
|
+
export declare function saveControlChain(storage: Pick<LfcpStorage, "control" | "commit">, chain: Extract<ChainResult, {
|
|
43
|
+
kind: "linear";
|
|
44
|
+
}>, expected: ControlRecordId | null): Promise<CommitResult>;
|
|
45
|
+
/** Records a Control Chain fork (CONTROL_CONFLICT): its competing heads, sorted by bytes. */
|
|
46
|
+
export declare function saveControlConflict(storage: Pick<LfcpStorage, "commit">, resource: ResourceId, conflict: Extract<ChainResult, {
|
|
47
|
+
kind: "conflict";
|
|
48
|
+
}>): Promise<CommitResult>;
|
|
49
|
+
/**
|
|
50
|
+
* Epoch rows of `chain` with no DEK reference take a DEK this client
|
|
51
|
+
* already holds under the epoch's standard reference (dekSecretRef): one
|
|
52
|
+
* it created (queueKeyEpoch), or one a Key Package delivered before the
|
|
53
|
+
* row was stored. A DEK is adopted only if it matches the epoch's
|
|
54
|
+
* commitment in the validated chain. Returns the adopted epochs. Called
|
|
55
|
+
* whenever a chain is saved, so the row and its key meet without waiting
|
|
56
|
+
* for another Key Package request.
|
|
57
|
+
*/
|
|
58
|
+
export declare function adoptStoredDeks(storage: Pick<LfcpStorage, "control" | "commit">, secrets: SecretStore, chain: Extract<ChainResult, {
|
|
59
|
+
kind: "linear";
|
|
60
|
+
}>): Promise<DataEpoch[]>;
|
|
61
|
+
/**
|
|
62
|
+
* The DEK lookup for a receiver or writer: the epoch's stored DEK reference,
|
|
63
|
+
* resolved in the SecretStore. Undefined when this client has no DEK for it.
|
|
64
|
+
*/
|
|
65
|
+
export declare function dekResolver(storage: Pick<LfcpStorage, "control">, secrets: SecretStore, resource: ResourceId): (epoch: DataEpoch) => Promise<ResourceDEK | undefined>;
|
|
66
|
+
/**
|
|
67
|
+
* Creates this client's Data Unit with a sequence from the storage's
|
|
68
|
+
* reservation, then commits in one batch: the exact unit (merged and
|
|
69
|
+
* accepted: it is this client's own history), its outbound entry, and any
|
|
70
|
+
* `also` writes (e.g. the profile checkpoint containing the change). A
|
|
71
|
+
* crash before the commit leaves only an abandoned sequence, never a
|
|
72
|
+
* reused one; a retry sends the queued bytes, never a re-created unit.
|
|
73
|
+
*/
|
|
74
|
+
/**
|
|
75
|
+
* §26.2 (G-DP1-GAP): the unit a writer's next unit names as `previous`:
|
|
76
|
+
* its latest own unit it still holds as accepted, or null before its
|
|
77
|
+
* first. Local units are stored accepted in the commit that queues them,
|
|
78
|
+
* so this is its last published unit, except one it has itself seen
|
|
79
|
+
* excluded by a cutoff (G-EP7) or as equivocation (G-DP5): no receiver
|
|
80
|
+
* accepts those either, so the next unit (e.g. stale work re-applied,
|
|
81
|
+
* G-EP5) names the unit before them and links. After an abandoned
|
|
82
|
+
* sequence N the next unit names N - 1. Read from storage, so a restart
|
|
83
|
+
* chooses the same unit.
|
|
84
|
+
*/
|
|
85
|
+
export declare function latestAcceptedOwnUnit(storage: Pick<LfcpStorage, "dataUnits">, resource: ResourceId, actor: PrincipalId): Promise<DataUnitId | null>;
|
|
86
|
+
export declare function createQueuedDataUnit<T>(storage: Pick<LfcpStorage, "actorSequences" | "commit" | "dataUnits">, options: Omit<CreateDataUnitOptions<T>, "sequences" | "previousUnitId"> & {
|
|
87
|
+
/** The previous unit (§26.2); default the writer's latest own unit still accepted (latestAcceptedOwnUnit). */
|
|
88
|
+
readonly previousUnitId?: DataUnitId | null;
|
|
89
|
+
/**
|
|
90
|
+
* Called once the unit is sealed, before the commit: e.g. the Data
|
|
91
|
+
* Profile records which change the unit carries (recordLocal), so that
|
|
92
|
+
* a function `also` can put the updated checkpoint in the same batch.
|
|
93
|
+
*/
|
|
94
|
+
readonly onCreated?: (created: CreatedDataUnit, value: T) => void;
|
|
95
|
+
}, also?: readonly StorageWrite[] | ((created: CreatedDataUnit) => readonly StorageWrite[])): Promise<CreatedDataUnit>;
|
|
96
|
+
//# sourceMappingURL=storage.d.ts.map
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { actorSequence, bytesEqual, hash32, LfcpError, toHex, } from "@openlfcp/core";
|
|
2
|
+
import { dekCommitment, importResourceDEK } from "@openlfcp/crypto";
|
|
3
|
+
import { dekSecretRef, } from "@openlfcp/storage";
|
|
4
|
+
import { parseDataUnit, validateControlChain, } from "@openlfcp/wire";
|
|
5
|
+
import { createDataUnit } from "./data-unit.js";
|
|
6
|
+
/**
|
|
7
|
+
* The client code on top of the storage interfaces (LFCP-034), so that a
|
|
8
|
+
* durable adapter (LFCP-035) is all a platform has to write.
|
|
9
|
+
*/
|
|
10
|
+
/** A Data Unit's storage row: its exact bytes and header indexes. Throws for bytes that do not parse. */
|
|
11
|
+
export function dataUnitRow(bytes) {
|
|
12
|
+
const parsed = parseDataUnit(bytes);
|
|
13
|
+
const p = parsed.payload;
|
|
14
|
+
return {
|
|
15
|
+
unitId: parsed.signed.id,
|
|
16
|
+
resourceId: p.resourceId,
|
|
17
|
+
dataEpoch: p.dataEpoch,
|
|
18
|
+
actor: p.actor,
|
|
19
|
+
actorSeq: p.actorSeq,
|
|
20
|
+
prevDataUnitId: p.prevDataUnitId,
|
|
21
|
+
controlHead: p.controlHead,
|
|
22
|
+
bytes: parsed.signed.bytes,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The wire SeenUnits over LfcpStorage, durable and with un-accept
|
|
27
|
+
* (set-accepted false). A unit is stored only once its signature verifies:
|
|
28
|
+
* the receiver announces the row with expect() before verification, and
|
|
29
|
+
* recordSignatureValid stores it.
|
|
30
|
+
*/
|
|
31
|
+
export class StoredSeenUnits {
|
|
32
|
+
#storage;
|
|
33
|
+
#expected = new Map();
|
|
34
|
+
constructor(storage) {
|
|
35
|
+
this.#storage = storage;
|
|
36
|
+
}
|
|
37
|
+
/** The row of a unit about to be verified (kept in memory until it is). */
|
|
38
|
+
expect(row) {
|
|
39
|
+
this.#expected.set(toHex(row.unitId), row);
|
|
40
|
+
}
|
|
41
|
+
async recordSignatureValid(_resource, _actor, _seq, unitId) {
|
|
42
|
+
const key = toHex(unitId);
|
|
43
|
+
const row = this.#expected.get(key) ?? (await this.#storage.dataUnits.get(unitId));
|
|
44
|
+
if (row === undefined)
|
|
45
|
+
throw new Error(`StoredSeenUnits: no row for unit ${key}; call expect() first`);
|
|
46
|
+
this.#expected.delete(key);
|
|
47
|
+
return this.#storage.dataUnits.recordSeen(row);
|
|
48
|
+
}
|
|
49
|
+
acceptedAt(resource, actor, seq) {
|
|
50
|
+
return this.#storage.dataUnits.acceptedAt(resource, actor, seq);
|
|
51
|
+
}
|
|
52
|
+
async acceptedIn(resource, actor, from, to) {
|
|
53
|
+
return (await this.#storage.dataUnits.range(resource, actor, from, to))
|
|
54
|
+
.filter((u) => u.accepted)
|
|
55
|
+
.map((u) => ({ seq: u.actorSeq, unitId: u.unitId }));
|
|
56
|
+
}
|
|
57
|
+
async markAccepted(_resource, _actor, _seq, unitId) {
|
|
58
|
+
const r = await this.#storage.commit([{ op: "set-accepted", unitId, accepted: true }]);
|
|
59
|
+
if (!r.ok)
|
|
60
|
+
throw new Error("set-accepted has no precondition");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The stored Control Chain of `resource`, validated: the records from the
|
|
65
|
+
* stored head back to Genesis. Undefined when nothing is stored.
|
|
66
|
+
*/
|
|
67
|
+
export async function loadControlChain(storage, resource) {
|
|
68
|
+
const head = await storage.control.head(resource);
|
|
69
|
+
if (head === undefined)
|
|
70
|
+
return undefined;
|
|
71
|
+
const byId = new Map((await storage.control.records(resource)).map((r) => [toHex(r.recordId), r]));
|
|
72
|
+
const chain = [];
|
|
73
|
+
for (let at = head.head; at !== null;) {
|
|
74
|
+
const r = byId.get(toHex(at));
|
|
75
|
+
if (r === undefined)
|
|
76
|
+
throw new LfcpError("INVALID_CONTROL_CHAIN", `stored Control Record ${toHex(at)} is missing (local corruption)`);
|
|
77
|
+
chain.unshift(r.bytes);
|
|
78
|
+
at = r.prevControlId;
|
|
79
|
+
}
|
|
80
|
+
const result = validateControlChain(chain);
|
|
81
|
+
// Fail closed on local corruption: the records must validate to exactly the
|
|
82
|
+
// stored head and sequence; an older or different state is never used silently.
|
|
83
|
+
if (result.kind === "linear" &&
|
|
84
|
+
(!bytesEqual(result.state.head, head.head) || result.state.seq !== head.controlSeq))
|
|
85
|
+
throw new LfcpError("INVALID_CONTROL_CHAIN", `the stored Control Records validate to sequence ${result.state.seq}, not the stored head at ${head.controlSeq} (local corruption)`);
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Persists a validated linear chain in one atomic batch: its exact records,
|
|
90
|
+
* the head (compare-and-set against `expected`), the epoch history (keeping
|
|
91
|
+
* known DEK references), the route, and no conflict. A head that moved
|
|
92
|
+
* meanwhile writes nothing and returns CONTROL_HEAD_MISMATCH.
|
|
93
|
+
*/
|
|
94
|
+
export async function saveControlChain(storage, chain, expected) {
|
|
95
|
+
const s = chain.state;
|
|
96
|
+
const known = new Map((await storage.control.epochs(s.resourceId)).map((e) => [String(e.epoch), e]));
|
|
97
|
+
const epochs = [...s.epochs.values()].map((e) => ({
|
|
98
|
+
op: "put-epoch",
|
|
99
|
+
resourceId: s.resourceId,
|
|
100
|
+
epoch: {
|
|
101
|
+
epoch: e.epoch,
|
|
102
|
+
dekCommitment: e.dekCommitment,
|
|
103
|
+
openedBy: e.openedBy,
|
|
104
|
+
closedBy: e.closedBy,
|
|
105
|
+
dekRef: known.get(String(e.epoch))?.dekRef ?? null,
|
|
106
|
+
},
|
|
107
|
+
}));
|
|
108
|
+
return storage.commit([
|
|
109
|
+
{
|
|
110
|
+
op: "put-control-records",
|
|
111
|
+
records: chain.records.map((r) => ({
|
|
112
|
+
recordId: r.signed.id,
|
|
113
|
+
resourceId: r.payload.resourceId,
|
|
114
|
+
controlSeq: r.payload.controlSeq,
|
|
115
|
+
prevControlId: r.payload.prevControlId,
|
|
116
|
+
bytes: r.signed.bytes,
|
|
117
|
+
})),
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
op: "set-control-head",
|
|
121
|
+
resourceId: s.resourceId,
|
|
122
|
+
expected,
|
|
123
|
+
head: { head: s.head, controlSeq: s.seq },
|
|
124
|
+
},
|
|
125
|
+
...epochs,
|
|
126
|
+
{
|
|
127
|
+
op: "put-route",
|
|
128
|
+
resourceId: s.resourceId,
|
|
129
|
+
route: {
|
|
130
|
+
routeVersion: s.routeVersion,
|
|
131
|
+
endpoints: s.route.endpoints.map((e) => ({
|
|
132
|
+
url: e.url,
|
|
133
|
+
priority: e.priority,
|
|
134
|
+
...(e.flags === undefined ? {} : { flags: e.flags }),
|
|
135
|
+
})),
|
|
136
|
+
coordinatorUrl: s.route.coordinatorUrl,
|
|
137
|
+
source: s.head,
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
{ op: "set-control-conflict", resourceId: s.resourceId, conflict: null },
|
|
141
|
+
]);
|
|
142
|
+
}
|
|
143
|
+
/** Records a Control Chain fork (CONTROL_CONFLICT): its competing heads, sorted by bytes. */
|
|
144
|
+
export async function saveControlConflict(storage, resource, conflict) {
|
|
145
|
+
return storage.commit([
|
|
146
|
+
{ op: "set-control-conflict", resourceId: resource, conflict: { heads: conflict.competing } },
|
|
147
|
+
]);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Epoch rows of `chain` with no DEK reference take a DEK this client
|
|
151
|
+
* already holds under the epoch's standard reference (dekSecretRef): one
|
|
152
|
+
* it created (queueKeyEpoch), or one a Key Package delivered before the
|
|
153
|
+
* row was stored. A DEK is adopted only if it matches the epoch's
|
|
154
|
+
* commitment in the validated chain. Returns the adopted epochs. Called
|
|
155
|
+
* whenever a chain is saved, so the row and its key meet without waiting
|
|
156
|
+
* for another Key Package request.
|
|
157
|
+
*/
|
|
158
|
+
export async function adoptStoredDeks(storage, secrets, chain) {
|
|
159
|
+
const R = chain.state.resourceId;
|
|
160
|
+
const writes = [];
|
|
161
|
+
const adopted = [];
|
|
162
|
+
for (const row of await storage.control.epochs(R)) {
|
|
163
|
+
if (row.dekRef !== null)
|
|
164
|
+
continue;
|
|
165
|
+
const epoch = chain.state.epochs.get(String(row.epoch));
|
|
166
|
+
if (epoch === undefined)
|
|
167
|
+
continue;
|
|
168
|
+
const ref = dekSecretRef(R, row.epoch);
|
|
169
|
+
const bytes = await secrets.get(ref);
|
|
170
|
+
if (bytes === undefined)
|
|
171
|
+
continue;
|
|
172
|
+
const matches = bytesEqual(dekCommitment(R, row.epoch, importResourceDEK(bytes)), epoch.dekCommitment);
|
|
173
|
+
bytes.fill(0);
|
|
174
|
+
if (!matches)
|
|
175
|
+
continue;
|
|
176
|
+
// put-epoch merges inside the commit: a concurrent close is kept.
|
|
177
|
+
writes.push({ op: "put-epoch", resourceId: R, epoch: { ...row, dekRef: ref } });
|
|
178
|
+
adopted.push(row.epoch);
|
|
179
|
+
}
|
|
180
|
+
if (writes.length > 0) {
|
|
181
|
+
const r = await storage.commit(writes);
|
|
182
|
+
if (!r.ok)
|
|
183
|
+
throw new Error(`the DEK references were not stored: ${r.reason}`);
|
|
184
|
+
}
|
|
185
|
+
return adopted;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* The DEK lookup for a receiver or writer: the epoch's stored DEK reference,
|
|
189
|
+
* resolved in the SecretStore. Undefined when this client has no DEK for it.
|
|
190
|
+
*/
|
|
191
|
+
export function dekResolver(storage, secrets, resource) {
|
|
192
|
+
return async (epoch) => {
|
|
193
|
+
const row = (await storage.control.epochs(resource)).find((e) => e.epoch === epoch);
|
|
194
|
+
if (row?.dekRef == null)
|
|
195
|
+
return undefined;
|
|
196
|
+
const bytes = await secrets.get(row.dekRef);
|
|
197
|
+
return bytes === undefined ? undefined : importResourceDEK(bytes);
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Creates this client's Data Unit with a sequence from the storage's
|
|
202
|
+
* reservation, then commits in one batch: the exact unit (merged and
|
|
203
|
+
* accepted: it is this client's own history), its outbound entry, and any
|
|
204
|
+
* `also` writes (e.g. the profile checkpoint containing the change). A
|
|
205
|
+
* crash before the commit leaves only an abandoned sequence, never a
|
|
206
|
+
* reused one; a retry sends the queued bytes, never a re-created unit.
|
|
207
|
+
*/
|
|
208
|
+
/**
|
|
209
|
+
* §26.2 (G-DP1-GAP): the unit a writer's next unit names as `previous`:
|
|
210
|
+
* its latest own unit it still holds as accepted, or null before its
|
|
211
|
+
* first. Local units are stored accepted in the commit that queues them,
|
|
212
|
+
* so this is its last published unit, except one it has itself seen
|
|
213
|
+
* excluded by a cutoff (G-EP7) or as equivocation (G-DP5): no receiver
|
|
214
|
+
* accepts those either, so the next unit (e.g. stale work re-applied,
|
|
215
|
+
* G-EP5) names the unit before them and links. After an abandoned
|
|
216
|
+
* sequence N the next unit names N - 1. Read from storage, so a restart
|
|
217
|
+
* chooses the same unit.
|
|
218
|
+
*/
|
|
219
|
+
export async function latestAcceptedOwnUnit(storage, resource, actor) {
|
|
220
|
+
const mine = await storage.dataUnits.range(resource, actor, actorSequence(1n), actorSequence(2n ** 64n - 1n));
|
|
221
|
+
return mine.filter((u) => u.accepted).at(-1)?.unitId ?? null;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Local units of one (storage, Resource, actor) are created one at a time
|
|
225
|
+
* in this process: each must name the previous one (§26.2). Across
|
|
226
|
+
* processes sharing a store, the commit's expect-previous-unit refuses a
|
|
227
|
+
* unit whose previous was superseded meanwhile.
|
|
228
|
+
*/
|
|
229
|
+
const localWriters = new WeakMap();
|
|
230
|
+
function serialized(storage, key, run) {
|
|
231
|
+
let tails = localWriters.get(storage);
|
|
232
|
+
if (tails === undefined) {
|
|
233
|
+
tails = new Map();
|
|
234
|
+
localWriters.set(storage, tails);
|
|
235
|
+
}
|
|
236
|
+
const done = (tails.get(key) ?? Promise.resolve()).then(run, run);
|
|
237
|
+
const tail = done.then(() => undefined, () => undefined);
|
|
238
|
+
tails.set(key, tail);
|
|
239
|
+
void tail.then(() => {
|
|
240
|
+
if (tails.get(key) === tail)
|
|
241
|
+
tails.delete(key);
|
|
242
|
+
});
|
|
243
|
+
return done;
|
|
244
|
+
}
|
|
245
|
+
export async function createQueuedDataUnit(storage, options, also = []) {
|
|
246
|
+
const resource = options.view.state.resourceId;
|
|
247
|
+
const actor = options.actor.descriptor.principalId;
|
|
248
|
+
return serialized(storage, `${toHex(resource)}:${toHex(actor)}`, () => createQueuedDataUnitNow(storage, options, also, resource, actor));
|
|
249
|
+
}
|
|
250
|
+
async function createQueuedDataUnitNow(storage, options, also, resource, actor) {
|
|
251
|
+
const { onCreated, previousUnitId, ...create } = options;
|
|
252
|
+
const previous = previousUnitId !== undefined
|
|
253
|
+
? previousUnitId
|
|
254
|
+
: await latestAcceptedOwnUnit(storage, resource, actor);
|
|
255
|
+
const created = await createDataUnit({
|
|
256
|
+
...create,
|
|
257
|
+
previousUnitId: previous,
|
|
258
|
+
sequences: storage.actorSequences,
|
|
259
|
+
});
|
|
260
|
+
onCreated?.(created, options.value);
|
|
261
|
+
const extra = typeof also === "function" ? also(created) : also;
|
|
262
|
+
const row = dataUnitRow(created.bytes);
|
|
263
|
+
const result = await storage.commit([
|
|
264
|
+
// Unless the caller named its own previous unit, it must still be the
|
|
265
|
+
// latest when this unit is stored (another writer, e.g. another process).
|
|
266
|
+
...(previousUnitId !== undefined
|
|
267
|
+
? []
|
|
268
|
+
: [{ op: "expect-previous-unit", resourceId: resource, actor, previous }]),
|
|
269
|
+
{ op: "put-data-unit", unit: row, status: "merged", detail: "local", accepted: true },
|
|
270
|
+
{
|
|
271
|
+
op: "enqueue",
|
|
272
|
+
item: {
|
|
273
|
+
itemId: hash32(created.unitId),
|
|
274
|
+
resourceId: row.resourceId,
|
|
275
|
+
kind: "data-unit",
|
|
276
|
+
bytes: created.bytes,
|
|
277
|
+
attempts: 0,
|
|
278
|
+
lastAttempt: null,
|
|
279
|
+
nextAttempt: null,
|
|
280
|
+
blocked: null,
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
...extra,
|
|
284
|
+
]);
|
|
285
|
+
if (!result.ok)
|
|
286
|
+
throw new Error(`the local unit was not stored: ${result.reason}`);
|
|
287
|
+
return created;
|
|
288
|
+
}
|
|
289
|
+
//# sourceMappingURL=storage.js.map
|