Premiere brique de l'app d'admin independante : une surface /admin/*
ajoutee a apps/api, avec une authentification totalement distincte de
celle des utilisateurs.
- Table AdminUser isolee (aucune relation vers UserProfile), migration
20260828120000_admin_user.
- lib/admin-jwt.ts : sign/verify d'un JWT admin, secret ADMIN_JWT_SECRET
propre (jamais interchangeable avec JWT_SECRET).
- middlewares/require-admin.ts : cookie admin_session dedie, re-check
tokenVersion, echoue ferme si ADMIN_JWT_SECRET absent (posture
requireInternalWorker). res.locals.adminUser type via AdminLocals.
- modules/admin/ : admin-auth.{routes,service}.ts (POST /login, POST
/logout, GET /me), admin.routes.ts agregateur monte /admin. Pas de
signup expose.
- lib/safe-admin.ts : mapping AdminUser -> AdminUserView (drop passwordHash
+ tokenVersion, dates ISO).
- scripts/create-admin.ts : creation du 1er admin hors-bande (flags ou
ADMIN_INITIAL_*).
- CORS : setupCore accepte string[] ; app.ts autorise CORS_ORIGIN +
ADMIN_CORS_ORIGIN.
- Shared : schemas/admin.ts (adminLoginSchema), types/admin.ts
(AdminUserView).
- Env : ADMIN_JWT_SECRET (optionnel), ADMIN_COOKIE_NAME, ADMIN_CORS_ORIGIN,
ADMIN_INITIAL_* ; .env.example, .env.test.example, docker-compose.yml,
ci.yml mis a jour.
- reset-db.ts truncate admin_users.
- Tests Mocha admin-auth.test.ts : 400 sans body, 401 email inconnu /
mauvais mdp, login OK (cookie pose, lastLoginAt, pas de hash/tokenVersion
dans la reponse), /me derriere requireAdmin, logout, et un cookie
`session` d'utilisateur normal ne donne pas acces a /admin/*.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
122 lines
4.7 KiB
TypeScript
122 lines
4.7 KiB
TypeScript
import path from "node:path";
|
|
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(s) allowed by CORS — a single origin, or a list when more than
|
|
* one frontend talks to this API from a different origin (e.g. the main
|
|
* app plus a separate admin app). Passed straight through to the `cors`
|
|
* package, which matches an incoming `Origin` against any entry of the
|
|
* list.
|
|
*/
|
|
corsOrigin: string | 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);
|
|
}
|
|
|
|
/**
|
|
* Serves a built single-page app (static assets + SPA fallback) from
|
|
* `distDir`. Call this **after** every `mountRouter`/`addRoute`, so API
|
|
* routes always win, and **before** {@link addMiddleware}'s catch-all
|
|
* 404 — any GET request that doesn't match an API route or a file in
|
|
* `distDir` falls through to `index.html`, letting the client-side
|
|
* router (e.g. react-router) handle it instead of a 404.
|
|
*
|
|
* Only meant for the production Docker image, where the frontend build
|
|
* is copied alongside the API — native dev (`pnpm dev:api`) never calls
|
|
* this, so `pnpm dev:web`'s own Vite dev server is unaffected.
|
|
*/
|
|
public serveStaticFrontend(distDir: string): void {
|
|
this._app.use(express.static(distDir));
|
|
this._app.get("*", (_req, res) => {
|
|
res.sendFile(path.join(distDir, "index.html"));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
}
|