@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 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.10`, not every deferred WIRE-01 feature. It does not
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.4`, npm dist-tag `latest`). Until 1.0, minor versions may change APIs.
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 { CommitResult, ControlConflictRow, ControlHeadRow, ControlRecordRow, DataUnitRow, DataUnitStatus, EpochRow, KeyPackageRow, LfcpStorage, OutboundItem, ProfileCheckpoint, ResourceRow, RouteRow, SeenRecord, SnapshotRow, StorageWrite, StoredDataUnit, SyncStateRow } from "./store.js";
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
- * and invitation secrets live only in a SecretStore. Public storage rows
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
@@ -4,6 +4,7 @@ const KINDS = new Set([
4
4
  "principal-agreement-key",
5
5
  "resource-dek",
6
6
  "invitation-secret",
7
+ "local-state-key",
7
8
  ]);
8
9
  const ID = /^[A-Za-z0-9._-]{1,200}$/;
9
10
  /** A secret reference; `id` is a public name such as hex IDs joined with ".". */
package/dist/store.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ActorSequence, ControlRecordId, DataEpoch, DataUnitId, Hash32, PrincipalId, ResourceId } from "@openlfcp/core";
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
- export {};
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.1.5",
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": "latest"
45
+ "tag": "beta"
46
46
  },
47
47
  "dependencies": {
48
- "@openlfcp/core": "^0.1.5"
48
+ "@openlfcp/core": "^0.2.0-beta.10"
49
49
  }
50
50
  }