@coffre/vault 0.14.0 → 0.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,6 +18,7 @@ export declare class VaultEntrypoint extends WorkerEntrypoint implements Vault {
18
18
  #private;
19
19
  unwrap(input: UnwrapInput): Promise<import("@coffre/core/vault").Outcome<{
20
20
  keys: string[];
21
+ accessRevision?: string;
21
22
  }>>;
22
23
  wrap(input: WrapInput): Promise<import("@coffre/core/vault").Outcome<import("@coffre/core/vault").Wrapped>>;
23
24
  rewrap(input: RewrapInput): Promise<import("@coffre/core/vault").Outcome<import("@coffre/core/vault").Wrapped>>;
@@ -1,5 +1,5 @@
1
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-C5n9wSvj.js";
2
+ import { n as prepareVault, t as openVault } from "./vault-hLffpMy5.js";
3
3
  import { createDatabase } from "@coffre/db";
4
4
  import { HyperdrivePool, postgres } from "@coffre/db/hyperdrive";
5
5
  import { WorkerEntrypoint } from "cloudflare:workers";
package/dist/node.js CHANGED
@@ -1,5 +1,5 @@
1
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-C5n9wSvj.js";
2
+ import { n as prepareVault, t as openVault } from "./vault-hLffpMy5.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,4 +1,4 @@
1
- import { ACCESS_ACTIONS, ACCESS_ACTIONS as ACCESS_ACTIONS$1, POLICY_REVISION, checkpointMessage, checkpointVerifier, describeAccessFault, handsBack, policyRevision } from "@coffre/core/vault";
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
3
  import { createHash, createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
4
4
  import { prepared, tablesOf } from "@coffre/db";
@@ -1891,6 +1891,7 @@ const MESSAGES = {
1891
1891
  last_owner: "the last Owner of the whole instance stays one: make someone else an Owner first",
1892
1892
  invalid: "not something the rules allow",
1893
1893
  policy_changed: "the instance's roles or protected environments changed while this was asked: try again",
1894
+ access_changed: "what this member holds changed since the read was decided: try again",
1894
1895
  log_broken: "the vault log does not hold from the last checkpoint",
1895
1896
  wrong_kek: "this vault's key does not open the data it holds",
1896
1897
  tampered: "this member's record failed the vault's integrity check"
@@ -2390,6 +2391,7 @@ var VaultService = class {
2390
2391
  const { principal } = input;
2391
2392
  validateText(input.purpose);
2392
2393
  if (input.keepsSensitive !== void 0 && typeof input.keepsSensitive !== "boolean") throw new Error("keepsSensitive is true, false or absent");
2394
+ if (input.accessRevision !== void 0 && typeof input.accessRevision !== "string") throw new Error("accessRevision names an answer of access, or is absent");
2393
2395
  const vias = input.items.map((item) => validateVia(item.via));
2394
2396
  const throughs = input.items.map((item) => validateThrough(item.through));
2395
2397
  const loaded = await this.#versions(input, "secret.read", { purpose: input.purpose });
@@ -2431,8 +2433,8 @@ var VaultService = class {
2431
2433
  secretVersionId: versionIds.get(secret)
2432
2434
  };
2433
2435
  };
2434
- if (viaOk.includes(false)) return this.#refuseVersions(principal, "bad_claim", items.map((item) => entry(item.secret, "deny", "bad_claim")));
2435
- if (throughOk.includes(false)) return this.#refuseVersions(principal, "bad_claim", items.map((item) => entry(item.secret, "deny", "bad_claim")));
2436
+ if (viaOk.includes(false)) return this.#refuseVersions(principal, "bad_claim", items.map((item) => entry(item.secret, "deny", "bad_claim")), { asked: input.accessRevision });
2437
+ if (throughOk.includes(false)) return this.#refuseVersions(principal, "bad_claim", items.map((item) => entry(item.secret, "deny", "bad_claim")), { asked: input.accessRevision });
2436
2438
  const ends = sealed.flatMap((seal) => seal === null ? [] : [seal.seq]);
2437
2439
  const remote = items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
2438
2440
  return this.#keys({
@@ -2453,13 +2455,16 @@ var VaultService = class {
2453
2455
  key,
2454
2456
  wipe: () => key.fill(0)
2455
2457
  };
2456
- }), (opened) => ({ keys: opened.map(({ key }) => {
2457
- try {
2458
- return base64(key);
2459
- } finally {
2460
- key.fill(0);
2461
- }
2462
- }) }));
2458
+ }), (opened) => ({
2459
+ keys: opened.map(({ key }) => {
2460
+ try {
2461
+ return base64(key);
2462
+ } finally {
2463
+ key.fill(0);
2464
+ }
2465
+ }),
2466
+ ...input.accessRevision === void 0 ? {} : { accessRevision: input.accessRevision }
2467
+ }));
2463
2468
  }
2464
2469
  async wrap(input) {
2465
2470
  const { principal, items } = input;
@@ -2510,7 +2515,7 @@ var VaultService = class {
2510
2515
  if (items.some((item, i) => !loosens[i] && item.secret.sensitive !== true && strict(i))) return this.#refuseVersions(principal, "invalid", loaded.versions.map((source) => ({
2511
2516
  ...keyEntry("key.rewrap", principal, source.secret, "deny", "invalid", input),
2512
2517
  secretVersionId: source.id
2513
- })), LOOSER);
2518
+ })), { message: LOOSER });
2514
2519
  const sources = new Map(items.map((item, i) => [item.secret, loaded.versions[i]]));
2515
2520
  const loosened = new Set(items.flatMap((item, i) => loosens[i] ? [item.secret] : []));
2516
2521
  const entry = (secret, decision, code) => ({
@@ -2572,7 +2577,7 @@ var VaultService = class {
2572
2577
  if ([...found.values()].some((version) => version.deleted)) return this.#refuseVersions(input.principal, "deleted", [...found.values()].map((version) => ({
2573
2578
  ...keyEntry(action, input.principal, version.secret, "deny", "deleted", input, detail),
2574
2579
  secretVersionId: version.id
2575
- })));
2580
+ })), { asked: input.accessRevision });
2576
2581
  if (ids.some((id) => !found.has(id))) return this.#refuseVersions(input.principal, "bad_claim", ids.map((id) => {
2577
2582
  const version = found.get(id);
2578
2583
  if (version === void 0) return {
@@ -2592,14 +2597,23 @@ var VaultService = class {
2592
2597
  ...keyEntry(action, input.principal, version.secret, "deny", "bad_claim", input, detail),
2593
2598
  secretVersionId: id
2594
2599
  };
2595
- }));
2600
+ }), { asked: input.accessRevision });
2596
2601
  return {
2597
2602
  ok: true,
2598
2603
  versions: ids.map((id) => found.get(id))
2599
2604
  };
2600
2605
  }
2601
- async #refuseVersions(principal, code, entries, message = MESSAGES[code]) {
2602
- return this.#decide([principal], async () => {
2606
+ /**
2607
+ * Refuse a call for what its versions are, under the principal's row, as
2608
+ * any decision: and a read on a kept answer no longer theirs as that, not
2609
+ * for its versions, logging no read (`accessRevision`).
2610
+ */
2611
+ async #refuseVersions(principal, code, entries, { message = MESSAGES[code], asked } = {}) {
2612
+ return this.#decide([principal], async (d) => {
2613
+ if (asked !== void 0) {
2614
+ const standing = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads);
2615
+ if (this.#revisionOf(standing, d) !== asked) throw new Refused(refusal("access_changed", MESSAGES.access_changed), []);
2616
+ }
2603
2617
  throw new Refused(refusal(code, message), entries);
2604
2618
  });
2605
2619
  }
@@ -2636,7 +2650,10 @@ var VaultService = class {
2636
2650
  });
2637
2651
  const owners = /* @__PURE__ */ new Map();
2638
2652
  const check = async (d) => {
2639
- const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), call.input);
2653
+ const standing = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads);
2654
+ const asked = call.input.accessRevision;
2655
+ if (asked !== void 0 && this.#revisionOf(standing, d) !== asked) throw new Refused(refusal("access_changed", MESSAGES.access_changed), []);
2656
+ const reader = through(standing, call.input);
2640
2657
  const at = secrets.map((secret, i) => call.places?.[i] ?? secret);
2641
2658
  const environments = d.reads.environments;
2642
2659
  owners.clear();
@@ -2725,7 +2742,7 @@ var VaultService = class {
2725
2742
  try {
2726
2743
  await check(d);
2727
2744
  } catch (error) {
2728
- if (error instanceof Refused) throw new Refused(error.refusal, secrets.map((secret, item) => outcomeEntry(secret, item, "deny", error.entries[item].code ?? error.refusal.code)));
2745
+ if (error instanceof Refused && !(error.refusal.code === "access_changed" && intentSeq === null)) throw new Refused(error.refusal, secrets.map((secret, item) => outcomeEntry(secret, item, "deny", error.entries[item]?.code ?? error.refusal.code)));
2729
2746
  throw error;
2730
2747
  }
2731
2748
  };
@@ -3110,7 +3127,7 @@ var VaultService = class {
3110
3127
  }
3111
3128
  #access(principal, row, held, at, tampered, policy) {
3112
3129
  const active = PRINCIPAL.test(principal) && !tampered && row?.status === "active";
3113
- return {
3130
+ const access = {
3114
3131
  principal,
3115
3132
  status: tampered ? "tampered" : !PRINCIPAL.test(principal) ? "unknown" : row?.status ?? "unknown",
3116
3133
  generation: row?.generation ?? 0,
@@ -3123,6 +3140,16 @@ var VaultService = class {
3123
3140
  by: row?.statusChangedBy ?? null,
3124
3141
  policy
3125
3142
  };
3143
+ return {
3144
+ ...access,
3145
+ revision: accessRevision(access)
3146
+ };
3147
+ }
3148
+ /** The revision of what `access` would answer about a standing a decision read: none but an active member's matches a kept answer. */
3149
+ #revisionOf(standing, d) {
3150
+ const { policy } = standing.live;
3151
+ if (standing.status !== "active" || policy === void 0) return null;
3152
+ return this.#access(standing.principal, d.members.get(standing.principal), standing.stored, d.at, false, policy).revision;
3126
3153
  }
3127
3154
  /**
3128
3155
  * What `principal` holds now. Read without locks, the fast way; a row that
@@ -3959,15 +3986,16 @@ var VaultService = class {
3959
3986
  /**
3960
3987
  * Sign the log up to its last entry, from the newest checkpoint's tally.
3961
3988
  *
3962
- * In one snapshot, and without the log's lock, so that no append waits on
3963
- * it: every member's row checked, since rows changed around the vault
3964
- * write nothing to the log; the prefix the newest checkpoint signed still
3965
- * there, its hash where it was, which a rewrite chained again changes, and
3966
- * every entry of it, which a cut changes; the chain recomputed from there,
3967
- * every hash from content and every vault entry by its MAC, no further
3968
- * than one walk (`walks.checkpoint`); and a stretch of the prefixes signed
3969
- * before rehashed (`#scrub`). Then, under the lock, the entries appended
3970
- * since the snapshot, so that it signs the head.
3989
+ * Without the log's lock, so that no append waits on it, and in one short
3990
+ * snapshot: every member's row checked, since rows changed around the
3991
+ * vault write nothing to the log; the prefix the newest checkpoint signed
3992
+ * still there, its hash where it was, which a rewrite chained again
3993
+ * changes, and every entry of it, which a cut changes. Then, in no
3994
+ * transaction, a batch a statement, the chain recomputed from there, every
3995
+ * hash from content and every vault entry by its MAC, no further than one
3996
+ * walk (`walks.checkpoint`); and a stretch of the prefixes signed before
3997
+ * rehashed (`#scrub`). Then, under the lock, the entries appended since
3998
+ * the walk, so that it signs the head.
3971
3999
  *
3972
4000
  * A walk cut short signs where it got to, behind the head: the log after
3973
4001
  * an upgrade from 0.10.0, whose checkpoints kept no tally, is signed again
@@ -3980,7 +4008,7 @@ var VaultService = class {
3980
4008
  const { walks } = this.#prepared.options;
3981
4009
  const found = [];
3982
4010
  let seen = null;
3983
- const walked = await this.#db.transaction(async (tx) => {
4011
+ const resumed = await this.#db.transaction(async (tx) => {
3984
4012
  seen = (await newestCheckpointEntry(tx))?.seq ?? null;
3985
4013
  await this.#sweep(tx, found);
3986
4014
  const from = await this.#resume(tx, "newest");
@@ -3997,20 +4025,9 @@ var VaultService = class {
3997
4025
  failedAtSeq: Number(gap.found),
3998
4026
  reason: `sequence gap: expected seq ${gap.expected}, found ${gap.found}`
3999
4027
  };
4000
- const walk = new Walk(from.tally, from.digest);
4001
- const chain = await rehash(tx, logKeys, from.at, walk, { limit: walks.checkpoint });
4002
- if (!chain.ok) return chain;
4003
- const unsigned = await this.#checkpointFault(tx, walk.checkpoints);
4004
- if (unsigned !== null) return unsigned;
4005
- return {
4006
- ok: true,
4007
- from,
4008
- tally: walk.tally(),
4009
- digest: walk.digest(),
4010
- at: chain.at,
4011
- done: chain.done
4012
- };
4028
+ return from;
4013
4029
  }, SNAPSHOT);
4030
+ const walked = resumed.ok ? await this.#walkOn(resumed, walks.checkpoint) : resumed;
4014
4031
  let scrub = {
4015
4032
  ok: true,
4016
4033
  next: walked.ok ? walked.from.scrub : null
@@ -4120,6 +4137,31 @@ var VaultService = class {
4120
4137
  });
4121
4138
  }
4122
4139
  /**
4140
+ * The walk on from where a checkpoint resumes, no further than `limit`
4141
+ * entries, outside any transaction: a batch a statement, each linked to
4142
+ * the hash it verified last, so that no connection is held while it
4143
+ * rehashes, seconds for a long walk. Entries are appended in order, under
4144
+ * the head, and never change, so no snapshot keeps them: what it read is
4145
+ * held to the log again under the head, before anything is signed over it
4146
+ * (`carries`).
4147
+ */
4148
+ async #walkOn(from, limit) {
4149
+ const { logKeys } = this.#prepared;
4150
+ const walk = new Walk(from.tally, from.digest);
4151
+ const chain = await rehash(this.#db, logKeys, from.at, walk, { limit });
4152
+ if (!chain.ok) return chain;
4153
+ const unsigned = await this.#checkpointFault(this.#db, walk.checkpoints);
4154
+ if (unsigned !== null) return unsigned;
4155
+ return {
4156
+ ok: true,
4157
+ from,
4158
+ tally: walk.tally(),
4159
+ digest: walk.digest(),
4160
+ at: chain.at,
4161
+ done: chain.done
4162
+ };
4163
+ }
4164
+ /**
4123
4165
  * The prefixes signed before, rehashed a stretch at a time, in laps: a lap
4124
4166
  * runs from the first entry to the prefix the newest checkpoint signed
4125
4167
  * when it began, which it keeps (`end`), `walks.scrub` entries a call, each
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffre/vault",
3
- "version": "0.14.0",
3
+ "version": "0.14.2",
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.0",
40
- "@coffre/db": "0.14.0"
39
+ "@coffre/core": "0.14.2",
40
+ "@coffre/db": "0.14.2"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "26.1.1",
package/src/vault.ts CHANGED
@@ -61,6 +61,7 @@ import {
61
61
  checkpointVerifier,
62
62
  describeAccessFault,
63
63
  POLICY_REVISION,
64
+ accessRevision,
64
65
  policyRevision,
65
66
  type Access,
66
67
  type AccessChange,
@@ -341,6 +342,7 @@ const MESSAGES: Record<RefusalCode, string> = {
341
342
  last_owner: 'the last Owner of the whole instance stays one: make someone else an Owner first',
342
343
  invalid: 'not something the rules allow',
343
344
  policy_changed: "the instance's roles or protected environments changed while this was asked: try again",
345
+ access_changed: 'what this member holds changed since the read was decided: try again',
344
346
  log_broken: 'the vault log does not hold from the last checkpoint',
345
347
  wrong_kek: "this vault's key does not open the data it holds",
346
348
  tampered: "this member's record failed the vault's integrity check",
@@ -906,10 +908,11 @@ class VaultService implements Vault {
906
908
 
907
909
  // Raw DEKs are cleared on every path. JSON and base64 leave strings that
908
910
  // cannot be wiped, so this is best-effort memory hygiene.
909
- async unwrap(input: UnwrapInput): Promise<Outcome<{ keys: string[] }>> {
911
+ async unwrap(input: UnwrapInput): Promise<Outcome<{ keys: string[]; accessRevision?: string }>> {
910
912
  const { principal } = input;
911
913
  validateText(input.purpose);
912
914
  if (input.keepsSensitive !== undefined && typeof input.keepsSensitive !== 'boolean') throw new Error('keepsSensitive is true, false or absent');
915
+ if (input.accessRevision !== undefined && typeof input.accessRevision !== 'string') throw new Error('accessRevision names an answer of access, or is absent');
913
916
  const vias = input.items.map((item) => validateVia(item.via));
914
917
  const throughs = input.items.map((item) => validateThrough(item.through));
915
918
  const loaded = await this.#versions(input, 'secret.read', { purpose: input.purpose });
@@ -947,10 +950,10 @@ class VaultService implements Vault {
947
950
  };
948
951
  };
949
952
  if (viaOk.includes(false)) {
950
- return this.#refuseVersions(principal, 'bad_claim', items.map((item) => entry(item.secret, 'deny', 'bad_claim')));
953
+ return this.#refuseVersions(principal, 'bad_claim', items.map((item) => entry(item.secret, 'deny', 'bad_claim')), { asked: input.accessRevision });
951
954
  }
952
955
  if (throughOk.includes(false)) {
953
- return this.#refuseVersions(principal, 'bad_claim', items.map((item) => entry(item.secret, 'deny', 'bad_claim')));
956
+ return this.#refuseVersions(principal, 'bad_claim', items.map((item) => entry(item.secret, 'deny', 'bad_claim')), { asked: input.accessRevision });
954
957
  }
955
958
  const ends = sealed.flatMap((seal) => (seal === null ? [] : [seal.seq]));
956
959
  const remote = items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
@@ -984,6 +987,8 @@ class VaultService implements Vault {
984
987
  key.fill(0);
985
988
  }
986
989
  }),
990
+ // Named back: the read was decided on that answer, which this vault checked.
991
+ ...(input.accessRevision === undefined ? {} : { accessRevision: input.accessRevision }),
987
992
  }),
988
993
  );
989
994
  }
@@ -1042,7 +1047,7 @@ class VaultService implements Vault {
1042
1047
  if (items.some((item, i) => !loosens[i] && item.secret.sensitive !== true && strict(i))) {
1043
1048
  return this.#refuseVersions(principal, 'invalid', loaded.versions.map((source) => ({
1044
1049
  ...keyEntry('key.rewrap', principal, source.secret, 'deny', 'invalid', input), secretVersionId: source.id,
1045
- })), LOOSER);
1050
+ })), { message: LOOSER });
1046
1051
  }
1047
1052
  const sources = new Map(items.map((item, i) => [item.secret, loaded.versions[i]]));
1048
1053
  const loosened = new Set(items.flatMap((item, i) => (loosens[i] ? [item.secret] : [])));
@@ -1094,7 +1099,7 @@ class VaultService implements Vault {
1094
1099
 
1095
1100
  /** Versions need no lock: they change only by being erased, once their place is archived, and their ids determine the whole batch before any key call. */
1096
1101
  async #versions(
1097
- input: Correlation & { principal: string; items: { secretVersionId: string }[] },
1102
+ input: Correlation & { principal: string; items: { secretVersionId: string }[]; accessRevision?: string },
1098
1103
  action: KeyAction,
1099
1104
  detail: Record<string, unknown> = {},
1100
1105
  ): Promise<Outcome<{ versions: store.SecretVersion[] }>> {
@@ -1110,7 +1115,7 @@ class VaultService implements Vault {
1110
1115
  if ([...found.values()].some((version) => version.deleted)) {
1111
1116
  return this.#refuseVersions(input.principal, 'deleted', [...found.values()].map((version) => ({
1112
1117
  ...keyEntry(action, input.principal, version.secret, 'deny', 'deleted', input, detail), secretVersionId: version.id,
1113
- })));
1118
+ })), { asked: input.accessRevision });
1114
1119
  }
1115
1120
  if (ids.some((id) => !found.has(id))) {
1116
1121
  return this.#refuseVersions(input.principal, 'bad_claim', ids.map((id) => {
@@ -1121,13 +1126,27 @@ class VaultService implements Vault {
1121
1126
  metadata: JSON.stringify({ ...detail, secretVersionId: id, ...traced(input) }),
1122
1127
  };
1123
1128
  return { ...keyEntry(action, input.principal, version.secret, 'deny', 'bad_claim', input, detail), secretVersionId: id };
1124
- }));
1129
+ }), { asked: input.accessRevision });
1125
1130
  }
1126
1131
  return { ok: true, versions: ids.map((id) => found.get(id)!) };
1127
1132
  }
1128
1133
 
1129
- async #refuseVersions(principal: string, code: 'bad_claim' | 'deleted' | 'invalid', entries: NewEntry[], message = MESSAGES[code]): Promise<Outcome<never>> {
1130
- return this.#decide([principal], async () => {
1134
+ /**
1135
+ * Refuse a call for what its versions are, under the principal's row, as
1136
+ * any decision: and a read on a kept answer no longer theirs as that, not
1137
+ * for its versions, logging no read (`accessRevision`).
1138
+ */
1139
+ async #refuseVersions(
1140
+ principal: string,
1141
+ code: 'bad_claim' | 'deleted' | 'invalid',
1142
+ entries: NewEntry[],
1143
+ { message = MESSAGES[code], asked }: { message?: string; asked?: string } = {},
1144
+ ): Promise<Outcome<never>> {
1145
+ return this.#decide([principal], async (d) => {
1146
+ if (asked !== undefined) {
1147
+ const standing = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads);
1148
+ if (this.#revisionOf(standing, d) !== asked) throw new Refused(refusal('access_changed', MESSAGES.access_changed), []);
1149
+ }
1131
1150
  throw new Refused(refusal(code, message), entries);
1132
1151
  });
1133
1152
  }
@@ -1185,7 +1204,7 @@ class VaultService implements Vault {
1185
1204
  viaReference?: readonly boolean[];
1186
1205
  remote: boolean;
1187
1206
  entry: (secret: SecretRef, decision: 'allow' | 'deny', code: string | null) => NewEntry;
1188
- input: Correlation & Through & { purpose?: string; keepsSensitive?: boolean };
1207
+ input: Correlation & Through & { purpose?: string; keepsSensitive?: boolean; accessRevision?: string };
1189
1208
  },
1190
1209
  /**
1191
1210
  * One per secret; null for a bad claim, an error for an outage or an
@@ -1206,7 +1225,11 @@ class VaultService implements Vault {
1206
1225
  // Each personal config's owner, hashed, by environment, as the decision read them: what its keys' contexts bind.
1207
1226
  const owners = new Map<string, string>();
1208
1227
  const check = async (d: Decision) => {
1209
- const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), call.input);
1228
+ const standing = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads);
1229
+ // A read decided on an earlier answer about its reader, only while that is still the answer: nothing read, nothing logged.
1230
+ const asked = call.input.accessRevision;
1231
+ if (asked !== undefined && this.#revisionOf(standing, d) !== asked) throw new Refused(refusal('access_changed', MESSAGES.access_changed), []);
1232
+ const reader = through(standing, call.input);
1210
1233
  // One read of the environments the decision is about, as the store has them, not as the app sent them:
1211
1234
  // each secret's and, for a read through a reference, its holder's. A grant on one slug in every project
1212
1235
  // matches the slug there, and a place deleted on either side opens for none.
@@ -1290,9 +1313,11 @@ class VaultService implements Vault {
1290
1313
  try {
1291
1314
  await check(d);
1292
1315
  } catch (error) {
1293
- if (error instanceof Refused) {
1316
+ // A read on a kept answer no longer the vault's logs no read; but a key service's intent, logged already, is
1317
+ // accounted for all the same, each key refused, as after any other refusal.
1318
+ if (error instanceof Refused && !(error.refusal.code === 'access_changed' && intentSeq === null)) {
1294
1319
  throw new Refused(error.refusal, secrets.map((secret, item) =>
1295
- outcomeEntry(secret, item, 'deny', error.entries[item].code ?? error.refusal.code)));
1320
+ outcomeEntry(secret, item, 'deny', error.entries[item]?.code ?? error.refusal.code)));
1296
1321
  }
1297
1322
  throw error;
1298
1323
  }
@@ -1665,7 +1690,7 @@ class VaultService implements Vault {
1665
1690
 
1666
1691
  #access(principal: string, row: Member | undefined, held: readonly GrantRow[], at: number, tampered: boolean, policy: Policy): Access {
1667
1692
  const active = PRINCIPAL.test(principal) && !tampered && row?.status === 'active';
1668
- return {
1693
+ const access: Access = {
1669
1694
  principal,
1670
1695
  status: tampered ? 'tampered' : !PRINCIPAL.test(principal) ? 'unknown' : (row?.status ?? 'unknown'),
1671
1696
  generation: row?.generation ?? 0,
@@ -1675,6 +1700,14 @@ class VaultService implements Vault {
1675
1700
  by: row?.statusChangedBy ?? null,
1676
1701
  policy,
1677
1702
  };
1703
+ return { ...access, revision: accessRevision(access) };
1704
+ }
1705
+
1706
+ /** The revision of what `access` would answer about a standing a decision read: none but an active member's matches a kept answer. */
1707
+ #revisionOf(standing: Standing, d: Decision): string | null {
1708
+ const { policy } = standing.live;
1709
+ if (standing.status !== 'active' || policy === undefined) return null;
1710
+ return this.#access(standing.principal, d.members.get(standing.principal), standing.stored, d.at, false, policy).revision!;
1678
1711
  }
1679
1712
 
1680
1713
  /**
@@ -2498,15 +2531,16 @@ class VaultService implements Vault {
2498
2531
  /**
2499
2532
  * Sign the log up to its last entry, from the newest checkpoint's tally.
2500
2533
  *
2501
- * In one snapshot, and without the log's lock, so that no append waits on
2502
- * it: every member's row checked, since rows changed around the vault
2503
- * write nothing to the log; the prefix the newest checkpoint signed still
2504
- * there, its hash where it was, which a rewrite chained again changes, and
2505
- * every entry of it, which a cut changes; the chain recomputed from there,
2506
- * every hash from content and every vault entry by its MAC, no further
2507
- * than one walk (`walks.checkpoint`); and a stretch of the prefixes signed
2508
- * before rehashed (`#scrub`). Then, under the lock, the entries appended
2509
- * since the snapshot, so that it signs the head.
2534
+ * Without the log's lock, so that no append waits on it, and in one short
2535
+ * snapshot: every member's row checked, since rows changed around the
2536
+ * vault write nothing to the log; the prefix the newest checkpoint signed
2537
+ * still there, its hash where it was, which a rewrite chained again
2538
+ * changes, and every entry of it, which a cut changes. Then, in no
2539
+ * transaction, a batch a statement, the chain recomputed from there, every
2540
+ * hash from content and every vault entry by its MAC, no further than one
2541
+ * walk (`walks.checkpoint`); and a stretch of the prefixes signed before
2542
+ * rehashed (`#scrub`). Then, under the lock, the entries appended since
2543
+ * the walk, so that it signs the head.
2510
2544
  *
2511
2545
  * A walk cut short signs where it got to, behind the head: the log after
2512
2546
  * an upgrade from 0.10.0, whose checkpoints kept no tally, is signed again
@@ -2522,7 +2556,7 @@ class VaultService implements Vault {
2522
2556
  const found: NewEntry[] = [];
2523
2557
  // The newest checkpoint entry, signed or refused, as this call's snapshot found it.
2524
2558
  let seen: bigint | null = null;
2525
- const walked = await this.#db.transaction(async (tx) => {
2559
+ const resumed = await this.#db.transaction(async (tx) => {
2526
2560
  seen = (await store.newestCheckpointEntry(tx))?.seq ?? null;
2527
2561
  await this.#sweep(tx, found);
2528
2562
  const from = await this.#resume(tx, 'newest');
@@ -2533,13 +2567,9 @@ class VaultService implements Vault {
2533
2567
  }
2534
2568
  const gap = await missingEntry(tx, from.at.seq);
2535
2569
  if (gap !== null) return { ok: false as const, failedAtSeq: Number(gap.found), reason: `sequence gap: expected seq ${gap.expected}, found ${gap.found}` };
2536
- const walk = new Walk(from.tally, from.digest);
2537
- const chain = await rehash(tx, logKeys, from.at, walk, { limit: walks.checkpoint });
2538
- if (!chain.ok) return chain;
2539
- const unsigned = await this.#checkpointFault(tx, walk.checkpoints);
2540
- if (unsigned !== null) return unsigned;
2541
- return { ok: true as const, from, tally: walk.tally(), digest: walk.digest(), at: chain.at, done: chain.done };
2570
+ return from;
2542
2571
  }, SNAPSHOT);
2572
+ const walked = resumed.ok ? await this.#walkOn(resumed, walks.checkpoint) : resumed;
2543
2573
  // The past rehashed, in a snapshot of its own. The database failing it leaves the stretch for the
2544
2574
  // next call, where it was, and the checkpoint is signed all the same, saying why (`warning`); a bug
2545
2575
  // throws, as anywhere else.
@@ -2580,7 +2610,7 @@ class VaultService implements Vault {
2580
2610
  // Nothing since the last one, which signed every entry before its own: it is still the newest prefix.
2581
2611
  if (newest !== null && latest!.seq === head.nextSeq - 1n && latest!.relatedSeq === latest!.seq - 1n) return { checkpoint: signedPart(newest) };
2582
2612
  // Under the lock, the rest: the prefix signed last still where it was,
2583
- // and every entry since the snapshot, unless the walk is catching up,
2613
+ // and every entry since the walk, unless the walk is catching up,
2584
2614
  // when what it walked must still be there. A rewrite is never signed over.
2585
2615
  if (newest !== null && !(await carries(d.tx, newest))) {
2586
2616
  throw refused('log_broken', { reason: `the log up to entry ${newest.seq} is not the prefix the last checkpoint signed` });
@@ -2588,7 +2618,7 @@ class VaultService implements Vault {
2588
2618
  const walk = new Walk(walked.tally, walked.digest);
2589
2619
  let { at } = walked;
2590
2620
  if (walked.done) {
2591
- // Where the snapshot's walk ended still has the hash it found there: what follows links to it.
2621
+ // Where the walk ended still has the hash it found there: what follows links to it.
2592
2622
  if (!(await carries(d.tx, headOf(at)))) {
2593
2623
  throw refused('log_broken', { failedAtSeq: Number(at.seq), reason: `the log up to entry ${at.seq} changed while the vault walked it` });
2594
2624
  }
@@ -2625,6 +2655,25 @@ class VaultService implements Vault {
2625
2655
  });
2626
2656
  }
2627
2657
 
2658
+ /**
2659
+ * The walk on from where a checkpoint resumes, no further than `limit`
2660
+ * entries, outside any transaction: a batch a statement, each linked to
2661
+ * the hash it verified last, so that no connection is held while it
2662
+ * rehashes, seconds for a long walk. Entries are appended in order, under
2663
+ * the head, and never change, so no snapshot keeps them: what it read is
2664
+ * held to the log again under the head, before anything is signed over it
2665
+ * (`carries`).
2666
+ */
2667
+ async #walkOn(from: Resumed, limit: number) {
2668
+ const { logKeys } = this.#prepared;
2669
+ const walk = new Walk(from.tally, from.digest);
2670
+ const chain = await rehash(this.#db, logKeys, from.at, walk, { limit });
2671
+ if (!chain.ok) return chain;
2672
+ const unsigned = await this.#checkpointFault(this.#db, walk.checkpoints);
2673
+ if (unsigned !== null) return unsigned;
2674
+ return { ok: true as const, from, tally: walk.tally(), digest: walk.digest(), at: chain.at, done: chain.done };
2675
+ }
2676
+
2628
2677
  /**
2629
2678
  * The prefixes signed before, rehashed a stretch at a time, in laps: a lap
2630
2679
  * runs from the first entry to the prefix the newest checkpoint signed