@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 type { ManagedEnvironment } from "../scope";
6
+ import type { SecretRotationOutcome } from "./rotateValue";
7
+
8
+ /**
9
+ * **Where a rotation is recorded — stated once, so a third caller inherits it.**
10
+ *
11
+ * `#379` is what happens when it is not. `runWriteSecret` seeded a rotation row only on a *first* write,
12
+ * a rotation dispatches `mode: "update"`, and so a `pithy secrets rotate` that fully succeeded left
13
+ * `lastRotatedAt` untouched and the secret reporting **overdue forever** — while a rotation from the
14
+ * control-plane route recorded correctly. Two paths to one act, disagreeing about whether the act
15
+ * happened, and an operator rotating on the command line during an incident told by the product that they
16
+ * did not.
17
+ *
18
+ * The fix is not a second recorder beside the first. It is that {@link RotationLedger} is an argument to
19
+ * `rotateSecretValue`, and **required**: the one function that performs a rotation is the one that records
20
+ * it, and a caller that would rather not cannot express that.
21
+ *
22
+ * ## Two implementations, because there are two kinds of caller and only one of them holds the database
23
+ *
24
+ * The ledger is `pithy_secrets_rotations`, in the **per-environment secrets D1**. A Worker holds one
25
+ * directly (`trackerRotationLedger`, over `RotationTracker`). The CLI holds none — the master key is
26
+ * worker-only and every value-touching command is a dispatch to that environment's manager Workflow — so
27
+ * it records the same way it writes (`dispatchedRotationLedger`, in `../cli/rotationLedger.ts`). Same
28
+ * rows, same columns, same sentence on a failure, because both close through {@link rotationClosure}.
29
+ *
30
+ * ## Opened before the roll, closed after it
31
+ *
32
+ * A rotator that never returns still leaves a trace: an `in_progress` row naming the secret and who asked.
33
+ * That is the difference between an incident review that can see the attempt and one that reads a gap.
34
+ * It is opened only after every refusal, so a rotation that never started writes no history at all.
35
+ *
36
+ * ## A first write is not a rotation, and the ledger says which it was
37
+ *
38
+ * `RotationTracker.recordBaseline` still writes the `baseline` row a brand-new secret gets, and it stays
39
+ * exactly where it is — on the *create* branch of `runWriteSecret`. A first write and a rotation are
40
+ * different events: one establishes a value, the other replaces one. Conflating them to make a count come
41
+ * out would mean `pithy secrets update` — a typo fix, a value pasted from a console — silently advancing
42
+ * a freshness clock nobody rotated. So the writer never infers, the rotator declares, and the row's
43
+ * `trigger` column (`baseline` / `manual` / `cron`) is what tells a reader which of the two it is reading.
44
+ */
45
+
46
+ /**
47
+ * Why a rotation row closed `failed`, as a code rather than as free text.
48
+ *
49
+ * **Free text is what a value gets pasted into.** `admin/status.ts` refuses to publish `error_message`
50
+ * for exactly that reason, and the CLI's closure crosses a process boundary before it reaches the column
51
+ * — so what crosses is one of these three, and the sentence is composed inside the Worker by
52
+ * {@link rotationFailureText}. There is no shape of close request that could carry a credential.
53
+ */
54
+ export const RotationFailureReason = z
55
+ .enum(["roll-failed", "not-recorded", "not-rotated"])
56
+ .describe(
57
+ "Why a rotation row closed failed. `roll-failed` — the rotator never answered, so whether the issuer rolled is unknown. `not-recorded` — the issuer rolled and this environment's store did not take the value, which is the incident that needs a human now. `not-rotated` — nothing was rolled and nothing was written, so the previous value is still live.",
58
+ );
59
+ export type RotationFailureReason = z.output<typeof RotationFailureReason>;
60
+
61
+ /**
62
+ * How one rotation row closes, in one environment.
63
+ *
64
+ * The two members of `RotationStatus` a finished attempt can hold — `in_progress` is what the row already
65
+ * says, and closing to it would be closing nothing. A discriminated union rather than an optional reason
66
+ * so a `failed` closure cannot be constructed without one.
67
+ */
68
+ export const RotationClosure = z
69
+ .discriminatedUnion("status", [
70
+ z
71
+ .object({
72
+ status: z
73
+ .literal("success")
74
+ .describe("The new value reached this environment's store, so the row closes success."),
75
+ })
76
+ .describe("A rotation that landed here."),
77
+ z
78
+ .object({
79
+ status: z.literal("failed").describe("The new value never reached this environment's store."),
80
+ reason: RotationFailureReason.describe("Which failure it was, as a code the Worker renders."),
81
+ })
82
+ .describe("A rotation that did not land here, and why."),
83
+ ])
84
+ .describe("How one rotation row closes in one environment: success, or failed with a reason.");
85
+ export type RotationClosure = z.output<typeof RotationClosure>;
86
+
87
+ /**
88
+ * Every code that may close a rotation row `failed` — the domain of the column's fixed text.
89
+ *
90
+ * **A superset of {@link RotationFailureReason}, and the two are not the same question.** A *reason* is
91
+ * how a value rotation's closure ended, and it crosses a process boundary as data, so it is a Zod enum
92
+ * parsed on arrival. A *code* is what `RotationTracker.markFailure` accepts, and the whole-store at-rest
93
+ * key rotation closes rows too — under `AT_REST_ROTATION_NAME`, from inside a Worker, never over a wire.
94
+ * Widening the wire enum to admit a code no closure can produce would let a caller name a rotation it
95
+ * cannot perform; keeping the code a TypeScript union keeps that impossible and still makes every reason
96
+ * a code by construction.
97
+ *
98
+ * The set is closed on purpose. `#386` is what an open one costs: the at-rest path composed `cause.message`
99
+ * into this column, and the exceptions reaching that catch come from decryption, envelope decoding and
100
+ * config parsing — the paths whose message text can carry key material.
101
+ */
102
+ export type RotationFailureCode = RotationFailureReason | "at-rest-incomplete";
103
+
104
+ /** The sentence written into a failed row's `error_message`. Fixed text, chosen by a code, never composed from an exception. */
105
+ const FAILURE_TEXT: Record<RotationFailureCode, string> = {
106
+ "roll-failed": "the rotator did not answer, so nothing was recorded here",
107
+ "not-recorded": "rolled at the issuer, and not recorded here",
108
+ "not-rotated": "not rotated: nothing was rolled and nothing was written",
109
+ "at-rest-incomplete": "the at-rest key rotation did not finish",
110
+ };
111
+
112
+ /**
113
+ * The failure sentence for a code.
114
+ *
115
+ * It names no environment, because it does not have to: the row lives in that environment's own D1, so
116
+ * *here* is unambiguous and a name copied into the text is a name that can be wrong.
117
+ *
118
+ * It names no cause either, and that is the harder half. `at-rest-incomplete` covers a write-back that
119
+ * failed, a config that would not parse and a batch that would not decrypt, and it says none of them —
120
+ * the throw still carries that, in a `PithyError`'s `detail`, which the HTTP codec strips. What a caller
121
+ * wants here is one sentence a row can hold; what it must not be able to write is the exception's own.
122
+ */
123
+ export function rotationFailureText(code: RotationFailureCode): string {
124
+ // **The fallback is not dead code, and it is the half a type cannot do.** `markFailure`'s parameter is
125
+ // the gate, and a gate in the type system is absent at runtime: a JavaScript consumer, a cast, or a code
126
+ // some future revision parses off a wire all reach here holding something that is not a member. Such a
127
+ // caller gets a fixed sentence too. What no caller gets is the string it arrived with — which is the
128
+ // whole invariant, and the reason it is stated here rather than trusted to the signature alone.
129
+ return FAILURE_TEXT[code] ?? "the rotation failed";
130
+ }
131
+
132
+ /**
133
+ * How the row in `environment` closes, given what the whole run did.
134
+ *
135
+ * **Per environment, and that is the point.** A fan-out that reached staging and stranded prod is a
136
+ * success in one ledger and an incident in the other, and one verdict written to both would either
137
+ * advance a freshness clock over a dead credential or report a landed value as lost. `recorded` is the
138
+ * list of environments the value actually reached, grown as each write landed, so it is the only thing
139
+ * here that can answer the question.
140
+ */
141
+ export function rotationClosure(outcome: SecretRotationOutcome, environment: ManagedEnvironment): RotationClosure {
142
+ if (outcome.recorded.includes(environment)) return { status: "success" };
143
+ if (outcome.rollFailed === true) return { status: "failed", reason: "roll-failed" };
144
+ if (outcome.rolled) return { status: "failed", reason: "not-recorded" };
145
+ return { status: "failed", reason: "not-rotated" };
146
+ }
147
+
148
+ /** A rotation row that has been opened and is waiting to be closed. */
149
+ export interface OpenRotation {
150
+ /**
151
+ * Close what was opened, with what the run actually did. Called once, on every path below the open —
152
+ * including the failing ones, which are the paths whose record matters most.
153
+ */
154
+ close(outcome: SecretRotationOutcome): Promise<void>;
155
+ }
156
+
157
+ /**
158
+ * The rotation ledger, from the two sides that can reach one.
159
+ *
160
+ * `open` takes the secret's name and nothing else: *who* asked and *what triggered it* belong to the
161
+ * implementation, which is the thing that knows — a Worker has a verified control-plane subject, the CLI
162
+ * has a command. Passing them per call would let one caller record a rotation as somebody else.
163
+ */
164
+ export interface RotationLedger {
165
+ /** Open an `in_progress` row for `name`, before anything is rolled. */
166
+ open(name: string): Promise<OpenRotation>;
167
+ }
@@ -0,0 +1,76 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { SecretRegistryEntry } from "../registry";
5
+ import type { ManagedEnvironment } from "../scope";
6
+
7
+ /**
8
+ * **The value-rotation seam, live since #367.** It was declared inert by #322 — "no concrete provider
9
+ * ships yet, and nothing in the package calls it" — because the tag was landing ahead of the act. The tag
10
+ * is `rotation.kind: "provider"`: *replaced by calling the issuer, which returns the new value.* This is
11
+ * the code behind that tag, and `pithy secrets rotate` is what calls it.
12
+ *
13
+ * A function cannot cross into `pithy.manifest.json`, so the declaration and the implementation are two
14
+ * things by construction: the manifest carries `rotation`, which any client branches on, and the registry
15
+ * carries this, which only the process performing a rotation ever holds. `defineSecretRegistry` refuses a
16
+ * pair that disagrees — see `SecretRegistryEntryBase.rotator`.
17
+ *
18
+ * ## The one rule this interface exists to state
19
+ *
20
+ * **`roll` mutates somebody else's system, so it runs exactly once and is never retried.** Everything
21
+ * downstream — storing the value, recording the rotation — retries against what this returned. Rolling
22
+ * again does not repair a failed store; it issues a *third* credential and loses the second, which is the
23
+ * failure the retry was meant to prevent, arriving by way of the retry. `rotateSecretValue` is where that
24
+ * ordering is enforced, and it is enforced there rather than trusted here.
25
+ *
26
+ * ## Retiring the old credential is not here, deliberately
27
+ *
28
+ * #322's first draft hung a `cleanup` off this to deactivate the previous credential once its overlap had
29
+ * elapsed. It is gone: retirement runs on its own clock, long after the rotation, and the overlap window
30
+ * exists precisely so that things issued under the old value keep working. A method called at rotation
31
+ * time that ends the overlap early is data loss wearing a tidy name. `ROTATION_SWEEP` is the kit's existing
32
+ * shape for a scheduled retirement, and that is where it belongs when it lands.
33
+ */
34
+
35
+ /** What a rotator is told about the secret it is replacing. */
36
+ export interface ValueRotationContext {
37
+ /** The registry name of the secret being rotated. */
38
+ name: string;
39
+ /** The environment whose value is being replaced. A rotator holding per-environment credentials needs it. */
40
+ env: ManagedEnvironment;
41
+ /** The registry entry, for a rotator that reads its own declaration rather than restating it. */
42
+ entry: SecretRegistryEntry;
43
+ /**
44
+ * The value being replaced, **when the caller can read it** — and the CLI cannot.
45
+ *
46
+ * Optional, and the optionality is a fact about the architecture rather than a convenience. A `d1`
47
+ * secret is sealed under a master key that never leaves its environment's manager Worker; the CLI's only
48
+ * read seam (`SecretProbe`) resolves to a bit, and "there is no shape of request that would make it"
49
+ * return a value. So `pithy secrets rotate` calls a rotator with this absent, always.
50
+ *
51
+ * A rotator whose provider authenticates with the credential being replaced — GitLab's token rotation is
52
+ * the case — therefore cannot be driven from the CLI, and must say so plainly rather than fail obscurely.
53
+ * It has its own credentials, or it refuses.
54
+ */
55
+ currentValue?: string;
56
+ }
57
+
58
+ /** What a rotator gives back. The successor value, and nothing else — there is nothing else to carry. */
59
+ export interface ValueRotationResult {
60
+ /** The freshly-issued value. Stored, never returned to a caller, never printed, never logged. */
61
+ newValue: string;
62
+ }
63
+
64
+ /**
65
+ * A per-secret value rotator. Supplied on the registry entry by whoever owns the secret — the capability
66
+ * that declares it, or the adopter who declared their own.
67
+ */
68
+ export interface ValueRotator {
69
+ /**
70
+ * Roll the credential at its issuer and return the successor.
71
+ *
72
+ * **Called at most once per rotation, and never speculatively.** By the time this returns, the previous
73
+ * value may already be dead at the provider and this value may be the only copy in existence.
74
+ */
75
+ roll(context: ValueRotationContext): Promise<ValueRotationResult>;
76
+ }
package/src/scope.ts ADDED
@@ -0,0 +1,120 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { type DeclaredEnvironments, isValidEnvironment } from "@pithy-sh/core/src/naming/environment";
5
+ import { z } from "zod";
6
+ import type { SecretBackend, SecretScope } from "./registry";
7
+
8
+ /**
9
+ * The environments Pithy manages remotely — **every environment the project declares, and no other**.
10
+ *
11
+ * ## Declared and managed are one set. The decision, and the argument for it.
12
+ *
13
+ * This was a closed enum, `["staging", "prod"]`, pinned against core's `ENVIRONMENTS` by a test. That made
14
+ * the type system say a custom environment could not be managed while the rest of the CLI cheerfully
15
+ * accepted one: `pithy migrate --env live` ran, `<project>-live-db` would have been created, and
16
+ * `pithy secrets provision` — iterating the enum — gave `live` no master key, no manager, and no store
17
+ * entry, silently, until the first request. The gap was not that the enum held the wrong two names. It was
18
+ * that a project had no way to say which environments it had, so three parts of the CLI guessed
19
+ * differently.
20
+ *
21
+ * Now the project says, in the root `pithy.config.ts` (core's `DeclaredEnvironments`), and this reads it.
22
+ *
23
+ * **Managed could have stayed narrower than declared, and deliberately does not.** The cost of widening is
24
+ * real and is worth naming: everything that iterates this set multiplies with it, and the largest item is
25
+ * a manager Worker deployed per environment, each with its own rotation cron and its own D1. A project
26
+ * declaring five environments gets five managers. The tempting alternative is a second, smaller list —
27
+ * "declared, but only these are managed" — so the common project pays for two.
28
+ *
29
+ * That alternative is the bug, restated. An environment that is deployed and *not* managed is an
30
+ * environment whose secrets have no master key: exactly the silent state `live` was already in, except now
31
+ * with a config field that made it look intentional. There is no useful meaning for "this project deploys
32
+ * to `live` but `live` gets no secrets" — the Worker still starts, still reads `SECRETS_ENCRYPTION_KEYS`,
33
+ * and still fails at the first request. So the second list would only ever be a way to reintroduce the
34
+ * silence with paperwork.
35
+ *
36
+ * The cost is therefore charged where it belongs: **declaring an environment is what costs a manager.** A
37
+ * project that does not want five managers declares fewer environments, which is also true of five D1
38
+ * databases, five KV namespaces, and five sets of resource names. The declaration is the one place that
39
+ * decision is made and the one place `pithy doctor` can see it.
40
+ *
41
+ * **`dev` is still never here**, and does not need excluding: it is local-only — resolved from the
42
+ * project's own Miniflare-backed store, seeded from the dev secrets file — and core refuses to let a
43
+ * declaration name it at all.
44
+ */
45
+ export const ManagedEnvironment = z
46
+ .string()
47
+ .refine((value) => isValidEnvironment(value) && value !== "dev", {
48
+ error:
49
+ "A deployed environment is a legal environment name that is not `dev`. Declare it in the root pithy.config.ts.",
50
+ })
51
+ .describe("A deployed environment with its own secrets store and manager (everything except local dev).");
52
+
53
+ /**
54
+ * One deployed environment. **A validated `string`, not a union of two literals** — the union was the bug
55
+ * this module's header describes, and a type cannot know a name the project writes down at `init`.
56
+ */
57
+ export type ManagedEnvironment = z.output<typeof ManagedEnvironment>;
58
+
59
+ /**
60
+ * Every managed environment, in order — the set a `global` D1 secret fans out across, and the order
61
+ * provisioning walks. It is the declaration, unchanged: see the header for why nothing narrows it.
62
+ *
63
+ * The declaration is a **required** argument rather than one defaulted to `["staging", "prod"]`, because a
64
+ * default here is indistinguishable from the silence this replaced — a caller that forgot to load the
65
+ * project would skip `live` exactly as the closed enum did, and nothing would say so. Required, the
66
+ * compiler names every call site instead.
67
+ */
68
+ export function managedEnvironments(declared: DeclaredEnvironments | readonly string[]): ManagedEnvironment[] {
69
+ return [...declared];
70
+ }
71
+
72
+ /**
73
+ * The environment a `global` CF-Secrets-Store secret is canonically written through — **the last declared
74
+ * one**.
75
+ *
76
+ * A Secrets Store entry is a single account-level secret that every environment binds, so it is written
77
+ * once rather than per environment, and *which* manager does the writing has to be a stable function of
78
+ * the project rather than of whichever environment the operator happened to type. It used to be the
79
+ * literal `prod`, which is right for a project that has one and writes through a manager that was never
80
+ * deployed for a project that does not.
81
+ *
82
+ * Last, because the declaration is ordered least-production first: the canonical writer is the most
83
+ * production-class environment, which is the one that certainly exists and the one whose credentials are
84
+ * scoped hardest. `["staging", "prod"]` gives `prod`, so nothing about the default project changes.
85
+ */
86
+ export function canonicalGlobalEnvironment(
87
+ declared: DeclaredEnvironments | readonly string[],
88
+ ): ManagedEnvironment | undefined {
89
+ return declared[declared.length - 1];
90
+ }
91
+
92
+ /**
93
+ * The environments a write must reach, given its backend and scope — the routing the CLI applies
94
+ * before dispatching to each env's manager (issue #26's backend × scope table):
95
+ *
96
+ * - `environment` scope → exactly the requested env (its value legitimately differs per env).
97
+ * - `global` + `d1` → **every** declared env: each env's store is separate and keyed by its own
98
+ * master key, so the same value is written into each (fan-out, kept in lockstep).
99
+ * - `global` + `cf-secrets-store` → **the canonical env only**: a CF Secrets Store entry is one
100
+ * account-level secret every env binds, so it is written once, via
101
+ * {@link canonicalGlobalEnvironment}.
102
+ *
103
+ * A `global` write therefore reaches every environment by construction; an `environment` write touches
104
+ * exactly one.
105
+ */
106
+ export function resolveWriteTargets(
107
+ backend: SecretBackend,
108
+ scope: SecretScope,
109
+ requested: ManagedEnvironment,
110
+ declared: DeclaredEnvironments | readonly string[],
111
+ ): ManagedEnvironment[] {
112
+ if (scope === "environment") return [requested];
113
+ if (backend === "cf-secrets-store") {
114
+ const canonical = canonicalGlobalEnvironment(declared);
115
+ // A declaration is never empty — core refuses that — so this only guards the caller who passed a
116
+ // list core never validated. Falling back to the requested env writes the secret somewhere real.
117
+ return [canonical ?? requested];
118
+ }
119
+ return managedEnvironments(declared);
120
+ }