From 44189feb2e8298c31c423ee69b3002617791d64b Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:02:32 +0100 Subject: [PATCH 1/7] docs: plan dseg slice 8 asset filesystem guard --- dseg-s8-asset-filesystem-guard.plan.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 dseg-s8-asset-filesystem-guard.plan.md 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. From 001b002424bef8d84e21c5c36513088b8d0c9408 Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:58:09 +0100 Subject: [PATCH 2/7] feat: guard project assets and files by audience --- apps/server/src/assets/AssetAccess.test.ts | 27 + apps/server/src/assets/AssetAccess.ts | 37 +- apps/server/src/http.ts | 7 + .../ProjectFilesystemAudienceGuard.test.ts | 274 ++++++++ .../project/ProjectFilesystemAudienceGuard.ts | 161 +++++ apps/server/src/server.test.ts | 53 ++ .../src/workspace/WorkspaceFileSystem.test.ts | 54 ++ .../src/workspace/WorkspaceFileSystem.ts | 367 ++++++++++- apps/server/src/ws.ts | 616 ++++++++++++++---- 9 files changed, 1450 insertions(+), 146 deletions(-) create mode 100644 apps/server/src/project/ProjectFilesystemAudienceGuard.test.ts create mode 100644 apps/server/src/project/ProjectFilesystemAudienceGuard.ts diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 06a22754e55..611602f2746 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -202,6 +202,33 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("rejects private-context attachment URLs under a factory resolver 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 result = yield* issueAssetUrl({ + resource: { _tag: "attachment", attachmentId }, + dataAudience: "private", + audienceCeiling: "private", + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + const token = suffix.slice(0, separatorIndex); + + expect(yield* resolveAsset(token, "ignored.png", { audienceCeiling: "factory" })).toBeNull(); + expect(yield* resolveAsset(token, "ignored.png", { audienceCeiling: "private" })).toEqual({ + kind: "file", + path: attachmentPath, + }); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("issues project favicon capabilities with a signed fallback", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index b469e0e315b..b6c6faf3fdd 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -1,6 +1,6 @@ -import type { AssetResource } from "@t3tools/contracts"; import { AssetAttachmentNotFoundError, + AuthAudienceCeiling, AssetPreviewTypeValidationError, AssetProjectFaviconInspectionError, AssetProjectFaviconNotFoundError, @@ -12,6 +12,10 @@ import { AssetWorkspacePathValidationError, AssetWorkspaceResolutionError, AssetWorkspaceRootNormalizationError, + DataAudience, + type AssetResource, + type AuthAudienceCeiling as AuthAudienceCeilingType, + type DataAudience as DataAudienceType, } from "@t3tools/contracts"; import { isWorkspaceImagePreviewPath, @@ -35,6 +39,7 @@ import { timingSafeEqualBase64Url, } from "../auth/utils.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { canReadDataAudience } from "../auth/audienceDataPolicy.ts"; import { resolveAttachmentPathById } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; @@ -56,6 +61,11 @@ const PREVIEW_ASSET_EXTENSIONS = new Set([ ".woff2", ]); +const AudienceBoundAssetClaimFields = { + dataAudience: Schema.optional(DataAudience), + audienceCeiling: Schema.optional(AuthAudienceCeiling), +}; + const AssetClaimsSchema = Schema.Union([ Schema.Struct({ version: Schema.Literal(1), @@ -63,6 +73,7 @@ const AssetClaimsSchema = Schema.Union([ workspaceRoot: Schema.String, baseRelativePath: Schema.String, expiresAt: Schema.Number, + ...AudienceBoundAssetClaimFields, }), Schema.Struct({ version: Schema.Literal(1), @@ -70,12 +81,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 +96,7 @@ const AssetClaimsSchema = Schema.Union([ workspaceRoot: Schema.String, relativePath: Schema.NullOr(Schema.String), expiresAt: Schema.Number, + ...AudienceBoundAssetClaimFields, }), ]); type AssetClaims = typeof AssetClaimsSchema.Type; @@ -165,11 +179,17 @@ const resolveCanonicalWorkspaceFileForRequest = (input: { export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: { readonly resource: AssetResource; readonly workspaceRoot?: string; + readonly dataAudience?: DataAudienceType; + readonly audienceCeiling?: AuthAudienceCeilingType; }) { 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 audienceClaims = { + dataAudience: input.dataAudience ?? ("private" as const), + audienceCeiling: input.audienceCeiling ?? ("private" as const), + }; let claims: AssetClaims; let fileName: string; @@ -241,6 +261,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i workspaceRoot: canonicalWorkspaceRoot, relativePath: resolved.relativePath, expiresAt, + ...audienceClaims, } : { version: 1, @@ -248,6 +269,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i workspaceRoot: canonicalWorkspaceRoot, baseRelativePath: path.dirname(resolved.relativePath), expiresAt, + ...audienceClaims, }; fileName = path.basename(resolved.relativePath); break; @@ -268,6 +290,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i kind: "attachment", attachmentId: input.resource.attachmentId, expiresAt, + ...audienceClaims, }; fileName = path.basename(attachmentPath); break; @@ -323,6 +346,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i ), relativePath, expiresAt, + ...audienceClaims, }; fileName = relativePath ? path.basename(relativePath) : PROJECT_FAVICON_FALLBACK_MARKER; break; @@ -350,6 +374,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( token: string, relativePath: string, + options?: { readonly audienceCeiling?: AuthAudienceCeilingType }, ) { const [encodedPayload, signature] = token.split("."); if (!encodedPayload || !signature) return null; @@ -365,6 +390,16 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( const claims = decodeClaims(encodedPayload); if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null; + const requestedAudienceCeiling = options?.audienceCeiling ?? ("private" as const); + const claimsAudienceCeiling = claims.audienceCeiling ?? ("private" as const); + const claimsDataAudience = claims.dataAudience ?? ("private" as const); + if ( + claimsAudienceCeiling !== requestedAudienceCeiling || + !canReadDataAudience(requestedAudienceCeiling, claimsDataAudience) + ) { + return null; + } + if (claims.kind === "attachment") { const config = yield* ServerConfig.ServerConfig; const attachmentPath = resolveAttachmentPathById({ diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 90222521591..ce397ace66e 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -537,9 +537,16 @@ export const assetRouteLayer = HttpRouter.add( return HttpServerResponse.text("Not Found", { status: 404 }); } + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const session = yield* serverAuth.authenticateHttpRequest(request).pipe(Effect.option); + if (Option.isNone(session)) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + const asset = yield* resolveAsset( suffix.slice(0, separatorIndex), suffix.slice(separatorIndex + 1), + { audienceCeiling: session.value.audienceCeiling }, ); if (!asset) { return HttpServerResponse.text("Not Found", { status: 404 }); diff --git a/apps/server/src/project/ProjectFilesystemAudienceGuard.test.ts b/apps/server/src/project/ProjectFilesystemAudienceGuard.test.ts new file mode 100644 index 00000000000..4df8f2c4455 --- /dev/null +++ b/apps/server/src/project/ProjectFilesystemAudienceGuard.test.ts @@ -0,0 +1,274 @@ +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 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 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 => ({ + 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, + 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("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}/`, + }), + ), + factoryBrowseTarget: runAsFactory( + ProjectFilesystemAudienceGuard.isBrowseTargetVisibleToCurrentAudience({ + partialPath: `${factoryRoot}/`, + }), + ), + 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, + factoryBrowseTarget: 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); + }), + ); +}); diff --git a/apps/server/src/project/ProjectFilesystemAudienceGuard.ts b/apps/server/src/project/ProjectFilesystemAudienceGuard.ts new file mode 100644 index 00000000000..e04701207e4 --- /dev/null +++ b/apps/server/src/project/ProjectFilesystemAudienceGuard.ts @@ -0,0 +1,161 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeOS from "node:os"; + +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; +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 } from "../auth/audienceDataPolicy.ts"; +import { expandHomePath } from "../pathExpansion.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; + +function resolveHomeAwarePath(input: string): string { + const trimmed = input.trim(); + return trimmed.length === 0 ? NodeOS.homedir() : expandHomePath(trimmed); +} + +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 candidates = [ + ...snapshot.projects.map((project) => ({ + path: project.workspaceRoot, + dataAudience: project.dataAudience, + })), + ...snapshot.threads.flatMap((thread) => + thread.worktreePath === null + ? [] + : [{ path: thread.worktreePath, dataAudience: thread.dataAudience }], + ), + ]; + const roots: Array<{ readonly path: string; readonly dataAudience: "factory" | "private" }> = + []; + for (const candidate of candidates) { + const realRoot = yield* realPathOption(fileSystem, candidate.path); + if (Option.isSome(realRoot) && !roots.some((root) => root.path === realRoot.value)) { + roots.push({ path: realRoot.value, dataAudience: candidate.dataAudience }); + } + } + return roots.sort((left, right) => right.path.length - left.path.length); + }, +); + +const classifyExistingPathAudience = Effect.fn( + "ProjectFilesystemAudienceGuard.classifyExistingPathAudience", +)(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 isPathVisibleToCurrentAudience = Effect.fn( + "ProjectFilesystemAudienceGuard.isPathVisibleToCurrentAudience", +)(function* (candidatePath: string) { + const audienceCeiling = yield* currentReadAudienceCeiling; + if (audienceCeiling === "private") return true; + + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const realCandidate = yield* realPathOption( + fileSystem, + path.resolve(expandHomePath(candidatePath)), + ); + if (Option.isNone(realCandidate)) return false; + + return (yield* classifyExistingPathAudience(realCandidate.value)) === "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* classifyExistingPathAudience(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 isBrowseTargetVisibleToCurrentAudience = Effect.fn( + "ProjectFilesystemAudienceGuard.isBrowseTargetVisibleToCurrentAudience", +)(function* (input: { readonly cwd?: string | undefined; readonly partialPath: string }) { + const audienceCeiling = yield* currentReadAudienceCeiling; + if (audienceCeiling === "private") return true; + + const platform = yield* HostProcessPlatform; + const path = yield* Path.Path; + const partialPath = input.partialPath.trim(); + if (platform !== "win32" && isWindowsAbsolutePath(partialPath)) { + return false; + } + + let resolvedInputPath: string; + if (!isExplicitRelativePath(partialPath)) { + resolvedInputPath = path.resolve(resolveHomeAwarePath(partialPath)); + } else { + if (!input.cwd) return false; + resolvedInputPath = path.resolve(resolveHomeAwarePath(input.cwd), partialPath); + } + + const endsWithSeparator = + partialPath.length === 0 || /[\\/]$/.test(partialPath) || partialPath === "~"; + const parentPath = endsWithSeparator ? resolvedInputPath : path.dirname(resolvedInputPath); + return yield* isPathVisibleToCurrentAudience(parentPath); +}); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 5f2f0b74c0d..e47163c9403 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -6279,6 +6279,59 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("masks private signed asset URLs under a factory audience token", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* buildAppUnderTest(); + const attachmentId = "thread-private-http-assets-00000000-0000-4000-8000-000000000001"; + yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(config.attachmentsDir, `${attachmentId}.bin`), + "private 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)), + ).pipe(Layer.provideMerge(NodeServices.layer)); + const issuedAssetUrl = yield* AssetAccess.issueAssetUrl({ + resource: { _tag: "attachment", attachmentId }, + dataAudience: "private", + audienceCeiling: "private", + }).pipe(Effect.provide(assetSetupLayer)); + + const unauthenticatedResponse = yield* fetchEffect( + yield* getHttpServerUrl(issuedAssetUrl.relativeUrl), + ); + assert.equal(unauthenticatedResponse.status, 404); + + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const unrestrictedResponse = yield* fetchEffect( + yield* getHttpServerUrl(issuedAssetUrl.relativeUrl), + { headers: { cookie: ownerCookie } }, + ); + assert.equal(unrestrictedResponse.status, 200); + assert.equal(yield* unrestrictedResponse.text, "private attachment"); + + const { response: tokenResponse, body: tokenBody } = yield* exchangeAccessToken( + defaultDesktopBootstrapToken, + { audienceCeiling: "factory", scope: "orchestration:read" }, + ); + assert.equal(tokenResponse.status, 200); + assert.isDefined(tokenBody.access_token); + + const factoryResponse = yield* fetchEffect( + yield* getHttpServerUrl(issuedAssetUrl.relativeUrl), + { headers: { authorization: `Bearer ${tokenBody.access_token ?? ""}` } }, + ); + assert.equal(factoryResponse.status, 404); + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + it.effect("routes websocket rpc projects.writeFile", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; 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..254521cdee6 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,8 +131,8 @@ 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; @@ -259,6 +263,16 @@ export const make = Effect.gen(function* () { ); }); + const realPathInsideWorkspace = (realWorkspaceRoot: string, candidatePath: string) => { + const relativeRealPath = path.relative(realWorkspaceRoot, candidatePath); + return ( + relativeRealPath === "" || + (relativeRealPath !== ".." && + !relativeRealPath.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativeRealPath)) + ); + }; + const writeFile: WorkspaceFileSystem["Service"]["writeFile"] = Effect.fn( "WorkspaceFileSystem.writeFile", )(function* (input) { @@ -267,32 +281,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..4f7e2256b3c 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"; @@ -91,6 +96,7 @@ 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 { parseThreadSegmentFromAttachmentId } from "./attachmentStore.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; @@ -100,6 +106,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"; @@ -439,6 +446,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 +541,284 @@ 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, + ), + ); + 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 visibleBrowseTarget = (input: { + readonly cwd?: string | undefined; + readonly partialPath: string; + }): Effect.Effect => + currentSession.audienceCeiling === "private" + ? Effect.succeed(true) + : withFilesystemGuardServices( + ProjectFilesystemAudienceGuard.isBrowseTargetVisibleToCurrentAudience(input), + ).pipe( + Effect.catchCause((cause) => + Effect.logWarning("filesystem browse audience guard failed closed", { + input, + 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; + }) => { + const parentPath = input.cwd ?? input.partialPath; + return visibleBrowseTarget(input).pipe( + Effect.flatMap((visible) => + visible + ? hasHiddenDescendant(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, + }), + ), + ), + ); + }; + 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 + ? hasHiddenDescendant(input.cwd).pipe(Effect.map((hidden) => !hidden)) + : Effect.succeed(false), + ), + Effect.flatMap((visible) => + visible ? Effect.void : Effect.fail(gitAudienceDenied(input)), + ), + ); + 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, cwd: targetPath })), + ), + ); + }; + 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: thread.value.dataAudience, + workspaceRoot: thread.value.worktreePath ?? project.value.workspaceRoot, + }; + } + case "attachment": { + const threadSegment = parseThreadSegmentFromAttachmentId(resource.attachmentId); + if (!threadSegment) { + if (currentSession.audienceCeiling === "private") { + return { dataAudience: "private" as const }; + } + return yield* new AssetWorkspaceContextNotFoundError({ resource }); + } + const thread = yield* withAuthenticatedPrincipal( + projectionSnapshotQuery.getThreadShellByIdIncludingArchived( + ThreadId.make(threadSegment), + ), + ); + if (Option.isNone(thread)) { + if (currentSession.audienceCeiling === "private") { + return { dataAudience: "private" as const }; + } + return yield* new AssetWorkspaceContextNotFoundError({ resource }); + } + return { dataAudience: thread.value.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 +1871,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 +1892,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 +1911,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 +1930,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,14 +1954,18 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => [WS_METHODS.filesystemBrowse]: (input) => observeRpcEffect( WS_METHODS.filesystemBrowse, - workspaceEntries.browse(input).pipe( - Effect.mapError( - (cause) => - new FilesystemBrowseError({ - ...input, - ...filesystemBrowseFailureContext(cause), - cause, - }), + ensureBrowseVisible(input).pipe( + Effect.andThen( + workspaceEntries.browse(input).pipe( + Effect.mapError( + (cause) => + new FilesystemBrowseError({ + ...input, + ...filesystemBrowseFailureContext(cause), + cause, + }), + ), + ), ), ), { "rpc.aggregate": "workspace" }, @@ -1666,55 +1973,40 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => [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({ + ensureAssetResourceVisible(input.resource).pipe( + Effect.mapError((cause) => + cause._tag === "AssetWorkspaceContextNotFoundError" + ? cause + : new AssetWorkspaceContextResolutionError({ + resource: input.resource, + cause, + }), + ), + Effect.flatMap((context) => + issueAssetUrl({ resource: input.resource, - }); - } - return yield* issueAssetUrl({ - resource: input.resource, - workspaceRoot: thread.value.worktreePath ?? project.value.workspaceRoot, - }); - }), + ...(context.workspaceRoot ? { workspaceRoot: context.workspaceRoot } : {}), + dataAudience: context.dataAudience, + audienceCeiling: currentSession.audienceCeiling, + }), + ), + ), { "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 +2014,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 +2026,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 +2044,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,45 +2083,117 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => [WS_METHODS.gitPreparePullRequestThread]: (input) => observeRpcEffect( WS_METHODS.gitPreparePullRequestThread, - gitWorkflow - .preparePullRequestThread(input) - .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + ensureGitCwdVisible({ + operation: "preparePullRequestThread", + command: "git fetch", + cwd: input.cwd, + }).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))), + ensureGitCwdVisible({ + operation: "createWorktree", + command: "git worktree add", + cwd: input.cwd, + }).pipe( + Effect.andThen( + ensureGitMutationTargetVisible({ + operation: "createWorktree", + command: "git worktree add", + cwd: input.cwd, + targetPath: input.path, + }), + ), + Effect.andThen( + gitWorkflow + .createWorktree(input) + .pipe(Effect.tap(() => refreshGitStatus(input.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) => From 57967c9b7cb87fb4f964145867895369f7065de6 Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:12:28 +0100 Subject: [PATCH 3/7] test: authenticate asset characterization probe --- apps/server/src/server.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index e47163c9403..0c20cbd3fb8 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -2184,6 +2184,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { const response = yield* fetchEffect( yield* getHttpServerUrl(issuedAssetUrl.relativeUrl), + { headers: { cookie: ownerCookie } }, ); if (response.status === 401 || response.status === 403) { return yield* new SecurityProbeDenied({ source: "http" }); From 180d36dadc322a723249f646b60c28d1564a7968 Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 03:30:40 +0100 Subject: [PATCH 4/7] fix: close asset and browse segregation gaps --- apps/server/src/assets/AssetAccess.test.ts | 21 ++- apps/server/src/assets/AssetAccess.ts | 13 +- apps/server/src/http.ts | 10 +- .../ProjectFilesystemAudienceGuard.test.ts | 17 ++- .../project/ProjectFilesystemAudienceGuard.ts | 34 +---- apps/server/src/server.test.ts | 111 ++++++++++---- apps/server/src/vcs/GitVcsDriverCore.ts | 9 +- apps/server/src/vcs/worktreePath.ts | 15 ++ apps/server/src/workspace/WorkspaceEntries.ts | 130 +++++++++-------- apps/server/src/ws.ts | 137 +++++++++++------- 10 files changed, 301 insertions(+), 196 deletions(-) create mode 100644 apps/server/src/vcs/worktreePath.ts diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 611602f2746..043118b316b 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -202,7 +202,7 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); - it.effect("rejects private-context attachment URLs under a factory resolver ceiling", () => + it.effect("rejects audience claims that exceed their issuing ceiling", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; @@ -215,14 +215,27 @@ describe("AssetAccess", () => { const result = yield* issueAssetUrl({ resource: { _tag: "attachment", attachmentId }, dataAudience: "private", - audienceCeiling: "private", + audienceCeiling: "factory", }); const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); const separatorIndex = suffix.indexOf("/"); const token = suffix.slice(0, separatorIndex); - expect(yield* resolveAsset(token, "ignored.png", { audienceCeiling: "factory" })).toBeNull(); - expect(yield* resolveAsset(token, "ignored.png", { audienceCeiling: "private" })).toEqual({ + expect(yield* resolveAsset(token, "ignored.png")).toBeNull(); + + const privateResult = yield* issueAssetUrl({ + resource: { _tag: "attachment", attachmentId }, + dataAudience: "private", + audienceCeiling: "private", + }); + const privateSuffix = privateResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const privateSeparatorIndex = privateSuffix.indexOf("/"); + expect( + yield* resolveAsset( + privateSuffix.slice(0, privateSeparatorIndex), + privateSuffix.slice(privateSeparatorIndex + 1), + ), + ).toEqual({ kind: "file", path: attachmentPath, }); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index b6c6faf3fdd..987019790a2 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -62,8 +62,8 @@ const PREVIEW_ASSET_EXTENSIONS = new Set([ ]); const AudienceBoundAssetClaimFields = { - dataAudience: Schema.optional(DataAudience), - audienceCeiling: Schema.optional(AuthAudienceCeiling), + dataAudience: DataAudience, + audienceCeiling: AuthAudienceCeiling, }; const AssetClaimsSchema = Schema.Union([ @@ -374,7 +374,6 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( token: string, relativePath: string, - options?: { readonly audienceCeiling?: AuthAudienceCeilingType }, ) { const [encodedPayload, signature] = token.split("."); if (!encodedPayload || !signature) return null; @@ -390,13 +389,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( const claims = decodeClaims(encodedPayload); if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null; - const requestedAudienceCeiling = options?.audienceCeiling ?? ("private" as const); - const claimsAudienceCeiling = claims.audienceCeiling ?? ("private" as const); - const claimsDataAudience = claims.dataAudience ?? ("private" as const); - if ( - claimsAudienceCeiling !== requestedAudienceCeiling || - !canReadDataAudience(requestedAudienceCeiling, claimsDataAudience) - ) { + if (!canReadDataAudience(claims.audienceCeiling, claims.dataAudience)) { return null; } diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index ce397ace66e..9a019436ab7 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -537,16 +537,12 @@ export const assetRouteLayer = HttpRouter.add( return HttpServerResponse.text("Not Found", { status: 404 }); } - const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; - const session = yield* serverAuth.authenticateHttpRequest(request).pipe(Effect.option); - if (Option.isNone(session)) { - return HttpServerResponse.text("Not Found", { status: 404 }); - } - + // Asset URLs intentionally self-authenticate: DOM, native image, and browser-preview loads + // cannot attach bearer/DPoP headers. Resolution validates the short-lived signature and its + // required audience binding, so a separate request session must not be required here. const asset = yield* resolveAsset( suffix.slice(0, separatorIndex), suffix.slice(separatorIndex + 1), - { audienceCeiling: session.value.audienceCeiling }, ); if (!asset) { return HttpServerResponse.text("Not Found", { status: 404 }); diff --git a/apps/server/src/project/ProjectFilesystemAudienceGuard.test.ts b/apps/server/src/project/ProjectFilesystemAudienceGuard.test.ts index 4df8f2c4455..50029695eda 100644 --- a/apps/server/src/project/ProjectFilesystemAudienceGuard.test.ts +++ b/apps/server/src/project/ProjectFilesystemAudienceGuard.test.ts @@ -214,11 +214,22 @@ describe("ProjectFilesystemAudienceGuard", () => { partialPath: `${privateRoot}/`, }), ), - factoryBrowseTarget: runAsFactory( + 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, @@ -246,7 +257,9 @@ describe("ProjectFilesystemAudienceGuard", () => { nestedPrivateProjectFile: false, symlinkEscape: false, privateBrowseTarget: false, - factoryBrowseTarget: true, + factoryBrowseTargetWithHiddenDescendant: false, + absoluteBrowseTargetIgnoresCleanFactoryCwd: false, + cleanFactoryBrowseTarget: true, factoryWorktreeDestination: true, privateWorktreeDestination: false, symlinkWorktreeDestination: false, diff --git a/apps/server/src/project/ProjectFilesystemAudienceGuard.ts b/apps/server/src/project/ProjectFilesystemAudienceGuard.ts index e04701207e4..3d28a1777c8 100644 --- a/apps/server/src/project/ProjectFilesystemAudienceGuard.ts +++ b/apps/server/src/project/ProjectFilesystemAudienceGuard.ts @@ -1,8 +1,3 @@ -// @effect-diagnostics nodeBuiltinImport:off -import * as NodeOS from "node:os"; - -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -11,11 +6,7 @@ import * as Path from "effect/Path"; import { currentReadAudienceCeiling } from "../auth/audienceDataPolicy.ts"; import { expandHomePath } from "../pathExpansion.ts"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; - -function resolveHomeAwarePath(input: string): string { - const trimmed = input.trim(); - return trimmed.length === 0 ? NodeOS.homedir() : expandHomePath(trimmed); -} +import * as WorkspaceEntries from "../workspace/WorkspaceEntries.ts"; function isWithinRoot(path: Path.Path, root: string, candidate: string): boolean { const relative = path.relative(root, candidate); @@ -139,23 +130,8 @@ export const isBrowseTargetVisibleToCurrentAudience = Effect.fn( const audienceCeiling = yield* currentReadAudienceCeiling; if (audienceCeiling === "private") return true; - const platform = yield* HostProcessPlatform; - const path = yield* Path.Path; - const partialPath = input.partialPath.trim(); - if (platform !== "win32" && isWindowsAbsolutePath(partialPath)) { - return false; - } - - let resolvedInputPath: string; - if (!isExplicitRelativePath(partialPath)) { - resolvedInputPath = path.resolve(resolveHomeAwarePath(partialPath)); - } else { - if (!input.cwd) return false; - resolvedInputPath = path.resolve(resolveHomeAwarePath(input.cwd), partialPath); - } - - const endsWithSeparator = - partialPath.length === 0 || /[\\/]$/.test(partialPath) || partialPath === "~"; - const parentPath = endsWithSeparator ? resolvedInputPath : path.dirname(resolvedInputPath); - return yield* isPathVisibleToCurrentAudience(parentPath); + 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 0c20cbd3fb8..3d79662a8ee 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -130,6 +130,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"; @@ -2184,7 +2185,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { const response = yield* fetchEffect( yield* getHttpServerUrl(issuedAssetUrl.relativeUrl), - { headers: { cookie: ownerCookie } }, ); if (response.status === 401 || response.status === 403) { return yield* new SecurityProbeDenied({ source: "http" }); @@ -6280,7 +6280,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("masks private signed asset URLs under a factory audience token", () => + it.effect("serves self-authenticating asset URLs and masks invalid audience claims", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -6304,32 +6304,72 @@ it.layer(NodeServices.layer)("server router seam", (it) => { dataAudience: "private", audienceCeiling: "private", }).pipe(Effect.provide(assetSetupLayer)); + const crossAudienceAssetUrl = yield* AssetAccess.issueAssetUrl({ + resource: { _tag: "attachment", attachmentId }, + dataAudience: "private", + audienceCeiling: "factory", + }).pipe(Effect.provide(assetSetupLayer)); + const factoryAssetUrl = yield* AssetAccess.issueAssetUrl({ + resource: { _tag: "attachment", attachmentId }, + 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 unauthenticatedResponse = yield* fetchEffect( - yield* getHttpServerUrl(issuedAssetUrl.relativeUrl), - ); - assert.equal(unauthenticatedResponse.status, 404); - - const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); - const unrestrictedResponse = yield* fetchEffect( - yield* getHttpServerUrl(issuedAssetUrl.relativeUrl), - { headers: { cookie: ownerCookie } }, + const rewriteSignedClaims = ( + relativeUrl: string, + rewrite: (claims: Record) => Record, + ): string => { + const suffix = relativeUrl.slice(`${AssetAccess.ASSET_ROUTE_PREFIX}/`.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 `${AssetAccess.ASSET_ROUTE_PREFIX}/${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", + ), + ), ); - assert.equal(unrestrictedResponse.status, 200); - assert.equal(yield* unrestrictedResponse.text, "private attachment"); + const invalidAssetUrl = issuedAssetUrl.relativeUrl.replace(/\.([^./]+)\//, ".$1x/"); - const { response: tokenResponse, body: tokenBody } = yield* exchangeAccessToken( - defaultDesktopBootstrapToken, - { audienceCeiling: "factory", scope: "orchestration:read" }, - ); - assert.equal(tokenResponse.status, 200); - assert.isDefined(tokenBody.access_token); + for (const relativeUrl of [issuedAssetUrl.relativeUrl, factoryAssetUrl.relativeUrl]) { + const unauthenticatedResponse = yield* fetchEffect(yield* getHttpServerUrl(relativeUrl)); + assert.equal(unauthenticatedResponse.status, 200); + assert.equal(yield* unauthenticatedResponse.text, "private attachment"); + } - const factoryResponse = yield* fetchEffect( - yield* getHttpServerUrl(issuedAssetUrl.relativeUrl), - { headers: { authorization: `Bearer ${tokenBody.access_token ?? ""}` } }, - ); - assert.equal(factoryResponse.status, 404); + for (const relativeUrl of [ + invalidAssetUrl, + expiredAssetUrl, + legacyAssetUrl, + crossAudienceAssetUrl.relativeUrl, + ]) { + 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"); + } }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), ); @@ -6541,7 +6581,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", }, @@ -6677,10 +6723,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 }), @@ -6800,6 +6848,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/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/ws.ts b/apps/server/src/ws.ts index 4f7e2256b3c..3398d83ce47 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -126,6 +126,7 @@ 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"; @@ -568,22 +569,6 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => }).pipe(Effect.as(false)), ), ); - const visibleBrowseTarget = (input: { - readonly cwd?: string | undefined; - readonly partialPath: string; - }): Effect.Effect => - currentSession.audienceCeiling === "private" - ? Effect.succeed(true) - : withFilesystemGuardServices( - ProjectFilesystemAudienceGuard.isBrowseTargetVisibleToCurrentAudience(input), - ).pipe( - Effect.catchCause((cause) => - Effect.logWarning("filesystem browse audience guard failed closed", { - input, - cause, - }).pipe(Effect.as(false)), - ), - ); const hasHiddenDescendant = (candidatePath: string): Effect.Effect => currentSession.audienceCeiling === "private" ? Effect.succeed(false) @@ -675,15 +660,17 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => ), ), ); - const ensureBrowseVisible = (input: { - readonly cwd?: string | undefined; - readonly partialPath: string; - }) => { - const parentPath = input.cwd ?? input.partialPath; - return visibleBrowseTarget(input).pipe( + const ensureBrowseVisible = ( + input: { + readonly cwd?: string | undefined; + readonly partialPath: string; + }, + target: WorkspaceEntries.ResolvedBrowseTarget, + ) => + visiblePath(target.parentPath).pipe( Effect.flatMap((visible) => visible - ? hasHiddenDescendant(parentPath).pipe(Effect.map((hidden) => !hidden)) + ? hasHiddenDescendant(target.parentPath).pipe(Effect.map((hidden) => !hidden)) : Effect.succeed(false), ), Effect.flatMap((visible) => @@ -693,12 +680,11 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => new FilesystemBrowseError({ ...input, failure: "read_directory_failed", - parentPath, + parentPath: target.parentPath, }), ), ), ); - }; const gitAudienceDenied = (input: { readonly operation: string; readonly command: string; @@ -728,6 +714,27 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => 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; @@ -754,7 +761,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => const targetPath = input.targetPath; return visibleMutationTarget({ cwd: input.cwd, targetPath }).pipe( Effect.flatMap((visible) => - visible ? Effect.void : Effect.fail(gitAudienceDenied({ ...input, cwd: targetPath })), + visible ? Effect.void : Effect.fail(gitAudienceDenied(input)), ), ); }; @@ -1954,16 +1961,29 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => [WS_METHODS.filesystemBrowse]: (input) => observeRpcEffect( WS_METHODS.filesystemBrowse, - ensureBrowseVisible(input).pipe( - Effect.andThen( - workspaceEntries.browse(input).pipe( - Effect.mapError( - (cause) => - new FilesystemBrowseError({ - ...input, - ...filesystemBrowseFailureContext(cause), - cause, - }), + WorkspaceEntries.resolveBrowseTarget(input).pipe( + Effect.provideService(Path.Path, pathService), + Effect.mapError( + (cause) => + new FilesystemBrowseError({ + ...input, + ...filesystemBrowseFailureContext(cause), + cause, + }), + ), + Effect.flatMap((target) => + ensureBrowseVisible(input, target).pipe( + Effect.andThen( + workspaceEntries.browseResolved(input, target).pipe( + Effect.mapError( + (cause) => + new FilesystemBrowseError({ + ...input, + ...filesystemBrowseFailureContext(cause), + cause, + }), + ), + ), ), ), ), @@ -2083,11 +2103,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => [WS_METHODS.gitPreparePullRequestThread]: (input) => observeRpcEffect( WS_METHODS.gitPreparePullRequestThread, - ensureGitCwdVisible({ - operation: "preparePullRequestThread", - command: "git fetch", - cwd: input.cwd, - }).pipe( + ensureGitPreparePullRequestVisible(input).pipe( Effect.andThen( gitWorkflow .preparePullRequestThread(input) @@ -2111,23 +2127,34 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => [WS_METHODS.vcsCreateWorktree]: (input) => observeRpcEffect( WS_METHODS.vcsCreateWorktree, - ensureGitCwdVisible({ - operation: "createWorktree", - command: "git worktree add", - cwd: input.cwd, + Effect.succeed({ + ...input, + path: resolveCreateWorktreePath({ + ...input, + worktreesDir: config.worktreesDir, + pathService, + }), }).pipe( - Effect.andThen( - ensureGitMutationTargetVisible({ + Effect.flatMap((resolvedInput) => + ensureGitCwdVisible({ operation: "createWorktree", command: "git worktree add", - cwd: input.cwd, - targetPath: input.path, - }), - ), - Effect.andThen( - gitWorkflow - .createWorktree(input) - .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + 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" }, From bee071604e64e1dd14660f3539ba6aeead687c45 Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 06:53:53 +0100 Subject: [PATCH 5/7] fix: revalidate asset audience at serve time --- apps/server/src/assets/AssetAccess.test.ts | 214 +++++++++++--- apps/server/src/assets/AssetAccess.ts | 276 +++++++++++++++--- apps/server/src/auth/audienceDataPolicy.ts | 4 + apps/server/src/http.ts | 52 +++- .../ProjectFilesystemAudienceGuard.test.ts | 92 +++++- .../project/ProjectFilesystemAudienceGuard.ts | 57 ++-- apps/server/src/server.test.ts | 212 +++++++++++++- .../src/workspace/WorkspaceFileSystem.ts | 66 ++++- apps/server/src/ws.ts | 40 ++- 9 files changed, 863 insertions(+), 150 deletions(-) diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 043118b316b..457ff590322 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ThreadId } from "@t3tools/contracts"; +import { ThreadId, type OrchestrationReadModel } from "@t3tools/contracts"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -11,8 +11,21 @@ import * as PlatformError from "effect/PlatformError"; import * as ServerSecretStore from "../auth/ServerSecretStore.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, + classifyAttachmentAudience, + issueAssetUrl, + resolveAsset, + type AssetResolution, +} from "./AssetAccess.ts"; + +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 +35,15 @@ const testLayer = Layer.mergeAll( WorkspacePaths.layer, ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer)), ServerSecretStore.layer.pipe(Layer.provide(configLayer)), + 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", () => { @@ -52,17 +74,15 @@ describe("AssetAccess", () => { 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)), ); @@ -169,15 +189,134 @@ describe("AssetAccess", () => { 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 = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + 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 = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + 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; @@ -195,10 +334,9 @@ describe("AssetAccess", () => { 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)), ); @@ -212,16 +350,12 @@ describe("AssetAccess", () => { yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3])); - const result = yield* issueAssetUrl({ + const error = yield* issueAssetUrl({ resource: { _tag: "attachment", attachmentId }, dataAudience: "private", audienceCeiling: "factory", - }); - const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); - const separatorIndex = suffix.indexOf("/"); - const token = suffix.slice(0, separatorIndex); - - expect(yield* resolveAsset(token, "ignored.png")).toBeNull(); + }).pipe(Effect.flip); + expect(error._tag).toBe("AssetWorkspaceContextNotFoundError"); const privateResult = yield* issueAssetUrl({ resource: { _tag: "attachment", attachmentId }, @@ -230,15 +364,15 @@ describe("AssetAccess", () => { }); const privateSuffix = privateResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); const privateSeparatorIndex = privateSuffix.indexOf("/"); - expect( - yield* resolveAsset( - privateSuffix.slice(0, privateSeparatorIndex), - privateSuffix.slice(privateSeparatorIndex + 1), - ), - ).toEqual({ - kind: "file", - path: attachmentPath, + const privateToken = privateSuffix.slice(0, privateSeparatorIndex); + const privateFileName = privateSuffix.slice(privateSeparatorIndex + 1); + expect(yield* resolveAsset(privateToken, privateFileName)).toEqual({ kind: "forbidden" }); + expect(yield* resolveAsset(privateToken, privateFileName, "factory")).toEqual({ + kind: "forbidden", }); + expect( + summarizeAssetResolution(yield* resolveAsset(privateToken, privateFileName, "private")), + ).toEqual({ kind: "file", path: attachmentPath }); }).pipe(Effect.provide(testLayer)), ); @@ -259,9 +393,12 @@ describe("AssetAccess", () => { const faviconSuffix = faviconResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); 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 }); @@ -276,6 +413,7 @@ describe("AssetAccess", () => { 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 987019790a2..f1ed46f6fcd 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -1,3 +1,7 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; + import { AssetAttachmentNotFoundError, AuthAudienceCeiling, @@ -9,6 +13,7 @@ import { AssetWorkspaceAssetInspectionError, AssetWorkspaceAssetNotFoundError, AssetWorkspaceContextNotFoundError, + AssetWorkspaceContextResolutionError, AssetWorkspacePathValidationError, AssetWorkspaceResolutionError, AssetWorkspaceRootNormalizationError, @@ -26,6 +31,7 @@ import { import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import * as Clock from "effect/Clock"; 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"; @@ -39,9 +45,15 @@ import { timingSafeEqualBase64Url, } from "../auth/utils.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import { canReadDataAudience } from "../auth/audienceDataPolicy.ts"; -import { resolveAttachmentPathById } from "../attachmentStore.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"; @@ -105,7 +117,128 @@ const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema); const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson); const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson); -export type ResolvedAsset = { readonly kind: "file"; readonly path: string }; +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; +}; + +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 { @@ -186,10 +319,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i const path = yield* Path.Path; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS; - const audienceClaims = { - dataAudience: input.dataAudience ?? ("private" as const), - audienceCeiling: input.audienceCeiling ?? ("private" as const), - }; + const claimedDataAudience = input.dataAudience ?? ("private" as const); + const audienceCeiling = input.audienceCeiling ?? ("private" as const); let claims: AssetClaims; let fileName: string; @@ -245,6 +376,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) => @@ -285,6 +422,18 @@ 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", @@ -316,22 +465,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", @@ -374,6 +529,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( token: string, relativePath: string, + requestAudienceCeiling: AuthAudienceCeilingType | null = null, ) { const [encodedPayload, signature] = token.split("."); if (!encodedPayload || !signature) return null; @@ -392,6 +548,38 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( if (!canReadDataAudience(claims.audienceCeiling, claims.dataAudience)) { return null; } + if (!canReadDataAudience(requestAudienceCeiling ?? "factory", claims.dataAudience)) { + return forbiddenAsset; + } + + 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; + } + if (!canReadDataAudience(requestAudienceCeiling ?? "factory", liveDataAudience)) { + return forbiddenAsset; + } + return transferToResponse(); + }), + ); + }, + ); if (claims.kind === "attachment") { const config = yield* ServerConfig.ServerConfig; @@ -400,20 +588,38 @@ 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; + } + if (!canReadDataAudience(requestAudienceCeiling ?? "factory", 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") { @@ -422,7 +628,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); @@ -435,7 +641,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(/[\\/]/); @@ -453,5 +659,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 9a019436ab7..32a32e3af2d 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -537,25 +537,55 @@ export const assetRouteLayer = HttpRouter.add( return HttpServerResponse.text("Not Found", { status: 404 }); } - // Asset URLs intentionally self-authenticate: DOM, native image, and browser-preview loads - // cannot attach bearer/DPoP headers. Resolution validates the short-lived signature and its - // required audience binding, so a separate request session must not be required here. + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const authenticatedSession = yield* serverAuth + .authenticateHttpRequest(request) + .pipe(Effect.option); + const requestAudienceCeiling = Option.match(authenticatedSession, { + onNone: () => null, + onSome: (session) => + session.scopes.includes(AuthOrchestrationReadScope) ? session.audienceCeiling : null, + }); + + // Factory assets remain short-lived bearer URLs for DOM and browser-preview loads. Private + // assets additionally require a live private read session, and all path-backed assets are + // reclassified from the canonical target before they are served. const asset = yield* resolveAsset( suffix.slice(0, separatorIndex), suffix.slice(separatorIndex + 1), + requestAudienceCeiling, ); if (!asset) { 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, + }); }), ); diff --git a/apps/server/src/project/ProjectFilesystemAudienceGuard.test.ts b/apps/server/src/project/ProjectFilesystemAudienceGuard.test.ts index 50029695eda..c3a1bbc4ed0 100644 --- a/apps/server/src/project/ProjectFilesystemAudienceGuard.test.ts +++ b/apps/server/src/project/ProjectFilesystemAudienceGuard.test.ts @@ -10,6 +10,7 @@ import { type AuthAudienceCeiling, type OrchestrationProject, type OrchestrationReadModel, + type OrchestrationThread, type OrchestrationThreadShell, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -60,7 +61,7 @@ const makeThread = (input: { readonly projectId: ProjectId; readonly dataAudience: OrchestrationThreadShell["dataAudience"]; readonly worktreePath: string | null; -}): OrchestrationThreadShell => ({ +}): OrchestrationThreadShell & Pick => ({ id: ThreadId.make(input.id), projectId: input.projectId, dataAudience: input.dataAudience, @@ -74,6 +75,7 @@ const makeThread = (input: { createdAt: now, updatedAt: now, archivedAt: null, + deletedAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, @@ -284,4 +286,92 @@ describe("ProjectFilesystemAudienceGuard", () => { 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 index 3d28a1777c8..5058631bc6a 100644 --- a/apps/server/src/project/ProjectFilesystemAudienceGuard.ts +++ b/apps/server/src/project/ProjectFilesystemAudienceGuard.ts @@ -3,7 +3,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; -import { currentReadAudienceCeiling } from "../auth/audienceDataPolicy.ts"; +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"; @@ -27,22 +27,36 @@ const classifiedProjectRoots = Effect.fn("ProjectFilesystemAudienceGuard.classif 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 = [ - ...snapshot.projects.map((project) => ({ + ...activeProjects.map((project) => ({ path: project.workspaceRoot, dataAudience: project.dataAudience, })), - ...snapshot.threads.flatMap((thread) => - thread.worktreePath === null + ...snapshot.threads.flatMap((thread) => { + const project = activeProjectById.get(thread.projectId); + return thread.deletedAt !== null || !project || thread.worktreePath === null ? [] - : [{ path: thread.worktreePath, dataAudience: thread.dataAudience }], - ), + : [ + { + path: thread.worktreePath, + dataAudience: strictestDataAudience(thread.dataAudience, project.dataAudience), + }, + ]; + }), ]; - const roots: Array<{ readonly path: string; readonly dataAudience: "factory" | "private" }> = - []; + const roots: Array<{ readonly path: string; dataAudience: "factory" | "private" }> = []; for (const candidate of candidates) { const realRoot = yield* realPathOption(fileSystem, candidate.path); - if (Option.isSome(realRoot) && !roots.some((root) => root.path === realRoot.value)) { + 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 }); } } @@ -50,8 +64,8 @@ const classifiedProjectRoots = Effect.fn("ProjectFilesystemAudienceGuard.classif }, ); -const classifyExistingPathAudience = Effect.fn( - "ProjectFilesystemAudienceGuard.classifyExistingPathAudience", +export const classifyCanonicalPathAudience = Effect.fn( + "ProjectFilesystemAudienceGuard.classifyCanonicalPathAudience", )(function* (realCandidatePath: string) { const path = yield* Path.Path; const roots = yield* classifiedProjectRoots(); @@ -59,21 +73,26 @@ const classifyExistingPathAudience = Effect.fn( return match?.dataAudience ?? null; }); -export const isPathVisibleToCurrentAudience = Effect.fn( - "ProjectFilesystemAudienceGuard.isPathVisibleToCurrentAudience", +export const classifyPathAudience = Effect.fn( + "ProjectFilesystemAudienceGuard.classifyPathAudience", )(function* (candidatePath: string) { - const audienceCeiling = yield* currentReadAudienceCeiling; - if (audienceCeiling === "private") return true; - const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const realCandidate = yield* realPathOption( fileSystem, path.resolve(expandHomePath(candidatePath)), ); - if (Option.isNone(realCandidate)) return false; + 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* classifyExistingPathAudience(realCandidate.value)) === "factory"; + return (yield* classifyPathAudience(candidatePath)) === "factory"; }); export const isMutationTargetVisibleToCurrentAudience = Effect.fn( @@ -92,7 +111,7 @@ export const isMutationTargetVisibleToCurrentAudience = Effect.fn( while (true) { const realCandidate = yield* realPathOption(fileSystem, candidatePath); if (Option.isSome(realCandidate)) { - return (yield* classifyExistingPathAudience(realCandidate.value)) === "factory"; + return (yield* classifyCanonicalPathAudience(realCandidate.value)) === "factory"; } const parentPath = path.dirname(candidatePath); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 3d79662a8ee..1bfafbae923 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -20,6 +20,7 @@ import { MessageId, NonNegativeInt, ExternalLauncherCommandNotFoundError, + type OrchestrationReadModel, type OrchestrationThreadShell, TerminalNotRunningError, type OrchestrationCommand, @@ -1918,6 +1919,9 @@ 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 issuedAssetUrl = yield* AssetAccess.issueAssetUrl({ resource: { _tag: "attachment", attachmentId }, @@ -6280,17 +6284,54 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("serves self-authenticating asset URLs and masks invalid audience claims", () => + it.effect("requires private sessions for private assets and revalidates live path audience", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const config = yield* buildAppUnderTest(); + const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-http-asset-audience-", + }); + const workspaceFile = path.join(workspaceRoot, "report.html"); + yield* fileSystem.writeFileString(workspaceFile, "factory report"); + 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( @@ -6298,19 +6339,25 @@ 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 }), ).pipe(Layer.provideMerge(NodeServices.layer)); const issuedAssetUrl = yield* AssetAccess.issueAssetUrl({ resource: { _tag: "attachment", attachmentId }, dataAudience: "private", audienceCeiling: "private", }).pipe(Effect.provide(assetSetupLayer)); - const crossAudienceAssetUrl = yield* AssetAccess.issueAssetUrl({ - resource: { _tag: "attachment", attachmentId }, - dataAudience: "private", + const factoryAssetUrl = yield* AssetAccess.issueAssetUrl({ + resource: { _tag: "attachment", attachmentId: factoryAttachmentId }, + dataAudience: "factory", audienceCeiling: "factory", }).pipe(Effect.provide(assetSetupLayer)); - const factoryAssetUrl = yield* AssetAccess.issueAssetUrl({ - resource: { _tag: "attachment", attachmentId }, + 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)); @@ -6344,19 +6391,63 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ), ); + const invalidAudienceAssetUrl = rewriteSignedClaims(issuedAssetUrl.relativeUrl, (claims) => ({ + ...claims, + audienceCeiling: "factory", + })); const invalidAssetUrl = issuedAssetUrl.relativeUrl.replace(/\.([^./]+)\//, ".$1x/"); - for (const relativeUrl of [issuedAssetUrl.relativeUrl, factoryAssetUrl.relativeUrl]) { - const unauthenticatedResponse = yield* fetchEffect(yield* getHttpServerUrl(relativeUrl)); - assert.equal(unauthenticatedResponse.status, 200); - assert.equal(yield* unauthenticatedResponse.text, "private attachment"); - } + const privateCookie = yield* getAuthenticatedSessionCookieHeader(); + 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, 403); + 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, 403); + assert.notInclude(yield* factoryAuthenticatedPrivateResponse.text, "private attachment"); + + const privateAuthenticatedResponse = yield* fetchEffect( + yield* getHttpServerUrl(issuedAssetUrl.relativeUrl), + { headers: { cookie: privateCookie } }, + ); + assert.equal(privateAuthenticatedResponse.status, 200); + assert.equal(privateAuthenticatedResponse.headers["cache-control"], "no-store"); + assert.equal(yield* privateAuthenticatedResponse.text, "private attachment"); + + 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 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, - crossAudienceAssetUrl.relativeUrl, + invalidAudienceAssetUrl, ]) { const response = yield* fetchEffect(yield* getHttpServerUrl(relativeUrl)); const body = yield* response.text; @@ -6370,6 +6461,101 @@ it.layer(NodeServices.layer)("server router seam", (it) => { 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), ); diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index 254521cdee6..c8784bff311 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -136,6 +136,16 @@ export const make = Effect.gen(function* () { 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) { @@ -184,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, @@ -217,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({ @@ -263,16 +317,6 @@ export const make = Effect.gen(function* () { ); }); - const realPathInsideWorkspace = (realWorkspaceRoot: string, candidatePath: string) => { - const relativeRealPath = path.relative(realWorkspaceRoot, candidatePath); - return ( - relativeRealPath === "" || - (relativeRealPath !== ".." && - !relativeRealPath.startsWith(`..${path.sep}`) && - !path.isAbsolute(relativeRealPath)) - ); - }; - const writeFile: WorkspaceFileSystem["Service"]["writeFile"] = Effect.fn( "WorkspaceFileSystem.writeFile", )(function* (input) { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 3398d83ce47..b3b43271fbf 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -95,8 +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 { parseThreadSegmentFromAttachmentId } from "./attachmentStore.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"; @@ -133,6 +132,7 @@ 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); @@ -789,30 +789,24 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => return yield* new AssetWorkspaceContextNotFoundError({ resource }); } return { - dataAudience: thread.value.dataAudience, + dataAudience: strictestDataAudience( + thread.value.dataAudience, + project.value.dataAudience, + ), workspaceRoot: thread.value.worktreePath ?? project.value.workspaceRoot, }; } case "attachment": { - const threadSegment = parseThreadSegmentFromAttachmentId(resource.attachmentId); - if (!threadSegment) { - if (currentSession.audienceCeiling === "private") { - return { dataAudience: "private" as const }; - } - return yield* new AssetWorkspaceContextNotFoundError({ resource }); - } - const thread = yield* withAuthenticatedPrincipal( - projectionSnapshotQuery.getThreadShellByIdIncludingArchived( - ThreadId.make(threadSegment), - ), + const dataAudience = yield* withAuthenticatedPrincipal( + classifyAttachmentAudience(resource.attachmentId), ); - if (Option.isNone(thread)) { + if (dataAudience === null) { if (currentSession.audienceCeiling === "private") { return { dataAudience: "private" as const }; } return yield* new AssetWorkspaceContextNotFoundError({ resource }); } - return { dataAudience: thread.value.dataAudience }; + return { dataAudience }; } case "project-favicon": { const project = yield* withAuthenticatedPrincipal( @@ -2003,12 +1997,14 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => }), ), Effect.flatMap((context) => - issueAssetUrl({ - resource: input.resource, - ...(context.workspaceRoot ? { workspaceRoot: context.workspaceRoot } : {}), - dataAudience: context.dataAudience, - audienceCeiling: currentSession.audienceCeiling, - }), + withFilesystemGuardServices( + issueAssetUrl({ + resource: input.resource, + ...(context.workspaceRoot ? { workspaceRoot: context.workspaceRoot } : {}), + dataAudience: context.dataAudience, + audienceCeiling: currentSession.audienceCeiling, + }), + ), ), ), { "rpc.aggregate": "workspace" }, From 59f54c34ab5b9f9fc36829f27f902b3894cdb8e4 Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 07:18:55 +0100 Subject: [PATCH 6/7] test: authenticate private asset characterization --- apps/server/src/server.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 1bfafbae923..dbdc01a0a6c 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -2189,6 +2189,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { const response = yield* fetchEffect( yield* getHttpServerUrl(issuedAssetUrl.relativeUrl), + { headers: { cookie: ownerCookie } }, ); if (response.status === 401 || response.status === 403) { return yield* new SecurityProbeDenied({ source: "http" }); From e18f301a5f7463ac3a2e34307c78e5206e680354 Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:38:20 +0100 Subject: [PATCH 7/7] fix: bind private assets to negotiated surfaces --- apps/mobile/src/components/ProjectFavicon.tsx | 25 +- .../features/files/ThreadFilesRouteScreen.tsx | 30 +- .../files/WorkspaceFileImagePreview.tsx | 26 +- .../files/WorkspaceFileWebPreview.tsx | 23 +- .../features/files/workspaceFileAssetUrl.ts | 4 +- .../src/features/threads/ThreadFeed.tsx | 17 +- apps/mobile/src/state/assets.ts | 35 ++- apps/server/src/assets/AssetAccess.test.ts | 162 +++++++++- apps/server/src/assets/AssetAccess.ts | 177 ++++++++++- apps/server/src/http.ts | 146 ++++++++- .../ProjectFilesystemAudienceGuard.test.ts | 64 ++++ .../project/ProjectFilesystemAudienceGuard.ts | 13 + apps/server/src/server.test.ts | 284 +++++++++++++++++- apps/server/src/server.ts | 2 + apps/server/src/ws.ts | 35 ++- apps/web/src/assets/assetUrls.test.ts | 37 ++- apps/web/src/assets/assetUrls.ts | 136 ++++++++- apps/web/src/browser/openFileInPreview.ts | 5 +- .../client-runtime/src/state/assets.test.ts | 96 ++++++ packages/client-runtime/src/state/assets.ts | 57 +++- packages/contracts/src/assets.test.ts | 73 +++++ packages/contracts/src/assets.ts | 30 ++ 22 files changed, 1356 insertions(+), 121 deletions(-) create mode 100644 packages/contracts/src/assets.test.ts 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 457ff590322..6f872157b60 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -1,26 +1,75 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ThreadId, type OrchestrationReadModel } 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, + ASSET_SURFACE_RELAY_PREFIX, classifyAttachmentAudience, - issueAssetUrl, - resolveAsset, + 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(); @@ -35,6 +84,10 @@ 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({ @@ -70,7 +123,7 @@ 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); @@ -185,7 +238,7 @@ 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); @@ -221,7 +274,7 @@ describe("AssetAccess", () => { }, workspaceRoot: root, }); - const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const suffix = assetRouteSuffix(result.relativeUrl); const separatorIndex = suffix.indexOf("/"); const resolution = yield* resolveAsset( suffix.slice(0, separatorIndex), @@ -268,7 +321,7 @@ describe("AssetAccess", () => { }, workspaceRoot: root, }); - const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const suffix = assetRouteSuffix(result.relativeUrl); const separatorIndex = suffix.indexOf("/"); yield* fileSystem.rename(root, retiredRoot); @@ -330,7 +383,7 @@ 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); @@ -362,20 +415,99 @@ describe("AssetAccess", () => { dataAudience: "private", audienceCeiling: "private", }); - const privateSuffix = privateResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + 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)).toEqual({ kind: "forbidden" }); - expect(yield* resolveAsset(privateToken, privateFileName, "factory")).toEqual({ - kind: "forbidden", - }); + 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)), + ); + it.effect("issues project favicon capabilities with a signed fallback", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -390,7 +522,7 @@ 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( summarizeAssetResolution( @@ -407,7 +539,7 @@ 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( diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index f1ed46f6fcd..4fd398a6674 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -3,6 +3,8 @@ import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import { + ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY, + AssetClientUpgradeRequiredError, AssetAttachmentNotFoundError, AuthAudienceCeiling, AssetPreviewTypeValidationError, @@ -10,6 +12,8 @@ import { AssetProjectFaviconNotFoundError, AssetProjectFaviconResolutionError, AssetSigningKeyLoadError, + AuthSessionId, + type AssetClientCapability, AssetWorkspaceAssetInspectionError, AssetWorkspaceAssetNotFoundError, AssetWorkspaceContextNotFoundError, @@ -30,6 +34,7 @@ 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"; @@ -45,6 +50,7 @@ import { timingSafeEqualBase64Url, } from "../auth/utils.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as SessionStore from "../auth/SessionStore.ts"; import { canReadDataAudience, strictestDataAudience } from "../auth/audienceDataPolicy.ts"; import { parseThreadSegmentFromAttachmentId, @@ -58,6 +64,20 @@ 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; @@ -76,8 +96,18 @@ const PREVIEW_ASSET_EXTENSIONS = new Set([ 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), @@ -116,6 +146,11 @@ 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"; @@ -248,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); @@ -314,11 +369,15 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i 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; @@ -398,6 +457,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i workspaceRoot: canonicalWorkspaceRoot, relativePath: resolved.relativePath, expiresAt, + surfaceBindingId: null, ...audienceClaims, } : { @@ -406,6 +466,7 @@ 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); @@ -439,6 +500,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i kind: "attachment", attachmentId: input.resource.attachmentId, expiresAt, + surfaceBindingId: null, ...audienceClaims, }; fileName = path.basename(attachmentPath); @@ -501,6 +563,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i ), relativePath, expiresAt, + surfaceBindingId: null, ...audienceClaims, }; fileName = relativePath ? path.basename(relativePath) : PROJECT_FAVICON_FALLBACK_MARKER; @@ -508,6 +571,24 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i } } + 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( @@ -518,18 +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, - requestAudienceCeiling: AuthAudienceCeilingType | null = null, + requestSurfaceCredentials: string | null | ReadonlyArray = null, ) { const [encodedPayload, signature] = token.split("."); if (!encodedPayload || !signature) return null; @@ -548,8 +694,21 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( if (!canReadDataAudience(claims.audienceCeiling, claims.dataAudience)) { return null; } - if (!canReadDataAudience(requestAudienceCeiling ?? "factory", claims.dataAudience)) { - return forbiddenAsset; + 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")( @@ -572,9 +731,6 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( if (!canReadDataAudience(claims.dataAudience, liveDataAudience)) { return forbiddenAsset; } - if (!canReadDataAudience(requestAudienceCeiling ?? "factory", liveDataAudience)) { - return forbiddenAsset; - } return transferToResponse(); }), ); @@ -614,9 +770,6 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( if (!canReadDataAudience(claims.dataAudience, liveDataAudience)) { return forbiddenAsset; } - if (!canReadDataAudience(requestAudienceCeiling ?? "factory", liveDataAudience)) { - return forbiddenAsset; - } return transferToResponse(); }), ); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 32a32e3af2d..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,31 +543,43 @@ 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 serverAuth = yield* EnvironmentAuth.EnvironmentAuth; - const authenticatedSession = yield* serverAuth - .authenticateHttpRequest(request) - .pipe(Effect.option); - const requestAudienceCeiling = Option.match(authenticatedSession, { - onNone: () => null, - onSome: (session) => - session.scopes.includes(AuthOrchestrationReadScope) ? session.audienceCeiling : null, - }); + 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 for DOM and browser-preview loads. Private - // assets additionally require a live private read session, and all path-backed assets are - // reclassified from the canonical target before they are served. + // 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), - requestAudienceCeiling, + 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 }); } if (asset.kind === "forbidden") { @@ -589,6 +613,98 @@ export const assetRouteLayer = HttpRouter.add( }), ); +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); + }), +); + export const staticAndDevRouteLayer = HttpRouter.add( "GET", "*", diff --git a/apps/server/src/project/ProjectFilesystemAudienceGuard.test.ts b/apps/server/src/project/ProjectFilesystemAudienceGuard.test.ts index c3a1bbc4ed0..5009d927b33 100644 --- a/apps/server/src/project/ProjectFilesystemAudienceGuard.test.ts +++ b/apps/server/src/project/ProjectFilesystemAudienceGuard.test.ts @@ -22,6 +22,8 @@ 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"; @@ -136,6 +138,68 @@ const makeProjectionLayer = (input: { }); 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; diff --git a/apps/server/src/project/ProjectFilesystemAudienceGuard.ts b/apps/server/src/project/ProjectFilesystemAudienceGuard.ts index 5058631bc6a..c2b913ddd1d 100644 --- a/apps/server/src/project/ProjectFilesystemAudienceGuard.ts +++ b/apps/server/src/project/ProjectFilesystemAudienceGuard.ts @@ -7,6 +7,7 @@ import { currentReadAudienceCeiling, strictestDataAudience } from "../auth/audie 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); @@ -143,6 +144,18 @@ export const hasHiddenDescendantForCurrentAudience = Effect.fn( ); }); +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 }) { diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index dbdc01a0a6c..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, @@ -1574,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]; @@ -1923,9 +1961,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => { 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"), }); @@ -2180,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, @@ -2189,7 +2235,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { const response = yield* fetchEffect( yield* getHttpServerUrl(issuedAssetUrl.relativeUrl), - { headers: { cookie: ownerCookie } }, + { headers: { cookie: assetSurfaceCookie } }, ); if (response.status === 401 || response.status === 403) { return yield* new SecurityProbeDenied({ source: "http" }); @@ -6285,7 +6331,68 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("requires private sessions for private assets and revalidates live path audience", () => + 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; @@ -6293,7 +6400,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { prefix: "t3-http-asset-audience-", }); const workspaceFile = path.join(workspaceRoot, "report.html"); - yield* fileSystem.writeFileString(workspaceFile, "factory report"); + 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"), @@ -6342,10 +6454,36 @@ it.layer(NodeServices.layer)("server router seam", (it) => { 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 }, @@ -6371,7 +6509,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { relativeUrl: string, rewrite: (claims: Record) => Record, ): string => { - const suffix = relativeUrl.slice(`${AssetAccess.ASSET_ROUTE_PREFIX}/`.length); + 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); @@ -6379,7 +6520,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.isDefined(encodedPayload); const claims = JSON.parse(base64UrlDecodeUtf8(encodedPayload)) as Record; const rewrittenPayload = base64UrlEncode(JSON.stringify(rewrite(claims))); - return `${AssetAccess.ASSET_ROUTE_PREFIX}/${rewrittenPayload}.${signPayload(rewrittenPayload, signingSecret)}/${fileName}`; + return `${routePrefix}/${rewrittenPayload}.${signPayload(rewrittenPayload, signingSecret)}/${fileName}`; }; const expiredAssetUrl = rewriteSignedClaims(issuedAssetUrl.relativeUrl, (claims) => ({ ...claims, @@ -6392,13 +6533,36 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ), ); + 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, + ); - const privateCookie = yield* getAuthenticatedSessionCookieHeader(); + 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, { @@ -6411,24 +6575,119 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const unauthenticatedPrivateResponse = yield* fetchEffect( yield* getHttpServerUrl(issuedAssetUrl.relativeUrl), ); - assert.equal(unauthenticatedPrivateResponse.status, 403); + 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, 403); + assert.equal(factoryAuthenticatedPrivateResponse.status, 404); assert.notInclude(yield* factoryAuthenticatedPrivateResponse.text, "private attachment"); const privateAuthenticatedResponse = yield* fetchEffect( yield* getHttpServerUrl(issuedAssetUrl.relativeUrl), - { headers: { cookie: privateCookie } }, + { 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), ); @@ -6436,6 +6695,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { 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" }, @@ -6448,6 +6713,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { invalidAssetUrl, expiredAssetUrl, legacyAssetUrl, + legacyUnboundPrivateUrl, invalidAudienceAssetUrl, ]) { const response = yield* fetchEffect(yield* getHttpServerUrl(relativeUrl)); 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/ws.ts b/apps/server/src/ws.ts index b3b43271fbf..336b2e7896f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -410,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; @@ -555,6 +556,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => ProjectionSnapshotQuery.ProjectionSnapshotQuery, projectionSnapshotQuery, ), + Effect.provideService(VcsDriverRegistry.VcsDriverRegistry, vcsDriverRegistry), ); const visiblePath = (candidatePath: string): Effect.Effect => currentSession.audienceCeiling === "private" @@ -707,7 +709,19 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => visiblePath(input.cwd).pipe( Effect.flatMap((visible) => visible - ? hasHiddenDescendant(input.cwd).pipe(Effect.map((hidden) => !hidden)) + ? 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) => @@ -2003,6 +2017,11 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => ...(context.workspaceRoot ? { workspaceRoot: context.workspaceRoot } : {}), dataAudience: context.dataAudience, audienceCeiling: currentSession.audienceCeiling, + ...(input.capabilities ? { clientCapabilities: input.capabilities } : {}), + surfaceSessionId: currentSession.sessionId, + ...(currentSession.expiresAt + ? { surfaceSessionExpiresAt: currentSession.expiresAt } + : {}), }), ), ), @@ -2220,9 +2239,17 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => { "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/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