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 @@...@@ -1,5 +1,6 @@
1import { it } from "@effect/vitest";1import { it } from "@effect/vitest";
2import { type PreviewEvent, ThreadId } from "@t3tools/contracts";2import { type PreviewEvent, ThreadId } from "@t3tools/contracts";
3import { PreviewUrlNormalizationError } from "@t3tools/shared/preview";
3import { Effect, PubSub } from "effect";4import { Effect, PubSub } from "effect";
4import { expect } from "vite-plus/test";5import { expect } from "vite-plus/test";
56
...@@ -83,6 +84,31 @@ it.layer(PreviewManager.layer)("PreviewManager", (it) => {...@@ -83,6 +84,31 @@ it.layer(PreviewManager.layer)("PreviewManager", (it) => {
83 const manager = yield* PreviewManager.PreviewManager;84 const manager = yield* PreviewManager.PreviewManager;
84 const error = yield* Effect.flip(manager.open({ threadId, url: " " }));85 const error = yield* Effect.flip(manager.open({ threadId, url: " " }));
85 expect(error._tag).toBe("PreviewInvalidUrlError");86 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/);
86 }),112 }),
87 );113 );
88114
apps/server/src/preview/Manager.ts+17-11
...@@ -24,9 +24,9 @@ import {...@@ -24,9 +24,9 @@ import {
24 type PreviewSessionSnapshot,24 type PreviewSessionSnapshot,
25} from "@t3tools/contracts";25} from "@t3tools/contracts";
26import {26import {
27 isPreviewUrlNormalizationError,
27 newPreviewTabId,28 newPreviewTabId,
28 normalizePreviewUrl,29 normalizePreviewUrl,
29 PreviewUrlNormalizationError,
30} from "@t3tools/shared/preview";30} from "@t3tools/shared/preview";
31import * as Context from "effect/Context";31import * as Context from "effect/Context";
32import * as DateTime from "effect/DateTime";32import * as DateTime from "effect/DateTime";
...@@ -82,16 +82,22 @@ const sessionsForThread = (...@@ -82,16 +82,22 @@ const sessionsForThread = (
82const normalizeUrl = (rawUrl: string): Effect.Effect<string, PreviewInvalidUrlError> =>82const normalizeUrl = (rawUrl: string): Effect.Effect<string, PreviewInvalidUrlError> =>
83 Effect.try({83 Effect.try({
84 try: () => normalizePreviewUrl(rawUrl),84 try: () => normalizePreviewUrl(rawUrl),
85 catch: (cause) =>85 catch: (cause) => {
86 new PreviewInvalidUrlError({86 if (isPreviewUrlNormalizationError(cause)) {
87 rawUrl,87 return new PreviewInvalidUrlError({
88 detail:88 inputLength: cause.inputLength,
89 cause instanceof PreviewUrlNormalizationError89 reason: cause.reason,
90 ? cause.detail90 protocol: cause.protocol,
91 : cause instanceof Error91 cause,
92 ? cause.message92 });
93 : String(cause),93 }
94 }),94
95 return new PreviewInvalidUrlError({
96 inputLength: rawUrl.length,
97 reason: "unexpected",
98 cause,
99 });
100 },
95 });101 });
96102
97const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso));103const 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...@@ -172,14 +172,15 @@ export class PreviewSessionLookupError extends Schema.TaggedErrorClass<PreviewSe
172export class PreviewInvalidUrlError extends Schema.TaggedErrorClass<PreviewInvalidUrlError>()(172export class PreviewInvalidUrlError extends Schema.TaggedErrorClass<PreviewInvalidUrlError>()(
173 "PreviewInvalidUrlError",173 "PreviewInvalidUrlError",
174 {174 {
175 rawUrl: Schema.String,175 inputLength: Schema.Number,
176 detail: Schema.optional(Schema.String),176 reason: Schema.Literals(["empty", "parse", "unsupported-protocol", "unexpected"]),
177 protocol: Schema.optional(Schema.String),
178 cause: Schema.Defect(),
177 },179 },
178) {180) {
179 override get message() {181 override get message() {
180 return this.detail182 const protocol = this.protocol === undefined ? "" : `: ${this.protocol}`;
181 ? `Invalid preview URL: ${this.rawUrl} (${this.detail})`183 return `Invalid preview URL (${this.reason}${protocol}; input length ${this.inputLength}).`;
182 : `Invalid preview URL: ${this.rawUrl}`;
183 }184 }
184}185}
185186
packages/shared/src/preview.test.ts+41-5
...@@ -61,15 +61,51 @@ describe("normalizePreviewUrl", () => {...@@ -61,15 +61,51 @@ describe("normalizePreviewUrl", () => {
61 });61 });
6262
63 it("rejects empty input", () => {63 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 }
65 });73 });
6674
67 it("rejects unsupported protocols", () => {75 it("rejects unsupported protocols", () => {
68 expect(() => normalizePreviewUrl("ftp://example.com")).toThrow(PreviewUrlNormalizationError);76 try {
69 expect(() => normalizePreviewUrl("file:///etc/passwd")).toThrow(PreviewUrlNormalizationError);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 }
70 });87 });
7188
72 it("rejects unparseable junk", () => {89 it("rejects unparseable input without retaining credentials or tokens", () => {
73 expect(() => normalizePreviewUrl("http://")).toThrow(PreviewUrlNormalizationError);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 }
74 });110 });
75});111});
packages/shared/src/preview.ts+32-14
...@@ -4,6 +4,8 @@...@@ -4,6 +4,8 @@
4 * on what counts as "loopback" and how to normalise a free-form URL string.4 * on what counts as "loopback" and how to normalise a free-form URL string.
5 */5 */
66
7import * as Schema from "effect/Schema";
8
7const TAB_ID_PREFIX = "tab_";9const TAB_ID_PREFIX = "tab_";
8let nextPreviewTabSequence = 0;10let nextPreviewTabSequence = 0;
911
...@@ -45,17 +47,27 @@ export function isPreviewableUrl(rawUrl: string): boolean {...@@ -45,17 +47,27 @@ export function isPreviewableUrl(rawUrl: string): boolean {
45 }47 }
46}48}
4749
48export class PreviewUrlNormalizationError extends Error {50export class PreviewUrlNormalizationError extends Schema.TaggedErrorClass<PreviewUrlNormalizationError>()(
49 readonly rawUrl: string;51 "PreviewUrlNormalizationError",
50 readonly detail: string;52 {
51 constructor(rawUrl: string, detail: string) {53 inputLength: Schema.Number,
52 super(`Invalid preview URL: ${rawUrl} (${detail})`);54 reason: Schema.Literals(["empty", "parse", "unsupported-protocol"]),
53 this.name = "PreviewUrlNormalizationError";55 protocol: Schema.optional(Schema.String),
54 this.rawUrl = rawUrl;56 cause: Schema.optional(Schema.Defect()),
55 this.detail = detail;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}).`;
56 }62 }
57}63}
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
59/**71/**
60 * Normalise a free-form URL string into a fully-qualified `http(s)://` URL.72 * Normalise a free-form URL string into a fully-qualified `http(s)://` URL.
61 *73 *
...@@ -69,7 +81,7 @@ export class PreviewUrlNormalizationError extends Error {...@@ -69,7 +81,7 @@ export class PreviewUrlNormalizationError extends Error {
69export function normalizePreviewUrl(rawUrl: string): string {81export function normalizePreviewUrl(rawUrl: string): string {
70 const trimmed = rawUrl.trim();82 const trimmed = rawUrl.trim();
71 if (trimmed.length === 0) {83 if (trimmed.length === 0) {
72 throw new PreviewUrlNormalizationError(rawUrl, "empty");84 throw new PreviewUrlNormalizationError({ inputLength: rawUrl.length, reason: "empty" });
73 }85 }
74 const useHttp = LOOPBACK_PREFIX_PATTERN.test(trimmed);86 const useHttp = LOOPBACK_PREFIX_PATTERN.test(trimmed);
75 const candidate = trimmed.includes("://")87 const candidate = trimmed.includes("://")
...@@ -79,13 +91,19 @@ export function normalizePreviewUrl(rawUrl: string): string {...@@ -79,13 +91,19 @@ export function normalizePreviewUrl(rawUrl: string): string {
79 try {91 try {
80 parsed = new URL(candidate);92 parsed = new URL(candidate);
81 } catch (cause) {93 } catch (cause) {
82 throw new PreviewUrlNormalizationError(94 throw new PreviewUrlNormalizationError({
83 rawUrl,95 inputLength: rawUrl.length,
84 cause instanceof Error ? cause.message : "unparseable",96 reason: "parse",
85 );97 protocol: previewUrlProtocol(candidate),
98 cause,
99 });
86 }100 }
87 if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {101 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 });
89 }107 }
90 return parsed.href;108 return parsed.href;
91}109}