@openlfcp/storage-idb 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/idb.d.ts +51 -1
- package/dist/idb.js +149 -10
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -22,9 +22,9 @@ installs must never share sequence counters.
|
|
|
22
22
|
|
|
23
23
|
Part of the OpenLFCP TypeScript SDK, which implements the OpenLFCP MVP 0.1
|
|
24
24
|
subset of [LFCP-WIRE-01](https://github.com/openlfcp/spec/blob/main/wire/LFCP-WIRE-01.md)
|
|
25
|
-
at `mvp-0.1-baseline.
|
|
25
|
+
at `mvp-0.1-baseline.9`, not every deferred WIRE-01 feature. It does not
|
|
26
26
|
claim full LFCP-WIRE-01 conformance. This is the MVP 0.1 release
|
|
27
|
-
(`0.1.
|
|
27
|
+
(`0.1.3`, npm dist-tag `latest`). Until 1.0, minor versions may change APIs.
|
|
28
28
|
|
|
29
29
|
## Guarantees
|
|
30
30
|
|
package/dist/idb.d.ts
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
import { type ActorSequence, type ControlRecordId, type DataEpoch, type DataUnitId, type Hash32, type PrincipalId, type ResourceId } from "@openlfcp/core";
|
|
2
|
-
import { type ActorSequenceReservation, 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 SnapshotSequenceReservation, type StorageWrite, type StoredDataUnit, type SyncStateRow } from "@openlfcp/storage";
|
|
2
|
+
import { type ActorSequenceReservation, type CommitResult, type ControlConflictRow, type ControlHeadRow, type ControlRecordRow, type DataUnitRow, type DataUnitStatus, type EpochRow, type KeyPackageRow, type LfcpStorage, type LocalStateCipher, type LocalStateDiagnostics, type OutboundItem, type ProfileCheckpoint, type ResourceRow, type RouteRow, type SecretStore, type SeenRecord, type SnapshotRow, type SnapshotSequenceReservation, type StorageWrite, type StoredDataUnit, type SyncStateRow } from "@openlfcp/storage";
|
|
3
|
+
/**
|
|
4
|
+
* LfcpStorage on IndexedDB (LFCP-059): one database per client install,
|
|
5
|
+
* named by the caller. Portable: browsers, Electron, mobile WebViews.
|
|
6
|
+
*
|
|
7
|
+
* - Atomicity: one commit() is one readwrite transaction over every object
|
|
8
|
+
* store; the Control Head compare-and-set is read inside it, and any
|
|
9
|
+
* failure aborts the whole transaction.
|
|
10
|
+
* - Durability: every readwrite transaction asks for durability "strict"
|
|
11
|
+
* (Chromium's default is relaxed), and a promise resolves only on the
|
|
12
|
+
* transaction's complete event.
|
|
13
|
+
* - Sequences: a reservation reads the counter, checks it against this
|
|
14
|
+
* Principal's own stored objects (fail closed, SEQUENCE_REUSE) and writes
|
|
15
|
+
* it in one strict transaction, then calls onReserved before returning.
|
|
16
|
+
* - Exact bytes: signed objects are stored as their exact bytes; other
|
|
17
|
+
* fields are indexes. Values are structured clones, so nothing returned
|
|
18
|
+
* aliases a stored buffer or a caller's.
|
|
19
|
+
*
|
|
20
|
+
* A transaction only awaits IndexedDB requests (never other promises), so it
|
|
21
|
+
* cannot auto-commit half way.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* The database version (LFCP-02-029). 1: the LFCP-059 stores (0.1.x).
|
|
25
|
+
* 2: the same stores; checkpoints may be sealed (LFCP-02-098) and 0.2
|
|
26
|
+
* records live in local marks. Nothing is converted from 1 to 2: the
|
|
27
|
+
* version only makes a 0.1.x client, which opens version 1, refuse the
|
|
28
|
+
* database (VersionError) instead of reading sealed rows as plaintext.
|
|
29
|
+
*/
|
|
30
|
+
export declare const IDB_VERSION = 2;
|
|
3
31
|
/** A reservation that is durable and about to be returned. */
|
|
4
32
|
export interface ReservedSequence {
|
|
5
33
|
readonly kind: "actor" | "snapshot";
|
|
@@ -16,12 +44,34 @@ export interface IdbStorageOptions {
|
|
|
16
44
|
* fails and its value is abandoned, never reissued.
|
|
17
45
|
*/
|
|
18
46
|
readonly onReserved?: (reserved: ReservedSequence) => void;
|
|
47
|
+
/**
|
|
48
|
+
* Local state encrypted at rest (LFCP-02-098): the SecretStore that holds
|
|
49
|
+
* the install's local state key, and the cipher (`localStateCipher` of
|
|
50
|
+
* @openlfcp/crypto). Profile checkpoints are then stored as `lse1`
|
|
51
|
+
* envelopes. A database that seals its local state cannot be opened
|
|
52
|
+
* without it.
|
|
53
|
+
*/
|
|
54
|
+
readonly localState?: IdbLocalState;
|
|
55
|
+
}
|
|
56
|
+
export interface IdbLocalState {
|
|
57
|
+
readonly secrets: SecretStore;
|
|
58
|
+
readonly cipher: LocalStateCipher;
|
|
59
|
+
/** The clock of the diagnostics' events (default: the system clock). */
|
|
60
|
+
readonly now?: () => Date;
|
|
19
61
|
}
|
|
20
62
|
export declare class IdbLfcpStorage implements LfcpStorage {
|
|
21
63
|
#private;
|
|
22
64
|
private constructor();
|
|
23
65
|
/** Opens (creating or upgrading) the database `name`. */
|
|
24
66
|
static open(name: string, options?: IdbStorageOptions): Promise<IdbLfcpStorage>;
|
|
67
|
+
/**
|
|
68
|
+
* Rotates the local state key (LFCP-02-098 §8): writes the next
|
|
69
|
+
* generation's key, seals every checkpoint again, then removes the old
|
|
70
|
+
* key. Resumed on the next open after a crash.
|
|
71
|
+
*/
|
|
72
|
+
rotateLocalStateKey(): Promise<void>;
|
|
73
|
+
/** The local state encryption status, or null when the database was opened without it. */
|
|
74
|
+
localStateDiagnostics(): Promise<LocalStateDiagnostics | null>;
|
|
25
75
|
/** Closes the connection; later calls fail. */
|
|
26
76
|
close(): void;
|
|
27
77
|
commit(writes: readonly StorageWrite[]): Promise<CommitResult>;
|
package/dist/idb.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/// <reference lib="dom" />
|
|
2
2
|
import { bytesEqual, LfcpError, toHex, UINT64_MAX, } from "@openlfcp/core";
|
|
3
|
-
import { nextActorSequence, } from "@openlfcp/storage";
|
|
3
|
+
import { LocalStateKeyring, nextActorSequence, reseal, } from "@openlfcp/storage";
|
|
4
4
|
/**
|
|
5
5
|
* LfcpStorage on IndexedDB (LFCP-059): one database per client install,
|
|
6
6
|
* named by the caller. Portable: browsers, Electron, mobile WebViews.
|
|
@@ -21,7 +21,14 @@ import { nextActorSequence, } from "@openlfcp/storage";
|
|
|
21
21
|
* A transaction only awaits IndexedDB requests (never other promises), so it
|
|
22
22
|
* cannot auto-commit half way.
|
|
23
23
|
*/
|
|
24
|
-
|
|
24
|
+
/**
|
|
25
|
+
* The database version (LFCP-02-029). 1: the LFCP-059 stores (0.1.x).
|
|
26
|
+
* 2: the same stores; checkpoints may be sealed (LFCP-02-098) and 0.2
|
|
27
|
+
* records live in local marks. Nothing is converted from 1 to 2: the
|
|
28
|
+
* version only makes a 0.1.x client, which opens version 1, refuse the
|
|
29
|
+
* database (VersionError) instead of reading sealed rows as plaintext.
|
|
30
|
+
*/
|
|
31
|
+
export const IDB_VERSION = 2;
|
|
25
32
|
const STORES = [
|
|
26
33
|
"records",
|
|
27
34
|
"heads",
|
|
@@ -38,6 +45,9 @@ const STORES = [
|
|
|
38
45
|
"counters",
|
|
39
46
|
"meta",
|
|
40
47
|
];
|
|
48
|
+
/** The meta key of the local state encryption metadata; the store name of checkpoints in their AAD. */
|
|
49
|
+
const LOCAL_STATE = "local-state";
|
|
50
|
+
const CHECKPOINTS = "checkpoints";
|
|
41
51
|
const hex = toHex;
|
|
42
52
|
/** uint64 as a fixed-width decimal string, so key order is numeric order. */
|
|
43
53
|
const pad = (n) => n.toString().padStart(20, "0");
|
|
@@ -94,7 +104,11 @@ class HeadMismatch {
|
|
|
94
104
|
this.result = result;
|
|
95
105
|
}
|
|
96
106
|
}
|
|
97
|
-
|
|
107
|
+
/** Brings a database from version `from` to IDB_VERSION, in the version change transaction. */
|
|
108
|
+
function upgrade(db, from) {
|
|
109
|
+
// 1 → 2 adds no store: the stores of version 1 are kept as they are.
|
|
110
|
+
if (from >= 1)
|
|
111
|
+
return;
|
|
98
112
|
const plain = (name) => db.createObjectStore(name);
|
|
99
113
|
db.createObjectStore("records").createIndex("r", "r");
|
|
100
114
|
plain("heads");
|
|
@@ -118,23 +132,134 @@ function upgrade(db) {
|
|
|
118
132
|
export class IdbLfcpStorage {
|
|
119
133
|
#db;
|
|
120
134
|
#onReserved;
|
|
135
|
+
#localState;
|
|
136
|
+
#keyring = null;
|
|
121
137
|
constructor(db, options) {
|
|
122
138
|
this.#db = db;
|
|
123
139
|
this.#onReserved = options.onReserved;
|
|
140
|
+
this.#localState = options.localState;
|
|
124
141
|
}
|
|
125
142
|
/** Opens (creating or upgrading) the database `name`. */
|
|
126
143
|
static async open(name, options = {}) {
|
|
127
144
|
const factory = options.indexedDB ?? globalThis.indexedDB;
|
|
128
145
|
if (factory === undefined)
|
|
129
146
|
throw new LfcpError("UNSUPPORTED_VALUE", "IndexedDB is not available in this runtime");
|
|
130
|
-
const open = factory.open(name,
|
|
131
|
-
open.onupgradeneeded = () => upgrade(open.result);
|
|
147
|
+
const open = factory.open(name, IDB_VERSION);
|
|
148
|
+
open.onupgradeneeded = (event) => upgrade(open.result, event.oldVersion);
|
|
132
149
|
const db = await new Promise((resolve, reject) => {
|
|
133
150
|
open.onsuccess = () => resolve(open.result);
|
|
134
|
-
open.onerror = () => reject(open.error
|
|
151
|
+
open.onerror = () => reject(open.error?.name === "VersionError"
|
|
152
|
+
? new LfcpError("UNSUPPORTED_VALUE", `IndexedDB ${name} was written by a newer client than this one (version ${IDB_VERSION}); it is left as it is`)
|
|
153
|
+
: open.error);
|
|
135
154
|
open.onblocked = () => reject(new LfcpError("UNSUPPORTED_VALUE", `IndexedDB ${name} is blocked by another connection`));
|
|
136
155
|
});
|
|
137
|
-
|
|
156
|
+
const storage = new IdbLfcpStorage(db, options);
|
|
157
|
+
try {
|
|
158
|
+
await storage.#startLocalState();
|
|
159
|
+
}
|
|
160
|
+
catch (e) {
|
|
161
|
+
db.close();
|
|
162
|
+
throw e;
|
|
163
|
+
}
|
|
164
|
+
return storage;
|
|
165
|
+
}
|
|
166
|
+
#now() {
|
|
167
|
+
return (this.#localState?.now?.() ?? new Date()).toISOString();
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* LFCP-02-098: with `localState`, prepares the keyring (the key goes to the
|
|
171
|
+
* SecretStore before the metadata that names it), then migrates, or
|
|
172
|
+
* continues a half-done migration or rotation. Without it, a database that
|
|
173
|
+
* seals its local state is refused.
|
|
174
|
+
*/
|
|
175
|
+
async #startLocalState() {
|
|
176
|
+
const stored = (await this.meta.get(LOCAL_STATE));
|
|
177
|
+
const local = this.#localState;
|
|
178
|
+
if (local === undefined) {
|
|
179
|
+
if (stored !== undefined)
|
|
180
|
+
throw new LfcpError("UNSUPPORTED_VALUE", "this database seals its local state: open it with the localState option");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const keyring = await LocalStateKeyring.prepare(local.cipher, local.secrets, stored, this.#now());
|
|
184
|
+
if (JSON.stringify(keyring.meta) !== JSON.stringify(stored))
|
|
185
|
+
await this.meta.put(LOCAL_STATE, keyring.meta);
|
|
186
|
+
this.#keyring = keyring;
|
|
187
|
+
if (keyring.meta.phase !== "ready")
|
|
188
|
+
await this.#finishPhase();
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Seals every checkpoint the current phase needs, in resumable batches,
|
|
192
|
+
* then records the phase as ready and removes a key a rotation retired. A
|
|
193
|
+
* batch writes a row only if it still holds the bytes it read, so a
|
|
194
|
+
* checkpoint committed meanwhile is never overwritten.
|
|
195
|
+
*/
|
|
196
|
+
async #finishPhase() {
|
|
197
|
+
const keyring = this.#keyring;
|
|
198
|
+
const read = new Map();
|
|
199
|
+
const scan = (after, limit) => this.#tx(["checkpoints"], "readonly", async (tx) => {
|
|
200
|
+
const range = after === null ? undefined : IDBKeyRange.lowerBound(after, true);
|
|
201
|
+
const values = (await req(tx.objectStore("checkpoints").getAll(range, limit)));
|
|
202
|
+
return values.map((c) => {
|
|
203
|
+
const key = hex(c.resourceId);
|
|
204
|
+
read.set(key, c.state);
|
|
205
|
+
return { store: CHECKPOINTS, key, bytes: Uint8Array.from(c.state) };
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
const write = (rows) => this.#tx(["checkpoints"], "readwrite", async (tx) => {
|
|
209
|
+
const store = tx.objectStore("checkpoints");
|
|
210
|
+
for (const r of rows) {
|
|
211
|
+
const now = (await req(store.get(r.key)));
|
|
212
|
+
const before = read.get(r.key);
|
|
213
|
+
if (now === undefined || before === undefined || !bytesEqual(now.state, before))
|
|
214
|
+
continue;
|
|
215
|
+
await req(store.put({ ...now, state: r.bytes }, r.key));
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
await reseal(keyring, scan, write);
|
|
219
|
+
const retired = keyring.retired;
|
|
220
|
+
const finished = keyring.finished(this.#now());
|
|
221
|
+
await this.meta.put(LOCAL_STATE, finished.meta);
|
|
222
|
+
this.#keyring = finished;
|
|
223
|
+
// The old key goes last: a crash before this line leaves it, harmlessly.
|
|
224
|
+
if (retired !== null)
|
|
225
|
+
await this.#localState.secrets.delete(retired);
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Rotates the local state key (LFCP-02-098 §8): writes the next
|
|
229
|
+
* generation's key, seals every checkpoint again, then removes the old
|
|
230
|
+
* key. Resumed on the next open after a crash.
|
|
231
|
+
*/
|
|
232
|
+
async rotateLocalStateKey() {
|
|
233
|
+
if (this.#keyring === null || this.#localState === undefined)
|
|
234
|
+
throw new LfcpError("UNSUPPORTED_VALUE", "this database was not opened with localState");
|
|
235
|
+
this.#keyring = await this.#keyring.rotate(this.#localState.secrets, this.#now());
|
|
236
|
+
await this.meta.put(LOCAL_STATE, this.#keyring.meta);
|
|
237
|
+
await this.#finishPhase();
|
|
238
|
+
}
|
|
239
|
+
/** The local state encryption status, or null when the database was opened without it. */
|
|
240
|
+
async localStateDiagnostics() {
|
|
241
|
+
const keyring = this.#keyring;
|
|
242
|
+
if (keyring === null)
|
|
243
|
+
return null;
|
|
244
|
+
const values = await this.#tx(["checkpoints"], "readonly", async (tx) => (await req(tx.objectStore("checkpoints").getAll())));
|
|
245
|
+
const rows = { sealed: 0, plaintext: 0, unreadable: 0 };
|
|
246
|
+
for (const c of values) {
|
|
247
|
+
const opened = keyring.open(CHECKPOINTS, hex(c.resourceId), c.state);
|
|
248
|
+
if (opened.kind === "plain")
|
|
249
|
+
rows.plaintext++;
|
|
250
|
+
else if (opened.kind === "opened")
|
|
251
|
+
rows.sealed++;
|
|
252
|
+
else
|
|
253
|
+
rows.unreadable++;
|
|
254
|
+
}
|
|
255
|
+
return Object.freeze({
|
|
256
|
+
scheme: keyring.meta.scheme,
|
|
257
|
+
generation: keyring.meta.generation,
|
|
258
|
+
keyPresent: keyring.keyPresent,
|
|
259
|
+
phase: keyring.meta.phase,
|
|
260
|
+
rows: Object.freeze(rows),
|
|
261
|
+
lastEvent: keyring.meta.lastEvent,
|
|
262
|
+
});
|
|
138
263
|
}
|
|
139
264
|
/** Closes the connection; later calls fail. */
|
|
140
265
|
close() {
|
|
@@ -342,9 +467,14 @@ export class IdbLfcpStorage {
|
|
|
342
467
|
case "dequeue":
|
|
343
468
|
await req(s("outbound").delete(hex(w.itemId)));
|
|
344
469
|
return;
|
|
345
|
-
case "put-profile-checkpoint":
|
|
346
|
-
|
|
470
|
+
case "put-profile-checkpoint": {
|
|
471
|
+
const key = hex(w.checkpoint.resourceId);
|
|
472
|
+
const state = this.#keyring === null
|
|
473
|
+
? w.checkpoint.state
|
|
474
|
+
: this.#keyring.seal(CHECKPOINTS, key, w.checkpoint.state);
|
|
475
|
+
await req(s("checkpoints").put({ ...w.checkpoint, state }, key));
|
|
347
476
|
return;
|
|
477
|
+
}
|
|
348
478
|
case "put-sync-state":
|
|
349
479
|
await req(s("syncStates").put(w.row, hex(w.row.resourceId)));
|
|
350
480
|
return;
|
|
@@ -517,7 +647,16 @@ export class IdbLfcpStorage {
|
|
|
517
647
|
get: (id) => this.#get("outbound", hex(id)).then((v) => v?.row),
|
|
518
648
|
};
|
|
519
649
|
profileState = {
|
|
520
|
-
checkpoint: (resource) =>
|
|
650
|
+
checkpoint: async (resource) => {
|
|
651
|
+
const row = await this.#get("checkpoints", hex(resource));
|
|
652
|
+
if (row === undefined || this.#keyring === null)
|
|
653
|
+
return row;
|
|
654
|
+
const opened = this.#keyring.open(CHECKPOINTS, hex(resource), row.state);
|
|
655
|
+
// Sealed under a lost key: absent, rebuilt from the stored units.
|
|
656
|
+
if (opened.kind === "unreadable")
|
|
657
|
+
return undefined;
|
|
658
|
+
return Object.freeze({ ...row, state: opened.bytes });
|
|
659
|
+
},
|
|
521
660
|
};
|
|
522
661
|
syncState = {
|
|
523
662
|
get: (resource) => this.#get("syncStates", hex(resource)),
|
package/dist/index.d.ts
CHANGED
|
@@ -5,5 +5,5 @@
|
|
|
5
5
|
* SecretStore.
|
|
6
6
|
*/
|
|
7
7
|
export declare const PACKAGE = "@openlfcp/storage-idb";
|
|
8
|
-
export { IdbLfcpStorage, type IdbStorageOptions, type ReservedSequence } from "./idb.js";
|
|
8
|
+
export { IDB_VERSION, IdbLfcpStorage, type IdbLocalState, type IdbStorageOptions, type ReservedSequence, } from "./idb.js";
|
|
9
9
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openlfcp/storage-idb",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.0-beta.1",
|
|
4
4
|
"description": "Durable portable storage for LFCP clients on IndexedDB (browsers, Electron, mobile WebViews).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openlfcp",
|
|
@@ -39,11 +39,11 @@
|
|
|
39
39
|
],
|
|
40
40
|
"publishConfig": {
|
|
41
41
|
"access": "public",
|
|
42
|
-
"tag": "
|
|
42
|
+
"tag": "beta"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@openlfcp/core": "^0.1
|
|
46
|
-
"@openlfcp/storage": "^0.1
|
|
45
|
+
"@openlfcp/core": "^0.2.0-beta.1",
|
|
46
|
+
"@openlfcp/storage": "^0.2.0-beta.1"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
49
|
"fake-indexeddb": "6.2.5"
|