@coffre/vault 0.14.5 → 0.15.0

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.
Files changed (43) hide show
  1. package/README.md +6 -4
  2. package/dist/cloudflare.d.ts +341 -4
  3. package/dist/cloudflare.js +802 -6
  4. package/dist/index.js +1 -1
  5. package/dist/log-entry-BYWqj3O5.d.ts +364 -0
  6. package/dist/node.d.ts +181 -6
  7. package/dist/node.js +358 -16
  8. package/dist/{src-Bg0ZeMJW.js → src-DheG92EM.js} +1 -1
  9. package/dist/vault-kmtojYnW.js +14623 -0
  10. package/package.json +4 -5
  11. package/src/bulk.ts +89 -0
  12. package/src/changes.ts +478 -0
  13. package/src/checkpointer-node.ts +342 -0
  14. package/src/checkpointer-workers.ts +514 -0
  15. package/src/checkpointer.ts +1165 -0
  16. package/src/cloudflare.ts +207 -13
  17. package/src/config.ts +3 -2
  18. package/src/credentials.postgres.ts +78 -0
  19. package/src/credentials.sqlite.ts +240 -0
  20. package/src/credentials.ts +630 -0
  21. package/src/cutover.ts +275 -0
  22. package/src/decide.ts +437 -2
  23. package/src/inputs.ts +499 -0
  24. package/src/local.ts +20 -4
  25. package/src/log-checkpointer.ts +116 -0
  26. package/src/log-entry.ts +113 -0
  27. package/src/log-replay.ts +188 -0
  28. package/src/log-store.ts +695 -0
  29. package/src/node.ts +73 -9
  30. package/src/objects.ts +141 -0
  31. package/src/reads.ts +502 -0
  32. package/src/retention.ts +229 -0
  33. package/src/snapshot.postgres.ts +39 -9
  34. package/src/snapshot.sqlite.ts +28 -3
  35. package/src/snapshot.ts +64 -10
  36. package/src/store.ts +832 -36
  37. package/src/use.postgres.ts +111 -0
  38. package/src/use.sqlite.ts +542 -0
  39. package/src/use.ts +1060 -0
  40. package/src/vault.ts +1366 -220
  41. package/src/verifier.ts +136 -0
  42. package/src/witness-workers.ts +163 -0
  43. package/dist/vault-Cbi45kLB.js +0 -5197
package/dist/node.js CHANGED
@@ -1,9 +1,291 @@
1
- import { a as verifyCheckpoint, c as resolveVaultConfig, i as checkpointMessage, n as KekUnavailableError, r as awsKms, s as derivedSigningKey, t as KekBadClaimError } from "./src-Bg0ZeMJW.js";
2
- import { n as prepareVault, t as openVault } from "./vault-Cbi45kLB.js";
1
+ import { a as verifyCheckpoint, c as resolveVaultConfig, i as checkpointMessage, n as KekUnavailableError, r as awsKms, s as derivedSigningKey, t as KekBadClaimError } from "./src-DheG92EM.js";
2
+ import { _ as Unsealed, a as replayStored, b as sealingOf, c as openLogCheckpointer, f as Projector, g as TICK_BOUND_MS, h as Stalled, i as logStore, l as reasonOf, n as prepareVault, p as Fence, r as verifierKeys, s as daysAround, t as openVault, u as upkeep, x as within, y as heldIn } from "./vault-kmtojYnW.js";
3
3
  import { isUnavailable } from "@coffre/db/dialect";
4
4
  import { chmodSync, existsSync, lstatSync, rmSync } from "node:fs";
5
5
  import { Agent, createServer, request } from "node:http";
6
- import { openDatabase } from "@coffre/db/connect";
6
+ import { engineOfUrl, openDatabase } from "@coffre/db/connect";
7
+ import { appendFile, open, readFile, rename, stat, writeFile } from "node:fs/promises";
8
+ import { dirname } from "node:path";
9
+ //#region src/checkpointer-node.ts
10
+ /**
11
+ * The 0.15 checkpointer on Node (docs/design/audit-tree.md): one process
12
+ * seals the log every 500 ms, its memory a file beside the vault, synced
13
+ * before the database has the pair it holds (RememberFirst). The vault's
14
+ * process runs it (`keepCheckpointer`, node.ts). Nothing it awaits is
15
+ * unbounded (T169): a tick past its bound stalls the checkpointer, and the
16
+ * vault's process opens another.
17
+ */
18
+ /** The signer's memory in a file: written whole, synced, renamed over the last, and the directory synced, so a power loss keeps it. */
19
+ function fileMemory(path) {
20
+ return {
21
+ last: () => readFile(path, "utf8").catch((error) => {
22
+ if (error.code === "ENOENT") return null;
23
+ throw error;
24
+ }),
25
+ async remember(record) {
26
+ const file = await open(`${path}.part`, "w");
27
+ try {
28
+ await file.writeFile(record);
29
+ await file.sync();
30
+ } finally {
31
+ await file.close();
32
+ }
33
+ await rename(`${path}.part`, path);
34
+ const dir = await open(dirname(path), "r");
35
+ try {
36
+ await dir.sync();
37
+ } finally {
38
+ await dir.close();
39
+ }
40
+ }
41
+ };
42
+ }
43
+ /** How long the runner waits on an open, the next days of uses, or the log's upkeep after a tick (T169). */
44
+ const OPEN_BOUND_MS = TICK_BOUND_MS;
45
+ const UPKEEP_BOUND_MS = 6e4;
46
+ /**
47
+ * Opens the checkpointer over `database` (the vault's login) and ticks it
48
+ * every `intervalMs`, a tick after the last one ends, each followed by the
49
+ * log's upkeep (days made ahead, retention past `retainDays`, the bulk
50
+ * cap's rows pruned). Each alarm is appended to `<memory>.alarms`, one JSON
51
+ * line each. Given `connect` instead, over a session of its own, cut once
52
+ * it stalls or stops, so that what it still awaits holds no connection of
53
+ * the vault's (review r1).
54
+ */
55
+ async function runCheckpointer(options) {
56
+ const alarmsPath = `${options.memory}.alarms`;
57
+ let appending = Promise.resolve();
58
+ if (options.connect === void 0 === (options.database === void 0)) throw new Error("the checkpointer runs over its database or a session it connects, one of the two");
59
+ const session = options.connect === void 0 ? null : await within(options.connect(), options.tickBoundMs ?? OPEN_BOUND_MS, "connecting");
60
+ const store = logStore(session?.db ?? options.database, { tileRange: options.tileRange });
61
+ const fence = new Fence();
62
+ const checkpointer = await within(store.makeDays(daysAround(Date.now())), options.tickBoundMs ?? OPEN_BOUND_MS, "making the next days of uses").then(() => within(openLogCheckpointer({
63
+ store,
64
+ seed: options.seed,
65
+ memory: (options.memoryOf ?? fileMemory)(options.memory),
66
+ witnesses: options.witnesses,
67
+ record: (alarm) => record(alarm),
68
+ fence,
69
+ ...options.tickBoundMs === void 0 ? {} : { tickBoundMs: options.tickBoundMs }
70
+ }), options.tickBoundMs ?? OPEN_BOUND_MS, "the open")).catch(async (error) => {
71
+ await session?.cut().catch(() => {});
72
+ await fence.close("its open failed");
73
+ throw error;
74
+ });
75
+ /** In order, one after the other; a file grown past a megabyte keeps its last 30 days. */
76
+ function record(alarm) {
77
+ appending = appending.then(async () => {
78
+ await appendFile(alarmsPath, `${JSON.stringify(alarm)}\n`);
79
+ if ((await stat(alarmsPath)).size > 1e6) {
80
+ const kept = (await readAlarms(alarmsPath)).filter((a) => a.at >= alarm.at - 2592e6);
81
+ await writeFile(`${alarmsPath}.part`, kept.map((a) => `${JSON.stringify(a)}\n`).join(""));
82
+ await rename(`${alarmsPath}.part`, alarmsPath);
83
+ }
84
+ }).catch((error) => console.warn(`coffre audit: an alarm could not be kept: ${error.message}`));
85
+ return appending;
86
+ }
87
+ const keep = upkeep(checkpointer, fence.store(store), {
88
+ keepDays: options.retainDays ?? null,
89
+ seed: options.seed,
90
+ alert: (message) => {
91
+ console.warn(`coffre audit: ${message}`);
92
+ record({
93
+ at: Date.now(),
94
+ message
95
+ });
96
+ }
97
+ });
98
+ const keys = await verifierKeys([options.seed]);
99
+ const projector = new Projector(store, keys, (message) => {
100
+ console.warn(`coffre audit: ${message}`);
101
+ record({
102
+ at: Date.now(),
103
+ message
104
+ });
105
+ });
106
+ const interval = options.intervalMs ?? 500;
107
+ let stopped = false;
108
+ let timer;
109
+ let ticking = Promise.resolve();
110
+ let failed = null;
111
+ let last = null;
112
+ let stalled = false;
113
+ let upkeeping = null;
114
+ let retired = false;
115
+ const retire = (why) => {
116
+ if (retired) return;
117
+ retired = true;
118
+ options.stalled?.(why);
119
+ };
120
+ const upkeepOnce = async () => {
121
+ if (upkeeping !== null) return;
122
+ const run = upkeeping = keep().finally(() => {
123
+ if (upkeeping === run) upkeeping = null;
124
+ });
125
+ await within(run, options.upkeepBoundMs ?? UPKEEP_BOUND_MS, "the log's upkeep").catch(async (error) => {
126
+ const message = `a checkpoint failed: ${error.message}`;
127
+ console.warn(`coffre audit: ${message}`);
128
+ failed = message;
129
+ stalled = true;
130
+ clearTimeout(timer);
131
+ await record({
132
+ at: Date.now(),
133
+ message
134
+ });
135
+ ticking.finally(() => retire(message));
136
+ });
137
+ };
138
+ let more = false;
139
+ const loop = () => {
140
+ ticking = checkpointer.tick().then((t) => {
141
+ failed = null;
142
+ last = {
143
+ at: Date.now(),
144
+ failures: t.failures
145
+ };
146
+ projector.kick();
147
+ more = t.more;
148
+ if (!more) upkeepOnce();
149
+ }, async (error) => {
150
+ more = false;
151
+ const message = `a checkpoint failed: ${reasonOf(error)}`;
152
+ if (message !== failed) {
153
+ failed = message;
154
+ console.warn(`coffre audit: ${message}`);
155
+ await record({
156
+ at: Date.now(),
157
+ message
158
+ });
159
+ }
160
+ if (error instanceof Stalled) stalled = true;
161
+ await within(store.makeDays(daysAround(Date.now())), options.tickBoundMs ?? OPEN_BOUND_MS, "making the next days of uses").catch(() => {});
162
+ }).finally(() => {
163
+ if (stalled) retire(failed ?? "the log's upkeep did not end in time");
164
+ else if (!stopped) timer = setTimeout(loop, more ? 0 : interval);
165
+ });
166
+ };
167
+ loop();
168
+ return {
169
+ checkpointer,
170
+ sealThrough: (number, deadline) => checkpointer.sealThrough(number, deadline),
171
+ async heldOutside() {
172
+ return heldIn(await fileMemory(options.memory).last());
173
+ },
174
+ async projected() {
175
+ for (let run = 0; run < 100; run += 1) {
176
+ await projector.settled();
177
+ projector.kick();
178
+ await projector.settled();
179
+ const [cursor, newest] = await Promise.all([store.readsCursor(), store.newest()]);
180
+ if (newest === null || cursor.through >= newest.upto) return;
181
+ }
182
+ throw new Error("the reads' projection did not catch up with the newest pair");
183
+ },
184
+ sealing: () => sealingOf({
185
+ open: !stalled && !stopped,
186
+ failed: failed ?? checkpointer.standing,
187
+ last
188
+ }),
189
+ async alarms(since = 0) {
190
+ await appending;
191
+ return (await readAlarms(alarmsPath)).filter((a) => a.at >= since);
192
+ },
193
+ async stop() {
194
+ stopped = true;
195
+ clearTimeout(timer);
196
+ if (stalled) await session?.cut().catch(() => {});
197
+ await ticking;
198
+ const projecting = projector.stop();
199
+ if (!stalled) await within(projecting, options.tickBoundMs ?? OPEN_BOUND_MS, "the reads' projection").catch(() => {});
200
+ await checkpointer.close();
201
+ await session?.cut().catch(() => {});
202
+ }
203
+ };
204
+ }
205
+ /** The alarms in the file, a line each; a line a crash left torn is skipped. */
206
+ async function readAlarms(path) {
207
+ return (await readFile(path, "utf8").catch((error) => {
208
+ if (error.code === "ENOENT") return "";
209
+ throw error;
210
+ })).split("\n").flatMap((line) => {
211
+ try {
212
+ return line.length > 0 ? [JSON.parse(line)] : [];
213
+ } catch {
214
+ return [];
215
+ }
216
+ });
217
+ }
218
+ /** How long after a refused open the vault's checkpointer tries again (`keepCheckpointer`). */
219
+ const OPEN_AGAIN_MS = 1e4;
220
+ /**
221
+ * The checkpointer as the vault's process keeps it: `runCheckpointer`, tried
222
+ * again every `retryMs` while it cannot open (a log it refuses, as after a
223
+ * restore, docs/restore.md; the database out of reach), each try making the
224
+ * next days of uses first, so that uses go on; its failure an alarm, kept
225
+ * once while it stands. Meanwhile a change's seal fails with why, as on
226
+ * Workers, and the pair held outside is read from the file as ever. One
227
+ * that stalls (T169) is stopped, fenced, and another opened a tick later.
228
+ */
229
+ async function keepCheckpointer(options) {
230
+ let running = null;
231
+ let failed = null;
232
+ let stopped = false;
233
+ let timer;
234
+ const alarmsPath = `${options.memory}.alarms`;
235
+ let retiring = Promise.resolve();
236
+ const tryOpen = async () => {
237
+ try {
238
+ const opened = await runCheckpointer({
239
+ ...options,
240
+ stalled: (why) => {
241
+ if (running === opened) running = null;
242
+ failed = why;
243
+ const retired = opened.stop().catch(() => {});
244
+ retiring = Promise.all([retiring, retired]).then(() => {});
245
+ retired.finally(() => {
246
+ if (!stopped) timer = setTimeout(() => void (trying = tryOpen()), 500);
247
+ });
248
+ }
249
+ });
250
+ running = opened;
251
+ failed = null;
252
+ } catch (error) {
253
+ const message = `the checkpointer cannot open: ${reasonOf(error)}`;
254
+ if (message !== failed) {
255
+ failed = message;
256
+ console.warn(`coffre audit: ${message}`);
257
+ await appendFile(alarmsPath, `${JSON.stringify({
258
+ at: Date.now(),
259
+ message
260
+ })}\n`).catch((e) => console.warn(`coffre audit: an alarm could not be kept: ${e.message}`));
261
+ }
262
+ if (!stopped) timer = setTimeout(() => void (trying = tryOpen()), options.retryMs ?? OPEN_AGAIN_MS);
263
+ }
264
+ };
265
+ let trying = tryOpen();
266
+ await trying;
267
+ return {
268
+ sealThrough: (number, deadline) => running === null ? Promise.reject(new Unsealed(number, failed ?? "the checkpointer is not open")) : running.sealThrough(number, deadline),
269
+ sealing: () => running === null ? {
270
+ ok: false,
271
+ reason: failed ?? "the checkpointer is not open"
272
+ } : running.sealing(),
273
+ async heldOutside() {
274
+ return heldIn(await fileMemory(options.memory).last());
275
+ },
276
+ async alarms(since = 0) {
277
+ return running === null ? (await readAlarms(alarmsPath)).filter((a) => a.at >= since) : running.alarms(since);
278
+ },
279
+ async stop() {
280
+ stopped = true;
281
+ clearTimeout(timer);
282
+ await trying;
283
+ await running?.stop();
284
+ await retiring;
285
+ }
286
+ };
287
+ }
288
+ //#endregion
7
289
  //#region src/failure.ts
8
290
  /**
9
291
  * A vault call's failure, as `serveVault` answers it: its message, and the
@@ -55,16 +337,31 @@ const METHODS = [
55
337
  "checkpoint",
56
338
  "about",
57
339
  "keyChecks",
58
- "verifyLog"
340
+ "verifyLog",
341
+ "use",
342
+ "useNamed",
343
+ "useMany",
344
+ "prepare",
345
+ "waitSealed",
346
+ "signIn",
347
+ "revokeCredential",
348
+ "issueToken",
349
+ "changeBindings",
350
+ "cutover",
351
+ "verifyTrees",
352
+ "verifyReads",
353
+ "vouch",
354
+ "sealing"
59
355
  ];
60
356
  /**
61
- * The vault in this process, over `db`. Every argument and result goes
357
+ * The vault in this process, over `db`, and `hot` for 0.15's uses (vault.ts,
358
+ * `openVault`). Every argument and result goes
62
359
  * through JSON on the way, as it would over RPC or a socket, so what works
63
360
  * here does not rely on sharing objects with the caller. `close` is
64
361
  * whatever lets go of the database, when the vault opened it.
65
362
  */
66
- async function openLocalVault(db, config, options = {}, close = async () => {}) {
67
- const vault = openVault(db, await prepareVault(config, options));
363
+ async function openLocalVault(db, config, options = {}, close = async () => {}, hot) {
364
+ const vault = openVault(db, await prepareVault(config, options), hot);
68
365
  const local = Object.fromEntries(METHODS.map((name) => [name, async (...args) => json(await vault[name](...json(args)))]));
69
366
  return Object.assign(local, { close });
70
367
  }
@@ -75,11 +372,12 @@ function json(value) {
75
372
  //#region src/node.ts
76
373
  /**
77
374
  * The vault on Node, one of two ways, each over the database the server
78
- * uses, through the vault's own login:
375
+ * uses, through the vault's own login and its uses' (`hotDatabase`), with
376
+ * the checkpointer that seals its log in the same process (`checkpointer`):
79
377
  *
80
- * - in the server's own process: `vault: await localVault({ database, ...keys })`;
378
+ * - in the server's own process: `vault: await localVault({ database, hotDatabase, checkpointer, ...keys })`;
81
379
  * - as a process of its own, which the server reaches over a Unix socket:
82
- * `serveVault({ socket, database, ...keys })` there, and
380
+ * `serveVault({ socket, database, hotDatabase, checkpointer, ...keys })` there, and
83
381
  * `vault: connectVault(socket)` in the server.
84
382
  *
85
383
  * The second keeps every key out of the process that faces the network. The
@@ -89,15 +387,59 @@ function json(value) {
89
387
  * with the arguments as a JSON array, and refusals come back as values, as
90
388
  * from any vault; only a failure is an error.
91
389
  */
92
- /** The vault in this process. */
390
+ /**
391
+ * The vault in this process, and under 0.15's log, the default, its
392
+ * checkpointer, opened before the vault serves, so that no call meets a log
393
+ * with no checkpoint yet. A configuration without what 0.15 needs is refused.
394
+ */
93
395
  async function localVault(config, options = {}) {
94
396
  const resolved = resolveVaultConfig(config);
95
- if (typeof config.database !== "string") return openLocalVault(config.database, resolved, options);
96
- const { db, close } = await openDatabase(config.database);
97
- return openLocalVault(db, resolved, options, close).catch(async (error) => {
397
+ const log015 = (options.log ?? "0.15") === "0.15";
398
+ const own = log015 && options.sealThrough === void 0;
399
+ if (log015 && config.hotDatabase === void 0) throw new Error("the vault's uses run as a login of their own: set hotDatabase in the vault's configuration, the database as coffre_vault_hot (HOT_DATABASE_URL)");
400
+ if (own && config.checkpointer?.memory === void 0) throw new Error("the vault answers a change once its checkpointer has sealed it: set checkpointer: { memory } in the vault's configuration, the file its signer remembers in (CHECKPOINTER_MEMORY)");
401
+ const opened = [];
402
+ const open = async (database) => {
403
+ if (typeof database !== "string") return database;
404
+ const { db, close } = await openDatabase(database);
405
+ opened.push(close);
406
+ return db;
407
+ };
408
+ let checkpointer = null;
409
+ const close = async () => {
410
+ await checkpointer?.stop();
411
+ await Promise.all(opened.map((done) => done()));
412
+ };
413
+ try {
414
+ const db = await open(config.database);
415
+ const hot = config.hotDatabase === void 0 ? void 0 : config.hotDatabase === config.database ? db : await open(config.hotDatabase);
416
+ const connections = hot?.$client?.options?.max;
417
+ const uses = {
418
+ ...connections === void 0 ? {} : { connections },
419
+ ...options.uses
420
+ };
421
+ if (own) {
422
+ const url = typeof config.database === "string" && engineOfUrl(config.database) === "postgres" ? config.database : null;
423
+ const kept = checkpointer = await keepCheckpointer({
424
+ ...url === null ? { database: db } : { connect: () => openDatabase(url, { max: 2 }) },
425
+ seed: resolved.signingKeys[0],
426
+ memory: config.checkpointer.memory
427
+ });
428
+ options = {
429
+ ...options,
430
+ sealThrough: kept.sealThrough,
431
+ heldOutside: kept.heldOutside,
432
+ sealing: kept.sealing
433
+ };
434
+ }
435
+ return await openLocalVault(db, resolved, {
436
+ ...options,
437
+ uses
438
+ }, close, hot);
439
+ } catch (error) {
98
440
  await close();
99
441
  throw error;
100
- });
442
+ }
101
443
  }
102
444
  const METHOD_NAMES = new Set(METHODS);
103
445
  const MAX_BODY = 8388608;
@@ -182,4 +524,4 @@ async function readBody(stream) {
182
524
  return Buffer.concat(chunks).toString("utf8");
183
525
  }
184
526
  //#endregion
185
- export { KekBadClaimError, KekUnavailableError, awsKms, checkpointMessage, connectVault, derivedSigningKey, localVault, serveVault, verifyCheckpoint };
527
+ export { KekBadClaimError, KekUnavailableError, awsKms, checkpointMessage, connectVault, derivedSigningKey, fileMemory, keepCheckpointer, localVault, replayStored, runCheckpointer, serveVault, verifyCheckpoint };
@@ -83,7 +83,7 @@ function signingKeys(given, keks) {
83
83
  return [signing, ...derived].filter((key, i, all) => all.findIndex((other) => other.equals(key)) === i);
84
84
  }
85
85
  function checkBulkLimit({ count, windowMinutes }) {
86
- if (!Number.isInteger(count) || count < 1 || !(windowMinutes > 0) || windowMinutes > 1440) throw new Error(`bulkLimit needs a whole count of at least 1 and a window above 0 minutes, a day (${MAX_BULK_WINDOW_MINUTES}) at most`);
86
+ if (!Number.isInteger(count) || count < 1 || !(windowMinutes > 0) || windowMinutes > 1440 || !Number.isInteger(windowMinutes * 6e4)) throw new Error(`bulkLimit needs a whole count of at least 1 and a window above 0 minutes, a day (${MAX_BULK_WINDOW_MINUTES}) at most, a whole number of milliseconds`);
87
87
  return {
88
88
  count,
89
89
  windowMs: windowMinutes * 6e4