yay-layer 1.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/crypto.js ADDED
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+ // YayLayer crypto core — ed25519 signatures + passphrase-encrypted keystore.
3
+ // Zero dependencies: everything here is Node's built-in `crypto`.
4
+ //
5
+ // Design (see standard/STANDARD.md, §Integrity):
6
+ // - A SEAL is a signature over the canonical bytes of an approval object.
7
+ // - The private key never leaves its owner; only the base64 SPKI public key
8
+ // travels (into .yaylayer/config.json's roster).
9
+ // - At-rest the private key is encrypted with a scrypt-derived AES-256-GCM key.
10
+ // (The production path signs on a phone enclave; this local keystore is the
11
+ // MVP stand-in so the whole loop runs today. See README "MVP vs roadmap".)
12
+
13
+ const crypto = require('crypto');
14
+
15
+ const SCRYPT = { N: 16384, r: 8, p: 1, keylen: 32 };
16
+
17
+ function generateKeypair() {
18
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
19
+ return {
20
+ pubB64: publicKey.export({ type: 'spki', format: 'der' }).toString('base64'),
21
+ privDer: privateKey.export({ type: 'pkcs8', format: 'der' }), // Buffer
22
+ };
23
+ }
24
+
25
+ function encryptKeystore(privDer, passphrase) {
26
+ if (!passphrase) throw new Error('a passphrase is required to encrypt the key');
27
+ const salt = crypto.randomBytes(16);
28
+ const key = crypto.scryptSync(passphrase, salt, SCRYPT.keylen, SCRYPT);
29
+ const iv = crypto.randomBytes(12);
30
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
31
+ const ct = Buffer.concat([cipher.update(privDer), cipher.final()]);
32
+ return {
33
+ v: 1,
34
+ kdf: 'scrypt',
35
+ salt: salt.toString('base64'),
36
+ iv: iv.toString('base64'),
37
+ tag: cipher.getAuthTag().toString('base64'),
38
+ ct: ct.toString('base64'),
39
+ };
40
+ }
41
+
42
+ function decryptKeystore(ks, passphrase) {
43
+ const salt = Buffer.from(ks.salt, 'base64');
44
+ const key = crypto.scryptSync(passphrase, salt, SCRYPT.keylen, SCRYPT);
45
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(ks.iv, 'base64'));
46
+ decipher.setAuthTag(Buffer.from(ks.tag, 'base64'));
47
+ try {
48
+ return Buffer.concat([decipher.update(Buffer.from(ks.ct, 'base64')), decipher.final()]);
49
+ } catch (_) {
50
+ throw new Error('wrong passphrase, or the keystore is corrupt');
51
+ }
52
+ }
53
+
54
+ function sign(message, privDer) {
55
+ const key = crypto.createPrivateKey({ key: privDer, format: 'der', type: 'pkcs8' });
56
+ return crypto.sign(null, Buffer.from(message), key).toString('base64');
57
+ }
58
+
59
+ function verify(message, sigB64, pubB64) {
60
+ try {
61
+ const key = crypto.createPublicKey({ key: Buffer.from(pubB64, 'base64'), format: 'der', type: 'spki' });
62
+ return crypto.verify(null, Buffer.from(message), key, Buffer.from(sigB64, 'base64'));
63
+ } catch (_) {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ function sha256(str) {
69
+ return crypto.createHash('sha256').update(str).digest('hex');
70
+ }
71
+
72
+ function randomNonce() {
73
+ return crypto.randomBytes(12).toString('hex');
74
+ }
75
+
76
+ module.exports = {
77
+ generateKeypair, encryptKeystore, decryptKeystore, sign, verify, sha256, randomNonce,
78
+ };
@@ -0,0 +1,463 @@
1
+ 'use strict';
2
+ // `yay dashboard` — a persistent local control panel. Serves the map LIVE
3
+ // (auto-refresh + Refresh button) and hosts on-demand ACTIONS that a static map
4
+ // can't: run the project's test suite, and regenerate the System Plan (an LLM call,
5
+ // so never automatic). Mutating/executing actions are gated to localhost — anyone
6
+ // on the Wi-Fi can VIEW the map, but only this machine can run tests / spend LLM
7
+ // tokens. (v-next: route phone pair/sign through here too, for one origin.)
8
+
9
+ const http = require('http');
10
+ const os = require('os');
11
+ const crypto = require('crypto');
12
+ const C = require('./crypto');
13
+ const { canonical } = require('./util');
14
+ const { signerHTML } = require('./signer-page');
15
+
16
+ function confirmCode(pubB64) { return String(parseInt(C.sha256('yay-pair:' + pubB64).slice(0, 8), 16) % 1000000).padStart(6, '0'); }
17
+ function readBody(req) {
18
+ return new Promise((resolve) => { let b = ''; req.on('data', (c) => { b += c; if (b.length > 4e6) req.destroy(); }); req.on('end', () => { try { resolve(JSON.parse(b || '{}')); } catch (_) { resolve({}); } }); req.on('error', () => resolve({})); });
19
+ }
20
+
21
+ function lanIP() {
22
+ const ifaces = os.networkInterfaces();
23
+ for (const name of Object.keys(ifaces)) for (const i of ifaces[name] || []) if (i.family === 'IPv4' && !i.internal) return i.address;
24
+ return '127.0.0.1';
25
+ }
26
+ // This machine's OWN addresses — so an action triggered from the laptop is allowed
27
+ // whether it reached the dashboard via localhost OR the machine's own LAN IP, while
28
+ // a DIFFERENT device (the phone, a teammate's laptop) is still blocked.
29
+ const OWN = new Set(['127.0.0.1', '::1', 'localhost']);
30
+ try { const ifs = os.networkInterfaces(); for (const n of Object.keys(ifs)) for (const i of ifs[n] || []) if (i.address) OWN.add(i.address); } catch (_) {}
31
+ function isLocal(req) { const a = (req.socket.remoteAddress || '').replace(/^::ffff:/, ''); return OWN.has(a); }
32
+ function sendJSON(res, status, obj) { res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(obj)); }
33
+ function sendHTML(res, html) { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }); res.end(html); }
34
+ // Serve the (public) CA certificate with the content-type that makes iOS/Android
35
+ // offer to install it as a trusted root. NEVER serve the CA private key.
36
+ function sendCert(res, pem, filename) {
37
+ res.writeHead(200, { 'content-type': 'application/x-x509-ca-cert', 'content-disposition': 'attachment; filename="' + (filename || 'yaylayer-ca.crt') + '"', 'cache-control': 'no-store', 'access-control-allow-origin': '*' });
38
+ res.end(pem);
39
+ }
40
+ // A tiny self-contained page that hands the phone the certificate + the exact
41
+ // (non-obvious) trust steps for iOS and Android, so users get the cert from
42
+ // yay-layer itself instead of copying a file off the laptop.
43
+ function trustHTML() {
44
+ return '<!doctype html><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1">'
45
+ + '<title>Trust this dashboard</title>'
46
+ + '<style>body{font:16px/1.5 -apple-system,system-ui,sans-serif;margin:0;padding:24px;max-width:640px;color:#1a1a1a;background:#fff}'
47
+ + 'h1{font-size:20px;margin:0 0 4px}.sub{color:#666;margin:0 0 20px}'
48
+ + 'a.btn{display:block;text-align:center;background:#2f6f4f;color:#fff;text-decoration:none;padding:14px;border-radius:12px;font-weight:600;margin:16px 0}'
49
+ + 'ol{padding-left:20px}li{margin:6px 0}h2{font-size:15px;margin:22px 0 6px}.note{color:#666;font-size:14px;margin-top:20px}code{background:#f0f0f0;padding:1px 5px;border-radius:5px}</style>'
50
+ + '<h1>Trust this dashboard</h1>'
51
+ + '<p class=sub>One-time setup so your phone shows a secure padlock (no warning) for signing.</p>'
52
+ + '<a class=btn href="/ca.crt">⬇ Download the certificate</a>'
53
+ + '<p class=note>Tap through any "not private" warning to download — you\'re about to make it trusted.</p>'
54
+ + '<h2>iPhone / iPad</h2><ol>'
55
+ + '<li>After the download, open <b>Settings</b> — you\'ll see <b>Profile Downloaded</b> near the top → tap <b>Install</b> (enter passcode).</li>'
56
+ + '<li>Go to <b>Settings → General → About</b>, scroll to the very bottom → <b>Certificate Trust Settings</b>.</li>'
57
+ + '<li>Turn <b>ON</b> the switch next to the <b>mkcert</b> entry (this step is separate — installing the profile alone is not enough).</li>'
58
+ + '</ol>'
59
+ + '<h2>Android</h2><ol>'
60
+ + '<li>Open <b>Settings → Security</b> (or <b>Security &amp; privacy</b>) → <b>More settings / Encryption &amp; credentials</b>.</li>'
61
+ + '<li>Tap <b>Install a certificate → CA certificate</b>, accept the warning, and pick the downloaded file.</li>'
62
+ + '<li>Exact menu names vary by phone; search settings for <code>CA certificate</code> if needed.</li>'
63
+ + '</ol>'
64
+ + '<p class=note>Then reopen the signing page — it\'ll be a trusted <code>https</code> connection. Menu paths differ slightly by OS version.</p>';
65
+ }
66
+
67
+ // Inject the live controls (Refresh + Run tests + Regenerate plan) + a results panel
68
+ // + an auto-poller that reloads the page when the underlying state version changes.
69
+ function withLiveControls(mapHTML, version) {
70
+ const bar = '<div id="yd-bar" class="yd-float">'
71
+ + '<div class="yd-livewrap"><span id="yd-live" class="yd-live">● live</span><button id="yd-refresh" class="yd-refresh" aria-label="Refresh" title="Refresh — reload the dashboard">↻</button></div>'
72
+ + '<button class="yd-btn yd-primary" id="yd-req" title="Describe a change you want, in plain words. It is queued as a request your AI picks up (it runs `yay requests`) and turns into a polished Brief + specs for you to sign on your phone. You never write the Brief here."><span class="yd-icon">➕</span>Request a change</button>'
73
+ + '<button class="yd-btn" id="yd-diffs"><span class="yd-icon">≷</span>Changes</button>'
74
+ + '<button class="yd-btn" id="yd-prev" title="Preview: run a package.json script (dev server, build, …) through the dashboard — see its URL + output and stop it."><span class="yd-icon">▷</span>Preview</button>'
75
+ + '<button class="yd-btn" id="yd-tests"><span class="yd-icon">▶</span>Run tests</button>'
76
+ + '<button class="yd-btn" id="yd-adv"><span class="yd-icon">⚔</span>Adversary</button>'
77
+ + '<button class="yd-btn" id="yd-reseal" title="Re-seal the project foundation (Constitution, CI workflow, .gitignore, protocol files) after a legitimate change — approve on your phone."><span class="yd-icon">🛡</span>Re-seal foundation</button></div>'
78
+ + '<div id="yd-panel" style="display:none"><div id="yd-phead" style="display:flex;justify-content:space-between;align-items:center;padding:10px 14px;border-bottom:1px solid #2b2b2b;position:sticky;top:0;background:#0f1115"><b id="yd-ptitle">Output</b><button class="yd-btn yd-panel-btn" id="yd-close" style="padding:3px 10px">✕ close</button></div>'
79
+ + '<pre id="yd-pout" style="margin:0;padding:12px 14px;white-space:pre-wrap;word-break:break-word"></pre></div>'
80
+ // Request-a-change: a proper in-page modal (theme-aware), not a native prompt().
81
+ + '<div id="yd-reqmodal" class="yd-modal"><div class="yd-modal-card">'
82
+ + '<div class="yd-modal-h">Request a change</div>'
83
+ + '<p class="yd-modal-p">Describe what you want built or changed, in plain words — just like you\'d tell your AI. It becomes a request your AI picks up, turns into a polished Brief + specs, and sends to your phone to sign. You don\'t write the Brief here.</p>'
84
+ + '<textarea id="yd-reqtext" placeholder="e.g. Add rate-limiting to the login endpoint, 5 attempts per minute…"></textarea>'
85
+ + '<div class="yd-modal-actions"><span id="yd-reqmsg" class="yd-modal-msg"></span><button id="yd-reqcancel" class="yd-mbtn">Cancel</button><button id="yd-reqsend" class="yd-mbtn yd-mbtn-primary">Send request →</button></div>'
86
+ + '<div class="yd-modal-hint">⌘/Ctrl + Enter to send · Esc to close</div>'
87
+ + '</div></div>'
88
+ // Reusable confirm modal (replaces native confirm() for Adversary / Regenerate, etc.)
89
+ + '<div id="yd-confirm" class="yd-modal"><div class="yd-modal-card" style="max-width:440px">'
90
+ + '<div class="yd-modal-h" id="yd-cfh">Confirm</div>'
91
+ + '<p class="yd-modal-p" id="yd-cfp" style="margin-bottom:4px"></p>'
92
+ + '<div class="yd-modal-actions"><button id="yd-cfcancel" class="yd-mbtn">Cancel</button><button id="yd-cfok" class="yd-mbtn yd-mbtn-primary">Confirm</button></div>'
93
+ + '</div></div>'
94
+ + '<style>'
95
+ // docked into the sidebar (the default: the dashboard shell provides #yd-slot)
96
+ + '.yd-dock{display:flex;flex-direction:column;gap:0;padding:7px 12px 9px}'
97
+ + '.yd-dock .yd-live{display:inline-flex;align-items:center;gap:6px;font-family:var(--sans);font-size:.62rem;font-weight:700;letter-spacing:.09em;text-transform:uppercase;color:#1f9d57;padding:0 2px 6px;transition:color .2s ease}'
98
+ + '.yd-dock .yd-live.upd{color:var(--amber)}'
99
+ + '.yd-dock .yd-live.stopped{color:var(--mut)}'
100
+ + '.yd-dock .yd-livewrap{display:flex;align-items:center;justify-content:space-between;padding:0 2px 4px}'
101
+ + '.yd-dock .yd-livewrap .yd-live{padding:0}'
102
+ + '.yd-float .yd-livewrap{display:flex;align-items:center;gap:8px;justify-content:center;margin-bottom:2px}'
103
+ + '.yd-refresh{background:none;border:none;color:var(--mut);cursor:pointer;font-size:1rem;line-height:1;padding:3px 5px;border-radius:7px;transition:color .14s,background .14s}'
104
+ + '.yd-refresh:hover{color:var(--ink);background:color-mix(in srgb,var(--ink) 8%,transparent)}'
105
+ + '.yd-dock .yd-btn{display:flex;align-items:center;gap:11px;width:100%;text-align:left;background:none;border:none;color:var(--ink2);border-radius:8px;padding:5px 12px;line-height:1.25;font-family:var(--sans);font-size:.84rem;font-weight:500;cursor:pointer;transition:background .14s,color .14s}'
106
+ + '.yd-dock .yd-btn:hover{background:color-mix(in srgb,var(--ink) 6%,transparent);color:var(--ink)}'
107
+ + '.yd-dock .yd-btn.yd-primary{color:var(--accent);font-weight:600}'
108
+ + '.yd-dock .yd-icon{flex:0 0 18px;display:inline-flex;align-items:center;justify-content:center;font-size:13px;opacity:.85}'
109
+ // floating fallback (only if no sidebar slot exists)
110
+ + '.yd-float{position:fixed;right:16px;bottom:16px;z-index:99999;display:flex;flex-direction:column;gap:7px;align-items:stretch;font-family:ui-monospace,Menlo,monospace}'
111
+ + '.yd-float .yd-live{align-self:center;padding:4px 12px;border-radius:100px;background:#1f9d57;color:#fff;font-size:11px;margin-bottom:2px}'
112
+ + '.yd-float .yd-live.upd{background:#c9860f}.yd-float .yd-live.stopped{background:#8a939b}'
113
+ + '.yd-float .yd-btn{display:flex;align-items:center;gap:9px;width:188px;box-sizing:border-box;padding:10px 14px;border-radius:11px;border:1px solid #3ecf8e;background:#fff;color:#159a63;font-weight:700;font-size:12.5px;text-align:left;cursor:pointer;box-shadow:0 3px 12px -6px rgba(0,0,0,.3)}'
114
+ + '.yd-float .yd-btn.yd-primary{background:#3ecf8e;color:#04231a}.yd-float .yd-btn:hover{background:#f1fbf6}.yd-float .yd-icon{flex:0 0 18px;text-align:center;font-size:14px}'
115
+ // output panel: overlays the main column, clear of the left sidebar
116
+ + '#yd-panel{position:fixed;left:258px;right:16px;bottom:16px;max-height:64vh;overflow:auto;z-index:99998;background:#0f1115;color:#e6e6e6;border:1px solid #2b2b2b;border-radius:12px;box-shadow:0 24px 60px -20px rgba(0,0,0,.6);font-family:ui-monospace,Menlo,monospace;font-size:12.5px}'
117
+ + '.yd-panel-btn{border:1px solid #3ecf8e;background:#fff;color:#159a63;border-radius:8px;font-weight:700;cursor:pointer}'
118
+ + '@media(max-width:900px){#yd-panel{left:8px;right:8px;bottom:auto;top:8px;max-height:62vh}}'
119
+ // request-a-change modal (theme-aware, matches the app)
120
+ + '.yd-modal{position:fixed;inset:0;z-index:100000;display:none;align-items:flex-start;justify-content:center;padding:64px 16px;background:rgba(10,12,16,.55);backdrop-filter:blur(3px)}'
121
+ + '.yd-modal.open{display:flex}'
122
+ + '.yd-modal-card{background:var(--paper);color:var(--ink);border:1px solid var(--rule);border-radius:16px;max-width:520px;width:100%;padding:22px 24px 18px;box-shadow:0 40px 90px -30px rgba(0,0,0,.5);font-family:var(--sans)}'
123
+ + '.yd-modal-h{font-size:1.15rem;font-weight:700;letter-spacing:-.01em;margin:0 0 8px}'
124
+ + '.yd-modal-p{font-size:.88rem;color:var(--ink2);line-height:1.55;margin:0 0 14px}'
125
+ + '.yd-modal textarea{width:100%;min-height:112px;box-sizing:border-box;padding:11px 13px;border-radius:10px;border:1px solid var(--rule);background:var(--card2);color:var(--ink);font-family:var(--sans);font-size:.92rem;line-height:1.5;resize:vertical}'
126
+ + '.yd-modal textarea:focus{outline:none;border-color:var(--brand);box-shadow:0 0 0 3px color-mix(in srgb,var(--brand) 22%,transparent)}'
127
+ + '.yd-modal-actions{display:flex;align-items:center;justify-content:flex-end;gap:9px;margin-top:14px}'
128
+ + '.yd-modal-msg{margin-right:auto;font-size:.82rem;color:var(--mut)}'
129
+ + '.yd-mbtn{font-family:var(--sans);font-size:.85rem;font-weight:600;padding:9px 16px;border-radius:9px;border:1px solid var(--rule);background:var(--card);color:var(--ink);cursor:pointer;transition:border-color .14s,filter .14s}'
130
+ + '.yd-mbtn:hover{border-color:var(--mut)}'
131
+ + '.yd-mbtn-primary{background:var(--brand);color:#04231a;border-color:var(--brand)}.yd-mbtn-primary:hover{filter:brightness(1.05)}'
132
+ + '.yd-mbtn:disabled{opacity:.55;cursor:default}'
133
+ + '.yd-modal-hint{margin-top:12px;font-size:.72rem;color:var(--mut);text-align:right}</style>';
134
+ const js = '<script>(function(){var V=' + JSON.stringify(version) + ';'
135
+ + 'var live=document.getElementById("yd-live"),panel=document.getElementById("yd-panel"),pout=document.getElementById("yd-pout"),ptitle=document.getElementById("yd-ptitle");'
136
+ // Dock the controls into the sidebar slot when the dashboard shell provides one (the norm);
137
+ // otherwise leave them floating (fallback for any non-shell page).
138
+ + 'var ydSlot=document.getElementById("yd-slot"),ydBar=document.getElementById("yd-bar");'
139
+ + 'if(ydSlot&&ydBar){ydBar.className="yd-dock";ydSlot.appendChild(ydBar);}else if(panel){panel.style.left="16px";}'
140
+ + 'function esc(s){return String(s==null?"":s).replace(/[&<>]/g,function(m){return m==="&"?"&amp;":m==="<"?"&lt;":"&gt;";});}'
141
+ + 'function show(t,txt,cls){ptitle.textContent=t;pout.textContent=txt;pout.style.color=cls==="ok"?"#3fbf77":cls==="err"?"#ff6b6b":"#e6e6e6";panel.style.display="block";}'
142
+ + 'document.getElementById("yd-close").onclick=function(){panel.style.display="none";};'
143
+ + 'document.getElementById("yd-refresh").onclick=function(){location.reload();};'
144
+ + 'var pvTimer=null;function pvStop(){if(pvTimer){clearInterval(pvTimer);pvTimer=null;}}'
145
+ + 'async function openPreview(){ptitle.textContent="Preview — run a package.json script";pout.style.color="#e6e6e6";panel.style.display="block";try{var j=await fetch("/api/scripts").then(function(r){return r.json();});var run=(j.running||[]),scripts=(j.scripts||[]);var h="";if(run.length){h+="<div style=\\"font-weight:700;margin:0 0 6px\\">Running</div>";run.forEach(function(r){h+="<div style=\\"border:1px solid #2b2b2b;border-radius:8px;padding:8px 10px;margin:0 0 8px\\"><div style=\\"display:flex;justify-content:space-between;gap:8px;align-items:center;flex-wrap:wrap\\"><b>"+esc(r.name)+"</b><span>"+(r.url?("<a href=\\""+esc(r.url)+"\\" target=\\"_blank\\" rel=\\"noopener\\" style=\\"color:#3fbf77;font-weight:700;text-decoration:none\\">Open "+esc(r.url)+" ↗</a>"):(r.alive?"<span style=\\"color:#c9860f\\">starting…</span>":"<span style=\\"color:#ff6b6b\\">stopped</span>"))+" <button class=\\"yd-btn ps-stop\\" data-n=\\""+esc(r.name)+"\\" style=\\"padding:3px 10px\\">Stop</button></span></div>"+(r.output?"<pre style=\\"margin:6px 0 0;white-space:pre-wrap;max-height:150px;overflow:auto;color:#b8b8b8;font-size:11px\\">"+esc(r.output)+"</pre>":"")+"</div>";});}h+="<div style=\\"font-weight:700;margin:10px 0 6px\\">Scripts</div>";if(!scripts.length)h+="<div style=\\"color:#8a8a8a\\">No scripts in package.json.</div>";scripts.forEach(function(sn){var isr=run.some(function(r){return r.name===sn.name&&r.alive;});h+="<div style=\\"display:flex;justify-content:space-between;gap:10px;align-items:center;padding:5px 0;border-bottom:1px solid #222\\"><div><b>"+esc(sn.name)+"</b> <span style=\\"color:#8a8a8a;font-size:11px\\">"+esc(sn.cmd)+"</span></div>"+(isr?"<span style=\\"color:#3fbf77;font-size:11px\\">running</span>":"<button class=\\"yd-btn ps-run\\" data-n=\\""+esc(sn.name)+"\\" style=\\"padding:3px 12px\\">Run</button>")+"</div>";});pout.innerHTML=h;Array.prototype.forEach.call(pout.querySelectorAll(".ps-run"),function(b){b.onclick=async function(){b.textContent="…";await fetch("/api/scripts/run",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({name:b.getAttribute("data-n")})});setTimeout(openPreview,500);};});Array.prototype.forEach.call(pout.querySelectorAll(".ps-stop"),function(b){b.onclick=async function(){await fetch("/api/scripts/stop",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({name:b.getAttribute("data-n")})});setTimeout(openPreview,400);};});}catch(e){pout.textContent="Could not load scripts: "+e;}}'
146
+ + 'document.getElementById("yd-prev").onclick=function(){pvStop();openPreview();pvTimer=setInterval(function(){if(panel.style.display!=="none"&&ptitle.textContent.indexOf("Preview")===0)openPreview();else pvStop();},2500);};'
147
+ + 'document.getElementById("yd-diffs").onclick=async function(){ptitle.textContent="Spec changes since last commit";pout.style.color="#e6e6e6";pout.textContent="loading…";panel.style.display="block";try{var j=await fetch("/api/diffs").then(function(r){return r.json();});if(!j.diffs||!j.diffs.length){pout.textContent="No spec changes since the last commit (working tree matches HEAD).";return;}pout.innerHTML=j.diffs.map(function(c){var lines=c.diff.map(function(d){var col=d.t==="+"?"#3fbf77":d.t==="-"?"#ff6b6b":"#8a8a8a";var pre=d.t==="+"?"+ ":d.t==="-"?"- ":" ";return "<div style=\\"color:"+col+"\\">"+esc(pre+d.text)+"</div>";}).join("");return "<div style=\\"margin:0 0 16px\\"><div style=\\"color:#e6e6e6;font-weight:700;margin-bottom:5px\\">"+esc(c.id+(c.unit?" · "+c.unit:"")+" "+c.file)+"</div>"+lines+"</div>";}).join("");}catch(e){pout.textContent="Could not load diffs: "+e;}};'
148
+ + 'document.getElementById("yd-adv").onclick=async function(){if(!(await ydConfirm("Run the spec-only adversary?","An LLM writes probes from the specs (never the code) and runs them to try to break the promise — this costs tokens.","Run adversary")))return;show("Adversary","Writing probes from the specs and running them… (a few seconds)");try{var r=await fetch("/api/adversary/run",{method:"POST"});if(r.status===403){show("Adversary","Run this on THIS computer (localhost).","err");return;}var j=await r.json();if(j.error){show("Adversary","✗ "+j.error,"err");return;}var rows=(j.results||[]);var broke=rows.filter(function(x){return x.status==="broke";});var html=rows.map(function(x){var col=x.status==="broke"?"#ff6b6b":x.status==="survived"?"#3fbf77":"#c9860f";var msg=x.status==="broke"?("BROKE: "+x.counterexample):x.status==="survived"?"survived":(x.reason||x.status);return "<div style=\\"color:"+col+"\\">"+esc((x.status==="broke"?"✗ ":x.status==="survived"?"✓ ":"– ")+x.id+" "+(x.unit||"")+" — "+msg)+"</div>";}).join("")||"No eligible Cells (need a leaf unit with ensures/out/throws).";ptitle.textContent="Adversary — "+broke.length+" broke / "+rows.length+" probed";pout.style.color="#e6e6e6";pout.innerHTML=html;panel.style.display="block";}catch(e){show("Adversary","Could not run: "+e,"err");}};'
149
+ + 'document.getElementById("yd-tests").onclick=async function(){show("Tests","Running the project test suite…");try{var r=await fetch("/api/tests/run",{method:"POST"});if(r.status===403){show("Tests","Run tests from the dashboard on THIS computer (localhost) — not from the phone.","err");return;}var j=await r.json();show("Tests "+(j.configured?(j.ok?"✓ passed":"✗ failed (exit "+j.code+")"):""),(j.cmd?("$ "+j.cmd+"\\n\\n"):"")+(j.output||""),j.configured?(j.ok?"ok":"err"):"");}catch(e){show("Tests","Could not run: "+e,"err");}};'
150
+ + 'var reqModal=document.getElementById("yd-reqmodal"),reqText=document.getElementById("yd-reqtext"),reqMsg=document.getElementById("yd-reqmsg"),reqSend=document.getElementById("yd-reqsend");'
151
+ + 'function reqOpen(){reqText.value="";reqMsg.textContent="";reqMsg.style.color="var(--mut)";reqSend.disabled=false;reqModal.classList.add("open");setTimeout(function(){reqText.focus();},30);}'
152
+ + 'function reqClose(){reqModal.classList.remove("open");}'
153
+ + 'async function reqSubmit(){var t=(reqText.value||"").trim();if(!t){reqMsg.style.color="#cf4436";reqMsg.textContent="Type what you want changed.";reqText.focus();return;}reqSend.disabled=true;reqMsg.style.color="var(--mut)";reqMsg.textContent="Sending…";try{var r=await fetch("/api/request/create",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({text:t})});if(r.status===403){reqMsg.style.color="#cf4436";reqMsg.textContent="Add a request from the dashboard on THIS computer (localhost).";reqSend.disabled=false;return;}var j=await r.json();if(j.ok){reqClose();show("Request","✓ Queued as "+j.id+".\\n\\nYour AI picks this up when it next checks — tell it to \\"check requests\\" now, or it runs `yay requests` at the start of a session. It will draft the Brief + specs and send them to your phone to sign.","ok");}else{reqMsg.style.color="#cf4436";reqMsg.textContent="✗ "+(j.error||"failed");reqSend.disabled=false;}}catch(e){reqMsg.style.color="#cf4436";reqMsg.textContent="Could not queue: "+e;reqSend.disabled=false;}}'
154
+ + 'document.getElementById("yd-req").onclick=reqOpen;document.getElementById("yd-reqcancel").onclick=reqClose;reqSend.onclick=reqSubmit;'
155
+ + 'reqModal.addEventListener("click",function(e){if(e.target===reqModal)reqClose();});'
156
+ + 'reqText.addEventListener("keydown",function(e){if(e.key==="Escape"){reqClose();}else if((e.metaKey||e.ctrlKey)&&e.key==="Enter"){reqSubmit();}});'
157
+ // Promise-based confirm modal (replaces native confirm()).
158
+ + 'var cfModal=document.getElementById("yd-confirm"),cfh=document.getElementById("yd-cfh"),cfp=document.getElementById("yd-cfp"),cfok=document.getElementById("yd-cfok"),cfcancel=document.getElementById("yd-cfcancel"),cfResolve=null;'
159
+ + 'function ydConfirm(title,msg,okLabel){cfh.textContent=title;cfp.textContent=msg;cfok.textContent=okLabel||"Confirm";cfModal.classList.add("open");setTimeout(function(){cfok.focus();},30);return new Promise(function(res){cfResolve=res;});}'
160
+ + 'function cfDone(v){cfModal.classList.remove("open");if(cfResolve){var r=cfResolve;cfResolve=null;r(v);}}'
161
+ + 'cfok.onclick=function(){cfDone(true);};cfcancel.onclick=function(){cfDone(false);};'
162
+ + 'cfModal.addEventListener("click",function(e){if(e.target===cfModal)cfDone(false);});'
163
+ + 'document.addEventListener("keydown",function(e){if(cfModal.classList.contains("open")&&e.key==="Escape")cfDone(false);});'
164
+ + 'var rs=document.getElementById("yd-reseal");if(rs)rs.onclick=async function(){if(!(await ydConfirm("Re-seal the foundation?","Signs a new baseline of the fixed core files (Constitution, CI workflow, .gitignore, protocol) — approve on your phone. This is identical to running `yay protect` in the terminal. Do this only after a change you made on purpose.","Re-seal")))return;show("Foundation","Sent to your phone — approve there…");try{var r=await fetch("/api/protect/reseal",{method:"POST"});if(r.status===403){show("Foundation","Re-seal from the dashboard on THIS computer (localhost).","err");return;}var j=await r.json();if(j.ok){show("Foundation","✓ Re-sealed. Reloading…","ok");setTimeout(function(){location.reload();},900);}else{show("Foundation","✗ "+(j.error||"failed"),"err");}}catch(e){show("Foundation","Could not re-seal: "+e,"err");}};'
165
+ + 'var ydPlanBtn=document.getElementById("plan-refresh");if(ydPlanBtn)ydPlanBtn.onclick=async function(){if(!(await ydConfirm("Regenerate the System Plan?","This calls your configured AI and costs tokens.","Regenerate")))return;ydPlanBtn.classList.add("spin");show("System Plan","Regenerating via your configured AI… (a few seconds)");try{var r=await fetch("/api/plan/regen",{method:"POST"});if(r.status===403){ydPlanBtn.classList.remove("spin");show("System Plan","Regenerate from the dashboard on THIS computer (localhost).","err");return;}var j=await r.json();if(j.ok){show("System Plan","✓ Updated ("+j.provider+"/"+j.model+", "+j.subsystems+" subsystems). Reloading…","ok");setTimeout(function(){location.reload();},900);}else{ydPlanBtn.classList.remove("spin");show("System Plan","✗ "+(j.error||"failed"),"err");}}catch(e){ydPlanBtn.classList.remove("spin");show("System Plan","Could not regenerate: "+e,"err");}};'
166
+ + 'function liveState(cls,txt){ if(!live) return; live.className="yd-live"+(cls?(" "+cls):""); live.textContent=txt; }'
167
+ + 'async function poll(){try{var r=await fetch("/api/version",{cache:"no-store"});var j=await r.json();if(j.v&&j.v!==V){liveState("upd","● Updated — refreshing");setTimeout(function(){location.reload();},500);}else{liveState("","● Live");}}catch(_){liveState("stopped","● Server stopped");}}'
168
+ + 'setInterval(poll,3000);})();</script>';
169
+ return mapHTML.indexOf('</body>') >= 0 ? mapHTML.replace('</body>', bar + js + '</body>') : mapHTML + bar + js;
170
+ }
171
+
172
+ // deps: { buildMapHTML():{html}, version():string, testInfo():{configured,cmd},
173
+ // runTests():Promise<result>, regenPlan():Promise<{ok,...}> }
174
+ function startDashboard(deps, opts) {
175
+ opts = opts || {};
176
+ let lastTest = null;
177
+ // ── relay: one pending request at a time; the CLI posts it, the phone (already
178
+ // open at this one origin) picks it up and signs, the CLI reads the result. This
179
+ // is what lets the human scan the QR ONCE and then approve everything from here.
180
+ let pending = null; // { mode, session, expectPubs, genesis, done, submitted, waiters:[], at }
181
+ let finalStatus = null; // outcome the phone shows after it submits (pair confirm / ✓)
182
+ // ── teammate invites (owner-initiated, 30-min one-time). An owner runs `yay invite`
183
+ // to mint one; the new member opens /join, makes a key, and submits their PUBLIC key;
184
+ // we then run the normal owner-signed enroll (routed to the owner's phone), so nothing
185
+ // trust-critical is new here — this only collects the pubkey and triggers `yay enroll`.
186
+ const invites = new Map(); // token → { name, role, exp, used, done, result, code }
187
+ const INVITE_TTL = 30 * 60 * 1000;
188
+ const purgeInvites = () => { const now = Date.now(); for (const [k, v] of invites) if (v.exp < now && v.done !== false) invites.delete(k); };
189
+ const phoneHTML = signerHTML({ mode: 'dashboard', project: deps.project || 'project' });
190
+ function verifySubmit(b) {
191
+ if (pending.mode === 'pair') {
192
+ const { name, pubB64, proof } = b || {};
193
+ if (!name || !pubB64 || !proof) return { error: 'missing name/pubB64/proof' };
194
+ if (pending.genesis) {
195
+ const ev = { ...pending.genesis, name: String(name), pub: pubB64, by: String(name) };
196
+ if (!C.verify(canonical(ev), proof, pubB64)) return { error: 'genesis self-signature failed' };
197
+ return { code: confirmCode(pubB64), done: { name: String(name), pubB64, code: confirmCode(pubB64), genesisEvent: { ...ev, signature: proof } } };
198
+ }
199
+ if (!C.verify(pending.session.challenge, proof, pubB64)) return { error: 'key possession proof failed' };
200
+ return { code: confirmCode(pubB64), done: { name: String(name), pubB64, proof, code: confirmCode(pubB64) } };
201
+ }
202
+ // Send back (§5): the signer declined an approval and optionally noted what to change.
203
+ if (pending.mode === 'approve' && b && b.rejected) return { done: { rejected: true, reason: String(b.reason || '').trim(), tags: Array.isArray(b.tags) ? b.tags : undefined } };
204
+ // approve / authorize: verify the signature over the canonical approval/event.
205
+ // If the phone edited the Brief text (§5), rebuild the approval with it so the
206
+ // signature is checked against — and the seal stores — exactly what was signed.
207
+ let target = pending.session.approval || pending.session.event;
208
+ const editedBrief = (b && b.brief !== undefined && pending.session.approval && pending.session.approval.brief);
209
+ if (editedBrief) target = { ...pending.session.approval, brief: { ...pending.session.approval.brief, text: String(b.brief).trim() } };
210
+ const canon = canonical(target);
211
+ if (!b || !b.signature) return { error: 'missing signature' };
212
+ if (!(pending.expectPubs || []).some((pub) => pub && C.verify(canon, b.signature, pub))) return { error: 'not signed by an authorized key on this phone' };
213
+ return { done: editedBrief ? { signature: b.signature, brief: String(b.brief).trim() } : { signature: b.signature } };
214
+ }
215
+ const handler = async (req, res) => {
216
+ const url = req.url.split('?')[0];
217
+ if (req.method === 'OPTIONS') return sendJSON(res, 200, {});
218
+ if (req.method === 'GET' && url === '/api/ping') return sendJSON(res, 200, { yay: 'dashboard', busy: !!pending });
219
+
220
+ // ── CLI-facing (localhost only) ──
221
+ if (req.method === 'POST' && url === '/api/request') {
222
+ if (!isLocal(req)) return sendJSON(res, 403, { error: 'local only' });
223
+ if (pending) return sendJSON(res, 409, { error: 'a request is already awaiting the phone' });
224
+ const b = await readBody(req);
225
+ const session = { mode: b.mode };
226
+ if (b.approval) session.approval = b.approval;
227
+ if (b.event) session.event = b.event;
228
+ if (b.summary) session.summary = b.summary;
229
+ if (b.tagPool) session.tagPool = b.tagPool;
230
+ if (b.challenge) session.challenge = b.challenge;
231
+ if (b.genesis) session.genesis = b.genesis;
232
+ if (b.signer) session.signer = b.signer;
233
+ if (b.signerPubs) session.signerPubs = b.signerPubs;
234
+ pending = { mode: b.mode, session, expectPubs: b.expectPubB64 || b.ownerPubs || [], genesis: b.genesis || null, done: null, submitted: false, waiters: [], at: Date.now() };
235
+ finalStatus = null;
236
+ return sendJSON(res, 200, { ok: true });
237
+ }
238
+ if (req.method === 'GET' && url === '/api/result') { // CLI long-poll for the phone's subbrief
239
+ if (!isLocal(req)) return sendJSON(res, 403, { error: 'local only' });
240
+ if (!pending) return sendJSON(res, 200, { result: null, gone: true });
241
+ if (pending.done) return sendJSON(res, 200, { result: pending.done });
242
+ pending.waiters.push(res); return; // held until the phone submits
243
+ }
244
+ if (req.method === 'POST' && url === '/api/final') { // CLI publishes the outcome, then clears the slot
245
+ if (!isLocal(req)) return sendJSON(res, 403, { error: 'local only' });
246
+ finalStatus = (await readBody(req)).final || null; pending = null; return sendJSON(res, 200, { ok: true });
247
+ }
248
+ if (req.method === 'POST' && url === '/api/cancel') { if (!isLocal(req)) return sendJSON(res, 403, { error: 'local only' }); pending = null; finalStatus = null; return sendJSON(res, 200, { ok: true }); }
249
+ // Owner mints a teammate invite (localhost only — an owner on this machine).
250
+ if (req.method === 'POST' && url === '/api/invite/create') {
251
+ if (!isLocal(req)) return sendJSON(res, 403, { error: 'local only' });
252
+ purgeInvites();
253
+ const b = await readBody(req);
254
+ const name = String(b.name || '').trim(); // an optional SUGGESTION — the joiner can edit or set their own
255
+ const role = b.role === 'owner' ? 'owner' : 'signer';
256
+ const token = crypto.randomBytes(18).toString('hex');
257
+ invites.set(token, { name, role, exp: Date.now() + INVITE_TTL, used: false, done: null, result: null, code: null });
258
+ return sendJSON(res, 200, { ok: true, token, name, role, joinPath: '/join?t=' + token, expiresInMin: INVITE_TTL / 60000 });
259
+ }
260
+ // Owner issues an Autopilot grant (localhost only; the approval routes to the owner's phone).
261
+ if (req.method === 'POST' && url === '/api/grant/create') {
262
+ if (!isLocal(req)) return sendJSON(res, 403, { error: 'issue a grant from the dashboard on THIS computer (localhost).' });
263
+ if (!deps.grant) return sendJSON(res, 200, { error: 'grant issuance is not available on this dashboard' });
264
+ const b = await readBody(req);
265
+ const r = await deps.grant(b);
266
+ return sendJSON(res, 200, r);
267
+ }
268
+
269
+ // ── phone-facing ──
270
+ if (req.method === 'GET' && (url === '/phone' || url === '/phone.html')) return sendHTML(res, phoneHTML);
271
+ // Teammate join page + its API (the new member's phone).
272
+ if (req.method === 'GET' && url === '/join') {
273
+ const t = new URLSearchParams(req.url.split('?')[1] || '').get('t') || '';
274
+ return sendHTML(res, signerHTML({ mode: 'join', project: deps.project || 'project', token: t }));
275
+ }
276
+ if (req.method === 'GET' && url === '/api/invite/info') {
277
+ const t = new URLSearchParams(req.url.split('?')[1] || '').get('t') || '';
278
+ const inv = invites.get(t);
279
+ if (!inv || inv.exp < Date.now()) return sendJSON(res, 200, { valid: false });
280
+ return sendJSON(res, 200, { valid: true, name: inv.name, role: inv.role, used: !!inv.used, project: deps.project || 'project' });
281
+ }
282
+ if (req.method === 'POST' && url === '/api/invite/join') {
283
+ const b = await readBody(req);
284
+ const inv = invites.get(b.token);
285
+ if (!inv || inv.exp < Date.now()) return sendJSON(res, 400, { error: 'this invite is invalid or has expired — ask for a new one' });
286
+ if (inv.used) return sendJSON(res, 409, { error: 'this invite has already been used' });
287
+ if (!b.pubB64 || !b.proof || !C.verify(b.token, b.proof, b.pubB64)) return sendJSON(res, 400, { error: 'key possession proof failed' });
288
+ if (!deps.enroll) return sendJSON(res, 200, { error: 'enrollment is not available on this dashboard' });
289
+ // The joiner's chosen name (pre-filled from the invite suggestion, editable) is the
290
+ // roster label; the owner sees and approves it on their phone.
291
+ const memberName = String(b.name || '').trim() || inv.name;
292
+ if (!memberName) return sendJSON(res, 400, { error: 'a name is required' });
293
+ inv.used = true; inv.done = false; inv.result = null; inv.code = confirmCode(b.pubB64); inv.memberName = memberName;
294
+ // Run the normal owner-signed enroll (routes the approval to the owner's phone).
295
+ Promise.resolve(deps.enroll({ name: memberName, pubkey: b.pubB64, role: inv.role }))
296
+ .then((r) => { inv.result = r || { ok: false, error: 'no result' }; inv.done = true; })
297
+ .catch((e) => { inv.result = { ok: false, error: String((e && e.message) || e) }; inv.done = true; });
298
+ return sendJSON(res, 200, { ok: true, code: inv.code });
299
+ }
300
+ if (req.method === 'GET' && url === '/api/invite/status') {
301
+ const t = new URLSearchParams(req.url.split('?')[1] || '').get('t') || '';
302
+ const inv = invites.get(t);
303
+ if (!inv) return sendJSON(res, 200, { gone: true });
304
+ return sendJSON(res, 200, { done: !!inv.done, result: inv.result || null, code: inv.code || null });
305
+ }
306
+ // Certificate download + trust guide, so users get the cert FROM yay-layer (not off the laptop).
307
+ if (req.method === 'GET' && (url === '/ca' || url === '/ca.crt' || url === '/ca.pem' || url === '/ca.cer')) {
308
+ if (!opts.caPem) return sendJSON(res, 404, { error: 'no certificate to install (running over http, or self-signed cert unavailable)' });
309
+ return sendCert(res, opts.caPem, opts.caFilename);
310
+ }
311
+ if (req.method === 'GET' && (url === '/trust' || url === '/cert')) {
312
+ if (!opts.caPem) return sendHTML(res, '<meta name=viewport content="width=device-width,initial-scale=1"><p style="font:16px sans-serif;padding:24px">This dashboard is running over plain http (or without an installable certificate), so there is nothing to trust — the phone connects directly.</p>');
313
+ return sendHTML(res, trustHTML());
314
+ }
315
+ if (req.method === 'GET' && url === '/api/session') return sendJSON(res, 200, pending ? { ...pending.session } : { mode: 'idle' });
316
+ if (req.method === 'GET' && url === '/api/status') return sendJSON(res, 200, { final: finalStatus });
317
+ if (req.method === 'POST' && url === '/api/submit') {
318
+ if (!pending) return sendJSON(res, 409, { error: 'nothing to sign right now' });
319
+ const out = verifySubmit(await readBody(req));
320
+ if (out.error) return sendJSON(res, 400, { error: out.error });
321
+ pending.done = out.done; pending.submitted = true;
322
+ const ws = pending.waiters; pending.waiters = [];
323
+ for (const w of ws) { try { sendJSON(w, 200, { result: out.done }); } catch (_) {} }
324
+ return sendJSON(res, 200, { ok: true, code: out.code });
325
+ }
326
+
327
+ if (req.method === 'GET' && url === '/api/version') { try { return sendJSON(res, 200, { v: deps.version() }); } catch (e) { return sendJSON(res, 200, { v: 'err' }); } }
328
+ if (req.method === 'GET' && url === '/api/diffs') { try { return sendJSON(res, 200, { diffs: deps.diffs ? deps.diffs() : [] }); } catch (e) { return sendJSON(res, 200, { diffs: [], error: String(e && e.message || e) }); } }
329
+ if (req.method === 'GET' && url === '/api/tests') return sendJSON(res, 200, { ...(deps.testInfo ? deps.testInfo() : { configured: false }), last: lastTest });
330
+ if (req.method === 'POST' && url === '/api/tests/run') {
331
+ if (!isLocal(req)) return sendJSON(res, 403, { error: 'local only' });
332
+ if (!deps.runTests) return sendJSON(res, 200, { configured: false, output: 'tests not available' });
333
+ const r = await deps.runTests(); lastTest = r; return sendJSON(res, 200, r);
334
+ }
335
+ if (req.method === 'POST' && url === '/api/adversary/run') {
336
+ if (!isLocal(req)) return sendJSON(res, 403, { error: 'local only' });
337
+ if (!deps.adversary) return sendJSON(res, 200, { error: 'adversary not available' });
338
+ return sendJSON(res, 200, await deps.adversary());
339
+ }
340
+ if (req.method === 'POST' && url === '/api/ask') {
341
+ if (!isLocal(req)) return sendJSON(res, 403, { error: 'local only' });
342
+ if (!deps.ask) return sendJSON(res, 200, { ok: false, error: 'assistant not available' });
343
+ const q = ((await readBody(req)).question || '').toString();
344
+ try { return sendJSON(res, 200, await deps.ask(q)); }
345
+ catch (e) { return sendJSON(res, 200, { ok: false, error: String((e && e.message) || e) }); }
346
+ }
347
+ if (req.method === 'POST' && url === '/api/protect/reseal') {
348
+ if (!isLocal(req)) return sendJSON(res, 403, { error: 'local only' });
349
+ if (!deps.protect) return sendJSON(res, 200, { ok: false, error: 'foundation seal not available' });
350
+ return sendJSON(res, 200, await deps.protect());
351
+ }
352
+ if (req.method === 'POST' && url === '/api/plan/regen') {
353
+ if (!isLocal(req)) return sendJSON(res, 403, { error: 'local only' });
354
+ if (!deps.regenPlan) return sendJSON(res, 200, { ok: false, error: 'plan not available' });
355
+ return sendJSON(res, 200, await deps.regenPlan());
356
+ }
357
+ // Human-initiated sign FROM the dashboard: gather the current change-set under the
358
+ // given brief and push it to the phone to approve (the key stays on the phone).
359
+ if (req.method === 'POST' && url === '/api/sign/start') {
360
+ if (!isLocal(req)) return sendJSON(res, 403, { error: 'local only' });
361
+ if (!deps.signPending) return sendJSON(res, 200, { ok: false, error: 'signing not available' });
362
+ if (pending) return sendJSON(res, 409, { error: 'a request is already awaiting the phone' });
363
+ const brief = ((await readBody(req)).brief || '').trim();
364
+ if (!brief) return sendJSON(res, 400, { error: 'a brief is required' });
365
+ try { return sendJSON(res, 200, await deps.signPending(brief)); }
366
+ catch (e) { return sendJSON(res, 200, { ok: false, error: String((e && e.message) || e) }); }
367
+ }
368
+ // Queue a plain-language request FROM the dashboard. The AI picks it up (`yay requests`)
369
+ // and turns it into a polished Brief + Cells to sign — the human never writes the Brief.
370
+ if (req.method === 'POST' && url === '/api/request/create') {
371
+ if (!isLocal(req)) return sendJSON(res, 403, { error: 'local only' });
372
+ if (!deps.addRequest) return sendJSON(res, 200, { ok: false, error: 'requests not available' });
373
+ const text = ((await readBody(req)).text || '').trim();
374
+ if (!text) return sendJSON(res, 400, { error: 'a request is required' });
375
+ try { return sendJSON(res, 200, await deps.addRequest(text)); }
376
+ catch (e) { return sendJSON(res, 200, { ok: false, error: String((e && e.message) || e) }); }
377
+ }
378
+ // Batch settings: how many small changes group into one Brief.
379
+ if (req.method === 'POST' && url === '/api/batch') {
380
+ if (!isLocal(req)) return sendJSON(res, 403, { error: 'local only' });
381
+ if (!deps.batchSet) return sendJSON(res, 200, { ok: false, error: 'batch settings not available' });
382
+ const b = await readBody(req);
383
+ try { return sendJSON(res, 200, deps.batchSet(b)); }
384
+ catch (e) { return sendJSON(res, 200, { ok: false, error: String((e && e.message) || e) }); }
385
+ }
386
+ // Preview: run package.json scripts (dev server, build…) through the dashboard.
387
+ if (req.method === 'GET' && url === '/api/scripts') {
388
+ if (!isLocal(req)) return sendJSON(res, 403, { error: 'local only' });
389
+ if (!deps.scripts) return sendJSON(res, 200, { scripts: [], running: [] });
390
+ try { return sendJSON(res, 200, deps.scripts()); } catch (e) { return sendJSON(res, 200, { scripts: [], running: [], error: String((e && e.message) || e) }); }
391
+ }
392
+ if (req.method === 'POST' && (url === '/api/scripts/run' || url === '/api/scripts/stop')) {
393
+ if (!isLocal(req)) return sendJSON(res, 403, { error: 'local only' });
394
+ const fn = url.endsWith('/run') ? deps.runScript : deps.stopScript;
395
+ if (!fn) return sendJSON(res, 200, { ok: false, error: 'preview not available' });
396
+ const name = (await readBody(req)).name;
397
+ try { return sendJSON(res, 200, fn(name)); } catch (e) { return sendJSON(res, 200, { ok: false, error: String((e && e.message) || e) }); }
398
+ }
399
+ // Tag-pool editor (live): add / remove / rename / describe tags in .yaylayer/tags.json.
400
+ if (req.method === 'POST' && url === '/api/tags/edit') {
401
+ if (!isLocal(req)) return sendJSON(res, 403, { error: 'local only' });
402
+ if (!deps.tagsEdit) return sendJSON(res, 200, { ok: false, error: 'tag editing not available' });
403
+ const b = await readBody(req);
404
+ try { return sendJSON(res, 200, deps.tagsEdit(b)); }
405
+ catch (e) { return sendJSON(res, 200, { ok: false, error: String((e && e.message) || e) }); }
406
+ }
407
+ // Policy editor: edit the DRAFT (.yaylayer/policy.json), then owner-sign it into effect.
408
+ if (req.method === 'POST' && url === '/api/policy/rule') {
409
+ if (!isLocal(req)) return sendJSON(res, 403, { error: 'local only' });
410
+ if (!deps.policyAddRule) return sendJSON(res, 200, { ok: false, error: 'policy editing not available' });
411
+ const b = await readBody(req);
412
+ try { return sendJSON(res, 200, deps.policyAddRule({ match: b.match || {}, signer: b.signer, inert: b.inert, ignore: b.ignore })); }
413
+ catch (e) { return sendJSON(res, 200, { ok: false, error: String((e && e.message) || e) }); }
414
+ }
415
+ if (req.method === 'POST' && url === '/api/policy/remove') {
416
+ if (!isLocal(req)) return sendJSON(res, 403, { error: 'local only' });
417
+ if (!deps.policyRemoveRule) return sendJSON(res, 200, { ok: false, error: 'policy editing not available' });
418
+ const b = await readBody(req);
419
+ try { return sendJSON(res, 200, deps.policyRemoveRule(parseInt(b.index, 10))); }
420
+ catch (e) { return sendJSON(res, 200, { ok: false, error: String((e && e.message) || e) }); }
421
+ }
422
+ if (req.method === 'POST' && url === '/api/policy/apply') {
423
+ if (!isLocal(req)) return sendJSON(res, 403, { error: 'local only' });
424
+ if (!deps.policyApply) return sendJSON(res, 200, { ok: false, error: 'policy apply not available' });
425
+ if (pending) return sendJSON(res, 409, { error: 'a request is already awaiting the phone' });
426
+ try { return sendJSON(res, 200, await deps.policyApply()); }
427
+ catch (e) { return sendJSON(res, 200, { ok: false, error: String((e && e.message) || e) }); }
428
+ }
429
+ // Autopilot: ratify (human-sign for real) the Cells auto-approved under a grant. Routes
430
+ // the signature to the phone (or local key), same as any sign — supersedes the delegation.
431
+ if (req.method === 'POST' && url === '/api/ratify') {
432
+ if (!isLocal(req)) return sendJSON(res, 403, { error: 'local only' });
433
+ if (!deps.ratifyApply) return sendJSON(res, 200, { ok: false, error: 'ratify not available' });
434
+ if (pending) return sendJSON(res, 409, { error: 'a request is already awaiting the phone' });
435
+ const b = await readBody(req); // b.reviewed = the bundle hash the page rendered (TOCTOU guard)
436
+ try { return sendJSON(res, 200, await deps.ratifyApply(b && b.reviewed)); }
437
+ catch (e) { return sendJSON(res, 200, { ok: false, error: String((e && e.message) || e) }); }
438
+ }
439
+ // Briefs history lens: a Cell as a given Brief signed it (git), + current + a then→now diff.
440
+ if (req.method === 'POST' && url === '/api/cell-history') {
441
+ if (!isLocal(req)) return sendJSON(res, 403, { error: 'local only' });
442
+ if (!deps.cellAsOf) return sendJSON(res, 200, { ok: false, error: 'history not available' });
443
+ const b = await readBody(req);
444
+ try { return sendJSON(res, 200, deps.cellAsOf(String(b.brief || ''), String(b.cell || ''))); }
445
+ catch (e) { return sendJSON(res, 200, { ok: false, error: String((e && e.message) || e) }); }
446
+ }
447
+ if (req.method === 'GET' && (url === '/' || url === '/index.html' || url === '/map')) {
448
+ try { const m = deps.buildMapHTML(); return sendHTML(res, withLiveControls(m.html, deps.version())); }
449
+ catch (e) { return sendHTML(res, '<pre style="font-family:monospace;padding:24px;color:#d92d20">map build error:\n' + String((e && e.stack) || e).replace(/[<&]/g, '_') + '</pre>'); }
450
+ }
451
+ sendJSON(res, 404, { error: 'not found' });
452
+ };
453
+ const tls = opts.tls;
454
+ const server = tls ? require('https').createServer({ key: tls.key, cert: tls.cert }, handler) : http.createServer(handler);
455
+ const scheme = tls ? 'https' : 'http';
456
+ const port = (opts.port !== undefined && opts.port !== null) ? opts.port : 48757; // port 0 = random (tests)
457
+ return new Promise((resolve, reject) => {
458
+ server.once('error', reject);
459
+ server.listen(port, '0.0.0.0', () => { const bound = server.address().port; resolve({ url: `${scheme}://${lanIP()}:${bound}`, local: `${scheme}://localhost:${bound}`, port: bound, close: () => server.close() }); });
460
+ });
461
+ }
462
+
463
+ module.exports = { startDashboard, withLiveControls, lanIP };