@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,219 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { type Capability, defineCapability } from "@pithy-sh/core/src/capability/capability";
5
+ import { secretsHealth } from "./admin/health";
6
+ import { EncryptionConfig } from "./crypto/envelope";
7
+ import { secretsTables } from "./data/tables";
8
+ import { MASTER_KEY_BINDING } from "./env/bindings";
9
+ import { secretsAdminRoutes } from "./http/guards";
10
+ import { registerSecretsRoutes, SECRETS_DEFAULT_BASE_PATH } from "./http/routes";
11
+ import { secrets_0001_init } from "./migrations/0001_init";
12
+ import { MANAGER_CF_API_TOKEN_SECRET } from "./provision/provisionSecrets";
13
+ import { defineSecretRegistry, type SecretRegistry, type SecretRegistryEntry } from "./registry";
14
+ import {
15
+ aggregateSecretRegistries,
16
+ configureSharedSecrets,
17
+ DEFAULT_SECRETS_CACHE_TTL_SECONDS,
18
+ } from "./sharedSecretsStore";
19
+ import { PACKAGE_VERSION } from "./version.generated";
20
+
21
+ /**
22
+ * The secrets manager's own token profile — the standard default for its least-privilege runtime
23
+ * credential. Declared here, next to the capability, and consumed as the single source of that scope
24
+ * (the CLI provisioner mints the manager token from it). Secrets Store read + write, nothing else.
25
+ *
26
+ * `secretScope: "global"` is load-bearing, not documentation. It puts the literal `global` in the
27
+ * environment slot of the store entry the minted value is written to, so `pithy token mint secrets`
28
+ * lands on `<project>-global-secrets-manager-cf-api-token` — byte-identical to what
29
+ * `managerCfApiTokenSecretName` provisions and what the manager's `CLOUDFLARE_API_TOKEN` binding
30
+ * reads. Drop it and a mint writes a per-environment entry nothing binds, leaving the manager on a
31
+ * credential the operator believes they just rolled.
32
+ */
33
+ export const secretsTokenProfile = {
34
+ permissions: ["secrets:read", "secrets:write"],
35
+ secret: MANAGER_CF_API_TOKEN_SECRET,
36
+ secretScope: "global",
37
+ defaultStore: "secrets-store",
38
+ description: "The secrets manager's runtime credential — reads and writes CF Secrets Store from its Worker.",
39
+ } as const;
40
+
41
+ /**
42
+ * The master key, declared as what it already is: a `cf-secrets-store` secret, one value for the whole
43
+ * project, read from the `SECRETS_ENCRYPTION_KEYS` binding (#179).
44
+ *
45
+ * **It was a bare `requiredBindings` entry with no backend, scope, rotatable or valueType** — so nothing
46
+ * could route it, and local dev had to keep it in a file of its own with a special case at every reader.
47
+ * Production has always stored it in the account's Secrets Store, under the entry `masterKeySecretName`
48
+ * composes; this records that, and dev then materialises it from `secrets.jsonc` like every other secret
49
+ * of that backend.
50
+ *
51
+ * **`json`, against `EncryptionConfig`, because that is what the value is.** A hand-edit that drops
52
+ * `lastRotatedAt` or mistypes a key version is caught by the seeder naming the secret, rather than by a
53
+ * Worker answering every request with `secrets/crypto_failed`.
54
+ *
55
+ * **`bootstrap`, because the Worker reads it without the decoder.** See the axis for why that is the one
56
+ * secret whose binding carries its value rather than its envelope — and, since #323, the one secret whose
57
+ * entry in `secrets.jsonc` is that value rather than an envelope around it. Both statements are the same
58
+ * rule: the file states the payload the destination receives.
59
+ *
60
+ * **No `devValue`.** A random string is not an `EncryptionConfig`, and the registry refuses `devValue` on
61
+ * a non-text entry for exactly that reason. `pithy add secrets` mints one properly, through
62
+ * `initialMasterKeyConfig`, and writes it into `secrets.jsonc`.
63
+ *
64
+ * **`rotatable: false` is about *value* versions, not key versions.** The master key rotates on the
65
+ * `versions` map inside its own `EncryptionConfig` — that is what `atRestKeyRotation` walks — and a
66
+ * second value-envelope version on top would be a second rotation axis for one key.
67
+ *
68
+ * **`origin` is a structured mint, and that is why `SecretRecipe` is a union (#322).** The issue that
69
+ * introduced these axes asserted this secret was minted from a random string. It is minted — by
70
+ * `initialMasterKeyConfig` — and its value is an `EncryptionConfig`, so a `recipe` that knew only
71
+ * `random` would have failed on the first secret it was drafted around. `recipe: "encryptionConfig"` says
72
+ * both halves, which is why the entry still carries no `devValue` and `isMintableSecret` still answers
73
+ * false: nothing arbitrary fills this.
74
+ *
75
+ * **`rotation: "local"` and `rotatable: false` are not in conflict.** They answer different questions.
76
+ * Replacing this value is something the kit does entirely by itself — that is `local`. Whether the stored
77
+ * *envelope* accumulates versions is `rotatable`, and here it does not, because the versions live inside
78
+ * the value.
79
+ */
80
+ export const masterKeyRegistryEntry: SecretRegistryEntry = {
81
+ backend: "cf-secrets-store",
82
+ scope: "environment",
83
+ rotatable: false,
84
+ bootstrap: true,
85
+ valueType: "json",
86
+ schema: EncryptionConfig,
87
+ origin: { kind: "minted", recipe: { kind: "encryptionConfig" } },
88
+ rotation: { kind: "local" },
89
+ notes: "The at-rest master key every other secret is encrypted under. Minted by pithy add secrets.",
90
+ };
91
+
92
+ /** Sort order of the secrets migrations within the `SECRETS` database. */
93
+ const SECRETS_MIGRATION_ORDER = 100;
94
+
95
+ /** Default at-rest key-rotation cadence, in days. */
96
+ const DEFAULT_ROTATION_INTERVAL_DAYS = 30;
97
+
98
+ /** Configuration for the secrets capability, passed in `pithy.config.ts`. */
99
+ export interface SecretsConfig {
100
+ /**
101
+ * The project's secret registry — the single source of truth for backend, scope, rotatability,
102
+ * value type, and (for json) schema, per secret. Both the worker's `secretsStore` and the
103
+ * `pithy secrets` CLI read it: the CLI discovers it off this capability in `pithy.config.ts`.
104
+ */
105
+ registry: SecretRegistry;
106
+ /** At-rest key-rotation cadence in days. Defaults to 30. Surfaced as the `rotationIntervalDays` option. */
107
+ rotationIntervalDays?: number;
108
+ /**
109
+ * Lifetime in seconds of the shared per-invocation secrets cache. Within one worker invocation
110
+ * every capability's secrets are resolved once and reused for this long; after it elapses the next
111
+ * access re-fetches the full combined set. Defaults to 60. Lower it to pick up a rotated secret
112
+ * sooner; raise it to cut Secrets Store round-trips further.
113
+ */
114
+ secretsCacheTtlSeconds?: number;
115
+ /**
116
+ * Mount the management surface somewhere other than `/secrets`. Moves the paths the control-plane
117
+ * manifest advertises with it, so a client composing its calls from the manifest follows.
118
+ */
119
+ basePath?: string;
120
+ }
121
+
122
+ /**
123
+ * The secrets capability, with its registry attached. The attachment is what lets the
124
+ * `pithy secrets` CLI discover the registry by loading `pithy.config.ts` and finding this
125
+ * capability — no separate registry-loading convention needed.
126
+ */
127
+ export interface SecretsCapability extends Capability {
128
+ secretRegistry: SecretRegistry;
129
+ rotationIntervalDays: number;
130
+ secretsCacheTtlSeconds: number;
131
+ }
132
+
133
+ /**
134
+ * The secrets capability. It contributes a **dedicated** `SECRETS` D1 database — distinct from the
135
+ * app `DB`, because the app database is provisioned ephemerally per feature branch and secrets are
136
+ * durable and shared, so they cannot be the same database. It requires the `SECRETS` D1 binding and
137
+ * the `SECRETS_ENCRYPTION_KEYS` master-key binding (CF Secrets Store, worker-only), and carries the
138
+ * project's {@link SecretRegistry} — with {@link masterKeyRegistryEntry} merged in, so the binding it
139
+ * requires and the secret that fills it are one declaration rather than two that can drift.
140
+ *
141
+ * It also contributes a **read-only** control-plane surface behind `secrets:status:read` — when each
142
+ * secret was last rotated, and whether it is past the cadence its registry entry declares. Metadata
143
+ * only, and structurally so: `http/responses.ts` has no field that could carry a value. Like every
144
+ * capability's, the routes are always mounted and default-denied — with `controlplane()` uncomposed each
145
+ * one raises `controlplane/not_connected`, because a management surface that appears only when something
146
+ * else is installed is a surface nobody can discover.
147
+ */
148
+ export function secrets(config: SecretsConfig): SecretsCapability {
149
+ const ttlSeconds = config.secretsCacheTtlSeconds ?? DEFAULT_SECRETS_CACHE_TTL_SECONDS;
150
+ // The capability's own secret, merged under the adopter's so a project that declares nothing still has
151
+ // a routable master key — and so an adopter who deliberately overrides the entry keeps that power.
152
+ //
153
+ // Validated on the way out, because the merge is what produces the registry everything else reads and
154
+ // it is the one registry nothing had checked: `masterKeyRegistryEntry` is a plain const, and an
155
+ // override arrives from `pithy.config.ts` where an adopter may well have written an object literal.
156
+ // Re-checking an already-defined registry costs nothing and is the difference between a contradiction
157
+ // caught at boot and one discovered by a client rendering it.
158
+ const registry: SecretRegistry = defineSecretRegistry({
159
+ [MASTER_KEY_BINDING]: masterKeyRegistryEntry,
160
+ ...config.registry,
161
+ });
162
+ // Resolved once, here, and handed to both the router and the manifest — so the advertised admin paths
163
+ // and the mounted ones cannot disagree about where the surface lives.
164
+ const mountPath = config.basePath ?? SECRETS_DEFAULT_BASE_PATH;
165
+ // What the status surface reports over, replaced by `compose` with the combined registry. It starts as
166
+ // this capability's own so a Worker that somehow answers before composing still reports honestly
167
+ // rather than emptily; after compose it is every capability's, which is the useful answer — a project's
168
+ // auth signing key and email link key are secrets its owner needs the status of just as much.
169
+ const reported = { current: registry };
170
+ const capability = defineCapability({
171
+ name: "secrets",
172
+ // The package version this capability ships at, stamped by `scripts/stampVersions.ts` — a Worker
173
+ // cannot read its own package.json. Reported per capability by the control-plane manifest.
174
+ version: PACKAGE_VERSION,
175
+ secretRegistry: registry,
176
+ tokenProfiles: { secrets: secretsTokenProfile },
177
+ requiredBindings: [
178
+ { type: "d1", name: "SECRETS" },
179
+ { type: "secret", name: "SECRETS_ENCRYPTION_KEYS" },
180
+ ],
181
+ databases: {
182
+ secrets: {
183
+ binding: "SECRETS",
184
+ tables: secretsTables,
185
+ migrationOrder: SECRETS_MIGRATION_ORDER,
186
+ migrations: { "0001_init": secrets_0001_init },
187
+ },
188
+ },
189
+ routes: registerSecretsRoutes({ registry: () => reported.current, basePath: mountPath }),
190
+ // Built from the resolved mount path, never the default: an adopter who mounts this at `/vault` gets
191
+ // a manifest naming `/vault/admin/status`, which is what a management client composes its calls from.
192
+ adminRoutes: secretsAdminRoutes(mountPath),
193
+ // One number on the manifest entry — how many secrets are past their declared cadence — so a client
194
+ // renders "3 need rotating" beside the rail from the read it already made, instead of spending a
195
+ // credential per screen load to ask (#317). Behind the same scope as the listing it summarizes, and
196
+ // over the same combined registry, so a secret whose freshness can be seen is one this counts.
197
+ health: secretsHealth(() => reported.current),
198
+ // At worker startup, merge every capability's secret-registry slice into one combined registry and
199
+ // back the shared per-invocation accessor from it — so all secrets resolve in one batch, shared
200
+ // across capabilities, with this capability's configured TTL.
201
+ compose: ({ capabilities }) => {
202
+ const combined = aggregateSecretRegistries(capabilities);
203
+ configureSharedSecrets({ registry: combined, ttlSeconds });
204
+ // The same combined set the accessor resolves is the set the status surface reports. One
205
+ // aggregation, so a secret that can be read is a secret whose freshness can be seen.
206
+ reported.current = combined;
207
+ },
208
+ });
209
+ return Object.assign(capability, {
210
+ secretRegistry: registry,
211
+ rotationIntervalDays: config.rotationIntervalDays ?? DEFAULT_ROTATION_INTERVAL_DAYS,
212
+ secretsCacheTtlSeconds: ttlSeconds,
213
+ });
214
+ }
215
+
216
+ /** Whether a capability is the secrets capability — carries a registry. The CLI uses this to discover it. */
217
+ export function isSecretsCapability(capability: Capability): capability is SecretsCapability {
218
+ return capability.name === "secrets" && "secretRegistry" in capability;
219
+ }
@@ -0,0 +1,33 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The `ls --check` audit. Compares the secrets the registry declares against what is actually
6
+ * present in an environment, surfacing two problems:
7
+ *
8
+ * - **missing** — a declared secret with no value in this env. The app needs it, so this is what
9
+ * the promote gate fails on (`ls --check --env prod`): a newly-required secret must be set
10
+ * before prod deploys against it.
11
+ * - **orphan** — a stored secret no longer in the registry. Harmless but worth cleaning up.
12
+ *
13
+ * Pure set logic — the caller gathers `expectedNames` (registry) and `presentNames` (the store's D1
14
+ * metadata + bound CF Secrets Store entries) for the env.
15
+ */
16
+ export interface AuditResult {
17
+ missing: string[];
18
+ orphan: string[];
19
+ }
20
+
21
+ export function auditSecrets(expectedNames: string[], presentNames: string[]): AuditResult {
22
+ const present = new Set(presentNames);
23
+ const expected = new Set(expectedNames);
24
+ return {
25
+ missing: [...expected].filter((name) => !present.has(name)).sort(),
26
+ orphan: [...present].filter((name) => !expected.has(name)).sort(),
27
+ };
28
+ }
29
+
30
+ /** The promote gate: an env is promotable only when no declared secret is missing a value. */
31
+ export function passesPromoteGate(audit: AuditResult): boolean {
32
+ return audit.missing.length === 0;
33
+ }
@@ -0,0 +1,192 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { DeclaredEnvironments } from "@pithy-sh/core/src/naming/environment";
5
+ import type { RotationTrigger } from "../data/secretRotations";
6
+ import type { SecretBackend, SecretScope, SecretValueType } from "../registry";
7
+ import type { RotationClosure } from "../rotation/rotationLedger";
8
+ import type { ManagedEnvironment } from "../scope";
9
+ import { partialWriteReport } from "./partialWrite";
10
+ import { secretWriteTargets } from "./writeTargets";
11
+
12
+ /**
13
+ * One write/delete dispatched to a single environment's manager Workflow. The CLI never encrypts or
14
+ * writes locally — the master key is worker-only — so every value-touching command becomes one of
15
+ * these per target environment.
16
+ */
17
+ export interface SecretWriteRequest {
18
+ env: ManagedEnvironment;
19
+ mode: "create" | "update" | "delete";
20
+ name: string;
21
+ /** Present for create/update; omitted for delete. Already validated + canonicalized by the CLI. */
22
+ value?: string;
23
+ valueType?: SecretValueType;
24
+ rotatable?: boolean;
25
+ }
26
+
27
+ /**
28
+ * The dispatch seam: send one request to an environment's manager Workflow and resolve once it
29
+ * reaches a terminal state. Stubbed in tests; backed by the CF Workflows REST client (dispatch +
30
+ * poll) in the manager-worker/provisioning slice.
31
+ */
32
+ export interface SecretDispatcher {
33
+ dispatch(request: SecretWriteRequest): Promise<void>;
34
+ }
35
+
36
+ /** One presence question, asked of one environment's manager. Carries a name and nothing else. */
37
+ export interface SecretProbeRequest {
38
+ env: ManagedEnvironment;
39
+ /** The secret to ask about. */
40
+ name: string;
41
+ }
42
+
43
+ /**
44
+ * **The read seam, and it is deliberately its own.** A `d1` secret's value is sealed under a master
45
+ * key that never leaves the manager Worker, so whether one exists is a question only the manager can
46
+ * answer — and provisioning has to ask it *before* deciding, because a per-environment decision taken
47
+ * during a fan-out cannot preserve a cross-environment invariant.
48
+ *
49
+ * Separate from {@link SecretDispatcher} because the contracts are opposites: one mutates and returns
50
+ * nothing, this returns and mutates nothing. Folding a read into a writer is how a "check" ends up
51
+ * being the write it was meant to gate.
52
+ *
53
+ * It resolves to a bit. It never resolves to a value, and there is no shape of request that would make
54
+ * it.
55
+ */
56
+ export interface SecretProbe {
57
+ probe(request: SecretProbeRequest): Promise<boolean>;
58
+ }
59
+
60
+ /** Open one rotation row, in one environment's manager, before anything is rolled. */
61
+ export interface SecretRotationOpenRequest {
62
+ /** Which environment's ledger. The rotation table is per-environment, like the store it describes. */
63
+ env: ManagedEnvironment;
64
+ /** The secret being rotated, by registry name. */
65
+ name: string;
66
+ /** What caused it. `manual` from a command, `cron` from a schedule — never `baseline`, which is a first write. */
67
+ trigger: RotationTrigger;
68
+ /** Who asked. See `./rotationLedger.ts` for what the CLI can honestly put here. */
69
+ rotatedBy: string;
70
+ }
71
+
72
+ /** Close a row opened by {@link SecretRotationRecorder.openRotation}, with what the run did in that environment. */
73
+ export interface SecretRotationCloseRequest {
74
+ /** The environment whose row this is. */
75
+ env: ManagedEnvironment;
76
+ /** The row id that environment's manager handed back when it opened. */
77
+ rotationId: number;
78
+ /** How it closes there. A code and never free text — a value cannot be pasted into an enum. */
79
+ closure: RotationClosure;
80
+ }
81
+
82
+ /**
83
+ * **The rotation-ledger seam, and it is deliberately its own.**
84
+ *
85
+ * `pithy_secrets_rotations` lives in the per-environment secrets D1, which the CLI cannot reach — the same
86
+ * reason {@link SecretProbe} exists. So a rotation records the way it writes: one dispatch to that
87
+ * environment's manager Workflow, which holds the database.
88
+ *
89
+ * Separate from {@link SecretDispatcher} because the contracts differ in the way that matters: a write
90
+ * returns nothing, and opening a row returns the id the close needs. Folding an id-returning call into a
91
+ * `Promise<void>` writer is how the id gets dropped and the row never closes.
92
+ */
93
+ export interface SecretRotationRecorder {
94
+ /** Open an `in_progress` row and return its id. */
95
+ openRotation(request: SecretRotationOpenRequest): Promise<number>;
96
+ /** Close a row previously opened in the same environment. */
97
+ closeRotation(request: SecretRotationCloseRequest): Promise<void>;
98
+ }
99
+
100
+ /** A value-touching command before routing — the CLI resolves backend/scope from the registry. */
101
+ export interface SecretWrite {
102
+ mode: "create" | "update" | "delete";
103
+ name: string;
104
+ backend: SecretBackend;
105
+ scope: SecretScope;
106
+ rotatable: boolean;
107
+ valueType: SecretValueType;
108
+ /** The validated value for create/update; omitted for delete. */
109
+ value?: string;
110
+ /**
111
+ * The environment the operator named, or `undefined` when they named none.
112
+ *
113
+ * Optional, and that is the point. A missing `--env` on a `global` secret used to be resolved to the
114
+ * canonical environment before it got here, which erased the difference between *narrow this write*
115
+ * and *say nothing* — and `secretWriteTargets` refuses on exactly that difference. An
116
+ * `environment`-scoped write still requires one; the rule says so rather than a default hiding it.
117
+ */
118
+ requested?: ManagedEnvironment;
119
+ }
120
+
121
+ /** Every environment written before an interrupted fan-out threw. See {@link environmentsWrittenBeforeFailure}. */
122
+ const dispatched = partialWriteReport<ManagedEnvironment[]>(
123
+ "pithy.secrets.dispatchedBeforeFailure",
124
+ (value): value is ManagedEnvironment[] => Array.isArray(value) && value.every((env) => typeof env === "string"),
125
+ );
126
+
127
+ /**
128
+ * The environments an interrupted {@link dispatchSecretWrite} actually reached, in order. Empty when the
129
+ * thrown thing carries no report — which is the honest answer for a throw from anywhere else.
130
+ *
131
+ * **This is the whole of what the product can offer against a mid-fan-out fault.** There is no
132
+ * transaction across environments and no rollback — each is a separate Workflow in a separate Worker,
133
+ * and a compensating write is itself a Workflow that can fail. So a split that a fault created is not
134
+ * prevented; it is *reported*, by name, to the operator who has to repair it. `runSecretWrite` puts these
135
+ * environments in the failure audit and `pithy secrets` prints them before the error.
136
+ */
137
+ export function environmentsWrittenBeforeFailure(error: unknown): ManagedEnvironment[] {
138
+ return dispatched.read(error) ?? [];
139
+ }
140
+
141
+ /**
142
+ * Decide where a write may land (`secretWriteTargets`), then dispatch it to each target environment's
143
+ * manager, in order. A `global` write reaches every declared environment; an `environment` write reaches
144
+ * exactly one. Returns the environments written, for the CLI to report.
145
+ *
146
+ * **The decision is taken before the first dispatch, and it is not taken here.** `secretWriteTargets`
147
+ * owns it, `mintDeclaredSecrets` asks the same function, and a `global` write that names an environment
148
+ * is refused with nothing sent. That is what keeps an operator from creating a split by asking for one.
149
+ *
150
+ * **What it cannot do is undo a fan-out that died in the middle**, so it does not pretend to. The
151
+ * environments already written are attached to whatever ended the run and read back with
152
+ * {@link environmentsWrittenBeforeFailure}. Grown one at a time and pushed only *after* the write it
153
+ * describes lands, because an environment named before its dispatch resolved is a plan, and a plan
154
+ * printed as a result is how a partial run reports work it never did (#324).
155
+ *
156
+ * `declared` is the project's environment set, from the root `pithy.config.ts`. It is what a `global`
157
+ * write fans out across, so passing the wrong one writes a shared secret into some environments and not
158
+ * others — which is why it is an argument here rather than a default anything can forget.
159
+ */
160
+ export async function dispatchSecretWrite(
161
+ dispatcher: SecretDispatcher,
162
+ write: SecretWrite,
163
+ declared: DeclaredEnvironments | readonly string[],
164
+ ): Promise<ManagedEnvironment[]> {
165
+ const targets = secretWriteTargets({
166
+ name: write.name,
167
+ backend: write.backend,
168
+ scope: write.scope,
169
+ mode: write.mode,
170
+ requested: write.requested,
171
+ declared,
172
+ });
173
+ const written: ManagedEnvironment[] = [];
174
+ try {
175
+ for (const env of targets) {
176
+ await dispatcher.dispatch({
177
+ env,
178
+ mode: write.mode,
179
+ name: write.name,
180
+ value: write.value,
181
+ valueType: write.valueType,
182
+ rotatable: write.rotatable,
183
+ });
184
+ written.push(env);
185
+ }
186
+ } catch (error) {
187
+ throw dispatched.carry(error, written);
188
+ }
189
+ // What landed, not what was planned. They agree on every run that finishes, and the one that does not
190
+ // is the run whose answer matters.
191
+ return written;
192
+ }
@@ -0,0 +1,69 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * **How a run that failed part-way still says what it wrote.**
6
+ *
7
+ * A fan-out across environments has no transaction. The third write throws, the first two already
8
+ * landed, and the return value that would have named them never happens — so the operator is told
9
+ * nothing was done while two environments hold a brand-new signing key. That is the state #324 found in
10
+ * `mintDeclaredSecrets` and #325 found again in `dispatchSecretWrite`, which is the path every
11
+ * `pithy secrets create`, `update` and `rm` takes.
12
+ *
13
+ * The fix is the same both times, so it is written once here rather than twice at the call sites — two
14
+ * producers of one rule is the defect class this repository has produced four times over.
15
+ *
16
+ * **Carried, never replaced.** The report is attached to whatever ended the run and the same thing is
17
+ * rethrown: same class, same payload, same `instanceof`. The failure an operator has to read is
18
+ * unchanged, and what the run wrote is what makes the remedy in it safe to perform.
19
+ *
20
+ * **A symbol, non-enumerable, and not a payload field.** The payload is what the HTTP codec encodes and
21
+ * what `--json` prints as `{ error }`; this is the command's own output, on its own stream. Non-enumerable
22
+ * so nothing serializing an error picks it up by accident.
23
+ *
24
+ * **The one thing it cannot carry: a thrown primitive.** Nothing can be attached to a thrown string, so a
25
+ * caller that throws one loses its report. Replacing it with a wrapper would fix that and break the rule
26
+ * this is built on — the failure an operator reads must be the failure that happened. Everything in this
27
+ * repository throws a `PithyError`, and the kit's own linting says so; a dependency that throws a string
28
+ * is the case this trades away deliberately.
29
+ *
30
+ * **`Symbol.for`, and one key per report.** The key is registry-global, so a report attached inside
31
+ * `@pithy-sh/secrets` is readable from `@pithy-sh/cli` however the module graph resolved. Distinct keys
32
+ * because there are genuinely two reports with two shapes — what a mint run created, and which
33
+ * environments one dispatch reached — and a single shared key would let one reader decode the other's
34
+ * payload as its own.
35
+ */
36
+
37
+ /** Attach a report to a failing run, and read one back off whatever it threw. */
38
+ export interface PartialWriteReport<P> {
39
+ /** Attach `report` to `error` and hand the same thing back, to be rethrown unchanged. */
40
+ carry<E>(error: E, report: P): E;
41
+ /** The report this run attached, or `undefined` for a throw from anywhere else. */
42
+ read(error: unknown): P | undefined;
43
+ }
44
+
45
+ /**
46
+ * One report channel, keyed by `key`. Call this once per module, at module scope: the key is the
47
+ * contract between the writer and the reader, and one built per call would be a channel nobody else
48
+ * can open.
49
+ *
50
+ * `isReport` is what makes reading safe. A carried value arrives as `unknown` — the error may have
51
+ * crossed a package boundary, or been constructed by something else entirely — so the reader narrows it
52
+ * rather than casting, exactly as every other boundary in this repository does.
53
+ */
54
+ export function partialWriteReport<P>(key: string, isReport: (value: unknown) => value is P): PartialWriteReport<P> {
55
+ const symbol = Symbol.for(key);
56
+ return {
57
+ carry<E>(error: E, report: P): E {
58
+ if (typeof error === "object" && error !== null) {
59
+ Object.defineProperty(error, symbol, { value: report, enumerable: false, configurable: true });
60
+ }
61
+ return error;
62
+ },
63
+ read(error: unknown): P | undefined {
64
+ if (typeof error !== "object" || error === null) return undefined;
65
+ const carried = (error as Record<symbol, unknown>)[symbol];
66
+ return isReport(carried) ? carried : undefined;
67
+ },
68
+ };
69
+ }
@@ -0,0 +1,98 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { RotationTrigger } from "../data/secretRotations";
5
+ import type { OpenRotation, RotationLedger } from "../rotation/rotationLedger";
6
+ import { rotationClosure } from "../rotation/rotationLedger";
7
+ import type { ManagedEnvironment } from "../scope";
8
+ import type { SecretRotationRecorder } from "./dispatch";
9
+
10
+ /**
11
+ * **The out-of-process {@link RotationLedger}: the same rows, written the only way the CLI can write them.**
12
+ *
13
+ * A rotation's history belongs in the environment's own secrets D1, and the CLI holds no handle to one —
14
+ * the master key is worker-only, which is why every value-touching command is a dispatch. So the ledger is
15
+ * dispatched too, through {@link SecretRotationRecorder}, and lands on the same `RotationTracker` the
16
+ * in-Worker ledger uses. That is the whole of `#379`'s fix: not a second mechanism for the command line,
17
+ * the same mechanism reached over a different wire.
18
+ *
19
+ * ## One row per target environment
20
+ *
21
+ * A `global` secret rotates in every declared environment at once, and each has its own ledger. A single
22
+ * aggregate row would have to live somewhere, and wherever it lived it would answer *is this environment's
23
+ * credential fresh* for environments it was not about. So this opens one row per target and closes each
24
+ * with {@link rotationClosure} against that environment — a fan-out that reached staging and stranded prod
25
+ * closes `success` in one ledger and `failed` in the other, which is what actually happened.
26
+ *
27
+ * ## A manager that cannot be reached costs a row, never the rotation
28
+ *
29
+ * Each open and each close is attempted independently and a failure is dropped. The rotation is the act;
30
+ * the row is the record of it. Refusing to roll a credential during the incident that demanded it, because
31
+ * one environment's manager would not answer, is the wrong trade in both directions — and the missing row
32
+ * is itself visible, as a gap in the history and a `lastRotatedAt` that did not move.
33
+ */
34
+
35
+ /**
36
+ * Who a command-line rotation is recorded as.
37
+ *
38
+ * **It names the path, not the person, and that is deliberate rather than a shortcut.** `createCliAudit`
39
+ * resolves an operator from the Cloudflare API token and falls back to `system, actorResolutionFailed`
40
+ * when there is none — so the honest answer to *who* is on the `secrets/rotated` audit event, where the
41
+ * resolver already runs. Writing a guessed name into the rotation row would put a second, weaker answer to
42
+ * the same question in a second place, and an incident review comparing them would have no way to tell
43
+ * which one was resolved. What this row does say — and what the audit trail cannot — is which of the two
44
+ * paths performed the act.
45
+ */
46
+ export const CLI_ROTATED_BY = "pithy secrets rotate";
47
+
48
+ /** What a dispatched ledger needs: where the rotation lands, and how the rows are labeled. */
49
+ export interface DispatchedRotationLedgerOptions {
50
+ /** Every environment this rotation writes to. One row is opened in each. */
51
+ targets: readonly ManagedEnvironment[];
52
+ /** What caused it. Defaults to `manual` — a person at a terminal. */
53
+ trigger?: Exclude<RotationTrigger, "baseline">;
54
+ /** Who to record. Defaults to {@link CLI_ROTATED_BY}. */
55
+ rotatedBy?: string;
56
+ }
57
+
58
+ /** A row this ledger managed to open, and where. */
59
+ interface OpenedRow {
60
+ env: ManagedEnvironment;
61
+ rotationId: number;
62
+ }
63
+
64
+ /** The CLI's {@link RotationLedger}, over the manager write-Workflow. */
65
+ export function dispatchedRotationLedger(
66
+ recorder: SecretRotationRecorder,
67
+ options: DispatchedRotationLedgerOptions,
68
+ ): RotationLedger {
69
+ const trigger = options.trigger ?? "manual";
70
+ const rotatedBy = options.rotatedBy ?? CLI_ROTATED_BY;
71
+ return {
72
+ async open(name: string): Promise<OpenRotation> {
73
+ const opened: OpenedRow[] = [];
74
+ for (const env of options.targets) {
75
+ try {
76
+ opened.push({ env, rotationId: await recorder.openRotation({ env, name, trigger, rotatedBy }) });
77
+ } catch {
78
+ // One manager short of a full history is a gap. It is not a reason to leave the credential alone.
79
+ }
80
+ }
81
+ return {
82
+ async close(outcome): Promise<void> {
83
+ for (const row of opened) {
84
+ try {
85
+ await recorder.closeRotation({
86
+ env: row.env,
87
+ rotationId: row.rotationId,
88
+ closure: rotationClosure(outcome, row.env),
89
+ });
90
+ } catch {
91
+ // The row stays `in_progress`, which reads as an attempt whose end is unknown — true.
92
+ }
93
+ }
94
+ },
95
+ };
96
+ },
97
+ };
98
+ }
@@ -0,0 +1,38 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { SecretInvalidValueError } from "../error/errors";
5
+ import type { SecretRegistryEntry } from "../registry";
6
+
7
+ /**
8
+ * The CLI's authoritative, client-side value validation (the A2 model). The worker cannot validate
9
+ * a not-yet-deployed secret's shape, so the CLI — which runs from the user's repo with the fresh
10
+ * registry — is the validator. A `text` value passes through; a `json` value is parsed and validated
11
+ * against the entry's schema, then returned re-serialized (canonical form) for dispatch.
12
+ *
13
+ * Errors are redacted: only `path:code`, never `issue.message`/the payload, which can echo the
14
+ * secret material. This mirrors the read seam's `parseValue` so write-time and read-time validation
15
+ * report identically.
16
+ */
17
+ export function validateSecretValue(entry: SecretRegistryEntry, name: string, raw: string): string {
18
+ if (entry.valueType === "text") return raw;
19
+
20
+ let parsed: unknown;
21
+ try {
22
+ parsed = JSON.parse(raw);
23
+ } catch (cause) {
24
+ throw new SecretInvalidValueError(
25
+ { message: `Secret '${name}' is not valid JSON.`, detail: `json secret '${name}' failed to parse` },
26
+ { cause },
27
+ );
28
+ }
29
+ const result = entry.schema.safeParse(parsed);
30
+ if (!result.success) {
31
+ const summary = result.error.issues.map((i) => `${i.path.join(".") || "<root>"}:${i.code}`).join(", ");
32
+ throw new SecretInvalidValueError({
33
+ message: `Secret '${name}' failed validation.`,
34
+ detail: `json secret '${name}' failed registry validation: ${summary}`,
35
+ });
36
+ }
37
+ return JSON.stringify(result.data);
38
+ }