← 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 &amp; 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 };