diff --git a/api/connection.ts b/api/connection.ts new file mode 100644 index 0000000..7f269fe --- /dev/null +++ b/api/connection.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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)); +} diff --git a/api/router.ts b/api/router.ts new file mode 100644 index 0000000..2246b19 --- /dev/null +++ b/api/router.ts @@ -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/` 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, + ); + } +}); diff --git a/chat/CLAUDE.md b/chat/CLAUDE.md new file mode 100644 index 0000000..228d4b6 --- /dev/null +++ b/chat/CLAUDE.md @@ -0,0 +1,12 @@ +# OpenSEO + +You are in the OpenSEO panel. The plugin's skill is `skills/openseo` — read it before the first call, +because it carries the one route in and what each call costs. + +**Every request is billed.** OpenSEO is free software over DataForSEO, metered and paid by the owner per +lookup. Answer the question asked; do not sweep the adjacent ones. If doing it properly needs many +lookups, say how many before spending them. + +**The app is on screen beside you.** The person can see their projects, so a summary of what is already +visible is not an answer. What they cannot do in the UI is ask a question that spans it — that is what +you are for. diff --git a/db/schema.ts b/db/schema.ts new file mode 100644 index 0000000..1245ead --- /dev/null +++ b/db/schema.ts @@ -0,0 +1,53 @@ +import { pgTable, serial, text, timestamp, integer } from 'drizzle-orm/pg-core'; + +/** + * The connection to an OpenSEO instance. One row, and the only thing this plugin stores. + * + * ── Why there is nothing else here ── + * + * OpenSEO keeps its own data — projects, keywords, rankings, crawls — in its own SQLite database inside + * its container. None of it belongs here, and the temptation to cache "just the project list" should be + * resisted for a reason worth writing down: the plugin installer drops a plugin's tables when the + * "delete its data" checkbox is ticked. So the strongest thing that can be said about this plugin is a + * consequence of this file being one table long — **no operation on it can destroy anybody's research.** + * Uninstalling removes a URL and a key; every project survives in the container. + * + * ── The DataForSEO key lives here, not in the container's env ── + * + * OpenSEO takes `DATAFORSEO_API_KEY` from its environment, so an instance somebody else started already + * has one and this column stays null. It exists for the case where Officer provisions the container — + * the direction `todo/infra/opm-officer-s-own-process-manager` is heading — and it is stored encrypted + * from the start so that day needs no migration and no window where a key sits in plaintext. + * + * It is a real cost, not a free tier: DataForSEO is metered and billed directly by them. + */ +export const openseoConnection = pgTable('openseo_connection', { + id: serial('id').primaryKey(), + + /** Where the instance is — the origin its UI and `/mcp` are served from. No trailing slash. */ + url: text('url').notNull(), + + /** + * Base64 of `login:password`, encrypted with `encryptSecret('openseo', …)` — its own purpose key, so + * a leak of that one opens this column and nothing else. + * + * **Never returned across the API boundary**, at any level, to anybody including the owner. A form + * asking "is one set" gets a boolean. + */ + dataForSeoKey: text('data_for_seo_key'), + + /** From the last successful probe — what that instance said about itself. Diagnostic only. */ + probedVersion: text('probed_version'), + + lastProbeAt: timestamp('last_probe_at', { withTimezone: true }), + /** + * Three states, not two — the same reasoning proxy-manager gives for using an integer. Null means + * never probed, which reads differently from probed-and-failed. + */ + lastProbeOk: integer('last_probe_ok'), + + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}); + +export type OpenSeoConnection = typeof openseoConnection.$inferSelect; diff --git a/manifest.ts b/manifest.ts index 423e308..5c20d97 100644 --- a/manifest.ts +++ b/manifest.ts @@ -1,7 +1,7 @@ import type { PluginManifest } from '@@/plugins/manifest'; /** - * OpenSEO — self-hosted SEO research, behind Officer's permission gate. + * OpenSEO — self-hosted keyword research, rank tracking and site audits, inside Officer. * * ── It points at an instance; it does not own one ── * @@ -10,21 +10,63 @@ import type { PluginManifest } from '@@/plugins/manifest'; * a stored connection to a service running somewhere, with the credential write-only across the * boundary. * - * That is a decision to revisit rather than a permanent one. `todo/infra/opm-officer-s-own-process-manager` - * is heading toward the Docker network, and on the day a plugin can declare a container the connection - * becomes something this plugin provisions instead of something somebody types. + * A decision to revisit rather than a permanent one. `todo/infra/opm-officer-s-own-process-manager` is + * heading toward the Docker network, and on the day a plugin can declare a container, the connection + * becomes something this provisions rather than something somebody types. * - * ── Why the mount matters more here than usual ── + * ── The permission split, and why it is not the same as proxy-manager's ── * - * Upstream self-hosting runs `AUTH_MODE=local_noauth` by design, and their docs say to expose it only - * behind auth. Everything this plugin serves is therefore gated by Officer, and the pass-through below - * is the only door. Nothing here may publish OpenSEO's own port. + * Proxy Manager carves out `/_config` because its credential can redirect any hostname on the machine. + * The shape here is the same and the reason is different: OpenSEO self-hosts with + * `AUTH_MODE=local_noauth`, so ANY reachable request to it is unauthenticated by design — the whole of + * Officer's gate is the authentication. A member who could read the connection could reach the instance + * directly and bypass this plugin entirely. + * + * So `/_config` is owner-only, and the pass-through is the only door. Nothing here may publish + * OpenSEO's own port, and that is not a hardening measure — it is the security model upstream + * documents: *"only expose it behind your own auth-protected reverse proxy, tunnel, or private + * network."* + * + * ── Why the tools are not reimplemented here ── + * + * OpenSEO ships its own MCP server at `/mcp`. Most integrations need Officer to invent the agent-facing + * surface; this one arrives with one, built for exactly this kind of client. Wrapping it in + * Officer-native tools is a real option and an open decision — but inventing a second vocabulary for + * tools that already exist would be work whose only product is drift. */ export const manifest: PluginManifest = { - name: 'OpenSEO', - description: 'Keyword research, rank tracking, competitors, backlinks and site audits — self-hosted.', - mountPrefix: '/openseo', + publisher: 'officerdev', version: '0.1.0', + platform: '>=1.0.0', + + label: 'OpenSEO', + summary: 'Keyword research, rank tracking, competitors, backlinks and site audits — self-hosted', + /* + `Marketing` and `Research` would both be more accurate and neither exists — the union is closed, and + extending it is a platform change rather than a plugin's to make. `Development` is the least wrong of + what is there: this is a tool about websites, used by whoever builds them. Worth revisiting if the + store ever grows a marketing shelf. + */ + tags: ['Development', 'Homelab'], + icon: 'TrendingUp', + color: '#22c55e', + + permissions: [ + { + key: 'openseo', + label: 'OpenSEO', + description: 'Keyword research, rank tracking, competitor and backlink data, and site audits', + + /* + The connection form, the probe, and the DataForSEO key. + + Owner-only by PREFIX, so `/_config/test` is covered too. That matters here for the same reason it + does in proxy-manager: the test request carries a credential in its body, and a member who can + call it has an oracle pointed at somebody else's key. + */ + ownerOnly: ['/_config'], + }, + ], }; export default manifest; diff --git a/skills/openseo/SKILL.md b/skills/openseo/SKILL.md new file mode 100644 index 0000000..b303963 --- /dev/null +++ b/skills/openseo/SKILL.md @@ -0,0 +1,76 @@ +--- +name: openseo +title: OpenSEO +description: Use for keyword research, rank tracking, competitor and backlink data, site audits and AI visibility — "what do people search for around X", "where does this domain rank", "who competes with us", "audit this site". Carries the one route in, what the tools cost, and the two answers that are not failures. +version: 1 +--- + +Officer proxies an OpenSEO instance. This plugin holds its address and hands you **OpenSEO's own MCP +server** through Officer's permission gate. + +## The route + +``` +/api/openseo/ -> / +``` + +Method, path, query and body are forwarded unchanged, and the instance's own status and body come back +unchanged — including its errors. `/api/openseo/mcp` is the MCP server; everything else is its API and +its web UI. + +```bash +curl -sS -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \ + -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \ + "$OFFICER_API_URL/api/openseo/mcp" +``` + +**The contract is OpenSEO's MCP, not ours.** Start with `tools/list` and read what that instance +actually offers — the tool set moves upstream, and a list from this document would be the stale copy. + +**Never reach the instance directly.** Not by its port, not by its container. It self-hosts with +`AUTH_MODE=local_noauth`, which means any request that arrives is trusted — Officer's gate is the ONLY +authentication in front of it, and going around the proxy is going around the authentication. + +## Calling a tool + +```jsonc +{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": { "name": "", "arguments": { … } } } +``` + +Replies arrive as JSON or as an SSE frame depending on the `accept` header you send. Ask for both, as +above, and read the first `{` onwards. + +## Every call costs real money + +OpenSEO is free software over **DataForSEO**, a metered API billed pay-as-you-go directly to the owner. +A keyword lookup, a SERP check and a backlink query are each a charge. + +So: **do not sweep.** Ask for the keywords, the domain or the pages you were asked about, and not the +adjacent ones you could imagine being useful. If a question would take fifty lookups to answer well, +say that and what it would cover before spending it — the owner may want ten, or a different ten. + +Nothing here warns about spend, and nothing here can see the balance. + +## Long work is asynchronous, and that is not a hang + +Crawls and rank checks return a task id and are collected later; the tool descriptions say which. A call +that returns "queued" has succeeded. Poll with the resume tool that instance lists rather than calling +again — a second call is a second charge for the same work. + +The proxy allows ten minutes per request. Past that it answers 502 with a plain sentence, and the work +on the far side may well have finished — check before retrying, for the same reason. + +## Two answers that are not failures + +- **409 "No OpenSEO instance is configured."** Nothing is broken; the owner has not connected one. Only + the owner can, in the plugin's Settings panel. Do not go looking for an instance to point it at. +- **403** — either the account lacks `openseo`, or it has it at read level. Report it; there is no way + round and looking for one is the mistake this paragraph exists to prevent. + +## What this plugin does NOT do + +It stores an address and a key. It has no tables of its own beyond that, on purpose: every project, +keyword, ranking and crawl lives in the instance, so uninstalling this plugin cannot destroy anybody's +research. If you find yourself wanting to cache a project list here, that is the reason not to. diff --git a/web/AppPanel.tsx b/web/AppPanel.tsx new file mode 100644 index 0000000..5e2daac --- /dev/null +++ b/web/AppPanel.tsx @@ -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(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 ( +
+ +
+ ); + } + + if (!configured) return ; + + return ( +