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
* and translated to JSON responses with the right status code.
* Domain errors caught by `serviceErrorHandler` from @mana/shared-hono.
*
* 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 {
constructor(
public status: number,
message: string,
public code?: string,
public details?: unknown
) {
super(message);
this.name = 'HttpError';
}
import { HTTPException } from 'hono/http-exception';
import type { ContentfulStatusCode } from 'hono/utils/http-status';
function makeException(
status: ContentfulStatusCode,
message: string,
code?: string,
details?: unknown
) {
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') {
super(401, message, 'unauthorized');
this.name = 'UnauthorizedError';
super(401, { message, cause: { code: 'unauthorized' } });
}
}
export class ForbiddenError extends HttpError {
export class ForbiddenError extends HTTPException {
constructor(message = 'Forbidden') {
super(403, message, 'forbidden');
this.name = 'ForbiddenError';
super(403, { message, cause: { code: 'forbidden' } });
}
}
export class NotFoundError extends HttpError {
export class NotFoundError extends HTTPException {
constructor(message = 'Not found') {
super(404, message, 'not_found');
this.name = 'NotFoundError';
super(404, { message, cause: { code: 'not_found' } });
}
}
export class ConflictError extends HttpError {
export class ConflictError extends HTTPException {
constructor(message = 'Conflict') {
super(409, message, 'conflict');
this.name = 'ConflictError';
super(409, { message, cause: { code: 'conflict' } });
}
}
export class BadRequestError extends HttpError {
export class BadRequestError extends HTTPException {
constructor(message = 'Bad request', details?: unknown) {
super(400, message, 'bad_request', details);
this.name = 'BadRequestError';
super(400, {
message,
cause: details ? { code: 'bad_request', details } : { code: 'bad_request' },
});
}
}
// Keep makeException exported in case future code wants the raw factory.
export { makeException };