← back to Settlement Review

server.js

108 lines

// settlement-review — password-protected settlement verdict recorder.
// Zero dependencies. Serves a static snapshot (data/items.json) and records
// decisions to an append-only log + current-state map OUTSIDE the deploy tree
// (so an rsync --delete deploy can never wipe them).
'use strict';
const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');

const PORT = parseInt(process.env.PORT || '9403', 10);
const HOST = process.env.HOST || '127.0.0.1';
const USER = process.env.AUTH_USER || 'admin';
const PASS = process.env.AUTH_PASS || 'DW2024!';
const STATE_DIR = process.env.STATE_DIR || path.join(__dirname, '..', 'settlement-review-state');
const LOG_FILE = path.join(STATE_DIR, 'decisions.log.jsonl');
const MAP_FILE = path.join(STATE_DIR, 'decisions.json');
const PUB = path.join(__dirname, 'public');
const VERDICTS = new Set(['APPROVE', 'EXCLUDE', 'UNSURE']);

fs.mkdirSync(STATE_DIR, { recursive: true });
const snapshot = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'items.json'), 'utf8'));
const itemByKey = new Map(snapshot.items.map((i) => [i.key, i]));

let decisions = {};
try { decisions = JSON.parse(fs.readFileSync(MAP_FILE, 'utf8')); } catch (_) { decisions = {}; }

function saveMap() {
  const tmp = MAP_FILE + '.tmp';
  fs.writeFileSync(tmp, JSON.stringify(decisions, null, 1));
  fs.renameSync(tmp, MAP_FILE);
}

function authed(req) {
  const h = req.headers.authorization || '';
  if (!h.startsWith('Basic ')) return false;
  const [u, ...rest] = Buffer.from(h.slice(6), 'base64').toString('utf8').split(':');
  const p = rest.join(':');
  const eq = (a, b) => a.length === b.length && crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
  return eq(u, USER) && eq(p, PASS);
}

function send(res, code, body, type = 'application/json; charset=utf-8', extra = {}) {
  res.writeHead(code, { 'Content-Type': type, 'Cache-Control': 'no-store', 'X-Robots-Tag': 'noindex, nofollow', ...extra });
  res.end(typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body));
}

function readBody(req) {
  return new Promise((resolve, reject) => {
    let b = '';
    req.on('data', (c) => { b += c; if (b.length > 1e5) { reject(new Error('too large')); req.destroy(); } });
    req.on('end', () => resolve(b));
    req.on('error', reject);
  });
}

const PAGES = new Set(['/', '/jstevens', '/review', '/errors', '/prohibited', '/decided']);
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'application/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8' };

const server = http.createServer(async (req, res) => {
  const url = new URL(req.url, 'http://x');
  const p = url.pathname;
  if (!authed(req)) {
    return send(res, 401, 'Authentication required', 'text/plain', { 'WWW-Authenticate': 'Basic realm="Settlement Review"' });
  }
  try {
    if (req.method === 'GET' && p === '/healthz') return send(res, 200, { ok: true, items: snapshot.items.length, decided: Object.keys(decisions).length });
    if (req.method === 'GET' && p === '/api/items') return send(res, 200, snapshot);
    if (req.method === 'GET' && p === '/api/decisions') return send(res, 200, decisions);
    if (req.method === 'GET' && p === '/api/decisions.json') {
      const current = Object.entries(decisions).map(([key, d]) => {
        const it = itemByKey.get(key) || {};
        return { key, ...d, queue: it.queue, title: it.title, sku: it.sku, vendor: it.vendor, source: it.source, admin: it.admin || null };
      });
      let log = [];
      try { log = fs.readFileSync(LOG_FILE, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l)); } catch (_) {}
      return send(res, 200, { exported_at: new Date().toISOString(), snapshot_generated_at: snapshot.generated_at, counts: snapshot.counts, decided: current.length, current, log }, 'application/json; charset=utf-8', { 'Content-Disposition': 'inline; filename="decisions.json"' });
    }
    if (req.method === 'POST' && p === '/api/decision') {
      const body = JSON.parse(await readBody(req) || '{}');
      const key = String(body.key || '');
      if (!itemByKey.has(key)) return send(res, 400, { error: 'unknown key' });
      const verdict = body.verdict === null || body.verdict === 'CLEAR' ? null : String(body.verdict || '').toUpperCase();
      if (verdict !== null && !VERDICTS.has(verdict)) return send(res, 400, { error: 'verdict must be APPROVE|EXCLUDE|UNSURE|CLEAR' });
      const note = String(body.note || '').slice(0, 2000);
      const at = new Date().toISOString();
      const by = String(body.by || USER).slice(0, 60);
      fs.appendFileSync(LOG_FILE, JSON.stringify({ at, key, verdict: verdict || 'CLEAR', note, by, prev: decisions[key] ? decisions[key].verdict : null }) + '\n');
      if (verdict) decisions[key] = { verdict, note, at, by };
      else delete decisions[key];
      saveMap();
      return send(res, 200, { ok: true, key, decision: decisions[key] || null });
    }
    if (req.method === 'GET' && PAGES.has(p)) return send(res, 200, fs.readFileSync(path.join(PUB, 'index.html')), MIME['.html']);
    if (req.method === 'GET') {
      const f = path.normalize(path.join(PUB, p));
      if (f.startsWith(PUB + path.sep) && fs.existsSync(f) && fs.statSync(f).isFile()) {
        return send(res, 200, fs.readFileSync(f), MIME[path.extname(f)] || 'application/octet-stream');
      }
    }
    return send(res, 404, { error: 'not found' });
  } catch (e) {
    return send(res, 500, { error: String(e.message || e) });
  }
});

server.listen(PORT, HOST, () => console.log(`settlement-review on http://${HOST}:${PORT} — ${snapshot.items.length} items, state ${STATE_DIR}`));