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()),
},