From 3c3961f2420d7f978f445a16d082287556fb6578 Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:32:48 +0200 Subject: [PATCH] fix(api): keep host folders to the deployment owner (#112) spaces.create linked any workspacePath to the new project, and computer.files and computer.readFile then listed and read files under that folder for anyone in the space. On a deployment with signups, any member could link / or the API's own checkout and read whatever the API process can read, such as its .env file. Linking also ran git in the chosen folder and stored its remote URL. A linked folder lives on the host, and the host is the deployment owner's machine, the rule the onboarding scan already follows: - spaces.create refuses a folder from anyone but the deployment owner, before the space is created or the folder is touched. - Project files are listed and read only for the deployment owner, so a space a member linked before this change stays closed too. Space navigation no longer shows a linked folder's path and git remote to other members. - A linked folder is stored as its real path, and listing and reading follow symlinks only while they stay inside it. Symlinks that leave the folder or lead nowhere are left out of a listing instead of failing it. - The web New project dialog offers the folder field only to the deployment owner. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EyQw3AEaHYXegvgvTPAsi1 --- apps/api/src/router.ts | 24 ++--- apps/web/e2e/spaces.spec.ts | 25 +++++ apps/web/src/pages/Shell.tsx | 1 + apps/web/src/pages/shell/dialogs.tsx | 8 +- .../src/project-workspace-files.test.ts | 60 ++++++++++++ .../adapters/src/project-workspace-files.ts | 50 +++++++--- packages/db/src/project-workspace.test.ts | 20 +++- packages/db/src/project-workspace.ts | 14 +-- packages/testkit/src/authorization.test.ts | 96 ++++++++++++++++++- 9 files changed, 258 insertions(+), 40 deletions(-) create mode 100644 packages/adapters/src/project-workspace-files.test.ts diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 2baf21e..963a533 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -569,6 +569,10 @@ export function createRouter(deps: RouterDeps) { spaceNavigationDto(deps, context.actor, repos, groupRepos), ), create: authed.spaces.create.handler(async ({ context, input }) => { + // A linked folder lives on the host, and the host is the deployment owner's machine. + if (input.workspacePath && !context.actor.isDeploymentOwner) { + throw new ORPCError("FORBIDDEN"); + } let space: { id: string; name: string }; try { space = await createSpaceForMember(deps.prisma, { @@ -2230,10 +2234,7 @@ export function createRouter(deps: RouterDeps) { } } if (isProjectWorkspacePath(storedPath)) { - const workspaceRoot = await requireProjectWorkspaceRoot( - deps.prisma, - context.actor.spaceId, - ); + const workspaceRoot = await requireProjectWorkspaceRoot(deps.prisma, context.actor); const entries = await listProjectWorkspaceFiles(workspaceRoot, storedPath); return entries.map((entry) => ({ ...entry, @@ -2281,10 +2282,7 @@ export function createRouter(deps: RouterDeps) { } } if (isProjectWorkspacePath(storedPath)) { - const workspaceRoot = await requireProjectWorkspaceRoot( - deps.prisma, - context.actor.spaceId, - ); + const workspaceRoot = await requireProjectWorkspaceRoot(deps.prisma, context.actor); const bytes = await readProjectWorkspaceFile(workspaceRoot, storedPath, { maxBytes: MAX_COMPUTER_TEXT_FILE_BYTES, }); @@ -4894,10 +4892,13 @@ function mapUpdaterError(error: unknown): never { async function requireProjectWorkspaceRoot( prisma: Pick, - spaceId: string, + actor: Actor, ): Promise { + // Only the deployment owner may link a host folder, so only they may browse one. This also + // closes folders that other members linked before linking was limited to the owner. + if (!actor.isDeploymentOwner) throw new ORPCError("FORBIDDEN"); const space = await prisma.space.findUnique({ - where: { id: spaceId }, + where: { id: actor.spaceId }, select: { workspacePath: true }, }); if (!space?.workspacePath) { @@ -5009,7 +5010,8 @@ async function spaceNavigationDto( memberships.length > 1 && !spacesWithContent.has(membership.spaceId) && (membership.space.deletingAt === null || membership.space.deletingAt < staleClaimBefore), - workspace: projectWorkspaceDto(membership.space), + // A linked folder is on the owner's host; other members see neither its path nor remote. + workspace: actor.isDeploymentOwner ? projectWorkspaceDto(membership.space) : null, bots: spaceBots.map((bot) => ({ id: bot.id, spaceId: bot.spaceId, diff --git a/apps/web/e2e/spaces.spec.ts b/apps/web/e2e/spaces.spec.ts index d3b1dc6..7492809 100644 --- a/apps/web/e2e/spaces.spec.ts +++ b/apps/web/e2e/spaces.spec.ts @@ -3,6 +3,7 @@ import { captureScreenshot, completeOnboarding, openNewSpace, + routePatchRpc, rpc, signup, unterhaltungenOnly, @@ -45,6 +46,30 @@ test("Projekte + opens new project dialog instead of group form", async ({ page await expect(page.getByTestId("project-start-main")).toHaveText(/Starten/); }); +for (const owner of [true, false]) { + const role = owner ? "owner" : "member"; + const title = owner + ? "new project dialog offers a host folder to the deployment owner" + : "new project dialog offers no host folder to a member"; + test(title, async ({ page }, testInfo) => { + await signup(page, `proj-folder-${role}-${Date.now()}@rakazo.test`, "password12", "Folder"); + await completeOnboarding(page); + // Whoever signs up first owns the deployment, which depends on spec order. + await routePatchRpc(page, "bootstrap", (json) => ({ + ...json, + me: { ...(json.me as Record), isDeploymentOwner: owner }, + })); + await page.goto("/app"); + await page.waitForURL(/\/app\/[^/]+$/); + + await page.locator(".sidebar-projekte").getByTestId("create-project-trigger").click(); + const dialog = page.getByRole("dialog", { name: "New project" }); + await expect(dialog.getByLabel("Name")).toBeVisible(); + await expect(dialog.getByLabel("Project folder")).toHaveCount(owner ? 1 : 0); + await captureScreenshot(page, testInfo, `new-project-${role}`); + }); +} + test("spaces stay invisible by default and chat creation requires approval", async ({ page, }, testInfo) => { diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index f50bf3b..1708a55 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -5482,6 +5482,7 @@ export function ShellPage() { {newSpaceOpen ? ( setNewSpaceOpen(false)} onConfirm={async ({ name, workspacePath }) => { const space = await rpc.spaces.create({ diff --git a/apps/web/src/pages/shell/dialogs.tsx b/apps/web/src/pages/shell/dialogs.tsx index b094ce1..3542790 100644 --- a/apps/web/src/pages/shell/dialogs.tsx +++ b/apps/web/src/pages/shell/dialogs.tsx @@ -38,10 +38,13 @@ export type NewSpaceConfirmInput = { export function NewSpaceDialog({ variant = "space", + canLinkFolder, onCancel, onConfirm, }: { variant?: "space" | "project"; + /** Folders live on the host, which only the deployment owner may link. */ + canLinkFolder: boolean; onCancel: () => void; onConfirm: (input: NewSpaceConfirmInput) => Promise; }) { @@ -53,6 +56,7 @@ export function NewSpaceDialog({ const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const isProject = variant === "project"; + const showFolder = isProject && canLinkFolder; const desktop = desktopBridge(); const trimmedWorkspacePath = workspacePath.trim(); @@ -63,7 +67,7 @@ export function NewSpaceDialog({ setError(null); void onConfirm({ name: trimmed, - ...(isProject && trimmedWorkspacePath ? { workspacePath: trimmedWorkspacePath } : {}), + ...(showFolder && trimmedWorkspacePath ? { workspacePath: trimmedWorkspacePath } : {}), }).catch((reason: unknown) => { setError( reason instanceof Error @@ -104,7 +108,7 @@ export function NewSpaceDialog({ className="mt-2" /> - {isProject ? ( + {showFolder ? (