@openlfcp/storage 0.1.5 → 0.2.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/contract.js +139 -0
- package/dist/delete-resource.d.ts +14 -0
- package/dist/delete-resource.js +21 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -0
- package/dist/local-state.d.ts +145 -0
- package/dist/local-state.js +214 -0
- package/dist/memory.d.ts +1 -1
- package/dist/memory.js +46 -0
- package/dist/secrets.d.ts +4 -3
- package/dist/secrets.js +1 -0
- package/dist/store.d.ts +37 -1
- package/dist/store.js +18 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -12,9 +12,9 @@ ESM only; Node.js 24 or later (browsers and Electron for every package except `@
|
|
|
12
12
|
|
|
13
13
|
Part of the OpenLFCP TypeScript SDK, which implements the OpenLFCP MVP 0.1
|
|
14
14
|
subset of [LFCP-WIRE-01](https://github.com/openlfcp/spec/blob/main/wire/LFCP-WIRE-01.md)
|
|
15
|
-
at `mvp-0.1-baseline.
|
|
15
|
+
at `mvp-0.1-baseline.9`, not every deferred WIRE-01 feature. It does not
|
|
16
16
|
claim full LFCP-WIRE-01 conformance. This is the MVP 0.1 release
|
|
17
|
-
(`0.1.
|
|
17
|
+
(`0.1.3`, npm dist-tag `latest`). Until 1.0, minor versions may change APIs.
|
|
18
18
|
|
|
19
19
|
## Links
|
|
20
20
|
|
package/dist/contract.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { actorSequence, bytesEqual, controlRecordId, dataEpoch, dataUnitId, hash32, LfcpError, principalId, resourceId, toHex, } from "@openlfcp/core";
|
|
2
|
+
import { deleteUnsentResource } from "./delete-resource.js";
|
|
2
3
|
import { dekSecretRef, isSecretRef, principalKeySecretRef } from "./secrets.js";
|
|
3
4
|
// ---------------------------------------------------------------------------
|
|
4
5
|
// assertions (no test framework)
|
|
@@ -508,6 +509,144 @@ export function runStorageContract(t, name, open) {
|
|
|
508
509
|
if (!bytesEqual(record(0, null).bytes, (await r.control.records(R))[0]?.bytes ?? new Uint8Array()))
|
|
509
510
|
throw new Error("exact bytes");
|
|
510
511
|
});
|
|
512
|
+
/** Everything a client stores of R before sending it, and another Resource R2. */
|
|
513
|
+
const unsent = () => [
|
|
514
|
+
{ op: "put-resource", row: resourceRow },
|
|
515
|
+
{ op: "put-route", resourceId: R, route },
|
|
516
|
+
{ op: "put-control-records", records: [record(0, null), record(1, 0)] },
|
|
517
|
+
{
|
|
518
|
+
op: "set-control-head",
|
|
519
|
+
resourceId: R,
|
|
520
|
+
expected: null,
|
|
521
|
+
head: { head: record(1, 0).recordId, controlSeq: 1n },
|
|
522
|
+
},
|
|
523
|
+
{ op: "set-control-conflict", resourceId: R, conflict: { heads: [record(1, 0).recordId] } },
|
|
524
|
+
...epochRows.map((epoch) => ({ op: "put-epoch", resourceId: R, epoch })),
|
|
525
|
+
{ op: "put-data-unit", unit: unit(1, 1n), status: "merged", accepted: true },
|
|
526
|
+
{ op: "put-key-package", row: keyPackage(1) },
|
|
527
|
+
{ op: "put-snapshot", row: snapshot },
|
|
528
|
+
{
|
|
529
|
+
op: "enqueue",
|
|
530
|
+
item: {
|
|
531
|
+
itemId: hash32(id(160)),
|
|
532
|
+
resourceId: R,
|
|
533
|
+
kind: "control-record",
|
|
534
|
+
bytes: Uint8Array.of(1),
|
|
535
|
+
attempts: 0,
|
|
536
|
+
lastAttempt: null,
|
|
537
|
+
nextAttempt: null,
|
|
538
|
+
blocked: null,
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
{ op: "put-profile-checkpoint", checkpoint },
|
|
542
|
+
{ op: "put-sync-state", row: { resourceId: R, recentlyAcked: [], ackedDurability: null } },
|
|
543
|
+
{ op: "put-resource", row: { ...resourceRow, resourceId: R2 } },
|
|
544
|
+
{
|
|
545
|
+
op: "enqueue",
|
|
546
|
+
item: {
|
|
547
|
+
itemId: hash32(id(161)),
|
|
548
|
+
resourceId: R2,
|
|
549
|
+
kind: "control-record",
|
|
550
|
+
bytes: Uint8Array.of(2),
|
|
551
|
+
attempts: 3,
|
|
552
|
+
lastAttempt: "2026-10-05T12:00:00Z",
|
|
553
|
+
nextAttempt: null,
|
|
554
|
+
blocked: null,
|
|
555
|
+
},
|
|
556
|
+
},
|
|
557
|
+
{ op: "put-local-mark", key: "apply:x", value: "1" },
|
|
558
|
+
];
|
|
559
|
+
test("deletes a Resource never sent, every row of it at once; others, marks and counters stay", async ({ storage: s, }) => {
|
|
560
|
+
const reserved = await s.actorSequences.reserveNext(R, ALICE);
|
|
561
|
+
await ok(s, unsent(), "stored");
|
|
562
|
+
await ok(s, [{ op: "delete-resource", resourceId: R }], "deleted");
|
|
563
|
+
eq(await s.resources.get(R), undefined, "resource");
|
|
564
|
+
eq(await s.resources.route(R), undefined, "route");
|
|
565
|
+
eq(await s.control.records(R), [], "records");
|
|
566
|
+
eq(await s.control.head(R), undefined, "head");
|
|
567
|
+
eq(await s.control.conflict(R), undefined, "conflict");
|
|
568
|
+
eq(await s.control.epochs(R), [], "epochs");
|
|
569
|
+
eq(await s.dataUnits.get(unit(1, 1n).unitId), undefined, "unit");
|
|
570
|
+
eq(await s.dataUnits.withStatus(R, "merged"), [], "units by status");
|
|
571
|
+
eq(await s.keyPackages.list(R), [], "key packages");
|
|
572
|
+
eq(await s.snapshots.list(R), [], "snapshots");
|
|
573
|
+
eq(await s.outbound.list(R), [], "outbound");
|
|
574
|
+
eq(await s.profileState.checkpoint(R), undefined, "checkpoint");
|
|
575
|
+
eq(await s.syncState.get(R), undefined, "sync state");
|
|
576
|
+
eq((await s.resources.list()).map((r) => r.resourceId), [R2], "the other Resource");
|
|
577
|
+
eq((await s.outbound.list(R2)).length, 1, "its outbound item");
|
|
578
|
+
eq(await s.localMarks.get("apply:x"), "1", "local marks");
|
|
579
|
+
const next = await s.actorSequences.reserveNext(R, ALICE);
|
|
580
|
+
if (next <= reserved)
|
|
581
|
+
throw new Error(`actor sequence reused: ${next} after ${reserved}`);
|
|
582
|
+
await ok(s, [{ op: "delete-resource", resourceId: R }], "deleting again is harmless");
|
|
583
|
+
});
|
|
584
|
+
test("refuses to delete a Resource that may have been sent, writing nothing of the batch", async ({ storage: s, }) => {
|
|
585
|
+
await ok(s, unsent(), "stored");
|
|
586
|
+
const cases = [
|
|
587
|
+
[
|
|
588
|
+
{
|
|
589
|
+
op: "update-outbound",
|
|
590
|
+
itemId: hash32(id(160)),
|
|
591
|
+
attempts: 1,
|
|
592
|
+
lastAttempt: "2026-10-05T12:00:00Z",
|
|
593
|
+
},
|
|
594
|
+
"attempted",
|
|
595
|
+
],
|
|
596
|
+
[
|
|
597
|
+
{
|
|
598
|
+
op: "put-sync-state",
|
|
599
|
+
row: { resourceId: R, recentlyAcked: [hash32(id(160))], ackedDurability: 1n },
|
|
600
|
+
},
|
|
601
|
+
"acked",
|
|
602
|
+
],
|
|
603
|
+
[
|
|
604
|
+
{ op: "put-data-unit", unit: unit(2, 1n, BOB), status: "merged", accepted: true },
|
|
605
|
+
"received",
|
|
606
|
+
],
|
|
607
|
+
];
|
|
608
|
+
for (const [shown, detail] of cases) {
|
|
609
|
+
const h = await s.commit([shown]);
|
|
610
|
+
eq(h, { ok: true }, `stored: ${detail}`);
|
|
611
|
+
const refused = await s.commit([
|
|
612
|
+
{ op: "put-local-mark", key: "batch", value: "written" },
|
|
613
|
+
{ op: "delete-resource", resourceId: R },
|
|
614
|
+
]);
|
|
615
|
+
eq(refused, { ok: false, reason: "RESOURCE_SENT", resourceId: R, detail }, detail);
|
|
616
|
+
eq(await s.localMarks.get("batch"), undefined, `nothing of the batch: ${detail}`);
|
|
617
|
+
eq((await s.resources.get(R))?.resourceId, R, `still stored: ${detail}`);
|
|
618
|
+
// Undone for the next case (a unit cannot be removed: it comes last).
|
|
619
|
+
if (detail === "attempted")
|
|
620
|
+
await ok(s, [{ op: "update-outbound", itemId: hash32(id(160)), attempts: 0, lastAttempt: null }], "undone");
|
|
621
|
+
if (detail === "acked")
|
|
622
|
+
await ok(s, [
|
|
623
|
+
{
|
|
624
|
+
op: "put-sync-state",
|
|
625
|
+
row: { resourceId: R, recentlyAcked: [], ackedDurability: null },
|
|
626
|
+
},
|
|
627
|
+
], "undone");
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
test("deleteUnsentResource deletes the Resource's keys, never the Principal's", async ({ storage: s, secrets, }) => {
|
|
631
|
+
await ok(s, unsent(), "stored");
|
|
632
|
+
const keys = [
|
|
633
|
+
dekSecretRef(R, E0),
|
|
634
|
+
dekSecretRef(R, E1),
|
|
635
|
+
principalKeySecretRef(ALICE, "signing"),
|
|
636
|
+
];
|
|
637
|
+
for (const k of keys)
|
|
638
|
+
await secrets.put(k, Uint8Array.of(42));
|
|
639
|
+
eq(await deleteUnsentResource(s, secrets, R), { ok: true }, "deleted");
|
|
640
|
+
eq(await secrets.get(dekSecretRef(R, E0)), undefined, "epoch 0 key");
|
|
641
|
+
eq(await secrets.get(dekSecretRef(R, E1)), undefined, "epoch 1 key");
|
|
642
|
+
eq(await secrets.get(principalKeySecretRef(ALICE, "signing")), Uint8Array.of(42), "Principal key");
|
|
643
|
+
// A Resource that may have been sent: refused, its keys kept.
|
|
644
|
+
await ok(s, unsent(), "stored again");
|
|
645
|
+
await ok(s, [{ op: "put-data-unit", unit: unit(2, 1n, BOB), status: "merged", accepted: true }], "received");
|
|
646
|
+
await secrets.put(dekSecretRef(R, E1), Uint8Array.of(7));
|
|
647
|
+
eq(await deleteUnsentResource(s, secrets, R), { ok: false, reason: "RESOURCE_SENT", resourceId: R, detail: "received" }, "refused");
|
|
648
|
+
eq(await secrets.get(dekSecretRef(R, E1)), Uint8Array.of(7), "key kept");
|
|
649
|
+
});
|
|
511
650
|
});
|
|
512
651
|
}
|
|
513
652
|
//# sourceMappingURL=contract.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ResourceId } from "@openlfcp/core";
|
|
2
|
+
import { type SecretStore } from "./secrets.js";
|
|
3
|
+
import type { CommitResult, LfcpStorage } from "./store.js";
|
|
4
|
+
/**
|
|
5
|
+
* Forgets a Resource this client made and never sent (a share given up
|
|
6
|
+
* before it was hosted): every stored row of it in one atomic commit
|
|
7
|
+
* (delete-resource), then the secrets only it used, its Data Epoch keys.
|
|
8
|
+
* The local Principal's keys are kept: other Resources use them. Refused,
|
|
9
|
+
* with nothing deleted, when the Resource may have been sent
|
|
10
|
+
* (RESOURCE_SENT). The rows go first: a crash before the keys are deleted
|
|
11
|
+
* leaves keys nothing refers to, never a Resource without its keys.
|
|
12
|
+
*/
|
|
13
|
+
export declare function deleteUnsentResource(storage: LfcpStorage, secrets: SecretStore, resource: ResourceId): Promise<CommitResult>;
|
|
14
|
+
//# sourceMappingURL=delete-resource.d.ts.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { dekSecretRef } from "./secrets.js";
|
|
2
|
+
/**
|
|
3
|
+
* Forgets a Resource this client made and never sent (a share given up
|
|
4
|
+
* before it was hosted): every stored row of it in one atomic commit
|
|
5
|
+
* (delete-resource), then the secrets only it used, its Data Epoch keys.
|
|
6
|
+
* The local Principal's keys are kept: other Resources use them. Refused,
|
|
7
|
+
* with nothing deleted, when the Resource may have been sent
|
|
8
|
+
* (RESOURCE_SENT). The rows go first: a crash before the keys are deleted
|
|
9
|
+
* leaves keys nothing refers to, never a Resource without its keys.
|
|
10
|
+
*/
|
|
11
|
+
export async function deleteUnsentResource(storage, secrets, resource) {
|
|
12
|
+
const epochs = await storage.control.epochs(resource);
|
|
13
|
+
const refs = new Set(epochs.map((e) => e.dekRef ?? dekSecretRef(resource, e.epoch)));
|
|
14
|
+
const result = await storage.commit([{ op: "delete-resource", resourceId: resource }]);
|
|
15
|
+
if (!result.ok)
|
|
16
|
+
return result;
|
|
17
|
+
for (const ref of refs)
|
|
18
|
+
await secrets.delete(ref);
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
//# sourceMappingURL=delete-resource.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
/** Name of this package. */
|
|
2
2
|
export declare const PACKAGE = "@openlfcp/storage";
|
|
3
|
+
export { deleteUnsentResource } from "./delete-resource.js";
|
|
4
|
+
export { LOCAL_STATE_SCHEME, type LocalStateCipher, type LocalStateDiagnostics, type LocalStateEvent, LocalStateKeyring, type LocalStateMeta, localStateAad, localStateKeyRef, type OpenedLocal, type ResealRow, reseal, } from "./local-state.js";
|
|
3
5
|
export { InMemoryLfcpStorage } from "./memory.js";
|
|
4
6
|
export { dekSecretRef, InMemorySecretStore, isSecretRef, principalKeySecretRef, type SecretKind, type SecretRef, type SecretStore, secretRef, } from "./secrets.js";
|
|
5
7
|
export { type ActorSequenceReservation, InMemoryActorSequenceReservation, nextActorSequence, SequenceReuseGuard, } from "./sequence.js";
|
|
6
8
|
export { InMemorySnapshotSequenceReservation, SnapshotSequenceGuard, type SnapshotSequenceReservation, } from "./snapshot-sequence.js";
|
|
7
|
-
export type { CommitResult, ControlConflictRow, ControlHeadRow, ControlReader, ControlRecordRow, DataUnitReader, DataUnitRow, DataUnitStatus, EpochRow, KeyPackageReader, KeyPackageRow, LfcpStorage, LocalMarkReader, OutboundBlock, OutboundItem, OutboundKind, OutboundReader, ProfileCheckpoint, ProfileStateReader, ResourceReader, ResourceRow, RouteRow, SeenRecord, SnapshotReader, SnapshotRow, StorageWrite, StoredDataUnit, SyncStateReader, SyncStateRow, } from "./store.js";
|
|
9
|
+
export type { CommitResult, ControlConflictRow, ControlHeadRow, ControlReader, ControlRecordRow, DataUnitReader, DataUnitRow, DataUnitStatus, EpochRow, KeyPackageReader, KeyPackageRow, LfcpStorage, LocalMarkReader, OutboundBlock, OutboundItem, OutboundKind, OutboundReader, ProfileCheckpoint, ProfileStateReader, ResourceReader, ResourceRow, ResourceSentDetail, RouteRow, SeenRecord, SnapshotReader, SnapshotRow, StorageWrite, StoredDataUnit, SyncStateReader, SyncStateRow, } from "./store.js";
|
|
10
|
+
export { resourceSent } from "./store.js";
|
|
8
11
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/** Name of this package. */
|
|
2
2
|
export const PACKAGE = "@openlfcp/storage";
|
|
3
|
+
export { deleteUnsentResource } from "./delete-resource.js";
|
|
4
|
+
export { LOCAL_STATE_SCHEME, LocalStateKeyring, localStateAad, localStateKeyRef, reseal, } from "./local-state.js";
|
|
3
5
|
export { InMemoryLfcpStorage } from "./memory.js";
|
|
4
6
|
export { dekSecretRef, InMemorySecretStore, isSecretRef, principalKeySecretRef, secretRef, } from "./secrets.js";
|
|
5
7
|
export { InMemoryActorSequenceReservation, nextActorSequence, SequenceReuseGuard, } from "./sequence.js";
|
|
6
8
|
export { InMemorySnapshotSequenceReservation, SnapshotSequenceGuard, } from "./snapshot-sequence.js";
|
|
9
|
+
export { resourceSent } from "./store.js";
|
|
7
10
|
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { type SecretRef, type SecretStore } from "./secrets.js";
|
|
2
|
+
/**
|
|
3
|
+
* Local state encrypted at rest (LFCP-02-098; .github
|
|
4
|
+
* docs/devel/design/local-state-encryption.md).
|
|
5
|
+
*
|
|
6
|
+
* A storage adapter opened with a SecretStore seals the rows that hold
|
|
7
|
+
* profile text (ProfileCheckpoint.state) with the install's local state key
|
|
8
|
+
* before they reach the disk, as `lse1` envelopes. The
|
|
9
|
+
* key lives only in the SecretStore, under
|
|
10
|
+
* `lfcp-secret:local-state-key:<install id>.<generation>`; the adapter's
|
|
11
|
+
* metadata records the scheme, the install ID and the current generation,
|
|
12
|
+
* never key bytes.
|
|
13
|
+
*
|
|
14
|
+
* The keyring decides what to do when an adapter opens:
|
|
15
|
+
* - a new install, or a database from before the scheme: create the key
|
|
16
|
+
* (secret first, then the metadata), then seal every plaintext row
|
|
17
|
+
* (`migrating`);
|
|
18
|
+
* - a migration or rotation left half done: continue it;
|
|
19
|
+
* - a missing key: never a crash and never a silent deletion. The next
|
|
20
|
+
* generation's key is created, rows sealed under the lost one read as
|
|
21
|
+
* unreadable (a checkpoint as absent: the profile state is rebuilt from
|
|
22
|
+
* the stored units), and they are replaced as they are rewritten.
|
|
23
|
+
* Rotation is explicit: the next generation's key is written, every row is
|
|
24
|
+
* sealed again, then the old key is removed.
|
|
25
|
+
*
|
|
26
|
+
* This package does no cryptography (it may not depend on @openlfcp/crypto,
|
|
27
|
+
* LFCP-014): the application passes the cipher, `localStateCipher` of
|
|
28
|
+
* @openlfcp/crypto, which keeps the key bytes inside that package.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* The envelope cipher: `localStateCipher` of @openlfcp/crypto
|
|
32
|
+
* (XChaCha20-Poly1305, `lse1` envelopes). Keys are opaque here.
|
|
33
|
+
*/
|
|
34
|
+
export interface LocalStateCipher<K = unknown> {
|
|
35
|
+
generateKey(): K;
|
|
36
|
+
importKey(bytes: Uint8Array): K;
|
|
37
|
+
/** A copy of the key bytes, for the SecretStore only. */
|
|
38
|
+
exportKey(key: K): Uint8Array;
|
|
39
|
+
seal(key: K, generation: number, aad: Uint8Array, plaintext: Uint8Array): Uint8Array;
|
|
40
|
+
/** Throws on a wrong key, AAD or generation, or tampered bytes. */
|
|
41
|
+
open(key: K, aad: Uint8Array, envelope: Uint8Array): Uint8Array;
|
|
42
|
+
isSealed(bytes: Uint8Array): boolean;
|
|
43
|
+
generationOf(bytes: Uint8Array): number | undefined;
|
|
44
|
+
}
|
|
45
|
+
/** The scheme an adapter records in its metadata. */
|
|
46
|
+
export declare const LOCAL_STATE_SCHEME = "lse-v1";
|
|
47
|
+
/** Something that happened to the local state key, for diagnostics. */
|
|
48
|
+
export interface LocalStateEvent {
|
|
49
|
+
readonly kind: "created" | "migrated" | "key-lost" | "rotated";
|
|
50
|
+
/** RFC 3339. */
|
|
51
|
+
readonly at: string;
|
|
52
|
+
}
|
|
53
|
+
/** The adapter's record of local encryption. It never holds key bytes. */
|
|
54
|
+
export interface LocalStateMeta {
|
|
55
|
+
readonly scheme: typeof LOCAL_STATE_SCHEME;
|
|
56
|
+
/** A random public name of this install, in every row's AAD and key reference. */
|
|
57
|
+
readonly installId: string;
|
|
58
|
+
/** The generation rows are sealed with. */
|
|
59
|
+
readonly generation: number;
|
|
60
|
+
/** `ready`, or a migration or rotation to continue on the next open. */
|
|
61
|
+
readonly phase: "migrating" | "rotating" | "ready";
|
|
62
|
+
/** While rotating: the generation being replaced. */
|
|
63
|
+
readonly previous: number | null;
|
|
64
|
+
readonly lastEvent: LocalStateEvent | null;
|
|
65
|
+
}
|
|
66
|
+
/** What an adapter's `localStateDiagnostics` reports: never key bytes, envelopes or plaintext. */
|
|
67
|
+
export interface LocalStateDiagnostics {
|
|
68
|
+
readonly scheme: string;
|
|
69
|
+
readonly generation: number;
|
|
70
|
+
readonly keyPresent: boolean;
|
|
71
|
+
readonly phase: LocalStateMeta["phase"];
|
|
72
|
+
/** Rows sealed under a held key, in plaintext, and sealed under a lost key. */
|
|
73
|
+
readonly rows: {
|
|
74
|
+
readonly sealed: number;
|
|
75
|
+
readonly plaintext: number;
|
|
76
|
+
readonly unreadable: number;
|
|
77
|
+
};
|
|
78
|
+
readonly lastEvent: LocalStateEvent | null;
|
|
79
|
+
}
|
|
80
|
+
/** A row's bytes as read: plaintext from before the scheme, opened, or unreadable. */
|
|
81
|
+
export type OpenedLocal = {
|
|
82
|
+
readonly kind: "plain";
|
|
83
|
+
readonly bytes: Uint8Array;
|
|
84
|
+
} | {
|
|
85
|
+
readonly kind: "opened";
|
|
86
|
+
readonly bytes: Uint8Array;
|
|
87
|
+
readonly generation: number;
|
|
88
|
+
} | {
|
|
89
|
+
readonly kind: "unreadable";
|
|
90
|
+
readonly reason: "missing-key" | "authentication";
|
|
91
|
+
};
|
|
92
|
+
/** The SecretStore reference of an install's key of one generation. */
|
|
93
|
+
export declare const localStateKeyRef: (installId: string, generation: number) => SecretRef;
|
|
94
|
+
/**
|
|
95
|
+
* The AAD of a row: the label, the install ID, the store and the row's
|
|
96
|
+
* primary key, each length-prefixed, so a row cannot be swapped with
|
|
97
|
+
* another one or moved to another install.
|
|
98
|
+
*/
|
|
99
|
+
export declare function localStateAad(installId: string, store: string, key: string): Uint8Array;
|
|
100
|
+
/** An install's local state keys and metadata, held by an open adapter. */
|
|
101
|
+
export declare class LocalStateKeyring {
|
|
102
|
+
#private;
|
|
103
|
+
readonly meta: LocalStateMeta;
|
|
104
|
+
private constructor();
|
|
105
|
+
/**
|
|
106
|
+
* The keyring for an adapter opening with `stored` metadata (undefined: a
|
|
107
|
+
* new install or a database from before the scheme). Writes any new key
|
|
108
|
+
* to `secrets` before it returns; the caller then records `meta` and runs
|
|
109
|
+
* `reseal` while `meta.phase` is not `ready`.
|
|
110
|
+
*/
|
|
111
|
+
static prepare(cipher: LocalStateCipher, secrets: SecretStore, stored: LocalStateMeta | undefined, now: string): Promise<LocalStateKeyring>;
|
|
112
|
+
/**
|
|
113
|
+
* Begins a rotation: writes the next generation's key to `secrets` and
|
|
114
|
+
* returns the keyring to record (phase `rotating`) before `reseal` runs.
|
|
115
|
+
*/
|
|
116
|
+
rotate(secrets: SecretStore, now: string): Promise<LocalStateKeyring>;
|
|
117
|
+
/** The keyring once `reseal` has finished: phase `ready`. */
|
|
118
|
+
finished(now: string): LocalStateKeyring;
|
|
119
|
+
/** Whether the current generation's key is held. */
|
|
120
|
+
get keyPresent(): boolean;
|
|
121
|
+
/** The envelope of a row's `plaintext`, under the current key. */
|
|
122
|
+
seal(store: string, key: string, plaintext: Uint8Array): Uint8Array;
|
|
123
|
+
/** A row's bytes as stored: plaintext, opened, or unreadable (never a throw). */
|
|
124
|
+
open(store: string, key: string, bytes: Uint8Array): OpenedLocal;
|
|
125
|
+
/** Whether a stored row needs sealing again: plaintext, or sealed under a readable older generation. */
|
|
126
|
+
needsReseal(bytes: Uint8Array): boolean;
|
|
127
|
+
/** The SecretStore reference of the key a finished rotation replaced, to remove. */
|
|
128
|
+
get retired(): SecretRef | null;
|
|
129
|
+
toJSON(): string;
|
|
130
|
+
}
|
|
131
|
+
/** One row to seal again, as an adapter scans it. */
|
|
132
|
+
export interface ResealRow {
|
|
133
|
+
readonly store: string;
|
|
134
|
+
readonly key: string;
|
|
135
|
+
readonly bytes: Uint8Array;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Seals every row that needs it, in batches: `scan` returns up to `limit`
|
|
139
|
+
* rows after `after` (null: from the start) in key order; `write` stores the
|
|
140
|
+
* sealed rows atomically. Resumable: a row already sealed under the current
|
|
141
|
+
* generation is skipped, so a crash between batches continues on the next
|
|
142
|
+
* open. A row whose key is lost is left as it is.
|
|
143
|
+
*/
|
|
144
|
+
export declare function reseal(keyring: LocalStateKeyring, scan: (after: string | null, limit: number) => Promise<readonly ResealRow[]>, write: (rows: readonly ResealRow[]) => Promise<void>, limit?: number): Promise<number>;
|
|
145
|
+
//# sourceMappingURL=local-state.d.ts.map
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { LfcpError, toHex } from "@openlfcp/core";
|
|
2
|
+
import { secretRef } from "./secrets.js";
|
|
3
|
+
/** The scheme an adapter records in its metadata. */
|
|
4
|
+
export const LOCAL_STATE_SCHEME = "lse-v1";
|
|
5
|
+
/** The SecretStore reference of an install's key of one generation. */
|
|
6
|
+
export const localStateKeyRef = (installId, generation) => secretRef("local-state-key", `${installId}.${generation}`);
|
|
7
|
+
const LABEL = "openlfcp-local-v1";
|
|
8
|
+
// WHATWG TextEncoder and Web Crypto's getRandomValues, which browsers,
|
|
9
|
+
// Node.js and editors all provide; declared here so the package needs no DOM
|
|
10
|
+
// or Node type library.
|
|
11
|
+
const g = globalThis;
|
|
12
|
+
const utf8 = (text) => new g.TextEncoder().encode(text);
|
|
13
|
+
/**
|
|
14
|
+
* The AAD of a row: the label, the install ID, the store and the row's
|
|
15
|
+
* primary key, each length-prefixed, so a row cannot be swapped with
|
|
16
|
+
* another one or moved to another install.
|
|
17
|
+
*/
|
|
18
|
+
export function localStateAad(installId, store, key) {
|
|
19
|
+
const parts = [utf8(LABEL), utf8(installId), utf8(store), utf8(key)];
|
|
20
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + 4 + p.length, 0));
|
|
21
|
+
const view = new DataView(out.buffer);
|
|
22
|
+
let at = 0;
|
|
23
|
+
for (const p of parts) {
|
|
24
|
+
view.setUint32(at, p.length);
|
|
25
|
+
out.set(p, at + 4);
|
|
26
|
+
at += 4 + p.length;
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
const randomInstallId = () => toHex(g.crypto.getRandomValues(new Uint8Array(16)));
|
|
31
|
+
async function loadKey(cipher, secrets, installId, generation) {
|
|
32
|
+
const bytes = await secrets.get(localStateKeyRef(installId, generation));
|
|
33
|
+
// An empty value marks a deleted secret where the store has no delete.
|
|
34
|
+
if (bytes === undefined || bytes.length === 0)
|
|
35
|
+
return undefined;
|
|
36
|
+
try {
|
|
37
|
+
return cipher.importKey(bytes);
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
bytes.fill(0);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async function storeKey(cipher, secrets, installId, generation) {
|
|
44
|
+
const key = cipher.generateKey();
|
|
45
|
+
const bytes = cipher.exportKey(key);
|
|
46
|
+
try {
|
|
47
|
+
await secrets.put(localStateKeyRef(installId, generation), bytes);
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
bytes.fill(0);
|
|
51
|
+
}
|
|
52
|
+
return key;
|
|
53
|
+
}
|
|
54
|
+
/** An install's local state keys and metadata, held by an open adapter. */
|
|
55
|
+
export class LocalStateKeyring {
|
|
56
|
+
#cipher;
|
|
57
|
+
#keys;
|
|
58
|
+
meta;
|
|
59
|
+
constructor(cipher, meta, keys) {
|
|
60
|
+
this.#cipher = cipher;
|
|
61
|
+
this.meta = Object.freeze({ ...meta });
|
|
62
|
+
this.#keys = keys;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The keyring for an adapter opening with `stored` metadata (undefined: a
|
|
66
|
+
* new install or a database from before the scheme). Writes any new key
|
|
67
|
+
* to `secrets` before it returns; the caller then records `meta` and runs
|
|
68
|
+
* `reseal` while `meta.phase` is not `ready`.
|
|
69
|
+
*/
|
|
70
|
+
static async prepare(cipher, secrets, stored, now) {
|
|
71
|
+
if (stored === undefined) {
|
|
72
|
+
const installId = randomInstallId();
|
|
73
|
+
const key = await storeKey(cipher, secrets, installId, 1);
|
|
74
|
+
return new LocalStateKeyring(cipher, {
|
|
75
|
+
scheme: LOCAL_STATE_SCHEME,
|
|
76
|
+
installId,
|
|
77
|
+
generation: 1,
|
|
78
|
+
phase: "migrating",
|
|
79
|
+
previous: null,
|
|
80
|
+
lastEvent: { kind: "created", at: now },
|
|
81
|
+
}, new Map([[1, key]]));
|
|
82
|
+
}
|
|
83
|
+
if (stored.scheme !== LOCAL_STATE_SCHEME)
|
|
84
|
+
throw new LfcpError("UNSUPPORTED_VALUE", `unknown local state scheme ${stored.scheme}`);
|
|
85
|
+
const keys = new Map();
|
|
86
|
+
const current = await loadKey(cipher, secrets, stored.installId, stored.generation);
|
|
87
|
+
if (stored.previous !== null) {
|
|
88
|
+
const previous = await loadKey(cipher, secrets, stored.installId, stored.previous);
|
|
89
|
+
if (previous !== undefined)
|
|
90
|
+
keys.set(stored.previous, previous);
|
|
91
|
+
}
|
|
92
|
+
if (current !== undefined) {
|
|
93
|
+
keys.set(stored.generation, current);
|
|
94
|
+
return new LocalStateKeyring(cipher, stored, keys);
|
|
95
|
+
}
|
|
96
|
+
// The current key is lost: start the next generation. Rows sealed under
|
|
97
|
+
// the lost key stay unreadable until they are rewritten.
|
|
98
|
+
const generation = stored.generation + 1;
|
|
99
|
+
keys.set(generation, await storeKey(cipher, secrets, stored.installId, generation));
|
|
100
|
+
return new LocalStateKeyring(cipher, {
|
|
101
|
+
...stored,
|
|
102
|
+
generation,
|
|
103
|
+
phase: stored.phase === "rotating" ? "rotating" : stored.phase,
|
|
104
|
+
lastEvent: { kind: "key-lost", at: now },
|
|
105
|
+
}, keys);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Begins a rotation: writes the next generation's key to `secrets` and
|
|
109
|
+
* returns the keyring to record (phase `rotating`) before `reseal` runs.
|
|
110
|
+
*/
|
|
111
|
+
async rotate(secrets, now) {
|
|
112
|
+
if (this.meta.phase !== "ready")
|
|
113
|
+
throw new LfcpError("UNSUPPORTED_VALUE", `the local state is ${this.meta.phase}`);
|
|
114
|
+
const generation = this.meta.generation + 1;
|
|
115
|
+
const keys = new Map(this.#keys);
|
|
116
|
+
keys.set(generation, await storeKey(this.#cipher, secrets, this.meta.installId, generation));
|
|
117
|
+
return new LocalStateKeyring(this.#cipher, {
|
|
118
|
+
...this.meta,
|
|
119
|
+
generation,
|
|
120
|
+
phase: "rotating",
|
|
121
|
+
previous: this.meta.generation,
|
|
122
|
+
lastEvent: { kind: "rotated", at: now },
|
|
123
|
+
}, keys);
|
|
124
|
+
}
|
|
125
|
+
/** The keyring once `reseal` has finished: phase `ready`. */
|
|
126
|
+
finished(now) {
|
|
127
|
+
const migrated = this.meta.phase === "migrating";
|
|
128
|
+
const keys = new Map([...this.#keys].filter(([g]) => g === this.meta.generation));
|
|
129
|
+
return new LocalStateKeyring(this.#cipher, {
|
|
130
|
+
...this.meta,
|
|
131
|
+
phase: "ready",
|
|
132
|
+
previous: null,
|
|
133
|
+
lastEvent: migrated ? { kind: "migrated", at: now } : this.meta.lastEvent,
|
|
134
|
+
}, keys);
|
|
135
|
+
}
|
|
136
|
+
/** Whether the current generation's key is held. */
|
|
137
|
+
get keyPresent() {
|
|
138
|
+
return this.#keys.has(this.meta.generation);
|
|
139
|
+
}
|
|
140
|
+
/** The envelope of a row's `plaintext`, under the current key. */
|
|
141
|
+
seal(store, key, plaintext) {
|
|
142
|
+
const k = this.#keys.get(this.meta.generation);
|
|
143
|
+
if (k === undefined)
|
|
144
|
+
throw new LfcpError("CRYPTO_FAILURE", "the local state key is missing");
|
|
145
|
+
return this.#cipher.seal(k, this.meta.generation, localStateAad(this.meta.installId, store, key), plaintext);
|
|
146
|
+
}
|
|
147
|
+
/** A row's bytes as stored: plaintext, opened, or unreadable (never a throw). */
|
|
148
|
+
open(store, key, bytes) {
|
|
149
|
+
if (!this.#cipher.isSealed(bytes))
|
|
150
|
+
return { kind: "plain", bytes };
|
|
151
|
+
const generation = this.#cipher.generationOf(bytes);
|
|
152
|
+
const k = this.#keys.get(generation);
|
|
153
|
+
if (k === undefined)
|
|
154
|
+
return { kind: "unreadable", reason: "missing-key" };
|
|
155
|
+
try {
|
|
156
|
+
return {
|
|
157
|
+
kind: "opened",
|
|
158
|
+
bytes: this.#cipher.open(k, localStateAad(this.meta.installId, store, key), bytes),
|
|
159
|
+
generation,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return { kind: "unreadable", reason: "authentication" };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** Whether a stored row needs sealing again: plaintext, or sealed under a readable older generation. */
|
|
167
|
+
needsReseal(bytes) {
|
|
168
|
+
if (!this.#cipher.isSealed(bytes))
|
|
169
|
+
return true;
|
|
170
|
+
const generation = this.#cipher.generationOf(bytes);
|
|
171
|
+
return generation !== this.meta.generation && this.#keys.has(generation);
|
|
172
|
+
}
|
|
173
|
+
/** The SecretStore reference of the key a finished rotation replaced, to remove. */
|
|
174
|
+
get retired() {
|
|
175
|
+
return this.meta.previous === null
|
|
176
|
+
? null
|
|
177
|
+
: localStateKeyRef(this.meta.installId, this.meta.previous);
|
|
178
|
+
}
|
|
179
|
+
toJSON() {
|
|
180
|
+
return "[LocalStateKeyring]";
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Seals every row that needs it, in batches: `scan` returns up to `limit`
|
|
185
|
+
* rows after `after` (null: from the start) in key order; `write` stores the
|
|
186
|
+
* sealed rows atomically. Resumable: a row already sealed under the current
|
|
187
|
+
* generation is skipped, so a crash between batches continues on the next
|
|
188
|
+
* open. A row whose key is lost is left as it is.
|
|
189
|
+
*/
|
|
190
|
+
export async function reseal(keyring, scan, write, limit = 64) {
|
|
191
|
+
let after = null;
|
|
192
|
+
let count = 0;
|
|
193
|
+
for (;;) {
|
|
194
|
+
const rows = await scan(after, limit);
|
|
195
|
+
if (rows.length === 0)
|
|
196
|
+
return count;
|
|
197
|
+
const sealed = [];
|
|
198
|
+
for (const row of rows) {
|
|
199
|
+
if (!keyring.needsReseal(row.bytes))
|
|
200
|
+
continue;
|
|
201
|
+
const opened = keyring.open(row.store, row.key, row.bytes);
|
|
202
|
+
if (opened.kind === "unreadable")
|
|
203
|
+
continue;
|
|
204
|
+
sealed.push({ ...row, bytes: keyring.seal(row.store, row.key, opened.bytes) });
|
|
205
|
+
}
|
|
206
|
+
if (sealed.length > 0)
|
|
207
|
+
await write(sealed);
|
|
208
|
+
count += sealed.length;
|
|
209
|
+
after = rows.at(-1).key;
|
|
210
|
+
if (rows.length < limit)
|
|
211
|
+
return count;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
//# sourceMappingURL=local-state.js.map
|
package/dist/memory.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type ActorSequence, type ControlRecordId, type DataEpoch, type DataUnitId, type Hash32, type PrincipalId, type ResourceId } from "@openlfcp/core";
|
|
2
2
|
import { type ActorSequenceReservation } from "./sequence.js";
|
|
3
3
|
import { type SnapshotSequenceReservation } from "./snapshot-sequence.js";
|
|
4
|
-
import type
|
|
4
|
+
import { type CommitResult, type ControlConflictRow, type ControlHeadRow, type ControlRecordRow, type DataUnitRow, type DataUnitStatus, type EpochRow, type KeyPackageRow, type LfcpStorage, type OutboundItem, type ProfileCheckpoint, type ResourceRow, type RouteRow, type SeenRecord, type SnapshotRow, type StorageWrite, type StoredDataUnit, type SyncStateRow } from "./store.js";
|
|
5
5
|
/**
|
|
6
6
|
* FOR TESTS AND DEVELOPMENT ONLY: an LfcpStorage in process memory, lost on
|
|
7
7
|
* restart, with the same semantic contracts as a durable adapter (exact
|
package/dist/memory.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { bytesEqual, LfcpError, toHex, } from "@openlfcp/core";
|
|
2
2
|
import { InMemoryActorSequenceReservation } from "./sequence.js";
|
|
3
3
|
import { InMemorySnapshotSequenceReservation, } from "./snapshot-sequence.js";
|
|
4
|
+
import { resourceSent, } from "./store.js";
|
|
4
5
|
/** A deep copy: every Uint8Array copied, everything frozen. Nothing stored aliases a caller's buffer. */
|
|
5
6
|
function own(value) {
|
|
6
7
|
if (value instanceof Uint8Array)
|
|
@@ -156,6 +157,25 @@ function apply(s, w) {
|
|
|
156
157
|
else
|
|
157
158
|
s.marks.set(w.key, w.value);
|
|
158
159
|
return;
|
|
160
|
+
case "delete-resource": {
|
|
161
|
+
const r = hex(w.resourceId);
|
|
162
|
+
const of = (row) => hex(row.resourceId) === r;
|
|
163
|
+
for (const map of [s.records, s.units, s.keyPackages, s.snapshots, s.outbound])
|
|
164
|
+
for (const [k, v] of map)
|
|
165
|
+
if (of(v))
|
|
166
|
+
map.delete(k);
|
|
167
|
+
for (const map of [
|
|
168
|
+
s.heads,
|
|
169
|
+
s.conflicts,
|
|
170
|
+
s.epochs,
|
|
171
|
+
s.resources,
|
|
172
|
+
s.routes,
|
|
173
|
+
s.checkpoints,
|
|
174
|
+
s.syncStates,
|
|
175
|
+
])
|
|
176
|
+
map.delete(r);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
159
179
|
}
|
|
160
180
|
}
|
|
161
181
|
const pickUnit = (u) => ({
|
|
@@ -230,6 +250,32 @@ export class InMemoryLfcpStorage {
|
|
|
230
250
|
current: current === null ? null : own(current),
|
|
231
251
|
}));
|
|
232
252
|
}
|
|
253
|
+
for (const w of writes) {
|
|
254
|
+
if (w.op !== "delete-resource")
|
|
255
|
+
continue;
|
|
256
|
+
const r = hex(w.resourceId);
|
|
257
|
+
const detail = resourceSent({
|
|
258
|
+
local: next.resources.get(r)?.localPrincipal?.principalId ?? null,
|
|
259
|
+
outbound: [...next.outbound.values()].filter((o) => hex(o.resourceId) === r),
|
|
260
|
+
syncState: next.syncStates.get(r),
|
|
261
|
+
actors: [
|
|
262
|
+
...[...next.units.values()].filter((u) => hex(u.resourceId) === r).map((u) => u.actor),
|
|
263
|
+
...[...next.keyPackages.values()]
|
|
264
|
+
.filter((k) => hex(k.resourceId) === r)
|
|
265
|
+
.map((k) => k.sender),
|
|
266
|
+
...[...next.snapshots.values()]
|
|
267
|
+
.filter((x) => hex(x.resourceId) === r)
|
|
268
|
+
.map((x) => x.publisher),
|
|
269
|
+
],
|
|
270
|
+
});
|
|
271
|
+
if (detail !== null)
|
|
272
|
+
return Promise.resolve(Object.freeze({
|
|
273
|
+
ok: false,
|
|
274
|
+
reason: "RESOURCE_SENT",
|
|
275
|
+
resourceId: own(w.resourceId),
|
|
276
|
+
detail,
|
|
277
|
+
}));
|
|
278
|
+
}
|
|
233
279
|
for (const w of writes) {
|
|
234
280
|
if (w.op !== "expect-previous-unit")
|
|
235
281
|
continue;
|
package/dist/secrets.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { type DataEpoch, type PrincipalId, type ResourceId } from "@openlfcp/core";
|
|
2
2
|
/**
|
|
3
|
-
* Client secret material (LFCP-034): Principal private keys, Resource DEKs
|
|
4
|
-
*
|
|
3
|
+
* Client secret material (LFCP-034): Principal private keys, Resource DEKs,
|
|
4
|
+
* invitation secrets and local state keys (LFCP-02-098) live only in a
|
|
5
|
+
* SecretStore. Public storage rows
|
|
5
6
|
* hold a SecretRef, a name that reveals nothing about the value.
|
|
6
7
|
*
|
|
7
8
|
* A SecretStore cannot be enumerated: there is no list operation, so no
|
|
@@ -14,7 +15,7 @@ import { type DataEpoch, type PrincipalId, type ResourceId } from "@openlfcp/cor
|
|
|
14
15
|
* first, then commit the row that references it. A reference without a
|
|
15
16
|
* value is a recoverable gap; a value without a reference is harmless.
|
|
16
17
|
*/
|
|
17
|
-
export type SecretKind = "principal-signing-key" | "principal-agreement-key" | "resource-dek" | "invitation-secret";
|
|
18
|
+
export type SecretKind = "principal-signing-key" | "principal-agreement-key" | "resource-dek" | "invitation-secret" | "local-state-key";
|
|
18
19
|
/** The name of a secret: "lfcp-secret:<kind>:<id>". It never contains secret material. */
|
|
19
20
|
export type SecretRef = string & {
|
|
20
21
|
readonly __secretRef: true;
|
package/dist/secrets.js
CHANGED
package/dist/store.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ActorSequence, type ControlRecordId, type DataEpoch, type DataUnitId, type Hash32, type PrincipalId, type ResourceId } from "@openlfcp/core";
|
|
2
2
|
import type { SecretRef } from "./secrets.js";
|
|
3
3
|
import type { ActorSequenceReservation } from "./sequence.js";
|
|
4
4
|
import type { SnapshotSequenceReservation } from "./snapshot-sequence.js";
|
|
@@ -321,6 +321,20 @@ export type StorageWrite = {
|
|
|
321
321
|
readonly op: "put-local-mark";
|
|
322
322
|
readonly key: string;
|
|
323
323
|
readonly value: string | null;
|
|
324
|
+
} | {
|
|
325
|
+
/**
|
|
326
|
+
* Forgets a Resource this client made and never sent: its row, route,
|
|
327
|
+
* Control Records, head and conflict, epochs, Data Units, Key
|
|
328
|
+
* Packages, Snapshots, outbound items, profile checkpoint and sync
|
|
329
|
+
* state. Fails the batch (RESOURCE_SENT) when anything of it may have
|
|
330
|
+
* left this device or come from elsewhere: an outbound item attempted,
|
|
331
|
+
* an ACK recorded, or a Data Unit, Key Package or Snapshot of another
|
|
332
|
+
* Principal stored. The actor and Snapshot sequence counters are kept
|
|
333
|
+
* (a number is never reused). Secrets are the caller's
|
|
334
|
+
* (deleteUnsentResource deletes the Resource's DEKs).
|
|
335
|
+
*/
|
|
336
|
+
readonly op: "delete-resource";
|
|
337
|
+
readonly resourceId: ResourceId;
|
|
324
338
|
};
|
|
325
339
|
export type CommitResult = {
|
|
326
340
|
readonly ok: true;
|
|
@@ -339,7 +353,29 @@ export type CommitResult = {
|
|
|
339
353
|
readonly resourceId: ResourceId;
|
|
340
354
|
readonly actor: PrincipalId;
|
|
341
355
|
readonly current: DataUnitId | null;
|
|
356
|
+
}
|
|
357
|
+
/** Nothing was written: the Resource to delete may have been sent (delete-resource). */
|
|
358
|
+
| {
|
|
359
|
+
readonly ok: false;
|
|
360
|
+
readonly reason: "RESOURCE_SENT";
|
|
361
|
+
readonly resourceId: ResourceId;
|
|
362
|
+
/** What shows it: "attempted", "acked" or "received". */
|
|
363
|
+
readonly detail: ResourceSentDetail;
|
|
342
364
|
};
|
|
365
|
+
/** Why a Resource counts as sent (delete-resource). */
|
|
366
|
+
export type ResourceSentDetail = "attempted" | "acked" | "received";
|
|
367
|
+
/**
|
|
368
|
+
* Why `resource` counts as sent, from what is stored of it, or null: an
|
|
369
|
+
* outbound item attempted, an ACK recorded, or a Data Unit, Key Package or
|
|
370
|
+
* Snapshot of a Principal other than its local one. Shared by the adapters
|
|
371
|
+
* (delete-resource).
|
|
372
|
+
*/
|
|
373
|
+
export declare function resourceSent(stored: {
|
|
374
|
+
readonly local: PrincipalId | null;
|
|
375
|
+
readonly outbound: readonly OutboundItem[];
|
|
376
|
+
readonly syncState: SyncStateRow | undefined;
|
|
377
|
+
readonly actors: readonly PrincipalId[];
|
|
378
|
+
}): ResourceSentDetail | null;
|
|
343
379
|
export interface ControlReader {
|
|
344
380
|
record(recordId: ControlRecordId): Promise<ControlRecordRow | undefined>;
|
|
345
381
|
/** Every stored record of the Resource, by (controlSeq, recordId bytes). */
|
package/dist/store.js
CHANGED
|
@@ -1,2 +1,19 @@
|
|
|
1
|
-
|
|
1
|
+
import { bytesEqual, } from "@openlfcp/core";
|
|
2
|
+
/**
|
|
3
|
+
* Why `resource` counts as sent, from what is stored of it, or null: an
|
|
4
|
+
* outbound item attempted, an ACK recorded, or a Data Unit, Key Package or
|
|
5
|
+
* Snapshot of a Principal other than its local one. Shared by the adapters
|
|
6
|
+
* (delete-resource).
|
|
7
|
+
*/
|
|
8
|
+
export function resourceSent(stored) {
|
|
9
|
+
if (stored.outbound.some((o) => o.attempts > 0 || o.lastAttempt !== null))
|
|
10
|
+
return "attempted";
|
|
11
|
+
const s = stored.syncState;
|
|
12
|
+
if (s !== undefined && (s.recentlyAcked.length > 0 || s.ackedDurability !== null))
|
|
13
|
+
return "acked";
|
|
14
|
+
const local = stored.local;
|
|
15
|
+
if (stored.actors.some((a) => local === null || !bytesEqual(a, local)))
|
|
16
|
+
return "received";
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
2
19
|
//# sourceMappingURL=store.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openlfcp/storage",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0-beta.10",
|
|
4
4
|
"description": "Storage interfaces for LFCP clients (no adapters).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openlfcp",
|
|
@@ -42,9 +42,9 @@
|
|
|
42
42
|
],
|
|
43
43
|
"publishConfig": {
|
|
44
44
|
"access": "public",
|
|
45
|
-
"tag": "
|
|
45
|
+
"tag": "beta"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@openlfcp/core": "^0.
|
|
48
|
+
"@openlfcp/core": "^0.2.0-beta.10"
|
|
49
49
|
}
|
|
50
50
|
}
|