@openlfcp/client 0.2.0-beta.5 → 0.2.0-beta.6
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/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/invite.d.ts +1 -1
- package/dist/invite.js +40 -8
- package/dist/outbound.d.ts +7 -0
- package/dist/outbound.js +10 -0
- package/dist/repropose.d.ts +81 -0
- package/dist/repropose.js +221 -0
- package/dist/storage.d.ts +17 -0
- package/dist/storage.js +33 -1
- package/dist/sync-client.d.ts +13 -0
- package/dist/sync-client.js +55 -1
- package/package.json +5 -5
package/dist/index.d.ts
CHANGED
|
@@ -11,11 +11,12 @@ export { type AcceptedInvitation, type AcceptInvitationOptions, type AcceptInvit
|
|
|
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
|
@@ -11,11 +11,12 @@ export { abandonInvitationClaim, acceptInvitation, createInvitation, DEFAULT_INV
|
|
|
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
|
@@ -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
|
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
|
@@ -11,6 +11,7 @@ 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;
|
|
@@ -1062,6 +1065,12 @@ export class SyncClient {
|
|
|
1062
1065
|
}
|
|
1063
1066
|
}
|
|
1064
1067
|
await this.#o.outbound.controlSynced(R);
|
|
1068
|
+
// §47, §21: on the fresh head, re-propose our own Control Records that a
|
|
1069
|
+
// moved head refused (CONTROL_HEAD_MISMATCH) and recover any persisted
|
|
1070
|
+
// "repropose" items (a restart, or an older SDK). Runs here, under the
|
|
1071
|
+
// serial lock, on every control sync — so it covers both the start of an
|
|
1072
|
+
// open and a sync triggered by a repropose NACK; it is idempotent.
|
|
1073
|
+
await this.#reproposeControl(ctx);
|
|
1065
1074
|
if (ctx.state === "CONTROL_SYNC") {
|
|
1066
1075
|
this.#move(ctx, "CONTROL_COMPLETE");
|
|
1067
1076
|
this.#requestKeys(ctx, this.#o.now());
|
|
@@ -1070,6 +1079,29 @@ export class SyncClient {
|
|
|
1070
1079
|
this.#requestKeys(ctx, this.#o.now());
|
|
1071
1080
|
}
|
|
1072
1081
|
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Re-proposes this client's blocked "repropose" Control Records of `ctx`'s
|
|
1084
|
+
* Resource on the current local head, re-sealing their dependent Key
|
|
1085
|
+
* Packages, and flushes the re-authored objects. Bounded and idempotent
|
|
1086
|
+
* (repropose.ts); a dropped record (no longer authorized, or a dependent's
|
|
1087
|
+
* key is not held) is surfaced so the application can act.
|
|
1088
|
+
*/
|
|
1089
|
+
async #reproposeControl(ctx) {
|
|
1090
|
+
const R = ctx.binding.resourceId;
|
|
1091
|
+
const { outcomes } = await reproposeControlRecords({
|
|
1092
|
+
storage: this.#o.storage,
|
|
1093
|
+
secrets: this.#o.secrets,
|
|
1094
|
+
signer: this.#o.signer,
|
|
1095
|
+
resource: R,
|
|
1096
|
+
queue: this.#o.outbound,
|
|
1097
|
+
attempts: this.#reproposeAttempts,
|
|
1098
|
+
});
|
|
1099
|
+
if (outcomes.length === 0)
|
|
1100
|
+
return;
|
|
1101
|
+
this.#emit({ type: "repropose", resourceId: R, outcomes });
|
|
1102
|
+
if (outcomes.some((o) => o.kind === "reproposed"))
|
|
1103
|
+
await this.#flush(ctx);
|
|
1104
|
+
}
|
|
1073
1105
|
// -------------------------------------------------------------------------
|
|
1074
1106
|
// Keys (§52, §53, §66 step 2)
|
|
1075
1107
|
async #epochsWithoutDek(ctx) {
|
|
@@ -1770,12 +1802,16 @@ export class SyncClient {
|
|
|
1770
1802
|
await this.#reportBatches(R, ops.keys(), finals);
|
|
1771
1803
|
this.#emit({ type: "ack", outcome });
|
|
1772
1804
|
// 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)
|
|
1805
|
+
if (m.body.requestType === MESSAGE_TYPE.CONTROL_PUT && outcome.acked.length > 0) {
|
|
1806
|
+
// A committed record is settled: clear its bounded re-proposal count.
|
|
1807
|
+
for (const id of outcome.acked)
|
|
1808
|
+
this.#reproposeAttempts.delete(toHex(id));
|
|
1774
1809
|
for (const ctx of this.#resources.values())
|
|
1775
1810
|
if (ctx.state === "LIVE")
|
|
1776
1811
|
this.#refreshControl(ctx);
|
|
1777
1812
|
else
|
|
1778
1813
|
ctx.controlStale = true;
|
|
1814
|
+
}
|
|
1779
1815
|
}
|
|
1780
1816
|
/**
|
|
1781
1817
|
* An ACK of our CONTROL_PUT (§47) means the server committed the record
|
|
@@ -1829,10 +1865,28 @@ export class SyncClient {
|
|
|
1829
1865
|
ctx.lossReason ??= "unknown-previous";
|
|
1830
1866
|
}
|
|
1831
1867
|
this.#emit({ type: "nack", outcome });
|
|
1868
|
+
// MISSING_DEPENDENCY: the server lacks our Control records; a sync releases the held puts.
|
|
1832
1869
|
if (outcome.kind === "needs-control-sync")
|
|
1833
1870
|
for (const ctx of this.#resources.values())
|
|
1834
1871
|
if (ctx.state === "LIVE")
|
|
1835
1872
|
this.#refreshControl(ctx);
|
|
1873
|
+
// CONTROL_HEAD_MISMATCH: re-author the refused record on the current head
|
|
1874
|
+
// (§47, §21). When we already hold that head (e.g. the winning record was
|
|
1875
|
+
// pushed to us live, §65), a CONTROL_GET would return nothing and never
|
|
1876
|
+
// reach #controlComplete, so re-propose here directly; otherwise sync
|
|
1877
|
+
// first and #controlComplete drives it.
|
|
1878
|
+
if (outcome.kind === "repropose")
|
|
1879
|
+
for (const ctx of this.#resources.values()) {
|
|
1880
|
+
if (ctx.state !== "LIVE")
|
|
1881
|
+
continue;
|
|
1882
|
+
const head = await this.#o.storage.control.head(ctx.binding.resourceId);
|
|
1883
|
+
if (outcome.currentHead !== null &&
|
|
1884
|
+
head !== undefined &&
|
|
1885
|
+
bytesEqual(head.head, outcome.currentHead))
|
|
1886
|
+
await this.#reproposeControl(ctx);
|
|
1887
|
+
else
|
|
1888
|
+
this.#refreshControl(ctx);
|
|
1889
|
+
}
|
|
1836
1890
|
// §51.1: the server lost what our unit names. Its DATA_HAVE answer
|
|
1837
1891
|
// starts the offer (§68.1), which releases the held items.
|
|
1838
1892
|
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.6",
|
|
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/crypto": "^0.2.0-beta.
|
|
47
|
-
"@openlfcp/
|
|
48
|
-
"@openlfcp/
|
|
45
|
+
"@openlfcp/core": "^0.2.0-beta.6",
|
|
46
|
+
"@openlfcp/crypto": "^0.2.0-beta.6",
|
|
47
|
+
"@openlfcp/storage": "^0.2.0-beta.6",
|
|
48
|
+
"@openlfcp/wire": "^0.2.0-beta.6"
|
|
49
49
|
}
|
|
50
50
|
}
|