@pithy-sh/core 0.3.0 → 0.4.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.
@@ -46,17 +46,20 @@ import { z } from "zod";
46
46
  * ## What a refusal says
47
47
  *
48
48
  * `core/webhook_unverified`, 401, one code for every step a sender can fail — unreadable token, unknown key,
49
- * bad signature, wrong audience, expired, predicate false. The step goes in `detail`, which the HTTP codec
49
+ * a key that cannot verify an `RS256` signature, bad signature, wrong audience, expired, predicate false. A
50
+ * `kid` selects the key, and a `kid` is a value the sender chose, so *every* answer the key leads to is the
51
+ * sender's. The step goes in `detail`, which the HTTP codec
50
52
  * strips, so an operator reading a log learns which check refused it and the sender learns only that
51
53
  * something did. Values that decide something are named there (`kid`, `iss`, `aud`) because a misconfigured
52
54
  * audience is the realistic failure and an operator cannot fix what the log will not name — truncated by
53
55
  * {@link snippet}, and never the signature and never the whole token.
54
56
  *
55
- * Two failures are deliberately **not** the sender's. An endpoint with no issuers, no audience, no key
56
- * endpoint, or a clock that is not a clock is a configuration fault and takes `core/internal` — reporting it
57
- * as an unverified webhook sends an operator hunting a forger who is not there. And a key endpoint that
58
- * cannot be reached or answers nonsense is `core/upstream_failed` (502), because a 500 tells an operator to
59
- * read *our* logs and the thing that broke is the issuer's. Both still refuse the delivery: fail closed is
57
+ * Two failures are deliberately **not** the sender's, and the test for both is whether the sender could have
58
+ * chosen them. An endpoint with no issuers, no audience, no key endpoint, or a clock that is not a clock is a
59
+ * configuration fault and takes `core/internal` — reporting it as an unverified webhook sends an operator
60
+ * hunting a forger who is not there. And a key endpoint that cannot be reached or answers nonsense is
61
+ * `core/upstream_failed` (502), because a 500 tells an operator to read *our* logs and the thing that broke
62
+ * is the issuer's; no `kid` decides whether that happens. Both still refuse the delivery: fail closed is
60
63
  * about what happens to the request, not about whose code goes on it.
61
64
  *
62
65
  * ## What this proves, and what it does not
@@ -65,8 +68,29 @@ import { z } from "zod";
65
68
  * never that it is *new*. A captured token replays until it expires. A handler that grants, charges, or
66
69
  * deletes needs its own uniqueness key.
67
70
  */
68
- /** How long a fetched key set is reused. Issuers rotate slowly, and an unknown `kid` forces a refresh anyway. */
71
+ /** How long a fetched key set is reused. Issuers rotate slowly, so an hour of it is one round trip an hour. */
69
72
  const OIDC_JWKS_TTL_SECONDS = 3600;
73
+ /**
74
+ * **The most often the issuer's key endpoint is asked, whatever a token's `kid` claims.**
75
+ *
76
+ * The TTL above bounds the *hit* path and bounds nothing else. A `kid` the held set does not name is a miss,
77
+ * a miss is a reason to refresh — that is what makes rotation invisible — and `kid` arrives in a header
78
+ * nobody has verified yet. So the miss path was the whole amplifier: `Bearer <jwt with kid: random-uuid>` is
79
+ * exactly as cheap to mint as one naming a published key, and each one bought a round trip to the issuer,
80
+ * 1:1, from an anonymous caller. The cache answered only the forger who bothered to name a real `kid`.
81
+ *
82
+ * A window closes it, and it closes it for every `kid` at once because it is not about the `kid`: at most one
83
+ * ask per window per key endpoint, and a miss inside the window is refused on what we already hold rather
84
+ * than on a fresh look. A random `kid` therefore costs a lookup in memory, and the outbound rate is a
85
+ * constant an attacker cannot move.
86
+ *
87
+ * **A minute, and the number is a rotation delay.** It is how long a genuinely new signing key can be refused
88
+ * before we look again — an issuer publishes the key before the first token names it, and every one of them
89
+ * overlaps rotations by hours, so a minute is inside the overlap and a refusal inside it is a redelivery
90
+ * rather than a loss. Wider trades rotation latency for nothing much: one ask a minute is already too little
91
+ * traffic for any issuer to rate-limit.
92
+ */
93
+ const OIDC_JWKS_MIN_REFRESH_SECONDS = 60;
70
94
  /** Tolerance on `exp`, `nbf` and `iat`. A minute covers clock drift; an hour would cover a replay. */
71
95
  const OIDC_CLOCK_SKEW_SECONDS = 60;
72
96
  /**
@@ -142,6 +166,8 @@ const oidcJwksFetch = (url) => fetch(url);
142
166
  function memoryJwksCache(options = {}) {
143
167
  const now = options.now ?? (() => /* @__PURE__ */ new Date());
144
168
  const held = /* @__PURE__ */ new Map();
169
+ /** When the endpoint behind each url may be asked again. The miss path's whole state. */
170
+ const askedUntil = /* @__PURE__ */ new Map();
145
171
  return {
146
172
  get: async (url) => {
147
173
  const entry = held.get(url);
@@ -157,6 +183,13 @@ function memoryJwksCache(options = {}) {
157
183
  keys,
158
184
  expiresAt: now().getTime() + ttlSeconds * 1e3
159
185
  });
186
+ },
187
+ claimRefresh: async (url, seconds) => {
188
+ const at = now().getTime();
189
+ const until = askedUntil.get(url);
190
+ if (until !== void 0 && until > at) return false;
191
+ askedUntil.set(url, at + seconds * 1e3);
192
+ return true;
160
193
  }
161
194
  };
162
195
  }
@@ -169,9 +202,11 @@ function memoryJwksCache(options = {}) {
169
202
  * it has not checked yet.
170
203
  *
171
204
  * @throws {@link WebhookUnverifiedError} (401) when the sender did not prove itself — unreadable token,
172
- * unknown key, bad signature, wrong issuer or audience, expired, not yet valid, predicate false.
205
+ * unknown key, a key that cannot verify an `RS256` signature, bad signature, wrong issuer or audience,
206
+ * expired, not yet valid, predicate false.
173
207
  * @throws {@link InternalError} (500) when this endpoint is not configured to verify anything.
174
- * @throws {@link UpstreamError} (502) when the issuer's key endpoint could not be read.
208
+ * @throws {@link UpstreamError} (502) when the issuer's key endpoint could not be read — either because the
209
+ * ask failed, or because it was asked inside {@link OIDC_JWKS_MIN_REFRESH_SECONDS} and nothing is held.
175
210
  */
176
211
  async function verifyOidcToken(token, options) {
177
212
  const issuers = options.issuers.filter((issuer) => issuer.length > 0);
@@ -209,21 +244,24 @@ async function verifyOidcToken(token, options) {
209
244
  * be unauthenticated work an anonymous POST could buy on the one route whose purpose is to refuse anonymous
210
245
  * callers.
211
246
  *
212
- * ## Why a cache is not optional here
247
+ * ## Why a store is not optional here
213
248
  *
214
249
  * A caller that presents *something* is past that first check, and the next thing the verifier needs is the
215
250
  * issuer's key set. So the guard builds a {@link memoryJwksCache} when the route gives none — once, when the
216
- * middleware is constructed, never per request, which is a cache that has never held anything. A `kid` is
217
- * published by definition, which makes a 49-byte token naming one the cheapest thing on the internet to send,
218
- * and with no cache each one is a round trip to the issuer, 1:1. That is a bandwidth multiplier aimed at
219
- * somebody else's endpoint, and once the issuer rate-limits us {@link fetchJwks} maps it to
220
- * `core/upstream_failed` for the genuine deliveries too — an anonymous caller taking the boundary down by
221
- * asking it politely. A route wanting a harder bound (shared across isolates, or rate-limiting) passes its
222
- * own {@link OidcWebhookScheme.jwksCache}, and this one is never built.
223
- *
224
- * A `kid` **nobody** publishes still costs one fetch per delivery, cache or no cache, because an unknown key
225
- * id is how rotation announces itself and declining to look is an outage. That bound is per delivery and
226
- * lives in {@link resolveKey}; the cache is what collapses the other flood, the one naming a key that exists.
251
+ * middleware is constructed, never per request, which is a store that has never held anything. A 49-byte
252
+ * token is the cheapest thing on the internet to send, and with no store each one is a round trip to the
253
+ * issuer, 1:1. That is a bandwidth multiplier aimed at somebody else's endpoint, and once the issuer
254
+ * rate-limits us {@link fetchJwks} maps it to `core/upstream_failed` for the genuine deliveries too — an
255
+ * anonymous caller taking the boundary down by asking it politely.
256
+ *
257
+ * **Both halves of the flood, not one.** A token naming a `kid` the issuer really publishes is answered out
258
+ * of what the store holds. A token naming a `kid` nobody has ever published is answered by
259
+ * {@link JwksCache.claimRefresh}: one ask per {@link OIDC_JWKS_MIN_REFRESH_SECONDS} per key endpoint, for
260
+ * every `kid` at once, so a `kid` an attacker invents costs a map lookup and no outbound request. The first
261
+ * round of this fix had only the first half, which meant the cache helped exactly against a forger who
262
+ * bothered to copy a real `kid` and not at all against one who did not. A route wanting the bound shared
263
+ * across isolates rather than held per isolate passes its own {@link OidcWebhookScheme.jwksCache} — over KV
264
+ * or the Cache API — and this one is never built.
227
265
  *
228
266
  * ## Why nothing is published to the request
229
267
  *
@@ -252,20 +290,44 @@ function requireOidcWebhook(options) {
252
290
  };
253
291
  }
254
292
  /**
255
- * The key a token's `kid` names, from the cache when it holds it and from the issuer otherwise.
256
- *
257
- * A `kid` the cache does not hold triggers **exactly one** refresh, which is what makes key rotation
258
- * invisible: a new signing key appears in a token before anything tells us to look for it, and waiting out a
259
- * TTL would be an outage. Exactly one, because otherwise a stream of forged tokens naming random `kid`s is a
260
- * way to make us hammer the issuer — one delivery buys one fetch, whatever it claims. A caller that wants a
261
- * harder bound than that gives a {@link JwksCache} that rate-limits, which is the other thing injecting the
262
- * store buys.
293
+ * The key a token's `kid` names, from the store when it holds it and from the issuer otherwise.
294
+ *
295
+ * ## The miss path is the one an anonymous caller reaches
296
+ *
297
+ * A `kid` the store does not hold is a reason to refresh, and it has to be: a new signing key appears in a
298
+ * token before anything tells us to look for it, so waiting out a TTL would be an outage. But `kid` is read
299
+ * out of a header nobody has verified yet, and the two things it can mean — *the issuer rotated* and *I made
300
+ * this up* — are indistinguishable until after the fetch it costs. That is the whole amplifier, and it is why
301
+ * a store keyed only on what the issuer published answered the wrong half of it: the forger who names a
302
+ * published `kid` was collapsed onto one fetch, and the one who names `crypto.randomUUID()` bought a round
303
+ * trip per delivery, from an endpoint whose purpose is refusing anonymous callers.
304
+ *
305
+ * So the ask is claimed before it is made — {@link JwksCache.claimRefresh}, one per
306
+ * {@link OIDC_JWKS_MIN_REFRESH_SECONDS} per key endpoint, for every `kid` together rather than per `kid`.
307
+ * Per `kid` would be no bound at all: an attacker has as many of those as they care to type.
308
+ *
309
+ * ## What a miss inside the window answers, and why it is two different codes
310
+ *
311
+ * Whatever we hold is what the issuer published a moment ago, so a `kid` outside it is a `kid` the issuer
312
+ * does not publish — the sender's failure, 401, audited, and not worth retrying. **Unless we hold nothing**,
313
+ * which means the last ask inside this window failed or has not come back: then the honest answer is that we
314
+ * could not look, and that is the issuer's 502. Neither answer costs an outbound request, which is the point.
315
+ *
316
+ * With no store at all there is nothing to claim and nothing to hold, so every call fetches. That is
317
+ * {@link verifyOidcToken}'s direct-call shape — one caller, one token it already decided to check — and
318
+ * never the guard's, which builds a store precisely so this paragraph does not describe it.
263
319
  */
264
320
  async function resolveKey(kid, options) {
265
- const cached = (await options.jwksCache?.get(options.jwksUrl))?.find((key) => key.kid === kid);
321
+ const cache = options.jwksCache;
322
+ const held = await cache?.get(options.jwksUrl);
323
+ const cached = held?.find((key) => key.kid === kid);
266
324
  if (cached) return cached;
325
+ if (cache !== void 0 && !await cache.claimRefresh(options.jwksUrl, 60)) {
326
+ if (held === void 0 || held.length === 0) throw upstream(`The OIDC key endpoint ${options.jwksUrl} was asked within the last 60 seconds and nothing is held, so the token's kid could not be looked up.`);
327
+ throw unverified(`no key the issuer publishes matches the token's kid ${snippet(kid)}, and the published set was read within the last 60 seconds.`);
328
+ }
267
329
  const refreshed = await fetchJwks(options);
268
- await options.jwksCache?.set(options.jwksUrl, refreshed, options.jwksTtlSeconds ?? 3600);
330
+ await cache?.set(options.jwksUrl, refreshed, options.jwksTtlSeconds ?? 3600);
269
331
  const found = refreshed.find((key) => key.kid === kid);
270
332
  if (!found) throw unverified(`no key the issuer publishes matches the token's kid ${snippet(kid)}.`);
271
333
  return found;
@@ -297,16 +359,30 @@ async function fetchJwks(options) {
297
359
  if (!parsed.success) throw upstream(`The OIDC key endpoint ${options.jwksUrl} answered an unexpected shape — ${issues(parsed.error)}.`);
298
360
  return parsed.data.keys;
299
361
  }
300
- /** Import one published JWK as a verification key. A non-RSA key is refused rather than handed to WebCrypto. */
362
+ /**
363
+ * Import one published JWK as a verification key. A non-RSA key is refused rather than handed to WebCrypto.
364
+ *
365
+ * **Both refusals are the sender's, 401, and the reason is who chose the key.** A published key that cannot
366
+ * verify an `RS256` signature looks like the issuer's fact, and as a fact about the key set it is — but the
367
+ * key reached here because a `kid` in an unverified header selected it, so as an *answer* it is attacker-
368
+ * chosen. A 502 hands that caller three things it must not have: a code the webhook guard passes through
369
+ * untouched, so the refusal never reaches the audit trail that exists to show a rail being probed; a status
370
+ * a sender is invited to retry, which for Pub/Sub means forever; and a lever for picking which of those two
371
+ * an endpoint answers. The narrower reading is also the true one — a token naming a key that cannot check its
372
+ * own algorithm is a token nothing can verify, which is what `core/webhook_unverified` says.
373
+ *
374
+ * The issuer's own failures stay the issuer's: {@link fetchJwks} is 502 for every one of them, and no `kid`
375
+ * decides whether they happen.
376
+ */
301
377
  async function importKey(jwk) {
302
- if (jwk.kty !== "RSA") throw upstream(`Published key ${snippet(jwk.kid)} is ${snippet(jwk.kty)}, and only RSA keys sign an OIDC token.`);
378
+ if (jwk.kty !== "RSA") throw unverified(`the token's kid ${snippet(jwk.kid)} names a ${snippet(jwk.kty)} key, and only RSA keys sign an OIDC token.`);
303
379
  try {
304
380
  return await crypto.subtle.importKey("jwk", {
305
381
  ...jwk,
306
382
  alg: "RS256"
307
383
  }, RS256, false, ["verify"]);
308
384
  } catch (cause) {
309
- throw upstream(`Published key ${snippet(jwk.kid)} could not be imported.`, cause);
385
+ throw unverified(`the key the token's kid ${snippet(jwk.kid)} names could not be imported.`, cause);
310
386
  }
311
387
  }
312
388
  /**
@@ -411,4 +487,4 @@ function snippet(value) {
411
487
  return JSON.stringify(trimmed);
412
488
  }
413
489
  //#endregion
414
- export { OIDC_CLOCK_SKEW_SECONDS, OIDC_JWKS_TTL_SECONDS, OIDC_MAX_CLOCK_SKEW_SECONDS, OidcClaims, OidcJwk, OidcJwks, memoryJwksCache, oidcJwksFetch, requireOidcWebhook, verifyOidcToken };
490
+ export { OIDC_CLOCK_SKEW_SECONDS, OIDC_JWKS_MIN_REFRESH_SECONDS, OIDC_JWKS_TTL_SECONDS, OIDC_MAX_CLOCK_SKEW_SECONDS, OidcClaims, OidcJwk, OidcJwks, memoryJwksCache, oidcJwksFetch, requireOidcWebhook, verifyOidcToken };
@@ -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.3.0";
6
+ export declare const PACKAGE_VERSION = "0.4.0";
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.3.0";
7
+ const PACKAGE_VERSION = "0.4.0";
8
8
  //#endregion
9
9
  export { PACKAGE_NAME, PACKAGE_VERSION };
@@ -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.3.0",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -155,6 +155,16 @@ export interface SecretRegistryEntrySeam {
155
155
  * which it can do without executing the package.
156
156
  */
157
157
  readonly devValue?: string;
158
+ /**
159
+ * The Worker binding this value exists to reach, or absent when it reaches none — the constant half
160
+ * of *can this composition use this secret* (#541).
161
+ *
162
+ * Read here and never interpreted, exactly like {@link devValue}: the CLI joins it against a Worker's
163
+ * `declinedBindings` so a credential whose bucket the project declined stops being listed as
164
+ * outstanding work. `@pithy-sh/secrets` owns the concrete field; core needs it on the seam so a
165
+ * reporting surface can read a capability's declared slice off this contract.
166
+ */
167
+ readonly binding?: string;
158
168
  }
159
169
 
160
170
  /** A capability's secret-registry slice: secret name → {@link SecretRegistryEntrySeam}. */
@@ -188,6 +198,35 @@ export type SecretRegistrySeam = Record<string, SecretRegistryEntrySeam>;
188
198
  */
189
199
  export type SecretBranchSeam = Record<string, readonly string[]>;
190
200
 
201
+ /**
202
+ * **Which of this capability's secrets this Worker's configuration cannot reach** — secret name → the
203
+ * reason, in an operator's words.
204
+ *
205
+ * The neighbor of {@link SecretBranchSeam}, one level up. That one says which *blocks* of a bundle
206
+ * apply when the bundle itself does; this says that a whole secret does not. `@pithy-sh/auth` declares
207
+ * one credential per OAuth provider, so a project running Google and GitHub has two secrets that no
208
+ * code path will ever read — and until #541 `pithy doctor` listed both as outstanding work under
209
+ * *"fine to leave until you need it"*, which is not true of a credential the configuration has refused.
210
+ *
211
+ * **Declared, never inferred**, for {@link SecretBranchSeam}'s reason: `auth-apple-credentials` and
212
+ * `providers.apple` are spelled alike today and nothing makes a secret name and a config key the same
213
+ * thing. The capability holds both halves at construction; a CLI matching them by substring would be
214
+ * right by luck.
215
+ *
216
+ * **Per composition, which is why it is here and not on the registry entry.** A registry entry is plain
217
+ * data shared by every Worker that declares the name; whether a provider is enabled is one Worker's
218
+ * configuration. The constant half of the same question — *which binding does this credential exist to
219
+ * reach* — is on the entry, as `binding`, because it does not change between compositions.
220
+ *
221
+ * **The reason reaches a terminal, so it is a sentence and not a code.** Name the configuration that
222
+ * decided it, the way an operator would have to edit it: `providers.apple is not enabled in
223
+ * pithy.config.ts`. It answers *what would I have to do to turn this on* as well as *why is this here*.
224
+ *
225
+ * Declaring nothing is the ordinary case and means every declared secret applies. It is descriptive
226
+ * only: nothing in the runtime reads it, and no command acts on it beyond reporting.
227
+ */
228
+ export type InapplicableSecretSeam = Record<string, string>;
229
+
191
230
  /**
192
231
  * The structural seam for one capability's token-profile slice — profile name → the scoped CF API
193
232
  * token that capability's code needs. Declared next to the code that uses it (alongside
@@ -332,6 +371,12 @@ export interface Capability<
332
371
  * no `json` secret at all, declares nothing.
333
372
  */
334
373
  secretBranches?: SecretBranchSeam;
374
+ /**
375
+ * Which of this capability's secrets this Worker's configuration cannot reach, and why — see
376
+ * {@link InapplicableSecretSeam}. Additive and optional: a capability whose secrets all apply
377
+ * whenever it is composed declares nothing, which is the ordinary case.
378
+ */
379
+ inapplicableSecrets?: InapplicableSecretSeam;
335
380
  /**
336
381
  * The scoped CF API tokens this capability's code needs, as a token-profile slice (profile name →
337
382
  * {@link TokenProfileSeam}). Additive and optional, and declared next to {@link Capability.secretRegistry}
@@ -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;