Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
deef5e4382 |
@@ -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.
|
* 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
|
* 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
|
* `GET/PUT /__config` from the kit, and the console renders whatever schema comes back. The browser
|
||||||
* goes through the existing session-gated `/plugin-ui/<id>/…` proxy, so there is **zero new host
|
* never learns the plugin's port or secret — the console reads it server-side over loopback.
|
||||||
* surface** — the browser never learns the plugin's port or secret.
|
*
|
||||||
|
* 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
|
* 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
|
* 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;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/plugin-ui/${pluginId}/__config`, {
|
const res = await fetch(`/api/plugin-config/${pluginId}`, {
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
@@ -77,7 +83,7 @@ export const SourceSettingsDialog: FC<{
|
|||||||
const save = async (value: JsonObject) => {
|
const save = async (value: JsonObject) => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/plugin-ui/${pluginId}/__config`, {
|
const res = await fetch(`/api/plugin-config/${pluginId}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
|
|||||||
Reference in New Issue
Block a user