Files
rm-circle-team-router/suite-traffic.js
T
martbost 3f48bdfd99 Traffic Desk: tag ad destinations so clicks are attributable
The Traffic Desk has been unmeasurable since it shipped. track.js reads ?v=
before it reads document.referrer, so every angle-linked ad click was filed
under "angle:graveyard" (or two/phone/pocket) and the referring domain was
discarded. Ad clicks, flyer QR scans, massifly angle links and Telegram angle
links all collapsed into the same four buckets — about 11,400 landings with
no attributable source between them — while networkadspace.com showed one
single visit against roughly 45,000 served impressions.

That reads as "the ads produce nothing." What it actually means is that
nothing carried a source.

Destinations now carry src=nas-banner or src=nas-text. `src` is checked
before `v` in track.js so it wins cleanly, and the landing page reads ?v=
separately so the angle hook is untouched. Split by format because we have
believed text outperforms banners for weeks without being able to show it.

This is the prerequisite for everything else here: pooled Ad Intel
benchmarks, judging which placements are worth expanding, and telling a
member what their impressions bought.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 06:37:41 -05:00

373 lines
17 KiB
JavaScript

// 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);
}
// The ad network counts the SAME `remaining` column in opposite directions for
// the two placement types, which is not documented anywhere and had to be
// measured:
//
// TEXT ads — `remaining` counts DOWN from the purchase to zero as it serves.
// (ad 2689: 1,111 left mid-flight, 0 once complete.)
// BANNER ads — `remaining` counts UP as impressions are delivered, straight
// past the amount purchased. (ad 2707: 3,521 one day, 6,129 the
// next, on a 2,500 buy.)
//
// Reading both the same way made every banner report "0 served" while quietly
// delivering thousands — and made stop() treat delivered impressions as
// unserved and refund them.
//
// 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);
const rem = Math.max(0, Number(stat && stat.remaining) || 0);
let served;
if (kind === 'text') {
// count-down: what is gone is what was bought minus what is left
served = b - Math.min(rem, b);
} else {
// count-up: `remaining` IS the delivered count
served = 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/<id> also redirects to /join/<id> 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 };