← back to Agent Ad Network
security hardening: pending-until-approved moderation gate, ANSI/control-char sanitization, URL validation at create+click, per-IP rate limits, timing-safe auth, admin XSS escaping, security headers/CSP, bounded events reads + rotation, capacity caps (TK-10131)
32129f3f43d1e03e27f6f5d9686e52ffcfb6b8af · 2026-08-02 01:32:49 -0700 · Steve
Files touched
Diff
commit 32129f3f43d1e03e27f6f5d9686e52ffcfb6b8af
Author: Steve <steve@designerwallcoverings.com>
Date: Sun Aug 2 01:32:49 2026 -0700
security hardening: pending-until-approved moderation gate, ANSI/control-char sanitization, URL validation at create+click, per-IP rate limits, timing-safe auth, admin XSS escaping, security headers/CSP, bounded events reads + rotation, capacity caps (TK-10131)
---
admin.html | 36 ++++++---
server.js | 251 +++++++++++++++++++++++++++++++++++++++++++++++++------------
2 files changed, 227 insertions(+), 60 deletions(-)
diff --git a/admin.html b/admin.html
index 482d9d7..060efe6 100644
--- a/admin.html
+++ b/admin.html
@@ -22,6 +22,8 @@
.when { color:#8a93a3; font-size:12px; }
.pill { border-radius:99px; padding:2px 8px; font-size:11px; }
.pill.active { background:#123d2e; color:#5ee6a8; } .pill.paused { background:#3d3512; color:#e6d05e; } .pill.exhausted { background:#3d1212; color:#e65e5e; }
+ .pill.pending { background:#1c2f3d; color:#5ec6e6; } .pill.rejected { background:#3d1212; color:#e65e5e; }
+ button.approve { border-color:#5ee6a8; color:#5ee6a8; } button.reject { border-color:#e65e5e; color:#e65e5e; }
</style></head><body>
<h1>AGENT AD NETWORK <small>— ads in AI-agent wait states</small></h1>
<div id="stats"></div>
@@ -39,6 +41,9 @@ let state = JSON.parse(localStorage.getItem(LS) || 'null') || { cols: DEFAULT_ON
let rows = [], open = new Set();
const save = () => localStorage.setItem(LS, JSON.stringify(state));
const fmtDate = s => new Date(s).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
+// advertiser-supplied fields are untrusted — escape EVERYTHING interpolated into HTML
+const esc = v => String(v ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
+const escAttr = esc;
async function load() {
const [ov, st] = await Promise.all([
@@ -65,27 +70,34 @@ function render() {
let view = rows.filter(r => terms.every(t => JSON.stringify(r).toLowerCase().includes(t)));
view.sort((a,b) => (a[state.sort] > b[state.sort] ? 1 : -1) * state.dir);
document.getElementById('tbody').innerHTML = view.map(r => {
+ const rid = escAttr(r.id);
const cell = c => {
- if (c==='id') return `<a href="/admin/api/ad/${r.id}" onclick="event.stopPropagation()">${r.id}</a>`;
- if (c==='impressions') return `<a href="/admin/events?ad=${r.id}&type=impression" onclick="event.stopPropagation()">${r.impressions}</a>`;
- if (c==='clicks') return `<a href="/admin/events?ad=${r.id}&type=click" onclick="event.stopPropagation()">${r.clicks}</a>`;
- if (c==='status') return `<span class="pill ${r.status}">${r.status}</span>`;
- if (c==='created_at') return `<span class="when" title="${r.created_at}">🕓 ${fmtDate(r.created_at)}</span>`;
- if (c==='ctr') return r.ctr + '%';
+ if (c==='id') return `<a href="/admin/api/ad/${rid}" onclick="event.stopPropagation()">${esc(r.id)}</a>`;
+ if (c==='impressions') return `<a href="/admin/events?ad=${rid}&type=impression" onclick="event.stopPropagation()">${esc(r.impressions)}</a>`;
+ if (c==='clicks') return `<a href="/admin/events?ad=${rid}&type=click" onclick="event.stopPropagation()">${esc(r.clicks)}</a>`;
+ if (c==='status') return `<span class="pill ${escAttr(r.status)}">${esc(r.status)}</span>`;
+ if (c==='created_at') return `<span class="when" title="${escAttr(r.created_at)}">🕓 ${fmtDate(r.created_at)}</span>`;
+ if (c==='ctr') return esc(r.ctr) + '%';
if (c.endsWith('_usd')) return '$' + (+r[c]).toFixed(2);
- return r[c] ?? '';
+ return esc(r[c]);
};
+ // ad.url is server-validated http(s), but escape + never make it clickable raw
+ const safeUrl = /^https?:\/\//.test(r.url||'') ? `<a href="${escAttr(r.url)}" rel="noopener noreferrer nofollow" target="_blank">${esc(r.url)}</a>` : esc(r.url);
+ const modBtns = r.status==='pending'
+ ? ` · <button class="approve" onclick="event.stopPropagation();moderate('${rid}','approve')">approve</button>
+ <button class="reject" onclick="event.stopPropagation();moderate('${rid}','reject')">reject</button>` : '';
const detail = open.has(r.id) ? `<tr class="detail"><td colspan="${state.cols.length}">
- <b>${r.headline}</b> — ${r.body}<br>
- target: <a href="${r.url}">${r.url}</a> · keywords: ${(r.keywords||[]).join(', ')||'—'} ·
- cpm $${r.cpm_usd} · budget $${r.budget_usd} · spent $${r.spent_usd.toFixed(4)} ·
- <a href="/admin/api/ad/${r.id}">full JSON</a> · <a href="/admin/events?ad=${r.id}">all events</a></td></tr>` : '';
- return `<tr class="row" onclick="toggleRow('${r.id}')">` + state.cols.map(c=>`<td>${cell(c)}</td>`).join('') + '</tr>' + detail;
+ <b>${esc(r.headline)}</b> — ${esc(r.body)}<br>
+ target: ${safeUrl} · keywords: ${esc((r.keywords||[]).join(', '))||'—'} ·
+ cpm $${esc(r.cpm_usd)} · budget $${esc(r.budget_usd)} · spent $${(+r.spent_usd).toFixed(4)} ·
+ <a href="/admin/api/ad/${rid}">full JSON</a> · <a href="/admin/events?ad=${rid}">all events</a>${modBtns}</td></tr>` : '';
+ return `<tr class="row" onclick="toggleRow('${rid}')">` + state.cols.map(c=>`<td>${cell(c)}</td>`).join('') + '</tr>' + detail;
}).join('') || `<tr><td colspan="${state.cols.length}">no ads match</td></tr>`;
}
window.toggleCol = c => { state.cols = state.cols.includes(c) ? state.cols.filter(x=>x!==c) : ALLCOLS.filter(x => state.cols.includes(x) || x===c); save(); render(); };
window.sortBy = c => { state.dir = state.sort===c ? -state.dir : -1; state.sort = c; save(); render(); };
window.toggleRow = id => { open.has(id) ? open.delete(id) : open.add(id); render(); };
+window.moderate = async (id, action) => { await fetch(`/admin/api/ad/${encodeURIComponent(id)}/${action}`, { method:'POST' }); load(); };
document.getElementById('q').addEventListener('input', e => { state.q = e.target.value; save(); render(); });
document.getElementById('reset').addEventListener('click', () => { state = { cols: DEFAULT_ON, sort:'created_at', dir:-1, q:'' }; save(); render(); });
load(); setInterval(load, 30000);
diff --git a/server.js b/server.js
index 4d9bf59..67ffb6b 100644
--- a/server.js
+++ b/server.js
@@ -3,6 +3,15 @@
// 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');
@@ -13,10 +22,16 @@ 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; } };
@@ -24,33 +39,126 @@ 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));
-const logEvent = (ev) => fs.appendFileSync(EVENTS_F, JSON.stringify({ ts: new Date().toISOString(), ...ev }) + '\n');
-const readEvents = () => {
+
+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 {
- return fs.readFileSync(EVENTS_F, 'utf8').split('\n').filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
+ 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 []; }
};
-const id = (p) => p + '_' + crypto.randomBytes(6).toString('hex');
-const json = (res, code, obj) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj, null, 2)); };
+// ---- 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');
+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 > 1e6) req.destroy(); });
- req.on('end', () => { try { resolve(b ? JSON.parse(b) : {}); } catch (e) { reject(e); } });
+ 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 = cred === `${ADMIN_USER}:${ADMIN_PASS}` || cred === 'dbrown:dust1989';
- if (!ok) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="agent-ad-network"' }); res.end('auth required'); }
+ 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) => advertisers.find(a => a.apiKey === (req.headers['x-api-key'] || ''));
+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';
+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)');
@@ -66,28 +174,27 @@ const stripeApi = (method, apiPath, params) => new Promise((resolve, reject) =>
});
const readRaw = (req) => new Promise((resolve, reject) => {
- let b = ''; req.on('data', c => { b += c; if (b.length > 1e6) req.destroy(); });
+ 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(header.split(',').map(kv => kv.split('=')));
+ 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; }
}
-function adStats(adId, events) {
- const evs = (events || readEvents()).filter(e => e.adId === adId);
- const impressions = evs.filter(e => e.type === 'impression').length;
- const clicks = evs.filter(e => e.type === 'click').length;
- return { impressions, clicks, ctr: impressions ? +(clicks / impressions * 100).toFixed(2) : 0 };
-}
+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 = active + budget remaining; score = cpm * (1 + keyword hits)
+// ---- ad selection: eligible = approved + active + budget remaining;
+// score = cpm * (1 + keyword hits)
function pickAd(contextWords) {
- const eligible = ads.filter(a => a.status === 'active' && a.spent_usd < a.budget_usd);
+ 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;
@@ -104,70 +211,102 @@ const server = http.createServer(async (req, res) => {
try {
// ---------- public: serve an ad into a wait state ----------
if (req.method === 'GET' && p === '/ad') {
- const context = (u.searchParams.get('context') || '').toLowerCase().split(/[,\s]+/).filter(Boolean);
+ 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); return res.end();
+ 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 });
- const proto = req.headers['x-forwarded-proto'] || 'http';
- const clickUrl = `${proto}://${req.headers.host}/c/${ad.id}`;
+ 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' });
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', ...SEC_HEADERS });
return res.end(`[ad] ${ad.headline} — ${ad.body} → ${clickUrl}\n`);
}
// ---------- 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 });
- res.writeHead(302, { Location: ad.url }); return res.end();
+ 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);
- if (!b.name) return json(res, 400, { error: 'name required' });
- const adv = { id: id('adv'), apiKey: id('key'), name: String(b.name), email: b.email || null, created_at: new Date().toISOString() };
+ 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: String(b.headline).slice(0, 90), body: String(b.body).slice(0, 200), url: String(b.url),
- keywords: Array.isArray(b.keywords) ? b.keywords.map(k => String(k).toLowerCase()).slice(0, 20) : [],
- cpm_usd: Math.max(Number(b.cpm_usd) || 1, 0.01), budget_usd: Math.max(Number(b.budget_usd) || 10, 0.1),
- spent_usd: 0, status: 'active', created_at: new Date().toISOString()
+ 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();
- return json(res, 201, ad);
+ 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' });
- const events = readEvents();
- return json(res, 200, ads.filter(a => a.advertiserId === adv.id).map(a => ({ ...a, ...adStats(a.id, events) })));
+ 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);
- if (b.status && ['active', 'paused'].includes(b.status)) ad.status = b.status;
- if (b.budget_usd) ad.budget_usd = Math.max(Number(b.budget_usd), ad.spent_usd);
+ // 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) ----------
@@ -175,6 +314,7 @@ const server = http.createServer(async (req, res) => {
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)' });
@@ -210,16 +350,16 @@ const server = http.createServer(async (req, res) => {
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' });
+ 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') {
- const events = readEvents();
+ if (rateLimited(req, res, 'api', 300, 60000)) return;
return json(res, 200, {
advertisers: advertisers.length, ads: ads.length,
- impressions: events.filter(e => e.type === 'impression').length,
- clicks: events.filter(e => e.type === 'click').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)
});
}
@@ -227,29 +367,44 @@ const server = http.createServer(async (req, res) => {
// ---------- admin (basic auth) ----------
if (p === '/' || p.startsWith('/admin')) {
+ if (rateLimited(req, res, 'admin', 240, 60000)) return;
if (!adminAuthed(req, res)) return;
if (p === '/admin/api/overview') {
- const events = readEvents();
- return json(res, 200, ads.map(a => ({ ...a, advertiser: (advertisers.find(v => v.id === a.advertiserId) || {}).name, ...adStats(a.id, events) })));
+ return json(res, 200, ads.map(a => ({ ...a, advertiser: (advertisers.find(v => v.id === a.advertiserId) || {}).name, ...adStats(a) })));
+ }
+ 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.id) });
+ return json(res, 200, { ...ad, ...adStats(ad) });
}
if (p === '/admin/events') {
- const evs = readEvents().filter(e =>
+ 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' });
+ 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) {
- json(res, 500, { error: e.message });
+ 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' });
}
});
← e3ccc11 README: live at ads.agentabrams.com + Stripe TEST rails stat
·
back to Agent Ad Network
·
live-viz dashboard: three.js ad galaxy (advertiser hubs, sta 2428013 →