← back to Agent Ad Network

server.js

590 lines

#!/usr/bin/env node
// agent-ad-network — ads served into AI-agent wait states (loading pauses).
// Zero-dependency Node. Advertisers (human or agent) place ads via API;
// any agent/CLI fetches GET /ad during a loading wait. Billing is ledger-only
// (budget debited per impression at CPM/1000) — no real money moves here.
//
// Security model (hardened pass, TK-10131):
// - New ads land as status:'pending' and are NEVER served until an admin
//   approves them (ads are text injected into AI-agent terminals — unreviewed
//   copy is a prompt-injection / terminal-escape vector).
// - All advertiser-supplied text is stripped of control chars / ANSI escapes.
// - Target URLs must be well-formed http(s), validated at create AND click.
// - Per-IP rate limits on every write/serve route; global capacity caps.
// - Timing-safe credential compares; security headers on every response.
'use strict';
const http = require('http');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');

const PORT = Number(process.env.PORT || 9932);
const HOST = process.env.HOST || '127.0.0.1';
const ADMIN_USER = process.env.ADMIN_USER || 'admin';
const ADMIN_PASS = process.env.ADMIN_PASS || 'DW2024!';
const ADMIN_USER2 = process.env.ADMIN_USER2 || 'dbrown';
const ADMIN_PASS2 = process.env.ADMIN_PASS2 || 'dust1989';
const DATA = path.join(__dirname, 'data');
const ADVERTISERS_F = path.join(DATA, 'advertisers.json');
const ADS_F = path.join(DATA, 'ads.json');
const EVENTS_F = path.join(DATA, 'events.jsonl');
const MAX_BODY = 64 * 1024;            // 64KB is generous for this API
const MAX_ADVERTISERS = 10000;         // capacity caps — disk-fill guard
const MAX_ADS = 50000;
const MAX_EVENTS_BYTES = 50 * 1024 * 1024; // rotate events.jsonl at 50MB

fs.mkdirSync(DATA, { recursive: true });
const loadJson = (f, fb) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return fb; } };
let advertisers = loadJson(ADVERTISERS_F, []);
let ads = loadJson(ADS_F, []);
const saveAdvertisers = () => fs.writeFileSync(ADVERTISERS_F, JSON.stringify(advertisers, null, 2));
const saveAds = () => fs.writeFileSync(ADS_F, JSON.stringify(ads, null, 2));

let eventCount = 0;
const logEvent = (ev) => {
  fs.appendFileSync(EVENTS_F, JSON.stringify({ ts: new Date().toISOString(), ...ev }) + '\n');
  if (++eventCount % 500 === 0) rotateEventsIfBig();
};
function rotateEventsIfBig() {
  try {
    if (fs.statSync(EVENTS_F).size > MAX_EVENTS_BYTES)
      fs.renameSync(EVENTS_F, EVENTS_F.replace(/\.jsonl$/, `-${Date.now()}.jsonl`));
  } catch { /* no file yet */ }
}
// tail-bounded read: only the last ~1MB of events ever enters memory
const readEventsTail = (maxBytes = 1024 * 1024) => {
  try {
    const size = fs.statSync(EVENTS_F).size;
    const start = Math.max(0, size - maxBytes);
    const fd = fs.openSync(EVENTS_F, 'r');
    const buf = Buffer.alloc(size - start);
    fs.readSync(fd, buf, 0, buf.length, start);
    fs.closeSync(fd);
    const lines = buf.toString('utf8').split('\n');
    if (start > 0) lines.shift(); // drop partial first line
    return lines.filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
  } catch { return []; }
};

// ---- one-time migration: per-ad counters replace whole-file event scans;
// grandfather pre-hardening 'active' ads as approved.
{
  let dirty = false;
  const evs = ads.some(a => a.impressions == null) ? readEventsTail(64 * 1024 * 1024) : null;
  for (const a of ads) {
    if (a.impressions == null) {
      a.impressions = evs.filter(e => e.type === 'impression' && e.adId === a.id).length;
      a.clicks = evs.filter(e => e.type === 'click' && e.adId === a.id).length;
      dirty = true;
    }
    if (a.approved == null) { a.approved = a.status === 'active' || a.status === 'exhausted'; dirty = true; }
  }
  if (dirty) saveAds();
}

const id = (p, bytes = 6) => p + '_' + crypto.randomBytes(bytes).toString('hex');
// opaque public tokens for the no-auth galaxy feed — per-process salt, so real
// ids are unrecoverable; stability across polls is all the frontend needs.
const GALAXY_SALT = crypto.randomBytes(16);
const opaque = (s) => crypto.createHmac('sha256', GALAXY_SALT).update(String(s)).digest('hex').slice(0, 10);
const SEC_HEADERS = {
  'X-Content-Type-Options': 'nosniff',
  'X-Frame-Options': 'DENY',
  'Referrer-Policy': 'no-referrer',
  'Strict-Transport-Security': 'max-age=15552000'
};
const json = (res, code, obj) => {
  res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', ...SEC_HEADERS });
  res.end(JSON.stringify(obj, null, 2));
};
const readBody = (req) => new Promise((resolve, reject) => {
  let b = ''; req.on('data', c => { b += c; if (b.length > MAX_BODY) { req.destroy(); reject(Object.assign(new Error('body too large'), { status: 413 })); } });
  req.on('end', () => { try { resolve(b ? JSON.parse(b) : {}); } catch { reject(Object.assign(new Error('invalid JSON body'), { status: 400 })); } });
  req.on('error', reject);
});

// strip control chars, ANSI escapes, and line/paragraph separators from
// advertiser text — this copy is printed into terminals and agent contexts.
const clean = (s, max) => String(s)
  .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')      // ANSI CSI sequences
  .replace(/[\x00-\x1f\x7f-\x9f\u2028\u2029]/g, ' ')
  .replace(/\s+/g, ' ').trim().slice(0, max);

function validAdUrl(raw) {
  try {
    const url = new URL(String(raw));
    if (!['http:', 'https:'].includes(url.protocol)) return null;
    if (url.username || url.password) return null;
    if (String(raw).length > 2048) return null;
    return url.href;
  } catch { return null; }
}

const safeEq = (a, b) => {
  const A = Buffer.from(String(a)), B = Buffer.from(String(b));
  return A.length === B.length && crypto.timingSafeEqual(A, B);
};

function adminAuthed(req, res) {
  const h = req.headers.authorization || '';
  const cred = h.startsWith('Basic ') ? Buffer.from(h.slice(6), 'base64').toString() : '';
  const ok = safeEq(cred, `${ADMIN_USER}:${ADMIN_PASS}`) || safeEq(cred, `${ADMIN_USER2}:${ADMIN_PASS2}`);
  if (!ok) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="agent-ad-network"', ...SEC_HEADERS }); res.end('auth required'); }
  return ok;
}
const advertiserByKey = (req) => {
  const key = String(req.headers['x-api-key'] || '');
  if (!key) return null;
  return advertisers.find(a => safeEq(a.apiKey, key)) || null;
};

// ---- per-IP rate limiting. Server binds loopback behind nginx, which
// APPENDS the real client IP to X-Forwarded-For — trust the LAST entry only.
const buckets = new Map();
function clientIp(req) {
  const xff = String(req.headers['x-forwarded-for'] || '').split(',').map(s => s.trim()).filter(Boolean);
  return xff.length ? xff[xff.length - 1] : (req.socket.remoteAddress || 'unknown');
}
function rateLimited(req, res, route, limit, windowMs) {
  const key = `${route}:${clientIp(req)}`;
  const now = Date.now();
  let b = buckets.get(key);
  if (!b || now > b.reset) { b = { count: 0, reset: now + windowMs }; buckets.set(key, b); }
  if (++b.count > limit) {
    res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': String(Math.ceil((b.reset - now) / 1000)), ...SEC_HEADERS });
    res.end(JSON.stringify({ error: 'rate limited' }));
    return true;
  }
  return false;
}
setInterval(() => { const now = Date.now(); for (const [k, b] of buckets) if (now > b.reset) buckets.delete(k); }, 60000).unref();

// ---- Stripe billing — TEST MODE ONLY (sk_test_ enforced; a live key disables billing) ----
const STRIPE_KEY = process.env.STRIPE_TEST_KEY || '';
const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET || '';
const PUBLIC_URL = (process.env.PUBLIC_URL || 'https://ads.agentabrams.com').replace(/\/+$/, '');
const stripeReady = STRIPE_KEY.startsWith('sk_test_');
if (STRIPE_KEY && !stripeReady) console.error('REFUSING non-test Stripe key — billing stays disabled (sk_test_ only)');

const stripeApi = (method, apiPath, params) => new Promise((resolve, reject) => {
  const body = params ? new URLSearchParams(params).toString() : '';
  const r = require('https').request({
    hostname: 'api.stripe.com', path: apiPath, method,
    headers: { Authorization: `Bearer ${STRIPE_KEY}`, 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(body) }
  }, res2 => { let d = ''; res2.on('data', c => d += c); res2.on('end', () => {
    try { const j = JSON.parse(d); j.error ? reject(new Error(j.error.message)) : resolve(j); } catch (e) { reject(e); }
  }); });
  r.on('error', reject); r.end(body);
});

const readRaw = (req) => new Promise((resolve, reject) => {
  let b = ''; req.on('data', c => { b += c; if (b.length > MAX_BODY) { req.destroy(); reject(Object.assign(new Error('body too large'), { status: 413 })); } });
  req.on('end', () => resolve(b)); req.on('error', reject);
});

function verifyStripeSig(raw, header) {
  if (!STRIPE_WEBHOOK_SECRET || !header) return false;
  const parts = Object.fromEntries(String(header).split(',').map(kv => kv.split('=')));
  if (!parts.t || !parts.v1) return false;
  const expected = crypto.createHmac('sha256', STRIPE_WEBHOOK_SECRET).update(`${parts.t}.${raw}`).digest('hex');
  try { return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1)); } catch { return false; }
}

const adStats = (a) => ({
  impressions: a.impressions || 0, clicks: a.clicks || 0,
  ctr: a.impressions ? +((a.clicks || 0) / a.impressions * 100).toFixed(2) : 0
});

// ---- ad selection: eligible = approved + active + budget remaining;
// score = cpm * (1 + keyword hits)
function pickAd(contextWords) {
  const eligible = ads.filter(a => a.approved && a.status === 'active' && a.spent_usd < a.budget_usd);
  if (!eligible.length) return null;
  const scored = eligible.map(a => {
    const hits = (a.keywords || []).filter(k => contextWords.includes(k.toLowerCase())).length;
    return { a, w: Math.max(a.cpm_usd, 0.01) * (1 + hits) };
  });
  let r = Math.random() * scored.reduce((s, x) => s + x.w, 0);
  for (const x of scored) { r -= x.w; if (r <= 0) return x.a; }
  return scored[scored.length - 1].a;
}

// ---------- DW-network display surface (web ad slots on sister sites) ----------
// Fill priority: a live PAID ad (billed) → a DW cross-promo → external (stub) → empty.
let DW_PROMOS = loadJson(path.join(DATA, 'dw-promos.json'), []);
function pickPromo() {
  if (!DW_PROMOS.length) return null;
  const tot = DW_PROMOS.reduce((s, p) => s + (p.weight || 1), 0);
  let r = Math.random() * tot;
  for (const p of DW_PROMOS) { r -= (p.weight || 1); if (r <= 0) return p; }
  return DW_PROMOS[DW_PROMOS.length - 1];
}
// external/affiliate fallback hook — returns null until Steve wires a real feed (gated).
function pickExternalFill() { return null; }
function pickDisplayFill(context, req, site, slot) {
  const meta = { site: site || '', slot: slot || '' };
  const paid = pickAd(context);
  if (paid) {
    paid.spent_usd = +(paid.spent_usd + paid.cpm_usd / 1000).toFixed(6);
    paid.impressions = (paid.impressions || 0) + 1;
    if (paid.spent_usd >= paid.budget_usd) paid.status = 'exhausted';
    saveAds();
    logEvent({ type: 'impression', kind: 'display', adId: paid.id, advertiserId: paid.advertiserId, ip: clientIp(req), ...meta });
    return { headline: paid.headline, body: paid.body, sponsor: paid.sponsor, clickUrl: `${PUBLIC_URL}/c/${paid.id}` };
  }
  const promo = pickPromo();
  if (promo) {
    logEvent({ type: 'impression', kind: 'promo', promoId: promo.id, ip: clientIp(req), ...meta });
    return { headline: promo.headline, body: promo.body, sponsor: promo.sponsor, clickUrl: `${PUBLIC_URL}/cp/${promo.id}` };
  }
  logEvent({ type: 'unfilled', kind: 'display', ip: clientIp(req), ...meta });
  return pickExternalFill();
}

const server = http.createServer(async (req, res) => {
  const u = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
  const p = u.pathname;
  try {
    // ---------- public: serve an ad into a wait state ----------
    if (req.method === 'GET' && p === '/ad') {
      if (rateLimited(req, res, 'ad', 120, 60000)) return;
      const context = (u.searchParams.get('context') || '').toLowerCase().slice(0, 500).split(/[,\s]+/).filter(Boolean);
      const ad = pickAd(context);
      if (!ad) { // fail-open: agents must never break on an empty network
        if ((u.searchParams.get('format') || 'text') === 'json') return json(res, 200, { ad: null });
        res.writeHead(204, SEC_HEADERS); return res.end();
      }
      ad.spent_usd = +(ad.spent_usd + ad.cpm_usd / 1000).toFixed(6);
      ad.impressions = (ad.impressions || 0) + 1;
      if (ad.spent_usd >= ad.budget_usd) ad.status = 'exhausted';
      saveAds();
      logEvent({ type: 'impression', adId: ad.id, advertiserId: ad.advertiserId, context, ip: clientIp(req) });
      const clickUrl = `${PUBLIC_URL}/c/${ad.id}`;
      if ((u.searchParams.get('format') || 'text') === 'json') {
        return json(res, 200, { ad: { id: ad.id, headline: ad.headline, body: ad.body, clickUrl, sponsor: ad.sponsor } });
      }
      res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', ...SEC_HEADERS });
      return res.end(`[ad] ${ad.headline} — ${ad.body}  →  ${clickUrl}\n`);
    }

    // ---------- public: one-line embed script for sister sites ----------
    if (req.method === 'GET' && p === '/embed.js') {
      try {
        const js = fs.readFileSync(path.join(__dirname, 'assets', 'embed.js'), 'utf8');
        res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8',
          'Access-Control-Allow-Origin': '*', 'Cache-Control': 'public, max-age=3600', ...SEC_HEADERS });
        return res.end(js);
      } catch (e) { res.writeHead(404, SEC_HEADERS); return res.end('// embed unavailable'); }
    }

    // ---------- public: serve a DISPLAY ad into a web slot (sister sites) ----------
    if (req.method === 'GET' && p === '/serve/display') {
      if (rateLimited(req, res, 'display', 240, 60000)) return;
      const context = (u.searchParams.get('context') || '').toLowerCase().slice(0, 500).split(/[,\s]+/).filter(Boolean);
      const site = (u.searchParams.get('site') || '').slice(0, 120);
      const slot = (u.searchParams.get('slot') || '').slice(0, 40);
      const fill = pickDisplayFill(context, req, site, slot);
      const H = { 'Access-Control-Allow-Origin': '*', ...SEC_HEADERS };
      if (!fill) { res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', ...H }); return res.end(JSON.stringify({ ad: null })); }
      res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', ...H });
      return res.end(JSON.stringify({ ad: fill }));
    }

    // ---------- public: DW cross-promo click-through ----------
    if (req.method === 'GET' && p.startsWith('/cp/')) {
      if (rateLimited(req, res, 'click', 60, 60000)) return;
      const promo = DW_PROMOS.find(x => x.id === p.slice(4));
      const target = promo && validAdUrl(promo.target);
      if (!target) return json(res, 404, { error: 'unknown promo' });
      logEvent({ type: 'click', kind: 'promo', promoId: promo.id, ip: clientIp(req) });
      res.writeHead(302, { Location: target, ...SEC_HEADERS }); return res.end();
    }

    // ---------- public: click-through ----------
    if (req.method === 'GET' && p.startsWith('/c/')) {
      if (rateLimited(req, res, 'click', 60, 60000)) return;
      const ad = ads.find(a => a.id === p.slice(3));
      if (!ad) return json(res, 404, { error: 'unknown ad' });
      const target = validAdUrl(ad.url); // re-validate at click time — never 302 to junk
      if (!target || !ad.approved) return json(res, 404, { error: 'unknown ad' });
      ad.clicks = (ad.clicks || 0) + 1; saveAds();
      logEvent({ type: 'click', adId: ad.id, advertiserId: ad.advertiserId, ip: clientIp(req) });
      res.writeHead(302, { Location: target, ...SEC_HEADERS }); return res.end();
    }

    // ---------- advertiser self-serve (agents place ads here) ----------
    if (req.method === 'POST' && p === '/api/advertisers') {
      if (rateLimited(req, res, 'register', 5, 3600000)) return;
      if (advertisers.length >= MAX_ADVERTISERS) return json(res, 503, { error: 'at capacity' });
      const b = await readBody(req);
      const name = clean(b.name || '', 80);
      if (!name) return json(res, 400, { error: 'name required' });
      const email = b.email ? clean(b.email, 120) : null;
      const adv = { id: id('adv'), apiKey: id('key', 24), name, email, created_at: new Date().toISOString() };
      advertisers.push(adv); saveAdvertisers();
      logEvent({ type: 'advertiser_registered', advertiserId: adv.id, ip: clientIp(req) });
      return json(res, 201, adv);
    }
    if (p === '/api/ads' && req.method === 'POST') {
      if (rateLimited(req, res, 'createAd', 30, 3600000)) return;
      if (ads.length >= MAX_ADS) return json(res, 503, { error: 'at capacity' });
      const adv = advertiserByKey(req);
      if (!adv) return json(res, 401, { error: 'valid x-api-key required (POST /api/advertisers to register)' });
      const b = await readBody(req);
      for (const f of ['headline', 'body', 'url']) if (!b[f]) return json(res, 400, { error: `${f} required` });
      const url = validAdUrl(b.url);
      if (!url) return json(res, 400, { error: 'url must be a valid http(s) URL' });
      const headline = clean(b.headline, 90), body = clean(b.body, 200);
      if (!headline || !body) return json(res, 400, { error: 'headline/body empty after sanitization' });
      const ad = {
        id: id('ad'), advertiserId: adv.id, sponsor: adv.name,
        headline, body, url,
        keywords: Array.isArray(b.keywords) ? b.keywords.map(k => clean(k, 40).toLowerCase()).filter(Boolean).slice(0, 20) : [],
        cpm_usd: Math.min(Math.max(Number(b.cpm_usd) || 1, 0.01), 100),
        budget_usd: Math.min(Math.max(Number(b.budget_usd) || 10, 0.1), 10000),
        spent_usd: 0, impressions: 0, clicks: 0,
        // security gate: pending until an admin approves — ad copy is injected
        // into AI-agent terminals, so nothing unreviewed is ever served.
        status: 'pending', approved: false,
        created_at: new Date().toISOString()
      };
      ads.push(ad); saveAds();
      logEvent({ type: 'ad_submitted', adId: ad.id, advertiserId: adv.id, ip: clientIp(req) });
      return json(res, 201, { ...ad, note: 'ad is pending review — it will serve once approved' });
    }
    if (p === '/api/ads' && req.method === 'GET') {
      if (rateLimited(req, res, 'api', 300, 60000)) return;
      const adv = advertiserByKey(req);
      if (!adv) return json(res, 401, { error: 'valid x-api-key required' });
      return json(res, 200, ads.filter(a => a.advertiserId === adv.id).map(a => ({ ...a, ...adStats(a) })));
    }
    if (p.startsWith('/api/ads/') && req.method === 'PATCH') {
      if (rateLimited(req, res, 'api', 300, 60000)) return;
      const adv = advertiserByKey(req);
      if (!adv) return json(res, 401, { error: 'valid x-api-key required' });
      const ad = ads.find(a => a.id === p.slice('/api/ads/'.length) && a.advertiserId === adv.id);
      if (!ad) return json(res, 404, { error: 'ad not found' });
      const b = await readBody(req);
      // advertisers may pause/resume — but 'active' only if admin-approved
      if (b.status === 'paused') ad.status = 'paused';
      else if (b.status === 'active') {
        if (!ad.approved) return json(res, 403, { error: 'ad not approved yet' });
        ad.status = 'active';
      }
      if (b.budget_usd != null) {
        const nb = Number(b.budget_usd);
        if (!Number.isFinite(nb) || nb <= 0) return json(res, 400, { error: 'budget_usd must be a positive number' });
        ad.budget_usd = Math.min(Math.max(nb, ad.spent_usd), 10000);
      }
      saveAds(); return json(res, 200, ad);
    }
    // ---------- billing (Stripe TEST mode only) ----------
    if (req.method === 'GET' && p === '/api/billing/status') {
      return json(res, 200, { mode: 'test', configured: stripeReady, webhook_configured: !!STRIPE_WEBHOOK_SECRET });
    }
    if (req.method === 'POST' && p === '/api/billing/checkout') {
      if (rateLimited(req, res, 'billing', 30, 3600000)) return;
      const adv = advertiserByKey(req);
      if (!adv) return json(res, 401, { error: 'valid x-api-key required' });
      if (!stripeReady) return json(res, 503, { error: 'billing not configured (TEST key pending)' });
      const b = await readBody(req);
      const amount = Math.min(Math.max(Number(b.amount_usd) || 0, 5), 500);
      if (!Number(b.amount_usd)) return json(res, 400, { error: 'amount_usd required ($5–$500 test)' });
      const session = await stripeApi('POST', '/v1/checkout/sessions', {
        mode: 'payment',
        'line_items[0][price_data][currency]': 'usd',
        'line_items[0][price_data][product_data][name]': 'Agent Ad Network — budget top-up (TEST)',
        'line_items[0][price_data][unit_amount]': String(Math.round(amount * 100)),
        'line_items[0][quantity]': '1',
        'metadata[advertiserId]': adv.id,
        success_url: `${PUBLIC_URL}/billing/success`,
        cancel_url: `${PUBLIC_URL}/billing/cancelled`
      });
      logEvent({ type: 'checkout_created', advertiserId: adv.id, usd: amount, session: session.id });
      return json(res, 200, { url: session.url, session: session.id, amount_usd: amount, mode: 'test' });
    }
    if (req.method === 'POST' && p === '/api/billing/webhook') {
      const raw = await readRaw(req);
      if (!verifyStripeSig(raw, req.headers['stripe-signature'])) return json(res, 400, { error: 'bad signature' });
      const ev = JSON.parse(raw);
      if (ev.type === 'checkout.session.completed' && ev.data.object.payment_status === 'paid') {
        const s = ev.data.object;
        const adv = advertisers.find(a => a.id === (s.metadata || {}).advertiserId);
        if (adv) {
          adv.balance_usd = +((adv.balance_usd || 0) + s.amount_total / 100).toFixed(2);
          saveAdvertisers();
          logEvent({ type: 'topup', advertiserId: adv.id, usd: s.amount_total / 100, session: s.id, mode: 'test' });
        }
      }
      return json(res, 200, { received: true });
    }
    if (req.method === 'GET' && (p === '/billing/success' || p === '/billing/cancelled')) {
      res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', ...SEC_HEADERS });
      return res.end(p.endsWith('success') ? 'Payment received (TEST mode) — your balance will update momentarily.\n' : 'Checkout cancelled.\n');
    }

    if (req.method === 'GET' && p === '/api/stats') {
      if (rateLimited(req, res, 'api', 300, 60000)) return;
      return json(res, 200, {
        advertisers: advertisers.length, ads: ads.length,
        impressions: ads.reduce((s, a) => s + (a.impressions || 0), 0),
        clicks: ads.reduce((s, a) => s + (a.clicks || 0), 0),
        booked_spend_usd: +ads.reduce((s, a) => s + a.spent_usd, 0).toFixed(4)
      });
    }
    if (req.method === 'GET' && p === '/healthz') return json(res, 200, { ok: true, ads: ads.length });

    // ---------- public landing + anonymized galaxy feed (no auth) ----------
    // Numbers only: opaque HMAC tokens replace real ad/advertiser ids (pending
    // and rejected ads' ids must never leak), and no headline/sponsor/url/
    // budget ever leaves this endpoint.
    if (req.method === 'GET' && p === '/api/galaxy') {
      if (rateLimited(req, res, 'galaxy', 120, 60000)) return;
      const statuses = {};
      for (const a of ads) statuses[a.status] = (statuses[a.status] || 0) + 1;
      return json(res, 200, {
        nodes: ads.map(a => ({
          id: opaque(a.id), hub: opaque(a.advertiserId), status: a.status,
          impressions: a.impressions || 0, clicks: a.clicks || 0
        })),
        statuses,
        totals: {
          advertisers: advertisers.length, ads: ads.length,
          impressions: ads.reduce((s, a) => s + (a.impressions || 0), 0),
          clicks: ads.reduce((s, a) => s + (a.clicks || 0), 0)
        }
      });
    }
    if (req.method === 'GET' && p === '/assets/three.min.js') {
      res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'public, max-age=86400', ...SEC_HEADERS });
      return res.end(fs.readFileSync(path.join(__dirname, 'assets', 'three.min.js')));
    }
    if (req.method === 'GET' && p === '/') {
      if (rateLimited(req, res, 'landing', 240, 60000)) return;
      res.writeHead(200, {
        'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', ...SEC_HEADERS,
        'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
      });
      return res.end(fs.readFileSync(path.join(__dirname, 'landing.html')));
    }

    // ---------- admin (basic auth) ----------
    if (p.startsWith('/admin')) {
      if (rateLimited(req, res, 'admin', 240, 60000)) return;
      if (!adminAuthed(req, res)) return;
      if (p === '/admin/api/overview') {
        return json(res, 200, ads.map(a => ({ ...a, advertiser: (advertisers.find(v => v.id === a.advertiserId) || {}).name, ...adStats(a) })));
      }
      // ----- DISPLAY network dashboard (web ad slots on sister sites) -----
      if (p === '/admin/api/display') {
        const evs = readEventsTail(6 * 1024 * 1024);
        const isImp = e => e.type === 'impression';
        let paidImpr = 0, promoImpr = 0, unfilled = 0, promoClicks = 0, paidDisplayClicks = 0;
        const promoStat = {}, siteStat = {}, slotStat = {};
        DW_PROMOS.forEach(pr => promoStat[pr.id] = { id: pr.id, headline: pr.headline, target: pr.target, weight: pr.weight || 1, impr: 0, clicks: 0 });
        const displayAdIds = new Set(ads.map(a => a.id)); // for paid-display click attribution
        for (const e of evs) {
          const site = e.site || '(unknown)', slot = e.slot || '(none)';
          if (isImp(e) && e.kind === 'display') { paidImpr++; siteStat[site] = (siteStat[site]||0)+1; slotStat[slot]=(slotStat[slot]||0)+1; }
          else if (isImp(e) && e.kind === 'promo') { promoImpr++; siteStat[site]=(siteStat[site]||0)+1; slotStat[slot]=(slotStat[slot]||0)+1; if (promoStat[e.promoId]) promoStat[e.promoId].impr++; }
          else if (e.type === 'unfilled' && e.kind === 'display') { unfilled++; }
          else if (e.type === 'click' && e.kind === 'promo') { promoClicks++; if (promoStat[e.promoId]) promoStat[e.promoId].clicks++; }
          else if (e.type === 'click' && e.adId && displayAdIds.has(e.adId)) { paidDisplayClicks++; }
        }
        const totalImpr = paidImpr + promoImpr;
        const totalClicks = promoClicks + paidDisplayClicks;
        return json(res, 200, {
          totals: {
            impressions: totalImpr, paidImpr, promoImpr, unfilled,
            fillRate: totalImpr + unfilled ? +(totalImpr / (totalImpr + unfilled) * 100).toFixed(1) : 0,
            clicks: totalClicks, promoClicks, paidDisplayClicks,
            ctr: totalImpr ? +(totalClicks / totalImpr * 100).toFixed(2) : 0,
            activeSites: Object.keys(siteStat).filter(s => s !== '(unknown)').length,
            promoPool: DW_PROMOS.length,
          },
          promos: Object.values(promoStat).sort((a,b) => b.impr - a.impr),
          sites: Object.entries(siteStat).map(([site, impr]) => ({ site, impr })).sort((a,b) => b.impr - a.impr).slice(0, 40),
          slots: Object.entries(slotStat).map(([slot, impr]) => ({ slot, impr })).sort((a,b) => b.impr - a.impr),
          paidAds: ads.filter(a => a.approved && a.status === 'active').map(a => ({ id: a.id, headline: a.headline, sponsor: a.sponsor, impressions: a.impressions||0, clicks: a.clicks||0, spent: +(a.spent_usd||0).toFixed(2), budget: a.budget_usd })),
        });
      }
      if (p === '/admin/display') {
        res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', ...SEC_HEADERS });
        return res.end(fs.readFileSync(path.join(__dirname, 'display-admin.html')));
      }
      // live-viz feed: per-minute (60m) + per-hour (24h) buckets from the
      // events tail — impressions, clicks, submissions, and unique users
      // (distinct IPs; older events without ip fall back to advertiserId).
      if (p === '/admin/api/timeseries') {
        const evs = readEventsTail(4 * 1024 * 1024);
        const now = Date.now();
        const mkBuckets = (n, stepMs) => Array.from({ length: n }, (_, i) => {
          const t = now - (n - 1 - i) * stepMs;
          return { t: new Date(Math.floor(t / stepMs) * stepMs).toISOString(), impressions: 0, clicks: 0, submissions: 0, users: new Set() };
        });
        const minutes = mkBuckets(60, 60000), hours = mkBuckets(24, 3600000);
        for (const e of evs) {
          const ts = Date.parse(e.ts); if (!ts) continue;
          const who = e.ip || e.advertiserId || null;
          for (const [buckets, stepMs] of [[minutes, 60000], [hours, 3600000]]) {
            const idx = buckets.length - 1 - Math.floor((now - ts) / stepMs);
            if (idx < 0 || idx >= buckets.length) continue;
            const b = buckets[idx];
            if (e.type === 'impression') b.impressions++;
            else if (e.type === 'click') b.clicks++;
            else if (e.type === 'ad_submitted') b.submissions++;
            if (who) b.users.add(who);
          }
        }
        const finish = (bs) => bs.map(b => ({ ...b, users: b.users.size }));
        const statuses = {};
        for (const a of ads) statuses[a.status] = (statuses[a.status] || 0) + 1;
        return json(res, 200, { minutes: finish(minutes), hours: finish(hours), statuses,
          totals: { advertisers: advertisers.length, ads: ads.length,
            impressions: ads.reduce((s, a) => s + (a.impressions || 0), 0),
            clicks: ads.reduce((s, a) => s + (a.clicks || 0), 0),
            booked_spend_usd: +ads.reduce((s, a) => s + a.spent_usd, 0).toFixed(4) } });
      }
      // locally-vendored three.js — served same-origin so CSP stays 'self'
      if (p === '/admin/assets/three.min.js') {
        res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'public, max-age=86400', ...SEC_HEADERS });
        return res.end(fs.readFileSync(path.join(__dirname, 'assets', 'three.min.js')));
      }
      const approveM = p.match(/^\/admin\/api\/ad\/([\w]+)\/(approve|reject)$/);
      if (approveM && req.method === 'POST') {
        const ad = ads.find(a => a.id === approveM[1]);
        if (!ad) return json(res, 404, { error: 'not found' });
        if (approveM[2] === 'approve') { ad.approved = true; ad.status = 'active'; }
        else { ad.approved = false; ad.status = 'rejected'; }
        saveAds();
        logEvent({ type: `ad_${approveM[2]}d`, adId: ad.id, advertiserId: ad.advertiserId });
        return json(res, 200, ad);
      }
      if (p.startsWith('/admin/api/ad/')) {
        const ad = ads.find(a => a.id === p.slice('/admin/api/ad/'.length));
        if (!ad) return json(res, 404, { error: 'not found' });
        return json(res, 200, { ...ad, ...adStats(ad) });
      }
      if (p === '/admin/events') {
        const evs = readEventsTail().filter(e =>
          (!u.searchParams.get('ad') || e.adId === u.searchParams.get('ad')) &&
          (!u.searchParams.get('type') || e.type === u.searchParams.get('type')));
        return json(res, 200, evs.slice(-500));
      }
      res.writeHead(200, {
        'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', ...SEC_HEADERS,
        'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
      });
      return res.end(fs.readFileSync(path.join(__dirname, 'admin.html')));
    }

    json(res, 404, { error: 'not found' });
  } catch (e) {
    if (e && (e.status === 400 || e.status === 413)) return json(res, e.status, { error: e.message });
    console.error('unhandled:', e);
    json(res, 500, { error: 'internal error' });
  }
});

server.listen(PORT, HOST, () => console.log(`agent-ad-network listening on http://${HOST}:${PORT}`));