← 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
M .gitignoreM public/index.htmlM server.js
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 →