@coffre/vault 0.14.4 → 0.14.5

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-CIq4i_KT.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-DQ-FKVF3.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";
@@ -16,10 +16,7 @@ export type WorkersVaultConfig = VaultConfig & {
16
16
  */
17
17
  export declare class VaultEntrypoint extends WorkerEntrypoint implements Vault {
18
18
  #private;
19
- unwrap(input: UnwrapInput): Promise<import("@coffre/core/vault").Outcome<{
20
- keys: string[];
21
- accessRevision?: string;
22
- }>>;
19
+ unwrap(input: UnwrapInput): Promise<import("@coffre/core/vault").Outcome<import("@coffre/core/vault").Unwrapped>>;
23
20
  wrap(input: WrapInput): Promise<import("@coffre/core/vault").Outcome<import("@coffre/core/vault").Wrapped>>;
24
21
  rewrap(input: RewrapInput): Promise<import("@coffre/core/vault").Outcome<import("@coffre/core/vault").Wrapped>>;
25
22
  reference(input: ReferenceInput): Promise<import("@coffre/core/vault").Outcome<{
@@ -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-NM5NsYFs.js";
2
- import { n as prepareVault, t as openVault } from "./vault-BBdXsny3.js";
1
+ import { a as verifyCheckpoint, c as resolveVaultConfig, i as checkpointMessage, n as KekUnavailableError, r as awsKms, s as derivedSigningKey, t as KekBadClaimError } from "./src-Bg0ZeMJW.js";
2
+ import { n as prepareVault, t as openVault } from "./vault-Cbi45kLB.js";
3
3
  import { createDatabase } from "@coffre/db";
4
4
  import { HyperdrivePool, postgres } from "@coffre/db/hyperdrive";
5
5
  import { WorkerEntrypoint } from "cloudflare:workers";
@@ -36,7 +36,10 @@ type VaultConfig = {
36
36
  * service holds the KEK, as AWS KMS does: the vault never sees that key.
37
37
  */
38
38
  signingKey?: string;
39
- /** At most `count` distinct data keys unwrapped per principal in any `windowMinutes`; 1000 in 15 unless set. */
39
+ /**
40
+ * At most `count` distinct data keys unwrapped per principal in any `windowMinutes`; 1000 in 15 unless set. The
41
+ * window is a day at most: the releases it counts are kept a day and an hour (`prune_vault_releases`).
42
+ */
40
43
  bulkLimit?: {
41
44
  count: number;
42
45
  windowMinutes: number;
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-CIq4i_KT.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-DQ-FKVF3.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-NM5NsYFs.js";
1
+ import { a as verifyCheckpoint, i as checkpointMessage, n as KekUnavailableError, r as awsKms, s as derivedSigningKey, t as KekBadClaimError } from "./src-Bg0ZeMJW.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-CIq4i_KT.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-DQ-FKVF3.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-NM5NsYFs.js";
2
- import { n as prepareVault, t as openVault } from "./vault-BBdXsny3.js";
1
+ import { a as verifyCheckpoint, c as resolveVaultConfig, i as checkpointMessage, n as KekUnavailableError, r as awsKms, s as derivedSigningKey, t as KekBadClaimError } from "./src-Bg0ZeMJW.js";
2
+ import { n as prepareVault, t as openVault } from "./vault-Cbi45kLB.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";
@@ -11,6 +11,13 @@ const DEFAULT_BULK_LIMIT = {
11
11
  count: 1e3,
12
12
  windowMs: 9e5
13
13
  };
14
+ /**
15
+ * The longest bulk limit window: a day. The releases a window counts are
16
+ * kept a day and an hour (migration 0021's `prune_vault_releases`, and on
17
+ * SQLite `RELEASES_KEPT_MS`), so every window up to it, and one widened
18
+ * since, counts exactly.
19
+ */
20
+ const MAX_BULK_WINDOW_MINUTES = 1440;
14
21
  function key32(value, what) {
15
22
  const raw = Buffer.from(value, "base64");
16
23
  if (raw.length !== 32) throw new Error(`${what} must be 32 bytes, base64; got ${raw.length} bytes`);
@@ -76,7 +83,7 @@ function signingKeys(given, keks) {
76
83
  return [signing, ...derived].filter((key, i, all) => all.findIndex((other) => other.equals(key)) === i);
77
84
  }
78
85
  function checkBulkLimit({ count, windowMinutes }) {
79
- if (!Number.isInteger(count) || count < 1 || !(windowMinutes > 0)) throw new Error("bulkLimit needs a whole count of at least 1 and a window above 0 minutes");
86
+ if (!Number.isInteger(count) || count < 1 || !(windowMinutes > 0) || windowMinutes > 1440) throw new Error(`bulkLimit needs a whole count of at least 1 and a window above 0 minutes, a day (${MAX_BULK_WINDOW_MINUTES}) at most`);
80
87
  return {
81
88
  count,
82
89
  windowMs: windowMinutes * 6e4
@@ -105,4 +112,4 @@ function checkOwners(emails) {
105
112
  return [...new Set(owners.map((entry) => entry.toLowerCase()))];
106
113
  }
107
114
  //#endregion
108
- export { verifyCheckpoint as a, checkpointMessage$1 as i, KekUnavailableError$1 as n, derivedSigningKey as o, awsKms as r, resolveVaultConfig as s, KekBadClaimError$1 as t };
115
+ export { verifyCheckpoint as a, resolveVaultConfig as c, checkpointMessage$1 as i, KekUnavailableError$1 as n, MAX_BULK_WINDOW_MINUTES as o, awsKms as r, derivedSigningKey as s, KekBadClaimError$1 as t };
@@ -1,4 +1,5 @@
1
- import { ACCESS_ACTIONS, ACCESS_ACTIONS as ACCESS_ACTIONS$1, POLICY_REVISION, accessRevision, checkpointMessage, checkpointVerifier, describeAccessFault, handsBack, policyRevision } from "@coffre/core/vault";
1
+ import { o as MAX_BULK_WINDOW_MINUTES } from "./src-Bg0ZeMJW.js";
2
+ import { ACCESS_ACTIONS, ACCESS_ACTIONS as ACCESS_ACTIONS$1, POLICY_REVISION, USE_METADATA_LENGTH, accessRevision, checkpointMessage, checkpointVerifier, describeAccessFault, handsBack, policyRevision } from "@coffre/core/vault";
2
3
  import { DEK_BYTES, KEY_CHECK, KEY_CHECK_CONTEXT, KEY_CHECK_VALUE, KekBadClaimError, KekCancelledError, KekUnavailableError, LocalKekProvider } from "@coffre/core/kek";
3
4
  import { prepared, tablesOf } from "@coffre/db";
4
5
  import { createHash, createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
@@ -6,9 +7,9 @@ import { DEFAULT_POLICY, EVERYWHERE, EVERY_ENVIRONMENT, FIXED_ROLES, INSTANCE_RO
6
7
  import { GENESIS_HASH, deriveLogKey, verifyEntries } from "@coffre/core/audit";
7
8
  import { checkContext, ownerKey } from "@coffre/core/envelope";
8
9
  import { TOMBSTONE, shownMember } from "@coffre/core/schemas";
9
- import { SNAPSHOT, clockMillis, engineOf, forUpdate, isUnavailable, isUniqueViolation, rowsOf, tombstone, truth } from "@coffre/db/dialect";
10
+ import { SNAPSHOT, changedRows, clockMillis, engineOf, forUpdate, isUnavailable, isUniqueViolation, rowsOf, tombstone, truth } from "@coffre/db/dialect";
10
11
  import { accessEntriesAbout, everyProjectConversion, insertGrant, liveProjects, newestAccess, readGrants, reportStands, roleColumns, storedRole } from "@coffre/db/grants";
11
- import { LogHeadMismatch, appendEntries, entryColumns, lockLogHead, missingEntry, newestAuthentic, newestVaultEntries, withinBytes } from "@coffre/db/log";
12
+ import { LogHeadMismatch, appendEntries, entryColumns, lockLogHead, lockLogHeadQueued, missingEntry, newestAuthentic, newestVaultEntries, withinBytes } from "@coffre/db/log";
12
13
  import { and, asc, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, lte, or, sql } from "drizzle-orm";
13
14
  //#region src/checkpoint.ts
14
15
  const PKCS8_ED25519 = [
@@ -189,6 +190,33 @@ async function boundLockWaits(tx, ms, statements = false) {
189
190
  const bound = String(Math.trunc(ms));
190
191
  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}`));
191
192
  }
193
+ /**
194
+ * How long a release is kept: the longest bulk limit window
195
+ * (`MAX_BULK_WINDOW_MINUTES`, a day), and an hour, a margin for the
196
+ * database's clock stepping back between a prune and a later count, which
197
+ * is seconds. Migration 0021's `prune_vault_releases` holds the same, a day
198
+ * and an hour, in its own SQL: a longer window takes a migration too.
199
+ */
200
+ const RELEASES_KEPT_MS = MAX_BULK_WINDOW_MINUTES * 6e4 + 36e5;
201
+ /**
202
+ * Delete up to `atMost` rows of `vault_releases` released over
203
+ * `RELEASES_KEPT_MS` before the database's clock: what no bulk count can
204
+ * read again. On Postgres the vault's login deletes no row itself, and asks
205
+ * `prune_vault_releases`, which deletes only those (migration 0021). How
206
+ * many went.
207
+ */
208
+ async function pruneReleases(db, atMost) {
209
+ if (engineOf(db) === "postgres") {
210
+ const [row] = await rowsOf(db, sql`SELECT prune_vault_releases(${atMost}::int) AS pruned`);
211
+ return row.pruned;
212
+ }
213
+ const { vaultReleases } = tablesOf(db);
214
+ const old = db.select({
215
+ principal: vaultReleases.principal,
216
+ key: vaultReleases.key
217
+ }).from(vaultReleases).where(lt(vaultReleases.releasedAt, sql`${clockMillis(db)} - ${RELEASES_KEPT_MS}`)).limit(atMost);
218
+ return changedRows(await db.delete(vaultReleases).where(sql`(${vaultReleases.principal}, ${vaultReleases.key}) IN ${old}`));
219
+ }
192
220
  /** The database's clock, now. */
193
221
  async function now(db) {
194
222
  const [{ at }] = await clock(db);
@@ -1322,7 +1350,7 @@ async function snapshot(db, asked) {
1322
1350
  const read = () => readOnce(db, asked);
1323
1351
  const first = await read();
1324
1352
  if (asked.head !== true || first.head !== null && current(first.head)) return first;
1325
- if (first.head === null) await lockLogHead(db);
1353
+ if (first.head === null) await (asked.waiters === void 0 ? lockLogHead(db) : lockLogHeadQueued(db, asked.waiters));
1326
1354
  const again = await read();
1327
1355
  if (again.head === null) throw new Error("the log's head, held by this transaction, was read as held by another");
1328
1356
  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`);
@@ -1815,6 +1843,31 @@ const LOCK_TIMEOUT_MS = 15e3;
1815
1843
  * again later.
1816
1844
  */
1817
1845
  const READ_LOCK_TIMEOUT_MS = 3e3;
1846
+ /**
1847
+ * How long a whole read call may take, from its start to its answer, its
1848
+ * refusal's entries, the record of a vault key's check and, with a key
1849
+ * service, its intent and its own budget (added) included: each transaction's waits are bounded by what is left of it, and
1850
+ * past it the call fails as unavailable (`Overdue`) rather than start
1851
+ * another. So a caller hears within it, ok or 503, never tens of seconds later.
1852
+ */
1853
+ const READ_DEADLINE_MS = 5e3;
1854
+ /** How many releases a checkpoint prunes at most: one short statement, a few each scheduled run. */
1855
+ const RELEASES_PRUNED = 1e4;
1856
+ /** A read's call past its deadline (`READ_DEADLINE_MS`): unavailable, as a statement past its bound (57014) is. */
1857
+ var Overdue = class extends Error {
1858
+ code = "57014";
1859
+ constructor() {
1860
+ super("this read took longer than it may: ask again shortly");
1861
+ this.name = "Overdue";
1862
+ }
1863
+ };
1864
+ /** The bound of a transaction of a call due at `deadline`: `ms`, or what is left of the call, if less. */
1865
+ function within(deadline, ms) {
1866
+ if (deadline === void 0) return ms;
1867
+ const left = deadline - Date.now();
1868
+ if (left <= 0) throw new Overdue();
1869
+ return Math.min(ms, left);
1870
+ }
1818
1871
  /** A member: `user:<email>` or `token:<name>`. */
1819
1872
  const PRINCIPAL = /^(user|token):[^\s:][^\s]*$/;
1820
1873
  /** A person, and a service account: only a person sets one up (`givesService`). */
@@ -1995,12 +2048,13 @@ var VaultService = class {
1995
2048
  return reads.at + this.#prepared.options.clockOffset();
1996
2049
  }
1997
2050
  /** What a decision about `principals` reads, and `asked` besides, in one statement: under the head when `head` is set. */
1998
- #snapshot(tx, principals, asked, head) {
2051
+ #snapshot(tx, principals, asked, head, reading = false) {
1999
2052
  const { released, ...rest } = asked;
2000
2053
  return snapshot(tx, {
2001
2054
  ...rest,
2002
2055
  principals,
2003
2056
  head,
2057
+ ...reading ? { waiters: 16 } : {},
2004
2058
  ...released === void 0 ? {} : { released: {
2005
2059
  ...released,
2006
2060
  windowMs: this.#config.bulkLimit.windowMs,
@@ -2034,19 +2088,23 @@ var VaultService = class {
2034
2088
  /** `#decide`, once the vault is settled. */
2035
2089
  async #decideNow(principals, decide, how = {}) {
2036
2090
  const reports = [];
2091
+ const due = how.reads === true ? {
2092
+ ...how,
2093
+ deadline: how.deadline ?? Date.now() + 5e3
2094
+ } : how;
2037
2095
  for (let attempt = 1;; attempt += 1) try {
2038
- return await this.#decideOnce(principals, decide, reports, how);
2096
+ return await this.#decideOnce(principals, decide, reports, due);
2039
2097
  } catch (error) {
2040
2098
  if (attempt < 3 && (error instanceof Retry || isUniqueViolation(error))) continue;
2041
2099
  throw error;
2042
2100
  }
2043
2101
  }
2044
- async #decideOnce(principals, decide, reports, { late = false, asked = {}, reads: shared = false }) {
2102
+ async #decideOnce(principals, decide, reports, { late = false, asked = {}, reads: shared = false, deadline }) {
2045
2103
  try {
2046
2104
  const result = await this.#db.transaction(async (tx) => {
2047
- await boundLockWaits(tx, shared ? READ_LOCK_TIMEOUT_MS : LOCK_TIMEOUT_MS, shared);
2105
+ await boundLockWaits(tx, shared ? within(deadline, READ_LOCK_TIMEOUT_MS) : LOCK_TIMEOUT_MS, shared);
2048
2106
  const members = principals.length === 0 ? /* @__PURE__ */ new Map() : await lockMembers(tx, principals, shared);
2049
- const reads = await this.#snapshot(tx, principals, asked, !late);
2107
+ const reads = await this.#snapshot(tx, principals, asked, !late, shared);
2050
2108
  const d = {
2051
2109
  tx,
2052
2110
  members,
@@ -2089,7 +2147,8 @@ var VaultService = class {
2089
2147
  if (!(error instanceof Refused || error instanceof Outage)) throw error;
2090
2148
  const entries = [...reports, ...readsByPlace(error.entries)];
2091
2149
  if (entries.length > 0) await this.#db.transaction(async (tx) => {
2092
- await boundLockWaits(tx, LOCK_TIMEOUT_MS);
2150
+ await boundLockWaits(tx, shared ? within(deadline, READ_LOCK_TIMEOUT_MS) : LOCK_TIMEOUT_MS, shared);
2151
+ if (shared) await lockLogHeadQueued(tx, 16);
2093
2152
  await this.#append(tx, entries);
2094
2153
  });
2095
2154
  this.#reported(reports);
@@ -2427,6 +2486,7 @@ var VaultService = class {
2427
2486
  }
2428
2487
  async unwrap(input) {
2429
2488
  const { principal } = input;
2489
+ const use = input.use === void 0 ? null : useEntry(principal, input, input.use);
2430
2490
  validateText(input.purpose);
2431
2491
  if (input.keepsSensitive !== void 0 && typeof input.keepsSensitive !== "boolean") throw new Error("keepsSensitive is true, false or absent");
2432
2492
  if (input.accessRevision !== void 0 && typeof input.accessRevision !== "string") throw new Error("accessRevision names an answer of access, or is absent");
@@ -2473,6 +2533,16 @@ var VaultService = class {
2473
2533
  };
2474
2534
  if (viaOk.includes(false)) return this.#refuseVersions(principal, "bad_claim", items.map((item) => entry(item.secret, "deny", "bad_claim")), { asked: input.accessRevision });
2475
2535
  if (throughOk.includes(false)) return this.#refuseVersions(principal, "bad_claim", items.map((item) => entry(item.secret, "deny", "bad_claim")), { asked: input.accessRevision });
2536
+ if (use !== null && (use.environmentId !== null || use.projectId !== null)) {
2537
+ if (!items.some((item, i) => {
2538
+ const fork = throughs[i] === null ? void 0 : forks.get(throughs[i]);
2539
+ const place = fork === void 0 ? sealed[i]?.holder ?? item.secret : {
2540
+ projectId: fork.projectId,
2541
+ environmentId: throughs[i]
2542
+ };
2543
+ return (use.environmentId === null || place.environmentId === use.environmentId) && (use.projectId === null || place.projectId === use.projectId);
2544
+ })) return this.#refuseVersions(principal, "bad_claim", items.map((item) => entry(item.secret, "deny", "bad_claim")), { asked: input.accessRevision });
2545
+ }
2476
2546
  const ends = sealed.flatMap((seal) => seal === null ? [] : [seal.seq]);
2477
2547
  const remote = items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
2478
2548
  return this.#keys({
@@ -2486,7 +2556,8 @@ var VaultService = class {
2486
2556
  vet: ends.length === 0 ? void 0 : async (_, reads) => sealed.map((seal) => seal !== null && reads.ended.has(seal.seq) ? "ended" : null),
2487
2557
  remote,
2488
2558
  entry,
2489
- input
2559
+ input,
2560
+ ...use === null ? {} : { also: [use] }
2490
2561
  }, (contextOf) => items.map(({ secret, wrapped }) => async (operation) => {
2491
2562
  const key = await this.#open(wrapped, contextOf(secret), operation);
2492
2563
  return key && {
@@ -2501,7 +2572,8 @@ var VaultService = class {
2501
2572
  key.fill(0);
2502
2573
  }
2503
2574
  }),
2504
- ...input.accessRevision === void 0 ? {} : { accessRevision: input.accessRevision }
2575
+ ...input.accessRevision === void 0 ? {} : { accessRevision: input.accessRevision },
2576
+ ...use === null ? {} : { used: true }
2505
2577
  }));
2506
2578
  }
2507
2579
  async wrap(input) {
@@ -2682,9 +2754,13 @@ var VaultService = class {
2682
2754
  async #keys(call, operations, result) {
2683
2755
  const { action, principal, secrets, entry } = call;
2684
2756
  for (const secret of secrets) entry(secret, "allow", null);
2685
- const wrongKek = await this.#kekMismatch();
2757
+ const deadline = Date.now() + READ_DEADLINE_MS + (call.remote ? this.#prepared.options.keyBudgetMs : 0);
2758
+ const wrongKek = await this.#kekMismatch(deadline);
2686
2759
  if (wrongKek !== null) return this.#decide([], async () => {
2687
2760
  throw new Refused(refusal("wrong_kek", wrongKek), secrets.map((secret) => entry(secret, "deny", "wrong_kek")));
2761
+ }, {
2762
+ reads: true,
2763
+ deadline
2688
2764
  });
2689
2765
  const owners = /* @__PURE__ */ new Map();
2690
2766
  const check = async (d) => {
@@ -2773,7 +2849,8 @@ var VaultService = class {
2773
2849
  }], [], d.reads)).seqStart };
2774
2850
  }, {
2775
2851
  asked: reads,
2776
- reads: true
2852
+ reads: true,
2853
+ deadline
2777
2854
  });
2778
2855
  if (!intent.ok) return intent;
2779
2856
  intentSeq = intent.seq;
@@ -2796,7 +2873,7 @@ var VaultService = class {
2796
2873
  if (first === void 0 || first === null) return;
2797
2874
  throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, item) => outcomeEntry(secret, item, "deny", codes[item] ?? first)));
2798
2875
  });
2799
- const { outcomes, expired } = await settle(operations((secret) => context(secret, owners.get(secret.environmentId))), this.#prepared.options.keyBudgetMs);
2876
+ const { outcomes, expired } = await settle(operations((secret) => context(secret, owners.get(secret.environmentId))), within(deadline, this.#prepared.options.keyBudgetMs));
2800
2877
  const done = outcomes.flatMap((outcome) => outcome.ok ? [outcome.value] : []);
2801
2878
  try {
2802
2879
  const entries = () => secrets.map((secret, i) => outcomeEntry(secret, i, "deny", outcomes[i].ok ? "withheld" : outcomes[i].code));
@@ -2807,13 +2884,13 @@ var VaultService = class {
2807
2884
  if (expired) throw new Outage(new KekUnavailableError("key operation exceeded its deadline"), entries());
2808
2885
  if (done.length < outcomes.length) throw new Refused(refusal("bad_claim", MESSAGES.bad_claim), entries());
2809
2886
  if (late) {
2810
- d.reads = await this.#snapshot(d.tx, [principal], reads, true);
2887
+ d.reads = await this.#snapshot(d.tx, [principal], reads, true, true);
2811
2888
  d.at = this.#at(d.reads);
2812
2889
  await decided(d);
2813
2890
  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")));
2814
2891
  }
2815
2892
  const released = secrets.map((secret, i) => outcomeEntry(secret, i, "allow", null));
2816
- d.log.push(...released);
2893
+ d.log.push(...released, ...call.also ?? []);
2817
2894
  const answer = result(done);
2818
2895
  if (action !== "secret.read") d.after.push((seqOf) => Object.assign(answer, { seqs: released.map(seqOf) }));
2819
2896
  return answer;
@@ -2823,7 +2900,8 @@ var VaultService = class {
2823
2900
  }, {
2824
2901
  late,
2825
2902
  asked: reads,
2826
- reads: true
2903
+ reads: true,
2904
+ deadline
2827
2905
  });
2828
2906
  }
2829
2907
  /**
@@ -3851,10 +3929,10 @@ var VaultService = class {
3851
3929
  * service that cannot answer leaves it undecided: the error is thrown,
3852
3930
  * and the next call asks again.
3853
3931
  */
3854
- async #kekMismatch() {
3932
+ async #kekMismatch(deadline) {
3855
3933
  const prepared = this.#prepared;
3856
3934
  if (!prepared.kekChecked) {
3857
- const wrong = await this.#checkKeks();
3935
+ const wrong = await this.#checkKeks(deadline);
3858
3936
  if (!prepared.kekChecked) {
3859
3937
  prepared.wrongKek = wrong;
3860
3938
  prepared.kekChecked = true;
@@ -3868,15 +3946,16 @@ var VaultService = class {
3868
3946
  * keys it wrapped, if there are any; then its check value is recorded.
3869
3947
  * A KEK that opens neither is not the one that wrapped the data.
3870
3948
  */
3871
- async #checkKeks() {
3949
+ async #checkKeks(deadline) {
3872
3950
  const superseded = await this.#settled();
3873
3951
  if (superseded !== null) return superseded;
3874
3952
  const checks = await this.#checkValues(this.#db);
3875
3953
  const budget = this.#prepared.options.keyBudgetMs;
3876
3954
  for (const kek of this.#config.keks.all) {
3955
+ const spend = within(deadline, budget);
3877
3956
  const operation = {
3878
- deadline: Date.now() + budget,
3879
- signal: AbortSignal.timeout(budget)
3957
+ deadline: Date.now() + spend,
3958
+ signal: AbortSignal.timeout(spend)
3880
3959
  };
3881
3960
  const opens = async (wrapped, context, expected) => {
3882
3961
  try {
@@ -3911,6 +3990,7 @@ var VaultService = class {
3911
3990
  if (samples.length > 0 && proof === null) return mismatch;
3912
3991
  const wrapped = await kek.wrap(Buffer.from(KEY_CHECK_VALUE), KEY_CHECK_CONTEXT, operation);
3913
3992
  await this.#db.transaction(async (tx) => {
3993
+ await boundLockWaits(tx, within(deadline, READ_LOCK_TIMEOUT_MS), true);
3914
3994
  await lockLogHead(tx);
3915
3995
  if ((await this.#checkValues(tx)).has(`${kek.provider}:${kek.keyId}`)) return;
3916
3996
  await this.#append(tx, [{
@@ -4086,7 +4166,7 @@ var VaultService = class {
4086
4166
  if (!isUnavailable(error)) throw error;
4087
4167
  stalled = `the rehash of the past did not run, and goes on where it was next time: ${error.message}`;
4088
4168
  }
4089
- return this.#decide([], async (d) => {
4169
+ const decided = await this.#decide([], async (d) => {
4090
4170
  for (const entry of found) if (!d.reports.includes(entry)) d.reports.push(entry);
4091
4171
  const head = await lockLogHead(d.tx);
4092
4172
  const latest = await latestVaultEntry(d.tx, [CHECKPOINT]);
@@ -4186,6 +4266,13 @@ var VaultService = class {
4186
4266
  warning: stalled
4187
4267
  };
4188
4268
  });
4269
+ if (!decided.ok) return decided;
4270
+ const pruned = await pruneReleases(this.#db, RELEASES_PRUNED).then(() => null, (error) => `the releases the bulk limit counts were not pruned, and the next checkpoint tries again: ${error.message}`);
4271
+ if (pruned === null) return decided;
4272
+ return {
4273
+ ...decided,
4274
+ warning: decided.warning === void 0 ? pruned : `${decided.warning}; ${pruned}`
4275
+ };
4189
4276
  }
4190
4277
  /**
4191
4278
  * The walk on from where a checkpoint resumes, no further than `limit`
@@ -4659,6 +4746,39 @@ function validateText(value) {
4659
4746
  if (typeof value !== "string" || /[\uD800-\uDFFF]/u.test(value)) throw new Error("key request strings must be well-formed Unicode");
4660
4747
  }
4661
4748
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
4749
+ /**
4750
+ * The app's `credential.use` entry, as the vault appends it beside a read
4751
+ * (`UnwrapInput.use`): what the app wrote, checked to be what an entry
4752
+ * holds, in its reader's name. The app wrote it under the head itself
4753
+ * before 0.14.5; it describes the read, and decides nothing.
4754
+ */
4755
+ function useEntry(principal, correlation, use) {
4756
+ const id = (value, name) => {
4757
+ if (value !== null && (typeof value !== "string" || !UUID.test(value))) throw new Error(`a use's ${name} must be a lowercase UUID or null`);
4758
+ return value;
4759
+ };
4760
+ if (typeof use.metadata !== "string" || use.metadata.length > USE_METADATA_LENGTH) throw new Error(`a use's metadata must be a JSON object of at most ${USE_METADATA_LENGTH} characters`);
4761
+ if (use.sourceIp !== null) validateText(use.sourceIp);
4762
+ validateText(use.metadata);
4763
+ let metadata;
4764
+ try {
4765
+ metadata = JSON.parse(use.metadata);
4766
+ } catch {
4767
+ metadata = void 0;
4768
+ }
4769
+ if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata)) throw new Error(`a use's metadata must be a JSON object of at most ${USE_METADATA_LENGTH} characters`);
4770
+ return {
4771
+ actor: principal,
4772
+ action: "credential.use",
4773
+ decision: "allow",
4774
+ projectId: id(use.projectId, "projectId"),
4775
+ environmentId: id(use.environmentId, "environmentId"),
4776
+ operationId: correlation.operationId ?? null,
4777
+ requestId: correlation.requestId ?? null,
4778
+ sourceIp: use.sourceIp,
4779
+ metadata: use.metadata
4780
+ };
4781
+ }
4662
4782
  /** The caller's ids go into the log as they are: a request id is text, an operation id a lowercase UUID. */
4663
4783
  function validateCorrelation(input) {
4664
4784
  if (input.requestId != null) validateText(input.requestId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffre/vault",
3
- "version": "0.14.4",
3
+ "version": "0.14.5",
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.4",
40
- "@coffre/db": "0.14.4"
39
+ "@coffre/core": "0.14.5",
40
+ "@coffre/db": "0.14.5"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "26.1.1",
package/src/config.ts CHANGED
@@ -11,6 +11,14 @@ export type BulkLimit = { count: number; windowMs: number };
11
11
  */
12
12
  export const DEFAULT_BULK_LIMIT: BulkLimit = { count: 1000, windowMs: 15 * 60_000 };
13
13
 
14
+ /**
15
+ * The longest bulk limit window: a day. The releases a window counts are
16
+ * kept a day and an hour (migration 0021's `prune_vault_releases`, and on
17
+ * SQLite `RELEASES_KEPT_MS`), so every window up to it, and one widened
18
+ * since, counts exactly.
19
+ */
20
+ export const MAX_BULK_WINDOW_MINUTES = 24 * 60;
21
+
14
22
  /** The vault as it runs: keys decoded, emails checked. */
15
23
  export type ResolvedVaultConfig = {
16
24
  keks: KekRegistry;
@@ -61,7 +69,10 @@ export type VaultConfig = {
61
69
  * service holds the KEK, as AWS KMS does: the vault never sees that key.
62
70
  */
63
71
  signingKey?: string;
64
- /** At most `count` distinct data keys unwrapped per principal in any `windowMinutes`; 1000 in 15 unless set. */
72
+ /**
73
+ * At most `count` distinct data keys unwrapped per principal in any `windowMinutes`; 1000 in 15 unless set. The
74
+ * window is a day at most: the releases it counts are kept a day and an hour (`prune_vault_releases`).
75
+ */
65
76
  bulkLimit?: { count: number; windowMinutes: number };
66
77
  /**
67
78
  * @deprecated Gone in 0.10, and refused: each key says who sees it now. Mark
@@ -176,8 +187,8 @@ function signingKeys(given: string | undefined, keks: { provider: KekProvider; s
176
187
 
177
188
 
178
189
  function checkBulkLimit({ count, windowMinutes }: { count: number; windowMinutes: number }): BulkLimit {
179
- if (!Number.isInteger(count) || count < 1 || !(windowMinutes > 0)) {
180
- throw new Error('bulkLimit needs a whole count of at least 1 and a window above 0 minutes');
190
+ if (!Number.isInteger(count) || count < 1 || !(windowMinutes > 0) || windowMinutes > MAX_BULK_WINDOW_MINUTES) {
191
+ throw new Error(`bulkLimit needs a whole count of at least 1 and a window above 0 minutes, a day (${MAX_BULK_WINDOW_MINUTES}) at most`);
181
192
  }
182
193
  return { count, windowMs: windowMinutes * 60_000 };
183
194
  }
package/src/snapshot.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { StoredEntry } from '@coffre/core/audit';
2
2
  import type { Queryable, Transaction } from '@coffre/db';
3
- import { LogHeadMismatch, lockLogHead } from '@coffre/db/log';
3
+ import { LogHeadMismatch, lockLogHead, lockLogHeadQueued } from '@coffre/db/log';
4
4
  import { engineOf, rowsOf } from '@coffre/db/dialect';
5
5
  import type { GrantRow } from '@coffre/db/grants';
6
6
 
@@ -83,6 +83,12 @@ export type Asked = {
83
83
  released?: { principal: string; keys: readonly string[]; windowMs: number; clockOffsetMs: number; cap: number };
84
84
  /** Lock the log's head first. */
85
85
  head?: boolean;
86
+ /**
87
+ * Held by another, wait for the head only in one of this many places in
88
+ * line, and refuse at once when none is free (`lockLogHeadQueued`); with
89
+ * none, wait as long as the transaction's bound lets.
90
+ */
91
+ waiters?: number;
86
92
  };
87
93
 
88
94
  /** One statement's read, as `Snapshot` says; with the head, two more when another holds it. */
@@ -94,7 +100,7 @@ export async function snapshot(db: Queryable, asked: Asked): Promise<Snapshot> {
94
100
  if (asked.head !== true || (first.head !== null && current(first.head))) return first;
95
101
  // Held by another, or moved in the instant before the lock: everything again, once the head is this transaction's.
96
102
  // Asked for the head, `db` is a transaction (the overloads above).
97
- if (first.head === null) await lockLogHead(db as Transaction);
103
+ if (first.head === null) await (asked.waiters === undefined ? lockLogHead(db as Transaction) : lockLogHeadQueued(db as Transaction, asked.waiters));
98
104
  const again = await read();
99
105
  if (again.head === null) throw new Error("the log's head, held by this transaction, was read as held by another");
100
106
  // Held, nothing appends: a head that names another last entry was moved, or the log written, around it (`appendEntries`).
package/src/store.ts CHANGED
@@ -2,9 +2,11 @@ import { EVERYWHERE, ownsInstance, type InstanceRole, type Scope } from '@coffre
2
2
  import type { Author, StoredEntry } from '@coffre/core/audit';
3
3
  import { ACCESS_ACTIONS, type SecretRef, type WrappedKey } from '@coffre/core/vault';
4
4
  import { prepared, tablesOf, type Queryable, type Transaction } from '@coffre/db';
5
- import { clockMillis, engineOf, forUpdate, tombstone, truth } from '@coffre/db/dialect';
5
+ import { changedRows, clockMillis, engineOf, forUpdate, rowsOf, tombstone, truth } from '@coffre/db/dialect';
6
6
  import { readGrants, roleColumns, storedRole, type GrantRow } from '@coffre/db/grants';
7
7
  import { entryColumns, withinBytes } from '@coffre/db/log';
8
+
9
+ import { MAX_BULK_WINDOW_MINUTES } from './config.ts';
8
10
  import { and, asc, count, desc, eq, gt, gte, inArray, isNotNull, isNull, like, lt, lte, or, sql, type SQL, type SQLWrapper } from 'drizzle-orm';
9
11
 
10
12
  /**
@@ -67,6 +69,33 @@ export async function boundLockWaits(tx: Transaction, ms: number, statements = f
67
69
  : sql.raw(`SET LOCAL lock_timeout = ${bound}`));
68
70
  }
69
71
 
72
+ /**
73
+ * How long a release is kept: the longest bulk limit window
74
+ * (`MAX_BULK_WINDOW_MINUTES`, a day), and an hour, a margin for the
75
+ * database's clock stepping back between a prune and a later count, which
76
+ * is seconds. Migration 0021's `prune_vault_releases` holds the same, a day
77
+ * and an hour, in its own SQL: a longer window takes a migration too.
78
+ */
79
+ export const RELEASES_KEPT_MS = MAX_BULK_WINDOW_MINUTES * 60_000 + 3_600_000;
80
+
81
+ /**
82
+ * Delete up to `atMost` rows of `vault_releases` released over
83
+ * `RELEASES_KEPT_MS` before the database's clock: what no bulk count can
84
+ * read again. On Postgres the vault's login deletes no row itself, and asks
85
+ * `prune_vault_releases`, which deletes only those (migration 0021). How
86
+ * many went.
87
+ */
88
+ export async function pruneReleases(db: Queryable, atMost: number): Promise<number> {
89
+ if (engineOf(db) === 'postgres') {
90
+ const [row] = await rowsOf<{ pruned: number }>(db, sql`SELECT prune_vault_releases(${atMost}::int) AS pruned`);
91
+ return row!.pruned;
92
+ }
93
+ const { vaultReleases } = tablesOf(db);
94
+ const old = db.select({ principal: vaultReleases.principal, key: vaultReleases.key }).from(vaultReleases)
95
+ .where(lt(vaultReleases.releasedAt, sql`${clockMillis(db)} - ${RELEASES_KEPT_MS}`)).limit(atMost);
96
+ return changedRows(await db.delete(vaultReleases).where(sql`(${vaultReleases.principal}, ${vaultReleases.key}) IN ${old}`));
97
+ }
98
+
70
99
  /** The database's clock, now. */
71
100
  export async function now(db: Queryable): Promise<number> {
72
101
  const [{ at }] = await clock(db);
package/src/vault.ts CHANGED
@@ -91,6 +91,9 @@ import {
91
91
  type Settings,
92
92
  type Through,
93
93
  type UnwrapInput,
94
+ USE_METADATA_LENGTH,
95
+ type Unwrapped,
96
+ type UseRecord,
94
97
  type Wrapped,
95
98
  type Vault,
96
99
  type Via,
@@ -101,7 +104,7 @@ import {
101
104
  import type { Database, Queryable, Transaction } from '@coffre/db';
102
105
  import { isUnavailable, isUniqueViolation, SNAPSHOT } from '@coffre/db/dialect';
103
106
  import { everyProjectConversion, newestAccess, reportStands } from '@coffre/db/grants';
104
- import { appendEntries, lockLogHead, missingEntry, newestAuthentic, type Appended, type Authenticity, type Newest, type NewEntry } from '@coffre/db/log';
107
+ import { appendEntries, lockLogHead, lockLogHeadQueued, missingEntry, newestAuthentic, type Appended, type Authenticity, type Newest, type NewEntry } from '@coffre/db/log';
105
108
 
106
109
  import { signer, type Signer } from './checkpoint.ts';
107
110
  import { keyRefusals, placeIn, referenceRefusal, refuses, sensitiveRefusals, useOnlyRefusals, type SecretPlace } from './decide.ts';
@@ -212,6 +215,46 @@ const LOCK_TIMEOUT_MS = 15_000;
212
215
  */
213
216
  const READ_LOCK_TIMEOUT_MS = 3_000;
214
217
 
218
+ /**
219
+ * How long a whole read call may take, from its start to its answer, its
220
+ * refusal's entries, the record of a vault key's check and, with a key
221
+ * service, its intent and its own budget (added) included: each transaction's waits are bounded by what is left of it, and
222
+ * past it the call fails as unavailable (`Overdue`) rather than start
223
+ * another. So a caller hears within it, ok or 503, never tens of seconds later.
224
+ */
225
+ export const READ_DEADLINE_MS = 5_000;
226
+
227
+ /**
228
+ * How many reads may wait for the log's head at once, through all of the
229
+ * vault's connections; the next is refused at once, as unavailable, holding
230
+ * no connection while it waits (`lockLogHeadQueued`). A read holds the head
231
+ * for a few round trips, about 150 ms through Hyperdrive, so this many
232
+ * clear within a read's bound (2.4 s of its 3), and no more than this many
233
+ * of the pool's connections wait. A burst of 50 reads at a 2-vCPU database
234
+ * a 30 ms round trip away: 17 read within 2.2 s, 33 refused at once.
235
+ */
236
+ export const READ_HEAD_WAITERS = 16;
237
+
238
+ /** How many releases a checkpoint prunes at most: one short statement, a few each scheduled run. */
239
+ const RELEASES_PRUNED = 10_000;
240
+
241
+ /** A read's call past its deadline (`READ_DEADLINE_MS`): unavailable, as a statement past its bound (57014) is. */
242
+ class Overdue extends Error {
243
+ readonly code = '57014';
244
+ constructor() {
245
+ super('this read took longer than it may: ask again shortly');
246
+ this.name = 'Overdue';
247
+ }
248
+ }
249
+
250
+ /** The bound of a transaction of a call due at `deadline`: `ms`, or what is left of the call, if less. */
251
+ function within(deadline: number | undefined, ms: number): number {
252
+ if (deadline === undefined) return ms;
253
+ const left = deadline - Date.now();
254
+ if (left <= 0) throw new Overdue();
255
+ return Math.min(ms, left);
256
+ }
257
+
215
258
  /** A member: `user:<email>` or `token:<name>`. */
216
259
  const PRINCIPAL = /^(user|token):[^\s:][^\s]*$/;
217
260
 
@@ -406,7 +449,12 @@ type Reads = Pick<Asked, 'environments' | 'references'> & { released?: { princip
406
449
  * is what it reads besides its members, and `reads` says it changes none
407
450
  * of them (`#decide`).
408
451
  */
409
- type How = { late?: boolean; asked?: Reads; reads?: boolean };
452
+ /**
453
+ * How a decision is made. `deadline`, a read's (`reads`): when its whole
454
+ * call must have answered, every wait in every transaction it takes
455
+ * included (`READ_DEADLINE_MS`).
456
+ */
457
+ type How = { late?: boolean; asked?: Reads; reads?: boolean; deadline?: number };
410
458
 
411
459
  /** The actions of the entries the vault writes about keys. */
412
460
  type KeyAction = 'secret.read' | 'key.wrap' | 'key.rewrap';
@@ -492,12 +540,14 @@ class VaultService implements Vault {
492
540
  }
493
541
 
494
542
  /** What a decision about `principals` reads, and `asked` besides, in one statement: under the head when `head` is set. */
495
- #snapshot(tx: Transaction, principals: readonly string[], asked: Reads, head: boolean): Promise<Snapshot> {
543
+ #snapshot(tx: Transaction, principals: readonly string[], asked: Reads, head: boolean, reading = false): Promise<Snapshot> {
496
544
  const { released, ...rest } = asked;
497
545
  return snapshot(tx, {
498
546
  ...rest,
499
547
  principals,
500
548
  head,
549
+ // A read waits for the head in one of a few places in line, or is refused at once.
550
+ ...(reading ? { waiters: READ_HEAD_WAITERS } : {}),
501
551
  ...(released === undefined ? {} : { released: { ...released, windowMs: this.#config.bulkLimit.windowMs, clockOffsetMs: this.#prepared.options.clockOffset(), cap: this.#config.bulkLimit.count } }),
502
552
  });
503
553
  }
@@ -525,9 +575,11 @@ class VaultService implements Vault {
525
575
  /** `#decide`, once the vault is settled. */
526
576
  async #decideNow<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, how: How = {}): Promise<Outcome<T>> {
527
577
  const reports: NewEntry[] = [];
578
+ // A read's call is due from its first decision, unless its caller set when.
579
+ const due = how.reads === true ? { ...how, deadline: how.deadline ?? Date.now() + READ_DEADLINE_MS } : how;
528
580
  for (let attempt = 1; ; attempt += 1) {
529
581
  try {
530
- return await this.#decideOnce(principals, decide, reports, how);
582
+ return await this.#decideOnce(principals, decide, reports, due);
531
583
  } catch (error) {
532
584
  // A member who had no row when this decision locked theirs, and has
533
585
  // one now: another decision admitted them meanwhile. Again, with it.
@@ -537,13 +589,13 @@ class VaultService implements Vault {
537
589
  }
538
590
  }
539
591
 
540
- async #decideOnce<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, reports: NewEntry[], { late = false, asked = {}, reads: shared = false }: How): Promise<Outcome<T>> {
592
+ async #decideOnce<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, reports: NewEntry[], { late = false, asked = {}, reads: shared = false, deadline }: How): Promise<Outcome<T>> {
541
593
  try {
542
594
  const result = await this.#db.transaction(async (tx) => {
543
- await store.boundLockWaits(tx, shared ? READ_LOCK_TIMEOUT_MS : LOCK_TIMEOUT_MS, shared);
595
+ await store.boundLockWaits(tx, shared ? within(deadline, READ_LOCK_TIMEOUT_MS) : LOCK_TIMEOUT_MS, shared);
544
596
  const members = principals.length === 0 ? new Map<string, Member>() : await store.lockMembers(tx, principals, shared);
545
597
  // Then the head, and everything the decision reads, in one statement (`snapshot` says why it holds).
546
- const reads = await this.#snapshot(tx, principals, asked, !late);
598
+ const reads = await this.#snapshot(tx, principals, asked, !late, shared);
547
599
  const d: Decision = {
548
600
  tx, members, at: this.#at(reads), reads, log: [], writes: [], touched: new Set(), grants: new Map(), reports, after: [], underLock: [],
549
601
  };
@@ -577,9 +629,12 @@ class VaultService implements Vault {
577
629
  if (!(error instanceof Refused || error instanceof Outage)) throw error;
578
630
  const entries = [...reports, ...readsByPlace(error.entries)];
579
631
  if (entries.length > 0) {
580
- // A refusal's entries are written whatever the decision's bound: it answers only once they are.
632
+ // A refusal's entries are written whatever the decision's bound: it answers only once they are. A read's, within
633
+ // its call's deadline: past it, the call fails as unavailable, having released nothing.
581
634
  await this.#db.transaction(async (tx) => {
582
- await store.boundLockWaits(tx, LOCK_TIMEOUT_MS);
635
+ await store.boundLockWaits(tx, shared ? within(deadline, READ_LOCK_TIMEOUT_MS) : LOCK_TIMEOUT_MS, shared);
636
+ // In one of the head's places in line, as the read's decision waited, or refused at once.
637
+ if (shared) await lockLogHeadQueued(tx, READ_HEAD_WAITERS);
583
638
  await this.#append(tx, entries);
584
639
  });
585
640
  }
@@ -928,8 +983,9 @@ class VaultService implements Vault {
928
983
 
929
984
  // Raw DEKs are cleared on every path. JSON and base64 leave strings that
930
985
  // cannot be wiped, so this is best-effort memory hygiene.
931
- async unwrap(input: UnwrapInput): Promise<Outcome<{ keys: string[]; accessRevision?: string }>> {
986
+ async unwrap(input: UnwrapInput): Promise<Outcome<Unwrapped>> {
932
987
  const { principal } = input;
988
+ const use = input.use === undefined ? null : useEntry(principal, input, input.use);
933
989
  validateText(input.purpose);
934
990
  if (input.keepsSensitive !== undefined && typeof input.keepsSensitive !== 'boolean') throw new Error('keepsSensitive is true, false or absent');
935
991
  if (input.accessRevision !== undefined && typeof input.accessRevision !== 'string') throw new Error('accessRevision names an answer of access, or is absent');
@@ -975,6 +1031,17 @@ class VaultService implements Vault {
975
1031
  if (throughOk.includes(false)) {
976
1032
  return this.#refuseVersions(principal, 'bad_claim', items.map((item) => entry(item.secret, 'deny', 'bad_claim')), { asked: input.accessRevision });
977
1033
  }
1034
+ // The app's record of a use names where the read was, which the vault vouches for once it appends it: the place an
1035
+ // item was read at, the fork it is read in, else a reference's holder, else its own, with its project. Another is
1036
+ // a claim the read does not bear out, refused and logged as one; no use is appended.
1037
+ if (use !== null && (use.environmentId !== null || use.projectId !== null)) {
1038
+ const named = items.some((item, i) => {
1039
+ const fork = throughs[i] === null ? undefined : forks.get(throughs[i]!);
1040
+ const place = fork === undefined ? sealed[i]?.holder ?? item.secret : { projectId: fork.projectId, environmentId: throughs[i]! };
1041
+ return (use.environmentId === null || place.environmentId === use.environmentId) && (use.projectId === null || place.projectId === use.projectId);
1042
+ });
1043
+ if (!named) return this.#refuseVersions(principal, 'bad_claim', items.map((item) => entry(item.secret, 'deny', 'bad_claim')), { asked: input.accessRevision });
1044
+ }
978
1045
  const ends = sealed.flatMap((seal) => (seal === null ? [] : [seal.seq]));
979
1046
  const remote = items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
980
1047
  return this.#keys(
@@ -993,6 +1060,7 @@ class VaultService implements Vault {
993
1060
  remote,
994
1061
  entry,
995
1062
  input,
1063
+ ...(use === null ? {} : { also: [use] }),
996
1064
  },
997
1065
  (contextOf) =>
998
1066
  items.map(({ secret, wrapped }) => async (operation: KeyOperation) => {
@@ -1009,6 +1077,8 @@ class VaultService implements Vault {
1009
1077
  }),
1010
1078
  // Named back: the read was decided on that answer, which this vault checked.
1011
1079
  ...(input.accessRevision === undefined ? {} : { accessRevision: input.accessRevision }),
1080
+ // And the use, appended with the read: the app has nothing left to append.
1081
+ ...(use === null ? {} : { used: true as const }),
1012
1082
  }),
1013
1083
  );
1014
1084
  }
@@ -1224,6 +1294,8 @@ class VaultService implements Vault {
1224
1294
  viaReference?: readonly boolean[];
1225
1295
  remote: boolean;
1226
1296
  entry: (secret: SecretRef, decision: 'allow' | 'deny', code: string | null) => NewEntry;
1297
+ /** Entries appended with the release, after its own, only once the keys are released: the app's record of a use. */
1298
+ also?: readonly NewEntry[];
1227
1299
  input: Correlation & Through & { purpose?: string; keepsSensitive?: boolean; accessRevision?: string };
1228
1300
  },
1229
1301
  /**
@@ -1236,11 +1308,14 @@ class VaultService implements Vault {
1236
1308
  ): Promise<Outcome<R>> {
1237
1309
  const { action, principal, secrets, entry } = call;
1238
1310
  for (const secret of secrets) entry(secret, 'allow', null);
1239
- const wrongKek = await this.#kekMismatch();
1311
+ // One deadline for the call: the record of the KEK's check, the decisions, the intent's and the keys', and, with a
1312
+ // key service, its own budget besides, spent from it too.
1313
+ const deadline = Date.now() + READ_DEADLINE_MS + (call.remote ? this.#prepared.options.keyBudgetMs : 0);
1314
+ const wrongKek = await this.#kekMismatch(deadline);
1240
1315
  if (wrongKek !== null) {
1241
1316
  return this.#decide([], async () => {
1242
1317
  throw new Refused(refusal('wrong_kek', wrongKek), secrets.map((secret) => entry(secret, 'deny', 'wrong_kek')));
1243
- });
1318
+ }, { reads: true, deadline });
1244
1319
  }
1245
1320
  // Each personal config's owner, hashed, by environment, as the decision read them: what its keys' contexts bind.
1246
1321
  const owners = new Map<string, string>();
@@ -1319,7 +1394,7 @@ class VaultService implements Vault {
1319
1394
  }),
1320
1395
  }], [], d.reads);
1321
1396
  return { seq: appended.seqStart };
1322
- }, { asked: reads, reads: true });
1397
+ }, { asked: reads, reads: true, deadline });
1323
1398
  if (!intent.ok) return intent;
1324
1399
  intentSeq = intent.seq;
1325
1400
  }
@@ -1355,7 +1430,7 @@ class VaultService implements Vault {
1355
1430
  throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, item) => outcomeEntry(secret, item, 'deny', codes[item] ?? first)));
1356
1431
  });
1357
1432
  }
1358
- const { outcomes, expired } = await settle(operations((secret) => context(secret, owners.get(secret.environmentId))), this.#prepared.options.keyBudgetMs);
1433
+ const { outcomes, expired } = await settle(operations((secret) => context(secret, owners.get(secret.environmentId))), within(deadline, this.#prepared.options.keyBudgetMs));
1359
1434
  const done = outcomes.flatMap((outcome) => (outcome.ok ? [outcome.value] : []));
1360
1435
  try {
1361
1436
  const entries = () => secrets.map((secret, i) =>
@@ -1381,7 +1456,7 @@ class VaultService implements Vault {
1381
1456
  // A read through a reference: its holder's place too, which a deletion may have taken.
1382
1457
  // And as the policy is now, under the head every change to it takes: an environment protected, or a role narrowed,
1383
1458
  // since the check above is seen here, and nothing it refuses now is released. All of it read again, holding the head.
1384
- d.reads = await this.#snapshot(d.tx, [principal], reads, true);
1459
+ d.reads = await this.#snapshot(d.tx, [principal], reads, true, true);
1385
1460
  d.at = this.#at(d.reads);
1386
1461
  await decided(d);
1387
1462
  const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
@@ -1390,7 +1465,7 @@ class VaultService implements Vault {
1390
1465
  }
1391
1466
  }
1392
1467
  const released = secrets.map((secret, i) => outcomeEntry(secret, i, 'allow', null));
1393
- d.log.push(...released);
1468
+ d.log.push(...released, ...(call.also ?? []));
1394
1469
  const answer = result(done);
1395
1470
  // A wrap's answer names its entries, which the app's writes refer to.
1396
1471
  if (action !== 'secret.read') d.after.push((seqOf) => Object.assign(answer as object, { seqs: released.map(seqOf) }));
@@ -1398,7 +1473,7 @@ class VaultService implements Vault {
1398
1473
  } finally {
1399
1474
  for (const value of done) value.wipe();
1400
1475
  }
1401
- }, { late, asked: reads, reads: true });
1476
+ }, { late, asked: reads, reads: true, deadline });
1402
1477
  }
1403
1478
 
1404
1479
  /**
@@ -2404,10 +2479,10 @@ class VaultService implements Vault {
2404
2479
  * service that cannot answer leaves it undecided: the error is thrown,
2405
2480
  * and the next call asks again.
2406
2481
  */
2407
- async #kekMismatch(): Promise<string | null> {
2482
+ async #kekMismatch(deadline?: number): Promise<string | null> {
2408
2483
  const prepared = this.#prepared;
2409
2484
  if (!prepared.kekChecked) {
2410
- const wrong = await this.#checkKeks();
2485
+ const wrong = await this.#checkKeks(deadline);
2411
2486
  if (!prepared.kekChecked) {
2412
2487
  prepared.wrongKek = wrong;
2413
2488
  prepared.kekChecked = true;
@@ -2422,14 +2497,16 @@ class VaultService implements Vault {
2422
2497
  * keys it wrapped, if there are any; then its check value is recorded.
2423
2498
  * A KEK that opens neither is not the one that wrapped the data.
2424
2499
  */
2425
- async #checkKeks(): Promise<string | null> {
2500
+ async #checkKeks(deadline?: number): Promise<string | null> {
2426
2501
  // A vault that may not write cannot record a check either; why it may not is the answer.
2427
2502
  const superseded = await this.#settled();
2428
2503
  if (superseded !== null) return superseded;
2429
2504
  const checks = await this.#checkValues(this.#db);
2430
2505
  const budget = this.#prepared.options.keyBudgetMs;
2431
2506
  for (const kek of this.#config.keks.all) {
2432
- const operation = { deadline: Date.now() + budget, signal: AbortSignal.timeout(budget) };
2507
+ // A read's, within what is left of its deadline.
2508
+ const spend = within(deadline, budget);
2509
+ const operation = { deadline: Date.now() + spend, signal: AbortSignal.timeout(spend) };
2433
2510
  const opens = async (wrapped: WrappedDek, context: SecretContext, expected?: Buffer) => {
2434
2511
  try {
2435
2512
  const key = await kek.unwrap(wrapped, context, operation);
@@ -2460,6 +2537,8 @@ class VaultService implements Vault {
2460
2537
  // The key it opened, if any, is in the log, as every key the vault opens is. Another call that
2461
2538
  // checked at the same time may have recorded one first: under the log's lock, the first stays.
2462
2539
  await this.#db.transaction(async (tx) => {
2540
+ // Once a process, by its first key operation: a read's waits as the read's, within its deadline.
2541
+ await store.boundLockWaits(tx, within(deadline, READ_LOCK_TIMEOUT_MS), true);
2463
2542
  await lockLogHead(tx);
2464
2543
  if ((await this.#checkValues(tx)).has(`${kek.provider}:${kek.keyId}`)) return;
2465
2544
  await this.#append(tx, [
@@ -2607,7 +2686,7 @@ class VaultService implements Vault {
2607
2686
  stalled = `the rehash of the past did not run, and goes on where it was next time: ${(error as Error).message}`;
2608
2687
  }
2609
2688
  }
2610
- return this.#decide([], async (d) => {
2689
+ const decided = await this.#decide([], async (d) => {
2611
2690
  for (const entry of found) if (!d.reports.includes(entry)) d.reports.push(entry);
2612
2691
  // Checkpoints one at a time, each against the one before.
2613
2692
  const head = await lockLogHead(d.tx);
@@ -2683,6 +2762,15 @@ class VaultService implements Vault {
2683
2762
  d.log.push({ actor: SCHEDULER, action: CHECKPOINT, decision: 'allow', relatedSeq: at.seq, metadata: JSON.stringify(kept) });
2684
2763
  return stalled === null ? { checkpoint } : { checkpoint, warning: stalled };
2685
2764
  });
2765
+ // Then the bulk count's upkeep, which no read waits on, after a checkpoint signed, never instead of a refusal: the
2766
+ // releases no window can count again, kept a day and an hour (`RELEASES_KEPT_MS`), longer than any window, up to
2767
+ // `RELEASES_PRUNED` of them, in one statement of its own, holding no lock any read takes but the rows it deletes,
2768
+ // and none an append holds. What is kept is the database's to say, not the caller's: no login forgets a release a
2769
+ // window counts. A failure leaves it to the next checkpoint, and says so.
2770
+ if (!decided.ok) return decided;
2771
+ const pruned = await store.pruneReleases(this.#db, RELEASES_PRUNED).then(() => null, (error: Error) => `the releases the bulk limit counts were not pruned, and the next checkpoint tries again: ${error.message}`);
2772
+ if (pruned === null) return decided;
2773
+ return { ...decided, warning: decided.warning === undefined ? pruned : `${decided.warning}; ${pruned}` };
2686
2774
  }
2687
2775
 
2688
2776
  /**
@@ -3088,6 +3176,45 @@ function validateText(value: string): void {
3088
3176
 
3089
3177
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
3090
3178
 
3179
+ /**
3180
+ * The app's `credential.use` entry, as the vault appends it beside a read
3181
+ * (`UnwrapInput.use`): what the app wrote, checked to be what an entry
3182
+ * holds, in its reader's name. The app wrote it under the head itself
3183
+ * before 0.14.5; it describes the read, and decides nothing.
3184
+ */
3185
+ function useEntry(principal: string, correlation: Correlation, use: UseRecord): NewEntry {
3186
+ const id = (value: unknown, name: string) => {
3187
+ if (value !== null && (typeof value !== 'string' || !UUID.test(value))) throw new Error(`a use's ${name} must be a lowercase UUID or null`);
3188
+ return value;
3189
+ };
3190
+ // The bound first: nothing past it is read.
3191
+ if (typeof use.metadata !== 'string' || use.metadata.length > USE_METADATA_LENGTH) {
3192
+ throw new Error(`a use's metadata must be a JSON object of at most ${USE_METADATA_LENGTH} characters`);
3193
+ }
3194
+ if (use.sourceIp !== null) validateText(use.sourceIp);
3195
+ validateText(use.metadata);
3196
+ let metadata: unknown;
3197
+ try {
3198
+ metadata = JSON.parse(use.metadata);
3199
+ } catch {
3200
+ metadata = undefined;
3201
+ }
3202
+ if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) {
3203
+ throw new Error(`a use's metadata must be a JSON object of at most ${USE_METADATA_LENGTH} characters`);
3204
+ }
3205
+ return {
3206
+ actor: principal,
3207
+ action: 'credential.use',
3208
+ decision: 'allow',
3209
+ projectId: id(use.projectId, 'projectId'),
3210
+ environmentId: id(use.environmentId, 'environmentId'),
3211
+ operationId: correlation.operationId ?? null,
3212
+ requestId: correlation.requestId ?? null,
3213
+ sourceIp: use.sourceIp,
3214
+ metadata: use.metadata,
3215
+ };
3216
+ }
3217
+
3091
3218
  /** The caller's ids go into the log as they are: a request id is text, an operation id a lowercase UUID. */
3092
3219
  function validateCorrelation(input: Correlation): void {
3093
3220
  if (input.requestId != null) validateText(input.requestId);