From 1f436a42b18bc9f1da25e9d5ffbd02c36307da52 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 30 Aug 2026 13:38:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20guest=20shares=20=E2=80=94=20join?= =?UTF-8?q?=20link,=20ro=20terminal,=20guest=20state=20(G1e)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per G1 spec §6 web. The /#join- link opens the guest ceremony: the same blind pair room and NNpsk0 initiator as pairing (openPairRoom extracted from pair.js, no crypto forked), the guest proves its key and presents its transport binding, shows the safety number to read aloud (the owner approves on their side, with the CLI invite's 5-minute window), then the verified grant lands as a guest-flagged machine. A share attaches through the guest path (owner routing mirrors G1c's Machine.Owner), renders ⇢ with the CLI-identical summary line, and carries no owner affordances — no rename, no retire, no share. A read-only share is guarded at the send path: current.send becomes a property whose every (re)assignment wraps the sender to drop DATA and CONTROL frames before the wire — no keystroke source can bypass it. The topbar says "read-only · expires in 42 min"; an expired share gets the honest line before any dial, and closed shares age out of the local store exactly like the CLI sweep. No share minting in the SPA in v1 (approved decision — phone minting is a fast follow). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KeiotDVE94wEzvc7wcvm1y --- web/index.html | 2 + web/src/app.js | 93 ++++++++++++++++++++++++++++--- web/src/guest.js | 104 ++++++++++++++++++++++++++++++++++ web/src/join.js | 66 ++++++++++++++++++++++ web/src/pair.js | 79 +++++++++++++++----------- web/sw.js | 4 +- web/test/guest.test.js | 124 +++++++++++++++++++++++++++++++++++++++++ web/test/sw.test.js | 2 +- 8 files changed, 431 insertions(+), 43 deletions(-) create mode 100644 web/src/guest.js create mode 100644 web/src/join.js create mode 100644 web/test/guest.test.js diff --git a/web/index.html b/web/index.html index bf5c1b6..98fcb3e 100644 --- a/web/index.html +++ b/web/index.html @@ -37,6 +37,8 @@ .topbar { display: flex; align-items: center; gap: 6px; background: #0d121b; border-bottom: 1px solid var(--line); padding: calc(env(safe-area-inset-top) + 8px) 10px 8px; } .tb-btn { background: none; border: 0; color: var(--fg); font: inherit; font-size: 15px; cursor: pointer; padding: 8px; min-width: 44px; } + .tb-ro { flex: 0 0 auto; font: 11px ui-monospace, monospace; color: var(--mut); + border: 1px solid var(--line); border-radius: 6px; padding: 2px 6px; white-space: nowrap; } .tb-title { flex: 1; text-align: center; font-weight: 600; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } [hidden] { display: none !important; } /* .machbar/.termbox toggle via el.hidden; explicit display would win otherwise */ .machbar { display: flex; align-items: center; gap: 4px; padding: 4px 8px; background: #0d121b; border-bottom: 1px solid var(--line); } diff --git a/web/src/app.js b/web/src/app.js index 6578316..cd03e23 100644 --- a/web/src/app.js +++ b/web/src/app.js @@ -14,6 +14,8 @@ import { FitAddon } from '@xterm/addon-fit'; import { listMachines, addMachine } from './store.js'; import { fetchMachines, mergeMachines, freshDevices, sealMachineRecord } from './registry.js'; import { pairWithCode } from './pair.js'; +import { joinWithCode } from './join.js'; +import { saveGuestGrant, guestGrantFor, grantLive, sweepGuestGrants, shareSummary, expiryPhrase, modeWord, guardReadonlySend } from './guest.js'; import { confirmPairingSafety, machineAfterConfirmedPairing, pendingPairingConfirmation } from './pairing/confirm.js'; import { registerPasskey, signInPasskey, devOwnerKey, passkeySupported, isLocalhost } from './identity.js'; import { signBinding, recordJSON } from './identity/binding.js'; @@ -107,8 +109,10 @@ export async function connectOnce(machine, term, current, onConnected, onWindows const signer = signerKey(); if (isMachineRevoked(machine.machine_id, signer.address)) throw new Error('machine revoked'); // owner_id is the neutral Ed25519 address; the signed binding authorizes this - // browser's X25519 transport key for the Noise handshake. - const ownerId = signer.address; + // browser's X25519 transport key for the Noise handshake. A share routes + // under the MACHINE OWNER's id (the agent registers there) while the binding + // still authenticates us — the guest (G1c). + const ownerId = machine.owner || signer.address; const binding = recordJSON(signBinding(signer, deviceID(), bytesToHex(owner.pub), Math.floor(Date.now() / 1000))); const diag = { step: 'start', ws: 'init', gather: '', iceConn: '', conn: '', dc: 'init' }; window.__diag = diag; @@ -426,6 +430,10 @@ function openSession(machine) { loop: null, notify: null, // set by the mounted terminal view (mountGen-guarded) }; + if (machine.owner) { + const g = guestGrantFor(id); + if (!g || g.mode !== 'rw') guardReadonlySend(sess.current); // ro share: output only, at the send path + } sess.term.write('[mir] connecting to ' + (machine.name || machine.machine_id) + '…\r\n'); sessions.set(id, sess); startLoop(sess); @@ -639,7 +647,16 @@ function renderMachines(root, machines, fresh) { if (!machines.length) { mount(root, emptyMachinesView(root)); return; } const viewEl = el('div', { className: 'view' }); const grid = el('div', { className: 'grid' }); + const allShared = machines.every((m) => m.owner); for (const m of machines) { + if (m.owner) { + // A share someone gave this identity: the grant, not the registry, says + // what it is — and it carries no owner affordances (no rename/retire). + grid.append(el('button', { className: 'card machine shared', onclick: () => viewTerminal(root, m) }, + el('div', { className: 'name' }, '⇢ ' + (m.name || m.machine_id)), + el('div', { className: 'sub' }, shareSummary(guestGrantFor(m.machine_id))))); + continue; + } // A machine that is warm in the session pool (R2) shows its live state on // the card — tapping it switches back in place, scrollback intact. const warm = sessions.get(m.machine_id); @@ -656,8 +673,10 @@ function renderMachines(root, machines, fresh) { grid.append(el('button', { className: 'card add', onclick: () => viewPair(root) }, el('div', { className: 'plus' }, '+'), el('div', { className: 'sub' }, 'Pair a machine'))); const kids = [ - el('h1', {}, 'your machines'), - el('p', { className: 'muted' }, 'Your live terminals. Leave one device, continue on another.'), + el('h1', {}, allShared ? 'shared with you' : 'your machines'), + el('p', { className: 'muted' }, allShared + ? 'Terminals people shared with you. Each expires on its own.' + : 'Your live terminals. Leave one device, continue on another.'), ...retiredNotice(), ]; if (fresh && fresh.length) { @@ -677,6 +696,11 @@ function renderMachines(root, machines, fresh) { // best-effort: a failure just leaves the local list. Discovery only. When the // resulting list is empty, pollForMachine keeps refreshing it live (U3). function viewMachines(root) { + // Shares whose window has fully closed age out here, like the CLI sweep. + for (const gone of sweepGuestGrants()) { + const entry = listMachines().find((x) => x.machine_id === gone); + if (entry && entry.owner) removeMachine(gone); + } let localRevocations; try { localRevocations = loadRevocations(signerKey().address); } catch (e) { @@ -832,7 +856,48 @@ function viewPair(root, prefill = '', auto = false) { el('button', { className: 'link back', onclick: () => leaveScanner(() => viewMachines(root)) }, '← machines'))); } +// viewJoin claims a share invite (the /#join- link): run the guest +// ceremony, show the safety number to read aloud (the owner approves on their +// side), and land the share as a guest entry. +function viewJoin(root, code) { + const status = el('div', { className: 'status' }); + mount(root, el('div', { className: 'view' }, + el('h1', {}, 'joining a shared terminal…'), status)); + status.textContent = 'connecting to the invite…'; + const signer = signerKey(); + const binding = recordJSON(signBinding(signer, deviceID(), bytesToHex(ownerKey().pub), Math.floor(Date.now() / 1000))); + joinWithCode(code, signer, binding, (sas) => { + status.innerHTML = ''; + window.__lastSafety = sas; + status.append( + el('div', { className: 'ok' }, 'Read this safety number aloud to the person sharing:'), + el('div', { className: 'sas' }, sas), + el('div', { className: 'muted' }, 'They compare and approve on their side — nothing else to do here.')); + }).then(({ machine, grant }) => { + addMachine(machine); + saveGuestGrant(grant); + status.innerHTML = ''; + status.append( + el('div', { className: 'ok' }, '✓ joined ' + (machine.name || machine.machine_id) + ' — ' + modeWord(grant.mode) + ', ' + expiryPhrase(grant.na)), + el('div', { className: 'actions' }, + el('button', { className: 'btn', onclick: () => viewTerminal(root, machine) }, 'Open the terminal'), + el('button', { className: 'link', onclick: () => viewMachines(root) }, 'Done'))); + }).catch((e) => { + status.innerHTML = ''; + status.append( + el('div', { className: 'muted' }, (e && e.message) || String(e)), + el('button', { className: 'link', onclick: () => viewMachines(root) }, '← machines')); + }); +} + function viewTerminal(root, machineToOpen) { + // A share is checked against its own clock first: an expired grant would only + // earn the agent's silent refusal, which reads as "offline". + if (machineToOpen.owner && !grantLive(guestGrantFor(machineToOpen.machine_id))) { + noticeSheet('Your share of ' + (machineToOpen.name || machineToOpen.machine_id) + ' has ended — ask the owner for a new invite.'); + viewMachines(root); + return; + } // The view is a SHELL over the warm session pool: machineToOpen becomes the // active session (joining the pool — possibly evicting the LRU background // machine); every pooled machine keeps its terminal alive in the DOM, hidden @@ -879,13 +944,14 @@ function viewTerminal(root, machineToOpen) { const sw = el('button', { className: 'tb-btn', title: 'switch machine', onclick: () => openSwitcher() }, '⇄'); const revokeBtn = el('button', { className: 'tb-btn', title: 'retire machine', onclick: retire }, '⊘'); const titleEl = el('div', { className: 'tb-title' }, m().name || m().machine_id); + const roChip = el('span', { className: 'tb-ro', hidden: true }); const renameBtn = el('button', { className: 'tb-btn', title: 'rename machine', onclick: () => renameMachineUI() }, '✎'); // machbar: one chip per warm machine (name + state dot), shown only when two // or more are pooled — a single machine keeps today's clean layout. const machbar = el('div', { className: 'machbar', hidden: true }); const strip = el('div', { className: 'winbar' }); const view = el('div', { className: 'view term' }, - el('div', { className: 'topbar' }, back, titleEl, renameBtn, sw, revokeBtn), + el('div', { className: 'topbar' }, back, titleEl, roChip, renameBtn, sw, revokeBtn), machbar, strip, termHost); mount(root, view); const viewGen = mountGen; // this mount's token: stale session notifies no-op @@ -1076,7 +1142,19 @@ function viewTerminal(root, machineToOpen) { }; const renderTitle = () => { const mm = m(); titleEl.textContent = mm.name || mm.machine_id; }; - function renderChrome() { renderTitle(); renderPill(); renderMachbar(); renderStrip(); } + // syncGuestChrome: a share carries no owner affordances; a read-only share + // says so, with its clock, right in the topbar. + function syncGuestChrome() { + const mm = m(); + const isGuest = !!mm.owner; + renameBtn.hidden = isGuest; + revokeBtn.hidden = isGuest; + const g = isGuest ? guestGrantFor(mm.machine_id) : null; + const ro = g && g.mode !== 'rw'; + roChip.hidden = !ro; + if (ro) roChip.textContent = 'read-only · ' + expiryPhrase(g.na); + } + function renderChrome() { renderTitle(); renderPill(); renderMachbar(); renderStrip(); syncGuestChrome(); } // Every pooled terminal lives in the DOM, hidden except the active one — the // durable-terminal design across machines: scrollback survives switching. @@ -1134,7 +1212,8 @@ function viewTerminal(root, machineToOpen) { // after sign-in: replay a scanned pairing code, else show machines function afterSignIn(root, pendingFrag) { - if (pendingFrag) viewPair(root, pendingFrag, true); + if (pendingFrag && pendingFrag.startsWith('join-')) viewJoin(root, pendingFrag.slice(5)); + else if (pendingFrag) viewPair(root, pendingFrag, true); else viewMachines(root); } diff --git a/web/src/guest.js b/web/src/guest.js new file mode 100644 index 0000000..de87619 --- /dev/null +++ b/web/src/guest.js @@ -0,0 +1,104 @@ +// web/src/guest.js — the guest side of sharing in the SPA (G1e): the local +// grant store, the shared expiry phrasing, and the read-only send guard. +// Mirrors the CLI's client/shares.go where the shapes overlap. +import { FRAME_RESIZE } from './noise/frame.js'; +import { verifyGrant, validAt } from './identity/grant.js'; + +const KEY = 'tr_guest_grants'; +const SKEW_S = 300; // matches identity.GrantSkew + +function readAll() { + try { + return JSON.parse(localStorage.getItem(KEY) || '{}'); + } catch { + return {}; + } +} + +function writeAll(map) { + try { + localStorage.setItem(KEY, JSON.stringify(map)); + } catch {} +} + +// saveGuestGrant stores a verified grant record (the caller verified it). +export function saveGuestGrant(sg) { + const map = readAll(); + map[sg.gid] = sg; + writeAll(map); +} + +export function listGuestGrants() { + return Object.values(readAll()).sort((a, b) => b.na - a.na); +} + +// guestGrantFor returns the latest-expiring grant covering machineID, or null. +export function guestGrantFor(machineID) { + for (const g of listGuestGrants()) { + if (g.machine === machineID) return g; + } + return null; +} + +// grantLive reports whether a grant's window covers now and it still verifies. +export function grantLive(g, nowSec = Math.floor(Date.now() / 1000)) { + return !!g && verifyGrant(g) && validAt(g, nowSec); +} + +// sweepGuestGrants drops grants whose window has fully closed (past na + skew) +// and returns the machine ids left with no grant at all — the caller removes +// those machine entries, exactly like the CLI's SweepGuestState. +export function sweepGuestGrants(nowSec = Math.floor(Date.now() / 1000)) { + const map = readAll(); + const hadMachine = new Set(); + const liveMachine = new Set(); + for (const [gid, g] of Object.entries(map)) { + hadMachine.add(g.machine); + if (g.na < nowSec - SKEW_S) delete map[gid]; + else liveMachine.add(g.machine); + } + writeAll(map); + return [...hadMachine].filter((m) => !liveMachine.has(m)); +} + +// expiryPhrase matches the CLI's wording exactly ("expires in 42 min"). +export function expiryPhrase(na, nowSec = Math.floor(Date.now() / 1000)) { + const left = na - nowSec; + if (left <= 0) return 'expired'; + if (left < 60) return 'expires in under a minute'; + if (left < 3600) return `expires in ${Math.floor(left / 60)} min`; + return `expires in ${Math.floor(left / 3600)}h ${String(Math.floor(left / 60) % 60).padStart(2, '0')}min`; +} + +export function modeWord(mode) { + return mode === 'rw' ? 'read-write' : 'read-only'; +} + +// shareSummary is the one line a share renders under its name — identical to +// the CLI's `mir ls` phrasing. +export function shareSummary(grant) { + if (!grant) return 'shared with you'; + return `shared with you · ${modeWord(grant.mode)} · ${expiryPhrase(grant.na)}`; +} + +// guardReadonlySend makes a session's send path drop everything except RESIZE +// before it reaches the wire. connectOnce assigns current.send on every +// (re)connect, so the guard is a property setter: every assignment flows +// through it, and no keystroke source (term.onData, the key bar, tmux control) +// can bypass it — they all call current.send. The agent drops guest input +// anyway (G1c); this keeps the honest client from even sending it. +export function guardReadonlySend(current) { + let inner = current.send || null; + Object.defineProperty(current, 'send', { + get() { + if (!inner) return inner; + return (framed) => { + if (framed && framed[0] !== FRAME_RESIZE) return; // ro: output only + inner(framed); + }; + }, + set(fn) { + inner = fn; + }, + }); +} diff --git a/web/src/join.js b/web/src/join.js new file mode 100644 index 0000000..9eaca70 --- /dev/null +++ b/web/src/join.js @@ -0,0 +1,66 @@ +// web/src/join.js — claim a share invite from the browser (G1e). Mirrors the +// CLI's `mir join`: the guest rides the same blind pair room as pairing, proves +// its own key with pairing's msg1/msg3, presents its transport binding, shows +// the safety number (the OWNER holds the y/N on their side), and waits for the +// signed grant. Crypto is pairing's and grant.js's — nothing new. +import { startInitiator } from './pairing/nnpsk0.js'; +import { decodeCode } from './pairing/code.js'; +import { safetyNumber } from './pairing/sas.js'; +import { openPairRoom } from './pair.js'; +import { verifyGrant, validAt } from './identity/grant.js'; + +// The owner is a human deciding on a safety number: give them the same window +// the CLI invite has (5 min), not pairing's 30 s transport ceiling. +const JOIN_VERDICT_MS = 5 * 60 * 1000; + +// joinWithCode runs the guest ceremony. `bindingRecord` is this browser's +// signed transport binding (the same record every attach presents), built by +// the caller. onSafety(sas) fires as soon as the number is comparable — before +// the verdict wait — so the guest can read it aloud. Returns +// { machine, grant } with machine carrying the owner for attach routing. +export async function joinWithCode(code, signer, bindingRecord, onSafety) { + const { signalURL, token } = decodeCode(code); + const room = await openPairRoom(signalURL, token, JOIN_VERDICT_MS); + try { + const started = await startInitiator(room.mc, token, signer); + if (onSafety) onSafety(safetyNumber(started.binding)); + // The guest risks nothing by proceeding — the owner decides. Prove our key + // (msg3), present the binding, then wait for the verdict. + await started.finish(null); + room.mc.send(new TextEncoder().encode(bindingRecord)); + + let verdict; + try { + verdict = await room.mc.recv(); + } catch { + throw new Error('the invite was declined or expired — nothing was set up'); + } + let grant; + try { + grant = JSON.parse(new TextDecoder().decode(verdict)); + } catch { + throw new Error('the share record did not verify — ask for a new invite'); + } + if (!verifyGrant(grant)) { + throw new Error('the share record did not verify — ask for a new invite'); + } + if (grant.guest !== signer.address || grant.machine !== started.info.machine_id) { + throw new Error('the share was minted for a different device or machine — ask for a new invite'); + } + if (!validAt(grant, Math.floor(Date.now() / 1000))) { + throw new Error('this share has already ended — ask for a new invite'); + } + return { + machine: { + machine_id: started.info.machine_id, + host_pub: started.info.host_pub, + name: started.info.name, + signal: signalURL, + owner: grant.owner, // attach routes under the machine owner; we authenticate as the guest + }, + grant, + }; + } finally { + room.close(); + } +} diff --git a/web/src/pair.js b/web/src/pair.js index 27a3e8d..2d6e5c4 100644 --- a/web/src/pair.js +++ b/web/src/pair.js @@ -13,12 +13,10 @@ const wsBase = (signalURL) => 'ws' + signalURL.slice(4); // http->ws, https->wss // "pairing…" forever. 30s is generous for a human-paced QR scan + two round trips. const PAIR_TIMEOUT_MS = 30000; -// pairWithCode runs the pairing handshake using `signer` ({ address, priv }) as our -// identity: it sends the legacy-wire owner field and proves control with an auth -// signature over the channel binding. Returns { machine, safetyNumber }. -export async function pairWithCode(code, signer, secret = null) { - const { signalURL, token } = decodeCode(code); - +// openPairRoom dials the blind one-shot /pair room and returns a MsgConn over +// it plus the shared failure latch. Both ceremonies that ride the room — the +// owner pairing below and the guest join (join.js) — use this one scaffold. +export async function openPairRoom(signalURL, token, timeoutMs = PAIR_TIMEOUT_MS) { const ws = new WebSocket(wsBase(signalURL) + '/pair?room=' + roomID(token)); ws.binaryType = 'arraybuffer'; @@ -31,8 +29,7 @@ export async function pairWithCode(code, signer, secret = null) { if (!failed) failed = err; if (waiter) { const w = waiter; waiter = null; w.reject(failed); } }; - // 30s ceiling on the whole ceremony (mirrors connectOnce's connect timeout). - const timer = setTimeout(() => fail(new Error('pairing timed out')), PAIR_TIMEOUT_MS); + const timer = setTimeout(() => fail(new Error('pairing timed out')), timeoutMs); try { await new Promise((res, rej) => { @@ -41,29 +38,46 @@ export async function pairWithCode(code, signer, secret = null) { ws.onerror = () => rej(new Error('could not reach the pairing relay')); ws.onclose = () => rej(new Error('pairing relay closed the connection')); }); + } catch (e) { + clearTimeout(timer); + try { ws.close(); } catch {} + throw e; + } - // Post-open: re-wire close/error to the failure latch so a relay drop DURING the - // handshake rejects the pending recv() (the pre-open handlers' rejection is moot - // once open resolved). Without this the read side waited forever on a dead socket. - ws.onerror = () => fail(new Error('pairing relay error')); - ws.onclose = () => fail(new Error('pairing relay closed the connection')); + // Post-open: re-wire close/error to the failure latch so a relay drop DURING the + // handshake rejects the pending recv() (the pre-open handlers' rejection is moot + // once open resolved). Without this the read side waited forever on a dead socket. + ws.onerror = () => fail(new Error('pairing relay error')); + ws.onclose = () => fail(new Error('pairing relay closed the connection')); - // MsgConn over the WebSocket: one binary message per send/recv (the /pair - // bridge preserves message boundaries). - const inbox = []; - ws.onmessage = (ev) => { - const u = new Uint8Array(ev.data); - if (waiter) { const w = waiter; waiter = null; w.resolve(u); } else inbox.push(u); - }; - const mc = { - send: (b) => ws.send(b), - recv: () => new Promise((resolve, reject) => { - if (inbox.length) return resolve(inbox.shift()); - if (failed) return reject(failed); - waiter = { resolve, reject }; - }), - }; + // MsgConn over the WebSocket: one binary message per send/recv (the /pair + // bridge preserves message boundaries). + const inbox = []; + ws.onmessage = (ev) => { + const u = new Uint8Array(ev.data); + if (waiter) { const w = waiter; waiter = null; w.resolve(u); } else inbox.push(u); + }; + const mc = { + send: (b) => ws.send(b), + recv: () => new Promise((resolve, reject) => { + if (inbox.length) return resolve(inbox.shift()); + if (failed) return reject(failed); + waiter = { resolve, reject }; + }), + }; + const close = () => { clearTimeout(timer); try { ws.close(); } catch {} }; + return { mc, fail, close }; +} + +// pairWithCode runs the pairing handshake using `signer` ({ address, priv }) as our +// identity: it sends the legacy-wire owner field and proves control with an auth +// signature over the channel binding. Returns { machine, safetyNumber }. +export async function pairWithCode(code, signer, secret = null) { + const { signalURL, token } = decodeCode(code); + const room = await openPairRoom(signalURL, token); + const { mc, fail } = room; + try { const provisioner = secret ? (info) => { const record = new TextEncoder().encode(JSON.stringify({ v: 1, @@ -82,16 +96,14 @@ export async function pairWithCode(code, signer, secret = null) { const abort = () => { if (finished) return; finished = true; - clearTimeout(timer); fail(new Error('pairing cancelled')); - try { ws.close(); } catch {} + room.close(); }; const commit = async () => { if (finished) throw new Error('pairing already finished'); await started.finish(provisioner); finished = true; - clearTimeout(timer); - try { ws.close(); } catch {} + room.close(); }; return { machine: { machine_id: started.info.machine_id, host_pub: started.info.host_pub, name: started.info.name, signal: signalURL }, @@ -100,8 +112,7 @@ export async function pairWithCode(code, signer, secret = null) { abort, }; } catch (e) { - clearTimeout(timer); - try { ws.close(); } catch {} + room.close(); throw e; } } diff --git a/web/sw.js b/web/sw.js index b62fd13..eaee66f 100644 --- a/web/sw.js +++ b/web/sw.js @@ -13,7 +13,7 @@ // relay serves sw.js no-store — so a new deploy replaces this worker on the // next online load. -const CACHE = 'mir-shell-v5'; +const CACHE = 'mir-shell-v6'; // Everything the app needs to boot. test/sw.test.js fails if this list drifts // from the files on disk — add new modules here when you add them to src/. @@ -25,6 +25,8 @@ const SHELL = [ '/icons/icon-512.png', '/icons/icon-maskable-512.png', '/src/app.js', + '/src/guest.js', + '/src/join.js', '/src/boot.js', '/src/identity.js', '/src/identity/auth.js', diff --git a/web/test/guest.test.js b/web/test/guest.test.js new file mode 100644 index 0000000..a3d17ab --- /dev/null +++ b/web/test/guest.test.js @@ -0,0 +1,124 @@ +// web/test/guest.test.js — the SPA guest side (G1e): grant store + sweep, the +// CLI-identical phrasing, the read-only send guard, and source pins on the +// app wiring (join routing, owner routing, no owner affordances on a share). +import { test, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { + saveGuestGrant, listGuestGrants, guestGrantFor, grantLive, sweepGuestGrants, + expiryPhrase, shareSummary, guardReadonlySend, +} from '../src/guest.js'; +import { FRAME_DATA, FRAME_RESIZE, FRAME_CONTROL } from '../src/noise/frame.js'; + +const here = dirname(fileURLToPath(import.meta.url)); +const webRoot = join(here, '..'); +const vector = JSON.parse(readFileSync(join(webRoot, '..', 'testdata', 'grant.json'), 'utf8')); + +const vectorGrant = () => JSON.parse(vector.record); + +beforeEach(() => { + const values = new Map(); + globalThis.localStorage = { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, String(value)), + }; +}); + +// --- phrasing: byte-identical to the CLI ---------------------------------- + +test('expiryPhrase matches the CLI wording', () => { + const now = 1_000_000; + assert.equal(expiryPhrase(now + 30, now), 'expires in under a minute'); + assert.equal(expiryPhrase(now + 42 * 60 + 30, now), 'expires in 42 min'); + assert.equal(expiryPhrase(now + 3 * 3600 + 7 * 60, now), 'expires in 3h 07min'); + assert.equal(expiryPhrase(now - 1, now), 'expired'); +}); + +test('shareSummary matches the CLI list line', () => { + const now = Math.floor(Date.now() / 1000); + const g = { ...vectorGrant(), mode: 'ro', na: now + 42 * 60 + 30 }; + assert.equal(shareSummary(g), 'shared with you · read-only · expires in 42 min'); + assert.equal(shareSummary(null), 'shared with you'); +}); + +// --- store + sweep --------------------------------------------------------- + +test('guest grants store, pick-latest, and sweep like the CLI', () => { + const now = Math.floor(Date.now() / 1000); + const old = { ...vectorGrant(), gid: 'aaaaaaaaaaaaaaaa', machine: 'm1', na: now + 600 }; + const newer = { ...vectorGrant(), gid: 'bbbbbbbbbbbbbbbb', machine: 'm1', na: now + 3600 }; + const dead = { ...vectorGrant(), gid: 'cccccccccccccccc', machine: 'm2', na: now - 3600 }; + for (const g of [old, newer, dead]) saveGuestGrant(g); + + assert.equal(guestGrantFor('m1').gid, 'bbbbbbbbbbbbbbbb'); + assert.equal(guestGrantFor('nope'), null); + + const orphaned = sweepGuestGrants(now); + assert.deepEqual(orphaned, ['m2']); // only the machine with no live grant + assert.equal(listGuestGrants().length, 2); // the dead grant is gone +}); + +test('grantLive requires both a verifying signature and a live window', () => { + const g = vectorGrant(); + const inWindow = g.nb + 60; + assert.equal(grantLive(g, inWindow), true); + assert.equal(grantLive(g, g.na + 1), false); // expired + assert.equal(grantLive({ ...g, scope: 'other' }, inWindow), false); // tampered + assert.equal(grantLive(null, inWindow), false); +}); + +// --- the read-only send guard --------------------------------------------- + +test('guardReadonlySend drops data and control, passes resize, survives reconnect reassignment', () => { + const sent = []; + const current = {}; + guardReadonlySend(current); + + current.send = (framed) => sent.push(framed[0]); // first connect + current.send(Uint8Array.of(FRAME_DATA, 0x41)); + current.send(Uint8Array.of(FRAME_CONTROL, 0x7b)); + current.send(Uint8Array.of(FRAME_RESIZE, 0, 80, 0, 24)); + assert.deepEqual(sent, [FRAME_RESIZE]); + + current.send = (framed) => sent.push(framed[0]); // reconnect swaps send — guard must hold + current.send(Uint8Array.of(FRAME_DATA, 0x42)); + current.send(Uint8Array.of(FRAME_RESIZE, 0, 100, 0, 30)); + assert.deepEqual(sent, [FRAME_RESIZE, FRAME_RESIZE]); + + current.send = null; // closeSession does this; must not throw + assert.equal(current.send, null); +}); + +// --- source pins on the app wiring ---------------------------------------- + +const app = readFileSync(join(webRoot, 'src', 'app.js'), 'utf8'); + +test('a join link routes before a pairing code', () => { + const i = app.indexOf("pendingFrag.startsWith('join-')"); + const j = app.indexOf('viewPair(root, pendingFrag, true)'); + assert.ok(i >= 0 && j >= 0 && i < j, 'afterSignIn must branch join- before replaying as a pairing code'); +}); + +test('attach routes a share under the machine owner', () => { + assert.match(app, /machine\.owner \|\| signer\.address/); +}); + +test('a read-only share is guarded at session creation', () => { + const open = app.slice(app.indexOf('function openSession'), app.indexOf('function closeSession')); + assert.match(open, /guardReadonlySend\(sess\.current\)/); +}); + +test('a share card carries no owner affordances', () => { + const shared = app.slice(app.indexOf("className: 'card machine shared'"), app.indexOf("continue;")); + assert.ok(!shared.includes('retire'), 'shared card must not offer retire'); + assert.match(shared, /shareSummary/); +}); + +test('the guest chrome hides rename and retire', () => { + const chrome = app.slice(app.indexOf('function syncGuestChrome'), app.indexOf('function renderChrome')); + assert.match(chrome, /renameBtn\.hidden = isGuest/); + assert.match(chrome, /revokeBtn\.hidden = isGuest/); + assert.match(chrome, /read-only · /); +}); diff --git a/web/test/sw.test.js b/web/test/sw.test.js index d00c044..4519b6f 100644 --- a/web/test/sw.test.js +++ b/web/test/sw.test.js @@ -102,7 +102,7 @@ test('network-first: serves the fresh response and rewrites the cache', async () request: getRequest('/src/app.js'), }); assert.equal(await response.text(), 'shell:/src/app.js'); - assert.equal(await stores.get('mir-shell-v5').get('/src/app.js').clone().text(), 'shell:/src/app.js'); + assert.equal(await stores.get('mir-shell-v6').get('/src/app.js').clone().text(), 'shell:/src/app.js'); }); test('relay unreachable: falls back to the cached shell', async () => {