diff --git a/.changeset/dev-url-headers.md b/.changeset/dev-url-headers.md new file mode 100644 index 000000000..c571a3c8f --- /dev/null +++ b/.changeset/dev-url-headers.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Add HTTP Basic userinfo and repeatable `-H, --header` support to `eve dev` URL targets so the terminal UI can send credentials or routing headers to protected remote deployments. diff --git a/docs/guides/dev-tui.md b/docs/guides/dev-tui.md index b1b4b7a5e..57cebc4bf 100644 --- a/docs/guides/dev-tui.md +++ b/docs/guides/dev-tui.md @@ -128,7 +128,13 @@ Pass a URL and the TUI talks to a running deployment instead of starting a local eve dev https:// ``` -The bare URL is shorthand for `--url`; it cannot be combined with `--host`, `--port`, or `--no-ui`. +The bare URL is shorthand for `--url`; it cannot be combined with `--host`, `--port`, or `--no-ui`. For HTTP Basic auth, put credentials in the URL; eve sends them as a Basic `Authorization` header and strips them from the server URL before connecting: + +```bash +eve dev https://user:pass@ +``` + +For bearer tokens or custom schemes, repeat `-H, --header` to attach request headers. At startup the TUI asks Vercel to resolve the remote origin under the active scope. A resolved response is the authority for a project-scoped OIDC token—refreshing an expired development token when refresh credentials exist—or an automation-bypass secret. An unresolved host is probed anonymously. The TUI then requests `/eve/v1/info`, with a ten-second timeout. A successful response marks the remote ready. An eve OIDC challenge, Vercel Deployment Protection challenge, or `TRUSTED_SOURCES_ENVIRONMENT_MISMATCH` opens `/vc:login` automatically; ordinary network failures and server errors remain remote-availability errors and do not start an authentication flow. Esc or Ctrl-C cancels the authentication flow. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index cf6478fea..76c2d8b1f 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -91,13 +91,14 @@ eve dev [options] eve dev https://your-app.vercel.app ``` -Pass a bare URL as the only argument and the UI connects to that server instead of booting a local one (same as `--url`), which lets you smoke-test a preview or production deployment. The interactive UI turns off in a non-TTY terminal. +Pass a bare URL and the UI connects to that server instead of booting a local one (same as `--url`), which lets you smoke-test a preview or production deployment. The interactive UI turns off in a non-TTY terminal. | Flag | Type | Default | Description | | ----------------------------------- | ------ | ------------------ | ----------------------------------------------------------------------------------------- | | `--host ` | string | all interfaces | Host interface to bind | | `--port ` | number | `$PORT`, then 3000 | Port to listen on | | `-u, --url ` | string | none | Connect to an existing server URL instead of starting one | +| `-H, --header
` | string | none | Request header for a URL target, in `Name: value` form; repeat for multiple headers | | `--no-ui` | flag | UI on | Start the server without an interactive UI | | `--name ` | string | app folder name | Title shown in the terminal UI | | `--input ` | string | none | Pre-fill the prompt input; bare local `/model` starts onboarding | @@ -111,6 +112,14 @@ Pass a bare URL as the only argument and the UI connects to that server instead A fresh `eve init` passes `--input /model`. That bare local input starts onboarding: the TUI installs the Vercel CLI if needed, asks you to log in if needed, then opens `/model`. Other input stays editable in the prompt. +For a URL target protected by HTTP Basic auth, put the credentials in the URL. Eve sends them as a Basic `Authorization` header and strips them from the server URL before connecting: + +```bash +eve dev https://user:pass@your-app.example.com +``` + +For bearer tokens or custom schemes, pass explicit headers with `-H`. + Local dev records the last ready URL per resolved app root in `.eve/dev-server-state.v1.json`. A second interactive `eve dev` reconnects only when that URL is loopback and healthy; each terminal UI creates a fresh client session while sharing the server process. A stale or malformed record is replaced when eve starts a new server. Passing `--host`, `--port`, or a `PORT` environment value skips reconnection and reports a healthy recorded server instead. Local dev keeps immutable runtime source snapshots under `.eve/dev-runtime/snapshots/` so in-flight sessions hold a consistent code revision while new prompts pick up rebuilds. On startup, `eve dev` prunes stale runtime snapshots and old local sandbox templates in the background. For manual cleanup, stop `eve dev` and delete `.eve/dev-runtime/snapshots/` or `.eve/sandbox-cache/local/templates/`. diff --git a/packages/eve/src/cli/dev/tui/tui.test.ts b/packages/eve/src/cli/dev/tui/tui.test.ts index 6a13f87f7..59904c180 100644 --- a/packages/eve/src/cli/dev/tui/tui.test.ts +++ b/packages/eve/src/cli/dev/tui/tui.test.ts @@ -20,6 +20,7 @@ import { runDevelopmentTui, type DevelopmentTuiTarget } from "./tui.js"; describe("runDevelopmentTui", () => { beforeEach(() => { + vi.restoreAllMocks(); mocks.runnerOptions.length = 0; }); @@ -40,4 +41,46 @@ describe("runDevelopmentTui", () => { expect(first.client).not.toBe(second.client); expect(first.session).not.toBe(second.session); }); + + it.each([ + [ + "remote", + { + kind: "remote", + serverUrl: "https://remote.example.com/", + workspaceRoot: "/tmp/app", + }, + ], + [ + "local", + { + kind: "local", + serverUrl: "http://127.0.0.1:4321/", + workspaceRoot: "/tmp/app", + }, + ], + ] satisfies Array)( + "passes explicit headers to %s TUI client requests", + async (_name, target) => { + await runDevelopmentTui({ + headers: { + authorization: "Basic dGVzdDpzZWNyZXQ=", + "x-tenant": "acme", + }, + target, + }); + + const client = mocks.runnerOptions[0]?.client; + if (client === undefined) { + throw new Error("Expected a TUI client."); + } + + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null)); + await client.fetch("/eve/v1/info"); + + const headers = new Headers(fetchMock.mock.calls[0]?.[1]?.headers); + expect(headers.get("authorization")).toBe("Basic dGVzdDpzZWNyZXQ="); + expect(headers.get("x-tenant")).toBe("acme"); + }, + ); }); diff --git a/packages/eve/src/cli/dev/tui/tui.ts b/packages/eve/src/cli/dev/tui/tui.ts index 2694bd9ab..0b3d15127 100644 --- a/packages/eve/src/cli/dev/tui/tui.ts +++ b/packages/eve/src/cli/dev/tui/tui.ts @@ -26,6 +26,8 @@ export type { DevelopmentTuiTarget } from "./target.js"; export interface RunDevelopmentTuiInput extends TuiDisplayOptions { /** The local server or remote URL used by this TUI session. */ readonly target: DevelopmentTuiTarget; + /** Additional request headers sent by this TUI client. */ + readonly headers?: Readonly>; /** * Text to seed the prompt input with after the UI launches. A bare local * `/model` starts fresh-agent onboarding. Applies to the first prompt only. @@ -77,17 +79,20 @@ function prepareDevelopmentTarget(target: DevelopmentTuiTarget): PreparedDevelop * the inline error region rather than crashing the command. */ export async function runDevelopmentTui(input: RunDevelopmentTuiInput): Promise { - const { target, initialInput, onBootProgress, ...display } = input; + const { target, headers, initialInput, onBootProgress, ...display } = input; const prepared = prepareDevelopmentTarget(target); const { serverUrl } = target; + const headerOptions = headers === undefined ? {} : { headers }; const client = new Client( prepared.kind === "local" ? resolveLocalDevelopmentClientOptions({ + ...headerOptions, serverUrl, token: () => resolveLinkedDevelopmentOidcToken(prepared.target.workspaceRoot), }) : resolveRemoteDevelopmentClientOptions({ + ...headerOptions, serverUrl, credentials: prepared.remote.credentials, }), diff --git a/packages/eve/src/cli/run.test.ts b/packages/eve/src/cli/run.test.ts index 2ad5cf86a..5991ab613 100644 --- a/packages/eve/src/cli/run.test.ts +++ b/packages/eve/src/cli/run.test.ts @@ -25,6 +25,17 @@ async function withInteractiveTerminal(fn: () => Promise): Promise { } } +async function runInteractiveDev( + argv: string[], + runtime: NonNullable[2]> = {}, +) { + const runDevelopmentTui = vi.fn(async () => {}); + await withInteractiveTerminal(() => + runCli(argv, { error: () => {}, log: () => {} }, { ...runtime, runDevelopmentTui }), + ); + return runDevelopmentTui; +} + describe("CLI command registration", () => { it("lists the current project creation and Vercel commands", async () => { const output: string[] = []; @@ -85,15 +96,13 @@ describe("eve CLI malformed argument handling", () => { describe("eve dev --input", () => { it("forwards the initial draft to the interactive TUI", async () => { - const runDevelopmentTui = vi.fn(async () => {}); - - await withInteractiveTerminal(() => - runCli( - ["dev", "--url", "https://example.com", "--input", "/model"], - { error: () => {}, log: () => {} }, - { runDevelopmentTui }, - ), - ); + const runDevelopmentTui = await runInteractiveDev([ + "dev", + "--url", + "https://example.com", + "--input", + "/model", + ]); expect(runDevelopmentTui).toHaveBeenCalledWith( expect.objectContaining({ @@ -128,44 +137,113 @@ describe("eve dev --input", () => { }); describe("eve dev --url protocol", () => { - it("uses the local TUI credential path only for this app's running dev server", async () => { - const runDevelopmentTui = vi.fn(async () => {}); + it("lowers URL userinfo to a Basic authorization header and strips it from the target URL", async () => { + const runDevelopmentTui = await runInteractiveDev([ + "dev", + "https://test%40user:p%20ss@example.com", + ]); - await withInteractiveTerminal(() => - runCli( - ["dev", "--url", "http://127.0.0.1:2000"], - { error: () => {}, log: () => {} }, - { - isActiveDevelopmentServerForApp: async () => true, - runDevelopmentTui, + expect(runDevelopmentTui).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { + Authorization: `Basic ${btoa("test@user:p ss")}`, }, - ), + target: { + kind: "remote", + serverUrl: "https://example.com/", + workspaceRoot: process.cwd(), + }, + }), ); + }); + + it("prefers explicit authorization headers over URL userinfo", async () => { + const runDevelopmentTui = await runInteractiveDev([ + "dev", + "https://user:pass@example.com", + "-H", + "Authorization: Bearer explicit-token", + ]); expect(runDevelopmentTui).toHaveBeenCalledWith( expect.objectContaining({ + headers: { + Authorization: "Bearer explicit-token", + }, target: { - kind: "local", - serverUrl: "http://127.0.0.1:2000/", + kind: "remote", + serverUrl: "https://example.com/", workspaceRoot: process.cwd(), }, }), ); }); - it("keeps an unverified loopback URL on the remote credential path", async () => { - const runDevelopmentTui = vi.fn(async () => {}); + it("forwards repeatable request headers to the remote TUI", async () => { + const runDevelopmentTui = await runInteractiveDev([ + "dev", + "--url", + "https://example.com", + "-H", + "Authorization: Basic dGVzdDpzZWNyZXQ=", + "--header", + "X-Tenant: acme", + ]); - await withInteractiveTerminal(() => + expect(runDevelopmentTui).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { + Authorization: "Basic dGVzdDpzZWNyZXQ=", + "X-Tenant": "acme", + }, + target: { + kind: "remote", + serverUrl: "https://example.com/", + workspaceRoot: process.cwd(), + }, + }), + ); + }); + + it("rejects malformed request headers", async () => { + await expect( runCli( - ["dev", "--url", "http://127.0.0.1:2000"], + ["dev", "--url", "https://example.com", "-H", "Authorization"], { error: () => {}, log: () => {} }, - { - isActiveDevelopmentServerForApp: async () => false, - runDevelopmentTui, - }, + { runDevelopmentTui: vi.fn(async () => {}) }, ), + ).rejects.toThrow('Expected header in "Name: value" format'); + }); + + it("rejects request headers without a URL target", async () => { + await expect( + runCli(["dev", "-H", "Authorization: Bearer dev-token"], { + error: () => {}, + log: () => {}, + }), + ).rejects.toThrow("The --header option can only be used with --url or a bare URL."); + }); + + it("uses the local TUI credential path only for this app's running dev server", async () => { + const runDevelopmentTui = await runInteractiveDev(["dev", "--url", "http://127.0.0.1:2000"], { + isActiveDevelopmentServerForApp: async () => true, + }); + + expect(runDevelopmentTui).toHaveBeenCalledWith( + expect.objectContaining({ + target: { + kind: "local", + serverUrl: "http://127.0.0.1:2000/", + workspaceRoot: process.cwd(), + }, + }), ); + }); + + it("keeps an unverified loopback URL on the remote credential path", async () => { + const runDevelopmentTui = await runInteractiveDev(["dev", "--url", "http://127.0.0.1:2000"], { + isActiveDevelopmentServerForApp: async () => false, + }); expect(runDevelopmentTui).toHaveBeenCalledWith( expect.objectContaining({ @@ -195,15 +273,13 @@ describe("eve eval --url protocol", () => { describe("eve dev --logs", () => { it("accepts sandbox as the initial TUI log mode", async () => { - const runDevelopmentTui = vi.fn(async () => {}); - - await withInteractiveTerminal(() => - runCli( - ["dev", "--url", "https://example.com", "--logs", "sandbox"], - { error: () => {}, log: () => {} }, - { runDevelopmentTui }, - ), - ); + const runDevelopmentTui = await runInteractiveDev([ + "dev", + "--url", + "https://example.com", + "--logs", + "sandbox", + ]); expect(runDevelopmentTui).toHaveBeenCalledWith( expect.objectContaining({ @@ -273,11 +349,7 @@ describe("eve dev local server ownership", () => { }), close: async () => {}, })); - const runDevelopmentTui = vi.fn(async () => {}); - - await withInteractiveTerminal(() => - runCli(["dev"], { error: () => {}, log: () => {} }, { runDevelopmentTui, startHost }), - ); + const runDevelopmentTui = await runInteractiveDev(["dev"], { startHost }); expect(startHost).toHaveBeenCalledWith(expect.any(String), { existing: "attach-if-unconfigured", @@ -308,13 +380,7 @@ describe("eve dev local server ownership", () => { close, })); - await withInteractiveTerminal(() => - runCli( - ["dev"], - { error: () => {}, log: () => {} }, - { runDevelopmentTui: vi.fn(async () => {}), startHost }, - ), - ); + await runInteractiveDev(["dev"], { startHost }); expect(close).toHaveBeenCalledOnce(); }); }); diff --git a/packages/eve/src/cli/run.ts b/packages/eve/src/cli/run.ts index bd96c477c..738e8c139 100644 --- a/packages/eve/src/cli/run.ts +++ b/packages/eve/src/cli/run.ts @@ -2,6 +2,7 @@ import { Command, CommanderError, InvalidArgumentError } from "#compiled/command import { devBootPhase, type DevBootProgressReporter } from "#internal/dev-boot-progress.js"; import { resolveApplicationRoot } from "#internal/application/paths.js"; import { resolveInstalledPackageInfo } from "#internal/application/package.js"; +import { encodeBasicCredentials } from "#internal/http/basic-auth.js"; import { isCodingAgentLaunch } from "#cli/agent-detection.js"; import { eveCliBanner } from "#cli/banner.js"; import { registerProjectCommands } from "#cli/commands/register-project-commands.js"; @@ -31,10 +32,13 @@ interface CliLogger { log(message: string): void; } +type DevelopmentRequestHeaders = Readonly>; + interface DevelopmentCliOptions { assistantResponseStats?: AssistantResponseStatsMode; connectionAuth?: TerminalPartDisplayMode; contextSize?: number; + header?: DevelopmentRequestHeaders; host?: string; input?: string; logs?: LogDisplayMode; @@ -250,39 +254,101 @@ function hasInteractiveTerminal(): boolean { return Boolean(process.stdin.isTTY && process.stdout.isTTY); } -function rewriteDevelopmentUrlShorthand(argv: readonly string[]): string[] { - const shorthandUrl = argv[1]; - - if ( - argv[0] !== "dev" || - argv.length !== 2 || - shorthandUrl === undefined || - shorthandUrl.startsWith("-") - ) { - return [...argv]; +function parseDevelopmentHeaderOption( + value: string, + previous: DevelopmentRequestHeaders = {}, +): DevelopmentRequestHeaders { + const separatorIndex = value.indexOf(":"); + if (separatorIndex < 1) { + throw new InvalidArgumentError(`Expected header in "Name: value" format, received "${value}".`); } - return ["dev", "--url", shorthandUrl]; + const name = value.slice(0, separatorIndex).trim(); + const headerValue = value.slice(separatorIndex + 1).trim(); + try { + new Headers([[name, headerValue]]); + } catch { + throw new InvalidArgumentError(`Expected a valid HTTP header, received "${value}".`); + } + return mergeDevelopmentHeaders(previous, { [name]: headerValue }) ?? {}; } -function resolveRemoteDevelopmentServerUrl(options: DevelopmentCliOptions): string | undefined { - if (!options.url) { +function resolveDevelopmentUrlTarget( + options: DevelopmentCliOptions, + positionalUrl: string | undefined, +): { readonly headers?: DevelopmentRequestHeaders; readonly serverUrl: string } | undefined { + if (options.url !== undefined && positionalUrl !== undefined) { + throw new InvalidArgumentError("Pass either --url or a bare URL, not both."); + } + + const url = options.url ?? positionalUrl; + if (url === undefined) { + if (options.header !== undefined) { + throw new InvalidArgumentError( + "The --header option can only be used with --url or a bare URL.", + ); + } return undefined; } if (options.host !== undefined) { throw new InvalidArgumentError("The --host option cannot be used with --url."); } - if (options.port !== undefined) { throw new InvalidArgumentError("The --port option cannot be used with --url."); } - if (options.ui === false) { throw new InvalidArgumentError("The --no-ui option cannot be used with --url."); } - return options.url; + const parsedUrl = URL.parse(url); + if (parsedUrl === null) { + throw new InvalidArgumentError(`Expected an absolute http(s) URL, received "${url}".`); + } + + const headers = mergeDevelopmentHeaders(extractDevelopmentUrlHeaders(parsedUrl), options.header); + const serverUrl = parsedUrl.toString(); + return headers === undefined ? { serverUrl } : { headers, serverUrl }; +} + +function mergeDevelopmentHeaders( + base: DevelopmentRequestHeaders | undefined, + override: DevelopmentRequestHeaders | undefined, +): DevelopmentRequestHeaders | undefined { + if (base === undefined) return override; + if (override === undefined) return base; + + const headers: Record = {}; + const overrideNames = new Set(Object.keys(override).map((name) => name.toLowerCase())); + for (const [name, value] of Object.entries(base)) { + if (!overrideNames.has(name.toLowerCase())) { + headers[name] = value; + } + } + for (const [name, value] of Object.entries(override)) { + headers[name] = value; + } + return headers; +} + +function extractDevelopmentUrlHeaders(url: URL): DevelopmentRequestHeaders | undefined { + if (url.username === "" && url.password === "") return undefined; + + const username = decodeUrlUserInfo(url.username, "username"); + const password = decodeUrlUserInfo(url.password, "password"); + url.username = ""; + url.password = ""; + return { + Authorization: `Basic ${encodeBasicCredentials(username, password)}`, + }; +} + +function decodeUrlUserInfo(value: string, label: "username" | "password"): string { + try { + return decodeURIComponent(value); + } catch { + throw new InvalidArgumentError(`Expected a valid URL-encoded ${label} in URL userinfo.`); + } } function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Command { @@ -408,9 +474,15 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm program .command("dev") .description("Start the eve development server or connect to an existing URL.") + .argument("[url]", "Connect to an existing server URL", parseDevelopmentServerUrl) .option("--host ", "Host interface to bind") .option("--port ", "Port to listen on (defaults to $PORT, then 2000)", parsePortOption) .option("-u, --url ", "Connect to an existing server URL", parseDevelopmentServerUrl) + .option( + "-H, --header
", + 'Request header for a URL target, in "Name: value" form (repeatable)', + parseDevelopmentHeaderOption, + ) .option("--no-ui", "Start the server without an interactive UI") .option("--name ", "Title shown in the terminal UI (defaults to the app folder name)") .option("--input ", "Pre-fill the prompt input, or start onboarding with /model") @@ -451,10 +523,11 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm ) .addHelpText( "after", - "\nYou can also pass a bare URL as the only argument, for example: eve dev https://example.com\n", + "\nYou can also pass a bare URL, for example: eve dev https://example.com\n", ) - .action(async (options: DevelopmentCliOptions) => { - const remoteServerUrl = resolveRemoteDevelopmentServerUrl(options); + .action(async (positionalUrl: string | undefined, options: DevelopmentCliOptions) => { + const remoteTarget = resolveDevelopmentUrlTarget(options, positionalUrl); + const remoteServerUrl = remoteTarget?.serverUrl; const interactive = hasInteractiveTerminal(); const mode = resolveDevUiMode({ options, interactive }); if (options.input !== undefined && mode === "headless") { @@ -489,13 +562,17 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm : { kind: "remote", serverUrl: input.serverUrl, workspaceRoot: appRoot }; const title = resolveTuiTitle({ name: options.name, target }); if (title !== undefined) display.name = title; - const tuiInput: RunDevelopmentTuiInput = { + const tuiInput = { target, initialInput: options.input, onBootProgress: report, ...display, - }; - await runDevelopmentTui(tuiInput); + } satisfies RunDevelopmentTuiInput; + if (remoteTarget?.headers !== undefined) { + await runDevelopmentTui({ ...tuiInput, headers: remoteTarget.headers }); + } else { + await runDevelopmentTui(tuiInput); + } }; if (remoteServerUrl) { @@ -632,7 +709,7 @@ export async function runCli( runtime: CliRuntimeOverrides = {}, ): Promise { const program = createCliProgram(logger, runtime); - const input = argv.length === 0 ? ["dev"] : rewriteDevelopmentUrlShorthand(argv); + const input = argv.length === 0 ? ["dev"] : argv; try { await program.parseAsync(input, { diff --git a/packages/eve/src/client/client.ts b/packages/eve/src/client/client.ts index 87aec69b7..2e3276891 100644 --- a/packages/eve/src/client/client.ts +++ b/packages/eve/src/client/client.ts @@ -1,5 +1,6 @@ import { EVE_HEALTH_ROUTE_PATH, EVE_INFO_ROUTE_PATH } from "#protocol/routes.js"; import { AgentInfoResponseError } from "#client/agent-info-error.js"; +import { encodeBasicCredentials } from "#internal/http/basic-auth.js"; import { AgentInfoResultSchema } from "#client/agent-info-schema.js"; import { ClientError } from "#client/client-error.js"; import { ClientSession } from "#client/session.js"; @@ -241,14 +242,3 @@ function withRedirectPolicy( ): RequestInit { return redirect === undefined ? init : { ...init, redirect }; } - -/** - * Encodes a username:password pair as a base64 Basic auth credential. - * Uses `TextEncoder` for correct UTF-8 handling across all runtimes. - */ -function encodeBasicCredentials(username: string, password: string): string { - const encoder = new TextEncoder(); - const bytes = encoder.encode(`${username}:${password}`); - const binaryString = Array.from(bytes, (byte) => String.fromCodePoint(byte)).join(""); - return btoa(binaryString); -} diff --git a/packages/eve/src/internal/http/basic-auth.ts b/packages/eve/src/internal/http/basic-auth.ts new file mode 100644 index 000000000..e9f310145 --- /dev/null +++ b/packages/eve/src/internal/http/basic-auth.ts @@ -0,0 +1,6 @@ +export function encodeBasicCredentials(username: string, password: string): string { + const encoder = new TextEncoder(); + const bytes = encoder.encode(`${username}:${password}`); + const binaryString = Array.from(bytes, (byte) => String.fromCodePoint(byte)).join(""); + return btoa(binaryString); +} diff --git a/packages/eve/src/services/dev-client/client-options.test.ts b/packages/eve/src/services/dev-client/client-options.test.ts index 1cd6625a3..5d045b714 100644 --- a/packages/eve/src/services/dev-client/client-options.test.ts +++ b/packages/eve/src/services/dev-client/client-options.test.ts @@ -1,13 +1,19 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Client } from "#client/client.js"; import { resolveDevelopmentClientOptions, resolveLocalDevelopmentClientOptions, resolveRemoteDevelopmentClientOptions, } from "./client-options.js"; +import type { DevelopmentCredentialGate } from "./credential-gate.js"; import { createDevelopmentCredentialGate } from "./credential-gate.js"; import { isLocalDevelopmentServerUrl } from "./local-host.js"; +afterEach(() => { + vi.restoreAllMocks(); +}); + describe("resolveDevelopmentClientOptions", () => { it("targets the given host without inferring credentials from locality", () => { const options = resolveDevelopmentClientOptions("http://localhost:3000"); @@ -48,6 +54,47 @@ describe("resolveDevelopmentClientOptions", () => { }); }); + it("does not override explicit local authorization with the linked Vercel bearer", async () => { + const token = vi.fn(async () => "user-oidc-token"); + + const options = resolveLocalDevelopmentClientOptions({ + headers: { + Authorization: "Basic dGVzdDpzZWNyZXQ=", + "x-tenant": "acme", + }, + serverUrl: "http://127.0.0.1:3000", + token, + }); + + expect(options.auth).toBeUndefined(); + + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null)); + await new Client(options).fetch("/eve/v1/info"); + + const headers = new Headers(fetchMock.mock.calls[0]?.[1]?.headers); + expect(headers.get("authorization")).toBe("Basic dGVzdDpzZWNyZXQ="); + expect(headers.get("x-tenant")).toBe("acme"); + expect(token).not.toHaveBeenCalled(); + }); + + it("keeps the linked Vercel bearer for local headers without authorization", async () => { + const token = vi.fn(async () => "user-oidc-token"); + + const options = resolveLocalDevelopmentClientOptions({ + headers: { "x-tenant": "acme" }, + serverUrl: "http://127.0.0.1:3000", + token, + }); + + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null)); + await new Client(options).fetch("/eve/v1/info"); + + const headers = new Headers(fetchMock.mock.calls[0]?.[1]?.headers); + expect(headers.get("authorization")).toBe("Bearer user-oidc-token"); + expect(headers.get("x-tenant")).toBe("acme"); + expect(token).toHaveBeenCalledTimes(1); + }); + it("binds an authorized credential gate to a non-redirecting client", () => { const credentials = createDevelopmentCredentialGate("https://verified.example.com"); @@ -62,4 +109,38 @@ describe("resolveDevelopmentClientOptions", () => { // The token flows through the higher-level vercelOidc auth, never headers. expect(options.auth).toEqual({ vercelOidc: { token: expect.any(Function) } }); }); + + it("keeps explicit remote authorization while adding Vercel bypass and trusted headers", async () => { + const credentials = { + authorize: vi.fn(() => () => {}), + lastTokenFailure: vi.fn(() => undefined), + resolveBypassHeaders: vi.fn(async () => ({ + "x-explicit": "from-bypass", + "x-vercel-protection-bypass": "from-env", + })), + resolveToken: vi.fn(async () => "oidc-token"), + serverOrigin: "https://verified.example.com", + } satisfies DevelopmentCredentialGate; + + const options = resolveRemoteDevelopmentClientOptions({ + credentials, + headers: { + authorization: "Basic dGVzdDpzZWNyZXQ=", + "x-explicit": "from-cli", + }, + serverUrl: "https://verified.example.com", + }); + + expect(options.auth).toBeUndefined(); + if (typeof options.headers !== "function") { + throw new Error("Expected dynamic headers."); + } + + await expect(options.headers()).resolves.toEqual({ + authorization: "Basic dGVzdDpzZWNyZXQ=", + "x-explicit": "from-cli", + "x-vercel-protection-bypass": "from-env", + "x-vercel-trusted-oidc-idp-token": "oidc-token", + }); + }); }); diff --git a/packages/eve/src/services/dev-client/client-options.ts b/packages/eve/src/services/dev-client/client-options.ts index d29d5119d..2f283de1e 100644 --- a/packages/eve/src/services/dev-client/client-options.ts +++ b/packages/eve/src/services/dev-client/client-options.ts @@ -1,7 +1,46 @@ import type { ClientOptions } from "#client/index.js"; +import { VERCEL_TRUSTED_OIDC_IDP_TOKEN_HEADER } from "#client/types.js"; import type { DevelopmentCredentialGate } from "./credential-gate.js"; +type DevelopmentClientHeaders = Readonly>; + +function hasAuthorizationHeader( + headers: DevelopmentClientHeaders | undefined, +): headers is DevelopmentClientHeaders { + return ( + headers !== undefined && + Object.keys(headers).some((name) => name.toLowerCase() === "authorization") + ); +} + +async function resolveRemoteHeaders(input: { + readonly credentials: DevelopmentCredentialGate; + readonly headers: DevelopmentClientHeaders | undefined; + readonly includeTrustedOidcHeader: boolean; +}): Promise { + if (!input.includeTrustedOidcHeader) { + return { + ...(await input.credentials.resolveBypassHeaders()), + ...input.headers, + }; + } + + const [bypassHeaders, token] = await Promise.all([ + input.credentials.resolveBypassHeaders(), + input.credentials.resolveToken(), + ]); + const headers: Record = { + ...bypassHeaders, + ...input.headers, + }; + const trimmedToken = token.trim(); + if (trimmedToken.length > 0) { + headers[VERCEL_TRUSTED_OIDC_IDP_TOKEN_HEADER] = trimmedToken; + } + return headers; +} + /** * Builds anonymous {@link ClientOptions} for a development target. Locality is * not an authorization decision, so remote URLs receive no ambient Vercel @@ -11,21 +50,36 @@ export function resolveDevelopmentClientOptions(serverUrl: string): ClientOption return { host: serverUrl }; } -/** Builds a non-redirecting local client with an explicit per-request bearer source. */ +/** Builds a non-redirecting local client, using ambient bearer auth only when it owns Authorization. */ export function resolveLocalDevelopmentClientOptions(input: { + readonly headers?: DevelopmentClientHeaders; readonly serverUrl: string; readonly token: () => Promise; }): ClientOptions { - return { - auth: { bearer: input.token }, + const options = { host: input.serverUrl, redirect: "manual", - }; + } satisfies ClientOptions; + + if (hasAuthorizationHeader(input.headers)) { + return { ...options, headers: input.headers }; + } + + const authorizedOptions = { + ...options, + auth: { bearer: input.token }, + } satisfies ClientOptions; + + if (input.headers !== undefined) { + return { ...authorizedOptions, headers: input.headers }; + } + return authorizedOptions; } /** Builds non-redirecting client options backed by one verified credential gate. */ export function resolveRemoteDevelopmentClientOptions(input: { readonly credentials: DevelopmentCredentialGate; + readonly headers?: DevelopmentClientHeaders; readonly serverUrl: string; }): ClientOptions { const serverOrigin = new URL(input.serverUrl).origin; @@ -34,9 +88,30 @@ export function resolveRemoteDevelopmentClientOptions(input: { `Credential gate origin ${input.credentials.serverOrigin} does not match client origin ${serverOrigin}.`, ); } + if (hasAuthorizationHeader(input.headers)) { + return { + headers: () => + resolveRemoteHeaders({ + credentials: input.credentials, + headers: input.headers, + includeTrustedOidcHeader: true, + }), + host: input.serverUrl, + redirect: "manual", + }; + } + return { auth: { vercelOidc: { token: () => input.credentials.resolveToken() } }, - headers: input.credentials.resolveBypassHeaders, + headers: + input.headers === undefined + ? input.credentials.resolveBypassHeaders + : () => + resolveRemoteHeaders({ + credentials: input.credentials, + headers: input.headers, + includeTrustedOidcHeader: false, + }), host: input.serverUrl, redirect: "manual", };