import type { FastifyInstance } from 'fastify'; import { mkdir, stat } from 'node:fs/promises'; import type { CreateProjectRequest, CreateProjectResponse } from '@arboretum/shared'; import type { PtyManager } from '../core/pty-manager.js'; import { gitInit, isSafeAbsolutePath } from '../core/git.js'; import { resolveProjectDir } from '../core/project.js'; import { recordAudit } from '../core/audit-log.js'; import type { Db } from '../db/index.js'; /** * Création d'un nouveau projet : crée le dossier `/` (la SEULE écriture fs autorisée * côté serveur en dehors du dataDir) puis lance une session dedans. `git init` optionnel. * Cohérent avec le modèle de sécurité : un client authentifié dispose déjà d'un terminal (RCE par * conception). On valide tout de même strictement `name` (anti-traversal) et on exige une racine * EXISTANTE (mkdir non récursif → pas de création d'arborescence arbitraire). * * POST /api/v1/projects { root, name, gitInit?, command? } → 201 { session, path, gitInitialized } */ export function registerProjectRoutes(app: FastifyInstance, manager: PtyManager, db: Db): void { app.post('/api/v1/projects', async (req, reply) => { const body = req.body as Partial | null; if (!body || typeof body.root !== 'string' || !isSafeAbsolutePath(body.root)) { return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'root (absolute path) is required' } }); } if (typeof body.name !== 'string') { return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'name is required' } }); } if (body.command !== undefined && body.command !== 'claude' && body.command !== 'bash') { return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'command must be claude or bash' } }); } // La racine doit exister et être un répertoire (mkdir non récursif derrière). try { const st = await stat(body.root); if (!st.isDirectory()) { return reply.status(400).send({ error: { code: 'NOT_A_DIRECTORY', message: `Not a directory: ${body.root}` } }); } } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code === 'ENOENT') return reply.status(404).send({ error: { code: 'NOT_FOUND', message: `No such directory: ${body.root}` } }); if (code === 'EACCES') return reply.status(403).send({ error: { code: 'FORBIDDEN', message: `Permission denied: ${body.root}` } }); return reply.status(400).send({ error: { code: 'BAD_PATH', message: (err as Error).message } }); } let dir: string; try { dir = resolveProjectDir(body.root, body.name); } catch (err) { return reply.status((err as { statusCode?: number }).statusCode ?? 400).send({ error: { code: 'BAD_REQUEST', message: (err as Error).message } }); } // mkdir non récursif : EEXIST → 409 (on n'écrase jamais un dossier existant). try { await mkdir(dir); } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code === 'EEXIST') return reply.status(409).send({ error: { code: 'PROJECT_EXISTS', message: `Already exists: ${dir}` } }); if (code === 'EACCES') return reply.status(403).send({ error: { code: 'FORBIDDEN', message: `Permission denied: ${dir}` } }); return reply.status(400).send({ error: { code: 'MKDIR_FAILED', message: (err as Error).message } }); } const wantsGitInit = body.gitInit === true; let gitInitialized = false; if (wantsGitInit) { try { await gitInit(dir); gitInitialized = true; } catch { // Le dossier est créé et la session démarrera quand même : un échec git n'annule pas le projet. gitInitialized = false; } } try { const session = manager.spawn({ cwd: dir, ...(body.command ? { command: body.command } : {}) }); recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'project.create', resourceId: dir, details: { gitInit: gitInitialized }, }); const res: CreateProjectResponse = { session, path: dir, gitInitialized }; return reply.status(201).send(res); } catch (err) { const statusCode = (err as { statusCode?: number }).statusCode ?? 500; return reply.status(statusCode).send({ error: { code: 'SPAWN_FAILED', message: (err as Error).message } }); } }); }