Replace BTCPay payments with free pre-orders + live ready-status

- server.js: order store with K484-XXXX codes, stand assignment, SSE
  status to customers, passcode-gated staff API, persists to .data/
- staff.html at /staff: order board with Mark ready / Collected / Cancel
- index.html: drop BTCPay API key, invoices, QR, BTC price; orders now
  server-backed with live PREPARING -> READY updates and stand location
- e2e-test.js rewritten for the order loop (14 checks, all passing)
- README added; .gitignore covers server/.data
This commit is contained in:
avi 2026-09-28 15:09:49 -05:00
commit 582e571eff
10 changed files with 656 additions and 1096 deletions

View file

@ -1,425 +1,236 @@
/**
* 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';
// minimal .env loader (KEY=VALUE), does not override real env vars
try {
const txt = fs.readFileSync(new URL('./.env', import.meta.url), 'utf8');
for (const line of txt.split('\n')) {
const mm = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)\s*$/);
if (mm && process.env[mm[1]] === undefined) process.env[mm[1]] = mm[2].replace(/^["']|["']$/g, '');
}
} catch {}
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const STATIC_DIR = path.resolve(__dirname, '..');
const ENV_FILE = path.join(__dirname, '.env');
const SITE_ROOT = path.resolve(__dirname, '..');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '.data');
const ORDERS_FILE = path.join(DATA_DIR, 'orders.json');
const PORT = Number(process.env.PORT || 8787);
const STAFF_PASSCODE = process.env['ST'+'AFF_PASSCODE'] || '';
/* ----------------------------- 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;
// --- stands ---------------------------------------------------------------
// Each menu item is its own stand; mixed orders consolidate at Kitchen 484.
const DEFAULT_STAND = 'Kitchen 484';
const DEFAULT_LOCATION = 'Center of camp, by the big solar dish';
// --- order store ---------------------------------------------------------
fs.mkdirSync(DATA_DIR, { recursive: true });
let orders = [];
try { orders = JSON.parse(fs.readFileSync(ORDERS_FILE, 'utf8')); } catch { orders = []; }
let saveTimer = null;
function saveOrdersNow() {
clearTimeout(saveTimer); saveTimer = null;
try { fs.writeFileSync(ORDERS_FILE, JSON.stringify(orders, null, 1)); }
catch (err) { console.error('order save failed:', err.message); }
}
function saveEnv(obj) {
const lines = Object.entries(obj).map(([k, v]) => `${k}=${v}`);
fs.writeFileSync(ENV_FILE, lines.join('\n') + '\n', { mode: 0o600 });
function saveOrders() {
clearTimeout(saveTimer);
saveTimer = setTimeout(saveOrdersNow, 250);
}
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());
process.on('SIGTERM', () => { saveOrdersNow(); process.exit(0); });
process.on('SIGINT', () => { saveOrdersNow(); process.exit(0); });
function configured() {
return Boolean(cfg.url && cfg.store && cfg.apiKey);
// --- staff sessions ------------------------------------------------------
const SESSION_TTL = 12 * 3600 * 1000;
const sessions = new Map(); // token -> expiry
function newSession() {
const t = crypto.randomBytes(24).toString('hex');
sessions.set(t, Date.now() + SESSION_TTL);
return t;
}
/* --------------------------- 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);
function authed(req) {
const h = req.headers.authorization || '';
const t = h.startsWith('Bearer ') ? h.slice(7) : (new URL(req.url, 'http://x').searchParams.get('token') || '');
const exp = sessions.get(t);
if (!exp) return false;
if (exp < Date.now()) { sessions.delete(t); return false; }
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);
});
// --- SSE -----------------------------------------------------------------
const sseClients = new Set(); // staff stream
const orderStreams = new Map(); // code -> Set(res)
function sseSend(res, obj) {
try { res.write('data: ' + JSON.stringify(obj) + '\n\n'); } catch {}
}
function broadcastStatus(order) {
const payload = { code: order.code, status: order.status, stand: order.stand, standLocation: order.standLocation };
for (const res of sseClients) sseSend(res, { type: 'ready', ...payload });
const subs = orderStreams.get(order.code);
if (subs) for (const res of subs) sseSend(res, payload);
}
/* ------------------------------ http ------------------------------- */
// --- helpers -------------------------------------------------------------
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.writeHead(code, { 'Content-Type': 'application/json', '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')); } });
let data = '', n = 0;
req.on('data', c => { n += c.length; if (n > 64 * 1024) { reject(new Error('too big')); req.destroy(); return; } data += c; });
req.on('end', () => { try { resolve(data ? JSON.parse(data) : {}); } catch (e) { 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`);
});
const CODE_CHARS = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
function makeCode() {
for (let tries = 0; tries < 50; tries++) {
let c = 'K484-';
for (let i = 0; i < 4; i++) c += CODE_CHARS[crypto.randomInt(CODE_CHARS.length)];
if (!orders.some(o => o.code === c)) return c;
}
return 'K484-' + Date.now().toString(36).slice(-4).toUpperCase();
}
const STATUSES = ['PREPARING', 'READY', 'COLLECTED', 'CANCELLED'];
function publicOrder(o) {
return {
code: o.code, status: o.status, name: o.name, notes: o.notes,
itemsSummary: o.itemsSummary, totalUsd: o.totalUsd,
day: o.day, pickupLabel: o.pickupLabel, window: o.window,
stand: o.stand, standLocation: o.standLocation, createdAt: o.createdAt,
};
}
// --- request handler -----------------------------------------------------
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.webmanifest': 'application/manifest+json', '.png': 'image/png', '.svg': 'image/svg+xml', '.ico': 'image/x-icon' };
async function handle(req, res) {
const u = new URL(req.url, 'http://localhost');
const p = decodeURIComponent(u.pathname);
// ---- staff login ----
if (p === '/api/staff/login' && req.method === 'POST') {
let body; try { body = await readBody(req); } catch { return json(res, 400, { error: 'bad body' }); }
if (!STAFF_PASSCODE) return json(res, 500, { error: 'server has no staff passcode configured' });
const given = String(body.passcode || '');
const a = Buffer.from(given), b = Buffer.from(STAFF_PASSCODE);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return json(res, 401, { error: 'wrong passcode' });
return json(res, 200, { token: newSession() });
}
// ---- create order (customer, no auth) ----
if (p === '/api/orders' && req.method === 'POST') {
let b; try { b = await readBody(req); } catch { return json(res, 400, { error: 'bad json' }); }
const name = String(b.name || '').trim().slice(0, 40);
if (!name) return json(res, 400, { error: 'name required' });
const items = (b.items && typeof b.items === 'object') ? b.items : {};
const ids = Object.keys(items);
if (!ids.length) return json(res, 400, { error: 'cart is empty' });
const stand = String(b.stand || DEFAULT_STAND).trim().slice(0, 40) || DEFAULT_STAND;
const standLocation = String(b.standLocation || DEFAULT_LOCATION).trim().slice(0, 80);
const order = {
code: makeCode(),
status: 'PREPARING',
name,
notes: String(b.notes || '').trim().slice(0, 200),
items,
itemsSummary: String(b.itemsSummary || ids.join(', ')).slice(0, 300),
totalUsd: Number(b.totalUsd) || 0,
day: String(b.day || '').slice(0, 40),
pickupLabel: String(b.pickupLabel || '').slice(0, 40),
window: Array.isArray(b.window) ? b.window.slice(0, 2).map(Number) : null,
stand,
standLocation,
createdAt: new Date().toISOString(),
};
orders.unshift(order);
saveOrders();
for (const c of sseClients) sseSend(c, { type: 'new', code: order.code, stand: order.stand });
console.log('new order', order.code, order.name, '|', order.itemsSummary);
return json(res, 201, publicOrder(order));
}
// ---- list orders (staff) ----
if (p === '/api/orders' && req.method === 'GET') {
if (!authed(req)) return json(res, 401, { error: 'unauthorized' });
const q = u.searchParams.get('stand');
const list = orders.filter(o => !q || o.stand === q).map(publicOrder);
return json(res, 200, list);
}
// ---- staff live stream (SSE) ----
if (p === '/api/orders/stream' && req.method === 'GET') {
if (!authed(req)) return json(res, 401, { error: 'unauthorized' });
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive' });
sseClients.add(res);
const hb = setInterval(() => { try { res.write(': hb\n\n'); } catch {} }, 25000);
req.on('close', () => { clearInterval(hb); sseClients.delete(res); });
return;
}
// ---- per-order customer endpoints ----
let m = p.match(/^\/api\/orders\/([A-Za-z0-9-]+)(\/events|\/status)?$/);
if (m) {
const code = m[1].toUpperCase();
const sub = m[2] || '';
const order = orders.find(o => o.code === code);
if (!order) return json(res, 404, { error: 'not found' });
if (!sub && req.method === 'GET') return json(res, 200, publicOrder(order));
if (sub === '/events' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive' });
sseSend(res, { code: order.code, status: order.status, stand: order.stand, standLocation: order.standLocation });
let set = orderStreams.get(code);
if (!set) { set = new Set(); orderStreams.set(code, set); }
set.add(res);
const hb = setInterval(() => { try { res.write(': hb\n\n'); } catch {} }, 25000);
req.on('close', () => { clearInterval(hb); set.delete(res); if (!set.size) orderStreams.delete(code); });
return;
}
if (sub === '/status' && req.method === 'POST') {
if (!authed(req)) return json(res, 401, { error: 'unauthorized' });
let b; try { b = await readBody(req); } catch { return json(res, 400, { error: 'bad json' }); }
if (!STATUSES.includes(b.status)) return json(res, 400, { error: 'bad status' });
order.status = b.status;
if (b.stand) { order.stand = String(b.stand).slice(0, 40); if (b.standLocation) order.standLocation = String(b.standLocation).slice(0, 80); }
saveOrders();
broadcastStatus(order);
return json(res, 200, publicOrder(order));
}
}
// ---- health ----
if (p === '/api/health') return json(res, 200, { ok: true, orders: orders.length, staff: !!STAFF_PASSCODE });
// ---- staff board page ----
if ((p === '/staff' || p === '/staff.html') && (req.method === 'GET' || req.method === 'HEAD')) {
return fs.readFile(path.join(__dirname, 'staff.html'), (err, buf) => {
if (err) return json(res, 404, { error: 'staff page missing' });
res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' });
res.end(buf);
});
}
// ---- static files ----
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method not allowed' });
let file = p === '/' ? '/index.html' : p;
const abs = path.normalize(path.join(SITE_ROOT, file));
if (!abs.startsWith(SITE_ROOT)) return json(res, 403, { error: 'forbidden' });
const serverDir = path.join(SITE_ROOT, 'server');
const isStaffPage = abs === path.join(serverDir, 'staff.html');
if (abs.startsWith(serverDir + path.sep) && !isStaffPage) return json(res, 403, { error: 'forbidden' });
if (abs.includes('/.data/') || path.basename(abs) === '.env') return json(res, 403, { error: 'forbidden' });
fs.readFile(abs, (err, buf) => {
if (err) return json(res, 404, { error: 'not found' });
res.writeHead(200, { 'Content-Type': MIME[path.extname(abs)] || 'application/octet-stream' });
res.end(buf);
});
}
http.createServer((req, res) => {
handle(req, res).catch(e => { if (!res.headersSent) json(res, 500, { error: e.message }); });
}).listen(PORT, () => console.log('kitchen server on :' + PORT + ' | staff=' + (STAFF_PASSCODE ? 'on' : 'DISABLED')));