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