From 4d4e62db0f93eb33de00d44c614a9ae14d79f7cf Mon Sep 17 00:00:00 2001 From: James Villarrubia Date: Mon, 21 Sep 2026 10:25:18 -0400 Subject: [PATCH 1/4] fix(desktop): retry transient bearer bootstrap and degrade on session fetch failure The desktop app hard-crashed on launch when the main-process /oauth/token exchange hit a transient failure (common after the macOS 27 upgrade): the bearer path had no retry (unlike the renderer's cookie path) and a single 503/transport error became DesktopLocalEnvironmentAuthSessionBootstrapError. The renderer's bootstrapServerAuth also called fetchSessionState() outside its try/catch, so that fatal error escaped beforeLoad and rendered the root error view with no recovery. - Main process (DesktopLocalEnvironmentAuth.ts): add isTransientBearerBootstrapError and Effect.retry(spaced(500ms).upTo(15s)) on bootstrapRemoteBearerSession, mirroring the renderer's retryTransientBootstrap. Transient: 502/503/504, RemoteEnvironmentAuthFetchError, RemoteEnvironmentAuthTimeoutError, TypeError. Non-transient: EnvironmentInternalError, EnvironmentAuthInvalidError, EnvironmentRequestInvalidError. - Renderer (primary/auth.ts): wrap initial fetchSessionState() in bootstrapServerAuth so a failure degrades to requires-auth with errorMessage, mirroring how the cookie-exchange path degrades. The auth gate renders the pairing UI instead of crashing. Tests: DesktopLocalEnvironmentAuth.test.ts retries two 503s then succeeds on the third (deterministic via TestClock). authBootstrap.test.ts verifies a failed initial session fetch resolves to requires-auth instead of throwing. Both pass; tsc --noEmit clean. Verified by local build + launching the desktop app on macOS 27. Model/harness: claude-5-sonnet-high / Oz --- .../DesktopLocalEnvironmentAuth.test.ts | 73 ++++++++++++++++++- .../backend/DesktopLocalEnvironmentAuth.ts | 71 ++++++++++++++++++ apps/web/src/authBootstrap.test.ts | 22 ++++++ apps/web/src/environments/primary/auth.ts | 25 ++++++- 4 files changed, 188 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts index e7a58baef140..bf188968fb2b 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts @@ -1,10 +1,12 @@ import { assert, describe, it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; -import * as HttpClient from "effect/unstable/http/HttpClient"; -import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; import * as DesktopBackendPool from "./DesktopBackendPool.ts"; @@ -78,4 +80,71 @@ describe("DesktopLocalEnvironmentAuth", () => { assert.strictEqual(yield* Ref.get(requestCount), 1); }), ); + + it.effect("retries transient /oauth/token failures before surfacing the bootstrap error", () => + Effect.gen(function* () { + // The first two attempts return a 503 (the shape a settling backend + // produces); the third succeeds. Before the retry, a single transient + // failure hard-failed into DesktopLocalEnvironmentAuthSessionBootstrapError. + // A 503 flows through the real HttpApiClient pipeline and maps to + // RemoteEnvironmentAuthUndeclaredStatusError(url, 503), which the + // classifier treats as transient. TestClock advances the spaced schedule + // deterministically instead of waiting out real 500ms delays. + const requestCount = yield* Ref.make(0); + const successResponse = (request: HttpClientRequest.HttpClientRequest) => + HttpClientResponse.fromWeb( + request, + new Response( + JSON.stringify({ + access_token: "desktop-bearer-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "Bearer", + expires_in: 3600, + scope: "orchestration:read", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + const transientResponse = (request: HttpClientRequest.HttpClientRequest) => + HttpClientResponse.fromWeb(request, new Response("", { status: 503 })); + const httpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Ref.modify(requestCount, (n) => [n + 1, n + 1]).pipe( + Effect.flatMap((count) => + count <= 2 + ? Effect.succeed(transientResponse(request)) + : Effect.succeed(successResponse(request)), + ), + ), + ), + ); + const poolLayer = Layer.succeed(DesktopBackendPool.DesktopBackendPool, { + list: Effect.succeed([ + { + id: PRIMARY_LOCAL_ENVIRONMENT_ID, + label: Effect.succeed("Windows"), + currentConfig: Effect.succeed(Option.some(config)), + }, + ]), + } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]); + const testLayer = DesktopLocalEnvironmentAuth.layer.pipe( + Layer.provide(Layer.mergeAll(poolLayer, httpClientLayer)), + ); + + const auth = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth.pipe( + Effect.provide(testLayer), + ); + // Fork getBearerToken so the TestClock can advance the spaced schedule + // delays without blocking the test fiber. + const fiber = yield* auth.getBearerToken.pipe(Effect.forkChild); + // Two transient attempts each park for 500ms on the spaced schedule; + // advance past both delays so the third attempt (success) can run. + yield* TestClock.adjust(Duration.millis(500)); + yield* TestClock.adjust(Duration.millis(500)); + const token = yield* Fiber.join(fiber); + assert.strictEqual(token, "desktop-bearer-token"); + assert.isAtLeast(yield* Ref.get(requestCount), 3); + }).pipe(Effect.provide(TestClock.layer())), + ); }); diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts index dae20e2da955..fd4e520c6544 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts @@ -1,16 +1,76 @@ import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization"; import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as DesktopBackendPool from "./DesktopBackendPool.ts"; +// The renderer's cookie-session bootstrap retries transient failures for +// ~15s (see retryTransientBootstrap in apps/web/src/environments/primary/auth.ts). +// The main-process bearer exchange had no such retry, so a single transient +// /oauth/token failure (a first-request connection reset on a new OS, a +// loopback quirk, a brief 503 while the backend settles) hard-crashed the app +// via DesktopLocalEnvironmentAuthSessionBootstrapError. Mirror the renderer's +// transient classification here so the bearer path degrades the same way. +const BOOTSTRAP_TRANSIENT_RETRY_TIMEOUT = Duration.seconds(15); +const BOOTSTRAP_TRANSIENT_RETRY_INTERVAL = Duration.millis(500); +const TRANSIENT_BOOTSTRAP_STATUS_CODES = new Set([502, 503, 504]); + +// `executeEnvironmentHttpRequest` normalizes every failure into a +// `RemoteEnvironmentRequestError` (a union of Data.TaggedError shapes from +// packages/client-runtime/src/rpc/http.ts). Declared server errors keep their +// own `_tag`; transport failures become `RemoteEnvironmentAuthFetchError`; +// undeclared HTTP statuses become `RemoteEnvironmentAuthUndeclaredStatusError`. +// Classify by `_tag` so we don't need value imports of the error constructors. +const isTransientBearerBootstrapError = (error: unknown): boolean => { + if (error === null || typeof error !== "object") return false; + const tag = (error as { readonly _tag?: unknown })._tag; + if (typeof tag !== "string") return false; + // Declared EnvironmentHttp errors with a known non-transient status. The + // token endpoint wraps internal failures in EnvironmentInternalError (500), + // credential problems in EnvironmentAuthInvalidError (401), and bad requests + // in EnvironmentRequestInvalidError (400). None of these are transient. + if ( + tag === "EnvironmentInternalError" || + tag === "EnvironmentAuthInvalidError" || + tag === "EnvironmentRequestInvalidError" + ) { + return false; + } + // Undeclared HTTP statuses: transient only for 502/503/504. + if (tag === "RemoteEnvironmentAuthUndeclaredStatusError") { + const status = (error as { readonly status?: number }).status; + return typeof status === "number" && TRANSIENT_BOOTSTRAP_STATUS_CODES.has(status); + } + // A transport-level fetch failure (connection reset, ECONNREFUSED while the + // loopback listener is still coming up, an undici quirk on a new OS) surfaces + // as RemoteEnvironmentAuthFetchError. Treat these as transient so the bearer + // exchange rides out a brief loopback hiccup instead of crashing the app. + if (tag === "RemoteEnvironmentAuthFetchError") { + return true; + } + if (tag === "RemoteEnvironmentAuthTimeoutError") { + return true; + } + // Effect's HttpClientError carries a response status for status-code errors. + const maybeResponse = (error as { readonly response?: { readonly status: number } }).response; + if (maybeResponse !== undefined && TRANSIENT_BOOTSTRAP_STATUS_CODES.has(maybeResponse.status)) { + return true; + } + // Node fetch throws a TypeError on a network-level failure (DNS, refused, + // reset). The renderer's isTransientBootstrapError treats TypeError as + // transient; mirror that for the main-process undici client. + return error instanceof TypeError; +}; + export class DesktopLocalEnvironmentAuthBackendNotConfiguredError extends Schema.TaggedError()( "DesktopLocalEnvironmentAuthBackendNotConfiguredError", {}, @@ -77,6 +137,17 @@ export const make = Effect.gen(function* () { }, }).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), + // Retry transient failures (502/503/504, transport-level fetch errors, + // timeouts, TypeErrors) for up to ~15s before surfacing the bootstrap + // error. A single transient /oauth/token failure no longer hard-crashes + // the app; the renderer's cookie path already had this resilience. + // spaced(500ms) + upTo(15s) gives ~30 attempts in a 15s window. + Effect.retry({ + while: isTransientBearerBootstrapError, + schedule: Schedule.spaced(BOOTSTRAP_TRANSIENT_RETRY_INTERVAL).pipe( + Schedule.upTo({ duration: BOOTSTRAP_TRANSIENT_RETRY_TIMEOUT }), + ), + }), Effect.mapError( (cause) => new DesktopLocalEnvironmentAuthSessionBootstrapError({ diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index cac54fe1ec84..eca49ed29b1f 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -269,6 +269,28 @@ describe("resolveInitialServerAuthGateState", () => { expect(attempts).toBe(4); }); + it("degrades to requires-auth instead of crashing beforeLoad when the initial session fetch fails", async () => { + // Reproduces the launch crash: the initial fetchSessionState() goes + // through the desktop bearer-token path, and a failed bearer bootstrap + // surfaced as an uncaught error in beforeLoad. The fix wraps the initial + // fetch so a failure degrades to requires-auth with an error message. + const runner: PrimaryHttpEffectRunner = async () => { + throw new Error("Failed to create the local desktop bearer session."); + }; + __setPrimaryHttpRunnerForTests(runner); + installDesktopBootstrap(); + + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + // fetchSessionState wraps the thrown error as a PrimaryEnvironmentRequestError + // (status 500 for a non-HTTP cause). The important behavior is that the gate + // resolves to requires-auth instead of rejecting — before the fix this + // rejection crashed TanStack Router's beforeLoad. + await expect(resolveInitialServerAuthGateState()).resolves.toMatchObject({ + status: "requires-auth", + }); + }); + it("takes a pairing token from the location hash and strips it immediately", async () => { const testWindow = installTestBrowser("http://localhost/#token=pairing-token"); const { takePairingTokenFromUrl } = await import("./environments/primary"); diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index ce06fbf5da05..ad2455e3db2e 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -308,7 +308,30 @@ function isTransientBootstrapError(error: unknown): boolean { } async function bootstrapServerAuth(urlCredential: string | null): Promise { - const currentSession = await fetchSessionState(); + // The initial session fetch goes through the desktop bearer-token path + // (withPrimaryBearerToken in httpLayer.ts), which calls the main-process + // IPC getLocalEnvironmentBearerToken -> DesktopLocalEnvironmentAuth. + // A transient failure there (or in the /api/auth/session request itself) + // used to escape beforeLoad and render the root error component, crashing + // the app on launch. Fall back to requires-auth so the auth gate renders + // the pairing UI and the next navigation re-attempts bootstrap, mirroring + // how the cookie-exchange path below degrades. + let currentSession: AuthSessionState; + try { + currentSession = await fetchSessionState(); + } catch (error) { + const fallbackAuth: AuthSessionState["auth"] = { + policy: "desktop-managed-local", + bootstrapMethods: ["desktop-bootstrap"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }; + return { + status: "requires-auth", + auth: fallbackAuth, + errorMessage: error instanceof Error ? error.message : "Authentication failed.", + }; + } if (currentSession.authenticated && !urlCredential) { return { status: "authenticated" }; } From 4f15f0638a1d65201ca5826ec795f5f095629b8e Mon Sep 17 00:00:00 2001 From: James Villarrubia Date: Mon, 21 Sep 2026 10:30:10 -0400 Subject: [PATCH 2/4] fix(desktop): bound entire bearer bootstrap with outer Effect.timeout Schedule.upTo only limits when retries are scheduled, not an in-flight request. An attempt starting near the 15s mark could consume its own per-request timeout, leaving getBearerToken blocked for ~20s+. Add an outer Effect.timeout(15s) so a hung request is interrupted at the deadline and mapped to DesktopLocalEnvironmentAuthSessionBootstrapError. Addresses review comment on #12919. --- .../src/backend/DesktopLocalEnvironmentAuth.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts index fd4e520c6544..daa467271fb5 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts @@ -138,16 +138,22 @@ export const make = Effect.gen(function* () { }).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), // Retry transient failures (502/503/504, transport-level fetch errors, - // timeouts, TypeErrors) for up to ~15s before surfacing the bootstrap - // error. A single transient /oauth/token failure no longer hard-crashes - // the app; the renderer's cookie path already had this resilience. - // spaced(500ms) + upTo(15s) gives ~30 attempts in a 15s window. + // timeouts, TypeErrors) before surfacing the bootstrap error. A single + // transient /oauth/token failure no longer hard-crashes the app; the + // renderer's cookie path already had this resilience. spaced(500ms) + + // upTo(15s) gives ~30 attempts in a 15s window. Effect.retry({ while: isTransientBearerBootstrapError, schedule: Schedule.spaced(BOOTSTRAP_TRANSIENT_RETRY_INTERVAL).pipe( Schedule.upTo({ duration: BOOTSTRAP_TRANSIENT_RETRY_TIMEOUT }), ), }), + // Schedule.upTo only bounds when retries are *scheduled*, not an + // in-flight request: an attempt starting near the 15s mark could run + // for its own per-request timeout, leaving getBearerToken blocked for + // ~20s+. Bound the entire retrying effect so a hung request is + // interrupted at the deadline and mapped to the bootstrap error below. + Effect.timeout(BOOTSTRAP_TRANSIENT_RETRY_TIMEOUT), Effect.mapError( (cause) => new DesktopLocalEnvironmentAuthSessionBootstrapError({ From cc43e332788b7663aa5de43f137b02f5c5f27737 Mon Sep 17 00:00:00 2001 From: James Villarrubia Date: Mon, 21 Sep 2026 10:50:20 -0400 Subject: [PATCH 3/4] fix(web): use environment-specific fallback auth metadata When the initial session fetch fails, bootstrapServerAuth now advertises desktop-managed-local only if the desktop bridge is present. Browser clients get loopback-browser on loopback hosts and remote-reachable otherwise, both with one-time-token, so PairingRouteSurface does not send web users down the desktop pairing path. --- apps/web/src/authBootstrap.test.ts | 45 ++++++++++++++++++- apps/web/src/environments/primary/auth.ts | 54 +++++++++++++++++++---- 2 files changed, 90 insertions(+), 9 deletions(-) diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index eca49ed29b1f..4e514dee1bf4 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -35,6 +35,13 @@ const DESKTOP_AUTH = { sessionCookieName: "t3_session", } as const; +const REMOTE_AUTH = { + policy: "remote-reachable", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", +} as const; + const SESSION_EXPIRES_AT = DateTime.makeUnsafe("2026-04-05T00:00:00.000Z"); const unauthenticatedSession = (auth: AuthSessionState["auth"]): AuthSessionState => ({ authenticated: false, @@ -286,8 +293,44 @@ describe("resolveInitialServerAuthGateState", () => { // (status 500 for a non-HTTP cause). The important behavior is that the gate // resolves to requires-auth instead of rejecting — before the fix this // rejection crashed TanStack Router's beforeLoad. - await expect(resolveInitialServerAuthGateState()).resolves.toMatchObject({ + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: DESKTOP_AUTH, + errorMessage: "Primary environment request failed during fetch-session-state (HTTP 500).", + }); + }); + + it("uses loopback-browser fallback metadata when the session fetch fails without a desktop bridge", async () => { + const runner: PrimaryHttpEffectRunner = async () => { + throw new Error("session fetch failed"); + }; + __setPrimaryHttpRunnerForTests(runner); + installTestBrowser("http://localhost/"); + + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: LOOPBACK_AUTH, + errorMessage: "Primary environment request failed during fetch-session-state (HTTP 500).", + }); + }); + + it("uses remote-reachable fallback metadata when the session fetch fails on a remote origin", async () => { + const runner: PrimaryHttpEffectRunner = async () => { + throw new Error("session fetch failed"); + }; + __setPrimaryHttpRunnerForTests(runner); + vi.stubEnv("VITE_HTTP_URL", "https://remote.example.com"); + vi.stubEnv("VITE_WS_URL", "wss://remote.example.com"); + installTestBrowser("https://remote.example.com/"); + + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ status: "requires-auth", + auth: REMOTE_AUTH, + errorMessage: "Primary environment request failed during fetch-session-state (HTTP 500).", }); }); diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index ad2455e3db2e..a20630982c66 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -18,9 +18,11 @@ import { stripPairingTokenFromUrl as stripPairingTokenUrl, } from "../../pairingUrl"; -import { PrimaryEnvironmentHttpClient } from "./httpClient"; import { runPrimaryHttp } from "../../lib/runtime"; +import { PrimaryEnvironmentHttpClient } from "./httpClient"; +import { isLoopbackHostname, readPrimaryEnvironmentTarget } from "./target"; + const PrimaryEnvironmentRequestOperation = Schema.Literals([ "fetch-session-state", "exchange-bootstrap-credential", @@ -307,6 +309,48 @@ function isTransientBootstrapError(error: unknown): boolean { return error instanceof DOMException && error.name === "AbortError"; } +const FALLBACK_SESSION_COOKIE_NAME = "t3_session"; + +function fallbackAuthDescriptor(): AuthSessionState["auth"] { + // The session endpoint is the source of truth for auth metadata. When it is + // unreachable we still need a descriptor so PairingRouteSurface can render + // the right pairing copy. Desktop has a bridge; browsers do not, so they + // must not be sent down the desktop-bootstrap path. + if (window.desktopBridge !== undefined) { + return { + policy: "desktop-managed-local", + bootstrapMethods: ["desktop-bootstrap"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: FALLBACK_SESSION_COOKIE_NAME, + }; + } + + const hostname = readFallbackAuthHostname(); + const isRemote = hostname === null || !isLoopbackHostname(hostname); + return { + policy: isRemote ? "remote-reachable" : "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: FALLBACK_SESSION_COOKIE_NAME, + }; +} + +function readFallbackAuthHostname(): string | null { + try { + const target = readPrimaryEnvironmentTarget(); + if (target) { + return new URL(target.target.httpBaseUrl).hostname; + } + } catch { + // Fall through to the page origin if the primary target is unreadable. + } + try { + return new URL(window.location.href).hostname; + } catch { + return null; + } +} + async function bootstrapServerAuth(urlCredential: string | null): Promise { // The initial session fetch goes through the desktop bearer-token path // (withPrimaryBearerToken in httpLayer.ts), which calls the main-process @@ -320,15 +364,9 @@ async function bootstrapServerAuth(urlCredential: string | null): Promise Date: Tue, 6 Oct 2026 00:04:35 -0700 Subject: [PATCH 4/4] fix(desktop): retry only the local bearer exchange's transient failures Classify retryable failures by the request error union instead of probing untyped fields, and let the outer 15 s timeout alone bound the retries. Tests cover both sides: a backend still starting (503) is retried until the token arrives, and a rejected credential (401) fails after one request. The renderer fallback is dropped. When the session fetch fails on desktop it showed the pairing form, which asks for a token a desktop user does not have; with the bearer exchange retrying, the root error view's Try again and Reload app cover what is left. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../DesktopLocalEnvironmentAuth.test.ts | 126 ++++++++++-------- .../backend/DesktopLocalEnvironmentAuth.ts | 80 +++-------- apps/web/src/authBootstrap.test.ts | 65 --------- apps/web/src/environments/primary/auth.ts | 65 +-------- 4 files changed, 91 insertions(+), 245 deletions(-) diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts index 1f784acb1da0..3f0bd82d5888 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts @@ -91,7 +91,7 @@ describe("DesktopLocalEnvironmentAuth", () => { () => Effect.gen(function* () { const presented = yield* Ref.make(null); - const layerHttpClient = Layer.succeed( + const httpClientLayer = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => { const body = @@ -136,7 +136,7 @@ describe("DesktopLocalEnvironmentAuth", () => { }).pipe( Effect.provide( DesktopLocalEnvironmentAuth.layer.pipe( - Layer.provide(Layer.mergeAll(poolLayer, layerHttpClient)), + Layer.provide(Layer.mergeAll(poolLayer, httpClientLayer)), ), ), ); @@ -148,70 +148,90 @@ describe("DesktopLocalEnvironmentAuth", () => { }).pipe(Effect.provide(TestClock.layer())), ); - it.effect("retries transient /oauth/token failures before surfacing the bootstrap error", () => + const tokenResponse = (request: HttpClientRequest.HttpClientRequest) => + HttpClientResponse.fromWeb( + request, + new Response( + JSON.stringify({ + access_token: "desktop-bearer-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "Bearer", + expires_in: 3600, + scope: "orchestration:read", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + const layerPool = Layer.succeed(DesktopBackendPool.DesktopBackendPool, { + list: Effect.succeed([ + { + id: PRIMARY_LOCAL_ENVIRONMENT_ID, + label: Effect.succeed("Windows"), + currentConfig: Effect.succeedSome(config), + }, + ]), + } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]); + // Answers the first `failures` exchanges with `failure`, then with a token. + const makeExchange = (failures: number, failure: () => Response) => Effect.gen(function* () { - // The first two attempts return a 503 (the shape a settling backend - // produces); the third succeeds. Before the retry, a single transient - // failure hard-failed into DesktopLocalEnvironmentAuthSessionBootstrapError. - // A 503 flows through the real HttpApiClient pipeline and maps to - // RemoteEnvironmentAuthUndeclaredStatusError(url, 503), which the - // classifier treats as transient. TestClock advances the spaced schedule - // deterministically instead of waiting out real 500ms delays. const requestCount = yield* Ref.make(0); - const successResponse = (request: HttpClientRequest.HttpClientRequest) => - HttpClientResponse.fromWeb( - request, - new Response( - JSON.stringify({ - access_token: "desktop-bearer-token", - issued_token_type: "urn:ietf:params:oauth:token-type:access_token", - token_type: "Bearer", - expires_in: 3600, - scope: "orchestration:read", - }), - { status: 200, headers: { "content-type": "application/json" } }, - ), - ); - const transientResponse = (request: HttpClientRequest.HttpClientRequest) => - HttpClientResponse.fromWeb(request, new Response("", { status: 503 })); const layerHttpClient = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => - Ref.modify(requestCount, (n) => [n + 1, n + 1]).pipe( - Effect.flatMap((count) => - count <= 2 - ? Effect.succeed(transientResponse(request)) - : Effect.succeed(successResponse(request)), + Ref.updateAndGet(requestCount, (count) => count + 1).pipe( + Effect.map((count) => + count <= failures + ? HttpClientResponse.fromWeb(request, failure()) + : tokenResponse(request), ), ), ), ); - const poolLayer = Layer.succeed(DesktopBackendPool.DesktopBackendPool, { - list: Effect.succeed([ - { - id: PRIMARY_LOCAL_ENVIRONMENT_ID, - label: Effect.succeed("Windows"), - currentConfig: Effect.succeedSome(config), - }, - ]), - } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]); - const testLayer = DesktopLocalEnvironmentAuth.layer.pipe( - Layer.provide(Layer.mergeAll(poolLayer, layerHttpClient)), + const auth = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth.pipe( + Effect.provide( + DesktopLocalEnvironmentAuth.layer.pipe( + Layer.provide(Layer.mergeAll(layerPool, layerHttpClient)), + ), + ), ); + return { auth, requestCount }; + }); - const auth = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth.pipe( - Effect.provide(testLayer), + it.effect("retries a backend that is still starting", () => + Effect.gen(function* () { + const { auth, requestCount } = yield* makeExchange( + 2, + () => new Response("", { status: 503 }), ); - // Fork getBearerToken so the TestClock can advance the spaced schedule - // delays without blocking the test fiber. + const fiber = yield* auth.getBearerToken.pipe(Effect.forkChild); - // Two transient attempts each park for 500ms on the spaced schedule; - // advance past both delays so the third attempt (success) can run. - yield* TestClock.adjust(Duration.millis(500)); - yield* TestClock.adjust(Duration.millis(500)); - const token = yield* Fiber.join(fiber); - assert.strictEqual(token, "desktop-bearer-token"); - assert.isAtLeast(yield* Ref.get(requestCount), 3); + yield* TestClock.adjust(Duration.seconds(1)); + + assert.strictEqual(yield* Fiber.join(fiber), "desktop-bearer-token"); + assert.strictEqual(yield* Ref.get(requestCount), 3); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("does not retry a rejected bootstrap credential", () => + Effect.gen(function* () { + const { auth, requestCount } = yield* makeExchange(1, () => + Response.json( + { + _tag: "EnvironmentAuthInvalidError", + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-1", + }, + { status: 401 }, + ), + ); + + const fiber = yield* auth.getBearerToken.pipe(Effect.flip, Effect.forkChild); + yield* TestClock.adjust(Duration.seconds(1)); + const error = yield* Fiber.join(fiber); + + assert.strictEqual(error._tag, "DesktopLocalEnvironmentAuthSessionBootstrapError"); + assert.strictEqual(yield* Ref.get(requestCount), 1); }).pipe(Effect.provide(TestClock.layer())), ); }); diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts index c8166aac84cd..d137d502e6c5 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts @@ -1,4 +1,5 @@ import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization"; +import type { RemoteEnvironmentRequestError } from "@t3tools/client-runtime/rpc"; import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken"; import * as Clock from "effect/Clock"; @@ -15,62 +16,24 @@ import * as HttpClient from "effect/http/HttpClient"; import * as DesktopBackendPool from "./DesktopBackendPool.ts"; -// The renderer's cookie-session bootstrap retries transient failures for -// ~15s (see retryTransientBootstrap in apps/web/src/environments/primary/auth.ts). -// The main-process bearer exchange had no such retry, so a single transient -// /oauth/token failure (a first-request connection reset on a new OS, a -// loopback quirk, a brief 503 while the backend settles) hard-crashed the app -// via DesktopLocalEnvironmentAuthSessionBootstrapError. Mirror the renderer's -// transient classification here so the bearer path degrades the same way. +// A loopback /oauth/token exchange can fail transiently while the backend +// settles (a 502-504, a refused or reset connection, a timeout). Retry those +// for as long as the renderer's own bootstrap does; a rejected credential or a +// server error is final. const BOOTSTRAP_TRANSIENT_RETRY_TIMEOUT = Duration.seconds(15); const BOOTSTRAP_TRANSIENT_RETRY_INTERVAL = Duration.millis(500); const TRANSIENT_BOOTSTRAP_STATUS_CODES = new Set([502, 503, 504]); -// `executeEnvironmentHttpRequest` normalizes every failure into a -// `RemoteEnvironmentRequestError` (a union of Data.TaggedError shapes from -// packages/client-runtime/src/rpc/http.ts). Declared server errors keep their -// own `_tag`; transport failures become `RemoteEnvironmentAuthFetchError`; -// undeclared HTTP statuses become `RemoteEnvironmentAuthUndeclaredStatusError`. -// Classify by `_tag` so we don't need value imports of the error constructors. -const isTransientBearerBootstrapError = (error: unknown): boolean => { - if (error === null || typeof error !== "object") return false; - const tag = (error as { readonly _tag?: unknown })._tag; - if (typeof tag !== "string") return false; - // Declared EnvironmentHttp errors with a known non-transient status. The - // token endpoint wraps internal failures in EnvironmentInternalError (500), - // credential problems in EnvironmentAuthInvalidError (401), and bad requests - // in EnvironmentRequestInvalidError (400). None of these are transient. - if ( - tag === "EnvironmentInternalError" || - tag === "EnvironmentAuthInvalidError" || - tag === "EnvironmentRequestInvalidError" - ) { - return false; +const isTransientBearerBootstrapError = (error: RemoteEnvironmentRequestError): boolean => { + switch (error._tag) { + case "RemoteEnvironmentAuthFetchError": + case "RemoteEnvironmentAuthTimeoutError": + return true; + case "RemoteEnvironmentAuthUndeclaredStatusError": + return TRANSIENT_BOOTSTRAP_STATUS_CODES.has(error.status); + default: + return false; } - // Undeclared HTTP statuses: transient only for 502/503/504. - if (tag === "RemoteEnvironmentAuthUndeclaredStatusError") { - const status = (error as { readonly status?: number }).status; - return typeof status === "number" && TRANSIENT_BOOTSTRAP_STATUS_CODES.has(status); - } - // A transport-level fetch failure (connection reset, ECONNREFUSED while the - // loopback listener is still coming up, an undici quirk on a new OS) surfaces - // as RemoteEnvironmentAuthFetchError. Treat these as transient so the bearer - // exchange rides out a brief loopback hiccup instead of crashing the app. - if (tag === "RemoteEnvironmentAuthFetchError") { - return true; - } - if (tag === "RemoteEnvironmentAuthTimeoutError") { - return true; - } - // Effect's HttpClientError carries a response status for status-code errors. - const maybeResponse = (error as { readonly response?: { readonly status: number } }).response; - if (maybeResponse !== undefined && TRANSIENT_BOOTSTRAP_STATUS_CODES.has(maybeResponse.status)) { - return true; - } - // Node fetch throws a TypeError on a network-level failure (DNS, refused, - // reset). The renderer's isTransientBootstrapError treats TypeError as - // transient; mirror that for the main-process undici client. - return error instanceof TypeError; }; export class DesktopLocalEnvironmentAuthBackendNotConfiguredError extends Schema.TaggedError()( @@ -146,22 +109,11 @@ export const make = Effect.gen(function* () { }, }).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), - // Retry transient failures (502/503/504, transport-level fetch errors, - // timeouts, TypeErrors) before surfacing the bootstrap error. A single - // transient /oauth/token failure no longer hard-crashes the app; the - // renderer's cookie path already had this resilience. spaced(500ms) + - // upTo(15s) gives ~30 attempts in a 15s window. Effect.retry({ while: isTransientBearerBootstrapError, - schedule: Schedule.spaced(BOOTSTRAP_TRANSIENT_RETRY_INTERVAL).pipe( - Schedule.upTo({ duration: BOOTSTRAP_TRANSIENT_RETRY_TIMEOUT }), - ), + schedule: Schedule.spaced(BOOTSTRAP_TRANSIENT_RETRY_INTERVAL), }), - // Schedule.upTo only bounds when retries are *scheduled*, not an - // in-flight request: an attempt starting near the 15s mark could run - // for its own per-request timeout, leaving getBearerToken blocked for - // ~20s+. Bound the entire retrying effect so a hung request is - // interrupted at the deadline and mapped to the bootstrap error below. + // Bounds the attempts and any request still in flight at the deadline. Effect.timeout(BOOTSTRAP_TRANSIENT_RETRY_TIMEOUT), Effect.mapError( (cause) => diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index cc33c9a1b839..0e4c5d46df4a 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -35,13 +35,6 @@ const DESKTOP_AUTH = { sessionCookieName: "t3_session", } as const; -const REMOTE_AUTH = { - policy: "remote-reachable", - bootstrapMethods: ["one-time-token"], - sessionMethods: ["browser-session-cookie"], - sessionCookieName: "t3_session", -} as const; - const SESSION_EXPIRES_AT = DateTime.makeUnsafe("2026-04-05T00:00:00.000Z"); const unauthenticatedSession = (auth: AuthSessionState["auth"]): AuthSessionState => ({ authenticated: false, @@ -276,64 +269,6 @@ describe("resolveInitialServerAuthGateState", () => { expect(attempts).toBe(4); }); - it("degrades to requires-auth instead of crashing beforeLoad when the initial session fetch fails", async () => { - // Reproduces the launch crash: the initial fetchSessionState() goes - // through the desktop bearer-token path, and a failed bearer bootstrap - // surfaced as an uncaught error in beforeLoad. The fix wraps the initial - // fetch so a failure degrades to requires-auth with an error message. - const runner: PrimaryHttpEffectRunner = async () => { - throw new Error("Failed to create the local desktop bearer session."); - }; - __setPrimaryHttpRunnerForTests(runner); - installDesktopBootstrap(); - - const { resolveInitialServerAuthGateState } = await import("./environments/primary"); - - // fetchSessionState wraps the thrown error as a PrimaryEnvironmentRequestError - // (status 500 for a non-HTTP cause). The important behavior is that the gate - // resolves to requires-auth instead of rejecting — before the fix this - // rejection crashed TanStack Router's beforeLoad. - await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ - status: "requires-auth", - auth: DESKTOP_AUTH, - errorMessage: "Primary environment request failed during fetch-session-state (HTTP 500).", - }); - }); - - it("uses loopback-browser fallback metadata when the session fetch fails without a desktop bridge", async () => { - const runner: PrimaryHttpEffectRunner = async () => { - throw new Error("session fetch failed"); - }; - __setPrimaryHttpRunnerForTests(runner); - installTestBrowser("http://localhost/"); - - const { resolveInitialServerAuthGateState } = await import("./environments/primary"); - - await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ - status: "requires-auth", - auth: LOOPBACK_AUTH, - errorMessage: "Primary environment request failed during fetch-session-state (HTTP 500).", - }); - }); - - it("uses remote-reachable fallback metadata when the session fetch fails on a remote origin", async () => { - const runner: PrimaryHttpEffectRunner = async () => { - throw new Error("session fetch failed"); - }; - __setPrimaryHttpRunnerForTests(runner); - vi.stubEnv("VITE_HTTP_URL", "https://remote.example.com"); - vi.stubEnv("VITE_WS_URL", "wss://remote.example.com"); - installTestBrowser("https://remote.example.com/"); - - const { resolveInitialServerAuthGateState } = await import("./environments/primary"); - - await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ - status: "requires-auth", - auth: REMOTE_AUTH, - errorMessage: "Primary environment request failed during fetch-session-state (HTTP 500).", - }); - }); - it("takes a pairing token from the location hash and strips it immediately", async () => { const testWindow = installTestBrowser("http://localhost/#token=pairing-token"); const { takePairingTokenFromUrl } = await import("./environments/primary"); diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index 6e916be2e97a..5a09dcb12884 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -18,10 +18,8 @@ import { stripPairingTokenFromUrl as stripPairingTokenUrl, } from "../../pairingUrl"; -import { runPrimaryHttp } from "../../lib/runtime"; - import { PrimaryEnvironmentHttpClient } from "./httpClient"; -import { isLoopbackHostname, readPrimaryEnvironmentTarget } from "./target"; +import { runPrimaryHttp } from "../../lib/runtime"; const PrimaryEnvironmentRequestOperation = Schema.Literals([ "fetch-session-state", @@ -309,67 +307,8 @@ function isTransientBootstrapError(error: unknown): boolean { return error instanceof DOMException && error.name === "AbortError"; } -const FALLBACK_SESSION_COOKIE_NAME = "t3_session"; - -function fallbackAuthDescriptor(): AuthSessionState["auth"] { - // The session endpoint is the source of truth for auth metadata. When it is - // unreachable we still need a descriptor so PairingRouteSurface can render - // the right pairing copy. Desktop has a bridge; browsers do not, so they - // must not be sent down the desktop-bootstrap path. - if (window.desktopBridge !== undefined) { - return { - policy: "desktop-managed-local", - bootstrapMethods: ["desktop-bootstrap"], - sessionMethods: ["browser-session-cookie"], - sessionCookieName: FALLBACK_SESSION_COOKIE_NAME, - }; - } - - const hostname = readFallbackAuthHostname(); - const isRemote = hostname === null || !isLoopbackHostname(hostname); - return { - policy: isRemote ? "remote-reachable" : "loopback-browser", - bootstrapMethods: ["one-time-token"], - sessionMethods: ["browser-session-cookie"], - sessionCookieName: FALLBACK_SESSION_COOKIE_NAME, - }; -} - -function readFallbackAuthHostname(): string | null { - try { - const target = readPrimaryEnvironmentTarget(); - if (target) { - return new URL(target.target.httpBaseUrl).hostname; - } - } catch { - // Fall through to the page origin if the primary target is unreadable. - } - try { - return new URL(window.location.href).hostname; - } catch { - return null; - } -} - async function bootstrapServerAuth(urlCredential: string | null): Promise { - // The initial session fetch goes through the desktop bearer-token path - // (withPrimaryBearerToken in httpLayer.ts), which calls the main-process - // IPC getLocalEnvironmentBearerToken -> DesktopLocalEnvironmentAuth. - // A transient failure there (or in the /api/auth/session request itself) - // used to escape beforeLoad and render the root error component, crashing - // the app on launch. Fall back to requires-auth so the auth gate renders - // the pairing UI and the next navigation re-attempts bootstrap, mirroring - // how the cookie-exchange path below degrades. - let currentSession: AuthSessionState; - try { - currentSession = await fetchSessionState(); - } catch (error) { - return { - status: "requires-auth", - auth: fallbackAuthDescriptor(), - errorMessage: error instanceof Error ? error.message : "Authentication failed.", - }; - } + const currentSession = await fetchSessionState(); if (currentSession.authenticated && !urlCredential) { return { status: "authenticated" }; }