diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index 772d5e8cc14..9b830344d75 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -5,7 +5,7 @@ import { View } from "react-native"; import type { EnvironmentId } from "@t3tools/contracts"; import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon"; import { useThemeColor } from "../lib/useThemeColor"; -import { useAssetUrl } from "../state/assets"; +import { type AssetRequestSource, useAssetRequestSource } from "../state/assets"; /* ─── Favicon cache (matches web pattern) ────────────────────────────── */ const loadedFaviconUrls = new Set(); @@ -19,18 +19,20 @@ export function ProjectFavicon(props: { readonly workspaceRoot?: string | null; }) { const size = props.size ?? 42; - const faviconUrl = useAssetUrl( + const faviconSource = useAssetRequestSource( props.environmentId, props.workspaceRoot === null || props.workspaceRoot === undefined ? null : { _tag: "project-favicon", cwd: props.workspaceRoot }, ); - const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl; + const renderableFaviconSource = isProjectFaviconFallbackUrl(faviconSource?.uri ?? null) + ? null + : faviconSource; return ( (() => - props.faviconUrl && loadedFaviconUrls.has(props.faviconUrl) ? "loaded" : "loading", + props.faviconSource && loadedFaviconUrls.has(props.faviconSource.uri) ? "loaded" : "loading", ); - const showImage = props.faviconUrl !== null && status === "loaded"; + const showImage = props.faviconSource !== null && status === "loaded"; return ( { - if (props.faviconUrl) loadedFaviconUrls.add(props.faviconUrl); + if (props.faviconSource) loadedFaviconUrls.add(props.faviconSource.uri); setStatus("loaded"); }} onError={() => setStatus("error")} diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 012f99536d2..13172b4d286 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -40,6 +40,7 @@ import { SourceFileSurface } from "./SourceFileSurface"; import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane"; import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview"; import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; +import type { AssetRequestSource } from "../../state/assets"; import { basename, isBrowserPreviewFile, @@ -83,7 +84,7 @@ function defaultViewMode(path: string | null): FileViewMode { function FileContent(props: { readonly activeMode: FileViewMode; - readonly previewUri: string | null; + readonly previewSource: AssetRequestSource | null; readonly fileContents: string | null; readonly fileError: string | null; readonly relativePath: string; @@ -97,18 +98,18 @@ function FileContent(props: { if (props.activeMode === "preview" && isImageFile) { if (isSvgImagePreviewFile(props.relativePath)) { - return ; + return ; } return ( ); } if (props.activeMode === "preview" && isBrowserFile) { - return ; + return ; } if (props.fileError && props.fileContents === null) { @@ -482,16 +483,19 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { : defaultViewMode(relativePath); const resolvedActiveMode = canPreview ? activeMode : "source"; const assetPreviewPath = isBrowserFile || isImageFile ? relativePath : null; - const assetPreviewUri = useWorkspaceFileAssetUrl({ + const assetPreviewSource = useWorkspaceFileAssetUrl({ cwd, environmentId, relativePath: assetPreviewPath, threadId, }); - const previewUri = - assetPreviewUri === null || previewRevision === 0 - ? assetPreviewUri - : `${assetPreviewUri}${assetPreviewUri.includes("?") ? "&" : "?"}revision=${previewRevision}`; + const previewSource = + assetPreviewSource === null || previewRevision === 0 + ? assetPreviewSource + : { + ...assetPreviewSource, + uri: `${assetPreviewSource.uri}${assetPreviewSource.uri.includes("?") ? "&" : "?"}revision=${previewRevision}`, + }; const needsFileContents = relativePath !== null && (resolvedActiveMode === "source" || isMarkdownPreviewFile(relativePath)); @@ -629,11 +633,13 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { > Copy path - {isBrowserFile && typeof assetPreviewUri === "string" ? ( + {isBrowserFile && + assetPreviewSource !== null && + assetPreviewSource.headers === undefined ? ( { - void tryOpenExternalUrl(assetPreviewUri, "file-preview"); + void tryOpenExternalUrl(assetPreviewSource.uri, "file-preview"); }} > Open in Safari @@ -653,7 +659,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { (null); const [fullScreenVisible, setFullScreenVisible] = useState(false); const imageSource = useMemo( - () => ({ uri: props.uri, cache: "force-cache" as const }), - [props.uri], + () => ({ + uri: props.source.uri, + headers: props.source.headers, + cache: "force-cache" as const, + }), + [props.source], ); const fullScreenImages = useMemo(() => [imageSource], [imageSource]); @@ -89,16 +94,16 @@ function CachedWorkspaceFileImagePreview(props: { return ( ); } export function WorkspaceFileImagePreview(props: { readonly accessibilityLabel: string; - readonly uri: string | null; + readonly source: AssetRequestSource | null; }) { - if (props.uri === null) { + if (props.source === null) { return ( @@ -109,10 +114,15 @@ export function WorkspaceFileImagePreview(props: { ); } - return ( + return props.source.headers === undefined ? ( + ) : ( + ); } diff --git a/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx b/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx index efa7ee88cba..6b088afe596 100644 --- a/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx +++ b/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx @@ -1,15 +1,30 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { ActivityIndicator, View } from "react-native"; import { WebView } from "react-native-webview"; import { AppText as Text } from "../../components/AppText"; import { LoadingStrip } from "../../components/LoadingStrip"; +import type { AssetRequestSource } from "../../state/assets"; -export function WorkspaceFileWebPreview(props: { readonly uri: string | null }) { +export function WorkspaceFileWebPreview(props: { readonly source: AssetRequestSource | null }) { const [loadProgress, setLoadProgress] = useState(0); const [loadError, setLoadError] = useState(null); + const webViewSource = useMemo(() => { + if (props.source === null) return null; + if (props.source.surfaceBinding === undefined) return { uri: props.source.uri }; + const assetUrl = new URL(props.source.uri); + return { + uri: props.source.surfaceBinding.uri, + method: "POST" as const, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + credential: props.source.surfaceBinding.credential, + redirect: `${assetUrl.pathname}${assetUrl.search}`, + }), + }; + }, [props.source]); - if (props.uri === null) { + if (webViewSource === null) { return ( @@ -28,7 +43,7 @@ export function WorkspaceFileWebPreview(props: { readonly uri: string | null }) ) : null} ) => void; }) { - const uri = useAssetUrl(props.environmentId, { + const source = useAssetRequestSource(props.environmentId, { _tag: "attachment", attachmentId: props.attachmentId, }); - if (uri === null) { + if (source === null) { return ( @@ -161,8 +161,15 @@ function MessageAttachmentImage(props: { } return ( - props.onPressImage(uri)}> - + props.onPressImage(source.uri, source.headers)} + > + ); } diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index b8b827585ea..94d29cc2400 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -7,15 +7,25 @@ import { connectionAtomRuntime } from "../connection/runtime"; import { usePreparedConnection } from "./session"; export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); +const ASSET_SURFACE_CREDENTIAL_HEADER = "x-t3-asset-surface"; + +export interface AssetRequestSource { + readonly uri: string; + readonly headers?: Record; + readonly surfaceBinding?: { + readonly uri: string; + readonly credential: string; + }; +} const EMPTY_ASSET_URL_ATOM = Atom.make(AsyncResult.initial(false)).pipe( Atom.withLabel("mobile-asset-url:empty"), ); -export function useAssetUrl( +export function useAssetRequestSource( environmentId: EnvironmentId | null, resource: AssetResource | null, -): string | null { +): AssetRequestSource | null { const preparedConnection = usePreparedConnection(environmentId); const result = useAtomValue( environmentId === null || resource === null @@ -25,5 +35,24 @@ export function useAssetUrl( if (preparedConnection._tag === "None" || result._tag !== "Success") { return null; } - return resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); + const uri = resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); + if (uri === null) return null; + const surfaceCredential = result.value.surfaceCredential; + return { + uri, + ...(surfaceCredential === null || surfaceCredential === undefined + ? {} + : { + headers: { + [ASSET_SURFACE_CREDENTIAL_HEADER]: surfaceCredential, + }, + surfaceBinding: { + uri: new URL( + "/api/assets/relay/surface", + preparedConnection.value.httpBaseUrl, + ).toString(), + credential: surfaceCredential, + }, + }), + }; } diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 06a22754e55..6f872157b60 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -1,18 +1,80 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ThreadId } from "@t3tools/contracts"; +import { + ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY, + AuthSessionId, + ThreadId, + type OrchestrationReadModel, +} from "@t3tools/contracts"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import { describe, expect, it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import * as TestClock from "effect/testing/TestClock"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as SessionStore from "../auth/SessionStore.ts"; import * as ServerConfig from "../config.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; -import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts"; +import { + ASSET_ROUTE_PREFIX, + ASSET_SURFACE_RELAY_PREFIX, + classifyAttachmentAudience, + issueAssetUrl as issueAssetUrlImpl, + resolveAsset as resolveAssetImpl, + type AssetResolution, +} from "./AssetAccess.ts"; + +const TEST_SURFACE_SESSION_ID = AuthSessionId.make("asset-access-test-surface"); +const TEST_SURFACE_SESSION_EXPIRES_AT = DateTime.makeUnsafe("2999-01-01T00:00:00.000Z"); +const issuedSurfaceCredentialByToken = new Map(); + +const assetRouteSuffix = (relativeUrl: string) => { + const prefix = relativeUrl.startsWith(`${ASSET_SURFACE_RELAY_PREFIX}/`) + ? ASSET_SURFACE_RELAY_PREFIX + : ASSET_ROUTE_PREFIX; + return relativeUrl.slice(`${prefix}/`.length); +}; + +const issueAssetUrl = (input: Parameters[0]) => + issueAssetUrlImpl({ + ...input, + clientCapabilities: [ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY], + surfaceSessionId: TEST_SURFACE_SESSION_ID, + surfaceSessionExpiresAt: TEST_SURFACE_SESSION_EXPIRES_AT, + }).pipe( + Effect.tap((result) => + Effect.sync(() => { + const token = assetRouteSuffix(result.relativeUrl).split("/", 1)[0]; + if (token && result.surfaceCredential) { + issuedSurfaceCredentialByToken.set(token, result.surfaceCredential); + } + }), + ), + ); + +const resolveAsset = (token: string, relativePath: string, requestProof: string | null = null) => + resolveAssetImpl( + token, + relativePath, + requestProof === "private" + ? (issuedSurfaceCredentialByToken.get(token) ?? null) + : requestProof === "factory" + ? null + : requestProof, + ); + +const summarizeAssetResolution = (resolution: AssetResolution | null) => { + if (resolution?.kind !== "file") return resolution; + resolution.stream.destroy(); + return { kind: resolution.kind, path: resolution.path }; +}; const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-asset-access-test-", @@ -22,6 +84,19 @@ const testLayer = Layer.mergeAll( WorkspacePaths.layer, ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer)), ServerSecretStore.layer.pipe(Layer.provide(configLayer)), + Layer.mock(SessionStore.SessionStore)({ + cookieName: "t3_asset_access_test", + isActive: () => Effect.succeed(true), + }), + Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ + getCommandReadModel: () => + Effect.succeed({ + snapshotSequence: 0, + projects: [], + threads: [], + updatedAt: "1970-01-01T00:00:00.000Z", + } satisfies OrchestrationReadModel), + }), ).pipe(Layer.provideMerge(NodeServices.layer)); describe("AssetAccess", () => { @@ -48,21 +123,19 @@ describe("AssetAccess", () => { }, workspaceRoot: root, }); - const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const suffix = assetRouteSuffix(result.relativeUrl); const separatorIndex = suffix.indexOf("/"); const token = suffix.slice(0, separatorIndex); - expect(yield* resolveAsset(token, "report.html")).toEqual({ - kind: "file", - path: canonicalHtmlPath, - }); - expect(yield* resolveAsset(token, "report.css")).toEqual({ - kind: "file", - path: canonicalCssPath, - }); - expect(yield* resolveAsset(token, "../secret.txt")).toBeNull(); - expect(yield* resolveAsset(token, ".env")).toBeNull(); - expect(yield* resolveAsset(`${token}tampered`, "report.html")).toBeNull(); + expect( + summarizeAssetResolution(yield* resolveAsset(token, "report.html", "private")), + ).toEqual({ kind: "file", path: canonicalHtmlPath }); + expect(summarizeAssetResolution(yield* resolveAsset(token, "report.css", "private"))).toEqual( + { kind: "file", path: canonicalCssPath }, + ); + expect(yield* resolveAsset(token, "../secret.txt", "private")).toBeNull(); + expect(yield* resolveAsset(token, ".env", "private")).toBeNull(); + expect(yield* resolveAsset(`${token}tampered`, "report.html", "private")).toBeNull(); }).pipe(Effect.provide(testLayer)), ); @@ -165,19 +238,138 @@ describe("AssetAccess", () => { }, workspaceRoot: root, }); - const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const suffix = assetRouteSuffix(result.relativeUrl); const separatorIndex = suffix.indexOf("/"); const token = suffix.slice(0, separatorIndex); - expect(yield* resolveAsset(token, "icon.png")).toEqual({ + expect(summarizeAssetResolution(yield* resolveAsset(token, "icon.png", "private"))).toEqual({ kind: "file", path: canonicalImagePath, }); - expect(yield* resolveAsset(token, "other.png")).toBeNull(); - expect(yield* resolveAsset(token, "../icon.png")).toBeNull(); + expect(yield* resolveAsset(token, "other.png", "private")).toBeNull(); + expect(yield* resolveAsset(token, "../icon.png", "private")).toBeNull(); }).pipe(Effect.provide(testLayer)), ); + it.effect("streams the same file handle that passed audience authorization", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-handle-workspace-", + }); + const outside = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-handle-private-", + }); + const entryPath = path.join(root, "report.html"); + const privatePath = path.join(outside, "private.html"); + yield* fileSystem.writeFileString(entryPath, "authorized contents"); + yield* fileSystem.writeFileString(privatePath, "private contents"); + + const result = yield* issueAssetUrl({ + resource: { + _tag: "workspace-file", + threadId: ThreadId.make("thread-handle-race"), + path: entryPath, + }, + workspaceRoot: root, + }); + const suffix = assetRouteSuffix(result.relativeUrl); + const separatorIndex = suffix.indexOf("/"); + const resolution = yield* resolveAsset( + suffix.slice(0, separatorIndex), + suffix.slice(separatorIndex + 1), + "private", + ); + expect(resolution?.kind).toBe("file"); + if (!resolution || resolution.kind !== "file") return; + + yield* fileSystem.remove(entryPath); + yield* fileSystem.symlink(privatePath, entryPath); + const bytes = yield* Effect.tryPromise(async () => { + const chunks: Buffer[] = []; + for await (const chunk of resolution.stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString("utf8"); + }); + expect(bytes).toBe("authorized contents"); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects assets after the signed workspace root is replaced", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parent = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-replaced-root-", + }); + const root = path.join(parent, "workspace"); + const retiredRoot = path.join(parent, "retired-workspace"); + const replacementRoot = path.join(parent, "replacement"); + const entryPath = path.join(root, "report.html"); + yield* fileSystem.makeDirectory(root); + yield* fileSystem.makeDirectory(replacementRoot); + yield* fileSystem.writeFileString(entryPath, "authorized contents"); + yield* fileSystem.writeFileString(path.join(replacementRoot, "secret.css"), "private"); + + const result = yield* issueAssetUrl({ + resource: { + _tag: "workspace-file", + threadId: ThreadId.make("thread-replaced-root"), + path: entryPath, + }, + workspaceRoot: root, + }); + const suffix = assetRouteSuffix(result.relativeUrl); + const separatorIndex = suffix.indexOf("/"); + + yield* fileSystem.rename(root, retiredRoot); + yield* fileSystem.symlink(replacementRoot, root); + expect( + yield* resolveAsset(suffix.slice(0, separatorIndex), "secret.css", "private"), + ).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("takes the strictest audience across lossy attachment thread id matches", () => { + const readModel = { + snapshotSequence: 1, + updatedAt: "2026-07-19T00:00:00.000Z", + projects: [ + { id: "project-factory", dataAudience: "factory", deletedAt: null }, + { + id: "project-private", + dataAudience: "private", + deletedAt: "2026-07-19T00:00:00.000Z", + }, + ], + threads: [ + { + id: "Thread.Foo", + projectId: "project-private", + dataAudience: "private", + deletedAt: "2026-07-19T00:00:00.000Z", + }, + { + id: "thread-foo", + projectId: "project-factory", + dataAudience: "factory", + deletedAt: null, + }, + ], + } as unknown as OrchestrationReadModel; + const collisionProjectionLayer = Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ + getCommandReadModel: () => Effect.succeed(readModel), + }); + + return Effect.gen(function* () { + expect( + yield* classifyAttachmentAudience("thread-foo-00000000-0000-4000-8000-000000000003"), + ).toBe("private"); + }).pipe(Effect.provide(collisionProjectionLayer)); + }); + it.effect("issues exact attachment capabilities by attachment id", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; @@ -191,14 +383,128 @@ describe("AssetAccess", () => { const result = yield* issueAssetUrl({ resource: { _tag: "attachment", attachmentId }, }); - const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const suffix = assetRouteSuffix(result.relativeUrl); const separatorIndex = suffix.indexOf("/"); const token = suffix.slice(0, separatorIndex); - expect(yield* resolveAsset(token, "ignored.png")).toEqual({ - kind: "file", - path: attachmentPath, + expect( + summarizeAssetResolution(yield* resolveAsset(token, "ignored.png", "private")), + ).toEqual({ kind: "file", path: attachmentPath }); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects audience claims that exceed their issuing ceiling", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const attachmentId = "thread-private-00000000-0000-4000-8000-000000000002"; + const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.png`); + yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); + yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3])); + + const error = yield* issueAssetUrl({ + resource: { _tag: "attachment", attachmentId }, + dataAudience: "private", + audienceCeiling: "factory", + }).pipe(Effect.flip); + expect(error._tag).toBe("AssetWorkspaceContextNotFoundError"); + + const privateResult = yield* issueAssetUrl({ + resource: { _tag: "attachment", attachmentId }, + dataAudience: "private", + audienceCeiling: "private", }); + const privateSuffix = assetRouteSuffix(privateResult.relativeUrl); + const privateSeparatorIndex = privateSuffix.indexOf("/"); + const privateToken = privateSuffix.slice(0, privateSeparatorIndex); + const privateFileName = privateSuffix.slice(privateSeparatorIndex + 1); + expect(yield* resolveAsset(privateToken, privateFileName)).toBeNull(); + expect(yield* resolveAsset(privateToken, privateFileName, "factory")).toBeNull(); + expect( + summarizeAssetResolution(yield* resolveAsset(privateToken, privateFileName, "private")), + ).toEqual({ kind: "file", path: attachmentPath }); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("binds private capabilities to the surface session that minted them", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const attachmentId = "thread-surface-00000000-0000-4000-8000-000000000004"; + const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.png`); + yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); + yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3])); + const sessionExpiresAt = DateTime.makeUnsafe((yield* Clock.currentTimeMillis) + 5 * 60_000); + + const owner = yield* issueAssetUrlImpl({ + resource: { _tag: "attachment", attachmentId }, + clientCapabilities: [ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY], + surfaceSessionId: AuthSessionId.make("surface-owner"), + surfaceSessionExpiresAt: sessionExpiresAt, + }); + const other = yield* issueAssetUrlImpl({ + resource: { _tag: "attachment", attachmentId }, + clientCapabilities: [ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY], + surfaceSessionId: AuthSessionId.make("surface-other"), + surfaceSessionExpiresAt: sessionExpiresAt, + }); + const suffix = assetRouteSuffix(owner.relativeUrl); + const separatorIndex = suffix.indexOf("/"); + const token = suffix.slice(0, separatorIndex); + const fileName = suffix.slice(separatorIndex + 1); + + expect(yield* resolveAssetImpl(token, fileName)).toBeNull(); + expect(yield* resolveAssetImpl(token, fileName, other.surfaceCredential)).toBeNull(); + expect( + summarizeAssetResolution(yield* resolveAssetImpl(token, fileName, owner.surfaceCredential)), + ).toEqual({ kind: "file", path: attachmentPath }); + expect(owner.expiresAt).toBe(sessionExpiresAt.epochMilliseconds); + + expect( + yield* resolveAssetImpl(token, fileName, owner.surfaceCredential).pipe( + Effect.provide( + Layer.mock(SessionStore.SessionStore)({ + cookieName: "t3_asset_access_revoked_test", + isActive: () => Effect.succeed(false), + }), + ), + ), + ).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("issues a stable surface credential across asset refreshes in one session", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const attachmentId = "thread-surface-stable-00000000-0000-4000-8000-000000000005"; + const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.png`); + yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); + yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3])); + const sessionId = AuthSessionId.make("surface-stable"); + const sessionExpiresAt = DateTime.makeUnsafe( + (yield* Clock.currentTimeMillis) + 2 * 60 * 60_000, + ); + + const first = yield* issueAssetUrlImpl({ + resource: { _tag: "attachment", attachmentId }, + clientCapabilities: [ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY], + surfaceSessionId: sessionId, + surfaceSessionExpiresAt: sessionExpiresAt, + }); + yield* TestClock.adjust("1 minute"); + const refreshed = yield* issueAssetUrlImpl({ + resource: { _tag: "attachment", attachmentId }, + clientCapabilities: [ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY], + surfaceSessionId: sessionId, + surfaceSessionExpiresAt: sessionExpiresAt, + }); + + expect(refreshed.expiresAt).toBeGreaterThan(first.expiresAt); + expect(refreshed.surfaceCredential).toBe(first.surfaceCredential); }).pipe(Effect.provide(testLayer)), ); @@ -216,12 +522,15 @@ describe("AssetAccess", () => { const faviconResult = yield* issueAssetUrl({ resource: { _tag: "project-favicon", cwd: root }, }); - const faviconSuffix = faviconResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const faviconSuffix = assetRouteSuffix(faviconResult.relativeUrl); const faviconSeparatorIndex = faviconSuffix.indexOf("/"); expect( - yield* resolveAsset( - faviconSuffix.slice(0, faviconSeparatorIndex), - faviconSuffix.slice(faviconSeparatorIndex + 1), + summarizeAssetResolution( + yield* resolveAsset( + faviconSuffix.slice(0, faviconSeparatorIndex), + faviconSuffix.slice(faviconSeparatorIndex + 1), + "private", + ), ), ).toEqual({ kind: "file", path: canonicalFaviconPath }); @@ -230,12 +539,13 @@ describe("AssetAccess", () => { resource: { _tag: "project-favicon", cwd: root }, }); expect(fallbackResult.relativeUrl.endsWith(`/${PROJECT_FAVICON_FALLBACK_MARKER}`)).toBe(true); - const fallbackSuffix = fallbackResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const fallbackSuffix = assetRouteSuffix(fallbackResult.relativeUrl); const fallbackSeparatorIndex = fallbackSuffix.indexOf("/"); expect( yield* resolveAsset( fallbackSuffix.slice(0, fallbackSeparatorIndex), fallbackSuffix.slice(fallbackSeparatorIndex + 1), + "private", ), ).toBeNull(); }).pipe(Effect.provide(testLayer)), diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index b469e0e315b..4fd398a6674 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -1,17 +1,30 @@ -import type { AssetResource } from "@t3tools/contracts"; +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; + import { + ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY, + AssetClientUpgradeRequiredError, AssetAttachmentNotFoundError, + AuthAudienceCeiling, AssetPreviewTypeValidationError, AssetProjectFaviconInspectionError, AssetProjectFaviconNotFoundError, AssetProjectFaviconResolutionError, AssetSigningKeyLoadError, + AuthSessionId, + type AssetClientCapability, AssetWorkspaceAssetInspectionError, AssetWorkspaceAssetNotFoundError, AssetWorkspaceContextNotFoundError, + AssetWorkspaceContextResolutionError, AssetWorkspacePathValidationError, AssetWorkspaceResolutionError, AssetWorkspaceRootNormalizationError, + DataAudience, + type AssetResource, + type AuthAudienceCeiling as AuthAudienceCeilingType, + type DataAudience as DataAudienceType, } from "@t3tools/contracts"; import { isWorkspaceImagePreviewPath, @@ -21,7 +34,9 @@ import { } from "@t3tools/shared/filePreview"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import * as Clock from "effect/Clock"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -35,12 +50,34 @@ import { timingSafeEqualBase64Url, } from "../auth/utils.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import { resolveAttachmentPathById } from "../attachmentStore.ts"; +import * as SessionStore from "../auth/SessionStore.ts"; +import { canReadDataAudience, strictestDataAudience } from "../auth/audienceDataPolicy.ts"; +import { + parseThreadSegmentFromAttachmentId, + resolveAttachmentPathById, + toSafeThreadAttachmentSegment, +} from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ProjectFilesystemAudienceGuard from "../project/ProjectFilesystemAudienceGuard.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; export const ASSET_ROUTE_PREFIX = "/api/assets"; +export const ASSET_SURFACE_RELAY_PREFIX = `${ASSET_ROUTE_PREFIX}/relay`; +export const ASSET_SURFACE_BIND_PATH = `${ASSET_SURFACE_RELAY_PREFIX}/surface`; +export const ASSET_SURFACE_CREDENTIAL_HEADER = "x-t3-asset-surface"; + +export function assetSurfaceCookiePrefix(sessionCookieName: string): string { + return `${sessionCookieName}_asset_surface_`; +} + +export function assetSurfaceCookieName( + sessionCookieName: string, + surfaceBindingId: string, +): string { + return `${assetSurfaceCookiePrefix(sessionCookieName)}${surfaceBindingId}`; +} const SIGNING_SECRET_NAME = "asset-access-signing-key"; const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; @@ -56,6 +93,21 @@ const PREVIEW_ASSET_EXTENSIONS = new Set([ ".woff2", ]); +const AudienceBoundAssetClaimFields = { + dataAudience: DataAudience, + audienceCeiling: AuthAudienceCeiling, + surfaceBindingId: Schema.optionalKey(Schema.NullOr(Schema.String)), +}; + +const AssetSurfaceCredentialClaimsSchema = Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("asset-surface"), + surfaceSessionId: AuthSessionId, + surfaceBindingId: Schema.String, + expiresAt: Schema.Number, +}); +type AssetSurfaceCredentialClaims = typeof AssetSurfaceCredentialClaimsSchema.Type; + const AssetClaimsSchema = Schema.Union([ Schema.Struct({ version: Schema.Literal(1), @@ -63,6 +115,7 @@ const AssetClaimsSchema = Schema.Union([ workspaceRoot: Schema.String, baseRelativePath: Schema.String, expiresAt: Schema.Number, + ...AudienceBoundAssetClaimFields, }), Schema.Struct({ version: Schema.Literal(1), @@ -70,12 +123,14 @@ const AssetClaimsSchema = Schema.Union([ workspaceRoot: Schema.String, relativePath: Schema.String, expiresAt: Schema.Number, + ...AudienceBoundAssetClaimFields, }), Schema.Struct({ version: Schema.Literal(1), kind: Schema.Literal("attachment"), attachmentId: Schema.String, expiresAt: Schema.Number, + ...AudienceBoundAssetClaimFields, }), Schema.Struct({ version: Schema.Literal(1), @@ -83,6 +138,7 @@ const AssetClaimsSchema = Schema.Union([ workspaceRoot: Schema.String, relativePath: Schema.NullOr(Schema.String), expiresAt: Schema.Number, + ...AudienceBoundAssetClaimFields, }), ]); type AssetClaims = typeof AssetClaimsSchema.Type; @@ -90,8 +146,134 @@ type AssetClaims = typeof AssetClaimsSchema.Type; const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema); const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson); const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson); +const AssetSurfaceCredentialClaimsJson = Schema.fromJsonString(AssetSurfaceCredentialClaimsSchema); +const decodeAssetSurfaceCredentialClaims = Schema.decodeUnknownOption( + AssetSurfaceCredentialClaimsJson, +); +const encodeAssetSurfaceCredentialClaims = Schema.encodeSync(AssetSurfaceCredentialClaimsJson); + +export type ResolvedAsset = { + readonly kind: "file"; + readonly path: string; + readonly contentLength: number; + readonly stream: NodeFS.ReadStream; +}; +export type AssetResolution = ResolvedAsset | { readonly kind: "forbidden" }; + +const forbiddenAsset = { kind: "forbidden" } as const satisfies AssetResolution; + +const audienceClaimsForIssue = Effect.fn("AssetAccess.audienceClaimsForIssue")(function* (input: { + readonly resource: AssetResource; + readonly claimedDataAudience: DataAudienceType; + readonly audienceCeiling: AuthAudienceCeilingType; + readonly canonicalTargetPath?: string; + readonly liveDataAudience?: DataAudienceType; +}) { + const liveDataAudience = input.canonicalTargetPath + ? ((yield* ProjectFilesystemAudienceGuard.classifyPathAudience(input.canonicalTargetPath).pipe( + Effect.mapError( + (cause) => new AssetWorkspaceContextResolutionError({ resource: input.resource, cause }), + ), + )) ?? "private") + : (input.liveDataAudience ?? input.claimedDataAudience); + const dataAudience = strictestDataAudience(input.claimedDataAudience, liveDataAudience); + if (!canReadDataAudience(input.audienceCeiling, dataAudience)) { + return yield* new AssetWorkspaceContextNotFoundError({ resource: input.resource }); + } + return { dataAudience, audienceCeiling: input.audienceCeiling }; +}); + +export const classifyAttachmentAudience = Effect.fn("AssetAccess.classifyAttachmentAudience")( + function* (attachmentId: string) { + const threadSegment = parseThreadSegmentFromAttachmentId(attachmentId); + if (!threadSegment) return null; + + const snapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const snapshot = yield* snapshotQuery.getCommandReadModel(); + const projectById = new Map(snapshot.projects.map((project) => [project.id, project])); + let audience: DataAudienceType | null = null; + for (const thread of snapshot.threads) { + if (toSafeThreadAttachmentSegment(thread.id) !== threadSegment) { + continue; + } + const project = projectById.get(thread.projectId); + const threadAudience = project + ? strictestDataAudience(thread.dataAudience, project.dataAudience) + : thread.dataAudience; + audience = + audience === null ? threadAudience : strictestDataAudience(audience, threadAudience); + } + return audience; + }, +); + +type OpenedAssetFile = { + readonly handle: NodeFSP.FileHandle; + readonly canonicalPath: string; + readonly contentLength: number; +}; -export type ResolvedAsset = { readonly kind: "file"; readonly path: string }; +const closeOpenedAssetFile = (opened: OpenedAssetFile) => + Effect.promise(() => opened.handle.close().catch(() => undefined)); + +const openResolvedAssetFile = Effect.fn("AssetAccess.openResolvedAssetFile")(function* ( + resolvedPath: string, +) { + const handle = yield* Effect.tryPromise(() => + NodeFSP.open(resolvedPath, NodeFS.constants.O_RDONLY | NodeFS.constants.O_NOFOLLOW), + ).pipe(Effect.orElseSucceed(() => null)); + if (!handle) return null; + + const opened = yield* Effect.tryPromise(async () => { + const handleInfo = await handle.stat(); + if (!handleInfo.isFile()) return null; + + // Re-resolve and compare the inode after opening. This catches both final-component and + // ancestor swaps while retaining the exact handle that was authorized for streaming. + const canonicalPath = await NodeFSP.realpath(resolvedPath); + const pathInfo = await NodeFSP.stat(canonicalPath); + if (!pathInfo.isFile() || pathInfo.dev !== handleInfo.dev || pathInfo.ino !== handleInfo.ino) { + return null; + } + return { handle, canonicalPath, contentLength: handleInfo.size } satisfies OpenedAssetFile; + }).pipe(Effect.orElseSucceed(() => null)); + + if (!opened) { + yield* Effect.promise(() => handle.close().catch(() => undefined)); + } + return opened; +}); + +const toResolvedAsset = (opened: OpenedAssetFile): ResolvedAsset => ({ + kind: "file", + path: opened.canonicalPath, + contentLength: opened.contentLength, + stream: opened.handle.createReadStream({ autoClose: true }), +}); + +const withOpenedAssetFile = ( + resolvedPath: string, + use: ( + opened: OpenedAssetFile, + transferToResponse: () => ResolvedAsset, + ) => Effect.Effect, +): Effect.Effect => { + let transferredToResponse = false; + return Effect.acquireUseRelease( + openResolvedAssetFile(resolvedPath), + (opened) => { + if (!opened) return Effect.succeed(null); + return use(opened, () => { + transferredToResponse = true; + return toResolvedAsset(opened); + }); + }, + (opened, exit) => + opened && !(transferredToResponse && Exit.isSuccess(exit)) + ? closeOpenedAssetFile(opened) + : Effect.void, + ); +}; function decodeClaims(encodedPayload: string): AssetClaims | null { try { @@ -101,6 +283,26 @@ function decodeClaims(encodedPayload: string): AssetClaims | null { } } +function decodeSurfaceCredentialClaims( + encodedPayload: string, +): AssetSurfaceCredentialClaims | null { + try { + return Option.getOrNull( + decodeAssetSurfaceCredentialClaims(base64UrlDecodeUtf8(encodedPayload)), + ); + } catch { + return null; + } +} + +function signToken(encodedPayload: string, signingSecret: Uint8Array): string { + return `${encodedPayload}.${signPayload(encodedPayload, signingSecret)}`; +} + +function surfaceBindingIdForSession(sessionId: AuthSessionId, signingSecret: Uint8Array): string { + return signPayload(`asset-surface:${sessionId}`, signingSecret); +} + function decodeRelativePath(value: string): string | null { try { return decodeURIComponent(value); @@ -165,11 +367,19 @@ const resolveCanonicalWorkspaceFileForRequest = (input: { export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: { readonly resource: AssetResource; readonly workspaceRoot?: string; + readonly dataAudience?: DataAudienceType; + readonly audienceCeiling?: AuthAudienceCeilingType; + readonly clientCapabilities?: ReadonlyArray; + readonly surfaceSessionId?: AuthSessionId; + readonly surfaceSessionExpiresAt?: DateTime.DateTime; }) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; - const expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS; + const now = yield* Clock.currentTimeMillis; + let expiresAt = now + ASSET_TOKEN_TTL_MS; + const claimedDataAudience = input.dataAudience ?? ("private" as const); + const audienceCeiling = input.audienceCeiling ?? ("private" as const); let claims: AssetClaims; let fileName: string; @@ -225,6 +435,12 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i resource: input.resource, }); } + const audienceClaims = yield* audienceClaimsForIssue({ + resource: input.resource, + claimedDataAudience, + audienceCeiling, + canonicalTargetPath: canonicalFile, + }); const canonicalWorkspaceRoot = yield* fileSystem.realPath(workspaceRoot).pipe( Effect.mapError( (cause) => @@ -241,6 +457,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i workspaceRoot: canonicalWorkspaceRoot, relativePath: resolved.relativePath, expiresAt, + surfaceBindingId: null, + ...audienceClaims, } : { version: 1, @@ -248,6 +466,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i workspaceRoot: canonicalWorkspaceRoot, baseRelativePath: path.dirname(resolved.relativePath), expiresAt, + surfaceBindingId: null, + ...audienceClaims, }; fileName = path.basename(resolved.relativePath); break; @@ -263,11 +483,25 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i resource: input.resource, }); } + const audienceClaims = yield* audienceClaimsForIssue({ + resource: input.resource, + claimedDataAudience, + audienceCeiling, + liveDataAudience: + (yield* classifyAttachmentAudience(input.resource.attachmentId).pipe( + Effect.mapError( + (cause) => + new AssetWorkspaceContextResolutionError({ resource: input.resource, cause }), + ), + )) ?? "private", + }); claims = { version: 1, kind: "attachment", attachmentId: input.resource.attachmentId, expiresAt, + surfaceBindingId: null, + ...audienceClaims, }; fileName = path.basename(attachmentPath); break; @@ -293,22 +527,28 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i ), ); const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null; - if ( - relativePath && - !(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe( - Effect.mapError( - (cause) => - new AssetProjectFaviconInspectionError({ - resource: input.resource, - cause, - }), - ), - )) - ) { + const canonicalFaviconPath = relativePath + ? yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe( + Effect.mapError( + (cause) => + new AssetProjectFaviconInspectionError({ + resource: input.resource, + cause, + }), + ), + ) + : null; + if (relativePath && !canonicalFaviconPath) { return yield* new AssetProjectFaviconNotFoundError({ resource: input.resource, }); } + const audienceClaims = yield* audienceClaimsForIssue({ + resource: input.resource, + claimedDataAudience, + audienceCeiling, + ...(canonicalFaviconPath ? { canonicalTargetPath: canonicalFaviconPath } : {}), + }); claims = { version: 1, kind: "project-favicon", @@ -323,12 +563,32 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i ), relativePath, expiresAt, + surfaceBindingId: null, + ...audienceClaims, }; fileName = relativePath ? path.basename(relativePath) : PROJECT_FAVICON_FALLBACK_MARKER; break; } } + if ( + claims.dataAudience === "private" && + !input.clientCapabilities?.includes(ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY) + ) { + yield* Effect.logWarning( + "Private asset URL issuance requires a client upgrade for same-origin relay support.", + { + "asset.outcome": "upgrade_required", + "asset.required_capability": ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY, + "asset.resource_kind": input.resource._tag, + }, + ); + return yield* new AssetClientUpgradeRequiredError({ + resource: input.resource, + requiredCapability: ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY, + }); + } + const secretStore = yield* ServerSecretStore.ServerSecretStore; const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32).pipe( Effect.mapError( @@ -339,17 +599,83 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }), ), ); + let surfaceSessionId: AuthSessionId | null = null; + let surfaceBindingId: string | null = null; + let surfaceCredentialExpiresAt: number | null = null; + if (claims.dataAudience === "private") { + if (input.surfaceSessionId === undefined || input.surfaceSessionExpiresAt === undefined) { + return yield* new AssetWorkspaceContextNotFoundError({ resource: input.resource }); + } + surfaceSessionId = input.surfaceSessionId; + surfaceBindingId = surfaceBindingIdForSession(surfaceSessionId, signingSecret); + surfaceCredentialExpiresAt = input.surfaceSessionExpiresAt.epochMilliseconds; + expiresAt = Math.min(expiresAt, surfaceCredentialExpiresAt); + if (expiresAt <= now) { + return yield* new AssetWorkspaceContextNotFoundError({ resource: input.resource }); + } + } + claims = { ...claims, surfaceBindingId, expiresAt }; const encodedPayload = base64UrlEncode(encodeAssetClaims(claims)); - const token = `${encodedPayload}.${signPayload(encodedPayload, signingSecret)}`; + const token = signToken(encodedPayload, signingSecret); + const surfaceCredential = + surfaceBindingId === null || surfaceSessionId === null || surfaceCredentialExpiresAt === null + ? null + : signToken( + base64UrlEncode( + encodeAssetSurfaceCredentialClaims({ + version: 1, + kind: "asset-surface", + surfaceSessionId, + surfaceBindingId, + expiresAt: surfaceCredentialExpiresAt, + }), + ), + signingSecret, + ); return { - relativeUrl: `${ASSET_ROUTE_PREFIX}/${token}/${encodeURIComponent(fileName)}`, + relativeUrl: `${surfaceBindingId === null ? ASSET_ROUTE_PREFIX : ASSET_SURFACE_RELAY_PREFIX}/${token}/${encodeURIComponent(fileName)}`, expiresAt, + surfaceCredential, }; }); +export const verifyAssetSurfaceCredential = Effect.fn("AssetAccess.verifyAssetSurfaceCredential")( + function* (credential: string) { + const [encodedPayload, signature] = credential.split("."); + if (!encodedPayload || !signature) return null; + + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32).pipe( + Effect.tapError((cause) => + Effect.logError("Failed to load the asset surface signing key.", { cause }), + ), + Effect.orElseSucceed(() => null), + ); + if (!signingSecret) return null; + if (!timingSafeEqualBase64Url(signature, signPayload(encodedPayload, signingSecret))) + return null; + + const claims = decodeSurfaceCredentialClaims(encodedPayload); + if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null; + const sessions = yield* SessionStore.SessionStore; + const active = yield* sessions.isActive(claims.surfaceSessionId).pipe( + Effect.tapError((cause) => + Effect.logWarning("Failed to verify the asset surface session.", { + sessionId: claims.surfaceSessionId, + cause, + }), + ), + Effect.orElseSucceed(() => false), + ); + if (!active) return null; + return claims; + }, +); + export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( token: string, relativePath: string, + requestSurfaceCredentials: string | null | ReadonlyArray = null, ) { const [encodedPayload, signature] = token.split("."); if (!encodedPayload || !signature) return null; @@ -365,6 +691,52 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( const claims = decodeClaims(encodedPayload); if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null; + if (!canReadDataAudience(claims.audienceCeiling, claims.dataAudience)) { + return null; + } + if (claims.dataAudience === "private") { + if (claims.surfaceBindingId === null || claims.surfaceBindingId === undefined) return null; + const credentials = + typeof requestSurfaceCredentials === "string" + ? [requestSurfaceCredentials] + : (requestSurfaceCredentials ?? []); + let matchedSurface = false; + for (const credential of credentials) { + const surfaceCredential = yield* verifyAssetSurfaceCredential(credential); + if (surfaceCredential?.surfaceBindingId === claims.surfaceBindingId) { + matchedSurface = true; + break; + } + } + if (!matchedSurface) return null; + } + + const authorizeResolvedPath = Effect.fn("AssetAccess.resolveAsset.authorizeResolvedPath")( + function* (resolvedPath: string, signedWorkspaceRoot: string) { + return yield* withOpenedAssetFile(resolvedPath, (opened, transferToResponse) => + Effect.gen(function* () { + const path = yield* Path.Path; + const relativeToSignedRoot = path.relative(signedWorkspaceRoot, opened.canonicalPath); + if ( + relativeToSignedRoot === ".." || + relativeToSignedRoot.startsWith(`..${path.sep}`) || + path.isAbsolute(relativeToSignedRoot) + ) { + return null; + } + const liveDataAudience = + (yield* ProjectFilesystemAudienceGuard.classifyCanonicalPathAudience( + opened.canonicalPath, + ).pipe(Effect.orElseSucceed(() => null))) ?? "private"; + if (!canReadDataAudience(claims.dataAudience, liveDataAudience)) { + return forbiddenAsset; + } + return transferToResponse(); + }), + ); + }, + ); + if (claims.kind === "attachment") { const config = yield* ServerConfig.ServerConfig; const attachmentPath = resolveAttachmentPathById({ @@ -372,20 +744,35 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( attachmentId: claims.attachmentId, }); if (!attachmentPath) return null; - const fileSystem = yield* FileSystem.FileSystem; - const info = yield* optionOnNotFound(fileSystem.stat(attachmentPath)).pipe( - Effect.tapError((cause) => - Effect.logError("Failed to inspect attachment asset.", { - attachmentId: claims.attachmentId, - path: attachmentPath, - cause, - }), - ), - Effect.orElseSucceed(() => Option.none()), + return yield* withOpenedAssetFile(attachmentPath, (opened, transferToResponse) => + Effect.gen(function* () { + const path = yield* Path.Path; + const canonicalAttachmentsDir = yield* Effect.tryPromise(() => + NodeFSP.realpath(config.attachmentsDir), + ).pipe(Effect.orElseSucceed(() => null)); + const attachmentRelativePath = canonicalAttachmentsDir + ? path.relative(canonicalAttachmentsDir, opened.canonicalPath) + : null; + if ( + attachmentRelativePath === null || + attachmentRelativePath === "" || + attachmentRelativePath === ".." || + attachmentRelativePath.startsWith(`..${path.sep}`) || + path.isAbsolute(attachmentRelativePath) + ) { + return null; + } + + const liveDataAudience = + (yield* classifyAttachmentAudience(claims.attachmentId).pipe( + Effect.orElseSucceed(() => null), + )) ?? "private"; + if (!canReadDataAudience(claims.dataAudience, liveDataAudience)) { + return forbiddenAsset; + } + return transferToResponse(); + }), ); - return Option.isSome(info) && info.value.type === "File" - ? ({ kind: "file", path: attachmentPath } satisfies ResolvedAsset) - : null; } if (claims.kind === "project-favicon") { @@ -394,7 +781,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( workspaceRoot: claims.workspaceRoot, relativePath: claims.relativePath, }); - return faviconPath ? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset) : null; + return faviconPath ? yield* authorizeResolvedPath(faviconPath, claims.workspaceRoot) : null; } const decodedPath = decodeRelativePath(relativePath); @@ -407,7 +794,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( relativePath: claims.relativePath, }); return exactWorkspaceFile - ? ({ kind: "file", path: exactWorkspaceFile } satisfies ResolvedAsset) + ? yield* authorizeResolvedPath(exactWorkspaceFile, claims.workspaceRoot) : null; } const segments = decodedPath.split(/[\\/]/); @@ -425,5 +812,5 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( workspaceRoot: claims.workspaceRoot, relativePath: joinedRelativePath, }); - return workspaceFile ? ({ kind: "file", path: workspaceFile } satisfies ResolvedAsset) : null; + return workspaceFile ? yield* authorizeResolvedPath(workspaceFile, claims.workspaceRoot) : null; }); diff --git a/apps/server/src/auth/audienceDataPolicy.ts b/apps/server/src/auth/audienceDataPolicy.ts index 8ea1cc26e90..4c5e3a3a151 100644 --- a/apps/server/src/auth/audienceDataPolicy.ts +++ b/apps/server/src/auth/audienceDataPolicy.ts @@ -28,3 +28,7 @@ export function canReadDataAudience( ): boolean { return audienceCeiling === "private" || dataAudience === "factory"; } + +export function strictestDataAudience(left: DataAudience, right: DataAudience): DataAudience { + return left === "private" || right === "private" ? "private" : "factory"; +} diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 90222521591..e643c3e62d4 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -10,6 +10,7 @@ import { } from "@t3tools/contracts"; import { decodeOtlpTraceRecords } from "@t3tools/shared/observability"; import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -17,6 +18,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { cast } from "effect/Function"; +import * as Cookies from "effect/unstable/http/Cookies"; import { HttpBody, HttpClient, @@ -30,9 +32,19 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import { OtlpTracer } from "effect/unstable/observability"; import * as ServerConfig from "./config.ts"; -import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts"; +import { + ASSET_ROUTE_PREFIX, + ASSET_SURFACE_BIND_PATH, + ASSET_SURFACE_CREDENTIAL_HEADER, + ASSET_SURFACE_RELAY_PREFIX, + assetSurfaceCookieName, + assetSurfaceCookiePrefix, + resolveAsset, + verifyAssetSurfaceCredential, +} from "./assets/AssetAccess.ts"; import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; +import * as SessionStore from "./auth/SessionStore.ts"; import * as DeviceNotifications from "./notifications/DeviceNotifications.ts"; import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; import { traceRelayRequest } from "./cloud/traceRelayRequest.ts"; @@ -531,28 +543,165 @@ export const assetRouteLayer = HttpRouter.add( return HttpServerResponse.text("Bad Request", { status: 400 }); } - const suffix = url.value.pathname.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const isSurfaceRelay = url.value.pathname.startsWith(`${ASSET_SURFACE_RELAY_PREFIX}/`); + const routePrefix = isSurfaceRelay ? ASSET_SURFACE_RELAY_PREFIX : ASSET_ROUTE_PREFIX; + const suffix = url.value.pathname.slice(`${routePrefix}/`.length); const separatorIndex = suffix.indexOf("/"); if (separatorIndex <= 0) { return HttpServerResponse.text("Not Found", { status: 404 }); } + const explicitSurfaceCredential = request.headers[ASSET_SURFACE_CREDENTIAL_HEADER]; + const requestSurfaceCredentials = + explicitSurfaceCredential === undefined ? [] : [explicitSurfaceCredential]; + if (isSurfaceRelay) { + const sessions = yield* SessionStore.SessionStore; + const surfaceCookiePrefix = assetSurfaceCookiePrefix(sessions.cookieName); + requestSurfaceCredentials.push( + ...Object.entries(request.cookies) + .filter(([name]) => name.startsWith(surfaceCookiePrefix)) + .map(([, credential]) => credential), + ); + } + + // Factory assets remain short-lived bearer URLs. Private browser assets are issued only + // beneath the same-origin relay prefix, where the surface cookie is revalidated on every + // request. The direct path deliberately ignores cookies so cross-site DOM loads fail closed + // in every browser; native clients may still present the capability explicitly. const asset = yield* resolveAsset( suffix.slice(0, separatorIndex), suffix.slice(separatorIndex + 1), + requestSurfaceCredentials, ); if (!asset) { + if (isSurfaceRelay) { + yield* Effect.logWarning("Asset surface relay request was masked as not found.", { + "asset.outcome": "masked_not_found", + "asset.surface_proof_present": requestSurfaceCredentials.length > 0, + }); + } return HttpServerResponse.text("Not Found", { status: 404 }); } - return yield* HttpServerResponse.file(asset.path, { + if (asset.kind === "forbidden") { + return HttpServerResponse.text("Forbidden", { + status: 403, + headers: { "Cache-Control": "no-store" }, + }); + } + const responseHeaders = { + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + }; + const contentType = Mime.getType(asset.path) ?? "application/octet-stream"; + if (request.method === "HEAD") { + asset.stream.destroy(); + return HttpServerResponse.empty({ + status: 200, + headers: { + ...responseHeaders, + "Content-Length": String(asset.contentLength), + "Content-Type": contentType, + }, + }); + } + return HttpServerResponse.raw(asset.stream, { status: 200, - headers: { - "Cache-Control": "private, max-age=3600", - "X-Content-Type-Options": "nosniff", - }, - }).pipe( - Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })), + contentType, + contentLength: asset.contentLength, + headers: responseHeaders, + }); + }), +); + +const AssetSurfaceBindingInput = Schema.Struct({ + credential: Schema.String, + redirect: Schema.optionalKey(Schema.String), +}); +const decodeAssetSurfaceBindingInput = Schema.decodeUnknownOption(AssetSurfaceBindingInput); + +function validateAssetSurfaceRedirect(value: string): string | null { + if (!value.startsWith("/") || value.startsWith("//")) return null; + let url: URL; + try { + url = new URL(value, "http://asset.invalid"); + } catch { + return null; + } + const prefix = `${ASSET_SURFACE_RELAY_PREFIX}/`; + if (!url.pathname.startsWith(prefix) || url.pathname === ASSET_SURFACE_BIND_PATH) return null; + const suffix = url.pathname.slice(prefix.length); + const separatorIndex = suffix.indexOf("/"); + if (separatorIndex <= 0 || separatorIndex === suffix.length - 1) return null; + return `${url.pathname}${url.search}`; +} + +export const assetSurfaceBindingRouteLayer = HttpRouter.add( + "POST", + ASSET_SURFACE_BIND_PATH, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const input = yield* request.json.pipe( + Effect.map(decodeAssetSurfaceBindingInput), + Effect.orElseSucceed(() => Option.none()), ); + if (Option.isNone(input)) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + const redirect = + input.value.redirect === undefined + ? null + : validateAssetSurfaceRedirect(input.value.redirect); + if (input.value.redirect !== undefined && redirect === null) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + + const verified = yield* verifyAssetSurfaceCredential(input.value.credential); + if (verified === null) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + + const sessions = yield* SessionStore.SessionStore; + const requestOrigin = normalizeCorsOrigin(request.headers.origin); + const forwardedProtocol = request.headers["x-forwarded-proto"] + ?.split(",", 1)[0] + ?.trim() + .toLowerCase(); + const secure = + (requestOrigin !== null && new URL(requestOrigin).protocol === "https:") || + forwardedProtocol === "https:" || + forwardedProtocol === "https" || + (() => { + try { + return new URL(request.originalUrl).protocol === "https:"; + } catch { + return false; + } + })(); + const cookies = yield* Effect.fromResult( + Cookies.set( + Cookies.empty, + assetSurfaceCookieName(sessions.cookieName, verified.surfaceBindingId), + input.value.credential, + { + expires: DateTime.toDate(DateTime.makeUnsafe(verified.expiresAt)), + httpOnly: true, + path: ASSET_SURFACE_RELAY_PREFIX, + sameSite: "lax", + secure, + }, + ), + ).pipe(Effect.orDie); + const response = + redirect === null + ? HttpServerResponse.empty({ + status: 204, + headers: { "Cache-Control": "no-store" }, + }) + : HttpServerResponse.redirect(redirect, { + status: 303, + headers: { "Cache-Control": "no-store" }, + }); + return HttpServerResponse.mergeCookies(response, cookies); }), ); diff --git a/apps/server/src/project/ProjectFilesystemAudienceGuard.test.ts b/apps/server/src/project/ProjectFilesystemAudienceGuard.test.ts new file mode 100644 index 00000000000..5009d927b33 --- /dev/null +++ b/apps/server/src/project/ProjectFilesystemAudienceGuard.test.ts @@ -0,0 +1,441 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { + AuthOrchestrationReadScope, + AuthSessionId, + EnvironmentAuthenticatedPrincipal, + ProjectId, + ProviderInstanceId, + ThreadId, + type AuthAudienceCeiling, + type OrchestrationProject, + type OrchestrationReadModel, + type OrchestrationThread, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import { canReadDataAudience, currentReadAudienceCeiling } from "../auth/audienceDataPolicy.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as ProjectFilesystemAudienceGuard from "./ProjectFilesystemAudienceGuard.ts"; + +const now = "2026-07-18T12:00:00.000Z"; +const modelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", +} as const; + +const principal = (audienceCeiling: AuthAudienceCeiling) => + EnvironmentAuthenticatedPrincipal.of({ + sessionId: AuthSessionId.make(`session-${audienceCeiling}`), + subject: `test-${audienceCeiling}`, + method: "bearer-access-token", + scopes: new Set([AuthOrchestrationReadScope]), + audienceCeiling, + }); + +const makeProject = ( + id: string, + workspaceRoot: string, + dataAudience: OrchestrationProject["dataAudience"], +): OrchestrationProject => ({ + id: ProjectId.make(id), + title: id, + workspaceRoot, + dataAudience, + repositoryIdentity: null, + defaultModelSelection: modelSelection, + scripts: [], + createdAt: now, + updatedAt: now, + deletedAt: null, +}); + +const makeThread = (input: { + readonly id: string; + readonly projectId: ProjectId; + readonly dataAudience: OrchestrationThreadShell["dataAudience"]; + readonly worktreePath: string | null; +}): OrchestrationThreadShell & Pick => ({ + id: ThreadId.make(input.id), + projectId: input.projectId, + dataAudience: input.dataAudience, + title: input.id, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: input.worktreePath, + latestTurn: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + deletedAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + parentThreadId: null, +}); + +const makeProjectionLayer = (input: { + readonly projects: ReadonlyArray; + readonly threads: ReadonlyArray; +}) => + Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getCommandReadModel: () => + Effect.succeed({ + snapshotSequence: 1, + updatedAt: now, + projects: input.projects, + threads: input.threads, + } as unknown as OrchestrationReadModel), + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => + currentReadAudienceCeiling.pipe( + Effect.map((audienceCeiling) => ({ + snapshotSequence: 1, + updatedAt: now, + projects: input.projects.filter((project) => + canReadDataAudience(audienceCeiling, project.dataAudience), + ), + threads: input.threads.filter((thread) => + canReadDataAudience(audienceCeiling, thread.dataAudience), + ), + })), + ), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => Effect.die("unused"), + getCounts: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: (workspaceRoot) => + currentReadAudienceCeiling.pipe( + Effect.map((audienceCeiling) => { + const project = input.projects.find( + (candidate) => + candidate.workspaceRoot === workspaceRoot && + canReadDataAudience(audienceCeiling, candidate.dataAudience), + ); + return project === undefined ? Option.none() : Option.some(project); + }), + ), + getProjectShellById: () => Effect.die("unused"), + getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getThreadCheckpointContext: () => Effect.die("unused"), + getFullThreadDiffContext: () => Effect.die("unused"), + getThreadShellById: () => Effect.die("unused"), + getThreadShellByIdIncludingArchived: () => Effect.die("unused"), + getThreadShellSnapshotByIdIncludingArchived: () => Effect.die("unused"), + getThreadDetailById: () => Effect.die("unused"), + getThreadDetailSnapshot: () => Effect.die("unused"), + }); + +describe("ProjectFilesystemAudienceGuard", () => { + it.effect("checks repo-wide Git reads from the repository root, not a clean subdirectory", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const repoRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-guard-repo-root-", + }); + const cleanSubdirectory = path.join(repoRoot, "src"); + const privateSibling = path.join(repoRoot, "private"); + yield* fileSystem.makeDirectory(cleanSubdirectory); + yield* fileSystem.makeDirectory(privateSibling); + yield* fileSystem.writeFileString(path.join(privateSibling, "secret.txt"), "private\n"); + + const registry = yield* VcsDriverRegistry.VcsDriverRegistry; + const git = yield* registry.get("git"); + yield* git.execute({ operation: "test.git.init", cwd: repoRoot, args: ["init"] }); + yield* git.execute({ + operation: "test.git.add-private-sibling", + cwd: repoRoot, + args: ["add", "private/secret.txt"], + }); + const unguardedStatus = yield* git.execute({ + operation: "test.git.status", + cwd: cleanSubdirectory, + args: ["status", "--porcelain=2", "--branch"], + }); + expect(unguardedStatus.stdout).toContain("../private/secret.txt"); + + const factoryProject = makeProject("project-repo-factory", repoRoot, "factory"); + const privateProject = makeProject("project-repo-private", privateSibling, "private"); + const projectionLayer = makeProjectionLayer({ + projects: [factoryProject, privateProject], + threads: [], + }); + const runAsFactory = (effect: Effect.Effect) => + effect.pipe(Effect.provideService(EnvironmentAuthenticatedPrincipal, principal("factory"))); + + const result = yield* Effect.all({ + callerScopedCheck: runAsFactory( + ProjectFilesystemAudienceGuard.hasHiddenDescendantForCurrentAudience(cleanSubdirectory), + ), + repositoryScopedCheck: runAsFactory( + ProjectFilesystemAudienceGuard.hasHiddenDescendantAtGitRepositoryRootForCurrentAudience( + cleanSubdirectory, + ), + ), + }).pipe(Effect.provide(projectionLayer)); + + expect(result).toEqual({ + callerScopedCheck: false, + repositoryScopedCheck: true, + }); + }).pipe( + Effect.provide( + VcsDriverRegistry.layer.pipe( + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + it.effect("keeps factory filesystem and VCS paths inside factory project roots", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const privateRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-guard-private-", + }); + const factoryRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-guard-factory-", + }); + const factoryWorktree = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-guard-factory-worktree-", + }); + const outsideRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-guard-outside-", + }); + yield* fileSystem.writeFileString(path.join(privateRoot, "secret.txt"), "private\n"); + yield* fileSystem.writeFileString(path.join(factoryRoot, "visible.txt"), "factory\n"); + yield* fileSystem.writeFileString(path.join(factoryWorktree, "diff.txt"), "worktree\n"); + const privateNestedRoot = path.join(factoryRoot, "private-nested"); + yield* fileSystem.makeDirectory(privateNestedRoot); + yield* fileSystem.writeFileString( + path.join(privateNestedRoot, "secret.txt"), + "nested private\n", + ); + yield* fileSystem.writeFileString(path.join(outsideRoot, "escaped.txt"), "outside\n"); + yield* fileSystem.symlink(outsideRoot, path.join(factoryRoot, "linked-outside")); + + const factoryProject = makeProject("project-factory", factoryRoot, "factory"); + const privateProject = makeProject("project-private", privateRoot, "private"); + const nestedPrivateProject = makeProject( + "project-private-nested", + privateNestedRoot, + "private", + ); + const projectionLayer = makeProjectionLayer({ + projects: [privateProject, nestedPrivateProject, factoryProject], + threads: [ + makeThread({ + id: "thread-factory-worktree", + projectId: factoryProject.id, + dataAudience: "factory", + worktreePath: factoryWorktree, + }), + ], + }); + const runAsFactory = (effect: Effect.Effect) => + effect.pipe(Effect.provideService(EnvironmentAuthenticatedPrincipal, principal("factory"))); + + const result = yield* Effect.all({ + factoryProjectFile: runAsFactory( + ProjectFilesystemAudienceGuard.isPathVisibleToCurrentAudience( + path.join(factoryRoot, "visible.txt"), + ), + ), + factoryWorktreeFile: runAsFactory( + ProjectFilesystemAudienceGuard.isPathVisibleToCurrentAudience( + path.join(factoryWorktree, "diff.txt"), + ), + ), + privateProjectFile: runAsFactory( + ProjectFilesystemAudienceGuard.isPathVisibleToCurrentAudience( + path.join(privateRoot, "secret.txt"), + ), + ), + nestedPrivateProjectFile: runAsFactory( + ProjectFilesystemAudienceGuard.isPathVisibleToCurrentAudience( + path.join(privateNestedRoot, "secret.txt"), + ), + ), + symlinkEscape: runAsFactory( + ProjectFilesystemAudienceGuard.isPathVisibleToCurrentAudience( + path.join(factoryRoot, "linked-outside", "escaped.txt"), + ), + ), + privateBrowseTarget: runAsFactory( + ProjectFilesystemAudienceGuard.isBrowseTargetVisibleToCurrentAudience({ + partialPath: `${privateRoot}/`, + }), + ), + factoryBrowseTargetWithHiddenDescendant: runAsFactory( + ProjectFilesystemAudienceGuard.isBrowseTargetVisibleToCurrentAudience({ + partialPath: `${factoryRoot}/`, + }), + ), + absoluteBrowseTargetIgnoresCleanFactoryCwd: runAsFactory( + ProjectFilesystemAudienceGuard.isBrowseTargetVisibleToCurrentAudience({ + cwd: factoryWorktree, + partialPath: `${factoryRoot}/`, + }), + ), + cleanFactoryBrowseTarget: runAsFactory( + ProjectFilesystemAudienceGuard.isBrowseTargetVisibleToCurrentAudience({ + partialPath: `${factoryWorktree}/`, + }), + ), + factoryWorktreeDestination: runAsFactory( + ProjectFilesystemAudienceGuard.isMutationTargetVisibleToCurrentAudience({ + cwd: factoryRoot, + targetPath: "worktrees/new-factory", + }), + ), + privateWorktreeDestination: runAsFactory( + ProjectFilesystemAudienceGuard.isMutationTargetVisibleToCurrentAudience({ + cwd: factoryRoot, + targetPath: path.join(privateRoot, "new-private-worktree"), + }), + ), + symlinkWorktreeDestination: runAsFactory( + ProjectFilesystemAudienceGuard.isMutationTargetVisibleToCurrentAudience({ + cwd: factoryRoot, + targetPath: path.join(factoryRoot, "linked-outside"), + }), + ), + }).pipe(Effect.provide(projectionLayer), Effect.provideService(HostProcessPlatform, "linux")); + + expect(result).toEqual({ + factoryProjectFile: true, + factoryWorktreeFile: true, + privateProjectFile: false, + nestedPrivateProjectFile: false, + symlinkEscape: false, + privateBrowseTarget: false, + factoryBrowseTargetWithHiddenDescendant: false, + absoluteBrowseTargetIgnoresCleanFactoryCwd: false, + cleanFactoryBrowseTarget: true, + factoryWorktreeDestination: true, + privateWorktreeDestination: false, + symlinkWorktreeDestination: false, + }); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("leaves private filesystem sessions unrestricted", () => + Effect.gen(function* () { + const result = yield* ProjectFilesystemAudienceGuard.isPathVisibleToCurrentAudience( + "/definitely/not/a/project/missing.txt", + ).pipe( + Effect.provide( + Layer.mergeAll( + makeProjectionLayer({ projects: [], threads: [] }), + NodeServices.layer, + Layer.succeed(EnvironmentAuthenticatedPrincipal, principal("private")), + ), + ), + ); + + expect(result).toBe(true); + }), + ); + + it.effect("ignores deleted project roots and stale or deleted worktrees", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-guard-readded-private-", + }); + const staleAudienceWorktree = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-guard-stale-audience-worktree-", + }); + const privateAudienceWorktree = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-guard-private-audience-worktree-", + }); + const factoryRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-guard-active-factory-", + }); + const secretPath = path.join(root, "secret.txt"); + const staleAudienceSecretPath = path.join(staleAudienceWorktree, "secret.txt"); + const privateAudienceSecretPath = path.join(privateAudienceWorktree, "secret.txt"); + yield* fileSystem.writeFileString(secretPath, "private\n"); + yield* fileSystem.writeFileString(staleAudienceSecretPath, "private worktree\n"); + yield* fileSystem.writeFileString(privateAudienceSecretPath, "private thread worktree\n"); + + const deletedFactoryProject = { + ...makeProject("project-deleted-factory", root, "factory"), + deletedAt: now, + }; + const privateProject = makeProject("project-readded-private", root, "private"); + const factoryProject = makeProject("project-active-factory", factoryRoot, "factory"); + const deletedFactoryWorktree = { + ...makeThread({ + id: "thread-deleted-factory-worktree", + projectId: deletedFactoryProject.id, + dataAudience: "factory", + worktreePath: root, + }), + deletedAt: now, + }; + const staleFactoryWorktree = makeThread({ + id: "thread-stale-factory-worktree", + projectId: deletedFactoryProject.id, + dataAudience: "factory", + worktreePath: root, + }); + const staleAudienceFactoryWorktree = makeThread({ + id: "thread-stale-audience-factory-worktree", + projectId: privateProject.id, + dataAudience: "factory", + worktreePath: staleAudienceWorktree, + }); + const privateAudienceFactoryWorktree = makeThread({ + id: "thread-private-audience-factory-worktree", + projectId: factoryProject.id, + dataAudience: "private", + worktreePath: privateAudienceWorktree, + }); + const projectionLayer = makeProjectionLayer({ + projects: [deletedFactoryProject, privateProject, factoryProject], + threads: [ + deletedFactoryWorktree, + staleFactoryWorktree, + staleAudienceFactoryWorktree, + privateAudienceFactoryWorktree, + ], + }); + + const runAsFactory = (effect: Effect.Effect) => + effect.pipe(Effect.provideService(EnvironmentAuthenticatedPrincipal, principal("factory"))); + const result = yield* Effect.all({ + readdedPrivateProject: runAsFactory( + ProjectFilesystemAudienceGuard.isPathVisibleToCurrentAudience(secretPath), + ), + staleAudienceWorktree: runAsFactory( + ProjectFilesystemAudienceGuard.isPathVisibleToCurrentAudience(staleAudienceSecretPath), + ), + privateAudienceWorktree: runAsFactory( + ProjectFilesystemAudienceGuard.isPathVisibleToCurrentAudience(privateAudienceSecretPath), + ), + }).pipe(Effect.provide(projectionLayer)); + + expect(result).toEqual({ + readdedPrivateProject: false, + staleAudienceWorktree: false, + privateAudienceWorktree: false, + }); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/project/ProjectFilesystemAudienceGuard.ts b/apps/server/src/project/ProjectFilesystemAudienceGuard.ts new file mode 100644 index 00000000000..c2b913ddd1d --- /dev/null +++ b/apps/server/src/project/ProjectFilesystemAudienceGuard.ts @@ -0,0 +1,169 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import { currentReadAudienceCeiling, strictestDataAudience } from "../auth/audienceDataPolicy.ts"; +import { expandHomePath } from "../pathExpansion.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as WorkspaceEntries from "../workspace/WorkspaceEntries.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; + +function isWithinRoot(path: Path.Path, root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return ( + relative === "" || + (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) + ); +} + +const realPathOption = (fileSystem: FileSystem.FileSystem, value: string) => + fileSystem.realPath(value).pipe( + Effect.map(Option.some), + Effect.orElseSucceed(() => Option.none()), + ); + +const classifiedProjectRoots = Effect.fn("ProjectFilesystemAudienceGuard.classifiedProjectRoots")( + function* () { + const fileSystem = yield* FileSystem.FileSystem; + const snapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const snapshot = yield* snapshotQuery.getCommandReadModel(); + const activeProjects = snapshot.projects.filter((project) => project.deletedAt === null); + const activeProjectById = new Map(activeProjects.map((project) => [project.id, project])); + const candidates = [ + ...activeProjects.map((project) => ({ + path: project.workspaceRoot, + dataAudience: project.dataAudience, + })), + ...snapshot.threads.flatMap((thread) => { + const project = activeProjectById.get(thread.projectId); + return thread.deletedAt !== null || !project || thread.worktreePath === null + ? [] + : [ + { + path: thread.worktreePath, + dataAudience: strictestDataAudience(thread.dataAudience, project.dataAudience), + }, + ]; + }), + ]; + const roots: Array<{ readonly path: string; dataAudience: "factory" | "private" }> = []; + for (const candidate of candidates) { + const realRoot = yield* realPathOption(fileSystem, candidate.path); + if (Option.isNone(realRoot)) continue; + + const existingRoot = roots.find((root) => root.path === realRoot.value); + if (existingRoot) { + if (candidate.dataAudience === "private") { + existingRoot.dataAudience = "private"; + } + } else { + roots.push({ path: realRoot.value, dataAudience: candidate.dataAudience }); + } + } + return roots.sort((left, right) => right.path.length - left.path.length); + }, +); + +export const classifyCanonicalPathAudience = Effect.fn( + "ProjectFilesystemAudienceGuard.classifyCanonicalPathAudience", +)(function* (realCandidatePath: string) { + const path = yield* Path.Path; + const roots = yield* classifiedProjectRoots(); + const match = roots.find((root) => isWithinRoot(path, root.path, realCandidatePath)); + return match?.dataAudience ?? null; +}); + +export const classifyPathAudience = Effect.fn( + "ProjectFilesystemAudienceGuard.classifyPathAudience", +)(function* (candidatePath: string) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const realCandidate = yield* realPathOption( + fileSystem, + path.resolve(expandHomePath(candidatePath)), + ); + if (Option.isNone(realCandidate)) return null; + return yield* classifyCanonicalPathAudience(realCandidate.value); +}); + +export const isPathVisibleToCurrentAudience = Effect.fn( + "ProjectFilesystemAudienceGuard.isPathVisibleToCurrentAudience", +)(function* (candidatePath: string) { + const audienceCeiling = yield* currentReadAudienceCeiling; + if (audienceCeiling === "private") return true; + + return (yield* classifyPathAudience(candidatePath)) === "factory"; +}); + +export const isMutationTargetVisibleToCurrentAudience = Effect.fn( + "ProjectFilesystemAudienceGuard.isMutationTargetVisibleToCurrentAudience", +)(function* (input: { readonly cwd: string; readonly targetPath: string }) { + const audienceCeiling = yield* currentReadAudienceCeiling; + if (audienceCeiling === "private") return true; + + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const expandedTargetPath = expandHomePath(input.targetPath); + let candidatePath = path.isAbsolute(expandedTargetPath) + ? path.resolve(expandedTargetPath) + : path.resolve(expandHomePath(input.cwd), expandedTargetPath); + + while (true) { + const realCandidate = yield* realPathOption(fileSystem, candidatePath); + if (Option.isSome(realCandidate)) { + return (yield* classifyCanonicalPathAudience(realCandidate.value)) === "factory"; + } + + const parentPath = path.dirname(candidatePath); + if (parentPath === candidatePath) return false; + candidatePath = parentPath; + } +}); + +export const hasHiddenDescendantForCurrentAudience = Effect.fn( + "ProjectFilesystemAudienceGuard.hasHiddenDescendantForCurrentAudience", +)(function* (candidatePath: string) { + const audienceCeiling = yield* currentReadAudienceCeiling; + if (audienceCeiling === "private") return false; + + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const realCandidate = yield* realPathOption( + fileSystem, + path.resolve(expandHomePath(candidatePath)), + ); + if (Option.isNone(realCandidate)) return true; + + const roots = yield* classifiedProjectRoots(); + return roots.some( + (root) => + root.dataAudience !== "factory" && + root.path !== realCandidate.value && + isWithinRoot(path, realCandidate.value, root.path), + ); +}); + +export const hasHiddenDescendantAtGitRepositoryRootForCurrentAudience = Effect.fn( + "ProjectFilesystemAudienceGuard.hasHiddenDescendantAtGitRepositoryRootForCurrentAudience", +)(function* (cwd: string) { + const audienceCeiling = yield* currentReadAudienceCeiling; + if (audienceCeiling === "private") return false; + + const registry = yield* VcsDriverRegistry.VcsDriverRegistry; + const detected = yield* registry.detect({ cwd, requestedKind: "git", cache: "bypass" }); + if (detected === null) return true; + return yield* hasHiddenDescendantForCurrentAudience(detected.repository.rootPath); +}); + +export const isBrowseTargetVisibleToCurrentAudience = Effect.fn( + "ProjectFilesystemAudienceGuard.isBrowseTargetVisibleToCurrentAudience", +)(function* (input: { readonly cwd?: string | undefined; readonly partialPath: string }) { + const audienceCeiling = yield* currentReadAudienceCeiling; + if (audienceCeiling === "private") return true; + + const target = yield* WorkspaceEntries.resolveBrowseTarget(input).pipe(Effect.option); + if (Option.isNone(target)) return false; + if (!(yield* isPathVisibleToCurrentAudience(target.value.parentPath))) return false; + return !(yield* hasHiddenDescendantForCurrentAudience(target.value.parentPath)); +}); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 5f2f0b74c0d..a3ef4a05639 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -7,8 +7,10 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { + ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY, AuthAccessTokenType, AuthEnvironmentBootstrapTokenType, + AuthSessionId, AuthTokenExchangeGrantType, CommandId, DEFAULT_SERVER_SETTINGS, @@ -20,6 +22,7 @@ import { MessageId, NonNegativeInt, ExternalLauncherCommandNotFoundError, + type OrchestrationReadModel, type OrchestrationThreadShell, TerminalNotRunningError, type OrchestrationCommand, @@ -130,6 +133,7 @@ import * as SourceControlRepositoryService from "./sourceControl/SourceControlRe import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as AssetAccess from "./assets/AssetAccess.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; +import { base64UrlDecodeUtf8, base64UrlEncode, signPayload } from "./auth/utils.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; @@ -1572,6 +1576,42 @@ const getAuthenticatedBearerSessionToken = (credential = defaultDesktopBootstrap return body.access_token; }); +const decodeSurfaceSessionClaims = Schema.decodeUnknownSync( + Schema.fromJsonString( + Schema.Struct({ + sid: Schema.String, + exp: Schema.Number, + }), + ), +); + +const issueAuthenticatedSurfaceSession = Effect.gen(function* () { + const token = yield* getAuthenticatedBearerSessionToken(); + const encodedClaims = token.split(".")[0]; + assert.isDefined(encodedClaims); + const claims = decodeSurfaceSessionClaims(base64UrlDecodeUtf8(encodedClaims)); + return { + sessionId: AuthSessionId.make(claims.sid), + expiresAt: DateTime.makeUnsafe(claims.exp), + }; +}); + +const bindAssetSurfaceCredential = (credential: string) => + Effect.gen(function* () { + const response = yield* fetchEffect( + yield* getHttpServerUrl(AssetAccess.ASSET_SURFACE_BIND_PATH), + { + method: "POST", + headers: { "content-type": "application/json" }, + body: jsonRequestBody({ credential }), + }, + ); + assert.equal(response.status, 204); + const cookie = response.headers["set-cookie"]; + assert.isDefined(cookie); + return cookie?.split(";")[0] ?? ""; + }); + const extractSessionTokenFromSetCookie = (cookieHeader: string): string => { const [nameValue] = cookieHeader.split(";", 1); const token = nameValue?.split("=", 2)[1]; @@ -1917,10 +1957,20 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ServerSecretStore.layer.pipe(Layer.provide(configLayer)), WorkspacePaths.layer, ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer)), + Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ + getCommandReadModel: () => Effect.succeed(makeDefaultOrchestrationReadModel()), + }), ).pipe(Layer.provideMerge(NodeServices.layer)); + const assetSurfaceSession = yield* issueAuthenticatedSurfaceSession; const issuedAssetUrl = yield* AssetAccess.issueAssetUrl({ resource: { _tag: "attachment", attachmentId }, + clientCapabilities: [ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY], + surfaceSessionId: assetSurfaceSession.sessionId, + surfaceSessionExpiresAt: assetSurfaceSession.expiresAt, }).pipe(Effect.provide(assetSetupLayer)); + const assetSurfaceCookie = yield* bindAssetSurfaceCredential( + issuedAssetUrl.surfaceCredential ?? "", + ); const issuedMcpPeerToken = yield* McpSessionRegistry.issueActiveMcpPeerCredential({ sourceEnvironmentId: EnvironmentId.make("environment-security-source"), }); @@ -2175,6 +2225,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { rpc((client) => client[WS_METHODS.assetsCreateUrl]({ resource: { _tag: "attachment", attachmentId }, + capabilities: [ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY], }).pipe(Effect.map(encodeSecurityObservation)), ), attachmentId, @@ -2184,6 +2235,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { const response = yield* fetchEffect( yield* getHttpServerUrl(issuedAssetUrl.relativeUrl), + { headers: { cookie: assetSurfaceCookie } }, ); if (response.status === 401 || response.status === 403) { return yield* new SecurityProbeDenied({ source: "http" }); @@ -6279,6 +6331,501 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect( + "characterizes desktop preview partition loads without a relay cookie as masked 404", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* buildAppUnderTest(); + const attachmentId = "thread-default-00000000-0000-4000-8000-000000000019"; + const privateContents = "desktop preview private asset"; + yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(config.attachmentsDir, `${attachmentId}.bin`), + privateContents, + ); + + const wsUrl = yield* getWsServerUrl("/ws"); + const { oldClientError, issued } = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const oldClientError = yield* client[WS_METHODS.assetsCreateUrl]({ + resource: { _tag: "attachment", attachmentId }, + }).pipe(Effect.flip); + const issued = yield* client[WS_METHODS.assetsCreateUrl]({ + resource: { _tag: "attachment", attachmentId }, + capabilities: [ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY], + }); + return { oldClientError, issued }; + }), + ), + ); + + assert.equal(oldClientError._tag, "AssetClientUpgradeRequiredError"); + if (oldClientError._tag !== "AssetClientUpgradeRequiredError") { + assert.fail(`Expected AssetClientUpgradeRequiredError, got ${oldClientError._tag}`); + } + assert.equal(oldClientError.requiredCapability, ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY); + assert.isTrue(issued.relativeUrl.startsWith(`${AssetAccess.ASSET_SURFACE_RELAY_PREFIX}/`)); + assert.isString(issued.surfaceCredential); + + // Electron's persist:t3code-preview-* partition has an independent cookie jar. Until the + // deliberately deferred partition bridge exists, its cookie-less navigation must remain + // fail-closed and must never be replaced with an unbound private URL. + const previewPartitionResponse = yield* fetchEffect( + yield* getHttpServerUrl(issued.relativeUrl), + ); + assert.equal(previewPartitionResponse.status, 404); + assert.equal(yield* previewPartitionResponse.text, "Not Found"); + + const hypotheticalUnboundUrl = issued.relativeUrl.replace( + AssetAccess.ASSET_SURFACE_RELAY_PREFIX, + AssetAccess.ASSET_ROUTE_PREFIX, + ); + assert.notEqual(issued.relativeUrl, hypotheticalUnboundUrl); + const hypotheticalUnboundResponse = yield* fetchEffect( + yield* getHttpServerUrl(hypotheticalUnboundUrl), + ); + assert.equal(hypotheticalUnboundResponse.status, 404); + assert.notInclude(yield* hypotheticalUnboundResponse.text, privateContents); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("requires bound surfaces for private assets and revalidates live path audience", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-http-asset-audience-", + }); + const workspaceFile = path.join(workspaceRoot, "report.html"); + const workspaceStylesheet = path.join(workspaceRoot, "report.css"); + yield* fileSystem.writeFileString( + workspaceFile, + 'factory report', + ); + yield* fileSystem.writeFileString(workspaceStylesheet, "body { color: green; }"); + const now = IsoDateTime.make("2026-07-19T06:00:00.000Z"); + const factoryProject = { + id: ProjectId.make("project-http-asset-factory"), + title: "Factory asset project", + workspaceRoot, + dataAudience: "factory" as const, + defaultModelSelection, + scripts: [], + createdAt: now, + updatedAt: now, + deletedAt: null, + }; + const factoryThread = { + ...makeDefaultOrchestrationReadModel().threads[0]!, + id: ThreadId.make("thread-http-asset-factory"), + projectId: factoryProject.id, + dataAudience: "factory" as const, + }; + let liveReadModel: OrchestrationReadModel = { + snapshotSequence: 1, + projects: [factoryProject], + threads: [factoryThread], + updatedAt: now, + }; + const getCommandReadModel = () => Effect.sync(() => liveReadModel); + const config = yield* buildAppUnderTest({ + layers: { projectionSnapshotQuery: { getCommandReadModel } }, + }); + const attachmentId = "thread-private-http-assets-00000000-0000-4000-8000-000000000001"; + const factoryAttachmentId = "thread-http-asset-factory-00000000-0000-4000-8000-000000000002"; + yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(config.attachmentsDir, `${attachmentId}.bin`), + "private attachment", + ); + yield* fileSystem.writeFileString( + path.join(config.attachmentsDir, `${factoryAttachmentId}.bin`), + "factory attachment", + ); + + const configLayer = ServerConfig.layer(config); + const assetSetupLayer = Layer.mergeAll( + configLayer, + ServerSecretStore.layer.pipe(Layer.provide(configLayer)), + WorkspacePaths.layer, + ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer)), + Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ getCommandReadModel }), + ).pipe(Layer.provideMerge(NodeServices.layer)); + const ownerSurfaceSession = yield* issueAuthenticatedSurfaceSession; + const otherSurfaceSession = yield* issueAuthenticatedSurfaceSession; + const issuedAssetUrl = yield* AssetAccess.issueAssetUrl({ + resource: { _tag: "attachment", attachmentId }, + dataAudience: "private", + audienceCeiling: "private", + clientCapabilities: [ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY], + surfaceSessionId: ownerSurfaceSession.sessionId, + surfaceSessionExpiresAt: ownerSurfaceSession.expiresAt, + }).pipe(Effect.provide(assetSetupLayer)); + const otherSurfaceAssetUrl = yield* AssetAccess.issueAssetUrl({ + resource: { _tag: "attachment", attachmentId }, + dataAudience: "private", + audienceCeiling: "private", + clientCapabilities: [ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY], + surfaceSessionId: otherSurfaceSession.sessionId, + surfaceSessionExpiresAt: otherSurfaceSession.expiresAt, + }).pipe(Effect.provide(assetSetupLayer)); + const privateWorkspaceAssetUrl = yield* AssetAccess.issueAssetUrl({ + resource: { + _tag: "workspace-file", + threadId: ThreadId.make("thread-http-asset-factory"), + path: workspaceFile, + }, + workspaceRoot, + dataAudience: "private", + audienceCeiling: "private", + clientCapabilities: [ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY], + surfaceSessionId: ownerSurfaceSession.sessionId, + surfaceSessionExpiresAt: ownerSurfaceSession.expiresAt, + }).pipe(Effect.provide(assetSetupLayer)); + const factoryAssetUrl = yield* AssetAccess.issueAssetUrl({ + resource: { _tag: "attachment", attachmentId: factoryAttachmentId }, + dataAudience: "factory", + audienceCeiling: "factory", + }).pipe(Effect.provide(assetSetupLayer)); + const staleWorkspaceAssetUrl = yield* AssetAccess.issueAssetUrl({ + resource: { + _tag: "workspace-file", + threadId: ThreadId.make("thread-http-asset-factory"), + path: workspaceFile, + }, + workspaceRoot, + dataAudience: "factory", + audienceCeiling: "factory", + }).pipe(Effect.provide(assetSetupLayer)); + const signingSecret = yield* Effect.gen(function* () { + const secretStore = yield* ServerSecretStore.ServerSecretStore; + return yield* secretStore.getOrCreateRandom("asset-access-signing-key", 32); + }).pipe(Effect.provide(assetSetupLayer)); + + const rewriteSignedClaims = ( + relativeUrl: string, + rewrite: (claims: Record) => Record, + ): string => { + const routePrefix = relativeUrl.startsWith(`${AssetAccess.ASSET_SURFACE_RELAY_PREFIX}/`) + ? AssetAccess.ASSET_SURFACE_RELAY_PREFIX + : AssetAccess.ASSET_ROUTE_PREFIX; + const suffix = relativeUrl.slice(`${routePrefix}/`.length); + const separatorIndex = suffix.indexOf("/"); + const token = suffix.slice(0, separatorIndex); + const fileName = suffix.slice(separatorIndex + 1); + const encodedPayload = token.split(".")[0]; + assert.isDefined(encodedPayload); + const claims = JSON.parse(base64UrlDecodeUtf8(encodedPayload)) as Record; + const rewrittenPayload = base64UrlEncode(JSON.stringify(rewrite(claims))); + return `${routePrefix}/${rewrittenPayload}.${signPayload(rewrittenPayload, signingSecret)}/${fileName}`; + }; + const expiredAssetUrl = rewriteSignedClaims(issuedAssetUrl.relativeUrl, (claims) => ({ + ...claims, + expiresAt: 0, + })); + const legacyAssetUrl = rewriteSignedClaims(issuedAssetUrl.relativeUrl, (claims) => + Object.fromEntries( + Object.entries(claims).filter( + ([key]) => key !== "dataAudience" && key !== "audienceCeiling", + ), + ), + ); + const legacyUnboundPrivateUrl = rewriteSignedClaims(issuedAssetUrl.relativeUrl, (claims) => + Object.fromEntries(Object.entries(claims).filter(([key]) => key !== "surfaceBindingId")), + ); + const legacyFactoryAssetUrl = rewriteSignedClaims(factoryAssetUrl.relativeUrl, (claims) => + Object.fromEntries(Object.entries(claims).filter(([key]) => key !== "surfaceBindingId")), + ); + const invalidAudienceAssetUrl = rewriteSignedClaims(issuedAssetUrl.relativeUrl, (claims) => ({ + ...claims, + audienceCeiling: "factory", + })); + const invalidAssetUrl = issuedAssetUrl.relativeUrl.replace(/\.([^./]+)\//, ".$1x/"); + const rejectedDirectPrivateUrl = issuedAssetUrl.relativeUrl.replace( + AssetAccess.ASSET_SURFACE_RELAY_PREFIX, + AssetAccess.ASSET_ROUTE_PREFIX, + ); + + assert.isTrue( + issuedAssetUrl.relativeUrl.startsWith(`${AssetAccess.ASSET_SURFACE_RELAY_PREFIX}/`), + ); + assert.isTrue(factoryAssetUrl.relativeUrl.startsWith(`${AssetAccess.ASSET_ROUTE_PREFIX}/`)); + assert.isFalse( + factoryAssetUrl.relativeUrl.startsWith(`${AssetAccess.ASSET_SURFACE_RELAY_PREFIX}/`), + ); + + const privateSurfaceCookie = yield* bindAssetSurfaceCredential( + issuedAssetUrl.surfaceCredential ?? "", + ); + const otherSurfaceCookie = yield* bindAssetSurfaceCredential( + otherSurfaceAssetUrl.surfaceCredential ?? "", + ); + const { response: factoryTokenResponse, body: factoryTokenBody } = yield* exchangeAccessToken( + defaultDesktopBootstrapToken, + { + audienceCeiling: "factory", + }, + ); + assert.equal(factoryTokenResponse.status, 200); + assert.isDefined(factoryTokenBody.access_token); + + const unauthenticatedPrivateResponse = yield* fetchEffect( + yield* getHttpServerUrl(issuedAssetUrl.relativeUrl), + ); + assert.equal(unauthenticatedPrivateResponse.status, 404); + assert.notInclude(yield* unauthenticatedPrivateResponse.text, "private attachment"); + + const factoryAuthenticatedPrivateResponse = yield* fetchEffect( + yield* getHttpServerUrl(issuedAssetUrl.relativeUrl), + { headers: { authorization: `Bearer ${factoryTokenBody.access_token ?? ""}` } }, + ); + assert.equal(factoryAuthenticatedPrivateResponse.status, 404); + assert.notInclude(yield* factoryAuthenticatedPrivateResponse.text, "private attachment"); + + const privateAuthenticatedResponse = yield* fetchEffect( + yield* getHttpServerUrl(issuedAssetUrl.relativeUrl), + { headers: { cookie: privateSurfaceCookie } }, + ); + assert.equal(privateAuthenticatedResponse.status, 200); + assert.equal(privateAuthenticatedResponse.headers["cache-control"], "no-store"); + assert.equal(yield* privateAuthenticatedResponse.text, "private attachment"); + + const rejectedCrossSiteCookieResponse = yield* fetchEffect( + yield* getHttpServerUrl(rejectedDirectPrivateUrl), + { headers: { cookie: privateSurfaceCookie } }, + ); + assert.equal(rejectedCrossSiteCookieResponse.status, 404); + assert.equal(yield* rejectedCrossSiteCookieResponse.text, "Not Found"); + + const nativeAuthenticatedResponse = yield* fetchEffect( + yield* getHttpServerUrl(issuedAssetUrl.relativeUrl), + { headers: { "x-t3-asset-surface": issuedAssetUrl.surfaceCredential ?? "" } }, + ); + assert.equal(nativeAuthenticatedResponse.status, 200); + assert.equal(yield* nativeAuthenticatedResponse.text, "private attachment"); + + const nativeWithStaleCookieResponse = yield* fetchEffect( + yield* getHttpServerUrl(issuedAssetUrl.relativeUrl), + { + headers: { + cookie: otherSurfaceCookie, + "x-t3-asset-surface": issuedAssetUrl.surfaceCredential ?? "", + }, + }, + ); + assert.equal(nativeWithStaleCookieResponse.status, 200); + assert.equal(yield* nativeWithStaleCookieResponse.text, "private attachment"); + + const concurrentSurfaceResponse = yield* fetchEffect( + yield* getHttpServerUrl(issuedAssetUrl.relativeUrl), + { headers: { cookie: `${otherSurfaceCookie}; ${privateSurfaceCookie}` } }, + ); + assert.equal(concurrentSurfaceResponse.status, 200); + assert.equal(yield* concurrentSurfaceResponse.text, "private attachment"); + + const crossSurfaceResponse = yield* fetchEffect( + yield* getHttpServerUrl(issuedAssetUrl.relativeUrl), + { headers: { cookie: otherSurfaceCookie } }, + ); + assert.equal(crossSurfaceResponse.status, 404); + assert.equal(yield* crossSurfaceResponse.text, "Not Found"); + + const webViewBindingResponse = yield* fetchEffect( + yield* getHttpServerUrl(AssetAccess.ASSET_SURFACE_BIND_PATH), + { + method: "POST", + redirect: "manual", + headers: { "content-type": "application/json" }, + body: jsonRequestBody({ + credential: privateWorkspaceAssetUrl.surfaceCredential ?? "", + redirect: privateWorkspaceAssetUrl.relativeUrl, + }), + }, + ); + assert.equal(webViewBindingResponse.status, 303); + assert.equal(webViewBindingResponse.headers.location, privateWorkspaceAssetUrl.relativeUrl); + const webViewSurfaceCookie = webViewBindingResponse.headers["set-cookie"]?.split(";")[0]; + assert.isDefined(webViewSurfaceCookie); + assert.include(webViewBindingResponse.headers["set-cookie"] ?? "", "SameSite=Lax"); + assert.include( + webViewBindingResponse.headers["set-cookie"] ?? "", + `Path=${AssetAccess.ASSET_SURFACE_RELAY_PREFIX}`, + ); + assert.notInclude(webViewBindingResponse.headers["set-cookie"] ?? "", "SameSite=None"); + assert.notInclude(webViewBindingResponse.headers["set-cookie"] ?? "", "Secure"); + + const hostedBindingResponse = yield* fetchEffect( + yield* getHttpServerUrl(AssetAccess.ASSET_SURFACE_BIND_PATH), + { + method: "POST", + headers: { + "content-type": "application/json", + "x-forwarded-proto": "https", + }, + body: jsonRequestBody({ credential: issuedAssetUrl.surfaceCredential ?? "" }), + }, + ); + assert.equal(hostedBindingResponse.status, 204); + assert.include(hostedBindingResponse.headers["set-cookie"] ?? "", "SameSite=Lax"); + assert.include(hostedBindingResponse.headers["set-cookie"] ?? "", "Secure"); + const webViewDocumentResponse = yield* fetchEffect( + yield* getHttpServerUrl(privateWorkspaceAssetUrl.relativeUrl), + { headers: { cookie: webViewSurfaceCookie ?? "" } }, + ); + assert.equal(webViewDocumentResponse.status, 200); + assert.include(yield* webViewDocumentResponse.text, "factory report"); + const webViewStylesheetUrl = privateWorkspaceAssetUrl.relativeUrl.replace( + /\/[^/]+$/, + "/report.css", + ); + const webViewStylesheetResponse = yield* fetchEffect( + yield* getHttpServerUrl(webViewStylesheetUrl), + { headers: { cookie: webViewSurfaceCookie ?? "" } }, + ); + assert.equal(webViewStylesheetResponse.status, 200); + assert.equal(yield* webViewStylesheetResponse.text, "body { color: green; }"); + + const factoryResponse = yield* fetchEffect( + yield* getHttpServerUrl(factoryAssetUrl.relativeUrl), + ); + assert.equal(factoryResponse.status, 200); + assert.equal(factoryResponse.headers["cache-control"], "no-store"); + assert.equal(yield* factoryResponse.text, "factory attachment"); + + const legacyFactoryResponse = yield* fetchEffect( + yield* getHttpServerUrl(legacyFactoryAssetUrl), + ); + assert.equal(legacyFactoryResponse.status, 200); + assert.equal(yield* legacyFactoryResponse.text, "factory attachment"); + + const factoryHeadResponse = yield* fetchEffect( + yield* getHttpServerUrl(factoryAssetUrl.relativeUrl), + { method: "HEAD" }, + ); + assert.equal(factoryHeadResponse.status, 200); + assert.equal(factoryHeadResponse.headers["cache-control"], "no-store"); + assert.equal(yield* factoryHeadResponse.text, ""); + + for (const relativeUrl of [ + invalidAssetUrl, + expiredAssetUrl, + legacyAssetUrl, + legacyUnboundPrivateUrl, + invalidAudienceAssetUrl, + ]) { + const response = yield* fetchEffect(yield* getHttpServerUrl(relativeUrl)); + const body = yield* response.text; + const serializedResponse = encodeSecurityObservation({ + status: response.status, + headers: response.headers, + body, + }); + assert.equal(response.status, 404); + assert.equal(body, "Not Found"); + assert.notInclude(serializedResponse, attachmentId); + assert.notInclude(serializedResponse, "private attachment"); + } + + const readdedPrivateProject = { + ...factoryProject, + id: ProjectId.make("project-http-asset-private"), + title: "Re-added private asset project", + dataAudience: "private" as const, + }; + const collidingPrivateThread = { + ...factoryThread, + id: ThreadId.make("Thread.Http.Asset.Factory"), + projectId: readdedPrivateProject.id, + dataAudience: "private" as const, + }; + liveReadModel = { + snapshotSequence: 2, + projects: [{ ...factoryProject, deletedAt: now }, readdedPrivateProject], + threads: [factoryThread, collidingPrivateThread], + updatedAt: now, + }; + const staleResponse = yield* fetchEffect( + yield* getHttpServerUrl(staleWorkspaceAssetUrl.relativeUrl), + ); + assert.equal(staleResponse.status, 403); + assert.notInclude(yield* staleResponse.text, "factory report"); + const staleAttachmentResponse = yield* fetchEffect( + yield* getHttpServerUrl(factoryAssetUrl.relativeUrl), + ); + assert.equal(staleAttachmentResponse.status, 403); + assert.notInclude(yield* staleAttachmentResponse.text, "factory attachment"); + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + + it.effect("refuses factory-ceiling asset URLs for files in nested private projects", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const factoryRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-factory-asset-root-", + }); + const privateRoot = path.join(factoryRoot, "private-project"); + const privateFile = path.join(privateRoot, "secret.html"); + yield* fileSystem.makeDirectory(privateRoot, { recursive: true }); + yield* fileSystem.writeFileString(privateFile, "private descendant"); + + const baseReadModel = makeDefaultOrchestrationReadModel(); + const factoryProject = { + ...baseReadModel.projects[0]!, + workspaceRoot: factoryRoot, + dataAudience: "factory" as const, + }; + const privateProject = { + ...factoryProject, + id: ProjectId.make("project-nested-private-asset"), + title: "Nested private asset project", + workspaceRoot: privateRoot, + dataAudience: "private" as const, + }; + const factoryThread = { + ...baseReadModel.threads[0]!, + projectId: factoryProject.id, + dataAudience: "factory" as const, + }; + const commandReadModel: OrchestrationReadModel = { + ...baseReadModel, + projects: [factoryProject, privateProject], + threads: [factoryThread], + }; + const getCommandReadModel = () => Effect.succeed(commandReadModel); + const config = yield* buildAppUnderTest({ + layers: { + projectionSnapshotQuery: { + getCommandReadModel, + }, + }, + }); + const configLayer = ServerConfig.layer(config); + const assetLayer = Layer.mergeAll( + configLayer, + ServerSecretStore.layer.pipe(Layer.provide(configLayer)), + WorkspacePaths.layer, + ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer)), + Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ getCommandReadModel }), + ).pipe(Layer.provideMerge(NodeServices.layer)); + const error = yield* AssetAccess.issueAssetUrl({ + resource: { + _tag: "workspace-file", + threadId: factoryThread.id, + path: privateFile, + }, + workspaceRoot: factoryRoot, + dataAudience: factoryThread.dataAudience, + audienceCeiling: "factory", + }).pipe(Effect.provide(assetLayer), Effect.flip); + + assert.equal(error._tag, "AssetWorkspaceContextNotFoundError"); + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + it.effect("routes websocket rpc projects.writeFile", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -6487,7 +7034,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("routes websocket rpc git methods", () => Effect.gen(function* () { - yield* buildAppUnderTest({ + const path = yield* Path.Path; + const createWorktreeInputs: Array<{ + readonly cwd: string; + readonly refName: string; + readonly path: string | null; + }> = []; + const config = yield* buildAppUnderTest({ config: { cwd: "/tmp/repo", }, @@ -6623,10 +7176,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { nextCursor: null, totalCount: 1, }), - createWorktree: () => - Effect.succeed({ + createWorktree: (input) => { + createWorktreeInputs.push(input); + return Effect.succeed({ worktree: { path: "/tmp/wt", refName: "feature/demo" }, - }), + }); + }, removeWorktree: () => Effect.void, createRef: (input) => Effect.succeed({ refName: input.refName }), switchRef: (input) => Effect.succeed({ refName: input.refName }), @@ -6746,6 +7301,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ); assert.equal(worktree.worktree.refName, "feature/demo"); + assert.deepEqual(createWorktreeInputs, [ + { + cwd: "/tmp/repo", + refName: "main", + path: path.join(config.worktreesDir, "repo", "main"), + }, + ]); yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 0ef48f9e5b7..f6c6e35a43e 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -13,6 +13,7 @@ import { notificationRecoveryRouteLayer, mcpPeerTokenRouteLayer, assetRouteLayer, + assetSurfaceBindingRouteLayer, serverEnvironmentHttpApiLayer, staticAndDevRouteLayer, browserApiCorsLayer, @@ -424,6 +425,7 @@ export const makeRoutesLayer = Layer.mergeAll( notificationAckRouteLayer, notificationRecoveryRouteLayer, assetRouteLayer, + assetSurfaceBindingRouteLayer, staticAndDevRouteLayer, websocketRpcRouteLayer, ), diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 373b35b7fb9..7901e336ff8 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -30,6 +30,7 @@ import { compactTraceAttributes } from "@t3tools/shared/observability"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; +import { resolveCreateWorktreePath } from "./worktreePath.ts"; import { parseRemoteNames, parseRemoteNamesInGitOrder, @@ -2360,9 +2361,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "createWorktree", )(function* (input) { const targetBranch = input.newRefName ?? input.refName; - const sanitizedBranch = targetBranch.replace(/\//g, "-"); - const repoName = path.basename(input.cwd); - const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); + const worktreePath = resolveCreateWorktreePath({ + ...input, + worktreesDir, + pathService: path, + }); const args = input.newRefName ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName] : ["worktree", "add", worktreePath, input.refName]; diff --git a/apps/server/src/vcs/worktreePath.ts b/apps/server/src/vcs/worktreePath.ts new file mode 100644 index 00000000000..4b0b4e7924d --- /dev/null +++ b/apps/server/src/vcs/worktreePath.ts @@ -0,0 +1,15 @@ +import type * as Path from "effect/Path"; + +export function resolveCreateWorktreePath(input: { + readonly cwd: string; + readonly refName: string; + readonly newRefName?: string | undefined; + readonly path: string | null; + readonly worktreesDir: string; + readonly pathService: Path.Path; +}): string { + const targetBranch = input.newRefName ?? input.refName; + const sanitizedBranch = targetBranch.replace(/\//g, "-"); + const repoName = input.pathService.basename(input.cwd); + return input.path ?? input.pathService.join(input.worktreesDir, repoName, sanitizedBranch); +} diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index 87c8a3d7b96..c8bf94ae1b9 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -82,12 +82,22 @@ export const WorkspaceEntriesError = Schema.Union([ ]); export type WorkspaceEntriesError = typeof WorkspaceEntriesError.Type; +export interface ResolvedBrowseTarget { + readonly parentPath: string; + readonly prefix: string; + readonly showHidden: boolean; +} + export class WorkspaceEntries extends Context.Service< WorkspaceEntries, { readonly browse: ( input: FilesystemBrowseInput, ) => Effect.Effect; + readonly browseResolved: ( + input: FilesystemBrowseInput, + target: ResolvedBrowseTarget, + ) => Effect.Effect; readonly list: ( input: ProjectListEntriesInput, ) => Effect.Effect; @@ -103,11 +113,11 @@ function resolveHomeAwarePath(input: string): string { return trimmed.length === 0 ? NodeOS.homedir() : expandHomePath(trimmed); } -const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(function* ( +export const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(function* ( input: FilesystemBrowseInput, - path: Path.Path, -): Effect.fn.Return { +): Effect.fn.Return { const platform = yield* HostProcessPlatform; + const path = yield* Path.Path; const partialPath = input.partialPath.trim(); if (platform !== "win32" && isWindowsAbsolutePath(partialPath)) { @@ -118,16 +128,19 @@ const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(fu }); } - if (!isExplicitRelativePath(partialPath)) { - return path.resolve(resolveHomeAwarePath(partialPath)); - } - - if (!input.cwd) { - return yield* new WorkspaceEntriesCurrentProjectRequiredError({ - partialPath, - }); - } - return path.resolve(resolveHomeAwarePath(input.cwd), partialPath); + const resolvedInputPath = !isExplicitRelativePath(partialPath) + ? path.resolve(resolveHomeAwarePath(partialPath)) + : input.cwd + ? path.resolve(resolveHomeAwarePath(input.cwd), partialPath) + : yield* new WorkspaceEntriesCurrentProjectRequiredError({ partialPath }); + const endsWithSeparator = + partialPath.length === 0 || /[\\/]$/.test(partialPath) || partialPath === "~"; + const prefix = endsWithSeparator ? "" : path.basename(resolvedInputPath); + return { + parentPath: endsWithSeparator ? resolvedInputPath : path.dirname(resolvedInputPath), + prefix, + showHidden: endsWithSeparator || prefix.startsWith("."), + }; }); export const make = Effect.gen(function* () { @@ -176,54 +189,55 @@ export const make = Effect.gen(function* () { }, ); - const browse: WorkspaceEntries["Service"]["browse"] = Effect.fn("WorkspaceEntries.browse")( - function* (input) { - const partialPath = input.partialPath.trim(); - const resolvedInputPath = yield* resolveBrowseTarget(input, path); - const endsWithSeparator = - partialPath.length === 0 || /[\\/]$/.test(partialPath) || partialPath === "~"; - const parentPath = endsWithSeparator ? resolvedInputPath : path.dirname(resolvedInputPath); - const prefix = endsWithSeparator ? "" : path.basename(resolvedInputPath); + const browseResolved: WorkspaceEntries["Service"]["browseResolved"] = Effect.fn( + "WorkspaceEntries.browseResolved", + )(function* (input, target) { + const partialPath = input.partialPath.trim(); - const dirents = yield* Effect.tryPromise({ - try: () => NodeFSP.readdir(parentPath, { withFileTypes: true }), - catch: (cause) => - new WorkspaceEntriesReadDirectoryError({ - cwd: input.cwd, - partialPath, - parentPath, - cause, - }), - }).pipe( - Effect.catchIf( - (error) => { - const code = (error.cause as NodeJS.ErrnoException | undefined)?.code; - return code === "EACCES" || code === "EPERM"; - }, - () => Effect.succeed([]), - ), - ); + const dirents = yield* Effect.tryPromise({ + try: () => NodeFSP.readdir(target.parentPath, { withFileTypes: true }), + catch: (cause) => + new WorkspaceEntriesReadDirectoryError({ + cwd: input.cwd, + partialPath, + parentPath: target.parentPath, + cause, + }), + }).pipe( + Effect.catchIf( + (error) => { + const code = (error.cause as NodeJS.ErrnoException | undefined)?.code; + return code === "EACCES" || code === "EPERM"; + }, + () => Effect.succeed([]), + ), + ); - const showHidden = endsWithSeparator || prefix.startsWith("."); - const lowerPrefix = prefix.toLowerCase(); - const entries: Array<{ readonly name: string; readonly fullPath: string }> = []; - for (const dirent of dirents) { - if ( - dirent.isDirectory() && - dirent.name.toLowerCase().startsWith(lowerPrefix) && - (showHidden || !dirent.name.startsWith(".")) - ) { - entries.push({ - name: dirent.name, - fullPath: path.join(parentPath, dirent.name), - }); - } + const lowerPrefix = target.prefix.toLowerCase(); + const entries: Array<{ readonly name: string; readonly fullPath: string }> = []; + for (const dirent of dirents) { + if ( + dirent.isDirectory() && + dirent.name.toLowerCase().startsWith(lowerPrefix) && + (target.showHidden || !dirent.name.startsWith(".")) + ) { + entries.push({ + name: dirent.name, + fullPath: path.join(target.parentPath, dirent.name), + }); } + } + + return { + parentPath: target.parentPath, + entries: entries.toSorted((left, right) => left.name.localeCompare(right.name)), + }; + }); - return { - parentPath, - entries: entries.toSorted((left, right) => left.name.localeCompare(right.name)), - }; + const browse: WorkspaceEntries["Service"]["browse"] = Effect.fn("WorkspaceEntries.browse")( + function* (input) { + const target = yield* resolveBrowseTarget(input).pipe(Effect.provideService(Path.Path, path)); + return yield* browseResolved(input, target); }, ); @@ -251,7 +265,7 @@ export const make = Effect.gen(function* () { }, ); - return WorkspaceEntries.of({ browse, list, refresh, search }); + return WorkspaceEntries.of({ browse, browseResolved, list, refresh, search }); }); export const layer = Layer.effect(WorkspaceEntries, make).pipe( diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index cecffbc1993..89b99a25d58 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -1,4 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { it, describe, expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -28,6 +29,7 @@ const TestLayer = Layer.empty.pipe( }), ), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.succeed(HostProcessPlatform, "linux")), ); const makeTempDir = Effect.gen(function* () { @@ -264,5 +266,57 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i expect(escapedStat).toBeNull(); }), ); + + it.effect("rejects writes through an existing leaf symlink outside the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const outsideDir = yield* makeTempDir; + const outsideFile = path.join(outsideDir, "secret.txt"); + yield* fileSystem.writeFileString(outsideFile, "outside"); + yield* fileSystem.symlink(outsideFile, path.join(cwd, "linked-secret.txt")); + + const result = yield* workspaceFileSystem + .writeFile({ + cwd, + relativePath: "linked-secret.txt", + contents: "overwritten", + }) + .pipe(Effect.result); + + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(result.failure).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFilePathEscapeError); + } + expect(yield* fileSystem.readFileString(outsideFile)).toBe("outside"); + }), + ); + + it.effect("rejects writes through a symlinked parent outside the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const outsideDir = yield* makeTempDir; + yield* fileSystem.symlink(outsideDir, path.join(cwd, "linked-outside")); + + const result = yield* workspaceFileSystem + .writeFile({ + cwd, + relativePath: "linked-outside/escaped.txt", + contents: "outside", + }) + .pipe(Effect.result); + + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(result.failure).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFilePathEscapeError); + } + expect(yield* fileSystem.exists(path.join(outsideDir, "escaped.txt"))).toBe(false); + }), + ); }); }); diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index e2dc9cbbb39..c8784bff311 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -7,6 +7,7 @@ * * @module WorkspaceFileSystem */ +import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import type { @@ -15,9 +16,9 @@ import type { ProjectWriteFileInput, ProjectWriteFileResult, } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; @@ -100,6 +101,9 @@ export const WorkspaceFileSystemError = Schema.Union([ ]); export type WorkspaceFileSystemError = typeof WorkspaceFileSystemError.Type; +const isWorkspaceFileSystemOperationError = Schema.is(WorkspaceFileSystemOperationError); +const isWorkspaceFilePathEscapeError = Schema.is(WorkspaceFilePathEscapeError); + /** Service tag for workspace file operations. */ export class WorkspaceFileSystem extends Context.Service< WorkspaceFileSystem, @@ -127,11 +131,21 @@ export class WorkspaceFileSystem extends Context.Service< >()("t3/workspace/WorkspaceFileSystem") {} export const make = Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const hostPlatform = yield* HostProcessPlatform; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const realPathInsideWorkspace = (realWorkspaceRoot: string, candidatePath: string) => { + const relativeRealPath = path.relative(realWorkspaceRoot, candidatePath); + return ( + relativeRealPath === "" || + (relativeRealPath !== ".." && + !relativeRealPath.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativeRealPath)) + ); + }; + const readFile: WorkspaceFileSystem["Service"]["readFile"] = Effect.fn( "WorkspaceFileSystem.readFile", )(function* (input) { @@ -180,7 +194,8 @@ export const make = Effect.gen(function* () { return yield* Effect.acquireUseRelease( Effect.tryPromise({ - try: () => NodeFSP.open(realTargetPath, "r"), + try: () => + NodeFSP.open(realTargetPath, NodeFS.constants.O_RDONLY | NodeFS.constants.O_NOFOLLOW), catch: (cause) => new WorkspaceFileSystemOperationError({ workspaceRoot: input.cwd, @@ -213,6 +228,49 @@ export const make = Effect.gen(function* () { }); } + const currentRealTargetPath = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(target.absolutePath), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: realTargetPath, + operationPath: target.absolutePath, + operation: "realpath-target", + cause, + }), + }); + if (!realPathInsideWorkspace(realWorkspaceRoot, currentRealTargetPath)) { + return yield* new WorkspaceFilePathEscapeError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedWorkspaceRoot: realWorkspaceRoot, + resolvedPath: currentRealTargetPath, + }); + } + const currentStat = yield* Effect.tryPromise({ + try: () => NodeFSP.stat(currentRealTargetPath), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: currentRealTargetPath, + operationPath: currentRealTargetPath, + operation: "stat", + cause, + }), + }); + if (currentStat.dev !== stat.dev || currentStat.ino !== stat.ino) { + return yield* new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: currentRealTargetPath, + operationPath: currentRealTargetPath, + operation: "stat", + cause: new Error("Workspace target changed while it was being authorized."), + }); + } + const bytesToRead = Math.min(stat.size, PROJECT_READ_FILE_MAX_BYTES); const buffer = Buffer.alloc(bytesToRead); const { bytesRead } = yield* Effect.tryPromise({ @@ -267,32 +325,329 @@ export const make = Effect.gen(function* () { relativePath: input.relativePath, }); - yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe( - Effect.mapError( - (cause) => - new WorkspaceFileSystemOperationError({ - workspaceRoot: input.cwd, - relativePath: input.relativePath, - resolvedPath: target.absolutePath, - operationPath: path.dirname(target.absolutePath), - operation: "make-directory", - cause, - }), - ), - ); - yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe( - Effect.mapError( - (cause) => - new WorkspaceFileSystemOperationError({ - workspaceRoot: input.cwd, - relativePath: input.relativePath, - resolvedPath: target.absolutePath, - operationPath: target.absolutePath, - operation: "write-file", - cause, - }), - ), - ); + const realWorkspaceRoot = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(input.cwd), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: input.cwd, + operation: "realpath-workspace-root", + cause, + }), + }); + + const targetParentPath = path.dirname(target.absolutePath); + const parentRelativePath = path.relative(input.cwd, targetParentPath); + const parentSegments = parentRelativePath === "" ? [] : parentRelativePath.split(path.sep); + const leafName = path.basename(target.absolutePath); + + if (hostPlatform !== "linux") { + yield* Effect.tryPromise({ + try: async () => { + let ancestorPath = input.cwd; + for (const segment of parentSegments) { + ancestorPath = path.join(ancestorPath, segment); + let ancestorStat: NodeFS.Stats; + try { + ancestorStat = await NodeFSP.lstat(ancestorPath); + } catch (cause) { + if ((cause as NodeJS.ErrnoException).code === "ENOENT") break; + throw new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: ancestorPath, + operation: "stat", + cause, + }); + } + + if (ancestorStat.isSymbolicLink()) { + throw new WorkspaceFilePathEscapeError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedWorkspaceRoot: realWorkspaceRoot, + resolvedPath: ancestorPath, + }); + } + if (!ancestorStat.isDirectory()) { + throw new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: ancestorPath, + operation: "make-directory", + cause: new Error("Existing parent path is not a directory."), + }); + } + + const realAncestorPath = await NodeFSP.realpath(ancestorPath); + if (!realPathInsideWorkspace(realWorkspaceRoot, realAncestorPath)) { + throw new WorkspaceFilePathEscapeError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedWorkspaceRoot: realWorkspaceRoot, + resolvedPath: realAncestorPath, + }); + } + } + + try { + await NodeFSP.mkdir(targetParentPath, { recursive: true }); + } catch (cause) { + throw new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: targetParentPath, + operation: "make-directory", + cause, + }); + } + + const realTargetParentPath = await NodeFSP.realpath(targetParentPath); + const realTargetPath = path.join(realTargetParentPath, leafName); + if (!realPathInsideWorkspace(realWorkspaceRoot, realTargetPath)) { + throw new WorkspaceFilePathEscapeError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedWorkspaceRoot: realWorkspaceRoot, + resolvedPath: realTargetPath, + }); + } + + try { + const leafStat = await NodeFSP.lstat(realTargetPath); + if (leafStat.isSymbolicLink()) { + throw new WorkspaceFilePathEscapeError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedWorkspaceRoot: realWorkspaceRoot, + resolvedPath: realTargetPath, + }); + } + } catch (cause) { + if (isWorkspaceFilePathEscapeError(cause)) throw cause; + if ((cause as NodeJS.ErrnoException).code !== "ENOENT") { + throw new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: realTargetPath, + operationPath: realTargetPath, + operation: "stat", + cause, + }); + } + } + + let handle: NodeFSP.FileHandle | null = null; + try { + handle = await NodeFSP.open( + realTargetPath, + NodeFS.constants.O_WRONLY | + NodeFS.constants.O_CREAT | + NodeFS.constants.O_TRUNC | + NodeFS.constants.O_NOFOLLOW, + ); + await handle.writeFile(input.contents, "utf8"); + } catch (cause) { + if ((cause as NodeJS.ErrnoException).code === "ELOOP") { + throw new WorkspaceFilePathEscapeError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedWorkspaceRoot: realWorkspaceRoot, + resolvedPath: realTargetPath, + }); + } + throw new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: realTargetPath, + operationPath: realTargetPath, + operation: "write-file", + cause, + }); + } finally { + await handle?.close().catch(() => undefined); + } + }, + catch: (cause) => + isWorkspaceFilePathEscapeError(cause) || isWorkspaceFileSystemOperationError(cause) + ? cause + : new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: target.absolutePath, + operation: "write-file", + cause, + }), + }); + + yield* workspaceEntries.refresh(input.cwd); + return { relativePath: target.relativePath }; + } + + const fdDirectoryRoot = "/proc/self/fd"; + + yield* Effect.tryPromise({ + try: async () => { + const directoryHandles: NodeFSP.FileHandle[] = []; + const fdPath = (fd: number) => path.join(fdDirectoryRoot, String(fd)); + const openDirectory = async (directoryPath: string) => + await NodeFSP.open( + directoryPath, + NodeFS.constants.O_RDONLY | NodeFS.constants.O_DIRECTORY | NodeFS.constants.O_NOFOLLOW, + ); + + try { + let currentDirectory = await openDirectory(realWorkspaceRoot); + directoryHandles.push(currentDirectory); + + for (const segment of parentSegments) { + const childPath = path.join(fdPath(currentDirectory.fd), segment); + let childStat: NodeFS.Stats; + try { + childStat = await NodeFSP.lstat(childPath); + } catch (cause) { + if ((cause as NodeJS.ErrnoException).code !== "ENOENT") { + throw new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: childPath, + operation: "stat", + cause, + }); + } + try { + await NodeFSP.mkdir(childPath); + childStat = await NodeFSP.lstat(childPath); + } catch (mkdirCause) { + if ((mkdirCause as NodeJS.ErrnoException).code === "EEXIST") { + childStat = await NodeFSP.lstat(childPath); + } else { + throw new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: childPath, + operation: "make-directory", + cause: mkdirCause, + }); + } + } + } + + if (childStat.isSymbolicLink()) { + throw new WorkspaceFilePathEscapeError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedWorkspaceRoot: realWorkspaceRoot, + resolvedPath: childPath, + }); + } + if (!childStat.isDirectory()) { + throw new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: childPath, + operation: "make-directory", + cause: new Error("Existing parent path is not a directory."), + }); + } + + const realChildPath = await NodeFSP.realpath(childPath); + if (!realPathInsideWorkspace(realWorkspaceRoot, realChildPath)) { + throw new WorkspaceFilePathEscapeError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedWorkspaceRoot: realWorkspaceRoot, + resolvedPath: realChildPath, + }); + } + + currentDirectory = await openDirectory(childPath); + directoryHandles.push(currentDirectory); + } + + const leafPath = path.join(fdPath(currentDirectory.fd), leafName); + try { + const leafStat = await NodeFSP.lstat(leafPath); + if (leafStat.isSymbolicLink()) { + throw new WorkspaceFilePathEscapeError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedWorkspaceRoot: realWorkspaceRoot, + resolvedPath: leafPath, + }); + } + } catch (cause) { + if (isWorkspaceFilePathEscapeError(cause)) throw cause; + if ((cause as NodeJS.ErrnoException).code !== "ENOENT") { + throw new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: leafPath, + operation: "stat", + cause, + }); + } + } + + let handle: NodeFSP.FileHandle | null = null; + try { + handle = await NodeFSP.open( + leafPath, + NodeFS.constants.O_WRONLY | + NodeFS.constants.O_CREAT | + NodeFS.constants.O_TRUNC | + NodeFS.constants.O_NOFOLLOW, + ); + await handle.writeFile(input.contents, "utf8"); + } catch (cause) { + if ((cause as NodeJS.ErrnoException).code === "ELOOP") { + throw new WorkspaceFilePathEscapeError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedWorkspaceRoot: realWorkspaceRoot, + resolvedPath: leafPath, + }); + } + throw new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: leafPath, + operation: "write-file", + cause, + }); + } finally { + await handle?.close().catch(() => undefined); + } + } finally { + for (const handle of directoryHandles.toReversed()) { + await handle.close().catch(() => undefined); + } + } + }, + catch: (cause) => + isWorkspaceFilePathEscapeError(cause) || isWorkspaceFileSystemOperationError(cause) + ? cause + : new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: target.absolutePath, + operation: "write-file", + cause, + }), + }); + yield* workspaceEntries.refresh(input.cwd); return { relativePath: target.relativePath }; }); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 356feb2a617..336b2e7896f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2,6 +2,7 @@ import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; @@ -19,11 +20,14 @@ import { AuthAccessStreamError, type AuthAccessStreamEvent, type AuthEnvironmentScope, + type AssetResource, AuthSessionId, type DiscoveredLocalServerList, type OrchestrationCommand, type GitActionProgressEvent, type GitManagerServiceError, + GitCommandError, + VcsRepositoryDetectionError, NonNegativeInt, OrchestrationDispatchCommandError, type OrchestrationEvent, @@ -59,6 +63,7 @@ import { WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { clamp } from "effect/Number"; import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; @@ -90,7 +95,7 @@ import * as ServerSettings from "./serverSettings.ts"; import * as PlanUsageSnapshot from "./usage/PlanUsageSnapshot.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewManager from "./preview/Manager.ts"; -import { issueAssetUrl } from "./assets/AssetAccess.ts"; +import { classifyAttachmentAudience, issueAssetUrl } from "./assets/AssetAccess.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; @@ -100,6 +105,7 @@ import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; +import * as ProjectFilesystemAudienceGuard from "./project/ProjectFilesystemAudienceGuard.ts"; import * as BootstrapTurnStartDispatcher from "./orchestration/Services/BootstrapTurnStartDispatcher.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; @@ -119,12 +125,14 @@ import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; +import { resolveCreateWorktreePath } from "./vcs/worktreePath.ts"; import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; +import { strictestDataAudience } from "./auth/audienceDataPolicy.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; import { makeServerConfigHeartbeatStream, shouldSendServerConfigHeartbeat } from "./wsKeepalive.ts"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); @@ -402,6 +410,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => const keybindings = yield* Keybindings.Keybindings; const externalLauncher = yield* ExternalLauncher.ExternalLauncher; const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; + const vcsDriverRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; const review = yield* ReviewService.ReviewService; const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; @@ -439,6 +448,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const deviceNotifications = yield* DeviceNotifications.DeviceNotifications; const relayClient = yield* RelayClient.RelayClient; + const pathService = yield* Path.Path; + const hostProcessPlatform = yield* HostProcessPlatform; const authenticatedPrincipal = { ...currentSession, scopes: new Set(currentSession.scopes), @@ -532,6 +543,297 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => traceAttributes, ); }; + const withAuthenticatedPrincipal = (effect: Effect.Effect) => + effect.pipe( + Effect.provideService(EnvironmentAuthenticatedPrincipal, authenticatedPrincipal), + ); + const withFilesystemGuardServices = (effect: Effect.Effect) => + withAuthenticatedPrincipal(effect).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, pathService), + Effect.provideService(HostProcessPlatform, hostProcessPlatform), + Effect.provideService( + ProjectionSnapshotQuery.ProjectionSnapshotQuery, + projectionSnapshotQuery, + ), + Effect.provideService(VcsDriverRegistry.VcsDriverRegistry, vcsDriverRegistry), + ); + const visiblePath = (candidatePath: string): Effect.Effect => + currentSession.audienceCeiling === "private" + ? Effect.succeed(true) + : withFilesystemGuardServices( + ProjectFilesystemAudienceGuard.isPathVisibleToCurrentAudience(candidatePath), + ).pipe( + Effect.catchCause((cause) => + Effect.logWarning("filesystem audience guard failed closed", { + candidatePath, + cause, + }).pipe(Effect.as(false)), + ), + ); + const hasHiddenDescendant = (candidatePath: string): Effect.Effect => + currentSession.audienceCeiling === "private" + ? Effect.succeed(false) + : withFilesystemGuardServices( + ProjectFilesystemAudienceGuard.hasHiddenDescendantForCurrentAudience(candidatePath), + ).pipe( + Effect.catchCause((cause) => + Effect.logWarning("filesystem hidden descendant guard failed closed", { + candidatePath, + cause, + }).pipe(Effect.as(true)), + ), + ); + const projectEntriesDeniedContext = (cwd: string) => ({ + failure: "workspace_root_not_found" as const, + normalizedCwd: pathService.resolve(cwd), + }); + const projectFileDeniedContext = (input: { + readonly cwd: string; + readonly relativePath: string; + }) => ({ + failure: "operation_failed" as const, + resolvedPath: pathService.resolve(input.cwd, input.relativePath), + operation: "realpath-workspace-root" as const, + operationPath: input.cwd, + }); + const ensureProjectEntriesVisible = (cwd: string) => + visiblePath(cwd).pipe( + Effect.flatMap((visible) => + visible + ? hasHiddenDescendant(cwd).pipe(Effect.map((hidden) => !hidden)) + : Effect.succeed(false), + ), + Effect.flatMap((visible) => + visible + ? Effect.void + : Effect.fail( + new ProjectListEntriesError({ cwd, ...projectEntriesDeniedContext(cwd) }), + ), + ), + ); + const ensureProjectSearchVisible = (input: { + readonly cwd: string; + readonly query: string; + readonly limit: number; + }) => + visiblePath(input.cwd).pipe( + Effect.flatMap((visible) => + visible + ? hasHiddenDescendant(input.cwd).pipe(Effect.map((hidden) => !hidden)) + : Effect.succeed(false), + ), + Effect.flatMap((visible) => + visible + ? Effect.void + : Effect.fail( + new ProjectSearchEntriesError({ + cwd: input.cwd, + queryLength: input.query.length, + limit: input.limit, + ...projectEntriesDeniedContext(input.cwd), + }), + ), + ), + ); + const ensureProjectReadVisible = (input: { + readonly cwd: string; + readonly relativePath: string; + }) => + visiblePath(pathService.resolve(input.cwd, input.relativePath)).pipe( + Effect.flatMap((visible) => + visible + ? Effect.void + : Effect.fail( + new ProjectReadFileError({ ...input, ...projectFileDeniedContext(input) }), + ), + ), + ); + const ensureProjectWriteVisible = (input: { + readonly cwd: string; + readonly relativePath: string; + }) => + visibleMutationTarget({ cwd: input.cwd, targetPath: input.relativePath }).pipe( + Effect.flatMap((visible) => + visible + ? Effect.void + : Effect.fail( + new ProjectWriteFileError({ ...input, ...projectFileDeniedContext(input) }), + ), + ), + ); + const ensureBrowseVisible = ( + input: { + readonly cwd?: string | undefined; + readonly partialPath: string; + }, + target: WorkspaceEntries.ResolvedBrowseTarget, + ) => + visiblePath(target.parentPath).pipe( + Effect.flatMap((visible) => + visible + ? hasHiddenDescendant(target.parentPath).pipe(Effect.map((hidden) => !hidden)) + : Effect.succeed(false), + ), + Effect.flatMap((visible) => + visible + ? Effect.void + : Effect.fail( + new FilesystemBrowseError({ + ...input, + failure: "read_directory_failed", + parentPath: target.parentPath, + }), + ), + ), + ); + const gitAudienceDenied = (input: { + readonly operation: string; + readonly command: string; + readonly cwd: string; + }) => + new GitCommandError({ + ...input, + detail: "Repository was not found.", + }); + const vcsAudienceDenied = (input: { readonly operation: string; readonly cwd: string }) => + new VcsRepositoryDetectionError({ + ...input, + detail: "Repository was not found.", + }); + const ensureGitCwdVisible = (input: { + readonly operation: string; + readonly command: string; + readonly cwd: string; + }) => + visiblePath(input.cwd).pipe( + Effect.flatMap((visible) => + visible + ? withFilesystemGuardServices( + ProjectFilesystemAudienceGuard.hasHiddenDescendantAtGitRepositoryRootForCurrentAudience( + input.cwd, + ), + ).pipe( + Effect.catchCause((cause) => + Effect.logWarning("git repository audience guard failed closed", { + cwd: input.cwd, + cause, + }).pipe(Effect.as(true)), + ), + Effect.map((hidden) => !hidden), + ) + : Effect.succeed(false), + ), + Effect.flatMap((visible) => + visible ? Effect.void : Effect.fail(gitAudienceDenied(input)), + ), + ); + const ensureGitPreparePullRequestVisible = (input: { + readonly cwd: string; + readonly mode: "local" | "worktree"; + }) => + ensureGitCwdVisible({ + operation: "preparePullRequestThread", + command: "git fetch", + cwd: input.cwd, + }).pipe( + Effect.andThen( + currentSession.audienceCeiling === "factory" && input.mode === "worktree" + ? Effect.fail( + gitAudienceDenied({ + operation: "preparePullRequestThread", + command: "git worktree add", + cwd: input.cwd, + }), + ) + : Effect.void, + ), + ); + const visibleMutationTarget = (input: { + readonly cwd: string; + readonly targetPath: string; + }): Effect.Effect => + currentSession.audienceCeiling === "private" + ? Effect.succeed(true) + : withFilesystemGuardServices( + ProjectFilesystemAudienceGuard.isMutationTargetVisibleToCurrentAudience(input), + ).pipe( + Effect.catchCause((cause) => + Effect.logWarning("filesystem mutation audience guard failed closed", { + input, + cause, + }).pipe(Effect.as(false)), + ), + ); + const ensureGitMutationTargetVisible = (input: { + readonly operation: string; + readonly command: string; + readonly cwd: string; + readonly targetPath: string | null; + }) => { + if (input.targetPath === null) return Effect.void; + const targetPath = input.targetPath; + return visibleMutationTarget({ cwd: input.cwd, targetPath }).pipe( + Effect.flatMap((visible) => + visible ? Effect.void : Effect.fail(gitAudienceDenied(input)), + ), + ); + }; + const ensureVcsInitTargetVisible = (cwd: string) => + visibleMutationTarget({ cwd, targetPath: "." }).pipe( + Effect.flatMap((visible) => + visible ? Effect.void : Effect.fail(vcsAudienceDenied({ operation: "init", cwd })), + ), + ); + const ensureAssetResourceVisible = Effect.fn("ws.ensureAssetResourceVisible")(function* ( + resource: AssetResource, + ) { + switch (resource._tag) { + case "workspace-file": { + const thread = yield* withAuthenticatedPrincipal( + projectionSnapshotQuery.getThreadShellById(resource.threadId), + ); + if (Option.isNone(thread)) { + return yield* new AssetWorkspaceContextNotFoundError({ resource }); + } + const project = yield* withAuthenticatedPrincipal( + projectionSnapshotQuery.getProjectShellById(thread.value.projectId), + ); + if (Option.isNone(project)) { + return yield* new AssetWorkspaceContextNotFoundError({ resource }); + } + return { + dataAudience: strictestDataAudience( + thread.value.dataAudience, + project.value.dataAudience, + ), + workspaceRoot: thread.value.worktreePath ?? project.value.workspaceRoot, + }; + } + case "attachment": { + const dataAudience = yield* withAuthenticatedPrincipal( + classifyAttachmentAudience(resource.attachmentId), + ); + if (dataAudience === null) { + if (currentSession.audienceCeiling === "private") { + return { dataAudience: "private" as const }; + } + return yield* new AssetWorkspaceContextNotFoundError({ resource }); + } + return { dataAudience }; + } + case "project-favicon": { + const project = yield* withAuthenticatedPrincipal( + projectionSnapshotQuery.getActiveProjectByWorkspaceRoot(resource.cwd), + ); + if (Option.isNone(project)) { + return yield* new AssetWorkspaceContextNotFoundError({ resource }); + } + return { dataAudience: project.value.dataAudience }; + } + } + }); + const toDispatchCommandError = (cause: unknown, fallbackMessage: string) => isOrchestrationDispatchCommandError(cause) ? cause @@ -1584,16 +1886,20 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => [WS_METHODS.projectsSearchEntries]: (input) => observeRpcEffect( WS_METHODS.projectsSearchEntries, - workspaceEntries.search(input).pipe( - Effect.mapError( - (cause) => - new ProjectSearchEntriesError({ - cwd: input.cwd, - queryLength: input.query.length, - limit: input.limit, - ...projectEntriesFailureContext(cause), - cause, - }), + ensureProjectSearchVisible(input).pipe( + Effect.andThen( + workspaceEntries.search(input).pipe( + Effect.mapError( + (cause) => + new ProjectSearchEntriesError({ + cwd: input.cwd, + queryLength: input.query.length, + limit: input.limit, + ...projectEntriesFailureContext(cause), + cause, + }), + ), + ), ), ), { "rpc.aggregate": "workspace" }, @@ -1601,14 +1907,18 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => [WS_METHODS.projectsListEntries]: (input) => observeRpcEffect( WS_METHODS.projectsListEntries, - workspaceEntries.list(input).pipe( - Effect.mapError( - (cause) => - new ProjectListEntriesError({ - ...input, - ...projectEntriesFailureContext(cause), - cause, - }), + ensureProjectEntriesVisible(input.cwd).pipe( + Effect.andThen( + workspaceEntries.list(input).pipe( + Effect.mapError( + (cause) => + new ProjectListEntriesError({ + ...input, + ...projectEntriesFailureContext(cause), + cause, + }), + ), + ), ), ), { "rpc.aggregate": "workspace" }, @@ -1616,14 +1926,18 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => [WS_METHODS.projectsReadFile]: (input) => observeRpcEffect( WS_METHODS.projectsReadFile, - workspaceFileSystem.readFile(input).pipe( - Effect.mapError( - (cause) => - new ProjectReadFileError({ - ...input, - ...projectFileFailureContext(cause), - cause, - }), + ensureProjectReadVisible(input).pipe( + Effect.andThen( + workspaceFileSystem.readFile(input).pipe( + Effect.mapError( + (cause) => + new ProjectReadFileError({ + ...input, + ...projectFileFailureContext(cause), + cause, + }), + ), + ), ), ), { "rpc.aggregate": "workspace" }, @@ -1631,15 +1945,19 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => [WS_METHODS.projectsWriteFile]: (input) => observeRpcEffect( WS_METHODS.projectsWriteFile, - workspaceFileSystem.writeFile(input).pipe( - Effect.mapError( - (cause) => - new ProjectWriteFileError({ - cwd: input.cwd, - relativePath: input.relativePath, - ...projectFileFailureContext(cause), - cause, - }), + ensureProjectWriteVisible(input).pipe( + Effect.andThen( + withAuthenticatedPrincipal(workspaceFileSystem.writeFile(input)).pipe( + Effect.mapError( + (cause) => + new ProjectWriteFileError({ + cwd: input.cwd, + relativePath: input.relativePath, + ...projectFileFailureContext(cause), + cause, + }), + ), + ), ), ), { "rpc.aggregate": "workspace" }, @@ -1651,7 +1969,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => [WS_METHODS.filesystemBrowse]: (input) => observeRpcEffect( WS_METHODS.filesystemBrowse, - workspaceEntries.browse(input).pipe( + WorkspaceEntries.resolveBrowseTarget(input).pipe( + Effect.provideService(Path.Path, pathService), Effect.mapError( (cause) => new FilesystemBrowseError({ @@ -1660,61 +1979,69 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => cause, }), ), + Effect.flatMap((target) => + ensureBrowseVisible(input, target).pipe( + Effect.andThen( + workspaceEntries.browseResolved(input, target).pipe( + Effect.mapError( + (cause) => + new FilesystemBrowseError({ + ...input, + ...filesystemBrowseFailureContext(cause), + cause, + }), + ), + ), + ), + ), + ), ), { "rpc.aggregate": "workspace" }, ), [WS_METHODS.assetsCreateUrl]: (input) => observeRpcEffect( WS_METHODS.assetsCreateUrl, - Effect.gen(function* () { - if (input.resource._tag !== "workspace-file") { - return yield* issueAssetUrl({ resource: input.resource }); - } - const thread = yield* projectionSnapshotQuery - .getThreadShellById(input.resource.threadId) - .pipe( - Effect.mapError( - (cause) => - new AssetWorkspaceContextResolutionError({ - resource: input.resource, - cause, - }), - ), - ); - if (Option.isNone(thread)) { - return yield* new AssetWorkspaceContextNotFoundError({ - resource: input.resource, - }); - } - const project = yield* projectionSnapshotQuery - .getProjectShellById(thread.value.projectId) - .pipe( - Effect.mapError( - (cause) => - new AssetWorkspaceContextResolutionError({ - resource: input.resource, - cause, - }), - ), - ); - if (Option.isNone(project)) { - return yield* new AssetWorkspaceContextNotFoundError({ - resource: input.resource, - }); - } - return yield* issueAssetUrl({ - resource: input.resource, - workspaceRoot: thread.value.worktreePath ?? project.value.workspaceRoot, - }); - }), + ensureAssetResourceVisible(input.resource).pipe( + Effect.mapError((cause) => + cause._tag === "AssetWorkspaceContextNotFoundError" + ? cause + : new AssetWorkspaceContextResolutionError({ + resource: input.resource, + cause, + }), + ), + Effect.flatMap((context) => + withFilesystemGuardServices( + issueAssetUrl({ + resource: input.resource, + ...(context.workspaceRoot ? { workspaceRoot: context.workspaceRoot } : {}), + dataAudience: context.dataAudience, + audienceCeiling: currentSession.audienceCeiling, + ...(input.capabilities ? { clientCapabilities: input.capabilities } : {}), + surfaceSessionId: currentSession.sessionId, + ...(currentSession.expiresAt + ? { surfaceSessionExpiresAt: currentSession.expiresAt } + : {}), + }), + ), + ), + ), { "rpc.aggregate": "workspace" }, ), [WS_METHODS.subscribeVcsStatus]: (input) => - observeRpcStream( + observeRpcStreamEffect( WS_METHODS.subscribeVcsStatus, - vcsStatusBroadcaster.streamStatus(input, { - automaticRemoteRefreshInterval: automaticGitFetchInterval, - }), + ensureGitCwdVisible({ + operation: "status", + command: "git status", + cwd: input.cwd, + }).pipe( + Effect.as( + vcsStatusBroadcaster.streamStatus(input, { + automaticRemoteRefreshInterval: automaticGitFetchInterval, + }), + ), + ), { "rpc.aggregate": "vcs", }, @@ -1722,7 +2049,11 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => [WS_METHODS.vcsRefreshStatus]: (input) => observeRpcEffect( WS_METHODS.vcsRefreshStatus, - vcsStatusBroadcaster.refreshStatus(input.cwd), + ensureGitCwdVisible({ + operation: "status", + command: "git status", + cwd: input.cwd, + }).pipe(Effect.andThen(vcsStatusBroadcaster.refreshStatus(input.cwd))), { "rpc.aggregate": "vcs", }, @@ -1730,7 +2061,12 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => [WS_METHODS.vcsPull]: (input) => observeRpcEffect( WS_METHODS.vcsPull, - gitWorkflow.pullCurrentBranch(input.cwd).pipe( + ensureGitCwdVisible({ + operation: "pull", + command: "git pull --ff-only", + cwd: input.cwd, + }).pipe( + Effect.andThen(gitWorkflow.pullCurrentBranch(input.cwd)), Effect.matchCauseEffect({ onFailure: (cause) => Effect.failCause(cause), onSuccess: (result) => @@ -1743,29 +2079,38 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => observeRpcStream( WS_METHODS.gitRunStackedAction, Stream.callback((queue) => - gitWorkflow - .runStackedAction(input, { - actionId: input.actionId, - progressReporter: { - publish: (event) => Queue.offer(queue, event).pipe(Effect.asVoid), - }, - }) - .pipe( - Effect.matchCauseEffect({ - onFailure: (cause) => Queue.failCause(queue, cause), - onSuccess: () => - refreshGitStatus(input.cwd).pipe( - Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)), - ), + ensureGitCwdVisible({ + operation: "runStackedAction", + command: "git status", + cwd: input.cwd, + }).pipe( + Effect.andThen( + gitWorkflow.runStackedAction(input, { + actionId: input.actionId, + progressReporter: { + publish: (event) => Queue.offer(queue, event).pipe(Effect.asVoid), + }, }), ), + Effect.matchCauseEffect({ + onFailure: (cause) => Queue.failCause(queue, cause), + onSuccess: () => + refreshGitStatus(input.cwd).pipe( + Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)), + ), + }), + ), ), { "rpc.aggregate": "vcs" }, ), [WS_METHODS.gitResolvePullRequest]: (input) => observeRpcEffect( WS_METHODS.gitResolvePullRequest, - gitWorkflow.resolvePullRequest(input), + ensureGitCwdVisible({ + operation: "resolvePullRequest", + command: "git remote get-url origin", + cwd: input.cwd, + }).pipe(Effect.andThen(gitWorkflow.resolvePullRequest(input))), { "rpc.aggregate": "git", }, @@ -1773,51 +2118,138 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => [WS_METHODS.gitPreparePullRequestThread]: (input) => observeRpcEffect( WS_METHODS.gitPreparePullRequestThread, - gitWorkflow - .preparePullRequestThread(input) - .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + ensureGitPreparePullRequestVisible(input).pipe( + Effect.andThen( + gitWorkflow + .preparePullRequestThread(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + ), + ), { "rpc.aggregate": "git" }, ), [WS_METHODS.vcsListRefs]: (input) => - observeRpcEffect(WS_METHODS.vcsListRefs, gitWorkflow.listRefs(input), { - "rpc.aggregate": "vcs", - }), + observeRpcEffect( + WS_METHODS.vcsListRefs, + ensureGitCwdVisible({ + operation: "listRefs", + command: "git branch", + cwd: input.cwd, + }).pipe(Effect.andThen(gitWorkflow.listRefs(input))), + { + "rpc.aggregate": "vcs", + }, + ), [WS_METHODS.vcsCreateWorktree]: (input) => observeRpcEffect( WS_METHODS.vcsCreateWorktree, - gitWorkflow.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + Effect.succeed({ + ...input, + path: resolveCreateWorktreePath({ + ...input, + worktreesDir: config.worktreesDir, + pathService, + }), + }).pipe( + Effect.flatMap((resolvedInput) => + ensureGitCwdVisible({ + operation: "createWorktree", + command: "git worktree add", + cwd: resolvedInput.cwd, + }).pipe( + Effect.andThen( + ensureGitMutationTargetVisible({ + operation: "createWorktree", + command: "git worktree add", + cwd: resolvedInput.cwd, + targetPath: resolvedInput.path, + }), + ), + Effect.andThen( + gitWorkflow + .createWorktree(resolvedInput) + .pipe(Effect.tap(() => refreshGitStatus(resolvedInput.cwd))), + ), + ), + ), + ), { "rpc.aggregate": "vcs" }, ), [WS_METHODS.vcsRemoveWorktree]: (input) => observeRpcEffect( WS_METHODS.vcsRemoveWorktree, - gitWorkflow.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + ensureGitCwdVisible({ + operation: "removeWorktree", + command: "git worktree remove", + cwd: input.cwd, + }).pipe( + Effect.andThen( + ensureGitMutationTargetVisible({ + operation: "removeWorktree", + command: "git worktree remove", + cwd: input.cwd, + targetPath: input.path, + }), + ), + Effect.andThen( + gitWorkflow + .removeWorktree(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + ), + ), { "rpc.aggregate": "vcs" }, ), [WS_METHODS.vcsCreateRef]: (input) => observeRpcEffect( WS_METHODS.vcsCreateRef, - gitWorkflow.createRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + ensureGitCwdVisible({ + operation: "createRef", + command: "git branch", + cwd: input.cwd, + }).pipe( + Effect.andThen( + gitWorkflow.createRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + ), + ), { "rpc.aggregate": "vcs" }, ), [WS_METHODS.vcsSwitchRef]: (input) => observeRpcEffect( WS_METHODS.vcsSwitchRef, - gitWorkflow.switchRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + ensureGitCwdVisible({ + operation: "switchRef", + command: "git checkout", + cwd: input.cwd, + }).pipe( + Effect.andThen( + gitWorkflow.switchRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + ), + ), { "rpc.aggregate": "vcs" }, ), [WS_METHODS.vcsInit]: (input) => observeRpcEffect( WS_METHODS.vcsInit, - vcsProvisioning - .initRepository(input) - .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + ensureVcsInitTargetVisible(input.cwd).pipe( + Effect.andThen( + vcsProvisioning + .initRepository(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + ), + ), { "rpc.aggregate": "vcs" }, ), [WS_METHODS.reviewGetDiffPreview]: (input) => - observeRpcEffect(WS_METHODS.reviewGetDiffPreview, review.getDiffPreview(input), { - "rpc.aggregate": "review", - }), + observeRpcEffect( + WS_METHODS.reviewGetDiffPreview, + ensureGitCwdVisible({ + operation: "getDiffPreview", + command: "git diff", + cwd: input.cwd, + }).pipe(Effect.andThen(review.getDiffPreview(input))), + { + "rpc.aggregate": "review", + }, + ), [WS_METHODS.terminalOpen]: (input) => observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { "rpc.aggregate": "terminal", diff --git a/apps/web/src/assets/assetUrls.test.ts b/apps/web/src/assets/assetUrls.test.ts index e4634f5b98d..547b6515057 100644 --- a/apps/web/src/assets/assetUrls.test.ts +++ b/apps/web/src/assets/assetUrls.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { resolveAssetUrl } from "./assetUrls"; +import { + resolveAssetUrl, + resolveBoundAssetUrl, + resolveBrowserAssetSurfaceBaseUrl, +} from "./assetUrls"; describe("resolveAssetUrl", () => { it("resolves an environment-relative asset URL", () => { @@ -12,4 +16,35 @@ describe("resolveAssetUrl", () => { it("rejects an invalid environment base URL", () => { expect(resolveAssetUrl("not a URL", "/api/assets/signed-token/favicon.png")).toBeNull(); }); + + it("keeps credential-less assets on the environment origin", async () => { + await expect( + resolveBoundAssetUrl("https://environment.example/", "t3code://app/", { + relativeUrl: "/api/assets/signed-token/report.html", + expiresAt: Date.now() + 60_000, + surfaceCredential: null, + }), + ).resolves.toBe("https://environment.example/api/assets/signed-token/report.html"); + }); + + it("keeps old-server asset results on the environment origin", async () => { + await expect( + resolveBoundAssetUrl("https://environment.example/", "t3code://app/", { + relativeUrl: "/api/assets/signed-token/legacy-report.html", + expiresAt: Date.now() + 60_000, + }), + ).resolves.toBe("https://environment.example/api/assets/signed-token/legacy-report.html"); + }); + + it("uses the app origin for surface-bound relay URLs", () => { + expect( + resolveBrowserAssetSurfaceBaseUrl("https://private-app.example/settings?tab=connections"), + ).toBe("https://private-app.example/"); + expect(resolveBrowserAssetSurfaceBaseUrl("t3code://app/settings")).toBe("t3code://app/"); + }); + + it("rejects non-origin browser locations", () => { + expect(resolveBrowserAssetSurfaceBaseUrl("not a URL")).toBeNull(); + expect(resolveBrowserAssetSurfaceBaseUrl("file:///tmp/index.html")).toBeNull(); + }); }); diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index 673b093e333..c6b8bffbb91 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -1,26 +1,130 @@ import { useAtomValue } from "@effect/atom-react"; import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; -import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; +import { bindAssetSurface } from "@t3tools/client-runtime/state/assets"; +import type { AssetCreateUrlResult, AssetResource, EnvironmentId } from "@t3tools/contracts"; import { AsyncResult } from "effect/unstable/reactivity"; -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { assetEnvironment } from "~/state/assets"; import { usePreparedConnection } from "~/state/session"; export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +const surfaceBindingPromises = new Map>(); +const SURFACE_BIND_RETRY_INITIAL_MS = 1_000; +const SURFACE_BIND_RETRY_MAX_MS = 30_000; + +export function resolveBrowserAssetSurfaceBaseUrl(href: string): string | null { + try { + const url = new URL(href); + if (!url.host) return null; + return `${url.protocol}//${url.host}/`; + } catch { + return null; + } +} + +export function resolveBoundAssetUrl( + httpBaseUrl: string, + surfaceBaseUrl: string | null, + asset: AssetCreateUrlResult, +): Promise { + if (asset.surfaceCredential === null || asset.surfaceCredential === undefined) { + return Promise.resolve(resolveAssetUrl(httpBaseUrl, asset.relativeUrl)); + } + if (surfaceBaseUrl === null) return Promise.resolve(null); + const key = JSON.stringify([surfaceBaseUrl, asset.surfaceCredential, asset.relativeUrl]); + const existing = surfaceBindingPromises.get(key); + if (existing !== undefined) return existing; + const binding = bindAssetSurface(surfaceBaseUrl, asset); + surfaceBindingPromises.set(key, binding); + void binding.then(() => { + if (surfaceBindingPromises.get(key) === binding) surfaceBindingPromises.delete(key); + }); + return binding; +} + +function useBoundAssetUrls( + httpBaseUrl: string | null, + surfaceBaseUrl: string | null, + assets: ReadonlyArray, +): ReadonlyArray { + const key = JSON.stringify([httpBaseUrl, surfaceBaseUrl, assets]); + const immediate = useMemo( + () => + assets.map((asset) => + asset !== null && + (asset.surfaceCredential === null || asset.surfaceCredential === undefined) && + httpBaseUrl !== null + ? resolveAssetUrl(httpBaseUrl, asset.relativeUrl) + : null, + ), + [assets, httpBaseUrl], + ); + const [bound, setBound] = useState<{ + readonly key: string; + readonly urls: ReadonlyArray; + } | null>(null); + useEffect(() => { + let active = true; + let retryTimer: ReturnType | undefined; + let retryDelay = SURFACE_BIND_RETRY_INITIAL_MS; + if (httpBaseUrl === null) { + setBound({ key, urls: assets.map(() => null) }); + return () => { + active = false; + }; + } + const bind = async () => { + const urls = await Promise.all( + assets.map((asset) => + asset === null + ? Promise.resolve(null) + : resolveBoundAssetUrl(httpBaseUrl, surfaceBaseUrl, asset), + ), + ); + if (!active) return; + setBound({ key, urls }); + const privateBindingFailed = assets.some( + (asset, index) => + asset !== null && + asset.surfaceCredential !== null && + asset.surfaceCredential !== undefined && + urls[index] === null, + ); + if (privateBindingFailed) { + retryTimer = setTimeout(() => { + retryDelay = Math.min(retryDelay * 2, SURFACE_BIND_RETRY_MAX_MS); + void bind(); + }, retryDelay); + } + }; + void bind(); + return () => { + active = false; + if (retryTimer !== undefined) clearTimeout(retryTimer); + }; + }, [assets, httpBaseUrl, key, surfaceBaseUrl]); + return bound?.key === key ? bound.urls : immediate; +} + export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null { const preparedConnection = usePreparedConnection(environmentId); + const surfaceBaseUrl = + typeof window === "undefined" ? null : resolveBrowserAssetSurfaceBaseUrl(window.location.href); const result = useAtomValue( assetEnvironment.createUrl({ environmentId, input: { resource }, }), ); - if (preparedConnection._tag === "None" || result._tag !== "Success") { - return null; - } - return resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); + const assets = useMemo(() => [result._tag === "Success" ? result.value : null], [result]); + const urls = useBoundAssetUrls( + preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, + surfaceBaseUrl, + assets, + ); + return urls[0] ?? null; } export function useAssetUrls( @@ -28,21 +132,21 @@ export function useAssetUrls( resources: ReadonlyArray, ): ReadonlyArray { const preparedConnection = usePreparedConnection(environmentId); + const surfaceBaseUrl = + typeof window === "undefined" ? null : resolveBrowserAssetSurfaceBaseUrl(window.location.href); const results = useAtomValue( assetEnvironment.createUrls({ environmentId, resources, }), ); - return useMemo( - () => - preparedConnection._tag === "None" - ? resources.map(() => null) - : results.map((result) => - AsyncResult.isSuccess(result) - ? resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl) - : null, - ), - [preparedConnection, resources, results], + const assets = useMemo( + () => results.map((result) => (AsyncResult.isSuccess(result) ? result.value : null)), + [results], + ); + return useBoundAssetUrls( + preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, + surfaceBaseUrl, + assets, ); } diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index b89b87c9289..0bdacc1d458 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -14,13 +14,13 @@ import * as Cause from "effect/Cause"; import * as Data from "effect/Data"; import { AsyncResult } from "effect/unstable/reactivity"; -import { resolveAssetUrl } from "~/assets/assetUrls"; import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime, rememberPreviewUrl, } from "~/previewStateStore"; import { useRightPanelStore } from "~/rightPanelStore"; +import { resolveBoundAssetUrl, resolveBrowserAssetSurfaceBaseUrl } from "~/assets/assetUrls"; export const isBrowserPreviewFile = (path: string): boolean => /\.(?:html?|pdf)$/i.test(path.split(/[?#]/, 1)[0] ?? ""); @@ -84,7 +84,8 @@ export async function openFileInPreview(input: { if (assetResult._tag === "Failure") { return AsyncResult.failure(assetResult.cause); } - const assetUrl = resolveAssetUrl(input.httpBaseUrl, assetResult.value.relativeUrl); + const surfaceBaseUrl = resolveBrowserAssetSurfaceBaseUrl(window.location.href); + const assetUrl = await resolveBoundAssetUrl(input.httpBaseUrl, surfaceBaseUrl, assetResult.value); if (assetUrl === null) { return AsyncResult.failure( Cause.die(new Error("The environment returned an invalid asset URL.")), diff --git a/dseg-s8-asset-filesystem-guard.plan.md b/dseg-s8-asset-filesystem-guard.plan.md new file mode 100644 index 00000000000..23bce91bf40 --- /dev/null +++ b/dseg-s8-asset-filesystem-guard.plan.md @@ -0,0 +1,16 @@ +Intent: bind asset and filesystem/VCS access to the owning project's `dataAudience`, while preserving unrestricted private-session behavior. The smallest useful slice is a path-to-project guard around asset issuance/resolution, project file/list/search/browse RPCs, and the VCS RPCs already exposed from `ws.ts`. + +Blast radius: `apps/server/src/assets/AssetAccess.ts` and tests; `apps/server/src/ws.ts` project file, filesystem browse, asset URL, and VCS handlers; `apps/server/src/auth/audienceScopePolicy.ts` only for read methods that become guarded here; `apps/server/src/server.test.ts` focused WebSocket/HTTP coverage; contract schemas only if an existing structured error cannot express existence-masked denial. + +Edge-case matrix and red-first tests: + +- `asset-token-private-presented-in-factory`: a token minted for a private attachment/workspace/favicon must resolve to null under a factory ceiling, matching an absent/tampered asset URL. +- `asset-token-old-or-missing-audience`: a legacy v1 token without audience must fail closed for factory and continue for private only when ownership can still be classified. +- `file-rpc-cross-audience-read-write-list-search-browse`: factory-ceiling file/list/search/browse/read/write requests for a private project path return the same structured failure shape as a missing root/path; the same operations succeed for a factory project. +- `vcs-cross-audience-read-write`: factory-ceiling VCS status/listRefs/pull/worktree/ref/init requests against a private project cwd are denied before the VCS service is called, while factory-project calls reach the service. +- `path-traversal-and-absolute-escape`: `..`, absolute paths, and cwd/partial-path combinations cannot classify a private/outside target as factory. +- `symlink-escape`: a symlink inside a factory project to a private/outside file is rejected after realpath resolution for project file reads and asset serving. +- `unknown-owner-fail-closed`: a cwd/attachment/workspace root that cannot be mapped to one projected project is denied in a factory context and treated like nonexistent. +- `same-timestamp-duplicates-replay-version-skew`: no new persistence ordering is introduced; duplicate/replayed URLs rely on signed claims plus live project classification, and unknown/old claim fields fail closed for factory. + +Smallest-change argument: add local classification helpers that resolve real paths and compare them against projected project roots/worktree paths, then wrap only the asset/filesystem/VCS call sites. Do not change read-query/event/command dispatch surfaces, capability RPC policy beyond the guarded read methods, or provider/terminal sandbox behavior. diff --git a/packages/client-runtime/src/state/assets.test.ts b/packages/client-runtime/src/state/assets.test.ts index 58add31d6bb..9f947eb8cbe 100644 --- a/packages/client-runtime/src/state/assets.test.ts +++ b/packages/client-runtime/src/state/assets.test.ts @@ -5,11 +5,107 @@ import { Atom } from "effect/unstable/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; import { + bindAssetSurface, createAssetEnvironmentAtoms, InvalidAssetCollectionKeyError, parseAssetCollectionKey, } from "./assets.ts"; +describe("asset surface binding", () => { + it("installs a private surface credential on the app relay origin", async () => { + const requests: Array<{ readonly input: string; readonly init: RequestInit | undefined }> = []; + const fetchImpl: typeof fetch = async (input, init) => { + requests.push({ input: String(input), init }); + return new Response(null, { status: 204 }); + }; + + await expect( + bindAssetSurface( + "https://private-app.example/base/", + { + relativeUrl: "/api/assets/relay/signed/private.png", + expiresAt: 123, + surfaceCredential: "surface.credential", + }, + fetchImpl, + ), + ).resolves.toBe("https://private-app.example/api/assets/relay/signed/private.png"); + expect(requests).toEqual([ + { + input: "https://private-app.example/api/assets/relay/surface", + init: { + method: "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ credential: "surface.credential" }), + }, + }, + ]); + }); + + it("refuses to bind private credentials to the direct cross-site asset path", async () => { + let fetched = false; + const fetchImpl: typeof fetch = async () => { + fetched = true; + return new Response(null, { status: 204 }); + }; + + await expect( + bindAssetSurface( + "https://environment.example/", + { + relativeUrl: "/api/assets/signed/private.png", + expiresAt: 123, + surfaceCredential: "surface.credential", + }, + fetchImpl, + ), + ).resolves.toBeNull(); + expect(fetched).toBe(false); + }); + + it("keeps public factory asset URLs cookie-free", async () => { + let fetched = false; + const fetchImpl: typeof fetch = async () => { + fetched = true; + return new Response(null, { status: 204 }); + }; + + await expect( + bindAssetSurface( + "https://environment.example/", + { + relativeUrl: "/api/assets/signed/factory.png", + expiresAt: 123, + surfaceCredential: null, + }, + fetchImpl, + ), + ).resolves.toBe("https://environment.example/api/assets/signed/factory.png"); + expect(fetched).toBe(false); + }); + + it("accepts an unbound URL decoded from an old server result", async () => { + let fetched = false; + const fetchImpl: typeof fetch = async () => { + fetched = true; + return new Response(null, { status: 204 }); + }; + + await expect( + bindAssetSurface( + "https://environment.example/", + { + relativeUrl: "/api/assets/signed/legacy.png", + expiresAt: 123, + }, + fetchImpl, + ), + ).resolves.toBe("https://environment.example/api/assets/signed/legacy.png"); + expect(fetched).toBe(false); + }); +}); + describe("asset collection keys", () => { it("preserves malformed JSON and its native cause", () => { const key = "not-json"; diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts index e407f5d0028..99e20ea0b77 100644 --- a/packages/client-runtime/src/state/assets.ts +++ b/packages/client-runtime/src/state/assets.ts @@ -1,4 +1,10 @@ -import { AssetResource, EnvironmentId, WS_METHODS } from "@t3tools/contracts"; +import { + ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY, + AssetResource, + EnvironmentId, + WS_METHODS, + type AssetCreateUrlResult, +} from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { Atom } from "effect/unstable/reactivity"; @@ -8,6 +14,8 @@ import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000; const ASSET_URL_STALE_TIME_MS = 5 * 60_000; const ASSET_URL_IDLE_TTL_MS = 60 * 60_000; +const ASSET_SURFACE_RELAY_PREFIX = "/api/assets/relay/"; +const ASSET_SURFACE_BIND_PATH = `${ASSET_SURFACE_RELAY_PREFIX}surface`; export class InvalidAssetCollectionKeyError extends Schema.TaggedErrorClass()( "InvalidAssetCollectionKeyError", @@ -43,16 +51,61 @@ export function resolveAssetUrl(httpBaseUrl: string, relativeUrl: string): strin } } +export async function bindAssetSurface( + httpBaseUrl: string, + asset: AssetCreateUrlResult, + fetchImpl: typeof fetch = globalThis.fetch, +): Promise { + const assetUrl = resolveAssetUrl(httpBaseUrl, asset.relativeUrl); + if ( + assetUrl === null || + asset.surfaceCredential === null || + asset.surfaceCredential === undefined + ) { + return assetUrl; + } + if (!asset.relativeUrl.startsWith(ASSET_SURFACE_RELAY_PREFIX)) return null; + + let bindingUrl: string; + try { + bindingUrl = new URL(ASSET_SURFACE_BIND_PATH, httpBaseUrl).toString(); + } catch { + return null; + } + try { + const response = await fetchImpl(bindingUrl, { + method: "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ credential: asset.surfaceCredential }), + }); + return response.ok ? assetUrl : null; + } catch { + return null; + } +} + export function createAssetEnvironmentAtoms( runtime: Atom.AtomRuntime, ) { - const createUrl = createEnvironmentRpcQueryAtomFamily(runtime, { + const createUrlQuery = createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:assets:create-url", tag: WS_METHODS.assetsCreateUrl, staleTimeMs: ASSET_URL_STALE_TIME_MS, idleTtlMs: ASSET_URL_IDLE_TTL_MS, refreshIntervalMs: ASSET_URL_REFRESH_INTERVAL_MS, }); + const createUrl = (target: { + readonly environmentId: EnvironmentId; + readonly input: { readonly resource: AssetResource }; + }) => + createUrlQuery({ + environmentId: target.environmentId, + input: { + resource: target.input.resource, + capabilities: [ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY], + }, + }); const createUrlsFamily = Atom.family((key: string) => { const [environmentId, resources] = parseAssetCollectionKey(key); return Atom.make((get) => diff --git a/packages/contracts/src/assets.test.ts b/packages/contracts/src/assets.test.ts new file mode 100644 index 00000000000..dadbae2868c --- /dev/null +++ b/packages/contracts/src/assets.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; + +import { + ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY, + AssetCreateUrlInput, + AssetCreateUrlResult, + AssetResource, +} from "./assets.ts"; + +const LegacyAssetCreateUrlInput = Schema.Struct({ resource: AssetResource }); +const LegacyAssetCreateUrlResult = Schema.Struct({ + relativeUrl: Schema.String, + expiresAt: Schema.Number, +}); +const decodeAssetCreateUrlInput = Schema.decodeUnknownSync(AssetCreateUrlInput); +const decodeAssetCreateUrlResult = Schema.decodeUnknownSync(AssetCreateUrlResult); +const decodeLegacyAssetCreateUrlInput = Schema.decodeUnknownSync(LegacyAssetCreateUrlInput); +const decodeLegacyAssetCreateUrlResult = Schema.decodeUnknownSync(LegacyAssetCreateUrlResult); + +describe("asset protocol compatibility", () => { + const resource = { + _tag: "attachment" as const, + attachmentId: "attachment-1", + }; + + it("decodes old client requests without advertised capabilities", () => { + expect(decodeAssetCreateUrlInput({ resource })).toEqual({ resource }); + }); + + it("decodes the same-origin relay capability advertised by new clients", () => { + expect( + decodeAssetCreateUrlInput({ + resource, + capabilities: [ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY], + }), + ).toEqual({ + resource, + capabilities: [ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY], + }); + }); + + it("lets an old server decode a new client request", () => { + expect( + decodeLegacyAssetCreateUrlInput({ + resource, + capabilities: [ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY], + }), + ).toEqual({ resource }); + }); + + it("decodes old server results without a surface credential", () => { + const oldServerResult = { + relativeUrl: "/api/assets/signed/attachment.png", + expiresAt: 123, + }; + + expect(decodeAssetCreateUrlResult(oldServerResult)).toEqual(oldServerResult); + }); + + it("lets an old client decode a new server result", () => { + expect( + decodeLegacyAssetCreateUrlResult({ + relativeUrl: "/api/assets/relay/signed/attachment.png", + expiresAt: 123, + surfaceCredential: "surface.credential", + }), + ).toEqual({ + relativeUrl: "/api/assets/relay/signed/attachment.png", + expiresAt: 123, + }); + }); +}); diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts index 0dbe7d9fa25..d587805ba73 100644 --- a/packages/contracts/src/assets.ts +++ b/packages/contracts/src/assets.ts @@ -3,6 +3,15 @@ import * as Schema from "effect/Schema"; import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; const ASSET_PATH_MAX_LENGTH = 1024; +const ASSET_CLIENT_CAPABILITY_MAX_LENGTH = 64; +const ASSET_CLIENT_CAPABILITIES_MAX_LENGTH = 16; + +export const ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY = "same-origin-relay-v1" as const; + +export const AssetClientCapability = TrimmedNonEmptyString.check( + Schema.isMaxLength(ASSET_CLIENT_CAPABILITY_MAX_LENGTH), +); +export type AssetClientCapability = typeof AssetClientCapability.Type; export const AssetResource = Schema.Union([ Schema.TaggedStruct("workspace-file", { @@ -20,15 +29,35 @@ export type AssetResource = typeof AssetResource.Type; export const AssetCreateUrlInput = Schema.Struct({ resource: AssetResource, + capabilities: Schema.optionalKey( + Schema.Array(AssetClientCapability).check( + Schema.isMaxLength(ASSET_CLIENT_CAPABILITIES_MAX_LENGTH), + ), + ), }); export type AssetCreateUrlInput = typeof AssetCreateUrlInput.Type; export const AssetCreateUrlResult = Schema.Struct({ relativeUrl: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)), expiresAt: Schema.Number, + surfaceCredential: Schema.optionalKey( + Schema.NullOr(TrimmedNonEmptyString.check(Schema.isMaxLength(4096))), + ), }); export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type; +export class AssetClientUpgradeRequiredError extends Schema.TaggedErrorClass()( + "AssetClientUpgradeRequiredError", + { + resource: AssetResource, + requiredCapability: Schema.Literal(ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY), + }, +) { + override get message(): string { + return "Upgrade the client to load private assets."; + } +} + export class AssetWorkspaceContextNotFoundError extends Schema.TaggedErrorClass()( "AssetWorkspaceContextNotFoundError", { @@ -181,6 +210,7 @@ export class AssetSigningKeyLoadError extends Schema.TaggedErrorClass