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/CONSTITUTION.md +55 -0
- package/LICENSE +21 -0
- package/README.md +383 -0
- package/bin/yay.js +3550 -0
- package/package.json +55 -0
- package/src/adopt.js +181 -0
- package/src/adversary.js +119 -0
- package/src/analyze.js +270 -0
- package/src/assurance.js +122 -0
- package/src/attest.js +216 -0
- package/src/capability.js +59 -0
- package/src/constitution.js +162 -0
- package/src/coverage.js +77 -0
- package/src/crypto.js +78 -0
- package/src/dashboard.js +463 -0
- package/src/durable.js +152 -0
- package/src/e2e.js +67 -0
- package/src/extract.js +179 -0
- package/src/foundation.js +143 -0
- package/src/gate.js +252 -0
- package/src/grants.js +249 -0
- package/src/history.js +77 -0
- package/src/ids.js +40 -0
- package/src/manifest.js +303 -0
- package/src/map.js +1742 -0
- package/src/mutate.js +192 -0
- package/src/objects.js +31 -0
- package/src/phone.js +188 -0
- package/src/plan.js +141 -0
- package/src/policy.js +0 -0
- package/src/predicate.js +143 -0
- package/src/prove.js +876 -0
- package/src/ratify.js +28 -0
- package/src/record.js +30 -0
- package/src/reverify.js +212 -0
- package/src/roster.js +117 -0
- package/src/signer-page.js +603 -0
- package/src/specdiff.js +69 -0
- package/src/tags.js +67 -0
- package/src/testrun.js +41 -0
- package/src/util.js +234 -0
- package/src/vendor/recovery.js +217 -0
- package/src/vendor/tweetnacl.min.js +1 -0
- package/src/verify.js +560 -0
- package/standard/STANDARD.md +135 -0
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// The phone signer — a self-contained page served by `yay pair` / `yay sign --phone`.
|
|
3
|
+
// The key is generated and kept ON THE PHONE (ed25519 via bundled TweetNaCl, stored
|
|
4
|
+
// locally); the laptop only receives the public key and signatures. Formats match
|
|
5
|
+
// src/crypto.js: public key = base64 SPKI-DER (raw key wrapped with the ed25519 SPKI
|
|
6
|
+
// header), signature = base64 raw ed25519 over canonical(approval) — so `yay verify`
|
|
7
|
+
// checks it like any other seal.
|
|
8
|
+
//
|
|
9
|
+
// Uses PURE-JS crypto (not WebCrypto), so it works over a plain http LAN address —
|
|
10
|
+
// no secure-context / HTTPS requirement. (TweetNaCl only needs crypto.getRandomValues,
|
|
11
|
+
// which is available over http.) HTTPS is an opt-in transport, not a requirement.
|
|
12
|
+
//
|
|
13
|
+
// Visual design: a calm, card-led look matching relay.yaylayer.com — accent-bordered
|
|
14
|
+
// cards, uppercase micro-labels, muted helper text, one clear primary action per screen.
|
|
15
|
+
// System fonts only (no web-font fetch), so it renders fully offline on the LAN.
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const NACL = fs.readFileSync(path.join(__dirname, 'vendor', 'tweetnacl.min.js'), 'utf8');
|
|
20
|
+
const RECOVERY = fs.readFileSync(path.join(__dirname, 'vendor', 'recovery.js'), 'utf8');
|
|
21
|
+
|
|
22
|
+
function signerHTML({ mode, project, token }) {
|
|
23
|
+
const M = JSON.stringify(mode || 'pair');
|
|
24
|
+
const P = JSON.stringify(project || 'project');
|
|
25
|
+
const T = JSON.stringify(token || '');
|
|
26
|
+
return `<!doctype html><html lang="en"><head>
|
|
27
|
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
|
28
|
+
<title>YayLayer Signer</title>
|
|
29
|
+
<style>
|
|
30
|
+
:root{
|
|
31
|
+
--ground:#f6f8f6;--panel:#ffffff;--card:#ffffff;--card-tint:#eef5f0;
|
|
32
|
+
--ink:#131714;--ink-2:#5a635c;--mut:#8b948d;--rule:#e4e9e5;
|
|
33
|
+
--accent:#177f52;--accent-ink:#0a2c1d;--green:#1f9d57;--amber:#b7791f;--red:#c8402f;
|
|
34
|
+
--shadow:0 18px 40px -24px rgba(16,40,28,.40);
|
|
35
|
+
--mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
|
|
36
|
+
--sans:system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
|
37
|
+
}
|
|
38
|
+
/* Light by default on every phone (matches the site + mockups), regardless of the phone's OS theme. */
|
|
39
|
+
*{box-sizing:border-box}
|
|
40
|
+
body{margin:0;background:var(--ground);color:var(--ink);font-family:var(--sans);line-height:1.55;-webkit-font-smoothing:antialiased;
|
|
41
|
+
min-height:100vh;display:flex;flex-direction:column;align-items:center;padding:26px 18px}
|
|
42
|
+
.top{width:100%;max-width:460px;margin-bottom:14px;text-align:center}
|
|
43
|
+
.brandrow{display:flex;align-items:center;justify-content:center;gap:9px;margin-bottom:10px}
|
|
44
|
+
.logo{display:inline-flex;flex:none}
|
|
45
|
+
.brand{font-weight:700;font-size:.92rem;color:var(--ink)}
|
|
46
|
+
h1{font-weight:800;font-size:1.5rem;letter-spacing:-.01em;margin:2px 0 0}
|
|
47
|
+
.sub{color:var(--mut);font-size:.76rem;font-family:var(--mono);letter-spacing:.02em;margin-top:3px}
|
|
48
|
+
#app{width:100%;max-width:460px;background:var(--panel);border:1px solid var(--rule);border-radius:20px;padding:22px;box-shadow:var(--shadow)}
|
|
49
|
+
.msg{color:var(--ink);font-size:.98rem;margin:0 0 14px}
|
|
50
|
+
.help{color:var(--mut);font-size:.85rem;line-height:1.5;margin:0 0 14px}
|
|
51
|
+
.lab{font-family:var(--mono);font-size:.64rem;font-weight:600;letter-spacing:.15em;text-transform:uppercase;color:var(--accent);margin:0 0 8px}
|
|
52
|
+
.lbl{display:block;font-size:.8rem;color:var(--mut);margin:0 0 7px}
|
|
53
|
+
.inp{width:100%;font-size:1.05rem;padding:13px 14px;border-radius:12px;border:1px solid var(--rule);background:var(--card);color:var(--ink);font-family:var(--sans);margin-bottom:14px}
|
|
54
|
+
.inp:focus{outline:2px solid var(--accent);outline-offset:1px;border-color:var(--accent)}
|
|
55
|
+
textarea.inp{min-height:96px;resize:vertical;font-family:var(--mono);font-size:.95rem}
|
|
56
|
+
.btn{width:100%;font-family:var(--sans);font-size:1rem;font-weight:700;padding:15px;border:none;border-radius:13px;background:var(--accent);color:var(--accent-ink);cursor:pointer}
|
|
57
|
+
.btn:active{filter:brightness(.94)}
|
|
58
|
+
.btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
59
|
+
.btn.alt,.btn.ghost{background:transparent;color:var(--accent);border:1px solid var(--rule);margin-top:10px}
|
|
60
|
+
.btnrow{display:flex;gap:10px}.btnrow .btn{flex:1;margin-top:0}
|
|
61
|
+
.link{display:block;text-align:center;margin-top:15px;color:var(--accent);text-decoration:underline;cursor:pointer;font-size:.9rem}
|
|
62
|
+
.code{font-family:var(--mono);font-size:clamp(1.7rem,8.5vw,2.3rem);font-weight:600;letter-spacing:.12em;text-align:center;color:var(--accent);margin:4px 0;white-space:nowrap;overflow-x:auto}
|
|
63
|
+
.bigok{display:grid;place-items:center;gap:13px;text-align:center;padding:20px 0}
|
|
64
|
+
.check{width:64px;height:64px;border-radius:50%;background:var(--card-tint);display:grid;place-items:center}
|
|
65
|
+
.check svg{width:32px;height:32px;stroke:var(--green);stroke-width:3;fill:none;stroke-linecap:round;stroke-linejoin:round}
|
|
66
|
+
.bigok .t{font-weight:800;font-size:1.4rem;color:var(--green)}
|
|
67
|
+
.bigok .h{font-weight:700;font-size:1.15rem;color:var(--ink)}
|
|
68
|
+
.bigok .help{margin:0;max-width:27ch}
|
|
69
|
+
.ok-big{font-weight:800;font-size:1.4rem;color:var(--green);text-align:center;margin:6px 0}
|
|
70
|
+
.spin{width:38px;height:38px;border-radius:50%;border:3px solid var(--rule);border-top-color:var(--accent);animation:sp 1s linear infinite}
|
|
71
|
+
@media(prefers-reduced-motion:reduce){.spin{animation:none}}
|
|
72
|
+
@keyframes sp{to{transform:rotate(360deg)}}
|
|
73
|
+
.crow{border-top:1px solid var(--rule)}.crow:first-of-type{border-top:none}.crow .cell{border-top:none}
|
|
74
|
+
.cell{display:flex;align-items:flex-start;gap:11px;padding:12px 2px;border-top:1px solid var(--rule)}
|
|
75
|
+
.cell:first-of-type{border-top:none}
|
|
76
|
+
.cell.tap{cursor:pointer;user-select:none}
|
|
77
|
+
.dot{width:9px;height:9px;border-radius:50%;flex:none;margin-top:6px}
|
|
78
|
+
.cid{font-family:var(--mono);font-size:.79rem;font-weight:600}
|
|
79
|
+
.cin{color:var(--ink-2);font-size:.84rem;line-height:1.4}
|
|
80
|
+
.col{margin-left:auto;font-family:var(--mono);font-size:.58rem;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--mut);border:1px solid var(--rule);border-radius:99px;padding:3px 8px;white-space:nowrap;display:inline-flex;align-items:center;gap:4px}
|
|
81
|
+
.caret{color:var(--mut);font-size:.7rem}
|
|
82
|
+
.detailwrap{padding:2px 2px 12px 20px}
|
|
83
|
+
.kv{display:flex;gap:10px;padding:5px 0;font-size:.85rem;border-top:1px solid var(--rule)}.kv:first-child{border-top:none}
|
|
84
|
+
.kv .k{font-family:var(--mono);color:var(--mut);min-width:62px;flex:none}
|
|
85
|
+
.kv .v{white-space:pre-wrap;word-break:break-word}
|
|
86
|
+
.notes{margin-top:8px;padding-top:8px;border-top:1px dashed var(--rule)}.note{font-size:.82rem;padding:2px 0}
|
|
87
|
+
.difflbl{font-family:var(--mono);font-size:.7rem;color:var(--mut);margin:10px 0 6px;text-transform:uppercase;letter-spacing:.06em}
|
|
88
|
+
.diff{font-family:var(--mono);font-size:.8rem;border:1px solid var(--rule);border-radius:9px;overflow:hidden}
|
|
89
|
+
.dl{padding:3px 9px;white-space:pre-wrap;word-break:break-word;border-top:1px solid var(--rule)}.dl:first-child{border-top:none}
|
|
90
|
+
.dl.add{background:rgba(31,157,87,.14);color:var(--green)}
|
|
91
|
+
.dl.del{background:rgba(200,64,47,.14);color:var(--red)}
|
|
92
|
+
.dl.ctx{color:var(--mut)}
|
|
93
|
+
.codeblk{font-family:var(--mono);font-size:.8rem;border:1px solid var(--rule);border-radius:9px;padding:9px 11px;background:var(--card-tint);white-space:pre-wrap;word-break:break-word;overflow-x:auto;margin-top:4px;color:var(--ink)}
|
|
94
|
+
.mcard{background:var(--card-tint);border:1px solid var(--rule);border-left:3px solid var(--accent);border-radius:14px;padding:14px 15px;margin:0 0 16px}
|
|
95
|
+
.mcard .mlab{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}
|
|
96
|
+
.mcard .mtag{font-family:var(--mono);font-weight:600;letter-spacing:.15em;font-size:.64rem;text-transform:uppercase;color:var(--accent)}
|
|
97
|
+
.mcard .medit{font-size:.8rem;font-weight:600;background:none;border:0;color:var(--accent);cursor:pointer;padding:0}
|
|
98
|
+
.mcard .mtxt{font-size:1.04rem;line-height:1.45;color:var(--ink)}
|
|
99
|
+
.marea{display:block;width:100%;box-sizing:border-box;font-size:1rem;line-height:1.45;padding:10px;border-radius:10px;border:1px solid var(--rule);background:var(--card);color:var(--ink);font-family:var(--sans);min-height:88px}
|
|
100
|
+
.sbwarn{display:none;color:var(--accent);font-size:.85rem;font-weight:600;margin:7px 2px 0}
|
|
101
|
+
.mcard .msub{color:var(--mut);font-size:.75rem;margin-top:9px}
|
|
102
|
+
.words{display:grid;grid-template-columns:1fr 1fr;gap:8px 10px;margin:12px 0}
|
|
103
|
+
.word{font-family:var(--mono);font-size:.85rem;padding:8px 10px;background:var(--card);border:1px solid var(--rule);border-radius:9px;display:flex;gap:8px}
|
|
104
|
+
.word i{color:var(--mut);font-style:normal;min-width:1.3em;text-align:right}
|
|
105
|
+
.warn{color:var(--red);font-size:.84rem;line-height:1.45;margin:12px 0}
|
|
106
|
+
.chk{display:flex;align-items:flex-start;gap:9px;font-size:.9rem;margin:14px 0}
|
|
107
|
+
.chk input{margin-top:3px;width:18px;height:18px;flex:none;accent-color:var(--accent)}
|
|
108
|
+
.edited{font-family:var(--mono);font-size:.56rem;color:var(--amber);border:1px solid currentColor;border-radius:5px;padding:0 5px;margin-left:6px;vertical-align:middle;text-transform:uppercase;letter-spacing:.04em}
|
|
109
|
+
#status{width:100%;max-width:460px;margin-top:13px;font-family:var(--mono);font-size:.78rem;color:var(--mut);text-align:center;min-height:1.2em}
|
|
110
|
+
#status.ok{color:var(--green)}#status.err{color:var(--red)}
|
|
111
|
+
</style></head><body>
|
|
112
|
+
<div class="top"><div class="brandrow"><span class="logo"><svg width="24" height="24" viewBox="0 0 26 26" aria-hidden="true"><rect width="26" height="26" rx="7" fill="#177f52"/><path d="M6.5 13.5l4 4L20 7.5" fill="none" stroke="#eef5f0" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg></span><span class="brand">YayLayer Signer</span></div><h1 id="ttl">Sign</h1><div class="sub" id="proj"></div></div>
|
|
113
|
+
<div id="app"><div class="msg">Loading…</div></div>
|
|
114
|
+
<div id="status"></div>
|
|
115
|
+
<script>${NACL}</script>
|
|
116
|
+
<script>${RECOVERY}</script>
|
|
117
|
+
<script>
|
|
118
|
+
(function(){
|
|
119
|
+
var MODE=${M}, PROJECT=${P}, TOKEN=${T};
|
|
120
|
+
var app=document.getElementById('app'), statusEl=document.getElementById('status');
|
|
121
|
+
document.getElementById('proj').textContent=PROJECT;
|
|
122
|
+
document.getElementById('ttl').textContent=(MODE==='pair'?'Pair this phone':MODE==='authorize'?'Authorize change':MODE==='dashboard'?'YayLayer Signer':MODE==='join'?'Join the team':'Approve changes');
|
|
123
|
+
function setStatus(t,cls){statusEl.textContent=t;statusEl.className=cls||'';}
|
|
124
|
+
function h(html){app.innerHTML=html;}
|
|
125
|
+
function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
|
|
126
|
+
// Calm success screen (check circle + title + one muted line) — matches the mockup.
|
|
127
|
+
function okScreen(title,msg){h('<div class="bigok"><div class="check"><svg viewBox="0 0 24 24"><path d="M4 12.5l5 5L20 6.5"/></svg></div><div class="t">'+esc(title)+'</div><div class="help">'+esc(msg||'')+'</div></div>');}
|
|
128
|
+
var SPKI=new Uint8Array([48,42,48,5,6,3,43,101,112,3,33,0]); // ed25519 SPKI header (so the raw key matches Node's SPKI-DER)
|
|
129
|
+
function b64(buf){var b=new Uint8Array(buf),s='';for(var i=0;i<b.length;i++)s+=String.fromCharCode(b[i]);return btoa(s);}
|
|
130
|
+
function unb64(s){var bin=atob(s),a=new Uint8Array(bin.length);for(var i=0;i<bin.length;i++)a[i]=bin.charCodeAt(i);return a;}
|
|
131
|
+
function ebytes(str){return new TextEncoder().encode(str);}
|
|
132
|
+
function canonical(o){if(o===null||typeof o!=='object')return JSON.stringify(o);if(Array.isArray(o))return '['+o.map(canonical).join(',')+']';var k=Object.keys(o).sort(),p=[];for(var i=0;i<k.length;i++)p.push(JSON.stringify(k[i])+':'+canonical(o[k[i]]));return '{'+p.join(',')+'}';}
|
|
133
|
+
function hasCrypto(){return typeof nacl!=='undefined' && typeof YayRecovery!=='undefined' && window.crypto && typeof window.crypto.getRandomValues==='function';}
|
|
134
|
+
// WYSIWYS: a pure-JS sha256 (verified to match Node's crypto sha256 over the same UTF-8 bytes) so the
|
|
135
|
+
// phone can recompute each Cell's specHash from the spec block it DISPLAYS and refuse to sign unless it
|
|
136
|
+
// equals approval.items[id]. Runs regardless of https, so a compromised laptop can't show X and sign Y.
|
|
137
|
+
function sha256hex(str){
|
|
138
|
+
function rotr(n,x){return (x>>>n)|(x<<(32-n));}
|
|
139
|
+
var K=[0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];
|
|
140
|
+
var H=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19];
|
|
141
|
+
var bytes=[];for(var i=0;i<str.length;i++){var c=str.charCodeAt(i);
|
|
142
|
+
if(c<0x80)bytes.push(c);
|
|
143
|
+
else if(c<0x800)bytes.push(0xc0|(c>>6),0x80|(c&0x3f));
|
|
144
|
+
else if(c<0xd800||c>=0xe000)bytes.push(0xe0|(c>>12),0x80|((c>>6)&0x3f),0x80|(c&0x3f));
|
|
145
|
+
else{i++;var c2=str.charCodeAt(i);var cp=0x10000+(((c&0x3ff)<<10)|(c2&0x3ff));bytes.push(0xf0|(cp>>18),0x80|((cp>>12)&0x3f),0x80|((cp>>6)&0x3f),0x80|(cp&0x3f));}}
|
|
146
|
+
var l=bytes.length;bytes.push(0x80);while((bytes.length%64)!==56)bytes.push(0);
|
|
147
|
+
var bl=l*8,hi=Math.floor(bl/0x100000000),lo=bl>>>0;
|
|
148
|
+
bytes.push((hi>>>24)&0xff,(hi>>>16)&0xff,(hi>>>8)&0xff,hi&0xff,(lo>>>24)&0xff,(lo>>>16)&0xff,(lo>>>8)&0xff,lo&0xff);
|
|
149
|
+
var w=new Array(64);
|
|
150
|
+
for(var j=0;j<bytes.length;j+=64){
|
|
151
|
+
for(var t=0;t<16;t++)w[t]=(bytes[j+t*4]<<24)|(bytes[j+t*4+1]<<16)|(bytes[j+t*4+2]<<8)|(bytes[j+t*4+3]);
|
|
152
|
+
for(t=16;t<64;t++){var s0=rotr(7,w[t-15])^rotr(18,w[t-15])^(w[t-15]>>>3);var s1=rotr(17,w[t-2])^rotr(19,w[t-2])^(w[t-2]>>>10);w[t]=(w[t-16]+s0+w[t-7]+s1)|0;}
|
|
153
|
+
var a=H[0],b=H[1],c3=H[2],d=H[3],e=H[4],f=H[5],g=H[6],hh=H[7];
|
|
154
|
+
for(t=0;t<64;t++){var S1=rotr(6,e)^rotr(11,e)^rotr(25,e);var ch=(e&f)^((~e)&g);var t1=(hh+S1+ch+K[t]+w[t])|0;var S0=rotr(2,a)^rotr(13,a)^rotr(22,a);var maj=(a&b)^(a&c3)^(b&c3);var t2=(S0+maj)|0;hh=g;g=f;f=e;e=(d+t1)|0;d=c3;c3=b;b=a;a=(t1+t2)|0;}
|
|
155
|
+
H[0]=(H[0]+a)|0;H[1]=(H[1]+b)|0;H[2]=(H[2]+c3)|0;H[3]=(H[3]+d)|0;H[4]=(H[4]+e)|0;H[5]=(H[5]+f)|0;H[6]=(H[6]+g)|0;H[7]=(H[7]+hh)|0;}
|
|
156
|
+
var hex='';for(var k=0;k<8;k++){var v=H[k]>>>0;hex+=('00000000'+v.toString(16)).slice(-8);}
|
|
157
|
+
return hex;
|
|
158
|
+
}
|
|
159
|
+
// Strip a comment lead (// # -- * ;) from a spec-block line — no regex (this lives in a template literal).
|
|
160
|
+
function stripLead(s){s=String(s).trim();var lead=['//','#','--','*',';'];for(var i=0;i<lead.length;i++){if(s.indexOf(lead[i])===0){s=s.slice(lead[i].length);break;}}return s.trim();}
|
|
161
|
+
// Parse the VERIFIED spec block into fields for display, so what the phone shows is derived from the
|
|
162
|
+
// exact bytes it hash-checked (not from separately-sent, spoofable parsed fields).
|
|
163
|
+
function parseSpecBlock(block){
|
|
164
|
+
var out={fields:[]},lines=String(block||'').split('\\n');
|
|
165
|
+
for(var i=0;i<lines.length;i++){
|
|
166
|
+
var t=stripLead(lines[i]);
|
|
167
|
+
if(!t||t.indexOf('YAY')>=0)continue; // skip blanks + the ∷YAY / ∷YAY-END marker lines
|
|
168
|
+
var ci=t.indexOf(':');
|
|
169
|
+
if(ci>0){var k=t.slice(0,ci).trim(),v=t.slice(ci+1).trim();if(k&&k.indexOf(' ')<0){out.fields.push({k:k,v:v});if(k==='unit')out.unit=v;if(k==='intent')out.intent=v;}}
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
// The heart of WYSIWYS: every Cell the signature would bind (approval.items) must have a DISPLAYED spec
|
|
174
|
+
// block whose sha256 equals the bound specHash. Any missing block or mismatch → not verified → block signing.
|
|
175
|
+
function wysiwygCheck(sess){
|
|
176
|
+
var ap=sess.approval||{},items=ap.items||{},sum=sess.summary||[];
|
|
177
|
+
var byId={};for(var i=0;i<sum.length;i++)byId[sum[i].id]=sum[i];
|
|
178
|
+
var ids=Object.keys(items),bad=[],perCell={};
|
|
179
|
+
for(var j=0;j<ids.length;j++){var id=ids[j],c=byId[id];
|
|
180
|
+
if(!c||typeof c.block!=='string'||!c.block){perCell[id]={ok:false};bad.push(id);continue;}
|
|
181
|
+
if(sha256hex(c.block)!==items[id]){perCell[id]={ok:false};bad.push(id);}else perCell[id]={ok:true};}
|
|
182
|
+
return {ok:(ids.length>0&&bad.length===0),bad:bad,perCell:perCell,count:ids.length};
|
|
183
|
+
}
|
|
184
|
+
// Governance events (grant / foundation seal / enroll / revoke / reroot / policy) are signed DIRECTLY
|
|
185
|
+
// (canonical(event)), so WYSIWYS = render the screen FROM the event the key signs, never from a separate
|
|
186
|
+
// server summary. A trustworthy heading from the event type, a readable field projection, and the exact
|
|
187
|
+
// signed bytes — so a compromised laptop can't show a benign action while binding a different one.
|
|
188
|
+
function eventLabel(ev){
|
|
189
|
+
var t=(ev&&ev.type)||'';
|
|
190
|
+
var map={grant:'Grant Autopilot (delegated execution)',foundation:'Seal the project foundation',enroll:'Enroll a signer',add:'Enroll a signer',revoke:'Revoke a key / signer',genesis:'Establish / rotate the trust root',reroot:'Re-root the trust root',policy:'Set the signing policy'};
|
|
191
|
+
return map[t]||('Authorize: '+(t||'change'));
|
|
192
|
+
}
|
|
193
|
+
function eventDisplay(ev){
|
|
194
|
+
if(!ev||typeof ev!=='object') return {rows:'',raw:'',ok:false};
|
|
195
|
+
var skip={signature:1,nonce:1,prev:1};
|
|
196
|
+
var order=['type','name','role','pub','mode','reason','rerootReason','supersedes','envelope','seal','policy','expiresAt','maxCount','by','at','id'];
|
|
197
|
+
var rows=[],seen={};
|
|
198
|
+
function push(k,v){ rows.push('<div class="cell"><div><div class="cid">'+esc(k)+'</div><div class="cin" style="word-break:break-word;white-space:pre-wrap">'+esc(v)+'</div></div></div>'); }
|
|
199
|
+
function val(k){
|
|
200
|
+
var v=ev[k];
|
|
201
|
+
if(k==='envelope'&&v&&typeof v==='object'){ var e=v,p=[],ff=['cell','allow','deny','allowTags','denyTags','maxRisk','guard','childGrants','maxDepth','deps','deploy']; for(var i=0;i<ff.length;i++){ if(e[ff[i]]!=null) p.push(ff[i]+': '+(typeof e[ff[i]]==='object'?JSON.stringify(e[ff[i]]):e[ff[i]])); } return p.join('\\n')||JSON.stringify(e); }
|
|
202
|
+
if(k==='seal'&&v&&typeof v==='object'){ var nf=v.files?Object.keys(v.files).length:0,z=v.zones?Object.keys(v.zones).join(', '):''; return nf+' core file(s) sealed'+(z?(' · zones: '+z):'')+(v.ignore&&v.ignore.length?(' · ignore: '+v.ignore.join(', ')):''); }
|
|
203
|
+
if(k==='policy'&&v&&typeof v==='object'){ return (v.rules?v.rules.length:0)+' rule(s): '+JSON.stringify(v.rules||[]); }
|
|
204
|
+
if(v!=null&&typeof v==='object') return JSON.stringify(v);
|
|
205
|
+
return v==null?'':String(v);
|
|
206
|
+
}
|
|
207
|
+
function add(k){ if(skip[k]||seen[k]||ev[k]==null||ev[k]==='') return; seen[k]=1; var sv=val(k); if(sv!=='') push(k,sv); }
|
|
208
|
+
order.forEach(add); Object.keys(ev).forEach(add);
|
|
209
|
+
return {rows:rows.join(''),raw:canonical(ev),ok:true};
|
|
210
|
+
}
|
|
211
|
+
function loadKey(){try{return JSON.parse(localStorage.getItem('yay.key')||'null');}catch(e){return null;}}
|
|
212
|
+
function saveKey(o){localStorage.setItem('yay.key',JSON.stringify(o));}
|
|
213
|
+
// New identity from a fresh 24-word recovery phrase (the phrase is shown once,
|
|
214
|
+
// never stored — only the derived keypair is kept on the device).
|
|
215
|
+
function newIdentity(name){var ent=new Uint8Array(32);window.crypto.getRandomValues(ent);var mnemonic=YayRecovery.newMnemonic(ent);var kp=YayRecovery.mnemonicToKeypair(mnemonic);return {name:name,sec:kp.sec,pub:kp.pub,mnemonic:mnemonic};}
|
|
216
|
+
// Re-derive the SAME keypair from a written-down phrase (device loss / new phone).
|
|
217
|
+
function restoreIdentity(name,phrase){var kp=YayRecovery.mnemonicToKeypair(phrase);return {name:name,sec:kp.sec,pub:kp.pub};}
|
|
218
|
+
// ── PIN lock ── the secret is stored ENCRYPTED (nacl.secretbox under a PBKDF2(PIN)
|
|
219
|
+
// key); only ciphertext hits localStorage. Unlocked once per page-load, cached in
|
|
220
|
+
// memory. A longer passphrase is stronger; the 24 words remain the master backup.
|
|
221
|
+
var unlocked=null; // {pub,sec} after a successful unlock this page-load
|
|
222
|
+
function askPin(title,sub){
|
|
223
|
+
return new Promise(function(resolve){
|
|
224
|
+
h('<div class="msg"><b>'+esc(title)+'</b></div>'+(sub?'<div class="help">'+esc(sub)+'</div>':'')+'<input id="pin" class="inp" type="password" autocomplete="off" autocapitalize="off" placeholder="PIN or passphrase (6+ characters)"><button id="go" class="btn">Continue</button>');
|
|
225
|
+
document.getElementById('pin').focus();
|
|
226
|
+
document.getElementById('go').onclick=function(){var v=document.getElementById('pin').value||'';if(v.length<6){setStatus('At least 6 characters','err');return;}setStatus('');resolve(v);};
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
// Set a PIN, seal the key, store only ciphertext, cache it for this page-load.
|
|
230
|
+
async function setPinAndSave(k){
|
|
231
|
+
var pin=await askPin('Protect your key with a PIN','You’ll enter this to sign on this phone. A longer passphrase is stronger. It never leaves the phone and can’t be recovered — but your 24 words can always restore the key.');
|
|
232
|
+
var pin2=await askPin('Confirm your PIN');
|
|
233
|
+
if(pin!==pin2){setStatus('PINs didn’t match — start again','err');return setPinAndSave(k);}
|
|
234
|
+
setStatus('Encrypting…'); await sleep(30);
|
|
235
|
+
saveKey({name:k.name,pub:k.pub,enc:YayRecovery.sealSecret(k.sec,pin),v:2});
|
|
236
|
+
unlocked={pub:k.pub,sec:k.sec}; setStatus('');
|
|
237
|
+
}
|
|
238
|
+
// Plaintext secret for a stored key, unlocking with the PIN if it's encrypted.
|
|
239
|
+
async function getSecret(key){
|
|
240
|
+
if(key.sec) return key.sec; // legacy plaintext key
|
|
241
|
+
if(!key.enc) throw new Error('no key material on this device');
|
|
242
|
+
if(unlocked&&unlocked.pub===key.pub) return unlocked.sec;
|
|
243
|
+
for(;;){
|
|
244
|
+
var pin=await askPin('Enter your PIN to sign');
|
|
245
|
+
setStatus('Unlocking…'); await sleep(30);
|
|
246
|
+
var sec=YayRecovery.openSecret(key.enc,pin);
|
|
247
|
+
if(sec){unlocked={pub:key.pub,sec:sec};setStatus('');return sec;}
|
|
248
|
+
setStatus('Wrong PIN — try again','err');
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function signStr(secB64,str){return b64(nacl.sign.detached(ebytes(str),unb64(secB64)));}
|
|
252
|
+
async function api(path,body){var r=await fetch(path,{method:body?'POST':'GET',headers:body?{'content-type':'application/json'}:{},body:body?JSON.stringify(body):undefined});return r.json();}
|
|
253
|
+
|
|
254
|
+
async function main(){
|
|
255
|
+
if(!hasCrypto()){ h('<div class="msg">This browser is too old to sign here — it lacks <b>crypto.getRandomValues</b>. Try a current mobile browser.</div>'); return; }
|
|
256
|
+
if(MODE==='dashboard') return dashLoop(); // persistent: scan once, requests appear
|
|
257
|
+
if(MODE==='join') return joinFlow(); // new teammate: make a key, request to join
|
|
258
|
+
try{
|
|
259
|
+
var sess=await api('/api/session');
|
|
260
|
+
if(sess.mode==='pair'||sess.mode==='approve'||sess.mode==='authorize') return dispatch(sess);
|
|
261
|
+
h('<div class="msg">Nothing to do right now.</div>');
|
|
262
|
+
}catch(e){ setStatus('Could not reach the laptop — still waiting? '+e,'err'); }
|
|
263
|
+
}
|
|
264
|
+
// Dashboard mode: idle here until the laptop sends a request, handle it, then wait
|
|
265
|
+
// for the next — the human only ever scans the QR once, at the start of the session.
|
|
266
|
+
function idleScreen(msg){ h('<div class="bigok"><div class="spin"></div><div class="h">'+esc(msg||'Waiting for a request')+'</div><div class="help">You’re connected. When your AI asks for approval, the brief shows up here.</div></div>'); setStatus('● connected','ok'); }
|
|
267
|
+
async function waitCleared(){ for(var i=0;i<4000;i++){ await sleep(1500); try{ var s=await api('/api/session'); if(!s||!s.mode||s.mode==='idle') return; }catch(e){ return; } } }
|
|
268
|
+
async function dashLoop(){
|
|
269
|
+
idleScreen();
|
|
270
|
+
for(;;){
|
|
271
|
+
try{
|
|
272
|
+
var sess=await api('/api/session');
|
|
273
|
+
if(sess && sess.mode && sess.mode!=='idle'){ dispatch(sess); await waitCleared(); idleScreen(); }
|
|
274
|
+
}catch(e){ setStatus('reconnecting…','err'); }
|
|
275
|
+
await sleep(1500);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
function pairFlow(sess){
|
|
279
|
+
var key=loadKey();
|
|
280
|
+
var note=sess.genesis?'<div class="help" style="color:var(--accent)">This phone will become the project’s <b>trust root</b> — no key is stored on the computer.</div>':'';
|
|
281
|
+
if(key){ h(note+'<div class="msg">Key ready for <b>'+esc(key.name)+'</b> on this device.</div><button id="go" class="btn">'+(sess.genesis?'Become trust root & pair':'Pair this device')+'</button>'); document.getElementById('go').onclick=function(){doPair(sess,key);}; return; }
|
|
282
|
+
h(note+'<label class="lbl">Your name (shown on every signature)</label><input id="nm" class="inp" placeholder="e.g. Alex Doe" autocapitalize="words"><button id="go" class="btn">Create key & pair</button><span id="rst" class="link">Restore from recovery phrase</span>');
|
|
283
|
+
document.getElementById('go').onclick=function(){
|
|
284
|
+
var name=(document.getElementById('nm').value||'').trim(); if(!name){setStatus('Enter a name','err');return;}
|
|
285
|
+
setStatus('Generating your key…');
|
|
286
|
+
try{ var k=newIdentity(name); showBackup(sess,k); setStatus(''); }catch(e){ setStatus('Key generation failed: '+e,'err'); }
|
|
287
|
+
};
|
|
288
|
+
document.getElementById('rst').onclick=function(){restoreFlow(sess);};
|
|
289
|
+
}
|
|
290
|
+
// Show the 24-word recovery phrase ONCE. It is the only backup and is never
|
|
291
|
+
// stored on the device or sent to the laptop; only the derived key is kept.
|
|
292
|
+
function showBackup(sess,k){
|
|
293
|
+
var words=k.mnemonic.split(' ');
|
|
294
|
+
var grid=words.map(function(w,i){return '<div class="word"><i>'+(i+1)+'</i>'+esc(w)+'</div>';}).join('');
|
|
295
|
+
h('<div class="lab">Recovery phrase</div>'
|
|
296
|
+
+'<div class="msg">Write these 24 words down on paper, in order.</div>'
|
|
297
|
+
+'<div class="words">'+grid+'</div>'
|
|
298
|
+
+'<div class="warn">This is the ONLY way to restore your key if you lose this phone. Anyone who has it can sign as you. Never photograph it, type it into a website, or store it online.</div>'
|
|
299
|
+
+'<label class="chk"><input type="checkbox" id="ack"><span>I have written down my recovery phrase and stored it safely.</span></label>'
|
|
300
|
+
+'<button id="go" class="btn">Continue</button>');
|
|
301
|
+
document.getElementById('go').onclick=function(){
|
|
302
|
+
if(!document.getElementById('ack').checked){setStatus('Confirm you saved your phrase','err');return;}
|
|
303
|
+
verifyBackup(sess,k,words);
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
// Lightweight proof they actually recorded it: re-enter one random word.
|
|
307
|
+
function verifyBackup(sess,k,words){
|
|
308
|
+
var pos=(window.crypto.getRandomValues(new Uint32Array(1))[0])%words.length;
|
|
309
|
+
h('<div class="msg">Quick check — type word <b>#'+(pos+1)+'</b> of your recovery phrase.</div>'
|
|
310
|
+
+'<input id="wv" class="inp" autocapitalize="none" autocomplete="off" placeholder="word #'+(pos+1)+'"><button id="go" class="btn">Confirm & pair</button><span id="sk" class="link">Show my phrase again</span>');
|
|
311
|
+
document.getElementById('go').onclick=async function(){
|
|
312
|
+
var v=(document.getElementById('wv').value||'').trim().toLowerCase();
|
|
313
|
+
if(v!==words[pos]){setStatus('That word doesn’t match #'+(pos+1)+' — check your written copy','err');return;}
|
|
314
|
+
setStatus(''); delete k.mnemonic; await setPinAndSave(k); await doPair(sess,{name:k.name,sec:k.sec,pub:k.pub});
|
|
315
|
+
};
|
|
316
|
+
document.getElementById('sk').onclick=function(){showBackup(sess,k);};
|
|
317
|
+
}
|
|
318
|
+
// Re-enter the flow for the current mode after the key is available.
|
|
319
|
+
function dispatch(sess){ if(sess.mode==='approve') return approveFlow(sess); if(sess.mode==='authorize') return authorizeFlow(sess); return pairFlow(sess); }
|
|
320
|
+
// Restore an existing identity by pasting its written-down phrase. Works from ANY
|
|
321
|
+
// mode (pair / approve / authorize) — after restoring it continues that flow.
|
|
322
|
+
function restoreFlow(sess){
|
|
323
|
+
h('<div class="lab">Restore your key</div>'
|
|
324
|
+
+'<div class="msg">Paste your 24-word recovery phrase.</div>'
|
|
325
|
+
+'<label class="lbl">Your name (as shown on your signatures)</label><input id="nm" class="inp" placeholder="e.g. Alex Doe" autocapitalize="words">'
|
|
326
|
+
+'<label class="lbl">Recovery phrase</label><textarea id="ph" class="inp" autocapitalize="none" autocomplete="off" placeholder="word1 word2 … word24"></textarea>'
|
|
327
|
+
+'<button id="go" class="btn">Restore key</button><span id="bk" class="link">Back</span>');
|
|
328
|
+
document.getElementById('go').onclick=async function(){
|
|
329
|
+
var name=(document.getElementById('nm').value||'').trim(); if(!name){setStatus('Enter your name','err');return;}
|
|
330
|
+
var phrase=(document.getElementById('ph').value||'').trim(); if(!phrase){setStatus('Paste your phrase','err');return;}
|
|
331
|
+
setStatus('Restoring your key…');
|
|
332
|
+
try{ var k=restoreIdentity(name,phrase); await setPinAndSave(k); if(sess.mode==='pair'){ await doPair(sess,k); } else { dispatch(sess); } }
|
|
333
|
+
catch(e){ setStatus(String(e&&e.message||e).replace(/^Error:\\s*/,''),'err'); }
|
|
334
|
+
};
|
|
335
|
+
document.getElementById('bk').onclick=function(){dispatch(sess);};
|
|
336
|
+
}
|
|
337
|
+
async function doPair(sess,key){
|
|
338
|
+
try{
|
|
339
|
+
var sec=await getSecret(key);
|
|
340
|
+
setStatus('Pairing…');
|
|
341
|
+
var proof;
|
|
342
|
+
if(sess.genesis){
|
|
343
|
+
// No trust root yet → this phone BECOMES it. Self-sign the genesis event
|
|
344
|
+
// (canonical() here matches the laptop's eventBytes exactly).
|
|
345
|
+
var g=sess.genesis;
|
|
346
|
+
var ev={id:g.id,type:g.type,name:key.name,pub:key.pub,role:g.role,by:key.name,prev:g.prev,nonce:g.nonce,at:g.at};
|
|
347
|
+
proof=signStr(sec,canonical(ev));
|
|
348
|
+
}else{
|
|
349
|
+
proof=signStr(sec,sess.challenge);
|
|
350
|
+
}
|
|
351
|
+
var res=await api('/api/submit',{name:key.name,pubB64:key.pub,proof:proof});
|
|
352
|
+
if(res.error){ setStatus('Rejected: '+res.error,'err'); return; }
|
|
353
|
+
h('<div class="lab" style="text-align:center">Code on this phone</div><div class="code">'+esc(res.code)+'</div><div class="help" style="text-align:center;margin:0">Your laptop should show this exact code. If it matches, approve it there.</div>');
|
|
354
|
+
setStatus('Waiting for the laptop…','ok');
|
|
355
|
+
pollPairStatus();
|
|
356
|
+
}catch(e){ setStatus('Pairing failed: '+e,'err'); }
|
|
357
|
+
}
|
|
358
|
+
function sleep(ms){return new Promise(function(r){setTimeout(r,ms);});}
|
|
359
|
+
// After submitting, the phone waits for the laptop to confirm the code; poll the
|
|
360
|
+
// outcome so this screen flips to success/failure instead of hanging forever.
|
|
361
|
+
async function pollPairStatus(){
|
|
362
|
+
for(var i=0;i<800;i++){
|
|
363
|
+
try{
|
|
364
|
+
var s=await api('/api/status');
|
|
365
|
+
if(s&&s.final){
|
|
366
|
+
if(s.final.ok){ okScreen('Paired', s.final.message||'Done — you can close this.'); setStatus('Paired','ok'); }
|
|
367
|
+
else { h('<div class="msg">Pairing wasn’t completed'+(s.final.reason?': '+esc(s.final.reason):'')+'.</div><div class="help" style="margin:0">Start again with <b>yay pair</b> on the laptop.</div>'); setStatus('Not paired','err'); }
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
}catch(e){ return; } // server closed after finishing — stop quietly
|
|
371
|
+
await sleep(1500);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
// Full spec of a Cell, rendered from the VERIFIED spec block (parsed on-phone), so what you read is
|
|
375
|
+
// derived from the exact bytes whose sha256 the signature binds — never from separately-sent fields.
|
|
376
|
+
// verified = did sha256(block) match approval.items[id]. When false, we say so loudly.
|
|
377
|
+
function detailHTML(c, verified){
|
|
378
|
+
var pb=parseSpecBlock(c.block), order=['intent','ensures','in','out','pure','throws','feeds','contains','lang','unit'], byk={}, seen={}, parts=[];
|
|
379
|
+
pb.fields.forEach(function(fd){ byk[fd.k]=fd.v; });
|
|
380
|
+
function add(k){ if(byk[k]!=null && String(byk[k]).trim()!==''){ seen[k]=1; parts.push('<div class="kv"><span class="k">'+esc(k)+'</span><span class="v">'+esc(String(byk[k]))+'</span></div>'); } }
|
|
381
|
+
order.forEach(add);
|
|
382
|
+
pb.fields.forEach(function(fd){ if(!seen[fd.k]) add(fd.k); });
|
|
383
|
+
if(c.file) parts.push('<div class="kv"><span class="k">file</span><span class="v">'+esc(c.file)+(c.line?':'+c.line:'')+'</span></div>');
|
|
384
|
+
var vbadge=verified
|
|
385
|
+
?'<div class="difflbl" style="color:#1f9d57">✓ verified on this phone — sha256 matches what your signature binds</div>'
|
|
386
|
+
:'<div class="difflbl" style="color:var(--red)">⚠ NOT verified — the shown spec does NOT match what would be signed. Do not approve.</div>';
|
|
387
|
+
var diff='';
|
|
388
|
+
if(c.diff&&c.diff.length){
|
|
389
|
+
diff='<div class="difflbl">changes vs last committed spec</div><div class="diff">'
|
|
390
|
+
+c.diff.map(function(d){ var cl=d.t==='+'?'add':(d.t==='-'?'del':'ctx'); var pre=d.t==='+'?'+ ':(d.t==='-'?'- ':' '); return '<div class="dl '+cl+'">'+pre+esc(d.text)+'</div>'; }).join('')
|
|
391
|
+
+'</div>';
|
|
392
|
+
}
|
|
393
|
+
var raw='<div class="difflbl">exact signed spec (this is what sha256 hashed)</div><pre class="codeblk">'+esc(c.block||'(no spec block was sent — cannot verify)')+'</pre>';
|
|
394
|
+
var notes=(c.notes||[]).map(function(nt){ var col=nt.level==='red'?'var(--red)':(nt.level==='yellow'?'var(--amber)':'var(--mut)'); return '<div class="note" style="color:'+col+'">'+esc(nt.text)+'</div>'; }).join('');
|
|
395
|
+
// Ratification only: the implementation already exists (built unattended under a grant), so
|
|
396
|
+
// show it for review — this is the one sign where the human sees real code, not just intent.
|
|
397
|
+
var code=c.code?('<div class="difflbl" style="color:var(--amber)">code it built'+(c.grant?' · ran under grant '+esc(c.grant):'')+'</div><pre class="codeblk">'+esc(c.code)+'</pre>'):'';
|
|
398
|
+
return '<div class="detail">'+vbadge+(parts.join('')||'<div class="kv"><span class="v">No structured spec fields.</span></div>')+diff+raw+code+(notes?'<div class="notes">'+notes+'</div>':'')+'</div>';
|
|
399
|
+
}
|
|
400
|
+
// Is THIS phone the one the laptop asked for? It tells us the intended signer
|
|
401
|
+
// (sess.signer name + sess.signerPubs keys). If this phone holds a different key we
|
|
402
|
+
// name who it's for and refuse — so the wrong person can't waste a tap on a request
|
|
403
|
+
// that would be rejected anyway (and to make “Lisa signs security code” policy clear).
|
|
404
|
+
function signerGate(sess){
|
|
405
|
+
var want=sess.signerPubs, name=sess.signer;
|
|
406
|
+
if(!want || !want.length) return {ok:true, banner:''}; // older laptop didn't say → allow
|
|
407
|
+
var key=loadKey(), mine=key&&key.pub, forWho=name?esc(name):'someone else';
|
|
408
|
+
if(mine && want.indexOf(mine)>=0){
|
|
409
|
+
return {ok:true, banner:'<div class="help" style="color:var(--accent);margin:0 0 12px">Signing as <b>'+forWho+'</b> on this phone.</div>'};
|
|
410
|
+
}
|
|
411
|
+
return {ok:false, banner:'<div class="mcard" style="border-left-color:var(--red)"><div class="mtag" style="color:var(--red)">Not for this phone</div><div class="mtxt" style="margin-top:6px">This request is for <b>'+forWho+'</b>.</div><div class="msub">This phone signs as <b>'+esc(key?key.name:'a different identity')+'</b> — you can’t approve it here. It should be approved on '+forWho+'’s phone.</div></div>'};
|
|
412
|
+
}
|
|
413
|
+
function approveFlow(sess){
|
|
414
|
+
var key=loadKey();
|
|
415
|
+
if(!key){ h('<div class="msg">This phone has no key on this page yet — restore it from your recovery phrase, or run <b>yay pair</b>.</div><button id="rst" class="btn">Restore from recovery phrase</button>'); document.getElementById('rst').onclick=function(){restoreFlow(sess);}; return; }
|
|
416
|
+
var gate=signerGate(sess);
|
|
417
|
+
var wy=wysiwygCheck(sess); // re-hash every displayed spec against what the signature would bind
|
|
418
|
+
var rows=(sess.summary||[]).map(function(c,i){
|
|
419
|
+
var pb=parseSpecBlock(c.block), ver=!!(wy.perCell[c.id]&&wy.perCell[c.id].ok);
|
|
420
|
+
var unit=pb.unit||c.unit||'', intent=pb.intent||'', ed=(c.diff&&c.diff.length)?' <span class="edited">edited</span>':'';
|
|
421
|
+
var vm=ver?'':' <span style="color:var(--red);font-weight:700" title="This shown spec does not match what would be signed">⚠ unverified</span>';
|
|
422
|
+
return '<div class="crow"><div class="cell tap" data-i="'+i+'"><span class="dot" style="background:'+(ver?(c.color||'#888'):'var(--red)')+'"></span><div><div class="cid">'+esc(c.id)+' · '+esc(unit)+ed+vm+'</div><div class="cin">'+esc(intent)+'</div></div><span class="col">'+esc(c.state||'')+'<span class="caret">▸</span></span></div><div class="detailwrap" id="d'+i+'" style="display:none">'+detailHTML(c,ver)+'</div></div>';
|
|
423
|
+
}).join('');
|
|
424
|
+
// BRIEF header (Standard §5): the human-owned headline over these parts. DISPLAY-ONLY —
|
|
425
|
+
// it can't be reworded on the phone, because a Brief change must pull its Cells with it and
|
|
426
|
+
// only the AI loop can do that. The choice is Accept, or Send back (with an optional note).
|
|
427
|
+
var brief=sess.approval&&sess.approval.brief;
|
|
428
|
+
// Tags CAN be switched here (only within the project pool), but switching one BLOCKS Accept:
|
|
429
|
+
// a tag is inside the signed Brief, so a change must round-trip to the AI to re-issue.
|
|
430
|
+
var pool=(sess.tagPool||[]), origTags=(brief&&brief.tags)||[], lc=function(s){return String(s).toLowerCase();};
|
|
431
|
+
var sel=origTags.slice();
|
|
432
|
+
function selHas(t){return sel.some(function(x){return lc(x)===lc(t);});}
|
|
433
|
+
function tagsChanged(){ return sel.map(lc).sort().join('|')!==origTags.map(lc).sort().join('|'); }
|
|
434
|
+
function chipStyle(on){ return 'display:inline-block;font-size:.72rem;font-weight:700;padding:3px 10px;border-radius:100px;border:1px solid '+(on?'var(--accent)':'var(--rule)')+';margin:0 5px 6px 0;cursor:pointer;'+(on?'background:var(--accent);color:#fff':'color:var(--muted,#8a8a8a);background:transparent'); }
|
|
435
|
+
var tagSel=(brief&&pool.length)
|
|
436
|
+
?('<div class="msub" style="margin:11px 0 5px">Tags — tap to change (from the project pool):</div><div id="tagsel">'+pool.map(function(t){return '<span class="tchip" data-tag="'+esc(t)+'" style="'+chipStyle(selHas(t))+'">'+esc(t)+'</span>';}).join('')+'</div><div id="taghint" style="display:none;color:var(--accent);font-size:.82rem;margin:7px 0 0">You changed the tags — the AI must re-issue this Brief. Tap <b>Send back</b> to request it.</div>')
|
|
437
|
+
:(brief&&origTags.length?('<div style="margin-top:9px">'+origTags.map(function(t){return '<span style="display:inline-block;font-size:.68rem;font-weight:700;padding:2px 9px;border-radius:100px;border:1px solid var(--rule);color:var(--accent);margin:0 5px 5px 0">'+esc(t)+'</span>';}).join('')+'</div>'):'');
|
|
438
|
+
var briefCard=brief?('<div class="mcard"><div class="mlab"><span class="mtag">Brief</span></div>'
|
|
439
|
+
+(brief.title?('<div style="font-weight:800;font-size:1.08rem;color:var(--ink);margin:0 0 4px">'+esc(brief.title)+'</div>'):'')
|
|
440
|
+
+'<div class="mtxt"'+(brief.title?' style="font-size:.95rem;color:var(--muted,#8a8a8a)"':'')+'>'+esc(brief.text)+'</div>'+tagSel
|
|
441
|
+
+'<div class="msub">covers '+((sess.summary||[]).length)+' part(s) · sign it, or send it back for changes</div></div>'):'';
|
|
442
|
+
var wyBanner=wy.ok?'':'<div class="mcard" style="border-left-color:var(--red)"><div class="mtag" style="color:var(--red)">Do not sign — cannot verify</div><div class="mtxt" style="margin-top:6px">The spec shown here does <b>not</b> match what your signature would bind for <b>'+wy.bad.length+'</b> part(s). On a healthy setup this never happens — your laptop may be compromised or out of date.</div><div class="msub">Signing is disabled on this phone. Re-run <b>yay sign</b>; if it persists, stop and investigate.</div></div>';
|
|
443
|
+
var wyOkNote=(gate.ok&&wy.ok&&wy.count>0)?'<div class="help" style="color:#1f9d57;margin:2px 0 0">✓ What you see is what you sign — every part re-checked (sha256) on this phone.</div>':'';
|
|
444
|
+
h(gate.banner+briefCard+'<div class="help">Approve these <b>'+((sess.summary||[]).length)+'</b> change(s) — tap a part to see its spec.</div>'+rows+wyOkNote+(gate.ok?(wy.ok?'<button id="go" class="btn" style="margin-top:16px">Accept & sign</button><textarea id="sbnote" class="marea" rows="2" style="margin-top:12px" placeholder="What should change? (optional — for Send back)"></textarea><div id="sbwarn" class="sbwarn">What should change?</div><button id="sb" class="btn ghost">Send back</button>':wyBanner):''));
|
|
445
|
+
var taps=document.querySelectorAll('.cell.tap');
|
|
446
|
+
for(var ti=0;ti<taps.length;ti++){(function(el){el.onclick=function(){var d=document.getElementById('d'+el.getAttribute('data-i'));var open=d.style.display!=='none';d.style.display=open?'none':'block';var car=el.querySelector('.caret');if(car)car.textContent=open?'▸':'▾';};})(taps[ti]);}
|
|
447
|
+
if(!gate.ok) return; // wrong signer for this request — no buttons wired
|
|
448
|
+
if(!wy.ok) return; // WYSIWYS failed — Accept isn't rendered; nothing to wire (never sign the unverifiable)
|
|
449
|
+
function refreshTagUI(){ var ch=tagsChanged(); var go=document.getElementById('go'); if(go){go.disabled=ch;go.style.opacity=ch?'.45':'';go.style.cursor=ch?'not-allowed':'';} var hint=document.getElementById('taghint'); if(hint)hint.style.display=ch?'block':'none'; }
|
|
450
|
+
Array.prototype.forEach.call(document.querySelectorAll('.tchip'),function(chip){ chip.onclick=function(){ var t=chip.getAttribute('data-tag'); if(selHas(t)) sel=sel.filter(function(x){return lc(x)!==lc(t);}); else sel.push(t); chip.setAttribute('style',chipStyle(selHas(t))); refreshTagUI(); }; });
|
|
451
|
+
refreshTagUI();
|
|
452
|
+
document.getElementById('go').onclick=async function(){
|
|
453
|
+
if(tagsChanged()) return; // Accept is blocked while tags differ — send it back instead
|
|
454
|
+
try{
|
|
455
|
+
var sec=await getSecret(key);
|
|
456
|
+
setStatus('Signing…');
|
|
457
|
+
var sig=signStr(sec,canonical(sess.approval));
|
|
458
|
+
var res=await api('/api/submit',{signature:sig});
|
|
459
|
+
if(res.error){ setStatus('Rejected: '+res.error,'err'); return; }
|
|
460
|
+
okScreen('Signed','The seal is on your laptop. Leave this open — the next request appears here automatically.');
|
|
461
|
+
setStatus('Signed','ok');
|
|
462
|
+
}catch(e){ setStatus('Signing failed: '+e,'err'); }
|
|
463
|
+
};
|
|
464
|
+
// Send back is ONE tap: the note field sits right on this screen. An empty note on
|
|
465
|
+
// the first tap shows a tiny inline nudge ("What should change?") instead of sending;
|
|
466
|
+
// the next tap sends regardless (a deliberate blank is allowed — the AI will ask).
|
|
467
|
+
// A tags-only correction never nudges: the new tags ARE the message.
|
|
468
|
+
var sbWarned=false;
|
|
469
|
+
var sbNote=document.getElementById('sbnote');
|
|
470
|
+
if(sbNote) sbNote.addEventListener('input',function(){ if(sbNote.value.trim()){ var w=document.getElementById('sbwarn'); if(w) w.style.display='none'; } });
|
|
471
|
+
document.getElementById('sb').onclick=async function(){
|
|
472
|
+
var changed=tagsChanged();
|
|
473
|
+
var note=(sbNote&&sbNote.value||'').trim();
|
|
474
|
+
if(!note && !changed && !sbWarned){
|
|
475
|
+
sbWarned=true;
|
|
476
|
+
var w=document.getElementById('sbwarn'); if(w) w.style.display='block';
|
|
477
|
+
if(sbNote) sbNote.focus();
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
try{
|
|
481
|
+
setStatus('Sending back…');
|
|
482
|
+
var res=await api('/api/submit',{rejected:true,reason:note,tags:(changed?sel:undefined)});
|
|
483
|
+
if(res.error){ setStatus('Failed: '+res.error,'err'); return; }
|
|
484
|
+
okScreen('Sent back','The requester has been notified in their tool. Leave this open for the next request.');
|
|
485
|
+
setStatus('Sent back');
|
|
486
|
+
}catch(e){ setStatus('Failed: '+e,'err'); }
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
// Owner authorizes a roster/governance change (enroll, revoke, reroot) from the phone.
|
|
490
|
+
function authorizeFlow(sess){
|
|
491
|
+
var key=loadKey();
|
|
492
|
+
if(!key){ h('<div class="msg">This phone has no key on this page yet — restore it from your recovery phrase (an existing owner’s phrase is required to authorize).</div><button id="rst" class="btn">Restore from recovery phrase</button>'); document.getElementById('rst').onclick=function(){restoreFlow(sess);}; return; }
|
|
493
|
+
var s=sess.summary||{};
|
|
494
|
+
var gate=signerGate(sess);
|
|
495
|
+
// WYSIWYS for governance: render FROM the exact event the key signs (heading, fields, raw bytes) —
|
|
496
|
+
// never trust a separately-sent summary. No event → nothing to verify → refuse to sign.
|
|
497
|
+
var view=eventDisplay(sess.event), noEv=!view.ok;
|
|
498
|
+
var heading=eventLabel(sess.event)||(s.title||'Authorize this change');
|
|
499
|
+
var warn=s.warn?'<div class="warn">'+esc(s.warn)+'</div>':'';
|
|
500
|
+
var rawBlock=view.raw?'<div class="difflbl">exact signed event (this is what your key signs)</div><pre class="codeblk">'+esc(view.raw)+'</pre>':'';
|
|
501
|
+
var okNote=(!noEv&&gate.ok)?'<div class="help" style="color:#1f9d57;margin:2px 0 0">✓ What you see is what you sign — read from the exact event your key signs on this phone.</div>':'';
|
|
502
|
+
var cannot=noEv?'<div class="mcard" style="border-left-color:var(--red)"><div class="mtag" style="color:var(--red)">Do not authorize — nothing to verify</div><div class="mtxt" style="margin-top:6px">No event was sent to this phone, so it cannot confirm what would be signed.</div></div>':'';
|
|
503
|
+
h(gate.banner+'<div class="msg">'+esc(heading)+'</div>'+view.rows+rawBlock+warn+okNote+cannot+((gate.ok&&!noEv)?'<button id="go" class="btn" style="margin-top:16px">Authorize & sign</button>':''));
|
|
504
|
+
if(!gate.ok||noEv) return; // not an owner key, or no event to verify — never sign the unverifiable
|
|
505
|
+
document.getElementById('go').onclick=async function(){
|
|
506
|
+
try{
|
|
507
|
+
var sec=await getSecret(key);
|
|
508
|
+
setStatus('Signing…');
|
|
509
|
+
var sig=signStr(sec,canonical(sess.event));
|
|
510
|
+
var res=await api('/api/submit',{signature:sig});
|
|
511
|
+
if(res.error){ setStatus('Rejected: '+res.error,'err'); return; }
|
|
512
|
+
okScreen('Authorized','Done — the laptop has the signed event.');
|
|
513
|
+
setStatus('Authorized','ok');
|
|
514
|
+
}catch(e){ setStatus('Signing failed: '+e,'err'); }
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
// ── Join flow: a new teammate makes their key and requests to join. The owner
|
|
518
|
+
// approves on their phone; nothing here is trust-critical (only the PUBLIC key is
|
|
519
|
+
// sent — the private key and 24 words never leave this device).
|
|
520
|
+
async function joinFlow(){
|
|
521
|
+
var info; try{ info=await api('/api/invite/info?t='+encodeURIComponent(TOKEN)); }catch(e){ info=null; }
|
|
522
|
+
if(!info || !info.valid){ h('<div class="msg">This invite link is invalid or has expired.</div><div class="help" style="margin:0">Ask a project owner to send you a fresh <b>yay invite</b> link.</div>'); setStatus('Invite expired','err'); return; }
|
|
523
|
+
if(info.used){ h('<div class="msg">This invite has already been used.</div><div class="help" style="margin:0">Ask for a new one if you still need to join.</div>'); setStatus('Already used','err'); return; }
|
|
524
|
+
var roleLbl=info.role==='owner'?'owner — can manage the team':'signer';
|
|
525
|
+
var key=loadKey();
|
|
526
|
+
var head='<div class="help">You’re joining <b>'+esc(info.project||PROJECT)+'</b> as a <b>'+esc(roleLbl)+'</b>. Your signing key is made here and never leaves this phone.</div>';
|
|
527
|
+
var nameField='<label class="lbl">Your name (shown on every signature)</label><input id="nm" class="inp" value="'+esc((key&&key.name)||info.name||'')+'" placeholder="e.g. Bob Carlsen" autocapitalize="words">';
|
|
528
|
+
if(key){
|
|
529
|
+
h(head+'<div class="msg">A key already exists on this phone for <b>'+esc(key.name)+'</b>.</div>'+nameField+'<button id="go" class="btn">Request to join</button>');
|
|
530
|
+
document.getElementById('go').onclick=function(){ var nm=(document.getElementById('nm').value||'').trim(); if(!nm){setStatus('Enter your name','err');return;} doJoin(info,key,nm); };
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
h(head+nameField+'<button id="go" class="btn">Create my key & request to join</button><span id="rst" class="link">Restore from recovery phrase</span>');
|
|
534
|
+
document.getElementById('go').onclick=function(){
|
|
535
|
+
var nm=(document.getElementById('nm').value||'').trim(); if(!nm){setStatus('Enter your name','err');return;}
|
|
536
|
+
setStatus('Generating your key…');
|
|
537
|
+
try{ var k=newIdentity(nm); joinBackup(info,k); setStatus(''); }catch(e){ setStatus('Key generation failed: '+e,'err'); }
|
|
538
|
+
};
|
|
539
|
+
document.getElementById('rst').onclick=function(){ joinRestore(info); };
|
|
540
|
+
}
|
|
541
|
+
function joinBackup(info,k){
|
|
542
|
+
var words=k.mnemonic.split(' ');
|
|
543
|
+
var grid=words.map(function(w,i){return '<div class="word"><i>'+(i+1)+'</i>'+esc(w)+'</div>';}).join('');
|
|
544
|
+
h('<div class="lab">Recovery phrase</div><div class="msg">Write these 24 words down on paper, in order.</div><div class="words">'+grid+'</div>'
|
|
545
|
+
+'<div class="warn">This is the ONLY way to restore your key if you lose this phone. Anyone who has it can sign as you. Never photograph it or store it online.</div>'
|
|
546
|
+
+'<label class="chk"><input type="checkbox" id="ack"><span>I have written down my recovery phrase and stored it safely.</span></label>'
|
|
547
|
+
+'<button id="go" class="btn">Continue</button>');
|
|
548
|
+
document.getElementById('go').onclick=function(){ if(!document.getElementById('ack').checked){setStatus('Confirm you saved your phrase','err');return;} joinVerify(info,k,words); };
|
|
549
|
+
}
|
|
550
|
+
function joinVerify(info,k,words){
|
|
551
|
+
var pos=(window.crypto.getRandomValues(new Uint32Array(1))[0])%words.length;
|
|
552
|
+
h('<div class="msg">Quick check — type word <b>#'+(pos+1)+'</b> of your recovery phrase.</div><input id="wv" class="inp" autocapitalize="none" autocomplete="off" placeholder="word #'+(pos+1)+'"><button id="go" class="btn">Confirm</button><span id="sk" class="link">Show my phrase again</span>');
|
|
553
|
+
document.getElementById('go').onclick=async function(){
|
|
554
|
+
var v=(document.getElementById('wv').value||'').trim().toLowerCase();
|
|
555
|
+
if(v!==words[pos]){setStatus('That word doesn’t match #'+(pos+1),'err');return;}
|
|
556
|
+
setStatus(''); delete k.mnemonic; await setPinAndSave(k); await doJoin(info,{name:k.name,sec:k.sec,pub:k.pub}, k.name);
|
|
557
|
+
};
|
|
558
|
+
document.getElementById('sk').onclick=function(){ joinBackup(info,k); };
|
|
559
|
+
}
|
|
560
|
+
function joinRestore(info){
|
|
561
|
+
h('<div class="lab">Restore your key</div><div class="msg">Paste your 24-word recovery phrase.</div>'
|
|
562
|
+
+'<label class="lbl">Your name (shown on every signature)</label><input id="nm" class="inp" value="'+esc(info.name||'')+'" placeholder="e.g. Bob Carlsen" autocapitalize="words">'
|
|
563
|
+
+'<label class="lbl">Recovery phrase</label><textarea id="ph" class="inp" autocapitalize="none" autocomplete="off" placeholder="word1 word2 … word24"></textarea><button id="go" class="btn">Restore & request to join</button><span id="bk" class="link">Back</span>');
|
|
564
|
+
document.getElementById('go').onclick=async function(){
|
|
565
|
+
var nm=(document.getElementById('nm').value||'').trim(); if(!nm){setStatus('Enter your name','err');return;}
|
|
566
|
+
var phrase=(document.getElementById('ph').value||'').trim(); if(!phrase){setStatus('Paste your phrase','err');return;}
|
|
567
|
+
setStatus('Restoring…');
|
|
568
|
+
try{ var k=restoreIdentity(nm,phrase); await setPinAndSave(k); await doJoin(info,k,nm); }
|
|
569
|
+
catch(e){ setStatus(String(e&&e.message||e).replace(/^Error:\s*/,''),'err'); }
|
|
570
|
+
};
|
|
571
|
+
document.getElementById('bk').onclick=function(){ joinFlow(); };
|
|
572
|
+
}
|
|
573
|
+
async function doJoin(info,key,name){
|
|
574
|
+
try{
|
|
575
|
+
var sec=await getSecret(key);
|
|
576
|
+
setStatus('Sending your request…');
|
|
577
|
+
var proof=signStr(sec, TOKEN);
|
|
578
|
+
var res=await api('/api/invite/join',{token:TOKEN,name:name,pubB64:key.pub,proof:proof});
|
|
579
|
+
if(res.error){ h('<div class="msg">Couldn’t join: '+esc(res.error)+'</div>'); setStatus('Not joined','err'); return; }
|
|
580
|
+
h('<div class="lab" style="text-align:center">Read this code to the approver</div><div class="code">'+esc(res.code)+'</div><div class="help" style="text-align:center;margin:0">They’ll see the same code on their phone and approve you.</div>');
|
|
581
|
+
setStatus('Waiting for approval…','ok');
|
|
582
|
+
pollJoin();
|
|
583
|
+
}catch(e){ setStatus('Join failed: '+e,'err'); }
|
|
584
|
+
}
|
|
585
|
+
async function pollJoin(){
|
|
586
|
+
for(var i=0;i<800;i++){
|
|
587
|
+
try{
|
|
588
|
+
var s=await api('/api/invite/status?t='+encodeURIComponent(TOKEN));
|
|
589
|
+
if(s&&s.done){
|
|
590
|
+
if(s.result&&s.result.ok){ okScreen('You’re in!','You’re now a signer on this project. Keep this phone — your approvals will appear here.'); setStatus('Joined','ok'); }
|
|
591
|
+
else { h('<div class="msg">Not approved'+(s.result&&s.result.error?': '+esc(s.result.error):'')+'.</div><div class="help" style="margin:0">Ask the owner to try again, or for a fresh invite.</div>'); setStatus('Not approved','err'); }
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
}catch(e){ /* keep waiting */ }
|
|
595
|
+
await sleep(1500);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
main();
|
|
599
|
+
})();
|
|
600
|
+
</script></body></html>`;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
module.exports = { signerHTML };
|