ESM hoists imports anyway but a mid-file `import` statement reads as a foot-gun on review. No behaviour change; rules out one variable while diagnosing a prod boot crash. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
217 lines
11 KiB
TypeScript
217 lines
11 KiB
TypeScript
#!/usr/bin/env tsx
|
|
// V2 HTTP server: serves the operator-app SPA + JSON API.
|
|
// Routing is plain http + URL pattern matching (no Express).
|
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
import { join, resolve, normalize, extname } from 'node:path';
|
|
import { dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { envInt, envStr, IS_PRODUCTION } from './lib/env.js';
|
|
import { assertComposeNameOrExit } from './lib/compose-name-guard.js';
|
|
import { sendJSON, sendText } from './lib/http.js';
|
|
|
|
import { handleSsoTokenExchange, handleLogout } from './routes/sso.js';
|
|
import { handlePasswordLogin } from './auth/password-fallback.js';
|
|
import { handleGetMe, handlePatchActiveTeam, handleGetSession } from './routes/me.js';
|
|
import {
|
|
handleListTeams, handleCreateTeam, handleGetTeam,
|
|
handleAddMember, handleUpdateMemberRole, handleRemoveMember,
|
|
} from './routes/teams.js';
|
|
import { handleListAllUsers, handleToggleSuperAdmin } from './routes/admin.js';
|
|
import {
|
|
handleListBriefs, handleCreateBrief, handleGetBrief, handleDeleteBrief, handleUpdateBrief,
|
|
} from './routes/briefs.js';
|
|
import {
|
|
handleListReports, handleListReportsForBrief, handleGetReport, handleRunPipeline,
|
|
handleDashboardServe, handleQaSignoff, handleBuildReport, handleReportDataset,
|
|
handleRetryReport, handleCancelReport,
|
|
} from './routes/reports.js';
|
|
import { sql as bootSql } from './db/client.js';
|
|
|
|
assertComposeNameOrExit();
|
|
|
|
const PORT = envInt('PORT', 3457);
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
const SPA_DIST = resolve(__dirname, '../operator-app/dist');
|
|
const ALLOWED_ORIGIN = envStr('ALLOWED_ORIGIN');
|
|
|
|
const MIME: Record<string, string> = {
|
|
'.html': 'text/html; charset=utf-8',
|
|
'.js': 'application/javascript; charset=utf-8',
|
|
'.mjs': 'application/javascript; charset=utf-8',
|
|
'.css': 'text/css; charset=utf-8',
|
|
'.json': 'application/json; charset=utf-8',
|
|
'.svg': 'image/svg+xml',
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.ico': 'image/x-icon',
|
|
'.woff2':'font/woff2',
|
|
'.woff': 'font/woff',
|
|
};
|
|
|
|
function setSecurityHeaders(res: ServerResponse): void {
|
|
res.setHeader('X-Frame-Options', 'DENY');
|
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
res.setHeader(
|
|
'Content-Security-Policy',
|
|
"default-src 'self'; " +
|
|
"script-src 'self' 'unsafe-inline'; " +
|
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
|
|
"font-src 'self' https://fonts.gstatic.com; " +
|
|
"img-src 'self' data: blob: https:; " +
|
|
"connect-src 'self' https://login.microsoftonline.com; " +
|
|
"frame-src 'self' https://login.microsoftonline.com",
|
|
);
|
|
}
|
|
|
|
function applyCors(req: IncomingMessage, res: ServerResponse): void {
|
|
const origin = req.headers.origin || '';
|
|
if (ALLOWED_ORIGIN === '*') {
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
} else if (ALLOWED_ORIGIN && origin === ALLOWED_ORIGIN) {
|
|
res.setHeader('Access-Control-Allow-Origin', ALLOWED_ORIGIN);
|
|
res.setHeader('Vary', 'Origin');
|
|
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
}
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS');
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
}
|
|
|
|
function serveStatic(req: IncomingMessage, res: ServerResponse, urlPath: string): boolean {
|
|
if (!existsSync(SPA_DIST)) return false;
|
|
|
|
let p = urlPath === '/' ? '/index.html' : urlPath;
|
|
// Defence in depth — the URL parser already strips `..`, but normalize anyway.
|
|
p = normalize(p).replace(/^(\.\.[/\\])+/, '');
|
|
const candidate = join(SPA_DIST, p);
|
|
if (!candidate.startsWith(SPA_DIST)) return false;
|
|
|
|
if (existsSync(candidate) && statSync(candidate).isFile()) {
|
|
const ext = extname(candidate).toLowerCase();
|
|
const mime = MIME[ext] || 'application/octet-stream';
|
|
res.setHeader('Cache-Control', ext === '.html' ? 'no-cache' : 'public, max-age=3600');
|
|
res.writeHead(200, { 'Content-Type': mime });
|
|
res.end(readFileSync(candidate));
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function spaFallback(res: ServerResponse): void {
|
|
const indexFile = join(SPA_DIST, 'index.html');
|
|
if (existsSync(indexFile)) {
|
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' });
|
|
res.end(readFileSync(indexFile));
|
|
} else {
|
|
sendText(res, 503, 'Operator-app not built yet. Run: npm run ui:build');
|
|
}
|
|
}
|
|
|
|
interface Route {
|
|
method: string;
|
|
pattern: RegExp;
|
|
handle: (req: IncomingMessage, res: ServerResponse, params: string[]) => Promise<void> | void;
|
|
}
|
|
|
|
const ROUTES: Route[] = [
|
|
{ method: 'GET', pattern: /^\/api\/health$/, handle: (_req, res) => sendJSON(res, 200, { ok: true }) },
|
|
{ method: 'GET', pattern: /^\/api\/auth$/, handle: handleGetSession },
|
|
{ method: 'POST', pattern: /^\/api\/sso\/token-exchange$/, handle: handleSsoTokenExchange },
|
|
{ method: 'POST', pattern: /^\/api\/login$/, handle: handlePasswordLogin },
|
|
{ method: 'GET', pattern: /^\/api\/logout$/, handle: handleLogout },
|
|
{ method: 'GET', pattern: /^\/api\/me$/, handle: handleGetMe },
|
|
{ method: 'PATCH', pattern: /^\/api\/me\/active-team$/, handle: handlePatchActiveTeam },
|
|
{ method: 'GET', pattern: /^\/api\/teams$/, handle: handleListTeams },
|
|
{ method: 'POST', pattern: /^\/api\/teams$/, handle: handleCreateTeam },
|
|
{ method: 'GET', pattern: /^\/api\/teams\/([0-9a-f-]{36})$/, handle: (req, res, [id]) => handleGetTeam(req, res, id!) },
|
|
{ method: 'POST', pattern: /^\/api\/teams\/([0-9a-f-]{36})\/members$/, handle: (req, res, [id]) => handleAddMember(req, res, id!) },
|
|
{ method: 'PATCH', pattern: /^\/api\/teams\/([0-9a-f-]{36})\/members\/([0-9a-f-]{36})\/role$/, handle: (req, res, [t, u]) => handleUpdateMemberRole(req, res, t!, u!) },
|
|
{ method: 'DELETE', pattern: /^\/api\/teams\/([0-9a-f-]{36})\/members\/([0-9a-f-]{36})$/, handle: (req, res, [t, u]) => handleRemoveMember(req, res, t!, u!) },
|
|
{ method: 'GET', pattern: /^\/api\/admin\/users$/, handle: handleListAllUsers },
|
|
{ method: 'PATCH', pattern: /^\/api\/admin\/users\/([0-9a-f-]{36})\/super$/, handle: (req, res, [id]) => handleToggleSuperAdmin(req, res, id!) },
|
|
{ method: 'GET', pattern: /^\/api\/briefs$/, handle: handleListBriefs },
|
|
{ method: 'POST', pattern: /^\/api\/briefs$/, handle: handleCreateBrief },
|
|
{ method: 'GET', pattern: /^\/api\/briefs\/([0-9a-f-]{36})$/, handle: (req, res, [id]) => handleGetBrief(req, res, id!) },
|
|
{ method: 'PATCH', pattern: /^\/api\/briefs\/([0-9a-f-]{36})$/, handle: (req, res, [id]) => handleUpdateBrief(req, res, id!) },
|
|
{ method: 'DELETE', pattern: /^\/api\/briefs\/([0-9a-f-]{36})$/, handle: (req, res, [id]) => handleDeleteBrief(req, res, id!) },
|
|
{ method: 'POST', pattern: /^\/api\/briefs\/([0-9a-f-]{36})\/run$/, handle: (req, res, [id]) => handleRunPipeline(req, res, id!) },
|
|
{ method: 'GET', pattern: /^\/api\/briefs\/([0-9a-f-]{36})\/reports$/, handle: (req, res, [id]) => handleListReportsForBrief(req, res, id!) },
|
|
{ method: 'GET', pattern: /^\/api\/reports$/, handle: handleListReports },
|
|
{ method: 'GET', pattern: /^\/api\/reports\/([0-9a-f-]{36})$/, handle: (req, res, [id]) => handleGetReport(req, res, id!) },
|
|
{ method: 'POST', pattern: /^\/api\/reports\/([0-9a-f-]{36})\/retry$/, handle: (req, res, [id]) => handleRetryReport(req, res, id!) },
|
|
{ method: 'POST', pattern: /^\/api\/reports\/([0-9a-f-]{36})\/cancel$/, handle: (req, res, [id]) => handleCancelReport(req, res, id!) },
|
|
{ method: 'GET', pattern: /^\/api\/reports\/([0-9a-f-]{36})\/dataset$/, handle: (req, res, [id]) => handleReportDataset(req, res, id!) },
|
|
{ method: 'GET', pattern: /^\/api\/reports\/([0-9a-f-]{36})\/dashboard\/?$/, handle: (req, res, [id]) => handleDashboardServe(req, res, id!, '') },
|
|
{ method: 'GET', pattern: /^\/api\/reports\/([0-9a-f-]{36})\/dashboard\/(.+)$/, handle: (req, res, [id, sub]) => handleDashboardServe(req, res, id!, sub!) },
|
|
{ method: 'POST', pattern: /^\/api\/reports\/([0-9a-f-]{36})\/qa\/sign$/, handle: (req, res, [id]) => handleQaSignoff(req, res, id!) },
|
|
{ method: 'POST', pattern: /^\/api\/reports\/([0-9a-f-]{36})\/build$/, handle: (req, res, [id]) => handleBuildReport(req, res, id!) },
|
|
];
|
|
|
|
async function route(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
const url = new URL(req.url || '/', `http://localhost:${PORT}`);
|
|
const pathname = url.pathname;
|
|
const method = req.method || 'GET';
|
|
|
|
for (const r of ROUTES) {
|
|
if (r.method !== method) continue;
|
|
const m = r.pattern.exec(pathname);
|
|
if (!m) continue;
|
|
await r.handle(req, res, m.slice(1));
|
|
return;
|
|
}
|
|
|
|
if (pathname.startsWith('/api/')) {
|
|
sendJSON(res, 404, { error: 'Not found' });
|
|
return;
|
|
}
|
|
|
|
if (method === 'GET') {
|
|
if (serveStatic(req, res, pathname)) return;
|
|
spaFallback(res);
|
|
return;
|
|
}
|
|
sendJSON(res, 404, { error: 'Not found' });
|
|
}
|
|
|
|
const server = createServer(async (req, res) => {
|
|
setSecurityHeaders(res);
|
|
applyCors(req, res);
|
|
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
|
|
try {
|
|
await route(req, res);
|
|
} catch (err) {
|
|
console.error('[server] unhandled error:', err);
|
|
if (!res.headersSent) sendJSON(res, 500, { error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
// Orphan sweep: any report row left in a non-terminal status when the server
|
|
// boots is by definition orphaned — its child process did not survive whatever
|
|
// killed the previous server (deploy, OOM, manual restart). Without this they
|
|
// keep polling forever in the UI ("running for 14h") and the run/retry guard
|
|
// thinks one is still in flight, blocking new triggers.
|
|
async function sweepOrphans(): Promise<void> {
|
|
try {
|
|
const rows = await bootSql<Array<{ id: string }>>`
|
|
UPDATE reports SET
|
|
status = 'failed',
|
|
finished_at = NOW(),
|
|
error_message = COALESCE(error_message, 'Orphaned by server restart — pipeline child process did not survive')
|
|
WHERE status NOT IN ('completed', 'failed')
|
|
RETURNING id
|
|
`;
|
|
if (rows.length > 0) {
|
|
console.log(`[v2] swept ${rows.length} orphaned report(s) on boot: ${rows.map((r) => r.id.slice(0, 8)).join(', ')}`);
|
|
}
|
|
} catch (e) {
|
|
console.error('[v2] orphan sweep failed:', (e as Error).message);
|
|
}
|
|
}
|
|
|
|
server.listen(PORT, async () => {
|
|
console.log(`[v2] listening on :${PORT} (${IS_PRODUCTION ? 'prod' : 'dev'})`);
|
|
await sweepOrphans();
|
|
});
|