Files
parking_solution/apps/server/src/routes/auth.ts
T
julian 23d6379be8 feat(modules): venue-module registry — entitled ∩ activated, requireModule, Setup panel
Groundwork for the Car Wash pilot (wiki/decisions/venue-modules.md, build-order
steps 1 + 3). No Car Wash code yet; validation is the first module behind the
seam, unchanged in behaviour.

- @parking/shared: MODULE_IDS, ModuleManifest, MODULES (parking required;
  validation dependsOn parking), parseEntitledModules / resolveModuleActivation
  / effectiveModules as pure functions.
- DB: site_config.modules_json (migration 0026, hand-written + journal;
  additive, nullable = everything entitled).
- Server: modules.ts (entitledModules from MODULES_ENTITLED env, activated
  from site_config, effective set, requireModule preHandler → 403
  module_disabled); modules/index.ts registers folder-based modules by
  iterating the registry (modules/validation); site-config GET exposes
  modules/modulesEntitled/modulesActivated, PUT takes the full desired set,
  enforces entitlement + dependency rules (400 with reason) and signs one
  config_change per module that actually flips; /api/auth/me carries the
  effective set; validation routes guarded requireModule → requirePermission.
- Web: lib/modules.ts + modules/{index,validation}; router.tsx spreads
  WEB_MODULES into nav + route tree (validate route no longer named there);
  Setup → Site "Modules" panel (required shown disabled, dependencies as
  hints, server refusal shown verbatim); validation sections + programs fetch
  gated on the module; App invalidates the router whenever the session
  changes (route-context consumers only re-read on navigation — the nav was
  stale after a flip, and after every other setUser too).
- Lavazh validation station retired (STATIONS = ["bar"]; rows untouched).
- Deploy: MODULES_ENTITLED=parking,validation explicit in both booth stacks;
  documented in .env.example.
- Tests: modules.test.ts (7); suite 329/329; web build clean; Playwright
  round-trip on /setup/site verified live.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-05 11:04:39 +02:00

270 lines
11 KiB
TypeScript

import bcrypt from "bcrypt";
import type { FastifyInstance } from "fastify";
import { eq, roles, users, type Db } from "@parking/db";
import { effectiveModulesFor } from "../modules.js";
import {
clearAuthCookies,
newCsrfToken,
permissionsFor,
requireAuth,
setAuthCookies,
} from "../auth.js";
// Local auth: username + bcrypt password → signed JWT in an HttpOnly cookie.
// Fully offline; no external identity provider. See wiki/entities/local-jwt-auth.md.
interface LoginBody {
username: string;
password: string;
}
const LANGS = ["sq", "en"] as const;
type Lang = (typeof LANGS)[number];
interface LanguageBody {
language: Lang;
}
const THEMES = ["dark", "light"] as const;
type Theme = (typeof THEMES)[number];
interface ThemeBody {
theme: Theme;
}
// UI font scale: percent of base, clamped to [80, 160] in steps of 10. Integer percent.
const FONT_SCALE_MIN = 80;
const FONT_SCALE_MAX = 160;
interface FontScaleBody {
fontScale: number;
}
// Self-service profile: a signed-in user edits their OWN display name + email. This is
// NOT the admin user-management path (routes/users.ts) — it only ever touches the caller
// (req.user.sub), needs no `user:*` permission, and can't change username, role, or any
// other account. "" clears a field (→ null). See wiki/entities/local-jwt-auth.md.
interface ProfileBody {
fullName?: string | null;
email?: string | null;
}
// Self-service password change: the user proves they hold the CURRENT password before
// setting a new one — unlike the admin reset (users.ts), which sets it outright. This is
// why it lives here and not behind a permission: it's account-self-care, not admin power.
interface PasswordBody {
currentPassword: string;
newPassword: string;
}
const MIN_PASSWORD = 8;
/** Trim a self-service profile string; "" (or whitespace) → null (clear the field).
* Returns undefined for an absent key so an update only touches what was sent. */
function cleanProfileField(v: string | null | undefined): string | null | undefined {
if (v === undefined) return undefined;
const trimmed = typeof v === "string" ? v.trim() : "";
return trimmed === "" ? null : trimmed;
}
/** The session shape the SPA bootstraps from: identity + role + its permission
* list (so the UI can gate nav/routes) + language. Role NAME is for display; the
* permissions are the source of truth.
*
* `csrf`, when passed, echoes the SAME value already sent as the readable
* parking_csrf cookie — not a new secret, just a second channel to learn it.
* The desktop shell needs this: tauri-plugin-http's fetch() runs through
* Rust's reqwest, which keeps its own cookie jar separate from the webview,
* so document.cookie on the tauri://localhost page never sees a cookie set
* on a plugin-routed response (open upstream bug, tauri-apps/tauri#13045).
* The cookie itself IS still sent back to the server by reqwest on
* subsequent requests — only the *client-side read* is broken — so
* api.ts's desktop path stashes this body value in memory instead of
* reading document.cookie, and echoes it in X-CSRF-Token exactly as the
* browser path echoes the cookie. See lib/api.ts and assertCsrf() in
* ../auth.ts (unchanged — this never touches verification, only how the
* desktop client learns what to send). */
function sessionView(
db: Db,
user: {
id: string;
username: string;
roleId: string;
language: string;
theme: string;
fontScale: number;
fullName?: string | null;
email?: string | null;
},
csrf?: string,
) {
const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get();
const permissions = [...permissionsFor(user.roleId)];
return {
id: user.id,
username: user.username,
roleId: user.roleId,
roleName: role?.name ?? user.roleId,
permissions,
language: user.language,
theme: user.theme,
fontScale: user.fontScale,
fullName: user.fullName ?? null,
email: user.email ?? null,
// Effective venue modules (entitled ∩ activated) so the SPA can hide nav/routes
// on first paint. The server still enforces via requireModule — this is display.
modules: effectiveModulesFor(db),
...(csrf ? { csrfToken: csrf } : {}),
};
}
export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
app.post<{ Body: LoginBody }>("/api/auth/login", async (req, reply) => {
const { username, password } = req.body ?? {};
if (!username || !password) {
return reply.code(400).send({ error: "username and password required" });
}
const user = await db.select().from(users).where(eq(users.username, username)).get();
// Always run a bcrypt compare to avoid leaking which usernames exist (timing).
const hash = user?.passwordHash ?? "$2b$10$invalidinvalidinvalidinvalidinvalidinvalidinv";
const ok = await bcrypt.compare(password, hash);
// A soft-deleted user (in the recycle bin) cannot log in — treat as invalid, with no
// distinct error so a deleted account isn't enumerable.
if (!user || !ok || user.deletedAt) {
return reply.code(401).send({ error: "invalid credentials" });
}
const csrf = newCsrfToken();
// No expiresIn: the token is valid until explicit logout (see auth.ts). The
// token carries roleId (not the permission list) — perms resolve per-request,
// so a role edit applies immediately with no re-login.
const token = await reply.jwtSign({
sub: user.id,
username: user.username,
roleId: user.roleId,
csrf,
});
setAuthCookies(reply, token, csrf);
// `language` is NOT in the JWT (identity/role only) — it's a mutable preference
// read from the DB, so changing it needs no token refresh.
return sessionView(db, user, csrf);
});
app.post("/api/auth/logout", async (_req, reply) => {
clearAuthCookies(reply);
return { ok: true };
});
// Who am I — used by the SPA to bootstrap session state on load. Reads the live
// `language` preference from the DB (not the token).
app.get(
"/api/auth/me",
{ preHandler: requireAuth },
async (req, reply) => {
const row = await db.select().from(users).where(eq(users.id, req.user.sub)).get();
if (!row) {
// The user was deleted while their cookie was still valid — clear it.
clearAuthCookies(reply);
return reply.code(401).send({ error: "session no longer valid" });
}
// req.user.csrf is the value bound into the JWT at login (see assertCsrf in
// ../auth.ts) — same value as the cookie, re-surfaced for the desktop path.
return sessionView(db, row, req.user.csrf);
},
);
// Change MY own UI language preference (any signed-in user). Persisted to the
// users row so it's restored on the next login, from any booth. See i18n.md.
app.put<{ Body: LanguageBody }>(
"/api/auth/language",
{ preHandler: requireAuth },
async (req, reply) => {
const language = req.body?.language;
if (!language || !LANGS.includes(language)) {
return reply.code(400).send({ error: `language must be one of: ${LANGS.join(", ")}` });
}
await db.update(users).set({ language }).where(eq(users.id, req.user.sub)).run();
return { language };
},
);
// Change MY own UI theme preference (any signed-in user). Persisted to the users
// row like `language`, so it's restored on the next login from any booth.
app.put<{ Body: ThemeBody }>(
"/api/auth/theme",
{ preHandler: requireAuth },
async (req, reply) => {
const theme = req.body?.theme;
if (!theme || !THEMES.includes(theme)) {
return reply.code(400).send({ error: `theme must be one of: ${THEMES.join(", ")}` });
}
await db.update(users).set({ theme }).where(eq(users.id, req.user.sub)).run();
return { theme };
},
);
// Change MY own UI font scale (any signed-in user). Percent of base, clamped to
// [80, 160] in steps of 10. Persisted like `theme`, restored on the next login.
app.put<{ Body: FontScaleBody }>(
"/api/auth/font-scale",
{ preHandler: requireAuth },
async (req, reply) => {
const raw = req.body?.fontScale;
if (typeof raw !== "number" || !Number.isFinite(raw)) {
return reply.code(400).send({ error: "fontScale must be a number" });
}
// Snap to a 10-step and clamp to the allowed band (defensive — the UI already does).
const fontScale = Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, Math.round(raw / 10) * 10));
await db.update(users).set({ fontScale }).where(eq(users.id, req.user.sub)).run();
return { fontScale };
},
);
// Edit MY own display name / email (any signed-in user; no permission needed — it only
// touches the caller). Cannot change username or role — those stay admin-only (users.ts).
app.put<{ Body: ProfileBody }>(
"/api/auth/profile",
{ preHandler: requireAuth },
async (req, reply) => {
const fullName = cleanProfileField(req.body?.fullName);
const email = cleanProfileField(req.body?.email);
const patch: Record<string, string | null> = {};
if (fullName !== undefined) patch.fullName = fullName;
if (email !== undefined) patch.email = email;
if (Object.keys(patch).length === 0) {
return reply.code(400).send({ error: "nothing to update" });
}
await db.update(users).set(patch).where(eq(users.id, req.user.sub)).run();
const row = await db.select().from(users).where(eq(users.id, req.user.sub)).get();
if (!row) return reply.code(401).send({ error: "session no longer valid" });
return sessionView(db, row);
},
);
// Change MY own password — must prove the CURRENT one first (defends against a walked-up,
// already-logged-in booth: a passerby can't silently re-key the account). New password
// >= MIN_PASSWORD. Distinct from the admin reset (users.ts), which needs no current pw.
app.put<{ Body: PasswordBody }>(
"/api/auth/password",
{ preHandler: requireAuth },
async (req, reply) => {
const currentPassword = req.body?.currentPassword ?? "";
const newPassword = req.body?.newPassword ?? "";
if (newPassword.length < MIN_PASSWORD) {
return reply.code(400).send({ error: `password must be at least ${MIN_PASSWORD} characters` });
}
const row = await db.select().from(users).where(eq(users.id, req.user.sub)).get();
if (!row) {
clearAuthCookies(reply);
return reply.code(401).send({ error: "session no longer valid" });
}
const ok = await bcrypt.compare(currentPassword, row.passwordHash);
if (!ok) {
return reply.code(403).send({ error: "current password is incorrect" });
}
const passwordHash = await bcrypt.hash(newPassword, 12);
await db.update(users).set({ passwordHash }).where(eq(users.id, req.user.sub)).run();
return { ok: true };
},
);
}