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,