From deef5e43825c1c757e1d0c8a2ab7c3c5a3ec0b18 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 8 Aug 2026 14:17:58 +0200 Subject: [PATCH] =?UTF-8?q?fix(console):=20library=20source=20settings=204?= =?UTF-8?q?04'd=20=E2=80=94=20the=20drawer=20still=20called=20the=20old=20?= =?UTF-8?q?plugin=20origin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a library source's settings did nothing, for every library plugin. Confirmed on `.21` against the running console: console origin :47992 /plugin-ui/lutris/__config -> 404 plugin origin :47993 /plugin-ui/lutris/__config -> 401 The drawer fetches a RELATIVE `/plugin-ui//__config`, so it resolves against the console's own origin — where `middleware/auth.ts` answers 404 for `/plugin-ui/**` unconditionally and by design. That refusal is the 2026-08-05 review's origin split (H-3): plugin UIs moved to their own listener, and neither origin may serve the other's paths. The drawer is the only consumer of `/plugin-ui` that is NOT an iframe — every other caller builds an absolute URL from `pluginOriginFrom(uiConfig)` — so it was the one thing the split broke, and nothing failed loudly enough to notice. The fix is deliberately not to 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 exactly the hole the split closed. What this drawer needs is DATA, not an embedded UI: `/api/plugin-config/` reads the plugin's `__config` server-side over loopback and returns the JSON same-origin, so no plugin markup or script is ever served from the console origin and the per-boot secret stays on the server, as with the `/plugin-ui` proxy. `/api/**` is always session-gated (`isPublicPath`), so the new route inherits the gate and answers 401 as JSON rather than redirecting to /login — which is what a `fetch` needs and what the old path could never give it. It forwards only GET and PUT, reads the body BEFORE the stale-credential retry (`readRawBody` drains the stream, so a retried PUT would have saved `{}` over the operator's config), and passes the plugin's own body through untouched so a 400's decode issue still reaches the operator. Verified against the real built server: `/api/plugin-config/lutris` answers 401 — the route resolves and is gated, and the BFF catch-all at `api/[...]` does not swallow it — while `/plugin-ui/lutris/__config` still answers 404 on the console origin, i.e. the split is intact. `/api/v1/status` still reaches the BFF. tsc clean, production build clean, i18n 633 messages across en+de, biome clean on both touched files (the one warning in SourceSettings.tsx pre-dates this change). --- web/server/routes/api/plugin-config/[id].ts | 91 +++++++++++++++++++++ web/src/sections/Library/SourceSettings.tsx | 16 ++-- 2 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 web/server/routes/api/plugin-config/[id].ts 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" },