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 () => {