1use std::collections::HashMap;
18use std::time::{SystemTime, UNIX_EPOCH};
19
20use serde_json::{json, Value};
21
22pub type ConnId = u64;
24
25pub enum Inbound {
27 Connect(ConnId, String),
29 Text(ConnId, String),
31 Binary(ConnId, Vec<u8>),
34 Disconnect(ConnId),
36}
37
38pub struct Outbound {
40 pub conn: ConnId,
41 pub body: Body,
42}
43
44#[derive(Clone)]
46pub enum Body {
47 Text(String),
48 Binary(Vec<u8>),
49}
50
51impl Outbound {
52 pub fn text(&self) -> &str {
54 match &self.body {
55 Body::Text(t) => t,
56 Body::Binary(_) => "",
57 }
58 }
59}
60
61pub const MAX_VOICE_FRAME: usize = 8 * 1024;
64
65struct Occupant {
66 id: u32,
67 world: String,
68 pos: [f32; 3],
69 sub: Option<String>,
74 name: Option<String>,
75 avatar: Option<String>,
76}
77
78impl Occupant {
79 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
95pub struct Hub {
97 aoi_radius: f32,
101 tick_hz: u32,
103 next_id: u32,
104 conn_world: HashMap<ConnId, String>,
106 occupants: HashMap<ConnId, Occupant>,
108 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 pub fn occupant_count(&self) -> usize {
132 self.occupants.len()
133 }
134
135 pub fn observer_count(&self) -> usize {
137 self.observers.len()
138 }
139
140 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(), },
151 Inbound::Binary(conn, data) => {
152 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 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 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 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 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 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 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 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 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 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 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
364pub 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 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 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 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 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 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(_) => {} Err(_) => break,
447 }
448 }
449
450 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
478fn 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
496fn 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
523fn 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
531fn 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 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 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 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 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 #[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 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]; 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 let out = hub.handle(Inbound::Binary(1, vec![0u8; MAX_VOICE_FRAME + 1]));
625 assert!(out.is_empty());
626 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); 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 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 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 #[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 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 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 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 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 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 let out = hub.handle(Inbound::Disconnect(2));
755 assert!(out
756 .iter()
757 .any(|o| o.conn == 9 && o.text().contains("leave")));
758
759 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 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 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 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 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}