authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 16:15:22-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 16:15:22-07:00
log90dc76b11eac811e8bc89cad3f60bb5c7b56514f
tree3a9e0b56bde5f05cfae9416d3316b27e225eae04
parent57d25c934b74986423896a7dc433595ed09b9c8d
signature Signed by PGP key B5690EEEBB952194

Preserve asset access failure causes (#3342)

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

6 files changed, 512 insertions(+), 69 deletions(-)

apps/server/src/assets/AssetAccess.test.ts+86-1
......@@ -5,6 +5,7 @@ import * as Effect from "effect/Effect";
55import * as FileSystem from "effect/FileSystem";
66import * as Layer from "effect/Layer";
77import * as Path from "effect/Path";
8import * as PlatformError from "effect/PlatformError";
89
910import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
1011import * as ServerConfig from "../config.ts";
......@@ -85,7 +86,58 @@ describe("AssetAccess", () => {
8586 },
8687 workspaceRoot: root,
8788 }).pipe(Effect.flip);
88 expect(error.message).toContain("relative to the project root");
89 expect(error.message).toBe("Workspace file path must be relative to the project root.");
90 expect(error).toMatchObject({
91 operation: "validate-workspace-path",
92 resource: {
93 _tag: "workspace-file",
94 threadId: "thread-1",
95 path: htmlPath,
96 },
97 });
98 expect(error.cause).toBeInstanceOf(WorkspacePaths.WorkspacePathOutsideRootError);
99 }).pipe(Effect.provide(testLayer)),
100 );
101
102 it.effect("preserves non-missing canonical path failures when issuing asset URLs", () =>
103 Effect.gen(function* () {
104 const fileSystem = yield* FileSystem.FileSystem;
105 const path = yield* Path.Path;
106 const root = yield* fileSystem.makeTempDirectoryScoped({
107 prefix: "t3-asset-permission-root-",
108 });
109 const htmlPath = path.join(root, "report.html");
110 yield* fileSystem.writeFileString(htmlPath, "<p>report</p>");
111 const cause = PlatformError.systemError({
112 _tag: "PermissionDenied",
113 module: "FileSystem",
114 method: "realPath",
115 pathOrDescriptor: htmlPath,
116 });
117 const failingFileSystem = FileSystem.FileSystem.of({
118 ...fileSystem,
119 realPath: () => Effect.fail(cause),
120 });
121
122 const error = yield* issueAssetUrl({
123 resource: {
124 _tag: "workspace-file",
125 threadId: ThreadId.make("thread-1"),
126 path: htmlPath,
127 },
128 workspaceRoot: root,
129 }).pipe(Effect.provideService(FileSystem.FileSystem, failingFileSystem), Effect.flip);
130
131 expect(error.message).toBe("Failed to inspect the workspace asset.");
132 expect(error).toMatchObject({
133 operation: "inspect-workspace-asset",
134 resource: {
135 _tag: "workspace-file",
136 threadId: "thread-1",
137 path: htmlPath,
138 },
139 });
140 expect(error.cause).toBe(cause);
89141 }).pipe(Effect.provide(testLayer)),
90142 );
91143
......@@ -186,4 +238,37 @@ describe("AssetAccess", () => {
186238 ).toEqual({ kind: "project-favicon-fallback" });
187239 }).pipe(Effect.provide(testLayer)),
188240 );
241
242 it.effect("preserves structured project favicon resolution causes", () =>
243 Effect.gen(function* () {
244 const fileSystem = yield* FileSystem.FileSystem;
245 const root = yield* fileSystem.makeTempDirectoryScoped({
246 prefix: "t3-asset-favicon-error-",
247 });
248 const platformCause = PlatformError.systemError({
249 _tag: "PermissionDenied",
250 module: "FileSystem",
251 method: "stat",
252 });
253 const resolutionCause = new ProjectFaviconResolver.ProjectFaviconResolutionError({
254 operation: "stat-candidate",
255 workspaceRoot: root,
256 relativePath: "favicon.svg",
257 cause: platformCause,
258 });
259 const resolver = ProjectFaviconResolver.ProjectFaviconResolver.of({
260 resolvePath: () => Effect.fail(resolutionCause),
261 });
262
263 const error = yield* issueAssetUrl({
264 resource: { _tag: "project-favicon", cwd: root },
265 }).pipe(
266 Effect.provideService(ProjectFaviconResolver.ProjectFaviconResolver, resolver),
267 Effect.flip,
268 );
269
270 expect(error.message).toBe("Failed to resolve project favicon.");
271 expect(error.cause).toBe(resolutionCause);
272 }).pipe(Effect.provide(testLayer)),
273 );
189274});
apps/server/src/assets/AssetAccess.ts+181-44
......@@ -11,6 +11,7 @@ import * as Effect from "effect/Effect";
1111import * as FileSystem from "effect/FileSystem";
1212import * as Option from "effect/Option";
1313import * as Path from "effect/Path";
14import * as PlatformError from "effect/PlatformError";
1415import * as Schema from "effect/Schema";
1516
1617import {
......@@ -97,33 +98,59 @@ function decodeRelativePath(value: string): string | null {
9798 }
9899}
99100
100const failAccess = (message: string, cause?: unknown) =>
101 new AssetAccessError({ message, ...(cause === undefined ? {} : { cause }) });
101const optionOnNotFound = <A, R>(
102 effect: Effect.Effect<A, PlatformError.PlatformError, R>,
103): Effect.Effect<Option.Option<A>, PlatformError.PlatformError, R> =>
104 effect.pipe(
105 Effect.map(Option.some),
106 Effect.catchTags({
107 PlatformError: (error) =>
108 error.reason._tag === "NotFound" ? Effect.succeed(Option.none<A>()) : Effect.fail(error),
109 }),
110 );
102111
103112const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWorkspaceFile")(
104113 function* (input: { readonly workspaceRoot: string; readonly relativePath: string }) {
105114 const fileSystem = yield* FileSystem.FileSystem;
106115 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
107 const resolved = yield* workspacePaths
108 .resolveRelativePathWithinRoot(input)
109 .pipe(Effect.orElseSucceed(() => null));
110 if (!resolved) return null;
116 const resolved = yield* workspacePaths.resolveRelativePathWithinRoot(input).pipe(
117 Effect.map(Option.some),
118 Effect.catchTags({
119 WorkspacePathOutsideRootError: () => Effect.succeed(Option.none()),
120 }),
121 );
122 if (Option.isNone(resolved)) return null;
111123
112124 const [canonicalRoot, canonicalFile] = yield* Effect.all([
113 fileSystem.realPath(input.workspaceRoot).pipe(Effect.orElseSucceed(() => null)),
114 fileSystem.realPath(resolved.absolutePath).pipe(Effect.orElseSucceed(() => null)),
125 optionOnNotFound(fileSystem.realPath(input.workspaceRoot)),
126 optionOnNotFound(fileSystem.realPath(resolved.value.absolutePath)),
115127 ]);
116 if (!canonicalRoot || !canonicalFile) return null;
128 if (Option.isNone(canonicalRoot) || Option.isNone(canonicalFile)) return null;
117129
118130 const path = yield* Path.Path;
119 const relative = path.relative(canonicalRoot, canonicalFile);
131 const relative = path.relative(canonicalRoot.value, canonicalFile.value);
120132 if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) return null;
121133
122 const info = yield* fileSystem.stat(canonicalFile).pipe(Effect.orElseSucceed(() => null));
123 return info?.type === "File" ? canonicalFile : null;
134 const info = yield* optionOnNotFound(fileSystem.stat(canonicalFile.value));
135 return Option.isSome(info) && info.value.type === "File" ? canonicalFile.value : null;
124136 },
125137);
126138
139const resolveCanonicalWorkspaceFileForRequest = (input: {
140 readonly workspaceRoot: string;
141 readonly relativePath: string;
142}) =>
143 resolveCanonicalWorkspaceFile(input).pipe(
144 Effect.tapError((cause) =>
145 Effect.logError("Failed to resolve canonical asset path.", {
146 workspaceRoot: input.workspaceRoot,
147 relativePath: input.relativePath,
148 cause,
149 }),
150 ),
151 Effect.orElseSucceed(() => null),
152 );
153
127154export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: {
128155 readonly resource: AssetResource;
129156 readonly workspaceRoot?: string;
......@@ -138,30 +165,78 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
138165 switch (input.resource._tag) {
139166 case "workspace-file": {
140167 if (!input.workspaceRoot) {
141 return yield* failAccess("Workspace context was not found.");
168 return yield* new AssetAccessError({
169 operation: "resolve-workspace-context",
170 resource: input.resource,
171 message: "Workspace context was not found.",
172 });
142173 }
143 const workspaceRoot = yield* workspacePaths
144 .normalizeWorkspaceRoot(input.workspaceRoot)
145 .pipe(Effect.mapError((cause) => failAccess(cause.message, cause)));
174 const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.workspaceRoot).pipe(
175 Effect.mapError(
176 (cause) =>
177 new AssetAccessError({
178 operation: "normalize-workspace-root",
179 resource: input.resource,
180 message: "Failed to normalize the workspace root.",
181 cause,
182 }),
183 ),
184 );
146185 const relativePath = path.isAbsolute(input.resource.path)
147186 ? path.relative(workspaceRoot, input.resource.path)
148187 : input.resource.path;
149188 const resolved = yield* workspacePaths
150189 .resolveRelativePathWithinRoot({ workspaceRoot, relativePath })
151 .pipe(Effect.mapError((cause) => failAccess(cause.message, cause)));
190 .pipe(
191 Effect.mapError(
192 (cause) =>
193 new AssetAccessError({
194 operation: "validate-workspace-path",
195 resource: input.resource,
196 message: "Workspace file path must be relative to the project root.",
197 cause,
198 }),
199 ),
200 );
152201 if (!isWorkspacePreviewEntryPath(resolved.relativePath)) {
153 return yield* failAccess("Only browser documents and images can be previewed.");
202 return yield* new AssetAccessError({
203 operation: "validate-preview-type",
204 resource: input.resource,
205 message: "Only browser documents and images can be previewed.",
206 });
154207 }
155208 const canonicalFile = yield* resolveCanonicalWorkspaceFile({
156209 workspaceRoot,
157210 relativePath: resolved.relativePath,
158 });
211 }).pipe(
212 Effect.mapError(
213 (cause) =>
214 new AssetAccessError({
215 operation: "inspect-workspace-asset",
216 resource: input.resource,
217 message: "Failed to inspect the workspace asset.",
218 cause,
219 }),
220 ),
221 );
159222 if (!canonicalFile) {
160 return yield* failAccess("Workspace asset was not found.");
223 return yield* new AssetAccessError({
224 operation: "locate-workspace-asset",
225 resource: input.resource,
226 message: "Workspace asset was not found.",
227 });
161228 }
162 const canonicalWorkspaceRoot = yield* fileSystem
163 .realPath(workspaceRoot)
164 .pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause)));
229 const canonicalWorkspaceRoot = yield* fileSystem.realPath(workspaceRoot).pipe(
230 Effect.mapError(
231 (cause) =>
232 new AssetAccessError({
233 operation: "resolve-workspace",
234 resource: input.resource,
235 message: "Failed to resolve workspace.",
236 cause,
237 }),
238 ),
239 );
165240 claims = isWorkspaceImagePreviewPath(resolved.relativePath)
166241 ? {
167242 version: 1,
......@@ -187,7 +262,11 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
187262 attachmentId: input.resource.attachmentId,
188263 });
189264 if (!attachmentPath) {
190 return yield* failAccess("Attachment was not found.");
265 return yield* new AssetAccessError({
266 operation: "locate-attachment",
267 resource: input.resource,
268 message: "Attachment was not found.",
269 });
191270 }
192271 claims = {
193272 version: 1,
......@@ -199,24 +278,64 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
199278 break;
200279 }
201280 case "project-favicon": {
202 const workspaceRoot = yield* workspacePaths
203 .normalizeWorkspaceRoot(input.resource.cwd)
204 .pipe(Effect.mapError((cause) => failAccess(cause.message, cause)));
281 const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.resource.cwd).pipe(
282 Effect.mapError(
283 (cause) =>
284 new AssetAccessError({
285 operation: "normalize-workspace-root",
286 resource: input.resource,
287 message: "Failed to normalize the workspace root.",
288 cause,
289 }),
290 ),
291 );
205292 const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
206 const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot);
293 const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot).pipe(
294 Effect.mapError(
295 (cause) =>
296 new AssetAccessError({
297 operation: "resolve-project-favicon",
298 resource: input.resource,
299 message: "Failed to resolve project favicon.",
300 cause,
301 }),
302 ),
303 );
207304 const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null;
208305 if (
209306 relativePath &&
210 !(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }))
307 !(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe(
308 Effect.mapError(
309 (cause) =>
310 new AssetAccessError({
311 operation: "inspect-project-favicon",
312 resource: input.resource,
313 message: "Failed to inspect the project favicon.",
314 cause,
315 }),
316 ),
317 ))
211318 ) {
212 return yield* failAccess("Project favicon was not found.");
319 return yield* new AssetAccessError({
320 operation: "locate-project-favicon",
321 resource: input.resource,
322 message: "Project favicon was not found.",
323 });
213324 }
214325 claims = {
215326 version: 1,
216327 kind: "project-favicon",
217 workspaceRoot: yield* fileSystem
218 .realPath(workspaceRoot)
219 .pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause))),
328 workspaceRoot: yield* fileSystem.realPath(workspaceRoot).pipe(
329 Effect.mapError(
330 (cause) =>
331 new AssetAccessError({
332 operation: "resolve-workspace",
333 resource: input.resource,
334 message: "Failed to resolve workspace.",
335 cause,
336 }),
337 ),
338 ),
220339 relativePath,
221340 expiresAt,
222341 };
......@@ -226,9 +345,17 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
226345 }
227346
228347 const secretStore = yield* ServerSecretStore.ServerSecretStore;
229 const signingSecret = yield* secretStore
230 .getOrCreateRandom(SIGNING_SECRET_NAME, 32)
231 .pipe(Effect.mapError((cause) => failAccess(cause.message, cause)));
348 const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32).pipe(
349 Effect.mapError(
350 (cause) =>
351 new AssetAccessError({
352 operation: "load-signing-key",
353 resource: input.resource,
354 message: "Failed to load the asset signing key.",
355 cause,
356 }),
357 ),
358 );
232359 const encodedPayload = base64UrlEncode(encodeAssetClaims(claims));
233360 const token = `${encodedPayload}.${signPayload(encodedPayload, signingSecret)}`;
234361 return {
......@@ -245,9 +372,10 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
245372 if (!encodedPayload || !signature) return null;
246373
247374 const secretStore = yield* ServerSecretStore.ServerSecretStore;
248 const signingSecret = yield* secretStore
249 .getOrCreateRandom(SIGNING_SECRET_NAME, 32)
250 .pipe(Effect.orElseSucceed(() => null));
375 const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32).pipe(
376 Effect.tapError((cause) => Effect.logError("Failed to load the asset signing key.", { cause })),
377 Effect.orElseSucceed(() => null),
378 );
251379 if (!signingSecret) return null;
252380 if (!timingSafeEqualBase64Url(signature, signPayload(encodedPayload, signingSecret))) return null;
253381
......@@ -262,8 +390,17 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
262390 });
263391 if (!attachmentPath) return null;
264392 const fileSystem = yield* FileSystem.FileSystem;
265 const info = yield* fileSystem.stat(attachmentPath).pipe(Effect.orElseSucceed(() => null));
266 return info?.type === "File"
393 const info = yield* optionOnNotFound(fileSystem.stat(attachmentPath)).pipe(
394 Effect.tapError((cause) =>
395 Effect.logError("Failed to inspect attachment asset.", {
396 attachmentId: claims.attachmentId,
397 path: attachmentPath,
398 cause,
399 }),
400 ),
401 Effect.orElseSucceed(() => Option.none()),
402 );
403 return Option.isSome(info) && info.value.type === "File"
267404 ? ({ kind: "file", path: attachmentPath } satisfies ResolvedAsset)
268405 : null;
269406 }
......@@ -272,7 +409,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
272409 if (claims.relativePath === null) {
273410 return { kind: "project-favicon-fallback" } satisfies ResolvedAsset;
274411 }
275 const faviconPath = yield* resolveCanonicalWorkspaceFile({
412 const faviconPath = yield* resolveCanonicalWorkspaceFileForRequest({
276413 workspaceRoot: claims.workspaceRoot,
277414 relativePath: claims.relativePath,
278415 });
......@@ -284,7 +421,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
284421 const path = yield* Path.Path;
285422 if (claims.kind === "workspace-file-exact") {
286423 if (decodedPath !== path.basename(claims.relativePath)) return null;
287 const exactWorkspaceFile = yield* resolveCanonicalWorkspaceFile({
424 const exactWorkspaceFile = yield* resolveCanonicalWorkspaceFileForRequest({
288425 workspaceRoot: claims.workspaceRoot,
289426 relativePath: claims.relativePath,
290427 });
......@@ -303,7 +440,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
303440 }
304441 const joinedRelativePath =
305442 claims.baseRelativePath === "." ? decodedPath : path.join(claims.baseRelativePath, decodedPath);
306 const workspaceFile = yield* resolveCanonicalWorkspaceFile({
443 const workspaceFile = yield* resolveCanonicalWorkspaceFileForRequest({
307444 workspaceRoot: claims.workspaceRoot,
308445 relativePath: joinedRelativePath,
309446 });
apps/server/src/project/ProjectFaviconResolver.test.ts+120
......@@ -4,6 +4,7 @@ import * as Effect from "effect/Effect";
44import * as FileSystem from "effect/FileSystem";
55import * as Layer from "effect/Layer";
66import * as Path from "effect/Path";
7import * as PlatformError from "effect/PlatformError";
78
89import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
910import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
......@@ -34,6 +35,12 @@ const writeTextFile = Effect.fn("writeTextFile")(function* (
3435 yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie);
3536});
3637
38const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) =>
39 ProjectFaviconResolver.make.pipe(
40 Effect.provide(WorkspacePaths.layer),
41 Effect.provideService(FileSystem.FileSystem, fileSystem),
42 );
43
3744it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {
3845 describe("resolvePath", () => {
3946 it.effect("prefers well-known favicon files", () =>
......@@ -73,5 +80,118 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {
7380 expect(resolved).toBeNull();
7481 }),
7582 );
83
84 it.effect("preserves workspace normalization context", () =>
85 Effect.gen(function* () {
86 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
87 const cwd = yield* makeTempDir;
88 const missingCwd = `${cwd}/missing`;
89
90 const error = yield* resolver.resolvePath(missingCwd).pipe(Effect.flip);
91
92 expect(error).toMatchObject({
93 _tag: "ProjectFaviconResolutionError",
94 operation: "normalize-workspace",
95 workspaceRoot: missingCwd,
96 });
97 expect(error.cause).toBeInstanceOf(WorkspacePaths.WorkspaceRootNotExistsError);
98 }),
99 );
100
101 it.effect("preserves non-missing candidate stat failures", () =>
102 Effect.gen(function* () {
103 const fileSystem = yield* FileSystem.FileSystem;
104 const path = yield* Path.Path;
105 const cwd = yield* makeTempDir;
106 const faviconPath = path.join(cwd, "favicon.svg");
107 const cause = PlatformError.systemError({
108 _tag: "PermissionDenied",
109 module: "FileSystem",
110 method: "stat",
111 pathOrDescriptor: faviconPath,
112 });
113 const resolver = yield* makeResolverWithFileSystem(
114 FileSystem.FileSystem.of({
115 ...fileSystem,
116 stat: (filePath) =>
117 filePath === faviconPath ? Effect.fail(cause) : fileSystem.stat(filePath),
118 }),
119 );
120
121 const error = yield* resolver.resolvePath(cwd).pipe(Effect.flip);
122
123 expect(error).toMatchObject({
124 _tag: "ProjectFaviconResolutionError",
125 operation: "stat-candidate",
126 workspaceRoot: cwd,
127 relativePath: "favicon.svg",
128 absolutePath: faviconPath,
129 });
130 expect(error.cause).toBe(cause);
131 }),
132 );
133
134 it.effect("preserves icon source read failures", () =>
135 Effect.gen(function* () {
136 const fileSystem = yield* FileSystem.FileSystem;
137 const path = yield* Path.Path;
138 const cwd = yield* makeTempDir;
139 const sourcePath = path.join(cwd, "index.html");
140 yield* writeTextFile(cwd, "index.html", '<link rel="icon" href="/favicon.svg">');
141 const cause = PlatformError.systemError({
142 _tag: "PermissionDenied",
143 module: "FileSystem",
144 method: "readFileString",
145 pathOrDescriptor: sourcePath,
146 });
147 const resolver = yield* makeResolverWithFileSystem(
148 FileSystem.FileSystem.of({
149 ...fileSystem,
150 readFileString: (filePath, options) =>
151 filePath === sourcePath
152 ? Effect.fail(cause)
153 : fileSystem.readFileString(filePath, options),
154 }),
155 );
156
157 const error = yield* resolver.resolvePath(cwd).pipe(Effect.flip);
158
159 expect(error).toMatchObject({
160 _tag: "ProjectFaviconResolutionError",
161 operation: "read-source",
162 workspaceRoot: cwd,
163 relativePath: "index.html",
164 absolutePath: sourcePath,
165 });
166 expect(error.cause).toBe(cause);
167 }),
168 );
169
170 it.effect("skips icon metadata paths outside the workspace", () =>
171 Effect.gen(function* () {
172 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
173 const cwd = yield* makeTempDir;
174 yield* writeTextFile(cwd, "index.html", '<link rel="icon" href="../../secret.svg">');
175
176 const resolved = yield* resolver.resolvePath(cwd);
177
178 expect(resolved).toBeNull();
179 }),
180 );
181
182 it.effect("continues to later sources after an outside-root icon href", () =>
183 Effect.gen(function* () {
184 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
185 const cwd = yield* makeTempDir;
186 yield* writeTextFile(cwd, "index.html", '<link rel="icon" href="../../secret.svg">');
187 yield* writeTextFile(cwd, "public/index.html", '<link rel="icon" href="/brand/logo.svg">');
188 yield* writeTextFile(cwd, "public/brand/logo.svg", "<svg>brand</svg>");
189
190 const resolved = yield* resolver.resolvePath(cwd);
191
192 expect(resolved).not.toBeNull();
193 expect(resolved).toContain("public/brand/logo.svg");
194 }),
195 );
76196 });
77197});
apps/server/src/project/ProjectFaviconResolver.ts+99-24
......@@ -10,7 +10,10 @@ import * as Context from "effect/Context";
1010import * as Effect from "effect/Effect";
1111import * as FileSystem from "effect/FileSystem";
1212import * as Layer from "effect/Layer";
13import * as Option from "effect/Option";
1314import * as Path from "effect/Path";
15import * as PlatformError from "effect/PlatformError";
16import * as Schema from "effect/Schema";
1417
1518import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
1619
......@@ -56,6 +59,26 @@ const LINK_ICON_HTML_RE =
5659const LINK_ICON_OBJ_RE =
5760 /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i;
5861
62export class ProjectFaviconResolutionError extends Schema.TaggedErrorClass<ProjectFaviconResolutionError>()(
63 "ProjectFaviconResolutionError",
64 {
65 operation: Schema.Literals([
66 "normalize-workspace",
67 "resolve-path",
68 "stat-candidate",
69 "read-source",
70 ]),
71 workspaceRoot: Schema.String,
72 relativePath: Schema.optional(Schema.String),
73 absolutePath: Schema.optional(Schema.String),
74 cause: Schema.Defect(),
75 },
76) {
77 override get message(): string {
78 return `Failed to resolve project favicon during ${this.operation} for workspace ${this.workspaceRoot}.`;
79 }
80}
81
5982/** Service tag for project favicon resolution. */
6083export class ProjectFaviconResolver extends Context.Service<
6184 ProjectFaviconResolver,
......@@ -65,7 +88,9 @@ export class ProjectFaviconResolver extends Context.Service<
6588 *
6689 * Returns `null` when no candidate icon file can be found.
6790 */
68 readonly resolvePath: (cwd: string) => Effect.Effect<string | null>;
91 readonly resolvePath: (
92 cwd: string,
93 ) => Effect.Effect<string | null, ProjectFaviconResolutionError>;
6994 }
7095>()("t3/project/ProjectFaviconResolver") {}
7196
......@@ -77,6 +102,17 @@ function extractIconHref(source: string): string | null {
77102 return null;
78103}
79104
105const optionOnNotFound = <A, R>(
106 effect: Effect.Effect<A, PlatformError.PlatformError, R>,
107): Effect.Effect<Option.Option<A>, PlatformError.PlatformError, R> =>
108 effect.pipe(
109 Effect.map(Option.some),
110 Effect.catchTags({
111 PlatformError: (error) =>
112 error.reason._tag === "NotFound" ? Effect.succeed(Option.none<A>()) : Effect.fail(error),
113 }),
114 );
115
80116export const make = Effect.gen(function* () {
81117 const fileSystem = yield* FileSystem.FileSystem;
82118 const path = yield* Path.Path;
......@@ -90,22 +126,39 @@ export const make = Effect.gen(function* () {
90126 const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* (
91127 projectCwd: string,
92128 relativeCandidates: ReadonlyArray<string>,
93 ): Effect.fn.Return<string | null> {
129 ): Effect.fn.Return<string | null, ProjectFaviconResolutionError> {
94130 for (const relativePath of relativeCandidates) {
95131 const candidate = yield* workspacePaths
96132 .resolveRelativePathWithinRoot({
97133 workspaceRoot: projectCwd,
98134 relativePath,
99135 })
100 .pipe(Effect.orElseSucceed(() => null));
101 if (!candidate) {
136 .pipe(
137 Effect.map(Option.some),
138 Effect.catchTags({
139 WorkspacePathOutsideRootError: () =>
140 Effect.succeed(
141 Option.none<{ readonly absolutePath: string; readonly relativePath: string }>(),
142 ),
143 }),
144 );
145 if (Option.isNone(candidate)) {
102146 continue;
103147 }
104 const stats = yield* fileSystem
105 .stat(candidate.absolutePath)
106 .pipe(Effect.orElseSucceed(() => null));
107 if (stats?.type === "File") {
108 return candidate.absolutePath;
148 const stats = yield* optionOnNotFound(fileSystem.stat(candidate.value.absolutePath)).pipe(
149 Effect.mapError(
150 (cause) =>
151 new ProjectFaviconResolutionError({
152 operation: "stat-candidate",
153 workspaceRoot: projectCwd,
154 relativePath,
155 absolutePath: candidate.value.absolutePath,
156 cause,
157 }),
158 ),
159 );
160 if (Option.isSome(stats) && stats.value.type === "File") {
161 return candidate.value.absolutePath;
109162 }
110163 }
111164 return null;
......@@ -114,12 +167,16 @@ export const make = Effect.gen(function* () {
114167 const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn(
115168 "ProjectFaviconResolver.resolvePath",
116169 )(function* (cwd) {
117 const projectCwd = yield* workspacePaths
118 .normalizeWorkspaceRoot(cwd)
119 .pipe(Effect.orElseSucceed(() => null));
120 if (!projectCwd) {
121 return null;
122 }
170 const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe(
171 Effect.mapError(
172 (cause) =>
173 new ProjectFaviconResolutionError({
174 operation: "normalize-workspace",
175 workspaceRoot: cwd,
176 cause,
177 }),
178 ),
179 );
123180 for (const candidate of FAVICON_CANDIDATES) {
124181 const existing = yield* findExistingFile(projectCwd, [candidate]);
125182 if (existing) {
......@@ -133,17 +190,35 @@ export const make = Effect.gen(function* () {
133190 workspaceRoot: projectCwd,
134191 relativePath: sourceFile,
135192 })
136 .pipe(Effect.orElseSucceed(() => null));
137 if (!sourcePath) {
138 continue;
139 }
140 const source = yield* fileSystem
141 .readFileString(sourcePath.absolutePath)
142 .pipe(Effect.orElseSucceed(() => null));
143 if (!source) {
193 .pipe(
194 Effect.mapError(
195 (cause) =>
196 new ProjectFaviconResolutionError({
197 operation: "resolve-path",
198 workspaceRoot: projectCwd,
199 relativePath: sourceFile,
200 cause,
201 }),
202 ),
203 );
204 const source = yield* optionOnNotFound(
205 fileSystem.readFileString(sourcePath.absolutePath),
206 ).pipe(
207 Effect.mapError(
208 (cause) =>
209 new ProjectFaviconResolutionError({
210 operation: "read-source",
211 workspaceRoot: projectCwd,
212 relativePath: sourceFile,
213 absolutePath: sourcePath.absolutePath,
214 cause,
215 }),
216 ),
217 );
218 if (Option.isNone(source)) {
144219 continue;
145220 }
146 const href = extractIconHref(source);
221 const href = extractIconHref(source.value);
147222 if (!href) {
148223 continue;
149224 }
apps/server/src/ws.ts+8
......@@ -1414,6 +1414,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
14141414 Effect.mapError(
14151415 (cause) =>
14161416 new AssetAccessError({
1417 operation: "resolve-workspace-context",
1418 resource: input.resource,
14171419 message: "Failed to resolve workspace context.",
14181420 cause,
14191421 }),
......@@ -1421,6 +1423,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
14211423 );
14221424 if (Option.isNone(thread)) {
14231425 return yield* new AssetAccessError({
1426 operation: "resolve-workspace-context",
1427 resource: input.resource,
14241428 message: "Workspace context was not found.",
14251429 });
14261430 }
......@@ -1430,6 +1434,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
14301434 Effect.mapError(
14311435 (cause) =>
14321436 new AssetAccessError({
1437 operation: "resolve-workspace-context",
1438 resource: input.resource,
14331439 message: "Failed to resolve workspace context.",
14341440 cause,
14351441 }),
......@@ -1437,6 +1443,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
14371443 );
14381444 if (Option.isNone(project)) {
14391445 return yield* new AssetAccessError({
1446 operation: "resolve-workspace-context",
1447 resource: input.resource,
14401448 message: "Workspace context was not found.",
14411449 });
14421450 }
packages/contracts/src/assets.ts+18
......@@ -29,9 +29,27 @@ export const AssetCreateUrlResult = Schema.Struct({
2929});
3030export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type;
3131
32export const AssetAccessOperation = Schema.Literals([
33 "resolve-workspace-context",
34 "normalize-workspace-root",
35 "validate-workspace-path",
36 "validate-preview-type",
37 "inspect-workspace-asset",
38 "locate-workspace-asset",
39 "resolve-workspace",
40 "locate-attachment",
41 "resolve-project-favicon",
42 "inspect-project-favicon",
43 "locate-project-favicon",
44 "load-signing-key",
45]);
46export type AssetAccessOperation = typeof AssetAccessOperation.Type;
47
3248export class AssetAccessError extends Schema.TaggedErrorClass<AssetAccessError>()(
3349 "AssetAccessError",
3450 {
51 operation: AssetAccessOperation,
52 resource: AssetResource,
3553 message: TrimmedNonEmptyString,
3654 cause: Schema.optional(Schema.Defect()),
3755 },