@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,580 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { ValidationError } from "../error/pithyError";
6
+ import { NAMESPACE_PATTERN } from "../migrations/registry";
7
+ import { BindingSpec } from "./bindings";
8
+ import { DevSecret } from "./devSecret";
9
+ import { DeclaredSecret } from "./secretOrigin";
10
+ import { manifestRecord } from "./vanishingKey";
11
+
12
+ /**
13
+ * Whether a string prints as a double-quoted literal Biome leaves exactly as written.
14
+ *
15
+ * Two sequences are out, and only two. A `"` inside the value makes `JSON.stringify` escape it, and
16
+ * Biome's formatter then reprints the whole literal in single quotes to avoid the escape —
17
+ * `"he said \"hi\""` becomes `'he said "hi"'`, a formatting diff on a file the adopter never opened.
18
+ * And `${` trips `lint/suspicious/noTemplateCurlyInString`, which is noise in the same place.
19
+ *
20
+ * An apostrophe stays legal: with no `"` to escape, Biome keeps the double quotes. So do a backslash, a
21
+ * tab, a newline, and any non-ASCII character — every one of these checked by running Biome over the
22
+ * rendered output, because the whole defect class this closes is guessing what Biome would print.
23
+ */
24
+ function isPrintableString(value: string): boolean {
25
+ return !value.includes('"') && !value.includes("${");
26
+ }
27
+
28
+ /** A number whose `String()` form is already the literal Biome prints: a plain decimal, no exponent. */
29
+ const PLAIN_DECIMAL = /^-?\d+(?:\.\d+)?$/;
30
+
31
+ /**
32
+ * Whether a number prints as a numeric literal Biome leaves exactly as written.
33
+ *
34
+ * `String(1e21)` is `"1e+21"`, which Biome normalizes to `1e21`. Rather than carry a copy of its numeric
35
+ * normalizer, a default is held to a plain decimal — which is every number a worked example has any
36
+ * business carrying. `NaN` and `Infinity` fall out of the same rule, and JSON cannot state either.
37
+ */
38
+ function isPrintableNumber(value: number): boolean {
39
+ return PLAIN_DECIMAL.test(String(value));
40
+ }
41
+
42
+ /** A string a manifest may state, and an object key it may use: one {@link renderConfigValue} can print. */
43
+ const PrintableString = z.string().refine(isPrintableString, {
44
+ error: 'A rendered value may not contain a double quote or "${" — Biome reprints the first and lints the second.',
45
+ });
46
+
47
+ /** A number a manifest may state: one whose `String()` form is the literal Biome prints. */
48
+ const PrintableNumber = z.number().refine(isPrintableNumber, {
49
+ error: "A rendered number must be a plain decimal — Biome prints 1e21 where JavaScript prints 1e+21.",
50
+ });
51
+
52
+ /**
53
+ * What a manifest states as an option's rendered value: a JSON value other than `null`, nested freely,
54
+ * and printable as source without Biome wanting it back a different way.
55
+ *
56
+ * A scalar is what an option normally is, and every `--set` override still coerces to one — the CLI
57
+ * takes strings from a flag or a prompt, and neither can carry an object.
58
+ *
59
+ * Objects and arrays are here for the options a scalar cannot express: one the capability's config type
60
+ * **requires** and whose contents only the adopter can write. `SecretsConfig` requires a `registry`; a
61
+ * manifest could not state one, so `pithy add secrets` rendered every option but that and left a
62
+ * `pithy.config.ts` failing `tsc` with TS2741 on a project nobody had touched yet (#161).
63
+ *
64
+ * #161 admitted only the **empty** literal, which is right for a registry — an empty registry is a legal
65
+ * registry. It is wrong everywhere the collection is required to be non-empty. `ledger.currencies`,
66
+ * `leaderboard.boards` and `multiplayer.games` each carry `.min(1)` with a message saying why, so an
67
+ * empty seed typechecks and then throws `too_small` on the first config load — and `pithy upgrade`
68
+ * reports that as "Could not load pithy.config.ts", naming the wrong cause (#168). So a default may now
69
+ * be a **complete, minimal, working example**: one currency, one board, one game. The option's
70
+ * `describe`, rendered as the comment directly above it, is what tells the adopter to replace it.
71
+ *
72
+ * `null` stays out. `typeof null === "object"`, and the CLI reads exactly that to decide an option is
73
+ * hand-written and therefore not settable from `--set` or a prompt; a null default would be mistaken for
74
+ * a collection nobody can fill.
75
+ *
76
+ * The recursion is this widening's cost, paid in the open. Every shape admitted here has to come back
77
+ * out as the TypeScript Biome prints — see {@link renderConfigValue}.
78
+ *
79
+ * **The schema is narrower than the type, deliberately.** TypeScript cannot say "a string with no double
80
+ * quote in it", so the two characters {@link renderConfigValue} cannot print as Biome would are refused
81
+ * here instead, at the one door a manifest comes through. The alternative was to teach the renderer
82
+ * Biome's quote-preference heuristic and its numeric-literal normalizer, and then keep both in step with
83
+ * a formatter that is free to change either — for inputs no worked example should carry. A manifest
84
+ * default is an *example*; one that needs a quote inside a string is already too clever (#171).
85
+ */
86
+ export type ConfigOptionValue = string | number | boolean | ConfigOptionValue[] | { [key: string]: ConfigOptionValue };
87
+
88
+ export const ConfigOptionValue: z.ZodType<ConfigOptionValue> = z
89
+ .lazy(() =>
90
+ z.union([
91
+ PrintableString,
92
+ PrintableNumber,
93
+ z.boolean(),
94
+ z.array(ConfigOptionValue),
95
+ manifestRecord(z.record(PrintableString, ConfigOptionValue)),
96
+ ]),
97
+ )
98
+ .describe("An option's rendered value: a JSON scalar, or a minimal worked example the adopter replaces.");
99
+
100
+ /**
101
+ * An object key that can be written bare in a TypeScript object literal.
102
+ *
103
+ * ASCII only, which is narrower than JavaScript: `café` is a legal bare identifier and Biome keeps it,
104
+ * checked by running Biome over it. It stays out because this is also the shape a *config option's* key
105
+ * is held to, and an option name an English keyboard cannot type is not one a capability should ship.
106
+ * Reserved words are in — `{ default: 1 }` and `{ class: 1 }` are both legal property names, checked the
107
+ * same way — so nothing here needs a keyword list to keep in step with the language.
108
+ */
109
+ const BARE_KEY = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
110
+
111
+ /**
112
+ * The characters that end a `//` comment. All four are line terminators to a JavaScript parser, so a
113
+ * `describe` carrying one puts everything after it into `pithy.config.ts` as bare code — Biome's first
114
+ * report on the generated file is a parse error, not a lint (#174). U+2028 and U+2029 are here because
115
+ * they really do terminate the comment; that was measured with Biome, not assumed.
116
+ */
117
+ const LINE_TERMINATOR = /[\n\r\u2028\u2029]/;
118
+
119
+ /**
120
+ * Whether a string prints as a `//` comment Biome leaves exactly as written.
121
+ *
122
+ * Two things break it, and only two. A line terminator ends the comment, so the rest of the `describe`
123
+ * lands as code. And trailing whitespace parses fine and then fails `biome format`, which is a diff on a
124
+ * file the adopter never opened — the same failure mode #171 closed for values.
125
+ *
126
+ * Everything else stays legal, each one checked by running Biome over the rendered comment: leading
127
+ * whitespace, an interior tab, a block-comment terminator, `${x}`, non-ASCII, and a line of any length.
128
+ * A `//` comment ends at the line and nowhere else, and Biome never reflows one, so there is no width
129
+ * rule here either.
130
+ */
131
+ function isPrintableComment(value: string): boolean {
132
+ return !LINE_TERMINATOR.test(value) && value === value.trimEnd();
133
+ }
134
+
135
+ /** Print one string as source, refusing the two sequences {@link isPrintableString} keeps out. */
136
+ function renderString(value: string): string {
137
+ if (!isPrintableString(value)) {
138
+ throw new ValidationError({
139
+ message: 'A config value may not contain a double quote or "${".',
140
+ action: "Pick a value carrying neither, or write the option by hand in pithy.config.ts.",
141
+ detail: `Rendered as ${JSON.stringify(value)}, Biome would reprint or lint the line, so the generated pithy.config.ts would fail the scaffold's own biome check.`,
142
+ });
143
+ }
144
+ return JSON.stringify(value);
145
+ }
146
+
147
+ /** Print one number as source, refusing anything {@link isPrintableNumber} keeps out. */
148
+ function renderNumber(value: number): string {
149
+ if (!isPrintableNumber(value)) {
150
+ throw new ValidationError({
151
+ message: "A config number must be a plain decimal.",
152
+ action: "Write it without an exponent.",
153
+ detail: `${String(value)} is not the numeric literal Biome prints for it, so the generated pithy.config.ts would fail the scaffold's own biome check.`,
154
+ });
155
+ }
156
+ return String(value);
157
+ }
158
+
159
+ /**
160
+ * Render a manifest default as TypeScript source, on one line.
161
+ *
162
+ * `pithy add` writes this straight into the adopter's `pithy.config.ts`, which the scaffold's own
163
+ * `biome check` then reads — so *valid* TypeScript is not the bar. It has to be the TypeScript Biome
164
+ * would have printed. `JSON.stringify` is not: it quotes every key, and Biome's `quoteProperties`
165
+ * default rewrites `{"code":"chips"}` to `{ code: "chips" }`, failing the lint gate on a project the
166
+ * adopter has not touched. This prints Biome's shape directly — bare keys wherever they are legal,
167
+ * spaces inside braces, `, ` between entries, `{}` and `[]` for the empty literals #161 relies on.
168
+ *
169
+ * **Total over what {@link ConfigOptionValue} *parses*, which is narrower than what it types.** The two
170
+ * shapes the schema refuses — a string carrying `"` or `${`, a number no plain decimal spells — are
171
+ * refused here too, with a `ValidationError`. A manifest cannot reach that path, since every manifest is
172
+ * parsed before it is rendered; `pithy add --set` can, because a flag's string goes straight through. A
173
+ * refusal at the command beats a config file that fails the adopter's first `bun run lint`.
174
+ *
175
+ * One line, always. Biome breaks any literal past {@link CONFIG_LINE_WIDTH}, and a broken literal is a
176
+ * `biome check` failure on a project the adopter has not touched — #161 and #168 both. That rule used to
177
+ * be this sentence and nothing else, with multiplayer's seed sitting at 98 columns of 120; it is now a
178
+ * test over every shipped manifest default, rendered by {@link renderConfigOptionLine} at the indent the
179
+ * writers really use (#171).
180
+ */
181
+ export function renderConfigValue(value: ConfigOptionValue): string {
182
+ if (typeof value === "string") return renderString(value);
183
+ if (typeof value === "number") return renderNumber(value);
184
+ if (typeof value === "boolean") return String(value);
185
+ if (Array.isArray(value)) return `[${value.map(renderConfigValue).join(", ")}]`;
186
+ const entries = Object.entries(value).map(
187
+ ([key, nested]) => `${BARE_KEY.test(key) ? key : renderString(key)}: ${renderConfigValue(nested)}`,
188
+ );
189
+ return entries.length === 0 ? "{}" : `{ ${entries.join(", ")} }`;
190
+ }
191
+
192
+ /**
193
+ * The column Biome wraps at — `formatter.lineWidth` in the `biome.jsonc` that `pithy init` scaffolds.
194
+ *
195
+ * A line of exactly this width survives untouched; one character more and Biome explodes the literal
196
+ * across a dozen lines, so `biome check` fails on a file the adopter never opened. Measured by running
197
+ * Biome over generated lines of 118 through 122 columns, not read off a docs page.
198
+ */
199
+ export const CONFIG_LINE_WIDTH = 120;
200
+
201
+ /**
202
+ * The indent both writers render an option line at in a scaffolded project: the managed-region marker
203
+ * sits four columns in, and an option sits two further.
204
+ *
205
+ * Each writer takes its real indent from the file it is editing, so an adopter who nested `capabilities`
206
+ * deeper gets a longer line than this. This is the scaffold's indent, and the scaffold is what the width
207
+ * rule is measured against — a default that does not fit here fits nowhere.
208
+ */
209
+ export const CONFIG_OPTION_INDENT = " ";
210
+
211
+ /**
212
+ * The one line both writers put in `pithy.config.ts` for one option.
213
+ *
214
+ * `pithy add` renders a whole registration and `pithy upgrade` splices keys into an existing one, but the
215
+ * line itself is this — so neither command can drift from the other by rendering a default its own way.
216
+ * That drift was real and shipped: `upgrade` called `JSON.stringify` where `add` called
217
+ * {@link renderConfigValue}, so one manifest produced `{"code":"chips"}` from one command and
218
+ * `{ code: "chips" }` from the other, and only the second survived `biome check` (#171).
219
+ *
220
+ * The comment above the line is not measured against {@link CONFIG_LINE_WIDTH}: Biome never reflows a
221
+ * comment, so an option's `describe` may run as long as it needs to.
222
+ */
223
+ /**
224
+ * What a writer has decided to put on the right of an option line: a value, or one of the scaffold's
225
+ * constants referenced by name.
226
+ *
227
+ * A marker object rather than a string, because a string would be indistinguishable from a value that
228
+ * happens to spell an identifier — and the difference is quotes, which is the difference between an
229
+ * origin that follows the environment and one written down. Constructed by the CLI from
230
+ * `ConfigOption.constant` after it has checked the target config declares it; never parsed from a
231
+ * manifest, which states only the key.
232
+ */
233
+ export interface ConfigConstantRef {
234
+ /** The constant to render, by key into {@link CONFIG_CONSTANTS}. */
235
+ readonly constant: ConfigConstant;
236
+ }
237
+
238
+ /** Whether a rendered value is a reference to one of the scaffold's constants rather than a literal. */
239
+ function isConstantRef(value: ConfigOptionValue | ConfigConstantRef): value is ConfigConstantRef {
240
+ return typeof value === "object" && value !== null && !Array.isArray(value) && "constant" in value;
241
+ }
242
+
243
+ export function renderConfigOptionLine(
244
+ key: string,
245
+ value: ConfigOptionValue | ConfigConstantRef,
246
+ indent: string,
247
+ ): string {
248
+ if (!BARE_KEY.test(key)) {
249
+ throw new ValidationError({
250
+ message: `A config option key must be a bare identifier, and "${key}" is not.`,
251
+ action: "Rename the option, or write it by hand in pithy.config.ts.",
252
+ detail: `Rendered as ${JSON.stringify(`${key}: …`)}, the generated pithy.config.ts would not parse as TypeScript — a key is written bare, so it cannot carry a hyphen, a space, a quote, or a leading digit.`,
253
+ });
254
+ }
255
+ // A constant is rendered bare — that is its whole point, and the identifier comes from
256
+ // {@link CONFIG_CONSTANTS} rather than from the manifest, so nothing a package states reaches the
257
+ // adopter's TypeScript unquoted.
258
+ const rendered = isConstantRef(value) ? CONFIG_CONSTANTS[value.constant] : renderConfigValue(value);
259
+ return `${indent}${key}: ${rendered},`;
260
+ }
261
+
262
+ /**
263
+ * The comment line both writers put above an option: its `describe`, verbatim, as a `//` comment.
264
+ *
265
+ * The rationale is the whole reason the generated config documents itself, and it comes out of a manifest
266
+ * read from `node_modules` — third-party text reaching generated source. Both writers used to build this
267
+ * line themselves, which is the arrangement that let `pithy add` and `pithy upgrade` disagree about the
268
+ * line *below* it (#171); they now share this, so a rule stated here holds for both.
269
+ *
270
+ * Total over what {@link ConfigOption} parses, exactly as {@link renderConfigOptionLine} is: a manifest
271
+ * cannot reach the throw, because every manifest is parsed before it is rendered.
272
+ */
273
+ export function renderConfigOptionComment(describe: string, indent: string): string {
274
+ if (!isPrintableComment(describe)) {
275
+ throw new ValidationError({
276
+ message: "A config option's rationale must be one line, with no trailing whitespace.",
277
+ action: "Put it on one line, or write the option by hand in pithy.config.ts.",
278
+ detail: `Rendered as ${JSON.stringify(`// ${describe}`)}, the generated pithy.config.ts would either not parse — a line break ends the comment and the rest becomes code — or fail biome format on the trailing whitespace.`,
279
+ });
280
+ }
281
+ return `${indent}// ${describe}`;
282
+ }
283
+
284
+ /**
285
+ * A capability's own name, as generated source carries it.
286
+ *
287
+ * The same shape a config option's key is held to, and for the same reason: it is written **bare**, and
288
+ * twice — as the binding of the import statement (`import { auth } from …`) and as the registration call
289
+ * (`auth(),`). Neither position quotes anything, so a name that is not an identifier is not a syntax
290
+ * error in a string, it is a hole. `audit }) ; evil(` closed the capabilities array and opened a call
291
+ * (#183).
292
+ *
293
+ * It is also the leaf of the fork directory `pithy add --eject` writes (`./capabilities/<name>`), so the
294
+ * same rule keeps a name from carrying a path separator or a `..`.
295
+ */
296
+ const CAPABILITY_NAME = BARE_KEY;
297
+
298
+ /**
299
+ * The npm package name a capability ships under, as the import specifier carries it.
300
+ *
301
+ * Rendered inside a double-quoted specifier — `"@pithy-sh/auth/src/index"` — where the required shape is
302
+ * an npm package name and not a bare identifier. The registry's own grammar is narrower than "no quote
303
+ * in it", so that is what is stated: an optional `@scope/`, then a name starting alphanumeric and made of
304
+ * letters, digits, `.`, `_` and `-`. Uppercase is admitted because npm still serves the legacy names that
305
+ * carry it; everything npm forbids outright — a leading `.` or `_`, a space, a second `/`, a quote — is
306
+ * out, and with it the escape `@pithy-sh/audit"; evil(); //` used to close the specifier and append a
307
+ * statement (#183).
308
+ *
309
+ * 214 is npm's own length cap, checked separately so the refusal can say which rule was broken.
310
+ */
311
+ const PACKAGE_NAME = /^(?:@[A-Za-z0-9][A-Za-z0-9._-]*\/)?[A-Za-z0-9][A-Za-z0-9._-]*$/;
312
+
313
+ /** npm's own limit on a package name, counted across the scope and the separator. */
314
+ const PACKAGE_NAME_MAX = 214;
315
+
316
+ /**
317
+ * Whether a module specifier prints inside a double-quoted literal Biome leaves exactly as written.
318
+ *
319
+ * The same two sequences {@link isPrintableString} keeps out of a value, plus the line terminators that
320
+ * would end the statement early. A specifier is not a manifest field — it is composed from one
321
+ * ({@link CapabilityManifest.package}) or from the fork path — so it is checked here rather than at the
322
+ * schema, and the check is what makes {@link renderCapabilityImport} total over both callers.
323
+ */
324
+ function isPrintableSpecifier(value: string): boolean {
325
+ return value.length > 0 && isPrintableString(value) && !LINE_TERMINATOR.test(value);
326
+ }
327
+
328
+ /** Refuse a capability name generated source cannot carry. Total over what {@link CapabilityManifest} parses. */
329
+ function requireCapabilityName(name: string): void {
330
+ if (CAPABILITY_NAME.test(name)) return;
331
+ throw new ValidationError({
332
+ message: `A capability name must be a bare identifier, and ${JSON.stringify(name)} is not.`,
333
+ action: "Rename the capability, or wire it by hand in pithy.config.ts.",
334
+ detail: `It is written bare into the generated pithy.config.ts twice — as the import binding and as the registration call — so ${JSON.stringify(name)} would produce a file that does not parse as TypeScript.`,
335
+ });
336
+ }
337
+
338
+ /**
339
+ * The one import line a capability's wiring is written as.
340
+ *
341
+ * Both halves are interpolated raw, and both used to be `z.string().min(1)`: `pithy add` wrote
342
+ * `import { audit }) ; evil( } from "@pithy-sh/audit/src/index";` from a manifest that parsed, and
343
+ * reported `Done.` (#183). The line has one producer now, for the same reason
344
+ * {@link renderConfigOptionLine} does — a rule stated at one call site is a rule the next call site does
345
+ * not have.
346
+ *
347
+ * Total over what {@link CapabilityManifest} parses. A parsed manifest cannot reach either throw; the
348
+ * fork path `pithy add --eject` composes can, which is why the specifier is checked rather than assumed.
349
+ */
350
+ export function renderCapabilityImport(name: string, specifier: string): string {
351
+ requireCapabilityName(name);
352
+ if (!isPrintableSpecifier(specifier)) {
353
+ throw new ValidationError({
354
+ message: `A capability's import specifier may not be empty, span lines, or contain a double quote or "\${".`,
355
+ action: "Correct the capability's package name, or write the import by hand in pithy.config.ts.",
356
+ detail: `Rendered as ${JSON.stringify(`from "${specifier}"`)}, the generated pithy.config.ts would either not parse or fail the scaffold's own biome check.`,
357
+ });
358
+ }
359
+ return `import { ${name} } from "${specifier}";`;
360
+ }
361
+
362
+ /** What {@link renderCapabilityRegistration} writes one registration from. */
363
+ export interface CapabilityRegistration {
364
+ /** The capability's name — written bare as the call, so a bare identifier and nothing else. */
365
+ name: string;
366
+ /** The indent the call sits at, taken by each writer from the file it is editing. */
367
+ indent: string;
368
+ /**
369
+ * The option lines already rendered by {@link renderConfigOptionComment} and
370
+ * {@link renderConfigOptionLine}, in order. Empty renders the one-liner form.
371
+ */
372
+ optionLines?: readonly string[];
373
+ /**
374
+ * Whether the registration ends with the array's separating comma. `pithy add` writes a whole entry and
375
+ * needs it; `pithy upgrade` splices a block over an existing `name()` whose comma is already in the file.
376
+ */
377
+ trailingComma?: boolean;
378
+ }
379
+
380
+ /**
381
+ * The registration both writers put in `pithy.config.ts` for one capability — `auth(),` with no options,
382
+ * the block form with them.
383
+ *
384
+ * The name is the only thing here that comes from a manifest, and it is the thing that reached generated
385
+ * source unchecked (#183). One producer, so the rule holds for `pithy add`'s whole-entry write and for
386
+ * `pithy upgrade`'s conversion of a one-liner into a block alike.
387
+ */
388
+ export function renderCapabilityRegistration({
389
+ name,
390
+ indent,
391
+ optionLines = [],
392
+ trailingComma = true,
393
+ }: CapabilityRegistration): string {
394
+ requireCapabilityName(name);
395
+ const end = trailingComma ? "," : "";
396
+ if (optionLines.length === 0) return `${indent}${name}()${end}`;
397
+ return [`${indent}${name}({`, ...optionLines, `${indent}})${end}`].join("\n");
398
+ }
399
+
400
+ /**
401
+ * One configurable option a capability exposes. `pithy add` renders each as
402
+ * `cap({ key: default })` in `pithy.config.ts`, with `describe` as the comment
403
+ * above it — the self-documenting config surface (docs/CLI.md §Config). The
404
+ * mount-point model rides on options like this (e.g. `basePath`), so handlers
405
+ * stay in the package and the user owns the wiring.
406
+ *
407
+ * **Every option the capability's config type requires belongs here.** The manifest is the only thing
408
+ * `pithy add` reads, so an option it omits is an option the generated config omits — see
409
+ * {@link ConfigOptionValue} for the one that got away.
410
+ *
411
+ * **All three fields are narrowed to what the renderer can print, not just the value.** #171 held
412
+ * `default` to shapes Biome prints unchanged and left `key` and `describe` as `z.string().min(1)`, so a
413
+ * manifest could still state `content-type` — rendered `content-type: "x",`, which is not TypeScript at
414
+ * all — or a `describe` with a newline in it, whose second line landed in `pithy.config.ts` as bare code.
415
+ * A manifest is third-party data read from `node_modules`, and an option key is that data interpolated
416
+ * into generated source; `}) ; evil(` is the shape that makes the point. Narrowing here is what lets
417
+ * {@link renderConfigOptionLine} and {@link renderConfigOptionComment} guarantee the whole line rather
418
+ * than its right-hand side (#174).
419
+ */
420
+ /**
421
+ * The constants a scaffolded `pithy.config.ts` defines, that a manifest option may name instead of
422
+ * stating a literal — identifier by key, so the manifest carries a **name from a closed set** and never
423
+ * an expression.
424
+ *
425
+ * That is the whole security argument, and it is the same one #183 settled for a capability's name: a
426
+ * manifest is third-party data read out of `node_modules` and written into the adopter's TypeScript, so
427
+ * anything it states unquoted must come from a list this package controls. An `expression` field would
428
+ * put arbitrary code in the adopter's config on the strength of a package they installed.
429
+ *
430
+ * One entry today. `PUBLIC_ORIGIN` is the Worker's address for the environment it composes in, derived
431
+ * by `originFor(compositionEnvironment(), DOMAINS)` in the scaffolded config — the line that exists so no
432
+ * capability ever asks an adopter to write an origin down again.
433
+ */
434
+ export const CONFIG_CONSTANTS = { publicOrigin: "PUBLIC_ORIGIN" } as const;
435
+
436
+ /** Which scaffolded constant an option's value is, by key. */
437
+ export const ConfigConstant = z
438
+ .enum(Object.keys(CONFIG_CONSTANTS) as [keyof typeof CONFIG_CONSTANTS])
439
+ .describe("A constant the scaffolded pithy.config.ts defines, named from a closed set rather than spelled out.");
440
+ export type ConfigConstant = z.infer<typeof ConfigConstant>;
441
+
442
+ export const ConfigOption = z
443
+ .object({
444
+ key: z
445
+ .string()
446
+ .regex(BARE_KEY, {
447
+ error: (issue) =>
448
+ `A config option key must be a bare identifier, and ${JSON.stringify(issue.input)} is not — it is written bare into pithy.config.ts.`,
449
+ })
450
+ .describe('Option name passed to the capability factory (e.g. "basePath"); a bare identifier.'),
451
+ default: ConfigOptionValue.optional().describe(
452
+ "The value rendered into pithy.config.ts when no override is given — and **its absence is what makes an option required**. There is no flag beside it saying so: an option that states a default has an answer the kit is willing to pick, and an option that states none has one only the adopter can. A required option is settled before a byte is written — a prompt asks it with nothing to accept by pressing enter, `--set key=value` answers it non-interactively, and a run carrying neither is refused naming the flag. Nothing is guessed, because a decision worth refusing over is one that is expensive to reverse: payments' `billingSubject` decides whether an entitlement is held by a person or a company, and it lands in a column and a UNIQUE index.",
453
+ ),
454
+ choices: z
455
+ .array(PrintableString)
456
+ .min(1)
457
+ .optional()
458
+ .describe(
459
+ "The values this option accepts, when they are a closed set. Three things follow from stating them, and none of them can be had without: the prompt is a select rather than free text, `--set` refuses a value outside them instead of writing it into the config, and a refusal can name what is legal — which is the whole of what a caller needs in order to answer a required option. Held to the same printable-string rule as a default, because a choice becomes the value written into the adopter's TypeScript.",
460
+ ),
461
+ constant: ConfigConstant.optional().describe(
462
+ "A constant the scaffolded pithy.config.ts defines, rendered *unquoted* in place of `default` when the target config declares it. The one that exists is `publicOrigin` — `PUBLIC_ORIGIN`, this Worker's address for the environment it composes in. An option whose value is an origin must name it rather than state a URL, because a URL written down is production's URL written into staging: it is what mailed staging's testers magic links into production and unsubscribed them there (#256). `default` is still required *for such an option* and is what a config with no such constant gets, so an older project is never handed an identifier it does not define — the one place the absent-default rule does not reach, and it is checked rather than trusted.",
463
+ ),
464
+ describe: z
465
+ .string()
466
+ .min(1)
467
+ .refine(isPrintableComment, {
468
+ error: (issue) =>
469
+ `A config option's rationale must be one line with no trailing whitespace, and ${JSON.stringify(issue.input)} is not — it is written into pithy.config.ts as a // comment.`,
470
+ })
471
+ .describe("Rationale rendered as the comment above this option in pithy.config.ts; one line."),
472
+ })
473
+ // Self-consistency, checked where manifests are validated rather than at an adopter's prompt. Both of
474
+ // these are manifest bugs — a capability author's mistake, not an adopter's — and each of them surfaces
475
+ // hours later and somewhere else if it is not caught here: a default outside its own choices renders a
476
+ // value the capability's config then refuses, and a `constant` with no default hands a project that
477
+ // predates the scaffolded identifier a name nothing declares.
478
+ .check((ctx) => {
479
+ const option = ctx.value;
480
+ if (option.choices && option.default !== undefined && !option.choices.includes(option.default as string)) {
481
+ ctx.issues.push({
482
+ code: "custom",
483
+ input: option.default,
484
+ path: ["default"],
485
+ message: `A config option's default must be one of its choices, and ${JSON.stringify(option.default)} is not — ${option.key} offers ${JSON.stringify(option.choices)}.`,
486
+ });
487
+ }
488
+ if (option.constant !== undefined && option.default === undefined) {
489
+ ctx.issues.push({
490
+ code: "custom",
491
+ input: option.constant,
492
+ path: ["default"],
493
+ message: `A config option naming a constant must still carry a default, and ${option.key} names ${JSON.stringify(option.constant)} with none — a project whose pithy.config.ts predates the constant gets the default instead, so an option with no default would hand it an identifier nothing declares.`,
494
+ });
495
+ }
496
+ })
497
+ .describe("A configurable option a capability exposes (key, default, choices, rationale).");
498
+ export type ConfigOption = z.infer<typeof ConfigOption>;
499
+
500
+ /**
501
+ * Declarative, CLI-facing description of a capability. Lives at `pithy.manifest.json` in
502
+ * each capability package; read by `pithy add`/`upgrade` to wire bindings into
503
+ * `wrangler.jsonc` and scaffold config — without executing the package. Plain data, so
504
+ * it's a validated Zod object. `requiredBindings` reuse the `BindingSpec` contract, so a
505
+ * manifest's bindings are normalized (and rejected) exactly like a capability's own.
506
+ *
507
+ * **The invariant, stated here rather than at the writers: every string a manifest states that reaches
508
+ * generated source is constrained to what the renderer can print.** Three rounds got there one field at a
509
+ * time — #171 narrowed an option's `default`, #174 its `key` and `describe`, #183 the capability's own
510
+ * `name` and `package` — because each time the rule lived at the call site that had just been fixed. It
511
+ * lives here now, and `manifest.test.ts` walks this schema and fails the build on a string field that is
512
+ * neither constrained nor declared prose the CLI only ever prints.
513
+ */
514
+ export const CapabilityManifest = z
515
+ .object({
516
+ name: z
517
+ .string()
518
+ .regex(CAPABILITY_NAME, {
519
+ error: (issue) =>
520
+ `A capability name must be a bare identifier, and ${JSON.stringify(issue.input)} is not — it is written bare into pithy.config.ts as both the import binding and the registration call.`,
521
+ })
522
+ .describe('Capability name, e.g. "auth"; a bare identifier.'),
523
+ package: z
524
+ .string()
525
+ .max(PACKAGE_NAME_MAX)
526
+ .regex(PACKAGE_NAME, {
527
+ error: (issue) =>
528
+ `A capability's package must be an npm package name, and ${JSON.stringify(issue.input)} is not — it is written into pithy.config.ts as the import specifier.`,
529
+ })
530
+ .describe('npm package providing this capability, e.g. "@pithy-sh/auth".'),
531
+ requiredBindings: z
532
+ .array(BindingSpec)
533
+ .describe("Bindings the CLI must wire into wrangler.jsonc (normalized via BindingSpec)."),
534
+ peerCapabilities: z
535
+ .array(
536
+ z.string().regex(CAPABILITY_NAME, {
537
+ error: (issue) =>
538
+ `A peer capability is named by its capability name, and ${JSON.stringify(issue.input)} is not a bare identifier.`,
539
+ }),
540
+ )
541
+ .default([])
542
+ .describe("Capabilities that must also be present (e.g. auth ⇒ email)."),
543
+ optionalCapabilities: z
544
+ .array(
545
+ z.string().regex(CAPABILITY_NAME, {
546
+ error: (issue) =>
547
+ `An optional capability is named by its capability name, and ${JSON.stringify(issue.input)} is not a bare identifier.`,
548
+ }),
549
+ )
550
+ .default([])
551
+ .describe("Capabilities that, if present, get wired together (e.g. turnstile onto auth)."),
552
+ migrationNamespace: z
553
+ .string()
554
+ .regex(NAMESPACE_PATTERN)
555
+ .optional()
556
+ .describe(
557
+ "Namespace prefix for this capability's migrations — must match the registry's format (e.g. \"auth\").",
558
+ ),
559
+ devSecrets: z
560
+ .array(DevSecret)
561
+ .default([])
562
+ .describe("Secrets from this capability's registry whose dev value `pithy add` mints into `.dev.vars`."),
563
+ secrets: z
564
+ .array(DeclaredSecret)
565
+ .default([])
566
+ .describe(
567
+ "Every secret from this capability's registry that declares how it comes to exist and how it is replaced. The route `devSecrets` proved: a client reads it without executing the package, so `pithy doctor` says *not set — run this* or *not set — get it here*, and no client keeps its own table of names. A secret declaring neither axis is absent, which reads as *nothing is known*, not as *nothing can help*.",
568
+ ),
569
+ scaffold: z.array(z.string()).default([]).describe("Human-readable scaffold steps the CLI performs or explains."),
570
+ configOptions: z
571
+ .array(ConfigOption)
572
+ .default([])
573
+ .describe("Config options the capability exposes; `pithy add` renders each as `cap({ key: default })`."),
574
+ whenToEnable: z
575
+ .string()
576
+ .optional()
577
+ .describe("Self-documenting rationale the CLI surfaces when offering this capability."),
578
+ })
579
+ .describe("Declarative, CLI-facing description of a capability (pithy.manifest.json).");
580
+ export type CapabilityManifest = z.infer<typeof CapabilityManifest>;