dc0f0d056d
Second delivery surface for member campaigns, and deliberately the opposite shape to the Network Ad Space rail: that one is Tier 3 heavy, this one only has prices configured for US, CA, GB, AU, NZ and UM, so its traffic is Tier 1 by construction. The AdRevLinks database is not reachable from this server, so rather than opening a database port to the internet there is a narrow authenticated endpoint on that box which does the insert locally. Five actions, no general query surface: a leaked key can only create or remove popup campaigns. The secret lives outside that server's webroot and is compared in constant time; Apache there strips Authorization, so it travels as X-Bridge-Key. adrevlnks.js mirrors nas.js, with two rules carried over from this morning's billing bug: - DELIVERY IS READ, NEVER DERIVED. status returns the rotator's own per-country counters. Nothing is computed from a figure a stop could overwrite, which is exactly what charged 16 members for undelivered impressions on the NAS side. - EVERY WRITE IS IDEMPOTENT. Each campaign carries ref "iap:<id>", so a retry after a timeout returns the existing campaign instead of booking a second one. Capped on purpose. That server serves roughly 5,500 popup impressions a DAY in total, shared by every active campaign, and its rotator favours whichever has delivered least. So syndicating everything unchecked would starve what is already running, Marty's own ads included. Hence a credit floor and a per-campaign view cap. Cost is not the reason: he owns the platform and treats the placements as free. Finite shared inventory is the reason. Hooked into create, pause/resume and the scheduled end sweep so both networks stay in step. Inert unless ADREVLNKS_BRIDGE_URL and _KEY are set, and a bridge hiccup can never block a campaign going live. qa/adrevlnks-bridge.mjs (15 assertions) drives the REAL endpoint: under-floor campaigns skipped, full credit value booked, Tier 1 targeting, retry returns the same campaign, delivery read back, pause/resume mirrored, then deleted and confirmed gone. Creates only paused campaigns so no live traffic is spent, and leaves nothing behind. nas-served 8, fraud-allow 12, sponsor-note 5, chatbot-parse 35, qa/run.sh member 0 bugs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1705 lines
101 KiB
JavaScript
1705 lines
101 KiB
JavaScript
// Ad engine v1 (spec §8b types 1-3). Dual-mode like accounts.js:
|
||
// MySQL (db.enabled) with guarded UPDATEs for the concurrent serving path,
|
||
// JSON volume file fallback for local dev.
|
||
// Spend accrues per campaign; burns queue for the engine signer to consume()
|
||
// on-chain (/api/admin/burns). All exported functions are async.
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const crypto = require('crypto');
|
||
const db = require('./db');
|
||
const nas = require('./nas'); // NAS syndication (inert unless NAS_DB_* env is set)
|
||
const adrevlnks = require('./adrevlnks'); // AdRevLinks popup syndication (inert unless ADREVLNKS_BRIDGE_* is set)
|
||
|
||
let DATA_DIR = null;
|
||
let chain = null;
|
||
const RATES_FILE = () => path.join(DATA_DIR, 'adrates.json');
|
||
|
||
// REHEARSAL PLACEHOLDER RATES — admin-adjustable via /api/admin/rates.
|
||
// Admin house ads: owned by this pseudo-account, funded by nothing. They serve
|
||
// through every normal path but the spend is never charged to anyone; the
|
||
// budget only acts as a delivery cap (status flips to 'out' when it's reached).
|
||
const HOUSE_OWNER = 'house@instantadpay.com';
|
||
function rates() {
|
||
let saved = {};
|
||
try { saved = JSON.parse(fs.readFileSync(RATES_FILE(), 'utf8')); } catch (e) {}
|
||
return Object.assign({
|
||
bannerBatch: 10, bannerCreditsPerBatch: 2,
|
||
textBatch: 10, textCreditsPerBatch: 1,
|
||
loginCreditsPerDay: 100,
|
||
loginDwellSeconds: 10, // full-screen interstitial after sign-in
|
||
burnBatchMin: 50,
|
||
welcomeCredits: 25,
|
||
dailyViewTarget: 5, // ads to view for the daily claim (spec §8b attention-gated claim)
|
||
dailyClaimCredits: 5,
|
||
viewDwellSeconds: 10, // Marty 2026-09-09: at least 10s so people actually look
|
||
// onsite solo ads: full-message inbox delivery, charged per guaranteed recipient
|
||
soloCostPerRecipient: 5,
|
||
soloMinRecipients: 10,
|
||
soloReadCredits: 2, // earned by the reader per rewarded read
|
||
soloReadCapPerDay: 5,
|
||
soloReadDwellSeconds: 10,
|
||
// watch-to-earn video ads: advertiser picks a required watch length, which
|
||
// sets the per-view price; the viewer earns per completed watch
|
||
videoTiers: [
|
||
{ secs: 10, cost: 3, reward: 1 },
|
||
{ secs: 30, cost: 7, reward: 2 },
|
||
{ secs: 60, cost: 12, reward: 4 }
|
||
],
|
||
videoWatchCapPerDay: 8,
|
||
// featured rotation: your link runs in the featured strip for N days; the
|
||
// dilution (how many links share the rotation) is disclosed before you buy
|
||
featuredPerDay: 40, // credits per day
|
||
featuredDurations: [1, 2, 7],
|
||
featuredSlotsPerDay: 10, // cap on links sharing the rotation on any one day
|
||
featuredWindowDays: 7, // how far ahead a day can be booked
|
||
// verified visits: buy a pack of guaranteed unique human visits; each is a
|
||
// dwelled + captcha-verified visit by a distinct member (never a repeat)
|
||
visitCostPerVisit: 3, // credits the advertiser pays per delivered visit
|
||
visitMinPack: 20, // smallest pack
|
||
visitReward: 1, // credits the viewer earns per verified visit
|
||
visitDwellSeconds: 8,
|
||
visitCapPerDay: 20, // per-viewer daily cap on rewarded visits
|
||
// credit bonuses paid once when a member reaches each milestone (the same
|
||
// ladder as the Overview stepper). Keys: payouts / firstBuyer / level2 / level3
|
||
milestoneBonus: { payouts: 10, firstBuyer: 25, level2: 50, level3: 100 }
|
||
}, saved);
|
||
}
|
||
function setRates(patch) {
|
||
fs.writeFileSync(RATES_FILE(), JSON.stringify(Object.assign(rates(), patch), null, 2));
|
||
return rates();
|
||
}
|
||
|
||
const TYPES = ['banner', 'text', 'login', 'solo', 'video', 'featured', 'visits'];
|
||
// standard IAB sizes — ids map straight to NAS sponsorads width/height (pid 2)
|
||
const BANNER_SIZES = [
|
||
{ id: '728x90', w: 728, h: 90, label: 'Leaderboard 728×90' },
|
||
{ id: '300x250', w: 300, h: 250, label: 'Medium rectangle 300×250' },
|
||
{ id: '468x60', w: 468, h: 60, label: 'Banner 468×60' },
|
||
{ id: '160x600', w: 160, h: 600, label: 'Wide skyscraper 160×600' },
|
||
{ id: '120x600', w: 120, h: 600, label: 'Skyscraper 120×600' },
|
||
{ id: '320x50', w: 320, h: 50, label: 'Mobile leaderboard 320×50' },
|
||
{ id: '125x125', w: 125, h: 125, label: 'Square button 125×125' }
|
||
];
|
||
// solo bodies are member-authored rich text rendered in OTHER members'
|
||
// browsers: whitelist-sanitize server-side — known tags only, no attributes
|
||
// (except http(s) hrefs, rebuilt clean), every stray angle bracket escaped
|
||
const SOLO_TAGS = new Set(['b', 'strong', 'i', 'em', 'u', 's', 'p', 'br',
|
||
'ul', 'ol', 'li', 'h2', 'h3', 'h4', 'blockquote', 'div', 'span', 'figure']);
|
||
function sanitizeSolo(html) {
|
||
const src = String(html || '').replace(/ |