Member campaigns now go out as click offers on DripOffers

Third syndication rail. The first two send impressions; this one sends
clicks — a real person picks the offer off the offerwall, goes to the
member's link, and has to stay the dwell time before anyone is paid.

Two things it does that the other rails can't:

- It carries geo-targeted campaigns. DripOffers filters on an explicit
  country list, so a campaign aimed at Tier 1 finally reaches an outside
  audience instead of staying on our own site. Tier-3-only campaigns are
  skipped rather than quietly sent worldwide, because an inclusion list
  can't express "everywhere except the other tiers" and widening it would
  deliver exactly the traffic the owner chose to exclude.
- Delivery is counted, not derived. It reads rows from the platform's
  permanent click ledger. It never computes delivery as ordered minus
  remaining, and pausing never zeroes remaining — that pair is what
  charged members for impressions that never ran on Network Ad Space.

Also fixes two live bugs found while wiring it: the AdRevLinks pause was
nested inside the Network Ad Space check in both the end sweep and
setStatus, so with NAS switched off an ended or paused campaign kept
running on AdRevLinks. Each rail is now checked on its own.

Caps: 1,000 credits minimum, 10,000 clicks maximum per campaign. Not for
cost — Marty owns the platform and these placements are free — but because
the whole site delivers around 2,700 clicks a day, and one campaign
booking 50,000 would sit in the list for weeks.

Inert unless DRIPOFFERS_BRIDGE_URL and _KEY are set. 26 checks green
against the live endpoint; member walk clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-18 06:27:33 -05:00
parent dc0f0d056d
commit d4e2f59f9b
5 changed files with 254 additions and 1 deletions
BIN
View File
Binary file not shown.
+1
View File
@@ -120,6 +120,7 @@ async function bootstrap() {
await alterSafe('ALTER TABLE campaigns ADD COLUMN height INT NULL'); // banner size (IAB) → also NAS height await alterSafe('ALTER TABLE campaigns ADD COLUMN height INT NULL'); // banner size (IAB) → also NAS height
await alterSafe('ALTER TABLE campaigns ADD COLUMN nas_ad_id INT NULL'); // syndicated NAS sponsorads.ID await alterSafe('ALTER TABLE campaigns ADD COLUMN nas_ad_id INT NULL'); // syndicated NAS sponsorads.ID
await alterSafe('ALTER TABLE campaigns ADD COLUMN nas_served INT NOT NULL DEFAULT 0'); // NAS impressions already reconciled into spend await alterSafe('ALTER TABLE campaigns ADD COLUMN nas_served INT NOT NULL DEFAULT 0'); // NAS impressions already reconciled into spend
await alterSafe('ALTER TABLE campaigns ADD COLUMN drip_id INT NULL'); // syndicated DripOffers campaigns.id (2026-09-18)
await alterSafe('ALTER TABLE campaigns ADD COLUMN expires BIGINT NULL'); // featured rotation end time await alterSafe('ALTER TABLE campaigns ADD COLUMN expires BIGINT NULL'); // featured rotation end time
await alterSafe('ALTER TABLE campaigns ADD COLUMN starts BIGINT NULL'); // featured run start (booked day); any type: scheduled start await alterSafe('ALTER TABLE campaigns ADD COLUMN starts BIGINT NULL'); // featured run start (booked day); any type: scheduled start
await q(`CREATE TABLE IF NOT EXISTS camp_hours ( await q(`CREATE TABLE IF NOT EXISTS camp_hours (
+161
View File
@@ -0,0 +1,161 @@
// Syndicate member campaigns to DripOffers as paid-per-click offers.
//
// Third syndication rail, and the one that behaves least like the other two. Network Ad Space
// and AdRevLinks both serve impressions into a rotation. DripOffers serves *clicks*: a real
// person picks the offer off an offerwall, visits the target URL, and has to stay the pack's
// dwell time before anybody gets paid. So a DripOffers click is worth far more than an
// impression, and there are far fewer of them.
//
// The DripOffers database is not reachable from this server, so we talk to a narrow
// authenticated endpoint that runs there (public_html/iap-bridge.php) and does the insert
// locally. Six actions, no general query surface.
//
// FEATURE-FLAGGED: inert unless DRIPOFFERS_BRIDGE_URL and DRIPOFFERS_BRIDGE_KEY are set.
//
// Three things carried over deliberately from the NAS and AdRevLinks work:
//
// 1. DELIVERY IS READ, NEVER DERIVED. `status` counts rows in that platform's permanent
// per-click ledger. We never compute delivery from "ordered minus remaining", because
// remaining is mutable and deriving from it is what charged members for impressions that
// never ran on NAS.
//
// 2. EVERY WRITE IS IDEMPOTENT. Each campaign carries ref "iap:<id>" behind a UNIQUE index
// on the far side, so a retry after a timeout cannot book a second campaign.
//
// 3. A SYNDICATION HICCUP NEVER BLOCKS THE MEMBER. Callers swallow errors; the IAP campaign
// goes live either way.
//
// Capacity note (2026-09-18): the offerwall is a menu, not a rotator - every eligible campaign
// is listed at once, ordered by reward, and the member chooses. So adding campaigns does not
// starve the others the way the AdRevLinks rotator does; it lengthens the menu. What is finite
// is total clicks, running around 2,700 a day across the whole platform. Hence MAX_CLICKS
// below: one campaign booking 50,000 clicks would sit in the list for weeks. Cost is not the
// reason for the caps - Marty owns the platform and treats these placements as free.
'use strict';
const https = require('https');
const { URL } = require('url');
const MIN_CREDITS = Number(process.env.DRIPOFFERS_MIN_CREDITS || 1000); // below this, not worth a listing
const MAX_CLICKS = Number(process.env.DRIPOFFERS_MAX_CLICKS || 10000);
const MIN_CLICKS = 500; // the platform's own floor
const PACK_ID = Number(process.env.DRIPOFFERS_PACK_ID || 2); // 2 = a 10-second dwell
const USER_ID = Number(process.env.DRIPOFFERS_USER_ID || 1); // the funding account
// Which IAP formats make sense as a click offer: the ones whose whole point is sending a
// person to the target URL. Video and solo are a different product, and featured is a slot
// buy on our own site rather than a destination.
const KINDS = ['banner', 'text', 'visits'];
function enabled() {
return !!(process.env.DRIPOFFERS_BRIDGE_URL && process.env.DRIPOFFERS_BRIDGE_KEY);
}
function call(action, payload, timeoutMs) {
return new Promise((resolve, reject) => {
if (!enabled()) return reject(new Error('dripoffers-disabled'));
const u = new URL(process.env.DRIPOFFERS_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.DRIPOFFERS_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('dripoffers ' + 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('dripoffers timeout')));
req.end(body);
});
}
const refFor = campaignId => 'iap:' + Number(campaignId);
const kindOk = type => KINDS.includes(String(type || ''));
// How many clicks a campaign's remaining credits are worth, capped so one campaign cannot
// park itself at the top of the offerwall for a month.
function clicksFor(credits) {
const v = Math.max(0, Math.floor(Number(credits) || 0));
return Math.min(v, MAX_CLICKS);
}
// Country targeting. Unlike the AdRevLinks popup rail - which only has prices for five
// countries and so cannot carry a targeted campaign at all - this platform filters on an
// explicit country list, so geo-restricted campaigns CAN syndicate here. That is the point of
// including geo support: those campaigns currently reach no external rail at all.
//
// Tier 3 is the exception. It is defined as "everywhere the other two tiers are not", which an
// inclusion list cannot express, so a tier-3-only campaign is skipped rather than sent
// worldwide - sending it worldwide would quietly deliver the Tier 1 traffic its owner chose
// not to target.
function countriesFor(geo, tiers) {
const g = String(geo || '').trim();
if (!g) return '*';
const want = g.split(',').map(s => s.trim()).filter(Boolean);
if (want.length === 3) return '*';
const out = [];
if (want.includes('1')) out.push(...(tiers && tiers.t1 ? tiers.t1 : []));
if (want.includes('2')) out.push(...(tiers && tiers.t2 ? tiers.t2 : []));
if (!out.length) return null; // tier 3 only: not expressible, skip
return [...new Set(out.map(c => String(c).toUpperCase()))].join(',');
}
// Push a campaign. Returns null when it is not worth syndicating (or cannot be targeted
// faithfully) rather than throwing, so the caller treats "too small", "wrong format" and
// "disabled" the same quiet way.
async function push(campaign, opts) {
if (!enabled()) return null;
if (!kindOk(campaign.type)) return null;
const credits = Number((campaign.budget || 0) - (campaign.spent || 0) - (campaign.accrued || 0));
if (credits < MIN_CREDITS) return null;
const clicks = clicksFor(credits);
if (clicks < MIN_CLICKS) return null;
const url = String(campaign.targetUrl || campaign.target_url || '').trim();
if (!/^https?:\/\//i.test(url)) return null;
const countries = countriesFor(campaign.geo, (opts && opts.tiers) || null);
if (countries === null) return null;
const title = String(campaign.name || campaign.title || 'InstantAdPay').slice(0, 250);
// The offerwall shows this line under the title, so it has to read like an offer rather
// than like an internal campaign record.
const description = String(campaign.title || campaign.body || campaign.name || title).slice(0, 250);
return await call('create_ptc', {
title, description, url, clicks, countries,
device: 0,
pack_id: (opts && opts.packId) || PACK_ID,
user_id: USER_ID,
ref: refFor(campaign.id),
activate: !(opts && opts.paused)
});
}
// Real delivery, counted from the platform's own click ledger. 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; }
}
async function packs() { return enabled() ? call('packs', {}) : null; }
module.exports = { enabled, push, readServed, pause, resume, remove, packs,
clicksFor, countriesFor, kindOk, refFor,
MIN_CREDITS, MAX_CLICKS, MIN_CLICKS, PACK_ID, KINDS };
+91
View File
@@ -0,0 +1,91 @@
// DripOffers paid-per-click syndication, driven against the REAL bridge on dripoffers.com.
//
// 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 publisher is ever paid for a test click.
//
// DRIPOFFERS_BRIDGE_URL=... DRIPOFFERS_BRIDGE_KEY=... node qa/dripoffers-bridge.mjs
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const drip = require('../dripoffers.js');
const ok = [], bad = [];
const t = (n, c, extra) => { (c ? ok : bad).push(n + (c || !extra ? '' : ' -> ' + extra)); };
// ---- pure logic first: these run with or without a configured bridge ------
const tiers = { t1: new Set(['US', 'CA', 'GB']), t2: new Set(['BR', 'PL']) };
t('no targeting means worldwide', drip.countriesFor('', tiers) === '*', drip.countriesFor('', tiers));
t('all three tiers collapse to worldwide', drip.countriesFor('1,2,3', tiers) === '*', drip.countriesFor('1,2,3', tiers));
t('tier 1 becomes a country list', drip.countriesFor('1', tiers) === 'US,CA,GB', drip.countriesFor('1', tiers));
t('tier 1+2 merges both lists', drip.countriesFor('1,2', tiers) === 'US,CA,GB,BR,PL', drip.countriesFor('1,2', tiers));
// tier 3 is "everywhere the others are not" — an inclusion list cannot say that, and sending it
// worldwide would deliver the Tier 1 traffic its owner deliberately excluded
t('tier 3 alone is skipped, not silently widened', drip.countriesFor('3', tiers) === null, String(drip.countriesFor('3', tiers)));
t('credits convert to clicks one for one', drip.clicksFor(4000) === 4000, String(drip.clicksFor(4000)));
t('one campaign cannot swallow the platform', drip.clicksFor(500000) === drip.MAX_CLICKS, String(drip.clicksFor(500000)));
t('a click format is accepted', drip.kindOk('banner') && drip.kindOk('visits') && drip.kindOk('text'));
t('video and solo are not click formats', !drip.kindOk('video') && !drip.kindOk('solo') && !drip.kindOk('featured'));
if (!drip.enabled()) {
console.log('PASS ' + ok.length + ' (pure logic only)');
for (const b of bad) console.log('FAIL ' + b);
console.log('SKIP: DRIPOFFERS_BRIDGE_URL / _KEY not set — live bridge checks not run');
process.exit(bad.length ? 1 : 0);
}
// ---- live bridge ---------------------------------------------------------
const ID = 999000 + Math.floor(Math.random() * 900);
const camp = { id: ID, name: 'QA bridge campaign', title: 'A test offer that is never activated',
targetUrl: 'https://instantadpay.com/', budget: 12000, spent: 0, accrued: 0, type: 'banner' };
// a campaign below the credit floor is skipped quietly rather than taking a listing
const small = await drip.push({ ...camp, id: ID + 1, budget: 200 }, { paused: true, tiers });
t('a campaign under the credit floor is not syndicated', small === null, JSON.stringify(small));
// a format that is not a click offer never goes out
const wrongKind = await drip.push({ ...camp, id: ID + 2, type: 'video' }, { paused: true, tiers });
t('a video campaign is not syndicated as a click offer', wrongKind === null, JSON.stringify(wrongKind));
// the real push
const r = await drip.push(camp, { paused: true, tiers });
t('campaign syndicated', !!(r && r.campaign_id), JSON.stringify(r));
t('clicks are capped at the per-campaign ceiling', r && r.clicks_booked === drip.MAX_CLICKS, r && String(r.clicks_booked));
t('untargeted campaign goes worldwide', r && r.countries === '*', r && String(r.countries));
t('created paused, so no publisher is paid by the test', r && r.status === 0, r && String(r.status));
t('the funding account was actually debited', r && r.charged === true, r && String(r.charged));
// idempotency: the guard that stops a retry double-booking
const again = await drip.push(camp, { paused: true, tiers });
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 counted from the click ledger, never derived from remaining_visits
const st = await drip.readServed(ID);
t('delivery reads back from the platform', !!(st && st.ok), JSON.stringify(st).slice(0, 140));
t('ordered clicks match what was booked', st && st.ordered_clicks === drip.MAX_CLICKS, st && String(st.ordered_clicks));
t('delivered starts at zero and is a counted figure', st && st.delivered_clicks === 0, st && String(st.delivered_clicks));
// pause / resume mirror — and the reason this matters: a pause must NOT read back as delivered
await drip.resume(ID);
let s2 = await drip.readServed(ID);
t('resume activates it', s2 && s2.status === 1, s2 && String(s2.status));
await drip.pause(ID);
s2 = await drip.readServed(ID);
t('pause deactivates it', s2 && s2.status === 0, s2 && String(s2.status));
t('pausing does not fake delivery', s2 && s2.delivered_clicks === 0 && s2.remaining_visits === drip.MAX_CLICKS,
s2 && (s2.delivered_clicks + '/' + s2.remaining_visits));
// cleanup must actually work, or tests litter a production platform
const del = await drip.remove(ID);
t('the test campaign is deleted', !!(del && del.ok), JSON.stringify(del));
const gone = await drip.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 drip.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);
+1 -1
View File
@@ -386,7 +386,7 @@ async function boot() {
chain.init({ onEvent: ev => { attachNames([ev]).then(a => pushFeed(a[0])).catch(() => pushFeed(ev)); emailOnEvent(ev).catch(() => {}); telegramOnEvent(ev).catch(() => {}); sponsorSyncOnEvent(ev).catch(e => console.error('sponsor sync', e.message)); } }); chain.init({ onEvent: ev => { attachNames([ev]).then(a => pushFeed(a[0])).catch(() => pushFeed(ev)); emailOnEvent(ev).catch(() => {}); telegramOnEvent(ev).catch(() => {}); sponsorSyncOnEvent(ev).catch(e => console.error('sponsor sync', e.message)); } });
auth.init({ dataDir: DATA_DIR, chain, isProd: IS_PROD, site: 'instantadpay.com' }); auth.init({ dataDir: DATA_DIR, chain, isProd: IS_PROD, site: 'instantadpay.com' });
accounts.init({ dataDir: DATA_DIR }); accounts.init({ dataDir: DATA_DIR });
ads.init({ dataDir: DATA_DIR, chain }); ads.init({ dataDir: DATA_DIR, chain, tiers: () => geo.tierLists(siteConfig()) });
mailer.init({ dataDir: DATA_DIR }); mailer.init({ dataDir: DATA_DIR });
messages.init({ dataDir: DATA_DIR }); messages.init({ dataDir: DATA_DIR });
reports.init({ dataDir: DATA_DIR }); reports.init({ dataDir: DATA_DIR });