diff --git a/adrevlnks.js b/adrevlnks.js new file mode 100644 index 0000000..784c1c8 --- /dev/null +++ b/adrevlnks.js @@ -0,0 +1,118 @@ +// Syndicate member campaigns to AdRevLinks as popup ads. +// +// The AdRevLinks database is NOT reachable from this server (port 3306 on that box is +// closed to us), so we talk to a narrow authenticated endpoint that runs there and does +// the insert locally. It exposes exactly five actions and no general query surface, so a +// leaked key can only create or remove popup campaigns. +// +// FEATURE-FLAGGED: inert unless ADREVLNKS_BRIDGE_URL and ADREVLNKS_BRIDGE_KEY are set. +// +// Two things learned from the Network Ad Space integration, deliberately repeated here: +// +// 1. DELIVERY IS READ, NEVER DERIVED. `status` returns the rotator's own per-country view +// counters. We never compute "served" from a number that a stop or cancel could +// overwrite, which is exactly what charged members for undelivered impressions there. +// +// 2. EVERY WRITE IS IDEMPOTENT. Each campaign carries ref "iap:". A retry after a +// timeout returns the existing campaign instead of booking a second one. +// +// Capacity note (2026-09-18): that server serves roughly 5,500 popup impressions a day in +// TOTAL, shared by every active campaign, and its rotator favours campaigns with the least +// delivered. So syndicating everything would starve what is already running, including +// Marty's own ads. Hence MAX_VIEWS_PER_CAMPAIGN and MIN_CREDITS below. Cost is not the +// reason for the caps - Marty owns the platform and treats the placements as free - the +// reason is that inventory there is finite and shared. +'use strict'; +const https = require('https'); +const { URL } = require('url'); + +// Tier 1 only. These are the sole countries with a configured popup price on that server; +// anything else is refused by the bridge rather than silently accepted. +const TIER1 = ['US', 'CA', 'GB', 'AU', 'NZ']; +const WORLDWIDE = ['all']; + +const MIN_CREDITS = Number(process.env.ADREVLNKS_MIN_CREDITS || 1000); // below this, not worth a slot +const MAX_VIEWS_PER_CAMPAIGN = Number(process.env.ADREVLNKS_MAX_VIEWS || 50000); +const TRAFFIC_SOURCE = Number(process.env.ADREVLNKS_TRAFFIC_SOURCE || 1); +const USER_ID = Number(process.env.ADREVLNKS_USER_ID || 2); + +function enabled() { + return !!(process.env.ADREVLNKS_BRIDGE_URL && process.env.ADREVLNKS_BRIDGE_KEY); +} + +function call(action, payload, timeoutMs) { + return new Promise((resolve, reject) => { + if (!enabled()) return reject(new Error('adrevlnks-disabled')); + const u = new URL(process.env.ADREVLNKS_BRIDGE_URL); + const body = JSON.stringify(Object.assign({ action }, payload || {})); + const req = https.request({ + hostname: u.hostname, path: u.pathname + u.search, method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), + 'X-Bridge-Key': process.env.ADREVLNKS_BRIDGE_KEY }, + timeout: timeoutMs || 25000 + }, res => { + let d = ''; + res.on('data', c => d += c); + res.on('end', () => { + let j = null; + try { j = JSON.parse(d); } catch (e) {} + if (!j) return reject(new Error('adrevlnks ' + res.statusCode + ': ' + d.slice(0, 120))); + if (j.error) return reject(new Error(j.error)); + resolve(j); + }); + }); + req.on('error', reject); + req.on('timeout', () => req.destroy(new Error('adrevlnks timeout'))); + req.end(body); + }); +} + +const refFor = campaignId => 'iap:' + Number(campaignId); + +// How many popup views a campaign's credits are worth. 1 credit = 1 view, capped so a +// single large campaign cannot swallow the whole server's daily inventory. +function viewsFor(credits) { + const v = Math.max(0, Math.floor(Number(credits) || 0)); + return Math.min(v, MAX_VIEWS_PER_CAMPAIGN); +} + +// Push a campaign. Returns null when it is not worth syndicating, rather than throwing, +// so the caller can treat "too small" and "disabled" the same quiet way. +async function push(campaign, opts) { + if (!enabled()) return null; + const credits = Number((campaign.budget || 0) - (campaign.spent || 0) - (campaign.accrued || 0)); + if (credits < MIN_CREDITS) return null; + const views = viewsFor(credits); + if (views < 1000) return null; + + const title = String(campaign.name || campaign.title || 'InstantAdPay').slice(0, 250); + const url = String(campaign.targetUrl || campaign.target_url || '').trim(); + if (!/^https?:\/\//i.test(url)) return null; + + const countries = (opts && opts.worldwide) ? WORLDWIDE : TIER1; + return await call('create_popup', { + title, url, views, countries, + traffic_source: TRAFFIC_SOURCE, + user_id: USER_ID, + ref: refFor(campaign.id), + activate: !(opts && opts.paused) + }); +} + +// Real delivery, straight from the rotator's counters. Never derived. +async function readServed(campaignId) { + if (!enabled()) return null; + try { return await call('status', { ref: refFor(campaignId) }); } + catch (e) { if (/not found/i.test(e.message)) return null; throw e; } +} + +async function pause(campaignId) { return enabled() ? call('pause', { ref: refFor(campaignId) }) : null; } +async function resume(campaignId) { return enabled() ? call('resume', { ref: refFor(campaignId) }) : null; } +async function remove(campaignId) { + if (!enabled()) return null; + try { return await call('delete', { ref: refFor(campaignId) }); } + catch (e) { if (/not found/i.test(e.message)) return null; throw e; } +} + +module.exports = { enabled, push, readServed, pause, resume, remove, viewsFor, refFor, + TIER1, WORLDWIDE, MIN_CREDITS, MAX_VIEWS_PER_CAMPAIGN }; diff --git a/ads.js b/ads.js index 19632a4..51e6cef 100644 Binary files a/ads.js and b/ads.js differ diff --git a/qa/adrevlnks-bridge.mjs b/qa/adrevlnks-bridge.mjs new file mode 100644 index 0000000..485a1b6 --- /dev/null +++ b/qa/adrevlnks-bridge.mjs @@ -0,0 +1,65 @@ +// AdRevLinks popup syndication, driven against the REAL bridge on adrev.link. +// +// Creates a paused test campaign, reads its delivery back, pauses/resumes it, proves a +// retry cannot double-book, then deletes it. Nothing is left behind and nothing is ever +// activated, so no live traffic is spent. +// +// ADREVLNKS_BRIDGE_URL=... ADREVLNKS_BRIDGE_KEY=... node qa/adrevlnks-bridge.mjs +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +const arl = require('../adrevlnks.js'); + +const ok = [], bad = []; +const t = (n, c, extra) => { (c ? ok : bad).push(n + (c || !extra ? '' : ' -> ' + extra)); }; + +if (!arl.enabled()) { console.log('SKIP: ADREVLNKS_BRIDGE_URL / _KEY not set'); process.exit(0); } + +const ID = 999000 + Math.floor(Math.random() * 900); +const camp = { id: ID, name: 'QA bridge campaign', targetUrl: 'https://instantadpay.com/', + budget: 12000, spent: 0, accrued: 0, type: 'banner' }; + +// a campaign below the floor is skipped quietly rather than wasting a slot +const small = await arl.push({ ...camp, id: ID + 1, budget: 200 }, { paused: true }); +t('a campaign under the credit floor is not syndicated', small === null, JSON.stringify(small)); + +// the real push +const r = await arl.push(camp, { paused: true }); +t('campaign syndicated', !!(r && r.campaign_id), JSON.stringify(r)); +t('it books the full credit value as views', r && r.views_booked === 12000, r && String(r.views_booked)); +t('targeted Tier 1, not worldwide', r && Array.isArray(r.countries) && r.countries.includes('US') && !r.countries.includes('all'), + r && JSON.stringify(r.countries)); +t('created paused, so no live traffic is spent by the test', r && r.status === 2, r && String(r.status)); + +// idempotency: the guard that stops a retry double-booking someone's traffic +const again = await arl.push(camp, { paused: true }); +t('a retry returns the SAME campaign, never a second', again && again.campaign_id === r.campaign_id && again.duplicate === true, + JSON.stringify(again)); + +// delivery is read from the rotator, never derived +const st = await arl.readServed(ID); +t('delivery reads back from the server', !!(st && st.ok), JSON.stringify(st).slice(0, 120)); +t('ordered views match what was booked', st && st.ordered_views === 12000, st && String(st.ordered_views)); +t('served starts at zero and is a real counter', st && st.served_views === 0, st && String(st.served_views)); +t('per-country items came back', st && Array.isArray(st.items) && st.items.length >= 3, st && String((st.items || []).length)); + +// pause / resume mirror +await arl.resume(ID); +let s2 = await arl.readServed(ID); +t('resume activates it', s2 && s2.status === 1, s2 && String(s2.status)); +await arl.pause(ID); +s2 = await arl.readServed(ID); +t('pause deactivates it', s2 && s2.status === 2, s2 && String(s2.status)); + +// cleanup must actually work, or tests litter production +const del = await arl.remove(ID); +t('the test campaign is deleted', !!(del && del.ok), JSON.stringify(del)); +const gone = await arl.readServed(ID); +t('and it is really gone', gone === null, JSON.stringify(gone)); + +// an unknown campaign reads as null rather than throwing +const none = await arl.readServed(ID + 12345); +t('an unknown campaign reads as null', none === null, JSON.stringify(none)); + +console.log('PASS ' + ok.length); +for (const b of bad) console.log('FAIL ' + b); +process.exit(bad.length ? 1 : 0);