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 |
+
}
|