import type { FastifyInstance } from "fastify"; import type { AppLogRecord, ClientLogInput, LogLevel } from "@parking/shared"; import { requireAuth, requirePermission } from "../auth.js"; import type { LogService } from "../log-service.js"; // Application/diagnostic logs (app_logs) — see wiki/concepts/app-logs.md. Two ends: // - POST /api/logs : the FRONTEND ships its errors here (failed requests, uncaught // exceptions). Any signed-in user may write (it's their own // browser's diagnostics); CSRF still applies (mutation). // - GET /api/logs : read the store — gated by `log:read` (admin/diagnostic role). // Writes go through the shared LogService (bounded, best-effort, reentrancy-guarded); // the DB sink for BACKEND warn+ is wired at the pino stream, not here. const LEVELS: ReadonlySet = new Set(["trace", "debug", "info", "warn", "error", "fatal"]); /** Cap a single ingest batch so a misbehaving client can't flood the store. */ const MAX_BATCH = 50; function isValidEntry(e: unknown): e is ClientLogInput { if (!e || typeof e !== "object") return false; const o = e as Record; return typeof o.message === "string" && typeof o.level === "string" && LEVELS.has(o.level); } export async function logRoutes(app: FastifyInstance, logService: LogService): Promise { // INGEST — accept one entry or a small batch ({ entries: [...] }). Returns 204. // Deliberately tolerant: it never 4xx's on a malformed entry (a client erroring // while reporting an error shouldn't get a second error) — invalid items are skipped. app.post<{ Body: ClientLogInput | { entries?: unknown[] } }>( "/api/logs", { preHandler: requireAuth }, async (req, reply) => { const body = req.body as ClientLogInput | { entries?: unknown[] }; const raw = Array.isArray((body as { entries?: unknown[] }).entries) ? (body as { entries: unknown[] }).entries : [body]; const userId = req.user?.sub ?? null; const userAgent = req.headers["user-agent"] ?? null; for (const entry of raw.slice(0, MAX_BATCH)) { if (!isValidEntry(entry)) continue; logService.recordClient(entry, { userId, userAgent }); } reply.code(204).send(); }, ); // READ — newest first, with optional level/source/since filters + a limit. The // booth Logs viewer calls this. Gated by log:read. app.get<{ Querystring: { limit?: string; level?: string; source?: string; since?: string } }>( "/api/logs", { preHandler: requirePermission("log:read") }, async (req): Promise<{ logs: AppLogRecord[] }> => { const limit = Math.min(Math.max(Number(req.query.limit) || 200, 1), 2000); const level = (req.query.level ?? "").trim(); const source = (req.query.source ?? "").trim(); const since = (req.query.since ?? "").trim(); const logs = logService.query({ limit, level: LEVELS.has(level) ? (level as LogLevel) : undefined, source: source === "frontend" || source === "backend" ? source : undefined, since: since || undefined, }); return { logs }; }, ); }