@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,167 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { VersionedValue } from "../crypto/versionedValue";
6
+
7
+ /**
8
+ * The dev secrets file — the one hand-edited input for **local dev** secret values, and the format
9
+ * of the file the CLI seeds from.
10
+ *
11
+ * `.dev.vars` is what wrangler says it is: env bindings, `UPPER_SNAKE`, in the worker's directory
12
+ * because wrangler reads it there. Secrets live here instead, keyed by the **registry secret name
13
+ * verbatim** — `<capability>-<what>`, kebab — because that name is the join key into the registry,
14
+ * and a mapping table between the two would be one more thing to rot.
15
+ *
16
+ * **Nothing in this file names a destination.** The registry already knows each secret's `backend`,
17
+ * so the seeder derives where a value goes, and the file and the registry can never disagree.
18
+ *
19
+ * **And nothing here names its location, either.** Since #156 the file is machine-local, at
20
+ * `<config>/<project>/secrets.jsonc` — outside every checkout, resolved by the CLI. This package is
21
+ * Workers-runtime code with no `node:` imports and no filesystem: it parses text and returns what
22
+ * should be written. The path a caller passes is for error messages, and is always absolute in
23
+ * practice; {@link DEV_SECRETS_FILE} is only what an error says when a caller named nothing.
24
+ */
25
+
26
+ /** The file's bare name, for an error raised by a caller that passed no path. Mode `0600`. */
27
+ export const DEV_SECRETS_FILE = "secrets.jsonc";
28
+
29
+ /**
30
+ * The envelope, spelled out, so every error can show the shape rather than describe it.
31
+ *
32
+ * Here rather than in one reader, because two readers now say it — the loader and the payload reader —
33
+ * and a shape quoted twice is a shape that will be quoted two ways.
34
+ */
35
+ export const ENVELOPE_SHAPE = '{ "currentVersion": "1", "versions": { "1": <value> } }';
36
+
37
+ /**
38
+ * The committed example — the one artifact about secrets that stays in an adopter's repository, and
39
+ * documentation only. It is never copied to a working file: `pithy add` writes the real one, outside
40
+ * the checkout, and there is nothing in the project for it to sit beside.
41
+ */
42
+ export const DEV_SECRETS_EXAMPLE_FILE = ".dev.secrets.example.jsonc";
43
+
44
+ /**
45
+ * ## The rule, stated once (#323)
46
+ *
47
+ * **A secret's entry in this file is the precise payload its destination receives. Nothing wraps it,
48
+ * nothing unwraps it, and no secret is an exception.**
49
+ *
50
+ * The registry says what that payload is, per secret, and `devSecretPayload` (`./seedDevSecrets`) is
51
+ * the one reading of it:
52
+ *
53
+ * | secret | destination | payload |
54
+ * |---|---|---|
55
+ * | any ordinary secret | a D1 row, a `.dev.vars` line, a Secrets Store entry | a {@link DevSecretEnvelope} |
56
+ * | a `bootstrap` secret | its binding, read before any decoder exists | the value itself |
57
+ *
58
+ * There is one widening, for the person hand-editing the file: a `json` value is written as its own
59
+ * structure rather than as an escaped string inside a string, and the reader serializes it on the way
60
+ * out. That is a JSON-in-JSON concession, not a wrapper — nothing is added and nothing is removed.
61
+ *
62
+ * **Why `bootstrap` is not an exception to the rule but an instance of it.** `SECRETS_ENCRYPTION_KEYS`
63
+ * is what the envelope decoder needs in order to exist, so its binding has always carried a bare
64
+ * `EncryptionConfig` and `resolveEncryptionConfig` has always parsed one. The file used to state an
65
+ * envelope around it and the seeder used to take that envelope off again — a `currentVersion` for one
66
+ * concept written twice, carrying no information, and reported as file corruption by two readers in a
67
+ * row. Now the file states what the binding gets.
68
+ */
69
+
70
+ /**
71
+ * One secret's value in the file, for **every secret whose destination receives an envelope** — which
72
+ * is every secret that is not `bootstrap`. Always full, even for a single-version text secret.
73
+ *
74
+ * **This is the whole reason the format is unambiguous, not ceremony — do not "simplify" it away.**
75
+ * With optional envelopes a JSON-valued secret's own object cannot be told apart from an envelope
76
+ * without a marker or a heuristic: `{ "clientId": …, "clientSecret": … }` and
77
+ * `{ "currentVersion": …, "versions": … }` are both just objects. Requiring the envelope wherever the
78
+ * destination takes one means the outer object is *always* the envelope, and a JSON secret's own
79
+ * object sits unambiguously inside `versions`. It also matches what is actually stored, so dev stops
80
+ * being a shape production never sees — and `pithy secrets rotate --env dev` exercises the real
81
+ * rotation path. **The registry, not a heuristic, is what says which secrets those are.**
82
+ *
83
+ * The shape is {@link VersionedValue}'s, widened in exactly one place: a stored version is a string
84
+ * (a `json` secret stores its serialized form), while a hand-written one is the value itself, so that
85
+ * an adopter writes real structure rather than an escaped string inside a string. The seeder converts,
86
+ * validating each version against the registry entry's schema on the way.
87
+ *
88
+ * **Strict, and that is what makes the guarantee above true (#323).** Stripping unknown keys instead
89
+ * of refusing them is the same permissiveness the doc argues against, arriving by the back door: an
90
+ * `EncryptionConfig` is `{ currentVersion, versions, lastRotatedAt }`, a structural *superset* of an
91
+ * envelope, so a stripping parser accepted `SECRETS_ENCRYPTION_KEYS`' own value written bare, dropped
92
+ * `lastRotatedAt`, and left a base64 string where a nested object belongs. The failure then surfaced
93
+ * three frames later, naming neither the file nor the secret. Refusing here says it once, in place.
94
+ */
95
+ export const DevSecretEnvelope = VersionedValue.extend({
96
+ versions: z
97
+ .record(z.string(), z.unknown())
98
+ .describe(
99
+ "Every still-valid version: version key (a stringified integer) → the value itself — a string for a `text` secret, its own object for a `json` one. Always at least one entry.",
100
+ ),
101
+ })
102
+ .strict()
103
+ .describe(
104
+ "One secret's value in the dev secrets file, for every secret whose destination receives an envelope: an explicit current-version pointer plus every still-valid version, and nothing else. Always full, never partial.",
105
+ );
106
+ export type DevSecretEnvelope = z.output<typeof DevSecretEnvelope>;
107
+
108
+ /**
109
+ * What was found where an envelope belongs, as one clause for the caller's sentence — `it carries
110
+ * lastRotatedAt …`, `it is a string`, `it has no versions`.
111
+ *
112
+ * **Keys and types only, never a value.** The reason a shape error is worth saying at all is that the
113
+ * adopter is looking at a file they hand-wrote; the reason it must say this much and no more is that
114
+ * the same file holds OAuth client secrets, and this sentence reaches a terminal and a log.
115
+ */
116
+ export function describeNotEnvelope(value: unknown, error: z.ZodError): string {
117
+ if (value === null) return "it is null";
118
+ if (Array.isArray(value)) return "it is an array";
119
+ if (typeof value !== "object") return `it is a ${typeof value}`;
120
+ const unrecognized = error.issues.flatMap(unrecognizedKeys).sort();
121
+ if (unrecognized.length > 0) {
122
+ return `it carries ${unrecognized.join(", ")} beside currentVersion and versions, so it is a value's own object rather than an envelope around one`;
123
+ }
124
+ const absent = ["currentVersion", "versions"].filter((key) => !Object.hasOwn(value, key));
125
+ if (absent.length > 0) return `it has no ${absent.join(" and no ")}`;
126
+ return "currentVersion must be a string and versions a map of version key to value";
127
+ }
128
+
129
+ /** The keys one `unrecognized_keys` issue names, or none — narrowed rather than cast (no `any`). */
130
+ function unrecognizedKeys(issue: z.core.$ZodIssue): string[] {
131
+ if (issue.code !== "unrecognized_keys") return [];
132
+ return issue.keys.filter((key): key is string => typeof key === "string");
133
+ }
134
+
135
+ /**
136
+ * The whole file: registry secret name → **payload**. A record rather than a fixed object, because the
137
+ * declared set is whatever capabilities the project composes — the registry is the authority on that,
138
+ * not this schema.
139
+ *
140
+ * **The value is `unknown` here, and that is the shape of the rule rather than a gap in it (#323).**
141
+ * Which payload a name takes is the registry's answer, not this schema's: an ordinary secret's is a
142
+ * {@link DevSecretEnvelope}, a `bootstrap` secret's is its own value. A schema that named one of them
143
+ * for every entry would be the wrapper this issue removed, written as a type. `devSecretPayload`
144
+ * (`./seedDevSecrets`) is where a name and a registry entry meet, and it is the kit's only payload reader.
145
+ */
146
+ export const DevSecretsFile = z
147
+ .record(z.string(), z.unknown())
148
+ .describe(
149
+ "The parsed dev secrets file: registry secret name (`<capability>-<what>`) → the exact payload its destination receives. The registry decides which shape that is, and where the value is seeded.",
150
+ );
151
+ export type DevSecretsFile = z.output<typeof DevSecretsFile>;
152
+
153
+ /**
154
+ * The entry a freshly-minted dev value is written into the file as — **the payload its destination
155
+ * receives**, and nothing around it.
156
+ *
157
+ * For an ordinary secret that is a one-version envelope, the counterpart of `initialVersionedValue`
158
+ * over the file's wider version type. For a `bootstrap` secret it is the value, because the value is
159
+ * what its binding carries.
160
+ *
161
+ * **Every writer goes through here.** `pithy add secrets`, the provisioners and the seeder's own mint
162
+ * all call it, so there is one statement of what a fresh entry looks like. A second
163
+ * writer composing the envelope inline is how #323 got two shapes for one file.
164
+ */
165
+ export function initialDevSecret(entry: { bootstrap?: boolean }, value: unknown): unknown {
166
+ return entry.bootstrap === true ? value : { currentVersion: "1", versions: { "1": value } };
167
+ }
@@ -0,0 +1,128 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
5
+ import { parse } from "comment-json";
6
+ import type { SecretRegistry } from "../registry";
7
+ import { DEV_SECRETS_FILE, DevSecretsFile, ENVELOPE_SHAPE } from "./devSecretsFile";
8
+ import { devSecretPayload } from "./seedDevSecrets";
9
+
10
+ /**
11
+ * The dev secrets file boundary. Text in, a validated {@link DevSecretsFile} out — and every way
12
+ * the text can be wrong comes back as a `ValidationError` that names **which secret** and what to do.
13
+ * A Zod dump is not an answer to "my app will not start": the adopter hand-writes this file, so the
14
+ * error has to read like the fix.
15
+ *
16
+ * **JSONC**, parsed with `comment-json`, so the comments that say what a secret is for and the
17
+ * trailing comma left behind by deleting a line both survive. The parse is comment-stripping: this
18
+ * returns data. A caller writing minted values back re-parses with `comment-json` and edits that
19
+ * tree, which is how an adopter's comments survive a write.
20
+ *
21
+ * **Bytes are the caller's problem.** This package is Workers-runtime code and holds no `node:`
22
+ * imports; the CLI reads the file (and owns its `0600` mode, and its absence meaning "no secrets
23
+ * yet"). The same rule the seeder follows for `.dev.vars`: return what should be written, never write.
24
+ *
25
+ * **The registry is optional here, and what it buys is exactly the shape check (#323).** Which payload
26
+ * a name takes — an envelope, or the value itself for a `bootstrap` secret — is the registry's answer,
27
+ * so a loader without one cannot judge a slot and does not pretend to: it establishes that the text is
28
+ * a JSONC object of secret names, and `devSecretPayload` judges each value where the registry is in
29
+ * hand. Given a registry, it asks that same function per declared name, so a bad shape is caught at
30
+ * the boundary and in one wording. A name the registry does not declare is left alone either way —
31
+ * a removed capability must not brick dev, and `seedDevSecrets` reports it as undeclared.
32
+ */
33
+
34
+ /** Options for {@link loadDevSecrets}. */
35
+ export interface LoadDevSecretsOptions {
36
+ /**
37
+ * The path to name in errors — the one the caller read, so a message points at a real file in a
38
+ * multi-project checkout. Defaults to {@link DEV_SECRETS_FILE}.
39
+ */
40
+ path?: string;
41
+ /**
42
+ * The project's registry, when the caller has one. Every declared name's value is then checked
43
+ * against the payload its destination takes, which is the only way that question has an answer.
44
+ *
45
+ * Absent for a caller that has no project loaded — `pithy secrets edit` on a project whose config
46
+ * will not load is the case that matters, and it is the command an adopter reaches for to *fix* that.
47
+ */
48
+ registry?: SecretRegistry;
49
+ }
50
+
51
+ /**
52
+ * Parse and validate the dev secrets file. Throws `validation/invalid_input` naming the offending
53
+ * secret. Nothing thrown from here carries a value: `message`, `action` and `detail` all reach a
54
+ * terminal or a log, and the file holds OAuth client secrets.
55
+ */
56
+ export function loadDevSecrets(source: string, options: LoadDevSecretsOptions = {}): DevSecretsFile {
57
+ const path = options.path ?? DEV_SECRETS_FILE;
58
+
59
+ // A file with nothing in it is a project with no secrets yet — the same answer an absent file gets,
60
+ // and the same one the write path already gives when it merges a mint into empty content. A
61
+ // `touch`ed file used to fail `pithy add` outright, which is one state with two answers.
62
+ if (source.trim().length === 0) return {};
63
+
64
+ let parsed: unknown;
65
+ try {
66
+ parsed = parse(source, undefined, true);
67
+ } catch (cause) {
68
+ // **No `cause`, deliberately.** `comment-json`'s `SyntaxError` reads `Unexpected token '"', "{ …
69
+ // the entire file … }" is not valid JSON` — it embeds the source, so attaching it would carry
70
+ // every OAuth client secret in the file into whatever logs or prints the error chain. The
71
+ // position is kept, because a line and a column are not a value.
72
+ throw new ValidationError({
73
+ message: `${path} is not valid JSONC.`,
74
+ // **No command named here.** It said "run pithy seed", and the seed is rarely what failed —
75
+ // `pithy dev`, `pithy add` and `pithy secrets edit` all read this file, and the last of those
76
+ // printed advice to run itself while the adopter was inside it (#157). This function does not
77
+ // know which command is running, so it says what is wrong and leaves the command to the caller.
78
+ // The path is in `message`, which is what an adopter actually needs: the file is outside the
79
+ // checkout (#156), so naming it is the difference between a fixable fault and a hunt.
80
+ action: "Fix the syntax and try again. Comments and trailing commas are fine; unquoted keys are not.",
81
+ detail: `dev secrets file '${path}' failed to parse${position(cause)}`,
82
+ });
83
+ }
84
+
85
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
86
+ throw new ValidationError({
87
+ message: `${path} must be an object of secret name to value.`,
88
+ action: `Write each secret as "<capability>-<what>": the payload its destination receives — ${ENVELOPE_SHAPE} for an ordinary secret.`,
89
+ detail: `dev secrets file '${path}' top level is ${Array.isArray(parsed) ? "an array" : typeof parsed}`,
90
+ });
91
+ }
92
+
93
+ const file: DevSecretsFile = {};
94
+ const registry = options.registry;
95
+ for (const [name, value] of Object.entries(parsed as Record<string, unknown>)) {
96
+ // The registry entry, or nothing. `Object.hasOwn`, never `in`: `in` walks the prototype chain, so a
97
+ // secret named `toString` would be judged against an `Object.prototype` member.
98
+ const entry = registry && Object.hasOwn(registry, name) ? registry[name] : undefined;
99
+ // A name with no value at all, which is knowable without a registry and is the one shape check left
100
+ // here. `comment-json` parses `{ "a-b": }` to `undefined` rather than refusing it, so a half-deleted
101
+ // line reached the seeder as a declared secret holding nothing.
102
+ if (value === undefined) {
103
+ throw new ValidationError({
104
+ message: `Secret '${name}' in ${path} has no value.`,
105
+ action: `Give it one, or delete the line. An ordinary secret's is ${ENVELOPE_SHAPE}.`,
106
+ detail: `dev secrets file '${path}': '${name}' has no value`,
107
+ });
108
+ }
109
+ // Judged, and then discarded: what this returns is the file's own values, not the converted ones.
110
+ // A keyspace is not judged at all — it has no single value, and `seedDevSecrets` owns that refusal.
111
+ if (entry && !entry.keyed) devSecretPayload(entry, name, value, path);
112
+ file[name] = value;
113
+ }
114
+ return DevSecretsFile.parse(file);
115
+ }
116
+
117
+ /**
118
+ * The parse error's position, as ` at line L column C`, or nothing when the parser did not give one.
119
+ *
120
+ * The only part of a `SyntaxError` from `comment-json` that is safe to repeat. Its `message` quotes
121
+ * the source it choked on — the whole file — so nothing else from it is carried anywhere.
122
+ */
123
+ function position(cause: unknown): string {
124
+ if (typeof cause !== "object" || cause === null) return "";
125
+ const { line, column } = cause as { line?: unknown; column?: unknown };
126
+ if (typeof line !== "number" || typeof column !== "number") return "";
127
+ return ` at line ${line} column ${column}`;
128
+ }