Admin: award Suite capacity to any member as a team bonus

Marty asked for a way to grant impressions to anybody, labelled as an admin
bonus. Team Grants already did this for leaders, but only within their own
organisation and only out of a level pool.

adminGrant() is deliberately a different thing: it comes from no pool (the
team is not a position and has no allowance), it can reach ANY registered
position rather than only someone downline, and it carries a reason that
survives in the ledger. Recorded with by:0 so nothing downstream mistakes it
for a leader's grant — the member's own page renders those as "the team"
rather than "#0".

The route verifies the position exists on-chain before crediting it; a typo
would otherwise sit in the ledger crediting nobody, and it would be invisible
until someone went looking.

Awards land on top of whatever the level already allows and expire with the
month like every other allowance. The admin page lists what has been awarded
this month, so bonuses stay auditable rather than vanishing once given.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-08-30 05:40:00 -05:00
parent 59bd4bd4ab
commit 05afe5c5f6
4 changed files with 130 additions and 4 deletions
+68 -1
View File
@@ -1,5 +1,28 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex,nofollow"><title>RM Circle Team Build Admin</title><link rel="icon" type="image/png" href="/favicon.png"><link rel="stylesheet" href="/styles.css"></head><body class="admin-bg"> <!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex,nofollow"><title>RM Circle Team Build Admin</title><link rel="icon" type="image/png" href="/favicon.png"><link rel="stylesheet" href="/styles.css"></head><body class="admin-bg">
<section id="loginView" class="login-panel"><div class="brand" style="margin-bottom:22px"><img class="brand-mark" src="/logo.jpg" alt="The RM Circle" width="42" height="42"><span>RM Circle<small>Sponsor Router Admin</small></span></div><h1>Team Admin</h1><p>Manage the current sponsor, qualification queue, and onboarding settings.</p><form id="loginForm"><div class="field"><label for="password">Admin password</label><input id="password" class="input" type="password" autocomplete="current-password" required></div><button class="btn btn-primary" style="width:100%">Sign in</button><div id="loginError" class="micro" style="color:var(--danger)"></div></form></section> <section id="loginView" class="login-panel"><div class="brand" style="margin-bottom:22px"><img class="brand-mark" src="/logo.jpg" alt="The RM Circle" width="42" height="42"><span>RM Circle<small>Sponsor Router Admin</small></span></div><h1>Team Admin</h1><p>Manage the current sponsor, qualification queue, and onboarding settings.</p><form id="loginForm"><div class="field"><label for="password">Admin password</label><input id="password" class="input" type="password" autocomplete="current-password" required></div><button class="btn btn-primary" style="width:100%">Sign in</button><div id="loginError" class="micro" style="color:var(--danger)"></div></form></section>
<section class="card" style="margin-bottom:18px">
<h2 style="margin:0">Award Suite Capacity</h2>
<p class="muted" style="margin:6px 0 12px;font-size:13.5px">
Give any registered position extra Suite capacity as a team bonus. It sits on top of whatever their level
already allows, expires with the month like every other allowance, and shows on their side as coming from
&ldquo;the team&rdquo;. Nothing is deducted from anyone &mdash; this is issued, not transferred.
</p>
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end">
<label style="font-size:12px">Position<br><input id="agTo" type="number" min="1" placeholder="46" style="width:110px"></label>
<label style="font-size:12px">What<br><select id="agTool" style="min-width:180px"></select></label>
<label style="font-size:12px">How much<br><input id="agN" type="number" min="1" step="500" value="5000" style="width:130px"></label>
<label style="font-size:12px;flex:1;min-width:200px">Reason (shown in the log)<br><input id="agNote" type="text" maxlength="80" placeholder="Launch bonus" style="width:100%"></label>
<button id="agGo" class="btn btn-primary">Award it</button>
</div>
<div id="agMsg" style="margin-top:10px;font-size:14px"></div>
<div style="margin-top:14px">
<div class="muted" style="font-size:12px;text-transform:uppercase;letter-spacing:1px">Bonuses awarded this month</div>
<div class="table-wrap"><table style="width:100%;border-collapse:collapse;font-size:13.5px" id="agLogTbl">
<thead><tr><th style="text-align:left">To</th><th style="text-align:left">What</th><th style="text-align:left">Amount</th><th style="text-align:left">Reason</th><th style="text-align:left">When</th></tr></thead>
<tbody id="agLog"></tbody></table></div>
</div>
</section>
<section id="adminView" class="admin hidden"><div class="admin-top"><div><div class="eyebrow">Sponsor router</div><h1>RM Circle Team Build Admin</h1></div><div class="nav-actions"><a class="btn btn-secondary" href="/start" target="_blank">View Live Page ↗</a><button id="logoutBtn" class="btn btn-secondary">Log out</button></div></div> <section id="adminView" class="admin hidden"><div class="admin-top"><div><div class="eyebrow">Sponsor router</div><h1>RM Circle Team Build Admin</h1></div><div class="nav-actions"><a class="btn btn-secondary" href="/start" target="_blank">View Live Page ↗</a><button id="logoutBtn" class="btn btn-secondary">Log out</button></div></div>
<div class="admin-grid"><div class="stack"><div class="table-card"><div style="display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:12px"><div><h2 style="margin:0">Sponsor Queue</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">Mark a sponsor qualified to automatically activate the next waiting position.</p></div></div><div class="table-wrap"><table class="table"><thead><tr><th>Order</th><th>Sponsor</th><th>Parent</th><th>Directs</th><th>Level</th><th>Status</th><th>Clicks</th><th>Actions</th></tr></thead><tbody id="sponsorRows"></tbody></table></div></div> <div class="admin-grid"><div class="stack"><div class="table-card"><div style="display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:12px"><div><h2 style="margin:0">Sponsor Queue</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">Mark a sponsor qualified to automatically activate the next waiting position.</p></div></div><div class="table-wrap"><table class="table"><thead><tr><th>Order</th><th>Sponsor</th><th>Parent</th><th>Directs</th><th>Level</th><th>Status</th><th>Clicks</th><th>Actions</th></tr></thead><tbody id="sponsorRows"></tbody></table></div></div>
<div class="table-card"><div style="margin-bottom:12px"><h2 style="margin:0">Traffic &amp; Conversions</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">First-touch source per visitor session (referring domain or utm_source). Funnel: bridge page → start page → join click.</p></div><div id="funnelStats" class="funnel"></div><div class="table-wrap"><table class="table"><thead><tr><th>Source</th><th>Bridge views</th><th>Start views</th><th>Training views</th><th>Invite views</th><th>Join-now views</th><th>Join clicks</th><th>Confirmed</th><th>Start → Join</th></tr></thead><tbody id="trafficRows"></tbody></table></div></div> <div class="table-card"><div style="margin-bottom:12px"><h2 style="margin:0">Traffic &amp; Conversions</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">First-touch source per visitor session (referring domain or utm_source). Funnel: bridge page → start page → join click.</p></div><div id="funnelStats" class="funnel"></div><div class="table-wrap"><table class="table"><thead><tr><th>Source</th><th>Bridge views</th><th>Start views</th><th>Training views</th><th>Invite views</th><th>Join-now views</th><th>Join clicks</th><th>Confirmed</th><th>Start → Join</th></tr></thead><tbody id="trafficRows"></tbody></table></div></div>
@@ -14,4 +37,48 @@
<div class="table-card"><h2 style="margin-top:0">AI Chat</h2><p style="color:var(--muted);font-size:13px;margin:4px 0 12px">Paste an OpenRouter API key to switch the help chat from canned answers to AI (<span id="aiModel"></span>). Clear it to switch back.</p><div id="aiStatus" class="micro" style="margin-bottom:10px"></div><form id="aiKeyForm"><div class="field"><label>OpenRouter API key</label><input name="key" class="input" type="password" placeholder="sk-or-v1-…" autocomplete="off"></div><button class="btn btn-teal" style="width:100%">Save Key</button><button type="button" id="aiKeyClear" class="btn btn-secondary" style="width:100%;margin-top:8px">Clear Key (use canned answers)</button></form></div> <div class="table-card"><h2 style="margin-top:0">AI Chat</h2><p style="color:var(--muted);font-size:13px;margin:4px 0 12px">Paste an OpenRouter API key to switch the help chat from canned answers to AI (<span id="aiModel"></span>). Clear it to switch back.</p><div id="aiStatus" class="micro" style="margin-bottom:10px"></div><form id="aiKeyForm"><div class="field"><label>OpenRouter API key</label><input name="key" class="input" type="password" placeholder="sk-or-v1-…" autocomplete="off"></div><button class="btn btn-teal" style="width:100%">Save Key</button><button type="button" id="aiKeyClear" class="btn btn-secondary" style="width:100%;margin-top:8px">Clear Key (use canned answers)</button></form></div>
<div class="table-card"><h2 style="margin-top:0">Payment Emails (SendGrid)</h2><p style="color:var(--muted);font-size:13px;margin:4px 0 12px">When a payout hits a member whose sponsor record has a contact email, they get a "you've been paid" email automatically. Paste your SendGrid API key (Branded Voice Coolify app → Environment Variables → SENDGRID_API_KEY).</p><div id="emailStatus" class="micro" style="margin-bottom:10px"></div><form id="sgKeyForm"><div class="field"><label>SendGrid API key</label><input name="key" class="input" type="password" placeholder="SG.…" autocomplete="off"></div><button class="btn btn-teal" style="width:100%">Save Key</button><button type="button" id="sgKeyClear" class="btn btn-secondary" style="width:100%;margin-top:8px">Clear Key (disable emails)</button></form><div class="field" style="margin-top:12px"><label>From address (marketingwithmarty.com or mybrandedvoice.com — DKIM)</label><input id="emailFromInput" class="input" placeholder="The RM Circle Team &lt;no-reply@marketingwithmarty.com&gt;"><button type="button" id="emailFromSave" class="btn btn-secondary" style="width:100%;margin-top:8px">Save From Address</button></div></div> <div class="table-card"><h2 style="margin-top:0">Payment Emails (SendGrid)</h2><p style="color:var(--muted);font-size:13px;margin:4px 0 12px">When a payout hits a member whose sponsor record has a contact email, they get a "you've been paid" email automatically. Paste your SendGrid API key (Branded Voice Coolify app → Environment Variables → SENDGRID_API_KEY).</p><div id="emailStatus" class="micro" style="margin-bottom:10px"></div><form id="sgKeyForm"><div class="field"><label>SendGrid API key</label><input name="key" class="input" type="password" placeholder="SG.…" autocomplete="off"></div><button class="btn btn-teal" style="width:100%">Save Key</button><button type="button" id="sgKeyClear" class="btn btn-secondary" style="width:100%;margin-top:8px">Clear Key (disable emails)</button></form><div class="field" style="margin-top:12px"><label>From address (marketingwithmarty.com or mybrandedvoice.com — DKIM)</label><input id="emailFromInput" class="input" placeholder="The RM Circle Team &lt;no-reply@marketingwithmarty.com&gt;"><button type="button" id="emailFromSave" class="btn btn-secondary" style="width:100%;margin-top:8px">Save From Address</button></div></div>
<div class="table-card"><h2 style="margin-top:0">Public Page Settings</h2><form id="configForm"><div class="field"><label>Site name</label><input name="siteName" class="input"></div><div class="field"><label>Program name</label><input name="programName" class="input"></div><div class="field"><label>Bridge headline</label><input name="bridgeHeadline" class="input"></div><div class="field"><label>Bridge subheadline</label><textarea name="bridgeSubheadline" class="input" rows="3"></textarea></div><div class="field"><label>Premium entry (POL)</label><input name="premiumEntryPol" class="input" type="number"></div><div class="field"><label>RM dApp referral base URL</label><input name="dappReferralBaseUrl" class="input" placeholder="https://app.thermcircle.com?ref="></div><div class="field"><label>BeMob postback URL (paid traffic)</label><input name="bemobPostbackUrl" class="input" placeholder="https://xxxxx.bemobtrcks.com/postback"></div><div class="field"><label>Telegram bot token (Hermes notifications)</label><input name="telegramBotToken" class="input" type="password" autocomplete="off" placeholder="123456:ABC…"></div><div class="field"><label>Telegram chat ID (group or user)</label><input name="telegramChatId" class="input" placeholder="-1001234567890"></div><div class="field"><label>Telegram topic ID (optional, for forum groups)</label><input name="telegramTopicId" class="input" placeholder="55"></div><div class="field"><label>Team root ID (on-chain team-build alerts)</label><input name="teamRootId" class="input" inputmode="numeric" placeholder="21 — alerts fire for any activity at or below this member ID"></div><div class="field"><label>Team alert email (admin copy of every team-build alert)</label><input name="teamAlertEmail" class="input" type="email" placeholder="you@example.com — emailed for any activity below the team root"></div><div class="field"><label>Upgrade alert email (for your own positions)</label><input name="ownerAlertEmail" class="input" type="email" placeholder="you@example.com — emailed when a position in 'My Positions' needs upgrading to catch incoming pay"></div><div class="field"><label>Telegram/support URL (optional)</label><input name="telegramUrl" class="input"></div><div class="field"><label>Support message</label><textarea name="supportLabel" class="input" rows="3"></textarea></div><label style="text-transform:none;letter-spacing:0;margin:10px 0"><input type="checkbox" name="showSponsorName"> Show sponsor name publicly</label><label style="text-transform:none;letter-spacing:0;margin:10px 0"><input type="checkbox" name="showQueueProgress"> Show number waiting in queue</label><button class="btn btn-primary" style="width:100%;margin-top:8px">Save Settings</button></form></div></div></div></section> <div class="table-card"><h2 style="margin-top:0">Public Page Settings</h2><form id="configForm"><div class="field"><label>Site name</label><input name="siteName" class="input"></div><div class="field"><label>Program name</label><input name="programName" class="input"></div><div class="field"><label>Bridge headline</label><input name="bridgeHeadline" class="input"></div><div class="field"><label>Bridge subheadline</label><textarea name="bridgeSubheadline" class="input" rows="3"></textarea></div><div class="field"><label>Premium entry (POL)</label><input name="premiumEntryPol" class="input" type="number"></div><div class="field"><label>RM dApp referral base URL</label><input name="dappReferralBaseUrl" class="input" placeholder="https://app.thermcircle.com?ref="></div><div class="field"><label>BeMob postback URL (paid traffic)</label><input name="bemobPostbackUrl" class="input" placeholder="https://xxxxx.bemobtrcks.com/postback"></div><div class="field"><label>Telegram bot token (Hermes notifications)</label><input name="telegramBotToken" class="input" type="password" autocomplete="off" placeholder="123456:ABC…"></div><div class="field"><label>Telegram chat ID (group or user)</label><input name="telegramChatId" class="input" placeholder="-1001234567890"></div><div class="field"><label>Telegram topic ID (optional, for forum groups)</label><input name="telegramTopicId" class="input" placeholder="55"></div><div class="field"><label>Team root ID (on-chain team-build alerts)</label><input name="teamRootId" class="input" inputmode="numeric" placeholder="21 — alerts fire for any activity at or below this member ID"></div><div class="field"><label>Team alert email (admin copy of every team-build alert)</label><input name="teamAlertEmail" class="input" type="email" placeholder="you@example.com — emailed for any activity below the team root"></div><div class="field"><label>Upgrade alert email (for your own positions)</label><input name="ownerAlertEmail" class="input" type="email" placeholder="you@example.com — emailed when a position in 'My Positions' needs upgrading to catch incoming pay"></div><div class="field"><label>Telegram/support URL (optional)</label><input name="telegramUrl" class="input"></div><div class="field"><label>Support message</label><textarea name="supportLabel" class="input" rows="3"></textarea></div><label style="text-transform:none;letter-spacing:0;margin:10px 0"><input type="checkbox" name="showSponsorName"> Show sponsor name publicly</label><label style="text-transform:none;letter-spacing:0;margin:10px 0"><input type="checkbox" name="showQueueProgress"> Show number waiting in queue</label><button class="btn btn-primary" style="width:100%;margin-top:8px">Save Settings</button></form></div></div></div></section>
<div id="toast" class="toast"></div><script src="/admin.js"></script></body></html> <div id="toast" class="toast"></div><script src="/admin.js"></script><script>
(function(){
var $=function(i){return document.getElementById(i)};
var TOOLS=[];
function esc(x){return String(x==null?'':x).replace(/[&<>"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]})}
function renderLog(rows){
var tb=$('agLog'); if(!tb) return;
tb.innerHTML=(rows||[]).map(function(r){
var t=TOOLS.filter(function(x){return x.key===r.tool})[0]||{};
return '<tr><td><b>#'+esc(r.to)+'</b></td><td>'+esc(t.label||r.tool)+'</td><td>'+Number(r.n).toLocaleString()+' '+esc(t.unit||'')+
'</td><td>'+esc(r.note||'')+'</td><td>'+esc(String(r.at||'').slice(0,10))+'</td></tr>';
}).join('')||'<tr><td colspan="5" class="muted">None yet this month.</td></tr>';
}
async function boot(){
try{
var r=await fetch('/api/admin/grant'); if(!r.ok) return;
var d=await r.json(); TOOLS=d.tools||[];
$('agTool').innerHTML=TOOLS.map(function(t){return '<option value="'+t.key+'">'+esc(t.label)+'</option>'}).join('');
renderLog(d.log);
}catch(e){}
}
async function award(){
var msg=$('agMsg'); msg.textContent='';
var to=Number($('agTo').value)||0, n=Number($('agN').value)||0;
if(!to||!n){ msg.style.color='#f0a05a'; msg.textContent='Need a position and an amount.'; return; }
$('agGo').disabled=true;
try{
var r=await fetch('/api/admin/grant',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({to:to,tool:$('agTool').value,n:n,note:$('agNote').value})});
var d=await r.json();
if(!r.ok){ msg.style.color='#f0a05a'; msg.textContent=d.error||'Failed.'; }
else{
var t=TOOLS.filter(function(x){return x.key===d.granted.tool})[0]||{};
msg.style.color='var(--teal)';
msg.textContent='Awarded '+Number(d.granted.n).toLocaleString()+' '+(t.unit||'')+' to #'+d.granted.to+
' ('+(d.levelName||'')+'). It is in their allowance now.';
$('agNote').value=''; renderLog(d.log);
}
}catch(e){ msg.style.color='#f0a05a'; msg.textContent='Connection hiccup.'; }
$('agGo').disabled=false;
}
document.addEventListener('DOMContentLoaded',function(){ boot(); var b=$('agGo'); if(b) b.addEventListener('click',award); });
})();
</script>
</body></html>
+3 -2
View File
@@ -23,13 +23,14 @@
if (!keys.length) { $('gGot').innerHTML = ''; return; } if (!keys.length) { $('gGot').innerHTML = ''; return; }
var parts = keys.map(function (k) { var parts = keys.map(function (k) {
var r = received[k]; var r = received[k];
var who = r.from.map(function (f) { return '#' + f.by; }); // by:0 is an admin bonus from the team, not a member grant.
var who = r.from.map(function (f) { return Number(f.by) === 0 ? 'the team' : '#' + f.by; });
var uniq = who.filter(function (v, i) { return who.indexOf(v) === i; }); var uniq = who.filter(function (v, i) { return who.indexOf(v) === i; });
var t = (state.status && state.status.tools[k]) || {}; var t = (state.status && state.status.tools[k]) || {};
return '<div>· <b style="color:var(--text)">' + n(r.total) + ' ' + esc(t.unit || k) + return '<div>· <b style="color:var(--text)">' + n(r.total) + ' ' + esc(t.unit || k) +
'</b> of ' + esc((t.label || k).toLowerCase()) + ', from ' + uniq.join(', ') + '</div>'; '</b> of ' + esc((t.label || k).toLowerCase()) + ', from ' + uniq.join(', ') + '</div>';
}); });
$('gGot').innerHTML = '<div class="g-got"><b style="color:var(--teal)">Someone above you topped you up this month.</b>' + $('gGot').innerHTML = '<div class="g-got"><b style="color:var(--teal)">You have been topped up this month.</b>' +
parts.join('') + '<div style="color:var(--muted);font-size:12.5px;margin-top:6px">' + parts.join('') + '<div style="color:var(--muted);font-size:12.5px;margin-top:6px">' +
'It is already added to your allowances — nothing to claim.</div></div>'; 'It is already added to your allowances — nothing to claim.</div></div>';
} }
+20
View File
@@ -1356,6 +1356,26 @@ async function handleApi(req,res,pathname){
const s=getSession(req);if(s){sessions.delete(s.token);saveSessions();}return json(res,200,{ok:true},{'Set-Cookie':'ctb.sid=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'}); const s=getSession(req);if(s){sessions.delete(s.token);saveSessions();}return json(res,200,{ok:true},{'Set-Cookie':'ctb.sid=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'});
} }
if(pathname.startsWith('/api/admin/')&&!requireAdmin(req,res))return; if(pathname.startsWith('/api/admin/')&&!requireAdmin(req,res))return;
if(pathname==='/api/admin/grant'){
if(req.method==='GET'){
return json(res,200,{tools:suiteGrants.tools(),log:suiteGrants.adminLog(60)});
}
if(req.method==='POST'){
const b=await bodyJson(req)||{};
const to=Number(b.to)||0;
if(!to)return json(res,400,{error:'Enter the position number.'});
// Confirm the position actually exists before crediting it — a typo would
// otherwise sit in the ledger crediting nobody.
let md=null;
try{ md=await chain.memberPublic(to); }catch(err){}
if(!md||!md.registered)return json(res,404,{error:'Position #'+to+' is not registered on-chain.'});
try{
const rec=suiteGrants.adminGrant({to:to,tool:String(b.tool||''),n:b.n,note:b.note});
return json(res,200,{granted:rec,levelName:md.levelName,log:suiteGrants.adminLog(60)});
}catch(err){ return json(res,400,{error:String(err.message||err)}); }
}
}
if(req.method==='GET'&&pathname==='/api/admin/matrix-tree'){ if(req.method==='GET'&&pathname==='/api/admin/matrix-tree'){
return json(res,200,chain.getMatrixTree()); return json(res,200,chain.getMatrixTree());
} }
+39 -1
View File
@@ -112,6 +112,44 @@ function status(memberId, level, preview) {
}; };
} }
// An admin bonus. Distinct from a member grant in three ways: it comes from no
// pool (the team is not a position and has no allowance), it can go to ANY
// member rather than only someone in the granter's own organisation, and it
// carries a note so the reason survives in the ledger. Recorded with by:0 so
// nothing downstream mistakes it for a leader's grant.
function adminGrant(opts) {
const to = Number(opts.to);
const tool = String(opts.tool || '');
const n = Math.floor(Number(opts.n) || 0);
if (!POOLS[tool]) throw new Error('That is not something that can be granted.');
if (!to) throw new Error('Which position is this for?');
if (n <= 0) throw new Error('How much?');
if (n > 1000000) throw new Error('That is more than anyone could use in a month.');
const all = readAll();
const mk = monthKey();
if (!all[mk]) all[mk] = [];
const rec = {
by: 0, to: to, tool: tool, n: n,
admin: true,
note: String(opts.note || '').replace(/\s+/g, ' ').trim().slice(0, 80) || 'Team bonus',
at: new Date().toISOString()
};
all[mk].push(rec);
const keep = Object.keys(all).sort().slice(-3);
const trimmed = {};
keep.forEach(function (k) { trimmed[k] = all[k]; });
writeAll(trimmed);
return rec;
}
// Everything the team has handed out this month, newest first — for the admin
// view, so bonuses are auditable rather than invisible once given.
function adminLog(limit) {
return rows().filter(function (r) { return r.admin; })
.slice(-(Math.max(1, Math.min(200, Number(limit) || 50)))).reverse();
}
// Hand capacity to someone. The caller is responsible for having verified that // Hand capacity to someone. The caller is responsible for having verified that
// `to` is inside `by`'s organisation — that check needs the chain and lives in // `to` is inside `by`'s organisation — that check needs the chain and lives in
// the route, not here. // the route, not here.
@@ -148,4 +186,4 @@ function grant(opts) {
return rec; return rec;
} }
module.exports = { init, MIN_LEVEL, POOLS, tools, poolFor, status, grant, receivedBy, givenBy, receivedDetail }; module.exports = { init, MIN_LEVEL, POOLS, tools, poolFor, status, grant, adminGrant, adminLog, receivedBy, givenBy, receivedDetail };