@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,462 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { SecretRotation } from "@pithy-sh/core/src/capability/secretOrigin";
5
+ import { chunkByBoundParameters } from "@pithy-sh/core/src/data/boundParameters";
6
+ import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
7
+ import type { DatabaseSchema } from "@pithy-sh/core/src/data/db";
8
+ import { type Kysely, sql } from "kysely";
9
+ import { z } from "zod";
10
+ import { RotationStatus, RotationTrigger } from "../data/secretRotations";
11
+ import type { SecretsTables } from "../data/tables";
12
+ import { SecretBackend, type SecretRegistry, SecretValueType } from "../registry";
13
+ import type { CarriesNoValue } from "../valueBearing";
14
+
15
+ /**
16
+ * The read behind "is any secret overdue" — metadata about secrets, and never a secret.
17
+ *
18
+ * ## The one constraint everything here is arranged around
19
+ *
20
+ * A status read must not be able to disclose a value. Not "the projection happens to omit it" — the
21
+ * shapes below must be **incapable** of carrying one, so widening them is a compile error rather than a
22
+ * review somebody has to catch. `packages/auth/src/admin/users.ts` states the first half of the rule:
23
+ * *a projection cannot leak a column that was not selected*. This file adds the second: a type that has
24
+ * no field for a value cannot be handed one, whatever a future query selects.
25
+ *
26
+ * Four layers, because each catches what the others cannot:
27
+ *
28
+ * 1. **The queries name their columns.** `encrypted_value` and `iv` never reach the Worker's memory on
29
+ * a status read, so nothing downstream can leak them by accident.
30
+ * 2. **The shapes are Zod objects and rows are parsed through them**, so an unknown column is stripped
31
+ * rather than passed along — a `selectAll()` written here later still discloses nothing.
32
+ * 3. **{@link SECRET_STATUS_CARRIES_NO_VALUE} is a compile-time tripwire.** Adding a banned field to
33
+ * either shape makes this module fail to typecheck. The type behind it is `../valueBearing.ts`,
34
+ * moved out of this file so that `http/responses.ts` — a module a browser imports — can make the
35
+ * same promise without acquiring this file's Kysely reader and the D1 layer behind it (#419).
36
+ * 4. **`status.test.ts` asserts the exact field set**, so *any* new field — not only a banned one — has
37
+ * to be argued for by somebody editing a test that says why.
38
+ *
39
+ * ## `errorMessage` and `metadataSnapshot` are refused, and they are the interesting ones
40
+ *
41
+ * Both columns exist on `pithy_secrets_rotations` and neither is exposed. They are free text written at
42
+ * a failure site, which is precisely where a value gets pasted by accident — an exception message that
43
+ * interpolated what it was decrypting, a snapshot taken "for debugging". A failed rotation is still
44
+ * reported: {@link SecretRotationRecord.status} says `failed`, which is the fact an owner acts on. If a
45
+ * reason is ever wanted it belongs here as a **code** the capability defines, never as the message.
46
+ *
47
+ * ## What is reported, and what is deliberately not
48
+ *
49
+ * One row per **named** registry entry, from the registry the Worker actually composed — so a status
50
+ * read covers every capability's secrets (auth's signing key, email's link key), not only the ones the
51
+ * adopter typed. Keyed entries are excluded: a keyspace has no single value, and its members are
52
+ * per-tenant rows created at runtime, so listing them would turn a status read into a tenant
53
+ * enumeration. Stored rows that no registry entry names are excluded for the same reason — reaching
54
+ * them means listing the table, and the table is where keyspace members live.
55
+ *
56
+ * The whole-store at-rest key rotation records itself under a sentinel name (`AT_REST_ROTATION_NAME`)
57
+ * that no registry entry can carry, so it falls out of every query here without a filter. It is an
58
+ * event about the store, not about a secret.
59
+ */
60
+
61
+ /** The Kysely instance these reads run against — typed over the secrets tables, CamelCasePlugin installed. */
62
+ export type SecretsStatusDb = Kysely<DatabaseSchema<SecretsTables>>;
63
+
64
+ /**
65
+ * One rotation attempt, as an owner may see it: when, how it ended, what caused it, and who.
66
+ *
67
+ * Ordered newest first by the reader. `completedAt` null with status `in_progress` is a rotation still
68
+ * running; a `failed` row says only that it failed, on purpose — see the file comment.
69
+ */
70
+ export const SecretRotationRecord = z
71
+ .object({
72
+ startedAt: SQLiteDate.describe("When the rotation attempt began. Ms-epoch in SQLite, a `Date` here."),
73
+ completedAt: SQLiteDate.nullable().describe(
74
+ "When it finished, or null while it is still running. Ms-epoch in SQLite, a `Date` here.",
75
+ ),
76
+ status: RotationStatus.describe(
77
+ "How it ended: `in_progress`, `success`, or `failed`. A failure reports as a status and never as a message.",
78
+ ),
79
+ trigger: RotationTrigger.describe(
80
+ "What caused it — a scheduled `cron` run, a `manual` action, or the `baseline` marker written when a secret is first stored.",
81
+ ),
82
+ rotatedBy: z.string().describe("Who or what initiated it: a workflow instance id, an operator id, or `baseline`."),
83
+ })
84
+ .describe("One rotation attempt, in metadata only. Carries no value, no ciphertext, and no failure text.");
85
+ export type SecretRotationRecord = z.output<typeof SecretRotationRecord>;
86
+
87
+ /**
88
+ * One secret's status: what the registry declares about it, what the store knows about it, and whether
89
+ * it is late.
90
+ *
91
+ * **Null is load-bearing in three different ways here, and they are not the same fact.**
92
+ * `lastRotatedAt: null` means never rotated — which is not zero, not the epoch, and not "rotated a long
93
+ * time ago". `createdAt: null` means nothing is stored under this name in the secrets D1: either the
94
+ * secret was declared and never written, or it lives in Cloudflare's Secrets Store, which is why
95
+ * {@link SecretStatus.backend} is reported — without it the nulls are unreadable. `overdue: null` means
96
+ * the question has no answer, either because no cadence is declared or because there is no date to
97
+ * measure from.
98
+ */
99
+ export const SecretStatus = z
100
+ .object({
101
+ name: z.string().describe("The secret's registry name."),
102
+ backend: SecretBackend.describe(
103
+ "Where the value physically lives. Reported because it decides what a null `createdAt` means: a `cf-secrets-store` secret never has a row in this database.",
104
+ ),
105
+ valueType: SecretValueType.describe("How the value is interpreted, from the registry: `text` or `json`."),
106
+ rotatable: z
107
+ .boolean()
108
+ .describe(
109
+ "Whether a value-rotator may manage this secret. It changes what automation may do and never what an owner may see — a `false` secret reports exactly like a `true` one.",
110
+ ),
111
+ rotation: SecretRotation.nullable().describe(
112
+ "How this secret is replaced, from its registry entry: `local` (the kit mints another), `provider` (its issuer is called and returns one), or `manual` (a human in a console, with the issuer and the page named). Null when the entry declares none, which is a different fact from `manual` — nobody has said, rather than somebody has said it takes a human. **Not derivable from `rotatable`, and not a duplicate of it**: `SECRETS_ENCRYPTION_KEYS` is `local` and `rotatable: false`, while a payments credential is `rotatable: true` and rotates only by hand. Metadata by construction — a kind, an issuer and a documentation URL, none of which a value fits in.",
113
+ ),
114
+ keyVersion: z
115
+ .number()
116
+ .int()
117
+ .nullable()
118
+ .describe(
119
+ "Which master-key version the stored envelope sits under, or null when nothing is stored here. A number, never key material.",
120
+ ),
121
+ createdAt: SQLiteDate.nullable().describe(
122
+ "When the secret was first written to this store, or null when it is not stored here.",
123
+ ),
124
+ updatedAt: SQLiteDate.nullable().describe("When its value was last written, or null when it is not stored here."),
125
+ lastRotatedAt: SQLiteDate.nullable().describe(
126
+ "The newest rotation that completed successfully, or **null for never rotated** — which is a different fact from rotated long ago, and must not render as one.",
127
+ ),
128
+ rotationCount: z
129
+ .number()
130
+ .int()
131
+ .nonnegative()
132
+ .describe("How many rotation attempts are recorded for this secret, successful or not."),
133
+ rotateEveryDays: z
134
+ .number()
135
+ .int()
136
+ .positive()
137
+ .nullable()
138
+ .describe(
139
+ "The cadence the registry declares for this secret, or null when it declares none. The capability's own statement of what late means.",
140
+ ),
141
+ overdue: z
142
+ .boolean()
143
+ .nullable()
144
+ .describe(
145
+ "Whether it is past its declared cadence. Null when the question has no answer: no cadence declared, or nothing to measure from.",
146
+ ),
147
+ })
148
+ .describe("One secret's status — registry declaration, store metadata, and freshness. Never a value.");
149
+ export type SecretStatus = z.output<typeof SecretStatus>;
150
+
151
+ /**
152
+ * The compile-time half of the constraint. `true` only while neither shape names a value-bearing field;
153
+ * add one and this assignment fails, naming the file rather than waiting for a reviewer.
154
+ */
155
+ export const SECRET_STATUS_CARRIES_NO_VALUE: CarriesNoValue<SecretStatus> & CarriesNoValue<SecretRotationRecord> = true;
156
+
157
+ /** Milliseconds in a day. Cadences are declared in days because that is the unit people reason about. */
158
+ const MS_PER_DAY = 86_400_000;
159
+
160
+ /**
161
+ * Whether a secret is past its declared cadence.
162
+ *
163
+ * Returns null rather than false when it cannot be decided — no cadence declared, or no date to measure
164
+ * from. False would claim the secret is fine, which is a different and more comfortable answer than
165
+ * "nobody has said what fine is", and comfort is the wrong default on this surface.
166
+ */
167
+ export function overdueAgainst(reference: Date | null, rotateEveryDays: number | null, now: Date): boolean | null {
168
+ if (rotateEveryDays === null || reference === null) return null;
169
+ return now.getTime() - reference.getTime() > rotateEveryDays * MS_PER_DAY;
170
+ }
171
+
172
+ /** The named (non-keyed) entries of a registry, sorted — the set a status read reports over. */
173
+ function reportableNames(registry: SecretRegistry): string[] {
174
+ return Object.entries(registry)
175
+ .filter(([, entry]) => !entry.keyed)
176
+ .map(([name]) => name)
177
+ .sort();
178
+ }
179
+
180
+ /** What the secrets table knows about one name. No envelope columns are selected, so none can be returned. */
181
+ interface StoredFacts {
182
+ keyVersion: number;
183
+ createdAt: Date;
184
+ updatedAt: Date;
185
+ }
186
+
187
+ /** What the rotations table knows about one name, aggregated. */
188
+ interface RotationFacts {
189
+ rotationCount: number;
190
+ lastRotatedAt: Date | null;
191
+ }
192
+
193
+ /**
194
+ * One row of a batch read as the read found it: its facts, or that the row is there and would not decode.
195
+ *
196
+ * **The state rides on the value (`#384`, `#387`).** Every read in this file is a batch — a chunk of names
197
+ * against one statement — so a row that throws is a row that costs every other name in the chunk. Holding
198
+ * the outcome on the value means a caller cannot reach the facts without narrowing, and forgetting the
199
+ * unreadable case is a compile error rather than a silent empty.
200
+ *
201
+ * **Absent is a third fact and it is not in this union.** A name with no row is simply not a key in the
202
+ * map, which is what `SecretStatus`'s nulls already mean: declared and never written, or living in
203
+ * Cloudflare's Secrets Store. *Missing* and *malformed* have different remedies — write it, versus repair
204
+ * the row — and folding either into the other reports a stored secret as unprovisioned.
205
+ *
206
+ * `unreadable` carries nothing, for the reason `#384` gives: there is nothing safe to put on it. What the
207
+ * decode rejected is a column value from a row about a secret, and the name it is filed under is already
208
+ * the key.
209
+ */
210
+ type Decoded<T> = { state: "readable"; facts: T } | { state: "unreadable" };
211
+
212
+ /**
213
+ * Store metadata per name, in as few statements as D1's bound-parameter cap allows.
214
+ *
215
+ * The column list is the security boundary — `encryptedValue` and `iv` are on this table and are not
216
+ * named, so a status read never pulls a ciphertext into the Worker at all.
217
+ *
218
+ * **The date decode is per row since `#387`.** It sat inside `for (const row of rows)` unguarded, which is
219
+ * easy to miss because it does not read like parsing a row — it reads like converting a field. One corrupt
220
+ * ms-epoch threw out of the loop and lost the whole chunk, so every secret in it reported nothing on
221
+ * account of one.
222
+ */
223
+ async function storedFacts(db: SecretsStatusDb, names: string[]): Promise<Map<string, Decoded<StoredFacts>>> {
224
+ const found = new Map<string, Decoded<StoredFacts>>();
225
+ for (const chunk of chunkByBoundParameters(names, 0)) {
226
+ const rows = await db
227
+ .selectFrom("pithySecretsSystemSecrets")
228
+ .select(["name", "keyVersion", "createdAt", "updatedAt"])
229
+ .where("name", "in", chunk)
230
+ .execute();
231
+ for (const row of rows) {
232
+ // Dates decode here rather than at the shape, so every date this module handles is a `Date` and
233
+ // the ms-epoch the column actually holds stops being something a caller could get wrong.
234
+ //
235
+ // The `catch` takes no binding. A `SQLiteDate` rejection carries the offending column value as the
236
+ // issue's `input`, and these rows sit beside `error_message` and `metadata_snapshot` — free text
237
+ // written where a value was in scope. Nothing derived from the failure may travel, and nothing can,
238
+ // because there is nothing in scope to attach.
239
+ try {
240
+ found.set(row.name, {
241
+ state: "readable",
242
+ facts: {
243
+ keyVersion: row.keyVersion,
244
+ createdAt: SQLiteDate.parse(row.createdAt),
245
+ updatedAt: SQLiteDate.parse(row.updatedAt),
246
+ },
247
+ });
248
+ } catch {
249
+ found.set(row.name, { state: "unreadable" });
250
+ }
251
+ }
252
+ }
253
+ return found;
254
+ }
255
+
256
+ /**
257
+ * Rotation counts and the newest successful completion per name, in one grouped statement per chunk.
258
+ *
259
+ * `max(case when …)` rather than a second query: the newest *successful* completion is a different row
260
+ * from the newest attempt, and a failed rotation must never advance the freshness of a secret it did
261
+ * not rotate. The raw fragment names physical columns because `CamelCasePlugin` transforms identifiers
262
+ * the builder produces and leaves raw SQL alone — the same rule `packages/auth/src/admin/users.ts`
263
+ * follows for its `escape` clause.
264
+ *
265
+ * **The third site of `#387`'s shape, and it was not in the issue.** `#387` named `storedFacts` and
266
+ * `readSecretRotations`; this loop decodes `lastRotatedAt` exactly as `storedFacts` decodes its two, from
267
+ * the same aggregate over the same table, and was unguarded for the same reason — it reads like a field
268
+ * conversion. Worth stating plainly: the sweep that produced the issue looked at this file and did not
269
+ * see it, which is the argument for asking the question again rather than for trusting a list.
270
+ */
271
+ async function rotationFacts(db: SecretsStatusDb, names: string[]): Promise<Map<string, Decoded<RotationFacts>>> {
272
+ const found = new Map<string, Decoded<RotationFacts>>();
273
+ for (const chunk of chunkByBoundParameters(names, 0)) {
274
+ const rows = await db
275
+ .selectFrom("pithySecretsRotations")
276
+ .select((eb) => [
277
+ "name",
278
+ eb.fn.countAll<number>().as("rotationCount"),
279
+ sql<number | null>`max(case when status = 'success' then completed_at end)`.as("lastRotatedAt"),
280
+ ])
281
+ .where("name", "in", chunk)
282
+ .groupBy("name")
283
+ .execute();
284
+ for (const row of rows) {
285
+ // Null is not a failure here and must not become one: it is the aggregate saying this secret has
286
+ // never rotated successfully, which is the fact `SecretStatus.lastRotatedAt` is documented to carry.
287
+ // Only a non-null value that will not decode is unreadable.
288
+ try {
289
+ found.set(row.name, {
290
+ state: "readable",
291
+ facts: {
292
+ rotationCount: Number(row.rotationCount),
293
+ lastRotatedAt: row.lastRotatedAt === null ? null : SQLiteDate.parse(row.lastRotatedAt),
294
+ },
295
+ });
296
+ } catch {
297
+ found.set(row.name, { state: "unreadable" });
298
+ }
299
+ }
300
+ }
301
+ return found;
302
+ }
303
+
304
+ /**
305
+ * One declared secret's place in a status read: its status, or that a row about it would not decode.
306
+ *
307
+ * **A bad row is held against its own name (`#170`, `#384`).** The read is registry-driven and every name
308
+ * in it is one an operator declared, so the whole list still comes back and the one that could not be read
309
+ * says so under the name it belongs to. `#350` already made a throw here survivable — the capability
310
+ * reports `unavailable` and its siblings are fine — and that is a different thing from correct: the
311
+ * information was still lost for every secret because of one row, and the manifest could not say which.
312
+ *
313
+ * The name is safe to carry, and that was checked rather than assumed. It comes from
314
+ * `reportableNames(registry)`, so it is a registry literal an operator wrote. Keyed entries are excluded
315
+ * from this read, so no `<keyspace>/<key>` — a stored name embedding a tenant identifier from caller input
316
+ * — can appear here. That is the trap `#384` hit and had to correct.
317
+ */
318
+ export type SecretStatusEntry =
319
+ | {
320
+ /** The row decoded, and this secret's status is below. */
321
+ state: "readable";
322
+ /** Its declaration, its store metadata, and whether it is late. */
323
+ status: SecretStatus;
324
+ }
325
+ | {
326
+ /** A row this secret's status is built from did not decode. Its facts are not knowable from here. */
327
+ state: "unreadable";
328
+ /** Which secret. A registry name, never a stored one. */
329
+ name: string;
330
+ };
331
+
332
+ /**
333
+ * One entry of a secret's rotation history: the record, or that the row would not decode.
334
+ *
335
+ * Unlike {@link SecretStatusEntry} this carries no name, and that is not an oversight. Every row in the
336
+ * page is the *same* secret's — the name is the argument the read was called with, and it is echoed once
337
+ * by the caller. What distinguishes a row here is its position in a history, which the array preserves: a
338
+ * bad row costs its own entry and the rows around it still resolve, in order.
339
+ *
340
+ * Nothing else rides on the unreadable member. `startedAt` is the field most likely to be the one that
341
+ * would not decode, so a "when" would be exactly the thing that is missing.
342
+ */
343
+ export type SecretRotationEntry =
344
+ | {
345
+ /** The row decoded. */
346
+ state: "readable";
347
+ /** One rotation attempt, in metadata only. */
348
+ record: SecretRotationRecord;
349
+ }
350
+ | {
351
+ /** The row did not decode, and holds its own place in the history rather than emptying it. */
352
+ state: "unreadable";
353
+ };
354
+
355
+ /** Options for {@link readSecretStatus}. */
356
+ export interface SecretStatusOptions {
357
+ /** The clock `overdue` is measured against. Injected so a freshness test does not have to wait 90 days. */
358
+ now?: Date;
359
+ }
360
+
361
+ /**
362
+ * Every declared secret's status, by name.
363
+ *
364
+ * Registry-driven rather than table-driven, because the interesting cases are the ones with no row: a
365
+ * secret declared and never written is a real answer, and a secret that lives in Cloudflare's Secrets
366
+ * Store has no row here by design. A table-driven read would report neither and would additionally have
367
+ * to enumerate keyspace members to find them.
368
+ */
369
+ export async function readSecretStatus(
370
+ db: SecretsStatusDb,
371
+ registry: SecretRegistry,
372
+ options: SecretStatusOptions = {},
373
+ ): Promise<SecretStatusEntry[]> {
374
+ const names = reportableNames(registry);
375
+ if (names.length === 0) return [];
376
+ const now = options.now ?? new Date();
377
+ const [stored, rotations] = await Promise.all([storedFacts(db, names), rotationFacts(db, names)]);
378
+
379
+ return names.map((name): SecretStatusEntry => {
380
+ // Present because `reportableNames` derived the list from this registry.
381
+ const entry = registry[name] as SecretRegistry[string];
382
+ const storedEntry = stored.get(name);
383
+ const rotationEntry = rotations.get(name);
384
+ // Either table having an undecodable row about this secret makes its status unknowable, and both are
385
+ // held the same way. **Absent is not that**: `undefined` here is a name with no row, which is the
386
+ // answer this read exists to give — declared and never written, or stored in Cloudflare's Secrets
387
+ // Store. Missing and malformed stay separate before either becomes an error.
388
+ if (storedEntry?.state === "unreadable" || rotationEntry?.state === "unreadable") {
389
+ return { state: "unreadable", name };
390
+ }
391
+ const row = storedEntry?.facts;
392
+ const rotation = rotationEntry?.facts;
393
+ const lastRotatedAt = rotation?.lastRotatedAt ?? null;
394
+ const rotateEveryDays = entry.rotateEveryDays ?? null;
395
+ // Measured from the last successful rotation, and from first write when there has never been one:
396
+ // a key created two years ago and never rotated is late, and reporting it as unanswerable would
397
+ // hide exactly the secret this read exists for.
398
+ const reference = lastRotatedAt ?? row?.createdAt ?? null;
399
+ // **Left unguarded on purpose, and this is the note saying so.** `#387` was filed naming this parse as
400
+ // a third site and the claim was withdrawn on checking. It runs over registry declarations
401
+ // `defineSecretRegistry` already refused at define time, plus facts normalized above — so a throw here
402
+ // is an author error in a registry, not a bad row in a database. Guarding it would convert a defect
403
+ // that should be loud into a secret quietly reporting as unreadable.
404
+ const status = SecretStatus.parse({
405
+ name,
406
+ backend: entry.backend,
407
+ valueType: entry.valueType,
408
+ rotatable: entry.rotatable,
409
+ // Null rather than absent, and the declaration verbatim. `defineSecretRegistry` has already refused
410
+ // a malformed one, so this is a copy of something checked at define time rather than a second
411
+ // judgment about it — and a secret that declares nothing says so, instead of being reported as the
412
+ // kind a client would guess.
413
+ rotation: entry.rotation ?? null,
414
+ keyVersion: row?.keyVersion ?? null,
415
+ createdAt: row?.createdAt ?? null,
416
+ updatedAt: row?.updatedAt ?? null,
417
+ lastRotatedAt,
418
+ rotationCount: rotation?.rotationCount ?? 0,
419
+ rotateEveryDays,
420
+ overdue: overdueAgainst(reference, rotateEveryDays, now),
421
+ });
422
+ return { state: "readable", status };
423
+ });
424
+ }
425
+
426
+ /**
427
+ * One secret's rotation history, newest first, capped at `limit`.
428
+ *
429
+ * `id` breaks a tie on `startedAt`: two rotations recorded in the same millisecond would otherwise
430
+ * straddle the cap in an order SQLite is free to change between reads. It is a sort key and is not
431
+ * selected — a surrogate row id is not a fact about a secret.
432
+ *
433
+ * **Guarded per row since `#387`.** This ended `rows.map((row) => SecretRotationRecord.parse(row))`, so one
434
+ * malformed row threw out of the whole read — and the read is a *history*, per secret, so a single bad row
435
+ * cost every rotation record the caller asked for. A history is the surface an incident review reads, and
436
+ * losing all of it because the oldest row has a bad timestamp is the failure mode least worth having.
437
+ */
438
+ export async function readSecretRotations(
439
+ db: SecretsStatusDb,
440
+ name: string,
441
+ limit: number,
442
+ ): Promise<SecretRotationEntry[]> {
443
+ const rows = await db
444
+ .selectFrom("pithySecretsRotations")
445
+ .select(["startedAt", "completedAt", "status", "trigger", "rotatedBy"])
446
+ .where("name", "=", name)
447
+ .orderBy("startedAt", "desc")
448
+ .orderBy("id", "desc")
449
+ .limit(limit)
450
+ .execute();
451
+ return rows.map((row): SecretRotationEntry => {
452
+ // The `catch` takes no binding. A `ZodError` from this parse carries the offending column value as its
453
+ // issue `input`, and the row it came from is one whose neighboring columns are `error_message` and
454
+ // `metadata_snapshot` — free text written at a failure site. Nothing derived from the rejection may
455
+ // travel, and with nothing in scope there is nothing that could.
456
+ try {
457
+ return { state: "readable", record: SecretRotationRecord.parse(row) };
458
+ } catch {
459
+ return { state: "unreadable" };
460
+ }
461
+ });
462
+ }
@@ -0,0 +1,53 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The audit actions this capability emits, through the core `emit()` seam.
6
+ *
7
+ * **The two reads are audited.** This surface discloses no value, but it does disclose the
8
+ * shape of a project's secret estate — every name, which are stale, and which have never been rotated.
9
+ * That is a target list, and a credential quietly pulling it changes nothing, so without these lines it
10
+ * would leave no trace anywhere. "Who enumerated the secrets on the ninth" is a question with an
11
+ * answer, and these are what make it one.
12
+ *
13
+ * **The three member actions are writes, and they are the reason this list is not only reads.** A
14
+ * keyspace member is a per-tenant credential an application mints and stores on a request path
15
+ * (`../keyspaceWrite`), which is an administrative act however routine it looks — somebody's key to
16
+ * somebody's system now exists, or no longer does. The action codes are owned here so an adopter does
17
+ * not invent one for the kit's most sensitive write; the emitter and the actor come from the call site,
18
+ * because an accessor has neither. See `SecretsAccessor`'s `KeyedWriteAudit`.
19
+ *
20
+ * Emitted with `c.var.emit`, never by importing `@pithy-sh/audit` — the seam is always present
21
+ * (`noopEmit` when no audit capability is composed), so there is no null check and no hard dependency.
22
+ * Counts and names only in metadata: the trail is long-lived and more widely readable than this
23
+ * surface, so nothing about a value goes into it. Nothing about a value is available to put there.
24
+ */
25
+ export const SecretsAuditActions = {
26
+ /** A management client read the status of every declared secret. */
27
+ statusRead: "secrets/status_read",
28
+ /** A management client read one secret's rotation history. */
29
+ rotationsRead: "secrets/rotations_read",
30
+ /**
31
+ * A management client replaced one secret's value.
32
+ *
33
+ * The same code `pithy secrets rotate` emits, on purpose: it is the same act, and an incident review
34
+ * asking *who rolled the production key on the twelfth* must not have to know which door it came through
35
+ * to find it. Which door it was is in the actor — a `control-plane` actor with a connection id is the
36
+ * dashboard, a CLI actor is a terminal.
37
+ *
38
+ * **Emitted on failure as well as success, and `critical` for `unrecorded`.** A rotation that rolled a
39
+ * credential at its issuer and failed to store the successor is the one administrative act here that
40
+ * leaves a system broken, and a trail that records only the rotations that worked is a trail that is
41
+ * silent about exactly the ones somebody is looking for.
42
+ */
43
+ rotated: "secrets/rotated",
44
+ /** An application stored a keyspace member — created it, or replaced it (`mode` says which). */
45
+ memberWritten: "secrets/member_written",
46
+ /** An application added a version to a keyspace member, keeping the prior one valid. */
47
+ memberRotated: "secrets/member_rotated",
48
+ /** An application removed a keyspace member and every version of it. */
49
+ memberRemoved: "secrets/member_removed",
50
+ } as const;
51
+
52
+ /** One of the secrets capability's audit actions. */
53
+ export type SecretsAuditAction = (typeof SecretsAuditActions)[keyof typeof SecretsAuditActions];