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
+69
View File
@@ -0,0 +1,69 @@
import { useEffect, useRef, useState } from 'react';
import { Loader2 } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { ConnectionPanel } from './ConnectionPanel';
import { useOpenSeoChanged } from './channel';
/**
* OpenSEO's own interface, served through Officer.
*
* ── An iframe on the PROXY, never on the instance ──
*
* `src` points at `/api/openseo/`, this Officer's own origin, so the browser's request carries the
* session cookie and passes the permission gate like everything else. Pointing it at the instance
* instead would work on a laptop and be a hole: OpenSEO self-hosts with `AUTH_MODE=local_noauth`, so a
* URL that reaches it is a URL that needs no credential — and an iframe src is a URL the person can
* read out of the page source and open directly.
*
* So the proxy is not a convenience here. It is the authentication.
*
* ── Setup replaces the app rather than sitting beside it ──
*
* With nothing configured there is no app to show, and a panel offering both an empty frame and a form
* makes somebody choose between them. Configured, the form is gone — it lives in this plugin's own
* settings once there is something to look at.
*/
export const AppPanel = () => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [configured, setConfigured] = useState<boolean | null>(null);
const [signal] = useOpenSeoChanged();
useEffect(() => {
void (async () => {
try {
const status = await clientRef.current.get<{ configured: boolean }>('/openseo/status');
setConfigured(status.configured);
} catch {
// Cannot tell. Treated as configured rather than not: telling somebody they have set nothing up
// is a far more alarming thing to be wrong about, and invites them to re-enter a working address.
setConfigured(true);
}
})();
}, [signal]);
if (configured === null) {
return (
<div className="flex size-full items-center justify-center">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
);
}
if (!configured) return <ConnectionPanel />;
return (
<iframe
// Trailing slash: the app builds its asset paths relative to the document, and without it every
// one of them resolves a directory too high.
src="/api/openseo/"
title="OpenSEO"
className="size-full border-0"
// It is same-origin by construction — that is the point of the proxy — so no sandbox attribute
// here, which would break its own storage and its own service worker for no gain.
allow="clipboard-read; clipboard-write"
/>
);
};
+43
View File
@@ -0,0 +1,43 @@
import { useCallback } from 'react';
import { ChatPanelWrapper, ChatHeader } from 'officerdev';
import { useOpenSeoChanged } from './channel';
/**
* A chat beside the app.
*
* ── Why this plugin has one at all ──
*
* `fofos-admin`'s layout note draws the line: a chat panel is worth a third of the screen on the day the
* plugin ships a `skills/` directory, and not before. This ships one — and more than that, OpenSEO's own
* MCP server is the point of the integration. The conversation here is where that gets used.
*
* ── The panel is ours; the chat inside it is the platform's ──
*
* `PluginScreen` pins the layout to this plugin's own panel keys, so naming `officerdev/chat` in
* layout.ts renders the fallback instead — proxy-manager learned that by getting two of the same panel.
* `ChatPanelWrapper` brings the session persistence, the agent bar and resume-the-newest with it.
*/
const CHAT_CONTEXT = { context: 'plugin', contextId: 'openseo' } as const;
export const ChatPanel = ({ panelId }: { panelId: string }) => {
const [, changed] = useOpenSeoChanged();
/*
A turn that ran tools may have changed something the app is showing — a project created, a crawl
started, a keyword list added. Gated on `hadToolCalls` because a turn that only talked changed
nothing, and re-reading after every sentence puts a request behind every reply.
*/
const onTurnComplete = useCallback((hadToolCalls: boolean) => {
if (hadToolCalls) changed();
}, [changed]);
return (
<div className="flex h-full min-h-0 flex-col">
<ChatPanelWrapper panelId={panelId} chatContext={CHAT_CONTEXT} onTurnComplete={onTurnComplete} />
</div>
);
};
export const ChatPanelHeader = ({ panelId }: { panelId: string }) => (
<ChatHeader panelId={panelId} chatContext={CHAT_CONTEXT} commanderLabel="OpenSEO" />
);
+194
View File
@@ -0,0 +1,194 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { CircleAlert, CircleCheck, CircleDashed, Loader2, PlugZap, Save, Trash2 } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { readableError } from './errors';
import { useOpenSeoChanged } from './channel';
/**
* Where the OpenSEO instance is, and the DataForSEO key.
*
* ── The key field is never filled in ──
*
* The server reports whether one is stored and never what it is, so this shows a placeholder rather
* than a value. Blank therefore has to mean "leave it alone" — the API treats absent as keep and an
* explicit empty string as clear, because otherwise saving a changed ADDRESS from a form whose key box
* is empty would silently erase a working credential, and it would surface days later as an instance
* that had stopped answering.
*
* ── Test does not save ──
*
* So a wrong address never replaces a working one. It tests what is in the box, because testing what is
* stored while somebody looks at something different is worse than not testing.
*/
type Connection = {
url: string | null;
hasKey: boolean;
lastProbeAt: string | null;
lastProbeOk: boolean | null;
};
type Probe = { ok: boolean; detail: string };
export const ConnectionPanel = () => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [connection, setConnection] = useState<Connection | null>(null);
const [forbidden, setForbidden] = useState(false);
const [url, setUrl] = useState('');
const [key, setKey] = useState('');
const [busy, setBusy] = useState<'save' | 'test' | 'forget' | null>(null);
const [result, setResult] = useState<Probe | null>(null);
const [error, setError] = useState<string | null>(null);
const [, changed] = useOpenSeoChanged();
const load = useCallback(async () => {
try {
const current = await clientRef.current.get<Connection>('/openseo/_config');
setConnection(current);
setUrl((typed) => typed || (current.url ?? ''));
} catch (failure) {
// A member looking at an owner-only setting is not a fault and needs a different sentence.
if ((failure as { status?: number })?.status === 403) setForbidden(true);
else setError(readableError(failure));
}
}, []);
useEffect(() => {
void load();
}, [load]);
const save = async () => {
setBusy('save');
setError(null);
setResult(null);
try {
const saved = await clientRef.current.put<Connection & { probe: Probe }>('/openseo/_config', {
url,
// Absent, not empty — see the note above. Only sent when somebody typed one.
...(key ? { key } : {}),
});
setConnection(saved);
setResult(saved.probe);
setKey('');
changed();
} catch (failure) {
setError(readableError(failure));
} finally {
setBusy(null);
}
};
const test = async () => {
setBusy('test');
setError(null);
setResult(null);
try {
setResult(await clientRef.current.post<Probe>('/openseo/_config/test', { url }));
} catch (failure) {
setError(readableError(failure));
} finally {
setBusy(null);
}
};
const forget = async () => {
setBusy('forget');
setError(null);
try {
setConnection(await clientRef.current.delete<Connection>('/openseo/_config'));
setUrl('');
setResult(null);
changed();
} catch (failure) {
setError(readableError(failure));
} finally {
setBusy(null);
}
};
if (forbidden) {
return (
<div className="p-5 text-xs text-muted-foreground">
Only the owner can change where OpenSEO is.
</div>
);
}
const Icon = result ? (result.ok ? CircleCheck : CircleAlert) : connection?.lastProbeOk ? CircleCheck : CircleDashed;
return (
<div className="flex size-full flex-col gap-4 overflow-auto p-5">
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
<div className="space-y-1">
<h3 className="text-sm font-medium">OpenSEO instance</h3>
<p className="text-xs text-muted-foreground">
Where it is running. It is reached only through Officer — never published on its own port.
</p>
</div>
<Input
value={url}
onChange={(event) => setUrl(event.target.value)}
placeholder="http://127.0.0.1:3001"
className="font-mono text-xs"
autoComplete="off"
spellCheck={false}
/>
<div className="space-y-1">
<p className="text-xs font-medium">
DataForSEO key <span className="font-normal text-muted-foreground/70">base64 of login:password</span>
</p>
<Input
value={key}
onChange={(event) => setKey(event.target.value)}
type="password"
placeholder={connection?.hasKey ? 'stored — leave blank to keep it' : 'not set'}
className="font-mono text-xs"
autoComplete="off"
/>
{/* Said plainly, once, where somebody is about to enter it. */}
<p className="text-xs text-muted-foreground/70">
Billed pay-as-you-go by DataForSEO directly. Only needed if this Officer provisions the
instance — one started elsewhere already has its own.
</p>
</div>
{(result || connection?.lastProbeAt) && (
<p className="flex items-start gap-1.5 text-xs">
<Icon
className={`mt-px size-3.5 shrink-0 ${result?.ok || (!result && connection?.lastProbeOk) ? 'text-success' : result ? 'text-destructive' : 'text-muted-foreground'}`}
/>
<span className={result && !result.ok ? 'text-destructive' : 'text-muted-foreground'}>
{result?.detail ?? (connection?.lastProbeOk ? 'Answered when last checked.' : 'Did not answer when last checked.')}
</span>
</p>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-3">
<Button size="sm" onClick={() => void save()} disabled={busy !== null || !url.trim()}>
{busy === 'save' ? <Loader2 className="size-3.5 animate-spin" /> : <Save className="size-3.5" />}
Save
</Button>
<Button variant="outline" size="sm" onClick={() => void test()} disabled={busy !== null || !url.trim()}>
{busy === 'test' ? <Loader2 className="size-3.5 animate-spin" /> : <PlugZap className="size-3.5" />}
Test
</Button>
{connection?.url && (
<Button variant="ghost" size="sm" onClick={() => void forget()} disabled={busy !== null}>
<Trash2 className="size-3.5" />
Forget
</Button>
)}
</div>
</section>
</div>
);
};
+26
View File
@@ -0,0 +1,26 @@
import { useCallback, useRef } from 'react';
import { defineChannel } from 'hooks/usePanelChannel';
/**
* "The connection changed — anything reading it should read it again."
*
* A channel and not the URL, because `docs/navigation-audit.md` draws that line at ADDRESSABLE: nobody
* links to the moment after a connection was saved, and there is nothing selected. This is the second
* kind — a signal.
*
* The nonce is a module counter rather than `setSignal(n => n + 1)`, copied deliberately from
* proxy-manager's own channel, which records why: the functional setter applies against the value
* captured at render, so two bumps in one render window both compute `snapshot + 1` and the second is
* lost. `Date.now()` has the same flaw twice in a millisecond. A module counter never reads React state.
*/
const useOpenSeoChangedChannel = defineChannel<number>('openseo:changed', 0);
let nonce = 0;
export const useOpenSeoChanged = () => {
const [signal, setSignal] = useOpenSeoChangedChannel();
const setRef = useRef(setSignal);
setRef.current = setSignal;
const bump = useCallback(() => setRef.current(++nonce), []);
return [signal, bump] as const;
};
+5
View File
@@ -0,0 +1,5 @@
/** The server's own sentence when it sent one, rather than "Request failed". */
export const readableError = (failure: unknown): string => {
const body = (failure as { body?: { error?: string; detail?: string } })?.body;
return body?.error ?? body?.detail ?? (failure as Error)?.message ?? 'Something went wrong.';
};
+23
View File
@@ -0,0 +1,23 @@
import type { LayoutNode } from 'officerdev';
/**
* The app, with a chat down the right.
*
* The app takes most of it because it is a real application — tables, charts and crawls — and squeezing
* that into half a screen makes it unusable rather than merely small. The chat is a column because
* that is what a conversation wants, and because the agent's answers here are usually a sentence rather
* than a table.
*
* Settings is in the registry and NOT in this layout on purpose: it is a form somebody uses once. It is
* reachable by swapping a panel, which is what the panel picker is for, and giving it a permanent third
* of the screen would spend the most valuable space on the least used thing.
*/
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'openseo-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'openseo-app', appType: 'openseo-app' }, size: 68 },
{ node: { type: 'panel', id: 'openseo-chat', appType: 'openseo-chat' }, size: 32 },
],
};
+30
View File
@@ -0,0 +1,30 @@
import type { AppRegistryMeta } from 'officerdev';
import { MessageSquare, Settings, TrendingUp } from 'lucide-react';
import { AppPanel } from './AppPanel';
import { ChatPanel, ChatPanelHeader } from './ChatPanel';
import { ConnectionPanel } from './ConnectionPanel';
/**
* Three panels: the app, a chat, and the settings.
*
* The app is OpenSEO's own interface through the proxy — there is nothing for this plugin to curate in
* front of it, and building a worse OpenSEO is the failure mode proxy-manager's router warns about.
*
* The chat is worth its share of the screen here because OpenSEO ships an MCP server: the agent has
* real tools on the other side of it, which is the whole reason this integration is thin.
*
* `availableOnPanel: false` — these belong to this plugin's screen, not the generic panel picker. A
* "Settings" panel offered on somebody's dashboard would be a connection form with no context.
*/
export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'openseo-app', name: 'OpenSEO', icon: TrendingUp, component: AppPanel, availableOnPanel: false },
{
key: 'openseo-chat',
name: 'Chat',
icon: MessageSquare,
component: ChatPanel,
header: ChatPanelHeader,
availableOnPanel: false,
},
{ key: 'openseo-settings', name: 'Settings', icon: Settings, component: ConnectionPanel, availableOnPanel: false },
];