From c5e2c30ff3cd70c705490002fc93d845de10c3be Mon Sep 17 00:00:00 2001 From: martbost Date: Sun, 23 Aug 2026 08:10:29 -0500 Subject: [PATCH] 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 --- public/track.js | 18 ++++++++++++++++++ server.js | 34 ++++++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/public/track.js b/public/track.js index 28e503a..71c5218 100644 --- a/public/track.js +++ b/public/track.js @@ -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); + })(); })(); diff --git a/server.js b/server.js index 5f7d864..ceda29d 100644 --- a/server.js +++ b/server.js @@ -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.'});