Files

1 line
16 KiB
JSON

{"repo": "aununo/virgil", "files": {"src/app/api/proxy/[...path]/route.ts": {"bd262d914e01f4b768d13d6f571958116fcd0736": {"t": 1785746040.6490073, "n": 6502, "h": "43a702bc7d11469b", "c": "// Thin pass-through proxy for Anthropic-compatible gateways that don't\n// expose browser-friendly CORS (OpenRouter, v2.pincc.ai, etc.).\n//\n// The client sets the SDK baseURL to `/api/proxy` and adds an\n// `x-virgil-upstream` header carrying the real upstream base (e.g.\n// `https://openrouter.ai/api`). This route forwards method/body/headers\n// to `${upstream}/${path}` and streams the response back unchanged.\n//\n// The user's API key still lives only in their browser; this server\n// just relays the request — nothing is logged or persisted.\n//\n// Security: the upstream host must match the allowlist below (defense\n// against open-relay / SSRF abuse from anyone who finds the deployment).\n// Operators can extend the allowlist with the VIRGIL_PROXY_ALLOWED_HOSTS\n// env var (comma-separated hostnames).\n\nimport net from \"node:net\";\n\nexport const runtime = \"nodejs\";\nexport const dynamic = \"force-dynamic\";\n\nconst HOP_BY_HOP = new Set([\n \"host\",\n \"connection\",\n \"content-length\",\n \"accept-encoding\",\n \"x-virgil-upstream\",\n]);\n\n// Built-in allowlist of well-known Anthropic-compatible upstreams. Extend\n// via VIRGIL_PROXY_ALLOWED_HOSTS (comma-separated) at deploy time.\nconst BUILTIN_ALLOWED_HOSTS = [\n \"api.anthropic.com\",\n \"api.deepseek.com\",\n \"openrouter.ai\",\n \"v2.pincc.ai\",\n];\n\nconst ALLOWED_HOSTS: Set<string> = (() => {\n const extra = (process.env.VIRGIL_PROXY_ALLOWED_HOSTS || \"\")\n .split(\",\")\n .map((h) => h.trim().toLowerCase())\n .filter(Boolean);\n return new Set([...BUILTIN_ALLOWED_HOSTS, ...extra]);\n})();\n\n// Cap on simultaneous in-flight proxied requests across the whole process.\n// Keeps a single deployment from being weaponized as a high-throughput relay.\nconst MAX_CONCURRENT = Number(process.env.VIRGIL_PROXY_MAX_CONCURRENT || 16);\nlet inflight = 0;\n\n// Cap on request body size. The PDF base64 ride-along can be ~30 MB, so\n// 64 MB leaves headroom without making us a bulk-upload relay.\nconst MAX_BODY_BYTES = Number(\n process.env.VIRGIL_PROXY_MAX_BODY_BYTES || 64 * 1024 * 1024,\n);\n\nasync function handle(\n req: Request,\n ctx: { params: Promise<{ path: string[] }> },\n) {\n const upstream = req.headers.get(\"x-virgil-upstream\");\n if (!upstream) {\n return new Response(\"missing x-virgil-upstream header\", { status: 400 });\n }\n\n let parsedUpstream: URL;\n try {\n parsedUpstream = new URL(upstream);\n } catch {\n return new Response(\"invalid x-virgil-upstream\", { status: 400 });\n }\n\n if (parsedUpstream.protocol !== \"https:\") {\n return new Response(\"upstream must be https\", { status: 400 });\n }\n\n const host = parsedUpstream.hostname.toLowerCase();\n if (!ALLOWED_HOSTS.has(host)) {\n return new Response(\"upstream host not allowed\", { status: 403 });\n }\n if (isPrivateOrLoopback(host)) {\n // Defensive: a permitted hostname shouldn't resolve to a literal private\n // address, but a misconfigured allowlist entry could. Reject literals.\n return new Response(\"upstream host not allowed\", { status: 403 });\n }\n\n const declaredLen = req.headers.get(\"content-length\");\n if (declaredLen && Number(declaredLen) > MAX_BODY_BYTES) {\n return new Response(\"request body too large\", { status: 413 });\n }\n\n if (inflight >= MAX_CONCURRENT) {\n return new Response(\"proxy busy\", {\n status: 503,\n headers: { \"retry-after\": \"1\" },\n });\n }\n inflight++;\n\n try {\n const { path } = await ctx.params;\n const search = new URL(req.url).search;\n const target = `${upstream.replace(/\\/+$/, \"\")}/${path.join(\"/\")}${search}`;\n\n const headers = new Headers();\n req.headers.forEach((value, key) => {\n if (!HOP_BY_HOP.has(key.toLowerCase())) headers.set(key, value);\n });\n\n const body =\n req.method === \"GET\" || req.method === \"HEAD\"\n ? undefined\n : capBody(req.body, MAX_BODY_BYTES);\n\n const upstreamRes = await fetch(target, {\n method: req.method,\n headers,\n body,\n // @ts-expect-error — Node fetch requires this to stream a request body\n duplex: \"half\",\n redirect: \"manual\",\n });\n\n const resHeaders = new Headers(upstreamRes.headers);\n resHeaders.delete(\"content-encoding\");\n resHeaders.delete(\"content-length\");\n resHeaders.delete(\"transfer-encoding\");\n\n return new Response(upstreamRes.body, {\n status: upstreamRes.status,\n statusText: upstreamRes.statusText,\n headers: resHeaders,\n });\n } catch (err) {\n if (err instanceof Error && err.message === \"request body too large\") {\n return new Response(\"request body too large\", { status: 413 });\n }\n throw err;\n } finally {\n inflight--;\n }\n}\n\nfunction isPrivateOrLoopback(host: string): boolean {\n if (host === \"localhost\" || host.endsWith(\".localhost\")) return true;\n\n const family = net.isIP(host);\n if (family === 0) return false; // not an IP literal\n\n if (family === 4) {\n const parts = host.split(\".\").map(Number);\n if (parts.length !== 4) return true;\n const [a, b] = parts;\n if (a === 10) return true;\n if (a === 127) return true;\n if (a === 0) return true;\n if (a === 169 && b === 254) return true;\n if (a === 172 && b >= 16 && b <= 31) return true;\n if (a === 192 && b === 168) return true;\n return false;\n }\n\n // IPv6\n const lower = host.toLowerCase();\n if (lower === \"::1\" || lower === \"::\") return true;\n // fc00::/7 (unique local) and fe80::/10 (link-local)\n if (/^f[cd][0-9a-f]{2}:/.test(lower)) return true;\n if (/^fe[89ab][0-9a-f]:/.test(lower)) return true;\n // IPv4-mapped (::ffff:127.0.0.1 etc.) — be conservative\n if (lower.startsWith(\"::ffff:\")) {\n return isPrivateOrLoopback(lower.slice(7));\n }\n return false;\n}\n\nfunction capBody(\n source: ReadableStream<Uint8Array> | null,\n max: number,\n): ReadableStream<Uint8Array> | null {\n if (!source) return null;\n let seen = 0;\n return new ReadableStream<Uint8Array>({\n async start(controller) {\n const reader = source.getReader();\n try {\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n seen += value.byteLength;\n if (seen > max) {\n controller.error(new Error(\"request body too large\"));\n await reader.cancel().catch(() => {});\n return;\n }\n controller.enqueue(value);\n }\n controller.close();\n } catch (err) {\n controller.error(err);\n }\n },\n });\n}\n\nexport {\n handle as GET,\n handle as POST,\n handle as PUT,\n handle as PATCH,\n handle as DELETE,\n};\n"}, "067a04fa322ed46950f0e00b35f85795daac2fc7": {"t": 1785759107.4907289, "n": 6502, "h": "43a702bc7d11469b", "c": "// Thin pass-through proxy for Anthropic-compatible gateways that don't\n// expose browser-friendly CORS (OpenRouter, v2.pincc.ai, etc.).\n//\n// The client sets the SDK baseURL to `/api/proxy` and adds an\n// `x-virgil-upstream` header carrying the real upstream base (e.g.\n// `https://openrouter.ai/api`). This route forwards method/body/headers\n// to `${upstream}/${path}` and streams the response back unchanged.\n//\n// The user's API key still lives only in their browser; this server\n// just relays the request — nothing is logged or persisted.\n//\n// Security: the upstream host must match the allowlist below (defense\n// against open-relay / SSRF abuse from anyone who finds the deployment).\n// Operators can extend the allowlist with the VIRGIL_PROXY_ALLOWED_HOSTS\n// env var (comma-separated hostnames).\n\nimport net from \"node:net\";\n\nexport const runtime = \"nodejs\";\nexport const dynamic = \"force-dynamic\";\n\nconst HOP_BY_HOP = new Set([\n \"host\",\n \"connection\",\n \"content-length\",\n \"accept-encoding\",\n \"x-virgil-upstream\",\n]);\n\n// Built-in allowlist of well-known Anthropic-compatible upstreams. Extend\n// via VIRGIL_PROXY_ALLOWED_HOSTS (comma-separated) at deploy time.\nconst BUILTIN_ALLOWED_HOSTS = [\n \"api.anthropic.com\",\n \"api.deepseek.com\",\n \"openrouter.ai\",\n \"v2.pincc.ai\",\n];\n\nconst ALLOWED_HOSTS: Set<string> = (() => {\n const extra = (process.env.VIRGIL_PROXY_ALLOWED_HOSTS || \"\")\n .split(\",\")\n .map((h) => h.trim().toLowerCase())\n .filter(Boolean);\n return new Set([...BUILTIN_ALLOWED_HOSTS, ...extra]);\n})();\n\n// Cap on simultaneous in-flight proxied requests across the whole process.\n// Keeps a single deployment from being weaponized as a high-throughput relay.\nconst MAX_CONCURRENT = Number(process.env.VIRGIL_PROXY_MAX_CONCURRENT || 16);\nlet inflight = 0;\n\n// Cap on request body size. The PDF base64 ride-along can be ~30 MB, so\n// 64 MB leaves headroom without making us a bulk-upload relay.\nconst MAX_BODY_BYTES = Number(\n process.env.VIRGIL_PROXY_MAX_BODY_BYTES || 64 * 1024 * 1024,\n);\n\nasync function handle(\n req: Request,\n ctx: { params: Promise<{ path: string[] }> },\n) {\n const upstream = req.headers.get(\"x-virgil-upstream\");\n if (!upstream) {\n return new Response(\"missing x-virgil-upstream header\", { status: 400 });\n }\n\n let parsedUpstream: URL;\n try {\n parsedUpstream = new URL(upstream);\n } catch {\n return new Response(\"invalid x-virgil-upstream\", { status: 400 });\n }\n\n if (parsedUpstream.protocol !== \"https:\") {\n return new Response(\"upstream must be https\", { status: 400 });\n }\n\n const host = parsedUpstream.hostname.toLowerCase();\n if (!ALLOWED_HOSTS.has(host)) {\n return new Response(\"upstream host not allowed\", { status: 403 });\n }\n if (isPrivateOrLoopback(host)) {\n // Defensive: a permitted hostname shouldn't resolve to a literal private\n // address, but a misconfigured allowlist entry could. Reject literals.\n return new Response(\"upstream host not allowed\", { status: 403 });\n }\n\n const declaredLen = req.headers.get(\"content-length\");\n if (declaredLen && Number(declaredLen) > MAX_BODY_BYTES) {\n return new Response(\"request body too large\", { status: 413 });\n }\n\n if (inflight >= MAX_CONCURRENT) {\n return new Response(\"proxy busy\", {\n status: 503,\n headers: { \"retry-after\": \"1\" },\n });\n }\n inflight++;\n\n try {\n const { path } = await ctx.params;\n const search = new URL(req.url).search;\n const target = `${upstream.replace(/\\/+$/, \"\")}/${path.join(\"/\")}${search}`;\n\n const headers = new Headers();\n req.headers.forEach((value, key) => {\n if (!HOP_BY_HOP.has(key.toLowerCase())) headers.set(key, value);\n });\n\n const body =\n req.method === \"GET\" || req.method === \"HEAD\"\n ? undefined\n : capBody(req.body, MAX_BODY_BYTES);\n\n const upstreamRes = await fetch(target, {\n method: req.method,\n headers,\n body,\n // @ts-expect-error — Node fetch requires this to stream a request body\n duplex: \"half\",\n redirect: \"manual\",\n });\n\n const resHeaders = new Headers(upstreamRes.headers);\n resHeaders.delete(\"content-encoding\");\n resHeaders.delete(\"content-length\");\n resHeaders.delete(\"transfer-encoding\");\n\n return new Response(upstreamRes.body, {\n status: upstreamRes.status,\n statusText: upstreamRes.statusText,\n headers: resHeaders,\n });\n } catch (err) {\n if (err instanceof Error && err.message === \"request body too large\") {\n return new Response(\"request body too large\", { status: 413 });\n }\n throw err;\n } finally {\n inflight--;\n }\n}\n\nfunction isPrivateOrLoopback(host: string): boolean {\n if (host === \"localhost\" || host.endsWith(\".localhost\")) return true;\n\n const family = net.isIP(host);\n if (family === 0) return false; // not an IP literal\n\n if (family === 4) {\n const parts = host.split(\".\").map(Number);\n if (parts.length !== 4) return true;\n const [a, b] = parts;\n if (a === 10) return true;\n if (a === 127) return true;\n if (a === 0) return true;\n if (a === 169 && b === 254) return true;\n if (a === 172 && b >= 16 && b <= 31) return true;\n if (a === 192 && b === 168) return true;\n return false;\n }\n\n // IPv6\n const lower = host.toLowerCase();\n if (lower === \"::1\" || lower === \"::\") return true;\n // fc00::/7 (unique local) and fe80::/10 (link-local)\n if (/^f[cd][0-9a-f]{2}:/.test(lower)) return true;\n if (/^fe[89ab][0-9a-f]:/.test(lower)) return true;\n // IPv4-mapped (::ffff:127.0.0.1 etc.) — be conservative\n if (lower.startsWith(\"::ffff:\")) {\n return isPrivateOrLoopback(lower.slice(7));\n }\n return false;\n}\n\nfunction capBody(\n source: ReadableStream<Uint8Array> | null,\n max: number,\n): ReadableStream<Uint8Array> | null {\n if (!source) return null;\n let seen = 0;\n return new ReadableStream<Uint8Array>({\n async start(controller) {\n const reader = source.getReader();\n try {\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n seen += value.byteLength;\n if (seen > max) {\n controller.error(new Error(\"request body too large\"));\n await reader.cancel().catch(() => {});\n return;\n }\n controller.enqueue(value);\n }\n controller.close();\n } catch (err) {\n controller.error(err);\n }\n },\n });\n}\n\nexport {\n handle as GET,\n handle as POST,\n handle as PUT,\n handle as PATCH,\n handle as DELETE,\n};\n"}, "14f51beb0b23ca9c4d2e3c88051a13ea6a2ce631": {"t": 1785759108.8770058, "n": 2026, "h": "9095c2ab361076b7", "c": "// Thin pass-through proxy for Anthropic-compatible gateways that don't\n// expose browser-friendly CORS (OpenRouter, v2.pincc.ai, etc.).\n//\n// The client sets the SDK baseURL to `/api/proxy` and adds an\n// `x-virgil-upstream` header carrying the real upstream base (e.g.\n// `https://openrouter.ai/api`). This route forwards method/body/headers\n// to `${upstream}/${path}` and streams the response back unchanged.\n//\n// The user's API key still lives only in their browser; this server\n// just relays the request — nothing is logged or persisted.\n\nexport const runtime = \"nodejs\";\nexport const dynamic = \"force-dynamic\";\n\nconst HOP_BY_HOP = new Set([\n \"host\",\n \"connection\",\n \"content-length\",\n \"accept-encoding\",\n \"x-virgil-upstream\",\n]);\n\nasync function handle(\n req: Request,\n ctx: { params: Promise<{ path: string[] }> },\n) {\n const upstream = req.headers.get(\"x-virgil-upstream\");\n if (!upstream) {\n return new Response(\"missing x-virgil-upstream header\", { status: 400 });\n }\n\n const { path } = await ctx.params;\n const search = new URL(req.url).search;\n const target = `${upstream.replace(/\\/+$/, \"\")}/${path.join(\"/\")}${search}`;\n\n const headers = new Headers();\n req.headers.forEach((value, key) => {\n if (!HOP_BY_HOP.has(key.toLowerCase())) headers.set(key, value);\n });\n\n const upstreamRes = await fetch(target, {\n method: req.method,\n headers,\n body:\n req.method === \"GET\" || req.method === \"HEAD\" ? undefined : req.body,\n // @ts-expect-error — Node fetch requires this to stream a request body\n duplex: \"half\",\n redirect: \"manual\",\n });\n\n const resHeaders = new Headers(upstreamRes.headers);\n resHeaders.delete(\"content-encoding\");\n resHeaders.delete(\"content-length\");\n resHeaders.delete(\"transfer-encoding\");\n\n return new Response(upstreamRes.body, {\n status: upstreamRes.status,\n statusText: upstreamRes.statusText,\n headers: resHeaders,\n });\n}\n\nexport {\n handle as GET,\n handle as POST,\n handle as PUT,\n handle as PATCH,\n handle as DELETE,\n};\n"}}}}