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
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:
@@ -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