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));
}