6a7c5161ff
Marty's read was right — the upper tiers felt flat, and the reason is that they scaled QUANTITY, not KIND. L1->L2 was a category change (you couldn't write, now you can). Everything above L4 was a multiplier on a capability already owned at L2: sound more like you, measure it, more pages, five at once. Meanwhile the price doubles each rung — Corona costs ~17x Culmen. No batch button is worth 17x a voice profile. The fix is a change of axis. Every tool from L1 to L8 was a "me" tool, but past the first levels this business isn't about you: get two, help your two, teach them to teach. So the top now operates on the ORGANISATION. L7 Team Grants — hand Suite capacity down to anyone in your own org. Deliberately from a separate pool that the level grants, NOT out of the leader's own allowance: making someone choose between equipping their team and using their own tools means nobody ever grants anything. Recipients are verified in-org against the contract. Grants can lift a tool the recipient's level hasn't unlocked — that's the point, not a loophole. Being helped is made visible to the recipient, since half the value is knowing an upline backed you. L8 Network Intelligence — every team ad pooled and anonymised: which angles, headlines, creative and formats actually earn their impressions. No member alone has enough traffic to learn anything from display; together they do. It can't be bought, and it compounds monthly with no further work. Holds two lines: nothing identifies anyone, and nothing under 2,000 impressions gets a reported rate — under-evidenced rows are counted and disclosed rather than silently dropped. The written readout is hand-authored, not generated: these are conclusions about money, and a model paraphrasing a table is exactly where an invented number slips in. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
234 lines
10 KiB
JavaScript
234 lines
10 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);
|
|
}
|
|
|
|
// ── 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;
|
|
}
|
|
|
|
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 row = rows.find(function (r) { return Number(r.adId) === Number(adId); });
|
|
if (!row) throw new Error('That banner is not one of yours from this month.');
|
|
if (row.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) });
|
|
|
|
// 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(all);
|
|
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 };
|