open-standards/joule-code/joule-code-rs/crates/joule-moq-web/web/room/messages.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

462 lines
19 KiB
JavaScript

// A conversation in a page (WAI live-messaging): a member of an end-to-end encrypted room
// kept by a room service (`joule-moq-room` with `[messaging]`).
//
// The page makes its own Ed25519 key and joins with a proof of it (a proof naming no grant,
// live-rooms §10.5.3); the service admits it under a grant, and its clock records the `join`.
// The page leaves a KeyPackage and reads the conversation's log from its first entry
// (live-messaging §9.1): the clock's lifecycle records, each checked as the clock's; the
// group's handshake messages, which its MLS member (`joule_moq_web_e2ee`'s E2eeMember, a
// conversation's member, `anyMemberCommits`) checks and applies, creating the group or
// committing when the room calls on it (§7.2); the clock's epoch statements, each checked
// against the epoch the member reached (§7.3); messages, each opened by the member through the
// receiving checks and verified as §11.3 verifies it (`wai_live_web`'s Messages); and
// acknowledgements. It seals each message it sends under its latest epoch, signs it into its
// receipt chain (§4.3), and posts it; it acknowledges what it has shown (§8). Threads,
// reactions, edits and retractions are messages naming their target's link hash (§6): a sink
// shows an edited message's current text and that it was edited, and a retracted one's body
// never. The member's MLS state and every key it derives live in its memory alone: a page that
// is closed and opened again is a new member (§7.5), and opens nothing it held before.
//
// Query parameters:
// service=<url> the room service, on the page's own origin (./endpoint.js)
//
// Everything a test reads is on `window.msgTest`.
import initWai, * as wai from "../pkg/wai_live_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 KIND = { text: 1, reply: 2, reaction: 3, edit: 4, retraction: 5 };
const PRIORITY = 64; // live-messaging §4.2
const q = new URLSearchParams(location.search);
const logLines = [];
function log(m) {
logLines.push(`${(performance.now() / 1000).toFixed(3)} ${m}`);
const el = $("log");
if (el) el.textContent = logLines.slice(-200).join("\n");
}
function status(m) {
const el = $("status");
if (el) el.textContent = m;
}
const state = {
phase: "idle", // idle, joining, member, removed, left, refused
me: null,
room: null,
at: 0,
epoch: null,
members: [],
view: null,
refused: {},
posts: { ok: 0, refused: {} },
acks: { ok: 0, refused: {} },
opened: 0,
unopened: 0,
offline: false,
error: null,
};
window.msgTest = { state, logLines };
let service = null;
{
const v = q.get("service");
if (v) {
const c = checkEndpoint(v, "http", location.href);
if (c.ok) service = c.url.replace(/\/$/, "");
else {
state.phase = "refused";
status(`This link names a room service that is not this page's own (${v}: ${c.reason}). The page does not use it.`);
}
}
}
let moq = null;
let member = null;
let messages = null; // wai_live_web's Messages: the conversation as this member verifies it
let seed = null;
let grant = null;
let conv = null; // GET /msg's view at joining
let chain = { seq: 0, prev: "00".repeat(32) };
const challenges = []; // single-use, each from a response of the service (live-rooms §10.5.3)
const acked = {}; // author -> the last group this page acknowledged read
// One request to the service, with this page's grant (or, before it has one, a proof of its
// key alone) and a proof over a challenge the service issued; asked once more with a fresh
// challenge when refused 401.
async function request(method, path, body) {
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}/msg/challenge`, location.href), { cache: "no-store" });
ch = unhex((await r.json()).challenge);
}
const target = `//${u.host}${u.pathname}`;
const proof = grant ? moq.grantProofHttp(unhex(grant), seed, ch, method, target) : moq.keyProofHttp(seed, ch, method, target);
const headers = { "wai-room-proof": b64u(proof) };
if (grant) headers.authorization = `Bearer ${b64u(unhex(grant))}`;
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 };
}
// Every change to the member and to the verified conversation, one at a time.
let lock = Promise.resolve();
const serial = (f) => {
const p = lock.then(f, f);
lock = p.catch(() => {});
return p;
};
function nsOf(author) {
return [state.room, author, "messages"];
}
// One entry of the log, in its order.
function take(e) {
const seq = BigInt(e.seq);
if (e.event) {
const v = JSON.parse(wai.eventRead(unhex(e.event)));
if (!v.ok || v.principal !== conv.clock || v.room !== state.room) throw new Error(`a lifecycle record that is not the clock's: ${v.reason || v.principal}`);
if (v.seq !== chain.seq || v.prev !== chain.prev) throw new Error(`the clock's chain breaks at ${v.seq}`);
chain = { seq: v.seq + 1, prev: v.digest };
const p = v.payload || {};
if (v.kind_name === "join") member.event(JSON.stringify({ event: "join", subject: p.subject, keyed: true }));
else if (v.kind_name === "leave") member.event(JSON.stringify({ event: "leave", subject: p.subject, reason: p.detail.reason }));
} else if (e.mls) {
const v = JSON.parse(member.receive(unhex(e.mls)));
if (!v.ok) {
const why = v.reason || v.error;
state.refused[why] = (state.refused[why] || 0) + 1;
log(`an MLS message refused: ${why}${v.detail ? ` (${v.detail})` : ""}`);
} else if (["epoch", "joined", "created"].includes(v.received)) {
log(`epoch ${v.epoch} (${v.received})`);
} else if (v.received === "removed") {
log(`removed from the group by the commit that opens epoch ${v.epoch}`);
}
} else if (e.epoch) {
const v = JSON.parse(messages.offerEpoch(seq, unhex(e.epoch)));
if (!v.ok) throw new Error(`an epoch statement does not verify: ${v.reason}`);
// §7.3: the statement names the roster this member's group reached.
const ms = JSON.parse(member.state());
if (ms.epoch === v.epoch && ms.roster) {
const mine = Object.entries(ms.roster).map(([l, p]) => [Number(l), p]).sort((a, b) => a[0] - b[0]);
if (JSON.stringify(mine) !== JSON.stringify(v.roster)) throw new Error(`the epoch statement for epoch ${v.epoch} is not the roster this page's group reached`);
}
state.epoch = { generation: v.generation, epoch: v.epoch };
state.members = v.roster.map((x) => x[1]);
} else if (e.message) {
const m = e.message;
const props = unhex(m.properties);
const payload = unhex(m.payload);
const o = member.open(JSON.stringify(nsOf(m.author)), "0", BigInt(m.group), 0n, PRIORITY, props, payload);
const ov = JSON.parse(o.json);
const opened = ov.ok ? o.payload() : undefined;
o.free();
if (ov.ok) state.opened++;
else { state.unopened++; log(`a message at ${e.seq} did not open: ${ov.reason || `pending ${ov.pending}`}`); }
const r = JSON.parse(messages.offerMessage(seq, unhex(m.author), BigInt(m.group), props, payload, unhex(m.receipts), opened));
if (r.outcome !== "accepted") log(`a message at ${e.seq}: ${r.outcome}`);
} else if (e.ack) {
messages.offerAck(seq, unhex(e.ack));
}
}
// Read the log from where this page stopped; `wait` holds the request while nothing is new.
async function readOnce(wait) {
const r = await request("GET", `/msg/log?from=${state.at}&wait_ms=${wait}`);
if (r.status === 403) {
state.phase = state.phase === "left" ? "left" : "removed";
status(state.phase === "removed" ? "You are no longer a member of this conversation: nothing sent after your removal reaches this page." : "You left the conversation.");
return false;
}
if (r.status !== 200) throw new Error(`the log answered ${r.status}`);
await serial(async () => {
for (const e of r.body.entries) {
if (e.seq < state.at) continue;
take(e);
state.at = e.seq + 1;
}
state.at = Math.max(state.at, r.body.next);
await act();
render();
});
return true;
}
// Create the group, or commit, when the room calls on this member; the log's order decides
// whether it is applied (confidentiality §15.2).
async function act() {
if (member.owesCreate()) {
await postMls([member.stageCreate()], "GroupInfo");
} else if (member.shouldCommit()) {
const c = member.stageCommit();
const w = c.welcome();
const frames = [c.commit(), ...(w ? [w] : [])];
const e = c.epoch;
c.free();
await postMls(frames, `commit opening epoch ${e}`);
}
}
async function postMls(frames, what) {
const r = await request("POST", "/msg/mls", { frames: frames.map(hex) });
if (r.status === 200) log(`posted the ${what}`);
else {
member.withdraw();
log(`the ${what} was not carried: ${r.status}${r.body && r.body.error ? ` (${r.body.error})` : ""}`);
}
}
async function follow() {
let back = 500;
while (state.phase === "member") {
if (state.offline) { await sleep(200); continue; }
try {
if (!(await readOnce(member.staged() ? 0 : 4000))) break;
await ackRead();
back = 500;
} catch (e) {
log(`reading the log: ${e.message || e}`);
await sleep(back);
back = Math.min(back * 2, 4000);
}
}
}
function roomNow() {
return BigInt(Date.now()) * 1000n - BigInt(conv.t0_us);
}
// Seal, sign into this page's receipt chain, and post one message (§4.2, §4.3, §5); on a
// stale epoch or a group already taken, read the log and try once more.
async function send(kind, target, text) {
if (state.phase !== "member") throw new Error("not a member");
for (let attempt = 0; attempt < 3; attempt++) {
const r = await serial(async () => {
if (member.epoch() === undefined) return { status: 0, body: { error: "no epoch yet" } };
const nx = JSON.parse(messages.nextOf(unhex(state.me)));
const group = BigInt(nx.group);
const rec = wai.messageRecord(seed, unhex(state.room), group, roomNow(), KIND[kind], target ? unhex(target) : new Uint8Array(0), text);
const s = member.seal(JSON.stringify(nsOf(state.me)), "0", group, 0n, PRIORITY, rec, false, false, 0n);
const props = s.properties();
const payload = s.payload();
s.free();
const rc = JSON.parse(wai.messageReceipt(seed, group, payload, nx.parent ? unhex(nx.parent) : new Uint8Array(0), new Date().toISOString().replace(/\.\d+Z$/, "Z")));
return request("POST", "/msg/post", { group: nx.group, properties: hex(props), payload: hex(payload), receipts: rc.receipts }).then((x) => ({ ...x, link: rc.link }));
});
if (r.status === 200) {
state.posts.ok++;
await readOnce(0);
return r.link;
}
const why = (r.body && r.body.error) || String(r.status);
state.posts.refused[why] = (state.posts.refused[why] || 0) + 1;
log(`a message was not taken: ${why}`);
if (!["epoch_stale", "message_gap", "no epoch yet"].includes(why)) throw new Error(`the conversation refused the message: ${why}`);
await readOnce(0);
await sleep(200);
}
throw new Error("the message was not taken");
}
// Acknowledge, as read, every author's track up to what this page shows (§8).
async function ackRead() {
if (document.visibilityState === "hidden") return;
const v = JSON.parse(messages.view());
const last = {};
for (const m of v.messages) if (m.outcome === "accepted" && m.author !== state.me) last[m.author] = m;
for (const [author, m] of Object.entries(last)) {
if (acked[author] != null && acked[author] >= m.group) continue;
const a = wai.ackRecord(seed, unhex(state.room), unhex(author), BigInt(m.group), unhex(m.link), 2, roomNow());
const r = await request("POST", "/msg/ack", { ack: hex(a) });
if (r.status === 200) { acked[author] = m.group; state.acks.ok++; }
else { const why = (r.body && r.body.error) || String(r.status); state.acks.refused[why] = (state.acks.refused[why] || 0) + 1; acked[author] = m.group; }
}
}
// --------------------------------------------------------------------------- the view
let replyTo = null;
let editing = null;
function short(p) {
return p === state.me ? "you" : p.slice(0, 8);
}
function render() {
if (!messages) return;
const v = JSON.parse(messages.view());
state.view = v;
const list = $("messages");
if (!list) return;
const byLink = new Map(v.messages.filter((m) => m.outcome === "accepted").map((m) => [m.link, m]));
const roots = v.messages.filter((m) => m.outcome === "accepted" && m.kind === "text");
list.replaceChildren(...roots.map((m) => item(m, byLink)));
$("who").textContent = `you · ${state.me.slice(0, 12)}`;
if (state.epoch) $("epoch").textContent = `Epoch ${state.epoch.epoch} of the room's group`;
$("members").replaceChildren(...state.members.map((p) => { const li = document.createElement("li"); li.textContent = short(p); return li; }));
}
function item(m, byLink) {
const li = document.createElement("li");
li.dataset.link = m.link;
if (m.author === state.me) li.classList.add("mine");
const body = document.createElement("div");
body.className = "body";
const who = document.createElement("span");
who.className = "meta";
who.textContent = `${short(m.author)} `;
body.append(who);
const text = document.createElement("span");
if (m.status === "retracted") { text.className = "retracted"; text.textContent = "retracted"; }
else text.textContent = m.text;
body.append(text);
if (m.status === "edited") { const e = document.createElement("span"); e.className = "meta"; e.textContent = " (edited)"; body.append(e); }
if (m.status !== "retracted") body.append(actions(m));
li.append(body);
if (m.reactions && m.reactions.length) {
const counts = {};
for (const [w, r] of m.reactions) counts[r] = (counts[r] || 0) + 1;
const rs = document.createElement("div");
rs.className = "reactions";
for (const [r, n] of Object.entries(counts)) { const s = document.createElement("span"); s.textContent = `${r} ${n}`; rs.append(s); }
li.append(rs);
}
if (m.replies && m.replies.length) {
const ul = document.createElement("ul");
ul.className = "thread";
for (const l of m.replies) { const r = byLink.get(l); if (r) ul.append(item(r, byLink)); }
li.append(ul);
}
return li;
}
function button(label, f) {
const b = document.createElement("button");
b.type = "button";
b.textContent = label;
b.addEventListener("click", () => f().catch(fail));
return b;
}
function actions(m) {
const s = document.createElement("span");
s.className = "acts";
if (m.kind === "text") s.append(button("Reply", async () => { replyTo = m.link; editing = null; showTarget(`Replying to ${short(m.author)}`); }));
const mine = m.reactions && m.reactions.some(([w, r]) => w === state.me && r === "+1");
if (!mine && m.author !== state.me) s.append(button("+1", () => send("reaction", m.link, "+1")));
if (m.author === state.me) {
s.append(button("Edit", async () => { editing = m.link; replyTo = null; $("text").value = m.text || ""; showTarget("Editing your message"); }));
s.append(button("Retract", () => send("retraction", m.link, "")));
}
return s;
}
function showTarget(t) {
$("replying").hidden = !t;
$("replying").textContent = t || "";
$("cancel").hidden = !t;
$("text").focus();
}
function fail(e) {
state.error = String(e && e.message || e);
log(`error: ${state.error}`);
status(`Something went wrong: ${state.error}`);
}
// --------------------------------------------------------------------------- joining
async function join() {
if (state.phase !== "idle") return state.phase;
state.phase = "joining";
const m = await import("../pkg/joule_moq_web_e2ee.js");
await m.default();
moq = m;
await initWai();
seed = crypto.getRandomValues(new Uint8Array(32));
state.me = hex(moq.publicKeyOf(seed));
status("Asking to join…");
const r = await request("POST", "/msg/join", {});
if (r.status !== 200) {
state.phase = "refused";
status(`The conversation did not admit this page: ${(r.body && r.body.error) || r.status}`);
return state.phase;
}
grant = r.body.grant;
conv = r.body.conversation;
state.room = conv.room;
// The room's confidentiality record, checked against its descriptor (confidentiality §3.3).
const d = JSON.parse(wai.roomDescriptor(unhex(conv.descriptor)));
const c = JSON.parse(moq.e2eeCheckRecord(unhex(conv.record), unhex(conv.room), unhex(d.authority), d.recording_mode));
if (!c.ok) throw new Error(`the conversation's confidentiality record is refused: ${c.reason}`);
messages = new wai.Messages(unhex(conv.descriptor), unhex(conv.record), unhex(conv.statement));
member = new moq.E2eeMember(unhex(conv.room), unhex(conv.clock), seed, c.suite);
member.anyMemberCommits();
const kp = await request("POST", "/msg/key-package", { key_package: hex(member.keyPackage()) });
if (kp.status !== 200) throw new Error(`the conversation kept no KeyPackage: ${kp.status}`);
state.phase = "member";
status("You are a member. Messages are sealed on this page before they are sent.");
$("compose").hidden = false;
$("join").hidden = true;
$("leave").hidden = false;
render();
follow().catch(fail);
return state.phase;
}
async function leave() {
if (state.phase !== "member") return;
state.phase = "left";
await request("POST", "/msg/leave", {});
status("You left the conversation.");
}
Object.assign(window.msgTest, {
join,
leave,
send: (text) => send("text", null, text),
reply: (target, text) => send("reply", target, text),
react: (target, r) => send("reaction", target, r),
edit: (target, text) => send("edit", target, text),
retract: (target) => send("retraction", target, ""),
remove: (principal) => request("POST", "/msg/remove", { principal }),
setOffline: (v) => { state.offline = !!v; },
memberState: () => (member ? JSON.parse(member.state()) : null),
view: () => (messages ? JSON.parse(messages.view()) : null),
});
if (service && state.phase === "idle") {
$("join").addEventListener("click", () => join().catch(fail));
$("leave").addEventListener("click", () => leave().catch(fail));
$("cancel").addEventListener("click", () => { replyTo = null; editing = null; showTarget(null); });
$("compose").addEventListener("submit", (e) => {
e.preventDefault();
const t = $("text").value.trim();
if (!t) return;
$("text").value = "";
const go = editing ? send("edit", editing, t) : replyTo ? send("reply", replyTo, t) : send("text", null, t);
replyTo = null;
editing = null;
showTarget(null);
go.catch(fail);
});
status("Join to read and write in this conversation.");
} else if (!service && state.phase === "idle") {
$("join").disabled = true;
status("No room service: open this page with ?service=<url> on its own origin.");
}