From 3fd9ea25b8e7acaf879ed6f66d27f6e572d5b213 Mon Sep 17 00:00:00 2001 From: Bello Qudus Folohunsho Date: Mon, 12 Oct 2026 09:23:02 +0100 Subject: [PATCH 1/2] fix(api): answer unexpected errors with a json body Better Auth swallows anything that is not one of its own API errors and returns Response(null, { status: 500 }), so a database outage reached the client as an empty 500 and the express error handler never ran. Set onAPIError.throw so those errors escape better auth, and add an express error handler that logs the real cause and answers with json. A failed auth call now reads {"error":"Internal server error"} instead of an empty body, while better-auth's own API errors keep their json shape. Covered by a regression test that makes the prisma mock throw. --- backend/src/app.test.ts | 28 ++++++++++++++++++++++++++-- backend/src/app.ts | 28 +++++++++++++++++++++++++++- backend/src/lib/auth.ts | 5 +++++ 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/backend/src/app.test.ts b/backend/src/app.test.ts index 54eb044..6d7dea7 100644 --- a/backend/src/app.test.ts +++ b/backend/src/app.test.ts @@ -23,8 +23,21 @@ vi.mock("./lib/prisma.js", () => { createdAt: new Date("2026-01-01T00:00:00.000Z"), updatedAt: new Date("2026-01-01T00:00:00.000Z"), }; - const findUserByEmail = async ({ where }: { where?: { email?: string } }) => - where?.email === "pa@example.com" ? existingUser : null; + const findUserByEmail = async ({ + where, + }: { + where?: { email?: unknown }; + }) => { + const raw = where?.email; + const email = + typeof raw === "object" && raw !== null + ? (raw as { equals?: string }).equals + : raw; + if (email === "db-down@example.com") { + throw new Error("database unreachable"); + } + return email === "pa@example.com" ? existingUser : null; + }; return { prisma: { $queryRaw: vi.fn().mockResolvedValue([{ "?column?": 1 }]), @@ -135,6 +148,17 @@ describe("app", () => { expect(knownWithoutCredentials.body).toEqual(unknown.body); }); + it("answers a broken database with a json error body instead of an empty 500", async () => { + const res = await request(app) + .post("/api/auth/sign-in/email") + .set(trustedOrigin) + .send({ email: "db-down@example.com", password: "local-dev-password" }); + + expect(res.status).toBe(500); + expect(res.headers["content-type"]).toContain("application/json"); + expect(res.body).toEqual({ error: "Internal server error" }); + }); + it("rejects malformed json bodies without taking the server down", async () => { const res = await request(app) .post("/api/v1/health") diff --git a/backend/src/app.ts b/backend/src/app.ts index 0c49e3b..b6c94c7 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,10 +1,34 @@ import { toNodeHandler } from "better-auth/node"; -import express from "express"; +import express, { type ErrorRequestHandler } from "express"; import { pinoHttp } from "pino-http"; import { auth } from "./lib/auth.js"; import { logger } from "./lib/logger.js"; import { healthRouter } from "./routes/health.js"; +function statusOf(err: unknown): number { + const code = + (err as { status?: unknown }).status ?? + (err as { statusCode?: unknown }).statusCode; + return typeof code === "number" && code >= 400 && code < 600 ? code : 500; +} + +function messageOf(err: unknown, status: number): string { + if (status < 500 && err instanceof Error && err.message) return err.message; + return status < 500 ? "Bad request" : "Internal server error"; +} + +// catches anything a route lets out: db outages, bugs, bad json. +// without it express falls back to an html body with a stack trace. +const errorHandler: ErrorRequestHandler = (err, _req, res, next) => { + if (res.headersSent) { + next(err); + return; + } + const status = statusOf(err); + if (status >= 500) logger.error({ err }, "request failed"); + res.status(status).json({ error: messageOf(err, status) }); +}; + export function createApp() { const app = express(); @@ -19,5 +43,7 @@ export function createApp() { res.status(404).json({ error: "Not found" }); }); + app.use(errorHandler); + return app; } diff --git a/backend/src/lib/auth.ts b/backend/src/lib/auth.ts index 78b1b89..e08530d 100644 --- a/backend/src/lib/auth.ts +++ b/backend/src/lib/auth.ts @@ -5,6 +5,11 @@ import { prisma } from "./prisma.js"; export const auth = betterAuth({ database: prismaAdapter(prisma, { provider: "postgresql" }), + // let unexpected errors (db down, bugs) reach the express error handler instead of + // becoming an empty 500; better-auth's own API errors still return their JSON body + onAPIError: { + throw: true, + }, emailAndPassword: { enabled: true, }, From 4e2594f220d5c8a03e8a09af2ec1ac7fc14dd61c Mon Sep 17 00:00:00 2001 From: Bello Qudus Folohunsho Date: Mon, 12 Oct 2026 09:25:57 +0100 Subject: [PATCH 2/2] docs(architecture): check the real migrate chain in verify step The step grepped render.yaml for the literal string "prisma migrate deploy", but render.yaml calls it indirectly through npm run db:deploy, so the step failed while the deploy itself was fine. Reword the step to assert both links: render.yaml runs db:deploy, and backend/package.json defines db:deploy as prisma migrate deploy. --- docs/specs/0001-stack-and-architecture/verify.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/specs/0001-stack-and-architecture/verify.md b/docs/specs/0001-stack-and-architecture/verify.md index 9e6e30b..b6492a9 100644 --- a/docs/specs/0001-stack-and-architecture/verify.md +++ b/docs/specs/0001-stack-and-architecture/verify.md @@ -1,4 +1,4 @@ -# Verify: Stack & architecture · spec 0001 · updated 2026-10-09 +# Verify: Stack & architecture · spec 0001 · updated 2026-10-12 _Steps derived from spec 0001 and the feature's Done when. `/check verify` runs these; `/test` locks the durable ones._ @@ -26,7 +26,7 @@ Acceptance criteria, taken from the scope's Done when: - [x] `cd backend && npm run typecheck` → passes → AC-1 - [x] `cd backend && npm run build && PORT=4100 node dist/index.js` → `api listening`, health returns ok → production API build runs → AC-3 - [x] `cd client && npm run build` → build finishes → production web build runs → AC-3 -- [ ] `test -f render.yaml && grep -q 'prisma migrate deploy' render.yaml` → Render build applies migrations → AC-3 +- [x] `test -f render.yaml && grep -q 'db:deploy' render.yaml && grep -q '"db:deploy": "prisma migrate deploy"' backend/package.json` → the Render build runs `npm run db:deploy`, which is `prisma migrate deploy` → AC-3 - [x] `test -f docs/deploy.md && test -f backend/.env.example && test -f client/.env.example` → environment variables documented → AC-3 - [x] `git -C . check-ignore backend/.env client/.env.local` → both ignored → secrets stay out of the repo → AC-3