@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.
@@ -49,17 +49,20 @@ import { InternalError, UpstreamError, WebhookUnverifiedError } from "../error/p
49
49
  * ## What a refusal says
50
50
  *
51
51
  * `core/webhook_unverified`, 401, one code for every step a sender can fail — unreadable token, unknown key,
52
- * bad signature, wrong audience, expired, predicate false. The step goes in `detail`, which the HTTP codec
52
+ * a key that cannot verify an `RS256` signature, bad signature, wrong audience, expired, predicate false. A
53
+ * `kid` selects the key, and a `kid` is a value the sender chose, so *every* answer the key leads to is the
54
+ * sender's. The step goes in `detail`, which the HTTP codec
53
55
  * strips, so an operator reading a log learns which check refused it and the sender learns only that
54
56
  * something did. Values that decide something are named there (`kid`, `iss`, `aud`) because a misconfigured
55
57
  * audience is the realistic failure and an operator cannot fix what the log will not name — truncated by
56
58
  * {@link snippet}, and never the signature and never the whole token.
57
59
  *
58
- * Two failures are deliberately **not** the sender's. An endpoint with no issuers, no audience, no key
59
- * endpoint, or a clock that is not a clock is a configuration fault and takes `core/internal` — reporting it
60
- * as an unverified webhook sends an operator hunting a forger who is not there. And a key endpoint that
61
- * cannot be reached or answers nonsense is `core/upstream_failed` (502), because a 500 tells an operator to
62
- * read *our* logs and the thing that broke is the issuer's. Both still refuse the delivery: fail closed is
60
+ * Two failures are deliberately **not** the sender's, and the test for both is whether the sender could have
61
+ * chosen them. An endpoint with no issuers, no audience, no key endpoint, or a clock that is not a clock is a
62
+ * configuration fault and takes `core/internal` — reporting it as an unverified webhook sends an operator
63
+ * hunting a forger who is not there. And a key endpoint that cannot be reached or answers nonsense is
64
+ * `core/upstream_failed` (502), because a 500 tells an operator to read *our* logs and the thing that broke
65
+ * is the issuer's; no `kid` decides whether that happens. Both still refuse the delivery: fail closed is
63
66
  * about what happens to the request, not about whose code goes on it.
64
67
  *
65
68
  * ## What this proves, and what it does not
@@ -69,9 +72,31 @@ import { InternalError, UpstreamError, WebhookUnverifiedError } from "../error/p
69
72
  * deletes needs its own uniqueness key.
70
73
  */
71
74
 
72
- /** How long a fetched key set is reused. Issuers rotate slowly, and an unknown `kid` forces a refresh anyway. */
75
+ /** How long a fetched key set is reused. Issuers rotate slowly, so an hour of it is one round trip an hour. */
73
76
  export const OIDC_JWKS_TTL_SECONDS = 3600;
74
77
 
78
+ /**
79
+ * **The most often the issuer's key endpoint is asked, whatever a token's `kid` claims.**
80
+ *
81
+ * The TTL above bounds the *hit* path and bounds nothing else. A `kid` the held set does not name is a miss,
82
+ * a miss is a reason to refresh — that is what makes rotation invisible — and `kid` arrives in a header
83
+ * nobody has verified yet. So the miss path was the whole amplifier: `Bearer <jwt with kid: random-uuid>` is
84
+ * exactly as cheap to mint as one naming a published key, and each one bought a round trip to the issuer,
85
+ * 1:1, from an anonymous caller. The cache answered only the forger who bothered to name a real `kid`.
86
+ *
87
+ * A window closes it, and it closes it for every `kid` at once because it is not about the `kid`: at most one
88
+ * ask per window per key endpoint, and a miss inside the window is refused on what we already hold rather
89
+ * than on a fresh look. A random `kid` therefore costs a lookup in memory, and the outbound rate is a
90
+ * constant an attacker cannot move.
91
+ *
92
+ * **A minute, and the number is a rotation delay.** It is how long a genuinely new signing key can be refused
93
+ * before we look again — an issuer publishes the key before the first token names it, and every one of them
94
+ * overlaps rotations by hours, so a minute is inside the overlap and a refusal inside it is a redelivery
95
+ * rather than a loss. Wider trades rotation latency for nothing much: one ask a minute is already too little
96
+ * traffic for any issuer to rate-limit.
97
+ */
98
+ export const OIDC_JWKS_MIN_REFRESH_SECONDS = 60;
99
+
75
100
  /** Tolerance on `exp`, `nbf` and `iat`. A minute covers clock drift; an hour would cover a replay. */
76
101
  export const OIDC_CLOCK_SKEW_SECONDS = 60;
77
102
 
@@ -210,20 +235,43 @@ export const oidcJwksFetch: OidcJwksFetch = (url) => fetch(url) as unknown as Pr
210
235
  * over KV or the Cache API and shares it across isolates.
211
236
  *
212
237
  * **Owning the store is not the same as having none.** {@link requireOidcWebhook} builds one per guard when a
213
- * route gives none, because the guard is the surface anonymous traffic arrives on: with no cache, a 49-byte
214
- * forged token naming a published `kid` buys a round trip to the issuer, 1:1, until the issuer rate-limits us
215
- * and the real deliveries 502 alongside the forgeries. {@link verifyOidcToken} called directly is the other
216
- * case and does fetch per call — its caller holds one token it has already decided to check, so there is no
217
- * flood to absorb and no cache to acquire by surprise.
238
+ * route gives none, because the guard is the surface anonymous traffic arrives on: with no store, a 49-byte
239
+ * forged token buys a round trip to the issuer, 1:1, until the issuer rate-limits us and the real deliveries
240
+ * 502 alongside the forgeries. {@link verifyOidcToken} called directly is the other case and does fetch per
241
+ * call — its caller holds one token it has already decided to check, so there is no flood to absorb and no
242
+ * cache to acquire by surprise.
243
+ *
244
+ * **Two facts, not one, and the second is the one that bounds an attacker.** `get`/`set` hold what the issuer
245
+ * published, which answers a `kid` we have seen. {@link claimRefresh} holds *when we last asked*, which is
246
+ * what answers a `kid` we have not — and that is the whole of the miss path, where an unverified header used
247
+ * to buy an outbound request per delivery. Both live in the store rather than in a closure so that a store
248
+ * shared across isolates (KV, the Cache API) shares the bound too: one ask per window for the deployment,
249
+ * not one per isolate.
218
250
  *
219
251
  * Public keys, so nothing here is what CLAUDE.md's secrets rule governs: the whole point of a JWKS endpoint
220
- * is that anyone may read it. What the cache buys is one round trip per TTL instead of one per delivery.
252
+ * is that anyone may read it. What the store buys is one round trip per TTL on the hit path and one per
253
+ * {@link OIDC_JWKS_MIN_REFRESH_SECONDS} on the miss path, instead of one per delivery on both.
221
254
  */
222
255
  export interface JwksCache {
223
256
  /** The key set held for `url`, or `undefined` when nothing usable is held. */
224
257
  get(url: string): Promise<readonly OidcJwk[] | undefined>;
225
258
  /** Hold a freshly fetched key set for `url` for `ttlSeconds`. */
226
259
  set(url: string, keys: readonly OidcJwk[], ttlSeconds: number): Promise<void>;
260
+ /**
261
+ * **May the issuer's endpoint be asked for `url` right now?** `true` grants the ask and records it for
262
+ * `seconds`; `false` says somebody already asked inside that window.
263
+ *
264
+ * One call rather than a read and a write, because the question and the claim are the same act: two
265
+ * deliveries that both read *nobody has asked* and then both ask are the flood this exists to stop. An
266
+ * implementation decides and records before it yields — {@link memoryJwksCache} does it synchronously
267
+ * inside the promise, so concurrent deliveries in one isolate produce exactly one ask. A store over a
268
+ * network is best-effort about that, and best-effort is still a bound.
269
+ *
270
+ * It is about the *ask*, never about the answer, which is what keeps a failing endpoint from being a
271
+ * second amplifier: a refresh that ends in `core/upstream_failed` has still spent the window, so a flood
272
+ * arriving while the issuer is down costs one attempt per window rather than one per delivery.
273
+ */
274
+ claimRefresh(url: string, seconds: number): Promise<boolean>;
227
275
  }
228
276
 
229
277
  /**
@@ -236,6 +284,8 @@ export interface JwksCache {
236
284
  export function memoryJwksCache(options: { now?: () => Date } = {}): JwksCache {
237
285
  const now = options.now ?? (() => new Date());
238
286
  const held = new Map<string, { keys: readonly OidcJwk[]; expiresAt: number }>();
287
+ /** When the endpoint behind each url may be asked again. The miss path's whole state. */
288
+ const askedUntil = new Map<string, number>();
239
289
  return {
240
290
  get: async (url) => {
241
291
  const entry = held.get(url);
@@ -249,6 +299,16 @@ export function memoryJwksCache(options: { now?: () => Date } = {}): JwksCache {
249
299
  set: async (url, keys, ttlSeconds) => {
250
300
  held.set(url, { keys, expiresAt: now().getTime() + ttlSeconds * 1000 });
251
301
  },
302
+ // Decided and recorded with no `await` between the read and the write, which is what makes this a claim
303
+ // rather than a check: an isolate handling a thousand concurrent forgeries runs this body a thousand
304
+ // times and grants it once. The async signature is the seam's, for the stores that do reach a network.
305
+ claimRefresh: async (url, seconds) => {
306
+ const at = now().getTime();
307
+ const until = askedUntil.get(url);
308
+ if (until !== undefined && until > at) return false;
309
+ askedUntil.set(url, at + seconds * 1000);
310
+ return true;
311
+ },
252
312
  };
253
313
  }
254
314
 
@@ -281,6 +341,10 @@ export interface OidcWebhookScheme {
281
341
  /**
282
342
  * How long a fetched key set is held. Defaults to {@link OIDC_JWKS_TTL_SECONDS} and must be a finite number
283
343
  * of seconds — a `NaN` is an entry that never expires, which pins a key the issuer has rotated out.
344
+ *
345
+ * Keep it above {@link OIDC_JWKS_MIN_REFRESH_SECONDS}. Below it, a set expires while the window that
346
+ * governs re-asking is still spent, and every delivery in the gap is answered `core/upstream_failed`
347
+ * because nothing is held and nothing may be asked. An hour against a minute is not close to that line.
284
348
  */
285
349
  jwksTtlSeconds?: number;
286
350
  /**
@@ -315,9 +379,11 @@ export interface OidcWebhookGuardOptions extends OidcWebhookScheme {
315
379
  * it has not checked yet.
316
380
  *
317
381
  * @throws {@link WebhookUnverifiedError} (401) when the sender did not prove itself — unreadable token,
318
- * unknown key, bad signature, wrong issuer or audience, expired, not yet valid, predicate false.
382
+ * unknown key, a key that cannot verify an `RS256` signature, bad signature, wrong issuer or audience,
383
+ * expired, not yet valid, predicate false.
319
384
  * @throws {@link InternalError} (500) when this endpoint is not configured to verify anything.
320
- * @throws {@link UpstreamError} (502) when the issuer's key endpoint could not be read.
385
+ * @throws {@link UpstreamError} (502) when the issuer's key endpoint could not be read — either because the
386
+ * ask failed, or because it was asked inside {@link OIDC_JWKS_MIN_REFRESH_SECONDS} and nothing is held.
321
387
  */
322
388
  export async function verifyOidcToken(token: string, options: VerifyOidcTokenOptions): Promise<OidcClaims> {
323
389
  // Ours before theirs. An endpoint with nothing to check against refuses every delivery, and calling that an
@@ -435,21 +501,24 @@ export async function verifyOidcToken(token: string, options: VerifyOidcTokenOpt
435
501
  * be unauthenticated work an anonymous POST could buy on the one route whose purpose is to refuse anonymous
436
502
  * callers.
437
503
  *
438
- * ## Why a cache is not optional here
504
+ * ## Why a store is not optional here
439
505
  *
440
506
  * A caller that presents *something* is past that first check, and the next thing the verifier needs is the
441
507
  * issuer's key set. So the guard builds a {@link memoryJwksCache} when the route gives none — once, when the
442
- * middleware is constructed, never per request, which is a cache that has never held anything. A `kid` is
443
- * published by definition, which makes a 49-byte token naming one the cheapest thing on the internet to send,
444
- * and with no cache each one is a round trip to the issuer, 1:1. That is a bandwidth multiplier aimed at
445
- * somebody else's endpoint, and once the issuer rate-limits us {@link fetchJwks} maps it to
446
- * `core/upstream_failed` for the genuine deliveries too — an anonymous caller taking the boundary down by
447
- * asking it politely. A route wanting a harder bound (shared across isolates, or rate-limiting) passes its
448
- * own {@link OidcWebhookScheme.jwksCache}, and this one is never built.
449
- *
450
- * A `kid` **nobody** publishes still costs one fetch per delivery, cache or no cache, because an unknown key
451
- * id is how rotation announces itself and declining to look is an outage. That bound is per delivery and
452
- * lives in {@link resolveKey}; the cache is what collapses the other flood, the one naming a key that exists.
508
+ * middleware is constructed, never per request, which is a store that has never held anything. A 49-byte
509
+ * token is the cheapest thing on the internet to send, and with no store each one is a round trip to the
510
+ * issuer, 1:1. That is a bandwidth multiplier aimed at somebody else's endpoint, and once the issuer
511
+ * rate-limits us {@link fetchJwks} maps it to `core/upstream_failed` for the genuine deliveries too — an
512
+ * anonymous caller taking the boundary down by asking it politely.
513
+ *
514
+ * **Both halves of the flood, not one.** A token naming a `kid` the issuer really publishes is answered out
515
+ * of what the store holds. A token naming a `kid` nobody has ever published is answered by
516
+ * {@link JwksCache.claimRefresh}: one ask per {@link OIDC_JWKS_MIN_REFRESH_SECONDS} per key endpoint, for
517
+ * every `kid` at once, so a `kid` an attacker invents costs a map lookup and no outbound request. The first
518
+ * round of this fix had only the first half, which meant the cache helped exactly against a forger who
519
+ * bothered to copy a real `kid` and not at all against one who did not. A route wanting the bound shared
520
+ * across isolates rather than held per isolate passes its own {@link OidcWebhookScheme.jwksCache} — over KV
521
+ * or the Cache API — and this one is never built.
453
522
  *
454
523
  * ## Why nothing is published to the request
455
524
  *
@@ -482,22 +551,52 @@ export function requireOidcWebhook(options: OidcWebhookGuardOptions): Middleware
482
551
  }
483
552
 
484
553
  /**
485
- * The key a token's `kid` names, from the cache when it holds it and from the issuer otherwise.
486
- *
487
- * A `kid` the cache does not hold triggers **exactly one** refresh, which is what makes key rotation
488
- * invisible: a new signing key appears in a token before anything tells us to look for it, and waiting out a
489
- * TTL would be an outage. Exactly one, because otherwise a stream of forged tokens naming random `kid`s is a
490
- * way to make us hammer the issuer — one delivery buys one fetch, whatever it claims. A caller that wants a
491
- * harder bound than that gives a {@link JwksCache} that rate-limits, which is the other thing injecting the
492
- * store buys.
554
+ * The key a token's `kid` names, from the store when it holds it and from the issuer otherwise.
555
+ *
556
+ * ## The miss path is the one an anonymous caller reaches
557
+ *
558
+ * A `kid` the store does not hold is a reason to refresh, and it has to be: a new signing key appears in a
559
+ * token before anything tells us to look for it, so waiting out a TTL would be an outage. But `kid` is read
560
+ * out of a header nobody has verified yet, and the two things it can mean — *the issuer rotated* and *I made
561
+ * this up* — are indistinguishable until after the fetch it costs. That is the whole amplifier, and it is why
562
+ * a store keyed only on what the issuer published answered the wrong half of it: the forger who names a
563
+ * published `kid` was collapsed onto one fetch, and the one who names `crypto.randomUUID()` bought a round
564
+ * trip per delivery, from an endpoint whose purpose is refusing anonymous callers.
565
+ *
566
+ * So the ask is claimed before it is made — {@link JwksCache.claimRefresh}, one per
567
+ * {@link OIDC_JWKS_MIN_REFRESH_SECONDS} per key endpoint, for every `kid` together rather than per `kid`.
568
+ * Per `kid` would be no bound at all: an attacker has as many of those as they care to type.
569
+ *
570
+ * ## What a miss inside the window answers, and why it is two different codes
571
+ *
572
+ * Whatever we hold is what the issuer published a moment ago, so a `kid` outside it is a `kid` the issuer
573
+ * does not publish — the sender's failure, 401, audited, and not worth retrying. **Unless we hold nothing**,
574
+ * which means the last ask inside this window failed or has not come back: then the honest answer is that we
575
+ * could not look, and that is the issuer's 502. Neither answer costs an outbound request, which is the point.
576
+ *
577
+ * With no store at all there is nothing to claim and nothing to hold, so every call fetches. That is
578
+ * {@link verifyOidcToken}'s direct-call shape — one caller, one token it already decided to check — and
579
+ * never the guard's, which builds a store precisely so this paragraph does not describe it.
493
580
  */
494
581
  async function resolveKey(kid: string, options: VerifyOidcTokenOptions): Promise<OidcJwk> {
495
- const held = await options.jwksCache?.get(options.jwksUrl);
582
+ const cache = options.jwksCache;
583
+ const held = await cache?.get(options.jwksUrl);
496
584
  const cached = held?.find((key) => key.kid === kid);
497
585
  if (cached) return cached;
498
586
 
587
+ if (cache !== undefined && !(await cache.claimRefresh(options.jwksUrl, OIDC_JWKS_MIN_REFRESH_SECONDS))) {
588
+ if (held === undefined || held.length === 0) {
589
+ throw upstream(
590
+ `The OIDC key endpoint ${options.jwksUrl} was asked within the last ${OIDC_JWKS_MIN_REFRESH_SECONDS} seconds and nothing is held, so the token's kid could not be looked up.`,
591
+ );
592
+ }
593
+ throw unverified(
594
+ `no key the issuer publishes matches the token's kid ${snippet(kid)}, and the published set was read within the last ${OIDC_JWKS_MIN_REFRESH_SECONDS} seconds.`,
595
+ );
596
+ }
597
+
499
598
  const refreshed = await fetchJwks(options);
500
- await options.jwksCache?.set(options.jwksUrl, refreshed, options.jwksTtlSeconds ?? OIDC_JWKS_TTL_SECONDS);
599
+ await cache?.set(options.jwksUrl, refreshed, options.jwksTtlSeconds ?? OIDC_JWKS_TTL_SECONDS);
501
600
  const found = refreshed.find((key) => key.kid === kid);
502
601
  if (!found) {
503
602
  throw unverified(`no key the issuer publishes matches the token's kid ${snippet(kid)}.`);
@@ -541,17 +640,33 @@ async function fetchJwks(options: VerifyOidcTokenOptions): Promise<OidcJwk[]> {
541
640
  return parsed.data.keys;
542
641
  }
543
642
 
544
- /** Import one published JWK as a verification key. A non-RSA key is refused rather than handed to WebCrypto. */
643
+ /**
644
+ * Import one published JWK as a verification key. A non-RSA key is refused rather than handed to WebCrypto.
645
+ *
646
+ * **Both refusals are the sender's, 401, and the reason is who chose the key.** A published key that cannot
647
+ * verify an `RS256` signature looks like the issuer's fact, and as a fact about the key set it is — but the
648
+ * key reached here because a `kid` in an unverified header selected it, so as an *answer* it is attacker-
649
+ * chosen. A 502 hands that caller three things it must not have: a code the webhook guard passes through
650
+ * untouched, so the refusal never reaches the audit trail that exists to show a rail being probed; a status
651
+ * a sender is invited to retry, which for Pub/Sub means forever; and a lever for picking which of those two
652
+ * an endpoint answers. The narrower reading is also the true one — a token naming a key that cannot check its
653
+ * own algorithm is a token nothing can verify, which is what `core/webhook_unverified` says.
654
+ *
655
+ * The issuer's own failures stay the issuer's: {@link fetchJwks} is 502 for every one of them, and no `kid`
656
+ * decides whether they happen.
657
+ */
545
658
  async function importKey(jwk: OidcJwk): Promise<CryptoKey> {
546
659
  if (jwk.kty !== "RSA") {
547
- throw upstream(`Published key ${snippet(jwk.kid)} is ${snippet(jwk.kty)}, and only RSA keys sign an OIDC token.`);
660
+ throw unverified(
661
+ `the token's kid ${snippet(jwk.kid)} names a ${snippet(jwk.kty)} key, and only RSA keys sign an OIDC token.`,
662
+ );
548
663
  }
549
664
  try {
550
665
  // `alg` is forced to `RS256` rather than taken from the published key: the algorithm is already pinned by
551
666
  // the header literal, and a key set that published a different one would otherwise decide it for us.
552
667
  return await crypto.subtle.importKey("jwk", { ...jwk, alg: "RS256" }, RS256, false, ["verify"]);
553
668
  } catch (cause) {
554
- throw upstream(`Published key ${snippet(jwk.kid)} could not be imported.`, cause);
669
+ throw unverified(`the key the token's kid ${snippet(jwk.kid)} names could not be imported.`, cause);
555
670
  }
556
671
  }
557
672
 
@@ -13,4 +13,4 @@
13
13
  export const PACKAGE_NAME = "@pithy-sh/core";
14
14
 
15
15
  /** This package's version, stamped from its own package.json at generation time. */
16
- export const PACKAGE_VERSION = "0.3.0";
16
+ export const PACKAGE_VERSION = "0.4.0";
@@ -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
  /**
@@ -123,17 +138,48 @@ export function splitWorkflowStepCode(text: string): { code?: string; rest: stri
123
138
  return { code: matched[1], rest: text.slice(matched[0].length) };
124
139
  }
125
140
 
141
+ /**
142
+ * One half as this encoding can carry it: a single line, within the bound.
143
+ *
144
+ * Both reshapes exist because the alternative is losing the field, and the two ways a half can be
145
+ * unreadable are the two ways a writer actually gets it wrong. A line break would encode a shape the
146
+ * reader declines — the sentence would take the remedy down with it, or the remedy would forge a
147
+ * second field — so every break collapses to a space, exactly as `@pithy-sh/cloudflare` flattens an
148
+ * upstream's own text before it lands in `message`. Length is the same argument with a different
149
+ * cause: half a sentence naming its code and its remedy beats the platform's prose about durable
150
+ * execution, so the tail is spent rather than the field. The whole of it was never here anyway —
151
+ * `detail` holds the raw text, on the far side of a boundary it does not cross.
152
+ */
153
+ function encodable(text: string): string {
154
+ // The break and the whitespace that dressed it: an indent under a line is structure, and structure
155
+ // that survives as run-on spaces reads as a typo. Whitespace elsewhere in the half is left alone —
156
+ // this reshapes what the line break did, not what the sentence says.
157
+ const flattened = text.replace(/\s*[\n\r]+\s*/g, " ").trim();
158
+ if (flattened.length <= MAX_WORKFLOW_STEP_TEXT) return flattened;
159
+ return `${flattened.slice(0, MAX_WORKFLOW_STEP_TEXT - 1).trimEnd()}…`;
160
+ }
161
+
126
162
  /**
127
163
  * The text a terminal step throws. The inverse of {@link decodeWorkflowStepMessage}, and tested as one.
128
164
  *
129
- * An action is appended only when there is one to append and it is a single line. An action carrying a
130
- * break would encode a shape the reader declines, taking the sentence down with it — so the sentence
131
- * is kept and the action dropped. Losing the remedy is a worse day; losing both is a worse one still.
165
+ * **What this writes, that reads back — for a well-formed code and a sentence there is one.** Each half
166
+ * goes through {@link encodable}, so a multi-line sentence is flattened rather than fatal and an overlong
167
+ * one is truncated rather than declined. An action is appended only when there is one left after that —
168
+ * a remedy that was empty or only whitespace leaves no trailing separator, because a dangling separator
169
+ * is a field the reader would have to decline.
170
+ *
171
+ * **Two inputs it deliberately cannot represent, so "total" is not the whole word for it.** An empty
172
+ * `message` encodes to text {@link decodeWorkflowStepMessage} declines, and so does a `code` that does
173
+ * not match `CODE_PREFIX`. Both refusals are the boundary working: a step that failed without a sentence
174
+ * has none to promote — the caller's own `fallbackMessage` is what the operator should see — and a code
175
+ * the reader cannot recognize is exactly the foreign text this encoding exists to keep out of an
176
+ * operator's sentence. Neither is reachable from a kit throw site; an adopter code declared through
177
+ * `defineErrorPayload` is checked against the same shape.
132
178
  */
133
179
  export function encodeWorkflowStepMessage(fields: WorkflowStepMessage): string {
134
- const stated = `${fields.code}: ${fields.message}`;
135
- const action = fields.action?.trim();
136
- if (action === undefined || action === "" || LINE_BREAK.test(action)) return stated;
180
+ const stated = `${fields.code}: ${encodable(fields.message)}`;
181
+ const action = encodable(fields.action ?? "");
182
+ if (action === "") return stated;
137
183
  return `${stated}${WORKFLOW_STEP_SEPARATOR}${action}`;
138
184
  }
139
185