evOo-local/packages/db/src/project-workspace.ts
cynarAI 3c3961f242
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EyQw3AEaHYXegvgvTPAsi1
2026-10-03 18:32:48 +02:00

107 lines
3.3 KiB
TypeScript

import { realpath, stat } from "node:fs/promises";
import path from "node:path";
import { readGitCheckout } from "@rakazo/core/git-checkout";
import type { PrismaClient } from "./client.js";
export class InvalidProjectWorkspacePathError extends Error {
constructor(message: string) {
super(message);
this.name = "InvalidProjectWorkspacePathError";
}
}
export function normalizeProjectWorkspacePath(input: string): string {
const trimmed = input.trim();
if (!trimmed) throw new InvalidProjectWorkspacePathError("A folder path is required.");
if (trimmed.length > 500) {
throw new InvalidProjectWorkspacePathError("That folder path is too long.");
}
if (trimmed.includes("\0")) {
throw new InvalidProjectWorkspacePathError("That folder path is not valid.");
}
return path.resolve(trimmed);
}
/** The real folder behind a path, so a symlink cannot later point the link somewhere else. */
export async function resolveProjectWorkspaceDirectory(workspacePath: string): Promise<string> {
const normalized = normalizeProjectWorkspacePath(workspacePath);
let resolved: string;
let info: Awaited<ReturnType<typeof stat>>;
try {
resolved = await realpath(normalized);
info = await stat(resolved);
} catch {
throw new InvalidProjectWorkspacePathError("That folder does not exist or is not readable.");
}
if (!info.isDirectory()) {
throw new InvalidProjectWorkspacePathError("That path is not a folder.");
}
return resolved;
}
export interface WiredProjectWorkspace {
path: string;
git: {
remoteUrl: string;
defaultBranch: string;
headCommit: string | null;
dirty: boolean;
dirtyPaths: string[];
} | null;
}
/** Persist a host folder and optional git metadata on a newly created space. */
export async function wireProjectWorkspace(
prisma: Pick<PrismaClient, "space">,
input: { spaceId: string; workspacePath: string },
): Promise<WiredProjectWorkspace> {
const resolved = await resolveProjectWorkspaceDirectory(input.workspacePath);
const git = await readGitCheckout(resolved);
await prisma.space.update({
where: { id: input.spaceId },
data: {
workspacePath: resolved,
gitRemoteUrl: git.present && git.remoteUrl ? git.remoteUrl : null,
gitDefaultBranch: git.present && git.defaultBranch ? git.defaultBranch : null,
gitHeadCommit: git.present ? git.headCommit : null,
},
});
return {
path: resolved,
git:
git.present && git.remoteUrl && git.defaultBranch
? {
remoteUrl: git.remoteUrl,
defaultBranch: git.defaultBranch,
headCommit: git.headCommit,
dirty: git.dirty,
dirtyPaths: git.dirtyPaths,
}
: null,
};
}
export function projectWorkspaceDto(space: {
workspacePath: string | null;
gitRemoteUrl: string | null;
gitDefaultBranch: string | null;
gitHeadCommit: string | null;
}): WiredProjectWorkspace | null {
if (!space.workspacePath) return null;
return {
path: space.workspacePath,
git:
space.gitRemoteUrl && space.gitDefaultBranch
? {
remoteUrl: space.gitRemoteUrl,
defaultBranch: space.gitDefaultBranch,
headCommit: space.gitHeadCommit,
dirty: false,
dirtyPaths: [],
}
: null,
};
}