@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pithy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,15 @@
1
+ # @pithy-sh/secrets
2
+
3
+ Encrypted secret storage for Pithy. One dedicated store per environment, a worker-only master key, automatic at-rest key rotation, and the `pithy secrets` CLI to manage it.
4
+
5
+ ```sh
6
+ pithy add secrets
7
+ ```
8
+
9
+ **Documentation: [pithy.sh/docs/capabilities/secrets](https://pithy.sh/docs/capabilities/secrets).** Overview, adding it, using it, and the reference: the two backends, keyspaces, scope, rotation, the master key, dev values.
10
+
11
+ _Everything else is on the site. `pithy.sh/docs` is canonical — new prose goes there, not here._
12
+
13
+ ## License
14
+
15
+ MIT — adopter-side app value. The root `LICENSE` covers it.
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@pithy-sh/secrets",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/pithy-sh/pithy.git",
8
+ "directory": "packages/secrets"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "pithy.manifest.json",
13
+ "!src/**/*.test.*"
14
+ ],
15
+ "type": "module",
16
+ "engines": {
17
+ "node": ">=22"
18
+ },
19
+ "exports": {
20
+ "./src/*": "./src/*.ts"
21
+ },
22
+ "scripts": {
23
+ "build": "tsc -p tsconfig.json --noEmit false --outDir dist",
24
+ "typecheck": "tsc -p tsconfig.json",
25
+ "test": "vitest run",
26
+ "test:node": "vitest run --project=node",
27
+ "test:workers": "vitest run --project=workers",
28
+ "test:integration": "vitest run --config vitest.integration.config.ts",
29
+ "clean": "rm -rf dist .turbo",
30
+ "reset": "bun run clean && rm -rf node_modules"
31
+ },
32
+ "dependencies": {
33
+ "@cloudflare/workers-types": "^5.20260729.1",
34
+ "@hono/zod-validator": "^0.9.0",
35
+ "@pithy-sh/cloudflare": "workspace:*",
36
+ "@pithy-sh/core": "workspace:*",
37
+ "comment-json": "^5.0.0",
38
+ "hono": "^4.13.2",
39
+ "kysely": "^0.29.0",
40
+ "zod": "^4.0.0"
41
+ },
42
+ "devDependencies": {
43
+ "@cloudflare/vitest-plugin": "^1.0.0",
44
+ "@pithy-sh/tsconfig": "workspace:*",
45
+ "@types/node": "^22.15.0",
46
+ "@vitest/coverage-v8": "^4.1.0",
47
+ "kysely-d1": "^0.4.0",
48
+ "typescript": "^7.0.2",
49
+ "vitest": "^4.1.0",
50
+ "wrangler": "^4.115.0"
51
+ }
52
+ }
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "secrets",
3
+ "package": "@pithy-sh/secrets",
4
+ "requiredBindings": [{ "type": "d1", "name": "SECRETS" }, { "type": "secret", "name": "SECRETS_ENCRYPTION_KEYS" }],
5
+ "optionalCapabilities": ["controlplane", "audit"],
6
+ "migrationNamespace": "secrets",
7
+ "secrets": [
8
+ {
9
+ "name": "SECRETS_ENCRYPTION_KEYS",
10
+ "origin": {
11
+ "kind": "minted",
12
+ "recipe": {
13
+ "kind": "encryptionConfig"
14
+ }
15
+ },
16
+ "rotation": {
17
+ "kind": "local"
18
+ }
19
+ },
20
+ {
21
+ "name": "CLOUDFLARE_API_TOKEN",
22
+ "origin": {
23
+ "kind": "helped",
24
+ "issuer": "cloudflare",
25
+ "needs": { "cloudflare": ["secrets:read", "secrets:write"] },
26
+ "documentation": "https://developers.cloudflare.com/fundamentals/api/get-started/create-token/"
27
+ },
28
+ "rotation": {
29
+ "kind": "provider",
30
+ "issuer": "cloudflare",
31
+ "documentation": "https://developers.cloudflare.com/api/resources/user/subresources/tokens/methods/update/"
32
+ }
33
+ }
34
+ ],
35
+ "whenToEnable": "Encrypted secret storage with a worker-only master key and automatic at-rest key rotation. Auth's token-signing key lives here.",
36
+ "configOptions": [
37
+ {
38
+ "key": "registry",
39
+ "default": {},
40
+ "describe": "Your secrets, keyed by name — each declaring where it lives (`backend`), whether its value is the same everywhere (`scope`), whether a rotator may manage it (`rotatable`), how it is read (`valueType`, plus a Zod schema for `json`), and — optionally, together — how it comes to exist (`origin`) and how it is replaced (`rotation`), which is what lets a tool tell you *run this* or *get it here* instead of *not set*. Empty because the contents are yours and `pithy add` cannot invent them. Both the worker's `secretsStore` and the `pithy secrets` CLI read this object, so a secret that is not declared here does not exist. Fill it in before you store anything — see @pithy-sh/secrets' README."
41
+ },
42
+ {
43
+ "key": "rotationIntervalDays",
44
+ "default": 30,
45
+ "describe": "How often the at-rest encryption key rotates. The secrets manager re-encrypts every stored secret on this cadence; raise it to rotate less often, lower it to rotate more aggressively."
46
+ }
47
+ ]
48
+ }
@@ -0,0 +1,94 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { type CapabilityHealth, defineCapabilityHealth } from "@pithy-sh/core/src/controlPlane/discovery/health";
5
+ import { secretsStatusDatabase } from "../data/statusDb";
6
+ import { SECRETS_STATUS_READ_SCOPE } from "../http/guards";
7
+ import type { SecretRegistry } from "../registry";
8
+ import { readSecretStatus, type SecretStatusEntry, type SecretsStatusDb } from "./status";
9
+
10
+ /**
11
+ * What this capability contributes to its manifest entry: one number, so a management client can say
12
+ * "3 secrets need rotating" from the read it already made (#317).
13
+ *
14
+ * ## Why a count belongs on the manifest and the list does not
15
+ *
16
+ * The list is `GET {base}/admin/status`, and it stays there. What a rail needs is whether the detail is
17
+ * worth fetching, and that is one scalar. Anything more — which secrets, since when — grows with the
18
+ * adopter's registry and would turn a discovery read into a data API.
19
+ *
20
+ * ## What it costs, stated
21
+ *
22
+ * Exactly what the status read costs, because it *is* the status read: one `where name in (...)` against
23
+ * `pithy_secrets_system_secrets`, where `name` is unique, and one grouped `where name in (...)` against
24
+ * `pithy_secrets_rotations`, where `name` is indexed (`pithySecretsRotationsNameIdx`). Both are bounded
25
+ * by how many secrets the composed registry *declares* — a number in the adopter's source — and never by
26
+ * how many rows they hold. That is what `cost: "indexed"` claims, and it is the whole reason this may
27
+ * sit on the most frequently fetched read the seam serves.
28
+ *
29
+ * ## One definition of late
30
+ *
31
+ * The count is derived from {@link readSecretStatus} rather than from a second query that re-decides
32
+ * what overdue means. A secret whose registry entry declares no cadence, or that has nothing to measure
33
+ * from, reports `overdue: null` there and is not counted here: nobody has said what late means for it,
34
+ * and counting it would be an opinion the capability does not hold.
35
+ */
36
+
37
+ /** The key the count appears under. Exported so a client and a test name it from one place. */
38
+ export const SECRETS_DUE_FOR_ROTATION = "secretsDueForRotation";
39
+
40
+ /**
41
+ * How many of these entries are past their declared cadence — the one definition of "due", read by
42
+ * the manifest count here and by the status route's own audit metadata.
43
+ *
44
+ * `overdue === true` and never merely truthy: the third state is null — nobody has said what late means
45
+ * for that secret — and folding it into "not overdue" would be the same mistake as reporting a withheld
46
+ * number as zero.
47
+ *
48
+ * **An unreadable entry is not counted, and the count is still a number (`#387`).** Before the per-row
49
+ * guard, one malformed row threw out of `readSecretStatus` and this capability reported `unavailable` for
50
+ * the whole manifest key — `#350` working exactly as designed, and still the wrong answer, because the
51
+ * other secrets' freshness was knowable and went unreported. A secret whose row will not decode is now in
52
+ * the same position as one that declares no cadence: nobody can say whether it is late, so it is not
53
+ * asserted to be. That is a smaller lie than counting it either way, and a much smaller one than
54
+ * withholding the number.
55
+ */
56
+ export function dueForRotation(entries: readonly SecretStatusEntry[]): number {
57
+ return entries.filter((entry) => entry.state === "readable" && entry.status.overdue === true).length;
58
+ }
59
+
60
+ /** How many declared secrets are past the cadence their registry entry declares. */
61
+ export async function countSecretsDueForRotation(
62
+ db: SecretsStatusDb,
63
+ registry: SecretRegistry,
64
+ now?: Date,
65
+ ): Promise<number> {
66
+ return dueForRotation(await readSecretStatus(db, registry, now ? { now } : {}));
67
+ }
68
+
69
+ /**
70
+ * The capability's health summary, over the registry it actually composed.
71
+ *
72
+ * A thunk for the same reason the status routes take one: the set worth reporting is every capability's
73
+ * combined registry, and that only exists after `compose` has run.
74
+ */
75
+ export function secretsHealth(registry: () => SecretRegistry): CapabilityHealth {
76
+ return defineCapabilityHealth({
77
+ keys: [
78
+ {
79
+ key: SECRETS_DUE_FOR_ROTATION,
80
+ kind: "count",
81
+ states: null,
82
+ // The scope the status read is already behind. A count is a smaller disclosure than the listing
83
+ // it summarizes, but it is a disclosure of the same thing — which credentials are stale is a map
84
+ // of where to push — so an adopter who withheld the listing withholds the number with it.
85
+ scope: SECRETS_STATUS_READ_SCOPE,
86
+ cost: "indexed",
87
+ summary: "Secrets past the rotation cadence their registry entry declares.",
88
+ },
89
+ ],
90
+ read: async (c) => ({
91
+ [SECRETS_DUE_FOR_ROTATION]: await countSecretsDueForRotation(secretsStatusDatabase(c), registry()),
92
+ }),
93
+ });
94
+ }