diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index c0713bfc059..ced16c15f4e 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -71,6 +71,18 @@ function installTestBrowser(url: string) { return testWindow; } +function installDesktopBootstrap() { + const testWindow = installTestBrowser("http://localhost/"); + testWindow.desktopBridge = { + getLocalEnvironmentBootstrap: () => ({ + label: "Local environment", + httpBaseUrl: "http://localhost:3773", + wsBaseUrl: "ws://localhost:3773", + bootstrapToken: "desktop-bootstrap-token", + }), + } as DesktopBridge; +} + function sequence(...values: ReadonlyArray) { let index = 0; return () => values[Math.min(index++, values.length - 1)]!; @@ -131,15 +143,7 @@ describe("resolveInitialServerAuthGateState", () => { browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), }); - const testWindow = installTestBrowser("http://localhost/"); - testWindow.desktopBridge = { - getLocalEnvironmentBootstrap: () => ({ - label: "Local environment", - httpBaseUrl: "http://localhost:3773", - wsBaseUrl: "ws://localhost:3773", - bootstrapToken: "desktop-bootstrap-token", - }), - } as DesktopBridge; + installDesktopBootstrap(); const { resolveInitialServerAuthGateState } = await import("./environments/primary"); @@ -289,6 +293,23 @@ describe("resolveInitialServerAuthGateState", () => { expect(testApi.calls.session).toBe(2); }); + it("rejects a blank pairing token with a structured validation error", async () => { + const { PrimaryEnvironmentPairingCredentialRequiredError, submitServerAuthCredential } = + await import("./environments/primary/auth"); + + const error = await submitServerAuthCredential(" ").then( + () => null, + (failure: unknown) => failure, + ); + + expect(error).toBeInstanceOf(PrimaryEnvironmentPairingCredentialRequiredError); + expect(error).toMatchObject({ + _tag: "PrimaryEnvironmentPairingCredentialRequiredError", + providedLength: 3, + message: "Enter a pairing token to continue.", + }); + }); + it("surfaces a friendly error message when an invalid pairing token is submitted", async () => { const cause = new EnvironmentAuthInvalidError({ code: "auth_invalid", @@ -299,7 +320,7 @@ describe("resolveInitialServerAuthGateState", () => { browserSession: () => Effect.fail(cause), }); - const { isPrimaryEnvironmentRequestError, submitServerAuthCredential } = + const { isPrimaryEnvironmentPairingCredentialRejectedError, submitServerAuthCredential } = await import("./environments/primary"); const error = await submitServerAuthCredential("bad-token").then( @@ -307,14 +328,13 @@ describe("resolveInitialServerAuthGateState", () => { (failure: unknown) => failure, ); expect(error).toMatchObject({ - _tag: "PrimaryEnvironmentRequestError", - operation: "exchange-bootstrap-credential", - status: 401, - detail: "Invalid pairing token. Check the token and try again.", + _tag: "PrimaryEnvironmentPairingCredentialRejectedError", + providedLength: 9, + message: "Invalid pairing token. Check the token and try again.", }); - expect(isPrimaryEnvironmentRequestError(error)).toBe(true); - if (!isPrimaryEnvironmentRequestError(error)) { - throw new Error("Expected a structured primary environment request error."); + expect(isPrimaryEnvironmentPairingCredentialRejectedError(error)).toBe(true); + if (!isPrimaryEnvironmentPairingCredentialRejectedError(error)) { + throw new Error("Expected a structured rejected pairing credential error."); } expect(error.cause).toMatchObject({ _tag: "EnvironmentAuthInvalidError", @@ -325,6 +345,22 @@ describe("resolveInitialServerAuthGateState", () => { expect(testApi.calls.browserSession).toEqual([{ credential: "bad-token" }]); }); + it("derives primary request messages from structural request context", async () => { + const cause = new Error("private transport detail"); + const { PrimaryEnvironmentRequestError } = await import("./environments/primary"); + const error = PrimaryEnvironmentRequestError.fromCause({ + operation: "list-pairing-links", + cause, + }); + + expect(error.status).toBe(500); + expect(error.cause).toBe(cause); + expect(error.message).toBe( + "Primary environment request failed during list-pairing-links (HTTP 500).", + ); + expect(error.message).not.toContain(cause.message); + }); + it("waits for the authenticated session to become observable after silent desktop bootstrap", async () => { vi.useFakeTimers(); const nextSession = sequence( @@ -337,15 +373,7 @@ describe("resolveInitialServerAuthGateState", () => { browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), }); - const testWindow = installTestBrowser("http://localhost/"); - testWindow.desktopBridge = { - getLocalEnvironmentBootstrap: () => ({ - label: "Local environment", - httpBaseUrl: "http://localhost:3773", - wsBaseUrl: "ws://localhost:3773", - bootstrapToken: "desktop-bootstrap-token", - }), - } as DesktopBridge; + installDesktopBootstrap(); const { resolveInitialServerAuthGateState } = await import("./environments/primary"); @@ -356,6 +384,28 @@ describe("resolveInitialServerAuthGateState", () => { expect(testApi.calls.session).toBe(3); }); + it("preserves the timeout message when a bootstrapped session never becomes observable", async () => { + vi.useFakeTimers(); + const testApi = await installAuthApi({ + session: () => unauthenticatedSession(DESKTOP_AUTH), + browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), + }); + + installDesktopBootstrap(); + + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + const gateStatePromise = resolveInitialServerAuthGateState(); + await vi.advanceTimersByTimeAsync(2_000); + + await expect(gateStatePromise).resolves.toEqual({ + status: "requires-auth", + auth: DESKTOP_AUTH, + errorMessage: "Timed out waiting for authenticated session after bootstrap.", + }); + expect(testApi.calls.browserSession).toEqual([{ credential: "desktop-bootstrap-token" }]); + }); + it("memoizes the authenticated gate state after the first successful read", async () => { const testApi = await installAuthApi({ session: sequence(authenticatedSession(LOOPBACK_AUTH), unauthenticatedSession(LOOPBACK_AUTH)), diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index 5cf7d2d34b7..96814b92b79 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -40,7 +40,6 @@ export class PrimaryEnvironmentRequestError extends Schema.TaggedErrorClass string; - readonly formatDetail?: (detail: string, status: number) => string; readonly pairingLinkId?: string; readonly sessionId?: string; }): PrimaryEnvironmentRequestError { const status = readHttpApiStatus(input.cause) ?? 500; - const rawDetail = readHttpApiErrorMessage(input.cause, input.fallbackMessage(status)); return new PrimaryEnvironmentRequestError({ operation: input.operation, status, - detail: input.formatDetail?.(rawDetail, status) ?? rawDetail, ...(input.pairingLinkId !== undefined ? { pairingLinkId: input.pairingLinkId } : {}), ...(input.sessionId !== undefined ? { sessionId: input.sessionId } : {}), cause: input.cause, @@ -67,11 +62,59 @@ export class PrimaryEnvironmentRequestError extends Schema.TaggedErrorClass()( + "PrimaryEnvironmentPairingCredentialRejectedError", + { + providedLength: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Invalid pairing token. Check the token and try again."; + } +} + +export const isPrimaryEnvironmentPairingCredentialRejectedError = Schema.is( + PrimaryEnvironmentPairingCredentialRejectedError, +); + +export class PrimaryEnvironmentAuthSessionTimeoutError extends Schema.TaggedErrorClass()( + "PrimaryEnvironmentAuthSessionTimeoutError", + { + timeoutMs: Schema.Number, + elapsedMs: Schema.Number, + }, +) { + override get message(): string { + return "Timed out waiting for authenticated session after bootstrap."; + } +} + +export const isPrimaryEnvironmentAuthSessionTimeoutError = Schema.is( + PrimaryEnvironmentAuthSessionTimeoutError, +); + +export class PrimaryEnvironmentPairingCredentialRequiredError extends Schema.TaggedErrorClass()( + "PrimaryEnvironmentPairingCredentialRequiredError", + { + providedLength: Schema.Number, + }, +) { + override get message(): string { + return "Enter a pairing token to continue."; + } +} + +export const isPrimaryEnvironmentPairingCredentialRequiredError = Schema.is( + PrimaryEnvironmentPairingCredentialRequiredError, +); + const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); export interface ServerPairingLinkRecord { @@ -151,7 +194,6 @@ export async function fetchSessionState(): Promise { throw PrimaryEnvironmentRequestError.fromCause({ operation: "fetch-session-state", cause: error, - fallbackMessage: (status) => `Failed to load server auth session state (${status}).`, }); } }); @@ -180,42 +222,6 @@ function readEnvironmentHttpErrorStatus(error: EnvironmentHttpCommonErrorType): } } -function readHttpApiErrorMessage(error: unknown, fallbackMessage: string): string { - if (!isEnvironmentHttpCommonError(error)) { - return fallbackMessage; - } - switch (error._tag) { - case "EnvironmentAuthInvalidError": - return error.reason === "missing_credential" - ? "Authentication required." - : "Invalid bootstrap credential."; - case "EnvironmentRequestInvalidError": - return error.reason === "invalid_scope" - ? "Requested token scope is invalid." - : "Requested scope exceeds the bootstrap credential grant."; - case "EnvironmentScopeRequiredError": - return `The authenticated token is missing required scope: ${error.requiredScope}.`; - case "EnvironmentOperationForbiddenError": - return "This operation is not allowed for the current session."; - case "EnvironmentInternalError": - return fallbackMessage; - } -} - -const INVALID_BOOTSTRAP_CREDENTIAL_MESSAGES = new Set([ - "Invalid bootstrap credential.", - "Unknown bootstrap credential.", -]); - -function toFriendlyBootstrapErrorMessage(status: number, message: string): string { - const trimmedMessage = message.trim(); - if (status === 401 && INVALID_BOOTSTRAP_CREDENTIAL_MESSAGES.has(trimmedMessage)) { - return "Invalid pairing token. Check the token and try again."; - } - - return trimmedMessage; -} - async function exchangeBootstrapCredential(credential: string): Promise { return retryTransientBootstrap(async () => { try { @@ -225,11 +231,19 @@ async function exchangeBootstrapCredential(credential: string): Promise `Failed to bootstrap auth session (${status}).`, - formatDetail: (detail, status) => toFriendlyBootstrapErrorMessage(status, detail), }); } }); @@ -244,8 +258,12 @@ async function waitForAuthenticatedSessionAfterBootstrap(): Promise= AUTH_SESSION_ESTABLISH_TIMEOUT_MS) { - throw new Error("Timed out waiting for authenticated session after bootstrap."); + const elapsedMs = Date.now() - startedAt; + if (elapsedMs >= AUTH_SESSION_ESTABLISH_TIMEOUT_MS) { + throw new PrimaryEnvironmentAuthSessionTimeoutError({ + timeoutMs: AUTH_SESSION_ESTABLISH_TIMEOUT_MS, + elapsedMs, + }); } await waitForBootstrapRetry(AUTH_SESSION_ESTABLISH_STEP_MS); @@ -323,7 +341,9 @@ async function bootstrapServerAuth(): Promise { export async function submitServerAuthCredential(credential: string): Promise { const trimmedCredential = credential.trim(); if (!trimmedCredential) { - throw new Error("Enter a pairing token to continue."); + throw new PrimaryEnvironmentPairingCredentialRequiredError({ + providedLength: credential.length, + }); } resolvedAuthenticatedGateState = null; @@ -355,7 +375,6 @@ export async function createServerPairingCredential(input?: { throw PrimaryEnvironmentRequestError.fromCause({ operation: "create-pairing-credential", cause: error, - fallbackMessage: (status) => `Failed to create pairing credential (${status}).`, }); } } @@ -396,7 +415,6 @@ export async function listServerPairingLinks(): Promise `Failed to load pairing links (${status}).`, }); } } @@ -413,7 +431,6 @@ export async function revokeServerPairingLink(id: string): Promise { operation: "revoke-pairing-link", pairingLinkId: id, cause: error, - fallbackMessage: (status) => `Failed to revoke pairing link (${status}).`, }); } } @@ -446,7 +463,6 @@ export async function listServerClientSessions(): Promise< throw PrimaryEnvironmentRequestError.fromCause({ operation: "list-client-sessions", cause: error, - fallbackMessage: (status) => `Failed to load paired clients (${status}).`, }); } } @@ -465,7 +481,6 @@ export async function revokeServerClientSession(sessionId: AuthSessionId): Promi operation: "revoke-client-session", sessionId, cause: error, - fallbackMessage: (status) => `Failed to revoke client session (${status}).`, }); } } @@ -482,7 +497,6 @@ export async function revokeOtherServerClientSessions(): Promise { throw PrimaryEnvironmentRequestError.fromCause({ operation: "revoke-other-client-sessions", cause: error, - fallbackMessage: (status) => `Failed to revoke other client sessions (${status}).`, }); } } diff --git a/apps/web/src/environments/primary/context.ts b/apps/web/src/environments/primary/context.ts index 40b6f68bd09..e1021a7feb4 100644 --- a/apps/web/src/environments/primary/context.ts +++ b/apps/web/src/environments/primary/context.ts @@ -46,7 +46,6 @@ async function fetchPrimaryEnvironmentDescriptor(): Promise `Failed to load server environment descriptor (${status}).`, }); } diff --git a/apps/web/src/environments/primary/index.ts b/apps/web/src/environments/primary/index.ts index e888560539d..58342d53054 100644 --- a/apps/web/src/environments/primary/index.ts +++ b/apps/web/src/environments/primary/index.ts @@ -16,10 +16,12 @@ export { export { createServerPairingCredential, fetchSessionState, + isPrimaryEnvironmentPairingCredentialRejectedError, isPrimaryEnvironmentRequestError, listServerClientSessions, listServerPairingLinks, peekPairingTokenFromUrl, + PrimaryEnvironmentPairingCredentialRejectedError, PrimaryEnvironmentRequestError, resolveInitialServerAuthGateState, revokeOtherServerClientSessions,