diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts index 2dc1424..2cff9f1 100644 --- a/app/api/auth/login/route.ts +++ b/app/api/auth/login/route.ts @@ -3,21 +3,26 @@ import { z } from "zod"; import { getEnv } from "@/lib/env"; import { timingSafeStringEqual } from "@/lib/auth/timing-safe-compare"; import { createSessionToken, sessionCookieOptions } from "@/lib/auth/session"; +import { withRouteLogging } from "@/lib/api/with-route-logging"; +import { createLogger } from "@/lib/logger"; +const log = createLogger("auth"); const bodySchema = z.object({ secret: z.string().min(1) }); -export async function POST(req: Request) { +export const POST = withRouteLogging("auth.login", async (req: Request) => { const parsed = bodySchema.safeParse(await req.json().catch(() => null)); if (!parsed.success) { return NextResponse.json({ error: "secret is required" }, { status: 400 }); } if (!timingSafeStringEqual(parsed.data.secret, getEnv().ACCESS_PASSWORD)) { + log.warn("login attempt with invalid secret"); return NextResponse.json({ error: "invalid secret" }, { status: 401 }); } + log.info("login succeeded"); const token = await createSessionToken(); const res = NextResponse.json({ ok: true }); res.cookies.set({ ...sessionCookieOptions, value: token }); return res; -} +}); diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts index a3277ea..11d67ee 100644 --- a/app/api/auth/logout/route.ts +++ b/app/api/auth/logout/route.ts @@ -1,8 +1,9 @@ import { NextResponse } from "next/server"; import { SESSION_COOKIE_NAME } from "@/lib/auth/session"; +import { withRouteLogging } from "@/lib/api/with-route-logging"; -export async function POST() { +export const POST = withRouteLogging("auth.logout", async () => { const res = NextResponse.json({ ok: true }); res.cookies.delete(SESSION_COOKIE_NAME); return res; -} +}); diff --git a/app/api/devices/[id]/route.ts b/app/api/devices/[id]/route.ts index ef6898e..54e3314 100644 --- a/app/api/devices/[id]/route.ts +++ b/app/api/devices/[id]/route.ts @@ -1,16 +1,20 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { renameDevice } from "@/lib/db/queries/devices"; +import { withRouteLogging } from "@/lib/api/with-route-logging"; const bodySchema = z.object({ displayName: z.string().min(1).max(120) }); -export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - const parsed = bodySchema.safeParse(await req.json().catch(() => null)); - if (!parsed.success) { - return NextResponse.json({ error: "displayName is required" }, { status: 400 }); - } - const updated = await renameDevice(Number(id), parsed.data.displayName); - if (!updated) return NextResponse.json({ error: "not found" }, { status: 404 }); - return NextResponse.json({ device: updated }); -} +export const PATCH = withRouteLogging( + "devices.rename", + async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params; + const parsed = bodySchema.safeParse(await req.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json({ error: "displayName is required" }, { status: 400 }); + } + const updated = await renameDevice(Number(id), parsed.data.displayName); + if (!updated) return NextResponse.json({ error: "not found" }, { status: 404 }); + return NextResponse.json({ device: updated }); + }, +); diff --git a/app/api/devices/route.ts b/app/api/devices/route.ts index 9ee1bad..abc4435 100644 --- a/app/api/devices/route.ts +++ b/app/api/devices/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from "next/server"; import { listDevices } from "@/lib/db/queries/devices"; +import { withRouteLogging } from "@/lib/api/with-route-logging"; -export async function GET() { +export const GET = withRouteLogging("devices.list", async () => { const rows = await listDevices(); return NextResponse.json({ devices: rows }); -} +}); diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 7afa84e..6711db1 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -1,11 +1,16 @@ import { NextResponse } from "next/server"; import { sqlite } from "@/lib/db/client"; +import { withRouteLogging } from "@/lib/api/with-route-logging"; +import { createLogger } from "@/lib/logger"; -export async function GET() { +const log = createLogger("health"); + +export const GET = withRouteLogging("health.check", async () => { try { sqlite.prepare("select 1").get(); return NextResponse.json({ status: "ok" }); - } catch { + } catch (err) { + log.error("health check failed", { error: err instanceof Error ? err.message : String(err) }); return NextResponse.json({ status: "error" }, { status: 500 }); } -} +}); diff --git a/app/api/play-sessions/[id]/events/route.ts b/app/api/play-sessions/[id]/events/route.ts index b14dcd5..63db4b8 100644 --- a/app/api/play-sessions/[id]/events/route.ts +++ b/app/api/play-sessions/[id]/events/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { insertEvents } from "@/lib/db/queries/session-events"; +import { withRouteLogging } from "@/lib/api/with-route-logging"; const eventSchema = z.object({ sessionDeviceId: z.number().int().positive(), @@ -13,12 +14,15 @@ const eventSchema = z.object({ const bodySchema = z.object({ events: z.array(eventSchema).max(2000) }); -export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - const parsed = bodySchema.safeParse(await req.json().catch(() => null)); - if (!parsed.success) { - return NextResponse.json({ error: parsed.error.message }, { status: 400 }); - } - await insertEvents(Number(id), parsed.data.events); - return NextResponse.json({ inserted: parsed.data.events.length }); -} +export const POST = withRouteLogging( + "play-sessions.events.append", + async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params; + const parsed = bodySchema.safeParse(await req.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.message }, { status: 400 }); + } + await insertEvents(Number(id), parsed.data.events); + return NextResponse.json({ inserted: parsed.data.events.length }); + }, +); diff --git a/app/api/play-sessions/[id]/route.ts b/app/api/play-sessions/[id]/route.ts index ccd7804..ae244f7 100644 --- a/app/api/play-sessions/[id]/route.ts +++ b/app/api/play-sessions/[id]/route.ts @@ -2,42 +2,55 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { deletePlaySession, endPlaySession, getPlaySessionDetail } from "@/lib/db/queries/play-sessions"; import { getRecordingsForSession } from "@/lib/db/queries/recordings"; +import { withRouteLogging } from "@/lib/api/with-route-logging"; +import { createLogger } from "@/lib/logger"; +const log = createLogger("play-sessions"); const patchSchema = z.object({ status: z.enum(["completed", "aborted"]) }); -export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - const detail = await getPlaySessionDetail(Number(id)); - if (!detail) return NextResponse.json({ error: "not found" }, { status: 404 }); - return NextResponse.json(detail); -} +export const GET = withRouteLogging( + "play-sessions.get", + async (_req: Request, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params; + const detail = await getPlaySessionDetail(Number(id)); + if (!detail) return NextResponse.json({ error: "not found" }, { status: 404 }); + return NextResponse.json(detail); + }, +); -export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - const parsed = patchSchema.safeParse(await req.json().catch(() => null)); - if (!parsed.success) { - return NextResponse.json({ error: "status must be completed or aborted" }, { status: 400 }); - } - const updated = await endPlaySession(Number(id), parsed.data); - if (!updated) return NextResponse.json({ error: "not found" }, { status: 404 }); - return NextResponse.json({ playSession: updated }); -} - -export async function DELETE(req: Request, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - const cascade = new URL(req.url).searchParams.get("cascade") === "true"; - try { - await deletePlaySession(Number(id), { cascade }); - return NextResponse.json({ ok: true }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - if (message.includes("FOREIGN KEY") || message.includes("SQLITE_CONSTRAINT")) { - const blockingRecordings = await getRecordingsForSession(Number(id)); - return NextResponse.json( - { error: "cannot delete a session that a saved recording still references", recordings: blockingRecordings }, - { status: 409 }, - ); +export const PATCH = withRouteLogging( + "play-sessions.end", + async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params; + const parsed = patchSchema.safeParse(await req.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json({ error: "status must be completed or aborted" }, { status: 400 }); } - throw err; - } -} + const updated = await endPlaySession(Number(id), parsed.data); + if (!updated) return NextResponse.json({ error: "not found" }, { status: 404 }); + return NextResponse.json({ playSession: updated }); + }, +); + +export const DELETE = withRouteLogging( + "play-sessions.delete", + async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params; + const cascade = new URL(req.url).searchParams.get("cascade") === "true"; + try { + await deletePlaySession(Number(id), { cascade }); + return NextResponse.json({ ok: true }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message.includes("FOREIGN KEY") || message.includes("SQLITE_CONSTRAINT")) { + log.warn("blocked session delete due to referencing recording", { sessionId: id, cascade }); + const blockingRecordings = await getRecordingsForSession(Number(id)); + return NextResponse.json( + { error: "cannot delete a session that a saved recording still references", recordings: blockingRecordings }, + { status: 409 }, + ); + } + throw err; + } + }, +); diff --git a/app/api/play-sessions/route.ts b/app/api/play-sessions/route.ts index 5255431..8281a77 100644 --- a/app/api/play-sessions/route.ts +++ b/app/api/play-sessions/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { listPlaySessions, startPlaySession } from "@/lib/db/queries/play-sessions"; +import { withRouteLogging } from "@/lib/api/with-route-logging"; const deviceInputSchema = z.object({ slotLabel: z.string().min(1), @@ -16,16 +17,16 @@ const bodySchema = z.object({ devices: z.array(deviceInputSchema).min(1), }); -export async function GET() { +export const GET = withRouteLogging("play-sessions.list", async () => { const rows = await listPlaySessions(); return NextResponse.json({ playSessions: rows }); -} +}); -export async function POST(req: Request) { +export const POST = withRouteLogging("play-sessions.start", async (req: Request) => { const parsed = bodySchema.safeParse(await req.json().catch(() => null)); if (!parsed.success) { return NextResponse.json({ error: parsed.error.message }, { status: 400 }); } const result = await startPlaySession(parsed.data); return NextResponse.json(result, { status: 201 }); -} +}); diff --git a/app/api/recordings/[id]/route.ts b/app/api/recordings/[id]/route.ts index d544f0c..4de99a5 100644 --- a/app/api/recordings/[id]/route.ts +++ b/app/api/recordings/[id]/route.ts @@ -1,32 +1,42 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { deleteRecording, getRecording, renameRecording } from "@/lib/db/queries/recordings"; +import { withRouteLogging } from "@/lib/api/with-route-logging"; const patchSchema = z.object({ name: z.string().min(1).max(160).optional(), description: z.string().max(2000).optional(), }); -export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - const result = await getRecording(Number(id)); - if (!result) return NextResponse.json({ error: "not found" }, { status: 404 }); - return NextResponse.json(result); -} +export const GET = withRouteLogging( + "recordings.get", + async (_req: Request, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params; + const result = await getRecording(Number(id)); + if (!result) return NextResponse.json({ error: "not found" }, { status: 404 }); + return NextResponse.json(result); + }, +); -export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - const parsed = patchSchema.safeParse(await req.json().catch(() => null)); - if (!parsed.success) { - return NextResponse.json({ error: "invalid body" }, { status: 400 }); - } - const updated = await renameRecording(Number(id), parsed.data); - if (!updated) return NextResponse.json({ error: "not found" }, { status: 404 }); - return NextResponse.json({ recording: updated }); -} +export const PATCH = withRouteLogging( + "recordings.update", + async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params; + const parsed = patchSchema.safeParse(await req.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json({ error: "invalid body" }, { status: 400 }); + } + const updated = await renameRecording(Number(id), parsed.data); + if (!updated) return NextResponse.json({ error: "not found" }, { status: 404 }); + return NextResponse.json({ recording: updated }); + }, +); -export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - await deleteRecording(Number(id)); - return NextResponse.json({ ok: true }); -} +export const DELETE = withRouteLogging( + "recordings.delete", + async (_req: Request, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params; + await deleteRecording(Number(id)); + return NextResponse.json({ ok: true }); + }, +); diff --git a/app/api/recordings/route.ts b/app/api/recordings/route.ts index 64b4c1c..0c398e2 100644 --- a/app/api/recordings/route.ts +++ b/app/api/recordings/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { createRecording, listRecordings } from "@/lib/db/queries/recordings"; +import { withRouteLogging } from "@/lib/api/with-route-logging"; const bodySchema = z.object({ sourcePlaySessionId: z.number().int().positive(), @@ -8,16 +9,16 @@ const bodySchema = z.object({ description: z.string().max(2000).optional(), }); -export async function GET() { +export const GET = withRouteLogging("recordings.list", async () => { const rows = await listRecordings(); return NextResponse.json({ recordings: rows }); -} +}); -export async function POST(req: Request) { +export const POST = withRouteLogging("recordings.create", async (req: Request) => { const parsed = bodySchema.safeParse(await req.json().catch(() => null)); if (!parsed.success) { return NextResponse.json({ error: parsed.error.message }, { status: 400 }); } const recording = await createRecording(parsed.data); return NextResponse.json({ recording }, { status: 201 }); -} +}); diff --git a/app/api/stats/devices/route.ts b/app/api/stats/devices/route.ts index b87697b..f60a6e4 100644 --- a/app/api/stats/devices/route.ts +++ b/app/api/stats/devices/route.ts @@ -1,9 +1,10 @@ import { NextResponse } from "next/server"; import { getDeviceCommandCounts, getDeviceUsageStats } from "@/lib/db/queries/stats"; +import { withRouteLogging } from "@/lib/api/with-route-logging"; -export async function GET() { +export const GET = withRouteLogging("stats.devices", async () => { const [usage, commandCounts] = await Promise.all([getDeviceUsageStats(), getDeviceCommandCounts()]); const commandCountByDevice = new Map(commandCounts.map((c) => [c.deviceId, c.commandCount])); const devices = usage.map((d) => ({ ...d, commandCount: commandCountByDevice.get(d.deviceId) ?? 0 })); return NextResponse.json({ devices }); -} +}); diff --git a/app/api/stats/recordings/route.ts b/app/api/stats/recordings/route.ts index caa6d43..80ab381 100644 --- a/app/api/stats/recordings/route.ts +++ b/app/api/stats/recordings/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from "next/server"; import { getRecordingLibraryStats } from "@/lib/db/queries/stats"; +import { withRouteLogging } from "@/lib/api/with-route-logging"; -export async function GET() { +export const GET = withRouteLogging("stats.recordings", async () => { const stats = await getRecordingLibraryStats(); return NextResponse.json(stats); -} +}); diff --git a/app/api/stats/sessions/[id]/timeline/route.ts b/app/api/stats/sessions/[id]/timeline/route.ts index 97d45ed..f247134 100644 --- a/app/api/stats/sessions/[id]/timeline/route.ts +++ b/app/api/stats/sessions/[id]/timeline/route.ts @@ -1,9 +1,13 @@ import { NextResponse } from "next/server"; import { getSessionTimeline } from "@/lib/db/queries/stats"; +import { withRouteLogging } from "@/lib/api/with-route-logging"; -export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - const bucketMs = Number(new URL(req.url).searchParams.get("bucketMs") ?? 1000); - const timeline = await getSessionTimeline(Number(id), bucketMs); - return NextResponse.json({ timeline }); -} +export const GET = withRouteLogging( + "stats.session-timeline", + async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params; + const bucketMs = Number(new URL(req.url).searchParams.get("bucketMs") ?? 1000); + const timeline = await getSessionTimeline(Number(id), bucketMs); + return NextResponse.json({ timeline }); + }, +); diff --git a/app/api/stats/sessions/route.ts b/app/api/stats/sessions/route.ts index 9d6321c..17db5da 100644 --- a/app/api/stats/sessions/route.ts +++ b/app/api/stats/sessions/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from "next/server"; import { getSessionsSummary } from "@/lib/db/queries/stats"; +import { withRouteLogging } from "@/lib/api/with-route-logging"; -export async function GET() { +export const GET = withRouteLogging("stats.sessions", async () => { const summary = await getSessionsSummary(); return NextResponse.json(summary); -} +}); diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..fb53da0 --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,26 @@ +import Link from "next/link"; +import type { Metadata } from "next"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { BrandMark } from "@/components/layout/BrandMark"; + +export const metadata: Metadata = { title: "Not found" }; + +export default function NotFound() { + return ( +
+ + + +
+

404

+

This page doesn't exist.

+
+ +
+
+
+ ); +} diff --git a/instrumentation.ts b/instrumentation.ts index 8dc7d8e..969a7c1 100644 --- a/instrumentation.ts +++ b/instrumentation.ts @@ -1,3 +1,5 @@ +import { createLogger } from "@/lib/logger"; + export async function register() { // Only the Node.js server runtime touches better-sqlite3; the Edge // middleware runtime must never import this module. @@ -6,3 +8,26 @@ export async function register() { runMigrations(); } } + +export async function onRequestError( + error: unknown, + request: { path: string; method: string; headers: Record }, + context: { routerKind: string; routePath: string; routeType: string }, +) { + const log = createLogger("uncaught"); + + // Safety net for errors that escape a route handler's own try/catch (e.g. a + // bug in code that never reaches withRouteLogging, or a rendering error) - + // route handlers wrapped in withRouteLogging already log and convert their + // own errors to a JSON 500, so this rarely double-logs the same failure. + const digest = typeof error === "object" && error !== null && "digest" in error ? String(error.digest) : undefined; + log.error("unhandled error", { + message: error instanceof Error ? error.message : String(error), + digest, + stack: error instanceof Error ? error.stack : undefined, + path: request.path, + method: request.method, + routePath: context.routePath, + routeType: context.routeType, + }); +} diff --git a/lib/api/with-route-logging.ts b/lib/api/with-route-logging.ts new file mode 100644 index 0000000..6e662bb --- /dev/null +++ b/lib/api/with-route-logging.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import { randomUUID } from "node:crypto"; +import { createLogger } from "@/lib/logger"; + +const log = createLogger("api"); + +type RouteHandler = (req: Request, ctx: Ctx) => Promise | Response; + +/** + * Wraps an App Router route handler with consistent request/response logging: + * a reqId (also echoed as `x-request-id`), method/path, status, and duration on + * every call, plus a logged stack trace and JSON 500 for anything the handler + * doesn't catch itself. Every export in app/api/** goes through this so log + * shape and error handling stay uniform across routes instead of being + * reimplemented per file. + */ +export function withRouteLogging(routeName: string, handler: RouteHandler): RouteHandler { + return async (req, ctx) => { + const reqId = randomUUID(); + const start = performance.now(); + const { pathname, search } = new URL(req.url); + const reqLog = log.child({ reqId, route: routeName, method: req.method }); + + reqLog.debug("request start", { path: pathname + search }); + + try { + const res = await handler(req, ctx); + const durationMs = Math.round(performance.now() - start); + const level = res.status >= 500 ? "error" : res.status >= 400 ? "warn" : "info"; + reqLog[level]("request end", { status: res.status, durationMs }); + res.headers.set("x-request-id", reqId); + return res; + } catch (err) { + const durationMs = Math.round(performance.now() - start); + reqLog.error("request failed", { + durationMs, + error: err instanceof Error ? err.message : String(err), + stack: err instanceof Error ? err.stack : undefined, + }); + return NextResponse.json({ error: "internal error", reqId }, { status: 500 }); + } + }; +} diff --git a/lib/logger.ts b/lib/logger.ts index dcf4247..8388549 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -34,14 +34,21 @@ export interface Logger { info(message: string, fields?: LogFields): void; warn(message: string, fields?: LogFields): void; error(message: string, fields?: LogFields): void; + /** Returns a logger that merges `bindings` into every call's fields - for attaching + * per-request context (reqId, route) without threading it through every log call. */ + child(bindings: LogFields): Logger; } /** Scoped logger. `scope` is the bracketed tag, e.g. createLogger("api") -> "[api]". */ -export function createLogger(scope: string): Logger { +export function createLogger(scope: string, bindings?: LogFields): Logger { + const log = (level: LogLevel, message: string, fields?: LogFields) => + write(level, scope, message, bindings || fields ? { ...bindings, ...fields } : undefined); + return { - debug: (message, fields) => write("debug", scope, message, fields), - info: (message, fields) => write("info", scope, message, fields), - warn: (message, fields) => write("warn", scope, message, fields), - error: (message, fields) => write("error", scope, message, fields), + debug: (message, fields) => log("debug", message, fields), + info: (message, fields) => log("info", message, fields), + warn: (message, fields) => log("warn", message, fields), + error: (message, fields) => log("error", message, fields), + child: (extra) => createLogger(scope, { ...bindings, ...extra }), }; } diff --git a/package.json b/package.json index 9561c90..87299c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sexy", - "version": "0.3.0", + "version": "0.4.0", "private": true, "scripts": { "dev": "next dev", diff --git a/proxy.ts b/proxy.ts index dc44594..f9e27de 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,5 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { SESSION_COOKIE_NAME, verifySessionToken } from "@/lib/auth/session"; +import { createLogger } from "@/lib/logger"; + +const log = createLogger("auth"); export default async function proxy(req: NextRequest) { const { pathname } = req.nextUrl; @@ -13,9 +16,11 @@ export default async function proxy(req: NextRequest) { } if (pathname.startsWith("/api/")) { + log.warn("rejected unauthenticated request", { path: pathname, method: req.method }); return NextResponse.json({ error: "unauthorized" }, { status: 401 }); } + log.warn("redirecting unauthenticated request to login", { path: pathname }); const loginUrl = new URL("/login", req.url); loginUrl.searchParams.set("from", pathname); return NextResponse.redirect(loginUrl);