Address second review round: interface comments, res.locals, ExpressServer, assertIsNever

Five more explicit review points, on the same PR branch.

## Every interface key commented

Audited all 6 interfaces in the codebase. Two had partially-commented
members (violates the "every key gets /** */" rule): AuthResult
(apps/api/auth.service.ts) and SafeUserProfile (packages/shared) — both
now fully commented. The other four (AuthTokenPayload,
AuthContextValue, ErrorHandlingResult, ApiErrorResponse) were already
compliant.

## Removed the Express namespace augmentation

apps/api/src/types/express.d.ts (renamed to express-request.augment.ts
in the last round) is gone entirely. requireAuth now attaches the
authenticated profile to `res.locals.userProfile` — Express's own
built-in per-request mechanism for exactly this — typed via a new
AuthLocals interface and `Response<unknown, AuthLocals>`, instead of a
project-wide `declare global` silently changing every Request's type
whether or not it went through the middleware.

## ErrorHandlerService confirmed framework-agnostic

It already had zero Express import. Documented this explicitly (in the
package's index.ts and the new backend-architecture.md spec) as a
deliberate split: ErrorHandlerService is framework-agnostic (would work
behind Fastify too), ExpressServer/createErrorMiddleware are the actual
Express integration layer.

## packages/express-tools: server init + route/middleware utilities

New ExpressServer class, modeled on the pattern shared as a reference
(adapted, not copied 1:1 — deliberately left out the reference's custom
runtime param-type-validation system, since zod already does that job
in this codebase and running two parallel validation mechanisms would
be redundant, not "propre"):
- setupCore() — the common cors/json/cookie-parser stack
- addRoute() — registers a route, warns+skips instead of silently
  double-registering the same method+path
- addMiddleware() / mountRouter() / setErrorHandler()
- listen()
- .instance — the raw Express app, for supertest

Also added wrapAsyncHandler() — forwards a thrown/rejected error from an
async handler to next(err) automatically, removing the manual
try/catch/next(err) every route needed.

apps/api/src/app.ts now builds via ExpressServer (createServer(),
consumed by both server.ts's .listen() and createApp()'s .instance for
tests). auth.routes.ts's signup/login handlers use wrapAsyncHandler
instead of manual try/catch. cookie-parser/cors moved out of apps/api's
own dependencies entirely — they're express-tools' concern now.

## assertIsNever (packages/shared/src/tools/)

Exhaustiveness-check helper for switch/if-chains over a union: takes a
`never`-typed value and throws, so a forgotten case in a later-added
union member becomes a compile error instead of a silent runtime
fallthrough. Verified for real (not just written and assumed correct):
wrote a throwaway switch missing a case and confirmed `tsc` rejects it
with the exact expected error, then deleted the scratch file. No
existing switch/if-chain over a union in the codebase yet to retrofit
it into — noted as ready for when one appears (e.g. the not-yet-built
batch-cooking calculation module or recipe-import pipeline).

## specs/ updated

New specs/backend-architecture.md — ExpressServer, wrapAsyncHandler,
the res.locals decision (with the "why not declare global" reasoning
spelled out), assertIsNever. error-handling.md and
frontend-architecture.md cross-link to it instead of duplicating.
README covers the same, briefly.

## Verification

Full lint/mocha/cucumber/build green. Re-ran `node dist/server.js`
standalone (mirrors Docker, no tsx) after the ExpressServer refactor:
/health, a 404 (numeric 4040), and a real signup + GET /me round trip
confirming res.locals-based auth actually works at runtime, not just
that tsc accepts the types.
This commit is contained in:
Nicolas 2026-08-16 17:00:23 +02:00
parent 1cedb25d74
commit 3a5dc83bf9
19 changed files with 440 additions and 96 deletions

View file

@ -11,9 +11,10 @@ Monorepo pnpm workspaces :
`loginSchema`), types (`SafeUserProfile`), et le contrat d'erreurs (`ErrorCode` `loginSchema`), types (`SafeUserProfile`), et le contrat d'erreurs (`ErrorCode`
numérique, `ApiErrorResponse`, voir [specs/error-handling.md](specs/error-handling.md)) — numérique, `ApiErrorResponse`, voir [specs/error-handling.md](specs/error-handling.md)) —
même règles des deux côtés, pas de risque de dérive entre front et back. même règles des deux côtés, pas de risque de dérive entre front et back.
- `packages/express-tools` — outillage Express générique et réutilisable (`HttpError`, - `packages/express-tools` — outillage Express générique et réutilisable : `ExpressServer`
`ErrorHandlerService`, `createErrorMiddleware`), séparé d'`apps/api` : pas de (init serveur, routes, middlewares), `wrapAsyncHandler`, `HttpError`,
logique métier, juste de l'infra Express. `ErrorHandlerService`, `createErrorMiddleware` — séparé d'`apps/api`, pas de
logique métier. Détail : [specs/backend-architecture.md](specs/backend-architecture.md).
`packages/shared` et `packages/express-tools` ont un vrai build (`tsc` → `dist/`, `packages/shared` et `packages/express-tools` ont un vrai build (`tsc` → `dist/`,
voir leur `package.json`) : consommés en JS compilé, pas en TS brut — nécessaire voir leur `package.json`) : consommés en JS compilé, pas en TS brut — nécessaire
@ -202,6 +203,15 @@ en dur nulle part (toujours `ErrorCode.XXX`, y compris dans les mocks Cypress).
Détail complet (schéma, exemples, comment ajouter un nouveau code d'erreur) : Détail complet (schéma, exemples, comment ajouter un nouveau code d'erreur) :
[specs/error-handling.md](specs/error-handling.md). [specs/error-handling.md](specs/error-handling.md).
Le profil authentifié (`requireAuth`) passe par `res.locals.userProfile`
(typé via `AuthLocals`), pas par une augmentation du namespace global Express —
voir [specs/backend-architecture.md](specs/backend-architecture.md) pour le détail
et le pourquoi.
`packages/shared` fournit aussi `assertIsNever` (vérification d'exhaustivité de
switch/if-chain sur une union, erreur de **compilation** si un cas est oublié) —
voir [specs/backend-architecture.md](specs/backend-architecture.md#packagesshared--assertisnever).
## i18n ## i18n
**i18next** + **react-i18next** — tout le texte affiché (formulaires, boutons, **i18next** + **react-i18next** — tout le texte affiché (formulaires, boutons,

View file

@ -18,8 +18,6 @@
"@batch-cooking/shared": "workspace:*", "@batch-cooking/shared": "workspace:*",
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"argon2": "0.31.2", "argon2": "0.31.2",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.21.1", "express": "^4.21.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
@ -28,8 +26,6 @@
"devDependencies": { "devDependencies": {
"@cucumber/cucumber": "^13.2.1", "@cucumber/cucumber": "^13.2.1",
"@faker-js/faker": "^10.6.0", "@faker-js/faker": "^10.6.0",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^22.9.0", "@types/node": "^22.9.0",

View file

@ -1,37 +1,34 @@
import { createErrorMiddleware, errorHandlerService } from "@batch-cooking/express-tools"; import {
ExpressServer,
createErrorMiddleware,
errorHandlerService,
} from "@batch-cooking/express-tools";
import { ErrorCode } from "@batch-cooking/shared"; import { ErrorCode } from "@batch-cooking/shared";
import cookieParser from "cookie-parser"; import type { Express, Request, Response } from "express";
import cors from "cors";
import express, { type Request, type Response } from "express";
import { env } from "./config/env.js"; import { env } from "./config/env.js";
import { authRouter } from "./modules/auth/auth.routes.js"; import { authRouter } from "./modules/auth/auth.routes.js";
/** /**
* Builds a fresh Express application instance (no shared mutable state * Builds the API's `ExpressServer`: standard middleware, routes, and the
* between calls used both by the real server entrypoint and by tests, * final error handler, in that order. Returns the `ExpressServer` wrapper
* which each get their own app via supertest). * (not just the raw Express app) so `server.ts` can call `.listen()` on
* * it {@link createApp} below is the thinner entry point that exposes
* Feature modules are mounted under `/`-prefixed routers as specs land; * just the raw `Express` instance, for test tooling (supertest) that
* `auth` is the first one (login page / profile creation). * expects one.
*/ */
export function createApp() { export function createServer(): ExpressServer {
const app = express(); const server = new ExpressServer();
server.setupCore({ corsOrigin: env.CORS_ORIGIN });
// Frontend and API run on different origins — `credentials: true` is server.addRoute("get", "/health", (_req: Request, res: Response) => {
// required for the httpOnly session cookie to be sent/received.
app.use(cors({ origin: env.CORS_ORIGIN, credentials: true }));
app.use(express.json());
app.use(cookieParser());
app.get("/health", (_req: Request, res: Response) => {
res.status(200).json({ status: "ok" }); res.status(200).json({ status: "ok" });
}); });
app.use("/auth", authRouter); server.mountRouter("/auth", authRouter);
// No route matched — same shape as every other error response, via the // No route matched — same shape as every other error response, via the
// shared ErrorCode contract, so clients never special-case 404s. // shared ErrorCode contract, so clients never special-case 404s.
app.use((_req: Request, res: Response) => { server.addMiddleware((_req: Request, res: Response) => {
res.status(404).json({ code: ErrorCode.NOT_FOUND, message: "Not found" }); res.status(404).json({ code: ErrorCode.NOT_FOUND, message: "Not found" });
}); });
@ -39,7 +36,17 @@ export function createApp() {
// the app ends up here. All the "what status/body does this error map // the app ends up here. All the "what status/body does this error map
// to" logic lives in ErrorHandlerService, from @batch-cooking/express-tools // to" logic lives in ErrorHandlerService, from @batch-cooking/express-tools
// — this stays a thin adapter. // — this stays a thin adapter.
app.use(createErrorMiddleware(errorHandlerService)); server.setErrorHandler(createErrorMiddleware(errorHandlerService));
return app; return server;
}
/**
* Builds a fresh Express application instance (no shared mutable state
* between calls used both indirectly by the real server entrypoint
* (`server.ts`, via {@link createServer}) and directly by tests, which
* each get their own app via supertest).
*/
export function createApp(): Express {
return createServer().instance;
} }

View file

@ -1,10 +1,21 @@
import { HttpError } from "@batch-cooking/express-tools"; import { HttpError } from "@batch-cooking/express-tools";
import { ErrorCode } from "@batch-cooking/shared"; import { ErrorCode, type SafeUserProfile } from "@batch-cooking/shared";
import type { NextFunction, Request, Response } from "express"; import type { NextFunction, Request, Response } from "express";
import { env } from "../config/env.js"; import { env } from "../config/env.js";
import { prisma } from "../db/prisma.js"; import { prisma } from "../db/prisma.js";
import { verifyAuthToken } from "../lib/jwt.js"; import { verifyAuthToken } from "../lib/jwt.js";
/**
* Shape of `res.locals` once {@link requireAuth} has run successfully. Type
* a route handler's response as `Response<unknown, AuthLocals>` (see
* `auth.routes.ts`'s `/me` handler) to read `res.locals.userProfile` fully
* typed, no cast needed.
*/
export interface AuthLocals {
/** The authenticated profile, resolved from the session cookie's JWT. */
userProfile: SafeUserProfile;
}
/** /**
* Express middleware guarding routes that require an authenticated * Express middleware guarding routes that require an authenticated
* profile. Reads the session cookie, verifies the JWT, and re-checks * profile. Reads the session cookie, verifies the JWT, and re-checks
@ -12,14 +23,24 @@ import { verifyAuthToken } from "../lib/jwt.js";
* invalidated server-side (e.g. on password change / logout-everywhere, * invalidated server-side (e.g. on password change / logout-everywhere,
* once that feature exists) despite carrying no server-side session. * once that feature exists) despite carrying no server-side session.
* *
* On success, attaches the resolved profile to `req.userProfile` (see * On success, attaches the resolved profile to `res.locals.userProfile`
* `src/types/express-request.augment.ts`) for downstream handlers to use. * (typed via {@link AuthLocals}) for downstream handlers to use.
* Deliberately `res.locals` rather than augmenting Express's global
* `Request` type via `declare global`: `res.locals` is Express's own
* built-in mechanism for exactly this (passing data from a middleware to
* the next handler), typed per-route through a generic parameter no
* project-wide ambient augmentation silently changing every `Request` in
* the codebase, whether or not it went through this middleware.
* *
* @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure missing * @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure missing
* cookie, malformed/expired JWT, unknown profile, or stale tokenVersion. * cookie, malformed/expired JWT, unknown profile, or stale tokenVersion.
* Never distinguishes the reason to the client. * Never distinguishes the reason to the client.
*/ */
export async function requireAuth(req: Request, _res: Response, next: NextFunction) { export async function requireAuth(
req: Request,
res: Response<unknown, AuthLocals>,
next: NextFunction,
) {
try { try {
const token = req.cookies?.[env.AUTH_COOKIE_NAME]; const token = req.cookies?.[env.AUTH_COOKIE_NAME];
if (typeof token !== "string") { if (typeof token !== "string") {
@ -34,7 +55,7 @@ export async function requireAuth(req: Request, _res: Response, next: NextFuncti
} }
const { passwordHash: _passwordHash, ...safeProfile } = profile; const { passwordHash: _passwordHash, ...safeProfile } = profile;
req.userProfile = safeProfile; res.locals.userProfile = safeProfile;
next(); next();
} catch (err) { } catch (err) {
if (err instanceof HttpError) { if (err instanceof HttpError) {

View file

@ -1,8 +1,9 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { loginSchema, signupSchema } from "@batch-cooking/shared"; import { loginSchema, signupSchema } from "@batch-cooking/shared";
import { Router } from "express"; import { Router } from "express";
import type { CookieOptions } from "express"; import type { CookieOptions, Response } from "express";
import { env } from "../../config/env.js"; import { env } from "../../config/env.js";
import { requireAuth } from "../../middlewares/require-auth.js"; import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { login, signup } from "./auth.service.js"; import { login, signup } from "./auth.service.js";
/** Router mounted at `/auth` in app.ts — signup, login, logout, current-profile. */ /** Router mounted at `/auth` in app.ts — signup, login, logout, current-profile. */
@ -23,29 +24,31 @@ const cookieOptions: CookieOptions = {
maxAge: SEVEN_DAYS_MS, maxAge: SEVEN_DAYS_MS,
}; };
/** Creates a profile (+ its household) and logs the new user in immediately. */ /**
authRouter.post("/signup", async (req, res, next) => { * Creates a profile (+ its household) and logs the new user in
try { * immediately. `wrapAsyncHandler` forwards a thrown/rejected error to
* Express's error middleware automatically no manual try/catch needed.
*/
authRouter.post(
"/signup",
wrapAsyncHandler(async (req, res) => {
const input = signupSchema.parse(req.body); const input = signupSchema.parse(req.body);
const { profile, token } = await signup(input); const { profile, token } = await signup(input);
res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions); res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions);
res.status(201).json(profile); res.status(201).json(profile);
} catch (err) { }),
next(err); );
}
});
/** Verifies credentials and starts a new session. */ /** Verifies credentials and starts a new session. */
authRouter.post("/login", async (req, res, next) => { authRouter.post(
try { "/login",
wrapAsyncHandler(async (req, res) => {
const input = loginSchema.parse(req.body); const input = loginSchema.parse(req.body);
const { profile, token } = await login(input); const { profile, token } = await login(input);
res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions); res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions);
res.status(200).json(profile); res.status(200).json(profile);
} catch (err) { }),
next(err); );
}
});
/** Ends the current session by clearing the cookie. Stateless JWT, so there's nothing to revoke server-side (yet — see tokenVersion). */ /** Ends the current session by clearing the cookie. Stateless JWT, so there's nothing to revoke server-side (yet — see tokenVersion). */
authRouter.post("/logout", (_req, res) => { authRouter.post("/logout", (_req, res) => {
@ -54,6 +57,6 @@ authRouter.post("/logout", (_req, res) => {
}); });
/** Returns the currently authenticated profile. Behind requireAuth — 401s if there's no valid session. */ /** Returns the currently authenticated profile. Behind requireAuth — 401s if there's no valid session. */
authRouter.get("/me", requireAuth, (req, res) => { authRouter.get("/me", requireAuth, (_req, res: Response<unknown, AuthLocals>) => {
res.status(200).json(req.userProfile); res.status(200).json(res.locals.userProfile);
}); });

View file

@ -11,7 +11,9 @@ type SafeProfile = Omit<UserProfile, "passwordHash">;
/** Result of a successful signup/login: the safe profile plus the signed session JWT to set as a cookie. */ /** Result of a successful signup/login: the safe profile plus the signed session JWT to set as a cookie. */
interface AuthResult { interface AuthResult {
/** The authenticated profile, safe to hand back to the client. */
profile: SafeProfile; profile: SafeProfile;
/** Signed session JWT — the caller sets this as the session cookie's value. */
token: string; token: string;
} }

View file

@ -1,8 +1,8 @@
import { createApp } from "./app.js"; import { createServer } from "./app.js";
import { env } from "./config/env.js"; import { env } from "./config/env.js";
const app = createApp(); const server = createServer();
app.listen(env.PORT, () => { server.listen(env.PORT, () => {
console.log(`API listening on http://localhost:${env.PORT}`); console.log(`API listening on http://localhost:${env.PORT}`);
}); });

View file

@ -1,18 +0,0 @@
import type { UserProfile } from "@prisma/client";
// Module augmentation: adds a `userProfile` field to Express's Request type
// so `requireAuth` can attach the authenticated profile and downstream
// handlers get it fully typed, without an `as` cast at every call site.
//
// Deliberately a plain .ts file, not a .d.ts: `declare global` augmentation
// works identically either way as long as the file has a top-level import
// (which makes it a module TS will include in the program) — no need for
// the .d.ts extension just for this.
declare global {
namespace Express {
interface Request {
/** Set by requireAuth once the session cookie's JWT has been verified. */
userProfile?: Omit<UserProfile, "passwordHash">;
}
}
}

View file

@ -17,12 +17,16 @@
"postinstall": "tsc -p tsconfig.json" "postinstall": "tsc -p tsconfig.json"
}, },
"devDependencies": { "devDependencies": {
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/node": "^22.9.0", "@types/node": "^22.9.0",
"typescript": "^5.7.2" "typescript": "^5.7.2"
}, },
"dependencies": { "dependencies": {
"@batch-cooking/shared": "workspace:*", "@batch-cooking/shared": "workspace:*",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"express": "^4.21.1", "express": "^4.21.1",
"zod": "^3.25.76" "zod": "^3.25.76"
} }

View file

@ -0,0 +1,34 @@
import type { NextFunction, Request, RequestHandler, Response } from "express";
/**
* An Express route handler whose body is `async` (returns a `Promise`).
* Only `ResBody`/`Locals` are made generic (what this codebase actually
* varies per-route) params/request-body/query stay at Express's own
* internal defaults, same as an unparameterized `Request`.
*/
export type AsyncRequestHandler<
ResBody = unknown,
Locals extends Record<string, unknown> = Record<string, unknown>,
> = (req: Request, res: Response<ResBody, Locals>, next: NextFunction) => Promise<void>;
/**
* Wraps an async Express handler so a thrown error or rejected promise is
* forwarded to `next(err)` automatically. Without this, an unhandled
* rejection inside an `async` route handler never reaches Express's error
* middleware every route ends up needing its own `try { ... } catch (err)
* { next(err); }` boilerplate, which this removes.
*
* @example
* router.post("/signup", wrapAsyncHandler(async (req, res) => {
* const profile = await signup(req.body);
* res.status(201).json(profile);
* }));
*/
export function wrapAsyncHandler<
ResBody = unknown,
Locals extends Record<string, unknown> = Record<string, unknown>,
>(handler: AsyncRequestHandler<ResBody, Locals>): RequestHandler {
return (req, res, next) => {
handler(req, res as Response<ResBody, Locals>, next).catch(next);
};
}

View file

@ -0,0 +1,96 @@
import cookieParser from "cookie-parser";
import cors from "cors";
import express, {
type ErrorRequestHandler,
type Express,
type RequestHandler,
type Router,
} from "express";
/** HTTP verbs {@link ExpressServer.addRoute} accepts. */
export type HttpMethod = "get" | "post" | "put" | "patch" | "delete";
/** Options for {@link ExpressServer.setupCore}. */
export interface ExpressServerCoreOptions {
/** Origin allowed by CORS — must match wherever the frontend is served from. */
corsOrigin: string;
}
/**
* Thin wrapper around an Express application: bundles the common
* "set up the standard middleware stack, register routes without
* duplicates, wire the error handler, start listening" concerns behind a
* small typed API, instead of every service in the monorepo repeating the
* same raw `express()` setup by hand.
*
* Framework-specific on purpose unlike `ErrorHandlerService` (which has
* no Express dependency at all), this class *is* the Express integration
* layer. Business/domain code should never import `express` directly;
* it goes through this instead.
*/
export class ExpressServer {
/** The underlying Express application. */
private readonly app: Express;
/** Tracks `"METHOD path"` keys already registered via {@link addRoute}, to warn instead of silently double-registering a route. */
private readonly registeredRoutes = new Set<string>();
public constructor() {
this.app = express();
}
/** The underlying Express application — needed by test tooling (e.g. supertest) that expects a raw `Express` instance. */
public get instance(): Express {
return this.app;
}
/**
* Registers the standard middleware stack every service in this
* monorepo needs: CORS (with credentials, for the session cookie),
* JSON body parsing, and cookie parsing. Call once, before registering
* any route.
*/
public setupCore(options: ExpressServerCoreOptions): void {
this.app.use(cors({ origin: options.corsOrigin, credentials: true }));
this.app.use(express.json());
this.app.use(cookieParser());
}
/** Registers a middleware that runs on every request (e.g. logging, a catch-all 404 handler). */
public addMiddleware(middleware: RequestHandler): void {
this.app.use(middleware);
}
/**
* Registers the final Express error-handling middleware (the 4-argument
* form). Must be added last Express only treats a middleware as an
* error handler by its arity, and only the last matching one runs.
*/
public setErrorHandler(middleware: ErrorRequestHandler): void {
this.app.use(middleware);
}
/** Mounts a whole `express.Router` under a base path (e.g. `mountRouter("/auth", authRouter)`). */
public mountRouter(basePath: string, router: Router): void {
this.app.use(basePath, router);
}
/**
* Registers a single route with its handler(s). Warns and skips instead
* of registering if the same method+path was already added catches a
* copy-paste mistake at startup instead of silently shadowing a route.
*/
public addRoute(method: HttpMethod, path: string, ...handlers: RequestHandler[]): void {
const key = `${method.toUpperCase()} ${path}`;
if (this.registeredRoutes.has(key)) {
console.warn(`[ExpressServer] Route already registered, skipping: ${key}`);
return;
}
this.registeredRoutes.add(key);
this.app[method](path, ...handlers);
}
/** Starts listening on the given port. `onListening` is called once the server is up (e.g. to log the URL). */
public listen(port: number, onListening?: () => void): void {
this.app.listen(port, onListening);
}
}

View file

@ -2,7 +2,16 @@
// Express app in this monorepo (currently apps/api). Generic HTTP/Express // Express app in this monorepo (currently apps/api). Generic HTTP/Express
// infrastructure lives here — domain-specific code (auth, business logic) // infrastructure lives here — domain-specific code (auth, business logic)
// stays in the consuming app. // stays in the consuming app.
//
// Note the split: ErrorHandlerService (error-handler.service.ts) has zero
// dependency on Express — it's a plain "map an error to {status, body}"
// service that would work identically behind Fastify or any other
// framework. ExpressServer and createErrorMiddleware are the actual
// Express-specific layer, adapting framework-agnostic pieces (like
// ErrorHandlerService) onto Express's API.
export * from "./async-handler.js";
export * from "./error-handler.service.js"; export * from "./error-handler.service.js";
export * from "./error-middleware.js"; export * from "./error-middleware.js";
export * from "./express-server.js";
export * from "./http-error.js"; export * from "./http-error.js";

View file

@ -5,4 +5,5 @@
export * from "./errors/error-codes.js"; export * from "./errors/error-codes.js";
export * from "./schemas/auth.js"; export * from "./schemas/auth.js";
export * from "./tools/assert-is-never.js";
export * from "./types/user-profile.js"; export * from "./types/user-profile.js";

View file

@ -0,0 +1,35 @@
/**
* Exhaustiveness check for a `switch`/`if`-chain over a union type. Call
* this in the `default` case (or final `else`) with the value being
* switched on: if every member of the union has been handled by an
* earlier branch, TypeScript narrows that value to `never` there, and the
* call type-checks. If a new member is later added to the union and a
* branch is forgotten, `value` is no longer `never` at that point the
* call becomes a **compile error**, catching the missing case before it
* ships, instead of silently falling through at runtime.
*
* @example
* type Shape = { kind: "circle"; radius: number } | { kind: "square"; side: number };
*
* function area(shape: Shape): number {
* switch (shape.kind) {
* case "circle":
* return Math.PI * shape.radius ** 2;
* case "square":
* return shape.side ** 2;
* default:
* // Compile error here if a new `Shape` variant is added without a
* // matching case above — `shape` wouldn't be `never` anymore.
* return assertIsNever(shape);
* }
* }
*
* @param value - The value that should be `never` at this point (compile-time check).
* @param message - Optional custom error message; defaults to including the offending value.
* @throws Always throws this is also a real runtime safety net for a
* value that reaches here despite the type system (e.g. unvalidated
* external data cast to the union type).
*/
export function assertIsNever(value: never, message?: string): never {
throw new Error(message ?? `Unexpected value: ${JSON.stringify(value)}`);
}

View file

@ -7,8 +7,11 @@
export interface SafeUserProfile { export interface SafeUserProfile {
/** Primary key. */ /** Primary key. */
id: number; id: number;
/** First name. */
firstName: string; firstName: string;
/** Last name. */
lastName: string; lastName: string;
/** Email address — unique, used as the login identifier. */
email: string; email: string;
/** Incremented server-side to invalidate previously-issued JWTs (e.g. on password change). Not used directly by the client. */ /** Incremented server-side to invalidate previously-issued JWTs (e.g. on password change). Not used directly by the client. */
tokenVersion: number; tokenVersion: number;

View file

@ -29,12 +29,6 @@ importers:
argon2: argon2:
specifier: 0.31.2 specifier: 0.31.2
version: 0.31.2 version: 0.31.2
cookie-parser:
specifier: ^1.4.7
version: 1.4.7
cors:
specifier: ^2.8.6
version: 2.8.6
dotenv: dotenv:
specifier: ^16.4.5 specifier: ^16.4.5
version: 16.6.1 version: 16.6.1
@ -54,12 +48,6 @@ importers:
'@faker-js/faker': '@faker-js/faker':
specifier: ^10.6.0 specifier: ^10.6.0
version: 10.6.0 version: 10.6.0
'@types/cookie-parser':
specifier: ^1.4.10
version: 1.4.10(@types/express@4.17.25)
'@types/cors':
specifier: ^2.8.19
version: 2.8.19
'@types/express': '@types/express':
specifier: ^4.17.21 specifier: ^4.17.21
version: 4.17.25 version: 4.17.25
@ -151,6 +139,12 @@ importers:
'@batch-cooking/shared': '@batch-cooking/shared':
specifier: workspace:* specifier: workspace:*
version: link:../shared version: link:../shared
cookie-parser:
specifier: ^1.4.7
version: 1.4.7
cors:
specifier: ^2.8.6
version: 2.8.6
express: express:
specifier: ^4.21.1 specifier: ^4.21.1
version: 4.22.2 version: 4.22.2
@ -158,6 +152,12 @@ importers:
specifier: ^3.25.76 specifier: ^3.25.76
version: 3.25.76 version: 3.25.76
devDependencies: devDependencies:
'@types/cookie-parser':
specifier: ^1.4.10
version: 1.4.10(@types/express@4.17.25)
'@types/cors':
specifier: ^2.8.19
version: 2.8.19
'@types/express': '@types/express':
specifier: ^4.17.21 specifier: ^4.17.21
version: 4.17.25 version: 4.17.25

View file

@ -0,0 +1,136 @@
# Architecture backend — Projet Batch-cooking
> Documentation de l'organisation d'`apps/api` et de l'outillage partagé
> (`packages/express-tools`, `packages/shared`).
---
## `packages/express-tools` — outillage Express générique
Package séparé, réutilisable par n'importe quel service Express du monorepo (pas
seulement `apps/api`) : pas de logique métier, juste de l'infra Express.
### `ExpressServer` — init serveur, routes, middlewares
Enveloppe une application Express derrière une API typée, au lieu que chaque
service refasse le même `express()` à la main :
```ts
const server = new ExpressServer();
server.setupCore({ corsOrigin: env.CORS_ORIGIN }); // cors + json + cookie-parser
server.addRoute("get", "/health", (_req, res) => res.status(200).json({ status: "ok" }));
server.mountRouter("/auth", authRouter);
server.addMiddleware(notFoundHandler);
server.setErrorHandler(createErrorMiddleware(errorHandlerService));
server.listen(port, () => console.log(`Listening on ${port}`));
```
- `setupCore(options)` — middleware stack commun (CORS avec credentials, JSON,
cookies).
- `addRoute(method, path, ...handlers)` — enregistre une route ; avertit et
ignore au lieu d'écraser silencieusement si la même route (méthode + chemin)
est déjà enregistrée.
- `addMiddleware` / `mountRouter` / `setErrorHandler` — ajout de middleware
générique, montage d'un `Router` complet, middleware d'erreur final (4
arguments — doit être ajouté en dernier).
- `.instance` — l'app Express brute, nécessaire pour les outils de test
(supertest) qui attendent une instance `Express`, pas le wrapper.
- `.listen(port, onListening?)` — démarre le serveur.
`apps/api/src/app.ts` expose deux fonctions : `createServer(): ExpressServer`
(utilisée par `server.ts`, qui appelle `.listen()`) et `createApp(): Express`
(= `createServer().instance`, utilisée par les tests).
### `wrapAsyncHandler` — plus de try/catch répété dans les routes
```ts
router.post("/signup", wrapAsyncHandler(async (req, res) => {
const profile = await signup(req.body); // une erreur/rejet ici va automatiquement à next()
res.status(201).json(profile);
}));
```
Sans ça, une exception dans un handler `async` ne remonte jamais tout seule au
middleware d'erreur d'Express — chaque route devait faire son propre
`try { ... } catch (err) { next(err); }`. `wrapAsyncHandler` l'automatise.
### `HttpError` / `ErrorHandlerService` / `createErrorMiddleware`
Voir [error-handling.md](./error-handling.md) pour le détail. À noter :
`ErrorHandlerService` **n'a aucune dépendance à Express** — c'est un service
générique `erreur → { status, body }` qui fonctionnerait à l'identique derrière
Fastify ou n'importe quel autre framework. `ExpressServer` et
`createErrorMiddleware` sont la vraie couche Express : elles adaptent des
pièces indépendantes du framework (comme `ErrorHandlerService`) à l'API
d'Express. C'est pour ça que `ErrorHandlerService` n'importe jamais `express`.
---
## Auth : `res.locals`, pas d'augmentation du namespace Express
`requireAuth` (`apps/api/src/middlewares/require-auth.ts`) attache le profil
authentifié à **`res.locals.userProfile`**, typé via l'interface `AuthLocals` :
```ts
export interface AuthLocals {
userProfile: SafeUserProfile;
}
export async function requireAuth(req: Request, res: Response<unknown, AuthLocals>, next: NextFunction) {
// ...
res.locals.userProfile = safeProfile;
next();
}
```
Un handler derrière ce middleware type sa réponse `Response<unknown, AuthLocals>`
et lit `res.locals.userProfile` sans cast :
```ts
authRouter.get("/me", requireAuth, (_req, res: Response<unknown, AuthLocals>) => {
res.status(200).json(res.locals.userProfile);
});
```
**Pourquoi pas `declare global { namespace Express { interface Request {...} } }`**
(l'approche initialement utilisée, retirée depuis) : `res.locals` est le
mécanisme natif d'Express prévu exactement pour ça (faire passer des données
d'un middleware au handler suivant), typé par route via un paramètre
générique — pas une augmentation globale et permanente qui change
silencieusement le type de **toutes** les `Request` du projet, qu'elles soient
passées par ce middleware ou non.
---
## `packages/shared``assertIsNever`
`packages/shared/src/tools/assert-is-never.ts` — vérification d'exhaustivité
pour un `switch`/`if`-chain sur une union :
```ts
switch (shape.kind) {
case "circle": return Math.PI * shape.radius ** 2;
case "square": return shape.side ** 2;
default: return assertIsNever(shape); // erreur de compilation si un cas manque
}
```
Si un membre de l'union n'est pas traité par une branche précédente, `shape`
n'est plus de type `never` au niveau du `default` → **erreur de compilation**
(vérifié : `tsc` rejette bien un cas manquant). Lève aussi une vraie erreur au
runtime, en filet de sécurité si une valeur invalide échappe au système de
types (ex. donnée externe non validée).
Pas encore de point d'usage réel dans le code métier actuel (aucun
switch/if-chain exhaustif sur une union n'existe encore) — prêt à l'emploi dès
qu'un cas s'y prête (le module « Calcul batch-cooking » ou le pipeline d'import
de recette, tous deux encore à construire, en auront probablement).
---
## Pas de fichiers `.d.ts` écrits à la main
Voir [frontend-architecture.md](./frontend-architecture.md#note-sur-les-fichiers-dts)
pour le détail côté `apps/web`. Côté `apps/api` : aucune augmentation de type
globale (`declare global`) n'est utilisée — voir la section `res.locals`
ci-dessus, qui est précisément ce qui aurait nécessité ce genre de fichier.

View file

@ -97,10 +97,12 @@ Pour ajouter un nouveau cas d'erreur :
--- ---
## `packages/express-tools`outillage Express générique ## `packages/express-tools`les pièces liées aux erreurs
Séparé d'`apps/api` volontairement : ce sont des briques génériques (n'importe quel `packages/express-tools` contient aussi `ExpressServer` (init serveur,
service Express du monorepo pourrait les utiliser), pas de logique métier. enregistrement de routes/middlewares) et `wrapAsyncHandler` — voir
[backend-architecture.md](./backend-architecture.md) pour le détail complet du
package. Les pièces qui concernent spécifiquement les erreurs :
- **`http-error.ts`** — `HttpError` : erreur typée portant `status` (code HTTP) et - **`http-error.ts`** — `HttpError` : erreur typée portant `status` (code HTTP) et
`code` (`ErrorCode`). C'est ce que lèvent les routes/services au lieu de `code` (`ErrorCode`). C'est ce que lèvent les routes/services au lieu de
@ -109,9 +111,11 @@ service Express du monorepo pourrait les utiliser), pas de logique métier.
transformer n'importe quelle erreur JS (`ZodError`, `HttpError`, n'importe quoi transformer n'importe quelle erreur JS (`ZodError`, `HttpError`, n'importe quoi
d'autre) en `{ status, body }`. Le cas générique (`INTERNAL_ERROR`, 500) logue d'autre) en `{ status, body }`. Le cas générique (`INTERNAL_ERROR`, 500) logue
l'erreur côté serveur sans jamais exposer de détail interne au client. l'erreur côté serveur sans jamais exposer de détail interne au client.
**N'importe pas `express`** — c'est un service générique, indépendant du
framework HTTP, qui fonctionnerait à l'identique derrière Fastify ou autre.
- **`error-middleware.ts`** — `createErrorMiddleware(service)` : construit le - **`error-middleware.ts`** — `createErrorMiddleware(service)` : construit le
middleware d'erreur Express (signature à 4 arguments) à partir du service — middleware d'erreur Express (signature à 4 arguments) à partir du service —
adaptateur fin, aucune logique de mapping n'y vit. c'est LUI la vraie couche Express, `ErrorHandlerService` reste agnostique.
Build réel (`tsc` → `dist/`, comme `packages/shared`) : consommé en JS compilé, Build réel (`tsc` → `dist/`, comme `packages/shared`) : consommé en JS compilé,
pas en TS brut — voir la note dans pas en TS brut — voir la note dans
@ -120,9 +124,10 @@ sur pourquoi ça compte pour un runtime Node pur (Docker).
## Côté API (`apps/api`) ## Côté API (`apps/api`)
- **`app.ts`** — le middleware d'erreur final est - **`app.ts`** — le middleware d'erreur final est enregistré via
`app.use(createErrorMiddleware(errorHandlerService))` ; aucune logique de `server.setErrorHandler(createErrorMiddleware(errorHandlerService))` (voir
mapping n'y vit directement, tout est dans `express-tools`. [backend-architecture.md](./backend-architecture.md) pour `ExpressServer`) ;
aucune logique de mapping n'y vit directement, tout est dans `express-tools`.
- Les modules métier (`modules/auth/auth.service.ts`, `middlewares/require-auth.ts`) - Les modules métier (`modules/auth/auth.service.ts`, `middlewares/require-auth.ts`)
importent `HttpError` depuis `@batch-cooking/express-tools` et `ErrorCode` depuis importent `HttpError` depuis `@batch-cooking/express-tools` et `ErrorCode` depuis
`@batch-cooking/shared`. `@batch-cooking/shared`.

View file

@ -116,10 +116,10 @@ Aucun fichier `.d.ts` écrit à la main dans `apps/web` : le
`tsconfig.app.json` — même effet (typage de `import.meta.env`, imports d'assets), `tsconfig.app.json` — même effet (typage de `import.meta.env`, imports d'assets),
sans fichier dédié. sans fichier dédié.
Même logique côté `apps/api` : l'augmentation du type `Express.Request` (pour Côté `apps/api`, aucune augmentation de type globale n'est utilisée du tout — voir
`req.userProfile`) vit dans `src/types/express-request.augment.ts`, un fichier [backend-architecture.md](./backend-architecture.md#auth--reslocals-pas-daugmentation-du-namespace-express)
`.ts` classique (pas `.d.ts`) — une augmentation `declare global` fonctionne : le profil authentifié passe par `res.locals` (mécanisme natif d'Express), pas
identiquement dans les deux, tant que le fichier a un import qui en fait un module. par un `declare global` sur `Express.Request`.
--- ---