@coffre/vault 0.6.2 → 0.8.0

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-CwsWOH6j.js";
1
+ import { a as KekUnavailableError, c as awsKms, d as Kek, f as People, i as KekProvider, l as checkpointMessage, m as derivedSigningKey, n as AwsKmsOptions, o as SecretContext, p as VaultConfig, r as KekBadClaimError, s as WrappedDek, t as AwsCredentials, u as verifyCheckpoint } from "./index-BkHJR9Gt.js";
2
2
  import { AdmitInput, 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";
@@ -63,4 +63,4 @@ export declare class VaultEntrypoint extends WorkerEntrypoint implements Vault {
63
63
  /** The vault Worker's default export, `VaultEntrypoint`, configured from its `env`. */
64
64
  export declare function vault<Env>(configure_: (env: Env) => WorkersVaultConfig): typeof VaultEntrypoint;
65
65
  //#endregion
66
- export { type AwsCredentials, type AwsKmsOptions, type Kek, KekBadClaimError, type KekProvider, KekUnavailableError, type PostgresDatabase, type SecretContext, type Vault, type VaultConfig, type WrappedDek, awsKms, checkpointMessage, derivedSigningKey, postgres, verifyCheckpoint };
66
+ export { type AwsCredentials, type AwsKmsOptions, type Kek, KekBadClaimError, type KekProvider, KekUnavailableError, type People, type PostgresDatabase, type SecretContext, type Vault, type VaultConfig, type WrappedDek, awsKms, checkpointMessage, derivedSigningKey, postgres, verifyCheckpoint };
@@ -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-CQJS3OZZ.js";
2
- import { n as prepareVault, t as openVault } from "./vault-ihE3Yc4a.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-BcttALFQ.js";
2
+ import { n as prepareVault, t as openVault } from "./vault-lBbyZ3kQ.js";
3
3
  import { createDatabase } from "@coffre/db";
4
4
  import { HyperdrivePool, postgres } from "@coffre/db/hyperdrive";
5
5
  import { WorkerEntrypoint } from "cloudflare:workers";
@@ -2,6 +2,15 @@ import { checkpointMessage as checkpointMessage$1, verifyCheckpoint } from "@cof
2
2
  import { AwsCredentials, AwsKmsOptions, KekBadClaimError as KekBadClaimError$1, KekProvider, KekProvider as KekProvider$1, KekRegistry, KekUnavailableError as KekUnavailableError$1, WrappedDek, awsKms } from "@coffre/core/kek";
3
3
  import { SecretContext } from "@coffre/core/envelope";
4
4
  //#region src/config.d.ts
5
+ /**
6
+ * What a person's reads open (docs/design/credential-proxy.md): `values`,
7
+ * as any member's; or `stand-ins`, where a person, on any credential of
8
+ * theirs (a session, a personal token, an MCP connection), opens values
9
+ * only for coffre to use for them, never to be handed back: the credential
10
+ * proxy injects them, and the deployment's `expose` list names the few it
11
+ * may pass on. Service accounts read values either way.
12
+ */
13
+ type People = 'values' | 'stand-ins';
5
14
  /**
6
15
  * A key-encryption key: 32 random bytes, base64, and the id envelopes record
7
16
  * it under; or one a key service holds, such as `awsKms({ keyArn, … })`.
@@ -41,6 +50,8 @@ type VaultConfig = {
41
50
  count: number;
42
51
  windowMinutes: number;
43
52
  };
53
+ /** What a person's reads open (`People`): `values` unless set. */
54
+ people?: People;
44
55
  } & Owners;
45
56
  /**
46
57
  * At least one email, each an Owner of the whole instance: made one when
@@ -69,4 +80,4 @@ type Owners = {
69
80
  /** The signing key a local KEK stands for, when the vault is given none: HKDF-SHA-256 of the KEK. */
70
81
  declare function derivedSigningKey(kek: Uint8Array): Buffer;
71
82
  //#endregion
72
- 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 };
83
+ export { KekUnavailableError$1 as a, awsKms as c, Kek as d, People as f, KekProvider$1 as i, checkpointMessage$1 as l, derivedSigningKey as m, AwsKmsOptions as n, SecretContext as o, VaultConfig 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-CwsWOH6j.js";
1
+ import { a as KekUnavailableError, c as awsKms, d as Kek, f as People, i as KekProvider, l as checkpointMessage, m as derivedSigningKey, n as AwsKmsOptions, o as SecretContext, p as VaultConfig, r as KekBadClaimError, s as WrappedDek, t as AwsCredentials, u as verifyCheckpoint } from "./index-BkHJR9Gt.js";
2
2
  export type * from "@coffre/core/vault";
3
- export { type AwsCredentials, type AwsKmsOptions, type Kek, KekBadClaimError, type KekProvider, KekUnavailableError, type SecretContext, type VaultConfig, type WrappedDek, awsKms, checkpointMessage, derivedSigningKey, verifyCheckpoint };
3
+ export { type AwsCredentials, type AwsKmsOptions, type Kek, KekBadClaimError, type KekProvider, KekUnavailableError, type People, 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-CQJS3OZZ.js";
1
+ import { a as verifyCheckpoint, i as checkpointMessage, n as KekUnavailableError, o as derivedSigningKey, r as awsKms, t as KekBadClaimError } from "./src-BcttALFQ.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-CwsWOH6j.js";
1
+ import { a as KekUnavailableError, c as awsKms, d as Kek, f as People, i as KekProvider, l as checkpointMessage, m as derivedSigningKey, n as AwsKmsOptions, o as SecretContext, p as VaultConfig, r as KekBadClaimError, s as WrappedDek, t as AwsCredentials, u as verifyCheckpoint } from "./index-BkHJR9Gt.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/audit";
@@ -45,4 +45,4 @@ export declare function serveVault(config: NodeVaultConfig & {
45
45
  /** The vault that `serveVault` runs, from the server's side of its socket. */
46
46
  export declare function connectVault(socket: string): Vault;
47
47
  //#endregion
48
- export { type AwsCredentials, type AwsKmsOptions, type Kek, KekBadClaimError, type KekProvider, KekUnavailableError, type LocalVault, type SecretContext, type Vault, type VaultConfig, type VaultOptions, type WrappedDek, awsKms, checkpointMessage, derivedSigningKey, verifyCheckpoint };
48
+ export { type AwsCredentials, type AwsKmsOptions, type Kek, KekBadClaimError, type KekProvider, KekUnavailableError, type LocalVault, type People, type SecretContext, type Vault, type VaultConfig, type VaultOptions, type WrappedDek, awsKms, checkpointMessage, derivedSigningKey, verifyCheckpoint };
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-CQJS3OZZ.js";
2
- import { n as prepareVault, t as openVault } from "./vault-ihE3Yc4a.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-BcttALFQ.js";
2
+ import { n as prepareVault, t as openVault } from "./vault-lBbyZ3kQ.js";
3
3
  import { chmodSync, existsSync, lstatSync, rmSync } from "node:fs";
4
4
  import { Agent, createServer, request } from "node:http";
5
5
  import { openDatabase } from "@coffre/db/connect";
@@ -57,7 +57,8 @@ function resolveVaultConfig(config) {
57
57
  keks: new KekRegistry(current, previous),
58
58
  owners: checkOwners(config.owners ?? config.bootstrapOwners ?? config.rootAdmins ?? []),
59
59
  signingKeys: signingKeys(config.signingKey, keks),
60
- bulkLimit: config.bulkLimit === void 0 ? DEFAULT_BULK_LIMIT : checkBulkLimit(config.bulkLimit)
60
+ bulkLimit: config.bulkLimit === void 0 ? DEFAULT_BULK_LIMIT : checkBulkLimit(config.bulkLimit),
61
+ people: checkPeople(config.people ?? "values")
61
62
  };
62
63
  }
63
64
  /**
@@ -80,6 +81,10 @@ function signingKeys(given, keks) {
80
81
  }
81
82
  return [signing, ...derived].filter((key, i, all) => all.findIndex((other) => other.equals(key)) === i);
82
83
  }
84
+ function checkPeople(people) {
85
+ if (people !== "values" && people !== "stand-ins") throw new Error(`people is 'values' or 'stand-ins'; got ${JSON.stringify(people)}`);
86
+ return people;
87
+ }
83
88
  function checkBulkLimit({ count, windowMinutes }) {
84
89
  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");
85
90
  return {
@@ -1,15 +1,15 @@
1
- import { ACCESS_ACTIONS, checkpointMessage, checkpointVerifier, describeAccessFault } from "@coffre/core/vault";
1
+ import { ACCESS_ACTIONS, HANDED_BACK, checkpointMessage, checkpointVerifier, describeAccessFault } 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 { createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
4
4
  import { tablesOf } from "@coffre/db";
5
- import { EVERYWHERE, NOTHING, allows, assignableToEnvironment, givesService, grantKind, isCredentialScope, isInstanceRole, isRole, isScope, managesService, mayManageAccess, normalScope, ownsInstance, runsInstance, setsUpServices, unscoped } from "@coffre/core/access";
5
+ import { EVERYWHERE, NOTHING, allows, assignableToEnvironment, givesService, grantKind, isCredentialScope, isInstanceRole, isRole, isScope, managesService, mayManageAccess, normalScope, ownsInstance, runsInstance, setsUpServices, unscoped, withinCredential } from "@coffre/core/access";
6
6
  import { GENESIS_HASH, deriveLogKey, verifyEntries } from "@coffre/core/audit";
7
- import { checkContext } from "@coffre/core/envelope";
7
+ import { checkContext, ownerKey } from "@coffre/core/envelope";
8
8
  import { shownMember } from "@coffre/core/schemas";
9
9
  import { SNAPSHOT, clockMillis, engineOf, forUpdate, isUniqueViolation, tombstone, truth } from "@coffre/db/dialect";
10
10
  import { accessEntriesAbout, everyProjectConversion, insertGrant, liveProjects, newestAccess, readGrants, reportStands, roleColumns, storedRole } from "@coffre/db/grants";
11
- import { LOOK_BACK, appendEntries, entryColumns, lockLogHead, newestAuthentic, newestVaultEntries } from "@coffre/db/log";
12
- import { and, asc, count, desc, eq, gt, gte, inArray, isNull, lt, lte, or, sql } from "drizzle-orm";
11
+ import { LOOK_BACK, appendEntries, entryColumns, lockLogHead, newestAuthentic, newestVaultEntries, withinBytes } from "@coffre/db/log";
12
+ import { and, asc, count, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, lte, or, sql } from "drizzle-orm";
13
13
  //#region src/accounting.ts
14
14
  const KEY_ACTIONS = /* @__PURE__ */ new Set([
15
15
  "secret.read",
@@ -49,12 +49,14 @@ var KeyAccounting = class {
49
49
  const parsed = parseOutcome(entry);
50
50
  if (typeof parsed === "string") return broken(parsed);
51
51
  const intent = this.#intents.get(parsed.relatedSeq);
52
- const key = intent?.keys[parsed.item];
53
- if (intent === void 0 || key === void 0 || intent.seen.has(parsed.item)) return broken("key outcome does not identify one item of its intent");
54
- if (parsed.intentId !== intent.intentId || entry.actor !== intent.entry.actor || entry.action !== intent.operation) return broken("key outcome belongs to another operation");
55
- if (parsed.secretId !== key.secretId || parsed.version !== key.version || parsed.subject !== key.subject) return broken("key outcome does not match its intended secret");
56
- intent.seen.add(parsed.item);
57
- if (intent.seen.size === intent.keys.length) this.#intents.delete(intent.entry.seq);
52
+ for (const outcome of parsed.items) {
53
+ const key = intent?.keys[outcome.item];
54
+ if (intent === void 0 || key === void 0 || intent.seen.has(outcome.item)) return broken("key outcome does not identify one item of its intent");
55
+ if (parsed.intentId !== intent.intentId || entry.actor !== intent.entry.actor || entry.action !== intent.operation) return broken("key outcome belongs to another operation");
56
+ if (outcome.secretId !== key.secretId || outcome.version !== key.version || outcome.subject !== key.subject) return broken("key outcome does not match its intended secret");
57
+ intent.seen.add(outcome.item);
58
+ }
59
+ if (intent !== void 0 && intent.seen.size === intent.keys.length) this.#intents.delete(intent.entry.seq);
58
60
  }
59
61
  }
60
62
  /** The first fault, or the batches still under way at `at`: live calls can finish after the snapshot. */
@@ -109,19 +111,32 @@ function parseIntent(entry) {
109
111
  function parseOutcome(entry) {
110
112
  const detail = payload(entry.metadata);
111
113
  if (detail === null) return "key outcome has no valid accounting payload";
112
- const { intent, item, version, subject } = detail;
114
+ const { intent } = detail;
113
115
  if (typeof intent !== "string" || entry.relatedSeq === null) return "key outcome has no intent identity";
114
- if (typeof item !== "number" || !Number.isSafeInteger(item) || item < 0) return "key outcome has no valid item number";
115
- const secretId = entry.secretId ?? detail.secretId;
116
- if (typeof secretId !== "string" || typeof subject !== "string") return "key outcome has no secret identity";
117
- if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key outcome has no valid version";
116
+ const many = detail.secrets;
117
+ if (many !== void 0 && (!Array.isArray(many) || many.length === 0)) return "key outcome has no valid item list";
118
+ const listed = many === void 0 ? [{
119
+ ...detail,
120
+ secretId: entry.secretId ?? detail.secretId
121
+ }] : many.map(record);
122
+ const items = [];
123
+ for (const one of listed) {
124
+ if (one === null) return "key outcome has no valid item list";
125
+ const { item, secretId, version, subject } = one;
126
+ if (typeof item !== "number" || !Number.isSafeInteger(item) || item < 0) return "key outcome has no valid item number";
127
+ if (typeof secretId !== "string" || typeof subject !== "string") return "key outcome has no secret identity";
128
+ if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key outcome has no valid version";
129
+ items.push({
130
+ item,
131
+ secretId,
132
+ version,
133
+ subject
134
+ });
135
+ }
118
136
  return {
119
137
  intentId: intent,
120
138
  relatedSeq: entry.relatedSeq,
121
- item,
122
- secretId,
123
- version,
124
- subject
139
+ items
125
140
  };
126
141
  }
127
142
  function payload(text) {
@@ -274,12 +289,6 @@ function at(db, place) {
274
289
  if (place.projectId !== null) return and(eq(vaultGrants.projectId, place.projectId), isNull(vaultGrants.environmentId));
275
290
  return and(isNull(vaultGrants.projectId), isNull(vaultGrants.environmentId), place.environmentSlug === null ? isNull(vaultGrants.environmentSlug) : eq(vaultGrants.environmentSlug, place.environmentSlug));
276
291
  }
277
- /**
278
- * Each of these environments that exists, by id, with its project, its
279
- * slug, what grants on one slug in every project match, and whether it was
280
- * deleted, alone or with its project: then no key under it opens, or is
281
- * wrapped, whatever grant would cover it.
282
- */
283
292
  async function environmentsById(db, ids) {
284
293
  if (ids.length === 0) return /* @__PURE__ */ new Map();
285
294
  const { environments, projects } = tablesOf(db);
@@ -287,7 +296,10 @@ async function environmentsById(db, ids) {
287
296
  id: environments.id,
288
297
  projectId: environments.projectId,
289
298
  slug: environments.slug,
290
- deleted: truth(or(tombstone(environments.slug), tombstone(projects.slug)))
299
+ owner: environments.owner,
300
+ parentId: environments.parentId,
301
+ deleted: truth(or(tombstone(environments.slug), tombstone(projects.slug))),
302
+ archived: truth(or(isNotNull(environments.archivedAt), isNotNull(projects.archivedAt)))
291
303
  }).from(environments).innerJoin(projects, eq(projects.id, environments.projectId)).where(inArray(environments.id, [...new Set(ids)]));
292
304
  return new Map(rows.map(({ id, ...environment }) => [id, environment]));
293
305
  }
@@ -348,11 +360,16 @@ async function everyProjectHolders(db) {
348
360
  }
349
361
  /** The action of an entry that releases a key, which the bulk limit counts. */
350
362
  const RELEASE = "secret.read";
351
- /** How many keys the vault has released to `principal` since `after`. */
363
+ /**
364
+ * How many keys the vault has released to `principal` since `after`: one
365
+ * for an entry about one secret, and as many as it lists for one about
366
+ * several (`secrets`), a read logged once per place.
367
+ */
352
368
  async function releasesSince(db, principal, after) {
353
369
  const { auditLog } = tablesOf(db);
354
- const [{ n }] = await db.select({ n: count() }).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.actor, principal), eq(auditLog.action, RELEASE), eq(auditLog.decision, "allow"), gt(auditLog.occurredAt, after)));
355
- return Number(n);
370
+ const listed = engineOf(db) === "postgres" ? sql`jsonb_array_length((${auditLog.metadata})::jsonb -> 'secrets')` : sql`json_array_length(${auditLog.metadata}, '$.secrets')`;
371
+ const [{ n }] = await db.select({ n: sql`SUM(COALESCE(${listed}, 1))` }).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.actor, principal), eq(auditLog.action, RELEASE), eq(auditLog.decision, "allow"), gt(auditLog.occurredAt, after)));
372
+ return Number(n ?? 0);
356
373
  }
357
374
  /** Rows of the log as the chain's codec reads them: `author` and `decision` are checked by the table. */
358
375
  function stored(rows) {
@@ -361,12 +378,17 @@ function stored(rows) {
361
378
  /** Up to `limit` entries of either author from `fromSeq`, oldest first: the chain as it runs. */
362
379
  async function entriesFrom(db, fromSeq, limit) {
363
380
  const { auditLog } = tablesOf(db);
364
- return stored(await db.select(entryColumns(db)).from(auditLog).where(gte(auditLog.seq, fromSeq)).orderBy(asc(auditLog.seq)).limit(limit));
381
+ const batch = await withinBytes(db, gte(auditLog.seq, fromSeq), limit);
382
+ if (batch === null) return [];
383
+ return stored(await db.select(entryColumns(db)).from(auditLog).where(and(gte(auditLog.seq, batch.from), lte(auditLog.seq, batch.to))).orderBy(asc(auditLog.seq)));
365
384
  }
366
385
  /** Up to `limit` of the vault's entries after `afterSeq`, oldest first: every one, whatever its action or decision. */
367
386
  async function vaultEntriesAfter(db, afterSeq, limit) {
368
387
  const { auditLog } = tablesOf(db);
369
- return stored(await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), gt(auditLog.seq, afterSeq))).orderBy(asc(auditLog.seq)).limit(limit));
388
+ const vault = eq(auditLog.author, "vault");
389
+ const batch = await withinBytes(db, and(vault, gt(auditLog.seq, afterSeq)), limit);
390
+ if (batch === null) return [];
391
+ return stored(await db.select(entryColumns(db)).from(auditLog).where(and(vault, gte(auditLog.seq, batch.from), lte(auditLog.seq, batch.to))).orderBy(asc(auditLog.seq)));
370
392
  }
371
393
  /** The hash of the entry at each of `seqs` the log holds, by seq. */
372
394
  async function hashesAt(db, seqs) {
@@ -552,7 +574,7 @@ const UNVERIFIED = {
552
574
  function further(a, b) {
553
575
  return b.nextSeq > a.nextSeq ? b : a;
554
576
  }
555
- /** Entries read at once: a few megabytes, well within a Worker's memory, and a fifth of the round trips of 1000. */
577
+ /** Entries read at once, at most, and no more than `BATCH_BYTES` of what they say: well within a Worker's memory, and a fifth of the round trips of 1000. */
556
578
  const VERIFY_BATCH = 5e3;
557
579
  /**
558
580
  * Check the chain, a batch in memory at a time, and return where it is now
@@ -611,7 +633,6 @@ async function verifyChain(db, logKeys, shown, anchor, onBatch = () => {}) {
611
633
  vaultEntries: verified.vaultEntries + result.authenticated,
612
634
  vaultKeys: moved.keys
613
635
  };
614
- if (batch.length < 5e3) break;
615
636
  }
616
637
  return {
617
638
  verification: {
@@ -1061,6 +1082,8 @@ const MESSAGES = {
1061
1082
  bad_claim: "the key does not belong to this secret",
1062
1083
  deleted: "this project or environment was deleted",
1063
1084
  ended: "the reference read through was ended",
1085
+ personal: "a personal config's values are its owner's alone",
1086
+ stand_ins: "people use this instance's values through stand-ins, never as values: `coffre run` gives your app them; a service account reads values",
1064
1087
  not_allowed: "not allowed to change this",
1065
1088
  last_owner: "the last Owner of the whole instance stays one: make someone else an Owner first",
1066
1089
  invalid: "not something the rules allow",
@@ -1161,7 +1184,7 @@ var VaultService = class {
1161
1184
  underLock: []
1162
1185
  };
1163
1186
  const result = await decide(d);
1164
- const entries = [...reports, ...d.log];
1187
+ const entries = [...reports, ...readsByPlace(d.log)];
1165
1188
  let at = d.at;
1166
1189
  const accessSeq = /* @__PURE__ */ new Map();
1167
1190
  if (entries.length > 0) {
@@ -1184,7 +1207,7 @@ var VaultService = class {
1184
1207
  };
1185
1208
  } catch (error) {
1186
1209
  if (!(error instanceof Refused || error instanceof Outage)) throw error;
1187
- const entries = [...reports, ...error.entries];
1210
+ const entries = [...reports, ...readsByPlace(error.entries)];
1188
1211
  if (entries.length > 0) await this.#db.transaction(async (tx) => {
1189
1212
  await boundLockWaits(tx, LOCK_TIMEOUT_MS);
1190
1213
  await this.#append(tx, entries);
@@ -1507,6 +1530,7 @@ var VaultService = class {
1507
1530
  const { principal } = input;
1508
1531
  validateText(input.purpose);
1509
1532
  const vias = input.items.map((item) => validateVia(item.via));
1533
+ const throughs = input.items.map((item) => validateThrough(item.through));
1510
1534
  const loaded = await this.#versions(input, "secret.read", { purpose: input.purpose });
1511
1535
  if (!loaded.ok) return loaded;
1512
1536
  const items = loaded.versions.map((version) => ({
@@ -1515,22 +1539,39 @@ var VaultService = class {
1515
1539
  }));
1516
1540
  validateItems(items, "wrapped");
1517
1541
  const sealed = await this.#referencesAt(vias);
1542
+ const forks = await environmentsById(this.#db, throughs.flatMap((through) => through === null ? [] : [through]));
1543
+ const viaOk = items.map((item, i) => vias[i] === null || sealed[i] !== null && sealed[i].source.secretId === item.secret.secretId && item.current);
1544
+ const throughOk = items.map((item, i) => {
1545
+ const through = throughs[i];
1546
+ if (through === null) return true;
1547
+ if (!viaOk[i]) return false;
1548
+ const fork = forks.get(through);
1549
+ const decidedAt = sealed[i]?.holder ?? item.secret;
1550
+ return fork !== void 0 && !fork.deleted && !fork.archived && fork.projectId === decidedAt.projectId && through !== decidedAt.environmentId;
1551
+ });
1518
1552
  const versionIds = new Map(items.map((item) => [item.secret, item.id]));
1519
1553
  const viaOf = new Map(items.map((item, i) => [item.secret, {
1520
1554
  via: vias[i],
1521
- sealed: sealed[i]
1555
+ sealed: sealed[i],
1556
+ through: throughs[i],
1557
+ throughOk: throughOk[i]
1522
1558
  }]));
1523
1559
  const entry = (secret, decision, code) => {
1524
- const { via, sealed: seal } = viaOf.get(secret);
1560
+ const { via, sealed: seal, through, throughOk: ok } = viaOf.get(secret);
1525
1561
  return {
1526
1562
  ...keyEntry("secret.read", principal, secret, decision, code, input, {
1527
1563
  purpose: input.purpose,
1528
- ...via === null ? {} : viaDetail(via, seal)
1564
+ ...via === null ? {} : viaDetail(via, seal),
1565
+ ...through === null ? {} : ok ? throughDetail(through, forks.get(through), via !== null) : { through: {
1566
+ environmentId: through,
1567
+ refused: true
1568
+ } }
1529
1569
  }),
1530
1570
  secretVersionId: versionIds.get(secret)
1531
1571
  };
1532
1572
  };
1533
- if (items.some((item, i) => vias[i] !== null && (sealed[i] === null || sealed[i].source.secretId !== item.secret.secretId || !item.current))) return this.#refuseVersions(principal, "bad_claim", items.map((item) => entry(item.secret, "deny", "bad_claim")));
1573
+ if (viaOk.includes(false)) return this.#refuseVersions(principal, "bad_claim", items.map((item) => entry(item.secret, "deny", "bad_claim")));
1574
+ if (throughOk.includes(false)) return this.#refuseVersions(principal, "bad_claim", items.map((item) => entry(item.secret, "deny", "bad_claim")));
1534
1575
  const ends = sealed.flatMap((seal) => seal === null ? [] : [seal.seq]);
1535
1576
  const remote = items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
1536
1577
  return this.#keys({
@@ -1546,8 +1587,8 @@ var VaultService = class {
1546
1587
  remote,
1547
1588
  entry,
1548
1589
  input
1549
- }, () => items.map(({ secret, wrapped }) => async (operation) => {
1550
- const key = await this.#open(wrapped, secret, operation);
1590
+ }, (contextOf) => items.map(({ secret, wrapped }) => async (operation) => {
1591
+ const key = await this.#open(wrapped, contextOf(secret), operation);
1551
1592
  return key && {
1552
1593
  key,
1553
1594
  wipe: () => key.fill(0)
@@ -1574,11 +1615,11 @@ var VaultService = class {
1574
1615
  remote: this.#remote(this.#config.keks.primary),
1575
1616
  entry,
1576
1617
  input
1577
- }, () => items.map(({ secret, key }) => async (operation) => {
1618
+ }, (contextOf) => items.map(({ secret, key }) => async (operation) => {
1578
1619
  const dek = Buffer.from(key, "base64");
1579
1620
  try {
1580
1621
  return {
1581
- wrapped: serialisable(await this.#config.keks.wrap(dek, context(secret), operation)),
1622
+ wrapped: serialisable(await this.#config.keks.wrap(dek, contextOf(secret), operation)),
1582
1623
  wipe: () => {}
1583
1624
  };
1584
1625
  } finally {
@@ -1617,12 +1658,12 @@ var VaultService = class {
1617
1658
  remote,
1618
1659
  entry,
1619
1660
  input
1620
- }, () => items.map(({ secret, wrapped }) => async (operation) => {
1621
- const key = await this.#open(wrapped, secret, operation);
1661
+ }, (contextOf) => items.map(({ secret, wrapped }) => async (operation) => {
1662
+ const key = await this.#open(wrapped, contextOf(secret), operation);
1622
1663
  if (key === null) return null;
1623
1664
  try {
1624
1665
  return {
1625
- wrapped: serialisable(await this.#config.keks.wrap(key, context(secret), operation)),
1666
+ wrapped: serialisable(await this.#config.keks.wrap(key, contextOf(secret), operation)),
1626
1667
  wipe: () => {}
1627
1668
  };
1628
1669
  } finally {
@@ -1679,9 +1720,9 @@ var VaultService = class {
1679
1720
  * that is not what it says. A key service that cannot answer is an outage,
1680
1721
  * not a verdict on the claim, and throws.
1681
1722
  */
1682
- async #open(wrapped, secret, operation) {
1723
+ async #open(wrapped, context, operation) {
1683
1724
  try {
1684
- return await this.#config.keks.unwrap(unwrappable(wrapped), context(secret), operation);
1725
+ return await this.#config.keks.unwrap(unwrappable(wrapped), context, operation);
1685
1726
  } catch (error) {
1686
1727
  if (error instanceof KekBadClaimError) return null;
1687
1728
  throw error;
@@ -1705,14 +1746,24 @@ var VaultService = class {
1705
1746
  if (wrongKek !== null) return this.#decide([], async () => {
1706
1747
  throw new Refused(refusal("wrong_kek", wrongKek), secrets.map((secret) => entry(secret, "deny", "wrong_kek")));
1707
1748
  });
1749
+ const owners = /* @__PURE__ */ new Map();
1708
1750
  const check = async (d) => {
1709
1751
  const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), call.input);
1710
1752
  const at = secrets.map((secret, i) => call.places?.[i] ?? secret);
1711
1753
  const environments = await environmentsById(d.tx, [...secrets, ...at].map((place) => place.environmentId));
1754
+ const parents = [...environments.values()].flatMap((environment) => environment.owner === null || environment.parentId === null ? [] : [environment.parentId]);
1755
+ for (const [id, parent] of await environmentsById(d.tx, parents)) environments.set(id, parent);
1712
1756
  const gone = (place) => environments.get(place.environmentId)?.deleted ?? false;
1713
- const codes = secrets.map((secret, i) => refuses(reader, call.permission, placeIn(at[i], environments), gone(secret) || gone(at[i])));
1757
+ owners.clear();
1758
+ for (const [id, environment] of environments) if (environment.owner !== null) owners.set(id, ownerKey(environment.owner));
1759
+ const codes = secrets.map((secret, i) => {
1760
+ const personal = personalRefusal(reader, principal, call.permission, secret, at[i], environments);
1761
+ if (personal !== void 0) return personal;
1762
+ return refuses(reader, call.permission, placeIn(at[i], environments), gone(secret) || gone(at[i]));
1763
+ });
1714
1764
  if (call.vet !== void 0 && !codes.some((code) => code !== null)) (await call.vet(d.tx)).forEach((code, i) => codes[i] = code);
1715
1765
  let first = codes.find((code) => code !== null) ?? null;
1766
+ if (first === null && action === "secret.read" && this.#config.people === "stand-ins" && principal.startsWith("user:") && HANDED_BACK.includes(call.input.purpose)) first = "stand_ins";
1716
1767
  if (first === null && action === "secret.read" && await this.#overBulkLimit(d, principal, secrets.length)) first = "bulk_limit";
1717
1768
  if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, i) => entry(secret, "deny", codes[i] ?? first)));
1718
1769
  };
@@ -1774,7 +1825,7 @@ var VaultService = class {
1774
1825
  if (first === void 0 || first === null) return;
1775
1826
  throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, item) => outcomeEntry(secret, item, "deny", codes[item] ?? first)));
1776
1827
  });
1777
- const { outcomes, expired } = await settle(operations(), this.#prepared.options.keyBudgetMs);
1828
+ const { outcomes, expired } = await settle(operations((secret) => context(secret, owners.get(secret.environmentId))), this.#prepared.options.keyBudgetMs);
1778
1829
  const done = outcomes.flatMap((outcome) => outcome.ok ? [outcome.value] : []);
1779
1830
  try {
1780
1831
  const entries = () => secrets.map((secret, i) => outcomeEntry(secret, i, "deny", outcomes[i].ok ? "withheld" : outcomes[i].code));
@@ -1861,6 +1912,7 @@ var VaultService = class {
1861
1912
  const source = sources.get(item.source.secretId);
1862
1913
  if (source === void 0 || projectOf.get(item.holder.environmentId) !== item.holder.projectId) return "bad_claim";
1863
1914
  if (source.secretId === item.holder.secretId) return "invalid";
1915
+ if (environments.get(item.holder.environmentId)?.owner != null || environments.get(source.environmentId)?.owner != null) return "personal";
1864
1916
  return refuses(reader, "secret.write", placeIn(item.holder, environments), gone(item.holder)) ?? refuses(reader, "secret.read", placeIn(source, environments), gone(source));
1865
1917
  });
1866
1918
  const first = codes.find((code) => code !== null) ?? null;
@@ -2965,6 +3017,7 @@ var VaultService = class {
2965
3017
  let keys = [];
2966
3018
  for (let after = -1n;;) {
2967
3019
  const batch = await vaultEntriesAfter(tx, after, VERIFY_BATCH);
3020
+ if (batch.length === 0) return { ok: true };
2968
3021
  for (const entry of batch) {
2969
3022
  const result = verifyEntries([entry], {
2970
3023
  startSeq: entry.seq,
@@ -2985,7 +3038,6 @@ var VaultService = class {
2985
3038
  };
2986
3039
  keys = moved.keys;
2987
3040
  for (const entry of batch) findings.add(entry);
2988
- if (batch.length < 5e3) return { ok: true };
2989
3041
  after = batch[batch.length - 1].seq;
2990
3042
  }
2991
3043
  }
@@ -3211,6 +3263,30 @@ function validateVia(via) {
3211
3263
  seq: via.seq
3212
3264
  };
3213
3265
  }
3266
+ /** A read's fork, checked for shape: null for none. */
3267
+ function validateThrough(through) {
3268
+ if (through === void 0 || through === null) return null;
3269
+ if (typeof through !== "string" || !UUID.test(through)) throw new Error("a fork's id must be a lowercase UUID");
3270
+ return through;
3271
+ }
3272
+ /**
3273
+ * How a read of an inherited value is logged: the fork it was read in,
3274
+ * `through`, and as `also`, which puts it in the fork's log too, unless it
3275
+ * was read through the parent's reference. The fork decides nothing: the
3276
+ * reader's grants where the value is held do, as for any read.
3277
+ */
3278
+ function throughDetail(through, fork, viaReference) {
3279
+ return {
3280
+ through: {
3281
+ environmentId: through,
3282
+ ...fork === void 0 ? {} : { slug: fork.slug }
3283
+ },
3284
+ ...viaReference ? {} : { also: {
3285
+ projectId: fork?.projectId ?? null,
3286
+ environmentId: through
3287
+ } }
3288
+ };
3289
+ }
3214
3290
  /**
3215
3291
  * How a read through a reference is logged: the way in, and `also`, the
3216
3292
  * holder's place, which puts the read in the holder's project's log too.
@@ -3254,6 +3330,26 @@ function placeIn(place, environments) {
3254
3330
  * deleted place is refused before any grant is asked, so no kind of grant,
3255
3331
  * on the project, the environment or every project, can reach into one.
3256
3332
  */
3333
+ /**
3334
+ * A key of a personal config is its owner's alone (docs/design/forking.md,
3335
+ * section 4): no grant or role opens or wraps it, an Owner's included,
3336
+ * only its owner, while they read the environment it is a config of,
3337
+ * whatever the call is. Nor does any reference reach into or out of one.
3338
+ * Undefined for a key of any other environment, decided as ever.
3339
+ */
3340
+ function personalRefusal(reader, principal, permission, secret, at, environments) {
3341
+ const own = environments.get(secret.environmentId);
3342
+ const held = at.environmentId === secret.environmentId ? own : environments.get(at.environmentId);
3343
+ if (own?.owner == null && held?.owner == null) return void 0;
3344
+ if (own !== held || principal !== own?.owner || own.parentId === null) return "personal";
3345
+ const parent = placeIn({
3346
+ projectId: secret.projectId,
3347
+ environmentId: own.parentId
3348
+ }, environments);
3349
+ const refused = refuses(reader, "secret.read", parent, own.deleted || (environments.get(own.parentId)?.deleted ?? true));
3350
+ if (refused !== null) return refused;
3351
+ return withinCredential(reader.live, permission, parent) ? null : "no_grant";
3352
+ }
3257
3353
  function refuses(reader, permission, where, deleted) {
3258
3354
  if (reader.status === "tampered") return "tampered";
3259
3355
  if (reader.status === "removed") return "removed";
@@ -3286,6 +3382,89 @@ function keyEntry(action, principal, secret, decision, code, correlation, detail
3286
3382
  })
3287
3383
  };
3288
3384
  }
3385
+ /** What every secret of one read at a place shares, said once in its entry rather than in each of its `secrets`. */
3386
+ const READ_SHARED = [
3387
+ "purpose",
3388
+ "intent",
3389
+ "credentialId",
3390
+ "through"
3391
+ ];
3392
+ /**
3393
+ * A decision's `secret.read` entries, made one per secret, as the log keeps
3394
+ * them: one per place it was decided at, the environment read and, through
3395
+ * a reference, the holder's, or the fork that inherits it (`also`,
3396
+ * `through`). An entry about several secrets lists
3397
+ * them in `secrets`, each with what its own entry would have said: its
3398
+ * `secretId`, `secretVersionId`, `subject`, `version`, and its `via`, holder
3399
+ * (`also.secretId`) and intent `item` when it has them. An entry about one
3400
+ * secret is as it always was. A `coffre run` of a hundred keys is one entry,
3401
+ * not a hundred; what it decided, and for which keys, is the same. Other
3402
+ * entries pass through, in order.
3403
+ */
3404
+ function readsByPlace(entries) {
3405
+ const groups = /* @__PURE__ */ new Map();
3406
+ const out = [];
3407
+ for (const entry of entries) {
3408
+ if (entry.action !== "secret.read") {
3409
+ out.push(entry);
3410
+ continue;
3411
+ }
3412
+ const { also, through } = JSON.parse(entry.metadata ?? "{}");
3413
+ const place = JSON.stringify([
3414
+ entry.actor,
3415
+ entry.decision,
3416
+ entry.code ?? null,
3417
+ entry.projectId ?? null,
3418
+ entry.environmentId ?? null,
3419
+ entry.operationId ?? null,
3420
+ entry.requestId ?? null,
3421
+ String(entry.relatedSeq ?? null),
3422
+ also?.projectId ?? null,
3423
+ also?.environmentId ?? null,
3424
+ through ?? null
3425
+ ]);
3426
+ const group = groups.get(place);
3427
+ if (group !== void 0) {
3428
+ group.push(entry);
3429
+ continue;
3430
+ }
3431
+ const fresh = [entry];
3432
+ groups.set(place, fresh);
3433
+ out.push(fresh);
3434
+ }
3435
+ return out.map((item) => Array.isArray(item) ? oneRead(item) : item);
3436
+ }
3437
+ /** Entries of one read at one place as one entry: `readsByPlace`. */
3438
+ function oneRead(group) {
3439
+ const [first] = group;
3440
+ if (group.length === 1) return first;
3441
+ const details = group.map((entry) => JSON.parse(entry.metadata ?? "{}"));
3442
+ const shared = Object.fromEntries(READ_SHARED.flatMap((field) => details[0][field] === void 0 ? [] : [[field, details[0][field]]]));
3443
+ const beside = details[0].also;
3444
+ const secrets = group.map((entry, i) => {
3445
+ const own = Object.fromEntries(Object.entries(details[i]).filter(([field]) => !READ_SHARED.includes(field) && field !== "also"));
3446
+ const holder = details[i].also?.secretId;
3447
+ return {
3448
+ ...entry.secretId == null ? {} : { secretId: entry.secretId },
3449
+ ...entry.secretVersionId == null ? {} : { secretVersionId: entry.secretVersionId },
3450
+ ...own,
3451
+ ...holder === void 0 ? {} : { also: { secretId: holder } }
3452
+ };
3453
+ });
3454
+ return {
3455
+ ...first,
3456
+ secretId: null,
3457
+ secretVersionId: null,
3458
+ metadata: JSON.stringify({
3459
+ ...shared,
3460
+ ...beside === void 0 ? {} : { also: {
3461
+ projectId: beside.projectId,
3462
+ environmentId: beside.environmentId
3463
+ } },
3464
+ secrets
3465
+ })
3466
+ };
3467
+ }
3289
3468
  /** Whether `entry` changes a member's access: what their row's `access_seq` names. */
3290
3469
  function isAccessEntry(entry) {
3291
3470
  return entry.decision === "allow" && entry.subjectPrincipal !== void 0 && entry.subjectPrincipal !== null && ACCESS_ACTIONS.includes(entry.action);
@@ -3366,11 +3545,13 @@ function view(grant) {
3366
3545
  function iso(ms) {
3367
3546
  return new Date(ms).toISOString();
3368
3547
  }
3369
- function context(secret) {
3548
+ /** A secret's binding context: its ids, and a personal config's owner, hashed, as the decision read it. */
3549
+ function context(secret, owner) {
3370
3550
  return {
3371
3551
  projectId: secret.projectId,
3372
3552
  environmentId: secret.environmentId,
3373
- secretId: secret.secretId
3553
+ secretId: secret.secretId,
3554
+ ...owner === void 0 ? {} : { owner }
3374
3555
  };
3375
3556
  }
3376
3557
  function unwrappable(wrapped) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffre/vault",
3
- "version": "0.6.2",
3
+ "version": "0.8.0",
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.6.2",
40
- "@coffre/db": "0.6.2"
39
+ "@coffre/core": "0.8.0",
40
+ "@coffre/db": "0.8.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "26.1.1",