@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/src/store.ts CHANGED
@@ -1,12 +1,16 @@
1
1
  import { EVERYWHERE, ownsInstance, type InstanceRole, type Scope } from '@coffre/core/access';
2
2
  import type { Author, StoredEntry } from '@coffre/core/audit';
3
- import { ACCESS_ACTIONS, type SecretRef, type WrappedKey } from '@coffre/core/vault';
4
- import { prepared, tablesOf, type Queryable, type Transaction } from '@coffre/db';
3
+ import { deletedWithProject, isTombstone, TOMBSTONE } from '@coffre/core/schemas';
4
+ import { ACCESS_ACTIONS, type BindingColumns, type ConnectionColumns, type CredentialColumns, type EnvironmentColumns, type SecretRef, type WrappedKey } from '@coffre/core/vault';
5
+ import { prepared, tablesOf, type Database, type Queryable, type Transaction } from '@coffre/db';
5
6
  import { changedRows, clockMillis, engineOf, forUpdate, rowsOf, tombstone, truth } from '@coffre/db/dialect';
6
7
  import { readGrants, roleColumns, storedRole, type GrantRow } from '@coffre/db/grants';
7
8
  import { entryColumns, withinBytes } from '@coffre/db/log';
8
9
 
9
10
  import { MAX_BULK_WINDOW_MINUTES } from './config.ts';
11
+ import { objectKey, type EnvironmentInput, type Input, type Kind, type Ref } from './inputs.ts';
12
+ import { CUTOVER_OBJECT } from './log-entry.ts';
13
+ import { configObject, environmentObject, memberStanding, principalObject, secretObject } from './objects.ts';
10
14
  import { and, asc, count, desc, eq, gt, gte, inArray, isNotNull, isNull, like, lt, lte, or, sql, type SQL, type SQLWrapper } from 'drizzle-orm';
11
15
 
12
16
  /**
@@ -182,7 +186,9 @@ export async function instanceOwners(db: Queryable): Promise<Member[]> {
182
186
  * removal off for ever. So each row comes with an advisory lock on its
183
187
  * principal, which the lock manager queues in order: a read asked after a
184
188
  * writer waits behind it. The same statement takes it, then the row, one
185
- * principal after another.
189
+ * principal after another. 0.15's `change_input` (0022_log_015) takes the
190
+ * same two in one statement, before the log's head, and
191
+ * scripts/test-schema.sh holds the pair to that order: keep them together.
186
192
  */
187
193
  export async function lockMembers(tx: Transaction, principals: readonly string[], shared = false): Promise<Map<string, Member>> {
188
194
  const { vaultMembers } = tablesOf(tx);
@@ -252,12 +258,6 @@ function at(db: Queryable, place: Place) {
252
258
  );
253
259
  }
254
260
 
255
- /**
256
- * Each of these environments that exists, by id, with its project, its
257
- * slug, what grants on one slug in every project match, and whether it was
258
- * deleted, alone or with its project: then no key under it opens, or is
259
- * wrapped, whatever grant would cover it.
260
- */
261
261
  /** An environment as a decision reads it. */
262
262
  export type EnvironmentRow = {
263
263
  projectId: string;
@@ -266,30 +266,143 @@ export type EnvironmentRow = {
266
266
  parentId: string | null;
267
267
  deleted: boolean;
268
268
  archived: boolean;
269
+ /** Read when asked (`seals`): what 0.15's log holds the row to. */
270
+ sealed?: EnvironmentSeal;
269
271
  };
270
272
 
273
+ /**
274
+ * What 0.15's log holds an environment's row to (T159): the row's object
275
+ * (`environmentObject`), its seal, and its object's newest change's seal,
276
+ * null for none. The vault checks them before it decides on the row.
277
+ */
278
+ export type EnvironmentSeal = { object: EnvironmentInput; seal: Buffer | null; newest: Buffer | null };
279
+
280
+ /**
281
+ * Each of these environments that exists, by id, with its project, its
282
+ * slug, what grants on one slug in every project match, and whether it was
283
+ * deleted, alone or with its project: then no key under it opens, or is
284
+ * wrapped, whatever grant would cover it; `all`, every one. With `seals`,
285
+ * what 0.15's log holds each to as well, in the same statement on Postgres.
286
+ */
271
287
  export async function environmentsById(
272
288
  db: Queryable,
273
- ids: readonly string[],
289
+ ids: readonly string[] | 'all',
290
+ { seals = false }: { seals?: boolean } = {},
274
291
  ): Promise<Map<string, EnvironmentRow>> {
275
- if (ids.length === 0) return new Map();
292
+ if (ids !== 'all' && ids.length === 0) return new Map();
293
+ // With seals, on SQLite the rows' newest seals in a statement after, and anywhere those of ids with no row: one snapshot.
294
+ return seals ? oneSnapshot(db, (on) => environmentRows(on, ids, true)) : environmentRows(db, ids, false);
295
+ }
296
+
297
+ async function environmentRows(db: Queryable, ids: readonly string[] | 'all', seals: boolean): Promise<Map<string, EnvironmentRow>> {
276
298
  const { environments, projects } = tablesOf(db);
277
- const rows = await db
278
- .select({
279
- id: environments.id,
280
- projectId: environments.projectId,
281
- slug: environments.slug,
282
- // A personal config's owner, and the environment it is a config of, whose read it takes.
283
- owner: environments.owner,
284
- parentId: environments.parentId,
285
- deleted: truth(or(tombstone(environments.slug), tombstone(projects.slug))!),
286
- // Only what names a fork asks it: nothing is decided on it.
287
- archived: truth(or(isNotNull(environments.archivedAt), isNotNull(projects.archivedAt))!),
288
- })
299
+ const postgres = engineOf(db) === 'postgres';
300
+ const columns = {
301
+ id: environments.id,
302
+ projectId: environments.projectId,
303
+ slug: environments.slug,
304
+ // A personal config's owner, and the environment it is a config of, whose read it takes.
305
+ owner: environments.owner,
306
+ parentId: environments.parentId,
307
+ deleted: truth(or(tombstone(environments.slug), tombstone(projects.slug))!),
308
+ // Only what names a fork asks it: nothing is decided on it.
309
+ archived: truth(or(isNotNull(environments.archivedAt), isNotNull(projects.archivedAt))!),
310
+ };
311
+ const sealColumns = {
312
+ protected: environments.protected,
313
+ ownArchived: truth(isNotNull(environments.archivedAt)),
314
+ projectArchived: truth(isNotNull(projects.archivedAt)),
315
+ seal: environments.seal,
316
+ // One probe of its object's changes, newest first (`log_changes_object_idx`, which holds the seal).
317
+ ...(postgres ? { newest: sql<Buffer | Uint8Array | null>`(SELECT l.seal FROM log_changes l WHERE l.object = log_object('environment', ${environments.id}::text) ORDER BY l.number DESC LIMIT 1)` } : {}),
318
+ };
319
+ const rows = (await db
320
+ .select(seals ? { ...columns, ...sealColumns } : columns)
289
321
  .from(environments)
290
322
  .innerJoin(projects, eq(projects.id, environments.projectId))
291
- .where(inArray(environments.id, [...new Set(ids)]));
292
- return new Map(rows.map(({ id, ...environment }) => [id, environment]));
323
+ .where(ids === 'all' ? undefined : inArray(environments.id, [...new Set(ids)]))) as (Omit<EnvironmentRow, 'sealed'> & { id: string } & Partial<SealedColumns>)[];
324
+ // SQLite has no `log_object`: the newest changes in a statement of their own, by the keys the vault computes.
325
+ const newest = seals && !postgres ? await newestSeals(db, rows.map((row) => ({ kind: 'environment', id: row.id }))) : null;
326
+ // An id asked with no row, which the log holds a change of: deleted outright around it (review r5). A statement more,
327
+ // only when a row is missing.
328
+ const rowless = seals && ids !== 'all' ? [...new Set(ids)].filter((id) => !rows.some((row) => row.id === id)) : [];
329
+ const gone = await loggedEnvironments(db, rowless);
330
+ return new Map<string, EnvironmentRow>([...[...gone].map(([id, last]) => [id, goneEnvironment(id, last)] as const), ...rows.map(({ id, protected: guarded, ownArchived, projectArchived, seal, newest: last, ...environment }) => [id, {
331
+ ...environment,
332
+ ...(seals ? {
333
+ sealed: environmentSeal(
334
+ { id, projectId: environment.projectId, slug: environment.slug, owner: environment.owner, parentId: environment.parentId, protected: guarded == null ? null : Boolean(guarded), archived: Boolean(ownArchived), projectArchived: Boolean(projectArchived) },
335
+ seal ?? null,
336
+ newest === null ? last ?? null : newest.get(objectKey({ kind: 'environment', id })) ?? null,
337
+ ),
338
+ } : {}),
339
+ }] as const)]);
340
+ }
341
+
342
+ /**
343
+ * A place the log holds a change of, which no row answers to (deleted outright around the log, or its project's): read
344
+ * as a row whose seal is none, which no check passes (`#placeFault`), so that every decision on it fails closed as on
345
+ * any other row the log does not hold, a protection on it kept (review r5). Its other columns name nothing.
346
+ */
347
+ export function goneEnvironment(id: string, newest: Buffer | null): EnvironmentRow {
348
+ const columns = { id, projectId: '', slug: '', owner: null, parentId: null, protected: null, archived: false, projectArchived: false };
349
+ return { projectId: '', slug: '', owner: null, parentId: null, deleted: false, archived: false, sealed: { object: environmentObject(columns), seal: null, newest } };
350
+ }
351
+
352
+ /**
353
+ * Of these environment ids, those the log holds a change of: by its newest seal, null for one that left none. One probe
354
+ * of each object's changes.
355
+ */
356
+ async function loggedEnvironments(db: Queryable, ids: readonly string[]): Promise<Map<string, Buffer | null>> {
357
+ if (ids.length === 0) return new Map();
358
+ if (engineOf(db) !== 'postgres') {
359
+ const found = await newestSeals(db, ids.map((id) => ({ kind: 'environment', id })));
360
+ return new Map(ids.flatMap((id) => (found.has(objectKey({ kind: 'environment', id })) ? [[id, found.get(objectKey({ kind: 'environment', id })) ?? null] as const] : [])));
361
+ }
362
+ // Still no row as of this statement: one made since the read before is no place deleted (review r6).
363
+ const rows = await rowsOf<{ id: string; seal: Buffer | Uint8Array | null }>(db, sql`SELECT g.id::text AS id, l.seal FROM unnest(${sql.param([...ids])}::uuid[]) AS g (id)
364
+ 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
365
+ WHERE NOT EXISTS (SELECT 1 FROM environments e JOIN projects p ON p.id = e.project_id WHERE e.id = g.id)`);
366
+ return new Map(rows.map((row) => [row.id, bytes(row.seal)]));
367
+ }
368
+
369
+ /**
370
+ * `read`, on SQLite, where a read of seals takes two statements, in one transaction, one snapshot of the file, so that
371
+ * no change commits between them (review r6): its own, or the one `db` is already. On Postgres as it is: each such read
372
+ * is one statement, and the one more for ids with no row checks again that none answers to them.
373
+ */
374
+ export function oneSnapshot<T>(db: Queryable, read: (on: Queryable) => Promise<T>): Promise<T> {
375
+ return engineOf(db) === 'postgres' || 'rollback' in db ? read(db) : (db as Database).transaction((tx) => read(tx));
376
+ }
377
+
378
+ /** The columns `environmentsById` reads with `seals`, as the drivers answer them. */
379
+ type SealedColumns = { protected: boolean | number | null; ownArchived: boolean | number; projectArchived: boolean | number; seal: Buffer | Uint8Array | null; newest: Buffer | Uint8Array | null };
380
+
381
+ /** An environment's row as 0.15's log holds it to (`EnvironmentSeal`), from its columns and the two seals as read. */
382
+ export function environmentSeal(columns: EnvironmentColumns, seal: Buffer | Uint8Array | null, newest: Buffer | Uint8Array | null): EnvironmentSeal {
383
+ return { object: environmentObject(columns), seal: bytes(seal), newest: bytes(newest) };
384
+ }
385
+
386
+ /**
387
+ * The seal each object's newest change left, by its key in the log
388
+ * (`objectKey`), null for one that left none; an object with no change is
389
+ * not in the map. One probe of each object's changes, newest first.
390
+ */
391
+ export async function newestSeals(db: Queryable, refs: readonly Ref[]): Promise<Map<string, Buffer | null>> {
392
+ if (refs.length === 0) return new Map();
393
+ const rows = await rowsOf<{ object: string; seal: Buffer | Uint8Array | null }>(db, newestSealsQuery(refs));
394
+ return new Map(rows.map((row) => [row.object, bytes(row.seal)]));
395
+ }
396
+
397
+ /**
398
+ * `newestSeals`' statement, on SQLite: from the keys asked, one JSON list however many (a compound SELECT stops at 500
399
+ * terms, review r2), each its newest change's number by one probe of `log_changes_object_idx` (its `max`), then that
400
+ * row by its number. The same work however long each object's history (R48).
401
+ */
402
+ export function newestSealsQuery(refs: readonly Ref[]): SQL {
403
+ const keys = JSON.stringify([...new Set(refs.map((ref) => objectKey(ref)))]);
404
+ return sql`SELECT c.object, c.seal FROM json_each(${keys}) k
405
+ JOIN log_changes c ON c.object = k.value AND c.number = (SELECT max(d.number) FROM log_changes d WHERE d.object = k.value)`;
293
406
  }
294
407
 
295
408
  export async function deleteGrant(tx: Transaction, principal: string, place: Place): Promise<void> {
@@ -333,25 +446,20 @@ export async function places(
333
446
  db: Queryable,
334
447
  projectIds: readonly string[],
335
448
  environmentIds: readonly string[],
336
- ): Promise<{ projects: Set<string>; environments: Map<string, { projectId: string; slug: string }>; deleted: Set<string> }> {
337
- const { projects, environments } = tablesOf(db);
449
+ { seals = false }: { seals?: boolean } = {},
450
+ ): Promise<{ projects: Set<string>; environments: Map<string, EnvironmentRow>; deleted: Set<string> }> {
451
+ const { projects } = tablesOf(db);
338
452
  const [foundProjects, foundEnvironments] = await Promise.all([
339
453
  projectIds.length === 0
340
454
  ? []
341
455
  : db.select({ id: projects.id, deleted: truth(tombstone(projects.slug)) }).from(projects).where(inArray(projects.id, [...projectIds])),
342
- environmentIds.length === 0
343
- ? []
344
- : db
345
- .select({ id: environments.id, projectId: environments.projectId, slug: environments.slug, deleted: truth(or(tombstone(environments.slug), tombstone(projects.slug))!) })
346
- .from(environments)
347
- .innerJoin(projects, eq(projects.id, environments.projectId))
348
- .where(inArray(environments.id, [...environmentIds])),
456
+ environmentsById(db, environmentIds, { seals }),
349
457
  ]);
350
458
  return {
351
459
  projects: new Set(foundProjects.map((row) => row.id)),
352
- environments: new Map(foundEnvironments.map((row) => [row.id, { projectId: row.projectId, slug: row.slug }])),
460
+ environments: foundEnvironments,
353
461
  // The projects and environments among them that were deleted, alone or with their project.
354
- deleted: new Set([...foundProjects, ...foundEnvironments].filter((row) => row.deleted).map((row) => row.id)),
462
+ deleted: new Set([...foundProjects.filter((row) => row.deleted).map((row) => row.id), ...[...foundEnvironments].filter(([, row]) => row.deleted).map(([id]) => id)]),
355
463
  };
356
464
  }
357
465
 
@@ -476,6 +584,19 @@ export async function endedReferences(db: Queryable, seqs: readonly bigint[]): P
476
584
  return new Set(rows.map((row) => row.relatedSeq!));
477
585
  }
478
586
 
587
+ /**
588
+ * The vault's allowed `reference.end` entries naming these `reference.create`
589
+ * entries, whole, for a caller to hold to their MACs: a break weighs only
590
+ * authentic ends (a forged one must not stop a genuine break), where a read
591
+ * takes any (`endedReferences`).
592
+ */
593
+ export async function referenceEnds(db: Queryable, seqs: readonly bigint[]): Promise<StoredEntry[]> {
594
+ if (seqs.length === 0) return [];
595
+ const { auditLog } = tablesOf(db);
596
+ return stored(await db.select(entryColumns(db)).from(auditLog)
597
+ .where(and(eq(auditLog.author, 'vault'), eq(auditLog.action, 'reference.end'), eq(auditLog.decision, 'allow'), inArray(auditLog.relatedSeq, [...new Set(seqs)]))));
598
+ }
599
+
479
600
  /** The project each environment is in: a holder's ids, checked against each other. */
480
601
  /** Whether a project of this id is there, and not deleted. */
481
602
  export async function projectLive(db: Queryable, id: string): Promise<boolean> {
@@ -802,3 +923,678 @@ function heldAs(secretVersions: ReturnType<typeof tablesOf>['secretVersions'], c
802
923
  return sql`SELECT ${column} FROM secret_versions same WHERE same.secret_id = ${secretVersions.secretId}
803
924
  AND same.iv = ${secretVersions.iv} AND same.auth_tag = ${secretVersions.authTag} ORDER BY same.version DESC LIMIT 1`;
804
925
  }
926
+
927
+ // --- 0.15's log: objects and changes ----------------------------------------------
928
+
929
+ /**
930
+ * An object of 0.15's log as its rows hold it (inputs.ts), and the seal on
931
+ * them; `row` null when it has none. The roles' and the policy's rows are
932
+ * their objects as the change that made them wrote them.
933
+ *
934
+ * Each object is read from its rows as the change path writes them
935
+ * (objects.ts): an environment is deleted when its own slug is a tombstone,
936
+ * not its project's (a project's deletion writes its environments' rows,
937
+ * plan PR 4b), unprotected until a change or the cutover says otherwise,
938
+ * and holds its project's archival apart from its own; a key's source is
939
+ * its newest reference row's until that row has ended.
940
+ */
941
+ export type HeldObject = { row: Input | null; seal: Buffer | null };
942
+
943
+ /**
944
+ * 0.15's log as stored, whole: its changes and its uses, each with the
945
+ * columns its replay reads (`replayStored`), bytes as the driver gives them.
946
+ */
947
+ export async function loggedRows(db: Queryable): Promise<{
948
+ changes: { number: string | number; at: string | number; object: string; payload: string; seal: Uint8Array | null }[];
949
+ uses: { number: string | number; at: string | number; credential: string; secret: string; purpose: number | string; place: string | null }[];
950
+ }> {
951
+ return {
952
+ changes: await rowsOf(db, sql`SELECT number, at, object, payload, seal FROM log_changes`),
953
+ uses: await rowsOf(db, sql`SELECT number, at, credential, secret, purpose, place FROM log_uses`),
954
+ };
955
+ }
956
+
957
+ /**
958
+ * The kinds whose objects the change path reads: the vault's own, an
959
+ * environment's, a key's (its reference's end), the roles' and the policy's.
960
+ */
961
+ export type ReadKind = Exclude<Kind, 'credential' | 'connection' | 'binding'>;
962
+
963
+ /** `archived`: a key's rows read even while it is archived, as its unarchive would make them (an end's check, T134). */
964
+ export async function readObject(db: Queryable, ref: Ref & { kind: ReadKind }, { archived = false } = {}): Promise<HeldObject> {
965
+ const { vaultMembers, environments, projects, logConfig } = tablesOf(db);
966
+ switch (ref.kind) {
967
+ case 'principal': {
968
+ const [row] = await db
969
+ .select({ status: vaultMembers.status, generation: vaultMembers.generation, owner: vaultMembers.owner, role: vaultMembers.role, scope: vaultMembers.scope, seal: vaultMembers.seal })
970
+ .from(vaultMembers)
971
+ .where(eq(vaultMembers.principal, ref.id));
972
+ if (row === undefined) return { row: null, seal: null };
973
+ return {
974
+ row: principalObject({ principal: ref.id, status: row.status, generation: row.generation, ...memberStanding(row) }, await readGrants(db, { principal: ref.id })),
975
+ seal: row.seal ?? null,
976
+ };
977
+ }
978
+ case 'environment': {
979
+ const [row] = await db
980
+ .select({
981
+ projectId: environments.projectId,
982
+ slug: environments.slug,
983
+ owner: environments.owner,
984
+ parentId: environments.parentId,
985
+ protected: environments.protected,
986
+ archivedAt: environments.archivedAt,
987
+ projectArchived: truth(isNotNull(projects.archivedAt)),
988
+ seal: environments.seal,
989
+ })
990
+ .from(environments)
991
+ .leftJoin(projects, eq(projects.id, environments.projectId))
992
+ .where(eq(environments.id, ref.id));
993
+ if (row === undefined) return { row: null, seal: null };
994
+ const { projectId, slug, owner, parentId, archivedAt } = row;
995
+ return {
996
+ row: environmentObject({ id: ref.id, projectId, slug, owner, parentId, protected: row.protected, archived: archivedAt !== null, projectArchived: Boolean(row.projectArchived) }),
997
+ seal: row.seal ?? null,
998
+ };
999
+ }
1000
+ case 'secret': {
1001
+ // Unarchived (unless asked): an archived key is no object (`secret.delete`). Its version's plaintext, for a plain one's digest.
1002
+ const [row] = await rowsOf<{
1003
+ project_id: string; environment_id: string; seal: Buffer | Uint8Array | null; version_id: string | null; source: string | null; reference: string | null;
1004
+ sensitive: boolean | number | null; plaintext: string | null; kek_provider: string | null; kek_id: string | null; kek_version: string | null; wrapped_dek: Buffer | Uint8Array | null;
1005
+ }>(db, sql`SELECT s.project_id, s.environment_id, s.seal, s.current_version_id AS version_id, ${newestSource(sql`s.id`)} AS source,
1006
+ ${newestReference(sql`s.id`)} AS reference,
1007
+ v.sensitive, v.plaintext, v.kek_provider, v.kek_id, v.kek_version, v.wrapped_dek
1008
+ FROM secrets s LEFT JOIN secret_versions v ON v.id = s.current_version_id WHERE s.id = ${ref.id} ${archived ? sql`` : sql`AND s.archived_at IS NULL`}`);
1009
+ if (row === undefined) return { row: null, seal: null };
1010
+ const version = row.version_id === null || row.wrapped_dek === null ? null : {
1011
+ id: row.version_id, sensitive: Boolean(row.sensitive), plaintext: row.plaintext,
1012
+ kekProvider: row.kek_provider!, kekId: row.kek_id!, kekVersion: row.kek_version!, wrappedDek: Buffer.from(row.wrapped_dek).toString('base64'),
1013
+ };
1014
+ return {
1015
+ row: secretObject({
1016
+ id: ref.id, projectId: row.project_id, environmentId: row.environment_id,
1017
+ source: row.version_id === null ? row.source : null, reference: row.version_id === null ? row.reference : null, version,
1018
+ }),
1019
+ seal: row.seal === null ? null : Buffer.from(row.seal),
1020
+ };
1021
+ }
1022
+ case 'roles': case 'policy': {
1023
+ const [row] = await db.select({ value: logConfig.value, seal: logConfig.seal }).from(logConfig).where(eq(logConfig.name, ref.kind));
1024
+ if (row === undefined) return { row: null, seal: null };
1025
+ // Whatever it holds: a value that is not the object fails its seal.
1026
+ return { row: configObject(row.value), seal: row.seal ?? null };
1027
+ }
1028
+ }
1029
+ }
1030
+
1031
+ /** The object's newest change in the log: whether it has one, and the seal it left (HotPathOne's NewestIn). */
1032
+ export async function newestChange(db: Queryable, ref: Ref): Promise<{ seal: Buffer | null } | null> {
1033
+ const { logChanges } = tablesOf(db);
1034
+ const [row] = await db
1035
+ .select({ seal: logChanges.seal })
1036
+ .from(logChanges)
1037
+ .where(eq(logChanges.object, objectKey(ref)))
1038
+ .orderBy(desc(logChanges.number))
1039
+ .limit(1);
1040
+ return row === undefined ? null : { seal: row.seal ?? null };
1041
+ }
1042
+
1043
+ /** `newestSealedChanges`' statement, which its cost test explains. */
1044
+ export function newestSealedQuery(refs: readonly Ref[]): SQL {
1045
+ const newest = sql.join(refs.map((ref) => sql`(SELECT d.number FROM log_changes d WHERE d.object = ${objectKey(ref)} AND d.seal IS NOT NULL ORDER BY d.number DESC LIMIT 1)`), sql`, `);
1046
+ return sql`SELECT c.number, c.at, c.object, c.payload, c.seal FROM log_changes c WHERE c.number IN (${newest})`;
1047
+ }
1048
+
1049
+ /**
1050
+ * Each object's newest change that left a seal, by its key in the log: what
1051
+ * an unarchive restores (an archived key's newest change is its
1052
+ * `secret.delete`, which left none). One probe of each object's changes,
1053
+ * newest first (`log_changes_object_idx`), then its row by number: the
1054
+ * same work however long its history.
1055
+ */
1056
+ export async function newestSealedChanges(db: Queryable, refs: readonly Ref[]): Promise<Map<string, { number: bigint; at: number; object: string; payload: string; seal: Buffer }>> {
1057
+ if (refs.length === 0) return new Map();
1058
+ const found = await rowsOf<{ number: string | number; at: string | number; object: string; payload: string; seal: Buffer | Uint8Array }>(db, newestSealedQuery(refs));
1059
+ return new Map(found.map((row) => [row.object, { number: BigInt(row.number), at: Number(row.at), object: row.object, payload: row.payload, seal: Buffer.from(row.seal) }]));
1060
+ }
1061
+
1062
+ /**
1063
+ * Whether the indexes that keep request ids unique stand: a
1064
+ * second entry under one request is a replay the database refuses, so a
1065
+ * walk need hold only its own stretch to it (`RequestIdChecked`).
1066
+ */
1067
+ export async function requestsUnique(db: Queryable): Promise<boolean> {
1068
+ // Each index by its definition, so that one of the same name over other columns does not pass: a use's request is
1069
+ // unique by its time and id, a change's by its id.
1070
+ const defined = (text: string) => text.replace(/[`"\s]/g, '').toLowerCase();
1071
+ const wanted = (uses: string | undefined, changes: string | undefined) =>
1072
+ uses !== undefined && changes !== undefined && /^createuniqueindex.*on(only)?(public\.)?log_uses(usingbtree)?\(at,request_id\)$/.test(defined(uses))
1073
+ && /^createuniqueindex.*on(only)?(public\.)?log_changes(usingbtree)?\(request_id\)$/.test(defined(changes));
1074
+ if (engineOf(db) === 'sqlite') {
1075
+ const rows = await rowsOf<{ name: string; sql: string | null }>(db, sql`SELECT name, sql FROM sqlite_master
1076
+ WHERE type = 'index' AND name IN ('log_uses_request_idx', 'log_changes_request_id_key')`);
1077
+ const of = (name: string) => rows.find((row) => row.name === name)?.sql ?? undefined;
1078
+ return wanted(of('log_uses_request_idx'), of('log_changes_request_id_key'));
1079
+ }
1080
+ const rows = await rowsOf<{ name: string; def: string }>(db, sql`SELECT c.relname AS name, pg_get_indexdef(i.indexrelid) AS def FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
1081
+ WHERE i.indexrelid IN (to_regclass('public.log_uses_request_idx'), to_regclass('public.log_changes_request_id_key')) AND i.indisunique AND i.indisvalid`);
1082
+ const of = (name: string) => rows.find((row) => row.name === name)?.def;
1083
+ return wanted(of('log_uses_request_idx'), of('log_changes_request_id_key'));
1084
+ }
1085
+
1086
+ /**
1087
+ * A key's source as its object holds it: its newest reference row's, unless
1088
+ * that row has ended (`ended_at`, 0029): a reference broken, replaced or
1089
+ * cut. Null for a key with no reference row. `secretId`: the key's id, as
1090
+ * its query names it.
1091
+ */
1092
+ function newestSource(secretId: SQL): SQL<string | null> {
1093
+ return sql<string | null>`(SELECT CASE WHEN y.ended_at IS NULL THEN y.source_secret_id END FROM secret_references y WHERE y.secret_id = ${secretId} ORDER BY y.created_seq DESC LIMIT 1)`;
1094
+ }
1095
+
1096
+ /** ... and that row's id, which the object holds beside its source. */
1097
+ function newestReference(secretId: SQL): SQL<string | null> {
1098
+ return sql<string | null>`(SELECT CASE WHEN y.ended_at IS NULL THEN y.id END FROM secret_references y WHERE y.secret_id = ${secretId} ORDER BY y.created_seq DESC LIMIT 1)`;
1099
+ }
1100
+
1101
+ /**
1102
+ * The rows' ends of references whose end changes no object, by their ids
1103
+ * (`end_reference`, 0029), written beside 0.14's entry: a holder archived,
1104
+ * holding a value, standing on another reference, or 0.14's alone (no
1105
+ * seal, no change). A standing one of a logged holder is refused: its end
1106
+ * is its holder's change.
1107
+ */
1108
+ export async function endReferenceRows(tx: Transaction, ids: readonly string[], at: number): Promise<void> {
1109
+ if (ids.length === 0) return;
1110
+ if (!Number.isSafeInteger(at) || at < 0) throw new Error('a reference ends at a time, whole ms since the epoch');
1111
+ if (engineOf(tx) === 'postgres') {
1112
+ await rowsOf(tx, sql`SELECT end_reference(r.id, ${at}::bigint) FROM unnest(${sql.param([...ids])}::uuid[]) AS r(id)`);
1113
+ return;
1114
+ }
1115
+ // One writer at a time on SQLite: no lock to take.
1116
+ const listed = sql.join(ids.map((id) => sql`${id}`), sql`, `);
1117
+ const standing = await rowsOf<{ id: string; holder: string; seal: Buffer | Uint8Array | null }>(tx, sql`SELECT y.id, x.id AS holder, x.seal FROM secret_references y JOIN secrets x ON x.id = y.secret_id
1118
+ WHERE y.id IN (${listed}) AND y.ended_at IS NULL AND x.archived_at IS NULL AND x.current_version_id IS NULL
1119
+ AND y.id = (SELECT z.id FROM secret_references z WHERE z.secret_id = x.id ORDER BY z.created_seq DESC LIMIT 1)`);
1120
+ for (const row of standing) {
1121
+ if (row.seal !== null || (await newestChange(tx, { kind: 'secret', id: row.holder })) !== null) {
1122
+ // Postgres's code for it, `invalid_parameter_value`, as `end_reference` raises it.
1123
+ throw Object.assign(new Error(`reference ${row.id} is its holder's standing one: its end is a change of the holder`), { code: '22023' });
1124
+ }
1125
+ }
1126
+ await rowsOf(tx, sql`UPDATE secret_references SET ended_at = ${at} WHERE id IN (${listed}) AND ended_at IS NULL`);
1127
+ }
1128
+
1129
+ /**
1130
+ * A change as `change_input` takes it: the object, the request it is (a
1131
+ * retry finds its entry), when and by whom, what it does (`payload`, the
1132
+ * op as logged), the object it leaves where the function writes it
1133
+ * (`value`), the seal it was read at and the one it leaves (none: a key
1134
+ * deleted), and the entry's MAC.
1135
+ */
1136
+ export type ChangeCall = {
1137
+ ref: Ref;
1138
+ requestId: string;
1139
+ at: number;
1140
+ actor: string;
1141
+ payload: string;
1142
+ value: string | null;
1143
+ sealFrom: Buffer | null;
1144
+ seal: Buffer | null;
1145
+ mac: Buffer;
1146
+ };
1147
+
1148
+ /**
1149
+ * Each change, in order, in one round trip, each `change_input` (0023):
1150
+ * its entry's number, or null where the row's seal was no longer the one
1151
+ * read, and nothing was done. A row that is not its object's newest
1152
+ * change's fails the call (`isStale`), and its transaction with it.
1153
+ *
1154
+ * On Postgres, the calls are rows of one `VALUES`, scanned in order. On
1155
+ * SQLite, which has no functions, `changeOnSqlite` does what it does, in
1156
+ * the call's transaction, which holds the database's one write lock: the
1157
+ * fence there is implicit (plan, "Postgres serves; SQLite is for tests").
1158
+ */
1159
+ export async function changeInputs(tx: Transaction, calls: readonly ChangeCall[]): Promise<(bigint | null)[]> {
1160
+ if (calls.length === 0) return [];
1161
+ if (engineOf(tx) === 'sqlite') {
1162
+ const numbers: (bigint | null)[] = [];
1163
+ for (const call of calls) numbers.push(await changeOnSqlite(tx, call));
1164
+ return numbers;
1165
+ }
1166
+ const rows = sql.join(calls.map((call, i) => sql`(${i}::int, ${call.ref.kind}::text, ${call.ref.id}::text, ${call.requestId}::text, ${call.at}::bigint, ${call.actor}::text, ${call.payload}::text, ${call.value}::text, ${call.sealFrom}::bytea, ${call.seal}::bytea, ${call.mac}::bytea)`), sql`, `);
1167
+ // In order: one decision's changes to one object chain, each from the seal the one before leaves. A volatile
1168
+ // function in the select list runs after the sort (Postgres 9.6 on), so each call runs in `ord`'s order.
1169
+ const numbers = await rowsOf<{ ord: number; number: string | number | null }>(tx, sql`
1170
+ SELECT c.ord, change_input(c.kind, c.id, c.request, c.at, c.actor, c.payload, c.value, c.seal_from, c.seal, c.mac) AS number
1171
+ FROM (VALUES ${rows}) AS c(ord, kind, id, request, at, actor, payload, value, seal_from, seal, mac)
1172
+ ORDER BY c.ord`);
1173
+ if (numbers.some(({ ord }, i) => Number(ord) !== i)) throw new Error('change_input answered out of order');
1174
+ return numbers.map(({ number }) => (number === null ? null : BigInt(number)));
1175
+ }
1176
+
1177
+ /** Postgres's `data_corrupted`, which `change_input` raises on a row that is not its object's newest change's. */
1178
+ const STALE = 'XX001';
1179
+
1180
+ /** Whether `error` is `change_input`'s refusal of a stale row (ChangeChecksSeal). */
1181
+ export function isStale(error: unknown): boolean {
1182
+ for (let cause = error; typeof cause === 'object' && cause !== null; cause = (cause as { cause?: unknown }).cause) {
1183
+ if (cause instanceof StaleRow || (cause as { code?: unknown }).code === STALE) return true;
1184
+ }
1185
+ return false;
1186
+ }
1187
+
1188
+ class StaleRow extends Error {}
1189
+
1190
+ /**
1191
+ * The ops `change_input` takes, by kind (0023, 0029, 0030): the cutover's genesis on any. A key's: its reference's
1192
+ * end, and an archived holder's object deleted again after its end.
1193
+ */
1194
+ const KNOWN_OPS: Record<Kind, readonly string[]> = {
1195
+ principal: ['member.add', 'member.role', 'member.remove', 'member.restore', 'access.grant', 'access.revoke'],
1196
+ environment: ['environment.protect', 'environment.rename', 'environment.archive'],
1197
+ roles: ['role.define'],
1198
+ policy: ['bulk.limit'],
1199
+ credential: [],
1200
+ // The app's changes alone make and end a connection (`app_change`): the change path seals 0.14's at the cutover.
1201
+ connection: [],
1202
+ // The vault's own statements change a binding (`change_bindings`), never the change path.
1203
+ binding: [],
1204
+ secret: ['secret.write', 'secret.delete'],
1205
+ };
1206
+
1207
+ /** `change_input`, step for step, on SQLite. */
1208
+ async function changeOnSqlite(tx: Transaction, call: ChangeCall): Promise<bigint | null> {
1209
+ const { logChanges, logUses, vaultMembers, environments, projects, logConfig, credentials, mcpConnections, serviceBindings, secrets, secretReferences } = tablesOf(tx);
1210
+ const { ref } = call;
1211
+ const target = objectKey(ref);
1212
+ const [prior] = await tx.select({ number: logChanges.number, object: logChanges.object, payload: logChanges.payload, seal: logChanges.seal }).from(logChanges).where(eq(logChanges.requestId, call.requestId));
1213
+ if (prior !== undefined) {
1214
+ if (prior.object !== target || prior.payload !== call.payload || !sameBytes(prior.seal ?? null, call.seal)) {
1215
+ throw Object.assign(new Error(`request ${call.requestId} names another change`), { code: 'SQLITE_CONSTRAINT_UNIQUE' });
1216
+ }
1217
+ return BigInt(prior.number);
1218
+ }
1219
+ const op = (JSON.parse(call.payload) as { op?: unknown }).op;
1220
+ if (ref.kind === 'binding') throw new Error('a binding changes by the vault\'s own statements alone (`change_bindings`)');
1221
+ if (typeof op !== 'string' || (op !== 'genesis' && !KNOWN_OPS[ref.kind].includes(op))) throw new Error(`this schema knows no change ${String(op)} to a ${ref.kind}`);
1222
+ const named = (JSON.parse(call.payload) as { ref?: { kind?: unknown; id?: unknown } }).ref;
1223
+ if ((op !== 'genesis' || named !== undefined) && (named?.kind !== ref.kind || named.id !== ref.id)) throw new Error(`a change names the object it is to: ${ref.kind}, ${ref.id}`);
1224
+ const value = call.value === null ? null : (JSON.parse(call.value) as Record<string, unknown>);
1225
+ const valued = ref.kind === 'environment' ? op !== 'genesis' && value === null
1226
+ : ref.kind === 'secret' ? (op === 'secret.write') === (value === null)
1227
+ : ref.kind === 'roles' || ref.kind === 'policy' ? (op === 'genesis') === (value !== null) : value !== null;
1228
+ if (valued) throw new Error(`a ${ref.kind} change ${op} carries the object it leaves exactly when change_input writes it`);
1229
+ // ChangeChecksSeal.
1230
+ const held = await heldSeal(tx, ref);
1231
+ const newest = await newestChange(tx, ref);
1232
+ if (!sameBytes(held, newest?.seal ?? null)) throw new StaleRow(`the ${ref.kind} object's row is not its newest change's: stale`);
1233
+ // ChangeCAS.
1234
+ const from = (column: SQLWrapper) => (call.sealFrom === null ? isNull(column) : eq(column as never, call.sealFrom));
1235
+ let done: number;
1236
+ switch (ref.kind) {
1237
+ case 'principal':
1238
+ done = changedRows(await tx.update(vaultMembers).set({ seal: call.seal }).where(and(eq(vaultMembers.principal, ref.id), from(vaultMembers.seal))));
1239
+ break;
1240
+ case 'credential':
1241
+ done = changedRows(await tx.update(credentials).set({ seal: call.seal }).where(and(eq(credentials.id, ref.id), from(credentials.seal))))
1242
+ || changedRows(await tx.update(serviceBindings).set({ seal: call.seal }).where(and(eq(serviceBindings.id, ref.id), from(serviceBindings.seal))));
1243
+ break;
1244
+ case 'connection':
1245
+ done = changedRows(await tx.update(mcpConnections).set({ seal: call.seal }).where(and(eq(mcpConnections.id, ref.id), from(mcpConnections.seal))));
1246
+ break;
1247
+ case 'secret': {
1248
+ if (value === null) {
1249
+ done = changedRows(await tx.update(secrets).set({ seal: call.seal }).where(and(eq(secrets.id, ref.id), from(secrets.seal))));
1250
+ break;
1251
+ }
1252
+ // A reference's end (0029): the holder, with no version of its own, left with no source. Live, from its seal, its
1253
+ // newest reference row ended, which must stand; archived, from none, its row ended by its id already
1254
+ // (`end_reference`), and its object deleted again after (0030).
1255
+ const [row] = await tx.select({ projectId: secrets.projectId, environmentId: secrets.environmentId, archivedAt: secrets.archivedAt }).from(secrets)
1256
+ .where(and(eq(secrets.id, ref.id), from(secrets.seal), isNull(secrets.currentVersionId)));
1257
+ const ends = row !== undefined && (row.archivedAt === null) === (call.sealFrom !== null)
1258
+ && value.kind === 'secret' && value.id === ref.id && row.projectId === value.projectId && row.environmentId === value.environmentId
1259
+ && value.version === '' && value.nature === 'secret' && value.key === null && value.digest === null && value.source === null
1260
+ && value.reference === null;
1261
+ if (!ends) {
1262
+ done = 0;
1263
+ break;
1264
+ }
1265
+ done = changedRows(await tx.update(secrets).set({ seal: call.seal }).where(eq(secrets.id, ref.id)));
1266
+ if (row.archivedAt !== null) break;
1267
+ const [newest] = await tx.select({ id: secretReferences.id, endedAt: secretReferences.endedAt }).from(secretReferences)
1268
+ .where(eq(secretReferences.secretId, ref.id)).orderBy(desc(secretReferences.createdSeq)).limit(1);
1269
+ if (newest === undefined || newest.endedAt !== null) throw new StaleRow("the secret object's reference has no standing row: stale");
1270
+ await tx.update(secretReferences).set({ endedAt: new Date(call.at) }).where(eq(secretReferences.id, newest.id));
1271
+ break;
1272
+ }
1273
+ case 'environment': {
1274
+ if (value === null) {
1275
+ done = changedRows(await tx.update(environments).set({ seal: call.seal }).where(and(eq(environments.id, ref.id), from(environments.seal))));
1276
+ break;
1277
+ }
1278
+ const [row] = await tx.select({
1279
+ projectId: environments.projectId, parentId: environments.parentId, owner: environments.owner, slug: environments.slug, archivedAt: environments.archivedAt, seal: environments.seal,
1280
+ projectArchived: truth(isNotNull(projects.archivedAt)),
1281
+ }).from(environments).leftJoin(projects, eq(projects.id, environments.projectId)).where(eq(environments.id, ref.id));
1282
+ const deleted = value.deleted === true;
1283
+ const holds = row !== undefined && sameBytes(row.seal ?? null, call.sealFrom) && value.kind === 'environment' && value.id === ref.id
1284
+ && row.projectId === value.projectId && row.parentId === (value.parentId ?? null) && row.owner === (value.owner ?? null)
1285
+ // Its project's archival is the project's row's, none once deleted: held, never written here (0029).
1286
+ && (!deleted && Boolean(row.projectArchived)) === value.projectArchived
1287
+ // A deleted row keeps its tombstone, and its object the slug before it (objects.ts): never written again (0025).
1288
+ && isTombstone(row.slug) === deleted && !isTombstone(String(value.slug)) && (!deleted || row.slug.slice(0, row.slug.indexOf(TOMBSTONE)) === value.slug);
1289
+ if (!holds) {
1290
+ done = 0;
1291
+ break;
1292
+ }
1293
+ done = changedRows(await tx.update(environments).set({
1294
+ slug: deleted ? row.slug : String(value.slug),
1295
+ protected: value.protected === true,
1296
+ archivedAt: value.archived === true ? (row.archivedAt ?? new Date(call.at)) : null,
1297
+ seal: call.seal,
1298
+ }).where(eq(environments.id, ref.id)));
1299
+ break;
1300
+ }
1301
+ case 'roles': case 'policy': {
1302
+ const config = op === 'genesis' ? (JSON.parse(call.payload) as { value?: unknown }).value : value;
1303
+ if (ref.id !== 'instance' || config === null || typeof config !== 'object' || Array.isArray(config) || (op === 'genesis' && call.sealFrom !== null)) {
1304
+ throw new Error(`the ${ref.kind} object is one, made once from its value and changed from its seal`);
1305
+ }
1306
+ done = call.sealFrom === null
1307
+ ? changedRows(await tx.insert(logConfig).values({ name: ref.kind, value: JSON.stringify(config), seal: call.seal }).onConflictDoNothing())
1308
+ : changedRows(await tx.update(logConfig).set({ value: JSON.stringify(config), seal: call.seal }).where(and(eq(logConfig.name, ref.kind), eq(logConfig.seal, call.sealFrom))));
1309
+ break;
1310
+ }
1311
+ }
1312
+ if (done === 0) return null;
1313
+ // One sequence for both tables, as `log_number` is on Postgres: the transaction holds the database's write lock.
1314
+ // Unlike the sequence, the newest number again once its entries are deleted: SQLite is for tests (the plan), where
1315
+ // only a test deletes entries.
1316
+ const [{ last }] = await tx.select({ last: sql<number | string | null>`max(n)` }).from(
1317
+ sql`(SELECT max(${logChanges.number}) AS n FROM ${logChanges} UNION ALL SELECT max(${logUses.number}) FROM ${logUses})`,
1318
+ );
1319
+ const number = BigInt(last ?? 0) + 1n;
1320
+ await tx.insert(logChanges).values({ number, at: call.at, requestId: call.requestId, object: target, actor: call.actor, payload: call.payload, seal: call.seal, mac: call.mac });
1321
+ return number;
1322
+ }
1323
+
1324
+ /** The seal on an object's row, null if it has none or no row (the credentials' table first: a binding's id never meets a token's). */
1325
+ async function heldSeal(db: Queryable, ref: Ref): Promise<Buffer | null> {
1326
+ const { credentials, mcpConnections, serviceBindings, secrets } = tablesOf(db);
1327
+ switch (ref.kind) {
1328
+ case 'connection': {
1329
+ const [row] = await db.select({ seal: mcpConnections.seal }).from(mcpConnections).where(eq(mcpConnections.id, ref.id));
1330
+ return row?.seal ?? null;
1331
+ }
1332
+ case 'credential': {
1333
+ const [token] = await db.select({ seal: credentials.seal }).from(credentials).where(eq(credentials.id, ref.id));
1334
+ if (token !== undefined) return token.seal ?? null;
1335
+ const [binding] = await db.select({ seal: serviceBindings.seal }).from(serviceBindings).where(eq(serviceBindings.id, ref.id));
1336
+ return binding?.seal ?? null;
1337
+ }
1338
+ case 'secret': {
1339
+ const [row] = await db.select({ seal: secrets.seal }).from(secrets).where(eq(secrets.id, ref.id));
1340
+ return row?.seal ?? null;
1341
+ }
1342
+ default:
1343
+ return (await readObject(db, ref as Ref & { kind: ReadKind })).seal;
1344
+ }
1345
+ }
1346
+
1347
+ function sameBytes(a: Buffer | null, b: Buffer | null): boolean {
1348
+ return a === null || b === null ? a === b : Buffer.compare(a, b) === 0;
1349
+ }
1350
+
1351
+ /**
1352
+ * A credential a token's issue came in on (`Vault.issueToken`, T149): its
1353
+ * object's columns, as the app sends them (`CredentialColumns`), its seal,
1354
+ * and its object's newest change's seal, in one read; null for no row.
1355
+ */
1356
+ export async function issuingCredential(db: Queryable, id: string): Promise<{ columns: CredentialColumns; seal: Buffer | null; newest: Buffer | null } | null> {
1357
+ type Read = { id: string; principal: string; kind: string; scope: string | null; revoked: boolean | number; expires_at: number | string; not_after: number | string | null; generation: number; seal: Buffer | null; newest: Buffer | null };
1358
+ const [row] = engineOf(db) === 'postgres'
1359
+ ? await rowsOf<Read>(db, sql`SELECT * FROM issuing_credential(${id}::uuid)`)
1360
+ : await rowsOf<Read>(db, sql`SELECT c.id, c.principal, c.kind, c.scope, c.revoked_at IS NOT NULL AS revoked, c.expires_at, c.not_after, c.generation, c.seal,
1361
+ (SELECT l.seal FROM log_changes l WHERE l.object = ${objectKey({ kind: 'credential', id })} ORDER BY l.number DESC LIMIT 1) AS newest
1362
+ FROM credentials c WHERE c.id = ${id}`);
1363
+ if (row === undefined) return null;
1364
+ const ends = [Number(row.expires_at), ...(row.not_after == null ? [] : [Number(row.not_after)])];
1365
+ const bytes = (value: Buffer | null) => (value == null ? null : Buffer.from(value));
1366
+ return {
1367
+ columns: { id: row.id, principal: row.principal, kind: row.kind, scope: row.scope, revoked: Boolean(row.revoked), expiresAt: Math.min(...ends), generation: Number(row.generation) },
1368
+ seal: bytes(row.seal),
1369
+ newest: bytes(row.newest),
1370
+ };
1371
+ }
1372
+
1373
+ // --- the cutover (cutover.ts) ------------------------------------------------
1374
+
1375
+ /** Whether the log is cut over from 0.14: its cutover change, one probe of `log_changes_object_idx`, no lock. */
1376
+ export async function cutOver(db: Queryable): Promise<boolean> {
1377
+ return (await cutoverNumber(db)) !== null;
1378
+ }
1379
+
1380
+ /** The cutover change's number, null for none: one probe of `log_changes_object_idx`, no lock. */
1381
+ export async function cutoverNumber(db: Queryable): Promise<bigint | null> {
1382
+ const [row] = await rowsOf<{ number: string | number }>(db, sql`SELECT number FROM log_changes WHERE object = ${CUTOVER_OBJECT} LIMIT 1`);
1383
+ return row === undefined ? null : BigInt(row.number);
1384
+ }
1385
+
1386
+ /** Every object the log has a change of, by its key: before the cutover, the few 0.15's changes made already. */
1387
+ export async function loggedObjects(db: Queryable): Promise<Set<string>> {
1388
+ const rows = await rowsOf<{ object: string }>(db, sql`SELECT DISTINCT object FROM log_changes`);
1389
+ return new Set(rows.map((row) => row.object));
1390
+ }
1391
+
1392
+ /** The rows `log_config` holds, by name: only `change_input` writes one, sealed (the roles', the policy's). */
1393
+ export async function configRows(db: Queryable): Promise<Set<string>> {
1394
+ const rows = await rowsOf<{ name: string }>(db, sql`SELECT name FROM log_config`);
1395
+ return new Set(rows.map((row) => row.name));
1396
+ }
1397
+
1398
+ /** A member's row's seal, by principal. */
1399
+ export async function memberSeals(db: Queryable): Promise<Map<string, Buffer | null>> {
1400
+ const rows = await rowsOf<{ principal: string; seal: Buffer | Uint8Array | null }>(db, sql`SELECT principal, seal FROM vault_members`);
1401
+ return new Map(rows.map((row) => [row.principal, bytes(row.seal)]));
1402
+ }
1403
+
1404
+ export type ProjectRow = { id: string; slug: string; archived: boolean };
1405
+ export type EnvironmentRead = { id: string; projectId: string; slug: string; owner: string | null; parentId: string | null; archived: boolean; seal: Buffer | null };
1406
+ /** A key's rows, archived or not: its current version, and its newest reference row, ended or not. */
1407
+ export type SecretRead = {
1408
+ id: string;
1409
+ projectId: string;
1410
+ environmentId: string;
1411
+ archived: boolean;
1412
+ seal: Buffer | null;
1413
+ /**
1414
+ * Whether its current version is one of its own and its newest by the
1415
+ * versions' own order, as 0.14 holds a source's (`versions`, which joins a
1416
+ * version to its key): the app may write `current_version_id`, never a
1417
+ * version. False for a pointer to another key's version, or to none.
1418
+ */
1419
+ newest: boolean;
1420
+ version: { id: string; sensitive: boolean; plaintext: string | null; kekProvider: string; kekId: string; kekVersion: string; wrappedDek: string } | null;
1421
+ /** Its newest reference row: its id, its source, the `reference.create` it names, and whether its row says it ended. */
1422
+ reference: { id: string; source: string; createdSeq: bigint; ended: boolean } | null;
1423
+ };
1424
+
1425
+ export async function everyProject(db: Queryable): Promise<ProjectRow[]> {
1426
+ const rows = await rowsOf<{ id: string; slug: string; archived: boolean | number }>(db, sql`SELECT id, slug, archived_at IS NOT NULL AS archived FROM projects`);
1427
+ return rows.map((row) => ({ id: row.id, slug: row.slug, archived: Boolean(row.archived) }));
1428
+ }
1429
+
1430
+ export async function everyEnvironment(db: Queryable): Promise<EnvironmentRead[]> {
1431
+ const rows = await rowsOf<{ id: string; project_id: string; slug: string; owner: string | null; parent_id: string | null; archived: boolean | number; seal: Buffer | Uint8Array | null }>(
1432
+ db, sql`SELECT id, project_id, slug, owner, parent_id, archived_at IS NOT NULL AS archived, seal FROM environments`,
1433
+ );
1434
+ return rows.map((row) => ({ id: row.id, projectId: row.project_id, slug: row.slug, owner: row.owner, parentId: row.parent_id, archived: Boolean(row.archived), seal: bytes(row.seal) }));
1435
+ }
1436
+
1437
+ /** Every key, archived or not, as `readObject` makes one (store.ts), with its newest reference row whole. */
1438
+ export async function everySecret(db: Queryable): Promise<SecretRead[]> {
1439
+ const newest = (column: string) => sql.raw(`(SELECT y.${column} FROM secret_references y WHERE y.secret_id = s.id ORDER BY y.created_seq DESC LIMIT 1)`);
1440
+ const rows = await rowsOf<{
1441
+ id: string; project_id: string; environment_id: string; archived: boolean | number; seal: Buffer | Uint8Array | null; version_id: string | null;
1442
+ sensitive: boolean | number | null; plaintext: string | null; kek_provider: string | null; kek_id: string | null; kek_version: string | null; wrapped_dek: Buffer | Uint8Array | null;
1443
+ reference_id: string | null; reference_source: string | null; reference_seq: string | number | null; reference_ended: boolean | number | null;
1444
+ newest: boolean | number | null;
1445
+ }>(db, sql`SELECT s.id, s.project_id, s.environment_id, s.archived_at IS NOT NULL AS archived, s.seal, s.current_version_id AS version_id,
1446
+ s.current_version_id IS NULL OR (v.secret_id = s.id AND v.version = (SELECT max(w.version) FROM secret_versions w WHERE w.secret_id = s.id)) AS newest,
1447
+ v.sensitive, v.plaintext, v.kek_provider, v.kek_id, v.kek_version, v.wrapped_dek,
1448
+ ${newest('id')} AS reference_id, ${newest('source_secret_id')} AS reference_source, ${newest('created_seq')} AS reference_seq,
1449
+ ${newest('ended_at')} IS NOT NULL AS reference_ended
1450
+ FROM secrets s LEFT JOIN secret_versions v ON v.id = s.current_version_id`);
1451
+ return rows.map((row) => ({
1452
+ id: row.id,
1453
+ projectId: row.project_id,
1454
+ environmentId: row.environment_id,
1455
+ archived: Boolean(row.archived),
1456
+ seal: bytes(row.seal),
1457
+ newest: Boolean(row.newest),
1458
+ // As `readObject` takes a version: none without a wrapped key.
1459
+ version: row.version_id === null || row.wrapped_dek === null ? null : {
1460
+ id: row.version_id, sensitive: Boolean(row.sensitive), plaintext: row.plaintext,
1461
+ kekProvider: row.kek_provider!, kekId: row.kek_id!, kekVersion: row.kek_version!, wrappedDek: Buffer.from(row.wrapped_dek).toString('base64'),
1462
+ },
1463
+ reference: row.reference_id === null ? null : {
1464
+ id: row.reference_id, source: row.reference_source!, createdSeq: BigInt(row.reference_seq!), ended: Boolean(row.reference_ended),
1465
+ },
1466
+ }));
1467
+ }
1468
+
1469
+ /** A credential the app vouched for, as its row is now (`cutover_credentials`): its object's columns, its seal, and whether the log has a change of it. */
1470
+ export type CredentialRead = { columns: CredentialColumns; seal: Buffer | null; logged: boolean };
1471
+ /** A binding the app vouched for, as its row is now: its columns but the instance it was made on, its row's `origin` apart (null on one of 0.14's, which the cutover writes), its seal. */
1472
+ export type BindingRead = { columns: Omit<BindingColumns, 'origin'>; origin: string | null; seal: Buffer | null; logged: boolean };
1473
+
1474
+ export async function credentialsAt(db: Queryable, ids: readonly string[]): Promise<Map<string, CredentialRead>> {
1475
+ if (ids.length === 0) return new Map();
1476
+ type Read = { id: string; principal: string; kind: string; scope: string | null; revoked: boolean | number; expires_at: string | number; not_after: string | number | null; generation: number; seal: Buffer | Uint8Array | null; logged: boolean | number };
1477
+ const rows = engineOf(db) === 'postgres'
1478
+ ? await rowsOf<Read>(db, sql`SELECT * FROM cutover_credentials(${sql.param([...ids])}::uuid[])`)
1479
+ // SQLite has no `log_object`: whether the log holds each is the plan's to see, by its key (`CutoverFacts.logged`).
1480
+ : await rowsOf<Read>(db, sql`SELECT c.id, c.principal, c.kind, c.scope, c.revoked_at IS NOT NULL AS revoked, c.expires_at, c.not_after, c.generation, c.seal,
1481
+ 0 AS logged FROM credentials c WHERE c.id IN (${sql.join(ids.map((id) => sql`${id}`), sql`, `)})`);
1482
+ return new Map(rows.map((row) => {
1483
+ // As `issuingCredential` reads one (store.ts): its end the earlier of its expiry and its hard end.
1484
+ const ends = [Number(row.expires_at), ...(row.not_after == null ? [] : [Number(row.not_after)])];
1485
+ const columns: CredentialColumns = { id: row.id, principal: row.principal, kind: row.kind, scope: row.scope, revoked: Boolean(row.revoked), expiresAt: Math.min(...ends), generation: Number(row.generation) };
1486
+ return [row.id, { columns, seal: bytes(row.seal), logged: Boolean(row.logged) }];
1487
+ }));
1488
+ }
1489
+
1490
+ /** An MCP connection the app vouched for, as its row is now (`cutover_connections`, T147): its object's columns, its seal, and whether the log has a change of it. */
1491
+ export type ConnectionRead = { columns: ConnectionColumns; seal: Buffer | null; logged: boolean };
1492
+
1493
+ export async function connectionsAt(db: Queryable, ids: readonly string[]): Promise<Map<string, ConnectionRead>> {
1494
+ if (ids.length === 0) return new Map();
1495
+ type Read = { id: string; principal: string; generation: number; expires_at: string | number; revoked: boolean | number; seal: Buffer | Uint8Array | null; logged: boolean | number };
1496
+ const rows = engineOf(db) === 'postgres'
1497
+ ? await rowsOf<Read>(db, sql`SELECT * FROM cutover_connections(${sql.param([...ids])}::uuid[])`)
1498
+ : await rowsOf<Read>(db, sql`SELECT k.id, k.principal, k.generation, k.expires_at, k.revoked_at IS NOT NULL AS revoked, k.seal,
1499
+ 0 AS logged FROM mcp_connections k WHERE k.id IN (${sql.join(ids.map((id) => sql`${id}`), sql`, `)})`);
1500
+ return new Map(rows.map((row) => {
1501
+ const columns: ConnectionColumns = { id: row.id, principal: row.principal, generation: Number(row.generation), expiresAt: Number(row.expires_at), revoked: Boolean(row.revoked) };
1502
+ return [row.id, { columns, seal: bytes(row.seal), logged: Boolean(row.logged) }];
1503
+ }));
1504
+ }
1505
+
1506
+ export async function bindingsAt(db: Queryable, ids: readonly string[]): Promise<Map<string, BindingRead>> {
1507
+ if (ids.length === 0) return new Map();
1508
+ type Read = { id: string; principal: string; generation: number; profile: string; issuer: string; origin: string | null; jwks_uri: string; claims: string; scope: string | null; policy: string | null; revoked: boolean | number; seal: Buffer | Uint8Array | null; logged: boolean | number };
1509
+ const rows = engineOf(db) === 'postgres'
1510
+ ? await rowsOf<Read>(db, sql`SELECT * FROM cutover_bindings(${sql.param([...ids])}::uuid[])`)
1511
+ : await rowsOf<Read>(db, sql`SELECT b.id, b.principal, b.generation, b.profile, b.issuer, b.origin, b.jwks_uri, b.claims, b.scope, b.policy, b.revoked_at IS NOT NULL AS revoked, b.seal,
1512
+ 0 AS logged FROM service_bindings b WHERE b.id IN (${sql.join(ids.map((id) => sql`${id}`), sql`, `)})`);
1513
+ return new Map(rows.map((row) => {
1514
+ const columns: Omit<BindingColumns, 'origin'> = {
1515
+ id: row.id, principal: row.principal, generation: Number(row.generation), profile: row.profile, issuer: row.issuer, jwksUri: row.jwks_uri,
1516
+ claims: row.claims, scope: row.scope, policy: row.policy, revoked: Boolean(row.revoked),
1517
+ };
1518
+ return [row.id, { columns, origin: row.origin, seal: bytes(row.seal), logged: Boolean(row.logged) }];
1519
+ }));
1520
+ }
1521
+
1522
+ /** Whether the app wrote any entry of 0.14's after `seq`: what it vouched for may have moved since. */
1523
+ export async function appEntriesAfter(db: Queryable, seq: bigint): Promise<boolean> {
1524
+ const rows = await rowsOf<{ found: number }>(db, sql`SELECT 1 AS found FROM audit_log WHERE seq > ${seq} AND author = 'app' LIMIT 1`);
1525
+ return rows.length > 0;
1526
+ }
1527
+
1528
+ /** The places 0.14 deleted made as 0.15 leaves them: each environment given `<slug>~deleted`, each key archived (`cutover_places`). */
1529
+ export async function cutoverPlaces(tx: Transaction, at: number, environments: readonly string[], secrets: readonly string[]): Promise<void> {
1530
+ if (environments.length === 0 && secrets.length === 0) return;
1531
+ if (engineOf(tx) === 'postgres') {
1532
+ await rowsOf(tx, sql`SELECT cutover_places(${at}::bigint, ${sql.param([...environments])}::uuid[], ${sql.param([...secrets])}::uuid[])`);
1533
+ return;
1534
+ }
1535
+ // `cutover_places`, step for step: the transaction holds the database's write lock.
1536
+ const { environments: envs, secrets: keys, projects } = tablesOf(tx);
1537
+ if (await cutOver(tx)) throw new Error('the log is cut over from 0.14 already');
1538
+ const logged = await loggedObjects(tx);
1539
+ const rows = environments.length === 0 ? [] : await tx.select({ id: envs.id, slug: envs.slug, seal: envs.seal, projectSlug: projects.slug })
1540
+ .from(envs).innerJoin(projects, eq(projects.id, envs.projectId)).where(inArray(envs.id, [...environments]));
1541
+ if (rows.length !== environments.length || rows.some((row) => isTombstone(row.slug) || row.seal !== null || !isTombstone(row.projectSlug) || logged.has(objectKey({ kind: 'environment', id: row.id })))) {
1542
+ throw new Error('an environment named is no standing one of a deleted project, 0.14\'s alone');
1543
+ }
1544
+ for (const row of rows) await tx.update(envs).set({ slug: deletedWithProject(row.slug) }).where(eq(envs.id, row.id));
1545
+ const held = secrets.length === 0 ? [] : await tx.select({ id: keys.id, seal: keys.seal, archivedAt: keys.archivedAt, slug: envs.slug, projectSlug: projects.slug })
1546
+ .from(keys).innerJoin(envs, eq(envs.id, keys.environmentId)).innerJoin(projects, eq(projects.id, envs.projectId)).where(inArray(keys.id, [...secrets]));
1547
+ if (held.length !== secrets.length || held.some((row) => row.archivedAt !== null || row.seal !== null || !(isTombstone(row.slug) || isTombstone(row.projectSlug)) || logged.has(objectKey({ kind: 'secret', id: row.id })))) {
1548
+ throw new Error('a key named is no live one under a deleted place, 0.14\'s alone');
1549
+ }
1550
+ const archived = changedRows(await tx.update(keys).set({ archivedAt: new Date(at) }).where(and(inArray(keys.id, [...secrets]), isNull(keys.archivedAt))));
1551
+ if (archived !== secrets.length) throw new Error('a key named was archived meanwhile');
1552
+ }
1553
+
1554
+ /** A binding's genesis (`seal_binding`): its number, or null if its row is not as its object says, or the log holds it. */
1555
+ export async function sealBinding(tx: Transaction, call: ChangeCall): Promise<bigint | null> {
1556
+ if (engineOf(tx) === 'postgres') {
1557
+ const [row] = await rowsOf<{ n: string | number | null }>(tx, sql`SELECT seal_binding(${call.ref.id}::uuid, ${call.requestId}, ${call.at}::bigint, ${call.actor}, ${call.payload},
1558
+ ${call.seal}::bytea, ${call.mac}::bytea) AS n`);
1559
+ return row?.n == null ? null : BigInt(row.n);
1560
+ }
1561
+ const { serviceBindings, logChanges } = tablesOf(tx);
1562
+ if (await cutOver(tx)) throw new Error('the log is cut over from 0.14 already: a binding is the vault\'s to make');
1563
+ if ((await loggedObjects(tx)).has(objectKey(call.ref))) return null;
1564
+ const origin = (JSON.parse(call.payload) as { value?: { origin?: unknown } }).value?.origin;
1565
+ if (typeof origin !== 'string') throw new Error("a binding's genesis names the instance it was made on");
1566
+ const done = changedRows(await tx.update(serviceBindings).set({ seal: call.seal, origin })
1567
+ .where(and(eq(serviceBindings.id, call.ref.id), isNull(serviceBindings.seal), isNull(serviceBindings.revokedAt), isNull(serviceBindings.origin))));
1568
+ if (done === 0) return null;
1569
+ const number = await nextNumber(tx);
1570
+ await tx.insert(logChanges).values({ number, at: call.at, requestId: call.requestId, object: objectKey(call.ref), actor: call.actor, payload: call.payload, seal: call.seal, mac: call.mac });
1571
+ return number;
1572
+ }
1573
+
1574
+ /** The cutover's change (`log_cutover`): its number. */
1575
+ export async function logCutover(tx: Transaction, row: { requestId: string; at: number; actor: string; payload: string; mac: Buffer }): Promise<bigint> {
1576
+ if (engineOf(tx) === 'postgres') {
1577
+ const [found] = await rowsOf<{ n: string | number }>(tx, sql`SELECT log_cutover(${row.requestId}, ${row.at}::bigint, ${row.actor}, ${row.payload}, ${row.mac}::bytea) AS n`);
1578
+ return BigInt(found!.n);
1579
+ }
1580
+ const { logChanges, auditChainHead } = tablesOf(tx);
1581
+ if (await cutOver(tx)) throw Object.assign(new Error('the log is cut over from 0.14 already'), { code: 'SQLITE_CONSTRAINT_UNIQUE' });
1582
+ const { anchor } = JSON.parse(row.payload) as { anchor: { seq: string; hash: string } };
1583
+ const [head] = await tx.select({ nextSeq: auditChainHead.nextSeq, headHash: auditChainHead.headHash }).from(auditChainHead);
1584
+ if (head === undefined || BigInt(head.nextSeq) !== BigInt(anchor.seq) + 1n || Buffer.from(head.headHash).toString('hex') !== anchor.hash) {
1585
+ throw new Error('a cutover names 0.14\'s head as it stands');
1586
+ }
1587
+ const number = await nextNumber(tx);
1588
+ await tx.insert(logChanges).values({ number, at: row.at, requestId: row.requestId, object: CUTOVER_OBJECT, actor: row.actor, payload: row.payload, seal: null, mac: row.mac });
1589
+ return number;
1590
+ }
1591
+
1592
+ /** SQLite's next number, both tables one sequence (as `changeOnSqlite` takes it, store.ts). */
1593
+ async function nextNumber(tx: Transaction): Promise<bigint> {
1594
+ const [{ last }] = await rowsOf<{ last: number | string | null }>(tx, sql`SELECT max(n) AS last FROM (SELECT max(number) AS n FROM log_changes UNION ALL SELECT max(number) FROM log_uses)`);
1595
+ return BigInt(last ?? 0) + 1n;
1596
+ }
1597
+
1598
+ function bytes(value: Buffer | Uint8Array | null): Buffer | null {
1599
+ return value === null ? null : Buffer.from(value);
1600
+ }