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