← 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
A .gitignoreA recs.jsonA server.js
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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=>({'&':'&','<':'<','>':'>'}[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 →