Skip to content

Commit d512dea

Browse files
[codex] Structure preview URL failures (#3275)
Co-authored-by: codex <codex@users.noreply.github.com>
1 parent 32c7f90 commit d512dea

5 files changed

Lines changed: 122 additions & 35 deletions

File tree

apps/server/src/preview/Manager.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { it } from "@effect/vitest";
22
import { type PreviewEvent, ThreadId } from "@t3tools/contracts";
3+
import { PreviewUrlNormalizationError } from "@t3tools/shared/preview";
34
import { Effect, PubSub } from "effect";
45
import { 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

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ import {
2424
type PreviewSessionSnapshot,
2525
} from "@t3tools/contracts";
2626
import {
27+
isPreviewUrlNormalizationError,
2728
newPreviewTabId,
2829
normalizePreviewUrl,
29-
PreviewUrlNormalizationError,
3030
} from "@t3tools/shared/preview";
3131
import * as Context from "effect/Context";
3232
import * as DateTime from "effect/DateTime";
@@ -82,16 +82,22 @@ const sessionsForThread = (
8282
const 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

97103
const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso));

packages/contracts/src/preview.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -172,14 +172,15 @@ export class PreviewSessionLookupError extends Schema.TaggedErrorClass<PreviewSe
172172
export 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

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
* on what counts as "loopback" and how to normalise a free-form URL string.
55
*/
66

7+
import * as Schema from "effect/Schema";
8+
79
const TAB_ID_PREFIX = "tab_";
810
let nextPreviewTabSequence = 0;
911

@@ -45,17 +47,27 @@ export function isPreviewableUrl(rawUrl: string): boolean {
4547
}
4648
}
4749

48-
export 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;
50+
export 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

65+
export const isPreviewUrlNormalizationError = Schema.is(PreviewUrlNormalizationError);
66+
67+
function 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 {
6981
export 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
}

0 commit comments

Comments
 (0)