InstantAdPay site skeleton: SIWE auth, live chain ledger, join links, buy flow

Zero-dependency Node server on the RM Circle pattern. Chain config lives in
the volume so the same code runs the Amoy dress rehearsal and mainnet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-04 12:25:34 -05:00
commit f053c1befa
20 changed files with 3116 additions and 0 deletions
+210
View File
@@ -0,0 +1,210 @@
// InstantAdPay — membership advertising with immutable on-chain settlement.
// Zero-dependency Node server (RM Circle pattern): static pages + JSON API,
// wallet sign-in (SIWE), live contract ledger, sponsor join links.
//
// Chain selection (Amoy rehearsal vs mainnet) lives in data/config.json —
// see chain.js. Wipe the volume's accounts/sessions + flip config = launch.
const http = require('http');
const fs = require('fs');
const path = require('path');
const { URL } = require('url');
const chain = require('./chain');
const auth = require('./auth');
const accounts = require('./accounts');
const PORT = Number(process.env.PORT || 3000);
const ROOT = __dirname;
const PUBLIC_DIR = path.join(ROOT, 'public');
const DATA_DIR = process.env.DATA_DIR || path.join(ROOT, 'data');
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'changeme';
const IS_PROD = process.env.NODE_ENV === 'production';
const SITE_FILE = path.join(DATA_DIR, 'site.json');
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 });
function siteConfig() {
let saved = {};
try { saved = JSON.parse(fs.readFileSync(SITE_FILE, 'utf8')); } catch (e) {}
return Object.assign({
siteName: 'InstantAdPay',
tagline: 'Advertising that pays the people who build it — instantly, on-chain.',
rehearsal: true // shows the testnet banner; flipped off at mainnet launch
}, saved);
}
// ---- helpers ----
const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript',
'.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml', '.webp': 'image/webp',
'.ico': 'image/x-icon', '.json': 'application/json', '.mp4': 'video/mp4', '.woff2': 'font/woff2' };
const CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self' data:; form-action 'self'";
function baseHeaders(extra) {
return Object.assign({ 'Content-Security-Policy': CSP, 'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'strict-origin-when-cross-origin' }, extra || {});
}
function json(res, code, obj, extra) {
const body = JSON.stringify(obj);
res.writeHead(code, baseHeaders(Object.assign({ 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, extra)));
res.end(body);
}
function sendFile(res, file) {
fs.readFile(file, (err, data) => {
if (err) { res.writeHead(404, baseHeaders({ 'Content-Type': 'text/plain' })); return res.end('Not found'); }
const ext = path.extname(file).toLowerCase();
res.writeHead(200, baseHeaders({ 'Content-Type': MIME[ext] || 'application/octet-stream',
'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=3600' }));
res.end(data);
});
}
function readBody(req) {
return new Promise((resolve, reject) => {
let d = ''; let n = 0;
req.on('data', c => { n += c.length; if (n > 64 * 1024) { req.destroy(); reject(new Error('too big')); } d += c; });
req.on('end', () => { try { resolve(d ? JSON.parse(d) : {}); } catch (e) { reject(e); } });
req.on('error', reject);
});
}
function parseCookies(req) {
const out = {};
for (const p of (req.headers.cookie || '').split(';')) {
const i = p.indexOf('='); if (i > 0) out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim());
}
return out;
}
function isAdmin(req) {
const h = req.headers.authorization || '';
return h === 'Bearer ' + ADMIN_PASSWORD;
}
// ---- live feed (SSE) ----
const feedClients = new Set();
function pushFeed(ev) {
const line = 'data: ' + JSON.stringify(ev) + '\n\n';
for (const res of feedClients) { try { res.write(line); } catch (e) { feedClients.delete(res); } }
}
// ---- server ----
const server = http.createServer(async (req, res) => {
try {
const u = new URL(req.url, 'http://x');
const p = u.pathname;
// -- join links: /join/<sponsorId> — first-touch attribution cookie
let m = /^\/join\/(\d{1,9})$/.exec(p);
if (m && req.method === 'GET') {
const sid = Number(m[1]);
const cookies = parseCookies(req);
const headers = { Location: '/' };
if (!cookies['iap.sponsor']) {
headers['Set-Cookie'] = `iap.sponsor=${sid}; Path=/; SameSite=Lax; Max-Age=${180 * 24 * 3600}${IS_PROD ? '; Secure' : ''}`;
}
res.writeHead(302, baseHeaders(headers));
return res.end();
}
// -- public API
if (p === '/api/config' && req.method === 'GET') {
const c = chain.getConfig();
return json(res, 200, Object.assign({ contract: c.contract, chainId: c.chainId,
chainName: c.chainName, explorer: c.explorer, rpc: c.rpcs[0] }, siteConfig()));
}
if (p === '/api/catalog' && req.method === 'GET') {
return json(res, 200, { products: await chain.catalog() });
}
if (p === '/api/feed' && req.method === 'GET') {
return json(res, 200, { events: chain.recentEvents(Number(u.searchParams.get('n')) || 100) });
}
if (p === '/api/feed/live' && req.method === 'GET') {
res.writeHead(200, baseHeaders({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive' }));
res.write(': connected\n\n');
feedClients.add(res);
req.on('close', () => feedClients.delete(res));
return;
}
if (p === '/api/sponsor' && req.method === 'GET') {
const sid = Number(parseCookies(req)['iap.sponsor']) || 0;
let sponsor = null;
if (sid) { try { const mm = await chain.member(sid); if (mm.account !== '0x' + '0'.repeat(40)) sponsor = { id: sid }; } catch (e) {} }
return json(res, 200, { sponsorId: sponsor ? sid : 0 });
}
if (p === '/api/stats' && req.method === 'GET') {
let members = 0; try { members = await chain.memberCount(); } catch (e) {}
return json(res, 200, { onchainMembers: members, siteAccounts: accounts.count() });
}
// -- auth
if (p === '/api/auth/challenge' && req.method === 'POST') {
const b = await readBody(req);
const r = auth.makeChallenge(b.address);
return json(res, r.error ? 400 : 200, r);
}
if (p === '/api/auth/verify' && req.method === 'POST') {
const b = await readBody(req);
const r = await auth.verifyChallenge(b.address, b.signature);
if (r.error) return json(res, 400, r);
// bind the visitor's sponsor cookie to this wallet, first touch wins
const sid = Number(parseCookies(req)['iap.sponsor']) || 0;
const attributed = accounts.attributeSponsor(r.address, sid);
accounts.upsert(r.address, { lastSeen: Date.now() });
return json(res, 200, { ok: true, address: r.address, memberId: r.memberId, sponsorId: attributed },
{ 'Set-Cookie': auth.sessionCookie(r.token) });
}
if (p === '/api/auth/logout' && req.method === 'POST') {
auth.logout(req);
return json(res, 200, { ok: true }, { 'Set-Cookie': auth.clearCookie() });
}
if (p === '/api/me' && req.method === 'GET') {
const s = auth.fromRequest(req);
if (!s) return json(res, 200, { signedIn: false });
const memberId = await auth.refreshMemberId(s);
const acct = accounts.get(s.address) || {};
const out = { signedIn: true, address: s.address, memberId, sponsorId: acct.sponsorId || 0 };
if (memberId) {
try {
const mm = await chain.member(memberId);
out.buyerCount = mm.buyerCount;
out.onchainSponsorId = mm.sponsorId;
out.credits = await chain.creditBalance(memberId, 0);
} catch (e) { out.chainReadError = true; }
}
return json(res, 200, out);
}
// -- admin (Bearer ADMIN_PASSWORD)
if (p === '/api/admin/site' && req.method === 'PATCH') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
const b = await readBody(req);
const cur = siteConfig();
fs.writeFileSync(SITE_FILE, JSON.stringify(Object.assign(cur, b), null, 2));
return json(res, 200, { ok: true, site: siteConfig() });
}
if (p === '/api/admin/chain' && req.method === 'PATCH') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
const b = await readBody(req);
const file = path.join(DATA_DIR, 'config.json');
let cur = {}; try { cur = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) {}
fs.writeFileSync(file, JSON.stringify(Object.assign(cur, b), null, 2));
chain.reloadConfig();
return json(res, 200, { ok: true, config: chain.getConfig() });
}
// -- pages
if (req.method === 'GET') {
if (p === '/') return sendFile(res, path.join(PUBLIC_DIR, 'index.html'));
if (p === '/ledger') return sendFile(res, path.join(PUBLIC_DIR, 'ledger.html'));
if (p === '/my') return sendFile(res, path.join(PUBLIC_DIR, 'my.html'));
const safe = path.normalize(p).replace(/^([.\\/])+/, '');
const file = path.join(PUBLIC_DIR, safe);
if (file.startsWith(PUBLIC_DIR) && fs.existsSync(file) && fs.statSync(file).isFile()) return sendFile(res, file);
}
res.writeHead(404, baseHeaders({ 'Content-Type': 'text/plain' }));
res.end('Not found');
} catch (e) {
console.error('request error', req.url, e.message);
try { json(res, 500, { error: 'server error' }); } catch (_) {}
}
});
server.listen(PORT, () => console.log(`InstantAdPay site on :${PORT} — chain: ${chain.getConfig().chainName}`));