@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,300 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { zValidator } from "@hono/zod-validator";
5
+ import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
6
+ import type { ControlPlaneContext } from "@pithy-sh/core/src/controlPlane/context";
7
+ import { requireControlPlane } from "@pithy-sh/core/src/controlPlane/http/guard";
8
+ import type { ControlPlaneScope } from "@pithy-sh/core/src/controlPlane/scope/scope";
9
+ import { pageLimit } from "@pithy-sh/core/src/data/cursor";
10
+ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
11
+ import { validationHook } from "@pithy-sh/core/src/http/validation";
12
+ import type { VerificationStrategy } from "@pithy-sh/core/src/http/verification";
13
+ import type { Context, Hono } from "hono";
14
+ import { dueForRotation } from "../admin/health";
15
+ import { readSecretRotations, readSecretStatus } from "../admin/status";
16
+ import { type SecretsAuditAction, SecretsAuditActions } from "../audit/actions";
17
+ import { secretsStatusDatabase } from "../data/statusDb";
18
+ import { SecretNotFoundError } from "../error/errors";
19
+ import type { SecretRegistry, SecretRegistryEntry } from "../registry";
20
+ import type { SecretRotationOutcome } from "../rotation/rotateValue";
21
+ import { SECRETS_ROTATE_SCOPE, SECRETS_STATUS_READ_SCOPE } from "./guards";
22
+ import type { SecretRotateResponse, SecretRotationsResponse, SecretsStatusResponse } from "./responses";
23
+ import { runWorkerRotation, workerRotationDeps, workerRotationEnvironment } from "./rotate";
24
+ import { RotationsQuery, SecretNameParam } from "./schemas";
25
+ import { secretRotationOutcomeView, secretRotationView, secretStatusView } from "./view";
26
+
27
+ /**
28
+ * The secrets capability's management surface — two reads and one write, all control-plane, none of them
29
+ * carrying a value:
30
+ *
31
+ * GET /secrets/admin/status → every declared secret's status (control-plane: secrets:status:read)
32
+ * GET /secrets/admin/status/:name/rotations → one secret's rotation history (control-plane: secrets:status:read) param: SecretNameParam, query: RotationsQuery
33
+ * POST /secrets/admin/status/:name/rotate → replace one secret, here (control-plane: secrets:rotate) param: SecretNameParam
34
+ *
35
+ * ## The write is a rotation and nothing else, and that is what makes it possible
36
+ *
37
+ * This surface was two reads because **a management client cannot supply a value.** It holds neither the
38
+ * adopter's registry nor their Zod schemas, so a create or an update here would write a value against a
39
+ * schema it could not check (`management/writeSecret.ts` explains why the Worker cannot be that
40
+ * validator), and a route that writes a secret it cannot check is not a feature.
41
+ *
42
+ * A rotation supplies nothing. The successor is produced *inside* the Worker — minted from the entry's own
43
+ * recipe for a `local` secret, or returned by the rotator the registry entry carries for a `provider` one —
44
+ * so the value never crosses a boundary in either direction, and the schema that governs it is the one that
45
+ * produced it. That is the whole of why this write can exist where create and update still cannot, and it
46
+ * is a fact about rotation rather than an exception granted to it.
47
+ *
48
+ * **What a Worker can rotate is narrower than what the CLI can**, and `./rotate.ts` is where that is argued
49
+ * and refused: one environment's D1, its own master key, and nothing that lives in Cloudflare's Secrets
50
+ * Store or has to be identical across environments.
51
+ *
52
+ * ## There is no route that reads a value, and there is no scope that could grant one
53
+ *
54
+ * Not an omission to be filled in later. The whole point of storing secrets in the customer's own D1,
55
+ * under a master key their Worker holds, is that no third party has a path to a plaintext. A route here
56
+ * would be that path, and it would exist in every deployment whether or not anybody granted it. The
57
+ * rotation route does not weaken this: it produces a value, stores it, and answers with a shape that has no
58
+ * field one could sit in (`SecretRotationOutcomeView`). A rotation that cannot store its successor discards
59
+ * it and says so — it never hands it back rather than lose it.
60
+ *
61
+ * ## The rotation is a `POST` with no body, and CSRF has nothing to ride
62
+ *
63
+ * `control-plane` is not a cookie strategy. `requireControlPlane` verifies a detached EdDSA token in the
64
+ * `CONTROL_PLANE_HEADER`, signed over the request body, against a key the adopter registered — there is no
65
+ * ambient credential a browser attaches on its own, so a cross-site form post carries no authority. CSRF
66
+ * middleware belongs with `session`, and stacking it here would guard a door that has no hinge. The route
67
+ * takes **no body at all**: everything it needs is the `:name` it is addressed at and the environment its
68
+ * own verified context names, and a body it does not read is a body nobody can smuggle a value into.
69
+ *
70
+ * ## `requireAuth()` is never on these lines
71
+ *
72
+ * The seam leaves `c.var.auth` null on a control-plane call by design, so an auth gate would deny every
73
+ * legitimate management call permanently and no credential could fix it. `requireControlPlane`
74
+ * **replaces** it; it does not stack with it.
75
+ *
76
+ * Validators sit **after** the guard on every route line: an unverified caller is turned away before its
77
+ * request is parsed, so a malformed request can never downgrade a 403 to a 400 and tell a caller with no
78
+ * credential which requests were well-formed. On a surface that enumerates a project's credentials, that
79
+ * is a live oracle.
80
+ */
81
+
82
+ /**
83
+ * Where the secrets management surface mounts when an adopter names nothing.
84
+ *
85
+ * Exported because two places must agree on it: the router below, and `secretsAdminRoutes` in
86
+ * `guards.ts`. A default living only in the registrar would let the manifest advertise `/secrets/...`
87
+ * while the routes mounted somewhere else, and a management client composing its calls from the
88
+ * manifest would 404 with nothing to diagnose.
89
+ */
90
+ export const SECRETS_DEFAULT_BASE_PATH = "/secrets";
91
+
92
+ /**
93
+ * What every route this capability mounts declares: its path, its verification strategy, and the
94
+ * control-plane scope it checks.
95
+ *
96
+ * Exported so a test can assert against the declaration rather than against a middleware count.
97
+ * Counting middleware proves that *something* runs before the handler; it cannot prove *what*, and a
98
+ * bare `zValidator` satisfies a count. `routeContract.test.ts` checks this list against the routes Hono
99
+ * actually registered in both directions.
100
+ */
101
+ export interface SecretsRouteDeclaration {
102
+ readonly method: "GET" | "POST";
103
+ /** The path relative to the configured `basePath`, e.g. `/admin/status`. */
104
+ readonly path: string;
105
+ readonly strategy: VerificationStrategy;
106
+ /** The control-plane scope this route checks. */
107
+ readonly scope: ControlPlaneScope;
108
+ }
109
+
110
+ /** Every route, and how it is gated. */
111
+ export const SECRETS_ROUTES: readonly SecretsRouteDeclaration[] = [
112
+ { method: "GET", path: "/admin/status", strategy: "control-plane", scope: SECRETS_STATUS_READ_SCOPE },
113
+ { method: "GET", path: "/admin/status/:name/rotations", strategy: "control-plane", scope: SECRETS_STATUS_READ_SCOPE },
114
+ { method: "POST", path: "/admin/status/:name/rotate", strategy: "control-plane", scope: SECRETS_ROTATE_SCOPE },
115
+ ];
116
+
117
+ export interface SecretsRoutesOptions {
118
+ /**
119
+ * The registry to report over, read **per request** rather than captured.
120
+ *
121
+ * A function, because the set worth reporting is not the one this capability was constructed with. The
122
+ * combined registry — every composed capability's slice, auth's signing key and email's link key
123
+ * included — only exists once `compose` has run, which is after the router is built. Capturing the
124
+ * value here would report the adopter's own secrets and silently omit every capability's, which is
125
+ * most of them.
126
+ */
127
+ registry: () => SecretRegistry;
128
+ /** Mount the surface somewhere other than `/secrets`. Moves the advertised paths with it. */
129
+ basePath?: string;
130
+ }
131
+
132
+ /**
133
+ * The verified management client behind a control-plane call.
134
+ *
135
+ * `requireControlPlane()` has run on every route that calls this, so `c.var.controlPlane` is populated
136
+ * by the time a handler reads it. The throw is a programming-error guard rather than a runtime path:
137
+ * reaching it would mean a management route was mounted without its gate, which is the one mistake this
138
+ * file is arranged to make impossible.
139
+ */
140
+ function caller(c: Context<PithyHonoEnv>): ControlPlaneContext {
141
+ const context = c.var.controlPlane;
142
+ if (!context) {
143
+ throw new InternalError({
144
+ message: "The secrets surface could not identify the management caller.",
145
+ detail: "requireControlPlane() must run before a secrets management handler reads the caller.",
146
+ });
147
+ }
148
+ return context;
149
+ }
150
+
151
+ /**
152
+ * The registry entry this call is addressed at, or a 404.
153
+ *
154
+ * **Membership is the gate, not the string's shape.** Without it the history route would read rotation
155
+ * rows by arbitrary name — including the sentinel the whole-store key rotation records itself under, and
156
+ * any row left by a secret since removed from the registry — and the rotation route would be pointed at a
157
+ * name no capability has ever declared. A keyed entry is refused with the rest: a keyspace has no single
158
+ * value, so neither call has an answer for one.
159
+ */
160
+ function declared(registry: SecretRegistry, name: string): SecretRegistryEntry {
161
+ const entry = registry[name];
162
+ if (!entry || entry.keyed) {
163
+ throw new SecretNotFoundError({
164
+ message: `No secret named '${name}' is declared.`,
165
+ action: "Read the declared names from the status listing.",
166
+ detail: `secret status: '${name}' is not a named entry of the composed registry`,
167
+ });
168
+ }
169
+ return entry;
170
+ }
171
+
172
+ /** Record a management read. Names and counts only — there is nothing else to record, by construction. */
173
+ async function record(
174
+ c: Context<PithyHonoEnv>,
175
+ action: SecretsAuditAction,
176
+ resourceId: string | null,
177
+ metadata: Record<string, unknown>,
178
+ ): Promise<void> {
179
+ const who = caller(c);
180
+ await c.var.emit({
181
+ action,
182
+ outcome: "success",
183
+ actorType: "control-plane",
184
+ actorId: who.subject,
185
+ resourceType: "secret",
186
+ resourceId,
187
+ requestId: c.req.header("cf-ray"),
188
+ ip: c.req.header("cf-connecting-ip"),
189
+ userAgent: c.req.header("user-agent"),
190
+ metadata: { connectionId: who.connectionId, ...metadata },
191
+ });
192
+ }
193
+
194
+ /**
195
+ * Record a rotation — the one administrative act on this surface, on success and on failure alike.
196
+ *
197
+ * `severity` is the field an incident review scans, and `unrecorded` is `critical` because it is the only
198
+ * outcome here that leaves a system broken: a credential dead at its issuer and its successor never stored.
199
+ * `rollFailed` is written down rather than left to be inferred from an empty `recorded`, because *was
200
+ * rolled* and *may have been rolled* is the distinction the review turns on and the two are the same
201
+ * absence otherwise.
202
+ *
203
+ * Names, environments and flags. Nothing about a value is here, and nothing about one is available to put
204
+ * here — `SecretRotationOutcome` has no field that could carry one.
205
+ */
206
+ async function recordRotation(c: Context<PithyHonoEnv>, outcome: SecretRotationOutcome): Promise<void> {
207
+ const who = caller(c);
208
+ await c.var.emit({
209
+ action: SecretsAuditActions.rotated,
210
+ outcome: outcome.status === "rotated" ? "success" : "failure",
211
+ severity: outcome.status === "unrecorded" ? "critical" : "warning",
212
+ actorType: "control-plane",
213
+ actorId: who.subject,
214
+ resourceType: "secret",
215
+ resourceId: outcome.name,
216
+ requestId: c.req.header("cf-ray"),
217
+ ip: c.req.header("cf-connecting-ip"),
218
+ userAgent: c.req.header("user-agent"),
219
+ metadata: {
220
+ connectionId: who.connectionId,
221
+ name: outcome.name,
222
+ status: outcome.status,
223
+ rotation: outcome.kind,
224
+ rolled: outcome.rolled,
225
+ environments: outcome.recorded,
226
+ ...(outcome.rollFailed === undefined ? {} : { rollFailed: outcome.rollFailed }),
227
+ ...(outcome.stranded.length > 0 ? { stranded: outcome.stranded } : {}),
228
+ },
229
+ });
230
+ }
231
+
232
+ export function registerSecretsRoutes(options: SecretsRoutesOptions): (app: Hono<PithyHonoEnv>) => void {
233
+ const base = options.basePath ?? SECRETS_DEFAULT_BASE_PATH;
234
+
235
+ return (app) => {
236
+ app.get(`${base}/admin/status`, requireControlPlane(SECRETS_STATUS_READ_SCOPE), async (c) => {
237
+ const entries = await readSecretStatus(secretsStatusDatabase(c), options.registry());
238
+ // Narrowed, not filtered by shape. An entry whose row would not decode has no `status` to render, so
239
+ // it cannot reach `secretStatusView` — the union is what makes that a compile error rather than an
240
+ // `undefined` on the wire (#387).
241
+ const secrets = entries.flatMap((entry) => (entry.state === "readable" ? [secretStatusView(entry.status)] : []));
242
+ const unreadable = entries.flatMap((entry) => (entry.state === "unreadable" ? [entry.name] : []));
243
+ await record(c, SecretsAuditActions.statusRead, null, {
244
+ declared: entries.length,
245
+ overdue: dueForRotation(entries),
246
+ unreadable: unreadable.length,
247
+ });
248
+ return c.json({ secrets, unreadable } satisfies SecretsStatusResponse, 200);
249
+ });
250
+
251
+ app.get(
252
+ `${base}/admin/status/:name/rotations`,
253
+ requireControlPlane(SECRETS_STATUS_READ_SCOPE),
254
+ zValidator("param", SecretNameParam, validationHook),
255
+ zValidator("query", RotationsQuery, validationHook),
256
+ async (c) => {
257
+ const { name } = c.req.valid("param");
258
+ declared(options.registry(), name);
259
+ const entries = await readSecretRotations(
260
+ secretsStatusDatabase(c),
261
+ name,
262
+ pageLimit(c.req.valid("query").limit),
263
+ );
264
+ const rotations = entries.flatMap((entry) =>
265
+ entry.state === "readable" ? [secretRotationView(entry.record)] : [],
266
+ );
267
+ const unreadable = entries.length - rotations.length;
268
+ await record(c, SecretsAuditActions.rotationsRead, name, { name, returned: rotations.length, unreadable });
269
+ return c.json({ name, rotations, unreadable } satisfies SecretRotationsResponse, 200);
270
+ },
271
+ );
272
+
273
+ app.post(
274
+ `${base}/admin/status/:name/rotate`,
275
+ requireControlPlane(SECRETS_ROTATE_SCOPE),
276
+ zValidator("param", SecretNameParam, validationHook),
277
+ async (c) => {
278
+ const { name } = c.req.valid("param");
279
+ const who = caller(c);
280
+ const entry = declared(options.registry(), name);
281
+ const outcome = await runWorkerRotation(await workerRotationDeps(c), {
282
+ name,
283
+ entry,
284
+ // From the verified context, not from a body and not from a raw binding. A caller cannot name
285
+ // the environment it wants to write, because a caller naming an environment would be a caller
286
+ // choosing which of the adopter's deployments to touch.
287
+ environment: workerRotationEnvironment(who.environment),
288
+ actor: who.subject,
289
+ });
290
+ // Audited on both outcomes, and never for a rotation that did nothing — a trail logging a
291
+ // rotation for a secret nothing touched is a trail nobody can read. The metadata is the same set
292
+ // `pithy secrets rotate` records, so one query answers the question whichever door was used.
293
+ if (outcome.status !== "unchanged") {
294
+ await recordRotation(c, outcome);
295
+ }
296
+ return c.json({ rotation: secretRotationOutcomeView(outcome) } satisfies SecretRotateResponse, 200);
297
+ },
298
+ );
299
+ };
300
+ }
@@ -0,0 +1,53 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { MAX_PAGE_SIZE } from "@pithy-sh/core/src/data/cursor";
5
+ import { z } from "zod";
6
+
7
+ /**
8
+ * The HTTP boundary shapes for the secrets management routes. Everything a client can send is declared
9
+ * here and parsed on the route line, so reading a route tells you what it accepts without opening the
10
+ * handler. There are no bodies: this surface is read-only.
11
+ *
12
+ * ## Why `:name` carries no character pattern
13
+ *
14
+ * `defineSecretRegistry` refuses only two things in a name — empty, and the keyspace separator — so any
15
+ * other spelling an adopter chose is a legitimate registry entry. A charset pattern here would 400 a
16
+ * name the project genuinely declares, and a status read that cannot address a secret is worse than one
17
+ * that answers 404 for a name that does not exist.
18
+ *
19
+ * The safety comes from the shape of the check instead of from the shape of the string: the handler
20
+ * looks the name up in the composed registry by exact match and refuses anything it does not find, so
21
+ * the only names that reach a query or a response are ones the project itself declared. Routing does
22
+ * the rest — a keyspace member is `<keyspace>/<key>`, and a slash cannot appear in one path segment.
23
+ *
24
+ * The length bound is not the security control; it is the ordinary refusal to run a query over an
25
+ * unbounded string a caller chose.
26
+ */
27
+
28
+ /** How many rotation rows one call returns. Bounded, because a verified client can still have a bug. */
29
+ const Limit = z.coerce
30
+ .number()
31
+ .int()
32
+ .min(1)
33
+ .max(MAX_PAGE_SIZE)
34
+ .optional()
35
+ .describe(`How many rotation rows to return, from 1 to ${MAX_PAGE_SIZE}. Defaults to a page a screen can render.`);
36
+
37
+ export const SecretNameParam = z
38
+ .object({
39
+ name: z
40
+ .string()
41
+ .min(1)
42
+ .max(256)
43
+ .describe(
44
+ "The secret to read — the `:name` path segment, matched exactly against the composed registry. A name no capability declares is a 404, not an empty history.",
45
+ ),
46
+ })
47
+ .describe("The `:name` path segment naming one declared secret.");
48
+ export type SecretNameParam = z.output<typeof SecretNameParam>;
49
+
50
+ export const RotationsQuery = z
51
+ .object({ limit: Limit })
52
+ .describe("The rotation-history query: how many attempts to return, newest first.");
53
+ export type RotationsQuery = z.output<typeof RotationsQuery>;
@@ -0,0 +1,74 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { SecretRotationRecord, SecretStatus } from "../admin/status";
5
+ import type { SecretRotationOutcome } from "../rotation/rotateValue";
6
+ import type { SecretRotationOutcomeView, SecretRotationView, SecretStatusView } from "./responses";
7
+
8
+ /**
9
+ * What a client is shown. Nothing in this capability's management surface ever returns a raw row.
10
+ *
11
+ * The only work here is the date rendering — ms-epoch in SQLite, `Date` in TypeScript, ISO-8601 on the
12
+ * wire — and it is a function rather than a spread for one reason: **a spread would carry any field the
13
+ * reader shape later gained, straight to a client, silently.** Naming every field means a new fact has
14
+ * to be added here, to `responses.ts`, and to the test that asserts both field sets, which is three
15
+ * places somebody has to mean it.
16
+ *
17
+ * Null passes through as null, in every case, because null is a fact on this surface: never rotated,
18
+ * not stored here, no cadence declared, unanswerable. See `admin/status.ts`.
19
+ */
20
+
21
+ /** One secret's status, rendered for the wire. */
22
+ export function secretStatusView(status: SecretStatus): SecretStatusView {
23
+ return {
24
+ name: status.name,
25
+ backend: status.backend,
26
+ valueType: status.valueType,
27
+ rotatable: status.rotatable,
28
+ rotation: status.rotation,
29
+ keyVersion: status.keyVersion,
30
+ createdAt: status.createdAt?.toISOString() ?? null,
31
+ updatedAt: status.updatedAt?.toISOString() ?? null,
32
+ lastRotatedAt: status.lastRotatedAt?.toISOString() ?? null,
33
+ rotationCount: status.rotationCount,
34
+ rotateEveryDays: status.rotateEveryDays,
35
+ overdue: status.overdue,
36
+ };
37
+ }
38
+
39
+ /**
40
+ * One rotation outcome, rendered for the wire.
41
+ *
42
+ * **Two fields of the core's outcome are not named here, and that is the whole of what this function is
43
+ * for.** `cause` is an `unknown` thrown by a store — an exception raised where a value was in scope — and
44
+ * it does not cross. Optionality is flattened rather than forwarded: `rollFailed` becomes a plain boolean
45
+ * because absent and false mean the same thing beside `rolled`, while `reason` and `attempts` become null,
46
+ * because for those two the absence *is* the fact — nothing was skipped, nothing was stored.
47
+ *
48
+ * Naming every field rather than spreading, for the reason `secretStatusView` states: a spread would carry
49
+ * a field the core's outcome later gained straight to a client, silently.
50
+ */
51
+ export function secretRotationOutcomeView(outcome: SecretRotationOutcome): SecretRotationOutcomeView {
52
+ return {
53
+ name: outcome.name,
54
+ status: outcome.status,
55
+ kind: outcome.kind,
56
+ rolled: outcome.rolled,
57
+ rollFailed: outcome.rollFailed ?? false,
58
+ recorded: [...outcome.recorded],
59
+ stranded: [...outcome.stranded],
60
+ reason: outcome.reason ?? null,
61
+ attempts: outcome.attempts ?? null,
62
+ };
63
+ }
64
+
65
+ /** One rotation attempt, rendered for the wire. */
66
+ export function secretRotationView(record: SecretRotationRecord): SecretRotationView {
67
+ return {
68
+ startedAt: record.startedAt.toISOString(),
69
+ completedAt: record.completedAt?.toISOString() ?? null,
70
+ status: record.status,
71
+ trigger: record.trigger,
72
+ rotatedBy: record.rotatedBy,
73
+ };
74
+ }
package/src/index.ts ADDED
@@ -0,0 +1,50 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The package entrypoint — the surface `pithy add secrets` wires into `pithy.config.ts`. Deliberately
6
+ * narrow: the capability factory, the registry helper and the schemas a registry entry is written
7
+ * against, the two accessors a Worker reads secrets through, and the table map. Every other module is
8
+ * imported by deep path (`@pithy-sh/secrets/src/...`); this is the documented contract, not a barrel.
9
+ *
10
+ * It exists because `pithy add` writes `import { secrets } from "@pithy-sh/secrets/src/index";`, and
11
+ * for a while nothing answered that specifier. `@pithy-sh/core/src/index.ts` says the same thing for
12
+ * the same reason.
13
+ */
14
+
15
+ export {
16
+ isSecretsCapability,
17
+ type SecretsCapability,
18
+ type SecretsConfig,
19
+ secrets,
20
+ secretsTokenProfile,
21
+ } from "./capability";
22
+ export { type SecretsTables, secretsTables } from "./data/tables";
23
+ // A keyspace member's stored name. Exported because the app that mints per-tenant credentials writes
24
+ // them, and composing that name by hand is exactly the mistake `keyedSecretName` exists to prevent.
25
+ export { keyedSecretName, SecretKey } from "./keyspace";
26
+ export type { KeyedWriteMode } from "./keyspaceWrite";
27
+ export {
28
+ defineSecretRegistry,
29
+ type KeyedSecretName,
30
+ SecretBackend,
31
+ type SecretName,
32
+ type SecretRegistry,
33
+ type SecretRegistryEntry,
34
+ SecretScope,
35
+ type SecretValue,
36
+ SecretValueType,
37
+ } from "./registry";
38
+ // `SecretsAccessor` is a type here, not the class. Its constructor takes already-resolved plaintext,
39
+ // and an entrypoint that calls itself narrow has no business handing out a way to mint one over
40
+ // arbitrary values. `secretsStore` is how a Worker gets one.
41
+ export {
42
+ type KeyedPutOptions,
43
+ type KeyedWriteAudit,
44
+ type KeyedWriteOptions,
45
+ type KeyedWriteResult,
46
+ type SecretsAccessor,
47
+ secretsStore,
48
+ type VersionedSecret,
49
+ } from "./secretsStore";
50
+ export { DEFAULT_SECRETS_CACHE_TTL_SECONDS, sharedSecretsStore } from "./sharedSecretsStore";
@@ -0,0 +1,70 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
5
+ import { z } from "zod";
6
+
7
+ /**
8
+ * Keyspaces — the naming rule behind a `keyed` registry entry.
9
+ *
10
+ * A named entry is one secret with one value. A keyed entry declares a *keyspace*: one schema, one
11
+ * backend, and an unbounded set of members whose keys only exist at runtime — one signing key per
12
+ * customer connection, one credential per tenant. The registry can no more list those names than it
13
+ * can list next month's customers.
14
+ *
15
+ * A member is stored under `<entry>/<key>` in the same encrypted store as everything else, so at-rest
16
+ * rotation, the audit and teardown all keep working with no second storage path. `/` is the one
17
+ * separator, and `defineSecretRegistry` refuses it in an entry name — so a keyspace member can never
18
+ * collide with a declared secret, and no key can compose its way into a neighboring keyspace.
19
+ *
20
+ * That is the whole security argument, and it rests on {@link SecretKey}: every key is validated
21
+ * before it is composed, so `../OTHER/victim` is refused at the call rather than resolved at the
22
+ * store. One tenant's credential is not reachable with another tenant's key.
23
+ */
24
+
25
+ /** The one separator between a keyspace and a member key. Illegal in a registry entry name. */
26
+ export const KEYSPACE_SEPARATOR = "/";
27
+
28
+ /** Longest member key. Generous for a uuid or a prefixed id; short enough to keep a stored name bounded. */
29
+ const MAX_KEY_LENGTH = 128;
30
+
31
+ export const SecretKey = z
32
+ .string()
33
+ .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/)
34
+ .max(MAX_KEY_LENGTH)
35
+ .describe(
36
+ "A keyspace member key supplied at runtime — a tenant, connection, or installation id. Letters, digits, dot, dash and underscore only, starting with a letter or digit, at most 128 characters: the separator, whitespace and control characters are excluded so a key can never compose its way out of its keyspace.",
37
+ );
38
+ export type SecretKey = z.output<typeof SecretKey>;
39
+
40
+ /**
41
+ * The stored name of one keyspace member. The only sanctioned way to compose one — read and write
42
+ * both go through here, so there is a single place where a key is proven legal.
43
+ *
44
+ * The refusal names the keyspace and never the key. `detail` reaches logs verbatim, and a rejected
45
+ * key is unvalidated input: echoing it would put attacker-chosen bytes, newlines included, in the
46
+ * operator's log.
47
+ */
48
+ export function keyedSecretName(name: string, key: string): string {
49
+ const parsed = SecretKey.safeParse(key);
50
+ if (!parsed.success) {
51
+ throw new ValidationError({
52
+ message: "That secret key is not valid.",
53
+ action: "Use letters, digits, dot, dash or underscore — starting with a letter or digit, up to 128 characters.",
54
+ detail: `keyspace '${name}': the supplied key failed SecretKey validation`,
55
+ });
56
+ }
57
+ return `${name}${KEYSPACE_SEPARATOR}${parsed.data}`;
58
+ }
59
+
60
+ /**
61
+ * Split a stored name back into its keyspace and key, or `undefined` when it is not a keyspace
62
+ * member. The audit uses it to attribute a stored member to the keyspace that declared it, so a
63
+ * tenant's credential is not reported as an orphan.
64
+ */
65
+ export function parseKeyedSecretName(stored: string): { name: string; key: string } | undefined {
66
+ const at = stored.indexOf(KEYSPACE_SEPARATOR);
67
+ if (at <= 0) return undefined;
68
+ const key = stored.slice(at + 1);
69
+ return SecretKey.safeParse(key).success ? { name: stored.slice(0, at), key } : undefined;
70
+ }