diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6abd8f48e611aba8701f62be9defe01039253f64
--- /dev/null
+++ b/apps/server/src/assets/AssetAccess.test.ts
@@ -0,0 +1,150 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { ThreadId } from "@t3tools/contracts";
+import { describe, expect, it } from "@effect/vitest";
+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 ServerSecretStore from "../auth/ServerSecretStore.ts";
+import { ServerConfig } from "../config.ts";
+import { ProjectFaviconResolverLive } from "../project/Layers/ProjectFaviconResolver.ts";
+import { WorkspacePathsLive } from "../workspace/Layers/WorkspacePaths.ts";
+import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts";
+
+const configLayer = ServerConfig.layerTest(process.cwd(), {
+ prefix: "t3-asset-access-test-",
+});
+const testLayer = Layer.mergeAll(
+ configLayer,
+ WorkspacePathsLive,
+ ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive)),
+ ServerSecretStore.layer.pipe(Layer.provide(configLayer)),
+).pipe(Layer.provideMerge(NodeServices.layer));
+
+describe("AssetAccess", () => {
+ it.effect("issues workspace URLs that resolve the entry file and sibling assets", () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const root = yield* fileSystem.makeTempDirectoryScoped({
+ prefix: "t3-asset-workspace-",
+ });
+ const htmlPath = path.join(root, "report.html");
+ const cssPath = path.join(root, "report.css");
+ yield* fileSystem.writeFileString(htmlPath, ' ');
+ yield* fileSystem.writeFileString(cssPath, "body { color: red; }");
+ yield* fileSystem.writeFileString(path.join(root, ".env"), "SECRET=value");
+
+ const result = yield* issueAssetUrl({
+ resource: {
+ _tag: "workspace-file",
+ threadId: ThreadId.make("thread-1"),
+ path: htmlPath,
+ },
+ workspaceRoot: root,
+ });
+ const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
+ const separatorIndex = suffix.indexOf("/");
+ const token = suffix.slice(0, separatorIndex);
+
+ expect(yield* resolveAsset(token, "report.html")).toEqual({
+ kind: "file",
+ path: htmlPath,
+ });
+ expect(yield* resolveAsset(token, "report.css")).toEqual({
+ kind: "file",
+ path: cssPath,
+ });
+ expect(yield* resolveAsset(token, "../secret.txt")).toBeNull();
+ expect(yield* resolveAsset(token, ".env")).toBeNull();
+ expect(yield* resolveAsset(`${token}tampered`, "report.html")).toBeNull();
+ }).pipe(Effect.provide(testLayer)),
+ );
+
+ it.effect("rejects workspace files outside the authorized root", () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const root = yield* fileSystem.makeTempDirectoryScoped({
+ prefix: "t3-asset-root-",
+ });
+ const outside = yield* fileSystem.makeTempDirectoryScoped({
+ prefix: "t3-asset-outside-",
+ });
+ const htmlPath = path.join(outside, "report.html");
+ yield* fileSystem.writeFileString(htmlPath, "
outside
");
+
+ const error = yield* issueAssetUrl({
+ resource: {
+ _tag: "workspace-file",
+ threadId: ThreadId.make("thread-1"),
+ path: htmlPath,
+ },
+ workspaceRoot: root,
+ }).pipe(Effect.flip);
+ expect(error.message).toContain("relative to the project root");
+ }).pipe(Effect.provide(testLayer)),
+ );
+
+ it.effect("issues exact attachment capabilities by attachment id", () =>
+ Effect.gen(function* () {
+ const config = yield* ServerConfig;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001";
+ const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.png`);
+ yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true });
+ yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3]));
+
+ const result = yield* issueAssetUrl({
+ resource: { _tag: "attachment", attachmentId },
+ });
+ const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
+ const separatorIndex = suffix.indexOf("/");
+ const token = suffix.slice(0, separatorIndex);
+
+ expect(yield* resolveAsset(token, "ignored.png")).toEqual({
+ kind: "file",
+ path: attachmentPath,
+ });
+ }).pipe(Effect.provide(testLayer)),
+ );
+
+ it.effect("issues project favicon capabilities with a signed fallback", () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const root = yield* fileSystem.makeTempDirectoryScoped({
+ prefix: "t3-asset-favicon-",
+ });
+ const faviconPath = path.join(root, "favicon.svg");
+ yield* fileSystem.writeFileString(faviconPath, " ");
+
+ const faviconResult = yield* issueAssetUrl({
+ resource: { _tag: "project-favicon", cwd: root },
+ });
+ const faviconSuffix = faviconResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
+ const faviconSeparatorIndex = faviconSuffix.indexOf("/");
+ expect(
+ yield* resolveAsset(
+ faviconSuffix.slice(0, faviconSeparatorIndex),
+ faviconSuffix.slice(faviconSeparatorIndex + 1),
+ ),
+ ).toEqual({ kind: "file", path: faviconPath });
+
+ yield* fileSystem.remove(faviconPath);
+ const fallbackResult = yield* issueAssetUrl({
+ resource: { _tag: "project-favicon", cwd: root },
+ });
+ const fallbackSuffix = fallbackResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
+ const fallbackSeparatorIndex = fallbackSuffix.indexOf("/");
+ expect(
+ yield* resolveAsset(
+ fallbackSuffix.slice(0, fallbackSeparatorIndex),
+ fallbackSuffix.slice(fallbackSeparatorIndex + 1),
+ ),
+ ).toEqual({ kind: "project-favicon-fallback" });
+ }).pipe(Effect.provide(testLayer)),
+ );
+});
diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts
new file mode 100644
index 0000000000000000000000000000000000000000..659413f47481dcdf261ea01564ee32af3b0ebd53
--- /dev/null
+++ b/apps/server/src/assets/AssetAccess.ts
@@ -0,0 +1,287 @@
+import type { AssetResource } from "@t3tools/contracts";
+import { AssetAccessError } from "@t3tools/contracts";
+import * as Clock from "effect/Clock";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Option from "effect/Option";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+
+import {
+ base64UrlDecodeUtf8,
+ base64UrlEncode,
+ signPayload,
+ timingSafeEqualBase64Url,
+} from "../auth/utils.ts";
+import { ServerSecretStore } from "../auth/ServerSecretStore.ts";
+import { resolveAttachmentPathById } from "../attachmentStore.ts";
+import { ServerConfig } from "../config.ts";
+import { ProjectFaviconResolver } from "../project/Services/ProjectFaviconResolver.ts";
+import { WorkspacePaths } from "../workspace/Services/WorkspacePaths.ts";
+
+export const ASSET_ROUTE_PREFIX = "/api/assets";
+export const FALLBACK_PROJECT_FAVICON_SVG = ` `;
+
+const SIGNING_SECRET_NAME = "asset-access-signing-key";
+const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000;
+const PREVIEWABLE_EXTENSIONS = new Set([".htm", ".html", ".pdf"]);
+const PREVIEW_ASSET_EXTENSIONS = new Set([
+ ...PREVIEWABLE_EXTENSIONS,
+ ".avif",
+ ".css",
+ ".gif",
+ ".ico",
+ ".jpeg",
+ ".jpg",
+ ".js",
+ ".mjs",
+ ".otf",
+ ".png",
+ ".svg",
+ ".ttf",
+ ".webp",
+ ".woff",
+ ".woff2",
+]);
+
+const AssetClaimsSchema = Schema.Union([
+ Schema.Struct({
+ version: Schema.Literal(1),
+ kind: Schema.Literal("workspace-file"),
+ workspaceRoot: Schema.String,
+ baseRelativePath: Schema.String,
+ expiresAt: Schema.Number,
+ }),
+ Schema.Struct({
+ version: Schema.Literal(1),
+ kind: Schema.Literal("attachment"),
+ attachmentId: Schema.String,
+ expiresAt: Schema.Number,
+ }),
+ Schema.Struct({
+ version: Schema.Literal(1),
+ kind: Schema.Literal("project-favicon"),
+ workspaceRoot: Schema.String,
+ relativePath: Schema.NullOr(Schema.String),
+ expiresAt: Schema.Number,
+ }),
+]);
+type AssetClaims = typeof AssetClaimsSchema.Type;
+
+const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema);
+const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson);
+const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson);
+
+export type ResolvedAsset =
+ | { readonly kind: "file"; readonly path: string }
+ | { readonly kind: "project-favicon-fallback" };
+
+function decodeClaims(encodedPayload: string): AssetClaims | null {
+ try {
+ return Option.getOrNull(decodeAssetClaims(base64UrlDecodeUtf8(encodedPayload)));
+ } catch {
+ return null;
+ }
+}
+
+function decodeRelativePath(value: string): string | null {
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return null;
+ }
+}
+
+const failAccess = (message: string, cause?: unknown) =>
+ new AssetAccessError({ message, ...(cause === undefined ? {} : { cause }) });
+
+const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWorkspaceFile")(
+ function* (input: { readonly workspaceRoot: string; readonly relativePath: string }) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const workspacePaths = yield* WorkspacePaths;
+ const resolved = yield* workspacePaths
+ .resolveRelativePathWithinRoot(input)
+ .pipe(Effect.orElseSucceed(() => null));
+ if (!resolved) return null;
+
+ const [canonicalRoot, canonicalFile] = yield* Effect.all([
+ fileSystem.realPath(input.workspaceRoot).pipe(Effect.orElseSucceed(() => null)),
+ fileSystem.realPath(resolved.absolutePath).pipe(Effect.orElseSucceed(() => null)),
+ ]);
+ if (!canonicalRoot || !canonicalFile) return null;
+
+ const path = yield* Path.Path;
+ const relative = path.relative(canonicalRoot, canonicalFile);
+ if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) return null;
+
+ const info = yield* fileSystem.stat(canonicalFile).pipe(Effect.orElseSucceed(() => null));
+ return info?.type === "File" ? canonicalFile : null;
+ },
+);
+
+export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: {
+ readonly resource: AssetResource;
+ readonly workspaceRoot?: string;
+}) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const workspacePaths = yield* WorkspacePaths;
+ const expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS;
+ let claims: AssetClaims;
+ let fileName: string;
+
+ switch (input.resource._tag) {
+ case "workspace-file": {
+ if (!input.workspaceRoot) {
+ return yield* failAccess("Workspace context was not found.");
+ }
+ const workspaceRoot = yield* workspacePaths
+ .normalizeWorkspaceRoot(input.workspaceRoot)
+ .pipe(Effect.mapError((cause) => failAccess(cause.message, cause)));
+ const relativePath = path.isAbsolute(input.resource.path)
+ ? path.relative(workspaceRoot, input.resource.path)
+ : input.resource.path;
+ const resolved = yield* workspacePaths
+ .resolveRelativePathWithinRoot({ workspaceRoot, relativePath })
+ .pipe(Effect.mapError((cause) => failAccess(cause.message, cause)));
+ if (!PREVIEWABLE_EXTENSIONS.has(path.extname(resolved.relativePath).toLowerCase())) {
+ return yield* failAccess("Only HTML and PDF files can open in the browser.");
+ }
+ const canonicalFile = yield* resolveCanonicalWorkspaceFile({
+ workspaceRoot,
+ relativePath: resolved.relativePath,
+ });
+ if (!canonicalFile) {
+ return yield* failAccess("Workspace asset was not found.");
+ }
+ claims = {
+ version: 1,
+ kind: "workspace-file",
+ workspaceRoot: yield* fileSystem
+ .realPath(workspaceRoot)
+ .pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause))),
+ baseRelativePath: path.dirname(resolved.relativePath),
+ expiresAt,
+ };
+ fileName = path.basename(resolved.relativePath);
+ break;
+ }
+ case "attachment": {
+ const config = yield* ServerConfig;
+ const attachmentPath = resolveAttachmentPathById({
+ attachmentsDir: config.attachmentsDir,
+ attachmentId: input.resource.attachmentId,
+ });
+ if (!attachmentPath) {
+ return yield* failAccess("Attachment was not found.");
+ }
+ claims = {
+ version: 1,
+ kind: "attachment",
+ attachmentId: input.resource.attachmentId,
+ expiresAt,
+ };
+ fileName = path.basename(attachmentPath);
+ break;
+ }
+ case "project-favicon": {
+ const workspaceRoot = yield* workspacePaths
+ .normalizeWorkspaceRoot(input.resource.cwd)
+ .pipe(Effect.mapError((cause) => failAccess(cause.message, cause)));
+ const faviconResolver = yield* ProjectFaviconResolver;
+ const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot);
+ const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null;
+ if (
+ relativePath &&
+ !(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }))
+ ) {
+ return yield* failAccess("Project favicon was not found.");
+ }
+ claims = {
+ version: 1,
+ kind: "project-favicon",
+ workspaceRoot: yield* fileSystem
+ .realPath(workspaceRoot)
+ .pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause))),
+ relativePath,
+ expiresAt,
+ };
+ fileName = relativePath ? path.basename(relativePath) : "favicon.svg";
+ break;
+ }
+ }
+
+ const secretStore = yield* ServerSecretStore;
+ const signingSecret = yield* secretStore
+ .getOrCreateRandom(SIGNING_SECRET_NAME, 32)
+ .pipe(Effect.mapError((cause) => failAccess(cause.message, cause)));
+ const encodedPayload = base64UrlEncode(encodeAssetClaims(claims));
+ const token = `${encodedPayload}.${signPayload(encodedPayload, signingSecret)}`;
+ return {
+ relativeUrl: `${ASSET_ROUTE_PREFIX}/${token}/${encodeURIComponent(fileName)}`,
+ expiresAt,
+ };
+});
+
+export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
+ token: string,
+ relativePath: string,
+) {
+ const [encodedPayload, signature] = token.split(".");
+ if (!encodedPayload || !signature) return null;
+
+ const secretStore = yield* ServerSecretStore;
+ const signingSecret = yield* secretStore
+ .getOrCreateRandom(SIGNING_SECRET_NAME, 32)
+ .pipe(Effect.orElseSucceed(() => null));
+ if (!signingSecret) return null;
+ if (!timingSafeEqualBase64Url(signature, signPayload(encodedPayload, signingSecret))) return null;
+
+ const claims = decodeClaims(encodedPayload);
+ if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null;
+
+ if (claims.kind === "attachment") {
+ const config = yield* ServerConfig;
+ const attachmentPath = resolveAttachmentPathById({
+ attachmentsDir: config.attachmentsDir,
+ attachmentId: claims.attachmentId,
+ });
+ if (!attachmentPath) return null;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const info = yield* fileSystem.stat(attachmentPath).pipe(Effect.orElseSucceed(() => null));
+ return info?.type === "File"
+ ? ({ kind: "file", path: attachmentPath } satisfies ResolvedAsset)
+ : null;
+ }
+
+ if (claims.kind === "project-favicon") {
+ if (claims.relativePath === null) {
+ return { kind: "project-favicon-fallback" } satisfies ResolvedAsset;
+ }
+ const faviconPath = yield* resolveCanonicalWorkspaceFile({
+ workspaceRoot: claims.workspaceRoot,
+ relativePath: claims.relativePath,
+ });
+ return faviconPath ? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset) : null;
+ }
+
+ const decodedPath = decodeRelativePath(relativePath);
+ if (decodedPath === null) return null;
+ const path = yield* Path.Path;
+ const segments = decodedPath.split(/[\\/]/);
+ if (
+ decodedPath.length === 0 ||
+ decodedPath.includes("\0") ||
+ segments.some((segment) => segment === "." || segment === ".." || segment.startsWith(".")) ||
+ !PREVIEW_ASSET_EXTENSIONS.has(path.extname(decodedPath).toLowerCase())
+ ) {
+ return null;
+ }
+ const joinedRelativePath =
+ claims.baseRelativePath === "." ? decodedPath : path.join(claims.baseRelativePath, decodedPath);
+ const workspaceFile = yield* resolveCanonicalWorkspaceFile({
+ workspaceRoot: claims.workspaceRoot,
+ relativePath: joinedRelativePath,
+ });
+ return workspaceFile ? ({ kind: "file", path: workspaceFile } satisfies ResolvedAsset) : null;
+});
diff --git a/apps/server/src/attachmentPaths.ts b/apps/server/src/attachmentPaths.ts
index 8c6999a7341590118695f3ff4568ae88b9b30f64..dc7db4354262cb1edc1882c6f0677cea2099afe7 100644
--- a/apps/server/src/attachmentPaths.ts
+++ b/apps/server/src/attachmentPaths.ts
@@ -1,8 +1,6 @@
// @effect-diagnostics nodeBuiltinImport:off
import NodePath from "node:path";
-export const ATTACHMENTS_ROUTE_PREFIX = "/attachments";
-
export function normalizeAttachmentRelativePath(rawRelativePath: string): string | null {
const normalized = NodePath.normalize(rawRelativePath).replace(/^[/\\]+/, "");
if (normalized.length === 0 || normalized.startsWith("..") || normalized.includes("\0")) {
diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts
index 517d57168c39c0e830aa425344da09d6699eff2e..5197ad342964351ea523afb0ed5d2457fd2172cc 100644
--- a/apps/server/src/http.ts
+++ b/apps/server/src/http.ts
@@ -24,15 +24,13 @@ import {
import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
import { OtlpTracer } from "effect/unstable/observability";
-import {
- ATTACHMENTS_ROUTE_PREFIX,
- normalizeAttachmentRelativePath,
- resolveAttachmentRelativePath,
-} from "./attachmentPaths.ts";
-import { resolveAttachmentPathById } from "./attachmentStore.ts";
import { resolveStaticDir, ServerConfig } from "./config.ts";
+import {
+ ASSET_ROUTE_PREFIX,
+ FALLBACK_PROJECT_FAVICON_SVG,
+ resolveAsset,
+} from "./assets/AssetAccess.ts";
import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts";
-import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts";
import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
import {
annotateEnvironmentRequest,
@@ -43,8 +41,6 @@ import {
import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts";
import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts";
-const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600";
-const FALLBACK_PROJECT_FAVICON_SVG = ` `;
const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";
const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
@@ -169,107 +165,50 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add(
),
);
-export const attachmentsRouteLayer = HttpRouter.add(
+export const assetRouteLayer = HttpRouter.add(
"GET",
- `${ATTACHMENTS_ROUTE_PREFIX}/*`,
+ `${ASSET_ROUTE_PREFIX}/*`,
Effect.gen(function* () {
- yield* authenticateRawRouteWithScope(AuthOrchestrationReadScope);
const request = yield* HttpServerRequest.HttpServerRequest;
const url = HttpServerRequest.toURL(request);
if (Option.isNone(url)) {
return HttpServerResponse.text("Bad Request", { status: 400 });
}
- const config = yield* ServerConfig;
- const rawRelativePath = url.value.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length);
- const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath);
- if (!normalizedRelativePath) {
- return HttpServerResponse.text("Invalid attachment path", { status: 400 });
- }
-
- const isIdLookup =
- !normalizedRelativePath.includes("/") && !normalizedRelativePath.includes(".");
- const filePath = isIdLookup
- ? resolveAttachmentPathById({
- attachmentsDir: config.attachmentsDir,
- attachmentId: normalizedRelativePath,
- })
- : resolveAttachmentRelativePath({
- attachmentsDir: config.attachmentsDir,
- relativePath: normalizedRelativePath,
- });
- if (!filePath) {
- return HttpServerResponse.text(isIdLookup ? "Not Found" : "Invalid attachment path", {
- status: isIdLookup ? 404 : 400,
- });
- }
-
- const fileSystem = yield* FileSystem.FileSystem;
- const fileInfo = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null));
- if (!fileInfo || fileInfo.type !== "File") {
+ const suffix = url.value.pathname.slice(`${ASSET_ROUTE_PREFIX}/`.length);
+ const separatorIndex = suffix.indexOf("/");
+ if (separatorIndex <= 0) {
return HttpServerResponse.text("Not Found", { status: 404 });
}
- return yield* HttpServerResponse.file(filePath, {
- status: 200,
- headers: {
- "Cache-Control": "public, max-age=31536000, immutable",
- },
- }).pipe(
- Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })),
+ const asset = yield* resolveAsset(
+ suffix.slice(0, separatorIndex),
+ suffix.slice(separatorIndex + 1),
);
- }).pipe(
- Effect.catchTags({
- EnvironmentAuthInvalidError: HttpServerRespondable.toResponse,
- EnvironmentInternalError: HttpServerRespondable.toResponse,
- EnvironmentScopeRequiredError: HttpServerRespondable.toResponse,
- }),
- ),
-);
-
-export const projectFaviconRouteLayer = HttpRouter.add(
- "GET",
- "/api/project-favicon",
- Effect.gen(function* () {
- yield* authenticateRawRouteWithScope(AuthOrchestrationReadScope);
- const request = yield* HttpServerRequest.HttpServerRequest;
- const url = HttpServerRequest.toURL(request);
- if (Option.isNone(url)) {
- return HttpServerResponse.text("Bad Request", { status: 400 });
+ if (!asset) {
+ return HttpServerResponse.text("Not Found", { status: 404 });
}
-
- const projectCwd = url.value.searchParams.get("cwd");
- if (!projectCwd) {
- return HttpServerResponse.text("Missing cwd parameter", { status: 400 });
- }
-
- const faviconResolver = yield* ProjectFaviconResolver;
- const faviconFilePath = yield* faviconResolver.resolvePath(projectCwd);
- if (!faviconFilePath) {
+ if (asset.kind === "project-favicon-fallback") {
return HttpServerResponse.text(FALLBACK_PROJECT_FAVICON_SVG, {
status: 200,
contentType: "image/svg+xml",
headers: {
- "Cache-Control": PROJECT_FAVICON_CACHE_CONTROL,
+ "Cache-Control": "private, max-age=3600",
+ "X-Content-Type-Options": "nosniff",
},
});
}
- return yield* HttpServerResponse.file(faviconFilePath, {
+ return yield* HttpServerResponse.file(asset.path, {
status: 200,
headers: {
- "Cache-Control": PROJECT_FAVICON_CACHE_CONTROL,
+ "Cache-Control": "private, max-age=3600",
+ "X-Content-Type-Options": "nosniff",
},
}).pipe(
Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })),
);
- }).pipe(
- Effect.catchTags({
- EnvironmentAuthInvalidError: HttpServerRespondable.toResponse,
- EnvironmentInternalError: HttpServerRespondable.toResponse,
- EnvironmentScopeRequiredError: HttpServerRespondable.toResponse,
- }),
- ),
+ }),
);
export const staticAndDevRouteLayer = HttpRouter.add(
diff --git a/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts b/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts
index c983aca4ba7521597d4142c7e4cb6fa85ca0138a..5c0e5d957425e93317b04a86fda15963806de153 100644
--- a/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts
+++ b/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts
@@ -7,9 +7,10 @@ import * as Path from "effect/Path";
import { ProjectFaviconResolver } from "../Services/ProjectFaviconResolver.ts";
import { ProjectFaviconResolverLive } from "./ProjectFaviconResolver.ts";
+import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts";
const TestLayer = Layer.empty.pipe(
- Layer.provideMerge(ProjectFaviconResolverLive),
+ Layer.provideMerge(ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive))),
Layer.provideMerge(NodeServices.layer),
);
diff --git a/apps/server/src/project/Layers/ProjectFaviconResolver.ts b/apps/server/src/project/Layers/ProjectFaviconResolver.ts
index cdfddd5438a05bd9c6226eaf73ac6e7ff1f68065..a994d1a7e8c32d0d8bfb1db35b96f6b56699b636 100644
--- a/apps/server/src/project/Layers/ProjectFaviconResolver.ts
+++ b/apps/server/src/project/Layers/ProjectFaviconResolver.ts
@@ -7,6 +7,7 @@ import {
ProjectFaviconResolver,
type ProjectFaviconResolverShape,
} from "../Services/ProjectFaviconResolver.ts";
+import { WorkspacePaths } from "../../workspace/Services/WorkspacePaths.ts";
// Well-known favicon paths checked in order.
const FAVICON_CANDIDATES = [
@@ -61,28 +62,32 @@ function extractIconHref(source: string): string | null {
export const makeProjectFaviconResolver = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
+ const workspacePaths = yield* WorkspacePaths;
- const resolveIconHref = (projectCwd: string, href: string): string[] => {
+ const resolveIconHref = (href: string): string[] => {
const clean = href.replace(/^\//, "");
- return [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)];
- };
-
- const isPathWithinProject = (projectCwd: string, candidatePath: string): boolean => {
- const relative = path.relative(path.resolve(projectCwd), path.resolve(candidatePath));
- return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
+ return [path.join("public", clean), clean];
};
const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* (
projectCwd: string,
- candidates: ReadonlyArray,
+ relativeCandidates: ReadonlyArray,
): Effect.fn.Return {
- for (const candidate of candidates) {
- if (!isPathWithinProject(projectCwd, candidate)) {
+ 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).pipe(Effect.orElseSucceed(() => null));
+ const stats = yield* fileSystem
+ .stat(candidate.absolutePath)
+ .pipe(Effect.orElseSucceed(() => null));
if (stats?.type === "File") {
- return candidate;
+ return candidate.absolutePath;
}
}
return null;
@@ -91,18 +96,31 @@ export const makeProjectFaviconResolver = Effect.gen(function* () {
const resolvePath: ProjectFaviconResolverShape["resolvePath"] = Effect.fn(
"ProjectFaviconResolver.resolvePath",
)(function* (cwd: string): Effect.fn.Return {
+ const projectCwd = yield* workspacePaths
+ .normalizeWorkspaceRoot(cwd)
+ .pipe(Effect.orElseSucceed(() => null));
+ if (!projectCwd) {
+ return null;
+ }
for (const candidate of FAVICON_CANDIDATES) {
- const resolved = path.join(cwd, candidate);
- const existing = yield* findExistingFile(cwd, [resolved]);
+ const existing = yield* findExistingFile(projectCwd, [candidate]);
if (existing) {
return existing;
}
}
for (const sourceFile of ICON_SOURCE_FILES) {
- const sourcePath = path.join(cwd, sourceFile);
+ const sourcePath = yield* workspacePaths
+ .resolveRelativePathWithinRoot({
+ workspaceRoot: projectCwd,
+ relativePath: sourceFile,
+ })
+ .pipe(Effect.orElseSucceed(() => null));
+ if (!sourcePath) {
+ continue;
+ }
const source = yield* fileSystem
- .readFileString(sourcePath)
+ .readFileString(sourcePath.absolutePath)
.pipe(Effect.orElseSucceed(() => null));
if (!source) {
continue;
@@ -111,7 +129,7 @@ export const makeProjectFaviconResolver = Effect.gen(function* () {
if (!href) {
continue;
}
- const existing = yield* findExistingFile(cwd, resolveIconHref(cwd, href));
+ const existing = yield* findExistingFile(projectCwd, resolveIconHref(href));
if (existing) {
return existing;
}
diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts
index a1cd119a7a6d54770068b8d29bcbda011fdbefeb..40c9c7cd9a81abc374a725b41e9bda9463570239 100644
--- a/apps/server/src/server.test.ts
+++ b/apps/server/src/server.test.ts
@@ -71,7 +71,6 @@ const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
import type { ServerConfigShape } from "./config.ts";
import { deriveServerPaths, ServerConfig } from "./config.ts";
import { makeRoutesLayer } from "./server.ts";
-import { resolveAttachmentRelativePath } from "./attachmentPaths.ts";
import {
CheckpointDiffQuery,
type CheckpointDiffQueryShape,
@@ -516,7 +515,7 @@ const buildAppUnderTest = (options?: {
Layer.provide(WorkspacePathsLive),
Layer.provide(workspaceEntriesLayer),
),
- ProjectFaviconResolverLive,
+ ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive)),
);
const gitWorkflowLayer = GitWorkflowService.layer.pipe(
Layer.provideMerge(vcsDriverRegistryLayer),
@@ -1279,61 +1278,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);
- it.effect("serves project favicon requests before the dev URL redirect", () =>
- Effect.gen(function* () {
- const fileSystem = yield* FileSystem.FileSystem;
- const path = yield* Path.Path;
- const projectDir = yield* fileSystem.makeTempDirectoryScoped({
- prefix: "t3-router-project-favicon-",
- });
- yield* fileSystem.writeFileString(
- path.join(projectDir, "favicon.svg"),
- "router-project-favicon ",
- );
-
- yield* buildAppUnderTest({
- config: { devUrl: new URL("http://127.0.0.1:5173") },
- });
-
- const response = yield* HttpClient.get(
- `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`,
- {
- headers: {
- cookie: yield* getAuthenticatedSessionCookieHeader(),
- },
- },
- );
-
- assert.equal(response.status, 200);
- assert.equal(yield* response.text, "router-project-favicon ");
- }).pipe(Effect.provide(NodeHttpServer.layerTest)),
- );
-
- it.effect("serves the fallback project favicon when no icon exists", () =>
- Effect.gen(function* () {
- const fileSystem = yield* FileSystem.FileSystem;
- const projectDir = yield* fileSystem.makeTempDirectoryScoped({
- prefix: "t3-router-project-favicon-fallback-",
- });
-
- yield* buildAppUnderTest({
- config: { devUrl: new URL("http://127.0.0.1:5173") },
- });
-
- const response = yield* HttpClient.get(
- `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`,
- {
- headers: {
- cookie: yield* getAuthenticatedSessionCookieHeader(),
- },
- },
- );
-
- assert.equal(response.status, 200);
- assert.include(yield* response.text, 'data-fallback="project-favicon"');
- }).pipe(Effect.provide(NodeHttpServer.layerTest)),
- );
-
it.effect("serves the public environment descriptor without requiring auth", () =>
Effect.gen(function* () {
yield* buildAppUnderTest();
@@ -3195,28 +3139,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
},
});
const wsTicketBody = (yield* wsTicketResponse.json) as { readonly ticket: string };
- const faviconResponse = yield* HttpClient.get("/api/project-favicon?cwd=/tmp", {
- headers: {
- authorization: `Bearer ${tokenBody.access_token ?? ""}`,
- },
- });
- const faviconBody = (yield* faviconResponse.json) as {
- readonly _tag: string;
- readonly code: string;
- readonly requiredScope: string;
- readonly traceId: string;
- };
-
assert.equal(overbroadPairingResponse.status, 403);
assert.equal(overbroadPairingBody.requiredScope, "orchestration:read");
assert.equal(pairingResponse.status, 200);
assert.equal(wsTicketResponse.status, 200);
- assert.equal(faviconResponse.status, 403);
- assert.equal(faviconBody._tag, "EnvironmentScopeRequiredError");
- assert.equal(faviconBody.code, "insufficient_scope");
- assert.equal(faviconBody.requiredScope, "orchestration:read");
- assert.equal(typeof faviconBody.traceId, "string");
-
const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`;
const rpcError = yield* Effect.flip(
Effect.scoped(withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({}))),
@@ -3760,29 +3686,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);
- it.effect(
- "does not accept session tokens via query parameters on authenticated HTTP routes",
- () =>
- Effect.gen(function* () {
- const fileSystem = yield* FileSystem.FileSystem;
- const projectDir = yield* fileSystem.makeTempDirectoryScoped({
- prefix: "t3-router-project-favicon-query-token-",
- });
-
- yield* buildAppUnderTest();
-
- const { cookie } = yield* bootstrapBrowserSession();
- assert.isDefined(cookie);
- const sessionToken = extractSessionTokenFromSetCookie(cookie ?? "");
-
- const response = yield* HttpClient.get(
- `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}&token=${encodeURIComponent(sessionToken)}`,
- );
-
- assert.equal(response.status, 401);
- }).pipe(Effect.provide(NodeHttpServer.layerTest)),
- );
-
it.effect("accepts websocket rpc handshake with a bootstrapped browser session cookie", () =>
Effect.gen(function* () {
yield* buildAppUnderTest();
@@ -3853,60 +3756,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);
- it.effect("serves attachment files from state dir", () =>
- Effect.gen(function* () {
- const fileSystem = yield* FileSystem.FileSystem;
- const path = yield* Path.Path;
- const attachmentId = "thread-11111111-1111-4111-8111-111111111111";
-
- const config = yield* buildAppUnderTest();
- const attachmentPath = resolveAttachmentRelativePath({
- attachmentsDir: config.attachmentsDir,
- relativePath: `${attachmentId}.bin`,
- });
- assert.isNotNull(attachmentPath, "Attachment path should be resolvable");
-
- yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true });
- yield* fileSystem.writeFileString(attachmentPath, "attachment-ok");
-
- const response = yield* HttpClient.get(`/attachments/${attachmentId}`, {
- headers: {
- cookie: yield* getAuthenticatedSessionCookieHeader(),
- },
- });
- assert.equal(response.status, 200);
- assert.equal(yield* response.text, "attachment-ok");
- }).pipe(Effect.provide(NodeHttpServer.layerTest)),
- );
-
- it.effect("serves attachment files for URL-encoded paths", () =>
- Effect.gen(function* () {
- const fileSystem = yield* FileSystem.FileSystem;
- const path = yield* Path.Path;
-
- const config = yield* buildAppUnderTest();
- const attachmentPath = resolveAttachmentRelativePath({
- attachmentsDir: config.attachmentsDir,
- relativePath: "thread%20folder/message%20folder/file%20name.png",
- });
- assert.isNotNull(attachmentPath, "Attachment path should be resolvable");
-
- yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true });
- yield* fileSystem.writeFileString(attachmentPath, "attachment-encoded-ok");
-
- const response = yield* HttpClient.get(
- "/attachments/thread%20folder/message%20folder/file%20name.png",
- {
- headers: {
- cookie: yield* getAuthenticatedSessionCookieHeader(),
- },
- },
- );
- assert.equal(response.status, 200);
- assert.equal(yield* response.text, "attachment-encoded-ok");
- }).pipe(Effect.provide(NodeHttpServer.layerTest)),
- );
-
it.effect("proxies browser OTLP trace exports through the server", () =>
Effect.gen(function* () {
const upstreamRequests: Array<{
@@ -4196,22 +4045,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);
- it.effect("returns 404 for missing attachment id lookups", () =>
- Effect.gen(function* () {
- yield* buildAppUnderTest();
-
- const response = yield* HttpClient.get(
- "/attachments/missing-11111111-1111-4111-8111-111111111111",
- {
- headers: {
- cookie: yield* getAuthenticatedSessionCookieHeader(),
- },
- },
- );
- assert.equal(response.status, 404);
- }).pipe(Effect.provide(NodeHttpServer.layerTest)),
- );
-
it.effect("routes websocket rpc server.upsertKeybinding", () =>
Effect.gen(function* () {
const rule: KeybindingRule = {
diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts
index c565c91f1a49ceb749434bc6c0f6c5ca3bfd3e51..3a95f906866f7adf29d2d6e9115552e05c84745c 100644
--- a/apps/server/src/server.ts
+++ b/apps/server/src/server.ts
@@ -6,9 +6,8 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
import { ServerConfig } from "./config.ts";
import {
- attachmentsRouteLayer,
otlpTracesProxyRouteLayer,
- projectFaviconRouteLayer,
+ assetRouteLayer,
serverEnvironmentHttpApiLayer,
staticAndDevRouteLayer,
browserApiCorsLayer,
@@ -264,6 +263,10 @@ const WorkspaceLayerLive = Layer.mergeAll(
WorkspaceFileSystemLayerLive,
);
+const ProjectFaviconResolverLayerLive = ProjectFaviconResolverLive.pipe(
+ Layer.provide(WorkspacePathsLive),
+);
+
const AuthLayerLive = EnvironmentAuth.layer.pipe(
Layer.provideMerge(PersistenceLayerLive),
Layer.provide(ServerSecretStore.layer),
@@ -313,7 +316,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
Layer.provideMerge(OpenCodeRuntimeLive),
Layer.provideMerge(ServerSettingsLive),
Layer.provideMerge(WorkspaceLayerLive),
- Layer.provideMerge(ProjectFaviconResolverLive),
+ Layer.provideMerge(ProjectFaviconResolverLayerLive),
Layer.provideMerge(RepositoryIdentityResolverLive),
Layer.provideMerge(ServerEnvironmentLive),
Layer.provideMerge(AuthLayerLive),
@@ -350,9 +353,8 @@ export const makeRoutesLayer = Layer.mergeAll(
Layer.provide(serverEnvironmentHttpApiLayer),
Layer.provide(environmentAuthenticatedAuthLayer),
),
- attachmentsRouteLayer,
otlpTracesProxyRouteLayer,
- projectFaviconRouteLayer,
+ assetRouteLayer,
staticAndDevRouteLayer,
websocketRpcRouteLayer,
),
diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts
index d35ab88fee6b366ed8458c027680544a67ba465a..2823923e0335dc2378cd1e4b3fb44ac441ed62ca 100644
--- a/apps/server/src/ws.ts
+++ b/apps/server/src/ws.ts
@@ -40,6 +40,7 @@ import {
type RelayClientInstallProgressEvent,
OrchestrationReplayEventsError,
FilesystemBrowseError,
+ AssetAccessError,
EnvironmentAuthorizationError,
ThreadId,
type TerminalAttachStreamEvent,
@@ -73,6 +74,7 @@ import { redactServerSettingsForClient, ServerSettingsService } from "./serverSe
import { TerminalManager } from "./terminal/Services/Manager.ts";
import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts";
import * as PreviewManager from "./preview/Manager.ts";
+import { issueAssetUrl } from "./assets/AssetAccess.ts";
import * as PortScanner from "./preview/PortScanner.ts";
import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts";
import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts";
@@ -162,6 +164,7 @@ const RPC_REQUIRED_SCOPE = new Map([
[WS_METHODS.projectsWriteFile, AuthOrchestrationOperateScope],
[WS_METHODS.shellOpenInEditor, AuthOrchestrationOperateScope],
[WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope],
+ [WS_METHODS.assetsCreateUrl, AuthOrchestrationReadScope],
[WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope],
[WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope],
[WS_METHODS.vcsPull, AuthOrchestrationOperateScope],
@@ -1203,6 +1206,52 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>
),
{ "rpc.aggregate": "workspace" },
),
+ [WS_METHODS.assetsCreateUrl]: (input) =>
+ observeRpcEffect(
+ WS_METHODS.assetsCreateUrl,
+ Effect.gen(function* () {
+ if (input.resource._tag !== "workspace-file") {
+ return yield* issueAssetUrl({ resource: input.resource });
+ }
+ const thread = yield* projectionSnapshotQuery
+ .getThreadShellById(input.resource.threadId)
+ .pipe(
+ Effect.mapError(
+ (cause) =>
+ new AssetAccessError({
+ message: "Failed to resolve workspace context.",
+ cause,
+ }),
+ ),
+ );
+ if (Option.isNone(thread)) {
+ return yield* new AssetAccessError({
+ message: "Workspace context was not found.",
+ });
+ }
+ const project = yield* projectionSnapshotQuery
+ .getProjectShellById(thread.value.projectId)
+ .pipe(
+ Effect.mapError(
+ (cause) =>
+ new AssetAccessError({
+ message: "Failed to resolve workspace context.",
+ cause,
+ }),
+ ),
+ );
+ if (Option.isNone(project)) {
+ return yield* new AssetAccessError({
+ message: "Workspace context was not found.",
+ });
+ }
+ return yield* issueAssetUrl({
+ resource: input.resource,
+ workspaceRoot: thread.value.worktreePath ?? project.value.workspaceRoot,
+ });
+ }),
+ { "rpc.aggregate": "workspace" },
+ ),
[WS_METHODS.subscribeVcsStatus]: (input) =>
observeRpcStream(
WS_METHODS.subscribeVcsStatus,
diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e4fba2c5b99c36b1c43c3a964da02e4bad4e5ff6
--- /dev/null
+++ b/apps/web/src/assets/assetUrls.ts
@@ -0,0 +1,89 @@
+import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
+import { useEffect, useMemo, useState } from "react";
+
+import { readEnvironmentApi } from "~/environmentApi";
+import { readEnvironmentConnection } from "~/environments/runtime";
+
+const REFRESH_MARGIN_MS = 30_000;
+
+interface CachedAssetUrl {
+ readonly url: string;
+ readonly expiresAt: number;
+}
+
+const assetUrlCache = new Map();
+const assetUrlRequests = new Map>();
+
+function assetCacheKey(environmentId: EnvironmentId, resource: AssetResource): string {
+ return `${environmentId}:${JSON.stringify(resource)}`;
+}
+
+export async function resolveAssetUrl(
+ environmentId: EnvironmentId,
+ resource: AssetResource,
+): Promise {
+ const key = assetCacheKey(environmentId, resource);
+ const cached = assetUrlCache.get(key);
+ if (cached && cached.expiresAt - REFRESH_MARGIN_MS > Date.now()) {
+ return cached;
+ }
+
+ const inFlight = assetUrlRequests.get(key);
+ if (inFlight) {
+ return inFlight;
+ }
+
+ const request = (async () => {
+ const api = readEnvironmentApi(environmentId);
+ const connection = readEnvironmentConnection(environmentId);
+ if (!api || !connection) {
+ throw new Error("Environment is not connected.");
+ }
+ const result = await api.assets.createUrl({ resource });
+ const cachedResult = {
+ url: new URL(result.relativeUrl, connection.knownEnvironment.target.httpBaseUrl).toString(),
+ expiresAt: result.expiresAt,
+ };
+ assetUrlCache.set(key, cachedResult);
+ return cachedResult;
+ })().finally(() => {
+ assetUrlRequests.delete(key);
+ });
+ assetUrlRequests.set(key, request);
+ return request;
+}
+
+export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null {
+ const resourceJson = JSON.stringify(resource);
+ const stableResource = useMemo(() => JSON.parse(resourceJson) as AssetResource, [resourceJson]);
+ const key = assetCacheKey(environmentId, stableResource);
+ const [url, setUrl] = useState(() => assetUrlCache.get(key)?.url ?? null);
+
+ useEffect(() => {
+ let cancelled = false;
+ let refreshTimer: ReturnType | undefined;
+
+ const load = () => {
+ void resolveAssetUrl(environmentId, stableResource)
+ .then((result) => {
+ if (cancelled) return;
+ setUrl(result.url);
+ refreshTimer = setTimeout(
+ load,
+ Math.max(0, result.expiresAt - Date.now() - REFRESH_MARGIN_MS),
+ );
+ })
+ .catch(() => {
+ if (!cancelled) setUrl(null);
+ });
+ };
+ load();
+
+ return () => {
+ cancelled = true;
+ if (refreshTimer) clearTimeout(refreshTimer);
+ };
+ }, [environmentId, key, stableResource]);
+
+ return url;
+}
diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6fcc8ec9954e2ab7a4afcc6c9ef532887c4e4ed9
--- /dev/null
+++ b/apps/web/src/browser/openFileInPreview.ts
@@ -0,0 +1,36 @@
+import type { ScopedThreadRef } from "@t3tools/contracts";
+
+import { readEnvironmentApi } from "~/environmentApi";
+import { resolveAssetUrl } from "~/assets/assetUrls";
+import { isPreviewSupportedInRuntime, usePreviewStateStore } from "~/previewStateStore";
+import { useRightPanelStore } from "~/rightPanelStore";
+
+export const isBrowserPreviewFile = (path: string): boolean =>
+ /\.(?:html?|pdf)$/i.test(path.split(/[?#]/, 1)[0] ?? "");
+
+export async function openUrlInPreview(threadRef: ScopedThreadRef, url: string): Promise {
+ const api = readEnvironmentApi(threadRef.environmentId);
+ if (!api) {
+ throw new Error("Environment is not connected.");
+ }
+
+ const snapshot = await api.preview.open({ threadId: threadRef.threadId, url });
+ usePreviewStateStore.getState().applyServerSnapshot(threadRef, snapshot);
+ usePreviewStateStore.getState().rememberUrl(threadRef, url);
+ useRightPanelStore.getState().openBrowser(threadRef, snapshot.tabId);
+}
+
+export async function openFileInPreview(
+ threadRef: ScopedThreadRef,
+ filePath: string,
+): Promise {
+ if (!isPreviewSupportedInRuntime()) {
+ throw new Error("The integrated browser is unavailable in this runtime.");
+ }
+ const asset = await resolveAssetUrl(threadRef.environmentId, {
+ _tag: "workspace-file",
+ threadId: threadRef.threadId,
+ path: filePath,
+ });
+ await openUrlInPreview(threadRef, asset.url);
+}
diff --git a/apps/web/src/components/ChatMarkdown.browser.tsx b/apps/web/src/components/ChatMarkdown.browser.tsx
index e047392c12d7041727b15d73eaacf30e029e291d..a93a6d231fd868b147a81737355aea366475a2dc 100644
--- a/apps/web/src/components/ChatMarkdown.browser.tsx
+++ b/apps/web/src/components/ChatMarkdown.browser.tsx
@@ -4,11 +4,24 @@ import { page } from "vite-plus/test/browser";
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
import { render } from "vitest-browser-react";
-const { openInPreferredEditorMock, readLocalApiMock } = vi.hoisted(() => ({
+const {
+ contextMenuShowMock,
+ openFileInPreviewMock,
+ openInPreferredEditorMock,
+ openUrlInPreviewMock,
+ readLocalApiMock,
+} = vi.hoisted(() => ({
+ contextMenuShowMock: vi.fn(),
+ openFileInPreviewMock: vi.fn(async () => undefined),
openInPreferredEditorMock: vi.fn(async () => "vscode"),
+ openUrlInPreviewMock: vi.fn(async () => undefined),
readLocalApiMock: vi.fn(() => ({
+ contextMenu: { show: contextMenuShowMock },
server: { getConfig: vi.fn(async () => ({ availableEditors: ["vscode"] })) },
- shell: { openInEditor: vi.fn(async () => undefined) },
+ shell: {
+ openExternal: vi.fn(async () => undefined),
+ openInEditor: vi.fn(async () => undefined),
+ },
})),
}));
@@ -23,12 +36,32 @@ vi.mock("../localApi", () => ({
readLocalApi: readLocalApiMock,
}));
+vi.mock("../previewStateStore", async (importOriginal) => ({
+ ...(await importOriginal()),
+ isPreviewSupportedInRuntime: () => true,
+}));
+
+vi.mock("../browser/openFileInPreview", async (importOriginal) => ({
+ ...(await importOriginal()),
+ openFileInPreview: openFileInPreviewMock,
+ openUrlInPreview: openUrlInPreviewMock,
+}));
+
import ChatMarkdown from "./ChatMarkdown";
import { serializeTableElementToCsv, serializeTableElementToMarkdown } from "../markdown-clipboard";
+import { EnvironmentId, ThreadId } from "@t3tools/contracts";
+
+const threadRef = {
+ environmentId: EnvironmentId.make("environment-test"),
+ threadId: ThreadId.make("thread-test"),
+};
describe("ChatMarkdown", () => {
afterEach(() => {
openInPreferredEditorMock.mockClear();
+ openFileInPreviewMock.mockClear();
+ openUrlInPreviewMock.mockClear();
+ contextMenuShowMock.mockReset();
readLocalApiMock.mockClear();
localStorage.clear();
document.body.innerHTML = "";
@@ -155,6 +188,66 @@ describe("ChatMarkdown", () => {
}
});
+ it("opens web links in the integrated browser from the context menu", async () => {
+ contextMenuShowMock.mockResolvedValue("open-in-browser");
+ const screen = await render(
+ ,
+ );
+
+ try {
+ const link = page.getByRole("link", { name: "OpenAI" }).element();
+ link.dispatchEvent(
+ new MouseEvent("contextmenu", {
+ bubbles: true,
+ cancelable: true,
+ clientX: 12,
+ clientY: 24,
+ }),
+ );
+
+ await vi.waitFor(() => {
+ expect(contextMenuShowMock).toHaveBeenCalled();
+ expect(openUrlInPreviewMock).toHaveBeenCalledWith(threadRef, "https://openai.com/docs");
+ });
+ } finally {
+ await screen.unmount();
+ }
+ });
+
+ it("offers integrated browser opening for HTML file links", async () => {
+ contextMenuShowMock.mockResolvedValue("open-in-browser");
+ const filePath = "/repo/project/report.html";
+ const screen = await render(
+ ,
+ );
+
+ try {
+ const link = page.getByRole("link", { name: "report.html" }).element();
+ link.dispatchEvent(
+ new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 4, clientY: 8 }),
+ );
+
+ await vi.waitFor(() => {
+ expect(contextMenuShowMock).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: "open-in-browser",
+ label: "Open in integrated browser",
+ }),
+ ]),
+ { x: 4, y: 8 },
+ );
+ expect(openFileInPreviewMock).toHaveBeenCalledWith(threadRef, filePath);
+ });
+ } finally {
+ await screen.unmount();
+ }
+ });
+
it("keeps a favicon with the leading segment of a wrapping URL", async () => {
const url = "https://github.com/pingdotgg/t3code/pull/3017/changes";
const screen = await render(
diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx
index a9b4ae5372b2b75496fe43a3399edbbda9f3819b..3aba45249fa316298bbe6d8de47ae5e5edabfd97 100644
--- a/apps/web/src/components/ChatMarkdown.tsx
+++ b/apps/web/src/components/ChatMarkdown.tsx
@@ -8,7 +8,7 @@ import {
Minimize2Icon,
WrapTextIcon,
} from "lucide-react";
-import type { ServerProviderSkill } from "@t3tools/contracts";
+import type { ScopedThreadRef, ServerProviderSkill } from "@t3tools/contracts";
import React, {
Children,
Suspense,
@@ -62,6 +62,12 @@ import {
} from "../markdown-links";
import { readLocalApi } from "../localApi";
import { cn } from "../lib/utils";
+import { isPreviewSupportedInRuntime } from "../previewStateStore";
+import {
+ isBrowserPreviewFile,
+ openFileInPreview,
+ openUrlInPreview,
+} from "../browser/openFileInPreview";
class CodeHighlightErrorBoundary extends React.Component<
{ fallback: ReactNode; children: ReactNode },
@@ -87,6 +93,7 @@ class CodeHighlightErrorBoundary extends React.Component<
interface ChatMarkdownProps {
text: string;
cwd: string | undefined;
+ threadRef?: ScopedThreadRef | undefined;
isStreaming?: boolean;
skills?: ReadonlyArray>;
className?: string;
@@ -670,6 +677,7 @@ interface MarkdownFileLinkProps {
label: string;
copyMarkdown: string;
theme: "light" | "dark";
+ threadRef?: ScopedThreadRef | undefined;
className?: string | undefined;
}
@@ -936,6 +944,54 @@ function MarkdownExternalLinkContent({
);
}
+function MarkdownExternalLink({
+ href,
+ threadRef,
+ children,
+ ...props
+}: React.ComponentProps<"a"> & {
+ href: string;
+ threadRef?: ScopedThreadRef | undefined;
+}) {
+ const handleContextMenu = useCallback(
+ async (event: ReactMouseEvent) => {
+ if (!threadRef || !isPreviewSupportedInRuntime()) return;
+ event.preventDefault();
+ event.stopPropagation();
+
+ const api = readLocalApi();
+ if (!api) return;
+ const clicked = await api.contextMenu.show(
+ [
+ { id: "open-in-browser", label: "Open in integrated browser" },
+ { id: "open-external", label: "Open in system browser" },
+ ] as const,
+ { x: event.clientX, y: event.clientY },
+ );
+ if (clicked === "open-in-browser") {
+ void openUrlInPreview(threadRef, href).catch((error) => {
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title: "Unable to open link in browser",
+ description: error instanceof Error ? error.message : "An error occurred.",
+ }),
+ );
+ });
+ } else if (clicked === "open-external") {
+ void api.shell.openExternal(href);
+ }
+ },
+ [href, threadRef],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
const MarkdownFileLink = memo(function MarkdownFileLink({
href,
targetPath,
@@ -944,6 +1000,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
label,
copyMarkdown,
theme,
+ threadRef,
className,
}: MarkdownFileLinkProps) {
const handleOpen = useCallback(() => {
@@ -967,6 +1024,19 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
});
}, [targetPath]);
+ const handleOpenInBrowser = useCallback(() => {
+ if (!threadRef) return;
+ void openFileInPreview(threadRef, iconPath).catch((error) => {
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title: "Unable to open file in browser",
+ description: error instanceof Error ? error.message : "An error occurred.",
+ }),
+ );
+ });
+ }, [iconPath, threadRef]);
+
const handleCopy = useCallback((value: string, title: string) => {
if (typeof window === "undefined" || !navigator.clipboard?.writeText) {
toastManager.add(
@@ -1007,9 +1077,14 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
const api = readLocalApi();
if (!api) return;
+ const canOpenInBrowser =
+ Boolean(threadRef) && isPreviewSupportedInRuntime() && isBrowserPreviewFile(iconPath);
const clicked = await api.contextMenu.show(
[
{ id: "open", label: "Open in editor" },
+ ...(canOpenInBrowser
+ ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const)
+ : []),
{ id: "copy-relative", label: "Copy relative path" },
{ id: "copy-full", label: "Copy full path" },
] as const,
@@ -1020,6 +1095,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
handleOpen();
return;
}
+ if (clicked === "open-in-browser") {
+ handleOpenInBrowser();
+ return;
+ }
if (clicked === "copy-relative") {
handleCopy(displayPath, "Relative path");
return;
@@ -1028,7 +1107,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
handleCopy(targetPath, "Full path");
}
},
- [displayPath, handleCopy, handleOpen, targetPath],
+ [displayPath, handleCopy, handleOpen, handleOpenInBrowser, iconPath, targetPath, threadRef],
);
return (
@@ -1074,6 +1153,8 @@ function areMarkdownFileLinkPropsEqual(
previous.label === next.label &&
previous.copyMarkdown === next.copyMarkdown &&
previous.theme === next.theme &&
+ previous.threadRef?.environmentId === next.threadRef?.environmentId &&
+ previous.threadRef?.threadId === next.threadRef?.threadId &&
previous.className === next.className
);
}
@@ -1081,6 +1162,7 @@ function areMarkdownFileLinkPropsEqual(
function ChatMarkdown({
text,
cwd,
+ threadRef,
isStreaming = false,
skills = EMPTY_MARKDOWN_SKILLS,
className,
@@ -1137,9 +1219,10 @@ function ChatMarkdown({
const isSameDocumentLink = href?.startsWith("#") ?? false;
const onClick = props.onClick;
const link = (
- {
@@ -1156,7 +1239,7 @@ function ChatMarkdown({
) : (
children
)}
-
+
);
if (!faviconHost || !href) {
return link;
@@ -1194,6 +1277,7 @@ function ChatMarkdown({
label={labelParts.join(" · ")}
copyMarkdown={`[${fileLinkMeta.basename}](${normalizedHref})`}
theme={resolvedTheme}
+ threadRef={threadRef}
className={props.className}
/>
);
@@ -1238,6 +1322,7 @@ function ChatMarkdown({
fileLinkParentSuffixByPath,
isStreaming,
markdownFileLinkMetaByHref,
+ threadRef,
resolvedTheme,
skills,
],
diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx
index 249434a60d78b40d25556150bbf809f53bb48b50..9e939e838aeed8088ec91fa6d91d7fbfe52f95bb 100644
--- a/apps/web/src/components/ChatView.browser.tsx
+++ b/apps/web/src/components/ChatView.browser.tsx
@@ -250,6 +250,18 @@ function createMockEnvironmentApi(input: {
filesystem: {
browse: input.browse,
},
+ assets: {
+ createUrl: vi.fn(async ({ resource }) => ({
+ relativeUrl: `/api/assets/test/${encodeURIComponent(
+ resource._tag === "attachment"
+ ? resource.attachmentId
+ : resource._tag === "project-favicon"
+ ? "favicon.svg"
+ : (resource.path.split(/[\\/]/).at(-1) ?? "asset"),
+ )}`,
+ expiresAt: Date.now() + 60_000,
+ })),
+ },
sourceControl: {} as EnvironmentApi["sourceControl"],
vcs: {} as EnvironmentApi["vcs"],
git: {} as EnvironmentApi["git"],
@@ -373,7 +385,6 @@ function createSnapshotForTargetUser(options: {
name: `attachment-${attachmentIndex + 1}.png`,
mimeType: "image/png",
sizeBytes: 128,
- previewUrl: `/attachments/attachment-${attachmentIndex + 1}`,
}))
: undefined;
@@ -1153,14 +1164,13 @@ const worker = setupWorker(
});
}),
...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth),
- http.get("*/attachments/:attachmentId", () =>
+ http.get("*/api/assets/test/:assetName", () =>
HttpResponse.text(ATTACHMENT_SVG, {
headers: {
"Content-Type": "image/svg+xml",
},
}),
),
- http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })),
);
async function nextFrame(): Promise {
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index b2686f59c70e92f7b5a593170e7f2e32beb9eda6..9b99cc8272f8406d10a6b700a586c271e472663f 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -37,6 +37,7 @@ import { useShallow } from "zustand/react/shallow";
import { useVcsStatus } from "~/lib/vcsStatusState";
import { usePrimaryEnvironmentId } from "../environments/primary";
import { readEnvironmentApi } from "../environmentApi";
+import { resolveAssetUrl } from "../assets/assetUrls";
import { isElectron } from "../env";
import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner";
import { readLocalApi } from "../localApi";
@@ -1915,8 +1916,64 @@ export default function ChatView(props: ChatViewProps) {
});
}, []);
const serverMessages = activeThread?.messages;
+ const [attachmentAssetUrlById, setAttachmentAssetUrlById] = useState>({});
useEffect(() => {
- if (typeof Image === "undefined" || !serverMessages || serverMessages.length === 0) {
+ if (!serverMessages) return;
+ const attachmentIds = [
+ ...new Set(
+ serverMessages.flatMap(
+ (message) =>
+ message.attachments?.flatMap((attachment) =>
+ attachment.type === "image" && !attachment.previewUrl ? [attachment.id] : [],
+ ) ?? [],
+ ),
+ ),
+ ].filter((attachmentId) => !attachmentAssetUrlById[attachmentId]);
+ if (attachmentIds.length === 0) return;
+
+ let cancelled = false;
+ void Promise.all(
+ attachmentIds.map(async (attachmentId) => {
+ const asset = await resolveAssetUrl(environmentId, {
+ _tag: "attachment",
+ attachmentId,
+ });
+ return [attachmentId, asset.url] as const;
+ }),
+ )
+ .then((entries) => {
+ if (!cancelled) {
+ setAttachmentAssetUrlById((current) => ({ ...current, ...Object.fromEntries(entries) }));
+ }
+ })
+ .catch(() => undefined);
+
+ return () => {
+ cancelled = true;
+ };
+ }, [attachmentAssetUrlById, environmentId, serverMessages]);
+ const serverMessagesWithAssetUrls = useMemo(() => {
+ if (!serverMessages || Object.keys(attachmentAssetUrlById).length === 0) {
+ return serverMessages;
+ }
+ return serverMessages.map((message) => {
+ if (!message.attachments) return message;
+ let changed = false;
+ const attachments = message.attachments.map((attachment) => {
+ const previewUrl = attachmentAssetUrlById[attachment.id];
+ if (!previewUrl || attachment.previewUrl === previewUrl) return attachment;
+ changed = true;
+ return { ...attachment, previewUrl };
+ });
+ return changed ? { ...message, attachments } : message;
+ });
+ }, [attachmentAssetUrlById, serverMessages]);
+ useEffect(() => {
+ if (
+ typeof Image === "undefined" ||
+ !serverMessagesWithAssetUrls ||
+ serverMessagesWithAssetUrls.length === 0
+ ) {
return;
}
@@ -1929,7 +1986,7 @@ export default function ChatView(props: ChatViewProps) {
continue;
}
- const serverMessage = serverMessages.find(
+ const serverMessage = serverMessagesWithAssetUrls.find(
(message) => message.id === messageId && message.role === "user",
);
if (!serverMessage?.attachments || serverMessage.attachments.length === 0) {
@@ -1995,9 +2052,13 @@ export default function ChatView(props: ChatViewProps) {
cleanup();
}
};
- }, [attachmentPreviewHandoffByMessageId, clearAttachmentPreviewHandoff, serverMessages]);
+ }, [
+ attachmentPreviewHandoffByMessageId,
+ clearAttachmentPreviewHandoff,
+ serverMessagesWithAssetUrls,
+ ]);
const timelineMessages = useMemo(() => {
- const messages = serverMessages ?? [];
+ const messages = serverMessagesWithAssetUrls ?? [];
const serverMessagesWithPreviewHandoff =
Object.keys(attachmentPreviewHandoffByMessageId).length === 0
? messages
@@ -2048,7 +2109,7 @@ export default function ChatView(props: ChatViewProps) {
return serverMessagesWithPreviewHandoff;
}
return [...serverMessagesWithPreviewHandoff, ...pendingMessages];
- }, [serverMessages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]);
+ }, [serverMessagesWithAssetUrls, attachmentPreviewHandoffByMessageId, optimisticUserMessages]);
const timelineEntries = useMemo(
() =>
deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries),
@@ -4760,6 +4821,7 @@ export default function ChatView(props: ChatViewProps) {
activeProposedPlan={sidebarProposedPlan}
label={planSidebarLabel}
environmentId={environmentId}
+ threadRef={activeThreadRef}
markdownCwd={gitCwd ?? undefined}
workspaceRoot={activeWorkspaceRoot}
timestampFormat={timestampFormat}
diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx
index b7aa6d7a645b11e48b9dc552908b3e20312233d4..01f501b0a1b62a603f8a28e6830b1dd2f0596246 100644
--- a/apps/web/src/components/KeybindingsToast.browser.tsx
+++ b/apps/web/src/components/KeybindingsToast.browser.tsx
@@ -322,8 +322,7 @@ const worker = setupWorker(
});
}),
...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth),
- http.get("*/attachments/:attachmentId", () => new HttpResponse(null, { status: 204 })),
- http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })),
+ http.get("*/api/assets/*", () => new HttpResponse(null, { status: 204 })),
);
function sendServerConfigUpdatedPush(issues: ServerConfig["issues"]) {
diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx
index e87361c92d83c45127a3bb9fc4de577a29f4d94a..498f1912c71f16240365e1efa4a326f2d4ea4f7c 100644
--- a/apps/web/src/components/PlanSidebar.tsx
+++ b/apps/web/src/components/PlanSidebar.tsx
@@ -1,5 +1,5 @@
import { memo, useState, useCallback } from "react";
-import type { EnvironmentId } from "@t3tools/contracts";
+import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
import { type TimestampFormat } from "@t3tools/contracts/settings";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
@@ -56,6 +56,7 @@ interface PlanSidebarProps {
activeProposedPlan: LatestProposedPlanState | null;
label?: string;
environmentId: EnvironmentId;
+ threadRef?: ScopedThreadRef | undefined;
markdownCwd: string | undefined;
workspaceRoot: string | undefined;
timestampFormat: TimestampFormat;
@@ -68,6 +69,7 @@ const PlanSidebar = memo(function PlanSidebar({
activeProposedPlan,
label = "Plan",
environmentId,
+ threadRef,
markdownCwd,
workspaceRoot,
timestampFormat,
@@ -257,6 +259,7 @@ const PlanSidebar = memo(function PlanSidebar({
diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx
index ad47e01bb11163fe3282958aee9c49939b5aab91..0b849bc563ec1dc12003792b4f6751fa0b8d75cd 100644
--- a/apps/web/src/components/ProjectFavicon.tsx
+++ b/apps/web/src/components/ProjectFavicon.tsx
@@ -1,7 +1,7 @@
import type { EnvironmentId } from "@t3tools/contracts";
import { FolderIcon } from "lucide-react";
-import { useState } from "react";
-import { resolveEnvironmentHttpUrl } from "../environments/runtime";
+import { useEffect, useState } from "react";
+import { useAssetUrl } from "../assets/assetUrls";
const loadedProjectFaviconSrcs = new Set();
@@ -10,20 +10,16 @@ export function ProjectFavicon(input: {
cwd: string;
className?: string;
}) {
- const src = (() => {
- try {
- return resolveEnvironmentHttpUrl({
- environmentId: input.environmentId,
- pathname: "/api/project-favicon",
- searchParams: { cwd: input.cwd },
- });
- } catch {
- return null;
- }
- })();
+ const src = useAssetUrl(input.environmentId, {
+ _tag: "project-favicon",
+ cwd: input.cwd,
+ });
const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>
src && loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading",
);
+ useEffect(() => {
+ setStatus(src && loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading");
+ }, [src]);
if (!src) {
return (
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx
index a56330c592360a726a6d05bfd420dda5a3a8a0a2..216c7ca2a4dd86e207c7c3aab106bb704529366d 100644
--- a/apps/web/src/components/chat/MessagesTimeline.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.tsx
@@ -1,9 +1,11 @@
import {
type EnvironmentId,
type MessageId,
+ type ScopedThreadRef,
type ServerProviderSkill,
type TurnId,
} from "@t3tools/contracts";
+import { parseScopedThreadKey } from "@t3tools/client-runtime";
import {
createContext,
Fragment,
@@ -109,6 +111,7 @@ import {
interface TimelineRowSharedState {
timestampFormat: TimestampFormat;
routeThreadKey: string;
+ threadRef: ScopedThreadRef | null;
markdownCwd: string | undefined;
resolvedTheme: "light" | "dark";
workspaceRoot: string | undefined;
@@ -305,6 +308,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
() => ({
timestampFormat,
routeThreadKey,
+ threadRef: parseScopedThreadKey(routeThreadKey),
markdownCwd,
resolvedTheme,
workspaceRoot,
@@ -562,6 +566,7 @@ function AssistantTimelineRow({ row }: { row: Extract
@@ -625,6 +630,7 @@ function ProposedPlanTimelineRow({
@@ -1028,6 +1034,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
skills: ReadonlyArray>;
markdownCwd: string | undefined;
}) {
+ const ctx = use(TimelineRowCtx);
const renderInlineMarkdownSegment = (text: string, key: string) => {
const leadingWhitespace = /^\s+/.exec(text)?.[0] ?? "";
const textWithoutLeadingWhitespace = text.slice(leadingWhitespace.length);
@@ -1044,6 +1051,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
{canCollapse && !expanded ? (
-
+
) : (
-
+
)}
{canCollapse && !expanded ? (
diff --git a/apps/web/src/components/preview/PreviewChromeRow.browser.tsx b/apps/web/src/components/preview/PreviewChromeRow.browser.tsx
index f9a0e16e378587aeb540429a3a1e45c24c526662..d92839b55fa4d756274e0ec71fce8954042d0b92 100644
--- a/apps/web/src/components/preview/PreviewChromeRow.browser.tsx
+++ b/apps/web/src/components/preview/PreviewChromeRow.browser.tsx
@@ -38,4 +38,38 @@ describe("PreviewChromeRow", () => {
previouslyFocused.remove();
});
+
+ it("shows a friendly asset label until the URL input receives focus", async () => {
+ const fullUrl = "http://127.0.0.1:3773/api/assets/token/report.pdf";
+ await render(
+
,
+ );
+ const input = page.getByRole("textbox");
+
+ await expect.element(input).toHaveValue("Local environment · report.pdf");
+
+ await input.click();
+
+ await expect.element(input).toHaveValue(fullUrl);
+
+ input.element().dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
+
+ await expect.element(input).toHaveValue("Local environment · report.pdf");
+ });
+
+ it("shows only the host for regular URLs until the input receives focus", async () => {
+ const fullUrl = "https://t3.chat/chat/18378834-f776-4507-ada7-6f79";
+ await render(
);
+ const input = page.getByRole("textbox");
+
+ await expect.element(input).toHaveValue("t3.chat");
+
+ await input.click();
+
+ await expect.element(input).toHaveValue(fullUrl);
+ });
});
diff --git a/apps/web/src/components/preview/PreviewChromeRow.tsx b/apps/web/src/components/preview/PreviewChromeRow.tsx
index 9064dc71d2ae44391e12ac89087a92c59ea2a7bd..1452e6c758854707ebb5d13c09281cb06626d5cf 100644
--- a/apps/web/src/components/preview/PreviewChromeRow.tsx
+++ b/apps/web/src/components/preview/PreviewChromeRow.tsx
@@ -3,7 +3,6 @@ import {
ArrowRight,
Camera,
ExternalLink,
- Globe,
MousePointerClick,
RotateCw,
} from "lucide-react";
@@ -23,6 +22,7 @@ import { cn } from "~/lib/utils";
interface Props {
url: string;
+ displayUrl?: string | undefined;
loading: boolean;
loadProgress: number;
canGoBack: boolean;
@@ -61,6 +61,7 @@ const NOOP = () => {};
export function PreviewChromeRow({
url,
+ displayUrl,
loading,
loadProgress,
canGoBack,
@@ -84,6 +85,7 @@ export function PreviewChromeRow({
}: Props) {
const inputRef = useRef
(null);
const [draft, setDraft] = useState(url);
+ const [inputFocused, setInputFocused] = useState(false);
// Sync the input with external URL changes, but only when the user isn't
// actively typing (preserves in-progress edits during navigation events).
@@ -96,7 +98,6 @@ export function PreviewChromeRow({
const node = inputRef.current;
if (!node) return;
node.focus();
- node.select();
}, [focusUrlNonce]);
const submit = (event?: FormEvent | KeyboardEvent) => {
@@ -167,14 +168,23 @@ export function PreviewChromeRow({
-
-
-
-
+
setDraft(event.target.value)}
+ onFocus={() => {
+ setDraft(url);
+ setInputFocused(true);
+ queueMicrotask(() => inputRef.current?.select());
+ }}
+ onBlur={() => {
+ setDraft(url);
+ setInputFocused(false);
+ }}
onKeyDown={(event) => {
if (event.key === "Enter") submit(event);
if (event.key === "Escape") {
@@ -187,10 +197,14 @@ export function PreviewChromeRow({
spellCheck={false}
disabled={inputDisabled}
data-preview-url-input
+ title={!inputFocused && displayUrl ? url : undefined}
size="sm"
/>
- {onOpenInBrowser ? (
-
+ {onOpenInBrowser && !inputFocused ? (
+
{
@@ -501,6 +512,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
>
{
+ it("formats signed asset URLs with the environment label and decoded filename", () => {
+ expect(
+ formatPreviewUrl({
+ url: "http://127.0.0.1:3773/api/assets/token/architecture%20brief.pdf",
+ environmentLabel: "Local environment",
+ environmentHttpBaseUrl: "http://127.0.0.1:3773",
+ }),
+ ).toBe("Local environment · architecture brief.pdf");
+ });
+
+ it("does not alias assets from another origin", () => {
+ expect(
+ formatPreviewUrl({
+ url: "https://example.com/api/assets/token/report.pdf",
+ environmentLabel: "Local environment",
+ environmentHttpBaseUrl: "http://127.0.0.1:3773",
+ }),
+ ).toBe("example.com");
+ });
+
+ it("formats regular preview URLs as their exact host", () => {
+ expect(
+ formatPreviewUrl({
+ url: "http://127.0.0.1:5173/dashboard",
+ environmentLabel: "Local environment",
+ environmentHttpBaseUrl: "http://127.0.0.1:3773",
+ }),
+ ).toBe("127.0.0.1:5173");
+ });
+
+ it("does not compact non-http URLs", () => {
+ expect(
+ formatPreviewUrl({
+ url: "file:///tmp/report.pdf",
+ environmentLabel: "Local environment",
+ environmentHttpBaseUrl: "http://127.0.0.1:3773",
+ }),
+ ).toBeNull();
+ });
+});
diff --git a/apps/web/src/components/preview/previewUrlPresentation.ts b/apps/web/src/components/preview/previewUrlPresentation.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0ae3c1900aacd6fee6181770c14d4f4da0a89649
--- /dev/null
+++ b/apps/web/src/components/preview/previewUrlPresentation.ts
@@ -0,0 +1,27 @@
+interface PreviewUrlPresentationInput {
+ readonly url: string;
+ readonly environmentLabel: string;
+ readonly environmentHttpBaseUrl: string;
+}
+
+export function formatPreviewUrl(input: PreviewUrlPresentationInput): string | null {
+ try {
+ const url = new URL(input.url);
+ const environmentUrl = new URL(input.environmentHttpBaseUrl);
+ if (url.origin === environmentUrl.origin && url.pathname.startsWith("/api/assets/")) {
+ const encodedFileName = url.pathname.split("/").at(-1);
+ if (!encodedFileName) {
+ return null;
+ }
+ const fileName = decodeURIComponent(encodedFileName);
+ if (!fileName || fileName === "." || fileName === "..") {
+ return null;
+ }
+ return `${input.environmentLabel} · ${fileName}`;
+ }
+
+ return url.protocol === "http:" || url.protocol === "https:" ? url.host : null;
+ } catch {
+ return null;
+ }
+}
diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts
index 4bb3c3654cb639b2dd60a27ba481da89c00c788e..49de33091a2ee29bb0010bcc79fd129e8bf3d550 100644
--- a/apps/web/src/environmentApi.ts
+++ b/apps/web/src/environmentApi.ts
@@ -25,6 +25,9 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi {
filesystem: {
browse: rpcClient.filesystem.browse,
},
+ assets: {
+ createUrl: rpcClient.assets.createUrl,
+ },
sourceControl: {
lookupRepository: rpcClient.sourceControl.lookupRepository,
cloneRepository: rpcClient.sourceControl.cloneRepository,
diff --git a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts
index 94292da1d96904dda5bc79deefefde51f2708f55..2f223c4b1b4c771987d1b01d1bac9e47ce837f8c 100644
--- a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts
+++ b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts
@@ -127,6 +127,7 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => {
filesystem: {
browse: vi.fn(),
},
+ assets: { createUrl: vi.fn() },
sourceControl: {
lookupRepository: vi.fn(),
cloneRepository: vi.fn(),
diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts
index 12b621e3900fffa66e39b789fd5815a174a914aa..9a9b05f92f2a794536e88aeb72ee1e7accd5ee7a 100644
--- a/apps/web/src/store.ts
+++ b/apps/web/src/store.ts
@@ -34,7 +34,6 @@ import {
type ThreadTurnState,
type TurnDiffSummary,
} from "./types";
-import { resolveEnvironmentHttpUrl } from "./environments/runtime";
import { sanitizeThreadErrorMessage } from "./rpc/transportError";
import { getThreadFromEnvironmentState } from "./threadDerivation";
const isProviderDriverKindValue = Schema.is(ProviderDriverKind);
@@ -173,10 +172,6 @@ function mapMessage(environmentId: EnvironmentId, message: OrchestrationMessage)
name: attachment.name,
mimeType: attachment.mimeType,
sizeBytes: attachment.sizeBytes,
- previewUrl: resolveEnvironmentHttpUrl({
- environmentId,
- pathname: attachmentPreviewRoutePath(attachment.id),
- }),
}));
return {
@@ -1042,10 +1037,6 @@ function toLegacyProvider(providerName: string | null): ProviderDriverKind {
return ProviderDriverKind.make("codex");
}
-function attachmentPreviewRoutePath(attachmentId: string): string {
- return `/attachments/${encodeURIComponent(attachmentId)}`;
-}
-
function updateThreadState(
state: EnvironmentState,
threadId: ThreadId,
diff --git a/docs/cloud/t3-code-connect-auth-flow-3-page.html b/docs/cloud/t3-code-connect-auth-flow-3-page.html
new file mode 100644
index 0000000000000000000000000000000000000000..9fe7bd59081374c542990a8436cb0869c7168d7c
--- /dev/null
+++ b/docs/cloud/t3-code-connect-auth-flow-3-page.html
@@ -0,0 +1,592 @@
+
+
+
+
+
+ T3 Connect Architecture Brief
+
+
+
+
+ Architecture brief · page 1 of 3
+ T3 Connect Control Plane and Managed Endpoint Flow
+
+ T3 Connect links a locally authorized T3 environment to a signed-in cloud user, provisions a
+ managed HTTPS/WSS endpoint, and brokers proof-bound remote connections. The environment
+ remains the access authority; the relay coordinates identity, endpoint lifecycle, and
+ notifications.
+
+
+
+
+
+ Security Invariant
+ Cloud identity alone is not an environment login. Remote access requires:
+
+ a Clerk-authenticated T3 Connect user;
+ an active user/environment relay link;
+ a DPoP private key held by the remote client;
+ a relay-signed mint request accepted locally; and
+ an environment token bound to the client's DPoP key.
+
+
+ The relay cannot mint an environment access token by itself. The local environment
+ issues the bootstrap credential and final access token.
+
+
+
+ Trust Boundaries
+
+ Clerk: user identity and OAuth/PKCE.
+ Relay Worker: links, endpoint allocations, proofs, and notifications.
+ Environment: local sessions, signing keys, and access tokens.
+ Relay client: exposes only the validated loopback HTTP server.
+ Remote client: retains the DPoP private key and environment token.
+
+
+ The relay remains a trusted mint broker because it holds the cloud-mint signing key.
+ DPoP limits credential reuse but does not neutralize compromise of that authority.
+
+
+
+
+ Current Topology
+
+
+
Clients
+ Desktop web UI Mobile app Headless CLI
+ Clerk bearer / relay DPoP
+
+
→
+
+
T3 Code Relay
+ Cloudflare Worker Hyperdrive + Postgres Tunnel/DNS APIs APNs queue
+
+
→
+
+
Linked Environment
+ Managed HTTPS/WSS endpoint Relay client Loopback server Local secrets
+
+
+
+ Steady-state HTTP and WebSocket traffic goes directly from the remote client through the
+ managed endpoint. The relay handles sparse health and bootstrap requests, not the active
+ session data path.
+
+
+
+
+ Authentication Boundaries
+
+
+
+ Boundary
+ Authentication
+ Purpose
+
+
+
+
+ Client → relay link management
+ Clerk bearer / CLI OAuth
+ Create, list, or remove links.
+
+
+ Client → protected relay APIs
+ Relay DPoP token + proof
+ Status, connect, and mobile registration.
+
+
+ Relay → environment
+ Short-lived relay-signed JWT
+ Signed health and credential-mint requests.
+
+
+ Client → environment
+ Environment token + DPoP
+ Normal HTTPS APIs and WSS ticket issuance.
+
+
+ Environment → relay
+ Environment bearer + signed proof
+ Publish redacted agent activity.
+
+
+
+
+
+
+
+
+
+ Core workflows · page 2 of 3
+ Link, Bootstrap, and Operate
+
+ Every workflow preserves local authority while making cloud coordination retry-safe and
+ suitable for desktop, mobile, and headless use.
+
+
+
+
+
+ 1. Link a Local Environment
+
+
+ Authenticate Desktop/mobile uses a Clerk bearer. CLI uses browser PKCE and
+ persists desired link state.
+
+
+ Prove locally Relay issues a short-lived challenge. The local environment
+ validates loopback origin and signs it.
+
+
+ Reconcile Relay verifies proof and capabilities, then creates or reuses the
+ tunnel, ingress, and CNAME.
+
+
+ Activate Relay returns endpoint config and connector token. Environment persists
+ config and starts the relay client.
+
+
+
+ The local proof endpoint requires relay:write, rejects forwarded authority
+ headers, and atomically persists the environment key pair.
+
+
+
+
+ 2. Remote Connection Bootstrap
+
+
+ Proof key Client creates a DPoP key and exchanges its Clerk JWT for a relay DPoP
+ access token.
+
+
+ Mint request Client calls connect. Relay sends an audience-bound, short-lived
+ signed proof to the managed endpoint.
+
+
+ Local issue Environment validates user, scope, lifetime, nonce, and DPoP
+ thumbprint, then returns a signed bootstrap credential.
+
+
+ Direct session Client redeems locally for an environment token, gets a one-time
+ WSS ticket, and opens the WebSocket.
+
+
+
+ The relay validates the environment response signature, nonce, endpoint, and DPoP
+ binding before returning bootstrap data to the client.
+
+
+
+
+ Managed Endpoint Lifecycle
+
+
+ Reserve deterministic hostname and tunnel name for
+ (userId, environmentId).
+
+
+ Reuse or create the named tunnel; rewrite ingress to the validated loopback origin.
+
+
+ Reconcile CNAME records and remove duplicates; recover safely from create conflicts.
+
+ Mark ready only after resource IDs and namespace checks are persisted.
+
+
+
+ Unlink Cleanup
+
+ Delete managed DNS and tunnel resources.
+ Remove the allocation and revoke the user link.
+ Revoke publication credentials after the final active link disappears.
+ Stop the local relay client and clear persisted relay configuration.
+ Retain checkpoints when teardown fails so cleanup can be retried.
+
+
+
+
+ 3. Agent Activity Notifications
+
+
+ Project Environment creates a narrow publishable thread state and redacts/caps
+ failure details.
+
+
+ Sign Environment posts with its bearer credential and a per-state signed proof.
+
+
+ Persist Relay checks credential, signature, scope, expiry, and nonce, then
+ stores current state.
+
+
+ Deliver Queue workers send push notifications or Live Activity updates through
+ APNs.
+
+
+
+
+
+
+
+
+ Controls and operations · page 3 of 3
+ Hardening and Deployment Contract
+
+ The design constrains tunnel origins, proof audiences, egress destinations, redirects,
+ credential exposure, and teardown behavior.
+
+
+
+
+
+ SSRF and Tunnel Controls
+
+
+
+ Risk
+ Implemented control
+
+
+
+
+ Arbitrary tunnel upstream
+ Both sides accept managed origins only for loopback hosts and valid ports.
+
+
+ Forwarded-origin confusion
+
+ Link proof requires the exact loopback URL and rejects forwarded host/protocol
+ headers.
+
+
+
+ Stored endpoint replacement
+
+ Status/connect resolve ready allocations from Postgres and require the managed DNS
+ namespace.
+
+
+
+ Redirected relay egress
+ Relay health and mint requests use manual redirect handling.
+
+
+ Impersonated tunnel backend
+ Relay verifies signed environment responses, nonce, and request binding.
+
+
+ Stolen access token replay
+ Relay and environment tokens require DPoP proofs and replay guards.
+
+
+ Connector token exposure
+
+ Relay client is spawned without a shell and receives the token in
+ TUNNEL_TOKEN.
+
+
+
+
+
+
+
+ Credential Ownership
+
+ Environment Ed25519 private key stays local.
+
+ Cloud-mint private key stays in hosted relay config; only its public key is installed
+ locally.
+
+
+ Relay environment bearer is hashed in Postgres and stored plaintext only locally.
+
+ Remote DPoP private keys and environment access tokens remain client-side.
+ WebSocket tickets are short-lived and one-time.
+
+
+
+ Operational Profile
+
+ One named tunnel and DNS record per active user/environment allocation.
+ HTTPS request/response and WSS only; no arbitrary TCP exposure.
+ Relay traffic is limited to health and bootstrap control calls.
+
+ Active sessions are interactive and WebSocket-heavy; inactive links are nearly idle.
+
+
+ Dedicated registrable tunnel domain and Public Suffix List entry are required before
+ broad untrusted use.
+
+
+
+
+
+ Deployment and Runtime
+
+
+
+ Layer
+ Contract
+
+
+
+
+ Relay
+
+ Cloudflare Worker, tunnel/DNS bindings, Hyperdrive, PlanetScale Postgres, APNs
+ queue + DLQ, replay-row pruning cron, and Axiom OTLP traces.
+
+
+
+ Environment
+
+ Loopback server, atomic secret store, relay-client lifecycle, signed health/mint
+ responses, OAuth exchange, and WebSocket tickets.
+
+
+
+ Clients
+
+ Secure relay URL, Clerk configuration, DPoP key management, environment token
+ storage, and direct managed-endpoint traffic.
+
+
+
+
+
+
+
+ Release Gate
+
+
+
Identity
+ Keep private signing keys in their owning trust domain. Validate issuer, audience,
+ scope, expiry, nonce, and proof-key binding.
+
+
+
Network
+ Permit loopback tunnel origins only. Resolve relay egress from ready allocations and
+ disable redirects.
+
+
+
Lifecycle
+ Make provisioning idempotent, preserve failed-cleanup checkpoints, and revoke shared
+ credentials only after the final link disappears.
+
+
+
+ Standards basis: RFC 8252 for native-app OAuth, RFC 8693 for token exchange, and RFC
+ 9449 for DPoP proof of possession.
+
+
+
+
+
+
+
diff --git a/docs/cloud/t3-code-connect-auth-flow.pdf b/docs/cloud/t3-code-connect-auth-flow.pdf
new file mode 100644
index 0000000000000000000000000000000000000000..42c604f29ce1564cdee6b1bdef626e0517d6bf44
Binary files /dev/null and b/docs/cloud/t3-code-connect-auth-flow.pdf differ
diff --git a/packages/client-runtime/src/wsRpcClient.ts b/packages/client-runtime/src/wsRpcClient.ts
index ca01302cbe3759b1bc0e35a122bb09c542a90e5e..3e4e9f343ceb80c500ed685a8af3b9ab24f66c33 100644
--- a/packages/client-runtime/src/wsRpcClient.ts
+++ b/packages/client-runtime/src/wsRpcClient.ts
@@ -102,6 +102,9 @@ export interface WsRpcClient {
readonly filesystem: {
readonly browse: RpcUnaryMethod;
};
+ readonly assets: {
+ readonly createUrl: RpcUnaryMethod;
+ };
readonly sourceControl: {
readonly lookupRepository: RpcUnaryMethod;
readonly cloneRepository: RpcUnaryMethod;
@@ -272,6 +275,10 @@ export function createWsRpcClient(
filesystem: {
browse: (input) => transport.request((client) => client[WS_METHODS.filesystemBrowse](input)),
},
+ assets: {
+ createUrl: (input) =>
+ transport.request((client) => client[WS_METHODS.assetsCreateUrl](input)),
+ },
sourceControl: {
lookupRepository: (input) =>
transport.request((client) => client[WS_METHODS.sourceControlLookupRepository](input)),
diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts
new file mode 100644
index 0000000000000000000000000000000000000000..bd1ac0a53ec6d805d2226ca8882c0b84d8a17ba3
--- /dev/null
+++ b/packages/contracts/src/assets.ts
@@ -0,0 +1,38 @@
+import * as Schema from "effect/Schema";
+
+import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
+
+const ASSET_PATH_MAX_LENGTH = 1024;
+
+export const AssetResource = Schema.Union([
+ Schema.TaggedStruct("workspace-file", {
+ threadId: ThreadId,
+ path: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
+ }),
+ Schema.TaggedStruct("attachment", {
+ attachmentId: TrimmedNonEmptyString.check(Schema.isMaxLength(256)),
+ }),
+ Schema.TaggedStruct("project-favicon", {
+ cwd: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
+ }),
+]);
+export type AssetResource = typeof AssetResource.Type;
+
+export const AssetCreateUrlInput = Schema.Struct({
+ resource: AssetResource,
+});
+export type AssetCreateUrlInput = typeof AssetCreateUrlInput.Type;
+
+export const AssetCreateUrlResult = Schema.Struct({
+ relativeUrl: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)),
+ expiresAt: Schema.Number,
+});
+export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type;
+
+export class AssetAccessError extends Schema.TaggedErrorClass()(
+ "AssetAccessError",
+ {
+ message: TrimmedNonEmptyString,
+ cause: Schema.optional(Schema.Defect()),
+ },
+) {}
diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts
index 4ffb7642d17401ce36a54d2865148952c8e6f95e..43270efdec786152b221a0a1343b9b1815e2edee 100644
--- a/packages/contracts/src/index.ts
+++ b/packages/contracts/src/index.ts
@@ -21,6 +21,7 @@ export * from "./orchestration.ts";
export * from "./editor.ts";
export * from "./project.ts";
export * from "./filesystem.ts";
+export * from "./assets.ts";
export * from "./review.ts";
export * from "./preview.ts";
export * from "./previewAutomation.ts";
diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts
index 463879e9a5e19f48943fc3418d15289cb4370bd6..92f669c44f043471456c9de94002aa1f8bd19ed1 100644
--- a/packages/contracts/src/ipc.ts
+++ b/packages/contracts/src/ipc.ts
@@ -20,6 +20,7 @@ import type {
} from "./git.ts";
import type { ReviewDiffPreviewInput, ReviewDiffPreviewResult } from "./review.ts";
import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts";
+import type { AssetCreateUrlInput, AssetCreateUrlResult } from "./assets.ts";
import type {
ProjectSearchEntriesInput,
ProjectSearchEntriesResult,
@@ -1115,6 +1116,9 @@ export interface EnvironmentApi {
filesystem: {
browse: (input: FilesystemBrowseInput) => Promise;
};
+ assets: {
+ createUrl: (input: AssetCreateUrlInput) => Promise;
+ };
sourceControl: {
lookupRepository: (
input: SourceControlRepositoryLookupInput,
diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts
index 189b1bdb6796e35fb2c37fd8f25cfc8d2f7e4f0a..b1dc36651e3ffaaf46070ce52ceb9bd90b5e7585 100644
--- a/packages/contracts/src/rpc.ts
+++ b/packages/contracts/src/rpc.ts
@@ -13,6 +13,7 @@ import {
FilesystemBrowseResult,
FilesystemBrowseError,
} from "./filesystem.ts";
+import { AssetAccessError, AssetCreateUrlInput, AssetCreateUrlResult } from "./assets.ts";
import {
GitActionProgressEvent,
VcsSwitchRefInput,
@@ -148,6 +149,7 @@ export const WS_METHODS = {
// Filesystem methods
filesystemBrowse: "filesystem.browse",
+ assetsCreateUrl: "assets.createUrl",
// VCS methods
vcsPull: "vcs.pull",
@@ -365,6 +367,12 @@ export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, {
error: Schema.Union([FilesystemBrowseError, EnvironmentAuthorizationError]),
});
+export const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, {
+ payload: AssetCreateUrlInput,
+ success: AssetCreateUrlResult,
+ error: Schema.Union([AssetAccessError, EnvironmentAuthorizationError]),
+});
+
export const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, {
payload: VcsStatusInput,
success: VcsStatusStreamEvent,
@@ -672,6 +680,7 @@ export const WsRpcGroup = RpcGroup.make(
WsProjectsWriteFileRpc,
WsShellOpenInEditorRpc,
WsFilesystemBrowseRpc,
+ WsAssetsCreateUrlRpc,
WsSubscribeVcsStatusRpc,
WsVcsPullRpc,
WsVcsRefreshStatusRpc,