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";...@@ -5,6 +5,7 @@ import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
7import * as Path from "effect/Path";7import * as Path from "effect/Path";
8import * as PlatformError from "effect/PlatformError";
89
9import * as ServerSecretStore from "../auth/ServerSecretStore.ts";10import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
10import * as ServerConfig from "../config.ts";11import * as ServerConfig from "../config.ts";
...@@ -85,7 +86,58 @@ describe("AssetAccess", () => {...@@ -85,7 +86,58 @@ describe("AssetAccess", () => {
85 },86 },
86 workspaceRoot: root,87 workspaceRoot: root,
87 }).pipe(Effect.flip);88 }).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);
89 }).pipe(Effect.provide(testLayer)),141 }).pipe(Effect.provide(testLayer)),
90 );142 );
91143
...@@ -186,4 +238,37 @@ describe("AssetAccess", () => {...@@ -186,4 +238,37 @@ describe("AssetAccess", () => {
186 ).toEqual({ kind: "project-favicon-fallback" });238 ).toEqual({ kind: "project-favicon-fallback" });
187 }).pipe(Effect.provide(testLayer)),239 }).pipe(Effect.provide(testLayer)),
188 );240 );
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 );
189});274});
apps/server/src/assets/AssetAccess.ts+181-44
...@@ -11,6 +11,7 @@ import * as Effect from "effect/Effect";...@@ -11,6 +11,7 @@ import * as Effect from "effect/Effect";
11import * as FileSystem from "effect/FileSystem";11import * as FileSystem from "effect/FileSystem";
12import * as Option from "effect/Option";12import * as Option from "effect/Option";
13import * as Path from "effect/Path";13import * as Path from "effect/Path";
14import * as PlatformError from "effect/PlatformError";
14import * as Schema from "effect/Schema";15import * as Schema from "effect/Schema";
1516
16import {17import {
...@@ -97,33 +98,59 @@ function decodeRelativePath(value: string): string | null {...@@ -97,33 +98,59 @@ function decodeRelativePath(value: string): string | null {
97 }98 }
98}99}
99100
100const failAccess = (message: string, cause?: unknown) =>101const optionOnNotFound = <A, R>(
101 new AssetAccessError({ message, ...(cause === undefined ? {} : { cause }) });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
103const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWorkspaceFile")(112const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWorkspaceFile")(
104 function* (input: { readonly workspaceRoot: string; readonly relativePath: string }) {113 function* (input: { readonly workspaceRoot: string; readonly relativePath: string }) {
105 const fileSystem = yield* FileSystem.FileSystem;114 const fileSystem = yield* FileSystem.FileSystem;
106 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;115 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
107 const resolved = yield* workspacePaths116 const resolved = yield* workspacePaths.resolveRelativePathWithinRoot(input).pipe(
108 .resolveRelativePathWithinRoot(input)117 Effect.map(Option.some),
109 .pipe(Effect.orElseSucceed(() => null));118 Effect.catchTags({
110 if (!resolved) return null;119 WorkspacePathOutsideRootError: () => Effect.succeed(Option.none()),
120 }),
121 );
122 if (Option.isNone(resolved)) return null;
111123
112 const [canonicalRoot, canonicalFile] = yield* Effect.all([124 const [canonicalRoot, canonicalFile] = yield* Effect.all([
113 fileSystem.realPath(input.workspaceRoot).pipe(Effect.orElseSucceed(() => null)),125 optionOnNotFound(fileSystem.realPath(input.workspaceRoot)),
114 fileSystem.realPath(resolved.absolutePath).pipe(Effect.orElseSucceed(() => null)),126 optionOnNotFound(fileSystem.realPath(resolved.value.absolutePath)),
115 ]);127 ]);
116 if (!canonicalRoot || !canonicalFile) return null;128 if (Option.isNone(canonicalRoot) || Option.isNone(canonicalFile)) return null;
117129
118 const path = yield* Path.Path;130 const path = yield* Path.Path;
119 const relative = path.relative(canonicalRoot, canonicalFile);131 const relative = path.relative(canonicalRoot.value, canonicalFile.value);
120 if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) return null;132 if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) return null;
121133
122 const info = yield* fileSystem.stat(canonicalFile).pipe(Effect.orElseSucceed(() => null));134 const info = yield* optionOnNotFound(fileSystem.stat(canonicalFile.value));
123 return info?.type === "File" ? canonicalFile : null;135 return Option.isSome(info) && info.value.type === "File" ? canonicalFile.value : null;
124 },136 },
125);137);
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
127export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: {154export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: {
128 readonly resource: AssetResource;155 readonly resource: AssetResource;
129 readonly workspaceRoot?: string;156 readonly workspaceRoot?: string;
...@@ -138,30 +165,78 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -138,30 +165,78 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
138 switch (input.resource._tag) {165 switch (input.resource._tag) {
139 case "workspace-file": {166 case "workspace-file": {
140 if (!input.workspaceRoot) {167 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 });
142 }173 }
143 const workspaceRoot = yield* workspacePaths174 const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.workspaceRoot).pipe(
144 .normalizeWorkspaceRoot(input.workspaceRoot)175 Effect.mapError(
145 .pipe(Effect.mapError((cause) => failAccess(cause.message, cause)));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 );
146 const relativePath = path.isAbsolute(input.resource.path)185 const relativePath = path.isAbsolute(input.resource.path)
147 ? path.relative(workspaceRoot, input.resource.path)186 ? path.relative(workspaceRoot, input.resource.path)
148 : input.resource.path;187 : input.resource.path;
149 const resolved = yield* workspacePaths188 const resolved = yield* workspacePaths
150 .resolveRelativePathWithinRoot({ workspaceRoot, relativePath })189 .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 );
152 if (!isWorkspacePreviewEntryPath(resolved.relativePath)) {201 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 });
154 }207 }
155 const canonicalFile = yield* resolveCanonicalWorkspaceFile({208 const canonicalFile = yield* resolveCanonicalWorkspaceFile({
156 workspaceRoot,209 workspaceRoot,
157 relativePath: resolved.relativePath,210 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 );
159 if (!canonicalFile) {222 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 });
161 }228 }
162 const canonicalWorkspaceRoot = yield* fileSystem229 const canonicalWorkspaceRoot = yield* fileSystem.realPath(workspaceRoot).pipe(
163 .realPath(workspaceRoot)230 Effect.mapError(
164 .pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause)));231 (cause) =>
232 new AssetAccessError({
233 operation: "resolve-workspace",
234 resource: input.resource,
235 message: "Failed to resolve workspace.",
236 cause,
237 }),
238 ),
239 );
165 claims = isWorkspaceImagePreviewPath(resolved.relativePath)240 claims = isWorkspaceImagePreviewPath(resolved.relativePath)
166 ? {241 ? {
167 version: 1,242 version: 1,
...@@ -187,7 +262,11 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -187,7 +262,11 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
187 attachmentId: input.resource.attachmentId,262 attachmentId: input.resource.attachmentId,
188 });263 });
189 if (!attachmentPath) {264 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 });
191 }270 }
192 claims = {271 claims = {
193 version: 1,272 version: 1,
...@@ -199,24 +278,64 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -199,24 +278,64 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
199 break;278 break;
200 }279 }
201 case "project-favicon": {280 case "project-favicon": {
202 const workspaceRoot = yield* workspacePaths281 const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.resource.cwd).pipe(
203 .normalizeWorkspaceRoot(input.resource.cwd)282 Effect.mapError(
204 .pipe(Effect.mapError((cause) => failAccess(cause.message, cause)));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 );
205 const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;292 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 );
207 const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null;304 const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null;
208 if (305 if (
209 relativePath &&306 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 ))
211 ) {318 ) {
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 });
213 }324 }
214 claims = {325 claims = {
215 version: 1,326 version: 1,
216 kind: "project-favicon",327 kind: "project-favicon",
217 workspaceRoot: yield* fileSystem328 workspaceRoot: yield* fileSystem.realPath(workspaceRoot).pipe(
218 .realPath(workspaceRoot)329 Effect.mapError(
219 .pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause))),330 (cause) =>
331 new AssetAccessError({
332 operation: "resolve-workspace",
333 resource: input.resource,
334 message: "Failed to resolve workspace.",
335 cause,
336 }),
337 ),
338 ),
220 relativePath,339 relativePath,
221 expiresAt,340 expiresAt,
222 };341 };
...@@ -226,9 +345,17 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -226,9 +345,17 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
226 }345 }
227346
228 const secretStore = yield* ServerSecretStore.ServerSecretStore;347 const secretStore = yield* ServerSecretStore.ServerSecretStore;
229 const signingSecret = yield* secretStore348 const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32).pipe(
230 .getOrCreateRandom(SIGNING_SECRET_NAME, 32)349 Effect.mapError(
231 .pipe(Effect.mapError((cause) => failAccess(cause.message, cause)));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 );
232 const encodedPayload = base64UrlEncode(encodeAssetClaims(claims));359 const encodedPayload = base64UrlEncode(encodeAssetClaims(claims));
233 const token = `${encodedPayload}.${signPayload(encodedPayload, signingSecret)}`;360 const token = `${encodedPayload}.${signPayload(encodedPayload, signingSecret)}`;
234 return {361 return {
...@@ -245,9 +372,10 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (...@@ -245,9 +372,10 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
245 if (!encodedPayload || !signature) return null;372 if (!encodedPayload || !signature) return null;
246373
247 const secretStore = yield* ServerSecretStore.ServerSecretStore;374 const secretStore = yield* ServerSecretStore.ServerSecretStore;
248 const signingSecret = yield* secretStore375 const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32).pipe(
249 .getOrCreateRandom(SIGNING_SECRET_NAME, 32)376 Effect.tapError((cause) => Effect.logError("Failed to load the asset signing key.", { cause })),
250 .pipe(Effect.orElseSucceed(() => null));377 Effect.orElseSucceed(() => null),
378 );
251 if (!signingSecret) return null;379 if (!signingSecret) return null;
252 if (!timingSafeEqualBase64Url(signature, signPayload(encodedPayload, signingSecret))) return null;380 if (!timingSafeEqualBase64Url(signature, signPayload(encodedPayload, signingSecret))) return null;
253381
...@@ -262,8 +390,17 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (...@@ -262,8 +390,17 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
262 });390 });
263 if (!attachmentPath) return null;391 if (!attachmentPath) return null;
264 const fileSystem = yield* FileSystem.FileSystem;392 const fileSystem = yield* FileSystem.FileSystem;
265 const info = yield* fileSystem.stat(attachmentPath).pipe(Effect.orElseSucceed(() => null));393 const info = yield* optionOnNotFound(fileSystem.stat(attachmentPath)).pipe(
266 return info?.type === "File"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"
267 ? ({ kind: "file", path: attachmentPath } satisfies ResolvedAsset)404 ? ({ kind: "file", path: attachmentPath } satisfies ResolvedAsset)
268 : null;405 : null;
269 }406 }
...@@ -272,7 +409,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (...@@ -272,7 +409,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
272 if (claims.relativePath === null) {409 if (claims.relativePath === null) {
273 return { kind: "project-favicon-fallback" } satisfies ResolvedAsset;410 return { kind: "project-favicon-fallback" } satisfies ResolvedAsset;
274 }411 }
275 const faviconPath = yield* resolveCanonicalWorkspaceFile({412 const faviconPath = yield* resolveCanonicalWorkspaceFileForRequest({
276 workspaceRoot: claims.workspaceRoot,413 workspaceRoot: claims.workspaceRoot,
277 relativePath: claims.relativePath,414 relativePath: claims.relativePath,
278 });415 });
...@@ -284,7 +421,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (...@@ -284,7 +421,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
284 const path = yield* Path.Path;421 const path = yield* Path.Path;
285 if (claims.kind === "workspace-file-exact") {422 if (claims.kind === "workspace-file-exact") {
286 if (decodedPath !== path.basename(claims.relativePath)) return null;423 if (decodedPath !== path.basename(claims.relativePath)) return null;
287 const exactWorkspaceFile = yield* resolveCanonicalWorkspaceFile({424 const exactWorkspaceFile = yield* resolveCanonicalWorkspaceFileForRequest({
288 workspaceRoot: claims.workspaceRoot,425 workspaceRoot: claims.workspaceRoot,
289 relativePath: claims.relativePath,426 relativePath: claims.relativePath,
290 });427 });
...@@ -303,7 +440,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (...@@ -303,7 +440,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
303 }440 }
304 const joinedRelativePath =441 const joinedRelativePath =
305 claims.baseRelativePath === "." ? decodedPath : path.join(claims.baseRelativePath, decodedPath);442 claims.baseRelativePath === "." ? decodedPath : path.join(claims.baseRelativePath, decodedPath);
306 const workspaceFile = yield* resolveCanonicalWorkspaceFile({443 const workspaceFile = yield* resolveCanonicalWorkspaceFileForRequest({
307 workspaceRoot: claims.workspaceRoot,444 workspaceRoot: claims.workspaceRoot,
308 relativePath: joinedRelativePath,445 relativePath: joinedRelativePath,
309 });446 });
apps/server/src/project/ProjectFaviconResolver.test.ts+120
...@@ -4,6 +4,7 @@ import * as Effect from "effect/Effect";...@@ -4,6 +4,7 @@ import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
6import * as Path from "effect/Path";6import * as Path from "effect/Path";
7import * as PlatformError from "effect/PlatformError";
78
8import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";9import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
9import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";10import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
...@@ -34,6 +35,12 @@ const writeTextFile = Effect.fn("writeTextFile")(function* (...@@ -34,6 +35,12 @@ const writeTextFile = Effect.fn("writeTextFile")(function* (
34 yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie);35 yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie);
35});36});
3637
38const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) =>
39 ProjectFaviconResolver.make.pipe(
40 Effect.provide(WorkspacePaths.layer),
41 Effect.provideService(FileSystem.FileSystem, fileSystem),
42 );
43
37it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {44it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {
38 describe("resolvePath", () => {45 describe("resolvePath", () => {
39 it.effect("prefers well-known favicon files", () =>46 it.effect("prefers well-known favicon files", () =>
...@@ -73,5 +80,118 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {...@@ -73,5 +80,118 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {
73 expect(resolved).toBeNull();80 expect(resolved).toBeNull();
74 }),81 }),
75 );82 );
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 );
76 });196 });
77});197});
apps/server/src/project/ProjectFaviconResolver.ts+99-24
...@@ -10,7 +10,10 @@ import * as Context from "effect/Context";...@@ -10,7 +10,10 @@ import * as Context from "effect/Context";
10import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
11import * as FileSystem from "effect/FileSystem";11import * as FileSystem from "effect/FileSystem";
12import * as Layer from "effect/Layer";12import * as Layer from "effect/Layer";
13import * as Option from "effect/Option";
13import * as Path from "effect/Path";14import * as Path from "effect/Path";
15import * as PlatformError from "effect/PlatformError";
16import * as Schema from "effect/Schema";
1417
15import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";18import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
1619
...@@ -56,6 +59,26 @@ const LINK_ICON_HTML_RE =...@@ -56,6 +59,26 @@ const LINK_ICON_HTML_RE =
56const LINK_ICON_OBJ_RE =59const LINK_ICON_OBJ_RE =
57 /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i;60 /(?=[^}]*\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
59/** Service tag for project favicon resolution. */82/** Service tag for project favicon resolution. */
60export class ProjectFaviconResolver extends Context.Service<83export class ProjectFaviconResolver extends Context.Service<
61 ProjectFaviconResolver,84 ProjectFaviconResolver,
...@@ -65,7 +88,9 @@ export class ProjectFaviconResolver extends Context.Service<...@@ -65,7 +88,9 @@ export class ProjectFaviconResolver extends Context.Service<
65 *88 *
66 * Returns `null` when no candidate icon file can be found.89 * Returns `null` when no candidate icon file can be found.
67 */90 */
68 readonly resolvePath: (cwd: string) => Effect.Effect<string | null>;91 readonly resolvePath: (
92 cwd: string,
93 ) => Effect.Effect<string | null, ProjectFaviconResolutionError>;
69 }94 }
70>()("t3/project/ProjectFaviconResolver") {}95>()("t3/project/ProjectFaviconResolver") {}
7196
...@@ -77,6 +102,17 @@ function extractIconHref(source: string): string | null {...@@ -77,6 +102,17 @@ function extractIconHref(source: string): string | null {
77 return null;102 return null;
78}103}
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
80export const make = Effect.gen(function* () {116export const make = Effect.gen(function* () {
81 const fileSystem = yield* FileSystem.FileSystem;117 const fileSystem = yield* FileSystem.FileSystem;
82 const path = yield* Path.Path;118 const path = yield* Path.Path;
...@@ -90,22 +126,39 @@ export const make = Effect.gen(function* () {...@@ -90,22 +126,39 @@ export const make = Effect.gen(function* () {
90 const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* (126 const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* (
91 projectCwd: string,127 projectCwd: string,
92 relativeCandidates: ReadonlyArray<string>,128 relativeCandidates: ReadonlyArray<string>,
93 ): Effect.fn.Return<string | null> {129 ): Effect.fn.Return<string | null, ProjectFaviconResolutionError> {
94 for (const relativePath of relativeCandidates) {130 for (const relativePath of relativeCandidates) {
95 const candidate = yield* workspacePaths131 const candidate = yield* workspacePaths
96 .resolveRelativePathWithinRoot({132 .resolveRelativePathWithinRoot({
97 workspaceRoot: projectCwd,133 workspaceRoot: projectCwd,
98 relativePath,134 relativePath,
99 })135 })
100 .pipe(Effect.orElseSucceed(() => null));136 .pipe(
101 if (!candidate) {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)) {
102 continue;146 continue;
103 }147 }
104 const stats = yield* fileSystem148 const stats = yield* optionOnNotFound(fileSystem.stat(candidate.value.absolutePath)).pipe(
105 .stat(candidate.absolutePath)149 Effect.mapError(
106 .pipe(Effect.orElseSucceed(() => null));150 (cause) =>
107 if (stats?.type === "File") {151 new ProjectFaviconResolutionError({
108 return candidate.absolutePath;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;
109 }162 }
110 }163 }
111 return null;164 return null;
...@@ -114,12 +167,16 @@ export const make = Effect.gen(function* () {...@@ -114,12 +167,16 @@ export const make = Effect.gen(function* () {
114 const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn(167 const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn(
115 "ProjectFaviconResolver.resolvePath",168 "ProjectFaviconResolver.resolvePath",
116 )(function* (cwd) {169 )(function* (cwd) {
117 const projectCwd = yield* workspacePaths170 const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe(
118 .normalizeWorkspaceRoot(cwd)171 Effect.mapError(
119 .pipe(Effect.orElseSucceed(() => null));172 (cause) =>
120 if (!projectCwd) {173 new ProjectFaviconResolutionError({
121 return null;174 operation: "normalize-workspace",
122 }175 workspaceRoot: cwd,
176 cause,
177 }),
178 ),
179 );
123 for (const candidate of FAVICON_CANDIDATES) {180 for (const candidate of FAVICON_CANDIDATES) {
124 const existing = yield* findExistingFile(projectCwd, [candidate]);181 const existing = yield* findExistingFile(projectCwd, [candidate]);
125 if (existing) {182 if (existing) {
...@@ -133,17 +190,35 @@ export const make = Effect.gen(function* () {...@@ -133,17 +190,35 @@ export const make = Effect.gen(function* () {
133 workspaceRoot: projectCwd,190 workspaceRoot: projectCwd,
134 relativePath: sourceFile,191 relativePath: sourceFile,
135 })192 })
136 .pipe(Effect.orElseSucceed(() => null));193 .pipe(
137 if (!sourcePath) {194 Effect.mapError(
138 continue;195 (cause) =>
139 }196 new ProjectFaviconResolutionError({
140 const source = yield* fileSystem197 operation: "resolve-path",
141 .readFileString(sourcePath.absolutePath)198 workspaceRoot: projectCwd,
142 .pipe(Effect.orElseSucceed(() => null));199 relativePath: sourceFile,
143 if (!source) {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)) {
144 continue;219 continue;
145 }220 }
146 const href = extractIconHref(source);221 const href = extractIconHref(source.value);
147 if (!href) {222 if (!href) {
148 continue;223 continue;
149 }224 }
apps/server/src/ws.ts+8
...@@ -1414,6 +1414,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>...@@ -1414,6 +1414,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
1414 Effect.mapError(1414 Effect.mapError(
1415 (cause) =>1415 (cause) =>
1416 new AssetAccessError({1416 new AssetAccessError({
1417 operation: "resolve-workspace-context",
1418 resource: input.resource,
1417 message: "Failed to resolve workspace context.",1419 message: "Failed to resolve workspace context.",
1418 cause,1420 cause,
1419 }),1421 }),
...@@ -1421,6 +1423,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>...@@ -1421,6 +1423,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
1421 );1423 );
1422 if (Option.isNone(thread)) {1424 if (Option.isNone(thread)) {
1423 return yield* new AssetAccessError({1425 return yield* new AssetAccessError({
1426 operation: "resolve-workspace-context",
1427 resource: input.resource,
1424 message: "Workspace context was not found.",1428 message: "Workspace context was not found.",
1425 });1429 });
1426 }1430 }
...@@ -1430,6 +1434,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>...@@ -1430,6 +1434,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
1430 Effect.mapError(1434 Effect.mapError(
1431 (cause) =>1435 (cause) =>
1432 new AssetAccessError({1436 new AssetAccessError({
1437 operation: "resolve-workspace-context",
1438 resource: input.resource,
1433 message: "Failed to resolve workspace context.",1439 message: "Failed to resolve workspace context.",
1434 cause,1440 cause,
1435 }),1441 }),
...@@ -1437,6 +1443,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>...@@ -1437,6 +1443,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
1437 );1443 );
1438 if (Option.isNone(project)) {1444 if (Option.isNone(project)) {
1439 return yield* new AssetAccessError({1445 return yield* new AssetAccessError({
1446 operation: "resolve-workspace-context",
1447 resource: input.resource,
1440 message: "Workspace context was not found.",1448 message: "Workspace context was not found.",
1441 });1449 });
1442 }1450 }
packages/contracts/src/assets.ts+18
...@@ -29,9 +29,27 @@ export const AssetCreateUrlResult = Schema.Struct({...@@ -29,9 +29,27 @@ export const AssetCreateUrlResult = Schema.Struct({
29});29});
30export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type;30export 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
32export class AssetAccessError extends Schema.TaggedErrorClass<AssetAccessError>()(48export class AssetAccessError extends Schema.TaggedErrorClass<AssetAccessError>()(
33 "AssetAccessError",49 "AssetAccessError",
34 {50 {
51 operation: AssetAccessOperation,
52 resource: AssetResource,
35 message: TrimmedNonEmptyString,53 message: TrimmedNonEmptyString,
36 cause: Schema.optional(Schema.Defect()),54 cause: Schema.optional(Schema.Defect()),
37 },55 },