@coffre/vault 0.14.2 → 0.14.3

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-B4IivgU8.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-B4IivgU8.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
  */
@@ -1782,6 +1811,14 @@ function openVault(db, prepared) {
1782
1811
  const KEY_BUDGET_MS = 5e3;
1783
1812
  /** How long a decision waits for a lock: above the key budget, so a removal outwaits a read in flight. */
1784
1813
  const LOCK_TIMEOUT_MS = 15e3;
1814
+ /**
1815
+ * How long a statement of a read's decision runs, a wait for a lock
1816
+ * included: a read waits only for what changes its reader, and for the
1817
+ * log's head. Longer is a queue, which holds the read's connection while it
1818
+ * waits; refused, it fails as unavailable at once, and its caller tries
1819
+ * again later.
1820
+ */
1821
+ const READ_LOCK_TIMEOUT_MS = 3e3;
1785
1822
  /** A member: `user:<email>` or `token:<name>`. */
1786
1823
  const PRINCIPAL = /^(user|token):[^\s:][^\s]*$/;
1787
1824
  /** A person, and a service account: only a person sets one up (`givesService`). */
@@ -1986,30 +2023,32 @@ var VaultService = class {
1986
2023
  * commits: no decision acts on a policy read before it. A key operation
1987
2024
  * alone takes the head later (`late`), once its key service has answered,
1988
2025
  * which it must not wait on holding it, and decides again then (`#keys`).
2026
+ * A read (`reads`) changes no member: it takes their rows shared, so
2027
+ * that one member's reads decide side by side (`lockMembers`).
1989
2028
  */
1990
- async #decide(principals, decide, late = false, asked = {}) {
2029
+ async #decide(principals, decide, how = {}) {
1991
2030
  const superseded = await this.#settled();
1992
2031
  if (superseded !== null) return {
1993
2032
  ok: false,
1994
2033
  refusal: refusal("wrong_kek", superseded)
1995
2034
  };
1996
- return this.#decideNow(principals, decide, late, asked);
2035
+ return this.#decideNow(principals, decide, how);
1997
2036
  }
1998
2037
  /** `#decide`, once the vault is settled. */
1999
- async #decideNow(principals, decide, late = false, asked = {}) {
2038
+ async #decideNow(principals, decide, how = {}) {
2000
2039
  const reports = [];
2001
2040
  for (let attempt = 1;; attempt += 1) try {
2002
- return await this.#decideOnce(principals, decide, reports, late, asked);
2041
+ return await this.#decideOnce(principals, decide, reports, how);
2003
2042
  } catch (error) {
2004
2043
  if (attempt < 3 && (error instanceof Retry || isUniqueViolation(error))) continue;
2005
2044
  throw error;
2006
2045
  }
2007
2046
  }
2008
- async #decideOnce(principals, decide, reports, late, asked) {
2047
+ async #decideOnce(principals, decide, reports, { late = false, asked = {}, reads: shared = false }) {
2009
2048
  try {
2010
2049
  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);
2050
+ await boundLockWaits(tx, shared ? READ_LOCK_TIMEOUT_MS : LOCK_TIMEOUT_MS, shared);
2051
+ const members = principals.length === 0 ? /* @__PURE__ */ new Map() : await lockMembers(tx, principals, shared);
2013
2052
  const reads = await this.#snapshot(tx, principals, asked, !late);
2014
2053
  const d = {
2015
2054
  tx,
@@ -2039,7 +2078,9 @@ var VaultService = class {
2039
2078
  for (const then of d.after) then((entry) => seqs.get(entry));
2040
2079
  }
2041
2080
  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));
2081
+ const sealing = /* @__PURE__ */ new Set([...d.touched, ...accessSeq.keys()]);
2082
+ if (shared && sealing.size > 0) throw new Error("a read changes no member, and seals no row");
2083
+ for (const principal of sealing) await this.#seal(d, principal, accessSeq.get(principal));
2043
2084
  return result;
2044
2085
  });
2045
2086
  this.#reported(reports);
@@ -2615,7 +2656,7 @@ var VaultService = class {
2615
2656
  if (this.#revisionOf(standing, d) !== asked) throw new Refused(refusal("access_changed", MESSAGES.access_changed), []);
2616
2657
  }
2617
2658
  throw new Refused(refusal(code, message), entries);
2618
- });
2659
+ }, { reads: true });
2619
2660
  }
2620
2661
  /**
2621
2662
  * The data key, or null when it does not open as this secret's: a claim
@@ -2733,7 +2774,10 @@ var VaultService = class {
2733
2774
  }))
2734
2775
  })
2735
2776
  }], [], d.reads)).seqStart };
2736
- }, false, reads);
2777
+ }, {
2778
+ asked: reads,
2779
+ reads: true
2780
+ });
2737
2781
  if (!intent.ok) return intent;
2738
2782
  intentSeq = intent.seq;
2739
2783
  }
@@ -2779,13 +2823,19 @@ var VaultService = class {
2779
2823
  } finally {
2780
2824
  for (const value of done) value.wipe();
2781
2825
  }
2782
- }, late, reads);
2826
+ }, {
2827
+ late,
2828
+ asked: reads,
2829
+ reads: true
2830
+ });
2783
2831
  }
2784
2832
  /**
2785
2833
  * Whether this read's keys would take the reader past the bulk limit: the
2786
2834
  * distinct keys released to them in the window, these among them. A key
2787
2835
  * read again exposes nothing new, so it counts once, whatever its version.
2788
- * Counted under their row's lock, so exactly (`Reads`).
2836
+ * Counted under the log's head, which every release appends under, so
2837
+ * exactly: one reader's reads decide side by side under their row, and
2838
+ * each counts the others' releases again once it holds the head (`#keys`).
2789
2839
  */
2790
2840
  #overBulkLimit(d) {
2791
2841
  return d.reads.released > this.#config.bulkLimit.count;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffre/vault",
3
- "version": "0.14.2",
3
+ "version": "0.14.3",
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.3",
40
+ "@coffre/db": "0.14.3"
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 } {
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
 
@@ -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;