authorgravatar for 51714798+juliusmarminge@users.noreply.github.comJulius Marminge <51714798+juliusmarminge@users.noreply.github.com> 2026-06-12 19:29:22-07:00
committergravatar for me@theo.liTheo Browne <me@theo.li> 2026-06-13 22:18:37-07:00
log85d5df625652906937d5ac330205b1042dc9354f
tree8db1b0aa166b6edbcdb2650d0686c6e7c425a305
parentb5b769dddfe935650b48473dd5b55f2c099abdd8

Unify browser asset preview routing

- Replace attachment and favicon routes with signed asset URLs - Harden workspace and attachment asset resolution - Update browser preview components and shared contracts

35 files changed, 1779 insertions(+), 328 deletions(-)

apps/server/src/assets/AssetAccess.test.ts created+150
......@@ -0,0 +1,150 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { ThreadId } from "@t3tools/contracts";
3import { describe, expect, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Path from "effect/Path";
8
9import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
10import { ServerConfig } from "../config.ts";
11import { ProjectFaviconResolverLive } from "../project/Layers/ProjectFaviconResolver.ts";
12import { WorkspacePathsLive } from "../workspace/Layers/WorkspacePaths.ts";
13import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts";
14
15const configLayer = ServerConfig.layerTest(process.cwd(), {
16 prefix: "t3-asset-access-test-",
17});
18const testLayer = Layer.mergeAll(
19 configLayer,
20 WorkspacePathsLive,
21 ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive)),
22 ServerSecretStore.layer.pipe(Layer.provide(configLayer)),
23).pipe(Layer.provideMerge(NodeServices.layer));
24
25describe("AssetAccess", () => {
26 it.effect("issues workspace URLs that resolve the entry file and sibling assets", () =>
27 Effect.gen(function* () {
28 const fileSystem = yield* FileSystem.FileSystem;
29 const path = yield* Path.Path;
30 const root = yield* fileSystem.makeTempDirectoryScoped({
31 prefix: "t3-asset-workspace-",
32 });
33 const htmlPath = path.join(root, "report.html");
34 const cssPath = path.join(root, "report.css");
35 yield* fileSystem.writeFileString(htmlPath, '<link rel="stylesheet" href="report.css">');
36 yield* fileSystem.writeFileString(cssPath, "body { color: red; }");
37 yield* fileSystem.writeFileString(path.join(root, ".env"), "SECRET=value");
38
39 const result = yield* issueAssetUrl({
40 resource: {
41 _tag: "workspace-file",
42 threadId: ThreadId.make("thread-1"),
43 path: htmlPath,
44 },
45 workspaceRoot: root,
46 });
47 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
48 const separatorIndex = suffix.indexOf("/");
49 const token = suffix.slice(0, separatorIndex);
50
51 expect(yield* resolveAsset(token, "report.html")).toEqual({
52 kind: "file",
53 path: htmlPath,
54 });
55 expect(yield* resolveAsset(token, "report.css")).toEqual({
56 kind: "file",
57 path: cssPath,
58 });
59 expect(yield* resolveAsset(token, "../secret.txt")).toBeNull();
60 expect(yield* resolveAsset(token, ".env")).toBeNull();
61 expect(yield* resolveAsset(`${token}tampered`, "report.html")).toBeNull();
62 }).pipe(Effect.provide(testLayer)),
63 );
64
65 it.effect("rejects workspace files outside the authorized root", () =>
66 Effect.gen(function* () {
67 const fileSystem = yield* FileSystem.FileSystem;
68 const path = yield* Path.Path;
69 const root = yield* fileSystem.makeTempDirectoryScoped({
70 prefix: "t3-asset-root-",
71 });
72 const outside = yield* fileSystem.makeTempDirectoryScoped({
73 prefix: "t3-asset-outside-",
74 });
75 const htmlPath = path.join(outside, "report.html");
76 yield* fileSystem.writeFileString(htmlPath, "<p>outside</p>");
77
78 const error = yield* issueAssetUrl({
79 resource: {
80 _tag: "workspace-file",
81 threadId: ThreadId.make("thread-1"),
82 path: htmlPath,
83 },
84 workspaceRoot: root,
85 }).pipe(Effect.flip);
86 expect(error.message).toContain("relative to the project root");
87 }).pipe(Effect.provide(testLayer)),
88 );
89
90 it.effect("issues exact attachment capabilities by attachment id", () =>
91 Effect.gen(function* () {
92 const config = yield* ServerConfig;
93 const fileSystem = yield* FileSystem.FileSystem;
94 const path = yield* Path.Path;
95 const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001";
96 const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.png`);
97 yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true });
98 yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3]));
99
100 const result = yield* issueAssetUrl({
101 resource: { _tag: "attachment", attachmentId },
102 });
103 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
104 const separatorIndex = suffix.indexOf("/");
105 const token = suffix.slice(0, separatorIndex);
106
107 expect(yield* resolveAsset(token, "ignored.png")).toEqual({
108 kind: "file",
109 path: attachmentPath,
110 });
111 }).pipe(Effect.provide(testLayer)),
112 );
113
114 it.effect("issues project favicon capabilities with a signed fallback", () =>
115 Effect.gen(function* () {
116 const fileSystem = yield* FileSystem.FileSystem;
117 const path = yield* Path.Path;
118 const root = yield* fileSystem.makeTempDirectoryScoped({
119 prefix: "t3-asset-favicon-",
120 });
121 const faviconPath = path.join(root, "favicon.svg");
122 yield* fileSystem.writeFileString(faviconPath, "<svg />");
123
124 const faviconResult = yield* issueAssetUrl({
125 resource: { _tag: "project-favicon", cwd: root },
126 });
127 const faviconSuffix = faviconResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
128 const faviconSeparatorIndex = faviconSuffix.indexOf("/");
129 expect(
130 yield* resolveAsset(
131 faviconSuffix.slice(0, faviconSeparatorIndex),
132 faviconSuffix.slice(faviconSeparatorIndex + 1),
133 ),
134 ).toEqual({ kind: "file", path: faviconPath });
135
136 yield* fileSystem.remove(faviconPath);
137 const fallbackResult = yield* issueAssetUrl({
138 resource: { _tag: "project-favicon", cwd: root },
139 });
140 const fallbackSuffix = fallbackResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
141 const fallbackSeparatorIndex = fallbackSuffix.indexOf("/");
142 expect(
143 yield* resolveAsset(
144 fallbackSuffix.slice(0, fallbackSeparatorIndex),
145 fallbackSuffix.slice(fallbackSeparatorIndex + 1),
146 ),
147 ).toEqual({ kind: "project-favicon-fallback" });
148 }).pipe(Effect.provide(testLayer)),
149 );
150});
apps/server/src/assets/AssetAccess.ts created+287
......@@ -0,0 +1,287 @@
1import type { AssetResource } from "@t3tools/contracts";
2import { AssetAccessError } from "@t3tools/contracts";
3import * as Clock from "effect/Clock";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Option from "effect/Option";
7import * as Path from "effect/Path";
8import * as Schema from "effect/Schema";
9
10import {
11 base64UrlDecodeUtf8,
12 base64UrlEncode,
13 signPayload,
14 timingSafeEqualBase64Url,
15} from "../auth/utils.ts";
16import { ServerSecretStore } from "../auth/ServerSecretStore.ts";
17import { resolveAttachmentPathById } from "../attachmentStore.ts";
18import { ServerConfig } from "../config.ts";
19import { ProjectFaviconResolver } from "../project/Services/ProjectFaviconResolver.ts";
20import { WorkspacePaths } from "../workspace/Services/WorkspacePaths.ts";
21
22export const ASSET_ROUTE_PREFIX = "/api/assets";
23export const FALLBACK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#6b728080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-fallback="project-favicon"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z"/></svg>`;
24
25const SIGNING_SECRET_NAME = "asset-access-signing-key";
26const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000;
27const PREVIEWABLE_EXTENSIONS = new Set([".htm", ".html", ".pdf"]);
28const PREVIEW_ASSET_EXTENSIONS = new Set([
29 ...PREVIEWABLE_EXTENSIONS,
30 ".avif",
31 ".css",
32 ".gif",
33 ".ico",
34 ".jpeg",
35 ".jpg",
36 ".js",
37 ".mjs",
38 ".otf",
39 ".png",
40 ".svg",
41 ".ttf",
42 ".webp",
43 ".woff",
44 ".woff2",
45]);
46
47const AssetClaimsSchema = Schema.Union([
48 Schema.Struct({
49 version: Schema.Literal(1),
50 kind: Schema.Literal("workspace-file"),
51 workspaceRoot: Schema.String,
52 baseRelativePath: Schema.String,
53 expiresAt: Schema.Number,
54 }),
55 Schema.Struct({
56 version: Schema.Literal(1),
57 kind: Schema.Literal("attachment"),
58 attachmentId: Schema.String,
59 expiresAt: Schema.Number,
60 }),
61 Schema.Struct({
62 version: Schema.Literal(1),
63 kind: Schema.Literal("project-favicon"),
64 workspaceRoot: Schema.String,
65 relativePath: Schema.NullOr(Schema.String),
66 expiresAt: Schema.Number,
67 }),
68]);
69type AssetClaims = typeof AssetClaimsSchema.Type;
70
71const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema);
72const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson);
73const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson);
74
75export type ResolvedAsset =
76 | { readonly kind: "file"; readonly path: string }
77 | { readonly kind: "project-favicon-fallback" };
78
79function decodeClaims(encodedPayload: string): AssetClaims | null {
80 try {
81 return Option.getOrNull(decodeAssetClaims(base64UrlDecodeUtf8(encodedPayload)));
82 } catch {
83 return null;
84 }
85}
86
87function decodeRelativePath(value: string): string | null {
88 try {
89 return decodeURIComponent(value);
90 } catch {
91 return null;
92 }
93}
94
95const failAccess = (message: string, cause?: unknown) =>
96 new AssetAccessError({ message, ...(cause === undefined ? {} : { cause }) });
97
98const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWorkspaceFile")(
99 function* (input: { readonly workspaceRoot: string; readonly relativePath: string }) {
100 const fileSystem = yield* FileSystem.FileSystem;
101 const workspacePaths = yield* WorkspacePaths;
102 const resolved = yield* workspacePaths
103 .resolveRelativePathWithinRoot(input)
104 .pipe(Effect.orElseSucceed(() => null));
105 if (!resolved) return null;
106
107 const [canonicalRoot, canonicalFile] = yield* Effect.all([
108 fileSystem.realPath(input.workspaceRoot).pipe(Effect.orElseSucceed(() => null)),
109 fileSystem.realPath(resolved.absolutePath).pipe(Effect.orElseSucceed(() => null)),
110 ]);
111 if (!canonicalRoot || !canonicalFile) return null;
112
113 const path = yield* Path.Path;
114 const relative = path.relative(canonicalRoot, canonicalFile);
115 if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) return null;
116
117 const info = yield* fileSystem.stat(canonicalFile).pipe(Effect.orElseSucceed(() => null));
118 return info?.type === "File" ? canonicalFile : null;
119 },
120);
121
122export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: {
123 readonly resource: AssetResource;
124 readonly workspaceRoot?: string;
125}) {
126 const fileSystem = yield* FileSystem.FileSystem;
127 const path = yield* Path.Path;
128 const workspacePaths = yield* WorkspacePaths;
129 const expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS;
130 let claims: AssetClaims;
131 let fileName: string;
132
133 switch (input.resource._tag) {
134 case "workspace-file": {
135 if (!input.workspaceRoot) {
136 return yield* failAccess("Workspace context was not found.");
137 }
138 const workspaceRoot = yield* workspacePaths
139 .normalizeWorkspaceRoot(input.workspaceRoot)
140 .pipe(Effect.mapError((cause) => failAccess(cause.message, cause)));
141 const relativePath = path.isAbsolute(input.resource.path)
142 ? path.relative(workspaceRoot, input.resource.path)
143 : input.resource.path;
144 const resolved = yield* workspacePaths
145 .resolveRelativePathWithinRoot({ workspaceRoot, relativePath })
146 .pipe(Effect.mapError((cause) => failAccess(cause.message, cause)));
147 if (!PREVIEWABLE_EXTENSIONS.has(path.extname(resolved.relativePath).toLowerCase())) {
148 return yield* failAccess("Only HTML and PDF files can open in the browser.");
149 }
150 const canonicalFile = yield* resolveCanonicalWorkspaceFile({
151 workspaceRoot,
152 relativePath: resolved.relativePath,
153 });
154 if (!canonicalFile) {
155 return yield* failAccess("Workspace asset was not found.");
156 }
157 claims = {
158 version: 1,
159 kind: "workspace-file",
160 workspaceRoot: yield* fileSystem
161 .realPath(workspaceRoot)
162 .pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause))),
163 baseRelativePath: path.dirname(resolved.relativePath),
164 expiresAt,
165 };
166 fileName = path.basename(resolved.relativePath);
167 break;
168 }
169 case "attachment": {
170 const config = yield* ServerConfig;
171 const attachmentPath = resolveAttachmentPathById({
172 attachmentsDir: config.attachmentsDir,
173 attachmentId: input.resource.attachmentId,
174 });
175 if (!attachmentPath) {
176 return yield* failAccess("Attachment was not found.");
177 }
178 claims = {
179 version: 1,
180 kind: "attachment",
181 attachmentId: input.resource.attachmentId,
182 expiresAt,
183 };
184 fileName = path.basename(attachmentPath);
185 break;
186 }
187 case "project-favicon": {
188 const workspaceRoot = yield* workspacePaths
189 .normalizeWorkspaceRoot(input.resource.cwd)
190 .pipe(Effect.mapError((cause) => failAccess(cause.message, cause)));
191 const faviconResolver = yield* ProjectFaviconResolver;
192 const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot);
193 const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null;
194 if (
195 relativePath &&
196 !(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }))
197 ) {
198 return yield* failAccess("Project favicon was not found.");
199 }
200 claims = {
201 version: 1,
202 kind: "project-favicon",
203 workspaceRoot: yield* fileSystem
204 .realPath(workspaceRoot)
205 .pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause))),
206 relativePath,
207 expiresAt,
208 };
209 fileName = relativePath ? path.basename(relativePath) : "favicon.svg";
210 break;
211 }
212 }
213
214 const secretStore = yield* ServerSecretStore;
215 const signingSecret = yield* secretStore
216 .getOrCreateRandom(SIGNING_SECRET_NAME, 32)
217 .pipe(Effect.mapError((cause) => failAccess(cause.message, cause)));
218 const encodedPayload = base64UrlEncode(encodeAssetClaims(claims));
219 const token = `${encodedPayload}.${signPayload(encodedPayload, signingSecret)}`;
220 return {
221 relativeUrl: `${ASSET_ROUTE_PREFIX}/${token}/${encodeURIComponent(fileName)}`,
222 expiresAt,
223 };
224});
225
226export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
227 token: string,
228 relativePath: string,
229) {
230 const [encodedPayload, signature] = token.split(".");
231 if (!encodedPayload || !signature) return null;
232
233 const secretStore = yield* ServerSecretStore;
234 const signingSecret = yield* secretStore
235 .getOrCreateRandom(SIGNING_SECRET_NAME, 32)
236 .pipe(Effect.orElseSucceed(() => null));
237 if (!signingSecret) return null;
238 if (!timingSafeEqualBase64Url(signature, signPayload(encodedPayload, signingSecret))) return null;
239
240 const claims = decodeClaims(encodedPayload);
241 if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null;
242
243 if (claims.kind === "attachment") {
244 const config = yield* ServerConfig;
245 const attachmentPath = resolveAttachmentPathById({
246 attachmentsDir: config.attachmentsDir,
247 attachmentId: claims.attachmentId,
248 });
249 if (!attachmentPath) return null;
250 const fileSystem = yield* FileSystem.FileSystem;
251 const info = yield* fileSystem.stat(attachmentPath).pipe(Effect.orElseSucceed(() => null));
252 return info?.type === "File"
253 ? ({ kind: "file", path: attachmentPath } satisfies ResolvedAsset)
254 : null;
255 }
256
257 if (claims.kind === "project-favicon") {
258 if (claims.relativePath === null) {
259 return { kind: "project-favicon-fallback" } satisfies ResolvedAsset;
260 }
261 const faviconPath = yield* resolveCanonicalWorkspaceFile({
262 workspaceRoot: claims.workspaceRoot,
263 relativePath: claims.relativePath,
264 });
265 return faviconPath ? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset) : null;
266 }
267
268 const decodedPath = decodeRelativePath(relativePath);
269 if (decodedPath === null) return null;
270 const path = yield* Path.Path;
271 const segments = decodedPath.split(/[\\/]/);
272 if (
273 decodedPath.length === 0 ||
274 decodedPath.includes("\0") ||
275 segments.some((segment) => segment === "." || segment === ".." || segment.startsWith(".")) ||
276 !PREVIEW_ASSET_EXTENSIONS.has(path.extname(decodedPath).toLowerCase())
277 ) {
278 return null;
279 }
280 const joinedRelativePath =
281 claims.baseRelativePath === "." ? decodedPath : path.join(claims.baseRelativePath, decodedPath);
282 const workspaceFile = yield* resolveCanonicalWorkspaceFile({
283 workspaceRoot: claims.workspaceRoot,
284 relativePath: joinedRelativePath,
285 });
286 return workspaceFile ? ({ kind: "file", path: workspaceFile } satisfies ResolvedAsset) : null;
287});
apps/server/src/attachmentPaths.ts-2
......@@ -1,8 +1,6 @@
11// @effect-diagnostics nodeBuiltinImport:off
22import NodePath from "node:path";
33
4export const ATTACHMENTS_ROUTE_PREFIX = "/attachments";
5
64export function normalizeAttachmentRelativePath(rawRelativePath: string): string | null {
75 const normalized = NodePath.normalize(rawRelativePath).replace(/^[/\\]+/, "");
86 if (normalized.length === 0 || normalized.startsWith("..") || normalized.includes("\0")) {
apps/server/src/http.ts+22-83
......@@ -24,15 +24,13 @@ import {
2424import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
2525import { OtlpTracer } from "effect/unstable/observability";
2626
27import {
28 ATTACHMENTS_ROUTE_PREFIX,
29 normalizeAttachmentRelativePath,
30 resolveAttachmentRelativePath,
31} from "./attachmentPaths.ts";
32import { resolveAttachmentPathById } from "./attachmentStore.ts";
3327import { resolveStaticDir, ServerConfig } from "./config.ts";
28import {
29 ASSET_ROUTE_PREFIX,
30 FALLBACK_PROJECT_FAVICON_SVG,
31 resolveAsset,
32} from "./assets/AssetAccess.ts";
3433import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts";
35import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts";
3634import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
3735import {
3836 annotateEnvironmentRequest,
......@@ -43,8 +41,6 @@ import {
4341import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts";
4442import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts";
4543
46const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600";
47const FALLBACK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#6b728080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-fallback="project-favicon"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z"/></svg>`;
4844const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";
4945const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
5046
......@@ -169,107 +165,50 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add(
169165 ),
170166);
171167
172export const attachmentsRouteLayer = HttpRouter.add(
168export const assetRouteLayer = HttpRouter.add(
173169 "GET",
174 `${ATTACHMENTS_ROUTE_PREFIX}/*`,
170 `${ASSET_ROUTE_PREFIX}/*`,
175171 Effect.gen(function* () {
176 yield* authenticateRawRouteWithScope(AuthOrchestrationReadScope);
177172 const request = yield* HttpServerRequest.HttpServerRequest;
178173 const url = HttpServerRequest.toURL(request);
179174 if (Option.isNone(url)) {
180175 return HttpServerResponse.text("Bad Request", { status: 400 });
181176 }
182177
183 const config = yield* ServerConfig;
184 const rawRelativePath = url.value.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length);
185 const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath);
186 if (!normalizedRelativePath) {
187 return HttpServerResponse.text("Invalid attachment path", { status: 400 });
188 }
189
190 const isIdLookup =
191 !normalizedRelativePath.includes("/") && !normalizedRelativePath.includes(".");
192 const filePath = isIdLookup
193 ? resolveAttachmentPathById({
194 attachmentsDir: config.attachmentsDir,
195 attachmentId: normalizedRelativePath,
196 })
197 : resolveAttachmentRelativePath({
198 attachmentsDir: config.attachmentsDir,
199 relativePath: normalizedRelativePath,
200 });
201 if (!filePath) {
202 return HttpServerResponse.text(isIdLookup ? "Not Found" : "Invalid attachment path", {
203 status: isIdLookup ? 404 : 400,
204 });
205 }
206
207 const fileSystem = yield* FileSystem.FileSystem;
208 const fileInfo = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null));
209 if (!fileInfo || fileInfo.type !== "File") {
178 const suffix = url.value.pathname.slice(`${ASSET_ROUTE_PREFIX}/`.length);
179 const separatorIndex = suffix.indexOf("/");
180 if (separatorIndex <= 0) {
210181 return HttpServerResponse.text("Not Found", { status: 404 });
211182 }
212183
213 return yield* HttpServerResponse.file(filePath, {
214 status: 200,
215 headers: {
216 "Cache-Control": "public, max-age=31536000, immutable",
217 },
218 }).pipe(
219 Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })),
184 const asset = yield* resolveAsset(
185 suffix.slice(0, separatorIndex),
186 suffix.slice(separatorIndex + 1),
220187 );
221 }).pipe(
222 Effect.catchTags({
223 EnvironmentAuthInvalidError: HttpServerRespondable.toResponse,
224 EnvironmentInternalError: HttpServerRespondable.toResponse,
225 EnvironmentScopeRequiredError: HttpServerRespondable.toResponse,
226 }),
227 ),
228);
229
230export const projectFaviconRouteLayer = HttpRouter.add(
231 "GET",
232 "/api/project-favicon",
233 Effect.gen(function* () {
234 yield* authenticateRawRouteWithScope(AuthOrchestrationReadScope);
235 const request = yield* HttpServerRequest.HttpServerRequest;
236 const url = HttpServerRequest.toURL(request);
237 if (Option.isNone(url)) {
238 return HttpServerResponse.text("Bad Request", { status: 400 });
239 }
240
241 const projectCwd = url.value.searchParams.get("cwd");
242 if (!projectCwd) {
243 return HttpServerResponse.text("Missing cwd parameter", { status: 400 });
188 if (!asset) {
189 return HttpServerResponse.text("Not Found", { status: 404 });
244190 }
245
246 const faviconResolver = yield* ProjectFaviconResolver;
247 const faviconFilePath = yield* faviconResolver.resolvePath(projectCwd);
248 if (!faviconFilePath) {
191 if (asset.kind === "project-favicon-fallback") {
249192 return HttpServerResponse.text(FALLBACK_PROJECT_FAVICON_SVG, {
250193 status: 200,
251194 contentType: "image/svg+xml",
252195 headers: {
253 "Cache-Control": PROJECT_FAVICON_CACHE_CONTROL,
196 "Cache-Control": "private, max-age=3600",
197 "X-Content-Type-Options": "nosniff",
254198 },
255199 });
256200 }
257201
258 return yield* HttpServerResponse.file(faviconFilePath, {
202 return yield* HttpServerResponse.file(asset.path, {
259203 status: 200,
260204 headers: {
261 "Cache-Control": PROJECT_FAVICON_CACHE_CONTROL,
205 "Cache-Control": "private, max-age=3600",
206 "X-Content-Type-Options": "nosniff",
262207 },
263208 }).pipe(
264209 Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })),
265210 );
266 }).pipe(
267 Effect.catchTags({
268 EnvironmentAuthInvalidError: HttpServerRespondable.toResponse,
269 EnvironmentInternalError: HttpServerRespondable.toResponse,
270 EnvironmentScopeRequiredError: HttpServerRespondable.toResponse,
271 }),
272 ),
211 }),
273212);
274213
275214export const staticAndDevRouteLayer = HttpRouter.add(
apps/server/src/project/Layers/ProjectFaviconResolver.test.ts+2-1
......@@ -7,9 +7,10 @@ import * as Path from "effect/Path";
77
88import { ProjectFaviconResolver } from "../Services/ProjectFaviconResolver.ts";
99import { ProjectFaviconResolverLive } from "./ProjectFaviconResolver.ts";
10import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts";
1011
1112const TestLayer = Layer.empty.pipe(
12 Layer.provideMerge(ProjectFaviconResolverLive),
13 Layer.provideMerge(ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive))),
1314 Layer.provideMerge(NodeServices.layer),
1415);
1516
apps/server/src/project/Layers/ProjectFaviconResolver.ts+35-17
......@@ -7,6 +7,7 @@ import {
77 ProjectFaviconResolver,
88 type ProjectFaviconResolverShape,
99} from "../Services/ProjectFaviconResolver.ts";
10import { WorkspacePaths } from "../../workspace/Services/WorkspacePaths.ts";
1011
1112// Well-known favicon paths checked in order.
1213const FAVICON_CANDIDATES = [
......@@ -61,28 +62,32 @@ function extractIconHref(source: string): string | null {
6162export const makeProjectFaviconResolver = Effect.gen(function* () {
6263 const fileSystem = yield* FileSystem.FileSystem;
6364 const path = yield* Path.Path;
65 const workspacePaths = yield* WorkspacePaths;
6466
65 const resolveIconHref = (projectCwd: string, href: string): string[] => {
67 const resolveIconHref = (href: string): string[] => {
6668 const clean = href.replace(/^\//, "");
67 return [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)];
68 };
69
70 const isPathWithinProject = (projectCwd: string, candidatePath: string): boolean => {
71 const relative = path.relative(path.resolve(projectCwd), path.resolve(candidatePath));
72 return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
69 return [path.join("public", clean), clean];
7370 };
7471
7572 const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* (
7673 projectCwd: string,
77 candidates: ReadonlyArray<string>,
74 relativeCandidates: ReadonlyArray<string>,
7875 ): Effect.fn.Return<string | null> {
79 for (const candidate of candidates) {
80 if (!isPathWithinProject(projectCwd, candidate)) {
76 for (const relativePath of relativeCandidates) {
77 const candidate = yield* workspacePaths
78 .resolveRelativePathWithinRoot({
79 workspaceRoot: projectCwd,
80 relativePath,
81 })
82 .pipe(Effect.orElseSucceed(() => null));
83 if (!candidate) {
8184 continue;
8285 }
83 const stats = yield* fileSystem.stat(candidate).pipe(Effect.orElseSucceed(() => null));
86 const stats = yield* fileSystem
87 .stat(candidate.absolutePath)
88 .pipe(Effect.orElseSucceed(() => null));
8489 if (stats?.type === "File") {
85 return candidate;
90 return candidate.absolutePath;
8691 }
8792 }
8893 return null;
......@@ -91,18 +96,31 @@ export const makeProjectFaviconResolver = Effect.gen(function* () {
9196 const resolvePath: ProjectFaviconResolverShape["resolvePath"] = Effect.fn(
9297 "ProjectFaviconResolver.resolvePath",
9398 )(function* (cwd: string): Effect.fn.Return<string | null> {
99 const projectCwd = yield* workspacePaths
100 .normalizeWorkspaceRoot(cwd)
101 .pipe(Effect.orElseSucceed(() => null));
102 if (!projectCwd) {
103 return null;
104 }
94105 for (const candidate of FAVICON_CANDIDATES) {
95 const resolved = path.join(cwd, candidate);
96 const existing = yield* findExistingFile(cwd, [resolved]);
106 const existing = yield* findExistingFile(projectCwd, [candidate]);
97107 if (existing) {
98108 return existing;
99109 }
100110 }
101111
102112 for (const sourceFile of ICON_SOURCE_FILES) {
103 const sourcePath = path.join(cwd, sourceFile);
113 const sourcePath = yield* workspacePaths
114 .resolveRelativePathWithinRoot({
115 workspaceRoot: projectCwd,
116 relativePath: sourceFile,
117 })
118 .pipe(Effect.orElseSucceed(() => null));
119 if (!sourcePath) {
120 continue;
121 }
104122 const source = yield* fileSystem
105 .readFileString(sourcePath)
123 .readFileString(sourcePath.absolutePath)
106124 .pipe(Effect.orElseSucceed(() => null));
107125 if (!source) {
108126 continue;
......@@ -111,7 +129,7 @@ export const makeProjectFaviconResolver = Effect.gen(function* () {
111129 if (!href) {
112130 continue;
113131 }
114 const existing = yield* findExistingFile(cwd, resolveIconHref(cwd, href));
132 const existing = yield* findExistingFile(projectCwd, resolveIconHref(href));
115133 if (existing) {
116134 return existing;
117135 }
apps/server/src/server.test.ts+1-168
......@@ -71,7 +71,6 @@ const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
7171import type { ServerConfigShape } from "./config.ts";
7272import { deriveServerPaths, ServerConfig } from "./config.ts";
7373import { makeRoutesLayer } from "./server.ts";
74import { resolveAttachmentRelativePath } from "./attachmentPaths.ts";
7574import {
7675 CheckpointDiffQuery,
7776 type CheckpointDiffQueryShape,
......@@ -516,7 +515,7 @@ const buildAppUnderTest = (options?: {
516515 Layer.provide(WorkspacePathsLive),
517516 Layer.provide(workspaceEntriesLayer),
518517 ),
519 ProjectFaviconResolverLive,
518 ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive)),
520519 );
521520 const gitWorkflowLayer = GitWorkflowService.layer.pipe(
522521 Layer.provideMerge(vcsDriverRegistryLayer),
......@@ -1279,61 +1278,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
12791278 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
12801279 );
12811280
1282 it.effect("serves project favicon requests before the dev URL redirect", () =>
1283 Effect.gen(function* () {
1284 const fileSystem = yield* FileSystem.FileSystem;
1285 const path = yield* Path.Path;
1286 const projectDir = yield* fileSystem.makeTempDirectoryScoped({
1287 prefix: "t3-router-project-favicon-",
1288 });
1289 yield* fileSystem.writeFileString(
1290 path.join(projectDir, "favicon.svg"),
1291 "<svg>router-project-favicon</svg>",
1292 );
1293
1294 yield* buildAppUnderTest({
1295 config: { devUrl: new URL("http://127.0.0.1:5173") },
1296 });
1297
1298 const response = yield* HttpClient.get(
1299 `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`,
1300 {
1301 headers: {
1302 cookie: yield* getAuthenticatedSessionCookieHeader(),
1303 },
1304 },
1305 );
1306
1307 assert.equal(response.status, 200);
1308 assert.equal(yield* response.text, "<svg>router-project-favicon</svg>");
1309 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1310 );
1311
1312 it.effect("serves the fallback project favicon when no icon exists", () =>
1313 Effect.gen(function* () {
1314 const fileSystem = yield* FileSystem.FileSystem;
1315 const projectDir = yield* fileSystem.makeTempDirectoryScoped({
1316 prefix: "t3-router-project-favicon-fallback-",
1317 });
1318
1319 yield* buildAppUnderTest({
1320 config: { devUrl: new URL("http://127.0.0.1:5173") },
1321 });
1322
1323 const response = yield* HttpClient.get(
1324 `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`,
1325 {
1326 headers: {
1327 cookie: yield* getAuthenticatedSessionCookieHeader(),
1328 },
1329 },
1330 );
1331
1332 assert.equal(response.status, 200);
1333 assert.include(yield* response.text, 'data-fallback="project-favicon"');
1334 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1335 );
1336
13371281 it.effect("serves the public environment descriptor without requiring auth", () =>
13381282 Effect.gen(function* () {
13391283 yield* buildAppUnderTest();
......@@ -3195,28 +3139,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
31953139 },
31963140 });
31973141 const wsTicketBody = (yield* wsTicketResponse.json) as { readonly ticket: string };
3198 const faviconResponse = yield* HttpClient.get("/api/project-favicon?cwd=/tmp", {
3199 headers: {
3200 authorization: `Bearer ${tokenBody.access_token ?? ""}`,
3201 },
3202 });
3203 const faviconBody = (yield* faviconResponse.json) as {
3204 readonly _tag: string;
3205 readonly code: string;
3206 readonly requiredScope: string;
3207 readonly traceId: string;
3208 };
3209
32103142 assert.equal(overbroadPairingResponse.status, 403);
32113143 assert.equal(overbroadPairingBody.requiredScope, "orchestration:read");
32123144 assert.equal(pairingResponse.status, 200);
32133145 assert.equal(wsTicketResponse.status, 200);
3214 assert.equal(faviconResponse.status, 403);
3215 assert.equal(faviconBody._tag, "EnvironmentScopeRequiredError");
3216 assert.equal(faviconBody.code, "insufficient_scope");
3217 assert.equal(faviconBody.requiredScope, "orchestration:read");
3218 assert.equal(typeof faviconBody.traceId, "string");
3219
32203146 const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`;
32213147 const rpcError = yield* Effect.flip(
32223148 Effect.scoped(withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({}))),
......@@ -3760,29 +3686,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
37603686 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
37613687 );
37623688
3763 it.effect(
3764 "does not accept session tokens via query parameters on authenticated HTTP routes",
3765 () =>
3766 Effect.gen(function* () {
3767 const fileSystem = yield* FileSystem.FileSystem;
3768 const projectDir = yield* fileSystem.makeTempDirectoryScoped({
3769 prefix: "t3-router-project-favicon-query-token-",
3770 });
3771
3772 yield* buildAppUnderTest();
3773
3774 const { cookie } = yield* bootstrapBrowserSession();
3775 assert.isDefined(cookie);
3776 const sessionToken = extractSessionTokenFromSetCookie(cookie ?? "");
3777
3778 const response = yield* HttpClient.get(
3779 `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}&token=${encodeURIComponent(sessionToken)}`,
3780 );
3781
3782 assert.equal(response.status, 401);
3783 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
3784 );
3785
37863689 it.effect("accepts websocket rpc handshake with a bootstrapped browser session cookie", () =>
37873690 Effect.gen(function* () {
37883691 yield* buildAppUnderTest();
......@@ -3853,60 +3756,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
38533756 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
38543757 );
38553758
3856 it.effect("serves attachment files from state dir", () =>
3857 Effect.gen(function* () {
3858 const fileSystem = yield* FileSystem.FileSystem;
3859 const path = yield* Path.Path;
3860 const attachmentId = "thread-11111111-1111-4111-8111-111111111111";
3861
3862 const config = yield* buildAppUnderTest();
3863 const attachmentPath = resolveAttachmentRelativePath({
3864 attachmentsDir: config.attachmentsDir,
3865 relativePath: `${attachmentId}.bin`,
3866 });
3867 assert.isNotNull(attachmentPath, "Attachment path should be resolvable");
3868
3869 yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true });
3870 yield* fileSystem.writeFileString(attachmentPath, "attachment-ok");
3871
3872 const response = yield* HttpClient.get(`/attachments/${attachmentId}`, {
3873 headers: {
3874 cookie: yield* getAuthenticatedSessionCookieHeader(),
3875 },
3876 });
3877 assert.equal(response.status, 200);
3878 assert.equal(yield* response.text, "attachment-ok");
3879 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
3880 );
3881
3882 it.effect("serves attachment files for URL-encoded paths", () =>
3883 Effect.gen(function* () {
3884 const fileSystem = yield* FileSystem.FileSystem;
3885 const path = yield* Path.Path;
3886
3887 const config = yield* buildAppUnderTest();
3888 const attachmentPath = resolveAttachmentRelativePath({
3889 attachmentsDir: config.attachmentsDir,
3890 relativePath: "thread%20folder/message%20folder/file%20name.png",
3891 });
3892 assert.isNotNull(attachmentPath, "Attachment path should be resolvable");
3893
3894 yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true });
3895 yield* fileSystem.writeFileString(attachmentPath, "attachment-encoded-ok");
3896
3897 const response = yield* HttpClient.get(
3898 "/attachments/thread%20folder/message%20folder/file%20name.png",
3899 {
3900 headers: {
3901 cookie: yield* getAuthenticatedSessionCookieHeader(),
3902 },
3903 },
3904 );
3905 assert.equal(response.status, 200);
3906 assert.equal(yield* response.text, "attachment-encoded-ok");
3907 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
3908 );
3909
39103759 it.effect("proxies browser OTLP trace exports through the server", () =>
39113760 Effect.gen(function* () {
39123761 const upstreamRequests: Array<{
......@@ -4196,22 +4045,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
41964045 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
41974046 );
41984047
4199 it.effect("returns 404 for missing attachment id lookups", () =>
4200 Effect.gen(function* () {
4201 yield* buildAppUnderTest();
4202
4203 const response = yield* HttpClient.get(
4204 "/attachments/missing-11111111-1111-4111-8111-111111111111",
4205 {
4206 headers: {
4207 cookie: yield* getAuthenticatedSessionCookieHeader(),
4208 },
4209 },
4210 );
4211 assert.equal(response.status, 404);
4212 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
4213 );
4214
42154048 it.effect("routes websocket rpc server.upsertKeybinding", () =>
42164049 Effect.gen(function* () {
42174050 const rule: KeybindingRule = {
apps/server/src/server.ts+7-5
......@@ -6,9 +6,8 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
66
77import { ServerConfig } from "./config.ts";
88import {
9 attachmentsRouteLayer,
109 otlpTracesProxyRouteLayer,
11 projectFaviconRouteLayer,
10 assetRouteLayer,
1211 serverEnvironmentHttpApiLayer,
1312 staticAndDevRouteLayer,
1413 browserApiCorsLayer,
......@@ -264,6 +263,10 @@ const WorkspaceLayerLive = Layer.mergeAll(
264263 WorkspaceFileSystemLayerLive,
265264);
266265
266const ProjectFaviconResolverLayerLive = ProjectFaviconResolverLive.pipe(
267 Layer.provide(WorkspacePathsLive),
268);
269
267270const AuthLayerLive = EnvironmentAuth.layer.pipe(
268271 Layer.provideMerge(PersistenceLayerLive),
269272 Layer.provide(ServerSecretStore.layer),
......@@ -313,7 +316,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
313316 Layer.provideMerge(OpenCodeRuntimeLive),
314317 Layer.provideMerge(ServerSettingsLive),
315318 Layer.provideMerge(WorkspaceLayerLive),
316 Layer.provideMerge(ProjectFaviconResolverLive),
319 Layer.provideMerge(ProjectFaviconResolverLayerLive),
317320 Layer.provideMerge(RepositoryIdentityResolverLive),
318321 Layer.provideMerge(ServerEnvironmentLive),
319322 Layer.provideMerge(AuthLayerLive),
......@@ -350,9 +353,8 @@ export const makeRoutesLayer = Layer.mergeAll(
350353 Layer.provide(serverEnvironmentHttpApiLayer),
351354 Layer.provide(environmentAuthenticatedAuthLayer),
352355 ),
353 attachmentsRouteLayer,
354356 otlpTracesProxyRouteLayer,
355 projectFaviconRouteLayer,
357 assetRouteLayer,
356358 staticAndDevRouteLayer,
357359 websocketRpcRouteLayer,
358360 ),
apps/server/src/ws.ts+49
......@@ -40,6 +40,7 @@ import {
4040 type RelayClientInstallProgressEvent,
4141 OrchestrationReplayEventsError,
4242 FilesystemBrowseError,
43 AssetAccessError,
4344 EnvironmentAuthorizationError,
4445 ThreadId,
4546 type TerminalAttachStreamEvent,
......@@ -73,6 +74,7 @@ import { redactServerSettingsForClient, ServerSettingsService } from "./serverSe
7374import { TerminalManager } from "./terminal/Services/Manager.ts";
7475import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts";
7576import * as PreviewManager from "./preview/Manager.ts";
77import { issueAssetUrl } from "./assets/AssetAccess.ts";
7678import * as PortScanner from "./preview/PortScanner.ts";
7779import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts";
7880import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts";
......@@ -162,6 +164,7 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([
162164 [WS_METHODS.projectsWriteFile, AuthOrchestrationOperateScope],
163165 [WS_METHODS.shellOpenInEditor, AuthOrchestrationOperateScope],
164166 [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope],
167 [WS_METHODS.assetsCreateUrl, AuthOrchestrationReadScope],
165168 [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope],
166169 [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope],
167170 [WS_METHODS.vcsPull, AuthOrchestrationOperateScope],
......@@ -1203,6 +1206,52 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>
12031206 ),
12041207 { "rpc.aggregate": "workspace" },
12051208 ),
1209 [WS_METHODS.assetsCreateUrl]: (input) =>
1210 observeRpcEffect(
1211 WS_METHODS.assetsCreateUrl,
1212 Effect.gen(function* () {
1213 if (input.resource._tag !== "workspace-file") {
1214 return yield* issueAssetUrl({ resource: input.resource });
1215 }
1216 const thread = yield* projectionSnapshotQuery
1217 .getThreadShellById(input.resource.threadId)
1218 .pipe(
1219 Effect.mapError(
1220 (cause) =>
1221 new AssetAccessError({
1222 message: "Failed to resolve workspace context.",
1223 cause,
1224 }),
1225 ),
1226 );
1227 if (Option.isNone(thread)) {
1228 return yield* new AssetAccessError({
1229 message: "Workspace context was not found.",
1230 });
1231 }
1232 const project = yield* projectionSnapshotQuery
1233 .getProjectShellById(thread.value.projectId)
1234 .pipe(
1235 Effect.mapError(
1236 (cause) =>
1237 new AssetAccessError({
1238 message: "Failed to resolve workspace context.",
1239 cause,
1240 }),
1241 ),
1242 );
1243 if (Option.isNone(project)) {
1244 return yield* new AssetAccessError({
1245 message: "Workspace context was not found.",
1246 });
1247 }
1248 return yield* issueAssetUrl({
1249 resource: input.resource,
1250 workspaceRoot: thread.value.worktreePath ?? project.value.workspaceRoot,
1251 });
1252 }),
1253 { "rpc.aggregate": "workspace" },
1254 ),
12061255 [WS_METHODS.subscribeVcsStatus]: (input) =>
12071256 observeRpcStream(
12081257 WS_METHODS.subscribeVcsStatus,
apps/web/src/assets/assetUrls.ts created+89
......@@ -0,0 +1,89 @@
1import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
2import { useEffect, useMemo, useState } from "react";
3
4import { readEnvironmentApi } from "~/environmentApi";
5import { readEnvironmentConnection } from "~/environments/runtime";
6
7const REFRESH_MARGIN_MS = 30_000;
8
9interface CachedAssetUrl {
10 readonly url: string;
11 readonly expiresAt: number;
12}
13
14const assetUrlCache = new Map<string, CachedAssetUrl>();
15const assetUrlRequests = new Map<string, Promise<CachedAssetUrl>>();
16
17function assetCacheKey(environmentId: EnvironmentId, resource: AssetResource): string {
18 return `${environmentId}:${JSON.stringify(resource)}`;
19}
20
21export async function resolveAssetUrl(
22 environmentId: EnvironmentId,
23 resource: AssetResource,
24): Promise<CachedAssetUrl> {
25 const key = assetCacheKey(environmentId, resource);
26 const cached = assetUrlCache.get(key);
27 if (cached && cached.expiresAt - REFRESH_MARGIN_MS > Date.now()) {
28 return cached;
29 }
30
31 const inFlight = assetUrlRequests.get(key);
32 if (inFlight) {
33 return inFlight;
34 }
35
36 const request = (async () => {
37 const api = readEnvironmentApi(environmentId);
38 const connection = readEnvironmentConnection(environmentId);
39 if (!api || !connection) {
40 throw new Error("Environment is not connected.");
41 }
42 const result = await api.assets.createUrl({ resource });
43 const cachedResult = {
44 url: new URL(result.relativeUrl, connection.knownEnvironment.target.httpBaseUrl).toString(),
45 expiresAt: result.expiresAt,
46 };
47 assetUrlCache.set(key, cachedResult);
48 return cachedResult;
49 })().finally(() => {
50 assetUrlRequests.delete(key);
51 });
52 assetUrlRequests.set(key, request);
53 return request;
54}
55
56export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null {
57 const resourceJson = JSON.stringify(resource);
58 const stableResource = useMemo(() => JSON.parse(resourceJson) as AssetResource, [resourceJson]);
59 const key = assetCacheKey(environmentId, stableResource);
60 const [url, setUrl] = useState<string | null>(() => assetUrlCache.get(key)?.url ?? null);
61
62 useEffect(() => {
63 let cancelled = false;
64 let refreshTimer: ReturnType<typeof setTimeout> | undefined;
65
66 const load = () => {
67 void resolveAssetUrl(environmentId, stableResource)
68 .then((result) => {
69 if (cancelled) return;
70 setUrl(result.url);
71 refreshTimer = setTimeout(
72 load,
73 Math.max(0, result.expiresAt - Date.now() - REFRESH_MARGIN_MS),
74 );
75 })
76 .catch(() => {
77 if (!cancelled) setUrl(null);
78 });
79 };
80 load();
81
82 return () => {
83 cancelled = true;
84 if (refreshTimer) clearTimeout(refreshTimer);
85 };
86 }, [environmentId, key, stableResource]);
87
88 return url;
89}
apps/web/src/browser/openFileInPreview.ts created+36
......@@ -0,0 +1,36 @@
1import type { ScopedThreadRef } from "@t3tools/contracts";
2
3import { readEnvironmentApi } from "~/environmentApi";
4import { resolveAssetUrl } from "~/assets/assetUrls";
5import { isPreviewSupportedInRuntime, usePreviewStateStore } from "~/previewStateStore";
6import { useRightPanelStore } from "~/rightPanelStore";
7
8export const isBrowserPreviewFile = (path: string): boolean =>
9 /\.(?:html?|pdf)$/i.test(path.split(/[?#]/, 1)[0] ?? "");
10
11export async function openUrlInPreview(threadRef: ScopedThreadRef, url: string): Promise<void> {
12 const api = readEnvironmentApi(threadRef.environmentId);
13 if (!api) {
14 throw new Error("Environment is not connected.");
15 }
16
17 const snapshot = await api.preview.open({ threadId: threadRef.threadId, url });
18 usePreviewStateStore.getState().applyServerSnapshot(threadRef, snapshot);
19 usePreviewStateStore.getState().rememberUrl(threadRef, url);
20 useRightPanelStore.getState().openBrowser(threadRef, snapshot.tabId);
21}
22
23export async function openFileInPreview(
24 threadRef: ScopedThreadRef,
25 filePath: string,
26): Promise<void> {
27 if (!isPreviewSupportedInRuntime()) {
28 throw new Error("The integrated browser is unavailable in this runtime.");
29 }
30 const asset = await resolveAssetUrl(threadRef.environmentId, {
31 _tag: "workspace-file",
32 threadId: threadRef.threadId,
33 path: filePath,
34 });
35 await openUrlInPreview(threadRef, asset.url);
36}
apps/web/src/components/ChatMarkdown.browser.tsx+95-2
......@@ -4,11 +4,24 @@ import { page } from "vite-plus/test/browser";
44import { afterEach, describe, expect, it, vi } from "vite-plus/test";
55import { render } from "vitest-browser-react";
66
7const { openInPreferredEditorMock, readLocalApiMock } = vi.hoisted(() => ({
7const {
8 contextMenuShowMock,
9 openFileInPreviewMock,
10 openInPreferredEditorMock,
11 openUrlInPreviewMock,
12 readLocalApiMock,
13} = vi.hoisted(() => ({
14 contextMenuShowMock: vi.fn(),
15 openFileInPreviewMock: vi.fn(async () => undefined),
816 openInPreferredEditorMock: vi.fn(async () => "vscode"),
17 openUrlInPreviewMock: vi.fn(async () => undefined),
918 readLocalApiMock: vi.fn(() => ({
19 contextMenu: { show: contextMenuShowMock },
1020 server: { getConfig: vi.fn(async () => ({ availableEditors: ["vscode"] })) },
11 shell: { openInEditor: vi.fn(async () => undefined) },
21 shell: {
22 openExternal: vi.fn(async () => undefined),
23 openInEditor: vi.fn(async () => undefined),
24 },
1225 })),
1326}));
1427
......@@ -23,12 +36,32 @@ vi.mock("../localApi", () => ({
2336 readLocalApi: readLocalApiMock,
2437}));
2538
39vi.mock("../previewStateStore", async (importOriginal) => ({
40 ...(await importOriginal<typeof import("../previewStateStore")>()),
41 isPreviewSupportedInRuntime: () => true,
42}));
43
44vi.mock("../browser/openFileInPreview", async (importOriginal) => ({
45 ...(await importOriginal<typeof import("../browser/openFileInPreview")>()),
46 openFileInPreview: openFileInPreviewMock,
47 openUrlInPreview: openUrlInPreviewMock,
48}));
49
2650import ChatMarkdown from "./ChatMarkdown";
2751import { serializeTableElementToCsv, serializeTableElementToMarkdown } from "../markdown-clipboard";
52import { EnvironmentId, ThreadId } from "@t3tools/contracts";
53
54const threadRef = {
55 environmentId: EnvironmentId.make("environment-test"),
56 threadId: ThreadId.make("thread-test"),
57};
2858
2959describe("ChatMarkdown", () => {
3060 afterEach(() => {
3161 openInPreferredEditorMock.mockClear();
62 openFileInPreviewMock.mockClear();
63 openUrlInPreviewMock.mockClear();
64 contextMenuShowMock.mockReset();
3265 readLocalApiMock.mockClear();
3366 localStorage.clear();
3467 document.body.innerHTML = "";
......@@ -155,6 +188,66 @@ describe("ChatMarkdown", () => {
155188 }
156189 });
157190
191 it("opens web links in the integrated browser from the context menu", async () => {
192 contextMenuShowMock.mockResolvedValue("open-in-browser");
193 const screen = await render(
194 <ChatMarkdown
195 text="[OpenAI](https://openai.com/docs)"
196 cwd="/repo/project"
197 threadRef={threadRef}
198 />,
199 );
200
201 try {
202 const link = page.getByRole("link", { name: "OpenAI" }).element();
203 link.dispatchEvent(
204 new MouseEvent("contextmenu", {
205 bubbles: true,
206 cancelable: true,
207 clientX: 12,
208 clientY: 24,
209 }),
210 );
211
212 await vi.waitFor(() => {
213 expect(contextMenuShowMock).toHaveBeenCalled();
214 expect(openUrlInPreviewMock).toHaveBeenCalledWith(threadRef, "https://openai.com/docs");
215 });
216 } finally {
217 await screen.unmount();
218 }
219 });
220
221 it("offers integrated browser opening for HTML file links", async () => {
222 contextMenuShowMock.mockResolvedValue("open-in-browser");
223 const filePath = "/repo/project/report.html";
224 const screen = await render(
225 <ChatMarkdown text="[report.html](report.html)" cwd="/repo/project" threadRef={threadRef} />,
226 );
227
228 try {
229 const link = page.getByRole("link", { name: "report.html" }).element();
230 link.dispatchEvent(
231 new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 4, clientY: 8 }),
232 );
233
234 await vi.waitFor(() => {
235 expect(contextMenuShowMock).toHaveBeenCalledWith(
236 expect.arrayContaining([
237 expect.objectContaining({
238 id: "open-in-browser",
239 label: "Open in integrated browser",
240 }),
241 ]),
242 { x: 4, y: 8 },
243 );
244 expect(openFileInPreviewMock).toHaveBeenCalledWith(threadRef, filePath);
245 });
246 } finally {
247 await screen.unmount();
248 }
249 });
250
158251 it("keeps a favicon with the leading segment of a wrapping URL", async () => {
159252 const url = "https://github.com/pingdotgg/t3code/pull/3017/changes";
160253 const screen = await render(
apps/web/src/components/ChatMarkdown.tsx+90-5
......@@ -8,7 +8,7 @@ import {
88 Minimize2Icon,
99 WrapTextIcon,
1010} from "lucide-react";
11import type { ServerProviderSkill } from "@t3tools/contracts";
11import type { ScopedThreadRef, ServerProviderSkill } from "@t3tools/contracts";
1212import React, {
1313 Children,
1414 Suspense,
......@@ -62,6 +62,12 @@ import {
6262} from "../markdown-links";
6363import { readLocalApi } from "../localApi";
6464import { cn } from "../lib/utils";
65import { isPreviewSupportedInRuntime } from "../previewStateStore";
66import {
67 isBrowserPreviewFile,
68 openFileInPreview,
69 openUrlInPreview,
70} from "../browser/openFileInPreview";
6571
6672class CodeHighlightErrorBoundary extends React.Component<
6773 { fallback: ReactNode; children: ReactNode },
......@@ -87,6 +93,7 @@ class CodeHighlightErrorBoundary extends React.Component<
8793interface ChatMarkdownProps {
8894 text: string;
8995 cwd: string | undefined;
96 threadRef?: ScopedThreadRef | undefined;
9097 isStreaming?: boolean;
9198 skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
9299 className?: string;
......@@ -670,6 +677,7 @@ interface MarkdownFileLinkProps {
670677 label: string;
671678 copyMarkdown: string;
672679 theme: "light" | "dark";
680 threadRef?: ScopedThreadRef | undefined;
673681 className?: string | undefined;
674682}
675683
......@@ -936,6 +944,54 @@ function MarkdownExternalLinkContent({
936944 );
937945}
938946
947function MarkdownExternalLink({
948 href,
949 threadRef,
950 children,
951 ...props
952}: React.ComponentProps<"a"> & {
953 href: string;
954 threadRef?: ScopedThreadRef | undefined;
955}) {
956 const handleContextMenu = useCallback(
957 async (event: ReactMouseEvent<HTMLAnchorElement>) => {
958 if (!threadRef || !isPreviewSupportedInRuntime()) return;
959 event.preventDefault();
960 event.stopPropagation();
961
962 const api = readLocalApi();
963 if (!api) return;
964 const clicked = await api.contextMenu.show(
965 [
966 { id: "open-in-browser", label: "Open in integrated browser" },
967 { id: "open-external", label: "Open in system browser" },
968 ] as const,
969 { x: event.clientX, y: event.clientY },
970 );
971 if (clicked === "open-in-browser") {
972 void openUrlInPreview(threadRef, href).catch((error) => {
973 toastManager.add(
974 stackedThreadToast({
975 type: "error",
976 title: "Unable to open link in browser",
977 description: error instanceof Error ? error.message : "An error occurred.",
978 }),
979 );
980 });
981 } else if (clicked === "open-external") {
982 void api.shell.openExternal(href);
983 }
984 },
985 [href, threadRef],
986 );
987
988 return (
989 <a {...props} href={href} onContextMenu={handleContextMenu}>
990 {children}
991 </a>
992 );
993}
994
939995const MarkdownFileLink = memo(function MarkdownFileLink({
940996 href,
941997 targetPath,
......@@ -944,6 +1000,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
9441000 label,
9451001 copyMarkdown,
9461002 theme,
1003 threadRef,
9471004 className,
9481005}: MarkdownFileLinkProps) {
9491006 const handleOpen = useCallback(() => {
......@@ -967,6 +1024,19 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
9671024 });
9681025 }, [targetPath]);
9691026
1027 const handleOpenInBrowser = useCallback(() => {
1028 if (!threadRef) return;
1029 void openFileInPreview(threadRef, iconPath).catch((error) => {
1030 toastManager.add(
1031 stackedThreadToast({
1032 type: "error",
1033 title: "Unable to open file in browser",
1034 description: error instanceof Error ? error.message : "An error occurred.",
1035 }),
1036 );
1037 });
1038 }, [iconPath, threadRef]);
1039
9701040 const handleCopy = useCallback((value: string, title: string) => {
9711041 if (typeof window === "undefined" || !navigator.clipboard?.writeText) {
9721042 toastManager.add(
......@@ -1007,9 +1077,14 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
10071077 const api = readLocalApi();
10081078 if (!api) return;
10091079
1080 const canOpenInBrowser =
1081 Boolean(threadRef) && isPreviewSupportedInRuntime() && isBrowserPreviewFile(iconPath);
10101082 const clicked = await api.contextMenu.show(
10111083 [
10121084 { id: "open", label: "Open in editor" },
1085 ...(canOpenInBrowser
1086 ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const)
1087 : []),
10131088 { id: "copy-relative", label: "Copy relative path" },
10141089 { id: "copy-full", label: "Copy full path" },
10151090 ] as const,
......@@ -1020,6 +1095,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
10201095 handleOpen();
10211096 return;
10221097 }
1098 if (clicked === "open-in-browser") {
1099 handleOpenInBrowser();
1100 return;
1101 }
10231102 if (clicked === "copy-relative") {
10241103 handleCopy(displayPath, "Relative path");
10251104 return;
......@@ -1028,7 +1107,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
10281107 handleCopy(targetPath, "Full path");
10291108 }
10301109 },
1031 [displayPath, handleCopy, handleOpen, targetPath],
1110 [displayPath, handleCopy, handleOpen, handleOpenInBrowser, iconPath, targetPath, threadRef],
10321111 );
10331112
10341113 return (
......@@ -1074,6 +1153,8 @@ function areMarkdownFileLinkPropsEqual(
10741153 previous.label === next.label &&
10751154 previous.copyMarkdown === next.copyMarkdown &&
10761155 previous.theme === next.theme &&
1156 previous.threadRef?.environmentId === next.threadRef?.environmentId &&
1157 previous.threadRef?.threadId === next.threadRef?.threadId &&
10771158 previous.className === next.className
10781159 );
10791160}
......@@ -1081,6 +1162,7 @@ function areMarkdownFileLinkPropsEqual(
10811162function ChatMarkdown({
10821163 text,
10831164 cwd,
1165 threadRef,
10841166 isStreaming = false,
10851167 skills = EMPTY_MARKDOWN_SKILLS,
10861168 className,
......@@ -1137,9 +1219,10 @@ function ChatMarkdown({
11371219 const isSameDocumentLink = href?.startsWith("#") ?? false;
11381220 const onClick = props.onClick;
11391221 const link = (
1140 <a
1222 <MarkdownExternalLink
11411223 {...props}
1142 href={href}
1224 href={href ?? ""}
1225 threadRef={faviconHost && isPreviewSupportedInRuntime() ? threadRef : undefined}
11431226 target={isSameDocumentLink ? undefined : "_blank"}
11441227 rel={isSameDocumentLink ? undefined : "noopener noreferrer"}
11451228 onClick={(event) => {
......@@ -1156,7 +1239,7 @@ function ChatMarkdown({
11561239 ) : (
11571240 children
11581241 )}
1159 </a>
1242 </MarkdownExternalLink>
11601243 );
11611244 if (!faviconHost || !href) {
11621245 return link;
......@@ -1194,6 +1277,7 @@ function ChatMarkdown({
11941277 label={labelParts.join(" · ")}
11951278 copyMarkdown={`[${fileLinkMeta.basename}](${normalizedHref})`}
11961279 theme={resolvedTheme}
1280 threadRef={threadRef}
11971281 className={props.className}
11981282 />
11991283 );
......@@ -1238,6 +1322,7 @@ function ChatMarkdown({
12381322 fileLinkParentSuffixByPath,
12391323 isStreaming,
12401324 markdownFileLinkMetaByHref,
1325 threadRef,
12411326 resolvedTheme,
12421327 skills,
12431328 ],
apps/web/src/components/ChatView.browser.tsx+13-3
......@@ -250,6 +250,18 @@ function createMockEnvironmentApi(input: {
250250 filesystem: {
251251 browse: input.browse,
252252 },
253 assets: {
254 createUrl: vi.fn(async ({ resource }) => ({
255 relativeUrl: `/api/assets/test/${encodeURIComponent(
256 resource._tag === "attachment"
257 ? resource.attachmentId
258 : resource._tag === "project-favicon"
259 ? "favicon.svg"
260 : (resource.path.split(/[\\/]/).at(-1) ?? "asset"),
261 )}`,
262 expiresAt: Date.now() + 60_000,
263 })),
264 },
253265 sourceControl: {} as EnvironmentApi["sourceControl"],
254266 vcs: {} as EnvironmentApi["vcs"],
255267 git: {} as EnvironmentApi["git"],
......@@ -373,7 +385,6 @@ function createSnapshotForTargetUser(options: {
373385 name: `attachment-${attachmentIndex + 1}.png`,
374386 mimeType: "image/png",
375387 sizeBytes: 128,
376 previewUrl: `/attachments/attachment-${attachmentIndex + 1}`,
377388 }))
378389 : undefined;
379390
......@@ -1153,14 +1164,13 @@ const worker = setupWorker(
11531164 });
11541165 }),
11551166 ...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth),
1156 http.get("*/attachments/:attachmentId", () =>
1167 http.get("*/api/assets/test/:assetName", () =>
11571168 HttpResponse.text(ATTACHMENT_SVG, {
11581169 headers: {
11591170 "Content-Type": "image/svg+xml",
11601171 },
11611172 }),
11621173 ),
1163 http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })),
11641174);
11651175
11661176async function nextFrame(): Promise<void> {
apps/web/src/components/ChatView.tsx+67-5
......@@ -37,6 +37,7 @@ import { useShallow } from "zustand/react/shallow";
3737import { useVcsStatus } from "~/lib/vcsStatusState";
3838import { usePrimaryEnvironmentId } from "../environments/primary";
3939import { readEnvironmentApi } from "../environmentApi";
40import { resolveAssetUrl } from "../assets/assetUrls";
4041import { isElectron } from "../env";
4142import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner";
4243import { readLocalApi } from "../localApi";
......@@ -1915,8 +1916,64 @@ export default function ChatView(props: ChatViewProps) {
19151916 });
19161917 }, []);
19171918 const serverMessages = activeThread?.messages;
1919 const [attachmentAssetUrlById, setAttachmentAssetUrlById] = useState<Record<string, string>>({});
19181920 useEffect(() => {
1919 if (typeof Image === "undefined" || !serverMessages || serverMessages.length === 0) {
1921 if (!serverMessages) return;
1922 const attachmentIds = [
1923 ...new Set(
1924 serverMessages.flatMap(
1925 (message) =>
1926 message.attachments?.flatMap((attachment) =>
1927 attachment.type === "image" && !attachment.previewUrl ? [attachment.id] : [],
1928 ) ?? [],
1929 ),
1930 ),
1931 ].filter((attachmentId) => !attachmentAssetUrlById[attachmentId]);
1932 if (attachmentIds.length === 0) return;
1933
1934 let cancelled = false;
1935 void Promise.all(
1936 attachmentIds.map(async (attachmentId) => {
1937 const asset = await resolveAssetUrl(environmentId, {
1938 _tag: "attachment",
1939 attachmentId,
1940 });
1941 return [attachmentId, asset.url] as const;
1942 }),
1943 )
1944 .then((entries) => {
1945 if (!cancelled) {
1946 setAttachmentAssetUrlById((current) => ({ ...current, ...Object.fromEntries(entries) }));
1947 }
1948 })
1949 .catch(() => undefined);
1950
1951 return () => {
1952 cancelled = true;
1953 };
1954 }, [attachmentAssetUrlById, environmentId, serverMessages]);
1955 const serverMessagesWithAssetUrls = useMemo(() => {
1956 if (!serverMessages || Object.keys(attachmentAssetUrlById).length === 0) {
1957 return serverMessages;
1958 }
1959 return serverMessages.map((message) => {
1960 if (!message.attachments) return message;
1961 let changed = false;
1962 const attachments = message.attachments.map((attachment) => {
1963 const previewUrl = attachmentAssetUrlById[attachment.id];
1964 if (!previewUrl || attachment.previewUrl === previewUrl) return attachment;
1965 changed = true;
1966 return { ...attachment, previewUrl };
1967 });
1968 return changed ? { ...message, attachments } : message;
1969 });
1970 }, [attachmentAssetUrlById, serverMessages]);
1971 useEffect(() => {
1972 if (
1973 typeof Image === "undefined" ||
1974 !serverMessagesWithAssetUrls ||
1975 serverMessagesWithAssetUrls.length === 0
1976 ) {
19201977 return;
19211978 }
19221979
......@@ -1929,7 +1986,7 @@ export default function ChatView(props: ChatViewProps) {
19291986 continue;
19301987 }
19311988
1932 const serverMessage = serverMessages.find(
1989 const serverMessage = serverMessagesWithAssetUrls.find(
19331990 (message) => message.id === messageId && message.role === "user",
19341991 );
19351992 if (!serverMessage?.attachments || serverMessage.attachments.length === 0) {
......@@ -1995,9 +2052,13 @@ export default function ChatView(props: ChatViewProps) {
19952052 cleanup();
19962053 }
19972054 };
1998 }, [attachmentPreviewHandoffByMessageId, clearAttachmentPreviewHandoff, serverMessages]);
2055 }, [
2056 attachmentPreviewHandoffByMessageId,
2057 clearAttachmentPreviewHandoff,
2058 serverMessagesWithAssetUrls,
2059 ]);
19992060 const timelineMessages = useMemo(() => {
2000 const messages = serverMessages ?? [];
2061 const messages = serverMessagesWithAssetUrls ?? [];
20012062 const serverMessagesWithPreviewHandoff =
20022063 Object.keys(attachmentPreviewHandoffByMessageId).length === 0
20032064 ? messages
......@@ -2048,7 +2109,7 @@ export default function ChatView(props: ChatViewProps) {
20482109 return serverMessagesWithPreviewHandoff;
20492110 }
20502111 return [...serverMessagesWithPreviewHandoff, ...pendingMessages];
2051 }, [serverMessages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]);
2112 }, [serverMessagesWithAssetUrls, attachmentPreviewHandoffByMessageId, optimisticUserMessages]);
20522113 const timelineEntries = useMemo(
20532114 () =>
20542115 deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries),
......@@ -4760,6 +4821,7 @@ export default function ChatView(props: ChatViewProps) {
47604821 activeProposedPlan={sidebarProposedPlan}
47614822 label={planSidebarLabel}
47624823 environmentId={environmentId}
4824 threadRef={activeThreadRef}
47634825 markdownCwd={gitCwd ?? undefined}
47644826 workspaceRoot={activeWorkspaceRoot}
47654827 timestampFormat={timestampFormat}
apps/web/src/components/KeybindingsToast.browser.tsx+1-2
......@@ -322,8 +322,7 @@ const worker = setupWorker(
322322 });
323323 }),
324324 ...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth),
325 http.get("*/attachments/:attachmentId", () => new HttpResponse(null, { status: 204 })),
326 http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })),
325 http.get("*/api/assets/*", () => new HttpResponse(null, { status: 204 })),
327326);
328327
329328function sendServerConfigUpdatedPush(issues: ServerConfig["issues"]) {
apps/web/src/components/PlanSidebar.tsx+4-1
......@@ -1,5 +1,5 @@
11import { memo, useState, useCallback } from "react";
2import type { EnvironmentId } from "@t3tools/contracts";
2import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
33import { type TimestampFormat } from "@t3tools/contracts/settings";
44import { Badge } from "./ui/badge";
55import { Button } from "./ui/button";
......@@ -56,6 +56,7 @@ interface PlanSidebarProps {
5656 activeProposedPlan: LatestProposedPlanState | null;
5757 label?: string;
5858 environmentId: EnvironmentId;
59 threadRef?: ScopedThreadRef | undefined;
5960 markdownCwd: string | undefined;
6061 workspaceRoot: string | undefined;
6162 timestampFormat: TimestampFormat;
......@@ -68,6 +69,7 @@ const PlanSidebar = memo(function PlanSidebar({
6869 activeProposedPlan,
6970 label = "Plan",
7071 environmentId,
72 threadRef,
7173 markdownCwd,
7274 workspaceRoot,
7375 timestampFormat,
......@@ -257,6 +259,7 @@ const PlanSidebar = memo(function PlanSidebar({
257259 <ChatMarkdown
258260 text={displayedPlanMarkdown ?? ""}
259261 cwd={markdownCwd}
262 threadRef={threadRef}
260263 isStreaming={false}
261264 />
262265 </div>
apps/web/src/components/ProjectFavicon.tsx+9-13
......@@ -1,7 +1,7 @@
11import type { EnvironmentId } from "@t3tools/contracts";
22import { FolderIcon } from "lucide-react";
3import { useState } from "react";
4import { resolveEnvironmentHttpUrl } from "../environments/runtime";
3import { useEffect, useState } from "react";
4import { useAssetUrl } from "../assets/assetUrls";
55
66const loadedProjectFaviconSrcs = new Set<string>();
77
......@@ -10,20 +10,16 @@ export function ProjectFavicon(input: {
1010 cwd: string;
1111 className?: string;
1212}) {
13 const src = (() => {
14 try {
15 return resolveEnvironmentHttpUrl({
16 environmentId: input.environmentId,
17 pathname: "/api/project-favicon",
18 searchParams: { cwd: input.cwd },
19 });
20 } catch {
21 return null;
22 }
23 })();
13 const src = useAssetUrl(input.environmentId, {
14 _tag: "project-favicon",
15 cwd: input.cwd,
16 });
2417 const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>
2518 src && loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading",
2619 );
20 useEffect(() => {
21 setStatus(src && loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading");
22 }, [src]);
2723
2824 if (!src) {
2925 return (
apps/web/src/components/chat/MessagesTimeline.tsx+11
......@@ -1,9 +1,11 @@
11import {
22 type EnvironmentId,
33 type MessageId,
4 type ScopedThreadRef,
45 type ServerProviderSkill,
56 type TurnId,
67} from "@t3tools/contracts";
8import { parseScopedThreadKey } from "@t3tools/client-runtime";
79import {
810 createContext,
911 Fragment,
......@@ -109,6 +111,7 @@ import {
109111interface TimelineRowSharedState {
110112 timestampFormat: TimestampFormat;
111113 routeThreadKey: string;
114 threadRef: ScopedThreadRef | null;
112115 markdownCwd: string | undefined;
113116 resolvedTheme: "light" | "dark";
114117 workspaceRoot: string | undefined;
......@@ -305,6 +308,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
305308 () => ({
306309 timestampFormat,
307310 routeThreadKey,
311 threadRef: parseScopedThreadKey(routeThreadKey),
308312 markdownCwd,
309313 resolvedTheme,
310314 workspaceRoot,
......@@ -562,6 +566,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
562566 <ChatMarkdown
563567 text={messageText}
564568 cwd={ctx.markdownCwd}
569 threadRef={ctx.threadRef ?? undefined}
565570 isStreaming={Boolean(row.message.streaming)}
566571 skills={ctx.skills}
567572 />
......@@ -625,6 +630,7 @@ function ProposedPlanTimelineRow({
625630 <ProposedPlanCard
626631 planMarkdown={row.proposedPlan.planMarkdown}
627632 environmentId={ctx.activeThreadEnvironmentId}
633 threadRef={ctx.threadRef ?? undefined}
628634 cwd={ctx.markdownCwd}
629635 workspaceRoot={ctx.workspaceRoot}
630636 />
......@@ -1028,6 +1034,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
10281034 skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
10291035 markdownCwd: string | undefined;
10301036}) {
1037 const ctx = use(TimelineRowCtx);
10311038 const renderInlineMarkdownSegment = (text: string, key: string) => {
10321039 const leadingWhitespace = /^\s+/.exec(text)?.[0] ?? "";
10331040 const textWithoutLeadingWhitespace = text.slice(leadingWhitespace.length);
......@@ -1044,6 +1051,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
10441051 <ChatMarkdown
10451052 text={content}
10461053 cwd={props.markdownCwd}
1054 threadRef={ctx.threadRef ?? undefined}
10471055 skills={props.skills}
10481056 className="text-foreground"
10491057 lineBreaks
......@@ -1065,6 +1073,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
10651073 <ChatMarkdown
10661074 text={segment.text.trim()}
10671075 cwd={props.markdownCwd}
1076 threadRef={ctx.threadRef ?? undefined}
10681077 skills={props.skills}
10691078 className="text-foreground"
10701079 lineBreaks
......@@ -1152,6 +1161,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
11521161 key="user-message-terminal-context-inline-text"
11531162 text={props.text}
11541163 cwd={props.markdownCwd}
1164 threadRef={ctx.threadRef ?? undefined}
11551165 skills={props.skills}
11561166 className="text-foreground"
11571167 lineBreaks
......@@ -1176,6 +1186,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
11761186 <ChatMarkdown
11771187 text={props.text}
11781188 cwd={props.markdownCwd}
1189 threadRef={ctx.threadRef ?? undefined}
11791190 skills={props.skills}
11801191 className="text-foreground"
11811192 lineBreaks
apps/web/src/components/chat/ProposedPlanCard.tsx+15-3
......@@ -1,5 +1,5 @@
11import { memo, useState, useId } from "react";
2import type { EnvironmentId } from "@t3tools/contracts";
2import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
33import {
44 buildCollapsedProposedPlanPreviewMarkdown,
55 buildProposedPlanMarkdownFilename,
......@@ -31,11 +31,13 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
3131export const ProposedPlanCard = memo(function ProposedPlanCard({
3232 planMarkdown,
3333 environmentId,
34 threadRef,
3435 cwd,
3536 workspaceRoot,
3637}: {
3738 planMarkdown: string;
3839 environmentId: EnvironmentId;
40 threadRef?: ScopedThreadRef | undefined;
3941 cwd: string | undefined;
4042 workspaceRoot: string | undefined;
4143}) {
......@@ -163,9 +165,19 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
163165 <div className="mt-4">
164166 <div className={cn("relative", canCollapse && !expanded && "max-h-104 overflow-hidden")}>
165167 {canCollapse && !expanded ? (
166 <ChatMarkdown text={collapsedPreview ?? ""} cwd={cwd} isStreaming={false} />
168 <ChatMarkdown
169 text={collapsedPreview ?? ""}
170 cwd={cwd}
171 threadRef={threadRef}
172 isStreaming={false}
173 />
167174 ) : (
168 <ChatMarkdown text={displayedPlanMarkdown} cwd={cwd} isStreaming={false} />
175 <ChatMarkdown
176 text={displayedPlanMarkdown}
177 cwd={cwd}
178 threadRef={threadRef}
179 isStreaming={false}
180 />
169181 )}
170182 {canCollapse && !expanded ? (
171183 <div className="pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-linear-to-t from-card/95 via-card/80 to-transparent" />
apps/web/src/components/preview/PreviewChromeRow.browser.tsx+34
......@@ -38,4 +38,38 @@ describe("PreviewChromeRow", () => {
3838
3939 previouslyFocused.remove();
4040 });
41
42 it("shows a friendly asset label until the URL input receives focus", async () => {
43 const fullUrl = "http://127.0.0.1:3773/api/assets/token/report.pdf";
44 await render(
45 <PreviewChromeRow
46 {...defaultProps}
47 url={fullUrl}
48 displayUrl="Local environment · report.pdf"
49 />,
50 );
51 const input = page.getByRole("textbox");
52
53 await expect.element(input).toHaveValue("Local environment · report.pdf");
54
55 await input.click();
56
57 await expect.element(input).toHaveValue(fullUrl);
58
59 input.element().dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
60
61 await expect.element(input).toHaveValue("Local environment · report.pdf");
62 });
63
64 it("shows only the host for regular URLs until the input receives focus", async () => {
65 const fullUrl = "https://t3.chat/chat/18378834-f776-4507-ada7-6f79";
66 await render(<PreviewChromeRow {...defaultProps} url={fullUrl} displayUrl="t3.chat" />);
67 const input = page.getByRole("textbox");
68
69 await expect.element(input).toHaveValue("t3.chat");
70
71 await input.click();
72
73 await expect.element(input).toHaveValue(fullUrl);
74 });
4175});
apps/web/src/components/preview/PreviewChromeRow.tsx+23-9
......@@ -3,7 +3,6 @@ import {
33 ArrowRight,
44 Camera,
55 ExternalLink,
6 Globe,
76 MousePointerClick,
87 RotateCw,
98} from "lucide-react";
......@@ -23,6 +22,7 @@ import { cn } from "~/lib/utils";
2322
2423interface Props {
2524 url: string;
25 displayUrl?: string | undefined;
2626 loading: boolean;
2727 loadProgress: number;
2828 canGoBack: boolean;
......@@ -61,6 +61,7 @@ const NOOP = () => {};
6161
6262export function PreviewChromeRow({
6363 url,
64 displayUrl,
6465 loading,
6566 loadProgress,
6667 canGoBack,
......@@ -84,6 +85,7 @@ export function PreviewChromeRow({
8485}: Props) {
8586 const inputRef = useRef<HTMLInputElement | null>(null);
8687 const [draft, setDraft] = useState(url);
88 const [inputFocused, setInputFocused] = useState(false);
8789
8890 // Sync the input with external URL changes, but only when the user isn't
8991 // actively typing (preserves in-progress edits during navigation events).
......@@ -96,7 +98,6 @@ export function PreviewChromeRow({
9698 const node = inputRef.current;
9799 if (!node) return;
98100 node.focus();
99 node.select();
100101 }, [focusUrlNonce]);
101102
102103 const submit = (event?: FormEvent | KeyboardEvent) => {
......@@ -167,14 +168,23 @@ export function PreviewChromeRow({
167168 </Tooltip>
168169 </div>
169170
170 <InputGroup className="h-7 flex-1 rounded-md">
171 <InputGroupAddon align="inline-start">
172 <Globe className="size-3.5 text-muted-foreground" aria-hidden />
173 </InputGroupAddon>
171 <InputGroup className="group/address h-7 flex-1 rounded-md border-transparent bg-transparent shadow-none before:shadow-none hover:bg-muted/40 focus-within:bg-background">
174172 <InputGroupInput
175173 ref={inputRef}
176 value={draft}
174 value={inputFocused ? draft : (displayUrl ?? draft)}
175 className={cn(
176 onOpenInBrowser && !inputFocused && "group-hover/address:pe-7 transition-[padding]",
177 )}
177178 onChange={(event) => setDraft(event.target.value)}
179 onFocus={() => {
180 setDraft(url);
181 setInputFocused(true);
182 queueMicrotask(() => inputRef.current?.select());
183 }}
184 onBlur={() => {
185 setDraft(url);
186 setInputFocused(false);
187 }}
178188 onKeyDown={(event) => {
179189 if (event.key === "Enter") submit(event);
180190 if (event.key === "Escape") {
......@@ -187,10 +197,14 @@ export function PreviewChromeRow({
187197 spellCheck={false}
188198 disabled={inputDisabled}
189199 data-preview-url-input
200 title={!inputFocused && displayUrl ? url : undefined}
190201 size="sm"
191202 />
192 {onOpenInBrowser ? (
193 <InputGroupAddon align="inline-end">
203 {onOpenInBrowser && !inputFocused ? (
204 <InputGroupAddon
205 align="inline-end"
206 className="pointer-events-none absolute inset-y-0 right-0 opacity-0 transition-opacity group-hover/address:pointer-events-auto group-hover/address:opacity-100"
207 >
194208 <Tooltip>
195209 <TooltipTrigger
196210 render={
apps/web/src/components/preview/PreviewView.tsx+12
......@@ -14,11 +14,13 @@ import {
1414import { ensureLocalApi } from "~/localApi";
1515import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore";
1616import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";
17import { readEnvironmentConnection } from "~/environments/runtime";
1718
1819import { previewBridge } from "./previewBridge";
1920import { subscribePreviewAction } from "./previewActionBus";
2021import { openPreviewSession } from "./openPreviewSession";
2122import { PreviewChromeRow } from "./PreviewChromeRow";
23import { formatPreviewUrl } from "./previewUrlPresentation";
2224import { PreviewEmptyState } from "./PreviewEmptyState";
2325import { PreviewMoreMenu } from "./PreviewMoreMenu";
2426import { PreviewUnreachable } from "./PreviewUnreachable";
......@@ -87,6 +89,15 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
8789 const showEmptyState = shouldShowPreviewEmptyState(snapshot);
8890 const controller = desktopOverlay?.controller ?? "none";
8991 const loadProgress = useLoadingProgress(loading);
92 const environmentConnection = readEnvironmentConnection(threadRef.environmentId);
93 const displayUrl =
94 url && environmentConnection
95 ? (formatPreviewUrl({
96 url,
97 environmentLabel: environmentConnection.knownEnvironment.label,
98 environmentHttpBaseUrl: environmentConnection.knownEnvironment.target.httpBaseUrl,
99 }) ?? undefined)
100 : undefined;
90101
91102 const handleSubmitUrl = useCallback(
92103 async (next: string) => {
......@@ -501,6 +512,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
501512 >
502513 <PreviewChromeRow
503514 url={url}
515 displayUrl={displayUrl}
504516 loading={loading}
505517 loadProgress={loadProgress}
506518 canGoBack={canGoBack}
apps/web/src/components/preview/previewUrlPresentation.test.ts created+45
......@@ -0,0 +1,45 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { formatPreviewUrl } from "./previewUrlPresentation";
4
5describe("formatPreviewUrl", () => {
6 it("formats signed asset URLs with the environment label and decoded filename", () => {
7 expect(
8 formatPreviewUrl({
9 url: "http://127.0.0.1:3773/api/assets/token/architecture%20brief.pdf",
10 environmentLabel: "Local environment",
11 environmentHttpBaseUrl: "http://127.0.0.1:3773",
12 }),
13 ).toBe("Local environment · architecture brief.pdf");
14 });
15
16 it("does not alias assets from another origin", () => {
17 expect(
18 formatPreviewUrl({
19 url: "https://example.com/api/assets/token/report.pdf",
20 environmentLabel: "Local environment",
21 environmentHttpBaseUrl: "http://127.0.0.1:3773",
22 }),
23 ).toBe("example.com");
24 });
25
26 it("formats regular preview URLs as their exact host", () => {
27 expect(
28 formatPreviewUrl({
29 url: "http://127.0.0.1:5173/dashboard",
30 environmentLabel: "Local environment",
31 environmentHttpBaseUrl: "http://127.0.0.1:3773",
32 }),
33 ).toBe("127.0.0.1:5173");
34 });
35
36 it("does not compact non-http URLs", () => {
37 expect(
38 formatPreviewUrl({
39 url: "file:///tmp/report.pdf",
40 environmentLabel: "Local environment",
41 environmentHttpBaseUrl: "http://127.0.0.1:3773",
42 }),
43 ).toBeNull();
44 });
45});
apps/web/src/components/preview/previewUrlPresentation.ts created+27
......@@ -0,0 +1,27 @@
1interface PreviewUrlPresentationInput {
2 readonly url: string;
3 readonly environmentLabel: string;
4 readonly environmentHttpBaseUrl: string;
5}
6
7export function formatPreviewUrl(input: PreviewUrlPresentationInput): string | null {
8 try {
9 const url = new URL(input.url);
10 const environmentUrl = new URL(input.environmentHttpBaseUrl);
11 if (url.origin === environmentUrl.origin && url.pathname.startsWith("/api/assets/")) {
12 const encodedFileName = url.pathname.split("/").at(-1);
13 if (!encodedFileName) {
14 return null;
15 }
16 const fileName = decodeURIComponent(encodedFileName);
17 if (!fileName || fileName === "." || fileName === "..") {
18 return null;
19 }
20 return `${input.environmentLabel} · ${fileName}`;
21 }
22
23 return url.protocol === "http:" || url.protocol === "https:" ? url.host : null;
24 } catch {
25 return null;
26 }
27}
apps/web/src/environmentApi.ts+3
......@@ -25,6 +25,9 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi {
2525 filesystem: {
2626 browse: rpcClient.filesystem.browse,
2727 },
28 assets: {
29 createUrl: rpcClient.assets.createUrl,
30 },
2831 sourceControl: {
2932 lookupRepository: rpcClient.sourceControl.lookupRepository,
3033 cloneRepository: rpcClient.sourceControl.cloneRepository,
apps/web/src/environments/runtime/service.threadSubscriptions.test.ts+1
......@@ -127,6 +127,7 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => {
127127 filesystem: {
128128 browse: vi.fn(),
129129 },
130 assets: { createUrl: vi.fn() },
130131 sourceControl: {
131132 lookupRepository: vi.fn(),
132133 cloneRepository: vi.fn(),
apps/web/src/store.ts-9
......@@ -34,7 +34,6 @@ import {
3434 type ThreadTurnState,
3535 type TurnDiffSummary,
3636} from "./types";
37import { resolveEnvironmentHttpUrl } from "./environments/runtime";
3837import { sanitizeThreadErrorMessage } from "./rpc/transportError";
3938import { getThreadFromEnvironmentState } from "./threadDerivation";
4039const isProviderDriverKindValue = Schema.is(ProviderDriverKind);
......@@ -173,10 +172,6 @@ function mapMessage(environmentId: EnvironmentId, message: OrchestrationMessage)
173172 name: attachment.name,
174173 mimeType: attachment.mimeType,
175174 sizeBytes: attachment.sizeBytes,
176 previewUrl: resolveEnvironmentHttpUrl({
177 environmentId,
178 pathname: attachmentPreviewRoutePath(attachment.id),
179 }),
180175 }));
181176
182177 return {
......@@ -1042,10 +1037,6 @@ function toLegacyProvider(providerName: string | null): ProviderDriverKind {
10421037 return ProviderDriverKind.make("codex");
10431038}
10441039
1045function attachmentPreviewRoutePath(attachmentId: string): string {
1046 return `/attachments/${encodeURIComponent(attachmentId)}`;
1047}
1048
10491040function updateThreadState(
10501041 state: EnvironmentState,
10511042 threadId: ThreadId,
docs/cloud/t3-code-connect-auth-flow-3-page.html created+592
......@@ -0,0 +1,592 @@
1<!doctype html>
2<html lang="en">
3 <head>
4 <meta charset="utf-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1" />
6 <title>T3 Connect Architecture Brief</title>
7 <style>
8 :root {
9 --ink: #171717;
10 --muted: #62646b;
11 --line: #d8d8dd;
12 --paper: #f5f5f2;
13 --blue: #1457d9;
14 --green: #1d7d4d;
15 --amber: #a65f00;
16 }
17 * {
18 box-sizing: border-box;
19 }
20 @page {
21 size: Letter;
22 margin: 0;
23 }
24 body {
25 margin: 0;
26 color: var(--ink);
27 background: #ddd;
28 font-family:
29 ui-sans-serif,
30 -apple-system,
31 BlinkMacSystemFont,
32 "Segoe UI",
33 sans-serif;
34 line-height: 1.34;
35 }
36 .page {
37 position: relative;
38 width: 8.5in;
39 height: 11in;
40 margin: 20px auto;
41 padding: 0.47in 0.52in 0.42in;
42 overflow: hidden;
43 background:
44 linear-gradient(90deg, rgba(0, 0, 0, 0.028) 1px, transparent 1px),
45 linear-gradient(rgba(0, 0, 0, 0.028) 1px, transparent 1px), var(--paper);
46 background-size: 25px 25px;
47 break-after: page;
48 }
49 .page:last-child {
50 break-after: auto;
51 }
52 .eyebrow {
53 color: var(--blue);
54 font-size: 9px;
55 font-weight: 800;
56 letter-spacing: 0.1em;
57 text-transform: uppercase;
58 }
59 h1 {
60 max-width: 6.9in;
61 margin: 7px 0 10px;
62 font-size: 35px;
63 line-height: 0.98;
64 }
65 h2 {
66 margin: 0 0 8px;
67 font-size: 19px;
68 line-height: 1.08;
69 }
70 h3 {
71 margin: 0 0 4px;
72 font-size: 12px;
73 }
74 p {
75 margin: 0 0 7px;
76 }
77 ul {
78 margin: 5px 0 0;
79 padding-left: 17px;
80 }
81 li + li {
82 margin-top: 3px;
83 }
84 code {
85 border-radius: 3px;
86 background: #ecece8;
87 padding: 1px 3px;
88 font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
89 font-size: 0.9em;
90 }
91 .lede {
92 max-width: 7.1in;
93 color: var(--muted);
94 font-size: 12px;
95 }
96 .rule {
97 height: 2px;
98 margin: 14px 0;
99 background: var(--ink);
100 }
101 .grid {
102 display: grid;
103 grid-template-columns: repeat(12, 1fr);
104 gap: 10px;
105 }
106 .panel {
107 grid-column: span 12;
108 border: 1px solid var(--line);
109 border-radius: 7px;
110 background: rgba(255, 255, 255, 0.94);
111 padding: 12px;
112 font-size: 10px;
113 }
114 .half {
115 grid-column: span 6;
116 }
117 .third {
118 grid-column: span 4;
119 }
120 .callout {
121 margin-top: 8px;
122 border-left: 4px solid var(--blue);
123 background: #eef4ff;
124 padding: 8px 9px;
125 font-size: 10px;
126 }
127 .warning {
128 border-left-color: var(--amber);
129 background: #fff6e6;
130 }
131 .topology {
132 display: grid;
133 grid-template-columns: 1.1fr 34px 1.1fr 34px 1.1fr;
134 align-items: center;
135 gap: 5px;
136 margin-top: 8px;
137 }
138 .node {
139 min-height: 58px;
140 border: 1px solid var(--line);
141 border-top: 4px solid var(--blue);
142 border-radius: 6px;
143 background: white;
144 padding: 8px;
145 text-align: center;
146 font-size: 9px;
147 }
148 .node.green {
149 border-top-color: var(--green);
150 }
151 .arrow {
152 color: var(--muted);
153 text-align: center;
154 font-size: 22px;
155 }
156 .flow {
157 display: grid;
158 grid-template-columns: repeat(4, 1fr);
159 gap: 7px;
160 margin-top: 8px;
161 }
162 .step {
163 position: relative;
164 min-height: 80px;
165 border: 1px solid var(--line);
166 border-left: 4px solid var(--green);
167 border-radius: 6px;
168 background: white;
169 padding: 8px;
170 font-size: 9px;
171 }
172 .step b {
173 display: block;
174 margin-bottom: 3px;
175 font-size: 10px;
176 }
177 table {
178 width: 100%;
179 border-collapse: collapse;
180 font-size: 8.7px;
181 }
182 th,
183 td {
184 border-bottom: 1px solid var(--line);
185 padding: 5px 6px;
186 text-align: left;
187 vertical-align: top;
188 }
189 th {
190 color: var(--muted);
191 font-size: 8px;
192 letter-spacing: 0.05em;
193 text-transform: uppercase;
194 }
195 .footer {
196 position: absolute;
197 right: 0.52in;
198 bottom: 0.18in;
199 color: var(--muted);
200 font-size: 8px;
201 }
202 @media print {
203 body {
204 background: none;
205 }
206 .page {
207 margin: 0;
208 }
209 }
210 </style>
211 </head>
212 <body>
213 <section class="page">
214 <div class="eyebrow">Architecture brief · page 1 of 3</div>
215 <h1>T3 Connect Control Plane and Managed Endpoint Flow</h1>
216 <p class="lede">
217 T3 Connect links a locally authorized T3 environment to a signed-in cloud user, provisions a
218 managed HTTPS/WSS endpoint, and brokers proof-bound remote connections. The environment
219 remains the access authority; the relay coordinates identity, endpoint lifecycle, and
220 notifications.
221 </p>
222 <div class="rule"></div>
223
224 <div class="grid">
225 <article class="panel half">
226 <h2>Security Invariant</h2>
227 <p>Cloud identity alone is not an environment login. Remote access requires:</p>
228 <ul>
229 <li>a Clerk-authenticated T3 Connect user;</li>
230 <li>an active user/environment relay link;</li>
231 <li>a DPoP private key held by the remote client;</li>
232 <li>a relay-signed mint request accepted locally; and</li>
233 <li>an environment token bound to the client's DPoP key.</li>
234 </ul>
235 <div class="callout">
236 The relay cannot mint an environment access token by itself. The local environment
237 issues the bootstrap credential and final access token.
238 </div>
239 </article>
240 <article class="panel half">
241 <h2>Trust Boundaries</h2>
242 <ul>
243 <li><b>Clerk:</b> user identity and OAuth/PKCE.</li>
244 <li><b>Relay Worker:</b> links, endpoint allocations, proofs, and notifications.</li>
245 <li><b>Environment:</b> local sessions, signing keys, and access tokens.</li>
246 <li><b>Relay client:</b> exposes only the validated loopback HTTP server.</li>
247 <li><b>Remote client:</b> retains the DPoP private key and environment token.</li>
248 </ul>
249 <div class="callout warning">
250 The relay remains a trusted mint broker because it holds the cloud-mint signing key.
251 DPoP limits credential reuse but does not neutralize compromise of that authority.
252 </div>
253 </article>
254
255 <article class="panel">
256 <h2>Current Topology</h2>
257 <div class="topology">
258 <div class="node">
259 <h3>Clients</h3>
260 Desktop web UI<br />Mobile app<br />Headless CLI<br /><br />
261 <b>Clerk bearer / relay DPoP</b>
262 </div>
263 <div class="arrow">→</div>
264 <div class="node">
265 <h3>T3 Code Relay</h3>
266 Cloudflare Worker<br />Hyperdrive + Postgres<br />Tunnel/DNS APIs<br />APNs queue
267 </div>
268 <div class="arrow">→</div>
269 <div class="node green">
270 <h3>Linked Environment</h3>
271 Managed HTTPS/WSS endpoint<br />Relay client<br />Loopback server<br />Local secrets
272 </div>
273 </div>
274 <div class="callout">
275 Steady-state HTTP and WebSocket traffic goes directly from the remote client through the
276 managed endpoint. The relay handles sparse health and bootstrap requests, not the active
277 session data path.
278 </div>
279 </article>
280
281 <article class="panel">
282 <h2>Authentication Boundaries</h2>
283 <table>
284 <thead>
285 <tr>
286 <th>Boundary</th>
287 <th>Authentication</th>
288 <th>Purpose</th>
289 </tr>
290 </thead>
291 <tbody>
292 <tr>
293 <td>Client → relay link management</td>
294 <td>Clerk bearer / CLI OAuth</td>
295 <td>Create, list, or remove links.</td>
296 </tr>
297 <tr>
298 <td>Client → protected relay APIs</td>
299 <td>Relay DPoP token + proof</td>
300 <td>Status, connect, and mobile registration.</td>
301 </tr>
302 <tr>
303 <td>Relay → environment</td>
304 <td>Short-lived relay-signed JWT</td>
305 <td>Signed health and credential-mint requests.</td>
306 </tr>
307 <tr>
308 <td>Client → environment</td>
309 <td>Environment token + DPoP</td>
310 <td>Normal HTTPS APIs and WSS ticket issuance.</td>
311 </tr>
312 <tr>
313 <td>Environment → relay</td>
314 <td>Environment bearer + signed proof</td>
315 <td>Publish redacted agent activity.</td>
316 </tr>
317 </tbody>
318 </table>
319 </article>
320 </div>
321 <div class="footer">T3 Connect Architecture Brief · 1</div>
322 </section>
323
324 <section class="page">
325 <div class="eyebrow">Core workflows · page 2 of 3</div>
326 <h1>Link, Bootstrap, and Operate</h1>
327 <p class="lede">
328 Every workflow preserves local authority while making cloud coordination retry-safe and
329 suitable for desktop, mobile, and headless use.
330 </p>
331 <div class="rule"></div>
332
333 <div class="grid">
334 <article class="panel">
335 <h2>1. Link a Local Environment</h2>
336 <div class="flow">
337 <div class="step">
338 <b>Authenticate</b>Desktop/mobile uses a Clerk bearer. CLI uses browser PKCE and
339 persists desired link state.
340 </div>
341 <div class="step">
342 <b>Prove locally</b>Relay issues a short-lived challenge. The local environment
343 validates loopback origin and signs it.
344 </div>
345 <div class="step">
346 <b>Reconcile</b>Relay verifies proof and capabilities, then creates or reuses the
347 tunnel, ingress, and CNAME.
348 </div>
349 <div class="step">
350 <b>Activate</b>Relay returns endpoint config and connector token. Environment persists
351 config and starts the relay client.
352 </div>
353 </div>
354 <div class="callout">
355 The local proof endpoint requires <code>relay:write</code>, rejects forwarded authority
356 headers, and atomically persists the environment key pair.
357 </div>
358 </article>
359
360 <article class="panel">
361 <h2>2. Remote Connection Bootstrap</h2>
362 <div class="flow">
363 <div class="step">
364 <b>Proof key</b>Client creates a DPoP key and exchanges its Clerk JWT for a relay DPoP
365 access token.
366 </div>
367 <div class="step">
368 <b>Mint request</b>Client calls connect. Relay sends an audience-bound, short-lived
369 signed proof to the managed endpoint.
370 </div>
371 <div class="step">
372 <b>Local issue</b>Environment validates user, scope, lifetime, nonce, and DPoP
373 thumbprint, then returns a signed bootstrap credential.
374 </div>
375 <div class="step">
376 <b>Direct session</b>Client redeems locally for an environment token, gets a one-time
377 WSS ticket, and opens the WebSocket.
378 </div>
379 </div>
380 <div class="callout">
381 The relay validates the environment response signature, nonce, endpoint, and DPoP
382 binding before returning bootstrap data to the client.
383 </div>
384 </article>
385
386 <article class="panel half">
387 <h2>Managed Endpoint Lifecycle</h2>
388 <ol>
389 <li>
390 Reserve deterministic hostname and tunnel name for
391 <code>(userId, environmentId)</code>.
392 </li>
393 <li>
394 Reuse or create the named tunnel; rewrite ingress to the validated loopback origin.
395 </li>
396 <li>
397 Reconcile CNAME records and remove duplicates; recover safely from create conflicts.
398 </li>
399 <li>Mark ready only after resource IDs and namespace checks are persisted.</li>
400 </ol>
401 </article>
402 <article class="panel half">
403 <h2>Unlink Cleanup</h2>
404 <ul>
405 <li>Delete managed DNS and tunnel resources.</li>
406 <li>Remove the allocation and revoke the user link.</li>
407 <li>Revoke publication credentials after the final active link disappears.</li>
408 <li>Stop the local relay client and clear persisted relay configuration.</li>
409 <li>Retain checkpoints when teardown fails so cleanup can be retried.</li>
410 </ul>
411 </article>
412
413 <article class="panel">
414 <h2>3. Agent Activity Notifications</h2>
415 <div class="flow">
416 <div class="step">
417 <b>Project</b>Environment creates a narrow publishable thread state and redacts/caps
418 failure details.
419 </div>
420 <div class="step">
421 <b>Sign</b>Environment posts with its bearer credential and a per-state signed proof.
422 </div>
423 <div class="step">
424 <b>Persist</b>Relay checks credential, signature, scope, expiry, and nonce, then
425 stores current state.
426 </div>
427 <div class="step">
428 <b>Deliver</b>Queue workers send push notifications or Live Activity updates through
429 APNs.
430 </div>
431 </div>
432 </article>
433 </div>
434 <div class="footer">T3 Connect Architecture Brief · 2</div>
435 </section>
436
437 <section class="page">
438 <div class="eyebrow">Controls and operations · page 3 of 3</div>
439 <h1>Hardening and Deployment Contract</h1>
440 <p class="lede">
441 The design constrains tunnel origins, proof audiences, egress destinations, redirects,
442 credential exposure, and teardown behavior.
443 </p>
444 <div class="rule"></div>
445
446 <div class="grid">
447 <article class="panel">
448 <h2>SSRF and Tunnel Controls</h2>
449 <table>
450 <thead>
451 <tr>
452 <th>Risk</th>
453 <th>Implemented control</th>
454 </tr>
455 </thead>
456 <tbody>
457 <tr>
458 <td>Arbitrary tunnel upstream</td>
459 <td>Both sides accept managed origins only for loopback hosts and valid ports.</td>
460 </tr>
461 <tr>
462 <td>Forwarded-origin confusion</td>
463 <td>
464 Link proof requires the exact loopback URL and rejects forwarded host/protocol
465 headers.
466 </td>
467 </tr>
468 <tr>
469 <td>Stored endpoint replacement</td>
470 <td>
471 Status/connect resolve ready allocations from Postgres and require the managed DNS
472 namespace.
473 </td>
474 </tr>
475 <tr>
476 <td>Redirected relay egress</td>
477 <td>Relay health and mint requests use manual redirect handling.</td>
478 </tr>
479 <tr>
480 <td>Impersonated tunnel backend</td>
481 <td>Relay verifies signed environment responses, nonce, and request binding.</td>
482 </tr>
483 <tr>
484 <td>Stolen access token replay</td>
485 <td>Relay and environment tokens require DPoP proofs and replay guards.</td>
486 </tr>
487 <tr>
488 <td>Connector token exposure</td>
489 <td>
490 Relay client is spawned without a shell and receives the token in
491 <code>TUNNEL_TOKEN</code>.
492 </td>
493 </tr>
494 </tbody>
495 </table>
496 </article>
497
498 <article class="panel half">
499 <h2>Credential Ownership</h2>
500 <ul>
501 <li>Environment Ed25519 private key stays local.</li>
502 <li>
503 Cloud-mint private key stays in hosted relay config; only its public key is installed
504 locally.
505 </li>
506 <li>
507 Relay environment bearer is hashed in Postgres and stored plaintext only locally.
508 </li>
509 <li>Remote DPoP private keys and environment access tokens remain client-side.</li>
510 <li>WebSocket tickets are short-lived and one-time.</li>
511 </ul>
512 </article>
513 <article class="panel half">
514 <h2>Operational Profile</h2>
515 <ul>
516 <li>One named tunnel and DNS record per active user/environment allocation.</li>
517 <li>HTTPS request/response and WSS only; no arbitrary TCP exposure.</li>
518 <li>Relay traffic is limited to health and bootstrap control calls.</li>
519 <li>
520 Active sessions are interactive and WebSocket-heavy; inactive links are nearly idle.
521 </li>
522 <li>
523 Dedicated registrable tunnel domain and Public Suffix List entry are required before
524 broad untrusted use.
525 </li>
526 </ul>
527 </article>
528
529 <article class="panel">
530 <h2>Deployment and Runtime</h2>
531 <table>
532 <thead>
533 <tr>
534 <th>Layer</th>
535 <th>Contract</th>
536 </tr>
537 </thead>
538 <tbody>
539 <tr>
540 <td>Relay</td>
541 <td>
542 Cloudflare Worker, tunnel/DNS bindings, Hyperdrive, PlanetScale Postgres, APNs
543 queue + DLQ, replay-row pruning cron, and Axiom OTLP traces.
544 </td>
545 </tr>
546 <tr>
547 <td>Environment</td>
548 <td>
549 Loopback server, atomic secret store, relay-client lifecycle, signed health/mint
550 responses, OAuth exchange, and WebSocket tickets.
551 </td>
552 </tr>
553 <tr>
554 <td>Clients</td>
555 <td>
556 Secure relay URL, Clerk configuration, DPoP key management, environment token
557 storage, and direct managed-endpoint traffic.
558 </td>
559 </tr>
560 </tbody>
561 </table>
562 </article>
563
564 <article class="panel">
565 <h2>Release Gate</h2>
566 <div class="grid">
567 <div class="third">
568 <h3>Identity</h3>
569 Keep private signing keys in their owning trust domain. Validate issuer, audience,
570 scope, expiry, nonce, and proof-key binding.
571 </div>
572 <div class="third">
573 <h3>Network</h3>
574 Permit loopback tunnel origins only. Resolve relay egress from ready allocations and
575 disable redirects.
576 </div>
577 <div class="third">
578 <h3>Lifecycle</h3>
579 Make provisioning idempotent, preserve failed-cleanup checkpoints, and revoke shared
580 credentials only after the final link disappears.
581 </div>
582 </div>
583 <div class="callout warning">
584 Standards basis: RFC 8252 for native-app OAuth, RFC 8693 for token exchange, and RFC
585 9449 for DPoP proof of possession.
586 </div>
587 </article>
588 </div>
589 <div class="footer">T3 Connect Architecture Brief · 3</div>
590 </section>
591 </body>
592</html>
docs/cloud/t3-code-connect-auth-flow.pdf created
Binary files /dev/null and b/docs/cloud/t3-code-connect-auth-flow.pdf differ
packages/client-runtime/src/wsRpcClient.ts+7
......@@ -102,6 +102,9 @@ export interface WsRpcClient {
102102 readonly filesystem: {
103103 readonly browse: RpcUnaryMethod<typeof WS_METHODS.filesystemBrowse>;
104104 };
105 readonly assets: {
106 readonly createUrl: RpcUnaryMethod<typeof WS_METHODS.assetsCreateUrl>;
107 };
105108 readonly sourceControl: {
106109 readonly lookupRepository: RpcUnaryMethod<typeof WS_METHODS.sourceControlLookupRepository>;
107110 readonly cloneRepository: RpcUnaryMethod<typeof WS_METHODS.sourceControlCloneRepository>;
......@@ -272,6 +275,10 @@ export function createWsRpcClient(
272275 filesystem: {
273276 browse: (input) => transport.request((client) => client[WS_METHODS.filesystemBrowse](input)),
274277 },
278 assets: {
279 createUrl: (input) =>
280 transport.request((client) => client[WS_METHODS.assetsCreateUrl](input)),
281 },
275282 sourceControl: {
276283 lookupRepository: (input) =>
277284 transport.request((client) => client[WS_METHODS.sourceControlLookupRepository](input)),
packages/contracts/src/assets.ts created+38
......@@ -0,0 +1,38 @@
1import * as Schema from "effect/Schema";
2
3import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
4
5const ASSET_PATH_MAX_LENGTH = 1024;
6
7export const AssetResource = Schema.Union([
8 Schema.TaggedStruct("workspace-file", {
9 threadId: ThreadId,
10 path: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
11 }),
12 Schema.TaggedStruct("attachment", {
13 attachmentId: TrimmedNonEmptyString.check(Schema.isMaxLength(256)),
14 }),
15 Schema.TaggedStruct("project-favicon", {
16 cwd: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
17 }),
18]);
19export type AssetResource = typeof AssetResource.Type;
20
21export const AssetCreateUrlInput = Schema.Struct({
22 resource: AssetResource,
23});
24export type AssetCreateUrlInput = typeof AssetCreateUrlInput.Type;
25
26export const AssetCreateUrlResult = Schema.Struct({
27 relativeUrl: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)),
28 expiresAt: Schema.Number,
29});
30export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type;
31
32export class AssetAccessError extends Schema.TaggedErrorClass<AssetAccessError>()(
33 "AssetAccessError",
34 {
35 message: TrimmedNonEmptyString,
36 cause: Schema.optional(Schema.Defect()),
37 },
38) {}
packages/contracts/src/index.ts+1
......@@ -21,6 +21,7 @@ export * from "./orchestration.ts";
2121export * from "./editor.ts";
2222export * from "./project.ts";
2323export * from "./filesystem.ts";
24export * from "./assets.ts";
2425export * from "./review.ts";
2526export * from "./preview.ts";
2627export * from "./previewAutomation.ts";
packages/contracts/src/ipc.ts+4
......@@ -20,6 +20,7 @@ import type {
2020} from "./git.ts";
2121import type { ReviewDiffPreviewInput, ReviewDiffPreviewResult } from "./review.ts";
2222import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts";
23import type { AssetCreateUrlInput, AssetCreateUrlResult } from "./assets.ts";
2324import type {
2425 ProjectSearchEntriesInput,
2526 ProjectSearchEntriesResult,
......@@ -1115,6 +1116,9 @@ export interface EnvironmentApi {
11151116 filesystem: {
11161117 browse: (input: FilesystemBrowseInput) => Promise<FilesystemBrowseResult>;
11171118 };
1119 assets: {
1120 createUrl: (input: AssetCreateUrlInput) => Promise<AssetCreateUrlResult>;
1121 };
11181122 sourceControl: {
11191123 lookupRepository: (
11201124 input: SourceControlRepositoryLookupInput,
packages/contracts/src/rpc.ts+9
......@@ -13,6 +13,7 @@ import {
1313 FilesystemBrowseResult,
1414 FilesystemBrowseError,
1515} from "./filesystem.ts";
16import { AssetAccessError, AssetCreateUrlInput, AssetCreateUrlResult } from "./assets.ts";
1617import {
1718 GitActionProgressEvent,
1819 VcsSwitchRefInput,
......@@ -148,6 +149,7 @@ export const WS_METHODS = {
148149
149150 // Filesystem methods
150151 filesystemBrowse: "filesystem.browse",
152 assetsCreateUrl: "assets.createUrl",
151153
152154 // VCS methods
153155 vcsPull: "vcs.pull",
......@@ -365,6 +367,12 @@ export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, {
365367 error: Schema.Union([FilesystemBrowseError, EnvironmentAuthorizationError]),
366368});
367369
370export const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, {
371 payload: AssetCreateUrlInput,
372 success: AssetCreateUrlResult,
373 error: Schema.Union([AssetAccessError, EnvironmentAuthorizationError]),
374});
375
368376export const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, {
369377 payload: VcsStatusInput,
370378 success: VcsStatusStreamEvent,
......@@ -672,6 +680,7 @@ export const WsRpcGroup = RpcGroup.make(
672680 WsProjectsWriteFileRpc,
673681 WsShellOpenInEditorRpc,
674682 WsFilesystemBrowseRpc,
683 WsAssetsCreateUrlRpc,
675684 WsSubscribeVcsStatusRpc,
676685 WsVcsPullRpc,
677686 WsVcsRefreshStatusRpc,