@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,139 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
5
+ import type { DatabaseSchema } from "@pithy-sh/core/src/data/db";
6
+ import type { Kysely } from "kysely";
7
+ import { decryptValue, type EncryptionConfig, encryptValue } from "../crypto/envelope";
8
+ import type { SecretsTables } from "../data/tables";
9
+
10
+ type SecretsDb = Kysely<DatabaseSchema<SecretsTables>>;
11
+
12
+ /** Generate a fresh AES-256 key, base64-encoded. The encryption-key version axis (not value version). */
13
+ export async function generateKeyB64(): Promise<string> {
14
+ // generateKey is typed `CryptoKey | CryptoKeyPair` and exportKey("raw") `ArrayBuffer | JsonWebKey`;
15
+ // for a symmetric AES-GCM key the runtime returns the CryptoKey / ArrayBuffer branch.
16
+ const key = (await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, [
17
+ "encrypt",
18
+ "decrypt",
19
+ ])) as CryptoKey;
20
+ const exported = new Uint8Array((await crypto.subtle.exportKey("raw", key)) as ArrayBuffer);
21
+ let binary = "";
22
+ for (const byte of exported) binary += String.fromCharCode(byte);
23
+ return btoa(binary);
24
+ }
25
+
26
+ /**
27
+ * Add a fresh key as the next version and make it current, keeping every prior key so rows not yet
28
+ * re-encrypted stay decryptable through the overlap window.
29
+ */
30
+ export async function mergeNextKey(config: EncryptionConfig, now: Date = new Date()): Promise<EncryptionConfig> {
31
+ const nextVersion = String(Number(config.currentVersion) + 1);
32
+ return {
33
+ currentVersion: nextVersion,
34
+ versions: { ...config.versions, [nextVersion]: await generateKeyB64() },
35
+ lastRotatedAt: now.toISOString(),
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Drop every key except the current version. Returns `null` when there is nothing to prune (a
41
+ * single key already). Pruning is only safe once no row references an old version.
42
+ */
43
+ export function pruneOldKeys(config: EncryptionConfig): EncryptionConfig | null {
44
+ if (Object.keys(config.versions).length <= 1) return null;
45
+ const currentKey = config.versions[config.currentVersion];
46
+ if (currentKey === undefined) return null;
47
+ return {
48
+ currentVersion: config.currentVersion,
49
+ versions: { [config.currentVersion]: currentKey },
50
+ lastRotatedAt: config.lastRotatedAt,
51
+ };
52
+ }
53
+
54
+ /**
55
+ * One re-encryption batch: how many rows rolled to the current key, and how many would not.
56
+ *
57
+ * **Two counts, and no third field carrying why (`#386`).** This shape used to hold
58
+ * `errors: Array<{ id, error }>`, filled from a bound `cause.message` in the loop below. Nothing read it
59
+ * — `runAtRestKeyRotation` sums `failed` and never looks — so it disclosed nothing, and that is exactly
60
+ * the state worth removing rather than the state worth keeping. The rule is that a catch here takes no
61
+ * binding; a field waiting to be surfaced is how "let us report why the rotation failed" becomes a
62
+ * disclosure in one reasonable-looking commit, and every string that could have landed in it came from
63
+ * decrypting or encrypting a secret.
64
+ *
65
+ * What a run needs is whether progress is being made, which `rotated` answers, and whether rows are stuck,
66
+ * which `failed` answers. Which rows, and why, is a question for the throw site — and it does not throw.
67
+ */
68
+ export interface ReencryptResult {
69
+ rotated: number;
70
+ failed: number;
71
+ }
72
+
73
+ /**
74
+ * Re-encrypt one batch of `pithy_secrets_system_secrets` rows that are not on the current key
75
+ * version: decrypt under the row's old key, re-encrypt under the current key, update in place. The
76
+ * plaintext (the `{ currentVersion, versions }` value envelope) is opaque here — only the
77
+ * encryption key changes. Each row is independent; a failure is counted, never thrown, so one bad row
78
+ * cannot abort the batch — and never described, so nothing derived from it can travel.
79
+ */
80
+ export async function reencryptBatch(
81
+ db: SecretsDb,
82
+ config: EncryptionConfig,
83
+ batchSize = 100,
84
+ ): Promise<ReencryptResult> {
85
+ const result: ReencryptResult = { rotated: 0, failed: 0 };
86
+ const rows = await db
87
+ .selectFrom("pithySecretsSystemSecrets")
88
+ .select(["id", "name", "encryptedValue", "iv", "keyVersion"])
89
+ .where("keyVersion", "!=", Number(config.currentVersion))
90
+ .limit(batchSize)
91
+ .execute();
92
+ if (rows.length === 0) return result;
93
+
94
+ for (const row of rows) {
95
+ try {
96
+ // Same name in and out: re-encryption changes the key, never the bound context.
97
+ const plaintext = await decryptValue(config, row.name, row);
98
+ const reencrypted = await encryptValue(config, row.name, plaintext);
99
+ await db
100
+ .updateTable("pithySecretsSystemSecrets")
101
+ .set({
102
+ encryptedValue: reencrypted.encryptedValue,
103
+ iv: reencrypted.iv,
104
+ keyVersion: reencrypted.keyVersion,
105
+ updatedAt: SQLiteDate.encode(new Date()),
106
+ })
107
+ .where("id", "=", row.id)
108
+ .execute();
109
+ result.rotated++;
110
+ } catch {
111
+ // No binding, and that is the point rather than a tidiness (`#386`). A decrypt failure's own text
112
+ // names the key version it tried; an encrypt failure's names what it was sealing. Neither may reach
113
+ // a log, a response, or a stored column, and a catch with nothing in scope makes that impossible to
114
+ // get wrong later rather than merely absent today.
115
+ result.failed++;
116
+ }
117
+ }
118
+ return result;
119
+ }
120
+
121
+ const MS_PER_DAY = 86_400_000;
122
+
123
+ /**
124
+ * Whether an at-rest rotation is due: the configured interval has elapsed since `lastRotatedAt`.
125
+ * The cron fires on a fixed schedule and calls this so it only rotates when due, not every tick.
126
+ */
127
+ export function isRotationDue(lastRotatedAt: string, intervalDays: number, now: Date = new Date()): boolean {
128
+ return now.getTime() >= new Date(lastRotatedAt).getTime() + intervalDays * MS_PER_DAY;
129
+ }
130
+
131
+ /** Count rows still on a non-current key version — the signal for when pruning is safe. */
132
+ export async function countOnOldKeys(db: SecretsDb, config: EncryptionConfig): Promise<number> {
133
+ const row = await db
134
+ .selectFrom("pithySecretsSystemSecrets")
135
+ .select((eb) => eb.fn.countAll<number>().as("count"))
136
+ .where("keyVersion", "!=", Number(config.currentVersion))
137
+ .executeTakeFirstOrThrow();
138
+ return Number(row.count);
139
+ }
@@ -0,0 +1,412 @@
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 { mintSecretValue } from "../mintValue";
7
+ import type { SecretRegistryEntry } from "../registry";
8
+ import type { ManagedEnvironment } from "../scope";
9
+ import type { OpenRotation, RotationLedger } from "./rotationLedger";
10
+
11
+ /**
12
+ * **One rotation of one secret's value, built around the failure that cannot be undone.**
13
+ *
14
+ * The failure is `#322`'s, and it is the reason this module is shaped the way it is: *a provider roll
15
+ * succeeds and the store write fails.* The new value exists only in this process, the old one is dead at
16
+ * the issuer, and the Worker holds a credential that no longer works. Nothing downstream can repair that
17
+ * by trying harder, because trying harder at the *roll* produces a third value and loses the second.
18
+ *
19
+ * So the ordering is the design, not a caveat on it:
20
+ *
21
+ * 1. **Refuse everything refusable before anything is called.** A keyspace, an undeclared rotation, a
22
+ * `provider` secret with no rotator, the master key — each is answered with nothing rolled and nothing
23
+ * written. A refusal after a roll would be the worst of both.
24
+ * 2. **Produce the value once.** `local` mints it; `provider` calls the rotator. Exactly one call, and
25
+ * it is never repeated for any reason.
26
+ * 3. **Store with retries, against that value.** Every attempt writes the same string. A store that
27
+ * cannot be made to succeed ends the run; it never reaches back for a fresh value.
28
+ *
29
+ * ## What it reports, and why never in aggregate
30
+ *
31
+ * One {@link SecretRotationOutcome}, naming the environments the new value reached and the environments
32
+ * it did not. `unrecorded` is the state above — rolled at the issuer, not recorded — and it is a separate
33
+ * member from `failed` because the operator's next move differs completely: `failed` means the old value
34
+ * is still live and the command can simply be run again, while `unrecorded` means somebody must go to a
35
+ * console **now**. An "all rotated" line printed over that distinction is the shape this refuses.
36
+ *
37
+ * ## What happens to the value when the store will not take it
38
+ *
39
+ * It is discarded, and the run says so. It is not printed, not written to a file, not put in an audit
40
+ * event, and not handed back to the caller — see {@link SecretRotationOutcome}, which has no field that
41
+ * could carry one. That is a decision with a cost, and the cost is named in `docs/commands/secrets.md`
42
+ * beside the alternative: a live production credential in shell scrollback, in a CI log, and in every
43
+ * terminal-recording buffer on the machine is a permanent leak, where a rolled-but-unrecorded credential
44
+ * is an outage with a known remedy the declaration can name — roll again at the issuer by hand, then
45
+ * `pithy secrets update`. The retries above are what make reaching that point rare; the honesty about it
46
+ * is what makes it survivable.
47
+ *
48
+ * ## The attempt is recorded here, and only here
49
+ *
50
+ * A rotation that succeeds and records nothing leaves the secret reporting **overdue forever** — `#379`,
51
+ * which reached production behavior precisely because the recording lived at a *call site* rather than at
52
+ * the act. So the {@link RotationLedger} is an argument to this function and it is **required**: refuse,
53
+ * open the row, produce once, store with retries, close the row. A third caller inherits the ordering by
54
+ * calling this, and cannot opt out of it by forgetting. `./rotationLedger.ts` holds the seam and the
55
+ * argument for its shape.
56
+ *
57
+ * ## What this does not do, stated rather than implied
58
+ *
59
+ * It does not **verify** the new value against the issuer. `#322` puts a verify step between store and
60
+ * settle, and it belongs there — but no rotator ships in the kit today, so a verification seam nothing
61
+ * implements would be a step that always passes, which is worse than an absent one. The window it would
62
+ * close is real and is written down in the command's page.
63
+ */
64
+
65
+ export const SecretRotationStatus = z
66
+ .enum(["rotated", "unchanged", "unrecorded", "failed"])
67
+ .describe(
68
+ "How one secret's rotation ended. `rotated` — a new value exists and every target environment holds it. `unchanged` — nothing was called and nothing was written. `unrecorded` — **the issuer rolled and the store did not take the value**, so a live credential is gone and its successor is lost; distinct from `failed` because only this one needs a human in a console now. `failed` — the roll never happened or the store refused before one did, and the previous value is still live.",
69
+ );
70
+ export type SecretRotationStatus = z.output<typeof SecretRotationStatus>;
71
+
72
+ export const SecretRotationUnchangedReason = z
73
+ .enum(["manual", "dry-run"])
74
+ .describe(
75
+ "Why a rotation did nothing. `manual` — the secret declares that only a human in the issuer's console can replace it, so there was nothing to call. `dry-run` — the operator asked what would happen. Both are answers rather than failures, and both exit zero.",
76
+ );
77
+ export type SecretRotationUnchangedReason = z.output<typeof SecretRotationUnchangedReason>;
78
+
79
+ /**
80
+ * What one rotation did. Facts only — the prose belongs to the command, which owns the voice, so a
81
+ * sentence and the state it describes cannot drift apart into two producers.
82
+ *
83
+ * **There is no field for a value, and there must never be one.** That is the structural half of the rule
84
+ * `docs/commands/secrets.md` states in prose: a payload with nowhere to put a secret cannot leak one by
85
+ * a later caller's oversight.
86
+ */
87
+ export interface SecretRotationOutcome {
88
+ /** The secret's registry name. */
89
+ name: string;
90
+ /** How it ended. See {@link SecretRotationStatus}. */
91
+ status: SecretRotationStatus;
92
+ /** How the registry says this secret is replaced — `local`, `provider`, or `manual`. */
93
+ kind: "local" | "provider" | "manual";
94
+ /** Whether the issuer's credential was actually rolled. True only for a `provider` rotation that reached its rotator. */
95
+ rolled: boolean;
96
+ /**
97
+ * Whether the **rotator itself** failed, rather than the store after it.
98
+ *
99
+ * The difference is the difference between a true sentence and a false one. A rotator that returned and
100
+ * a store that refused means the credential *was* rolled. A rotator that threw means it *may* have been —
101
+ * the call reached the issuer and the answer did not come back, and no code here can tell a request that
102
+ * never landed from a response that was lost. Both need a human at the issuer; only one of them may be
103
+ * described as *rolled*, and a report that says so of both is wrong half the time about the one fact the
104
+ * operator is acting on.
105
+ */
106
+ rollFailed?: boolean;
107
+ /** The environments this run wrote the new value to, in order, as each write landed. */
108
+ recorded: ManagedEnvironment[];
109
+ /** The environments the new value never reached. Empty on a run that finished. */
110
+ stranded: ManagedEnvironment[];
111
+ /** Why nothing was called, when nothing was. */
112
+ reason?: SecretRotationUnchangedReason;
113
+ /** How many store attempts the failing environment cost, for the report. Absent when nothing was stored. */
114
+ attempts?: number;
115
+ /** What ended the run, for the command to render. Never inspected here, and never carries a value. */
116
+ cause?: unknown;
117
+ }
118
+
119
+ /** Write one value into one environment's store. The caller's dispatcher; this module never writes. */
120
+ export type SecretValueStore = (request: { env: ManagedEnvironment; value: string }) => Promise<void>;
121
+
122
+ /** Wait, between store attempts. Injected so a test spends no real time. */
123
+ export type RotationSleeper = (ms: number) => Promise<void>;
124
+
125
+ /** How many times the store is asked, and how long between asks. */
126
+ const DEFAULT_ATTEMPTS = 3;
127
+ const BACKOFF_MS = 250;
128
+
129
+ const defaultSleeper: RotationSleeper = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
130
+
131
+ export interface RotateSecretValueOptions {
132
+ /** The secret's registry name. */
133
+ name: string;
134
+ /** Its registry entry — the declaration every refusal below is read off. */
135
+ entry: SecretRegistryEntry;
136
+ /**
137
+ * Where the new value must land, already decided by `secretWriteTargets`. One environment for an
138
+ * `environment`-scoped secret, every declared one for a `global` secret.
139
+ *
140
+ * Passed in rather than computed, so a rotation and a `pithy secrets update` of the same secret cannot
141
+ * disagree about where its value lives.
142
+ */
143
+ targets: readonly ManagedEnvironment[];
144
+ /** The store write, per environment. Called once per attempt, always with the same value. */
145
+ store: SecretValueStore;
146
+ /**
147
+ * Where the attempt is recorded — opened before the roll, closed with the outcome.
148
+ *
149
+ * **Required, and that is the fix for `#379`.** An optional ledger is one a caller forgets, and the
150
+ * caller that forgot was `pithy secrets rotate`: it dispatched an ordinary `update`, nothing recorded a
151
+ * rotation, and the secret reported overdue permanently. See {@link RotationLedger}.
152
+ */
153
+ ledger: RotationLedger;
154
+ /** Store attempts per environment before the run ends. Defaults to 3. Never a re-roll. */
155
+ attempts?: number;
156
+ /** Injected in tests. Defaults to a real `setTimeout`. */
157
+ sleep?: RotationSleeper;
158
+ /** Resolve the declaration and report what would happen, calling nothing and writing nothing. */
159
+ dryRun?: boolean;
160
+ }
161
+
162
+ /**
163
+ * Refuse a rotation that cannot be performed, **before anything is called**.
164
+ *
165
+ * Every one of these is a fact about the declaration, knowable with no network and no provider contact, so
166
+ * every one of them is answered here rather than by a rotator failing in its own words halfway through.
167
+ * A refusal reaching an operator after a roll would leave a dead credential behind a message about
168
+ * configuration.
169
+ *
170
+ * **Exported so a caller can ask the same question earlier, and it is still asked here.** `rotateSecretValue`
171
+ * calls it first thing regardless, so this is a cheap pre-flight rather than a second authority — the HTTP
172
+ * route uses it to decide whether to open a rotation row before it starts one, since a row opened for a
173
+ * rotation that was refused before anything was called is a `failed` attempt in a history that records
174
+ * attempts. It is pure and reads only the declaration, so asking twice costs nothing and cannot disagree.
175
+ */
176
+ export function refuseUnrotatable(name: string, entry: SecretRegistryEntry): void {
177
+ if (entry.keyed) {
178
+ throw new ValidationError({
179
+ message: `Secret '${name}' is a keyspace, not a secret.`,
180
+ action: "Its members are rotated one key at a time by the application that owns them.",
181
+ detail: `rotate refused: '${name}' is a keyspace`,
182
+ });
183
+ }
184
+ if (entry.rotation === undefined) {
185
+ throw new ValidationError({
186
+ message: `Secret '${name}' does not declare how it rotates.`,
187
+ action: `Add a rotation to its registry entry, then run this again. Until then, replace it with pithy secrets update ${name}.`,
188
+ detail: `rotate refused: '${name}' declares no rotation`,
189
+ });
190
+ }
191
+ if (entry.rotation.kind === "provider" && entry.rotator === undefined) {
192
+ throw new ValidationError({
193
+ message: `Secret '${name}' rotates by calling ${entry.rotation.issuer}, and this project supplies no rotator for it.`,
194
+ action: `Attach a rotator to its registry entry, or roll it at ${entry.rotation.issuer} by hand and record it with pithy secrets update ${name}.`,
195
+ detail: `rotate refused: '${name}' is rotation.kind provider with no rotator on the registry entry`,
196
+ });
197
+ }
198
+ if (entry.rotation.kind !== "local") return;
199
+ // The master key is `local` and is the one secret this command must not touch. It is not a value in a
200
+ // versioned envelope; it *is* the `EncryptionConfig` every other value is read through, and replacing it
201
+ // here would leave every stored secret sealed under a key nothing holds. It rotates on its own axis —
202
+ // the `versions` map inside itself — driven by the manager's own cron.
203
+ if (entry.bootstrap) {
204
+ throw new ValidationError({
205
+ message: `Secret '${name}' is the key every other secret is read through, so nothing replaces it in place.`,
206
+ action: "It rotates on its own schedule, inside the secrets manager. Nothing here should roll it.",
207
+ detail: `rotate refused: '${name}' is a bootstrap secret; at-rest key rotation owns it`,
208
+ });
209
+ }
210
+ if (entry.devValue === undefined) {
211
+ throw new ValidationError({
212
+ message: `Secret '${name}' is minted from a structure this command cannot produce.`,
213
+ action: `Replace it with pithy secrets update ${name}.`,
214
+ detail: `rotate refused: '${name}' is rotation.kind local with no devValue recipe`,
215
+ });
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Produce the successor value. **The one third-party mutation, and it happens exactly once.**
221
+ *
222
+ * `local` mints it here, from the same `mintSecretValue` that created it, because what the kit can make
223
+ * it can make again. `provider` hands off to the declared rotator, and what comes back may be the only
224
+ * copy of a live credential in existence from the moment it returns.
225
+ */
226
+ async function nextValue(options: {
227
+ name: string;
228
+ entry: SecretRegistryEntry;
229
+ env: ManagedEnvironment;
230
+ }): Promise<{ value: string; rolled: boolean }> {
231
+ const { entry } = options;
232
+ if (entry.rotation?.kind === "provider" && entry.rotator !== undefined) {
233
+ const result = await entry.rotator.roll({ name: options.name, env: options.env, entry });
234
+ if (typeof result?.newValue !== "string" || result.newValue === "") {
235
+ // A rotator that answers with nothing usable has still, as far as anything here knows, rolled the
236
+ // credential. So this is not a quiet skip: it is the unrecorded state arriving from the other side,
237
+ // and it is raised where the caller classifies it as one.
238
+ throw new ValidationError({
239
+ message: `The rotator for '${options.name}' returned no value.`,
240
+ action: `Check whether ${entry.rotation.issuer} issued a new credential, and record it with pithy secrets update ${options.name}.`,
241
+ detail: `rotator for '${options.name}' resolved without a newValue string`,
242
+ });
243
+ }
244
+ return { value: result.newValue, rolled: true };
245
+ }
246
+ // `local`, and `refuseUnrotatable` has already proved the recipe is there.
247
+ if (entry.devValue === undefined) {
248
+ throw new ValidationError({
249
+ message: `Secret '${options.name}' cannot be minted.`,
250
+ detail: `nextValue reached for '${options.name}' with no devValue — refuseUnrotatable should have caught this`,
251
+ });
252
+ }
253
+ return { value: mintSecretValue(entry.devValue), rolled: false };
254
+ }
255
+
256
+ /**
257
+ * Open the rotation row, and **never let the bookkeeping stop the act**.
258
+ *
259
+ * A ledger that cannot be reached is a gap in a history. A credential that was not replaced because the
260
+ * history could not be written is an unrotated credential, and during the incident that prompted the
261
+ * rotation that is the worse of the two by a wide margin. So a ledger failure is absorbed: the row is
262
+ * simply absent, which is visible as a missing entry and as a `lastRotatedAt` that did not move — the
263
+ * same two signals `#379` itself was found through.
264
+ */
265
+ async function openAttempt(ledger: RotationLedger, name: string): Promise<OpenRotation | undefined> {
266
+ try {
267
+ return await ledger.open(name);
268
+ } catch {
269
+ return undefined;
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Close the row, absorbing a ledger failure for the same reason {@link openAttempt} does — and one more:
275
+ * by the time this runs the rotation has happened, and a throw here would take `recorded` and `stranded`
276
+ * with it. Losing the record of what landed is how a recoverable store failure becomes an unrecoverable
277
+ * one.
278
+ */
279
+ async function closeAttempt(attempt: OpenRotation | undefined, outcome: SecretRotationOutcome): Promise<void> {
280
+ if (attempt === undefined) return;
281
+ try {
282
+ await attempt.close(outcome);
283
+ } catch {
284
+ // Deliberately absorbed. See above.
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Rotate one secret's value: refuse, open the row, produce once, store with retries, close the row, report
290
+ * per secret.
291
+ *
292
+ * Never throws for a rotation that *happened* and went wrong — that is an outcome, because a throw would
293
+ * take the record of what landed with it, and the record is what makes the remedy safe. It throws only for
294
+ * the refusals above, which happen before anything is called and leave nothing to report — and, because
295
+ * they land before the row is opened, a refused rotation writes no history either.
296
+ */
297
+ export async function rotateSecretValue(options: RotateSecretValueOptions): Promise<SecretRotationOutcome> {
298
+ const { name, entry, targets } = options;
299
+ refuseUnrotatable(name, entry);
300
+ // Narrowed by the refusals; restated for the type, which cannot see through a function call.
301
+ const rotation = entry.rotation;
302
+ if (rotation === undefined)
303
+ throw new ValidationError({ message: `Secret '${name}' does not declare how it rotates.` });
304
+
305
+ const base: Pick<SecretRotationOutcome, "name" | "kind"> = { name, kind: rotation.kind };
306
+
307
+ // A human in a console, and nothing to call. An answer, not a failure — so it exits zero and says where.
308
+ if (rotation.kind === "manual") {
309
+ return { ...base, status: "unchanged", rolled: false, recorded: [], stranded: [], reason: "manual" };
310
+ }
311
+ if (options.dryRun) {
312
+ return { ...base, status: "unchanged", rolled: false, recorded: [], stranded: [...targets], reason: "dry-run" };
313
+ }
314
+ if (targets.length === 0) {
315
+ throw new ValidationError({
316
+ message: `Secret '${name}' has no environment to rotate in.`,
317
+ action: "Declare at least one environment in the root pithy.config.ts, then run this again.",
318
+ detail: `rotate refused: '${name}' resolved to no write targets`,
319
+ });
320
+ }
321
+
322
+ // **The line either side of which everything changes.** Above it, a refusal costs nothing. Below it, a
323
+ // credential may already be dead at its issuer, and every remaining step is retryable against one value.
324
+ const first = targets[0];
325
+ if (first === undefined) throw new ValidationError({ message: `Secret '${name}' has no environment to rotate in.` });
326
+
327
+ // **Opened here, and not one line later.** Every refusal above has already been answered, so no row is
328
+ // written for a rotation that never started; and the roll is the next thing that happens, so a rotator
329
+ // that never returns still leaves an `in_progress` row naming the secret and who asked.
330
+ const attempt = await openAttempt(options.ledger, name);
331
+ const outcome = await rollAndStore(options, { base, rotation, first });
332
+ await closeAttempt(attempt, outcome);
333
+ return outcome;
334
+ }
335
+
336
+ /**
337
+ * Everything below the irreversible line: produce the value once, then store it with retries.
338
+ *
339
+ * Split out so the ledger bracket in {@link rotateSecretValue} reads as one statement — open, run, close —
340
+ * rather than as a `close` repeated at each of four exits, which is the shape somebody eventually adds a
341
+ * fifth exit to.
342
+ */
343
+ async function rollAndStore(
344
+ options: RotateSecretValueOptions,
345
+ context: {
346
+ base: Pick<SecretRotationOutcome, "name" | "kind">;
347
+ rotation: NonNullable<SecretRegistryEntry["rotation"]>;
348
+ first: ManagedEnvironment;
349
+ },
350
+ ): Promise<SecretRotationOutcome> {
351
+ const { name, entry, targets } = options;
352
+ const { base, rotation, first } = context;
353
+ let produced: { value: string; rolled: boolean };
354
+ try {
355
+ produced = await nextValue({ name, entry, env: first });
356
+ } catch (error) {
357
+ // A `local` mint cannot reach a provider, so nothing was rolled and the old value is untouched. A
358
+ // `provider` rotator that threw may or may not have rolled — and "may have" is the state that needs a
359
+ // human, so it is reported as `unrecorded` rather than guessed into `failed`.
360
+ const rolled = rotation.kind === "provider";
361
+ return {
362
+ ...base,
363
+ status: rolled ? "unrecorded" : "failed",
364
+ rolled,
365
+ // A `local` mint that threw failed here, in this process, and reached nobody — so this is set only
366
+ // for the provider case, where it is what stops the report claiming a roll it cannot confirm.
367
+ ...(rolled ? { rollFailed: true } : {}),
368
+ recorded: [],
369
+ stranded: [...targets],
370
+ cause: error,
371
+ };
372
+ }
373
+
374
+ const attempts = options.attempts ?? DEFAULT_ATTEMPTS;
375
+ const sleep = options.sleep ?? defaultSleeper;
376
+ const recorded: ManagedEnvironment[] = [];
377
+ for (const env of targets) {
378
+ let spent = 0;
379
+ let last: unknown;
380
+ while (spent < attempts) {
381
+ spent += 1;
382
+ try {
383
+ // The same string, every attempt. Reaching back for a fresh value here is the mistake that turns
384
+ // a recoverable store failure into a lost credential, so there is nothing here that could.
385
+ await options.store({ env, value: produced.value });
386
+ last = undefined;
387
+ break;
388
+ } catch (error) {
389
+ last = error;
390
+ if (spent < attempts) await sleep(BACKOFF_MS * 2 ** (spent - 1));
391
+ }
392
+ }
393
+ if (last !== undefined) {
394
+ const stranded = targets.filter((target) => !recorded.includes(target));
395
+ return {
396
+ ...base,
397
+ // Rolled and not recorded *anywhere* is the incident. Rolled and recorded in some environments is
398
+ // the same incident for the rest of them: the issuer's old credential is dead there too, and no
399
+ // command can copy the new one across, because nothing reads a stored value back out.
400
+ status: produced.rolled ? "unrecorded" : "failed",
401
+ rolled: produced.rolled,
402
+ recorded,
403
+ stranded,
404
+ attempts: spent,
405
+ cause: last,
406
+ };
407
+ }
408
+ recorded.push(env);
409
+ }
410
+
411
+ return { ...base, status: "rotated", rolled: produced.rolled, recorded, stranded: [] };
412
+ }