import { createReadStream, existsSync } from 'node:fs'; import { randomBytes, timingSafeEqual } from 'node:crypto'; import { stat } from 'node:fs/promises'; import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { isIP } from 'node:net'; import { dirname, extname, normalize, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { VclawError } from './errors.js'; import { sendProxiedMedia, type ReviewUiMediaProxyDependencies } from './review-ui-media-proxy.js'; const SESSION_COOKIE = 'vclaw_review_session'; export interface ReviewUiServerLaunch { url: string; host: string; port: number; root: string; projectSlug: string; uiPath: string; dryRun: boolean; authRequired: true; remoteAccess: boolean; close?: () => Promise; } export interface ReviewUiServerHandlers { buildInventory(root: string, projectSlug: string): Promise; saveDecision(root: string, projectSlug: string, body: unknown): Promise; recordStillCandidate(root: string, projectSlug: string, body: unknown): Promise; recordUpscaledCandidate(root: string, projectSlug: string, body: unknown): Promise; recordStillRequest(root: string, projectSlug: string, body: unknown): Promise; recordCharacterRequest(root: string, projectSlug: string, body: unknown): Promise; resolveRemoteMedia(root: string, projectSlug: string, id: string | null): Promise; customMutations?: Record Promise>; } export class ReviewUiRequestError extends Error { constructor(public readonly statusCode: 400 | 403 | 409, message: string) { super(message); this.name = 'ReviewUiRequestError'; } } export interface ReviewUiServerOptions { root: string; projectSlug: string; host?: string; port?: number; uiPath?: string; dryRun?: boolean; allowRemote?: boolean; mediaProxyDependencies?: ReviewUiMediaProxyDependencies; } function defaultUiPath(): string { return resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'assets', 'review-station', 'index.html'); } function formatHost(host: string): string { return isIP(host) === 6 ? `[${host}]` : host; } function isWildcardHost(host: string): boolean { const normalized = host.trim().toLowerCase().replace(/^\[|\]$/g, ''); return normalized === '0.0.0.0' || normalized === '::'; } function isLoopbackHost(host: string): boolean { const normalized = host.trim().toLowerCase().replace(/^\[|\]$/g, ''); return normalized === 'localhost' || normalized === '::1' || normalized.startsWith('127.'); } export function timingSafeStringEqual(left: string, right: string): boolean { const leftBuffer = Buffer.from(left); const rightBuffer = Buffer.from(right); return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer); } function cookies(request: IncomingMessage): Record { const result: Record = {}; for (const pair of (request.headers.cookie ?? '').split(';')) { const separator = pair.indexOf('='); if (separator < 1) continue; try { result[pair.slice(0, separator).trim()] = decodeURIComponent(pair.slice(separator + 1).trim()); } catch { // A malformed cookie cannot authenticate a request. } } return result; } function setSecurityHeaders(response: ServerResponse): void { response.setHeader('X-Content-Type-Options', 'nosniff'); response.setHeader('Referrer-Policy', 'no-referrer'); response.setHeader('X-Frame-Options', 'DENY'); response.setHeader( 'Content-Security-Policy', "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'", ); } function requestAuthorityIsTrusted(request: IncomingMessage, host: string, port: number): boolean { const expected = `${formatHost(host)}:${port}`.toLowerCase(); if ((request.headers.host ?? '').toLowerCase() !== expected) return false; if (request.headers['sec-fetch-site'] === 'cross-site') return false; return !request.headers.origin || request.headers.origin === `http://${expected}`; } function sendText(response: ServerResponse, status: number, body: string): void { response.statusCode = status; response.setHeader('Content-Type', 'text/plain; charset=utf-8'); response.setHeader('Cache-Control', 'no-store'); response.end(`${body}\n`); } function sendJson(response: ServerResponse, body: unknown): void { response.statusCode = 200; response.setHeader('Content-Type', 'application/json; charset=utf-8'); response.setHeader('Cache-Control', 'no-store'); response.end(`${JSON.stringify(body, null, 2)}\n`); } function contentType(path: string): string { switch (extname(path).toLowerCase()) { case '.html': return 'text/html; charset=utf-8'; case '.css': return 'text/css; charset=utf-8'; case '.js': return 'text/javascript; charset=utf-8'; case '.json': return 'application/json; charset=utf-8'; case '.jpg': case '.jpeg': return 'image/jpeg'; case '.png': return 'image/png'; case '.gif': return 'image/gif'; case '.webp': return 'image/webp'; case '.mp4': return 'video/mp4'; default: return 'application/octet-stream'; } } async function sendFile(response: ServerResponse, path: string): Promise { response.statusCode = 200; response.setHeader('Content-Type', contentType(path)); response.setHeader('Cache-Control', 'no-store'); await new Promise((resolveStream, rejectStream) => { const stream = createReadStream(path); stream.once('error', rejectStream); response.once('finish', resolveStream); stream.pipe(response); }); } function safePath(root: string, pathname: string): string | null { let decoded: string; try { decoded = decodeURIComponent(pathname); } catch { return null; } const candidate = normalize(resolve(root, decoded.replace(/^\/+/, ''))); if (candidate === normalize(root)) return candidate; const normalizedRoot = normalize(root.endsWith(sep) ? root : `${root}${sep}`); return candidate.startsWith(normalizedRoot) ? candidate : null; } function projectPath(root: string, pathname: string): string | null { let decoded: string; try { decoded = decodeURIComponent(pathname); } catch { return null; } if (!/^projects\/[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\//.test(decoded.replace(/^\/+/, ''))) return null; return safePath(root, pathname); } function staticAssetPath(root: string, pathname: string): string | null { let decoded: string; try { decoded = decodeURIComponent(pathname); } catch { return null; } const relativePath = decoded.replace(/^\/+/, ''); if (!relativePath.startsWith('docs/assets/') && !/^skills\/[^/]+\/assets\//.test(relativePath)) return null; return safePath(root, pathname); } function isJsonRequest(request: IncomingMessage): boolean { const header = request.headers['content-type']; const value = Array.isArray(header) ? header[0] : header; return typeof value === 'string' && /^application\/json(?:\s*;|$)/i.test(value); } async function readJsonBody(request: IncomingMessage): Promise { const chunks: Buffer[] = []; let bytes = 0; for await (const chunk of request) { const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); bytes += buffer.length; if (bytes > 1024 * 1024) throw new Error('review decision payload is too large'); chunks.push(buffer); } const raw = Buffer.concat(chunks).toString('utf-8'); return raw.trim() ? JSON.parse(raw) as unknown : {}; } async function handleRequest( request: IncomingMessage, response: ServerResponse, context: Required> & { uiPath: string; staticRoot: string; host: string; port: number; authToken: string; mediaProxyDependencies?: ReviewUiMediaProxyDependencies; handlers: ReviewUiServerHandlers; }, ): Promise { setSecurityHeaders(response); const url = new URL(request.url ?? '/', 'http://localhost'); if (!requestAuthorityIsTrusted(request, context.host, context.port)) return sendText(response, 403, 'Untrusted Review UI origin or host'); const bootstrap = url.searchParams.get('token'); if ((url.pathname === '/' || url.pathname === '/review-ui') && bootstrap) { if (!timingSafeStringEqual(bootstrap, context.authToken)) return sendText(response, 401, 'Invalid Review UI launch token'); const projectSlug = url.searchParams.get('project') ?? context.projectSlug; response.statusCode = 303; response.setHeader('Set-Cookie', `${SESSION_COOKIE}=${encodeURIComponent(context.authToken)}; HttpOnly; SameSite=Strict; Path=/`); response.setHeader('Location', `/review-ui?project=${encodeURIComponent(projectSlug)}`); response.setHeader('Cache-Control', 'no-store'); response.end(); return; } const session = cookies(request)[SESSION_COOKIE]; if (!session || !timingSafeStringEqual(session, context.authToken)) { return sendText(response, 401, 'Open the one-time Review UI launch URL to start an authenticated session'); } if (url.pathname === '/' || url.pathname === '/review-ui') { if (request.method !== 'GET') return sendText(response, 405, 'Method not allowed'); return sendFile(response, context.uiPath); } const projectSlug = url.searchParams.get('project') ?? context.projectSlug; if (url.pathname === '/api/review-inventory') { if (request.method !== 'GET') return sendText(response, 405, 'Method not allowed'); return sendJson(response, await context.handlers.buildInventory(context.root, projectSlug)); } const mutations = new Map([ ['/api/review-decision', context.handlers.saveDecision], ['/api/storyboard-still-candidate', context.handlers.recordStillCandidate], ['/api/upscaled-still-candidate', context.handlers.recordUpscaledCandidate], ['/api/storyboard-still-request', context.handlers.recordStillRequest], ['/api/character-iteration-request', context.handlers.recordCharacterRequest], ]); for (const [path, handler] of Object.entries(context.handlers.customMutations ?? {})) { if (!path.startsWith('/api/') || mutations.has(path)) throw new Error(`Invalid or duplicate Review UI mutation path: ${path}`); mutations.set(path, handler); } const mutation = mutations.get(url.pathname) as ((root: string, slug: string, body: unknown) => Promise) | undefined; if (mutation) { if (request.method !== 'POST') return sendText(response, 405, 'Method not allowed'); if (!isJsonRequest(request)) return sendText(response, 415, 'Review UI mutations require application/json'); return sendJson(response, await mutation(context.root, projectSlug, await readJsonBody(request))); } if (url.pathname === '/api/media-proxy') { if (request.method !== 'GET') return sendText(response, 405, 'Method not allowed'); const mediaUrl = await context.handlers.resolveRemoteMedia(context.root, projectSlug, url.searchParams.get('id')); return sendProxiedMedia(response, mediaUrl, context.mediaProxyDependencies); } if (request.method !== 'GET') return sendText(response, 405, 'Method not allowed'); const localProjectPath = projectPath(context.root, url.pathname); if (localProjectPath && existsSync(localProjectPath) && (await stat(localProjectPath)).isFile()) return sendFile(response, localProjectPath); const localStaticPath = staticAssetPath(context.staticRoot, url.pathname); if (localStaticPath && existsSync(localStaticPath) && (await stat(localStaticPath)).isFile()) return sendFile(response, localStaticPath); if (!localProjectPath && !localStaticPath) return sendText(response, 403, 'Forbidden'); sendText(response, 404, 'Not found'); } export async function launchReviewUiServer( options: ReviewUiServerOptions, handlers: ReviewUiServerHandlers, ): Promise { const root = resolve(options.root); const host = options.host ?? '127.0.0.1'; const requestedPort = options.port ?? 4317; const remoteAccess = !isLoopbackHost(host); if (remoteAccess && !options.allowRemote) { throw new VclawError('invalid_flag_value', 'review-ui refuses a non-loopback --host without --allow-remote', { flag: '--host', value: host }); } if (isWildcardHost(host)) { throw new VclawError('invalid_flag_value', 'review-ui requires a concrete host address; wildcard hosts 0.0.0.0 and :: are not supported', { flag: '--host', value: host }); } const uiPath = resolve(options.uiPath ?? defaultUiPath()); const authToken = randomBytes(32).toString('base64url'); const urlFor = (port: number) => `http://${formatHost(host)}:${port}/review-ui?project=${encodeURIComponent(options.projectSlug)}&token=${encodeURIComponent(authToken)}`; const launch: ReviewUiServerLaunch = { url: urlFor(requestedPort), host, port: requestedPort, root, projectSlug: options.projectSlug, uiPath, dryRun: options.dryRun ?? false, authRequired: true, remoteAccess, }; if (options.dryRun) return launch; if (!existsSync(uiPath)) throw new VclawError('asset_not_found', `review-ui file not found: ${uiPath}`, { path: uiPath }); const server = createServer((request, response) => { handleRequest(request, response, { root, projectSlug: options.projectSlug, uiPath, staticRoot: resolve(dirname(uiPath), '..', '..'), host, port: launch.port, authToken, mediaProxyDependencies: options.mediaProxyDependencies, handlers, }).catch((error: unknown) => { const status = error instanceof ReviewUiRequestError ? error.statusCode : 500; if (!response.headersSent) sendText(response, status, error instanceof Error ? error.message : String(error)); else response.end(); }); }); await new Promise((resolveListen, rejectListen) => { server.once('error', rejectListen); server.listen(requestedPort, host, () => { server.off('error', rejectListen); const address = server.address(); if (address && typeof address === 'object') { launch.port = address.port; launch.url = urlFor(address.port); } resolveListen(); }); }); Object.defineProperty(launch, 'close', { enumerable: false, value: () => new Promise((resolveClose, rejectClose) => { server.close((error) => error ? rejectClose(error) : resolveClose()); }), }); return launch; }