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
+81 -50
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() {
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() {
try {
const tmp = SESS_FILE() + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(Object.fromEntries(sessions)), { mode: 0o600 });
fs.renameSync(tmp, SESS_FILE());
} catch (e) { console.error('session save failed', e.message); }
}
// ---- JSON session fallback ----
const J = {
sessions: new Map(),
FILE: () => path.join(DATA_DIR, 'sessions.json'),
load() {
try {
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 = 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 };