fix(cards-server): error classes extend Hono HTTPException

Shared-hono's serviceErrorHandler only translates HTTPException
instances; anything else degrades to 500. Our custom Error subclasses
were silently bypassing the translation layer, so a missing JWT came
back as `500 Internal server error` instead of the expected `401
Unauthorized`. Confirmed in prod logs after the Phase-β deploy.

Switching the error hierarchy to extend HTTPException directly. The
JSON body now carries the right status code + the existing `cause`
object surfaces our `code` discriminator + zod-style `details` for
BadRequest. No call-site changes needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-07 16:40:20 +02:00
parent 044d948155
commit be155ca737

View file

@ -1,51 +1,63 @@
/** /**
* Domain errors caught by serviceErrorHandler from @mana/shared-hono * Domain errors caught by `serviceErrorHandler` from @mana/shared-hono.
* and translated to JSON responses with the right status code. *
* The shared handler only translates Hono `HTTPException`s; anything
* else degrades to 500. So our errors extend HTTPException directly
* rather than maintaining a parallel hierarchy.
*
* `details` (e.g. zod issue tree) is passed via `cause` because the
* shared handler picks that up and surfaces it in the JSON body.
*/ */
export class HttpError extends Error { import { HTTPException } from 'hono/http-exception';
constructor( import type { ContentfulStatusCode } from 'hono/utils/http-status';
public status: number,
message: string, function makeException(
public code?: string, status: ContentfulStatusCode,
public details?: unknown message: string,
) { code?: string,
super(message); details?: unknown
this.name = 'HttpError'; ) {
} return new HTTPException(status, {
message,
cause: details ? { code, details } : code ? { code } : undefined,
});
} }
export class UnauthorizedError extends HttpError { export class HttpError extends HTTPException {}
export class UnauthorizedError extends HTTPException {
constructor(message = 'Unauthorized') { constructor(message = 'Unauthorized') {
super(401, message, 'unauthorized'); super(401, { message, cause: { code: 'unauthorized' } });
this.name = 'UnauthorizedError';
} }
} }
export class ForbiddenError extends HttpError { export class ForbiddenError extends HTTPException {
constructor(message = 'Forbidden') { constructor(message = 'Forbidden') {
super(403, message, 'forbidden'); super(403, { message, cause: { code: 'forbidden' } });
this.name = 'ForbiddenError';
} }
} }
export class NotFoundError extends HttpError { export class NotFoundError extends HTTPException {
constructor(message = 'Not found') { constructor(message = 'Not found') {
super(404, message, 'not_found'); super(404, { message, cause: { code: 'not_found' } });
this.name = 'NotFoundError';
} }
} }
export class ConflictError extends HttpError { export class ConflictError extends HTTPException {
constructor(message = 'Conflict') { constructor(message = 'Conflict') {
super(409, message, 'conflict'); super(409, { message, cause: { code: 'conflict' } });
this.name = 'ConflictError';
} }
} }
export class BadRequestError extends HttpError { export class BadRequestError extends HTTPException {
constructor(message = 'Bad request', details?: unknown) { constructor(message = 'Bad request', details?: unknown) {
super(400, message, 'bad_request', details); super(400, {
this.name = 'BadRequestError'; message,
cause: details ? { code: 'bad_request', details } : { code: 'bad_request' },
});
} }
} }
// Keep makeException exported in case future code wants the raw factory.
export { makeException };