[object Object]

← back to Ticket Triage Page

Hybrid execute: fire safe/reversible actions locally, copy-to-clipboard prod actions (server holds no prod exec path); bind 127.0.0.1

446e2be5b743c61b2a7ea0cce7a19597d54d73dd · 2026-07-30 20:52:21 -0700 · Steve Abrams

Files touched

Diff

commit 446e2be5b743c61b2a7ea0cce7a19597d54d73dd
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 30 20:52:21 2026 -0700

    Hybrid execute: fire safe/reversible actions locally, copy-to-clipboard prod actions (server holds no prod exec path); bind 127.0.0.1
---
 .gitignore |   1 +
 server.js  | 142 ++++++++++++++++++++++++++++++++-----------------------------
 2 files changed, 75 insertions(+), 68 deletions(-)

diff --git a/.gitignore b/.gitignore
index 08240ae..58680c2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,4 @@ tmp/
 .DS_Store
 dist/
 build/
+.secret-*.json
diff --git a/server.js b/server.js
index 930c57a..5aca62b 100644
--- a/server.js
+++ b/server.js
@@ -1,9 +1,11 @@
 #!/usr/bin/env node
-// Ticket Triage page — select + APPROVE + FIRE the 10 Steve-gated blocked tickets.
-// Zero deps (built-in http). Basic-auth admin/DW2024!. Open /healthz. Port 9759, bound 127.0.0.1 ONLY.
-// Buttons execute the runbook command verbatim. danger:'live' requires typing RUN to confirm.
-// Every fire is logged to the ticket (tk comment) with the command + exit code. Steve is the
-// human gate: nothing fires without his click (+ typed RUN on live ones). TK-13 is hands-off.
+// Ticket Triage — HYBRID: fire the safe/reversible actions, copy the prod ones.
+// Zero deps. Basic-auth admin/DW2024!. Open /healthz. Port 9759, bound 127.0.0.1 ONLY.
+//
+// SAFETY MODEL: the server's spawn() is reachable ONLY from exec.mode==='fire', whose
+// commands are local + reversible (TikTok dry-run). Prod/destructive actions are
+// mode:'copy' — the server NEVER runs them; the page copies pasteCmd to the clipboard and
+// you run it in a terminal (per-command gated). 'copy' Mark-fired only records to the ticket.
 const http = require('http');
 const fs = require('fs');
 const { execFileSync, spawn } = require('child_process');
@@ -11,7 +13,7 @@ const path = require('path');
 const os = require('os');
 
 const PORT = process.env.PORT || 9759;
-const HOST = '127.0.0.1'; // localhost-only — these buttons can hit prod; never bind 0.0.0.0
+const HOST = '127.0.0.1';
 const USER = process.env.BASIC_AUTH_USER || 'admin';
 const PASS = process.env.BASIC_AUTH_PASS || 'DW2024!';
 const TK = path.join(os.homedir(), 'Projects/ticket-system/tk');
@@ -26,14 +28,13 @@ function last4(v) { v = String(v || ''); return v.length <= 4 ? '****' : '…' +
 
 const BUCKET = { approval: ['#16a34a', '1-word approval'], deploy: ['#2563eb', 'deploy'], paste: ['#7c3aed', 'command'], task: ['#ea580c', 'hands-on task'], auto: ['#64748b', 'no action'] };
 
-// Run a shell command, capture combined output, cap runtime. Callback(code, output).
+// runCmd is only ever called with a `fire`-mode ticket's own cmd (local + reversible).
 function runCmd(cmd, cb) {
   const child = spawn('bash', ['-lc', cmd], { env: process.env });
   let out = '', done = false;
-  const finish = (code) => { if (done) return; done = true; cb(code, out.slice(0, 60000)); };
-  const to = setTimeout(() => { out += '\n[triage] TIMEOUT after 300s — killed.\n'; child.kill('SIGKILL'); finish(124); }, 300000);
-  child.stdout.on('data', d => out += d);
-  child.stderr.on('data', d => out += d);
+  const finish = code => { if (done) return; done = true; cb(code, out.slice(0, 60000)); };
+  const to = setTimeout(() => { out += '\n[triage] TIMEOUT 120s — killed.\n'; child.kill('SIGKILL'); finish(124); }, 120000);
+  child.stdout.on('data', d => out += d); child.stderr.on('data', d => out += d);
   child.on('close', code => { clearTimeout(to); finish(code); });
   child.on('error', e => { clearTimeout(to); out += '\n[triage] spawn error: ' + e.message + '\n'; finish(1); });
 }
@@ -43,89 +44,88 @@ function page() {
   const cards = TICKETS.map(t => {
     const [color, blabel] = BUCKET[t.bucket];
     const still = blocked.has(t.ref.match(/TK-[0-9]+/)[0]);
-    const e = t.exec, live = e.danger === 'live';
-    let btn;
-    if (e.mode === 'handsoff') btn = `<span class="handsoff">⬜ hands-off</span>`;
-    else if (e.mode === 'input') btn = `<button type="button" class="act input" data-ref="${esc(t.ref)}">${esc(e.btn)}</button>`;
-    else btn = `<button type="button" class="act ${live ? 'live' : 'safe'}" data-ref="${esc(t.ref)}">${live ? '🔴 ' : ''}${esc(e.btn)}</button>`;
+    const e = t.exec;
+    let ctrl;
+    if (e.mode === 'handsoff') ctrl = `<span class="handsoff">⬜ hands-off</span>`;
+    else if (e.mode === 'copy') ctrl = `<button type="button" class="btn copy" data-ref="${esc(t.ref)}">📋 Copy: ${esc(e.btn)}</button><button type="button" class="btn mark" data-ref="${esc(t.ref)}">✓ Mark fired</button>`;
+    else if (e.mode === 'input') ctrl = `<button type="button" class="btn input act" data-ref="${esc(t.ref)}">${esc(e.btn)}</button>`;
+    else ctrl = `<button type="button" class="btn ${e.mode === 'fire' ? 'fire' : 'dec'} act" data-ref="${esc(t.ref)}">${e.mode === 'fire' ? '▶ ' : ''}${esc(e.btn)}</button>`;
     return `<div class="card${still ? '' : ' cleared'}" data-bucket="${t.bucket}" id="card-${esc(t.ref)}">
       <div class="top"><span class="ref">${esc(t.ref)}</span>
         <span class="badge" style="background:${color}">${blabel}</span>
         <span class="rec">rec: <b>${esc(t.recommend)}</b></span>
+        ${e.danger === 'live' ? '<span class="livetag">LIVE — paste in terminal</span>' : ''}
         ${still ? '' : '<span class="done-pill">✓ cleared</span>'}</div>
       <div class="title">${esc(t.title)}</div>
       <div class="action">${esc(t.action)}</div>
-      <div class="foot">${btn}
-        ${t.memo ? `<button type="button" class="memo" data-file="${esc(t.memo)}">📄 runbook</button>` : ''}</div>
+      ${e.pasteCmd ? `<pre class="cmd">${esc(e.pasteCmd)}</pre>` : ''}
+      <div class="foot">${ctrl}${t.memo ? `<button type="button" class="btn ghost memo" data-file="${esc(t.memo)}">📄 runbook</button>` : ''}</div>
       <pre class="output" id="out-${esc(t.ref)}" hidden></pre>
     </div>`;
   }).join('\n');
 
   return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
-<title>Blocked-Ticket Triage · approve &amp; fire</title><style>
+<title>Blocked-Ticket Triage · fire safe · copy prod</title><style>
 :root{--bg:#0f172a;--card:#1e293b;--ink:#e2e8f0;--mut:#94a3b8;--line:#334155}
 *{box-sizing:border-box}body{margin:0;font:15px/1.5 -apple-system,system-ui,sans-serif;background:var(--bg);color:var(--ink)}
 header{position:sticky;top:0;background:#0b1220;border-bottom:1px solid var(--line);padding:14px 20px;z-index:5}
 h1{margin:0;font-size:18px}.sub{color:var(--mut);font-size:13px;margin-top:2px}
-.bar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:12px}
-button,select,input{font:inherit;border-radius:8px;border:1px solid var(--line);background:var(--card);color:var(--ink);padding:8px 12px}
-button{cursor:pointer}.act{font-weight:600}.act.safe{background:#16a34a;border-color:#16a34a}
-.act.live{background:#dc2626;border-color:#dc2626}.act.input{background:#7c3aed;border-color:#7c3aed}
-.act:disabled{opacity:.5;cursor:progress}
+.bar{display:flex;gap:10px;margin-top:12px}
+select,input,button{font:inherit;border-radius:8px;border:1px solid var(--line);background:var(--card);color:var(--ink);padding:8px 12px}
+.btn{cursor:pointer;font-weight:600}.act.fire,.btn.fire{background:#16a34a;border-color:#16a34a}
+.btn.dec{background:#0ea5e9;border-color:#0ea5e9}.btn.input{background:#7c3aed;border-color:#7c3aed}
+.btn.copy{background:#334155}.btn.mark{background:#1e293b}.btn.ghost{background:transparent}
+.btn:disabled{opacity:.5;cursor:progress}.btn.copied{background:#16a34a;border-color:#16a34a}
 main{padding:18px 20px;max-width:900px;margin:0 auto;display:flex;flex-direction:column;gap:12px}
-.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px 16px}
-.card.cleared{opacity:.5}
+.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px 16px}.card.cleared{opacity:.5}
 .top{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:4px}
 .ref{font-weight:700;font-family:ui-monospace,monospace}.badge{color:#fff;font-size:11px;padding:2px 8px;border-radius:20px}
 .rec{color:var(--mut);font-size:12px}.rec b{color:var(--ink)}.done-pill{color:#22c55e;font-size:12px}
+.livetag{color:#fca5a5;font-size:11px;border:1px solid #7f1d1d;padding:1px 7px;border-radius:20px}
 .title{font-weight:600;margin:2px 0}.action{color:var(--mut);font-size:13.5px}
-.foot{margin-top:10px;display:flex;gap:8px;flex-wrap:wrap}.memo{padding:6px 10px;font-size:12.5px}
+.cmd{margin:8px 0 0;padding:10px;background:#0b1220;border:1px solid var(--line);border-radius:8px;font:11.5px/1.5 ui-monospace,monospace;white-space:pre-wrap;overflow:auto}
+.foot{margin-top:10px;display:flex;gap:8px;flex-wrap:wrap}
 .handsoff{color:var(--mut);font-size:13px;padding:6px 0}
 .output{margin:10px 0 0;padding:12px;background:#0b1220;border:1px solid var(--line);border-radius:8px;font:12px/1.5 ui-monospace,monospace;white-space:pre-wrap;max-height:340px;overflow:auto}
 .output.ok{border-color:#16a34a}.output.err{border-color:#dc2626}
 #modal{position:fixed;inset:0;background:rgba(0,0,0,.65);display:none;align-items:center;justify-content:center;padding:20px;z-index:10}
 #modal .box{background:var(--card);border:1px solid var(--line);border-radius:12px;max-width:760px;width:100%;max-height:88vh;display:flex;flex-direction:column}
-#modal .h{padding:12px 16px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:center}
-#modal .c{padding:16px;overflow:auto}
-#modal pre{margin:0 0 12px;padding:12px;background:#0b1220;border-radius:8px;white-space:pre-wrap;font:12.5px/1.5 ui-monospace,monospace}
-.warn{color:#fca5a5;font-size:13px;margin:8px 0}.fld{display:block;margin:8px 0}.fld label{display:block;font-size:12px;color:var(--mut);margin-bottom:3px}.fld input{width:100%}
-.confirm-row{display:flex;gap:8px;align-items:center;margin-top:10px}
+#modal .h{padding:12px 16px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between}
+#modal .c{padding:16px;overflow:auto}#modal pre{margin:0 0 12px;padding:12px;background:#0b1220;border-radius:8px;white-space:pre-wrap;font:12.5px/1.5 ui-monospace,monospace}
+.fld{display:block;margin:8px 0}.fld label{display:block;font-size:12px;color:var(--mut);margin-bottom:3px}.fld input{width:100%}
 </style></head><body>
-<header><h1>Blocked-Ticket Triage — approve &amp; fire</h1>
-<div class="sub">Each button executes its ticket's runbook command. 🔴 live actions need you to type <b>RUN</b>. Every fire is logged to the ticket. You are the gate — nothing runs without your click.</div>
+<header><h1>Blocked-Ticket Triage — fire safe · copy prod</h1>
+<div class="sub">Green ▶ / blue buttons fire locally &amp; are reversible. <span style="color:#fca5a5">LIVE</span> prod actions show the exact command to <b>copy &amp; paste in your terminal</b> (per-command gated), then <b>Mark fired</b>. Every action logs to its ticket.</div>
 <div class="bar"><select id="filter"><option value="">All buckets</option><option value="approval">approvals</option><option value="deploy">deploys</option><option value="paste">commands</option><option value="task">tasks</option><option value="auto">no action</option></select></div></header>
 <main>${cards}</main>
-<div id="modal"><div class="box"><div class="h"><b id="mtitle"></b><button type="button" id="mclose">close</button></div><div class="c" id="mbody"></div></div></div>
+<div id="modal"><div class="box"><div class="h"><b id="mtitle"></b><button type="button" class="btn ghost" id="mclose">close</button></div><div class="c" id="mbody"></div></div></div>
 <script>
 const $=s=>document.querySelector(s),$$=s=>[...document.querySelectorAll(s)];
 const T=${JSON.stringify(TICKETS.map(t => ({ ref: t.ref, exec: t.exec })))};
+function esc(x){return String(x==null?'':x).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}
 $('#filter').onchange=e=>{const v=e.target.value;$$('.card').forEach(c=>c.style.display=(!v||c.dataset.bucket===v)?'':'none')};
-$$('.memo').forEach(b=>b.onclick=async()=>{openModal(b.dataset.file.split('/').pop(),'<pre>loading…</pre>');const r=await fetch('/api/memo?file='+encodeURIComponent(b.dataset.file));$('#mbody').innerHTML='<pre></pre>';$('#mbody pre').textContent=await r.text()});
 function openModal(title,html){$('#mtitle').textContent=title;$('#mbody').innerHTML=html;$('#modal').style.display='flex'}
 $('#mclose').onclick=()=>$('#modal').style.display='none';$('#modal').onclick=e=>{if(e.target.id==='modal')$('#modal').style.display='none'};
+$$('.memo').forEach(b=>b.onclick=async()=>{openModal(b.dataset.file.split('/').pop(),'<pre>loading…</pre>');const r=await fetch('/api/memo?file='+encodeURIComponent(b.dataset.file));$('#mbody').innerHTML='<pre></pre>';$('#mbody pre').textContent=await r.text()});
+
+// COPY prod command to clipboard
+$$('.btn.copy').forEach(b=>b.onclick=async()=>{const t=T.find(x=>x.ref===b.dataset.ref);try{await navigator.clipboard.writeText(t.exec.pasteCmd);b.classList.add('copied');b.textContent='✓ copied — paste in terminal';setTimeout(()=>{b.classList.remove('copied');b.textContent='📋 Copy: '+t.exec.btn},2500);}catch{const p=document.querySelector('#card-'+b.dataset.ref+' .cmd');if(p){const r=document.createRange();r.selectNode(p);getSelection().removeAllRanges();getSelection().addRange(r);}}});
+
+async function post(ref,body){const r=await fetch('/api/exec',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ref,...body})});return r.json()}
+function showOut(ref,j){const out=$('#out-'+ref);out.hidden=false;out.className='output '+(j.ok?'ok':'err');out.textContent=(j.ok?'✓ ':'✗ ')+(j.msg||'')+(j.output?('\\n\\n'+j.output):'');out.scrollIntoView({behavior:'smooth',block:'center'})}
+
+// MARK fired (copy-mode): record that Steve ran the prod command in a terminal
+$$('.btn.mark').forEach(b=>b.onclick=async()=>{b.disabled=true;const j=await post(b.dataset.ref,{markFired:true});showOut(b.dataset.ref,j);b.disabled=false;b.textContent=j.ok?'✓ marked':'retry'});
 
-$$('.act').forEach(b=>b.onclick=()=>{const ref=b.dataset.ref;const t=T.find(x=>x.ref===ref);const e=t.exec;
-  const live=e.danger==='live';
+// ACT (fire / decision / input)
+$$('.act').forEach(b=>b.onclick=()=>{const t=T.find(x=>x.ref===b.dataset.ref),e=t.exec;
   let body='<div>'+esc(e.detail||'')+'</div>';
-  if(e.mode==='fire'){body+='<div style="margin-top:10px;color:#94a3b8;font-size:12px">Command that will run:</div><pre>'+esc(e.cmd)+'</pre>';}
+  if(e.mode==='fire'){body+='<div style="margin-top:10px;color:#94a3b8;font-size:12px">Runs locally:</div><pre>'+esc(e.cmd)+'</pre>';}
   if(e.mode==='input'){body+=e.fields.map(f=>'<div class="fld"><label>'+esc(f.label)+'</label><input data-f="'+f.name+'" type="'+(f.type||'text')+'" autocomplete="off"></div>').join('');}
-  if(live){body+='<div class="warn">⚠ LIVE action. Type <b>RUN</b> to confirm.</div>';}
-  body+='<div class="confirm-row">'+(live?'<input id="cw" placeholder="type RUN" style="width:130px" autocomplete="off">':'')+'<button id="go" class="act '+(live?'live':(e.mode==='input'?'input':'safe'))+'">'+(live?'🔴 ':'')+'Confirm &amp; fire</button></div>';
-  openModal(ref+' · '+esc(e.btn),body);
-  $('#go').onclick=async()=>{
-    const payload={ref};
-    if(live){const cw=$('#cw').value.trim();if(cw!=='RUN'){$('#cw').style.borderColor='#dc2626';return;}payload.confirm='RUN';}
-    if(e.mode==='input'){payload.inputs={};$$('#mbody input[data-f]').forEach(i=>payload.inputs[i.dataset.f]=i.value);}
-    $('#go').disabled=true;$('#go').textContent='running…';
-    const r=await fetch('/api/exec',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload)});
-    const j=await r.json();$('#modal').style.display='none';
-    const out=$('#out-'+ref);out.hidden=false;out.className='output '+(j.ok?'ok':'err');
-    out.textContent=(j.ok?'✓ ':'✗ ')+(j.msg||'')+(j.output?('\\n\\n'+j.output):'');
-    out.scrollIntoView({behavior:'smooth',block:'center'});
-    b.textContent=j.ok?'✓ fired':'✗ failed — retry';b.disabled=false;
-  };
-});
-function esc(x){return String(x==null?'':x).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}
+  body+='<div style="margin-top:12px"><button id="go" class="btn '+(e.mode==='fire'?'fire':e.mode==='input'?'input':'dec')+'">'+(e.mode==='fire'?'▶ Run now':e.mode==='input'?'Save + prep':'Confirm')+'</button></div>';
+  openModal(b.dataset.ref+' · '+esc(e.btn),body);
+  $('#go').onclick=async()=>{const payload={};if(e.mode==='input'){payload.inputs={};$$('#mbody input[data-f]').forEach(i=>payload.inputs[i.dataset.f]=i.value);}
+    $('#go').disabled=true;$('#go').textContent='working…';const j=await post(b.dataset.ref,payload);$('#modal').style.display='none';showOut(b.dataset.ref,j);b.textContent=j.ok?'✓ done':'retry'}});
 </script></body></html>`;
 }
 
@@ -146,36 +146,42 @@ const server = http.createServer((req, res) => {
 
   if (url.pathname === '/api/exec' && req.method === 'POST') {
     let b = ''; req.on('data', c => b += c); req.on('end', () => {
-      let ref, confirm, inputs;
-      try { ({ ref, confirm, inputs } = JSON.parse(b)); } catch { res.writeHead(400); return res.end('{"ok":false,"msg":"bad json"}'); }
+      let ref, inputs, markFired;
+      try { ({ ref, inputs, markFired } = JSON.parse(b)); } catch { res.writeHead(400); return res.end('{"ok":false,"msg":"bad json"}'); }
       const t = byRef(ref); if (!t) { res.writeHead(404); return res.end('{"ok":false,"msg":"unknown ticket"}'); }
       const e = t.exec;
       const reply = o => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
 
-      if (e.mode === 'handsoff') return reply({ ok: false, msg: 'Hands-off — owned by the armed idle-watch; not fireable here.' });
+      if (e.mode === 'handsoff') return reply({ ok: false, msg: 'Hands-off — owned by the armed idle-watch.' });
+
+      // copy-mode: server NEVER runs the prod command. Mark-fired only records intent.
+      if (e.mode === 'copy') {
+        if (!markFired) return reply({ ok: false, msg: 'Use Copy to grab the command, run it in a terminal, then Mark fired.' });
+        tk(['comment', ref, `Steve ran the gated command in a terminal (marked fired via triage page): \`${e.pasteCmd}\``]);
+        tk(['status', ref, 'doing']);
+        return reply({ ok: true, msg: 'Recorded: you ran it in the terminal → ticket set to doing.' });
+      }
 
       if (e.mode === 'decision') {
         tk(['comment', ref, `Steve APPROVED via triage page: ${e.detail}`]);
         tk(['status', ref, 'doing']);
-        return reply({ ok: true, msg: 'Decision recorded (APPROVE) + ticket → doing.' });
+        return reply({ ok: true, msg: 'Decision recorded (APPROVE) → doing.' });
       }
 
       if (e.mode === 'input') {
         const digest = (e.fields || []).map(f => `${f.name}=${last4(inputs && inputs[f.name])}`).join(', ');
-        // Persist the provided secrets to a gitignored local file the terminal step reads — never echoed back.
         try { fs.writeFileSync(path.join(__dirname, `.secret-${ref}.json`), JSON.stringify(inputs || {}), { mode: 0o600 }); } catch {}
-        tk(['comment', ref, `Steve provided inputs via triage page (${digest}); saved to gitignored .secret-${ref}.json. nginx auth-drop is the remaining terminal step (prod SSH, irreversible-ish).`]);
+        tk(['comment', ref, `Steve provided inputs via triage page (${digest}); saved to gitignored .secret-${ref}.json. nginx auth-drop remains a terminal step.`]);
         tk(['status', ref, 'doing']);
-        return reply({ ok: true, msg: `Recorded ${digest}. Saved for the terminal step (nginx drop stays a manual prod SSH).` });
+        return reply({ ok: true, msg: `Recorded ${digest}. Saved for the terminal step.` });
       }
 
+      // fire-mode: local + reversible only. This is the ONLY path to spawn().
       if (e.mode === 'fire') {
-        if (e.danger === 'live' && confirm !== 'RUN') return reply({ ok: false, msg: 'Live action needs typed RUN confirmation.' });
-        tk(['status', ref, 'doing']);
-        tk(['log', ref, `FIRING via triage page: ${e.cmd}`]);
+        tk(['status', ref, 'doing']); tk(['log', ref, `FIRING (local) via triage page: ${e.cmd}`]);
         runCmd(e.cmd, (code, output) => {
           const ok = code === 0;
-          tk(['comment', ref, `Triage-page fire ${ok ? 'SUCCESS' : 'exit=' + code}: \`${e.cmd}\``]);
+          tk(['comment', ref, `Triage-page local fire ${ok ? 'SUCCESS' : 'exit=' + code}: \`${e.cmd}\``]);
           reply({ ok, msg: `exit ${code}` + (ok ? '' : ' — see output'), output });
         });
         return;
@@ -186,4 +192,4 @@ const server = http.createServer((req, res) => {
   }
   res.writeHead(404); res.end('not found');
 });
-server.listen(PORT, HOST, () => console.log(`ticket-triage on http://${HOST}:${PORT}  (admin/DW2024!, /healthz open, buttons FIRE)`));
+server.listen(PORT, HOST, () => console.log(`ticket-triage on http://${HOST}:${PORT}  (fire-safe/copy-prod, /healthz open)`));

← 2dc79d9 auto-save: 2026-07-30T20:51:09 (2 files) — server.js tickets  ·  back to Ticket Triage Page  ·  (newest)