| author | |
| committer | |
| log | 85d5df625652906937d5ac330205b1042dc9354f |
| tree | 8db1b0aa166b6edbcdb2650d0686c6e7c425a305 |
| parent | b5b769dddfe935650b48473dd5b55f2c099abdd8 |
- Replace attachment and favicon routes with signed asset URLs
- Harden workspace and attachment asset resolution
- Update browser preview components and shared contracts35 files changed, 1779 insertions(+), 328 deletions(-)
apps/server/src/assets/AssetAccess.test.ts created+150| ... | @@ -0,0 +1,150 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { ThreadId } from "@t3tools/contracts"; | ||
| 3 | import { describe, expect, it } from "@effect/vitest"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as FileSystem from "effect/FileSystem"; | ||
| 6 | import * as Layer from "effect/Layer"; | ||
| 7 | import * as Path from "effect/Path"; | ||
| 8 | |||
| 9 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | ||
| 10 | import { ServerConfig } from "../config.ts"; | ||
| 11 | import { ProjectFaviconResolverLive } from "../project/Layers/ProjectFaviconResolver.ts"; | ||
| 12 | import { WorkspacePathsLive } from "../workspace/Layers/WorkspacePaths.ts"; | ||
| 13 | import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts"; | ||
| 14 | |||
| 15 | const configLayer = ServerConfig.layerTest(process.cwd(), { | ||
| 16 | prefix: "t3-asset-access-test-", | ||
| 17 | }); | ||
| 18 | const 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 | |||
| 25 | describe("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 @@ | ||
| 1 | import type { AssetResource } from "@t3tools/contracts"; | ||
| 2 | import { AssetAccessError } from "@t3tools/contracts"; | ||
| 3 | import * as Clock from "effect/Clock"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as FileSystem from "effect/FileSystem"; | ||
| 6 | import * as Option from "effect/Option"; | ||
| 7 | import * as Path from "effect/Path"; | ||
| 8 | import * as Schema from "effect/Schema"; | ||
| 9 | |||
| 10 | import { | ||
| 11 | base64UrlDecodeUtf8, | ||
| 12 | base64UrlEncode, | ||
| 13 | signPayload, | ||
| 14 | timingSafeEqualBase64Url, | ||
| 15 | } from "../auth/utils.ts"; | ||
| 16 | import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; | ||
| 17 | import { resolveAttachmentPathById } from "../attachmentStore.ts"; | ||
| 18 | import { ServerConfig } from "../config.ts"; | ||
| 19 | import { ProjectFaviconResolver } from "../project/Services/ProjectFaviconResolver.ts"; | ||
| 20 | import { WorkspacePaths } from "../workspace/Services/WorkspacePaths.ts"; | ||
| 21 | |||
| 22 | export const ASSET_ROUTE_PREFIX = "/api/assets"; | ||
| 23 | export 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 | |||
| 25 | const SIGNING_SECRET_NAME = "asset-access-signing-key"; | ||
| 26 | const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; | ||
| 27 | const PREVIEWABLE_EXTENSIONS = new Set([".htm", ".html", ".pdf"]); | ||
| 28 | const 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 | |||
| 47 | const 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 | ]); | ||
| 69 | type AssetClaims = typeof AssetClaimsSchema.Type; | ||
| 70 | |||
| 71 | const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema); | ||
| 72 | const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson); | ||
| 73 | const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson); | ||
| 74 | |||
| 75 | export type ResolvedAsset = | ||
| 76 | | { readonly kind: "file"; readonly path: string } | ||
| 77 | | { readonly kind: "project-favicon-fallback" }; | ||
| 78 | |||
| 79 | function decodeClaims(encodedPayload: string): AssetClaims | null { | ||
| 80 | try { | ||
| 81 | return Option.getOrNull(decodeAssetClaims(base64UrlDecodeUtf8(encodedPayload))); | ||
| 82 | } catch { | ||
| 83 | return null; | ||
| 84 | } | ||
| 85 | } | ||
| 86 | |||
| 87 | function decodeRelativePath(value: string): string | null { | ||
| 88 | try { | ||
| 89 | return decodeURIComponent(value); | ||
| 90 | } catch { | ||
| 91 | return null; | ||
| 92 | } | ||
| 93 | } | ||
| 94 | |||
| 95 | const failAccess = (message: string, cause?: unknown) => | ||
| 96 | new AssetAccessError({ message, ...(cause === undefined ? {} : { cause }) }); | ||
| 97 | |||
| 98 | const 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 | |||
| 122 | export 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 | |||
| 226 | export 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 @@ | ... | @@ -1,8 +1,6 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | 1 | // @effect-diagnostics nodeBuiltinImport:off |
| 2 | import NodePath from "node:path"; | 2 | import NodePath from "node:path"; |
| 3 | 3 | ||
| 4 | export const ATTACHMENTS_ROUTE_PREFIX = "/attachments"; | ||
| 5 | |||
| 6 | export function normalizeAttachmentRelativePath(rawRelativePath: string): string | null { | 4 | export function normalizeAttachmentRelativePath(rawRelativePath: string): string | null { |
| 7 | const normalized = NodePath.normalize(rawRelativePath).replace(/^[/\\]+/, ""); | 5 | const normalized = NodePath.normalize(rawRelativePath).replace(/^[/\\]+/, ""); |
| 8 | if (normalized.length === 0 || normalized.startsWith("..") || normalized.includes("\0")) { | 6 | if (normalized.length === 0 || normalized.startsWith("..") || normalized.includes("\0")) { |
apps/server/src/http.ts+22-83| ... | @@ -24,15 +24,13 @@ import { | ... | @@ -24,15 +24,13 @@ import { |
| 24 | import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; | 24 | import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; |
| 25 | import { OtlpTracer } from "effect/unstable/observability"; | 25 | import { OtlpTracer } from "effect/unstable/observability"; |
| 26 | 26 | ||
| 27 | import { | ||
| 28 | ATTACHMENTS_ROUTE_PREFIX, | ||
| 29 | normalizeAttachmentRelativePath, | ||
| 30 | resolveAttachmentRelativePath, | ||
| 31 | } from "./attachmentPaths.ts"; | ||
| 32 | import { resolveAttachmentPathById } from "./attachmentStore.ts"; | ||
| 33 | import { resolveStaticDir, ServerConfig } from "./config.ts"; | 27 | import { resolveStaticDir, ServerConfig } from "./config.ts"; |
| 28 | import { | ||
| 29 | ASSET_ROUTE_PREFIX, | ||
| 30 | FALLBACK_PROJECT_FAVICON_SVG, | ||
| 31 | resolveAsset, | ||
| 32 | } from "./assets/AssetAccess.ts"; | ||
| 34 | import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts"; | 33 | import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts"; |
| 35 | import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts"; | ||
| 36 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; | 34 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| 37 | import { | 35 | import { |
| 38 | annotateEnvironmentRequest, | 36 | annotateEnvironmentRequest, |
| ... | @@ -43,8 +41,6 @@ import { | ... | @@ -43,8 +41,6 @@ import { |
| 43 | import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; | 41 | import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; |
| 44 | import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts"; | 42 | import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts"; |
| 45 | 43 | ||
| 46 | const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600"; | ||
| 47 | 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>`; | ||
| 48 | const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; | 44 | const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; |
| 49 | const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); | 45 | const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); |
| 50 | 46 | ||
| ... | @@ -169,107 +165,50 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( | ... | @@ -169,107 +165,50 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( |
| 169 | ), | 165 | ), |
| 170 | ); | 166 | ); |
| 171 | 167 | ||
| 172 | export const attachmentsRouteLayer = HttpRouter.add( | 168 | export const assetRouteLayer = HttpRouter.add( |
| 173 | "GET", | 169 | "GET", |
| 174 | `${ATTACHMENTS_ROUTE_PREFIX}/*`, | 170 | `${ASSET_ROUTE_PREFIX}/*`, |
| 175 | Effect.gen(function* () { | 171 | Effect.gen(function* () { |
| 176 | yield* authenticateRawRouteWithScope(AuthOrchestrationReadScope); | ||
| 177 | const request = yield* HttpServerRequest.HttpServerRequest; | 172 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 178 | const url = HttpServerRequest.toURL(request); | 173 | const url = HttpServerRequest.toURL(request); |
| 179 | if (Option.isNone(url)) { | 174 | if (Option.isNone(url)) { |
| 180 | return HttpServerResponse.text("Bad Request", { status: 400 }); | 175 | return HttpServerResponse.text("Bad Request", { status: 400 }); |
| 181 | } | 176 | } |
| 182 | 177 | ||
| 183 | const config = yield* ServerConfig; | 178 | const suffix = url.value.pathname.slice(`${ASSET_ROUTE_PREFIX}/`.length); |
| 184 | const rawRelativePath = url.value.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length); | 179 | const separatorIndex = suffix.indexOf("/"); |
| 185 | const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath); | 180 | if (separatorIndex <= 0) { |
| 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") { | ||
| 210 | return HttpServerResponse.text("Not Found", { status: 404 }); | 181 | return HttpServerResponse.text("Not Found", { status: 404 }); |
| 211 | } | 182 | } |
| 212 | 183 | ||
| 213 | return yield* HttpServerResponse.file(filePath, { | 184 | const asset = yield* resolveAsset( |
| 214 | status: 200, | 185 | suffix.slice(0, separatorIndex), |
| 215 | headers: { | 186 | suffix.slice(separatorIndex + 1), |
| 216 | "Cache-Control": "public, max-age=31536000, immutable", | ||
| 217 | }, | ||
| 218 | }).pipe( | ||
| 219 | Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })), | ||
| 220 | ); | 187 | ); |
| 221 | }).pipe( | 188 | if (!asset) { |
| 222 | Effect.catchTags({ | 189 | return HttpServerResponse.text("Not Found", { status: 404 }); |
| 223 | EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, | ||
| 224 | EnvironmentInternalError: HttpServerRespondable.toResponse, | ||
| 225 | EnvironmentScopeRequiredError: HttpServerRespondable.toResponse, | ||
| 226 | }), | ||
| 227 | ), | ||
| 228 | ); | ||
| 229 | |||
| 230 | export 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 }); | ||
| 244 | } | 190 | } |
| 245 | 191 | if (asset.kind === "project-favicon-fallback") { | |
| 246 | const faviconResolver = yield* ProjectFaviconResolver; | ||
| 247 | const faviconFilePath = yield* faviconResolver.resolvePath(projectCwd); | ||
| 248 | if (!faviconFilePath) { | ||
| 249 | return HttpServerResponse.text(FALLBACK_PROJECT_FAVICON_SVG, { | 192 | return HttpServerResponse.text(FALLBACK_PROJECT_FAVICON_SVG, { |
| 250 | status: 200, | 193 | status: 200, |
| 251 | contentType: "image/svg+xml", | 194 | contentType: "image/svg+xml", |
| 252 | headers: { | 195 | headers: { |
| 253 | "Cache-Control": PROJECT_FAVICON_CACHE_CONTROL, | 196 | "Cache-Control": "private, max-age=3600", |
| 197 | "X-Content-Type-Options": "nosniff", | ||
| 254 | }, | 198 | }, |
| 255 | }); | 199 | }); |
| 256 | } | 200 | } |
| 257 | 201 | ||
| 258 | return yield* HttpServerResponse.file(faviconFilePath, { | 202 | return yield* HttpServerResponse.file(asset.path, { |
| 259 | status: 200, | 203 | status: 200, |
| 260 | headers: { | 204 | headers: { |
| 261 | "Cache-Control": PROJECT_FAVICON_CACHE_CONTROL, | 205 | "Cache-Control": "private, max-age=3600", |
| 206 | "X-Content-Type-Options": "nosniff", | ||
| 262 | }, | 207 | }, |
| 263 | }).pipe( | 208 | }).pipe( |
| 264 | Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })), | 209 | Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })), |
| 265 | ); | 210 | ); |
| 266 | }).pipe( | 211 | }), |
| 267 | Effect.catchTags({ | ||
| 268 | EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, | ||
| 269 | EnvironmentInternalError: HttpServerRespondable.toResponse, | ||
| 270 | EnvironmentScopeRequiredError: HttpServerRespondable.toResponse, | ||
| 271 | }), | ||
| 272 | ), | ||
| 273 | ); | 212 | ); |
| 274 | 213 | ||
| 275 | export const staticAndDevRouteLayer = HttpRouter.add( | 214 | export const staticAndDevRouteLayer = HttpRouter.add( |
apps/server/src/project/Layers/ProjectFaviconResolver.test.ts+2-1| ... | @@ -7,9 +7,10 @@ import * as Path from "effect/Path"; | ... | @@ -7,9 +7,10 @@ import * as Path from "effect/Path"; |
| 7 | 7 | ||
| 8 | import { ProjectFaviconResolver } from "../Services/ProjectFaviconResolver.ts"; | 8 | import { ProjectFaviconResolver } from "../Services/ProjectFaviconResolver.ts"; |
| 9 | import { ProjectFaviconResolverLive } from "./ProjectFaviconResolver.ts"; | 9 | import { ProjectFaviconResolverLive } from "./ProjectFaviconResolver.ts"; |
| 10 | import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts"; | ||
| 10 | 11 | ||
| 11 | const TestLayer = Layer.empty.pipe( | 12 | const TestLayer = Layer.empty.pipe( |
| 12 | Layer.provideMerge(ProjectFaviconResolverLive), | 13 | Layer.provideMerge(ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive))), |
| 13 | Layer.provideMerge(NodeServices.layer), | 14 | Layer.provideMerge(NodeServices.layer), |
| 14 | ); | 15 | ); |
| 15 | 16 |
apps/server/src/project/Layers/ProjectFaviconResolver.ts+35-17| ... | @@ -7,6 +7,7 @@ import { | ... | @@ -7,6 +7,7 @@ import { |
| 7 | ProjectFaviconResolver, | 7 | ProjectFaviconResolver, |
| 8 | type ProjectFaviconResolverShape, | 8 | type ProjectFaviconResolverShape, |
| 9 | } from "../Services/ProjectFaviconResolver.ts"; | 9 | } from "../Services/ProjectFaviconResolver.ts"; |
| 10 | import { WorkspacePaths } from "../../workspace/Services/WorkspacePaths.ts"; | ||
| 10 | 11 | ||
| 11 | // Well-known favicon paths checked in order. | 12 | // Well-known favicon paths checked in order. |
| 12 | const FAVICON_CANDIDATES = [ | 13 | const FAVICON_CANDIDATES = [ |
| ... | @@ -61,28 +62,32 @@ function extractIconHref(source: string): string | null { | ... | @@ -61,28 +62,32 @@ function extractIconHref(source: string): string | null { |
| 61 | export const makeProjectFaviconResolver = Effect.gen(function* () { | 62 | export const makeProjectFaviconResolver = Effect.gen(function* () { |
| 62 | const fileSystem = yield* FileSystem.FileSystem; | 63 | const fileSystem = yield* FileSystem.FileSystem; |
| 63 | const path = yield* Path.Path; | 64 | const path = yield* Path.Path; |
| 65 | const workspacePaths = yield* WorkspacePaths; | ||
| 64 | 66 | ||
| 65 | const resolveIconHref = (projectCwd: string, href: string): string[] => { | 67 | const resolveIconHref = (href: string): string[] => { |
| 66 | const clean = href.replace(/^\//, ""); | 68 | const clean = href.replace(/^\//, ""); |
| 67 | return [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)]; | 69 | return [path.join("public", clean), 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)); | ||
| 73 | }; | 70 | }; |
| 74 | 71 | ||
| 75 | const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* ( | 72 | const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* ( |
| 76 | projectCwd: string, | 73 | projectCwd: string, |
| 77 | candidates: ReadonlyArray<string>, | 74 | relativeCandidates: ReadonlyArray<string>, |
| 78 | ): Effect.fn.Return<string | null> { | 75 | ): Effect.fn.Return<string | null> { |
| 79 | for (const candidate of candidates) { | 76 | for (const relativePath of relativeCandidates) { |
| 80 | if (!isPathWithinProject(projectCwd, candidate)) { | 77 | const candidate = yield* workspacePaths |
| 78 | .resolveRelativePathWithinRoot({ | ||
| 79 | workspaceRoot: projectCwd, | ||
| 80 | relativePath, | ||
| 81 | }) | ||
| 82 | .pipe(Effect.orElseSucceed(() => null)); | ||
| 83 | if (!candidate) { | ||
| 81 | continue; | 84 | continue; |
| 82 | } | 85 | } |
| 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)); | ||
| 84 | if (stats?.type === "File") { | 89 | if (stats?.type === "File") { |
| 85 | return candidate; | 90 | return candidate.absolutePath; |
| 86 | } | 91 | } |
| 87 | } | 92 | } |
| 88 | return null; | 93 | return null; |
| ... | @@ -91,18 +96,31 @@ export const makeProjectFaviconResolver = Effect.gen(function* () { | ... | @@ -91,18 +96,31 @@ export const makeProjectFaviconResolver = Effect.gen(function* () { |
| 91 | const resolvePath: ProjectFaviconResolverShape["resolvePath"] = Effect.fn( | 96 | const resolvePath: ProjectFaviconResolverShape["resolvePath"] = Effect.fn( |
| 92 | "ProjectFaviconResolver.resolvePath", | 97 | "ProjectFaviconResolver.resolvePath", |
| 93 | )(function* (cwd: string): Effect.fn.Return<string | null> { | 98 | )(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 | } | ||
| 94 | for (const candidate of FAVICON_CANDIDATES) { | 105 | for (const candidate of FAVICON_CANDIDATES) { |
| 95 | const resolved = path.join(cwd, candidate); | 106 | const existing = yield* findExistingFile(projectCwd, [candidate]); |
| 96 | const existing = yield* findExistingFile(cwd, [resolved]); | ||
| 97 | if (existing) { | 107 | if (existing) { |
| 98 | return existing; | 108 | return existing; |
| 99 | } | 109 | } |
| 100 | } | 110 | } |
| 101 | 111 | ||
| 102 | for (const sourceFile of ICON_SOURCE_FILES) { | 112 | 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 | } | ||
| 104 | const source = yield* fileSystem | 122 | const source = yield* fileSystem |
| 105 | .readFileString(sourcePath) | 123 | .readFileString(sourcePath.absolutePath) |
| 106 | .pipe(Effect.orElseSucceed(() => null)); | 124 | .pipe(Effect.orElseSucceed(() => null)); |
| 107 | if (!source) { | 125 | if (!source) { |
| 108 | continue; | 126 | continue; |
| ... | @@ -111,7 +129,7 @@ export const makeProjectFaviconResolver = Effect.gen(function* () { | ... | @@ -111,7 +129,7 @@ export const makeProjectFaviconResolver = Effect.gen(function* () { |
| 111 | if (!href) { | 129 | if (!href) { |
| 112 | continue; | 130 | continue; |
| 113 | } | 131 | } |
| 114 | const existing = yield* findExistingFile(cwd, resolveIconHref(cwd, href)); | 132 | const existing = yield* findExistingFile(projectCwd, resolveIconHref(href)); |
| 115 | if (existing) { | 133 | if (existing) { |
| 116 | return existing; | 134 | return existing; |
| 117 | } | 135 | } |
apps/server/src/server.test.ts+1-168| ... | @@ -71,7 +71,6 @@ const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); | ... | @@ -71,7 +71,6 @@ const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); |
| 71 | import type { ServerConfigShape } from "./config.ts"; | 71 | import type { ServerConfigShape } from "./config.ts"; |
| 72 | import { deriveServerPaths, ServerConfig } from "./config.ts"; | 72 | import { deriveServerPaths, ServerConfig } from "./config.ts"; |
| 73 | import { makeRoutesLayer } from "./server.ts"; | 73 | import { makeRoutesLayer } from "./server.ts"; |
| 74 | import { resolveAttachmentRelativePath } from "./attachmentPaths.ts"; | ||
| 75 | import { | 74 | import { |
| 76 | CheckpointDiffQuery, | 75 | CheckpointDiffQuery, |
| 77 | type CheckpointDiffQueryShape, | 76 | type CheckpointDiffQueryShape, |
| ... | @@ -516,7 +515,7 @@ const buildAppUnderTest = (options?: { | ... | @@ -516,7 +515,7 @@ const buildAppUnderTest = (options?: { |
| 516 | Layer.provide(WorkspacePathsLive), | 515 | Layer.provide(WorkspacePathsLive), |
| 517 | Layer.provide(workspaceEntriesLayer), | 516 | Layer.provide(workspaceEntriesLayer), |
| 518 | ), | 517 | ), |
| 519 | ProjectFaviconResolverLive, | 518 | ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive)), |
| 520 | ); | 519 | ); |
| 521 | const gitWorkflowLayer = GitWorkflowService.layer.pipe( | 520 | const gitWorkflowLayer = GitWorkflowService.layer.pipe( |
| 522 | Layer.provideMerge(vcsDriverRegistryLayer), | 521 | Layer.provideMerge(vcsDriverRegistryLayer), |
| ... | @@ -1279,61 +1278,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1279,61 +1278,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1279 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 1278 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1280 | ); | 1279 | ); |
| 1281 | 1280 | ||
| 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 | |||
| 1337 | it.effect("serves the public environment descriptor without requiring auth", () => | 1281 | it.effect("serves the public environment descriptor without requiring auth", () => |
| 1338 | Effect.gen(function* () { | 1282 | Effect.gen(function* () { |
| 1339 | yield* buildAppUnderTest(); | 1283 | yield* buildAppUnderTest(); |
| ... | @@ -3195,28 +3139,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -3195,28 +3139,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 3195 | }, | 3139 | }, |
| 3196 | }); | 3140 | }); |
| 3197 | const wsTicketBody = (yield* wsTicketResponse.json) as { readonly ticket: string }; | 3141 | 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 | |||
| 3210 | assert.equal(overbroadPairingResponse.status, 403); | 3142 | assert.equal(overbroadPairingResponse.status, 403); |
| 3211 | assert.equal(overbroadPairingBody.requiredScope, "orchestration:read"); | 3143 | assert.equal(overbroadPairingBody.requiredScope, "orchestration:read"); |
| 3212 | assert.equal(pairingResponse.status, 200); | 3144 | assert.equal(pairingResponse.status, 200); |
| 3213 | assert.equal(wsTicketResponse.status, 200); | 3145 | 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 | |||
| 3220 | const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`; | 3146 | const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`; |
| 3221 | const rpcError = yield* Effect.flip( | 3147 | const rpcError = yield* Effect.flip( |
| 3222 | Effect.scoped(withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({}))), | 3148 | Effect.scoped(withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({}))), |
| ... | @@ -3760,29 +3686,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -3760,29 +3686,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 3760 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 3686 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 3761 | ); | 3687 | ); |
| 3762 | 3688 | ||
| 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 | |||
| 3786 | it.effect("accepts websocket rpc handshake with a bootstrapped browser session cookie", () => | 3689 | it.effect("accepts websocket rpc handshake with a bootstrapped browser session cookie", () => |
| 3787 | Effect.gen(function* () { | 3690 | Effect.gen(function* () { |
| 3788 | yield* buildAppUnderTest(); | 3691 | yield* buildAppUnderTest(); |
| ... | @@ -3853,60 +3756,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -3853,60 +3756,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 3853 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 3756 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 3854 | ); | 3757 | ); |
| 3855 | 3758 | ||
| 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 | |||
| 3910 | it.effect("proxies browser OTLP trace exports through the server", () => | 3759 | it.effect("proxies browser OTLP trace exports through the server", () => |
| 3911 | Effect.gen(function* () { | 3760 | Effect.gen(function* () { |
| 3912 | const upstreamRequests: Array<{ | 3761 | const upstreamRequests: Array<{ |
| ... | @@ -4196,22 +4045,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -4196,22 +4045,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 4196 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 4045 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 4197 | ); | 4046 | ); |
| 4198 | 4047 | ||
| 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 | |||
| 4215 | it.effect("routes websocket rpc server.upsertKeybinding", () => | 4048 | it.effect("routes websocket rpc server.upsertKeybinding", () => |
| 4216 | Effect.gen(function* () { | 4049 | Effect.gen(function* () { |
| 4217 | const rule: KeybindingRule = { | 4050 | const rule: KeybindingRule = { |
apps/server/src/server.ts+7-5| ... | @@ -6,9 +6,8 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; | ... | @@ -6,9 +6,8 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; |
| 6 | 6 | ||
| 7 | import { ServerConfig } from "./config.ts"; | 7 | import { ServerConfig } from "./config.ts"; |
| 8 | import { | 8 | import { |
| 9 | attachmentsRouteLayer, | ||
| 10 | otlpTracesProxyRouteLayer, | 9 | otlpTracesProxyRouteLayer, |
| 11 | projectFaviconRouteLayer, | 10 | assetRouteLayer, |
| 12 | serverEnvironmentHttpApiLayer, | 11 | serverEnvironmentHttpApiLayer, |
| 13 | staticAndDevRouteLayer, | 12 | staticAndDevRouteLayer, |
| 14 | browserApiCorsLayer, | 13 | browserApiCorsLayer, |
| ... | @@ -264,6 +263,10 @@ const WorkspaceLayerLive = Layer.mergeAll( | ... | @@ -264,6 +263,10 @@ const WorkspaceLayerLive = Layer.mergeAll( |
| 264 | WorkspaceFileSystemLayerLive, | 263 | WorkspaceFileSystemLayerLive, |
| 265 | ); | 264 | ); |
| 266 | 265 | ||
| 266 | const ProjectFaviconResolverLayerLive = ProjectFaviconResolverLive.pipe( | ||
| 267 | Layer.provide(WorkspacePathsLive), | ||
| 268 | ); | ||
| 269 | |||
| 267 | const AuthLayerLive = EnvironmentAuth.layer.pipe( | 270 | const AuthLayerLive = EnvironmentAuth.layer.pipe( |
| 268 | Layer.provideMerge(PersistenceLayerLive), | 271 | Layer.provideMerge(PersistenceLayerLive), |
| 269 | Layer.provide(ServerSecretStore.layer), | 272 | Layer.provide(ServerSecretStore.layer), |
| ... | @@ -313,7 +316,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( | ... | @@ -313,7 +316,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( |
| 313 | Layer.provideMerge(OpenCodeRuntimeLive), | 316 | Layer.provideMerge(OpenCodeRuntimeLive), |
| 314 | Layer.provideMerge(ServerSettingsLive), | 317 | Layer.provideMerge(ServerSettingsLive), |
| 315 | Layer.provideMerge(WorkspaceLayerLive), | 318 | Layer.provideMerge(WorkspaceLayerLive), |
| 316 | Layer.provideMerge(ProjectFaviconResolverLive), | 319 | Layer.provideMerge(ProjectFaviconResolverLayerLive), |
| 317 | Layer.provideMerge(RepositoryIdentityResolverLive), | 320 | Layer.provideMerge(RepositoryIdentityResolverLive), |
| 318 | Layer.provideMerge(ServerEnvironmentLive), | 321 | Layer.provideMerge(ServerEnvironmentLive), |
| 319 | Layer.provideMerge(AuthLayerLive), | 322 | Layer.provideMerge(AuthLayerLive), |
| ... | @@ -350,9 +353,8 @@ export const makeRoutesLayer = Layer.mergeAll( | ... | @@ -350,9 +353,8 @@ export const makeRoutesLayer = Layer.mergeAll( |
| 350 | Layer.provide(serverEnvironmentHttpApiLayer), | 353 | Layer.provide(serverEnvironmentHttpApiLayer), |
| 351 | Layer.provide(environmentAuthenticatedAuthLayer), | 354 | Layer.provide(environmentAuthenticatedAuthLayer), |
| 352 | ), | 355 | ), |
| 353 | attachmentsRouteLayer, | ||
| 354 | otlpTracesProxyRouteLayer, | 356 | otlpTracesProxyRouteLayer, |
| 355 | projectFaviconRouteLayer, | 357 | assetRouteLayer, |
| 356 | staticAndDevRouteLayer, | 358 | staticAndDevRouteLayer, |
| 357 | websocketRpcRouteLayer, | 359 | websocketRpcRouteLayer, |
| 358 | ), | 360 | ), |
apps/server/src/ws.ts+49| ... | @@ -40,6 +40,7 @@ import { | ... | @@ -40,6 +40,7 @@ import { |
| 40 | type RelayClientInstallProgressEvent, | 40 | type RelayClientInstallProgressEvent, |
| 41 | OrchestrationReplayEventsError, | 41 | OrchestrationReplayEventsError, |
| 42 | FilesystemBrowseError, | 42 | FilesystemBrowseError, |
| 43 | AssetAccessError, | ||
| 43 | EnvironmentAuthorizationError, | 44 | EnvironmentAuthorizationError, |
| 44 | ThreadId, | 45 | ThreadId, |
| 45 | type TerminalAttachStreamEvent, | 46 | type TerminalAttachStreamEvent, |
| ... | @@ -73,6 +74,7 @@ import { redactServerSettingsForClient, ServerSettingsService } from "./serverSe | ... | @@ -73,6 +74,7 @@ import { redactServerSettingsForClient, ServerSettingsService } from "./serverSe |
| 73 | import { TerminalManager } from "./terminal/Services/Manager.ts"; | 74 | import { TerminalManager } from "./terminal/Services/Manager.ts"; |
| 74 | import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; | 75 | import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; |
| 75 | import * as PreviewManager from "./preview/Manager.ts"; | 76 | import * as PreviewManager from "./preview/Manager.ts"; |
| 77 | import { issueAssetUrl } from "./assets/AssetAccess.ts"; | ||
| 76 | import * as PortScanner from "./preview/PortScanner.ts"; | 78 | import * as PortScanner from "./preview/PortScanner.ts"; |
| 77 | import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; | 79 | import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; |
| 78 | import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; | 80 | import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; |
| ... | @@ -162,6 +164,7 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([ | ... | @@ -162,6 +164,7 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([ |
| 162 | [WS_METHODS.projectsWriteFile, AuthOrchestrationOperateScope], | 164 | [WS_METHODS.projectsWriteFile, AuthOrchestrationOperateScope], |
| 163 | [WS_METHODS.shellOpenInEditor, AuthOrchestrationOperateScope], | 165 | [WS_METHODS.shellOpenInEditor, AuthOrchestrationOperateScope], |
| 164 | [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope], | 166 | [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope], |
| 167 | [WS_METHODS.assetsCreateUrl, AuthOrchestrationReadScope], | ||
| 165 | [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope], | 168 | [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope], |
| 166 | [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope], | 169 | [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope], |
| 167 | [WS_METHODS.vcsPull, AuthOrchestrationOperateScope], | 170 | [WS_METHODS.vcsPull, AuthOrchestrationOperateScope], |
| ... | @@ -1203,6 +1206,52 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => | ... | @@ -1203,6 +1206,52 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => |
| 1203 | ), | 1206 | ), |
| 1204 | { "rpc.aggregate": "workspace" }, | 1207 | { "rpc.aggregate": "workspace" }, |
| 1205 | ), | 1208 | ), |
| 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 | ), | ||
| 1206 | [WS_METHODS.subscribeVcsStatus]: (input) => | 1255 | [WS_METHODS.subscribeVcsStatus]: (input) => |
| 1207 | observeRpcStream( | 1256 | observeRpcStream( |
| 1208 | WS_METHODS.subscribeVcsStatus, | 1257 | WS_METHODS.subscribeVcsStatus, |
apps/web/src/assets/assetUrls.ts created+89| ... | @@ -0,0 +1,89 @@ | ||
| 1 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { useEffect, useMemo, useState } from "react"; | ||
| 3 | |||
| 4 | import { readEnvironmentApi } from "~/environmentApi"; | ||
| 5 | import { readEnvironmentConnection } from "~/environments/runtime"; | ||
| 6 | |||
| 7 | const REFRESH_MARGIN_MS = 30_000; | ||
| 8 | |||
| 9 | interface CachedAssetUrl { | ||
| 10 | readonly url: string; | ||
| 11 | readonly expiresAt: number; | ||
| 12 | } | ||
| 13 | |||
| 14 | const assetUrlCache = new Map<string, CachedAssetUrl>(); | ||
| 15 | const assetUrlRequests = new Map<string, Promise<CachedAssetUrl>>(); | ||
| 16 | |||
| 17 | function assetCacheKey(environmentId: EnvironmentId, resource: AssetResource): string { | ||
| 18 | return `${environmentId}:${JSON.stringify(resource)}`; | ||
| 19 | } | ||
| 20 | |||
| 21 | export 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 | |||
| 56 | export 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 @@ | ||
| 1 | import type { ScopedThreadRef } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | import { readEnvironmentApi } from "~/environmentApi"; | ||
| 4 | import { resolveAssetUrl } from "~/assets/assetUrls"; | ||
| 5 | import { isPreviewSupportedInRuntime, usePreviewStateStore } from "~/previewStateStore"; | ||
| 6 | import { useRightPanelStore } from "~/rightPanelStore"; | ||
| 7 | |||
| 8 | export const isBrowserPreviewFile = (path: string): boolean => | ||
| 9 | /\.(?:html?|pdf)$/i.test(path.split(/[?#]/, 1)[0] ?? ""); | ||
| 10 | |||
| 11 | export 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 | |||
| 23 | export 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"; | ... | @@ -4,11 +4,24 @@ import { page } from "vite-plus/test/browser"; |
| 4 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | 4 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; |
| 5 | import { render } from "vitest-browser-react"; | 5 | import { render } from "vitest-browser-react"; |
| 6 | 6 | ||
| 7 | const { openInPreferredEditorMock, readLocalApiMock } = vi.hoisted(() => ({ | 7 | const { |
| 8 | contextMenuShowMock, | ||
| 9 | openFileInPreviewMock, | ||
| 10 | openInPreferredEditorMock, | ||
| 11 | openUrlInPreviewMock, | ||
| 12 | readLocalApiMock, | ||
| 13 | } = vi.hoisted(() => ({ | ||
| 14 | contextMenuShowMock: vi.fn(), | ||
| 15 | openFileInPreviewMock: vi.fn(async () => undefined), | ||
| 8 | openInPreferredEditorMock: vi.fn(async () => "vscode"), | 16 | openInPreferredEditorMock: vi.fn(async () => "vscode"), |
| 17 | openUrlInPreviewMock: vi.fn(async () => undefined), | ||
| 9 | readLocalApiMock: vi.fn(() => ({ | 18 | readLocalApiMock: vi.fn(() => ({ |
| 19 | contextMenu: { show: contextMenuShowMock }, | ||
| 10 | server: { getConfig: vi.fn(async () => ({ availableEditors: ["vscode"] })) }, | 20 | 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 | }, | ||
| 12 | })), | 25 | })), |
| 13 | })); | 26 | })); |
| 14 | 27 | ||
| ... | @@ -23,12 +36,32 @@ vi.mock("../localApi", () => ({ | ... | @@ -23,12 +36,32 @@ vi.mock("../localApi", () => ({ |
| 23 | readLocalApi: readLocalApiMock, | 36 | readLocalApi: readLocalApiMock, |
| 24 | })); | 37 | })); |
| 25 | 38 | ||
| 39 | vi.mock("../previewStateStore", async (importOriginal) => ({ | ||
| 40 | ...(await importOriginal<typeof import("../previewStateStore")>()), | ||
| 41 | isPreviewSupportedInRuntime: () => true, | ||
| 42 | })); | ||
| 43 | |||
| 44 | vi.mock("../browser/openFileInPreview", async (importOriginal) => ({ | ||
| 45 | ...(await importOriginal<typeof import("../browser/openFileInPreview")>()), | ||
| 46 | openFileInPreview: openFileInPreviewMock, | ||
| 47 | openUrlInPreview: openUrlInPreviewMock, | ||
| 48 | })); | ||
| 49 | |||
| 26 | import ChatMarkdown from "./ChatMarkdown"; | 50 | import ChatMarkdown from "./ChatMarkdown"; |
| 27 | import { serializeTableElementToCsv, serializeTableElementToMarkdown } from "../markdown-clipboard"; | 51 | import { serializeTableElementToCsv, serializeTableElementToMarkdown } from "../markdown-clipboard"; |
| 52 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 53 | |||
| 54 | const threadRef = { | ||
| 55 | environmentId: EnvironmentId.make("environment-test"), | ||
| 56 | threadId: ThreadId.make("thread-test"), | ||
| 57 | }; | ||
| 28 | 58 | ||
| 29 | describe("ChatMarkdown", () => { | 59 | describe("ChatMarkdown", () => { |
| 30 | afterEach(() => { | 60 | afterEach(() => { |
| 31 | openInPreferredEditorMock.mockClear(); | 61 | openInPreferredEditorMock.mockClear(); |
| 62 | openFileInPreviewMock.mockClear(); | ||
| 63 | openUrlInPreviewMock.mockClear(); | ||
| 64 | contextMenuShowMock.mockReset(); | ||
| 32 | readLocalApiMock.mockClear(); | 65 | readLocalApiMock.mockClear(); |
| 33 | localStorage.clear(); | 66 | localStorage.clear(); |
| 34 | document.body.innerHTML = ""; | 67 | document.body.innerHTML = ""; |
| ... | @@ -155,6 +188,66 @@ describe("ChatMarkdown", () => { | ... | @@ -155,6 +188,66 @@ describe("ChatMarkdown", () => { |
| 155 | } | 188 | } |
| 156 | }); | 189 | }); |
| 157 | 190 | ||
| 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 | |||
| 158 | it("keeps a favicon with the leading segment of a wrapping URL", async () => { | 251 | it("keeps a favicon with the leading segment of a wrapping URL", async () => { |
| 159 | const url = "https://github.com/pingdotgg/t3code/pull/3017/changes"; | 252 | const url = "https://github.com/pingdotgg/t3code/pull/3017/changes"; |
| 160 | const screen = await render( | 253 | const screen = await render( |
apps/web/src/components/ChatMarkdown.tsx+90-5| ... | @@ -8,7 +8,7 @@ import { | ... | @@ -8,7 +8,7 @@ import { |
| 8 | Minimize2Icon, | 8 | Minimize2Icon, |
| 9 | WrapTextIcon, | 9 | WrapTextIcon, |
| 10 | } from "lucide-react"; | 10 | } from "lucide-react"; |
| 11 | import type { ServerProviderSkill } from "@t3tools/contracts"; | 11 | import type { ScopedThreadRef, ServerProviderSkill } from "@t3tools/contracts"; |
| 12 | import React, { | 12 | import React, { |
| 13 | Children, | 13 | Children, |
| 14 | Suspense, | 14 | Suspense, |
| ... | @@ -62,6 +62,12 @@ import { | ... | @@ -62,6 +62,12 @@ import { |
| 62 | } from "../markdown-links"; | 62 | } from "../markdown-links"; |
| 63 | import { readLocalApi } from "../localApi"; | 63 | import { readLocalApi } from "../localApi"; |
| 64 | import { cn } from "../lib/utils"; | 64 | import { cn } from "../lib/utils"; |
| 65 | import { isPreviewSupportedInRuntime } from "../previewStateStore"; | ||
| 66 | import { | ||
| 67 | isBrowserPreviewFile, | ||
| 68 | openFileInPreview, | ||
| 69 | openUrlInPreview, | ||
| 70 | } from "../browser/openFileInPreview"; | ||
| 65 | 71 | ||
| 66 | class CodeHighlightErrorBoundary extends React.Component< | 72 | class CodeHighlightErrorBoundary extends React.Component< |
| 67 | { fallback: ReactNode; children: ReactNode }, | 73 | { fallback: ReactNode; children: ReactNode }, |
| ... | @@ -87,6 +93,7 @@ class CodeHighlightErrorBoundary extends React.Component< | ... | @@ -87,6 +93,7 @@ class CodeHighlightErrorBoundary extends React.Component< |
| 87 | interface ChatMarkdownProps { | 93 | interface ChatMarkdownProps { |
| 88 | text: string; | 94 | text: string; |
| 89 | cwd: string | undefined; | 95 | cwd: string | undefined; |
| 96 | threadRef?: ScopedThreadRef | undefined; | ||
| 90 | isStreaming?: boolean; | 97 | isStreaming?: boolean; |
| 91 | skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; | 98 | skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; |
| 92 | className?: string; | 99 | className?: string; |
| ... | @@ -670,6 +677,7 @@ interface MarkdownFileLinkProps { | ... | @@ -670,6 +677,7 @@ interface MarkdownFileLinkProps { |
| 670 | label: string; | 677 | label: string; |
| 671 | copyMarkdown: string; | 678 | copyMarkdown: string; |
| 672 | theme: "light" | "dark"; | 679 | theme: "light" | "dark"; |
| 680 | threadRef?: ScopedThreadRef | undefined; | ||
| 673 | className?: string | undefined; | 681 | className?: string | undefined; |
| 674 | } | 682 | } |
| 675 | 683 | ||
| ... | @@ -936,6 +944,54 @@ function MarkdownExternalLinkContent({ | ... | @@ -936,6 +944,54 @@ function MarkdownExternalLinkContent({ |
| 936 | ); | 944 | ); |
| 937 | } | 945 | } |
| 938 | 946 | ||
| 947 | function 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 | |||
| 939 | const MarkdownFileLink = memo(function MarkdownFileLink({ | 995 | const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 940 | href, | 996 | href, |
| 941 | targetPath, | 997 | targetPath, |
| ... | @@ -944,6 +1000,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ | ... | @@ -944,6 +1000,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 944 | label, | 1000 | label, |
| 945 | copyMarkdown, | 1001 | copyMarkdown, |
| 946 | theme, | 1002 | theme, |
| 1003 | threadRef, | ||
| 947 | className, | 1004 | className, |
| 948 | }: MarkdownFileLinkProps) { | 1005 | }: MarkdownFileLinkProps) { |
| 949 | const handleOpen = useCallback(() => { | 1006 | const handleOpen = useCallback(() => { |
| ... | @@ -967,6 +1024,19 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ | ... | @@ -967,6 +1024,19 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 967 | }); | 1024 | }); |
| 968 | }, [targetPath]); | 1025 | }, [targetPath]); |
| 969 | 1026 | ||
| 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 | |||
| 970 | const handleCopy = useCallback((value: string, title: string) => { | 1040 | const handleCopy = useCallback((value: string, title: string) => { |
| 971 | if (typeof window === "undefined" || !navigator.clipboard?.writeText) { | 1041 | if (typeof window === "undefined" || !navigator.clipboard?.writeText) { |
| 972 | toastManager.add( | 1042 | toastManager.add( |
| ... | @@ -1007,9 +1077,14 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ | ... | @@ -1007,9 +1077,14 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 1007 | const api = readLocalApi(); | 1077 | const api = readLocalApi(); |
| 1008 | if (!api) return; | 1078 | if (!api) return; |
| 1009 | 1079 | ||
| 1080 | const canOpenInBrowser = | ||
| 1081 | Boolean(threadRef) && isPreviewSupportedInRuntime() && isBrowserPreviewFile(iconPath); | ||
| 1010 | const clicked = await api.contextMenu.show( | 1082 | const clicked = await api.contextMenu.show( |
| 1011 | [ | 1083 | [ |
| 1012 | { id: "open", label: "Open in editor" }, | 1084 | { id: "open", label: "Open in editor" }, |
| 1085 | ...(canOpenInBrowser | ||
| 1086 | ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) | ||
| 1087 | : []), | ||
| 1013 | { id: "copy-relative", label: "Copy relative path" }, | 1088 | { id: "copy-relative", label: "Copy relative path" }, |
| 1014 | { id: "copy-full", label: "Copy full path" }, | 1089 | { id: "copy-full", label: "Copy full path" }, |
| 1015 | ] as const, | 1090 | ] as const, |
| ... | @@ -1020,6 +1095,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ | ... | @@ -1020,6 +1095,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 1020 | handleOpen(); | 1095 | handleOpen(); |
| 1021 | return; | 1096 | return; |
| 1022 | } | 1097 | } |
| 1098 | if (clicked === "open-in-browser") { | ||
| 1099 | handleOpenInBrowser(); | ||
| 1100 | return; | ||
| 1101 | } | ||
| 1023 | if (clicked === "copy-relative") { | 1102 | if (clicked === "copy-relative") { |
| 1024 | handleCopy(displayPath, "Relative path"); | 1103 | handleCopy(displayPath, "Relative path"); |
| 1025 | return; | 1104 | return; |
| ... | @@ -1028,7 +1107,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ | ... | @@ -1028,7 +1107,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 1028 | handleCopy(targetPath, "Full path"); | 1107 | handleCopy(targetPath, "Full path"); |
| 1029 | } | 1108 | } |
| 1030 | }, | 1109 | }, |
| 1031 | [displayPath, handleCopy, handleOpen, targetPath], | 1110 | [displayPath, handleCopy, handleOpen, handleOpenInBrowser, iconPath, targetPath, threadRef], |
| 1032 | ); | 1111 | ); |
| 1033 | 1112 | ||
| 1034 | return ( | 1113 | return ( |
| ... | @@ -1074,6 +1153,8 @@ function areMarkdownFileLinkPropsEqual( | ... | @@ -1074,6 +1153,8 @@ function areMarkdownFileLinkPropsEqual( |
| 1074 | previous.label === next.label && | 1153 | previous.label === next.label && |
| 1075 | previous.copyMarkdown === next.copyMarkdown && | 1154 | previous.copyMarkdown === next.copyMarkdown && |
| 1076 | previous.theme === next.theme && | 1155 | previous.theme === next.theme && |
| 1156 | previous.threadRef?.environmentId === next.threadRef?.environmentId && | ||
| 1157 | previous.threadRef?.threadId === next.threadRef?.threadId && | ||
| 1077 | previous.className === next.className | 1158 | previous.className === next.className |
| 1078 | ); | 1159 | ); |
| 1079 | } | 1160 | } |
| ... | @@ -1081,6 +1162,7 @@ function areMarkdownFileLinkPropsEqual( | ... | @@ -1081,6 +1162,7 @@ function areMarkdownFileLinkPropsEqual( |
| 1081 | function ChatMarkdown({ | 1162 | function ChatMarkdown({ |
| 1082 | text, | 1163 | text, |
| 1083 | cwd, | 1164 | cwd, |
| 1165 | threadRef, | ||
| 1084 | isStreaming = false, | 1166 | isStreaming = false, |
| 1085 | skills = EMPTY_MARKDOWN_SKILLS, | 1167 | skills = EMPTY_MARKDOWN_SKILLS, |
| 1086 | className, | 1168 | className, |
| ... | @@ -1137,9 +1219,10 @@ function ChatMarkdown({ | ... | @@ -1137,9 +1219,10 @@ function ChatMarkdown({ |
| 1137 | const isSameDocumentLink = href?.startsWith("#") ?? false; | 1219 | const isSameDocumentLink = href?.startsWith("#") ?? false; |
| 1138 | const onClick = props.onClick; | 1220 | const onClick = props.onClick; |
| 1139 | const link = ( | 1221 | const link = ( |
| 1140 | <a | 1222 | <MarkdownExternalLink |
| 1141 | {...props} | 1223 | {...props} |
| 1142 | href={href} | 1224 | href={href ?? ""} |
| 1225 | threadRef={faviconHost && isPreviewSupportedInRuntime() ? threadRef : undefined} | ||
| 1143 | target={isSameDocumentLink ? undefined : "_blank"} | 1226 | target={isSameDocumentLink ? undefined : "_blank"} |
| 1144 | rel={isSameDocumentLink ? undefined : "noopener noreferrer"} | 1227 | rel={isSameDocumentLink ? undefined : "noopener noreferrer"} |
| 1145 | onClick={(event) => { | 1228 | onClick={(event) => { |
| ... | @@ -1156,7 +1239,7 @@ function ChatMarkdown({ | ... | @@ -1156,7 +1239,7 @@ function ChatMarkdown({ |
| 1156 | ) : ( | 1239 | ) : ( |
| 1157 | children | 1240 | children |
| 1158 | )} | 1241 | )} |
| 1159 | </a> | 1242 | </MarkdownExternalLink> |
| 1160 | ); | 1243 | ); |
| 1161 | if (!faviconHost || !href) { | 1244 | if (!faviconHost || !href) { |
| 1162 | return link; | 1245 | return link; |
| ... | @@ -1194,6 +1277,7 @@ function ChatMarkdown({ | ... | @@ -1194,6 +1277,7 @@ function ChatMarkdown({ |
| 1194 | label={labelParts.join(" · ")} | 1277 | label={labelParts.join(" · ")} |
| 1195 | copyMarkdown={`[${fileLinkMeta.basename}](${normalizedHref})`} | 1278 | copyMarkdown={`[${fileLinkMeta.basename}](${normalizedHref})`} |
| 1196 | theme={resolvedTheme} | 1279 | theme={resolvedTheme} |
| 1280 | threadRef={threadRef} | ||
| 1197 | className={props.className} | 1281 | className={props.className} |
| 1198 | /> | 1282 | /> |
| 1199 | ); | 1283 | ); |
| ... | @@ -1238,6 +1322,7 @@ function ChatMarkdown({ | ... | @@ -1238,6 +1322,7 @@ function ChatMarkdown({ |
| 1238 | fileLinkParentSuffixByPath, | 1322 | fileLinkParentSuffixByPath, |
| 1239 | isStreaming, | 1323 | isStreaming, |
| 1240 | markdownFileLinkMetaByHref, | 1324 | markdownFileLinkMetaByHref, |
| 1325 | threadRef, | ||
| 1241 | resolvedTheme, | 1326 | resolvedTheme, |
| 1242 | skills, | 1327 | skills, |
| 1243 | ], | 1328 | ], |
apps/web/src/components/ChatView.browser.tsx+13-3| ... | @@ -250,6 +250,18 @@ function createMockEnvironmentApi(input: { | ... | @@ -250,6 +250,18 @@ function createMockEnvironmentApi(input: { |
| 250 | filesystem: { | 250 | filesystem: { |
| 251 | browse: input.browse, | 251 | browse: input.browse, |
| 252 | }, | 252 | }, |
| 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 | }, | ||
| 253 | sourceControl: {} as EnvironmentApi["sourceControl"], | 265 | sourceControl: {} as EnvironmentApi["sourceControl"], |
| 254 | vcs: {} as EnvironmentApi["vcs"], | 266 | vcs: {} as EnvironmentApi["vcs"], |
| 255 | git: {} as EnvironmentApi["git"], | 267 | git: {} as EnvironmentApi["git"], |
| ... | @@ -373,7 +385,6 @@ function createSnapshotForTargetUser(options: { | ... | @@ -373,7 +385,6 @@ function createSnapshotForTargetUser(options: { |
| 373 | name: `attachment-${attachmentIndex + 1}.png`, | 385 | name: `attachment-${attachmentIndex + 1}.png`, |
| 374 | mimeType: "image/png", | 386 | mimeType: "image/png", |
| 375 | sizeBytes: 128, | 387 | sizeBytes: 128, |
| 376 | previewUrl: `/attachments/attachment-${attachmentIndex + 1}`, | ||
| 377 | })) | 388 | })) |
| 378 | : undefined; | 389 | : undefined; |
| 379 | 390 | ||
| ... | @@ -1153,14 +1164,13 @@ const worker = setupWorker( | ... | @@ -1153,14 +1164,13 @@ const worker = setupWorker( |
| 1153 | }); | 1164 | }); |
| 1154 | }), | 1165 | }), |
| 1155 | ...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth), | 1166 | ...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth), |
| 1156 | http.get("*/attachments/:attachmentId", () => | 1167 | http.get("*/api/assets/test/:assetName", () => |
| 1157 | HttpResponse.text(ATTACHMENT_SVG, { | 1168 | HttpResponse.text(ATTACHMENT_SVG, { |
| 1158 | headers: { | 1169 | headers: { |
| 1159 | "Content-Type": "image/svg+xml", | 1170 | "Content-Type": "image/svg+xml", |
| 1160 | }, | 1171 | }, |
| 1161 | }), | 1172 | }), |
| 1162 | ), | 1173 | ), |
| 1163 | http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })), | ||
| 1164 | ); | 1174 | ); |
| 1165 | 1175 | ||
| 1166 | async function nextFrame(): Promise<void> { | 1176 | async function nextFrame(): Promise<void> { |
apps/web/src/components/ChatView.tsx+67-5| ... | @@ -37,6 +37,7 @@ import { useShallow } from "zustand/react/shallow"; | ... | @@ -37,6 +37,7 @@ import { useShallow } from "zustand/react/shallow"; |
| 37 | import { useVcsStatus } from "~/lib/vcsStatusState"; | 37 | import { useVcsStatus } from "~/lib/vcsStatusState"; |
| 38 | import { usePrimaryEnvironmentId } from "../environments/primary"; | 38 | import { usePrimaryEnvironmentId } from "../environments/primary"; |
| 39 | import { readEnvironmentApi } from "../environmentApi"; | 39 | import { readEnvironmentApi } from "../environmentApi"; |
| 40 | import { resolveAssetUrl } from "../assets/assetUrls"; | ||
| 40 | import { isElectron } from "../env"; | 41 | import { isElectron } from "../env"; |
| 41 | import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner"; | 42 | import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner"; |
| 42 | import { readLocalApi } from "../localApi"; | 43 | import { readLocalApi } from "../localApi"; |
| ... | @@ -1915,8 +1916,64 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1915,8 +1916,64 @@ export default function ChatView(props: ChatViewProps) { |
| 1915 | }); | 1916 | }); |
| 1916 | }, []); | 1917 | }, []); |
| 1917 | const serverMessages = activeThread?.messages; | 1918 | const serverMessages = activeThread?.messages; |
| 1919 | const [attachmentAssetUrlById, setAttachmentAssetUrlById] = useState<Record<string, string>>({}); | ||
| 1918 | useEffect(() => { | 1920 | 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 | ) { | ||
| 1920 | return; | 1977 | return; |
| 1921 | } | 1978 | } |
| 1922 | 1979 | ||
| ... | @@ -1929,7 +1986,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1929,7 +1986,7 @@ export default function ChatView(props: ChatViewProps) { |
| 1929 | continue; | 1986 | continue; |
| 1930 | } | 1987 | } |
| 1931 | 1988 | ||
| 1932 | const serverMessage = serverMessages.find( | 1989 | const serverMessage = serverMessagesWithAssetUrls.find( |
| 1933 | (message) => message.id === messageId && message.role === "user", | 1990 | (message) => message.id === messageId && message.role === "user", |
| 1934 | ); | 1991 | ); |
| 1935 | if (!serverMessage?.attachments || serverMessage.attachments.length === 0) { | 1992 | if (!serverMessage?.attachments || serverMessage.attachments.length === 0) { |
| ... | @@ -1995,9 +2052,13 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1995,9 +2052,13 @@ export default function ChatView(props: ChatViewProps) { |
| 1995 | cleanup(); | 2052 | cleanup(); |
| 1996 | } | 2053 | } |
| 1997 | }; | 2054 | }; |
| 1998 | }, [attachmentPreviewHandoffByMessageId, clearAttachmentPreviewHandoff, serverMessages]); | 2055 | }, [ |
| 2056 | attachmentPreviewHandoffByMessageId, | ||
| 2057 | clearAttachmentPreviewHandoff, | ||
| 2058 | serverMessagesWithAssetUrls, | ||
| 2059 | ]); | ||
| 1999 | const timelineMessages = useMemo(() => { | 2060 | const timelineMessages = useMemo(() => { |
| 2000 | const messages = serverMessages ?? []; | 2061 | const messages = serverMessagesWithAssetUrls ?? []; |
| 2001 | const serverMessagesWithPreviewHandoff = | 2062 | const serverMessagesWithPreviewHandoff = |
| 2002 | Object.keys(attachmentPreviewHandoffByMessageId).length === 0 | 2063 | Object.keys(attachmentPreviewHandoffByMessageId).length === 0 |
| 2003 | ? messages | 2064 | ? messages |
| ... | @@ -2048,7 +2109,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2048,7 +2109,7 @@ export default function ChatView(props: ChatViewProps) { |
| 2048 | return serverMessagesWithPreviewHandoff; | 2109 | return serverMessagesWithPreviewHandoff; |
| 2049 | } | 2110 | } |
| 2050 | return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; | 2111 | return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; |
| 2051 | }, [serverMessages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]); | 2112 | }, [serverMessagesWithAssetUrls, attachmentPreviewHandoffByMessageId, optimisticUserMessages]); |
| 2052 | const timelineEntries = useMemo( | 2113 | const timelineEntries = useMemo( |
| 2053 | () => | 2114 | () => |
| 2054 | deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), | 2115 | deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), |
| ... | @@ -4760,6 +4821,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -4760,6 +4821,7 @@ export default function ChatView(props: ChatViewProps) { |
| 4760 | activeProposedPlan={sidebarProposedPlan} | 4821 | activeProposedPlan={sidebarProposedPlan} |
| 4761 | label={planSidebarLabel} | 4822 | label={planSidebarLabel} |
| 4762 | environmentId={environmentId} | 4823 | environmentId={environmentId} |
| 4824 | threadRef={activeThreadRef} | ||
| 4763 | markdownCwd={gitCwd ?? undefined} | 4825 | markdownCwd={gitCwd ?? undefined} |
| 4764 | workspaceRoot={activeWorkspaceRoot} | 4826 | workspaceRoot={activeWorkspaceRoot} |
| 4765 | timestampFormat={timestampFormat} | 4827 | timestampFormat={timestampFormat} |
apps/web/src/components/KeybindingsToast.browser.tsx+1-2| ... | @@ -322,8 +322,7 @@ const worker = setupWorker( | ... | @@ -322,8 +322,7 @@ const worker = setupWorker( |
| 322 | }); | 322 | }); |
| 323 | }), | 323 | }), |
| 324 | ...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth), | 324 | ...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth), |
| 325 | http.get("*/attachments/:attachmentId", () => new HttpResponse(null, { status: 204 })), | 325 | http.get("*/api/assets/*", () => new HttpResponse(null, { status: 204 })), |
| 326 | http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })), | ||
| 327 | ); | 326 | ); |
| 328 | 327 | ||
| 329 | function sendServerConfigUpdatedPush(issues: ServerConfig["issues"]) { | 328 | function sendServerConfigUpdatedPush(issues: ServerConfig["issues"]) { |
apps/web/src/components/PlanSidebar.tsx+4-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { memo, useState, useCallback } from "react"; | 1 | import { memo, useState, useCallback } from "react"; |
| 2 | import type { EnvironmentId } from "@t3tools/contracts"; | 2 | import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; |
| 3 | import { type TimestampFormat } from "@t3tools/contracts/settings"; | 3 | import { type TimestampFormat } from "@t3tools/contracts/settings"; |
| 4 | import { Badge } from "./ui/badge"; | 4 | import { Badge } from "./ui/badge"; |
| 5 | import { Button } from "./ui/button"; | 5 | import { Button } from "./ui/button"; |
| ... | @@ -56,6 +56,7 @@ interface PlanSidebarProps { | ... | @@ -56,6 +56,7 @@ interface PlanSidebarProps { |
| 56 | activeProposedPlan: LatestProposedPlanState | null; | 56 | activeProposedPlan: LatestProposedPlanState | null; |
| 57 | label?: string; | 57 | label?: string; |
| 58 | environmentId: EnvironmentId; | 58 | environmentId: EnvironmentId; |
| 59 | threadRef?: ScopedThreadRef | undefined; | ||
| 59 | markdownCwd: string | undefined; | 60 | markdownCwd: string | undefined; |
| 60 | workspaceRoot: string | undefined; | 61 | workspaceRoot: string | undefined; |
| 61 | timestampFormat: TimestampFormat; | 62 | timestampFormat: TimestampFormat; |
| ... | @@ -68,6 +69,7 @@ const PlanSidebar = memo(function PlanSidebar({ | ... | @@ -68,6 +69,7 @@ const PlanSidebar = memo(function PlanSidebar({ |
| 68 | activeProposedPlan, | 69 | activeProposedPlan, |
| 69 | label = "Plan", | 70 | label = "Plan", |
| 70 | environmentId, | 71 | environmentId, |
| 72 | threadRef, | ||
| 71 | markdownCwd, | 73 | markdownCwd, |
| 72 | workspaceRoot, | 74 | workspaceRoot, |
| 73 | timestampFormat, | 75 | timestampFormat, |
| ... | @@ -257,6 +259,7 @@ const PlanSidebar = memo(function PlanSidebar({ | ... | @@ -257,6 +259,7 @@ const PlanSidebar = memo(function PlanSidebar({ |
| 257 | <ChatMarkdown | 259 | <ChatMarkdown |
| 258 | text={displayedPlanMarkdown ?? ""} | 260 | text={displayedPlanMarkdown ?? ""} |
| 259 | cwd={markdownCwd} | 261 | cwd={markdownCwd} |
| 262 | threadRef={threadRef} | ||
| 260 | isStreaming={false} | 263 | isStreaming={false} |
| 261 | /> | 264 | /> |
| 262 | </div> | 265 | </div> |
apps/web/src/components/ProjectFavicon.tsx+9-13| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | 1 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { FolderIcon } from "lucide-react"; | 2 | import { FolderIcon } from "lucide-react"; |
| 3 | import { useState } from "react"; | 3 | import { useEffect, useState } from "react"; |
| 4 | import { resolveEnvironmentHttpUrl } from "../environments/runtime"; | 4 | import { useAssetUrl } from "../assets/assetUrls"; |
| 5 | 5 | ||
| 6 | const loadedProjectFaviconSrcs = new Set<string>(); | 6 | const loadedProjectFaviconSrcs = new Set<string>(); |
| 7 | 7 | ||
| ... | @@ -10,20 +10,16 @@ export function ProjectFavicon(input: { | ... | @@ -10,20 +10,16 @@ export function ProjectFavicon(input: { |
| 10 | cwd: string; | 10 | cwd: string; |
| 11 | className?: string; | 11 | className?: string; |
| 12 | }) { | 12 | }) { |
| 13 | const src = (() => { | 13 | const src = useAssetUrl(input.environmentId, { |
| 14 | try { | 14 | _tag: "project-favicon", |
| 15 | return resolveEnvironmentHttpUrl({ | 15 | cwd: input.cwd, |
| 16 | environmentId: input.environmentId, | 16 | }); |
| 17 | pathname: "/api/project-favicon", | ||
| 18 | searchParams: { cwd: input.cwd }, | ||
| 19 | }); | ||
| 20 | } catch { | ||
| 21 | return null; | ||
| 22 | } | ||
| 23 | })(); | ||
| 24 | const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => | 17 | const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => |
| 25 | src && loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", | 18 | src && loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", |
| 26 | ); | 19 | ); |
| 20 | useEffect(() => { | ||
| 21 | setStatus(src && loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading"); | ||
| 22 | }, [src]); | ||
| 27 | 23 | ||
| 28 | if (!src) { | 24 | if (!src) { |
| 29 | return ( | 25 | return ( |
apps/web/src/components/chat/MessagesTimeline.tsx+11| ... | @@ -1,9 +1,11 @@ | ... | @@ -1,9 +1,11 @@ |
| 1 | import { | 1 | import { |
| 2 | type EnvironmentId, | 2 | type EnvironmentId, |
| 3 | type MessageId, | 3 | type MessageId, |
| 4 | type ScopedThreadRef, | ||
| 4 | type ServerProviderSkill, | 5 | type ServerProviderSkill, |
| 5 | type TurnId, | 6 | type TurnId, |
| 6 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | import { parseScopedThreadKey } from "@t3tools/client-runtime"; | ||
| 7 | import { | 9 | import { |
| 8 | createContext, | 10 | createContext, |
| 9 | Fragment, | 11 | Fragment, |
| ... | @@ -109,6 +111,7 @@ import { | ... | @@ -109,6 +111,7 @@ import { |
| 109 | interface TimelineRowSharedState { | 111 | interface TimelineRowSharedState { |
| 110 | timestampFormat: TimestampFormat; | 112 | timestampFormat: TimestampFormat; |
| 111 | routeThreadKey: string; | 113 | routeThreadKey: string; |
| 114 | threadRef: ScopedThreadRef | null; | ||
| 112 | markdownCwd: string | undefined; | 115 | markdownCwd: string | undefined; |
| 113 | resolvedTheme: "light" | "dark"; | 116 | resolvedTheme: "light" | "dark"; |
| 114 | workspaceRoot: string | undefined; | 117 | workspaceRoot: string | undefined; |
| ... | @@ -305,6 +308,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -305,6 +308,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 305 | () => ({ | 308 | () => ({ |
| 306 | timestampFormat, | 309 | timestampFormat, |
| 307 | routeThreadKey, | 310 | routeThreadKey, |
| 311 | threadRef: parseScopedThreadKey(routeThreadKey), | ||
| 308 | markdownCwd, | 312 | markdownCwd, |
| 309 | resolvedTheme, | 313 | resolvedTheme, |
| 310 | workspaceRoot, | 314 | workspaceRoot, |
| ... | @@ -562,6 +566,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess | ... | @@ -562,6 +566,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess |
| 562 | <ChatMarkdown | 566 | <ChatMarkdown |
| 563 | text={messageText} | 567 | text={messageText} |
| 564 | cwd={ctx.markdownCwd} | 568 | cwd={ctx.markdownCwd} |
| 569 | threadRef={ctx.threadRef ?? undefined} | ||
| 565 | isStreaming={Boolean(row.message.streaming)} | 570 | isStreaming={Boolean(row.message.streaming)} |
| 566 | skills={ctx.skills} | 571 | skills={ctx.skills} |
| 567 | /> | 572 | /> |
| ... | @@ -625,6 +630,7 @@ function ProposedPlanTimelineRow({ | ... | @@ -625,6 +630,7 @@ function ProposedPlanTimelineRow({ |
| 625 | <ProposedPlanCard | 630 | <ProposedPlanCard |
| 626 | planMarkdown={row.proposedPlan.planMarkdown} | 631 | planMarkdown={row.proposedPlan.planMarkdown} |
| 627 | environmentId={ctx.activeThreadEnvironmentId} | 632 | environmentId={ctx.activeThreadEnvironmentId} |
| 633 | threadRef={ctx.threadRef ?? undefined} | ||
| 628 | cwd={ctx.markdownCwd} | 634 | cwd={ctx.markdownCwd} |
| 629 | workspaceRoot={ctx.workspaceRoot} | 635 | workspaceRoot={ctx.workspaceRoot} |
| 630 | /> | 636 | /> |
| ... | @@ -1028,6 +1034,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { | ... | @@ -1028,6 +1034,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 1028 | skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; | 1034 | skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; |
| 1029 | markdownCwd: string | undefined; | 1035 | markdownCwd: string | undefined; |
| 1030 | }) { | 1036 | }) { |
| 1037 | const ctx = use(TimelineRowCtx); | ||
| 1031 | const renderInlineMarkdownSegment = (text: string, key: string) => { | 1038 | const renderInlineMarkdownSegment = (text: string, key: string) => { |
| 1032 | const leadingWhitespace = /^\s+/.exec(text)?.[0] ?? ""; | 1039 | const leadingWhitespace = /^\s+/.exec(text)?.[0] ?? ""; |
| 1033 | const textWithoutLeadingWhitespace = text.slice(leadingWhitespace.length); | 1040 | const textWithoutLeadingWhitespace = text.slice(leadingWhitespace.length); |
| ... | @@ -1044,6 +1051,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { | ... | @@ -1044,6 +1051,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 1044 | <ChatMarkdown | 1051 | <ChatMarkdown |
| 1045 | text={content} | 1052 | text={content} |
| 1046 | cwd={props.markdownCwd} | 1053 | cwd={props.markdownCwd} |
| 1054 | threadRef={ctx.threadRef ?? undefined} | ||
| 1047 | skills={props.skills} | 1055 | skills={props.skills} |
| 1048 | className="text-foreground" | 1056 | className="text-foreground" |
| 1049 | lineBreaks | 1057 | lineBreaks |
| ... | @@ -1065,6 +1073,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { | ... | @@ -1065,6 +1073,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 1065 | <ChatMarkdown | 1073 | <ChatMarkdown |
| 1066 | text={segment.text.trim()} | 1074 | text={segment.text.trim()} |
| 1067 | cwd={props.markdownCwd} | 1075 | cwd={props.markdownCwd} |
| 1076 | threadRef={ctx.threadRef ?? undefined} | ||
| 1068 | skills={props.skills} | 1077 | skills={props.skills} |
| 1069 | className="text-foreground" | 1078 | className="text-foreground" |
| 1070 | lineBreaks | 1079 | lineBreaks |
| ... | @@ -1152,6 +1161,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { | ... | @@ -1152,6 +1161,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 1152 | key="user-message-terminal-context-inline-text" | 1161 | key="user-message-terminal-context-inline-text" |
| 1153 | text={props.text} | 1162 | text={props.text} |
| 1154 | cwd={props.markdownCwd} | 1163 | cwd={props.markdownCwd} |
| 1164 | threadRef={ctx.threadRef ?? undefined} | ||
| 1155 | skills={props.skills} | 1165 | skills={props.skills} |
| 1156 | className="text-foreground" | 1166 | className="text-foreground" |
| 1157 | lineBreaks | 1167 | lineBreaks |
| ... | @@ -1176,6 +1186,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { | ... | @@ -1176,6 +1186,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 1176 | <ChatMarkdown | 1186 | <ChatMarkdown |
| 1177 | text={props.text} | 1187 | text={props.text} |
| 1178 | cwd={props.markdownCwd} | 1188 | cwd={props.markdownCwd} |
| 1189 | threadRef={ctx.threadRef ?? undefined} | ||
| 1179 | skills={props.skills} | 1190 | skills={props.skills} |
| 1180 | className="text-foreground" | 1191 | className="text-foreground" |
| 1181 | lineBreaks | 1192 | lineBreaks |
apps/web/src/components/chat/ProposedPlanCard.tsx+15-3| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { memo, useState, useId } from "react"; | 1 | import { memo, useState, useId } from "react"; |
| 2 | import type { EnvironmentId } from "@t3tools/contracts"; | 2 | import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; |
| 3 | import { | 3 | import { |
| 4 | buildCollapsedProposedPlanPreviewMarkdown, | 4 | buildCollapsedProposedPlanPreviewMarkdown, |
| 5 | buildProposedPlanMarkdownFilename, | 5 | buildProposedPlanMarkdownFilename, |
| ... | @@ -31,11 +31,13 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; | ... | @@ -31,11 +31,13 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 31 | export const ProposedPlanCard = memo(function ProposedPlanCard({ | 31 | export const ProposedPlanCard = memo(function ProposedPlanCard({ |
| 32 | planMarkdown, | 32 | planMarkdown, |
| 33 | environmentId, | 33 | environmentId, |
| 34 | threadRef, | ||
| 34 | cwd, | 35 | cwd, |
| 35 | workspaceRoot, | 36 | workspaceRoot, |
| 36 | }: { | 37 | }: { |
| 37 | planMarkdown: string; | 38 | planMarkdown: string; |
| 38 | environmentId: EnvironmentId; | 39 | environmentId: EnvironmentId; |
| 40 | threadRef?: ScopedThreadRef | undefined; | ||
| 39 | cwd: string | undefined; | 41 | cwd: string | undefined; |
| 40 | workspaceRoot: string | undefined; | 42 | workspaceRoot: string | undefined; |
| 41 | }) { | 43 | }) { |
| ... | @@ -163,9 +165,19 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ | ... | @@ -163,9 +165,19 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ |
| 163 | <div className="mt-4"> | 165 | <div className="mt-4"> |
| 164 | <div className={cn("relative", canCollapse && !expanded && "max-h-104 overflow-hidden")}> | 166 | <div className={cn("relative", canCollapse && !expanded && "max-h-104 overflow-hidden")}> |
| 165 | {canCollapse && !expanded ? ( | 167 | {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 | /> | ||
| 167 | ) : ( | 174 | ) : ( |
| 168 | <ChatMarkdown text={displayedPlanMarkdown} cwd={cwd} isStreaming={false} /> | 175 | <ChatMarkdown |
| 176 | text={displayedPlanMarkdown} | ||
| 177 | cwd={cwd} | ||
| 178 | threadRef={threadRef} | ||
| 179 | isStreaming={false} | ||
| 180 | /> | ||
| 169 | )} | 181 | )} |
| 170 | {canCollapse && !expanded ? ( | 182 | {canCollapse && !expanded ? ( |
| 171 | <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" /> | 183 | <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", () => { | ... | @@ -38,4 +38,38 @@ describe("PreviewChromeRow", () => { |
| 38 | 38 | ||
| 39 | previouslyFocused.remove(); | 39 | previouslyFocused.remove(); |
| 40 | }); | 40 | }); |
| 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 | }); | ||
| 41 | }); | 75 | }); |
apps/web/src/components/preview/PreviewChromeRow.tsx+23-9| ... | @@ -3,7 +3,6 @@ import { | ... | @@ -3,7 +3,6 @@ import { |
| 3 | ArrowRight, | 3 | ArrowRight, |
| 4 | Camera, | 4 | Camera, |
| 5 | ExternalLink, | 5 | ExternalLink, |
| 6 | Globe, | ||
| 7 | MousePointerClick, | 6 | MousePointerClick, |
| 8 | RotateCw, | 7 | RotateCw, |
| 9 | } from "lucide-react"; | 8 | } from "lucide-react"; |
| ... | @@ -23,6 +22,7 @@ import { cn } from "~/lib/utils"; | ... | @@ -23,6 +22,7 @@ import { cn } from "~/lib/utils"; |
| 23 | 22 | ||
| 24 | interface Props { | 23 | interface Props { |
| 25 | url: string; | 24 | url: string; |
| 25 | displayUrl?: string | undefined; | ||
| 26 | loading: boolean; | 26 | loading: boolean; |
| 27 | loadProgress: number; | 27 | loadProgress: number; |
| 28 | canGoBack: boolean; | 28 | canGoBack: boolean; |
| ... | @@ -61,6 +61,7 @@ const NOOP = () => {}; | ... | @@ -61,6 +61,7 @@ const NOOP = () => {}; |
| 61 | 61 | ||
| 62 | export function PreviewChromeRow({ | 62 | export function PreviewChromeRow({ |
| 63 | url, | 63 | url, |
| 64 | displayUrl, | ||
| 64 | loading, | 65 | loading, |
| 65 | loadProgress, | 66 | loadProgress, |
| 66 | canGoBack, | 67 | canGoBack, |
| ... | @@ -84,6 +85,7 @@ export function PreviewChromeRow({ | ... | @@ -84,6 +85,7 @@ export function PreviewChromeRow({ |
| 84 | }: Props) { | 85 | }: Props) { |
| 85 | const inputRef = useRef<HTMLInputElement | null>(null); | 86 | const inputRef = useRef<HTMLInputElement | null>(null); |
| 86 | const [draft, setDraft] = useState(url); | 87 | const [draft, setDraft] = useState(url); |
| 88 | const [inputFocused, setInputFocused] = useState(false); | ||
| 87 | 89 | ||
| 88 | // Sync the input with external URL changes, but only when the user isn't | 90 | // Sync the input with external URL changes, but only when the user isn't |
| 89 | // actively typing (preserves in-progress edits during navigation events). | 91 | // actively typing (preserves in-progress edits during navigation events). |
| ... | @@ -96,7 +98,6 @@ export function PreviewChromeRow({ | ... | @@ -96,7 +98,6 @@ export function PreviewChromeRow({ |
| 96 | const node = inputRef.current; | 98 | const node = inputRef.current; |
| 97 | if (!node) return; | 99 | if (!node) return; |
| 98 | node.focus(); | 100 | node.focus(); |
| 99 | node.select(); | ||
| 100 | }, [focusUrlNonce]); | 101 | }, [focusUrlNonce]); |
| 101 | 102 | ||
| 102 | const submit = (event?: FormEvent | KeyboardEvent) => { | 103 | const submit = (event?: FormEvent | KeyboardEvent) => { |
| ... | @@ -167,14 +168,23 @@ export function PreviewChromeRow({ | ... | @@ -167,14 +168,23 @@ export function PreviewChromeRow({ |
| 167 | </Tooltip> | 168 | </Tooltip> |
| 168 | </div> | 169 | </div> |
| 169 | 170 | ||
| 170 | <InputGroup className="h-7 flex-1 rounded-md"> | 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"> |
| 171 | <InputGroupAddon align="inline-start"> | ||
| 172 | <Globe className="size-3.5 text-muted-foreground" aria-hidden /> | ||
| 173 | </InputGroupAddon> | ||
| 174 | <InputGroupInput | 172 | <InputGroupInput |
| 175 | ref={inputRef} | 173 | 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 | )} | ||
| 177 | onChange={(event) => setDraft(event.target.value)} | 178 | 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 | }} | ||
| 178 | onKeyDown={(event) => { | 188 | onKeyDown={(event) => { |
| 179 | if (event.key === "Enter") submit(event); | 189 | if (event.key === "Enter") submit(event); |
| 180 | if (event.key === "Escape") { | 190 | if (event.key === "Escape") { |
| ... | @@ -187,10 +197,14 @@ export function PreviewChromeRow({ | ... | @@ -187,10 +197,14 @@ export function PreviewChromeRow({ |
| 187 | spellCheck={false} | 197 | spellCheck={false} |
| 188 | disabled={inputDisabled} | 198 | disabled={inputDisabled} |
| 189 | data-preview-url-input | 199 | data-preview-url-input |
| 200 | title={!inputFocused && displayUrl ? url : undefined} | ||
| 190 | size="sm" | 201 | size="sm" |
| 191 | /> | 202 | /> |
| 192 | {onOpenInBrowser ? ( | 203 | {onOpenInBrowser && !inputFocused ? ( |
| 193 | <InputGroupAddon align="inline-end"> | 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 | > | ||
| 194 | <Tooltip> | 208 | <Tooltip> |
| 195 | <TooltipTrigger | 209 | <TooltipTrigger |
| 196 | render={ | 210 | render={ |
apps/web/src/components/preview/PreviewView.tsx+12| ... | @@ -14,11 +14,13 @@ import { | ... | @@ -14,11 +14,13 @@ import { |
| 14 | import { ensureLocalApi } from "~/localApi"; | 14 | import { ensureLocalApi } from "~/localApi"; |
| 15 | import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore"; | 15 | import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore"; |
| 16 | import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; | 16 | import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; |
| 17 | import { readEnvironmentConnection } from "~/environments/runtime"; | ||
| 17 | 18 | ||
| 18 | import { previewBridge } from "./previewBridge"; | 19 | import { previewBridge } from "./previewBridge"; |
| 19 | import { subscribePreviewAction } from "./previewActionBus"; | 20 | import { subscribePreviewAction } from "./previewActionBus"; |
| 20 | import { openPreviewSession } from "./openPreviewSession"; | 21 | import { openPreviewSession } from "./openPreviewSession"; |
| 21 | import { PreviewChromeRow } from "./PreviewChromeRow"; | 22 | import { PreviewChromeRow } from "./PreviewChromeRow"; |
| 23 | import { formatPreviewUrl } from "./previewUrlPresentation"; | ||
| 22 | import { PreviewEmptyState } from "./PreviewEmptyState"; | 24 | import { PreviewEmptyState } from "./PreviewEmptyState"; |
| 23 | import { PreviewMoreMenu } from "./PreviewMoreMenu"; | 25 | import { PreviewMoreMenu } from "./PreviewMoreMenu"; |
| 24 | import { PreviewUnreachable } from "./PreviewUnreachable"; | 26 | import { PreviewUnreachable } from "./PreviewUnreachable"; |
| ... | @@ -87,6 +89,15 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, | ... | @@ -87,6 +89,15 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, |
| 87 | const showEmptyState = shouldShowPreviewEmptyState(snapshot); | 89 | const showEmptyState = shouldShowPreviewEmptyState(snapshot); |
| 88 | const controller = desktopOverlay?.controller ?? "none"; | 90 | const controller = desktopOverlay?.controller ?? "none"; |
| 89 | const loadProgress = useLoadingProgress(loading); | 91 | 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; | ||
| 90 | 101 | ||
| 91 | const handleSubmitUrl = useCallback( | 102 | const handleSubmitUrl = useCallback( |
| 92 | async (next: string) => { | 103 | async (next: string) => { |
| ... | @@ -501,6 +512,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, | ... | @@ -501,6 +512,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, |
| 501 | > | 512 | > |
| 502 | <PreviewChromeRow | 513 | <PreviewChromeRow |
| 503 | url={url} | 514 | url={url} |
| 515 | displayUrl={displayUrl} | ||
| 504 | loading={loading} | 516 | loading={loading} |
| 505 | loadProgress={loadProgress} | 517 | loadProgress={loadProgress} |
| 506 | canGoBack={canGoBack} | 518 | canGoBack={canGoBack} |
apps/web/src/components/preview/previewUrlPresentation.test.ts created+45| ... | @@ -0,0 +1,45 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { formatPreviewUrl } from "./previewUrlPresentation"; | ||
| 4 | |||
| 5 | describe("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 @@ | ||
| 1 | interface PreviewUrlPresentationInput { | ||
| 2 | readonly url: string; | ||
| 3 | readonly environmentLabel: string; | ||
| 4 | readonly environmentHttpBaseUrl: string; | ||
| 5 | } | ||
| 6 | |||
| 7 | export 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 { | ... | @@ -25,6 +25,9 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { |
| 25 | filesystem: { | 25 | filesystem: { |
| 26 | browse: rpcClient.filesystem.browse, | 26 | browse: rpcClient.filesystem.browse, |
| 27 | }, | 27 | }, |
| 28 | assets: { | ||
| 29 | createUrl: rpcClient.assets.createUrl, | ||
| 30 | }, | ||
| 28 | sourceControl: { | 31 | sourceControl: { |
| 29 | lookupRepository: rpcClient.sourceControl.lookupRepository, | 32 | lookupRepository: rpcClient.sourceControl.lookupRepository, |
| 30 | cloneRepository: rpcClient.sourceControl.cloneRepository, | 33 | 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) => { | ... | @@ -127,6 +127,7 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { |
| 127 | filesystem: { | 127 | filesystem: { |
| 128 | browse: vi.fn(), | 128 | browse: vi.fn(), |
| 129 | }, | 129 | }, |
| 130 | assets: { createUrl: vi.fn() }, | ||
| 130 | sourceControl: { | 131 | sourceControl: { |
| 131 | lookupRepository: vi.fn(), | 132 | lookupRepository: vi.fn(), |
| 132 | cloneRepository: vi.fn(), | 133 | cloneRepository: vi.fn(), |
apps/web/src/store.ts-9| ... | @@ -34,7 +34,6 @@ import { | ... | @@ -34,7 +34,6 @@ import { |
| 34 | type ThreadTurnState, | 34 | type ThreadTurnState, |
| 35 | type TurnDiffSummary, | 35 | type TurnDiffSummary, |
| 36 | } from "./types"; | 36 | } from "./types"; |
| 37 | import { resolveEnvironmentHttpUrl } from "./environments/runtime"; | ||
| 38 | import { sanitizeThreadErrorMessage } from "./rpc/transportError"; | 37 | import { sanitizeThreadErrorMessage } from "./rpc/transportError"; |
| 39 | import { getThreadFromEnvironmentState } from "./threadDerivation"; | 38 | import { getThreadFromEnvironmentState } from "./threadDerivation"; |
| 40 | const isProviderDriverKindValue = Schema.is(ProviderDriverKind); | 39 | const isProviderDriverKindValue = Schema.is(ProviderDriverKind); |
| ... | @@ -173,10 +172,6 @@ function mapMessage(environmentId: EnvironmentId, message: OrchestrationMessage) | ... | @@ -173,10 +172,6 @@ function mapMessage(environmentId: EnvironmentId, message: OrchestrationMessage) |
| 173 | name: attachment.name, | 172 | name: attachment.name, |
| 174 | mimeType: attachment.mimeType, | 173 | mimeType: attachment.mimeType, |
| 175 | sizeBytes: attachment.sizeBytes, | 174 | sizeBytes: attachment.sizeBytes, |
| 176 | previewUrl: resolveEnvironmentHttpUrl({ | ||
| 177 | environmentId, | ||
| 178 | pathname: attachmentPreviewRoutePath(attachment.id), | ||
| 179 | }), | ||
| 180 | })); | 175 | })); |
| 181 | 176 | ||
| 182 | return { | 177 | return { |
| ... | @@ -1042,10 +1037,6 @@ function toLegacyProvider(providerName: string | null): ProviderDriverKind { | ... | @@ -1042,10 +1037,6 @@ function toLegacyProvider(providerName: string | null): ProviderDriverKind { |
| 1042 | return ProviderDriverKind.make("codex"); | 1037 | return ProviderDriverKind.make("codex"); |
| 1043 | } | 1038 | } |
| 1044 | 1039 | ||
| 1045 | function attachmentPreviewRoutePath(attachmentId: string): string { | ||
| 1046 | return `/attachments/${encodeURIComponent(attachmentId)}`; | ||
| 1047 | } | ||
| 1048 | |||
| 1049 | function updateThreadState( | 1040 | function updateThreadState( |
| 1050 | state: EnvironmentState, | 1041 | state: EnvironmentState, |
| 1051 | threadId: ThreadId, | 1042 | 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 { | ... | @@ -102,6 +102,9 @@ export interface WsRpcClient { |
| 102 | readonly filesystem: { | 102 | readonly filesystem: { |
| 103 | readonly browse: RpcUnaryMethod<typeof WS_METHODS.filesystemBrowse>; | 103 | readonly browse: RpcUnaryMethod<typeof WS_METHODS.filesystemBrowse>; |
| 104 | }; | 104 | }; |
| 105 | readonly assets: { | ||
| 106 | readonly createUrl: RpcUnaryMethod<typeof WS_METHODS.assetsCreateUrl>; | ||
| 107 | }; | ||
| 105 | readonly sourceControl: { | 108 | readonly sourceControl: { |
| 106 | readonly lookupRepository: RpcUnaryMethod<typeof WS_METHODS.sourceControlLookupRepository>; | 109 | readonly lookupRepository: RpcUnaryMethod<typeof WS_METHODS.sourceControlLookupRepository>; |
| 107 | readonly cloneRepository: RpcUnaryMethod<typeof WS_METHODS.sourceControlCloneRepository>; | 110 | readonly cloneRepository: RpcUnaryMethod<typeof WS_METHODS.sourceControlCloneRepository>; |
| ... | @@ -272,6 +275,10 @@ export function createWsRpcClient( | ... | @@ -272,6 +275,10 @@ export function createWsRpcClient( |
| 272 | filesystem: { | 275 | filesystem: { |
| 273 | browse: (input) => transport.request((client) => client[WS_METHODS.filesystemBrowse](input)), | 276 | browse: (input) => transport.request((client) => client[WS_METHODS.filesystemBrowse](input)), |
| 274 | }, | 277 | }, |
| 278 | assets: { | ||
| 279 | createUrl: (input) => | ||
| 280 | transport.request((client) => client[WS_METHODS.assetsCreateUrl](input)), | ||
| 281 | }, | ||
| 275 | sourceControl: { | 282 | sourceControl: { |
| 276 | lookupRepository: (input) => | 283 | lookupRepository: (input) => |
| 277 | transport.request((client) => client[WS_METHODS.sourceControlLookupRepository](input)), | 284 | transport.request((client) => client[WS_METHODS.sourceControlLookupRepository](input)), |
packages/contracts/src/assets.ts created+38| ... | @@ -0,0 +1,38 @@ | ||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | |||
| 3 | import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; | ||
| 4 | |||
| 5 | const ASSET_PATH_MAX_LENGTH = 1024; | ||
| 6 | |||
| 7 | export 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 | ]); | ||
| 19 | export type AssetResource = typeof AssetResource.Type; | ||
| 20 | |||
| 21 | export const AssetCreateUrlInput = Schema.Struct({ | ||
| 22 | resource: AssetResource, | ||
| 23 | }); | ||
| 24 | export type AssetCreateUrlInput = typeof AssetCreateUrlInput.Type; | ||
| 25 | |||
| 26 | export const AssetCreateUrlResult = Schema.Struct({ | ||
| 27 | relativeUrl: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)), | ||
| 28 | expiresAt: Schema.Number, | ||
| 29 | }); | ||
| 30 | export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type; | ||
| 31 | |||
| 32 | export 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"; | ... | @@ -21,6 +21,7 @@ export * from "./orchestration.ts"; |
| 21 | export * from "./editor.ts"; | 21 | export * from "./editor.ts"; |
| 22 | export * from "./project.ts"; | 22 | export * from "./project.ts"; |
| 23 | export * from "./filesystem.ts"; | 23 | export * from "./filesystem.ts"; |
| 24 | export * from "./assets.ts"; | ||
| 24 | export * from "./review.ts"; | 25 | export * from "./review.ts"; |
| 25 | export * from "./preview.ts"; | 26 | export * from "./preview.ts"; |
| 26 | export * from "./previewAutomation.ts"; | 27 | export * from "./previewAutomation.ts"; |
packages/contracts/src/ipc.ts+4| ... | @@ -20,6 +20,7 @@ import type { | ... | @@ -20,6 +20,7 @@ import type { |
| 20 | } from "./git.ts"; | 20 | } from "./git.ts"; |
| 21 | import type { ReviewDiffPreviewInput, ReviewDiffPreviewResult } from "./review.ts"; | 21 | import type { ReviewDiffPreviewInput, ReviewDiffPreviewResult } from "./review.ts"; |
| 22 | import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts"; | 22 | import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts"; |
| 23 | import type { AssetCreateUrlInput, AssetCreateUrlResult } from "./assets.ts"; | ||
| 23 | import type { | 24 | import type { |
| 24 | ProjectSearchEntriesInput, | 25 | ProjectSearchEntriesInput, |
| 25 | ProjectSearchEntriesResult, | 26 | ProjectSearchEntriesResult, |
| ... | @@ -1115,6 +1116,9 @@ export interface EnvironmentApi { | ... | @@ -1115,6 +1116,9 @@ export interface EnvironmentApi { |
| 1115 | filesystem: { | 1116 | filesystem: { |
| 1116 | browse: (input: FilesystemBrowseInput) => Promise<FilesystemBrowseResult>; | 1117 | browse: (input: FilesystemBrowseInput) => Promise<FilesystemBrowseResult>; |
| 1117 | }; | 1118 | }; |
| 1119 | assets: { | ||
| 1120 | createUrl: (input: AssetCreateUrlInput) => Promise<AssetCreateUrlResult>; | ||
| 1121 | }; | ||
| 1118 | sourceControl: { | 1122 | sourceControl: { |
| 1119 | lookupRepository: ( | 1123 | lookupRepository: ( |
| 1120 | input: SourceControlRepositoryLookupInput, | 1124 | input: SourceControlRepositoryLookupInput, |
packages/contracts/src/rpc.ts+9| ... | @@ -13,6 +13,7 @@ import { | ... | @@ -13,6 +13,7 @@ import { |
| 13 | FilesystemBrowseResult, | 13 | FilesystemBrowseResult, |
| 14 | FilesystemBrowseError, | 14 | FilesystemBrowseError, |
| 15 | } from "./filesystem.ts"; | 15 | } from "./filesystem.ts"; |
| 16 | import { AssetAccessError, AssetCreateUrlInput, AssetCreateUrlResult } from "./assets.ts"; | ||
| 16 | import { | 17 | import { |
| 17 | GitActionProgressEvent, | 18 | GitActionProgressEvent, |
| 18 | VcsSwitchRefInput, | 19 | VcsSwitchRefInput, |
| ... | @@ -148,6 +149,7 @@ export const WS_METHODS = { | ... | @@ -148,6 +149,7 @@ export const WS_METHODS = { |
| 148 | 149 | ||
| 149 | // Filesystem methods | 150 | // Filesystem methods |
| 150 | filesystemBrowse: "filesystem.browse", | 151 | filesystemBrowse: "filesystem.browse", |
| 152 | assetsCreateUrl: "assets.createUrl", | ||
| 151 | 153 | ||
| 152 | // VCS methods | 154 | // VCS methods |
| 153 | vcsPull: "vcs.pull", | 155 | vcsPull: "vcs.pull", |
| ... | @@ -365,6 +367,12 @@ export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { | ... | @@ -365,6 +367,12 @@ export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { |
| 365 | error: Schema.Union([FilesystemBrowseError, EnvironmentAuthorizationError]), | 367 | error: Schema.Union([FilesystemBrowseError, EnvironmentAuthorizationError]), |
| 366 | }); | 368 | }); |
| 367 | 369 | ||
| 370 | export const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, { | ||
| 371 | payload: AssetCreateUrlInput, | ||
| 372 | success: AssetCreateUrlResult, | ||
| 373 | error: Schema.Union([AssetAccessError, EnvironmentAuthorizationError]), | ||
| 374 | }); | ||
| 375 | |||
| 368 | export const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, { | 376 | export const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, { |
| 369 | payload: VcsStatusInput, | 377 | payload: VcsStatusInput, |
| 370 | success: VcsStatusStreamEvent, | 378 | success: VcsStatusStreamEvent, |
| ... | @@ -672,6 +680,7 @@ export const WsRpcGroup = RpcGroup.make( | ... | @@ -672,6 +680,7 @@ export const WsRpcGroup = RpcGroup.make( |
| 672 | WsProjectsWriteFileRpc, | 680 | WsProjectsWriteFileRpc, |
| 673 | WsShellOpenInEditorRpc, | 681 | WsShellOpenInEditorRpc, |
| 674 | WsFilesystemBrowseRpc, | 682 | WsFilesystemBrowseRpc, |
| 683 | WsAssetsCreateUrlRpc, | ||
| 675 | WsSubscribeVcsStatusRpc, | 684 | WsSubscribeVcsStatusRpc, |
| 676 | WsVcsPullRpc, | 685 | WsVcsPullRpc, |
| 677 | WsVcsRefreshStatusRpc, | 686 | WsVcsRefreshStatusRpc, |