@pithy-sh/core 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 (134) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +47 -0
  3. package/pithy.manifest.json +74 -0
  4. package/src/address/address.ts +83 -0
  5. package/src/audit/auditEvent.ts +130 -0
  6. package/src/audit/recorder.ts +22 -0
  7. package/src/capability/bindings.ts +196 -0
  8. package/src/capability/capability.ts +555 -0
  9. package/src/capability/client.ts +136 -0
  10. package/src/capability/compose.ts +76 -0
  11. package/src/capability/composition.ts +98 -0
  12. package/src/capability/config.ts +19 -0
  13. package/src/capability/devSecret.ts +42 -0
  14. package/src/capability/manifest.ts +580 -0
  15. package/src/capability/secretOrigin.ts +253 -0
  16. package/src/capability/settings.ts +155 -0
  17. package/src/capability/validateBindings.ts +43 -0
  18. package/src/capability/vanishingKey.ts +92 -0
  19. package/src/cloudflare-test.d.ts +20 -0
  20. package/src/controlPlane/audit/actions.ts +81 -0
  21. package/src/controlPlane/capability.ts +228 -0
  22. package/src/controlPlane/config/config.ts +195 -0
  23. package/src/controlPlane/context.ts +63 -0
  24. package/src/controlPlane/data/connection.ts +123 -0
  25. package/src/controlPlane/data/keyLifecycle.ts +159 -0
  26. package/src/controlPlane/data/replay.ts +39 -0
  27. package/src/controlPlane/data/tables.ts +51 -0
  28. package/src/controlPlane/discovery/adminRoute.ts +250 -0
  29. package/src/controlPlane/discovery/configuration.ts +280 -0
  30. package/src/controlPlane/discovery/drift.ts +100 -0
  31. package/src/controlPlane/discovery/health.ts +213 -0
  32. package/src/controlPlane/discovery/healthSummary.ts +486 -0
  33. package/src/controlPlane/error/errors.ts +125 -0
  34. package/src/controlPlane/http/cors.ts +244 -0
  35. package/src/controlPlane/http/guard.ts +223 -0
  36. package/src/controlPlane/http/handlers.ts +346 -0
  37. package/src/controlPlane/http/responses.ts +92 -0
  38. package/src/controlPlane/http/routes.ts +115 -0
  39. package/src/controlPlane/http/schemas.ts +70 -0
  40. package/src/controlPlane/http/verify.ts +198 -0
  41. package/src/controlPlane/migrations/0001_init.ts +105 -0
  42. package/src/controlPlane/replay/d1Guard.ts +87 -0
  43. package/src/controlPlane/replay/guard.ts +55 -0
  44. package/src/controlPlane/replay/kvGuard.ts +143 -0
  45. package/src/controlPlane/scope/scope.ts +102 -0
  46. package/src/controlPlane/token/base64url.ts +65 -0
  47. package/src/controlPlane/token/claims.ts +151 -0
  48. package/src/controlPlane/token/digest.ts +63 -0
  49. package/src/controlPlane/token/jws.ts +112 -0
  50. package/src/controlPlane/token/mint.ts +93 -0
  51. package/src/controlPlane/wire.ts +138 -0
  52. package/src/createBackend.ts +292 -0
  53. package/src/createEntrypoint.ts +125 -0
  54. package/src/data/boundParameters.ts +197 -0
  55. package/src/data/codecs.ts +160 -0
  56. package/src/data/cursor.ts +127 -0
  57. package/src/data/databases.ts +84 -0
  58. package/src/data/db.ts +53 -0
  59. package/src/data/withD1Retry.ts +176 -0
  60. package/src/entitlement/entitlement.ts +191 -0
  61. package/src/entitlement/gateScan.ts +107 -0
  62. package/src/entitlement/require.ts +199 -0
  63. package/src/env/ambient.ts +67 -0
  64. package/src/env/ci.ts +43 -0
  65. package/src/env/stem.ts +34 -0
  66. package/src/error/cause.ts +208 -0
  67. package/src/error/client.ts +43 -0
  68. package/src/error/extend.ts +135 -0
  69. package/src/error/http.ts +92 -0
  70. package/src/error/payload.ts +2195 -0
  71. package/src/error/pithyError.ts +281 -0
  72. package/src/error/terminal.ts +36 -0
  73. package/src/http/authContext.ts +29 -0
  74. package/src/http/routeContract.ts +115 -0
  75. package/src/http/sameOrigin.ts +67 -0
  76. package/src/http/signedWebhook.ts +415 -0
  77. package/src/http/validation.ts +41 -0
  78. package/src/http/verification.ts +25 -0
  79. package/src/i18n/acceptLanguage.ts +70 -0
  80. package/src/i18n/catalog.ts +113 -0
  81. package/src/i18n/locale.ts +153 -0
  82. package/src/i18n/localeMarker.ts +116 -0
  83. package/src/i18n/match.ts +111 -0
  84. package/src/i18n/registry.ts +78 -0
  85. package/src/i18n/translator.ts +168 -0
  86. package/src/index.ts +116 -0
  87. package/src/kv/kv.ts +437 -0
  88. package/src/kv/namespaces.ts +102 -0
  89. package/src/logger/local.ts +91 -0
  90. package/src/logger/logger.ts +145 -0
  91. package/src/logger/record.ts +83 -0
  92. package/src/logger/worker.ts +117 -0
  93. package/src/migrations/batch.ts +226 -0
  94. package/src/migrations/bookkeeping.ts +85 -0
  95. package/src/migrations/owner.ts +166 -0
  96. package/src/migrations/registry.ts +121 -0
  97. package/src/migrations/runner.ts +295 -0
  98. package/src/naming/domains.ts +194 -0
  99. package/src/naming/environment.ts +224 -0
  100. package/src/naming/feature.ts +162 -0
  101. package/src/naming/limits.ts +223 -0
  102. package/src/naming/provisionScope.ts +143 -0
  103. package/src/naming/resource.ts +266 -0
  104. package/src/naming/resourceNames.ts +174 -0
  105. package/src/naming/segment.ts +32 -0
  106. package/src/projection/asRead.ts +211 -0
  107. package/src/projection/published.ts +210 -0
  108. package/src/schema/describedness.ts +250 -0
  109. package/src/seed/compose.ts +94 -0
  110. package/src/seed/devLogin.ts +67 -0
  111. package/src/seed/exampleIdentities.ts +43 -0
  112. package/src/seed/metadata.ts +27 -0
  113. package/src/seed/seed.ts +306 -0
  114. package/src/seed/seededRows.ts +41 -0
  115. package/src/seed/writeD1.ts +103 -0
  116. package/src/seed/writeKv.ts +99 -0
  117. package/src/semver/semver.ts +156 -0
  118. package/src/text/comments.ts +165 -0
  119. package/src/version.generated.ts +16 -0
  120. package/src/worker/health.ts +42 -0
  121. package/src/worker/identity.ts +243 -0
  122. package/src/workflow/bindings.ts +58 -0
  123. package/src/workflow/dispatch.ts +240 -0
  124. package/src/workflow/dispatchRoute.ts +184 -0
  125. package/src/workflow/faults.ts +219 -0
  126. package/src/workflow/host.ts +307 -0
  127. package/src/workflow/hostEntry.ts +71 -0
  128. package/src/workflow/hostEnv.ts +258 -0
  129. package/src/workflow/loopback.ts +149 -0
  130. package/src/workflow/naming.ts +170 -0
  131. package/src/workflow/register.ts +44 -0
  132. package/src/workflow/schemas.ts +84 -0
  133. package/src/workflow/spec.ts +86 -0
  134. package/src/workflow/stepMessage.ts +160 -0
@@ -0,0 +1,253 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { manifestRecord } from "./vanishingKey";
6
+
7
+ /**
8
+ * How a secret's value comes to exist, and how it is replaced.
9
+ *
10
+ * A secret the kit cannot mint leaves an adopter on their own. The registry has always known which
11
+ * those are — no `devValue` — and nothing about how a human is supposed to get one, so every client
12
+ * that wanted to help had to carry its own table of names, and a capability added tomorrow silently
13
+ * fell off it.
14
+ *
15
+ * It lives in core, and in its own module, for the reason `devSecret.ts` does: both ends need it and
16
+ * neither can import the other. The owning capability declares it on its `@pithy-sh/secrets` registry
17
+ * entry; a client — `pithy doctor`, `pithy secrets ls`, a management dashboard — reads the same
18
+ * declaration off `pithy.manifest.json`, which it must, since `pithy add` wires a capability without
19
+ * ever executing it.
20
+ *
21
+ * **A function cannot cross into the manifest. A tag can.** So everything here is data, and the code
22
+ * that knows what `random` means stays in the CLI — exactly as it already does for `devValue`.
23
+ */
24
+
25
+ export const SecretIssuer = z
26
+ .enum([
27
+ "project",
28
+ "cloudflare",
29
+ "apple",
30
+ "google",
31
+ "github",
32
+ "facebook",
33
+ "microsoft",
34
+ "stripe",
35
+ "lemonSqueezy",
36
+ "paddle",
37
+ "gitlab",
38
+ "atlassian",
39
+ "other",
40
+ ])
41
+ .describe(
42
+ "Who issues this value. `project` means it is arbitrary and ours to make. Every other member is a third party whose console or API is the only source: payments rails, auth's social providers, `cloudflare` for R2, Turnstile and API tokens, and the repository hosts an adopter may rotate CI credentials against. `other` is the floor an unrecognized issuer lands on, so a capability added tomorrow is renderable by a client built today.",
43
+ );
44
+ export type SecretIssuer = z.output<typeof SecretIssuer>;
45
+
46
+ /**
47
+ * The issuer **as a field**, with its one degradation rule attached.
48
+ *
49
+ * **An unrecognized issuer degrades to `other` rather than failing.** A manifest is read from
50
+ * `node_modules`, so it can be newer than the client reading it — and a client that threw on an issuer
51
+ * it had not been built against would blank a pane over a name it did not need to understand. `other`
52
+ * is the honest answer: *somebody issues this, and I cannot help you with them.*
53
+ *
54
+ * A field holds one value, so rewriting it destroys nothing. {@link IssuerKey} is the same name in the
55
+ * one position where that is false.
56
+ */
57
+ const IssuerName = SecretIssuer.catch("other");
58
+
59
+ /** The issuer as a field, with the sentence that site needs. The rule comes from {@link IssuerName}. */
60
+ function issuedBy(description: string): z.ZodType<SecretIssuer, unknown> {
61
+ return IssuerName.describe(description);
62
+ }
63
+
64
+ /** The characters an issuer's name may hold, where that name is a record key rendered into a heading. */
65
+ const ISSUER_KEY = /^[A-Za-z][A-Za-z0-9_.-]*$/;
66
+
67
+ /**
68
+ * The issuer **as a record key**, where the field's degradation rule would destroy data.
69
+ *
70
+ * **A key cannot degrade the way a field does, and the attempt to share one rule between them was the
71
+ * defect.** `SecretIssuer.catch("other")` rewrites what it parses. On a field that is a rename; on a key
72
+ * it is a *merge*, and the merge is silent. Keyed by the field's rule, `{ vercel: ["a"], netlify: ["b"] }`
73
+ * parsed to `{ other: ["b"] }` — vercel's requirement gone, the parse successful, nothing anywhere
74
+ * reporting it. Requirements lost without a word are worse than a manifest that refuses to parse, because
75
+ * a refusal is at least visible.
76
+ *
77
+ * So a key keeps the name it was written with. What a client loses by that is nothing it had: a reader
78
+ * that branches on the closed set runs `SecretIssuer.safeParse(key)` at the point of use and falls back to
79
+ * `other` for rendering, which is the same answer the schema used to force on it — except that the scopes
80
+ * are still there beside it, and a second unknown issuer is still a second entry.
81
+ *
82
+ * A preserved key reaches a rendered command and a rendered heading unchanged, so it is held to the shape
83
+ * of a name. That constraint is not a degradation path: an issuer a client has never heard of is still
84
+ * spelled like an issuer, and anything that is not is hostile data rather than a newer manifest.
85
+ */
86
+ const IssuerKey = z
87
+ .string()
88
+ .regex(ISSUER_KEY)
89
+ .describe(
90
+ "An issuer, as the key of a record. Preserved exactly as written — unlike the field, which degrades to `other` — because two unrecognized issuers rewritten to one key overwrite each other's requirements with no error. A client that only knows the closed set narrows this with `SecretIssuer.safeParse` where it renders.",
91
+ );
92
+
93
+ /** The characters a secret name may hold. Excludes `/`, which separates a keyspace from a member key. */
94
+ const SECRET_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
95
+
96
+ /** What a helper must supply, per item. Permission groups and scope names, and nothing that reads as code. */
97
+ const HELPER_NEED = /^[A-Za-z0-9][A-Za-z0-9_.:@/-]*$/;
98
+
99
+ /**
100
+ * A documentation link, held to `https:` and nothing else.
101
+ *
102
+ * `z.url()` on its own is not enough here: it accepts `javascript:alert(1)`, and it accepts `http:`. A
103
+ * manifest is third-party data read out of `node_modules`, and this is the one field in it whose whole
104
+ * purpose is to be turned into an anchor an operator clicks. So the scheme is constrained at the schema,
105
+ * where a client cannot forget to.
106
+ */
107
+ const DOCUMENTATION_URL = /^https:\/\//;
108
+
109
+ /** The documentation field, wherever it appears: a real URL, and an `https:` one. */
110
+ function documentedAt(description: string) {
111
+ return z.url().regex(DOCUMENTATION_URL).describe(description);
112
+ }
113
+
114
+ export const SecretRecipe = z
115
+ .discriminatedUnion("kind", [
116
+ z
117
+ .object({
118
+ kind: z
119
+ .literal("random")
120
+ .describe(
121
+ "Random bytes rendered as a string. Any value works, because nothing outside the project reads it.",
122
+ ),
123
+ bytes: z
124
+ .int()
125
+ .positive()
126
+ .describe(
127
+ "Entropy before encoding. Stated because 32 and 16 are not interchangeable — a value sized for AES-256 that arrives half that long fails at decrypt, not at write, and by then it is stored.",
128
+ ),
129
+ encoding: z
130
+ .enum(["base64url", "base64", "hex"])
131
+ .describe(
132
+ "How the bytes are rendered. The kit mints `base64url`, unpadded, so a value survives a `.dev.vars` line, a shell export and a URL without quoting.",
133
+ ),
134
+ })
135
+ .describe("A minted value the kit makes from entropy: how many bytes, and how they are rendered."),
136
+ z
137
+ .object({
138
+ kind: z
139
+ .literal("encryptionConfig")
140
+ .describe(
141
+ "An `EncryptionConfig` — `currentVersion`, a `versions` map, `lastRotatedAt` — minted by `initialMasterKeyConfig`. `SECRETS_ENCRYPTION_KEYS` is the one, and the reason this is a union rather than an enum: it is minted, and a random string is not one of these. It states no `bytes` or `encoding` because the structure states its own.",
142
+ ),
143
+ })
144
+ .describe(
145
+ "A minted structure rather than a string. The tag is the whole recipe; nothing about it is configurable.",
146
+ ),
147
+ ])
148
+ .describe(
149
+ "What produces a minted value. A tag the CLI resolves to code, never the code itself, because this crosses into the manifest.",
150
+ );
151
+ export type SecretRecipe = z.output<typeof SecretRecipe>;
152
+
153
+ export const SecretOrigin = z
154
+ .discriminatedUnion("kind", [
155
+ z
156
+ .object({
157
+ kind: z
158
+ .literal("minted")
159
+ .describe("The kit produces this value. Nothing outside the project has to agree with it."),
160
+ recipe: SecretRecipe.describe("What produces it."),
161
+ })
162
+ .describe("Minted by the kit. The recipe says how, and no third party has to agree with the result."),
163
+ z
164
+ .object({
165
+ kind: z
166
+ .literal("helped")
167
+ .describe(
168
+ "The kit cannot produce it, but knows enough to compose the command that does — a Cloudflare API token, whose permission groups an operator should not have to look up.",
169
+ ),
170
+ issuer: issuedBy("Whose console or API issues it."),
171
+ needs: manifestRecord(z.partialRecord(IssuerKey, z.array(z.string().regex(HELPER_NEED)))).describe(
172
+ "What a helper must supply, keyed by issuer. Cloudflare's key is its permission groups. Keyed rather than flat so a second issuer does not widen a shape every consumer must handle. An unrecognized key is kept verbatim rather than degraded — see `IssuerKey`: rewriting two of them onto `other` loses one issuer's requirements silently. Wrapped in `manifestRecord` so the key rule is given every key the manifest wrote, including the one a parse would otherwise drop before the rule ran.",
173
+ ),
174
+ documentation: documentedAt(
175
+ "Where the command's arguments come from, for an operator who would rather check them by hand.",
176
+ ).optional(),
177
+ })
178
+ .describe("Issued elsewhere, but the kit knows enough to compose the command that asks for it."),
179
+ z
180
+ .object({
181
+ kind: z
182
+ .literal("obtained")
183
+ .describe(
184
+ "A human gets it from a third party and there is no command. An OAuth client secret is the case, and always will be.",
185
+ ),
186
+ issuer: issuedBy("Whose console issues it."),
187
+ documentation: documentedAt(
188
+ "Where a human goes. The specific settings page, not a product homepage — the point is to end a search, not to start one.",
189
+ ),
190
+ })
191
+ .describe("Fetched by a human from a third party. There is no command, and the link is the whole help."),
192
+ ])
193
+ .describe(
194
+ "How this secret's value first comes to exist. Closed, so a consumer branches rather than parses, and so an unhandled member is a type error rather than a blank screen.",
195
+ );
196
+ export type SecretOrigin = z.output<typeof SecretOrigin>;
197
+
198
+ export const SecretRotation = z
199
+ .discriminatedUnion("kind", [
200
+ z
201
+ .object({
202
+ kind: z
203
+ .literal("local")
204
+ .describe(
205
+ 'Replaced by producing a new value the same way it was minted. Every `origin.kind: "minted"` secret, and nothing else — if the kit can make one it can make another.',
206
+ ),
207
+ })
208
+ .describe("Rotated by the kit, on its own. The tag carries everything; there is nobody to call."),
209
+ z
210
+ .object({
211
+ kind: z
212
+ .literal("provider")
213
+ .describe(
214
+ "Replaced by calling the issuer, which returns the new value. The kit cannot invent this call; the capability or the adopter supplies it, and it changes state in somebody else's system.",
215
+ ),
216
+ issuer: issuedBy("Who is called."),
217
+ documentation: documentedAt(
218
+ "What the call does, for an operator deciding whether to trust it with a live credential.",
219
+ ).optional(),
220
+ })
221
+ .describe("Rotated by calling the issuer. The new value comes back from somebody else's system."),
222
+ z
223
+ .object({
224
+ kind: z
225
+ .literal("manual")
226
+ .describe(
227
+ "A human, in a console. No API returns the new value — a GitHub or Google OAuth client secret. This is a real answer, not a missing one.",
228
+ ),
229
+ issuer: issuedBy("Whose console."),
230
+ documentation: documentedAt("The page where it is done."),
231
+ })
232
+ .describe("Rotated by a human, in a console. A real answer, and the page is the whole help."),
233
+ ])
234
+ .describe(
235
+ "How this secret is replaced, declared separately from `origin` because neither follows from the other: a GitLab token is `obtained` and rotates by `provider`, while an OAuth client secret is `obtained` and rotates only by `manual`. Declared per secret, never per issuer — some Cloudflare secrets roll and some do not.",
236
+ );
237
+ export type SecretRotation = z.output<typeof SecretRotation>;
238
+
239
+ export const DeclaredSecret = z
240
+ .object({
241
+ name: z
242
+ .string()
243
+ .regex(SECRET_NAME)
244
+ .describe(
245
+ "The registry name the secret is declared under. Constrained, because a manifest is third-party data read from `node_modules` and this name reaches a rendered command.",
246
+ ),
247
+ origin: SecretOrigin.describe("How its value first comes to exist. Must match the registry entry's `origin`."),
248
+ rotation: SecretRotation.describe("How it is replaced. Must match the registry entry's `rotation`."),
249
+ })
250
+ .describe(
251
+ "One declared secret, as a manifest carries it: its registry name and both axes. Both, always — an origin without a rotation answers half of what an operator holding an overdue secret needs to know. The manifest's projection of the registry entry, and each capability's own tests assert the two agree.",
252
+ );
253
+ export type DeclaredSecret = z.output<typeof DeclaredSecret>;
@@ -0,0 +1,155 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import type { HostEnvReport } from "../workflow/hostEnv";
6
+ import { hostEnvProviderSentence } from "../workflow/hostEnv";
7
+
8
+ /**
9
+ * How a capability checks that its own settings **work** — the seam `pithy doctor` runs (#411).
10
+ *
11
+ * Everything doctor asked before this was a question about *presence*: is the option key written, is the
12
+ * binding declared, is the ledger level. So a project could be entirely green while `fromAddress` named a
13
+ * domain nobody onboarded, the signing key was never created, `BASE_URL` was staging's URL in production's
14
+ * config, and no mail arrived. Presence is not the same question as correctness, and only the capability
15
+ * knows the second one.
16
+ *
17
+ * ## It hangs off the capability, never off the manifest
18
+ *
19
+ * The declaration sits on the {@link import("./capability").Capability} object, beside `health`, because
20
+ * `pithy.manifest.json` looks like the obvious signal and is the wrong one: `@pithy-sh/matchmaking` and
21
+ * `@pithy-sh/rating` are published capability packages that ship no manifest, and a manifest-keyed rule
22
+ * skips both in silence. The CLI already holds every composed instance (`project/workerScope.ts`), so
23
+ * discovery costs it nothing and misses nothing.
24
+ *
25
+ * A capability with nothing worth checking declares nothing. That is ordinary, silent, and not a fault.
26
+ *
27
+ * ## Two tiers, because they cost different things
28
+ *
29
+ * - **local** — does the value parse, is it the right shape, is it right for this environment. Free,
30
+ * offline, and always run. It validates through the very Zod object the capability's host Worker
31
+ * validates at boot ({@link hostEnvFindings} is the bridge), so the check an operator runs and the
32
+ * check the host runs cannot come to two answers.
33
+ * - **account** — is the domain a zone here, does the secret exist, is the database there. One Cloudflare
34
+ * call, so it is opt-in and it is skipped whenever the account cannot be reached.
35
+ *
36
+ * Both are faults. A local finding fails `pithy doctor`'s exit; an account finding does too, **but only
37
+ * when the account was reached** — an unreachable account is reported as *skipped*, never as a pass, and
38
+ * gates nothing. Three answers, and a boolean can hold two of them.
39
+ *
40
+ * ## Nothing here writes
41
+ *
42
+ * A check reports. `pithy upgrade`, `pithy <capability> provision` and the commands each finding names are
43
+ * what change anything, which is why every finding carries the action that resolves it.
44
+ */
45
+
46
+ /** One setting that does not work, and the one thing an operator does about it. */
47
+ export const SettingsFinding = z
48
+ .object({
49
+ setting: z
50
+ .string()
51
+ .min(1)
52
+ .describe(
53
+ "What is wrong — the env field, config key, or account resource, spelled as the operator would find it. It leads the line, so it is the name they search for.",
54
+ ),
55
+ environment: z
56
+ .string()
57
+ .min(1)
58
+ .nullable()
59
+ .describe(
60
+ "The environment this is about, or `null` where the setting is the same in all of them. A per-environment fault is reported once per environment, because that is how many edits it takes.",
61
+ ),
62
+ problem: z
63
+ .string()
64
+ .min(1)
65
+ .describe("Why it does not work, in one sentence. The problem line, in the same voice every PithyError uses."),
66
+ action: z
67
+ .string()
68
+ .min(1)
69
+ .describe(
70
+ "What resolves it: the `pithy` command, the `pithy.config.ts` key, or the one-time account action. Never optional — a finding nobody can act on is a complaint.",
71
+ ),
72
+ })
73
+ .describe("One capability setting that does not work, with the action that resolves it.");
74
+ export type SettingsFinding = z.infer<typeof SettingsFinding>;
75
+
76
+ /** One environment the project declares, and what this Worker's own config says about it. */
77
+ export interface SettingsEnvironment {
78
+ /** The environment id — `dev`, `staging`, `prod`. Verbatim, never `production`. */
79
+ name: string;
80
+ /**
81
+ * The origin this Worker answers on in that environment, or `null` where its config names none.
82
+ *
83
+ * Supplied by the CLI rather than resolved by the capability: which hostname an environment serves is
84
+ * the adopter's declaration to make, and `project/domains.ts` is the one reader of it.
85
+ */
86
+ origin: string | null;
87
+ }
88
+
89
+ /** What a check is told about the project it is checking. Both tiers get it; the account tier gets more. */
90
+ export interface SettingsCheckContext {
91
+ /** The root config's `name` — the leading segment of every resource this project provisions. */
92
+ project: string;
93
+ /** The Worker whose `pithy.config.ts` composed this capability. */
94
+ worker: string;
95
+ /** Every environment the project declares, in declaration order. */
96
+ environments: readonly SettingsEnvironment[];
97
+ }
98
+
99
+ /**
100
+ * The account questions a check may ask — a closed vocabulary, on purpose.
101
+ *
102
+ * Closed for the reason `CapabilityHealth` is: handing a capability a raw Cloudflare client would make
103
+ * every capability a place a network call can be invented, and would put `@pithy-sh/cloudflare` in the
104
+ * dependency list of packages that need none of it. Three questions cover what the first checks ask; a
105
+ * fourth lands here, once, when a capability actually needs it.
106
+ *
107
+ * Every method may throw. A throw is the account failing to answer, and the runner reports that
108
+ * capability's account tier as unchecked rather than as a pass.
109
+ */
110
+ export interface SettingsAccountReader {
111
+ /** Every D1 database name this account holds. */
112
+ d1Databases(): Promise<readonly string[]>;
113
+ /** Whether this account holds a Cloudflare zone covering the hostname — the prerequisite for onboarding it. */
114
+ zone(hostname: string): Promise<boolean>;
115
+ /** Whether a declared secret has a value in that environment. The name is the registry's, not a binding's. */
116
+ secret(request: { name: string; environment: string }): Promise<boolean>;
117
+ }
118
+
119
+ /** What the account tier is told: everything the local tier gets, plus the account it may ask. */
120
+ export interface SettingsAccountContext extends SettingsCheckContext {
121
+ /** The account, as the closed set of questions above. */
122
+ account: SettingsAccountReader;
123
+ }
124
+
125
+ /**
126
+ * A capability's settings check, as it hangs off the capability.
127
+ *
128
+ * `local` is required — a capability that declares this seam declares at least the free half of it. A
129
+ * check that would only ever reach the account is a check that says nothing offline, and offline is where
130
+ * most of these faults are cheapest to find.
131
+ */
132
+ export interface CapabilitySettings {
133
+ /** The free half. Pure, offline, and always run. */
134
+ local(context: SettingsCheckContext): SettingsFinding[] | Promise<SettingsFinding[]>;
135
+ /** The half that costs a Cloudflare call. Omitted where a capability has nothing to ask the account. */
136
+ account?(context: SettingsAccountContext): Promise<SettingsFinding[]>;
137
+ }
138
+
139
+ /**
140
+ * Turn a host env's own parse report into findings — the bridge that makes "one schema, two readers" real.
141
+ *
142
+ * A capability's local tier builds the env its host *would* be handed, runs {@link
143
+ * import("../workflow/hostEnv").checkHostEnv} against its own declaration, and hands the report here. The
144
+ * problem line is Zod's own message and the action line is {@link hostEnvProviderSentence} — the same
145
+ * words the host writes into its log before it refuses to start. Two readers of one declaration, and no
146
+ * second wording to drift.
147
+ */
148
+ export function hostEnvFindings(report: HostEnvReport<unknown>, environment: string | null): SettingsFinding[] {
149
+ return report.problems.map((problem) => ({
150
+ setting: problem.field,
151
+ environment,
152
+ problem: problem.reason,
153
+ action: hostEnvProviderSentence(problem.provider),
154
+ }));
155
+ }
@@ -0,0 +1,43 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { InternalError } from "../error/pithyError";
5
+ import { type BindingSpec, isProvisionedBinding } from "./bindings";
6
+
7
+ /** How a missing binding is named in both halves of the error: `kv:SESSIONS`. */
8
+ function label(binding: BindingSpec): string {
9
+ return `${binding.type}:${binding.name}`;
10
+ }
11
+
12
+ /**
13
+ * What to do about the bindings that are missing — and there are two answers, not one.
14
+ *
15
+ * A `kv` or `d1` binding is a line the adopter adds to `wrangler.jsonc`. A `secret`, `workflow`, or
16
+ * `vectorize` binding is not: its entry carries a provisioned value, so the old single line
17
+ * ("Add the binding(s) to wrangler.jsonc, then redeploy.") sent anyone missing a Secrets Store entry
18
+ * to a file that entry never appears in. Each list names only its own bindings, so a mixed failure
19
+ * says which of them is which instead of leaving the reader to sort it out.
20
+ */
21
+ function bindingAction(missing: BindingSpec[]): string {
22
+ const written = missing.filter((binding) => !isProvisionedBinding(binding.type)).map(label);
23
+ const provisioned = missing.filter((binding) => isProvisionedBinding(binding.type)).map(label);
24
+ const sentences: string[] = [];
25
+ if (written.length > 0) sentences.push(`Add ${written.join(", ")} to wrangler.jsonc, then redeploy.`);
26
+ if (provisioned.length > 0) {
27
+ sentences.push(`Provision ${provisioned.join(", ")} — a provision command creates those, or .dev.vars in dev.`);
28
+ }
29
+ return sentences.join(" ");
30
+ }
31
+
32
+ /** Throw a typed error if any non-optional binding is absent from `env`. */
33
+ export function validateBindings(env: Record<string, unknown>, required: BindingSpec[]): void {
34
+ const missing = required.filter((b) => !b.optional && env[b.name] == null);
35
+ if (missing.length > 0) {
36
+ // A missing binding is a Worker misconfiguration, not a client-facing fault. The names are
37
+ // config keys, not secrets, so they ride in the public message; this surfaces at startup.
38
+ throw new InternalError({
39
+ message: `Missing required bindings: ${missing.map(label).join(", ")}`,
40
+ action: bindingAction(missing),
41
+ });
42
+ }
43
+ }
@@ -0,0 +1,92 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+
6
+ /**
7
+ * The one key a parsed object loses on the way through a schema, and the guard that refuses it.
8
+ *
9
+ * `JSON.parse` gives `__proto__` an own property, because it must — the alternative is JSON that can set
10
+ * the prototype of every object built from it. Zod then skips that key while projecting an object or a
11
+ * record, for exactly the same reason: assigning it onto the `{}` it builds into would run the setter on
12
+ * `Object.prototype` instead of creating a property. Neither decision is wrong. Together they mean a key
13
+ * enters the parse, matches no rule, raises no issue, and is not in the result.
14
+ *
15
+ * That is the shape worth naming: **not a value rewritten, a key that was never reported at all.** A key
16
+ * schema would have refused `__proto__` on its first character; it never runs, because the skip happens
17
+ * before the key type does. So this does not add a rule. It gets the rule that is already written back in
18
+ * front of the data.
19
+ *
20
+ * **`Object.create(null)` does not close this, and #331 left that question open.** The idea is sound on
21
+ * its face — a null-prototype target has no `__proto__` setter to run, so the assignment would create a
22
+ * property and the key would survive — and it is wrong here for a reason that is worth writing down once.
23
+ * Zod's skip is a literal `key === "__proto__"` comparison made *before* any assignment
24
+ * (`zod/v4/core/schemas` at the object and record branches), so the prototype of the target has no bearing
25
+ * on it, and neither does the prototype of the input: handing `z.record` a value built with
26
+ * `Object.assign(Object.create(null), parsed)` still returns without the key. Measured, not assumed. The
27
+ * class cannot be closed by changing what the object is; it can only be closed in front of the schema,
28
+ * which is what this is.
29
+ *
30
+ * **Refuse, not degrade.** The surrounding design degrades an unrecognized *issuer* to `other` so a client
31
+ * built today can read a manifest written tomorrow, and that is right for a value. It is wrong here twice
32
+ * over. `__proto__` is not a name a future issuer will be called; it is a mistake or an attack. And
33
+ * degrading a key is a merge — the defect the issuer-key rule was written to close, where two unknown keys
34
+ * rewritten to one overwrite each other's requirements in silence. Refusing is the only answer that leaves
35
+ * nothing to detect after the fact: a payload that will not parse is reported by every client that reads
36
+ * it, and `pithy doctor` already names the package that shipped it.
37
+ *
38
+ * Applied at the schema, not at a call site, because the exposure belongs to the *shape* — any record read
39
+ * from outside this process has it — and a rule that lives at one call site is how this codebase has
40
+ * repeatedly fixed one field and left its siblings. `vanishingKey.test.ts` walks each guarded root and
41
+ * attacks every record it finds, so a record added later without this guard fails on the commit that adds
42
+ * it.
43
+ */
44
+ const VANISHING_KEY = "__proto__";
45
+
46
+ /**
47
+ * Whether a value states no key that would vanish during parsing.
48
+ *
49
+ * Non-objects pass: they hold no keys, and the schema behind this guard is what refuses them for being the
50
+ * wrong type. `Object.hasOwn` and not `in`, because every object inherits `__proto__` and only an own one
51
+ * is data somebody wrote.
52
+ */
53
+ export function statesNoVanishingKey(value: unknown): boolean {
54
+ if (typeof value !== "object" || value === null) return true;
55
+ return !Object.hasOwn(value, VANISHING_KEY);
56
+ }
57
+
58
+ /**
59
+ * A schema held to keys that survive being read.
60
+ *
61
+ * Wraps rather than replaces: the schema behind it keeps its own key and value rules, and this only
62
+ * guarantees they are given every key the payload wrote. See {@link VANISHING_KEY} for why that is not
63
+ * already true.
64
+ *
65
+ * `subject` names what wrote the record, because the reader of the error is the person who has to go and
66
+ * change it — a capability author for a manifest, an adopter for a projection.
67
+ *
68
+ * Typed on the wrapped schema's own input rather than on `unknown`, so a guard is transparent to whatever
69
+ * it is composed into. Widening every wrapped schema to `unknown` would make the guard's altitude a
70
+ * typing question — it could only go where nothing downstream needed the input type — and altitude is the
71
+ * one thing about this guard that should be decided on the exposure.
72
+ */
73
+ export function refusesVanishingKey<Output, Input>(
74
+ schema: z.ZodType<Output, Input>,
75
+ subject: string,
76
+ ): z.ZodPipe<z.ZodCustom<Input, Input>, z.ZodType<Output, Input>> {
77
+ return z
78
+ .custom<Input>(statesNoVanishingKey, {
79
+ error: `${subject} may not state a "${VANISHING_KEY}" key. It is the one name a parsed object loses without a word, so whatever it keyed would be gone with it.`,
80
+ })
81
+ .pipe(schema);
82
+ }
83
+
84
+ /**
85
+ * A record a manifest may state. The manifest's wording of {@link refusesVanishingKey}, kept in one place
86
+ * so its two call sites cannot drift into two sentences for one rule.
87
+ */
88
+ export function manifestRecord<Output, Input>(
89
+ record: z.ZodType<Output, Input>,
90
+ ): z.ZodPipe<z.ZodCustom<Input, Input>, z.ZodType<Output, Input>> {
91
+ return refusesVanishingKey(record, "A manifest record");
92
+ }
@@ -0,0 +1,20 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /// <reference types="@cloudflare/vitest-plugin/types" />
5
+
6
+ // Bindings the Workers-runtime test project provides to `*.workers.test.ts`,
7
+ // matching the Miniflare config in `vitest.workers.config.ts`: D1 `DB` and
8
+ // `ANALYTICS` (a second database, to exercise the multi-database registry), KV
9
+ // `SESSIONS`, and KV `CONTROL_PLANE` (the control-plane seam's replay set, which
10
+ // gets its own namespace rather than sharing sessions).
11
+ // `cloudflare:test` types its `env` as `Cloudflare.Env`, so the test bindings are
12
+ // declared by augmenting that interface.
13
+ declare namespace Cloudflare {
14
+ interface Env {
15
+ DB: D1Database;
16
+ ANALYTICS: D1Database;
17
+ SESSIONS: KVNamespace;
18
+ CONTROL_PLANE: KVNamespace;
19
+ }
20
+ }
@@ -0,0 +1,81 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { AuditEventInput } from "../../audit/auditEvent";
5
+ import type { AuditEmit } from "../../audit/recorder";
6
+ import type { Logger } from "../../logger/logger";
7
+
8
+ /**
9
+ * The audit action codes the `control-plane` seam emits, as `domain/reason` strings under the
10
+ * `controlplane` domain.
11
+ *
12
+ * **The domain is one word on purpose.** `AuditAction`'s pattern allows no hyphen, so
13
+ * `control-plane/call_allowed` is not a valid code at all. The route-strategy literal stays
14
+ * `control-plane`; every namespace token — capability, migrations, table prefix, error codes, these
15
+ * actions — is `controlplane`. The audit *actor kind* is the hyphenated `"control-plane"`, because
16
+ * that is an enum member and not a namespace. (None of this is Cloudflare's control plane; that is the
17
+ * outbound provisioning API behind `@pithy-sh/cloudflare`.)
18
+ *
19
+ * These are declared as a plain `as const` map rather than through `@pithy-sh/audit`'s
20
+ * `defineAuditActions`, because audit already depends on core — importing it back would be a cycle.
21
+ * `actions.test.ts` asserts every value against core's `AuditAction` pattern instead, so the map is
22
+ * held to the same contract the helper would have enforced.
23
+ *
24
+ * Emission goes through core's `emit` seam (`c.var.emit`), a no-op when the audit capability is
25
+ * absent, so the seam never imports audit at runtime either (principle 4).
26
+ *
27
+ * **Four of these are emitted by a route; three are emitted by the CLI** (#294). The connection
28
+ * lifecycle — registered, updated, removed — is written by `pithy dashboard` opening the adopter's D1
29
+ * directly, because those operations never reach their Worker: a first connect has no key to sign with
30
+ * and the Worker may not be deployed, an update re-points an address the Worker never reads, and a
31
+ * disconnect withdraws trust, which must not depend on a working connection (docs/CONTROL-PLANE.md §15).
32
+ * So there is no route in a position to record them, and the write records itself instead — in the same
33
+ * D1, from `connectionRegistry`. The `actorType` is what tells the two writers apart: a route records
34
+ * `control-plane`, meaning the management client called in and proved it, while a CLI-side event carries
35
+ * the adopter's own operator and no session at all.
36
+ *
37
+ * A declared code that nothing emits is a surface the trail promises and never delivers, which is what
38
+ * these three were until #294. `packages/cli/src/ci/auditActions.test.ts` fails the build on one.
39
+ */
40
+ export const ControlPlaneAuditActions = {
41
+ /** A verified, in-scope management call was admitted. The trail of what the dashboard actually did. */
42
+ callAllowed: "controlplane/call_allowed",
43
+ /** A management call was refused — no connection, bad credential, or an ungranted scope. Outcome `denied`. */
44
+ callDenied: "controlplane/call_denied",
45
+ /** A new public key was registered on a connection, opening a rotation overlap. */
46
+ keyRegistered: "controlplane/key_registered",
47
+ /** A superseded public key was given an end date and stopped being accepted. */
48
+ keyExpired: "controlplane/key_expired",
49
+ /** A management client was connected to this environment — a new connection, or one started over. */
50
+ connectionRegistered: "controlplane/connection_registered",
51
+ /** An existing connection changed — its granted scopes, the Worker URL it points at, or the keys it trusts. */
52
+ connectionUpdated: "controlplane/connection_updated",
53
+ /** A connection was removed. The seam returns to its shipped state: connected to nothing, denying everything. */
54
+ connectionRemoved: "controlplane/connection_removed",
55
+ } as const;
56
+
57
+ /** One of the control-plane audit action codes. */
58
+ export type ControlPlaneAuditAction = (typeof ControlPlaneAuditActions)[keyof typeof ControlPlaneAuditActions];
59
+
60
+ /**
61
+ * Emit an audit event and swallow any failure.
62
+ *
63
+ * **Every denial emits an event, and that is exactly why this cannot throw.** By the time the seam
64
+ * records a call, the security-relevant outcome is already decided — the 401 or 403 is going out
65
+ * whatever happens next. An audit write that threw would convert a correct denial into a 500: it would
66
+ * hand the caller a different response for a *failing* store than for a healthy one, and turn a
67
+ * transient D1 problem into an availability bug on the admin surface. The record is evidence of the
68
+ * decision, never part of making it.
69
+ *
70
+ * The real recorder is non-fatal by contract already ({@link AuditEmit}), so this is belt and braces —
71
+ * but the gate is a security boundary and does not get to assume its collaborators behave. The failure
72
+ * is logged when a logger is at hand, so a store that has stopped accepting writes is visible rather
73
+ * than silent.
74
+ */
75
+ export async function safeEmit(emit: AuditEmit, event: AuditEventInput, log?: Logger): Promise<void> {
76
+ try {
77
+ await emit(event);
78
+ } catch (error) {
79
+ log?.warn("control-plane audit event dropped", { action: event.action, error });
80
+ }
81
+ }