Skip to main content

thread_relay/
lib.rs

1//! The relay **Hub** — the socket-free core of a Thread presence relay.
2//!
3//! A relay is deliberately simple and *stateless-in-spirit*: it holds only
4//! ephemeral occupant positions, so a restart just means clients reconnect. All the
5//! wire behaviour ([presence-wire-v0.1]) lives here as a pure state machine —
6//! `handle(inbound) -> [outbound]` — so it's unit-testable with no network, and can
7//! be certified against the very same wire checker (`thread_conformance::relay`)
8//! that any browser author would run.
9//!
10//! [presence-wire-v0.1]: https://github.com/Pixygon/thread-spec/blob/main/specs/presence-wire-v0.1.md
11//!
12//! Decentralization by design: this is a *reference* relay anyone can `cargo run`
13//! (or `docker run`) on a cheap host. A world names its own relay(s) in its
14//! manifest; Pixygon runs at most one relay among many. The standard is the wire,
15//! not any single server — so presence survives any one operator disappearing.
16
17use std::collections::HashMap;
18use std::time::{SystemTime, UNIX_EPOCH};
19
20use serde_json::{json, Value};
21
22/// A connection handle assigned by the transport layer (one per WebSocket).
23pub type ConnId = u64;
24
25/// An event coming *into* the hub.
26pub enum Inbound {
27    /// A socket connected to `world_id` (from the `…/thread/:worldId` path).
28    Connect(ConnId, String),
29    /// A raw wire message arrived on a connection.
30    Text(ConnId, String),
31    /// A binary frame arrived — a **voice packet** (presence-wire §6). Opaque
32    /// audio payload; the hub only routes it.
33    Binary(ConnId, Vec<u8>),
34    /// A socket closed.
35    Disconnect(ConnId),
36}
37
38/// A message the hub wants sent to a specific connection.
39pub struct Outbound {
40    pub conn: ConnId,
41    pub body: Body,
42}
43
44/// A wire payload: JSON text (join/pose/leave) or a binary voice frame.
45#[derive(Clone)]
46pub enum Body {
47    Text(String),
48    Binary(Vec<u8>),
49}
50
51impl Outbound {
52    /// Convenience: this outbound's text, or "" for binary (tests + JSON paths).
53    pub fn text(&self) -> &str {
54        match &self.body {
55            Body::Text(t) => t,
56            Body::Binary(_) => "",
57        }
58    }
59}
60
61/// Voice frames above this size are dropped (abuse guard) — 20 ms of 48 kHz
62/// stereo PCM16 is ~7.7 KB; conformant 16 kHz mono frames are ~640 bytes.
63pub const MAX_VOICE_FRAME: usize = 8 * 1024;
64
65struct Occupant {
66    id: u32,
67    world: String,
68    pos: [f32; 3],
69    /// Identity claims read from the presented Passport (passport-v0.1 §2):
70    /// the stable `sub`, display `name`, and descriptor `avatar` URL. Carried
71    /// in `welcome.occupants[]` and the `join` broadcast so co-travelers can
72    /// render "you" from your descriptor. `None`s for anonymous travelers.
73    sub: Option<String>,
74    name: Option<String>,
75    avatar: Option<String>,
76}
77
78impl Occupant {
79    /// The occupant as it appears on the wire (id + identity claims, if any).
80    fn wire_json(&self) -> Value {
81        let mut v = json!({ "id": self.id });
82        if let Some(s) = &self.sub {
83            v["sub"] = json!(s);
84        }
85        if let Some(n) = &self.name {
86            v["name"] = json!(n);
87        }
88        if let Some(a) = &self.avatar {
89            v["avatar"] = json!(a);
90        }
91        v
92    }
93}
94
95/// The relay hub: worlds, occupants, and the wire state machine.
96pub struct Hub {
97    /// Area-of-interest radius in metres; poses are only fanned to occupants within
98    /// it. `0.0` = unlimited (fan to the whole world). This is the primary scale
99    /// lever: a 10k-occupant world only exchanges with the handful nearby.
100    aoi_radius: f32,
101    /// Suggested client send rate, advertised in `welcome`.
102    tick_hz: u32,
103    next_id: u32,
104    /// Which world a connection joined (from its URL path), set on `Connect`.
105    conn_world: HashMap<ConnId, String>,
106    /// Active occupants, keyed by connection.
107    occupants: HashMap<ConnId, Occupant>,
108    /// **Observers**: connections watching a world's roster without entering
109    /// it. A status bar, a dashboard, a directory's "who's home" light — all
110    /// want the roster and nothing else, and none of them should appear as a
111    /// body standing at the origin. Kept apart from `occupants` on purpose:
112    /// they are never counted, never fanned poses or voice, and their
113    /// leaving broadcasts nothing, because nothing of them was ever there.
114    observers: HashMap<ConnId, String>,
115}
116
117impl Hub {
118    pub fn new(aoi_radius: f32, tick_hz: u32) -> Self {
119        Hub {
120            aoi_radius,
121            tick_hz,
122            next_id: 1,
123            conn_world: HashMap::new(),
124            occupants: HashMap::new(),
125            observers: HashMap::new(),
126        }
127    }
128
129    /// Current number of live occupants across all worlds. Observers are not
130    /// occupants and are not counted — that is the whole point of them.
131    pub fn occupant_count(&self) -> usize {
132        self.occupants.len()
133    }
134
135    /// Connections watching without entering.
136    pub fn observer_count(&self) -> usize {
137        self.observers.len()
138    }
139
140    /// Advance the state machine by one event, returning messages to send.
141    pub fn handle(&mut self, ev: Inbound) -> Vec<Outbound> {
142        match ev {
143            Inbound::Connect(conn, world) => {
144                self.conn_world.insert(conn, world);
145                Vec::new()
146            }
147            Inbound::Text(conn, raw) => match serde_json::from_str::<Value>(&raw) {
148                Ok(v) => self.on_message(conn, &v),
149                Err(_) => Vec::new(), // ignore malformed frames (robustness)
150            },
151            Inbound::Binary(conn, data) => {
152                // A voice frame (presence-wire §6): prefix the sender's id
153                // (u32 LE) and fan it to the sender's world within the same
154                // area-of-interest as poses — voices carry as far as presence.
155                if data.is_empty() || data.len() > MAX_VOICE_FRAME {
156                    return Vec::new();
157                }
158                let Some(occ) = self.occupants.get(&conn) else {
159                    return Vec::new();
160                };
161                let mut framed = Vec::with_capacity(4 + data.len());
162                framed.extend_from_slice(&occ.id.to_le_bytes());
163                framed.extend_from_slice(&data);
164                let (world, pos) = (occ.world.clone(), occ.pos);
165                self.occupants
166                    .iter()
167                    .filter(|(c, o)| **c != conn && o.world == world && self.in_range(pos, o.pos))
168                    .map(|(c, _)| Outbound {
169                        conn: *c,
170                        body: Body::Binary(framed.clone()),
171                    })
172                    .collect()
173            }
174            Inbound::Disconnect(conn) => {
175                self.conn_world.remove(&conn);
176                // An observer leaving is a non-event: no body, no farewell.
177                self.observers.remove(&conn);
178                self.remove_occupant(conn)
179            }
180        }
181    }
182
183    fn on_message(&mut self, conn: ConnId, v: &Value) -> Vec<Outbound> {
184        match v.get("t").and_then(Value::as_str) {
185            Some("join") => self.on_join(conn, v),
186            Some("pose") => self.on_pose(conn, v),
187            Some("interact") => self.on_interact(conn, v),
188            Some("observe") => self.on_observe(conn),
189            Some("leave") => self.remove_occupant(conn),
190            _ => Vec::new(),
191        }
192    }
193
194    /// `{"t":"observe"}` — watch this world's roster without joining it.
195    ///
196    /// The observer receives the same `welcome` a joiner gets (so it learns
197    /// who is already present) and every subsequent `join`/`leave` for that
198    /// world, and nothing else: no poses, no voice, no interactions. It is
199    /// not an occupant, so it never appears in anyone's `welcome.occupants`,
200    /// is never counted, and its own departure is silent.
201    fn on_observe(&mut self, conn: ConnId) -> Vec<Outbound> {
202        let Some(world) = self.conn_world.get(&conn).cloned() else {
203            return Vec::new();
204        };
205        let occupants: Vec<Value> = self
206            .occupants
207            .values()
208            .filter(|o| o.world == world)
209            .map(Occupant::wire_json)
210            .collect();
211        self.observers.insert(conn, world);
212        let welcome = json!({
213            "t": "welcome", "id": 0, "observer": true,
214            "occupants": occupants, "tick_hz": self.tick_hz,
215        });
216        vec![Outbound {
217            conn,
218            body: Body::Text(welcome.to_string()),
219        }]
220    }
221
222    /// Everyone who should hear that a world's roster changed: its occupants
223    /// (except the one it is about) plus its observers.
224    fn roster_audience(&self, world: &str, except: ConnId) -> Vec<ConnId> {
225        self.occupants
226            .iter()
227            .filter(|(c, o)| **c != except && o.world == world)
228            .map(|(c, _)| *c)
229            .chain(
230                self.observers
231                    .iter()
232                    .filter(|(_, w)| *w == world)
233                    .map(|(c, _)| *c),
234            )
235            .collect()
236    }
237
238    fn on_join(&mut self, conn: ConnId, v: &Value) -> Vec<Outbound> {
239        let Some(world) = self.conn_world.get(&conn).cloned() else {
240            return Vec::new();
241        };
242        // NOTE: a production relay verifies the Passport against the issuer's
243        // jwks.json here. This reference relay runs "open" (dev/self-host default);
244        // pair it with a verifying proxy or extend this for production. It still
245        // *reads* the token's identity claims so co-travelers can render each
246        // other from their descriptors — unverified claims, open-trust posture.
247        let (sub, name, avatar) = passport_claims(v.get("passport").and_then(Value::as_str));
248        let id = self.next_id;
249        self.next_id += 1;
250
251        // The joiner learns the occupants already present (so it can render them
252        // at once).
253        let others: Vec<Value> = self
254            .occupants
255            .values()
256            .filter(|o| o.world == world)
257            .map(Occupant::wire_json)
258            .collect();
259
260        let joiner = Occupant {
261            id,
262            world: world.clone(),
263            pos: [0.0; 3],
264            sub,
265            name,
266            avatar,
267        };
268
269        // Everyone already present learns the joiner *with identity* now (poses
270        // alone carry only an id). Not AoI-gated — identity precedes proximity.
271        let mut join = joiner.wire_json();
272        join["t"] = json!("join");
273        let join_text = join.to_string();
274        let mut out: Vec<Outbound> = self
275            .roster_audience(&world, conn)
276            .into_iter()
277            .map(|c| Outbound {
278                conn: c,
279                body: Body::Text(join_text.clone()),
280            })
281            .collect();
282
283        self.occupants.insert(conn, joiner);
284
285        let welcome =
286            json!({ "t": "welcome", "id": id, "occupants": others, "tick_hz": self.tick_hz });
287        out.push(Outbound {
288            conn,
289            body: Body::Text(welcome.to_string()),
290        });
291        out
292    }
293
294    fn on_pose(&mut self, conn: ConnId, v: &Value) -> Vec<Outbound> {
295        // Update the sender's authoritative position first.
296        let (id, world, pos) = {
297            let Some(occ) = self.occupants.get_mut(&conn) else {
298                return Vec::new();
299            };
300            if let Some(p) = vec3(v, "p") {
301                occ.pos = p;
302            }
303            (occ.id, occ.world.clone(), occ.pos)
304        };
305
306        // Re-stamp with the authoritative occupant id + a server timestamp (the
307        // single clock source clients interpolate against). Preserve p/r/y/v/a.
308        let mut pose = v.clone();
309        pose["id"] = json!(id);
310        pose["ts"] = json!(now_ms());
311        let text = pose.to_string();
312
313        self.fan_out(&world, conn, pos, &text)
314    }
315
316    fn on_interact(&mut self, conn: ConnId, v: &Value) -> Vec<Outbound> {
317        let Some((world, pos)) = self.occupants.get(&conn).map(|o| (o.world.clone(), o.pos)) else {
318            return Vec::new();
319        };
320        self.fan_out(&world, conn, pos, &v.to_string())
321    }
322
323    /// Send `text` to every other occupant in `world` within area-of-interest of
324    /// `from_pos`.
325    fn fan_out(&self, world: &str, from: ConnId, from_pos: [f32; 3], text: &str) -> Vec<Outbound> {
326        self.occupants
327            .iter()
328            .filter(|(conn, o)| {
329                **conn != from && o.world == world && self.in_range(from_pos, o.pos)
330            })
331            .map(|(conn, _)| Outbound {
332                conn: *conn,
333                body: Body::Text(text.to_string()),
334            })
335            .collect()
336    }
337
338    fn remove_occupant(&mut self, conn: ConnId) -> Vec<Outbound> {
339        let Some(occ) = self.occupants.remove(&conn) else {
340            return Vec::new();
341        };
342        let leave = json!({ "t": "leave", "id": occ.id }).to_string();
343        // Tell the rest of the world they left (AoI doesn't gate leaves — everyone
344        // who might be rendering them needs to drop them), and tell the
345        // watchers too, since a roster change is exactly what they are here for.
346        self.roster_audience(&occ.world, conn)
347            .into_iter()
348            .map(|c| Outbound {
349                conn: c,
350                body: Body::Text(leave.clone()),
351            })
352            .collect()
353    }
354
355    fn in_range(&self, a: [f32; 3], b: [f32; 3]) -> bool {
356        if self.aoi_radius <= 0.0 {
357            return true;
358        }
359        let d2 = (a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2);
360        d2 <= self.aoi_radius * self.aoi_radius
361    }
362}
363
364/// Serve the relay on an already-bound listener until the process ends. This is
365/// the whole transport layer — the binary is just env parsing plus this call —
366/// exposed so tests (and embedders) can run an in-process relay on an ephemeral
367/// port, exactly like `thread_rendezvous::serve_listener`.
368pub async fn serve_listener(listener: tokio::net::TcpListener, aoi_radius: f32, tick_hz: u32) {
369    use std::sync::atomic::{AtomicU64, Ordering};
370    use std::sync::{Arc, Mutex};
371
372    type Senders = Arc<Mutex<HashMap<ConnId, tokio::sync::mpsc::UnboundedSender<Body>>>>;
373
374    /// Feed an event to the hub and route the resulting messages out.
375    fn dispatch(hub: &Mutex<Hub>, senders: &Senders, ev: Inbound) {
376        let outs: Vec<Outbound> = hub.lock().unwrap().handle(ev);
377        if outs.is_empty() {
378            return;
379        }
380        let s = senders.lock().unwrap();
381        for o in outs {
382            if let Some(tx) = s.get(&o.conn) {
383                let _ = tx.send(o.body);
384            }
385        }
386    }
387
388    /// Handle one WebSocket connection start-to-finish.
389    async fn serve(
390        stream: tokio::net::TcpStream,
391        conn: ConnId,
392        hub: Arc<Mutex<Hub>>,
393        senders: Senders,
394    ) -> Result<(), String> {
395        use futures_util::{SinkExt, StreamExt};
396        use tokio_tungstenite::tungstenite::handshake::server::{Request, Response};
397        use tokio_tungstenite::tungstenite::Message;
398
399        // The world id is the `…/thread/:worldId` path segment of the upgrade.
400        let world = Arc::new(Mutex::new(String::new()));
401        let ws = {
402            let slot = world.clone();
403            tokio_tungstenite::accept_hdr_async(stream, move |req: &Request, resp: Response| {
404                let path = req.uri().path();
405                let w = path
406                    .strip_prefix("/thread/")
407                    .unwrap_or(path.trim_start_matches('/'))
408                    .to_string();
409                *slot.lock().unwrap() = w;
410                Ok(resp)
411            })
412            .await
413            .map_err(|e| e.to_string())?
414        };
415        let world = world.lock().unwrap().clone();
416
417        // Register an outbound channel and a writer task for this connection.
418        let (out_tx, mut out_rx) = tokio::sync::mpsc::unbounded_channel::<Body>();
419        senders.lock().unwrap().insert(conn, out_tx);
420        let (mut write, mut read) = ws.split();
421        let writer = tokio::spawn(async move {
422            while let Some(body) = out_rx.recv().await {
423                let msg = match body {
424                    Body::Text(t) => Message::Text(t.into()),
425                    Body::Binary(b) => Message::Binary(b.into()),
426                };
427                if write.send(msg).await.is_err() {
428                    break;
429                }
430            }
431        });
432
433        dispatch(&hub, &senders, Inbound::Connect(conn, world));
434
435        // Pump inbound frames into the hub.
436        while let Some(msg) = read.next().await {
437            match msg {
438                Ok(Message::Text(t)) => {
439                    dispatch(&hub, &senders, Inbound::Text(conn, t.to_string()))
440                }
441                Ok(Message::Binary(b)) => {
442                    dispatch(&hub, &senders, Inbound::Binary(conn, b.to_vec()))
443                }
444                Ok(Message::Close(_)) => break,
445                Ok(_) => {} // ignore ping/pong
446                Err(_) => break,
447            }
448        }
449
450        // Cleanup: broadcast the leave, drop the sender, stop the writer.
451        dispatch(&hub, &senders, Inbound::Disconnect(conn));
452        senders.lock().unwrap().remove(&conn);
453        writer.abort();
454        Ok(())
455    }
456
457    let hub = Arc::new(Mutex::new(Hub::new(aoi_radius, tick_hz)));
458    let senders: Senders = Arc::new(Mutex::new(HashMap::new()));
459    let conn_seq = AtomicU64::new(1);
460
461    loop {
462        match listener.accept().await {
463            Ok((stream, _peer)) => {
464                let conn = conn_seq.fetch_add(1, Ordering::Relaxed);
465                let hub = hub.clone();
466                let senders = senders.clone();
467                tokio::spawn(async move {
468                    if let Err(e) = serve(stream, conn, hub, senders).await {
469                        tracing::debug!("conn {conn} ended: {e}");
470                    }
471                });
472            }
473            Err(e) => tracing::warn!("accept error: {e}"),
474        }
475    }
476}
477
478/// Read `(sub, name, avatar)` from a Passport token's payload, best-effort.
479/// Claims are *read*, not verified (open-trust reference posture — see the note
480/// in `on_join`); opaque/absent tokens yield an anonymous occupant. Mirrors
481/// `loom::passport` (a standalone relay can't depend on the engine).
482fn passport_claims(token: Option<&str>) -> (Option<String>, Option<String>, Option<String>) {
483    let Some(payload) = token.and_then(|t| t.split('.').nth(1)) else {
484        return (None, None, None);
485    };
486    let Some(bytes) = base64url_decode(payload) else {
487        return (None, None, None);
488    };
489    let Ok(claims) = serde_json::from_slice::<Value>(&bytes) else {
490        return (None, None, None);
491    };
492    let s = |k: &str| claims.get(k).and_then(Value::as_str).map(str::to_string);
493    (s("sub"), s("name"), s("avatar"))
494}
495
496/// Minimal base64url (RFC 4648 §5, padding optional) — enough to read a JWT
497/// payload without a dependency.
498fn base64url_decode(s: &str) -> Option<Vec<u8>> {
499    fn val(c: u8) -> Option<u32> {
500        match c {
501            b'A'..=b'Z' => Some(u32::from(c - b'A')),
502            b'a'..=b'z' => Some(u32::from(c - b'a') + 26),
503            b'0'..=b'9' => Some(u32::from(c - b'0') + 52),
504            b'-' => Some(62),
505            b'_' => Some(63),
506            _ => None,
507        }
508    }
509    let s = s.trim_end_matches('=');
510    let mut out = Vec::with_capacity(s.len() * 3 / 4);
511    let (mut buf, mut bits) = (0u32, 0u32);
512    for &c in s.as_bytes() {
513        buf = (buf << 6) | val(c)?;
514        bits += 6;
515        if bits >= 8 {
516            bits -= 8;
517            out.push((buf >> bits) as u8);
518        }
519    }
520    Some(out)
521}
522
523/// Relay-epoch milliseconds (u32 wraps ~49 days — acceptable per the spec).
524fn now_ms() -> u32 {
525    SystemTime::now()
526        .duration_since(UNIX_EPOCH)
527        .map(|d| d.as_millis() as u32)
528        .unwrap_or(0)
529}
530
531/// Extract a `[f32; 3]` at `key`, if present and well-formed.
532fn vec3(v: &Value, key: &str) -> Option<[f32; 3]> {
533    let a = v.get(key)?.as_array()?;
534    if a.len() != 3 {
535        return None;
536    }
537    let f = |i: usize| a[i].as_f64().map(|x| x as f32);
538    Some([f(0)?, f(1)?, f(2)?])
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544    use serde_json::Value;
545    use thread_conformance::clauses_pass;
546    use thread_conformance::relay::{check_pose, check_welcome};
547
548    fn parse(out: &Outbound) -> Value {
549        serde_json::from_str(out.text()).unwrap()
550    }
551
552    #[test]
553    fn join_produces_a_conformant_welcome() {
554        let mut hub = Hub::new(0.0, 15);
555        hub.handle(Inbound::Connect(1, "lobby".into()));
556        let out = hub.handle(Inbound::Text(1, r#"{"t":"join","passport":"tok"}"#.into()));
557        assert_eq!(out.len(), 1);
558        let welcome = parse(&out[0]);
559        // The relay's own output must pass the public wire checker.
560        assert!(
561            clauses_pass(&check_welcome(&welcome)),
562            "welcome not conformant: {welcome}"
563        );
564        assert_eq!(welcome["id"], 1);
565        assert_eq!(welcome["occupants"].as_array().unwrap().len(), 0);
566    }
567
568    #[test]
569    fn pose_is_stamped_conformant_and_fanned_to_others() {
570        let mut hub = Hub::new(0.0, 15);
571        // Two occupants in the same world.
572        hub.handle(Inbound::Connect(1, "plaza".into()));
573        hub.handle(Inbound::Connect(2, "plaza".into()));
574        hub.handle(Inbound::Text(1, r#"{"t":"join","passport":"a"}"#.into()));
575        hub.handle(Inbound::Text(2, r#"{"t":"join","passport":"b"}"#.into()));
576
577        // Occupant 1 sends a client pose (no ts, id 0) — the relay must fix both.
578        let raw = r#"{"t":"pose","id":0,"p":[1,2,3],"r":[0,0,0,1],"v":[0.5,0,0],"a":1}"#;
579        let out = hub.handle(Inbound::Text(1, raw.into()));
580
581        // Fanned to occupant 2 only (not echoed to the sender).
582        assert_eq!(out.len(), 1);
583        assert_eq!(out[0].conn, 2);
584        let pose = parse(&out[0]);
585        assert!(
586            clauses_pass(&check_pose(&pose)),
587            "pose not conformant: {pose}"
588        );
589        assert!(pose["ts"].as_u64().is_some(), "relay stamped ts");
590        assert_eq!(pose["id"], 1, "relay set the authoritative occupant id");
591    }
592
593    /// Voice frames (presence-wire §6): binary in → sender-id-prefixed binary
594    /// out, fanned only within area-of-interest, never echoed, size-capped —
595    /// a voice carries exactly as far as presence does.
596    #[test]
597    fn voice_frames_fan_within_earshot_with_sender_prefix() {
598        let mut hub = Hub::new(10.0, 15);
599        for c in [1, 2, 3] {
600            hub.handle(Inbound::Connect(c, "plaza".into()));
601            hub.handle(Inbound::Text(c, r#"{"t":"join","passport":"x"}"#.into()));
602        }
603        // Occupant 2 stands near the speaker, occupant 3 out of earshot.
604        hub.handle(Inbound::Text(
605            2,
606            r#"{"t":"pose","id":0,"p":[3,0,0],"v":[0,0,0],"r":[0,0,0,1],"a":0}"#.into(),
607        ));
608        hub.handle(Inbound::Text(
609            3,
610            r#"{"t":"pose","id":0,"p":[100,0,0],"v":[0,0,0],"r":[0,0,0,1],"a":0}"#.into(),
611        ));
612
613        let pcm = vec![7u8; 640]; // one 20 ms 16 kHz mono PCM16 frame
614        let out = hub.handle(Inbound::Binary(1, pcm.clone()));
615        assert_eq!(out.len(), 1, "only the near occupant hears");
616        assert_eq!(out[0].conn, 2);
617        let Body::Binary(b) = &out[0].body else {
618            panic!("voice must stay binary")
619        };
620        assert_eq!(&b[..4], &1u32.to_le_bytes(), "sender id prefixed");
621        assert_eq!(&b[4..], &pcm[..], "payload untouched");
622
623        // Oversized frames are dropped, not relayed.
624        let out = hub.handle(Inbound::Binary(1, vec![0u8; MAX_VOICE_FRAME + 1]));
625        assert!(out.is_empty());
626        // A connection that never joined has no voice.
627        hub.handle(Inbound::Connect(9, "plaza".into()));
628        assert!(hub.handle(Inbound::Binary(9, vec![1, 2, 3])).is_empty());
629    }
630
631    #[test]
632    fn area_of_interest_culls_distant_occupants() {
633        let mut hub = Hub::new(10.0, 15); // 10 m radius
634        for c in [1, 2, 3] {
635            hub.handle(Inbound::Connect(c, "field".into()));
636            hub.handle(Inbound::Text(c, r#"{"t":"join","passport":"x"}"#.into()));
637        }
638        // Place occupant 2 near, occupant 3 far.
639        hub.handle(Inbound::Text(
640            2,
641            r#"{"t":"pose","id":0,"p":[3,0,0],"v":[0,0,0],"r":[0,0,0,1],"a":0}"#.into(),
642        ));
643        hub.handle(Inbound::Text(
644            3,
645            r#"{"t":"pose","id":0,"p":[100,0,0],"v":[0,0,0],"r":[0,0,0,1],"a":0}"#.into(),
646        ));
647
648        // Occupant 1 poses at origin: only the near occupant (2) should hear it.
649        let out = hub.handle(Inbound::Text(
650            1,
651            r#"{"t":"pose","id":0,"p":[0,0,0],"v":[0,0,0],"r":[0,0,0,1],"a":0}"#.into(),
652        ));
653        let targets: Vec<ConnId> = out.iter().map(|o| o.conn).collect();
654        assert_eq!(
655            targets,
656            vec![2],
657            "only the in-range occupant receives the pose"
658        );
659    }
660
661    /// An observer watches the roster from outside: it learns who is here,
662    /// hears arrivals and departures, and is itself invisible — not in
663    /// anyone's welcome, not in the count, no phantom standing at the origin,
664    /// and no farewell when it closes the tab.
665
666    /// A join frame that *asserts* a name doesn't get one. An unattested name a
667    /// relay repeats is a name anyone can claim, so identity is read from the
668    /// Passport's claims and from nothing else — and the frame is accepted
669    /// rather than rejected, because unknown fields are ignored. That makes the
670    /// mistake a quiet one, which is why it is pinned here: a future change
671    /// that "helpfully" honours the field would be a silent impersonation bug,
672    /// not a feature. (An outside implementer hit this within twenty minutes of
673    /// reading the spec, which is how it came to be written down.)
674    #[test]
675    fn a_name_asserted_in_the_join_frame_is_not_an_identity() {
676        let mut hub = Hub::new(0.0, 15);
677        hub.handle(Inbound::Connect(1, "commons".into()));
678        hub.handle(Inbound::Text(
679            1,
680            r#"{"t":"join","passport":"","name":"ada","sub":"did:x:ada","avatar":"https://x/a.json"}"#
681                .into(),
682        ));
683        assert_eq!(hub.occupant_count(), 1, "the frame is accepted, not refused");
684
685        // What the world is told about them carries none of it.
686        hub.handle(Inbound::Connect(2, "commons".into()));
687        let out = hub.handle(Inbound::Text(2, r#"{"t":"join","passport":""}"#.into()));
688        let welcome: Value = out
689            .iter()
690            .find(|o| o.conn == 2)
691            .map(|o| serde_json::from_str(o.text()).unwrap())
692            .expect("the joiner is welcomed");
693        let them = &welcome["occupants"][0];
694        assert!(them["id"].is_number(), "they are there: {them}");
695        for claimed in ["name", "sub", "avatar"] {
696            assert!(
697                them[claimed].is_null(),
698                "'{claimed}' was asserted on the wire and must not be honoured: {them}"
699            );
700        }
701    }
702
703    #[test]
704    fn an_observer_watches_without_entering() {
705        let mut hub = Hub::new(0.0, 15);
706        hub.handle(Inbound::Connect(1, "commons".into()));
707        hub.handle(Inbound::Text(1, r#"{"t":"join","passport":""}"#.into()));
708
709        // The watcher arrives and is welcomed with the roster as it stands.
710        hub.handle(Inbound::Connect(9, "commons".into()));
711        let out = hub.handle(Inbound::Text(9, r#"{"t":"observe"}"#.into()));
712        assert_eq!(out.len(), 1, "only the watcher is told anything");
713        assert_eq!(out[0].conn, 9);
714        let w: Value = serde_json::from_str(out[0].text()).unwrap();
715        assert_eq!(w["t"], "welcome");
716        assert_eq!(w["observer"], true);
717        assert_eq!(
718            w["occupants"].as_array().unwrap().len(),
719            1,
720            "sees who is here"
721        );
722        assert_eq!(hub.occupant_count(), 1, "watching is not occupying");
723        assert_eq!(hub.observer_count(), 1);
724
725        // A real arrival reaches both the occupant and the watcher…
726        hub.handle(Inbound::Connect(2, "commons".into()));
727        let out = hub.handle(Inbound::Text(2, r#"{"t":"join","passport":""}"#.into()));
728        let told: Vec<ConnId> = out
729            .iter()
730            .filter(|o| o.text().contains("\"join\""))
731            .map(|o| o.conn)
732            .collect();
733        assert!(
734            told.contains(&1) && told.contains(&9),
735            "join reached {told:?}"
736        );
737        // …but the joiner's welcome lists only the real occupant, never the watcher.
738        let welcome: Value =
739            serde_json::from_str(out.iter().find(|o| o.conn == 2).expect("welcome").text())
740                .unwrap();
741        assert_eq!(welcome["occupants"].as_array().unwrap().len(), 1);
742
743        // Poses are never fanned to a watcher — it wants the roster, not the room.
744        let out = hub.handle(Inbound::Text(
745            1,
746            r#"{"t":"pose","p":[1,0,1],"r":[0,0,0,1],"v":[0,0,0],"a":0}"#.into(),
747        ));
748        assert!(
749            out.iter().all(|o| o.conn != 9),
750            "no pose reached the watcher"
751        );
752
753        // A departure reaches the watcher…
754        let out = hub.handle(Inbound::Disconnect(2));
755        assert!(out
756            .iter()
757            .any(|o| o.conn == 9 && o.text().contains("leave")));
758
759        // …and the watcher's own departure says nothing to anyone.
760        let out = hub.handle(Inbound::Disconnect(9));
761        assert!(
762            out.is_empty(),
763            "an observer leaving is a non-event ({} messages sent)",
764            out.len()
765        );
766        assert_eq!(hub.observer_count(), 0);
767        assert_eq!(hub.occupant_count(), 1);
768    }
769
770    #[test]
771    fn disconnect_broadcasts_a_leave() {
772        let mut hub = Hub::new(0.0, 15);
773        hub.handle(Inbound::Connect(1, "hall".into()));
774        hub.handle(Inbound::Connect(2, "hall".into()));
775        hub.handle(Inbound::Text(1, r#"{"t":"join","passport":"a"}"#.into()));
776        hub.handle(Inbound::Text(2, r#"{"t":"join","passport":"b"}"#.into()));
777
778        let out = hub.handle(Inbound::Disconnect(1));
779        assert_eq!(out.len(), 1);
780        assert_eq!(out[0].conn, 2);
781        let leave = parse(&out[0]);
782        assert_eq!(leave["t"], "leave");
783        assert_eq!(leave["id"], 1);
784        assert_eq!(hub.occupant_count(), 1);
785    }
786
787    /// An unsigned JWT-shaped token around a JSON payload (claims are read, not
788    /// verified, so a bare `alg:none` shape is enough to exercise the path).
789    fn token_with_payload(payload: &str) -> String {
790        fn b64url(data: &[u8]) -> String {
791            const ALPHABET: &[u8] =
792                b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
793            let mut out = String::new();
794            for chunk in data.chunks(3) {
795                let mut buf = [0u8; 3];
796                buf[..chunk.len()].copy_from_slice(chunk);
797                let n = (u32::from(buf[0]) << 16) | (u32::from(buf[1]) << 8) | u32::from(buf[2]);
798                for (i, shift) in [18u32, 12, 6, 0].iter().enumerate() {
799                    if i <= chunk.len() {
800                        out.push(ALPHABET[((n >> shift) & 63) as usize] as char);
801                    }
802                }
803            }
804            out
805        }
806        format!(
807            "{}.{}.sig",
808            b64url(br#"{"alg":"none"}"#),
809            b64url(payload.as_bytes())
810        )
811    }
812
813    #[test]
814    fn identity_claims_cross_the_wire() {
815        let mut hub = Hub::new(0.0, 15);
816        let token = token_with_payload(
817            r#"{"sub":"did:pixygon:abc","name":"Anders","avatar":"https://api.pixygon.io/v1/passport/avatar/did:pixygon:abc"}"#,
818        );
819
820        // First joiner carries a Passport; the room is empty, so just a welcome.
821        hub.handle(Inbound::Connect(1, "plaza".into()));
822        let out = hub.handle(Inbound::Text(
823            1,
824            format!(r#"{{"t":"join","passport":"{token}"}}"#),
825        ));
826        assert_eq!(out.len(), 1);
827
828        // Second joiner (anonymous): occupant 1 hears a `join` broadcast for it,
829        // and its welcome lists occupant 1 *with identity*.
830        hub.handle(Inbound::Connect(2, "plaza".into()));
831        let out = hub.handle(Inbound::Text(
832            2,
833            r#"{"t":"join","passport":"opaque"}"#.into(),
834        ));
835        assert_eq!(out.len(), 2);
836        let (join, welcome) = (parse(&out[0]), parse(&out[1]));
837        assert_eq!(out[0].conn, 1);
838        assert_eq!(join["t"], "join");
839        assert_eq!(join["id"], 2);
840        assert!(join.get("sub").is_none(), "anonymous joiner has no claims");
841        assert_eq!(out[1].conn, 2);
842        let occupants = welcome["occupants"].as_array().unwrap();
843        assert_eq!(occupants.len(), 1);
844        assert_eq!(occupants[0]["id"], 1);
845        assert_eq!(occupants[0]["sub"], "did:pixygon:abc");
846        assert_eq!(occupants[0]["name"], "Anders");
847        assert_eq!(
848            occupants[0]["avatar"],
849            "https://api.pixygon.io/v1/passport/avatar/did:pixygon:abc"
850        );
851    }
852
853    #[test]
854    fn worlds_are_isolated_from_each_other() {
855        let mut hub = Hub::new(0.0, 15);
856        hub.handle(Inbound::Connect(1, "world-a".into()));
857        hub.handle(Inbound::Connect(2, "world-b".into()));
858        hub.handle(Inbound::Text(1, r#"{"t":"join","passport":"a"}"#.into()));
859        hub.handle(Inbound::Text(2, r#"{"t":"join","passport":"b"}"#.into()));
860
861        // A pose in world-a must never reach world-b.
862        let out = hub.handle(Inbound::Text(
863            1,
864            r#"{"t":"pose","id":0,"p":[0,0,0],"v":[0,0,0],"r":[0,0,0,1],"a":0}"#.into(),
865        ));
866        assert!(out.is_empty(), "cross-world leakage");
867    }
868}