[object Object]

← back to Doing Viewer

doing-viewer: owner-cookie unlock — keep public URL live but route actions to owner device

76a7418c10eb9ccb9e7de69aeebfa1d337e14eb6 · 2026-08-20 12:55:33 -0700 · Steve

Files touched

Diff

commit 76a7418c10eb9ccb9e7de69aeebfa1d337e14eb6
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Aug 20 12:55:33 2026 -0700

    doing-viewer: owner-cookie unlock — keep public URL live but route actions to owner device
---
 .gitignore        |  4 ++++
 public/index.html |  9 +++++++++
 server.js         | 35 ++++++++++++++++++++++++++++++++---
 3 files changed, 45 insertions(+), 3 deletions(-)

diff --git a/.gitignore b/.gitignore
index b46ca66..514a103 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,7 @@ tmp/
 *.log
 .DS_Store
 data/eli12-cache.json
+data/owner.key
+data/snapshot.json
+cta/
+cta-report.png
diff --git a/public/index.html b/public/index.html
index 619c44f..9c29594 100644
--- a/public/index.html
+++ b/public/index.html
@@ -97,6 +97,7 @@
   <span class="pill"><b id="count">–</b> tasks in flight</span>
   <span class="pill" id="pend"></span>
   <div class="spacer"></div>
+  <span id="unlock"></span>
   <span id="clock">connecting…</span>
 </header>
 
@@ -123,6 +124,10 @@ function renderVerdict(i){
   return `<button class="verdict v-${v}" onclick="act('${id}','${verb}',this)">${label}</button>`;
 }
 
+function unlockDevice(){
+  const k = prompt('Enter your owner key to enable the Restart/Nudge buttons on this device:');
+  if (k) window.location = '/unlock?key=' + encodeURIComponent(k.trim());
+}
 async function act(id, verb, btn){
   const note = document.getElementById('note-'+id);
   const orig = btn.textContent;
@@ -146,6 +151,10 @@ async function load(){
   try{ d = await (await fetch('/api/doing',{cache:'no-store'})).json(); }
   catch(e){ document.getElementById('clock').textContent='reconnecting…'; return; }
   IS_MIRROR = !!d.mirror; CAN_ACT = !!d.canAct;
+  const un = document.getElementById('unlock');
+  if (!IS_MIRROR && !CAN_ACT) un.innerHTML = '<a href="#" onclick="unlockDevice();return false" style="color:#ffd479;text-decoration:none;font-size:12px">🔓 Unlock this device</a>';
+  else if (!IS_MIRROR && CAN_ACT) un.innerHTML = '<a href="/lock" style="color:#8b98a9;text-decoration:none;font-size:11px">lock</a>';
+  else un.innerHTML = '';
   document.getElementById('count').textContent = d.count;
   const pend = document.getElementById('pend');
   if (d.mirror) {
diff --git a/server.js b/server.js
index 4ec6ec5..bc30345 100644
--- a/server.js
+++ b/server.js
@@ -47,6 +47,20 @@ function isLocalTrusted(req) {
     /^172\.(1[6-9]|2[0-9]|3[01])\./.test(ip) || /^169\.254\./.test(ip);
 }
 
+// ── owner unlock: "keep it public but route actions to ME" ───────────────────
+// A secret key (data/owner.key) mints a long-lived httpOnly cookie. Any device
+// that visits /unlock?key=<KEY> once becomes an ACTION device — even over the
+// public tunnel. Everyone else (password only, no cookie) stays view-only.
+const crypto = require('crypto');
+const KEYFILE = path.join(__dirname, 'data', 'owner.key');
+let OWNER_KEY;
+try { OWNER_KEY = fs.readFileSync(KEYFILE, 'utf8').trim(); } catch {}
+if (!OWNER_KEY) { OWNER_KEY = crypto.randomBytes(18).toString('hex'); try { fs.writeFileSync(KEYFILE, OWNER_KEY); } catch {} }
+const OWNER_COOKIE = crypto.createHash('sha256').update(OWNER_KEY).digest('hex').slice(0, 32); // browser never sees the raw key
+function cookies(req) { const h = req.headers.cookie || ''; const o = {}; h.split(';').forEach(p => { const i = p.indexOf('='); if (i > 0) o[p.slice(0, i).trim()] = p.slice(i + 1).trim(); }); return o; }
+function isOwner(req) { return cookies(req)['doing_owner'] === OWNER_COOKIE; }
+function trustedForActions(req) { return isLocalTrusted(req) || isOwner(req); }
+
 // ── pm2 process liveness (best-effort, cached) ───────────────────────────────
 const { execFileSync } = require('child_process');
 let pm2Map = {}, pm2At = 0;
@@ -213,10 +227,25 @@ http.createServer((req, res) => {
     res.end('auth required');
     return;
   }
+  // ── owner unlock/lock (claim this device for actions) ──────────────────────
+  if (req.url.startsWith('/unlock')) {
+    const key = new URL(req.url, 'http://x').searchParams.get('key');
+    if (key === OWNER_KEY) {
+      res.writeHead(302, { 'Set-Cookie': `doing_owner=${OWNER_COOKIE}; HttpOnly; Secure; SameSite=Lax; Max-Age=31536000; Path=/`, 'Location': '/' });
+      return res.end();
+    }
+    res.writeHead(403, { 'Content-Type': 'text/html' });
+    return res.end('<body style="font-family:sans-serif;background:#0e1116;color:#e6edf3;padding:40px">Wrong or missing key. Append <code>?key=YOUR_KEY</code>.</body>');
+  }
+  if (req.url === '/lock') {
+    res.writeHead(302, { 'Set-Cookie': 'doing_owner=; Max-Age=0; Path=/', 'Location': '/' });
+    return res.end();
+  }
+
   // ── action: restart/nudge a ticket via the hardened run-ticket.sh ──────────
   if (req.method === 'POST' && req.url === '/api/action') {
-    if (!isLocalTrusted(req)) { res.writeHead(403, { 'Content-Type': 'application/json' });
-      return res.end(JSON.stringify({ error: 'Actions are locked to the local machine / home network. Open the board on Mac2 (or your LAN) to use the buttons.' })); }
+    if (!trustedForActions(req)) { res.writeHead(403, { 'Content-Type': 'application/json' });
+      return res.end(JSON.stringify({ error: 'This device is view-only. Unlock it once via /unlock?key=YOUR_KEY, or open the board on Mac2 / your home network.' })); }
     let raw = ''; req.on('data', c => (raw += c)); req.on('end', () => {
       let b; try { b = JSON.parse(raw); } catch { res.writeHead(400); return res.end('bad json'); }
       const id = String(b.id || ''), verb = String(b.verb || '');
@@ -244,7 +273,7 @@ http.createServer((req, res) => {
     const list = doingList();
     const pending = list.filter(i => !i.eli12).length;
     res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
-    res.end(JSON.stringify({ now: new Date().toISOString(), count: list.length, pending, canAct: isLocalTrusted(req), items: list }));
+    res.end(JSON.stringify({ now: new Date().toISOString(), count: list.length, pending, canAct: trustedForActions(req), items: list }));
     return;
   }
   if (req.url === '/' || req.url === '/index.html') {

← 5e55b38 doing-viewer: remove per-card scroll trap (clamp+more toggle  ·  back to Doing Viewer  ·  auto-data-snapshot: 2026-08-20T13:00:52 (1 data files) — dat 4b326d6 →