/** * API route wrappers. * * Replaces the 8-line auth + ownership boilerplate that every route file * was reimplementing (and getting subtly wrong in 18 of them — see the * 2026-05-17 QA pass). * * Usage: * * // Plain session-required handler. * export const GET = withAuth(async (req, ctx, { user }) => { * return NextResponse.json({ email: user.email }); * }); * * // Session + tenant-project ownership in one wrapper. * export const POST = withTenantProject(async (req, ctx, { user, project }) => { * // `project` is guaranteed to belong to `user`. * }, { paramName: 'projectId' }); * * // Workspace-scoped (session OR vibn_sk_ api key OK). * export const POST = withWorkspace(async (req, ctx, { principal }) => { * // `principal.workspace` is guaranteed to be tenant-checked. * }); * * // Admin secret (ops endpoint). * export const POST = withAdminSecret(async (req, ctx) => { … }, { * secretEnvVar: 'ADMIN_MIGRATE_SECRET', * }); */ import { NextResponse } from "next/server"; import type { Session } from "next-auth"; import { authSession } from "@/lib/auth/session-server"; import { queryOne } from "@/lib/db-postgres"; import { requireWorkspacePrincipal } from "@/lib/auth/workspace-auth"; import { timingSafeStringEq } from "@/lib/server/timing-safe"; import { rateLimit, type RateLimitOpts } from "@/lib/server/rate-limit"; type WithAuthCtx = { user: NonNullable }; type WithTenantProjectCtx = WithAuthCtx & { project: ProjectRow }; type WithAdminCtx = { secret: string }; export interface ProjectRow { id: string; data: Record; slug?: string; } type RouteHandler = ( req: Request, ctx: { params: Promise }, extra: TCtx, ) => Promise | Response; // ─── withAuth ───────────────────────────────────────────────────────────── export function withAuth( handler: RouteHandler, ) { return async (req: Request, ctx: { params: Promise }) => { const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } return handler(req, ctx, { user: session.user }); }; } // ─── withTenantProject ──────────────────────────────────────────────────── export interface WithTenantProjectOpts { /** * Where to find the project id. Default `'projectId'` in `params`. * - 'params:projectId' → ctx.params.projectId (default for `[projectId]` routes) * - 'search:projectId' → searchParams.projectId * - 'body:projectId' → body.projectId (consumes body via clone+json) */ source?: "params" | "search" | "body"; paramName?: string; } export function withTenantProject>( handler: RouteHandler, opts: WithTenantProjectOpts = {}, ) { const source = opts.source ?? "params"; const name = opts.paramName ?? "projectId"; return async (req: Request, ctx: { params: Promise }) => { const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } let projectId: string | undefined; if (source === "params") { const p = (await ctx.params) as Record; projectId = p?.[name]; } else if (source === "search") { projectId = new URL(req.url).searchParams.get(name) ?? undefined; } else if (source === "body") { try { const body = await req.clone().json(); projectId = body?.[name]; } catch { // fallthrough; caller will get 400 below } } if (!projectId) { return NextResponse.json( { error: `${name} is required` }, { status: 400 }, ); } // Ownership check: project must belong to the authenticated user. const row = await queryOne( `SELECT p.id::text AS id, p.data FROM fs_projects p JOIN fs_users u ON u.id = p.user_id WHERE p.id = $1::text AND u.data->>'email' = $2::text LIMIT 1`, [projectId, session.user.email], ); if (!row) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } return handler(req, ctx, { user: session.user, project: row }); }; } // ─── withWorkspace (re-export of existing helper with consistent shape) ── type WorkspacePrincipal = Exclude< Awaited>, NextResponse >; export function withWorkspace>( handler: RouteHandler<{ principal: WorkspacePrincipal }, TParams>, opts: { paramName?: string } = {}, ) { const name = opts.paramName ?? "slug"; return async (req: Request, ctx: { params: Promise }) => { const params = (await ctx.params) as Record | undefined; const targetSlug = params?.[name]; const principal = await requireWorkspacePrincipal(req, { targetSlug }); if (principal instanceof NextResponse) return principal; return handler(req, ctx, { principal }); }; } // ─── withAdminSecret ────────────────────────────────────────────────────── export interface WithAdminSecretOpts { /** env var that holds the expected secret. */ secretEnvVar: string; /** Header to read. Default `authorization` (expects `Bearer `). */ header?: string; /** Alternate header that may also carry the secret (e.g. `x-admin-secret`). */ altHeader?: string; } export function withAdminSecret( handler: RouteHandler, opts: WithAdminSecretOpts, ) { return async (req: Request, ctx: { params: Promise }) => { const expected = process.env[opts.secretEnvVar]?.trim() ?? ""; if (!expected) { return NextResponse.json( { error: `${opts.secretEnvVar} not configured — endpoint disabled` }, { status: 403 }, ); } const header = (opts.header ?? "authorization").toLowerCase(); const raw = req.headers.get(header) ?? ""; const bearer = raw.toLowerCase().startsWith("bearer ") ? raw.slice(7).trim() : ""; const alt = opts.altHeader ? (req.headers.get(opts.altHeader) ?? "").trim() : ""; const incoming = bearer || alt; if (!incoming || !timingSafeStringEq(expected, incoming)) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } return handler(req, ctx, { secret: expected }); }; } // ─── withRateLimit ──────────────────────────────────────────────────────── export interface WithRateLimitOpts extends Omit { /** * How to derive the per-call key. Receives the bound auth context if any. * Default: client IP. */ keyFn?: (req: Request, extra: unknown) => string | Promise; } /** Wrap any other wrapper's handler. Composes neatly with withAuth/withWorkspace. */ export function withRateLimit any>( handler: THandler, opts: WithRateLimitOpts, ): THandler { return (async (req: Request, ...rest: unknown[]) => { const extra = rest[1] ?? {}; const key = (opts.keyFn ? await opts.keyFn(req, extra) : null) ?? `ip:${req.headers.get("x-forwarded-for") ?? "unknown"}`; const rl = await rateLimit({ key, limit: opts.limit, windowMs: opts.windowMs, }); if (!rl.ok) { return NextResponse.json( { error: "Rate limit exceeded", retryAfterMs: rl.retryAfterMs }, { status: 429, headers: rl.retryAfterMs ? { "Retry-After": String(Math.ceil(rl.retryAfterMs / 1000)) } : undefined, }, ); } return handler(req, ...rest); }) as THandler; }