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 FROM node:22-alpine
WORKDIR /app WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev --no-audit --no-fund
COPY . . COPY . .
RUN mkdir -p /app/data RUN mkdir -p /app/data
ENV NODE_ENV=production ENV NODE_ENV=production
+142 -101
View File
@@ -1,44 +1,16 @@
// Site-side member accounts for InstantAdPay. // Site-side member accounts. Dual-mode:
// The chain is the source of truth for money, credits, and qualification; // MySQL (db.enabled) for real concurrency in production,
// this module holds what the chain doesn't: free members (email + password, // JSON volume file as the no-DATABASE_URL fallback (local dev).
// the way normal people join), sponsor attribution before first purchase // All exported functions are async; both modes return identical shapes.
// (spec §4), and the wallet link once one is connected at purchase time. // The chain remains the source of truth for money/credits/qualification.
// Wiping this file = the clean reset between rehearsal and mainnet.
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
const db = require('./db');
let DATA_DIR = null; let DATA_DIR = null;
const FILE = () => path.join(DATA_DIR, 'accounts.json');
let db = { v: 2, byEmail: {}, byAddress: {}, joins: 0 };
function load() { // ---- shared helpers ----
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) ----
function hashPassword(password) { function hashPassword(password) {
const salt = crypto.randomBytes(16); const salt = crypto.randomBytes(16);
const hash = crypto.scryptSync(String(password), salt, 32); const hash = crypto.scryptSync(String(password), salt, 32);
@@ -51,85 +23,154 @@ function checkPassword(password, stored) {
return crypto.timingSafeEqual(hash, Buffer.from(hashHex, 'hex')); return crypto.timingSafeEqual(hash, Buffer.from(hashHex, 'hex'));
} catch (e) { return false; } } catch (e) { return false; }
} }
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
const normEmail = e => String(e || '').trim().toLowerCase(); const normEmail = e => String(e || '').trim().toLowerCase();
const normAddr = a => String(a || '').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) ---- // ---- JSON fallback ----
function signup(email, password, sponsorRef) { 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); const e = normEmail(email);
if (!EMAIL_RE.test(e)) return { error: 'That email address does not look right.' }; 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 (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.' }; return impl().signup(e, String(password), String(sponsorRef || ''));
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]) };
} }
function login(email, password) { async function login(email, password) { return impl().login(normEmail(email), String(password || '')); }
const e = normEmail(email); async function ensure(email, sponsorRef) {
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) {
const e = normEmail(email); const e = normEmail(email);
if (!EMAIL_RE.test(e)) return { error: 'That email address does not look right.' }; if (!EMAIL_RE.test(e)) return { error: 'That email address does not look right.' };
let created = false; return impl().ensure(e, String(sponsorRef || ''));
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]) };
} }
function byCode(code) { async function byEmail(email) { return impl().byEmail(normEmail(email)); }
const e = db.byCode[String(code || '').toLowerCase()]; async function byAddress(address) { return impl().byAddress(normAddr(address)); }
return e ? publicView(db.byEmail[e]) : null; async function byCode(code) { return impl().byCode(String(code || '').toLowerCase()); }
} async function linkWallet(email, address) {
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);
const a = normAddr(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 (!/^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 ' return impl().linkWallet(normEmail(email), a);
+ 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) };
} }
async function count() { return impl().count(); }
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; }
module.exports = { init, signup, login, ensure, byEmail, byAddress, byCode, linkWallet, 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 // Ad engine v1 (spec §8b types 1-3). Dual-mode like accounts.js:
// PURCHASED on-chain credits. The chain is the money truth: spend accrues // MySQL (db.enabled) with guarded UPDATEs for the concurrent serving path,
// here, and burns are queued for the engine signer to consume() on-chain // JSON volume file fallback for local dev.
// (admin runs the burner; see /api/admin/burns). Earned-credit pool and the // Spend accrues per campaign; burns queue for the engine signer to consume()
// richer §8b types (inbox, surf, rotation, directory) come later. // on-chain (/api/admin/burns). All exported functions are async.
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
const db = require('./db');
let DATA_DIR = null; let DATA_DIR = null;
let chain = null; let chain = null;
const FILE = () => path.join(DATA_DIR, 'campaigns.json');
const RATES_FILE = () => path.join(DATA_DIR, 'adrates.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. // REHEARSAL PLACEHOLDER RATES — admin-adjustable via /api/admin/rates.
// Integer math: impressions accrue per campaign; credits deduct per BATCH.
function rates() { function rates() {
let saved = {}; let saved = {};
try { saved = JSON.parse(fs.readFileSync(RATES_FILE(), 'utf8')); } catch (e) {} try { saved = JSON.parse(fs.readFileSync(RATES_FILE(), 'utf8')); } catch (e) {}
return Object.assign({ return Object.assign({
bannerBatch: 10, bannerCreditsPerBatch: 2, // $2.00 CPM equivalent bannerBatch: 10, bannerCreditsPerBatch: 2,
textBatch: 10, textCreditsPerBatch: 1, // $1.00 CPM equivalent textBatch: 10, textCreditsPerBatch: 1,
loginCreditsPerDay: 100, // $1.00/day loginCreditsPerDay: 100,
burnBatchMin: 50 // queue a burn every 50cr of spend burnBatchMin: 50
}, saved); }, saved);
} }
function setRates(patch) {
function load() { fs.writeFileSync(RATES_FILE(), JSON.stringify(Object.assign(rates(), patch), null, 2));
try { db = JSON.parse(fs.readFileSync(FILE(), 'utf8')); } catch (e) {} return rates();
if (!db || db.v !== 1) db = { v: 1, nextId: 1, campaigns: [], burnsPending: [] };
} }
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 TYPES = ['banner', 'text', 'login'];
const URL_RE = /^https?:\/\/[^\s]+$/i; const URL_RE = /^https?:\/\/[^\s]+$/i;
const bid = () => crypto.randomBytes(8).toString('hex');
// unburned spend per member = credits owed to the burn queue + campaign accruals function batchFor(type, r) {
function unburnedSpend(memberId) { return type === 'banner' ? { n: r.bannerBatch, cr: r.bannerCreditsPerBatch }
let s = 0; : type === 'text' ? { n: r.textBatch, cr: r.textCreditsPerBatch } : null;
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;
} }
async function availableCredits(memberId) { function validate(input) {
const onchain = await chain.creditBalance(memberId, 0);
return Math.max(0, onchain - unburnedSpend(memberId));
}
async function createCampaign(owner, memberId, input) {
const type = String(input.type || ''); const type = String(input.type || '');
if (!TYPES.includes(type)) return { error: 'Unknown ad type.' }; if (!TYPES.includes(type)) return { error: 'Unknown ad type.' };
const name = String(input.name || '').trim().slice(0, 60); 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)://' }; if (!URL_RE.test(targetUrl)) return { error: 'Target URL must start with http(s)://' };
const budget = Math.floor(Number(input.budget) || 0); const budget = Math.floor(Number(input.budget) || 0);
if (budget < 10) return { error: 'Minimum budget is 10 credits.' }; if (budget < 10) return { error: 'Minimum budget is 10 credits.' };
const avail = await availableCredits(memberId); const out = { type, name, targetUrl, budget, imageUrl: null, title: null, body: null };
if (budget > avail) return { error: 'Budget exceeds your available credits (' + avail + ').' }; if (type === 'banner' || type === 'login') {
out.imageUrl = String(input.imageUrl || '').trim();
const c = { id: db.nextId++, owner, memberId, type, name, targetUrl, if (!URL_RE.test(out.imageUrl)) return { error: (type === 'banner' ? 'Banner' : 'Login') + ' ads need an image URL starting with http(s)://' };
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;
} }
if (type === 'text') { if (type === 'text') {
c.title = String(input.title || '').trim().slice(0, 60); out.title = String(input.title || '').trim().slice(0, 60);
c.body = String(input.body || '').trim().slice(0, 140); out.body = String(input.body || '').trim().slice(0, 140);
if (!c.title) return { error: 'Text ads need a headline.' }; if (!out.title) return { error: 'Text ads need a headline.' };
} }
if (type === 'login') { return { ok: true, c: out };
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) };
} }
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) { // ---- JSON fallback ----
return db.campaigns.filter(c => c.owner === owner).map(pub); const J = {
} db: { v: 1, nextId: 1, campaigns: [], burnsPending: [] },
function setStatus(owner, id, status) { FILE: () => path.join(DATA_DIR, 'campaigns.json'),
const c = db.campaigns.find(x => x.id === Number(id) && x.owner === owner); 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 (!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; c.status = status;
save(); this.save();
return { ok: true, campaign: pub(c) }; return { ok: true, campaign: pubC(c) };
} },
function pub(c) { async serve(type) {
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
const r = rates(); const r = rates();
if (c.accrued >= r.burnBatchMin) { const pool = this.db.campaigns.filter(c => c.type === type && c.status === 'active');
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');
if (!pool.length) return null; if (!pool.length) return null;
const c = pool[Math.floor(Math.random() * pool.length)]; const c = pool[Math.floor(Math.random() * pool.length)];
c.imps += 1; c.imps += 1;
if (type === 'banner') chargeBatch(c, r.bannerBatch, r.bannerCreditsPerBatch); const b = batchFor(type, r);
if (type === 'text') chargeBatch(c, r.textBatch, r.textCreditsPerBatch); if (b) {
// login ads are per-day; impressions tracked, charged by the daily sweep c.batchImps += 1;
save(); if (c.batchImps >= b.n) {
return { id: c.id, type: c.type, targetUrl: '/api/ads/click/' + c.id, c.batchImps = 0;
imageUrl: c.imageUrl || null, title: c.title || null, body: c.body || null }; c.accrued = (c.accrued || 0) + b.cr;
} if (c.accrued >= r.burnBatchMin) {
function click(id) { this.db.burnsPending.push({ id: bid(), memberId: c.memberId, amount: c.accrued, ref: 'campaign-' + c.id, ts: Date.now() });
const c = db.campaigns.find(x => x.id === Number(id)); 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; if (!c) return null;
c.clicks += 1; c.clicks += 1;
save(); this.save();
return c.targetUrl; return c.targetUrl;
} },
async dailySweep() {
// daily charge for login ads (called by a boot + interval sweep)
function dailySweep() {
const r = rates(); const r = rates();
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
let charged = 0; let n = 0;
for (const c of db.campaigns) { for (const c of this.db.campaigns) {
if (c.type !== 'login' || c.status !== 'active' || c.lastDayCharged === today) continue; if (c.type !== 'login' || c.status !== 'active' || c.lastDayCharged === today) continue;
c.lastDayCharged = today; c.lastDayCharged = today;
c.accrued = (c.accrued || 0) + r.loginCreditsPerDay; c.accrued = (c.accrued || 0) + r.loginCreditsPerDay;
if (c.accrued >= r.burnBatchMin) { if (c.accrued >= r.burnBatchMin) {
db.burnsPending.push({ id: crypto.randomBytes(8).toString('hex'), memberId: c.memberId, this.db.burnsPending.push({ id: bid(), memberId: c.memberId, amount: c.accrued, ref: 'campaign-' + c.id, ts: Date.now() });
amount: c.accrued, ref: 'campaign-' + c.id, ts: Date.now() });
c.spent += c.accrued; c.accrued = 0; c.spent += c.accrued; c.accrued = 0;
} }
if (c.spent + c.accrued >= c.budget) c.status = 'out'; if (c.spent + c.accrued >= c.budget) c.status = 'out';
charged += 1; n += 1;
} }
if (charged) save(); if (n) this.save();
return charged; return n;
} },
async pendingBurns() { return this.db.burnsPending.filter(b => !b.burnedTx); },
// ---- burn queue (admin/engine) ---- async markBurned(id, tx) {
function pendingBurns() { return db.burnsPending.filter(b => !b.burnedTx); } const b = this.db.burnsPending.find(x => x.id === id);
function markBurned(id, tx) {
const b = db.burnsPending.find(x => x.id === id);
if (!b) return { error: 'No such burn.' }; if (!b) return { error: 'No such burn.' };
b.burnedTx = tx; b.burnedAt = Date.now(); b.burnedTx = tx; b.burnedAt = Date.now();
save(); this.save();
return { ok: true }; 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) { async function createCampaign(owner, memberId, input) {
const cur = rates(); const v = validate(input);
fs.writeFileSync(RATES_FILE(), JSON.stringify(Object.assign(cur, patch), null, 2)); if (v.error) return v;
return rates(); 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, module.exports = { init, rates, setRates, createCampaign, listCampaigns, setStatus,
serve, click, dailySweep, availableCredits, pendingBurns, markBurned }; serve, click, dailySweep, availableCredits, pendingBurns, markBurned };
+78 -47
View File
@@ -1,15 +1,12 @@
// Wallet sign-in (SIWE / EIP-4361) for InstantAdPay. // Wallet sign-in (SIWE / EIP-4361) + session store for InstantAdPay.
// Pattern lifted from the RM Circle messages.js implementation (proven with // Sessions are dual-mode like accounts.js: MySQL when DATABASE_URL is set,
// MetaMask's friendly sign-in UI). One free signature, cannot move funds. // volume JSON otherwise. All session functions are async.
//
// 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).
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
const { keccak256 } = require('./vendor/sha3'); const { keccak256 } = require('./vendor/sha3');
const secp = require('./vendor/secp256k1'); const secp = require('./vendor/secp256k1');
const db = require('./db');
let DATA_DIR = null; let DATA_DIR = null;
let chain = null; let chain = null;
@@ -19,27 +16,76 @@ let SITE = 'instantadpay.com';
const CHALLENGE_TTL = 10 * 60 * 1000; const CHALLENGE_TTL = 10 * 60 * 1000;
const SESSION_TTL = 30 * 24 * 60 * 60 * 1000; // 30 days const SESSION_TTL = 30 * 24 * 60 * 60 * 1000; // 30 days
const challenges = new Map(); // addressLower -> {message, exp} const challenges = new Map(); // addressLower -> {message, exp}
let sessions = new Map(); // token -> {address, memberId, expires}
const SESS_FILE = () => path.join(DATA_DIR, 'sessions.json'); // ---- JSON session fallback ----
function loadSessions() { const J = {
sessions: new Map(),
FILE: () => path.join(DATA_DIR, 'sessions.json'),
load() {
try { try {
const o = JSON.parse(fs.readFileSync(SESS_FILE(), 'utf8')); const o = JSON.parse(fs.readFileSync(this.FILE(), 'utf8'));
sessions = new Map(Object.entries(o).filter(([, s]) => s.expires > Date.now())); this.sessions = new Map(Object.entries(o).filter(([, s]) => s.expires > Date.now()));
} catch (e) { sessions = new Map(); } } catch (e) { this.sessions = new Map(); }
} },
function saveSessions() { save() {
try { try {
const tmp = SESS_FILE() + '.tmp'; const tmp = this.FILE() + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(Object.fromEntries(sessions)), { mode: 0o600 }); fs.writeFileSync(tmp, JSON.stringify(Object.fromEntries(this.sessions)), { mode: 0o600 });
fs.renameSync(tmp, SESS_FILE()); fs.renameSync(tmp, this.FILE());
} catch (e) { console.error('session save failed', e.message); } } 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) { function init(opts) {
DATA_DIR = opts.dataDir; chain = opts.chain; IS_PROD = !!opts.isProd; DATA_DIR = opts.dataDir; chain = opts.chain; IS_PROD = !!opts.isProd;
if (opts.site) SITE = opts.site; if (opts.site) SITE = opts.site;
loadSessions(); J.load();
} }
// ---- crypto ---- // ---- crypto ----
@@ -65,7 +111,7 @@ function checksumAddress(address) {
return out; return out;
} }
// ---- auth flow ---- // ---- SIWE flow ----
function makeChallenge(address) { function makeChallenge(address) {
if (!ADDR_RE.test(address || '')) return { error: 'Bad address' }; if (!ADDR_RE.test(address || '')) return { error: 'Bad address' };
const a = address.toLowerCase(); const a = address.toLowerCase();
@@ -86,45 +132,30 @@ async function verifyChallenge(address, signature) {
challenges.delete(a); challenges.delete(a);
return { ok: true, address: 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. // ---- sessions ----
function mintSession(fields) { async function mintSession(fields) { return impl().mint(fields || {}); }
const token = crypto.randomBytes(32).toString('hex'); async function updateSession(token, fields) { return impl().update(token, fields || {}); }
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();
}
function sessionCookie(token) { function sessionCookie(token) {
return `iap.sid=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL / 1000}${IS_PROD ? '; Secure' : ''}`; 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 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 || ''); const m = /(?:^|;\s*)iap\.sid=([^;]+)/.exec(req.headers.cookie || '');
if (!m) return null; if (!m) return null;
const token = decodeURIComponent(m[1]); return impl().get(decodeURIComponent(m[1]));
const s = sessions.get(token);
if (!s || s.expires < Date.now()) return null;
return Object.assign({ token }, s);
} }
async function refreshMemberId(sess) { async function refreshMemberId(sess) {
// called after an on-chain action so the session learns its new member id if (!sess || !sess.address) return (sess && sess.memberId) || 0;
if (!sess.address) return sess.memberId || 0;
try { try {
const id = await chain.memberIdByAccount(sess.address); 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; return id || sess.memberId || 0;
} catch (e) { return sess.memberId || 0; } } catch (e) { return sess.memberId || 0; }
} }
function logout(req) { async function logout(req) {
const s = fromRequest(req); const s = await fromRequest(req);
if (s) { sessions.delete(s.token); saveSessions(); } if (s) await impl().drop(s.token);
} }
module.exports = { init, makeChallenge, verifyChallenge, mintSession, updateSession, sessionCookie, clearCookie, fromRequest, refreshMemberId, logout }; 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.", "description": "InstantAdPay membership advertising site - immutable on-chain settlement, transparent ledger.",
"main": "server.js", "main": "server.js",
"scripts": {"start": "node server.js", "dev": "node --watch 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 IS_PROD = process.env.NODE_ENV === 'production';
const SITE_FILE = path.join(DATA_DIR, 'site.json'); const SITE_FILE = path.join(DATA_DIR, 'site.json');
const db = require('./db');
fs.mkdirSync(DATA_DIR, { recursive: true }); 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(); const chatHits = new Map();
function chatLimited(ip) { function chatLimited(ip) {
const now = Date.now(), rec = chatHits.get(ip); const now = Date.now(), rec = chatHits.get(ip);
@@ -39,8 +34,17 @@ function chatLimited(ip) {
} }
// magic-code sign-in: emailLower -> {code, exp, tries} // magic-code sign-in: emailLower -> {code, exp, tries}
const emailCodes = new Map(); const emailCodes = new Map();
setTimeout(() => ads.dailySweep(), 60 * 1000); async function boot() {
setInterval(() => ads.dailySweep(), 60 * 60 * 1000); // login-ad daily charges 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() { function siteConfig() {
let saved = {}; let saved = {};
@@ -101,16 +105,16 @@ async function resolveSponsorToken(tok) {
const t = String(tok || '').trim().toLowerCase(); const t = String(tok || '').trim().toLowerCase();
if (!t) return 0; if (!t) return 0;
if (/^\d+$/.test(t)) return Number(t); if (/^\d+$/.test(t)) return Number(t);
const acct = accounts.byCode(t); const acct = await accounts.byCode(t);
if (!acct || !acct.address) return 0; if (!acct || !acct.address) return 0;
try { return await chain.memberIdByAccount(acct.address); } catch (e) { 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. // The moment someone joins through a code, nudge its owner to activate.
function nudgeReferrer(ref) { async function nudgeReferrer(ref) {
try { try {
const t = String(ref || '').trim().toLowerCase(); const t = String(ref || '').trim().toLowerCase();
if (!t || /^\d+$/.test(t) || !mailer.hasKey()) return; 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 if (!owner || !owner.email || owner.address) return; // already activated-ready
mailer.send(owner.email, 'Someone just joined through your InstantAdPay link', 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' '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') { if (p === '/api/stats' && req.method === 'GET') {
let members = 0; try { members = await chain.memberCount(); } catch (e) {} 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 // -- 24/7 assistant
@@ -193,19 +197,19 @@ const server = http.createServer(async (req, res) => {
if (p === '/api/signup' && req.method === 'POST') { if (p === '/api/signup' && req.method === 'POST') {
const b = await readBody(req); const b = await readBody(req);
const ref = parseCookies(req)['iap.sponsor'] || ''; // first-touch attribution 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); if (r.error) return json(res, 400, r);
nudgeReferrer(ref); nudgeReferrer(ref).catch(() => {});
const token = auth.mintSession({ email: r.account.email }); const token = await auth.mintSession({ email: r.account.email });
return json(res, 200, { ok: true, account: r.account }, { 'Set-Cookie': auth.sessionCookie(token) }); return json(res, 200, { ok: true, account: r.account }, { 'Set-Cookie': auth.sessionCookie(token) });
} }
if (p === '/api/login' && req.method === 'POST') { if (p === '/api/login' && req.method === 'POST') {
const b = await readBody(req); 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); if (r.error) return json(res, 400, r);
let memberId = 0; let memberId = 0;
if (r.account.address) { try { memberId = await chain.memberIdByAccount(r.account.address); } catch (e) {} } 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) }); 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.' }); if (String(b.code || '').trim() !== rec.code) return json(res, 400, { error: 'That code does not match.' });
emailCodes.delete(e); emailCodes.delete(e);
const ref = parseCookies(req)['iap.sponsor'] || ''; 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.error) return json(res, 400, r);
if (r.created) nudgeReferrer(ref); if (r.created) nudgeReferrer(ref).catch(() => {});
let memberId = 0; let memberId = 0;
if (r.account.address) { try { memberId = await chain.memberIdByAccount(r.account.address); } catch (err) {} } 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) }); 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); if (r.error) return json(res, 400, r);
let memberId = 0; let memberId = 0;
try { memberId = await chain.memberIdByAccount(r.address); } catch (e) {} try { memberId = await chain.memberIdByAccount(r.address); } catch (e) {}
const s = auth.fromRequest(req); const s = await auth.fromRequest(req);
if (s && s.email) { 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); 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 }); return json(res, 200, { ok: true, linked: true, address: r.address, memberId });
} }
const acct = accounts.byAddress(r.address); const acct = await accounts.byAddress(r.address);
const token = auth.mintSession({ email: acct ? acct.email : null, address: r.address, memberId }); const token = await auth.mintSession({ email: acct ? acct.email : null, address: r.address, memberId });
return json(res, 200, { ok: true, address: r.address, memberId }, return json(res, 200, { ok: true, address: r.address, memberId },
{ 'Set-Cookie': auth.sessionCookie(token) }); { 'Set-Cookie': auth.sessionCookie(token) });
} }
if (p === '/api/auth/logout' && req.method === 'POST') { 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() }); return json(res, 200, { ok: true }, { 'Set-Cookie': auth.clearCookie() });
} }
if (p === '/api/me' && req.method === 'GET') { 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 }); if (!s) return json(res, 200, { signedIn: false });
const memberId = await auth.refreshMemberId(s); 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 sponsorId = await resolveSponsorToken((acct && acct.sponsorRef) || parseCookies(req)['iap.sponsor']);
const out = { signedIn: true, email: s.email || (acct && acct.email) || null, const out = { signedIn: true, email: s.email || (acct && acct.email) || null,
address: s.address || (acct && acct.address) || null, memberId, 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') { 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.' }); if (!s) return json(res, 401, { error: 'Sign in first.' });
const id = s.memberId || await auth.refreshMemberId(s); const id = s.memberId || await auth.refreshMemberId(s);
if (!id) return json(res, 200, { memberId: 0, earnings: [], purchases: [], referrals: [] }); 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) // -- ad engine (spec §8b v1: banners, text, login ads)
if (p === '/api/ads/slot' && req.method === 'GET') { 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 }); return json(res, 200, { ad });
} }
m = /^\/api\/ads\/click\/(\d+)$/.exec(p); m = /^\/api\/ads\/click\/(\d+)$/.exec(p);
if (m && req.method === 'GET') { 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(); } if (!target) { res.writeHead(404, baseHeaders()); return res.end(); }
res.writeHead(302, baseHeaders({ Location: target })); res.writeHead(302, baseHeaders({ Location: target }));
return res.end(); return res.end();
} }
if (p === '/api/my/campaigns' && req.method === 'GET') { 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.' }); if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const memberId = await auth.refreshMemberId(s); 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; out.availableCredits = memberId ? await ads.availableCredits(memberId) : 0;
return json(res, 200, out); return json(res, 200, out);
} }
if (p === '/api/my/campaigns' && req.method === 'POST') { 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.' }); if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const memberId = await auth.refreshMemberId(s); 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.' }); 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); m = /^\/api\/my\/campaigns\/(\d+)\/(pause|resume)$/.exec(p);
if (m && req.method === 'POST') { 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.' }); 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); return json(res, r.error ? 400 : 200, r);
} }
// -- admin (Bearer ADMIN_PASSWORD) // -- admin (Bearer ADMIN_PASSWORD)
if (p === '/api/admin/burns' && req.method === 'GET') { if (p === '/api/admin/burns' && req.method === 'GET') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); 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 (p === '/api/admin/burns/mark' && req.method === 'POST') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
const b = await readBody(req); 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); return json(res, r.error ? 400 : 200, r);
} }
if (p === '/api/admin/rates' && req.method === 'PATCH') { 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 (_) {} 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); });