OpenSEO ships its own MCP server and its own web UI, so this plugin holds the address, holds the credential, and is the door. There are no curated routes in front of it and there should not be: proxy-manager's router already records where that leads — a curated surface cannot keep up, and the plugin becomes a worse version of the thing it wraps. The door IS the security model, not a convenience. Upstream self-hosts with AUTH_MODE=local_noauth, so every request reaching the instance is trusted by design and their docs say to put it behind auth. Officer's gate is that auth. Two things follow and neither is 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 iframe therefore points at /api/openseo/ — this Officer's origin — and never at the instance, since an iframe src is a URL anybody can read out of the page and open directly. The probe asks /mcp for tools/list rather than fetching the home page. A web root answers 200 for a great many things that are not this; a JSON-RPC reply carrying tool names is the instance identifying itself. Saving with an empty key field KEEPS the stored one — absent means keep, empty string means clear. Without that distinction, changing the address from a form that cannot display the credential would silently erase it, and it would surface days later as an instance that had stopped answering. One table, deliberately: every project, keyword and crawl lives in the instance, so uninstalling this cannot destroy anybody's research. The skill says so too, because the temptation to cache a project list is exactly what would break it. The skill leads with cost. This is free software over DataForSEO, metered and billed to the owner per lookup, so it tells the agent not to sweep and to say what a thorough answer would spend before spending it. Tags are `Development` and `Homelab`. `Marketing` and `Research` would both be more accurate and neither exists in the union — extending it is a platform change rather than a plugin's to make.
142 lines
5.9 KiB
TypeScript
142 lines
5.9 KiB
TypeScript
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/<the
|
|
* app's own paths>` 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,
|
|
);
|
|
}
|
|
});
|