forked from unom/punktfunk
fix(console): library source settings 404'd — the drawer still called the old plugin origin
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/<id>/__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/<id>` 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).
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
// GET/PUT /api/plugin-config/<id> — 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/<id>/__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<Response | null> => {
|
||||
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}` };
|
||||
}
|
||||
});
|
||||
@@ -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/<id>/…` 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/<id>` 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" },
|
||||
|
||||
Reference in New Issue
Block a user