← back to Norma
agents/shared/agent-base.js
384 lines
/**
* Norma Agent Base — Express Server Factory
*
* All 5 Norma agents (reddit, twitter, discord, twitch, moveon) import this
* to get a pre-configured Express app with auth, health checks, audit logging,
* cron management, and skill route wiring.
*
* Usage:
* const { createAgentServer } = require('../shared/agent-base');
* const { app, start } = createAgentServer({
* name: 'reddit-agent',
* port: 9801,
* skills: { discover, post, reply, monitor, report },
* cronJobs: [{ name: 'monitor', schedule: '0,30 * * * *', fn: monitorFn }],
* });
* start();
*/
const express = require('express');
const helmet = require('helmet');
const { getRecentActions } = require('./audit-logger');
const { CronScheduler } = require('./cron-scheduler');
const AUTH_USER = process.env.AUTH_USERNAME || 'admin';
const AUTH_PASS = process.env.AUTH_PASSWORD || '';
// Static CSS for the read-only status dashboard (GET /) — hoisted out of the
// per-request handler so it's defined once, not rebuilt on every page load.
const DASHBOARD_CSS = `
:root{--bg:#0e1116;--card:#171b22;--line:#242a33;--fg:#e6edf3;--mut:#8b949e;--ok:#3fb950;--warn:#d29922;--bad:#f85149;--accent:#58a6ff}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
header{padding:20px 24px;border-bottom:1px solid var(--line);display:flex;align-items:baseline;gap:14px;flex-wrap:wrap}
h1{font-size:18px;margin:0;font-weight:600}.sub{color:var(--mut);font-size:13px}
.dot{width:9px;height:9px;border-radius:50%;display:inline-block;margin-right:6px}
main{padding:20px 24px;display:grid;gap:18px;max-width:1000px}
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:16px 18px}
.card h2{font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--mut);margin:0 0 12px}
table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:7px 10px;border-bottom:1px solid var(--line);font-size:13px}
th{color:var(--mut);font-weight:500}tr:last-child td{border-bottom:0}
.pill{padding:2px 8px;border-radius:999px;font-size:12px;font-weight:600}
.pill.on{background:rgba(63,185,80,.15);color:var(--ok)}.pill.off{background:rgba(139,148,158,.15);color:var(--mut)}
.chips{display:flex;gap:8px;flex-wrap:wrap}.chip{background:#20262e;border:1px solid var(--line);border-radius:6px;padding:3px 10px;font-size:12px}
.muted{color:var(--mut)}code{background:#20262e;padding:1px 6px;border-radius:4px}
.foot{color:var(--mut);font-size:12px;padding:0 24px 24px}
.badge{padding:2px 9px;border-radius:999px;font-size:11px;font-weight:700;letter-spacing:.04em}
.badge.live{background:rgba(63,185,80,.15);color:var(--ok)}.badge.sim{background:rgba(210,153,34,.15);color:var(--warn)}
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:12px}
.stat{background:#20262e;border:1px solid var(--line);border-radius:8px;padding:12px 14px}
.stat .v{font-size:22px;font-weight:700}.stat .l{color:var(--mut);font-size:12px;margin-top:2px}.stat .s{color:var(--mut);font-size:11px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:12px}
.tile{border:1px solid var(--line);border-radius:8px;overflow:hidden;background:#20262e;text-decoration:none;color:inherit;display:flex;flex-direction:column}
.tile .ph{aspect-ratio:1;width:100%;object-fit:cover;display:block;background:linear-gradient(135deg,#2a3340,#3a2a40)}
.tile .cap{padding:8px 10px;font-size:12px;color:var(--fg);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.tile .meta{padding:0 10px 8px;font-size:11px;color:var(--mut)}
`;
/**
* Create a fully configured Express agent server.
*
* @param {Object} config
* @param {string} config.name - Agent name (e.g. 'reddit-agent')
* @param {number} config.port - Port to listen on (e.g. 9801)
* @param {Object} [config.skills={}] - Skill handler functions { discover, post, reply, monitor, report }
* @param {Array} [config.cronJobs=[]] - Array of { name, schedule, fn, options } for cron scheduling
* @returns {{ app: express.Application, start: Function, scheduler: CronScheduler }}
*/
function createAgentServer({ name, port, skills = {}, cronJobs = [], dashboard = null }) {
// `dashboard` (optional) is an async () => { mode, metrics:[{label,value,sub?}],
// media:[{thumb,caption,permalink,type,when}], note? } supplied by the agent so
// the shared base stays generic — it renders whatever metrics[]/media[] it gets.
const app = express();
// Security headers via helmet (added 2026-05-04 overnight YOLO loop)
app.use(helmet({ contentSecurityPolicy: false }));
const scheduler = new CronScheduler(name);
// Track last action for status endpoint
let lastAction = null;
// ──────────────────────────────────────
// Middleware
// ──────────────────────────────────────
// JSON body parsing with 5MB limit
app.use(express.json({ limit: '5mb' }));
// CORS — allow all origins (internal agents, dashboards, dev tools)
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') {
return res.sendStatus(204);
}
next();
});
// Request logging
app.use((req, res, next) => {
if (req.path !== '/health') {
console.log(`[${new Date().toISOString()}] [${name}] ${req.method} ${req.path}`);
}
next();
});
// ──────────────────────────────────────
// Health endpoint (NO auth required)
// ──────────────────────────────────────
app.get('/health', (req, res) => {
res.json({
status: 'ok',
agent: name,
port: port,
uptime: process.uptime(),
});
});
// ──────────────────────────────────────
// Basic Auth middleware (for everything below)
// ──────────────────────────────────────
app.use((req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Basic ')) {
res.setHeader('WWW-Authenticate', 'Basic realm="Norma Agent"');
return res.status(401).json({ error: 'Authentication required' });
}
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf-8');
const [user, pass] = decoded.split(':');
if (user !== AUTH_USER || pass !== AUTH_PASS) {
return res.status(403).json({ error: 'Invalid credentials' });
}
next();
});
// ──────────────────────────────────────
// Read-only status dashboard (GET /) — behind auth.
// Data is server-RENDERED (not client-fetched) so it never depends on
// the browser re-sending Basic auth on a sub-request. Refresh = reload.
// No write controls; cron start/stop stay API/skill-only by design.
// ──────────────────────────────────────
app.get('/', async (req, res) => {
const cronStatus = scheduler.status();
let audit = [];
try { audit = await getRecentActions(name, 20); } catch { audit = []; }
let dash = null;
if (typeof dashboard === 'function') {
try { dash = await dashboard(); }
catch (e) { dash = { error: e.message }; }
}
const data = {
agent: name,
port,
uptime: process.uptime(),
skills: Object.keys(skills),
monitors: cronStatus.jobs.filter((j) => j.running).map((j) => j.name),
lastAction,
cron: cronStatus,
audit,
dash,
};
const DATA = JSON.stringify(data).replace(/</g, '\\u003c');
res.type('html').send(`<!doctype html><html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>${name} — status</title>
<style>${DASHBOARD_CSS}</style></head><body>
<header>
<h1><span id="dot" class="dot" style="background:var(--mut)"></span>${name}</h1>
<span class="sub">port ${port} · read-only · auto-refresh 15s</span>
<span class="sub" id="uptime"></span>
</header>
<main>
<div class="card"><h2>Account insights <span id="modeBadge"></span></h2>
<div id="stats" class="stats"><span class="muted">loading…</span></div>
<div id="dashNote" class="sub" style="margin-top:10px"></div></div>
<div class="card"><h2>Recent media</h2>
<div id="media" class="grid"><span class="muted">loading…</span></div></div>
<div class="card"><h2>Skills & monitors</h2><div id="skills" class="chips muted">loading…</div>
<div style="margin-top:12px" id="monitors" class="chips"></div></div>
<div class="card"><h2>Cron jobs</h2>
<table><thead><tr><th>Job</th><th>Schedule</th><th>Running</th><th>Last run</th><th>Status</th><th>Runs</th></tr></thead>
<tbody id="cron"><tr><td colspan="6" class="muted">loading…</td></tr></tbody></table></div>
<div class="card"><h2>Recent activity</h2>
<table><thead><tr><th>When</th><th>Action</th><th>Detail</th></tr></thead>
<tbody id="audit"><tr><td colspan="3" class="muted">loading…</td></tr></tbody></table></div>
</main>
<div class="foot">Control is API/skill-driven — see <code>/api/status</code>, <code>/api/audit?limit=50</code>, <code>/api/cron/status</code>, <code>POST /api/cron/{start,stop}</code>, or the <code>instagram-account-manager</code> skill.</div>
<script>
const S=${DATA};
const esc=s=>String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const fmt=t=>{if(!t)return '<span class="muted">—</span>';const d=new Date(t);return isNaN(d)?esc(t):d.toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});};
document.getElementById('dot').style.background='var(--ok)';
document.getElementById('uptime').textContent='up '+Math.floor((S.uptime||0)/60)+'m';
document.getElementById('skills').innerHTML=(S.skills||[]).map(x=>'<span class="chip">'+esc(x)+'</span>').join('')||'<span class="muted">none</span>';
document.getElementById('monitors').innerHTML=(S.monitors||[]).map(x=>'<span class="chip">🟢 '+esc(x)+'</span>').join('');
const jobs=(S.cron&&S.cron.jobs)||[];
document.getElementById('cron').innerHTML=jobs.length?jobs.map(jb=>'<tr><td>'+esc(jb.name)+'</td><td><code>'+esc(jb.schedule)+'</code></td><td><span class="pill '+(jb.running?'on':'off')+'">'+(jb.running?'on':'off')+'</span></td><td>'+fmt(jb.lastRun)+'</td><td>'+(jb.lastError?'<span style="color:var(--bad)">error</span>':esc(jb.lastStatus||'—'))+'</td><td>'+(jb.runCount||0)+'</td></tr>').join(''):'<tr><td colspan="6" class="muted">no cron jobs</td></tr>';
const acts=(S.audit||[]);
document.getElementById('audit').innerHTML=acts.length?acts.map(x=>'<tr><td>'+fmt(x.timestamp||x.ts||x.time)+'</td><td>'+esc(x.action||x.type||x.event||'—')+'</td><td class="muted">'+esc(x.detail||x.summary||x.message||'')+'</td></tr>').join(''):'<tr><td colspan="3" class="muted">no recorded activity</td></tr>';
// --- Account insights + media (from the agent's dashboard() hook) ---
const D=S.dash||{};
const mode=D.mode||(D.error?'error':'unknown');
const mb=document.getElementById('modeBadge');
if(mb)mb.innerHTML=mode==='live'?'<span class="badge live">LIVE</span>':(mode==='simulation'?'<span class="badge sim">SIMULATION</span>':'');
const stats=document.getElementById('stats');
if(D.error){stats.innerHTML='<span style="color:var(--bad)">dashboard error: '+esc(D.error)+'</span>';}
else{const ms=(D.metrics||[]);stats.innerHTML=ms.length?ms.map(m=>'<div class="stat"><div class="v">'+esc(m.value)+'</div><div class="l">'+esc(m.label)+'</div>'+(m.sub?'<div class="s">'+esc(m.sub)+'</div>':'')+'</div>').join(''):'<span class="muted">no metrics available</span>';}
document.getElementById('dashNote').textContent=D.note||'';
const media=document.getElementById('media');const mm=(D.media||[]);
media.innerHTML=mm.length?mm.map(x=>{const cap=esc(x.caption||'');const link=(x.permalink&&/^https?:/i.test(x.permalink))?esc(x.permalink):'#';const thumb=(x.thumb&&/^https?:/i.test(x.thumb))?esc(x.thumb):'';const img=thumb?'<img class="ph" data-fallback="1" src="'+thumb+'" loading="lazy">':'<div class="ph"></div>';return '<a class="tile" href="'+link+'" target="_blank" rel="noopener noreferrer">'+img+'<div class="cap">'+(cap||'<span class="muted">no caption</span>')+'</div><div class="meta">'+esc(x.type||'')+(x.when?' · '+fmt(x.when):'')+'</div></a>';}).join(''):'<span class="muted">no media</span>';
document.querySelectorAll('img.ph[data-fallback]').forEach(im=>{im.onerror=function(){const d=document.createElement('div');d.className='ph';im.replaceWith(d);};});
setTimeout(()=>location.reload(),15000);
</script></body></html>`);
});
// ──────────────────────────────────────
// Status endpoint
// ──────────────────────────────────────
app.get('/api/status', (req, res) => {
const skillNames = Object.keys(skills);
const cronStatus = scheduler.status();
res.json({
agent: name,
port: port,
uptime: process.uptime(),
skills: skillNames,
monitors: cronStatus.jobs.filter((j) => j.running).map((j) => j.name),
lastAction: lastAction,
cron: cronStatus,
});
});
// ──────────────────────────────────────
// Audit endpoint
// ──────────────────────────────────────
app.get('/api/audit', async (req, res) => {
try {
const limit = Math.min(parseInt(req.query.limit) || 50, 200);
const actions = await getRecentActions(name, limit);
res.json({ agent: name, count: actions.length, actions });
} catch (err) {
console.error(`[${new Date().toISOString()}] [${name}] Audit fetch error:`, err.message);
res.status(500).json({ error: 'Failed to fetch audit log' });
}
});
// ──────────────────────────────────────
// Cron management endpoints
// ──────────────────────────────────────
app.get('/api/cron/status', (req, res) => {
res.json(scheduler.status());
});
app.post('/api/cron/start', (req, res) => {
const { job } = req.body || {};
try {
if (job) {
scheduler.start(job);
res.json({ message: `Job "${job}" started`, status: scheduler.status() });
} else {
scheduler.startAll();
res.json({ message: 'All jobs started', status: scheduler.status() });
}
} catch (err) {
res.status(400).json({ error: err.message });
}
});
app.post('/api/cron/stop', (req, res) => {
const { job } = req.body || {};
try {
if (job) {
scheduler.stop(job);
res.json({ message: `Job "${job}" stopped`, status: scheduler.status() });
} else {
scheduler.stopAll();
res.json({ message: 'All jobs stopped', status: scheduler.status() });
}
} catch (err) {
res.status(400).json({ error: err.message });
}
});
// ──────────────────────────────────────
// Skill route wiring
// Each skill handler receives (req, res) and should
// return results. The base wraps them with error handling.
// ──────────────────────────────────────
const skillNames = ['discover', 'post', 'reply', 'monitor', 'report'];
for (const skillName of skillNames) {
app.post(`/api/skill/${skillName}`, async (req, res) => {
const handler = skills[skillName];
if (!handler) {
return res.status(404).json({
error: `Skill "${skillName}" is not implemented by ${name}`,
availableSkills: Object.keys(skills),
});
}
try {
const result = await handler(req.body, req);
lastAction = {
skill: skillName,
timestamp: new Date().toISOString(),
status: 'success',
};
res.json({ success: true, skill: skillName, result });
} catch (err) {
lastAction = {
skill: skillName,
timestamp: new Date().toISOString(),
status: 'error',
error: err.message,
};
console.error(`[${new Date().toISOString()}] [${name}] Skill "${skillName}" error:`, err.message);
res.status(500).json({ success: false, skill: skillName, error: err.message });
}
});
}
// ──────────────────────────────────────
// Register cron jobs
// ──────────────────────────────────────
for (const cronJob of cronJobs) {
scheduler.add(cronJob.name, cronJob.schedule, cronJob.fn, cronJob.options || {});
}
// ──────────────────────────────────────
// 404 + error fallbacks — registered by start() AFTER any custom routes
// the caller adds. Registering the '*' catch-all inline here shadowed
// caller routes (e.g. instagram-agent's /api/skill/reel + /story 404'd).
// Fixed 2026-07-10: defer registration to start(), which every agent
// calls LAST, so custom routes always come before the catch-all.
// ──────────────────────────────────────
function registerFallbacks() {
app.use('*', (req, res) => {
res.status(404).json({
error: 'Route not found',
agent: name,
availableEndpoints: [
'GET /',
'GET /health',
'GET /api/status',
'GET /api/audit?limit=50',
'GET /api/cron/status',
'POST /api/cron/start',
'POST /api/cron/stop',
...Object.keys(skills).map((s) => `POST /api/skill/${s}`),
],
});
});
app.use((err, req, res, _next) => {
console.error(`[${new Date().toISOString()}] [${name}] Unhandled error:`, err.message);
res.status(500).json({ error: 'Internal server error', message: err.message });
});
}
// ──────────────────────────────────────
// Start function
// ──────────────────────────────────────
function start() {
registerFallbacks(); // after caller's custom routes → no shadowing
app.listen(port, () => {
console.log(`[${new Date().toISOString()}] [${name}] Agent running on port ${port}`);
console.log(`[${new Date().toISOString()}] [${name}] Skills: ${Object.keys(skills).join(', ') || 'none'}`);
console.log(`[${new Date().toISOString()}] [${name}] Cron jobs: ${cronJobs.length}`);
// Auto-start cron jobs
if (cronJobs.length > 0) {
scheduler.startAll();
}
});
}
return { app, start, scheduler };
}
module.exports = { createAgentServer };