@openlfcp/client 0.2.0-beta.5 → 0.2.0-beta.7

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.
@@ -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/index.d.ts CHANGED
@@ -4,18 +4,19 @@ export { type ApplyOutcome, type DataProfileHandler, DataUnitApplier, type DataU
4
4
  export { ACCEPTANCE_DURABILITY, type BatchStatus, type BatchStatusName, batchStatus, batchStatuses, } from "./batch-status.js";
5
5
  export { type CheckpointSource, ProfileCheckpointer } from "./checkpoint.js";
6
6
  export { type ClaimJournal, pendingInvitationClaims } from "./claim-journal.js";
7
- export { type ConnectionEvents, type ConnectionOptions, LFCP_SUBPROTOCOL, LfcpConnection, platformWebSocket, type WebSocketFactory, type WebSocketLike, } from "./connection.js";
7
+ export { type ConnectionEvents, ConnectionNotReady, type ConnectionOptions, LFCP_SUBPROTOCOL, LfcpConnection, platformWebSocket, type WebSocketFactory, type WebSocketLike, } from "./connection.js";
8
8
  export { type CreateDataUnitOptions, type CreatedDataUnit, createDataUnit } from "./data-unit.js";
9
9
  export { EngineGuard, type EngineItem, isEngineTrap, type Suspicion, snapshotItem, unitItem, } from "./engine-guard.js";
10
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
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
12
  export { controlRecordWrites, createQueuedSnapshot, keyPackageWrites, outboundItem, queueControlRecord, queueKeyEpoch, queueKeyPackage, queueSnapshot, } from "./queue.js";
13
13
  export { intentsHash, OperationIdReusedError, type Receipt, receiptOf, receiptsOf, releaseReceipt, } from "./receipts.js";
14
+ export { type ReproposeDropReason, type ReproposeOutcome, type ReproposeResult, reproposeControlRecords, } from "./repropose.js";
14
15
  export { type ResourcePhase, type ResourcePhaseEvent, resourcePhaseTransition, } from "./resource-state.js";
15
16
  export { planRevocation, type RemainingPath, type RevocationPlan, type RevocationRefusal, RevocationRefused, } from "./revoke.js";
16
17
  export { type CatchUp, type ReceivedFact, type ReceivedState, type ReofferReason, receivedState, type SectionState, type StatusEvent, type StatusSnapshot, } from "./section-status.js";
17
18
  export { type CreatedSnapshot, type CreateSnapshotOptions, createSnapshot } from "./snapshot.js";
18
- export { adoptStoredDeks, type CommitOperationOptions, commitOperation, createQueuedDataUnit, dataUnitRow, dekResolver, latestAcceptedOwnUnit, loadControlChain, StoredSeenUnits, saveControlChain, saveControlConflict, } from "./storage.js";
19
+ export { adoptStoredDeks, type CommitOperationOptions, commitOperation, createQueuedDataUnit, dataUnitRow, dekResolver, latestAcceptedOwnUnit, loadControlChain, localControlChain, StoredSeenUnits, saveControlChain, saveControlConflict, } from "./storage.js";
19
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";
20
21
  export { type Flushed, type Submitted, TypingCoalescer, type TypingCoalescerOptions, } from "./typing.js";
21
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";
package/dist/index.js CHANGED
@@ -4,18 +4,19 @@ export { DataUnitApplier, } from "./apply.js";
4
4
  export { ACCEPTANCE_DURABILITY, batchStatus, batchStatuses, } from "./batch-status.js";
5
5
  export { ProfileCheckpointer } from "./checkpoint.js";
6
6
  export { pendingInvitationClaims } from "./claim-journal.js";
7
- export { LFCP_SUBPROTOCOL, LfcpConnection, platformWebSocket, } from "./connection.js";
7
+ export { ConnectionNotReady, LFCP_SUBPROTOCOL, LfcpConnection, platformWebSocket, } from "./connection.js";
8
8
  export { createDataUnit } from "./data-unit.js";
9
9
  export { EngineGuard, isEngineTrap, snapshotItem, unitItem, } from "./engine-guard.js";
10
10
  export { abandonInvitationClaim, acceptInvitation, createInvitation, DEFAULT_INVITATION_ABILITIES, InvitationLink, resumeInvitationClaim, } from "./invite.js";
11
11
  export { exponentialBackoff, OutboundQueue, resourceSyncState, snapshotFrontier, } from "./outbound.js";
12
12
  export { controlRecordWrites, createQueuedSnapshot, keyPackageWrites, outboundItem, queueControlRecord, queueKeyEpoch, queueKeyPackage, queueSnapshot, } from "./queue.js";
13
13
  export { intentsHash, OperationIdReusedError, receiptOf, receiptsOf, releaseReceipt, } from "./receipts.js";
14
+ export { reproposeControlRecords, } from "./repropose.js";
14
15
  export { resourcePhaseTransition, } from "./resource-state.js";
15
16
  export { planRevocation, RevocationRefused, } from "./revoke.js";
16
17
  export { receivedState, } from "./section-status.js";
17
18
  export { createSnapshot } from "./snapshot.js";
18
- export { adoptStoredDeks, commitOperation, createQueuedDataUnit, dataUnitRow, dekResolver, latestAcceptedOwnUnit, loadControlChain, StoredSeenUnits, saveControlChain, saveControlConflict, } from "./storage.js";
19
+ export { adoptStoredDeks, commitOperation, createQueuedDataUnit, dataUnitRow, dekResolver, latestAcceptedOwnUnit, loadControlChain, localControlChain, StoredSeenUnits, saveControlChain, saveControlConflict, } from "./storage.js";
19
20
  export { CommitRefusedError, defaultReconnect, SyncClient, startSyncDriver, TERMINAL_RESOURCE_CODES, } from "./sync-client.js";
20
21
  export { TypingCoalescer, } from "./typing.js";
21
22
  export { accessState, NotWritableError, UNKNOWN_ACCESS, writeAccess, } from "./write-access.js";
package/dist/invite.d.ts CHANGED
@@ -49,7 +49,7 @@ export declare class InvitationLink {
49
49
  /** The usual invitation grant (§18): data/read, data/write, invite/claim. */
50
50
  export declare const DEFAULT_INVITATION_ABILITIES: readonly bigint[];
51
51
  export interface CreateInvitationOptions {
52
- readonly storage: Pick<LfcpStorage, "control" | "commit">;
52
+ readonly storage: Pick<LfcpStorage, "control" | "commit" | "outbound">;
53
53
  readonly resourceId: ResourceId;
54
54
  /** The issuer: needs the authority to grant the abilities (§17.2) and key/distribute (§25.2). */
55
55
  readonly inviter: Signer;
package/dist/invite.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { bytesEqual, fromHex, hash32, LfcpError, toHex, } from "@openlfcp/core";
2
2
  import { dekCommitment, exportSecretKeyBytes, InvitationSecret, } from "@openlfcp/crypto";
3
3
  import { dekSecretRef } from "@openlfcp/storage";
4
- import { ABILITY, assembleInviteUri, createMessage, ERROR_CODE, invitationPrincipal, parseControlRecord, parseInviteUri, parseKeyPackage, receiveKeyPackage, sealKeyPackage, signControlRecord, validateControlChain, verifyInvitationSecret, verifyKeyPackage, } from "@openlfcp/wire";
4
+ import { ABILITY, assembleInviteUri, createMessage, ERROR_CODE, invitationPrincipal, isGrantActive, parseControlRecord, parseInviteUri, parseKeyPackage, receiveKeyPackage, sealKeyPackage, signControlRecord, validateControlChain, verifyInvitationSecret, verifyKeyPackage, } from "@openlfcp/wire";
5
5
  import { clearClaimJournal, journaledDeks, readClaimJournal, writeClaimJournal, } from "./claim-journal.js";
6
6
  import { LfcpConnection } from "./connection.js";
7
7
  import { queueControlRecord, queueKeyPackage } from "./queue.js";
8
- import { loadControlChain, saveControlChain } from "./storage.js";
8
+ import { loadControlChain, localControlChain, saveControlChain } from "./storage.js";
9
9
  /**
10
10
  * Link invitations and the one-time capability claim (LFCP-WIRE-01 §18,
11
11
  * §18.1, §18.2, §25.2, §73; LFCP-053), built on the invitation codec of
@@ -69,6 +69,28 @@ export const DEFAULT_INVITATION_ABILITIES = Object.freeze([
69
69
  const fail = (code, why) => {
70
70
  throw new LfcpError(code, why);
71
71
  };
72
+ /**
73
+ * Resolves the invitation grant the claimant will reference (§18.1, §18.2).
74
+ * The link names the record the inviter first queued; a re-propose on a
75
+ * moved head gives the grant a new record ID while keeping the same
76
+ * Invitation Principal and claim parameters, so the grant is looked up by
77
+ * its ID first and, failing that, by its subject: the single active
78
+ * invitation grant (invite/claim) whose subject is the secret's Invitation
79
+ * Principal. Returns the on-chain grant ID, the grant and the claim signer.
80
+ * Throws MISSING_DEPENDENCY when no such grant is on the chain.
81
+ */
82
+ function resolveInvitationGrant(state, linkGrantId, secret) {
83
+ const principal = invitationPrincipal(secret);
84
+ const byId = state.grants.get(toHex(linkGrantId));
85
+ const grant = byId ??
86
+ [...state.grants.values()].find((g) => bytesEqual(g.subject, principal.principalId) &&
87
+ g.abilities.includes(ABILITY.INVITE_CLAIM) &&
88
+ isGrantActive(state, g.id)) ??
89
+ fail("MISSING_DEPENDENCY", "the invitation grant is not on the chain");
90
+ const grantId = grant.id;
91
+ const claimSigner = verifyInvitationSecret(state, grantId, secret);
92
+ return Object.freeze({ grantId, grant, claimSigner });
93
+ }
72
94
  /**
73
95
  * The inviter's side (§18, §73). The grant must validate on the stored
74
96
  * chain and the package must pass §25.2 at the grant's head, both checked
@@ -78,7 +100,11 @@ const fail = (code, why) => {
78
100
  */
79
101
  export async function createInvitation(options) {
80
102
  const R = options.resourceId;
81
- const stored = await loadControlChain(options.storage, R);
103
+ // §47: build the grant on the LOCAL head (the committed chain extended by
104
+ // this client's own still-queued records), so an invitation made before an
105
+ // earlier one is acknowledged chains on it instead of colliding on the
106
+ // committed head (CONTROL_HEAD_MISMATCH).
107
+ const stored = await localControlChain(options.storage, R);
82
108
  const view = stored?.kind === "linear"
83
109
  ? stored
84
110
  : fail("MISSING_DEPENDENCY", "no valid local Control Chain for the Resource");
@@ -290,10 +316,16 @@ export async function acceptInvitation(options) {
290
316
  if (heads.length !== 1)
291
317
  throw new Unavailable("the Resource has no single Control Head: no claim is made (§42)");
292
318
  let chain = await fetchChain(s, R, [], { seq: heads[0].seq });
293
- // §18.2: the secret's Principal must be the grant's subject before the secret is used further.
294
- const claimSigner = verifyInvitationSecret(chain.state, invitation.grantId, secret);
295
- const grant = chain.state.grants.get(toHex(invitation.grantId)) ??
296
- fail("MISSING_DEPENDENCY", "the invitation grant is not on the chain");
319
+ // §18.2: the secret's Principal must be the grant's subject before the
320
+ // secret is used further. The link names the grant the inviter first
321
+ // queued; if that grant was re-proposed on a moved head (a new record ID,
322
+ // the same Invitation Principal and claim parameters), it is resolved by
323
+ // its subject, so a link copied before the re-propose still joins (§18.1).
324
+ const resolved = resolveInvitationGrant(chain.state, invitation.grantId, secret);
325
+ const claimSigner = resolved.claimSigner;
326
+ const grant = resolved.grant;
327
+ // The claim references the grant that is actually on the chain.
328
+ const grantId = resolved.grantId;
297
329
  // §27: a profile the caller cannot open is refused before the DEK and the claim.
298
330
  if (options.dataProfiles !== undefined &&
299
331
  !options.dataProfiles.includes(chain.state.dataProfile))
@@ -338,7 +370,7 @@ export async function acceptInvitation(options) {
338
370
  progress("claiming-capability", attempts.length + 1);
339
371
  const claim = signControlRecord({ resourceId: R, controlSeq: chain.state.seq + 1n, prevControlId: chain.state.head }, {
340
372
  type: "CAPABILITY_CLAIM",
341
- invitationGrantId: invitation.grantId,
373
+ invitationGrantId: grantId,
342
374
  claimant: options.claimant.signer.descriptor,
343
375
  abilities,
344
376
  }, claimSigner);
@@ -239,6 +239,13 @@ export declare class OutboundQueue {
239
239
  * are sent as the same bytes.
240
240
  */
241
241
  reconcileEpochs(view: ControlView): Promise<StaleOutboundUnit[]>;
242
+ /**
243
+ * Drops all in-memory tracking of these items, which a re-proposal has
244
+ * replaced with re-authored ones in storage (repropose.ts). The items are
245
+ * already dequeued in that same transaction; this clears any stale flight,
246
+ * solo or awaiting-control state so the queue never looks for them again.
247
+ */
248
+ release(ids: readonly Hash32[]): void;
242
249
  /** Removes a blocked item once the application has dealt with it (e.g. re-proposed or re-applied). */
243
250
  discard(itemId: Hash32): Promise<void>;
244
251
  }
package/dist/outbound.js CHANGED
@@ -563,6 +563,16 @@ export class OutboundQueue {
563
563
  await this.#commit(writes);
564
564
  return out;
565
565
  }
566
+ /**
567
+ * Drops all in-memory tracking of these items, which a re-proposal has
568
+ * replaced with re-authored ones in storage (repropose.ts). The items are
569
+ * already dequeued in that same transaction; this clears any stale flight,
570
+ * solo or awaiting-control state so the queue never looks for them again.
571
+ */
572
+ release(ids) {
573
+ for (const id of ids)
574
+ this.#forget(id);
575
+ }
566
576
  /** Removes a blocked item once the application has dealt with it (e.g. re-proposed or re-applied). */
567
577
  async discard(itemId) {
568
578
  const item = await this.#storage.outbound.get(itemId);
@@ -0,0 +1,81 @@
1
+ import { type ControlRecordId, type Hash32, type ResourceId } from "@openlfcp/core";
2
+ import { type LfcpStorage, type SecretStore } from "@openlfcp/storage";
3
+ import { type Signer } from "@openlfcp/wire";
4
+ import type { OutboundQueue } from "./outbound.js";
5
+ /**
6
+ * Re-proposing this client's own Control Records that the coordinator
7
+ * refused with CONTROL_HEAD_MISMATCH (LFCP-WIRE-01 §21, §47; the LFCP-036
8
+ * design). A record proposed on a head that another writer moved is blocked
9
+ * "repropose" by the OutboundQueue; without this it — and every object that
10
+ * depends on it, such as a grant's invitation Key Package — would wait
11
+ * forever. Here each blocked record is re-authored on the current local
12
+ * head, keeping its intent, re-signed with a new record ID, and its
13
+ * dependent Key Packages are re-sealed to the new head; a record that is no
14
+ * longer authorized or meaningful is dropped with a typed outcome instead.
15
+ *
16
+ * Only the GRANT and REVOKE intents are re-authored: a revocation/rotation
17
+ * is serialized at the author (SyncClient.revokeAccess refuses while a
18
+ * Control Record is pending), so a KEY_EPOCH never reaches re-proposal; one
19
+ * that somehow does is dropped (its epoch number, DEK commitment and final
20
+ * frontier are bound to the head it was built on) and surfaced, so the
21
+ * application re-runs the high-level operation.
22
+ *
23
+ * Re-authoring is idempotent (a grant already on the chain with the same
24
+ * subject and abilities is treated as superseded, not duplicated) and
25
+ * bounded (an item re-proposed `maxAttempts` times without landing is
26
+ * dropped), so a head that keeps moving cannot loop.
27
+ */
28
+ export type ReproposeDropReason =
29
+ /** The blocked record was not authored by this client, so it cannot be re-signed. */
30
+ "not-author"
31
+ /** An equivalent grant is already on the chain: this one was re-proposed before. */
32
+ | "superseded"
33
+ /** The intent is no longer authorized on the moved head. */
34
+ | "unauthorized"
35
+ /** A revocation whose target grant is already inactive on the moved head. */
36
+ | "already-revoked"
37
+ /** A dependent Key Package's DEK is not held, so it cannot be re-sealed. */
38
+ | "no-dek"
39
+ /** A dependent Key Package no longer verifies on the moved head. */
40
+ | "key-unauthorized"
41
+ /** A KEY_EPOCH or other intent that is not re-authored here. */
42
+ | "unsupported-intent"
43
+ /** Re-proposed too many times without landing (the head kept moving). */
44
+ | "attempts-exhausted";
45
+ export type ReproposeOutcome = {
46
+ readonly kind: "reproposed";
47
+ readonly oldId: ControlRecordId;
48
+ readonly newId: ControlRecordId;
49
+ /** The dependent Key Packages re-sealed to the new head, old ID → new ID. */
50
+ readonly keyPackages: readonly {
51
+ readonly oldId: Hash32;
52
+ readonly newId: Hash32;
53
+ }[];
54
+ } | {
55
+ readonly kind: "dropped";
56
+ readonly oldId: ControlRecordId;
57
+ readonly reason: ReproposeDropReason;
58
+ };
59
+ export interface ReproposeResult {
60
+ readonly outcomes: readonly ReproposeOutcome[];
61
+ }
62
+ export interface ReproposeOptions {
63
+ readonly storage: Pick<LfcpStorage, "control" | "outbound" | "commit">;
64
+ readonly secrets: SecretStore;
65
+ readonly signer: Signer;
66
+ readonly resource: ResourceId;
67
+ /** For dropping in-memory tracking of the items a re-proposal replaces. */
68
+ readonly queue: OutboundQueue;
69
+ /** Re-proposal attempts per blocked record (by item ID hex), owned by the caller. */
70
+ readonly attempts: Map<string, number>;
71
+ /** The cap after which an item is dropped "attempts-exhausted" (default 8). */
72
+ readonly maxAttempts?: number;
73
+ }
74
+ /**
75
+ * Re-proposes every blocked "repropose" Control Record of `resource`, in
76
+ * record order, each on the local head left by the ones before it. Returns
77
+ * one outcome per blocked record. Call it under the same serial lock as the
78
+ * queue, so no other record is authored meanwhile.
79
+ */
80
+ export declare function reproposeControlRecords(options: ReproposeOptions): Promise<ReproposeResult>;
81
+ //# sourceMappingURL=repropose.d.ts.map
@@ -0,0 +1,221 @@
1
+ import { bytesEqual, controlRecordId, hash32, toHex, } from "@openlfcp/core";
2
+ import { importResourceDEK } from "@openlfcp/crypto";
3
+ import { dekSecretRef, } from "@openlfcp/storage";
4
+ import { controlRecordSigner, decodeControlRecord, isGrantActive, parseKeyPackage, sealKeyPackage, signControlRecord, validateControlChain, verifyKeyPackage, } from "@openlfcp/wire";
5
+ import { controlRecordWrites, keyPackageWrites } from "./queue.js";
6
+ import { localControlChain } from "./storage.js";
7
+ /** The descriptor of `principal` from the validated chain (its principals, owner or a grant subject). */
8
+ function descriptorOf(state, principal) {
9
+ const known = state.principals.get(toHex(principal));
10
+ if (known !== undefined)
11
+ return known;
12
+ if (bytesEqual(state.owner.principalId, principal))
13
+ return state.owner;
14
+ return undefined;
15
+ }
16
+ /** An active grant on `state` with the same subject and ability set (a prior re-proposal of this grant). */
17
+ function equivalentGrant(state, subject, abilities) {
18
+ const want = [...abilities].map(String).sort().join(",");
19
+ for (const g of state.grants.values())
20
+ if (bytesEqual(g.subject, subject) &&
21
+ isGrantActive(state, g.id) &&
22
+ [...g.abilities].map(String).sort().join(",") === want)
23
+ return true;
24
+ return false;
25
+ }
26
+ /** Re-signs a record on the local head and checks it extends the chain linearly; undefined when it does not. */
27
+ function reauthor(local, sign) {
28
+ const signed = sign({
29
+ resourceId: local.state.resourceId,
30
+ controlSeq: local.state.seq + 1n,
31
+ prevControlId: local.state.head,
32
+ });
33
+ const next = validateControlChain([...local.records.map((r) => r.signed.bytes), signed.bytes]);
34
+ if (next.kind !== "linear")
35
+ return undefined;
36
+ return { bytes: signed.bytes, newId: signed.recordId, next };
37
+ }
38
+ /** Re-seals every queued Key Package that depended on `oldHead` to the re-proposed `newHead`/`next`. */
39
+ async function resealDependents(options, next, oldHead, newHead) {
40
+ const removed = [];
41
+ const added = [];
42
+ const mapping = [];
43
+ for (const item of await options.storage.outbound.list(options.resource)) {
44
+ if (item.kind !== "key-package")
45
+ continue;
46
+ let controlHead;
47
+ let recipientId;
48
+ let epoch;
49
+ try {
50
+ const p = parseKeyPackage(item.bytes).payload;
51
+ controlHead = p.controlHead;
52
+ recipientId = p.recipient;
53
+ epoch = p.dataEpoch;
54
+ }
55
+ catch {
56
+ continue;
57
+ }
58
+ if (!bytesEqual(controlHead, oldHead))
59
+ continue;
60
+ const recipient = descriptorOf(next.state, recipientId);
61
+ const bytes = await options.secrets.get(dekSecretRef(options.resource, epoch));
62
+ if (recipient === undefined || bytes === undefined)
63
+ return { ok: false, reason: "no-dek" };
64
+ const sealed = await sealKeyPackage({
65
+ resourceId: options.resource,
66
+ epoch,
67
+ controlHead: newHead,
68
+ recipient,
69
+ dek: importResourceDEK(bytes),
70
+ signer: options.signer,
71
+ });
72
+ if (verifyKeyPackage(next, parseKeyPackage(sealed.bytes)).kind !== "authorized")
73
+ return { ok: false, reason: "key-unauthorized" };
74
+ removed.push(item.itemId);
75
+ added.push(sealed.bytes);
76
+ mapping.push({ oldId: item.itemId, newId: hash32(parseKeyPackage(sealed.bytes).signed.id) });
77
+ }
78
+ return Object.freeze({ ok: true, removed, added, mapping });
79
+ }
80
+ /** The queued Key Packages of `resource` whose control head is `controlHead`. */
81
+ async function dependentKeyPackages(storage, resource, controlHead) {
82
+ const out = [];
83
+ for (const item of await storage.outbound.list(resource)) {
84
+ if (item.kind !== "key-package")
85
+ continue;
86
+ try {
87
+ if (bytesEqual(parseKeyPackage(item.bytes).payload.controlHead, controlHead))
88
+ out.push(item.itemId);
89
+ }
90
+ catch {
91
+ // not a parsable Key Package: never a dependent of this head.
92
+ }
93
+ }
94
+ return out;
95
+ }
96
+ /** Enqueues the re-authored record and its re-sealed Key Packages and dequeues the old ones, in one transaction. */
97
+ async function swap(storage, queue, change) {
98
+ const writes = [
99
+ { op: "dequeue", itemId: change.removeControl },
100
+ ...change.removeKeyPackages.map((itemId) => ({ op: "dequeue", itemId })),
101
+ ...controlRecordWrites(change.addControl),
102
+ ...change.addKeyPackages.flatMap(keyPackageWrites),
103
+ ];
104
+ const r = await storage.commit(writes);
105
+ if (!r.ok)
106
+ throw new Error(`the re-proposal was not committed: ${r.reason}`);
107
+ queue.release([change.removeControl, ...change.removeKeyPackages]);
108
+ }
109
+ /** Dequeues a blocked record and its dependents and drops the queue's in-memory tracking of them. */
110
+ async function dropItem(storage, queue, ids) {
111
+ const r = await storage.commit(ids.map((itemId) => ({ op: "dequeue", itemId })));
112
+ if (!r.ok)
113
+ throw new Error(`a re-proposal drop was not committed: ${r.reason}`);
114
+ queue.release(ids);
115
+ }
116
+ /**
117
+ * Re-proposes every blocked "repropose" Control Record of `resource`, in
118
+ * record order, each on the local head left by the ones before it. Returns
119
+ * one outcome per blocked record. Call it under the same serial lock as the
120
+ * queue, so no other record is authored meanwhile.
121
+ */
122
+ export async function reproposeControlRecords(options) {
123
+ const { storage, signer, resource, queue } = options;
124
+ const maxAttempts = options.maxAttempts ?? 8;
125
+ const blocked = (await storage.outbound.list(resource))
126
+ .filter((i) => i.kind === "control-record" && i.blocked?.reason === "repropose")
127
+ .map((i) => ({ itemId: i.itemId, record: decodeControlRecord(i.bytes) }))
128
+ .sort((a, b) => (a.record.payload.controlSeq < b.record.payload.controlSeq ? -1 : 1));
129
+ const outcomes = [];
130
+ const mine = signer.descriptor.principalId;
131
+ for (const { itemId, record } of blocked) {
132
+ const oldId = controlRecordId(record.signed.id);
133
+ const drop = async (reason) => {
134
+ options.attempts.delete(toHex(itemId));
135
+ await dropItem(storage, queue, [
136
+ itemId,
137
+ ...(await dependentKeyPackages(storage, resource, oldId)),
138
+ ]);
139
+ outcomes.push(Object.freeze({ kind: "dropped", oldId, reason }));
140
+ };
141
+ if (!bytesEqual(controlRecordSigner(record), mine)) {
142
+ await drop("not-author");
143
+ continue;
144
+ }
145
+ const attempt = (options.attempts.get(toHex(itemId)) ?? 0) + 1;
146
+ if (attempt > maxAttempts) {
147
+ await drop("attempts-exhausted");
148
+ continue;
149
+ }
150
+ options.attempts.set(toHex(itemId), attempt);
151
+ const local = await localControlChain(storage, resource);
152
+ if (local === undefined || local.kind !== "linear") {
153
+ await drop("unauthorized");
154
+ continue;
155
+ }
156
+ const body = record.body;
157
+ if (body.type === "CAPABILITY_GRANT") {
158
+ if (equivalentGrant(local.state, body.subject.principalId, body.abilities)) {
159
+ await drop("superseded");
160
+ continue;
161
+ }
162
+ const reauthored = reauthor(local, (header) => signControlRecord(header, {
163
+ type: "CAPABILITY_GRANT",
164
+ subject: body.subject,
165
+ abilities: body.abilities,
166
+ delegable: body.delegable,
167
+ ...(body.claimLimit === undefined ? {} : { claimLimit: body.claimLimit }),
168
+ }, signer));
169
+ if (reauthored === undefined) {
170
+ await drop("unauthorized");
171
+ continue;
172
+ }
173
+ const resealed = await resealDependents(options, reauthored.next, oldId, reauthored.newId);
174
+ if (!resealed.ok) {
175
+ await drop(resealed.reason);
176
+ continue;
177
+ }
178
+ await swap(storage, queue, {
179
+ removeControl: itemId,
180
+ removeKeyPackages: resealed.removed,
181
+ addControl: reauthored.bytes,
182
+ addKeyPackages: resealed.added,
183
+ });
184
+ // Carry the attempt count onto the re-authored record, so a head that
185
+ // keeps moving is still bounded (the count is cleared on its ACK).
186
+ options.attempts.delete(toHex(itemId));
187
+ options.attempts.set(toHex(reauthored.newId), attempt);
188
+ outcomes.push(Object.freeze({
189
+ kind: "reproposed",
190
+ oldId,
191
+ newId: reauthored.newId,
192
+ keyPackages: resealed.mapping,
193
+ }));
194
+ continue;
195
+ }
196
+ if (body.type === "CAPABILITY_REVOKE") {
197
+ if (!isGrantActive(local.state, body.grantId)) {
198
+ await drop("already-revoked");
199
+ continue;
200
+ }
201
+ const reauthored = reauthor(local, (header) => signControlRecord(header, { type: "CAPABILITY_REVOKE", grantId: body.grantId }, signer));
202
+ if (reauthored === undefined) {
203
+ await drop("unauthorized");
204
+ continue;
205
+ }
206
+ await swap(storage, queue, {
207
+ removeControl: itemId,
208
+ removeKeyPackages: [],
209
+ addControl: reauthored.bytes,
210
+ addKeyPackages: [],
211
+ });
212
+ options.attempts.delete(toHex(itemId));
213
+ options.attempts.set(toHex(reauthored.newId), attempt);
214
+ outcomes.push(Object.freeze({ kind: "reproposed", oldId, newId: reauthored.newId, keyPackages: [] }));
215
+ continue;
216
+ }
217
+ await drop("unsupported-intent");
218
+ }
219
+ return Object.freeze({ outcomes: Object.freeze(outcomes) });
220
+ }
221
+ //# sourceMappingURL=repropose.js.map
package/dist/storage.d.ts CHANGED
@@ -34,6 +34,23 @@ export declare class StoredSeenUnits implements SeenUnits {
34
34
  * stored head back to Genesis. Undefined when nothing is stored.
35
35
  */
36
36
  export declare function loadControlChain(storage: Pick<LfcpStorage, "control">, resource: ResourceId): Promise<ChainResult | undefined>;
37
+ /**
38
+ * The client's LOCAL Control Chain: the committed chain extended by this
39
+ * client's own queued Control Records that are still live (not yet
40
+ * acknowledged, and not blocked). A writer chains a new record on this
41
+ * local head, not the committed head, so records authored back to back
42
+ * (e.g. two invitations) carry consecutive sequence numbers and a linear
43
+ * `previous` instead of colliding on the committed head (§47,
44
+ * CONTROL_HEAD_MISMATCH). Blocked items are excluded: they will not land as
45
+ * they are, so nothing is chained on them (the re-proposal re-authors them).
46
+ *
47
+ * Returns the committed chain unchanged when there are no live pending
48
+ * records, `undefined` when the Resource has no stored chain, and the
49
+ * committed chain when the pending records do not extend it linearly (they
50
+ * are then left for the re-proposal to sort out, and the caller sees the
51
+ * committed head).
52
+ */
53
+ export declare function localControlChain(storage: Pick<LfcpStorage, "control" | "outbound">, resource: ResourceId): Promise<ChainResult | undefined>;
37
54
  /**
38
55
  * Persists a validated linear chain in one atomic batch: its exact records,
39
56
  * the head (compare-and-set against `expected`), the epoch history (keeping
package/dist/storage.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { actorSequence, bytesEqual, hash32, LfcpError, toHex, } from "@openlfcp/core";
2
2
  import { dekCommitment, importResourceDEK } from "@openlfcp/crypto";
3
3
  import { dekSecretRef, } from "@openlfcp/storage";
4
- import { parseDataUnit, validateControlChain, } from "@openlfcp/wire";
4
+ import { parseControlRecord, parseDataUnit, validateControlChain, } from "@openlfcp/wire";
5
5
  import { receiptIndexWrites, replaceReleasedWrites } from "./batch-status.js";
6
6
  import { createDataUnit } from "./data-unit.js";
7
7
  import { intentsHash, OperationIdReusedError, receiptOf, receiptWrite, } from "./receipts.js";
@@ -87,6 +87,38 @@ export async function loadControlChain(storage, resource) {
87
87
  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)`);
88
88
  return result;
89
89
  }
90
+ /**
91
+ * The client's LOCAL Control Chain: the committed chain extended by this
92
+ * client's own queued Control Records that are still live (not yet
93
+ * acknowledged, and not blocked). A writer chains a new record on this
94
+ * local head, not the committed head, so records authored back to back
95
+ * (e.g. two invitations) carry consecutive sequence numbers and a linear
96
+ * `previous` instead of colliding on the committed head (§47,
97
+ * CONTROL_HEAD_MISMATCH). Blocked items are excluded: they will not land as
98
+ * they are, so nothing is chained on them (the re-proposal re-authors them).
99
+ *
100
+ * Returns the committed chain unchanged when there are no live pending
101
+ * records, `undefined` when the Resource has no stored chain, and the
102
+ * committed chain when the pending records do not extend it linearly (they
103
+ * are then left for the re-proposal to sort out, and the caller sees the
104
+ * committed head).
105
+ */
106
+ export async function localControlChain(storage, resource) {
107
+ const committed = await loadControlChain(storage, resource);
108
+ if (committed === undefined || committed.kind !== "linear")
109
+ return committed;
110
+ const pending = (await storage.outbound.list(resource))
111
+ .filter((i) => i.kind === "control-record" && i.blocked === null)
112
+ .map((i) => parseControlRecord(i.bytes))
113
+ .sort((a, b) => (a.payload.controlSeq < b.payload.controlSeq ? -1 : 1));
114
+ if (pending.length === 0)
115
+ return committed;
116
+ const extended = validateControlChain([
117
+ ...committed.records.map((r) => r.signed.bytes),
118
+ ...pending.map((p) => p.signed.bytes),
119
+ ]);
120
+ return extended.kind === "linear" ? extended : committed;
121
+ }
90
122
  /**
91
123
  * Persists a validated linear chain in one atomic batch: its exact records,
92
124
  * the head (compare-and-set against `expected`), the epoch history (keeping
@@ -7,6 +7,7 @@ import type { ProfileCheckpointer } from "./checkpoint.js";
7
7
  import { type WebSocketFactory } from "./connection.js";
8
8
  import type { AckOutcome, NackOutcome, OutboundQueue, StaleOutboundUnit } from "./outbound.js";
9
9
  import { type Receipt } from "./receipts.js";
10
+ import { type ReproposeOutcome } from "./repropose.js";
10
11
  import { type ResourcePhase } from "./resource-state.js";
11
12
  import { type RemainingPath, type RevocationRefusal } from "./revoke.js";
12
13
  import { type SectionState, type StatusEvent, type StatusSnapshot } from "./section-status.js";
@@ -263,6 +264,18 @@ export type SyncEvent = {
263
264
  readonly outcome: "started" | "recovered" | "ended";
264
265
  readonly reason?: string;
265
266
  }
267
+ /**
268
+ * This client's own Control Records that a moved head refused
269
+ * (CONTROL_HEAD_MISMATCH) were re-proposed on the current head, or dropped
270
+ * when no longer authorized or meaningful (repropose.ts). One outcome per
271
+ * record; a copied invitation link keeps working, as a re-proposed grant
272
+ * keeps the same Invitation Principal and claim parameters.
273
+ */
274
+ | {
275
+ readonly type: "repropose";
276
+ readonly resourceId: ResourceId;
277
+ readonly outcomes: readonly ReproposeOutcome[];
278
+ }
266
279
  /**
267
280
  * The server refused the Resource for good (see ResourceRefusal): it is
268
281
  * CLOSED and is not opened again until open() is called for it.
@@ -6,11 +6,12 @@ import { encode } from "@openlfcp/wire/cbor";
6
6
  import { afterAccepted, afterMismatch, RECOVERY_TRANSIENT_ATTEMPTS, RECOVERY_TRANSIENT_CODES, startRecovery, } from "./access-recovery.js";
7
7
  import { ACCEPTANCE_DURABILITY, batchStatus, batchStatuses, recordEvidence, } from "./batch-status.js";
8
8
  import { readClaimJournal } from "./claim-journal.js";
9
- import { LfcpConnection } from "./connection.js";
9
+ import { ConnectionNotReady, LfcpConnection } from "./connection.js";
10
10
  import { EngineGuard, isEngineTrap, snapshotItem } from "./engine-guard.js";
11
11
  import { resourceSyncState, snapshotFrontier } from "./outbound.js";
12
12
  import { controlRecordWrites, createQueuedSnapshot, keyPackageWrites } from "./queue.js";
13
13
  import { intentsHash, OperationIdReusedError, receiptOf } from "./receipts.js";
14
+ import { reproposeControlRecords } from "./repropose.js";
14
15
  import { resourcePhaseTransition, } from "./resource-state.js";
15
16
  import { planRevocation, RevocationRefused, } from "./revoke.js";
16
17
  import { receivedState, } from "./section-status.js";
@@ -102,6 +103,8 @@ export class SyncClient {
102
103
  #sentAt = new Map();
103
104
  #requestTimeoutMs;
104
105
  #listeners = new Set();
106
+ /** Re-proposal attempts per Control Record (item ID hex), bounded; cleared on ACK (repropose.ts). */
107
+ #reproposeAttempts = new Map();
105
108
  #antiEntropyMs;
106
109
  /** The crash-loop breaker for Snapshot loads (EngineGuard). */
107
110
  #snapshots;
@@ -346,6 +349,11 @@ export class SyncClient {
346
349
  this.#queue = this.#queue
347
350
  .then(() => (this.#trapped ? undefined : fn()))
348
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;
349
357
  if (!isEngineTrap(e)) {
350
358
  this.#emit({
351
359
  type: "error",
@@ -531,10 +539,19 @@ export class SyncClient {
531
539
  // -------------------------------------------------------------------------
532
540
  // plumbing
533
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
+ }
534
552
  const key = toHex(message.messageId);
535
553
  this.#requests.set(key, request);
536
554
  this.#sentAt.set(key, this.#o.now());
537
- this.#connection.send(message);
538
555
  }
539
556
  /**
540
557
  * Requests unanswered for requestTimeoutMs on a live connection (§70: a
@@ -1062,6 +1079,12 @@ export class SyncClient {
1062
1079
  }
1063
1080
  }
1064
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);
1065
1088
  if (ctx.state === "CONTROL_SYNC") {
1066
1089
  this.#move(ctx, "CONTROL_COMPLETE");
1067
1090
  this.#requestKeys(ctx, this.#o.now());
@@ -1070,6 +1093,29 @@ export class SyncClient {
1070
1093
  this.#requestKeys(ctx, this.#o.now());
1071
1094
  }
1072
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
+ }
1073
1119
  // -------------------------------------------------------------------------
1074
1120
  // Keys (§52, §53, §66 step 2)
1075
1121
  async #epochsWithoutDek(ctx) {
@@ -1770,12 +1816,16 @@ export class SyncClient {
1770
1816
  await this.#reportBatches(R, ops.keys(), finals);
1771
1817
  this.#emit({ type: "ack", outcome });
1772
1818
  // Our own Control Record was committed: catch up, since the server does not push it back to us.
1773
- 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));
1774
1823
  for (const ctx of this.#resources.values())
1775
1824
  if (ctx.state === "LIVE")
1776
1825
  this.#refreshControl(ctx);
1777
1826
  else
1778
1827
  ctx.controlStale = true;
1828
+ }
1779
1829
  }
1780
1830
  /**
1781
1831
  * An ACK of our CONTROL_PUT (§47) means the server committed the record
@@ -1829,10 +1879,28 @@ export class SyncClient {
1829
1879
  ctx.lossReason ??= "unknown-previous";
1830
1880
  }
1831
1881
  this.#emit({ type: "nack", outcome });
1882
+ // MISSING_DEPENDENCY: the server lacks our Control records; a sync releases the held puts.
1832
1883
  if (outcome.kind === "needs-control-sync")
1833
1884
  for (const ctx of this.#resources.values())
1834
1885
  if (ctx.state === "LIVE")
1835
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
+ }
1836
1904
  // §51.1: the server lost what our unit names. Its DATA_HAVE answer
1837
1905
  // starts the offer (§68.1), which releases the held items.
1838
1906
  if (outcome.kind === "needs-offer") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openlfcp/client",
3
- "version": "0.2.0-beta.5",
3
+ "version": "0.2.0-beta.7",
4
4
  "description": "LFCP client: session, Control Plane and Data Plane synchronization.",
5
5
  "keywords": [
6
6
  "openlfcp",
@@ -42,9 +42,9 @@
42
42
  "tag": "beta"
43
43
  },
44
44
  "dependencies": {
45
- "@openlfcp/core": "^0.2.0-beta.5",
46
- "@openlfcp/crypto": "^0.2.0-beta.5",
47
- "@openlfcp/wire": "^0.2.0-beta.5",
48
- "@openlfcp/storage": "^0.2.0-beta.5"
45
+ "@openlfcp/core": "^0.2.0-beta.7",
46
+ "@openlfcp/crypto": "^0.2.0-beta.7",
47
+ "@openlfcp/storage": "^0.2.0-beta.7",
48
+ "@openlfcp/wire": "^0.2.0-beta.7"
49
49
  }
50
50
  }