@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,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
|
+
}
|