authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 11:22:28-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 11:22:28-07:00
logc3e3e26844a591f2bb21e617db3c5494e700fbcc
treef407a8a4337511969217348b81dc3972ec80c628
parent1486a4a2b9c3f5a83d54f5ce591a2f60fba9316f
signature Signed by PGP key B5690EEEBB952194

[codex] Structure primary environment request failures (#3409)

Co-authored-by: codex <codex@users.noreply.github.com>

4 files changed, 127 insertions(+), 81 deletions(-)

apps/web/src/authBootstrap.test.ts+27-11
...@@ -290,22 +290,38 @@ describe("resolveInitialServerAuthGateState", () => {...@@ -290,22 +290,38 @@ describe("resolveInitialServerAuthGateState", () => {
290 });290 });
291291
292 it("surfaces a friendly error message when an invalid pairing token is submitted", async () => {292 it("surfaces a friendly error message when an invalid pairing token is submitted", async () => {
293 const cause = new EnvironmentAuthInvalidError({
294 code: "auth_invalid",
295 reason: "invalid_credential",
296 traceId: "trace-invalid-credential",
297 });
293 const testApi = await installAuthApi({298 const testApi = await installAuthApi({
294 browserSession: () =>299 browserSession: () => Effect.fail(cause),
295 Effect.fail(
296 new EnvironmentAuthInvalidError({
297 code: "auth_invalid",
298 reason: "invalid_credential",
299 traceId: "trace-invalid-credential",
300 }),
301 ),
302 });300 });
303301
304 const { submitServerAuthCredential } = await import("./environments/primary");302 const { isPrimaryEnvironmentRequestError, submitServerAuthCredential } =
303 await import("./environments/primary");
305304
306 await expect(submitServerAuthCredential("bad-token")).rejects.toThrow(305 const error = await submitServerAuthCredential("bad-token").then(
307 "Invalid pairing token. Check the token and try again.",306 () => null,
307 (failure: unknown) => failure,
308 );308 );
309 expect(error).toMatchObject({
310 _tag: "PrimaryEnvironmentRequestError",
311 operation: "exchange-bootstrap-credential",
312 status: 401,
313 detail: "Invalid pairing token. Check the token and try again.",
314 });
315 expect(isPrimaryEnvironmentRequestError(error)).toBe(true);
316 if (!isPrimaryEnvironmentRequestError(error)) {
317 throw new Error("Expected a structured primary environment request error.");
318 }
319 expect(error.cause).toMatchObject({
320 _tag: "EnvironmentAuthInvalidError",
321 code: "auth_invalid",
322 reason: "invalid_credential",
323 traceId: "trace-invalid-credential",
324 });
309 expect(testApi.calls.browserSession).toEqual([{ credential: "bad-token" }]);325 expect(testApi.calls.browserSession).toEqual([{ credential: "bad-token" }]);
310 });326 });
311327
apps/web/src/environments/primary/auth.ts+93-61
...@@ -21,15 +21,57 @@ import {...@@ -21,15 +21,57 @@ import {
2121
22import { PrimaryEnvironmentHttpClient } from "./httpClient";22import { PrimaryEnvironmentHttpClient } from "./httpClient";
23import { runPrimaryHttp } from "../../lib/runtime";23import { runPrimaryHttp } from "../../lib/runtime";
24import * as Data from "effect/Data";24
25import * as Predicate from "effect/Predicate";25const PrimaryEnvironmentRequestOperation = Schema.Literals([
2626 "fetch-session-state",
27export class BootstrapHttpError extends Data.TaggedError("BootstrapHttpError")<{27 "exchange-bootstrap-credential",
28 readonly message: string;28 "fetch-environment-descriptor",
29 readonly status: number;29 "create-pairing-credential",
30}> {}30 "list-pairing-links",
31const isBootstrapHttpError = (u: unknown): u is BootstrapHttpError =>31 "revoke-pairing-link",
32 Predicate.isTagged(u, "BootstrapHttpError");32 "list-client-sessions",
33 "revoke-client-session",
34 "revoke-other-client-sessions",
35]);
36type PrimaryEnvironmentRequestOperation = typeof PrimaryEnvironmentRequestOperation.Type;
37
38export class PrimaryEnvironmentRequestError extends Schema.TaggedErrorClass<PrimaryEnvironmentRequestError>()(
39 "PrimaryEnvironmentRequestError",
40 {
41 operation: PrimaryEnvironmentRequestOperation,
42 status: Schema.Number,
43 detail: Schema.String,
44 pairingLinkId: Schema.optional(Schema.String),
45 sessionId: Schema.optional(Schema.String),
46 cause: Schema.Defect(),
47 },
48) {
49 static fromCause(input: {
50 readonly operation: PrimaryEnvironmentRequestOperation;
51 readonly cause: unknown;
52 readonly fallbackMessage: (status: number) => string;
53 readonly formatDetail?: (detail: string, status: number) => string;
54 readonly pairingLinkId?: string;
55 readonly sessionId?: string;
56 }): PrimaryEnvironmentRequestError {
57 const status = readHttpApiStatus(input.cause) ?? 500;
58 const rawDetail = readHttpApiErrorMessage(input.cause, input.fallbackMessage(status));
59 return new PrimaryEnvironmentRequestError({
60 operation: input.operation,
61 status,
62 detail: input.formatDetail?.(rawDetail, status) ?? rawDetail,
63 ...(input.pairingLinkId !== undefined ? { pairingLinkId: input.pairingLinkId } : {}),
64 ...(input.sessionId !== undefined ? { sessionId: input.sessionId } : {}),
65 cause: input.cause,
66 });
67 }
68
69 override get message(): string {
70 return this.detail;
71 }
72}
73
74export const isPrimaryEnvironmentRequestError = Schema.is(PrimaryEnvironmentRequestError);
33const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError);75const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError);
3476
35export interface ServerPairingLinkRecord {77export interface ServerPairingLinkRecord {
...@@ -106,10 +148,10 @@ export async function fetchSessionState(): Promise<AuthSessionState> {...@@ -106,10 +148,10 @@ export async function fetchSessionState(): Promise<AuthSessionState> {
106 ),148 ),
107 );149 );
108 } catch (error) {150 } catch (error) {
109 const status = readHttpApiStatus(error);151 throw PrimaryEnvironmentRequestError.fromCause({
110 throw new BootstrapHttpError({152 operation: "fetch-session-state",
111 message: `Failed to load server auth session state (${status ?? "unknown"}).`,153 cause: error,
112 status: status ?? 500,154 fallbackMessage: (status) => `Failed to load server auth session state (${status}).`,
113 });155 });
114 }156 }
115 });157 });
...@@ -183,11 +225,11 @@ async function exchangeBootstrapCredential(credential: string): Promise<AuthBrow...@@ -183,11 +225,11 @@ async function exchangeBootstrapCredential(credential: string): Promise<AuthBrow
183 ),225 ),
184 );226 );
185 } catch (error) {227 } catch (error) {
186 const status = readHttpApiStatus(error) ?? 500;228 throw PrimaryEnvironmentRequestError.fromCause({
187 const message = toFriendlyBootstrapErrorMessage(status, readHttpApiErrorMessage(error, ""));229 operation: "exchange-bootstrap-credential",
188 throw new BootstrapHttpError({230 cause: error,
189 message: message || `Failed to bootstrap auth session (${status}).`,231 fallbackMessage: (status) => `Failed to bootstrap auth session (${status}).`,
190 status,232 formatDetail: (detail, status) => toFriendlyBootstrapErrorMessage(status, detail),
191 });233 });
192 }234 }
193 });235 });
...@@ -240,7 +282,7 @@ function waitForBootstrapRetry(delayMs: number): Promise<void> {...@@ -240,7 +282,7 @@ function waitForBootstrapRetry(delayMs: number): Promise<void> {
240}282}
241283
242function isTransientBootstrapError(error: unknown): boolean {284function isTransientBootstrapError(error: unknown): boolean {
243 if (isBootstrapHttpError(error)) {285 if (isPrimaryEnvironmentRequestError(error)) {
244 return TRANSIENT_BOOTSTRAP_STATUS_CODES.has(error.status);286 return TRANSIENT_BOOTSTRAP_STATUS_CODES.has(error.status);
245 }287 }
246288
...@@ -310,13 +352,11 @@ export async function createServerPairingCredential(input?: {...@@ -310,13 +352,11 @@ export async function createServerPairingCredential(input?: {
310 ),352 ),
311 );353 );
312 } catch (error) {354 } catch (error) {
313 throw new Error(355 throw PrimaryEnvironmentRequestError.fromCause({
314 readHttpApiErrorMessage(356 operation: "create-pairing-credential",
315 error,357 cause: error,
316 `Failed to create pairing credential (${readHttpApiStatus(error) ?? "unknown"}).`,358 fallbackMessage: (status) => `Failed to create pairing credential (${status}).`,
317 ),359 });
318 { cause: error },
319 );
320 }360 }
321}361}
322362
...@@ -353,13 +393,11 @@ export async function listServerPairingLinks(): Promise<ReadonlyArray<ServerPair...@@ -353,13 +393,11 @@ export async function listServerPairingLinks(): Promise<ReadonlyArray<ServerPair
353 };393 };
354 });394 });
355 } catch (error) {395 } catch (error) {
356 throw new Error(396 throw PrimaryEnvironmentRequestError.fromCause({
357 readHttpApiErrorMessage(397 operation: "list-pairing-links",
358 error,398 cause: error,
359 `Failed to load pairing links (${readHttpApiStatus(error) ?? "unknown"}).`,399 fallbackMessage: (status) => `Failed to load pairing links (${status}).`,
360 ),400 });
361 { cause: error },
362 );
363 }401 }
364}402}
365403
...@@ -371,13 +409,12 @@ export async function revokeServerPairingLink(id: string): Promise<void> {...@@ -371,13 +409,12 @@ export async function revokeServerPairingLink(id: string): Promise<void> {
371 ),409 ),
372 );410 );
373 } catch (error) {411 } catch (error) {
374 throw new Error(412 throw PrimaryEnvironmentRequestError.fromCause({
375 readHttpApiErrorMessage(413 operation: "revoke-pairing-link",
376 error,414 pairingLinkId: id,
377 `Failed to revoke pairing link (${readHttpApiStatus(error) ?? "unknown"}).`,415 cause: error,
378 ),416 fallbackMessage: (status) => `Failed to revoke pairing link (${status}).`,
379 { cause: error },417 });
380 );
381 }418 }
382}419}
383420
...@@ -406,13 +443,11 @@ export async function listServerClientSessions(): Promise<...@@ -406,13 +443,11 @@ export async function listServerClientSessions(): Promise<
406 current: clientSession.current,443 current: clientSession.current,
407 }));444 }));
408 } catch (error) {445 } catch (error) {
409 throw new Error(446 throw PrimaryEnvironmentRequestError.fromCause({
410 readHttpApiErrorMessage(447 operation: "list-client-sessions",
411 error,448 cause: error,
412 `Failed to load paired clients (${readHttpApiStatus(error) ?? "unknown"}).`,449 fallbackMessage: (status) => `Failed to load paired clients (${status}).`,
413 ),450 });
414 { cause: error },
415 );
416 }451 }
417}452}
418453
...@@ -426,13 +461,12 @@ export async function revokeServerClientSession(sessionId: AuthSessionId): Promi...@@ -426,13 +461,12 @@ export async function revokeServerClientSession(sessionId: AuthSessionId): Promi
426 ),461 ),
427 );462 );
428 } catch (error) {463 } catch (error) {
429 throw new Error(464 throw PrimaryEnvironmentRequestError.fromCause({
430 readHttpApiErrorMessage(465 operation: "revoke-client-session",
431 error,466 sessionId,
432 `Failed to revoke client session (${readHttpApiStatus(error) ?? "unknown"}).`,467 cause: error,
433 ),468 fallbackMessage: (status) => `Failed to revoke client session (${status}).`,
434 { cause: error },469 });
435 );
436 }470 }
437}471}
438472
...@@ -445,13 +479,11 @@ export async function revokeOtherServerClientSessions(): Promise<number> {...@@ -445,13 +479,11 @@ export async function revokeOtherServerClientSessions(): Promise<number> {
445 );479 );
446 return result.revokedCount;480 return result.revokedCount;
447 } catch (error) {481 } catch (error) {
448 throw new Error(482 throw PrimaryEnvironmentRequestError.fromCause({
449 readHttpApiErrorMessage(483 operation: "revoke-other-client-sessions",
450 error,484 cause: error,
451 `Failed to revoke other client sessions (${readHttpApiStatus(error) ?? "unknown"}).`,485 fallbackMessage: (status) => `Failed to revoke other client sessions (${status}).`,
452 ),486 });
453 { cause: error },
454 );
455 }487 }
456}488}
457489
apps/web/src/environments/primary/context.ts+5-9
...@@ -5,9 +5,8 @@ import {...@@ -5,9 +5,8 @@ import {
5} from "@t3tools/client-runtime/environment";5} from "@t3tools/client-runtime/environment";
6import type { ExecutionEnvironmentDescriptor } from "@t3tools/contracts";6import type { ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
7import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
8import { HttpClientError } from "effect/unstable/http";
98
10import { BootstrapHttpError, retryTransientBootstrap } from "./auth";9import { PrimaryEnvironmentRequestError, retryTransientBootstrap } from "./auth";
11import { PrimaryEnvironmentHttpClient } from "./httpClient";10import { PrimaryEnvironmentHttpClient } from "./httpClient";
1211
13import { runPrimaryHttp } from "../../lib/runtime";12import { runPrimaryHttp } from "../../lib/runtime";
...@@ -44,13 +43,10 @@ async function fetchPrimaryEnvironmentDescriptor(): Promise<ExecutionEnvironment...@@ -44,13 +43,10 @@ async function fetchPrimaryEnvironmentDescriptor(): Promise<ExecutionEnvironment
44 PrimaryEnvironmentHttpClient.pipe(Effect.flatMap((client) => client.metadata.descriptor())),43 PrimaryEnvironmentHttpClient.pipe(Effect.flatMap((client) => client.metadata.descriptor())),
45 );44 );
46 } catch (error) {45 } catch (error) {
47 const status =46 throw PrimaryEnvironmentRequestError.fromCause({
48 HttpClientError.isHttpClientError(error) && error.response !== undefined47 operation: "fetch-environment-descriptor",
49 ? error.response.status48 cause: error,
50 : 500;49 fallbackMessage: (status) => `Failed to load server environment descriptor (${status}).`,
51 throw new BootstrapHttpError({
52 message: `Failed to load server environment descriptor (${status}).`,
53 status,
54 });50 });
55 }51 }
5652
apps/web/src/environments/primary/index.ts+2
...@@ -16,9 +16,11 @@ export {...@@ -16,9 +16,11 @@ export {
16export {16export {
17 createServerPairingCredential,17 createServerPairingCredential,
18 fetchSessionState,18 fetchSessionState,
19 isPrimaryEnvironmentRequestError,
19 listServerClientSessions,20 listServerClientSessions,
20 listServerPairingLinks,21 listServerPairingLinks,
21 peekPairingTokenFromUrl,22 peekPairingTokenFromUrl,
23 PrimaryEnvironmentRequestError,
22 resolveInitialServerAuthGateState,24 resolveInitialServerAuthGateState,
23 revokeOtherServerClientSessions,25 revokeOtherServerClientSessions,
24 revokeServerClientSession,26 revokeServerClientSession,