@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,447 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
5
+ import { z } from "zod";
6
+ import {
7
+ currentValue,
8
+ encodeVersionedValue,
9
+ initialVersionedValue,
10
+ type VersionedValue,
11
+ } from "../crypto/versionedValue";
12
+ import { mintSecretValue } from "../mintValue";
13
+ import type { SecretRegistry, SecretRegistryEntry, SecretValueType } from "../registry";
14
+ import {
15
+ DEV_SECRETS_FILE,
16
+ DevSecretEnvelope,
17
+ type DevSecretsFile,
18
+ describeNotEnvelope,
19
+ ENVELOPE_SHAPE,
20
+ initialDevSecret,
21
+ } from "./devSecretsFile";
22
+
23
+ /**
24
+ * Seed a project's local dev secrets from the dev secrets file, with the **registry** deciding each
25
+ * secret's destination. The file states values; it never states where one goes. The registry already
26
+ * knows `backend`, so there is one source of truth and the two cannot disagree.
27
+ *
28
+ * `d1` → an encrypted row in the local `SECRETS` D1, under the dev master key, in
29
+ * exactly the shape a provisioned secret has.
30
+ * `cf-secrets-store` → returned as a `.dev.vars` line. There is no local Secrets Store, and the
31
+ * binding is the only place a worker can read it from in dev.
32
+ *
33
+ * **Nothing here writes a file.** `.dev.vars` belongs to the CLI, which owns file modes, the shared
34
+ * symlink, and not clobbering an adopter's hand-written lines. This returns what should be written —
35
+ * the same rule the loader follows for reading.
36
+ *
37
+ * **Idempotent, and it does not rotate.** A secret already stored with the value the file states is
38
+ * left untouched (no re-encrypt, no `updatedAt` churn); a secret the file has changed is written
39
+ * through, because the file is the source of truth for dev. A secret already *in the file* is never
40
+ * minted again — a fresh session key invalidates every live session, and a fresh link key breaks
41
+ * every link already in an inbox.
42
+ */
43
+
44
+ /**
45
+ * The store seam — the two operations seeding needs from the per-environment encrypted D1 store.
46
+ * `SystemSecretsStore` satisfies it structurally, so the CLI passes the real one and tests pass an
47
+ * in-memory double without a D1.
48
+ */
49
+ export interface DevSecretsStore {
50
+ /** The stored envelope for one secret, or `undefined` when nothing is stored under that name. */
51
+ getValue(name: string): Promise<VersionedValue | undefined>;
52
+ /** Encrypt and upsert one secret's envelope under the master key. */
53
+ put(name: string, value: VersionedValue, valueType: SecretValueType): Promise<void>;
54
+ }
55
+
56
+ /** What {@link seedDevSecrets} needs: the loaded file, the project's registry, and the local store. */
57
+ export interface SeedDevSecretsInput {
58
+ /** The parsed dev secrets file, as `loadDevSecrets` returns it. */
59
+ file: DevSecretsFile;
60
+ /** The project's combined secret registry — the authority on backend, value type, and schema. */
61
+ registry: SecretRegistry;
62
+ /** The local `SECRETS` D1 store, for `d1`-backed secrets. */
63
+ store: DevSecretsStore;
64
+ /** The absolute path to name in errors. Defaults to the file's bare name — see {@link DEV_SECRETS_FILE}. */
65
+ path?: string;
66
+ }
67
+
68
+ /** What one seed run did, and what the CLI must write. Every list is sorted, so a run is reproducible. */
69
+ export interface DevSecretsSeedResult {
70
+ /** `d1` secrets written to the store this run — new, or changed in the file. */
71
+ seeded: readonly string[];
72
+ /** `d1` secrets already stored with the value the file states. Left untouched. */
73
+ unchanged: readonly string[];
74
+ /** `cf-secrets-store` secrets, as the `.dev.vars` lines the CLI should write. Never a file write here. */
75
+ devVars: Readonly<Record<string, string>>;
76
+ /** Values minted this run, for the CLI to write back into the dev secrets file as version-1 envelopes. */
77
+ minted: DevSecretsFile;
78
+ /** Declared secrets with no value and nothing honest to mint — the CLI names them and says where they come from. */
79
+ missing: readonly string[];
80
+ /** Names in the file that no capability declares. Reported, not fatal: a removed capability must not brick dev. */
81
+ undeclared: readonly string[];
82
+ }
83
+
84
+ /**
85
+ * Seed every declared secret. Throws `validation/invalid_input` — naming the secret, never echoing
86
+ * its value — when the file and the registry disagree about a value's shape.
87
+ */
88
+ export async function seedDevSecrets(input: SeedDevSecretsInput): Promise<DevSecretsSeedResult> {
89
+ const { file, registry, store } = input;
90
+ const path = input.path ?? DEV_SECRETS_FILE;
91
+
92
+ const seeded: string[] = [];
93
+ const unchanged: string[] = [];
94
+ const missing: string[] = [];
95
+ const devVars: Record<string, string> = {};
96
+ // Every mint happens here, before a single store write, so a caller that wants to persist the file
97
+ // first can do exactly that: mint, write, then seed with the values already on disk. The CLI does.
98
+ const minted = mintMissingDevSecrets(file, registry);
99
+
100
+ for (const name of Object.keys(registry).sort()) {
101
+ const entry = registry[name];
102
+ if (!entry) continue;
103
+ // A keyspace declares an unbounded set of members whose keys exist only at runtime. It has no one
104
+ // value to seed, and the app writes its members itself — so it is neither seeded nor missing.
105
+ if (entry.keyed) {
106
+ if (file[name]) throw keyedSecretRefusal(name, path);
107
+ continue;
108
+ }
109
+
110
+ // Present-in-the-file always wins, whatever is stored and whatever was minted above.
111
+ const envelope = file[name] ?? minted[name];
112
+ if (!envelope) {
113
+ missing.push(name);
114
+ continue;
115
+ }
116
+
117
+ const secret = devSecretPayload(entry, name, envelope, path);
118
+
119
+ if (entry.backend === "cf-secrets-store") {
120
+ devVars[name] = secret.text;
121
+ continue;
122
+ }
123
+
124
+ const stored = await store.getValue(name);
125
+ if (stored && sameEnvelope(stored, secret.stored)) {
126
+ unchanged.push(name);
127
+ continue;
128
+ }
129
+ await store.put(name, secret.stored, entry.valueType);
130
+ seeded.push(name);
131
+ }
132
+
133
+ // `Object.hasOwn`, not `in`: `in` walks the prototype chain, so a stale `toString` left in the file
134
+ // read as declared by every capability and was never reported.
135
+ const undeclared = Object.keys(file)
136
+ .filter((name) => !Object.hasOwn(registry, name))
137
+ .sort();
138
+
139
+ return { seeded, unchanged, devVars, minted, missing, undeclared };
140
+ }
141
+
142
+ /**
143
+ * The refusal a keyspace given a single value in the dev secrets file earns.
144
+ *
145
+ * **A function rather than a `throw` written inline, because `pithy doctor` has to be able to say this
146
+ * without running a seed (#325).** Doctor's promise is that a green report means the next `pithy seed`
147
+ * works, and it kept that promise for every *stated* value by judging through {@link storedSecretValue} —
148
+ * but a keyspace never reached that call in either command, so the one input the seeder hard-fails on was
149
+ * the one input doctor passed. A second wording of the same rule is how the promise stops being true
150
+ * without anybody noticing; there is one wording, and it lives here beside the throw.
151
+ */
152
+ export function keyedSecretRefusal(name: string, path: string): ValidationError {
153
+ return new ValidationError({
154
+ message: `Secret '${name}' in ${path} is a keyspace, not a single value.`,
155
+ action: "Remove it. Its members are written by the app at runtime, one per key.",
156
+ detail: `dev secrets file '${path}': keyed entry '${name}' given a value`,
157
+ });
158
+ }
159
+
160
+ /**
161
+ * Every `cf-secrets-store` secret the file states, as the `.dev.vars` lines a Worker reads them from.
162
+ *
163
+ * **The one materialisation, so the seeder's report and the generated file cannot disagree.** Dev has no
164
+ * Secrets Store, so a binding is the only place one of these can come from — and since the generator
165
+ * builds each Worker's `.dev.vars` from the dev secrets file directly (#179), the generator and the
166
+ * seeder are two callers of this rather than two copies of it.
167
+ *
168
+ * A secret with no value in the file is simply absent, exactly as a Worker with no binding is: this
169
+ * answers what *can* be materialised, and {@link seedDevSecrets} is what reports the rest as missing.
170
+ */
171
+ export function devVarsForRegistry(
172
+ file: DevSecretsFile,
173
+ registry: SecretRegistry,
174
+ path: string = DEV_SECRETS_FILE,
175
+ ): Record<string, string> {
176
+ const devVars: Record<string, string> = {};
177
+ for (const name of Object.keys(registry).sort()) {
178
+ const entry = registry[name];
179
+ if (!entry || entry.keyed || entry.backend !== "cf-secrets-store") continue;
180
+ const stated = file[name];
181
+ if (!stated) continue;
182
+ devVars[name] = devSecretPayload(entry, name, stated, path).text;
183
+ }
184
+ return devVars;
185
+ }
186
+
187
+ /**
188
+ * Every secret the registry says may be minted and the file does not already carry, minted — and
189
+ * **nothing written anywhere**. The one place that decides what a mint is, so `seedDevSecrets` and a
190
+ * caller that needs the values before seeding cannot drift into two rules.
191
+ *
192
+ * That ordering is the point of exporting it. The CLI mints, persists the dev secrets file, then
193
+ * seeds: a store write that lands before the file does leaves a row no file explains, and the next
194
+ * run mints a different value and overwrites it — a session secret that changes on every `pithy dev`
195
+ * for as long as the file write keeps failing.
196
+ *
197
+ * A keyspace is skipped: its members exist only at runtime, so there is no one value to mint. A secret
198
+ * with no `devValue` is skipped too — nothing here invents a value something outside the project has
199
+ * to agree with.
200
+ */
201
+ export function mintMissingDevSecrets(file: DevSecretsFile, registry: SecretRegistry): DevSecretsFile {
202
+ const minted: DevSecretsFile = {};
203
+ for (const name of Object.keys(registry).sort()) {
204
+ const entry = registry[name];
205
+ if (!entry || entry.keyed || !entry.devValue) continue;
206
+ if (Object.hasOwn(file, name)) continue;
207
+ minted[name] = initialDevSecret(entry, mintSecretValue(entry.devValue));
208
+ }
209
+ return minted;
210
+ }
211
+
212
+ /**
213
+ * One secret as the file states it, in every form anything downstream needs. **The kit's only reading
214
+ * of a dev secrets payload** — see the rule at the top of `./devSecretsFile`.
215
+ *
216
+ * The registry entry says which payload the name takes, and every form below is derived from that one
217
+ * decision. Four callers used to answer it four times: the seeder, the `.dev.vars` generator, `pithy
218
+ * doctor` and the prepared-set reader, plus `bindingValue()` sitting between them switching on
219
+ * `bootstrap` — and #323 is what that cost.
220
+ *
221
+ * Named for what it is rather than for the file it came from: `DevSecret` is already a capability's
222
+ * *declaration* that a value may be minted (`core/src/capability/devSecret`), and two things called that
223
+ * is how a reader stops being able to search for either.
224
+ */
225
+ export interface StatedSecret {
226
+ /**
227
+ * The payload the destination receives, before serialization — what the file states, with a `json`
228
+ * value's structure intact. An envelope for an ordinary secret; the value itself for a `bootstrap`
229
+ * one.
230
+ */
231
+ readonly payload: unknown;
232
+ /**
233
+ * That payload as the string a binding or a Secrets Store entry carries. **The one materialisation**
234
+ * — `.dev.vars` locally, `secrets_store_secrets` deployed, and a minted store entry all read it here.
235
+ */
236
+ readonly text: string;
237
+ /** The envelope the D1 store holds. A `bootstrap` secret never reaches D1; its envelope is synthetic. */
238
+ readonly stored: VersionedValue;
239
+ /** The current version's value, as the runtime resolves it — a `json` secret's canonical serialization. */
240
+ readonly value: string;
241
+ /**
242
+ * Whether the file stated the **old wrapped shape** and this reading unwrapped it (#323).
243
+ *
244
+ * True only for a `bootstrap` secret written by a pithy older than this one. It is what makes the
245
+ * upgrade automatic rather than a hand-edit: `migrateDevSecrets` collects these, and the CLI writes
246
+ * the payload back. Reading keeps working either way, which is the order this change had to ship in
247
+ * — a reader that demanded the new shape would stop every project that has not been rewritten yet.
248
+ */
249
+ readonly wrapped: boolean;
250
+ }
251
+
252
+ /**
253
+ * Read one secret's stated entry against its registry entry, and return every form of it.
254
+ *
255
+ * Throws `validation/invalid_input` naming the secret, the file, and the shape expected — never a
256
+ * value. Errors carry only the secret name, the version key, and Zod `path:code` pairs. Never
257
+ * `issue.message` or `received`, either of which can echo credential material into a terminal or a log.
258
+ */
259
+ export function devSecretPayload(
260
+ entry: SecretRegistryEntry,
261
+ name: string,
262
+ stated: unknown,
263
+ path: string = DEV_SECRETS_FILE,
264
+ ): StatedSecret {
265
+ if (entry.bootstrap === true) {
266
+ // The old shape first, so an unmigrated project reads. `wrappedPayload` prefers the declared
267
+ // payload and only then considers an envelope, so a value that is legitimately its own envelope is
268
+ // never unwrapped out from under itself.
269
+ const unwrapped = wrappedPayload(entry, stated);
270
+ const wrapped = unwrapped !== undefined;
271
+ // `null`: this payload has no version. An unwrapped one never had; a wrapped one is being read past
272
+ // its envelope, and naming that envelope's version in an error is naming a shape being migrated off.
273
+ const value = storedVersion(entry, name, null, wrapped ? unwrapped : stated, path);
274
+ return {
275
+ payload: entry.valueType === "text" ? value : (JSON.parse(value) as unknown),
276
+ text: value,
277
+ stored: initialVersionedValue(value),
278
+ value,
279
+ wrapped,
280
+ };
281
+ }
282
+
283
+ const envelope = statedEnvelope(name, stated, path);
284
+ const versions: Record<string, string> = {};
285
+ for (const [version, value] of Object.entries(envelope.versions)) {
286
+ versions[version] = storedVersion(entry, name, version, value, path);
287
+ }
288
+ const stored: VersionedValue = { currentVersion: envelope.currentVersion, versions };
289
+ return { payload: stored, text: encodeVersionedValue(stored), stored, value: currentValue(stored), wrapped: false };
290
+ }
291
+
292
+ /**
293
+ * Convert one stated entry into the envelope the store holds: every version validated against the
294
+ * registry entry and reduced to the string form a stored secret has. A `text` version must be a
295
+ * string — a number or an object there is a hand-edit slip, not a value. A `json` version is parsed
296
+ * by the entry's schema and re-serialized canonically, which is exactly what the read seam expects to
297
+ * find and what `validateSecretValue` produces for a CLI write.
298
+ *
299
+ * A thin read of {@link devSecretPayload}, kept because a D1 caller wants exactly this and nothing else.
300
+ */
301
+ export function storedSecretValue(
302
+ entry: SecretRegistryEntry,
303
+ name: string,
304
+ stated: unknown,
305
+ path: string = DEV_SECRETS_FILE,
306
+ ): VersionedValue {
307
+ return devSecretPayload(entry, name, stated, path).stored;
308
+ }
309
+
310
+ /**
311
+ * Every entry the file states in the **old wrapped shape**, restated as the payload — the whole of the
312
+ * upgrade, and nothing else touched (#323).
313
+ *
314
+ * Only a `bootstrap` secret can be wrapped, because it is the only one whose payload is not an
315
+ * envelope. Nothing here throws: a value that is neither the new shape nor the old one is a malformed
316
+ * value, and `pithy seed` and `pithy doctor` are what name it. Migrating is not the place to discover
317
+ * that, and a `catch` here would be the swallow this issue began with.
318
+ */
319
+ export function migrateDevSecrets(file: DevSecretsFile, registry: SecretRegistry): DevSecretsFile {
320
+ const migrated: DevSecretsFile = {};
321
+ for (const name of Object.keys(registry).sort()) {
322
+ const entry = registry[name];
323
+ if (!entry || entry.keyed || entry.bootstrap !== true) continue;
324
+ if (!Object.hasOwn(file, name)) continue;
325
+ const unwrapped = wrappedPayload(entry, file[name]);
326
+ if (unwrapped !== undefined) migrated[name] = unwrapped;
327
+ }
328
+ return migrated;
329
+ }
330
+
331
+ /**
332
+ * The value inside the old envelope, when a `bootstrap` secret's entry is one — otherwise nothing.
333
+ *
334
+ * **The declared payload wins.** A secret whose own value is shaped like an envelope would otherwise be
335
+ * unwrapped into its own current version, which is a value nothing can put back. So the entry is asked
336
+ * against the registry's schema first, and only a value that fails *that* is considered for the old
337
+ * shape. `EncryptionConfig` carries `lastRotatedAt` and {@link DevSecretEnvelope} is strict, so for the
338
+ * one secret this is about the two never both parse.
339
+ */
340
+ function wrappedPayload(entry: SecretRegistryEntry, stated: unknown): unknown {
341
+ const declared = entry.valueType === "text" ? z.string() : entry.schema;
342
+ if (declared.safeParse(stated).success) return undefined;
343
+ const envelope = DevSecretEnvelope.safeParse(stated);
344
+ if (!envelope.success) return undefined;
345
+ return envelope.data.versions[envelope.data.currentVersion];
346
+ }
347
+
348
+ /**
349
+ * One stated entry, checked as a full envelope. The three failures are separated because they have
350
+ * different fixes: a value that is not an envelope at all is the migration case (it was a `.dev.vars`
351
+ * line yesterday, or a `bootstrap` payload in the wrong slot); an empty `versions` and a dangling
352
+ * `currentVersion` are hand-edit slips, and saying which one it is saves a round of guessing.
353
+ *
354
+ * **Here rather than at the file boundary (#323).** The loader reads text and knows no registry, and
355
+ * whether an envelope belongs in a slot is the registry's answer. Checking it there meant the check
356
+ * was applied to the one secret it is wrong for.
357
+ */
358
+ function statedEnvelope(name: string, stated: unknown, path: string): DevSecretEnvelope {
359
+ const result = DevSecretEnvelope.safeParse(stated);
360
+ if (!result.success) {
361
+ // What was found, not merely that it was wrong (#323). "Is not a versioned envelope" is true of a
362
+ // string, of a bare `EncryptionConfig`, and of a typo — three different edits, and the adopter is
363
+ // looking at the file. `describeNotEnvelope` names keys and types and never a value.
364
+ const found = describeNotEnvelope(stated, result.error);
365
+ throw new ValidationError({
366
+ message: `Secret '${name}' in ${path} is not a versioned envelope: ${found}.`,
367
+ action: `Write it as ${ENVELOPE_SHAPE}. Its destination receives an envelope, so the file states one.`,
368
+ detail: `dev secrets file '${path}': '${name}' is not a { currentVersion, versions } envelope: ${found}`,
369
+ });
370
+ }
371
+
372
+ const envelope = result.data;
373
+ if (Object.keys(envelope.versions).length === 0) {
374
+ throw new ValidationError({
375
+ message: `Secret '${name}' in ${path} has no versions.`,
376
+ action: `Give it at least one: ${ENVELOPE_SHAPE}.`,
377
+ detail: `dev secrets file '${path}': '${name}' has an empty versions map`,
378
+ });
379
+ }
380
+ // `Object.hasOwn`, never `in`: `in` walks the prototype chain, so a `currentVersion` of `toString`
381
+ // or `constructor` passed this check and failed much later inside the store, with an error naming
382
+ // neither the file nor the secret.
383
+ if (!Object.hasOwn(envelope.versions, envelope.currentVersion)) {
384
+ throw new ValidationError({
385
+ message: `Secret '${name}' in ${path} points at version '${envelope.currentVersion}', which it does not have.`,
386
+ action: "Set currentVersion to a key that is present in versions.",
387
+ detail: `dev secrets file '${path}': '${name}' currentVersion is absent from versions`,
388
+ });
389
+ }
390
+ return envelope;
391
+ }
392
+
393
+ /**
394
+ * One value, in the string form its destination holds — a version inside an envelope, or a `bootstrap`
395
+ * secret's whole payload.
396
+ *
397
+ * **`version` is `null` for a payload that has none, and the sentence changes with it (#323).** Saying
398
+ * *at version '1'* about an entry the adopter wrote with no versions in it sends them looking for a key
399
+ * that is not in their file, which is the class of message this issue exists to end.
400
+ */
401
+ function storedVersion(
402
+ entry: SecretRegistryEntry,
403
+ name: string,
404
+ version: string | null,
405
+ value: unknown,
406
+ path: string,
407
+ ): string {
408
+ const at = version === null ? "" : ` at version '${version}'`;
409
+ const of = version === null ? "" : ` version '${version}'`;
410
+ if (entry.valueType === "text") {
411
+ if (typeof value !== "string") {
412
+ throw new ValidationError({
413
+ message: `Secret '${name}' in ${path}${at} is not a string.`,
414
+ action: `'${name}' is a text secret. Quote the value.`,
415
+ detail: `dev secrets file '${path}': text secret '${name}'${of} is ${typeof value}`,
416
+ });
417
+ }
418
+ return value;
419
+ }
420
+
421
+ const result = entry.schema.safeParse(value);
422
+ if (!result.success) {
423
+ const summary = result.error.issues.map((i) => `${i.path.join(".") || "<root>"}:${i.code}`).join(", ");
424
+ throw new ValidationError({
425
+ message: `Secret '${name}' in ${path} failed validation${at}.`,
426
+ action: `Match the shape ${name} declares. The capability that owns it defines the schema.`,
427
+ detail: `dev secrets file '${path}': json secret '${name}'${of} failed registry validation: ${summary}`,
428
+ });
429
+ }
430
+ return JSON.stringify(result.data);
431
+ }
432
+
433
+ /**
434
+ * Whether the stored envelope already is the one the file states. A match skips the write entirely:
435
+ * re-encrypting an unchanged value would churn `updatedAt` and the ciphertext on every `pithy dev`,
436
+ * which is the difference between idempotent and merely convergent.
437
+ *
438
+ * Compared version by version rather than on the serialized form, because key order in `versions` is
439
+ * whatever the file happened to list — reordering two lines is not a value change, and treating it as
440
+ * one would rewrite the row for nothing.
441
+ */
442
+ function sameEnvelope(stored: VersionedValue, next: VersionedValue): boolean {
443
+ if (stored.currentVersion !== next.currentVersion) return false;
444
+ const keys = Object.keys(next.versions);
445
+ if (Object.keys(stored.versions).length !== keys.length) return false;
446
+ return keys.every((version) => stored.versions[version] === next.versions[version]);
447
+ }
@@ -0,0 +1,84 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ import { EncryptionConfig } from "../crypto/envelope";
6
+ import { SecretCryptoError, SecretNotFoundError } from "../error/errors";
7
+ import type { ManagedEnvironment } from "../scope";
8
+
9
+ /**
10
+ * The **binding name** every worker reads the master key through, fixed across environments — the
11
+ * counterpart to `masterKeySecretName`, which scopes the Secrets Store *entry* the binding points at.
12
+ * Local dev has no store: `.dev.vars` supplies this same name as a string, so it is also the key
13
+ * `pithy add secrets` writes there.
14
+ *
15
+ * Stated once, and here, beside the reader: the writer is in another package, and a near-miss between
16
+ * the two ends is a worker that boots into "Missing required bindings" over a value that was written.
17
+ */
18
+ export const MASTER_KEY_BINDING = "SECRETS_ENCRYPTION_KEYS";
19
+
20
+ /**
21
+ * A Cloudflare Secrets Store binding: `.get()` resolves the secret's plaintext inside the
22
+ * worker. In local dev `.dev.vars` resolves the same name to a literal string instead, so
23
+ * every binding is `SecretBinding | string` and {@link resolveBinding} normalizes the two.
24
+ */
25
+ export interface SecretBinding {
26
+ get(): Promise<string>;
27
+ }
28
+
29
+ /** The env a worker needs to read secrets: the dedicated `SECRETS` D1 and the master-key binding. */
30
+ export interface SecretsStoreEnv {
31
+ /** The per-environment secrets D1 (its own binding, distinct from the app `DB`). */
32
+ SECRETS: D1Database;
33
+ /** The master-key config — a CF Secrets Store binding in deployed envs, a string in local dev. */
34
+ SECRETS_ENCRYPTION_KEYS: SecretBinding | string;
35
+ /**
36
+ * The deployment environment, stamped into each deployed worker's vars at provision. Absent in local dev.
37
+ *
38
+ * **The read seam does not consult it (#153).** Which environment's values a worker reads is already
39
+ * decided by which `SECRETS` D1 and which master key it is bound to, so routing on this as well was a
40
+ * second answer to a settled question — and the answer it gave in dev was "resolve every secret from a
41
+ * plaintext binding, whatever its backend". It stays here because it is genuinely part of a deployed
42
+ * worker's env: the secrets manager reads it to name the environment it writes to.
43
+ */
44
+ ENVIRONMENT?: ManagedEnvironment;
45
+ }
46
+
47
+ /**
48
+ * Resolve a binding to its plaintext: a literal string passes through (local dev `.dev.vars`),
49
+ * a CF Secrets Store binding is read via `.get()`. Throws `secrets/not_found` when neither is
50
+ * present, so a missing binding fails loudly instead of surfacing as a silently-absent secret.
51
+ */
52
+ export async function resolveBinding(value: SecretBinding | string | undefined, name: string): Promise<string> {
53
+ if (typeof value === "string") return value;
54
+ if (value && typeof value.get === "function") return value.get();
55
+ throw new SecretNotFoundError({
56
+ message: `Secret binding '${name}' is not configured.`,
57
+ detail: `binding '${name}' is neither a CF Secrets Store binding nor a .dev.vars string`,
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Resolve and validate the master-key config from `SECRETS_ENCRYPTION_KEYS`. A missing binding,
63
+ * non-JSON, or a malformed config is a key-availability fault (`secrets/crypto_failed`) — the
64
+ * worker cannot decrypt anything without it. The raw key material never reaches the error.
65
+ */
66
+ export async function resolveEncryptionConfig(env: SecretsStoreEnv): Promise<EncryptionConfig> {
67
+ let raw: string;
68
+ try {
69
+ raw = await resolveBinding(env.SECRETS_ENCRYPTION_KEYS, MASTER_KEY_BINDING);
70
+ } catch (cause) {
71
+ throw new SecretCryptoError({ detail: `${MASTER_KEY_BINDING} binding is not configured` }, { cause });
72
+ }
73
+ let parsed: unknown;
74
+ try {
75
+ parsed = JSON.parse(raw);
76
+ } catch (cause) {
77
+ throw new SecretCryptoError({ detail: `${MASTER_KEY_BINDING} is not valid JSON` }, { cause });
78
+ }
79
+ const result = EncryptionConfig.safeParse(parsed);
80
+ if (!result.success) {
81
+ throw new SecretCryptoError({ detail: `${MASTER_KEY_BINDING} is not a valid EncryptionConfig` });
82
+ }
83
+ return result.data;
84
+ }
@@ -0,0 +1,155 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { PithyError } from "@pithy-sh/core/src/error/pithyError";
5
+ import type { MessageParams } from "@pithy-sh/core/src/i18n/catalog";
6
+
7
+ /**
8
+ * `@pithy-sh/secrets` throw sugar. The `secrets/*` codes live in core's closed `KitErrorPayload`
9
+ * union (CLAUDE.md §Errors: capabilities add their codes to the one union); these subclasses
10
+ * are the package-local vehicles that set one of those members — the same pattern as core's
11
+ * `NotFoundError` and `@pithy-sh/cloudflare`'s `CloudflareRequestError`. Runtime code in this
12
+ * package throws one of these, never a plain `new Error`.
13
+ */
14
+
15
+ /** Variable parts each subclass accepts; `code`/`status` are fixed by the subclass. */
16
+ interface SecretErrorArgs {
17
+ /** Override the public, safe-to-expose message. */
18
+ message?: string;
19
+ /** A remediation hint (CLI action line). */
20
+ action?: string;
21
+ /** Internal context for logs + audit. Never serialized to clients. */
22
+ detail?: string;
23
+ /**
24
+ * Values a translating client interpolates into its own wording for this code. Client-facing, so —
25
+ * unlike `action` and `detail` — these cross the boundary with `message`.
26
+ */
27
+ params?: MessageParams;
28
+ }
29
+
30
+ /** A requested secret is not present in the store. */
31
+ export class SecretNotFoundError extends PithyError {
32
+ constructor(args: SecretErrorArgs = {}, options?: { cause?: unknown }) {
33
+ super(
34
+ {
35
+ code: "secrets/not_found",
36
+ status: 404,
37
+ message: args.message ?? "Secret not found.",
38
+ action: args.action,
39
+ detail: args.detail,
40
+ params: args.params,
41
+ },
42
+ options,
43
+ );
44
+ }
45
+ }
46
+
47
+ /** A secret with this name already exists; `create` refuses to overwrite it. */
48
+ export class SecretAlreadyExistsError extends PithyError {
49
+ constructor(args: SecretErrorArgs = {}, options?: { cause?: unknown }) {
50
+ super(
51
+ {
52
+ code: "secrets/already_exists",
53
+ status: 409,
54
+ message: args.message ?? "A secret with this name already exists.",
55
+ action: args.action ?? "Use `update` to change an existing secret.",
56
+ detail: args.detail,
57
+ params: args.params,
58
+ },
59
+ options,
60
+ );
61
+ }
62
+ }
63
+
64
+ /** A secret value failed validation against its registry schema. */
65
+ export class SecretInvalidValueError extends PithyError {
66
+ constructor(args: SecretErrorArgs = {}, options?: { cause?: unknown }) {
67
+ super(
68
+ {
69
+ code: "secrets/invalid_value",
70
+ status: 400,
71
+ message: args.message ?? "Secret value failed validation.",
72
+ action: args.action,
73
+ detail: args.detail,
74
+ params: args.params,
75
+ },
76
+ options,
77
+ );
78
+ }
79
+ }
80
+
81
+ /**
82
+ * **The issuer rolled the credential and the store did not take its successor.**
83
+ *
84
+ * The one failure `pithy secrets rotate` is built around, and the reason it has a code of its own rather
85
+ * than an `UpstreamError` with a longer sentence: every other secrets failure leaves the previous value
86
+ * live and can be answered by running the command again, and this one cannot. Rolling again produces a
87
+ * third credential and loses the second, so the only remedy is a human in the issuer's console.
88
+ *
89
+ * The secret's name belongs in `message`, and so does the issuer — an operator holding this needs both
90
+ * before they can move, and `detail` is stripped at the HTTP boundary. Never the value: there is no value
91
+ * to carry by the time this is raised, which is the whole of what it says.
92
+ */
93
+ export class SecretRotationUnrecordedError extends PithyError {
94
+ constructor(args: SecretErrorArgs = {}, options?: { cause?: unknown }) {
95
+ super(
96
+ {
97
+ code: "secrets/rotation_unrecorded",
98
+ status: 500,
99
+ message: args.message ?? "A credential was rolled at its issuer and its successor was not stored.",
100
+ action: args.action,
101
+ detail: args.detail,
102
+ params: args.params,
103
+ },
104
+ options,
105
+ );
106
+ }
107
+ }
108
+
109
+ /**
110
+ * **This secret cannot be rotated from here, and something else can.**
111
+ *
112
+ * Raised only by the Worker-side rotation route, and only *before* anything is called — a Worker holds one
113
+ * environment's D1 and its own master key, and that is the whole of what it can replace. A
114
+ * `cf-secrets-store` value is one account-level entry written through Cloudflare's API with a token this
115
+ * Worker must never hold; a `global` value is defined by being identical everywhere, and a Worker that
116
+ * wrote its own environment and stopped would leave exactly the mixed state a rotation exists to avoid.
117
+ *
118
+ * Its own code rather than a 400 or a 500 because a client has three different things to render and only
119
+ * one of them is a mistake: *you may not* is a scope refusal, *it broke* is a fault, and this is neither —
120
+ * it is **run the command**. So the `action` names `pithy secrets rotate`, and the client can draw the free
121
+ * path instead of a button. `action` is the operator's and is stripped at the HTTP boundary; the sentence a
122
+ * client renders is in `message`, which is why the message names the command too.
123
+ */
124
+ export class SecretRotationUnsupportedError extends PithyError {
125
+ constructor(args: SecretErrorArgs = {}, options?: { cause?: unknown }) {
126
+ super(
127
+ {
128
+ code: "secrets/rotation_unsupported",
129
+ status: 409,
130
+ message: args.message ?? "This secret cannot be rotated from here.",
131
+ action: args.action,
132
+ detail: args.detail,
133
+ params: args.params,
134
+ },
135
+ options,
136
+ );
137
+ }
138
+ }
139
+
140
+ /** Encrypting or decrypting a secret failed — a missing key version, or unreadable ciphertext. */
141
+ export class SecretCryptoError extends PithyError {
142
+ constructor(args: SecretErrorArgs = {}, options?: { cause?: unknown }) {
143
+ super(
144
+ {
145
+ code: "secrets/crypto_failed",
146
+ status: 500,
147
+ message: args.message ?? "Could not encrypt or decrypt the secret.",
148
+ action: args.action,
149
+ detail: args.detail,
150
+ params: args.params,
151
+ },
152
+ options,
153
+ );
154
+ }
155
+ }