@pithy-sh/secrets 0.1.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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +15 -0
  3. package/package.json +52 -0
  4. package/pithy.manifest.json +48 -0
  5. package/src/admin/health.ts +94 -0
  6. package/src/admin/status.ts +462 -0
  7. package/src/audit/actions.ts +53 -0
  8. package/src/capability.ts +219 -0
  9. package/src/cli/audit.ts +33 -0
  10. package/src/cli/dispatch.ts +192 -0
  11. package/src/cli/partialWrite.ts +69 -0
  12. package/src/cli/rotationLedger.ts +98 -0
  13. package/src/cli/validate.ts +38 -0
  14. package/src/cli/writeTargets.ts +125 -0
  15. package/src/cloudflare-test.d.ts +16 -0
  16. package/src/crypto/envelope.ts +188 -0
  17. package/src/crypto/versionedValue.ts +82 -0
  18. package/src/data/secretRotations.ts +49 -0
  19. package/src/data/statusDb.ts +29 -0
  20. package/src/data/systemSecrets.ts +44 -0
  21. package/src/data/tables.ts +16 -0
  22. package/src/dev/devSecretsFile.ts +167 -0
  23. package/src/dev/loadDevSecrets.ts +128 -0
  24. package/src/dev/seedDevSecrets.ts +447 -0
  25. package/src/env/bindings.ts +84 -0
  26. package/src/error/errors.ts +155 -0
  27. package/src/http/guards.ts +107 -0
  28. package/src/http/responses.ts +225 -0
  29. package/src/http/rotate.ts +224 -0
  30. package/src/http/routes.ts +300 -0
  31. package/src/http/schemas.ts +53 -0
  32. package/src/http/view.ts +74 -0
  33. package/src/index.ts +50 -0
  34. package/src/keyspace.ts +70 -0
  35. package/src/keyspaceWrite.ts +135 -0
  36. package/src/management/writeSecret.ts +120 -0
  37. package/src/manager/configWriter.ts +19 -0
  38. package/src/manager/dispatcher.ts +142 -0
  39. package/src/manager/managerRegistry.ts +53 -0
  40. package/src/manager/retryPolicy.ts +44 -0
  41. package/src/manager/rotationWorkflow.ts +26 -0
  42. package/src/manager/secretsConfigWriter.ts +61 -0
  43. package/src/manager/worker.ts +119 -0
  44. package/src/manager/wrangler.jsonc +76 -0
  45. package/src/manager/writeWorkflow.ts +162 -0
  46. package/src/migrations/0001_init.ts +53 -0
  47. package/src/mintValue.ts +53 -0
  48. package/src/provision/provisionSecrets.ts +206 -0
  49. package/src/provision/resolveManagerConfig.ts +175 -0
  50. package/src/registry.ts +453 -0
  51. package/src/rotation/atRestKeyRotation.ts +146 -0
  52. package/src/rotation/keyRotation.ts +139 -0
  53. package/src/rotation/rotateValue.ts +412 -0
  54. package/src/rotation/rotationLedger.ts +167 -0
  55. package/src/rotation/valueRotator.ts +76 -0
  56. package/src/scope.ts +120 -0
  57. package/src/secretsStore.ts +765 -0
  58. package/src/sharedSecretsStore.ts +187 -0
  59. package/src/store/rotationTracker.ts +189 -0
  60. package/src/store/systemSecretsStore.ts +223 -0
  61. package/src/test-utils/devEncryptionKeys.ts +30 -0
  62. package/src/test-utils/secretFixtures.ts +178 -0
  63. package/src/valueBearing.ts +42 -0
  64. package/src/version.generated.ts +16 -0
@@ -0,0 +1,765 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { AuditActorType, AuditOutcome } from "@pithy-sh/core/src/audit/auditEvent";
5
+ import type { AuditEmit } from "@pithy-sh/core/src/audit/recorder";
6
+ import { InternalError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
7
+ import { type SecretsAuditAction, SecretsAuditActions } from "./audit/actions";
8
+ import {
9
+ currentValue,
10
+ decodeVersionedValue,
11
+ initialVersionedValue,
12
+ type VersionedValue,
13
+ } from "./crypto/versionedValue";
14
+ import { resolveBinding, type SecretBinding, type SecretsStoreEnv } from "./env/bindings";
15
+ import { SecretInvalidValueError, SecretNotFoundError } from "./error/errors";
16
+ import { keyedSecretName } from "./keyspace";
17
+ import { type KeyedMemberWrite, type KeyedWriteMode, runKeyedRemove, runKeyedWrite } from "./keyspaceWrite";
18
+ import type { KeyedSecretName, SecretName, SecretRegistry, SecretRegistryEntry, SecretValue } from "./registry";
19
+ import { RotationTracker } from "./store/rotationTracker";
20
+ import { type StoredSecretValue, SystemSecretsStore, unreadableSecret } from "./store/systemSecretsStore";
21
+
22
+ /**
23
+ * The read seam. `secretsStore(env, registry)` resolves every declared secret locally — no RPC —
24
+ * routing by backend (`d1` decrypts the per-environment row; `cf-secrets-store` reads the bound value)
25
+ * and exposing one uniform API. The accessor's named methods are synchronous.
26
+ *
27
+ * **One path, every environment (#153).** There is no dev branch. `ENVIRONMENT` decides nothing here;
28
+ * the environment is already expressed by which `SECRETS` D1 and which master key the worker is bound
29
+ * to, so routing on it as well was a second answer to a question the bindings had already settled. It
30
+ * had two costs. A `d1` secret resolved from a plaintext binding in dev, which is a shape production
31
+ * never sees — so `pithy secrets rotate --env dev` exercised a code path only staging ran, and a
32
+ * multi-version secret silently collapsed to one. And it made `.dev.vars` carry application secrets
33
+ * under kebab registry names, in wrangler's `UPPER_SNAKE` env-binding file, teaching every adopter that
34
+ * one of the two conventions was a mistake. Dev now reads the row `pithy dev` seeded, and `.dev.vars`
35
+ * goes back to being what wrangler says it is.
36
+ *
37
+ * The cost is stated plainly: **a worker with any `d1` secret needs its `SECRETS` D1 and a master key in
38
+ * dev too.** `pithy add secrets` mints both, and a project composing a capability that declares a `d1`
39
+ * secret already had to.
40
+ *
41
+ * `get(name)` returns the current value — what almost every consumer wants. `getVersions(name)`
42
+ * returns the current pointer plus every still-valid version — for the rare verifier that must
43
+ * check a kid against every valid key (e.g. token verification across a signing-key rotation).
44
+ * Both are available for every secret; the shape is not a per-secret switch.
45
+ *
46
+ * **A failure belongs to its secret, not to the batch (#170).** Resolution is eager, so one unset
47
+ * secret used to take the whole accessor down — and the accessor is shared, so an unconfigured OAuth
48
+ * provider in `auth` stopped `payments` reading a webhook key, in a capability that never mentions it.
49
+ * The registry already says which secrets are conditional ("read only when the provider is enabled"),
50
+ * and eager resolution turned every one of them into a boot requirement. So a secret that fails to
51
+ * resolve holds its error instead of throwing it, and the error is raised by the read of *that* secret.
52
+ * Nothing is swallowed: a secret that is genuinely missing still fails loudly, naming itself, at the
53
+ * first read. A secret nobody reads costs nothing, which is what "conditional" meant all along.
54
+ *
55
+ * **A keyspace is the one asymmetry, and it is honest.** A `keyed` entry covers an unbounded set of
56
+ * members whose keys exist only at runtime — one credential per tenant — so nothing is resolved for it
57
+ * up front, and `getKeyed(name, key)` / `getKeyedVersions(name, key)` are async: they fetch exactly the
58
+ * one member asked for, from the encrypted D1 store, in dev and deployed alike (nothing can inject a
59
+ * name that does not exist at build time). Members are never cached — one tenant's credential must not
60
+ * sit in an accessor the next request reuses.
61
+ *
62
+ * **A keyspace is also the one thing this accessor writes.** `putKeyed` / `rotateKeyed` / `deleteKeyed`
63
+ * seal and store one member inline, on the request, because the application that mints a per-tenant
64
+ * credential has to know it is stored before it can hand the other half out (`./keyspaceWrite`). A
65
+ * *named* secret is still written only by the CLI through the manager Workflow, and that stays: an
66
+ * operator-provisioned value has nobody waiting on the response, and the CLI is the only place holding
67
+ * the registry that can validate it before it is written.
68
+ *
69
+ * The master key never leaves the worker, and resolved plaintext is held in `#private` fields so
70
+ * it cannot leak via `JSON.stringify`, structured logging, or object spread.
71
+ */
72
+
73
+ /** A secret's resolved versions: the current pointer plus every still-valid version, parsed. */
74
+ export interface VersionedSecret<E extends SecretRegistryEntry> {
75
+ /** The version key whose value is current. */
76
+ currentVersion: string;
77
+ /** Every still-valid version: version key → parsed value. */
78
+ versions: Record<string, SecretValue<E>>;
79
+ }
80
+
81
+ /** Internal per-secret resolved shape — values already parsed to their value type. */
82
+ interface Resolved {
83
+ current: unknown;
84
+ currentVersion: string;
85
+ versions: Record<string, unknown>;
86
+ }
87
+
88
+ /**
89
+ * Everything the accessor does to one keyspace member, by its composed `<keyspace>/<key>` name.
90
+ *
91
+ * One seam rather than a read one and a write one, because they are one store and every operation
92
+ * runs the same way: build it now, do exactly one thing, and let it go. A seam at all so the
93
+ * accessor's rules — which name a read composes, what a create refuses, which errors never echo a
94
+ * key — are testable without a D1; {@link d1KeyedIO} is the real one.
95
+ */
96
+ export interface KeyedSecretIO {
97
+ /** One member's stored envelope, or `undefined` when nothing is stored under that name. */
98
+ read(storedName: string): Promise<VersionedValue | undefined>;
99
+ /** Seal and store one member; resolves to its current version key once the row is written. */
100
+ write(write: KeyedMemberWrite): Promise<string>;
101
+ /** Remove one member and every version of it. A no-op when it is not stored. */
102
+ remove(storedName: string): Promise<void>;
103
+ }
104
+
105
+ /**
106
+ * The real keyed I/O: the per-environment encrypted D1 store, one member per operation.
107
+ *
108
+ * The store is built per operation rather than once, so it re-resolves the master key — a
109
+ * `SECRETS_ENCRYPTION_KEYS` rotation is picked up instead of being pinned for the life of a cached
110
+ * accessor. A keyspace has no other home: a Secrets Store binding is declared at build time and a
111
+ * member's name is not — which is the argument that has now been made for every `d1` secret (#153).
112
+ */
113
+ export function d1KeyedIO(env: SecretsStoreEnv): KeyedSecretIO {
114
+ const deps = async () => ({
115
+ store: await SystemSecretsStore.fromEnv(env),
116
+ tracker: RotationTracker.fromD1(env.SECRETS),
117
+ });
118
+ return {
119
+ read: async (storedName) => (await SystemSecretsStore.fromEnv(env)).getValue(storedName),
120
+ write: async (write) => runKeyedWrite(await deps(), write),
121
+ remove: async (storedName) => runKeyedRemove(await deps(), storedName),
122
+ };
123
+ }
124
+
125
+ /** Parse a raw stored string into the entry's value type: `text` passes through, `json` is validated. */
126
+ function parseValue(entry: SecretRegistryEntry, name: string, raw: string): unknown {
127
+ if (entry.valueType === "text") return raw;
128
+ let parsed: unknown;
129
+ try {
130
+ parsed = JSON.parse(raw);
131
+ } catch (cause) {
132
+ // Never echo the payload — it is sensitive credential material.
133
+ throw new SecretInvalidValueError(
134
+ { message: `Secret '${name}' is not valid JSON.`, detail: `json secret '${name}' failed to parse` },
135
+ { cause },
136
+ );
137
+ }
138
+ const result = entry.schema.safeParse(parsed);
139
+ if (!result.success) {
140
+ // Only path + code — never `issue.message`/`received`, which can echo the secret value.
141
+ const summary = result.error.issues.map((i) => `${i.path.join(".") || "<root>"}:${i.code}`).join(", ");
142
+ throw new SecretInvalidValueError({
143
+ message: `Secret '${name}' failed validation.`,
144
+ detail: `json secret '${name}' failed registry validation: ${summary}`,
145
+ });
146
+ }
147
+ return result.data;
148
+ }
149
+
150
+ /**
151
+ * A caller's value as the string that goes inside the envelope — the inverse of {@link parseValue},
152
+ * and the write side of the same trust boundary.
153
+ *
154
+ * A `json` value is validated against the entry's own schema and re-serialized **from the parsed
155
+ * data**, never from the object handed in: Zod strips what the schema does not declare, so a stray
156
+ * extra property is not sealed in to come back out at a read that trusts the envelope. A `text` value
157
+ * must actually be a string — the types say so, but the one caller this exists for is application code
158
+ * holding a freshly minted credential, and `String(value)` on the wrong thing seals `[object Object]`
159
+ * under a name that will be read as a key.
160
+ *
161
+ * The refusal carries issue paths and codes only, like the read: a Zod issue can echo the value.
162
+ */
163
+ function serializeValue(entry: SecretRegistryEntry, name: string, value: unknown): string {
164
+ if (entry.valueType === "text") {
165
+ if (typeof value !== "string") {
166
+ throw new SecretInvalidValueError({
167
+ message: `Secret '${name}' takes a string value.`,
168
+ detail: `text secret '${name}' was handed a ${typeof value}`,
169
+ });
170
+ }
171
+ return value;
172
+ }
173
+ const result = entry.schema.safeParse(value);
174
+ if (!result.success) {
175
+ const summary = result.error.issues.map((i) => `${i.path.join(".") || "<root>"}:${i.code}`).join(", ");
176
+ throw new SecretInvalidValueError({
177
+ message: `Secret '${name}' failed validation.`,
178
+ detail: `json secret '${name}' failed registry validation on write: ${summary}`,
179
+ });
180
+ }
181
+ return JSON.stringify(result.data);
182
+ }
183
+
184
+ /** Resolve a decrypted value envelope (a `d1` secret), parsing every version. */
185
+ function resolveVersioned(entry: SecretRegistryEntry, name: string, value: VersionedValue): Resolved {
186
+ const versions: Record<string, unknown> = {};
187
+ for (const [version, raw] of Object.entries(value.versions)) versions[version] = parseValue(entry, name, raw);
188
+ return { current: parseValue(entry, name, currentValue(value)), currentVersion: value.currentVersion, versions };
189
+ }
190
+
191
+ /**
192
+ * Decode a **`cf-secrets-store`** binding's value into the uniform envelope. The canonical (provisioned)
193
+ * value is a JSON-encoded {@link VersionedValue}, so a value pithy wrote round-trips as
194
+ * `{ currentVersion, versions }`; anything else is wrapped as a one-version envelope, and the same
195
+ * accessor path serves both.
196
+ *
197
+ * **The wrap is permanent, and it is not leniency left over from #149.** A Secrets Store entry has no
198
+ * envelope to find and never will: `pithy token mint` writes a raw token through `putSecret`, and an
199
+ * entry set by hand in the dashboard or by `wrangler secrets-store secret create` is a plain string too.
200
+ * Refusing one would refuse the two ways the platform's own tooling writes a secret.
201
+ *
202
+ * It is no longer reachable with a `d1` entry. A `d1` secret's stored shape is always an envelope and
203
+ * always comes from the row — in dev exactly as deployed since #153 — so there is nothing left here to
204
+ * reinterpret it as. See {@link secretsStore}'s `unprovisioned`, which is where a `d1` secret handed a
205
+ * binding instead of a row is now answered.
206
+ */
207
+ function decodeInjectedValue(raw: string): VersionedValue {
208
+ try {
209
+ return decodeVersionedValue(raw);
210
+ } catch {
211
+ // Not a serialized envelope — a raw Secrets Store entry. Wrap it as a single version.
212
+ return initialVersionedValue(raw);
213
+ }
214
+ }
215
+
216
+ /** Resolve a `cf-secrets-store` binding's value as the uniform envelope. */
217
+ function resolveInjected(entry: SecretRegistryEntry, name: string, raw: string): Resolved {
218
+ return resolveVersioned(entry, name, decodeInjectedValue(raw));
219
+ }
220
+
221
+ /**
222
+ * Who wrote a member, and what records it.
223
+ *
224
+ * **Writing a credential is an administrative act, so it is audited — but the capability cannot audit
225
+ * it alone.** `emit` is the request context's recorder (`c.var.emit`), which lives on the request, not
226
+ * on an accessor cached across them; and the actor is known to the application and to nothing else. An
227
+ * event this capability emitted by itself would say a per-tenant credential was written and be unable
228
+ * to say by whom, which is the one thing worth recording. So the shape and the action codes are the
229
+ * capability's — an adopter does not invent a code for the most sensitive write in the kit — and the
230
+ * emitter and the principal come from the call.
231
+ *
232
+ * Omit it and nothing is recorded. That is a deliberate choice a caller makes, not a default it can
233
+ * fall into unaware: a write that matters is a write with a call site, and the field is one line.
234
+ */
235
+ export interface KeyedWriteAudit {
236
+ /** The request context's recorder, `c.var.emit`. Non-fatal by contract, so it cannot break a write. */
237
+ emit: AuditEmit;
238
+ /** What kind of principal is writing — a signed-in operator, a service token, an internal job. */
239
+ actorType: AuditActorType;
240
+ /** That principal's stable id, when there is one. */
241
+ actorId?: string | null;
242
+ /** The session the write belongs to, tying it to the rest of that session's actions. */
243
+ sessionId?: string | null;
244
+ /** The request correlation id, tying the event to one request or trace. */
245
+ requestId?: string | null;
246
+ }
247
+
248
+ /** What a member write settled on. */
249
+ export interface KeyedWriteResult {
250
+ /**
251
+ * The member's current version key after the write — `"1"` for a create or a replace, the appended
252
+ * one after a rotation. The pointer `getKeyed` will now resolve, so a caller can record which
253
+ * version of a tenant's credential it just handed out.
254
+ */
255
+ currentVersion: string;
256
+ }
257
+
258
+ /** Options for {@link SecretsAccessor.putKeyed}. */
259
+ export interface KeyedPutOptions {
260
+ /**
261
+ * Discard whatever is stored for this key and write the value as a fresh, single-version member.
262
+ *
263
+ * Off by default, and the default is the point. Create-or-replace would make "overwrite this
264
+ * tenant's signing key, losing the one still in use" a typo away, and the loss is silent and total —
265
+ * the old key is gone, so every token already signed with it stops verifying and nothing says why.
266
+ * Adding a key while the old one still works is {@link SecretsAccessor.rotateKeyed}; this is for the
267
+ * case where the stored value must *not* survive, a leaked credential being the whole of it.
268
+ */
269
+ replace?: boolean;
270
+ /** Record the write in the audit trail. */
271
+ audit?: KeyedWriteAudit;
272
+ }
273
+
274
+ /** Options for {@link SecretsAccessor.rotateKeyed} and {@link SecretsAccessor.deleteKeyed}. */
275
+ export interface KeyedWriteOptions {
276
+ /** Record the write in the audit trail. */
277
+ audit?: KeyedWriteAudit;
278
+ }
279
+
280
+ /**
281
+ * The resolved, typed accessor. The named methods are synchronous — every value was materialized by
282
+ * `secretsStore`; the keyed ones are not, because a keyspace member is fetched at the read. Resolved
283
+ * plaintext lives in `#private` fields, and `toJSON` redacts, so a stray `logger.info({ secrets })`
284
+ * surfaces only the count.
285
+ */
286
+ export class SecretsAccessor<R extends SecretRegistry> {
287
+ readonly #registry: R;
288
+ readonly #resolved: Record<string, Resolved>;
289
+ readonly #keyed: KeyedSecretIO | undefined;
290
+ readonly #failures: Record<string, Error>;
291
+
292
+ /**
293
+ * `failures` holds the error each unresolved secret raises when *it* is read — see the module note
294
+ * on #170. It is last and optional because almost nothing constructs one: `secretsStore` does, and
295
+ * a test that hands over already-resolved values has no failures to carry.
296
+ */
297
+ constructor(
298
+ registry: R,
299
+ resolved: Record<string, Resolved>,
300
+ keyed?: KeyedSecretIO,
301
+ failures: Record<string, Error> = {},
302
+ ) {
303
+ this.#registry = registry;
304
+ this.#resolved = resolved;
305
+ this.#keyed = keyed;
306
+ this.#failures = failures;
307
+ }
308
+
309
+ /** The current value of a declared secret. */
310
+ get<K extends SecretName<R>>(name: K): SecretValue<R[K]> {
311
+ return this.#require(name).current as SecretValue<R[K]>;
312
+ }
313
+
314
+ /** The current pointer plus every still-valid version of a declared secret. */
315
+ getVersions<K extends SecretName<R>>(name: K): VersionedSecret<R[K]> {
316
+ const resolved = this.#require(name);
317
+ return {
318
+ currentVersion: resolved.currentVersion,
319
+ versions: resolved.versions as Record<string, SecretValue<R[K]>>,
320
+ };
321
+ }
322
+
323
+ /**
324
+ * The current value of one member of a declared keyspace — the credential `key` names. Async
325
+ * because it is fetched now: a keyspace is unbounded, so nothing was resolved for it up front.
326
+ */
327
+ async getKeyed<K extends KeyedSecretName<R>>(name: K, key: string): Promise<SecretValue<R[K]>> {
328
+ return (await this.#loadKeyed(name, key)).current as SecretValue<R[K]>;
329
+ }
330
+
331
+ /**
332
+ * The current pointer plus every still-valid version of one keyspace member — what a verifier needs
333
+ * mid-rotation, when a tenant's retired key must still be honored alongside its new one.
334
+ */
335
+ async getKeyedVersions<K extends KeyedSecretName<R>>(name: K, key: string): Promise<VersionedSecret<R[K]>> {
336
+ const resolved = await this.#loadKeyed(name, key);
337
+ return {
338
+ currentVersion: resolved.currentVersion,
339
+ versions: resolved.versions as Record<string, SecretValue<R[K]>>,
340
+ };
341
+ }
342
+
343
+ /**
344
+ * Store one member of a declared keyspace — the credential `key` names — sealed through the same
345
+ * envelope and bound to the same `<keyspace>/<key>` context as every other secret.
346
+ *
347
+ * **Create-only by default.** An existing member is refused with `secrets/already_exists`; pass
348
+ * `{ replace: true }` to discard it. See {@link KeyedPutOptions.replace} for why that is not the
349
+ * default, and {@link rotateKeyed} for adding a key while the old one still verifies.
350
+ *
351
+ * The returned promise resolving is the persistence guarantee — the row is written, sealed under
352
+ * the current master key, before it settles. That is the whole reason this exists rather than a
353
+ * Workflow dispatch: a connect flow can only return a public half once the private half is stored,
354
+ * and "stored, probably, shortly" is a credential the customer discovers is broken later.
355
+ */
356
+ async putKeyed<K extends KeyedSecretName<R>>(
357
+ name: K,
358
+ key: string,
359
+ value: SecretValue<R[K]>,
360
+ options: KeyedPutOptions = {},
361
+ ): Promise<KeyedWriteResult> {
362
+ return await this.#writeKeyed(name, key, value, options.replace ? "replace" : "create", options.audit);
363
+ }
364
+
365
+ /**
366
+ * Add a new current value for one member, keeping every prior version valid — a tenant's key
367
+ * rotation, on the request path.
368
+ *
369
+ * This is the write that makes {@link getKeyedVersions} mean something: a verifier mid-rotation has
370
+ * to honor the retired key alongside the new one, and nothing else can produce that state. The
371
+ * keyspace must be declared `rotatable`, which is where that axis stops being forward-looking
372
+ * metadata — a keyspace that says it does not accumulate versions is not quietly made to.
373
+ *
374
+ * **Versions accumulate, and nothing prunes them yet.** Retiring a version after a grace window is
375
+ * the deferred half of value rotation (`crypto/versionedValue`), so every rotation makes the
376
+ * member's envelope larger and every read of it decrypts and parses the lot. That is fine for a
377
+ * credential rotated quarterly and wrong for one rotated hourly. Until pruning lands, a caller with
378
+ * a fast cadence collapses the member with `putKeyed(..., { replace: true })` once the retired key
379
+ * is genuinely dead.
380
+ */
381
+ async rotateKeyed<K extends KeyedSecretName<R>>(
382
+ name: K,
383
+ key: string,
384
+ value: SecretValue<R[K]>,
385
+ options: KeyedWriteOptions = {},
386
+ ): Promise<KeyedWriteResult> {
387
+ return await this.#writeKeyed(name, key, value, "rotate", options.audit);
388
+ }
389
+
390
+ /**
391
+ * Remove one member — every version of it, and its rotation history — as one operation.
392
+ *
393
+ * A tenant leaves once. Leaving a version behind because a caller looped and stopped early is the
394
+ * failure this signature exists to make impossible: a member is one row holding one envelope, so
395
+ * there is no partial state to reach.
396
+ *
397
+ * Idempotent, and it never reports whether anything was there. Deleting a member that does not
398
+ * exist is a retry, not an error — and an error that distinguished the two would answer "does this
399
+ * tenant have a credential" to anyone who could call it.
400
+ */
401
+ async deleteKeyed<K extends KeyedSecretName<R>>(
402
+ name: K,
403
+ key: string,
404
+ options: KeyedWriteOptions = {},
405
+ ): Promise<void> {
406
+ // Checked even though nothing here reads the entry: a delete against an undeclared name, or
407
+ // against a named secret, is the same author error it is on a read and answers the same way.
408
+ this.#keyspace(name);
409
+ const io = this.#requireKeyedIO(name);
410
+ const storedName = keyedSecretName(name, key);
411
+ try {
412
+ await io.remove(storedName);
413
+ } catch (error) {
414
+ await this.#auditKeyed(options.audit, SecretsAuditActions.memberRemoved, "failure", name, key, storedName);
415
+ throw error;
416
+ }
417
+ await this.#auditKeyed(options.audit, SecretsAuditActions.memberRemoved, "success", name, key, storedName);
418
+ }
419
+
420
+ /**
421
+ * A typed view over a subset of this accessor, restricted to `registry`'s names and sharing the
422
+ * already-resolved values — no re-fetch. Used by the shared per-invocation accessor: the combined
423
+ * registry is resolved once, then each capability gets a precisely-typed accessor over only its own
424
+ * slice. A name in `registry` that this accessor never resolved is simply absent, so a later
425
+ * `get`/`getVersions` fails loudly as `secrets/not_found` rather than returning a silent `undefined`.
426
+ *
427
+ * A held failure travels with its name and no further. That is the whole point of the slice: the
428
+ * view a capability gets carries the errors of its own secrets, and cannot be tripped by a
429
+ * neighbor's unset one.
430
+ *
431
+ * `keyed` defaults to this accessor's own. The shared store passes the current invocation's
432
+ * instead: it hands out views over an accessor cached across requests, and a keyspace read or write
433
+ * is real I/O, which must run through the binding of the request making it — not of whichever
434
+ * request happened to fill the cache.
435
+ */
436
+ subset<R2 extends SecretRegistry>(registry: R2, keyed: KeyedSecretIO | undefined = this.#keyed): SecretsAccessor<R2> {
437
+ const resolved: Record<string, Resolved> = {};
438
+ const failures: Record<string, Error> = {};
439
+ for (const name of Object.keys(registry)) {
440
+ const value = this.#resolved[name];
441
+ if (value) resolved[name] = value;
442
+ const failure = this.#failures[name];
443
+ if (failure) failures[name] = failure;
444
+ }
445
+ return new SecretsAccessor(registry, resolved, keyed, failures);
446
+ }
447
+
448
+ /**
449
+ * Resolve one keyspace member. The key is validated and composed by {@link keyedSecretName} — the
450
+ * one place a member name is ever built — so a key can neither escape into a neighboring keyspace
451
+ * nor land on a named entry. A member with no stored value throws instead of resolving `undefined`.
452
+ *
453
+ * Every failure names the keyspace and never the key: `detail` reaches logs verbatim, and the key
454
+ * is caller input identifying one tenant.
455
+ */
456
+ async #loadKeyed(name: string, key: string): Promise<Resolved> {
457
+ const entry = this.#keyspace(name);
458
+ const io = this.#requireKeyedIO(name);
459
+ const stored = await io.read(keyedSecretName(name, key));
460
+ if (!stored) {
461
+ throw new SecretNotFoundError({
462
+ message: `Secret '${name}' has no value for that key.`,
463
+ detail: `keyspace '${name}': no member stored under the requested key`,
464
+ });
465
+ }
466
+ // Parsed against the keyspace's name, not the member's, so a malformed member says which keyspace
467
+ // it belongs to without putting a tenant identifier in the log.
468
+ return resolveVersioned(entry, name, stored);
469
+ }
470
+
471
+ /**
472
+ * Seal and store one member. The shared body of `putKeyed` and `rotateKeyed`, because everything
473
+ * except the mode is identical and the ordering is the part that has to be.
474
+ *
475
+ * The order is: prove the keyspace, prove the key, validate the value, *then* touch the store. A
476
+ * traversing key and a value that fails its schema are both refused before anything is written, and
477
+ * neither is audited — nothing was attempted against the store, and a rejected key is unvalidated
478
+ * input that has no business in a trail field that means "tenant".
479
+ *
480
+ * Everything from the store attempt onward is audited with its outcome, refusals included. A connect
481
+ * flow that tried to overwrite a tenant's live signing key and was refused is exactly the line an
482
+ * operator wants to find later.
483
+ *
484
+ * Both callers `return await` this rather than returning it. A guard here refuses before awaiting
485
+ * anything, so the promise is already rejected when it is handed back, and an adopted one does not
486
+ * pick up its handler until the next tick — which workerd reports as an unhandled rejection.
487
+ */
488
+ async #writeKeyed(
489
+ name: string,
490
+ key: string,
491
+ value: unknown,
492
+ mode: KeyedWriteMode,
493
+ audit: KeyedWriteAudit | undefined,
494
+ ): Promise<KeyedWriteResult> {
495
+ const entry = this.#keyspace(name);
496
+ if (mode === "rotate" && !entry.rotatable) {
497
+ throw new InternalError({
498
+ message: `Secret '${name}' does not accumulate versions.`,
499
+ action:
500
+ "Declare the keyspace rotatable, or replace the member with putKeyed(name, key, value, { replace: true }).",
501
+ detail: `rotateKeyed called on keyspace '${name}', which is declared rotatable: false`,
502
+ });
503
+ }
504
+ const io = this.#requireKeyedIO(name);
505
+ const storedName = keyedSecretName(name, key);
506
+ const serialized = serializeValue(entry, name, value);
507
+
508
+ const action = mode === "rotate" ? SecretsAuditActions.memberRotated : SecretsAuditActions.memberWritten;
509
+ try {
510
+ const currentVersion = await io.write({
511
+ keyspace: name,
512
+ storedName,
513
+ mode,
514
+ value: serialized,
515
+ valueType: entry.valueType,
516
+ rotatable: entry.rotatable,
517
+ });
518
+ await this.#auditKeyed(audit, action, "success", name, key, storedName, mode);
519
+ return { currentVersion };
520
+ } catch (error) {
521
+ await this.#auditKeyed(audit, action, "failure", name, key, storedName, mode);
522
+ throw error;
523
+ }
524
+ }
525
+
526
+ /** The declared keyspace `name` names, or the author error that says why it is not one. */
527
+ #keyspace(name: string): SecretRegistryEntry {
528
+ const entry = this.#registry[name];
529
+ if (!entry) {
530
+ throw new SecretNotFoundError({ detail: `keyspace '${name}' is not declared in this registry` });
531
+ }
532
+ if (!entry.keyed) {
533
+ throw new InternalError({
534
+ message: `Secret '${name}' is not a keyspace.`,
535
+ action: "Read a named secret with get(name), or declare the entry keyed.",
536
+ detail: `a keyspace operation was attempted on named secret '${name}'`,
537
+ });
538
+ }
539
+ return entry;
540
+ }
541
+
542
+ /** The keyed I/O this accessor was built with, or the wiring error that says it has none. */
543
+ #requireKeyedIO(name: string): KeyedSecretIO {
544
+ if (!this.#keyed) {
545
+ throw new InternalError({
546
+ message: `Secret '${name}' cannot be reached here.`,
547
+ detail: `keyspace '${name}' used through an accessor built without keyed I/O`,
548
+ });
549
+ }
550
+ return this.#keyed;
551
+ }
552
+
553
+ /**
554
+ * Record one member write in the audit trail, when the caller supplied a recorder.
555
+ *
556
+ * The key is the `tenant` dimension — the field that exists so a trail can be read per customer —
557
+ * and it is legitimate there precisely because it has already been validated and composed into a
558
+ * stored name. Nothing about the value is here, and nothing about the value is available to put
559
+ * here: the accessor holds the plaintext for the length of one seal and never in a field this
560
+ * method can reach.
561
+ *
562
+ * `emit` never throws by contract, so a write is never broken by its own audit.
563
+ */
564
+ async #auditKeyed(
565
+ audit: KeyedWriteAudit | undefined,
566
+ action: SecretsAuditAction,
567
+ outcome: AuditOutcome,
568
+ name: string,
569
+ key: string,
570
+ storedName: string,
571
+ mode?: KeyedWriteMode,
572
+ ): Promise<void> {
573
+ if (!audit) return;
574
+ await audit.emit({
575
+ action,
576
+ outcome,
577
+ // A routine per-tenant credential write is routine. A refused one is notable — it means
578
+ // something tried to write over a credential that is presumably in use.
579
+ severity: outcome === "success" ? "info" : "warning",
580
+ actorType: audit.actorType,
581
+ actorId: audit.actorId,
582
+ sessionId: audit.sessionId,
583
+ requestId: audit.requestId,
584
+ resourceType: "secret",
585
+ resourceId: storedName,
586
+ tenant: key,
587
+ metadata: mode ? { keyspace: name, mode } : { keyspace: name },
588
+ });
589
+ }
590
+
591
+ #require(name: string): Resolved {
592
+ if (!(name in this.#registry)) {
593
+ throw new SecretNotFoundError({ detail: `secret '${name}' is not declared in this registry` });
594
+ }
595
+ if (this.#registry[name]?.keyed) {
596
+ throw new InternalError({
597
+ message: `Secret '${name}' is a keyspace, not a single value.`,
598
+ action: "Read one member with getKeyed(name, key).",
599
+ detail: `get called on keyspace '${name}'`,
600
+ });
601
+ }
602
+ // The failure this secret's own resolution held. Raised here, at its read, and nowhere else.
603
+ const failure = this.#failures[name];
604
+ if (failure) throw failure;
605
+ const resolved = this.#resolved[name];
606
+ if (!resolved) {
607
+ throw new SecretNotFoundError({
608
+ message: `Secret '${name}' is declared but not provisioned.`,
609
+ detail: `secret '${name}' declared but absent from the resolved batch`,
610
+ });
611
+ }
612
+ return resolved;
613
+ }
614
+
615
+ /** Redacted serialization — never the values. */
616
+ toJSON(): string {
617
+ return `[Secrets declared=${Object.keys(this.#registry).length}]`;
618
+ }
619
+ }
620
+
621
+ /**
622
+ * Resolve every secret declared in `registry` from `env` and return a typed accessor. **Routing is by
623
+ * registry backend and nothing else**, in every environment: `d1` entries are decrypted in one batch
624
+ * from the per-environment store, `cf-secrets-store` entries are read from their bound values. A keyed
625
+ * entry resolves nothing here — it declares a keyspace, not a value — and its members are fetched at
626
+ * the read.
627
+ *
628
+ * **This never rejects because a secret is missing (#170).** A secret that cannot be resolved — no row,
629
+ * no binding, a row that will not decrypt, a value that fails its schema — holds its error, and the read
630
+ * of that secret raises it. A missing secret still fails loudly and still names itself; it just stops
631
+ * taking every other capability's secrets down with it. Reject only on something that is nobody's secret
632
+ * in particular.
633
+ *
634
+ * **The unreadable row is in that list since #384, and was not before.** It threw out of the batch decrypt,
635
+ * so it was held against every `d1` name at once — #170's promise held for a *missing* row and not for an
636
+ * *unreadable* one, which is narrower than the sentence reads and narrower in the direction nobody guesses.
637
+ *
638
+ * Nothing reads `ENVIRONMENT`. A `d1` secret cannot be shadowed by a plaintext binding anywhere, which
639
+ * used to be true only of deployed workers.
640
+ */
641
+ export async function secretsStore<R extends SecretRegistry>(
642
+ env: SecretsStoreEnv,
643
+ registry: R,
644
+ ): Promise<SecretsAccessor<R>> {
645
+ const resolved: Record<string, Resolved> = {};
646
+ const failures: Record<string, Error> = {};
647
+ const bindings = env as unknown as Record<string, SecretBinding | string | undefined>;
648
+
649
+ const d1Names: string[] = [];
650
+ const cfNames: string[] = [];
651
+ for (const name of Object.keys(registry)) {
652
+ const entry = registry[name];
653
+ // Keyspaces are skipped: batching one would mean decrypting every tenant's credential to serve a
654
+ // request that wants one, which is the shape this entry type exists to avoid.
655
+ if (!entry || entry.keyed) continue;
656
+ if (entry.backend === "cf-secrets-store") cfNames.push(name);
657
+ else d1Names.push(name);
658
+ }
659
+
660
+ if (d1Names.length > 0) {
661
+ let values: Record<string, StoredSecretValue> = {};
662
+ let storeFailure: Error | undefined;
663
+ try {
664
+ values = await (await SystemSecretsStore.fromEnv(env)).getValues(d1Names);
665
+ } catch (error) {
666
+ // The store itself is unreachable — no `SECRETS` D1, or no master key. That is fatal for every
667
+ // `d1` secret and for none of the others, so it is held against each `d1` name rather than
668
+ // thrown: a `cf-secrets-store` secret in another capability is unaffected and still reads.
669
+ //
670
+ // **This is now the only thing that reaches here, and that is the #384 fix.** A row that would not
671
+ // decrypt used to throw out of `getValues` and land in this `catch`, so one unreadable secret was
672
+ // held against every `d1` name in the registry while the store was fine and every other row opened.
673
+ // Per-row outcomes come back on the value below; what is left here is genuinely nobody's secret in
674
+ // particular.
675
+ storeFailure = held(error);
676
+ }
677
+ for (const name of d1Names) {
678
+ const entry = registry[name];
679
+ if (!entry) continue;
680
+ if (storeFailure) {
681
+ failures[name] = storeFailure;
682
+ continue;
683
+ }
684
+ const value = values[name];
685
+ try {
686
+ // Three facts, three sentences. No row at all is unprovisioned. A row that would not open is
687
+ // `secrets/crypto_failed` against this name and no other — a different remedy, so a different
688
+ // sentence. Anything else resolves.
689
+ if (!value) throw unprovisioned(name, isBound(bindings, name));
690
+ if (value.state === "unreadable") throw unreadableSecret(name);
691
+ resolved[name] = resolveVersioned(entry, name, value.value);
692
+ } catch (error) {
693
+ failures[name] = held(error);
694
+ }
695
+ }
696
+ }
697
+
698
+ for (const name of cfNames) {
699
+ const entry = registry[name];
700
+ if (!entry) continue;
701
+ try {
702
+ resolved[name] = resolveInjected(entry, name, await resolveBinding(bindings[name], name));
703
+ } catch (error) {
704
+ failures[name] = held(error);
705
+ }
706
+ }
707
+
708
+ return new SecretsAccessor(registry, resolved, d1KeyedIO(env), failures);
709
+ }
710
+
711
+ /**
712
+ * A caught value as the `Error` a later read will re-throw. Anything thrown by a resolver is already a
713
+ * `PithyError`; the wrap is for the impossible case, so a held failure is never a bare string that
714
+ * `throw` would surface without a code. The thrown value is kept as `cause` and never stringified into
715
+ * `detail` — `detail` reaches logs verbatim, and nothing here has proved what that value holds.
716
+ */
717
+ function held(error: unknown): Error {
718
+ if (error instanceof Error) return error;
719
+ return new InternalError(
720
+ { message: "Secret resolution failed.", detail: "secret resolution threw a non-error value" },
721
+ { cause: error },
722
+ );
723
+ }
724
+
725
+ /**
726
+ * Whether a binding of this name is present and carries something — the one question a `d1` secret with
727
+ * no row has to ask before it says what is wrong.
728
+ *
729
+ * `Object.hasOwn`, never `in`: `env` is an ordinary object, so `in` finds `constructor` and `toString`
730
+ * on the prototype and would report a binding for a secret a capability chose to name either one.
731
+ */
732
+ function isBound(bindings: Record<string, SecretBinding | string | undefined>, name: string): boolean {
733
+ if (!Object.hasOwn(bindings, name)) return false;
734
+ const value = bindings[name];
735
+ if (typeof value === "string") return value !== "";
736
+ return typeof value?.get === "function";
737
+ }
738
+
739
+ /**
740
+ * What a declared `d1` secret with no row is told — and it is two different sentences, because it is two
741
+ * different mistakes.
742
+ *
743
+ * With no binding it is the plain case: nothing has provisioned it. With a binding of the same name it
744
+ * is the #153 case, and the binding is the reason the reader is confused rather than a place to fall
745
+ * back to. Dev used to resolve exactly that string, so this is the one shape an upgrade produces: an
746
+ * adopter's pre-#149 `.dev.vars` line, or a Workers-runtime test injecting a `d1` value as a bare
747
+ * string. Reading it would put back the asymmetry this change removed; saying nothing about it would
748
+ * answer "not provisioned" about a value sitting right there. So it is named, with the fix.
749
+ *
750
+ * `validation/invalid_input` rather than `secrets/not_found`: the store is not missing a value, the
751
+ * caller supplied one in a place it is never read from. Never echoes the value.
752
+ */
753
+ function unprovisioned(name: string, bound: boolean): Error {
754
+ if (!bound) {
755
+ return new SecretNotFoundError({
756
+ message: `Secret '${name}' is declared but not provisioned.`,
757
+ detail: `d1 secret '${name}' has no row in the secrets store`,
758
+ });
759
+ }
760
+ return new ValidationError({
761
+ message: `Secret '${name}' is bound as a plain value, and a d1 secret is never read from a binding.`,
762
+ action: `Put '${name}' in the dev secrets file and run pithy seed. Deployed environments get it from pithy secrets create.`,
763
+ detail: `d1 secret '${name}' has no row in the secrets store, and a binding of the same name was ignored`,
764
+ });
765
+ }