the plugin, start to finish: connection, pass-through, panels, skill

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.
This commit is contained in:
2026-08-29 18:42:43 +01:00
parent 14f3f3f1e5
commit 10378d08cc
13 changed files with 875 additions and 11 deletions
+150
View File
@@ -0,0 +1,150 @@
import { db } from 'officerdb/db';
import { eq } from 'drizzle-orm';
import { encryptSecret, decryptSecret } from 'officerdb/crypto';
import { openseoConnection } from '../db/schema';
/**
* Reading and writing the one connection row, and asking the instance whether it is there.
*
* ── The secret goes out of here exactly once, and never across the API ──
*
* `readCredentials` is for the server making a request. Everything that answers a browser uses
* `readConnection`, which reports whether a key is SET and never what it is. Two functions rather than
* one with a flag, because a flag is something a caller gets wrong once and nobody notices.
*/
const PURPOSE = 'openseo';
export type ConnectionView = {
url: string | null;
/** Whether a DataForSEO key is stored. Never the key. */
hasKey: boolean;
probedVersion: string | null;
lastProbeAt: string | null;
/** null = never probed, which is not the same as probed and failed. */
lastProbeOk: boolean | null;
};
const readRow = async () => (await db.select().from(openseoConnection).limit(1))[0] ?? null;
/** Normalised without a trailing slash, so every path this plugin builds concatenates cleanly. */
export const normaliseUrl = (raw: string): string => {
const trimmed = raw.trim().replace(/\/+$/, '');
if (!trimmed) throw new Error('An address is required.');
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
throw new Error(`"${trimmed}" is not a URL. It should look like http://127.0.0.1:3001`);
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('The address must be http or https.');
}
return trimmed;
};
export async function readConnection(): Promise<ConnectionView> {
const row = await readRow();
return {
url: row?.url ?? null,
hasKey: Boolean(row?.dataForSeoKey),
probedVersion: row?.probedVersion ?? null,
lastProbeAt: row?.lastProbeAt?.toISOString() ?? null,
lastProbeOk: row?.lastProbeOk === null || row?.lastProbeOk === undefined ? null : row.lastProbeOk === 1,
};
}
/** For the server, when it is about to make a request. Never for a response. */
export async function readCredentials(): Promise<{ url: string; key: string | null } | null> {
const row = await readRow();
if (!row) return null;
return { url: row.url, key: row.dataForSeoKey ? decryptSecret(PURPOSE, row.dataForSeoKey) : null };
}
export async function saveConnection(input: { url: string; key?: string | null }): Promise<ConnectionView> {
const url = normaliseUrl(input.url);
const row = await readRow();
/*
An absent key KEEPS the stored one; an empty string clears it.
The distinction matters because the form cannot show what is stored — it only knows whether
something is. Without it, saving a changed URL from a form whose key field is blank would silently
erase a working credential, and the failure would appear later as an instance that has stopped
answering.
*/
const key = (() => {
if (input.key === undefined || input.key === null) return row?.dataForSeoKey ?? null;
if (input.key === '') return null;
return encryptSecret(PURPOSE, input.key);
})();
if (row) {
await db
.update(openseoConnection)
.set({ url, dataForSeoKey: key, updatedAt: new Date() })
.where(eq(openseoConnection.id, row.id));
} else {
await db.insert(openseoConnection).values({ url, dataForSeoKey: key });
}
return readConnection();
}
export async function deleteConnection(): Promise<void> {
const row = await readRow();
if (row) await db.delete(openseoConnection).where(eq(openseoConnection.id, row.id));
}
export type Probe = { ok: boolean; version: string | null; detail: string };
/**
* Is something answering there, and is it OpenSEO?
*
* Asks `/mcp` with `tools/list` rather than fetching the home page. A web root answers 200 for a great
* many things that are not this — a login page, another app on a reused port, a proxy's error page —
* and "the address responds" is not the question anybody has. A JSON-RPC reply carrying tool names is
* the instance identifying itself.
*/
export async function probe(url: string): Promise<Probe> {
try {
const response = await fetch(`${normaliseUrl(url)}/mcp`, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }),
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) return { ok: false, version: null, detail: `It answered ${response.status}.` };
const body = (await response.text()).slice(0, 200_000);
// Served as JSON or as an SSE frame depending on what the client asked for; both carry the payload.
const payload = body.startsWith('data:') ? body.slice(body.indexOf('{')) : body;
const parsed = JSON.parse(payload) as { result?: { tools?: Array<{ name: string }> } };
const tools = parsed.result?.tools ?? [];
if (!tools.length) return { ok: false, version: null, detail: 'It answered, but listed no MCP tools.' };
return { ok: true, version: null, detail: `${tools.length} MCP tools available.` };
} catch (error) {
const message = (error as Error).message;
return {
ok: false,
version: null,
detail: message.includes('timed out') ? 'It did not answer within ten seconds.' : message,
};
}
}
export async function recordProbe(result: Probe): Promise<void> {
const row = await readRow();
if (!row) return;
await db
.update(openseoConnection)
.set({
lastProbeAt: new Date(),
lastProbeOk: result.ok ? 1 : 0,
probedVersion: result.version ?? row.probedVersion,
updatedAt: new Date(),
})
.where(eq(openseoConnection.id, row.id));
}
+141
View File
@@ -0,0 +1,141 @@
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,
);
}
});