@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,125 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
5
+ import type { DeclaredEnvironments } from "@pithy-sh/core/src/naming/environment";
6
+ import type { SecretBackend, SecretScope } from "../registry";
7
+ import { canonicalGlobalEnvironment, type ManagedEnvironment, resolveWriteTargets } from "../scope";
8
+
9
+ /**
10
+ * **Where a write is allowed to land, decided once, before anything is dispatched.**
11
+ *
12
+ * A `global` secret is *defined* by holding one value in every environment — `email-link-signing-key`
13
+ * exists so a link signed in staging verifies in prod. A split is therefore not a state the product has
14
+ * a meaning for, and it can arrive two ways:
15
+ *
16
+ * 1. **An operator asks for one.** `pithy secrets update <global> --env staging` narrows a scope that
17
+ * cannot be narrowed. This is a category error, not a fault, and it is what this module refuses.
18
+ * 2. **A fan-out fails part-way.** Three environments, the third throws. That one is a fault, it is not
19
+ * preventable here, and the honest treatment is below.
20
+ *
21
+ * ## Why refusal is all this can offer, and why that is enough
22
+ *
23
+ * The write path is `CLI → dispatch → one Workflow per environment, in one Worker per environment`.
24
+ * There is no transaction across them and no rollback: a compensating write is itself a Workflow that
25
+ * can fail, which is the same problem one level down. **So "complete-or-revert" is not available and is
26
+ * not implied.** What is available is deciding before anything is written, which is exactly what closes
27
+ * case 1 — and case 1 is the one an ordinary command produces.
28
+ *
29
+ * This is why the refusal is here and not in the manager. A Workflow that throws does not produce a CLI
30
+ * error: it fails an instance, the runtime retries it, and the CLI polls and has to read an operator's
31
+ * typo as a distributed failure. Retrying a refusal is nonsense. Refusing before dispatch also satisfies
32
+ * *a refusal that has already written is not a refusal* for free — nothing was sent, so nothing landed.
33
+ *
34
+ * ## The residual window, stated rather than papered over
35
+ *
36
+ * Case 2 remains reachable. A `global` + `d1` secret fans out across every declared environment, and a
37
+ * fault inside that fan-out leaves some environments on the new value and the rest on the old. Nothing
38
+ * here narrows that. What the dispatch path does instead is **say what it wrote** — see
39
+ * `environmentsWrittenBeforeFailure` in `./dispatch.ts` — so the split is visible to the operator who
40
+ * has to repair it. A guarantee that cannot be given is not given.
41
+ *
42
+ * `global` + `cf-secrets-store` needs none of this, and the reason is structural rather than an
43
+ * oversight: it is one account-level entry that every environment binds, so it is a single write with
44
+ * nothing to be inconsistent with (`resolveWriteTargets`). It still refuses `--env`, because narrowing a
45
+ * scope that has no parts is the same category error arriving at a backend where it happens to be
46
+ * harmless.
47
+ *
48
+ * ## One owner, so a third caller inherits it
49
+ *
50
+ * `resolveWriteTargets` in `../scope.ts` is the backend × scope **table**. It holds no policy and it
51
+ * refuses nothing. This is the **rule**, and it is what `dispatchSecretWrite` and `mintDeclaredSecrets`
52
+ * both call to obtain their targets. `capabilities/writeTargetsOwner.test.ts` in `@pithy-sh/cli` fails
53
+ * the build on any shipped module that reaches the table directly, so a write path added later cannot
54
+ * quietly skip the rule by naming the thing underneath it.
55
+ */
56
+ export interface SecretWriteIntent {
57
+ /** The secret's registry name. It appears in the refusal, so the operator knows which one is global. */
58
+ name: string;
59
+ /** Where the secret is held. Decides the fan-out shape for a `global` write; never decides the rule. */
60
+ backend: SecretBackend;
61
+ /** The secret's declared scope. This is what the rule is about. */
62
+ scope: SecretScope;
63
+ /** What the command is doing. It changes the wording of the remedy and nothing else — see the tests. */
64
+ mode: "create" | "update" | "delete";
65
+ /**
66
+ * The environment the operator named, or `undefined` when they named none.
67
+ *
68
+ * **The absence has to survive to here**, which is why this is optional rather than defaulted upstream.
69
+ * The command used to resolve a missing `--env` on a global secret to the canonical environment before
70
+ * dispatch, which erased the difference between *the operator narrowed the write* and *the operator
71
+ * said nothing* — and it is exactly that difference the rule turns on.
72
+ */
73
+ requested: ManagedEnvironment | undefined;
74
+ /** Every environment the project declares, from the root `pithy.config.ts`. The fan-out set. */
75
+ declared: DeclaredEnvironments | readonly string[];
76
+ }
77
+
78
+ /** The remedy's verb, by mode. A `rm` that is told to "set it everywhere" is a remedy for another command. */
79
+ const REMEDY: Record<SecretWriteIntent["mode"], string> = {
80
+ create: "set it in every environment",
81
+ update: "set it in every environment",
82
+ delete: "remove it from every environment",
83
+ };
84
+
85
+ /**
86
+ * The environments this write reaches — or a refusal, thrown, if the request and the scope disagree.
87
+ *
88
+ * Two refusals, and they are the same rule read from both ends: a `global` secret cannot be narrowed to
89
+ * one environment, and an `environment` secret cannot be widened to all of them. Neither has a bypass
90
+ * flag. The remedy for both is a single re-run, and a re-run is the confirmation of intent that a `--yes`
91
+ * would otherwise have to stand in for.
92
+ */
93
+ export function secretWriteTargets(intent: SecretWriteIntent): ManagedEnvironment[] {
94
+ if (intent.scope === "environment") {
95
+ if (intent.requested === undefined) {
96
+ throw new ValidationError({
97
+ message: `Secret '${intent.name}' is environment-scoped — choose an environment.`,
98
+ action: `Pass one of ${intent.declared.map((env) => `--env ${env}`).join(" or ")}.`,
99
+ detail: `environment-scoped secret '${intent.name}' dispatched with no requested environment`,
100
+ });
101
+ }
102
+ return resolveWriteTargets(intent.backend, intent.scope, intent.requested, intent.declared);
103
+ }
104
+
105
+ if (intent.requested !== undefined) {
106
+ throw new ValidationError({
107
+ message: `Secret '${intent.name}' is global. It holds one value across every environment, so --env cannot narrow it.`,
108
+ action: `Run it again without --env to ${REMEDY[intent.mode]}.`,
109
+ detail: `global secret '${intent.name}': ${intent.mode} narrowed to ${intent.requested}, which the scope does not permit`,
110
+ });
111
+ }
112
+
113
+ // The canonical environment is the table's input for a `global` write, not the operator's — it picks
114
+ // which manager performs the single `cf-secrets-store` write, and is unread for `d1`. Resolved here so
115
+ // the table keeps its signature and the command stops inventing an environment it was never given.
116
+ const canonical = canonicalGlobalEnvironment(intent.declared);
117
+ if (canonical === undefined) {
118
+ throw new ValidationError({
119
+ message: `This project declares no environments, so '${intent.name}' has nowhere to go.`,
120
+ action: "Declare at least one environment in the root pithy.config.ts, then run this again.",
121
+ detail: `global secret '${intent.name}': empty declaration`,
122
+ });
123
+ }
124
+ return resolveWriteTargets(intent.backend, intent.scope, canonical, intent.declared);
125
+ }
@@ -0,0 +1,16 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /// <reference types="@cloudflare/vitest-plugin/types" />
5
+
6
+ // Bindings the Workers-runtime test project provides to `*.workers.test.ts`,
7
+ // matching the Miniflare config in `vitest.workers.config.ts`: the dedicated D1
8
+ // `SECRETS` database. `cloudflare:test` types its `env` as `Cloudflare.Env`, so
9
+ // the test bindings are declared by augmenting that interface.
10
+ declare namespace Cloudflare {
11
+ interface Env {
12
+ SECRETS: D1Database;
13
+ /** The master-key config as a string (the `.dev.vars` shape), set in `vitest.workers.config.ts`. */
14
+ SECRETS_ENCRYPTION_KEYS: string;
15
+ }
16
+ }
@@ -0,0 +1,188 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { SecretCryptoError } from "../error/errors";
6
+
7
+ /**
8
+ * AES-256-GCM envelope helpers, ported from the CMS `secretsCrypto`. The master key never
9
+ * leaves the worker: it is read from the `SECRETS_ENCRYPTION_KEYS` binding (CF Secrets Store)
10
+ * as an {@link EncryptionConfig}, and every encrypt/decrypt runs in-process. Helpers take a
11
+ * parsed config so a caller resolves it once and passes the typed value through.
12
+ *
13
+ * Two version axes live here: `currentVersion` (which master key encrypts new writes) and the
14
+ * `keyVersion` persisted per row (which key decrypts it). Both let the at-rest rotation job
15
+ * re-encrypt under a new key with an overlap window. This is the *encryption-key* version — the
16
+ * *value* version is a separate concern (see `versionedValue.ts`).
17
+ *
18
+ * The config carries the same uniform `{ currentVersion, versions }` envelope every secret uses
19
+ * (`versionedValue.ts`) — `versions` is the still-valid key set, `currentVersion` the active
20
+ * pointer — plus `lastRotatedAt` beside it as rotation metadata. Stringified-integer version keys,
21
+ * so the keys read is inherently a full-set read (every still-valid version, never current-only).
22
+ *
23
+ * ## The secret's name is authenticated, not decorative
24
+ *
25
+ * Every seal binds the secret's **stored name** as AES-GCM additional authenticated data. It is not
26
+ * encrypted; it is bound. Without it a ciphertext is valid under the key alone, so one lifted from
27
+ * one row and written into another opens cleanly and the envelope has no opinion about whether it
28
+ * belongs there — every decision about which secret a caller gets then lives in the query above, and
29
+ * a bug there is a disclosure rather than a failure. With the name bound, a moved ciphertext does
30
+ * not open, inside the primitive, however that query is later rewritten.
31
+ *
32
+ * The stored name is the right thing to bind because it *is* the row's identity: `name` is unique in
33
+ * `pithy_secrets_system_secrets`, and for a keyspace member it is the whole `<entry>/<key>`
34
+ * (`keyspace.ts`) — so one tenant's credential does not open under another tenant's key.
35
+ *
36
+ * **What is bound must be stable for the life of the ciphertext, and this is.** Nothing renames a
37
+ * secret: the store offers `put` and `delete`, and a rename is a create under the new name plus a
38
+ * delete of the old, which re-encrypts through the plaintext. An `UPDATE ... SET name` in raw SQL
39
+ * would leave a row that never opens again — which is the property, not a regression. Choosing
40
+ * anything mutable (a display label, an owning tenant that could be reassigned) would make a rename
41
+ * an unopenable secret; the name cannot be renamed, so it cannot.
42
+ *
43
+ * The AAD is prefixed with {@link AAD_CONTEXT} for domain separation: a ciphertext from some future
44
+ * construction under the same master key will not open here, and vice versa. That prefix is
45
+ * versioned so a later change to what is bound is a deliberate, visible break.
46
+ *
47
+ * **No compatibility path.** Ciphertexts written before the binding existed carry no AAD and do not
48
+ * open — they fail as `secrets/crypto_failed`, like any other unreadable row. Nothing is published
49
+ * and no adopter stores production values yet, so accepting an unbound ciphertext "just this once"
50
+ * would buy nothing and leave the exact hole this closes permanently reachable.
51
+ */
52
+ export const EncryptionConfig = z
53
+ .object({
54
+ currentVersion: z
55
+ .string()
56
+ .describe(
57
+ "The version key (a stringified integer) whose master key encrypts new writes — the active master key.",
58
+ ),
59
+ versions: z
60
+ .record(z.string(), z.string())
61
+ .describe(
62
+ "Every still-valid master key: version key (stringified integer) → base64-encoded AES-256 key. Holds the current key plus any prior versions still needed to decrypt rows not yet re-encrypted.",
63
+ ),
64
+ lastRotatedAt: z.iso
65
+ .datetime()
66
+ .describe(
67
+ "ISO-8601 timestamp of the last at-rest key rotation; the cron compares against it to decide when to rotate.",
68
+ ),
69
+ })
70
+ .describe(
71
+ "The master-key configuration, read from the worker-only SECRETS_ENCRYPTION_KEYS binding (CF Secrets Store). The uniform versioned-value shape (currentVersion + versions) plus rotation metadata.",
72
+ );
73
+ export type EncryptionConfig = z.output<typeof EncryptionConfig>;
74
+
75
+ /**
76
+ * One AES-256-GCM envelope: base64 ciphertext, base64 IV, and the key version that produced it. The
77
+ * bound name is deliberately not in here — it is the row's own `name` column, supplied by the caller
78
+ * on the way back in, so a row that has been moved cannot carry its old context with it.
79
+ */
80
+ export interface EncryptedEnvelope {
81
+ encryptedValue: string;
82
+ iv: string;
83
+ keyVersion: number;
84
+ }
85
+
86
+ /** Base64-encode raw bytes without spreading into `fromCharCode` (stack-safe, lint-clean). */
87
+ function toBase64(bytes: Uint8Array): string {
88
+ let binary = "";
89
+ for (const byte of bytes) binary += String.fromCharCode(byte);
90
+ return btoa(binary);
91
+ }
92
+
93
+ /** Decode base64 to raw bytes. */
94
+ function fromBase64(b64: string): Uint8Array {
95
+ return Uint8Array.from(atob(b64), (char) => char.charCodeAt(0));
96
+ }
97
+
98
+ /**
99
+ * The domain-separation prefix on every seal's authenticated data. Versioned: changing what is bound
100
+ * changes this too, so the break is deliberate and every old ciphertext fails loudly rather than
101
+ * opening under a rule it was not sealed with.
102
+ */
103
+ const AAD_CONTEXT = "pithy.secrets.v1:";
104
+
105
+ /**
106
+ * The authenticated data for one secret: the domain prefix and the stored name.
107
+ *
108
+ * An empty name is refused on both halves. It would bind the prefix alone — every secret sharing one
109
+ * context is the same as binding nothing, and it would fail open rather than loudly.
110
+ */
111
+ function additionalData(name: string): Uint8Array {
112
+ if (name.length === 0) {
113
+ throw new SecretCryptoError({ detail: "the envelope needs a secret name to bind; an empty name binds nothing" });
114
+ }
115
+ return new TextEncoder().encode(`${AAD_CONTEXT}${name}`);
116
+ }
117
+
118
+ /** Import the AES-GCM key for `version` from the config, or throw if that version is absent. */
119
+ async function importKey(
120
+ config: EncryptionConfig,
121
+ version: number,
122
+ usage: ("encrypt" | "decrypt")[],
123
+ ): Promise<CryptoKey> {
124
+ const b64 = config.versions[String(version)];
125
+ if (!b64) {
126
+ throw new SecretCryptoError({
127
+ detail: `encryption key version ${version} not present in SECRETS_ENCRYPTION_KEYS`,
128
+ });
129
+ }
130
+ return crypto.subtle.importKey("raw", fromBase64(b64), "AES-GCM", false, usage);
131
+ }
132
+
133
+ /**
134
+ * Encrypt `plaintext` for the secret stored as `name`, under the config's current key version. The
135
+ * IV is 12 fresh random bytes; `name` is bound as authenticated data, so the result opens only for
136
+ * that name.
137
+ */
138
+ export async function encryptValue(
139
+ config: EncryptionConfig,
140
+ name: string,
141
+ plaintext: string,
142
+ ): Promise<EncryptedEnvelope> {
143
+ const aad = additionalData(name);
144
+ const keyVersion = Number(config.currentVersion);
145
+ const key = await importKey(config, keyVersion, ["encrypt"]);
146
+ const iv = crypto.getRandomValues(new Uint8Array(12));
147
+ const ciphertext = await crypto.subtle.encrypt(
148
+ { name: "AES-GCM", iv, additionalData: aad },
149
+ key,
150
+ new TextEncoder().encode(plaintext),
151
+ );
152
+ return { encryptedValue: toBase64(new Uint8Array(ciphertext)), iv: toBase64(iv), keyVersion };
153
+ }
154
+
155
+ /**
156
+ * Decrypt an envelope sealed for the secret stored as `name`. The key version may differ from
157
+ * `currentVersion` during a rotation window; the matching key must still be in the config.
158
+ *
159
+ * A missing key version, a tampered ciphertext, the wrong key, or a ciphertext sealed for a
160
+ * different name all throw `secrets/crypto_failed` — never the raw plaintext or key material. GCM
161
+ * cannot tell those apart, and separating them for the caller would be an oracle: the `detail` names
162
+ * the context the decrypt was *attempted under*, which is what an operator needs to see. `name`
163
+ * reaches logs verbatim there, which is safe because every name is either a registry literal or a
164
+ * `keyedSecretName` composed from a validated `SecretKey` — no newlines, no control bytes.
165
+ */
166
+ export async function decryptValue(
167
+ config: EncryptionConfig,
168
+ name: string,
169
+ envelope: EncryptedEnvelope,
170
+ ): Promise<string> {
171
+ const aad = additionalData(name);
172
+ const key = await importKey(config, envelope.keyVersion, ["decrypt"]);
173
+ try {
174
+ const plaintext = await crypto.subtle.decrypt(
175
+ { name: "AES-GCM", iv: fromBase64(envelope.iv), additionalData: aad },
176
+ key,
177
+ fromBase64(envelope.encryptedValue),
178
+ );
179
+ return new TextDecoder().decode(plaintext);
180
+ } catch (cause) {
181
+ throw new SecretCryptoError(
182
+ {
183
+ detail: `AES-GCM decrypt failed for secret '${name}' under key version ${envelope.keyVersion}: wrong key, tampered ciphertext, or a ciphertext sealed for a different name`,
184
+ },
185
+ { cause },
186
+ );
187
+ }
188
+ }
@@ -0,0 +1,82 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { SecretCryptoError } from "../error/errors";
6
+
7
+ /**
8
+ * The universal value serde. Every secret — rotatable or not — is stored the same way: a
9
+ * `{ currentVersion, versions }` envelope, JSON-encoded, then sealed in one AES-256-GCM
10
+ * envelope. The shape mirrors `EncryptionConfig`'s `{ currentVersion, keys }`: an explicit
11
+ * pointer to the current version (so we never sort stringified-integer keys, where `"10" < "2"`)
12
+ * plus every still-valid version. A new secret is a one-entry envelope; a value rotation (a
13
+ * deferred feature) appends the next version and repoints `currentVersion`. Values are strings —
14
+ * a `json` secret stores its serialized form, parsed at the read seam. This is the *value*
15
+ * version, distinct from the envelope's encryption-key version (see `envelope.ts`).
16
+ *
17
+ * Future: when a version is rotated out, it can carry a pruning TTL so a retired version is
18
+ * removed from the store after its grace window. That is modeled as a parallel `retiredAt` map
19
+ * (version → ISO timestamp), keeping `versions` itself string→string. Deferred with value rotation.
20
+ */
21
+ export const VersionedValue = z
22
+ .object({
23
+ currentVersion: z
24
+ .string()
25
+ .describe("The version key (a stringified integer) whose value is current — the default returned by `get`."),
26
+ versions: z
27
+ .record(z.string(), z.string())
28
+ .describe("Every still-valid version: version key (stringified integer) → value. Always at least one entry."),
29
+ })
30
+ .describe("A secret's stored plaintext: an explicit current-version pointer plus every still-valid version.");
31
+ export type VersionedValue = z.output<typeof VersionedValue>;
32
+
33
+ /** The initial envelope for a freshly-created secret: version 1 is current and holds the value. */
34
+ export function initialVersionedValue(value: string): VersionedValue {
35
+ return { currentVersion: "1", versions: { "1": value } };
36
+ }
37
+
38
+ /** Append `value` at the next version and make it current, keeping prior versions (a value rotation). */
39
+ export function appendVersion(value: VersionedValue, next: string): VersionedValue {
40
+ const version = String(highestVersion(value) + 1);
41
+ return { currentVersion: version, versions: { ...value.versions, [version]: next } };
42
+ }
43
+
44
+ /** The highest existing version number — used only to pick the next on append (never to find "current"). */
45
+ function highestVersion(value: VersionedValue): number {
46
+ const versions = Object.keys(value.versions)
47
+ .map(Number)
48
+ .filter((n) => Number.isInteger(n));
49
+ if (versions.length === 0) {
50
+ throw new SecretCryptoError({ detail: "decoded secret has no versions" });
51
+ }
52
+ return Math.max(...versions);
53
+ }
54
+
55
+ /** The current value — a direct lookup via the explicit `currentVersion` pointer, no sorting. */
56
+ export function currentValue(value: VersionedValue): string {
57
+ const current = value.versions[value.currentVersion];
58
+ if (current === undefined) {
59
+ throw new SecretCryptoError({ detail: `currentVersion '${value.currentVersion}' is absent from versions` });
60
+ }
61
+ return current;
62
+ }
63
+
64
+ /** Serialize an envelope to the plaintext that goes into the AES-256-GCM envelope. */
65
+ export function encodeVersionedValue(value: VersionedValue): string {
66
+ return JSON.stringify(VersionedValue.parse(value));
67
+ }
68
+
69
+ /** Parse decrypted plaintext into a validated envelope. Throws `secrets/crypto_failed` on a bad shape. */
70
+ export function decodeVersionedValue(plaintext: string): VersionedValue {
71
+ let parsed: unknown;
72
+ try {
73
+ parsed = JSON.parse(plaintext);
74
+ } catch (cause) {
75
+ throw new SecretCryptoError({ detail: "decrypted secret plaintext is not valid JSON" }, { cause });
76
+ }
77
+ const result = VersionedValue.safeParse(parsed);
78
+ if (!result.success) {
79
+ throw new SecretCryptoError({ detail: "decrypted secret plaintext is not a versioned value" });
80
+ }
81
+ return result.data;
82
+ }
@@ -0,0 +1,49 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
5
+ import { z } from "zod";
6
+
7
+ export const RotationStatus = z
8
+ .enum(["in_progress", "success", "failed"])
9
+ .describe("Lifecycle state of a rotation attempt: `in_progress` while running, then `success` or `failed`.");
10
+ export type RotationStatus = z.output<typeof RotationStatus>;
11
+
12
+ export const RotationTrigger = z
13
+ .enum(["cron", "manual", "baseline"])
14
+ .describe(
15
+ "What caused the rotation: a scheduled `cron` run, a `manual` operator action, or a `baseline` marker recorded when a secret is first established.",
16
+ );
17
+ export type RotationTrigger = z.output<typeof RotationTrigger>;
18
+
19
+ /**
20
+ * The `pithy_secrets_rotations` table — an append-only audit row per rotation attempt, in
21
+ * the per-environment secrets D1. Records both at-rest encryption-key rotations and (later)
22
+ * value rotations. Ported from the CMS `secret_rotations` model.
23
+ */
24
+ export const SecretRotation = z
25
+ .object({
26
+ id: z.number().int().describe("Auto-incrementing primary key for this rotation event."),
27
+ name: z
28
+ .string()
29
+ .describe(
30
+ "Secret being rotated (matches a registry entry / `pithy_secrets_system_secrets.name`), or a sentinel for whole-store key rotation.",
31
+ ),
32
+ startedAt: SQLiteDate.describe("When the rotation attempt began. Ms-epoch in SQLite, a `Date` in app code."),
33
+ completedAt: SQLiteDate.nullable().describe("When the rotation finished; null while in progress."),
34
+ status: RotationStatus.describe("Lifecycle state of the rotation event."),
35
+ trigger: RotationTrigger.describe("What caused this rotation."),
36
+ rotatedBy: z
37
+ .string()
38
+ .describe("Identifier of the agent that initiated the rotation (workflow instance id, operator id, etc.)."),
39
+ errorMessage: z
40
+ .string()
41
+ .nullable()
42
+ .describe("Human-readable failure reason; populated when status is `failed`, otherwise null."),
43
+ metadataSnapshot: z
44
+ .string()
45
+ .nullable()
46
+ .describe("Opaque JSON snapshot captured at rotation time (per-secret, heterogeneous). Null when none."),
47
+ })
48
+ .describe("One append-only rotation audit row in the per-environment secrets D1 (`pithy_secrets_rotations`).");
49
+ export type SecretRotation = z.output<typeof SecretRotation>;
@@ -0,0 +1,29 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
6
+ import { createDatabase } from "@pithy-sh/core/src/data/db";
7
+ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
8
+ import type { Context } from "hono";
9
+ import type { SecretsStatusDb } from "../admin/status";
10
+ import { secretsTables } from "./tables";
11
+
12
+ /**
13
+ * The per-environment secrets D1 for one request, typed over this capability's tables.
14
+ *
15
+ * One reader, because there are now two callers — the management routes and the manifest's health
16
+ * summary — and a binding rule that lives at a call site is a rule the second call site gets wrong.
17
+ * Every defect class in this kit with three producers began exactly there.
18
+ */
19
+ export function secretsStatusDatabase(c: Context<PithyHonoEnv>): SecretsStatusDb {
20
+ const binding = (c.env as Record<string, unknown>).SECRETS as D1Database | undefined;
21
+ if (!binding) {
22
+ throw new InternalError({
23
+ message: "The secrets store is not configured.",
24
+ action: "Bind a D1 database named SECRETS in wrangler.jsonc.",
25
+ detail: "The secrets management surface requires a `SECRETS` D1 binding; none was present on env.",
26
+ });
27
+ }
28
+ return createDatabase(binding, secretsTables);
29
+ }
@@ -0,0 +1,44 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
5
+ import { z } from "zod";
6
+ import { SecretValueType } from "../registry";
7
+
8
+ /**
9
+ * The `pithy_secrets_system_secrets` table — one encrypted row per stored secret in the
10
+ * per-environment secrets D1. Ported from the CMS `system_secrets` model.
11
+ *
12
+ * The row carries an AES-256-GCM envelope (`encryptedValue` + `iv`) plus the `keyVersion`
13
+ * that produced it, so the at-rest key-rotation job can re-encrypt under a new master key
14
+ * without a consumer-visible change. The decrypted plaintext is a JSON version→value map
15
+ * (`{ "1": <value> }`), so adding value-rotation later is append-a-version, not reshape.
16
+ *
17
+ * `z.output` is the app shape (a `Date` for the timestamps); `z.input` is the SQLite row
18
+ * shape (ms-epoch numbers), via the `SQLiteDate` codec.
19
+ */
20
+ export const SystemSecret = z
21
+ .object({
22
+ id: z
23
+ .number()
24
+ .int()
25
+ .describe("Surrogate primary key, autoincremented by SQLite. Lookups use the unique `name` column."),
26
+ name: z
27
+ .string()
28
+ .describe(
29
+ "Stable identifier for the secret: a registry entry name, or `<keyspace>/<key>` for one member of a keyed entry; uniquely indexed.",
30
+ ),
31
+ encryptedValue: z.string().describe("Base64-encoded AES-256-GCM ciphertext of the version→value map."),
32
+ iv: z.string().describe("Base64-encoded initialization vector; unique per encryption operation."),
33
+ keyVersion: z
34
+ .number()
35
+ .int()
36
+ .describe("Master-key version that produced this ciphertext; supports overlapping at-rest rotation windows."),
37
+ valueType: SecretValueType.describe(
38
+ "How the decrypted plaintext is interpreted once unwrapped from the version map: `text` or `json`.",
39
+ ),
40
+ createdAt: SQLiteDate.describe("When the secret was first written. Ms-epoch in SQLite, a `Date` in app code."),
41
+ updatedAt: SQLiteDate.describe("When the secret was last written. Ms-epoch in SQLite, a `Date` in app code."),
42
+ })
43
+ .describe("One encrypted secret row in the per-environment secrets D1 (`pithy_secrets_system_secrets`).");
44
+ export type SystemSecret = z.output<typeof SystemSecret>;
@@ -0,0 +1,16 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { SecretRotation } from "./secretRotations";
5
+ import { SystemSecret } from "./systemSecrets";
6
+
7
+ /**
8
+ * The secrets capability's table map: camelCase keys (CamelCasePlugin emits the snake_case
9
+ * `pithy_secrets_` SQL). One source of truth, shared by the capability wiring (`capability.ts`)
10
+ * and the D1 store (`store/systemSecretsStore.ts`) so both type against the same schema.
11
+ */
12
+ export const secretsTables = {
13
+ pithySecretsSystemSecrets: SystemSecret,
14
+ pithySecretsRotations: SecretRotation,
15
+ };
16
+ export type SecretsTables = typeof secretsTables;