@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 +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/schema.d.ts +2 -1
- package/dist/schema.js +13 -1
- package/dist/sqlite.d.ts +31 -1
- package/dist/sqlite.js +146 -3
- package/package.json +4 -4
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.
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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
|
|
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": "
|
|
42
|
+
"tag": "beta"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"better-sqlite3": "13.0.3",
|
|
46
|
-
"@openlfcp/
|
|
47
|
-
"@openlfcp/
|
|
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",
|