diff --git a/apps/server/src/auth.ts b/apps/server/src/auth.ts index 9298228..397e9c5 100644 --- a/apps/server/src/auth.ts +++ b/apps/server/src/auth.ts @@ -18,9 +18,15 @@ export const TOKEN_COOKIE = "parking_token"; export const CSRF_COOKIE = "parking_csrf"; export const CSRF_HEADER = "x-csrf-token"; -/** Token lifetime, also used as the cookie maxAge. */ -export const TOKEN_TTL = "8h"; -export const TOKEN_TTL_SECONDS = 8 * 60 * 60; +// Session lifetime: the JWT has NO expiry — a login is valid until explicit +// logout. Booth reality breaks any fixed clock (relief late/absent, forced double +// shifts), and a shift is a separate explicit boundary, not the token's lifetime. +// See wiki/entities/local-jwt-auth.md + wiki/concepts/shift.md. +// +// The cookie still needs a maxAge so it survives a browser restart (a session +// cookie would log out an active operator on browser close — the opposite of +// "until logout"). Use a long fixed window; the server clears it on logout. +export const COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60; // 30 days /** * Resolve the JWT signing secret, refusing to start without a strong one. @@ -55,7 +61,7 @@ export function setAuthCookies(reply: FastifyReply, jwt: string, csrf: string): sameSite: "strict", secure, path: "/", - maxAge: TOKEN_TTL_SECONDS, + maxAge: COOKIE_MAX_AGE_SECONDS, }); // Readable by JS so the SPA can echo it back in the CSRF header (double-submit). reply.setCookie(CSRF_COOKIE, csrf, { @@ -63,7 +69,7 @@ export function setAuthCookies(reply: FastifyReply, jwt: string, csrf: string): sameSite: "strict", secure, path: "/", - maxAge: TOKEN_TTL_SECONDS, + maxAge: COOKIE_MAX_AGE_SECONDS, }); } diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts index 38b3a78..a61501d 100644 --- a/apps/server/src/routes/auth.ts +++ b/apps/server/src/routes/auth.ts @@ -2,7 +2,6 @@ import bcrypt from "bcrypt"; import type { FastifyInstance } from "fastify"; import { eq, users, type Db } from "@parking/db"; import { - TOKEN_TTL, clearAuthCookies, newCsrfToken, requireRole, @@ -34,10 +33,13 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise { } const csrf = newCsrfToken(); - const token = await reply.jwtSign( - { sub: user.id, username: user.username, role: user.role, csrf }, - { expiresIn: TOKEN_TTL }, - ); + // No expiresIn: the token is valid until explicit logout (see auth.ts). + const token = await reply.jwtSign({ + sub: user.id, + username: user.username, + role: user.role, + csrf, + }); setAuthCookies(reply, token, csrf); return { id: user.id, username: user.username, role: user.role }; }); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 31996a4..db374d6 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -8,12 +8,14 @@ import { deviceEvents } from "./device-events.js"; import { EntryFlow } from "./entry-flow.js"; import { EventLog } from "./event-log.js"; import { ExitFlow } from "./exit-flow.js"; +import { PayStation } from "./pay-station.js"; import { LaneMap } from "./lane-map.js"; import { PrinterMonitor } from "./printer-monitor.js"; import { buildSigner } from "./signer.js"; import { authRoutes } from "./routes/auth.js"; import { deviceRoutes } from "./routes/devices.js"; import { eventRoutes } from "./routes/events.js"; +import { payRoutes } from "./routes/pay.js"; import { printerRoutes } from "./routes/printers.js"; import { setupRoutes } from "./routes/setup.js"; @@ -41,7 +43,8 @@ export async function buildServer(opts: BuildOptions = {}): Promise unsubscribeExit()); + // Pay station (pay-on-foot): quote an open session against the active tariff + + // take payment → signed `payment` event. See wiki/concepts/tariff.md. + const payStation = new PayStation(db, eventLog, app.log); + await payRoutes(app, payStation); + const unsubscribeInput = deviceEvents.onInput((e) => { // Resolve which lane the device belongs to. -1 marks "device fired but isn't // mapped to a lane" (assigned without a lane, or a stale id) — still recorded diff --git a/wiki/entities/local-jwt-auth.md b/wiki/entities/local-jwt-auth.md index f0ee59d..6bb9612 100644 --- a/wiki/entities/local-jwt-auth.md +++ b/wiki/entities/local-jwt-auth.md @@ -14,13 +14,13 @@ Authentication and authorization, kept **fully local** — a direct consequence - `@fastify/jwt` signs tokens with a **local secret** (symmetric HMAC). The server **refuses to start** without a strong `JWT_SECRET` (≥32 chars, no placeholder) — there is deliberately no insecure default. -- **Session lifetime: valid until explicit logout — no time expiry** (decision 2026-06-15). +- **Session lifetime: valid until explicit logout — no time expiry** (decision 2026-06-15, built). Booth reality breaks any fixed clock: relief arrives late, fails to show, or one operator is forced to work two shifts in a row — a token that expired mid-duty would strand an active operator. So the login persists until logout; a **[[shift]] is a separate, explicit boundary**, not tied to token lifetime. (Superseded the earlier "8h expiry, bound to a shift" assumption.) - > ⚠️ Code still mints an 8h-expiry token — this page records the decided design; the server - > change (drop `expiresIn`, persist until logout) is pending. + The JWT carries no `exp`; the cookie has a long fixed `maxAge` (30 days) so a browser restart + doesn't log out an active operator, and `logout` clears it. - A `users` table in [[sqlite]] holds **bcrypt** password hashes plus a **role** column. The first admin is seeded via `pnpm --filter @parking/server seed-admin` (no bootstrap endpoint). - Authorization = a simple `preHandler` role guard per route: **admin / operator / cashier /