6680e154d0
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>
409 lines
20 KiB
JavaScript
409 lines
20 KiB
JavaScript
// InstantAdPay — membership advertising with immutable on-chain settlement.
|
|
// Zero-dependency Node server (RM Circle pattern): static pages + JSON API,
|
|
// wallet sign-in (SIWE), live contract ledger, sponsor join links.
|
|
//
|
|
// Chain selection (Amoy rehearsal vs mainnet) lives in data/config.json —
|
|
// see chain.js. Wipe the volume's accounts/sessions + flip config = launch.
|
|
const http = require('http');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { URL } = require('url');
|
|
const chain = require('./chain');
|
|
const auth = require('./auth');
|
|
const accounts = require('./accounts');
|
|
const ads = require('./ads');
|
|
const mailer = require('./mailer');
|
|
const chatbot = require('./chatbot');
|
|
|
|
const PORT = Number(process.env.PORT || 3000);
|
|
const ROOT = __dirname;
|
|
const PUBLIC_DIR = path.join(ROOT, 'public');
|
|
const DATA_DIR = process.env.DATA_DIR || path.join(ROOT, 'data');
|
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'changeme';
|
|
const IS_PROD = process.env.NODE_ENV === 'production';
|
|
const SITE_FILE = path.join(DATA_DIR, 'site.json');
|
|
|
|
const db = require('./db');
|
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
const chatHits = new Map();
|
|
function chatLimited(ip) {
|
|
const now = Date.now(), rec = chatHits.get(ip);
|
|
if (!rec || now > rec.reset) { chatHits.set(ip, { count: 1, reset: now + 60000 }); return false; }
|
|
rec.count += 1;
|
|
return rec.count > 10;
|
|
}
|
|
// magic-code sign-in: emailLower -> {code, exp, tries}
|
|
const emailCodes = new Map();
|
|
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 = {};
|
|
try { saved = JSON.parse(fs.readFileSync(SITE_FILE, 'utf8')); } catch (e) {}
|
|
return Object.assign({
|
|
siteName: 'InstantAdPay',
|
|
tagline: 'Advertise and earn. Locked in code, not promises.',
|
|
rehearsal: true // shows the testnet banner; flipped off at mainnet launch
|
|
}, saved);
|
|
}
|
|
|
|
// ---- helpers ----
|
|
const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript',
|
|
'.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml', '.webp': 'image/webp',
|
|
'.ico': 'image/x-icon', '.json': 'application/json', '.mp4': 'video/mp4', '.woff2': 'font/woff2' };
|
|
const CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; connect-src 'self'; font-src 'self' data: https://fonts.gstatic.com; form-action 'self'";
|
|
function baseHeaders(extra) {
|
|
return Object.assign({ 'Content-Security-Policy': CSP, 'X-Content-Type-Options': 'nosniff',
|
|
'Referrer-Policy': 'strict-origin-when-cross-origin' }, extra || {});
|
|
}
|
|
function json(res, code, obj, extra) {
|
|
const body = JSON.stringify(obj);
|
|
res.writeHead(code, baseHeaders(Object.assign({ 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, extra)));
|
|
res.end(body);
|
|
}
|
|
function sendFile(res, file) {
|
|
fs.readFile(file, (err, data) => {
|
|
if (err) { res.writeHead(404, baseHeaders({ 'Content-Type': 'text/plain' })); return res.end('Not found'); }
|
|
const ext = path.extname(file).toLowerCase();
|
|
res.writeHead(200, baseHeaders({ 'Content-Type': MIME[ext] || 'application/octet-stream',
|
|
'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=3600' }));
|
|
res.end(data);
|
|
});
|
|
}
|
|
function readBody(req) {
|
|
return new Promise((resolve, reject) => {
|
|
let d = ''; let n = 0;
|
|
req.on('data', c => { n += c.length; if (n > 64 * 1024) { req.destroy(); reject(new Error('too big')); } d += c; });
|
|
req.on('end', () => { try { resolve(d ? JSON.parse(d) : {}); } catch (e) { reject(e); } });
|
|
req.on('error', reject);
|
|
});
|
|
}
|
|
function parseCookies(req) {
|
|
const out = {};
|
|
for (const p of (req.headers.cookie || '').split(';')) {
|
|
const i = p.indexOf('='); if (i > 0) out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim());
|
|
}
|
|
return out;
|
|
}
|
|
function isAdmin(req) {
|
|
const h = req.headers.authorization || '';
|
|
return h === 'Bearer ' + ADMIN_PASSWORD;
|
|
}
|
|
// A sponsor token is a numeric chain id or a site share code. Codes resolve
|
|
// to the referrer's CURRENT chain id, so activation any time before the
|
|
// referral's first purchase still locks the line to them.
|
|
async function resolveSponsorToken(tok) {
|
|
const t = String(tok || '').trim().toLowerCase();
|
|
if (!t) return 0;
|
|
if (/^\d+$/.test(t)) return Number(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.
|
|
async function nudgeReferrer(ref) {
|
|
try {
|
|
const t = String(ref || '').trim().toLowerCase();
|
|
if (!t || /^\d+$/.test(t) || !mailer.hasKey()) return;
|
|
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'
|
|
+ 'One thing to do so you never miss a payment: sign in and switch on payouts '
|
|
+ '(one free wallet step). The contract locks each buyer to their sponsor at their '
|
|
+ 'first purchase, so have payouts on before your people start buying.\n\n'
|
|
+ 'https://instantadpay.com/my\n\nInstantAdPay').catch(e => console.error('nudge failed', e.message));
|
|
} catch (e) {}
|
|
}
|
|
|
|
// ---- live feed (SSE) ----
|
|
const feedClients = new Set();
|
|
function pushFeed(ev) {
|
|
const line = 'data: ' + JSON.stringify(ev) + '\n\n';
|
|
for (const res of feedClients) { try { res.write(line); } catch (e) { feedClients.delete(res); } }
|
|
}
|
|
|
|
// ---- server ----
|
|
const server = http.createServer(async (req, res) => {
|
|
try {
|
|
const u = new URL(req.url, 'http://x');
|
|
const p = u.pathname;
|
|
|
|
// -- join links: /join/<memberId or share code> — first-touch cookie.
|
|
// Codes resolve LATE (at buy time) to whatever chain id the referrer
|
|
// has by then, so free members refer from day one.
|
|
let m = /^\/join\/([A-Za-z0-9]{1,16})$/.exec(p);
|
|
if (m && req.method === 'GET') {
|
|
const tok = m[1].toLowerCase();
|
|
const cookies = parseCookies(req);
|
|
const headers = { Location: '/' };
|
|
if (!cookies['iap.sponsor']) {
|
|
headers['Set-Cookie'] = `iap.sponsor=${tok}; Path=/; SameSite=Lax; Max-Age=${180 * 24 * 3600}${IS_PROD ? '; Secure' : ''}`;
|
|
}
|
|
res.writeHead(302, baseHeaders(headers));
|
|
return res.end();
|
|
}
|
|
|
|
// -- public API
|
|
if (p === '/api/config' && req.method === 'GET') {
|
|
const c = chain.getConfig();
|
|
return json(res, 200, Object.assign({ contract: c.contract, chainId: c.chainId,
|
|
chainName: c.chainName, explorer: c.explorer, rpc: c.rpcs[0],
|
|
emailAuth: mailer.hasKey() || !IS_PROD }, siteConfig()));
|
|
}
|
|
if (p === '/api/catalog' && req.method === 'GET') {
|
|
return json(res, 200, { products: await chain.catalog() });
|
|
}
|
|
if (p === '/api/feed' && req.method === 'GET') {
|
|
return json(res, 200, { events: chain.recentEvents(Number(u.searchParams.get('n')) || 100) });
|
|
}
|
|
if (p === '/api/feed/live' && req.method === 'GET') {
|
|
res.writeHead(200, baseHeaders({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive' }));
|
|
res.write(': connected\n\n');
|
|
feedClients.add(res);
|
|
req.on('close', () => feedClients.delete(res));
|
|
return;
|
|
}
|
|
if (p === '/api/sponsor' && req.method === 'GET') {
|
|
const tok = parseCookies(req)['iap.sponsor'] || '';
|
|
const sponsorId = await resolveSponsorToken(tok);
|
|
return json(res, 200, { ref: tok, sponsorId, invited: !!tok });
|
|
}
|
|
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: await accounts.count() }, chain.totals()));
|
|
}
|
|
|
|
// -- 24/7 assistant
|
|
if (p === '/api/chat' && req.method === 'POST') {
|
|
const ip = req.socket.remoteAddress || 'x';
|
|
if (chatLimited(ip)) return json(res, 429, { error: 'Give it a minute, then ask again.' });
|
|
const b = await readBody(req);
|
|
const r = await chatbot.answer(b.message, b.history);
|
|
return json(res, r.error ? 400 : 200, r);
|
|
}
|
|
|
|
// -- accounts: email + password is the normal join path (wallet comes
|
|
// out only at purchase / payout-activation time and gets linked then)
|
|
if (p === '/api/signup' && req.method === 'POST') {
|
|
const b = await readBody(req);
|
|
const ref = parseCookies(req)['iap.sponsor'] || ''; // first-touch attribution
|
|
const r = await accounts.signup(b.email, b.password, ref);
|
|
if (r.error) return json(res, 400, r);
|
|
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 = 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 = 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) });
|
|
}
|
|
|
|
// -- passwordless: email code sign-in (signup and login are the same act)
|
|
if (p === '/api/auth/email/start' && req.method === 'POST') {
|
|
const b = await readBody(req);
|
|
const e = String(b.email || '').trim().toLowerCase();
|
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(e)) return json(res, 400, { error: 'That email address does not look right.' });
|
|
const prev = emailCodes.get(e);
|
|
if (prev && Date.now() < prev.nextAt) return json(res, 429, { error: 'Code already sent. Give it a minute, then try again.' });
|
|
const code = String(Math.floor(100000 + Math.random() * 900000));
|
|
emailCodes.set(e, { code, exp: Date.now() + 15 * 60 * 1000, tries: 0, nextAt: Date.now() + 60 * 1000 });
|
|
if (mailer.hasKey()) {
|
|
try { await mailer.sendCode(e, code); } catch (err) {
|
|
console.error('sendCode failed', err.message);
|
|
return json(res, 502, { error: 'Could not send the email. Try again in a minute.' });
|
|
}
|
|
return json(res, 200, { ok: true, sent: true });
|
|
}
|
|
if (!IS_PROD) return json(res, 200, { ok: true, sent: false, devCode: code });
|
|
return json(res, 503, { error: 'Email sign-in is not configured yet.' });
|
|
}
|
|
if (p === '/api/auth/email/verify' && req.method === 'POST') {
|
|
const b = await readBody(req);
|
|
const e = String(b.email || '').trim().toLowerCase();
|
|
const rec = emailCodes.get(e);
|
|
if (!rec || rec.exp < Date.now()) return json(res, 400, { error: 'Code expired. Request a fresh one.' });
|
|
rec.tries += 1;
|
|
if (rec.tries > 6) { emailCodes.delete(e); return json(res, 400, { error: 'Too many tries. Request a fresh code.' }); }
|
|
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 = await accounts.ensure(e, ref); // first touch wins; existing accounts unchanged
|
|
if (r.error) return json(res, 400, r);
|
|
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 = 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) });
|
|
}
|
|
|
|
// -- wallet auth: link-to-account when an email session exists, or
|
|
// wallet-first sign-in for crypto-native users
|
|
if (p === '/api/auth/challenge' && req.method === 'POST') {
|
|
const b = await readBody(req);
|
|
const r = auth.makeChallenge(b.address);
|
|
return json(res, r.error ? 400 : 200, r);
|
|
}
|
|
if (p === '/api/auth/verify' && req.method === 'POST') {
|
|
const b = await readBody(req);
|
|
const r = await auth.verifyChallenge(b.address, b.signature);
|
|
if (r.error) return json(res, 400, r);
|
|
let memberId = 0;
|
|
try { memberId = await chain.memberIdByAccount(r.address); } catch (e) {}
|
|
const s = await auth.fromRequest(req);
|
|
if (s && s.email) {
|
|
const lr = await accounts.linkWallet(s.email, r.address);
|
|
if (lr.error) return json(res, 400, lr);
|
|
await auth.updateSession(s.token, { address: r.address, memberId });
|
|
return json(res, 200, { ok: true, linked: true, 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') {
|
|
await auth.logout(req);
|
|
return json(res, 200, { ok: true }, { 'Set-Cookie': auth.clearCookie() });
|
|
}
|
|
if (p === '/api/me' && req.method === 'GET') {
|
|
const s = await auth.fromRequest(req);
|
|
if (!s) return json(res, 200, { signedIn: false });
|
|
const memberId = await auth.refreshMemberId(s);
|
|
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,
|
|
refCode: (acct && acct.code) || null, sponsorId };
|
|
if (memberId) {
|
|
try {
|
|
const mm = await chain.member(memberId);
|
|
out.buyerCount = mm.buyerCount;
|
|
out.onchainSponsorId = mm.sponsorId;
|
|
out.credits = await chain.creditBalance(memberId, 0);
|
|
} catch (e) { out.chainReadError = true; }
|
|
}
|
|
return json(res, 200, out);
|
|
}
|
|
|
|
if (p === '/api/my/activity' && req.method === 'GET') {
|
|
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: [] });
|
|
const evs = chain.recentEvents(600);
|
|
return json(res, 200, {
|
|
memberId: id,
|
|
earnings: evs.filter(e => (e.type === 'TierPaid' && e.recipientId === id) || (e.type === 'AwardPaid' && e.toId === id)),
|
|
purchases: evs.filter(e => e.type === 'Purchase' && e.buyerId === id),
|
|
referrals: evs.filter(e => (e.type === 'MemberActivated' && e.sponsorId === id) || (e.type === 'BuyerCounted' && e.sponsorId === id))
|
|
});
|
|
}
|
|
|
|
// -- ad engine (spec §8b v1: banners, text, login ads)
|
|
if (p === '/api/ads/slot' && req.method === 'GET') {
|
|
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 = 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 = 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: 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 = 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.' });
|
|
const b = await readBody(req);
|
|
const r = await ads.createCampaign(s.email, memberId, b);
|
|
return json(res, r.error ? 400 : 200, r);
|
|
}
|
|
m = /^\/api\/my\/campaigns\/(\d+)\/(pause|resume)$/.exec(p);
|
|
if (m && req.method === 'POST') {
|
|
const s = await auth.fromRequest(req);
|
|
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
|
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: 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 = await ads.markBurned(b.id, b.tx);
|
|
return json(res, r.error ? 400 : 200, r);
|
|
}
|
|
if (p === '/api/admin/rates' && req.method === 'PATCH') {
|
|
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
|
const b = await readBody(req);
|
|
return json(res, 200, { ok: true, rates: ads.setRates(b) });
|
|
}
|
|
|
|
if (p === '/api/admin/site' && req.method === 'PATCH') {
|
|
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
|
const b = await readBody(req);
|
|
const cur = siteConfig();
|
|
fs.writeFileSync(SITE_FILE, JSON.stringify(Object.assign(cur, b), null, 2));
|
|
return json(res, 200, { ok: true, site: siteConfig() });
|
|
}
|
|
if (p === '/api/admin/chain' && req.method === 'PATCH') {
|
|
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
|
const b = await readBody(req);
|
|
const file = path.join(DATA_DIR, 'config.json');
|
|
let cur = {}; try { cur = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) {}
|
|
fs.writeFileSync(file, JSON.stringify(Object.assign(cur, b), null, 2));
|
|
chain.reloadConfig();
|
|
return json(res, 200, { ok: true, config: chain.getConfig() });
|
|
}
|
|
|
|
// -- pages
|
|
if (req.method === 'GET') {
|
|
if (p === '/') return sendFile(res, path.join(PUBLIC_DIR, 'index.html'));
|
|
if (p === '/ledger') return sendFile(res, path.join(PUBLIC_DIR, 'ledger.html'));
|
|
if (p === '/contract') return sendFile(res, path.join(PUBLIC_DIR, 'contract.html'));
|
|
if (p === '/my') return sendFile(res, path.join(PUBLIC_DIR, 'my.html'));
|
|
const safe = path.normalize(p).replace(/^([.\\/])+/, '');
|
|
const file = path.join(PUBLIC_DIR, safe);
|
|
if (file.startsWith(PUBLIC_DIR) && fs.existsSync(file) && fs.statSync(file).isFile()) return sendFile(res, file);
|
|
}
|
|
|
|
res.writeHead(404, baseHeaders({ 'Content-Type': 'text/plain' }));
|
|
res.end('Not found');
|
|
} catch (e) {
|
|
console.error('request error', req.url, e.message);
|
|
try { json(res, 500, { error: 'server error' }); } catch (_) {}
|
|
}
|
|
});
|
|
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); });
|