@coffre/vault 0.10.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffre/vault",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "description": "coffre's vault: keys, grants and members, in the database the app uses, as a Cloudflare Worker or on Node.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,8 +36,8 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "drizzle-orm": "0.45.2",
39
- "@coffre/core": "0.10.0",
40
- "@coffre/db": "0.10.0"
39
+ "@coffre/core": "0.10.1",
40
+ "@coffre/db": "0.10.1"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "26.1.1",
package/src/accounting.ts CHANGED
@@ -7,19 +7,48 @@ type Item = { item: number; secretId: string; version: number; subject: string }
7
7
  type ParsedIntent = { intentId: string; operation: string; expiresAt: number; keys: Item[] };
8
8
  /** An outcome entry: one item of its intent, or several, for a read logged once per place. */
9
9
  type ParsedOutcome = { intentId: string; relatedSeq: bigint; items: Item[] };
10
- type Intent = ParsedIntent & { entry: StoredEntry; seen: Set<number> };
10
+ type Intent = ParsedIntent & { seq: bigint; actor: string; seen: Set<number> };
11
11
  type Accounting = { ok: true; pending: number } | Extract<LogVerification, { ok: false }>;
12
+ type Broken = Extract<Accounting, { ok: false }>;
13
+
14
+ /** A key batch still under way, as a checkpoint keeps it: its intent's entry, and the items whose outcomes are logged. */
15
+ export type OpenIntent = { seq: number; intentId: string; operation: string; expiresAt: number; actor: string; keys: Item[]; seen: number[] };
16
+
17
+ /** Where the accounting stands, as a checkpoint keeps it: the batches still open, and the first fault, once found. */
18
+ export type AccountingState = { intents: OpenIntent[]; fault: { failedAtSeq: number; reason: string } | null };
12
19
 
13
20
  /**
14
21
  * Every key operation's intent and its outcomes, entry by entry, oldest
15
- * first, as the full check reads the vault's entries (`add`); then what is
16
- * still open (`result`). The chain can hold while a process died between an
22
+ * first, as a walk reads the vault's entries (`add`); then what is still
23
+ * open (`result`). The chain can hold while a process died between an
17
24
  * intent and its outcomes: only a missed deadline is a fault.
25
+ *
26
+ * A walk resumes from a checkpoint's `state()`: the batches open there, and
27
+ * nothing of those that closed. So an intent's identity is checked against
28
+ * those still open and those since, not every one the log ever held: a
29
+ * closed batch takes no more outcomes in any case, since each names its
30
+ * intent's entry.
18
31
  */
19
32
  export class KeyAccounting {
20
33
  readonly #intents = new Map<bigint, Intent>();
21
34
  readonly #ids = new Set<string>();
22
- #fault: Extract<Accounting, { ok: false }> | null = null;
35
+ #fault: Broken | null = null;
36
+
37
+ constructor(from: AccountingState = { intents: [], fault: null }) {
38
+ for (const { seq, seen, ...open } of from.intents) {
39
+ this.#intents.set(BigInt(seq), { ...open, seq: BigInt(seq), seen: new Set(seen) });
40
+ this.#ids.add(open.intentId);
41
+ }
42
+ this.#fault = from.fault === null ? null : { ok: false, ...from.fault };
43
+ }
44
+
45
+ /** Where it stands, to resume from: the batches open, oldest first. */
46
+ state(): AccountingState {
47
+ const intents = [...this.#intents.values()]
48
+ .sort((a, b) => (a.seq < b.seq ? -1 : 1))
49
+ .map(({ seq, seen, ...open }) => ({ ...open, seq: Number(seq), seen: [...seen].sort((a, b) => a - b) }));
50
+ return { intents, fault: this.#fault === null ? null : { failedAtSeq: this.#fault.failedAtSeq!, reason: this.#fault.reason } };
51
+ }
23
52
 
24
53
  add(entry: StoredEntry): void {
25
54
  if (this.#fault !== null || entry.author !== 'vault') return;
@@ -31,7 +60,7 @@ export class KeyAccounting {
31
60
  if (typeof parsed === 'string') return broken(parsed);
32
61
  if (this.#ids.has(parsed.intentId)) return broken('key intent repeats an operation identity');
33
62
  this.#ids.add(parsed.intentId);
34
- if (parsed.keys.length > 0) this.#intents.set(entry.seq, { ...parsed, entry, seen: new Set() });
63
+ if (parsed.keys.length > 0) this.#intents.set(entry.seq, { ...parsed, seq: entry.seq, actor: entry.actor, seen: new Set() });
35
64
  } else if (KEY_ACTIONS.has(entry.action) && entry.relatedSeq !== null) {
36
65
  // An outcome names its intent; a key released with no service to call has none.
37
66
  const parsed = parseOutcome(entry);
@@ -42,7 +71,7 @@ export class KeyAccounting {
42
71
  if (intent === undefined || key === undefined || intent.seen.has(outcome.item)) {
43
72
  return broken('key outcome does not identify one item of its intent');
44
73
  }
45
- if (parsed.intentId !== intent.intentId || entry.actor !== intent.entry.actor || entry.action !== intent.operation) {
74
+ if (parsed.intentId !== intent.intentId || entry.actor !== intent.actor || entry.action !== intent.operation) {
46
75
  return broken('key outcome belongs to another operation');
47
76
  }
48
77
  if (outcome.secretId !== key.secretId || outcome.version !== key.version || outcome.subject !== key.subject) {
@@ -51,7 +80,7 @@ export class KeyAccounting {
51
80
  intent.seen.add(outcome.item);
52
81
  }
53
82
  // Completed batches need no state, and another outcome for one is a duplicate.
54
- if (intent !== undefined && intent.seen.size === intent.keys.length) this.#intents.delete(intent.entry.seq);
83
+ if (intent !== undefined && intent.seen.size === intent.keys.length) this.#intents.delete(intent.seq);
55
84
  }
56
85
  }
57
86
 
@@ -65,7 +94,7 @@ export class KeyAccounting {
65
94
  const missing = intent.keys.length - intent.seen.size;
66
95
  return `key intent ${intent.intentId} is overdue: ${missing} of ${intent.keys.length} outcomes missing`;
67
96
  }).join('; ');
68
- return { ok: false, failedAtSeq: Number(overdue[0]!.entry.seq), reason };
97
+ return { ok: false, failedAtSeq: Number(overdue[0]!.seq), reason };
69
98
  }
70
99
  }
71
100
 
package/src/log.ts CHANGED
@@ -2,7 +2,8 @@ import { deriveLogKey, GENESIS_HASH, verifyEntries, type LogKey, type StoredEntr
2
2
  import type { LogHead, LogVerification } from '@coffre/core/vault';
3
3
  import type { Queryable } from '@coffre/db';
4
4
 
5
- import { entriesFrom, hashAt } from './store.ts';
5
+ import { entriesFrom, hashAt, vaultEntriesAfter } from './store.ts';
6
+ import type { Walk } from './tally.ts';
6
7
 
7
8
  /**
8
9
  * The vault's half of the one audit log: its entries are the ones it
@@ -21,83 +22,113 @@ export function vaultLogKey(signingKey: Uint8Array): LogKey {
21
22
  return deriveLogKey('vault', signingKey);
22
23
  }
23
24
 
24
- /**
25
- * How far this process has verified the chain: every entry before
26
- * `nextSeq`, the last of which has `hash`, with `vaultEntries` of the
27
- * vault's among them, written under `vaultKeys`, in the order the log moved
28
- * through them (`forward`). In memory only: the database cannot vouch for
29
- * itself.
30
- */
31
- export type Anchor = { nextSeq: bigint; hash: Buffer; vaultEntries: number; vaultKeys: readonly string[] };
25
+ /** Where a walk of the log stands: the last entry it covered, -1 before the first, and that entry's hash. */
26
+ export type Position = { seq: bigint; hash: Buffer };
32
27
 
33
- /** Before the first entry: nothing verified yet. */
34
- export const UNVERIFIED: Anchor = { nextSeq: 0n, hash: GENESIS_HASH, vaultEntries: 0, vaultKeys: [] };
28
+ /** Before the first entry. */
29
+ export const START: Position = { seq: -1n, hash: GENESIS_HASH };
35
30
 
36
- /** The further of two anchors, when two calls verified at once. */
37
- export function further(a: Anchor, b: Anchor): Anchor {
38
- return b.nextSeq > a.nextSeq ? b : a;
31
+ /** The further of two positions, when two calls walked at once. */
32
+ export function further(a: Position, b: Position): Position {
33
+ return b.seq > a.seq ? b : a;
39
34
  }
40
35
 
41
36
  /** 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. */
42
37
  export const VERIFY_BATCH = 5000;
43
38
 
44
- type Verified = { verification: LogVerification; anchor: Anchor };
39
+ type Broken = Extract<LogVerification, { ok: false }>;
45
40
 
46
41
  /**
47
- * Check the chain, a batch in memory at a time, and return where it is now
48
- * verified to. Three parts:
42
+ * Walk the chain on from `from`, a batch in memory at a time, through `to`
43
+ * or the log's last entry, and no more than `limit` entries: every link and
44
+ * hash recomputed, each of the vault's entries by its MAC, its keys moving
45
+ * only forward, and each fed to `walk`. Where it got to, and whether that
46
+ * is as far as it was to go: a walk cut short by `limit` goes on from there
47
+ * another time.
49
48
  *
50
- * - `shown`, the page a reader is looking at: each entry against its own
51
- * hash and MAC;
52
- * - `anchor`, the head at the last check: still there, unchanged. A rewrite
53
- * of anything before it, chained again to hide, changes its hash;
54
- * - every entry after the anchor, or from the first.
55
- *
56
- * So a view rehashes only what is new since the last one. What it leaves
57
- * out is an entry before the anchor edited in place, not chained again, and
58
- * not on the page: a full check, which starts from `UNVERIFIED`, finds that,
59
- * as does the first view after a start, which has no anchor. `onBatch` sees
60
- * each batch once it has verified, for checks that read the same entries.
49
+ * It takes `from` as given: whether the log still holds it is the caller's
50
+ * to check, with the other heads it must hold, in one read.
61
51
  */
62
- export async function verifyChain(
52
+ export async function rehash(
63
53
  db: Queryable,
64
54
  logKeys: readonly LogKey[],
65
- shown: readonly StoredEntry[],
66
- anchor: Anchor,
67
- onBatch: (batch: readonly StoredEntry[]) => void = () => {},
68
- ): Promise<Verified> {
69
- const broken = (failedAtSeq: bigint, reason: string): Verified => ({
70
- verification: { ok: false, failedAtSeq: Number(failedAtSeq), reason: withCause(reason) },
71
- anchor,
72
- });
73
- const keys = { keys: logKeys, chainOnly: ['app' as const] };
74
-
75
- for (const row of [...shown].sort((a, b) => (a.seq < b.seq ? -1 : 1))) {
76
- const result = verifyEntries([row], { ...keys, startSeq: row.seq, startPrevHash: row.prevHash });
77
- if (!result.ok) return broken(result.failedAtSeq, result.reason);
55
+ from: Position,
56
+ walk: Walk,
57
+ { to, limit = Infinity }: { to?: bigint; limit?: number },
58
+ ): Promise<{ ok: true; at: Position; done: boolean } | Broken> {
59
+ let at = from;
60
+ for (let left = limit; ; ) {
61
+ if (to !== undefined && at.seq >= to) return { ok: true, at, done: true };
62
+ // Out of entries to walk, it is done if there are none left to: a walk that ends at its budget's last read one more.
63
+ if (left <= 0) return { ok: true, at, done: !(await entriesFrom(db, at.seq + 1n, 1)).some((entry) => to === undefined || entry.seq <= to) };
64
+ let batch = await entriesFrom(db, at.seq + 1n, Math.min(VERIFY_BATCH, left));
65
+ if (to !== undefined) batch = batch.filter((entry) => entry.seq <= to);
66
+ if (batch.length === 0) return { ok: true, at, done: to === undefined };
67
+ const result = verifyEntries(batch, { keys: logKeys, chainOnly: ['app'], startSeq: at.seq + 1n, startPrevHash: at.hash });
68
+ if (!result.ok) return { ok: false, failedAtSeq: Number(result.failedAtSeq), reason: withCause(result.reason) };
69
+ for (const entry of batch) {
70
+ const moved = entry.author === 'vault' ? walk.add(entry, logKeys[0]!.keyId) : null;
71
+ if (moved !== null) return { ok: false, failedAtSeq: Number(moved.failedAtSeq), reason: moved.reason };
72
+ }
73
+ at = { seq: result.nextSeq - 1n, hash: result.head };
74
+ left -= batch.length;
78
75
  }
76
+ }
79
77
 
80
- if (anchor.nextSeq > 0n && !(await hashAt(db, anchor.nextSeq - 1n))?.equals(anchor.hash)) {
81
- return broken(anchor.nextSeq - 1n, 'changed since the vault last verified it');
78
+ /**
79
+ * The chain again from `from`, through `to` and no more than `limit`
80
+ * entries: each hash recomputed from its entry and each link checked, and
81
+ * nothing else, so a stretch costs a hash an entry. What a checkpoint does
82
+ * in the background to the prefixes signed before (`#scrub` in vault.ts):
83
+ * each entry's MAC was checked when it was signed over, and its hash pins
84
+ * it since, so all that can change behind a signed prefix and keep the
85
+ * chain is an entry edited in place, its stored hash left, which its hash
86
+ * finds.
87
+ */
88
+ export async function relink(db: Queryable, from: Position, to: bigint, limit: number): Promise<{ ok: true; at: Position } | Broken> {
89
+ let at = from;
90
+ while (at.seq < to && limit > 0) {
91
+ const batch = (await entriesFrom(db, at.seq + 1n, Math.min(VERIFY_BATCH, limit))).filter((entry) => entry.seq <= to);
92
+ if (batch.length === 0) break;
93
+ const result = verifyEntries(batch, { keys: [], chainOnly: ['app', 'vault'], startSeq: at.seq + 1n, startPrevHash: at.hash });
94
+ if (!result.ok) return { ok: false, failedAtSeq: Number(result.failedAtSeq), reason: result.reason };
95
+ at = { seq: result.nextSeq - 1n, hash: result.head };
96
+ limit -= batch.length;
82
97
  }
98
+ return { ok: true, at };
99
+ }
83
100
 
84
- let verified = anchor;
85
- for (;;) {
86
- const batch = await entriesFrom(db, verified.nextSeq, VERIFY_BATCH);
87
- if (batch.length === 0) break;
88
- const result = verifyEntries(batch, { ...keys, startSeq: verified.nextSeq, startPrevHash: verified.hash });
89
- if (!result.ok) return broken(result.failedAtSeq, result.reason);
90
- const moved = forward(batch, verified.vaultKeys, logKeys[0].keyId);
91
- if ('failedAtSeq' in moved) return broken(moved.failedAtSeq, moved.reason);
92
- onBatch(batch);
93
- verified = {
94
- nextSeq: result.nextSeq,
95
- hash: result.head,
96
- vaultEntries: verified.vaultEntries + result.authenticated,
97
- vaultKeys: moved.keys,
98
- };
101
+ /**
102
+ * The vault's own entries after `after`, through `to` or its last, no more
103
+ * than `limit` of them, oldest first: each against its own hash, and by its
104
+ * MAC, over the link it names, its keys moving only forward, each fed to
105
+ * `walk`. The links between entries are the caller's to recompute, as the
106
+ * app does over the same stretch. The last it read, and whether that is as
107
+ * far as it was to go.
108
+ */
109
+ export async function ownEntries(
110
+ db: Queryable,
111
+ logKeys: readonly LogKey[],
112
+ after: bigint,
113
+ walk: Walk,
114
+ { to, limit = Infinity }: { to?: bigint; limit?: number },
115
+ ): Promise<{ ok: true; at: bigint; done: boolean } | Broken> {
116
+ for (let left = limit; ; ) {
117
+ if (left <= 0) return { ok: true, at: after, done: !(await vaultEntriesAfter(db, after, 1)).some((entry) => to === undefined || entry.seq <= to) };
118
+ let batch = await vaultEntriesAfter(db, after, Math.min(VERIFY_BATCH, left));
119
+ if (to !== undefined) batch = batch.filter((entry) => entry.seq <= to);
120
+ if (batch.length === 0) return { ok: true, at: after, done: true };
121
+ for (const entry of batch) {
122
+ const result = verifyEntries([entry], { startSeq: entry.seq, startPrevHash: entry.prevHash, keys: logKeys });
123
+ if (!result.ok) return { ok: false, failedAtSeq: Number(result.failedAtSeq), reason: withCause(result.reason) };
124
+ }
125
+ for (const entry of batch) {
126
+ const moved = walk.add(entry, logKeys[0]!.keyId);
127
+ if (moved !== null) return { ok: false, failedAtSeq: Number(moved.failedAtSeq), reason: moved.reason };
128
+ }
129
+ after = batch[batch.length - 1]!.seq;
130
+ left -= batch.length;
99
131
  }
100
- return { verification: { ok: true, entries: verified.vaultEntries }, anchor: verified };
101
132
  }
102
133
 
103
134
  /**
package/src/replay.ts CHANGED
@@ -141,7 +141,7 @@ function placeOf(row: StoredEntry, detail: Record<string, unknown>): Place {
141
141
  }
142
142
 
143
143
  /** A grant's place: its environment, or its project when it has none, or every project's path. */
144
- function placeKey(place: Place): string {
144
+ export function placeKey(place: Place): string {
145
145
  return place.environmentId ?? place.projectId ?? (place.environmentSlug === null ? '*' : `*/${place.environmentSlug}`);
146
146
  }
147
147
 
package/src/store.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { EVERYWHERE, ownsInstance, type InstanceRole, type Scope } from '@coffre/core/access';
2
2
  import type { Author, StoredEntry } from '@coffre/core/audit';
3
3
  import { ACCESS_ACTIONS, type SecretRef, type WrappedKey } from '@coffre/core/vault';
4
- import { tablesOf, type Queryable, type Transaction } from '@coffre/db';
4
+ import { prepared, tablesOf, type Queryable, type Transaction } from '@coffre/db';
5
5
  import { clockMillis, engineOf, forUpdate, tombstone, truth } from '@coffre/db/dialect';
6
6
  import { readGrants, roleColumns, storedRole, type GrantRow } from '@coffre/db/grants';
7
7
  import { entryColumns, withinBytes } from '@coffre/db/log';
@@ -61,11 +61,12 @@ export async function boundLockWaits(tx: Transaction, ms: number): Promise<void>
61
61
 
62
62
  /** The database's clock, now. */
63
63
  export async function now(db: Queryable): Promise<number> {
64
- const { auditChainHead } = tablesOf(db);
65
- const [{ at }] = await db.select({ at: clockMillis(db) }).from(auditChainHead);
64
+ const [{ at }] = await clock(db);
66
65
  return at;
67
66
  }
68
67
 
68
+ const clock = prepared((db) => db.select({ at: clockMillis(db) }).from(tablesOf(db).auditChainHead));
69
+
69
70
  // --- members --------------------------------------------------------------------
70
71
 
71
72
  function memberColumns(db: Queryable) {
@@ -101,11 +102,15 @@ function storedChange(change: Partial<Omit<Member, 'principal'>>): Record<string
101
102
  }
102
103
 
103
104
  export async function member(db: Queryable, principal: string): Promise<Member | undefined> {
104
- const { vaultMembers } = tablesOf(db);
105
- const [row] = await db.select(memberColumns(db)).from(vaultMembers).where(eq(vaultMembers.principal, principal));
105
+ const [row] = await memberRow(db, { principal });
106
106
  return row === undefined ? undefined : memberOf(row as StoredMember);
107
107
  }
108
108
 
109
+ const memberRow = prepared((db) => {
110
+ const { vaultMembers } = tablesOf(db);
111
+ return db.select(memberColumns(db)).from(vaultMembers).where(eq(vaultMembers.principal, sql.placeholder('principal')));
112
+ });
113
+
109
114
  export async function allMembers(db: Queryable): Promise<Member[]> {
110
115
  const { vaultMembers } = tablesOf(db);
111
116
  return ((await db.select(memberColumns(db)).from(vaultMembers).orderBy(asc(vaultMembers.principal))) as StoredMember[]).map(memberOf);
@@ -312,24 +317,28 @@ export const RELEASE = 'secret.read';
312
317
  * several (`secrets`), a read logged once per place.
313
318
  */
314
319
  export async function releasesSince(db: Queryable, principal: string, after: number): Promise<number> {
320
+ const [{ n }] = await released(db, { principal, after });
321
+ return Number(n ?? 0);
322
+ }
323
+
324
+ const released = prepared((db) => {
315
325
  const { auditLog } = tablesOf(db);
316
326
  const listed = engineOf(db) === 'postgres'
317
327
  ? sql`jsonb_array_length((${auditLog.metadata})::jsonb -> 'secrets')`
318
328
  : sql`json_array_length(${auditLog.metadata}, '$.secrets')`;
319
- const [{ n }] = await db
329
+ return db
320
330
  .select({ n: sql<string | number | null>`SUM(COALESCE(${listed}, 1))` })
321
331
  .from(auditLog)
322
332
  .where(
323
333
  and(
324
334
  eq(auditLog.author, 'vault'),
325
- eq(auditLog.actor, principal),
335
+ eq(auditLog.actor, sql.placeholder('principal')),
326
336
  eq(auditLog.action, RELEASE),
327
337
  eq(auditLog.decision, 'allow'),
328
- gt(auditLog.occurredAt, after),
338
+ gt(auditLog.occurredAt, sql.placeholder('after')),
329
339
  ),
330
340
  );
331
- return Number(n ?? 0);
332
- }
341
+ });
333
342
 
334
343
  /** Rows of the log as the chain's codec reads them: `author` and `decision` are checked by the table. */
335
344
  function stored(rows: object[]): StoredEntry[] {
@@ -380,17 +389,6 @@ export async function hashesAt(db: Queryable, seqs: readonly bigint[]): Promise<
380
389
  /** How many entries' hashes one query asks for: a checkpoint every five minutes is tens of thousands of a year. */
381
390
  const HASHES_AT_ONCE = 5000;
382
391
 
383
- /**
384
- * Whether every entry from the first through `seq` is there. Seqs are the
385
- * primary key, so the count is `seq + 1` exactly when none is missing: one
386
- * pass over the key's index, never the entries.
387
- */
388
- export async function complete(db: Queryable, seq: bigint): Promise<boolean> {
389
- const { auditLog } = tablesOf(db);
390
- const [{ n }] = await db.select({ n: count() }).from(auditLog).where(and(gte(auditLog.seq, 0n), lte(auditLog.seq, seq)));
391
- return BigInt(n) === seq + 1n;
392
- }
393
-
394
392
  /** The entry at `seq`'s hash, or undefined when there is none. */
395
393
  export async function hashAt(db: Queryable, seq: bigint): Promise<Buffer | undefined> {
396
394
  const { auditLog } = tablesOf(db);
@@ -537,6 +535,82 @@ export async function latestVaultEntry(db: Queryable, actions: readonly string[]
537
535
  return row === undefined ? undefined : stored([row])[0];
538
536
  }
539
537
 
538
+ /**
539
+ * The vault's newest checkpoint that keeps a tally, allowed, or undefined:
540
+ * one written since 0.10.1, which names the last entry of the prefix it
541
+ * signs as related (`related_seq`); those before name none.
542
+ */
543
+ export async function latestTallied(db: Queryable): Promise<StoredEntry | undefined> {
544
+ const { auditLog } = tablesOf(db);
545
+ const [row] = await db
546
+ .select(entryColumns(db))
547
+ .from(auditLog)
548
+ .where(and(eq(auditLog.author, 'vault'), eq(auditLog.action, 'audit.checkpoint'), eq(auditLog.decision, 'allow'), isNotNull(auditLog.relatedSeq)))
549
+ .orderBy(desc(auditLog.seq))
550
+ .limit(1);
551
+ return row === undefined ? undefined : stored([row])[0];
552
+ }
553
+
554
+ /** The vault's newest `audit.checkpoint` entry, signed or refused, or undefined. */
555
+ export async function newestCheckpointEntry(db: Queryable): Promise<StoredEntry | undefined> {
556
+ const { auditLog } = tablesOf(db);
557
+ const [row] = await db
558
+ .select(entryColumns(db))
559
+ .from(auditLog)
560
+ .where(and(eq(auditLog.author, 'vault'), eq(auditLog.action, 'audit.checkpoint')))
561
+ .orderBy(desc(auditLog.seq))
562
+ .limit(1);
563
+ return row === undefined ? undefined : stored([row])[0];
564
+ }
565
+
566
+ /**
567
+ * The vault's checkpoints that keep a tally after entry `after` and through
568
+ * `through`, oldest first, whole: as many as `BATCH_BYTES` of what they say
569
+ * holds, but always one. A reader asks again after the last.
570
+ */
571
+ export async function talliedWithin(db: Queryable, after: bigint, through: bigint, limit: number): Promise<StoredEntry[]> {
572
+ const { auditLog } = tablesOf(db);
573
+ const tallied = and(
574
+ eq(auditLog.author, 'vault'), eq(auditLog.action, 'audit.checkpoint'), eq(auditLog.decision, 'allow'), isNotNull(auditLog.relatedSeq),
575
+ gt(auditLog.seq, after), lte(auditLog.seq, through),
576
+ );
577
+ const batch = await withinBytes(db, tallied, limit);
578
+ if (batch === null) return [];
579
+ const rows = await db.select(entryColumns(db)).from(auditLog)
580
+ .where(and(tallied, gte(auditLog.seq, batch.from), lte(auditLog.seq, batch.to))).orderBy(asc(auditLog.seq));
581
+ return stored(rows);
582
+ }
583
+
584
+ /** The vault's newest refusal of `action` with `code` after entry `after`, or undefined: one range of the (author, action, seq) index. */
585
+ export async function newestRefusal(db: Queryable, action: string, code: string, after: bigint): Promise<StoredEntry | undefined> {
586
+ const { auditLog } = tablesOf(db);
587
+ const [row] = await db
588
+ .select(entryColumns(db))
589
+ .from(auditLog)
590
+ .where(and(eq(auditLog.author, 'vault'), eq(auditLog.action, action), gt(auditLog.seq, after), eq(auditLog.decision, 'deny'), eq(auditLog.code, code)))
591
+ .orderBy(desc(auditLog.seq))
592
+ .limit(1);
593
+ return row === undefined ? undefined : stored([row])[0];
594
+ }
595
+
596
+ /** 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. */
597
+ export async function talliedAfter(db: Queryable, after: bigint, limit: number): Promise<{ seq: bigint; signs: bigint }[]> {
598
+ const { auditLog } = tablesOf(db);
599
+ const rows = await db
600
+ .select({ seq: auditLog.seq, signs: auditLog.relatedSeq })
601
+ .from(auditLog)
602
+ .where(and(
603
+ eq(auditLog.author, 'vault'),
604
+ eq(auditLog.action, 'audit.checkpoint'),
605
+ eq(auditLog.decision, 'allow'),
606
+ isNotNull(auditLog.relatedSeq),
607
+ gt(auditLog.seq, after),
608
+ ))
609
+ .orderBy(asc(auditLog.seq))
610
+ .limit(limit);
611
+ return rows.map(({ seq, signs }) => ({ seq, signs: signs! }));
612
+ }
613
+
540
614
  /** The vault's newest allowed access entry about each member who has one. */
541
615
  export async function newestAccessEntries(db: Queryable): Promise<Map<string, StoredEntry>> {
542
616
  const { auditLog } = tablesOf(db);