@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.
- package/LICENSE +21 -0
- package/README.md +15 -0
- package/package.json +52 -0
- package/pithy.manifest.json +48 -0
- package/src/admin/health.ts +94 -0
- package/src/admin/status.ts +462 -0
- package/src/audit/actions.ts +53 -0
- package/src/capability.ts +219 -0
- package/src/cli/audit.ts +33 -0
- package/src/cli/dispatch.ts +192 -0
- package/src/cli/partialWrite.ts +69 -0
- package/src/cli/rotationLedger.ts +98 -0
- package/src/cli/validate.ts +38 -0
- package/src/cli/writeTargets.ts +125 -0
- package/src/cloudflare-test.d.ts +16 -0
- package/src/crypto/envelope.ts +188 -0
- package/src/crypto/versionedValue.ts +82 -0
- package/src/data/secretRotations.ts +49 -0
- package/src/data/statusDb.ts +29 -0
- package/src/data/systemSecrets.ts +44 -0
- package/src/data/tables.ts +16 -0
- package/src/dev/devSecretsFile.ts +167 -0
- package/src/dev/loadDevSecrets.ts +128 -0
- package/src/dev/seedDevSecrets.ts +447 -0
- package/src/env/bindings.ts +84 -0
- package/src/error/errors.ts +155 -0
- package/src/http/guards.ts +107 -0
- package/src/http/responses.ts +225 -0
- package/src/http/rotate.ts +224 -0
- package/src/http/routes.ts +300 -0
- package/src/http/schemas.ts +53 -0
- package/src/http/view.ts +74 -0
- package/src/index.ts +50 -0
- package/src/keyspace.ts +70 -0
- package/src/keyspaceWrite.ts +135 -0
- package/src/management/writeSecret.ts +120 -0
- package/src/manager/configWriter.ts +19 -0
- package/src/manager/dispatcher.ts +142 -0
- package/src/manager/managerRegistry.ts +53 -0
- package/src/manager/retryPolicy.ts +44 -0
- package/src/manager/rotationWorkflow.ts +26 -0
- package/src/manager/secretsConfigWriter.ts +61 -0
- package/src/manager/worker.ts +119 -0
- package/src/manager/wrangler.jsonc +76 -0
- package/src/manager/writeWorkflow.ts +162 -0
- package/src/migrations/0001_init.ts +53 -0
- package/src/mintValue.ts +53 -0
- package/src/provision/provisionSecrets.ts +206 -0
- package/src/provision/resolveManagerConfig.ts +175 -0
- package/src/registry.ts +453 -0
- package/src/rotation/atRestKeyRotation.ts +146 -0
- package/src/rotation/keyRotation.ts +139 -0
- package/src/rotation/rotateValue.ts +412 -0
- package/src/rotation/rotationLedger.ts +167 -0
- package/src/rotation/valueRotator.ts +76 -0
- package/src/scope.ts +120 -0
- package/src/secretsStore.ts +765 -0
- package/src/sharedSecretsStore.ts +187 -0
- package/src/store/rotationTracker.ts +189 -0
- package/src/store/systemSecretsStore.ts +223 -0
- package/src/test-utils/devEncryptionKeys.ts +30 -0
- package/src/test-utils/secretFixtures.ts +178 -0
- package/src/valueBearing.ts +42 -0
- 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
|
+
}
|
package/src/cli/audit.ts
ADDED
|
@@ -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
|
+
}
|