← back to Gated Queue Viewer
initial scaffold: read-only web viewer for the gated approval queue
06e41e3dcfa9edac9896cb692340aa29bf6eb423 · 2026-09-23 09:21:54 -0700 · Steve
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
A .gitignoreA package.jsonA public/index.htmlA server.js
Diff
commit 06e41e3dcfa9edac9896cb692340aa29bf6eb423
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Sep 23 09:21:54 2026 -0700
initial scaffold: read-only web viewer for the gated approval queue
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
.gitignore | 8 +++
package.json | 8 +++
public/index.html | 146 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
server.js | 117 +++++++++++++++++++++++++++++++++++++++++++
4 files changed, 279 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1924158
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..bd395cb
--- /dev/null
+++ b/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "gated-queue-viewer",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Read-only web viewer for ~/.claude/yolo-queue/pending-approval/ (Basic-auth)",
+ "main": "server.js",
+ "scripts": { "start": "node server.js" }
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..33e7c59
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,146 @@
+<!doctype html>
+<html lang="en" data-theme="dark">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
+<title>Gated Approval Queue</title>
+<style>
+ :root{
+ --bg:#0e1116; --panel:#161b22; --panel2:#1c232d; --line:#2a3441; --ink:#e6edf3; --dim:#9aa7b4;
+ --accent:#4493f8; --red:#e5534b; --amber:#e0a44b; --purple:#a371f7; --green:#3fb950;
+ --cols:3;
+ }
+ *{box-sizing:border-box}
+ body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}
+ header{padding:18px 24px 10px;border-bottom:1px solid var(--line);position:sticky;top:env(safe-area-inset-top,0);background:var(--bg);z-index:5}
+ h1{margin:0 0 2px;font-size:19px}
+ .sub{color:var(--dim);font-size:12.5px}
+ .sub code{background:var(--panel2);padding:1px 6px;border-radius:5px;color:var(--accent)}
+ .controls{display:flex;gap:12px;align-items:center;flex-wrap:wrap;padding:10px 24px;border-bottom:1px solid var(--line);background:var(--panel);position:sticky;top:calc(env(safe-area-inset-top,0) + 56px);z-index:4}
+ .controls label{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--dim);margin-right:6px}
+ select,input[type=range],input[type=search]{background:var(--panel2);color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:6px 9px;font-size:13px}
+ input[type=range]{padding:0;width:130px}
+ input[type=search]{width:200px}
+ .stat{margin-left:auto;font-size:12.5px;color:var(--dim)}
+ .stat b{color:var(--ink)}
+ main{padding:18px 24px;display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:14px}
+ @media(max-width:900px){main{grid-template-columns:1fr}.controls{position:static}}
+ .card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px;display:flex;flex-direction:column;gap:8px;cursor:pointer;transition:border-color .12s}
+ .card:hover{border-color:var(--accent)}
+ .row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
+ .title{font-size:14px;font-weight:650;line-height:1.35}
+ .badge{font-size:10px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;padding:3px 7px;border-radius:20px;white-space:nowrap}
+ .b-credential{background:rgba(163,113,247,.16);color:#c9a4ff}
+ .b-gmc-google{background:rgba(224,164,75,.16);color:#f0b866}
+ .b-shopify{background:rgba(63,185,80,.16);color:#6ee787}
+ .b-dns-domain{background:rgba(68,147,248,.16);color:#79b8ff}
+ .b-deploy-prod{background:rgba(229,83,75,.16);color:#ff8b83}
+ .b-send-to-list{background:rgba(240,136,62,.18);color:#ffab5e}
+ .b-scheduled-job{background:rgba(110,231,183,.14);color:#7de8c8}
+ .b-other{background:rgba(154,167,180,.14);color:var(--dim)}
+ .when{font-size:10.5px;color:var(--dim);margin-left:auto;white-space:nowrap}
+ .excerpt{font-size:12px;color:var(--dim);overflow:hidden;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical}
+ .ticket{font-size:11px;color:var(--accent);font-family:ui-monospace,monospace}
+ .fname{font-size:10px;color:#5a6b7a;font-family:ui-monospace,monospace;word-break:break-all}
+ /* modal */
+ .modal-bg{position:fixed;inset:0;background:rgba(0,0,0,.6);display:none;align-items:center;justify-content:center;z-index:20;padding:20px}
+ .modal-bg.open{display:flex}
+ .modal{background:var(--panel);border:1px solid var(--line);border-radius:14px;max-width:900px;width:100%;max-height:85vh;display:flex;flex-direction:column}
+ .modal-head{padding:16px 20px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:10px}
+ .modal-head .fname{font-size:11px}
+ .modal-body{padding:20px;overflow-y:auto;white-space:pre-wrap;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px;line-height:1.6}
+ .close-btn{margin-left:auto;background:var(--panel2);border:1px solid var(--line);color:var(--ink);border-radius:7px;padding:6px 12px;cursor:pointer;font-size:13px}
+ .close-btn:hover{border-color:var(--accent)}
+</style>
+</head>
+<body>
+<header>
+ <h1>Gated Approval Queue <span style="color:var(--dim);font-weight:400">— read-only</span></h1>
+ <div class="sub">Every item drafted to <code>~/.claude/yolo-queue/pending-approval/</code> awaiting your APPROVE / REVISE / BLOCK · click a card to read the full memo</div>
+</header>
+<div class="controls">
+ <div><label>Sort</label>
+ <select id="sort">
+ <option value="newest">Newest</option>
+ <option value="oldest">Oldest</option>
+ <option value="ticket">Ticket #</option>
+ <option value="category">Category</option>
+ <option value="title">Title A→Z</option>
+ </select></div>
+ <div><label>Category</label>
+ <select id="cat"><option value="">All</option></select></div>
+ <div><label>Search</label><input type="search" id="q" placeholder="ticket, title, text…"></div>
+ <div><label>Density</label><input type="range" id="dens" min="1" max="4" value="3"></div>
+ <div class="stat" id="stat"></div>
+</div>
+<main id="grid"></main>
+
+<div class="modal-bg" id="modalBg">
+ <div class="modal">
+ <div class="modal-head">
+ <div>
+ <div class="title" id="modalTitle" style="font-size:15px"></div>
+ <div class="fname" id="modalFname"></div>
+ </div>
+ <button class="close-btn" id="closeBtn">Close ✕</button>
+ </div>
+ <div class="modal-body" id="modalBody">Loading…</div>
+ </div>
+</div>
+
+<script>
+const $ = s => document.querySelector(s);
+let ITEMS = [];
+const CATLABEL = { credential:'Credential', 'gmc-google':'GMC/Google', shopify:'Shopify', 'dns-domain':'DNS/Domain', 'deploy-prod':'Deploy/Prod', 'send-to-list':'Send-to-list', 'scheduled-job':'Scheduled job', other:'Other' };
+const fmtDate = iso => { try { return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); } catch(e){ return iso; } };
+
+function populateCatFilter(){
+ const cats = [...new Set(ITEMS.map(i=>i.category))].sort();
+ const sel = $('#cat');
+ for(const c of cats){ const o=document.createElement('option'); o.value=c; o.textContent=CATLABEL[c]||c; sel.appendChild(o); }
+}
+
+function render(){
+ const sort = $('#sort').value, cat = $('#cat').value, q = $('#q').value.toLowerCase();
+ let items = ITEMS.filter(i => (!cat || i.category===cat) && (!q || (i.title+' '+i.ticket+' '+i.excerpt+' '+i.filename).toLowerCase().includes(q)));
+ items = [...items].sort((a,b)=>{
+ if(sort==='newest') return new Date(b.created_at)-new Date(a.created_at);
+ if(sort==='oldest') return new Date(a.created_at)-new Date(b.created_at);
+ if(sort==='ticket') return (a.ticket||'zz').localeCompare(b.ticket||'zz');
+ if(sort==='category') return a.category.localeCompare(b.category) || a.title.localeCompare(b.title);
+ return a.title.localeCompare(b.title);
+ });
+ $('#stat').innerHTML = `<b>${items.length}</b> shown of <b>${ITEMS.length}</b> total`;
+ $('#grid').innerHTML = items.map(i=>`
+ <div class="card" data-id="${i.id}">
+ <div class="row">
+ <span class="badge b-${i.category}">${CATLABEL[i.category]||i.category}</span>
+ <span class="when" title="${i.created_at}">🕓 ${fmtDate(i.created_at)}</span>
+ </div>
+ <div class="title">${i.title}</div>
+ ${i.ticket?`<div class="ticket">${i.ticket}</div>`:''}
+ <div class="excerpt">${i.excerpt}</div>
+ <div class="fname">${i.filename}</div>
+ </div>`).join('') || '<p style="color:var(--dim)">No matches.</p>';
+ document.querySelectorAll('.card').forEach(el => el.onclick = () => openModal(el.dataset.id));
+}
+
+function openModal(id){
+ const item = ITEMS.find(i=>i.id===id);
+ $('#modalTitle').textContent = item ? item.title : id;
+ $('#modalFname').textContent = item ? item.filename : '';
+ $('#modalBody').textContent = 'Loading…';
+ $('#modalBg').classList.add('open');
+ fetch('/api/queue/'+id).then(r=>r.json()).then(d=>{ $('#modalBody').textContent = d.content || d.error || '(empty)'; });
+}
+$('#closeBtn').onclick = () => $('#modalBg').classList.remove('open');
+$('#modalBg').onclick = e => { if(e.target.id==='modalBg') $('#modalBg').classList.remove('open'); };
+
+$('#sort').onchange = render; $('#cat').onchange = render; $('#q').oninput = render;
+$('#dens').oninput = e=>{ document.documentElement.style.setProperty('--cols', e.target.value); try{localStorage.setItem('gq_dens',e.target.value);}catch(e){} };
+try{ const d=localStorage.getItem('gq_dens'); if(d){$('#dens').value=d;document.documentElement.style.setProperty('--cols',d);} }catch(e){}
+
+fetch('/api/queue').then(r=>r.json()).then(d=>{ ITEMS=d.items||[]; populateCatFilter(); render(); }).catch(e=>{ $('#grid').innerHTML='<p style="color:var(--red)">Failed to load: '+e+'</p>'; });
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..c7428df
--- /dev/null
+++ b/server.js
@@ -0,0 +1,117 @@
+// gated-queue-viewer — read-only web viewer for ~/.claude/yolo-queue/pending-approval/
+// Basic-auth (admin/DW2024!), zero-dependency Node http. READ-ONLY: never writes/moves/executes.
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+const PORT = process.env.PORT || 9771;
+const USER = process.env.BASIC_USER || 'admin';
+const PASS = process.env.BASIC_PASS || 'DW2024!';
+const QUEUE_DIR = process.env.QUEUE_DIR || path.join(process.env.HOME, '.claude/yolo-queue/pending-approval');
+const ROOT = __dirname;
+
+const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'application/javascript', '.json': 'application/json' };
+
+function unauthorized(res) {
+ res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="gated-queue", charset="UTF-8"' });
+ res.end('Auth required');
+}
+function checkAuth(req) {
+ const h = req.headers.authorization || '';
+ if (!h.startsWith('Basic ')) return false;
+ const [u, p] = Buffer.from(h.slice(6), 'base64').toString().split(':');
+ return u === USER && p === PASS;
+}
+
+// Category classification by keyword — order matters (first match wins)
+const CATS = [
+ ['credential', /credential|secret|api.?key|token|rotat/i],
+ ['gmc-google', /\bGMC\b|google merchant|google offer|google feed|google shopping/i],
+ ['shopify', /shopify|dw_unified/i],
+ ['dns-domain', /\bDNS\b|cloudflare|certbot|\bSSL\b|domain zone/i],
+ ['deploy-prod', /kamatera|deploy|rsync|pm2 (restart|reload)/i],
+ ['send-to-list', /send.?to.?list|constant contact|mailer|email blast|sms blast/i],
+ ['scheduled-job', /launchd|launchctl|cron|scheduled.?job/i],
+ ['other', /.*/],
+];
+function classify(text) {
+ for (const [name, re] of CATS) if (re.test(text)) return name;
+ return 'other';
+}
+
+function listQueue() {
+ const files = fs.readdirSync(QUEUE_DIR, { withFileTypes: true })
+ .filter(d => d.isFile() && d.name.endsWith('.md') && !d.name.startsWith('_'));
+ return files.map(d => {
+ const full = path.join(QUEUE_DIR, d.name);
+ const stat = fs.statSync(full);
+ let content = '';
+ try { content = fs.readFileSync(full, 'utf8'); } catch (e) { content = ''; }
+ const firstLine = (content.split('\n').find(l => l.trim().length) || d.name).replace(/^#+\s*/, '');
+ const ticketMatch = content.match(/TK-\d+/);
+ const excerpt = content.split('\n').filter(l => l.trim() && !l.startsWith('#')).slice(0, 3).join(' ').slice(0, 240);
+ return {
+ id: crypto.createHash('md5').update(d.name).digest('hex').slice(0, 12),
+ filename: d.name,
+ title: firstLine.slice(0, 140),
+ ticket: ticketMatch ? ticketMatch[0] : null,
+ category: classify(content),
+ created_at: stat.mtime.toISOString(),
+ size: stat.size,
+ excerpt,
+ };
+ }).sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
+}
+
+let FILENAME_BY_ID = {};
+
+const server = http.createServer((req, res) => {
+ if (!checkAuth(req)) return unauthorized(res);
+
+ if (req.url === '/api/queue') {
+ try {
+ const items = listQueue();
+ FILENAME_BY_ID = Object.fromEntries(items.map(i => [i.id, i.filename]));
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ items, count: items.length, source: QUEUE_DIR }));
+ } catch (e) {
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ error: String(e) }));
+ }
+ }
+
+ if (req.url.startsWith('/api/queue/')) {
+ const id = decodeURIComponent(req.url.split('/api/queue/')[1]);
+ const fname = FILENAME_BY_ID[id];
+ if (!fname) { res.writeHead(404, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: 'not found — refresh list first' })); }
+ try {
+ const content = fs.readFileSync(path.join(QUEUE_DIR, fname), 'utf8');
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ filename: fname, content }));
+ } catch (e) {
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ error: String(e) }));
+ }
+ }
+
+ if (req.url === '/health') {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ ok: true, port: PORT, queue_dir: QUEUE_DIR }));
+ }
+
+ let rel = req.url.split('?')[0];
+ if (rel === '/' || rel === '') rel = '/index.html';
+ const filePath = path.join(ROOT, 'public', path.normalize(rel));
+ if (!filePath.startsWith(path.join(ROOT, 'public'))) { res.writeHead(403); return res.end('forbidden'); }
+ fs.readFile(filePath, (err, buf) => {
+ if (err) { res.writeHead(404); return res.end('not found'); }
+ res.writeHead(200, { 'Content-Type': MIME[path.extname(filePath)] || 'application/octet-stream' });
+ res.end(buf);
+ });
+});
+
+// warm the id map on boot
+FILENAME_BY_ID = Object.fromEntries(listQueue().map(i => [i.id, i.filename]));
+
+server.listen(PORT, () => console.log(`gated-queue-viewer on http://127.0.0.1:${PORT} (admin/DW2024!) — READ-ONLY, source: ${QUEUE_DIR}`));
(oldest)
·
back to Gated Queue Viewer
·
TK-12268: add creds-in-URL fetch guard (fleet pattern) to ga 2c5cf4a →