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