A verification pass re-read every finding from the original sweep against the code on this branch rather than against the commit messages. It found that four of them were still broken, two because the edit I made was inert. Commit messages claim; code decides. - **The Storybook typecheck was never on.** `tsconfig.json` listed `.storybook` as a bare directory name, and tsc silently skips dot-prefixed directories in that form — so the entry typechecked nothing at all. Proved it by planting `export const __probe: string = 1` in `.storybook/preview.tsx` and watching `bun run lint` pass. `.storybook/**/*` is what actually pulls it in; the same probe now fails as it should. - **The Moonlight stale-PIN reset was a no-op.** `submit.reset()` sat at the top of `onSubmit`, immediately before `submit.mutate(...)` — which moves the status to pending in the same update, so it cleared a flag that was already changing. The green "PIN sent" note therefore still greeted the next pairing attempt over an empty PIN box. It now resets on the transition that actually matters: `pin_pending` going false → true. - **The session⇄game controls had the enforcement flag inverted**, and I never touched it. `enforced.length === 0 || …` reads an EMPTY list as "this build enforces everything", when the contract says the opposite in as many words: "Empty on a platform with no launch path (macOS), so the console can say so instead of offering a switch that does nothing". On exactly the platform the flag exists for, every control stayed live and reported success for an axis the host would never act on. Absent still means "assume it acts" — that is the compatible reading for an older host, and a different case from present-empty. - **Logout stopped revoking after a restart.** The epoch was a module-level counter starting at 1, so it revoked within one process run and then reset — and since the seal key derives from the stable mgmt token, a cookie captured before a restart unsealed fine and was accepted again for the rest of its 7-day TTL. One service restart undid the whole fix. It persists next to the host's config now. Verified: log out, restart the console, the captured cookie still 401s, a fresh login still works. Two more the pass rated as partial, both worth closing: - The plugin-UI response filter was a denylist of four header names, so `Clear-Site-Data` sailed through — a plugin error page could wipe `pf_session` and sign the operator out of the console, on our own origin, because the iframe is same-origin by design. It is an allowlist now; a plugin-supplied CSP, `X-Frame-Options` or CORS header no longer speaks for us either. - A half-configured TLS setup now refuses to start instead of logging a warning and serving anyway. Neither shape can work — one path missing puts the login password on the LAN in the clear, and PUNKTFUNK_UI_SECURE without TLS marks the cookie Secure so the browser drops it and login can never stick. Exiting with a reason beats a console that looks fine and is not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
130 lines
5.0 KiB
TypeScript
130 lines
5.0 KiB
TypeScript
// /plugin-ui/<id>/** → a plugin's loopback UI server (plugin-ui-surface §5). By the time we get
|
|
// here the gate (middleware/auth.ts) has confirmed a session — a plugin UI is reachable only by the
|
|
// logged-in operator, on the console's own origin, with no separate password. We look up the
|
|
// plugin's `{port, secret}` server-side, inject the secret as a bearer, strip the browser's cookie,
|
|
// and stream the response through (SSE included). The plugin only ever gets dialed on 127.0.0.1.
|
|
//
|
|
// This route runs in the built Bun/Nitro server. In `vite dev` a small middleware in vite.config.ts
|
|
// handles `/plugin-ui` instead (it intercepts before this route, like the /api dev proxy).
|
|
import {
|
|
defineEventHandler,
|
|
getProxyRequestHeaders,
|
|
getRequestURL,
|
|
readRawBody,
|
|
sendWebResponse,
|
|
setResponseStatus,
|
|
} from "h3";
|
|
import {
|
|
bustCredential,
|
|
fetchUiCredential,
|
|
PLUGIN_ID_RE,
|
|
} from "../../util/pluginProxy";
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
const { pathname, search } = getRequestURL(event);
|
|
// /plugin-ui/<id>/<rest…>
|
|
const m = pathname.match(/^\/plugin-ui\/([^/]+)(\/.*)?$/);
|
|
const id = m?.[1];
|
|
if (!id || !PLUGIN_ID_RE.test(id)) {
|
|
setResponseStatus(event, 404);
|
|
return { error: "not a valid plugin-ui path" };
|
|
}
|
|
const rest = m?.[2] ?? "/";
|
|
const prefix = `/plugin-ui/${id}`;
|
|
|
|
// Forwardable request headers (h3 strips hop-by-hop + host); we set our own auth and drop the
|
|
// session cookie so plugin code never sees it.
|
|
const headers = getProxyRequestHeaders(event) as Record<string, string>;
|
|
delete headers.cookie;
|
|
delete headers.authorization;
|
|
headers["x-forwarded-prefix"] = prefix;
|
|
const method = event.method;
|
|
// Only read a body for the methods that can carry one. `readRawBody` asserts a payload method,
|
|
// so calling it for OPTIONS (a plugin UI's CORS preflight, or any client probing Allow) threw
|
|
// 405 out of the CONSOLE before the plugin was ever dialed.
|
|
const body = BODY_METHODS.has(method)
|
|
? ((await readRawBody(event, false)) as Uint8Array | undefined)
|
|
: undefined;
|
|
|
|
// One proxied attempt; `null` means the plugin is unreachable (unregistered, or its port died).
|
|
const attempt = async (bustCache: boolean): Promise<Response | null> => {
|
|
const cred = await fetchUiCredential(id, { bustCache });
|
|
if (!cred) return null;
|
|
const target = `http://127.0.0.1:${cred.port}${rest}${search}`;
|
|
try {
|
|
return await fetch(target, {
|
|
method,
|
|
headers: { ...headers, authorization: `Bearer ${cred.secret}` },
|
|
body: body as BodyInit | undefined,
|
|
redirect: "manual",
|
|
});
|
|
} catch {
|
|
// The port is dead (plugin crashed/restarted on a new port): drop the stale credential so
|
|
// the next request re-resolves it.
|
|
bustCredential(id);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
let resp = await attempt(false);
|
|
// Stale secret after a plugin restart (S7): the plugin rejects our cached secret — re-fetch once.
|
|
if (resp?.status === 401) {
|
|
const retry = await attempt(true);
|
|
if (retry) resp = retry;
|
|
}
|
|
if (!resp) {
|
|
setResponseStatus(event, 502);
|
|
return { error: `plugin "${id}" is not running` };
|
|
}
|
|
return sendWebResponse(event, sanitize(resp));
|
|
});
|
|
|
|
/** Methods that may carry a request body. Anything else (GET, HEAD, OPTIONS, TRACE) must not be
|
|
* handed to `readRawBody`. */
|
|
const BODY_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
|
|
/**
|
|
* Rebuild a plugin's response before it goes out on the console's own origin.
|
|
*
|
|
* An ALLOWLIST, not a denylist. A plugin UI is proxied same-origin by design, so any header it
|
|
* returns is asserted for the console itself — and the first version of this dropped four names it
|
|
* had thought of. `Clear-Site-Data: "*"` from a plugin's error page was not one of them: the
|
|
* browser would honour it for this origin and wipe `pf_session`, signing the operator out of the
|
|
* console because a plugin 500'd. Same shape for a plugin-supplied `Content-Security-Policy`,
|
|
* `X-Frame-Options` or `Access-Control-Allow-Origin` — all of which would speak for us.
|
|
*
|
|
* So: name what a plugin page legitimately needs, and drop the rest. Framing headers
|
|
* (content-encoding/length, transfer-encoding) are deliberately absent — `fetch` already decoded
|
|
* the body, so re-emitting the plugin's originals made compressed pages fail to decode; ours are
|
|
* recomputed.
|
|
*/
|
|
const PLUGIN_HEADER_ALLOWLIST = new Set([
|
|
"content-type",
|
|
"cache-control",
|
|
"etag",
|
|
"last-modified",
|
|
"expires",
|
|
"vary",
|
|
"content-language",
|
|
"content-disposition",
|
|
"accept-ranges",
|
|
"content-range",
|
|
"location", // its own redirects, within its own prefix
|
|
"link", // preload hints for its own assets
|
|
"x-forwarded-prefix",
|
|
]);
|
|
|
|
function sanitize(resp: Response): Response {
|
|
const headers = new Headers();
|
|
for (const [k, v] of resp.headers) {
|
|
if (PLUGIN_HEADER_ALLOWLIST.has(k.toLowerCase())) headers.set(k, v);
|
|
}
|
|
// 204/304 must not carry a body — passing one through throws in the Response constructor.
|
|
const bodyless = resp.status === 204 || resp.status === 304;
|
|
return new Response(bodyless ? null : resp.body, {
|
|
status: resp.status,
|
|
statusText: resp.statusText,
|
|
headers,
|
|
});
|
|
}
|