Compare commits

..

1 commit

Author SHA1 Message Date
cf8ef26f63 feat(admin): fondation auth de l'application d'administration
All checks were successful
CI / lint (push) Successful in 1m4s
CI / intent-service-test (push) Successful in 10m52s
CI / build (push) Successful in 1m6s
CI / e2e (push) Successful in 5m36s
CI / test (push) Successful in 19m57s
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>
2026-08-28 12:02:19 +02:00
21 changed files with 642 additions and 8 deletions

View file

@ -15,6 +15,27 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# (docker-compose.yml), serving both the API and the built frontend. # (docker-compose.yml), serving both the API and the built frontend.
# APP_PORT=3000 # APP_PORT=3000
# --- Admin application (apps/admin-web + the /admin/* API surface) ---------
# All optional: an instance that doesn't run the admin app needs none of
# these. `requireAdmin` fails closed when ADMIN_JWT_SECRET is unset, so
# leaving it out simply disables every /admin/* route.
#
# Secret for the admin session JWT — MUST be different from JWT_SECRET so an
# end-user token can never be replayed against /admin/*. Generate your own
# the same way as JWT_SECRET above.
# ADMIN_JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Origin apps/admin-web is served from, added to the CORS allow-list.
# ADMIN_CORS_ORIGIN=http://localhost:5174
# Host port for the Docker `admin-web` service (static nginx serving the
# built admin frontend).
# ADMIN_WEB_PORT=3001
# Optional — read only by `src/scripts/create-admin.ts` when its --email /
# --password / --name flags are omitted (e.g. to bootstrap the first admin
# from inside the container). Never read by the running server.
# ADMIN_INITIAL_EMAIL=ops@example.com
# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars
# ADMIN_INITIAL_NAME=Ops
# Optional — only set this to false if THIS deployment is served over # Optional — only set this to false if THIS deployment is served over
# plain HTTP (no TLS in front of it). Left unset, the session cookie # plain HTTP (no TLS in front of it). Left unset, the session cookie
# requires HTTPS (Secure attribute) as it should for a real deployment; # requires HTTPS (Secure attribute) as it should for a real deployment;

View file

@ -19,6 +19,10 @@ env:
# exercise the success path (matching secret), not just the "unset" # exercise the success path (matching secret), not just the "unset"
# rejection every environment that doesn't set this gets by default. # rejection every environment that doesn't set this gets by default.
INTERNAL_WORKER_SECRET: "ci-only-worker-secret-not-used-anywhere-else-32chars+" INTERNAL_WORKER_SECRET: "ci-only-worker-secret-not-used-anywhere-else-32chars+"
# Same reasoning — lets admin-auth.test.ts exercise the admin login
# success path + the requireAdmin-guarded routes, not just the "no admin
# secret configured -> 401" path.
ADMIN_JWT_SECRET: "ci-only-admin-secret-not-used-anywhere-else-32chars+"
# Shared between the `test` job's own uvicorn step (below) and apps/api's # Shared between the `test` job's own uvicorn step (below) and apps/api's
# IntentServiceClient — see the `test` job for why this can't be a # IntentServiceClient — see the `test` job for why this can't be a
# `services:` container like postgres above (GitHub Actions can only pull # `services:` container like postgres above (GitHub Actions can only pull
@ -62,10 +66,6 @@ jobs:
--health-timeout 5s --health-timeout 5s
--health-retries 10 --health-retries 10
steps: steps:
- name: Install curl and dependencies
run: |
apt-get update && apt-get install -y curl
- uses: https://github.com/actions/checkout@v4 - uses: https://github.com/actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4

View file

@ -27,3 +27,10 @@ INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# success path (a request with a matching secret); every other test runs # success path (a request with a matching secret); every other test runs
# fine without it. Any value at least 32 chars works locally. # fine without it. Any value at least 32 chars works locally.
# INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars # INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Optional — set to run admin-auth.test.ts's login success path and the
# requireAdmin-guarded routes (any value at least 32 chars). Left unset,
# those cases self-skip and only the "no secret configured -> 401" path
# runs. Same "optional in test, fail-closed at runtime" posture as
# INTERNAL_WORKER_SECRET above.
# ADMIN_JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars

View file

@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "admin_users" (
"id" SERIAL NOT NULL,
"email" TEXT NOT NULL,
"password_hash" TEXT NOT NULL,
"name" TEXT NOT NULL,
"token_version" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"last_login_at" TIMESTAMP(3),
CONSTRAINT "admin_users_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "admin_users_email_key" ON "admin_users"("email");

View file

@ -897,3 +897,36 @@ model TechStepTrainingSuggestion {
@@map("tech_step_training_suggestion") @@map("tech_step_training_suggestion")
} }
// -----------------------------------------------------------------------------
// Admin application
// A separate operations app (usage metrics, microservice monitoring, tech-step
// correction triage) — see specs/backend-architecture.md's "admin" section.
// -----------------------------------------------------------------------------
/// An operator of the admin application (`apps/admin-web` / the `/admin/*`
/// API surface). Deliberately its **own** table with **no relation** to
/// `UserProfile`: admin access is a completely separate concern from being
/// an end user of the recipe app — a person can be one, both or neither,
/// and the two auth mechanisms (`requireAdmin` vs `requireAuth`, distinct
/// cookies, distinct JWT secrets) never overlap. No self-service signup —
/// the first row is created out-of-band by `src/scripts/create-admin.ts`,
/// and (for now) there's no in-app admin-management UI.
model AdminUser {
id Int @id @default(autoincrement())
email String @unique
/// argon2 hash of the password — same hashing as `UserProfile.passwordHash`
/// (`auth.service.ts`'s `hashOptions`, cheaper cost under NODE_ENV=test).
passwordHash String @map("password_hash")
/// Display name shown in the admin UI's account menu.
name String
/// Bumped to invalidate previously-issued admin JWTs — same mechanism as
/// `UserProfile.tokenVersion`, checked on every request by `requireAdmin`.
tokenVersion Int @default(0) @map("token_version")
createdAt DateTime @default(now()) @map("created_at")
/// Stamped on every successful login — a cheap "is this account still in
/// use" signal for the operator managing admins by hand.
lastLoginAt DateTime? @map("last_login_at")
@@map("admin_users")
}

View file

@ -5,6 +5,7 @@ import type { Express, Request, Response } from "express";
import { env } from "./config/env.js"; import { env } from "./config/env.js";
import { errorLogger } from "./middlewares/error-logger.js"; import { errorLogger } from "./middlewares/error-logger.js";
import { requestLogger } from "./middlewares/request-logger.js"; import { requestLogger } from "./middlewares/request-logger.js";
import { adminRouter } from "./modules/admin/admin.routes.js";
import { authRouter } from "./modules/auth/auth.routes.js"; import { authRouter } from "./modules/auth/auth.routes.js";
import { houseRouter } from "./modules/house/house.routes.js"; import { houseRouter } from "./modules/house/house.routes.js";
import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js"; import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js";
@ -32,13 +33,19 @@ export function createServer(): ExpressServer {
// pipeline (its "finish" listener still fires for a request that never // pipeline (its "finish" listener still fires for a request that never
// makes it past CORS/body-parsing, not just ones that reach a route). // makes it past CORS/body-parsing, not just ones that reach a route).
server.addMiddleware(requestLogger); server.addMiddleware(requestLogger);
server.setupCore({ corsOrigin: env.CORS_ORIGIN }); // Two allowed origins: the main app (`CORS_ORIGIN`) and the separate
// admin app (`ADMIN_CORS_ORIGIN`). The `cors` package matches an incoming
// `Origin` against any entry of the list.
server.setupCore({ corsOrigin: [env.CORS_ORIGIN, env.ADMIN_CORS_ORIGIN] });
server.addRoute("get", "/health", (_req: Request, res: Response) => { server.addRoute("get", "/health", (_req: Request, res: Response) => {
res.status(200).json({ status: "ok" }); res.status(200).json({ status: "ok" });
}); });
server.mountRouter("/auth", authRouter); server.mountRouter("/auth", authRouter);
// Admin application surface (`apps/admin-web`) — its own auth
// (`requireAdmin`, distinct cookie/secret), never the end-user session.
server.mountRouter("/admin", adminRouter);
server.mountRouter("/house", houseRouter); server.mountRouter("/house", houseRouter);
// Not user-facing — `services/tech-step-llm-worker` only, guarded by // Not user-facing — `services/tech-step-llm-worker` only, guarded by
// `requireInternalWorker` on every route within (see that router's own // `requireInternalWorker` on every route within (see that router's own

View file

@ -90,6 +90,27 @@ const envSchema = z.object({
* every technique detection request failing one at a time. * every technique detection request failing one at a time.
*/ */
INTENT_SERVICE_SECRET: z.string().min(32, "INTENT_SERVICE_SECRET must be at least 32 characters"), INTENT_SERVICE_SECRET: z.string().min(32, "INTENT_SERVICE_SECRET must be at least 32 characters"),
/**
* Secret used to sign/verify the **admin** session JWT (`lib/admin-jwt.ts`)
* entirely separate from `JWT_SECRET`, so an end-user session token can
* never be replayed against `/admin/*` and vice versa. `.optional()`
* (unlike `JWT_SECRET`): an instance that doesn't run the admin app at
* all never needs it but `requireAdmin` (`middlewares/require-admin.ts`)
* rejects every request outright when it's unset, so the surface fails
* closed, same posture as `INTERNAL_WORKER_SECRET`.
*/
ADMIN_JWT_SECRET: z
.string()
.min(32, "ADMIN_JWT_SECRET must be at least 32 characters")
.optional(),
/** Name of the httpOnly cookie carrying the admin session JWT — must differ from `AUTH_COOKIE_NAME` so the two sessions coexist in one browser. */
ADMIN_COOKIE_NAME: z.string().default("admin_session"),
/** Origin `apps/admin-web` is served from — added to the CORS allow-list alongside `CORS_ORIGIN`. */
ADMIN_CORS_ORIGIN: z.string().default("http://localhost:5174"),
/** Optional seed values read by `src/scripts/create-admin.ts` when its `--email`/`--password`/`--name` flags are omitted — never used by the running server. */
ADMIN_INITIAL_EMAIL: z.string().optional(),
ADMIN_INITIAL_PASSWORD: z.string().optional(),
ADMIN_INITIAL_NAME: z.string().optional(),
}); });
/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */ /** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */

View file

@ -0,0 +1,59 @@
import jwt from "jsonwebtoken";
import { env } from "../config/env.js";
/**
* Decoded contents of an **admin** session JWT, once verified. Deliberately
* a separate token type from `lib/jwt.ts`'s `AuthTokenPayload`: the admin
* app authenticates against its own `AdminUser` table with its own secret
* (`ADMIN_JWT_SECRET`), so an end-user session token and an admin session
* token are never interchangeable.
*/
export interface AdminTokenPayload {
/** `AdminUser.id` this token authenticates. */
adminUserId: number;
/** Snapshot of `AdminUser.tokenVersion` at sign time — re-checked against the DB on every request (see `requireAdmin`) to allow server-side invalidation. */
tokenVersion: number;
}
/**
* The admin JWT secret, or a thrown error if the instance never configured
* one. A misconfigured admin deployment surfaces as a loud 500 on login
* rather than a silently-unsigned token; a deployment that doesn't run the
* admin app at all never reaches here (nothing calls sign/verify), and
* `requireAdmin` independently fails closed on the same unset value.
*/
function adminSecret(): string {
if (env.ADMIN_JWT_SECRET === undefined) {
throw new Error("ADMIN_JWT_SECRET is not configured — cannot issue or verify admin sessions");
}
return env.ADMIN_JWT_SECRET;
}
/** Signs a new admin session JWT, expiring per `JWT_EXPIRES_IN` (shared with the end-user token — same "how long a session lasts" policy). */
export function signAdminToken(payload: AdminTokenPayload): string {
return jwt.sign(
{ sub: String(payload.adminUserId), tokenVersion: payload.tokenVersion },
adminSecret(),
{ expiresIn: env.JWT_EXPIRES_IN as jwt.SignOptions["expiresIn"] },
);
}
/**
* Verifies an admin session JWT's signature/expiry and decodes it back
* into an {@link AdminTokenPayload}.
*
* @throws {Error} if the token is invalid/expired (from `jwt.verify`) or
* structurally malformed (missing/wrong-typed claims).
*/
export function verifyAdminToken(token: string): AdminTokenPayload {
const decoded = jwt.verify(token, adminSecret());
const adminUserId = typeof decoded === "object" ? Number(decoded.sub) : Number.NaN;
if (
typeof decoded !== "object" ||
Number.isNaN(adminUserId) ||
typeof decoded.tokenVersion !== "number"
) {
throw new Error("Malformed admin token payload");
}
return { adminUserId, tokenVersion: decoded.tokenVersion };
}

View file

@ -0,0 +1,20 @@
import type { AdminUserView } from "@batch-cooking/shared";
import type { AdminUser } from "@prisma/client";
/**
* Shapes a Prisma `AdminUser` into the {@link AdminUserView} sent to the
* admin client drops `passwordHash` **and** `tokenVersion` (an internal
* invalidation counter the client never needs, unlike `SafeUserProfile`
* which does expose it), and serializes the two dates to ISO strings. The
* one place this security-relevant stripping happens, same role as
* `toSafeProfile` (`lib/safe-profile.ts`).
*/
export function toSafeAdmin(admin: AdminUser): AdminUserView {
return {
id: admin.id,
email: admin.email,
name: admin.name,
createdAt: admin.createdAt.toISOString(),
lastLoginAt: admin.lastLoginAt === null ? null : admin.lastLoginAt.toISOString(),
};
}

View file

@ -0,0 +1,71 @@
import { HttpError } from "@batch-cooking/error-tools";
import { type AdminUserView, ErrorCode } from "@batch-cooking/shared";
import type { NextFunction, Request, Response } from "express";
import { env } from "../config/env.js";
import { prisma } from "../db/prisma.js";
import { verifyAdminToken } from "../lib/admin-jwt.js";
import { toSafeAdmin } from "../lib/safe-admin.js";
/**
* Shape of `res.locals` once {@link requireAdmin} has run successfully.
* Type a handler's response as `Response<unknown, AdminLocals>` to read
* `res.locals.adminUser` fully typed, no cast same `res.locals` (not
* global `Request` augmentation) approach as {@link AuthLocals}
* (`require-auth.ts`).
*/
export interface AdminLocals {
/** The authenticated admin operator, resolved from the admin session cookie's JWT. */
adminUser: AdminUserView;
}
/**
* Express middleware guarding every `/admin/*` route the operations app
* (`apps/admin-web`) authenticating as an `AdminUser`. Reads the admin
* session cookie (`ADMIN_COOKIE_NAME`, deliberately **not** the same cookie
* as end-user sessions), verifies the JWT against `ADMIN_JWT_SECRET`
* (a different secret than `JWT_SECRET`), and re-checks `tokenVersion`
* against the database so a stateless JWT can still be invalidated
* server-side.
*
* A completely separate mechanism from {@link requireAuth}, not layered on
* it: an end-user session token and an admin session token are never
* interchangeable in either direction.
*
* Fails closed: an unset `ADMIN_JWT_SECRET` (the default for any instance
* that doesn't run the admin app) makes {@link verifyAdminToken} throw, so
* every request is rejected rather than the surface left open same
* posture as `requireInternalWorker`.
*
* @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure missing
* cookie, malformed/expired JWT, unknown admin, stale tokenVersion, or
* no secret configured. Never distinguishes the reason.
*/
export async function requireAdmin(
req: Request,
res: Response<unknown, AdminLocals>,
next: NextFunction,
) {
try {
const token = req.cookies?.[env.ADMIN_COOKIE_NAME];
if (typeof token !== "string") {
throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
}
const payload = verifyAdminToken(token);
const admin = await prisma.adminUser.findUnique({ where: { id: payload.adminUserId } });
if (!admin || admin.tokenVersion !== payload.tokenVersion) {
throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
}
res.locals.adminUser = toSafeAdmin(admin);
next();
} catch (err) {
if (err instanceof HttpError) {
next(err);
} else {
// Covers jwt.verify failures and the unset-secret throw from verifyAdminToken.
next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"));
}
}
}

View file

@ -0,0 +1,51 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { adminLoginSchema } from "@batch-cooking/shared";
import { type CookieOptions, type Response, Router } from "express";
import { env } from "../../config/env.js";
import { type AdminLocals, requireAdmin } from "../../middlewares/require-admin.js";
import { adminLogin } from "./admin-auth.service.js";
/** Router mounted at `/admin/auth` (via `admin.routes.ts`) — admin login, logout, current-admin. No signup: admins are created out-of-band (`src/scripts/create-admin.ts`). */
export const adminAuthRouter = Router();
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
/**
* Cookie options for the admin session same shape as `auth.routes.ts`'s
* end-user cookie (httpOnly, `Secure` in production unless `COOKIE_SECURE`
* overrides, `SameSite=Lax`), just written under {@link env.ADMIN_COOKIE_NAME}
* so the two sessions never collide in one browser.
*/
const adminCookieOptions: CookieOptions = {
httpOnly: true,
secure: env.COOKIE_SECURE ?? env.NODE_ENV === "production",
sameSite: "lax",
maxAge: SEVEN_DAYS_MS,
};
// `res.clearCookie` sets its own expiry — passing `maxAge` alongside is
// deprecated as of Express 4.20, so the logout route reuses the options
// minus that one field (same trick as `auth.routes.ts`).
const { maxAge: _maxAge, ...clearAdminCookieOptions } = adminCookieOptions;
/** Verifies admin credentials and starts an admin session. */
adminAuthRouter.post(
"/login",
wrapAsyncHandler(async (req, res) => {
const input = adminLoginSchema.parse(req.body);
const { admin, token } = await adminLogin(input);
res.cookie(env.ADMIN_COOKIE_NAME, token, adminCookieOptions);
res.status(200).json(admin);
}),
);
/** Ends the admin session by clearing the cookie. Stateless JWT — nothing to revoke server-side beyond bumping `tokenVersion` (no UI for that yet). */
adminAuthRouter.post("/logout", (_req, res) => {
res.clearCookie(env.ADMIN_COOKIE_NAME, clearAdminCookieOptions);
res.status(204).end();
});
/** Returns the currently authenticated admin. Behind `requireAdmin` — 401s if there's no valid admin session. */
adminAuthRouter.get("/me", requireAdmin, (_req, res: Response<unknown, AdminLocals>) => {
res.status(200).json(res.locals.adminUser);
});

View file

@ -0,0 +1,59 @@
import { HttpError } from "@batch-cooking/error-tools";
import { type AdminLoginInput, type AdminUserView, ErrorCode } from "@batch-cooking/shared";
import argon2 from "argon2";
import { env } from "../../config/env.js";
import { prisma } from "../../db/prisma.js";
import { signAdminToken } from "../../lib/admin-jwt.js";
import { toSafeAdmin } from "../../lib/safe-admin.js";
/** Result of a successful admin login: the safe admin view plus the signed admin session JWT to set as a cookie. */
interface AdminAuthResult {
admin: AdminUserView;
token: string;
}
// Same reasoning as `auth.service.ts`'s `hashOptions`: argon2's real
// defaults are deliberately expensive; the test suite hashes/verifies
// against throwaway data many times per run, so a cheaper cost keeps it
// fast without weakening anything real. Never applies outside NODE_ENV=test.
const testHashOptions = { memoryCost: 8192, timeCost: 2, parallelism: 1 };
const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined;
/** Exposed so `src/scripts/create-admin.ts` hashes exactly the same way `login` verifies. */
export function hashAdminPassword(password: string): Promise<string> {
return argon2.hash(password, hashOptions);
}
/**
* Verifies an admin operator's credentials, stamps `lastLoginAt`, and
* issues a fresh admin session token.
*
* @throws {HttpError} `401 INVALID_CREDENTIALS` for either an unknown email
* or a wrong password deliberately indistinguishable, same reasoning as
* `auth.service.ts`'s `login`.
*/
export async function adminLogin(input: AdminLoginInput): Promise<AdminAuthResult> {
try {
const admin = await prisma.adminUser.findUnique({ where: { email: input.email } });
if (!admin || !(await argon2.verify(admin.passwordHash, input.password))) {
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid email or password");
}
const updated = await prisma.adminUser.update({
where: { id: admin.id },
data: { lastLoginAt: new Date() },
});
const token = signAdminToken({
adminUserId: updated.id,
tokenVersion: updated.tokenVersion,
});
return { admin: toSafeAdmin(updated), token };
} catch (err) {
// Rethrown as-is — `wrapAsyncHandler`/the error middleware handles it,
// this service layer just isn't allowed a bare `await` per the repo's
// async/try-catch convention.
throw err;
}
}

View file

@ -0,0 +1,13 @@
import { Router } from "express";
import { adminAuthRouter } from "./admin-auth.routes.js";
/**
* Aggregator for the admin application's API surface, mounted at `/admin`
* in `app.ts`. Every sub-router here is for `apps/admin-web` only
* `/admin/auth` is public (login), everything added later
* (`/admin/metrics`, `/admin/monitoring`, `/admin/tech-steps/*`) sits
* behind `requireAdmin` (`middlewares/require-admin.ts`).
*/
export const adminRouter = Router();
adminRouter.use("/auth", adminAuthRouter);

View file

@ -0,0 +1,56 @@
import { env } from "../config/env.js";
import { prisma } from "../db/prisma.js";
import { hashAdminPassword } from "../modules/admin/admin-auth.service.js";
/**
* Creates the first (or an additional) `AdminUser` for the admin
* application there is no self-service admin signup, on purpose (see the
* `AdminUser` model doc comment in schema.prisma).
*
* pnpm --filter api exec tsx src/scripts/create-admin.ts \
* --email=ops@example.com --password='...' --name='Ops'
*
* Each flag falls back to the matching `ADMIN_INITIAL_*` env var when
* omitted, so a deployment can bake the first admin's credentials into its
* environment and run this once from the container without passing args.
* Refuses (exit 1) if an `AdminUser` with that email already exists
* changing an existing admin's password is a manual DB operation for now,
* not something this script does.
*/
function flag(name: string): string | undefined {
const prefix = `--${name}=`;
const arg = process.argv.find((value) => value.startsWith(prefix));
return arg === undefined ? undefined : arg.slice(prefix.length);
}
async function createAdmin(): Promise<void> {
const email = (flag("email") ?? env.ADMIN_INITIAL_EMAIL)?.trim().toLowerCase();
const password = flag("password") ?? env.ADMIN_INITIAL_PASSWORD;
const name = (flag("name") ?? env.ADMIN_INITIAL_NAME)?.trim();
if (!email || !password || !name) {
throw new Error(
"Missing required input. Provide --email, --password and --name (or set ADMIN_INITIAL_EMAIL / ADMIN_INITIAL_PASSWORD / ADMIN_INITIAL_NAME).",
);
}
if (password.length < 8) {
throw new Error("Password must be at least 8 characters.");
}
const existing = await prisma.adminUser.findUnique({ where: { email } });
if (existing) {
throw new Error(`An admin with email "${email}" already exists (id ${existing.id}).`);
}
const passwordHash = await hashAdminPassword(password);
const admin = await prisma.adminUser.create({ data: { email, name, passwordHash } });
console.info(`Created admin #${admin.id} <${admin.email}> ("${admin.name}").`);
}
createAdmin()
.then(() => prisma.$disconnect())
.catch(async (err) => {
console.error(err instanceof Error ? err.message : err);
await prisma.$disconnect();
process.exit(1);
});

View file

@ -47,7 +47,8 @@ export async function resetDatabase() {
"planning_item", "planning", "planning_item", "planning",
"recipe_ingredient", "step_tech_step", "step", "tech_step", "recipe_ingredient", "step_tech_step", "step", "tech_step",
"recipe", "ingredients", "sources", "unit", "recipe", "ingredients", "sources", "unit",
"user_profiles", "diet", "house" "user_profiles", "diet", "house",
"admin_users"
RESTART IDENTITY CASCADE; RESTART IDENTITY CASCADE;
`); `);
await seedReferenceData(prisma); await seedReferenceData(prisma);

View file

@ -0,0 +1,149 @@
import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { env } from "../src/config/env.js";
import { prisma } from "../src/db/prisma.js";
import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
import { resetDatabase } from "../test-support/reset-db.js";
/** See `auth.test.ts` — same rationale for generating rather than hardcoding. */
function buildSignupPayload(): SignupInput {
const firstName = faker.person.firstName();
const lastName = faker.person.lastName();
return {
firstName,
lastName,
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
password: faker.internet.password({ length: 16 }),
};
}
/** Inserts an `AdminUser` straight into the DB (no signup route exists) and returns its plaintext password. */
async function seedAdmin(): Promise<{ email: string; password: string }> {
const email = faker.internet.email().toLowerCase();
const password = faker.internet.password({ length: 16 });
await prisma.adminUser.create({
data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) },
});
return { email, password };
}
/** The admin login/verify path signs a JWT — self-skip those cases when no `ADMIN_JWT_SECRET` is configured (same posture as `tech-step-worker.routes.test.ts` with `INTERNAL_WORKER_SECRET`). */
const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined;
describe("Admin auth", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
describe("POST /admin/auth/login", () => {
it("rejects a missing body with 400 VALIDATION_ERROR", async () => {
const res = await request(app).post("/admin/auth/login").send({});
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("rejects an unknown email with 401 INVALID_CREDENTIALS", async () => {
const res = await request(app)
.post("/admin/auth/login")
.send({ email: "nobody@example.com", password: "whatever" });
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS);
});
it("rejects a wrong password with 401 INVALID_CREDENTIALS", async () => {
const { email } = await seedAdmin();
const res = await request(app)
.post("/admin/auth/login")
.send({ email, password: "not-the-password" });
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS);
});
it("logs in with correct credentials, sets the admin cookie, stamps lastLoginAt, never leaks the hash", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed without @types/mocha (not a dependency here).
(this as any).skip();
return;
}
const { email, password } = await seedAdmin();
const res = await request(app).post("/admin/auth/login").send({ email, password });
expect(res.status).to.equal(200);
expect(res.body.email).to.equal(email);
expect(res.body).to.not.have.property("passwordHash");
expect(res.body).to.not.have.property("tokenVersion");
expect(res.body.lastLoginAt).to.be.a("string");
const setCookie = res.headers["set-cookie"];
expect(Array.isArray(setCookie) ? setCookie.join(";") : String(setCookie)).to.include(
env.ADMIN_COOKIE_NAME,
);
const stored = await prisma.adminUser.findUniqueOrThrow({ where: { email } });
expect(stored.lastLoginAt).to.not.equal(null);
});
});
describe("GET /admin/auth/me", () => {
it("rejects a request with no admin cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).get("/admin/auth/me");
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("returns the admin behind a valid admin session", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: see above.
(this as any).skip();
return;
}
const { email, password } = await seedAdmin();
const agent = request.agent(app);
await agent.post("/admin/auth/login").send({ email, password });
const res = await agent.get("/admin/auth/me");
expect(res.status).to.equal(200);
expect(res.body.email).to.equal(email);
});
it("stops returning the admin after logout", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: see above.
(this as any).skip();
return;
}
const { email, password } = await seedAdmin();
const agent = request.agent(app);
await agent.post("/admin/auth/login").send({ email, password });
const logoutRes = await agent.post("/admin/auth/logout");
expect(logoutRes.status).to.equal(204);
const meRes = await agent.get("/admin/auth/me");
expect(meRes.status).to.equal(401);
});
it("does not accept an end-user session cookie as an admin session", async () => {
// An ordinary user logs in (sets the `session` cookie), then tries the
// admin surface with that same agent — `requireAdmin` reads a
// different cookie entirely, so this must 401 regardless of whether
// ADMIN_JWT_SECRET is configured.
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/admin/auth/me");
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
});
});

View file

@ -48,6 +48,15 @@ services:
# default: `/internal/tech-steps/*` fails closed rather than open # default: `/internal/tech-steps/*` fails closed rather than open
# for a deployment that doesn't run the worker at all. # for a deployment that doesn't run the worker at all.
INTERNAL_WORKER_SECRET: ${INTERNAL_WORKER_SECRET:-} INTERNAL_WORKER_SECRET: ${INTERNAL_WORKER_SECRET:-}
# Admin application (apps/admin-web + /admin/*). Both unset by default:
# `requireAdmin` fails closed without ADMIN_JWT_SECRET, so a stack
# that doesn't run the admin app simply has every /admin/* route 401.
# Must be a *different* secret than JWT_SECRET.
ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET:-}
# Public origin apps/admin-web is served from, added to the CORS
# allow-list alongside the main app. Defaults to the compose
# `admin-web` service's mapped host port.
ADMIN_CORS_ORIGIN: ${ADMIN_CORS_ORIGIN:-http://localhost:3001}
# Compose network service name, not localhost — same reasoning as # Compose network service name, not localhost — same reasoning as
# DATABASE_URL above. Unlike INTERNAL_WORKER_SECRET, no `:-` fallback: # DATABASE_URL above. Unlike INTERNAL_WORKER_SECRET, no `:-` fallback:
# tech-step-intent-service is a core dependency (see its own entry # tech-step-intent-service is a core dependency (see its own entry

View file

@ -13,8 +13,14 @@ export type HttpMethod = "get" | "post" | "put" | "patch" | "delete";
/** Options for {@link ExpressServer.setupCore}. */ /** Options for {@link ExpressServer.setupCore}. */
export interface ExpressServerCoreOptions { export interface ExpressServerCoreOptions {
/** Origin allowed by CORS — must match wherever the frontend is served from. */ /**
corsOrigin: string; * 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[];
} }
/** /**

View file

@ -7,6 +7,7 @@ export * from "./data/catalog-labels-en.js";
export * from "./data/catalog-labels-fr.js"; export * from "./data/catalog-labels-fr.js";
export * from "./errors/error-codes.js"; export * from "./errors/error-codes.js";
export * from "./schemas/account.js"; export * from "./schemas/account.js";
export * from "./schemas/admin.js";
export * from "./schemas/auth.js"; export * from "./schemas/auth.js";
export * from "./schemas/household.js"; export * from "./schemas/household.js";
export * from "./schemas/planning.js"; export * from "./schemas/planning.js";
@ -17,6 +18,7 @@ export * from "./schemas/shopping-list.js";
export * from "./schemas/sources.js"; export * from "./schemas/sources.js";
export * from "./schemas/tech-step-worker.js"; export * from "./schemas/tech-step-worker.js";
export * from "./tools/assert-is-never.js"; export * from "./tools/assert-is-never.js";
export * from "./types/admin.js";
export * from "./types/household.js"; export * from "./types/household.js";
export * from "./types/planning.js"; export * from "./types/planning.js";
export * from "./types/preferences.js"; export * from "./types/preferences.js";

View file

@ -0,0 +1,18 @@
import { z } from "zod";
// Shared between apps/api (server-side validation, source of truth) and
// apps/admin-web (client-side validation for instant feedback). Same
// rationale as schemas/auth.ts — one set of rules, French messages surfaced
// as-is in the admin login form.
/**
* Payload accepted by `POST /admin/auth/login`. Deliberately its own schema
* (not a re-export of `loginSchema`): the admin surface is a separate
* contract from the end-user one even though the shape currently matches.
*/
export const adminLoginSchema = z.object({
email: z.string().trim().toLowerCase().email("Email invalide"),
password: z.string().min(1, "Le mot de passe est requis"),
});
/** Inferred TS type for {@link adminLoginSchema}'s validated output. */
export type AdminLoginInput = z.infer<typeof adminLoginSchema>;

View file

@ -0,0 +1,16 @@
/**
* Public shape of an admin operator, as returned by `GET /admin/auth/me`
* and `POST /admin/auth/login` never includes the password hash. Mirrors
* apps/api's `Omit<PrismaAdminUser, "passwordHash" | "tokenVersion">`,
* declared by hand rather than derived from the Prisma type (same reason as
* {@link SafeUserProfile}: apps/admin-web must not depend on
* `@prisma/client`). `createdAt`/`lastLoginAt` are ISO 8601 strings (JSON
* has no date type).
*/
export interface AdminUserView {
id: number;
email: string;
name: string;
createdAt: string;
lastLoginAt: string | null;
}