open-standards/joule-code/joule-code-rs/crates/joule-moq-web/web/room/call.js
Transaction Science 02622a7fd1 joule-moq-web: a messaging view and a call flow beside the room page
web/room/messages.html: a member of a conversation in a page. It joins with a
proof of its own key, keys through the service's log with joule_moq_web_e2ee's
member (any member commits), seals each message before it is signed into its
receipt chain and posted, opens and verifies every message (wai_live_web's
Messages, live-messaging §11), shows threads, reactions, edits and
retractions, and acknowledges what it read.

web/room/call.html: a page's inbox, each invitation checked with its call
before it rings (callCheck); placing a call, ringing, accepting, declining and
cancelling; the call's media through the room page in a frame, which now takes
a grant handed to it (enter(role, { seed, answer })).

wai-live-web: the messaging records, receipts, acknowledgements, invitations
and answers a page signs, and Messages, verified against the corpus in its
tests. joule-moq-web: keyProofHttp, a proof naming no grant.

e2e/messaging.e2e.mjs (run by e2e/run.sh): the messaging scenario and the call
scenario.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:12:32 -04:00

236 lines
10 KiB
JavaScript

// 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=<url> the room service, on the page's own origin (./endpoint.js)
// ws=<url> 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);