@coffre/vault 0.10.0 → 0.10.2

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.
@@ -1,155 +1,15 @@
1
- import { ACCESS_ACTIONS, POLICY_REVISION, checkpointMessage, checkpointVerifier, describeAccessFault, handsBack, policyRevision } from "@coffre/core/vault";
1
+ import { ACCESS_ACTIONS, ACCESS_ACTIONS as ACCESS_ACTIONS$1, POLICY_REVISION, checkpointMessage, checkpointVerifier, describeAccessFault, handsBack, policyRevision } from "@coffre/core/vault";
2
2
  import { DEK_BYTES, KEY_CHECK, KEY_CHECK_CONTEXT, KEY_CHECK_VALUE, KekBadClaimError, KekCancelledError, KekUnavailableError, LocalKekProvider } from "@coffre/core/kek";
3
- import { createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
4
- import { tablesOf } from "@coffre/db";
3
+ import { createHash, createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
4
+ import { prepared, tablesOf } from "@coffre/db";
5
5
  import { DEFAULT_POLICY, EVERYWHERE, EVERY_ENVIRONMENT, FIXED_ROLES, INSTANCE_ROLES, NOTHING, PROJECT_ONLY_PERMISSIONS, ROLES, ROLE_ID, UNWRITTEN_FORK_MS, allows, assignableToEnvironment, definitionProblem, givesService, grantKind, isBuiltinRole, isCredentialScope, isInstanceRole, isRole, isScope, managesService, mayManageAccess, normalDefinition, normalScope, ownsInstance, runsInstance, setsUpServices, unscoped, withinCredential } from "@coffre/core/access";
6
6
  import { GENESIS_HASH, deriveLogKey, verifyEntries } from "@coffre/core/audit";
7
7
  import { checkContext, ownerKey } from "@coffre/core/envelope";
8
- import { shownMember } from "@coffre/core/schemas";
9
- import { SNAPSHOT, clockMillis, engineOf, forUpdate, isUniqueViolation, tombstone, truth } from "@coffre/db/dialect";
8
+ import { TOMBSTONE, shownMember } from "@coffre/core/schemas";
9
+ import { SNAPSHOT, clockMillis, engineOf, forUpdate, isUniqueViolation, isUnreachable, rowsOf, tombstone, truth } from "@coffre/db/dialect";
10
10
  import { accessEntriesAbout, everyProjectConversion, insertGrant, liveProjects, newestAccess, readGrants, reportStands, roleColumns, storedRole } from "@coffre/db/grants";
11
- import { LOOK_BACK, appendEntries, entryColumns, lockLogHead, newestAuthentic, newestVaultEntries, newestVaultSeq, withinBytes } from "@coffre/db/log";
12
- import { and, asc, count, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, lte, or, sql } from "drizzle-orm";
13
- //#region src/accounting.ts
14
- const KEY_ACTIONS = /* @__PURE__ */ new Set([
15
- "secret.read",
16
- "key.wrap",
17
- "key.rewrap"
18
- ]);
19
- /**
20
- * Every key operation's intent and its outcomes, entry by entry, oldest
21
- * first, as the full check reads the vault's entries (`add`); then what is
22
- * still open (`result`). The chain can hold while a process died between an
23
- * intent and its outcomes: only a missed deadline is a fault.
24
- */
25
- var KeyAccounting = class {
26
- #intents = /* @__PURE__ */ new Map();
27
- #ids = /* @__PURE__ */ new Set();
28
- #fault = null;
29
- add(entry) {
30
- if (this.#fault !== null || entry.author !== "vault") return;
31
- const broken = (reason) => {
32
- this.#fault = {
33
- ok: false,
34
- failedAtSeq: Number(entry.seq),
35
- reason
36
- };
37
- };
38
- if (entry.action === "key.intent") {
39
- const parsed = parseIntent(entry);
40
- if (typeof parsed === "string") return broken(parsed);
41
- if (this.#ids.has(parsed.intentId)) return broken("key intent repeats an operation identity");
42
- this.#ids.add(parsed.intentId);
43
- if (parsed.keys.length > 0) this.#intents.set(entry.seq, {
44
- ...parsed,
45
- entry,
46
- seen: /* @__PURE__ */ new Set()
47
- });
48
- } else if (KEY_ACTIONS.has(entry.action) && entry.relatedSeq !== null) {
49
- const parsed = parseOutcome(entry);
50
- if (typeof parsed === "string") return broken(parsed);
51
- const intent = this.#intents.get(parsed.relatedSeq);
52
- for (const outcome of parsed.items) {
53
- const key = intent?.keys[outcome.item];
54
- if (intent === void 0 || key === void 0 || intent.seen.has(outcome.item)) return broken("key outcome does not identify one item of its intent");
55
- if (parsed.intentId !== intent.intentId || entry.actor !== intent.entry.actor || entry.action !== intent.operation) return broken("key outcome belongs to another operation");
56
- if (outcome.secretId !== key.secretId || outcome.version !== key.version || outcome.subject !== key.subject) return broken("key outcome does not match its intended secret");
57
- intent.seen.add(outcome.item);
58
- }
59
- if (intent !== void 0 && intent.seen.size === intent.keys.length) this.#intents.delete(intent.entry.seq);
60
- }
61
- }
62
- /** The first fault, or the batches still under way at `at`: live calls can finish after the snapshot. */
63
- result(at) {
64
- if (this.#fault !== null) return this.#fault;
65
- const intents = [...this.#intents.values()];
66
- const overdue = intents.filter((intent) => intent.expiresAt <= at).sort((a, b) => a.expiresAt - b.expiresAt);
67
- if (overdue.length === 0) return {
68
- ok: true,
69
- pending: intents.length
70
- };
71
- const reason = overdue.map((intent) => {
72
- const missing = intent.keys.length - intent.seen.size;
73
- return `key intent ${intent.intentId} is overdue: ${missing} of ${intent.keys.length} outcomes missing`;
74
- }).join("; ");
75
- return {
76
- ok: false,
77
- failedAtSeq: Number(overdue[0].entry.seq),
78
- reason
79
- };
80
- }
81
- };
82
- function parseIntent(entry) {
83
- const detail = payload(entry.metadata);
84
- if (detail === null) return "key intent has no valid accounting payload";
85
- const { intent, operation, expiresAt } = detail;
86
- if (typeof intent !== "string") return "key intent has no operation identity";
87
- if (typeof operation !== "string" || !KEY_ACTIONS.has(operation)) return "key intent has an invalid operation";
88
- if (typeof expiresAt !== "number" || !Number.isSafeInteger(expiresAt) || expiresAt < entry.occurredAt) return "key intent has no valid deadline";
89
- if (!Array.isArray(detail.keys)) return "key intent has no valid item list";
90
- const keys = [];
91
- for (const [index, value] of detail.keys.entries()) {
92
- const key = record(value);
93
- if (key === null || key.item !== index) return "key intent items are not numbered in order";
94
- const { secretId, version, subject } = key;
95
- if (typeof secretId !== "string" || typeof subject !== "string") return "key intent item has no secret identity";
96
- if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key intent item has no valid version";
97
- keys.push({
98
- item: index,
99
- secretId,
100
- version,
101
- subject
102
- });
103
- }
104
- return {
105
- intentId: intent,
106
- operation,
107
- expiresAt,
108
- keys
109
- };
110
- }
111
- function parseOutcome(entry) {
112
- const detail = payload(entry.metadata);
113
- if (detail === null) return "key outcome has no valid accounting payload";
114
- const { intent } = detail;
115
- if (typeof intent !== "string" || entry.relatedSeq === null) return "key outcome has no intent identity";
116
- const many = detail.secrets;
117
- if (many !== void 0 && (!Array.isArray(many) || many.length === 0)) return "key outcome has no valid item list";
118
- const listed = many === void 0 ? [{
119
- ...detail,
120
- secretId: entry.secretId ?? detail.secretId
121
- }] : many.map(record);
122
- const items = [];
123
- for (const one of listed) {
124
- if (one === null) return "key outcome has no valid item list";
125
- const { item, secretId, version, subject } = one;
126
- if (typeof item !== "number" || !Number.isSafeInteger(item) || item < 0) return "key outcome has no valid item number";
127
- if (typeof secretId !== "string" || typeof subject !== "string") return "key outcome has no secret identity";
128
- if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key outcome has no valid version";
129
- items.push({
130
- item,
131
- secretId,
132
- version,
133
- subject
134
- });
135
- }
136
- return {
137
- intentId: intent,
138
- relatedSeq: entry.relatedSeq,
139
- items
140
- };
141
- }
142
- function payload(text) {
143
- try {
144
- return record(JSON.parse(text));
145
- } catch {
146
- return null;
147
- }
148
- }
149
- function record(value) {
150
- return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
151
- }
152
- //#endregion
11
+ import { LogHeadMismatch, appendEntries, entryColumns, lockLogHead, missingEntry, newestAuthentic, newestVaultEntries, withinBytes } from "@coffre/db/log";
12
+ import { and, asc, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, lte, or, sql } from "drizzle-orm";
153
13
  //#region src/checkpoint.ts
154
14
  const PKCS8_ED25519 = [
155
15
  48,
@@ -193,10 +53,10 @@ async function boundLockWaits(tx, ms) {
193
53
  }
194
54
  /** The database's clock, now. */
195
55
  async function now(db) {
196
- const { auditChainHead } = tablesOf(db);
197
- const [{ at }] = await db.select({ at: clockMillis(db) }).from(auditChainHead);
56
+ const [{ at }] = await clock(db);
198
57
  return at;
199
58
  }
59
+ const clock = prepared((db) => db.select({ at: clockMillis(db) }).from(tablesOf(db).auditChainHead));
200
60
  function memberColumns(db) {
201
61
  const { vaultMembers } = tablesOf(db);
202
62
  return {
@@ -215,7 +75,7 @@ function memberColumns(db) {
215
75
  };
216
76
  }
217
77
  /** A member as their row says (`storedRole`). */
218
- function memberOf({ owner, role, scope, ...row }) {
78
+ function memberOf$1({ owner, role, scope, ...row }) {
219
79
  return {
220
80
  ...row,
221
81
  ...storedRole({
@@ -234,13 +94,16 @@ function storedChange(change) {
234
94
  };
235
95
  }
236
96
  async function member(db, principal) {
237
- const { vaultMembers } = tablesOf(db);
238
- const [row] = await db.select(memberColumns(db)).from(vaultMembers).where(eq(vaultMembers.principal, principal));
239
- return row === void 0 ? void 0 : memberOf(row);
97
+ const [row] = await memberRow(db, { principal });
98
+ return row === void 0 ? void 0 : memberOf$1(row);
240
99
  }
100
+ const memberRow = prepared((db) => {
101
+ const { vaultMembers } = tablesOf(db);
102
+ return db.select(memberColumns(db)).from(vaultMembers).where(eq(vaultMembers.principal, sql.placeholder("principal")));
103
+ });
241
104
  async function allMembers(db) {
242
105
  const { vaultMembers } = tablesOf(db);
243
- return (await db.select(memberColumns(db)).from(vaultMembers).orderBy(asc(vaultMembers.principal))).map(memberOf);
106
+ return (await db.select(memberColumns(db)).from(vaultMembers).orderBy(asc(vaultMembers.principal))).map(memberOf$1);
244
107
  }
245
108
  /**
246
109
  * The rows that say their member is an active Owner of the whole instance
@@ -250,7 +113,7 @@ async function allMembers(db) {
250
113
  */
251
114
  async function instanceOwners(db) {
252
115
  const { vaultMembers } = tablesOf(db);
253
- return (await db.select(memberColumns(db)).from(vaultMembers).where(and(eq(vaultMembers.status, "active"), eq(vaultMembers.role, "owner"))).orderBy(asc(vaultMembers.principal))).map(memberOf).filter(ownsInstance);
116
+ return (await db.select(memberColumns(db)).from(vaultMembers).where(and(eq(vaultMembers.status, "active"), eq(vaultMembers.role, "owner"))).orderBy(asc(vaultMembers.principal))).map(memberOf$1).filter(ownsInstance);
254
117
  }
255
118
  /**
256
119
  * Lock these members' rows for the rest of the transaction, in one order
@@ -261,7 +124,7 @@ async function instanceOwners(db) {
261
124
  async function lockMembers(tx, principals) {
262
125
  const { vaultMembers } = tablesOf(tx);
263
126
  const rows = await forUpdate(tx, tx.select(memberColumns(tx)).from(vaultMembers).where(inArray(vaultMembers.principal, [...new Set(principals)])).orderBy(asc(vaultMembers.principal)), "no key update");
264
- return new Map(rows.map((row) => [row.principal, memberOf(row)]));
127
+ return new Map(rows.map((row) => [row.principal, memberOf$1(row)]));
265
128
  }
266
129
  /**
267
130
  * Add `row`. A principal has one row: when two decisions admit the same
@@ -358,19 +221,6 @@ async function everyProjectHolders(db) {
358
221
  const { vaultGrants } = tablesOf(db);
359
222
  return (await db.selectDistinct({ principal: vaultGrants.principal }).from(vaultGrants).where(and(isNull(vaultGrants.projectId), isNull(vaultGrants.environmentId))).orderBy(asc(vaultGrants.principal))).map((row) => row.principal);
360
223
  }
361
- /** The action of an entry that releases a key, which the bulk limit counts. */
362
- const RELEASE = "secret.read";
363
- /**
364
- * How many keys the vault has released to `principal` since `after`: one
365
- * for an entry about one secret, and as many as it lists for one about
366
- * several (`secrets`), a read logged once per place.
367
- */
368
- async function releasesSince(db, principal, after) {
369
- const { auditLog } = tablesOf(db);
370
- const listed = engineOf(db) === "postgres" ? sql`jsonb_array_length((${auditLog.metadata})::jsonb -> 'secrets')` : sql`json_array_length(${auditLog.metadata}, '$.secrets')`;
371
- const [{ n }] = await db.select({ n: sql`SUM(COALESCE(${listed}, 1))` }).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.actor, principal), eq(auditLog.action, RELEASE), eq(auditLog.decision, "allow"), gt(auditLog.occurredAt, after)));
372
- return Number(n ?? 0);
373
- }
374
224
  /** Rows of the log as the chain's codec reads them: `author` and `decision` are checked by the table. */
375
225
  function stored(rows) {
376
226
  return rows;
@@ -405,16 +255,6 @@ async function hashesAt(db, seqs) {
405
255
  }
406
256
  /** How many entries' hashes one query asks for: a checkpoint every five minutes is tens of thousands of a year. */
407
257
  const HASHES_AT_ONCE = 5e3;
408
- /**
409
- * Whether every entry from the first through `seq` is there. Seqs are the
410
- * primary key, so the count is `seq + 1` exactly when none is missing: one
411
- * pass over the key's index, never the entries.
412
- */
413
- async function complete(db, seq) {
414
- const { auditLog } = tablesOf(db);
415
- const [{ n }] = await db.select({ n: count() }).from(auditLog).where(and(gte(auditLog.seq, 0n), lte(auditLog.seq, seq)));
416
- return BigInt(n) === seq + 1n;
417
- }
418
258
  /** The entry at `seq`'s hash, or undefined when there is none. */
419
259
  async function hashAt(db, seq) {
420
260
  const { auditLog } = tablesOf(db);
@@ -529,10 +369,55 @@ async function latestVaultEntry(db, actions) {
529
369
  const [row] = await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), inArray(auditLog.action, [...actions]), eq(auditLog.decision, "allow"))).orderBy(desc(auditLog.seq)).limit(1);
530
370
  return row === void 0 ? void 0 : stored([row])[0];
531
371
  }
372
+ /**
373
+ * The vault's newest checkpoint that keeps a tally, allowed, or undefined:
374
+ * one written since 0.10.1, which names the last entry of the prefix it
375
+ * signs as related (`related_seq`); those before name none.
376
+ */
377
+ async function latestTallied(db) {
378
+ const { auditLog } = tablesOf(db);
379
+ const [row] = await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.action, "audit.checkpoint"), eq(auditLog.decision, "allow"), isNotNull(auditLog.relatedSeq))).orderBy(desc(auditLog.seq)).limit(1);
380
+ return row === void 0 ? void 0 : stored([row])[0];
381
+ }
382
+ /** The vault's newest `audit.checkpoint` entry, signed or refused, or undefined. */
383
+ async function newestCheckpointEntry(db) {
384
+ const { auditLog } = tablesOf(db);
385
+ const [row] = await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.action, "audit.checkpoint"))).orderBy(desc(auditLog.seq)).limit(1);
386
+ return row === void 0 ? void 0 : stored([row])[0];
387
+ }
388
+ /**
389
+ * The vault's checkpoints that keep a tally after entry `after` and through
390
+ * `through`, oldest first, whole: as many as `BATCH_BYTES` of what they say
391
+ * holds, but always one. A reader asks again after the last.
392
+ */
393
+ async function talliedWithin(db, after, through, limit) {
394
+ const { auditLog } = tablesOf(db);
395
+ const tallied = and(eq(auditLog.author, "vault"), eq(auditLog.action, "audit.checkpoint"), eq(auditLog.decision, "allow"), isNotNull(auditLog.relatedSeq), gt(auditLog.seq, after), lte(auditLog.seq, through));
396
+ const batch = await withinBytes(db, tallied, limit);
397
+ if (batch === null) return [];
398
+ return stored(await db.select(entryColumns(db)).from(auditLog).where(and(tallied, gte(auditLog.seq, batch.from), lte(auditLog.seq, batch.to))).orderBy(asc(auditLog.seq)));
399
+ }
400
+ /** The vault's newest refusal of `action` with `code` after entry `after`, or undefined: one range of the (author, action, seq) index. */
401
+ async function newestRefusal(db, action, code, after) {
402
+ const { auditLog } = tablesOf(db);
403
+ const [row] = await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.action, action), gt(auditLog.seq, after), eq(auditLog.decision, "deny"), eq(auditLog.code, code))).orderBy(desc(auditLog.seq)).limit(1);
404
+ return row === void 0 ? void 0 : stored([row])[0];
405
+ }
406
+ /** Up to `limit` of the vault's checkpoints that keep a tally after entry `after`, oldest first: each one's entry, and the last entry it signs. */
407
+ async function talliedAfter(db, after, limit) {
408
+ const { auditLog } = tablesOf(db);
409
+ return (await db.select({
410
+ seq: auditLog.seq,
411
+ signs: auditLog.relatedSeq
412
+ }).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.action, "audit.checkpoint"), eq(auditLog.decision, "allow"), isNotNull(auditLog.relatedSeq), gt(auditLog.seq, after))).orderBy(asc(auditLog.seq)).limit(limit)).map(({ seq, signs }) => ({
413
+ seq,
414
+ signs
415
+ }));
416
+ }
532
417
  /** The vault's newest allowed access entry about each member who has one. */
533
418
  async function newestAccessEntries(db) {
534
419
  const { auditLog } = tablesOf(db);
535
- const newest = db.select({ seq: sql`max(${auditLog.seq})` }).from(auditLog).where(and(eq(auditLog.author, "vault"), inArray(auditLog.action, [...ACCESS_ACTIONS]), eq(auditLog.decision, "allow"))).groupBy(auditLog.subjectPrincipal);
420
+ const newest = db.select({ seq: sql`max(${auditLog.seq})` }).from(auditLog).where(and(eq(auditLog.author, "vault"), inArray(auditLog.action, [...ACCESS_ACTIONS$1]), eq(auditLog.decision, "allow"))).groupBy(auditLog.subjectPrincipal);
536
421
  const rows = stored(await db.select(entryColumns(db)).from(auditLog).where(inArray(auditLog.seq, newest)));
537
422
  return new Map(rows.flatMap((row) => row.subjectPrincipal === null ? [] : [[row.subjectPrincipal, row]]));
538
423
  }
@@ -621,86 +506,158 @@ async function versions(db, ids) {
621
506
  function vaultLogKey(signingKey) {
622
507
  return deriveLogKey("vault", signingKey);
623
508
  }
624
- /** Before the first entry: nothing verified yet. */
625
- const UNVERIFIED = {
626
- nextSeq: 0n,
627
- hash: GENESIS_HASH,
628
- vaultEntries: 0,
629
- vaultKeys: []
509
+ /** Before the first entry. */
510
+ const START = {
511
+ seq: -1n,
512
+ hash: GENESIS_HASH
630
513
  };
631
- /** The further of two anchors, when two calls verified at once. */
514
+ /** The further of two positions, when two calls walked at once. */
632
515
  function further(a, b) {
633
- return b.nextSeq > a.nextSeq ? b : a;
516
+ return b.seq > a.seq ? b : a;
634
517
  }
635
518
  /** Entries read at once, at most, and no more than `BATCH_BYTES` of what they say: well within a Worker's memory, and a fifth of the round trips of 1000. */
636
519
  const VERIFY_BATCH = 5e3;
637
520
  /**
638
- * Check the chain, a batch in memory at a time, and return where it is now
639
- * verified to. Three parts:
640
- *
641
- * - `shown`, the page a reader is looking at: each entry against its own
642
- * hash and MAC;
643
- * - `anchor`, the head at the last check: still there, unchanged. A rewrite
644
- * of anything before it, chained again to hide, changes its hash;
645
- * - every entry after the anchor, or from the first.
521
+ * Walk the chain on from `from`, a batch in memory at a time, through `to`
522
+ * or the log's last entry, and no more than `limit` entries: every link and
523
+ * hash recomputed, each of the vault's entries by its MAC, its keys moving
524
+ * only forward, and each fed to `walk`. Where it got to, and whether that
525
+ * is as far as it was to go: a walk cut short by `limit` goes on from there
526
+ * another time.
646
527
  *
647
- * So a view rehashes only what is new since the last one. What it leaves
648
- * out is an entry before the anchor edited in place, not chained again, and
649
- * not on the page: a full check, which starts from `UNVERIFIED`, finds that,
650
- * as does the first view after a start, which has no anchor. `onBatch` sees
651
- * each batch once it has verified, for checks that read the same entries.
528
+ * It takes `from` as given: whether the log still holds it is the caller's
529
+ * to check, with the other heads it must hold, in one read.
652
530
  */
653
- async function verifyChain(db, logKeys, shown, anchor, onBatch = () => {}) {
654
- const broken = (failedAtSeq, reason) => ({
655
- verification: {
656
- ok: false,
657
- failedAtSeq: Number(failedAtSeq),
658
- reason: withCause(reason)
659
- },
660
- anchor
661
- });
662
- const keys = {
663
- keys: logKeys,
664
- chainOnly: ["app"]
665
- };
666
- for (const row of [...shown].sort((a, b) => a.seq < b.seq ? -1 : 1)) {
667
- const result = verifyEntries([row], {
668
- ...keys,
669
- startSeq: row.seq,
670
- startPrevHash: row.prevHash
531
+ async function rehash(db, logKeys, from, walk, { to, limit = Infinity }) {
532
+ let at = from;
533
+ for (let left = limit;;) {
534
+ if (to !== void 0 && at.seq >= to) return {
535
+ ok: true,
536
+ at,
537
+ done: true
538
+ };
539
+ if (left <= 0) return {
540
+ ok: true,
541
+ at,
542
+ done: !(await entriesFrom(db, at.seq + 1n, 1)).some((entry) => to === void 0 || entry.seq <= to)
543
+ };
544
+ let batch = await entriesFrom(db, at.seq + 1n, Math.min(VERIFY_BATCH, left));
545
+ if (to !== void 0) batch = batch.filter((entry) => entry.seq <= to);
546
+ if (batch.length === 0) return {
547
+ ok: true,
548
+ at,
549
+ done: to === void 0
550
+ };
551
+ const result = verifyEntries(batch, {
552
+ keys: logKeys,
553
+ chainOnly: ["app"],
554
+ startSeq: at.seq + 1n,
555
+ startPrevHash: at.hash
671
556
  });
672
- if (!result.ok) return broken(result.failedAtSeq, result.reason);
557
+ if (!result.ok) return {
558
+ ok: false,
559
+ failedAtSeq: Number(result.failedAtSeq),
560
+ reason: withCause(result.reason)
561
+ };
562
+ for (const entry of batch) {
563
+ const moved = entry.author === "vault" ? walk.add(entry, logKeys[0].keyId) : null;
564
+ if (moved !== null) return {
565
+ ok: false,
566
+ failedAtSeq: Number(moved.failedAtSeq),
567
+ reason: moved.reason
568
+ };
569
+ }
570
+ at = {
571
+ seq: result.nextSeq - 1n,
572
+ hash: result.head
573
+ };
574
+ left -= batch.length;
673
575
  }
674
- if (anchor.nextSeq > 0n && !(await hashAt(db, anchor.nextSeq - 1n))?.equals(anchor.hash)) return broken(anchor.nextSeq - 1n, "changed since the vault last verified it");
675
- let verified = anchor;
676
- for (;;) {
677
- const batch = await entriesFrom(db, verified.nextSeq, VERIFY_BATCH);
576
+ }
577
+ /**
578
+ * The chain again from `from`, through `to` and no more than `limit`
579
+ * entries: each hash recomputed from its entry and each link checked, and
580
+ * nothing else, so a stretch costs a hash an entry. What a checkpoint does
581
+ * in the background to the prefixes signed before (`#scrub` in vault.ts):
582
+ * each entry's MAC was checked when it was signed over, and its hash pins
583
+ * it since, so all that can change behind a signed prefix and keep the
584
+ * chain is an entry edited in place, its stored hash left, which its hash
585
+ * finds.
586
+ */
587
+ async function relink(db, from, to, limit) {
588
+ let at = from;
589
+ while (at.seq < to && limit > 0) {
590
+ const batch = (await entriesFrom(db, at.seq + 1n, Math.min(VERIFY_BATCH, limit))).filter((entry) => entry.seq <= to);
678
591
  if (batch.length === 0) break;
679
592
  const result = verifyEntries(batch, {
680
- ...keys,
681
- startSeq: verified.nextSeq,
682
- startPrevHash: verified.hash
593
+ keys: [],
594
+ chainOnly: ["app", "vault"],
595
+ startSeq: at.seq + 1n,
596
+ startPrevHash: at.hash
683
597
  });
684
- if (!result.ok) return broken(result.failedAtSeq, result.reason);
685
- const moved = forward(batch, verified.vaultKeys, logKeys[0].keyId);
686
- if ("failedAtSeq" in moved) return broken(moved.failedAtSeq, moved.reason);
687
- onBatch(batch);
688
- verified = {
689
- nextSeq: result.nextSeq,
690
- hash: result.head,
691
- vaultEntries: verified.vaultEntries + result.authenticated,
692
- vaultKeys: moved.keys
598
+ if (!result.ok) return {
599
+ ok: false,
600
+ failedAtSeq: Number(result.failedAtSeq),
601
+ reason: result.reason
602
+ };
603
+ at = {
604
+ seq: result.nextSeq - 1n,
605
+ hash: result.head
693
606
  };
607
+ limit -= batch.length;
694
608
  }
695
609
  return {
696
- verification: {
697
- ok: true,
698
- entries: verified.vaultEntries
699
- },
700
- anchor: verified
610
+ ok: true,
611
+ at
701
612
  };
702
613
  }
703
614
  /**
615
+ * The vault's own entries after `after`, through `to` or its last, no more
616
+ * than `limit` of them, oldest first: each against its own hash, and by its
617
+ * MAC, over the link it names, its keys moving only forward, each fed to
618
+ * `walk`. The links between entries are the caller's to recompute, as the
619
+ * app does over the same stretch. The last it read, and whether that is as
620
+ * far as it was to go.
621
+ */
622
+ async function ownEntries(db, logKeys, after, walk, { to, limit = Infinity }) {
623
+ for (let left = limit;;) {
624
+ if (left <= 0) return {
625
+ ok: true,
626
+ at: after,
627
+ done: !(await vaultEntriesAfter(db, after, 1)).some((entry) => to === void 0 || entry.seq <= to)
628
+ };
629
+ let batch = await vaultEntriesAfter(db, after, Math.min(VERIFY_BATCH, left));
630
+ if (to !== void 0) batch = batch.filter((entry) => entry.seq <= to);
631
+ if (batch.length === 0) return {
632
+ ok: true,
633
+ at: after,
634
+ done: true
635
+ };
636
+ for (const entry of batch) {
637
+ const result = verifyEntries([entry], {
638
+ startSeq: entry.seq,
639
+ startPrevHash: entry.prevHash,
640
+ keys: logKeys
641
+ });
642
+ if (!result.ok) return {
643
+ ok: false,
644
+ failedAtSeq: Number(result.failedAtSeq),
645
+ reason: withCause(result.reason)
646
+ };
647
+ }
648
+ for (const entry of batch) {
649
+ const moved = walk.add(entry, logKeys[0].keyId);
650
+ if (moved !== null) return {
651
+ ok: false,
652
+ failedAtSeq: Number(moved.failedAtSeq),
653
+ reason: moved.reason
654
+ };
655
+ }
656
+ after = batch[batch.length - 1].seq;
657
+ left -= batch.length;
658
+ }
659
+ }
660
+ /**
704
661
  * The vault's keys only move forward. Once its entries move from one key to
705
662
  * the next, the one before writes no more; once they reach `current`, the
706
663
  * key it writes with now, no other key writes again. So a key it replaced,
@@ -979,43 +936,662 @@ function grantSet(grants) {
979
936
  grant.grantedBy
980
937
  ]).map((tuple) => JSON.stringify(tuple)).sort();
981
938
  }
982
- /** Whether two reads of a member's grants are the same set, as the MAC sees them. */
983
- function sameGrants(a, b) {
984
- const [x, y] = [grantSet(a), grantSet(b)];
985
- return x.length === y.length && x.every((tuple, i) => tuple === y[i]);
939
+ /** Whether two reads of a member's grants are the same set, as the MAC sees them. */
940
+ function sameGrants(a, b) {
941
+ const [x, y] = [grantSet(a), grantSet(b)];
942
+ return x.length === y.length && x.every((tuple, i) => tuple === y[i]);
943
+ }
944
+ /**
945
+ * A member's instance role and its scope, as the MAC covers them: as 0.4
946
+ * did, whether they are an owner, for a Member or an Admin everywhere, so
947
+ * that every row 0.4 sealed holds as it is; otherwise the role and the
948
+ * scope, which no row of 0.4 can match.
949
+ */
950
+ function standing(member) {
951
+ const scope = normalScope(member.scope);
952
+ if (unscoped(scope) && (member.role === "member" || member.role === "admin")) return member.role === "admin";
953
+ return [member.role, scope];
954
+ }
955
+ function memberMac(key, member, grants) {
956
+ const held = grantSet(grants);
957
+ const tuple = [
958
+ "coffre.vault.member.v1",
959
+ member.principal,
960
+ member.status,
961
+ standing(member),
962
+ member.generation,
963
+ member.accessSeq.toString(),
964
+ member.createdAt,
965
+ member.createdBy,
966
+ member.statusChangedAt,
967
+ member.statusChangedBy,
968
+ held
969
+ ];
970
+ return createHmac("sha256", key).update(JSON.stringify(tuple)).digest();
971
+ }
972
+ /** Whether `member`'s MAC is the vault's, over this row and these grants. */
973
+ function sealed(key, member, grants) {
974
+ const expected = memberMac(key, member, grants);
975
+ return member.mac.length === expected.length && timingSafeEqual(member.mac, expected);
976
+ }
977
+ //#endregion
978
+ //#region src/snapshot.postgres.ts
979
+ /** An entry of `audit_log` aliased `e`, as JSON (`RawEntry`): whole numbers past 2^53 as text, bytes as hex. */
980
+ const ENTRY$1 = sql.raw(`json_build_object(
981
+ 'seq', e.seq::text, 'author', e.author, 'keyId', e.key_id, 'occurredAt', e.occurred_at, 'actor', e.actor, 'action', e.action,
982
+ 'decision', e.decision, 'code', e.code, 'subjectPrincipal', e.subject_principal, 'projectId', e.project_id,
983
+ 'environmentId', e.environment_id, 'secretId', e.secret_id, 'secretVersionId', e.secret_version_id,
984
+ 'operationId', e.operation_id, 'requestId', e.request_id, 'sourceIp', e.source_ip, 'relatedSeq', e.related_seq::text,
985
+ 'metadata', e.metadata, 'prevHash', encode(e.prev_hash, 'hex'), 'mac', encode(e.mac, 'hex'), 'hash', encode(e.hash, 'hex'))`);
986
+ const NOW$1 = sql.raw("(extract(epoch from clock_timestamp()) * 1000)::bigint");
987
+ /** `snapshot`'s statement on Postgres: one row, one JSON column. Each part reads one index range. */
988
+ function snapshotQuery$1(asked) {
989
+ const principals = sql`${sql.param([...asked.principals])}::text[]`;
990
+ const environments = sql`${sql.param([...asked.environments ?? []])}::uuid[]`;
991
+ const references = sql`${sql.param((asked.references ?? []).map(String))}::bigint[]`;
992
+ const actions = sql`${sql.param([...ACCESS_ACTIONS])}::text[]`;
993
+ const tomb = `%${TOMBSTONE}%`;
994
+ const released = asked.released;
995
+ const head = asked.head === true ? sql`, head AS (SELECT next_seq, head_hash FROM audit_chain_head WHERE only_row FOR UPDATE SKIP LOCKED)` : sql``;
996
+ return sql`
997
+ WITH policy AS (
998
+ SELECT * FROM audit_log WHERE author = 'vault' AND action = 'policy.change' AND decision = 'allow' ORDER BY seq DESC LIMIT 1
999
+ )${head}
1000
+ SELECT json_build_object(
1001
+ 'at', ${NOW$1},
1002
+ 'head', ${asked.head === true ? sql`(SELECT json_build_object('nextSeq', next_seq::text, 'hash', encode(head_hash, 'hex')) FROM head)` : sql`NULL`},
1003
+ 'last', ${asked.head === true ? sql`(SELECT json_build_object('seq', seq::text, 'hash', encode(hash, 'hex')) FROM audit_log ORDER BY seq DESC LIMIT 1)` : sql`NULL`},
1004
+ 'rotation', ${asked.head === true ? sql`(SELECT ${ENTRY$1} FROM audit_log e WHERE author = 'vault' AND action = 'key.rotate' AND decision = 'allow' ORDER BY seq DESC LIMIT 1)` : sql`NULL`},
1005
+ 'members', ${asked.members === true ? sql`(SELECT coalesce(json_agg(json_build_object(
1006
+ 'principal', principal, 'status', status, 'owner', owner, 'role', role, 'scope', scope, 'generation', generation,
1007
+ 'createdAt', created_at, 'createdBy', created_by, 'statusChangedAt', status_changed_at, 'statusChangedBy', status_changed_by,
1008
+ 'accessSeq', access_seq::text, 'mac', encode(mac, 'hex'))), '[]') FROM vault_members WHERE principal = ANY(${principals}))` : sql`'[]'::json`},
1009
+ 'grants', (
1010
+ SELECT coalesce(json_agg(json_build_object(
1011
+ 'principal', g.principal, 'projectId', coalesce(g.project_id, env.project_id), 'environmentId', g.environment_id,
1012
+ 'environmentSlug', g.environment_slug, 'role', g.role, 'expiresAt', g.expires_at, 'grantedAt', g.granted_at, 'grantedBy', g.granted_by)), '[]')
1013
+ FROM vault_grants g LEFT JOIN environments env ON env.id = g.environment_id
1014
+ WHERE g.principal = ANY(${principals})
1015
+ ),
1016
+ 'access', (
1017
+ SELECT coalesce(json_agg(${ENTRY$1}), '[]')
1018
+ FROM unnest(${principals}) AS asked (principal)
1019
+ CROSS JOIN LATERAL (
1020
+ SELECT * FROM audit_log
1021
+ WHERE author = 'vault' AND subject_principal = asked.principal AND action = ANY(${actions}) AND decision = 'allow'
1022
+ ORDER BY seq DESC LIMIT 1
1023
+ ) e
1024
+ ),
1025
+ 'policy', (SELECT ${ENTRY$1} FROM policy e),
1026
+ 'environments', (
1027
+ SELECT coalesce(json_agg(json_build_object(
1028
+ 'id', env.id, 'projectId', env.project_id, 'slug', env.slug, 'owner', env.owner, 'parentId', env.parent_id,
1029
+ 'deleted', env.slug LIKE ${tomb} OR project.slug LIKE ${tomb},
1030
+ 'archived', env.archived_at IS NOT NULL OR project.archived_at IS NOT NULL)), '[]')
1031
+ FROM environments env JOIN projects project ON project.id = env.project_id
1032
+ -- One list of ids, each found by the primary key.
1033
+ WHERE env.id = ANY(ARRAY(
1034
+ SELECT unnest(${environments})
1035
+ UNION SELECT parent_id FROM environments WHERE id = ANY(${environments}) AND owner IS NOT NULL
1036
+ -- What the newest policy protects, by the ids its JSON names; one that is no UUID names nothing.
1037
+ UNION SELECT (place #>> '{}')::uuid FROM policy, jsonb_path_query(policy.metadata::jsonb, '$.policy.protected[*].environmentId') AS place
1038
+ WHERE (place #>> '{}') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
1039
+ ))
1040
+ ),
1041
+ 'ended', (
1042
+ SELECT coalesce(json_agg(related_seq::text), '[]') FROM audit_log
1043
+ WHERE author = 'vault' AND action = 'reference.end' AND decision = 'allow' AND related_seq = ANY(${references})
1044
+ ),
1045
+ 'released', ${released === void 0 ? sql`0` : sql`(
1046
+ SELECT count(DISTINCT key) FROM (
1047
+ -- A read of one key names it; of several at one place, lists them (readsByPlace, vault.ts). An entry, or an
1048
+ -- item of its list, that names no key, which only a forged entry is, counts as one key of its own.
1049
+ SELECT coalesce(e.secret_id::text, CASE jsonb_typeof(one -> 'secretId') WHEN 'string' THEN one ->> 'secretId' END, 'entry:' || e.seq) FROM audit_log e
1050
+ -- Each item of a list, never what lax paths would make of an object; silent, as strict paths fail on what is missing.
1051
+ LEFT JOIN LATERAL jsonb_path_query(CASE WHEN e.secret_id IS NULL THEN e.metadata::jsonb END, 'strict $.secrets ? (@.type() == "array")[*]', '{}', true) AS one ON true
1052
+ WHERE e.author = 'vault' AND e.actor = ${released.principal} AND e.action = 'secret.read' AND e.decision = 'allow'
1053
+ AND e.occurred_at > ${NOW$1} + ${released.clockOffsetMs} - ${released.windowMs}
1054
+ UNION ALL SELECT unnest(${sql.param([...released.keys])}::text[])
1055
+ ) AS released (key)
1056
+ )`}
1057
+ ) AS snapshot`;
1058
+ }
1059
+ //#endregion
1060
+ //#region src/snapshot.sqlite.ts
1061
+ /**
1062
+ * An entry of `audit_log` aliased `e`, as JSON (`RawEntry`), bytes as hex.
1063
+ * A JSON value loses its type leaving a subquery, so whoever takes one
1064
+ * from a subquery wraps it in `json(…)`.
1065
+ */
1066
+ const ENTRY = sql.raw(`json_object(
1067
+ 'seq', e.seq, 'author', e.author, 'keyId', e.key_id, 'occurredAt', e.occurred_at, 'actor', e.actor, 'action', e.action,
1068
+ 'decision', e.decision, 'code', e.code, 'subjectPrincipal', e.subject_principal, 'projectId', e.project_id,
1069
+ 'environmentId', e.environment_id, 'secretId', e.secret_id, 'secretVersionId', e.secret_version_id,
1070
+ 'operationId', e.operation_id, 'requestId', e.request_id, 'sourceIp', e.source_ip, 'relatedSeq', e.related_seq,
1071
+ 'metadata', e.metadata, 'prevHash', hex(e.prev_hash), 'mac', hex(e.mac), 'hash', hex(e.hash))`);
1072
+ const NOW = sql.raw(`CAST(unixepoch('subsec') * 1000 AS INTEGER)`);
1073
+ /**
1074
+ * `snapshot`'s statement on SQLite, the same as on Postgres. Nothing is
1075
+ * locked: one transaction runs at a time (connect.ts), so the head is the
1076
+ * last entry's.
1077
+ */
1078
+ function snapshotQuery(asked) {
1079
+ const principals = JSON.stringify(asked.principals);
1080
+ const environments = JSON.stringify(asked.environments ?? []);
1081
+ const references = JSON.stringify((asked.references ?? []).map(Number));
1082
+ const actions = JSON.stringify(ACCESS_ACTIONS);
1083
+ const tomb = `%${TOMBSTONE}%`;
1084
+ const released = asked.released;
1085
+ const head = asked.head === true;
1086
+ return sql`
1087
+ WITH policy AS (
1088
+ SELECT * FROM audit_log WHERE author = 'vault' AND action = 'policy.change' AND decision = 'allow' ORDER BY seq DESC LIMIT 1
1089
+ )
1090
+ SELECT json_object(
1091
+ 'at', ${NOW},
1092
+ 'head', ${head ? sql`json((SELECT json_object('nextSeq', next_seq, 'hash', hex(head_hash)) FROM audit_chain_head WHERE only_row))` : sql`NULL`},
1093
+ 'last', ${head ? sql`json((SELECT json_object('seq', seq, 'hash', hex(hash)) FROM audit_log ORDER BY seq DESC LIMIT 1))` : sql`NULL`},
1094
+ 'rotation', ${head ? sql`json((SELECT ${ENTRY} FROM audit_log e WHERE author = 'vault' AND action = 'key.rotate' AND decision = 'allow' ORDER BY seq DESC LIMIT 1))` : sql`NULL`},
1095
+ 'members', json(${asked.members === true ? sql`(SELECT json_group_array(json_object(
1096
+ 'principal', principal, 'status', status, 'owner', owner, 'role', role, 'scope', scope, 'generation', generation,
1097
+ 'createdAt', created_at, 'createdBy', created_by, 'statusChangedAt', status_changed_at, 'statusChangedBy', status_changed_by,
1098
+ 'accessSeq', access_seq, 'mac', hex(mac))) FROM vault_members WHERE principal IN (SELECT value FROM json_each(${principals})))` : sql`'[]'`}),
1099
+ 'grants', json((
1100
+ SELECT json_group_array(json_object(
1101
+ 'principal', g.principal, 'projectId', coalesce(g.project_id, env.project_id), 'environmentId', g.environment_id,
1102
+ 'environmentSlug', g.environment_slug, 'role', g.role, 'expiresAt', g.expires_at, 'grantedAt', g.granted_at, 'grantedBy', g.granted_by))
1103
+ FROM vault_grants g LEFT JOIN environments env ON env.id = g.environment_id
1104
+ WHERE g.principal IN (SELECT value FROM json_each(${principals}))
1105
+ )),
1106
+ 'access', json((
1107
+ SELECT json_group_array(json(newest)) FROM (
1108
+ SELECT (
1109
+ SELECT ${ENTRY} FROM audit_log e
1110
+ WHERE author = 'vault' AND subject_principal = asked.value AND action IN (SELECT value FROM json_each(${actions})) AND decision = 'allow'
1111
+ ORDER BY seq DESC LIMIT 1
1112
+ ) AS newest
1113
+ FROM json_each(${principals}) AS asked
1114
+ ) WHERE newest IS NOT NULL
1115
+ )),
1116
+ 'policy', json((SELECT ${ENTRY} FROM policy e)),
1117
+ 'environments', json((
1118
+ SELECT json_group_array(json_object(
1119
+ 'id', env.id, 'projectId', env.project_id, 'slug', env.slug, 'owner', env.owner, 'parentId', env.parent_id,
1120
+ 'deleted', env.slug LIKE ${tomb} OR project.slug LIKE ${tomb},
1121
+ 'archived', env.archived_at IS NOT NULL OR project.archived_at IS NOT NULL))
1122
+ FROM environments env JOIN projects project ON project.id = env.project_id
1123
+ WHERE env.id IN (SELECT value FROM json_each(${environments}))
1124
+ OR env.id IN (SELECT parent_id FROM environments WHERE id IN (SELECT value FROM json_each(${environments})) AND owner IS NOT NULL)
1125
+ OR env.id IN (
1126
+ SELECT json_extract(place.value, '$.environmentId')
1127
+ FROM policy, json_each(policy.metadata, '$.policy.protected') AS place
1128
+ -- Of a forged entry's, an element that is no object names nothing: json_extract would fail on a string.
1129
+ WHERE json_valid(policy.metadata) AND json_type(policy.metadata, '$.policy.protected') = 'array' AND place.type = 'object'
1130
+ )
1131
+ )),
1132
+ 'ended', json((
1133
+ SELECT json_group_array(related_seq) FROM audit_log
1134
+ WHERE author = 'vault' AND action = 'reference.end' AND decision = 'allow' AND related_seq IN (SELECT value FROM json_each(${references}))
1135
+ )),
1136
+ 'released', ${released === void 0 ? sql`0` : sql`(
1137
+ SELECT count(DISTINCT key) FROM (
1138
+ -- A read of one key names it; of several at one place, lists them (readsByPlace, vault.ts). An entry, or an
1139
+ -- item of its list, that names no key, which only a forged entry is, counts as one key of its own.
1140
+ SELECT coalesce(e.secret_id, CASE WHEN one.type = 'object' AND json_type(one.value, '$.secretId') = 'text' THEN json_extract(one.value, '$.secretId') END, 'entry:' || e.seq) AS key
1141
+ FROM audit_log e
1142
+ LEFT JOIN json_each(CASE WHEN e.secret_id IS NULL AND json_type(e.metadata, '$.secrets') = 'array' THEN e.metadata END, '$.secrets') AS one
1143
+ WHERE e.author = 'vault' AND e.actor = ${released.principal} AND e.action = 'secret.read' AND e.decision = 'allow'
1144
+ AND e.occurred_at > ${NOW} + ${released.clockOffsetMs} - ${released.windowMs}
1145
+ UNION ALL SELECT value FROM json_each(${JSON.stringify(released.keys)})
1146
+ )
1147
+ )`}
1148
+ ) AS snapshot`;
1149
+ }
1150
+ //#endregion
1151
+ //#region src/snapshot.ts
1152
+ async function snapshot(db, asked) {
1153
+ const read = () => readOnce(db, asked);
1154
+ const first = await read();
1155
+ if (asked.head !== true || first.head !== null && current(first.head)) return first;
1156
+ if (first.head === null) await lockLogHead(db);
1157
+ const again = await read();
1158
+ if (again.head === null) throw new Error("the log's head, held by this transaction, was read as held by another");
1159
+ if (!current(again.head)) throw new LogHeadMismatch(`the log's head says entry ${again.head.nextSeq} comes next, but the log ends ${again.head.last === null ? "empty" : `at entry ${again.head.last.seq}`}, or with another hash`);
1160
+ return again;
1161
+ }
1162
+ /** Whether the head names the last entry the same statement saw: nothing was appended between its snapshot and its lock. */
1163
+ function current(head) {
1164
+ if (head.last === null) return head.nextSeq === 0n;
1165
+ return head.nextSeq === head.last.seq + 1n && head.headHash.equals(head.last.hash);
1166
+ }
1167
+ async function readOnce(db, asked) {
1168
+ const query = (engineOf(db) === "postgres" ? snapshotQuery$1 : snapshotQuery)(asked);
1169
+ const [row] = await rowsOf(db, query);
1170
+ const raw = typeof row.snapshot === "string" ? JSON.parse(row.snapshot) : row.snapshot;
1171
+ return {
1172
+ at: Number(raw.at),
1173
+ members: new Map(raw.members.map((member) => [member.principal, memberOf(member)])),
1174
+ grants: new Map(asked.principals.map((principal) => [principal, raw.grants.filter((grant) => grant.principal === principal).map(grantOf)])),
1175
+ newestAccess: new Map(raw.access.map((entry) => [entry.subjectPrincipal, entryOf(entry)])),
1176
+ newestPolicy: raw.policy === null ? null : entryOf(raw.policy),
1177
+ environments: new Map(raw.environments.map(({ id, ...environment }) => [id, {
1178
+ ...environment,
1179
+ deleted: Boolean(environment.deleted),
1180
+ archived: Boolean(environment.archived)
1181
+ }])),
1182
+ ended: new Set(raw.ended.map((seq) => BigInt(seq))),
1183
+ released: Number(raw.released ?? 0),
1184
+ head: raw.head === null ? null : {
1185
+ nextSeq: BigInt(raw.head.nextSeq),
1186
+ headHash: Buffer.from(raw.head.hash, "hex"),
1187
+ last: raw.last === null ? null : {
1188
+ seq: BigInt(raw.last.seq),
1189
+ hash: Buffer.from(raw.last.hash, "hex")
1190
+ },
1191
+ rotation: raw.rotation === null ? null : entryOf(raw.rotation)
1192
+ }
1193
+ };
1194
+ }
1195
+ /** An entry as the chain's codec reads it, from what `entryJson` wrote. */
1196
+ function entryOf(raw) {
1197
+ return {
1198
+ ...raw,
1199
+ seq: BigInt(raw.seq),
1200
+ author: raw.author,
1201
+ occurredAt: Number(raw.occurredAt),
1202
+ relatedSeq: raw.relatedSeq === null ? null : BigInt(raw.relatedSeq),
1203
+ prevHash: Buffer.from(raw.prevHash, "hex"),
1204
+ mac: Buffer.from(raw.mac, "hex"),
1205
+ hash: Buffer.from(raw.hash, "hex")
1206
+ };
1207
+ }
1208
+ function grantOf(raw) {
1209
+ return {
1210
+ ...raw,
1211
+ expiresAt: raw.expiresAt === null ? null : Number(raw.expiresAt),
1212
+ grantedAt: Number(raw.grantedAt)
1213
+ };
1214
+ }
1215
+ function memberOf({ owner, ...raw }) {
1216
+ return memberOf$1({
1217
+ ...raw,
1218
+ owner: owner === true || owner === 1,
1219
+ generation: Number(raw.generation),
1220
+ createdAt: Number(raw.createdAt),
1221
+ statusChangedAt: Number(raw.statusChangedAt),
1222
+ accessSeq: BigInt(raw.accessSeq),
1223
+ mac: Buffer.from(raw.mac, "hex")
1224
+ });
1225
+ }
1226
+ //#endregion
1227
+ //#region src/accounting.ts
1228
+ const KEY_ACTIONS = /* @__PURE__ */ new Set([
1229
+ "secret.read",
1230
+ "key.wrap",
1231
+ "key.rewrap"
1232
+ ]);
1233
+ /**
1234
+ * Every key operation's intent and its outcomes, entry by entry, oldest
1235
+ * first, as a walk reads the vault's entries (`add`); then what is still
1236
+ * open (`result`). The chain can hold while a process died between an
1237
+ * intent and its outcomes: only a missed deadline is a fault.
1238
+ *
1239
+ * A walk resumes from a checkpoint's `state()`: the batches open there, and
1240
+ * nothing of those that closed. So an intent's identity is checked against
1241
+ * those still open and those since, not every one the log ever held: a
1242
+ * closed batch takes no more outcomes in any case, since each names its
1243
+ * intent's entry.
1244
+ */
1245
+ var KeyAccounting = class {
1246
+ #intents = /* @__PURE__ */ new Map();
1247
+ #ids = /* @__PURE__ */ new Set();
1248
+ #fault = null;
1249
+ constructor(from = {
1250
+ intents: [],
1251
+ fault: null
1252
+ }) {
1253
+ for (const { seq, seen, ...open } of from.intents) {
1254
+ this.#intents.set(BigInt(seq), {
1255
+ ...open,
1256
+ seq: BigInt(seq),
1257
+ seen: new Set(seen)
1258
+ });
1259
+ this.#ids.add(open.intentId);
1260
+ }
1261
+ this.#fault = from.fault === null ? null : {
1262
+ ok: false,
1263
+ ...from.fault
1264
+ };
1265
+ }
1266
+ /** Where it stands, to resume from: the batches open, oldest first. */
1267
+ state() {
1268
+ return {
1269
+ intents: [...this.#intents.values()].sort((a, b) => a.seq < b.seq ? -1 : 1).map(({ seq, seen, ...open }) => ({
1270
+ ...open,
1271
+ seq: Number(seq),
1272
+ seen: [...seen].sort((a, b) => a - b)
1273
+ })),
1274
+ fault: this.#fault === null ? null : {
1275
+ failedAtSeq: this.#fault.failedAtSeq,
1276
+ reason: this.#fault.reason
1277
+ }
1278
+ };
1279
+ }
1280
+ add(entry) {
1281
+ if (this.#fault !== null || entry.author !== "vault") return;
1282
+ const broken = (reason) => {
1283
+ this.#fault = {
1284
+ ok: false,
1285
+ failedAtSeq: Number(entry.seq),
1286
+ reason
1287
+ };
1288
+ };
1289
+ if (entry.action === "key.intent") {
1290
+ const parsed = parseIntent(entry);
1291
+ if (typeof parsed === "string") return broken(parsed);
1292
+ if (this.#ids.has(parsed.intentId)) return broken("key intent repeats an operation identity");
1293
+ this.#ids.add(parsed.intentId);
1294
+ if (parsed.keys.length > 0) this.#intents.set(entry.seq, {
1295
+ ...parsed,
1296
+ seq: entry.seq,
1297
+ actor: entry.actor,
1298
+ seen: /* @__PURE__ */ new Set()
1299
+ });
1300
+ } else if (KEY_ACTIONS.has(entry.action) && entry.relatedSeq !== null) {
1301
+ const parsed = parseOutcome(entry);
1302
+ if (typeof parsed === "string") return broken(parsed);
1303
+ const intent = this.#intents.get(parsed.relatedSeq);
1304
+ for (const outcome of parsed.items) {
1305
+ const key = intent?.keys[outcome.item];
1306
+ if (intent === void 0 || key === void 0 || intent.seen.has(outcome.item)) return broken("key outcome does not identify one item of its intent");
1307
+ if (parsed.intentId !== intent.intentId || entry.actor !== intent.actor || entry.action !== intent.operation) return broken("key outcome belongs to another operation");
1308
+ if (outcome.secretId !== key.secretId || outcome.version !== key.version || outcome.subject !== key.subject) return broken("key outcome does not match its intended secret");
1309
+ intent.seen.add(outcome.item);
1310
+ }
1311
+ if (intent !== void 0 && intent.seen.size === intent.keys.length) this.#intents.delete(intent.seq);
1312
+ }
1313
+ }
1314
+ /** The first fault, or the batches still under way at `at`: live calls can finish after the snapshot. */
1315
+ result(at) {
1316
+ if (this.#fault !== null) return this.#fault;
1317
+ const intents = [...this.#intents.values()];
1318
+ const overdue = intents.filter((intent) => intent.expiresAt <= at).sort((a, b) => a.expiresAt - b.expiresAt);
1319
+ if (overdue.length === 0) return {
1320
+ ok: true,
1321
+ pending: intents.length
1322
+ };
1323
+ const reason = overdue.map((intent) => {
1324
+ const missing = intent.keys.length - intent.seen.size;
1325
+ return `key intent ${intent.intentId} is overdue: ${missing} of ${intent.keys.length} outcomes missing`;
1326
+ }).join("; ");
1327
+ return {
1328
+ ok: false,
1329
+ failedAtSeq: Number(overdue[0].seq),
1330
+ reason
1331
+ };
1332
+ }
1333
+ };
1334
+ function parseIntent(entry) {
1335
+ const detail = payload(entry.metadata);
1336
+ if (detail === null) return "key intent has no valid accounting payload";
1337
+ const { intent, operation, expiresAt } = detail;
1338
+ if (typeof intent !== "string") return "key intent has no operation identity";
1339
+ if (typeof operation !== "string" || !KEY_ACTIONS.has(operation)) return "key intent has an invalid operation";
1340
+ if (typeof expiresAt !== "number" || !Number.isSafeInteger(expiresAt) || expiresAt < entry.occurredAt) return "key intent has no valid deadline";
1341
+ if (!Array.isArray(detail.keys)) return "key intent has no valid item list";
1342
+ const keys = [];
1343
+ for (const [index, value] of detail.keys.entries()) {
1344
+ const key = record(value);
1345
+ if (key === null || key.item !== index) return "key intent items are not numbered in order";
1346
+ const { secretId, version, subject } = key;
1347
+ if (typeof secretId !== "string" || typeof subject !== "string") return "key intent item has no secret identity";
1348
+ if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key intent item has no valid version";
1349
+ keys.push({
1350
+ item: index,
1351
+ secretId,
1352
+ version,
1353
+ subject
1354
+ });
1355
+ }
1356
+ return {
1357
+ intentId: intent,
1358
+ operation,
1359
+ expiresAt,
1360
+ keys
1361
+ };
1362
+ }
1363
+ function parseOutcome(entry) {
1364
+ const detail = payload(entry.metadata);
1365
+ if (detail === null) return "key outcome has no valid accounting payload";
1366
+ const { intent } = detail;
1367
+ if (typeof intent !== "string" || entry.relatedSeq === null) return "key outcome has no intent identity";
1368
+ const many = detail.secrets;
1369
+ if (many !== void 0 && (!Array.isArray(many) || many.length === 0)) return "key outcome has no valid item list";
1370
+ const listed = many === void 0 ? [{
1371
+ ...detail,
1372
+ secretId: entry.secretId ?? detail.secretId
1373
+ }] : many.map(record);
1374
+ const items = [];
1375
+ for (const one of listed) {
1376
+ if (one === null) return "key outcome has no valid item list";
1377
+ const { item, secretId, version, subject } = one;
1378
+ if (typeof item !== "number" || !Number.isSafeInteger(item) || item < 0) return "key outcome has no valid item number";
1379
+ if (typeof secretId !== "string" || typeof subject !== "string") return "key outcome has no secret identity";
1380
+ if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key outcome has no valid version";
1381
+ items.push({
1382
+ item,
1383
+ secretId,
1384
+ version,
1385
+ subject
1386
+ });
1387
+ }
1388
+ return {
1389
+ intentId: intent,
1390
+ relatedSeq: entry.relatedSeq,
1391
+ items
1392
+ };
1393
+ }
1394
+ function payload(text) {
1395
+ try {
1396
+ return record(JSON.parse(text));
1397
+ } catch {
1398
+ return null;
1399
+ }
1400
+ }
1401
+ function record(value) {
1402
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
1403
+ }
1404
+ //#endregion
1405
+ //#region src/tally.ts
1406
+ /** Before the first entry. */
1407
+ const EMPTY_TALLY = {
1408
+ entries: 0,
1409
+ keys: [],
1410
+ accounting: {
1411
+ intents: [],
1412
+ fault: null
1413
+ },
1414
+ access: {
1415
+ members: [],
1416
+ grants: [],
1417
+ newest: []
1418
+ },
1419
+ replayFault: null
1420
+ };
1421
+ /** The digest of what `access` says, over its canonical form. */
1422
+ function accessDigest(access) {
1423
+ return createHash("sha256").update(canonical(access)).digest("hex");
986
1424
  }
987
1425
  /**
988
- * A member's instance role and its scope, as the MAC covers them: as 0.4
989
- * did, whether they are an owner, for a Member or an Admin everywhere, so
990
- * that every row 0.4 sealed holds as it is; otherwise the role and the
991
- * scope, which no row of 0.4 can match.
1426
+ * What two tallies are compared by, so that they match whatever order
1427
+ * their maps were built in: the members and grants by their digest, which
1428
+ * a stretch passing many checkpoints computes once a change; and a fault by
1429
+ * where it was found, since the words for it may change from one release
1430
+ * to the next.
992
1431
  */
993
- function standing(member) {
994
- const scope = normalScope(member.scope);
995
- if (unscoped(scope) && (member.role === "member" || member.role === "admin")) return member.role === "admin";
996
- return [member.role, scope];
1432
+ function tallyKey({ accounting, entries, keys, replayFault }, digest) {
1433
+ return canonical({
1434
+ entries,
1435
+ keys,
1436
+ replayFault,
1437
+ accounting: {
1438
+ ...accounting,
1439
+ fault: accounting.fault?.failedAtSeq ?? null
1440
+ },
1441
+ access: digest
1442
+ });
997
1443
  }
998
- function memberMac(key, member, grants) {
999
- const held = grantSet(grants);
1000
- const tuple = [
1001
- "coffre.vault.member.v1",
1002
- member.principal,
1003
- member.status,
1004
- standing(member),
1005
- member.generation,
1006
- member.accessSeq.toString(),
1007
- member.createdAt,
1008
- member.createdBy,
1009
- member.statusChangedAt,
1010
- member.statusChangedBy,
1011
- held
1012
- ];
1013
- return createHmac("sha256", key).update(JSON.stringify(tuple)).digest();
1444
+ /**
1445
+ * What a walk must come to at a checkpoint, from the tally it keeps: its
1446
+ * members and grants by the digest it keeps of them, never read.
1447
+ */
1448
+ function expectedKey(stored) {
1449
+ return tallyKey(stored, stored.access.digest);
1014
1450
  }
1015
- /** Whether `member`'s MAC is the vault's, over this row and these grants. */
1016
- function sealed(key, member, grants) {
1017
- const expected = memberMac(key, member, grants);
1018
- return member.mac.length === expected.length && timingSafeEqual(member.mac, expected);
1451
+ /**
1452
+ * The vault's entries walked, oldest first, each after its MAC was checked,
1453
+ * gathered into what the checks after the walk need: the tally it started
1454
+ * from, carried on.
1455
+ */
1456
+ var Walk = class {
1457
+ accounting;
1458
+ replayed;
1459
+ /** Each member's newest allowed access entry, by seq. */
1460
+ newestAccess;
1461
+ /** The first access entry the replay could not apply. */
1462
+ replayFault;
1463
+ /** The vault's entries so far, those before the walk included. */
1464
+ entries;
1465
+ /** The vault's log keys, as the log moved through them. */
1466
+ keys;
1467
+ /** The checkpoints walked, each to be checked against its signature and the prefix it signed once the walk ends: what it signed, and nothing it keeps beside. */
1468
+ checkpoints = [];
1469
+ /** The tallies the walk must come to as it passes them, oldest first (`expect`). */
1470
+ #expected = [];
1471
+ /** The first it did not come to, and why. */
1472
+ mismatch = null;
1473
+ /** The digest of the members and grants as the walk has them, until an access entry changes them. */
1474
+ #digest = null;
1475
+ /** The digest of the members and grants as the walk has them: the tally's it started from, until an access entry. */
1476
+ digest() {
1477
+ return this.#digest ??= accessDigest(this.tally().access);
1478
+ }
1479
+ /** The tally but for its members and grants, which `digest` stands for. */
1480
+ #unaccessed() {
1481
+ return {
1482
+ entries: this.entries,
1483
+ keys: [...this.keys],
1484
+ accounting: this.accounting.state(),
1485
+ replayFault: this.replayFault
1486
+ };
1487
+ }
1488
+ /** `digest` is that of `from`'s members and grants, when the caller has it: a beat with no access entry hashes none. */
1489
+ constructor(from, digest) {
1490
+ this.#digest = digest ?? null;
1491
+ this.accounting = new KeyAccounting(from.accounting);
1492
+ this.replayed = {
1493
+ members: new Map(from.access.members.map((member) => [member.principal, {
1494
+ ...member,
1495
+ accessSeq: BigInt(member.accessSeq)
1496
+ }])),
1497
+ held: /* @__PURE__ */ new Map()
1498
+ };
1499
+ for (const grant of from.access.grants) {
1500
+ const held = this.replayed.held.get(grant.principal) ?? /* @__PURE__ */ new Map();
1501
+ this.replayed.held.set(grant.principal, held);
1502
+ held.set(placeKey$1(grant), { ...grant });
1503
+ }
1504
+ this.newestAccess = new Map(from.access.newest.map(([principal, seq]) => [principal, BigInt(seq)]));
1505
+ this.replayFault = from.replayFault;
1506
+ this.entries = from.entries;
1507
+ this.keys = from.keys;
1508
+ }
1509
+ /**
1510
+ * Hold the walk to these tallies, each as the walk stands once it has
1511
+ * read every entry through the prefix's end: before the vault's next
1512
+ * entry, since only the vault's change a tally, or at `finish`.
1513
+ */
1514
+ expect(expected) {
1515
+ this.#expected = [...expected].sort((a, b) => a.through < b.through ? -1 : 1);
1516
+ }
1517
+ /** The tallies expected through `through`, held to the walk. */
1518
+ finish(through) {
1519
+ this.#reach(through + 1n);
1520
+ }
1521
+ #reach(before) {
1522
+ while (this.#expected.length > 0 && this.#expected[0].through < before) {
1523
+ const expected = this.#expected.shift();
1524
+ if (this.mismatch !== null) continue;
1525
+ if ("unreadable" in expected) this.mismatch = {
1526
+ entry: expected.entry,
1527
+ reason: expected.unreadable
1528
+ };
1529
+ else if (tallyKey(this.#unaccessed(), this.digest()) !== expected.key) this.mismatch = {
1530
+ entry: expected.entry,
1531
+ reason: "the tally this checkpoint keeps does not follow from the log before it"
1532
+ };
1533
+ }
1534
+ }
1535
+ /**
1536
+ * One of the vault's entries, its MAC checked, in the log's order: a
1537
+ * fault when it is under a key the vault had moved past (`forward`).
1538
+ */
1539
+ add(entry, current) {
1540
+ this.#reach(entry.seq);
1541
+ const moved = forward([entry], this.keys, current);
1542
+ if ("failedAtSeq" in moved) return moved;
1543
+ this.keys = moved.keys;
1544
+ this.entries += 1;
1545
+ this.accounting.add(entry);
1546
+ if (entry.decision !== "allow") return null;
1547
+ if (ACCESS.has(entry.action)) {
1548
+ this.#digest = null;
1549
+ this.replayFault ??= apply(this.replayed, entry);
1550
+ if (entry.subjectPrincipal !== null) this.newestAccess.set(entry.subjectPrincipal, entry.seq);
1551
+ } else if (entry.action === "audit.checkpoint") {
1552
+ const { seq, hash, signedAt, keyId, signature } = JSON.parse(entry.metadata);
1553
+ this.checkpoints.push({
1554
+ entry: {
1555
+ seq: entry.seq,
1556
+ keyId: entry.keyId,
1557
+ relatedSeq: entry.relatedSeq
1558
+ },
1559
+ checkpoint: {
1560
+ seq,
1561
+ hash,
1562
+ signedAt,
1563
+ keyId,
1564
+ signature
1565
+ }
1566
+ });
1567
+ }
1568
+ return null;
1569
+ }
1570
+ /** Where the walk stands, to resume from. */
1571
+ tally() {
1572
+ const byPrincipal = (entries) => [...entries].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
1573
+ return {
1574
+ entries: this.entries,
1575
+ keys: [...this.keys],
1576
+ accounting: this.accounting.state(),
1577
+ access: {
1578
+ members: byPrincipal(this.replayed.members).map(([, member]) => ({
1579
+ ...member,
1580
+ accessSeq: Number(member.accessSeq)
1581
+ })),
1582
+ grants: byPrincipal(this.replayed.held).flatMap(([, held]) => byPrincipal(held).map(([, grant]) => grant)),
1583
+ newest: byPrincipal(this.newestAccess).map(([principal, seq]) => [principal, Number(seq)])
1584
+ },
1585
+ replayFault: this.replayFault
1586
+ };
1587
+ }
1588
+ };
1589
+ /** The action of the vault's entry that signs a prefix of the log. */
1590
+ const CHECKPOINT = "audit.checkpoint";
1591
+ const ACCESS = new Set(ACCESS_ACTIONS$1);
1592
+ /** JSON with every object's keys sorted: one text for one value, however it was built. */
1593
+ function canonical(value) {
1594
+ return JSON.stringify(value, (_, inner) => inner !== null && typeof inner === "object" && !Array.isArray(inner) ? Object.fromEntries(Object.entries(inner).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : inner);
1019
1595
  }
1020
1596
  //#endregion
1021
1597
  //#region src/vault.ts
@@ -1025,9 +1601,13 @@ async function prepareVault(config, options = {}) {
1025
1601
  ...await keysOf(config.signingKeys),
1026
1602
  options: {
1027
1603
  keyBudgetMs: options.keyBudgetMs ?? KEY_BUDGET_MS,
1028
- clockOffset: options.clockOffset ?? (() => 0)
1604
+ clockOffset: options.clockOffset ?? (() => 0),
1605
+ walks: {
1606
+ ...WALKS,
1607
+ ...options.walks
1608
+ }
1029
1609
  },
1030
- verified: UNVERIFIED,
1610
+ verified: START,
1031
1611
  reported: /* @__PURE__ */ new Set(),
1032
1612
  since: null,
1033
1613
  superseded: null,
@@ -1076,8 +1656,6 @@ const UNSEALED = {
1076
1656
  };
1077
1657
  /** Why a change to a tampered member is refused. */
1078
1658
  const TAMPERED_SUBJECT = "this member's record failed the vault's integrity check: remove them to start over";
1079
- /** The action of the vault's entry that signs a prefix of the log. */
1080
- const CHECKPOINT = "audit.checkpoint";
1081
1659
  /** The action of the vault's entry that holds the instance's settings, and changes them (`settings`). */
1082
1660
  const SETTINGS = "settings.change";
1083
1661
  /** The settings before anyone changed them: people set up service accounts anywhere they hold access. */
@@ -1122,12 +1700,6 @@ function declaredOwner(principal) {
1122
1700
  const NOT_THEIRS = "a person gives a service account at most what they hold, where the instance lets them set one up, and only to one whose every grant they hold";
1123
1701
  /** Who asks for checkpoints: the app's scheduled job. */
1124
1702
  const SCHEDULER = "system:coffre-scheduler";
1125
- /**
1126
- * How often a checkpoint recomputes the chain from its first entry: the
1127
- * first of each hour, by the vault's clock. The others resume from the
1128
- * prefix the last one signed (`#recompute`).
1129
- */
1130
- const FULL_RECOMPUTE_MS = 36e5;
1131
1703
  /** A refusal and the entries that record it. */
1132
1704
  var Refused = class {
1133
1705
  refusal;
@@ -1229,6 +1801,24 @@ var VaultService = class {
1229
1801
  async #now(db) {
1230
1802
  return await now(db) + this.#prepared.options.clockOffset();
1231
1803
  }
1804
+ /** A snapshot's clock, as the vault keeps time (`#now`). */
1805
+ #at(reads) {
1806
+ return reads.at + this.#prepared.options.clockOffset();
1807
+ }
1808
+ /** What a decision about `principals` reads, and `asked` besides, in one statement: under the head when `head` is set. */
1809
+ #snapshot(tx, principals, asked, head) {
1810
+ const { released, ...rest } = asked;
1811
+ return snapshot(tx, {
1812
+ ...rest,
1813
+ principals,
1814
+ head,
1815
+ ...released === void 0 ? {} : { released: {
1816
+ ...released,
1817
+ windowMs: this.#config.bulkLimit.windowMs,
1818
+ clockOffsetMs: this.#prepared.options.clockOffset()
1819
+ } }
1820
+ });
1821
+ }
1232
1822
  /**
1233
1823
  * Decide in one transaction, with `principals`' rows locked first, then
1234
1824
  * the log's head. A `Refused` or an `Outage` rolls back all but its own
@@ -1241,34 +1831,35 @@ var VaultService = class {
1241
1831
  * alone takes the head later (`late`), once its key service has answered,
1242
1832
  * which it must not wait on holding it, and decides again then (`#keys`).
1243
1833
  */
1244
- async #decide(principals, decide, late = false) {
1834
+ async #decide(principals, decide, late = false, asked = {}) {
1245
1835
  const superseded = await this.#settled();
1246
1836
  if (superseded !== null) return {
1247
1837
  ok: false,
1248
1838
  refusal: refusal("wrong_kek", superseded)
1249
1839
  };
1250
- return this.#decideNow(principals, decide, late);
1840
+ return this.#decideNow(principals, decide, late, asked);
1251
1841
  }
1252
1842
  /** `#decide`, once the vault is settled. */
1253
- async #decideNow(principals, decide, late = false) {
1843
+ async #decideNow(principals, decide, late = false, asked = {}) {
1254
1844
  const reports = [];
1255
1845
  for (let attempt = 1;; attempt += 1) try {
1256
- return await this.#decideOnce(principals, decide, reports, late);
1846
+ return await this.#decideOnce(principals, decide, reports, late, asked);
1257
1847
  } catch (error) {
1258
1848
  if (attempt < 3 && (error instanceof Retry || isUniqueViolation(error))) continue;
1259
1849
  throw error;
1260
1850
  }
1261
1851
  }
1262
- async #decideOnce(principals, decide, reports, late) {
1852
+ async #decideOnce(principals, decide, reports, late, asked) {
1263
1853
  try {
1264
1854
  const result = await this.#db.transaction(async (tx) => {
1265
1855
  await boundLockWaits(tx, LOCK_TIMEOUT_MS);
1266
1856
  const members = principals.length === 0 ? /* @__PURE__ */ new Map() : await lockMembers(tx, principals);
1267
- if (!late) await lockLogHead(tx);
1857
+ const reads = await this.#snapshot(tx, principals, asked, !late);
1268
1858
  const d = {
1269
1859
  tx,
1270
1860
  members,
1271
- at: await this.#now(tx),
1861
+ at: this.#at(reads),
1862
+ reads,
1272
1863
  log: [],
1273
1864
  writes: [],
1274
1865
  touched: /* @__PURE__ */ new Set(),
@@ -1282,7 +1873,8 @@ var VaultService = class {
1282
1873
  let at = d.at;
1283
1874
  const accessSeq = /* @__PURE__ */ new Map();
1284
1875
  if (entries.length > 0) {
1285
- const appended = await this.#append(tx, entries, d.underLock);
1876
+ if (d.reads.head === null) throw new Error("a decision appends only under the head it read with");
1877
+ const appended = await this.#append(tx, entries, d.underLock, d.reads);
1286
1878
  at = appended.occurredAt;
1287
1879
  entries.forEach((entry, i) => {
1288
1880
  if (isAccessEntry(entry)) accessSeq.set(entry.subjectPrincipal, appended.seqStart + BigInt(i));
@@ -1347,12 +1939,12 @@ var VaultService = class {
1347
1939
  * MAC are passed over, and reported: otherwise whoever can insert a row
1348
1940
  * could lock any member out.
1349
1941
  */
1350
- async #integrity(db, principal, row, grants, reports) {
1942
+ async #integrity(db, principal, row, grants, reports, reads) {
1351
1943
  if (row !== void 0 && !this.#sealed(row, grants)) {
1352
1944
  this.#report(reports, principal, "mac", row.mac.toString("hex"));
1353
1945
  return "mac";
1354
1946
  }
1355
- const newest = await this.#newestAccess(db, principal, reports);
1947
+ const newest = await this.#newestAccess(db, principal, reports, reads);
1356
1948
  const seq = typeof newest !== "string" ? newest.seq : newest === "unreached" ? void 0 : null;
1357
1949
  if (row === void 0 ? seq === null : seq === row.accessSeq) return null;
1358
1950
  if (row === void 0 && await member(db, principal) !== void 0) throw new Retry();
@@ -1360,8 +1952,10 @@ var VaultService = class {
1360
1952
  return "stale";
1361
1953
  }
1362
1954
  /** The newest access entry about `principal` that carries the vault's MAC (`newestAccess` in @coffre/db/grants). */
1363
- #newestAccess(db, principal, reports) {
1364
- return newestAccess(db, principal, this.#check(reports, principal));
1955
+ #newestAccess(db, principal, reports, reads) {
1956
+ if (reads === void 0 || !reads.grants.has(principal)) return newestAccess(db, principal, this.#check(reports, principal));
1957
+ const newest = reads.newestAccess.get(principal);
1958
+ return newestAuthentic((limit, before) => before === void 0 ? Promise.resolve(newest === void 0 ? [] : [newest]) : accessEntriesAbout(db, principal, limit, before), this.#check(reports, principal));
1365
1959
  }
1366
1960
  /** Entries in the vault's name told apart by their MACs, each it did not write reported, about `principal`. */
1367
1961
  #check(reports, principal) {
@@ -1372,14 +1966,17 @@ var VaultService = class {
1372
1966
  }
1373
1967
  /** Whether `entry` carries the vault's MAC: under any of its keys before `since`, and only its current one from there. */
1374
1968
  #authentic(entry) {
1375
- const { logKeys, since } = this.#prepared;
1376
- const keys = since !== null && entry.seq >= since ? logKeys.slice(0, 1) : logKeys;
1377
1969
  return verifyEntries([entry], {
1378
1970
  startSeq: entry.seq,
1379
1971
  startPrevHash: entry.prevHash,
1380
- keys
1972
+ keys: this.#keysFor(entry)
1381
1973
  }).ok;
1382
1974
  }
1975
+ /** The vault's log keys an entry may be under: any of them before `since`, and only its current one from there. */
1976
+ #keysFor(entry) {
1977
+ const { logKeys, since } = this.#prepared;
1978
+ return since !== null && entry.seq >= since ? logKeys.slice(0, 1) : logKeys;
1979
+ }
1383
1980
  /**
1384
1981
  * Whether `row`, with these grants, carries the MAC of the vault's row
1385
1982
  * key, or of one it replaced before the rotation sealed every row again
@@ -1456,7 +2053,7 @@ var VaultService = class {
1456
2053
  }
1457
2054
  async #replaceEveryProject(d, principal) {
1458
2055
  const row = d.members.get(principal);
1459
- const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
2056
+ const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
1460
2057
  if (row === void 0 || subject.status !== "active") return;
1461
2058
  const everyProject = subject.stored.filter((grant) => grant.projectId === null && grant.environmentId === null);
1462
2059
  if (everyProject.length === 0) return;
@@ -1608,16 +2205,31 @@ var VaultService = class {
1608
2205
  * that an instance still running with the replaced KEK, as during a
1609
2206
  * deploy, cannot write after the rotation.
1610
2207
  */
1611
- #append(tx, entries, underLock = []) {
2208
+ async #append(tx, entries, underLock = [], held) {
1612
2209
  const prepared = this.#prepared;
1613
- return appendEntries(tx, prepared.logKey, entries, async (locked) => {
1614
- const rotation = await latestVaultEntry(locked, [KEY_ROTATE]);
2210
+ const head = held?.head ?? void 0;
2211
+ const appended = await appendEntries(tx, prepared.logKey, entries, async (locked) => {
2212
+ const rotation = head === void 0 ? await latestVaultEntry(locked, [KEY_ROTATE]) : head.rotation ?? void 0;
1615
2213
  if (rotation !== void 0 && !prepared.logKeys.some((key) => key.keyId === rotation.keyId)) {
1616
2214
  prepared.superseded = `the log moved on to ${rotation.keyId}, a key this vault does not hold: a vault given a newer vault key replaced it, and a replaced key writes nothing more`;
1617
2215
  throw new Error(prepared.superseded);
1618
2216
  }
1619
2217
  for (const check of underLock) await check(locked);
2218
+ }, head === void 0 ? void 0 : {
2219
+ head,
2220
+ last: head.last,
2221
+ at: held.at
1620
2222
  });
2223
+ if (head !== void 0) held.head = {
2224
+ ...head,
2225
+ nextSeq: appended.nextSeq,
2226
+ headHash: appended.headHash,
2227
+ last: {
2228
+ seq: appended.nextSeq - 1n,
2229
+ hash: appended.headHash
2230
+ }
2231
+ };
2232
+ return appended;
1621
2233
  }
1622
2234
  async unwrap(input) {
1623
2235
  const { principal } = input;
@@ -1674,10 +2286,8 @@ var VaultService = class {
1674
2286
  permission: input.purpose === "use" ? "secret.use" : "secret.read",
1675
2287
  secrets: items.map((item) => item.secret),
1676
2288
  places: sealed.map((seal, i) => seal?.holder ?? items[i].secret),
1677
- vet: ends.length === 0 ? void 0 : async (db) => {
1678
- const ended = await endedReferences(db, ends);
1679
- return sealed.map((seal) => seal !== null && ended.has(seal.seq) ? "ended" : null);
1680
- },
2289
+ references: ends,
2290
+ vet: ends.length === 0 ? void 0 : async (_, reads) => sealed.map((seal) => seal !== null && reads.ended.has(seal.seq) ? "ended" : null),
1681
2291
  remote,
1682
2292
  entry,
1683
2293
  input
@@ -1858,11 +2468,9 @@ var VaultService = class {
1858
2468
  });
1859
2469
  const owners = /* @__PURE__ */ new Map();
1860
2470
  const check = async (d) => {
1861
- const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), call.input);
2471
+ const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), call.input);
1862
2472
  const at = secrets.map((secret, i) => call.places?.[i] ?? secret);
1863
- const environments = await environmentsById(d.tx, [...secrets, ...at].map((place) => place.environmentId));
1864
- const parents = [...environments.values()].flatMap((environment) => environment.owner === null || environment.parentId === null ? [] : [environment.parentId]);
1865
- for (const [id, parent] of await environmentsById(d.tx, parents)) environments.set(id, parent);
2473
+ const environments = d.reads.environments;
1866
2474
  const gone = (place) => environments.get(place.environmentId)?.deleted ?? false;
1867
2475
  owners.clear();
1868
2476
  for (const [id, environment] of environments) if (environment.owner !== null) owners.set(id, ownerKey(environment.owner));
@@ -1878,7 +2486,7 @@ var VaultService = class {
1878
2486
  }
1879
2487
  return refuses(reader, call.permission, placeIn(at[i], environments), gone(secret) || gone(at[i]));
1880
2488
  });
1881
- if (call.vet !== void 0 && !codes.some((code) => code !== null)) (await call.vet(d.tx)).forEach((code, i) => codes[i] = code);
2489
+ if (call.vet !== void 0 && !codes.some((code) => code !== null)) (await call.vet(d.tx, d.reads)).forEach((code, i) => codes[i] = code);
1882
2490
  const copying = action === "secret.read" && call.input.purpose === "copy";
1883
2491
  if (action === "secret.read" && principal.startsWith("user:") && handsBack({
1884
2492
  purpose: call.input.purpose ?? "",
@@ -1887,9 +2495,17 @@ var VaultService = class {
1887
2495
  if (codes[i] === null && secret.sensitive === true) codes[i] = "sensitive";
1888
2496
  });
1889
2497
  let first = codes.find((code) => code !== null) ?? null;
1890
- if (first === null && action === "secret.read" && await this.#overBulkLimit(d, principal, secrets.length)) first = "bulk_limit";
2498
+ if (first === null && action === "secret.read" && this.#overBulkLimit(d)) first = "bulk_limit";
1891
2499
  if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, i) => entry(secret, "deny", codes[i] ?? first)));
1892
2500
  };
2501
+ const reads = {
2502
+ environments: [...secrets, ...secrets.map((secret, i) => call.places?.[i] ?? secret)].map((place) => place.environmentId),
2503
+ references: call.references ?? [],
2504
+ ...action === "secret.read" ? { released: {
2505
+ principal,
2506
+ keys: secrets.map((secret) => secret.secretId)
2507
+ } } : {}
2508
+ };
1893
2509
  const intentId = call.remote ? randomUUID() : null;
1894
2510
  let intentSeq = null;
1895
2511
  const outcomeEntry = (secret, item, decision, code) => {
@@ -1908,7 +2524,7 @@ var VaultService = class {
1908
2524
  if (call.remote) {
1909
2525
  const intent = await this.#decide([principal], async (d) => {
1910
2526
  await check(d);
1911
- const at = await this.#now(d.tx);
2527
+ const at = d.at;
1912
2528
  return { seq: (await this.#append(d.tx, [{
1913
2529
  actor: principal,
1914
2530
  action: "key.intent",
@@ -1928,21 +2544,25 @@ var VaultService = class {
1928
2544
  version: secret.version
1929
2545
  }))
1930
2546
  })
1931
- }])).seqStart };
1932
- });
2547
+ }], [], d.reads)).seqStart };
2548
+ }, false, reads);
1933
2549
  if (!intent.ok) return intent;
1934
2550
  intentSeq = intent.seq;
1935
2551
  }
1936
- return this.#decide([principal], async (d) => {
2552
+ const late = call.remote;
2553
+ const decided = async (d) => {
1937
2554
  try {
1938
2555
  await check(d);
1939
2556
  } catch (error) {
1940
2557
  if (error instanceof Refused) throw new Refused(error.refusal, secrets.map((secret, item) => outcomeEntry(secret, item, "deny", error.entries[item].code ?? error.refusal.code)));
1941
2558
  throw error;
1942
2559
  }
2560
+ };
2561
+ return this.#decide([principal], async (d) => {
2562
+ await decided(d);
1943
2563
  const { vet } = call;
1944
- if (vet !== void 0) d.underLock.push(async (locked) => {
1945
- const codes = await vet(locked);
2564
+ if (late && vet !== void 0) d.underLock.push(async (locked) => {
2565
+ const codes = await vet(locked, d.reads);
1946
2566
  const first = codes.find((code) => code !== null);
1947
2567
  if (first === void 0 || first === null) return;
1948
2568
  throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, item) => outcomeEntry(secret, item, "deny", codes[item] ?? first)));
@@ -1957,16 +2577,12 @@ var VaultService = class {
1957
2577
  if (unanswered > 0) throw new Outage(new KekUnavailableError(`the key service did not answer for ${unanswered} of ${secrets.length} keys`, outcomes.some((outcome) => !outcome.ok && outcome.code === "kms_uncertain")), entries());
1958
2578
  if (expired) throw new Outage(new KekUnavailableError("key operation exceeded its deadline"), entries());
1959
2579
  if (done.length < outcomes.length) throw new Refused(refusal("bad_claim", MESSAGES.bad_claim), entries());
1960
- await lockLogHead(d.tx);
1961
- try {
1962
- await check(d);
1963
- } catch (error) {
1964
- if (error instanceof Refused) throw new Refused(error.refusal, secrets.map((secret, item) => outcomeEntry(secret, item, "deny", error.entries[item].code ?? error.refusal.code)));
1965
- throw error;
2580
+ if (late) {
2581
+ d.reads = await this.#snapshot(d.tx, [principal], reads, true);
2582
+ d.at = this.#at(d.reads);
2583
+ await decided(d);
2584
+ if (secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]).some((place) => d.reads.environments.get(place.environmentId)?.deleted ?? false)) throw new Refused(refusal("deleted", MESSAGES.deleted), secrets.map((secret, i) => outcomeEntry(secret, i, "deny", "deleted")));
1966
2585
  }
1967
- const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
1968
- const now = await environmentsById(d.tx, places.map((place) => place.environmentId));
1969
- if (places.some((place) => now.get(place.environmentId)?.deleted ?? false)) throw new Refused(refusal("deleted", MESSAGES.deleted), secrets.map((secret, i) => outcomeEntry(secret, i, "deny", "deleted")));
1970
2586
  const released = secrets.map((secret, i) => outcomeEntry(secret, i, "allow", null));
1971
2587
  d.log.push(...released);
1972
2588
  const answer = result(done);
@@ -1975,12 +2591,16 @@ var VaultService = class {
1975
2591
  } finally {
1976
2592
  for (const value of done) value.wipe();
1977
2593
  }
1978
- }, true);
2594
+ }, late, reads);
1979
2595
  }
1980
- /** Whether `n` more keys would take `principal` past the bulk limit; counted under their row's lock, so exactly. */
1981
- async #overBulkLimit(d, principal, n) {
1982
- const { count, windowMs } = this.#config.bulkLimit;
1983
- return await releasesSince(d.tx, principal, d.at - windowMs) + n > count;
2596
+ /**
2597
+ * Whether this read's keys would take the reader past the bulk limit: the
2598
+ * distinct keys released to them in the window, these among them. A key
2599
+ * read again exposes nothing new, so it counts once, whatever its version.
2600
+ * Counted under their row's lock, so exactly (`Reads`).
2601
+ */
2602
+ #overBulkLimit(d) {
2603
+ return d.reads.released > this.#config.bulkLimit.count;
1984
2604
  }
1985
2605
  /**
1986
2606
  * Make each holder a reference to its source: the actor writes the holder
@@ -2030,7 +2650,7 @@ var VaultService = class {
2030
2650
  };
2031
2651
  };
2032
2652
  return this.#decide([principal], async (d) => {
2033
- const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
2653
+ const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), input);
2034
2654
  const natures$1 = await natures(d.tx, [...sources.values()].map((source) => source.secretId));
2035
2655
  const environments = await environmentsById(d.tx, items.flatMap((item) => {
2036
2656
  const source = sources.get(item.source.secretId);
@@ -2109,7 +2729,7 @@ var VaultService = class {
2109
2729
  return seals.map((seal) => done.has(seal.seq) ? "ended" : null);
2110
2730
  };
2111
2731
  return this.#decide([principal], async (d) => {
2112
- const actor = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
2732
+ const actor = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), input);
2113
2733
  const environments = await environmentsById(d.tx, seals.map((seal) => seal.holder.environmentId));
2114
2734
  const codes = seals.map((seal) => {
2115
2735
  const holder = refuses(actor, "secret.write", placeIn(seal.holder, environments), false);
@@ -2210,7 +2830,7 @@ var VaultService = class {
2210
2830
  */
2211
2831
  async #makeOwner(d, principal, reason) {
2212
2832
  const row = d.members.get(principal);
2213
- const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
2833
+ const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
2214
2834
  if (subject.status === "active" && ownsInstance(subject.live)) return false;
2215
2835
  const tampered = subject.status === "tampered";
2216
2836
  if (tampered && await this.#logged(d.tx, principal) === void 0) return false;
@@ -2264,10 +2884,10 @@ var VaultService = class {
2264
2884
  * What `principal` holds, their row checked first (`#integrity`): a row
2265
2885
  * that fails holds nothing, and is `tampered`.
2266
2886
  */
2267
- async #standing(db, principal, row, at, reports) {
2887
+ async #standing(db, principal, row, at, reports, reads) {
2268
2888
  const none = NOTHING;
2269
- const grants$1 = row === void 0 ? [] : await grants(db, principal);
2270
- const fault = await this.#integrity(db, principal, row, grants$1, reports);
2889
+ const grants$1 = row === void 0 ? [] : reads?.grants.get(principal) ?? await grants(db, principal);
2890
+ const fault = await this.#integrity(db, principal, row, grants$1, reports, reads);
2271
2891
  if (fault !== null) return {
2272
2892
  principal,
2273
2893
  status: "tampered",
@@ -2297,7 +2917,7 @@ var VaultService = class {
2297
2917
  role: grant.role
2298
2918
  }));
2299
2919
  const { role, scope } = roleOf(principal, row);
2300
- const { policy } = await this.#policyAt(db, reports, at);
2920
+ const { policy } = await this.#policyAt(db, reports, at, reads);
2301
2921
  const service = !principal.startsWith("user:");
2302
2922
  return {
2303
2923
  principal,
@@ -2357,13 +2977,17 @@ var VaultService = class {
2357
2977
  /** `access`, as the rows are now. */
2358
2978
  async #read(principal, superseded) {
2359
2979
  const read = async (db, reports) => {
2360
- const [row, held, at] = await Promise.all([
2361
- member(db, principal),
2362
- grants(db, principal),
2363
- this.#now(db)
2364
- ]);
2365
- const fault = await this.#integrity(db, principal, row, held, reports);
2366
- const { policy } = await this.#policyAt(db, reports, at);
2980
+ const reads = await snapshot(db, {
2981
+ principals: [principal],
2982
+ members: true
2983
+ });
2984
+ const [row, held, at] = [
2985
+ reads.members.get(principal),
2986
+ reads.grants.get(principal),
2987
+ this.#at(reads)
2988
+ ];
2989
+ const fault = await this.#integrity(db, principal, row, held, reports, reads);
2990
+ const { policy } = await this.#policyAt(db, reports, at, reads);
2367
2991
  return this.#access(principal, row, held, at, fault !== null, policy);
2368
2992
  };
2369
2993
  const found = [];
@@ -2438,7 +3062,7 @@ var VaultService = class {
2438
3062
  const expiresAt = change.expiresAt === null ? null : Date.parse(change.expiresAt);
2439
3063
  if (Number.isNaN(expiresAt) || expiresAt !== null && expiresAt <= d.at) throw refused("invalid", "an end date must be in the future");
2440
3064
  }
2441
- const { policy } = await this.#policyAt(d.tx, d.reports, d.at);
3065
+ const { policy } = await this.#policyAt(d.tx, d.reports, d.at, d.reads);
2442
3066
  for (const change of changes) {
2443
3067
  if (change.role !== null && !isRole(change.role, policy)) throw refused("invalid", `no such role: ${change.role}`);
2444
3068
  if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role, policy)) throw refused("invalid", `${change.role} can only be granted on a project`);
@@ -2454,13 +3078,13 @@ var VaultService = class {
2454
3078
  environmentSlug: environment.slug
2455
3079
  };
2456
3080
  });
2457
- const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
3081
+ const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
2458
3082
  if (acting.status === "tampered") throw refused("tampered");
2459
3083
  const manages = where.every((place) => mayManageAccess(acting.live, place));
2460
3084
  if (!manages && !(SERVICE.test(principal) && PERSON.test(actor))) throw refused("not_allowed");
2461
3085
  if (actor === principal && changes.some((change) => change.role !== null)) throw refused("not_allowed", "nobody grants themselves a role: ask another admin");
2462
3086
  const row = d.members.get(principal);
2463
- const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
3087
+ const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
2464
3088
  if (subject.status === "tampered") throw refused("tampered", TAMPERED_SUBJECT);
2465
3089
  const admitted = input.admit === true && row?.status !== "active" ? await this.#admitWith(d, acting, principal, row, input) : void 0;
2466
3090
  if (admitted === void 0 && row?.status === "removed") throw refused("removed");
@@ -2560,7 +3184,7 @@ var VaultService = class {
2560
3184
  const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [accessEntry(actor, "member.add", principal, "deny", input, asked, code)]);
2561
3185
  return this.#decide([actor, principal], async (d) => {
2562
3186
  validateCorrelation(input);
2563
- const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
3187
+ const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
2564
3188
  if (acting.status === "tampered") throw refused("tampered");
2565
3189
  const service = SERVICE.test(principal) && (input.role ?? "member") === "member" && input.scope === void 0;
2566
3190
  if (!runsInstance(acting.live)) await this.#setsUp(d, acting, service, refused);
@@ -2573,7 +3197,7 @@ var VaultService = class {
2573
3197
  };
2574
3198
  if (wanted !== void 0 && wanted.role !== "member" && !principal.startsWith("user:")) throw refused("invalid", "service accounts hold project grants only, never an instance role");
2575
3199
  const row = d.members.get(principal);
2576
- const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
3200
+ const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
2577
3201
  if (subject.status === "tampered") throw refused("tampered", TAMPERED_SUBJECT);
2578
3202
  d.touched.add(principal);
2579
3203
  d.grants.set(principal, [...subject.stored]);
@@ -2674,10 +3298,10 @@ var VaultService = class {
2674
3298
  validateCorrelation(input);
2675
3299
  if (!PRINCIPAL.test(principal)) throw refused("invalid", `not a member: ${shownMember(principal)}`);
2676
3300
  const row = d.members.get(principal);
2677
- const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
3301
+ const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
2678
3302
  if (acting.status === "tampered") throw refused("tampered");
2679
3303
  const holder = acting.live;
2680
- const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
3304
+ const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
2681
3305
  const held = subject.stored;
2682
3306
  if (this.#listed(principal)) throw refused("not_allowed", declaredOwner(principal));
2683
3307
  if (subject.status === "tampered") {
@@ -2760,7 +3384,7 @@ var VaultService = class {
2760
3384
  * the default: otherwise whoever can insert rows could open it again.
2761
3385
  */
2762
3386
  async #settingsAt(db, reports) {
2763
- const entry = await newestAuthentic((before) => newestVaultEntries(db, SETTINGS, LOOK_BACK.page, before), this.#check(reports, null));
3387
+ const entry = await newestAuthentic((limit, before) => newestVaultEntries(db, SETTINGS, limit, before), this.#check(reports, null));
2764
3388
  if (entry === "none") return DEFAULT_SETTINGS;
2765
3389
  if (typeof entry === "string") return CLOSED_SETTINGS;
2766
3390
  const { serviceAccounts } = JSON.parse(entry.metadata);
@@ -2781,20 +3405,21 @@ var VaultService = class {
2781
3405
  * no role holds anything but Owner, otherwise whoever can insert rows
2782
3406
  * could unprotect them. Its edits are then unknown, not none.
2783
3407
  */
2784
- async #policyAt(db, reports, at) {
2785
- const entry = await newestAuthentic((before) => newestVaultEntries(db, POLICY, LOOK_BACK.page, before), this.#check(reports, null));
3408
+ async #policyAt(db, reports, at, reads) {
3409
+ const entry = await newestAuthentic((limit, before) => reads !== void 0 && before === void 0 ? Promise.resolve(reads.newestPolicy === null ? [] : [reads.newestPolicy]) : newestVaultEntries(db, POLICY, limit, before), this.#check(reports, null));
2786
3410
  if (entry === "none") return {
2787
3411
  policy: DEFAULT_POLICY,
2788
3412
  edits: NO_EDITS,
2789
3413
  readable: true
2790
3414
  };
2791
3415
  const edits = typeof entry === "string" ? null : readEdits(JSON.parse(entry.metadata));
2792
- return edits === null ? {
3416
+ if (edits === null) return {
2793
3417
  policy: CLOSED_POLICY,
2794
3418
  edits: NO_EDITS,
2795
3419
  readable: false
2796
- } : {
2797
- policy: await livePolicy(db, edits, at),
3420
+ };
3421
+ return {
3422
+ policy: livePolicy(entry === reads?.newestPolicy ? reads.environments : await environmentsById(db, edits.protected.map((place) => place.environmentId)), edits, at),
2798
3423
  edits,
2799
3424
  readable: true
2800
3425
  };
@@ -2819,11 +3444,11 @@ var VaultService = class {
2819
3444
  const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [entry("deny", { change: describeChange(change) }, code)]);
2820
3445
  return this.#decide([actor], async (d) => {
2821
3446
  validateCorrelation(input);
2822
- const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
3447
+ const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
2823
3448
  if (acting.status === "tampered") throw refused("tampered");
2824
3449
  if (acting.status !== "active") throw refused(acting.status === "removed" ? "removed" : "not_a_member");
2825
- const newest = await newestVaultSeq(d.tx, POLICY);
2826
- const { policy: before, edits, readable } = await this.#policyAt(d.tx, d.reports, d.at);
3450
+ const newest = d.reads.newestPolicy?.seq ?? null;
3451
+ const { policy: before, edits, readable } = await this.#policyAt(d.tx, d.reports, d.at, d.reads);
2827
3452
  if (revision !== void 0 && policyRevision(newest, before) !== revision) throw refused("policy_changed");
2828
3453
  const owner = acting.live.credentialScope == null && ownsInstance(acting.live);
2829
3454
  if (!readable && !owner) throw refused("not_allowed", "the vault cannot read its own policy: only an Owner of the whole instance writes it again, from the roles as they ship");
@@ -2879,7 +3504,7 @@ var VaultService = class {
2879
3504
  }
2880
3505
  } else throw refused("invalid", "a change is to a role, an instance role, or protection");
2881
3506
  const answer = {
2882
- policy: await livePolicy(d.tx, next, d.at),
3507
+ policy: livePolicy(await environmentsById(d.tx, next.protected.map((place) => place.environmentId)), next, d.at),
2883
3508
  seq: newest === null ? null : Number(newest)
2884
3509
  };
2885
3510
  if (!readable || JSON.stringify(next) !== JSON.stringify(edits)) {
@@ -2911,7 +3536,7 @@ var VaultService = class {
2911
3536
  const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [entry("deny", { serviceAccounts: input.settings?.serviceAccounts ?? null }, code)]);
2912
3537
  return this.#decide([actor], async (d) => {
2913
3538
  validateCorrelation(input);
2914
- const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
3539
+ const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
2915
3540
  if (acting.status === "tampered") throw refused("tampered");
2916
3541
  if (!runsInstance(acting.live)) throw refused("not_allowed", "only admins and owners of the whole instance change its settings");
2917
3542
  if (!isScope(input.settings?.serviceAccounts)) throw refused("invalid", "who sets up service accounts is a scope: { projects, environments }, each all, only or except");
@@ -3077,63 +3702,162 @@ var VaultService = class {
3077
3702
  }
3078
3703
  return checks;
3079
3704
  }
3080
- /** The last checkpoint the vault signed, and the entry that holds it: its newest allowed `audit.checkpoint`. */
3081
- async #latest(db) {
3082
- const row = await latestVaultEntry(db, [CHECKPOINT]);
3083
- return row === void 0 ? null : {
3084
- checkpoint: JSON.parse(row.metadata),
3085
- seq: row.seq,
3086
- keyId: row.keyId
3705
+ /**
3706
+ * Where a walk of the log resumes: the first entry (`start`), a
3707
+ * checkpoint's entry that keeps a tally, or the newest such entry, the
3708
+ * first entry while there is none. The entry must be the vault's, by its
3709
+ * MAC, and its tally whole: the members and grants it keeps, or the
3710
+ * earlier checkpoint it names keeps, match the digest it carries.
3711
+ *
3712
+ * Its signature is left to the walk: the entry comes after the prefix it
3713
+ * signs, so a walk from there reads it, as it reads every other.
3714
+ */
3715
+ async #resume(db, from) {
3716
+ if (from === "start") return FIRST_ENTRY;
3717
+ const entry = from === "newest" ? await latestTallied(db) : (await entriesAt(db, [from])).get(from);
3718
+ if (entry === void 0) return from === "newest" ? FIRST_ENTRY : {
3719
+ ok: false,
3720
+ failedAtSeq: Number(from),
3721
+ reason: "no checkpoint at this entry"
3087
3722
  };
3723
+ return (await this.#resumeEach(db, [entry]))[0];
3724
+ }
3725
+ /** `#resume` for each of these checkpoint entries, the entries their members and grants are kept in read at once. */
3726
+ async #resumeEach(db, entries) {
3727
+ const reads = entries.map((entry) => this.#tallied(entry));
3728
+ const named = reads.flatMap((read) => typeof read !== "string" && "at" in read.tally.access ? [BigInt(read.tally.access.at)] : []);
3729
+ const carriers = new Map([...await entriesAt(db, named)].map(([seq, held]) => [seq, this.#tallied(held)]));
3730
+ return entries.map((entry, i) => {
3731
+ const broken = (reason) => ({
3732
+ ok: false,
3733
+ failedAtSeq: Number(entry.seq),
3734
+ reason
3735
+ });
3736
+ const read = reads[i];
3737
+ if (typeof read === "string") return broken(read);
3738
+ let carrier = entry.seq;
3739
+ let { access } = read.tally;
3740
+ if ("at" in access) {
3741
+ carrier = BigInt(access.at);
3742
+ const holder = carrier < entry.seq ? carriers.get(carrier) : void 0;
3743
+ if (holder === void 0 || typeof holder === "string" || "at" in holder.tally.access) return broken(`its tally keeps its members and grants in entry ${carrier}, which holds none`);
3744
+ access = holder.tally.access;
3745
+ }
3746
+ const kept = {
3747
+ members: access.members,
3748
+ grants: access.grants,
3749
+ newest: access.newest
3750
+ };
3751
+ if (keptDigest(access) !== read.tally.access.digest) return broken("its tally's members and grants do not match their digest");
3752
+ return {
3753
+ ok: true,
3754
+ entry: entry.seq,
3755
+ at: {
3756
+ seq: BigInt(read.seq),
3757
+ hash: Buffer.from(read.hash, "hex")
3758
+ },
3759
+ tally: {
3760
+ ...read.tally,
3761
+ access: kept
3762
+ },
3763
+ digest: read.tally.access.digest,
3764
+ carrier,
3765
+ scrub: read.scrub === null ? null : {
3766
+ at: positionOf(read.scrub.at),
3767
+ end: positionOf(read.scrub.end)
3768
+ }
3769
+ };
3770
+ });
3771
+ }
3772
+ /** A checkpoint's entry that keeps a tally, as it says, or why it is not one the vault wrote. */
3773
+ #tallied(entry) {
3774
+ const not = "not a checkpoint of the vault's that keeps a tally";
3775
+ if (entry.author !== "vault" || entry.action !== "audit.checkpoint" || entry.decision !== "allow" || entry.relatedSeq === null) return not;
3776
+ const authentic = verifyEntries([entry], {
3777
+ startSeq: entry.seq,
3778
+ startPrevHash: entry.prevHash,
3779
+ keys: this.#keysFor(entry)
3780
+ });
3781
+ if (!authentic.ok) return withCause(authentic.reason);
3782
+ const read = metadataOf(entry);
3783
+ if (read?.tally?.v !== 1 || read.scrub === void 0 || typeof read.seq !== "number" || BigInt(read.seq) !== entry.relatedSeq) return not;
3784
+ return read;
3785
+ }
3786
+ /** Which of `heads` the log still holds where they were, in one read. */
3787
+ async #holding(db, heads) {
3788
+ const hashes = await hashesAt(db, [...new Set(heads.filter((head) => head.seq >= 0).map((head) => BigInt(head.seq)))]);
3789
+ return (head) => head.hash === GENESIS_HASH.toString("hex") || hashes.get(BigInt(head.seq))?.toString("hex") === head.hash;
3088
3790
  }
3089
3791
  /**
3090
- * The chain recomputed to its last entry, for a checkpoint to sign: from
3091
- * its first entry at the first checkpoint of each hour, and otherwise from
3092
- * the prefix the last checkpoint signed, if that one was signed this hour.
3792
+ * Sign the log up to its last entry, from the newest checkpoint's tally.
3093
3793
  *
3094
- * Resuming never signs what the vault did not recompute. The vault
3095
- * recomputed the last checkpoint's prefix itself, from entry 0 at the
3096
- * hour's first checkpoint and onward at each one since, and the entry
3097
- * that holds it is the first one checked again, by its MAC. The next
3098
- * signature extends that same chain.
3794
+ * In one snapshot, and without the log's lock, so that no append waits on
3795
+ * it: every member's row checked, since rows changed around the vault
3796
+ * write nothing to the log; the prefix the newest checkpoint signed still
3797
+ * there, its hash where it was, which a rewrite chained again changes, and
3798
+ * every entry of it, which a cut changes; the chain recomputed from there,
3799
+ * every hash from content and every vault entry by its MAC, no further
3800
+ * than one walk (`walks.checkpoint`); and a stretch of the prefixes signed
3801
+ * before rehashed (`#scrub`). Then, under the lock, the entries appended
3802
+ * since the snapshot, so that it signs the head.
3099
3803
  *
3100
- * What resuming does not do is recompute the rows of that prefix again.
3101
- * They can change while the hash stored at its end stays, so it first
3102
- * checks the two things that are cheap: that hash is still there, which a
3103
- * prefix rewritten and chained again changes, and every entry of the
3104
- * prefix is, which a cut changes. Either failing, it recomputes from entry
3105
- * 0, which names what broke. An entry edited in place, its hash left as
3106
- * it was, is the one change left to the next full pass, within the hour.
3107
- *
3108
- * It resumes only from a checkpoint under the key the vault writes with
3109
- * now: once the log reaches that key, no other writes again (`forward`).
3110
- * After a rotation, the next pass is full.
3804
+ * A walk cut short signs where it got to, behind the head: the log after
3805
+ * an upgrade from 0.10.0, whose checkpoints kept no tally, is signed again
3806
+ * from the first entry, one walk a call, and `/readyz` stays red until the
3807
+ * checkpoints reach the newest heartbeat.
3111
3808
  */
3112
- async #recompute(tx) {
3113
- const { logKeys } = this.#prepared;
3114
- const latest = await this.#latest(tx);
3115
- const hour = (ms) => Math.floor(ms / FULL_RECOMPUTE_MS);
3116
- if (!(latest !== null && latest.keyId === logKeys[0].keyId && latest.seq === BigInt(latest.checkpoint.seq) + 1n && hour(Date.parse(latest.checkpoint.signedAt)) === hour(await this.#now(tx)) && await carries(tx, latest.checkpoint) && await complete(tx, BigInt(latest.checkpoint.seq)))) return verifyChain(tx, logKeys, [], UNVERIFIED);
3117
- const { seq, hash } = latest.checkpoint;
3118
- return verifyChain(tx, logKeys, [], {
3119
- nextSeq: BigInt(seq) + 1n,
3120
- hash: Buffer.from(hash, "hex"),
3121
- vaultEntries: 0,
3122
- vaultKeys: [logKeys[0].keyId]
3123
- });
3124
- }
3125
3809
  async checkpoint() {
3126
3810
  await this.#settled();
3127
- const { wrongKek } = this.#prepared;
3811
+ const { wrongKek, logKeys } = this.#prepared;
3812
+ const { walks } = this.#prepared.options;
3128
3813
  const found = [];
3129
- const whole = await this.#db.transaction(async (tx) => {
3814
+ let seen = null;
3815
+ const walked = await this.#db.transaction(async (tx) => {
3816
+ seen = (await newestCheckpointEntry(tx))?.seq ?? null;
3130
3817
  await this.#sweep(tx, found);
3131
- return this.#recompute(tx);
3818
+ const from = await this.#resume(tx, "newest");
3819
+ if (!from.ok) return from;
3820
+ const signed = headOf(from.at);
3821
+ if (!(await this.#holding(tx, [signed]))(signed)) return {
3822
+ ok: false,
3823
+ failedAtSeq: null,
3824
+ reason: `the log up to entry ${signed.seq} is not the prefix the last checkpoint signed`
3825
+ };
3826
+ const gap = await missingEntry(tx, from.at.seq);
3827
+ if (gap !== null) return {
3828
+ ok: false,
3829
+ failedAtSeq: Number(gap.found),
3830
+ reason: `sequence gap: expected seq ${gap.expected}, found ${gap.found}`
3831
+ };
3832
+ const walk = new Walk(from.tally, from.digest);
3833
+ const chain = await rehash(tx, logKeys, from.at, walk, { limit: walks.checkpoint });
3834
+ if (!chain.ok) return chain;
3835
+ const unsigned = await this.#checkpointFault(tx, walk.checkpoints);
3836
+ if (unsigned !== null) return unsigned;
3837
+ return {
3838
+ ok: true,
3839
+ from,
3840
+ tally: walk.tally(),
3841
+ digest: walk.digest(),
3842
+ at: chain.at,
3843
+ done: chain.done
3844
+ };
3132
3845
  }, SNAPSHOT);
3846
+ let scrub = {
3847
+ ok: true,
3848
+ next: walked.ok ? walked.from.scrub : null
3849
+ };
3850
+ let stalled = null;
3851
+ if (walked.ok) try {
3852
+ scrub = await this.#db.transaction((tx) => this.#scrub(tx, walked.from), SNAPSHOT);
3853
+ } catch (error) {
3854
+ if (!isUnreachable(error) && !timedOut(error)) throw error;
3855
+ stalled = `the rehash of the past did not run, and goes on where it was next time: ${error.message}`;
3856
+ }
3133
3857
  return this.#decide([], async (d) => {
3134
3858
  for (const entry of found) if (!d.reports.includes(entry)) d.reports.push(entry);
3135
3859
  const head = await lockLogHead(d.tx);
3136
- const latest = await this.#latest(d.tx);
3860
+ const latest = await latestVaultEntry(d.tx, [CHECKPOINT]);
3137
3861
  const refused = (code, detail, message = MESSAGES[code]) => new Refused(refusal(code, message), [{
3138
3862
  actor: SCHEDULER,
3139
3863
  action: CHECKPOINT,
@@ -3143,21 +3867,57 @@ var VaultService = class {
3143
3867
  }]);
3144
3868
  if (head.nextSeq === 0n) throw new Refused(refusal("invalid", "the log is empty"), []);
3145
3869
  if (wrongKek !== null) throw refused("wrong_kek", { reason: wrongKek }, wrongKek);
3146
- if (!whole.verification.ok) throw refused("log_broken", {
3147
- failedAtSeq: whole.verification.failedAtSeq,
3148
- reason: whole.verification.reason
3870
+ if (!walked.ok) throw refused("log_broken", {
3871
+ failedAtSeq: walked.failedAtSeq,
3872
+ reason: walked.reason
3149
3873
  });
3150
- if (latest !== null && latest.seq === head.nextSeq - 1n) return { checkpoint: latest.checkpoint };
3151
- if (latest !== null && !await carries(d.tx, latest.checkpoint)) throw refused("log_broken", { reason: `the log up to entry ${latest.checkpoint.seq} is not the prefix the last checkpoint signed` });
3152
- const { verification: held, anchor: verified } = await verifyChain(d.tx, this.#prepared.logKeys, [], whole.anchor);
3153
- if (!held.ok) throw refused("log_broken", {
3154
- failedAtSeq: held.failedAtSeq,
3155
- reason: held.reason
3874
+ if (!scrub.ok) throw refused("log_broken", {
3875
+ failedAtSeq: scrub.failedAtSeq,
3876
+ reason: scrub.reason
3156
3877
  });
3157
- if (verified.nextSeq !== head.nextSeq || !verified.hash.equals(head.headHash)) throw refused("log_broken", { reason: "the chain head does not name the last entry" });
3878
+ const newest = latest === void 0 ? null : JSON.parse(latest.metadata);
3879
+ if (stalled !== null || ((await newestCheckpointEntry(d.tx))?.seq ?? null) !== seen) {
3880
+ const standing = await this.#standingRefusal(d.tx);
3881
+ if (standing !== null) throw refused("log_broken", standing);
3882
+ if (stalled === null && newest !== null) return { checkpoint: signedPart(newest) };
3883
+ }
3884
+ if (newest !== null && latest.seq === head.nextSeq - 1n && latest.relatedSeq === latest.seq - 1n) return { checkpoint: signedPart(newest) };
3885
+ if (newest !== null && !await carries(d.tx, newest)) throw refused("log_broken", { reason: `the log up to entry ${newest.seq} is not the prefix the last checkpoint signed` });
3886
+ const walk = new Walk(walked.tally, walked.digest);
3887
+ let { at } = walked;
3888
+ if (walked.done) {
3889
+ if (!await carries(d.tx, headOf(at))) throw refused("log_broken", {
3890
+ failedAtSeq: Number(at.seq),
3891
+ reason: `the log up to entry ${at.seq} changed while the vault walked it`
3892
+ });
3893
+ const rest = await rehash(d.tx, logKeys, at, walk, {});
3894
+ if (!rest.ok) throw refused("log_broken", {
3895
+ failedAtSeq: rest.failedAtSeq,
3896
+ reason: rest.reason
3897
+ });
3898
+ const unsigned = await this.#checkpointFault(d.tx, walk.checkpoints);
3899
+ if (unsigned !== null) throw refused("log_broken", {
3900
+ failedAtSeq: unsigned.failedAtSeq,
3901
+ reason: unsigned.reason
3902
+ });
3903
+ at = rest.at;
3904
+ if (at.seq !== head.nextSeq - 1n || !at.hash.equals(head.headHash)) throw refused("log_broken", { reason: "the chain head does not name the last entry" });
3905
+ } else if (!await carries(d.tx, headOf(at))) throw refused("log_broken", { reason: `the log up to entry ${at.seq} changed while the vault walked it` });
3906
+ if (newest !== null && latest.relatedSeq !== null && latest.relatedSeq >= at.seq) return { checkpoint: signedPart(newest) };
3907
+ this.#prepared.verified = further(this.#prepared.verified, at);
3908
+ const tally = walk.tally();
3909
+ const digest = walk.digest();
3910
+ const { from } = walked;
3911
+ const access = digest === from.digest && from.carrier !== null ? {
3912
+ digest,
3913
+ at: Number(from.carrier)
3914
+ } : {
3915
+ digest,
3916
+ ...tally.access
3917
+ };
3158
3918
  const signed = {
3159
- seq: Number(verified.nextSeq - 1n),
3160
- hash: verified.hash.toString("hex"),
3919
+ seq: Number(at.seq),
3920
+ hash: at.hash.toString("hex"),
3161
3921
  signedAt: iso(d.at)
3162
3922
  };
3163
3923
  const checkpoint = {
@@ -3165,15 +3925,85 @@ var VaultService = class {
3165
3925
  keyId: this.#prepared.signer.keyId,
3166
3926
  signature: await this.#prepared.signer.sign(checkpointMessage(signed))
3167
3927
  };
3928
+ const lap = scrub.next === null ? null : {
3929
+ at: headOf(scrub.next.at),
3930
+ end: headOf(scrub.next.end)
3931
+ };
3932
+ const kept = {
3933
+ ...checkpoint,
3934
+ tally: {
3935
+ v: 1,
3936
+ ...tally,
3937
+ access
3938
+ },
3939
+ scrub: lap
3940
+ };
3168
3941
  d.log.push({
3169
3942
  actor: SCHEDULER,
3170
3943
  action: CHECKPOINT,
3171
3944
  decision: "allow",
3172
- metadata: JSON.stringify(checkpoint)
3945
+ relatedSeq: at.seq,
3946
+ metadata: JSON.stringify(kept)
3173
3947
  });
3174
- return { checkpoint };
3948
+ return stalled === null ? { checkpoint } : {
3949
+ checkpoint,
3950
+ warning: stalled
3951
+ };
3175
3952
  });
3176
3953
  }
3954
+ /**
3955
+ * The prefixes signed before, rehashed a stretch at a time, in laps: a lap
3956
+ * runs from the first entry to the prefix the newest checkpoint signed
3957
+ * when it began, which it keeps (`end`), `walks.scrub` entries a call, each
3958
+ * hash recomputed from its entry and each link checked (`relink`); the
3959
+ * next lap runs to the prefix signed then. A checkpoint resumes from the
3960
+ * newest one's tally and never reads what it signed again, and an entry
3961
+ * before it edited in place, its stored hash left as it was, keeps the
3962
+ * chain: this is what finds it, within two laps at most, each the length
3963
+ * of the log when it began over `walks.scrub` beats, however fast the log
3964
+ * grows meanwhile. The lap as it goes on, null between laps.
3965
+ */
3966
+ async #scrub(db, newest) {
3967
+ const lap = newest.scrub ?? {
3968
+ at: START,
3969
+ end: newest.at
3970
+ };
3971
+ if (lap.end.seq < 0n) return {
3972
+ ok: true,
3973
+ next: null
3974
+ };
3975
+ const relinked = await relink(db, lap.at, lap.end.seq, this.#prepared.options.walks.scrub);
3976
+ if (!relinked.ok) return relinked;
3977
+ if (relinked.at.seq < lap.end.seq) return {
3978
+ ok: true,
3979
+ next: {
3980
+ at: relinked.at,
3981
+ end: lap.end
3982
+ }
3983
+ };
3984
+ if (!relinked.at.hash.equals(lap.end.hash)) return {
3985
+ ok: false,
3986
+ failedAtSeq: Number(lap.end.seq),
3987
+ reason: `the log up to entry ${lap.end.seq} is not the prefix a checkpoint signed`
3988
+ };
3989
+ return {
3990
+ ok: true,
3991
+ next: null
3992
+ };
3993
+ }
3994
+ /**
3995
+ * The newest refusal for a broken log since the newest checkpoint was
3996
+ * signed, by the vault's MAC, as it said: it stands, for every
3997
+ * verification and checkpoint, until a checkpoint is signed, which takes
3998
+ * the check that found it running again and passing. A refusal of
3999
+ * another kind since, a wrong KEK, hides none.
4000
+ */
4001
+ async #standingRefusal(db) {
4002
+ const signed = await latestVaultEntry(db, [CHECKPOINT]);
4003
+ const refused = await newestRefusal(db, CHECKPOINT, "log_broken", signed?.seq ?? -1n);
4004
+ if (refused === void 0 || !this.#authentic(refused)) return null;
4005
+ return JSON.parse(refused.metadata);
4006
+ }
3177
4007
  async about() {
3178
4008
  await this.#settled();
3179
4009
  const { signers, since } = this.#prepared;
@@ -3202,65 +4032,181 @@ var VaultService = class {
3202
4032
  checks
3203
4033
  };
3204
4034
  }
3205
- verifyLog(input) {
3206
- return this.#verifyAll(input.upTo ?? null);
3207
- }
3208
4035
  /**
3209
- * The whole log, in one snapshot and one pass over the vault's entries:
3210
- * each by its MAC, the keys it moved through, and what the other checks
3211
- * need of it, gathered as it goes (`Findings`); then every checkpoint, the
3212
- * heads that must still be there, the key batches accounted for, and the
3213
- * members and grants replayed.
4036
+ * The log from a checkpoint's tally, in one snapshot: the vault's
4037
+ * entries since, each by its MAC, the keys it moved through, and what the
4038
+ * other checks need of it (`Walk`); then every checkpoint walked, the
4039
+ * heads that must still be there, and, for a walk that ends at the
4040
+ * present, the key batches accounted for and the members and grants
4041
+ * replayed against their rows.
3214
4042
  *
3215
4043
  * With `upTo`, the head the app verified to, the links and hashes of the
3216
- * chain are the app's to recompute, as it just has, over its own snapshot,
3217
- * through that head; `carries(upTo)` ties this snapshot to that one. So
3218
- * the vault reads only its own entries, each checked against its own hash
3219
- * and MAC over the link it names. Without `upTo`, nobody else recomputed
3220
- * the chain: the vault reads it all, and rehashes every link.
4044
+ * chain are the app's to recompute, as it just has, over its own
4045
+ * snapshot, through that head; holding `upTo` ties this snapshot to that
4046
+ * one. So the vault reads only its own entries, each checked against its
4047
+ * own hash and MAC over the link it names. Without `upTo`, nobody else
4048
+ * recomputed the chain: the vault reads it all, and rehashes every link,
4049
+ * no further than one walk (`walks.verify`).
3221
4050
  */
3222
- async #verifyAll(upTo) {
4051
+ async verifyLog(input) {
3223
4052
  await this.#settled();
4053
+ const upTo = input.upTo ?? null;
4054
+ const { logKeys } = this.#prepared;
4055
+ const { walks } = this.#prepared.options;
3224
4056
  return this.#db.transaction(async (tx) => {
3225
4057
  const remembered = this.#prepared.verified;
3226
- const findings = new Findings();
3227
- const read = upTo === null ? await this.#rehashed(tx, findings) : await this.#ownEntries(tx, findings);
3228
- if (!read.ok) return read;
3229
- const heads = [...upTo === null ? [] : [upTo], ...findings.checkpoints.map(({ checkpoint }) => checkpoint)];
3230
- const rememberedHead = remembered.nextSeq > 0n ? {
3231
- seq: Number(remembered.nextSeq - 1n),
3232
- hash: remembered.hash.toString("hex")
3233
- } : null;
3234
- if (rememberedHead !== null) heads.push(rememberedHead);
3235
- const hashes = await hashesAt(tx, [...new Set(heads.map((head) => BigInt(head.seq)))]);
3236
- const holds = (head) => head.hash === GENESIS_HASH.toString("hex") || hashes.get(BigInt(head.seq))?.toString("hex") === head.hash;
4058
+ const from = await this.#resume(tx, input.from === void 0 ? "newest" : input.from === "start" ? "start" : BigInt(input.from));
4059
+ if (!from.ok) return from;
4060
+ const to = input.to === void 0 ? null : BigInt(input.to);
4061
+ const ends = to === null ? null : (await entriesAt(tx, [to])).get(to)?.relatedSeq ?? -1n;
4062
+ if (ends !== null && (ends <= from.at.seq || upTo !== null && BigInt(upTo.seq) !== ends)) return {
4063
+ ok: false,
4064
+ failedAtSeq: Number(to),
4065
+ reason: "not a checkpoint after the one the walk starts from, whose prefix ends where the app verified to"
4066
+ };
4067
+ if (!(await this.#holding(tx, [headOf(from.at)]))(headOf(from.at))) return {
4068
+ ok: false,
4069
+ failedAtSeq: Number(from.entry),
4070
+ reason: `the log up to entry ${from.at.seq} is not the prefix this checkpoint signed: it was rewritten`
4071
+ };
4072
+ const gap = await missingEntry(tx, from.at.seq);
4073
+ if (gap !== null) return {
4074
+ ok: false,
4075
+ failedAtSeq: Number(gap.found),
4076
+ reason: `sequence gap: expected seq ${gap.expected}, found ${gap.found}`
4077
+ };
4078
+ const walk = new Walk(from.tally, from.digest);
4079
+ const passed = (await talliedAfter(tx, from.entry ?? -1n, walks.verify)).filter(({ seq }) => to === null || seq <= to);
4080
+ if (to !== null && passed.at(-1)?.seq !== to) return {
4081
+ ok: false,
4082
+ failedAtSeq: Number(to),
4083
+ reason: `a stretch passes no more than ${walks.verify} checkpoints: end one sooner`
4084
+ };
4085
+ const holding = /* @__PURE__ */ new Map();
4086
+ const carried = async (carrier) => {
4087
+ if (!holding.has(carrier)) {
4088
+ const entry = (await entriesAt(tx, [carrier])).get(carrier);
4089
+ const read = entry === void 0 ? "missing" : this.#tallied(entry);
4090
+ holding.set(carrier, typeof read === "string" || "at" in read.tally.access ? null : keptDigest(read.tally.access));
4091
+ }
4092
+ return holding.get(carrier);
4093
+ };
4094
+ const expected = [];
4095
+ let endSigned = null;
4096
+ for (let after = from.entry ?? -1n, last = passed.at(-1)?.seq; last !== void 0 && after < last;) {
4097
+ const batch = await talliedWithin(tx, after, last, walks.verify + 1);
4098
+ if (batch.length === 0) break;
4099
+ for (const entry of batch) {
4100
+ const read = this.#tallied(entry);
4101
+ const point = {
4102
+ entry: entry.seq,
4103
+ through: entry.relatedSeq
4104
+ };
4105
+ if (typeof read === "string") {
4106
+ expected.push({
4107
+ ...point,
4108
+ unreadable: read
4109
+ });
4110
+ continue;
4111
+ }
4112
+ if (entry.seq === to) endSigned = read.hash;
4113
+ const { access } = read.tally;
4114
+ if (!("at" in access)) holding.set(entry.seq, keptDigest(access));
4115
+ const kept = "at" in access ? BigInt(access.at) < entry.seq ? await carried(BigInt(access.at)) : null : holding.get(entry.seq);
4116
+ expected.push(kept === access.digest ? {
4117
+ ...point,
4118
+ key: expectedKey(read.tally)
4119
+ } : {
4120
+ ...point,
4121
+ unreadable: kept === null ? `its tally keeps its members and grants in entry ${"at" in access ? access.at : entry.seq}, which holds none` : "its tally's members and grants do not match their digest"
4122
+ });
4123
+ }
4124
+ after = batch.at(-1).seq;
4125
+ }
4126
+ walk.expect(expected);
4127
+ let through = from.at.seq;
4128
+ let reached = headOf(from.at).hash;
4129
+ let present = false;
4130
+ if (upTo === null) {
4131
+ const chain = await rehash(tx, logKeys, from.at, walk, ends === null ? { limit: walks.verify } : { to: ends });
4132
+ if (!chain.ok) return chain;
4133
+ [through, reached, present] = [
4134
+ chain.at.seq,
4135
+ headOf(chain.at).hash,
4136
+ chain.done && ends === null
4137
+ ];
4138
+ this.#prepared.verified = further(this.#prepared.verified, chain.at);
4139
+ } else {
4140
+ const own = await ownEntries(tx, logKeys, from.at.seq, walk, ends === null ? { limit: walks.verify } : { to: ends });
4141
+ if (!own.ok) return own;
4142
+ if (!own.done && input.from === void 0) return {
4143
+ ok: false,
4144
+ failedAtSeq: null,
4145
+ reason: `the vault verifies no more than ${walks.verify} of its entries a call, and its checkpoints are catching up: update the app`
4146
+ };
4147
+ const short = own.at < BigInt(upTo.seq) ? own.at : BigInt(upTo.seq) - 1n;
4148
+ [through, reached, present] = [
4149
+ own.done ? BigInt(upTo.seq) : short,
4150
+ upTo.hash,
4151
+ own.done && ends === null
4152
+ ];
4153
+ }
4154
+ walk.finish(ends ?? through);
4155
+ const rememberedHead = remembered.seq >= 0n ? headOf(remembered) : null;
4156
+ const heads = [...upTo === null ? [] : [upTo], ...rememberedHead === null ? [] : [rememberedHead]];
4157
+ const holds = await this.#holding(tx, [...heads, ...walk.checkpoints.map(({ checkpoint }) => checkpoint)]);
3237
4158
  if (upTo !== null && !holds(upTo)) return {
3238
4159
  ok: false,
3239
4160
  failedAtSeq: upTo.seq,
3240
4161
  reason: "not the entry the app verified up to: the log changed between the two checks"
3241
4162
  };
3242
- const unsigned = await this.#checkpointFault(findings, holds);
4163
+ const unsigned = await this.#checkpointFault(tx, walk.checkpoints, holds);
3243
4164
  if (unsigned !== null) return unsigned;
3244
4165
  if (rememberedHead !== null && !holds(rememberedHead)) return {
3245
4166
  ok: false,
3246
4167
  failedAtSeq: rememberedHead.seq,
3247
4168
  reason: "changed since the vault last verified it"
3248
4169
  };
4170
+ if (walk.mismatch !== null) return {
4171
+ ok: false,
4172
+ failedAtSeq: Number(walk.mismatch.entry),
4173
+ reason: walk.mismatch.reason
4174
+ };
4175
+ if (ends !== null) {
4176
+ if (upTo === null && through !== ends || endSigned !== reached) return {
4177
+ ok: false,
4178
+ failedAtSeq: Number(to),
4179
+ reason: `the log up to entry ${ends} is not the prefix this checkpoint signed: it was rewritten`
4180
+ };
4181
+ return {
4182
+ ok: true,
4183
+ entries: walk.entries,
4184
+ through: Number(ends)
4185
+ };
4186
+ }
4187
+ const standing = await this.#standingRefusal(tx);
4188
+ if (standing !== null) return {
4189
+ ok: false,
4190
+ failedAtSeq: standing.failedAtSeq ?? null,
4191
+ reason: `the last checkpoint was refused: ${standing.reason ?? "the log is broken"}`
4192
+ };
4193
+ const verified = {
4194
+ ok: true,
4195
+ entries: walk.entries,
4196
+ through: Number(through)
4197
+ };
4198
+ if (!present) return verified;
3249
4199
  const at = await this.#now(tx);
3250
- const accounting = findings.accounting.result(at);
4200
+ const accounting = walk.accounting.result(at);
3251
4201
  if (!accounting.ok) return accounting;
3252
4202
  const [rows, held] = await Promise.all([allMembers(tx), grants(tx)]);
3253
- const fault = this.#unsealed(rows, held, findings.newestAccess) ?? findings.replayFault ?? replayFault(findings.replayed, rows, held, at);
4203
+ const fault = this.#unsealed(rows, held, walk.newestAccess) ?? walk.replayFault ?? replayFault(walk.replayed, rows, held, at);
3254
4204
  if (fault !== null) return {
3255
4205
  ok: false,
3256
4206
  failedAtSeq: null,
3257
4207
  reason: describeAccessFault(fault),
3258
4208
  fault
3259
4209
  };
3260
- const verified = {
3261
- ok: true,
3262
- entries: findings.entries
3263
- };
3264
4210
  return accounting.pending === 0 ? verified : {
3265
4211
  ...verified,
3266
4212
  pending: accounting.pending
@@ -3268,55 +4214,15 @@ var VaultService = class {
3268
4214
  }, SNAPSHOT);
3269
4215
  }
3270
4216
  /**
3271
- * The vault's own entries, oldest first: each against its own hash, and by
3272
- * its MAC, over the link it names, which the app checked; and the vault's
3273
- * keys moving only forward through them.
3274
- */
3275
- async #ownEntries(tx, findings) {
3276
- const { logKeys } = this.#prepared;
3277
- let keys = [];
3278
- for (let after = -1n;;) {
3279
- const batch = await vaultEntriesAfter(tx, after, VERIFY_BATCH);
3280
- if (batch.length === 0) return { ok: true };
3281
- for (const entry of batch) {
3282
- const result = verifyEntries([entry], {
3283
- startSeq: entry.seq,
3284
- startPrevHash: entry.prevHash,
3285
- keys: logKeys
3286
- });
3287
- if (!result.ok) return {
3288
- ok: false,
3289
- failedAtSeq: Number(result.failedAtSeq),
3290
- reason: withCause(result.reason)
3291
- };
3292
- }
3293
- const moved = forward(batch, keys, logKeys[0].keyId);
3294
- if ("failedAtSeq" in moved) return {
3295
- ok: false,
3296
- failedAtSeq: Number(moved.failedAtSeq),
3297
- reason: moved.reason
3298
- };
3299
- keys = moved.keys;
3300
- for (const entry of batch) findings.add(entry);
3301
- after = batch[batch.length - 1].seq;
3302
- }
3303
- }
3304
- /** The whole chain from its first entry: every link and hash, the vault's MACs; the furthest verified is kept for checkpoints. */
3305
- async #rehashed(tx, findings) {
3306
- const { verification, anchor } = await verifyChain(tx, this.#prepared.logKeys, [], UNVERIFIED, (batch) => {
3307
- for (const entry of batch) if (entry.author === "vault") findings.add(entry);
3308
- });
3309
- if (!verification.ok) return verification;
3310
- this.#prepared.verified = further(this.#prepared.verified, anchor);
3311
- return { ok: true };
3312
- }
3313
- /**
3314
- * Every checkpoint, not only the newest: each signed by the key whose log
3315
- * key wrote its entry, over a prefix the log still holds (`holds`), entry
3316
- * for entry. Its entry's MAC is checked by now, and one under a key the
3317
- * vault replaced comes before the rotation.
4217
+ * Every checkpoint walked, not only the newest: each signed by the key
4218
+ * whose log key wrote its entry, over a prefix the log still holds,
4219
+ * entry for entry, which one that keeps a tally names as related. Its
4220
+ * entry's MAC is checked by now, and one under a key the vault replaced
4221
+ * comes before the rotation.
3318
4222
  */
3319
- async #checkpointFault(findings, holds) {
4223
+ async #checkpointFault(db, checkpoints, known) {
4224
+ if (checkpoints.length === 0) return null;
4225
+ const holds = known ?? await this.#holding(db, checkpoints.map(({ checkpoint }) => checkpoint));
3320
4226
  const { logKeys, signers } = this.#prepared;
3321
4227
  const verifiers = /* @__PURE__ */ new Map();
3322
4228
  const signed = (checkpoint, by) => {
@@ -3324,14 +4230,13 @@ var VaultService = class {
3324
4230
  if (verifier === void 0) verifiers.set(by.keyId, verifier = checkpointVerifier(by.publicKey));
3325
4231
  return verifier.then((verify) => verify(checkpoint));
3326
4232
  };
3327
- const { checkpoints } = findings;
3328
4233
  for (let from = 0; from < checkpoints.length; from += SIGNATURES_AT_ONCE) {
3329
4234
  const chunk = checkpoints.slice(from, from + SIGNATURES_AT_ONCE);
3330
4235
  const faults = await Promise.all(chunk.map(async ({ entry, checkpoint }) => {
3331
4236
  const by = signers[logKeys.findIndex((key) => key.keyId === entry.keyId)];
3332
4237
  if (by?.keyId !== checkpoint.keyId) return `a checkpoint signed under ${checkpoint.keyId}, in an entry written under ${entry.keyId}: each key signs only its own`;
3333
4238
  if (!await signed(checkpoint, by)) return "a checkpoint the vault did not sign";
3334
- if (BigInt(checkpoint.seq) >= entry.seq || !holds(checkpoint)) return `the log up to entry ${checkpoint.seq} is not the prefix this checkpoint signed: it was rewritten`;
4239
+ if (BigInt(checkpoint.seq) >= entry.seq || !holds(checkpoint) || entry.relatedSeq !== null && entry.relatedSeq !== BigInt(checkpoint.seq)) return `the log up to entry ${checkpoint.seq} is not the prefix this checkpoint signed: it was rewritten`;
3335
4240
  return null;
3336
4241
  }));
3337
4242
  const at = faults.findIndex((fault) => fault !== null);
@@ -3345,8 +4250,8 @@ var VaultService = class {
3345
4250
  }
3346
4251
  /**
3347
4252
  * The first member whose row fails its MAC, or names an older access
3348
- * entry than the log's newest about them (`newest`, from the full check's
3349
- * pass, where each entry's MAC was checked).
4253
+ * entry than the log's newest about them (`newest`, from the walk, where
4254
+ * each entry's MAC was checked, on from the tally).
3350
4255
  */
3351
4256
  #unsealed(rows, held, newest) {
3352
4257
  for (const row of rows) {
@@ -3365,44 +4270,71 @@ var VaultService = class {
3365
4270
  return null;
3366
4271
  }
3367
4272
  };
4273
+ /** Whether a statement was cancelled for taking too long: Postgres's `query_canceled`, a statement timeout among them. */
4274
+ function timedOut(error) {
4275
+ for (let cause = error; typeof cause === "object" && cause !== null; cause = cause.cause) if (cause.code === "57014") return true;
4276
+ return false;
4277
+ }
4278
+ const FIRST_ENTRY = {
4279
+ ok: true,
4280
+ entry: null,
4281
+ at: START,
4282
+ tally: EMPTY_TALLY,
4283
+ digest: accessDigest(EMPTY_TALLY.access),
4284
+ carrier: null,
4285
+ scrub: null
4286
+ };
3368
4287
  /**
3369
- * What one pass over the vault's entries gathers for the other checks of a
3370
- * full verification, so that the log is read once: the key batches, the
3371
- * access entries replayed, each member's newest, and the checkpoints. Each
3372
- * entry comes after its MAC was checked.
4288
+ * A walk rehashes, MACs and replays some 40 µs of CPU an entry, and 95
4289
+ * with V8's optimizer off, as on a cold isolate: 10,000 entries is a beat
4290
+ * of catching up, about a second, and a team's instance writes fewer than
4291
+ * a thousand a day. The rehash of the past is a hash an entry, 14 µs warm
4292
+ * and 55 cold, beside two reads: 100 entries keep it near 6 ms a beat.
3373
4293
  */
3374
- var Findings = class {
3375
- accounting = new KeyAccounting();
3376
- replayed = {
3377
- members: /* @__PURE__ */ new Map(),
3378
- held: /* @__PURE__ */ new Map()
3379
- };
3380
- /** The first access entry the replay could not apply. */
3381
- replayFault = null;
3382
- /** Each member's newest allowed access entry, by seq. */
3383
- newestAccess = /* @__PURE__ */ new Map();
3384
- /** Every allowed checkpoint, with its entry's seq and key: tens of thousands a year, so nothing more of it. */
3385
- checkpoints = [];
3386
- /** The vault's entries read. */
3387
- entries = 0;
3388
- add(entry) {
3389
- this.entries += 1;
3390
- this.accounting.add(entry);
3391
- if (entry.decision !== "allow") return;
3392
- if (ACCESS.has(entry.action)) {
3393
- this.replayFault ??= apply(this.replayed, entry);
3394
- if (entry.subjectPrincipal !== null) this.newestAccess.set(entry.subjectPrincipal, entry.seq);
3395
- } else if (entry.action === CHECKPOINT) this.checkpoints.push({
3396
- entry: {
3397
- seq: entry.seq,
3398
- keyId: entry.keyId
3399
- },
3400
- checkpoint: JSON.parse(entry.metadata)
3401
- });
3402
- }
4294
+ const WALKS = {
4295
+ checkpoint: 1e4,
4296
+ scrub: 100,
4297
+ verify: 1e4
3403
4298
  };
3404
- const ACCESS = new Set(ACCESS_ACTIONS);
3405
- /** Checkpoint signatures checked at once, in a full verification. */
4299
+ /** The digest of the members and grants a checkpoint keeps whole, beside the digest it says they have. */
4300
+ function keptDigest({ members, grants, newest }) {
4301
+ return accessDigest({
4302
+ members,
4303
+ grants,
4304
+ newest
4305
+ });
4306
+ }
4307
+ /** Each checkpoint entry's metadata, parsed once however many times it is asked about: one may hold every member and grant. */
4308
+ const parsed = /* @__PURE__ */ new WeakMap();
4309
+ function metadataOf(entry) {
4310
+ if (!parsed.has(entry)) parsed.set(entry, JSON.parse(entry.metadata));
4311
+ return parsed.get(entry);
4312
+ }
4313
+ /** A head, as an entry keeps it, as a walk goes on from it. */
4314
+ function positionOf(head) {
4315
+ return {
4316
+ seq: BigInt(head.seq),
4317
+ hash: Buffer.from(head.hash, "hex")
4318
+ };
4319
+ }
4320
+ /** A position as the log's heads are named, in hex. */
4321
+ function headOf(position) {
4322
+ return {
4323
+ seq: Number(position.seq),
4324
+ hash: position.hash.toString("hex")
4325
+ };
4326
+ }
4327
+ /** What a checkpoint's signature covers, and nothing it keeps beside it. */
4328
+ function signedPart({ seq, hash, signedAt, keyId, signature }) {
4329
+ return {
4330
+ seq,
4331
+ hash,
4332
+ signedAt,
4333
+ keyId,
4334
+ signature
4335
+ };
4336
+ }
4337
+ /** Checkpoint signatures checked at once. */
3406
4338
  const SIGNATURES_AT_ONCE = 256;
3407
4339
  /** Settle every operation before releasing the member lock, including cancelled requests. */
3408
4340
  async function settle(operations, budgetMs) {
@@ -3729,7 +4661,7 @@ function oneRead(group) {
3729
4661
  }
3730
4662
  /** Whether `entry` changes a member's access: what their row's `access_seq` names. */
3731
4663
  function isAccessEntry(entry) {
3732
- return entry.decision === "allow" && entry.subjectPrincipal !== void 0 && entry.subjectPrincipal !== null && ACCESS_ACTIONS.includes(entry.action);
4664
+ return entry.decision === "allow" && entry.subjectPrincipal !== void 0 && entry.subjectPrincipal !== null && ACCESS_ACTIONS$1.includes(entry.action);
3733
4665
  }
3734
4666
  /** An entry about a member's access. */
3735
4667
  function accessEntry(actor, action, principal, decision, correlation, detail, code = null) {
@@ -3833,8 +4765,7 @@ function readEdits(stored) {
3833
4765
  * keeps its slug, which a scoped instance role's filters read. The edits
3834
4766
  * keep them all: no write is needed.
3835
4767
  */
3836
- async function livePolicy(db, edits, at) {
3837
- const rows = await environmentsById(db, edits.protected.map((place) => place.environmentId));
4768
+ function livePolicy(rows, edits, at) {
3838
4769
  const live = edits.protected.filter((place) => {
3839
4770
  const row = rows.get(place.environmentId);
3840
4771
  return row === void 0 ? at - place.at < UNWRITTEN_FORK_MS : !row.deleted;