@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,187 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Capability } from "@pithy-sh/core/src/capability/capability";
5
+ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
6
+ import type { SecretsStoreEnv } from "./env/bindings";
7
+ import type { SecretRegistry, SecretRegistryEntry } from "./registry";
8
+ import { d1KeyedIO, type SecretsAccessor, secretsStore } from "./secretsStore";
9
+
10
+ /**
11
+ * The shared, per-invocation secrets accessor. Within one worker invocation many capabilities each
12
+ * read secrets; resolving them independently means a Secrets Store round-trip per call site, and a
13
+ * repeated round-trip when two capabilities share a secret. This module resolves the **combined**
14
+ * registry — every capability's {@link Capability.secretRegistry} slice merged into one — exactly
15
+ * once, caches the resulting accessor for a configurable TTL (default 60 s), and hands each call site
16
+ * a precisely-typed view over only its own slice via {@link SecretsAccessor.subset}.
17
+ *
18
+ * Sharing one resolution is what made #170 bite: the combined registry is every capability's, so an
19
+ * unset secret anywhere in it used to fail the one resolution every capability waits on. The fix is in
20
+ * {@link secretsStore} — a failure is held against its own name and raised at its own read — and it is
21
+ * the reason a shared accessor is safe to share. This module resolves once; it does not resolve
22
+ * all-or-nothing.
23
+ *
24
+ * The cache is module-scoped, so it is per worker isolate: built lazily on the first request that
25
+ * needs secrets, reused by every access within the TTL, and rebuilt on the first access after it
26
+ * expires. `@pithy-sh/secrets`' capability {@link configureSharedSecrets | configures} the combined
27
+ * registry and TTL at worker startup (via its `compose` hook); a standalone worker that uses the
28
+ * accessor without `createBackend` (the email and secrets-manager workers) configures it directly.
29
+ */
30
+
31
+ /** Default cache lifetime when the secrets capability does not override it. */
32
+ export const DEFAULT_SECRETS_CACHE_TTL_SECONDS = 60;
33
+
34
+ /** Resolves the combined registry against `env` — the real path calls {@link secretsStore}; tests inject a fake. */
35
+ type Resolver = (env: SecretsStoreEnv, registry: SecretRegistry) => Promise<SecretsAccessor<SecretRegistry>>;
36
+
37
+ /** A monotonic millisecond clock — `Date.now` in production, controllable in tests. */
38
+ type Clock = () => number;
39
+
40
+ /** Options for {@link configureSharedSecrets}. `resolve`/`now` are seams overridden only by tests. */
41
+ export interface ConfigureSharedSecretsOptions {
42
+ /** The combined registry to resolve — every capability's slice merged. */
43
+ registry: SecretRegistry;
44
+ /** Cache lifetime in seconds. Defaults to {@link DEFAULT_SECRETS_CACHE_TTL_SECONDS}. */
45
+ ttlSeconds?: number;
46
+ /** Override the resolver (testing). Defaults to a real {@link secretsStore} call. */
47
+ resolve?: Resolver;
48
+ /** Override the clock (testing). Defaults to `Date.now`. */
49
+ now?: Clock;
50
+ }
51
+
52
+ interface SharedConfig {
53
+ registry: SecretRegistry;
54
+ ttlMs: number;
55
+ resolve: Resolver;
56
+ now: Clock;
57
+ }
58
+
59
+ interface CacheEntry {
60
+ accessor: SecretsAccessor<SecretRegistry>;
61
+ expiresAt: number;
62
+ }
63
+
64
+ let config: SharedConfig | null = null;
65
+ let cache: CacheEntry | null = null;
66
+ let inflight: Promise<SecretsAccessor<SecretRegistry>> | null = null;
67
+
68
+ /**
69
+ * Configure the shared accessor with the combined registry and TTL. Called once at worker startup
70
+ * (the secrets capability's `compose` hook, or a standalone worker's module scope). Resets any
71
+ * cached accessor so the next access re-resolves against the new configuration.
72
+ */
73
+ export function configureSharedSecrets(options: ConfigureSharedSecretsOptions): void {
74
+ const ttlSeconds = options.ttlSeconds ?? DEFAULT_SECRETS_CACHE_TTL_SECONDS;
75
+ config = {
76
+ registry: options.registry,
77
+ ttlMs: ttlSeconds * 1000,
78
+ resolve: options.resolve ?? ((env, registry) => secretsStore(env, registry)),
79
+ now: options.now ?? Date.now,
80
+ };
81
+ cache = null;
82
+ inflight = null;
83
+ }
84
+
85
+ /** Clear all shared state — configuration and cache. For test isolation between cases. */
86
+ export function resetSharedSecrets(): void {
87
+ config = null;
88
+ cache = null;
89
+ inflight = null;
90
+ }
91
+
92
+ function requireConfig(): SharedConfig {
93
+ if (!config) {
94
+ throw new InternalError({
95
+ message: "The shared secrets accessor is not configured.",
96
+ detail: "configureSharedSecrets was never called — compose the `secrets` capability, or configure it directly.",
97
+ });
98
+ }
99
+ return config;
100
+ }
101
+
102
+ /** Resolve the combined accessor, honoring the TTL cache and de-duplicating a concurrent first fetch. */
103
+ async function resolveCombined(env: SecretsStoreEnv): Promise<SecretsAccessor<SecretRegistry>> {
104
+ const cfg = requireConfig();
105
+ if (cache && cfg.now() < cache.expiresAt) return cache.accessor;
106
+ // A concurrent access during the fetch shares the one in-flight resolution, so the combined
107
+ // registry is fetched once even when several capabilities read secrets in the same invocation.
108
+ if (inflight) return inflight;
109
+ inflight = cfg
110
+ .resolve(env, cfg.registry)
111
+ .then((accessor) => {
112
+ cache = { accessor, expiresAt: cfg.now() + cfg.ttlMs };
113
+ return accessor;
114
+ })
115
+ .finally(() => {
116
+ inflight = null;
117
+ });
118
+ return inflight;
119
+ }
120
+
121
+ /**
122
+ * The shared, per-invocation accessor for `registry` — the calling capability's own slice. Resolves
123
+ * the combined registry once (cached for the TTL) and returns a precisely-typed view over `registry`.
124
+ * Every name in `registry` must be part of the configured combined registry; a name that is not is a
125
+ * wiring bug (the capability did not declare its slice on `secretRegistry`) and throws at the call.
126
+ */
127
+ export async function sharedSecretsStore<R extends SecretRegistry>(
128
+ env: SecretsStoreEnv,
129
+ registry: R,
130
+ ): Promise<SecretsAccessor<R>> {
131
+ const cfg = requireConfig();
132
+ for (const name of Object.keys(registry)) {
133
+ if (!(name in cfg.registry)) {
134
+ throw new InternalError({
135
+ message: `Secret "${name}" is not in the aggregated registry.`,
136
+ detail: `'${name}' was requested but no capability declared it on its secretRegistry slice`,
137
+ action: "Declare the secret on the reading capability's `secretRegistry`.",
138
+ });
139
+ }
140
+ }
141
+ const combined = await resolveCombined(env);
142
+ // The combined accessor is cached across requests; a keyspace read or write is not cached at all,
143
+ // and runs real I/O. Bind it to *this* invocation's env so a member is never fetched — or sealed —
144
+ // through the binding of whichever earlier request happened to fill the cache.
145
+ return combined.subset(registry, d1KeyedIO(env));
146
+ }
147
+
148
+ /**
149
+ * Merge every capability's {@link Capability.secretRegistry} slice into one combined registry — the
150
+ * source of truth the shared accessor resolves. A secret name declared by more than one capability is
151
+ * allowed only when the declarations agree on every axis (`backend`, `scope`, `valueType`,
152
+ * `rotatable`, `keyed`); a divergent re-declaration is an author conflict and throws. The `secretsStore`
153
+ * reader keys purely on the name, so identical re-declarations resolve the same stored value.
154
+ */
155
+ export function aggregateSecretRegistries(capabilities: readonly Capability[]): SecretRegistry {
156
+ const combined: Record<string, SecretRegistryEntry> = {};
157
+ const owners: Record<string, string> = {};
158
+ for (const cap of capabilities) {
159
+ // The Capability contract carries the slice as the loose `SecretRegistrySeam`; the concrete
160
+ // entries are always built by `defineSecretRegistry`, so this narrowing is sound at the seam.
161
+ const slice = cap.secretRegistry as SecretRegistry | undefined;
162
+ if (!slice) continue;
163
+ for (const [name, entry] of Object.entries(slice)) {
164
+ const existing = combined[name];
165
+ if (existing) {
166
+ if (
167
+ existing.backend !== entry.backend ||
168
+ existing.scope !== entry.scope ||
169
+ existing.valueType !== entry.valueType ||
170
+ existing.rotatable !== entry.rotatable ||
171
+ // A keyspace and a name are not the same secret, whatever else agrees: one resolves
172
+ // `<name>/<key>`, the other `<name>`.
173
+ Boolean(existing.keyed) !== Boolean(entry.keyed)
174
+ ) {
175
+ throw new InternalError({
176
+ message: `Secret "${name}" is declared incompatibly by capabilities "${owners[name]}" and "${cap.name}".`,
177
+ action: "Declare the same backend, scope, valueType, rotatable, and keyed for a shared secret name.",
178
+ });
179
+ }
180
+ continue;
181
+ }
182
+ combined[name] = entry;
183
+ owners[name] = cap.name;
184
+ }
185
+ }
186
+ return combined;
187
+ }
@@ -0,0 +1,189 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
6
+ import { createDatabase, type DatabaseSchema } from "@pithy-sh/core/src/data/db";
7
+ import type { Kysely } from "kysely";
8
+ import type { RotationStatus, RotationTrigger } from "../data/secretRotations";
9
+ import { type SecretsTables, secretsTables } from "../data/tables";
10
+ import {
11
+ type OpenRotation,
12
+ type RotationFailureCode,
13
+ type RotationLedger,
14
+ rotationClosure,
15
+ rotationFailureText,
16
+ } from "../rotation/rotationLedger";
17
+ import type { ManagedEnvironment } from "../scope";
18
+
19
+ type SecretsDb = Kysely<DatabaseSchema<SecretsTables>>;
20
+
21
+ /**
22
+ * Append-only tracker for rotation attempts, over the per-environment secrets D1's
23
+ * `pithy_secrets_rotations` table. Ported from the CMS `RotationTracker`, scoped to one
24
+ * environment (the per-env manager owns one store).
25
+ *
26
+ * `startRotation` opens an `in_progress` row and returns its id; `markSuccess`/`markFailure`
27
+ * close it. `recordBaseline` seeds a `success`/`baseline` row when a rotatable secret is first
28
+ * written, so the cadence check never flags a brand-new secret as overdue. `purgeHistory` clears
29
+ * a deleted secret's rows so they don't linger.
30
+ */
31
+ export class RotationTracker {
32
+ readonly #db: SecretsDb;
33
+
34
+ constructor(db: SecretsDb) {
35
+ this.#db = db;
36
+ }
37
+
38
+ /** Build a tracker over a raw `SECRETS` D1 binding. */
39
+ static fromD1(d1: D1Database): RotationTracker {
40
+ return new RotationTracker(createDatabase(d1, secretsTables));
41
+ }
42
+
43
+ /** Open an `in_progress` rotation row and return its id. */
44
+ async startRotation(
45
+ name: string,
46
+ trigger: RotationTrigger,
47
+ rotatedBy: string,
48
+ metadataSnapshot?: unknown,
49
+ ): Promise<number> {
50
+ const inserted = await this.#db
51
+ .insertInto("pithySecretsRotations")
52
+ .values({
53
+ name,
54
+ startedAt: SQLiteDate.encode(new Date()),
55
+ completedAt: null,
56
+ status: "in_progress" satisfies RotationStatus,
57
+ trigger,
58
+ rotatedBy,
59
+ errorMessage: null,
60
+ metadataSnapshot: metadataSnapshot === undefined ? null : JSON.stringify(metadataSnapshot),
61
+ })
62
+ .returning("id")
63
+ .executeTakeFirstOrThrow();
64
+ return inserted.id;
65
+ }
66
+
67
+ /** Close a rotation row as `success`. */
68
+ async markSuccess(rotationId: number): Promise<void> {
69
+ await this.#db
70
+ .updateTable("pithySecretsRotations")
71
+ .set({ status: "success" satisfies RotationStatus, completedAt: SQLiteDate.encode(new Date()) })
72
+ .where("id", "=", rotationId)
73
+ .execute();
74
+ }
75
+
76
+ /**
77
+ * Close a rotation row as `failed`, under a code that names the failure.
78
+ *
79
+ * **It takes a code and not a sentence, and that is the whole of `#386`.** `error_message` is the one
80
+ * column on this table a failure site writes, `rotationLedger.ts` states that its text is fixed and
81
+ * chosen by a code, and the at-rest rotation path composed it from `cause.message` anyway — from a catch
82
+ * reached by decryption, envelope decoding and config parsing, which are the paths whose exception text
83
+ * can carry key material. Four files already refuse to publish this column; that refusal is defense in
84
+ * depth and was never the invariant. The invariant is that there is nothing here to publish.
85
+ *
86
+ * A comment asking for a code would have been the same comment that was already there. So the signature
87
+ * asks: {@link RotationFailureCode} is a closed union, `rotationFailureText` maps it here rather than at
88
+ * the call site, and a caller holding an exception has nowhere to put it. The exception is still raised,
89
+ * and its context still travels in a `PithyError`'s `detail`, which the HTTP codec strips.
90
+ */
91
+ async markFailure(rotationId: number, code: RotationFailureCode): Promise<void> {
92
+ await this.#db
93
+ .updateTable("pithySecretsRotations")
94
+ .set({
95
+ status: "failed" satisfies RotationStatus,
96
+ completedAt: SQLiteDate.encode(new Date()),
97
+ errorMessage: rotationFailureText(code),
98
+ })
99
+ .where("id", "=", rotationId)
100
+ .execute();
101
+ }
102
+
103
+ /**
104
+ * Seed a `success`/`baseline` row so a brand-new rotatable secret is not flagged overdue.
105
+ *
106
+ * **A first write, and it stays that.** `trigger: "baseline"` is what distinguishes establishing a value
107
+ * from replacing one — a rotation writes `manual` or `cron` through {@link trackerRotationLedger} and
108
+ * carries an actor. Widening this to cover updates would let a typo fix advance a freshness clock
109
+ * nobody rotated; see `../rotation/rotationLedger.ts`.
110
+ */
111
+ async recordBaseline(name: string): Promise<void> {
112
+ const now = SQLiteDate.encode(new Date());
113
+ await this.#db
114
+ .insertInto("pithySecretsRotations")
115
+ .values({
116
+ name,
117
+ startedAt: now,
118
+ completedAt: now,
119
+ status: "success" satisfies RotationStatus,
120
+ trigger: "baseline" satisfies RotationTrigger,
121
+ rotatedBy: "baseline",
122
+ errorMessage: null,
123
+ metadataSnapshot: null,
124
+ })
125
+ .execute();
126
+ }
127
+
128
+ /** The most recent successful completion for a name, or `null` if it has never succeeded. */
129
+ async getLatestSuccess(name: string): Promise<Date | null> {
130
+ const row = await this.#db
131
+ .selectFrom("pithySecretsRotations")
132
+ .select("completedAt")
133
+ .where("name", "=", name)
134
+ .where("status", "=", "success")
135
+ .where("completedAt", "is not", null)
136
+ .orderBy("completedAt", "desc")
137
+ .limit(1)
138
+ .executeTakeFirst();
139
+ if (!row || row.completedAt == null) return null;
140
+ return new Date(row.completedAt as number);
141
+ }
142
+
143
+ /** Remove all rotation rows for a secret (called on delete). Returns the count removed. */
144
+ async purgeHistory(name: string): Promise<number> {
145
+ const before = await this.#db
146
+ .selectFrom("pithySecretsRotations")
147
+ .select((eb) => eb.fn.countAll<number>().as("count"))
148
+ .where("name", "=", name)
149
+ .executeTakeFirst();
150
+ await this.#db.deleteFrom("pithySecretsRotations").where("name", "=", name).execute();
151
+ return Number(before?.count ?? 0);
152
+ }
153
+ }
154
+
155
+ /** What {@link trackerRotationLedger} needs beyond the tracker: which environment it is, and who is asking. */
156
+ export interface TrackerRotationLedgerOptions {
157
+ /** The environment this D1 belongs to. Decides how the row closes — see `rotationClosure`. */
158
+ environment: ManagedEnvironment;
159
+ /** What caused the rotation: an operator (`manual`) or the manager's own schedule (`cron`). Never `baseline`. */
160
+ trigger: Exclude<RotationTrigger, "baseline">;
161
+ /** Who asked. A verified control-plane subject in a Worker, a workflow instance id for a scheduled run. */
162
+ rotatedBy: string;
163
+ }
164
+
165
+ /**
166
+ * The in-Worker {@link RotationLedger}: the rotation table this process already holds a handle to.
167
+ *
168
+ * The direct half of the seam. Anything running *inside* an environment — a control-plane rotate route, the
169
+ * manager's own cron — records through this; a process outside one records the identical rows through
170
+ * `../cli/rotationLedger.ts`, over a dispatch. Both compose the closing verdict with `rotationClosure` and
171
+ * the failure sentence with `rotationFailureText`, which is what stops the two paths from disagreeing about
172
+ * whether a rotation happened (`#379`).
173
+ */
174
+ export function trackerRotationLedger(tracker: RotationTracker, options: TrackerRotationLedgerOptions): RotationLedger {
175
+ return {
176
+ async open(name: string): Promise<OpenRotation> {
177
+ const rotationId = await tracker.startRotation(name, options.trigger, options.rotatedBy);
178
+ return {
179
+ async close(outcome): Promise<void> {
180
+ const closure = rotationClosure(outcome, options.environment);
181
+ if (closure.status === "success") await tracker.markSuccess(rotationId);
182
+ // The reason, not its sentence. Every reason is a `RotationFailureCode`, and the tracker renders
183
+ // it — one place composes the text, on both sides of the seam (`#386`).
184
+ else await tracker.markFailure(rotationId, closure.reason);
185
+ },
186
+ };
187
+ },
188
+ };
189
+ }
@@ -0,0 +1,223 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { chunkByBoundParameters } from "@pithy-sh/core/src/data/boundParameters";
5
+ import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
6
+ import { createDatabase, type DatabaseSchema } from "@pithy-sh/core/src/data/db";
7
+ import type { Kysely } from "kysely";
8
+ import { decryptValue, type EncryptionConfig, encryptValue } from "../crypto/envelope";
9
+ import { decodeVersionedValue, encodeVersionedValue, type VersionedValue } from "../crypto/versionedValue";
10
+ import { type SecretsTables, secretsTables } from "../data/tables";
11
+ import { resolveEncryptionConfig, type SecretsStoreEnv } from "../env/bindings";
12
+ import { SecretCryptoError } from "../error/errors";
13
+ import type { SecretValueType } from "../registry";
14
+
15
+ /** The Kysely instance the store queries — typed over the secrets tables, CamelCasePlugin installed. */
16
+ type SecretsDb = Kysely<DatabaseSchema<SecretsTables>>;
17
+
18
+ /**
19
+ * One stored secret as a read found it: its envelope, or that the row is there and would not open (#384).
20
+ *
21
+ * **The state rides on the value**, so a caller cannot reach a plaintext without narrowing and forgetting
22
+ * the unreadable case is a compile error rather than a silent empty. That is the shape `#350`, `#371` and
23
+ * `#380` settled, and it is here for the reason it was there: this is a *batch* read, so an unreadable row
24
+ * that throws is an unreadable row that costs every other name in the call. `#170` promised that a failure
25
+ * belongs to its secret; it was true of a **missing** row and not of an **unreadable** one, which is a
26
+ * narrower guarantee than the sentence reads.
27
+ *
28
+ * **Absent is a third fact and it is not in this union.** A name with no row is simply not a key in the
29
+ * record `getValues` returns. Missing and unreadable are different faults with different remedies —
30
+ * provision it, versus investigate the row or re-seal it — and folding either into the other loses the
31
+ * only thing the operator needed.
32
+ *
33
+ * `unreadable` carries nothing. There is nothing safe to put on it: what the decrypt threw names a key
34
+ * version and a ciphertext it could not open, and the row's own name is already the key this value is
35
+ * filed under.
36
+ */
37
+ export type StoredSecretValue =
38
+ | {
39
+ /** The row opened and its plaintext parsed as a value envelope. */
40
+ state: "readable";
41
+ /** The decrypted `{ currentVersion, versions }` envelope. */
42
+ value: VersionedValue;
43
+ }
44
+ | {
45
+ /** The row is stored and did not open — a corrupt ciphertext, or a key version no longer held. */
46
+ state: "unreadable";
47
+ };
48
+
49
+ /**
50
+ * What a stored secret that will not open is told — the sibling of `secretsStore`'s `unprovisioned`, and
51
+ * deliberately a different sentence with a different code.
52
+ *
53
+ * Constructed here rather than re-thrown from the decrypt, because nothing derived from that failure may
54
+ * travel: the guard's `catch` takes no binding (`#350`), so there is no error object in scope to attach as
55
+ * `cause` and no text to fold into `detail`. What survives is the name — never a ciphertext, an IV, or a
56
+ * key version.
57
+ *
58
+ * **Only for a name a registry declares.** `message` crosses the HTTP boundary, so the name in it must be
59
+ * one an operator wrote in a registry and not one a caller supplied. A keyspace member's stored name embeds
60
+ * a tenant key; {@link SystemSecretsStore.getValue} keeps that out of `message` for exactly that reason.
61
+ */
62
+ export function unreadableSecret(name: string): SecretCryptoError {
63
+ return new SecretCryptoError({
64
+ message: `Secret '${name}' is stored and could not be read.`,
65
+ action:
66
+ "Check SECRETS_ENCRYPTION_KEYS still holds the key version this row was sealed under. If it does not, re-seal the secret with `pithy secrets update`.",
67
+ detail: `d1 secret '${name}' has a row that did not decrypt`,
68
+ });
69
+ }
70
+
71
+ /**
72
+ * Decrypt and parse one row, or answer that it would not open (#384).
73
+ *
74
+ * **The `catch` takes no binding, and that is the point rather than a tidiness.** A decryption failure's
75
+ * own text names the key version it tried and the context it tried under; `decodeVersionedValue`'s names
76
+ * the shape it found in a plaintext. Neither may reach a log, a response, or a held error, and a `catch`
77
+ * with nothing bound makes that impossible to get wrong later rather than merely absent today.
78
+ *
79
+ * Both halves are inside it. A ciphertext that opens to something that is not an envelope is as unreadable
80
+ * as one that does not open at all, and it arrives by the same routes.
81
+ */
82
+ async function readRow(
83
+ config: EncryptionConfig,
84
+ row: { name: string; encryptedValue: string; iv: string; keyVersion: number },
85
+ ): Promise<StoredSecretValue> {
86
+ try {
87
+ // The row's own name is the bound context: a ciphertext moved to another row does not open.
88
+ return { state: "readable", value: decodeVersionedValue(await decryptValue(config, row.name, row)) };
89
+ } catch {
90
+ return { state: "unreadable" };
91
+ }
92
+ }
93
+
94
+ /**
95
+ * The D1-backed encrypted store for `d1`-backed secrets, ported from the CMS `SystemSecretsStore`
96
+ * with Pithy's universal value envelope layered on. Every secret's plaintext is a
97
+ * `{ currentVersion, versions }` envelope (`crypto/versionedValue`), sealed in one AES-256-GCM
98
+ * envelope (`crypto/envelope`) and persisted as one `pithy_secrets_system_secrets` row. The master
99
+ * key is held only in memory, resolved once from the worker-only `SECRETS_ENCRYPTION_KEYS` binding.
100
+ *
101
+ * The store is the low-level read/write primitive: it persists and returns value envelopes. The
102
+ * create/update/rotate *semantics* (which envelope to write) live above it — in the manager
103
+ * Workflow and the CLI. Construct per request so a `SECRETS_ENCRYPTION_KEYS` rotation is picked up.
104
+ */
105
+ export class SystemSecretsStore {
106
+ readonly #db: SecretsDb;
107
+ readonly #config: EncryptionConfig;
108
+
109
+ constructor(db: SecretsDb, config: EncryptionConfig) {
110
+ this.#db = db;
111
+ this.#config = config;
112
+ }
113
+
114
+ /** Build a store from the worker env: its `SECRETS` D1 and resolved master-key config. */
115
+ static async fromEnv(env: SecretsStoreEnv): Promise<SystemSecretsStore> {
116
+ const config = await resolveEncryptionConfig(env);
117
+ return new SystemSecretsStore(createDatabase(env.SECRETS, secretsTables), config);
118
+ }
119
+
120
+ /**
121
+ * Read every requested name that exists, each row's outcome on its own value (absent names omitted).
122
+ *
123
+ * The name list is the *application's* size, not a query's: `secretsStore` hands this every D1-backed
124
+ * secret the registry declares, in one call, at boot. So it is chunked against D1's cap rather than
125
+ * assumed to fit. Unchunked, an app declaring 101 of them read none of them, and because every
126
+ * capability's secrets resolve through this one call, that is the whole Worker failing to start over a
127
+ * limit nothing in a registry mentions (#250).
128
+ *
129
+ * **And for the same reason a row that will not open costs only itself (#384).** The decrypt used to run
130
+ * in a bare loop, so one corrupt ciphertext — or one `keyVersion` orphaned by a master-key rotation —
131
+ * threw out of the batch, and the caller above held that against *every* `d1` name in the read. An
132
+ * unreadable `auth-github-credentials` therefore took `auth-session-secret` down with it, which is how
133
+ * one unreadable OAuth credential ended every form of sign-in (#381). Each row is guarded now and lands
134
+ * as a {@link StoredSecretValue} against its own name, which is what #170 promised.
135
+ */
136
+ async getValues(names: string[]): Promise<Record<string, StoredSecretValue>> {
137
+ if (names.length === 0) return {};
138
+ const out: Record<string, StoredSecretValue> = {};
139
+ for (const chunk of chunkByBoundParameters(names, 0)) {
140
+ const rows = await this.#db
141
+ .selectFrom("pithySecretsSystemSecrets")
142
+ .select(["name", "encryptedValue", "iv", "keyVersion"])
143
+ .where("name", "in", chunk)
144
+ .execute();
145
+
146
+ for (const row of rows) out[row.name] = await readRow(this.#config, row);
147
+ }
148
+ return out;
149
+ }
150
+
151
+ /**
152
+ * The value envelope for one secret, or `undefined` if it is not stored. A stored row that will not open
153
+ * throws `secrets/crypto_failed`.
154
+ *
155
+ * **The union collapses here, and only here, because a batch of one has nothing to protect.** The state
156
+ * rides on the value in {@link getValues} so that one bad row cannot cost its neighbors; asking for a
157
+ * single name there are no neighbors, and every caller of this one — a keyspace member read, a rotate's
158
+ * baseline, the dev seeder — wants the value or an exception. What it must *not* do is answer
159
+ * `undefined`, which means "nothing is stored" and sends the reader to provision a row that is already
160
+ * there.
161
+ *
162
+ * **The name goes in `detail` and never in `message`, which is why this is not {@link unreadableSecret}.**
163
+ * That one is handed a registry literal. This one is handed a *stored* name, and for a keyspace member
164
+ * that is the whole `<keyspace>/<key>` — a tenant identifier, supplied by a caller. `message` crosses the
165
+ * HTTP boundary and `detail` is stripped there, so a sentence naming the secret would answer "which of
166
+ * your tenants has a broken credential" to whoever could provoke it.
167
+ */
168
+ async getValue(name: string): Promise<VersionedValue | undefined> {
169
+ const stored = (await this.getValues([name]))[name];
170
+ if (!stored) return undefined;
171
+ if (stored.state === "unreadable") {
172
+ throw new SecretCryptoError({ detail: `stored secret '${name}' has a row that did not decrypt` });
173
+ }
174
+ return stored.value;
175
+ }
176
+
177
+ /** Whether a secret with this name is stored. */
178
+ async has(name: string): Promise<boolean> {
179
+ const row = await this.#db
180
+ .selectFrom("pithySecretsSystemSecrets")
181
+ .select("name")
182
+ .where("name", "=", name)
183
+ .executeTakeFirst();
184
+ return row !== undefined;
185
+ }
186
+
187
+ /**
188
+ * Encrypt `value` under the current key version and upsert the row. Inserts on first write,
189
+ * updates the envelope in place otherwise. `name` is sealed into the ciphertext as authenticated
190
+ * data (`crypto/envelope`), so a rename has to go through here and not through SQL: an
191
+ * `UPDATE ... SET name` leaves a row nothing can open. The caller owns the envelope's shape —
192
+ * `initialVersionedValue` for a create, an edited envelope for an update, `appendVersion` for a
193
+ * value rotation.
194
+ */
195
+ async put(name: string, value: VersionedValue, valueType: SecretValueType = "text"): Promise<void> {
196
+ const { encryptedValue, iv, keyVersion } = await encryptValue(this.#config, name, encodeVersionedValue(value));
197
+ const now = SQLiteDate.encode(new Date());
198
+
199
+ if (await this.has(name)) {
200
+ await this.#db
201
+ .updateTable("pithySecretsSystemSecrets")
202
+ .set({ encryptedValue, iv, keyVersion, valueType, updatedAt: now })
203
+ .where("name", "=", name)
204
+ .execute();
205
+ return;
206
+ }
207
+ await this.#db
208
+ .insertInto("pithySecretsSystemSecrets")
209
+ .values({ name, encryptedValue, iv, keyVersion, valueType, createdAt: now, updatedAt: now })
210
+ .execute();
211
+ }
212
+
213
+ /** Remove a secret. A no-op if it does not exist. */
214
+ async delete(name: string): Promise<void> {
215
+ await this.#db.deleteFrom("pithySecretsSystemSecrets").where("name", "=", name).execute();
216
+ }
217
+
218
+ /** Every stored secret name, sorted — the metadata `ls` reads without touching values. */
219
+ async listNames(): Promise<string[]> {
220
+ const rows = await this.#db.selectFrom("pithySecretsSystemSecrets").select("name").orderBy("name").execute();
221
+ return rows.map((row) => row.name);
222
+ }
223
+ }
@@ -0,0 +1,30 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { EncryptionConfig } from "../crypto/envelope";
5
+
6
+ /**
7
+ * A fresh AES-256 master-key config for a test run, as the string a `SECRETS_ENCRYPTION_KEYS` binding
8
+ * carries — the same shape `.dev.vars` supplies in local dev. Put it on a package's Miniflare
9
+ * `bindings` and the worker resolves its encryption config from it, so encrypt, decrypt, and at-rest
10
+ * rotation are all exercisable locally with no live Cloudflare Secrets Store.
11
+ *
12
+ * **Its own module, with one type-only import and nothing else, and that is load-bearing.** A
13
+ * `vitest.workers.config.ts` is loaded by vite through Node's own ESM resolver, which cannot follow
14
+ * the extensionless specifiers this repository's TypeScript sources use — so a config importing
15
+ * anything with a runtime dependency fails before a single test runs. The type import is erased.
16
+ * Keep this file free of runtime imports, or every workers config that reads it stops loading.
17
+ *
18
+ * Generated per call, and never reused as anything but a test key.
19
+ */
20
+ export function devEncryptionKeys(): string {
21
+ const key = crypto.getRandomValues(new Uint8Array(32));
22
+ let binary = "";
23
+ for (const byte of key) binary += String.fromCharCode(byte);
24
+ const config: EncryptionConfig = {
25
+ currentVersion: "1",
26
+ versions: { "1": btoa(binary) },
27
+ lastRotatedAt: "2026-01-01T00:00:00.000Z",
28
+ };
29
+ return JSON.stringify(config);
30
+ }