@coffre/vault 0.10.1 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,14 @@
1
- import { ACCESS_ACTIONS, POLICY_REVISION, checkpointMessage, checkpointVerifier, describeAccessFault, handsBack, policyRevision } from "@coffre/core/vault";
1
+ import { ACCESS_ACTIONS, ACCESS_ACTIONS as ACCESS_ACTIONS$1, POLICY_REVISION, checkpointMessage, checkpointVerifier, describeAccessFault, handsBack, policyRevision } from "@coffre/core/vault";
2
2
  import { DEK_BYTES, KEY_CHECK, KEY_CHECK_CONTEXT, KEY_CHECK_VALUE, KekBadClaimError, KekCancelledError, KekUnavailableError, LocalKekProvider } from "@coffre/core/kek";
3
3
  import { createHash, createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
4
4
  import { prepared, tablesOf } from "@coffre/db";
5
5
  import { DEFAULT_POLICY, EVERYWHERE, EVERY_ENVIRONMENT, FIXED_ROLES, INSTANCE_ROLES, NOTHING, PROJECT_ONLY_PERMISSIONS, ROLES, ROLE_ID, UNWRITTEN_FORK_MS, allows, assignableToEnvironment, definitionProblem, givesService, grantKind, isBuiltinRole, isCredentialScope, isInstanceRole, isRole, isScope, managesService, mayManageAccess, normalDefinition, normalScope, ownsInstance, runsInstance, setsUpServices, unscoped, withinCredential } from "@coffre/core/access";
6
6
  import { GENESIS_HASH, deriveLogKey, verifyEntries } from "@coffre/core/audit";
7
7
  import { checkContext, ownerKey } from "@coffre/core/envelope";
8
- import { shownMember } from "@coffre/core/schemas";
9
- import { SNAPSHOT, clockMillis, engineOf, forUpdate, isUniqueViolation, isUnreachable, tombstone, truth } from "@coffre/db/dialect";
8
+ import { TOMBSTONE, shownMember } from "@coffre/core/schemas";
9
+ import { SNAPSHOT, clockMillis, engineOf, forUpdate, isUniqueViolation, isUnreachable, rowsOf, tombstone, truth } from "@coffre/db/dialect";
10
10
  import { accessEntriesAbout, everyProjectConversion, insertGrant, liveProjects, newestAccess, readGrants, reportStands, roleColumns, storedRole } from "@coffre/db/grants";
11
- import { appendEntries, entryColumns, lockLogHead, missingEntry, newestAuthentic, newestVaultEntries, newestVaultSeq, withinBytes } from "@coffre/db/log";
11
+ import { LogHeadMismatch, appendEntries, entryColumns, lockLogHead, missingEntry, newestAuthentic, newestVaultEntries, withinBytes } from "@coffre/db/log";
12
12
  import { and, asc, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, lte, or, sql } from "drizzle-orm";
13
13
  //#region src/checkpoint.ts
14
14
  const PKCS8_ED25519 = [
@@ -75,7 +75,7 @@ function memberColumns(db) {
75
75
  };
76
76
  }
77
77
  /** A member as their row says (`storedRole`). */
78
- function memberOf({ owner, role, scope, ...row }) {
78
+ function memberOf$1({ owner, role, scope, ...row }) {
79
79
  return {
80
80
  ...row,
81
81
  ...storedRole({
@@ -95,7 +95,7 @@ function storedChange(change) {
95
95
  }
96
96
  async function member(db, principal) {
97
97
  const [row] = await memberRow(db, { principal });
98
- return row === void 0 ? void 0 : memberOf(row);
98
+ return row === void 0 ? void 0 : memberOf$1(row);
99
99
  }
100
100
  const memberRow = prepared((db) => {
101
101
  const { vaultMembers } = tablesOf(db);
@@ -103,7 +103,7 @@ const memberRow = prepared((db) => {
103
103
  });
104
104
  async function allMembers(db) {
105
105
  const { vaultMembers } = tablesOf(db);
106
- return (await db.select(memberColumns(db)).from(vaultMembers).orderBy(asc(vaultMembers.principal))).map(memberOf);
106
+ return (await db.select(memberColumns(db)).from(vaultMembers).orderBy(asc(vaultMembers.principal))).map(memberOf$1);
107
107
  }
108
108
  /**
109
109
  * The rows that say their member is an active Owner of the whole instance
@@ -113,7 +113,7 @@ async function allMembers(db) {
113
113
  */
114
114
  async function instanceOwners(db) {
115
115
  const { vaultMembers } = tablesOf(db);
116
- return (await db.select(memberColumns(db)).from(vaultMembers).where(and(eq(vaultMembers.status, "active"), eq(vaultMembers.role, "owner"))).orderBy(asc(vaultMembers.principal))).map(memberOf).filter(ownsInstance);
116
+ return (await db.select(memberColumns(db)).from(vaultMembers).where(and(eq(vaultMembers.status, "active"), eq(vaultMembers.role, "owner"))).orderBy(asc(vaultMembers.principal))).map(memberOf$1).filter(ownsInstance);
117
117
  }
118
118
  /**
119
119
  * Lock these members' rows for the rest of the transaction, in one order
@@ -124,7 +124,7 @@ async function instanceOwners(db) {
124
124
  async function lockMembers(tx, principals) {
125
125
  const { vaultMembers } = tablesOf(tx);
126
126
  const rows = await forUpdate(tx, tx.select(memberColumns(tx)).from(vaultMembers).where(inArray(vaultMembers.principal, [...new Set(principals)])).orderBy(asc(vaultMembers.principal)), "no key update");
127
- return new Map(rows.map((row) => [row.principal, memberOf(row)]));
127
+ return new Map(rows.map((row) => [row.principal, memberOf$1(row)]));
128
128
  }
129
129
  /**
130
130
  * Add `row`. A principal has one row: when two decisions admit the same
@@ -221,25 +221,6 @@ async function everyProjectHolders(db) {
221
221
  const { vaultGrants } = tablesOf(db);
222
222
  return (await db.selectDistinct({ principal: vaultGrants.principal }).from(vaultGrants).where(and(isNull(vaultGrants.projectId), isNull(vaultGrants.environmentId))).orderBy(asc(vaultGrants.principal))).map((row) => row.principal);
223
223
  }
224
- /** The action of an entry that releases a key, which the bulk limit counts. */
225
- const RELEASE = "secret.read";
226
- /**
227
- * How many keys the vault has released to `principal` since `after`: one
228
- * for an entry about one secret, and as many as it lists for one about
229
- * several (`secrets`), a read logged once per place.
230
- */
231
- async function releasesSince(db, principal, after) {
232
- const [{ n }] = await released(db, {
233
- principal,
234
- after
235
- });
236
- return Number(n ?? 0);
237
- }
238
- const released = prepared((db) => {
239
- const { auditLog } = tablesOf(db);
240
- const listed = engineOf(db) === "postgres" ? sql`jsonb_array_length((${auditLog.metadata})::jsonb -> 'secrets')` : sql`json_array_length(${auditLog.metadata}, '$.secrets')`;
241
- return db.select({ n: sql`SUM(COALESCE(${listed}, 1))` }).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.actor, sql.placeholder("principal")), eq(auditLog.action, RELEASE), eq(auditLog.decision, "allow"), gt(auditLog.occurredAt, sql.placeholder("after"))));
242
- });
243
224
  /** Rows of the log as the chain's codec reads them: `author` and `decision` are checked by the table. */
244
225
  function stored(rows) {
245
226
  return rows;
@@ -436,7 +417,7 @@ async function talliedAfter(db, after, limit) {
436
417
  /** The vault's newest allowed access entry about each member who has one. */
437
418
  async function newestAccessEntries(db) {
438
419
  const { auditLog } = tablesOf(db);
439
- const newest = db.select({ seq: sql`max(${auditLog.seq})` }).from(auditLog).where(and(eq(auditLog.author, "vault"), inArray(auditLog.action, [...ACCESS_ACTIONS]), eq(auditLog.decision, "allow"))).groupBy(auditLog.subjectPrincipal);
420
+ const newest = db.select({ seq: sql`max(${auditLog.seq})` }).from(auditLog).where(and(eq(auditLog.author, "vault"), inArray(auditLog.action, [...ACCESS_ACTIONS$1]), eq(auditLog.decision, "allow"))).groupBy(auditLog.subjectPrincipal);
440
421
  const rows = stored(await db.select(entryColumns(db)).from(auditLog).where(inArray(auditLog.seq, newest)));
441
422
  return new Map(rows.flatMap((row) => row.subjectPrincipal === null ? [] : [[row.subjectPrincipal, row]]));
442
423
  }
@@ -994,6 +975,255 @@ function sealed(key, member, grants) {
994
975
  return member.mac.length === expected.length && timingSafeEqual(member.mac, expected);
995
976
  }
996
977
  //#endregion
978
+ //#region src/snapshot.postgres.ts
979
+ /** An entry of `audit_log` aliased `e`, as JSON (`RawEntry`): whole numbers past 2^53 as text, bytes as hex. */
980
+ const ENTRY$1 = sql.raw(`json_build_object(
981
+ 'seq', e.seq::text, 'author', e.author, 'keyId', e.key_id, 'occurredAt', e.occurred_at, 'actor', e.actor, 'action', e.action,
982
+ 'decision', e.decision, 'code', e.code, 'subjectPrincipal', e.subject_principal, 'projectId', e.project_id,
983
+ 'environmentId', e.environment_id, 'secretId', e.secret_id, 'secretVersionId', e.secret_version_id,
984
+ 'operationId', e.operation_id, 'requestId', e.request_id, 'sourceIp', e.source_ip, 'relatedSeq', e.related_seq::text,
985
+ 'metadata', e.metadata, 'prevHash', encode(e.prev_hash, 'hex'), 'mac', encode(e.mac, 'hex'), 'hash', encode(e.hash, 'hex'))`);
986
+ const NOW$1 = sql.raw("(extract(epoch from clock_timestamp()) * 1000)::bigint");
987
+ /** `snapshot`'s statement on Postgres: one row, one JSON column. Each part reads one index range. */
988
+ function snapshotQuery$1(asked) {
989
+ const principals = sql`${sql.param([...asked.principals])}::text[]`;
990
+ const environments = sql`${sql.param([...asked.environments ?? []])}::uuid[]`;
991
+ const references = sql`${sql.param((asked.references ?? []).map(String))}::bigint[]`;
992
+ const actions = sql`${sql.param([...ACCESS_ACTIONS])}::text[]`;
993
+ const tomb = `%${TOMBSTONE}%`;
994
+ const released = asked.released;
995
+ const head = asked.head === true ? sql`, head AS (SELECT next_seq, head_hash FROM audit_chain_head WHERE only_row FOR UPDATE SKIP LOCKED)` : sql``;
996
+ return sql`
997
+ WITH policy AS (
998
+ SELECT * FROM audit_log WHERE author = 'vault' AND action = 'policy.change' AND decision = 'allow' ORDER BY seq DESC LIMIT 1
999
+ )${head}
1000
+ SELECT json_build_object(
1001
+ 'at', ${NOW$1},
1002
+ 'head', ${asked.head === true ? sql`(SELECT json_build_object('nextSeq', next_seq::text, 'hash', encode(head_hash, 'hex')) FROM head)` : sql`NULL`},
1003
+ 'last', ${asked.head === true ? sql`(SELECT json_build_object('seq', seq::text, 'hash', encode(hash, 'hex')) FROM audit_log ORDER BY seq DESC LIMIT 1)` : sql`NULL`},
1004
+ 'rotation', ${asked.head === true ? sql`(SELECT ${ENTRY$1} FROM audit_log e WHERE author = 'vault' AND action = 'key.rotate' AND decision = 'allow' ORDER BY seq DESC LIMIT 1)` : sql`NULL`},
1005
+ 'members', ${asked.members === true ? sql`(SELECT coalesce(json_agg(json_build_object(
1006
+ 'principal', principal, 'status', status, 'owner', owner, 'role', role, 'scope', scope, 'generation', generation,
1007
+ 'createdAt', created_at, 'createdBy', created_by, 'statusChangedAt', status_changed_at, 'statusChangedBy', status_changed_by,
1008
+ 'accessSeq', access_seq::text, 'mac', encode(mac, 'hex'))), '[]') FROM vault_members WHERE principal = ANY(${principals}))` : sql`'[]'::json`},
1009
+ 'grants', (
1010
+ SELECT coalesce(json_agg(json_build_object(
1011
+ 'principal', g.principal, 'projectId', coalesce(g.project_id, env.project_id), 'environmentId', g.environment_id,
1012
+ 'environmentSlug', g.environment_slug, 'role', g.role, 'expiresAt', g.expires_at, 'grantedAt', g.granted_at, 'grantedBy', g.granted_by)), '[]')
1013
+ FROM vault_grants g LEFT JOIN environments env ON env.id = g.environment_id
1014
+ WHERE g.principal = ANY(${principals})
1015
+ ),
1016
+ 'access', (
1017
+ SELECT coalesce(json_agg(${ENTRY$1}), '[]')
1018
+ FROM unnest(${principals}) AS asked (principal)
1019
+ CROSS JOIN LATERAL (
1020
+ SELECT * FROM audit_log
1021
+ WHERE author = 'vault' AND subject_principal = asked.principal AND action = ANY(${actions}) AND decision = 'allow'
1022
+ ORDER BY seq DESC LIMIT 1
1023
+ ) e
1024
+ ),
1025
+ 'policy', (SELECT ${ENTRY$1} FROM policy e),
1026
+ 'environments', (
1027
+ SELECT coalesce(json_agg(json_build_object(
1028
+ 'id', env.id, 'projectId', env.project_id, 'slug', env.slug, 'owner', env.owner, 'parentId', env.parent_id,
1029
+ 'deleted', env.slug LIKE ${tomb} OR project.slug LIKE ${tomb},
1030
+ 'archived', env.archived_at IS NOT NULL OR project.archived_at IS NOT NULL)), '[]')
1031
+ FROM environments env JOIN projects project ON project.id = env.project_id
1032
+ -- One list of ids, each found by the primary key.
1033
+ WHERE env.id = ANY(ARRAY(
1034
+ SELECT unnest(${environments})
1035
+ UNION SELECT parent_id FROM environments WHERE id = ANY(${environments}) AND owner IS NOT NULL
1036
+ -- What the newest policy protects, by the ids its JSON names; one that is no UUID names nothing.
1037
+ UNION SELECT (place #>> '{}')::uuid FROM policy, jsonb_path_query(policy.metadata::jsonb, '$.policy.protected[*].environmentId') AS place
1038
+ WHERE (place #>> '{}') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
1039
+ ))
1040
+ ),
1041
+ 'ended', (
1042
+ SELECT coalesce(json_agg(related_seq::text), '[]') FROM audit_log
1043
+ WHERE author = 'vault' AND action = 'reference.end' AND decision = 'allow' AND related_seq = ANY(${references})
1044
+ ),
1045
+ 'released', ${released === void 0 ? sql`0` : sql`(
1046
+ SELECT count(DISTINCT key) FROM (
1047
+ -- A read of one key names it; of several at one place, lists them (readsByPlace, vault.ts). An entry, or an
1048
+ -- item of its list, that names no key, which only a forged entry is, counts as one key of its own.
1049
+ SELECT coalesce(e.secret_id::text, CASE jsonb_typeof(one -> 'secretId') WHEN 'string' THEN one ->> 'secretId' END, 'entry:' || e.seq) FROM audit_log e
1050
+ -- Each item of a list, never what lax paths would make of an object; silent, as strict paths fail on what is missing.
1051
+ LEFT JOIN LATERAL jsonb_path_query(CASE WHEN e.secret_id IS NULL THEN e.metadata::jsonb END, 'strict $.secrets ? (@.type() == "array")[*]', '{}', true) AS one ON true
1052
+ WHERE e.author = 'vault' AND e.actor = ${released.principal} AND e.action = 'secret.read' AND e.decision = 'allow'
1053
+ AND e.occurred_at > ${NOW$1} + ${released.clockOffsetMs} - ${released.windowMs}
1054
+ UNION ALL SELECT unnest(${sql.param([...released.keys])}::text[])
1055
+ ) AS released (key)
1056
+ )`}
1057
+ ) AS snapshot`;
1058
+ }
1059
+ //#endregion
1060
+ //#region src/snapshot.sqlite.ts
1061
+ /**
1062
+ * An entry of `audit_log` aliased `e`, as JSON (`RawEntry`), bytes as hex.
1063
+ * A JSON value loses its type leaving a subquery, so whoever takes one
1064
+ * from a subquery wraps it in `json(…)`.
1065
+ */
1066
+ const ENTRY = sql.raw(`json_object(
1067
+ 'seq', e.seq, 'author', e.author, 'keyId', e.key_id, 'occurredAt', e.occurred_at, 'actor', e.actor, 'action', e.action,
1068
+ 'decision', e.decision, 'code', e.code, 'subjectPrincipal', e.subject_principal, 'projectId', e.project_id,
1069
+ 'environmentId', e.environment_id, 'secretId', e.secret_id, 'secretVersionId', e.secret_version_id,
1070
+ 'operationId', e.operation_id, 'requestId', e.request_id, 'sourceIp', e.source_ip, 'relatedSeq', e.related_seq,
1071
+ 'metadata', e.metadata, 'prevHash', hex(e.prev_hash), 'mac', hex(e.mac), 'hash', hex(e.hash))`);
1072
+ const NOW = sql.raw(`CAST(unixepoch('subsec') * 1000 AS INTEGER)`);
1073
+ /**
1074
+ * `snapshot`'s statement on SQLite, the same as on Postgres. Nothing is
1075
+ * locked: one transaction runs at a time (connect.ts), so the head is the
1076
+ * last entry's.
1077
+ */
1078
+ function snapshotQuery(asked) {
1079
+ const principals = JSON.stringify(asked.principals);
1080
+ const environments = JSON.stringify(asked.environments ?? []);
1081
+ const references = JSON.stringify((asked.references ?? []).map(Number));
1082
+ const actions = JSON.stringify(ACCESS_ACTIONS);
1083
+ const tomb = `%${TOMBSTONE}%`;
1084
+ const released = asked.released;
1085
+ const head = asked.head === true;
1086
+ return sql`
1087
+ WITH policy AS (
1088
+ SELECT * FROM audit_log WHERE author = 'vault' AND action = 'policy.change' AND decision = 'allow' ORDER BY seq DESC LIMIT 1
1089
+ )
1090
+ SELECT json_object(
1091
+ 'at', ${NOW},
1092
+ 'head', ${head ? sql`json((SELECT json_object('nextSeq', next_seq, 'hash', hex(head_hash)) FROM audit_chain_head WHERE only_row))` : sql`NULL`},
1093
+ 'last', ${head ? sql`json((SELECT json_object('seq', seq, 'hash', hex(hash)) FROM audit_log ORDER BY seq DESC LIMIT 1))` : sql`NULL`},
1094
+ 'rotation', ${head ? sql`json((SELECT ${ENTRY} FROM audit_log e WHERE author = 'vault' AND action = 'key.rotate' AND decision = 'allow' ORDER BY seq DESC LIMIT 1))` : sql`NULL`},
1095
+ 'members', json(${asked.members === true ? sql`(SELECT json_group_array(json_object(
1096
+ 'principal', principal, 'status', status, 'owner', owner, 'role', role, 'scope', scope, 'generation', generation,
1097
+ 'createdAt', created_at, 'createdBy', created_by, 'statusChangedAt', status_changed_at, 'statusChangedBy', status_changed_by,
1098
+ 'accessSeq', access_seq, 'mac', hex(mac))) FROM vault_members WHERE principal IN (SELECT value FROM json_each(${principals})))` : sql`'[]'`}),
1099
+ 'grants', json((
1100
+ SELECT json_group_array(json_object(
1101
+ 'principal', g.principal, 'projectId', coalesce(g.project_id, env.project_id), 'environmentId', g.environment_id,
1102
+ 'environmentSlug', g.environment_slug, 'role', g.role, 'expiresAt', g.expires_at, 'grantedAt', g.granted_at, 'grantedBy', g.granted_by))
1103
+ FROM vault_grants g LEFT JOIN environments env ON env.id = g.environment_id
1104
+ WHERE g.principal IN (SELECT value FROM json_each(${principals}))
1105
+ )),
1106
+ 'access', json((
1107
+ SELECT json_group_array(json(newest)) FROM (
1108
+ SELECT (
1109
+ SELECT ${ENTRY} FROM audit_log e
1110
+ WHERE author = 'vault' AND subject_principal = asked.value AND action IN (SELECT value FROM json_each(${actions})) AND decision = 'allow'
1111
+ ORDER BY seq DESC LIMIT 1
1112
+ ) AS newest
1113
+ FROM json_each(${principals}) AS asked
1114
+ ) WHERE newest IS NOT NULL
1115
+ )),
1116
+ 'policy', json((SELECT ${ENTRY} FROM policy e)),
1117
+ 'environments', json((
1118
+ SELECT json_group_array(json_object(
1119
+ 'id', env.id, 'projectId', env.project_id, 'slug', env.slug, 'owner', env.owner, 'parentId', env.parent_id,
1120
+ 'deleted', env.slug LIKE ${tomb} OR project.slug LIKE ${tomb},
1121
+ 'archived', env.archived_at IS NOT NULL OR project.archived_at IS NOT NULL))
1122
+ FROM environments env JOIN projects project ON project.id = env.project_id
1123
+ WHERE env.id IN (SELECT value FROM json_each(${environments}))
1124
+ OR env.id IN (SELECT parent_id FROM environments WHERE id IN (SELECT value FROM json_each(${environments})) AND owner IS NOT NULL)
1125
+ OR env.id IN (
1126
+ SELECT json_extract(place.value, '$.environmentId')
1127
+ FROM policy, json_each(policy.metadata, '$.policy.protected') AS place
1128
+ -- Of a forged entry's, an element that is no object names nothing: json_extract would fail on a string.
1129
+ WHERE json_valid(policy.metadata) AND json_type(policy.metadata, '$.policy.protected') = 'array' AND place.type = 'object'
1130
+ )
1131
+ )),
1132
+ 'ended', json((
1133
+ SELECT json_group_array(related_seq) FROM audit_log
1134
+ WHERE author = 'vault' AND action = 'reference.end' AND decision = 'allow' AND related_seq IN (SELECT value FROM json_each(${references}))
1135
+ )),
1136
+ 'released', ${released === void 0 ? sql`0` : sql`(
1137
+ SELECT count(DISTINCT key) FROM (
1138
+ -- A read of one key names it; of several at one place, lists them (readsByPlace, vault.ts). An entry, or an
1139
+ -- item of its list, that names no key, which only a forged entry is, counts as one key of its own.
1140
+ SELECT coalesce(e.secret_id, CASE WHEN one.type = 'object' AND json_type(one.value, '$.secretId') = 'text' THEN json_extract(one.value, '$.secretId') END, 'entry:' || e.seq) AS key
1141
+ FROM audit_log e
1142
+ LEFT JOIN json_each(CASE WHEN e.secret_id IS NULL AND json_type(e.metadata, '$.secrets') = 'array' THEN e.metadata END, '$.secrets') AS one
1143
+ WHERE e.author = 'vault' AND e.actor = ${released.principal} AND e.action = 'secret.read' AND e.decision = 'allow'
1144
+ AND e.occurred_at > ${NOW} + ${released.clockOffsetMs} - ${released.windowMs}
1145
+ UNION ALL SELECT value FROM json_each(${JSON.stringify(released.keys)})
1146
+ )
1147
+ )`}
1148
+ ) AS snapshot`;
1149
+ }
1150
+ //#endregion
1151
+ //#region src/snapshot.ts
1152
+ async function snapshot(db, asked) {
1153
+ const read = () => readOnce(db, asked);
1154
+ const first = await read();
1155
+ if (asked.head !== true || first.head !== null && current(first.head)) return first;
1156
+ if (first.head === null) await lockLogHead(db);
1157
+ const again = await read();
1158
+ if (again.head === null) throw new Error("the log's head, held by this transaction, was read as held by another");
1159
+ if (!current(again.head)) throw new LogHeadMismatch(`the log's head says entry ${again.head.nextSeq} comes next, but the log ends ${again.head.last === null ? "empty" : `at entry ${again.head.last.seq}`}, or with another hash`);
1160
+ return again;
1161
+ }
1162
+ /** Whether the head names the last entry the same statement saw: nothing was appended between its snapshot and its lock. */
1163
+ function current(head) {
1164
+ if (head.last === null) return head.nextSeq === 0n;
1165
+ return head.nextSeq === head.last.seq + 1n && head.headHash.equals(head.last.hash);
1166
+ }
1167
+ async function readOnce(db, asked) {
1168
+ const query = (engineOf(db) === "postgres" ? snapshotQuery$1 : snapshotQuery)(asked);
1169
+ const [row] = await rowsOf(db, query);
1170
+ const raw = typeof row.snapshot === "string" ? JSON.parse(row.snapshot) : row.snapshot;
1171
+ return {
1172
+ at: Number(raw.at),
1173
+ members: new Map(raw.members.map((member) => [member.principal, memberOf(member)])),
1174
+ grants: new Map(asked.principals.map((principal) => [principal, raw.grants.filter((grant) => grant.principal === principal).map(grantOf)])),
1175
+ newestAccess: new Map(raw.access.map((entry) => [entry.subjectPrincipal, entryOf(entry)])),
1176
+ newestPolicy: raw.policy === null ? null : entryOf(raw.policy),
1177
+ environments: new Map(raw.environments.map(({ id, ...environment }) => [id, {
1178
+ ...environment,
1179
+ deleted: Boolean(environment.deleted),
1180
+ archived: Boolean(environment.archived)
1181
+ }])),
1182
+ ended: new Set(raw.ended.map((seq) => BigInt(seq))),
1183
+ released: Number(raw.released ?? 0),
1184
+ head: raw.head === null ? null : {
1185
+ nextSeq: BigInt(raw.head.nextSeq),
1186
+ headHash: Buffer.from(raw.head.hash, "hex"),
1187
+ last: raw.last === null ? null : {
1188
+ seq: BigInt(raw.last.seq),
1189
+ hash: Buffer.from(raw.last.hash, "hex")
1190
+ },
1191
+ rotation: raw.rotation === null ? null : entryOf(raw.rotation)
1192
+ }
1193
+ };
1194
+ }
1195
+ /** An entry as the chain's codec reads it, from what `entryJson` wrote. */
1196
+ function entryOf(raw) {
1197
+ return {
1198
+ ...raw,
1199
+ seq: BigInt(raw.seq),
1200
+ author: raw.author,
1201
+ occurredAt: Number(raw.occurredAt),
1202
+ relatedSeq: raw.relatedSeq === null ? null : BigInt(raw.relatedSeq),
1203
+ prevHash: Buffer.from(raw.prevHash, "hex"),
1204
+ mac: Buffer.from(raw.mac, "hex"),
1205
+ hash: Buffer.from(raw.hash, "hex")
1206
+ };
1207
+ }
1208
+ function grantOf(raw) {
1209
+ return {
1210
+ ...raw,
1211
+ expiresAt: raw.expiresAt === null ? null : Number(raw.expiresAt),
1212
+ grantedAt: Number(raw.grantedAt)
1213
+ };
1214
+ }
1215
+ function memberOf({ owner, ...raw }) {
1216
+ return memberOf$1({
1217
+ ...raw,
1218
+ owner: owner === true || owner === 1,
1219
+ generation: Number(raw.generation),
1220
+ createdAt: Number(raw.createdAt),
1221
+ statusChangedAt: Number(raw.statusChangedAt),
1222
+ accessSeq: BigInt(raw.accessSeq),
1223
+ mac: Buffer.from(raw.mac, "hex")
1224
+ });
1225
+ }
1226
+ //#endregion
997
1227
  //#region src/accounting.ts
998
1228
  const KEY_ACTIONS = /* @__PURE__ */ new Set([
999
1229
  "secret.read",
@@ -1358,7 +1588,7 @@ var Walk = class {
1358
1588
  };
1359
1589
  /** The action of the vault's entry that signs a prefix of the log. */
1360
1590
  const CHECKPOINT = "audit.checkpoint";
1361
- const ACCESS = new Set(ACCESS_ACTIONS);
1591
+ const ACCESS = new Set(ACCESS_ACTIONS$1);
1362
1592
  /** JSON with every object's keys sorted: one text for one value, however it was built. */
1363
1593
  function canonical(value) {
1364
1594
  return JSON.stringify(value, (_, inner) => inner !== null && typeof inner === "object" && !Array.isArray(inner) ? Object.fromEntries(Object.entries(inner).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : inner);
@@ -1571,6 +1801,24 @@ var VaultService = class {
1571
1801
  async #now(db) {
1572
1802
  return await now(db) + this.#prepared.options.clockOffset();
1573
1803
  }
1804
+ /** A snapshot's clock, as the vault keeps time (`#now`). */
1805
+ #at(reads) {
1806
+ return reads.at + this.#prepared.options.clockOffset();
1807
+ }
1808
+ /** What a decision about `principals` reads, and `asked` besides, in one statement: under the head when `head` is set. */
1809
+ #snapshot(tx, principals, asked, head) {
1810
+ const { released, ...rest } = asked;
1811
+ return snapshot(tx, {
1812
+ ...rest,
1813
+ principals,
1814
+ head,
1815
+ ...released === void 0 ? {} : { released: {
1816
+ ...released,
1817
+ windowMs: this.#config.bulkLimit.windowMs,
1818
+ clockOffsetMs: this.#prepared.options.clockOffset()
1819
+ } }
1820
+ });
1821
+ }
1574
1822
  /**
1575
1823
  * Decide in one transaction, with `principals`' rows locked first, then
1576
1824
  * the log's head. A `Refused` or an `Outage` rolls back all but its own
@@ -1583,34 +1831,35 @@ var VaultService = class {
1583
1831
  * alone takes the head later (`late`), once its key service has answered,
1584
1832
  * which it must not wait on holding it, and decides again then (`#keys`).
1585
1833
  */
1586
- async #decide(principals, decide, late = false) {
1834
+ async #decide(principals, decide, late = false, asked = {}) {
1587
1835
  const superseded = await this.#settled();
1588
1836
  if (superseded !== null) return {
1589
1837
  ok: false,
1590
1838
  refusal: refusal("wrong_kek", superseded)
1591
1839
  };
1592
- return this.#decideNow(principals, decide, late);
1840
+ return this.#decideNow(principals, decide, late, asked);
1593
1841
  }
1594
1842
  /** `#decide`, once the vault is settled. */
1595
- async #decideNow(principals, decide, late = false) {
1843
+ async #decideNow(principals, decide, late = false, asked = {}) {
1596
1844
  const reports = [];
1597
1845
  for (let attempt = 1;; attempt += 1) try {
1598
- return await this.#decideOnce(principals, decide, reports, late);
1846
+ return await this.#decideOnce(principals, decide, reports, late, asked);
1599
1847
  } catch (error) {
1600
1848
  if (attempt < 3 && (error instanceof Retry || isUniqueViolation(error))) continue;
1601
1849
  throw error;
1602
1850
  }
1603
1851
  }
1604
- async #decideOnce(principals, decide, reports, late) {
1852
+ async #decideOnce(principals, decide, reports, late, asked) {
1605
1853
  try {
1606
1854
  const result = await this.#db.transaction(async (tx) => {
1607
1855
  await boundLockWaits(tx, LOCK_TIMEOUT_MS);
1608
1856
  const members = principals.length === 0 ? /* @__PURE__ */ new Map() : await lockMembers(tx, principals);
1609
- if (!late) await lockLogHead(tx);
1857
+ const reads = await this.#snapshot(tx, principals, asked, !late);
1610
1858
  const d = {
1611
1859
  tx,
1612
1860
  members,
1613
- at: await this.#now(tx),
1861
+ at: this.#at(reads),
1862
+ reads,
1614
1863
  log: [],
1615
1864
  writes: [],
1616
1865
  touched: /* @__PURE__ */ new Set(),
@@ -1624,7 +1873,8 @@ var VaultService = class {
1624
1873
  let at = d.at;
1625
1874
  const accessSeq = /* @__PURE__ */ new Map();
1626
1875
  if (entries.length > 0) {
1627
- const appended = await this.#append(tx, entries, d.underLock);
1876
+ if (d.reads.head === null) throw new Error("a decision appends only under the head it read with");
1877
+ const appended = await this.#append(tx, entries, d.underLock, d.reads);
1628
1878
  at = appended.occurredAt;
1629
1879
  entries.forEach((entry, i) => {
1630
1880
  if (isAccessEntry(entry)) accessSeq.set(entry.subjectPrincipal, appended.seqStart + BigInt(i));
@@ -1689,12 +1939,12 @@ var VaultService = class {
1689
1939
  * MAC are passed over, and reported: otherwise whoever can insert a row
1690
1940
  * could lock any member out.
1691
1941
  */
1692
- async #integrity(db, principal, row, grants, reports) {
1942
+ async #integrity(db, principal, row, grants, reports, reads) {
1693
1943
  if (row !== void 0 && !this.#sealed(row, grants)) {
1694
1944
  this.#report(reports, principal, "mac", row.mac.toString("hex"));
1695
1945
  return "mac";
1696
1946
  }
1697
- const newest = await this.#newestAccess(db, principal, reports);
1947
+ const newest = await this.#newestAccess(db, principal, reports, reads);
1698
1948
  const seq = typeof newest !== "string" ? newest.seq : newest === "unreached" ? void 0 : null;
1699
1949
  if (row === void 0 ? seq === null : seq === row.accessSeq) return null;
1700
1950
  if (row === void 0 && await member(db, principal) !== void 0) throw new Retry();
@@ -1702,8 +1952,10 @@ var VaultService = class {
1702
1952
  return "stale";
1703
1953
  }
1704
1954
  /** The newest access entry about `principal` that carries the vault's MAC (`newestAccess` in @coffre/db/grants). */
1705
- #newestAccess(db, principal, reports) {
1706
- return newestAccess(db, principal, this.#check(reports, principal));
1955
+ #newestAccess(db, principal, reports, reads) {
1956
+ if (reads === void 0 || !reads.grants.has(principal)) return newestAccess(db, principal, this.#check(reports, principal));
1957
+ const newest = reads.newestAccess.get(principal);
1958
+ return newestAuthentic((limit, before) => before === void 0 ? Promise.resolve(newest === void 0 ? [] : [newest]) : accessEntriesAbout(db, principal, limit, before), this.#check(reports, principal));
1707
1959
  }
1708
1960
  /** Entries in the vault's name told apart by their MACs, each it did not write reported, about `principal`. */
1709
1961
  #check(reports, principal) {
@@ -1801,7 +2053,7 @@ var VaultService = class {
1801
2053
  }
1802
2054
  async #replaceEveryProject(d, principal) {
1803
2055
  const row = d.members.get(principal);
1804
- const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
2056
+ const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
1805
2057
  if (row === void 0 || subject.status !== "active") return;
1806
2058
  const everyProject = subject.stored.filter((grant) => grant.projectId === null && grant.environmentId === null);
1807
2059
  if (everyProject.length === 0) return;
@@ -1953,16 +2205,31 @@ var VaultService = class {
1953
2205
  * that an instance still running with the replaced KEK, as during a
1954
2206
  * deploy, cannot write after the rotation.
1955
2207
  */
1956
- #append(tx, entries, underLock = []) {
2208
+ async #append(tx, entries, underLock = [], held) {
1957
2209
  const prepared = this.#prepared;
1958
- return appendEntries(tx, prepared.logKey, entries, async (locked) => {
1959
- const rotation = await latestVaultEntry(locked, [KEY_ROTATE]);
2210
+ const head = held?.head ?? void 0;
2211
+ const appended = await appendEntries(tx, prepared.logKey, entries, async (locked) => {
2212
+ const rotation = head === void 0 ? await latestVaultEntry(locked, [KEY_ROTATE]) : head.rotation ?? void 0;
1960
2213
  if (rotation !== void 0 && !prepared.logKeys.some((key) => key.keyId === rotation.keyId)) {
1961
2214
  prepared.superseded = `the log moved on to ${rotation.keyId}, a key this vault does not hold: a vault given a newer vault key replaced it, and a replaced key writes nothing more`;
1962
2215
  throw new Error(prepared.superseded);
1963
2216
  }
1964
2217
  for (const check of underLock) await check(locked);
2218
+ }, head === void 0 ? void 0 : {
2219
+ head,
2220
+ last: head.last,
2221
+ at: held.at
1965
2222
  });
2223
+ if (head !== void 0) held.head = {
2224
+ ...head,
2225
+ nextSeq: appended.nextSeq,
2226
+ headHash: appended.headHash,
2227
+ last: {
2228
+ seq: appended.nextSeq - 1n,
2229
+ hash: appended.headHash
2230
+ }
2231
+ };
2232
+ return appended;
1966
2233
  }
1967
2234
  async unwrap(input) {
1968
2235
  const { principal } = input;
@@ -2019,10 +2286,8 @@ var VaultService = class {
2019
2286
  permission: input.purpose === "use" ? "secret.use" : "secret.read",
2020
2287
  secrets: items.map((item) => item.secret),
2021
2288
  places: sealed.map((seal, i) => seal?.holder ?? items[i].secret),
2022
- vet: ends.length === 0 ? void 0 : async (db) => {
2023
- const ended = await endedReferences(db, ends);
2024
- return sealed.map((seal) => seal !== null && ended.has(seal.seq) ? "ended" : null);
2025
- },
2289
+ references: ends,
2290
+ vet: ends.length === 0 ? void 0 : async (_, reads) => sealed.map((seal) => seal !== null && reads.ended.has(seal.seq) ? "ended" : null),
2026
2291
  remote,
2027
2292
  entry,
2028
2293
  input
@@ -2203,11 +2468,9 @@ var VaultService = class {
2203
2468
  });
2204
2469
  const owners = /* @__PURE__ */ new Map();
2205
2470
  const check = async (d) => {
2206
- const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), call.input);
2471
+ const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), call.input);
2207
2472
  const at = secrets.map((secret, i) => call.places?.[i] ?? secret);
2208
- const environments = await environmentsById(d.tx, [...secrets, ...at].map((place) => place.environmentId));
2209
- const parents = [...environments.values()].flatMap((environment) => environment.owner === null || environment.parentId === null ? [] : [environment.parentId]);
2210
- for (const [id, parent] of await environmentsById(d.tx, parents)) environments.set(id, parent);
2473
+ const environments = d.reads.environments;
2211
2474
  const gone = (place) => environments.get(place.environmentId)?.deleted ?? false;
2212
2475
  owners.clear();
2213
2476
  for (const [id, environment] of environments) if (environment.owner !== null) owners.set(id, ownerKey(environment.owner));
@@ -2223,7 +2486,7 @@ var VaultService = class {
2223
2486
  }
2224
2487
  return refuses(reader, call.permission, placeIn(at[i], environments), gone(secret) || gone(at[i]));
2225
2488
  });
2226
- if (call.vet !== void 0 && !codes.some((code) => code !== null)) (await call.vet(d.tx)).forEach((code, i) => codes[i] = code);
2489
+ if (call.vet !== void 0 && !codes.some((code) => code !== null)) (await call.vet(d.tx, d.reads)).forEach((code, i) => codes[i] = code);
2227
2490
  const copying = action === "secret.read" && call.input.purpose === "copy";
2228
2491
  if (action === "secret.read" && principal.startsWith("user:") && handsBack({
2229
2492
  purpose: call.input.purpose ?? "",
@@ -2232,9 +2495,17 @@ var VaultService = class {
2232
2495
  if (codes[i] === null && secret.sensitive === true) codes[i] = "sensitive";
2233
2496
  });
2234
2497
  let first = codes.find((code) => code !== null) ?? null;
2235
- if (first === null && action === "secret.read" && await this.#overBulkLimit(d, principal, secrets.length)) first = "bulk_limit";
2498
+ if (first === null && action === "secret.read" && this.#overBulkLimit(d)) first = "bulk_limit";
2236
2499
  if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, i) => entry(secret, "deny", codes[i] ?? first)));
2237
2500
  };
2501
+ const reads = {
2502
+ environments: [...secrets, ...secrets.map((secret, i) => call.places?.[i] ?? secret)].map((place) => place.environmentId),
2503
+ references: call.references ?? [],
2504
+ ...action === "secret.read" ? { released: {
2505
+ principal,
2506
+ keys: secrets.map((secret) => secret.secretId)
2507
+ } } : {}
2508
+ };
2238
2509
  const intentId = call.remote ? randomUUID() : null;
2239
2510
  let intentSeq = null;
2240
2511
  const outcomeEntry = (secret, item, decision, code) => {
@@ -2253,7 +2524,7 @@ var VaultService = class {
2253
2524
  if (call.remote) {
2254
2525
  const intent = await this.#decide([principal], async (d) => {
2255
2526
  await check(d);
2256
- const at = await this.#now(d.tx);
2527
+ const at = d.at;
2257
2528
  return { seq: (await this.#append(d.tx, [{
2258
2529
  actor: principal,
2259
2530
  action: "key.intent",
@@ -2273,8 +2544,8 @@ var VaultService = class {
2273
2544
  version: secret.version
2274
2545
  }))
2275
2546
  })
2276
- }])).seqStart };
2277
- });
2547
+ }], [], d.reads)).seqStart };
2548
+ }, false, reads);
2278
2549
  if (!intent.ok) return intent;
2279
2550
  intentSeq = intent.seq;
2280
2551
  }
@@ -2291,7 +2562,7 @@ var VaultService = class {
2291
2562
  await decided(d);
2292
2563
  const { vet } = call;
2293
2564
  if (late && vet !== void 0) d.underLock.push(async (locked) => {
2294
- const codes = await vet(locked);
2565
+ const codes = await vet(locked, d.reads);
2295
2566
  const first = codes.find((code) => code !== null);
2296
2567
  if (first === void 0 || first === null) return;
2297
2568
  throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, item) => outcomeEntry(secret, item, "deny", codes[item] ?? first)));
@@ -2307,11 +2578,10 @@ var VaultService = class {
2307
2578
  if (expired) throw new Outage(new KekUnavailableError("key operation exceeded its deadline"), entries());
2308
2579
  if (done.length < outcomes.length) throw new Refused(refusal("bad_claim", MESSAGES.bad_claim), entries());
2309
2580
  if (late) {
2310
- await lockLogHead(d.tx);
2581
+ d.reads = await this.#snapshot(d.tx, [principal], reads, true);
2582
+ d.at = this.#at(d.reads);
2311
2583
  await decided(d);
2312
- const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
2313
- const now = await environmentsById(d.tx, places.map((place) => place.environmentId));
2314
- if (places.some((place) => now.get(place.environmentId)?.deleted ?? false)) throw new Refused(refusal("deleted", MESSAGES.deleted), secrets.map((secret, i) => outcomeEntry(secret, i, "deny", "deleted")));
2584
+ if (secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]).some((place) => d.reads.environments.get(place.environmentId)?.deleted ?? false)) throw new Refused(refusal("deleted", MESSAGES.deleted), secrets.map((secret, i) => outcomeEntry(secret, i, "deny", "deleted")));
2315
2585
  }
2316
2586
  const released = secrets.map((secret, i) => outcomeEntry(secret, i, "allow", null));
2317
2587
  d.log.push(...released);
@@ -2321,12 +2591,16 @@ var VaultService = class {
2321
2591
  } finally {
2322
2592
  for (const value of done) value.wipe();
2323
2593
  }
2324
- }, late);
2594
+ }, late, reads);
2325
2595
  }
2326
- /** Whether `n` more keys would take `principal` past the bulk limit; counted under their row's lock, so exactly. */
2327
- async #overBulkLimit(d, principal, n) {
2328
- const { count, windowMs } = this.#config.bulkLimit;
2329
- return await releasesSince(d.tx, principal, d.at - windowMs) + n > count;
2596
+ /**
2597
+ * Whether this read's keys would take the reader past the bulk limit: the
2598
+ * distinct keys released to them in the window, these among them. A key
2599
+ * read again exposes nothing new, so it counts once, whatever its version.
2600
+ * Counted under their row's lock, so exactly (`Reads`).
2601
+ */
2602
+ #overBulkLimit(d) {
2603
+ return d.reads.released > this.#config.bulkLimit.count;
2330
2604
  }
2331
2605
  /**
2332
2606
  * Make each holder a reference to its source: the actor writes the holder
@@ -2376,7 +2650,7 @@ var VaultService = class {
2376
2650
  };
2377
2651
  };
2378
2652
  return this.#decide([principal], async (d) => {
2379
- const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
2653
+ const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), input);
2380
2654
  const natures$1 = await natures(d.tx, [...sources.values()].map((source) => source.secretId));
2381
2655
  const environments = await environmentsById(d.tx, items.flatMap((item) => {
2382
2656
  const source = sources.get(item.source.secretId);
@@ -2455,7 +2729,7 @@ var VaultService = class {
2455
2729
  return seals.map((seal) => done.has(seal.seq) ? "ended" : null);
2456
2730
  };
2457
2731
  return this.#decide([principal], async (d) => {
2458
- const actor = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
2732
+ const actor = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), input);
2459
2733
  const environments = await environmentsById(d.tx, seals.map((seal) => seal.holder.environmentId));
2460
2734
  const codes = seals.map((seal) => {
2461
2735
  const holder = refuses(actor, "secret.write", placeIn(seal.holder, environments), false);
@@ -2556,7 +2830,7 @@ var VaultService = class {
2556
2830
  */
2557
2831
  async #makeOwner(d, principal, reason) {
2558
2832
  const row = d.members.get(principal);
2559
- const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
2833
+ const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
2560
2834
  if (subject.status === "active" && ownsInstance(subject.live)) return false;
2561
2835
  const tampered = subject.status === "tampered";
2562
2836
  if (tampered && await this.#logged(d.tx, principal) === void 0) return false;
@@ -2610,10 +2884,10 @@ var VaultService = class {
2610
2884
  * What `principal` holds, their row checked first (`#integrity`): a row
2611
2885
  * that fails holds nothing, and is `tampered`.
2612
2886
  */
2613
- async #standing(db, principal, row, at, reports) {
2887
+ async #standing(db, principal, row, at, reports, reads) {
2614
2888
  const none = NOTHING;
2615
- const grants$1 = row === void 0 ? [] : await grants(db, principal);
2616
- const fault = await this.#integrity(db, principal, row, grants$1, reports);
2889
+ const grants$1 = row === void 0 ? [] : reads?.grants.get(principal) ?? await grants(db, principal);
2890
+ const fault = await this.#integrity(db, principal, row, grants$1, reports, reads);
2617
2891
  if (fault !== null) return {
2618
2892
  principal,
2619
2893
  status: "tampered",
@@ -2643,7 +2917,7 @@ var VaultService = class {
2643
2917
  role: grant.role
2644
2918
  }));
2645
2919
  const { role, scope } = roleOf(principal, row);
2646
- const { policy } = await this.#policyAt(db, reports, at);
2920
+ const { policy } = await this.#policyAt(db, reports, at, reads);
2647
2921
  const service = !principal.startsWith("user:");
2648
2922
  return {
2649
2923
  principal,
@@ -2703,13 +2977,17 @@ var VaultService = class {
2703
2977
  /** `access`, as the rows are now. */
2704
2978
  async #read(principal, superseded) {
2705
2979
  const read = async (db, reports) => {
2706
- const [row, held, at] = await Promise.all([
2707
- member(db, principal),
2708
- grants(db, principal),
2709
- this.#now(db)
2710
- ]);
2711
- const fault = await this.#integrity(db, principal, row, held, reports);
2712
- const { policy } = await this.#policyAt(db, reports, at);
2980
+ const reads = await snapshot(db, {
2981
+ principals: [principal],
2982
+ members: true
2983
+ });
2984
+ const [row, held, at] = [
2985
+ reads.members.get(principal),
2986
+ reads.grants.get(principal),
2987
+ this.#at(reads)
2988
+ ];
2989
+ const fault = await this.#integrity(db, principal, row, held, reports, reads);
2990
+ const { policy } = await this.#policyAt(db, reports, at, reads);
2713
2991
  return this.#access(principal, row, held, at, fault !== null, policy);
2714
2992
  };
2715
2993
  const found = [];
@@ -2784,7 +3062,7 @@ var VaultService = class {
2784
3062
  const expiresAt = change.expiresAt === null ? null : Date.parse(change.expiresAt);
2785
3063
  if (Number.isNaN(expiresAt) || expiresAt !== null && expiresAt <= d.at) throw refused("invalid", "an end date must be in the future");
2786
3064
  }
2787
- const { policy } = await this.#policyAt(d.tx, d.reports, d.at);
3065
+ const { policy } = await this.#policyAt(d.tx, d.reports, d.at, d.reads);
2788
3066
  for (const change of changes) {
2789
3067
  if (change.role !== null && !isRole(change.role, policy)) throw refused("invalid", `no such role: ${change.role}`);
2790
3068
  if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role, policy)) throw refused("invalid", `${change.role} can only be granted on a project`);
@@ -2800,13 +3078,13 @@ var VaultService = class {
2800
3078
  environmentSlug: environment.slug
2801
3079
  };
2802
3080
  });
2803
- const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
3081
+ const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
2804
3082
  if (acting.status === "tampered") throw refused("tampered");
2805
3083
  const manages = where.every((place) => mayManageAccess(acting.live, place));
2806
3084
  if (!manages && !(SERVICE.test(principal) && PERSON.test(actor))) throw refused("not_allowed");
2807
3085
  if (actor === principal && changes.some((change) => change.role !== null)) throw refused("not_allowed", "nobody grants themselves a role: ask another admin");
2808
3086
  const row = d.members.get(principal);
2809
- const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
3087
+ const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
2810
3088
  if (subject.status === "tampered") throw refused("tampered", TAMPERED_SUBJECT);
2811
3089
  const admitted = input.admit === true && row?.status !== "active" ? await this.#admitWith(d, acting, principal, row, input) : void 0;
2812
3090
  if (admitted === void 0 && row?.status === "removed") throw refused("removed");
@@ -2906,7 +3184,7 @@ var VaultService = class {
2906
3184
  const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [accessEntry(actor, "member.add", principal, "deny", input, asked, code)]);
2907
3185
  return this.#decide([actor, principal], async (d) => {
2908
3186
  validateCorrelation(input);
2909
- const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
3187
+ const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
2910
3188
  if (acting.status === "tampered") throw refused("tampered");
2911
3189
  const service = SERVICE.test(principal) && (input.role ?? "member") === "member" && input.scope === void 0;
2912
3190
  if (!runsInstance(acting.live)) await this.#setsUp(d, acting, service, refused);
@@ -2919,7 +3197,7 @@ var VaultService = class {
2919
3197
  };
2920
3198
  if (wanted !== void 0 && wanted.role !== "member" && !principal.startsWith("user:")) throw refused("invalid", "service accounts hold project grants only, never an instance role");
2921
3199
  const row = d.members.get(principal);
2922
- const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
3200
+ const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
2923
3201
  if (subject.status === "tampered") throw refused("tampered", TAMPERED_SUBJECT);
2924
3202
  d.touched.add(principal);
2925
3203
  d.grants.set(principal, [...subject.stored]);
@@ -3020,10 +3298,10 @@ var VaultService = class {
3020
3298
  validateCorrelation(input);
3021
3299
  if (!PRINCIPAL.test(principal)) throw refused("invalid", `not a member: ${shownMember(principal)}`);
3022
3300
  const row = d.members.get(principal);
3023
- const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
3301
+ const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
3024
3302
  if (acting.status === "tampered") throw refused("tampered");
3025
3303
  const holder = acting.live;
3026
- const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
3304
+ const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
3027
3305
  const held = subject.stored;
3028
3306
  if (this.#listed(principal)) throw refused("not_allowed", declaredOwner(principal));
3029
3307
  if (subject.status === "tampered") {
@@ -3127,20 +3405,21 @@ var VaultService = class {
3127
3405
  * no role holds anything but Owner, otherwise whoever can insert rows
3128
3406
  * could unprotect them. Its edits are then unknown, not none.
3129
3407
  */
3130
- async #policyAt(db, reports, at) {
3131
- const entry = await newestAuthentic((limit, before) => newestVaultEntries(db, POLICY, limit, before), this.#check(reports, null));
3408
+ async #policyAt(db, reports, at, reads) {
3409
+ const entry = await newestAuthentic((limit, before) => reads !== void 0 && before === void 0 ? Promise.resolve(reads.newestPolicy === null ? [] : [reads.newestPolicy]) : newestVaultEntries(db, POLICY, limit, before), this.#check(reports, null));
3132
3410
  if (entry === "none") return {
3133
3411
  policy: DEFAULT_POLICY,
3134
3412
  edits: NO_EDITS,
3135
3413
  readable: true
3136
3414
  };
3137
3415
  const edits = typeof entry === "string" ? null : readEdits(JSON.parse(entry.metadata));
3138
- return edits === null ? {
3416
+ if (edits === null) return {
3139
3417
  policy: CLOSED_POLICY,
3140
3418
  edits: NO_EDITS,
3141
3419
  readable: false
3142
- } : {
3143
- policy: await livePolicy(db, edits, at),
3420
+ };
3421
+ return {
3422
+ policy: livePolicy(entry === reads?.newestPolicy ? reads.environments : await environmentsById(db, edits.protected.map((place) => place.environmentId)), edits, at),
3144
3423
  edits,
3145
3424
  readable: true
3146
3425
  };
@@ -3165,11 +3444,11 @@ var VaultService = class {
3165
3444
  const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [entry("deny", { change: describeChange(change) }, code)]);
3166
3445
  return this.#decide([actor], async (d) => {
3167
3446
  validateCorrelation(input);
3168
- const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
3447
+ const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
3169
3448
  if (acting.status === "tampered") throw refused("tampered");
3170
3449
  if (acting.status !== "active") throw refused(acting.status === "removed" ? "removed" : "not_a_member");
3171
- const newest = await newestVaultSeq(d.tx, POLICY);
3172
- const { policy: before, edits, readable } = await this.#policyAt(d.tx, d.reports, d.at);
3450
+ const newest = d.reads.newestPolicy?.seq ?? null;
3451
+ const { policy: before, edits, readable } = await this.#policyAt(d.tx, d.reports, d.at, d.reads);
3173
3452
  if (revision !== void 0 && policyRevision(newest, before) !== revision) throw refused("policy_changed");
3174
3453
  const owner = acting.live.credentialScope == null && ownsInstance(acting.live);
3175
3454
  if (!readable && !owner) throw refused("not_allowed", "the vault cannot read its own policy: only an Owner of the whole instance writes it again, from the roles as they ship");
@@ -3225,7 +3504,7 @@ var VaultService = class {
3225
3504
  }
3226
3505
  } else throw refused("invalid", "a change is to a role, an instance role, or protection");
3227
3506
  const answer = {
3228
- policy: await livePolicy(d.tx, next, d.at),
3507
+ policy: livePolicy(await environmentsById(d.tx, next.protected.map((place) => place.environmentId)), next, d.at),
3229
3508
  seq: newest === null ? null : Number(newest)
3230
3509
  };
3231
3510
  if (!readable || JSON.stringify(next) !== JSON.stringify(edits)) {
@@ -3257,7 +3536,7 @@ var VaultService = class {
3257
3536
  const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [entry("deny", { serviceAccounts: input.settings?.serviceAccounts ?? null }, code)]);
3258
3537
  return this.#decide([actor], async (d) => {
3259
3538
  validateCorrelation(input);
3260
- const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
3539
+ const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
3261
3540
  if (acting.status === "tampered") throw refused("tampered");
3262
3541
  if (!runsInstance(acting.live)) throw refused("not_allowed", "only admins and owners of the whole instance change its settings");
3263
3542
  if (!isScope(input.settings?.serviceAccounts)) throw refused("invalid", "who sets up service accounts is a scope: { projects, environments }, each all, only or except");
@@ -4382,7 +4661,7 @@ function oneRead(group) {
4382
4661
  }
4383
4662
  /** Whether `entry` changes a member's access: what their row's `access_seq` names. */
4384
4663
  function isAccessEntry(entry) {
4385
- return entry.decision === "allow" && entry.subjectPrincipal !== void 0 && entry.subjectPrincipal !== null && ACCESS_ACTIONS.includes(entry.action);
4664
+ return entry.decision === "allow" && entry.subjectPrincipal !== void 0 && entry.subjectPrincipal !== null && ACCESS_ACTIONS$1.includes(entry.action);
4386
4665
  }
4387
4666
  /** An entry about a member's access. */
4388
4667
  function accessEntry(actor, action, principal, decision, correlation, detail, code = null) {
@@ -4486,8 +4765,7 @@ function readEdits(stored) {
4486
4765
  * keeps its slug, which a scoped instance role's filters read. The edits
4487
4766
  * keep them all: no write is needed.
4488
4767
  */
4489
- async function livePolicy(db, edits, at) {
4490
- const rows = await environmentsById(db, edits.protected.map((place) => place.environmentId));
4768
+ function livePolicy(rows, edits, at) {
4491
4769
  const live = edits.protected.filter((place) => {
4492
4770
  const row = rows.get(place.environmentId);
4493
4771
  return row === void 0 ? at - place.at < UNWRITTEN_FORK_MS : !row.deleted;