// ---------------------------------------------------------------------------
// platform-proxy.ts — forward unhandled /api/v1/skills/* requests from the
// Skill Studio (eval-server, port-hashed e.g. 3162) to the vskill-platform
// (default https://verified-skill.com — the production worker — for parity
//  with src/api/client.ts:10 DEFAULT_BASE_URL).
//
// Why this exists (0712 US-003 follow-up T-016A/B; default re-targeted in 0725):
//   The studio frontend (src/eval-ui) issues *relative* fetches such as
//   `/api/v1/skills/check-updates` and `/api/v1/skills/stream`. Those land
//   on the eval-server itself, which has no handler for them and returns
//   `{"error":"Not found"}` 404. Production deploys the studio behind the
//   platform (same origin), so relative URLs resolve to the platform; in
//   local dev the studio is served by the eval-server CLI (`vskill studio`),
//   which is a *different* process from the platform. This helper closes
//   the gap by transparently proxying any unhandled `/api/v1/skills/*` path
//   to the platform target.
//
// Design notes:
//   - Target URL is configurable via `VSKILL_PLATFORM_URL` env (default
//     `https://verified-skill.com`). Local-platform devs running their own
//     `wrangler dev` opt back in via `VSKILL_PLATFORM_URL=http://localhost:3017`.
//     Must include scheme + host (port optional for https).
//   - Method, headers (minus hop-by-hop), query string, and request body
//     stream are forwarded verbatim. Response status, headers, and body
//     stream are piped back so SSE (`text/event-stream`) connections stay
//     long-lived without buffering.
//   - Path prefix is preserved exactly (`/api/v1/skills/...`) — the
//     platform routes match the same paths.
//   - Connection failures upstream return a `502 Bad Gateway` JSON
//     envelope so the studio's fetch error handlers see a structured
//     response rather than a hung socket.
//
// SSE subscription ID-format contract (0736 / AC-US3-04)
// -------------------------------------------------------
// /api/v1/skills/stream is forwarded verbatim to the platform's UpdateHub.
// The `?skills=<csv>` query param MUST contain UUID (`Skill.id`) or public
// slug (`sk_published_<owner>/<repo>/<skill>`) identifiers — NOT the studio's
// local `<plugin>/<skill>` name format (e.g. `.claude/greet-anton`).
//
// The studio side resolves platform IDs via the `resolveSubscriptionIds()`
// helper (src/eval-ui/src/utils/resolveSubscriptionIds.ts) before opening
// the EventSource. Skills without a resolvable UUID or slug are omitted from
// the filter; the polling fallback (usePluginsPolling) covers them so no
// updates are lost. See useSkillUpdates.ts for the full hook-side contract.
//
// This proxy does NOT transform or validate the `?skills=` parameter — it
// forwards exactly what the studio sends. Correctness is the studio's
// responsibility (enforced in resolveSubscriptionIds + useSkillUpdates).
// ---------------------------------------------------------------------------
import * as http from "node:http";
import * as https from "node:https";
import { randomUUID } from "node:crypto";
import { getDefaultKeychain } from "../lib/keychain.js";
const DEFAULT_PLATFORM_URL = "https://verified-skill.com";
// Hop-by-hop headers per RFC 2616 §13.5.1 — never forward these on a proxy.
const HOP_BY_HOP = new Set([
    "connection",
    "keep-alive",
    "proxy-authenticate",
    "proxy-authorization",
    "te",
    "trailers",
    "transfer-encoding",
    "upgrade",
    "host",
    "content-length",
]);
export function getPlatformBaseUrl() {
    const raw = process.env.VSKILL_PLATFORM_URL;
    if (typeof raw === "string" && raw.length > 0) {
        return raw.replace(/\/$/, "");
    }
    return DEFAULT_PLATFORM_URL;
}
/**
 * Decide whether a given request URL should be proxied to the platform.
 *
 * Forwarded prefixes:
 *   - /api/v1/skills/*           — original contract (skill metadata, versions, stream)
 *   - /api/v1/studio/search      — find-palette query endpoint (0741 US-005)
 *   - /api/v1/studio/telemetry/  — fire-and-forget palette telemetry (0741 US-005)
 *   - /api/v1/stats              — trending fallback for empty queries (0741 US-005)
 *
 * Other `/api/...` paths remain owned by the eval-server (e.g. `/api/config`,
 * `/api/skills` legacy local route, `/api/agents`, `/api/workspace`).
 */
const PROXY_PREFIXES = [
    "/api/v1/skills/",
    "/api/v1/studio/search",
    "/api/v1/studio/telemetry/",
    "/api/v1/stats",
    // Private (org-scoped) routes that must carry the user's GitHub bearer token
    // to the platform. The browser never sees this token; injection happens here.
    "/api/v1/private/",
    "/api/v1/tenants/",
    // 0836 US-003: account cabinet routes were previously fetched directly from
    // the WebView with `Authorization: Bearer <gho_*>` minted by the deleted
    // `account_get_token` IPC. Proxying them through the eval-server lets us
    // inject the bearer Rust-side from the keychain — the WebView never holds
    // the token. ALL /api/v1/account/* routes (profile, tokens, repos,
    // notifications, exports, delete, sign-out-all) are user-authenticated;
    // they all match AUTH_REQUIRED_PREFIXES below. If a future public
    // marketing route is added, route it under a different prefix (not
    // /api/v1/account/) so it doesn't pick up the bearer.
    "/api/v1/account/",
];
/**
 * Path prefixes that require an `Authorization: Bearer <github-token>` header
 * to be injected at the proxy boundary. Public skill routes are intentionally
 * excluded — they must remain anonymous so unauthenticated tabs continue to
 * function in the public skill catalog.
 */
const AUTH_REQUIRED_PREFIXES = [
    "/api/v1/private/",
    "/api/v1/tenants/",
    // 0836 US-003: every /api/v1/account/* route requires the user's bearer
    // (profile, repos, tokens, notifications, exports). Inject it here so the
    // WebView never sees a `gho_*` value.
    "/api/v1/account/",
];
export function shouldProxyToPlatform(url) {
    if (!url)
        return false;
    return PROXY_PREFIXES.some((p) => url.startsWith(p));
}
export function shouldInjectAuth(url) {
    if (!url)
        return false;
    return AUTH_REQUIRED_PREFIXES.some((p) => url.startsWith(p));
}
// In-process token cache so a burst of proxy requests doesn't repeatedly hit
// the OS keychain. The keychain itself is fast on macOS but can be slower on
// Linux libsecret.
//
// 0836 hardening (F-005): TTL kept intentionally short (5s) so that a desktop
// `sign_out` IPC — which clears the Rust keychain in a separate process —
// is reflected by the Node-side eval-server within at most 5 seconds.
// Cross-process cache invalidation would require a control-channel from the
// Tauri shell into the sidecar; bounding the staleness window achieves the
// same UX guarantee with no extra plumbing.
let _cachedToken = null;
const TOKEN_CACHE_MS = 5_000;
function readTokenForProxy(now = Date.now()) {
    if (_cachedToken && _cachedToken.expiresAt > now)
        return _cachedToken.value;
    let token = null;
    try {
        token = getDefaultKeychain().getGitHubToken();
    }
    catch {
        token = null;
    }
    _cachedToken = { value: token, expiresAt: now + TOKEN_CACHE_MS };
    return token;
}
/**
 * Public invalidation hook so callers (e.g. an HTTP control endpoint, or a
 * future SIGUSR1 handler) can drop the cached token immediately on auth
 * state changes. Tests use this to reset between cases as well.
 */
export function invalidatePlatformProxyTokenCache() {
    _cachedToken = null;
}
/** @deprecated Prefer `invalidatePlatformProxyTokenCache`. Kept for any
 * existing test imports until they migrate. */
export function _resetPlatformProxyAuthCacheForTests() {
    invalidatePlatformProxyTokenCache();
}
export function pickHeadersForUpstream(src, opts = {}) {
    const out = {};
    for (const [k, v] of Object.entries(src)) {
        if (typeof v === "undefined")
            continue;
        if (HOP_BY_HOP.has(k.toLowerCase()))
            continue;
        // Strip any client-supplied Authorization on private/tenant paths — we
        // mint our own from the keychain. On other paths we pass through (the
        // existing public proxy never carried Authorization, so this is a no-op).
        if (k.toLowerCase() === "authorization" && opts.path && shouldInjectAuth(opts.path)) {
            continue;
        }
        out[k] = Array.isArray(v) ? v.join(", ") : String(v);
    }
    // Preserve client-IP intent for any future platform-side observability.
    const xff = out["x-forwarded-for"];
    out["x-forwarded-for"] = xff ? `${xff}, 127.0.0.1` : "127.0.0.1";
    if (opts.path && shouldInjectAuth(opts.path)) {
        const tokenProvider = opts.tokenProvider ?? (() => readTokenForProxy());
        const token = tokenProvider();
        if (token) {
            out["authorization"] = `Bearer ${token}`;
        }
    }
    return out;
}
function pickHeadersForDownstream(src) {
    const out = {};
    for (const [k, v] of Object.entries(src)) {
        if (typeof v === "undefined")
            continue;
        if (HOP_BY_HOP.has(k.toLowerCase()))
            continue;
        out[k] = v;
    }
    return out;
}
/**
 * Determine whether a given request/response pair is on the SSE path.
 *
 * Heuristic (cheap — runs on every request when VSKILL_DEBUG_SSE=1):
 *   - Path ends with `/stream` (e.g. `/api/v1/skills/stream`).
 *   - OR upstream response carries `content-type: text/event-stream`.
 *
 * The path heuristic alone is sufficient for the only SSE endpoint the studio
 * proxies today (`/api/v1/skills/stream`). The content-type fallback covers
 * forward-compat in case future SSE endpoints are added without a `/stream`
 * suffix.
 */
function isSsePathOrResponse(pathname, responseContentType) {
    if (pathname.endsWith("/stream"))
        return true;
    if (typeof responseContentType === "string" &&
        responseContentType.toLowerCase().includes("text/event-stream")) {
        return true;
    }
    return false;
}
/** Whether structured per-request SSE logging is currently enabled. */
function isSseDebugEnabled() {
    return process.env.VSKILL_DEBUG_SSE === "1";
}
/**
 * Proxy a single HTTP request from the studio to the platform.
 * SSE-safe: streams the upstream body chunks directly to the response so
 * `text/event-stream` connections stay open.
 *
 * Resolves once the proxy pipeline finishes (either side ended) — callers
 * can `await` to know the response has been written, but typical use is
 * fire-and-forget within an HTTP server handler.
 *
 * SSE diagnostic logging (0838 / AC-US1-02):
 *   When `VSKILL_DEBUG_SSE=1`, every `text/event-stream`-shaped request emits
 *     proxy.sse.start{requestId, upstreamUrl}  on dispatch
 *     proxy.sse.end{requestId, status, durationMs}  on completion
 *   AND the response gets `X-Request-Id: <requestId>` so client-side debug
 *   logs (in useSkillUpdates) can be correlated with server-side logs.
 *   When the flag is unset (or "0"), no per-request logs and no header
 *   injection — preserves the production hot path.
 */
export function proxyToPlatform(req, res, baseUrl = getPlatformBaseUrl()) {
    return new Promise((resolve) => {
        const target = new URL(req.url ?? "/", baseUrl);
        const transport = target.protocol === "https:" ? https : http;
        const debugEnabled = isSseDebugEnabled();
        // Pre-allocate requestId only when debug is enabled — avoids unnecessary
        // crypto work on the production hot path.
        const requestId = debugEnabled ? randomUUID() : null;
        const startTime = debugEnabled ? Date.now() : 0;
        // Path-based heuristic for SSE detection (cheap + sufficient for /stream).
        let isSseRequest = debugEnabled && target.pathname.endsWith("/stream");
        let logEndEmitted = false;
        // Captured for client-disconnect cleanup. Once headers are received the
        // active socket is held by upstreamRes (an SSE stream may keep it open
        // for hours); destroying upstreamReq alone is not enough on Node's https
        // agent in all cases.
        let upstreamRes = null;
        function emitEndLog(status) {
            if (!debugEnabled || !isSseRequest || logEndEmitted || !requestId)
                return;
            logEndEmitted = true;
            const durationMs = Date.now() - startTime;
            console.log("proxy.sse.end", { requestId, status, durationMs });
        }
        const upstreamReq = transport.request({
            protocol: target.protocol,
            hostname: target.hostname,
            port: target.port || (target.protocol === "https:" ? 443 : 80),
            path: `${target.pathname}${target.search}`,
            method: req.method,
            headers: pickHeadersForUpstream(req.headers, { path: target.pathname }),
        }, (incoming) => {
            upstreamRes = incoming;
            const status = incoming.statusCode ?? 502;
            const headers = pickHeadersForDownstream(incoming.headers);
            // Promote to SSE if upstream content-type confirms it (covers future
            // SSE endpoints without a `/stream` suffix).
            if (debugEnabled && !isSseRequest) {
                const ct = incoming.headers["content-type"];
                isSseRequest = isSsePathOrResponse(target.pathname, Array.isArray(ct) ? ct[0] : ct);
            }
            // Inject X-Request-Id so studio debug logs can correlate to server.
            if (debugEnabled && isSseRequest && requestId) {
                headers["x-request-id"] = requestId;
                // Emit the start log only once we know this IS an SSE request.
                console.log("proxy.sse.start", {
                    requestId,
                    upstreamUrl: `${target.origin}${target.pathname}${target.search}`,
                });
            }
            try {
                res.writeHead(status, headers);
            }
            catch {
                // Headers already sent — fall through to pipe; `res.write` will
                // continue on the existing stream.
            }
            incoming.on("end", () => {
                emitEndLog(status);
                resolve();
            });
            incoming.on("error", () => {
                emitEndLog(status);
                if (!res.writableEnded)
                    res.end();
                resolve();
            });
            incoming.pipe(res);
        });
        upstreamReq.on("error", (err) => {
            if (!res.headersSent) {
                res.writeHead(502, { "content-type": "application/json" });
            }
            if (!res.writableEnded) {
                res.end(JSON.stringify({
                    error: "platform_unreachable",
                    message: `vskill-platform proxy failed: ${err.message}`,
                    target: target.origin,
                }));
            }
            emitEndLog(502);
            resolve();
        });
        // Close upstream cleanly when the client disconnects (esp. EventSource
        // unmounts) so we don't leak sockets on long-lived SSE streams.
        // We tear down BOTH the request and the response — the response holds
        // the socket once headers have been received.
        res.on("close", () => {
            try {
                upstreamReq.destroy();
            }
            catch { /* noop */ }
            try {
                upstreamRes?.destroy();
            }
            catch { /* noop */ }
            emitEndLog(upstreamRes?.statusCode ?? 0);
        });
        // Forward the request body (if any) — req is a readable stream.
        // Contract: callers MUST NOT pre-read the body before reaching this
        // proxy, otherwise pipe sends nothing. No middleware in eval-server
        // currently does, but new ones must respect this.
        req.pipe(upstreamReq);
    });
}
export async function submitSkillUpdateEvent(input, opts = {}) {
    const internalKey = opts.internalKey ?? process.env.INTERNAL_BROADCAST_KEY;
    if (!internalKey) {
        return { submitted: false, reason: "no_internal_key" };
    }
    const baseUrl = opts.baseUrl ?? getPlatformBaseUrl();
    const fetchImpl = opts.fetchImpl ?? fetch;
    const eventId = randomUUID();
    const event = {
        type: "skill.updated",
        eventId,
        skillId: input.skillId,
        version: input.version,
        gitSha: input.gitSha ?? `local-${Date.now().toString(16)}`,
        publishedAt: input.publishedAt ?? new Date().toISOString(),
        ...(input.diffSummary ? { diffSummary: input.diffSummary } : {}),
    };
    try {
        const resp = await fetchImpl(`${baseUrl}/api/v1/internal/skills/publish`, {
            method: "POST",
            headers: {
                "Content-Type": "application/json",
                "X-Internal-Key": internalKey,
            },
            body: JSON.stringify(event),
        });
        if (resp.ok) {
            return { submitted: true, status: resp.status, eventId };
        }
        const text = await resp.text().catch(() => "");
        return {
            submitted: false,
            reason: "platform_error",
            status: resp.status,
            message: text || resp.statusText,
        };
    }
    catch (err) {
        return {
            submitted: false,
            reason: "platform_error",
            message: err.message,
        };
    }
}
//# sourceMappingURL=platform-proxy.js.map