CTB featured-offer relay: 30s visible-dwell engaged event -> signed postback

track.js: on join pages with a ctb1_ clickid, count 30s of VISIBLE time (1s
ticks, pauses when tab hidden), then report engaged once per session.
server.js: relay engaged ctb1_ clickids to the CTB backend per the spec
contract (X-Offer-Secret header, any-200-is-done); light in-process dedupe.
New volume config keys ctbOfferPostbackUrl + ctbOfferSecret (PATCH allowlist).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-08-23 08:10:29 -05:00
parent cfe7399a33
commit c5e2c30ff3
2 changed files with 50 additions and 2 deletions
+18
View File
@@ -22,4 +22,22 @@
const path=location.pathname.replace(/\/$/,'')||'/';
const page=path==='/'?'bridge':(path==='/start'?'start':(path==='/training'?'training':(path==='/join-now'?'joinnow':(/^\/join\/\d+$/.test(path)?'join':null))));
if(page)fetch('/api/public/track',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({event:page,source:getSource()})}).catch(()=>{});
// CTB Rewards featured offer: a ctb1_ clickid means this visitor earns
// coins for a REAL look at the tour — count 30 seconds of visible time on
// a join page, then report "engaged" once (server relays it to CTB).
(function(){
const cid=window.ctbGetClickId();
if(!cid||cid.indexOf('ctb1_')!==0)return;
if(page!=='join'&&page!=='joinnow')return;
try{ if(sessionStorage.getItem('ctb.engaged')===cid)return; }catch(e){}
let seen=0;
const t=setInterval(function(){
if(document.visibilityState!=='visible')return;
if(++seen<30)return;
clearInterval(t);
try{ sessionStorage.setItem('ctb.engaged',cid); }catch(e){}
fetch('/api/public/track',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({event:'engaged',source:getSource(),clickid:cid})}).catch(()=>{});
},1000);
})();
})();
+32 -2
View File
@@ -195,6 +195,25 @@ function firePostback(clickid, txid, source) {
.then(r=>{ if(r.ok) recordEvent('postback', source); else console.error('bemob postback status', r.status); })
.catch(e=>console.error('bemob postback error', e.message));
}
// CTB Rewards featured offer: relay a verified 30s visit so CTB credits the
// user's coins. Contract per D:\Projects\CTBRewards\FEATURED-OFFER-SPEC.md:
// POST {clickid, event:'engaged', ts} + X-Offer-Secret; any 200 = done.
const ctbRelayed = new Set();
function fireCtbOfferPostback(clickid) {
const c = getConfig(), url = String(c.ctbOfferPostbackUrl || '').trim(), secret = String(c.ctbOfferSecret || '').trim();
if (!url || !secret || !/^https:\/\/[a-z0-9.-]+\//i.test(url)) return;
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Offer-Secret': secret },
body: JSON.stringify({ clickid, event: 'engaged', ts: Math.floor(Date.now() / 1000) })
})
.then(async r => {
const d = await r.json().catch(() => ({}));
if (r.ok) { recordEvent('ctb-offer-' + (d.status || 'ok'), 'ctbrewards'); console.log('ctb offer postback:', clickid.slice(0, 24), d.status, d.coins || ''); }
else console.error('ctb offer postback status', r.status, d.status || '');
})
.catch(e => console.error('ctb offer postback error', e.message));
}
async function handleSubmitId(req, res) {
const ip = String(req.headers['x-forwarded-for']||req.socket.remoteAddress||'').split(',')[0].trim();
if (submitRateLimited(ip)) return json(res, 429, { error: 'Too many submissions — please wait a few minutes.' });
@@ -693,7 +712,18 @@ async function handleApi(req,res,pathname){
if(req.method==='POST'&&pathname==='/api/public/chat')return await handleChat(req,res);
if(req.method==='POST'&&pathname==='/api/public/submit-id')return await handleSubmitId(req,res);
if(req.method==='POST'&&pathname==='/api/public/track'){
const b=await bodyJson(req).catch(()=>({}));recordEvent(b.event,b.source);return json(res,200,{ok:true});
const b=await bodyJson(req).catch(()=>({}));recordEvent(b.event,b.source);
// CTB Rewards featured offer: a 30s-dwell "engaged" event carrying a
// ctb1_ clickid is relayed server-to-server so CTB can credit the coins.
// CTB is idempotent per clickid; the local Set just cuts repeat traffic.
if(b.event==='engaged'){
const cid=typeof b.clickid==='string'?b.clickid.trim().slice(0,80).replace(/[^A-Za-z0-9._-]/g,''):'';
if(cid.startsWith('ctb1_')&&!ctbRelayed.has(cid)){
ctbRelayed.add(cid); if(ctbRelayed.size>2000)ctbRelayed.clear();
fireCtbOfferPostback(cid);
}
}
return json(res,200,{ok:true});
}
if(req.method==='POST'&&pathname==='/api/admin/login'){
const ip=String(req.headers['x-forwarded-for']||req.socket.remoteAddress||'').split(',')[0].trim();
@@ -788,7 +818,7 @@ async function handleApi(req,res,pathname){
const maxOrder=sponsors.reduce((m,s)=>Math.max(m,s.sortOrder||0),0);sponsors.push({id:String(id).trim(),name:String(name).trim(),parentId:String(parentId||'').trim(),directs:0,level,status:sponsors.some(s=>s.status==='active')?'waiting':'active',sortOrder:maxOrder+10,clicks:0,notes:String(notes||'').trim(),email:String(email||'').trim().slice(0,120)});sponsors=normalizeStatuses(sponsors);saveSponsors(sponsors);return json(res,201,{sponsors});
}
if(req.method==='PATCH'&&pathname==='/api/admin/config'){
const b=await bodyJson(req),cur=getConfig(),next={...cur};for(const k of ['siteName','programName','bridgeHeadline','bridgeSubheadline','premiumEntryPol','dappReferralBaseUrl','telegramUrl','supportLabel','showSponsorName','showQueueProgress','bemobPostbackUrl','telegramBotToken','companionBotToken','miniAppShortName','telegramChatId','telegramTopicId','telegramRecruitTopicId','teamRootId','emailFrom','teamAlertEmail','ownerIds','ownerAlertEmail','orgRootId','tweetEnabled','tweetCtaUrl','tweetHashtags','blotatoTwitterId','dappFallbackPublic','moonpayPublicKey','moonpaySecretKey','publicRotationMode','publicRotationRootId','rotationExcludeIds'])if(Object.prototype.hasOwnProperty.call(b,k))next[k]=b[k];next.premiumEntryPol=Number(next.premiumEntryPol)||362;next.updatedAt=new Date().toISOString();writeJson(CONFIG_FILE,next);return json(res,200,{config:next});
const b=await bodyJson(req),cur=getConfig(),next={...cur};for(const k of ['siteName','programName','bridgeHeadline','bridgeSubheadline','premiumEntryPol','dappReferralBaseUrl','telegramUrl','supportLabel','showSponsorName','showQueueProgress','bemobPostbackUrl','telegramBotToken','companionBotToken','miniAppShortName','telegramChatId','telegramTopicId','telegramRecruitTopicId','teamRootId','emailFrom','teamAlertEmail','ownerIds','ownerAlertEmail','orgRootId','ctbOfferPostbackUrl','ctbOfferSecret','tweetEnabled','tweetCtaUrl','tweetHashtags','blotatoTwitterId','dappFallbackPublic','moonpayPublicKey','moonpaySecretKey','publicRotationMode','publicRotationRootId','rotationExcludeIds'])if(Object.prototype.hasOwnProperty.call(b,k))next[k]=b[k];next.premiumEntryPol=Number(next.premiumEntryPol)||362;next.updatedAt=new Date().toISOString();writeJson(CONFIG_FILE,next);return json(res,200,{config:next});
}
const m=pathname.match(/^\/api\/admin\/sponsors\/([^/]+)(?:\/(increment|activate|qualify|reset|move))?$/);
if(m){const id=decodeURIComponent(m[1]),action=m[2]||null;let sponsors=getSponsors(),idx=sponsors.findIndex(s=>s.id===id);if(idx<0)return json(res,404,{error:'Sponsor not found.'});