← back to Approval Viewer
create local gated approval viewer
f1a536501f408e1fb2ba53fa5ee97b254f9426e8 · 2026-08-30 17:40:27 -0700 · Steve Abrams
Files touched
A .gitignoreA README.mdA public/index.htmlA server.js
Diff
commit f1a536501f408e1fb2ba53fa5ee97b254f9426e8
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun Aug 30 17:40:27 2026 -0700
create local gated approval viewer
---
.gitignore | 4 ++++
README.md | 11 +++++++++++
public/index.html | 4 ++++
server.js | 30 ++++++++++++++++++++++++++++++
4 files changed, 49 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/README.md b/README.md
new file mode 100644
index 0000000..bd25127
--- /dev/null
+++ b/README.md
@@ -0,0 +1,11 @@
+# Gated Approval Viewer
+
+Local, read-only approval surface for gated tickets. Decisions are appended to `decisions.jsonl` for audit; they never execute the approved action or contact an external system.
+
+Run:
+
+```sh
+PORT=9788 node server.js
+```
+
+Open `http://127.0.0.1:9788`. Remove `decisions.jsonl` only when you intentionally want to clear local demo history.
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..de8d3ef
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,4 @@
+<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Gated Approval Viewer</title><style>
+:root{color-scheme:dark;--bg:#101216;--panel:#191d24;--line:#303744;--ink:#f1f3f5;--muted:#aab3c0;--gold:#d9b166;--ok:#64d1ba;--red:#ed8e8e}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.45 system-ui,sans-serif}header{padding:24px max(18px,4vw);border-bottom:1px solid var(--line);background:#15181e}h1{margin:0 0 5px;font-size:25px}p{color:var(--muted);margin:5px 0}.notice{margin-top:14px;padding:10px 12px;border:1px solid #3e4b60;border-radius:9px;color:#c8d9f4}.tools{display:flex;gap:10px;flex-wrap:wrap;margin-top:17px}input,select,button,textarea{font:inherit;border:1px solid var(--line);border-radius:8px;padding:9px 11px;background:var(--panel);color:var(--ink)}input{min-width:240px}.grid{padding:22px max(18px,4vw);display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:15px}.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px;display:flex;flex-direction:column;gap:10px}.card.done{opacity:.62}.title{font-weight:700;font-size:17px}.meta{display:flex;gap:7px;flex-wrap:wrap}.badge{font-size:12px;padding:3px 8px;border-radius:999px;border:1px solid var(--line);color:var(--muted)}.hard{color:var(--red);border-color:#704244}.soft{color:var(--ok);border-color:#32675d}.summary{color:#d2d7df;min-height:45px}.actions{display:flex;gap:7px;flex-wrap:wrap;margin-top:auto}.actions button{cursor:pointer}.approve{border-color:#32675d;color:var(--ok)}.revise{border-color:#76602e;color:var(--gold)}.block{border-color:#704244;color:var(--red)}.status{font-size:13px;color:var(--muted)}.empty{padding:30px;color:var(--muted)}textarea{width:100%;min-height:44px;resize:vertical}
+</style></head><body><header><h1>Gated approvals</h1><p>Read the request. Choose one clear next step.</p><div class="notice">Safe mode: buttons record a local decision only. They do not publish, email, spend, deploy, delete, or call outside systems.</div><div class="tools"><input id="search" type="search" placeholder="Search tickets…"><select id="filter"><option value="all">All gates</option><option value="hard">Hard gates</option><option value="soft">Safe local items</option></select><span id="count" class="status"></span></div></header><main id="grid" class="grid"></main><script>
+let data={items:[],decisions:[]};const $=s=>document.querySelector(s);const latest=()=>Object.fromEntries(data.decisions.map(d=>[d.id,d]));function esc(s){return String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}function render(){let q=$('#search').value.toLowerCase(),f=$('#filter').value,st=latest();let list=data.items.filter(i=>(f==='all'||i.risk===f)&&(!q||JSON.stringify(i).toLowerCase().includes(q)));$('#count').textContent=`${list.length} item${list.length===1?'':'s'} shown`;$('#grid').innerHTML=list.length?list.map(i=>{let d=st[i.id];return `<article class="card ${d?'done':''}"><div class="title">${esc(i.title)}</div><div class="meta"><span class="badge">${esc(i.id)}</span><span class="badge ${i.risk}">${esc(i.gate)}</span><span class="badge">${esc(i.owner)}</span></div><div class="summary">${esc(i.summary)}</div><textarea id="note-${esc(i.id)}" placeholder="Optional note" aria-label="Note for ${esc(i.title)}"></textarea>${d?`<div class="status">Recorded: <b>${esc(d.action)}</b> · ${new Date(d.at).toLocaleString()}</div>`:`<div class="actions"><button class="approve" data-id="${esc(i.id)}" data-action="approve">Approve</button><button class="revise" data-id="${esc(i.id)}" data-action="revise">Revise</button><button class="block" data-id="${esc(i.id)}" data-action="block">Block</button></div>`}</article>`}).join(''):'<div class="empty">No matching items.</div>';document.querySelectorAll('[data-action]').forEach(b=>b.onclick=()=>decide(b.dataset.id,b.dataset.action));}async function decide(id,action){let note=$(`#note-${id}`).value;let r=await fetch('/api/decision',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,action,note})});let j=await r.json();if(!r.ok)return alert(j.error||'Could not record decision');data.decisions.push(j.decision);render();}async function load(){data=await (await fetch('/api/items')).json();render();}$('#search').oninput=render;$('#filter').onchange=render;load();</script></body></html>
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..2b270c5
--- /dev/null
+++ b/server.js
@@ -0,0 +1,30 @@
+'use strict';
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+const PORT = Number(process.env.PORT || 9788);
+const decisionsPath = path.join(__dirname, 'decisions.jsonl');
+const items = [
+ { id:'TK-10409-A', title:'Publish campaign to social accounts', gate:'external publish', risk:'hard', owner:'Marketing', summary:'Would publish to selected social accounts. Review targets and copy first.' },
+ { id:'TK-10409-B', title:'Deploy pending website change', gate:'production deploy', risk:'hard', owner:'Engineering', summary:'Would change the live site. Confirm tests, rollback, and timing.' },
+ { id:'TK-10409-C', title:'Send campaign email', gate:'outbound email', risk:'hard', owner:'Marketing', summary:'Would contact a list. Confirm audience, consent, and message.' },
+ { id:'TK-10409-D', title:'Run approval dry-run report', gate:'read-only', risk:'soft', owner:'Operations', summary:'Creates a local report only; no external side effects.' },
+ { id:'TK-10409-E', title:'Update campaign draft copy', gate:'local draft', risk:'soft', owner:'Marketing', summary:'Local draft edit. Nothing is published until separately approved.' }
+];
+function readDecisions(){ try{return fs.readFileSync(decisionsPath,'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);}catch(_){return [];} }
+function send(res,status,data,type='application/json'){res.writeHead(status,{'Content-Type':type});res.end(type==='application/json'?JSON.stringify(data):data);}
+function body(req){return new Promise(resolve=>{let s='';req.on('data',c=>s+=c);req.on('end',()=>resolve(s));});}
+const server=http.createServer(async (req,res)=>{
+ if(req.method==='GET' && (req.url==='/'||req.url==='/index.html')) return send(res,200,fs.readFileSync(path.join(__dirname,'public/index.html'),'utf8'),'text/html; charset=utf-8');
+ if(req.method==='GET' && req.url==='/api/items') return send(res,200,{items,decisions:readDecisions(),writePerformed:false});
+ if(req.method==='POST' && req.url==='/api/decision'){
+ let data; try{data=JSON.parse(await body(req));}catch(_){return send(res,400,{error:'invalid json'});}
+ if(!data.id||!['approve','revise','block'].includes(data.action)) return send(res,400,{error:'id and action approve|revise|block required'});
+ if(!items.some(i=>i.id===data.id)) return send(res,404,{error:'unknown item'});
+ const rec={id:data.id,action:data.action,note:String(data.note||'').slice(0,500),at:new Date().toISOString(),writePerformed:false};
+ fs.appendFileSync(decisionsPath,JSON.stringify(rec)+'\n');
+ return send(res,200,{ok:true,decision:rec,writePerformed:false});
+ }
+ send(res,404,{error:'not found'});
+});
+server.listen(PORT,()=>console.log(`Approval Viewer listening on http://127.0.0.1:${PORT}`));
(oldest)
·
back to Approval Viewer
·
connect viewer to live approval queue c28b876 →