diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 1a79f729bb0..94b79c816d8 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -200,6 +200,25 @@ describe("resolveInitialServerAuthGateState", () => { ); }); + it("uses the LAN dev proxy for same-origin auth requests", async () => { + await installAuthApi({ session: () => unauthenticatedSession(LOOPBACK_AUTH) }); + vi.stubEnv("VITE_DEV_SERVER_URL", "http://127.0.0.1:5733"); + vi.stubEnv("VITE_HTTP_URL", "http://127.0.0.1:3773"); + vi.stubEnv("VITE_WS_URL", "ws://127.0.0.1:3773"); + installTestBrowser("http://192.168.1.20:5733/"); + + const { resolveInitialServerAuthGateState, resolvePrimaryEnvironmentHttpUrl } = + await import("./environments/primary"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: LOOPBACK_AUTH, + }); + expect(resolvePrimaryEnvironmentHttpUrl("/api/auth/session")).toBe( + "http://192.168.1.20:5733/api/auth/session", + ); + }); + it("uses the vite proxy for desktop-managed loopback auth requests during local dev", async () => { await installAuthApi({ session: () => unauthenticatedSession(DESKTOP_AUTH) }); vi.stubEnv("VITE_DEV_SERVER_URL", "http://127.0.0.1:5733"); diff --git a/apps/web/src/environments/primary/bootstrap.test.ts b/apps/web/src/environments/primary/bootstrap.test.ts index e200ecf2ff8..81508518e31 100644 --- a/apps/web/src/environments/primary/bootstrap.test.ts +++ b/apps/web/src/environments/primary/bootstrap.test.ts @@ -60,6 +60,9 @@ describe("environmentBootstrap", () => { beforeEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); + vi.stubEnv("VITE_HTTP_URL", ""); + vi.stubEnv("VITE_WS_URL", ""); + vi.stubEnv("VITE_DEV_SERVER_URL", ""); installTestBrowser("http://localhost/"); }); @@ -165,8 +168,45 @@ describe("environmentBootstrap", () => { ); }); + it("uses the LAN dev proxy for HTTP and preserves the configured websocket port", async () => { + vi.stubEnv("VITE_DEV_SERVER_URL", "http://127.0.0.1:5733"); + vi.stubEnv("VITE_HTTP_URL", "http://127.0.0.1:3773"); + vi.stubEnv("VITE_WS_URL", "ws://127.0.0.1:3774"); + installTestBrowser("http://192.168.1.20:5733/"); + await installDescriptorApi(); + + await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT); + expect(resolvePrimaryEnvironmentHttpUrl("/.well-known/t3/environment")).toBe( + "http://192.168.1.20:5733/.well-known/t3/environment", + ); + expect(readPrimaryEnvironmentTarget()).toEqual({ + source: "configured", + target: { + httpBaseUrl: "http://192.168.1.20:5733/", + wsBaseUrl: "ws://192.168.1.20:3774/", + }, + }); + }); + + it("preserves an explicitly configured non-loopback interface", () => { + vi.stubEnv("VITE_DEV_SERVER_URL", "http://127.0.0.1:5733"); + vi.stubEnv("VITE_HTTP_URL", "http://10.0.0.12:3773"); + vi.stubEnv("VITE_WS_URL", "ws://10.0.0.12:3774"); + installTestBrowser("http://192.168.1.20:5733/"); + + expect(readPrimaryEnvironmentTarget()).toEqual({ + source: "configured", + target: { + httpBaseUrl: "http://10.0.0.12:3773/", + wsBaseUrl: "ws://10.0.0.12:3774/", + }, + }); + }); + it("uses the vite proxy for desktop-managed loopback descriptor requests during local dev", async () => { vi.stubEnv("VITE_DEV_SERVER_URL", "http://127.0.0.1:5733"); + vi.stubEnv("VITE_HTTP_URL", "http://127.0.0.1:4773"); + vi.stubEnv("VITE_WS_URL", "ws://127.0.0.1:4773"); vi.stubGlobal("window", { location: new URL("http://127.0.0.1:5733/"), history: { @@ -190,6 +230,13 @@ describe("environmentBootstrap", () => { expect(resolvePrimaryEnvironmentHttpUrl("/.well-known/t3/environment")).toBe( "http://127.0.0.1:5733/.well-known/t3/environment", ); + expect(readPrimaryEnvironmentTarget()).toEqual({ + source: "desktop-managed", + target: { + httpBaseUrl: "http://127.0.0.1:3773/", + wsBaseUrl: "ws://127.0.0.1:3773/", + }, + }); }); it("retains the URL parser cause without exposing the configured URL in its message", () => { @@ -212,7 +259,7 @@ describe("environmentBootstrap", () => { it("describes which desktop bootstrap endpoint is missing", () => { vi.stubGlobal("window", { - location: new URL("http://127.0.0.1:5733/"), + location: new URL("t3code-dev://app/"), history: { replaceState: vi.fn() }, desktopBridge: { getLocalEnvironmentBootstraps: () => [ diff --git a/apps/web/src/environments/primary/httpLayer.test.ts b/apps/web/src/environments/primary/httpLayer.test.ts index 5bc1ef01da1..77bcdb77ec6 100644 --- a/apps/web/src/environments/primary/httpLayer.test.ts +++ b/apps/web/src/environments/primary/httpLayer.test.ts @@ -10,6 +10,7 @@ describe.sequential("primary environment HTTP layer", () => { afterEach(() => { __resetDesktopPrimaryAuthForTests(); Reflect.deleteProperty(globalThis, "window"); + vi.unstubAllEnvs(); vi.unstubAllGlobals(); }); @@ -35,6 +36,28 @@ describe.sequential("primary environment HTTP layer", () => { }).pipe(Effect.provide(makePrimaryEnvironmentHttpLayer())); }); + it.effect("uses cookie credentials through the LAN dev proxy", () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + vi.stubEnv("VITE_DEV_SERVER_URL", "http://127.0.0.1:5733"); + vi.stubEnv("VITE_HTTP_URL", "http://127.0.0.1:3773"); + vi.stubEnv("VITE_WS_URL", "ws://127.0.0.1:3773"); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + location: new URL("http://192.168.1.20:5733/settings"), + }, + }); + + return Effect.gen(function* () { + yield* HttpClient.get("http://192.168.1.20:5733/api/auth/session"); + + const request = new Request(fetchMock.mock.calls[0]?.[0], fetchMock.mock.calls[0]?.[1]); + expect(request.credentials).toBe("include"); + expect(request.headers.get("authorization")).toBeNull(); + }).pipe(Effect.provide(makePrimaryEnvironmentHttpLayer())); + }); + it.effect("uses bearer auth without cookies for desktop-managed primaries", () => { const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); vi.stubGlobal("fetch", fetchMock); diff --git a/apps/web/src/environments/primary/target.ts b/apps/web/src/environments/primary/target.ts index cc002419fe7..463ef7efaef 100644 --- a/apps/web/src/environments/primary/target.ts +++ b/apps/web/src/environments/primary/target.ts @@ -141,11 +141,10 @@ export function isLoopbackHostname(hostname: string): boolean { return LOOPBACK_HOSTNAMES.has(normalizeHostname(hostname)); } -function resolveHttpRequestBaseUrl(primaryTarget: PrimaryEnvironmentTarget): string { - const httpBaseUrl = primaryTarget.target.httpBaseUrl; +function resolveCurrentDevServerOrigin(): string | null { const configuredDevServerUrl = import.meta.env.VITE_DEV_SERVER_URL?.trim(); if (!configuredDevServerUrl) { - return httpBaseUrl; + return null; } const currentUrl = parseTargetUrl({ @@ -153,11 +152,6 @@ function resolveHttpRequestBaseUrl(primaryTarget: PrimaryEnvironmentTarget): str source: "window-origin", urlKind: "window-location-url", }); - const targetUrl = parseTargetUrl({ - rawValue: httpBaseUrl, - source: primaryTarget.source, - urlKind: "http-base-url", - }); const devServerUrl = parseTargetUrl({ rawValue: configuredDevServerUrl, baseUrl: currentUrl.origin, @@ -165,20 +159,38 @@ function resolveHttpRequestBaseUrl(primaryTarget: PrimaryEnvironmentTarget): str urlKind: "development-server-url", }); - const isCurrentOriginDevServer = - (currentUrl.protocol === "http:" || currentUrl.protocol === "https:") && - currentUrl.origin === devServerUrl.origin; + if (currentUrl.protocol !== "http:" && currentUrl.protocol !== "https:") { + return null; + } + + const isExactDevServerOrigin = currentUrl.origin === devServerUrl.origin; + const isLanAliasForLoopbackDevServer = + !isLoopbackHostname(currentUrl.hostname) && + isLoopbackHostname(devServerUrl.hostname) && + currentUrl.protocol === devServerUrl.protocol && + currentUrl.port === devServerUrl.port; + + return isExactDevServerOrigin || isLanAliasForLoopbackDevServer ? currentUrl.origin : null; +} + +function resolveHttpRequestBaseUrl(primaryTarget: PrimaryEnvironmentTarget): string { + const httpBaseUrl = primaryTarget.target.httpBaseUrl; + const devServerOrigin = resolveCurrentDevServerOrigin(); + if (!devServerOrigin) { + return httpBaseUrl; + } - if ( - !isCurrentOriginDevServer || - currentUrl.origin === targetUrl.origin || - !isLoopbackHostname(currentUrl.hostname) || - !isLoopbackHostname(targetUrl.hostname) - ) { + const targetUrl = parseTargetUrl({ + rawValue: httpBaseUrl, + source: primaryTarget.source, + urlKind: "http-base-url", + }); + + if (devServerOrigin === targetUrl.origin || !isLoopbackHostname(targetUrl.hostname)) { return httpBaseUrl; } - return currentUrl.origin; + return devServerOrigin; } function resolveConfiguredPrimaryTarget(): PrimaryEnvironmentTarget | null { @@ -267,6 +279,60 @@ function resolveDesktopPrimaryTarget(): PrimaryEnvironmentTarget | null { }; } +function isHttpWindowOrigin(): boolean { + return window.location.protocol === "http:" || window.location.protocol === "https:"; +} + +function replaceLoopbackHostname( + rawValue: string, + hostname: string, + urlKind: "http-base-url" | "websocket-base-url", +): string { + const url = parseTargetUrl({ + rawValue, + source: "configured", + urlKind, + }); + if (isLoopbackHostname(url.hostname)) { + url.hostname = hostname; + } + return url.toString(); +} + +function resolveConfiguredTargetForWindow( + target: PrimaryEnvironmentTarget, +): PrimaryEnvironmentTarget { + if (!isHttpWindowOrigin() || isLoopbackHostname(window.location.hostname)) { + return target; + } + + const devServerOrigin = resolveCurrentDevServerOrigin(); + if (!devServerOrigin) { + return target; + } + + const configuredHttpUrl = parseTargetUrl({ + rawValue: target.target.httpBaseUrl, + source: "configured", + urlKind: "http-base-url", + }); + const httpBaseUrl = isLoopbackHostname(configuredHttpUrl.hostname) + ? new URL(devServerOrigin).toString() + : configuredHttpUrl.toString(); + + return { + source: target.source, + target: { + httpBaseUrl, + wsBaseUrl: replaceLoopbackHostname( + target.target.wsBaseUrl, + window.location.hostname, + "websocket-base-url", + ), + }, + }; +} + export function resolvePrimaryEnvironmentHttpUrl( pathname: string, searchParams?: Record, @@ -286,9 +352,15 @@ export function resolvePrimaryEnvironmentHttpUrl( } export function readPrimaryEnvironmentTarget(): PrimaryEnvironmentTarget { - return ( - resolveDesktopPrimaryTarget() ?? - resolveConfiguredPrimaryTarget() ?? - resolveWindowOriginPrimaryTarget() - ); + const desktopTarget = resolveDesktopPrimaryTarget(); + if (desktopTarget) { + return desktopTarget; + } + + const configuredTarget = resolveConfiguredPrimaryTarget(); + if (configuredTarget) { + return resolveConfiguredTargetForWindow(configuredTarget); + } + + return resolveWindowOriginPrimaryTarget(); }