#!/usr/bin/env node import http from 'node:http'; import fs from 'node:fs/promises'; import path from 'node:path'; import { URL } from 'node:url'; import { buildMoneyGraph, destinationTree, originTree, summarizeLedger } from './moneyGraph.js'; import { loadLedger, resolveAccountOwnerHints } from './ledgerStore.js'; const args = parseArgs(process.argv.slice(2)); const ledgerPath = args.ledger || 'data/example-ledger.json'; const port = Number(args.port || 3910); const host = args.host || '127.0.0.1'; const webDir = path.resolve(args.web || 'web'); let ledger = resolveAccountOwnerHints(await loadLedger(ledgerPath)); let graph = buildMoneyGraph(ledger); const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.ico': 'image/x-icon', }; const server = http.createServer(async (req, res) => { try { const url = new URL(req.url, `http://${req.headers.host}`); res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } if (req.method === 'GET' && url.pathname === '/health') { return sendJson(res, { status: 'ok', service: 'kua-money-trace', ledger: ledgerPath }); } if (req.method === 'POST' && url.pathname === '/reload') { ledger = resolveAccountOwnerHints(await loadLedger(ledgerPath)); graph = buildMoneyGraph(ledger); return sendJson(res, { reloaded: true, summary: summarizeLedger(ledger, graph) }); } if (req.method === 'GET' && url.pathname === '/summary') { return sendJson(res, summarizeLedger(ledger, graph)); } if (req.method === 'GET' && url.pathname === '/entities') { return sendJson(res, { entities: ledger.entities }); } if (req.method === 'GET' && url.pathname === '/accounts') { return sendJson(res, { accounts: ledger.accounts }); } if (req.method === 'GET' && url.pathname === '/movements') { return sendJson(res, { movements: ledger.movements }); } const originMatch = url.pathname.match(/^\/nodes\/(.+)\/origin-tree$/); if (req.method === 'GET' && originMatch) { return sendJson(res, originTree(graph, decodeURIComponent(originMatch[1]))); } const destinationMatch = url.pathname.match(/^\/nodes\/(.+)\/destination-tree$/); if (req.method === 'GET' && destinationMatch) { return sendJson(res, destinationTree(graph, decodeURIComponent(destinationMatch[1]))); } // static files from web/ if (req.method === 'GET') { const filePath = path.join(webDir, url.pathname === '/' ? 'dashboard.html' : url.pathname); const realPath = path.resolve(filePath); if (!realPath.startsWith(webDir + path.sep) && realPath !== webDir) { return sendJson(res, { error: 'forbidden' }, 403); } try { const data = await fs.readFile(realPath); const ext = path.extname(realPath); res.writeHead(200, { 'content-type': MIME[ext] || 'application/octet-stream' }); res.end(data); return; } catch { // fall through to 404 } } sendJson(res, { error: 'not found' }, 404); } catch (error) { sendJson(res, { error: error.message }, 500); } }); server.listen(port, host, () => { console.log(`kua-money-trace listening on http://${host}:${port}`); console.log(` dashboard: http://${host}:${port}/dashboard.html`); console.log(` ledger: ${ledgerPath}`); }); function sendJson(res, body, status = 200) { res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(body, null, 2)); } function parseArgs(argv) { const parsed = {}; for (let i = 0; i < argv.length; i += 1) { if (argv[i].startsWith('--')) { parsed[argv[i].slice(2)] = argv[i + 1]; i += 1; } } return parsed; }