/**
 * remote/http-server — reference HTTP+SSE transport for remote-control.
 *
 * Serves a `RemoteControlPort` over plain Node `http` (zero dependencies):
 *
 *   GET  /remote/health             liveness
 *   GET  /remote/state              JSON snapshot of SessionState
 *   GET  /remote/stream[?since=N]   SSE — backlog (seq > N) then live events
 *   POST /remote/inject             {text, whisper?} → InstructionInbox
 *   POST /remote/hitl/:id/approve   approve a parked HITL request
 *   POST /remote/hitl/:id/reject    reject a parked HITL request
 *   POST /remote/revoke/:jti        revoke a previously-minted sub-token
 *   GET  /remote/revocations        list currently-revoked jtis
 *
 * This is the LAN reference transport (T1). The relay client (T2) reuses the
 * same `RemoteControlPort` — the port/transport split means the relay is just
 * another adapter, no rework. Mirrors the pattern of `hitl/slack.ts` (a HITL
 * transport over the `ApprovalChannel` port).
 *
 * Security: optional Bearer token (constant-time compare). A non-loopback
 * bind WITHOUT a token is refused — fail closed.
 *
 * @public @since 2.11.0 — preste remote-control T1
 */
import { createServer } from "node:http";
import { DpopReplayStore, validateDpopProof } from "./dpop.js";
import { rebindSubToken, scopeCovers, verifySubToken, } from "./sub-token.js";
import { recordRevocation, resolveAuthScopeInstrumented, } from "./sub-token-otel.js";
const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
/** Read a bounded POST body; returns "" on error. */
function readBody(req, cap = 16_384) {
    return new Promise((resolve) => {
        let body = "";
        req.on("data", (c) => {
            body += c;
            if (body.length > cap)
                req.destroy();
        });
        req.on("end", () => resolve(body));
        req.on("error", () => resolve(""));
    });
}
/**
 * Start the remote-control HTTP+SSE server over a RemoteControlPort.
 * Resolves once listening.
 */
export async function createRemoteControlServer(port, opts = {}) {
    const tcpPort = opts.port ?? 3113;
    const host = opts.host ?? "127.0.0.1";
    const token = opts.token ?? "";
    const approvalChannel = opts.approvalChannel;
    const allowedOrigins = opts.allowedOrigins ?? [
        "https://preste.vauban.tech",
        "http://localhost:5180",
        "http://localhost:5173",
    ];
    const allowAllOrigins = allowedOrigins.includes("*");
    if (!LOOPBACK.has(host) && token === "") {
        throw new Error("remote-control: a non-loopback host requires a token — refusing to start unauthenticated.");
    }
    const sseClients = new Set();
    const persistence = opts.persistence;
    // T6f revocation — in-memory jti deny-list, optionally mirrored by the
    // configured PersistencePort (P7). On boot, we hydrate from the store
    // so revocations survive restarts ; on revoke, the in-memory write +
    // the durable write are sequenced so the operator's 200 response is
    // a real durability promise, not just an in-memory ack.
    //
    // Why fail-closed on persistence load failure : a partial deny-list is
    // a security regression. If the store cannot be read, the server must
    // refuse to start (the caller can clear persistence or restart with a
    // fresh store path).
    const revokedJtis = new Set();
    if (persistence) {
        const existing = await persistence.loadRevocations();
        for (const jti of existing)
            revokedJtis.add(jti);
    }
    // P1b — DPoP replay store. Defaults to an in-process bounded store ;
    // host can inject its own (Redis, SQLite) for multi-pod deployments.
    const dpopStore = opts.dpopReplayStore ?? new DpopReplayStore();
    const publicBaseUrl = opts.publicBaseUrl;
    // CORS — echo the request Origin when it's in the allowlist (or when "*"
    // is configured). Without this, the static PWA at preste.vauban.tech
    // cannot fetch/EventSource against an LAN preste --remote server.
    const corsHeadersFor = (req) => {
        const origin = req.headers.origin;
        if (!origin)
            return {};
        const allowed = allowAllOrigins || allowedOrigins.includes(origin) ? origin : null;
        if (!allowed)
            return {};
        return {
            "Access-Control-Allow-Origin": allowed,
            Vary: "Origin",
            "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
            "Access-Control-Allow-Headers": "Authorization, Content-Type",
            "Access-Control-Max-Age": "86400",
        };
    };
    const server = createServer(async (req, res) => {
        const url = req.url ?? "/";
        // Split pathname from query string once — route matching uses `pathname`
        // (exact-match safe even with ?token= / ?since=), while the auth gate
        // still inspects the full `url` for query params.
        const qIdx = url.indexOf("?");
        const pathname = qIdx === -1 ? url : url.slice(0, qIdx);
        const method = req.method ?? "GET";
        const corsHeaders = corsHeadersFor(req);
        // Mirror CORS headers on every response by patching writeHead.
        const origWriteHead = res.writeHead.bind(res);
        res.writeHead = ((statusCode, headers) => {
            const merged = { ...corsHeaders, ...(headers ?? {}) };
            return origWriteHead(statusCode, merged);
        });
        // OPTIONS preflight — answer with the CORS headers and 204 No Content.
        if (method === "OPTIONS") {
            res.writeHead(204);
            res.end();
            return;
        }
        // Liveness probe — intentionally unauthenticated. It returns only
        // {status:"ok"}, no session data, so a client / monitor can confirm the
        // server is up before it has the token.
        if (method === "GET" && pathname === "/remote/health") {
            res.writeHead(200, { "Content-Type": "application/json" });
            res.end(JSON.stringify({ status: "ok" }));
            return;
        }
        // Auth gate — every other route. When no token is configured (loopback
        // dev mode), grants "full" scope by default. Otherwise resolves the
        // presented Bearer to its effective scope (parent → full, valid
        // sub-token → its declared scope, else 401).
        //
        // The token can be presented via the Authorization: Bearer header OR
        // a ?token=<token> query string parameter. The latter is required by
        // `EventSource` (browser SSE) which CANNOT set custom headers.
        let effectiveScope;
        if (token === "") {
            effectiveScope = "full";
        }
        else {
            const auth = req.headers.authorization ?? "";
            const headerToken = auth.startsWith("Bearer ") ? auth.slice(7) : "";
            let queryToken = "";
            if (!headerToken) {
                const qsMatch = url.match(/[?&]token=([^&]+)/);
                if (qsMatch?.[1]) {
                    try {
                        queryToken = decodeURIComponent(qsMatch[1]);
                    }
                    catch {
                        queryToken = "";
                    }
                }
            }
            const presented = headerToken || queryToken;
            const resolved = resolveAuthScopeInstrumented(token, presented, Date.now(), (jti) => revokedJtis.has(jti));
            if (!resolved) {
                res.writeHead(401, { "Content-Type": "application/json" });
                res.end(JSON.stringify({ error: "unauthorized" }));
                return;
            }
            effectiveScope = resolved;
            // P1b — DPoP gate for device-bound sub-tokens. When the verified
            // sub-token carries a `cnf` claim, the bearer alone is not enough ;
            // the request MUST also include a fresh `DPoP: <jwt>` header signed
            // by the bound private key.
            //
            // Skip the gate when the presented token IS the parent (full scope)
            // — the parent token is owned by the founder's local process, not
            // a remote device.
            if (presented !== token) {
                const subVerification = verifySubToken(token, presented, Date.now());
                if (subVerification.valid && subVerification.cnf) {
                    const dpopHeader = req.headers.dpop;
                    const dpopJwt = Array.isArray(dpopHeader)
                        ? dpopHeader[0]
                        : dpopHeader;
                    if (typeof dpopJwt !== "string" || dpopJwt === "") {
                        res.writeHead(401, { "Content-Type": "application/json" });
                        res.end(JSON.stringify({
                            error: "dpop_required",
                            detail: "sub-token carries cnf claim ; DPoP header required",
                        }));
                        return;
                    }
                    // Reconstruct the expected htu. Prefer the configured public
                    // base URL ; fall back to request headers.
                    const base = publicBaseUrl
                        ? publicBaseUrl.replace(/\/$/, "")
                        : (() => {
                            const proto = req.headers["x-forwarded-proto"] ??
                                "http";
                            const reqHost = req.headers["x-forwarded-host"] ??
                                req.headers.host ??
                                `${host}:${tcpPort}`;
                            return `${proto}://${reqHost}`;
                        })();
                    const expectedHtu = `${base}${pathname}`;
                    const dpopResult = await validateDpopProof(dpopJwt, {
                        expectedJkt: subVerification.cnf.jkt,
                        expectedHtm: method.toUpperCase(),
                        expectedHtu,
                        accessToken: presented,
                        isReplayed: (jti) => dpopStore.isReplayed(jti),
                        recordJti: (jti, exp) => dpopStore.record(jti, exp),
                    });
                    if (!dpopResult.valid) {
                        res.writeHead(401, { "Content-Type": "application/json" });
                        res.end(JSON.stringify({
                            error: "dpop_invalid",
                            detail: dpopResult.reason,
                        }));
                        return;
                    }
                }
            }
        }
        // Per-route scope check helper.
        const requireScope = (required) => {
            if (scopeCovers(effectiveScope, required))
                return true;
            res.writeHead(403, { "Content-Type": "application/json" });
            res.end(JSON.stringify({
                error: "forbidden",
                required,
                granted: effectiveScope,
            }));
            return false;
        };
        if (method === "GET" && pathname === "/remote/state") {
            if (!requireScope("read-only"))
                return;
            res.writeHead(200, { "Content-Type": "application/json" });
            res.end(JSON.stringify(port.state()));
            return;
        }
        // GET /remote/stream[?since=N] — SSE
        if (method === "GET" && pathname === "/remote/stream") {
            if (!requireScope("read-only"))
                return;
            res.writeHead(200, {
                "Content-Type": "text/event-stream",
                "Cache-Control": "no-cache",
                Connection: "keep-alive",
            });
            const sinceMatch = url.match(/[?&]since=(\d+)/);
            const since = sinceMatch ? Number.parseInt(sinceMatch[1], 10) : undefined;
            // Backfill — gap-free reconnection.
            for (const event of port.backlog(since)) {
                res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
            }
            const unsubscribe = port.subscribe((event) => {
                try {
                    res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
                }
                catch {
                    sseClients.delete(res);
                }
            });
            sseClients.add(res);
            req.on("close", () => {
                unsubscribe();
                sseClients.delete(res);
            });
            return;
        }
        // POST /remote/inject — {text, whisper?}
        if (method === "POST" && pathname === "/remote/inject") {
            if (!requireScope("full"))
                return;
            void readBody(req).then((body) => {
                let text = "";
                let whisper = false;
                let source = "remote";
                try {
                    const p = body ? JSON.parse(body) : {};
                    text = String(p.text ?? "");
                    whisper = p.whisper === true;
                    if (typeof p.source === "string")
                        source = p.source;
                }
                catch {
                    /* malformed body → empty text → 400 below */
                }
                if (text.trim() === "") {
                    res.writeHead(400, { "Content-Type": "application/json" });
                    res.end(JSON.stringify({ error: "text is required" }));
                    return;
                }
                port.inbox.enqueue(text, source, whisper);
                res.writeHead(200, { "Content-Type": "application/json" });
                res.end(JSON.stringify({ queued: true, pending: port.inbox.size() }));
            });
            return;
        }
        // POST /remote/veto/:callId — {by?, reason?}  (T6a veto window)
        const vetoMatch = pathname.match(/^\/remote\/veto\/([^/]+)$/);
        if (method === "POST" && vetoMatch) {
            if (!requireScope("approve-only"))
                return;
            const [, callId] = vetoMatch;
            void readBody(req).then((body) => {
                let by = "remote-http";
                let reason;
                try {
                    const p = body ? JSON.parse(body) : {};
                    if (typeof p.by === "string" && p.by.trim() !== "")
                        by = p.by;
                    if (typeof p.reason === "string")
                        reason = p.reason;
                }
                catch {
                    /* tolerate empty/malformed body — defaults still meaningful */
                }
                port.vetoChannel.submit({
                    callId: callId,
                    by,
                    ...(reason ? { reason } : {}),
                    at: new Date().toISOString(),
                });
                res.writeHead(200, { "Content-Type": "application/json" });
                res.end(JSON.stringify({ vetoed: true, callId }));
            });
            return;
        }
        // POST /remote/hitl/:id/approve | reject
        const hitlMatch = pathname.match(/^\/remote\/hitl\/([^/]+)\/(approve|reject)$/);
        if (method === "POST" && hitlMatch) {
            if (!requireScope("approve-only"))
                return;
            const [, id, kind] = hitlMatch;
            if (!approvalChannel) {
                res.writeHead(409, { "Content-Type": "application/json" });
                res.end(JSON.stringify({ error: "no HITL channel on this session" }));
                return;
            }
            void readBody(req).then((body) => {
                let note = "";
                try {
                    const p = body ? JSON.parse(body) : {};
                    note = String(p.note ?? p.reason ?? "");
                }
                catch {
                    /* empty note is fine */
                }
                const ok = approvalChannel.resolve(id, kind === "approve", "remote", note);
                res.writeHead(ok ? 200 : 404, { "Content-Type": "application/json" });
                res.end(JSON.stringify(ok
                    ? { id, verdict: kind }
                    : { error: "unknown or already resolved" }));
            });
            return;
        }
        // POST /remote/revoke/:jti — full-scope only (T6f revocation).
        // Adds the jti to the in-memory deny-list AND (when persistence is
        // configured, P7) writes durably before responding 200. Idempotent —
        // re-revoking is a no-op.
        const revokeMatch = pathname.match(/^\/remote\/revoke\/([A-Za-z0-9_-]+)$/);
        if (method === "POST" && revokeMatch) {
            if (!requireScope("full"))
                return;
            const [, jti] = revokeMatch;
            const already = revokedJtis.has(jti);
            revokedJtis.add(jti);
            // Durable write before 200. If this fails, surface the error to the
            // operator — a 200 must mean "durable" when persistence is on, else
            // a kill-restart loop would silently re-validate the token.
            if (persistence && !already) {
                try {
                    await persistence.saveRevocation(jti);
                }
                catch (err) {
                    revokedJtis.delete(jti);
                    res.writeHead(503, { "Content-Type": "application/json" });
                    res.end(JSON.stringify({
                        error: "persistence.saveRevocation failed",
                        detail: err instanceof Error ? err.message : String(err),
                    }));
                    return;
                }
            }
            // P11 — record the revocation as an OTEL span + counter.
            recordRevocation(jti, already);
            res.writeHead(200, { "Content-Type": "application/json" });
            res.end(JSON.stringify({
                revoked: true,
                jti,
                alreadyRevoked: already,
                totalRevoked: revokedJtis.size,
                durable: persistence !== undefined,
            }));
            return;
        }
        // GET /remote/revocations — full-scope, list current jtis (debugability).
        if (method === "GET" && pathname === "/remote/revocations") {
            if (!requireScope("full"))
                return;
            res.writeHead(200, { "Content-Type": "application/json" });
            res.end(JSON.stringify({ jtis: Array.from(revokedJtis) }));
            return;
        }
        // POST /remote/bind-device — upgrade a bearer sub-token to device-bound (P1b-2).
        //
        // Input  : { jwk: EcP256Jwk } (the device's WebCrypto public key).
        // Auth   : Authorization: Bearer <existing sub-token without cnf>.
        //          Parent token rejected (the founder process is not a device).
        // Effect : mint a new sub-token with cnf.jkt = thumbprint(jwk), revoke
        //          the old jti durably (via the configured PersistencePort).
        // Output : { subToken, expiresAt, scope, oldJti, newJti }.
        if (method === "POST" && pathname === "/remote/bind-device") {
            if (token === "") {
                res.writeHead(409, { "Content-Type": "application/json" });
                res.end(JSON.stringify({
                    error: "bind_device_requires_token",
                    detail: "loopback-no-token mode cannot mint device-bound tokens",
                }));
                return;
            }
            // Re-extract the presented Bearer (the auth gate consumed it but the
            // local was not lifted to this scope). Mirroring the gate's logic
            // keeps the route self-contained.
            const auth2 = req.headers.authorization ?? "";
            const presented = auth2.startsWith("Bearer ") ? auth2.slice(7) : "";
            if (presented === "" || presented === token) {
                res.writeHead(403, { "Content-Type": "application/json" });
                res.end(JSON.stringify({
                    error: "bind_device_requires_sub_token",
                    detail: "parent token cannot be device-bound",
                }));
                return;
            }
            const body = await readBody(req);
            let jwk;
            try {
                const p = body ? JSON.parse(body) : {};
                if (p && typeof p.jwk === "object" && p.jwk !== null) {
                    jwk = p.jwk;
                }
            }
            catch {
                /* malformed body → handled below */
            }
            if (!jwk) {
                res.writeHead(400, { "Content-Type": "application/json" });
                res.end(JSON.stringify({ error: "jwk required in body" }));
                return;
            }
            let result;
            try {
                result = rebindSubToken({
                    parentToken: token,
                    oldSubToken: presented,
                    jwk,
                });
            }
            catch (err) {
                const code = err.code ?? "rebind_failed";
                const detail = err instanceof Error ? err.message : String(err);
                res.writeHead(400, { "Content-Type": "application/json" });
                res.end(JSON.stringify({ error: code, detail }));
                return;
            }
            // Atomic revocation : in-memory write, then durable write before 200.
            // On persistence failure, roll the in-memory state back — the caller
            // still has a usable bearer sub-token until they retry.
            const alreadyRevoked = revokedJtis.has(result.oldJti);
            revokedJtis.add(result.oldJti);
            if (persistence && !alreadyRevoked) {
                try {
                    await persistence.saveRevocation(result.oldJti);
                }
                catch (err) {
                    revokedJtis.delete(result.oldJti);
                    res.writeHead(503, { "Content-Type": "application/json" });
                    res.end(JSON.stringify({
                        error: "persistence.saveRevocation failed",
                        detail: err instanceof Error ? err.message : String(err),
                    }));
                    return;
                }
            }
            recordRevocation(result.oldJti, alreadyRevoked);
            res.writeHead(200, { "Content-Type": "application/json" });
            res.end(JSON.stringify({
                subToken: result.subToken,
                expiresAt: result.expiresAt,
                scope: result.scope,
                oldJti: result.oldJti,
                newJti: result.newJti,
                durable: persistence !== undefined,
            }));
            return;
        }
        res.writeHead(404, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ error: "not found" }));
    });
    await new Promise((resolve, reject) => {
        server.once("error", reject);
        server.listen(tcpPort, host, () => {
            server.removeListener("error", reject);
            resolve();
        });
    });
    // Resolve the actual bound port — when `tcpPort` was 0 (test mode), the
    // OS assigns a free port and `server.address()` is the only source of truth.
    const addr = server.address();
    const boundPort = addr && typeof addr === "object" && "port" in addr ? addr.port : tcpPort;
    return {
        url: `http://${host}:${boundPort}`,
        async close() {
            for (const res of sseClients) {
                try {
                    res.end();
                }
                catch {
                    /* ignore */
                }
            }
            sseClients.clear();
            await new Promise((resolve) => server.close(() => resolve()));
        },
    };
}
//# sourceMappingURL=http-server.js.map