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, }, 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