← 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}`));