Merge pull request 'Library source settings never opened — the drawer still called the old plugin origin' (#118) from worktree-library-settings-origin-split into main
ci / bun-nix (push) Successful in 31s
arch / build-publish (push) Canceled after 1m8s
ci / rust (push) Canceled after 52s
ci / docs-site (push) Canceled after 1m12s
ci / rust-arm64 (push) Canceled after 1m15s
ci / web (push) Canceled after 1m15s
deb / build-publish (push) Canceled after 55s
deb / build-publish-host (push) Canceled after 45s
deb / build-publish-client-arm64 (push) Canceled after 8s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 19s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 4s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 4s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 17s
docker / builders-arm64cross (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 26s
windows-host / package (push) Canceled after 1m54s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 53s

Reviewed-on: #118
This commit was merged in pull request #118.
This commit is contained in:
2026-08-08 12:40:44 +00:00
2 changed files with 102 additions and 5 deletions
@@ -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}` };
}
});
+11 -5
View File
@@ -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" },