@openlfcp/client 0.1.5 → 0.2.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/access-recovery.d.ts +69 -0
- package/dist/access-recovery.js +45 -0
- package/dist/batch-status.d.ts +104 -0
- package/dist/batch-status.js +171 -0
- package/dist/claim-journal.d.ts +54 -0
- package/dist/claim-journal.js +52 -0
- package/dist/index.d.ts +12 -5
- package/dist/index.js +11 -4
- package/dist/invite.d.ts +41 -2
- package/dist/invite.js +115 -2
- package/dist/outbound.d.ts +18 -3
- package/dist/outbound.js +7 -2
- package/dist/queue.d.ts +4 -0
- package/dist/queue.js +16 -10
- package/dist/receipts.d.ts +60 -0
- package/dist/receipts.js +118 -0
- package/dist/revoke.d.ts +71 -0
- package/dist/revoke.js +108 -0
- package/dist/section-status.d.ts +94 -0
- package/dist/section-status.js +16 -0
- package/dist/storage.d.ts +29 -0
- package/dist/storage.js +65 -0
- package/dist/sync-client.d.ts +148 -3
- package/dist/sync-client.js +736 -11
- package/dist/typing.d.ts +60 -0
- package/dist/typing.js +110 -0
- package/dist/write-access.d.ts +115 -0
- package/dist/write-access.js +87 -0
- package/package.json +6 -6
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" ||
|
|
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;
|
package/dist/outbound.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
43
|
-
export
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
115
|
+
return [
|
|
108
116
|
{
|
|
109
117
|
op: "enqueue",
|
|
110
118
|
item: outboundItem("control-record", id, parsed.payload.resourceId, parsed.signed.bytes),
|
|
111
119
|
},
|
|
112
|
-
|
|
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
|
package/dist/receipts.js
ADDED
|
@@ -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
|
package/dist/revoke.d.ts
ADDED
|
@@ -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
|