@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,107 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { AdminRoute } from "@pithy-sh/core/src/controlPlane/discovery/adminRoute";
5
+ import type { ControlPlaneScope } from "@pithy-sh/core/src/controlPlane/scope/scope";
6
+
7
+ /**
8
+ * The scopes the secrets capability's management surface demands, and the surface it advertises.
9
+ *
10
+ * ## Two scopes, and each is its own
11
+ *
12
+ * `secrets:status:read` is granted separately from everything else an adopter grants at connect. That
13
+ * separation is the point: the read is harmless in the sense that matters — it cannot disclose a value —
14
+ * and it is sensitive in a different sense, because the list of which credentials a project holds, which
15
+ * are stale, and which no automation will ever rotate is a map of where to push. An adopter who wants a
16
+ * dashboard reading purchases and users without also handing over that map says so by not granting this,
17
+ * and then it *cannot* be read, whatever the client intends.
18
+ *
19
+ * `scopeCovers` matches exactly, with no prefix or wildcard rule, so this confers nothing else and
20
+ * nothing else confers it.
21
+ *
22
+ * ## Why the history is not a second scope
23
+ *
24
+ * `@pithy-sh/ledger` splits balances from the entry log behind them, because a number and a behavioral
25
+ * record are different disclosures. Here they are the same disclosure at two resolutions: the listing
26
+ * already reports the last successful rotation and the count, and the history adds the attempts between
27
+ * them. Nothing appears in one that the other conceals, so a second grant would be a decision an adopter
28
+ * could not act on.
29
+ *
30
+ * ## The gate is core's, and this capability verifies nothing itself
31
+ *
32
+ * `requireControlPlane` lives in `@pithy-sh/core/src/controlPlane/http/guard` and the routes wear it
33
+ * directly. `@pithy-sh/core` is a hard dependency of every capability, so importing its gate cannot
34
+ * leave a deployment without one — and with the seam uncomposed it raises `controlplane/not_connected`
35
+ * rather than passing. There is no `requireAuth()` anywhere on this surface: the seam leaves
36
+ * `c.var.auth` null by design, so an auth gate would deny every legitimate management call permanently.
37
+ *
38
+ * The name is a constant rather than config, because a configurable scope name is a way to misconfigure
39
+ * a default-denied gate into a differently-named one — and it is the join key with what
40
+ * `pithy dashboard connect` offers an adopter to grant.
41
+ */
42
+
43
+ /** Read every declared secret's status, and one secret's rotation history. Metadata only, never a value. */
44
+ export const SECRETS_STATUS_READ_SCOPE: ControlPlaneScope = "secrets:status:read";
45
+
46
+ /**
47
+ * **Replace one declared secret's value.** The capability's first write scope, and it is separate from the
48
+ * read for a stronger reason than symmetry.
49
+ *
50
+ * `scopeCovers` matches exactly, so a scope confers every route that requires it and nothing else. Folding
51
+ * a rotation behind `secrets:status:read` would mean every adopter who ever granted a dashboard the ability
52
+ * to see that a key is stale had also, retroactively and without being asked, granted it the ability to
53
+ * replace their production credentials. That is the escalation the whole scope model exists to make
54
+ * impossible, and a name ending in `:read` gating a write is how it would have happened quietly.
55
+ *
56
+ * **It is deliberately not in the default grant, and nothing here has to arrange that.** `defaultGrant`
57
+ * classifies a scope by the methods of the routes requiring it — every route requiring this one is a
58
+ * `POST`, so a fresh `pithy dashboard connect` does not hand it out and an adopter has to name it. That is
59
+ * the correct default for a credential that reaches other people's live systems: an adopter who wants a
60
+ * dashboard to rotate for them says so once, out loud, and can stop saying it at any time by revoking.
61
+ */
62
+ export const SECRETS_ROTATE_SCOPE: ControlPlaneScope = "secrets:rotate";
63
+
64
+ /**
65
+ * Every control-plane scope this capability defines — what `pithy dashboard connect` offers for it, and
66
+ * the list a manifest or a doc quotes rather than re-typing.
67
+ */
68
+ export const SECRETS_CONTROL_PLANE_SCOPES: readonly ControlPlaneScope[] = [
69
+ SECRETS_STATUS_READ_SCOPE,
70
+ SECRETS_ROTATE_SCOPE,
71
+ ];
72
+
73
+ /**
74
+ * The secrets capability's management surface, as `GET /control-plane/manifest` reports it.
75
+ *
76
+ * Declared beside the scope rather than in `routes.ts` so the scope a route demands and the scope a
77
+ * manifest advertises are the same constant, read from one place. `basePath` is a parameter and never a
78
+ * default: an adopter who mounted this at `/vault` must get a manifest naming `/vault/admin/status`, or
79
+ * a management client composing its calls from it would 404 against exactly the adopters who customized
80
+ * anything.
81
+ *
82
+ * Everything sits under `admin/` for consistency with every other capability's management surface, and
83
+ * so that a future non-management route under this mount point cannot collide with one of these.
84
+ */
85
+ export function secretsAdminRoutes(basePath: string): AdminRoute[] {
86
+ return [
87
+ {
88
+ method: "GET",
89
+ path: `${basePath}/admin/status`,
90
+ scope: SECRETS_STATUS_READ_SCOPE,
91
+ summary: "Every declared secret's status: when it was last rotated, how often, and whether it is overdue.",
92
+ },
93
+ {
94
+ method: "GET",
95
+ path: `${basePath}/admin/status/:name/rotations`,
96
+ scope: SECRETS_STATUS_READ_SCOPE,
97
+ summary: "One secret's rotation history, newest first — when, how it ended, what caused it, and who.",
98
+ },
99
+ {
100
+ method: "POST",
101
+ path: `${basePath}/admin/status/:name/rotate`,
102
+ scope: SECRETS_ROTATE_SCOPE,
103
+ summary:
104
+ "Replace one declared secret in this environment, against its declared rotator. Reports per environment, never in aggregate.",
105
+ },
106
+ ];
107
+ }
@@ -0,0 +1,225 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { SecretRotation } from "@pithy-sh/core/src/capability/secretOrigin";
5
+ import { z } from "zod";
6
+ import { RotationStatus, RotationTrigger } from "../data/secretRotations";
7
+ import { SecretBackend, SecretValueType } from "../registry";
8
+ import { SecretRotationStatus, SecretRotationUnchangedReason } from "../rotation/rotateValue";
9
+ import type { CarriesNoValue } from "../valueBearing";
10
+
11
+ /**
12
+ * What the secrets management routes return, as Zod objects a client can validate against.
13
+ *
14
+ * `schemas.ts` bounds what a caller may send; this file states what it gets back. Both halves are
15
+ * runtime values rather than interfaces because a management client reading a customer's Worker is
16
+ * crossing a trust boundary and must validate what comes back — and a TypeScript interface is erased
17
+ * before it can help, so every client that had only an interface hand-wrote a mirror and the mirror
18
+ * drifted the first time a field landed here.
19
+ *
20
+ * **No codecs and no transforms.** These describe JSON on the wire, so parsing one hands back exactly
21
+ * what went in. Dates render as ISO-8601 strings: they are ms-epoch integers in SQLite and `Date`s in
22
+ * TypeScript, and a JSON number would leave every client guessing the unit.
23
+ *
24
+ * **One deliberate exception, and it is a rewrite rather than a decode: `rotation.issuer`.** `SecretRotation`
25
+ * comes from core with `SecretIssuer.catch("other")` on its issuer field, so a client parsing a response
26
+ * from a Worker running a *newer* kit than itself renders `other` instead of throwing. That is the right
27
+ * failure here for the same reason it is right on the manifest — this is third-party data from somebody
28
+ * else's deployment, and a pane blanked over an issuer name a client did not need to understand is worse
29
+ * than an honest *somebody issues this, and I cannot help you with them.* It rewrites a field, never a key,
30
+ * so nothing merges and nothing is lost; see `secretOrigin.ts`'s `IssuerKey` for why that distinction is
31
+ * load-bearing.
32
+ *
33
+ * **Nothing here can carry a value.** The field lists are the same argument `admin/status.ts` makes
34
+ * about its reader shapes, restated at the wire, and {@link SECRET_RESPONSES_CARRY_NO_VALUE} is the
35
+ * compile-time tripwire that keeps it true. A ciphertext, an IV, a metadata snapshot and a rotation's
36
+ * error message are all absent from the type rather than omitted by a projection.
37
+ *
38
+ * **A browser imports this file, so it reaches no module that needs the Workers runtime** — which is
39
+ * why `CarriesNoValue` comes from `../valueBearing.ts` and not from `admin/status.ts`, where it was
40
+ * declared until #419. The reader is a Kysely module; importing a *type* out of it still put the D1
41
+ * layer in the browser program's file set, and `@pithy-sh/support` shipped the version of that mistake
42
+ * where the reached module named a bare Workers global and the adopter's client build went red.
43
+ * `tooling/browser-scopes` compiles this module with `types: []` and asks the compiler which files that
44
+ * program included.
45
+ *
46
+ * **A field added here later is `.optional()`, not merely `.nullable()`.** This module is read across a
47
+ * version boundary — a management client validates a response with this schema against a customer's
48
+ * Worker at whatever kit version it is on — so an additive required key fails `safeParse` for everyone
49
+ * below that release and takes the whole pane with it (#450). Absent then means *this Worker cannot
50
+ * say*, which is a different fact from `null`.
51
+ */
52
+
53
+ /** One rotation attempt on the wire. */
54
+ export const SecretRotationView = z
55
+ .object({
56
+ startedAt: z.iso.datetime().describe("When the rotation attempt began, ISO-8601."),
57
+ completedAt: z.iso.datetime().nullable().describe("When it finished, ISO-8601; null while it is still running."),
58
+ status: RotationStatus.describe("How it ended: `in_progress`, `success`, or `failed`."),
59
+ trigger: RotationTrigger.describe("What caused it: `cron`, `manual`, or the `baseline` marker on first write."),
60
+ rotatedBy: z.string().describe("Who or what initiated it — a workflow instance id, an operator id, or `baseline`."),
61
+ })
62
+ .describe("One rotation attempt as a client reads it. A failure is a status, never a message.");
63
+ export type SecretRotationView = z.output<typeof SecretRotationView>;
64
+
65
+ /** One secret's status on the wire. */
66
+ export const SecretStatusView = z
67
+ .object({
68
+ name: z.string().describe("The secret's registry name."),
69
+ backend: SecretBackend.describe("Where the value lives. Decides what a null `createdAt` means."),
70
+ valueType: SecretValueType.describe("How the value is interpreted: `text` or `json`."),
71
+ rotatable: z.boolean().describe("Whether a value-rotator may manage it. Never changes what is reported."),
72
+ rotation: SecretRotation.nullable().describe(
73
+ "How this secret is replaced: `local`, `provider`, or `manual` — with the issuer, and the page a human goes to. Null when the registry declares nothing, which is not the same as `manual`. **This is the field a client branches on to decide whether a rotation control exists at all**, and it is metadata by construction: a kind, an issuer and an `https:` documentation URL are all it can hold. An issuer this client has never heard of parses as `other` rather than failing, so a Worker newer than its reader still renders.",
74
+ ),
75
+ keyVersion: z
76
+ .number()
77
+ .int()
78
+ .nullable()
79
+ .describe("Which master-key version the stored envelope sits under, or null when nothing is stored here."),
80
+ createdAt: z.iso
81
+ .datetime()
82
+ .nullable()
83
+ .describe("When it was first written to this store, ISO-8601; null when it is not stored here."),
84
+ updatedAt: z.iso
85
+ .datetime()
86
+ .nullable()
87
+ .describe("When its value was last written, ISO-8601; null when it is not stored here."),
88
+ lastRotatedAt: z.iso
89
+ .datetime()
90
+ .nullable()
91
+ .describe(
92
+ "The newest successful rotation, ISO-8601, or **null for never rotated** — not zero, and not the epoch.",
93
+ ),
94
+ rotationCount: z
95
+ .number()
96
+ .int()
97
+ .nonnegative()
98
+ .describe("How many rotation attempts are recorded, successful or not."),
99
+ rotateEveryDays: z
100
+ .number()
101
+ .int()
102
+ .positive()
103
+ .nullable()
104
+ .describe("The cadence the registry declares for this secret, or null when it declares none."),
105
+ overdue: z
106
+ .boolean()
107
+ .nullable()
108
+ .describe("Whether it is past that cadence. Null when the question has no answer, which is not the same as no."),
109
+ })
110
+ .describe("One secret's status as a client reads it. Metadata only — this shape cannot express a value.");
111
+ export type SecretStatusView = z.output<typeof SecretStatusView>;
112
+
113
+ /** `GET {base}/admin/status` — every declared secret. */
114
+ export const SecretsStatusResponse = z
115
+ .object({
116
+ secrets: z
117
+ .array(SecretStatusView)
118
+ .describe("Every named secret the composed Worker declares, by name. Keyed entries are excluded."),
119
+ unreadable: z
120
+ .array(z.string())
121
+ .describe(
122
+ "Declared secrets whose stored rows would not decode, by registry name — so one bad row costs its own entry and names itself, instead of costing the read (#387). Registry names only: keyed entries are excluded from this read, so no stored `<keyspace>/<key>` name can appear here. Carries no reason; why a row is malformed is a question for the database, not for a client.",
123
+ ),
124
+ })
125
+ .describe("The status of every declared secret, and any whose stored rows would not decode.");
126
+ export type SecretsStatusResponse = z.output<typeof SecretsStatusResponse>;
127
+
128
+ /** `GET {base}/admin/status/:name/rotations` — one secret's history. */
129
+ export const SecretRotationsResponse = z
130
+ .object({
131
+ name: z.string().describe("The secret this history belongs to, echoed so a client can label it."),
132
+ rotations: z.array(SecretRotationView).describe("Its rotation attempts, newest first, capped by `limit`."),
133
+ unreadable: z
134
+ .number()
135
+ .int()
136
+ .nonnegative()
137
+ .describe(
138
+ "How many rows in this page would not decode (#387). A count and not a list, because every row here is the same secret's and what identifies one is its place in the history — which is exactly what a row with an undecodable `startedAt` cannot supply. Reported so a client can say the history is incomplete rather than render a short list as a whole one.",
139
+ ),
140
+ })
141
+ .describe("One secret's rotation history, and how much of the page would not decode.");
142
+ export type SecretRotationsResponse = z.output<typeof SecretRotationsResponse>;
143
+
144
+ /**
145
+ * `POST {base}/admin/status/:name/rotate` — what one rotation did, **per environment and never in
146
+ * aggregate**.
147
+ *
148
+ * This is `SecretRotationOutcome` projected for the wire, field by field, and the projection is where two
149
+ * things are dropped on purpose:
150
+ *
151
+ * - **`cause`.** The core carries whatever the store threw, typed `unknown`, for the command to render into
152
+ * a `detail`. An `unknown` on a response schema is a hole of arbitrary shape pointed at a management
153
+ * client, filled by an exception raised at a site that was handling a value. It does not cross.
154
+ * - **Anything a value could sit in.** There is no such field, which is the same structural argument the
155
+ * core makes about its own outcome: a payload with nowhere to put a secret cannot leak one by a later
156
+ * caller's oversight. {@link SECRET_RESPONSES_CARRY_NO_VALUE} is the tripwire.
157
+ *
158
+ * **It answers 200 for every one of the four statuses, including `unrecorded`**, and that is a decision
159
+ * rather than an oversight. The alternative — throwing `secrets/rotation_unrecorded` so the incident
160
+ * arrives as a 500 — renders one sentence and drops `recorded` and `stranded` on the floor, which is
161
+ * exactly the "all rotated" summary over a partial failure that this whole design refuses. The status is a
162
+ * required field of a closed enum, so a client that validates its response cannot fail to see it, and the
163
+ * audit event for that member is `critical`.
164
+ */
165
+ export const SecretRotationOutcomeView = z
166
+ .object({
167
+ name: z.string().describe("The secret that was rotated, echoed so a client can label the result."),
168
+ status: SecretRotationStatus.describe(
169
+ "How it ended: `rotated`, `unchanged`, `unrecorded`, or `failed`. `unrecorded` is the one that needs a human in a console now — read it before `recorded`.",
170
+ ),
171
+ kind: z
172
+ .enum(["local", "provider", "manual"])
173
+ .describe(
174
+ "How the registry says this secret is replaced — the same fact `rotation.kind` reports on a status read.",
175
+ ),
176
+ rolled: z
177
+ .boolean()
178
+ .describe(
179
+ "Whether the issuer's credential was actually replaced. True only for a `provider` rotation that reached its rotator; a `local` mint rolls nothing anywhere.",
180
+ ),
181
+ rollFailed: z
182
+ .boolean()
183
+ .describe(
184
+ "Whether the **rotator itself** threw, rather than the store after it. With `rolled`, this is the difference between *was rolled* and *may have been rolled*: a call that reached the issuer and lost its answer cannot be told from one that never landed, and a report claiming either is wrong half the time about the fact being acted on. False on every run that never called a rotator.",
185
+ ),
186
+ recorded: z
187
+ .array(z.string())
188
+ .describe("The environments this run wrote the new value to, in the order each write landed."),
189
+ stranded: z
190
+ .array(z.string())
191
+ .describe(
192
+ "The environments the new value never reached. Empty on a run that finished. With `rolled` true these hold a credential the issuer has already retired.",
193
+ ),
194
+ reason: SecretRotationUnchangedReason.nullable().describe(
195
+ "Why nothing was called, when nothing was — `manual` for a secret only a human can replace. Null whenever something was attempted.",
196
+ ),
197
+ attempts: z
198
+ .number()
199
+ .int()
200
+ .positive()
201
+ .nullable()
202
+ .describe("How many store attempts the failing environment cost. Null when nothing was stored."),
203
+ })
204
+ .describe(
205
+ "What one rotation did, per environment. Facts only, no prose, and no field a value or an exception could sit in.",
206
+ );
207
+ export type SecretRotationOutcomeView = z.output<typeof SecretRotationOutcomeView>;
208
+
209
+ /** `POST {base}/admin/status/:name/rotate` — one secret, one outcome. */
210
+ export const SecretRotateResponse = z
211
+ .object({
212
+ rotation: SecretRotationOutcomeView.describe(
213
+ "The outcome. One secret per call, so there is no summary line to hide a partial failure behind.",
214
+ ),
215
+ })
216
+ .describe("One secret's rotation outcome.");
217
+ export type SecretRotateResponse = z.output<typeof SecretRotateResponse>;
218
+
219
+ /**
220
+ * The wire half of the constraint. `true` only while no response shape names a value-bearing field; add
221
+ * one and this assignment stops compiling.
222
+ */
223
+ export const SECRET_RESPONSES_CARRY_NO_VALUE: CarriesNoValue<SecretStatusView> &
224
+ CarriesNoValue<SecretRotationView> &
225
+ CarriesNoValue<SecretRotationOutcomeView> = true;
@@ -0,0 +1,224 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
6
+ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
7
+ import type { Context } from "hono";
8
+ import type { SecretBinding, SecretsStoreEnv } from "../env/bindings";
9
+ import { MASTER_KEY_BINDING } from "../env/bindings";
10
+ import { SecretNotFoundError, SecretRotationUnsupportedError } from "../error/errors";
11
+ import { runWriteSecret } from "../management/writeSecret";
12
+ import type { SecretRegistryEntry } from "../registry";
13
+ import { refuseUnrotatable, rotateSecretValue, type SecretRotationOutcome } from "../rotation/rotateValue";
14
+ import { ManagedEnvironment } from "../scope";
15
+ import { RotationTracker, trackerRotationLedger } from "../store/rotationTracker";
16
+ import { SystemSecretsStore } from "../store/systemSecretsStore";
17
+
18
+ /**
19
+ * **What a Worker can rotate, and the honest refusal for everything else.**
20
+ *
21
+ * `#367` gave the CLI a rotation and `#372` asks for the same act from a browser, and the two are not the
22
+ * same act performed by different callers. `pithy secrets rotate` runs in a process holding *the project*:
23
+ * the registry from source, a Cloudflare API token, and a dispatcher to every environment's manager Worker.
24
+ * A Worker holds **one environment's D1 and its own master key**. So the interesting question is not how to
25
+ * call `rotateSecretValue` from a handler — that part is easy — it is which secrets the answer is true for.
26
+ *
27
+ * ## The two refusals, and why they are refusals rather than partial successes
28
+ *
29
+ * - **A `cf-secrets-store` backend.** The value is one account-level Secrets Store entry, written through
30
+ * Cloudflare's REST API with a token the secrets *manager* holds. An app Worker binds that store
31
+ * read-only and must never hold that token — `env/bindings.ts` and the capability's token profile both
32
+ * say so. There is no write to attempt.
33
+ * - **`scope: "global"`.** A global secret is *defined* by being byte-identical in every environment;
34
+ * `resolveWriteTargets` fans a global `d1` write across all of them for exactly that reason. A Worker
35
+ * can reach one. Writing its own and reporting success would leave staging and prod holding different
36
+ * values for one name, which is the mixed state `#38` names as the shape to refuse — and, worse, it
37
+ * would arrive labeled `rotated`.
38
+ *
39
+ * Both are answered with `secrets/rotation_unsupported` **before anything is called**, naming the command
40
+ * that can. A client renders the free path instead of a dead button, which is the same answer `#38` already
41
+ * gives for a `manual` secret and for the same reason: an instruction that works beats a control that does
42
+ * not.
43
+ *
44
+ * ## Three more things are refused before the irreversible step
45
+ *
46
+ * {@link refuseUnrotatable} is asked first — a keyspace, an undeclared rotation, a `provider` secret with
47
+ * no rotator, the master key, a `local` secret with no recipe. Then the environment: a Worker that cannot
48
+ * name which environment it is cannot report where a value landed, and an outcome that cannot name its
49
+ * environments is the aggregate this design exists to refuse. Then presence: `runWriteSecret` in `update`
50
+ * mode raises on a name the store does not hold, and discovering that *after* a provider roll would
51
+ * manufacture the unrecorded incident out of a configuration gap that was knowable for free.
52
+ *
53
+ * ## The rotation row is opened before the roll, and closed after it
54
+ *
55
+ * Nothing else advances `lastRotatedAt`. A rotation that succeeded and recorded nothing leaves the secret
56
+ * reported overdue forever, so the button would appear to do nothing — and the history an incident review
57
+ * reads (`GET {base}/admin/status/:name/rotations`) would have no entry for the act it is reviewing. The row
58
+ * opens `in_progress` with the **management client's own subject** as `rotatedBy`, which is what makes *who
59
+ * rolled the production key on the twelfth* a question with an answer. It is opened before the roll so a
60
+ * rotator that never returns still leaves a trace, and only after every refusal above, so a refused call
61
+ * writes no history at all.
62
+ *
63
+ * The failure text written into the closing row is composed here from the outcome's own status — never from
64
+ * `cause`. `admin/status.ts` refuses to publish `error_message` precisely because it is free text written at
65
+ * a failure site, which is where a value gets pasted by accident; writing one from an exception message
66
+ * would be that accident, arranged in advance.
67
+ */
68
+
69
+ /** What one Worker-side rotation needs: this environment's store, and its rotation history. */
70
+ export interface WorkerRotationDeps {
71
+ store: SystemSecretsStore;
72
+ tracker: RotationTracker;
73
+ }
74
+
75
+ /** One Worker-side rotation request, already verified and already looked up. */
76
+ export interface WorkerRotationRequest {
77
+ /** The secret's registry name, matched exactly against the composed registry by the caller. */
78
+ name: string;
79
+ /** Its registry entry — every refusal below is read off this declaration. */
80
+ entry: SecretRegistryEntry;
81
+ /** The environment this Worker is, verified. The one environment a write can reach. */
82
+ environment: ManagedEnvironment;
83
+ /** Who asked, for the rotation row. The verified control-plane subject, never a claim from a body. */
84
+ actor: string;
85
+ /** Store attempts per environment. Defaults to the core's 3. Never a re-roll. */
86
+ attempts?: number;
87
+ }
88
+
89
+ /**
90
+ * The environment this Worker is, from the **verified** control-plane context rather than from a raw
91
+ * binding.
92
+ *
93
+ * `verifyControlPlaneCall` has already refused a credential bound to a different environment than the one
94
+ * this Worker's `ENVIRONMENT` var names, so by the time a handler reads it the two agree — and reading the
95
+ * checked value rather than the var means there is no second, unchecked source of the same fact.
96
+ *
97
+ * `dev` and an unstamped Worker are both refused. `ManagedEnvironment` excludes `dev` because local dev has
98
+ * no manager and no deployed store; an unstamped Worker yields the empty string, which is not a legal
99
+ * environment name. Either way the answer is the same: this deployment cannot say where a value would land,
100
+ * so it does not roll one.
101
+ */
102
+ export function workerRotationEnvironment(environment: string): ManagedEnvironment {
103
+ const parsed = ManagedEnvironment.safeParse(environment);
104
+ if (!parsed.success) {
105
+ throw new SecretRotationUnsupportedError({
106
+ message: "This deployment cannot say which environment it is, so it will not replace a credential.",
107
+ action:
108
+ "Stamp ENVIRONMENT on the Worker's vars (pithy provision does), or rotate from a machine that holds the project: pithy secrets rotate <NAME> --env <env>.",
109
+ detail: `rotate refused: the verified control-plane environment '${environment}' is not a deployed environment`,
110
+ });
111
+ }
112
+ return parsed.data;
113
+ }
114
+
115
+ /**
116
+ * Refuse a secret **this Worker** cannot replace, whatever the declaration says about rotating in general.
117
+ *
118
+ * Separate from {@link refuseUnrotatable}, deliberately: that one answers *can this secret be rotated at
119
+ * all*, and its answers are true everywhere. These two are true only of a Worker, and the same secret is
120
+ * rotatable from the CLI — so the refusal names the command rather than suggesting the declaration is
121
+ * wrong.
122
+ */
123
+ export function refuseUnrotatableHere(name: string, entry: SecretRegistryEntry): void {
124
+ if (entry.backend === "cf-secrets-store") {
125
+ throw new SecretRotationUnsupportedError({
126
+ message: `Secret '${name}' lives in Cloudflare's Secrets Store, which no application Worker writes to. Rotate it with pithy secrets rotate ${name}.`,
127
+ action: `Run pithy secrets rotate ${name} --env <env>. The secrets manager holds the Cloudflare credential this write needs; an application Worker binds that store read-only and must not hold one.`,
128
+ detail: `rotate refused: '${name}' is backend cf-secrets-store, which is written through the CF REST API and not from this Worker`,
129
+ });
130
+ }
131
+ if (entry.scope === "global") {
132
+ throw new SecretRotationUnsupportedError({
133
+ message: `Secret '${name}' is the same in every environment, and this Worker can only write its own. Rotate it with pithy secrets rotate ${name}.`,
134
+ action: `Run pithy secrets rotate ${name}. It writes every declared environment in one run, which is what keeps a global secret identical everywhere.`,
135
+ detail: `rotate refused: '${name}' is scope global, and a Worker-side rotation would write one of its environments and strand the rest`,
136
+ });
137
+ }
138
+ }
139
+
140
+ /** The store and the tracker for this request, from the Worker's own bindings. Built per request. */
141
+ export async function workerRotationDeps(c: Context<PithyHonoEnv>): Promise<WorkerRotationDeps> {
142
+ const bindings = c.env as Record<string, unknown>;
143
+ const database = bindings.SECRETS as D1Database | undefined;
144
+ const masterKey = bindings[MASTER_KEY_BINDING] as SecretBinding | string | undefined;
145
+ if (!database || masterKey === undefined) {
146
+ throw new InternalError({
147
+ message: "The secrets store is not configured.",
148
+ action: `Bind a D1 database named SECRETS and the ${MASTER_KEY_BINDING} secret in wrangler.jsonc.`,
149
+ detail: `A Worker-side rotation requires the SECRETS D1 binding and ${MASTER_KEY_BINDING}; ${
150
+ database ? MASTER_KEY_BINDING : "SECRETS"
151
+ } was absent on env.`,
152
+ });
153
+ }
154
+ const env: SecretsStoreEnv = { SECRETS: database, SECRETS_ENCRYPTION_KEYS: masterKey };
155
+ return { store: await SystemSecretsStore.fromEnv(env), tracker: RotationTracker.fromD1(database) };
156
+ }
157
+
158
+ /**
159
+ * Rotate one secret in this Worker's own environment, and record the attempt.
160
+ *
161
+ * Refuse, open the row, produce once, store with retries, close the row, hand back the outcome. It never
162
+ * throws for a rotation that *happened* and went wrong — that is what the outcome is for, and a throw would
163
+ * take `recorded` and `stranded` with it. It throws only for the refusals, every one of which lands before
164
+ * anything is called.
165
+ */
166
+ export async function runWorkerRotation(
167
+ deps: WorkerRotationDeps,
168
+ request: WorkerRotationRequest,
169
+ ): Promise<SecretRotationOutcome> {
170
+ const { name, entry } = request;
171
+ refuseUnrotatable(name, entry);
172
+ refuseUnrotatableHere(name, entry);
173
+
174
+ // A human in a console, and nothing to call. The core answers this too, and answering it here is what
175
+ // keeps a rotation row from being opened for an act that never starts: a history of attempts that logs
176
+ // the ones that were never attempted is a history nobody can read.
177
+ if (entry.rotation?.kind === "manual") {
178
+ return {
179
+ name,
180
+ kind: "manual",
181
+ status: "unchanged",
182
+ rolled: false,
183
+ recorded: [],
184
+ stranded: [],
185
+ reason: "manual",
186
+ };
187
+ }
188
+
189
+ // Knowable for free, and catastrophic to discover late: `runWriteSecret` in `update` mode raises on a
190
+ // name the store does not hold, and for a `provider` secret that raise would land *after* the issuer had
191
+ // rolled — manufacturing the one failure this design is built around out of a configuration gap.
192
+ if (!(await deps.store.has(name))) {
193
+ throw new SecretNotFoundError({
194
+ message: `Secret '${name}' has never been stored in this environment, so there is nothing to replace.`,
195
+ action: `Create it first: pithy secrets create ${name} --env ${request.environment}.`,
196
+ detail: `rotate refused: '${name}' has no row in this environment's store`,
197
+ });
198
+ }
199
+
200
+ // **The bracket is the core's, not this route's (`#379`).** This function opened the row and closed it
201
+ // by hand, which was right when `rotateSecretValue` recorded nothing. `#379` moved the bracket into the
202
+ // function that *performs* a rotation — same order, refuse then open then roll then close — and made
203
+ // the ledger a required option, so the CLI cannot omit it the way it did. Keeping this bracket as well
204
+ // would write the row twice.
205
+ return rotateSecretValue({
206
+ name,
207
+ entry,
208
+ targets: [request.environment],
209
+ ledger: trackerRotationLedger(deps.tracker, {
210
+ environment: request.environment,
211
+ trigger: "manual",
212
+ rotatedBy: request.actor,
213
+ }),
214
+ store: ({ value }) =>
215
+ runWriteSecret(deps, {
216
+ mode: "update",
217
+ name,
218
+ value,
219
+ valueType: entry.valueType,
220
+ rotatable: entry.rotatable,
221
+ }).then(() => undefined),
222
+ ...(request.attempts === undefined ? {} : { attempts: request.attempts }),
223
+ });
224
+ }