- broker.subscribeUser/publishUser via separate user topics map - /api/ws/user WS route subscribes to the user channel - projects/sessions POST/DELETE handlers emit lifecycle frames - inference 3 terminal-state sites emit session_updated with RETURNING
135 lines
4.0 KiB
TypeScript
135 lines
4.0 KiB
TypeScript
import type { FastifyInstance } from 'fastify';
|
|
import { z } from 'zod';
|
|
import { realpath, stat, readdir, access } from 'node:fs/promises';
|
|
import { basename, resolve, sep } from 'node:path';
|
|
import type { Sql } from '../db.js';
|
|
import type { Config } from '../config.js';
|
|
import type { Broker } from '../services/broker.js';
|
|
import type { Project, AvailableProject } from '../types/api.js';
|
|
|
|
const AddProjectBody = z.object({
|
|
path: z.string().min(1),
|
|
name: z.string().min(1).optional(),
|
|
});
|
|
|
|
async function isDir(path: string): Promise<boolean> {
|
|
try {
|
|
const s = await stat(path);
|
|
return s.isDirectory();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function resolveProjectPath(
|
|
raw: string,
|
|
whitelist: string
|
|
): Promise<{ real: string; name: string } | { error: string }> {
|
|
if (!raw.startsWith('/')) return { error: 'path must be absolute' };
|
|
let real: string;
|
|
try {
|
|
real = await realpath(raw);
|
|
} catch {
|
|
return { error: 'path does not exist' };
|
|
}
|
|
const whitelistReal = await realpath(whitelist);
|
|
if (real !== whitelistReal && !real.startsWith(whitelistReal + sep)) {
|
|
return { error: `path must be under ${whitelist}` };
|
|
}
|
|
if (!(await isDir(real))) return { error: 'path is not a directory' };
|
|
return { real, name: basename(real) };
|
|
}
|
|
|
|
export function registerProjectRoutes(
|
|
app: FastifyInstance,
|
|
sql: Sql,
|
|
config: Config,
|
|
broker: Broker
|
|
): void {
|
|
app.get('/api/projects', async () => {
|
|
const rows = await sql<Project[]>`
|
|
SELECT id, name, path, added_at, last_session_id
|
|
FROM projects
|
|
ORDER BY added_at DESC
|
|
`;
|
|
return rows;
|
|
});
|
|
|
|
app.post('/api/projects', async (req, reply) => {
|
|
const parsed = AddProjectBody.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
reply.code(400);
|
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
|
}
|
|
const resolved = await resolveProjectPath(parsed.data.path, config.PROJECT_ROOT_WHITELIST);
|
|
if ('error' in resolved) {
|
|
reply.code(400);
|
|
return { error: resolved.error };
|
|
}
|
|
const name = parsed.data.name?.trim() || resolved.name;
|
|
try {
|
|
const [row] = await sql<Project[]>`
|
|
INSERT INTO projects (name, path)
|
|
VALUES (${name}, ${resolved.real})
|
|
RETURNING id, name, path, added_at, last_session_id
|
|
`;
|
|
broker.publishUser(req.user!, { type: 'project_created', project: row as unknown as Project });
|
|
reply.code(201);
|
|
return row;
|
|
} catch (err) {
|
|
if (err instanceof Error && err.message.includes('duplicate key')) {
|
|
reply.code(409);
|
|
return { error: 'project already exists' };
|
|
}
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
app.delete<{ Params: { id: string } }>('/api/projects/:id', async (req, reply) => {
|
|
const id = req.params.id;
|
|
const result = await sql`DELETE FROM projects WHERE id = ${id}`;
|
|
if (result.count === 0) {
|
|
reply.code(404);
|
|
return { error: 'not found' };
|
|
}
|
|
broker.publishUser(req.user!, { type: 'project_deleted', project_id: id });
|
|
reply.code(204);
|
|
return null;
|
|
});
|
|
|
|
app.get('/api/projects/available', async () => {
|
|
const whitelist = await realpath(config.PROJECT_ROOT_WHITELIST);
|
|
let entries: string[];
|
|
try {
|
|
entries = await readdir(whitelist);
|
|
} catch {
|
|
return [] as AvailableProject[];
|
|
}
|
|
|
|
const existing = await sql<{ path: string }[]>`SELECT path FROM projects`;
|
|
const existingSet = new Set(existing.map((r) => r.path));
|
|
|
|
const out: AvailableProject[] = [];
|
|
for (const entry of entries) {
|
|
const full = resolve(whitelist, entry);
|
|
let real: string;
|
|
try {
|
|
real = await realpath(full);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (real !== whitelist && !real.startsWith(whitelist + sep)) continue;
|
|
if (existingSet.has(real)) continue;
|
|
if (!(await isDir(real))) continue;
|
|
try {
|
|
await access(resolve(real, '.git'));
|
|
} catch {
|
|
continue;
|
|
}
|
|
out.push({ path: real, name: basename(real) });
|
|
}
|
|
out.sort((a, b) => a.name.localeCompare(b.name));
|
|
return out;
|
|
});
|
|
}
|