@openlfcp/client 0.1.5 → 0.2.0-beta.2

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/invite.js CHANGED
@@ -1,7 +1,8 @@
1
- import { bytesEqual, hash32, LfcpError, toHex, } from "@openlfcp/core";
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
4
  import { ABILITY, assembleInviteUri, createMessage, ERROR_CODE, invitationPrincipal, parseControlRecord, parseInviteUri, parseKeyPackage, receiveKeyPackage, sealKeyPackage, signControlRecord, validateControlChain, verifyInvitationSecret, verifyKeyPackage, } from "@openlfcp/wire";
5
+ import { clearClaimJournal, journaledDeks, readClaimJournal, writeClaimJournal, } from "./claim-journal.js";
5
6
  import { LfcpConnection } from "./connection.js";
6
7
  import { queueControlRecord, queueKeyPackage } from "./queue.js";
7
8
  import { loadControlChain, saveControlChain } from "./storage.js";
@@ -274,6 +275,16 @@ export async function acceptInvitation(options) {
274
275
  // §73: the Invitation Principal authenticates and opens the Resource.
275
276
  progress("connecting");
276
277
  await s.connect();
278
+ // LFCP-02-110: a claim of this link whose answer was lost is settled first.
279
+ const journal = await readClaimJournal(options.storage, R);
280
+ if (journal !== undefined &&
281
+ journal.invitationGrantId === toHex(invitation.grantId) &&
282
+ journal.claimant === toHex(options.claimant.signer.descriptor.principalId)) {
283
+ progress("claiming-capability", 1);
284
+ const settled = await settleJournal(s, options, R, journal);
285
+ if (settled !== "not-committed")
286
+ return settled;
287
+ }
277
288
  progress("validating-invitation");
278
289
  const heads = await openAsInvitee(s, R, invitation.grantId);
279
290
  if (heads.length !== 1)
@@ -331,6 +342,18 @@ export async function acceptInvitation(options) {
331
342
  claimant: options.claimant.signer.descriptor,
332
343
  abilities,
333
344
  }, claimSigner);
345
+ // The journal before the claim: a lost answer is resolved from it.
346
+ await writeClaimJournal(options.storage, options.secrets, R, {
347
+ v: 1,
348
+ resourceId: toHex(R),
349
+ invitationGrantId: toHex(invitation.grantId),
350
+ claimant: toHex(options.claimant.signer.descriptor.principalId),
351
+ claim: toHex(claim.bytes),
352
+ expectedHead: toHex(chain.state.head),
353
+ chain: chain.records.map((r) => toHex(r.signed.bytes)),
354
+ epochs: [...deks.keys()],
355
+ abilities: abilities.map(String),
356
+ }, deks, exportSecretKeyBytes);
334
357
  const answer = await s.request(createMessage("CONTROL_PUT", {
335
358
  resourceId: R,
336
359
  expectedHead: chain.state.head,
@@ -345,6 +368,7 @@ export async function acceptInvitation(options) {
345
368
  if (claimed.kind !== "linear")
346
369
  fail("INVALID_CONTROL_CHAIN", "the acknowledged claim does not validate locally");
347
370
  await persist(options, claimed, deks);
371
+ await clearClaimJournal(options.storage, R);
348
372
  return Object.freeze({
349
373
  kind: "claimed",
350
374
  resourceId: R,
@@ -352,6 +376,7 @@ export async function acceptInvitation(options) {
352
376
  abilities: Object.freeze([...abilities]),
353
377
  epochs: Object.freeze([...deks.keys()].map((e) => BigInt(e))),
354
378
  attempts: Object.freeze(attempts),
379
+ resumed: false,
355
380
  });
356
381
  }
357
382
  if (answer.type !== "NACK")
@@ -360,13 +385,18 @@ export async function acceptInvitation(options) {
360
385
  attempts.push(code);
361
386
  const head = answer.body.details;
362
387
  // One refresh after a lost race (§73); the coordinator's next answer is final.
363
- if (code !== "CONTROL_HEAD_MISMATCH" || attempts.length > 1 || !(head instanceof Uint8Array))
388
+ if (code !== "CONTROL_HEAD_MISMATCH" ||
389
+ attempts.length > 1 ||
390
+ !(head instanceof Uint8Array)) {
391
+ // A refused claim was not committed: nothing to resume.
392
+ await clearClaimJournal(options.storage, R);
364
393
  return Object.freeze({
365
394
  kind: "refused",
366
395
  resourceId: R,
367
396
  code,
368
397
  attempts: Object.freeze(attempts),
369
398
  });
399
+ }
370
400
  chain = await fetchChain(s, R, chain.records.map((r) => r.signed.bytes), { head });
371
401
  }
372
402
  }
@@ -379,6 +409,89 @@ export async function acceptInvitation(options) {
379
409
  s.close();
380
410
  }
381
411
  }
412
+ /**
413
+ * LFCP-02-110: sends a journaled claim again. A coordinator answers a
414
+ * record it has already committed with the same ACK (§47, §70): then the
415
+ * claim landed before and the claimant joins from the journal. Any other
416
+ * answer means it was not committed: a moved head lets the caller claim
417
+ * anew ("not-committed"); another refusal is final. A transport stop
418
+ * throws Unavailable and keeps the journal.
419
+ */
420
+ async function settleJournal(s, options, R, journal) {
421
+ const claim = fromHex(journal.claim);
422
+ const answer = await s.request(createMessage("CONTROL_PUT", {
423
+ resourceId: R,
424
+ expectedHead: fromHex(journal.expectedHead),
425
+ record: claim,
426
+ }), "the CONTROL_PUT answer")();
427
+ if (answer.type === "ACK") {
428
+ const claimed = validateControlChain([...journal.chain.map(fromHex), claim]);
429
+ if (claimed.kind !== "linear")
430
+ fail("INVALID_CONTROL_CHAIN", "the journaled claim does not validate locally");
431
+ const deks = await journaledDeks(options.secrets, R, journal);
432
+ await persist(options, claimed, deks);
433
+ await clearClaimJournal(options.storage, R);
434
+ return Object.freeze({
435
+ kind: "claimed",
436
+ resourceId: R,
437
+ grantId: claimed.state.head,
438
+ abilities: Object.freeze(journal.abilities.map((a) => BigInt(a))),
439
+ epochs: Object.freeze([...deks.keys()].map((e) => BigInt(e))),
440
+ attempts: Object.freeze(["ACK"]),
441
+ resumed: true,
442
+ });
443
+ }
444
+ if (answer.type !== "NACK")
445
+ throw new Unavailable(`CONTROL_PUT was answered with ${answer.type}`);
446
+ await clearClaimJournal(options.storage, R);
447
+ const code = codeName(answer.body.code);
448
+ if (code === "CONTROL_HEAD_MISMATCH")
449
+ return "not-committed";
450
+ return Object.freeze({
451
+ kind: "refused",
452
+ resourceId: R,
453
+ code,
454
+ attempts: Object.freeze([code]),
455
+ });
456
+ }
457
+ /**
458
+ * Settles a journaled claim without its link, e.g. after a restart
459
+ * (LFCP-02-110): "claimed" when the claim had landed; "not-claimed" when it
460
+ * had not (the journal is removed, and the link, still unused, can be
461
+ * accepted again); "refused" for another refusal; "unavailable" keeps the
462
+ * journal for a later try; "none" when no claim is journaled.
463
+ */
464
+ export async function resumeInvitationClaim(options) {
465
+ const R = options.resourceId;
466
+ const journal = await readClaimJournal(options.storage, R);
467
+ if (journal === undefined)
468
+ return Object.freeze({ kind: "none", resourceId: R });
469
+ const s = new ClaimSession({
470
+ url: options.url,
471
+ signer: options.claimant.signer,
472
+ now: options.now,
473
+ ...(options.webSocket === undefined ? {} : { webSocket: options.webSocket }),
474
+ }, options.timeout ?? NEVER);
475
+ try {
476
+ await s.connect();
477
+ const settled = await settleJournal(s, options, R, journal);
478
+ return settled === "not-committed"
479
+ ? Object.freeze({ kind: "not-claimed", resourceId: R })
480
+ : settled;
481
+ }
482
+ catch (e) {
483
+ if (e instanceof Unavailable)
484
+ return Object.freeze({ kind: "unavailable", resourceId: R, reason: e.message });
485
+ throw e;
486
+ }
487
+ finally {
488
+ s.close();
489
+ }
490
+ }
491
+ /** Gives up a journaled claim (the user declined to retry it): its journal is removed. */
492
+ export async function abandonInvitationClaim(storage, resource) {
493
+ await clearClaimJournal(storage, resource);
494
+ }
382
495
  /** The claimant's storage: the chain with its grant, then each DEK before the row that names it (LFCP-034). */
383
496
  async function persist(options, chain, deks) {
384
497
  const R = chain.state.resourceId;
@@ -1,5 +1,5 @@
1
1
  import { type ActorSequence, type ControlRecordId, type DataEpoch, type Hash32, type PrincipalId, type ResourceId } from "@openlfcp/core";
2
- import type { LfcpStorage, OutboundBlock, OutboundItem, OutboundKind } from "@openlfcp/storage";
2
+ import type { LfcpStorage, OutboundBlock, OutboundItem, OutboundKind, StorageWrite } from "@openlfcp/storage";
3
3
  import { type ControlView, type HaveVector, type LfcpMessage, type WireErrorName } from "@openlfcp/wire";
4
4
  /**
5
5
  * The pending outbound queue and its sync state (LFCP-036): a
@@ -66,6 +66,20 @@ export interface AckOutcome {
66
66
  /** False when the ACK answers no message of this session (matched by object ID only). */
67
67
  readonly correlated: boolean;
68
68
  }
69
+ /** An item an ACK removed from the queue, for writes that go with its removal. */
70
+ export interface AckedItem {
71
+ readonly itemId: Hash32;
72
+ readonly resourceId: ResourceId;
73
+ readonly kind: OutboundKind;
74
+ }
75
+ /**
76
+ * Writes committed in the same transaction as an ACK's dequeue, from the
77
+ * items it removed and what the ACK established (e.g. batch acceptance).
78
+ */
79
+ export type AckWrites = (acked: readonly AckedItem[], ack: {
80
+ readonly durability: bigint;
81
+ readonly correlated: boolean;
82
+ }) => Promise<readonly StorageWrite[]>;
69
83
  /** A blocked item as surfaced to the application. */
70
84
  export interface BlockedItem {
71
85
  readonly itemId: Hash32;
@@ -186,9 +200,10 @@ export declare class OutboundQueue {
186
200
  * An ACK (§59): removes the queued items whose IDs it names (field 1),
187
201
  * and records them in the Resource's sync state. Items of the
188
202
  * acknowledged message it does not name stay queued. Idempotent: a
189
- * repeated ACK, or one for items already gone, changes nothing.
203
+ * repeated ACK, or one for items already gone, changes nothing. `also`
204
+ * adds writes to the dequeue's transaction.
190
205
  */
191
- onAck(message: LfcpMessage<"ACK">, now: string): Promise<AckOutcome>;
206
+ onAck(message: LfcpMessage<"ACK">, now: string, also?: AckWrites): Promise<AckOutcome>;
192
207
  /**
193
208
  * A NACK (§60) of one of this session's messages. Per-object codes on a
194
209
  * message of several objects resend each object alone, to learn which one
package/dist/outbound.js CHANGED
@@ -294,14 +294,16 @@ export class OutboundQueue {
294
294
  * An ACK (§59): removes the queued items whose IDs it names (field 1),
295
295
  * and records them in the Resource's sync state. Items of the
296
296
  * acknowledged message it does not name stay queued. Idempotent: a
297
- * repeated ACK, or one for items already gone, changes nothing.
297
+ * repeated ACK, or one for items already gone, changes nothing. `also`
298
+ * adds writes to the dequeue's transaction.
298
299
  */
299
- async onAck(message, now) {
300
+ async onAck(message, now, also) {
300
301
  const flight = this.#land(message.correlationId);
301
302
  const named = message.body.objectIds ?? [];
302
303
  const durability = message.body.durable === true ? this.#durability : 0n;
303
304
  const requestType = message.body.requestType;
304
305
  const acked = [];
306
+ const ackedItems = [];
305
307
  const below = [];
306
308
  const byResource = new Map();
307
309
  for (const id of named) {
@@ -317,6 +319,7 @@ export class OutboundQueue {
317
319
  continue;
318
320
  }
319
321
  acked.push(item.itemId);
322
+ ackedItems.push({ itemId: item.itemId, resourceId: item.resourceId, kind: item.kind });
320
323
  const key = toHex(item.resourceId);
321
324
  byResource.set(key, [...(byResource.get(key) ?? []), item.itemId]);
322
325
  this.#forget(item.itemId);
@@ -345,6 +348,8 @@ export class OutboundQueue {
345
348
  },
346
349
  });
347
350
  }
351
+ if (also !== undefined && ackedItems.length > 0)
352
+ writes.push(...(await also(ackedItems, { durability, correlated: flight !== undefined })));
348
353
  await this.#commit(writes);
349
354
  const notCovered = flight?.itemIds.filter((id) => !acked.some((a) => bytesEqual(a, id)) && !below.some((b) => bytesEqual(b, id))) ?? [];
350
355
  const stillQueued = [];
package/dist/queue.d.ts CHANGED
@@ -19,6 +19,8 @@ export declare const outboundItem: (kind: OutboundKind, itemId: Hash32, resource
19
19
  * (adoptStoredDeks) with no network round trip.
20
20
  */
21
21
  export declare function queueKeyEpoch(storage: Pick<LfcpStorage, "commit">, secrets: SecretStore, rotation: EpochRotation, also?: readonly StorageWrite[]): Promise<Hash32>;
22
+ /** The writes that store a sealed Key Package (§25) and queue it, for one commit with others. */
23
+ export declare function keyPackageWrites(bytes: Uint8Array): StorageWrite[];
22
24
  /** Stores a sealed Key Package (§25) and queues it for KEY_PACKAGE_PUT. */
23
25
  export declare function queueKeyPackage(storage: Pick<LfcpStorage, "commit">, bytes: Uint8Array, also?: readonly StorageWrite[]): Promise<Hash32>;
24
26
  /** Stores a sealed Snapshot (§29) with its selection metadata and queues it for SNAPSHOT_PUT. */
@@ -35,4 +37,6 @@ export declare function createQueuedSnapshot<T>(storage: Pick<LfcpStorage, "snap
35
37
  * validated chain contains it (saveControlChain).
36
38
  */
37
39
  export declare function queueControlRecord(storage: Pick<LfcpStorage, "commit">, bytes: Uint8Array, also?: readonly StorageWrite[]): Promise<Hash32>;
40
+ /** The write that queues a signed Control Record for CONTROL_PUT, for one commit with others. */
41
+ export declare function controlRecordWrites(bytes: Uint8Array): StorageWrite[];
38
42
  //# sourceMappingURL=queue.d.ts.map
package/dist/queue.js CHANGED
@@ -39,12 +39,12 @@ export async function queueKeyEpoch(storage, secrets, rotation, also = []) {
39
39
  await secrets.put(dekSecretRef(resource, rotation.epoch), exportSecretKeyBytes(rotation.dek));
40
40
  return queueControlRecord(storage, rotation.bytes, also);
41
41
  }
42
- /** Stores a sealed Key Package (§25) and queues it for KEY_PACKAGE_PUT. */
43
- export async function queueKeyPackage(storage, bytes, also = []) {
42
+ /** The writes that store a sealed Key Package (§25) and queue it, for one commit with others. */
43
+ export function keyPackageWrites(bytes) {
44
44
  const parsed = parseKeyPackage(bytes);
45
45
  const p = parsed.payload;
46
46
  const id = hash32(parsed.signed.id);
47
- await commit(storage, [
47
+ return [
48
48
  {
49
49
  op: "put-key-package",
50
50
  row: {
@@ -57,9 +57,12 @@ export async function queueKeyPackage(storage, bytes, also = []) {
57
57
  },
58
58
  },
59
59
  { op: "enqueue", item: outboundItem("key-package", id, p.resourceId, parsed.signed.bytes) },
60
- ...also,
61
- ]);
62
- return id;
60
+ ];
61
+ }
62
+ /** Stores a sealed Key Package (§25) and queues it for KEY_PACKAGE_PUT. */
63
+ export async function queueKeyPackage(storage, bytes, also = []) {
64
+ await commit(storage, [...keyPackageWrites(bytes), ...also]);
65
+ return hash32(parseKeyPackage(bytes).signed.id);
63
66
  }
64
67
  /** Stores a sealed Snapshot (§29) with its selection metadata and queues it for SNAPSHOT_PUT. */
65
68
  export async function queueSnapshot(storage, bytes, also = []) {
@@ -100,17 +103,20 @@ export async function createQueuedSnapshot(storage, options, also = []) {
100
103
  * validated chain contains it (saveControlChain).
101
104
  */
102
105
  export async function queueControlRecord(storage, bytes, also = []) {
106
+ await commit(storage, [...controlRecordWrites(bytes), ...also]);
107
+ return hash32(parseControlRecord(bytes).signed.id);
108
+ }
109
+ /** The write that queues a signed Control Record for CONTROL_PUT, for one commit with others. */
110
+ export function controlRecordWrites(bytes) {
103
111
  const parsed = parseControlRecord(bytes);
104
112
  if (parsed.payload.prevControlId === null)
105
113
  throw new Error("a Genesis record is hosted with RESOURCE_HOST, not CONTROL_PUT");
106
114
  const id = hash32(parsed.signed.id);
107
- await commit(storage, [
115
+ return [
108
116
  {
109
117
  op: "enqueue",
110
118
  item: outboundItem("control-record", id, parsed.payload.resourceId, parsed.signed.bytes),
111
119
  },
112
- ...also,
113
- ]);
114
- return id;
120
+ ];
115
121
  }
116
122
  //# sourceMappingURL=queue.js.map
@@ -0,0 +1,60 @@
1
+ import { type DataUnitId, type ResourceId } from "@openlfcp/core";
2
+ import type { LfcpStorage, StorageWrite } from "@openlfcp/storage";
3
+ /**
4
+ * Commit receipts (SDK-SECTIONS-INTEGRATION-01 §3, LFCP-02-025): the
5
+ * durable record that a batch of intents was committed locally, written in
6
+ * the same storage transaction as its Data Units, outbound entries and
7
+ * profile checkpoint (commitOperation). A receipt is kept as a local mark
8
+ * under its Resource and operation ID until the caller releases it.
9
+ *
10
+ * Released before its batch is final (accepted or rejected, §4.1), a
11
+ * receipt is no longer the caller's: receiptOf and receiptsOf no longer
12
+ * return it. Its batch is still reported (batchStatuses, the status
13
+ * snapshot) until it is final, and then forgotten; for that the receipt is
14
+ * kept under a `released-batch:` mark.
15
+ */
16
+ /** §3.2: what a committed batch produced. */
17
+ export interface Receipt {
18
+ readonly operationId: string;
19
+ /** The Data Unit IDs of the batch, in the order of their changes. */
20
+ readonly unitIds: readonly DataUnitId[];
21
+ /** The nodes the batch writes (and the section when it writes the title). */
22
+ readonly affectedNodeIds: readonly string[];
23
+ /** The local document's heads after the commit, sorted, as one opaque string. */
24
+ readonly modelRevision: string;
25
+ /** §3.3: SHA-256 (hex) of the batch's canonical form. */
26
+ readonly intentsHash: string;
27
+ /** Always true: a receipt exists only for a durable commit. */
28
+ readonly durable: true;
29
+ }
30
+ /** §3.3: an operation ID that already has a receipt for different intents. A local SDK error. */
31
+ export declare class OperationIdReusedError extends Error {
32
+ readonly operationId: string;
33
+ readonly code = "OPERATION_ID_REUSED";
34
+ constructor(operationId: string);
35
+ }
36
+ /**
37
+ * §3.3: SHA-256 (hex) of the deterministic CBOR (RFC 8949 §4.2.1) of the
38
+ * list of intents in order, each a map from its field names to their
39
+ * values, Text as given, nothing normalized.
40
+ */
41
+ export declare function intentsHash(intents: readonly unknown[]): string;
42
+ /** The local-mark write that stores `receipt` (for the commit's transaction). */
43
+ export declare function receiptWrite(resource: ResourceId, receipt: Receipt): StorageWrite;
44
+ /** §3.4: the receipt of an operation, definitive across restarts; undefined when nothing was committed. */
45
+ export declare function receiptOf(storage: Pick<LfcpStorage, "localMarks">, resource: ResourceId, operationId: string): Promise<Receipt | undefined>;
46
+ /** The receipt of a released batch that is not final yet; undefined otherwise. */
47
+ export declare function releasedReceiptOf(storage: Pick<LfcpStorage, "localMarks">, resource: ResourceId, operationId: string): Promise<Receipt | undefined>;
48
+ /** The receipts of a Resource's released batches that are not final yet. */
49
+ export declare function releasedReceiptsOf(storage: Pick<LfcpStorage, "localMarks">, resource: ResourceId): Promise<Receipt[]>;
50
+ /** Keeps a released receipt for its batch's status (or, with null, forgets it). */
51
+ export declare function releasedWrite(resource: ResourceId, operationId: string, receipt: Receipt | null): StorageWrite;
52
+ /** Every receipt of a Resource that is not released yet. */
53
+ export declare function receiptsOf(storage: Pick<LfcpStorage, "localMarks">, resource: ResourceId): Promise<Receipt[]>;
54
+ /**
55
+ * §3.5: forgets a receipt the caller has finished with. Its batch status
56
+ * goes with it when the batch is final (accepted or rejected); otherwise
57
+ * the batch is still reported until it is final.
58
+ */
59
+ export declare function releaseReceipt(storage: Pick<LfcpStorage, "commit" | "localMarks">, resource: ResourceId, operationId: string): Promise<void>;
60
+ //# sourceMappingURL=receipts.d.ts.map
@@ -0,0 +1,118 @@
1
+ import { dataUnitId, fromHex, toHex } from "@openlfcp/core";
2
+ import { sha256 } from "@openlfcp/crypto";
3
+ import { cborMap, encode } from "@openlfcp/wire/cbor";
4
+ import { releaseWrites } from "./batch-status.js";
5
+ /** §3.3: an operation ID that already has a receipt for different intents. A local SDK error. */
6
+ export class OperationIdReusedError extends Error {
7
+ operationId;
8
+ code = "OPERATION_ID_REUSED";
9
+ constructor(operationId) {
10
+ super(`operation ${operationId} already committed different intents (§3.3)`);
11
+ this.operationId = operationId;
12
+ this.name = "OperationIdReusedError";
13
+ }
14
+ }
15
+ const PREFIX = "receipt:";
16
+ const markKey = (resource, operationId) => `${PREFIX}${toHex(resource)}:${operationId}`;
17
+ const RELEASED = "released-batch:";
18
+ const releasedKey = (resource, operationId) => `${RELEASED}${toHex(resource)}:${operationId}`;
19
+ /** A plain value as the CBOR data model: objects become maps, absent fields are left out. */
20
+ function cbor(value, depth = 0) {
21
+ if (depth > 64)
22
+ throw new Error("an intent nests too deep to hash");
23
+ if (value === null || typeof value === "boolean" || typeof value === "string")
24
+ return value;
25
+ if (typeof value === "number") {
26
+ if (!Number.isSafeInteger(value))
27
+ throw new Error(`an intent holds a non-integer number: ${value}`);
28
+ return value;
29
+ }
30
+ if (typeof value === "bigint" || value instanceof Uint8Array)
31
+ return value;
32
+ if (Array.isArray(value))
33
+ return value.map((v) => cbor(v, depth + 1));
34
+ if (typeof value === "object")
35
+ return cborMap(Object.entries(value)
36
+ .filter(([, v]) => v !== undefined)
37
+ .map(([k, v]) => [k, cbor(v, depth + 1)]));
38
+ throw new Error(`an intent holds a ${typeof value}, which has no canonical form`);
39
+ }
40
+ /**
41
+ * §3.3: SHA-256 (hex) of the deterministic CBOR (RFC 8949 §4.2.1) of the
42
+ * list of intents in order, each a map from its field names to their
43
+ * values, Text as given, nothing normalized.
44
+ */
45
+ export function intentsHash(intents) {
46
+ return toHex(sha256(encode(cbor(intents))));
47
+ }
48
+ /** The receipt as stored in a local mark. */
49
+ function storedReceipt(receipt) {
50
+ const stored = {
51
+ operationId: receipt.operationId,
52
+ unitIds: receipt.unitIds.map((u) => toHex(u)),
53
+ affectedNodeIds: [...receipt.affectedNodeIds],
54
+ modelRevision: receipt.modelRevision,
55
+ intentsHash: receipt.intentsHash,
56
+ };
57
+ return JSON.stringify(stored);
58
+ }
59
+ /** The local-mark write that stores `receipt` (for the commit's transaction). */
60
+ export function receiptWrite(resource, receipt) {
61
+ return {
62
+ op: "put-local-mark",
63
+ key: markKey(resource, receipt.operationId),
64
+ value: storedReceipt(receipt),
65
+ };
66
+ }
67
+ const parse = (raw) => {
68
+ const s = JSON.parse(raw);
69
+ return Object.freeze({
70
+ operationId: s.operationId,
71
+ unitIds: Object.freeze(s.unitIds.map((u) => dataUnitId(fromHex(u)))),
72
+ affectedNodeIds: Object.freeze([...s.affectedNodeIds]),
73
+ modelRevision: s.modelRevision,
74
+ intentsHash: s.intentsHash,
75
+ durable: true,
76
+ });
77
+ };
78
+ /** §3.4: the receipt of an operation, definitive across restarts; undefined when nothing was committed. */
79
+ export async function receiptOf(storage, resource, operationId) {
80
+ const raw = await storage.localMarks.get(markKey(resource, operationId));
81
+ return raw === undefined ? undefined : parse(raw);
82
+ }
83
+ /** The receipt of a released batch that is not final yet; undefined otherwise. */
84
+ export async function releasedReceiptOf(storage, resource, operationId) {
85
+ const raw = await storage.localMarks.get(releasedKey(resource, operationId));
86
+ return raw === undefined ? undefined : parse(raw);
87
+ }
88
+ /** The receipts of a Resource's released batches that are not final yet. */
89
+ export async function releasedReceiptsOf(storage, resource) {
90
+ return (await storage.localMarks.list(`${RELEASED}${toHex(resource)}:`)).map((m) => parse(m.value));
91
+ }
92
+ /** Keeps a released receipt for its batch's status (or, with null, forgets it). */
93
+ export function releasedWrite(resource, operationId, receipt) {
94
+ return {
95
+ op: "put-local-mark",
96
+ key: releasedKey(resource, operationId),
97
+ value: receipt === null ? null : storedReceipt(receipt),
98
+ };
99
+ }
100
+ /** Every receipt of a Resource that is not released yet. */
101
+ export async function receiptsOf(storage, resource) {
102
+ return (await storage.localMarks.list(`${PREFIX}${toHex(resource)}:`)).map((m) => parse(m.value));
103
+ }
104
+ /**
105
+ * §3.5: forgets a receipt the caller has finished with. Its batch status
106
+ * goes with it when the batch is final (accepted or rejected); otherwise
107
+ * the batch is still reported until it is final.
108
+ */
109
+ export async function releaseReceipt(storage, resource, operationId) {
110
+ const receipt = await receiptOf(storage, resource, operationId);
111
+ const r = await storage.commit([
112
+ { op: "put-local-mark", key: markKey(resource, operationId), value: null },
113
+ ...(receipt === undefined ? [] : await releaseWrites(storage, resource, receipt)),
114
+ ]);
115
+ if (!r.ok)
116
+ throw new Error(`the receipt was not released: ${r.reason}`);
117
+ }
118
+ //# sourceMappingURL=receipts.js.map
@@ -0,0 +1,71 @@
1
+ import { type ControlRecordId, type DataEpoch, type PrincipalId } from "@openlfcp/core";
2
+ import type { ResourceDEK } from "@openlfcp/crypto";
3
+ import { type ChainResult, type HaveVector, type Signer } from "@openlfcp/wire";
4
+ /**
5
+ * Removing a member's access (LFCP-02-060, LFCP-WIRE-01 §17.3, §19, §25):
6
+ * every active grant naming the member that the revoker may revoke is
7
+ * revoked, which also deactivates the grants delegated from it (§17.2);
8
+ * then, when the member has no read access left, the Data Epoch is rotated
9
+ * with a fresh DEK (§19, reason "member revoked"), and the new DEK goes in
10
+ * Key Packages to every remaining reader, so the member cannot read new
11
+ * data. Every record is built on the validated head and checked by
12
+ * extending the chain before anything is queued.
13
+ */
14
+ type Linear = Extract<ChainResult, {
15
+ kind: "linear";
16
+ }>;
17
+ /** A grant naming the member that the revoker may not revoke: the member keeps access through it. */
18
+ export interface RemainingPath {
19
+ readonly grantId: ControlRecordId;
20
+ readonly issuer: PrincipalId;
21
+ readonly abilities: readonly string[];
22
+ }
23
+ /** What a revocation writes, before it is queued. */
24
+ export interface RevocationPlan {
25
+ /** The grants revoked, in record order. */
26
+ readonly revoked: readonly ControlRecordId[];
27
+ /** Grants delegated from them that the revocations deactivate (§17.2). */
28
+ readonly deactivated: readonly ControlRecordId[];
29
+ /** The signed Control Records in order: the revocations, then the Key Epoch if any. */
30
+ readonly records: readonly {
31
+ readonly recordId: ControlRecordId;
32
+ readonly bytes: Uint8Array;
33
+ }[];
34
+ /** The new epoch and its DEK, when the epoch is rotated. */
35
+ readonly rotation: {
36
+ readonly epoch: DataEpoch;
37
+ readonly dek: ResourceDEK;
38
+ } | null;
39
+ /** Sealed Key Packages of the new DEK, one per remaining reader. */
40
+ readonly keyPackages: readonly Uint8Array[];
41
+ /** The readers who receive them. */
42
+ readonly recipients: readonly PrincipalId[];
43
+ readonly remainingPaths: readonly RemainingPath[];
44
+ }
45
+ export type RevocationRefusal =
46
+ /** The member is the Resource's owner: ownership is transferred, never revoked. */
47
+ "would-remove-owner"
48
+ /** No active grant names the member. */
49
+ | "not-member"
50
+ /** The revoker may revoke none of the member's grants, or may not rotate the epoch. */
51
+ | "not-authorized";
52
+ export declare class RevocationRefused extends Error {
53
+ readonly reason: RevocationRefusal;
54
+ constructor(reason: RevocationRefusal, message: string);
55
+ }
56
+ /**
57
+ * Plans the removal of `subject` by `revoker` on the validated chain
58
+ * `view`. `frontier` is the closing epoch's accepted final frontier (the
59
+ * local Have). With `rotate: false`, or when the member keeps read access
60
+ * through a remaining path, the epoch is not rotated. Throws
61
+ * RevocationRefused.
62
+ */
63
+ export declare function planRevocation(options: {
64
+ readonly view: Linear;
65
+ readonly revoker: Signer;
66
+ readonly subject: PrincipalId;
67
+ readonly frontier: HaveVector;
68
+ readonly rotate?: boolean;
69
+ }): Promise<RevocationPlan>;
70
+ export {};
71
+ //# sourceMappingURL=revoke.d.ts.map