diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 4fcddd0f8e6..7df2e3361c8 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -87,6 +87,14 @@ describe("AssetAccess", () => { workspaceRoot: root, }).pipe(Effect.flip); expect(error.message).toBe("Workspace file path must be relative to the project root."); + expect(error).toMatchObject({ + operation: "validate-workspace-path", + resource: { + _tag: "workspace-file", + threadId: "thread-1", + path: htmlPath, + }, + }); expect(error.cause).toBeInstanceOf(WorkspacePaths.WorkspacePathOutsideRootError); }).pipe(Effect.provide(testLayer)), ); @@ -121,6 +129,14 @@ describe("AssetAccess", () => { }).pipe(Effect.provideService(FileSystem.FileSystem, failingFileSystem), Effect.flip); expect(error.message).toBe("Failed to inspect the workspace asset."); + expect(error).toMatchObject({ + operation: "inspect-workspace-asset", + resource: { + _tag: "workspace-file", + threadId: "thread-1", + path: htmlPath, + }, + }); expect(error.cause).toBe(cause); }).pipe(Effect.provide(testLayer)), ); @@ -222,4 +238,37 @@ describe("AssetAccess", () => { ).toEqual({ kind: "project-favicon-fallback" }); }).pipe(Effect.provide(testLayer)), ); + + it.effect("preserves structured project favicon resolution causes", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-error-", + }); + const platformCause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "stat", + }); + const resolutionCause = new ProjectFaviconResolver.ProjectFaviconResolutionError({ + operation: "stat-candidate", + workspaceRoot: root, + relativePath: "favicon.svg", + cause: platformCause, + }); + const resolver = ProjectFaviconResolver.ProjectFaviconResolver.of({ + resolvePath: () => Effect.fail(resolutionCause), + }); + + const error = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + }).pipe( + Effect.provideService(ProjectFaviconResolver.ProjectFaviconResolver, resolver), + Effect.flip, + ); + + expect(error.message).toBe("Failed to resolve project favicon."); + expect(error.cause).toBe(resolutionCause); + }).pipe(Effect.provide(testLayer)), + ); }); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index c4ba47c2ab8..f7be262b41a 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -165,12 +165,18 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i switch (input.resource._tag) { case "workspace-file": { if (!input.workspaceRoot) { - return yield* new AssetAccessError({ message: "Workspace context was not found." }); + return yield* new AssetAccessError({ + operation: "resolve-workspace-context", + resource: input.resource, + message: "Workspace context was not found.", + }); } const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.workspaceRoot).pipe( Effect.mapError( (cause) => new AssetAccessError({ + operation: "normalize-workspace-root", + resource: input.resource, message: "Failed to normalize the workspace root.", cause, }), @@ -185,6 +191,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i Effect.mapError( (cause) => new AssetAccessError({ + operation: "validate-workspace-path", + resource: input.resource, message: "Workspace file path must be relative to the project root.", cause, }), @@ -192,6 +200,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i ); if (!isWorkspacePreviewEntryPath(resolved.relativePath)) { return yield* new AssetAccessError({ + operation: "validate-preview-type", + resource: input.resource, message: "Only browser documents and images can be previewed.", }); } @@ -202,18 +212,26 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i Effect.mapError( (cause) => new AssetAccessError({ + operation: "inspect-workspace-asset", + resource: input.resource, message: "Failed to inspect the workspace asset.", cause, }), ), ); if (!canonicalFile) { - return yield* new AssetAccessError({ message: "Workspace asset was not found." }); + return yield* new AssetAccessError({ + operation: "locate-workspace-asset", + resource: input.resource, + message: "Workspace asset was not found.", + }); } const canonicalWorkspaceRoot = yield* fileSystem.realPath(workspaceRoot).pipe( Effect.mapError( (cause) => new AssetAccessError({ + operation: "resolve-workspace", + resource: input.resource, message: "Failed to resolve workspace.", cause, }), @@ -244,7 +262,11 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i attachmentId: input.resource.attachmentId, }); if (!attachmentPath) { - return yield* new AssetAccessError({ message: "Attachment was not found." }); + return yield* new AssetAccessError({ + operation: "locate-attachment", + resource: input.resource, + message: "Attachment was not found.", + }); } claims = { version: 1, @@ -260,13 +282,25 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i Effect.mapError( (cause) => new AssetAccessError({ + operation: "normalize-workspace-root", + resource: input.resource, message: "Failed to normalize the workspace root.", cause, }), ), ); const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; - const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot); + const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot).pipe( + Effect.mapError( + (cause) => + new AssetAccessError({ + operation: "resolve-project-favicon", + resource: input.resource, + message: "Failed to resolve project favicon.", + cause, + }), + ), + ); const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null; if ( relativePath && @@ -274,13 +308,19 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i Effect.mapError( (cause) => new AssetAccessError({ + operation: "inspect-project-favicon", + resource: input.resource, message: "Failed to inspect the project favicon.", cause, }), ), )) ) { - return yield* new AssetAccessError({ message: "Project favicon was not found." }); + return yield* new AssetAccessError({ + operation: "locate-project-favicon", + resource: input.resource, + message: "Project favicon was not found.", + }); } claims = { version: 1, @@ -289,6 +329,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i Effect.mapError( (cause) => new AssetAccessError({ + operation: "resolve-workspace", + resource: input.resource, message: "Failed to resolve workspace.", cause, }), @@ -307,6 +349,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i Effect.mapError( (cause) => new AssetAccessError({ + operation: "load-signing-key", + resource: input.resource, message: "Failed to load the asset signing key.", cause, }), diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts index 37bda11e6aa..f012face906 100644 --- a/apps/server/src/project/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/ProjectFaviconResolver.test.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; @@ -34,6 +35,12 @@ const writeTextFile = Effect.fn("writeTextFile")(function* ( yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie); }); +const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) => + ProjectFaviconResolver.make.pipe( + Effect.provide(WorkspacePaths.layer), + Effect.provideService(FileSystem.FileSystem, fileSystem), + ); + it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { describe("resolvePath", () => { it.effect("prefers well-known favicon files", () => @@ -73,5 +80,109 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { expect(resolved).toBeNull(); }), ); + + it.effect("preserves workspace normalization context", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + const missingCwd = `${cwd}/missing`; + + const error = yield* resolver.resolvePath(missingCwd).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "ProjectFaviconResolutionError", + operation: "normalize-workspace", + workspaceRoot: missingCwd, + }); + expect(error.cause).toBeInstanceOf(WorkspacePaths.WorkspaceRootNotExistsError); + }), + ); + + it.effect("preserves non-missing candidate stat failures", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const faviconPath = path.join(cwd, "favicon.svg"); + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "stat", + pathOrDescriptor: faviconPath, + }); + const resolver = yield* makeResolverWithFileSystem( + FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => + filePath === faviconPath ? Effect.fail(cause) : fileSystem.stat(filePath), + }), + ); + + const error = yield* resolver.resolvePath(cwd).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "ProjectFaviconResolutionError", + operation: "stat-candidate", + workspaceRoot: cwd, + relativePath: "favicon.svg", + absolutePath: faviconPath, + }); + expect(error.cause).toBe(cause); + }), + ); + + it.effect("preserves icon source read failures", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const sourcePath = path.join(cwd, "index.html"); + yield* writeTextFile(cwd, "index.html", ''); + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: sourcePath, + }); + const resolver = yield* makeResolverWithFileSystem( + FileSystem.FileSystem.of({ + ...fileSystem, + readFileString: (filePath, options) => + filePath === sourcePath + ? Effect.fail(cause) + : fileSystem.readFileString(filePath, options), + }), + ); + + const error = yield* resolver.resolvePath(cwd).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "ProjectFaviconResolutionError", + operation: "read-source", + workspaceRoot: cwd, + relativePath: "index.html", + absolutePath: sourcePath, + }); + expect(error.cause).toBe(cause); + }), + ); + + it.effect("rejects icon metadata paths outside the workspace", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "index.html", ''); + + const error = yield* resolver.resolvePath(cwd).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "ProjectFaviconResolutionError", + operation: "resolve-path", + workspaceRoot: cwd, + relativePath: "../secret.svg", + }); + expect(error.cause).toBeInstanceOf(WorkspacePaths.WorkspacePathOutsideRootError); + }), + ); }); }); diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index 4c685a20f88..46af1680244 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -10,7 +10,10 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; @@ -56,6 +59,26 @@ const LINK_ICON_HTML_RE = const LINK_ICON_OBJ_RE = /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i; +export class ProjectFaviconResolutionError extends Schema.TaggedErrorClass()( + "ProjectFaviconResolutionError", + { + operation: Schema.Literals([ + "normalize-workspace", + "resolve-path", + "stat-candidate", + "read-source", + ]), + workspaceRoot: Schema.String, + relativePath: Schema.optional(Schema.String), + absolutePath: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to resolve project favicon during ${this.operation} for workspace ${this.workspaceRoot}.`; + } +} + /** Service tag for project favicon resolution. */ export class ProjectFaviconResolver extends Context.Service< ProjectFaviconResolver, @@ -65,7 +88,9 @@ export class ProjectFaviconResolver extends Context.Service< * * Returns `null` when no candidate icon file can be found. */ - readonly resolvePath: (cwd: string) => Effect.Effect; + readonly resolvePath: ( + cwd: string, + ) => Effect.Effect; } >()("t3/project/ProjectFaviconResolver") {} @@ -77,6 +102,17 @@ function extractIconHref(source: string): string | null { return null; } +const optionOnNotFound = ( + effect: Effect.Effect, +): Effect.Effect, PlatformError.PlatformError, R> => + effect.pipe( + Effect.map(Option.some), + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed(Option.none()) : Effect.fail(error), + }), + ); + export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -90,21 +126,37 @@ export const make = Effect.gen(function* () { const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* ( projectCwd: string, relativeCandidates: ReadonlyArray, - ): Effect.fn.Return { + ): Effect.fn.Return { for (const relativePath of relativeCandidates) { const candidate = yield* workspacePaths .resolveRelativePathWithinRoot({ workspaceRoot: projectCwd, relativePath, }) - .pipe(Effect.orElseSucceed(() => null)); - if (!candidate) { - continue; - } - const stats = yield* fileSystem - .stat(candidate.absolutePath) - .pipe(Effect.orElseSucceed(() => null)); - if (stats?.type === "File") { + .pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "resolve-path", + workspaceRoot: projectCwd, + relativePath, + cause, + }), + ), + ); + const stats = yield* optionOnNotFound(fileSystem.stat(candidate.absolutePath)).pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "stat-candidate", + workspaceRoot: projectCwd, + relativePath, + absolutePath: candidate.absolutePath, + cause, + }), + ), + ); + if (Option.isSome(stats) && stats.value.type === "File") { return candidate.absolutePath; } } @@ -114,12 +166,16 @@ export const make = Effect.gen(function* () { const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn( "ProjectFaviconResolver.resolvePath", )(function* (cwd) { - const projectCwd = yield* workspacePaths - .normalizeWorkspaceRoot(cwd) - .pipe(Effect.orElseSucceed(() => null)); - if (!projectCwd) { - return null; - } + const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "normalize-workspace", + workspaceRoot: cwd, + cause, + }), + ), + ); for (const candidate of FAVICON_CANDIDATES) { const existing = yield* findExistingFile(projectCwd, [candidate]); if (existing) { @@ -133,17 +189,35 @@ export const make = Effect.gen(function* () { workspaceRoot: projectCwd, relativePath: sourceFile, }) - .pipe(Effect.orElseSucceed(() => null)); - if (!sourcePath) { - continue; - } - const source = yield* fileSystem - .readFileString(sourcePath.absolutePath) - .pipe(Effect.orElseSucceed(() => null)); - if (!source) { + .pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "resolve-path", + workspaceRoot: projectCwd, + relativePath: sourceFile, + cause, + }), + ), + ); + const source = yield* optionOnNotFound( + fileSystem.readFileString(sourcePath.absolutePath), + ).pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "read-source", + workspaceRoot: projectCwd, + relativePath: sourceFile, + absolutePath: sourcePath.absolutePath, + cause, + }), + ), + ); + if (Option.isNone(source)) { continue; } - const href = extractIconHref(source); + const href = extractIconHref(source.value); if (!href) { continue; } diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 03b609ddcfe..40755bb1244 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1341,6 +1341,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => Effect.mapError( (cause) => new AssetAccessError({ + operation: "resolve-workspace-context", + resource: input.resource, message: "Failed to resolve workspace context.", cause, }), @@ -1348,6 +1350,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => ); if (Option.isNone(thread)) { return yield* new AssetAccessError({ + operation: "resolve-workspace-context", + resource: input.resource, message: "Workspace context was not found.", }); } @@ -1357,6 +1361,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => Effect.mapError( (cause) => new AssetAccessError({ + operation: "resolve-workspace-context", + resource: input.resource, message: "Failed to resolve workspace context.", cause, }), @@ -1364,6 +1370,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => ); if (Option.isNone(project)) { return yield* new AssetAccessError({ + operation: "resolve-workspace-context", + resource: input.resource, message: "Workspace context was not found.", }); } diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts index bd1ac0a53ec..fdfbe64246e 100644 --- a/packages/contracts/src/assets.ts +++ b/packages/contracts/src/assets.ts @@ -29,9 +29,27 @@ export const AssetCreateUrlResult = Schema.Struct({ }); export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type; +export const AssetAccessOperation = Schema.Literals([ + "resolve-workspace-context", + "normalize-workspace-root", + "validate-workspace-path", + "validate-preview-type", + "inspect-workspace-asset", + "locate-workspace-asset", + "resolve-workspace", + "locate-attachment", + "resolve-project-favicon", + "inspect-project-favicon", + "locate-project-favicon", + "load-signing-key", +]); +export type AssetAccessOperation = typeof AssetAccessOperation.Type; + export class AssetAccessError extends Schema.TaggedErrorClass()( "AssetAccessError", { + operation: AssetAccessOperation, + resource: AssetResource, message: TrimmedNonEmptyString, cause: Schema.optional(Schema.Defect()), },