@pithy-sh/core 0.2.0 → 0.3.1

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 (68) hide show
  1. package/dist/capability/bindings.d.ts +5 -0
  2. package/dist/capability/bindings.d.ts.map +1 -1
  3. package/dist/capability/bindings.js +44 -3
  4. package/dist/capability/capability.d.ts +33 -0
  5. package/dist/capability/capability.d.ts.map +1 -1
  6. package/dist/capability/manifest.d.ts +5 -0
  7. package/dist/capability/manifest.d.ts.map +1 -1
  8. package/dist/capability/manifest.js +1 -1
  9. package/dist/capability/validateBindings.js +1 -1
  10. package/dist/controlPlane/http/cors.js +1 -1
  11. package/dist/createBackend.d.ts +2 -1
  12. package/dist/createBackend.d.ts.map +1 -1
  13. package/dist/createBackend.js +5 -4
  14. package/dist/error/http.d.ts +27 -5
  15. package/dist/error/http.d.ts.map +1 -1
  16. package/dist/error/http.js +33 -9
  17. package/dist/error/messageParams.d.ts +28 -0
  18. package/dist/error/messageParams.d.ts.map +1 -0
  19. package/dist/error/messageParams.js +31 -0
  20. package/dist/error/terminal.d.ts +16 -5
  21. package/dist/error/terminal.d.ts.map +1 -1
  22. package/dist/error/terminal.js +16 -5
  23. package/dist/http/oidcWebhook.d.ts +283 -0
  24. package/dist/http/oidcWebhook.d.ts.map +1 -0
  25. package/dist/http/oidcWebhook.js +414 -0
  26. package/dist/http/signedWebhook.d.ts +9 -65
  27. package/dist/http/signedWebhook.d.ts.map +1 -1
  28. package/dist/http/signedWebhook.js +19 -9
  29. package/dist/http/webhookWindow.d.ts +33 -0
  30. package/dist/http/webhookWindow.d.ts.map +1 -0
  31. package/dist/http/webhookWindow.js +35 -0
  32. package/dist/logger/errorHandler.d.ts +11 -0
  33. package/dist/logger/errorHandler.d.ts.map +1 -0
  34. package/dist/logger/errorHandler.js +87 -0
  35. package/dist/naming/environment.js +1 -1
  36. package/dist/naming/provisionScope.d.ts +108 -3
  37. package/dist/naming/provisionScope.d.ts.map +1 -1
  38. package/dist/naming/provisionScope.js +27 -2
  39. package/dist/naming/resource.js +1 -1
  40. package/dist/naming/resourceNames.js +1 -1
  41. package/dist/schema/describedness.d.ts +30 -1
  42. package/dist/schema/describedness.d.ts.map +1 -1
  43. package/dist/schema/describedness.js +42 -6
  44. package/dist/test-utils/oidcFixtures.d.ts +93 -0
  45. package/dist/test-utils/oidcFixtures.d.ts.map +1 -0
  46. package/dist/test-utils/oidcFixtures.js +111 -0
  47. package/dist/version.generated.d.ts +1 -1
  48. package/dist/version.generated.js +1 -1
  49. package/dist/workflow/naming.js +1 -1
  50. package/dist/workflow/stepMessage.d.ts +28 -3
  51. package/dist/workflow/stepMessage.d.ts.map +1 -1
  52. package/dist/workflow/stepMessage.js +48 -6
  53. package/package.json +1 -1
  54. package/src/capability/bindings.ts +49 -0
  55. package/src/capability/capability.ts +34 -0
  56. package/src/createBackend.ts +7 -3
  57. package/src/error/http.ts +34 -10
  58. package/src/error/messageParams.ts +38 -0
  59. package/src/error/terminal.ts +16 -5
  60. package/src/http/oidcWebhook.ts +672 -0
  61. package/src/http/signedWebhook.ts +44 -16
  62. package/src/http/webhookWindow.ts +35 -0
  63. package/src/logger/errorHandler.ts +94 -0
  64. package/src/naming/provisionScope.ts +150 -4
  65. package/src/schema/describedness.ts +51 -6
  66. package/src/test-utils/oidcFixtures.ts +138 -0
  67. package/src/version.generated.ts +1 -1
  68. package/src/workflow/stepMessage.ts +52 -6
@@ -0,0 +1,111 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ //#region src/test-utils/oidcFixtures.ts
4
+ /**
5
+ * Mint one RSA key pair and publish its public half as a JWK.
6
+ *
7
+ * Real WebCrypto, not a fixture: a hand-rolled key and a hand-rolled signature would let every signature
8
+ * assertion pass by agreeing with itself. A second minted key is what makes "signed by somebody else" a
9
+ * real statement rather than a label — mint one, publish only the first, and the refusal is the verifier's
10
+ * rather than the test's.
11
+ *
12
+ * Key generation is the slow part of any suite using this and a key is immutable, so mint in a
13
+ * `beforeAll` and reuse.
14
+ */
15
+ async function mintKey(kid) {
16
+ const pair = await crypto.subtle.generateKey({
17
+ name: "RSASSA-PKCS1-v1_5",
18
+ hash: "SHA-256",
19
+ modulusLength: 2048,
20
+ publicExponent: new Uint8Array([
21
+ 1,
22
+ 0,
23
+ 1
24
+ ])
25
+ }, true, ["sign", "verify"]);
26
+ const exported = await crypto.subtle.exportKey("jwk", pair.publicKey);
27
+ if (exported.kty === void 0 || exported.n === void 0 || exported.e === void 0) throw new Error("WebCrypto exported an RSA public key with no kty, n, or e.");
28
+ return {
29
+ kid,
30
+ jwk: {
31
+ ...exported,
32
+ kty: exported.kty,
33
+ n: exported.n,
34
+ e: exported.e,
35
+ kid,
36
+ alg: "RS256",
37
+ use: "sig"
38
+ },
39
+ privateKey: pair.privateKey
40
+ };
41
+ }
42
+ /**
43
+ * base64url without padding — how every segment of a compact JWS is encoded.
44
+ *
45
+ * Exported because a test that builds a token by hand needs it: the tamper case swaps a claims segment and
46
+ * keeps the signature, and re-encoding that segment with padding or with `+`/`/` would make the token
47
+ * refused for the wrong reason.
48
+ */
49
+ function base64Url(bytes) {
50
+ let binary = "";
51
+ for (const byte of bytes) binary += String.fromCharCode(byte);
52
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
53
+ }
54
+ /**
55
+ * Sign a compact JWT the way an identity provider signs an OIDC token: `RS256`, `typ: "JWT"`, and the
56
+ * signer's `kid` in the header.
57
+ *
58
+ * `payload` is the claims, written as the provider writes them — `iss`, `aud`, `exp`, and whatever the
59
+ * route's `claims` predicate reads. Nothing here defaults a claim, because a fixture that quietly supplied
60
+ * one would be testing itself.
61
+ *
62
+ * `header` overrides let a test present a header no provider would — `alg: "none"`, `alg: "RS512"`, a `kid`
63
+ * nobody published, or `kid: undefined` — which is how a route proves algorithm confusion and an
64
+ * unpublished key are rejected, instead of trusting that the kit covers it somewhere.
65
+ */
66
+ async function signToken(payload, signer, header = {}) {
67
+ const encoder = new TextEncoder();
68
+ const head = base64Url(encoder.encode(JSON.stringify({
69
+ alg: "RS256",
70
+ typ: "JWT",
71
+ kid: signer.kid,
72
+ ...header
73
+ })));
74
+ const body = base64Url(encoder.encode(JSON.stringify(payload)));
75
+ return `${head}.${body}.${base64Url(new Uint8Array(await crypto.subtle.sign("RSASSA-PKCS1-v1_5", signer.privateKey, encoder.encode(`${head}.${body}`))))}`;
76
+ }
77
+ /**
78
+ * A JWKS transport that publishes `keys` at `jwksUrl` and nothing anywhere else, recording every URL it was
79
+ * asked for in `seen`.
80
+ *
81
+ * **The response is the seam's own shape, which is the whole point of shipping this.** `text()`, never
82
+ * `json()` — {@link OidcJwksResponse} reads the body as text deliberately, so a proxy's HTML error page is a
83
+ * diagnosis rather than a throw. A hand-rolled transport that answers `json()` produces a 502 on every
84
+ * delivery from a fixture that looks correct. Typed as {@link OidcJwksFetch}, that mistake will not compile.
85
+ *
86
+ * **The URL is a parameter rather than a wildcard.** A transport that served keys at any URL would pass a
87
+ * route whose configured `jwksUrl` points at nothing — the one configuration mistake a test of a webhook
88
+ * route exists to catch. Anything else gets a 404 with a body, which is what an issuer's CDN actually
89
+ * returns and what the verifier maps to a 502.
90
+ *
91
+ * `seen` is an array the caller owns, so a test can assert the fetch count: one per delivery with no cache,
92
+ * one per TTL with one, and zero when a cache is pre-seeded. Pass `publishing(url, [], seen)` to prove a
93
+ * path never reaches the network at all — an empty key set is refused, so an unexpected fetch fails loudly.
94
+ */
95
+ function publishing(jwksUrl, keys, seen = []) {
96
+ return async (url) => {
97
+ seen.push(url);
98
+ if (url !== jwksUrl) return {
99
+ ok: false,
100
+ status: 404,
101
+ text: async () => "{}"
102
+ };
103
+ return {
104
+ ok: true,
105
+ status: 200,
106
+ text: async () => JSON.stringify({ keys })
107
+ };
108
+ };
109
+ }
110
+ //#endregion
111
+ export { base64Url, mintKey, publishing, signToken };
@@ -3,5 +3,5 @@
3
3
  /** This package's npm name — the join key against a release feed. */
4
4
  export declare const PACKAGE_NAME = "@pithy-sh/core";
5
5
  /** This package's version, stamped from its own package.json at generation time. */
6
- export declare const PACKAGE_VERSION = "0.2.0";
6
+ export declare const PACKAGE_VERSION = "0.3.1";
7
7
  //# sourceMappingURL=version.generated.d.ts.map
@@ -4,6 +4,6 @@
4
4
  /** This package's npm name — the join key against a release feed. */
5
5
  const PACKAGE_NAME = "@pithy-sh/core";
6
6
  /** This package's version, stamped from its own package.json at generation time. */
7
- const PACKAGE_VERSION = "0.2.0";
7
+ const PACKAGE_VERSION = "0.3.1";
8
8
  //#endregion
9
9
  export { PACKAGE_NAME, PACKAGE_VERSION };
@@ -1,7 +1,7 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
- import { NAME_SEGMENT } from "../naming/segment.js";
4
3
  import { InternalError } from "../error/pithyError.js";
4
+ import { NAME_SEGMENT } from "../naming/segment.js";
5
5
  import { NAMESPACE_LIMITS } from "../naming/limits.js";
6
6
  import { assertValidProjectName, kebab } from "../naming/resource.js";
7
7
  //#region src/workflow/naming.ts
@@ -68,6 +68,21 @@
68
68
  * `message`, throw-site context in `detail`, and a durable boundary is not a reason to move the line.
69
69
  * `action` crosses because `action` is already operator-facing — the CLI prints it and `operatorError`
70
70
  * includes it. This stops losing a field that was always meant for this reader.
71
+ *
72
+ * ## The encoder owns producing something the reader accepts
73
+ *
74
+ * The reader is strict, and it has to be: text the kit did not write must not become an operator's
75
+ * sentence. The consequence is that a *writer* handing it a shape it declines loses the whole channel —
76
+ * the code, the sentence and the remedy at once — and the operator is handed `core/workflow_failed` 500
77
+ * with the platform's prose about durable execution. That is not a hypothetical. #534 put an upstream's
78
+ * `errors[]` into a multi-line `message`, and every Cloudflare refusal raised inside a `classifiedSteps`
79
+ * step stopped crossing this boundary at all: the first newline read as the code/action separator and
80
+ * the second as a shape this never writes, so the read returned `null` and the refusal died at the step.
81
+ *
82
+ * So {@link encodeWorkflowStepMessage} is **total**: whatever fields it is handed, what it writes decodes
83
+ * back. Each half is flattened to one line and, past the bound, truncated with an ellipsis rather than
84
+ * dropped. The asymmetry with the reader is deliberate and is the whole point — the writer owns text the
85
+ * kit authored and may reshape it; the reader is handed text from a Worker we did not write and may not.
71
86
  */
72
87
  /**
73
88
  * The separator between the step's sentence and its action. One newline, stated once.
@@ -107,9 +122,19 @@ export declare function splitWorkflowStepCode(text: string): {
107
122
  /**
108
123
  * The text a terminal step throws. The inverse of {@link decodeWorkflowStepMessage}, and tested as one.
109
124
  *
110
- * An action is appended only when there is one to append and it is a single line. An action carrying a
111
- * break would encode a shape the reader declines, taking the sentence down with it — so the sentence
112
- * is kept and the action dropped. Losing the remedy is a worse day; losing both is a worse one still.
125
+ * **What this writes, that reads back — for a well-formed code and a sentence there is one.** Each half
126
+ * goes through {@link encodable}, so a multi-line sentence is flattened rather than fatal and an overlong
127
+ * one is truncated rather than declined. An action is appended only when there is one left after that —
128
+ * a remedy that was empty or only whitespace leaves no trailing separator, because a dangling separator
129
+ * is a field the reader would have to decline.
130
+ *
131
+ * **Two inputs it deliberately cannot represent, so "total" is not the whole word for it.** An empty
132
+ * `message` encodes to text {@link decodeWorkflowStepMessage} declines, and so does a `code` that does
133
+ * not match `CODE_PREFIX`. Both refusals are the boundary working: a step that failed without a sentence
134
+ * has none to promote — the caller's own `fallbackMessage` is what the operator should see — and a code
135
+ * the reader cannot recognize is exactly the foreign text this encoding exists to keep out of an
136
+ * operator's sentence. Neither is reachable from a kit throw site; an adopter code declared through
137
+ * `defineErrorPayload` is checked against the same shape.
113
138
  */
114
139
  export declare function encodeWorkflowStepMessage(fields: WorkflowStepMessage): string;
115
140
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"stepMessage.d.ts","sourceRoot":"","sources":["../../src/workflow/stepMessage.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoEG;AAEH;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,OAAO,CAAC;AAE5C;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAc1C,uGAAuG;AACvG,MAAM,WAAW,mBAAmB;IAClC,wEAAwE;IACxE,IAAI,EAAE,MAAM,CAAC;IACb,0FAA0F;IAC1F,OAAO,EAAE,MAAM,CAAC;IAChB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAInF;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,mBAAmB,GAAG,MAAM,CAK7E;AAED;;;;;;;GAOG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,GAAG,mBAAmB,GAAG,IAAI,CAYlF"}
1
+ {"version":3,"file":"stepMessage.d.ts","sourceRoot":"","sources":["../../src/workflow/stepMessage.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmFG;AAEH;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,OAAO,CAAC;AAE5C;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAc1C,uGAAuG;AACvG,MAAM,WAAW,mBAAmB;IAClC,wEAAwE;IACxE,IAAI,EAAE,MAAM,CAAC;IACb,0FAA0F;IAC1F,OAAO,EAAE,MAAM,CAAC;IAChB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAInF;AAuBD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,mBAAmB,GAAG,MAAM,CAK7E;AAED;;;;;;;GAOG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,GAAG,mBAAmB,GAAG,IAAI,CAYlF"}
@@ -69,6 +69,21 @@
69
69
  * `message`, throw-site context in `detail`, and a durable boundary is not a reason to move the line.
70
70
  * `action` crosses because `action` is already operator-facing — the CLI prints it and `operatorError`
71
71
  * includes it. This stops losing a field that was always meant for this reader.
72
+ *
73
+ * ## The encoder owns producing something the reader accepts
74
+ *
75
+ * The reader is strict, and it has to be: text the kit did not write must not become an operator's
76
+ * sentence. The consequence is that a *writer* handing it a shape it declines loses the whole channel —
77
+ * the code, the sentence and the remedy at once — and the operator is handed `core/workflow_failed` 500
78
+ * with the platform's prose about durable execution. That is not a hypothetical. #534 put an upstream's
79
+ * `errors[]` into a multi-line `message`, and every Cloudflare refusal raised inside a `classifiedSteps`
80
+ * step stopped crossing this boundary at all: the first newline read as the code/action separator and
81
+ * the second as a shape this never writes, so the read returned `null` and the refusal died at the step.
82
+ *
83
+ * So {@link encodeWorkflowStepMessage} is **total**: whatever fields it is handed, what it writes decodes
84
+ * back. Each half is flattened to one line and, past the bound, truncated with an ellipsis rather than
85
+ * dropped. The asymmetry with the reader is deliberate and is the whole point — the writer owns text the
86
+ * kit authored and may reshape it; the reader is handed text from a Worker we did not write and may not.
72
87
  */
73
88
  /**
74
89
  * The separator between the step's sentence and its action. One newline, stated once.
@@ -111,16 +126,43 @@ function splitWorkflowStepCode(text) {
111
126
  };
112
127
  }
113
128
  /**
129
+ * One half as this encoding can carry it: a single line, within the bound.
130
+ *
131
+ * Both reshapes exist because the alternative is losing the field, and the two ways a half can be
132
+ * unreadable are the two ways a writer actually gets it wrong. A line break would encode a shape the
133
+ * reader declines — the sentence would take the remedy down with it, or the remedy would forge a
134
+ * second field — so every break collapses to a space, exactly as `@pithy-sh/cloudflare` flattens an
135
+ * upstream's own text before it lands in `message`. Length is the same argument with a different
136
+ * cause: half a sentence naming its code and its remedy beats the platform's prose about durable
137
+ * execution, so the tail is spent rather than the field. The whole of it was never here anyway —
138
+ * `detail` holds the raw text, on the far side of a boundary it does not cross.
139
+ */
140
+ function encodable(text) {
141
+ const flattened = text.replace(/\s*[\n\r]+\s*/g, " ").trim();
142
+ if (flattened.length <= 512) return flattened;
143
+ return `${flattened.slice(0, 511).trimEnd()}…`;
144
+ }
145
+ /**
114
146
  * The text a terminal step throws. The inverse of {@link decodeWorkflowStepMessage}, and tested as one.
115
147
  *
116
- * An action is appended only when there is one to append and it is a single line. An action carrying a
117
- * break would encode a shape the reader declines, taking the sentence down with it — so the sentence
118
- * is kept and the action dropped. Losing the remedy is a worse day; losing both is a worse one still.
148
+ * **What this writes, that reads back — for a well-formed code and a sentence there is one.** Each half
149
+ * goes through {@link encodable}, so a multi-line sentence is flattened rather than fatal and an overlong
150
+ * one is truncated rather than declined. An action is appended only when there is one left after that —
151
+ * a remedy that was empty or only whitespace leaves no trailing separator, because a dangling separator
152
+ * is a field the reader would have to decline.
153
+ *
154
+ * **Two inputs it deliberately cannot represent, so "total" is not the whole word for it.** An empty
155
+ * `message` encodes to text {@link decodeWorkflowStepMessage} declines, and so does a `code` that does
156
+ * not match `CODE_PREFIX`. Both refusals are the boundary working: a step that failed without a sentence
157
+ * has none to promote — the caller's own `fallbackMessage` is what the operator should see — and a code
158
+ * the reader cannot recognize is exactly the foreign text this encoding exists to keep out of an
159
+ * operator's sentence. Neither is reachable from a kit throw site; an adopter code declared through
160
+ * `defineErrorPayload` is checked against the same shape.
119
161
  */
120
162
  function encodeWorkflowStepMessage(fields) {
121
- const stated = `${fields.code}: ${fields.message}`;
122
- const action = fields.action?.trim();
123
- if (action === void 0 || action === "" || LINE_BREAK.test(action)) return stated;
163
+ const stated = `${fields.code}: ${encodable(fields.message)}`;
164
+ const action = encodable(fields.action ?? "");
165
+ if (action === "") return stated;
124
166
  return `${stated}
125
167
  ${action}`;
126
168
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pithy-sh/core",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -2,6 +2,7 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
4
  import { z } from "zod";
5
+ import { FEATURE_RESOURCE_KINDS } from "../naming/feature";
5
6
  import { NAME_SEGMENT } from "../naming/segment";
6
7
 
7
8
  export const BindingType = z
@@ -100,6 +101,19 @@ const CLASS_NAME = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
100
101
  */
101
102
  const CLASS_MODULE = /^(@[a-z0-9~-][a-z0-9._~-]*\/)?[a-z0-9~-][a-z0-9._~-]*(\/[A-Za-z0-9_~-][A-Za-z0-9._~-]*)*$/;
102
103
 
104
+ /**
105
+ * The kinds whose resource Pithy names and creates — the only kinds `scope` and `resource` mean anything
106
+ * for.
107
+ *
108
+ * {@link FEATURE_RESOURCE_KINDS} rather than a fresh literal, because it is already the repo's answer to
109
+ * "which bindings does a provisioning run compose a `<project>-<scope>-<thing>` name for": it is the
110
+ * domain of `ProvisionScope.resource`, and the CLI's own `PROVISIONABLE` map is the same three. Every
111
+ * other kind's name is composed by a different rule with no `<thing>` slot to fill — a Workflow's from
112
+ * the capability and the job, a Worker script's by wrangler — and a `secret` already states its scope in
113
+ * `defineSecretRegistry`, where the value it names lives.
114
+ */
115
+ const NAMED_RESOURCE_KINDS = new Set<string>(FEATURE_RESOURCE_KINDS);
116
+
103
117
  /**
104
118
  * Declares a Cloudflare binding a capability requires in the Worker env. The authoring
105
119
  * shape lets `optional` be omitted (defaults false); `createBackend` normalizes via parse.
@@ -151,6 +165,24 @@ export const BindingSpec = z
151
165
  .describe(
152
166
  "Reach the real Cloudflare resource during local development instead of a local emulation. Set it for a binding that has none — Vectorize and Workers AI both lack local simulation — so `wrangler dev` and any Workflow host, which always runs locally, still work. Left unset rather than defaulting to false, so a spec that does not care emits no flag at all. Ignored in a deployed Worker.",
153
167
  ),
168
+ scope: z
169
+ .enum(["environment", "global"])
170
+ .optional()
171
+ .describe(
172
+ "Whether the resource behind this binding is one per environment (absent, the default) or one for the whole project. A `global` resource takes the literal `global` where the environment segment goes — `<project>-global-<thing>` — so every environment's stanza names the identical resource and provisioning creates it once. Declare it where the data is not an environment-local fact: `@pithy-sh/email`'s suppression list is one per project, because an unsubscribe recorded through a staging Worker has to be honored by a production one, and three per-environment databases meant it was not (#513). Say nothing where it is: a session store, a cache, and `@pithy-sh/secrets`' own database are per environment, and one line apart in the same env there is no convention to tell them apart by — which is why this is declared rather than inferred. Meaningful only for a `d1`, `kv` or `r2` binding.",
173
+ ),
174
+ resource: z
175
+ .string()
176
+ .min(1)
177
+ // The same rule the composed name answers to, asserted here rather than only where the name is
178
+ // built: a manifest is third-party data read out of `node_modules`, and this string is the last
179
+ // segment of a Cloudflare resource name. A manifest stating something a provisioning run would
180
+ // refuse is refused at parse instead, attributed to the capability, before anything is created.
181
+ .regex(NAME_SEGMENT, "A resource is one name segment: lowercase letters, digits, and single hyphens.")
182
+ .optional()
183
+ .describe(
184
+ "The `<thing>` segment of this resource's name, when it is not the kebabbed binding name. Absent is the ordinary case and means the binding name is the segment (`EMAIL_SUPPRESSIONS` → `email-suppressions`). Declare it where the two must differ: `SUPPORT_BUCKET` backs `<project>-global-support`, because the binding keeps `_BUCKET` for whoever reads `env.SUPPORT_BUCKET` in a Worker while the resource drops it — `r2` is already the composed name's kind, so the suffix repeats it. Meaningful only for a `d1`, `kv` or `r2` binding.",
185
+ ),
154
186
  })
155
187
  .describe("Declares a Cloudflare binding a capability requires in the Worker env.")
156
188
  .check((ctx) => {
@@ -189,6 +221,23 @@ export const BindingSpec = z
189
221
  message: `Service binding "${ctx.value.name}" needs a service — the Worker it calls, as named in apps/.`,
190
222
  });
191
223
  }
224
+ // `scope` and `resource` are the two halves of a *provisioned resource's name*, and only
225
+ // {@link NAMED_RESOURCE_KINDS} have one Pithy composes. On anything else they are inert — a Workflow
226
+ // would keep its `<capability>-<job>` name, a `secret` its registry's scope — so a manifest that
227
+ // states one is not describing a resource that exists. Refused rather than ignored, and refused here
228
+ // rather than in the writer, because a silently-dropped `scope: "global"` reads, in the manifest, as
229
+ // exactly the declaration #513 exists to make true.
230
+ if (!NAMED_RESOURCE_KINDS.has(ctx.value.type)) {
231
+ for (const field of ["scope", "resource"] as const) {
232
+ if (ctx.value[field] === undefined) continue;
233
+ ctx.issues.push({
234
+ code: "custom",
235
+ input: ctx.value,
236
+ path: [field],
237
+ message: `Binding "${ctx.value.name}" declares a ${field}, which only a d1, kv or r2 binding has — this one is a ${ctx.value.type}.`,
238
+ });
239
+ }
240
+ }
192
241
  });
193
242
  export type BindingSpec = z.infer<typeof BindingSpec>;
194
243
 
@@ -160,6 +160,34 @@ export interface SecretRegistryEntrySeam {
160
160
  /** A capability's secret-registry slice: secret name → {@link SecretRegistryEntrySeam}. */
161
161
  export type SecretRegistrySeam = Record<string, SecretRegistryEntrySeam>;
162
162
 
163
+ /**
164
+ * **Which branches of a `json` secret's schema this Worker's configuration actually uses** — secret
165
+ * name → the top-level keys that apply, in the order they should be offered.
166
+ *
167
+ * A `json` secret is often a bundle of independent blocks: `payments-provider-credentials` is five
168
+ * optional rail blocks, `turnstile-secret-keys` is two optional widgets. The schema says which blocks
169
+ * are *possible*; only the composed config says which are *real*, and a credential for a rail that is
170
+ * off is a value nothing will ever read. So `pithy secrets create` asks per field for the blocks named
171
+ * here, and never offers — never writes — one that is not.
172
+ *
173
+ * **It is declared, not matched by name.** `PaymentsRailToggles.paddle` and
174
+ * `PaymentsProviderCredentials.paddle` happen to be spelled the same today, and a CLI that keyed off
175
+ * that would be right by coincidence: nothing makes a config flag and a schema key the same thing, and
176
+ * two of them one line apart cannot be told apart by convention (#513). The capability already knows
177
+ * the answer at construction — it has both halves in hand — so it states it, and the CLI reads a fact
178
+ * instead of inferring one.
179
+ *
180
+ * **It lives on the capability rather than on the entry** because a registry entry is plain data,
181
+ * shared by every Worker that declares the name and projected into `pithy.manifest.json`. This answer
182
+ * is per composition — the same secret, two Workers, two configs — so it belongs where the config is.
183
+ *
184
+ * An absent declaration is not an empty one. A capability that says nothing leaves the CLI with no way
185
+ * to know which blocks apply, and it falls back to asking for the whole document rather than guessing;
186
+ * an empty array says *none of them apply*, which is the same fallback for a different reason. A
187
+ * secret whose schema has no branches at all needs no declaration — every field is asked.
188
+ */
189
+ export type SecretBranchSeam = Record<string, readonly string[]>;
190
+
163
191
  /**
164
192
  * The structural seam for one capability's token-profile slice — profile name → the scoped CF API
165
193
  * token that capability's code needs. Declared next to the code that uses it (alongside
@@ -298,6 +326,12 @@ export interface Capability<
298
326
  * every declared secret in one batch — and no capability needs to know another's secrets.
299
327
  */
300
328
  secretRegistry?: SecretRegistrySeam;
329
+ /**
330
+ * Which branches of this capability's `json` secrets this Worker's configuration uses — see
331
+ * {@link SecretBranchSeam}. Additive and optional: a capability whose secrets are flat, or which has
332
+ * no `json` secret at all, declares nothing.
333
+ */
334
+ secretBranches?: SecretBranchSeam;
301
335
  /**
302
336
  * The scoped CF API tokens this capability's code needs, as a token-profile slice (profile name →
303
337
  * {@link TokenProfileSeam}). Additive and optional, and declared next to {@link Capability.secretRegistry}
@@ -18,11 +18,11 @@ import { isControlPlaneCapability } from "./controlPlane/capability";
18
18
  import { allowedOriginSet, corsSurface, registerControlPlaneCors } from "./controlPlane/http/cors";
19
19
  import { buildDbRegistry, composeDatabases, type DbRegistry } from "./data/databases";
20
20
  import { noEntitlementProvider } from "./entitlement/entitlement";
21
- import { pithyErrorHandler } from "./error/http";
22
21
  import { ValidationError } from "./error/pithyError";
23
22
  import { catalogFor, composeMessages } from "./i18n/registry";
24
23
  import { bakedTranslator, DEFAULT_LOCALE } from "./i18n/translator";
25
24
  import { buildKvRegistry, composeKv, type KvRegistry } from "./kv/namespaces";
25
+ import { loggingErrorHandler } from "./logger/errorHandler";
26
26
  import type { Logger } from "./logger/logger";
27
27
  import { bindRequestContext, createWorkerLogger } from "./logger/worker";
28
28
  import { HEALTH_PATH } from "./worker/health";
@@ -111,7 +111,8 @@ function envNameOf(env: Record<string, unknown>, fallback: string | undefined):
111
111
  * each capability's middleware; and mounts the capability (then app) routes. On every request
112
112
  * `c.var.db` is the typed database registry — one `Kysely` per named database (`c.var.db.app`) —
113
113
  * and `c.var.kv` the typed namespace registry — one `TypedKv` per store (`c.var.kv.cms.pages`).
114
- * `PithyError`s become their declared HTTP status via {@link pithyErrorHandler}.
114
+ * `PithyError`s become their declared HTTP status via {@link loggingErrorHandler}, which also writes
115
+ * each fault to the request logger with its `action` and `detail` — the fields the wire body strips.
115
116
  *
116
117
  * Binding validation runs **once on the first request**, not at module load: in Workers `env` is
117
118
  * per-request, so there is no env to check until a request arrives. The result is memoized.
@@ -178,7 +179,10 @@ export function createBackend<
178
179
  // Build internally against the loose base env (db/kv are `unknown`, so `c.set` accepts the merged
179
180
  // registries); the precise types ride on the return below.
180
181
  const app = new Hono<PithyHonoEnv>();
181
- app.onError(pithyErrorHandler);
182
+ // The logging handler, not the bare encoder: a fault's `action` names the var or command an operator
183
+ // must fix, `clientError` strips it on the way to the wire (correctly), and without this the remedy
184
+ // reached no surface at all in a deployed Worker — one access log line reading `status: 500` (#521).
185
+ app.onError(loggingErrorHandler);
182
186
 
183
187
  // Set once at assembly (the per-request `env` constraint holds: correlation binds per request below,
184
188
  // not here). Defaults to the CF-native Mode 2 logger — structured records to Workers Logs, zero-config.
package/src/error/http.ts CHANGED
@@ -73,20 +73,44 @@ function fromHttpException(error: HTTPException): PithyError | null {
73
73
  }
74
74
 
75
75
  /**
76
- * Hono `onError` handler. Register once on the root app (`app.onError(pithyErrorHandler)`): any
77
- * `PithyError` becomes `{ error: <public payload> }` at its declared status; a Hono `HTTPException`
78
- * 400 becomes `validation/invalid_input`; any other throw is wrapped as a `core/internal` 500
79
- * carrying the original as `cause` (kept internal) and a generic public message — mirroring the
80
- * CLI's "unexpected crash" path.
76
+ * Whatever was thrown, as the `PithyError` the response will be rendered from.
77
+ *
78
+ * A `PithyError` is itself; a Hono `HTTPException` 400 becomes `validation/invalid_input`; anything
79
+ * else is wrapped as a `core/internal` 500 carrying the original as `cause` (kept internal) and a
80
+ * generic public message — mirroring the CLI's "unexpected crash" path.
81
+ *
82
+ * Split out from {@link pithyErrorHandler} so a caller that wants to **read** the fault before it is
83
+ * encoded — the logging handler in `logger/errorHandler.ts` — works from the same object the response
84
+ * is built from, rather than translating a second time and logging a different instance of it. The
85
+ * split is what keeps this module free of the logger: the translation is exported, the log is written
86
+ * on the other side of the boundary. See `logger/boundary.test.ts`.
81
87
  */
82
- export function pithyErrorHandler(err: Error, c: Context): Response {
88
+ export function toPithyError(err: Error): PithyError {
89
+ if (err instanceof PithyError) return err;
83
90
  const translated = err instanceof HTTPException ? fromHttpException(err) : null;
84
- const pithy =
85
- err instanceof PithyError
86
- ? err
87
- : (translated ?? new InternalError({ detail: err instanceof Error ? err.message : String(err) }, { cause: err }));
91
+ return translated ?? new InternalError({ detail: err instanceof Error ? err.message : String(err) }, { cause: err });
92
+ }
93
+
94
+ /** Render an already-translated fault as its HTTP response — the client half, and only that. */
95
+ export function renderPithyError(pithy: PithyError, c: Context): Response {
88
96
  // A kit member pins a literal status; an adopter's carries a `number` the schema has already
89
97
  // bounded to 400–599. Hono types the argument as its own literal union, and that is the one gap
90
98
  // between the two — the value is validated, so the assertion narrows rather than trusts.
91
99
  return c.json({ error: HttpError.encode(pithy.payload) }, pithy.payload.status as ContentfulStatusCode);
92
100
  }
101
+
102
+ /**
103
+ * Hono `onError` handler — the **encoder**, with no log of its own.
104
+ *
105
+ * Any `PithyError` becomes `{ error: <public payload> }` at its declared status; a Hono
106
+ * `HTTPException` 400 becomes `validation/invalid_input`; any other throw is wrapped as a
107
+ * `core/internal` 500.
108
+ *
109
+ * **Prefer `loggingErrorHandler` from `logger/errorHandler.ts`** — `createBackend` registers that one,
110
+ * and it is what makes a misconfiguration's `action` reach an operator instead of dying at the wire
111
+ * (#521). This one stays exported for an app assembled without the logger seam, and it stays here,
112
+ * logger-free, because this module is on the client side of the logger boundary.
113
+ */
114
+ export function pithyErrorHandler(err: Error, c: Context): Response {
115
+ return renderPithyError(toPithyError(err), c);
116
+ }
@@ -0,0 +1,38 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { KitErrorCode } from "./payload";
5
+
6
+ /**
7
+ * The `params` a throw site **guarantees** for a kit error code — and therefore the only names a
8
+ * locale may write as a `{placeholder}` in that code's sentence.
9
+ *
10
+ * **Why a declaration exists at all.** `params` crosses the wire with `message` so a translating
11
+ * client can render its own wording for a code (CLAUDE.md §Errors), and `interpolate` leaves a
12
+ * placeholder nobody supplied written out as `{apiAnswer}`. So a locale writing a placeholder is
13
+ * making a claim about a *call site*, and a call is not a declaration — there was nothing in this
14
+ * repository for a catalog test to read, which is why `@pithy-sh/i18n` could only pin the absence of
15
+ * placeholders. This is that missing half: one table, in `core`, that both sides import.
16
+ *
17
+ * **Guaranteed means every throw, not most.** A name belongs here only when the code's throw sites
18
+ * pass it on **every** path, including the degraded one — a Cloudflare call that never reached
19
+ * Cloudflare still supplies `apiAnswer`, as the empty string. A param a throw site passes only
20
+ * sometimes is still useful to a client reading `payload.params` directly, and is still stripped from
21
+ * nothing; it simply may not be written into a sentence, because the sentence has to render on the
22
+ * path where it is missing.
23
+ *
24
+ * A code absent from this table guarantees nothing, which is the honest default and the state of all
25
+ * but one of them.
26
+ */
27
+ export const GUARANTEED_ERROR_PARAMS: Partial<Record<KitErrorCode, readonly string[]>> = {
28
+ // `@pithy-sh/cloudflare`'s `cloudflareRefusal` composes every Cloudflare refusal and always passes
29
+ // `apiAnswer`: Cloudflare's own code and sentence, punctuated to append (`": 10000 Authentication
30
+ // error"`), or `""` when the call failed without an answer. The value carries its own separator so
31
+ // one sentence renders correctly both ways — a locale cannot write an `if`.
32
+ "cloudflare/request_failed": ["apiAnswer"],
33
+ };
34
+
35
+ /** The names a locale may interpolate into `code`'s sentence. Empty when the code guarantees none. */
36
+ export function guaranteedErrorParams(code: string): readonly string[] {
37
+ return GUARANTEED_ERROR_PARAMS[code as KitErrorCode] ?? [];
38
+ }
@@ -5,11 +5,22 @@ import type { ErrorPayload } from "./payload";
5
5
  import { PublicErrorPayload } from "./payload";
6
6
 
7
7
  /**
8
- * Render an error for the terminal: the public `message` becomes the problem line and the
9
- * optional `action` the action line — the two-line brand-voice shape `docs/CLI.md` §3.3 specifies.
10
- * This unifies the CLI's error output into the one `PithyError` family: the CLI catches a
11
- * `PithyError`, colorizes the first line via its `style.ts`, and prints this. `detail` is internal
12
- * and never rendered. The same payload that encodes to HTTP renders here — one error, two surfaces.
8
+ * Render an error for the terminal: the public `message` on top and the optional `action` last — the
9
+ * brand-voice shape `docs/CLI.md` §3.3 specifies. This unifies the CLI's error output into the one
10
+ * `PithyError` family: the CLI catches a `PithyError`, colorizes via its `style.ts`, and prints this.
11
+ * `detail` is internal and never rendered. The same payload that encodes to HTTP renders here — one
12
+ * error, two surfaces.
13
+ *
14
+ * **`message` is one line, and the newline this adds is the only one in the rendering.** #534 tried
15
+ * the other way — a problem line with an upstream's `errors[]` indented beneath it, which reads
16
+ * beautifully in a terminal and is unencodable everywhere else. The newline is a *field separator* in
17
+ * two places that matter more than the indent does. `../workflow/stepMessage` uses it to carry a
18
+ * terminal step's remedy across a durable boundary, so a multi-line `message` raised inside a Workflow
19
+ * step was declined whole and the operator got the platform's prose about durable execution instead of
20
+ * the refusal. And here, the action line is the one line after the message, so a `message` carrying a
21
+ * break puts upstream text where the remedy goes — a forged action line, from text a Worker we did not
22
+ * write can choose. Upstream words are marked with a word (`Cloudflare said:`), never with a line: a
23
+ * word survives a step record, a `--json` line and a browser.
13
24
  */
14
25
  export function renderTerminal(payload: ErrorPayload): string {
15
26
  return payload.action ? `${payload.message}\n${payload.action}` : payload.message;