@coffre/vault 0.14.5 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +6 -4
  2. package/dist/cloudflare.d.ts +341 -4
  3. package/dist/cloudflare.js +802 -6
  4. package/dist/index.js +1 -1
  5. package/dist/log-entry-BYWqj3O5.d.ts +364 -0
  6. package/dist/node.d.ts +181 -6
  7. package/dist/node.js +358 -16
  8. package/dist/{src-Bg0ZeMJW.js → src-DheG92EM.js} +1 -1
  9. package/dist/vault-kmtojYnW.js +14623 -0
  10. package/package.json +4 -5
  11. package/src/bulk.ts +89 -0
  12. package/src/changes.ts +478 -0
  13. package/src/checkpointer-node.ts +342 -0
  14. package/src/checkpointer-workers.ts +514 -0
  15. package/src/checkpointer.ts +1165 -0
  16. package/src/cloudflare.ts +207 -13
  17. package/src/config.ts +3 -2
  18. package/src/credentials.postgres.ts +78 -0
  19. package/src/credentials.sqlite.ts +240 -0
  20. package/src/credentials.ts +630 -0
  21. package/src/cutover.ts +275 -0
  22. package/src/decide.ts +437 -2
  23. package/src/inputs.ts +499 -0
  24. package/src/local.ts +20 -4
  25. package/src/log-checkpointer.ts +116 -0
  26. package/src/log-entry.ts +113 -0
  27. package/src/log-replay.ts +188 -0
  28. package/src/log-store.ts +695 -0
  29. package/src/node.ts +73 -9
  30. package/src/objects.ts +141 -0
  31. package/src/reads.ts +502 -0
  32. package/src/retention.ts +229 -0
  33. package/src/snapshot.postgres.ts +39 -9
  34. package/src/snapshot.sqlite.ts +28 -3
  35. package/src/snapshot.ts +64 -10
  36. package/src/store.ts +832 -36
  37. package/src/use.postgres.ts +111 -0
  38. package/src/use.sqlite.ts +542 -0
  39. package/src/use.ts +1060 -0
  40. package/src/vault.ts +1366 -220
  41. package/src/verifier.ts +136 -0
  42. package/src/witness-workers.ts +163 -0
  43. package/dist/vault-Cbi45kLB.js +0 -5197
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffre/vault",
3
- "version": "0.14.5",
3
+ "version": "0.15.0",
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,13 +36,12 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "drizzle-orm": "0.45.2",
39
- "@coffre/core": "0.14.5",
40
- "@coffre/db": "0.14.5"
39
+ "@coffre/core": "0.15.0",
40
+ "@coffre/db": "0.15.0"
41
41
  },
42
42
  "devDependencies": {
43
+ "@hegeldev/hegel": "0.4.7",
43
44
  "@types/node": "26.1.1",
44
- "@types/pg": "8.20.0",
45
- "pg": "8.16.3",
46
45
  "tsdown": "0.23.0",
47
46
  "typescript": "5.9.3",
48
47
  "wrangler": "4.142.0"
package/src/bulk.ts ADDED
@@ -0,0 +1,89 @@
1
+ import { USE_DEADLINE_MS } from '@coffre/core/vault';
2
+
3
+ import type { BulkLimit } from './config.ts';
4
+
5
+ export { USE_DEADLINE_MS };
6
+
7
+ /**
8
+ * The bulk cap of 0.15 (R28, R52, docs/design/sealed-inputs.md): at most
9
+ * `count` distinct keys per principal in any rolling `windowMs`, both the
10
+ * log's policy object, as the deployment configured it.
11
+ *
12
+ * One row per principal and key holds `last`, when the key was last
13
+ * counted, as the start of that use's refresh period (`periodOf`). A key
14
+ * is in the principal's set at `t` while `last > t − window − refresh`; a
15
+ * key in it passes; a key not in it passes only if fewer than `count`
16
+ * other keys count, and then enters (`last := periodOf(t)`). A key's row
17
+ * moves on only once it is from an earlier period than the use's, so a hot
18
+ * key writes its row once a minute rather than on every use; `last` lags
19
+ * by less than `refresh`, which the set's bound adds back. Uses that move
20
+ * one key on at once write the same period, or the greatest is kept: the
21
+ * row ends where the replay's does whichever commits first, so they never
22
+ * wait for one another (T127). Other keys count over a longer bound,
23
+ * `window + refresh + deadline + ahead`: uses are decided in number order,
24
+ * not by their time, so a key that entered at a later `at` may already be
25
+ * in when an earlier use is decided (TLC's finding, HotPathOneClock), up
26
+ * to the deadline after it, or the minute a Worker's clock may run ahead
27
+ * before it (HotPathOneBulkSkew, W228).
28
+ *
29
+ * Every request counts, whatever else refuses it but the cap: the log keeps
30
+ * requests, not outcomes, and the use's one statement writes the row before
31
+ * the vault decides. A key stays in the set up to `refresh` past its window,
32
+ * never less, and counts against a new key up to `refresh + deadline +
33
+ * ahead` past it.
34
+ */
35
+
36
+ /** How far past the database's clock a use may be stamped (`ahead` refuses past it): no use is decided earlier than this before its `at`. */
37
+ export const USE_AHEAD_MS = 60_000;
38
+
39
+ /** How stale a key's row may get before a use moves it on: a minute, or a tenth of a shorter window, at least 1 ms. */
40
+ export function refreshMs(limit: BulkLimit): number {
41
+ return Math.max(1, Math.min(60_000, Math.floor(limit.windowMs / 10)));
42
+ }
43
+
44
+ /** The start of `at`'s refresh period: what a use that writes its key's row writes. */
45
+ export function periodOf(at: number, refresh: number): number {
46
+ return at - (((at % refresh) + refresh) % refresh);
47
+ }
48
+
49
+ /** A principal's rows: each key's `last`, ms since the epoch. */
50
+ export type BulkRows = Map<string, number>;
51
+
52
+ /** What a use reads of the cap: whether its key is in the set, and how many other keys count (up to `count`, as far as it reads). */
53
+ export type BulkRead = { inSet: boolean; others: number };
54
+
55
+ /**
56
+ * The cap's rule for a request for `key` at `at`: what it reads, and the
57
+ * `last` it writes, or null for none. Live, the use's statement applies it
58
+ * to the `bulk` table, under the principal's lock to write: exclusive for a
59
+ * key that enters the set, shared for one moved on; replayed, `bulkStep`
60
+ * to the replay's rows.
61
+ */
62
+ export function bulkRule(rows: ReadonlyMap<string, number>, key: string, at: number, limit: BulkLimit): BulkRead & { write: number | null } {
63
+ const refresh = refreshMs(limit);
64
+ const last = rows.get(key);
65
+ const inSet = last !== undefined && last > at - limit.windowMs - refresh;
66
+ let others = 0;
67
+ if (!inSet) {
68
+ for (const [other, t] of rows) {
69
+ if (others >= limit.count) break;
70
+ if (other !== key && t > at - limit.windowMs - refresh - USE_DEADLINE_MS - USE_AHEAD_MS) others += 1;
71
+ }
72
+ }
73
+ const admitted = inSet || others < limit.count;
74
+ const period = periodOf(at, refresh);
75
+ const stale = last === undefined || last < period;
76
+ return { inSet, others, write: admitted && stale ? period : null };
77
+ }
78
+
79
+ /** The rule applied: the rows moved on, and what the request read. */
80
+ export function bulkStep(rows: BulkRows, key: string, at: number, limit: BulkLimit): BulkRead {
81
+ const { write, ...read } = bulkRule(rows, key, at, limit);
82
+ if (write !== null) rows.set(key, write);
83
+ return read;
84
+ }
85
+
86
+ /** Whether the cap refuses what a request read: a key not in the set, with `count` others already counted. */
87
+ export function overCap(read: BulkRead, limit: BulkLimit): boolean {
88
+ return !read.inSet && read.others >= limit.count;
89
+ }
package/src/changes.ts ADDED
@@ -0,0 +1,478 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ import type { ObjectChange, PreparedChange } from '@coffre/core/vault';
4
+ import type { Queryable, Transaction } from '@coffre/db';
5
+
6
+ import { applyOp, canonical, objectKey, refOf, sealOf, sealed, type Input, type Op, type Ref, type SealKeys } from './inputs.ts';
7
+ import type { LoggedChange } from './log-replay.ts';
8
+ import { entryMac, type LogMacKey } from './log-entry.ts';
9
+ import { connectionObject, credentialObject, environmentObject, secretObject } from './objects.ts';
10
+ import { changeInputs, isStale, newestChange, newestSealedChanges, readObject, type ChangeCall, type ReadKind } from './store.ts';
11
+
12
+ /**
13
+ * 0.15's change path (plan PR 4a; formal/HotPathOne.tla's change actions):
14
+ * the changes one decision makes to the objects of the log, each logged
15
+ * by `change_input` (migration 0023) in the decision's own transaction.
16
+ *
17
+ * A decision of 0.14's writes members, grants and its entries as it always
18
+ * has, until 0.16 (the plan's dual fencing); with 0.15's log on, it also
19
+ * stages, beside each write, the op that write is (`stage`), and once its
20
+ * writes have run, `commit` logs them:
21
+ *
22
+ * 1. **Before the writes:** each object is read once, its seal checked
23
+ * (`tampered` if the vault never made it over the row, `stale` if it is
24
+ * not the seal of the object's newest change), and every op applied to
25
+ * it in turn with `applyOp`, the replay's own function. An op that does
26
+ * nothing there is a bug of the decision's, which fails it.
27
+ * 2. **After them,** in the same transaction: a member's row and grants,
28
+ * which the decision wrote, are read back and must be the object the ops
29
+ * leave (0.14's `#seal` refuses a grant written around the vault
30
+ * meanwhile: a difference left is the decision's own bug); then every
31
+ * op is one call of `change_input`, which takes the fence exclusive,
32
+ * checks the row's seal against the newest change again under it
33
+ * (ChangeChecksSeal), moves it only from the seal read (ChangeCAS), writes
34
+ * what it keeps of the object (an environment's, the roles', the
35
+ * policy's: `value`), and numbers the entry under the lock
36
+ * (ChangeNumberUnderLock).
37
+ *
38
+ * Locks: the decision took its members' rows, then the log's head, before
39
+ * any of this; `change_input` takes the same, then the fence: row, head,
40
+ * fence, Migration015.tla's RowFirst and ChangeHeadFirst.
41
+ *
42
+ * An object that has a row but no seal, and no change in the log, is one
43
+ * from before the cutover (plan PR 9): 0.14's log is its record, and the
44
+ * cutover's genesis seals it as 0.14 left it. A decision changes it as
45
+ * 0.14 does, and logs nothing of it here. So are the roles with no row
46
+ * yet, whose edits 0.14's policy holds until the cutover. The roles' and
47
+ * the policy's rows are written only sealed, by `change_input`: one with
48
+ * no seal was planted, and is refused. Any other object with neither row
49
+ * nor change is new: only an op that makes it applies.
50
+ */
51
+
52
+ /** Why an object a decision would change is not the log's: its seal is not the vault's over it, or not its newest change's. */
53
+ export class NotLogged extends Error {
54
+ readonly ref: Ref | null;
55
+ readonly fault: 'tampered' | 'stale';
56
+
57
+ constructor(ref: Ref | null, fault: 'tampered' | 'stale') {
58
+ super(`${ref === null ? 'an object' : `the ${ref.kind} ${ref.id}`} is ${fault === 'tampered' ? 'not sealed by the vault' : "not its newest change's"}: 0.15's log refuses to change it`);
59
+ this.ref = ref;
60
+ this.fault = fault;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * An object as the log has it: `unlogged`, 0.14's alone (no change, no seal
66
+ * on its row: before the cutover); `gone`, the log's newest change of it
67
+ * left none (a key archived or deleted: `secret.delete`); else its object,
68
+ * its row checked against its seal and that seal the newest change's.
69
+ */
70
+ export type Held = { log: 'unlogged' } | { log: 'gone' } | { log: 'live'; object: Input };
71
+
72
+ /**
73
+ * Whether `ref` has an object, from its log, never its row's columns (an
74
+ * `archived_at` a writer set is no archive), and the object, checked: what
75
+ * a decision may weigh (formal/SealedObjects.tla, LivenessFromLog,
76
+ * ReferenceInObject). Throws `NotLogged` for a row the log does not bear
77
+ * out: a seal no change wrote, a gone object's row live again, a live one's
78
+ * row gone, or a row its seal does not cover.
79
+ */
80
+ export async function heldByLog(keys: SealKeys, db: Queryable, ref: Ref & { kind: ReadKind }): Promise<Held> {
81
+ const [{ row, seal }, newest] = await Promise.all([readObject(db, ref), newestChange(db, ref)]);
82
+ if (newest === null) {
83
+ if (seal !== null) throw new NotLogged(ref, 'stale');
84
+ return { log: 'unlogged' };
85
+ }
86
+ if (newest.seal === null) {
87
+ if (row !== null || seal !== null) throw new NotLogged(ref, 'stale');
88
+ return { log: 'gone' };
89
+ }
90
+ if (row === null) throw new NotLogged(ref, 'stale');
91
+ if (!sealed(keys, ref, row, seal)) throw new NotLogged(ref, 'tampered');
92
+ if (seal === null || !newest.seal.equals(seal)) throw new NotLogged(ref, 'stale');
93
+ return { log: 'live', object: row };
94
+ }
95
+
96
+ /** One of these changes' rows moved from the seal it was read at, under the locks: whoever wrote it went around the vault. */
97
+ export class Moved extends Error {}
98
+
99
+ /** The keys a change is made with: seals (inputs.ts) and entries' MACs (log-entry.ts). */
100
+ export type ChangeKeys = { seals: SealKeys; mac: LogMacKey };
101
+
102
+ type Step = { op: Op; actor: string };
103
+ type Read = { logged: false } | { logged: true; seal: Buffer | null; now: Input | null; afters: (Input | null)[] };
104
+ type Staged = { ref: Ref & { kind: ReadKind }; steps: Step[]; read?: Read; old?: bigint };
105
+
106
+ export class Changes {
107
+ readonly #keys: ChangeKeys;
108
+ readonly #staged = new Map<string, Staged>();
109
+
110
+ constructor(keys: ChangeKeys) {
111
+ this.#keys = keys;
112
+ }
113
+
114
+ /** `op` to `ref`, by `actor`, as the decision's write will make it, in the order the writes run. */
115
+ stage(ref: Ref & { kind: ReadKind }, op: Op, actor: string): void {
116
+ const key = objectKey(ref);
117
+ const staged = this.#staged.get(key) ?? { ref, steps: [] };
118
+ if (staged.read !== undefined) throw new Error('a change staged after its object was read: stage every op before the writes run');
119
+ staged.steps.push({ op, actor });
120
+ this.#staged.set(key, staged);
121
+ }
122
+
123
+ /**
124
+ * The newest entry of 0.14's log this decision's changes to `ref`
125
+ * account for: a member's newest access entry (the `access_seq` 0.14's
126
+ * row names), or the `policy.change` an environment's protection or a
127
+ * role came with. Each of its changes carries it (`payloadOf`'s `old`),
128
+ * so that a use sees a 0.14 write the new log has not accounted for
129
+ * (Migration015.tla's UsesSeeOldWrites, plan PR 5).
130
+ */
131
+ cite(ref: Ref, seq: bigint): void {
132
+ const staged = this.#staged.get(objectKey(ref));
133
+ if (staged !== undefined) staged.old = seq;
134
+ }
135
+
136
+ /**
137
+ * An object as the log has it (`heldByLog`), checked against its seal, for
138
+ * a decision to weigh before it stages anything.
139
+ */
140
+ held(db: Queryable, ref: Ref & { kind: ReadKind }): Promise<Held> {
141
+ return heldByLog(this.#keys.seals, db, ref);
142
+ }
143
+
144
+ /** What a key with no object would be unarchived as (`keptObjects`): null for one its log never held. */
145
+ async kept(db: Queryable, ref: Ref & { kind: 'secret' }): Promise<Input | null> {
146
+ return (await keptObjects(this.#keys.seals, db, [ref])).get(objectKey(ref))?.object ?? null;
147
+ }
148
+
149
+ /**
150
+ * That an archived key's rows make its kept object, as its unarchive
151
+ * would read them, before a decision changes that object (an end, T134):
152
+ * `stale` otherwise, as a live object's rows not its seal's refuse it.
153
+ */
154
+ async rowsAsKept(db: Queryable, ref: Ref & { kind: 'secret' }, kept: Input): Promise<void> {
155
+ const { row } = await readObject(db, ref, { archived: true });
156
+ if (!sameObject(this.#keys.seals[0]!, ref, row, kept)) throw new NotLogged(ref, 'stale');
157
+ }
158
+
159
+ /**
160
+ * Read and check each object staged, in `db` before the decision's writes
161
+ * run, and apply its ops to it in turn.
162
+ */
163
+ async prepare(db: Queryable): Promise<void> {
164
+ for (const staged of this.#staged.values()) {
165
+ if (staged.read !== undefined) continue;
166
+ const read = await this.#read(db, staged.ref);
167
+ if (read.logged) {
168
+ for (const { op } of staged.steps) {
169
+ const after = applyOp(read.now, staged.ref, op);
170
+ if (after === undefined) throw new Error(`${op.op} does nothing to the ${staged.ref.kind} ${staged.ref.id} as the log has it: the decision and its change disagree`);
171
+ read.afters.push(after);
172
+ read.now = after;
173
+ }
174
+ }
175
+ staged.read = read;
176
+ }
177
+ }
178
+
179
+ async #read(db: Queryable, ref: Ref & { kind: ReadKind }): Promise<Read> {
180
+ const [{ row, seal }, newest] = await Promise.all([readObject(db, ref), newestChange(db, ref)]);
181
+ // Before the cutover: a row, unsealed, that the log has never heard of; or the roles, whose edits 0.14's policy
182
+ // holds until the cutover's genesis writes them (a role defined before it is 0.14's alone). Not the roles' or the
183
+ // policy's row, which only change_input writes, sealed: one with no seal was planted.
184
+ if (newest === null && seal === null) {
185
+ if (row === null ? ref.kind === 'roles' : ref.kind !== 'roles' && ref.kind !== 'policy') return { logged: false };
186
+ if (row !== null) throw new NotLogged(ref, 'tampered');
187
+ }
188
+ if (row !== null && !sealed(this.#keys.seals, ref, row, seal)) throw new NotLogged(ref, 'tampered');
189
+ // Gone (a key archived: `secret.delete`), its row no object and no seal: only an op that makes one applies.
190
+ if (newest !== null && newest.seal === null && row === null && seal === null) return { logged: true, seal, now: null, afters: [] };
191
+ // A row whose seal is not its newest change's; one with no row the log has a change for (deleted outright).
192
+ if (newest !== null ? seal === null || newest.seal === null || !newest.seal.equals(seal) : row !== null) throw new NotLogged(ref, 'stale');
193
+ return { logged: true, seal, now: row, afters: [] };
194
+ }
195
+
196
+ /**
197
+ * Log every staged change, in the decision's transaction, once its writes
198
+ * have run, at `at`, `written` holding the members' objects as the
199
+ * decision read them back after its writes, if it did: the numbers of their entries, which the decision
200
+ * waits to see sealed before it answers (R51). Throws `NotLogged` for a
201
+ * row a writer put back (ChangeChecksSeal), and `Moved` for one whose
202
+ * seal moved since it was read (ChangeCAS), which the decision then reads
203
+ * again.
204
+ */
205
+ async commit(tx: Transaction, at: number, written: ReadonlyMap<string, Input> = new Map()): Promise<bigint[]> {
206
+ if ([...this.#staged.values()].some((staged) => staged.read === undefined)) throw new Error('changes committed before they were prepared: prepare reads each object before the writes run');
207
+ const calls: ChangeCall[] = [];
208
+ for (const { ref, steps, read, old } of this.#staged.values()) {
209
+ if (!read!.logged) continue;
210
+ const { seal: base, now, afters } = read!;
211
+ // The member's own rows, which the decision wrote: they must be what the ops leave, or something wrote around it.
212
+ // As the decision read them back to seal them (`written`), or read again here.
213
+ // A grant written around the vault meanwhile is refused before this (`#seal`, under the member's row lock): what is
214
+ // left to differ is the decision's writes and its ops, a bug of the vault's, never the member's tampering.
215
+ if (ref.kind === 'principal') {
216
+ const held = written.get(objectKey(ref)) ?? (await readObject(tx, ref)).row;
217
+ if (!sameObject(this.#keys.seals[0]!, ref, held, now)) throw new Error(`the ${ref.kind} ${ref.id}'s rows are not what its staged changes leave: the decision and its changes disagree`);
218
+ }
219
+ let from = base;
220
+ steps.forEach(({ op, actor }, i) => {
221
+ // A key deleted again (an archived holder's end) leaves no object, and no seal.
222
+ const after = afters[i]!;
223
+ const seal = after === null ? null : sealOf(this.#keys.seals[0]!, ref, after);
224
+ const row = { at, requestId: randomUUID(), object: objectKey(ref), actor, payload: payloadOf(ref, op, old), seal };
225
+ calls.push({ ref, ...row, value: writes(ref) && after !== null ? canonical(after) : null, sealFrom: from, mac: entryMac(this.#keys.mac, { tree: 'changes', row }) });
226
+ from = seal;
227
+ });
228
+ }
229
+ let numbers: (bigint | null)[];
230
+ try {
231
+ numbers = await changeInputs(tx, calls);
232
+ } catch (error) {
233
+ if (isStale(error)) throw new NotLogged(calls.length === 1 ? calls[0]!.ref : null, 'stale');
234
+ throw error;
235
+ }
236
+ if (numbers.some((number) => number === null)) throw new Moved();
237
+ return numbers as bigint[];
238
+ }
239
+ }
240
+
241
+ /**
242
+ * What each key in `refs` would be unarchived as: the object its log's
243
+ * newest change with a seal left it (`newestSealedChanges`; an archive,
244
+ * `secret.delete`, leaves none), by the key's key in the log; null for a key
245
+ * the log never held (0.14's alone, before the cutover). That change's seal
246
+ * is checked over the object its payload makes (`tampered` otherwise). A
247
+ * key's every sealed change is its whole object: a write or a genesis.
248
+ * Read outside any transaction: `app_change` holds the seal to the log's
249
+ * newest sealed one again under the fence (formal/SealedObjects.tla,
250
+ * RestorePinned).
251
+ */
252
+ export async function keptObjects(keys: SealKeys, db: Queryable, refs: readonly (Ref & { kind: 'secret' })[]): Promise<Map<string, { object: Input; seal: Buffer } | null>> {
253
+ const found = await newestSealedChanges(db, refs);
254
+ const kept = new Map<string, { object: Input; seal: Buffer } | null>();
255
+ for (const ref of refs) {
256
+ const row = found.get(objectKey(ref));
257
+ if (row === undefined) {
258
+ kept.set(objectKey(ref), null);
259
+ continue;
260
+ }
261
+ // Its payload names the key (`loggedChange` holds it to the row's key in the log), and makes its whole object.
262
+ const change = loggedChange(row);
263
+ const object = change === null ? undefined : applyOp(null, ref, change.op);
264
+ if (object == null || !sealed(keys, ref, object, row.seal)) throw new NotLogged(ref, 'tampered');
265
+ kept.set(objectKey(ref), { object, seal: row.seal });
266
+ }
267
+ return kept;
268
+ }
269
+
270
+ /**
271
+ * The changes the app is about to write to objects its own rows hold (plan
272
+ * PR 4b, `prepare`): credentials, keys and environments, sealed for its
273
+ * transaction to commit (`app_change`, 0025), at `at`, by `actor`. Pure:
274
+ * what the vault reads is `kept`, each unarchived key's object as its log
275
+ * last sealed it (`keptObjects`).
276
+ *
277
+ * - **Before:** the row's columns as the app read them, and their seal,
278
+ * which must be the vault's over them (else `tampered`). That it is the
279
+ * object's newest change's, and still the row's, is `app_change`'s to
280
+ * check under its locks (the fence; an issue, the issue lock: 0026)
281
+ * (ChangeChecksSeal, ChangeCAS), as `change_input`
282
+ * does for the vault's own. A row with no seal is 0.14's, before the
283
+ * cutover: its call logs nothing, and only checks that the log has no
284
+ * change of its object either (as `Changes` leaves such a row alone). No
285
+ * before is no object: no row, or a key archived, whose seal its delete
286
+ * left null.
287
+ * - **The changes:** what turns the object before into the object after,
288
+ * each one of the replay's ops (`opsBetween`), applied with `applyOp` and
289
+ * sealed in turn, each from the seal the one before leaves; a key's
290
+ * delete leaves none. Columns no op reaches (a credential's principal, a
291
+ * key moved to another environment, an environment's parent or its
292
+ * protection, which is the vault's) are a bug of the app's, which fails
293
+ * the call. Columns no object holds (a credential's MAC, a key's name, an
294
+ * environment's display name) are no change.
295
+ * - **An unarchive** (`restores`) restores the object its key's log last
296
+ * sealed, its rows having no seal while archived (T134): rows that make
297
+ * any other (a version, a reference row or a source edited meanwhile, an
298
+ * older version put back, an end cleared) are `stale`. Its change carries
299
+ * that seal, which `app_change` holds to the log's newest sealed one. A
300
+ * key its log never held is 0.14's, before the cutover: logged nothing.
301
+ */
302
+ export function prepareChanges(keys: ChangeKeys, at: number, actor: string, changes: readonly ObjectChange[], kept: ReadonlyMap<string, { object: Input; seal: Buffer } | null>): PreparedChange[] {
303
+ const prepared: PreparedChange[] = [];
304
+ for (const change of changes) {
305
+ const { kind, id, before } = change;
306
+ if (!Object.hasOwn(OBJECTS, kind)) throw new Error(`the app makes no change to a ${String(kind)}`);
307
+ const after = objectOf(change);
308
+ if ((after !== null && refOf(after).id !== id) || (before !== null && before.columns.id !== id)) {
309
+ throw new Error(`a change to a ${kind} names its row by its id, before and after`);
310
+ }
311
+ const ref: Ref = { kind, id };
312
+ const object = objectKey(ref);
313
+ const unlogged = { kind, id, requestId: null, at, object, actor, payload: null, sealFrom: null, seal: null, mac: null, restores: null };
314
+ if (before !== null && before.seal === null) {
315
+ prepared.push(unlogged);
316
+ continue;
317
+ }
318
+ let restores: Buffer | null = null;
319
+ if (change.kind === 'secret' && change.restores === true) {
320
+ if (before !== null || after === null) throw new Error(`an unarchive makes an archived key's object again: the ${kind} ${id} is none to restore`);
321
+ const last = kept.get(object);
322
+ if (last === undefined) throw new Error(`the ${kind} ${id} is restored from what its log last sealed, which was not read`);
323
+ if (last === null) {
324
+ prepared.push(unlogged);
325
+ continue;
326
+ }
327
+ if (!sameObject(keys.seals[0]!, ref, last.object, after)) throw new NotLogged(ref, 'stale');
328
+ restores = last.seal;
329
+ }
330
+ let now: Input | null = before === null ? null : OBJECTS[kind](before.columns as never);
331
+ let from: Buffer | null = before === null ? null : Buffer.from(before.seal!, 'base64');
332
+ if (now !== null && !sealed(keys.seals, ref, now, from)) throw new NotLogged(ref, 'tampered');
333
+ for (const op of opsBetween(now, after)) {
334
+ const next = applyOp(now, ref, op);
335
+ if (next === undefined) throw new Error(`${op.op} does nothing to the ${kind} ${id}: the app's write and its change disagree`);
336
+ // A key deleted leaves no object, and no seal: its row's is null, as a use reads it (decide.ts, checkSeals).
337
+ const seal = next === null ? null : sealOf(keys.seals[0]!, ref, next);
338
+ const row = { at, requestId: randomUUID(), object, actor, payload: payloadOf(ref, op), seal };
339
+ const mac = entryMac(keys.mac, { tree: 'changes', row });
340
+ prepared.push({
341
+ kind, id, ...row, seal: seal?.toString('base64') ?? null, sealFrom: from?.toString('base64') ?? null, mac: mac.toString('base64'),
342
+ restores: restores?.toString('base64') ?? null,
343
+ });
344
+ now = next;
345
+ from = seal;
346
+ }
347
+ if (!sameObject(keys.seals[0]!, ref, now, after)) throw new Error(`the ${kind} ${id} the app would write is no change the log knows from the one it read`);
348
+ }
349
+ return prepared;
350
+ }
351
+
352
+ /** Each kind of the app's objects, from its columns (objects.ts). */
353
+ const OBJECTS = { credential: credentialObject, connection: connectionObject, secret: secretObject, environment: environmentObject } as const;
354
+
355
+ /** The object a change leaves: null for a key archived or erased. */
356
+ function objectOf(change: ObjectChange): Input | null {
357
+ if (change.after === null) return null;
358
+ return OBJECTS[change.kind](change.after as never);
359
+ }
360
+
361
+ /**
362
+ * The ops that make `after` from `before`, in the order they apply; what
363
+ * else differs, they do not reach.
364
+ * - a credential: its issue (and a revoke, issued revoked); its scope
365
+ * written, its end moved, then its revoke;
366
+ * - an MCP connection: made, then its revoke (made revoked: a step-up's
367
+ * earlier one never is); nothing else of it moves;
368
+ * - a key: written whole, its value or its reference (`secret.write`); or
369
+ * deleted, archived or erased;
370
+ * - an environment: made, with the protection its id was given; renamed,
371
+ * archived or restored, its project archived or restored, then deleted.
372
+ */
373
+ function opsBetween(before: Input | null, after: Input | null): Op[] {
374
+ if (after === null) return before === null ? [] : [{ op: 'secret.delete' }];
375
+ switch (after.kind) {
376
+ case 'credential': {
377
+ if (before === null) {
378
+ const { principal, caller, scope, expiresAt, generation } = after;
379
+ return [{ op: 'credential.issue', principal, caller, scope, expiresAt, generation }, ...(after.revoked ? [{ op: 'credential.revoke' } as const] : [])];
380
+ }
381
+ if (before.kind !== 'credential') return [];
382
+ const ops: Op[] = [];
383
+ if (canonical(before.scope) !== canonical(after.scope)) ops.push({ op: 'credential.scope', scope: after.scope });
384
+ if (before.expiresAt !== after.expiresAt) ops.push({ op: 'credential.renew', expiresAt: after.expiresAt });
385
+ if (!before.revoked && after.revoked) ops.push({ op: 'credential.revoke' });
386
+ return ops;
387
+ }
388
+ case 'connection': {
389
+ if (before === null) return [{ op: 'connection.make', value: { ...after, revoked: false } }, ...(after.revoked ? [{ op: 'connection.revoke' } as const] : [])];
390
+ if (before.kind !== 'connection') return [];
391
+ return !before.revoked && after.revoked ? [{ op: 'connection.revoke' }] : [];
392
+ }
393
+ case 'secret': {
394
+ if (before !== null && canonical(before) === canonical(after)) return [];
395
+ const { projectId, environmentId, version, nature, key, digest, source, reference } = after;
396
+ return [{ op: 'secret.write', projectId, environmentId, version, nature, key, digest, source, reference }];
397
+ }
398
+ case 'environment': {
399
+ const ops: Op[] = [];
400
+ if (before === null) {
401
+ const { projectId, slug, owner, parentId } = after;
402
+ ops.push({ op: 'environment.create', projectId, slug, owner, parentId, protected: after.protected });
403
+ if (after.archived) ops.push({ op: 'environment.archive', archived: true });
404
+ if (after.projectArchived) ops.push({ op: 'project.archive', archived: true });
405
+ return ops;
406
+ }
407
+ if (before.kind !== 'environment') return [];
408
+ if (before.slug !== after.slug) ops.push({ op: 'environment.rename', slug: after.slug });
409
+ if (before.archived !== after.archived) ops.push({ op: 'environment.archive', archived: after.archived });
410
+ // A deletion clears it (`environment.delete`): no restore of the project's logged on the way.
411
+ if (before.projectArchived !== after.projectArchived && !after.deleted) ops.push({ op: 'project.archive', archived: after.projectArchived });
412
+ if (!before.deleted && after.deleted) ops.push({ op: 'environment.delete' });
413
+ return ops;
414
+ }
415
+ default:
416
+ return [];
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Whether `change_input` writes the object's rows from its value: those the
422
+ * vault's login cannot write. A key's, the one change the vault makes of
423
+ * one: its reference's end (`ended_at`, 0029).
424
+ */
425
+ function writes(ref: Ref): boolean {
426
+ return ref.kind === 'environment' || ref.kind === 'secret' || ref.kind === 'roles' || ref.kind === 'policy';
427
+ }
428
+
429
+ /** Two objects alike as a seal sees them (a principal's grants a set). */
430
+ function sameObject(key: Buffer, ref: Ref, a: Input | null, b: Input | null): boolean {
431
+ if (a === null || b === null) return a === b;
432
+ return sealOf(key, ref, a).equals(sealOf(key, ref, b));
433
+ }
434
+
435
+ /**
436
+ * A change's payload, as `log_changes` holds it: the op, the object it is
437
+ * to (`ref`), which the entry's `object` column holds only as a key a
438
+ * replay cannot read back, and, when it has one, the newest entry of
439
+ * 0.14's log it accounts for (`old`, its seq in decimal: `cite`).
440
+ * Canonical JSON, so that a retry sends the same bytes (`change_input`
441
+ * finds its entry by request id and payload).
442
+ */
443
+ export function payloadOf(ref: Ref, op: Op, old?: bigint): string {
444
+ return canonical({ ...op, ref: { kind: ref.kind, id: ref.id }, ...(old === undefined ? {} : { old: old.toString() }) });
445
+ }
446
+
447
+ /**
448
+ * A `log_changes` row as the replay takes it (log-replay.ts): its object
449
+ * from its payload's `ref`, or a genesis's value, and checked against the
450
+ * row's key; null for a payload that names no object, or another than the
451
+ * row's.
452
+ */
453
+ export function loggedChange(row: { number: bigint; at: number; object: string; payload: string; seal: Buffer | null }): LoggedChange | null {
454
+ // What a change records besides its op, which the replay leaves: the 0.14 entry it accounts for, and a sign-in's, a revoke's, an issue's or a binding's own (credentials.ts).
455
+ const { ref: named, old: _, signIn: _signIn, revoke: _revoke, issue: _issue, bind: _bind, unbind: _unbind, ...op } = JSON.parse(row.payload) as Op & {
456
+ ref?: Ref; old?: string; signIn?: unknown; revoke?: unknown; issue?: unknown; bind?: unknown; unbind?: unknown;
457
+ };
458
+ const ref = named ?? (op.op === 'genesis' ? refOf(op.value) : undefined);
459
+ if (ref === undefined || objectKey(ref) !== row.object) return null;
460
+ return { kind: 'change', number: row.number, at: row.at, ref: { kind: ref.kind, id: ref.id }, op: op as Op, seal: row.seal };
461
+ }
462
+
463
+ /**
464
+ * The cutover's change for one object (plan PR 9): its row sealed as it
465
+ * is, the object it holds the payload's `value`. An environment's carries
466
+ * the object as `value` too, which writes its protection, which 0.14 kept
467
+ * in its policy; the roles' and the policy's rows are made from the
468
+ * payload. From no seal: an object the log has never held. `old`: the
469
+ * newest entry of 0.14's it accounts for (`cite`): a member's `access_seq`,
470
+ * or the `policy.change` an environment's protection or the roles came
471
+ * from.
472
+ */
473
+ export function genesisCall(keys: ChangeKeys, ref: Ref, input: Input, at: number, actor: string, old?: bigint): ChangeCall {
474
+ const op: Op = { op: 'genesis', value: input };
475
+ const seal = sealOf(keys.seals[0]!, ref, input);
476
+ const row = { at, requestId: randomUUID(), object: objectKey(ref), actor, payload: payloadOf(ref, op, old), seal };
477
+ return { ref, ...row, value: ref.kind === 'environment' ? canonical(input) : null, sealFrom: null, mac: entryMac(keys.mac, { tree: 'changes', row }) };
478
+ }