mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Merge 67396350c8 into af4a62b69a
This commit is contained in:
commit
0c3dff0231
13 changed files with 1183 additions and 70 deletions
|
|
@ -201,10 +201,15 @@ export function validateArtifactManifestInput(
|
|||
}
|
||||
}
|
||||
|
||||
const safeEntry = typeof entry === 'string' ? entry : '';
|
||||
if (!safeEntry || safeEntry.length > MAX_ENTRY_LENGTH) {
|
||||
return { ok: false, error: `artifact entry exceeds max length (${MAX_ENTRY_LENGTH})` };
|
||||
const manifestEntry =
|
||||
typeof manifest.entry === 'string' && manifest.entry.trim()
|
||||
? manifest.entry.trim()
|
||||
: entry;
|
||||
const entryErr = validateSupportingPath(manifestEntry);
|
||||
if (entryErr) {
|
||||
return { ok: false, error: `artifactManifest.entry ${entryErr}` };
|
||||
}
|
||||
const safeEntry = (manifestEntry as string).replace(/\\/g, '/');
|
||||
|
||||
return { ok: true, value: sanitizeManifest(manifest, safeEntry, options) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { Express } from 'express';
|
||||
import nodePath from 'node:path';
|
||||
import type { RouteDeps } from './server-context.js';
|
||||
import {
|
||||
InlineAssetsLimitError,
|
||||
|
|
@ -358,7 +359,7 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx
|
|||
const { sendApiError } = ctx.http;
|
||||
const { PROJECTS_DIR } = ctx.paths;
|
||||
const { getProject } = ctx.projectStore;
|
||||
const { readProjectFile, resolveProjectFilePath } = ctx.projectFiles;
|
||||
const { listFiles, readProjectFile, resolveProjectFilePath } = ctx.projectFiles;
|
||||
const { isSafeId } = ctx.validation;
|
||||
const {
|
||||
buildProjectArchive,
|
||||
|
|
@ -447,6 +448,30 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx
|
|||
}
|
||||
});
|
||||
|
||||
app.get('/api/projects/:id/export/manifest', async (req, res) => {
|
||||
try {
|
||||
if (!isSafeId(req.params.id)) {
|
||||
return sendApiError(res, 400, 'BAD_REQUEST', 'invalid project id');
|
||||
}
|
||||
const project = getProject(db, req.params.id);
|
||||
if (!project) {
|
||||
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
|
||||
}
|
||||
const files = await listFiles(PROJECTS_DIR, req.params.id, {
|
||||
metadata: project.metadata,
|
||||
});
|
||||
/** @type {import('@open-design/contracts').ProjectExportManifestResponse} */
|
||||
const body = buildProjectExportManifestResponse({
|
||||
project,
|
||||
projectId: req.params.id,
|
||||
files,
|
||||
});
|
||||
res.json(body);
|
||||
} catch (err: any) {
|
||||
sendApiError(res, 400, 'BAD_REQUEST', String(err?.message || err));
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/projects/:id/export/pdf', async (req, res) => {
|
||||
if (typeof desktopPdfExporter !== 'function') {
|
||||
return sendApiError(
|
||||
|
|
@ -656,6 +681,177 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx
|
|||
|
||||
}
|
||||
|
||||
function buildProjectExportManifestResponse({
|
||||
project,
|
||||
projectId,
|
||||
files,
|
||||
}: {
|
||||
project: any;
|
||||
projectId: string;
|
||||
files: any[];
|
||||
}) {
|
||||
const sortedFiles = [...files].sort((a, b) => String(a.name).localeCompare(String(b.name)));
|
||||
const filesByName = new Map(sortedFiles.map((file) => [file.name, file]));
|
||||
const reasons = new Map<string, Set<string>>();
|
||||
const supportingNames = new Set<string>();
|
||||
const artifactNames = new Set<string>();
|
||||
const artifacts = [];
|
||||
|
||||
const note = (name: unknown, reason: string) => {
|
||||
if (typeof name !== 'string' || !filesByName.has(name)) return;
|
||||
if (!reasons.has(name)) reasons.set(name, new Set());
|
||||
reasons.get(name)?.add(reason);
|
||||
};
|
||||
|
||||
for (const file of sortedFiles) {
|
||||
const manifest = file.artifactManifest && typeof file.artifactManifest === 'object'
|
||||
? file.artifactManifest
|
||||
: null;
|
||||
if (!manifest) continue;
|
||||
if (isInferredArtifactManifest(manifest)) continue;
|
||||
artifactNames.add(file.name);
|
||||
note(file.name, 'artifact-manifest');
|
||||
|
||||
const artifactSupporting = new Set<string>();
|
||||
const addManifestRef = (
|
||||
ref: unknown,
|
||||
reason: string,
|
||||
options: { allowProjectRootFallback?: boolean; preferProjectRoot?: boolean } = {},
|
||||
) => {
|
||||
const ownerRelative = normalizeManifestProjectRef(ref, file.name);
|
||||
const projectRoot = normalizeManifestProjectRootRef(ref);
|
||||
const candidates = options.preferProjectRoot
|
||||
? [projectRoot, ownerRelative]
|
||||
: [
|
||||
ownerRelative,
|
||||
...(options.allowProjectRootFallback ? [projectRoot] : []),
|
||||
];
|
||||
const normalized = candidates.find((candidate) => candidate && filesByName.has(candidate));
|
||||
if (!normalized) return;
|
||||
if (normalized === file.name) return;
|
||||
supportingNames.add(normalized);
|
||||
artifactSupporting.add(normalized);
|
||||
note(normalized, reason);
|
||||
};
|
||||
addManifestRef(manifest.entry, 'artifact-entry', { preferProjectRoot: true });
|
||||
if (typeof manifest.primary === 'string') {
|
||||
addManifestRef(manifest.primary, 'artifact-primary', { preferProjectRoot: true });
|
||||
}
|
||||
if (Array.isArray(manifest.supportingFiles)) {
|
||||
for (const ref of manifest.supportingFiles) {
|
||||
addManifestRef(ref, 'artifact-supporting-file', { allowProjectRootFallback: true });
|
||||
}
|
||||
}
|
||||
|
||||
artifacts.push({
|
||||
file: file.name,
|
||||
title: typeof manifest.title === 'string' && manifest.title.trim()
|
||||
? manifest.title
|
||||
: file.name,
|
||||
kind: typeof manifest.kind === 'string' ? manifest.kind : (file.artifactKind ?? null),
|
||||
renderer: typeof manifest.renderer === 'string' ? manifest.renderer : null,
|
||||
status: typeof manifest.status === 'string' ? manifest.status : null,
|
||||
exports: Array.isArray(manifest.exports)
|
||||
? manifest.exports.filter((value: unknown): value is string => typeof value === 'string')
|
||||
: [],
|
||||
supportingFiles: Array.from(artifactSupporting).sort((a, b) => a.localeCompare(b)),
|
||||
updatedAt: typeof manifest.updatedAt === 'string' ? manifest.updatedAt : null,
|
||||
});
|
||||
}
|
||||
|
||||
const entryFile = chooseExportManifestEntryFile(project, sortedFiles, filesByName);
|
||||
note(entryFile, 'project-entry-file');
|
||||
|
||||
return {
|
||||
schema: 'open-design.project-export-manifest.v1',
|
||||
projectId,
|
||||
projectName: typeof project?.name === 'string' ? project.name : null,
|
||||
generatedAt: new Date().toISOString(),
|
||||
entryFile,
|
||||
files: sortedFiles.map((file) => ({
|
||||
...file,
|
||||
included: true,
|
||||
role: roleForExportManifestFile(file, {
|
||||
entryFile,
|
||||
artifactNames,
|
||||
supportingNames,
|
||||
}),
|
||||
reasons: Array.from(reasons.get(file.name) ?? ['visible-project-file']).sort((a, b) => a.localeCompare(b)),
|
||||
})),
|
||||
artifacts,
|
||||
};
|
||||
}
|
||||
|
||||
function isInferredArtifactManifest(manifest: any): boolean {
|
||||
return manifest?.metadata &&
|
||||
typeof manifest.metadata === 'object' &&
|
||||
manifest.metadata.inferred === true;
|
||||
}
|
||||
|
||||
function chooseExportManifestEntryFile(
|
||||
project: any,
|
||||
files: any[],
|
||||
filesByName: Map<string, any>,
|
||||
): string | null {
|
||||
const metadataEntry = typeof project?.metadata?.entryFile === 'string'
|
||||
? project.metadata.entryFile
|
||||
: null;
|
||||
if (metadataEntry && filesByName.has(metadataEntry)) return metadataEntry;
|
||||
for (const file of files) {
|
||||
const manifest = file.artifactManifest;
|
||||
if (!manifest || typeof manifest !== 'object') continue;
|
||||
if (isInferredArtifactManifest(manifest)) continue;
|
||||
if (manifest.primary === true) return file.name;
|
||||
if (typeof manifest.primary === 'string') {
|
||||
const rootPrimary = normalizeManifestProjectRootRef(manifest.primary);
|
||||
if (rootPrimary && filesByName.has(rootPrimary)) return rootPrimary;
|
||||
const ownerRelativePrimary = normalizeManifestProjectRef(manifest.primary, file.name);
|
||||
if (ownerRelativePrimary && filesByName.has(ownerRelativePrimary)) return ownerRelativePrimary;
|
||||
}
|
||||
const rootEntry = normalizeManifestProjectRootRef(manifest.entry);
|
||||
if (rootEntry && filesByName.has(rootEntry)) return rootEntry;
|
||||
const ownerRelativeEntry = normalizeManifestProjectRef(manifest.entry, file.name);
|
||||
if (ownerRelativeEntry && filesByName.has(ownerRelativeEntry)) return ownerRelativeEntry;
|
||||
}
|
||||
return files.find((file) => /(^|\/)index\.html?$/i.test(file.name))?.name
|
||||
?? files.find((file) => file.kind === 'html')?.name
|
||||
?? files[0]?.name
|
||||
?? null;
|
||||
}
|
||||
|
||||
function normalizeManifestProjectRootRef(ref: unknown): string | null {
|
||||
return normalizeManifestProjectRef(ref, '');
|
||||
}
|
||||
|
||||
function normalizeManifestProjectRef(ref: unknown, ownerFile: string): string | null {
|
||||
if (typeof ref !== 'string' || !ref.trim()) return null;
|
||||
const value = ref.trim();
|
||||
if (value.includes('\0') || value.startsWith('/')) return null;
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return null;
|
||||
const ownerDir = nodePath.posix.dirname(ownerFile);
|
||||
const joined = ownerDir === '.' ? value : `${ownerDir}/${value}`;
|
||||
const normalized = nodePath.posix.normalize(joined).replace(/^\.\//, '');
|
||||
if (!normalized || normalized === '.' || normalized.startsWith('../')) return null;
|
||||
if (normalized.split('/').some((segment) => segment === '..' || segment === '.')) return null;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function roleForExportManifestFile(
|
||||
file: any,
|
||||
refs: {
|
||||
entryFile: string | null;
|
||||
artifactNames: Set<string>;
|
||||
supportingNames: Set<string>;
|
||||
},
|
||||
) {
|
||||
if (file.name === refs.entryFile) return 'entry';
|
||||
if (refs.artifactNames.has(file.name)) return 'artifact';
|
||||
if (refs.supportingNames.has(file.name)) return 'supporting';
|
||||
if (file.kind === 'image' || file.kind === 'video' || file.kind === 'audio') return 'asset';
|
||||
if (file.kind === 'code' || file.kind === 'text') return 'source';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
export interface RegisterFinalizeRoutesDeps extends RouteDeps<'db' | 'http' | 'paths' | 'projectStore' | 'validation' | 'finalize'> {}
|
||||
|
||||
export function registerFinalizeRoutes(app: Express, ctx: RegisterFinalizeRoutesDeps) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { Express } from 'express';
|
||||
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,
|
||||
|
|
@ -1208,6 +1209,104 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
|
|||
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
|
||||
|
|
@ -1266,6 +1365,79 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
|
|||
}
|
||||
});
|
||||
|
||||
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
|
||||
|
|
@ -1293,66 +1465,23 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
|
|||
res.header('Access-Control-Allow-Origin', '*');
|
||||
}
|
||||
|
||||
const meta = await resolveProjectFilePath(
|
||||
PROJECTS_DIR,
|
||||
await sendProjectFile(
|
||||
req,
|
||||
res,
|
||||
projectId,
|
||||
relPath,
|
||||
project?.metadata,
|
||||
);
|
||||
|
||||
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, project?.metadata);
|
||||
undefined,
|
||||
(file) => {
|
||||
if (
|
||||
wantsUrlPreviewScrollBridge(req.query.odPreviewBridge) &&
|
||||
/^text\/html(?:;|$)/i.test(file.mime)
|
||||
) {
|
||||
res.type(file.mime).send(injectUrlPreviewScrollBridge(file.buffer.toString('utf8')));
|
||||
return;
|
||||
return injectUrlPreviewScrollBridge(file.buffer.toString('utf8'));
|
||||
}
|
||||
res.type(file.mime).send(file.buffer);
|
||||
return file.buffer;
|
||||
},
|
||||
);
|
||||
} catch (err: any) {
|
||||
const status = err && err.code === 'ENOENT' ? 404 : 400;
|
||||
sendApiError(
|
||||
|
|
|
|||
|
|
@ -32,10 +32,16 @@ const FORBIDDEN_SEGMENT = /^$|^\.\.?$/;
|
|||
const RESERVED_PROJECT_FILE_SEGMENTS = new Set(['.live-artifacts']);
|
||||
const DESIGN_HANDOFF_FILENAME = 'DESIGN-HANDOFF.md';
|
||||
const DESIGN_MANIFEST_FILENAME = 'DESIGN-MANIFEST.json';
|
||||
export const RUN_ARTIFACT_RECONCILE_MTIME_GRACE_MS = 1000;
|
||||
export const projectFileRenameTestHooks = {
|
||||
beforeCommit: null as null | ((paths: { source: string; target: string }) => Promise<void> | void),
|
||||
};
|
||||
|
||||
export function isRunTouchedProjectFile(fileMtimeMs, runStartTimeMs) {
|
||||
if (!Number.isFinite(fileMtimeMs) || !Number.isFinite(runStartTimeMs)) return false;
|
||||
return fileMtimeMs + RUN_ARTIFACT_RECONCILE_MTIME_GRACE_MS >= runStartTimeMs;
|
||||
}
|
||||
|
||||
export function projectDir(projectsRoot, projectId) {
|
||||
if (!isSafeId(projectId)) throw new Error('invalid project id');
|
||||
return path.join(projectsRoot, projectId);
|
||||
|
|
@ -626,7 +632,8 @@ export async function resolveProjectFilePath(projectsRoot, projectId, name, meta
|
|||
const dir = resolveProjectDir(projectsRoot, projectId, metadata);
|
||||
const file = await resolveSafeReal(dir, name);
|
||||
const st = await stat(file);
|
||||
const rel = toProjectPath(path.relative(dir, file));
|
||||
const rootReal = await realpath(dir).catch(() => dir);
|
||||
const rel = toProjectPath(path.relative(rootReal, file));
|
||||
return {
|
||||
filePath: file,
|
||||
name: rel,
|
||||
|
|
@ -880,6 +887,7 @@ export async function renameProjectFile(projectsRoot, projectId, fromName, toNam
|
|||
await projectFileRenameTestHooks.beforeCommit?.({ source, target: targetPath });
|
||||
await renameFilePath(source, targetPath, { noOverwrite: true });
|
||||
await commitArtifactManifestRename(manifestRename, newName);
|
||||
await updateArtifactManifestRefsForRename(dir, oldName, newName);
|
||||
|
||||
const st = await stat(targetPath);
|
||||
const manifest = await readManifestForPath(dir, newName);
|
||||
|
|
@ -974,16 +982,22 @@ async function prepareArtifactManifestRename(dir, oldName, newName) {
|
|||
}
|
||||
}
|
||||
|
||||
return { oldManifestPath, newManifestPath: targetManifestPath, raw };
|
||||
return { oldManifestPath, newManifestPath: targetManifestPath, raw, oldName };
|
||||
}
|
||||
|
||||
async function commitArtifactManifestRename(manifestRename, newName) {
|
||||
if (!manifestRename) return;
|
||||
const { oldManifestPath, newManifestPath, raw } = manifestRename;
|
||||
const { oldManifestPath, newManifestPath, raw, oldName } = manifestRename;
|
||||
await mkdir(path.dirname(newManifestPath), { recursive: true });
|
||||
const parsed = parseManifest(raw);
|
||||
if (parsed) {
|
||||
const validated = validateArtifactManifestInput(parsed, newName);
|
||||
const parsedEntry = typeof parsed.entry === 'string'
|
||||
? parsed.entry.replace(/\\/g, '/')
|
||||
: '';
|
||||
const renamedManifest = parsedEntry === oldName
|
||||
? { ...parsed, entry: newName }
|
||||
: parsed;
|
||||
const validated = validateArtifactManifestInput(renamedManifest, newName);
|
||||
if (validated.ok && validated.value) {
|
||||
await writeFile(oldManifestPath, JSON.stringify(validated.value, null, 2));
|
||||
await renameFilePath(oldManifestPath, newManifestPath, { noOverwrite: true });
|
||||
|
|
@ -993,6 +1007,153 @@ async function commitArtifactManifestRename(manifestRename, newName) {
|
|||
await renameFilePath(oldManifestPath, newManifestPath, { noOverwrite: true });
|
||||
}
|
||||
|
||||
async function updateArtifactManifestRefsForRename(dir, oldName, newName) {
|
||||
const manifests = [];
|
||||
await collectArtifactManifestFiles(dir, '', manifests);
|
||||
for (const manifestFile of manifests) {
|
||||
const ownerName = ownerNameForArtifactManifest(manifestFile.relPath);
|
||||
if (!ownerName) continue;
|
||||
let raw;
|
||||
try {
|
||||
raw = await readFile(manifestFile.fullPath, 'utf8');
|
||||
} catch (err) {
|
||||
if (err && err.code === 'ENOENT') continue;
|
||||
throw err;
|
||||
}
|
||||
const parsed = parseManifest(raw);
|
||||
if (!parsed) continue;
|
||||
|
||||
const updated = rewriteArtifactManifestRenameRefs(parsed, {
|
||||
ownerName,
|
||||
oldName,
|
||||
newName,
|
||||
});
|
||||
if (!updated.changed) continue;
|
||||
|
||||
const validated = validateArtifactManifestInput(updated.manifest, ownerName);
|
||||
if (!validated.ok || !validated.value) continue;
|
||||
await writeFile(manifestFile.fullPath, JSON.stringify(validated.value, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
async function collectArtifactManifestFiles(dir, relDir, out) {
|
||||
let entries = [];
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
if (err && err.code === 'ENOENT') return;
|
||||
throw err;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await collectArtifactManifestFiles(fullPath, relPath, out);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && entry.name.endsWith('.artifact.json')) {
|
||||
out.push({ relPath, fullPath });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ownerNameForArtifactManifest(manifestName) {
|
||||
const suffix = '.artifact.json';
|
||||
if (!manifestName.endsWith(suffix)) return null;
|
||||
return manifestName.slice(0, -suffix.length);
|
||||
}
|
||||
|
||||
function rewriteArtifactManifestRenameRefs(manifest, { ownerName, oldName, newName }) {
|
||||
let changed = false;
|
||||
const next = { ...manifest };
|
||||
|
||||
const entry = rewriteManifestRefForRename(next.entry, ownerName, oldName, newName, {
|
||||
preferProjectRoot: true,
|
||||
});
|
||||
if (entry.changed) {
|
||||
next.entry = entry.value;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (typeof next.primary === 'string') {
|
||||
const primary = rewriteManifestRefForRename(next.primary, ownerName, oldName, newName, {
|
||||
preferProjectRoot: true,
|
||||
});
|
||||
if (primary.changed) {
|
||||
next.primary = primary.value;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(next.supportingFiles)) {
|
||||
const supportingFiles = next.supportingFiles.map((ref) => {
|
||||
const updated = rewriteManifestRefForRename(ref, ownerName, oldName, newName);
|
||||
if (updated.changed) changed = true;
|
||||
return updated.value;
|
||||
});
|
||||
if (changed) next.supportingFiles = supportingFiles;
|
||||
}
|
||||
|
||||
return { changed, manifest: next };
|
||||
}
|
||||
|
||||
function rewriteManifestRefForRename(
|
||||
ref,
|
||||
ownerName,
|
||||
oldName,
|
||||
newName,
|
||||
options = {},
|
||||
) {
|
||||
if (typeof ref !== 'string') return { changed: false, value: ref };
|
||||
const normalized = ref.replace(/\\/g, '/').trim();
|
||||
if (!normalized) return { changed: false, value: ref };
|
||||
|
||||
if (options.preferProjectRoot && normalizeManifestProjectRootRef(normalized) === oldName) {
|
||||
return { changed: true, value: newName };
|
||||
}
|
||||
|
||||
if (normalizeManifestProjectRef(normalized, ownerName) === oldName) {
|
||||
return {
|
||||
changed: true,
|
||||
value: relativeManifestRefForOwner(ownerName, newName),
|
||||
};
|
||||
}
|
||||
|
||||
if (normalized === oldName) {
|
||||
return { changed: true, value: newName };
|
||||
}
|
||||
|
||||
return { changed: false, value: ref };
|
||||
}
|
||||
|
||||
function relativeManifestRefForOwner(ownerName, targetName) {
|
||||
const ownerDir = path.posix.dirname(ownerName);
|
||||
if (ownerDir === '.') return targetName;
|
||||
const relative = path.posix.relative(ownerDir, targetName);
|
||||
if (!relative || relative === '.' || relative.startsWith('../') || relative.includes('/../')) {
|
||||
return targetName;
|
||||
}
|
||||
return relative;
|
||||
}
|
||||
|
||||
function normalizeManifestProjectRootRef(ref) {
|
||||
return normalizeManifestProjectRef(ref, '');
|
||||
}
|
||||
|
||||
function normalizeManifestProjectRef(ref, ownerName) {
|
||||
if (typeof ref !== 'string' || !ref.trim()) return null;
|
||||
const value = ref.trim().replace(/\\/g, '/');
|
||||
if (value.includes('\0') || value.startsWith('/')) return null;
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return null;
|
||||
const ownerDir = path.posix.dirname(ownerName);
|
||||
const joined = ownerDir === '.' ? value : `${ownerDir}/${value}`;
|
||||
const normalized = path.posix.normalize(joined).replace(/^\.\//, '');
|
||||
if (!normalized || normalized === '.' || normalized.startsWith('../')) return null;
|
||||
if (normalized.split('/').some((segment) => segment === '..' || segment === '.')) return null;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function removeProjectDir(projectsRoot, projectId) {
|
||||
const dir = projectDir(projectsRoot, projectId);
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
|
|
|
|||
|
|
@ -353,6 +353,7 @@ import {
|
|||
assertSandboxProjectRootAvailable,
|
||||
detectEntryFile,
|
||||
ensureProject,
|
||||
isRunTouchedProjectFile,
|
||||
isSafeId,
|
||||
listFiles,
|
||||
mimeFor,
|
||||
|
|
@ -4174,7 +4175,7 @@ export async function startServer({
|
|||
// Routes that serve content to sandboxed iframes (Origin: null) for
|
||||
// read-only purposes. All other /api routes reject Origin: null.
|
||||
const _NULL_ORIGIN_SAFE_GET_RE =
|
||||
/^\/projects\/[^/]+\/raw\/|^\/codex-pets\/[^/]+\/spritesheet$/;
|
||||
/^\/projects\/[^/]+\/(?:raw|preview)\/|^\/codex-pets\/[^/]+\/spritesheet$/;
|
||||
|
||||
// Reject cross-origin requests to API endpoints.
|
||||
// Health/version remain open for monitoring probes.
|
||||
|
|
@ -12751,7 +12752,7 @@ export async function startServer({
|
|||
try {
|
||||
const filePath = path.join(dir, f.name);
|
||||
const st = await fs.promises.stat(filePath);
|
||||
if (st.mtimeMs < runStartTimeMs) continue;
|
||||
if (!isRunTouchedProjectFile(st.mtimeMs, runStartTimeMs)) continue;
|
||||
await reconcileHtmlArtifactManifest(
|
||||
PROJECTS_DIR,
|
||||
run.projectId,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import fs from 'node:fs';
|
|||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { closeDatabase, insertProject, openDatabase } from '../src/db.js';
|
||||
import { reconcileHtmlArtifactManifest, writeProjectFile } from '../src/projects.js';
|
||||
import { isRunTouchedProjectFile, reconcileHtmlArtifactManifest, writeProjectFile } from '../src/projects.js';
|
||||
|
||||
const PROJECT_ID = 'reconcile-test';
|
||||
let tempDir = null;
|
||||
|
|
@ -146,6 +146,9 @@ describe('run-end artifact manifest reconciliation (#2893)', () => {
|
|||
|
||||
// File written during the run
|
||||
await writeProjectFile(projectsRoot, PROJECT_ID, 'new-output.html', '<p>new</p>');
|
||||
const newPath = path.join(projectsRoot, PROJECT_ID, 'new-output.html');
|
||||
const coarseFsTime = new Date(runStartTimeMs - 500);
|
||||
fs.utimesSync(newPath, coarseFsTime, coarseFsTime);
|
||||
|
||||
// Simulate the close-handler reconciliation with mtime filter
|
||||
const dir = path.join(projectsRoot, PROJECT_ID);
|
||||
|
|
@ -154,7 +157,7 @@ describe('run-end artifact manifest reconciliation (#2893)', () => {
|
|||
const ext = path.extname(name).toLowerCase();
|
||||
if (ext !== '.html' && ext !== '.htm') continue;
|
||||
const st = fs.statSync(path.join(dir, name));
|
||||
if (st.mtimeMs < runStartTimeMs) continue;
|
||||
if (!isRunTouchedProjectFile(st.mtimeMs, runStartTimeMs)) continue;
|
||||
await reconcileHtmlArtifactManifest(projectsRoot, PROJECT_ID, name);
|
||||
}
|
||||
|
||||
|
|
|
|||
364
apps/daemon/tests/export-manifest-route.test.ts
Normal file
364
apps/daemon/tests/export-manifest-route.test.ts
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
import type http from 'node:http';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { writeFile as writeFsFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { startServer } from '../src/server.js';
|
||||
|
||||
describe('project export manifest route', () => {
|
||||
let server: http.Server;
|
||||
let baseUrl: string;
|
||||
const projectsToClean: string[] = [];
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = (await startServer({ port: 0, returnServer: true })) as {
|
||||
url: string;
|
||||
server: http.Server;
|
||||
};
|
||||
baseUrl = started.url;
|
||||
server = started.server;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of projectsToClean.splice(0)) {
|
||||
await fetch(`${baseUrl}/api/projects/${id}`, { method: 'DELETE' }).catch(() => {});
|
||||
}
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
function makeFolder(): string {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), 'od-export-manifest-'));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function withSandboxMode<T>(run: () => Promise<T>): Promise<T> {
|
||||
const previous = process.env.OD_SANDBOX_MODE;
|
||||
process.env.OD_SANDBOX_MODE = '1';
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
if (previous == null) delete process.env.OD_SANDBOX_MODE;
|
||||
else process.env.OD_SANDBOX_MODE = previous;
|
||||
}
|
||||
}
|
||||
|
||||
async function createProject(
|
||||
metadata: Record<string, unknown> = { kind: 'prototype', entryFile: 'index.html' },
|
||||
): Promise<string> {
|
||||
const id = `export-manifest-${randomUUID()}`;
|
||||
const response = await fetch(`${baseUrl}/api/projects`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id,
|
||||
name: 'Export manifest project',
|
||||
metadata,
|
||||
}),
|
||||
});
|
||||
expect(response.ok).toBe(true);
|
||||
projectsToClean.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function writeFile(projectId: string, body: Record<string, unknown>): Promise<void> {
|
||||
const response = await fetch(`${baseUrl}/api/projects/${projectId}/files`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
expect(response.ok).toBe(true);
|
||||
}
|
||||
|
||||
async function renameFile(projectId: string, from: string, to: string): Promise<void> {
|
||||
const response = await fetch(`${baseUrl}/api/projects/${projectId}/files/rename`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ from, to }),
|
||||
});
|
||||
expect(response.ok).toBe(true);
|
||||
}
|
||||
|
||||
it('lists exportable project files and artifact sidecar metadata without exposing sidecars', async () => {
|
||||
const projectId = await createProject();
|
||||
await writeFile(projectId, {
|
||||
name: 'styles.css',
|
||||
content: 'body { color: black; }',
|
||||
});
|
||||
await writeFile(projectId, {
|
||||
name: 'assets/logo.svg',
|
||||
content: '<svg xmlns="http://www.w3.org/2000/svg"></svg>',
|
||||
});
|
||||
await writeFile(projectId, {
|
||||
name: 'index.html',
|
||||
content: '<!doctype html><link rel="stylesheet" href="styles.css">',
|
||||
artifactManifest: {
|
||||
version: 1,
|
||||
kind: 'html',
|
||||
title: 'Reviewed prototype',
|
||||
entry: 'index.html',
|
||||
renderer: 'html',
|
||||
status: 'complete',
|
||||
exports: ['html', 'zip'],
|
||||
primary: true,
|
||||
supportingFiles: ['styles.css', 'assets/logo.svg', 'missing.png'],
|
||||
updatedAt: '2026-05-28T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/projects/${projectId}/export/manifest`);
|
||||
expect(response.ok).toBe(true);
|
||||
const body = await response.json() as {
|
||||
schema: string;
|
||||
projectId: string;
|
||||
entryFile: string;
|
||||
files: Array<{ name: string; role: string; reasons: string[]; artifactManifest?: unknown }>;
|
||||
artifacts: Array<{ file: string; title: string; supportingFiles: string[] }>;
|
||||
};
|
||||
|
||||
expect(body).toMatchObject({
|
||||
schema: 'open-design.project-export-manifest.v1',
|
||||
projectId,
|
||||
entryFile: 'index.html',
|
||||
});
|
||||
expect(body.files.map((file) => file.name)).toEqual([
|
||||
'assets/logo.svg',
|
||||
'index.html',
|
||||
'styles.css',
|
||||
]);
|
||||
expect(body.files.find((file) => file.name === 'index.html')).toMatchObject({
|
||||
role: 'entry',
|
||||
reasons: expect.arrayContaining(['artifact-manifest', 'project-entry-file']),
|
||||
});
|
||||
expect(body.files.find((file) => file.name === 'styles.css')).toMatchObject({
|
||||
role: 'supporting',
|
||||
reasons: ['artifact-supporting-file'],
|
||||
});
|
||||
expect(body.artifacts).toMatchObject([
|
||||
{
|
||||
file: 'index.html',
|
||||
title: 'Reviewed prototype',
|
||||
supportingFiles: ['assets/logo.svg', 'styles.css'],
|
||||
},
|
||||
]);
|
||||
expect(body.files.some((file) => file.name.endsWith('.artifact.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('uses artifact primary strings as project-relative entry refs', async () => {
|
||||
const projectId = await createProject({ kind: 'prototype' });
|
||||
await writeFile(projectId, {
|
||||
name: 'reviewed.html',
|
||||
content: '<!doctype html><main>reviewed</main>',
|
||||
});
|
||||
await writeFile(projectId, {
|
||||
name: 'preview/wrapper.html',
|
||||
content: '<!doctype html><iframe src="../reviewed.html"></iframe>',
|
||||
artifactManifest: {
|
||||
version: 1,
|
||||
kind: 'html',
|
||||
title: 'Review wrapper',
|
||||
renderer: 'html',
|
||||
status: 'complete',
|
||||
exports: ['html'],
|
||||
primary: 'reviewed.html',
|
||||
},
|
||||
});
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/projects/${projectId}/export/manifest`);
|
||||
expect(response.ok).toBe(true);
|
||||
const body = await response.json() as {
|
||||
entryFile: string;
|
||||
files: Array<{ name: string; role: string; reasons: string[] }>;
|
||||
};
|
||||
|
||||
expect(body.entryFile).toBe('reviewed.html');
|
||||
expect(body.files.find((file) => file.name === 'reviewed.html')).toMatchObject({
|
||||
role: 'entry',
|
||||
reasons: expect.arrayContaining(['artifact-primary', 'project-entry-file']),
|
||||
});
|
||||
});
|
||||
|
||||
it('uses artifact entry strings as project-relative entry refs without primary hints', async () => {
|
||||
const projectId = await createProject({ kind: 'prototype' });
|
||||
await writeFile(projectId, {
|
||||
name: 'index.html',
|
||||
content: '<!doctype html><main>fallback</main>',
|
||||
});
|
||||
await writeFile(projectId, {
|
||||
name: 'reviewed.html',
|
||||
content: '<!doctype html><main>reviewed</main>',
|
||||
});
|
||||
await writeFile(projectId, {
|
||||
name: 'preview/wrapper.html',
|
||||
content: '<!doctype html><iframe src="../reviewed.html"></iframe>',
|
||||
artifactManifest: {
|
||||
version: 1,
|
||||
kind: 'html',
|
||||
title: 'Review wrapper',
|
||||
entry: 'reviewed.html',
|
||||
renderer: 'html',
|
||||
status: 'complete',
|
||||
exports: ['html'],
|
||||
},
|
||||
});
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/projects/${projectId}/export/manifest`);
|
||||
expect(response.ok).toBe(true);
|
||||
const body = await response.json() as {
|
||||
entryFile: string;
|
||||
files: Array<{ name: string; role: string; reasons: string[] }>;
|
||||
};
|
||||
|
||||
expect(body.entryFile).toBe('reviewed.html');
|
||||
expect(body.files.find((file) => file.name === 'reviewed.html')).toMatchObject({
|
||||
role: 'entry',
|
||||
reasons: expect.arrayContaining(['artifact-entry', 'project-entry-file']),
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps artifact entry refs current when a referenced file is renamed', async () => {
|
||||
const projectId = await createProject({ kind: 'prototype' });
|
||||
await writeFile(projectId, {
|
||||
name: 'index.html',
|
||||
content: '<!doctype html><main>fallback</main>',
|
||||
});
|
||||
await writeFile(projectId, {
|
||||
name: 'reviewed.html',
|
||||
content: '<!doctype html><main>reviewed</main>',
|
||||
});
|
||||
await writeFile(projectId, {
|
||||
name: 'preview/wrapper.html',
|
||||
content: '<!doctype html><iframe src="../reviewed.html"></iframe>',
|
||||
artifactManifest: {
|
||||
version: 1,
|
||||
kind: 'html',
|
||||
title: 'Review wrapper',
|
||||
entry: 'reviewed.html',
|
||||
renderer: 'html',
|
||||
status: 'complete',
|
||||
exports: ['html'],
|
||||
primary: 'reviewed.html',
|
||||
supportingFiles: ['reviewed.html'],
|
||||
},
|
||||
});
|
||||
|
||||
await renameFile(projectId, 'reviewed.html', 'reviewed-renamed.html');
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/projects/${projectId}/export/manifest`);
|
||||
expect(response.ok).toBe(true);
|
||||
const body = await response.json() as {
|
||||
entryFile: string;
|
||||
files: Array<{ name: string; role: string; reasons: string[] }>;
|
||||
};
|
||||
|
||||
expect(body.entryFile).toBe('reviewed-renamed.html');
|
||||
expect(body.files.find((file) => file.name === 'reviewed-renamed.html')).toMatchObject({
|
||||
role: 'entry',
|
||||
reasons: expect.arrayContaining(['artifact-entry', 'artifact-primary', 'project-entry-file']),
|
||||
});
|
||||
|
||||
const filesResponse = await fetch(`${baseUrl}/api/projects/${projectId}/files`);
|
||||
expect(filesResponse.ok).toBe(true);
|
||||
const filesBody = await filesResponse.json() as {
|
||||
files: Array<{
|
||||
name: string;
|
||||
artifactManifest?: {
|
||||
entry?: string;
|
||||
primary?: string | boolean;
|
||||
supportingFiles?: string[];
|
||||
};
|
||||
}>;
|
||||
};
|
||||
expect(filesBody.files.find((file) => file.name === 'preview/wrapper.html')?.artifactManifest)
|
||||
.toMatchObject({
|
||||
entry: 'reviewed-renamed.html',
|
||||
primary: 'reviewed-renamed.html',
|
||||
supportingFiles: ['reviewed-renamed.html'],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps artifact entry refs current when a referenced file moves out of the wrapper directory', async () => {
|
||||
const projectId = await createProject({ kind: 'prototype' });
|
||||
await writeFile(projectId, {
|
||||
name: 'index.html',
|
||||
content: '<!doctype html><main>fallback</main>',
|
||||
});
|
||||
await writeFile(projectId, {
|
||||
name: 'preview/reviewed.html',
|
||||
content: '<!doctype html><main>reviewed</main>',
|
||||
});
|
||||
await writeFile(projectId, {
|
||||
name: 'preview/wrapper.html',
|
||||
content: '<!doctype html><iframe src="reviewed.html"></iframe>',
|
||||
artifactManifest: {
|
||||
version: 1,
|
||||
kind: 'html',
|
||||
title: 'Review wrapper',
|
||||
entry: 'reviewed.html',
|
||||
renderer: 'html',
|
||||
status: 'complete',
|
||||
exports: ['html'],
|
||||
primary: 'reviewed.html',
|
||||
supportingFiles: ['reviewed.html'],
|
||||
},
|
||||
});
|
||||
|
||||
await renameFile(projectId, 'preview/reviewed.html', 'reviewed.html');
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/projects/${projectId}/export/manifest`);
|
||||
expect(response.ok).toBe(true);
|
||||
const body = await response.json() as {
|
||||
entryFile: string;
|
||||
files: Array<{ name: string; role: string; reasons: string[] }>;
|
||||
artifacts: Array<{ file: string; supportingFiles: string[] }>;
|
||||
};
|
||||
|
||||
expect(body.entryFile).toBe('reviewed.html');
|
||||
expect(body.files.find((file) => file.name === 'reviewed.html')).toMatchObject({
|
||||
role: 'entry',
|
||||
reasons: expect.arrayContaining([
|
||||
'artifact-entry',
|
||||
'artifact-primary',
|
||||
'artifact-supporting-file',
|
||||
'project-entry-file',
|
||||
]),
|
||||
});
|
||||
expect(body.artifacts.find((artifact) => artifact.file === 'preview/wrapper.html'))
|
||||
.toMatchObject({
|
||||
supportingFiles: ['reviewed.html'],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid project ids before listing files', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/projects/bad:id/export/manifest`);
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects imported-folder projects in sandbox mode instead of returning an empty manifest', async () => {
|
||||
const folder = makeFolder();
|
||||
await writeFsFile(path.join(folder, 'index.html'), '<!doctype html>');
|
||||
|
||||
const importResponse = await fetch(`${baseUrl}/api/import/folder`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ baseDir: folder }),
|
||||
});
|
||||
expect(importResponse.status).toBe(200);
|
||||
const importBody = (await importResponse.json()) as { project: { id: string } };
|
||||
projectsToClean.push(importBody.project.id);
|
||||
|
||||
await withSandboxMode(async () => {
|
||||
const response = await fetch(`${baseUrl}/api/projects/${importBody.project.id}/export/manifest`);
|
||||
expect(response.status).toBe(400);
|
||||
const body = (await response.json()) as { error?: { message?: string } };
|
||||
expect(body.error?.message).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -357,6 +357,73 @@ describe('spawn writes external MCP config for Claude Code', () => {
|
|||
}
|
||||
}, 30_000);
|
||||
|
||||
it('binds conversation-less runs to the seeded project conversation', async () => {
|
||||
await withFakeClaude(async () => {
|
||||
const { id, conversationId } = await createProject();
|
||||
const recentConvRes = await fetch(`${baseUrl}/api/projects/${id}/conversations`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ title: 'Recently active' }),
|
||||
});
|
||||
expect(recentConvRes.ok).toBe(true);
|
||||
const recentConvBody = (await recentConvRes.json()) as {
|
||||
conversation: { id: string };
|
||||
};
|
||||
const recentConversationId = recentConvBody.conversation.id;
|
||||
await fetch(`${baseUrl}/api/projects/${id}/conversations/${recentConversationId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: 'Recently active',
|
||||
updatedAt: Date.now() + 60_000,
|
||||
}),
|
||||
});
|
||||
|
||||
const chatRes = await fetch(`${baseUrl}/api/runs`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
agentId: 'claude',
|
||||
projectId: id,
|
||||
message: 'headless fallback prompt',
|
||||
}),
|
||||
});
|
||||
expect(chatRes.status).toBe(202);
|
||||
const { runId, conversationId: resolvedConversationId } = (await chatRes.json()) as {
|
||||
runId: string;
|
||||
conversationId: string;
|
||||
};
|
||||
expect(resolvedConversationId).toBe(conversationId);
|
||||
const status = await waitForRunStatus(baseUrl, runId);
|
||||
expect(status.status).toBe('succeeded');
|
||||
|
||||
const defaultMessagesRes = await fetch(
|
||||
`${baseUrl}/api/projects/${id}/conversations/${conversationId}/messages`,
|
||||
);
|
||||
expect(defaultMessagesRes.ok).toBe(true);
|
||||
const defaultMessages = (await defaultMessagesRes.json()) as {
|
||||
messages: Array<{ role: string; content: string }>;
|
||||
};
|
||||
expect(defaultMessages.messages).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
role: 'user',
|
||||
content: 'headless fallback prompt',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const recentMessagesRes = await fetch(
|
||||
`${baseUrl}/api/projects/${id}/conversations/${recentConversationId}/messages`,
|
||||
);
|
||||
expect(recentMessagesRes.ok).toBe(true);
|
||||
const recentMessages = (await recentMessagesRes.json()) as {
|
||||
messages: Array<{ content: string }>;
|
||||
};
|
||||
expect(recentMessages.messages.some((msg) => msg.content === 'headless fallback prompt')).toBe(false);
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
it('injects run-scoped MCP servers without saving them to the persistent registry', async () => {
|
||||
await withFakeClaude(async () => {
|
||||
const { id, dir } = await createProject();
|
||||
|
|
|
|||
122
apps/daemon/tests/project-preview-containment.test.ts
Normal file
122
apps/daemon/tests/project-preview-containment.test.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import type http from 'node:http';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { startServer } from '../src/server.js';
|
||||
|
||||
describe('project preview containment routes', () => {
|
||||
let server: http.Server;
|
||||
let baseUrl: string;
|
||||
const projectsToClean: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = (await startServer({ port: 0, returnServer: true })) as {
|
||||
url: string;
|
||||
server: http.Server;
|
||||
};
|
||||
baseUrl = started.url;
|
||||
server = started.server;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of projectsToClean.splice(0)) {
|
||||
await fetch(`${baseUrl}/api/projects/${id}`, { method: 'DELETE' }).catch(() => {});
|
||||
}
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
async function createProject(metadata: Record<string, unknown> = {}): Promise<string> {
|
||||
const id = `preview-containment-${randomUUID()}`;
|
||||
const response = await fetch(`${baseUrl}/api/projects`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id,
|
||||
name: 'Preview containment project',
|
||||
metadata,
|
||||
}),
|
||||
});
|
||||
expect(response.ok).toBe(true);
|
||||
projectsToClean.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function writeProjectFile(projectId: string, name: string, content: string): Promise<void> {
|
||||
const response = await fetch(`${baseUrl}/api/projects/${projectId}/files`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name, content }),
|
||||
});
|
||||
expect(response.ok).toBe(true);
|
||||
}
|
||||
|
||||
it('returns a scoped preview URL with sandbox guidance and serves it with an opaque-origin CSP', async () => {
|
||||
const projectId = await createProject({ entryFile: 'pages/index.html' });
|
||||
await writeProjectFile(
|
||||
projectId,
|
||||
'pages/index.html',
|
||||
'<!doctype html><title>Preview</title><link rel="stylesheet" href="../styles/app.css">',
|
||||
);
|
||||
await writeProjectFile(projectId, 'styles/app.css', 'body { color: black; }');
|
||||
|
||||
const urlResponse = await fetch(
|
||||
`${baseUrl}/api/projects/${projectId}/preview-url?file=${encodeURIComponent('pages/index.html')}`,
|
||||
);
|
||||
expect(urlResponse.ok).toBe(true);
|
||||
expect(urlResponse.headers.get('cache-control')).toBe('no-store');
|
||||
const body = await urlResponse.json() as {
|
||||
url: string;
|
||||
file: string;
|
||||
csp: string;
|
||||
iframeSandbox: string;
|
||||
opaqueOrigin: true;
|
||||
};
|
||||
|
||||
expect(body.file).toBe('pages/index.html');
|
||||
expect(body.url).toContain(`/api/projects/${projectId}/preview/`);
|
||||
expect(body.url).toMatch(/\/preview\/[A-Za-z0-9_-]{8,128}\/pages\/index\.html$/u);
|
||||
expect(body.iframeSandbox).toBe('allow-scripts allow-forms');
|
||||
expect(body.iframeSandbox).not.toContain('allow-same-origin');
|
||||
expect(body.csp).toContain('sandbox allow-scripts allow-forms');
|
||||
expect(body.csp).toContain("connect-src 'none'");
|
||||
expect(body.csp).not.toContain('allow-same-origin');
|
||||
expect(body.opaqueOrigin).toBe(true);
|
||||
|
||||
const previewResponse = await fetch(`${baseUrl}${body.url}`, {
|
||||
headers: { Origin: 'null' },
|
||||
});
|
||||
expect(previewResponse.status).toBe(200);
|
||||
expect(previewResponse.headers.get('access-control-allow-origin')).toBe('*');
|
||||
expect(previewResponse.headers.get('cache-control')).toBe('no-store');
|
||||
expect(previewResponse.headers.get('x-content-type-options')).toBe('nosniff');
|
||||
const csp = previewResponse.headers.get('content-security-policy') ?? '';
|
||||
expect(csp).toContain('sandbox allow-scripts allow-forms');
|
||||
expect(csp).toContain("connect-src 'none'");
|
||||
expect(csp).not.toContain('allow-same-origin');
|
||||
expect(await previewResponse.text()).toContain('<title>Preview</title>');
|
||||
|
||||
const scope = body.url.match(/\/preview\/([^/]+)\//u)?.[1];
|
||||
expect(scope).toBeTruthy();
|
||||
const assetResponse = await fetch(
|
||||
`${baseUrl}/api/projects/${projectId}/preview/${scope}/styles/app.css`,
|
||||
{ headers: { Origin: 'null' } },
|
||||
);
|
||||
expect(assetResponse.status).toBe(200);
|
||||
expect(assetResponse.headers.get('access-control-allow-origin')).toBe('*');
|
||||
expect(assetResponse.headers.get('content-type')).toContain('text/css');
|
||||
expect(await assetResponse.text()).toContain('color: black');
|
||||
});
|
||||
|
||||
it('rejects invalid preview scopes and escaping preview-url paths', async () => {
|
||||
const projectId = await createProject();
|
||||
await writeProjectFile(projectId, 'index.html', '<!doctype html>');
|
||||
|
||||
const invalidScope = await fetch(`${baseUrl}/api/projects/${projectId}/preview/bad/index.html`);
|
||||
expect(invalidScope.status).toBe(400);
|
||||
|
||||
const escapingPath = await fetch(
|
||||
`${baseUrl}/api/projects/${projectId}/preview-url?file=${encodeURIComponent('../index.html')}`,
|
||||
);
|
||||
expect(escapingPath.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
|
@ -142,6 +142,8 @@ async function runToolsDevSuite(
|
|||
break;
|
||||
} catch (error) {
|
||||
if (attempt === 3 || !toolsDev.isToolsDevPortConflict(error)) throw error;
|
||||
await runtime.release().catch(() => {});
|
||||
await toolsDev.stopToolsDevWeb(suite).catch(() => {});
|
||||
runtime = await toolsDev.allocateToolsDevRuntime();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -161,7 +161,8 @@ export function isToolsDevPortConflict(error: unknown): boolean {
|
|||
const text = error instanceof Error
|
||||
? `${error.message}\n${error.stack ?? ''}`
|
||||
: String(error);
|
||||
return text.includes('EADDRINUSE');
|
||||
return text.includes('EADDRINUSE') ||
|
||||
(text.includes('is already running in namespace') && text.includes('stop it or choose another namespace'));
|
||||
}
|
||||
|
||||
async function runToolsDevJson<T>(suite: SmokeSuite, args: string[]): Promise<T> {
|
||||
|
|
|
|||
19
e2e/tests/tools-dev/startup-conflicts.test.ts
Normal file
19
e2e/tests/tools-dev/startup-conflicts.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { isToolsDevPortConflict } from '@/vitest/tools-dev';
|
||||
|
||||
describe('tools-dev startup conflict detection', () => {
|
||||
test('classifies port and namespace startup collisions as retryable', () => {
|
||||
expect(isToolsDevPortConflict(new Error('listen EADDRINUSE: address already in use 127.0.0.1:30123'))).toBe(true);
|
||||
expect(
|
||||
isToolsDevPortConflict(
|
||||
new Error(
|
||||
'daemon is already running in namespace e2e-orbit-run-123 at http://127.0.0.1:36695; stop it or choose another namespace',
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(isToolsDevPortConflict(new Error('daemon exited before readiness'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -46,6 +46,49 @@ export interface ProjectFilesResponse {
|
|||
files: ProjectFile[];
|
||||
}
|
||||
|
||||
export type ProjectExportManifestFileRole =
|
||||
| 'entry'
|
||||
| 'artifact'
|
||||
| 'supporting'
|
||||
| 'asset'
|
||||
| 'source'
|
||||
| 'other';
|
||||
|
||||
export interface ProjectExportManifestFile extends ProjectFile {
|
||||
included: boolean;
|
||||
role: ProjectExportManifestFileRole;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export interface ProjectExportManifestArtifact {
|
||||
file: string;
|
||||
title: string;
|
||||
kind: ArtifactKind | null;
|
||||
renderer: string | null;
|
||||
status: string | null;
|
||||
exports: string[];
|
||||
supportingFiles: string[];
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectExportManifestResponse {
|
||||
schema: 'open-design.project-export-manifest.v1';
|
||||
projectId: string;
|
||||
projectName: string | null;
|
||||
generatedAt: string;
|
||||
entryFile: string | null;
|
||||
files: ProjectExportManifestFile[];
|
||||
artifacts: ProjectExportManifestArtifact[];
|
||||
}
|
||||
|
||||
export interface ProjectPreviewUrlResponse {
|
||||
url: string;
|
||||
file: string;
|
||||
csp: string;
|
||||
iframeSandbox: string;
|
||||
opaqueOrigin: true;
|
||||
}
|
||||
|
||||
export interface ProjectFileResponse {
|
||||
file: ProjectFile;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue