mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* Add Claude-style design system workflow * Merge design system workflow into main * Restore design system workflow UI styles * Fix design system setup scrolling * Fix design system setup connector button * Preserve connector auth link after popup block * Simplify connected GitHub setup state * Open generated design system workspace project * Summarize design system auto prompt in chat * Add bounded GitHub connector design intake * Prefer path-scoped GitHub intake tools * Restore branch GitHub design context intake * Restore design system review workspace * Restore design system manager tab * Let design system workflow routes own details * Open editable design systems as projects * Restore design system workspace coverage * Fix bounded GitHub connector intake * Hide design system review while generating * Suppress design system generation questions * Constrain GitHub design intake to bounded command * Tolerate oversized GitHub metadata during intake * Rebuild daemon CLI when sources change * Fallback when GitHub connector snapshots are rate limited * Allow GitHub intake without Composio * Use native GitHub auth for design intake * Remove design system review group heading * Improve design system extraction evidence * Align design system scaffold with Claude output * Add evidence inventory for design system intake * Add local design system evidence intake * Add design system package audit gate * Allow auditing Claude Design reference packages * Audit design system package content quality * Migrate legacy design system artifacts * Clean migrated design system artifacts * Require modular design system UI kits * Reject thin design system UI kits * Prioritize core design evidence intake * Require role-based design system UI kits * Clean stale design system manifest references * Require representative preserved design assets * Warn on generic design system visuals * Enforce design system quality warnings * Audit connected design system UI kits * Require mounted design system UI kits * Require composed design system app shells * Require runnable JSX design system kits * Require browser globals for design system components * Infer design system names from source URLs * Require source examples in design system packages * Bind preserved fonts in design system tokens * Require skill frontmatter in design system packages * Preserve build icons in design system packages * Require real assets in brand previews * Require substantive source examples * Require product overview in design system README * Require reusable UI kit README * Require reusable design system skill docs * Seed Claude-style UI kit entry contract * Preserve runtime build assets in design packages * Audit design system packages after generation * Audit design system first-run output * Audit source-backed preview cards * Align design system UI kit scaffolds * Materialize design evidence package artifacts * Show project chat during design system setup * Hand off design system setup to project chat * Auto-repair design system audit failures * Harden design system evidence preservation * Tighten design system package guidance * Add targeted design system repair guidance * Bound design system audit auto repair * Use connector statuses in design system setup * Audit design system preview manifests * Require README preview manifests for design systems * Fix design system GitHub intake handoff * Fix daemon prompt CI assertions
692 lines
26 KiB
TypeScript
692 lines
26 KiB
TypeScript
import type { Express } from 'express';
|
|
import type { RouteDeps } from './server-context.js';
|
|
import {
|
|
InlineAssetsLimitError,
|
|
MAX_INLINE_OWNER_BYTES,
|
|
inlineRelativeAssets,
|
|
type InlineAssetReader,
|
|
} from './inline-assets.js';
|
|
|
|
export interface RegisterImportRoutesDeps extends RouteDeps<'db' | 'http' | 'uploads' | 'node' | 'ids' | 'paths' | 'imports' | 'auth' | 'projectStore' | 'conversations' | 'projectFiles' | 'validation'> {}
|
|
|
|
export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps) {
|
|
const { db } = ctx;
|
|
const { sendApiError } = ctx.http;
|
|
const { importUpload } = ctx.uploads;
|
|
const { fs, path } = ctx.node;
|
|
const { randomId } = ctx.ids;
|
|
const { PROJECTS_DIR, RUNTIME_DATA_DIR_CANONICAL } = ctx.paths;
|
|
const { importClaudeDesignZip, projectDir, detectEntryFile } = ctx.imports;
|
|
const {
|
|
consumedImportNonces,
|
|
desktopAuthSecret,
|
|
isDesktopAuthGateActive,
|
|
pruneExpiredImportNonces,
|
|
verifyDesktopImportToken,
|
|
} = ctx.auth;
|
|
const { insertProject } = ctx.projectStore;
|
|
const { insertConversation } = ctx.conversations;
|
|
const { setTabs } = ctx.projectFiles;
|
|
const { validateProjectDesignSystemId } = ctx.validation;
|
|
app.post(
|
|
'/api/import/claude-design',
|
|
importUpload.single('file'),
|
|
async (req, res) => {
|
|
try {
|
|
if (!req.file)
|
|
return res.status(400).json({ error: 'zip file required' });
|
|
const originalName =
|
|
req.file.originalname || 'Claude Design export.zip';
|
|
if (!/\.zip$/i.test(originalName)) {
|
|
fs.promises.unlink(req.file.path).catch(() => {});
|
|
return res.status(400).json({ error: 'expected a .zip file' });
|
|
}
|
|
const id = randomId();
|
|
const now = Date.now();
|
|
const baseName =
|
|
originalName.replace(/\.zip$/i, '').trim() || 'Claude Design import';
|
|
const imported = await importClaudeDesignZip(
|
|
req.file.path,
|
|
projectDir(PROJECTS_DIR, id),
|
|
);
|
|
fs.promises.unlink(req.file.path).catch(() => {});
|
|
|
|
const project = insertProject(db, {
|
|
id,
|
|
name: baseName,
|
|
skillId: null,
|
|
designSystemId: null,
|
|
pendingPrompt: `Imported from Claude Design ZIP: ${originalName}. Continue editing ${imported.entryFile}.`,
|
|
metadata: {
|
|
kind: 'prototype',
|
|
importedFrom: 'claude-design',
|
|
entryFile: imported.entryFile,
|
|
sourceFileName: originalName,
|
|
},
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
const cid = randomId();
|
|
insertConversation(db, {
|
|
id: cid,
|
|
projectId: id,
|
|
title: 'Imported Claude Design project',
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
setTabs(db, id, [imported.entryFile], imported.entryFile);
|
|
res.json({
|
|
project,
|
|
conversationId: cid,
|
|
entryFile: imported.entryFile,
|
|
files: imported.files,
|
|
});
|
|
} catch (err: any) {
|
|
if (req.file?.path) fs.promises.unlink(req.file.path).catch(() => {});
|
|
res.status(400).json({ error: String(err) });
|
|
}
|
|
},
|
|
);
|
|
|
|
// Import an existing local folder as a project. The user picks a folder
|
|
// and OD works inside it directly: every write goes to metadata.baseDir.
|
|
// No copy, no shadow tree — the user owns the workspace and is
|
|
// responsible for their own version control (git, time machine, etc.),
|
|
// mirroring how Cursor / Claude Code / Aider behave.
|
|
app.post('/api/import/folder', async (req, res) => {
|
|
try {
|
|
const { baseDir, name, skillId, designSystemId } = req.body || {};
|
|
if (typeof baseDir !== 'string' || !baseDir.trim()) {
|
|
return sendApiError(res, 400, 'BAD_REQUEST', 'baseDir required');
|
|
}
|
|
let trustedPickerImport = false;
|
|
if (isDesktopAuthGateActive()) {
|
|
const secret = desktopAuthSecret();
|
|
if (secret == null) {
|
|
return sendApiError(
|
|
res,
|
|
503,
|
|
'DESKTOP_AUTH_PENDING',
|
|
'desktop auth required but secret not yet registered',
|
|
{
|
|
details: { hint: 'restart desktop or wait for sidecar registration' },
|
|
retryable: true,
|
|
},
|
|
);
|
|
}
|
|
const headerValue = req.get('x-od-desktop-import-token');
|
|
const token = typeof headerValue === 'string' ? headerValue : '';
|
|
const now = Date.now();
|
|
pruneExpiredImportNonces(now);
|
|
const verification = verifyDesktopImportToken(
|
|
secret,
|
|
baseDir,
|
|
token,
|
|
now,
|
|
consumedImportNonces,
|
|
);
|
|
if (!verification.ok) {
|
|
return sendApiError(
|
|
res,
|
|
403,
|
|
'FORBIDDEN',
|
|
'desktop import token rejected',
|
|
{ details: { reason: verification.reason } },
|
|
);
|
|
}
|
|
consumedImportNonces.set(verification.nonce, verification.exp);
|
|
trustedPickerImport = true;
|
|
}
|
|
const trimmedInput = baseDir.trim();
|
|
if (!path.isAbsolute(path.normalize(trimmedInput))) {
|
|
return sendApiError(res, 400, 'BAD_REQUEST', 'baseDir must be absolute');
|
|
}
|
|
// Resolve symlinks once at import and persist the canonical path.
|
|
// Without this, a user-controlled symlink (e.g. ~/sneaky → /etc) at
|
|
// baseDir would let writeProjectFile escape the project sandbox at
|
|
// every later call: resolveSafe checks the *literal* baseDir, but
|
|
// the OS follows the symlink at write time. realpath() collapses
|
|
// the chain so the stored baseDir == what the kernel will write to.
|
|
let normalizedPath: string;
|
|
try {
|
|
normalizedPath = await fs.promises.realpath(trimmedInput);
|
|
} catch {
|
|
return sendApiError(res, 400, 'BAD_REQUEST', 'folder not found');
|
|
}
|
|
// realpath resolved → lstat the canonical path to ensure it's a
|
|
// real directory, not another symlink (defense-in-depth).
|
|
let dirStat;
|
|
try {
|
|
dirStat = await fs.promises.lstat(normalizedPath);
|
|
} catch {
|
|
return sendApiError(res, 400, 'BAD_REQUEST', 'folder not found');
|
|
}
|
|
if (!dirStat.isDirectory()) {
|
|
return sendApiError(res, 400, 'BAD_REQUEST', 'path must be a directory');
|
|
}
|
|
if (path.parse(normalizedPath).root === normalizedPath) {
|
|
return sendApiError(res, 400, 'BAD_REQUEST', 'cannot import the filesystem root');
|
|
}
|
|
// Prevent importing the data directory into itself (post-realpath so
|
|
// a symlink pointing into RUNTIME_DATA_DIR is also caught). Compare
|
|
// against the canonical alias because `normalizedPath` is the import
|
|
// folder's realpath; on macOS the data dir at /var/... resolves to
|
|
// /private/var/... and would never start-with the user-shaped path.
|
|
if (
|
|
normalizedPath === RUNTIME_DATA_DIR_CANONICAL ||
|
|
normalizedPath.startsWith(RUNTIME_DATA_DIR_CANONICAL + path.sep)
|
|
) {
|
|
return sendApiError(res, 400, 'BAD_REQUEST', 'cannot import the data directory');
|
|
}
|
|
|
|
const id = randomId();
|
|
const now = Date.now();
|
|
const projectName =
|
|
typeof name === 'string' && name.trim()
|
|
? name.trim()
|
|
: path.basename(normalizedPath);
|
|
const entryFile = await detectEntryFile(normalizedPath);
|
|
const designSystemValidation = await validateProjectDesignSystemId(designSystemId);
|
|
if (!designSystemValidation.ok) {
|
|
return sendApiError(
|
|
res,
|
|
400,
|
|
designSystemValidation.code,
|
|
designSystemValidation.message,
|
|
);
|
|
}
|
|
|
|
const project = insertProject(db, {
|
|
id,
|
|
name: projectName,
|
|
skillId: skillId ?? null,
|
|
designSystemId: designSystemValidation.id,
|
|
pendingPrompt: null,
|
|
metadata: {
|
|
kind: 'prototype',
|
|
baseDir: normalizedPath,
|
|
importedFrom: 'folder',
|
|
entryFile,
|
|
...(trustedPickerImport ? { fromTrustedPicker: true as const } : {}),
|
|
},
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
|
|
const cid = randomId();
|
|
insertConversation(db, {
|
|
id: cid,
|
|
projectId: id,
|
|
title: `Imported from ${projectName}`,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
if (entryFile) setTabs(db, id, [entryFile], entryFile);
|
|
/** @type {import('@open-design/contracts').ImportFolderResponse} */
|
|
const body = { project, conversationId: cid, entryFile };
|
|
res.json(body);
|
|
} catch (err: any) {
|
|
sendApiError(res, 400, 'BAD_REQUEST', String(err));
|
|
}
|
|
});
|
|
|
|
}
|
|
|
|
export interface RegisterProjectExportRoutesDeps extends RouteDeps<'db' | 'http' | 'paths' | 'projectStore' | 'exports' | 'projectFiles' | 'validation'> {}
|
|
|
|
export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectExportRoutesDeps) {
|
|
const { db } = ctx;
|
|
const { sendApiError } = ctx.http;
|
|
const { PROJECTS_DIR } = ctx.paths;
|
|
const { getProject } = ctx.projectStore;
|
|
const { readProjectFile, resolveProjectFilePath } = ctx.projectFiles;
|
|
const { isSafeId } = ctx.validation;
|
|
const {
|
|
buildProjectArchive,
|
|
buildBatchArchive,
|
|
buildDesktopPdfExportInput,
|
|
desktopPdfExporter,
|
|
daemonUrlRef,
|
|
sanitizeArchiveFilename,
|
|
} = ctx.exports;
|
|
// Streams a ZIP of the project's on-disk tree so the "Download as .zip"
|
|
// share menu can hand the user the actual files they uploaded — e.g. the
|
|
// imported `ui-design/` folder — instead of a one-file snapshot of the
|
|
// rendered HTML. `root` scopes the archive to a subdirectory; without
|
|
// it, the whole project is packed.
|
|
app.get('/api/projects/:id/archive', async (req, res) => {
|
|
try {
|
|
const root = typeof req.query?.root === 'string' ? req.query.root : '';
|
|
const project = getProject(db, req.params.id);
|
|
const { buffer, baseName } = await buildProjectArchive(
|
|
PROJECTS_DIR,
|
|
req.params.id,
|
|
root,
|
|
project?.metadata,
|
|
);
|
|
const fallbackName = project?.name || req.params.id;
|
|
const fileSlug = sanitizeArchiveFilename(baseName || fallbackName) || 'project';
|
|
const filename = `${fileSlug}.zip`;
|
|
// RFC 5987 dance: legacy `filename=` carries an ASCII fallback, while
|
|
// `filename*=UTF-8''…` lets modern browsers pick up project names
|
|
// with non-ASCII characters (accents, CJK, etc.) without mojibake.
|
|
const asciiFallback =
|
|
filename.replace(/[^\x20-\x7e]/g, '_').replace(/"/g, '_') || 'project.zip';
|
|
res.setHeader('Content-Type', 'application/zip');
|
|
res.setHeader(
|
|
'Content-Disposition',
|
|
`attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`,
|
|
);
|
|
res.send(buffer);
|
|
} catch (err: any) {
|
|
const code = err && err.code;
|
|
const status = code === 'ENOENT' || code === 'ENOTDIR' ? 404 : 400;
|
|
sendApiError(
|
|
res,
|
|
status,
|
|
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
|
|
String(err?.message || err),
|
|
);
|
|
}
|
|
});
|
|
|
|
// Batch archive: accepts a list of file names and returns a ZIP of just
|
|
// those files. Used by the Design Files panel multi-select download.
|
|
app.post('/api/projects/:id/archive/batch', async (req, res) => {
|
|
try {
|
|
const { files } = req.body || {};
|
|
if (!Array.isArray(files) || files.length === 0) {
|
|
sendApiError(res, 400, 'BAD_REQUEST', 'files must be a non-empty array');
|
|
return;
|
|
}
|
|
const project = getProject(db, req.params.id);
|
|
const { buffer } = await buildBatchArchive(
|
|
PROJECTS_DIR,
|
|
req.params.id,
|
|
files,
|
|
project?.metadata,
|
|
);
|
|
const fileSlug = sanitizeArchiveFilename(project?.name || req.params.id) || 'project';
|
|
const filename = `${fileSlug}.zip`;
|
|
const asciiFallback =
|
|
filename.replace(/[^\x20-\x7e]/g, '_').replace(/"/g, '_') || 'project.zip';
|
|
res.setHeader('Content-Type', 'application/zip');
|
|
res.setHeader(
|
|
'Content-Disposition',
|
|
`attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`,
|
|
);
|
|
res.send(buffer);
|
|
} catch (err: any) {
|
|
const code = err && err.code;
|
|
const status = code === 'ENOENT' ? 404 : 400;
|
|
sendApiError(
|
|
res,
|
|
status,
|
|
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
|
|
String(err?.message || err),
|
|
);
|
|
}
|
|
});
|
|
|
|
app.post('/api/projects/:id/export/pdf', async (req, res) => {
|
|
if (typeof desktopPdfExporter !== 'function') {
|
|
return sendApiError(
|
|
res,
|
|
501,
|
|
'UPSTREAM_UNAVAILABLE',
|
|
'desktop PDF export is only available in the desktop runtime',
|
|
);
|
|
}
|
|
try {
|
|
const { fileName, title, deck } = req.body || {};
|
|
if (typeof fileName !== 'string' || fileName.length === 0) {
|
|
return sendApiError(res, 400, 'BAD_REQUEST', 'fileName required');
|
|
}
|
|
const input = await buildDesktopPdfExportInput({
|
|
daemonUrl: daemonUrlRef.current,
|
|
deck: deck === true,
|
|
fileName,
|
|
projectId: req.params.id,
|
|
projectsRoot: PROJECTS_DIR,
|
|
title: typeof title === 'string' ? title : undefined,
|
|
});
|
|
const result = await desktopPdfExporter(input);
|
|
res.json(result);
|
|
} catch (err: any) {
|
|
const status = err && err.code === 'ENOENT' ? 404 : 400;
|
|
sendApiError(
|
|
res,
|
|
status,
|
|
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
|
|
String(err?.message || err),
|
|
);
|
|
}
|
|
});
|
|
|
|
// Export endpoint: serves an HTML body with every same-project
|
|
// top-level `<link rel=stylesheet>` / `<script src>` inlined.
|
|
// Counterpart to GET /api/projects/:id/raw/* — that route stays
|
|
// URL-load (one request per asset; FileViewer's default since
|
|
// PR #384). This route exists for explicit "Inline top-level
|
|
// CSS/JS" exports + the screenshot path where the headless browser
|
|
// fetches the response and renders it.
|
|
//
|
|
// Scope is intentionally narrow: only `<link rel=stylesheet>` and
|
|
// `<script src>` are rewritten. `<img src>`, CSS `url(...)` refs,
|
|
// `@import`, ES module imports, font sources, and similar remain
|
|
// external in the response — see the docstring on
|
|
// `apps/daemon/src/inline-assets.ts` for the full not-rewritten list
|
|
// and rationale. A fully offline "self-contained" export with image
|
|
// and font bundling would be a follow-up issue.
|
|
//
|
|
// Null-origin (sandboxed iframe srcdoc) callers are intentionally
|
|
// NOT supported — the only consumers are the daemon UI (same-origin)
|
|
// and server-side screenshot tooling (no Origin header). The
|
|
// response also carries `Content-Security-Policy: sandbox
|
|
// allow-scripts` so top-level browser navigation (no Origin header,
|
|
// would otherwise pass the daemon middleware) cannot escalate to
|
|
// daemon-origin privileges through script execution.
|
|
//
|
|
// See nexu-io/open-design#368 and the architecture lock at
|
|
// https://github.com/nexu-io/open-design/issues/368#issuecomment-4366243218.
|
|
app.get('/api/projects/:id/export/*', async (req, res) => {
|
|
try {
|
|
if (!isSafeId(req.params.id)) {
|
|
return sendApiError(res, 400, 'BAD_REQUEST', 'invalid project id');
|
|
}
|
|
|
|
const inlineRaw =
|
|
typeof req.query.inline === 'string' ? req.query.inline.trim().toLowerCase() : '';
|
|
if (!['1', 'true', 'yes', 'on'].includes(inlineRaw)) {
|
|
return sendApiError(
|
|
res,
|
|
400,
|
|
'BAD_REQUEST',
|
|
"query parameter 'inline=1' is required",
|
|
);
|
|
}
|
|
|
|
const project = getProject(db, req.params.id);
|
|
const relPath = (req.params as any)[0];
|
|
|
|
// PR #1312 round-5 (lefarcen P2): stat the owner file BEFORE
|
|
// readProjectFile so a 100 MiB owner HTML is rejected after a
|
|
// cheap stat() call, not after a 100 MiB readFile() into memory.
|
|
// The size check + mime check both run pre-buffer here, mirroring
|
|
// the sibling-asset stat-then-read contract round 4 already
|
|
// applied via AssetHandle. Size fires before mime so an oversize
|
|
// non-HTML file returns 413 (not 415) — that ordering is the
|
|
// observable Red→Green for this round.
|
|
//
|
|
// The helper's ownerBytes check (inline-assets.ts:127-133) stays
|
|
// as defense-in-depth: it still catches direct in-process callers
|
|
// that skip the route and any future drift in the size reported
|
|
// by stat vs the bytes actually returned by readFile.
|
|
let ownerMeta;
|
|
try {
|
|
ownerMeta = await resolveProjectFilePath(
|
|
PROJECTS_DIR,
|
|
req.params.id,
|
|
relPath,
|
|
project?.metadata,
|
|
);
|
|
} catch (err: any) {
|
|
const status = err && err.code === 'ENOENT' ? 404 : 400;
|
|
return sendApiError(
|
|
res,
|
|
status,
|
|
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
|
|
String(err),
|
|
);
|
|
}
|
|
|
|
if (ownerMeta.size > MAX_INLINE_OWNER_BYTES) {
|
|
return sendApiError(
|
|
res,
|
|
413,
|
|
'PAYLOAD_TOO_LARGE',
|
|
`owner html ${ownerMeta.size} bytes exceeds MAX_INLINE_OWNER_BYTES ${MAX_INLINE_OWNER_BYTES}`,
|
|
);
|
|
}
|
|
|
|
if (!ownerMeta.mime.startsWith('text/html')) {
|
|
return sendApiError(
|
|
res,
|
|
415,
|
|
'UNSUPPORTED_MEDIA_TYPE',
|
|
'export endpoint only supports HTML files',
|
|
);
|
|
}
|
|
|
|
let file;
|
|
try {
|
|
file = await readProjectFile(PROJECTS_DIR, req.params.id, relPath, project?.metadata);
|
|
} catch (err: any) {
|
|
const status = err && err.code === 'ENOENT' ? 404 : 400;
|
|
return sendApiError(
|
|
res,
|
|
status,
|
|
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
|
|
String(err),
|
|
);
|
|
}
|
|
|
|
// PR #1312 round-4 (lefarcen P2): stat first, then read. This
|
|
// lets the helper short-circuit on maxAssetBytes / maxTotalBytes
|
|
// BEFORE the buffer is materialized into memory. A 100 MiB
|
|
// sibling file is rejected after the cheap stat call, not after
|
|
// a 100 MiB readFile.
|
|
const fileReader: InlineAssetReader = async (sibling) => {
|
|
let meta;
|
|
try {
|
|
meta = await resolveProjectFilePath(
|
|
PROJECTS_DIR,
|
|
req.params.id,
|
|
sibling,
|
|
project?.metadata,
|
|
);
|
|
} catch {
|
|
return null;
|
|
}
|
|
return {
|
|
size: meta.size,
|
|
read: async () => {
|
|
try {
|
|
const siblingFile = await readProjectFile(
|
|
PROJECTS_DIR,
|
|
req.params.id,
|
|
sibling,
|
|
project?.metadata,
|
|
);
|
|
return siblingFile.buffer.toString('utf8');
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
};
|
|
};
|
|
|
|
const rendered = await inlineRelativeAssets(
|
|
file.buffer.toString('utf8'),
|
|
relPath,
|
|
fileReader,
|
|
);
|
|
// PR #1312 round-2 (lefarcen P2): top-level browser navigation to
|
|
// this URL sends no Origin header, so the /api middleware lets it
|
|
// through. Without a CSP, any JS in the exported document would
|
|
// run at daemon origin with access to /api/, cookies, localStorage,
|
|
// etc. `sandbox allow-scripts` treats the response like a sandboxed
|
|
// iframe with an opaque origin — scripts execute (that's the point
|
|
// of inlining JS for screenshot tooling), but cannot read cookies,
|
|
// hit /api/, or escalate to daemon-origin privileges.
|
|
res.setHeader('Content-Security-Policy', 'sandbox allow-scripts');
|
|
res.type('text/html').send(rendered);
|
|
} catch (err: any) {
|
|
// PR #1312 round-3 (lefarcen P2): the inliner's cap-enforcement
|
|
// throws InlineAssetsLimitError when the owner HTML, candidate
|
|
// count, or assembled output exceeds the module-level limits.
|
|
// Map every such throw to a 413 PAYLOAD_TOO_LARGE envelope so
|
|
// callers see a structured error rather than a generic 400.
|
|
if (err instanceof InlineAssetsLimitError || err?.name === 'InlineAssetsLimitError') {
|
|
return sendApiError(res, 413, 'PAYLOAD_TOO_LARGE', String(err));
|
|
}
|
|
sendApiError(res, 400, 'BAD_REQUEST', String(err));
|
|
}
|
|
});
|
|
|
|
}
|
|
|
|
export interface RegisterFinalizeRoutesDeps extends RouteDeps<'db' | 'http' | 'paths' | 'projectStore' | 'validation' | 'finalize'> {}
|
|
|
|
export function registerFinalizeRoutes(app: Express, ctx: RegisterFinalizeRoutesDeps) {
|
|
const { db } = ctx;
|
|
const { sendApiError } = ctx.http;
|
|
const { PROJECTS_DIR, DESIGN_SYSTEMS_DIR } = ctx.paths;
|
|
const { getProject } = ctx.projectStore;
|
|
const { isSafeId, validateExternalApiBaseUrl } = ctx.validation;
|
|
const {
|
|
defaultBaseUrlForFinalizeProtocol,
|
|
finalizeDesignPackage,
|
|
FinalizePackageLockedError,
|
|
FinalizeUpstreamError,
|
|
isFinalizeProviderProtocol,
|
|
redactSecrets,
|
|
} = ctx.finalize;
|
|
app.post('/api/projects/:id/finalize/:provider', async (req, res) => {
|
|
const { apiKey, baseUrl, model, maxTokens, apiVersion, protocol: bodyProtocol } = req.body || {};
|
|
try {
|
|
// Centralized path-traversal guard. `isSafeId` (apps/daemon/src/projects.ts)
|
|
// rejects pure-dot ids (`.`, `..`, etc.) which would otherwise pass
|
|
// the char-class regex and resolve to the parent directory under
|
|
// path.join. Express decodes percent-encoded `%2e%2e` to `..` before
|
|
// we see it, so this check covers both URL-supplied and stored-row
|
|
// attack vectors.
|
|
if (!isSafeId(req.params.id)) {
|
|
return sendApiError(res, 400, 'BAD_REQUEST', 'invalid project id');
|
|
}
|
|
|
|
const protocol = req.params.provider;
|
|
if (!isFinalizeProviderProtocol(protocol)) {
|
|
return sendApiError(
|
|
res,
|
|
400,
|
|
'BAD_REQUEST',
|
|
'provider must be one of anthropic|openai|azure|google|ollama',
|
|
);
|
|
}
|
|
if (bodyProtocol !== undefined && bodyProtocol !== protocol) {
|
|
return sendApiError(res, 400, 'BAD_REQUEST', 'body protocol must match route provider');
|
|
}
|
|
|
|
if (typeof apiKey !== 'string' || !apiKey.trim()) {
|
|
return sendApiError(res, 400, 'BAD_REQUEST', 'apiKey is required');
|
|
}
|
|
if (typeof model !== 'string' || !model.trim()) {
|
|
return sendApiError(res, 400, 'BAD_REQUEST', 'model is required');
|
|
}
|
|
let effectiveBaseUrl = defaultBaseUrlForFinalizeProtocol(protocol);
|
|
if (baseUrl !== undefined) {
|
|
if (typeof baseUrl !== 'string' || !baseUrl.trim()) {
|
|
return sendApiError(res, 400, 'BAD_REQUEST', 'baseUrl must be a non-empty string when provided');
|
|
}
|
|
effectiveBaseUrl = baseUrl.trim();
|
|
}
|
|
if (!effectiveBaseUrl) {
|
|
return sendApiError(res, 400, 'BAD_REQUEST', 'baseUrl is required for this provider');
|
|
}
|
|
const validated = await validateExternalApiBaseUrl(effectiveBaseUrl);
|
|
if (validated.error) {
|
|
return sendApiError(
|
|
res,
|
|
validated.forbidden ? 403 : 400,
|
|
validated.forbidden ? 'FORBIDDEN' : 'BAD_REQUEST',
|
|
validated.error,
|
|
);
|
|
}
|
|
if (maxTokens !== undefined && (typeof maxTokens !== 'number' || maxTokens <= 0)) {
|
|
return sendApiError(res, 400, 'BAD_REQUEST', 'maxTokens must be a positive number when provided');
|
|
}
|
|
if (apiVersion !== undefined && typeof apiVersion !== 'string') {
|
|
return sendApiError(res, 400, 'BAD_REQUEST', 'apiVersion must be a string when provided');
|
|
}
|
|
|
|
const project = getProject(db, req.params.id);
|
|
if (!project) {
|
|
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
|
|
}
|
|
|
|
const finalizeAbort = new AbortController();
|
|
const abortFromRequest = (): void => {
|
|
if (!finalizeAbort.signal.aborted) finalizeAbort.abort();
|
|
};
|
|
res.on('close', abortFromRequest);
|
|
|
|
let result;
|
|
try {
|
|
result = await finalizeDesignPackage(
|
|
db,
|
|
PROJECTS_DIR,
|
|
DESIGN_SYSTEMS_DIR,
|
|
req.params.id,
|
|
{
|
|
protocol,
|
|
apiKey,
|
|
baseUrl: effectiveBaseUrl,
|
|
model,
|
|
maxTokens,
|
|
...(typeof apiVersion === 'string' && apiVersion.trim()
|
|
? { apiVersion: apiVersion.trim() }
|
|
: {}),
|
|
signal: finalizeAbort.signal,
|
|
},
|
|
);
|
|
} finally {
|
|
res.off('close', abortFromRequest);
|
|
}
|
|
res.json(result);
|
|
} catch (err: any) {
|
|
// Concurrent finalize - the lockfile was already held by another
|
|
// call. Caller can retry after a short wait; not a client error.
|
|
// Maps to the shared CONFLICT code per @lefarcen P2 on PR #832.
|
|
if (err instanceof FinalizePackageLockedError) {
|
|
return sendApiError(res, 409, 'CONFLICT', err.message);
|
|
}
|
|
|
|
// Upstream provider error - status-aware mapping using shared
|
|
// ApiErrorCode values. Run the raw upstream body through
|
|
// redactSecrets so the API key cannot leak even if the provider
|
|
// echoes the inbound headers. Codes per @lefarcen P2 on PR #832:
|
|
// 401 -> UNAUTHORIZED, 429 -> RATE_LIMITED, others -> UPSTREAM_UNAVAILABLE.
|
|
if (err instanceof FinalizeUpstreamError) {
|
|
const safeDetails = redactSecrets(err.rawText || '', [apiKey]);
|
|
const init = safeDetails ? { details: safeDetails } : {};
|
|
if (err.status === 401) {
|
|
return sendApiError(res, 401, 'UNAUTHORIZED', err.message, init);
|
|
}
|
|
if (err.status === 429) {
|
|
return sendApiError(res, 429, 'RATE_LIMITED', err.message, init);
|
|
}
|
|
return sendApiError(res, 502, 'UPSTREAM_UNAVAILABLE', err.message, init);
|
|
}
|
|
|
|
// The blocking call hit our 120s AbortController timeout - or the
|
|
// caller passed an already-aborted signal. Either way, surface as
|
|
// 503 with the shared UPSTREAM_UNAVAILABLE code (no dedicated
|
|
// TIMEOUT code in the contracts ApiErrorCode union).
|
|
const errName =
|
|
err && typeof err === 'object' && 'name' in err ? (err as { name?: unknown }).name : '';
|
|
if (errName === 'AbortError') {
|
|
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'finalize timed out');
|
|
}
|
|
|
|
// Unexpected runtime failure (file IO, db access, prompt build).
|
|
// Log via console.error per the daemon convention; client sees a
|
|
// generic 500 with the shared INTERNAL_ERROR code. Run the message
|
|
// through redactSecrets defensively.
|
|
console.error('[finalize]', err);
|
|
const safeMsg = redactSecrets(String(err?.message || err), [apiKey]);
|
|
return sendApiError(res, 500, 'INTERNAL_ERROR', safeMsg);
|
|
}
|
|
});
|
|
|
|
}
|