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