// A call in a page (WAI live-messaging §10): a room service (`joule-moq-room` with `[call]`) // keeps the call's room, its records and each principal's inbox. // // The page makes its own Ed25519 key and shows it, for a caller to ring. It reads its inbox // (§10.4) every second on a proof of that key alone (a proof naming no grant, live-rooms // §10.5.3), and for each invitation checks the call descriptor and the invitation, their // signatures and their room, with `wai_live_web`'s callCheck, before it rings: an invitation // that does not verify is not shown. A missed call, signed by the room's clock at the // deadline, is shown as missed. // // To call, the page proves its key and names the keys it calls; the service's authority signs // the call descriptor and the caller's grant; the page signs an invitation for each invitee // and posts them with that grant. To answer, it signs accept or decline (the caller signs // cancel). The call's media is the room's own audio and video: the room page (./index.html), // in a frame, enters with the grant the call came with and this page's key, and publishes the // camera and the microphone as in any room. // // Query parameters: // service= the room service, on the page's own origin (./endpoint.js) // ws= the relay, passed on to the room page (its own origin, or loopback) // // Everything a test reads is on `window.callTest`. import initWai, * as wai from "../pkg/wai_live_web.js"; import initMoq, * as moq from "../pkg/joule_moq_web.js"; import { checkEndpoint } from "./endpoint.js"; const $ = (id) => document.getElementById(id); const hex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); const unhex = (h) => new Uint8Array((h.match(/../g) || []).map((x) => parseInt(x, 16))); const b64u = (b) => btoa(String.fromCharCode(...b)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); const fromB64u = (s) => Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0)); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const ACCEPT = 1, DECLINE = 2, CANCEL = 3; const q = new URLSearchParams(location.search); const logLines = []; function log(m) { logLines.push(`${(performance.now() / 1000).toFixed(3)} ${m}`); $("log").textContent = logLines.slice(-200).join("\n"); } function status(m) { $("status").textContent = m; } const state = { phase: "idle", me: null, inbox: [], refused: [], call: null, media: null, error: null }; window.callTest = { state, logLines }; let service = null; { const v = q.get("service"); const c = v ? checkEndpoint(v, "http", location.href) : { ok: false, reason: "none named" }; if (c.ok) service = c.url.replace(/\/$/, ""); else { state.phase = "refused"; status(`No room service this page takes (${v || "none"}: ${c.reason}).`); } } let seed = null; let grant = null; // the caller's grant, for ringing let skew = 0n; // the service's clock less this page's, in microseconds const challenges = []; async function request(method, path, body, withGrant = false) { const u = new URL(`${service}${path}`, location.href); for (let i = 0; i < 2; i++) { let ch = challenges.pop(); if (!ch) { const r = await fetch(new URL(`${service}/call/challenge`, location.href), { cache: "no-store" }); ch = unhex((await r.json()).challenge); } const target = `//${u.host}${u.pathname}`; const headers = {}; if (withGrant) { headers.authorization = `Bearer ${b64u(unhex(grant))}`; headers["wai-room-proof"] = b64u(moq.grantProofHttp(unhex(grant), seed, ch, method, target)); } else { headers["wai-room-proof"] = b64u(moq.keyProofHttp(seed, ch, method, target)); } if (body) headers["content-type"] = "application/json"; const r = await fetch(u, { method, headers, body: body ? JSON.stringify(body) : undefined, cache: "no-store" }); const c = r.headers.get("wai-room-challenge"); if (c) challenges.push(fromB64u(c)); let j = null; try { j = await r.json(); } catch { /* not JSON */ } if (r.status !== 401) return { status: r.status, body: j }; } return { status: 401, body: null }; } function roomNow(t0) { return BigInt(Date.now()) * 1000n + skew - BigInt(t0); } // The room page, in a frame, entering with the grant the call came with (live-rooms §4). async function enterMedia(answer) { $("media").hidden = false; const frame = $("room"); const u = new URL("./index.html", location.href); u.searchParams.set("service", q.get("service")); if (q.get("ws")) u.searchParams.set("ws", q.get("ws")); frame.src = u.toString(); for (let i = 0; i < 200; i++) { const w = frame.contentWindow; if (w && w.roomTest && w.roomTest.enter && w.roomTest.state && w.roomTest.state.phase === "idle") break; await sleep(100); } state.media = "entering"; const phase = await frame.contentWindow.roomTest.enter("publisher", { seed: hex(seed), answer }); state.media = phase; log(`the call's room: ${phase}`); return phase; } // Place a call to `keys` (64 hexadecimal digits each), ring each, and enter the call's room. async function place(keys) { const r = await request("POST", "/call/place", { invitees: keys }); if (r.status !== 200) throw new Error(`the call was not placed: ${(r.body && r.body.error) || r.status}`); const p = r.body; skew = BigInt(p.now_us) - BigInt(Date.now()) * 1000n; grant = p.grant; const t0 = p.room.t0_us; const t = roomNow(t0); const invitations = keys.map((k) => hex(wai.invitationRecord(seed, unhex(p.room.room), unhex(p.digest), unhex(k), t))); const rr = await request("POST", "/call/ring", { invitations }, true); if (rr.status !== 200) throw new Error(`the invitations were not taken: ${(rr.body && rr.body.error) || rr.status}`); state.call = { room: p.room.room, t0, digest: p.digest, invitations, ring_ms: p.ring_ms }; state.phase = "calling"; $("cancel").hidden = false; status(`Ringing ${keys.length === 1 ? "one" : keys.length}…`); log(`placed the call ${p.digest.slice(0, 12)} in room ${p.room.room.slice(0, 12)}`); await enterMedia({ grant: p.grant, digest: p.grant_digest, exp: p.exp, role: "publisher", room: p.room }); return state.call; } // Cancel every invitation still ringing (§10.3, kind 3). async function cancel() { const c = state.call; if (!c) return; for (const inv of c.invitations) { const digest = await crypto.subtle.digest("SHA-256", unhex(inv).slice(0, unhex(inv).length - 64)); const a = wai.answerRecord(seed, unhex(c.room), new Uint8Array(digest), CANCEL, roomNow(c.t0)); await request("POST", "/call/answer", { answer: hex(a) }); } } // Answer an invitation of the inbox: accept (then enter the call's room) or decline. async function answer(item, kind) { const a = wai.answerRecord(seed, unhex(item.check.room), unhex(item.check.digest), kind, roomNow(item.t0)); const r = await request("POST", "/call/answer", { answer: hex(a) }); if (r.status !== 200) throw new Error(`the answer was not taken: ${(r.body && r.body.error) || r.status}`); log(`answered ${kind === ACCEPT ? "accept" : "decline"}: ${r.body.state}`); if (kind === ACCEPT && r.body.grant) { state.phase = "in-call"; await enterMedia({ grant: r.body.grant, digest: r.body.grant_digest, exp: r.body.exp, role: "publisher", room: r.body.room }); } return r.body.state; } // The inbox, each invitation checked before it rings. async function readInbox() { const r = await request("GET", "/inbox"); if (r.status !== 200) return; if (r.body.now_us) skew = BigInt(r.body.now_us) - BigInt(Date.now()) * 1000n; const items = []; for (const x of r.body.invitations) { const check = JSON.parse(wai.callCheck(unhex(x.descriptor), unhex(x.call), unhex(x.invitation), x.answer ? unhex(x.answer) : undefined)); if (!check.ok || check.callee !== state.me) { if (!state.refused.some((y) => y.invitation === x.invitation)) { state.refused.push({ invitation: x.invitation, reason: check.reason || "not for this key" }); log(`an invitation that does not verify, not shown: ${check.reason || "another callee"}`); } continue; } const t0 = JSON.parse(wai.roomDescriptor(unhex(x.descriptor))).t0_us; items.push({ ...x, check, t0, state: check.state }); } state.inbox = items; render(); } function render() { const ul = $("inbox"); ul.replaceChildren(...state.inbox.map((x) => { const li = document.createElement("li"); li.dataset.state = x.state; const from = x.check.caller.slice(0, 12); const what = { ringing: `Ringing: a call from ${from}`, accepted: `Accepted: ${from}`, declined: `Declined: ${from}`, cancelled: `Cancelled by ${from}`, missed: `Missed call from ${from}` }[x.state] || `${x.state}: ${from}`; li.append(what); if (x.state === "ringing" && state.phase !== "in-call") { for (const [label, k] of [["Accept", ACCEPT], ["Decline", DECLINE]]) { const b = document.createElement("button"); b.type = "button"; b.textContent = label; b.style.marginLeft = "8px"; b.addEventListener("click", () => answer(x, k).catch(fail)); li.append(b); } } return li; })); } function fail(e) { state.error = String(e && e.message || e); log(`error: ${state.error}`); status(`Something went wrong: ${state.error}`); } async function main() { await initMoq(); await initWai(); seed = crypto.getRandomValues(new Uint8Array(32)); state.me = hex(moq.publicKeyOf(seed)); $("key").textContent = state.me; state.phase = "ready"; status("Ready: give your key to whoever calls you, or call someone."); $("place").addEventListener("click", () => { const keys = $("invitees").value.split(/\s+/).map((s) => s.trim()).filter((s) => /^[0-9a-f]{64}$/.test(s)); if (!keys.length) return status("Name at least one key of 64 hexadecimal digits."); place(keys).catch(fail); }); $("cancel").addEventListener("click", () => cancel().catch(fail)); for (;;) { try { await readInbox(); } catch (e) { log(`reading the inbox: ${e.message || e}`); } await sleep(1000); } } Object.assign(window.callTest, { place, cancel, accept: (i = 0) => answer(state.inbox.filter((x) => x.state === "ringing")[i], ACCEPT), decline: (i = 0) => answer(state.inbox.filter((x) => x.state === "ringing")[i], DECLINE), room: () => { const w = $("room").contentWindow; return w && w.roomTest; }, }); if (service) main().catch(fail);