Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 27 additions & 11 deletions apps/web/src/authBootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }]);
});

Expand Down
154 changes: 93 additions & 61 deletions apps/web/src/environments/primary/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>()(
"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 {
Expand Down Expand Up @@ -106,10 +148,10 @@ export async function fetchSessionState(): Promise<AuthSessionState> {
),
);
} 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}).`,
});
}
});
Expand Down Expand Up @@ -183,11 +225,11 @@ async function exchangeBootstrapCredential(credential: string): Promise<AuthBrow
),
);
} catch (error) {
const status = readHttpApiStatus(error) ?? 500;
const message = toFriendlyBootstrapErrorMessage(status, readHttpApiErrorMessage(error, ""));
throw new BootstrapHttpError({
message: message || `Failed to bootstrap auth session (${status}).`,
status,
throw PrimaryEnvironmentRequestError.fromCause({
operation: "exchange-bootstrap-credential",
cause: error,
fallbackMessage: (status) => `Failed to bootstrap auth session (${status}).`,
formatDetail: (detail, status) => toFriendlyBootstrapErrorMessage(status, detail),
});
}
});
Expand Down Expand Up @@ -240,7 +282,7 @@ function waitForBootstrapRetry(delayMs: number): Promise<void> {
}

function isTransientBootstrapError(error: unknown): boolean {
if (isBootstrapHttpError(error)) {
if (isPrimaryEnvironmentRequestError(error)) {
return TRANSIENT_BOOTSTRAP_STATUS_CODES.has(error.status);
}

Expand Down Expand Up @@ -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}).`,
});
}
}

Expand Down Expand Up @@ -353,13 +393,11 @@ export async function listServerPairingLinks(): Promise<ReadonlyArray<ServerPair
};
});
} catch (error) {
throw new Error(
readHttpApiErrorMessage(
error,
`Failed to load pairing links (${readHttpApiStatus(error) ?? "unknown"}).`,
),
{ cause: error },
);
throw PrimaryEnvironmentRequestError.fromCause({
operation: "list-pairing-links",
cause: error,
fallbackMessage: (status) => `Failed to load pairing links (${status}).`,
});
}
}

Expand All @@ -371,13 +409,12 @@ export async function revokeServerPairingLink(id: string): Promise<void> {
),
);
} 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}).`,
});
}
}

Expand Down Expand Up @@ -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}).`,
});
}
}

Expand All @@ -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}).`,
});
}
}

Expand All @@ -445,13 +479,11 @@ export async function revokeOtherServerClientSessions(): Promise<number> {
);
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}).`,
});
}
}

Expand Down
76 changes: 76 additions & 0 deletions apps/web/src/environments/primary/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"

import {
getPrimaryKnownEnvironment,
isDesktopEnvironmentBootstrapIncompleteError,
isPrimaryEnvironmentProtocolUnsupportedError,
isPrimaryEnvironmentUrlInvalidError,
readPrimaryEnvironmentTarget,
resolvePrimaryEnvironmentHttpUrl,
resolveInitialPrimaryEnvironmentDescriptor,
resetPrimaryEnvironmentDescriptorForTests,
Expand Down Expand Up @@ -43,6 +47,15 @@ function installTestBrowser(url: string) {
});
}

function captureThrown(run: () => unknown): unknown {
try {
run();
} catch (error) {
return error;
}
throw new Error("Expected the operation to throw.");
}

describe("environmentBootstrap", () => {
beforeEach(() => {
vi.restoreAllMocks();
Expand Down Expand Up @@ -175,4 +188,67 @@ describe("environmentBootstrap", () => {
"http://127.0.0.1:5733/.well-known/t3/environment",
);
});

it("retains the URL parser cause without exposing the configured URL in its message", () => {
vi.stubEnv("VITE_HTTP_URL", "http://[");

const error = captureThrown(readPrimaryEnvironmentTarget);

expect(isPrimaryEnvironmentUrlInvalidError(error)).toBe(true);
if (!isPrimaryEnvironmentUrlInvalidError(error)) {
throw new Error("Expected a structured primary environment URL error.");
}
expect(error).toMatchObject({
source: "configured",
urlKind: "http-base-url",
message: "Could not parse http-base-url for the configured primary environment target.",
});
expect(error.cause).toBeInstanceOf(TypeError);
expect(error.message).not.toContain("http://[");
});

it("describes which desktop bootstrap endpoint is missing", () => {
vi.stubGlobal("window", {
location: new URL("http://127.0.0.1:5733/"),
history: { replaceState: vi.fn() },
desktopBridge: {
getLocalEnvironmentBootstrap: () => ({
label: "Local environment",
httpBaseUrl: "http://127.0.0.1:3773",
bootstrapToken: "desktop-bootstrap-token",
}),
},
});

const error = captureThrown(readPrimaryEnvironmentTarget);

expect(isDesktopEnvironmentBootstrapIncompleteError(error)).toBe(true);
if (!isDesktopEnvironmentBootstrapIncompleteError(error)) {
throw new Error("Expected a structured desktop bootstrap error.");
}
expect(error).toMatchObject({
hasHttpBaseUrl: true,
hasWsBaseUrl: false,
message: "Desktop bootstrap is missing wsBaseUrl for the local environment.",
});
});

it("preserves an unsupported window-origin protocol", () => {
vi.stubGlobal("window", {
location: { origin: "file:///tmp/t3code/" },
history: { replaceState: vi.fn() },
});

const error = captureThrown(readPrimaryEnvironmentTarget);

expect(isPrimaryEnvironmentProtocolUnsupportedError(error)).toBe(true);
if (!isPrimaryEnvironmentProtocolUnsupportedError(error)) {
throw new Error("Expected a structured primary environment protocol error.");
}
expect(error).toMatchObject({
source: "window-origin",
protocol: "file:",
message: "The window-origin primary environment target uses unsupported protocol file:.",
});
});
});
14 changes: 5 additions & 9 deletions apps/web/src/environments/primary/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -44,13 +43,10 @@ async function fetchPrimaryEnvironmentDescriptor(): Promise<ExecutionEnvironment
PrimaryEnvironmentHttpClient.pipe(Effect.flatMap((client) => 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}).`,
});
}

Expand Down
Loading
Loading