// Circle Suite — Traffic Desk. Places member banner ads on the team's own // NetworkAdSpace network through the signed bridge API (rmc-api on that host). // // Two deliberate constraints: // 1. Creative comes from the TEAM banner kit only. Members never upload art. // That removes moderation risk entirely — every ad on the network carries // approved branding — and it means a member can launch in two clicks. // 2. Allowances scale with contract level and are counted per calendar month // against a local ledger, so a member cannot spend more than their level. 'use strict'; const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const suiteGrants = require('./suite-grants'); let DATA_DIR = null; function init(opts) { DATA_DIR = opts.dataDir; } function credsFile() { return path.join(DATA_DIR, 'nas-api.json'); } function creds() { try { return JSON.parse(fs.readFileSync(credsFile(), 'utf8')); } catch (e) { return null; } } function configured() { const c = creds(); return !!(c && c.url && c.secret); } // Monthly impressions by contract level (index = level-1). Marty-approved. const ALLOWANCE = [2500, 5000, 10000, 20000, 50000, 75000, 100000, 150000]; function allowanceFor(level) { const n = Math.max(1, Math.min(8, Number(level) || 1)); return ALLOWANCE[n - 1]; } // Team banner kit — every ad uses approved creative, so nothing needs review. const CREATIVES = { '468x60': [ 'rmc-468x60-v1.png', 'rmc-468x60-v2.png', 'rmc-468x60-v3.png', 'rmc-468x60-v4.png', 'rmc-468x60-v5.png', 'rmc-468x60-v6.png', 'rmc-468x60-v7.png', 'rmc-growing-468x60-bluegreen.png' ], '728x90': [ 'rmc-728x90-v1.png', 'rmc-728x90-v2.png', 'rmc-728x90-v3.png' ], '300x250': [ 'rmc-300x250-v1.png', 'rmc-300x250-v3.png', 'rmc-300x250-v4.png' ], '160x600': [ 'rmc-160x600-v1.png', 'rmc-160x600-v2.png', 'rmc-160x600-v3.png' ], '120x600': [ 'rmc-120x600-v1.png', 'rmc-120x600-v2.png' ], '125x125': [ 'rmc-banner-125x125.png' ] }; function sizes() { return Object.keys(CREATIVES); } // ── local ledger: what each position has been granted this month ──────────── function ledgerFile() { return path.join(DATA_DIR, 'traffic-grants.json'); } function readLedger() { try { return JSON.parse(fs.readFileSync(ledgerFile(), 'utf8')); } catch (e) { return {}; } } function writeLedger(v) { try { fs.writeFileSync(ledgerFile(), JSON.stringify(v)); } catch (e) {} } function monthKey() { const d = new Date(); return d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0'); } function usage(memberId) { const all = readLedger(); const rows = ((all[monthKey()] || {})[String(memberId)]) || []; const used = rows.reduce(function (s, r) { return s + (Number(r.impressions) || 0); }, 0); return { used: used, campaigns: rows }; } function status(memberId, level) { const base = allowanceFor(level); // Impressions an upline granted this month sit on top of the level allowance. let granted = 0; try { granted = suiteGrants.receivedBy(memberId, 'traffic') || 0; } catch (e) {} const limit = base + granted; const u = usage(memberId); return { limit: limit, base: base, granted: granted, used: u.used, remaining: Math.max(0, limit - u.used), campaigns: u.campaigns, resets: monthKey(), sizes: sizes(), creatives: CREATIVES }; } function record(memberId, entry) { const all = readLedger(); const mk = monthKey(); if (!all[mk]) all[mk] = {}; const id = String(memberId); if (!all[mk][id]) all[mk][id] = []; all[mk][id].push(entry); // keep three months of history; the file stays small forever const keep = Object.keys(all).sort().slice(-3); const trimmed = {}; keep.forEach(function (k) { trimmed[k] = all[k]; }); writeLedger(trimmed); } // `remaining` counts DOWN for BOTH placement types — measured across 1,604 // network ads, and it is exactly what the bridge's stats action already // computes (`served = assigned - remaining`, falling back to hits when // assigned was never set). Trust the bridge's `served` field first. // // The earlier "banners count UP" theory (and its ad-2707 evidence) was the // network's own post-insert counter top-ups being misread; acting on it made // every fresh banner look fully delivered the moment it launched, so the // sweeper deactivated brand-new campaigns and stamped them complete (bug // Marty caught 2026-09-01: a banner "delivered all its impressions" // instantly). Do not resurrect the count-up branch. // // Returns what the member should see: served + left always reconciles to what // they bought, and neither can exceed it. function interpret(kind, bought, stat) { const b = Math.max(0, Number(bought) || 0); let served; if (stat && stat.served != null) { served = Number(stat.served) || 0; } else { // count-down: what is gone is what was bought minus what is left const rem = Math.max(0, Number(stat && stat.remaining) || 0); served = b - Math.min(rem, b); } served = Math.max(0, Math.min(served, b)); return { served: served, left: Math.max(0, b - served) }; } // ── signed call to the NAS bridge ─────────────────────────────────────────── function callNas(payload) { return new Promise(function (resolve, reject) { const c = creds(); if (!c) return reject(new Error('The ad network bridge is not configured yet.')); const body = JSON.stringify(payload); const ts = String(Math.floor(Date.now() / 1000)); const sig = crypto.createHmac('sha256', c.secret).update(ts + '.' + body).digest('hex'); const u = new URL(c.url); const lib = u.protocol === 'https:' ? require('https') : require('http'); const req = lib.request({ hostname: u.hostname, port: u.port || (u.protocol === 'https:' ? 443 : 80), path: u.pathname + (u.search || ''), method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), 'X-RMC-Signature': sig, 'X-RMC-Timestamp': ts, 'User-Agent': 'RMCircleSuite/1.0' } }, function (res) { let data = ''; res.on('data', function (d) { data += d; }); res.on('end', function () { let j = null; try { j = JSON.parse(data); } catch (e) { return reject(new Error('The ad network returned something unexpected.')); } if (!j.ok) return reject(new Error(j.error || 'The ad network rejected that.')); resolve(j); }); }); req.on('error', function (e) { reject(new Error('Could not reach the ad network: ' + e.message)); }); req.setTimeout(45000, function () { req.destroy(new Error('The ad network took too long.')); }); req.write(body); req.end(); }); } async function launch(opts) { const level = Number(opts.level) || 1; const isText = opts.kind === 'text'; let size = '', file = '', subject = '', lines = []; if (isText) { // Limits measured from live network inventory — see suite-textads.js. subject = String(opts.subject || '').trim(); lines = (Array.isArray(opts.lines) ? opts.lines : []).slice(0, 3) .map(function (l) { return String(l || '').replace(/[<>]/g, '').trim(); }); while (lines.length < 3) lines.push(''); if (!subject || !lines[0]) throw new Error('That text ad is missing its headline or first line.'); if (Array.from(subject).length > 20) throw new Error('The headline is longer than the network allows.'); if (lines.some(function (l) { return Array.from(l).length > 24; })) { throw new Error('One of those lines is longer than the network allows.'); } } else { size = String(opts.size || ''); if (!CREATIVES[size]) throw new Error('Pick one of the available banner sizes.'); file = String(opts.creative || ''); if (CREATIVES[size].indexOf(file) === -1) throw new Error('Pick one of the team banner designs.'); } const st = status(opts.id, level); const impressions = Math.max(100, Math.min(st.limit, Number(opts.impressions) || 0)); if (impressions > st.remaining) { throw new Error('That is more than your remaining ' + st.remaining.toLocaleString() + ' impressions this month.'); } // Server-side guard: only allow the personal page as a destination when one // actually exists. /p/ also redirects to /join/ when empty, so a live // banner can never dead-end — but we shouldn't create that situation at all. let target = 'https://rmcircle.team/join/' + opts.id + (opts.angle ? '?v=' + opts.angle : ''); if (opts.target === 'page') { if (!opts.hasPage) throw new Error('Build your personal page first, then you can point ads at it.'); target = 'https://rmcircle.team/p/' + opts.id; } // Tag the destination so a click can be traced back to this network. // // Without this the Traffic Desk is unmeasurable: track.js reads ?v= BEFORE // it reads the referrer, so every angle-linked ad click was filed under // "angle:graveyard" and the referring domain thrown away. Ad clicks, flyer // QR scans and massifly angle links all landed in the same four buckets, // and networkadspace.com showed a grand total of 1 visit across ~45,000 // served impressions — not because the ads produced nothing, but because // nothing carried a source. // // `src` wins over `v` in track.js, and the landing page still reads ?v= // separately, so the hook keeps working. Split by format because we believe // text outperforms banners and have never been able to show it. target += (target.indexOf('?') === -1 ? '?' : '&') + 'src=nas-' + (isText ? 'text' : 'banner'); const idem = 'rmc-' + opts.id + '-' + monthKey() + '-' + crypto.randomBytes(6).toString('hex'); const payload = { action: 'create', member_id: Number(opts.id), idem_key: idem, kind: isText ? 'text' : 'banner', // 30 days, not 365: the allowance is monthly, so a campaign that outlives the // month it was paid from makes the allowance meaningless. Belt and braces // with sweepCompleted() — whichever ends it first. impressions: impressions, days: 30, target_url: target, advertiser_name: (opts.name || 'RM Circle member #' + opts.id).slice(0, 60), advertiser_email: '', catid: 5 }; if (isText) { payload.subject = subject; payload.lines = lines; } else { payload.size = size; payload.banner_url = 'https://rmcircle.team/banners/' + file; } const res = await callNas(payload); const entry = { adId: res.ad_id, impressions: impressions, size: isText ? 'text' : size, creative: isText ? '' : file, kind: isText ? 'text' : 'banner', subject: isText ? subject : undefined, lines: isText ? lines : undefined, target: target, at: new Date().toISOString() }; record(opts.id, entry); return entry; } // Stop a running banner and return the UNSERVED impressions to the member's // monthly balance. Order matters: read the counters BEFORE deactivating, // because deactivation zeroes `remaining` and would make it look fully served. async function stop(memberId, adId) { const all = readLedger(); const mk = monthKey(); const rows = ((all[mk] || {})[String(memberId)]) || []; const pre = rows.find(function (r) { return Number(r.adId) === Number(adId); }); if (!pre) throw new Error('That banner is not one of yours from this month.'); if (pre.stopped) throw new Error('That banner is already stopped.'); let served = 0, unserved = 0; const boughtGuess = Number(pre.bought != null ? pre.bought : pre.impressions) || 0; try { const s = await callNas({ action: 'stats', ad_ids: [Number(adId)] }); const st = (s.stats || [])[0]; if (st) { // Interpret by placement type — banners count up, text counts down. const r = interpret(pre.kind === 'text' ? 'text' : 'banner', boughtGuess, st); served = r.served; unserved = r.left; } } catch (e) { // Stats unavailable: refund nothing rather than guess. Guessing high would // hand back impressions that were actually delivered. served = boughtGuess; unserved = 0; } await callNas({ action: 'deactivate', ad_id: Number(adId) }); // RE-READ before writing. Everything above this point took two network round // trips to the ad network, and the ledger object read at the top of this // function is now seconds stale. Writing it back would silently discard any // campaign another member launched in the meantime — impressions spent on the // network with no record that they were. Node's single thread makes the block // below atomic; the danger was only ever the await gap. const fresh = readLedger(); const freshRows = ((fresh[mk] || {})[String(memberId)]) || []; const row2 = freshRows.find(function (r) { return Number(r.adId) === Number(adId); }); if (!row2) throw new Error('That banner is no longer in this month\'s ledger.'); if (row2.stopped) return { adId: Number(adId), served: row2.served || 0, refunded: row2.refunded || 0 }; const all2 = fresh; const row = row2; // Charge only what actually served; the rest returns to the allowance. // `bought` preserves the original order size so the member's history still // shows what they launched, not just what it ended up costing them. if (row.bought == null) row.bought = row.impressions; // interpret() already reconciled these against what was bought. unserved = Math.min(unserved, row.bought); served = row.bought - unserved; row.stopped = true; row.stoppedAt = new Date().toISOString(); row.served = served; row.refunded = unserved; row.impressions = served; // what this campaign counts against the month writeLedger(all2); return { adId: Number(adId), served: served, refunded: unserved }; } async function stats(adIds) { if (!adIds || !adIds.length) return []; const r = await callNas({ action: 'stats', ad_ids: adIds.slice(0, 200) }); return r.stats || []; } // Deactivate anything that has delivered what was bought. // // This exists because banner placements on this network have NO CAP: the // counter runs past the purchase and the ad keeps rotating until its expiry // date. Left alone that quietly destroys the whole point of the allowance — // if 2,500 impressions buys an ad that runs forever, nobody would ever spend // 50,000, and the level ladder from Scintilla to Corona stops meaning // anything. The impressions have to be the thing you are actually spending. // // Runs on a timer, batches its reads, and is deliberately conservative: it // only ever touches campaigns that are live, un-stopped, and provably at or // past their purchased amount. Nothing is refunded — they delivered in full. async function sweepCompleted() { if (!configured()) return { checked: 0, closed: 0 }; const all = readLedger(); const mk = monthKey(); const month = all[mk] || {}; const live = []; Object.keys(month).forEach(function (mid) { (month[mid] || []).forEach(function (r) { if (!r.stopped && !r.completed && r.adId) live.push({ mid: mid, row: r }); }); }); if (!live.length) return { checked: 0, closed: 0 }; const byId = {}; for (let i = 0; i < live.length; i += 150) { try { const chunk = await stats(live.slice(i, i + 150).map(function (x) { return x.adId || x.row.adId; })); chunk.forEach(function (st) { byId[st.ad_id] = st; }); } catch (e) { return { checked: live.length, closed: 0, error: e.message }; } } let closed = 0; for (const item of live) { const r = item.row; const st = byId[r.adId]; if (!st || !st.live) continue; const bought = Number(r.bought != null ? r.bought : r.impressions) || 0; if (!bought) continue; const got = interpret(r.kind === 'text' ? 'text' : 'banner', bought, st); if (got.served < bought) continue; try { await callNas({ action: 'deactivate', ad_id: Number(r.adId) }); } catch (e) { continue; } closed++; } if (closed) { // Re-read: the deactivate calls above took real time, and another member // may have launched in that window. const fresh = readLedger(); const fm = fresh[mk] || {}; Object.keys(fm).forEach(function (mid) { (fm[mid] || []).forEach(function (r) { const hit = live.find(function (x) { return x.row.adId === r.adId; }); if (!hit) return; const st = byId[r.adId]; if (!st || !st.live) return; const bought = Number(r.bought != null ? r.bought : r.impressions) || 0; if (!bought) return; if (interpret(r.kind === 'text' ? 'text' : 'banner', bought, st).served < bought) return; r.completed = true; r.completedAt = new Date().toISOString(); r.served = bought; }); }); writeLedger(fresh); } return { checked: live.length, closed: closed }; } module.exports = { init, configured, status, launch, stop, stats, interpret, sweepCompleted, allowanceFor, sizes, CREATIVES, ALLOWANCE };