Ad engine v1: banner/text/login campaigns against on-chain credits

Spec 8b types 1-3. Spend accrues per campaign in batches; burns queue for
the engine signer (admin runs consume() on-chain, /api/admin/burns). Rates
are volume config (adrates.json), rehearsal placeholders until Marty sets
the real card. Public slots serve on the ledger page; campaign manager in
the members area.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-04 13:04:47 -05:00
parent a90d5eee78
commit bee13ba4b8
7 changed files with 334 additions and 1 deletions
+57
View File
@@ -11,6 +11,7 @@ const { URL } = require('url');
const chain = require('./chain');
const auth = require('./auth');
const accounts = require('./accounts');
const ads = require('./ads');
const PORT = Number(process.env.PORT || 3000);
const ROOT = __dirname;
@@ -24,6 +25,9 @@ fs.mkdirSync(DATA_DIR, { recursive: true });
chain.init({ onEvent: ev => pushFeed(ev) });
auth.init({ dataDir: DATA_DIR, chain, isProd: IS_PROD, site: 'instantadpay.com' });
accounts.init({ dataDir: DATA_DIR });
ads.init({ dataDir: DATA_DIR, chain });
setTimeout(() => ads.dailySweep(), 60 * 1000);
setInterval(() => ads.dailySweep(), 60 * 60 * 1000); // login-ad daily charges
function siteConfig() {
let saved = {};
@@ -216,7 +220,60 @@ const server = http.createServer(async (req, res) => {
});
}
// -- ad engine (spec §8b v1: banners, text, login ads)
if (p === '/api/ads/slot' && req.method === 'GET') {
const ad = ads.serve(String(u.searchParams.get('type') || 'banner'));
return json(res, 200, { ad });
}
m = /^\/api\/ads\/click\/(\d+)$/.exec(p);
if (m && req.method === 'GET') {
const target = ads.click(m[1]);
if (!target) { res.writeHead(404, baseHeaders()); return res.end(); }
res.writeHead(302, baseHeaders({ Location: target }));
return res.end();
}
if (p === '/api/my/campaigns' && req.method === 'GET') {
const s = auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const memberId = await auth.refreshMemberId(s);
const out = { campaigns: ads.listCampaigns(s.email), rates: ads.rates() };
out.availableCredits = memberId ? await ads.availableCredits(memberId) : 0;
return json(res, 200, out);
}
if (p === '/api/my/campaigns' && req.method === 'POST') {
const s = auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const memberId = await auth.refreshMemberId(s);
if (!memberId) return json(res, 400, { error: 'Buy an ad package first. Campaigns spend the on-chain credits it mints.' });
const b = await readBody(req);
const r = await ads.createCampaign(s.email, memberId, b);
return json(res, r.error ? 400 : 200, r);
}
m = /^\/api\/my\/campaigns\/(\d+)\/(pause|resume)$/.exec(p);
if (m && req.method === 'POST') {
const s = auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const r = ads.setStatus(s.email, m[1], m[2] === 'pause' ? 'paused' : 'active');
return json(res, r.error ? 400 : 200, r);
}
// -- admin (Bearer ADMIN_PASSWORD)
if (p === '/api/admin/burns' && req.method === 'GET') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
return json(res, 200, { pending: ads.pendingBurns() });
}
if (p === '/api/admin/burns/mark' && req.method === 'POST') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
const b = await readBody(req);
const r = ads.markBurned(b.id, b.tx);
return json(res, r.error ? 400 : 200, r);
}
if (p === '/api/admin/rates' && req.method === 'PATCH') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
const b = await readBody(req);
return json(res, 200, { ok: true, rates: ads.setRates(b) });
}
if (p === '/api/admin/site' && req.method === 'PATCH') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
const b = await readBody(req);