Humuhumu33 commited on
Commit
15cd079
·
verified ·
1 Parent(s): 3c7990f

msgr: v2 compact room invite (roomLink → binary wire)

Browse files
b/bafaeff8a7ce9b81d1ebbfbfee54c79968d23ceda35fa734e0ca6529525da76d ADDED
@@ -0,0 +1,857 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // holo-direct.mjs - the Holo Direct ENGINE: one clean API the messenger calls, composing the verified primitives -
2
+ // holo-seal (sealed+signed+κ envelope), holo-dm (blind mailbox / offline delivery), holo-verify (safety number + TOFU
3
+ // trust), the sovereign identity - and, when a spine is passed, the NATIVE P2P fast-path (holo-net WebRtcLink, dialed
4
+ // through holo-rendezvous: sealed SDP over the same blind mailbox, W2). send() is DUAL-PATH and the UI never knows:
5
+ // a live link carries the envelope in ~30 ms (path:"p2p"); otherwise the mailbox takes it exactly as before
6
+ // (path:"mailbox") and a BACKGROUND dial warms the link so the NEXT word is fast. Both carriers drain through ONE
7
+ // receive gate (open → verify → κ-dedup → "message") - one canonical message contract, two carriers (L2). Trust is
8
+ // unchanged: TOFU + key-change warnings fire before anything is sent or answered, on both paths. Without a spine the
9
+ // engine degrades to exactly the old mailbox-only behavior. (holo-ratchet is the stronger per-session mode - enabled
10
+ // once prekey publishing lands; per-message ECIES already gives forward secrecy for each message.)
11
+
12
+ import * as Seal from "./holo-seal.mjs?v=n8";
13
+ import * as DM from "./holo-dm.mjs?v=n8";
14
+ import * as Verify from "./holo-verify.mjs?v=n8";
15
+ import * as RDV from "./holo-rendezvous.mjs";
16
+ import { wireEncode, wireDecode } from "./holo-net-wire.mjs";
17
+ import * as Media from "./holo-direct-media.mjs";
18
+
19
+ // STUN opens most links (a STUN server sees addresses, never content); TURN relays ENCRYPTED bytes it
20
+ // cannot read, for symmetric/mobile NATs (home↔office↔cellular — the team case) where a direct link never
21
+ // forms — Law L1/L5 hold, a relay is a dumb pipe. Open Relay is a shared best-effort PUBLIC TURN
22
+ // (rate-limited); a durable owned TURN (Cloudflare Calls) is a later rung. Pass ice:null (or stun:null) to
23
+ // go host-only (LAN / same machine — what most witnesses want).
24
+ const DEFAULT_ICE = [
25
+ { urls: "stun:stun.l.google.com:19302" },
26
+ { urls: "turn:openrelay.metered.ca:80", username: "openrelayproject", credential: "openrelayproject" },
27
+ { urls: "turn:openrelay.metered.ca:443", username: "openrelayproject", credential: "openrelayproject" },
28
+ { urls: "turns:openrelay.metered.ca:443", username: "openrelayproject", credential: "openrelayproject" },
29
+ ];
30
+
31
+ export async function makeDirect({ identity = null, mailboxBase = null, trustStore = null, load = null, save = null,
32
+ spine = null, stun = null, ice = DEFAULT_ICE, store = null, displayName = null, olm = null,
33
+ presenceBeatMs = 60000, presenceTtlMs = 180000 } = {}) {
34
+ const id = identity || await Seal.generateIdentity();
35
+ const myPub = await Seal.exportPublic(id);
36
+ // ── THE SEAL WAIST (R1-R3) — one interface so the door never knows which cipher sealed a message. Default
37
+ // impl is holo-seal (X25519 box + Ed25519 sign). When an Olm ratchet (`olm` = a seal2 instance) is passed
38
+ // AND we hold the peer's prekey bundle, outbound seals with vodozemac (envelope tag s:"olm") for forward
39
+ // secrecy + PCS; inbound routes by the `s` tag. Sessions bootstrap over holo-seal `voz-bundle` control
40
+ // frames (R2), so holo-seal is BOTH the fallback and the handshake carrier — never removed. An untagged
41
+ // envelope is holo-seal (backward-compatible with every already-shipped peer).
42
+ let olmId = null;
43
+ if (olm) { try { await olm.init(); olmId = olm.identityKey(); } catch (e) { olm = null; } }
44
+ const vozBook = new Map(); // cid → { bundle, oid } — the peer's Olm prekey bundle + identity key (persisted)
45
+ const _vozSent = new Set(); // contacts we've already handed our bundle to (send it once)
46
+ const _vozPending = new Map(); // signKey → {bundle,oid} — a bundle that arrived BEFORE we knew the contact (race)
47
+ const _stats = { olmSealed: 0, seal1Sealed: 0, olmOpened: 0, megolmSealed: 0, megolmOpened: 0 }; // honest instrumentation (witness + a truthful lock)
48
+ // ── ROOMS (M4) — a room is a κ-object: { id, name, creator, members }. Megolm seals room words (per-sender
49
+ // forward secrecy); each member's group session KEY rides the EXISTING pairwise Olm channels (room-key
50
+ // frames), never a server. Membership change ROTATES every member's session (PCS) so a removed member's
51
+ // old inbound views go dead — kick is cryptography, not a flag. All room frames are sealed+signed pairwise.
52
+ const rooms = new Map(); // roomId → { id, name, creator, members: Map<sign, {sign, box, name, admin}>, strand: [] }
53
+ const _roomWord = new Set(); const _roomWordQ = []; // room-msg dedup (mid)
54
+ const _roomDedup = (mid) => { if (_roomWord.has(mid)) return true; _roomWord.add(mid); _roomWordQ.push(mid); if (_roomWordQ.length > 1024) _roomWord.delete(_roomWordQ.shift()); return false; };
55
+ const sealer = {
56
+ get kind() { return olm ? "olm" : "seal1"; },
57
+ seal: (cid, plaintext, pub) => _sealImpl(cid, plaintext, pub),
58
+ open: (env) => _openImpl(env),
59
+ toWire: (env) => Seal.toWire(env),
60
+ fromWire: (s) => Seal.fromWire(s),
61
+ };
62
+ async function _sha256hex(s) { const h = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s)); return [...new Uint8Array(h)].map((x) => x.toString(16).padStart(2, "0")).join(""); }
63
+ // seal TO a contact: ratchet if we hold their bundle (establish the outbound session on first use), else
64
+ // holo-seal — AND kick off the handshake so the NEXT word ratchets. A ratchet hiccup falls back, never drops.
65
+ async function _sealImpl(cid, plaintext, pub) {
66
+ if (olm && pub && pub.box) {
67
+ const peer = vozBook.get(cid);
68
+ if (peer && peer.bundle) {
69
+ try {
70
+ if (!(await olm.hasSession(cid))) {
71
+ // DETERMINISTIC initiator — avoids Olm's two-way establishment race (both sides createOutbound →
72
+ // mismatched sessions that can't decrypt each other). The smaller sign key initiates; the other
73
+ // WAITS for its pre-key message (→ inbound session), sealing holo-seal until then. One shared session.
74
+ if (myPub.sign < pub.sign) await olm.startOutbound(cid, peer.bundle);
75
+ else throw new Error("await-inbound");
76
+ }
77
+ const m = await olm.sealTo(cid, plaintext); // {t,c}; the pickle re-persists (HARD-1)
78
+ const env = { s: "olm", t: m.t, c: m.c, from: myPub.sign, oid: olmId, ts: Date.now() };
79
+ env.kappa = await _sha256hex(JSON.stringify([env.s, env.t, env.c, env.from, env.oid, env.ts]));
80
+ _stats.olmSealed++;
81
+ return env;
82
+ } catch (e) { /* ratchet hiccup → holo-seal keeps the conversation alive (debt=HONESTY) */ }
83
+ } else { _ensureVoz(cid); } // no bundle yet → send ours
84
+ }
85
+ return Seal.seal(plaintext, { toBoxPub: pub.box, fromKeys: id, fromPub: myPub });
86
+ }
87
+ async function _openImpl(env) {
88
+ if (env && env.s === "olm") {
89
+ if (!olm) return { ok: false };
90
+ // one human can sit in the book under SEVERAL cids (a room-transport stub "direct:<hex>" AND a
91
+ // link-opened name) while the Olm session pickle lives under only ONE of them — _findBySign's first
92
+ // match routed to the wrong alias and the frame died in silence. Try EVERY alias for this sign; a
93
+ // wrong-session decrypt just throws and the loop moves on (fail-soft, exactly one can succeed).
94
+ for (const [cid, p] of book) {
95
+ if (p.sign !== env.from) continue;
96
+ try { const pt = await olm.receive(cid, env.oid, { t: env.t, c: env.c }); _stats.olmOpened++; return { ok: true, from: env.from, verified: true, plaintext: pt }; }
97
+ catch {}
98
+ }
99
+ return { ok: false };
100
+ }
101
+ return Seal.open(env, { myKeys: id });
102
+ }
103
+ // hand our Olm prekey bundle to a contact ONCE (a holo-seal-sealed, signed control frame → the bundle is
104
+ // authenticated + bound to our sign key at bootstrap, TOFU). Reciprocated on receipt. No prekey server.
105
+ async function _ensureVoz(cid) {
106
+ if (!olm || _vozSent.has(cid)) return;
107
+ const pub = book.get(cid); if (!pub || !pub.box) return;
108
+ _vozSent.add(cid);
109
+ try { const bundle = await olm.publishBundle(); await _sendControl(cid, { t: "voz-bundle", bundle, oid: olmId }); }
110
+ catch { _vozSent.delete(cid); }
111
+ }
112
+ // a bundle can arrive BEFORE its sender's first message creates the contact (the inviter knows the invitee
113
+ // from the link, so their bundle races their words). We stash such a bundle by sign key; when the contact
114
+ // forms (any inbound message), we bind it → the ratchet becomes symmetric. This fixed a one-way ratchet.
115
+ function _drainVoz(cid, fromSign) {
116
+ const p = _vozPending.get(fromSign); if (!p) return;
117
+ _vozPending.delete(fromSign);
118
+ vozBook.set(cid, p);
119
+ if (store && store.setMeta) store.setMeta("voz:peer:" + cid, JSON.stringify(p)).catch(() => {});
120
+ _ensureVoz(cid); // reciprocate now that they're answerable
121
+ }
122
+ // each sovereign identity gets its OWN trust store - never a shared global key (else two identities, or a stale entry
123
+ // from a prior session, collide and look like a key change). Namespace the persistence by my own identity.
124
+ const nsKey = "holo.direct.trust." + myPub.sign.slice(0, 22);
125
+ const trust = trustStore || Verify.makeTrustStore({
126
+ load: load || (() => { try { return (typeof localStorage !== "undefined") ? JSON.parse(localStorage.getItem(nsKey) || "{}") : {}; } catch { return {}; } }),
127
+ save: save || ((c) => { try { if (typeof localStorage !== "undefined") localStorage.setItem(nsKey, JSON.stringify(c)); } catch {} }),
128
+ });
129
+ const book = new Map(); // contactId → pub {sign,box}
130
+ const listeners = { message: [], keychange: [], tick: [], typing: [], media: [], room: [], roomevent: [], presence: [] };
131
+ const emit = (ev, x) => (listeners[ev] || []).forEach((f) => { try { f(x); } catch {} });
132
+
133
+ // the durable store (holo-direct-store, optional): contacts + messages survive reload, sealed at rest.
134
+ // Hydrate the address book NOW — identity persistence without contact persistence is a door with no
135
+ // address book. Without a store the engine degrades to exactly the old in-memory behavior.
136
+ if (store) { try { for (const c of await store.contacts()) book.set(c.contactId, c.pub); } catch {} }
137
+ // R2/R4 — rehydrate each contact's Olm prekey bundle so a RETURNING user keeps ratcheting with no fresh
138
+ // handshake (the seal2 account + sessions rehydrate from the vault inside `olm`; this restores the peer half).
139
+ if (olm && store && store.getMeta) { for (const cid of [...book.keys()]) { try { const v = await store.getMeta("voz:peer:" + cid); if (v) vozBook.set(cid, JSON.parse(v)); } catch {} } }
140
+ // R4/T4 — rehydrate ROOMS a returning member belongs to (roster + strand). The Megolm sessions themselves
141
+ // reload LAZILY from the vault inside `olm` (voz:group:out|in pickles) on first send/open — no re-handshake.
142
+ if (olm && store && store.getMeta) { try {
143
+ const idx = JSON.parse((await store.getMeta("rooms:index")) || "[]");
144
+ for (const rid of idx) { try { const snap = JSON.parse(await store.getMeta("room:" + rid)); if (snap && snap.id) {
145
+ const mm = new Map(); for (const m of (snap.members || [])) mm.set(m.sign, m);
146
+ rooms.set(snap.id, { id: snap.id, name: snap.name, creator: snap.creator, members: mm, strand: snap.strand || [] });
147
+ } } catch {} }
148
+ } catch {} }
149
+
150
+ // ── ONE wire contract (C3): user text travels as {t:"msg",text}; control frames are {t:"ack"|"typing"}.
151
+ // Legacy inbound bare text is accepted as a message (older peers); outbound always wraps.
152
+ // N8: every outbound user message also carries MY box key (+ display name if set) INSIDE the sealed
153
+ // payload — so the one-link door is TWO-WAY from the first word: the inviter's inbound-only stub
154
+ // upgrades to a full, answerable contact (TOFU, tied to the signature that sealed it). WhatsApp feel.
155
+ const _intro = () => ({ fromBox: myPub.box, ...(displayName ? { fromName: String(displayName).slice(0, 48) } : {}) });
156
+ const _wrap = (text, auto) => JSON.stringify({ t: "msg", text, ...(auto ? { auto: true } : {}), ..._intro() });
157
+ const _parse = (plaintext) => {
158
+ try { const p = JSON.parse(plaintext); if (p && p.t) return p; } catch {}
159
+ return { t: "msg", text: plaintext }; // legacy bare string
160
+ };
161
+
162
+ // exactly-once across carriers: the p2p frame and the mailbox blob can RACE (a send chose the mailbox while a dial
163
+ // completed). κ is already computed over the canonical envelope - dedup is a Set lookup, marked only AFTER a
164
+ // successful open (a forged κ with different bytes fails open and cannot block the real message).
165
+ const seen = new Set(); const seenQ = [];
166
+ const _dedup = (kappa) => { if (seen.has(kappa)) return true; seen.add(kappa); seenQ.push(kappa); if (seenQ.length > 512) seen.delete(seenQ.shift()); return false; };
167
+
168
+ // add / update a contact (pub from the sovereign graph). Detects a KEY CHANGE (anti-MITM) before trusting it.
169
+ function addContact(contactId, pub, { acceptChange = false } = {}) {
170
+ const st = trust.check(contactId, pub);
171
+ if (st.status === "changed" && !acceptChange) { emit("keychange", { contactId, pub, wasVerified: st.wasVerified }); book.set(contactId, pub); return { ...st, blocked: true }; }
172
+ trust.record(contactId, pub); book.set(contactId, pub);
173
+ if (store) store.putContact(contactId, { pub, addedTs: Date.now() }).catch(() => {}); // the address book survives
174
+ return trust.check(contactId, pub);
175
+ }
176
+ function _findBySign(signPub) { for (const [cid, p] of book) if (p.sign === signPub) return cid; return null; }
177
+
178
+ // the display name a first-word sender carried (inside the sealed, signed payload). NEVER merge threads:
179
+ // if the name is already a contact under a DIFFERENT key, refuse it and fall back to the key-derived id.
180
+ function _nameFor(payload, fromSign) {
181
+ if (!payload || typeof payload.fromName !== "string") return null;
182
+ const nm = payload.fromName.replace(/\s+/g, " ").trim().slice(0, 48);
183
+ if (!nm) return null;
184
+ const cur = book.get(nm);
185
+ if (cur && cur.sign !== fromSign) return null;
186
+ return nm;
187
+ }
188
+ // one-link two-way (N8): a payload that carries the sender's box key upgrades their inbound-only stub
189
+ // to a full, answerable contact — TOFU, bound to the signature that sealed the very message.
190
+ function _upgradeFrom(contactId, fromSign, payload) {
191
+ if (!payload || !payload.fromBox) return;
192
+ const cur = book.get(contactId);
193
+ if (cur && cur.box) return; // full contact already — a payload never overwrites keys
194
+ addContact(contactId, { sign: fromSign, box: payload.fromBox });
195
+ }
196
+
197
+ // a tiny sealed control frame back to a KNOWN contact — over the open link when up, the mailbox when not.
198
+ // (An unknown sender can't be acked: we know their sign key from the envelope, not their box key.)
199
+ // linkOnly: for ephemeral chatter (typing, presence keepalives) that is never worth a drop-box round-trip.
200
+ async function _sendControl(contactId, frame, { linkOnly = false } = {}) {
201
+ const pub = book.get(contactId); if (!pub) return false;
202
+ const env = await sealer.seal(contactId, JSON.stringify(frame), pub);
203
+ const link = links.get(contactId);
204
+ if (link && link.open) { try { link.send(wireEncode(env)); return true; } catch { links.delete(contactId); } }
205
+ if (linkOnly || frame.t === "typing") return false; // ephemeral hints never take the mailbox
206
+ await DM.mailboxDrop(pub.box, sealer.toWire(env), { mailboxBase }).catch(() => {});
207
+ return true;
208
+ }
209
+
210
+ // ── PRESENCE (AIM, A1/A3) — buddy state is PEER GOSSIP with a TTL, never a server's opinion. A state
211
+ // CHANGE fans dual-path (the mailbox reaches offline buddies); the keepalive rides warm links ONLY
212
+ // (the typing rule — ephemeral chatter never spams the drop-box). Receivers hold it in memory and let
213
+ // it LAPSE to offline: presence is only what a peer actually announced, expiry renders sign-off, and
214
+ // "online" is never invented. Away survives a reload (vault meta); one auto-reply per buddy per
215
+ // away-session rides the normal msg path, flagged so an auto-reply is never itself answered.
216
+ const PRESENCE_STATES = ["online", "idle", "away"];
217
+ // keyed by SIGN key, not contactId: one human may sit in the book under SEVERAL ids (a room-transport
218
+ // stub "direct:<hex>" AND a link-opened name) — presence belongs to the HUMAN, so any of their ids
219
+ // resolves to the same state. Each entry remembers the best-label cid for the event payload.
220
+ const _peerPresence = new Map(); // sign → {cid,state,msg,profile,ts,expires}
221
+ let _myPresence = { state: "online", msg: null, profile: null };
222
+ const _autoReplied = new Set(); // sign keys answered this away-session (id-aliasing can't double-reply)
223
+ if (store && store.getMeta) { try { const v = JSON.parse((await store.getMeta("presence:self")) || "null"); if (v && v.state === "away") _myPresence = { state: "away", msg: v.msg || null, profile: v.profile || null }; } catch {} }
224
+ const _presenceFrame = () => ({ t: "presence", state: _myPresence.state, ...(_myPresence.msg ? { msg: _myPresence.msg } : {}), ...(_myPresence.profile ? { profile: _myPresence.profile } : {}), ts: Date.now() });
225
+ async function _fanPresence({ beat = false } = {}) {
226
+ const f = _presenceFrame(); const fanned = new Set();
227
+ for (const [cid, pub] of book) { // one beacon per HUMAN — a two-alias contact is not two buddies
228
+ if (!pub || !pub.box || fanned.has(pub.sign)) continue;
229
+ fanned.add(pub.sign);
230
+ await _sendControl(cid, f, { linkOnly: beat }).catch(() => {});
231
+ }
232
+ }
233
+ async function setPresence({ state = "online", msg = null, profile = null } = {}) {
234
+ if (!PRESENCE_STATES.includes(state)) return { ok: false, error: "state must be online|idle|away" };
235
+ const was = _myPresence.state;
236
+ _myPresence = { state, msg: msg ? String(msg).replace(/\s+/g, " ").trim().slice(0, 240) || null : null,
237
+ profile: profile ? String(profile).trim().slice(0, 400) || null : null };
238
+ if (was !== state) _autoReplied.clear(); // a NEW away-session answers each buddy once again
239
+ if (store && store.setMeta) store.setMeta("presence:self", JSON.stringify(_myPresence)).catch(() => {});
240
+ await _fanPresence(); // state change → dual-path
241
+ return { ok: true, ..._myPresence };
242
+ }
243
+ // ── STATUS (WhatsApp Status): one frame fanned to every contact — dual-path like a presence CHANGE (the
244
+ // mailbox reaches offline contacts). One frame per HUMAN (sign-dedup); never to self. holo-status.mjs owns
245
+ // the shelf, TTL and receipts; the engine only carries. storyFan carries revokes over the same rail.
246
+ async function _fanStoryFrame(f) {
247
+ const fanned = new Set(); let sent = 0;
248
+ for (const [cid, pub] of book) {
249
+ if (!pub || !pub.box || fanned.has(pub.sign) || (myPub && pub.sign === myPub.sign)) continue;
250
+ fanned.add(pub.sign);
251
+ try { if (await _sendControl(cid, f)) sent++; } catch {}
252
+ }
253
+ return { ok: true, sent, contacts: fanned.size };
254
+ }
255
+ const postStory = (story) => _fanStoryFrame({ t: "story", story, ts: Date.now() });
256
+ const storyFan = (frame) => _fanStoryFrame(frame);
257
+ const storyCtl = (cidOrSign, frame) => { const cid = book.has(cidOrSign) ? cidOrSign : _findBySign(cidOrSign); return cid ? _sendControl(cid, frame) : Promise.resolve(false); };
258
+ const _beatT = setInterval(() => { _fanPresence({ beat: true }).catch(() => {}); }, Math.max(1000, presenceBeatMs));
259
+ const _sweepT = setInterval(() => {
260
+ const now = Date.now();
261
+ for (const [sign, p] of _peerPresence) if (p.expires <= now) { _peerPresence.delete(sign); emit("presence", { contactId: p.cid, sign, state: "offline", msg: null, profile: null, ts: now }); }
262
+ }, Math.max(200, Math.floor(presenceTtlMs / 3)));
263
+ // any of a human's contact ids (or their raw sign) → their one presence record
264
+ const _presenceFor = (cidOrSign) => {
265
+ const pub = book.get(cidOrSign);
266
+ const p = _peerPresence.get(pub ? pub.sign : cidOrSign);
267
+ return p && p.expires > Date.now() ? p : null;
268
+ };
269
+
270
+ // ---- the ONE receive gate: both carriers (p2p frame, mailbox blob) end here ----
271
+ async function _deliver(env, ts) {
272
+ if (!env || !env.kappa) return null;
273
+ const r = await sealer.open(env); if (!r.ok) return null;
274
+ const contactId = _findBySign(r.from);
275
+ const known = !!contactId;
276
+ const verified = !!r.verified && known; // signature valid AND from a contact we know
277
+ const payload = _parse(r.plaintext);
278
+
279
+ // R2 — the SEALED HANDSHAKE: a verified contact handed us their Olm prekey bundle (over holo-seal, so it
280
+ // is authenticated + bound to their sign key, TOFU). Record + persist it, then reciprocate ours. Consumed,
281
+ // never shown. From here the next word to/from this contact rides the ratchet.
282
+ if (payload.t === "voz-bundle") {
283
+ if (olm && payload.bundle && r.verified) { // sig-valid bundle (known OR not-yet-known sender)
284
+ const peer = { bundle: payload.bundle, oid: payload.oid };
285
+ if (contactId) {
286
+ vozBook.set(contactId, peer);
287
+ if (store && store.setMeta) store.setMeta("voz:peer:" + contactId, JSON.stringify(peer)).catch(() => {});
288
+ _ensureVoz(contactId); // reciprocate (once) so BOTH sides can ratchet
289
+ } else { _vozPending.set(r.from, peer); } // arrived before we knew them → bind when the contact forms
290
+ }
291
+ return null;
292
+ }
293
+
294
+ // control frames: consumed, never persisted, never emitted as messages. Idempotent by nature (a
295
+ // redelivered ack re-marks the same κ), so they skip the dedup index entirely.
296
+ if (payload.t === "ack") {
297
+ if (!verified) return null; // an ack must be from the known, verified recipient
298
+ if (store) store.markDelivered(payload.kappa).catch(() => {});
299
+ emit("tick", { contactId, kappa: payload.kappa, status: "delivered" });
300
+ return null;
301
+ }
302
+ if (payload.t === "typing") { if (verified) emit("typing", { contactId }); return null; }
303
+
304
+ // presence beacon: CONTACTS ONLY (verified = sig-valid AND known) — a stranger's beacon is refused at
305
+ // the door. Emit only on a real transition so a keepalive can never re-ring the doorbell (door sounds,
306
+ // A2); every beacon refreshes the TTL, and lapse — not a frame — renders offline.
307
+ if (payload.t === "presence") {
308
+ if (!verified) return null;
309
+ if (!PRESENCE_STATES.includes(payload.state)) return null;
310
+ const prev = _peerPresence.get(r.from);
311
+ const p = { cid: contactId, state: payload.state,
312
+ msg: typeof payload.msg === "string" ? payload.msg.slice(0, 240) : null,
313
+ profile: typeof payload.profile === "string" ? payload.profile.slice(0, 400) : null,
314
+ ts: payload.ts || Date.now(), expires: Date.now() + presenceTtlMs };
315
+ _peerPresence.set(r.from, p);
316
+ if (!prev || prev.state !== p.state || prev.msg !== p.msg) emit("presence", { contactId, sign: r.from, state: p.state, msg: p.msg, profile: p.profile, ts: p.ts });
317
+ return null;
318
+ }
319
+
320
+ // ── STATUS frames (WhatsApp Status over the SAME sealed door): a story is ephemeral social broadcast —
321
+ // consumed here, never a chat bubble. CONTACTS ONLY at the door (the presence rule). The engine only
322
+ // CARRIES: storage, the 24h TTL, seen-state, receipts and rendering all live in holo-status.mjs.
323
+ if (payload.t === "story" || payload.t === "story-ack" || payload.t === "story-revoke") {
324
+ if (!verified) return null;
325
+ emit("story", { contactId, sign: r.from, frame: payload, ts: payload.ts || Date.now() });
326
+ return null;
327
+ }
328
+
329
+ // ── KEY frames (Holo Keys): a live power, redeemed over this same sealed door. The engine only CARRIES —
330
+ // authority is issuer-local (holo-keys checks MY keyring; unknown/revoked/expired refuse at the door). A
331
+ // key-invoke may arrive from a first-contact holder (the grant introduced us, like a Direct link): the
332
+ // frame's _intro() box key upgrades them to answerable, so the key-result can travel back. Never persisted.
333
+ if (payload.t === "key-invoke" || payload.t === "key-result" || payload.t === "key-revoked") {
334
+ const cid = contactId || (_nameFor(payload, r.from) || "direct:" + (r.from || "").slice(0, 12));
335
+ _upgradeFrom(cid, r.from, payload);
336
+ try {
337
+ const Keys = await import("./holo-key.mjs?v=k1");
338
+ await Keys.handleFrame(payload, { from: r.from, cid, reply: (f) => _sendControl(cid, f) });
339
+ emit("key", { contactId: cid, from: r.from, frame: payload.t, grantId: payload.grantId || null });
340
+ } catch (e) { console.warn("[direct] key frame failed:", String(e)); }
341
+ return null;
342
+ }
343
+
344
+ // ── ROOM frames (M4): all authenticated (sealed+signed pairwise). r.from is the actor's sign key.
345
+ if (payload.t && payload.t.startsWith("room-")) { await _deliverRoom(payload, r, contactId); return null; }
346
+
347
+ // media (N7): the message is a sealed DESCRIPTOR {κ_ct, key, iv, name, mime, size}; the bytes live on
348
+ // the content network as ciphertext and fetch when a holder is up. Persisted like text; the fetch is
349
+ // background and honest — "pending-bytes" until the κ-fetch lands (MD2).
350
+ if (payload.t === "media") {
351
+ if (!payload.kappa || !payload.key || !payload.iv) return null;
352
+ if (_dedup(env.kappa)) return null;
353
+ if (store && await store.hasMsg(env.kappa).catch(() => false)) return null;
354
+ const cid = contactId || (_nameFor(payload, r.from) || "direct:" + (r.from || "").slice(0, 12));
355
+ _upgradeFrom(cid, r.from, payload);
356
+ _drainVoz(cid, r.from); // apply any bundle that beat this contact into being
357
+ const desc = { kappa: payload.kappa, kappas: payload.kappas || [payload.kappa], key: payload.key, iv: payload.iv,
358
+ name: payload.name || "file", mime: payload.mime || "application/octet-stream", size: payload.size };
359
+ if (store) {
360
+ store.putMsg({ kappa: env.kappa, contactId: cid, ts: ts || env.ts, dir: "in", text: "📎 " + desc.name, media: desc, status: "pending-bytes" }).catch(() => {});
361
+ if (!known && !payload.fromBox) store.putContact(cid, { pub: { sign: r.from, box: null }, addedTs: Date.now() }).catch(() => {});
362
+ }
363
+ const evt = { contactId: cid, from: r.from, verified, known, ts: ts || env.ts, kappa: env.kappa, media: desc, status: "pending-bytes" };
364
+ emit("media", evt);
365
+ const mAckTo = book.get(cid);
366
+ if (mAckTo && mAckTo.box) _sendControl(cid, { t: "ack", kappa: env.kappa });
367
+ _fetchMedia(cid, env.kappa, desc); // background — resolves now if a holder is reachable
368
+ return evt;
369
+ }
370
+ if (payload.t !== "msg" || typeof payload.text !== "string") return null;
371
+
372
+ // exactly-once, DURABLY: the in-memory LRU is the fast front; the store is the truth across restarts.
373
+ if (_dedup(env.kappa)) return null;
374
+ if (store && await store.hasMsg(env.kappa).catch(() => false)) return null;
375
+
376
+ const cid = contactId || (_nameFor(payload, r.from) || "direct:" + (r.from || "").slice(0, 12));
377
+ _upgradeFrom(cid, r.from, payload); // one-link two-way: stub → answerable contact
378
+ _drainVoz(cid, r.from); // apply any Olm bundle that raced ahead of this first word
379
+ if (store) {
380
+ store.putMsg({ kappa: env.kappa, contactId: cid, ts: ts || env.ts, dir: "in", text: payload.text }).catch(() => {});
381
+ if (!known && !payload.fromBox) store.putContact(cid, { pub: { sign: r.from, box: null }, addedTs: Date.now() }).catch(() => {}); // stub: listed, unanswerable until they share their link
382
+ }
383
+ const msg = { contactId: cid, from: r.from, text: payload.text, auto: !!payload.auto, verified, known, ts: ts || env.ts, kappa: env.kappa };
384
+ emit("message", msg);
385
+ // ✓✓ on their side — fire and forget. The upgrade above may have JUST made a first-word stranger
386
+ // answerable (their box key rode the sealed payload) — ack whoever we can now reach, not only
387
+ // contacts we knew before this message.
388
+ const ackTo = book.get(cid);
389
+ if (ackTo && ackTo.box) _sendControl(cid, { t: "ack", kappa: env.kappa });
390
+ // AIM away (A3): answer each buddy ONCE per away-session. An auto-reply is flagged in the sealed
391
+ // payload and is NEVER itself answered — the classic two-away-buddies loop is impossible by contract.
392
+ if (_myPresence.state === "away" && !payload.auto && !_autoReplied.has(r.from) && ackTo && ackTo.box) {
393
+ _autoReplied.add(r.from); // by SIGN — a human under two ids is answered once
394
+ send(cid, "Auto response from " + (displayName || "me") + ": " + (_myPresence.msg || "I am away from my computer right now."), { auto: true }).catch(() => {});
395
+ }
396
+ return msg;
397
+ }
398
+
399
+ // ---- media bytes (N7): fetch/decrypt/persist in the background; pending fetches retry on link-attach ----
400
+ const pendingMedia = new Map(); // msgKappa → {cid, desc} — bytes not yet fetched (holder was offline)
401
+ const mediaInFlight = new Set(); // a κ-fetch already running keeps polling until its window closes —
402
+ // a link-attach retry must not stack a second one on the same message
403
+ async function _fetchMedia(cid, msgKappa, desc) {
404
+ if (!spine) { pendingMedia.set(msgKappa, { cid, desc }); return; }
405
+ if (mediaInFlight.has(msgKappa)) return;
406
+ mediaInFlight.add(msgKappa);
407
+ try {
408
+ const held = store ? await store.getMedia(desc.kappa).catch(() => null) : null;
409
+ const bytes = held ? held.bytes : await Media.fetchAndDecrypt(spine, desc);
410
+ if (!held && store) await store.putMedia(desc.kappa, { bytes, name: desc.name, mime: desc.mime }).catch(() => {});
411
+ pendingMedia.delete(msgKappa);
412
+ if (store) store.setMsgStatus(msgKappa, "fetched").catch(() => {});
413
+ emit("media", { contactId: cid, kappa: msgKappa, media: desc, bytes, status: "fetched" });
414
+ } catch {
415
+ const tries = ((pendingMedia.get(msgKappa) || {}).tries || 0) + 1;
416
+ pendingMedia.set(msgKappa, { cid, desc, tries }); // honest: bytes need a holder online — retry on attach
417
+ const l = links.get(cid); // …but if the link is UP and the fetch still failed
418
+ if (l && l.open && tries < 3) setTimeout(() => _fetchMedia(cid, msgKappa, desc), 400); // (holder mid-boot), retry bounded
419
+ } finally { mediaInFlight.delete(msgKappa); }
420
+ }
421
+ // a link just attached: retry this contact's pending fetches, re-arm any that survived a reload (the
422
+ // store remembers "pending-bytes"), and RE-OFFER our own sent ciphertext so the peer's fetch finds a
423
+ // holder even after we reloaded (the vault has the plaintext; key+iv re-derive the same κ).
424
+ async function _fetchPendingFor(contactId) {
425
+ for (const [mk, p] of pendingMedia) if (p.cid === contactId) _fetchMedia(p.cid, mk, p.desc);
426
+ if (!store || !spine) return;
427
+ for (const m of await store.msgs(contactId).catch(() => [])) {
428
+ if (!m.media) continue;
429
+ if (m.dir === "in" && m.status === "pending-bytes" && !pendingMedia.has(m.kappa)) _fetchMedia(contactId, m.kappa, m.media);
430
+ if (m.dir === "out") {
431
+ const held = await store.getMedia(m.media.kappa).catch(() => null);
432
+ if (held) Media.reoffer(spine, held.bytes, m.media);
433
+ }
434
+ }
435
+ }
436
+
437
+ // ---- the native fast-path: one link per contact, opened lazily and kept in a registry ----
438
+ const links = new Map(); // contactId → live link (holo-net dial result)
439
+ const dialing = new Set(); // contactIds with a dial in flight (never stack dials)
440
+ function _attach(contactId, link) {
441
+ links.set(contactId, link);
442
+ link.onFrame((bytes) => { const env = wireDecode(bytes); if (env) _deliver(env, null); });
443
+ _fetchPendingFor(contactId).catch(() => {}); // the reunion: pending bytes fetch NOW (MD2)
444
+ }
445
+ // warm(contactId): call when a chat OPENS - the cold rendezvous happens while the human is still reading, so the
446
+ // first keystroke finds a live link. Failure is silent by design: the mailbox path is always there.
447
+ async function warm(contactId) {
448
+ if (!spine) return false;
449
+ const pub = book.get(contactId); if (!pub) return false;
450
+ const cur = links.get(contactId); if (cur && cur.open) return true;
451
+ if (dialing.has(contactId)) return false;
452
+ if (trust.check(contactId, pub).status === "changed") return false; // key change never auto-dials
453
+ dialing.add(contactId);
454
+ try { _attach(contactId, await RDV.rendezvousDial(pub, { identity: id, myPub, mailboxBase, spine, stun, ice, trust })); return true; }
455
+ catch { return false; }
456
+ finally { dialing.delete(contactId); }
457
+ }
458
+ // answer incoming dials from KNOWN contacts whose key hasn't changed - same policy as addContact, no new trust logic
459
+ let stopListen = null;
460
+ if (spine) {
461
+ stopListen = RDV.onRendezvous({ identity: id, myPub, mailboxBase, spine, stun, ice, trust },
462
+ (peerPub) => { const cid = _findBySign(peerPub.sign); return !!cid && trust.check(cid, { sign: peerPub.sign, box: peerPub.box }).status !== "changed"; },
463
+ (link, peerPub) => { const cid = _findBySign(peerPub.sign); if (cid) _attach(cid, link); });
464
+ }
465
+
466
+ // ---- send: seal once, then the fastest honest carrier ----
467
+ async function send(contactId, text, { auto = false } = {}) {
468
+ const pub = book.get(contactId); if (!pub) return { ok: false, error: "unknown contact" };
469
+ if (!pub.box) return { ok: false, error: "no box key — they must share their link first" }; // inbound-only stub
470
+ if (trust.check(contactId, pub).status === "changed") { emit("keychange", { contactId, pub }); return { ok: false, error: "key-changed", keychange: true }; }
471
+ const env = await sealer.seal(contactId, _wrap(text, auto), pub);
472
+ // persist BEFORE transport (✓ = it exists durably and was handed to a carrier; ✓✓ = the ack came back)
473
+ if (store) await store.putMsg({ kappa: env.kappa, contactId, ts: env.ts, dir: "out", text, status: "sent" }).catch(() => {});
474
+ const link = links.get(contactId);
475
+ if (link && link.open) {
476
+ try { link.send(wireEncode(env)); return { ok: true, kappa: env.kappa, ts: env.ts, contactId, path: "p2p" }; }
477
+ catch { links.delete(contactId); } // dead link - fall through to the mailbox, re-warm below
478
+ }
479
+ await DM.mailboxDrop(pub.box, sealer.toWire(env), { mailboxBase }); // offline-safe, exactly as always
480
+ if (spine) warm(contactId); // background - the NEXT word takes the fast path
481
+ return { ok: true, kappa: env.kappa, ts: env.ts, contactId, path: "mailbox" };
482
+ }
483
+
484
+ // ---- sendMedia (N7): encrypt fresh → put ciphertext on the content network → the sealed message
485
+ // carries only the descriptor. Same dual path, same trust gates, same ✓/✓✓ as text. 25 MB cap, plainly.
486
+ async function sendMedia(contactId, file) {
487
+ const pub = book.get(contactId); if (!pub) return { ok: false, error: "unknown contact" };
488
+ if (!pub.box) return { ok: false, error: "no box key — they must share their link first" };
489
+ if (!spine) return { ok: false, error: "media needs the native spine" };
490
+ if (trust.check(contactId, pub).status === "changed") { emit("keychange", { contactId, pub }); return { ok: false, error: "key-changed", keychange: true }; }
491
+ const bytes = new Uint8Array(await file.arrayBuffer());
492
+ if (bytes.length > Media.MAX_MEDIA_BYTES) return { ok: false, error: Media.TOO_BIG };
493
+ const desc = { ...(await Media.encryptAndPut(spine, bytes)), name: file.name || "file", mime: file.type || "application/octet-stream" };
494
+ const env = await sealer.seal(contactId, JSON.stringify({ t: "media", ...desc, ..._intro() }), pub);
495
+ if (store) {
496
+ await store.putMsg({ kappa: env.kappa, contactId, ts: env.ts, dir: "out", text: "📎 " + desc.name, media: desc, status: "sent" }).catch(() => {});
497
+ await store.putMedia(desc.kappa, { bytes, name: desc.name, mime: desc.mime }).catch(() => {}); // we stay a holder across reloads
498
+ }
499
+ const link = links.get(contactId);
500
+ if (link && link.open) {
501
+ try { link.send(wireEncode(env)); return { ok: true, kappa: env.kappa, mediaKappa: desc.kappa, ts: env.ts, contactId, path: "p2p" }; }
502
+ catch { links.delete(contactId); }
503
+ }
504
+ await DM.mailboxDrop(pub.box, sealer.toWire(env), { mailboxBase }); // the MESSAGE is offline-safe; the bytes wait for a link
505
+ if (spine) warm(contactId);
506
+ return { ok: true, kappa: env.kappa, mediaKappa: desc.kappa, ts: env.ts, contactId, path: "mailbox" };
507
+ }
508
+
509
+ // a live typing hint — link-only (never the mailbox), throttled so a fast typist costs ~1 frame / 3 s
510
+ const _typedAt = new Map();
511
+ function sendTyping(contactId) {
512
+ const t = _typedAt.get(contactId) || 0;
513
+ if (Date.now() - t < 3000) return;
514
+ _typedAt.set(contactId, Date.now());
515
+ _sendControl(contactId, { t: "typing" });
516
+ }
517
+
518
+ // pull everything waiting in the mailbox, through the SAME gate as the p2p frames (open → verify → dedup → emit)
519
+ async function poll() {
520
+ const items = await DM.mailboxPull(myPub.box, { mailboxBase });
521
+ const out = [], acked = [];
522
+ for (const it of items) {
523
+ acked.push(it.id); // ack even the refused - a corrupt blob must not loop forever
524
+ const env = sealer.fromWire(it.blob); if (!env) continue;
525
+ const msg = await _deliver(env, it.ts);
526
+ if (msg) out.push(msg);
527
+ }
528
+ if (acked.length) await DM.mailboxAck(myPub.box, acked, { mailboxBase });
529
+ return out;
530
+ }
531
+
532
+ // ── ROOM ENGINE (M4) ────────────────────────────────────────────────────────────────────────────────
533
+ // Bind a room member as an answerable contact (their sign+box), keyed by their sign so fan-out can reach
534
+ // them. Never overwrites an existing full contact's keys (anti-MITM, same policy as _upgradeFrom).
535
+ function _bindMember(m) {
536
+ if (!m || !m.sign) return null;
537
+ let cid = _findBySign(m.sign);
538
+ if (!cid) { cid = "direct:" + m.sign.slice(0, 12); if (m.box) addContact(cid, { sign: m.sign, box: m.box }); }
539
+ else if (m.box && !(book.get(cid) || {}).box) addContact(cid, { sign: m.sign, box: m.box });
540
+ return cid;
541
+ }
542
+ const _roomMembersArr = (room) => [...room.members.values()];
543
+ const _me = () => ({ sign: myPub.sign, box: myPub.box, name: displayName || null });
544
+ // hand MY current group key for a room to one member (over the pairwise sealed channel).
545
+ async function _sendRoomKey(room, memberSign) {
546
+ if (!olm) return;
547
+ const gk = await olm.groupKey(room.id); if (!gk) return;
548
+ const cid = _findBySign(memberSign); if (!cid) return;
549
+ await _sendControl(cid, { t: "room-key", room: room.id, key: gk.key, sid: gk.id, from: myPub.sign });
550
+ }
551
+ async function _broadcastRoomKey(room, exclude = []) {
552
+ for (const m of _roomMembersArr(room)) if (m.sign !== myPub.sign && !exclude.includes(m.sign)) await _sendRoomKey(room, m.sign);
553
+ }
554
+ // over a LOSSY relay a single key frame can drop, silently missing a sender's whole stream. Re-broadcast a
555
+ // couple of times after a membership change so keys converge without waiting for a can't-decrypt trigger.
556
+ function _broadcastRoomKeySoon(room, exclude = []) {
557
+ for (const ms of [2500, 6000]) setTimeout(() => { const r = rooms.get(room.id); if (r) _broadcastRoomKey(r, exclude).catch(() => {}); }, ms);
558
+ }
559
+
560
+ // CREATE a room: I am the admin; mint my Megolm outbound now. Returns the room + its invite link.
561
+ async function createRoom(name) {
562
+ if (!olm) return { ok: false, error: "rooms need the ratchet (olm)" };
563
+ const nonce = (typeof crypto !== "undefined" && crypto.getRandomValues) ? [...crypto.getRandomValues(new Uint8Array(9))].map((b) => b.toString(16).padStart(2, "0")).join("") : String(Date.now());
564
+ const id = "room:" + (await _sha256hex(myPub.sign + "|" + name + "|" + nonce)).slice(0, 24);
565
+ const room = { id, name: String(name || "Room").slice(0, 64), creator: myPub.sign, members: new Map(), strand: [] };
566
+ room.members.set(myPub.sign, { ..._me(), admin: true });
567
+ room.strand.push({ op: "create", by: myPub.sign, ts: Date.now(), name: room.name });
568
+ rooms.set(id, room);
569
+ await olm.groupCreate(id);
570
+ _persistRoom(room);
571
+ return { ok: true, room: _roomView(room), link: roomLink(id) };
572
+ }
573
+ const _roomSnapshot = (room) => ({ id: room.id, name: room.name, creator: room.creator, members: _roomMembersArr(room), strand: room.strand });
574
+ // persist a room snapshot AND keep the rooms:index (the store has no key enumeration) so a returning member
575
+ // rehydrates every room on boot. Fire-and-forget; the Megolm pickles persist separately inside `olm`.
576
+ async function _persistRoom(room) {
577
+ if (!store || !store.setMeta) return;
578
+ try {
579
+ await store.setMeta("room:" + room.id, JSON.stringify(_roomSnapshot(room)));
580
+ const idx = JSON.parse((await store.getMeta("rooms:index")) || "[]");
581
+ if (!idx.includes(room.id)) { idx.push(room.id); await store.setMeta("rooms:index", JSON.stringify(idx)); }
582
+ } catch {}
583
+ }
584
+ const _roomView = (room) => ({ id: room.id, name: room.name, creator: room.creator, admin: (room.members.get(myPub.sign) || {}).admin === true, members: _roomMembersArr(room).map((m) => ({ sign: m.sign, name: m.name, admin: m.admin, me: m.sign === myPub.sign })) });
585
+
586
+ // the invite link: keys ride the FRAGMENT (never a request line). Carries the creator's box so a fresh
587
+ // joiner can seal the pairwise join frame to them (TOFU, bound to the creator sign).
588
+ // v2 COMPACT wire — b64url( room(12B) ∥ sign(65B raw P-256) ∥ box(65B raw P-256) ∥ name-utf8 ): the v1
589
+ // JSON-in-base64 link was ~500 chars; v2 is ~200 and QR-clean. Emitted only when every field matches the
590
+ // native shape (room:<24hex>, raw uncompressed points); anything else falls back to v1 — never a dead link.
591
+ const _b64uOfBytes = (u) => (typeof btoa !== "undefined")
592
+ ? btoa(Array.from(u, (b) => String.fromCharCode(b)).join("")).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
593
+ : Buffer.from(u).toString("base64url");
594
+ const _rawPoint = (b64) => { try { const s = (typeof atob !== "undefined") ? atob(b64) : Buffer.from(b64, "base64").toString("binary"); if (s.length !== 65 || s.charCodeAt(0) !== 4) return null; const u = new Uint8Array(65); for (let i = 0; i < 65; i++) u[i] = s.charCodeAt(i) & 255; return u; } catch { return null; } };
595
+ function _roomFragment(room) {
596
+ const hex = /^room:([0-9a-f]{24})$/.exec(room.id);
597
+ const sign = _rawPoint(room.creator), box = _rawPoint((room.members.get(room.creator) || {}).box || myPub.box);
598
+ if (hex && sign && box) {
599
+ const name = new TextEncoder().encode(String(room.name || "").slice(0, 64));
600
+ const u = new Uint8Array(142 + name.length);
601
+ for (let i = 0; i < 12; i++) u[i] = parseInt(hex[1].slice(i * 2, i * 2 + 2), 16);
602
+ u.set(sign, 12); u.set(box, 77); u.set(name, 142);
603
+ return "room=v2." + _b64uOfBytes(u);
604
+ }
605
+ const payload = { v: 1, room: room.id, name: room.name, creator: room.creator, creatorBox: (room.members.get(room.creator) || {}).box || myPub.box };
606
+ const b64 = (typeof btoa !== "undefined")
607
+ ? btoa(unescape(encodeURIComponent(JSON.stringify(payload)))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
608
+ : Buffer.from(JSON.stringify(payload)).toString("base64url");
609
+ return "room=v1." + b64;
610
+ }
611
+ function roomLink(id) {
612
+ const room = rooms.get(id); if (!room) return null;
613
+ const frag = _roomFragment(room);
614
+ // point at the /join/ card page (fragment-preserving forward) so a shared room link renders the invite
615
+ // card on social platforms; the #fragment (keys) still travels only to the human's browser. Derive the
616
+ // messenger dir from this page's path (…/apps/holo-messenger/app.html → …/apps/holo-messenger/join/).
617
+ if (typeof location === "undefined") return "#" + frag;
618
+ // anchor to THIS MODULE's real URL, not the page's — the live shell URL-normalizes the page path to the
619
+ // site root, which would point the link at a nonexistent root /join/ (caught by the ship-clone gate).
620
+ try { return new URL("join/", import.meta.url).href + "#" + frag; } catch {}
621
+ const dir = location.pathname.replace(/[^/]*$/, ""); // fallback: strip the filename → the app dir
622
+ return location.origin + dir + "join/#" + frag;
623
+ }
624
+
625
+ // JOIN from an invite payload {room,name,creator,creatorBox}: bind the creator as a contact, send them a
626
+ // signed join intent over the pairwise channel. The creator (admin) admits us → room-welcome carries the
627
+ // roster + keys. Fully serverless.
628
+ async function joinRoom(payload) {
629
+ if (!olm) return { ok: false, error: "rooms need the ratchet (olm)" };
630
+ if (!payload || !payload.room || !payload.creator) return { ok: false, error: "bad room invite" };
631
+ const cid = _bindMember({ sign: payload.creator, box: payload.creatorBox, name: null });
632
+ // provisional local room shell (upgraded on welcome)
633
+ if (!rooms.get(payload.room)) rooms.set(payload.room, { id: payload.room, name: payload.name || "Room", creator: payload.creator, members: new Map([[payload.creator, { sign: payload.creator, box: payload.creatorBox, name: null, admin: true }]]), strand: [] });
634
+ await _ensureVoz(cid); // make sure we can ratchet with the admin
635
+ await _sendControl(cid, { t: "room-join", room: payload.room, member: _me() });
636
+ return { ok: true, room: payload.room, pending: true };
637
+ }
638
+
639
+ // SEND a room word: Megolm-seal once, fan the SAME ciphertext to every member over their pairwise channel.
640
+ async function roomSend(roomId, text) {
641
+ const room = rooms.get(roomId); if (!room) return { ok: false, error: "unknown room" };
642
+ if (!olm) return { ok: false, error: "no ratchet" };
643
+ let sealed; try { sealed = await olm.groupSeal(roomId, JSON.stringify({ text, name: displayName || null })); } catch { return { ok: false, error: "no group session — (re)join first" }; }
644
+ const mid = (await _sha256hex(roomId + "|" + myPub.sign + "|" + sealed.c + "|" + Date.now())).slice(0, 24);
645
+ const ts = Date.now();
646
+ _stats.megolmSealed++;
647
+ // echo locally so the sender sees their own word (Megolm can't self-decrypt an outbound session)
648
+ const mine = { room: roomId, from: myPub.sign, name: displayName || null, text, ts, mid, me: true };
649
+ if (store) store.putMsg({ kappa: mid, contactId: roomId, ts, dir: "out", text, room: roomId, status: "sent", name: displayName || null }).catch(() => {});
650
+ emit("room", mine);
651
+ for (const m of _roomMembersArr(room)) {
652
+ if (m.sign === myPub.sign) continue;
653
+ const cid = _findBySign(m.sign); if (!cid) continue;
654
+ _sendControl(cid, { t: "room-msg", room: roomId, c: sealed.c, sid: sealed.id, mid, ts, from: myPub.sign });
655
+ }
656
+ return { ok: true, mid, ts };
657
+ }
658
+
659
+ // LIVE PRESENCE — "who's inside a holospace right now". Ephemeral (never persisted, receivers expire it
660
+ // locally): fan one sealed control frame to the roster. This is the room's real-time multiplayer plane —
661
+ // "join them" just opens the same κ/url, and the experience itself streams from the substrate.
662
+ async function roomLive(roomId, { url = null, title = null, on = true } = {}) {
663
+ const room = rooms.get(roomId); if (!room) return { ok: false, error: "unknown room" };
664
+ for (const m of _roomMembersArr(room)) {
665
+ if (m.sign === myPub.sign) continue;
666
+ const cid = _findBySign(m.sign); if (!cid) continue;
667
+ _sendControl(cid, { t: "room-live", room: roomId, url, title, on: !!on, name: displayName || null, ts: Date.now() });
668
+ }
669
+ return { ok: true };
670
+ }
671
+
672
+ // TOGETHER frames — the co-presence plane for a shared experience (cursors, playback sync). Same shape
673
+ // as room-live: ephemeral, sealed pairwise, membership-gated, never persisted. `url` scopes the session
674
+ // (a room can host several experiences at once); `data` is the copresence protocol payload, opaque here.
675
+ async function roomCo(roomId, url, data) {
676
+ const room = rooms.get(roomId); if (!room) return { ok: false, error: "unknown room" };
677
+ for (const m of _roomMembersArr(room)) {
678
+ if (m.sign === myPub.sign) continue;
679
+ const cid = _findBySign(m.sign); if (!cid) continue;
680
+ _sendControl(cid, { t: "room-co", room: roomId, url, data, name: displayName || null, ts: Date.now() });
681
+ }
682
+ return { ok: true };
683
+ }
684
+
685
+ // KICK (admin): remove the member, ROTATE my outbound (PCS) and re-key only the REMAINING members. Tell the
686
+ // remaining members to remove + rotate too, so EVERY surviving member's stream is fresh — the kicked key
687
+ // receives nothing after the cut and its old inbound views can't read the new session ids.
688
+ async function roomKick(roomId, memberSign) {
689
+ const room = rooms.get(roomId); if (!room) return { ok: false, error: "unknown room" };
690
+ if ((room.members.get(myPub.sign) || {}).admin !== true) return { ok: false, error: "admin only" };
691
+ if (memberSign === myPub.sign) return { ok: false, error: "cannot kick yourself" };
692
+ room.members.delete(memberSign);
693
+ room.strand.push({ op: "remove", by: myPub.sign, member: memberSign, ts: Date.now() });
694
+ const keep = _roomMembersArr(room).map((m) => m.sign);
695
+ await olm.groupRotate(roomId, keep); // fresh outbound; drop kicked sender's inbound
696
+ _persistRoom(room);
697
+ for (const m of _roomMembersArr(room)) {
698
+ if (m.sign === myPub.sign) continue;
699
+ const cid = _findBySign(m.sign); if (!cid) continue;
700
+ await _sendControl(cid, { t: "room-remove", room: roomId, member: memberSign, from: myPub.sign });
701
+ await _sendRoomKey(room, m.sign); // my fresh key → remaining only
702
+ }
703
+ emit("roomevent", { room: roomId, kind: "remove", member: memberSign });
704
+ return { ok: true };
705
+ }
706
+
707
+ // the ONE room-frame gate. r.from = the authenticated actor sign key.
708
+ async function _deliverRoom(payload, r, contactId) {
709
+ if (!olm || !r.verified) return;
710
+ const roomId = payload.room; if (!roomId) return;
711
+ let room = rooms.get(roomId);
712
+
713
+ if (payload.t === "room-join") { // admin path: someone opened my link
714
+ if (!room || (room.members.get(myPub.sign) || {}).admin !== true) return; // only the admin admits
715
+ const nm = payload.member || {}; if (nm.sign !== r.from) return; // the join must be self-signed
716
+ const cid = _bindMember(nm);
717
+ const existing = _roomMembersArr(room).filter((m) => m.sign !== myPub.sign);
718
+ if (!room.members.has(nm.sign)) { room.members.set(nm.sign, { sign: nm.sign, box: nm.box, name: nm.name || null, admin: false }); room.strand.push({ op: "add", by: myPub.sign, member: nm.sign, ts: Date.now() }); }
719
+ _persistRoom(room);
720
+ // welcome the newcomer: full roster + my group key; then tell every existing member to add them.
721
+ const gk = await olm.groupKey(roomId);
722
+ await _sendControl(cid, { t: "room-welcome", room: roomId, name: room.name, creator: room.creator, roster: _roomMembersArr(room), key: gk && gk.key, sid: gk && gk.id, from: myPub.sign });
723
+ for (const ms of [2500, 6000]) setTimeout(() => { const r = rooms.get(roomId); if (r && r.members.has(nm.sign)) _sendRoomKey(r, nm.sign).catch(() => {}); }, ms); // re-hand my key (lossy relay)
724
+ for (const m of existing) { const mcid = _findBySign(m.sign); if (mcid) await _sendControl(mcid, { t: "room-add", room: roomId, member: { sign: nm.sign, box: nm.box, name: nm.name || null }, from: myPub.sign }); }
725
+ emit("roomevent", { room: roomId, kind: "add", member: nm.sign });
726
+ return;
727
+ }
728
+
729
+ if (payload.t === "room-welcome") { // joiner path: I've been admitted
730
+ if (r.from !== payload.creator) return; // welcome must come from the room creator (TOFU)
731
+ room = room || { id: roomId, name: payload.name || "Room", creator: payload.creator, members: new Map(), strand: [] };
732
+ room.name = payload.name || room.name; room.creator = payload.creator;
733
+ for (const m of (payload.roster || [])) { room.members.set(m.sign, { sign: m.sign, box: m.box, name: m.name || null, admin: m.sign === payload.creator }); _bindMember(m); }
734
+ room.members.set(myPub.sign, { ..._me(), admin: myPub.sign === payload.creator });
735
+ rooms.set(roomId, room);
736
+ if (payload.key) { await olm.groupAddInbound(roomId, payload.creator, payload.key); } // read the admin
737
+ await olm.groupCreate(roomId); // MY outbound for this room
738
+ _persistRoom(room);
739
+ await _broadcastRoomKey(room); // hand my key to everyone (admin + peers)
740
+ _broadcastRoomKeySoon(room); // …and again, so a dropped key frame converges
741
+ emit("roomevent", { room: roomId, kind: "joined", view: _roomView(room) });
742
+ return;
743
+ }
744
+
745
+ if (payload.t === "room-add") { // an existing member learns of a newcomer
746
+ if (!room) return;
747
+ const nm = payload.member || {}; if (!nm.sign) return;
748
+ if (!room.members.has(nm.sign)) { room.members.set(nm.sign, { sign: nm.sign, box: nm.box, name: nm.name || null, admin: false }); room.strand.push({ op: "add", by: r.from, member: nm.sign, ts: Date.now() }); }
749
+ _bindMember(nm);
750
+ _persistRoom(room);
751
+ await _sendRoomKey(room, nm.sign); // hand the newcomer MY key so they can read me
752
+ for (const ms of [2500, 6000]) setTimeout(() => { const r = rooms.get(roomId); if (r && r.members.has(nm.sign)) _sendRoomKey(r, nm.sign).catch(() => {}); }, ms);
753
+ emit("roomevent", { room: roomId, kind: "add", member: nm.sign });
754
+ return;
755
+ }
756
+
757
+ if (payload.t === "room-key") { // a member handed me their Megolm session key
758
+ if (!room) return;
759
+ await olm.groupAddInbound(roomId, r.from, payload.key);
760
+ return;
761
+ }
762
+
763
+ if (payload.t === "room-key-req") { // a member couldn't open my stream → re-hand my key
764
+ if (!room || !room.members.has(r.from)) return;
765
+ await _sendRoomKey(room, r.from);
766
+ return;
767
+ }
768
+
769
+ if (payload.t === "room-remove") { // admin removed someone → I rotate too (PCS)
770
+ if (!room || r.from !== room.creator) return; // only the creator/admin removes
771
+ const gone = payload.member; if (!gone) return;
772
+ room.members.delete(gone);
773
+ room.strand.push({ op: "remove", by: r.from, member: gone, ts: Date.now() });
774
+ const keep = _roomMembersArr(room).map((m) => m.sign);
775
+ await olm.groupRotate(roomId, keep); // fresh outbound + drop the kicked sender's inbound
776
+ _persistRoom(room);
777
+ await _broadcastRoomKey(room, [gone]); // my fresh key → remaining only
778
+ emit("roomevent", { room: roomId, kind: "remove", member: gone });
779
+ return;
780
+ }
781
+
782
+ if (payload.t === "room-live") { // ephemeral presence: a member is inside a holospace
783
+ if (!room || !room.members.has(r.from)) return; // members only; nothing persists, nothing replays
784
+ const sender = room.members.get(r.from) || {};
785
+ emit("roomevent", { room: roomId, kind: "live", member: r.from, name: payload.name || sender.name || null,
786
+ url: payload.url || null, title: payload.title || null, on: payload.on !== false, ts: payload.ts || Date.now() });
787
+ return;
788
+ }
789
+
790
+ if (payload.t === "room-co") { // ephemeral together-frames (cursors / playback sync)
791
+ if (!room || !room.members.has(r.from)) return; // members only; nothing persists, nothing replays
792
+ const sender = room.members.get(r.from) || {};
793
+ emit("roomevent", { room: roomId, kind: "co", member: r.from, name: payload.name || sender.name || null,
794
+ url: payload.url || null, data: payload.data ?? null, ts: payload.ts || Date.now() });
795
+ return;
796
+ }
797
+
798
+ if (payload.t === "room-msg") { // a Megolm room word
799
+ if (!room || !room.members.has(r.from)) return; // sender must be a current member
800
+ // SELF-HEAL over a lossy relay: if I hold no inbound for this sender (their room-key frame was dropped),
801
+ // ask for it — once per sender per gap — rather than silently missing their whole stream. The word that
802
+ // triggered this is replayed by the sender's next send (Megolm indices let a late inbound catch up).
803
+ if (!(await olm.hasGroupInbound(roomId, r.from))) { const cid = _findBySign(r.from); if (cid) _sendControl(cid, { t: "room-key-req", room: roomId, from: myPub.sign }); return; }
804
+ if (_roomDedup(payload.mid)) return;
805
+ let d; try { d = await olm.groupOpen(roomId, r.from, payload.c); } catch { const cid = _findBySign(r.from); if (cid) _sendControl(cid, { t: "room-key-req", room: roomId, from: myPub.sign }); return; } // rotated/gap → re-request
806
+ let text = d.p, name = null; try { const pj = JSON.parse(d.p); if (pj && typeof pj.text === "string") { text = pj.text; name = pj.name || null; } } catch {}
807
+ _stats.megolmOpened++;
808
+ const sender = room.members.get(r.from) || {};
809
+ if (name && !sender.name) { sender.name = name; room.members.set(r.from, sender); }
810
+ if (store) store.putMsg({ kappa: payload.mid, contactId: roomId, ts: payload.ts || Date.now(), dir: "in", text, room: roomId, name: sender.name || name || null }).catch(() => {});
811
+ emit("room", { room: roomId, from: r.from, name: sender.name || name, text, ts: payload.ts || Date.now(), mid: payload.mid, index: d.i });
812
+ return;
813
+ }
814
+ }
815
+
816
+ return {
817
+ myPub,
818
+ addContact,
819
+ // rooms (M4)
820
+ createRoom, joinRoom, roomSend, roomKick, roomLink, roomLive, roomCo,
821
+ rooms: () => [...rooms.values()].map(_roomView),
822
+ roomView: (id) => { const r = rooms.get(id); return r ? _roomView(r) : null; },
823
+ roomMembers: (id) => { const r = rooms.get(id); return r ? _roomMembersArr(r).map((m) => ({ sign: m.sign, name: m.name, admin: m.admin })) : []; },
824
+ contacts: () => [...book.keys()],
825
+ postStory, storyFan, storyCtl, // STATUS: fan a story / a revoke to every contact · ack back to its author
826
+ send, poll, warm, sendTyping, sendMedia,
827
+ // presence (AIM A1/A3): my state is what I announced; a buddy's state is what they announced, TTL-honest.
828
+ setPresence,
829
+ myPresence: () => ({ ..._myPresence }),
830
+ presenceOf: (cidOrSign) => { const p = _presenceFor(cidOrSign); return p ? { state: p.state, msg: p.msg, profile: p.profile, ts: p.ts } : { state: "offline", msg: null, profile: null, ts: 0 }; },
831
+ presences: () => { const now = Date.now(), o = {}; for (const [sign, p] of _peerPresence) if (p.expires > now) o[sign] = { cid: p.cid, state: p.state, msg: p.msg, profile: p.profile, ts: p.ts }; return o; },
832
+ // vault meta passthrough (AIM buddy groups etc.) — state lives sealed in the store, not localStorage
833
+ getMeta: (k) => (store && store.getMeta ? store.getMeta(k) : Promise.resolve(null)),
834
+ setMeta: (k, v) => (store && store.setMeta ? store.setMeta(k, v) : Promise.resolve()),
835
+ // Holo Keys carrier: one sealed control frame to a contact (dual-path like every control frame). The
836
+ // _intro() box key rides inside so a first-contact issuer can answer — the N8 two-way door, reused.
837
+ keySend: (cid, frame) => _sendControl(cid, { ...frame, ..._intro() }),
838
+ mediaBytes: (kappaCt) => (store ? store.getMedia(kappaCt) : Promise.resolve(null)), // → {bytes,name,mime}|null
839
+ history: (contactId, opts) => (store ? store.msgs(contactId, opts) : Promise.resolve([])),
840
+ conversations: async () => { // the list the sheet renders: contacts + last word, newest first
841
+ if (!store) return [];
842
+ const cs = await store.contacts().catch(() => []);
843
+ const out = [];
844
+ for (const c of cs) out.push({ ...c, last: await store.lastMsg(c.contactId).catch(() => null) });
845
+ return out.sort((a, b) => ((b.last && b.last.ts) || b.addedTs || 0) - ((a.last && a.last.ts) || a.addedTs || 0));
846
+ },
847
+ linkState: (contactId) => { const l = links.get(contactId); return l && l.open ? "p2p" : (dialing.has(contactId) ? "dialing" : "mailbox"); },
848
+ vozReady: async (contactId) => !!olm && !!vozBook.get(contactId) && await olm.hasSession(contactId), // is this thread on the ratchet?
849
+ sealStats: () => ({ ..._stats, olm: !!olm }), // honest counters (witness + truthful lock)
850
+ safetyNumber: (contactId) => { const p = book.get(contactId); return p ? Verify.safetyNumber(myPub, p) : Promise.resolve(null); },
851
+ safetyEmojis: Verify.safetyEmojis, safetyDigits: Verify.safetyDigits,
852
+ verifyStatus: (contactId) => { const p = book.get(contactId); return p ? trust.check(contactId, p) : { status: "unknown" }; },
853
+ markVerified: (contactId) => trust.markVerified(contactId),
854
+ on: (ev, cb) => { (listeners[ev] || (listeners[ev] = [])).push(cb); },
855
+ close: () => { if (stopListen) stopListen(); clearInterval(_beatT); clearInterval(_sweepT); for (const l of links.values()) { try { l.close(); } catch {} } links.clear(); },
856
+ };
857
+ }