Files
openseo/web/AppPanel.tsx
T
pastilhas 10378d08cc 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.
2026-08-29 18:42:43 +01:00

70 lines
2.7 KiB
TypeScript

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