// ---------------------------------------------------------------------------
// router.ts -- minimal HTTP router adapted from specweave dashboard pattern
//
// 0836 US-002: every /api/* request requires `X-Studio-Token`. The token is
// generated lazily per process and exposed to the Tauri WebView via the
// `get_studio_token` IPC. The previous LOCALHOST_ORIGIN_RE allowlist was
// permissive (any localhost browser tab + DNS rebinding). Replaced with a
// per-launch shared-secret gate using `crypto.timingSafeEqual` (constant
// time over equal-length buffers, length-mismatch fast-rejected).
// ---------------------------------------------------------------------------
import { randomBytes, timingSafeEqual } from "node:crypto";
export class Router {
    routes = [];
    options;
    get(path, handler) {
        this.addRoute("GET", path, handler);
    }
    post(path, handler) {
        this.addRoute("POST", path, handler);
    }
    put(path, handler) {
        this.addRoute("PUT", path, handler);
    }
    delete(path, handler) {
        this.addRoute("DELETE", path, handler);
    }
    addRoute(method, path, handler) {
        const paramNames = [];
        const pattern = path.replace(/:([^/]+)/g, (_, name) => {
            paramNames.push(name);
            return "([^/]+)";
        });
        this.routes.push({
            method,
            pattern: new RegExp(`^${pattern}$`),
            paramNames,
            handler,
        });
    }
    async handle(req, res) {
        const method = req.method || "GET";
        const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
        const pathname = url.pathname;
        // 0836 US-002: gate every /api/* request behind the X-Studio-Token. The
        // gate writes 401 + empty body on failure; we return `true` so the
        // server.ts dispatcher treats the response as owned and stops looking
        // for static-file fallbacks (which would otherwise serve index.html
        // for unmatched paths and confuse callers).
        if (!tokenGate(req, res)) {
            return true;
        }
        for (const route of this.routes) {
            if (route.method !== method)
                continue;
            const match = pathname.match(route.pattern);
            if (!match)
                continue;
            const params = {};
            route.paramNames.forEach((name, i) => {
                params[name] = decodeURIComponent(match[i + 1]);
            });
            try {
                await route.handler(req, res, params);
            }
            catch (err) {
                if (!res.headersSent) {
                    const message = err instanceof Error ? err.message : "Internal server error";
                    const sanitized = message.replace(/\/[^\s:]+/g, "<path>");
                    res.writeHead(500, { "Content-Type": "application/json" });
                    res.end(JSON.stringify({ ok: false, error: sanitized }));
                }
            }
            return true;
        }
        return false;
    }
}
// ---------------------------------------------------------------------------
// 0836 US-002 — X-Studio-Token (per-launch shared secret).
//
// The previous `LOCALHOST_ORIGIN_RE` allowlist was permissive (any tab on
// `http://localhost:*` could reach the bearer-injecting proxy, and DNS
// rebinding subverted it). It is REMOVED. Each /api/* request must now
// carry `X-Studio-Token: <token>`; the token lives only in the eval-server
// process memory and is delivered to the Tauri WebView via IPC and to the
// CLI via stdout banner.
// ---------------------------------------------------------------------------
let _studioToken = null;
/**
 * Lazily generate (or return) the per-process studio token. 32 bytes of
 * `crypto.randomBytes` encoded as base64url — 256 bits of entropy, 43 chars,
 * URL-safe and header-safe. Never persisted; rotated on every restart.
 */
export function getStudioToken() {
    if (_studioToken == null) {
        _studioToken = randomBytes(32).toString("base64url");
    }
    return _studioToken;
}
/** Test-only — never call from production code. */
export function _resetStudioTokenForTests() {
    _studioToken = null;
}
/**
 * Constant-time token comparison. `crypto.timingSafeEqual` requires equal-
 * length buffers; we length-check first (the token length is a public
 * constant, so the early return leaks no secret). String === would be V8
 * short-circuit and timing-leaky.
 */
export function tokensEqual(supplied, expected) {
    if (typeof supplied !== "string")
        return false;
    if (supplied.length !== expected.length)
        return false;
    const a = Buffer.from(supplied);
    const b = Buffer.from(expected);
    return timingSafeEqual(a, b);
}
/**
 * Returns true when the request is allowed to proceed. Returns false AND
 * writes a 401 (empty body) when the gate rejects.
 *
 * Bypasses:
 *   - any non-/api/* path (static files, root, eval-ui shell)
 *   - OPTIONS preflight (CORS handshake; no token possible from XHR)
 */
// 0855: GET EventSource stream paths that the browser opens via `new
// EventSource(url)`. The EventSource API CANNOT set request headers, so these
// streams authenticate via a `?studioToken=<t>` query param instead of the
// `X-Studio-Token` header — validated with the same timing-safe compare. The
// exemption is GET-only and limited to this allowlist so it cannot widen the
// 0836 gate for any other path or method.
//   - /api/v1/skills/stream — notification stream (proxied to the platform
//     UpdateHub). This is the one that 0836 silently broke (no live toasts).
//   - /api/events / /api/studio/ops/stream — local eval-server SSE streams
//     opened by EventSource; same header-less limitation applies.
const EVENTSOURCE_STREAM_PATHS = new Set([
    "/api/v1/skills/stream",
    "/api/events",
    "/api/studio/ops/stream",
]);
export function tokenGate(req, res) {
    const rawUrl = req.url || "/";
    // 0836 followup (Codex H#4): normalize req.url through URL parsing so that
    // an absolute-form request target (e.g. `GET http://127.0.0.1:3077/api/git/status`)
    // gates by its parsed pathname, not by the raw string. Without this,
    // `rawUrl.startsWith("/api/")` returns false for absolute-form URLs and
    // the router's pathname-based dispatch happily executes the route.
    let parsedUrl;
    try {
        parsedUrl = new URL(rawUrl, "http://127.0.0.1");
    }
    catch {
        // Truly malformed URL — let the underlying handlers reject it.
        return true;
    }
    const pathname = parsedUrl.pathname;
    const method = (req.method || "GET").toUpperCase();
    // Non-/api paths are static / SPA shell — never gated.
    if (!pathname.startsWith("/api/"))
        return true;
    // CORS preflight cannot send custom headers; let it pass.
    if (method === "OPTIONS")
        return true;
    // 0836 followup (Codex C#1): /api/health is the unauthenticated liveness
    // probe used by the desktop sidecar boot. The Tauri shell calls it BEFORE
    // it has parsed the studio_token from the sidecar's stdout, so token-gating
    // it would deadlock the chicken-and-egg: the token comes from the running
    // server, but the server has to be reachable to deliver it. Loopback bind
    // (US-001) is the network-level guard for /api/health.
    if (pathname === "/api/health")
        return true;
    // 0843 followup (2026-05-11): the GitHub OAuth browser callbacks are hit by
    // the user's browser AFTER they authorize at github.com — there's no way
    // for those requests to carry X-Studio-Token. The endpoints themselves are
    // CSRF-protected via the `state` param (validated against an in-memory
    // per-flow store), so exempting them from the token gate is safe.
    if (pathname === "/api/oauth/github/callback")
        return true;
    if (pathname === "/api/oauth/github/desktop-complete")
        return true;
    const expected = getStudioToken();
    // 0855: EventSource streams carry the token in a `?studioToken=<t>` query
    // param because the browser EventSource API cannot set request headers.
    // Scope: GET only + an allowlist of known stream paths. Validate with the
    // SAME timing-safe compare used for the header. On success, STRIP the
    // studioToken param from req.url BEFORE returning so it never reaches the
    // upstream platform proxy (which reads req.url after the gate) and never
    // leaks to verified-skill.com. The token is never logged.
    if (method === "GET" && EVENTSOURCE_STREAM_PATHS.has(pathname)) {
        const qpToken = parsedUrl.searchParams.get("studioToken");
        if (tokensEqual(qpToken, expected)) {
            parsedUrl.searchParams.delete("studioToken");
            req.url = `${parsedUrl.pathname}${parsedUrl.search}`;
            return true;
        }
        // Wrong/missing query token falls through to the header check below; if
        // that also fails the request is rejected (boundary preserved).
    }
    const supplied = readHeader(req, "x-studio-token");
    if (tokensEqual(supplied, expected))
        return true;
    // Reject. Empty body avoids leaking the expected-length info beyond what
    // a fixed 401 signals. Log at WARN — and crucially WITHOUT the supplied
    // token value (a wrong-length attempt could still be sensitive in CI logs).
    // We log the path + method only.
    if (!res.headersSent) {
        res.writeHead(401, { "Content-Type": "text/plain" });
        res.end("");
    }
    // eslint-disable-next-line no-console
    console.warn(`[router] X-Studio-Token rejected for ${method} ${pathname}`);
    return false;
}
function readHeader(req, name) {
    const v = req.headers[name];
    if (typeof v === "string")
        return v;
    if (Array.isArray(v))
        return v[0];
    return undefined;
}
export function sendJson(res, data, status = 200, _req) {
    // 0836 US-002: CORS Origin-allowlist deleted. The studio-token gate is the
    // sole authn for /api/*; any caller that passes the gate is same-origin
    // for our purposes (Tauri WebView, vskill CLI's curl, Playwright, dev).
    // We do not echo Access-Control-Allow-* headers anymore.
    const headers = {
        "Content-Type": "application/json",
        "Cache-Control": "no-store",
        Vary: "Origin",
    };
    res.writeHead(status, headers);
    res.end(JSON.stringify(data));
}
export async function readBody(req) {
    const MAX_BODY_SIZE = 1024 * 1024;
    const TIMEOUT_MS = 10_000;
    return new Promise((resolve, reject) => {
        const chunks = [];
        let size = 0;
        const timer = setTimeout(() => {
            req.destroy();
            reject(new Error("Request body timeout"));
        }, TIMEOUT_MS);
        req.on("data", (chunk) => {
            size += chunk.length;
            if (size > MAX_BODY_SIZE) {
                clearTimeout(timer);
                req.destroy();
                reject(new Error("Request body too large"));
                return;
            }
            chunks.push(chunk);
        });
        req.on("end", () => {
            clearTimeout(timer);
            const text = Buffer.concat(chunks).toString();
            if (!text.trim()) {
                resolve({});
                return;
            }
            try {
                resolve(JSON.parse(text));
            }
            catch {
                reject(new Error("Invalid JSON body"));
            }
        });
        req.on("error", (err) => {
            clearTimeout(timer);
            reject(err);
        });
    });
}
//# sourceMappingURL=router.js.map