@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,135 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { appendVersion, initialVersionedValue } from "./crypto/versionedValue";
|
|
5
|
+
import { SecretAlreadyExistsError, SecretNotFoundError } from "./error/errors";
|
|
6
|
+
import type { SecretValueType } from "./registry";
|
|
7
|
+
import type { RotationTracker } from "./store/rotationTracker";
|
|
8
|
+
import type { SystemSecretsStore } from "./store/systemSecretsStore";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The **request-path** write for one keyspace member — the counterpart to `management/writeSecret.ts`,
|
|
12
|
+
* and a different operation rather than a nicer API for the same one.
|
|
13
|
+
*
|
|
14
|
+
* Sealing can only happen in a Worker: the master key is a binding, and the CLI does not have it. The
|
|
15
|
+
* management path respects that by dispatching a Workflow, which runs in the Worker — correct for a
|
|
16
|
+
* secret an operator provisions, because nobody is waiting on the response. It does not fit a
|
|
17
|
+
* credential an application mints *during* a request. A connect flow generates a keypair, must seal the
|
|
18
|
+
* private half, and must return the public half in the same response; it cannot dispatch a Workflow and
|
|
19
|
+
* wait. Returning a public key whose private half is not yet stored hands out a credential that cannot
|
|
20
|
+
* be used, and discovers it later, at the customer.
|
|
21
|
+
*
|
|
22
|
+
* So this runs inline, on the request, through the same {@link SystemSecretsStore} and therefore the
|
|
23
|
+
* same AES-256-GCM envelope, the same bound name (`<entry>/<key>`, so one tenant's ciphertext does not
|
|
24
|
+
* open under another's), and the same `pithy_secrets_system_secrets` row every other secret uses. That
|
|
25
|
+
* last part is not a detail: the at-rest key rotation re-encrypts *rows*, keyed on nothing but
|
|
26
|
+
* `keyVersion`, so a member written here is picked up by it. A second storage path would have been a
|
|
27
|
+
* set of rows the cron never visits — the ones that quietly cannot be opened after a rotation.
|
|
28
|
+
*
|
|
29
|
+
* The promise resolving **is** the persistence guarantee. Nothing is queued and nothing is deferred, so
|
|
30
|
+
* a caller that awaits this before returning a credential knows the sealed half is stored.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What a write is allowed to do to a member that already exists.
|
|
35
|
+
*
|
|
36
|
+
* `create` refuses one — the default, because the failure worth designing against is a signing key
|
|
37
|
+
* silently overwritten mid-rotation, and a create-or-replace default makes that a typo away.
|
|
38
|
+
* `replace` is the explicit form: every prior version is discarded, which is what a compromised
|
|
39
|
+
* credential needs and what a retained one must never get by accident. `rotate` appends the value as
|
|
40
|
+
* the next version and keeps the old ones valid — the two-keys-during-rotation window `getKeyedVersions`
|
|
41
|
+
* exists to serve.
|
|
42
|
+
*/
|
|
43
|
+
export type KeyedWriteMode = "create" | "replace" | "rotate";
|
|
44
|
+
|
|
45
|
+
/** One member write, as the accessor hands it to the store. */
|
|
46
|
+
export interface KeyedMemberWrite {
|
|
47
|
+
/**
|
|
48
|
+
* The keyspace the member belongs to. Carried beside {@link storedName} because a refusal names the
|
|
49
|
+
* keyspace and never the key: `detail` reaches logs verbatim, and the key identifies one tenant.
|
|
50
|
+
*/
|
|
51
|
+
keyspace: string;
|
|
52
|
+
/** The member's stored name, `<entry>/<key>`, composed by `keyedSecretName` and never by hand. */
|
|
53
|
+
storedName: string;
|
|
54
|
+
/** What the write may do to an existing member. */
|
|
55
|
+
mode: KeyedWriteMode;
|
|
56
|
+
/** The serialized value — a `text` value as it stands, a `json` value already stringified. */
|
|
57
|
+
value: string;
|
|
58
|
+
/** The keyspace's value type, persisted on the row so a read knows how to interpret it. */
|
|
59
|
+
valueType: SecretValueType;
|
|
60
|
+
/** Whether the keyspace is rotatable — drives the rotation baseline on a create. */
|
|
61
|
+
rotatable: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** What a member write needs: the encrypted store, and the tracker that owns rotation history. */
|
|
65
|
+
export interface KeyedWriteDeps {
|
|
66
|
+
store: SystemSecretsStore;
|
|
67
|
+
tracker: RotationTracker;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Write one member and return its current version key.
|
|
72
|
+
*
|
|
73
|
+
* The existence check and the write are two statements, as they are in `runWriteSecret` — D1 has no
|
|
74
|
+
* transaction to put around them. The window is real and it is the narrowest one available; what it
|
|
75
|
+
* cannot do is turn a `create` into a silent overwrite, because the write that follows a passed check
|
|
76
|
+
* still refuses nothing and *overwrites* nothing a concurrent create could have put there. Two
|
|
77
|
+
* simultaneous creates for one tenant is a shape the caller already has to make impossible, since it
|
|
78
|
+
* would mint two credentials whatever this did.
|
|
79
|
+
*/
|
|
80
|
+
export async function runKeyedWrite(deps: KeyedWriteDeps, params: KeyedMemberWrite): Promise<string> {
|
|
81
|
+
if (params.mode === "rotate") {
|
|
82
|
+
// The only mode that reads the plaintext: appending a version needs the versions already there.
|
|
83
|
+
const existing = await deps.store.getValue(params.storedName);
|
|
84
|
+
if (!existing) {
|
|
85
|
+
throw new SecretNotFoundError({
|
|
86
|
+
message: `Secret '${params.keyspace}' has no value for that key.`,
|
|
87
|
+
action: "Create the member before rotating it.",
|
|
88
|
+
detail: `keyspace '${params.keyspace}': rotate found no member stored under the requested key`,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
const next = appendVersion(existing, params.value);
|
|
92
|
+
await deps.store.put(params.storedName, next, params.valueType);
|
|
93
|
+
return next.currentVersion;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const exists = await deps.store.has(params.storedName);
|
|
97
|
+
if (params.mode === "create" && exists) {
|
|
98
|
+
throw new SecretAlreadyExistsError({
|
|
99
|
+
message: `Secret '${params.keyspace}' already has a value for that key.`,
|
|
100
|
+
action: "Rotate the member to add a version, or pass replace to discard the stored one.",
|
|
101
|
+
detail: `keyspace '${params.keyspace}': create found a member already stored under the requested key`,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const next = initialVersionedValue(params.value);
|
|
106
|
+
await deps.store.put(params.storedName, next, params.valueType);
|
|
107
|
+
|
|
108
|
+
// Same rule the management write follows: a brand-new rotatable secret gets a baseline, so nothing
|
|
109
|
+
// that measures rotation age reports a member as overdue purely for having no history. It is when
|
|
110
|
+
// this tenant's credential was established, which is a question with an answer that does not touch
|
|
111
|
+
// the value.
|
|
112
|
+
//
|
|
113
|
+
// Unlike the management write there is no "has it any history" probe first, because a member that
|
|
114
|
+
// does not exist cannot have any: {@link runKeyedRemove} purges the history with the row, and so
|
|
115
|
+
// does the management delete. Paying a query per create to re-establish that on a request path is
|
|
116
|
+
// the wrong trade.
|
|
117
|
+
if (params.rotatable && !exists) await deps.tracker.recordBaseline(params.storedName);
|
|
118
|
+
return next.currentVersion;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Remove one member — every version of it, and its rotation history, as one operation.
|
|
123
|
+
*
|
|
124
|
+
* Every version goes because they are one row: a member's `{ currentVersion, versions }` envelope is a
|
|
125
|
+
* single sealed value, so there is no loop for a caller to write and no version that can survive the
|
|
126
|
+
* tenant it belonged to. The history goes for the reason the management delete purges it — rows naming
|
|
127
|
+
* a secret that no longer exists are rows nothing will ever explain.
|
|
128
|
+
*
|
|
129
|
+
* Idempotent. Removing a tenant twice is a normal shape (a retry, a redelivered webhook), and the
|
|
130
|
+
* second call must not be an error the caller has to special-case.
|
|
131
|
+
*/
|
|
132
|
+
export async function runKeyedRemove(deps: KeyedWriteDeps, storedName: string): Promise<void> {
|
|
133
|
+
await deps.store.delete(storedName);
|
|
134
|
+
await deps.tracker.purgeHistory(storedName);
|
|
135
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { initialVersionedValue } from "../crypto/versionedValue";
|
|
6
|
+
import { SecretAlreadyExistsError, SecretNotFoundError } from "../error/errors";
|
|
7
|
+
import type { SecretValueType } from "../registry";
|
|
8
|
+
import type { RotationTracker } from "../store/rotationTracker";
|
|
9
|
+
import type { SystemSecretsStore } from "../store/systemSecretsStore";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The management write core — the logic the per-env manager Workflow runs when the CLI dispatches
|
|
13
|
+
* a create/update/remove. Scoped to one environment: the CLI fans a `global` write out to each
|
|
14
|
+
* env's manager, so this core never replicates.
|
|
15
|
+
*
|
|
16
|
+
* **The worker does not validate the value's shape — the CLI does.** A brand-new secret's registry
|
|
17
|
+
* entry (and schema) is not bundled into any *deployed* manager yet, so the worker cannot be the
|
|
18
|
+
* authoritative validator without forcing a deploy before a secret can even be seeded (the
|
|
19
|
+
* chicken-and-egg). The CLI runs from the user's repo with the fresh registry and validates before
|
|
20
|
+
* dispatching. The worker is therefore a secure-but-dumb writer: enforce create/update intent
|
|
21
|
+
* (a store read, atomic with the write, so no TOCTOU), encrypt, store, and seed a rotation baseline.
|
|
22
|
+
*
|
|
23
|
+
* `create` refuses an existing name; `update` refuses a missing one — the guard that keeps a typo
|
|
24
|
+
* from creating a second secret or silently overwriting one. `probe` writes nothing and answers
|
|
25
|
+
* whether a name is there. A new value is written as a fresh one-version envelope
|
|
26
|
+
* (`initialVersionedValue`); value rotation (append) is a deferred feature.
|
|
27
|
+
*/
|
|
28
|
+
export type WriteSecretParams =
|
|
29
|
+
| {
|
|
30
|
+
/** `create` refuses an existing name; `update` refuses a missing one. */
|
|
31
|
+
mode: "create" | "update";
|
|
32
|
+
name: string;
|
|
33
|
+
value: string;
|
|
34
|
+
valueType: SecretValueType;
|
|
35
|
+
/** Whether the secret is rotatable — drives whether a rotation baseline is seeded. */
|
|
36
|
+
rotatable: boolean;
|
|
37
|
+
}
|
|
38
|
+
| {
|
|
39
|
+
/**
|
|
40
|
+
* **Is this name in the store?** The one read the CLI cannot do for itself: a `d1` value is
|
|
41
|
+
* sealed under a master key that never leaves this worker, so only the manager can answer.
|
|
42
|
+
*
|
|
43
|
+
* It writes nothing, decrypts nothing, and returns nothing but `present` or `absent` — a
|
|
44
|
+
* presence bit is not a secret, and no path here can be coaxed into returning a value.
|
|
45
|
+
*
|
|
46
|
+
* **It replaced `ensure`, and the replacement is the fix.** `ensure` wrote only when the name
|
|
47
|
+
* was absent and was otherwise a silent no-op, which is a *per-environment* answer to a
|
|
48
|
+
* *cross-environment* question. A `global` secret is defined by being identical everywhere, and
|
|
49
|
+
* a run that wrote staging, lost prod, and was re-run minted a second value, found staging
|
|
50
|
+
* present, skipped it, and wrote the second value to prod — two environments, two values, no
|
|
51
|
+
* error. Silence is what made that unnoticeable, so the mode that could be silent is gone. The
|
|
52
|
+
* caller asks first, decides across every environment at once, and writes with `create`, which
|
|
53
|
+
* cannot skip: it raises. See `cli/mintSecrets.ts` in the CLI.
|
|
54
|
+
*/
|
|
55
|
+
mode: "probe";
|
|
56
|
+
name: string;
|
|
57
|
+
}
|
|
58
|
+
| { mode: "delete"; name: string };
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* What one write actually did — the manager's answer, and the thing the caller could not otherwise
|
|
62
|
+
* know. A `d1` secret is sealed under a master key that never leaves this worker, so "was it already
|
|
63
|
+
* there" is a question only the store holder can be asked, and dropping the answer on the floor is how
|
|
64
|
+
* a partial fan-out completes silently.
|
|
65
|
+
*
|
|
66
|
+
* A Zod enum rather than a bare union because it crosses back out of the Worker as a Workflow
|
|
67
|
+
* instance's `output` and is decoded on the far side like every other external input.
|
|
68
|
+
*/
|
|
69
|
+
export const WriteSecretOutcome = z
|
|
70
|
+
.enum(["written", "present", "absent", "deleted"])
|
|
71
|
+
.describe(
|
|
72
|
+
"What a management write did: wrote a new value, found the name already present, found it absent, or removed it.",
|
|
73
|
+
);
|
|
74
|
+
export type WriteSecretOutcome = z.output<typeof WriteSecretOutcome>;
|
|
75
|
+
|
|
76
|
+
export interface WriteSecretDeps {
|
|
77
|
+
store: SystemSecretsStore;
|
|
78
|
+
tracker: RotationTracker;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function runWriteSecret(deps: WriteSecretDeps, params: WriteSecretParams): Promise<WriteSecretOutcome> {
|
|
82
|
+
if (params.mode === "delete") {
|
|
83
|
+
await deps.store.delete(params.name);
|
|
84
|
+
await deps.tracker.purgeHistory(params.name);
|
|
85
|
+
return "deleted";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const exists = await deps.store.has(params.name);
|
|
89
|
+
// The read, and only the read. Nothing is written, nothing is decrypted, and the answer is one bit.
|
|
90
|
+
if (params.mode === "probe") return exists ? "present" : "absent";
|
|
91
|
+
if (params.mode === "create" && exists) {
|
|
92
|
+
throw new SecretAlreadyExistsError({
|
|
93
|
+
message: `Secret '${params.name}' already exists.`,
|
|
94
|
+
detail: `create '${params.name}': already present in the store`,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (params.mode === "update" && !exists) {
|
|
98
|
+
throw new SecretNotFoundError({
|
|
99
|
+
message: `Secret '${params.name}' does not exist.`,
|
|
100
|
+
action: "Use create to add a new secret.",
|
|
101
|
+
detail: `update '${params.name}': not present in the store`,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
await deps.store.put(params.name, initialVersionedValue(params.value), params.valueType);
|
|
106
|
+
|
|
107
|
+
// Seed a rotation baseline for a brand-new rotatable secret so the cadence check never reports
|
|
108
|
+
// it immediately overdue purely for lacking history.
|
|
109
|
+
//
|
|
110
|
+
// **And nothing else here records anything, which is the point rather than the omission (`#379`).** A
|
|
111
|
+
// first write establishes a value; a rotation replaces one; they are different events and the ledger
|
|
112
|
+
// keeps them apart by `trigger`. This is a writer, and a writer cannot tell which it is being used for:
|
|
113
|
+
// an `update` is a rotation, a value pasted from a console, or a typo fix, and inferring a rotation from
|
|
114
|
+
// it would advance a freshness clock nobody rotated. The act declares itself — `rotateSecretValue` opens
|
|
115
|
+
// and closes the row around the roll, for every caller. See `../rotation/rotationLedger.ts`.
|
|
116
|
+
if (params.rotatable && !exists && (await deps.tracker.getLatestSuccess(params.name)) === null) {
|
|
117
|
+
await deps.tracker.recordBaseline(params.name);
|
|
118
|
+
}
|
|
119
|
+
return "written";
|
|
120
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The write-back seam for the master-key config. The at-rest rotation Workflow updates
|
|
6
|
+
* `SECRETS_ENCRYPTION_KEYS` (a CF Secrets Store entry) via the CF API — the binding itself is
|
|
7
|
+
* read-only. This interface decouples the rotation logic from the CF client so the core is
|
|
8
|
+
* testable with a stub; the real implementation (backed by `@pithy-sh/cloudflare`'s
|
|
9
|
+
* `CloudflareSecretsStoreManager`) is wired into the manager worker.
|
|
10
|
+
*/
|
|
11
|
+
export interface ConfigWriter {
|
|
12
|
+
/**
|
|
13
|
+
* Persist the new `SECRETS_ENCRYPTION_KEYS` value. The write is an in-place update, so a failure
|
|
14
|
+
* leaves the prior config intact and readable — losing this config would make every stored secret
|
|
15
|
+
* undecryptable, and no window exists in which it is absent. A failed write throws; the caller
|
|
16
|
+
* (the rotation Workflow step) retries.
|
|
17
|
+
*/
|
|
18
|
+
write(value: string): Promise<void>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { CloudflareWorkflowsClient } from "@pithy-sh/cloudflare/src/workflows/workflowsClient";
|
|
5
|
+
import { UpstreamError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import { resourceNames } from "@pithy-sh/core/src/naming/resourceNames";
|
|
7
|
+
import type {
|
|
8
|
+
SecretDispatcher,
|
|
9
|
+
SecretProbe,
|
|
10
|
+
SecretProbeRequest,
|
|
11
|
+
SecretRotationCloseRequest,
|
|
12
|
+
SecretRotationOpenRequest,
|
|
13
|
+
SecretRotationRecorder,
|
|
14
|
+
SecretWriteRequest,
|
|
15
|
+
} from "../cli/dispatch";
|
|
16
|
+
import type { ManagedEnvironment } from "../scope";
|
|
17
|
+
import { WriteWorkflowResult } from "./writeWorkflow";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The `<capability>` segment of every name the secrets manager deploys under — its Worker script, its
|
|
21
|
+
* D1, and both of its Workflows. One literal, so the manager, the dispatcher, and teardown cannot drift.
|
|
22
|
+
*/
|
|
23
|
+
export const SECRETS_CAPABILITY = "secrets";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The canonical name of one project's manager write-Workflow in one environment —
|
|
27
|
+
* `<project>-<env>-secrets-write`. This is the contract between the deployed manager and every CLI
|
|
28
|
+
* dispatch site, so it is defined once here: a rename can't leave a caller dispatching to a Workflow
|
|
29
|
+
* that does not exist.
|
|
30
|
+
*
|
|
31
|
+
* The project segment is not decoration. Workflow names are **account-scoped**, so without it a second
|
|
32
|
+
* Pithy project in the same account dispatches into the first project's manager — writing its secrets
|
|
33
|
+
* into another project's database, under another project's master key.
|
|
34
|
+
*
|
|
35
|
+
* Named as a **Workflow** through core's facade: 64 characters, and refused rather than truncated. A
|
|
36
|
+
* Workflow name is a running instance's address, so a silently reshaped one loses whatever was in
|
|
37
|
+
* flight — and this particular name is also the contract every CLI dispatch resolves against.
|
|
38
|
+
*/
|
|
39
|
+
export function secretsWriteWorkflowName(project: string, env: ManagedEnvironment): string {
|
|
40
|
+
return resourceNames(project).env(env).workflow(SECRETS_CAPABILITY, "write");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The canonical name of one project's at-rest key-rotation Workflow in one environment —
|
|
45
|
+
* `<project>-<env>-secrets-rotate`. Triggered by the manager's own cron rather than by the CLI, but
|
|
46
|
+
* named here beside the write Workflow because both are the same account-scoped namespace and the
|
|
47
|
+
* manager's resolved `wrangler.jsonc` declares them together.
|
|
48
|
+
*/
|
|
49
|
+
export function secretsRotateWorkflowName(project: string, env: ManagedEnvironment): string {
|
|
50
|
+
return resourceNames(project).env(env).workflow(SECRETS_CAPABILITY, "rotate");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The real {@link SecretDispatcher}: dispatches a write to the target environment's manager
|
|
55
|
+
* write-Workflow over the CF Workflows REST API and polls to completion. This is the CLI's write
|
|
56
|
+
* path — the master key is worker-only, so the CLI never encrypts or stores locally. The dispatched
|
|
57
|
+
* params carry the (already validated) value, so failures never echo them (see the client).
|
|
58
|
+
*
|
|
59
|
+
* **Bound to one project.** The dispatcher resolves the target Workflow itself rather than taking an
|
|
60
|
+
* env-to-name function: the name needs both the project and the environment, and a seam that took only
|
|
61
|
+
* the environment quietly invited a caller to supply an unscoped name that resolves to whichever
|
|
62
|
+
* project provisioned the account last.
|
|
63
|
+
*/
|
|
64
|
+
export class WorkflowSecretDispatcher implements SecretDispatcher, SecretProbe, SecretRotationRecorder {
|
|
65
|
+
readonly #client: CloudflareWorkflowsClient;
|
|
66
|
+
readonly #project: string;
|
|
67
|
+
|
|
68
|
+
constructor(client: CloudflareWorkflowsClient, project: string) {
|
|
69
|
+
this.#client = client;
|
|
70
|
+
this.#project = project;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async dispatch(request: SecretWriteRequest): Promise<void> {
|
|
74
|
+
await this.#client.dispatchAndPoll(secretsWriteWorkflowName(this.#project, request.env), {
|
|
75
|
+
mode: request.mode,
|
|
76
|
+
name: request.name,
|
|
77
|
+
value: request.value,
|
|
78
|
+
valueType: request.valueType,
|
|
79
|
+
rotatable: request.rotatable,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Ask one environment's manager whether a name is in its store. The same Workflow, in the one mode
|
|
85
|
+
* that writes nothing (`management/writeSecret.ts`), so a presence check cannot drift from the write
|
|
86
|
+
* it gates: they read the same store, in the same worker, through the same code.
|
|
87
|
+
*
|
|
88
|
+
* The instance output is **decoded, never trusted**. It arrives over the Workflows REST API as
|
|
89
|
+
* `unknown`, and an unread field would default to absent — which is the answer that makes
|
|
90
|
+
* provisioning mint a second value over a live one. A shape nobody expected stops the run instead.
|
|
91
|
+
*/
|
|
92
|
+
async probe(request: SecretProbeRequest): Promise<boolean> {
|
|
93
|
+
const output = await this.#client.dispatchAndPoll(secretsWriteWorkflowName(this.#project, request.env), {
|
|
94
|
+
mode: "probe",
|
|
95
|
+
name: request.name,
|
|
96
|
+
});
|
|
97
|
+
const parsed = WriteWorkflowResult.safeParse(output);
|
|
98
|
+
if (!parsed.success) {
|
|
99
|
+
throw new UpstreamError({
|
|
100
|
+
message: `The ${request.env} secrets manager gave no usable answer about '${request.name}'.`,
|
|
101
|
+
action: "Redeploy the manager with pithy secrets provision, then run this again.",
|
|
102
|
+
detail: `probe ${request.name} in ${request.env}: unexpected write-workflow output`,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return parsed.data.outcome === "present";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Open a rotation row in one environment's ledger and return its id.
|
|
110
|
+
*
|
|
111
|
+
* The same Workflow again, for the same reason the probe uses it: the rotation table sits in the manager's
|
|
112
|
+
* own D1, so the process that can write it is the one that can write the value. The output is decoded and
|
|
113
|
+
* the id demanded — a `rotation-open` that answered with no id would leave the close addressing nothing,
|
|
114
|
+
* and a row that never closes reads as a rotation still running long after it finished.
|
|
115
|
+
*/
|
|
116
|
+
async openRotation(request: SecretRotationOpenRequest): Promise<number> {
|
|
117
|
+
const output = await this.#client.dispatchAndPoll(secretsWriteWorkflowName(this.#project, request.env), {
|
|
118
|
+
mode: "rotation-open",
|
|
119
|
+
name: request.name,
|
|
120
|
+
trigger: request.trigger,
|
|
121
|
+
rotatedBy: request.rotatedBy,
|
|
122
|
+
});
|
|
123
|
+
const parsed = WriteWorkflowResult.safeParse(output);
|
|
124
|
+
if (!parsed.success || parsed.data.outcome !== "opened" || parsed.data.rotationId === undefined) {
|
|
125
|
+
throw new UpstreamError({
|
|
126
|
+
message: `The ${request.env} secrets manager did not record a rotation of '${request.name}'.`,
|
|
127
|
+
action: "Redeploy the manager with pithy secrets provision so its rotation history is written again.",
|
|
128
|
+
detail: `rotation-open ${request.name} in ${request.env}: unexpected write-workflow output`,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return parsed.data.rotationId;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Close a row this dispatcher opened in the same environment. */
|
|
135
|
+
async closeRotation(request: SecretRotationCloseRequest): Promise<void> {
|
|
136
|
+
await this.#client.dispatchAndPoll(secretsWriteWorkflowName(this.#project, request.env), {
|
|
137
|
+
mode: "rotation-close",
|
|
138
|
+
rotationId: request.rotationId,
|
|
139
|
+
closure: request.closure,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { defineSecretRegistry } from "../registry";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The secrets-manager worker's own secret registry. The manager reads it the seam's standard way —
|
|
8
|
+
* `sharedSecretsStore(env, managerRegistry)`, then `.get("CLOUDFLARE_API_TOKEN")` at the point of need, so
|
|
9
|
+
* every secret use is a visible, grep-able call site. The token is read as a `cf-secrets-store`
|
|
10
|
+
* binding, never a plaintext env string.
|
|
11
|
+
*
|
|
12
|
+
* `CLOUDFLARE_API_TOKEN` is `global` (one value, written once canonically via prod, bound the
|
|
13
|
+
* same way by every manager) and `rotatable` (forward-looking — a future value-rotator may self-roll
|
|
14
|
+
* it; no value-rotation logic ships now). Provisioning owns the store-entry-name → binding-var
|
|
15
|
+
* mapping out of band: the entry lives in the account's one flat Secrets Store under the
|
|
16
|
+
* project-scoped name `<project>-global-secrets-manager-cf-api-token` (`managerCfApiTokenSecretName`)
|
|
17
|
+
* and binds into each manager as `CLOUDFLARE_API_TOKEN`, so this registry stays keyed by the binding
|
|
18
|
+
* var — which is not scoped, and must not be (see `provision/provisionSecrets`).
|
|
19
|
+
*
|
|
20
|
+
* **Least privilege — this token needs Secrets Store edit access and nothing else.** The manager's
|
|
21
|
+
* only runtime use of the token is the at-rest rotation's config write-back (`CloudflareSecretsStoreManager.putSecret`
|
|
22
|
+
* → list + delete + create on the store). The rotation's D1 re-encryption runs entirely through the
|
|
23
|
+
* `SECRETS` binding, so the token grants no D1 or Workers access. Scope it to **Account › Secrets
|
|
24
|
+
* Store › Edit** on the one store. (It is distinct from the broad bootstrap token the CLI uses to
|
|
25
|
+
* deploy and provision — see `provision/provisionSecrets` for that boundary.)
|
|
26
|
+
*/
|
|
27
|
+
export const managerRegistry = defineSecretRegistry({
|
|
28
|
+
CLOUDFLARE_API_TOKEN: {
|
|
29
|
+
backend: "cf-secrets-store",
|
|
30
|
+
scope: "global",
|
|
31
|
+
rotatable: true,
|
|
32
|
+
valueType: "text",
|
|
33
|
+
// `helped` to create, `provider` to rotate — the case a single axis cannot express, and the reason
|
|
34
|
+
// #322 has two. We cannot mint a Cloudflare token: that needs credentials for their account, which
|
|
35
|
+
// this product must never hold. We can say exactly what one needs, so nothing downstream keeps its
|
|
36
|
+
// own table of permission groups — `needs.cloudflare` is `secretsTokenProfile.permissions`, and
|
|
37
|
+
// `capability.test.ts` holds the two to each other rather than trusting this copy.
|
|
38
|
+
origin: {
|
|
39
|
+
kind: "helped",
|
|
40
|
+
issuer: "cloudflare",
|
|
41
|
+
needs: { cloudflare: ["secrets:read", "secrets:write"] },
|
|
42
|
+
documentation: "https://developers.cloudflare.com/fundamentals/api/get-started/create-token/",
|
|
43
|
+
},
|
|
44
|
+
// Cloudflare rolls a token and returns the new value, so this one can replace itself. Declared per
|
|
45
|
+
// secret and never per issuer: some Cloudflare secrets roll and some do not, and `issuer` says
|
|
46
|
+
// nothing about which.
|
|
47
|
+
rotation: {
|
|
48
|
+
kind: "provider",
|
|
49
|
+
issuer: "cloudflare",
|
|
50
|
+
documentation: "https://developers.cloudflare.com/api/resources/user/subresources/tokens/methods/update/",
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { WorkflowRetryPolicy } from "@pithy-sh/core/src/workflow/faults";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* **What the secrets manager's Workflows retry, and what they refuse to.**
|
|
8
|
+
*
|
|
9
|
+
* The manager hosts two Workflows — the CLI's write dispatch and the at-rest key rotation — and both run
|
|
10
|
+
* against exactly two storage systems: this environment's D1, and the account's CF Secrets Store. That is
|
|
11
|
+
* a short list of ways a write can fail, so the classification is short too (pithy-sh/pithy#338).
|
|
12
|
+
*
|
|
13
|
+
* ## Terminal, and why
|
|
14
|
+
*
|
|
15
|
+
* - **`secrets/already_exists`** — `create` refusing a name that is already there. This is the write path
|
|
16
|
+
* *succeeding at its job*: the refusal is what closes the concurrent-write race, and it is what stops a
|
|
17
|
+
* typo minting a second secret over a live one. A name does not stop existing because you asked twice,
|
|
18
|
+
* so the retry is pure delay dressed as resilience.
|
|
19
|
+
* - **`secrets/not_found`** — `update` refusing a name that is not there. The same argument, mirrored.
|
|
20
|
+
* - **`secrets/invalid_value`**, **`validation/invalid_input`** — a value or a payload the schema refuses.
|
|
21
|
+
* Deterministic in the input; a second attempt parses the same bytes.
|
|
22
|
+
* - **`secrets/crypto_failed`** — a missing key version or unreadable ciphertext. A key that is not in the
|
|
23
|
+
* bound key set will not be in it on the next attempt either; this one wants an operator, not a backoff.
|
|
24
|
+
* - **A value too large for the Secrets Store** — a limit, and a refusal, not an outage.
|
|
25
|
+
*
|
|
26
|
+
* ## Retryable, and why
|
|
27
|
+
*
|
|
28
|
+
* - **`core/upstream_failed` / `core/upstream_timeout`** — the Cloudflare API, unreachable or out of time.
|
|
29
|
+
* Only the rotation write-back talks to it; the next attempt may well reach it.
|
|
30
|
+
* - **A transient D1 fault** — busy, timed out, connection lost, storage reset, internal. Core classifies
|
|
31
|
+
* those (`withD1Retry`), the write path already retries them in-process, and a step re-drive is the
|
|
32
|
+
* second, much longer backoff for a database that is still recovering. Nothing is stated here because
|
|
33
|
+
* nothing should be: one D1 vocabulary, in core, or the two drift.
|
|
34
|
+
*
|
|
35
|
+
* Everything absent is terminal. That is the whole point of the record: a code is retried because someone
|
|
36
|
+
* wrote down what a second attempt would see, not because nobody classified it.
|
|
37
|
+
*/
|
|
38
|
+
export const secretsWorkflowRetry: WorkflowRetryPolicy = {
|
|
39
|
+
capability: "secrets",
|
|
40
|
+
retryable: {
|
|
41
|
+
"core/upstream_failed": "The Cloudflare API could not be reached; the rotation write-back may reach it next time.",
|
|
42
|
+
"core/upstream_timeout": "The Cloudflare API ran out of time; the write-back is idempotent on the key set.",
|
|
43
|
+
},
|
|
44
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { createDatabase } from "@pithy-sh/core/src/data/db";
|
|
5
|
+
import { secretsTables } from "../data/tables";
|
|
6
|
+
import { resolveEncryptionConfig, type SecretsStoreEnv } from "../env/bindings";
|
|
7
|
+
import { type AtRestRotationResult, runAtRestKeyRotation, type StepRunner } from "../rotation/atRestKeyRotation";
|
|
8
|
+
import { RotationTracker } from "../store/rotationTracker";
|
|
9
|
+
import type { ConfigWriter } from "./configWriter";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The at-rest key-rotation Workflow's body: build the store, config, and tracker from the worker
|
|
13
|
+
* env and run the rotation core in durable steps. The `configWriter` is injected so this is testable
|
|
14
|
+
* against Miniflare with a stub (the only thing that needs a live CF Secrets Store is the write-back
|
|
15
|
+
* itself); the deployed worker passes the real `SecretsStoreConfigWriter`.
|
|
16
|
+
*/
|
|
17
|
+
export async function runRotationWorkflow(
|
|
18
|
+
env: SecretsStoreEnv,
|
|
19
|
+
configWriter: ConfigWriter,
|
|
20
|
+
step: StepRunner,
|
|
21
|
+
): Promise<AtRestRotationResult> {
|
|
22
|
+
const config = await resolveEncryptionConfig(env);
|
|
23
|
+
const db = createDatabase(env.SECRETS, secretsTables);
|
|
24
|
+
const tracker = RotationTracker.fromD1(env.SECRETS);
|
|
25
|
+
return runAtRestKeyRotation({ db, config, configWriter, tracker }, step);
|
|
26
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { CloudflareSecretsStoreManager } from "@pithy-sh/cloudflare/src/secrets/secretsStoreManager";
|
|
5
|
+
import { fromZodError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import { masterKeySecretName } from "../provision/provisionSecrets";
|
|
7
|
+
import { ManagedEnvironment } from "../scope";
|
|
8
|
+
import type { ConfigWriter } from "./configWriter";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The real {@link ConfigWriter}: writes the master-key config back to CF Secrets Store over REST
|
|
12
|
+
* during at-rest rotation (the binding itself is read-only). `putSecret` updates the entry in place,
|
|
13
|
+
* so a failed write leaves the prior config bound and decryptable — losing this config would make
|
|
14
|
+
* every stored secret undecryptable. This is the one write to CF Secrets Store that cannot run
|
|
15
|
+
* locally, so it is exercised by the integration suite, not the local one.
|
|
16
|
+
*
|
|
17
|
+
* `secretName` has no default on purpose: there is no safe unscoped entry name to fall back to, and a
|
|
18
|
+
* default here would be a silent write to somebody else's key entry.
|
|
19
|
+
*/
|
|
20
|
+
export class SecretsStoreConfigWriter implements ConfigWriter {
|
|
21
|
+
readonly #manager: CloudflareSecretsStoreManager;
|
|
22
|
+
readonly #secretName: string;
|
|
23
|
+
|
|
24
|
+
constructor(manager: CloudflareSecretsStoreManager, secretName: string) {
|
|
25
|
+
this.#manager = manager;
|
|
26
|
+
this.#secretName = secretName;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async write(value: string): Promise<void> {
|
|
30
|
+
await this.#manager.putSecret(this.#secretName, value);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build the at-rest rotation's config writer, targeting the **project- and env-scoped** master-key
|
|
36
|
+
* entry the manager actually binds (`<project>-<env>-secrets-encryption-keys`) — never a bare default.
|
|
37
|
+
* Both segments come from wrangler vars stamped at provision (`PROJECT`, `ENVIRONMENT`); they are
|
|
38
|
+
* external config, so the environment is validated here via `ManagedEnvironment.parse` and the project
|
|
39
|
+
* by the naming facade `masterKeySecretName` composes through (which refuses an empty or illegal one).
|
|
40
|
+
*
|
|
41
|
+
* Getting either wrong is not a failed write — it is a successful write to the wrong entry. The
|
|
42
|
+
* rotation would re-encrypt every row under a fresh key, persist that key where nothing binds it, and
|
|
43
|
+
* leave the old key bound: every secret in the store becomes undecryptable, silently, at the next read.
|
|
44
|
+
* With an unscoped name in a shared account it would be worse still — a rotation would land on another
|
|
45
|
+
* project's key entry and take their store down too.
|
|
46
|
+
*/
|
|
47
|
+
export function rotationConfigWriter(
|
|
48
|
+
manager: CloudflareSecretsStoreManager,
|
|
49
|
+
project: string,
|
|
50
|
+
environment: string,
|
|
51
|
+
): SecretsStoreConfigWriter {
|
|
52
|
+
const parsed = ManagedEnvironment.safeParse(environment);
|
|
53
|
+
if (!parsed.success) {
|
|
54
|
+
throw fromZodError(parsed.error, {
|
|
55
|
+
message: "The secrets manager's ENVIRONMENT var is not a managed environment.",
|
|
56
|
+
action: "Redeploy the manager with `pithy secrets provision`, which stamps it.",
|
|
57
|
+
detail: `rotation write-back: ENVIRONMENT=${environment}`,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return new SecretsStoreConfigWriter(manager, masterKeySecretName(project, parsed.data));
|
|
61
|
+
}
|