MySQL data layer: accounts, sessions, campaigns, burns (Marty: real concurrency)

Coolify MySQL (instantadpay-db) via DATABASE_URL; db.js bootstraps schema
and one-time imports the volume JSON. accounts/auth/ads are dual-mode: the
MySQL path uses guarded UPDATEs for the concurrent ad-serving hot path;
without DATABASE_URL the JSON stores remain (local dev). All data functions
async; server boots through db.init. Chain index stays a file: it is a
rebuildable cache of the blockchain, which remains the money truth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-04 14:13:37 -05:00
parent f3d2f7db88
commit 6680e154d0
7 changed files with 616 additions and 333 deletions
+2
View File
@@ -1,5 +1,7 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev --no-audit --no-fund
COPY . .
RUN mkdir -p /app/data
ENV NODE_ENV=production
+142 -101
View File
@@ -1,44 +1,16 @@
// Site-side member accounts for InstantAdPay.
// The chain is the source of truth for money, credits, and qualification;
// this module holds what the chain doesn't: free members (email + password,
// the way normal people join), sponsor attribution before first purchase
// (spec §4), and the wallet link once one is connected at purchase time.
// Wiping this file = the clean reset between rehearsal and mainnet.
// Site-side member accounts. Dual-mode:
// MySQL (db.enabled) for real concurrency in production,
// JSON volume file as the no-DATABASE_URL fallback (local dev).
// All exported functions are async; both modes return identical shapes.
// The chain remains the source of truth for money/credits/qualification.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const db = require('./db');
let DATA_DIR = null;
const FILE = () => path.join(DATA_DIR, 'accounts.json');
let db = { v: 2, byEmail: {}, byAddress: {}, joins: 0 };
function load() {
try { db = JSON.parse(fs.readFileSync(FILE(), 'utf8')); } catch (e) {}
if (!db || !db.v) db = { v: 2, byEmail: {}, byAddress: {}, joins: 0 };
if (db.v === 1) { db.v = 2; db.byEmail = db.byEmail || {}; } // early rehearsal file
if (!db.byCode) db.byCode = {};
// every account carries a share code from day one (backfill older records)
for (const a of Object.values(db.byEmail)) {
if (!a.code) { a.code = genCode(); db.byCode[a.code] = a.email; }
else if (!db.byCode[a.code]) db.byCode[a.code] = a.email;
}
}
function genCode() {
let c;
do { c = crypto.randomBytes(5).toString('base64url').replace(/[-_]/g, '').slice(0, 7).toLowerCase(); }
while (!c || c.length < 6 || (db.byCode && db.byCode[c]) || /^\d+$/.test(c));
return c;
}
function save() {
try {
const tmp = FILE() + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(db), { mode: 0o600 });
fs.renameSync(tmp, FILE());
} catch (e) { console.error('accounts save failed', e.message); }
}
function init(opts) { DATA_DIR = opts.dataDir; load(); }
// ---- password hashing (scrypt, no deps) ----
// ---- shared helpers ----
function hashPassword(password) {
const salt = crypto.randomBytes(16);
const hash = crypto.scryptSync(String(password), salt, 32);
@@ -51,85 +23,154 @@ function checkPassword(password, stored) {
return crypto.timingSafeEqual(hash, Buffer.from(hashHex, 'hex'));
} catch (e) { return false; }
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
const normEmail = e => String(e || '').trim().toLowerCase();
const normAddr = a => String(a || '').trim().toLowerCase();
function newCode(taken) {
let c;
do { c = crypto.randomBytes(5).toString('base64url').replace(/[-_]/g, '').slice(0, 7).toLowerCase(); }
while (!c || c.length < 6 || /^\d+$/.test(c) || (taken && taken(c)));
return c;
}
const pub = a => a ? { email: a.email, sponsorRef: a.sponsorRef || '', code: a.code || null,
address: a.address || null, created: a.created } : null;
// ---- email accounts (the normal join path) ----
function signup(email, password, sponsorRef) {
// ---- JSON fallback ----
const J = {
db: { v: 2, byEmail: {}, byAddress: {}, byCode: {}, joins: 0 },
FILE: () => path.join(DATA_DIR, 'accounts.json'),
load() {
try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) {}
if (!this.db || !this.db.v) this.db = { v: 2, byEmail: {}, byAddress: {}, byCode: {}, joins: 0 };
if (!this.db.byCode) this.db.byCode = {};
for (const a of Object.values(this.db.byEmail)) {
if (!a.code) { a.code = newCode(c => this.db.byCode[c]); this.db.byCode[a.code] = a.email; }
else if (!this.db.byCode[a.code]) this.db.byCode[a.code] = a.email;
}
},
save() {
try {
const tmp = this.FILE() + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(this.db), { mode: 0o600 });
fs.renameSync(tmp, this.FILE());
} catch (e) { console.error('accounts save failed', e.message); }
},
async signup(e, password, ref) {
if (this.db.byEmail[e]) return { error: 'That email already has an account. Log in instead.' };
const code = newCode(c => this.db.byCode[c]);
this.db.byEmail[e] = { email: e, pass: hashPassword(password), sponsorRef: ref, code, address: null, created: Date.now() };
this.db.byCode[code] = e;
this.save();
return { ok: true, created: true, account: pub(this.db.byEmail[e]) };
},
async login(e, password) {
const a = this.db.byEmail[e];
if (!a || !a.pass || !checkPassword(password, a.pass)) return { error: 'Wrong email or password.' };
return { ok: true, account: pub(a) };
},
async ensure(e, ref) {
let created = false;
if (!this.db.byEmail[e]) {
const code = newCode(c => this.db.byCode[c]);
this.db.byEmail[e] = { email: e, pass: null, sponsorRef: ref, code, address: null, created: Date.now() };
this.db.byCode[code] = e;
created = true;
this.save();
}
return { ok: true, created, account: pub(this.db.byEmail[e]) };
},
async byEmail(e) { return pub(this.db.byEmail[e]); },
async byAddress(a) { const e = this.db.byAddress[a]; return e ? pub(this.db.byEmail[e]) : null; },
async byCode(c) { const e = this.db.byCode[c]; return e ? pub(this.db.byEmail[e]) : null; },
async linkWallet(e, a) {
const acct = this.db.byEmail[e];
if (!acct) return { error: 'No such account.' };
if (acct.address && acct.address !== a) return { error: 'This account is already linked to wallet '
+ acct.address.slice(0, 6) + '…' + acct.address.slice(-4) + '. Connect that wallet instead.' };
if (this.db.byAddress[a] && this.db.byAddress[a] !== e) return { error: 'That wallet is already linked to a different account.' };
acct.address = a;
this.db.byAddress[a] = e;
this.save();
return { ok: true, account: pub(acct) };
},
async count() { return Object.keys(this.db.byEmail).length; }
};
// ---- MySQL mode ----
const rowPub = r => r ? pub({ email: r.email, sponsorRef: r.sponsor_ref, code: r.code, address: r.address, created: Number(r.created) }) : null;
const D = {
async signup(e, password, ref) {
const code = newCode();
try {
await db.q('INSERT INTO accounts (email,pass,sponsor_ref,code,address,created) VALUES (?,?,?,?,NULL,?)',
[e, hashPassword(password), ref, code, Date.now()]);
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') return String(err.message).includes('code')
? this.signup(e, password, ref) // code collision: retry with a new code
: { error: 'That email already has an account. Log in instead.' };
throw err;
}
return { ok: true, created: true, account: await this.byEmail(e) };
},
async login(e, password) {
const rows = await db.q('SELECT * FROM accounts WHERE email=?', [e]);
if (!rows.length || !rows[0].pass || !checkPassword(password, rows[0].pass)) return { error: 'Wrong email or password.' };
return { ok: true, account: rowPub(rows[0]) };
},
async ensure(e, ref) {
const code = newCode();
let created = false;
try {
await db.q('INSERT INTO accounts (email,pass,sponsor_ref,code,address,created) VALUES (?,NULL,?,?,NULL,?)',
[e, ref, code, Date.now()]);
created = true;
} catch (err) {
if (err.code !== 'ER_DUP_ENTRY') throw err;
if (String(err.message).includes('code')) return this.ensure(e, ref);
}
return { ok: true, created, account: await this.byEmail(e) };
},
async byEmail(e) { const r = await db.q('SELECT * FROM accounts WHERE email=?', [e]); return rowPub(r[0]); },
async byAddress(a) { const r = await db.q('SELECT * FROM accounts WHERE address=?', [a]); return rowPub(r[0]); },
async byCode(c) { const r = await db.q('SELECT * FROM accounts WHERE code=?', [c]); return rowPub(r[0]); },
async linkWallet(e, a) {
const cur = await this.byEmail(e);
if (!cur) return { error: 'No such account.' };
if (cur.address && cur.address !== a) return { error: 'This account is already linked to wallet '
+ cur.address.slice(0, 6) + '…' + cur.address.slice(-4) + '. Connect that wallet instead.' };
try { await db.q('UPDATE accounts SET address=? WHERE email=?', [a, e]); }
catch (err) {
if (err.code === 'ER_DUP_ENTRY') return { error: 'That wallet is already linked to a different account.' };
throw err;
}
return { ok: true, account: await this.byEmail(e) };
},
async count() { const r = await db.q('SELECT COUNT(*) n FROM accounts'); return Number(r[0].n); }
};
const impl = () => db.enabled() ? D : J;
function init(opts) { DATA_DIR = opts.dataDir; J.load(); }
async function signup(email, password, sponsorRef) {
const e = normEmail(email);
if (!EMAIL_RE.test(e)) return { error: 'That email address does not look right.' };
if (String(password || '').length < 8) return { error: 'Password needs at least 8 characters.' };
if (db.byEmail[e]) return { error: 'That email already has an account. Log in instead.' };
const code = genCode();
db.byEmail[e] = {
email: e,
pass: hashPassword(password),
sponsorRef: String(sponsorRef || ''), // first touch; resolved to a chain id at buy time
code,
address: null,
created: Date.now()
};
db.byCode[code] = e;
db.joins += 1;
save();
return { ok: true, created: true, account: publicView(db.byEmail[e]) };
return impl().signup(e, String(password), String(sponsorRef || ''));
}
function login(email, password) {
const e = normEmail(email);
const acct = db.byEmail[e];
if (!acct || !checkPassword(password, acct.pass)) return { error: 'Wrong email or password.' };
acct.lastSeen = Date.now(); save();
return { ok: true, account: publicView(acct) };
}
// Passwordless path: a verified email code proves ownership, so the account
// may exist with no password at all.
function ensure(email, sponsorRef) {
async function login(email, password) { return impl().login(normEmail(email), String(password || '')); }
async function ensure(email, sponsorRef) {
const e = normEmail(email);
if (!EMAIL_RE.test(e)) return { error: 'That email address does not look right.' };
let created = false;
if (!db.byEmail[e]) {
const code = genCode();
db.byEmail[e] = { email: e, pass: null, sponsorRef: String(sponsorRef || ''), code, address: null, created: Date.now() };
db.byCode[code] = e;
db.joins += 1;
created = true;
save();
}
return { ok: true, created, account: publicView(db.byEmail[e]) };
return impl().ensure(e, String(sponsorRef || ''));
}
function byCode(code) {
const e = db.byCode[String(code || '').toLowerCase()];
return e ? publicView(db.byEmail[e]) : null;
}
function byEmail(email) { const a = db.byEmail[normEmail(email)]; return a ? publicView(a) : null; }
function byAddress(address) {
const e = db.byAddress[normAddr(address)];
return e ? publicView(db.byEmail[e]) : null;
}
// ---- wallet link (happens at purchase / payout activation time) ----
// First link wins and is permanent for the account; one wallet, one account.
function linkWallet(email, address) {
const e = normEmail(email);
async function byEmail(email) { return impl().byEmail(normEmail(email)); }
async function byAddress(address) { return impl().byAddress(normAddr(address)); }
async function byCode(code) { return impl().byCode(String(code || '').toLowerCase()); }
async function linkWallet(email, address) {
const a = normAddr(address);
const acct = db.byEmail[e];
if (!acct) return { error: 'No such account.' };
if (!/^0x[0-9a-f]{40}$/.test(a)) return { error: 'Bad wallet address.' };
if (acct.address && acct.address !== a) return { error: 'This account is already linked to wallet '
+ acct.address.slice(0, 6) + '…' + acct.address.slice(-4) + '. Earnings pay to that wallet. Connect it instead.' };
if (db.byAddress[a] && db.byAddress[a] !== e) return { error: 'That wallet is already linked to a different account.' };
acct.address = a;
db.byAddress[a] = e;
save();
return { ok: true, account: publicView(acct) };
return impl().linkWallet(normEmail(email), a);
}
function publicView(a) {
return { email: a.email, sponsorRef: a.sponsorRef || String(a.sponsorId || '') || '',
code: a.code || null, address: a.address || null, created: a.created };
}
function count() { return Object.keys(db.byEmail).length; }
async function count() { return impl().count(); }
module.exports = { init, signup, login, ensure, byEmail, byAddress, byCode, linkWallet, count };
+209 -125
View File
@@ -1,61 +1,41 @@
// Ad engine v1 (spec §8b): banners, text ads, login ads served against
// PURCHASED on-chain credits. The chain is the money truth: spend accrues
// here, and burns are queued for the engine signer to consume() on-chain
// (admin runs the burner; see /api/admin/burns). Earned-credit pool and the
// richer §8b types (inbox, surf, rotation, directory) come later.
// Ad engine v1 (spec §8b types 1-3). Dual-mode like accounts.js:
// MySQL (db.enabled) with guarded UPDATEs for the concurrent serving path,
// JSON volume file fallback for local dev.
// Spend accrues per campaign; burns queue for the engine signer to consume()
// on-chain (/api/admin/burns). All exported functions are async.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const db = require('./db');
let DATA_DIR = null;
let chain = null;
const FILE = () => path.join(DATA_DIR, 'campaigns.json');
const RATES_FILE = () => path.join(DATA_DIR, 'adrates.json');
let db = { v: 1, nextId: 1, campaigns: [], burnsPending: [] };
// REHEARSAL PLACEHOLDER RATES — admin-adjustable via /api/admin/rates.
// Integer math: impressions accrue per campaign; credits deduct per BATCH.
function rates() {
let saved = {};
try { saved = JSON.parse(fs.readFileSync(RATES_FILE(), 'utf8')); } catch (e) {}
return Object.assign({
bannerBatch: 10, bannerCreditsPerBatch: 2, // $2.00 CPM equivalent
textBatch: 10, textCreditsPerBatch: 1, // $1.00 CPM equivalent
loginCreditsPerDay: 100, // $1.00/day
burnBatchMin: 50 // queue a burn every 50cr of spend
bannerBatch: 10, bannerCreditsPerBatch: 2,
textBatch: 10, textCreditsPerBatch: 1,
loginCreditsPerDay: 100,
burnBatchMin: 50
}, saved);
}
function load() {
try { db = JSON.parse(fs.readFileSync(FILE(), 'utf8')); } catch (e) {}
if (!db || db.v !== 1) db = { v: 1, nextId: 1, campaigns: [], burnsPending: [] };
function setRates(patch) {
fs.writeFileSync(RATES_FILE(), JSON.stringify(Object.assign(rates(), patch), null, 2));
return rates();
}
function save() {
try {
const tmp = FILE() + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(db));
fs.renameSync(tmp, FILE());
} catch (e) { console.error('ads save failed', e.message); }
}
function init(opts) { DATA_DIR = opts.dataDir; chain = opts.chain; load(); }
const TYPES = ['banner', 'text', 'login'];
const URL_RE = /^https?:\/\/[^\s]+$/i;
// unburned spend per member = credits owed to the burn queue + campaign accruals
function unburnedSpend(memberId) {
let s = 0;
for (const b of db.burnsPending) if (b.memberId === memberId && !b.burnedTx) s += b.amount;
for (const c of db.campaigns) if (c.memberId === memberId) s += c.accrued || 0;
return s;
const bid = () => crypto.randomBytes(8).toString('hex');
function batchFor(type, r) {
return type === 'banner' ? { n: r.bannerBatch, cr: r.bannerCreditsPerBatch }
: type === 'text' ? { n: r.textBatch, cr: r.textCreditsPerBatch } : null;
}
async function availableCredits(memberId) {
const onchain = await chain.creditBalance(memberId, 0);
return Math.max(0, onchain - unburnedSpend(memberId));
}
async function createCampaign(owner, memberId, input) {
function validate(input) {
const type = String(input.type || '');
if (!TYPES.includes(type)) return { error: 'Unknown ad type.' };
const name = String(input.name || '').trim().slice(0, 60);
@@ -64,122 +44,226 @@ async function createCampaign(owner, memberId, input) {
if (!URL_RE.test(targetUrl)) return { error: 'Target URL must start with http(s)://' };
const budget = Math.floor(Number(input.budget) || 0);
if (budget < 10) return { error: 'Minimum budget is 10 credits.' };
const avail = await availableCredits(memberId);
if (budget > avail) return { error: 'Budget exceeds your available credits (' + avail + ').' };
const c = { id: db.nextId++, owner, memberId, type, name, targetUrl,
budget, spent: 0, accrued: 0, imps: 0, clicks: 0, batchImps: 0,
status: 'active', created: Date.now() };
if (type === 'banner') {
const imageUrl = String(input.imageUrl || '').trim();
if (!URL_RE.test(imageUrl)) return { error: 'Banner image URL must start with http(s)://' };
c.imageUrl = imageUrl;
const out = { type, name, targetUrl, budget, imageUrl: null, title: null, body: null };
if (type === 'banner' || type === 'login') {
out.imageUrl = String(input.imageUrl || '').trim();
if (!URL_RE.test(out.imageUrl)) return { error: (type === 'banner' ? 'Banner' : 'Login') + ' ads need an image URL starting with http(s)://' };
}
if (type === 'text') {
c.title = String(input.title || '').trim().slice(0, 60);
c.body = String(input.body || '').trim().slice(0, 140);
if (!c.title) return { error: 'Text ads need a headline.' };
out.title = String(input.title || '').trim().slice(0, 60);
out.body = String(input.body || '').trim().slice(0, 140);
if (!out.title) return { error: 'Text ads need a headline.' };
}
if (type === 'login') {
c.imageUrl = String(input.imageUrl || '').trim();
if (!URL_RE.test(c.imageUrl)) return { error: 'Login ads need an image URL.' };
}
db.campaigns.push(c);
save();
return { ok: true, campaign: pub(c) };
return { ok: true, c: out };
}
const pubC = c => ({ id: c.id, type: c.type, name: c.name, targetUrl: c.targetUrl, imageUrl: c.imageUrl || null,
title: c.title || null, body: c.body || null, budget: c.budget, spent: (c.spent || 0) + (c.accrued || 0),
imps: c.imps || 0, clicks: c.clicks || 0, status: c.status, created: c.created });
const served = c => ({ id: c.id, type: c.type, targetUrl: '/api/ads/click/' + c.id,
imageUrl: c.imageUrl || null, title: c.title || null, body: c.body || null });
function listCampaigns(owner) {
return db.campaigns.filter(c => c.owner === owner).map(pub);
}
function setStatus(owner, id, status) {
const c = db.campaigns.find(x => x.id === Number(id) && x.owner === owner);
// ---- JSON fallback ----
const J = {
db: { v: 1, nextId: 1, campaigns: [], burnsPending: [] },
FILE: () => path.join(DATA_DIR, 'campaigns.json'),
load() {
try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) {}
if (!this.db || this.db.v !== 1) this.db = { v: 1, nextId: 1, campaigns: [], burnsPending: [] };
},
save() {
try {
const tmp = this.FILE() + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(this.db));
fs.renameSync(tmp, this.FILE());
} catch (e) { console.error('ads save failed', e.message); }
},
async unburned(memberId) {
let s = 0;
for (const b of this.db.burnsPending) if (b.memberId === memberId && !b.burnedTx) s += b.amount;
for (const c of this.db.campaigns) if (c.memberId === memberId) s += c.accrued || 0;
return s;
},
async create(owner, memberId, c) {
const row = Object.assign({ id: this.db.nextId++, owner, memberId, spent: 0, accrued: 0,
imps: 0, clicks: 0, batchImps: 0, status: 'active', created: Date.now() }, c);
this.db.campaigns.push(row);
this.save();
return pubC(row);
},
async list(owner) { return this.db.campaigns.filter(c => c.owner === owner).map(pubC); },
async setStatus(owner, id, status) {
const c = this.db.campaigns.find(x => x.id === Number(id) && x.owner === owner);
if (!c) return { error: 'No such campaign.' };
if (!['active', 'paused'].includes(status)) return { error: 'Bad status.' };
if (c.status === 'out' && status === 'active' && c.spent + c.accrued >= c.budget) return { error: 'Budget exhausted. Raise it first.' };
c.status = status;
save();
return { ok: true, campaign: pub(c) };
}
function pub(c) {
return { id: c.id, type: c.type, name: c.name, targetUrl: c.targetUrl, imageUrl: c.imageUrl || null,
title: c.title || null, body: c.body || null, budget: c.budget, spent: c.spent + (c.accrued || 0),
imps: c.imps, clicks: c.clicks, status: c.status, created: c.created };
}
// ---- serving ----
function chargeBatch(c, batch, credits) {
c.batchImps += 1;
if (c.batchImps >= batch) {
c.batchImps = 0;
c.accrued = (c.accrued || 0) + credits;
// roll accruals into the burn queue in chunks
this.save();
return { ok: true, campaign: pubC(c) };
},
async serve(type) {
const r = rates();
if (c.accrued >= r.burnBatchMin) {
db.burnsPending.push({ id: crypto.randomBytes(8).toString('hex'), memberId: c.memberId,
amount: c.accrued, ref: 'campaign-' + c.id, ts: Date.now() });
c.spent += c.accrued;
c.accrued = 0;
}
if (c.spent + c.accrued >= c.budget) c.status = 'out';
}
}
function serve(type) {
const r = rates();
const pool = db.campaigns.filter(c => c.type === type && c.status === 'active');
const pool = this.db.campaigns.filter(c => c.type === type && c.status === 'active');
if (!pool.length) return null;
const c = pool[Math.floor(Math.random() * pool.length)];
c.imps += 1;
if (type === 'banner') chargeBatch(c, r.bannerBatch, r.bannerCreditsPerBatch);
if (type === 'text') chargeBatch(c, r.textBatch, r.textCreditsPerBatch);
// login ads are per-day; impressions tracked, charged by the daily sweep
save();
return { id: c.id, type: c.type, targetUrl: '/api/ads/click/' + c.id,
imageUrl: c.imageUrl || null, title: c.title || null, body: c.body || null };
}
function click(id) {
const c = db.campaigns.find(x => x.id === Number(id));
const b = batchFor(type, r);
if (b) {
c.batchImps += 1;
if (c.batchImps >= b.n) {
c.batchImps = 0;
c.accrued = (c.accrued || 0) + b.cr;
if (c.accrued >= r.burnBatchMin) {
this.db.burnsPending.push({ id: bid(), memberId: c.memberId, amount: c.accrued, ref: 'campaign-' + c.id, ts: Date.now() });
c.spent += c.accrued; c.accrued = 0;
}
if (c.spent + c.accrued >= c.budget) c.status = 'out';
}
}
this.save();
return served(c);
},
async click(id) {
const c = this.db.campaigns.find(x => x.id === Number(id));
if (!c) return null;
c.clicks += 1;
save();
this.save();
return c.targetUrl;
}
// daily charge for login ads (called by a boot + interval sweep)
function dailySweep() {
},
async dailySweep() {
const r = rates();
const today = new Date().toISOString().slice(0, 10);
let charged = 0;
for (const c of db.campaigns) {
let n = 0;
for (const c of this.db.campaigns) {
if (c.type !== 'login' || c.status !== 'active' || c.lastDayCharged === today) continue;
c.lastDayCharged = today;
c.accrued = (c.accrued || 0) + r.loginCreditsPerDay;
if (c.accrued >= r.burnBatchMin) {
db.burnsPending.push({ id: crypto.randomBytes(8).toString('hex'), memberId: c.memberId,
amount: c.accrued, ref: 'campaign-' + c.id, ts: Date.now() });
this.db.burnsPending.push({ id: bid(), memberId: c.memberId, amount: c.accrued, ref: 'campaign-' + c.id, ts: Date.now() });
c.spent += c.accrued; c.accrued = 0;
}
if (c.spent + c.accrued >= c.budget) c.status = 'out';
charged += 1;
n += 1;
}
if (charged) save();
return charged;
}
// ---- burn queue (admin/engine) ----
function pendingBurns() { return db.burnsPending.filter(b => !b.burnedTx); }
function markBurned(id, tx) {
const b = db.burnsPending.find(x => x.id === id);
if (n) this.save();
return n;
},
async pendingBurns() { return this.db.burnsPending.filter(b => !b.burnedTx); },
async markBurned(id, tx) {
const b = this.db.burnsPending.find(x => x.id === id);
if (!b) return { error: 'No such burn.' };
b.burnedTx = tx; b.burnedAt = Date.now();
save();
this.save();
return { ok: true };
}
};
// ---- MySQL mode: guarded UPDATEs keep the serving path concurrency-safe ----
const rowC = r => ({ id: r.id, owner: r.owner_email, memberId: r.member_id, type: r.type, name: r.name,
targetUrl: r.target_url, imageUrl: r.image_url, title: r.title, body: r.body, budget: r.budget,
spent: r.spent, accrued: r.accrued, imps: r.imps, clicks: r.clicks, batchImps: r.batch_imps,
status: r.status, created: Number(r.created) });
const D = {
async unburned(memberId) {
const a = await db.q('SELECT COALESCE(SUM(amount),0) s FROM burns WHERE member_id=? AND burned_tx IS NULL', [memberId]);
const b = await db.q('SELECT COALESCE(SUM(accrued),0) s FROM campaigns WHERE member_id=?', [memberId]);
return Number(a[0].s) + Number(b[0].s);
},
async create(owner, memberId, c) {
const r = await db.q(`INSERT INTO campaigns (owner_email,member_id,type,name,target_url,image_url,title,body,budget,created)
VALUES (?,?,?,?,?,?,?,?,?,?)`,
[owner, memberId, c.type, c.name, c.targetUrl, c.imageUrl, c.title, c.body, c.budget, Date.now()]);
const rows = await db.q('SELECT * FROM campaigns WHERE id=?', [r.insertId]);
return pubC(rowC(rows[0]));
},
async list(owner) {
const rows = await db.q('SELECT * FROM campaigns WHERE owner_email=? ORDER BY id DESC', [owner]);
return rows.map(r => pubC(rowC(r)));
},
async setStatus(owner, id, status) {
const r = await db.q('UPDATE campaigns SET status=? WHERE id=? AND owner_email=?', [status, Number(id), owner]);
if (!r.affectedRows) return { error: 'No such campaign.' };
const rows = await db.q('SELECT * FROM campaigns WHERE id=?', [Number(id)]);
return { ok: true, campaign: pubC(rowC(rows[0])) };
},
async serve(type) {
const r = rates();
const rows = await db.q('SELECT * FROM campaigns WHERE type=? AND status=\'active\' ORDER BY RAND() LIMIT 1', [type]);
if (!rows.length) return null;
const c = rowC(rows[0]);
await db.q('UPDATE campaigns SET imps=imps+1, batch_imps=batch_imps+1 WHERE id=?', [c.id]);
const b = batchFor(type, r);
if (b) {
// atomic batch rollover: only one worker wins the WHERE guard
await db.q('UPDATE campaigns SET batch_imps=batch_imps-?, accrued=accrued+? WHERE id=? AND batch_imps>=?',
[b.n, b.cr, c.id, b.n]);
await this.rollBurn(c.id, r);
}
return served(c);
},
async rollBurn(id, r) {
const rows = await db.q('SELECT * FROM campaigns WHERE id=?', [id]);
if (!rows.length) return;
const c = rowC(rows[0]);
if (c.accrued >= r.burnBatchMin) {
const upd = await db.q('UPDATE campaigns SET spent=spent+?, accrued=accrued-? WHERE id=? AND accrued>=?',
[c.accrued, c.accrued, id, c.accrued]);
if (upd.affectedRows) {
await db.q('INSERT INTO burns (id,member_id,amount,ref,ts) VALUES (?,?,?,?,?)',
[bid(), c.memberId, c.accrued, 'campaign-' + id, Date.now()]);
}
}
await db.q('UPDATE campaigns SET status=\'out\' WHERE id=? AND status=\'active\' AND spent+accrued>=budget', [id]);
},
async click(id) {
const rows = await db.q('SELECT target_url FROM campaigns WHERE id=?', [Number(id)]);
if (!rows.length) return null;
await db.q('UPDATE campaigns SET clicks=clicks+1 WHERE id=?', [Number(id)]);
return rows[0].target_url;
},
async dailySweep() {
const r = rates();
const today = new Date().toISOString().slice(0, 10);
const upd = await db.q(`UPDATE campaigns SET last_day_charged=?, accrued=accrued+?
WHERE type='login' AND status='active' AND (last_day_charged IS NULL OR last_day_charged<>?)`,
[today, r.loginCreditsPerDay, today]);
if (upd.affectedRows) {
const rows = await db.q('SELECT id FROM campaigns WHERE type=\'login\' AND last_day_charged=?', [today]);
for (const row of rows) await this.rollBurn(row.id, r);
}
return upd.affectedRows || 0;
},
async pendingBurns() {
const rows = await db.q('SELECT * FROM burns WHERE burned_tx IS NULL ORDER BY ts');
return rows.map(b => ({ id: b.id, memberId: b.member_id, amount: b.amount, ref: b.ref, ts: Number(b.ts) }));
},
async markBurned(id, tx) {
const r = await db.q('UPDATE burns SET burned_tx=?, burned_at=? WHERE id=? AND burned_tx IS NULL', [String(tx), Date.now(), String(id)]);
return r.affectedRows ? { ok: true } : { error: 'No such burn.' };
}
};
const impl = () => db.enabled() ? D : J;
function init(opts) { DATA_DIR = opts.dataDir; chain = opts.chain; J.load(); }
async function availableCredits(memberId) {
const onchain = await chain.creditBalance(memberId, 0);
return Math.max(0, onchain - await impl().unburned(memberId));
}
function setRates(patch) {
const cur = rates();
fs.writeFileSync(RATES_FILE(), JSON.stringify(Object.assign(cur, patch), null, 2));
return rates();
async function createCampaign(owner, memberId, input) {
const v = validate(input);
if (v.error) return v;
const avail = await availableCredits(memberId);
if (v.c.budget > avail) return { error: 'Budget exceeds your available credits (' + avail + ').' };
return { ok: true, campaign: await impl().create(owner, memberId, v.c) };
}
async function listCampaigns(owner) { return impl().list(owner); }
async function setStatus(owner, id, status) {
if (!['active', 'paused'].includes(status)) return { error: 'Bad status.' };
return impl().setStatus(owner, id, status);
}
async function serve(type) { return TYPES.includes(type) ? impl().serve(type) : null; }
async function click(id) { return impl().click(id); }
async function dailySweep() { return impl().dailySweep(); }
async function pendingBurns() { return impl().pendingBurns(); }
async function markBurned(id, tx) { return impl().markBurned(id, tx); }
module.exports = { init, rates, setRates, createCampaign, listCampaigns, setStatus,
serve, click, dailySweep, availableCredits, pendingBurns, markBurned };
+78 -47
View File
@@ -1,15 +1,12 @@
// Wallet sign-in (SIWE / EIP-4361) for InstantAdPay.
// Pattern lifted from the RM Circle messages.js implementation (proven with
// MetaMask's friendly sign-in UI). One free signature, cannot move funds.
//
// Difference from RM Circle: a wallet WITHOUT an on-chain member id still gets
// a session — free members exist site-side only until their payout activation
// or first purchase writes them on-chain (spec §4).
// Wallet sign-in (SIWE / EIP-4361) + session store for InstantAdPay.
// Sessions are dual-mode like accounts.js: MySQL when DATABASE_URL is set,
// volume JSON otherwise. All session functions are async.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { keccak256 } = require('./vendor/sha3');
const secp = require('./vendor/secp256k1');
const db = require('./db');
let DATA_DIR = null;
let chain = null;
@@ -19,27 +16,76 @@ let SITE = 'instantadpay.com';
const CHALLENGE_TTL = 10 * 60 * 1000;
const SESSION_TTL = 30 * 24 * 60 * 60 * 1000; // 30 days
const challenges = new Map(); // addressLower -> {message, exp}
let sessions = new Map(); // token -> {address, memberId, expires}
const SESS_FILE = () => path.join(DATA_DIR, 'sessions.json');
function loadSessions() {
// ---- JSON session fallback ----
const J = {
sessions: new Map(),
FILE: () => path.join(DATA_DIR, 'sessions.json'),
load() {
try {
const o = JSON.parse(fs.readFileSync(SESS_FILE(), 'utf8'));
sessions = new Map(Object.entries(o).filter(([, s]) => s.expires > Date.now()));
} catch (e) { sessions = new Map(); }
}
function saveSessions() {
const o = JSON.parse(fs.readFileSync(this.FILE(), 'utf8'));
this.sessions = new Map(Object.entries(o).filter(([, s]) => s.expires > Date.now()));
} catch (e) { this.sessions = new Map(); }
},
save() {
try {
const tmp = SESS_FILE() + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(Object.fromEntries(sessions)), { mode: 0o600 });
fs.renameSync(tmp, SESS_FILE());
const tmp = this.FILE() + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(Object.fromEntries(this.sessions)), { mode: 0o600 });
fs.renameSync(tmp, this.FILE());
} catch (e) { console.error('session save failed', e.message); }
}
},
async mint(fields) {
const token = crypto.randomBytes(32).toString('hex');
this.sessions.set(token, Object.assign({ email: null, address: null, memberId: 0 }, fields,
{ expires: Date.now() + SESSION_TTL }));
this.save();
return token;
},
async get(token) {
const s = this.sessions.get(token);
if (!s || s.expires < Date.now()) return null;
return Object.assign({ token }, s);
},
async update(token, fields) {
const s = this.sessions.get(token);
if (!s) return;
this.sessions.set(token, Object.assign({}, s, fields));
this.save();
},
async drop(token) { this.sessions.delete(token); this.save(); }
};
// ---- MySQL session mode ----
const D = {
async mint(fields) {
const token = crypto.randomBytes(32).toString('hex');
await db.q('INSERT INTO sessions (token,email,address,member_id,expires) VALUES (?,?,?,?,?)',
[token, fields.email || null, fields.address || null, fields.memberId || 0, Date.now() + SESSION_TTL]);
return token;
},
async get(token) {
const rows = await db.q('SELECT * FROM sessions WHERE token=? AND expires>?', [token, Date.now()]);
if (!rows.length) return null;
const r = rows[0];
return { token, email: r.email, address: r.address, memberId: r.member_id, expires: Number(r.expires) };
},
async update(token, fields) {
const sets = [], vals = [];
if ('email' in fields) { sets.push('email=?'); vals.push(fields.email); }
if ('address' in fields) { sets.push('address=?'); vals.push(fields.address); }
if ('memberId' in fields) { sets.push('member_id=?'); vals.push(fields.memberId); }
if (!sets.length) return;
vals.push(token);
await db.q('UPDATE sessions SET ' + sets.join(',') + ' WHERE token=?', vals);
},
async drop(token) { await db.q('DELETE FROM sessions WHERE token=?', [token]); }
};
const impl = () => db.enabled() ? D : J;
function init(opts) {
DATA_DIR = opts.dataDir; chain = opts.chain; IS_PROD = !!opts.isProd;
if (opts.site) SITE = opts.site;
loadSessions();
J.load();
}
// ---- crypto ----
@@ -65,7 +111,7 @@ function checksumAddress(address) {
return out;
}
// ---- auth flow ----
// ---- SIWE flow ----
function makeChallenge(address) {
if (!ADDR_RE.test(address || '')) return { error: 'Bad address' };
const a = address.toLowerCase();
@@ -86,45 +132,30 @@ async function verifyChallenge(address, signature) {
challenges.delete(a);
return { ok: true, address: a };
}
// Sessions carry {email, address, memberId} — email accounts are the normal
// join path; the wallet fields fill in when one is linked at purchase time.
function mintSession(fields) {
const token = crypto.randomBytes(32).toString('hex');
sessions.set(token, Object.assign({ email: null, address: null, memberId: 0 }, fields,
{ expires: Date.now() + SESSION_TTL }));
saveSessions();
return token;
}
function updateSession(token, fields) {
const s = sessions.get(token);
if (!s) return;
sessions.set(token, Object.assign({}, s, fields));
saveSessions();
}
// ---- sessions ----
async function mintSession(fields) { return impl().mint(fields || {}); }
async function updateSession(token, fields) { return impl().update(token, fields || {}); }
function sessionCookie(token) {
return `iap.sid=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL / 1000}${IS_PROD ? '; Secure' : ''}`;
}
function clearCookie() { return 'iap.sid=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'; }
function fromRequest(req) {
async function fromRequest(req) {
const m = /(?:^|;\s*)iap\.sid=([^;]+)/.exec(req.headers.cookie || '');
if (!m) return null;
const token = decodeURIComponent(m[1]);
const s = sessions.get(token);
if (!s || s.expires < Date.now()) return null;
return Object.assign({ token }, s);
return impl().get(decodeURIComponent(m[1]));
}
async function refreshMemberId(sess) {
// called after an on-chain action so the session learns its new member id
if (!sess.address) return sess.memberId || 0;
if (!sess || !sess.address) return (sess && sess.memberId) || 0;
try {
const id = await chain.memberIdByAccount(sess.address);
if (id && id !== sess.memberId) updateSession(sess.token, { memberId: id });
if (id && id !== sess.memberId) await updateSession(sess.token, { memberId: id });
return id || sess.memberId || 0;
} catch (e) { return sess.memberId || 0; }
}
function logout(req) {
const s = fromRequest(req);
if (s) { sessions.delete(s.token); saveSessions(); }
async function logout(req) {
const s = await fromRequest(req);
if (s) await impl().drop(s.token);
}
module.exports = { init, makeChallenge, verifyChallenge, mintSession, updateSession, sessionCookie, clearCookie, fromRequest, refreshMemberId, logout };
+118
View File
@@ -0,0 +1,118 @@
// MySQL data layer (Marty: real concurrency over flat files).
// DATABASE_URL present -> mysql2 pool, schema bootstrap, one-time import of
// any existing volume JSON (accounts/sessions/
// campaigns) so nothing is lost on the switch.
// DATABASE_URL absent -> db.enabled() is false and the modules fall back to
// their JSON stores (local dev keeps working).
// The chain index stays a file: it is a rebuildable cache of the blockchain.
const fs = require('fs');
const path = require('path');
let pool = null;
let DATA_DIR = null;
function enabled() { return !!process.env.DATABASE_URL; }
async function init(opts) {
DATA_DIR = opts.dataDir;
if (!enabled()) return false;
const mysql = require('mysql2/promise');
pool = mysql.createPool(process.env.DATABASE_URL + '?connectionLimit=10&charset=utf8mb4');
await bootstrap();
await importJsonOnce();
console.log('db: MySQL connected, schema ready');
return true;
}
const q = async (sql, params) => (await pool.query(sql, params))[0];
async function bootstrap() {
await q(`CREATE TABLE IF NOT EXISTS accounts (
email VARCHAR(190) PRIMARY KEY,
pass VARCHAR(200) NULL,
sponsor_ref VARCHAR(32) NOT NULL DEFAULT '',
code VARCHAR(16) NOT NULL UNIQUE,
address VARCHAR(64) NULL UNIQUE,
created BIGINT NOT NULL,
last_seen BIGINT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await q(`CREATE TABLE IF NOT EXISTS sessions (
token VARCHAR(80) PRIMARY KEY,
email VARCHAR(190) NULL,
address VARCHAR(64) NULL,
member_id INT NOT NULL DEFAULT 0,
expires BIGINT NOT NULL,
INDEX (expires)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await q(`CREATE TABLE IF NOT EXISTS campaigns (
id INT AUTO_INCREMENT PRIMARY KEY,
owner_email VARCHAR(190) NOT NULL,
member_id INT NOT NULL,
type VARCHAR(12) NOT NULL,
name VARCHAR(80) NOT NULL,
target_url VARCHAR(500) NOT NULL,
image_url VARCHAR(500) NULL,
title VARCHAR(80) NULL,
body VARCHAR(200) NULL,
budget INT NOT NULL,
spent INT NOT NULL DEFAULT 0,
accrued INT NOT NULL DEFAULT 0,
imps INT NOT NULL DEFAULT 0,
clicks INT NOT NULL DEFAULT 0,
batch_imps INT NOT NULL DEFAULT 0,
status VARCHAR(12) NOT NULL DEFAULT 'active',
last_day_charged VARCHAR(10) NULL,
created BIGINT NOT NULL,
INDEX (owner_email), INDEX (type, status), INDEX (member_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await q(`CREATE TABLE IF NOT EXISTS burns (
id VARCHAR(32) PRIMARY KEY,
member_id INT NOT NULL,
amount INT NOT NULL,
ref VARCHAR(64) NOT NULL,
ts BIGINT NOT NULL,
burned_tx VARCHAR(80) NULL,
burned_at BIGINT NULL,
INDEX (burned_tx)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
}
// one-time import: only when the tables are empty and JSON files exist
async function importJsonOnce() {
const [{ n }] = await q('SELECT COUNT(*) n FROM accounts');
if (n > 0) return;
try {
const a = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'accounts.json'), 'utf8'));
for (const acc of Object.values(a.byEmail || {})) {
await q('INSERT IGNORE INTO accounts (email,pass,sponsor_ref,code,address,created) VALUES (?,?,?,?,?,?)',
[acc.email, acc.pass || null, String(acc.sponsorRef || acc.sponsorId || ''), acc.code, acc.address || null, acc.created || Date.now()]);
}
console.log('db: imported', Object.keys(a.byEmail || {}).length, 'account(s) from JSON');
} catch (e) {}
try {
const c = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'campaigns.json'), 'utf8'));
for (const cp of c.campaigns || []) {
await q(`INSERT IGNORE INTO campaigns (owner_email,member_id,type,name,target_url,image_url,title,body,
budget,spent,accrued,imps,clicks,batch_imps,status,last_day_charged,created)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[cp.owner, cp.memberId, cp.type, cp.name, cp.targetUrl, cp.imageUrl || null, cp.title || null, cp.body || null,
cp.budget, cp.spent || 0, cp.accrued || 0, cp.imps || 0, cp.clicks || 0, cp.batchImps || 0,
cp.status, cp.lastDayCharged || null, cp.created || Date.now()]);
}
for (const b of c.burnsPending || []) {
await q('INSERT IGNORE INTO burns (id,member_id,amount,ref,ts,burned_tx,burned_at) VALUES (?,?,?,?,?,?,?)',
[b.id, b.memberId, b.amount, b.ref, b.ts, b.burnedTx || null, b.burnedAt || null]);
}
console.log('db: imported', (c.campaigns || []).length, 'campaign(s) from JSON');
} catch (e) {}
try {
const s = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'sessions.json'), 'utf8'));
for (const [tok, sess] of Object.entries(s)) {
if (sess.expires > Date.now()) {
await q('INSERT IGNORE INTO sessions (token,email,address,member_id,expires) VALUES (?,?,?,?,?)',
[tok, sess.email || null, sess.address || null, sess.memberId || 0, sess.expires]);
}
}
} catch (e) {}
}
module.exports = { init, enabled, q: (...a) => q(...a) };
+2 -1
View File
@@ -5,5 +5,6 @@
"description": "InstantAdPay membership advertising site - immutable on-chain settlement, transparent ledger.",
"main": "server.js",
"scripts": {"start": "node server.js", "dev": "node --watch server.js"},
"engines": {"node": ">=20"}
"engines": {"node": ">=20"},
"dependencies": {"mysql2": "^3.11.0"}
}
+45 -39
View File
@@ -23,13 +23,8 @@ 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');
const db = require('./db');
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 });
mailer.init({ dataDir: DATA_DIR });
chatbot.init({ dataDir: DATA_DIR, chain });
const chatHits = new Map();
function chatLimited(ip) {
const now = Date.now(), rec = chatHits.get(ip);
@@ -39,8 +34,17 @@ function chatLimited(ip) {
}
// magic-code sign-in: emailLower -> {code, exp, tries}
const emailCodes = new Map();
setTimeout(() => ads.dailySweep(), 60 * 1000);
setInterval(() => ads.dailySweep(), 60 * 60 * 1000); // login-ad daily charges
async function boot() {
await db.init({ dataDir: DATA_DIR }); // no-op without DATABASE_URL (JSON mode)
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 });
mailer.init({ dataDir: DATA_DIR });
chatbot.init({ dataDir: DATA_DIR, chain });
setTimeout(() => ads.dailySweep().catch(e => console.error('sweep', e.message)), 60 * 1000);
setInterval(() => ads.dailySweep().catch(e => console.error('sweep', e.message)), 60 * 60 * 1000);
}
function siteConfig() {
let saved = {};
@@ -101,16 +105,16 @@ async function resolveSponsorToken(tok) {
const t = String(tok || '').trim().toLowerCase();
if (!t) return 0;
if (/^\d+$/.test(t)) return Number(t);
const acct = accounts.byCode(t);
const acct = await accounts.byCode(t);
if (!acct || !acct.address) return 0;
try { return await chain.memberIdByAccount(acct.address); } catch (e) { return 0; }
}
// The moment someone joins through a code, nudge its owner to activate.
function nudgeReferrer(ref) {
async function nudgeReferrer(ref) {
try {
const t = String(ref || '').trim().toLowerCase();
if (!t || /^\d+$/.test(t) || !mailer.hasKey()) return;
const owner = accounts.byCode(t);
const owner = await accounts.byCode(t);
if (!owner || !owner.email || owner.address) return; // already activated-ready
mailer.send(owner.email, 'Someone just joined through your InstantAdPay link',
'Good news: a new member just signed up through your share link.\n\n'
@@ -176,7 +180,7 @@ const server = http.createServer(async (req, res) => {
}
if (p === '/api/stats' && req.method === 'GET') {
let members = 0; try { members = await chain.memberCount(); } catch (e) {}
return json(res, 200, Object.assign({ onchainMembers: members, siteAccounts: accounts.count() }, chain.totals()));
return json(res, 200, Object.assign({ onchainMembers: members, siteAccounts: await accounts.count() }, chain.totals()));
}
// -- 24/7 assistant
@@ -193,19 +197,19 @@ const server = http.createServer(async (req, res) => {
if (p === '/api/signup' && req.method === 'POST') {
const b = await readBody(req);
const ref = parseCookies(req)['iap.sponsor'] || ''; // first-touch attribution
const r = accounts.signup(b.email, b.password, ref);
const r = await accounts.signup(b.email, b.password, ref);
if (r.error) return json(res, 400, r);
nudgeReferrer(ref);
const token = auth.mintSession({ email: r.account.email });
nudgeReferrer(ref).catch(() => {});
const token = await auth.mintSession({ email: r.account.email });
return json(res, 200, { ok: true, account: r.account }, { 'Set-Cookie': auth.sessionCookie(token) });
}
if (p === '/api/login' && req.method === 'POST') {
const b = await readBody(req);
const r = accounts.login(b.email, b.password);
const r = await accounts.login(b.email, b.password);
if (r.error) return json(res, 400, r);
let memberId = 0;
if (r.account.address) { try { memberId = await chain.memberIdByAccount(r.account.address); } catch (e) {} }
const token = auth.mintSession({ email: r.account.email, address: r.account.address, memberId });
const token = await auth.mintSession({ email: r.account.email, address: r.account.address, memberId });
return json(res, 200, { ok: true, account: r.account }, { 'Set-Cookie': auth.sessionCookie(token) });
}
@@ -238,12 +242,12 @@ const server = http.createServer(async (req, res) => {
if (String(b.code || '').trim() !== rec.code) return json(res, 400, { error: 'That code does not match.' });
emailCodes.delete(e);
const ref = parseCookies(req)['iap.sponsor'] || '';
const r = accounts.ensure(e, ref); // first touch wins; existing accounts unchanged
const r = await accounts.ensure(e, ref); // first touch wins; existing accounts unchanged
if (r.error) return json(res, 400, r);
if (r.created) nudgeReferrer(ref);
if (r.created) nudgeReferrer(ref).catch(() => {});
let memberId = 0;
if (r.account.address) { try { memberId = await chain.memberIdByAccount(r.account.address); } catch (err) {} }
const token = auth.mintSession({ email: r.account.email, address: r.account.address, memberId });
const token = await auth.mintSession({ email: r.account.email, address: r.account.address, memberId });
return json(res, 200, { ok: true, account: r.account }, { 'Set-Cookie': auth.sessionCookie(token) });
}
@@ -260,27 +264,27 @@ const server = http.createServer(async (req, res) => {
if (r.error) return json(res, 400, r);
let memberId = 0;
try { memberId = await chain.memberIdByAccount(r.address); } catch (e) {}
const s = auth.fromRequest(req);
const s = await auth.fromRequest(req);
if (s && s.email) {
const lr = accounts.linkWallet(s.email, r.address);
const lr = await accounts.linkWallet(s.email, r.address);
if (lr.error) return json(res, 400, lr);
auth.updateSession(s.token, { address: r.address, memberId });
await auth.updateSession(s.token, { address: r.address, memberId });
return json(res, 200, { ok: true, linked: true, address: r.address, memberId });
}
const acct = accounts.byAddress(r.address);
const token = auth.mintSession({ email: acct ? acct.email : null, address: r.address, memberId });
const acct = await accounts.byAddress(r.address);
const token = await auth.mintSession({ email: acct ? acct.email : null, address: r.address, memberId });
return json(res, 200, { ok: true, address: r.address, memberId },
{ 'Set-Cookie': auth.sessionCookie(token) });
}
if (p === '/api/auth/logout' && req.method === 'POST') {
auth.logout(req);
await 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);
const s = await auth.fromRequest(req);
if (!s) return json(res, 200, { signedIn: false });
const memberId = await auth.refreshMemberId(s);
const acct = (s.email && accounts.byEmail(s.email)) || (s.address && accounts.byAddress(s.address)) || null;
const acct = (s.email && await accounts.byEmail(s.email)) || (s.address && await accounts.byAddress(s.address)) || null;
const sponsorId = await resolveSponsorToken((acct && acct.sponsorRef) || parseCookies(req)['iap.sponsor']);
const out = { signedIn: true, email: s.email || (acct && acct.email) || null,
address: s.address || (acct && acct.address) || null, memberId,
@@ -297,7 +301,7 @@ const server = http.createServer(async (req, res) => {
}
if (p === '/api/my/activity' && req.method === 'GET') {
const s = auth.fromRequest(req);
const s = await auth.fromRequest(req);
if (!s) return json(res, 401, { error: 'Sign in first.' });
const id = s.memberId || await auth.refreshMemberId(s);
if (!id) return json(res, 200, { memberId: 0, earnings: [], purchases: [], referrals: [] });
@@ -312,26 +316,26 @@ 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'));
const ad = await 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]);
const target = await 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);
const s = await 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() };
const out = { campaigns: await 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);
const s = await 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.' });
@@ -341,21 +345,21 @@ const server = http.createServer(async (req, res) => {
}
m = /^\/api\/my\/campaigns\/(\d+)\/(pause|resume)$/.exec(p);
if (m && req.method === 'POST') {
const s = auth.fromRequest(req);
const s = await 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');
const r = await 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() });
return json(res, 200, { pending: await 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);
const r = await ads.markBurned(b.id, b.tx);
return json(res, r.error ? 400 : 200, r);
}
if (p === '/api/admin/rates' && req.method === 'PATCH') {
@@ -399,4 +403,6 @@ const server = http.createServer(async (req, res) => {
try { json(res, 500, { error: 'server error' }); } catch (_) {}
}
});
server.listen(PORT, () => console.log(`InstantAdPay site on :${PORT} — chain: ${chain.getConfig().chainName}`));
boot().then(() => {
server.listen(PORT, () => console.log(`InstantAdPay site on :${PORT} — chain: ${chain.getConfig().chainName} — store: ${db.enabled() ? 'MySQL' : 'volume JSON'}`));
}).catch(e => { console.error('boot failed:', e.message); process.exit(1); });