Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .changeset/23365-hono-declared-envelope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
---
'@objectstack/hono': minor
'@objectstack/plugin-hono-server': minor
---

fix(hono): `createHonoApp` answers an escaped throw that declares an ADR-0112 envelope as that envelope, not as `500 INTERNAL_ERROR` (#23365)

`@objectstack/hono`'s `createHonoApp` has two catch arms: the auth mount's, and
the `${prefix}/*` catch-all's around `HttpDispatcher.dispatch()`. Both answered
an escaped throw from its `statusCode` alone, so a producer that declared
`status` and a registered `code` lost both on this host. A `GET`, `PATCH` or
`DELETE` of `/data/:object/:id` for an id that names no row answered
`500 INTERNAL_ERROR` where the standalone REST server answers
`404 RECORD_NOT_FOUND`. The `/data` version refusals answered `500` too (a stale
`If-Match`'s `409 CONCURRENT_UPDATE`, and `If-Match: ""`'s
`400 VALIDATION_FAILED`), and so did an authorization-store outage
(`AuthzStoreUnavailableError`, `503 SERVICE_UNAVAILABLE`).

**What changed.** Both arms now answer a throw that carries **both** a declared
ADR-0112 status (a key of `HttpStatusErrorCodeMap`) **and** a code registered in
`ErrorCode` as that envelope. The recognition is `declaredEnvelopeForThrow`, the
rule `@objectstack/plugin-hono-server`'s direct-mount routes have used since
#16545, so the two Hono doors answer one throw alike by construction: the same
two conditions, the same 5xx disclosure filter (`looksLikeInternalErrorLeak`),
and `details` and `userMessage` forwarded only when the producer declared them.

The rule's other limbs come with it, as they did on the direct-mount door. A
validation-shaped throw answers `400 VALIDATION_FAILED` with its `fields[]`. A
driver fault the engine has already classified keeps its `500` and now carries
the code it declared instead of `INTERNAL_ERROR`. Measured: `GET /data/:object`
over an object whose table is missing answers `500 DATABASE_ERROR`, with the
same message as before.

**What did NOT change**, pinned in the same PR through a real `createHonoApp`
over the real dispatcher and protocol (`packages/qa/http-conformance`):

- a throw that is **not** such an envelope answers what it answered before. A
bare `Error` is still `500 INTERNAL_ERROR` carrying `code` and `message`, with
no `cause` in the body, and a throw that declares `statusCode` with no
registered code keeps that status;
- ⛔ no error code is minted and no ledger row is added.

`@objectstack/plugin-hono-server` gains one export, `declaredEnvelopeForThrow`,
which was module-private. `createHonoApp` imports it rather than restating the
rule. Its behaviour is unchanged.

Earlier entries say a `createHonoApp` host still renders the `/data` version
refusals as `500`. This change ends that: they are answered `409` and `400` as
declared.

⚠️ **Known gap, stated rather than left silent.** The `409 CONCURRENT_UPDATE`
this host now answers carries `code` and `message`, but not the
`currentVersion` and `currentRecord` that the standalone REST server's `409`
carries. The recogniser forwards only the `details` a producer declares, and
those two fields are own properties of the error. That gap is outside this
change.

Clause-②: yes (widening)
38 changes: 36 additions & 2 deletions packages/adapters/hono/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
hasWildcardPattern,
DEFAULT_CORS_ALLOW_HEADERS,
DEFAULT_CORS_EXPOSE_HEADERS,
declaredEnvelopeForThrow,
} from '@objectstack/plugin-hono-server';

export interface ObjectStackHonoCorsOptions {
Expand Down Expand Up @@ -508,6 +509,33 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono {
return c.json({ success: false, error: { code, message } }, status);
};

/**
* [#23365] What BOTH catch arms below answer for a throw that escaped them.
*
* A throw that carries a declared ADR-0112 `status` and a registered `code`
* is answered as that envelope. Both arms used to read `statusCode` alone, so
* a declared `status` and `code` were lost: a missing record's
* `404 RECORD_NOT_FOUND`, the `/data` version refusals' `409` and `400`, and
* an authorization-store outage's `503` all left `dispatch()` and answered
* `500 INTERNAL_ERROR` on this host only.
*
* The recognition is `declaredEnvelopeForThrow`, the ONE rule #16545 landed
* for `@objectstack/plugin-hono-server`'s direct-mount routes, imported and
* ⛔ never restated here, so the two Hono doors answer one throw alike: the
* same two conditions, the same 5xx disclosure filter, and the same body
* (`details` and `userMessage` only when the producer declared them).
*
* A throw the recogniser does not accept takes the arm's answer exactly as
* before: a bare `Error` is still `500` carrying `code` and `message`, with
* no `cause`, and a throw declaring `statusCode` with no registered code
* keeps that status.
*/
const thrownJson = (c: any, err: any) => {
const declared = declaredEnvelopeForThrow(err);
if (declared) return c.json(declared.body, declared.status);
return errorJson(c, err.message || 'Internal Server Error', err.statusCode || 500);
};

const toResponse = (c: any, result: HttpDispatcherResult) => {
if (result.handled) {
if (result.response) {
Expand Down Expand Up @@ -779,7 +807,11 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono {
if (!result.handled) return yieldUnowned(c, next, () => toResponse(c, result));
return toResponse(c, result);
} catch (err: any) {
return errorJson(c, err.message || 'Internal Server Error', err.statusCode || 500);
// [#23365] The same rendering as the catch-all below (`thrownJson`). No
// in-repo producer was found that reaches this arm with a declared
// envelope (better-auth answers its own failures as a `Response`), so it
// takes the rule for uniformity: one rule for both arms, not two.
return thrownJson(c, err);
}
});

Expand Down Expand Up @@ -828,7 +860,9 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono {
const result = await dispatcher.dispatch(method, subPath, body, queryParams, { request: c.req.raw }, prefix);
return toResponse(c, result);
} catch (err: any) {
return errorJson(c, err.message || 'Internal Server Error', err.statusCode || 500);
// [#23365] A declared ADR-0112 envelope that left `dispatch()` (its foot
// catch re-raises everything but a denial) is answered as declared.
return thrownJson(c, err);
}
});

Expand Down
13 changes: 12 additions & 1 deletion packages/plugins/plugin-hono-server/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,19 @@ function toLoggableError(thrown: unknown): Error {
* producer's spelling IS the registered member sitting in `code`, so
* `demotedDeclaredCode` returns `undefined` by construction — forwarding it
* would put two spellings of one fact on every envelope this seam renders.
*
* ## Exported for the other Hono door (#23365)
*
* `@objectstack/hono`'s `createHonoApp` is a second Hono door: it builds its own
* app, and its two catch arms (the auth mount's and the `dispatch()`
* catch-all's) read `statusCode` alone, so a declared `status` and `code` were
* lost there and the answer was `500`. Those arms now ask THIS function, so the
* two doors answer one throw alike. It is exported so that they can, and ⛔ it
* is not restated there: a second recogniser is how the doors would start
* disagreeing about what counts as declared. Each caller keeps its own
* fallback arm for the `undefined` answer.
*/
function declaredEnvelopeForThrow(thrown: unknown): {
export function declaredEnvelopeForThrow(thrown: unknown): {
status: number;
body: { success: false; error: Record<string, unknown> };
} | undefined {
Expand Down
Loading
Loading