import { createRouter } from '@@/create-router'; import { deleteConnection, readConnection, readCredentials, normaliseUrl, probe, recordProbe, saveConnection } from './connection'; /** * Mounted at `/api/openseo`. * * ── One surface, not two ── * * proxy-manager has curated routes for its panels AND a pass-through for agents, and gives a good * reason for each. This has only the pass-through, and the difference is the reason: OpenSEO already * ships an MCP server designed for agents, and a web UI designed for people. There is nothing to curate * — no list this plugin can sort better than the app that owns the data, and no shape a panel needs that * the app does not already produce. * * So the plugin holds the address, holds the credential, and is the door. Building a second, worse * OpenSEO in front of the real one is the failure mode proxy-manager's own comment warns about, and * here there is not even the excuse that the upstream API is awkward. * * ── The door is the whole security model ── * * Upstream self-hosts with `AUTH_MODE=local_noauth`: every request that reaches the instance is * unauthenticated, by design, and their docs say to put it behind auth. Officer's permission gate IS * that auth. Which means two things that are not optional: * * - nothing here may publish OpenSEO's own port, and * - `/_config` is owner-only, because the address alone is enough to bypass this plugin entirely. * * The manifest declares the second. The first is a property of never writing the code that would. */ export const router = createRouter(); router.get('/status', async (ctx) => { const connection = await readConnection(); return ctx.json({ configured: Boolean(connection.url), ...connection, }); }); router.get('/_config', async (ctx) => ctx.json(await readConnection())); router.put('/_config', async (ctx) => { const { url, key } = (ctx.get('body') ?? {}) as { url?: string; key?: string }; try { const saved = await saveConnection({ url: url ?? '', key }); // Probed immediately, so the form can say whether it WORKS rather than only that it saved. A stored // address that answers nothing is the thing somebody needs to know now, not on first use. const result = await probe(saved.url!); await recordProbe(result); return ctx.json({ ...(await readConnection()), probe: result }); } catch (error) { return ctx.json({ error: (error as Error).message }, 400); } }); router.delete('/_config', async (ctx) => { await deleteConnection(); return ctx.json(await readConnection()); }); /** * Test an address WITHOUT storing it. * * So a wrong one never replaces a working one — the same reason proxy-manager has this route. It takes * the value from the form rather than from storage, because testing what is saved while somebody looks * at something different is worse than not testing. */ router.post('/_config/test', async (ctx) => { const { url } = (ctx.get('body') ?? {}) as { url?: string }; try { return ctx.json(await probe(normaliseUrl(url ?? ''))); } catch (error) { return ctx.json({ ok: false, version: null, detail: (error as Error).message }, 400); } }); /** * Everything else — the pass-through. * * Method, path, query and body forwarded unchanged; the instance's own status and body come back * unchanged, including its errors. `/api/openseo/mcp` reaches OpenSEO's MCP server; `/api/openseo/` reaches its API and its UI. * * ── Streaming is preserved, deliberately ── * * MCP replies as `text/event-stream` when the client asks for it, and long tool calls arrive in frames. * Buffering the body here would turn a progressive answer into a silent wait and then everything at * once, which for a crawl or a rank check is the difference between watching it work and wondering * whether it hung. So the response body is handed back as it arrives. */ router.all('/*', async (ctx) => { const credentials = await readCredentials(); if (!credentials) return ctx.json({ error: 'No OpenSEO instance is configured.' }, 409); const incoming = new URL(ctx.req.url); // Everything after the mount, so `/mcp` reaches the instance as `/mcp`. const path = incoming.pathname.replace(/^\/api\/openseo/, '') || '/'; const target = `${credentials.url}${path}${incoming.search}`; /* The hop-by-hop and identity headers are dropped rather than forwarded. `host` would name Officer to an app that builds absolute URLs from it; `content-length` stops being true the moment anything re-encodes; and Officer's own `authorization` is about Officer, not about the instance, which under local_noauth wants no credential at all and could only be confused by one. */ const headers = new Headers(); for (const [name, value] of ctx.req.raw.headers) { if (['host', 'content-length', 'connection', 'authorization', 'cookie'].includes(name.toLowerCase())) continue; headers.set(name, value); } try { const response = await fetch(target, { method: ctx.req.method, headers, body: ['GET', 'HEAD'].includes(ctx.req.method) ? undefined : ctx.req.raw.body, // Bun requires this to send a stream as a body rather than buffering it first. // @ts-expect-error — `duplex` is standard fetch and not yet in the DOM types Bun ships against. duplex: 'half', redirect: 'manual', signal: AbortSignal.timeout(10 * 60 * 1000), }); // Its body, its status, its content type — unbuffered. See the note above. const out = new Headers(response.headers); out.delete('content-encoding'); out.delete('content-length'); return new Response(response.body, { status: response.status, headers: out }); } catch (error) { const message = (error as Error).message; return ctx.json( { error: message.includes('timed out') ? 'The OpenSEO instance did not answer within ten minutes.' : `Could not reach the OpenSEO instance: ${message}`, }, 502, ); } });