/** * API: OpenAPI "Try It" Proxy * POST — forwards a request to a local endpoint and returns the result */ import { z } from "zod"; import { NextRequest, NextResponse } from "next/server"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; const ALLOWED_TRY_PATH_PREFIXES = ["/api/", "/v1/", "/v1beta/", "/a2a", "/.well-known/agent.json"]; const BLOCKED_FORWARD_HEADERS = new Set([ "connection", "content-length", "cookie", "host", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", "x-forwarded-for", "x-forwarded-host", "x-forwarded-proto", ]); const tryRequestSchema = z.object({ method: z .enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]) .optional() .default("GET"), path: z .string() .min(1, "Path is required") .startsWith("/", "Path must start with /") .refine((value) => !value.startsWith("//"), "Path must be a same-origin path") .refine( (value) => ALLOWED_TRY_PATH_PREFIXES.some((prefix) => value.startsWith(prefix)), "Path must target an OmniRoute API endpoint" ), headers: z.record(z.string(), z.string()).optional().default({}), body: z.any().optional(), }); function getRequestOrigin(request: NextRequest) { return request.nextUrl?.origin || new URL(request.url).origin; } function buildForwardHeaders(headers: Record) { const forwardHeaders: Record = {}; for (const [key, value] of Object.entries(headers)) { const normalizedKey = key.trim().toLowerCase(); if (!normalizedKey || BLOCKED_FORWARD_HEADERS.has(normalizedKey)) continue; forwardHeaders[key] = value; } return forwardHeaders; } export async function POST(request: NextRequest) { const authError = await requireManagementAuth(request); if (authError) return authError; try { const rawBody = await request.json(); const validation = validateBody(tryRequestSchema, rawBody); if (isValidationFailure(validation)) { return NextResponse.json({ error: validation.error }, { status: 400 }); } const { method, path, headers, body: reqBody } = validation.data; const origin = getRequestOrigin(request); const targetUrl = new URL(path, origin); if (targetUrl.origin !== origin) { return NextResponse.json({ error: "Path must be same-origin" }, { status: 400 }); } const start = performance.now(); // Forward cookies/auth from the original request const forwardHeaders = buildForwardHeaders(headers as Record); // Forward auth from the dashboard session const cookie = request.headers.get("cookie"); if (cookie && !forwardHeaders["Cookie"]) { forwardHeaders["Cookie"] = cookie; } if (reqBody && !forwardHeaders["Content-Type"]) { forwardHeaders["Content-Type"] = "application/json"; } const fetchOptions: RequestInit = { method: method.toUpperCase(), headers: forwardHeaders, }; if (reqBody && method.toUpperCase() !== "GET") { fetchOptions.body = typeof reqBody === "string" ? reqBody : JSON.stringify(reqBody); } const res = await fetch(targetUrl, fetchOptions); const latencyMs = Math.round(performance.now() - start); // Read response const contentType = res.headers.get("content-type") || ""; let responseBody: any; if (contentType.includes("application/json")) { responseBody = await res.json(); } else { const text = await res.text(); // Truncate very large responses responseBody = text.length > 10000 ? text.slice(0, 10000) + "\n... (truncated)" : text; } // Collect response headers const responseHeaders: Record = {}; res.headers.forEach((value, key) => { responseHeaders[key] = value; }); return NextResponse.json({ status: res.status, statusText: res.statusText, headers: responseHeaders, body: responseBody, latencyMs, contentType, }); } catch (error: any) { return NextResponse.json( { status: 0, statusText: "Network Error", headers: {}, body: { error: sanitizeErrorMessage(error) || "Request failed" }, latencyMs: 0, contentType: "application/json", }, { status: 200 } // Return 200 so the frontend can display the error ); } }