authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 11:37:01-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 11:37:01-07:00
logd512deac84aecb8ec2e4978599ce31a3185f5350
treeb333bced9c009b93d4f6b154a05111929f57ac78
parent32c7f90d9e6d4045a709b2a0faf30b977bdf26da
signature Signed by PGP key B5690EEEBB952194

[codex] Structure preview URL failures (#3275)

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

5 files changed, 122 insertions(+), 35 deletions(-)

apps/server/src/preview/Manager.test.ts+26
......@@ -1,5 +1,6 @@
11import { it } from "@effect/vitest";
22import { type PreviewEvent, ThreadId } from "@t3tools/contracts";
3import { PreviewUrlNormalizationError } from "@t3tools/shared/preview";
34import { Effect, PubSub } from "effect";
45import { expect } from "vite-plus/test";
56
......@@ -83,6 +84,31 @@ it.layer(PreviewManager.layer)("PreviewManager", (it) => {
8384 const manager = yield* PreviewManager.PreviewManager;
8485 const error = yield* Effect.flip(manager.open({ threadId, url: " " }));
8586 expect(error._tag).toBe("PreviewInvalidUrlError");
87 expect(error).toMatchObject({ inputLength: 3, reason: "empty" });
88 expect(error).not.toHaveProperty("rawUrl");
89 expect(error.cause).toBeInstanceOf(PreviewUrlNormalizationError);
90 expect((error.cause as PreviewUrlNormalizationError).reason).toBe("empty");
91 }),
92 );
93
94 it.effect("preserves URL parser failures as the invalid URL cause chain", () =>
95 Effect.gen(function* () {
96 const threadId = freshThreadId();
97 const manager = yield* PreviewManager.PreviewManager;
98 const rawUrl = "https://user:password@example.com:bad/path?access_token=secret#fragment";
99 const error = yield* Effect.flip(manager.open({ threadId, url: rawUrl }));
100
101 expect(error).toMatchObject({
102 inputLength: rawUrl.length,
103 reason: "parse",
104 protocol: "https:",
105 });
106 expect(error).not.toHaveProperty("rawUrl");
107 expect(error.cause).toBeInstanceOf(PreviewUrlNormalizationError);
108 const normalizationError = error.cause as PreviewUrlNormalizationError;
109 expect(normalizationError.cause).toBeInstanceOf(Error);
110 expect(error.message).not.toContain((normalizationError.cause as Error).message);
111 expect(error.message).not.toMatch(/user|password|access_token|secret|fragment/);
86112 }),
87113 );
88114
apps/server/src/preview/Manager.ts+17-11
......@@ -24,9 +24,9 @@ import {
2424 type PreviewSessionSnapshot,
2525} from "@t3tools/contracts";
2626import {
27 isPreviewUrlNormalizationError,
2728 newPreviewTabId,
2829 normalizePreviewUrl,
29 PreviewUrlNormalizationError,
3030} from "@t3tools/shared/preview";
3131import * as Context from "effect/Context";
3232import * as DateTime from "effect/DateTime";
......@@ -82,16 +82,22 @@ const sessionsForThread = (
8282const normalizeUrl = (rawUrl: string): Effect.Effect<string, PreviewInvalidUrlError> =>
8383 Effect.try({
8484 try: () => normalizePreviewUrl(rawUrl),
85 catch: (cause) =>
86 new PreviewInvalidUrlError({
87 rawUrl,
88 detail:
89 cause instanceof PreviewUrlNormalizationError
90 ? cause.detail
91 : cause instanceof Error
92 ? cause.message
93 : String(cause),
94 }),
85 catch: (cause) => {
86 if (isPreviewUrlNormalizationError(cause)) {
87 return new PreviewInvalidUrlError({
88 inputLength: cause.inputLength,
89 reason: cause.reason,
90 protocol: cause.protocol,
91 cause,
92 });
93 }
94
95 return new PreviewInvalidUrlError({
96 inputLength: rawUrl.length,
97 reason: "unexpected",
98 cause,
99 });
100 },
95101 });
96102
97103const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso));
packages/contracts/src/preview.ts+6-5
......@@ -172,14 +172,15 @@ export class PreviewSessionLookupError extends Schema.TaggedErrorClass<PreviewSe
172172export class PreviewInvalidUrlError extends Schema.TaggedErrorClass<PreviewInvalidUrlError>()(
173173 "PreviewInvalidUrlError",
174174 {
175 rawUrl: Schema.String,
176 detail: Schema.optional(Schema.String),
175 inputLength: Schema.Number,
176 reason: Schema.Literals(["empty", "parse", "unsupported-protocol", "unexpected"]),
177 protocol: Schema.optional(Schema.String),
178 cause: Schema.Defect(),
177179 },
178180) {
179181 override get message() {
180 return this.detail
181 ? `Invalid preview URL: ${this.rawUrl} (${this.detail})`
182 : `Invalid preview URL: ${this.rawUrl}`;
182 const protocol = this.protocol === undefined ? "" : `: ${this.protocol}`;
183 return `Invalid preview URL (${this.reason}${protocol}; input length ${this.inputLength}).`;
183184 }
184185}
185186
packages/shared/src/preview.test.ts+41-5
......@@ -61,15 +61,51 @@ describe("normalizePreviewUrl", () => {
6161 });
6262
6363 it("rejects empty input", () => {
64 expect(() => normalizePreviewUrl(" ")).toThrow(PreviewUrlNormalizationError);
64 try {
65 normalizePreviewUrl(" ");
66 expect.unreachable("expected URL normalization to fail");
67 } catch (error) {
68 expect(error).toBeInstanceOf(PreviewUrlNormalizationError);
69 expect(error).toMatchObject({ inputLength: 3, reason: "empty" });
70 expect(error).not.toHaveProperty("rawUrl");
71 expect("cause" in (error as object)).toBe(false);
72 }
6573 });
6674
6775 it("rejects unsupported protocols", () => {
68 expect(() => normalizePreviewUrl("ftp://example.com")).toThrow(PreviewUrlNormalizationError);
69 expect(() => normalizePreviewUrl("file:///etc/passwd")).toThrow(PreviewUrlNormalizationError);
76 try {
77 normalizePreviewUrl("ftp://example.com");
78 expect.unreachable("expected URL normalization to fail");
79 } catch (error) {
80 expect(error).toBeInstanceOf(PreviewUrlNormalizationError);
81 expect(error).toMatchObject({
82 inputLength: "ftp://example.com".length,
83 reason: "unsupported-protocol",
84 protocol: "ftp:",
85 });
86 }
7087 });
7188
72 it("rejects unparseable junk", () => {
73 expect(() => normalizePreviewUrl("http://")).toThrow(PreviewUrlNormalizationError);
89 it("rejects unparseable input without retaining credentials or tokens", () => {
90 const rawUrl = "https://user:password@example.com:bad/path?access_token=secret#fragment";
91 try {
92 normalizePreviewUrl(rawUrl);
93 expect.unreachable("expected URL normalization to fail");
94 } catch (error) {
95 expect(error).toBeInstanceOf(PreviewUrlNormalizationError);
96 expect(error).toMatchObject({
97 inputLength: rawUrl.length,
98 reason: "parse",
99 protocol: "https:",
100 });
101 expect(error).not.toHaveProperty("rawUrl");
102 expect((error as PreviewUrlNormalizationError).cause).toBeInstanceOf(Error);
103 expect((error as PreviewUrlNormalizationError).message).not.toContain(
104 ((error as PreviewUrlNormalizationError).cause as Error).message,
105 );
106 expect((error as PreviewUrlNormalizationError).message).not.toMatch(
107 /user|password|access_token|secret|fragment/,
108 );
109 }
74110 });
75111});
packages/shared/src/preview.ts+32-14
......@@ -4,6 +4,8 @@
44 * on what counts as "loopback" and how to normalise a free-form URL string.
55 */
66
7import * as Schema from "effect/Schema";
8
79const TAB_ID_PREFIX = "tab_";
810let nextPreviewTabSequence = 0;
911
......@@ -45,17 +47,27 @@ export function isPreviewableUrl(rawUrl: string): boolean {
4547 }
4648}
4749
48export class PreviewUrlNormalizationError extends Error {
49 readonly rawUrl: string;
50 readonly detail: string;
51 constructor(rawUrl: string, detail: string) {
52 super(`Invalid preview URL: ${rawUrl} (${detail})`);
53 this.name = "PreviewUrlNormalizationError";
54 this.rawUrl = rawUrl;
55 this.detail = detail;
50export class PreviewUrlNormalizationError extends Schema.TaggedErrorClass<PreviewUrlNormalizationError>()(
51 "PreviewUrlNormalizationError",
52 {
53 inputLength: Schema.Number,
54 reason: Schema.Literals(["empty", "parse", "unsupported-protocol"]),
55 protocol: Schema.optional(Schema.String),
56 cause: Schema.optional(Schema.Defect()),
57 },
58) {
59 override get message(): string {
60 const protocol = this.protocol === undefined ? "" : `: ${this.protocol}`;
61 return `Invalid preview URL (${this.reason}${protocol}; input length ${this.inputLength}).`;
5662 }
5763}
5864
65export const isPreviewUrlNormalizationError = Schema.is(PreviewUrlNormalizationError);
66
67function previewUrlProtocol(rawUrl: string): string | undefined {
68 return /^([A-Za-z][A-Za-z\d+.-]*):/.exec(rawUrl)?.[1]?.toLowerCase().concat(":");
69}
70
5971/**
6072 * Normalise a free-form URL string into a fully-qualified `http(s)://` URL.
6173 *
......@@ -69,7 +81,7 @@ export class PreviewUrlNormalizationError extends Error {
6981export function normalizePreviewUrl(rawUrl: string): string {
7082 const trimmed = rawUrl.trim();
7183 if (trimmed.length === 0) {
72 throw new PreviewUrlNormalizationError(rawUrl, "empty");
84 throw new PreviewUrlNormalizationError({ inputLength: rawUrl.length, reason: "empty" });
7385 }
7486 const useHttp = LOOPBACK_PREFIX_PATTERN.test(trimmed);
7587 const candidate = trimmed.includes("://")
......@@ -79,13 +91,19 @@ export function normalizePreviewUrl(rawUrl: string): string {
7991 try {
8092 parsed = new URL(candidate);
8193 } catch (cause) {
82 throw new PreviewUrlNormalizationError(
83 rawUrl,
84 cause instanceof Error ? cause.message : "unparseable",
85 );
94 throw new PreviewUrlNormalizationError({
95 inputLength: rawUrl.length,
96 reason: "parse",
97 protocol: previewUrlProtocol(candidate),
98 cause,
99 });
86100 }
87101 if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
88 throw new PreviewUrlNormalizationError(rawUrl, `unsupported protocol ${parsed.protocol}`);
102 throw new PreviewUrlNormalizationError({
103 inputLength: rawUrl.length,
104 reason: "unsupported-protocol",
105 protocol: parsed.protocol,
106 });
89107 }
90108 return parsed.href;
91109}