// Canonical reconstruction of the app control-API base a caller reached us on (e.g. // "https://host/app/api"), so an operation can rewrite its embedded examples to THIS instance and // keep them copy-pasteable. One implementation shared by every /app/api operation that keys output // to the request base (getAgentInstructions, getAgentSkill, …) — per AGENTS.md "Derivation over // duplication: no drift surfaces", proxy-header handling and base-path stripping must not fork. // // Honour reverse-proxy forwarding headers — proto, host, and the external path prefix // (X-Forwarded-Prefix, e.g. the console app-view proxy's "/console/app-view/{project}") — and fall // back to a localhost default when the Host header is absent (e.g. a raw unit-test request). /** * Recover the control-API base from a request, stripping the operation's own mount suffix. * * @param req the request (path + headers) * @param mountSuffix the operation's path suffix to strip to recover the base, e.g. "agent" or * "agent/skill" (with or without a leading slash). The base defaults to * "/app/api" when the path is nothing but the suffix. */ export function resolveApiBase(req: { path: string; headers: Headers }, mountSuffix: string): string { const { proto, host } = requestProtoHost(req); const prefix = sanitiseForwardedPrefix(req.headers.get("x-forwarded-prefix")); // The op is mounted at "/"; strip the trailing segments to recover the base path. const suffix = mountSuffix.replace(/^\/+/, "").replace(/\/+$/, ""); const stripRe = new RegExp(`/${suffix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/*$`); const basePath = req.path.replace(stripRe, "") || "/app/api"; return host ? `${proto}://${host}${prefix}${basePath}` : `http://localhost:3000${prefix}${basePath}`; } /** The public ORIGIN (+ proxy prefix) this request arrived on — the base for a navigational link * handed back to the caller (e.g. a cockpit deep-link), WITHOUT the `/app/api` mount suffix that * {@link resolveApiBase} keeps. Where `resolveApiBase` reconstructs the control-API base an *agent* * calls back on, this reconstructs the human-facing origin: proto + host (same proxy-header trust as * `resolveApiBase`) plus any reverse-proxy path prefix advertised via `x-forwarded-prefix`, so a * link built as `${resolvePublicOrigin(req)}/app/pages/…` opens on the exact origin the operator is * driving this app from (e.g. a tunnel), not a static deployment-wide base. Falls back to a * localhost origin when the Host header is absent (a raw unit-test request). */ export function resolvePublicOrigin(req: { path: string; headers: Headers }): string { const { proto, host } = requestProtoHost(req); const prefix = sanitiseForwardedPrefix(req.headers.get("x-forwarded-prefix")); return host ? `${proto}://${host}${prefix}` : `http://localhost:3000${prefix}`; } /** Sanitise the untrusted, proxy/user-controlled `x-forwarded-prefix` into a safe leading-slash, * no-trailing-slash path segment (or ""). The ONE canonical prefix sanitiser shared by * {@link resolveApiBase} and {@link resolvePublicOrigin} (AGENTS.md "derivation over duplication") — * the prefix is the reverse-proxy path the public URL was mounted under (e.g. * "/console/app-view/Workforce") and is reflected into a caller-facing URL, so it must not smuggle a * scheme, an authority ("//host"), or a "."/".." traversal segment into the URL. Accept only an * absolute path of URL-safe path characters, then drop trailing slashes so it composes cleanly with * the base path; anything else falls back to an empty prefix. Percent-encoding can smuggle those * forms past a literal check ("%2e%2e" decodes to "..", "%2f%2f" to an authority-introducing "//"), * so normalise the common encoded spellings of "." and "/" (case-insensitively) before rejecting * dot-segments and "//"; the still-encoded raw value is what we reflect once it validates. Because * the return is always either "" or a leading-"/" path, it can never alter the `${proto}://${host}` * authority. */ function sanitiseForwardedPrefix(raw: string | null): string { const rawPrefix = (raw ?? "").split(",")[0].trim(); const decodedPrefix = rawPrefix.replace(/%2e/gi, ".").replace(/%2f/gi, "/"); return /^\/(?!\/)[A-Za-z0-9._~\-/%]*$/.test(rawPrefix) && !decodedPrefix.includes("//") && !/(^|\/)\.\.?(\/|$)/.test(decodedPrefix) ? rawPrefix.replace(/\/+$/, "") : ""; } /** The trusted (proto, host) pair for a request — the ONE place proxy-header handling lives so * `resolveApiBase` and `resolvePublicOrigin` can't drift (AGENTS.md "derivation over duplication"). * Only `http`/`https` are trusted from the user-controlled `x-forwarded-proto`; the host prefers * `x-forwarded-host` over `host`. `host` is "" when neither header is present or the advertised host * is not a valid authority (see {@link sanitiseHost}). */ function requestProtoHost(req: { headers: Headers }): { proto: string; host: string } { const rawProto = (req.headers.get("x-forwarded-proto") ?? "http").split(",")[0].trim().toLowerCase(); const proto = rawProto === "http" || rawProto === "https" ? rawProto : "http"; const rawHost = (req.headers.get("x-forwarded-host") ?? req.headers.get("host") ?? "").split(",")[0].trim(); return { proto, host: sanitiseHost(rawHost) }; } /** Sanitise the untrusted, proxy/user-controlled host (`x-forwarded-host`/`host`) into a bare * authority — a registered name or IPv4 with an optional `:port`, or a bracketed IPv6 literal with * an optional `:port` — or "" when it carries anything else. The host is reflected verbatim into the * `${proto}://${host}` authority of a caller-facing URL, so a hostile value like * `evil.com@real.example` (userinfo injection) or `real.example/extra-path` (path injection) must be * rejected outright rather than smuggled through. */ function sanitiseHost(host: string): string { if (!host) return ""; const valid = /^(?:[A-Za-z0-9.-]+|\[[0-9A-Fa-f:.]+\])(?::\d+)?$/.test(host); return valid ? host : ""; }