Compare commits

...

2 Commits

Author SHA1 Message Date
Claude
0047671458
fix(api): check scanned project folders before creating their projects
The onboarding scan resolved each repository with realpath and left the
remaining checks to wireProjectWorkspace, which ran them only after the
project had been created. wireProjectWorkspace now stores the folder it
is given, so the scan resolves and checks the folder with
resolveProjectWorkspaceDirectory first, like spaces.create, and skips a
repository whose folder cannot be linked before creating its project.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HG4pFTUBDpuCS2Y44TV8x
2026-10-03 17:46:06 +00:00
Claude
a8169da11c
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HG4pFTUBDpuCS2Y44TV8x
2026-10-03 17:46:06 +00:00
5 changed files with 38 additions and 25 deletions

View File

@ -1,13 +1,14 @@
// Onboarding scan — real filesystem scan, Git analysis, and profile compilation. // Onboarding scan — real filesystem scan, Git analysis, and profile compilation.
// Runs synchronously on the host machine (no sandbox needed). // Runs synchronously on the host machine (no sandbox needed).
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { access, readdir, realpath, stat } from "node:fs/promises"; import { access, readdir, stat } from "node:fs/promises";
import { basename, dirname, join } from "node:path"; import { basename, dirname, join } from "node:path";
import type { Actor } from "@rakazo/contracts"; import type { Actor } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db"; import type { PrismaClient } from "@rakazo/db";
import { import {
createSpaceForMember, createSpaceForMember,
InvalidSpaceNameError, InvalidSpaceNameError,
resolveProjectWorkspaceDirectory,
SpaceLimitError, SpaceLimitError,
wireProjectWorkspace, wireProjectWorkspace,
} from "@rakazo/db"; } from "@rakazo/db";
@ -656,7 +657,8 @@ async function createProjects(
for (const repo of repos) { for (const repo of repos) {
// Compare and link the real folder, so the next scan recognizes the projects of this one. // Compare and link the real folder, so the next scan recognizes the projects of this one.
const folder = await realpath(repo.path).catch(() => null); // Checking it before the project exists means a folder that cannot be linked leaves none.
const folder = await resolveProjectWorkspaceDirectory(repo.path).catch(() => null);
if (!folder || linkedFolders.has(folder)) continue; if (!folder || linkedFolders.has(folder)) continue;
let space: { id: string; name: string }; let space: { id: string; name: string };
try { try {

View File

@ -137,6 +137,7 @@ import {
releaseSpaceDeletionClaim, releaseSpaceDeletionClaim,
renewSpaceDeletionClaim, renewSpaceDeletionClaim,
requireMembership, requireMembership,
resolveProjectWorkspaceDirectory,
SPACE_DELETION_CLAIM_TIMEOUT_MS, SPACE_DELETION_CLAIM_TIMEOUT_MS,
SpaceDeletionInProgressError, SpaceDeletionInProgressError,
SpaceLimitError, SpaceLimitError,
@ -573,6 +574,18 @@ export function createRouter(deps: RouterDeps) {
if (input.workspacePath && !context.actor.isDeploymentOwner) { if (input.workspacePath && !context.actor.isDeploymentOwner) {
throw new ORPCError("FORBIDDEN"); 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 }; let space: { id: string; name: string };
try { try {
space = await createSpaceForMember(deps.prisma, { space = await createSpaceForMember(deps.prisma, {
@ -586,20 +599,9 @@ export function createRouter(deps: RouterDeps) {
} }
throw error; throw error;
} }
let workspace = null; const workspace = workspacePath
if (input.workspacePath) { ? await wireProjectWorkspace(deps.prisma, { spaceId: space.id, workspacePath })
try { : null;
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;
}
}
return { return {
id: space.id, id: space.id,
name: space.name, name: space.name,

View File

@ -72,7 +72,7 @@ describe("project workspace wiring", () => {
const wired = await wireProjectWorkspace(prisma as unknown as Pick<PrismaClient, "space">, { const wired = await wireProjectWorkspace(prisma as unknown as Pick<PrismaClient, "space">, {
spaceId: "space-1", spaceId: "space-1",
workspacePath: root, workspacePath: await resolveProjectWorkspaceDirectory(root),
}); });
expect(wired.path).toBe(realpathSync(root)); expect(wired.path).toBe(realpathSync(root));
expect(wired.git).toEqual({ expect(wired.git).toEqual({

View File

@ -50,18 +50,17 @@ export interface WiredProjectWorkspace {
} | null; } | 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( export async function wireProjectWorkspace(
prisma: Pick<PrismaClient, "space">, prisma: Pick<PrismaClient, "space">,
input: { spaceId: string; workspacePath: string }, input: { spaceId: string; workspacePath: string },
): Promise<WiredProjectWorkspace> { ): Promise<WiredProjectWorkspace> {
const resolved = await resolveProjectWorkspaceDirectory(input.workspacePath); const git = await readGitCheckout(input.workspacePath);
const git = await readGitCheckout(resolved);
await prisma.space.update({ await prisma.space.update({
where: { id: input.spaceId }, where: { id: input.spaceId },
data: { data: {
workspacePath: resolved, workspacePath: input.workspacePath,
gitRemoteUrl: git.present && git.remoteUrl ? git.remoteUrl : null, gitRemoteUrl: git.present && git.remoteUrl ? git.remoteUrl : null,
gitDefaultBranch: git.present && git.defaultBranch ? git.defaultBranch : null, gitDefaultBranch: git.present && git.defaultBranch ? git.defaultBranch : null,
gitHeadCommit: git.present ? git.headCommit : null, gitHeadCommit: git.present ? git.headCommit : null,
@ -69,7 +68,7 @@ export async function wireProjectWorkspace(
}); });
return { return {
path: resolved, path: input.workspacePath,
git: git:
git.present && git.remoteUrl && git.defaultBranch git.present && git.remoteUrl && git.defaultBranch
? { ? {

View File

@ -2239,15 +2239,25 @@ describeWithDatabase("API authorization and resource isolation", () => {
const legacyNavigation = await rpc<SpaceNavigation>(app, member, "spaces/list"); const legacyNavigation = await rpc<SpaceNavigation>(app, member, "spaces/list");
expect(legacyNavigation.spaces.find((space) => space.id === legacy.id)?.workspace).toBeNull(); 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<Space>(app, owner, "spaces/create", { const linked = await rpc<Space>(app, owner, "spaces/create", {
name: "Owner folder", name: "Owner folder",
workspacePath: hostFolder, workspacePath: hostFolder,
}); });
expect(linked.workspace?.path).toBe(hostFolder); expect(linked.workspace?.path).toBe(hostFolder);
const ownerNavigation = await rpc<SpaceNavigation>(app, owner, "spaces/list"); const ownerNavigation = await rpc<SpaceNavigation>(app, owner, "spaces/list");
expect(ownerNavigation.spaces.find((space) => space.id === linked.id)?.workspace?.path).toBe( expect(
hostFolder, ownerNavigation.spaces
); .filter((space) => space.name === "Owner folder")
.map((space) => space.workspace?.path),
).toEqual([hostFolder]);
const ownerBot = await rpc<Bot>( const ownerBot = await rpc<Bot>(
app, app,
owner, owner,