diff --git a/web/server/routes/api/plugin-config/[id].ts b/web/server/routes/api/plugin-config/[id].ts new file mode 100644 index 00000000..58dc5e51 --- /dev/null +++ b/web/server/routes/api/plugin-config/[id].ts @@ -0,0 +1,91 @@ +// GET/PUT /api/plugin-config/ — a plugin's `__config`, readable from the CONSOLE origin. +// +// The Library section's "Game sources" settings drawer renders a form from a library plugin's +// `__config` (the kit's generic settings surface, so a scanner needs no SPA of its own). It fetched +// `/plugin-ui//__config` same-origin — and that stopped working the moment plugin UIs moved to +// their own origin (2026-08-05 review H-3): `middleware/auth.ts` answers 404 for `/plugin-ui/**` on +// the console origin, unconditionally and by design. The drawer is the only NON-IFRAME consumer of +// that path, so nothing else noticed, and settings silently failed to open for every library plugin. +// +// The fix is deliberately not "point the drawer at the plugin origin". That needs CORS plus +// cross-site cookies, and it would put a plugin-controlled response inside a credentialed +// cross-origin fetch — reopening the hole the split exists to close. What the drawer needs is DATA, +// not an embedded UI: this reads the JSON server-side over loopback and returns it same-origin, so +// no plugin HTML or JS is ever served from the console origin. +// +// Auth: `/api/**` is always session-gated (`isPublicPath`), so reaching here means a logged-in +// operator, and it answers 401 as JSON rather than redirecting — which is what a `fetch` needs. The +// plugin's per-boot secret stays server-side, exactly as in the `/plugin-ui` proxy. +import { + defineEventHandler, + getRouterParam, + readRawBody, + setResponseStatus, +} from "h3"; +import { + bustCredential, + fetchUiCredential, + PLUGIN_ID_RE, +} from "../../../util/pluginProxy"; + +/** `GET` reads schema + current value; `PUT` validates and saves. Nothing else is forwarded. */ +const ALLOWED = new Set(["GET", "PUT"]); + +export default defineEventHandler(async (event) => { + const id = getRouterParam(event, "id"); + if (!id || !PLUGIN_ID_RE.test(id)) { + setResponseStatus(event, 404); + return { error: "not a valid plugin id" }; + } + const method = event.method; + if (!ALLOWED.has(method)) { + setResponseStatus(event, 405); + return { error: "method not allowed" }; + } + // Read the body BEFORE the retry below: `readRawBody` drains the stream, so a second attempt + // would forward an empty PUT and quietly save `{}` over the operator's config. + const body = + method === "PUT" + ? ((await readRawBody(event, false)) as Uint8Array | undefined) + : undefined; + + const attempt = async (bustCache: boolean): Promise => { + const cred = await fetchUiCredential(id, { bustCache }); + if (!cred) return null; + try { + return await fetch(`http://127.0.0.1:${cred.port}/__config`, { + method, + headers: { + authorization: `Bearer ${cred.secret}`, + ...(method === "PUT" ? { "content-type": "application/json" } : {}), + }, + body: body as BodyInit | undefined, + }); + } catch { + return null; + } + }; + + // A plugin's secret rotates when its process restarts, which happens well inside the credential + // cache's TTL — so a 401 here means "stale credential", not "denied". Same one-shot retry the + // `/plugin-ui` proxy does, for the same reason. + let res = await attempt(false); + if (res?.status === 401) { + bustCredential(id); + res = await attempt(true); + } + if (!res) { + setResponseStatus(event, 502); + return { error: `plugin ${id} is not reachable` }; + } + + setResponseStatus(event, res.status); + // Pass the plugin's own body through untouched: a 400 from `__config` carries the decode issue + // the drawer shows the operator, and rewriting it would throw away the only useful part. + const text = await res.text(); + try { + return JSON.parse(text) as unknown; + } catch { + return { error: text || `plugin ${id} answered ${res.status}` }; + } +}); diff --git a/web/src/sections/Library/SourceSettings.tsx b/web/src/sections/Library/SourceSettings.tsx index 2a001258..353193f3 100644 --- a/web/src/sections/Library/SourceSettings.tsx +++ b/web/src/sections/Library/SourceSettings.tsx @@ -26,9 +26,15 @@ import { m } from "@/paraglide/messages"; * A library source's settings, rendered as a **generic form** from the plugin's own JSON Schema. * * The point (design D7, closing G8): a scanner plugin ships no SPA at all. It serves - * `GET/PUT /__config` from the kit, and the console renders whatever schema comes back. Everything - * goes through the existing session-gated `/plugin-ui//…` proxy, so there is **zero new host - * surface** — the browser never learns the plugin's port or secret. + * `GET/PUT /__config` from the kit, and the console renders whatever schema comes back. The browser + * never learns the plugin's port or secret — the console reads it server-side over loopback. + * + * That read goes through `/api/plugin-config/` on the CONSOLE origin, not the `/plugin-ui/…` + * proxy this used to call. Plugin UIs live on their own origin (2026-08-05 review H-3) and the + * console origin now answers 404 for `/plugin-ui/**` by design, which broke this drawer for every + * library plugin — it is the one consumer of that path that is not an iframe. What it needs is + * DATA, not an embedded UI, so it gets JSON same-origin and no plugin markup ever reaches the + * console origin. * * Fields the derivation can't express fall back to a raw JSON editor. That fallback is what bounds * the risk of the whole approach: worst case the drawer is a validated textarea, and the PUT still @@ -51,7 +57,7 @@ export const SourceSettingsDialog: FC<{ let cancelled = false; (async () => { try { - const res = await fetch(`/plugin-ui/${pluginId}/__config`, { + const res = await fetch(`/api/plugin-config/${pluginId}`, { credentials: "same-origin", }); if (!res.ok) throw new Error(`HTTP ${res.status}`); @@ -77,7 +83,7 @@ export const SourceSettingsDialog: FC<{ const save = async (value: JsonObject) => { setSaving(true); try { - const res = await fetch(`/plugin-ui/${pluginId}/__config`, { + const res = await fetch(`/api/plugin-config/${pluginId}`, { method: "PUT", credentials: "same-origin", headers: { "content-type": "application/json" },