Initial commit: SolLunar Kitchen under version control

This commit is contained in:
Avi 2026-09-23 18:57:11 -05:00
commit 601c85f236
37 changed files with 6206 additions and 0 deletions

15
server/.env.example Normal file
View file

@ -0,0 +1,15 @@
# Copy to .env and fill in. The server also accepts settings at runtime
# via the ⚙️ settings modal (POST /api/settings), which writes .env for you.
PORT=8787
# Your BTCPay Server (regtest: http://localhost:15808, mainnet: https://btcpay.yourhost.com)
BTCPAY_URL=
BTCPAY_STORE=
BTCPAY_API_KEY=
*** Set automatically by POST /api/webhook/register, or paste it manually
# after creating a webhook in BTCPay (Store → Settings → Webhooks).
WEBHOOK_SECRET=
*** Public URL of THIS backend (what BTCPay can reach), e.g. https://kitchen.example.com
WEBHOOK_PUBLIC_URL=

View file

@ -0,0 +1,188 @@
# FEST 484 / SolLunar Kitchen — local BTCPay regtest environment
# ------------------------------------------------------------------
# Self-contained regtest Bitcoin network + BTCPay Server + merchant
# Lightning (c-lightning). Based on the official BTCPayServer test
# compose (BTCPayServer.Tests/docker-compose.yml) with the test-only
# services removed and a BTCPay Server app container added.
#
# Start: docker compose -f docker-compose.btcpay.yml up -d
# BTCPay UI: http://localhost:15808 (first run: account setup wizard)
# Mail UI: http://localhost:34218 (catches the setup email)
# Stop: docker compose -f docker-compose.btcpay.yml down
# Reset: docker compose -f docker-compose.btcpay.yml down -v (wipes data!)
#
# RAM: ~1.2-1.5 GB while running.
services:
btcpayserver:
image: btcpayserver/btcpayserver:2.4.3
restart: unless-stopped
ports:
- "15808:80"
environment:
BTCPAY_HOSTS: "127.0.0.1:15808"
NBITCOIN_NETWORK: "regtest"
BTCPAY_POSTGRES: "Server=postgres;Port=5432;Database=btcpayserver;Username=postgres;Include Error Detail=true;"
NBXPLORER_HOST: "http://nbxplorer:32838/"
# Merchant Lightning via c-lightning unix socket (shared volume below)
BTCPAY_BTCLIGHTNING: "type=clightning;server=unix:///etc/merchant_lightning/lightning-rpc"
# Mail via Mailpit (no real SMTP needed)
SMTP_SERVER: "mailpit:1025"
SMTP_USERNAME: ""
SMTP_PASSWORD: ""
SMTP_SECURITY: "NONE"
volumes:
- "btcpay_data:/home/btcpayserver/.btcpay"
- "merchant_lightningd_datadir:/etc/merchant_lightning"
depends_on:
- nbxplorer
- postgres
- merchant_lightningd
nbxplorer:
image: nicolasdorier/nbxplorer:2.6.10
restart: unless-stopped
ports:
- "32838:32838"
expose:
- "32838"
environment:
NBXPLORER_NETWORK: regtest
NBXPLORER_CHAINS: "btc"
NBXPLORER_BTCRPCURL: http://bitcoind:43782/
NBXPLORER_BTCNODEENDPOINT: bitcoind:39388
NBXPLORER_BTCRPCUSER: ceiwHEbqWI83
NBXPLORER_BTCRPCPASSWORD: "DwubwWsoo3"
NBXPLORER_BIND: 0.0.0.0:32838
NBXPLORER_MINGAPSIZE: 5
NBXPLORER_MAXGAPSIZE: 10
NBXPLORER_VERBOSE: 1
NBXPLORER_POSTGRES: User ID=postgres;Include Error Detail=true;Host=postgres;Port=5432;Database=nbxplorer
NBXPLORER_EXPOSERPC: 1
NBXPLORER_NOAUTH: 1
depends_on:
- bitcoind
bitcoind:
restart: unless-stopped
image: btcpayserver/bitcoin:31.0
environment:
BITCOIN_NETWORK: regtest
BITCOIN_WALLETDIR: "/data/wallets"
BITCOIN_EXTRA_ARGS: |-
rpcuser=ceiwHEbqWI83
rpcpassword=DwubwWsoo3
rpcport=43782
rpcbind=0.0.0.0:43782
rpcallowip=0.0.0.0/0
port=39388
whitelist=0.0.0.0/0
zmqpubrawblock=tcp://0.0.0.0:28332
zmqpubrawtx=tcp://0.0.0.0:28333
deprecatedrpc=signrawtransaction
fallbackfee=0.0002
minrelaytxfee=0.00001000
unsafesqlitesync=1
ports:
- "43782:43782" # RPC
- "39388:39388" # P2P
expose:
- "43782"
- "39388"
- "28332"
- "28333"
volumes:
- "bitcoin_datadir:/data"
# Merchant Lightning node (c-lightning) — provides the Lightning payment
# option in BTCPay. Shares its datadir with the btcpayserver container so
# BTCPay can reach the lightning-rpc unix socket.
merchant_lightningd:
image: btcpayserver/lightning:v26.06.1
stop_signal: SIGKILL
restart: unless-stopped
environment:
EXPOSE_TCP: "true"
LIGHTNINGD_CHAIN: "btc"
LIGHTNINGD_NETWORK: "regtest"
LIGHTNINGD_OPT: |
developer
bitcoin-datadir=/etc/bitcoin
bitcoin-rpcconnect=bitcoind
announce-addr=merchant_lightningd:9735
log-level=debug
funding-confirms=1
dev-fast-gossip
dev-bitcoind-poll=1
ports:
- "30993:9835" # REST API
- "30893:9735" # v1 P2P
expose:
- "9735"
- "9835"
volumes:
- "bitcoin_datadir:/etc/bitcoin"
- "merchant_lightningd_datadir:/root/.lightning"
depends_on:
- bitcoind
# Customer Lightning node — ONLY needed to pay Lightning test invoices.
# Remove this service to save ~300MB RAM if you only test onchain BTC.
customer_lightningd:
image: btcpayserver/lightning:v26.06.1
stop_signal: SIGKILL
restart: unless-stopped
environment:
EXPOSE_TCP: "true"
LIGHTNINGD_CHAIN: "btc"
LIGHTNINGD_NETWORK: "regtest"
LIGHTNINGD_OPT: |
developer
bitcoin-datadir=/etc/bitcoin
bitcoin-rpcconnect=bitcoind
announce-addr=customer_lightningd:9735
log-level=debug
funding-confirms=1
dev-fast-gossip
dev-bitcoind-poll=1
ports:
- "30992:9835" # REST API
- "30892:9735" # v1 P2P
expose:
- "9735"
- "9835"
volumes:
- "bitcoin_datadir:/etc/bitcoin"
- "customer_lightningd_datadir:/root/.lightning"
depends_on:
- bitcoind
postgres:
image: postgres:18.1
environment:
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- "39372:5432"
command: ["-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"]
expose:
- "5432"
volumes:
- "postgres_test_datadir:/var/lib/postgresql"
# Mail catcher for BTCPay's setup email
mailpit:
image: axllent/mailpit:v1.27
ports:
- "34218:8025" # web UI
- "34219:1025" # SMTP
environment:
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
volumes:
bitcoin_datadir:
btcpay_data:
merchant_lightningd_datadir:
customer_lightningd_datadir:
postgres_test_datadir:

121
server/e2e-test.js Normal file
View file

@ -0,0 +1,121 @@
/**
* E2E test: boots mock-btcpay + server, then exercises:
* health → settings save → webhook auto-register → create invoice →
* poll status → (mock settles + fires signed webhook) → status Paid → SSE saw the update
*/
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import crypto from 'node:crypto';
const PORT = 8787, MOCK_PORT = 8899, BASE = `http://localhost:${PORT}`;
let failures = 0;
function check(name, cond, extra = '') {
console.log((cond ? ' PASS ' : ' FAIL ') + name + (extra ? ` [${extra}]` : ''));
if (!cond) failures++;
}
async function j(method, path, body) {
const r = await fetch(BASE + path, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
return { status: r.status, body: await r.json().catch(() => ({})) };
}
function boot(file, env) {
const c = spawn(process.execPath, [file], {
env: { ...process.env, ...env },
stdio: ['ignore', 'pipe', 'pipe'],
});
c.stdout.on('data', d => process.env.QUIET || console.log(' [' + file + '] ' + d.toString().trim()));
c.stderr.on('data', d => process.stderr.write(d));
return c;
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
/* boot */
const mock = boot('mock-btcpay.js', { MOCK_PORT: String(MOCK_PORT), MOCK_SETTLE_MS: '6000' });
const srv = boot('server.js', {
PORT: String(PORT),
DATA_FILE: '/tmp/fest484-e2e-invoices.json',
WEBHOOK_PUBLIC_URL: BASE,
MOCK_API_KEY: 'mock-store-key',
});
try { fs.rmSync('/tmp/fest484-e2e-invoices.json'); } catch { }
await sleep(1200);
try {
/* 1 health */
const h = await j('GET', '/health');
check('health ok', h.status === 200 && h.body.ok === true);
/* 2 save settings (points at the mock) */
const s = await j('POST', '/api/settings', { url: `http://localhost:${MOCK_PORT}`, store: 'mock-store-1234', apiKey: 'mock-store-key' });
check('settings saved', s.status === 200 && s.body.ok === true, JSON.stringify(s.body));
/* 3 auto-register webhook (mock returns a secret) */
const w = await j('POST', '/api/webhook/register', {});
check('webhook registered', w.status === 200 && w.body.ok === true, JSON.stringify(w.body));
/* 4 create invoice */
const inv = await j('POST', '/api/invoices', {
amount: 24.5, currency: 'USD', orderCode: 'F484-TEST1', description: 'E2E test order',
metadata: { pickup: '11:00 – 12:00', name: 'E2E' },
});
check('invoice created', inv.status === 201 && inv.body.id, inv.body.id || JSON.stringify(inv.body));
check('has bolt11', typeof inv.body.bolt11 === 'string' && inv.body.bolt11.startsWith('lnbc'));
check('has btc address', typeof inv.body.btcAddress === 'string' && inv.body.btcAddress.startsWith('bc1'));
/* 5 open SSE and wait for the status event */
const sseEvents = [];
const ac = new AbortController();
const es = fetch(BASE + '/api/invoices/' + inv.body.id + '/events', { signal: ac.signal })
.then(async r => {
const reader = r.body.getReader();
const dec = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf('\n\n')) >= 0) {
const frame = buf.slice(0, i); buf = buf.slice(i + 2);
for (const line of frame.split('\n')) if (line.startsWith('data: ')) sseEvents.push(JSON.parse(line.slice(6)));
}
}
}).catch(() => { });
await sleep(300);
check('sse initial status New', sseEvents.some(e => e.type === 'status' && e.status === 'New'), JSON.stringify(sseEvents));
/* 6 poll until Paid (webhook from mock settles it after ~6s) */
let paid = null;
for (let i = 0; i < 20; i++) {
const st = await j('GET', '/api/invoices/' + inv.body.id);
if (st.body.status === 'Paid') { paid = st.body; break; }
await sleep(1000);
}
check('invoice reached Paid (webhook or poll)', Boolean(paid));
check('sse received Paid event', sseEvents.some(e => e.type === 'status' && e.status === 'Paid'), JSON.stringify(sseEvents));
ac.abort();
/* 7 webhook rejects bad signature */
const bad = await fetch(BASE + '/api/btcpay/webhook', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'BTCPay-Sig': 'sha256=' + 'ab'.repeat(32) },
body: JSON.stringify({ event: 'InvoiceSettled', invoice: inv.body.id }),
});
check('webhook rejects bad sig (401)', bad.status === 401);
/* 8 rate limit sanity (not critical) */
const rl = await j('POST', '/api/invoices', { amount: 1, currency: 'USD' });
check('invoice create still works for 2nd invoice', rl.status === 201);
console.log(failures === 0 ? '\nE2E: ALL PASS ✅' : `\nE2E: ${failures} FAILURE(S) ❌`);
process.exit(failures === 0 ? 0 : 1);
} catch (e) {
console.error('E2E crashed:', e);
process.exit(1);
} finally {
mock.kill('SIGTERM');
srv.kill('SIGTERM');
}

124
server/mock-btcpay.js Normal file
View file

@ -0,0 +1,124 @@
/**
* Mock BTCPay Server — enough of the Greenfield API to exercise the
* payment backend and frontend: GET /api/v1/stores/:id,
* POST /api/v1/stores/:id/invoices, GET /api/v1/invoices/:id,
* POST /api/v1/stores/:id/webhooks.
*
* Simulates a payment: once an invoice exists, after MOCK_SETTLE_MS (or when
* you hit POST /mock/settle/:id) it flips to Settled and fires the webhook.
*/
import http from 'node:http';
import crypto from 'node:crypto';
const PORT = Number(process.env.MOCK_PORT || 8899);
const SETTLE_MS = Number(process.env.MOCK_SETTLE_MS || 8000);
const API_KEY = process.env.MOCK_API_KEY || 'mock-store-key';
const STORE_ID = process.env.MOCK_STORE_ID || 'mock-store-1234';
const invoices = new Map();
let webhookSecret = 'mock-webhook-secret';
let webhookUrl = process.env.MOCK_WEBHOOK_URL || 'http://localhost:8787/api/btcpay/webhook';
function json(res, code, obj) {
const b = JSON.stringify(obj);
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(b);
}
function readBody(req) {
return new Promise((resolve, reject) => {
let d = ''; req.on('data', c => d += c); req.on('end', () => { try { resolve(d ? JSON.parse(d) : {}); } catch { reject(new Error('bad json')); } }); req.on('error', reject);
});
}
function authed(req) {
const h = req.headers['authorization'] || '';
return h === 'token ' + API_KEY;
}
const server = http.createServer(async (req, res) => {
const u = new URL(req.url, 'http://localhost');
const p = u.pathname;
try {
let m;
if (req.method === 'GET' && (m = p.match(/^\/api\/v1\/stores\/([^/]+)$/))) {
if (m[1] !== STORE_ID) return json(res, 404, { message: 'store not found' });
if (!authed(req)) return json(res, 401, { message: 'unauthorized' });
return json(res, 200, { id: STORE_ID, name: 'KITCHEN 484 (mock)', network: 'mainnet' });
}
if (req.method === 'POST' && (m = p.match(/^\/api\/v1\/stores\/([^/]+)\/invoices$/))) {
if (!authed(req)) return json(res, 401, { message: 'unauthorized' });
const body = await readBody(req);
const id = crypto.randomUUID();
const bolt11 = 'lnbc' + Math.round(body.amount * 1e8) + 'nMOCKBOLT11' + id.replace(/-/g, '').slice(0, 20);
const addr = 'bc1qmock' + id.replace(/-/g, '').slice(0, 30);
const rec = {
id,
status: 'New',
checkoutUrl: `https://mock.btcpay/checkout/${id}`,
paymentUrl: `https://mock.btcpay/pay/${id}`,
amount: body.amount,
currency: body.currency,
metadata: body.metadata || {},
paymentMethods: [
{ cryptoCode: 'BTC', data: { address: addr } },
{ cryptoCode: 'LIGHTNING', bolt11 },
],
};
invoices.set(id, rec);
console.log(`[mock] invoice ${id} ${body.amount} ${body.currency}`);
setTimeout(() => settle(id, 'timer'), SETTLE_MS);
return json(res, 201, rec);
}
if (req.method === 'GET' && (m = p.match(/^\/api\/v1\/invoices\/([^/]+)$/))) {
const rec = invoices.get(m[1]);
if (!rec) return json(res, 404, { message: 'not found' });
return json(res, 200, rec);
}
if (req.method === 'POST' && (m = p.match(/^\/api\/v1\/stores\/([^/]+)\/webhooks$/))) {
if (!authed(req)) return json(res, 401, { message: 'unauthorized' });
const body = await readBody(req);
webhookUrl = body.url || webhookUrl;
webhookSecret = body.secret || crypto.randomBytes(16).toString('hex');
console.log(`[mock] webhook registered → ${webhookUrl} secret=${webhookSecret.slice(0, 8)}…`);
return json(res, 200, { id: 'wh-mock-1', secret: webhookSecret, url: webhookUrl });
}
if (req.method === 'POST' && p === '/mock/settle') {
// body {id} or path /mock/settle/:id
const body = await readBody(req).catch(() => ({}));
const id = body.id;
if (id) return settle(id, 'manual'), json(res, 200, { ok: true });
return json(res, 400, { message: 'need {id}' });
}
if (req.method === 'POST' && (m = p.match(/^\/mock\/settle\/([^/]+)$/))) {
settle(m[1], 'manual');
return json(res, 200, { ok: true });
}
json(res, 404, { message: 'mock: unknown route ' + req.method + ' ' + p });
} catch (e) {
json(res, 500, { message: e.message });
}
});
function settle(id, via) {
const rec = invoices.get(id);
if (!rec || rec.status === 'Settled') return;
rec.status = 'Settled';
console.log(`[mock] settling ${id} via ${via} → firing webhook to ${webhookUrl}`);
const payload = JSON.stringify({
event: 'InvoiceSettled',
invoice: id,
storeId: STORE_ID,
status: 'Settled',
amount: rec.amount,
currency: rec.currency,
});
fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'BTCPay-Sig': 'sha256=' + crypto.createHmac('sha256', webhookSecret).update(payload).digest('hex'),
},
body: payload,
}).catch(e => console.error('[mock] webhook delivery failed:', e.message));
}
server.listen(PORT, () => console.log(`Mock BTCPay on :${PORT} (store=${STORE_ID}, settles after ${SETTLE_MS}ms)`));

16
server/package.json Normal file
View file

@ -0,0 +1,16 @@
{
"name": "kitchen484-btc-pay",
"version": "1.0.0",
"private": true,
"description": "BTCPay proxy backend for KITCHEN 484 / SOLARPUNK SUMMIT — keeps the BTCPay API key server-side",
"type": "module",
"main": "server.js",
"scripts": {
"start": "node server.js",
"mock": "node mock-btcpay.js",
"test:e2e": "node e2e-test.js"
},
"engines": {
"node": ">=18"
}
}

425
server/server.js Normal file
View file

@ -0,0 +1,425 @@
/**
* KITCHEN 484 / SOLARPUNK SUMMIT — BTCPay payment backend
* ------------------------------------------------------------------
* A tiny Node (no dependencies) proxy that:
* - keeps the BTCPay API key SERVER-SIDE (never sent to the browser)
* - creates BTCPay invoices (onchain BTC + Lightning bolt11)
* - reports status via polling AND Server-Sent Events
* - receives BTCPay webhooks (HMAC-SHA256 verified via BTCPay-Sig)
* - serves the static site from the parent directory
*
* Env (also persisted to .env next to this file):
* PORT listen port (default 8787)
* BTCPAY_URL e.g. https://btcpay.example.com
* BTCPAY_STORE store id
* BTCPAY_API_KEY store api key (token)
* WEBHOOK_SECRET secret used by BTCPay to sign webhook deliveries
* WEBHOOK_PUBLIC_URL your public origin, e.g. https://kitchen.example.com
* (used when auto-registering the webhook)
* DATA_FILE where invoices are persisted (default .data/invoices.json)
*/
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const STATIC_DIR = path.resolve(__dirname, '..');
const ENV_FILE = path.join(__dirname, '.env');
/* ----------------------------- config ----------------------------- */
function loadEnv() {
if (!fs.existsSync(ENV_FILE)) return {};
const out = {};
for (const line of fs.readFileSync(ENV_FILE, 'utf8').split('\n')) {
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
if (m) out[m[1]] = m[2].replace(/^["']|["']$/g, '');
}
return out;
}
function saveEnv(obj) {
const lines = Object.entries(obj).map(([k, v]) => `${k}=${v}`);
fs.writeFileSync(ENV_FILE, lines.join('\n') + '\n', { mode: 0o600 });
}
const cfg = {
port: Number(process.env.PORT || 8787),
url: process.env.BTCPAY_URL || '',
store: process.env.BTCPAY_STORE || '',
apiKey: process.env.BTCPAY_API_KEY || '',
webhookSecret: process.env.WEBHOOK_SECRET || '',
publicUrl: process.env.WEBHOOK_PUBLIC_URL || '',
dataFile: process.env.DATA_FILE || path.join(__dirname, '.data/invoices.json'),
};
Object.assign(cfg, loadEnv());
function configured() {
return Boolean(cfg.url && cfg.store && cfg.apiKey);
}
/* --------------------------- persistence --------------------------- */
const invoices = new Map(); // btcpayInvoiceId -> record
function loadInvoices() {
try {
for (const rec of JSON.parse(fs.readFileSync(cfg.dataFile, 'utf8'))) {
invoices.set(rec.id, rec);
}
} catch { /* first run */ }
}
function persist() {
fs.mkdirSync(path.dirname(cfg.dataFile), { recursive: true });
fs.writeFileSync(cfg.dataFile, JSON.stringify([...invoices.values()], null, 2));
}
loadInvoices();
/* ------------------------- BTCPay API client ------------------------ */
async function btcpay(pathname, { method = 'GET', body } = {}) {
const base = cfg.url.replace(/\/+$/, '');
const res = await fetch(base + pathname, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: 'token ' + cfg.apiKey,
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let json;
try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text }; }
if (!res.ok) {
const err = new Error(`BTCPay ${res.status}: ${JSON.stringify(json).slice(0, 300)}`);
err.status = res.status;
throw err;
}
return json;
}
/* ------------------------------ helpers ----------------------------- */
const PAID = new Set(['Paid', 'Complete', 'Settled', 'Confirmed']);
const DEAD = new Set(['Expired', 'Invalid', 'Cancelled', 'Failed']);
function normStatus(s) {
s = String(s || 'New');
if (PAID.has(s)) return 'Paid';
if (DEAD.has(s)) return 'Expired';
return s; // New | Processing | …
}
function sseClients() {
// Map invoiceId -> Set<res>; plus a '*' key for global listeners
return global.__sse;
}
const sse = new Map();
global.__sse = sse;
function broadcast(id, payload) {
for (const key of [id, '*']) {
const set = sse.get(key);
if (!set) continue;
const msg = `data: ${JSON.stringify(payload)}\n\n`;
for (const res of set) { try { res.write(msg); } catch { set.delete(res); } }
}
}
function updateInvoice(id, status, extra = {}) {
const rec = invoices.get(id);
if (!rec) return;
const next = normStatus(status);
if (next !== rec.status) {
rec.status = next;
rec.updatedAt = Date.now();
persist();
broadcast(id, { type: 'status', id, status: next });
}
Object.assign(rec, extra);
persist();
}
/* simple per-IP rate limit for invoice creation */
const hitTimes = new Map();
function rateLimit(ip, max = 10, windowMs = 60_000) {
const now = Date.now();
const arr = (hitTimes.get(ip) || []).filter(t => now - t < windowMs);
if (arr.length >= max) return false;
arr.push(now);
hitTimes.set(ip, arr);
return true;
}
/* ----------------------------- static ------------------------------ */
const MIME = {
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript',
'.css': 'text/css', '.json': 'application/json', '.webmanifest': 'application/manifest+json',
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml',
'.webp': 'image/webp', '.ico': 'image/x-icon', '.txt': 'text/plain',
};
function serveStatic(req, res, url) {
let p = decodeURIComponent(url.pathname);
if (p === '/') p = '/index.html';
const file = path.normalize(path.join(STATIC_DIR, p));
if (!file.startsWith(STATIC_DIR)) { res.writeHead(403); return res.end('forbidden'); }
fs.readFile(file, (err, data) => {
if (err) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('not found'); }
res.writeHead(200, { 'Content-Type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream' });
res.end(data);
});
}
/* ------------------------------ http ------------------------------- */
function json(res, code, obj) {
const body = JSON.stringify(obj);
res.writeHead(code, {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-store',
});
res.end(body);
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost');
const ip = req.socket.remoteAddress || 'unknown';
try {
/* CORS preflight */
if (req.method === 'OPTIONS') {
res.writeHead(204, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
});
return res.end();
}
const p = url.pathname;
/* ---------- health ---------- */
if (p === '/health') {
return json(res, 200, { ok: true, configured: configured(), time: Date.now() });
}
/* ---------- settings ---------- */
if (p === '/api/settings' && req.method === 'GET') {
return json(res, 200, { configured: configured(), url: cfg.url || null, store: cfg.store || null });
}
if (p === '/api/settings' && req.method === 'POST') {
// body: {url, store, apiKey} — validates the connection, then persists
const body = await readBody(req);
const u = String(body.url || '').trim();
const store = String(body.store || '').trim();
const key = String(body.apiKey || '').trim();
if (!u || !store) return json(res, 400, { ok: false, error: 'url and store are required' });
let probe = null;
try {
const base = u.replace(/\/+$/, '');
const r = await fetch(base + '/api/v1/stores/' + encodeURIComponent(store), {
headers: { Authorization: 'token ' + key, 'Content-Type': 'application/json' },
});
const t = await r.text();
let j = {}; try { j = JSON.parse(t); } catch { /* ignore */ }
if (!r.ok) throw new Error(`HTTP ${r.status} ${t.slice(0, 160)}`);
probe = { storeId: j.id, storeName: j.name || null, network: j.network || null };
} catch (e) {
return json(res, 400, { ok: false, error: 'Could not verify store: ' + e.message });
}
cfg.url = u; cfg.store = store; cfg.apiKey = key;
persistConfig();
return json(res, 200, { ok: true, ...probe });
}
/* ---------- webhook secret (manual mode) ---------- */
if (p === '/api/webhook-secret' && req.method === 'POST') {
const body = await readBody(req);
cfg.webhookSecret = String(body.secret || '').trim();
persistConfig();
return json(res, 200, { ok: true });
}
/* ---------- auto-register webhook ---------- */
if (p === '/api/webhook/register' && req.method === 'POST') {
if (!configured()) return json(res, 400, { ok: false, error: 'BTCPay not configured' });
const publicUrl = cfg.publicUrl ? cfg.publicUrl.replace(/\/+$/, '') : '';
if (!publicUrl) return json(res, 400, { ok: false, error: 'Set WEBHOOK_PUBLIC_URL in .env first' });
const wh = await btcpay(`/api/v1/stores/${cfg.store}/webhooks`, {
method: 'POST',
body: {
url: `${publicUrl}/api/btcpay/webhook`,
enabled: true,
automaticRedelivery: true,
authorizedEvents: { invoiceSettled: true, invoiceExpired: true, invoiceInvalid: true, invoiceReceivedPayment: true },
},
});
cfg.webhookSecret = wh.secret || cfg.webhookSecret;
persistConfig();
return json(res, 200, { ok: true, webhookId: wh.id, secretSet: Boolean(wh.secret) });
}
/* ---------- create invoice ---------- */
if (p === '/api/invoices' && req.method === 'POST') {
if (!configured()) return json(res, 503, { error: 'BTCPay not configured — save settings first' });
if (!rateLimit(ip)) return json(res, 429, { error: 'Too many invoices, slow down' });
const body = await readBody(req);
const amount = Number(body.amount);
if (!Number.isFinite(amount) || amount <= 0) return json(res, 400, { error: 'amount must be a positive number' });
const currency = String(body.currency || 'USD').toUpperCase();
const orderCode = String(body.orderCode || '').slice(0, 64);
const description = String(body.description || 'KITCHEN 484 order').slice(0, 512);
const inv = await btcpay(`/api/v1/stores/${cfg.store}/invoices`, {
method: 'POST',
body: {
amount: Math.round(amount * 1e8) / 1e8,
currency,
description,
expirationInterval: 30 * 60, // 30 min
metadata: { orderCode, ...pick(body.metadata, ['pickup', 'name', 'items', 'day']) },
},
});
const id = inv.id;
const rec = {
id,
orderCode,
amount,
currency,
status: normStatus(inv.status),
bolt11: extractBolt11(inv),
btcAddress: extractBtcAddress(inv),
checkoutUrl: inv.checkoutUrl || inv.paymentUrl || null,
createdAt: Date.now(),
updatedAt: Date.now(),
};
invoices.set(id, rec);
persist();
broadcast(id, { type: 'created', id, status: rec.status });
return json(res, 201, publicInvoice(rec));
}
/* ---------- invoice status (polling) ---------- */
let m;
if ((m = p.match(/^\/api\/invoices\/([^/]+)$/)) && req.method === 'GET') {
const rec = invoices.get(m[1]);
if (!rec) return json(res, 404, { error: 'unknown invoice' });
return json(res, 200, publicInvoice(rec));
}
/* ---------- invoice status (SSE) ---------- */
if ((m = p.match(/^\/api\/invoices\/([^/]+)\/events$/)) && req.method === 'GET') {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-store',
Connection: 'keep-alive',
'Access-Control-Allow-Origin': '*',
});
res.write(`retry: 3000\n\n`);
const id = m[1];
const set = new Set([res]);
sse.set(id, set);
const rec = invoices.get(id);
if (rec) res.write(`data: ${JSON.stringify({ type: 'status', id, status: rec.status })}\n\n`);
const ping = setInterval(() => { try { res.write(`: ping\n\n`); } catch { /* closed */ } }, 25_000);
req.on('close', () => {
clearInterval(ping);
set.delete(res);
if (set.size === 0) sse.delete(id);
});
return;
}
/* ---------- BTCPay webhook ---------- */
if (p === '/api/btcpay/webhook' && req.method === 'POST') {
const raw = await readRaw(req);
const sig = req.headers['btcpay-sig'];
if (!cfg.webhookSecret || !verifyBtcpaySig(raw, sig, cfg.webhookSecret)) {
return json(res, 401, { error: 'bad signature' });
}
let data; try { data = JSON.parse(raw.toString('utf8')); } catch { data = {}; }
const invId = data.invoice; // BTCPay sends the invoice id in the payload
const rec = invId && invoices.get(String(invId));
if (!rec) {
// Could be an invoice created before a restart; accept and log it.
console.log('[webhook] unknown invoice', invId, 'event', data.event);
return json(res, 200, { ok: true, unknown: true });
}
const event = String(data.event || '');
let status = normStatus(data.status || rec.status);
if (event === 'InvoiceSettled' || event === 'invoice_settled') status = 'Paid';
if (event === 'InvoiceExpired') status = 'Expired';
if (event === 'InvoiceInvalid') status = 'Expired';
updateInvoice(rec.id, status, { event, receivedAt: Date.now() });
console.log(`[webhook] ${rec.id} ${event} → ${rec.status}`);
return json(res, 200, { ok: true });
}
/* ---------- everything else: static site ---------- */
if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res, url);
res.writeHead(405, { 'Access-Control-Allow-Origin': '*' });
return res.end('method not allowed');
} catch (e) {
const code = e.status || 500;
console.error('[error]', e.message);
return json(res, code, { error: e.message || 'internal error' });
}
});
function persistConfig() {
const cur = loadEnv();
saveEnv({
BTCPAY_URL: cfg.url,
BTCPAY_STORE: cfg.store,
BTCPAY_API_KEY: cfg.apiKey,
WEBHOOK_SECRET: cfg.webhookSecret,
WEBHOOK_PUBLIC_URL: cfg.publicUrl,
...pick(cur, ['PORT', 'DATA_FILE']),
});
}
function readBody(req) {
return new Promise((resolve, reject) => {
let data = '';
req.on('data', c => { data += c; if (data.length > 1e6) { reject(new Error('body too large')); req.destroy(); } });
req.on('end', () => { try { resolve(data ? JSON.parse(data) : {}); } catch { reject(new Error('bad json')); } });
req.on('error', reject);
});
}
function readRaw(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', c => chunks.push(c));
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
function pick(obj, keys) {
const out = {};
for (const k of keys) if (obj && obj[k] !== undefined) out[k] = obj[k];
return out;
}
function publicInvoice(rec) {
return {
id: rec.id, orderCode: rec.orderCode, amount: rec.amount, currency: rec.currency,
status: rec.status, bolt11: rec.bolt11 || null, btcAddress: rec.btcAddress || null,
checkoutUrl: rec.checkoutUrl || null, createdAt: rec.createdAt, updatedAt: rec.updatedAt,
};
}
function extractBolt11(inv) {
const pm = (inv.paymentMethods || []).find(x => x.cryptoCode === 'LIGHTNING');
return pm && (pm.bolt11 || (pm.data && pm.data.bolt11)) || null;
}
function extractBtcAddress(inv) {
const pm = (inv.paymentMethods || []).find(x => x.cryptoCode === 'BTC');
if (pm && pm.data && pm.data.address) return pm.data.address;
if (pm && pm.address) return pm.address;
if (typeof inv.paymentAddresses === 'string') return inv.paymentAddresses;
if (inv.paymentAddresses && inv.paymentAddresses.BTC) return inv.paymentAddresses.BTC;
return null;
}
/** BTCPay webhook signature: BTCPay-Sig: sha256=<hex hmac of raw body with secret> */
function verifyBtcpaySig(rawBody, sigHeader, secret) {
if (!sigHeader || !String(sigHeader).startsWith('sha256=')) return false;
const given = String(sigHeader).slice('sha256='.length);
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
try {
return crypto.timingSafeEqual(Buffer.from(given, 'hex'), Buffer.from(expected, 'hex'));
} catch { return false; }
}
server.listen(cfg.port, () => {
console.log(`KITCHEN 484 pay backend listening on :${cfg.port}`);
console.log(configured()
? `BTCPay: ${cfg.url} store=${cfg.store} webhookSecret=${cfg.webhookSecret ? 'set' : 'MISSING'}`
: 'BTCPay NOT configured — POST /api/settings with {url, store, apiKey}');
if (cfg.publicUrl) console.log(`Public origin for webhook: ${cfg.publicUrl}/api/btcpay/webhook`);
});