@coffre/vault 0.14.2 → 0.14.4

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,4 +1,4 @@
1
- import { a as KekUnavailableError, c as awsKms, d as Kek, f as VaultConfig, i as KekProvider, l as checkpointMessage, n as AwsKmsOptions, o as SecretContext, p as derivedSigningKey, r as KekBadClaimError, s as WrappedDek, t as AwsCredentials, u as verifyCheckpoint } from "./index-zkW6DEm9.js";
1
+ import { a as KekUnavailableError, c as awsKms, d as Kek, f as VaultConfig, i as KekProvider, l as checkpointMessage, n as AwsKmsOptions, o as SecretContext, p as derivedSigningKey, r as KekBadClaimError, s as WrappedDek, t as AwsCredentials, u as verifyCheckpoint } from "./index-CIq4i_KT.js";
2
2
  import { AdmitInput, ChangePolicyInput, EndReferencesInput, ReferenceInput, RemoveInput, RewrapInput, SetAccessInput, SetSettingsInput, UnwrapInput, Vault, VerifyLogInput, WrapInput } from "@coffre/core/vault";
3
3
  import { PostgresDatabase, PostgresDatabase as PostgresDatabase$1, postgres } from "@coffre/db/hyperdrive";
4
4
  import { WorkerEntrypoint } from "cloudflare:workers";
@@ -1,5 +1,5 @@
1
- import { a as verifyCheckpoint, i as checkpointMessage, n as KekUnavailableError, o as derivedSigningKey, r as awsKms, s as resolveVaultConfig, t as KekBadClaimError } from "./src-B0d-W-SI.js";
2
- import { n as prepareVault, t as openVault } from "./vault-hLffpMy5.js";
1
+ import { a as verifyCheckpoint, i as checkpointMessage, n as KekUnavailableError, o as derivedSigningKey, r as awsKms, s as resolveVaultConfig, t as KekBadClaimError } from "./src-NM5NsYFs.js";
2
+ import { n as prepareVault, t as openVault } from "./vault-BBdXsny3.js";
3
3
  import { createDatabase } from "@coffre/db";
4
4
  import { HyperdrivePool, postgres } from "@coffre/db/hyperdrive";
5
5
  import { WorkerEntrypoint } from "cloudflare:workers";
@@ -1,5 +1,5 @@
1
1
  import { checkpointMessage as checkpointMessage$1, verifyCheckpoint } from "@coffre/core/vault";
2
- import { AwsCredentials, AwsKmsOptions, KekBadClaimError as KekBadClaimError$1, KekProvider, KekProvider as KekProvider$1, KekRegistry, KekUnavailableError as KekUnavailableError$1, WrappedDek, awsKms } from "@coffre/core/kek";
2
+ import { AwsCredentials, AwsKmsOptions, KekBadClaimError as KekBadClaimError$1, KekProvider, KekProvider as KekProvider$1, KekRegistry, KekUnavailableError as KekUnavailableError$1, WrappedDek, awsKms, derivedSigningKey } from "@coffre/core/kek";
3
3
  import { SecretContext } from "@coffre/core/envelope";
4
4
  //#region src/config.d.ts
5
5
  /**
@@ -72,7 +72,5 @@ type Owners = {
72
72
  owners?: never;
73
73
  bootstrapOwners?: never;
74
74
  };
75
- /** The signing key a local KEK stands for, when the vault is given none: HKDF-SHA-256 of the KEK. */
76
- declare function derivedSigningKey(kek: Uint8Array): Buffer;
77
75
  //#endregion
78
76
  export { KekUnavailableError$1 as a, awsKms as c, Kek as d, VaultConfig as f, KekProvider$1 as i, checkpointMessage$1 as l, AwsKmsOptions as n, SecretContext as o, derivedSigningKey as p, KekBadClaimError$1 as r, WrappedDek as s, AwsCredentials as t, verifyCheckpoint as u };
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- import { a as KekUnavailableError, c as awsKms, d as Kek, f as VaultConfig, i as KekProvider, l as checkpointMessage, n as AwsKmsOptions, o as SecretContext, p as derivedSigningKey, r as KekBadClaimError, s as WrappedDek, t as AwsCredentials, u as verifyCheckpoint } from "./index-zkW6DEm9.js";
1
+ import { a as KekUnavailableError, c as awsKms, d as Kek, f as VaultConfig, i as KekProvider, l as checkpointMessage, n as AwsKmsOptions, o as SecretContext, p as derivedSigningKey, r as KekBadClaimError, s as WrappedDek, t as AwsCredentials, u as verifyCheckpoint } from "./index-CIq4i_KT.js";
2
2
  export type * from "@coffre/core/vault";
3
3
  export { type AwsCredentials, type AwsKmsOptions, type Kek, KekBadClaimError, type KekProvider, KekUnavailableError, type SecretContext, type VaultConfig, type WrappedDek, awsKms, checkpointMessage, derivedSigningKey, verifyCheckpoint };
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- import { a as verifyCheckpoint, i as checkpointMessage, n as KekUnavailableError, o as derivedSigningKey, r as awsKms, t as KekBadClaimError } from "./src-B0d-W-SI.js";
1
+ import { a as verifyCheckpoint, i as checkpointMessage, n as KekUnavailableError, o as derivedSigningKey, r as awsKms, t as KekBadClaimError } from "./src-NM5NsYFs.js";
2
2
  export { KekBadClaimError, KekUnavailableError, awsKms, checkpointMessage, derivedSigningKey, verifyCheckpoint };
package/dist/node.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as KekUnavailableError, c as awsKms, d as Kek, f as VaultConfig, i as KekProvider, l as checkpointMessage, n as AwsKmsOptions, o as SecretContext, p as derivedSigningKey, r as KekBadClaimError, s as WrappedDek, t as AwsCredentials, u as verifyCheckpoint } from "./index-zkW6DEm9.js";
1
+ import { a as KekUnavailableError, c as awsKms, d as Kek, f as VaultConfig, i as KekProvider, l as checkpointMessage, n as AwsKmsOptions, o as SecretContext, p as derivedSigningKey, r as KekBadClaimError, s as WrappedDek, t as AwsCredentials, u as verifyCheckpoint } from "./index-CIq4i_KT.js";
2
2
  import { Vault, Vault as Vault$1 } from "@coffre/core/vault";
3
3
  import { Database } from "@coffre/db";
4
4
  import "@coffre/core/access";
package/dist/node.js CHANGED
@@ -1,5 +1,5 @@
1
- import { a as verifyCheckpoint, i as checkpointMessage, n as KekUnavailableError, o as derivedSigningKey, r as awsKms, s as resolveVaultConfig, t as KekBadClaimError } from "./src-B0d-W-SI.js";
2
- import { n as prepareVault, t as openVault } from "./vault-hLffpMy5.js";
1
+ import { a as verifyCheckpoint, i as checkpointMessage, n as KekUnavailableError, o as derivedSigningKey, r as awsKms, s as resolveVaultConfig, t as KekBadClaimError } from "./src-NM5NsYFs.js";
2
+ import { n as prepareVault, t as openVault } from "./vault-BBdXsny3.js";
3
3
  import { isUnavailable } from "@coffre/db/dialect";
4
4
  import { chmodSync, existsSync, lstatSync, rmSync } from "node:fs";
5
5
  import { Agent, createServer, request } from "node:http";
@@ -1,6 +1,5 @@
1
1
  import { checkpointMessage as checkpointMessage$1, verifyCheckpoint } from "@coffre/core/vault";
2
- import { KekBadClaimError as KekBadClaimError$1, KekRegistry, KekUnavailableError as KekUnavailableError$1, LocalKekProvider, awsKms } from "@coffre/core/kek";
3
- import { hkdfSync } from "node:crypto";
2
+ import { KekBadClaimError as KekBadClaimError$1, KekRegistry, KekUnavailableError as KekUnavailableError$1, LocalKekProvider, awsKms, derivedSigningKey } from "@coffre/core/kek";
4
3
  //#region src/config.ts
5
4
  /**
6
5
  * 1000 different keys in 15 minutes, which no person or deploy pipeline
@@ -21,12 +20,6 @@ const KEK_ID = /^[A-Za-z0-9._-]{1,64}$/;
21
20
  const KEK_PROVIDER = /^[a-z0-9][a-z0-9-]{0,31}$/;
22
21
  /** Every row records it, and `provider:keyId` finds the KEK again: visible ASCII, bounded. */
23
22
  const KEK_NAME = /^[\x21-\x7e]{1,255}$/;
24
- /** The label no other use of a KEK shares: a local KEK is otherwise only ever an AES-256-GCM key. */
25
- const SIGNING_KEY_LABEL = "coffre.vault.signing-key.v1";
26
- /** The signing key a local KEK stands for, when the vault is given none: HKDF-SHA-256 of the KEK. */
27
- function derivedSigningKey(kek) {
28
- return Buffer.from(hkdfSync("sha256", kek, /* @__PURE__ */ new Uint8Array(0), SIGNING_KEY_LABEL, 32));
29
- }
30
23
  /** A KEK as the registry takes it, and the signing key it stands for when it is one the vault holds. */
31
24
  function kek(entry) {
32
25
  if (!("wrap" in entry)) {
@@ -1,7 +1,7 @@
1
1
  import { ACCESS_ACTIONS, ACCESS_ACTIONS as ACCESS_ACTIONS$1, POLICY_REVISION, accessRevision, 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
- import { createHash, createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
4
3
  import { prepared, tablesOf } from "@coffre/db";
4
+ import { createHash, createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
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, ownsProject, readsSensitive, runsInstance, setsUpServices, unscoped, useOnly, 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";
@@ -178,10 +178,16 @@ function referenceRefusal(reader, holder, source, environments, nature) {
178
178
  //#region src/store.ts
179
179
  /**
180
180
  * On Postgres, fail a lock wait in this transaction after `ms`, rather than
181
- * wait for ever behind a transaction that never ends.
181
+ * wait for ever behind a transaction that never ends; with `statements`,
182
+ * any statement that takes longer, too. A queue for a row each holder
183
+ * updates, as the log's head, is no one wait: a waiter follows each update
184
+ * to the row's new version and waits again, its lock's clock started over,
185
+ * so only the statement's own bound ends it.
182
186
  */
183
- async function boundLockWaits(tx, ms) {
184
- if (engineOf(tx) === "postgres") await tx.execute(sql.raw(`SET LOCAL lock_timeout = ${Math.trunc(ms)}`));
187
+ async function boundLockWaits(tx, ms, statements = false) {
188
+ if (engineOf(tx) !== "postgres") return;
189
+ const bound = String(Math.trunc(ms));
190
+ await tx.execute(statements ? sql`SELECT set_config('lock_timeout', ${bound}, true), set_config('statement_timeout', ${bound}, true)` : sql.raw(`SET LOCAL lock_timeout = ${bound}`));
185
191
  }
186
192
  /** The database's clock, now. */
187
193
  async function now(db) {
@@ -251,14 +257,37 @@ async function instanceOwners(db) {
251
257
  * Lock these members' rows for the rest of the transaction, in one order
252
258
  * everywhere, and read them: those that have none are not in the map. Every
253
259
  * decision about a member takes its row first, so two of them, from any
254
- * process, never interleave.
260
+ * process, never interleave; but `shared`, for a decision that writes no
261
+ * member's row, as a read, takes them as other such decisions do, side by
262
+ * side, and only what changes the member waits for it, and it for that.
263
+ *
264
+ * Postgres lets a share lock join those already held even while a writer
265
+ * waits for them, so a steady stream of one member's reads would hold a
266
+ * removal off for ever. So each row comes with an advisory lock on its
267
+ * principal, which the lock manager queues in order: a read asked after a
268
+ * writer waits behind it. The same statement takes it, then the row, one
269
+ * principal after another.
255
270
  */
256
- async function lockMembers(tx, principals) {
271
+ async function lockMembers(tx, principals, shared = false) {
257
272
  const { vaultMembers } = tablesOf(tx);
258
- 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");
259
- return new Map(rows.map((row) => [row.principal, memberOf$1(row)]));
273
+ const queued = engineOf(tx) === "postgres" ? { queued: sql`${sql.raw(shared ? "pg_advisory_xact_lock_shared" : "pg_advisory_xact_lock")}(${MEMBER_QUEUE}, hashtext(${vaultMembers.principal}))` } : {};
274
+ const rows = await forUpdate(tx, tx.select({
275
+ ...memberColumns(tx),
276
+ ...queued
277
+ }).from(vaultMembers).where(inArray(vaultMembers.principal, [...new Set(principals)])).orderBy(asc(vaultMembers.principal)), shared ? "share" : "no key update");
278
+ return new Map(rows.map(({ queued: _, ...row }) => [row.principal, memberOf$1(row)]));
260
279
  }
261
280
  /**
281
+ * The first key of the members' queues, Postgres's advisory locks: the
282
+ * second is the principal's hash, and the pair is in a space of its own,
283
+ * apart from the migrator's single key. A hash two principals share queues
284
+ * one behind the other's writer; and two changes that each lock two
285
+ * members, where one's first and the other's second share it, can wait on
286
+ * each other, which Postgres ends after a second by failing one of them
287
+ * (40P01): about n² / 2³³ for an instance of n members.
288
+ */
289
+ const MEMBER_QUEUE = 1668245101;
290
+ /**
262
291
  * Add `row`. A principal has one row: when two decisions admit the same
263
292
  * one at once, the second fails here, and rolls back with its entries.
264
293
  */
@@ -1187,17 +1216,16 @@ function snapshotQuery$1(asked) {
1187
1216
  WHERE author = 'vault' AND action = 'reference.end' AND decision = 'allow' AND related_seq = ANY(${references})
1188
1217
  ),
1189
1218
  'released', ${released === void 0 ? sql`0` : sql`(
1190
- SELECT count(DISTINCT key) FROM (
1191
- -- A read of one key names it; of several at one place, lists them (readsByPlace, vault.ts). An entry, or an
1192
- -- item of its list, that names no key, which only a forged entry is, counts as one key of its own.
1193
- SELECT coalesce(e.secret_id::text, CASE jsonb_typeof(one -> 'secretId') WHEN 'string' THEN one ->> 'secretId' END, 'entry:' || e.seq) FROM audit_log e
1194
- -- Each item of a list, never what lax paths would make of an object; silent, as strict paths fail on what is missing.
1195
- 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
1196
- WHERE e.author = 'vault' AND e.actor = ${released.principal} AND e.action = 'secret.read' AND e.decision = 'allow'
1197
- AND e.occurred_at > ${NOW$1} + ${released.clockOffsetMs} - ${released.windowMs}
1198
- UNION ALL SELECT unnest(${sql.param([...released.keys])}::text[])
1199
- ) AS released (key)
1200
- )`}
1219
+ -- The keys released within the window, but those asked for, up to one past the cap: a row for each key the
1220
+ -- principal was ever released, however many reads of them the log holds (vault_releases, which the database
1221
+ -- keeps from it). The bound is a subquery, evaluated once: clock_timestamp() alone is volatile, and runs a row.
1222
+ SELECT count(*) FROM (
1223
+ SELECT 1 FROM vault_releases
1224
+ WHERE principal = ${released.principal} AND released_at > (SELECT ${NOW$1} + ${released.clockOffsetMs} - ${released.windowMs})
1225
+ AND key <> ALL(${sql.param([...released.keys])}::text[])
1226
+ LIMIT ${released.cap + 1}
1227
+ ) AS before
1228
+ ) + (SELECT count(DISTINCT key) FROM unnest(${sql.param([...released.keys])}::text[]) AS asked (key))`}
1201
1229
  ) AS snapshot`;
1202
1230
  }
1203
1231
  //#endregion
@@ -1278,17 +1306,14 @@ function snapshotQuery(asked) {
1278
1306
  WHERE author = 'vault' AND action = 'reference.end' AND decision = 'allow' AND related_seq IN (SELECT value FROM json_each(${references}))
1279
1307
  )),
1280
1308
  'released', ${released === void 0 ? sql`0` : sql`(
1281
- SELECT count(DISTINCT key) FROM (
1282
- -- A read of one key names it; of several at one place, lists them (readsByPlace, vault.ts). An entry, or an
1283
- -- item of its list, that names no key, which only a forged entry is, counts as one key of its own.
1284
- 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
1285
- FROM audit_log e
1286
- 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
1287
- WHERE e.author = 'vault' AND e.actor = ${released.principal} AND e.action = 'secret.read' AND e.decision = 'allow'
1288
- AND e.occurred_at > ${NOW} + ${released.clockOffsetMs} - ${released.windowMs}
1289
- UNION ALL SELECT value FROM json_each(${JSON.stringify(released.keys)})
1309
+ -- The keys released within the window, but those asked for, up to one past the cap (snapshot.postgres.ts).
1310
+ SELECT count(*) FROM (
1311
+ SELECT 1 FROM vault_releases
1312
+ WHERE principal = ${released.principal} AND released_at > ${NOW} + ${released.clockOffsetMs} - ${released.windowMs}
1313
+ AND key NOT IN (SELECT value FROM json_each(${JSON.stringify(released.keys)}))
1314
+ LIMIT ${released.cap + 1}
1290
1315
  )
1291
- )`}
1316
+ ) + (SELECT count(DISTINCT value) FROM json_each(${JSON.stringify(released.keys)}))`}
1292
1317
  ) AS snapshot`;
1293
1318
  }
1294
1319
  //#endregion
@@ -1782,6 +1807,14 @@ function openVault(db, prepared) {
1782
1807
  const KEY_BUDGET_MS = 5e3;
1783
1808
  /** How long a decision waits for a lock: above the key budget, so a removal outwaits a read in flight. */
1784
1809
  const LOCK_TIMEOUT_MS = 15e3;
1810
+ /**
1811
+ * How long a statement of a read's decision runs, a wait for a lock
1812
+ * included: a read waits only for what changes its reader, and for the
1813
+ * log's head. Longer is a queue, which holds the read's connection while it
1814
+ * waits; refused, it fails as unavailable at once, and its caller tries
1815
+ * again later.
1816
+ */
1817
+ const READ_LOCK_TIMEOUT_MS = 3e3;
1785
1818
  /** A member: `user:<email>` or `token:<name>`. */
1786
1819
  const PRINCIPAL = /^(user|token):[^\s:][^\s]*$/;
1787
1820
  /** A person, and a service account: only a person sets one up (`givesService`). */
@@ -1971,7 +2004,8 @@ var VaultService = class {
1971
2004
  ...released === void 0 ? {} : { released: {
1972
2005
  ...released,
1973
2006
  windowMs: this.#config.bulkLimit.windowMs,
1974
- clockOffsetMs: this.#prepared.options.clockOffset()
2007
+ clockOffsetMs: this.#prepared.options.clockOffset(),
2008
+ cap: this.#config.bulkLimit.count
1975
2009
  } }
1976
2010
  });
1977
2011
  }
@@ -1986,30 +2020,32 @@ var VaultService = class {
1986
2020
  * commits: no decision acts on a policy read before it. A key operation
1987
2021
  * alone takes the head later (`late`), once its key service has answered,
1988
2022
  * which it must not wait on holding it, and decides again then (`#keys`).
2023
+ * A read (`reads`) changes no member: it takes their rows shared, so
2024
+ * that one member's reads decide side by side (`lockMembers`).
1989
2025
  */
1990
- async #decide(principals, decide, late = false, asked = {}) {
2026
+ async #decide(principals, decide, how = {}) {
1991
2027
  const superseded = await this.#settled();
1992
2028
  if (superseded !== null) return {
1993
2029
  ok: false,
1994
2030
  refusal: refusal("wrong_kek", superseded)
1995
2031
  };
1996
- return this.#decideNow(principals, decide, late, asked);
2032
+ return this.#decideNow(principals, decide, how);
1997
2033
  }
1998
2034
  /** `#decide`, once the vault is settled. */
1999
- async #decideNow(principals, decide, late = false, asked = {}) {
2035
+ async #decideNow(principals, decide, how = {}) {
2000
2036
  const reports = [];
2001
2037
  for (let attempt = 1;; attempt += 1) try {
2002
- return await this.#decideOnce(principals, decide, reports, late, asked);
2038
+ return await this.#decideOnce(principals, decide, reports, how);
2003
2039
  } catch (error) {
2004
2040
  if (attempt < 3 && (error instanceof Retry || isUniqueViolation(error))) continue;
2005
2041
  throw error;
2006
2042
  }
2007
2043
  }
2008
- async #decideOnce(principals, decide, reports, late, asked) {
2044
+ async #decideOnce(principals, decide, reports, { late = false, asked = {}, reads: shared = false }) {
2009
2045
  try {
2010
2046
  const result = await this.#db.transaction(async (tx) => {
2011
- await boundLockWaits(tx, LOCK_TIMEOUT_MS);
2012
- const members = principals.length === 0 ? /* @__PURE__ */ new Map() : await lockMembers(tx, principals);
2047
+ await boundLockWaits(tx, shared ? READ_LOCK_TIMEOUT_MS : LOCK_TIMEOUT_MS, shared);
2048
+ const members = principals.length === 0 ? /* @__PURE__ */ new Map() : await lockMembers(tx, principals, shared);
2013
2049
  const reads = await this.#snapshot(tx, principals, asked, !late);
2014
2050
  const d = {
2015
2051
  tx,
@@ -2039,7 +2075,9 @@ var VaultService = class {
2039
2075
  for (const then of d.after) then((entry) => seqs.get(entry));
2040
2076
  }
2041
2077
  for (const write of d.writes) await write(at);
2042
- for (const principal of /* @__PURE__ */ new Set([...d.touched, ...accessSeq.keys()])) await this.#seal(d, principal, accessSeq.get(principal));
2078
+ const sealing = /* @__PURE__ */ new Set([...d.touched, ...accessSeq.keys()]);
2079
+ if (shared && sealing.size > 0) throw new Error("a read changes no member, and seals no row");
2080
+ for (const principal of sealing) await this.#seal(d, principal, accessSeq.get(principal));
2043
2081
  return result;
2044
2082
  });
2045
2083
  this.#reported(reports);
@@ -2615,7 +2653,7 @@ var VaultService = class {
2615
2653
  if (this.#revisionOf(standing, d) !== asked) throw new Refused(refusal("access_changed", MESSAGES.access_changed), []);
2616
2654
  }
2617
2655
  throw new Refused(refusal(code, message), entries);
2618
- });
2656
+ }, { reads: true });
2619
2657
  }
2620
2658
  /**
2621
2659
  * The data key, or null when it does not open as this secret's: a claim
@@ -2733,7 +2771,10 @@ var VaultService = class {
2733
2771
  }))
2734
2772
  })
2735
2773
  }], [], d.reads)).seqStart };
2736
- }, false, reads);
2774
+ }, {
2775
+ asked: reads,
2776
+ reads: true
2777
+ });
2737
2778
  if (!intent.ok) return intent;
2738
2779
  intentSeq = intent.seq;
2739
2780
  }
@@ -2779,13 +2820,19 @@ var VaultService = class {
2779
2820
  } finally {
2780
2821
  for (const value of done) value.wipe();
2781
2822
  }
2782
- }, late, reads);
2823
+ }, {
2824
+ late,
2825
+ asked: reads,
2826
+ reads: true
2827
+ });
2783
2828
  }
2784
2829
  /**
2785
2830
  * Whether this read's keys would take the reader past the bulk limit: the
2786
2831
  * distinct keys released to them in the window, these among them. A key
2787
2832
  * read again exposes nothing new, so it counts once, whatever its version.
2788
- * Counted under their row's lock, so exactly (`Reads`).
2833
+ * Counted under the log's head, which every release appends under, so
2834
+ * exactly: one reader's reads decide side by side under their row, and
2835
+ * each counts the others' releases again once it holds the head (`#keys`).
2789
2836
  */
2790
2837
  #overBulkLimit(d) {
2791
2838
  return d.reads.released > this.#config.bulkLimit.count;
@@ -4007,9 +4054,9 @@ var VaultService = class {
4007
4054
  const { wrongKek, logKeys } = this.#prepared;
4008
4055
  const { walks } = this.#prepared.options;
4009
4056
  const found = [];
4010
- let seen = null;
4057
+ let seen;
4011
4058
  const resumed = await this.#db.transaction(async (tx) => {
4012
- seen = (await newestCheckpointEntry(tx))?.seq ?? null;
4059
+ seen = await newestCheckpointEntry(tx);
4013
4060
  await this.#sweep(tx, found);
4014
4061
  const from = await this.#resume(tx, "newest");
4015
4062
  if (!from.ok) return from;
@@ -4061,10 +4108,14 @@ var VaultService = class {
4061
4108
  reason: scrub.reason
4062
4109
  });
4063
4110
  const newest = latest === void 0 ? null : JSON.parse(latest.metadata);
4064
- if (stalled !== null || ((await newestCheckpointEntry(d.tx))?.seq ?? null) !== seen) {
4111
+ if (stalled !== null || ((await newestCheckpointEntry(d.tx))?.seq ?? null) !== (seen?.seq ?? null)) {
4065
4112
  const standing = await this.#standingRefusal(d.tx);
4066
4113
  if (standing !== null) throw refused("log_broken", standing);
4067
4114
  if (stalled === null && newest !== null) return { checkpoint: signedPart(newest) };
4115
+ } else if (seen?.decision === "deny" && walked.ok) {
4116
+ const standing = await this.#standingRefusal(d.tx);
4117
+ const named = typeof standing?.failedAtSeq === "number" ? BigInt(standing.failedAtSeq) : null;
4118
+ if (named !== null && named <= walked.from.at.seq && !await this.#entryHolds(d.tx, named)) throw refused("log_broken", standing);
4068
4119
  }
4069
4120
  if (newest !== null && latest.seq === head.nextSeq - 1n && latest.relatedSeq === latest.seq - 1n) return { checkpoint: signedPart(newest) };
4070
4121
  if (newest !== null && !await carries(d.tx, newest)) throw refused("log_broken", { reason: `the log up to entry ${newest.seq} is not the prefix the last checkpoint signed` });
@@ -4202,6 +4253,30 @@ var VaultService = class {
4202
4253
  };
4203
4254
  }
4204
4255
  /**
4256
+ * Whether entry `seq` is as the walk checks each: there, its hash its
4257
+ * own, linked to the entry before, and the vault's under its MAC; and the
4258
+ * entry after it, if any, linked to it, for a refusal can name either side
4259
+ * of a broken link (`forward`, at the end of a batch).
4260
+ */
4261
+ async #entryHolds(db, seq) {
4262
+ const found = await entriesAt(db, seq === 0n ? [seq, seq + 1n] : [
4263
+ seq - 1n,
4264
+ seq,
4265
+ seq + 1n
4266
+ ]);
4267
+ const entry = found.get(seq);
4268
+ const before = seq === 0n ? { hash: GENESIS_HASH } : found.get(seq - 1n);
4269
+ if (entry === void 0 || before === void 0) return false;
4270
+ const after = found.get(seq + 1n);
4271
+ if (after !== void 0 && !after.prevHash.equals(entry.hash)) return false;
4272
+ return verifyEntries([entry], {
4273
+ keys: this.#keysFor(entry),
4274
+ chainOnly: ["app"],
4275
+ startSeq: seq,
4276
+ startPrevHash: before.hash
4277
+ }).ok;
4278
+ }
4279
+ /**
4205
4280
  * The newest refusal for a broken log since the newest checkpoint was
4206
4281
  * signed, by the vault's MAC, as it said: it stands, for every
4207
4282
  * verification and checkpoint, until a checkpoint is signed, which takes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffre/vault",
3
- "version": "0.14.2",
3
+ "version": "0.14.4",
4
4
  "description": "coffre's vault: keys, grants and members, in the database the app uses, as a Cloudflare Worker or on Node.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,8 +36,8 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "drizzle-orm": "0.45.2",
39
- "@coffre/core": "0.14.2",
40
- "@coffre/db": "0.14.2"
39
+ "@coffre/core": "0.14.4",
40
+ "@coffre/db": "0.14.4"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "26.1.1",
package/src/config.ts CHANGED
@@ -1,6 +1,4 @@
1
- import { hkdfSync } from 'node:crypto';
2
-
3
- import { KekRegistry, LocalKekProvider, type KekProvider } from '@coffre/core/kek';
1
+ import { derivedSigningKey, KekRegistry, LocalKekProvider, type KekProvider } from '@coffre/core/kek';
4
2
 
5
3
  /** At most `count` distinct data keys unwrapped per principal in any `windowMs`. */
6
4
  export type BulkLimit = { count: number; windowMs: number };
@@ -108,13 +106,7 @@ const KEK_PROVIDER = /^[a-z0-9][a-z0-9-]{0,31}$/;
108
106
  /** Every row records it, and `provider:keyId` finds the KEK again: visible ASCII, bounded. */
109
107
  const KEK_NAME = /^[\x21-\x7e]{1,255}$/;
110
108
 
111
- /** The label no other use of a KEK shares: a local KEK is otherwise only ever an AES-256-GCM key. */
112
- const SIGNING_KEY_LABEL = 'coffre.vault.signing-key.v1';
113
-
114
- /** The signing key a local KEK stands for, when the vault is given none: HKDF-SHA-256 of the KEK. */
115
- export function derivedSigningKey(kek: Uint8Array): Buffer {
116
- return Buffer.from(hkdfSync('sha256', kek, new Uint8Array(0), SIGNING_KEY_LABEL, 32));
117
- }
109
+ export { derivedSigningKey };
118
110
 
119
111
  /** A KEK as the registry takes it, and the signing key it stands for when it is one the vault holds. */
120
112
  function kek(entry: Kek): { provider: KekProvider; signingKey: Buffer | null } {
@@ -82,16 +82,15 @@ export function snapshotQuery(asked: Asked): SQL {
82
82
  'released', ${released === undefined
83
83
  ? sql`0`
84
84
  : sql`(
85
- SELECT count(DISTINCT key) FROM (
86
- -- A read of one key names it; of several at one place, lists them (readsByPlace, vault.ts). An entry, or an
87
- -- item of its list, that names no key, which only a forged entry is, counts as one key of its own.
88
- SELECT coalesce(e.secret_id::text, CASE jsonb_typeof(one -> 'secretId') WHEN 'string' THEN one ->> 'secretId' END, 'entry:' || e.seq) FROM audit_log e
89
- -- Each item of a list, never what lax paths would make of an object; silent, as strict paths fail on what is missing.
90
- 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
91
- WHERE e.author = 'vault' AND e.actor = ${released.principal} AND e.action = 'secret.read' AND e.decision = 'allow'
92
- AND e.occurred_at > ${NOW} + ${released.clockOffsetMs} - ${released.windowMs}
93
- UNION ALL SELECT unnest(${sql.param([...released.keys])}::text[])
94
- ) AS released (key)
95
- )`}
85
+ -- The keys released within the window, but those asked for, up to one past the cap: a row for each key the
86
+ -- principal was ever released, however many reads of them the log holds (vault_releases, which the database
87
+ -- keeps from it). The bound is a subquery, evaluated once: clock_timestamp() alone is volatile, and runs a row.
88
+ SELECT count(*) FROM (
89
+ SELECT 1 FROM vault_releases
90
+ WHERE principal = ${released.principal} AND released_at > (SELECT ${NOW} + ${released.clockOffsetMs} - ${released.windowMs})
91
+ AND key <> ALL(${sql.param([...released.keys])}::text[])
92
+ LIMIT ${released.cap + 1}
93
+ ) AS before
94
+ ) + (SELECT count(DISTINCT key) FROM unnest(${sql.param([...released.keys])}::text[]) AS asked (key))`}
96
95
  ) AS snapshot`;
97
96
  }
@@ -88,16 +88,13 @@ export function snapshotQuery(asked: Asked): SQL {
88
88
  'released', ${released === undefined
89
89
  ? sql`0`
90
90
  : sql`(
91
- SELECT count(DISTINCT key) FROM (
92
- -- A read of one key names it; of several at one place, lists them (readsByPlace, vault.ts). An entry, or an
93
- -- item of its list, that names no key, which only a forged entry is, counts as one key of its own.
94
- 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
95
- FROM audit_log e
96
- 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
97
- WHERE e.author = 'vault' AND e.actor = ${released.principal} AND e.action = 'secret.read' AND e.decision = 'allow'
98
- AND e.occurred_at > ${NOW} + ${released.clockOffsetMs} - ${released.windowMs}
99
- UNION ALL SELECT value FROM json_each(${JSON.stringify(released.keys)})
91
+ -- The keys released within the window, but those asked for, up to one past the cap (snapshot.postgres.ts).
92
+ SELECT count(*) FROM (
93
+ SELECT 1 FROM vault_releases
94
+ WHERE principal = ${released.principal} AND released_at > ${NOW} + ${released.clockOffsetMs} - ${released.windowMs}
95
+ AND key NOT IN (SELECT value FROM json_each(${JSON.stringify(released.keys)}))
96
+ LIMIT ${released.cap + 1}
100
97
  )
101
- )`}
98
+ ) + (SELECT count(DISTINCT value) FROM json_each(${JSON.stringify(released.keys)}))`}
102
99
  ) AS snapshot`;
103
100
  }
package/src/snapshot.ts CHANGED
@@ -49,7 +49,9 @@ export type Snapshot = {
49
49
  /**
50
50
  * How many distinct keys `asked.released.principal` holds released within
51
51
  * its window once it is given `asked.released.keys` too: a key released
52
- * again, or asked for again, counts once.
52
+ * again, or asked for again, counts once. Those released before are
53
+ * counted up to one past `asked.released.cap`, which is enough to say
54
+ * whether the read goes past it.
53
55
  */
54
56
  released: number;
55
57
  /** The log's head, locked, with the last entry and the newest `key.rotate`; null unless asked, or held by another. */
@@ -74,9 +76,11 @@ export type Asked = {
74
76
  references?: readonly bigint[];
75
77
  /**
76
78
  * One principal's releases within the last `windowMs` of the clock, moved
77
- * on by `clockOffsetMs` as the vault's is, with `keys`, secret ids, besides.
79
+ * on by `clockOffsetMs` as the vault's is, with `keys`, secret ids, besides;
80
+ * read from `vault_releases`, which the database keeps from the log, up to
81
+ * `cap` + 1 of them.
78
82
  */
79
- released?: { principal: string; keys: readonly string[]; windowMs: number; clockOffsetMs: number };
83
+ released?: { principal: string; keys: readonly string[]; windowMs: number; clockOffsetMs: number; cap: number };
80
84
  /** Lock the log's head first. */
81
85
  head?: boolean;
82
86
  };
package/src/store.ts CHANGED
@@ -53,10 +53,18 @@ export { newestVaultEntries } from '@coffre/db/log';
53
53
 
54
54
  /**
55
55
  * On Postgres, fail a lock wait in this transaction after `ms`, rather than
56
- * wait for ever behind a transaction that never ends.
56
+ * wait for ever behind a transaction that never ends; with `statements`,
57
+ * any statement that takes longer, too. A queue for a row each holder
58
+ * updates, as the log's head, is no one wait: a waiter follows each update
59
+ * to the row's new version and waits again, its lock's clock started over,
60
+ * so only the statement's own bound ends it.
57
61
  */
58
- export async function boundLockWaits(tx: Transaction, ms: number): Promise<void> {
59
- if (engineOf(tx) === 'postgres') await tx.execute(sql.raw(`SET LOCAL lock_timeout = ${Math.trunc(ms)}`));
62
+ export async function boundLockWaits(tx: Transaction, ms: number, statements = false): Promise<void> {
63
+ if (engineOf(tx) !== 'postgres') return;
64
+ const bound = String(Math.trunc(ms));
65
+ await tx.execute(statements
66
+ ? sql`SELECT set_config('lock_timeout', ${bound}, true), set_config('statement_timeout', ${bound}, true)`
67
+ : sql.raw(`SET LOCAL lock_timeout = ${bound}`));
60
68
  }
61
69
 
62
70
  /** The database's clock, now. */
@@ -136,22 +144,45 @@ export async function instanceOwners(db: Queryable): Promise<Member[]> {
136
144
  * Lock these members' rows for the rest of the transaction, in one order
137
145
  * everywhere, and read them: those that have none are not in the map. Every
138
146
  * decision about a member takes its row first, so two of them, from any
139
- * process, never interleave.
147
+ * process, never interleave; but `shared`, for a decision that writes no
148
+ * member's row, as a read, takes them as other such decisions do, side by
149
+ * side, and only what changes the member waits for it, and it for that.
150
+ *
151
+ * Postgres lets a share lock join those already held even while a writer
152
+ * waits for them, so a steady stream of one member's reads would hold a
153
+ * removal off for ever. So each row comes with an advisory lock on its
154
+ * principal, which the lock manager queues in order: a read asked after a
155
+ * writer waits behind it. The same statement takes it, then the row, one
156
+ * principal after another.
140
157
  */
141
- export async function lockMembers(tx: Transaction, principals: readonly string[]): Promise<Map<string, Member>> {
158
+ export async function lockMembers(tx: Transaction, principals: readonly string[], shared = false): Promise<Map<string, Member>> {
142
159
  const { vaultMembers } = tablesOf(tx);
160
+ const queued: Record<string, SQL> = engineOf(tx) === 'postgres'
161
+ ? { queued: sql`${sql.raw(shared ? 'pg_advisory_xact_lock_shared' : 'pg_advisory_xact_lock')}(${MEMBER_QUEUE}, hashtext(${vaultMembers.principal}))` }
162
+ : {};
143
163
  const rows = await forUpdate(
144
164
  tx,
145
165
  tx
146
- .select(memberColumns(tx))
166
+ .select({ ...memberColumns(tx), ...queued })
147
167
  .from(vaultMembers)
148
168
  .where(inArray(vaultMembers.principal, [...new Set(principals)]))
149
169
  .orderBy(asc(vaultMembers.principal)),
150
- 'no key update',
170
+ shared ? 'share' : 'no key update',
151
171
  );
152
- return new Map((rows as StoredMember[]).map((row) => [row.principal, memberOf(row)]));
172
+ return new Map((rows as (StoredMember & { queued?: unknown })[]).map(({ queued: _, ...row }) => [row.principal, memberOf(row)]));
153
173
  }
154
174
 
175
+ /**
176
+ * The first key of the members' queues, Postgres's advisory locks: the
177
+ * second is the principal's hash, and the pair is in a space of its own,
178
+ * apart from the migrator's single key. A hash two principals share queues
179
+ * one behind the other's writer; and two changes that each lock two
180
+ * members, where one's first and the other's second share it, can wait on
181
+ * each other, which Postgres ends after a second by failing one of them
182
+ * (40P01): about n² / 2³³ for an instance of n members.
183
+ */
184
+ const MEMBER_QUEUE = 0x636f666d;
185
+
155
186
  /**
156
187
  * Add `row`. A principal has one row: when two decisions admit the same
157
188
  * one at once, the second fails here, and rolls back with its entries.
package/src/vault.ts CHANGED
@@ -203,6 +203,15 @@ const KEY_BUDGET_MS = 5_000;
203
203
  /** How long a decision waits for a lock: above the key budget, so a removal outwaits a read in flight. */
204
204
  const LOCK_TIMEOUT_MS = 15_000;
205
205
 
206
+ /**
207
+ * How long a statement of a read's decision runs, a wait for a lock
208
+ * included: a read waits only for what changes its reader, and for the
209
+ * log's head. Longer is a queue, which holds the read's connection while it
210
+ * waits; refused, it fails as unavailable at once, and its caller tries
211
+ * again later.
212
+ */
213
+ const READ_LOCK_TIMEOUT_MS = 3_000;
214
+
206
215
  /** A member: `user:<email>` or `token:<name>`. */
207
216
  const PRINCIPAL = /^(user|token):[^\s:][^\s]*$/;
208
217
 
@@ -392,6 +401,13 @@ type Decision = {
392
401
  */
393
402
  type Reads = Pick<Asked, 'environments' | 'references'> & { released?: { principal: string; keys: readonly string[] } };
394
403
 
404
+ /**
405
+ * How a decision runs: `late` takes the log's head only to append, `asked`
406
+ * is what it reads besides its members, and `reads` says it changes none
407
+ * of them (`#decide`).
408
+ */
409
+ type How = { late?: boolean; asked?: Reads; reads?: boolean };
410
+
395
411
  /** The actions of the entries the vault writes about keys. */
396
412
  type KeyAction = 'secret.read' | 'key.wrap' | 'key.rewrap';
397
413
 
@@ -482,7 +498,7 @@ class VaultService implements Vault {
482
498
  ...rest,
483
499
  principals,
484
500
  head,
485
- ...(released === undefined ? {} : { released: { ...released, windowMs: this.#config.bulkLimit.windowMs, clockOffsetMs: this.#prepared.options.clockOffset() } }),
501
+ ...(released === undefined ? {} : { released: { ...released, windowMs: this.#config.bulkLimit.windowMs, clockOffsetMs: this.#prepared.options.clockOffset(), cap: this.#config.bulkLimit.count } }),
486
502
  });
487
503
  }
488
504
 
@@ -497,19 +513,21 @@ class VaultService implements Vault {
497
513
  * commits: no decision acts on a policy read before it. A key operation
498
514
  * alone takes the head later (`late`), once its key service has answered,
499
515
  * which it must not wait on holding it, and decides again then (`#keys`).
516
+ * A read (`reads`) changes no member: it takes their rows shared, so
517
+ * that one member's reads decide side by side (`lockMembers`).
500
518
  */
501
- async #decide<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, late = false, asked: Reads = {}): Promise<Outcome<T>> {
519
+ async #decide<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, how: How = {}): Promise<Outcome<T>> {
502
520
  const superseded = await this.#settled();
503
521
  if (superseded !== null) return { ok: false, refusal: refusal('wrong_kek', superseded) };
504
- return this.#decideNow(principals, decide, late, asked);
522
+ return this.#decideNow(principals, decide, how);
505
523
  }
506
524
 
507
525
  /** `#decide`, once the vault is settled. */
508
- async #decideNow<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, late = false, asked: Reads = {}): Promise<Outcome<T>> {
526
+ async #decideNow<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, how: How = {}): Promise<Outcome<T>> {
509
527
  const reports: NewEntry[] = [];
510
528
  for (let attempt = 1; ; attempt += 1) {
511
529
  try {
512
- return await this.#decideOnce(principals, decide, reports, late, asked);
530
+ return await this.#decideOnce(principals, decide, reports, how);
513
531
  } catch (error) {
514
532
  // A member who had no row when this decision locked theirs, and has
515
533
  // one now: another decision admitted them meanwhile. Again, with it.
@@ -519,11 +537,11 @@ class VaultService implements Vault {
519
537
  }
520
538
  }
521
539
 
522
- async #decideOnce<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, reports: NewEntry[], late: boolean, asked: Reads): Promise<Outcome<T>> {
540
+ async #decideOnce<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, reports: NewEntry[], { late = false, asked = {}, reads: shared = false }: How): Promise<Outcome<T>> {
523
541
  try {
524
542
  const result = await this.#db.transaction(async (tx) => {
525
- await store.boundLockWaits(tx, LOCK_TIMEOUT_MS);
526
- const members = principals.length === 0 ? new Map<string, Member>() : await store.lockMembers(tx, principals);
543
+ await store.boundLockWaits(tx, shared ? READ_LOCK_TIMEOUT_MS : LOCK_TIMEOUT_MS, shared);
544
+ const members = principals.length === 0 ? new Map<string, Member>() : await store.lockMembers(tx, principals, shared);
527
545
  // Then the head, and everything the decision reads, in one statement (`snapshot` says why it holds).
528
546
  const reads = await this.#snapshot(tx, principals, asked, !late);
529
547
  const d: Decision = {
@@ -547,9 +565,10 @@ class VaultService implements Vault {
547
565
  for (const then of d.after) then((entry) => seqs.get(entry)!);
548
566
  }
549
567
  for (const write of d.writes) await write(at);
550
- for (const principal of new Set([...d.touched, ...accessSeq.keys()])) {
551
- await this.#seal(d, principal, accessSeq.get(principal));
552
- }
568
+ const sealing = new Set([...d.touched, ...accessSeq.keys()]);
569
+ // Under a share lock, a write to the row would wait for every other read holding it, and they for it.
570
+ if (shared && sealing.size > 0) throw new Error('a read changes no member, and seals no row');
571
+ for (const principal of sealing) await this.#seal(d, principal, accessSeq.get(principal));
553
572
  return result;
554
573
  });
555
574
  this.#reported(reports);
@@ -558,6 +577,7 @@ class VaultService implements Vault {
558
577
  if (!(error instanceof Refused || error instanceof Outage)) throw error;
559
578
  const entries = [...reports, ...readsByPlace(error.entries)];
560
579
  if (entries.length > 0) {
580
+ // A refusal's entries are written whatever the decision's bound: it answers only once they are.
561
581
  await this.#db.transaction(async (tx) => {
562
582
  await store.boundLockWaits(tx, LOCK_TIMEOUT_MS);
563
583
  await this.#append(tx, entries);
@@ -1148,7 +1168,7 @@ class VaultService implements Vault {
1148
1168
  if (this.#revisionOf(standing, d) !== asked) throw new Refused(refusal('access_changed', MESSAGES.access_changed), []);
1149
1169
  }
1150
1170
  throw new Refused(refusal(code, message), entries);
1151
- });
1171
+ }, { reads: true });
1152
1172
  }
1153
1173
 
1154
1174
  /**
@@ -1299,12 +1319,13 @@ class VaultService implements Vault {
1299
1319
  }),
1300
1320
  }], [], d.reads);
1301
1321
  return { seq: appended.seqStart };
1302
- }, false, reads);
1322
+ }, { asked: reads, reads: true });
1303
1323
  if (!intent.ok) return intent;
1304
1324
  intentSeq = intent.seq;
1305
1325
  }
1306
1326
 
1307
- // Under the reader's row: the check, the key operations, and their outcome. With a key in memory, under the
1327
+ // Under the reader's row, which their other reads share and a change to them waits for: the check, the key
1328
+ // operations, and their outcome. With a key in memory, under the
1308
1329
  // log's head too, from the start (`#decideOnce`): what the check read stands until the commit, as every change it
1309
1330
  // reads appends under the head, and the head waits on nothing slow. With a key service, which nothing may wait on
1310
1331
  // holding the head, the head is taken once the keys are open, and what the check read is read again under it.
@@ -1377,14 +1398,16 @@ class VaultService implements Vault {
1377
1398
  } finally {
1378
1399
  for (const value of done) value.wipe();
1379
1400
  }
1380
- }, late, reads);
1401
+ }, { late, asked: reads, reads: true });
1381
1402
  }
1382
1403
 
1383
1404
  /**
1384
1405
  * Whether this read's keys would take the reader past the bulk limit: the
1385
1406
  * distinct keys released to them in the window, these among them. A key
1386
1407
  * read again exposes nothing new, so it counts once, whatever its version.
1387
- * Counted under their row's lock, so exactly (`Reads`).
1408
+ * Counted under the log's head, which every release appends under, so
1409
+ * exactly: one reader's reads decide side by side under their row, and
1410
+ * each counts the others' releases again once it holds the head (`#keys`).
1388
1411
  */
1389
1412
  #overBulkLimit(d: Decision): boolean {
1390
1413
  return d.reads.released > this.#config.bulkLimit.count;
@@ -2555,9 +2578,9 @@ class VaultService implements Vault {
2555
2578
  const { walks } = this.#prepared.options;
2556
2579
  const found: NewEntry[] = [];
2557
2580
  // The newest checkpoint entry, signed or refused, as this call's snapshot found it.
2558
- let seen: bigint | null = null;
2581
+ let seen: StoredEntry | undefined;
2559
2582
  const resumed = await this.#db.transaction(async (tx) => {
2560
- seen = (await store.newestCheckpointEntry(tx))?.seq ?? null;
2583
+ seen = await store.newestCheckpointEntry(tx);
2561
2584
  await this.#sweep(tx, found);
2562
2585
  const from = await this.#resume(tx, 'newest');
2563
2586
  if (!from.ok) return from;
@@ -2602,10 +2625,17 @@ class VaultService implements Vault {
2602
2625
  // Another call checkpointed, or refused to, since this one's snapshot, or this one's rehash of the past
2603
2626
  // could not run: what it would sign over is not what it checked. A refusal for a broken log stands,
2604
2627
  // and a checkpoint signed meanwhile is the newest prefix; the next call starts from them.
2605
- if (stalled !== null || ((await store.newestCheckpointEntry(d.tx))?.seq ?? null) !== seen) {
2628
+ if (stalled !== null || ((await store.newestCheckpointEntry(d.tx))?.seq ?? null) !== (seen?.seq ?? null)) {
2606
2629
  const standing = await this.#standingRefusal(d.tx);
2607
2630
  if (standing !== null) throw refused('log_broken', standing);
2608
2631
  if (stalled === null && newest !== null) return { checkpoint: signedPart(newest) };
2632
+ } else if (seen?.decision === 'deny' && walked.ok) {
2633
+ // A refusal for a broken log stands since this call's snapshot, of an entry before where its walk began, which
2634
+ // it never read: that entry again, as the walk checks each, before anything is signed over it. Still broken,
2635
+ // the refusal stands; whole again, the signature clears it (T104).
2636
+ const standing = await this.#standingRefusal(d.tx);
2637
+ const named = typeof standing?.failedAtSeq === 'number' ? BigInt(standing.failedAtSeq) : null;
2638
+ if (named !== null && named <= walked.from.at.seq && !(await this.#entryHolds(d.tx, named))) throw refused('log_broken', standing!);
2609
2639
  }
2610
2640
  // Nothing since the last one, which signed every entry before its own: it is still the newest prefix.
2611
2641
  if (newest !== null && latest!.seq === head.nextSeq - 1n && latest!.relatedSeq === latest!.seq - 1n) return { checkpoint: signedPart(newest) };
@@ -2699,6 +2729,22 @@ class VaultService implements Vault {
2699
2729
  return { ok: true, next: null };
2700
2730
  }
2701
2731
 
2732
+ /**
2733
+ * Whether entry `seq` is as the walk checks each: there, its hash its
2734
+ * own, linked to the entry before, and the vault's under its MAC; and the
2735
+ * entry after it, if any, linked to it, for a refusal can name either side
2736
+ * of a broken link (`forward`, at the end of a batch).
2737
+ */
2738
+ async #entryHolds(db: Queryable, seq: bigint): Promise<boolean> {
2739
+ const found = await store.entriesAt(db, seq === 0n ? [seq, seq + 1n] : [seq - 1n, seq, seq + 1n]);
2740
+ const entry = found.get(seq);
2741
+ const before = seq === 0n ? { hash: GENESIS_HASH } : found.get(seq - 1n);
2742
+ if (entry === undefined || before === undefined) return false;
2743
+ const after = found.get(seq + 1n);
2744
+ if (after !== undefined && !after.prevHash.equals(entry.hash)) return false;
2745
+ return verifyEntries([entry], { keys: this.#keysFor(entry), chainOnly: ['app'], startSeq: seq, startPrevHash: before.hash }).ok;
2746
+ }
2747
+
2702
2748
  /**
2703
2749
  * The newest refusal for a broken log since the newest checkpoint was
2704
2750
  * signed, by the vault's MAC, as it said: it stands, for every