// The standalone fallback UI server (design §9/§10.2, D1): for host-only installs without the console, // or as schedule insurance if the console surface is unavailable. Serves the SAME SPA + router, but on // a fixed port with its own password/session auth instead of the console proxy. Fail-closed: a // non-loopback bind REQUIRES a password (a UI that can rewrite launch templates is host-user code). import * as nodePath from "node:path"; import type { Config } from "../config.js"; import type { Engine } from "../engine/index.js"; import { log } from "../log.js"; import { resolveUiDir } from "../paths.js"; import { verifyPassword } from "./password.js"; import { makeRouter } from "./router.js"; export interface StandaloneHandle { url: string; close(): Promise; } const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]); const COOKIE = "pf_rm_session"; const loginPage = (error?: string): Response => new Response( ` ROM Manager

ROM Manager

${error ? `
${error}
` : ""}
`, { status: error ? 401 : 200, headers: { "content-type": "text/html; charset=utf-8" }, }, ); /** Resolve a request path to a file inside `root`, or null on traversal (mirrors servePluginUi). */ const staticFile = (root: string, pathname: string): string | null => { let rel: string; try { rel = decodeURIComponent(pathname); } catch { return null; } if (rel.endsWith("/")) rel += "index.html"; if (!rel.startsWith("/")) rel = `/${rel}`; const abs = nodePath.resolve(root, `.${rel}`); const rootAbs = nodePath.resolve(root); if (abs !== rootAbs && !abs.startsWith(rootAbs + nodePath.sep)) return null; return abs; }; const cookieHas = (req: Request, name: string, value: string): boolean => { const raw = req.headers.get("cookie") ?? ""; return raw.split(/;\s*/).some((c) => c === `${name}=${value}`); }; /** Serve the standalone UI. Throws (fail-closed) if a non-loopback bind lacks a password. */ export const serveStandaloneUi = ( engine: Engine, config: Config, ): StandaloneHandle => { const { port, bind, passwordHash } = config.ui; const isLoopback = LOOPBACK.has(bind); if (!isLoopback && !passwordHash) { throw new Error( `standalone UI refuses to bind ${bind} without a password (set ui.passwordHash via \`punktfunk-plugin-rom-manager set-password\`, or bind 127.0.0.1)`, ); } const authRequired = Boolean(passwordHash); // A per-boot session token — any client presenting it in the cookie is authed until the plugin restarts. const sessionToken = crypto.randomUUID().replace(/-/g, ""); const root = resolveUiDir(import.meta.url); const router = makeRouter(engine); const authed = (req: Request) => !authRequired || cookieHas(req, COOKIE, sessionToken); const server = Bun.serve({ hostname: bind, port, async fetch(req) { const { pathname } = new URL(req.url); if (pathname === "/__health") return Response.json({ ok: true }); if (authRequired && pathname === "/login" && req.method === "POST") { const form = await req.formData().catch(() => null); const password = form?.get("password"); if ( typeof password === "string" && passwordHash && verifyPassword(password, passwordHash) ) { return new Response(null, { status: 303, headers: { location: "./", "set-cookie": `${COOKIE}=${sessionToken}; HttpOnly; SameSite=Strict; Path=/`, }, }); } return loginPage("Incorrect password"); } if (pathname === "/logout" && req.method === "POST") { return new Response(null, { status: 303, headers: { location: "./", "set-cookie": `${COOKIE}=; Max-Age=0; Path=/`, }, }); } if (!authed(req)) { if (pathname.startsWith("/api/")) return Response.json({ error: "unauthorized" }, { status: 401 }); return loginPage(); } // 1) plugin-local API const api = await router(req); if (api) return api; // 2) static asset const file = staticFile(root, pathname); if (file) { const bf = Bun.file(file); if (await bf.exists()) return new Response(bf); } // 3) SPA fallback if ( req.method === "GET" && (req.headers.get("accept") ?? "").includes("text/html") ) { const index = Bun.file(nodePath.join(root, "index.html")); if (await index.exists()) return new Response(index); } return new Response("not found", { status: 404 }); }, }); const url = `http://${isLoopback ? "127.0.0.1" : bind}:${server.port}`; log( `standalone UI on ${url}${authRequired ? " (password-protected)" : " (loopback, no password)"}`, ); return { url, async close() { server.stop(true); }, }; };