From 165f315d4614bf0b5739c675cf84ff2a35db7d5f Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sun, 24 May 2026 14:30:25 -0500 Subject: [PATCH] fix: route the openapi playground's ?url= proxy calls to the daemon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fumadocs-openapi Try It playground calls the proxy as /api/coven-proxy?url=&cookie= with the target URL as a query parameter — a single endpoint, not a path prefix. The existing catch-all route at /api/coven-proxy/[...path]/route.ts requires at least one path segment, so the playground's request hit Next's /_not-found and the Try It panel rendered a 404 HTML dump instead of the daemon's JSON envelope. Adds a sibling route at /api/coven-proxy/route.ts that reads ?url=, strips the /api/coven-proxy prefix from the target's pathname, and forwards the remaining daemon path to the Unix socket. Extracts the dialing logic into lib/coven-proxy-dial.ts so both proxy shapes share one implementation: - /api/coven-proxy/[...path]/route.ts → status banner, curl tests - /api/coven-proxy/route.ts → fumadocs-openapi playground Both end up calling dialDaemon(targetPath, ...) on $COVEN_HOME/coven.sock with the same envelope-error contract (daemon_unreachable, invalid_request, internal_error). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/coven-proxy/[...path]/route.ts | 162 ++---------------------- app/api/coven-proxy/route.ts | 68 ++++++++++ lib/coven-proxy-dial.ts | 167 +++++++++++++++++++++++++ 3 files changed, 242 insertions(+), 155 deletions(-) create mode 100644 app/api/coven-proxy/route.ts create mode 100644 lib/coven-proxy-dial.ts diff --git a/app/api/coven-proxy/[...path]/route.ts b/app/api/coven-proxy/[...path]/route.ts index 66db2dd..f28d4bd 100644 --- a/app/api/coven-proxy/[...path]/route.ts +++ b/app/api/coven-proxy/[...path]/route.ts @@ -1,173 +1,25 @@ -// Bridges browser fetch() calls from the docs site to the local Coven daemon's -// Unix socket. Used by the OpenAPI Try It panels and the daemon status banner. -// -// Trust model: the daemon's only access control is file-system permissions on -// the socket. This proxy must NOT forward auth headers, cookies, or anything -// that could let a cross-origin payload smuggle credentials into the daemon — -// it forwards content-type and accept only. +// Path-prefix bridge for direct daemon calls (status banner, curl tests). +// Sibling route at app/api/coven-proxy/route.ts handles the fumadocs-openapi +// playground's `?url=` style. Dialing logic lives in lib/coven-proxy-dial.ts. -import http from 'node:http'; -import os from 'node:os'; -import path from 'node:path'; +import { dialDaemon } from '@/lib/coven-proxy-dial'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -const FORWARDED_REQUEST_HEADERS = new Set(['content-type', 'accept']); -// content-length / transfer-encoding / connection are set by Node's http -// machinery; pass-through would double-set them on the outbound Response. -const SKIP_RESPONSE_HEADERS = - /^(content-length|transfer-encoding|connection|keep-alive)$/i; -const REQUEST_TIMEOUT_MS = 5000; - -function resolveSocketPath(): string { - const home = - process.env.COVEN_HOME?.trim() || path.join(os.homedir(), '.coven'); - if (!path.isAbsolute(home)) { - throw new Error(`COVEN_HOME must be an absolute path: ${home}`); - } - if (home.split(path.sep).includes('..')) { - throw new Error(`COVEN_HOME must not contain "..": ${home}`); - } - return path.join(home, 'coven.sock'); -} - -function envelope( - code: string, - message: string, - details: Record, - status: number, -): Response { - return Response.json({ error: { code, message, details } }, { status }); -} - -function daemonUnreachable( - socketPath: string, - cause: string, - extra: Record = {}, -): Response { - return envelope( - 'daemon_unreachable', - `No daemon listening at ${socketPath}. Run \`coven daemon start\` and verify with \`coven daemon status\`.`, - { socketPath, cause, ...extra }, - 503, - ); -} - -interface DialResult { - status: number; - headers: http.IncomingHttpHeaders; - body: Buffer; -} - -function dial( - opts: http.RequestOptions, - body: Buffer | undefined, -): Promise { - return new Promise((resolve, reject) => { - const req = http.request(opts, (res) => { - const chunks: Buffer[] = []; - res.on('data', (c: Buffer) => chunks.push(c)); - res.on('end', () => - resolve({ - status: res.statusCode ?? 502, - headers: res.headers, - body: Buffer.concat(chunks), - }), - ); - res.on('error', reject); - }); - req.on('error', reject); - req.setTimeout(REQUEST_TIMEOUT_MS, () => { - const err = Object.assign(new Error('request timed out'), { - code: 'ETIMEDOUT', - }); - req.destroy(err); - }); - if (body && body.byteLength > 0) req.write(body); - req.end(); - }); -} - async function proxy( req: Request, ctx: { params: Promise<{ path?: string[] }> }, ): Promise { - // Hosted-detection short-circuit: the docs site running on Vercel cannot - // reach the reader's local socket. Fail fast with a distinct cause so the - // status banner can render "run docs locally" guidance instead of a - // misleading ENOENT. - if (process.env.VERCEL) { - return daemonUnreachable('$COVEN_HOME/coven.sock', 'hosted', { - hosted: true, - }); - } - - let socketPath: string; - try { - socketPath = resolveSocketPath(); - } catch (err) { - return envelope( - 'invalid_request', - err instanceof Error ? err.message : String(err), - { source: 'COVEN_HOME' }, - 400, - ); - } - const { path: segments = [] } = await ctx.params; - const targetPath = '/' + segments.join('/'); const search = new URL(req.url).search; - const fullPath = targetPath + search; + const targetPath = '/' + segments.join('/') + search; const method = req.method.toUpperCase(); const hasBody = method !== 'GET' && method !== 'HEAD'; - const body = hasBody - ? Buffer.from(await req.arrayBuffer()) - : undefined; - - const headers: Record = { host: 'localhost' }; - for (const [key, value] of req.headers) { - if (FORWARDED_REQUEST_HEADERS.has(key.toLowerCase())) headers[key] = value; - } - if (body) headers['content-length'] = String(body.byteLength); - - try { - const { status, headers: respHeaders, body: respBody } = await dial( - { socketPath, method, path: fullPath, headers }, - body, - ); + const body = hasBody ? Buffer.from(await req.arrayBuffer()) : undefined; - const out = new Headers(); - for (const [k, v] of Object.entries(respHeaders)) { - if (SKIP_RESPONSE_HEADERS.test(k)) continue; - if (v === undefined) continue; - if (Array.isArray(v)) for (const item of v) out.append(k, item); - else out.set(k, v); - } - // Buffer is a Uint8Array at runtime — valid BodyInit — but @types/node's - // ArrayBufferLike generic confuses TS's BodyInit union, so cast. - return new Response(respBody as unknown as BodyInit, { - status, - headers: out, - }); - } catch (err: unknown) { - const code = (err as NodeJS.ErrnoException)?.code; - if ( - code === 'ENOENT' || - code === 'ECONNREFUSED' || - code === 'ETIMEDOUT' || - code === 'EACCES' - ) { - return daemonUnreachable(socketPath, code); - } - return envelope( - 'internal_error', - err instanceof Error ? err.message : 'Unexpected proxy error', - { code: code ?? 'unknown' }, - 502, - ); - } + return dialDaemon(targetPath, method, req.headers, body); } export const GET = proxy; diff --git a/app/api/coven-proxy/route.ts b/app/api/coven-proxy/route.ts new file mode 100644 index 0000000..78daddd --- /dev/null +++ b/app/api/coven-proxy/route.ts @@ -0,0 +1,68 @@ +// fumadocs-openapi playground proxy. The playground composes URLs from the +// spec's server URL + operation path and POSTs them through here as +// +// GET /api/coven-proxy?url=&cookie= +// +// We strip the docs-site prefix from the target URL's pathname and forward +// the remaining daemon-side path to the Unix socket via the shared +// dialDaemon() helper. + +import { dialDaemon, envelope } from '@/lib/coven-proxy-dial'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const PROXY_PREFIX = '/api/coven-proxy'; + +async function proxy(req: Request): Promise { + const reqUrl = new URL(req.url); + const target = reqUrl.searchParams.get('url'); + if (!target) { + return envelope( + 'invalid_request', + 'Missing required "url" query parameter.', + { source: 'playground' }, + 400, + ); + } + + let targetUrl: URL; + try { + targetUrl = new URL(target); + } catch { + return envelope( + 'invalid_request', + `Invalid url query parameter: ${target}`, + { source: 'playground' }, + 400, + ); + } + + // The playground composes URLs like: + // spec server: /api/coven-proxy/api/v1 + // operation: /health + // final URL: http://localhost:3000/api/coven-proxy/api/v1/health + // Strip the docs-site prefix to recover the daemon-side path (/api/v1/...). + if (!targetUrl.pathname.startsWith(PROXY_PREFIX)) { + return envelope( + 'invalid_request', + `Target URL pathname must start with ${PROXY_PREFIX}: ${targetUrl.pathname}`, + { source: 'playground' }, + 400, + ); + } + const targetPath = targetUrl.pathname.slice(PROXY_PREFIX.length) + targetUrl.search; + + const method = req.method.toUpperCase(); + const hasBody = method !== 'GET' && method !== 'HEAD'; + const body = hasBody ? Buffer.from(await req.arrayBuffer()) : undefined; + + return dialDaemon(targetPath, method, req.headers, body); +} + +export const GET = proxy; +export const POST = proxy; +export const PUT = proxy; +export const PATCH = proxy; +export const DELETE = proxy; +export const HEAD = proxy; diff --git a/lib/coven-proxy-dial.ts b/lib/coven-proxy-dial.ts new file mode 100644 index 0000000..2cd7030 --- /dev/null +++ b/lib/coven-proxy-dial.ts @@ -0,0 +1,167 @@ +// Shared dial-to-daemon helper used by both proxy route shapes: +// - app/api/coven-proxy/[...path]/route.ts (path-prefix; used by the +// daemon status banner and direct curl tests) +// - app/api/coven-proxy/route.ts (?url= style; used by the +// fumadocs-openapi Try It playground) +// +// Trust model: the daemon's only access control is file-system permissions on +// the socket. This module must NOT forward auth headers, cookies, or anything +// that could let a cross-origin payload smuggle credentials in — it forwards +// only content-type and accept. + +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; + +const FORWARDED_REQUEST_HEADERS = new Set(['content-type', 'accept']); +const SKIP_RESPONSE_HEADERS = + /^(content-length|transfer-encoding|connection|keep-alive)$/i; +const REQUEST_TIMEOUT_MS = 5000; + +export function resolveSocketPath(): string { + const home = + process.env.COVEN_HOME?.trim() || path.join(os.homedir(), '.coven'); + if (!path.isAbsolute(home)) { + throw new Error(`COVEN_HOME must be an absolute path: ${home}`); + } + if (home.split(path.sep).includes('..')) { + throw new Error(`COVEN_HOME must not contain "..": ${home}`); + } + return path.join(home, 'coven.sock'); +} + +export function envelope( + code: string, + message: string, + details: Record, + status: number, +): Response { + return Response.json({ error: { code, message, details } }, { status }); +} + +export function daemonUnreachable( + socketPath: string, + cause: string, + extra: Record = {}, +): Response { + return envelope( + 'daemon_unreachable', + `No daemon listening at ${socketPath}. Run \`coven daemon start\` and verify with \`coven daemon status\`.`, + { socketPath, cause, ...extra }, + 503, + ); +} + +interface DialResult { + status: number; + headers: http.IncomingHttpHeaders; + body: Buffer; +} + +function dial( + opts: http.RequestOptions, + body: Buffer | undefined, +): Promise { + return new Promise((resolve, reject) => { + const req = http.request(opts, (res) => { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => + resolve({ + status: res.statusCode ?? 502, + headers: res.headers, + body: Buffer.concat(chunks), + }), + ); + res.on('error', reject); + }); + req.on('error', reject); + req.setTimeout(REQUEST_TIMEOUT_MS, () => { + const err = Object.assign(new Error('request timed out'), { + code: 'ETIMEDOUT', + }); + req.destroy(err); + }); + if (body && body.byteLength > 0) req.write(body); + req.end(); + }); +} + +/** + * Dial the daemon's Unix socket with the given path/method/body and return + * a `Response` mirroring the daemon's reply. On connect failure or timeout, + * returns a 503 `daemon_unreachable` envelope. + * + * `targetPath` is the daemon-side path (e.g., `/api/v1/health`), NOT the + * docs-site bridge path. + */ +export async function dialDaemon( + targetPath: string, + method: string, + requestHeaders: Headers, + body: Buffer | undefined, +): Promise { + // Hosted-detection short-circuit: a Vercel deployment can't reach the + // reader's local socket. Fail fast with a distinct cause so the status + // banner renders "run docs locally" guidance instead of a misleading ENOENT. + if (process.env.VERCEL) { + return daemonUnreachable('$COVEN_HOME/coven.sock', 'hosted', { + hosted: true, + }); + } + + let socketPath: string; + try { + socketPath = resolveSocketPath(); + } catch (err) { + return envelope( + 'invalid_request', + err instanceof Error ? err.message : String(err), + { source: 'COVEN_HOME' }, + 400, + ); + } + + const headers: Record = { host: 'localhost' }; + for (const [key, value] of requestHeaders) { + if (FORWARDED_REQUEST_HEADERS.has(key.toLowerCase())) headers[key] = value; + } + if (body) headers['content-length'] = String(body.byteLength); + + try { + const { status, headers: respHeaders, body: respBody } = await dial( + { socketPath, method, path: targetPath, headers }, + body, + ); + + const out = new Headers(); + for (const [k, v] of Object.entries(respHeaders)) { + if (SKIP_RESPONSE_HEADERS.test(k)) continue; + if (v === undefined) continue; + if (Array.isArray(v)) for (const item of v) out.append(k, item); + else out.set(k, v); + } + // Buffer is a Uint8Array at runtime — valid BodyInit — but @types/node's + // ArrayBufferLike generic confuses TS's BodyInit union, so cast. + return new Response(respBody as unknown as BodyInit, { + status, + headers: out, + }); + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException)?.code; + if ( + code === 'ENOENT' || + code === 'ECONNREFUSED' || + code === 'ETIMEDOUT' || + code === 'EACCES' + ) { + return daemonUnreachable(socketPath, code); + } + return envelope( + 'internal_error', + err instanceof Error ? err.message : 'Unexpected proxy error', + { code: code ?? 'unknown' }, + 502, + ); + } +}