diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 53c17c06402..c0713bfc059 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -290,22 +290,38 @@ describe("resolveInitialServerAuthGateState", () => { }); it("surfaces a friendly error message when an invalid pairing token is submitted", async () => { + const cause = new EnvironmentAuthInvalidError({ + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-invalid-credential", + }); const testApi = await installAuthApi({ - browserSession: () => - Effect.fail( - new EnvironmentAuthInvalidError({ - code: "auth_invalid", - reason: "invalid_credential", - traceId: "trace-invalid-credential", - }), - ), + browserSession: () => Effect.fail(cause), }); - const { submitServerAuthCredential } = await import("./environments/primary"); + const { isPrimaryEnvironmentRequestError, submitServerAuthCredential } = + await import("./environments/primary"); - await expect(submitServerAuthCredential("bad-token")).rejects.toThrow( - "Invalid pairing token. Check the token and try again.", + const error = await submitServerAuthCredential("bad-token").then( + () => null, + (failure: unknown) => failure, ); + expect(error).toMatchObject({ + _tag: "PrimaryEnvironmentRequestError", + operation: "exchange-bootstrap-credential", + status: 401, + detail: "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(error.cause).toMatchObject({ + _tag: "EnvironmentAuthInvalidError", + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-invalid-credential", + }); expect(testApi.calls.browserSession).toEqual([{ credential: "bad-token" }]); }); diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index f6f07dbb303..5cf7d2d34b7 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -21,15 +21,57 @@ import { import { PrimaryEnvironmentHttpClient } from "./httpClient"; import { runPrimaryHttp } from "../../lib/runtime"; -import * as Data from "effect/Data"; -import * as Predicate from "effect/Predicate"; - -export class BootstrapHttpError extends Data.TaggedError("BootstrapHttpError")<{ - readonly message: string; - readonly status: number; -}> {} -const isBootstrapHttpError = (u: unknown): u is BootstrapHttpError => - Predicate.isTagged(u, "BootstrapHttpError"); + +const PrimaryEnvironmentRequestOperation = Schema.Literals([ + "fetch-session-state", + "exchange-bootstrap-credential", + "fetch-environment-descriptor", + "create-pairing-credential", + "list-pairing-links", + "revoke-pairing-link", + "list-client-sessions", + "revoke-client-session", + "revoke-other-client-sessions", +]); +type PrimaryEnvironmentRequestOperation = typeof PrimaryEnvironmentRequestOperation.Type; + +export class PrimaryEnvironmentRequestError extends Schema.TaggedErrorClass()( + "PrimaryEnvironmentRequestError", + { + operation: PrimaryEnvironmentRequestOperation, + status: Schema.Number, + detail: Schema.String, + pairingLinkId: Schema.optional(Schema.String), + sessionId: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + static fromCause(input: { + readonly operation: PrimaryEnvironmentRequestOperation; + readonly cause: unknown; + readonly fallbackMessage: (status: number) => 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, + }); + } + + override get message(): string { + return this.detail; + } +} + +export const isPrimaryEnvironmentRequestError = Schema.is(PrimaryEnvironmentRequestError); const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); export interface ServerPairingLinkRecord { @@ -106,10 +148,10 @@ export async function fetchSessionState(): Promise { ), ); } catch (error) { - const status = readHttpApiStatus(error); - throw new BootstrapHttpError({ - message: `Failed to load server auth session state (${status ?? "unknown"}).`, - status: status ?? 500, + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "fetch-session-state", + cause: error, + fallbackMessage: (status) => `Failed to load server auth session state (${status}).`, }); } }); @@ -183,11 +225,11 @@ async function exchangeBootstrapCredential(credential: string): Promise `Failed to bootstrap auth session (${status}).`, + formatDetail: (detail, status) => toFriendlyBootstrapErrorMessage(status, detail), }); } }); @@ -240,7 +282,7 @@ function waitForBootstrapRetry(delayMs: number): Promise { } function isTransientBootstrapError(error: unknown): boolean { - if (isBootstrapHttpError(error)) { + if (isPrimaryEnvironmentRequestError(error)) { return TRANSIENT_BOOTSTRAP_STATUS_CODES.has(error.status); } @@ -310,13 +352,11 @@ export async function createServerPairingCredential(input?: { ), ); } catch (error) { - throw new Error( - readHttpApiErrorMessage( - error, - `Failed to create pairing credential (${readHttpApiStatus(error) ?? "unknown"}).`, - ), - { cause: error }, - ); + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "create-pairing-credential", + cause: error, + fallbackMessage: (status) => `Failed to create pairing credential (${status}).`, + }); } } @@ -353,13 +393,11 @@ export async function listServerPairingLinks(): Promise `Failed to load pairing links (${status}).`, + }); } } @@ -371,13 +409,12 @@ export async function revokeServerPairingLink(id: string): Promise { ), ); } catch (error) { - throw new Error( - readHttpApiErrorMessage( - error, - `Failed to revoke pairing link (${readHttpApiStatus(error) ?? "unknown"}).`, - ), - { cause: error }, - ); + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "revoke-pairing-link", + pairingLinkId: id, + cause: error, + fallbackMessage: (status) => `Failed to revoke pairing link (${status}).`, + }); } } @@ -406,13 +443,11 @@ export async function listServerClientSessions(): Promise< current: clientSession.current, })); } catch (error) { - throw new Error( - readHttpApiErrorMessage( - error, - `Failed to load paired clients (${readHttpApiStatus(error) ?? "unknown"}).`, - ), - { cause: error }, - ); + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "list-client-sessions", + cause: error, + fallbackMessage: (status) => `Failed to load paired clients (${status}).`, + }); } } @@ -426,13 +461,12 @@ export async function revokeServerClientSession(sessionId: AuthSessionId): Promi ), ); } catch (error) { - throw new Error( - readHttpApiErrorMessage( - error, - `Failed to revoke client session (${readHttpApiStatus(error) ?? "unknown"}).`, - ), - { cause: error }, - ); + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "revoke-client-session", + sessionId, + cause: error, + fallbackMessage: (status) => `Failed to revoke client session (${status}).`, + }); } } @@ -445,13 +479,11 @@ export async function revokeOtherServerClientSessions(): Promise { ); return result.revokedCount; } catch (error) { - throw new Error( - readHttpApiErrorMessage( - error, - `Failed to revoke other client sessions (${readHttpApiStatus(error) ?? "unknown"}).`, - ), - { cause: error }, - ); + 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 eb818e8f558..40b6f68bd09 100644 --- a/apps/web/src/environments/primary/context.ts +++ b/apps/web/src/environments/primary/context.ts @@ -5,9 +5,8 @@ import { } from "@t3tools/client-runtime/environment"; import type { ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import { HttpClientError } from "effect/unstable/http"; -import { BootstrapHttpError, retryTransientBootstrap } from "./auth"; +import { PrimaryEnvironmentRequestError, retryTransientBootstrap } from "./auth"; import { PrimaryEnvironmentHttpClient } from "./httpClient"; import { runPrimaryHttp } from "../../lib/runtime"; @@ -44,13 +43,10 @@ async function fetchPrimaryEnvironmentDescriptor(): Promise client.metadata.descriptor())), ); } catch (error) { - const status = - HttpClientError.isHttpClientError(error) && error.response !== undefined - ? error.response.status - : 500; - throw new BootstrapHttpError({ - message: `Failed to load server environment descriptor (${status}).`, - status, + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "fetch-environment-descriptor", + cause: error, + fallbackMessage: (status) => `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 305ced9c905..3cb570d66ab 100644 --- a/apps/web/src/environments/primary/index.ts +++ b/apps/web/src/environments/primary/index.ts @@ -16,9 +16,11 @@ export { export { createServerPairingCredential, fetchSessionState, + isPrimaryEnvironmentRequestError, listServerClientSessions, listServerPairingLinks, peekPairingTokenFromUrl, + PrimaryEnvironmentRequestError, resolveInitialServerAuthGateState, revokeOtherServerClientSessions, revokeServerClientSession,