@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 CHANGED
@@ -12,9 +12,9 @@ ESM only; Node.js 24 or later (browsers and Electron for every package except `@
12
12
 
13
13
  Part of the OpenLFCP TypeScript SDK, which implements the OpenLFCP MVP 0.1
14
14
  subset of [LFCP-WIRE-01](https://github.com/openlfcp/spec/blob/main/wire/LFCP-WIRE-01.md)
15
- at `mvp-0.1-baseline.10`, not every deferred WIRE-01 feature. It does not
15
+ at `mvp-0.1-baseline.9`, not every deferred WIRE-01 feature. It does not
16
16
  claim full LFCP-WIRE-01 conformance. This is the MVP 0.1 release
17
- (`0.1.4`, npm dist-tag `latest`). Until 1.0, minor versions may change APIs.
17
+ (`0.1.3`, npm dist-tag `latest`). Until 1.0, minor versions may change APIs.
18
18
 
19
19
  ## Links
20
20
 
@@ -0,0 +1,69 @@
1
+ import { type PrincipalId } from "@openlfcp/core";
2
+ import { type ControlRecord, type ControlState } from "@openlfcp/wire";
3
+ /**
4
+ * Recovering access after a server restore (LFCP-02-106; design:
5
+ * `.github: docs/devel/design/member-recovery-after-restore.md`).
6
+ *
7
+ * A restored server may have lost the Control Records that grant this
8
+ * session's Principal `data/read`, and then refuses RESOURCE_OPEN with
9
+ * AUTHORIZATION_FAILED. A member whose own validated chain grants it
10
+ * `data/read` re-supplies the records the server lacks with CONTROL_PUT,
11
+ * which needs no read access (§47, §84): the server checks each record by
12
+ * its issuer. It learns the server's head from CONTROL_HEAD_MISMATCH,
13
+ * pushes the records above it oldest first, and opens again once.
14
+ *
15
+ * Security: the member uploads only records it holds, each signed by its
16
+ * issuer, and learns only the server's head ID (any session learns it from
17
+ * a mismatch). A chain that does not grant the member access sends nothing;
18
+ * a server head the member does not know (a revocation it has not seen, a
19
+ * fork) stops the recovery. Attempts are bounded.
20
+ *
21
+ * These are the pure decisions; SyncClient sends the requests.
22
+ */
23
+ /** What to do next. */
24
+ export type RecoveryStep =
25
+ /** CONTROL_PUT of `records[index]`, expecting `records[index - 1]`. */
26
+ {
27
+ readonly kind: "push";
28
+ readonly index: number;
29
+ }
30
+ /** The server holds the whole chain now: RESOURCE_OPEN again, once. */
31
+ | {
32
+ readonly kind: "reopen";
33
+ }
34
+ /** No recovery: the refusal stands. */
35
+ | {
36
+ readonly kind: "final";
37
+ readonly reason: RecoveryEnd;
38
+ };
39
+ export type RecoveryEnd =
40
+ /** The local chain does not grant this Principal data/read: never tried. */
41
+ "not-granted"
42
+ /** The server holds our head: the refusal is not caused by a loss. */
43
+ | "server-current"
44
+ /** The server's head is not in our chain: a record we have not seen, or a fork. */
45
+ | "unknown-head"
46
+ /** The server refused a record for good, or the transient bound was reached. */
47
+ | "refused";
48
+ /** Transient refusals of a recovery CONTROL_PUT, retried with backoff a bounded number of times. */
49
+ export declare const RECOVERY_TRANSIENT_CODES: ReadonlySet<string>;
50
+ /** How many transient refusals a recovery tolerates before it ends. */
51
+ export declare const RECOVERY_TRANSIENT_ATTEMPTS = 3;
52
+ /**
53
+ * The first step after RESOURCE_OPEN was refused with AUTHORIZATION_FAILED:
54
+ * push our head (the server's answer tells where its chain ends), or not
55
+ * at all when our chain does not grant `principal` data/read or has nothing
56
+ * above the Genesis to push.
57
+ */
58
+ export declare function startRecovery(state: ControlState, records: readonly ControlRecord[], principal: PrincipalId): RecoveryStep;
59
+ /** The next step after the server committed `records[index]`. */
60
+ export declare function afterAccepted(records: readonly ControlRecord[], index: number): RecoveryStep;
61
+ /**
62
+ * The next step after CONTROL_HEAD_MISMATCH naming the server's head
63
+ * `head`: push the record above it; open again once when the server holds
64
+ * our head (another holder re-supplied it meanwhile; if the refusal is not
65
+ * caused by a loss, the second AUTHORIZATION_FAILED is final); stop at a
66
+ * head we do not know.
67
+ */
68
+ export declare function afterMismatch(records: readonly ControlRecord[], head: unknown): RecoveryStep;
69
+ //# sourceMappingURL=access-recovery.d.ts.map
@@ -0,0 +1,45 @@
1
+ import { bytesEqual } from "@openlfcp/core";
2
+ import { ABILITY, hasAbility } from "@openlfcp/wire";
3
+ /** Transient refusals of a recovery CONTROL_PUT, retried with backoff a bounded number of times. */
4
+ export const RECOVERY_TRANSIENT_CODES = new Set([
5
+ "RATE_LIMITED",
6
+ "INTERNAL_ERROR",
7
+ ]);
8
+ /** How many transient refusals a recovery tolerates before it ends. */
9
+ export const RECOVERY_TRANSIENT_ATTEMPTS = 3;
10
+ /**
11
+ * The first step after RESOURCE_OPEN was refused with AUTHORIZATION_FAILED:
12
+ * push our head (the server's answer tells where its chain ends), or not
13
+ * at all when our chain does not grant `principal` data/read or has nothing
14
+ * above the Genesis to push.
15
+ */
16
+ export function startRecovery(state, records, principal) {
17
+ if (!hasAbility(state, principal, ABILITY.DATA_READ))
18
+ return final("not-granted");
19
+ if (records.length < 2)
20
+ return final("server-current");
21
+ return { kind: "push", index: records.length - 1 };
22
+ }
23
+ /** The next step after the server committed `records[index]`. */
24
+ export function afterAccepted(records, index) {
25
+ return index >= records.length - 1 ? { kind: "reopen" } : { kind: "push", index: index + 1 };
26
+ }
27
+ /**
28
+ * The next step after CONTROL_HEAD_MISMATCH naming the server's head
29
+ * `head`: push the record above it; open again once when the server holds
30
+ * our head (another holder re-supplied it meanwhile; if the refusal is not
31
+ * caused by a loss, the second AUTHORIZATION_FAILED is final); stop at a
32
+ * head we do not know.
33
+ */
34
+ export function afterMismatch(records, head) {
35
+ if (!(head instanceof Uint8Array))
36
+ return final("refused");
37
+ const at = records.findIndex((r) => bytesEqual(r.signed.id, head));
38
+ if (at < 0)
39
+ return final("unknown-head");
40
+ if (at === records.length - 1)
41
+ return { kind: "reopen" };
42
+ return { kind: "push", index: at + 1 };
43
+ }
44
+ const final = (reason) => ({ kind: "final", reason });
45
+ //# sourceMappingURL=access-recovery.js.map
@@ -0,0 +1,104 @@
1
+ import { type DataUnitId, type ResourceId } from "@openlfcp/core";
2
+ import type { LfcpStorage, StorageWrite } from "@openlfcp/storage";
3
+ import { type Receipt } from "./receipts.js";
4
+ /**
5
+ * The status of a committed batch (SDK-SECTIONS-INTEGRATION-01 §4.1,
6
+ * LFCP-02-026): which of its units the server accepted, and whether it was
7
+ * rejected. Kept next to the batch's receipt as a local mark, and written
8
+ * in the same storage transaction as the evidence that changes it (the
9
+ * ACK's dequeue), so a restart never loses an acceptance nor invents one.
10
+ * An index maps each unit of a receipt to its operation. A batch whose
11
+ * receipt was released before it was final stays here until it is final
12
+ * (receipts.ts).
13
+ */
14
+ /** §4.1. */
15
+ export type BatchStatusName = "saved" | "pending" | "accepted" | "evidence-unavailable" | "rejected"
16
+ /**
17
+ * LFCP-02-115: not accepted, and the server refuses the Resource in this
18
+ * session (e.g. AUTHORIZATION_FAILED on RESOURCE_OPEN): it cannot be
19
+ * accepted there now. Not a refusal of the content; the work is kept.
20
+ */
21
+ | "blocked";
22
+ /** §4.1, §5 `batch`: a committed batch and the evidence about its units. */
23
+ export interface BatchStatus {
24
+ readonly operationId: string;
25
+ readonly status: BatchStatusName;
26
+ readonly unitIds: readonly DataUnitId[];
27
+ /** The units a correlated durable ACK from a durability-2 server on the route set named (§4.1). */
28
+ readonly acceptedUnitIds: readonly DataUnitId[];
29
+ readonly affectedNodeIds: readonly string[];
30
+ /** A terminal NACK of one of its units. */
31
+ readonly rejection?: {
32
+ readonly code: string;
33
+ readonly unitIds: readonly DataUnitId[];
34
+ };
35
+ }
36
+ /** The durability (§37) an ACK must establish to be acceptance evidence (§4.1). */
37
+ export declare const ACCEPTANCE_DURABILITY = 2n;
38
+ interface Stored {
39
+ /** Units with acceptance evidence (hex). */
40
+ readonly accepted: readonly string[];
41
+ /** Units acknowledged without it: not durable, below durability 2, or off the route set (hex). */
42
+ readonly unconfirmed: readonly string[];
43
+ readonly rejection?: {
44
+ readonly code: string;
45
+ readonly unitIds: readonly string[];
46
+ };
47
+ }
48
+ /** The index writes of a new receipt (in the commit's transaction). */
49
+ export declare function receiptIndexWrites(resource: ResourceId, receipt: Receipt): StorageWrite[];
50
+ /**
51
+ * The writes of releasing `receipt` (with its receipt mark's removal): a
52
+ * final batch's status and index go; a batch not final yet is kept,
53
+ * reported until it is final.
54
+ */
55
+ export declare function releaseWrites(storage: Pick<LfcpStorage, "localMarks">, resource: ResourceId, receipt: Receipt): Promise<StorageWrite[]>;
56
+ /**
57
+ * The writes that forget a released batch still kept for its status, when
58
+ * a new commit takes its operation ID (the new batch replaces it).
59
+ */
60
+ export declare function replaceReleasedWrites(storage: Pick<LfcpStorage, "localMarks">, resource: ResourceId, operationId: string): Promise<StorageWrite[]>;
61
+ /** The writes that forget a released receipt's status and index. */
62
+ export declare function releaseStatusWrites(resource: ResourceId, receipt: Receipt): StorageWrite[];
63
+ /**
64
+ * §4.1: the status of a receipt's batch. `readyDurability` is the current
65
+ * session's READY durability, or null without a session.
66
+ */
67
+ export declare function deriveStatus(receipt: Receipt, s: Stored, readyDurability: bigint | null, refused?: boolean): BatchStatus;
68
+ /** The status of one operation's batch; undefined without a receipt. */
69
+ export declare function batchStatus(storage: Pick<LfcpStorage, "localMarks">, resource: ResourceId, operationId: string, readyDurability: bigint | null, refused?: boolean): Promise<BatchStatus | undefined>;
70
+ /** Every receipt's batch of a Resource, and every released batch not final yet, by operation ID. */
71
+ export declare function batchStatuses(storage: Pick<LfcpStorage, "localMarks">, resource: ResourceId, readyDurability: bigint | null, refused?: boolean): Promise<BatchStatus[]>;
72
+ /** What happened to units of receipts: how their batches' stored status changes. */
73
+ export type UnitEvidence =
74
+ /** Acceptance evidence (§4.1). */
75
+ {
76
+ readonly kind: "accepted";
77
+ }
78
+ /** Acknowledged without acceptance evidence. */
79
+ | {
80
+ readonly kind: "unconfirmed";
81
+ }
82
+ /** A terminal NACK. */
83
+ | {
84
+ readonly kind: "rejected";
85
+ readonly code: string;
86
+ }
87
+ /** Offered again after the server lost them (§4.2): no longer accepted. */
88
+ | {
89
+ readonly kind: "reoffered";
90
+ };
91
+ /**
92
+ * The writes recording `evidence` for `units` (hex), and the operations
93
+ * whose stored status changed with the units that changed in each. Units
94
+ * of no receipt are ignored. A released batch that becomes final is
95
+ * forgotten in the same writes; its final status is in `finals`, since it
96
+ * cannot be read back. Pure apart from reading the marks.
97
+ */
98
+ export declare function recordEvidence(storage: Pick<LfcpStorage, "localMarks">, resource: ResourceId, units: readonly string[], evidence: UnitEvidence): Promise<{
99
+ readonly writes: StorageWrite[];
100
+ readonly changed: ReadonlyMap<string, readonly string[]>;
101
+ readonly finals: ReadonlyMap<string, BatchStatus>;
102
+ }>;
103
+ export {};
104
+ //# sourceMappingURL=batch-status.d.ts.map
@@ -0,0 +1,171 @@
1
+ import { dataUnitId, fromHex, toHex } from "@openlfcp/core";
2
+ import { receiptOf, receiptsOf, releasedReceiptOf, releasedReceiptsOf, releasedWrite, } from "./receipts.js";
3
+ /** The durability (§37) an ACK must establish to be acceptance evidence (§4.1). */
4
+ export const ACCEPTANCE_DURABILITY = 2n;
5
+ const EMPTY = { accepted: [], unconfirmed: [] };
6
+ const statusKey = (resource, operationId) => `batch-status:${toHex(resource)}:${operationId}`;
7
+ const unitKey = (resource, unit) => `receipt-unit:${toHex(resource)}:${unit}`;
8
+ /** The index writes of a new receipt (in the commit's transaction). */
9
+ export function receiptIndexWrites(resource, receipt) {
10
+ return receipt.unitIds.map((u) => ({
11
+ op: "put-local-mark",
12
+ key: unitKey(resource, toHex(u)),
13
+ value: receipt.operationId,
14
+ }));
15
+ }
16
+ /** Accepted or rejected: nothing more will happen to the batch (§4.1). */
17
+ function isFinal(receipt, s) {
18
+ return s.rejection !== undefined || receipt.unitIds.every((u) => s.accepted.includes(toHex(u)));
19
+ }
20
+ /**
21
+ * The writes of releasing `receipt` (with its receipt mark's removal): a
22
+ * final batch's status and index go; a batch not final yet is kept,
23
+ * reported until it is final.
24
+ */
25
+ export async function releaseWrites(storage, resource, receipt) {
26
+ const s = await stored(storage, resource, receipt.operationId);
27
+ return isFinal(receipt, s)
28
+ ? releaseStatusWrites(resource, receipt)
29
+ : [releasedWrite(resource, receipt.operationId, receipt)];
30
+ }
31
+ /**
32
+ * The writes that forget a released batch still kept for its status, when
33
+ * a new commit takes its operation ID (the new batch replaces it).
34
+ */
35
+ export async function replaceReleasedWrites(storage, resource, operationId) {
36
+ const released = await releasedReceiptOf(storage, resource, operationId);
37
+ return released === undefined
38
+ ? []
39
+ : [releasedWrite(resource, operationId, null), ...releaseStatusWrites(resource, released)];
40
+ }
41
+ /** The writes that forget a released receipt's status and index. */
42
+ export function releaseStatusWrites(resource, receipt) {
43
+ return [
44
+ { op: "put-local-mark", key: statusKey(resource, receipt.operationId), value: null },
45
+ ...receipt.unitIds.map((u) => ({
46
+ op: "put-local-mark",
47
+ key: unitKey(resource, toHex(u)),
48
+ value: null,
49
+ })),
50
+ ];
51
+ }
52
+ async function stored(storage, resource, operationId) {
53
+ const raw = await storage.localMarks.get(statusKey(resource, operationId));
54
+ return raw === undefined ? EMPTY : JSON.parse(raw);
55
+ }
56
+ /**
57
+ * §4.1: the status of a receipt's batch. `readyDurability` is the current
58
+ * session's READY durability, or null without a session.
59
+ */
60
+ export function deriveStatus(receipt, s, readyDurability, refused = false) {
61
+ const units = receipt.unitIds.map((u) => toHex(u));
62
+ const accepted = units.filter((u) => s.accepted.includes(u));
63
+ const status = s.rejection !== undefined
64
+ ? "rejected"
65
+ : units.length === 0
66
+ ? "saved"
67
+ : accepted.length === units.length
68
+ ? "accepted"
69
+ : refused
70
+ ? "blocked"
71
+ : units.some((u) => s.unconfirmed.includes(u) && !s.accepted.includes(u)) ||
72
+ (readyDurability !== null && readyDurability < ACCEPTANCE_DURABILITY)
73
+ ? "evidence-unavailable"
74
+ : "pending";
75
+ const ids = (hex) => Object.freeze(hex.map((h) => dataUnitId(fromHex(h))));
76
+ return Object.freeze({
77
+ operationId: receipt.operationId,
78
+ status,
79
+ unitIds: receipt.unitIds,
80
+ acceptedUnitIds: ids(accepted),
81
+ affectedNodeIds: receipt.affectedNodeIds,
82
+ ...(s.rejection === undefined
83
+ ? {}
84
+ : {
85
+ rejection: Object.freeze({ code: s.rejection.code, unitIds: ids(s.rejection.unitIds) }),
86
+ }),
87
+ });
88
+ }
89
+ /** The status of one operation's batch; undefined without a receipt. */
90
+ export async function batchStatus(storage, resource, operationId, readyDurability, refused = false) {
91
+ const receipt = (await receiptOf(storage, resource, operationId)) ??
92
+ (await releasedReceiptOf(storage, resource, operationId));
93
+ if (receipt === undefined)
94
+ return undefined;
95
+ return deriveStatus(receipt, await stored(storage, resource, operationId), readyDurability, refused);
96
+ }
97
+ /** Every receipt's batch of a Resource, and every released batch not final yet, by operation ID. */
98
+ export async function batchStatuses(storage, resource, readyDurability, refused = false) {
99
+ const out = [];
100
+ const held = await receiptsOf(storage, resource);
101
+ const ids = new Set(held.map((r) => r.operationId));
102
+ const released = (await releasedReceiptsOf(storage, resource)).filter((r) => !ids.has(r.operationId));
103
+ for (const receipt of [...held, ...released])
104
+ out.push(deriveStatus(receipt, await stored(storage, resource, receipt.operationId), readyDurability, refused));
105
+ return out.sort((a, b) => (a.operationId < b.operationId ? -1 : 1));
106
+ }
107
+ /**
108
+ * The writes recording `evidence` for `units` (hex), and the operations
109
+ * whose stored status changed with the units that changed in each. Units
110
+ * of no receipt are ignored. A released batch that becomes final is
111
+ * forgotten in the same writes; its final status is in `finals`, since it
112
+ * cannot be read back. Pure apart from reading the marks.
113
+ */
114
+ export async function recordEvidence(storage, resource, units, evidence) {
115
+ const byOperation = new Map();
116
+ for (const u of new Set(units)) {
117
+ const op = await storage.localMarks.get(unitKey(resource, u));
118
+ if (op !== undefined)
119
+ byOperation.set(op, [...(byOperation.get(op) ?? []), u]);
120
+ }
121
+ const writes = [];
122
+ const changed = new Map();
123
+ const finals = new Map();
124
+ for (const [op, mine] of byOperation) {
125
+ const s = await stored(storage, resource, op);
126
+ let next = s;
127
+ let touched = [];
128
+ switch (evidence.kind) {
129
+ case "accepted":
130
+ touched = mine.filter((u) => !s.accepted.includes(u));
131
+ next = { ...s, accepted: [...s.accepted, ...touched] };
132
+ break;
133
+ case "unconfirmed":
134
+ touched = mine.filter((u) => !s.accepted.includes(u) && !s.unconfirmed.includes(u));
135
+ next = { ...s, unconfirmed: [...s.unconfirmed, ...touched] };
136
+ break;
137
+ case "rejected":
138
+ if (s.rejection !== undefined)
139
+ break;
140
+ touched = mine;
141
+ next = { ...s, rejection: { code: evidence.code, unitIds: mine } };
142
+ break;
143
+ case "reoffered":
144
+ touched = mine.filter((u) => s.accepted.includes(u) || s.unconfirmed.includes(u));
145
+ next = {
146
+ ...s,
147
+ accepted: s.accepted.filter((u) => !mine.includes(u)),
148
+ unconfirmed: s.unconfirmed.filter((u) => !mine.includes(u)),
149
+ };
150
+ break;
151
+ }
152
+ if (touched.length === 0)
153
+ continue;
154
+ changed.set(op, touched);
155
+ const released = (await receiptOf(storage, resource, op)) === undefined
156
+ ? await releasedReceiptOf(storage, resource, op)
157
+ : undefined;
158
+ if (released !== undefined && isFinal(released, next)) {
159
+ finals.set(op, deriveStatus(released, next, null));
160
+ writes.push(releasedWrite(resource, op, null), ...releaseStatusWrites(resource, released));
161
+ continue;
162
+ }
163
+ writes.push({
164
+ op: "put-local-mark",
165
+ key: statusKey(resource, op),
166
+ value: JSON.stringify(next),
167
+ });
168
+ }
169
+ return { writes, changed, finals };
170
+ }
171
+ //# sourceMappingURL=batch-status.js.map
@@ -0,0 +1,54 @@
1
+ import { type ResourceId } from "@openlfcp/core";
2
+ import { type ResourceDEK } from "@openlfcp/crypto";
3
+ import { type LfcpStorage, type SecretStore } from "@openlfcp/storage";
4
+ /**
5
+ * The journal of a capability claim in flight (LFCP-02-110).
6
+ *
7
+ * A one-time invitation is spent when the coordinator commits the claim
8
+ * (LFCP-WIRE-01 §18.1). If the CONTROL_PUT's answer is lost, the claimant
9
+ * cannot tell whether it was: retrying with the link fails once the
10
+ * invitation is used up, and the claimant would be left without the chain
11
+ * and the DEK. So before every claim is sent, the claimant journals it:
12
+ * the claim record, the head it expects, the validated chain up to that
13
+ * head, and the epochs whose DEKs it holds (the DEKs themselves go to the
14
+ * SecretStore first, LFCP-034). After a lost answer, the same record is
15
+ * sent again: a coordinator answers a record it has already committed with
16
+ * the same ACK (§47, §70), so a claim that landed completes without
17
+ * spending the invitation twice, and one that did not is refused or meets
18
+ * a moved head.
19
+ *
20
+ * The journal is a local mark (`invitation-claim:<resource hex>`). It holds
21
+ * no secret: the claim and the chain are signed public records.
22
+ */
23
+ /** A journaled claim, as JSON in a local mark. Byte strings are hex. */
24
+ export interface ClaimJournal {
25
+ readonly v: 1;
26
+ readonly resourceId: string;
27
+ /** The invitation grant the claim spends. */
28
+ readonly invitationGrantId: string;
29
+ /** The claimant's Principal ID. */
30
+ readonly claimant: string;
31
+ /** The signed CAPABILITY_CLAIM record. */
32
+ readonly claim: string;
33
+ /** The head the claim was sent against (its prev_control_id). */
34
+ readonly expectedHead: string;
35
+ /** The validated Control Records up to that head, in order. */
36
+ readonly chain: readonly string[];
37
+ /** The epochs whose DEK is in the SecretStore, as decimal strings. */
38
+ readonly epochs: readonly string[];
39
+ /** The claimed abilities, as decimal strings. */
40
+ readonly abilities: readonly string[];
41
+ }
42
+ type JournalStorage = Pick<LfcpStorage, "localMarks" | "commit">;
43
+ /** Writes the DEKs to the SecretStore, then the journal (secret first, LFCP-034). */
44
+ export declare function writeClaimJournal(storage: JournalStorage, secrets: SecretStore, resource: ResourceId, journal: ClaimJournal, deks: ReadonlyMap<string, ResourceDEK>, exportDek: (dek: ResourceDEK) => Uint8Array): Promise<void>;
45
+ /** The journaled claim of `resource`, if any. */
46
+ export declare function readClaimJournal(storage: Pick<LfcpStorage, "localMarks">, resource: ResourceId): Promise<ClaimJournal | undefined>;
47
+ /** Removes the journal of `resource` once its claim's outcome is known. */
48
+ export declare function clearClaimJournal(storage: JournalStorage, resource: ResourceId): Promise<void>;
49
+ /** Every journaled claim, e.g. to resume them after a restart (resumeInvitationClaim). */
50
+ export declare function pendingInvitationClaims(storage: Pick<LfcpStorage, "localMarks">): Promise<readonly ClaimJournal[]>;
51
+ /** The journaled epochs' DEKs, from the SecretStore. */
52
+ export declare function journaledDeks(secrets: SecretStore, resource: ResourceId, journal: ClaimJournal): Promise<Map<string, ResourceDEK>>;
53
+ export {};
54
+ //# sourceMappingURL=claim-journal.d.ts.map
@@ -0,0 +1,52 @@
1
+ import { dataEpoch, toHex } from "@openlfcp/core";
2
+ import { importResourceDEK } from "@openlfcp/crypto";
3
+ import { dekSecretRef } from "@openlfcp/storage";
4
+ const PREFIX = "invitation-claim:";
5
+ const key = (resource) => `${PREFIX}${toHex(resource)}`;
6
+ /** Writes the DEKs to the SecretStore, then the journal (secret first, LFCP-034). */
7
+ export async function writeClaimJournal(storage, secrets, resource, journal, deks, exportDek) {
8
+ for (const [epoch, dek] of deks) {
9
+ const bytes = exportDek(dek);
10
+ try {
11
+ await secrets.put(dekSecretRef(resource, dataEpoch(BigInt(epoch))), bytes);
12
+ }
13
+ finally {
14
+ bytes.fill(0);
15
+ }
16
+ }
17
+ const result = await storage.commit([
18
+ { op: "put-local-mark", key: key(resource), value: JSON.stringify(journal) },
19
+ ]);
20
+ if (!result.ok)
21
+ throw new Error(`the claim journal was not stored: ${result.reason}`);
22
+ }
23
+ /** The journaled claim of `resource`, if any. */
24
+ export async function readClaimJournal(storage, resource) {
25
+ const value = await storage.localMarks.get(key(resource));
26
+ return value === undefined ? undefined : JSON.parse(value);
27
+ }
28
+ /** Removes the journal of `resource` once its claim's outcome is known. */
29
+ export async function clearClaimJournal(storage, resource) {
30
+ await storage.commit([{ op: "put-local-mark", key: key(resource), value: null }]);
31
+ }
32
+ /** Every journaled claim, e.g. to resume them after a restart (resumeInvitationClaim). */
33
+ export async function pendingInvitationClaims(storage) {
34
+ return (await storage.localMarks.list(PREFIX)).map((m) => JSON.parse(m.value));
35
+ }
36
+ /** The journaled epochs' DEKs, from the SecretStore. */
37
+ export async function journaledDeks(secrets, resource, journal) {
38
+ const deks = new Map();
39
+ for (const epoch of journal.epochs) {
40
+ const bytes = await secrets.get(dekSecretRef(resource, dataEpoch(BigInt(epoch))));
41
+ if (bytes === undefined)
42
+ continue;
43
+ try {
44
+ deks.set(epoch, importResourceDEK(bytes));
45
+ }
46
+ finally {
47
+ bytes.fill(0);
48
+ }
49
+ }
50
+ return deks;
51
+ }
52
+ //# sourceMappingURL=claim-journal.js.map
@@ -1,3 +1,4 @@
1
+ import { LfcpError } from "@openlfcp/core";
1
2
  import { type AnyMessage, type ClientConnectionState, type ClientHandshakeConfig, type ReadySession } from "@openlfcp/wire";
2
3
  /**
3
4
  * One LFCP connection over a WebSocket (LFCP-WIRE-01 §30, §31, §34-§38,
@@ -36,6 +37,14 @@ export interface WebSocketLike {
36
37
  export type WebSocketFactory = (url: string, protocols: readonly string[]) => WebSocketLike;
37
38
  /** The platform WebSocket (globalThis.WebSocket); throws if the runtime has none. */
38
39
  export declare function platformWebSocket(): WebSocketFactory;
40
+ /**
41
+ * A send on a connection that is not READY: it closed while the caller was
42
+ * working (an ordinary network loss, not a fault of the caller's). Nothing
43
+ * was sent. Still an LfcpError UNSUPPORTED_VALUE, as before.
44
+ */
45
+ export declare class ConnectionNotReady extends LfcpError {
46
+ constructor();
47
+ }
39
48
  /** §30: the only LFCP subprotocol. */
40
49
  export declare const LFCP_SUBPROTOCOL = "lfcp-1";
41
50
  export interface ConnectionEvents {
@@ -7,6 +7,17 @@ export function platformWebSocket() {
7
7
  throw new LfcpError("UNSUPPORTED_VALUE", "this runtime has no global WebSocket; inject a factory");
8
8
  return (url, protocols) => new ctor(url, [...protocols]);
9
9
  }
10
+ /**
11
+ * A send on a connection that is not READY: it closed while the caller was
12
+ * working (an ordinary network loss, not a fault of the caller's). Nothing
13
+ * was sent. Still an LfcpError UNSUPPORTED_VALUE, as before.
14
+ */
15
+ export class ConnectionNotReady extends LfcpError {
16
+ constructor() {
17
+ super("UNSUPPORTED_VALUE", "the connection is not READY");
18
+ this.name = "ConnectionNotReady";
19
+ }
20
+ }
10
21
  /** §30: the only LFCP subprotocol. */
11
22
  export const LFCP_SUBPROTOCOL = "lfcp-1";
12
23
  const BYTES = (data) => {
@@ -147,7 +158,7 @@ export class LfcpConnection {
147
158
  /** Sends already encoded message bytes (e.g. an OutboundMessage) on a READY session. */
148
159
  sendEncoded(bytes) {
149
160
  if (this.#state !== "READY" || this.#socket === null)
150
- throw new LfcpError("UNSUPPORTED_VALUE", "the connection is not READY");
161
+ throw new ConnectionNotReady();
151
162
  if (bytes.length > this.#limit())
152
163
  throw new LfcpError("MESSAGE_TOO_LARGE", `${bytes.length} bytes exceed the session limit ${this.#limit()}`);
153
164
  this.#socket.send(bytes);
package/dist/data-unit.js CHANGED
@@ -23,10 +23,12 @@ export async function createDataUnit(options) {
23
23
  const plaintext = options.profile.encode(options.value);
24
24
  const seq = await options.sequences.reserveNext(atHead.resourceId, actor);
25
25
  options.guard?.claim(atHead.resourceId, actor, seq);
26
- if ((seq === 1n) !== (options.previousUnitId === null))
27
- throw new LfcpError("INVALID_STRUCTURE", seq === 1n
28
- ? "the actor's first unit (sequence 1) must have a null previous unit (§26.2)"
29
- : `sequence ${seq} needs the actor's unit at sequence ${seq - 1n} as its previous unit (§26.2)`);
26
+ // §26.2: sequence 1 names no previous unit. A later one names the writer's
27
+ // latest own unit still accepted, or none: after a first unit that went
28
+ // stale (a Key Epoch cut it off) or was abandoned, the unit at 2 has a null
29
+ // previous, and a receiver that accepted nothing of the actor links it.
30
+ if (seq === 1n && options.previousUnitId !== null)
31
+ throw new LfcpError("INVALID_STRUCTURE", "the actor's first unit (sequence 1) must have a null previous unit (§26.2)");
30
32
  const sealed = sealDataUnit({
31
33
  resourceId: atHead.resourceId,
32
34
  dataEpoch: epoch,
package/dist/index.d.ts CHANGED
@@ -1,15 +1,23 @@
1
1
  /** Name of this package. */
2
2
  export declare const PACKAGE = "@openlfcp/client";
3
3
  export { type ApplyOutcome, type DataProfileHandler, DataUnitApplier, type DataUnitApplierOptions, type EpochReconciliation, type EquivocationOutcome, type ExcludedUnit, type ProfileApplyResult, type ProfileDiagnostic, type ProfileExcludeResult, type ProfileUnit, } from "./apply.js";
4
+ export { ACCEPTANCE_DURABILITY, type BatchStatus, type BatchStatusName, batchStatus, batchStatuses, } from "./batch-status.js";
4
5
  export { type CheckpointSource, ProfileCheckpointer } from "./checkpoint.js";
5
- export { type ConnectionEvents, type ConnectionOptions, LFCP_SUBPROTOCOL, LfcpConnection, platformWebSocket, type WebSocketFactory, type WebSocketLike, } from "./connection.js";
6
+ export { type ClaimJournal, pendingInvitationClaims } from "./claim-journal.js";
7
+ export { type ConnectionEvents, ConnectionNotReady, type ConnectionOptions, LFCP_SUBPROTOCOL, LfcpConnection, platformWebSocket, type WebSocketFactory, type WebSocketLike, } from "./connection.js";
6
8
  export { type CreateDataUnitOptions, type CreatedDataUnit, createDataUnit } from "./data-unit.js";
7
9
  export { EngineGuard, type EngineItem, isEngineTrap, type Suspicion, snapshotItem, unitItem, } from "./engine-guard.js";
8
- export { type AcceptedInvitation, type AcceptInvitationOptions, type AcceptInvitationProgress, type AcceptInvitationStage, acceptInvitation, type CreatedInvitation, type CreateInvitationOptions, createInvitation, DEFAULT_INVITATION_ABILITIES, InvitationLink, } from "./invite.js";
9
- export { type AckOutcome, type BlockedItem, exponentialBackoff, type NackOutcome, type OutboundMessage, OutboundQueue, type OutboundQueueOptions, type ResourceSyncState, type RetryPolicy, type RetryReason, resourceSyncState, type StaleOutboundUnit, snapshotFrontier, } from "./outbound.js";
10
- export { createQueuedSnapshot, outboundItem, queueControlRecord, queueKeyEpoch, queueKeyPackage, queueSnapshot, } from "./queue.js";
10
+ export { type AcceptedInvitation, type AcceptInvitationOptions, type AcceptInvitationProgress, type AcceptInvitationStage, abandonInvitationClaim, acceptInvitation, type CreatedInvitation, type CreateInvitationOptions, createInvitation, DEFAULT_INVITATION_ABILITIES, InvitationLink, type ResumeInvitationClaimOptions, resumeInvitationClaim, } from "./invite.js";
11
+ export { type AckedItem, type AckOutcome, type AckWrites, type BlockedItem, exponentialBackoff, type NackOutcome, type OutboundMessage, OutboundQueue, type OutboundQueueOptions, type ResourceSyncState, type RetryPolicy, type RetryReason, resourceSyncState, type StaleOutboundUnit, snapshotFrontier, } from "./outbound.js";
12
+ export { controlRecordWrites, createQueuedSnapshot, keyPackageWrites, outboundItem, queueControlRecord, queueKeyEpoch, queueKeyPackage, queueSnapshot, } from "./queue.js";
13
+ export { intentsHash, OperationIdReusedError, type Receipt, receiptOf, receiptsOf, releaseReceipt, } from "./receipts.js";
14
+ export { type ReproposeDropReason, type ReproposeOutcome, type ReproposeResult, reproposeControlRecords, } from "./repropose.js";
11
15
  export { type ResourcePhase, type ResourcePhaseEvent, resourcePhaseTransition, } from "./resource-state.js";
16
+ export { planRevocation, type RemainingPath, type RevocationPlan, type RevocationRefusal, RevocationRefused, } from "./revoke.js";
17
+ export { type CatchUp, type ReceivedFact, type ReceivedState, type ReofferReason, receivedState, type SectionState, type StatusEvent, type StatusSnapshot, } from "./section-status.js";
12
18
  export { type CreatedSnapshot, type CreateSnapshotOptions, createSnapshot } from "./snapshot.js";
13
- export { adoptStoredDeks, createQueuedDataUnit, dataUnitRow, dekResolver, latestAcceptedOwnUnit, loadControlChain, StoredSeenUnits, saveControlChain, saveControlConflict, } from "./storage.js";
14
- export { defaultReconnect, type ReconnectPolicy, type ResourceBinding, type ResourceRefusal, type SnapshotBinding, SyncClient, type SyncClientOptions, type SyncEvent, startSyncDriver, TERMINAL_RESOURCE_CODES, } from "./sync-client.js";
19
+ export { adoptStoredDeks, type CommitOperationOptions, commitOperation, createQueuedDataUnit, dataUnitRow, dekResolver, latestAcceptedOwnUnit, loadControlChain, localControlChain, StoredSeenUnits, saveControlChain, saveControlConflict, } from "./storage.js";
20
+ export { type CommitBinding, CommitRefusedError, defaultReconnect, type ReconnectPolicy, type ResourceBinding, type ResourceRefusal, type RevokeAccessResult, type SnapshotBinding, type StagedOperation, SyncClient, type SyncClientOptions, type SyncEvent, startSyncDriver, TERMINAL_RESOURCE_CODES, } from "./sync-client.js";
21
+ export { type Flushed, type Submitted, TypingCoalescer, type TypingCoalescerOptions, } from "./typing.js";
22
+ export { type AccessContext, type AccessState, accessState, type GrantPath, type IssuedInvitation, NotWritableError, type PendingControl, type ServerRefusal, UNKNOWN_ACCESS, type WriteAccess, type WriteDeniedReason, writeAccess, } from "./write-access.js";
15
23
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -1,15 +1,23 @@
1
1
  /** Name of this package. */
2
2
  export const PACKAGE = "@openlfcp/client";
3
3
  export { DataUnitApplier, } from "./apply.js";
4
+ export { ACCEPTANCE_DURABILITY, batchStatus, batchStatuses, } from "./batch-status.js";
4
5
  export { ProfileCheckpointer } from "./checkpoint.js";
5
- export { LFCP_SUBPROTOCOL, LfcpConnection, platformWebSocket, } from "./connection.js";
6
+ export { pendingInvitationClaims } from "./claim-journal.js";
7
+ export { ConnectionNotReady, LFCP_SUBPROTOCOL, LfcpConnection, platformWebSocket, } from "./connection.js";
6
8
  export { createDataUnit } from "./data-unit.js";
7
9
  export { EngineGuard, isEngineTrap, snapshotItem, unitItem, } from "./engine-guard.js";
8
- export { acceptInvitation, createInvitation, DEFAULT_INVITATION_ABILITIES, InvitationLink, } from "./invite.js";
10
+ export { abandonInvitationClaim, acceptInvitation, createInvitation, DEFAULT_INVITATION_ABILITIES, InvitationLink, resumeInvitationClaim, } from "./invite.js";
9
11
  export { exponentialBackoff, OutboundQueue, resourceSyncState, snapshotFrontier, } from "./outbound.js";
10
- export { createQueuedSnapshot, outboundItem, queueControlRecord, queueKeyEpoch, queueKeyPackage, queueSnapshot, } from "./queue.js";
12
+ export { controlRecordWrites, createQueuedSnapshot, keyPackageWrites, outboundItem, queueControlRecord, queueKeyEpoch, queueKeyPackage, queueSnapshot, } from "./queue.js";
13
+ export { intentsHash, OperationIdReusedError, receiptOf, receiptsOf, releaseReceipt, } from "./receipts.js";
14
+ export { reproposeControlRecords, } from "./repropose.js";
11
15
  export { resourcePhaseTransition, } from "./resource-state.js";
16
+ export { planRevocation, RevocationRefused, } from "./revoke.js";
17
+ export { receivedState, } from "./section-status.js";
12
18
  export { createSnapshot } from "./snapshot.js";
13
- export { adoptStoredDeks, createQueuedDataUnit, dataUnitRow, dekResolver, latestAcceptedOwnUnit, loadControlChain, StoredSeenUnits, saveControlChain, saveControlConflict, } from "./storage.js";
14
- export { defaultReconnect, SyncClient, startSyncDriver, TERMINAL_RESOURCE_CODES, } from "./sync-client.js";
19
+ export { adoptStoredDeks, commitOperation, createQueuedDataUnit, dataUnitRow, dekResolver, latestAcceptedOwnUnit, loadControlChain, localControlChain, StoredSeenUnits, saveControlChain, saveControlConflict, } from "./storage.js";
20
+ export { CommitRefusedError, defaultReconnect, SyncClient, startSyncDriver, TERMINAL_RESOURCE_CODES, } from "./sync-client.js";
21
+ export { TypingCoalescer, } from "./typing.js";
22
+ export { accessState, NotWritableError, UNKNOWN_ACCESS, writeAccess, } from "./write-access.js";
15
23
  //# sourceMappingURL=index.js.map