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,