Beta-tester safety: preview caps on the level override, and close a race in stop()

Two things needed before testers get level overrides.

1. The override decided what a position could SPEND as well as what it could
   SEE. A tester at Scintilla overridden to Corona would have had 150,000 real
   network impressions instead of 2,500, and a 200,000-impression grant pool to
   hand to real members — real inventory, spent for real. Ad allowances and
   split-test launches are now metered on trueLevel, and grant pools are capped
   to small preview amounts (5,000 impressions / 25 copy / 2 pages / 5 ad
   batches) when a position is overridden: enough to exercise the whole flow,
   trivial to lose.

2. suite-traffic.stop() read the ledger, made TWO network round trips to the ad
   network, then wrote back the object it had read seconds earlier — silently
   discarding any campaign another member launched in that window, with the
   impressions already spent on the network. It now re-reads after the awaits
   and mutates the fresh copy.

Worth recording that this was the ONLY such race: Node's single thread makes a
synchronous read-modify-write atomic, so the meter, grants and split ledgers
were never at risk. The danger was only ever the await gap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-08-28 16:01:53 -05:00
parent d5385b0512
commit 343f1edd57
3 changed files with 49 additions and 20 deletions
+16 -10
View File
@@ -692,6 +692,12 @@ async function handleApi(req,res,pathname){
return {d:dd,inOrg,beta,allowed};
}
// A level override decides what a position can SEE. It must never decide what
// a position can SPEND: ad impressions and grant pools are real inventory on
// a real network. So anything that costs something is metered on trueLevel.
function spendLevel(e){ return (e.d && e.d.trueLevel != null) ? e.d.trueLevel : (e.d ? e.d.level : 1); }
function isPreview(e){ return !!(e.d && e.d.levelOverridden); }
// ── Page Builder ─────────────────────────────────────────────────────────
// Level 6 extra pages: /p/<id>/<slug>. Falls back to the member's main page
// rather than 404ing, for the same reason /p/<id> falls back to /join/<id>.
@@ -817,7 +823,7 @@ async function handleApi(req,res,pathname){
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup.',code:500}));
if(e.error)return json(res,e.code||500,{error:e.error});
if(!e.inOrg||!e.allowed)return json(res,403,{error:'Not available for this position yet.'});
const st=suiteTraffic.status(e.d.id,e.d.level);
const st=suiteTraffic.status(e.d.id,spendLevel(e));
let live=[];
try{ live=await suiteTraffic.stats(st.campaigns.map(c=>c.adId)); }catch(err){}
// Tell the UI whether a personal page actually exists, so it can't be
@@ -836,14 +842,14 @@ async function handleApi(req,res,pathname){
try{
const pgRec=suitePages.load(e.d.id);
const entry=await suiteTraffic.launch({
id:e.d.id, level:e.d.level, size:b.size, creative:b.creative,
id:e.d.id, level:spendLevel(e), size:b.size, creative:b.creative,
kind:b.kind==='text'?'text':'banner',
subject:b.subject, lines:b.lines,
impressions:b.impressions, target:b.target, angle:b.angle,
hasPage:!!(pgRec&&pgRec.copy),
name:String(b.name||'').slice(0,60)
});
return json(res,200,{campaign:entry,status:suiteTraffic.status(e.d.id,e.d.level)});
return json(res,200,{campaign:entry,status:suiteTraffic.status(e.d.id,spendLevel(e))});
}catch(err){ return json(res,400,{error:String(err.message||err)}); }
}
@@ -890,7 +896,7 @@ async function handleApi(req,res,pathname){
let r=[]; try{ r=await suiteSplit.results(e.d.id); }catch(err){}
const pg=suitePages.load(e.d.id);
return json(res,200,{tests:r,level:e.d.level,id:e.d.id,
traffic:suiteTraffic.status(e.d.id,e.d.level),hasPage:!!(pg&&pg.copy),
traffic:suiteTraffic.status(e.d.id,spendLevel(e)),hasPage:!!(pg&&pg.copy),
minPerArm:suiteSplit.MIN_PER_ARM,maxArms:suiteSplit.MAX_ARMS,
textAngles:suiteTextAds.angles(),
textMeter:suiteMeter.check(e.d.id,e.d.level,'textad')});
@@ -903,9 +909,9 @@ async function handleApi(req,res,pathname){
}
const pg=suitePages.load(e.d.id);
try{
const t=await suiteSplit.launch({id:e.d.id,level:e.d.level,arms:b.arms,
const t=await suiteSplit.launch({id:e.d.id,level:spendLevel(e),arms:b.arms,
impressions:b.impressions,target:b.target,angle:b.angle,hasPage:!!(pg&&pg.copy)});
return json(res,200,{test:t,traffic:suiteTraffic.status(e.d.id,e.d.level)});
return json(res,200,{test:t,traffic:suiteTraffic.status(e.d.id,spendLevel(e))});
}catch(err){ return json(res,400,{error:String(err.message||err)}); }
}
}
@@ -1031,7 +1037,7 @@ async function handleApi(req,res,pathname){
if(req.method==='GET'){
// Recipients see what they were given even below level 7 - that is the
// half of the feature that makes being helped visible.
return json(res,200,{status:suiteGrants.status(e.d.id,e.d.level),tools:suiteGrants.tools(),
return json(res,200,{status:suiteGrants.status(e.d.id,e.d.level,isPreview(e)),tools:suiteGrants.tools(),
minLevel:suiteGrants.MIN_LEVEL,level:e.d.level,id:e.d.id});
}
if(req.method==='POST'){
@@ -1044,8 +1050,8 @@ async function handleApi(req,res,pathname){
try{ inTeam=chain.isInTeam(to,Number(e.d.id)); }catch(err){}
if(!inTeam)return json(res,400,{error:'Position #'+to+' is not in your organisation. You can only grant to people below you.'});
try{
const rec=suiteGrants.grant({by:e.d.id,to:to,tool:String(b.tool||''),n:b.n,level:e.d.level});
return json(res,200,{granted:rec,status:suiteGrants.status(e.d.id,e.d.level)});
const rec=suiteGrants.grant({by:e.d.id,to:to,tool:String(b.tool||''),n:b.n,level:e.d.level,preview:isPreview(e)});
return json(res,200,{granted:rec,status:suiteGrants.status(e.d.id,e.d.level,isPreview(e))});
}catch(err){ return json(res,400,{error:String(err.message||err)}); }
}
}
@@ -1101,7 +1107,7 @@ async function handleApi(req,res,pathname){
const b=await bodyJson(req)||{};
try{
const r=await suiteTraffic.stop(e.d.id,Number(b.ad_id));
return json(res,200,{stopped:r,status:suiteTraffic.status(e.d.id,e.d.level)});
return json(res,200,{stopped:r,status:suiteTraffic.status(e.d.id,spendLevel(e))});
}catch(err){ return json(res,400,{error:String(err.message||err)}); }
}