@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,453 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { DevSecretValue } from "@pithy-sh/core/src/capability/devSecret";
5
+ import { SecretOrigin, SecretRotation } from "@pithy-sh/core/src/capability/secretOrigin";
6
+ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
7
+ import { z } from "zod";
8
+ import { KEYSPACE_SEPARATOR } from "./keyspace";
9
+ import type { ValueRotator } from "./rotation/valueRotator";
10
+
11
+ /**
12
+ * The secret registry is the dispatcher. Each entry declares where a secret lives
13
+ * (`backend`), whether its value is shared across environments (`scope`), whether a future
14
+ * value-rotator may manage it (`rotatable`), and how it is interpreted (`valueType`). The
15
+ * `secretsStore` read seam and the `pithy secrets` CLI both route off these axes — never off
16
+ * hard-coded names. The entry itself is a TypeScript type (not a Zod object) because a `json`
17
+ * entry carries a Zod schema; the enum axes below are exported Zod enums so they document
18
+ * themselves and can be validated at the boundary.
19
+ *
20
+ * **One uniform serde.** Every secret is stored the same way — a `{ currentVersion, versions }`
21
+ * value envelope inside one AES-256-GCM envelope — and read through one uniform API
22
+ * (`get` / `getVersions`). `rotatable` never changes storage or the fetch shape; it is
23
+ * forward-looking metadata: may a value-rotator (a deferred feature) manage this secret, and so
24
+ * accumulate multiple still-valid versions. Storing consistently today is how adding that rotator
25
+ * becomes append-a-version rather than reshape-everything.
26
+ */
27
+
28
+ export const SecretBackend = z
29
+ .enum(["d1", "cf-secrets-store"])
30
+ .describe(
31
+ "Where a secret physically lives: `d1` is an encrypted row in the per-environment secrets D1; `cf-secrets-store` is a native Cloudflare Secrets Store entry bound into the worker.",
32
+ );
33
+ export type SecretBackend = z.output<typeof SecretBackend>;
34
+
35
+ export const SecretScope = z
36
+ .enum(["environment", "global"])
37
+ .describe(
38
+ "Whether a secret's value differs per environment (`environment`) or is identical everywhere (`global`). Drives whether a CLI write targets one environment or fans out to all of them.",
39
+ );
40
+ export type SecretScope = z.output<typeof SecretScope>;
41
+
42
+ export const SecretValueType = z
43
+ .enum(["text", "json"])
44
+ .describe(
45
+ "How a decrypted value is interpreted: `text` is a raw string; `json` is parsed and validated against the entry's Zod schema before it is exposed.",
46
+ );
47
+ export type SecretValueType = z.output<typeof SecretValueType>;
48
+
49
+ /** Fields shared by every registry entry, regardless of value type. */
50
+ interface SecretRegistryEntryBase {
51
+ /** Storage backend — drives how the value is read and written. */
52
+ backend: SecretBackend;
53
+ /** Whether the value is shared across environments or differs per environment. */
54
+ scope: SecretScope;
55
+ /**
56
+ * Whether a future value-rotator may manage this secret (and so it may carry multiple
57
+ * still-valid versions). Forward-looking metadata only — storage and the read API are the
58
+ * same either way. Value rotation itself is deferred.
59
+ */
60
+ rotatable: boolean;
61
+ /**
62
+ * How often this secret is expected to be rotated, in days. Absent means no expectation, and then
63
+ * nothing may call it overdue.
64
+ *
65
+ * **This is why "overdue" is a fact rather than a client's guess.** An age is a number; whether that
66
+ * age is late is a policy, and the policy belongs beside the secret it is about. Ninety days is
67
+ * unremarkable for a session signing key and a long time for a payment processor's live key. Without
68
+ * this, every management client picks its own threshold, they disagree, and the one an owner happens
69
+ * to be looking at decides whether they are told.
70
+ *
71
+ * **It is independent of {@link rotatable}, deliberately.** `rotatable` says what automation may do;
72
+ * this says what the *organization* expects, whoever performs it. A `rotatable: false` third-party
73
+ * key — a Stripe key, an OAuth client secret — is exactly the case where no tooling will ever help
74
+ * and a stated expectation is the only thing that surfaces the drift. Refusing this field on a
75
+ * non-rotatable secret would silence the secrets that most need saying.
76
+ *
77
+ * Measured from the newest successful rotation, or from when the secret was first written when there
78
+ * has never been one. See `admin/status.ts`.
79
+ */
80
+ rotateEveryDays?: number;
81
+ /**
82
+ * How this secret's value may be minted, when it may be minted at all.
83
+ *
84
+ * Set it when the value is *arbitrary* — a session signing key, a link signing key: any random
85
+ * string works, because nothing outside the project has to agree with it. Leave it off when the
86
+ * value must match something that already exists — an OAuth app's client secret, a Stripe key, a
87
+ * storage credential. A generated value there authenticates against nothing, and hides the real gap
88
+ * behind one that looks filled in.
89
+ *
90
+ * **The name says dev; the property does not (#321).** *Nothing outside the project has to agree with
91
+ * this* is a fact about the value, and it is as true of production as of a laptop: a session signing
92
+ * key is arbitrary in prod for exactly the reason it is arbitrary in dev, because no third party
93
+ * validates it. For four releases only local dev read this field, so provisioning a deployed
94
+ * environment stopped to ask a human to run `pithy secrets create` on values with no human decision in
95
+ * them. Every environment reads it now. Ask {@link isMintableSecret} rather than the field, so the day
96
+ * the name is corrected there is one site to correct.
97
+ *
98
+ * The declaration lives here, with the capability that owns the secret, so `pithy add` never carries
99
+ * a list of names that drifts as capabilities are added. It is mirrored into the capability's
100
+ * `pithy.manifest.json` as `devSecrets` — the CLI wires a capability without executing it — and the
101
+ * capability's own tests assert the two agree.
102
+ */
103
+ devValue?: DevSecretValue;
104
+ /**
105
+ * Whether this secret is read **straight from its binding**, by code that runs before the store it
106
+ * would otherwise be decoded through exists.
107
+ *
108
+ * **Read this before deleting it as a special case for one secret.** It has exactly one member today —
109
+ * `SECRETS_ENCRYPTION_KEYS` — and that is not the same thing as being a special case. It describes an
110
+ * asymmetry that **already exists in production and predates this axis**: `ensureMasterKey` has always
111
+ * written the master key into the Secrets Store as a bare `EncryptionConfig`, and
112
+ * `resolveEncryptionConfig` has always parsed its binding directly. Nothing was changed to make that
113
+ * true; this only writes it down where the code that materialises a value can see it.
114
+ *
115
+ * **Why it cannot be otherwise.** Every other secret is stored — in the Secrets Store, in a D1 row, and
116
+ * in a `.dev.vars` line — as an encoded `{ currentVersion, versions }` envelope, and read back through
117
+ * {@link decodeVersionedValue}. The master key is what that decoder's *decryption* needs in order to
118
+ * exist: it is resolved first, before any secret can be read at all, so it cannot arrive in a form
119
+ * whose reading depends on it. Its binding therefore carries the value, and a `bootstrap` secret's
120
+ * materialised value is its **current version's value** rather than the envelope.
121
+ *
122
+ * **What deleting it would cost.** Making the master key uniform means changing what
123
+ * `resolveEncryptionConfig` accepts and rewriting the stored value in every already-provisioned
124
+ * environment — a data migration of the one value that, botched, makes every other secret unreadable
125
+ * with no error naming the cause. Weigh that before treating this field as tidying.
126
+ *
127
+ * **The dev secrets file states the value, not an envelope around it (#323).** It used to state one,
128
+ * and the seeder took it off again on the way to the binding — so `currentVersion` appeared twice for
129
+ * one concept, carrying no information, and two readers in a row reported a correct file as corrupt.
130
+ * The file states the payload its destination receives, for this secret as for every other; a future
131
+ * *value* rotation of the master key is a rotation of the `EncryptionConfig`'s own `versions` map —
132
+ * the axis that key has always rotated on, and the reason `rotatable` stays false.
133
+ *
134
+ * Enforced at define time: a `bootstrap` entry must be `cf-secrets-store` (a D1 row cannot be read
135
+ * before the store that decrypts it is open) and must not be `keyed` (a keyspace has no one value to
136
+ * bind). See {@link defineSecretRegistry}.
137
+ */
138
+ bootstrap?: boolean;
139
+ /**
140
+ * How this value comes to exist — minted by the kit, composable into a command, or a human in
141
+ * somebody else's console. See {@link SecretOrigin}.
142
+ *
143
+ * **Declared with {@link rotation} or not at all**, enforced at define time. An origin alone renders as
144
+ * a whole answer — *obtained from GitHub*, and nothing about what to do when it is ninety days old — and
145
+ * the operator most in need of the second half is the one who reads only the first.
146
+ *
147
+ * **It does not replace {@link devValue} yet, and it may not contradict it.** `origin.kind: "minted"`
148
+ * with `recipe.kind: "random"` is exactly the `devValue` case, and `defineSecretRegistry` refuses either
149
+ * without the other, so the two cannot drift while both exist. When `devValue` goes, {@link
150
+ * isMintableSecret} becomes a read of this field and no caller changes.
151
+ */
152
+ origin?: SecretOrigin;
153
+ /**
154
+ * How this value is replaced. See {@link SecretRotation}.
155
+ *
156
+ * **A separate axis from {@link origin}, because neither follows from the other.** A GitLab token is
157
+ * `obtained` — a human made the first one — and rotates by `provider`, since the API authenticates with
158
+ * the token being replaced and returns its successor. An OAuth client secret is also `obtained` and
159
+ * rotates only by `manual`. One field cannot say both.
160
+ *
161
+ * **It does not derive {@link rotatable}, and must not.** They answer different questions, and two
162
+ * secrets in this repository prove it: `SECRETS_ENCRYPTION_KEYS` is `rotation: "local"` and
163
+ * `rotatable: false`, because it rotates on the `versions` map inside its own `EncryptionConfig` rather
164
+ * than on a second value envelope; `payments-provider-credentials` is `rotatable: true` and rotates only
165
+ * by a human in Stripe's console. `rotatable` is about how a value is *stored* while it changes; this is
166
+ * about who changes it.
167
+ */
168
+ rotation?: SecretRotation;
169
+ /**
170
+ * **The code behind a `provider` rotation.** See {@link ValueRotator}.
171
+ *
172
+ * `rotation` is a tag and crosses into `pithy.manifest.json`; this is a function and cannot. That is the
173
+ * whole reason they are two fields rather than one — #322's constraint, stated where an author meets it.
174
+ * Neither is derivable from the other, so both are declared, and `defineSecretRegistry` refuses a pair
175
+ * that disagrees:
176
+ *
177
+ * - **`provider` may carry one, and only `provider` may.** `local` is rotated by the same
178
+ * `mintSecretValue` that created the value, and a second producer beside `origin.recipe` is exactly
179
+ * the drift the origin/rotation checks exist to refuse. `manual` means no API returns the new value,
180
+ * so a rotator there is a contradiction the declaration already spelled out.
181
+ * - **`provider` is not *required* to carry one**, because an adopter may hold the credentials a
182
+ * capability's secret rotates against while the capability does not. Turnstile is the case in the kit
183
+ * today: `rotation.kind: "provider"`, issuer `cloudflare`, and no rotator, because rolling that widget
184
+ * needs an account token this package must never hold. `pithy secrets rotate` answers that state by
185
+ * naming it and the two ways out, which beats a define-time refusal that would make declaring the
186
+ * truth impossible.
187
+ *
188
+ * **Never serialized.** `DeclaredSecret` — the manifest's projection — carries `name`, `origin` and
189
+ * `rotation`, and parses rather than copies, so a function has no route into a JSON document. Held to
190
+ * that by `registry.test.ts` rather than by this sentence.
191
+ */
192
+ rotator?: ValueRotator;
193
+ /** Optional human note surfaced by the audit (`ls --check`). */
194
+ notes?: string;
195
+ }
196
+
197
+ /** A `text` entry exposes a raw string. */
198
+ type TextEntry = { valueType: "text" };
199
+ /** A `json` entry is parsed and validated against `schema` before exposure. */
200
+ type JsonEntry = { valueType: "json"; schema: z.ZodType };
201
+
202
+ /** A named entry — one secret, one value, its name declared here. The default, and the common case. */
203
+ type NamedEntry = { keyed?: false };
204
+ /**
205
+ * A keyed entry declares a **keyspace** instead of a name: the same backend, scope and schema applied
206
+ * to an unbounded set of members whose keys exist only at runtime — one signing key per customer
207
+ * connection, one credential per tenant. Read a member with `getKeyed(name, key)`; `get(name)` is
208
+ * refused, because a keyspace has no single value.
209
+ *
210
+ * `d1` and `environment` are enforced, not conventional. A Cloudflare Secrets Store binding is
211
+ * declared in `wrangler.jsonc` at build time, so a name that does not exist then can never have one;
212
+ * and a member is written to one environment's store by the app itself, so `global` would promise a
213
+ * fan-out that no CLI write performs. See `./keyspace` for how a member is named.
214
+ */
215
+ type KeyedEntry = { keyed: true };
216
+
217
+ /** A single registry entry — the cross-product of the base fields, the value-type discriminant, and named vs keyed. */
218
+ export type SecretRegistryEntry = SecretRegistryEntryBase & (TextEntry | JsonEntry) & (NamedEntry | KeyedEntry);
219
+
220
+ /** A registry: secret name (or keyspace) → entry. The source of truth for backend, scope, rotatability, and value type. */
221
+ export type SecretRegistry = Record<string, SecretRegistryEntry>;
222
+
223
+ /**
224
+ * The in-memory value type for an entry: a `string` for `text`, the inferred schema type for
225
+ * `json`. `get(name)` returns this; `getVersions(name)` returns a map of it per version.
226
+ */
227
+ export type SecretValue<E extends SecretRegistryEntry> = E extends { valueType: "json"; schema: infer S }
228
+ ? S extends z.ZodType
229
+ ? z.infer<S>
230
+ : never
231
+ : string;
232
+
233
+ /**
234
+ * The declared **named** secrets of a registry — constrains callers to entries that exist and that
235
+ * have one value. A keyed entry is excluded on purpose: `get("CONNECTION_SIGNING_KEY")` is a
236
+ * question with no answer, and the type says so before the accessor has to.
237
+ */
238
+ export type SecretName<R extends SecretRegistry> = {
239
+ [K in keyof R]: R[K] extends { keyed: true } ? never : K;
240
+ }[keyof R] &
241
+ string;
242
+
243
+ /** The declared **keyspaces** of a registry — the only names `getKeyed` accepts. */
244
+ export type KeyedSecretName<R extends SecretRegistry> = {
245
+ [K in keyof R]: R[K] extends { keyed: true } ? K : never;
246
+ }[keyof R] &
247
+ string;
248
+
249
+ /**
250
+ * **May this secret's value be minted?** The one question every creator asks, in every environment.
251
+ *
252
+ * Two kinds of secret, and the difference is the whole of it. A *supplied* secret was issued elsewhere —
253
+ * an OAuth client secret, a payment rail's key — and only the person holding it can provide one; a
254
+ * creator must stop and say so. A *generated* secret is random bytes nobody chooses, and stopping to ask
255
+ * a human to ask a tool to generate them is a round trip and nothing else.
256
+ *
257
+ * The answer is {@link SecretRegistryEntryBase.devValue}, read through here rather than directly: the
258
+ * field is named for the environment that happened to read it first, the property is not, and one
259
+ * predicate is what keeps the two from being confused again. A keyspace is refused a second time — the
260
+ * define-time check already refuses the pair, and this stays true of a registry assembled by hand.
261
+ */
262
+ export function isMintableSecret(entry: SecretRegistryEntry): boolean {
263
+ return entry.devValue !== undefined && !entry.keyed;
264
+ }
265
+
266
+ /**
267
+ * Check one entry's `origin` and `rotation` — that each is well-formed, that they agree with each other,
268
+ * and that neither contradicts what the entry already says about itself.
269
+ *
270
+ * **Every rule here exists because the alternative is a client rendering a confident wrong thing.** A
271
+ * declaration is read by `pithy doctor`, by `pithy secrets ls`, and by a management dashboard, none of
272
+ * which can tell a mistake from a fact. So the mistakes are refused where the author is.
273
+ */
274
+ function validateSecretDeclaration(name: string, entry: SecretRegistryEntry): void {
275
+ // The tag and the code must agree, and neither is derivable from the other (#322), so both are checked
276
+ // against each other here — where the author is — rather than at a rotation that finds a rotator on a
277
+ // secret nothing was ever going to call one for. See `SecretRegistryEntryBase.rotator`.
278
+ if (entry.rotator !== undefined) {
279
+ if (typeof entry.rotator.roll !== "function") {
280
+ throw new InternalError({ message: `secret registry: entry "${name}" has a rotator with no roll().` });
281
+ }
282
+ if (entry.rotation?.kind !== "provider") {
283
+ throw new InternalError({
284
+ message: `secret registry: entry "${name}" carries a rotator, so its rotation must be provider — ${
285
+ entry.rotation === undefined
286
+ ? "it declares none"
287
+ : entry.rotation.kind === "local"
288
+ ? "a local secret is re-minted from its own recipe, and a second producer is drift"
289
+ : "a manual secret is replaced by a human, and no call returns the new value"
290
+ }.`,
291
+ });
292
+ }
293
+ }
294
+ // Declared together or not at all. See `SecretRegistryEntryBase.origin`.
295
+ if ((entry.origin === undefined) !== (entry.rotation === undefined)) {
296
+ throw new InternalError({
297
+ message: `secret registry: entry "${name}" declares one of origin and rotation — declare both or neither.`,
298
+ });
299
+ }
300
+ if (entry.origin === undefined || entry.rotation === undefined) return;
301
+ const origin = SecretOrigin.safeParse(entry.origin);
302
+ if (!origin.success) {
303
+ throw new InternalError({ message: `secret registry: entry "${name}" has an invalid origin.` });
304
+ }
305
+ const rotation = SecretRotation.safeParse(entry.rotation);
306
+ if (!rotation.success) {
307
+ throw new InternalError({ message: `secret registry: entry "${name}" has an invalid rotation.` });
308
+ }
309
+ // What the kit can make, the kit can make again — and nothing else can be replaced without its issuer.
310
+ // So `minted` and `local` imply each other, and a pair that disagrees is one of the two being wrong.
311
+ const minted = origin.data.kind === "minted";
312
+ if (minted !== (rotation.data.kind === "local")) {
313
+ throw new InternalError({
314
+ message: minted
315
+ ? `secret registry: entry "${name}" is minted, so its rotation is local — the kit can make another.`
316
+ : `secret registry: entry "${name}" is not minted, so its rotation cannot be local — only its issuer can replace it.`,
317
+ });
318
+ }
319
+ // One axis, not two fields that can disagree. `devValue` is the random-mint case and nothing else: the
320
+ // master key is minted too, and no random string is an `EncryptionConfig`.
321
+ const randomlyMinted = minted && origin.data.kind === "minted" && origin.data.recipe.kind === "random";
322
+ if (randomlyMinted !== (entry.devValue !== undefined)) {
323
+ throw new InternalError({
324
+ message: randomlyMinted
325
+ ? `secret registry: entry "${name}" is minted from a random value, so it must declare devValue.`
326
+ : `secret registry: entry "${name}" declares devValue but its origin is not a random mint.`,
327
+ });
328
+ }
329
+ // A structured mint produces a structure. The `random`/`text` half of this is enforced with `devValue`
330
+ // above; this is its mirror, and without it a json entry could claim a mint that cannot satisfy its schema.
331
+ if (origin.data.kind === "minted" && origin.data.recipe.kind !== "random" && entry.valueType !== "json") {
332
+ throw new InternalError({
333
+ message: `secret registry: entry "${name}" mints a structured value, so it must be a json entry.`,
334
+ });
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Author a registry. Validates each entry's enum axes, that `rotatable` is a boolean, and that
340
+ * every `json` entry carries a Zod schema, so a malformed registry fails at define time
341
+ * (attributed to the offending name) rather than deep in a read or a write. A misconfiguration is
342
+ * an author error, so it throws `InternalError` — mirroring `createMigrationRegistry`. The `const`
343
+ * type param preserves the precise entry literals for `SecretValue`/`SecretName`.
344
+ */
345
+ export function defineSecretRegistry<const R extends SecretRegistry>(registry: R): R {
346
+ for (const [name, entry] of Object.entries(registry)) {
347
+ if (!name) throw new InternalError({ message: "secret registry: every entry needs a non-empty name." });
348
+ // A name carrying the separator would be indistinguishable from a keyspace member, so one
349
+ // registry entry could shadow another tenant's stored credential. Refused at define time.
350
+ if (name.includes(KEYSPACE_SEPARATOR)) {
351
+ throw new InternalError({
352
+ message: `secret registry: entry "${name}" must not contain '${KEYSPACE_SEPARATOR}' — it separates a keyspace from a member key.`,
353
+ });
354
+ }
355
+ const axes: [string, z.ZodType, unknown][] = [
356
+ ["backend", SecretBackend, entry.backend],
357
+ ["scope", SecretScope, entry.scope],
358
+ ["valueType", SecretValueType, entry.valueType],
359
+ ];
360
+ for (const [field, schema, value] of axes) {
361
+ if (!schema.safeParse(value).success) {
362
+ throw new InternalError({
363
+ message: `secret registry: entry "${name}" has an invalid ${field} (${String(value)}).`,
364
+ });
365
+ }
366
+ }
367
+ if (typeof entry.rotatable !== "boolean") {
368
+ throw new InternalError({ message: `secret registry: entry "${name}" must declare rotatable as a boolean.` });
369
+ }
370
+ if (entry.rotateEveryDays !== undefined) {
371
+ // A cadence that is not a whole number of days, or is zero or negative, would make every read of
372
+ // this secret's status permanently overdue — a warning nobody can clear and everybody learns to
373
+ // ignore. Caught at define time, where the author is.
374
+ if (!Number.isInteger(entry.rotateEveryDays) || entry.rotateEveryDays < 1) {
375
+ throw new InternalError({
376
+ message: `secret registry: entry "${name}" must declare rotateEveryDays as a whole number of days, at least 1.`,
377
+ });
378
+ }
379
+ }
380
+ if (entry.valueType === "json" && !(entry.schema instanceof z.ZodType)) {
381
+ throw new InternalError({ message: `secret registry: json entry "${name}" must declare a Zod schema.` });
382
+ }
383
+ if (entry.devValue !== undefined) {
384
+ if (!DevSecretValue.safeParse(entry.devValue).success) {
385
+ throw new InternalError({
386
+ message: `secret registry: entry "${name}" has an invalid devValue (${String(entry.devValue)}).`,
387
+ });
388
+ }
389
+ // A minted value is a random string, so only a `text` entry can hold one: a `json` entry's shape
390
+ // is the schema's, and nothing can invent a credential that satisfies it.
391
+ if (entry.valueType !== "text") {
392
+ throw new InternalError({
393
+ message: `secret registry: entry "${name}" declares devValue but is not a text entry — a random value cannot satisfy a json schema.`,
394
+ });
395
+ }
396
+ // A keyspace has no single value to mint, and its members do not exist until runtime.
397
+ if (entry.keyed) {
398
+ throw new InternalError({
399
+ message: `secret registry: keyed entry "${name}" must not declare devValue — a keyspace has no one value.`,
400
+ });
401
+ }
402
+ }
403
+ validateSecretDeclaration(name, entry);
404
+ if (entry.bootstrap !== undefined) {
405
+ if (typeof entry.bootstrap !== "boolean") {
406
+ throw new InternalError({
407
+ message: `secret registry: entry "${name}" must declare bootstrap as a boolean.`,
408
+ });
409
+ }
410
+ // A bootstrap secret is one a Worker reads from a binding before the store exists, so the D1 store
411
+ // is not a place it can come from. Caught here, where the author is, rather than at a read that
412
+ // finds a row nothing will ever look at.
413
+ if (entry.bootstrap && entry.backend !== "cf-secrets-store") {
414
+ throw new InternalError({
415
+ message: `secret registry: bootstrap entry "${name}" must use the cf-secrets-store backend — it is read from its binding, before any store is open.`,
416
+ });
417
+ }
418
+ if (entry.bootstrap && entry.keyed) {
419
+ throw new InternalError({
420
+ message: `secret registry: keyed entry "${name}" must not declare bootstrap — a keyspace has no one value to bind.`,
421
+ });
422
+ }
423
+ // A bootstrap secret is what the decoder needs in order to exist, so nothing may invent one: the
424
+ // master key is composed by `initialMasterKeyConfig`, and a random string in its place orphans
425
+ // every secret already encrypted under the real one. Refused here so that every writer of a fresh
426
+ // dev secret — `pithy add`, `adopt`, the provisioners — can state a mintable secret is not
427
+ // bootstrap as a fact rather than as an assumption. See `initialDevSecret`.
428
+ if (entry.bootstrap && entry.devValue !== undefined) {
429
+ throw new InternalError({
430
+ message: `secret registry: bootstrap entry "${name}" must not declare devValue — nothing may mint the value every other secret is read through.`,
431
+ });
432
+ }
433
+ }
434
+ if (entry.keyed !== undefined && typeof entry.keyed !== "boolean") {
435
+ throw new InternalError({ message: `secret registry: entry "${name}" must declare keyed as a boolean.` });
436
+ }
437
+ if (entry.keyed) {
438
+ // Both axes are load-bearing for a keyspace — see `KeyedEntry`. Caught here, where the author
439
+ // is, rather than at a read that finds nothing and cannot say why.
440
+ if (entry.backend !== "d1") {
441
+ throw new InternalError({
442
+ message: `secret registry: keyed entry "${name}" must use the d1 backend — a Secrets Store binding is declared at build time, and its members are not.`,
443
+ });
444
+ }
445
+ if (entry.scope !== "environment") {
446
+ throw new InternalError({
447
+ message: `secret registry: keyed entry "${name}" must be environment-scoped — its members are written to one environment's store.`,
448
+ });
449
+ }
450
+ }
451
+ }
452
+ return registry;
453
+ }
@@ -0,0 +1,146 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { DatabaseSchema } from "@pithy-sh/core/src/data/db";
5
+ import type { Kysely } from "kysely";
6
+ import type { EncryptionConfig } from "../crypto/envelope";
7
+ import type { SecretsTables } from "../data/tables";
8
+ import type { ConfigWriter } from "../manager/configWriter";
9
+ import type { RotationTracker } from "../store/rotationTracker";
10
+ import { countOnOldKeys, mergeNextKey, pruneOldKeys, reencryptBatch } from "./keyRotation";
11
+
12
+ type SecretsDb = Kysely<DatabaseSchema<SecretsTables>>;
13
+
14
+ /** The sentinel name a whole-store at-rest key rotation is recorded under in `pithy_secrets_rotations`. */
15
+ export const AT_REST_ROTATION_NAME = "__at_rest_key_rotation__";
16
+
17
+ /**
18
+ * A durable step runner — the structural subset of Cloudflare's `WorkflowStep` we use. The
19
+ * Workflow class passes the real runtime step; tests pass a synchronous mock that runs each
20
+ * callback immediately. Keeping it structural avoids a hard dependency on `cloudflare:workers`.
21
+ */
22
+ export interface StepRunner {
23
+ do<T>(name: string, fn: () => Promise<T>): Promise<T>;
24
+ }
25
+
26
+ /** Everything the at-rest rotation needs, injected so the core is testable without CF wiring. */
27
+ export interface AtRestRotationDeps {
28
+ /** The per-environment secrets D1. */
29
+ db: SecretsDb;
30
+ /** The current master-key config (resolved from `SECRETS_ENCRYPTION_KEYS`). */
31
+ config: EncryptionConfig;
32
+ /** Writes the updated config back to CF Secrets Store. */
33
+ configWriter: ConfigWriter;
34
+ /** Records the rotation attempt in `pithy_secrets_rotations`. */
35
+ tracker: RotationTracker;
36
+ }
37
+
38
+ export interface AtRestRotationOptions {
39
+ batchSize?: number;
40
+ maxBatches?: number;
41
+ rotatedBy?: string;
42
+ /**
43
+ * The clock, for a test that wants a fixed one.
44
+ *
45
+ * Read **inside** the `pass-instant` step rather than beside it, so journalling this value did not
46
+ * close the seam: an injected clock is still what gets read and still what gets journalled.
47
+ */
48
+ now?: Date;
49
+ }
50
+
51
+ export interface AtRestRotationResult {
52
+ rotated: number;
53
+ failed: number;
54
+ newCurrentVersion: number;
55
+ pruned: boolean;
56
+ }
57
+
58
+ /**
59
+ * Rotate the at-rest encryption key for one environment's store, in durable steps:
60
+ *
61
+ * 1. open a rotation row (`in_progress`);
62
+ * 2. generate a fresh master key, merge it as the new current version, and persist the config;
63
+ * 3. re-encrypt every row to the new key, in batches, until none remain (or `maxBatches`);
64
+ * 4. once no row references an old key, prune the old keys and persist again;
65
+ * 5. close the rotation row (`success`, or `failed` on any throw, which re-raises).
66
+ *
67
+ * Each step is retryable and idempotent: re-encryption only touches rows not yet on the current
68
+ * version, and the old key stays available until pruning, so a mid-run retry is safe. Scoped to one
69
+ * environment — the per-env manager owns one store; cross-env fan-out is the CLI's job.
70
+ */
71
+ export async function runAtRestKeyRotation(
72
+ deps: AtRestRotationDeps,
73
+ step: StepRunner,
74
+ options: AtRestRotationOptions = {},
75
+ ): Promise<AtRestRotationResult> {
76
+ const batchSize = options.batchSize ?? 100;
77
+ const maxBatches = options.maxBatches ?? 50;
78
+ const rotatedBy = options.rotatedBy ?? "cron";
79
+
80
+ /**
81
+ * The pass instant, journalled (pithy-sh/pithy#329).
82
+ *
83
+ * A Workflow re-executes this body from the top on a resume and serves every completed step from the
84
+ * journal, so a clock read beside this line answers differently on every attempt. It is the instant
85
+ * written as `lastRotatedAt`, and a pass interrupted at midnight and resumed at six dated the key it
86
+ * rotated by the resume — a rotation history that cannot be reconciled against the work it names.
87
+ *
88
+ * **This one is a stamp and nothing else, and that was checked rather than assumed.** `lastRotatedAt`
89
+ * has one reader, `isRotationDue`, which asks a cadence question in days on a cron that starts nothing
90
+ * while an instance is live; the rotation row's `startedAt`/`completedAt` are written by
91
+ * `RotationTracker` inside its own steps and read only for display. So freezing this instant strands no
92
+ * running work. The sibling case in the email worker looked equally plain and was not — there `now` is
93
+ * the scheduler's heartbeat too, and freezing it lets a live batch be re-driven as stuck.
94
+ *
95
+ * Epoch milliseconds rather than a `Date`, because a journal round-trips JSON: a `Date` would come back
96
+ * a string on the resume and an object on the first pass.
97
+ */
98
+ const nowMs: number = await step.do("pass-instant", async () => (options.now ?? new Date()).getTime());
99
+ const now = new Date(nowMs);
100
+
101
+ const rotationId = await step.do("start", () => deps.tracker.startRotation(AT_REST_ROTATION_NAME, "cron", rotatedBy));
102
+
103
+ try {
104
+ const newConfig = await step.do("generate-key", () => mergeNextKey(deps.config, now));
105
+ await step.do("write-config", async () => {
106
+ await deps.configWriter.write(JSON.stringify(newConfig));
107
+ });
108
+
109
+ let rotated = 0;
110
+ let failed = 0;
111
+ for (let batch = 0; batch < maxBatches; batch++) {
112
+ const result = await step.do(`reencrypt-${batch}`, () => reencryptBatch(deps.db, newConfig, batchSize));
113
+ rotated += result.rotated;
114
+ failed += result.failed;
115
+ // Stop when a batch makes no progress — either the store is fully rotated, or only
116
+ // failures remain (which would loop forever).
117
+ if (result.rotated === 0) break;
118
+ }
119
+
120
+ let pruned = false;
121
+ const remaining = await step.do("count-remaining", () => countOnOldKeys(deps.db, newConfig));
122
+ if (remaining === 0) {
123
+ const prunedConfig = pruneOldKeys(newConfig);
124
+ if (prunedConfig) {
125
+ await step.do("prune", async () => {
126
+ await deps.configWriter.write(JSON.stringify(prunedConfig));
127
+ });
128
+ pruned = true;
129
+ }
130
+ }
131
+
132
+ await step.do("mark-success", () => deps.tracker.markSuccess(rotationId));
133
+ return { rotated, failed, newCurrentVersion: Number(newConfig.currentVersion), pruned };
134
+ } catch (cause) {
135
+ // **The binding rethrows and does nothing else (`#386`).** It used to become the row's `error_message`
136
+ // via `cause.message`, and the exceptions that reach here come from decryption, envelope decoding and
137
+ // config parsing — the paths whose text can carry key material. `markFailure` now takes a code and
138
+ // renders the sentence itself, so there is no argument this `cause` would fit.
139
+ //
140
+ // Rethrown unchanged, which is where the detail belongs: the Workflow logs a `PithyError` whose
141
+ // `detail` the HTTP codec strips. Nothing about this failure is written to a column, and the column
142
+ // is still refused for publication — that refusal is defense in depth, not this fix.
143
+ await step.do("mark-failure", () => deps.tracker.markFailure(rotationId, "at-rest-incomplete"));
144
+ throw cause;
145
+ }
146
+ }