@coffre/vault 0.14.5 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +6 -4
  2. package/dist/cloudflare.d.ts +341 -4
  3. package/dist/cloudflare.js +802 -6
  4. package/dist/index.js +1 -1
  5. package/dist/log-entry-BYWqj3O5.d.ts +364 -0
  6. package/dist/node.d.ts +181 -6
  7. package/dist/node.js +358 -16
  8. package/dist/{src-Bg0ZeMJW.js → src-DheG92EM.js} +1 -1
  9. package/dist/vault-kmtojYnW.js +14623 -0
  10. package/package.json +4 -5
  11. package/src/bulk.ts +89 -0
  12. package/src/changes.ts +478 -0
  13. package/src/checkpointer-node.ts +342 -0
  14. package/src/checkpointer-workers.ts +514 -0
  15. package/src/checkpointer.ts +1165 -0
  16. package/src/cloudflare.ts +207 -13
  17. package/src/config.ts +3 -2
  18. package/src/credentials.postgres.ts +78 -0
  19. package/src/credentials.sqlite.ts +240 -0
  20. package/src/credentials.ts +630 -0
  21. package/src/cutover.ts +275 -0
  22. package/src/decide.ts +437 -2
  23. package/src/inputs.ts +499 -0
  24. package/src/local.ts +20 -4
  25. package/src/log-checkpointer.ts +116 -0
  26. package/src/log-entry.ts +113 -0
  27. package/src/log-replay.ts +188 -0
  28. package/src/log-store.ts +695 -0
  29. package/src/node.ts +73 -9
  30. package/src/objects.ts +141 -0
  31. package/src/reads.ts +502 -0
  32. package/src/retention.ts +229 -0
  33. package/src/snapshot.postgres.ts +39 -9
  34. package/src/snapshot.sqlite.ts +28 -3
  35. package/src/snapshot.ts +64 -10
  36. package/src/store.ts +832 -36
  37. package/src/use.postgres.ts +111 -0
  38. package/src/use.sqlite.ts +542 -0
  39. package/src/use.ts +1060 -0
  40. package/src/vault.ts +1366 -220
  41. package/src/verifier.ts +136 -0
  42. package/src/witness-workers.ts +163 -0
  43. package/dist/vault-Cbi45kLB.js +0 -5197
@@ -0,0 +1,229 @@
1
+ import { leafData, openPair, proveConsistency, TREES, verifyConsistency, type Cut, type LogEntry, type StoredPair } from '@coffre/core/merkle';
2
+
3
+ import { FenceClosed, type Checkpointer, type CheckpointStore } from './checkpointer.ts';
4
+ import { entryMac, RETENTION_ACTOR, RETENTION_OBJECT, retentionCut, retentionPayload, type LogMacKey } from './log-entry.ts';
5
+ import { cursorHolds, type ReadsCursor } from './reads.ts';
6
+
7
+ /**
8
+ * Retention (docs/design/0.15-plan.md, PR 7; formal/Seal.tla's retention):
9
+ * days of uses dropped once a sealed retention change cuts the uses tree
10
+ * above them, and the uses tree's tiles below the cut, the one holding it at
11
+ * each level kept. Changes are kept for good: the changes tree is never cut.
12
+ *
13
+ * A day goes in these steps, oldest first, each a precondition of the next:
14
+ * 1. Its cut: the first pair whose floor of `at` is past the day's end. Every
15
+ * use of the day took its number at or below it (a use's `at` is held to
16
+ * the floor), so all of them lie below the cut; a few of the next day's
17
+ * do too (stragglers), and stay until their own day goes. The cut must be
18
+ * a pair the signer signed, the first past the day's end by its own note
19
+ * (its `prev`, the floor of the pair signed before it, below the end:
20
+ * CutFirst), on its history (a consistency proof to its head in both
21
+ * trees), at or below its head.
22
+ * 2. The retention change naming it, unless the newest sealed one already
23
+ * cuts at or past it (a quiet day, or a retry): appended, and sealed in
24
+ * the signer's pair (DropAfterSeal; else the verifier, walking the uses
25
+ * from genesis, would find the day gone).
26
+ * 3. Freshness: the pair that sealed it (or the signer's, for a day an older
27
+ * change cuts) at least two freshness bounds past the cut (FreshBeforeDrop).
28
+ * The signer refuses any entry stamped a bound before its pair (FRESH_MS),
29
+ * so no copy of a dropped use, which keeps its stamp and has no original
30
+ * left for a request-ID check to find, is ever sealed again. Two bounds,
31
+ * not one: one for the clocks.
32
+ * 4. The day's partition: detached, and dropped by the next run (`drop_day`).
33
+ * Measured at 1,000 uses a second (0.15-plan.md, "As built: PR 7"): a DROP
34
+ * right after the DETACH lands while every backend replans its plan of
35
+ * `log_uses`, and stalls uses 200 to 400 ms; seconds later, it stalls none.
36
+ * A crash between any two steps is finished by the next run.
37
+ * Then the tiles below the newest cut (`drop_tiles`).
38
+ *
39
+ * The signer's checks are the vault's: its memory, its pair, its keys. The
40
+ * database's functions check what they can see, so that the vault's login
41
+ * drops nothing a retention change it holds does not cut.
42
+ */
43
+ export type RetentionStore = {
44
+ /** How far the reads' projector has gone (reads.ts, T166), with its MAC: no day nor tile it has yet to read is dropped. */
45
+ readsCursor(): Promise<ReadsCursor>;
46
+ /** The UTC days of uses the database holds, oldest first: YYYY-MM-DD. */
47
+ days(): Promise<string[]>;
48
+ /** Makes each day's partition of uses (`make_day`), once a store. */
49
+ makeDays(days: readonly string[]): Promise<void>;
50
+ /** The first checkpoint whose floor of `at` is at or past `ms`. */
51
+ cutAt(ms: number): Promise<StoredPair | null>;
52
+ /** The newest retention change at or below the newest checkpoint, with its leaf index; null if none. */
53
+ retention(): Promise<{ entry: LogEntry; index: number } | null>;
54
+ /** Appends a retention change, or finds the one its request ID names: its number. */
55
+ appendRetention(change: { requestId: string; at: number; payload: string; mac: Buffer }): Promise<bigint>;
56
+ /** One step of dropping a day: detached, dropped, or gone. */
57
+ dropDay(day: string): Promise<'detached' | 'dropped' | 'gone'>;
58
+ /** The uses tree's tiles wholly below leaf `below`: how many were deleted or ranges dropped. */
59
+ dropTiles(below: number): Promise<number>;
60
+ /** At most `limit` of the bulk cap's rows that its window no longer counts: how many went. */
61
+ pruneBulk(limit: number): Promise<number>;
62
+ };
63
+
64
+ export const DAY_MS = 86_400_000;
65
+
66
+ /** A UTC day, YYYY-MM-DD, of a time in ms. */
67
+ export const dayOf = (ms: number) => new Date(ms).toISOString().slice(0, 10);
68
+
69
+ export type RetentionRun = {
70
+ /** The days whose uses went this run, each with the retention change written for it, if one was. */
71
+ dropped: { day: string; change: bigint | null }[];
72
+ /** Of those, the days detached and not dropped yet: the next run drops them. */
73
+ detached: string[];
74
+ /**
75
+ * Why the run stopped before a due day (`day`) or the tiles (no day), when it waits for something: the log to move on,
76
+ * or (`lock`) a reader holding the uses past the lock timeout. No alarm; a lock standing a day is one (`upkeep`).
77
+ */
78
+ waiting: { day: string | null; lock: boolean; reason: string } | null;
79
+ /** Tiles deleted or leaf ranges dropped below the newest cut. */
80
+ tiles: number;
81
+ };
82
+
83
+ type Options = {
84
+ /** Full days of uses kept before today's: a day goes once `keepDays` days have ended after it. */
85
+ keepDays: number;
86
+ /** The log's MAC key (`logMacKey`): the retention change is an entry like any. */
87
+ macKey: LogMacKey;
88
+ now?: () => number;
89
+ };
90
+
91
+ /**
92
+ * One run. Throws on what is an alarm (a cut off the signer's history, a
93
+ * retention change not sealed, a day the database refuses); returns `waiting`
94
+ * when a day is not ready yet.
95
+ */
96
+ export async function retain(cp: Checkpointer, store: CheckpointStore & RetentionStore, options: Options): Promise<RetentionRun> {
97
+ if (!Number.isSafeInteger(options.keepDays) || options.keepDays < 1) throw new Error(`keepDays must be a whole number of days, at least 1, not ${options.keepDays}`);
98
+ const now = options.now ?? Date.now;
99
+ const run: RetentionRun = { dropped: [], detached: [], waiting: null, tiles: 0 };
100
+ const last = dayOf(now() - (options.keepDays + 1) * DAY_MS);
101
+ for (const day of (await store.days()).filter((d) => d <= last)) {
102
+ const done = await retainDay(cp, store, day, options.macKey, now);
103
+ if ('reason' in done) {
104
+ run.waiting = { day, ...done };
105
+ break;
106
+ }
107
+ run.dropped.push({ day, change: done.change });
108
+ // One DETACH a run: each invalidates every backend's plan of log_uses, and a second landing while they replan stalls
109
+ // uses (measured for a DROP: 0.15-plan.md, "As built: PR 7"). A backlog of days goes a day an hour.
110
+ if (done.detached) {
111
+ run.detached.push(day);
112
+ break;
113
+ }
114
+ }
115
+ // The tiles below the newest cut, once its sealing is two freshness bounds past it, as a day goes (FreshBeforeDrop).
116
+ const newest = await sealedCut(cp, store);
117
+ const projecting = newest === null ? null : await behindReads(store, options.macKey, newest.upto);
118
+ if (projecting !== null) run.waiting ??= { day: null, lock: false, reason: `the tiles below ${newest!.upto}: ${projecting}` };
119
+ else if (newest !== null && cp.pair.pair.at >= newest.at + 2 * cp.freshMs) {
120
+ const tiles = await waitingOnLock(() => store.dropTiles(newest.uses.size));
121
+ if (typeof tiles === 'number') run.tiles = tiles;
122
+ else run.waiting ??= { day: null, lock: true, reason: `the tiles below ${newest.upto}: ${tiles.reason}` };
123
+ }
124
+ return run;
125
+ }
126
+
127
+ /** One day: dropped, with the change written for it if any; or why it waits. */
128
+ type Wait = { lock: boolean; reason: string };
129
+
130
+ async function retainDay(cp: Checkpointer, store: CheckpointStore & RetentionStore, day: string, macKey: LogMacKey, now: () => number): Promise<{ change: bigint | null; detached: boolean } | Wait> {
131
+ const end = Date.parse(`${day}T00:00:00Z`) + DAY_MS;
132
+ const head = cp.pair.pair;
133
+ if (head.floor < end) return { lock: false, reason: `the signer's newest pair (up to ${head.upto}) is not past the day's end yet` };
134
+ // 1. The cut: a pair of this log at or below the signer's, the first it signed past the day's end by its own note (its
135
+ // floor at or past the end, the floor before it below: CutFirst). The rows say which pair that is, and a writer who
136
+ // deletes some, or edits their floors, would make a later one pass for it, and every use between go unwalked.
137
+ const row = await store.cutAt(end);
138
+ const cut = row === null ? null : await openPair(row.notes, cp.verifiers);
139
+ if (row === null || cut === null || cut.upto !== row.upto || cut.floor < end || cut.prev >= end || cut.upto > head.upto) {
140
+ throw new Error(`retention of ${day}: the database holds no pair of this log, at or below the signer's (up to ${head.upto}), that is the first past the day's end: checkpoints were deleted or replaced`);
141
+ }
142
+ // On the signer's history, by a consistency proof to its head; unless the newest sealed cut is at or past it (a day a
143
+ // change already cut, or one waiting to be dropped): that cut was proved when it was sealed, and the tiles below it,
144
+ // which a proof from this one would read, may be gone.
145
+ const sealed = await sealedCut(cp, store);
146
+ if (sealed === null || sealed.upto < cut.upto) {
147
+ for (const tree of TREES) {
148
+ if (cut[tree].size === head[tree].size && cut.upto === head.upto) continue;
149
+ // A fence closed under it (T169) is no answer of the database's: it goes through, and no alarm says the cut is off.
150
+ const proof = cut[tree].size > head[tree].size ? null : await proveConsistency(store, tree, cut[tree].size, head[tree].size).catch((error: unknown) => {
151
+ if (error instanceof FenceClosed) throw error;
152
+ return null;
153
+ });
154
+ if (proof === null || !verifyConsistency(cut[tree].size, head[tree].size, cut[tree].root, head[tree].root, proof)) {
155
+ throw new Error(`retention of ${day}: the cut's ${tree} head (up to ${cut.upto}) is not on the signer's history`);
156
+ }
157
+ }
158
+ }
159
+ // 2. The retention change, unless the newest sealed one cuts at or past this day's.
160
+ let change: bigint | null = null;
161
+ let freshAt = head.at;
162
+ if (sealed === null || sealed.upto < cut.upto) {
163
+ const named: Cut = { upto: cut.upto, at: cut.at, uses: { size: cut.uses.size, root: cut.uses.root } };
164
+ const at = now();
165
+ const requestId = `retention:${day}`;
166
+ const payload = retentionPayload(day, named);
167
+ const mac = entryMac(macKey, { tree: 'changes', row: { at, requestId, object: RETENTION_OBJECT, actor: RETENTION_ACTOR, payload, seal: null } });
168
+ change = await store.appendRetention({ requestId, at, payload, mac });
169
+ // Sealed in the signer's pair, witnessed or not: its proof is checked against the pair. A retry finds the change it
170
+ // made before, whose cut is this day's (the first pair past its end) unless the rows moved: then nothing is dropped.
171
+ const answer = await cp.sealThrough(change);
172
+ const entry = (await store.retention())?.entry;
173
+ const written = entry?.number === change ? retentionCut(entry) : null;
174
+ if (written === null || written.upto !== cut.upto || !leafData('changes', entry!.number, entry!.columns).equals(answer.proof.leaf)) {
175
+ throw new Error(`retention of ${day}: the retention change ${change} is not the one sealed, or does not cut at ${cut.upto}`);
176
+ }
177
+ freshAt = answer.pair.at;
178
+ }
179
+ // 3. Freshness, against the signer's own pair, never a row.
180
+ const bound = Math.max(cut.at, end) + 2 * cp.freshMs;
181
+ if (freshAt < bound) return { lock: false, reason: `the pair sealing its cut (at ${freshAt}) is not two freshness bounds past it (${bound}) yet` };
182
+ // 4. Not before the reads' projector has read every use at or below the cut (T166): those it has yet to list would
183
+ // otherwise never be, and the range it walks needs the tiles too.
184
+ const projecting = await behindReads(store, macKey, cut.upto);
185
+ if (projecting !== null) return { lock: false, reason: projecting };
186
+ // 5. The day's partition: detached now, and dropped by a later run, never right after its DETACH (see above). A reader
187
+ // holding log_uses longer than `drop_day`'s lock_timeout (a backup, say) makes it give up: the next run tries again.
188
+ let state = await waitingOnLock(() => store.dropDay(day));
189
+ if (state === 'dropped') state = await waitingOnLock(() => store.dropDay(day));
190
+ if (typeof state === 'object') return state;
191
+ if (state === 'dropped') throw new Error(`retention of ${day}: its uses were not dropped`);
192
+ return { change, detached: state === 'detached' };
193
+ }
194
+
195
+ /**
196
+ * Why a cut waits on the reads' projector (T166), or null once it has gone
197
+ * past it: by the cursor the vault's MAC holds, never a number a database
198
+ * writer moved on, which would drop uses the projector has yet to list
199
+ * (review r7). Such a cursor stops the projector too, so retention waits,
200
+ * and alarms once it has waited long.
201
+ */
202
+ async function behindReads(store: RetentionStore, macKey: LogMacKey, upto: bigint): Promise<string | null> {
203
+ const cursor = await store.readsCursor();
204
+ if (!cursorHolds(macKey, cursor)) return `the reads' cursor, at ${cursor.through}, is not the vault's: it was moved around the projector`;
205
+ return cursor.through >= upto ? null : `the reads' projection (through ${cursor.through}) has not reached the cut (${upto}) yet`;
206
+ }
207
+
208
+ /** What `work` answers, or, when the database gave up waiting for a lock (55P03, lock_timeout), why: no alarm. */
209
+ async function waitingOnLock<T>(work: () => Promise<T>): Promise<T | Wait> {
210
+ try {
211
+ return await work();
212
+ } catch (error) {
213
+ const e = error as { code?: unknown; cause?: { code?: unknown } };
214
+ if (e.code === '55P03' || e.cause?.code === '55P03') return { lock: true, reason: 'a reader holds the table past the lock timeout: the next run tries again' };
215
+ throw error;
216
+ }
217
+ }
218
+
219
+ /** The newest retention change's cut, proved in the signer's pair; null if there is none. */
220
+ async function sealedCut(cp: Checkpointer, store: RetentionStore): Promise<Cut | null> {
221
+ const found = await store.retention();
222
+ if (found === null) return null;
223
+ const cut = retentionCut(found.entry);
224
+ const proof = await cp.prove(found.entry.number, { tree: 'changes' });
225
+ if (cut === null || proof === null || proof.tree !== 'changes' || !proof.leaf.equals(leafData('changes', found.entry.number, found.entry.columns))) {
226
+ throw new Error(`the retention change ${found.entry.number} is not sealed in the signer's changes tree as the database holds it`);
227
+ }
228
+ return cut;
229
+ }
@@ -22,6 +22,19 @@ export function snapshotQuery(asked: Asked): SQL {
22
22
  const actions = sql`${sql.param([...ACCESS_ACTIONS])}::text[]`;
23
23
  const tomb = `%${TOMBSTONE}%`;
24
24
  const released = asked.released;
25
+ const seals = asked.seals === true;
26
+ // The places read: those asked, the environments each personal config among them is a config of, with `seals` those
27
+ // the asked principals' grants are on (whose projects their grants are read with), and what the newest policy
28
+ // protects, by the ids its JSON names (one that is no UUID names nothing).
29
+ const placeIds = sql`ARRAY(
30
+ SELECT unnest(${environments})
31
+ UNION SELECT parent_id FROM environments WHERE id = ANY(${environments}) AND owner IS NOT NULL${asked.seals === true
32
+ ? sql`
33
+ UNION SELECT environment_id FROM vault_grants WHERE principal = ANY(${principals}) AND environment_id IS NOT NULL`
34
+ : sql``}
35
+ UNION SELECT (place #>> '{}')::uuid FROM policy, jsonb_path_query(policy.metadata::jsonb, '$.policy.protected[*].environmentId') AS place
36
+ WHERE (place #>> '{}') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
37
+ )`;
25
38
  // The head only if nobody holds it: a statement never waits for it, so all it reads is as of when it took it (`snapshot`).
26
39
  const head = asked.head === true
27
40
  ? sql`, head AS (SELECT next_seq, head_hash FROM audit_chain_head WHERE only_row FOR UPDATE SKIP LOCKED)`
@@ -60,21 +73,38 @@ export function snapshotQuery(asked: Asked): SQL {
60
73
  ) e
61
74
  ),
62
75
  'policy', (SELECT ${ENTRY} FROM policy e),
76
+ 'settings', ${asked.settings === true
77
+ ? sql`(SELECT ${ENTRY} FROM audit_log e WHERE author = 'vault' AND action = 'settings.change' AND decision = 'allow' ORDER BY seq DESC LIMIT 1)`
78
+ : sql`NULL`},
63
79
  'environments', (
64
80
  SELECT coalesce(json_agg(json_build_object(
65
81
  'id', env.id, 'projectId', env.project_id, 'slug', env.slug, 'owner', env.owner, 'parentId', env.parent_id,
66
82
  'deleted', env.slug LIKE ${tomb} OR project.slug LIKE ${tomb},
67
- 'archived', env.archived_at IS NOT NULL OR project.archived_at IS NOT NULL)), '[]')
83
+ 'archived', env.archived_at IS NOT NULL OR project.archived_at IS NOT NULL${seals
84
+ ? sql`,
85
+ -- What 0.15's log holds the row to (T159): its object's own columns, its seal, and its newest change's, one probe
86
+ -- of its object's changes, newest first (log_changes_object_idx, which holds the seal).
87
+ 'protected', env.protected, 'ownArchived', env.archived_at IS NOT NULL, 'projectArchived', project.archived_at IS NOT NULL,
88
+ 'seal', encode(env.seal, 'hex'),
89
+ 'newest', (SELECT encode(l.seal, 'hex') FROM log_changes l WHERE l.object = log_object('environment', env.id::text) ORDER BY l.number DESC LIMIT 1)`
90
+ : sql``})), '[]')
68
91
  FROM environments env JOIN projects project ON project.id = env.project_id
69
92
  -- One list of ids, each found by the primary key.
70
- WHERE env.id = ANY(ARRAY(
71
- SELECT unnest(${environments})
72
- UNION SELECT parent_id FROM environments WHERE id = ANY(${environments}) AND owner IS NOT NULL
73
- -- What the newest policy protects, by the ids its JSON names; one that is no UUID names nothing.
74
- UNION SELECT (place #>> '{}')::uuid FROM policy, jsonb_path_query(policy.metadata::jsonb, '$.policy.protected[*].environmentId') AS place
75
- WHERE (place #>> '{}') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
76
- ))
77
- ),
93
+ WHERE env.id = ANY(${placeIds})
94
+ ),${seals
95
+ ? sql`
96
+ -- Of those ids, each the log holds a change of but no row (or no project's) answers to: deleted outright around it,
97
+ -- with its newest change's seal (T159, review r5). One probe of its object's changes each.
98
+ 'gone', (
99
+ SELECT coalesce(json_agg(json_build_object('id', g.id, 'newest', encode(l.seal, 'hex'))), '[]')
100
+ FROM (
101
+ SELECT a.id FROM unnest(${placeIds}) AS a (id)
102
+ WHERE NOT EXISTS (SELECT 1 FROM environments env JOIN projects project ON project.id = env.project_id WHERE env.id = a.id)
103
+ OFFSET 0
104
+ ) g
105
+ CROSS JOIN LATERAL (SELECT l.seal FROM log_changes l WHERE l.object = log_object('environment', g.id::text) ORDER BY l.number DESC LIMIT 1) l
106
+ ),`
107
+ : sql``}
78
108
  'ended', (
79
109
  SELECT coalesce(json_agg(related_seq::text), '[]') FROM audit_log
80
110
  WHERE author = 'vault' AND action = 'reference.end' AND decision = 'allow' AND related_seq = ANY(${references})
@@ -31,6 +31,7 @@ export function snapshotQuery(asked: Asked): SQL {
31
31
  const tomb = `%${TOMBSTONE}%`;
32
32
  const released = asked.released;
33
33
  const head = asked.head === true;
34
+ const seals = asked.seals === true;
34
35
  return sql`
35
36
  WITH policy AS (
36
37
  SELECT * FROM audit_log WHERE author = 'vault' AND action = 'policy.change' AND decision = 'allow' ORDER BY seq DESC LIMIT 1
@@ -66,21 +67,45 @@ export function snapshotQuery(asked: Asked): SQL {
66
67
  ) WHERE newest IS NOT NULL
67
68
  )),
68
69
  'policy', json((SELECT ${ENTRY} FROM policy e)),
70
+ 'settings', ${asked.settings === true
71
+ ? sql`json((SELECT ${ENTRY} FROM audit_log e WHERE author = 'vault' AND action = 'settings.change' AND decision = 'allow' ORDER BY seq DESC LIMIT 1))`
72
+ : sql`NULL`},
69
73
  'environments', json((
70
74
  SELECT json_group_array(json_object(
71
75
  'id', env.id, 'projectId', env.project_id, 'slug', env.slug, 'owner', env.owner, 'parentId', env.parent_id,
72
76
  'deleted', env.slug LIKE ${tomb} OR project.slug LIKE ${tomb},
73
- 'archived', env.archived_at IS NOT NULL OR project.archived_at IS NOT NULL))
77
+ 'archived', env.archived_at IS NOT NULL OR project.archived_at IS NOT NULL${seals
78
+ ? sql`,
79
+ -- What 0.15's log holds the row to (T159), but its newest change's seal, which snapshot reads after.
80
+ 'protected', env.protected, 'ownArchived', env.archived_at IS NOT NULL, 'projectArchived', project.archived_at IS NOT NULL,
81
+ 'seal', lower(hex(env.seal))`
82
+ : sql``}))
74
83
  FROM environments env JOIN projects project ON project.id = env.project_id
75
84
  WHERE env.id IN (SELECT value FROM json_each(${environments}))
76
- OR env.id IN (SELECT parent_id FROM environments WHERE id IN (SELECT value FROM json_each(${environments})) AND owner IS NOT NULL)
85
+ OR env.id IN (SELECT parent_id FROM environments WHERE id IN (SELECT value FROM json_each(${environments})) AND owner IS NOT NULL)${seals
86
+ ? sql`
87
+ OR env.id IN (SELECT environment_id FROM vault_grants WHERE principal IN (SELECT value FROM json_each(${principals})) AND environment_id IS NOT NULL)`
88
+ : sql``}
77
89
  OR env.id IN (
78
90
  SELECT json_extract(place.value, '$.environmentId')
79
91
  FROM policy, json_each(policy.metadata, '$.policy.protected') AS place
80
92
  -- Of a forged entry's, an element that is no object names nothing: json_extract would fail on a string.
81
93
  WHERE json_valid(policy.metadata) AND json_type(policy.metadata, '$.policy.protected') = 'array' AND place.type = 'object'
82
94
  )
83
- )),
95
+ )),${seals
96
+ ? sql`
97
+ -- Every place id asked, row or none: snapshot finds those the log holds a change of and no row answers to (review r5).
98
+ 'placeIds', json((
99
+ SELECT json_group_array(id) FROM (
100
+ SELECT value AS id FROM json_each(${environments})
101
+ UNION SELECT parent_id FROM environments WHERE id IN (SELECT value FROM json_each(${environments})) AND owner IS NOT NULL
102
+ UNION SELECT environment_id FROM vault_grants WHERE principal IN (SELECT value FROM json_each(${principals})) AND environment_id IS NOT NULL
103
+ UNION SELECT json_extract(place.value, '$.environmentId')
104
+ FROM policy, json_each(policy.metadata, '$.policy.protected') AS place
105
+ WHERE json_valid(policy.metadata) AND json_type(policy.metadata, '$.policy.protected') = 'array' AND place.type = 'object'
106
+ ) WHERE id IS NOT NULL
107
+ )),`
108
+ : sql``}
84
109
  'ended', json((
85
110
  SELECT json_group_array(related_seq) FROM audit_log
86
111
  WHERE author = 'vault' AND action = 'reference.end' AND decision = 'allow' AND related_seq IN (SELECT value FROM json_each(${references}))
package/src/snapshot.ts CHANGED
@@ -6,6 +6,7 @@ import type { GrantRow } from '@coffre/db/grants';
6
6
 
7
7
  import { snapshotQuery as postgres } from './snapshot.postgres.ts';
8
8
  import { snapshotQuery as sqlite } from './snapshot.sqlite.ts';
9
+ import { objectKey } from './inputs.ts';
9
10
  import * as store from './store.ts';
10
11
  import type { EnvironmentRow, Member } from './store.ts';
11
12
 
@@ -39,9 +40,13 @@ export type Snapshot = {
39
40
  newestAccess: Map<string, StoredEntry>;
40
41
  /** The newest allowed `policy.change` in the vault's name, unchecked. */
41
42
  newestPolicy: StoredEntry | null;
43
+ /** With `asked.settings`, the newest allowed `settings.change` in the vault's name, unchecked, or null for none; not asked, undefined. */
44
+ newestSettings: StoredEntry | null | undefined;
42
45
  /**
43
46
  * The asked environments, the environments each personal config among
44
- * them is a config of, and those the newest `policy.change` names, by id.
47
+ * them is a config of, and those the newest `policy.change` names, by id;
48
+ * with `asked.seals`, those the asked principals' grants are on too, each
49
+ * with what 0.15's log holds it to (`EnvironmentRow.sealed`).
45
50
  */
46
51
  environments: Map<string, EnvironmentRow>;
47
52
  /** Of the asked `reference.create` entries, those a `reference.end` of the vault's names, allowed. */
@@ -72,7 +77,15 @@ export type Asked = {
72
77
  /** Whose grants and newest access entry; with `members`, their rows too, unlocked. */
73
78
  principals: readonly string[];
74
79
  members?: boolean;
80
+ /** The newest `settings.change` too (`newestSettings`). */
81
+ settings?: boolean;
75
82
  environments?: readonly string[];
83
+ /**
84
+ * Each environment read with what 0.15's log holds it to, and the asked
85
+ * principals' grants' too, whose projects their grants are read with
86
+ * (T159): asked once 0.15's log is cut over, before which no row is sealed.
87
+ */
88
+ seals?: boolean;
76
89
  references?: readonly bigint[];
77
90
  /**
78
91
  * One principal's releases within the last `windowMs` of the clock, moved
@@ -117,20 +130,48 @@ export function current(head: LockedHead): boolean {
117
130
  }
118
131
 
119
132
  async function readOnce(db: Queryable, asked: Asked): Promise<Snapshot> {
120
- const query = (engineOf(db) === 'postgres' ? postgres : sqlite)(asked);
121
- const [row] = await rowsOf<{ snapshot: string | Raw }>(db, query);
122
- const raw: Raw = typeof row!.snapshot === 'string' ? (JSON.parse(row!.snapshot) as Raw) : row!.snapshot;
133
+ const engine = engineOf(db);
134
+ const query = (engine === 'postgres' ? postgres : sqlite)(asked);
135
+ const statement = async (on: Queryable) => {
136
+ const [row] = await rowsOf<{ snapshot: string | Raw }>(on, query);
137
+ return typeof row!.snapshot === 'string' ? (JSON.parse(row!.snapshot) as Raw) : row!.snapshot;
138
+ };
139
+ if (engine === 'postgres' || asked.seals !== true) return snapshotOf(asked, await statement(db), null);
140
+ // SQLite has no `log_object`: the environments' newest changes in a statement of their own, by the keys the vault
141
+ // computes, the places asked with no row among them (`gone`, which Postgres's statement answers itself). Both in one
142
+ // transaction, one snapshot of the file, so that a change committed between them is never a row's seal against a
143
+ // newer one (review r6); inside a decision's, a savepoint of it.
144
+ return store.oneSnapshot(db, async (on) => {
145
+ const raw = await statement(on);
146
+ const rowless = (raw.placeIds ?? []).filter((id) => !raw.environments.some((row) => row.id === id));
147
+ return snapshotOf(asked, raw, await store.newestSeals(on, [...raw.environments.map(({ id }) => id), ...rowless].map((id) => ({ kind: 'environment' as const, id }))), rowless);
148
+ });
149
+ }
150
+
151
+ /** A snapshot from its statement's row; on SQLite, with the newest seals and the rowless places read beside it. */
152
+ function snapshotOf(asked: Asked, raw: Raw, newest: Map<string, Buffer | null> | null, rowless: readonly string[] = []): Snapshot {
153
+ const gone = newest === null
154
+ ? (raw.gone ?? []).map(({ id, newest: last }) => [id, hex(last)] as const)
155
+ : rowless.flatMap((id) => (newest.has(objectKey({ kind: 'environment', id })) ? [[id, newest.get(objectKey({ kind: 'environment', id })) ?? null] as const] : []));
123
156
  return {
124
157
  at: Number(raw.at),
125
158
  members: new Map(raw.members.map((member) => [member.principal, memberOf(member)])),
126
159
  grants: new Map(asked.principals.map((principal) => [principal, raw.grants.filter((grant) => grant.principal === principal).map(grantOf)])),
127
160
  newestAccess: new Map(raw.access.map((entry) => [entry.subjectPrincipal!, entryOf(entry)])),
128
161
  newestPolicy: raw.policy === null ? null : entryOf(raw.policy),
129
- environments: new Map(raw.environments.map(({ id, ...environment }) => [id, {
130
- ...environment,
131
- deleted: Boolean(environment.deleted),
132
- archived: Boolean(environment.archived),
133
- }])),
162
+ newestSettings: asked.settings !== true ? undefined : raw.settings == null ? null : entryOf(raw.settings),
163
+ environments: new Map<string, EnvironmentRow>([...gone.map(([id, last]) => [id, store.goneEnvironment(id, last)] as const), ...raw.environments.map(({ id, projectId, slug, owner, parentId, deleted, archived, ...sealed }) => [id, {
164
+ projectId, slug, owner, parentId,
165
+ deleted: Boolean(deleted),
166
+ archived: Boolean(archived),
167
+ ...(asked.seals === true ? {
168
+ sealed: store.environmentSeal(
169
+ { id, projectId, slug, owner, parentId, protected: sealed.protected == null ? null : Boolean(sealed.protected), archived: Boolean(sealed.ownArchived), projectArchived: Boolean(sealed.projectArchived) },
170
+ hex(sealed.seal),
171
+ newest === null ? hex(sealed.newest) : newest.get(objectKey({ kind: 'environment', id })) ?? null,
172
+ ),
173
+ } : {}),
174
+ }] as const)]),
134
175
  ended: new Set(raw.ended.map((seq) => BigInt(seq))),
135
176
  released: Number(raw.released ?? 0),
136
177
  head: raw.head === null ? null : {
@@ -165,11 +206,24 @@ type Raw = {
165
206
  grants: { principal: string; projectId: string | null; environmentId: string | null; environmentSlug: string | null; role: string; expiresAt: Int | null; grantedAt: Int; grantedBy: string }[];
166
207
  access: RawEntry[];
167
208
  policy: RawEntry | null;
168
- environments: { id: string; projectId: string; slug: string; owner: string | null; parentId: string | null; deleted: boolean | number; archived: boolean | number }[];
209
+ settings?: RawEntry | null;
210
+ environments: {
211
+ id: string; projectId: string; slug: string; owner: string | null; parentId: string | null; deleted: boolean | number; archived: boolean | number;
212
+ /** With `asked.seals`: the object's own columns, the row's seal and, on Postgres, its newest change's, as hex. */
213
+ protected?: boolean | number | null; ownArchived?: boolean | number; projectArchived?: boolean | number; seal?: string | null; newest?: string | null;
214
+ }[];
215
+ /** With `asked.seals`, on Postgres: the places asked that the log holds a change of and no row answers to. */
216
+ gone?: { id: string; newest: string | null }[];
217
+ /** With `asked.seals`, on SQLite: every place id asked, row or none. */
218
+ placeIds?: string[];
169
219
  ended: Int[];
170
220
  released: Int | null;
171
221
  };
172
222
 
223
+ function hex(value: string | null | undefined): Buffer | null {
224
+ return value == null ? null : Buffer.from(value, 'hex');
225
+ }
226
+
173
227
  /** An entry as the chain's codec reads it, from what `entryJson` wrote. */
174
228
  export function entryOf(raw: RawEntry): StoredEntry {
175
229
  return {