feat(ui): TDS migration stage 1 — EJS layout, CSP, vendored design system, shared runtime

- Vendor web_template base.css/base.js (v1.2 bbec859) under public/web_template with sync script
- Add views/layout.ejs (fixed upstream EJS comment delimiters, mobile drawer, cmd palette,
  keys help, WS status, theme toggle) and lib/render.js two-step renderer
- Add page routes for /, /workers, /workflows, /executions, /quick, /scheduler (stub views)
- helmet with strict nonce CSP (script-src-attr 'none'), /csp-report, report-only toggle
- lib/pageauth.js: shared users upsert + HTML 401/403 for page routes
- PULSE_DEV_READONLY guard disabling background writers for local testing
- public/assets/app.js shared runtime (Pulse namespace, delegated actions, WS singleton,
  themed confirm modal) and app.css
- eslint globals for browser files; ignore vendored assets

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HamVMDrA8RqhyxmUHgiqRp
This commit is contained in:
2026-09-08 21:46:24 -04:00
co-authored by Claude Fable 5.1
parent 53b61249b0
commit cbb91a306d
24 changed files with 10750 additions and 38 deletions
+149 -34
View File
@@ -6,16 +6,62 @@ const crypto = require('crypto');
const vm = require('vm');
const rateLimit = require('express-rate-limit');
const cronParser = require('cron-parser');
const path = require('path');
const fs = require('fs');
const helmet = require('helmet');
require('dotenv').config();
const { validateWebhookUrl, applyParams, evalCondition, calculateNextRun } = require('./lib/utils');
const { PAGES } = require('./lib/nav');
const { renderPage, renderError } = require('./lib/render');
const { upsertUser, makeAuthenticatePage } = require('./lib/pageauth');
const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });
// Dev/read-only guard: skip every background job that writes to the database.
const DEV_READONLY = process.env.PULSE_DEV_READONLY === '1' || process.env.DISABLE_BACKGROUND_JOBS === '1';
// Middleware
// CSP nonce — must run before helmet so the directive can read it.
app.use((req, res, next) => {
res.locals.nonce = crypto.randomBytes(16).toString('base64');
next();
});
const CSP_REPORT_ONLY = process.env.PULSE_CSP_REPORT_ONLY === '1';
const cspDirectives = {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
scriptSrcAttr: ["'none'"],
styleSrcElem: ["'self'", 'https://fonts.googleapis.com'],
styleSrcAttr: ["'unsafe-inline'"],
styleSrc: ["'self'", 'https://fonts.googleapis.com', "'unsafe-inline'"],
fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'],
imgSrc: ["'self'", 'data:'],
connectSrc: ["'self'", 'ws:', 'wss:'],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"],
formAction: ["'self'"],
upgradeInsecureRequests: null
};
if (CSP_REPORT_ONLY) {
cspDirectives.reportUri = ['/csp-report'];
}
app.use(helmet({
contentSecurityPolicy: {
useDefaults: false,
directives: cspDirectives,
reportOnly: CSP_REPORT_ONLY
},
crossOriginEmbedderPolicy: false,
hsts: process.env.NODE_ENV === 'production' ? undefined : false
}));
app.use(express.json());
app.use(express.static('public'));
// Rate limiting
const apiLimiter = rateLimit({
@@ -32,7 +78,6 @@ const executionLimiter = rateLimit({
legacyHeaders: false,
message: { error: 'Too many execution requests, please slow down' }
});
app.use('/api/', apiLimiter);
// Named constants for timeouts and limits
const PROMPT_TIMEOUT_MS = 60 * 60 * 1000; // 60 min — how long a prompt waits for user input
@@ -53,6 +98,24 @@ app.use((req, res, next) => {
next();
});
// CSP violation reports (only useful with PULSE_CSP_REPORT_ONLY=1, but always mounted)
app.post('/csp-report',
express.json({ type: ['application/csp-report', 'application/reports+json', 'application/json'] }),
(req, res) => {
try {
console.log('[CSP]', JSON.stringify(req.body));
} catch (_) {
console.log('[CSP] (unparseable report)');
}
res.status(204).end();
});
// Static assets — the old root mount of public/ is gone on purpose.
app.use('/web_template', express.static(path.join(__dirname, 'public/web_template'), { maxAge: '1h' }));
app.use('/assets', express.static(path.join(__dirname, 'public/assets'), { maxAge: '1h' }));
app.use('/api/', apiLimiter);
// Content-Type guard for JSON endpoints
function requireJSON(req, res, next) {
const ct = req.headers['content-type'] || '';
@@ -160,19 +223,23 @@ async function initDatabase() {
`);
// Recover stale executions from a previous server crash
const [staleExecs] = await connection.query("SELECT id FROM executions WHERE status = 'running'");
if (staleExecs.length > 0) {
for (const exec of staleExecs) {
await connection.query(
"UPDATE executions SET status = 'failed', completed_at = NOW() WHERE id = ?",
[exec.id]
);
await connection.query(
"UPDATE executions SET logs = JSON_ARRAY_APPEND(COALESCE(logs, '[]'), '$', JSON_EXTRACT(?, '$')) WHERE id = ?",
[JSON.stringify({ action: 'server_restart_recovery', message: 'Execution marked failed due to server restart', timestamp: new Date().toISOString() }), exec.id]
);
if (!DEV_READONLY) {
const [staleExecs] = await connection.query("SELECT id FROM executions WHERE status = 'running'");
if (staleExecs.length > 0) {
for (const exec of staleExecs) {
await connection.query(
"UPDATE executions SET status = 'failed', completed_at = NOW() WHERE id = ?",
[exec.id]
);
await connection.query(
"UPDATE executions SET logs = JSON_ARRAY_APPEND(COALESCE(logs, '[]'), '$', JSON_EXTRACT(?, '$')) WHERE id = ?",
[JSON.stringify({ action: 'server_restart_recovery', message: 'Execution marked failed due to server restart', timestamp: new Date().toISOString() }), exec.id]
);
}
console.log(`[Recovery] Marked ${staleExecs.length} stale execution(s) as failed`);
}
console.log(`[Recovery] Marked ${staleExecs.length} stale execution(s) as failed`);
} else {
console.log('[DEV] skipped: stale-execution recovery');
}
console.log('Database tables initialized successfully');
@@ -202,10 +269,14 @@ async function cleanupOldExecutions() {
}
}
// Run cleanup hourly
setInterval(cleanupOldExecutions, 60 * 60 * 1000);
// Run cleanup on startup
cleanupOldExecutions();
if (!DEV_READONLY) {
// Run cleanup hourly
setInterval(cleanupOldExecutions, 60 * 60 * 1000);
// Run cleanup on startup
cleanupOldExecutions();
} else {
console.log('[DEV] skipped: cleanupOldExecutions interval + startup run');
}
// Scheduled Commands Processor
async function processScheduledCommands() {
@@ -292,10 +363,14 @@ async function processScheduledCommands() {
}
// Run scheduler every minute
setInterval(processScheduledCommands, 60 * 1000);
// Initial run on startup
setTimeout(processScheduledCommands, 5000);
if (!DEV_READONLY) {
// Run scheduler every minute
setInterval(processScheduledCommands, 60 * 1000);
// Initial run on startup
setTimeout(processScheduledCommands, 5000);
} else {
console.log('[DEV] skipped: processScheduledCommands interval + startup run');
}
// Mark workers offline when their heartbeat goes stale
async function markStaleWorkersOffline() {
@@ -314,7 +389,11 @@ async function markStaleWorkersOffline() {
console.error('[Worker] Stale check error:', error);
}
}
setInterval(markStaleWorkersOffline, 60_000);
if (!DEV_READONLY) {
setInterval(markStaleWorkersOffline, 60_000);
} else {
console.log('[DEV] skipped: markStaleWorkersOffline interval');
}
// WebSocket connections
const browserClients = new Set(); // Browser UI connections
@@ -624,17 +703,7 @@ async function authenticateSSO(req, res, next) {
// Store/update user in database
try {
const userId = crypto.randomUUID();
await pool.query(
`INSERT INTO users (id, username, display_name, email, groups, last_login)
VALUES (?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
display_name=VALUES(display_name),
email=VALUES(email),
groups=VALUES(groups),
last_login=NOW()`,
[userId, remoteUser, remoteName, remoteEmail, remoteGroups]
);
await upsertUser(pool, req.headers);
} catch (error) {
console.error('Error updating user:', error);
}
@@ -1082,6 +1151,28 @@ async function waitForCommandResult(executionId, commandId, timeout) {
});
}
// HTML page routes (registered before the API routes)
const authenticatePage = makeAuthenticatePage(pool);
PAGES.forEach((p) => {
app.get(p.path, authenticatePage, async (req, res, next) => {
try {
await renderPage(req, res, p.view, {
pageTitle: p.title,
activeNav: p.key,
pageScripts: [`/assets/pages/${p.view}.js`],
pageStyles: fs.existsSync(path.join(__dirname, `public/assets/pages/${p.view}.css`))
? [`/assets/pages/${p.view}.css`]
: [],
pageConfig: { isAdmin: req.user.isAdmin }
});
} catch (e) {
next(e);
}
});
});
// Routes - All protected by SSO
app.get('/api/user', authenticateSSO, (req, res) => {
res.json(req.user);
@@ -1717,6 +1808,24 @@ app.post('/api/workers/:id/command', authenticateSSO, requireJSON, async (req, r
}
});
// 404 — JSON under /api/, themed HTML elsewhere
app.use((req, res) => {
if (req.path.startsWith('/api/')) {
return res.status(404).json({ error: 'Not found' });
}
renderError(req, res, 404, 'Not found', `No page exists at ${req.path}`);
});
// Error handler
app.use((err, req, res, next) => { // eslint-disable-line no-unused-vars
console.error('[Error]', err && err.stack ? err.stack : err);
if (res.headersSent) return;
if (req.path.startsWith('/api/')) {
return res.status(500).json({ error: 'Internal server error' });
}
renderError(req, res, 500, 'Internal server error', 'Something went wrong rendering this page.');
});
// Start server
const PORT = process.env.PORT || 8080;
const HOST = process.env.HOST || '0.0.0.0';
@@ -1727,6 +1836,12 @@ initDatabase().then(() => {
console.log(`Connected to MariaDB at ${process.env.DB_HOST}`);
console.log(`Authentication: Authelia SSO`);
console.log(`Worker API Key configured: ${process.env.WORKER_API_KEY ? 'Yes' : 'No'}`);
if (DEV_READONLY) {
console.warn('************************************************************');
console.warn('* PULSE_DEV_READONLY IS ON — background jobs are disabled *');
console.warn('* No cleanup, scheduler, stale-worker or recovery writes. *');
console.warn('************************************************************');
}
});
}).catch(err => {
console.error('Failed to start server:', err);