// 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); } // ── 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; } 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', impressions: impressions, days: 365, 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; try { const s = await callNas({ action: 'stats', ad_ids: [Number(adId)] }); const st = (s.stats || [])[0]; if (st) { served = Math.max(0, Number(st.served) || 0); unserved = Math.max(0, Number(st.remaining) || 0); } } catch (e) { /* if stats are unavailable, refund nothing rather than guess */ } 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; // A handful of rows on the network carry a `remaining` larger than their // `assigned` (a pre-existing counter quirk). Clamp, or a member sees // "1,002 returned" on an ad they bought 1,000 impressions for. 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 || []; } module.exports = { init, configured, status, launch, stop, stats, allowanceFor, sizes, CREATIVES, ALLOWANCE };