From a8169da11cabc5ae6f2a726e0ee59d56d4206ddb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 14:29:15 +0000 Subject: [PATCH] fix(api): check a project folder before creating the space spaces.create created the space first and only then checked the folder, so a folder that was missing or not a folder returned 400 but left the new space behind. The New project dialog shows that error, so fixing the path and submitting again created a second project with the same name. The folder is now resolved and checked right after the deployment-owner check, before the space exists, and the space stores that resolved path. wireProjectWorkspace takes the resolved folder instead of resolving it again after the space was created. The authorization test has the deployment owner submit a missing folder and a file (both 400) and then the real folder under the same name, and checks that exactly one project with that name exists. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012HG4pFTUBDpuCS2Y44TV8x --- apps/api/src/router.ts | 30 ++++++++++++---------- packages/db/src/project-workspace.test.ts | 2 +- packages/db/src/project-workspace.ts | 9 +++---- packages/testkit/src/authorization.test.ts | 16 +++++++++--- 4 files changed, 34 insertions(+), 23 deletions(-) diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 963a533..0f515aa 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -137,6 +137,7 @@ import { releaseSpaceDeletionClaim, renewSpaceDeletionClaim, requireMembership, + resolveProjectWorkspaceDirectory, SPACE_DELETION_CLAIM_TIMEOUT_MS, SpaceDeletionInProgressError, SpaceLimitError, @@ -573,6 +574,18 @@ export function createRouter(deps: RouterDeps) { if (input.workspacePath && !context.actor.isDeploymentOwner) { throw new ORPCError("FORBIDDEN"); } + // Check the folder before the space exists, so a bad path leaves no space behind. + let workspacePath: string | null = null; + if (input.workspacePath) { + try { + workspacePath = await resolveProjectWorkspaceDirectory(input.workspacePath); + } catch (error) { + if (error instanceof InvalidProjectWorkspacePathError) { + throw new ORPCError("BAD_REQUEST", { message: error.message }); + } + throw error; + } + } let space: { id: string; name: string }; try { space = await createSpaceForMember(deps.prisma, { @@ -586,20 +599,9 @@ export function createRouter(deps: RouterDeps) { } throw error; } - let workspace = null; - if (input.workspacePath) { - try { - workspace = await wireProjectWorkspace(deps.prisma, { - spaceId: space.id, - workspacePath: input.workspacePath, - }); - } catch (error) { - if (error instanceof InvalidProjectWorkspacePathError) { - throw new ORPCError("BAD_REQUEST", { message: error.message }); - } - throw error; - } - } + const workspace = workspacePath + ? await wireProjectWorkspace(deps.prisma, { spaceId: space.id, workspacePath }) + : null; return { id: space.id, name: space.name, diff --git a/packages/db/src/project-workspace.test.ts b/packages/db/src/project-workspace.test.ts index 75f0693..1a20e8c 100644 --- a/packages/db/src/project-workspace.test.ts +++ b/packages/db/src/project-workspace.test.ts @@ -72,7 +72,7 @@ describe("project workspace wiring", () => { const wired = await wireProjectWorkspace(prisma as unknown as Pick, { spaceId: "space-1", - workspacePath: root, + workspacePath: await resolveProjectWorkspaceDirectory(root), }); expect(wired.path).toBe(realpathSync(root)); expect(wired.git).toEqual({ diff --git a/packages/db/src/project-workspace.ts b/packages/db/src/project-workspace.ts index 50b3c5f..fd0c04a 100644 --- a/packages/db/src/project-workspace.ts +++ b/packages/db/src/project-workspace.ts @@ -50,18 +50,17 @@ export interface WiredProjectWorkspace { } | null; } -/** Persist a host folder and optional git metadata on a newly created space. */ +/** Persist a folder from `resolveProjectWorkspaceDirectory`, and its git metadata, on a new space. */ export async function wireProjectWorkspace( prisma: Pick, input: { spaceId: string; workspacePath: string }, ): Promise { - const resolved = await resolveProjectWorkspaceDirectory(input.workspacePath); - const git = await readGitCheckout(resolved); + const git = await readGitCheckout(input.workspacePath); await prisma.space.update({ where: { id: input.spaceId }, data: { - workspacePath: resolved, + workspacePath: input.workspacePath, gitRemoteUrl: git.present && git.remoteUrl ? git.remoteUrl : null, gitDefaultBranch: git.present && git.defaultBranch ? git.defaultBranch : null, gitHeadCommit: git.present ? git.headCommit : null, @@ -69,7 +68,7 @@ export async function wireProjectWorkspace( }); return { - path: resolved, + path: input.workspacePath, git: git.present && git.remoteUrl && git.defaultBranch ? { diff --git a/packages/testkit/src/authorization.test.ts b/packages/testkit/src/authorization.test.ts index ff9020c..ddd0059 100644 --- a/packages/testkit/src/authorization.test.ts +++ b/packages/testkit/src/authorization.test.ts @@ -2239,15 +2239,25 @@ describeWithDatabase("API authorization and resource isolation", () => { const legacyNavigation = await rpc(app, member, "spaces/list"); expect(legacyNavigation.spaces.find((space) => space.id === legacy.id)?.workspace).toBeNull(); + // A bad folder is rejected before the project exists, so fixing it and retrying leaves one. + for (const badPath of [path.join(hostFolder, "missing"), path.join(hostFolder, ".env")]) { + const rejected = await raw(app, owner, "spaces/create", { + name: "Owner folder", + workspacePath: badPath, + }); + expect(rejected.status, badPath).toBe(400); + } const linked = await rpc(app, owner, "spaces/create", { name: "Owner folder", workspacePath: hostFolder, }); expect(linked.workspace?.path).toBe(hostFolder); const ownerNavigation = await rpc(app, owner, "spaces/list"); - expect(ownerNavigation.spaces.find((space) => space.id === linked.id)?.workspace?.path).toBe( - hostFolder, - ); + expect( + ownerNavigation.spaces + .filter((space) => space.name === "Owner folder") + .map((space) => space.workspace?.path), + ).toEqual([hostFolder]); const ownerBot = await rpc( app, owner,