@openlfcp/client 0.2.0-beta.1 → 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/dist/connection.d.ts +9 -0
- package/dist/connection.js +12 -1
- package/dist/data-unit.js +6 -4
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/invite.d.ts +1 -1
- package/dist/invite.js +40 -8
- package/dist/outbound.d.ts +7 -0
- package/dist/outbound.js +31 -2
- package/dist/repropose.d.ts +81 -0
- package/dist/repropose.js +221 -0
- package/dist/storage.d.ts +17 -0
- package/dist/storage.js +40 -4
- package/dist/sync-client.d.ts +13 -0
- package/dist/sync-client.js +71 -3
- package/package.json +5 -5
package/dist/connection.d.ts
CHANGED
|
@@ -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 {
|
package/dist/connection.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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:
|
|
373
|
+
invitationGrantId: grantId,
|
|
342
374
|
claimant: options.claimant.signer.descriptor,
|
|
343
375
|
abilities,
|
|
344
376
|
}, claimSigner);
|
package/dist/outbound.d.ts
CHANGED
|
@@ -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
|
@@ -36,6 +36,8 @@ const PER_OBJECT = new Set([
|
|
|
36
36
|
const CODE_NAMES = new Map(Object.entries(ERROR_CODE).map(([name, code]) => [code, name]));
|
|
37
37
|
/** Room left in a message for the envelope and body around the objects. */
|
|
38
38
|
const OVERHEAD = 1024;
|
|
39
|
+
/** How many expired flights are remembered for a late answer. */
|
|
40
|
+
const EXPIRED_KEPT = 1024;
|
|
39
41
|
const AT_ONCE = { nextAttempt: () => null };
|
|
40
42
|
/** Data Units by actor, then actor sequence; unparsable bytes last (they are blocked when sent). */
|
|
41
43
|
function unitOrder(a, b) {
|
|
@@ -65,6 +67,16 @@ export class OutboundQueue {
|
|
|
65
67
|
#maxMessageBytes = DEFAULT_MAX_MESSAGE_BYTES;
|
|
66
68
|
/** message ID hex → what it carried */
|
|
67
69
|
#flights = new Map();
|
|
70
|
+
/**
|
|
71
|
+
* Flights that expired unanswered (#expire), by message ID, the most
|
|
72
|
+
* recent {@link EXPIRED_KEPT}: a late answer to one is still an answer to
|
|
73
|
+
* our message, correlated for §4.1's acceptance evidence. Without them, an
|
|
74
|
+
* ACK handled after the timeout sweep (a busy event loop: the sweep's
|
|
75
|
+
* timer runs before the waiting message) dequeued the items as
|
|
76
|
+
* uncorrelated, their batch was "evidence-unavailable" for good, and no
|
|
77
|
+
* later ACK could change it (the Shared Tasks soak of 0.4.0-beta.17).
|
|
78
|
+
*/
|
|
79
|
+
#expired = new Map();
|
|
68
80
|
/** item ID hex → message ID hex */
|
|
69
81
|
#inFlight = new Map();
|
|
70
82
|
/**
|
|
@@ -99,6 +111,9 @@ export class OutboundQueue {
|
|
|
99
111
|
if (flight.deadline > at)
|
|
100
112
|
continue;
|
|
101
113
|
this.#flights.delete(key);
|
|
114
|
+
this.#expired.set(key, flight);
|
|
115
|
+
if (this.#expired.size > EXPIRED_KEPT)
|
|
116
|
+
this.#expired.delete(this.#expired.keys().next().value);
|
|
102
117
|
for (const id of flight.itemIds)
|
|
103
118
|
if (this.#inFlight.get(toHex(id)) === key) {
|
|
104
119
|
this.#inFlight.delete(toHex(id));
|
|
@@ -261,8 +276,12 @@ export class OutboundQueue {
|
|
|
261
276
|
return undefined;
|
|
262
277
|
const key = toHex(messageId);
|
|
263
278
|
const flight = this.#flights.get(key);
|
|
264
|
-
if (flight === undefined)
|
|
265
|
-
|
|
279
|
+
if (flight === undefined) {
|
|
280
|
+
// A late answer to a message whose flight expired: ours, correlated.
|
|
281
|
+
const late = this.#expired.get(key);
|
|
282
|
+
this.#expired.delete(key);
|
|
283
|
+
return late;
|
|
284
|
+
}
|
|
266
285
|
this.#flights.delete(key);
|
|
267
286
|
for (const id of flight.itemIds) {
|
|
268
287
|
if (this.#inFlight.get(toHex(id)) === key)
|
|
@@ -563,6 +582,16 @@ export class OutboundQueue {
|
|
|
563
582
|
await this.#commit(writes);
|
|
564
583
|
return out;
|
|
565
584
|
}
|
|
585
|
+
/**
|
|
586
|
+
* Drops all in-memory tracking of these items, which a re-proposal has
|
|
587
|
+
* replaced with re-authored ones in storage (repropose.ts). The items are
|
|
588
|
+
* already dequeued in that same transaction; this clears any stale flight,
|
|
589
|
+
* solo or awaiting-control state so the queue never looks for them again.
|
|
590
|
+
*/
|
|
591
|
+
release(ids) {
|
|
592
|
+
for (const id of ids)
|
|
593
|
+
this.#forget(id);
|
|
594
|
+
}
|
|
566
595
|
/** Removes a blocked item once the application has dealt with it (e.g. re-proposed or re-applied). */
|
|
567
596
|
async discard(itemId) {
|
|
568
597
|
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
|
|
@@ -251,9 +283,13 @@ export async function createQueuedDataUnit(storage, options, also = []) {
|
|
|
251
283
|
}
|
|
252
284
|
async function createQueuedDataUnitNow(storage, options, also, resource, actor) {
|
|
253
285
|
const { onCreated, previousUnitId, ...create } = options;
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
286
|
+
const latest = await latestAcceptedOwnUnit(storage, resource, actor);
|
|
287
|
+
// §26.2: a null previous only when the writer holds no own unit as
|
|
288
|
+
// accepted (before its first, or after a first that went stale); while it
|
|
289
|
+
// holds one, a receiver that accepted it would hold this unit for good.
|
|
290
|
+
if (previousUnitId === null && latest !== null)
|
|
291
|
+
throw new LfcpError("INVALID_STRUCTURE", "the writer still holds an accepted own unit: its next unit names it as previous (§26.2)");
|
|
292
|
+
const previous = previousUnitId !== undefined ? previousUnitId : latest;
|
|
257
293
|
const created = await createDataUnit({
|
|
258
294
|
...create,
|
|
259
295
|
previousUnitId: previous,
|
package/dist/sync-client.d.ts
CHANGED
|
@@ -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.
|
package/dist/sync-client.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.2.0-beta.10",
|
|
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.
|
|
46
|
-
"@openlfcp/
|
|
47
|
-
"@openlfcp/
|
|
48
|
-
"@openlfcp/
|
|
45
|
+
"@openlfcp/core": "^0.2.0-beta.10",
|
|
46
|
+
"@openlfcp/storage": "^0.2.0-beta.10",
|
|
47
|
+
"@openlfcp/wire": "^0.2.0-beta.10",
|
|
48
|
+
"@openlfcp/crypto": "^0.2.0-beta.10"
|
|
49
49
|
}
|
|
50
50
|
}
|