@@ -27,6 +27,21 @@ import { routePath } from 'hono/route';
2727import { serve } from '@hono/node-server' ;
2828import { serveStatic } from '@hono/node-server/serve-static' ;
2929import { matchesRoutePattern } from './route-pattern' ;
30+ // The ADR-0112 wire vocabulary, read as DATA rather than restated: `ErrorCode`
31+ // is the closed union (`StandardErrorCode` ∪ `ERROR_CODE_LEDGER`) a registered
32+ // code must be a member of, and `HttpStatusErrorCodeMap` IS the set of statuses
33+ // ADR-0112 declares — the same table `standardErrorCodeForHttpStatus` derives
34+ // from, so "a declared ADR-0112 status" needs no second list here.
35+ import { ErrorCode , HttpStatusErrorCodeMap } from '@objectstack/spec/api' ;
36+ // The ONE rule for "what HTTP answer does a THROWN error declare?" (#8016), and
37+ // the 5xx disclosure filter every door that emits a thrown message already runs
38+ // (#3867 / #8086). Both are CALLED, never restated — a second ladder here is
39+ // how the `/api/v1/packages` two-door divergence arose in the first place.
40+ import {
41+ resolveThrownHttpError ,
42+ looksLikeInternalErrorLeak ,
43+ INTERNAL_ERROR_MESSAGE ,
44+ } from '@objectstack/types' ;
3045
3146/**
3247 * Request headers allowed on preflight, by default.
@@ -163,6 +178,116 @@ function toLoggableError(thrown: unknown): Error {
163178 return new Error ( `Non-Error value thrown: ${ described } ` ) ;
164179}
165180
181+ /**
182+ * The declared ADR-0112 envelope an escaped throw CARRIES, or `undefined` when
183+ * it carries none (#16545, the `domain:cli` half of the #15999 ruling).
184+ *
185+ * ## What the ruling asked for
186+ *
187+ * > **Shared half** (`domain:cli`, hono adapter / registrar wrapper): an
188+ * > escaped throw carrying a declared ADR-0112 `status` + registered `code` is
189+ * > rendered by them, not as a bare `500 INTERNAL_ERROR "No response from
190+ * > handler"`. This changes what an escaped throw means for every direct-mount
191+ * > route; the PR pins that an escaped **non**-envelope throw still answers 500
192+ * > with no cause in the body.
193+ *
194+ * The measured motivating path: `service-datasource`'s `requireDatasourceAdmin`
195+ * re-raises `AuthzStoreUnavailableError` (declared `status: 503` / `code:
196+ * SERVICE_UNAVAILABLE`) on an unreadable authorization store, deliberately and
197+ * per the #13279 ruling — and the caller was told `500 INTERNAL_ERROR "No
198+ * response from handler"`. The declared code never reached the caller and the
199+ * message named the wrong component. Only the RENDERING moves here; #13279's
200+ * discipline (an unreadable authz store licenses no verdict) is untouched.
201+ *
202+ * ## Why this is a GATE and not `sendThrownError`
203+ *
204+ * `packages/rest`'s `sendThrownError` maps EVERY throw through
205+ * `resolveThrownHttpError`, so an undeclared fault arrives as `500
206+ * INTERNAL_ERROR` carrying the thrown message. That is right for a REST
207+ * registrar, whose bodies are parsed against `BaseResponseSchema` by its own
208+ * conformance suite. It is NOT what this seam may do: the ruling pins that a
209+ * non-envelope throw keeps today's behaviour EXACTLY — 500, and no cause in the
210+ * body — so the fallback arm must stay byte-identical rather than gain the
211+ * thrown message. Hence a gate that answers `undefined` for everything the
212+ * ruling did not name, and `wrap`'s existing literal for that arm.
213+ *
214+ * ## The two conditions, both read off the ONE rule
215+ *
216+ * `resolveThrownHttpError` reports the DECLARATION it read — `declaredStatus`
217+ * is absent exactly when the throw declared no status (its docblock states the
218+ * distinction and why `status` cannot answer it), and `declaredCode` is the
219+ * producer's own spelling. So neither condition re-spells that function's
220+ * precedence chain here; a second chain is the divergence #8016 removed.
221+ *
222+ * 1. **a declared ADR-0112 status** — a key of `HttpStatusErrorCodeMap`. That
223+ * table is ADR-0112's own status list, so the vocabulary has one home. A
224+ * producer that declares `418` or `599` is NOT naming an ADR-0112 status
225+ * and takes the fallback arm.
226+ * 2. **a registered code** — a member of `ErrorCode`, i.e. `StandardErrorCode`
227+ * ∪ `ERROR_CODE_LEDGER`. ⛔ No code is minted here and no ledger row is
228+ * added; a code this path carries that is NOT registered is a ledger gap
229+ * under the #16404 ruling and takes the fallback arm rather than being
230+ * registered in passing.
231+ *
232+ * ⚠️ **Blast radius, stated because it is wider than the motivating path.**
233+ * `resolveThrownHttpError` treats the validation SHAPE as a declaration too
234+ * (`err.name === 'ValidationError'` ⇒ `400` / `VALIDATION_FAILED`), so a bare
235+ * `ValidationError` escaping a direct-mount handler now answers `400
236+ * VALIDATION_FAILED` with its `fields[]` instead of a bare 500. That is the one
237+ * rule's own semantics, and second-guessing one of its limbs at this door is
238+ * precisely how two doors start disagreeing — so it is accepted and recorded,
239+ * not carved out.
240+ *
241+ * ⛔ `declaredCode` is deliberately NOT forwarded. Under this gate the
242+ * producer's spelling IS the registered member sitting in `code`, so
243+ * `demotedDeclaredCode` returns `undefined` by construction — forwarding it
244+ * would put two spellings of one fact on every envelope this seam renders.
245+ */
246+ function declaredEnvelopeForThrow ( thrown : unknown ) : {
247+ status : number ;
248+ body : { success : false ; error : Record < string , unknown > } ;
249+ } | undefined {
250+ const resolved = resolveThrownHttpError ( thrown ) ;
251+
252+ // Condition 1 — the throw DECLARED a status, and it is one ADR-0112 names.
253+ if ( resolved . declaredStatus === undefined ) return undefined ;
254+ if ( ! Object . prototype . hasOwnProperty . call ( HttpStatusErrorCodeMap , resolved . declaredStatus ) ) {
255+ return undefined ;
256+ }
257+ // Condition 2 — the producer's OWN code is a member of the closed union.
258+ if ( resolved . declaredCode === undefined ) return undefined ;
259+ if ( ! ErrorCode . safeParse ( resolved . declaredCode ) . success ) return undefined ;
260+
261+ // The 5xx disclosure filter every door emitting a thrown message runs
262+ // (`HttpDispatcher.error` since #3867, `packages/rest`'s registrars since
263+ // #8086). This seam becomes such a door with this change, so it owes the
264+ // rule from its first day: without it a driver dump reaching a declared
265+ // 5xx would newly travel to the client, where the old bare 500 disclosed
266+ // nothing. Scoped to 5xx, like the twins: a 4xx message is a
267+ // caller-facing answer by design.
268+ const message = resolved . status >= 500 && looksLikeInternalErrorLeak ( resolved . message )
269+ ? INTERNAL_ERROR_MESSAGE
270+ : resolved . message ;
271+
272+ return {
273+ status : resolved . status ,
274+ body : {
275+ success : false ,
276+ error : {
277+ code : resolved . code ,
278+ message,
279+ // The producer's structured context and its END-USER-addressed
280+ // refusal text (#9934), forwarded exactly as the REST twin
281+ // forwards them. Both are absent unless the producer declared
282+ // them, so a throw that carried neither renders the same two
283+ // keys it always did.
284+ ...( resolved . details ? { details : resolved . details } : { } ) ,
285+ ...( resolved . userMessage !== undefined ? { userMessage : resolved . userMessage } : { } ) ,
286+ } ,
287+ } ,
288+ } ;
289+ }
290+
166291/**
167292 * The matched route's path parameters, or `{}` when there is no matched route.
168293 *
@@ -296,7 +421,18 @@ export class HonoHttpServer implements IHttpServer {
296421 // internal helper to convert standard handler to Hono handler
297422 private wrap ( handler : RouteHandler ) {
298423 return async ( c : any ) => {
299- const { response } = await this . runHandler ( c , handler ) ;
424+ // `renderDeclaredEnvelope` is the #16545 opt-in, and it is opt-IN
425+ // rather than the default because the OTHER caller of `runHandler`
426+ // — the `notFound` seam — must keep answering `Fallback handler
427+ // failed`: a fallback that threw is a broken consumer, not a
428+ // refusal the consumer declared. The ruling names direct-mount
429+ // ROUTES, which is exactly this call site.
430+ const { response } = await this . runHandler ( c , handler , {
431+ renderDeclaredEnvelope : true ,
432+ } ) ;
433+ // Unchanged, and pinned byte-for-byte: a throw that declared no
434+ // ADR-0112 envelope, and a handler that simply wrote nothing, both
435+ // still answer 500 with no cause in the body.
300436 return response ?? c . json (
301437 {
302438 success : false ,
@@ -332,6 +468,15 @@ export class HonoHttpServer implements IHttpServer {
332468 private async runHandler (
333469 c : any ,
334470 handler : RouteHandler ,
471+ opts : {
472+ /**
473+ * Render an escaped throw that carries a declared ADR-0112 status
474+ * and a registered code as THAT envelope (#16545). Off by default
475+ * — see {@link declaredEnvelopeForThrow} for the rule and
476+ * {@link wrap} for why only the route caller opts in.
477+ */
478+ renderDeclaredEnvelope ?: boolean ;
479+ } = { } ,
335480 ) : Promise < { response : Response | null ; failed : boolean } > {
336481 let body : any = { } ;
337482
@@ -465,7 +610,7 @@ export class HonoHttpServer implements IHttpServer {
465610
466611 // Create a streaming response wrapper — if handler calls res.write(),
467612 // we return a ReadableStream; otherwise fall back to capturedResponse.
468- const streamPromise = new Promise < { response : Response | null ; failed : boolean } > ( ( resolve ) => {
613+ const streamPromise = new Promise < { response : Response | null ; failed : boolean ; thrown ?: unknown } > ( ( resolve ) => {
469614 const stream = new ReadableStream ( {
470615 start ( controller ) {
471616 streamController = controller ;
@@ -506,21 +651,37 @@ export class HonoHttpServer implements IHttpServer {
506651 } ) . catch ( ( err ) => {
507652 _endHandler ?.( ) ;
508653 closeStream ( ) ;
509- // The ONE place an escaping throw is reported (#5848). Both
510- // callers turn `failed: true` into a 500 that says nothing
511- // about the cause — `wrap`'s `No response from handler` and
512- // the `notFound` seam's `Fallback handler failed` — so if the
513- // diagnosis is not emitted here it does not exist anywhere.
514- this . reportHandlerFailure ( c , err ) ;
515- resolve ( { response : null , failed : true } ) ;
654+ // [#16545] The throw is CARRIED OUT rather than reported here.
655+ // Reporting moved below so the diagnosis can name the answer
656+ // that was actually sent: since this seam may now render a
657+ // declared envelope, a line hard-coding "answered 500 with no
658+ // cause" would be false for exactly the requests the render
659+ // exists to fix. Still reported exactly once per escaped
660+ // throw, and still the ONLY place it is reported (#5848).
661+ resolve ( { response : null , failed : true , thrown : err } ) ;
516662 } ) ;
517663 } ) ;
518664
519665 const outcome = await streamPromise ;
520- return {
521- response : outcome . response ?? capturedResponse ?? null ,
522- failed : outcome . failed ,
523- } ;
666+ // A handler that WROTE and then threw keeps what it wrote — unchanged,
667+ // and the reason the render decision is taken here rather than in the
668+ // `catch`: `capturedResponse` is not visible from inside the executor's
669+ // rejection path, so deciding there would have let a declared envelope
670+ // overwrite a response the handler had already produced.
671+ let response = outcome . response ?? capturedResponse ?? null ;
672+ let rendered : { status : number ; code : unknown } | undefined ;
673+
674+ if ( outcome . failed && response === null && opts . renderDeclaredEnvelope ) {
675+ const envelope = declaredEnvelopeForThrow ( outcome . thrown ) ;
676+ if ( envelope ) {
677+ response = c . json ( envelope . body , envelope . status ) ;
678+ rendered = { status : envelope . status , code : envelope . body . error . code } ;
679+ }
680+ }
681+
682+ if ( outcome . failed ) this . reportHandlerFailure ( c , outcome . thrown , rendered ) ;
683+
684+ return { response, failed : outcome . failed } ;
524685 }
525686
526687 /**
@@ -567,14 +728,39 @@ export class HonoHttpServer implements IHttpServer {
567728 * likely place for credentials and PII to sit, and `message` + `stack`
568729 * already locate the failure in the code.
569730 */
570- private reportHandlerFailure ( c : any , thrown : unknown ) : void {
731+ private reportHandlerFailure (
732+ c : any ,
733+ thrown : unknown ,
734+ /**
735+ * [#16545] What the caller actually answered, when the throw carried a
736+ * declared ADR-0112 envelope and this seam rendered it. Absent for
737+ * every throw that took the unchanged bare-500 arm.
738+ *
739+ * The log line branches on it because the old sentence is a factual
740+ * CLAIM about the response — "answered 500 with no cause in the body"
741+ * — and it stops being true for precisely the requests this card
742+ * repairs. An operator reading `503 SERVICE_UNAVAILABLE` on the wire
743+ * beside a log line insisting the caller got an opaque 500 would be
744+ * debugging the seam instead of the outage.
745+ */
746+ rendered ?: { status : number ; code : unknown } ,
747+ ) : void {
571748 try {
572749 const method = typeof c ?. req ?. method === 'string' ? c . req . method : undefined ;
573750 const path = typeof c ?. req ?. path === 'string' ? c . req . path : undefined ;
751+ // Still `error`, in BOTH arms. A rendered envelope makes the answer
752+ // honest; it does not make the escape intentional — a handler that
753+ // throws its refusal past its own `catch` is still a server-side
754+ // defect, and the AGENTS.md "handed to the CALLER" exemption does
755+ // not apply to a throw nobody caught.
574756 this . logger . error (
575- '[hono] route handler threw — request answered 500 with no cause in the body' ,
757+ rendered
758+ ? '[hono] route handler threw — request answered with the throw\'s declared ADR-0112 envelope'
759+ : '[hono] route handler threw — request answered 500 with no cause in the body' ,
576760 toLoggableError ( thrown ) ,
577- { method, path } ,
761+ rendered
762+ ? { method, path, status : rendered . status , code : rendered . code }
763+ : { method, path } ,
578764 ) ;
579765 } catch {
580766 // Reporting the failure must never become a second failure: a
0 commit comments