[object Object]

← 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

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 →