open-design/apps/daemon/src/project-routes.ts
2026-05-31 09:04:38 +04:00

1765 lines
65 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import { rm } from 'node:fs/promises';
import path from 'node:path';
import type { Express, Response } from 'express';
import {
defaultScenarioPluginIdForProjectMetadata,
type PluginManifest,
} from '@open-design/contracts';
import { createProjectArtifactFile } from './artifact-create.js';
import { ArtifactPublicationBlockedError } from './artifact-publication-guard.js';
import { ArtifactRegressionError } from './artifact-stub-guard.js';
import { listDesignSystems } from './design-systems.js';
import {
FIRST_PARTY_ATOMS,
buildConnectorProbe,
getInstalledPlugin,
listInstalledPlugins,
resolvePluginSnapshot,
} from './plugins/index.js';
import { connectorService } from './connectors/service.js';
import type { RouteDeps } from './server-context.js';
import { listSkills } from './skills.js';
import { isSafeId } from './projects.js';
import {
BUILT_IN_PROJECT_LOCATION_ID,
allProjectLocations,
createLocationProjectDir,
ensureProjectLocation,
scanProjectLocation,
writeProjectManifest,
} from './project-locations.js';
import { auditDesignSystemPackage } from './tools-connectors-cli.js';
export interface RegisterProjectRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'paths' | 'projectStore' | 'projectFiles' | 'conversations' | 'templates' | 'status' | 'events' | 'ids' | 'telemetry' | 'appConfig' | 'validation'> {}
function projectDetailResolvedDir(
projectsRoot: string,
project: any,
resolveProjectDir: (
projectsRoot: string,
projectId: string,
metadata?: unknown,
opts?: { allowUnavailableSandboxImportedProject?: boolean },
) => string,
): string {
const baseDir = typeof project?.metadata?.baseDir === 'string'
? path.normalize(project.metadata.baseDir)
: null;
if (baseDir && path.isAbsolute(baseDir)) return baseDir;
return resolveProjectDir(projectsRoot, project.id, project.metadata, {
allowUnavailableSandboxImportedProject: true,
});
}
const URL_PREVIEW_SCROLL_BRIDGE = `<script data-od-url-scroll-bridge>
(function(){
if (window.__odUrlScrollBridge) return;
window.__odUrlScrollBridge = true;
var pending = false;
function scrollElement(){
return document.querySelector('.design-canvas') || document.scrollingElement || document.documentElement;
}
function num(value){
var next = Number(value || 0);
return Number.isFinite(next) ? next : 0;
}
function post(){
var el = scrollElement();
if (!el) return;
var frame = document.scrollingElement || document.documentElement;
window.parent.postMessage({
type: 'od:preview-scroll',
canvasLeft: Math.round(el.scrollLeft || 0),
canvasTop: Math.round(el.scrollTop || 0),
frameLeft: Math.round(frame.scrollLeft || 0),
frameTop: Math.round(frame.scrollTop || 0)
}, '*');
}
function schedule(){
if (pending) return;
pending = true;
window.requestAnimationFrame(function(){
pending = false;
post();
});
}
function scrollTo(el, left, top){
if (!el) return;
if (typeof el.scrollTo === 'function') el.scrollTo(num(left), num(top));
else {
el.scrollLeft = num(left);
el.scrollTop = num(top);
}
}
function scrollBy(el, left, top){
if (!el) return;
var dx = num(left);
var dy = num(top);
if (!dx && !dy) return;
if (typeof el.scrollBy === 'function') el.scrollBy({ left: dx, top: dy, behavior: 'auto' });
else {
el.scrollLeft = (el.scrollLeft || 0) + dx;
el.scrollTop = (el.scrollTop || 0) + dy;
}
}
function requestRestore(){
window.parent.postMessage({ type: 'od:preview-scroll-request' }, '*');
}
window.addEventListener('message', function(ev){
var data = ev && ev.data;
if (!data || !data.type) return;
if (data.type === 'od:preview-scroll-restore') {
scrollTo(document.scrollingElement || document.documentElement, data.frameLeft, data.frameTop);
scrollTo(scrollElement(), data.canvasLeft, data.canvasTop);
setTimeout(post, 0);
return;
}
if (data.type === 'od:preview-scroll-by') {
scrollBy(scrollElement(), data.left, data.top);
schedule();
}
});
window.addEventListener('scroll', schedule, true);
document.addEventListener('scroll', schedule, true);
window.addEventListener('resize', schedule);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function(){
requestRestore();
schedule();
});
} else {
setTimeout(function(){
requestRestore();
schedule();
}, 0);
}
})();
</script>`;
function wantsUrlPreviewScrollBridge(value: unknown): boolean {
if (Array.isArray(value)) return value.some(wantsUrlPreviewScrollBridge);
if (typeof value !== 'string') return false;
return value === 'scroll' || value === '1' || value === 'true';
}
function injectUrlPreviewScrollBridge(html: string): string {
if (html.includes('data-od-url-scroll-bridge')) return html;
const bodyCloseIndex = html.search(/<\/body\s*>/i);
if (bodyCloseIndex >= 0) {
return `${html.slice(0, bodyCloseIndex)}${URL_PREVIEW_SCROLL_BRIDGE}${html.slice(bodyCloseIndex)}`;
}
return `${html}${URL_PREVIEW_SCROLL_BRIDGE}`;
}
export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDeps) {
const { db, design } = ctx;
const { sendApiError, createSseResponse } = ctx.http;
const { DESIGN_SYSTEMS_DIR, PROJECTS_DIR, SKILLS_DIR } = ctx.paths;
const { readAppConfig, writeAppConfig } = ctx.appConfig;
const { insertProject, validateLinkedDirs, getProject, updateProject, dbDeleteProject, removeProjectDir } = ctx.projectStore;
const { writeProjectFile, readProjectFile, ensureProject, listFiles, listTabs, setTabs, resolveProjectDir } = ctx.projectFiles;
const { insertConversation, getConversation, listConversations, updateConversation, deleteConversation, listMessages, upsertMessage, listPreviewComments, upsertPreviewComment, updatePreviewCommentStatus, deletePreviewComment } = ctx.conversations;
const { getTemplate, listTemplates, deleteTemplate, insertTemplate, findTemplateByNameAndProject, updateTemplate } = ctx.templates;
const { listLatestProjectRunStatuses, listProjectsAwaitingInput, normalizeProjectDisplayStatus, composeProjectDisplayStatus, listProjects } = ctx.status;
const { subscribeFileEvents, activeProjectEventSinks } = ctx.events;
const { randomId } = ctx.ids;
const { validateProjectDesignSystemId, validateProjectSkillId } = ctx.validation;
async function loadPluginRegistryView() {
const [skills, designSystems] = await Promise.all([
listSkills(SKILLS_DIR),
listDesignSystems(DESIGN_SYSTEMS_DIR),
]);
return {
skills: skills.map((s) => ({ id: s.id, title: s.name, description: s.description })),
designSystems: designSystems.map((d) => ({ id: d.id, title: d.title })),
craft: [],
atoms: FIRST_PARTY_ATOMS.map((a) => ({ id: a.id, label: a.label })),
scenarios: collectBundledScenarios(),
};
}
function collectBundledScenarios() {
type ScenarioEntry = {
id: string;
taskKind: 'new-generation' | 'figma-migration' | 'code-migration' | 'tune-collab';
pipeline: NonNullable<NonNullable<PluginManifest['od']>['pipeline']>;
};
const byTaskKind = new Map<ScenarioEntry['taskKind'], ScenarioEntry>();
try {
const all = listInstalledPlugins(db);
for (const row of all) {
if (row.sourceKind !== 'bundled') continue;
const od = row.manifest.od;
if (!od || od.kind !== 'scenario') continue;
if (!od.pipeline || !Array.isArray(od.pipeline.stages) || od.pipeline.stages.length === 0) continue;
const taskKind = (od.taskKind ?? 'new-generation') as ScenarioEntry['taskKind'];
if (
taskKind !== 'new-generation' &&
taskKind !== 'figma-migration' &&
taskKind !== 'code-migration' &&
taskKind !== 'tune-collab'
) {
continue;
}
const entry: ScenarioEntry = { id: row.id, taskKind, pipeline: od.pipeline };
const existing = byTaskKind.get(taskKind);
if (!existing || entry.id === `od-${taskKind}`) {
byTaskKind.set(taskKind, entry);
}
}
} catch {
return [];
}
return Array.from(byTaskKind.values());
}
async function configuredProjectLocations() {
const config = await readAppConfig(ctx.paths.RUNTIME_DATA_DIR);
const all = allProjectLocations(PROJECTS_DIR, config.projectLocations);
const valid = all[0] ? [all[0]] : [];
for (const location of all.slice(1)) {
const validated = validateLinkedDirs([location.path]);
if (validated.error) continue;
const canonical = validated.dirs[0];
if (!canonical) continue;
if (locationOverlapsDaemonData(canonical)) continue;
valid.push({ ...location, path: canonical });
}
return valid;
}
function locationOverlapsDaemonData(locationPath: string): boolean {
const runtimeDir = ctx.paths.RUNTIME_DATA_DIR_CANONICAL || ctx.paths.RUNTIME_DATA_DIR;
const projectsDir = path.join(runtimeDir, 'projects');
const relativeToRuntime = pathRelative(runtimeDir, locationPath);
const runtimeInsideLocation = pathRelative(locationPath, runtimeDir);
const relativeToProjects = pathRelative(projectsDir, locationPath);
const projectsInsideLocation = pathRelative(locationPath, projectsDir);
return isInsideOrSame(relativeToRuntime) || isInsideOrSame(runtimeInsideLocation)
|| isInsideOrSame(relativeToProjects) || isInsideOrSame(projectsInsideLocation);
}
function pathRelative(from: string, to: string): string {
return path.relative(from, to);
}
function isInsideOrSame(relative: string): boolean {
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
function projectBelongsToLocation(project: any, location: { id: string; path: string }): boolean {
const metadata = project?.metadata;
if (typeof metadata?.baseDir !== 'string') return metadata?.projectLocationId === location.id;
const relative = path.relative(location.path, metadata.baseDir);
return isInsideOrSame(relative) && relative !== '';
}
function isProjectLocationProject(project: any): boolean {
const metadata = project?.metadata;
return metadata?.importedFrom === 'project-location'
|| typeof metadata?.projectLocationId === 'string';
}
function projectVisibleForLocations(
project: any,
locations: Array<{ id: string; path: string; builtIn?: boolean }>,
): boolean {
if (!isProjectLocationProject(project)) return true;
return locations.some((location) => !location.builtIn && projectBelongsToLocation(project, location));
}
async function resolveCreateProjectLocationId(explicitProjectLocationId: unknown): Promise<string> {
if (typeof explicitProjectLocationId === 'string' && explicitProjectLocationId.trim()) {
return explicitProjectLocationId.trim();
}
const config = await readAppConfig(ctx.paths.RUNTIME_DATA_DIR);
const configuredDefault = typeof config.defaultProjectLocationId === 'string'
? config.defaultProjectLocationId.trim()
: '';
if (!configuredDefault || configuredDefault === BUILT_IN_PROJECT_LOCATION_ID) {
return BUILT_IN_PROJECT_LOCATION_ID;
}
const locations = await configuredProjectLocations();
return locations.some((location) => !location.builtIn && location.id === configuredDefault)
? configuredDefault
: BUILT_IN_PROJECT_LOCATION_ID;
}
function unregisterProjectsForRemovedLocations(
previousLocations: Array<{ id: string; path: string; builtIn?: boolean }>,
nextLocations: Array<{ id?: string; path: string }>,
): string[] {
const nextIds = new Set(nextLocations.map((location) => location.id).filter(Boolean));
const nextPaths = new Set(nextLocations.map((location) => location.path));
const removed = previousLocations.filter(
(location) => !location.builtIn && !nextIds.has(location.id) && !nextPaths.has(location.path),
);
if (removed.length === 0) return [];
return listProjects(db)
.filter((project: any) => removed.some((location) => projectBelongsToLocation(project, location)))
.map((project: any) => project.id);
}
app.get('/api/project-locations', async (_req, res) => {
try {
const locations = await configuredProjectLocations();
/** @type {import('@open-design/contracts').ProjectLocationsResponse} */
const body = { locations };
res.json(body);
} catch (err: any) {
sendApiError(res, 500, 'INTERNAL_ERROR', String(err));
}
});
app.put('/api/project-locations', async (req, res) => {
try {
const requested = Array.isArray(req.body?.locations) ? req.body.locations : null;
if (!requested) return sendApiError(res, 400, 'BAD_REQUEST', 'locations must be an array');
const previousLocations = await configuredProjectLocations();
const prepared = [];
for (const loc of requested) {
if (!loc || typeof loc !== 'object' || typeof loc.path !== 'string') continue;
const canonicalPath = await ensureProjectLocation(loc.path);
const validated = validateLinkedDirs([canonicalPath]);
if (validated.error) return sendApiError(res, 400, 'BAD_REQUEST', validated.error);
if (locationOverlapsDaemonData(canonicalPath)) {
return sendApiError(res, 400, 'BAD_REQUEST', 'project location cannot overlap daemon data');
}
prepared.push({
id: typeof loc.id === 'string' ? loc.id : undefined,
name: typeof loc.name === 'string' ? loc.name : undefined,
path: canonicalPath,
});
}
const config = await writeAppConfig(ctx.paths.RUNTIME_DATA_DIR, { projectLocations: prepared });
const locations = allProjectLocations(PROJECTS_DIR, config.projectLocations);
const removedProjectIds = unregisterProjectsForRemovedLocations(previousLocations, config.projectLocations ?? []);
/** @type {import('@open-design/contracts').ProjectLocationsResponse} */
const body = { locations, removedProjectIds };
res.json(body);
} catch (err: any) {
sendApiError(res, 400, 'BAD_REQUEST', String(err));
}
});
app.post('/api/project-locations/scan', async (_req, res) => {
try {
const locations = (await configuredProjectLocations()).filter((loc: any) => !loc.builtIn);
const imported = [];
const existing: string[] = [];
const skipped: Array<{ path: string; reason: string }> = [];
let scanned = 0;
const now = Date.now();
for (const location of locations) {
let found;
try {
found = await scanProjectLocation(location);
} catch (err: any) {
skipped.push({ path: location.path, reason: String(err?.message ?? err) });
continue;
}
scanned += found.length;
for (const entry of found) {
const { manifest } = entry;
if (getProject(db, manifest.id)) {
existing.push(manifest.id);
continue;
}
try {
const project = insertProject(db, {
id: manifest.id,
name: manifest.name,
skillId: manifest.skillId ?? null,
designSystemId: manifest.designSystemId ?? null,
pendingPrompt: null,
metadata: {
kind: 'prototype',
baseDir: entry.dir,
importedFrom: 'project-location',
projectLocationId: location.id,
},
customInstructions: null,
createdAt: manifest.createdAt,
updatedAt: manifest.updatedAt,
});
insertConversation(db, {
id: randomId(),
projectId: manifest.id,
title: null,
createdAt: now,
updatedAt: now,
});
if (project) imported.push(project);
} catch (err: any) {
skipped.push({ path: entry.dir, reason: String(err?.message ?? err) });
}
}
}
/** @type {import('@open-design/contracts').ScanProjectLocationsResponse} */
const body = { scanned, imported, existing, skipped };
res.json(body);
} catch (err: any) {
sendApiError(res, 400, 'BAD_REQUEST', String(err));
}
});
app.get('/api/projects', async (_req, res) => {
try {
const locations = await configuredProjectLocations();
const latestRunStatuses = listLatestProjectRunStatuses(db);
const awaitingInputProjects = listProjectsAwaitingInput(db);
const activeRunStatuses = new Map();
for (const run of design.runs.list()) {
if (!run.projectId) continue;
const runStatus = projectStatusFromRun(run);
if (design.runs.isTerminal(run.status)) {
const existing = latestRunStatuses.get(run.projectId);
if (!existing || run.updatedAt > (existing.updatedAt ?? 0)) {
latestRunStatuses.set(run.projectId, runStatus);
}
} else {
const existing = activeRunStatuses.get(run.projectId);
if (!existing || run.updatedAt > (existing.updatedAt ?? 0)) {
activeRunStatuses.set(run.projectId, runStatus);
}
}
}
/** @type {import('@open-design/contracts').ProjectsResponse} */
const body = {
projects: listProjects(db)
.filter((project: any) => projectVisibleForLocations(project, locations))
.map((project: any) => ({
...project,
status: composeProjectDisplayStatus(
activeRunStatuses.get(project.id) ??
latestRunStatuses.get(project.id) ?? { value: 'not_started' },
awaitingInputProjects,
project.id,
),
})),
};
res.json(body);
} catch (err: any) {
sendApiError(res, 500, 'INTERNAL_ERROR', String(err));
}
});
function projectStatusFromRun(run: any) {
return {
value: normalizeProjectDisplayStatus(run.status),
updatedAt: run.updatedAt,
runId: run.id,
};
}
app.post('/api/projects', async (req, res) => {
try {
const { id, name, projectLocationId, skillId, designSystemId, pendingPrompt, metadata, customInstructions, skipDiscoveryBrief } =
req.body || {};
if (typeof id !== 'string' || !isSafeId(id)) {
return sendApiError(res, 400, 'BAD_REQUEST', 'invalid project id');
}
if (typeof name !== 'string' || !name.trim()) {
return sendApiError(res, 400, 'BAD_REQUEST', 'name required');
}
// baseDir is privileged: it lets a project root directly inside the
// user's filesystem. The /api/import/folder endpoint is the only
// path that's allowed to set it, because that's where realpath() +
// RUNTIME_DATA_DIR reentry checks live. Block client-supplied
// metadata.baseDir on this generic create endpoint so an attacker
// can't smuggle e.g. /etc through here. Same rule for
// originalBaseDir / importedFrom='folder' — only the import path
// owns those state fields.
if (metadata && typeof metadata === 'object') {
if ('baseDir' in metadata) {
return sendApiError(
res, 400, 'BAD_REQUEST',
'baseDir can only be set via POST /api/import/folder',
);
}
if ('fromTrustedPicker' in metadata) {
return sendApiError(
res, 400, 'BAD_REQUEST',
'fromTrustedPicker can only be set via POST /api/import/folder',
);
}
}
if (customInstructions !== undefined
&& typeof customInstructions !== 'string'
&& customInstructions !== null) {
return sendApiError(res, 400, 'BAD_REQUEST', 'customInstructions must be a string or null');
}
if (typeof customInstructions === 'string' && customInstructions.length > 5000) {
return sendApiError(res, 400, 'BAD_REQUEST', 'customInstructions exceeds 5 000 character limit');
}
if (skipDiscoveryBrief !== undefined && typeof skipDiscoveryBrief !== 'boolean') {
return sendApiError(res, 400, 'BAD_REQUEST', 'skipDiscoveryBrief must be a boolean');
}
const designSystemValidation = await validateProjectDesignSystemId(designSystemId);
if (!designSystemValidation.ok) {
return sendApiError(
res,
400,
designSystemValidation.code,
designSystemValidation.message,
);
}
const normalizedDesignSystemId = designSystemValidation.id;
const skillValidation = await validateProjectSkillId(skillId);
if (!skillValidation.ok) {
return sendApiError(res, 400, skillValidation.code, skillValidation.message);
}
const normalizedSkillId = skillValidation.id;
const selectedLocationId = await resolveCreateProjectLocationId(projectLocationId);
let externalProjectDir: string | null = null;
if (selectedLocationId !== BUILT_IN_PROJECT_LOCATION_ID) {
const location = (await configuredProjectLocations()).find((loc: any) => loc.id === selectedLocationId);
if (!location || location.builtIn) {
return sendApiError(res, 400, 'BAD_REQUEST', 'unknown project location');
}
if (getProject(db, id)) {
return sendApiError(res, 400, 'BAD_REQUEST', 'project id already exists');
}
externalProjectDir = await createLocationProjectDir(location, id);
}
const projectMetadata =
metadata && typeof metadata === 'object'
? {
...metadata,
...(skipDiscoveryBrief === true ? { skipDiscoveryBrief: true } : {}),
...(externalProjectDir
? {
baseDir: externalProjectDir,
importedFrom: 'project-location',
projectLocationId: selectedLocationId,
}
: {}),
...(Array.isArray(metadata.linkedDirs)
? (() => {
const v = validateLinkedDirs(metadata.linkedDirs);
return v.error ? {} : { linkedDirs: v.dirs };
})()
: {}),
}
: skipDiscoveryBrief === true
? {
skipDiscoveryBrief: true,
...(externalProjectDir
? {
baseDir: externalProjectDir,
importedFrom: 'project-location',
projectLocationId: selectedLocationId,
}
: {}),
}
: externalProjectDir
? {
kind: 'prototype',
baseDir: externalProjectDir,
importedFrom: 'project-location',
projectLocationId: selectedLocationId,
}
: null;
const now = Date.now();
let project;
try {
if (externalProjectDir) {
await writeProjectManifest(externalProjectDir, {
schemaVersion: 1,
id,
name: name.trim(),
createdAt: now,
updatedAt: now,
skillId: normalizedSkillId,
designSystemId: normalizedDesignSystemId,
});
}
project = insertProject(db, {
id,
name: name.trim(),
skillId: normalizedSkillId,
designSystemId: normalizedDesignSystemId,
pendingPrompt: pendingPrompt || null,
metadata: projectMetadata,
customInstructions:
typeof customInstructions === 'string'
? customInstructions
: null,
createdAt: now,
updatedAt: now,
});
} catch (err) {
if (externalProjectDir) {
await rm(externalProjectDir, { recursive: true, force: true }).catch(() => {});
}
throw err;
}
// Seed a default conversation so the UI always has somewhere to write.
const cid = randomId();
insertConversation(db, {
id: cid,
projectId: id,
title: null,
createdAt: now,
updatedAt: now,
});
const explicitPlugin =
typeof req.body?.pluginId === 'string' && req.body.pluginId.trim().length > 0
? true
: typeof req.body?.appliedPluginSnapshotId === 'string'
&& req.body.appliedPluginSnapshotId.trim().length > 0;
let resolveBody =
explicitPlugin ? (req.body as Record<string, unknown>) : null;
if (!resolveBody) {
const fallbackPluginId = defaultScenarioPluginIdForProjectMetadata(projectMetadata);
if (fallbackPluginId && getInstalledPlugin(db, fallbackPluginId)) {
resolveBody = { ...(req.body || {}), pluginId: fallbackPluginId };
}
}
let resolvedSnapshot = null;
if (resolveBody) {
const registry = await loadPluginRegistryView();
const resolved = resolvePluginSnapshot({
db,
body: resolveBody,
projectId: id,
conversationId: cid,
registry,
activeProjectDesignSystem:
typeof normalizedDesignSystemId === 'string' && normalizedDesignSystemId.length > 0
? { id: normalizedDesignSystemId }
: undefined,
connectorProbe: buildConnectorProbe(connectorService),
});
if (resolved && !resolved.ok) {
if (!explicitPlugin) {
console.warn(
`[plugins] default-scenario fallback skipped for project ${id}: ${resolved.body?.error?.code ?? 'unknown'}`,
);
} else {
return res.status(resolved.status).json(resolved.body);
}
} else {
resolvedSnapshot = resolved;
}
}
// For "from template" projects, seed the chosen template's snapshot
// HTML into the new project folder so the agent can Read/edit files
// on disk (the system prompt also embeds them, but a real on-disk
// copy lets the agent treat them as the project's working state).
if (
metadata &&
typeof metadata === 'object' &&
metadata.kind === 'template' &&
typeof metadata.templateId === 'string'
) {
const tpl = getTemplate(db, metadata.templateId);
if (tpl && Array.isArray(tpl.files) && tpl.files.length > 0) {
await ensureProject(PROJECTS_DIR, id, projectMetadata);
for (const f of tpl.files) {
if (
!f ||
typeof f.name !== 'string' ||
typeof f.content !== 'string'
) {
continue;
}
try {
await writeProjectFile(
PROJECTS_DIR,
id,
f.name,
Buffer.from(f.content, 'utf8'),
{},
projectMetadata,
);
} catch {
// Skip individual file failures — the template snapshot is
// best-effort; the agent still has the embedded copy.
}
}
}
}
/** @type {import('@open-design/contracts').CreateProjectResponse} */
const body = {
project: resolvedSnapshot?.ok ? getProject(db, id) ?? project : project,
conversationId: cid,
...(resolvedSnapshot?.ok
? { appliedPluginSnapshotId: resolvedSnapshot.snapshotId }
: {}),
};
res.json(body);
} catch (err: any) {
sendApiError(res, 400, 'BAD_REQUEST', String(err));
}
});
app.get('/api/projects/:id', async (req, res) => {
const project = getProject(db, req.params.id);
const locations = await configuredProjectLocations();
if (!project || !projectVisibleForLocations(project, locations))
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found');
const resolvedDir = projectDetailResolvedDir(PROJECTS_DIR, project, resolveProjectDir);
/** @type {import('@open-design/contracts').ProjectResponse} */
const body = { project, resolvedDir };
res.json(body);
});
app.patch('/api/projects/:id', async (req, res) => {
try {
const patch = req.body || {};
// baseDir / folder-import state is privileged: it's set only by the
// import endpoint and otherwise immutable. Two failure modes to
// guard against here:
// 1. Explicit attempt to change baseDir → reject with 400.
// 2. A regular metadata patch that *omits* baseDir (e.g. a UI
// that only edits linkedDirs sends `{ metadata: { kind, linkedDirs } }`).
// updateProject() replaces metadata wholesale, so without
// preservation the existing baseDir gets wiped and the project
// detaches from the user's folder — subsequent reads/writes
// silently fall back to .od/projects/<id>.
// For case 2 we re-stamp the immutable fields from the existing
// project record onto the incoming patch so the user can keep
// patching other metadata without ever losing their import root.
if (patch.metadata && typeof patch.metadata === 'object') {
const existing = getProject(db, req.params.id);
const existingMeta = existing?.metadata;
if ('fromTrustedPicker' in patch.metadata
&& patch.metadata.fromTrustedPicker !== existingMeta?.fromTrustedPicker) {
return sendApiError(
res, 400, 'BAD_REQUEST',
'fromTrustedPicker can only be set via POST /api/import/folder',
);
}
if (existingMeta?.baseDir) {
if ('baseDir' in patch.metadata && patch.metadata.baseDir !== existingMeta.baseDir) {
return sendApiError(
res, 400, 'BAD_REQUEST',
'baseDir is immutable after import; use a new import to change it',
);
}
patch.metadata = {
...patch.metadata,
baseDir: existingMeta.baseDir,
...(existingMeta.importedFrom === 'folder'
? { importedFrom: 'folder' }
: {}),
...(existingMeta.importedFrom === 'project-location'
? { importedFrom: 'project-location' }
: {}),
...(typeof existingMeta.projectLocationId === 'string'
? { projectLocationId: existingMeta.projectLocationId }
: {}),
...(existingMeta.fromTrustedPicker === true
? { fromTrustedPicker: true as const }
: {}),
};
} else if ('baseDir' in patch.metadata) {
// Non-imported project trying to acquire a baseDir → reject (only
// /api/import/folder can set it).
return sendApiError(
res, 400, 'BAD_REQUEST',
'baseDir can only be set via POST /api/import/folder',
);
}
}
if (patch.metadata?.linkedDirs) {
const existing = getProject(db, req.params.id);
const validated = validateLinkedDirs(patch.metadata.linkedDirs);
if (validated.error) {
return sendApiError(res, 400, 'INVALID_LINKED_DIR', validated.error);
}
patch.metadata.linkedDirs =
existing?.metadata?.fromTrustedPicker === true
? patch.metadata.linkedDirs
: validated.dirs;
}
if (patch.customInstructions !== undefined
&& typeof patch.customInstructions !== 'string'
&& patch.customInstructions !== null) {
return sendApiError(res, 400, 'BAD_REQUEST', 'customInstructions must be a string or null');
}
if (typeof patch.customInstructions === 'string' && patch.customInstructions.length > 5000) {
return sendApiError(res, 400, 'BAD_REQUEST', 'customInstructions exceeds 5 000 character limit');
}
if (Object.prototype.hasOwnProperty.call(patch, 'designSystemId')) {
const designSystemValidation = await validateProjectDesignSystemId(patch.designSystemId);
if (!designSystemValidation.ok) {
return sendApiError(
res,
400,
designSystemValidation.code,
designSystemValidation.message,
);
}
patch.designSystemId = designSystemValidation.id;
}
if (Object.prototype.hasOwnProperty.call(patch, 'skillId')) {
const skillValidation = await validateProjectSkillId(patch.skillId);
if (!skillValidation.ok) {
return sendApiError(res, 400, skillValidation.code, skillValidation.message);
}
patch.skillId = skillValidation.id;
}
const project = updateProject(db, req.params.id, patch);
if (!project)
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found');
/** @type {import('@open-design/contracts').ProjectResponse} */
const body = { project };
res.json(body);
} catch (err: any) {
sendApiError(res, 400, 'BAD_REQUEST', String(err));
}
});
app.delete('/api/projects/:id', async (req, res) => {
try {
dbDeleteProject(db, req.params.id);
await removeProjectDir(PROJECTS_DIR, req.params.id).catch(() => {});
/** @type {import('@open-design/contracts').OkResponse} */
const body = { ok: true };
res.json(body);
} catch (err: any) {
sendApiError(res, 400, 'BAD_REQUEST', String(err));
}
});
// SSE stream of file-changed events for a project. Drives preview live-reload.
// Receipt of a `file-changed` event triggers a file-list refresh, which
// propagates new mtimes through to FileViewer iframes (the URL-load
// `?v=${mtime}` cache-bust from PR #384 then reloads the iframe automatically).
// Subscribers come and go as users open/close project tabs; the underlying
// chokidar watcher is refcounted in project-watchers.ts so we never hold
// descriptors for projects no UI is looking at.
app.get('/api/projects/:id/events', (req, res) => {
if (!getProject(db, req.params.id)) {
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found');
}
let sub: any;
try {
const sse = createSseResponse(res);
const projectEventSink = (payload: any) => {
sse.send(payload.type, payload);
};
let sinks = activeProjectEventSinks.get(req.params.id);
if (!sinks) {
sinks = new Set();
activeProjectEventSinks.set(req.params.id, sinks);
}
sinks.add(projectEventSink);
const watchProject = getProject(db, req.params.id);
sub = subscribeFileEvents(PROJECTS_DIR, req.params.id, (evt: any) => {
sse.send('file-changed', evt);
}, { metadata: watchProject?.metadata });
sub.ready.then(() => sse.send('ready', { projectId: req.params.id })).catch(() => {});
const cleanup = () => {
if (sub) {
const { unsubscribe } = sub;
sub = null;
Promise.resolve(unsubscribe()).catch(() => {});
}
const currentSinks = activeProjectEventSinks.get(req.params.id);
currentSinks?.delete(projectEventSink);
if (currentSinks?.size === 0) activeProjectEventSinks.delete(req.params.id);
};
res.on('close', cleanup);
res.on('finish', cleanup);
} catch (err: any) {
if (sub) Promise.resolve(sub.unsubscribe()).catch(() => {});
if (!res.headersSent) sendApiError(res, 400, 'BAD_REQUEST', String(err?.message || err));
}
});
// ---- Conversations --------------------------------------------------------
app.get('/api/projects/:id/conversations', (req, res) => {
if (!getProject(db, req.params.id)) {
return res.status(404).json({ error: 'project not found' });
}
res.json({ conversations: listConversations(db, req.params.id) });
});
app.post('/api/projects/:id/conversations', (req, res) => {
if (!getProject(db, req.params.id)) {
return res.status(404).json({ error: 'project not found' });
}
const { title } = req.body || {};
const now = Date.now();
const conv = insertConversation(db, {
id: randomId(),
projectId: req.params.id,
title: typeof title === 'string' ? title.trim() || null : null,
createdAt: now,
updatedAt: now,
});
res.json({ conversation: conv });
});
app.patch('/api/projects/:id/conversations/:cid', (req, res) => {
const conv = getConversation(db, req.params.cid);
if (!conv || conv.projectId !== req.params.id) {
return res.status(404).json({ error: 'not found' });
}
const updated = updateConversation(db, req.params.cid, req.body || {});
res.json({ conversation: updated });
});
app.delete('/api/projects/:id/conversations/:cid', (req, res) => {
const conv = getConversation(db, req.params.cid);
if (!conv || conv.projectId !== req.params.id) {
return res.status(404).json({ error: 'not found' });
}
deleteConversation(db, req.params.cid);
res.json({ ok: true });
});
// ---- Messages -------------------------------------------------------------
app.get('/api/projects/:id/conversations/:cid/messages', (req, res) => {
const conv = getConversation(db, req.params.cid);
if (!conv || conv.projectId !== req.params.id) {
return res.status(404).json({ error: 'conversation not found' });
}
res.json({ messages: listMessages(db, req.params.cid) });
});
app.put('/api/projects/:id/conversations/:cid/messages/:mid', (req, res) => {
const conv = getConversation(db, req.params.cid);
if (!conv || conv.projectId !== req.params.id) {
return res.status(404).json({ error: 'conversation not found' });
}
const m = req.body || {};
if (m.id && m.id !== req.params.mid) {
return res.status(400).json({ error: 'id mismatch' });
}
const saved = upsertMessage(db, req.params.cid, {
...m,
id: req.params.mid,
});
// Bump the parent project's updatedAt so the project list re-orders.
updateProject(db, req.params.id, {});
ctx.telemetry?.reportFinalizedMessage(saved, m);
res.json({ message: saved });
});
// ---- Preview comments ----------------------------------------------------
app.get('/api/projects/:id/conversations/:cid/comments', (req, res) => {
const conv = getConversation(db, req.params.cid);
if (!conv || conv.projectId !== req.params.id) {
return res.status(404).json({ error: 'conversation not found' });
}
res.json({
comments: listPreviewComments(db, req.params.id, req.params.cid),
});
});
app.post('/api/projects/:id/conversations/:cid/comments', (req, res) => {
const conv = getConversation(db, req.params.cid);
if (!conv || conv.projectId !== req.params.id) {
return res.status(404).json({ error: 'conversation not found' });
}
try {
const comment = upsertPreviewComment(
db,
req.params.id,
req.params.cid,
req.body || {},
);
updateProject(db, req.params.id, {});
res.json({ comment });
} catch (err: any) {
res.status(400).json({ error: String(err?.message || err) });
}
});
app.patch(
'/api/projects/:id/conversations/:cid/comments/:commentId',
(req, res) => {
const conv = getConversation(db, req.params.cid);
if (!conv || conv.projectId !== req.params.id) {
return res.status(404).json({ error: 'conversation not found' });
}
try {
const comment = updatePreviewCommentStatus(
db,
req.params.id,
req.params.cid,
req.params.commentId,
req.body?.status,
);
if (!comment)
return res.status(404).json({ error: 'comment not found' });
updateProject(db, req.params.id, {});
res.json({ comment });
} catch (err: any) {
res.status(400).json({ error: String(err?.message || err) });
}
},
);
app.delete(
'/api/projects/:id/conversations/:cid/comments/:commentId',
(req, res) => {
const conv = getConversation(db, req.params.cid);
if (!conv || conv.projectId !== req.params.id) {
return res.status(404).json({ error: 'conversation not found' });
}
const ok = deletePreviewComment(
db,
req.params.id,
req.params.cid,
req.params.commentId,
);
if (!ok) return res.status(404).json({ error: 'comment not found' });
updateProject(db, req.params.id, {});
res.json({ ok: true });
},
);
// ---- Tabs -----------------------------------------------------------------
app.get('/api/projects/:id/tabs', (req, res) => {
if (!getProject(db, req.params.id)) {
return res.status(404).json({ error: 'project not found' });
}
res.json(listTabs(db, req.params.id));
});
app.put('/api/projects/:id/tabs', (req, res) => {
if (!getProject(db, req.params.id)) {
return res.status(404).json({ error: 'project not found' });
}
const { tabs = [], active = null } = req.body || {};
if (!Array.isArray(tabs) || !tabs.every((t) => typeof t === 'string')) {
return res.status(400).json({ error: 'tabs must be string[]' });
}
const result = setTabs(
db,
req.params.id,
tabs,
typeof active === 'string' ? active : null,
);
res.json(result);
});
// ---- Templates ----------------------------------------------------------
// User-saved snapshots of a project's HTML files. Surfaced in the
// "From template" tab of the new-project panel so a user can spin up
// a fresh project pre-seeded with another project's design as a
// starting point. Created via the project's Share menu (snapshots
// every .html file in the project folder at the moment of save).
app.get('/api/templates', (_req, res) => {
res.json({ templates: listTemplates(db) });
});
app.get('/api/templates/:id', (req, res) => {
const t = getTemplate(db, req.params.id);
if (!t) return res.status(404).json({ error: 'not found' });
res.json({ template: t });
});
app.post('/api/templates', async (req, res) => {
try {
const { name, description, sourceProjectId } = req.body || {};
if (typeof name !== 'string' || !name.trim()) {
return res.status(400).json({ error: 'name required' });
}
if (name.length > 100) {
return res.status(400).json({ error: 'name must be 100 characters or fewer' });
}
if (typeof sourceProjectId !== 'string') {
return res.status(400).json({ error: 'sourceProjectId required' });
}
const sourceProject = getProject(db, sourceProjectId);
if (!sourceProject) {
return res.status(404).json({ error: 'source project not found' });
}
// Snapshot every HTML / sketch / text file in the source project.
// We deliberately skip binary uploads — templates are about the
// generated design, not the user's reference imagery.
const files = await listFiles(PROJECTS_DIR, sourceProjectId, {
metadata: sourceProject.metadata,
});
const snapshot = [];
for (const f of files) {
if (f.kind !== 'html' && f.kind !== 'text' && f.kind !== 'code')
continue;
const entry = await readProjectFile(
PROJECTS_DIR,
sourceProjectId,
f.name,
sourceProject.metadata,
);
if (entry && Buffer.isBuffer(entry.buffer)) {
snapshot.push({
name: f.name,
content: entry.buffer.toString('utf8'),
});
}
}
const trimmedName = name.trim();
const descValue = typeof description === 'string' ? description : null;
const existing = findTemplateByNameAndProject(db, trimmedName, sourceProjectId);
let t;
if (existing) {
t = updateTemplate(db, existing.id, {
description: descValue,
files: snapshot,
});
} else {
t = insertTemplate(db, {
id: randomId(),
name: trimmedName,
description: descValue,
sourceProjectId,
files: snapshot,
createdAt: Date.now(),
});
}
res.json({ template: t });
} catch (err: any) {
res.status(400).json({ error: String(err) });
}
});
app.delete('/api/templates/:id', (req, res) => {
deleteTemplate(db, req.params.id);
res.json({ ok: true });
});
}
export interface RegisterProjectArtifactRoutesDeps extends RouteDeps<'http' | 'uploads' | 'paths' | 'node' | 'artifacts'> {}
export function registerProjectArtifactRoutes(app: Express, ctx: RegisterProjectArtifactRoutesDeps) {
const { upload } = ctx.uploads;
const { ARTIFACTS_DIR } = ctx.paths;
const { path, fs } = ctx.node;
const { sanitizeSlug, lintArtifact, renderFindingsForAgent } = ctx.artifacts;
app.post('/api/upload', upload.array('images', 8), (req, res) => {
const files = ((req.files || []) as any[]).map((f: any) => ({
name: f.originalname,
path: f.path,
size: f.size,
}));
res.json({ files });
});
// Persist a generated artifact (HTML) to disk so the user can re-open it
// in their browser or hand it off. Returns the on-disk path + a served URL.
// The body is also passed through the anti-slop linter; findings are
// returned alongside the path so the UI can render a P0/P1 badge and the
// chat layer can splice them into a system reminder for the agent.
app.post('/api/artifacts/save', (req, res) => {
try {
const { identifier, title, html } = req.body || {};
if (typeof html !== 'string' || html.length === 0) {
return res.status(400).json({ error: 'html required' });
}
const stamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19);
const slug = sanitizeSlug(identifier || title || 'artifact');
const dir = path.join(ARTIFACTS_DIR, `${stamp}-${slug}`);
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, 'index.html');
fs.writeFileSync(file, html, 'utf8');
const findings = lintArtifact(html);
res.json({
path: file,
url: `/artifacts/${path.basename(dir)}/index.html`,
lint: findings,
});
} catch (err: any) {
res.status(500).json({ error: String(err) });
}
});
// Standalone lint endpoint — POST raw HTML, get findings back.
// The chat layer uses this to lint streamed-in artifacts without writing
// them to disk first, so a P0 issue can be surfaced before save.
app.post('/api/artifacts/lint', (req, res) => {
try {
const { html } = req.body || {};
if (typeof html !== 'string' || html.length === 0) {
return res.status(400).json({ error: 'html required' });
}
const findings = lintArtifact(html);
res.json({
findings,
agentMessage: renderFindingsForAgent(findings),
});
} catch (err: any) {
res.status(500).json({ error: String(err) });
}
});
}
export interface RegisterProjectFileRoutesDeps extends RouteDeps<'db' | 'http' | 'paths' | 'uploads' | 'node' | 'projectStore' | 'projectFiles' | 'documents' | 'artifacts'> {}
export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFileRoutesDeps) {
const { db } = ctx;
const { sendApiError, sendMulterError } = ctx.http;
const { PROJECTS_DIR } = ctx.paths;
const { upload } = ctx.uploads;
const { fs } = ctx.node;
const { getProject } = ctx.projectStore;
const { listFiles, searchProjectFiles, readProjectFile, resolveProjectDir, resolveProjectFilePath, parseByteRange, renameProjectFile, deleteProjectFile, writeProjectFile, sanitizeName, ensureProject } = ctx.projectFiles;
const { buildDocumentPreview } = ctx.documents;
const { validateArtifactManifestInput } = ctx.artifacts;
const projectPreviewIframeSandbox = 'allow-scripts allow-forms';
const projectPreviewCsp = [
`sandbox ${projectPreviewIframeSandbox}`,
"default-src 'self' data: blob:",
"img-src 'self' data: blob:",
"media-src 'self' data: blob:",
"font-src 'self' data:",
"style-src 'self' 'unsafe-inline'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"connect-src 'none'",
"form-action 'none'",
"base-uri 'none'",
"object-src 'none'",
].join('; ');
const previewScopeRe = /^[A-Za-z0-9_-]{8,128}$/u;
function setProjectPreviewHeaders(res: Response) {
res.setHeader('Cache-Control', 'no-store');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Security-Policy', projectPreviewCsp);
}
async function sendProjectFile(
req: any,
res: Response,
projectId: string,
relPath: string,
metadata?: unknown,
beforeSend?: (mime: string) => void,
transformFile?: (file: { mime: string; buffer: Buffer }) => Buffer | string,
) {
const meta = await resolveProjectFilePath(
PROJECTS_DIR,
projectId,
relPath,
metadata,
);
beforeSend?.(meta.mime);
if (meta.mime.startsWith('video/') || meta.mime.startsWith('audio/')) {
res.setHeader('Accept-Ranges', 'bytes');
res.setHeader('Content-Type', meta.mime);
if (meta.size === 0) {
res.setHeader('Content-Length', '0');
return res.status(200).end();
}
const range = parseByteRange(req.headers.range, meta.size);
if (range === 'unsatisfiable') {
res.setHeader('Content-Range', `bytes */${meta.size}`);
return res.status(416).end();
}
let start;
let end;
let statusCode;
if (range) {
({ start, end } = range);
statusCode = 206;
res.setHeader('Content-Range', `bytes ${start}-${end}/${meta.size}`);
res.setHeader('Content-Length', String(end - start + 1));
} else {
start = 0;
end = meta.size - 1;
statusCode = 200;
res.setHeader('Content-Length', String(meta.size));
}
res.status(statusCode);
const stream = fs.createReadStream(meta.filePath, { start, end });
stream.on('error', (streamErr: any) => {
if (!res.headersSent) {
sendApiError(res, 500, 'STREAM_ERROR', String(streamErr));
} else {
res.destroy(streamErr);
}
});
stream.pipe(res);
return;
}
const file = await readProjectFile(PROJECTS_DIR, projectId, relPath, metadata);
res.type(file.mime).send(transformFile ? transformFile(file) : file.buffer);
}
function previewFilePathForProject(project: any, queryFile: unknown): string {
if (typeof queryFile === 'string' && queryFile.trim().length > 0) {
return queryFile;
}
const entryFile = project?.metadata?.entryFile;
return typeof entryFile === 'string' && entryFile.length > 0 ? entryFile : 'index.html';
}
function encodeProjectPathForUrl(filePath: string): string {
return filePath.split('/').map((segment) => encodeURIComponent(segment)).join('/');
}
// Project files. Each project owns a flat folder under .od/projects/<id>/
// containing every file the user has uploaded, pasted, sketched, or that
// the agent has generated. Names are sanitized; paths are confined to the
// project's own folder (see apps/daemon/src/projects.ts).
app.get('/api/projects/:id/files', async (req, res) => {
try {
const since = Number(req.query?.since);
const project = getProject(db, req.params.id);
const files = await listFiles(PROJECTS_DIR, req.params.id, {
since: Number.isFinite(since) ? since : undefined,
metadata: project?.metadata,
});
/** @type {import('@open-design/contracts').ProjectFilesResponse} */
const body = { files };
res.json(body);
} catch (err: any) {
sendApiError(res, 400, 'BAD_REQUEST', String(err));
}
});
app.get('/api/projects/:id/search', async (req, res) => {
try {
const query = String(req.query.q ?? '');
if (!query) {
sendApiError(res, 400, 'BAD_REQUEST', 'q query parameter is required');
return;
}
const pattern = req.query.pattern ? String(req.query.pattern) : null;
const max = Math.min(Number(req.query.max) || 200, 1000);
const searchProject = getProject(db, req.params.id);
const matches = await searchProjectFiles(PROJECTS_DIR, req.params.id, query, {
pattern,
max,
metadata: searchProject?.metadata,
});
res.json({ query, matches });
} catch (err: any) {
sendApiError(res, 400, 'BAD_REQUEST', String(err));
}
});
app.get('/api/projects/:id/design-system-package-audit', async (req, res) => {
try {
const project = getProject(db, req.params.id);
if (!project) {
sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
return;
}
const projectRoot = resolveProjectDir(PROJECTS_DIR, project.id, project.metadata);
const audit = await auditDesignSystemPackage(projectRoot);
res.setHeader('Cache-Control', 'no-store');
res.json({ audit });
} catch (err: any) {
sendApiError(res, 400, 'BAD_REQUEST', String(err));
}
});
app.get('/api/projects/:id/preview-url', async (req, res) => {
try {
const project = getProject(db, req.params.id);
if (!project) {
sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
return;
}
const requestedPath = previewFilePathForProject(project, req.query.file);
const meta = await resolveProjectFilePath(
PROJECTS_DIR,
project.id,
requestedPath,
project.metadata,
);
const scope = randomUUID();
/** @type {import('@open-design/contracts').ProjectPreviewUrlResponse} */
const body = {
url: `/api/projects/${encodeURIComponent(project.id)}/preview/${scope}/${encodeProjectPathForUrl(meta.name)}`,
file: meta.name,
csp: projectPreviewCsp,
iframeSandbox: projectPreviewIframeSandbox,
opaqueOrigin: true,
};
res.setHeader('Cache-Control', 'no-store');
res.json(body);
} catch (err: any) {
const status = err && err.code === 'ENOENT' ? 404 : 400;
sendApiError(
res,
status,
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
String(err),
);
}
});
app.get(/^\/api\/projects\/([^/]+)\/preview\/([^/]+)\/(.+)$/u, async (req, res) => {
try {
const params = req.params as unknown as { 0?: string; 1?: string; 2?: string };
const projectId = String(params[0] ?? '');
const scope = String(params[1] ?? '');
const relPath = String(params[2] ?? '');
if (!previewScopeRe.test(scope)) {
sendApiError(res, 400, 'BAD_REQUEST', 'invalid preview scope');
return;
}
const project = getProject(db, projectId);
if (!project) {
sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
return;
}
if (req.headers.origin === 'null') {
res.header('Access-Control-Allow-Origin', '*');
}
await sendProjectFile(
req,
res,
project.id,
relPath,
project.metadata,
() => setProjectPreviewHeaders(res),
);
} catch (err: any) {
const status = err && err.code === 'ENOENT' ? 404 : 400;
sendApiError(
res,
status,
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
String(err),
);
}
});
// Preflight for the raw file route. Current artifact fetches are simple GETs
// (no preflight needed), but an explicit handler future-proofs the route if
// artifacts ever add custom request headers.
app.options(/^\/api\/projects\/([^/]+)\/raw\/(.+)$/u, (req, res) => {
if (req.headers.origin === 'null') {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET');
res.header('Access-Control-Allow-Headers', 'Content-Type');
}
res.sendStatus(204);
});
app.get(/^\/api\/projects\/([^/]+)\/raw\/(.+)$/u, async (req, res) => {
try {
const params = req.params as unknown as { 0?: string; 1?: string };
const projectId = String(params[0] ?? '');
const relPath = String(params[1] ?? '');
const project = getProject(db, projectId);
// PreviewModal loads artifact HTML via srcdoc, giving the iframe Origin: "null".
// data: URIs, file://, and some sandboxed iframes also send null — all are
// local-only callers, so this is safe. Real cross-origin sites send a real
// origin and remain blocked by the browser's same-origin policy.
if (req.headers.origin === 'null') {
res.header('Access-Control-Allow-Origin', '*');
}
await sendProjectFile(
req,
res,
projectId,
relPath,
project?.metadata,
undefined,
(file) => {
if (
wantsUrlPreviewScrollBridge(req.query.odPreviewBridge) &&
/^text\/html(?:;|$)/i.test(file.mime)
) {
return injectUrlPreviewScrollBridge(file.buffer.toString('utf8'));
}
return file.buffer;
},
);
} catch (err: any) {
const status = err && err.code === 'ENOENT' ? 404 : 400;
sendApiError(
res,
status,
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
String(err),
);
}
});
app.delete(/^\/api\/projects\/([^/]+)\/raw\/(.+)$/u, async (req, res) => {
try {
const params = req.params as unknown as { 0?: string; 1?: string };
const projectId = String(params[0] ?? '');
const rawSplat = String(params[1] ?? '');
const project = getProject(db, projectId);
await deleteProjectFile(PROJECTS_DIR, projectId, rawSplat, project?.metadata);
/** @type {import('@open-design/contracts').DeleteProjectFileResponse} */
const body = { ok: true };
res.json(body);
} catch (err: any) {
const status = err && err.code === 'ENOENT' ? 404 : 400;
sendApiError(
res,
status,
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
String(err),
);
}
});
app.get('/api/projects/:id/files/:name/preview', async (req, res) => {
try {
const project = getProject(db, req.params.id);
const file = await readProjectFile(
PROJECTS_DIR,
req.params.id,
req.params.name,
project?.metadata,
);
const preview = await buildDocumentPreview(file);
res.json(preview);
} catch (err: any) {
const status =
err && err.statusCode
? err.statusCode
: err && err.code === 'ENOENT'
? 404
: 400;
sendApiError(
res,
status,
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
err?.message || 'preview unavailable',
);
}
});
app.get(/^\/api\/projects\/([^/]+)\/files\/(.+)$/u, async (req, res) => {
try {
const params = req.params as unknown as { 0?: string; 1?: string };
const projectId = String(params[0] ?? '');
const fileSplat = String(params[1] ?? '');
const project = getProject(db, projectId);
const file = await readProjectFile(
PROJECTS_DIR,
projectId,
fileSplat,
project?.metadata,
);
res.type(file.mime).send(file.buffer);
} catch (err: any) {
const status = err && err.code === 'ENOENT' ? 404 : 400;
sendApiError(
res,
status,
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
String(err),
);
}
});
// Two ways to upload: multipart for binary files (images), and JSON
// {name, content, encoding} for sketches and pasted text. The frontend
// uses both depending on the file source.
app.post(
'/api/projects/:id/files',
(req, res, next) => {
upload.single('file')(req, res, (err: any) => {
if (err) return sendMulterError(res, err);
next();
});
},
async (req, res) => {
try {
const uploadProject = getProject(db, req.params.id);
await ensureProject(PROJECTS_DIR, req.params.id, uploadProject?.metadata);
if (req.file) {
const buf = await fs.promises.readFile(req.file.path);
const desiredName = sanitizeName(
req.body?.name || req.file.originalname,
);
const meta = await writeProjectFile(
PROJECTS_DIR,
req.params.id,
desiredName,
buf,
{},
uploadProject?.metadata,
);
fs.promises.unlink(req.file.path).catch(() => {});
/** @type {import('@open-design/contracts').ProjectFileResponse} */
const body = { file: meta };
return res.json(body);
}
const { name, content, encoding, artifactManifest, artifact, overwrite } = req.body || {};
if (typeof name !== 'string' || typeof content !== 'string') {
return sendApiError(
res,
400,
'BAD_REQUEST',
'name and content required',
);
}
if (artifactManifest !== undefined && artifactManifest !== null) {
const validated = validateArtifactManifestInput(
artifactManifest,
name,
);
if (!validated.ok) {
return sendApiError(
res,
400,
'BAD_REQUEST',
`invalid artifactManifest: ${validated.error}`,
);
}
}
const buf =
encoding === 'base64'
? Buffer.from(content, 'base64')
: Buffer.from(content, 'utf8');
const meta = artifact === true
? await createProjectArtifactFile({
projectsRoot: PROJECTS_DIR,
projectId: req.params.id,
input: { name, content, encoding, artifactManifest },
metadata: uploadProject?.metadata,
writeProjectFile,
})
: await writeProjectFile(
PROJECTS_DIR,
req.params.id,
name,
buf,
{
artifactManifest,
...(overwrite === false ? { overwrite: false } : {}),
},
uploadProject?.metadata,
);
/** @type {import('@open-design/contracts').ProjectFileResponse} */
const body = { file: meta };
res.json(body);
} catch (err: any) {
if (err instanceof ArtifactRegressionError) {
return sendApiError(res, 422, 'ARTIFACT_REGRESSION', err.message, {
details: {
identifier: err.identifier,
newSize: err.newSize,
priorSize: err.priorSize,
priorName: err.priorName,
},
});
}
if (err instanceof ArtifactPublicationBlockedError) {
return sendApiError(res, 422, 'ARTIFACT_PUBLICATION_BLOCKED', err.message, {
details: { placeholders: err.placeholders },
});
}
if (err?.code === 'EEXIST') {
return sendApiError(res, 409, 'FILE_EXISTS', 'file already exists');
}
if (err?.code === 'ARTIFACT_MANIFEST_REQUIRED') {
return sendApiError(res, 400, 'ARTIFACT_MANIFEST_REQUIRED', err.message);
}
if (err?.code === 'ARTIFACT_MANIFEST_INVALID') {
return sendApiError(res, 400, 'BAD_REQUEST', err.message);
}
sendApiError(res, 500, 'INTERNAL_ERROR', 'upload failed');
}
},
);
app.post('/api/projects/:id/files/rename', async (req, res) => {
try {
const { from, to } = req.body || {};
if (typeof from !== 'string' || typeof to !== 'string') {
return sendApiError(res, 400, 'BAD_REQUEST', 'from and to required');
}
const project = getProject(db, req.params.id);
const result = await renameProjectFile(
PROJECTS_DIR,
req.params.id,
from,
to,
project?.metadata,
);
/** @type {import('@open-design/contracts').RenameProjectFileResponse} */
const body = result;
res.json(body);
} catch (err: any) {
if (err?.code === 'EEXIST') {
return sendApiError(res, 409, 'CONFLICT', String(err?.message || err));
}
const message = String(err?.message || err);
if (err?.code === 'ENOENT' || message.includes('ENOENT') || message.includes('no such file or directory')) {
return sendApiError(res, 404, 'FILE_NOT_FOUND', message);
}
sendApiError(res, 400, 'BAD_REQUEST', message);
}
});
app.delete('/api/projects/:id/files/:name', async (req, res) => {
try {
const delProject = getProject(db, req.params.id);
await deleteProjectFile(PROJECTS_DIR, req.params.id, req.params.name, delProject?.metadata);
/** @type {import('@open-design/contracts').DeleteProjectFileResponse} */
const body = { ok: true };
res.json(body);
} catch (err: any) {
const status = err && err.code === 'ENOENT' ? 404 : 400;
sendApiError(
res,
status,
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
String(err),
);
}
});
}
export interface RegisterProjectUploadRoutesDeps extends RouteDeps<'http' | 'uploads' | 'node'> {}
export function registerProjectUploadRoutes(app: Express, ctx: RegisterProjectUploadRoutesDeps) {
const { sendApiError } = ctx.http;
const { handleProjectUpload } = ctx.uploads;
const { fs } = ctx.node;
app.post(
'/api/projects/:id/upload',
handleProjectUpload,
async (req, res) => {
try {
const incoming = Array.isArray(req.files) ? req.files : [];
const out = [];
for (const f of incoming) {
try {
const stat = await fs.promises.stat(f.path);
out.push({
name: f.filename,
path: f.filename,
size: stat.size,
mtime: stat.mtimeMs,
originalName: f.originalname,
});
} catch {
// skip files that vanished mid-flight
}
}
/** @type {import('@open-design/contracts').UploadProjectFilesResponse} */
const body = { files: out };
res.json(body);
} catch (err: any) {
sendApiError(res, 500, 'INTERNAL_ERROR', 'upload failed');
}
},
);
}