Repository Public
1import { NextResponse } from "next/server";2import { z } from "zod";34export class ApiError extends Error {5 constructor(public status: number, public code: string, message?: string, public details?: unknown) { super(message ?? code); }6}78export const ok = (data: unknown, init?: ResponseInit) => NextResponse.json(data, { ...init, headers: { "Cache-Control": "no-store", ...(init?.headers ?? {}) } });910export function fail(e: unknown): NextResponse {11 if (e instanceof ApiError) return NextResponse.json({ error: { code: e.code, message: e.message, details: e.details } }, { status: e.status, headers: { "Cache-Control": "no-store" } });12 if (e instanceof z.ZodError) return NextResponse.json({ error: { code: "invalid_input", message: "Invalid input", details: e.issues.map((i) => ({ path: i.path.join("."), message: i.message })) } }, { status: 400 });13 // Never leak internals; the log line carries no request body.14 console.error("[api] unhandled", e instanceof Error ? `${e.name}: ${e.message}` : String(e));15 return NextResponse.json({ error: { code: "internal", message: "Internal error" } }, { status: 500 });16}1718type Ctx<P> = { params: Promise<P> };19export function route<P = Record<string, string>>(fn: (req: Request, params: P) => Promise<Response>) {20 return async (req: Request, ctx: Ctx<P>) => {21 try { return await fn(req, await ctx.params); } catch (e) { return fail(e); }22 };23}2425export async function body<T extends z.ZodType>(req: Request, schema: T): Promise<z.infer<T>> {26 let raw: unknown;27 try { raw = await req.json(); } catch { throw new ApiError(400, "invalid_json", "Body must be JSON"); }28 return schema.parse(raw);29}3031/** Caddy on the tunnel gateway is the only hop; it sets X-Forwarded-For. */32export function clientIp(req: Request): string {33 const xff = req.headers.get("x-forwarded-for");34 return (xff ? xff.split(",")[0].trim() : "") || req.headers.get("x-real-ip") || "local";35}3637export const uuid = z.string().uuid();38