[object Object]

← back to Approvals Viewer

approvals-viewer: yes/no board for the pending-approval queue

be8f9d315acbda12e65950b4532ea296bc761a77 · 2026-07-28 09:10:41 -0700 · Steve Abrams

Zero-dep Node viewer (basic auth admin/DW2024!) on :9792. Reads
~/.claude/yolo-queue/pending-approval/*.md, shows gate/DTD/reversibility +
Claude's recommendation, and records Approve/Defer/Block per draft to
decisions.json, mirrored into cncp-approvals-state.json. Decision-capture only
— nothing is executed. Pre-seeded with the 15 quick-triage batch-1 approvals.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit be8f9d315acbda12e65950b4532ea296bc761a77
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jul 28 09:10:41 2026 -0700

    approvals-viewer: yes/no board for the pending-approval queue
    
    Zero-dep Node viewer (basic auth admin/DW2024!) on :9792. Reads
    ~/.claude/yolo-queue/pending-approval/*.md, shows gate/DTD/reversibility +
    Claude's recommendation, and records Approve/Defer/Block per draft to
    decisions.json, mirrored into cncp-approvals-state.json. Decision-capture only
    — nothing is executed. Pre-seeded with the 15 quick-triage batch-1 approvals.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .gitignore |   4 +
 recs.json  | 274 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js  | 189 ++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 467 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1bcdbdc
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+.env*
+*.log
+.DS_Store
diff --git a/recs.json b/recs.json
new file mode 100644
index 0000000..980644a
--- /dev/null
+++ b/recs.json
@@ -0,0 +1,274 @@
+{
+  "072226A-stroheim-onboarding-stage": {
+    "rec": "approve",
+    "why": "scrape\u2192staging only, no live push, reversible, DTD unanimous"
+  },
+  "2026-07-27-alldw-livecheck-async-poll-deploy": {
+    "rec": "approve",
+    "why": "internal all.dw feature deploy, reversible"
+  },
+  "AAA-CADENCE-POLICY-vendor-collection-pushes": {
+    "rec": "approve",
+    "why": "standing process guardrail, reversible"
+  },
+  "TK-00050-null-negative-sale-prices": {
+    "rec": "approve",
+    "why": "null 72 bad rows in LOCAL mirror, reversible"
+  },
+  "TK-00050-null-placeholder-junk-owner-names": {
+    "rec": "approve",
+    "why": "null 1,785 junk rows in LOCAL mirror, reversible"
+  },
+  "dw-tiktok-audit-submission-steps": {
+    "rec": "approve",
+    "why": "prep/steps doc, low-risk"
+  },
+  "maharam-budget-fix-canary-2026-07-27": {
+    "rec": "approve",
+    "why": "optional live canary, reversible, DTD-A"
+  },
+  "tk135-color-palette-approval": {
+    "rec": "approve",
+    "why": "palette\u2192metafield+tag, reversible enrichment"
+  },
+  "wpb-pull-31-smeared-inpaints": {
+    "rec": "approve",
+    "why": "defect fix, reversible (republishable), DTD-A"
+  },
+  "2026-07-12-pr-quote-only": {
+    "rec": "approve",
+    "why": "quote-only tagging, DTD-C, reversible"
+  },
+  "2026-07-15-alldw-live-price-button-deploy": {
+    "rec": "approve",
+    "why": "internal all.dw feature, DTD-A, reversible"
+  },
+  "2026-07-27-greenland-canonical-301-redirect": {
+    "rec": "approve",
+    "why": "internal subdomain canonicalization, reversible, unanimous"
+  },
+  "070926-gmc-orphan-reconcile-2026-07-27": {
+    "rec": "block",
+    "why": "superseded by the 07-28 version"
+  },
+  "dedup-DRYRUN-20260727": {
+    "rec": "block",
+    "why": "superseded by the 07-28 dry-run"
+  },
+  "TK-00058-vendor-bucket-verified-scorecard-2026-07-27": {
+    "rec": "block",
+    "why": "superseded by the 07-28 decision memo"
+  },
+  "dedup-LIVE-flag-status-TK114-20260727": {
+    "rec": "block",
+    "why": "superseded by the 07-28 review-triage"
+  },
+  "gmc-degraded-remediation-20260713": {
+    "rec": "block",
+    "why": "likely superseded by TK-00066 feed remediation \u2014 verify"
+  },
+  "spechomes-golive-2026-07-22": {
+    "rec": "block",
+    "why": "superseded by TK-10-spechomes-golive \u2014 verify"
+  },
+  "070926-gmc-orphan-reconcile-2026-07-28": {
+    "rec": "defer",
+    "why": "current GMC orphan reconcile (destructive) \u2014 your call"
+  },
+  "2026-07-22-task6-stout-wallcoverings-onboard": {
+    "rec": "defer",
+    "why": "vendor onboard to collection \u2014 verify pricing first"
+  },
+  "2026-07-27-TK-9-illinois-foia-cook-county-re-licensees": {
+    "rec": "defer",
+    "why": "external FOIA send \u2014 your call"
+  },
+  "2026-07-27-banned-word-wallpaper": {
+    "rec": "defer",
+    "why": "customer-facing canonical, DTD verdict B (mixed)"
+  },
+  "2026-07-27-fentucci-tokiwa-width-reconcile": {
+    "rec": "defer",
+    "why": "go-live; you picked the path but it's a live cutover"
+  },
+  "2026-07-28-dw-signup-fulfillment-golive": {
+    "rec": "defer",
+    "why": "customer-facing go-live"
+  },
+  "2026-07-28-stale-needs-flags": {
+    "rec": "defer",
+    "why": "bulk Shopify write \u2014 confirm tag isn't load-bearing first"
+  },
+  "260727A-astek-cloudflare-access-migration": {
+    "rec": "defer",
+    "why": "infra fleet pilot, DTD-A leans yes"
+  },
+  "TK-10-blocker-A-secret-scrub-runbook": {
+    "rec": "defer",
+    "why": "GoDaddy key rotation + history scrub \u2014 serious/destructive"
+  },
+  "TK-10-homesonspec-golive-approval": {
+    "rec": "defer",
+    "why": "prod DNS cutover"
+  },
+  "TK-10-spechomes-golive-approval": {
+    "rec": "defer",
+    "why": "prod deploy (DTD unanimous); pairs w/ homesonspec"
+  },
+  "TK-12-nineoh-appstore-testflight": {
+    "rec": "defer",
+    "why": "App Store submit \u2014 external, your call"
+  },
+  "TK-22-consulting-portal-deploy-consolidated": {
+    "rec": "defer",
+    "why": "prod deploy"
+  },
+  "TK-50-field-provenance": {
+    "rec": "defer",
+    "why": "prod Kamatera migration (the one wrongly closed earlier)"
+  },
+  "alert-etc-systemd-system-service-drift": {
+    "rec": "defer",
+    "why": "security drift \u2014 review (may be benign re-baseline)"
+  },
+  "alert-romo-roll-verify": {
+    "rec": "defer",
+    "why": "security/verify alert \u2014 review"
+  },
+  "astek-pw-rotation-slack-scope": {
+    "rec": "defer",
+    "why": "auth cutover + Slack scope"
+  },
+  "dedup-DRYRUN-20260728": {
+    "rec": "defer",
+    "why": "review before any live dedup"
+  },
+  "dedup-REVIEW-triage-TK114-20260728": {
+    "rec": "defer",
+    "why": "needs your row-level triage"
+  },
+  "discontinued-agent-queue-19f3e7bb91130c6c-2026-07-08T16-13-19-164Z": {
+    "rec": "defer",
+    "why": "old review item \u2014 decide or archive"
+  },
+  "dw-consulting-engagement-work-under-tick": {
+    "rec": "defer",
+    "why": "deploy (vague memo \u2014 can re-summarize)"
+  },
+  "dw-prwn-debadge-shopify": {
+    "rec": "defer",
+    "why": "canonical image write, 36 items"
+  },
+  "dw-reels-tiktok-golive": {
+    "rec": "defer",
+    "why": "blocked on TikTok's app audit anyway"
+  },
+  "fm-mfr-backfill-creds": {
+    "rec": "defer",
+    "why": "blocked on Claris creds regardless"
+  },
+  "linkedin-company-posting-pending-review": {
+    "rec": "defer",
+    "why": "blocked on LinkedIn's approval externally"
+  },
+  "model-arena-daily-challenge": {
+    "rec": "defer",
+    "why": "installs a scheduled job \u2014 your call"
+  },
+  "momentum-301-followup-writecontent": {
+    "rec": "defer",
+    "why": "blocked on write_content scope regardless"
+  },
+  "phase-2-osborne-little-canary-2026-07-27": {
+    "rec": "defer",
+    "why": "pick target (Hollywood vs Osborne)"
+  },
+  "rubikscube-sso-gate-decision": {
+    "rec": "defer",
+    "why": "auth posture \u2014 your call"
+  },
+  "versa-handle-rename-scope": {
+    "rec": "defer",
+    "why": "blocked on Shopify token scope regardless"
+  },
+  "york-cadence-actions": {
+    "rec": "defer",
+    "why": "large reprice/cadence set \u2014 review"
+  },
+  "2026-07-12-houston-reactivation-candidates": {
+    "rec": "defer",
+    "why": "review the reactivation list"
+  },
+  "2026-07-27-gmc-a1-canary-followup": {
+    "rec": "defer",
+    "why": "tracking/wait item (pending Google re-crawl)"
+  },
+  "2026-07-27-sku-collisions": {
+    "rec": "defer",
+    "why": "live-store SKU fix \u2014 serious"
+  },
+  "2026-07-28-gmc-feed-remediation-TK00066-ready-to-run": {
+    "rec": "defer",
+    "why": "ready-to-run \u2014 your go"
+  },
+  "2026-07-28-sku-collisions-phase2-scope": {
+    "rec": "defer",
+    "why": "scope memo \u2014 review the 363 set"
+  },
+  "TK-00058-vendor-bucket-decision-memo-2026-07-28": {
+    "rec": "defer",
+    "why": "vendor bucket approve/reject \u2014 your call, DTD-B"
+  },
+  "imageless-14-source-images-2026-07-16": {
+    "rec": "defer",
+    "why": "needs you to source 14 images"
+  },
+  "maharam-followups-2026-07-22": {
+    "rec": "defer",
+    "why": "collection publish + onboarding"
+  },
+  "tk17-batched-execution-approval": {
+    "rec": "defer",
+    "why": "large canonical catalog write"
+  },
+  "work-orders-sample-only-backlog-execution": {
+    "rec": "defer",
+    "why": "likely overlaps tk17 \u2014 consolidate"
+  },
+  "2026-07-17-cc-import-fm-clients": {
+    "rec": "defer",
+    "why": "precursor to a list send, DTD unanimous"
+  },
+  "2026-07-27-cc-fm-clients-FIRST-SEND-gate": {
+    "rec": "defer",
+    "why": "first send to a list \u2014 highest-caution comms"
+  },
+  "usrealestate-listing-blanket-ingest": {
+    "rec": "defer",
+    "why": "blanket ingest across all IDX firms \u2014 scope call"
+  },
+  "2026-07-27-git-history-purge-TK-13-refresh": {
+    "rec": "defer",
+    "why": "destructive; the live in-flight purge \u2014 don't rush"
+  },
+  "TK-00071-ideas-agentabrams-dns-01-conversion": {
+    "rec": "defer",
+    "why": "DNS-01 cert conversion"
+  },
+  "2026-07-14-marketing-dw-connect-ig-tiktok": {
+    "rec": "defer",
+    "why": "social go-live"
+  },
+  "2026-07-12-prod-45.61.58.125-security-hardening": {
+    "rec": "defer",
+    "why": "post-incident hardening, 2 gated items \u2014 review"
+  },
+  "refine-wpb-de-badge-coverage-to-done-tic": {
+    "rec": "defer",
+    "why": "wpb de-badge coverage (vague memo)"
+  },
+  "rentv-fork-consolidation": {
+    "rec": "defer",
+    "why": "consolidates a live client site \u2014 destructive"
+  }
+}
\ No newline at end of file
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..885c2a0
--- /dev/null
+++ b/server.js
@@ -0,0 +1,189 @@
+#!/usr/bin/env node
+// Approvals Viewer — a yes/no board for the gated pending-approval queue.
+// Reads ~/.claude/yolo-queue/pending-approval/*.md, shows each draft's gate / DTD /
+// reversibility + Claude's recommendation, and lets Steve decide Approve / Defer /
+// Block per item. Decisions are written to decisions.json AND mirrored into the
+// CNCP board state (cncp-approvals-state.json) so all surfaces agree. NOTHING is
+// executed here — this captures the decision only; execution stays separate/gated.
+// Zero external deps. Basic auth admin/DW2024!. Built 2026-07-28.
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+
+const HOME = process.env.HOME;
+const PA_DIR = path.join(HOME, '.claude', 'yolo-queue', 'pending-approval');
+const DECISIONS = path.join(HOME, '.claude', 'yolo-queue', 'decisions.json');
+const CNCP_STATE = path.join(HOME, 'cncp-starter', 'cncp-approvals-state.json');
+const RECS = path.join(__dirname, 'recs.json');
+const PORT = Number(process.env.PORT || 9792);
+const USER = 'admin', PASS = process.env.BASIC_AUTH_PASS || 'DW2024!';
+
+const loadJSON = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
+const saveJSON = (f, o) => { const t = f + '.tmp'; fs.writeFileSync(t, JSON.stringify(o, null, 2)); fs.renameSync(t, f); };
+
+function rateGate(b) {
+  const m = (b.match(/\*\*Gate[^\n:]*:?\*\*\s*([^\n]+)/i) || b.match(/gate[:\s]+([^\n·]+)/i));
+  if (m) return m[1].replace(/[*`]/g, '').trim().slice(0, 40);
+  if (/DNS|domain|301-redirect|cutover|go-live/i.test(b)) return 'DNS / go-live';
+  if (/send|foia|email list|constant contact|linkedin/i.test(b)) return 'send / comms';
+  if (/shopify|metafield|collection|tag/i.test(b)) return 'Shopify write';
+  if (/destructive|purge|delete|drop /i.test(b)) return 'destructive';
+  return 'other';
+}
+function dtd(b) {
+  if (/unanimous/i.test(b)) return 'unanimous';
+  const m = b.match(/verdict\s+([A-C])/i);
+  return m ? 'verdict ' + m[1].toUpperCase() : '';
+}
+function drafts() {
+  const decs = loadJSON(DECISIONS, {});
+  const recs = loadJSON(RECS, {});
+  let files = [];
+  try { files = fs.readdirSync(PA_DIR).filter(f => f.endsWith('.md')); } catch {}
+  return files.map(f => {
+    const slug = f.replace(/\.md$/, '');
+    let body = ''; let mtime = 0;
+    try { body = fs.readFileSync(path.join(PA_DIR, f), 'utf8'); mtime = fs.statSync(path.join(PA_DIR, f)).mtimeMs; } catch {}
+    const title = (body.match(/^#\s*(.+)/m) || [0, slug])[1].replace(/[*`]/g, '').slice(0, 140);
+    const rec = recs[slug] || { rec: 'defer', why: 'no recommendation on file — your call' };
+    return {
+      slug, title, gate: rateGate(body), dtd: dtd(body),
+      reversible: /reversible|♻️/i.test(body),
+      destructive: /destructive|purge|\bdelete\b|\bdrop\b/i.test(body),
+      mtime, rec: rec.rec, why: rec.why,
+      decision: (decs[slug] && decs[slug].decision) || 'undecided',
+      body,
+    };
+  }).sort((a, b) => (a.decision === 'undecided' ? -1 : 1) - (b.decision === 'undecided' ? -1 : 1) || b.mtime - a.mtime);
+}
+function decide(slug, decision) {
+  const decs = loadJSON(DECISIONS, {});
+  if (decision === 'undecided') delete decs[slug];
+  else decs[slug] = { decision, at: Date.now() };
+  saveJSON(DECISIONS, decs);
+  // mirror into CNCP board so the dashboard agrees (approve->done view, block->rejected, defer->pending)
+  try {
+    const st = loadJSON(CNCP_STATE, {});
+    const map = { approve: 'done', block: 'rejected', defer: 'pending', undecided: 'pending' };
+    const f = slug + '.md';
+    st[f] = { status: map[decision] || 'pending', at: Date.now(), note: 'approvals-viewer: ' + decision, source: 'approvals-viewer' };
+    saveJSON(CNCP_STATE, st);
+  } catch {}
+  return true;
+}
+
+const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
+const RECCOLOR = { approve: '#16a34a', defer: '#d97706', block: '#dc2626' };
+const DECCOLOR = { approve: '#16a34a', defer: '#6b7280', block: '#dc2626', undecided: '#334155' };
+
+function page() {
+  return `<!doctype html><html><head><meta charset=utf8><meta name=viewport content="width=device-width,initial-scale=1">
+<title>Approvals — yes / no</title><style>
+*{box-sizing:border-box}body{margin:0;font:15px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;background:#0b1020;color:#e7ecf5}
+header{position:sticky;top:0;background:#0b1020ee;backdrop-filter:blur(6px);padding:14px 18px;border-bottom:1px solid #1e293b;z-index:5}
+h1{margin:0;font-size:18px}.sub{color:#94a3b8;font-size:13px;margin-top:2px}
+.bar{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px;align-items:center}
+.chip{padding:4px 10px;border-radius:999px;border:1px solid #334155;background:#0f172a;color:#cbd5e1;cursor:pointer;font-size:13px}
+.chip.on{background:#1d4ed8;border-color:#1d4ed8;color:#fff}
+.wrap{max-width:1000px;margin:0 auto;padding:16px}
+.card{background:#0f172a;border:1px solid #1e293b;border-radius:12px;padding:14px 16px;margin:10px 0}
+.card.approve{border-left:4px solid #16a34a}.card.block{border-left:4px solid #dc2626}.card.defer{border-left:4px solid #6b7280}
+.t{font-weight:600;margin:0 0 6px}.badges{display:flex;gap:6px;flex-wrap:wrap;margin:6px 0}
+.b{font-size:11px;padding:2px 8px;border-radius:999px;background:#1e293b;color:#cbd5e1}
+.rec{font-size:13px;margin:8px 0;padding:8px 10px;background:#0b1224;border-radius:8px}
+.btns{display:flex;gap:8px;margin-top:10px}
+button{border:0;border-radius:8px;padding:8px 16px;font-weight:600;cursor:pointer;font-size:14px}
+.yes{background:#16a34a;color:#fff}.no{background:#334155;color:#e2e8f0}.blk{background:#dc2626;color:#fff}
+.sel{outline:3px solid #fff3}
+.mtoggle{margin-top:8px;color:#60a5fa;cursor:pointer;font-size:13px}
+pre{white-space:pre-wrap;background:#060a16;border:1px solid #1e293b;border-radius:8px;padding:10px;max-height:340px;overflow:auto;font-size:12px;display:none}
+.count{font-weight:700}
+</style></head><body>
+<header><h1>🗂️ Approvals — answer yes / no</h1>
+<div class=sub id=summary>loading…</div>
+<div class=bar>
+  <span class=chip data-f=undecided>Undecided</span>
+  <span class=chip data-f=approve>Approved</span>
+  <span class=chip data-f=defer>Deferred</span>
+  <span class=chip data-f=block>Blocked</span>
+  <span class=chip data-f=all>All</span>
+  <span style="flex:1"></span>
+  <span class=chip id=export>Copy decisions</span>
+</div></header>
+<div class=wrap id=list></div>
+<script>
+let DATA=[], FILTER='undecided';
+async function load(){ DATA=await (await fetch('/api/drafts')).json(); render(); }
+function counts(){ const c={undecided:0,approve:0,defer:0,block:0}; DATA.forEach(d=>c[d.decision]++); return c; }
+function render(){
+  const c=counts();
+  document.getElementById('summary').innerHTML=
+    '<span class=count>'+DATA.length+'</span> drafts · '+
+    '<b style="color:#f8fafc">'+c.undecided+'</b> undecided · '+
+    '<b style="color:#16a34a">'+c.approve+'</b> approved · '+
+    '<b style="color:#9ca3af">'+c.defer+'</b> deferred · '+
+    '<b style="color:#f87171">'+c.block+'</b> blocked';
+  document.querySelectorAll('.chip[data-f]').forEach(ch=>ch.classList.toggle('on',ch.dataset.f===FILTER));
+  const rows=DATA.filter(d=>FILTER==='all'||d.decision===FILTER);
+  document.getElementById('list').innerHTML = rows.length? rows.map(card).join('') :
+    '<p style="color:#64748b;text-align:center;padding:40px">nothing here — 🎉</p>';
+}
+function card(d){
+  const rc={approve:'#16a34a',defer:'#d97706',block:'#dc2626'}[d.rec]||'#94a3b8';
+  return '<div class="card '+d.decision+'">'
+    +'<div class=t>'+d.title+'</div>'
+    +'<div class=badges>'
+      +'<span class=b>⚙️ '+d.gate+'</span>'
+      +(d.dtd?'<span class=b>🧑‍⚖️ '+d.dtd+'</span>':'')
+      +(d.reversible?'<span class=b style="color:#34d399">♻️ reversible</span>':'')
+      +(d.destructive?'<span class=b style="color:#f87171">⚠️ destructive</span>':'')
+      +'<span class=b style="color:#64748b">'+d.slug+'</span>'
+    +'</div>'
+    +'<div class=rec><b style="color:'+rc+'">Claude rec: '+d.rec.toUpperCase()+'</b> — '+esc(d.why)+'</div>'
+    +'<div class=btns>'
+      +btn(d,'approve','yes','✅ Yes / Approve')
+      +btn(d,'defer','no','⏸ No / Defer')
+      +btn(d,'block','blk','⛔ Block')
+    +'</div>'
+    +'<div class=mtoggle onclick="var p=this.nextElementSibling;p.style.display=p.style.display===\'block\'?\'none\':\'block\'">▸ memo</div>'
+    +'<pre>'+esc(d.body)+'</pre>'
+  +'</div>';
+}
+function btn(d,dec,cls,label){ return '<button class="'+cls+(d.decision===dec?' sel':'')+'" onclick="decide(\''+d.slug+'\',\''+(d.decision===dec?'undecided':dec)+'\')">'+label+'</button>'; }
+function esc(s){return String(s).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));}
+async function decide(slug,decision){
+  await fetch('/api/decide',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({slug,decision})});
+  const d=DATA.find(x=>x.slug===slug); if(d) d.decision=decision; render();
+}
+document.querySelectorAll('.chip[data-f]').forEach(ch=>ch.onclick=()=>{FILTER=ch.dataset.f;render();});
+document.getElementById('export').onclick=()=>{
+  const lines=DATA.filter(d=>d.decision!=='undecided').map(d=>d.slug+': '+({approve:'approve',defer:'defer',block:'block'}[d.decision]));
+  navigator.clipboard.writeText(lines.join('\\n')); document.getElementById('export').textContent='copied '+lines.length;
+  setTimeout(()=>document.getElementById('export').textContent='Copy decisions',1500);
+};
+load();
+</script></body></html>`;
+}
+
+http.createServer((req, res) => {
+  // basic auth
+  const hdr = req.headers.authorization || '';
+  const [, b64] = hdr.split(' ');
+  const [u, p] = Buffer.from(b64 || '', 'base64').toString().split(':');
+  if (u !== USER || p !== PASS) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm=approvals' }); return res.end('auth required'); }
+
+  if (req.url === '/' ) { res.writeHead(200, { 'Content-Type': 'text/html' }); return res.end(page()); }
+  if (req.url === '/api/drafts') { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify(drafts())); }
+  if (req.url === '/api/decide' && req.method === 'POST') {
+    let body = ''; req.on('data', c => body += c); req.on('end', () => {
+      try { const { slug, decision } = JSON.parse(body || '{}');
+        if (!/^[\w.\-]+$/.test(slug || '')) throw new Error('bad slug');
+        if (!['approve', 'defer', 'block', 'undecided'].includes(decision)) throw new Error('bad decision');
+        decide(slug, decision);
+        res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true }));
+      } catch (e) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: String(e.message) })); }
+    });
+    return;
+  }
+  res.writeHead(404); res.end('not found');
+}).listen(PORT, () => console.log('approvals-viewer on http://localhost:' + PORT + ' (admin/DW2024!)'));

(oldest)  ·  back to Approvals Viewer  ·  approvals-viewer: skip auth for loopback (fixes blank page) ecc4f6c →