@openlfcp/storage-node 0.1.4 → 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 CHANGED
@@ -18,9 +18,9 @@ on it. The Obsidian plugin does not use it: Obsidian has its own adapter
18
18
 
19
19
  Part of the OpenLFCP TypeScript SDK, which implements the OpenLFCP MVP 0.1
20
20
  subset of [LFCP-WIRE-01](https://github.com/openlfcp/spec/blob/main/wire/LFCP-WIRE-01.md)
21
- at `mvp-0.1-baseline.10`, not every deferred WIRE-01 feature. It does not
21
+ at `mvp-0.1-baseline.9`, not every deferred WIRE-01 feature. It does not
22
22
  claim full LFCP-WIRE-01 conformance. This is the MVP 0.1 release
23
- (`0.1.4`, npm dist-tag `latest`). Until 1.0, minor versions may change APIs.
23
+ (`0.1.3`, npm dist-tag `latest`). Until 1.0, minor versions may change APIs.
24
24
 
25
25
  ## SQLite
26
26
 
package/dist/index.d.ts CHANGED
@@ -7,5 +7,5 @@
7
7
  export declare const PACKAGE = "@openlfcp/storage-node";
8
8
  export { MIGRATIONS, migrate, SCHEMA_VERSION, schemaVersion } from "./schema.js";
9
9
  export { FileSecretStore } from "./secrets.js";
10
- export { SqliteLfcpStorage, type SqliteStorageOptions } from "./sqlite.js";
10
+ export { SqliteLfcpStorage, type SqliteLocalState, type SqliteStorageOptions, } from "./sqlite.js";
11
11
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -7,5 +7,5 @@
7
7
  export const PACKAGE = "@openlfcp/storage-node";
8
8
  export { MIGRATIONS, migrate, SCHEMA_VERSION, schemaVersion } from "./schema.js";
9
9
  export { FileSecretStore } from "./secrets.js";
10
- export { SqliteLfcpStorage } from "./sqlite.js";
10
+ export { SqliteLfcpStorage, } from "./sqlite.js";
11
11
  //# sourceMappingURL=index.js.map
package/dist/schema.d.ts CHANGED
@@ -2,7 +2,8 @@ import type Database from "better-sqlite3";
2
2
  /**
3
3
  * The SQLite schema and its migrations. Version 1 is the LFCP-034 storage
4
4
  * model; version 2 adds outbound retry state and sync state (LFCP-036);
5
- * version 3 adds local marks (the crash-loop breaker's apply markers).
5
+ * version 3 adds local marks (the crash-loop breaker's apply markers);
6
+ * version 4 adds the local state encryption metadata (LFCP-02-098).
6
7
  * A database at a newer version than this code knows is refused:
7
8
  * it is never downgraded or "repaired".
8
9
  *
package/dist/schema.js CHANGED
@@ -2,7 +2,8 @@ import { LfcpError } from "@openlfcp/core";
2
2
  /**
3
3
  * The SQLite schema and its migrations. Version 1 is the LFCP-034 storage
4
4
  * model; version 2 adds outbound retry state and sync state (LFCP-036);
5
- * version 3 adds local marks (the crash-loop breaker's apply markers).
5
+ * version 3 adds local marks (the crash-loop breaker's apply markers);
6
+ * version 4 adds the local state encryption metadata (LFCP-02-098).
6
7
  * A database at a newer version than this code knows is refused:
7
8
  * it is never downgraded or "repaired".
8
9
  *
@@ -156,6 +157,17 @@ CREATE TABLE local_marks (
156
157
  key TEXT PRIMARY KEY,
157
158
  value TEXT NOT NULL
158
159
  ) WITHOUT ROWID;
160
+ `,
161
+ ],
162
+ [
163
+ // LFCP-02-098: the local state encryption metadata (scheme, install ID,
164
+ // generation, phase); never key bytes.
165
+ 4,
166
+ `
167
+ CREATE TABLE local_state (
168
+ id INTEGER PRIMARY KEY CHECK (id = 1),
169
+ meta TEXT NOT NULL
170
+ );
159
171
  `,
160
172
  ],
161
173
  ];
package/dist/sqlite.d.ts CHANGED
@@ -1,8 +1,19 @@
1
- import { type ActorSequenceReservation, type CommitResult, type ControlReader, type DataUnitReader, type KeyPackageReader, type LfcpStorage, type LocalMarkReader, type OutboundReader, type ProfileStateReader, type ResourceReader, type SnapshotReader, type SnapshotSequenceReservation, type StorageWrite, type SyncStateReader } from "@openlfcp/storage";
1
+ import { type ActorSequenceReservation, type CommitResult, type ControlReader, type DataUnitReader, type KeyPackageReader, type LfcpStorage, type LocalMarkReader, type LocalStateCipher, type LocalStateDiagnostics, type OutboundReader, type ProfileStateReader, type ResourceReader, type SecretStore, type SnapshotReader, type SnapshotSequenceReservation, type StorageWrite, type SyncStateReader } from "@openlfcp/storage";
2
2
  export interface SqliteStorageOptions {
3
3
  /** Milliseconds to wait for another process's write lock (default 5000). */
4
4
  readonly busyTimeoutMs?: number;
5
5
  }
6
+ /**
7
+ * Local state encrypted at rest (LFCP-02-098): the SecretStore that holds
8
+ * the install's local state key, and the cipher (`localStateCipher` of
9
+ * @openlfcp/crypto).
10
+ */
11
+ export interface SqliteLocalState {
12
+ readonly secrets: SecretStore;
13
+ readonly cipher: LocalStateCipher;
14
+ /** The clock of the diagnostics' events (default: the system clock). */
15
+ readonly now?: () => Date;
16
+ }
6
17
  export declare class SqliteLfcpStorage implements LfcpStorage {
7
18
  #private;
8
19
  /** The schema version after migration. */
@@ -13,6 +24,25 @@ export declare class SqliteLfcpStorage implements LfcpStorage {
13
24
  * current schema and sets the durability pragmas.
14
25
  */
15
26
  static open(path: string, options?: SqliteStorageOptions): SqliteLfcpStorage;
27
+ /**
28
+ * Opens the database at `path` with its local state sealed at rest
29
+ * (LFCP-02-098): profile checkpoints are stored as `lse1` envelopes under
30
+ * the install's local state key, which lives only in `localState.secrets`.
31
+ * A database from before the scheme is migrated, in resumable batches; a
32
+ * half-done migration or rotation continues. A lost key is no failure: the
33
+ * next generation starts, and checkpoints sealed under the lost key read
34
+ * as absent until they are rewritten (the client rebuilds them from the
35
+ * stored units).
36
+ */
37
+ static openSealed(path: string, localState: SqliteLocalState, options?: SqliteStorageOptions): Promise<SqliteLfcpStorage>;
38
+ /**
39
+ * Rotates the local state key (LFCP-02-098 §8): writes the next
40
+ * generation's key, seals every checkpoint again, then removes the old
41
+ * key. Resumed on the next open after a crash.
42
+ */
43
+ rotateLocalStateKey(): Promise<void>;
44
+ /** The local state encryption status, or null when the database was opened without it. */
45
+ localStateDiagnostics(): LocalStateDiagnostics | null;
16
46
  close(): void;
17
47
  commit(writes: readonly StorageWrite[]): Promise<CommitResult>;
18
48
  readonly control: ControlReader;
package/dist/sqlite.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { actorSequence, bytesEqual, dataEpoch, LfcpError, toHex, UINT64_MAX, } from "@openlfcp/core";
2
- import { nextActorSequence, } from "@openlfcp/storage";
2
+ import { LocalStateKeyring, nextActorSequence, reseal, } from "@openlfcp/storage";
3
3
  import Database from "better-sqlite3";
4
4
  import { migrate } from "./schema.js";
5
5
  /** uint64 as 20-digit zero-padded TEXT (sorts numerically, never overflows). */
@@ -118,10 +118,14 @@ const checkpointRow = (r) => ({
118
118
  ref,
119
119
  })),
120
120
  });
121
+ /** The store name and primary key of a checkpoint in its AAD. */
122
+ const CHECKPOINTS = "checkpoints";
121
123
  export class SqliteLfcpStorage {
122
124
  #db;
123
125
  /** The schema version after migration. */
124
126
  schemaVersion;
127
+ #keyring = null;
128
+ #localState = null;
125
129
  constructor(db, version) {
126
130
  this.#db = db;
127
131
  this.schemaVersion = version;
@@ -131,6 +135,46 @@ export class SqliteLfcpStorage {
131
135
  * current schema and sets the durability pragmas.
132
136
  */
133
137
  static open(path, options = {}) {
138
+ const storage = SqliteLfcpStorage.#open(path, options);
139
+ if (storage.#storedMeta() !== undefined) {
140
+ storage.close();
141
+ throw new LfcpError("UNSUPPORTED_VALUE", "this database seals its local state: open it with SqliteLfcpStorage.openSealed");
142
+ }
143
+ return storage;
144
+ }
145
+ /**
146
+ * Opens the database at `path` with its local state sealed at rest
147
+ * (LFCP-02-098): profile checkpoints are stored as `lse1` envelopes under
148
+ * the install's local state key, which lives only in `localState.secrets`.
149
+ * A database from before the scheme is migrated, in resumable batches; a
150
+ * half-done migration or rotation continues. A lost key is no failure: the
151
+ * next generation starts, and checkpoints sealed under the lost key read
152
+ * as absent until they are rewritten (the client rebuilds them from the
153
+ * stored units).
154
+ */
155
+ static async openSealed(path, localState, options = {}) {
156
+ const storage = SqliteLfcpStorage.#open(path, options);
157
+ try {
158
+ // Freed pages are overwritten with zeros, so an old row's bytes do not
159
+ // linger in the file once replaced.
160
+ storage.#db.pragma("secure_delete = ON");
161
+ storage.#localState = localState;
162
+ const stored = storage.#storedMeta();
163
+ const keyring = await LocalStateKeyring.prepare(localState.cipher, localState.secrets, stored, storage.#now());
164
+ // The key is in the SecretStore; now the metadata that names it.
165
+ if (JSON.stringify(keyring.meta) !== JSON.stringify(stored))
166
+ storage.#writeMeta(keyring.meta);
167
+ storage.#keyring = keyring;
168
+ if (keyring.meta.phase !== "ready")
169
+ await storage.#finishPhase();
170
+ return storage;
171
+ }
172
+ catch (e) {
173
+ storage.close();
174
+ throw e;
175
+ }
176
+ }
177
+ static #open(path, options) {
134
178
  const db = new Database(path);
135
179
  try {
136
180
  db.pragma("journal_mode = WAL");
@@ -144,6 +188,94 @@ export class SqliteLfcpStorage {
144
188
  throw e;
145
189
  }
146
190
  }
191
+ #now() {
192
+ return (this.#localState?.now?.() ?? new Date()).toISOString();
193
+ }
194
+ #storedMeta() {
195
+ const row = this.#get("SELECT meta FROM local_state WHERE id = 1");
196
+ return row === undefined ? undefined : JSON.parse(row.meta);
197
+ }
198
+ #writeMeta(meta) {
199
+ this.#db
200
+ .prepare("INSERT INTO local_state (id, meta) VALUES (1, ?) ON CONFLICT (id) DO UPDATE SET meta = excluded.meta")
201
+ .run(JSON.stringify(meta));
202
+ }
203
+ /**
204
+ * Seals every checkpoint the current phase needs, in resumable batches,
205
+ * then records the phase as ready and removes a key a rotation retired.
206
+ * A batch writes a row only if it is still the bytes it read, so a
207
+ * checkpoint committed meanwhile is never overwritten.
208
+ */
209
+ async #finishPhase() {
210
+ const keyring = this.#keyring;
211
+ const read = new Map();
212
+ const scan = async (after, limit) => {
213
+ const rows = after === null
214
+ ? this.#all("SELECT resource_id, state FROM profile_checkpoints ORDER BY resource_id LIMIT ?", limit)
215
+ : this.#all("SELECT resource_id, state FROM profile_checkpoints WHERE resource_id > ? ORDER BY resource_id LIMIT ?", Buffer.from(after, "hex"), limit);
216
+ return rows.map((r) => {
217
+ const key = toHex(bytes(r.resource_id));
218
+ read.set(key, r.state);
219
+ return { store: CHECKPOINTS, key, bytes: bytes(r.state) };
220
+ });
221
+ };
222
+ const write = (rows) => this.#tx(() => {
223
+ const update = this.#db.prepare("UPDATE profile_checkpoints SET state = ? WHERE resource_id = ? AND state = ?");
224
+ for (const r of rows)
225
+ update.run(blob(r.bytes), Buffer.from(r.key, "hex"), read.get(r.key));
226
+ });
227
+ await reseal(keyring, scan, write);
228
+ const retired = keyring.retired;
229
+ const finished = keyring.finished(this.#now());
230
+ this.#writeMeta(finished.meta);
231
+ this.#keyring = finished;
232
+ if (keyring.meta.phase === "migrating") {
233
+ // Plaintext from before the scheme may linger in pages freed before
234
+ // secure_delete and in the WAL: rewrite the file and truncate the WAL.
235
+ this.#db.pragma("wal_checkpoint(TRUNCATE)");
236
+ this.#db.exec("VACUUM");
237
+ this.#db.pragma("wal_checkpoint(TRUNCATE)");
238
+ }
239
+ // The old key goes last: a crash before this line leaves it, harmlessly.
240
+ if (retired !== null)
241
+ await this.#localState.secrets.delete(retired);
242
+ }
243
+ /**
244
+ * Rotates the local state key (LFCP-02-098 §8): writes the next
245
+ * generation's key, seals every checkpoint again, then removes the old
246
+ * key. Resumed on the next open after a crash.
247
+ */
248
+ async rotateLocalStateKey() {
249
+ if (this.#keyring === null || this.#localState === null)
250
+ throw new LfcpError("UNSUPPORTED_VALUE", "this database was not opened with openSealed");
251
+ this.#keyring = await this.#keyring.rotate(this.#localState.secrets, this.#now());
252
+ this.#writeMeta(this.#keyring.meta);
253
+ await this.#finishPhase();
254
+ }
255
+ /** The local state encryption status, or null when the database was opened without it. */
256
+ localStateDiagnostics() {
257
+ const keyring = this.#keyring;
258
+ if (keyring === null)
259
+ return null;
260
+ const rows = { sealed: 0, plaintext: 0, unreadable: 0 };
261
+ for (const r of this.#all("SELECT resource_id, state FROM profile_checkpoints")) {
262
+ const opened = keyring.open(CHECKPOINTS, toHex(bytes(r.resource_id)), bytes(r.state));
263
+ if (opened.kind === "plain")
264
+ rows.plaintext++;
265
+ else if (opened.kind === "opened")
266
+ rows.sealed++;
267
+ else
268
+ rows.unreadable++;
269
+ }
270
+ return Object.freeze({
271
+ scheme: keyring.meta.scheme,
272
+ generation: keyring.meta.generation,
273
+ keyPresent: keyring.keyPresent,
274
+ phase: keyring.meta.phase,
275
+ rows: Object.freeze(rows),
276
+ lastEvent: keyring.meta.lastEvent,
277
+ });
278
+ }
147
279
  close() {
148
280
  if (this.#db.open)
149
281
  this.#db.close();
@@ -376,7 +508,9 @@ export class SqliteLfcpStorage {
376
508
  case "put-profile-checkpoint": {
377
509
  const c = w.checkpoint;
378
510
  db.prepare(`INSERT INTO profile_checkpoints (resource_id, data_profile, state, actor_seq, units) VALUES (?, ?, ?, ?, ?)
379
- ON CONFLICT (resource_id) DO UPDATE SET data_profile = excluded.data_profile, state = excluded.state, actor_seq = excluded.actor_seq, units = excluded.units`).run(blob(c.resourceId), c.dataProfile, blob(c.state), c.actorSeq, JSON.stringify(c.units.map((u) => [toHex(u.unitId), u.ref])));
511
+ ON CONFLICT (resource_id) DO UPDATE SET data_profile = excluded.data_profile, state = excluded.state, actor_seq = excluded.actor_seq, units = excluded.units`).run(blob(c.resourceId), c.dataProfile, blob(this.#keyring === null
512
+ ? c.state
513
+ : this.#keyring.seal(CHECKPOINTS, toHex(c.resourceId), c.state)), c.actorSeq, JSON.stringify(c.units.map((u) => [toHex(u.unitId), u.ref])));
380
514
  return;
381
515
  }
382
516
  }
@@ -482,7 +616,16 @@ export class SqliteLfcpStorage {
482
616
  profileState = {
483
617
  checkpoint: (res) => this.#read(() => {
484
618
  const r = this.#get("SELECT * FROM profile_checkpoints WHERE resource_id = ?", blob(res));
485
- return r === undefined ? undefined : checkpointRow(r);
619
+ if (r === undefined)
620
+ return undefined;
621
+ const row = checkpointRow(r);
622
+ if (this.#keyring === null)
623
+ return row;
624
+ const opened = this.#keyring.open(CHECKPOINTS, toHex(res), row.state);
625
+ // Sealed under a lost key: absent, rebuilt from the stored units.
626
+ if (opened.kind === "unreadable")
627
+ return undefined;
628
+ return { ...row, state: opened.bytes };
486
629
  }),
487
630
  };
488
631
  syncState = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openlfcp/storage-node",
3
- "version": "0.1.4",
3
+ "version": "0.2.0-beta.1",
4
4
  "description": "Durable Node.js storage for LFCP clients: SQLite (better-sqlite3) and a file secret store. Node only.",
5
5
  "keywords": [
6
6
  "openlfcp",
@@ -39,12 +39,12 @@
39
39
  ],
40
40
  "publishConfig": {
41
41
  "access": "public",
42
- "tag": "latest"
42
+ "tag": "beta"
43
43
  },
44
44
  "dependencies": {
45
45
  "better-sqlite3": "13.0.3",
46
- "@openlfcp/storage": "^0.1.4",
47
- "@openlfcp/core": "^0.1.4"
46
+ "@openlfcp/core": "^0.2.0-beta.1",
47
+ "@openlfcp/storage": "^0.2.0-beta.1"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@types/better-sqlite3": "9.6.0",