@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,178 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { createDatabase } from "@pithy-sh/core/src/data/db";
5
+ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
6
+ import type { Kysely } from "kysely";
7
+ import { initialVersionedValue } from "../crypto/versionedValue";
8
+ import { secretsTables } from "../data/tables";
9
+ import type { SecretsStoreEnv } from "../env/bindings";
10
+ import { secrets_0001_init } from "../migrations/0001_init";
11
+ import type { SecretName, SecretRegistry, SecretRegistryEntry, SecretValue } from "../registry";
12
+ import { SecretsAccessor } from "../secretsStore";
13
+ import { configureSharedSecrets } from "../sharedSecretsStore";
14
+ import { SystemSecretsStore } from "../store/systemSecretsStore";
15
+
16
+ /**
17
+ * How a test gives a capability a secret. One harness, so no capability invents a fixture shape of
18
+ * its own.
19
+ *
20
+ * There was nothing to share before #153, because nothing was needed. Dev resolved **every** secret
21
+ * from a plaintext binding whatever its registry said, so a test put `{"auth-session-secret": "x"}`
22
+ * on the worker env and the reader took it. Four packages independently learned that trick, and it
23
+ * was never a fixture: it was a product code path, bent. That path is gone — a `d1` secret is read
24
+ * from its encrypted row in every environment now — and a bare string on the env is a
25
+ * `ValidationError` naming the secret. These two functions are what replaces it.
26
+ *
27
+ * **Which one you use is decided by the runtime, not by taste.**
28
+ *
29
+ * - `*.workers.test.ts` → {@link seedSecrets}. There is a real D1 there, so write the real row and
30
+ * let the code under test read it through `secretsStore` exactly as a deployed worker does. Same
31
+ * value envelope, same AES-256-GCM envelope, same master-key resolution, same schema validation at
32
+ * the read. Nothing about the secrets pipeline is faked, which is the point of running in workerd.
33
+ * - `*.test.ts` (Node) → {@link stubSecrets}. There is no D1 in the Node project and
34
+ * `SystemSecretsStore` needs one. So the resolved accessor is built in memory and installed at
35
+ * `configureSharedSecrets`'s `resolve` seam — a seam that exists to be replaced, rather than a
36
+ * product path bent into a fixture again.
37
+ *
38
+ * Both take the capability's own registry and a partial map of name → value, and both hold the value
39
+ * to that registry: a `json` secret's fixture is checked against the entry's schema before it is
40
+ * stored or resolved, so a fixture that production would reject fails here, naming the secret,
41
+ * rather than three layers down at a read.
42
+ *
43
+ * The master key a seeding suite needs is `devEncryptionKeys` — in its own module, because a vitest
44
+ * config cannot import one with runtime dependencies. See the note there.
45
+ */
46
+
47
+ /** A test's values for a registry: secret name → its value, in the shape `get(name)` returns. */
48
+ export type SecretFixture<R extends SecretRegistry> = {
49
+ [K in SecretName<R>]?: SecretValue<R[K]>;
50
+ };
51
+
52
+ /**
53
+ * Write each named value into `env`'s `SECRETS` D1 as the encrypted row the reader reads — the
54
+ * Workers-runtime idiom.
55
+ *
56
+ * Every value lands as version `"1"` of a fresh envelope, which is what a `pithy secrets create` or a
57
+ * `pithy dev` seed writes, and the `kid` a token minted in a test will carry. Call it again for the
58
+ * same name to overwrite: `put` upserts, so a case that wants different credentials than its suite's
59
+ * default just seeds over them before the request.
60
+ *
61
+ * The secrets tables are created on first use, so a suite needs no migration step of its own — the
62
+ * store is a fixture here, not the subject.
63
+ */
64
+ export async function seedSecrets<R extends SecretRegistry>(
65
+ env: SecretsStoreEnv,
66
+ registry: R,
67
+ fixture: SecretFixture<R>,
68
+ ): Promise<void> {
69
+ await ensureSecretsTables(env);
70
+ const store = await SystemSecretsStore.fromEnv(env);
71
+ for (const [name, value] of Object.entries(fixture)) {
72
+ if (value === undefined) continue;
73
+ const entry = declared(registry, name);
74
+ const stored = entry.valueType === "text" ? String(value) : JSON.stringify(parsed(entry, name, value));
75
+ await store.put(name, initialVersionedValue(stored), entry.valueType);
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Install `fixture` as the shared per-invocation accessor's resolution of `registry` — the
81
+ * Node-runtime idiom, where there is no D1 to seed.
82
+ *
83
+ * Values are held already parsed, exactly as {@link SecretsAccessor} holds them after a real read:
84
+ * the object for a `json` secret, the string for a `text` one. Each is one version, `"1"`, current.
85
+ *
86
+ * A name the fixture omits stays **unresolved** rather than becoming `undefined` — so reading it
87
+ * throws `secrets/not_found`, which is how a test says "this secret is declared but not provisioned"
88
+ * without reaching past the accessor.
89
+ *
90
+ * Call `resetSharedSecrets()` in `afterEach`; the configuration is module-scoped, as it is in a
91
+ * worker isolate.
92
+ */
93
+ export function stubSecrets<R extends SecretRegistry>(registry: R, fixture: SecretFixture<R>): void {
94
+ const accessor = stubSecretsAccessor(registry, fixture);
95
+ configureSharedSecrets({ registry, resolve: async () => accessor });
96
+ }
97
+
98
+ /**
99
+ * The accessor {@link stubSecrets} installs, on its own — for a test that calls a resolver directly
100
+ * instead of going through the shared store.
101
+ */
102
+ export function stubSecretsAccessor<R extends SecretRegistry>(
103
+ registry: R,
104
+ fixture: SecretFixture<R>,
105
+ ): SecretsAccessor<R> {
106
+ const resolved: Record<string, { current: unknown; currentVersion: string; versions: Record<string, unknown> }> = {};
107
+ for (const [name, value] of Object.entries(fixture)) {
108
+ if (value === undefined) continue;
109
+ const entry = declared(registry, name);
110
+ const current = parsed(entry, name, value);
111
+ resolved[name] = { current, currentVersion: "1", versions: { "1": current } };
112
+ }
113
+ return new SecretsAccessor(registry, resolved);
114
+ }
115
+
116
+ /** The registry entry a fixture names, or a loud author error — a fixture for an undeclared secret is a typo. */
117
+ function declared(registry: SecretRegistry, name: string): SecretRegistryEntry {
118
+ const entry = registry[name];
119
+ if (!entry) {
120
+ throw new InternalError({
121
+ message: `Test fixture names '${name}', which this registry does not declare.`,
122
+ detail: `secret fixture '${name}' is absent from the registry passed to the harness`,
123
+ });
124
+ }
125
+ if (entry.keyed) {
126
+ throw new InternalError({
127
+ message: `Secret '${name}' is a keyspace, so it has no single value to seed.`,
128
+ action: "Write a keyspace member the way the app does: secrets.putKeyed(name, key, value).",
129
+ detail: `secret fixture '${name}' names a keyed entry`,
130
+ });
131
+ }
132
+ return entry;
133
+ }
134
+
135
+ /**
136
+ * A fixture value in the shape the accessor exposes: a `text` value passes through, a `json` value is
137
+ * validated against the entry's own schema first.
138
+ *
139
+ * The check is here rather than left to the read because a fixture production would reject should
140
+ * fail at the line that wrote it. Only issue paths and codes reach the message — the same rule the
141
+ * reader follows, since a Zod issue can echo the value.
142
+ */
143
+ function parsed(entry: SecretRegistryEntry, name: string, value: unknown): unknown {
144
+ if (entry.valueType === "text") return value;
145
+ const result = entry.schema.safeParse(value);
146
+ if (!result.success) {
147
+ const summary = result.error.issues.map((i) => `${i.path.join(".") || "<root>"}:${i.code}`).join(", ");
148
+ throw new InternalError({
149
+ message: `Test fixture for secret '${name}' does not satisfy its registry schema.`,
150
+ detail: `secret fixture '${name}' failed registry validation: ${summary}`,
151
+ });
152
+ }
153
+ return result.data;
154
+ }
155
+
156
+ /**
157
+ * Create the secrets tables if this D1 does not have them yet, so seeding is a single call and a suite
158
+ * needs no migration step of its own.
159
+ *
160
+ * The migration is the real one, run unmodified — never a hand-rolled `create table if not exists` —
161
+ * so a schema change reaches every suite that seeds without any of them being edited. Presence is
162
+ * probed with a query rather than by introspection because D1 refuses to read `sqlite_master`
163
+ * (`SQLITE_AUTH`), which is what Kysely's SQLite introspector is built on.
164
+ */
165
+ async function ensureSecretsTables(env: SecretsStoreEnv): Promise<void> {
166
+ const db = createDatabase(env.SECRETS, secretsTables);
167
+ const present = await db
168
+ .selectFrom("pithySecretsSystemSecrets")
169
+ .select("name")
170
+ .limit(1)
171
+ .executeTakeFirst()
172
+ .then(
173
+ () => true,
174
+ () => false,
175
+ );
176
+ if (present) return;
177
+ await secrets_0001_init.up(db as unknown as Kysely<unknown>);
178
+ }
@@ -0,0 +1,42 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The compile-time half of "a secrets read must not be able to disclose a value".
6
+ *
7
+ * Two shapes make that promise — the reader shapes in `admin/status.ts` and the wire shapes in
8
+ * `http/responses.ts` — and both keep it the same way: by being **incapable** of carrying a value
9
+ * rather than by omitting one. `admin/status.ts` §The one constraint everything here is arranged around
10
+ * makes the argument in full; this module is only the type it turns on.
11
+ *
12
+ * ## Why it is not declared in either of them
13
+ *
14
+ * It was `admin/status.ts`'s, and `http/responses.ts` imported it from there — a type-only import, so
15
+ * nothing was in the bundle and nothing was wrong at runtime. It was still wrong for the compiler.
16
+ * `admin/status.ts` is a Kysely reader: it names `SecretsStatusDb`, reaches `@pithy-sh/core/src/data/db`,
17
+ * and through it `D1Database`. A response schema is a module a browser imports, so naming a *type* in
18
+ * the reader put the whole D1 layer in the browser program's file set — one bare Workers global away
19
+ * from the compile error `@pithy-sh/support` actually shipped (#419). Type-only or not, an edge the
20
+ * compiler follows is an edge.
21
+ *
22
+ * So the type lives where neither half owns it, and imports nothing. (Jim, 2026-08-21.)
23
+ *
24
+ * `tooling/browser-scopes` holds the rule: a module a browser may import reaches no module that needs
25
+ * the Workers runtime.
26
+ */
27
+
28
+ /**
29
+ * Field names that would carry a secret's value, the envelope around it, or free text written where a
30
+ * value is in scope. Named as a type so the tripwires that use it are a list somebody has to delete
31
+ * from rather than a rule somebody has to remember.
32
+ */
33
+ type ValueBearing = "encryptedValue" | "iv" | "value" | "plaintext" | "metadataSnapshot" | "errorMessage";
34
+
35
+ /**
36
+ * `true` when `T` names none of {@link ValueBearing}, and `never` when it names any — so an assignment
37
+ * of `true` to this type stops compiling the moment a shape is widened.
38
+ *
39
+ * The tuple wrapper is not decoration: a bare `Extract<...> extends never` on a union would distribute
40
+ * and answer `true` for every member, which is the one way this check could quietly pass.
41
+ */
42
+ export type CarriesNoValue<T> = [Extract<keyof T, ValueBearing>] extends [never] ? true : never;
@@ -0,0 +1,16 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ // GENERATED by scripts/stampVersions.ts — do not edit by hand. Regenerate with `bun run stamp-versions`.
5
+ //
6
+ // A Worker cannot read its own package.json, so this is how @pithy-sh/secrets knows its own version at
7
+ // runtime. The capability attaches it, and `GET /control-plane/manifest` reports it per capability —
8
+ // which is what answers "should this project upgrade" and "is this customer exposed to what we just
9
+ // fixed". Those questions are only answerable per module, because a project composes some capabilities
10
+ // and not others.
11
+
12
+ /** This package's npm name — the join key against a release feed. */
13
+ export const PACKAGE_NAME = "@pithy-sh/secrets";
14
+
15
+ /** This package's version, stamped from its own package.json at generation time. */
16
+ export const PACKAGE_VERSION = "0.1.0";