Skip to main content

thread_engine/
passport.rs

1//! The traveler's Passport — one portable identity across the Thread.
2//!
3//! Implements the *browser* side of [passport-v0.1](https://github.com/Pixygon/thread-spec/blob/main/specs/passport-v0.1.md):
4//! hold the token for the traveler, read its claims, and fetch the descriptor
5//! (the portable "you": name, avatar, consent) fresh from the issuer. The token
6//! comes from `INFINITE_PASSPORT`; the descriptor URL is the token's `avatar`
7//! claim. Everything degrades gracefully — no passport means an anonymous
8//! traveler, never a gate on walking (spec §4).
9//!
10//! Claims are decoded **unverified** here: the browser is the token's *holder*,
11//! not a verifier — it reads its own claims for display and routing. Hosts and
12//! relays are the parties that verify keys-only against the issuer's JWKS.
13
14use std::collections::HashMap;
15use std::sync::mpsc::{channel, Receiver, TryRecvError};
16
17use infinite_avatar::AvatarSpec;
18use serde::Deserialize;
19
20/// The claims a browser reads from its own token (passport-v0.1 §2).
21/// Tolerant to extra fields; every field optional so a dev-issued or
22/// non-JWT opaque token still carries.
23#[derive(Debug, Clone, Default, Deserialize)]
24pub struct Claims {
25    #[serde(default)]
26    pub iss: String,
27    /// DID-style stable id — THE identity.
28    #[serde(default)]
29    pub sub: String,
30    #[serde(default)]
31    pub name: Option<String>,
32    /// The descriptor URL (spec §3) — where the portable "you" is fetched from.
33    #[serde(default)]
34    pub avatar: Option<String>,
35    #[serde(default)]
36    pub scopes: Vec<String>,
37    #[serde(default)]
38    pub exp: Option<u64>,
39}
40
41/// The fetchable descriptor (passport-v0.1 §3) — fetched fresh, rendered,
42/// discarded. Never persisted beyond the session.
43#[derive(Debug, Clone, Default, Deserialize)]
44pub struct Descriptor {
45    #[serde(default)]
46    pub passport: String,
47    #[serde(default)]
48    pub identity: DescriptorIdentity,
49    #[serde(default)]
50    pub avatar: Option<AvatarDescriptor>,
51    #[serde(default)]
52    pub home: Option<String>,
53}
54
55#[derive(Debug, Clone, Default, Deserialize)]
56pub struct DescriptorIdentity {
57    #[serde(default)]
58    pub id: String,
59    #[serde(default)]
60    pub name: String,
61}
62
63/// avatar-v0.1 envelope — the portable "you" (spec §3 + avatar-v0.1).
64///
65/// The heart is `spec`: the **shared [`AvatarSpec`]** (slot → partId +
66/// bodyHeight) — the exact same model `com.pixygon.avatar` (Unity) and
67/// `@pixygon/avatar` (web) persist, so one saved avatar renders identically in
68/// a Unity game, the web Studio, and any Thread browser. Parts resolve through
69/// the Portable Item Convention (`infinite_avatar::manifest`): partId →
70/// `AvatarAsset` doc → one GLB carrying its own meaning.
71#[derive(Debug, Clone, Default, Deserialize)]
72pub struct AvatarDescriptor {
73    /// The shared avatar model. Absent → render the placeholder traveler.
74    #[serde(default)]
75    pub spec: Option<AvatarSpec>,
76    #[serde(default)]
77    pub colors: HashMap<String, String>,
78    /// Catalog/CDN base for partId → GLB resolution.
79    #[serde(default)]
80    pub assets: Option<String>,
81    /// Legacy draft envelope (pre avatar-v0.1) — still parsed so early issuers
82    /// keep rendering. `spec` wins when both are present.
83    #[serde(default)]
84    pub base: Option<u32>,
85    #[serde(default)]
86    pub worn: Vec<u32>,
87}
88
89/// The traveler's held Passport: the token, its claims, and (once fetched)
90/// the descriptor. Fetching runs on a background thread; call [`Passport::poll`]
91/// once per frame, like the other Loom clients.
92pub struct Passport {
93    token: String,
94    claims: Claims,
95    descriptor: Option<Descriptor>,
96    pending: Option<Receiver<Result<Descriptor, String>>>,
97    error: Option<String>,
98}
99
100impl Passport {
101    /// Hold a token. Claims decode best-effort — an opaque (non-JWT) dev token
102    /// still carries, it just has no readable claims. Empty tokens are `None`:
103    /// no passport is a valid, anonymous way to walk.
104    pub fn from_token(token: impl Into<String>) -> Option<Self> {
105        let token = token.into();
106        if token.trim().is_empty() {
107            return None;
108        }
109        let claims = decode_claims(&token).unwrap_or_default();
110        Some(Self {
111            token,
112            claims,
113            descriptor: None,
114            pending: None,
115            error: None,
116        })
117    }
118
119    /// The traveler's passport from `INFINITE_PASSPORT` (unset/empty → anonymous).
120    pub fn from_env() -> Option<Self> {
121        Self::from_token(std::env::var("INFINITE_PASSPORT").unwrap_or_default())
122    }
123
124    /// The raw token, as presented on presence `join` / portal handoff.
125    pub fn token(&self) -> &str {
126        &self.token
127    }
128
129    pub fn claims(&self) -> &Claims {
130        &self.claims
131    }
132
133    /// The stable identity, if the token's claims were readable.
134    pub fn sub(&self) -> Option<&str> {
135        (!self.claims.sub.is_empty()).then_some(self.claims.sub.as_str())
136    }
137
138    /// Best display name: descriptor (freshest) → token claim → the sub itself.
139    pub fn display_name(&self) -> Option<&str> {
140        if let Some(d) = &self.descriptor {
141            if !d.identity.name.is_empty() {
142                return Some(&d.identity.name);
143            }
144        }
145        if let Some(n) = &self.claims.name {
146            if !n.is_empty() {
147                return Some(n);
148            }
149        }
150        self.sub()
151    }
152
153    /// The fetched descriptor, once [`Passport::poll`] has collected it.
154    pub fn descriptor(&self) -> Option<&Descriptor> {
155        self.descriptor.as_ref()
156    }
157
158    /// The traveler's own [`AvatarSpec`] (the shared avatar model), once the
159    /// descriptor has landed and if the issuer provided one.
160    pub fn avatar_spec(&self) -> Option<&AvatarSpec> {
161        self.descriptor.as_ref()?.avatar.as_ref()?.spec.as_ref()
162    }
163
164    /// Why the last descriptor fetch failed, if it did (e.g. `410 Gone` after
165    /// erasure — spec §6 — in which case the traveler renders anonymous).
166    pub fn descriptor_error(&self) -> Option<&str> {
167        self.error.as_deref()
168    }
169
170    /// Start fetching the descriptor from the token's `avatar` claim on a
171    /// background thread. No-op without a claim URL, while a fetch is in
172    /// flight, or once loaded (the descriptor is session-cached; spec §3).
173    pub fn fetch_descriptor(&mut self) {
174        if self.pending.is_some() || self.descriptor.is_some() {
175            return;
176        }
177        let Some(url) = self.claims.avatar.clone() else {
178            return;
179        };
180        let (tx, rx) = channel();
181        std::thread::spawn(move || {
182            let _ = tx.send(fetch_blocking(&url));
183        });
184        self.pending = Some(rx);
185    }
186
187    /// Collect a finished background fetch, if any. Call once per frame.
188    /// Returns `true` on the frame the descriptor arrives.
189    pub fn poll(&mut self) -> bool {
190        let Some(rx) = &self.pending else {
191            return false;
192        };
193        match rx.try_recv() {
194            Ok(Ok(descriptor)) => {
195                self.descriptor = Some(descriptor);
196                self.error = None;
197                self.pending = None;
198                true
199            }
200            Ok(Err(msg)) => {
201                self.error = Some(msg);
202                self.pending = None;
203                false
204            }
205            Err(TryRecvError::Empty) => false,
206            Err(TryRecvError::Disconnected) => {
207                self.error = Some("fetch thread stopped".into());
208                self.pending = None;
209                false
210            }
211        }
212    }
213}
214
215/// Fetches co-travelers' descriptors by the `avatar` URL the relay announced
216/// (welcome occupants / join broadcast), so the browser renders "them" from
217/// *their* Passport. Session-scoped and fetch-render-discard, per spec §3:
218/// nothing here outlives the run. Keyed by occupant id.
219#[derive(Default)]
220pub struct DescriptorPool {
221    pending: Vec<(u32, Receiver<Result<Descriptor, String>>)>,
222    /// Ids already fetched (or failed) this session — no refetch churn.
223    done: std::collections::HashSet<u32>,
224}
225
226impl DescriptorPool {
227    pub fn new() -> Self {
228        Self::default()
229    }
230
231    /// Start fetching `id`'s descriptor from `url` on a background thread.
232    /// No-op if this occupant was already fetched or is in flight.
233    pub fn fetch(&mut self, id: u32, url: &str) {
234        if self.done.contains(&id) || self.pending.iter().any(|(p, _)| *p == id) {
235            return;
236        }
237        let url = url.to_string();
238        let (tx, rx) = channel();
239        std::thread::spawn(move || {
240            let _ = tx.send(fetch_blocking(&url));
241        });
242        self.pending.push((id, rx));
243    }
244
245    /// Collect descriptors that arrived since last frame. Failures are dropped
246    /// silently — the traveler simply keeps their placeholder look.
247    pub fn poll(&mut self) -> Vec<(u32, Descriptor)> {
248        let mut arrived = Vec::new();
249        self.pending.retain(|(id, rx)| match rx.try_recv() {
250            Ok(Ok(d)) => {
251                arrived.push((*id, d));
252                false
253            }
254            Ok(Err(_)) | Err(TryRecvError::Disconnected) => false,
255            Err(TryRecvError::Empty) => true,
256        });
257        for (id, _) in &arrived {
258            self.done.insert(*id);
259        }
260        arrived
261    }
262
263    /// Forget a traveler (they left) so a rejoin fetches fresh.
264    pub fn forget(&mut self, id: u32) {
265        self.done.remove(&id);
266        self.pending.retain(|(p, _)| *p != id);
267    }
268}
269
270/// Parse a descriptor colour (`"#rrggbb"`) into linear-ish RGBA for rendering.
271pub fn parse_color(hex: &str) -> Option<[f32; 4]> {
272    let h = hex.strip_prefix('#')?;
273    if h.len() != 6 {
274        return None;
275    }
276    let byte = |i: usize| u8::from_str_radix(&h[i..i + 2], 16).ok();
277    Some([
278        f32::from(byte(0)?) / 255.0,
279        f32::from(byte(2)?) / 255.0,
280        f32::from(byte(4)?) / 255.0,
281        1.0,
282    ])
283}
284
285/// Decode a JWT's payload segment without verifying — the holder reading its
286/// own claims. Returns `None` for opaque (non-JWT) tokens.
287fn decode_claims(token: &str) -> Option<Claims> {
288    let payload = token.split('.').nth(1)?;
289    let bytes = base64url_decode(payload)?;
290    serde_json::from_slice(&bytes).ok()
291}
292
293/// Minimal base64url (RFC 4648 §5, padding optional) — just enough to read a
294/// JWT payload without pulling a dependency into the lean browser build.
295fn base64url_decode(s: &str) -> Option<Vec<u8>> {
296    fn val(c: u8) -> Option<u32> {
297        match c {
298            b'A'..=b'Z' => Some(u32::from(c - b'A')),
299            b'a'..=b'z' => Some(u32::from(c - b'a') + 26),
300            b'0'..=b'9' => Some(u32::from(c - b'0') + 52),
301            b'-' => Some(62),
302            b'_' => Some(63),
303            _ => None,
304        }
305    }
306    let s = s.trim_end_matches('=');
307    let mut out = Vec::with_capacity(s.len() * 3 / 4);
308    let (mut buf, mut bits) = (0u32, 0u32);
309    for &c in s.as_bytes() {
310        buf = (buf << 6) | val(c)?;
311        bits += 6;
312        if bits >= 8 {
313            bits -= 8;
314            out.push((buf >> bits) as u8);
315        }
316    }
317    Some(out)
318}
319
320/// Blocking fetch on a throwaway current-thread runtime (reqwest is async-only
321/// in this workspace). Runs on a background thread, so blocking is fine here.
322fn fetch_blocking(url: &str) -> Result<Descriptor, String> {
323    let rt = tokio::runtime::Builder::new_current_thread()
324        .enable_all()
325        .build()
326        .map_err(|e| e.to_string())?;
327    rt.block_on(async {
328        let resp = reqwest::get(url).await.map_err(|e| e.to_string())?;
329        let status = resp.status();
330        if status.as_u16() == 410 {
331            // Erasure (spec §6): the identity went dark. Render anonymous.
332            return Err("passport erased (410 Gone)".into());
333        }
334        if !status.is_success() {
335            return Err(format!("HTTP {}", status.as_u16()));
336        }
337        let text = resp.text().await.map_err(|e| e.to_string())?;
338        serde_json::from_str(&text).map_err(|e| e.to_string())
339    })
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    /// Build an unsigned JWT-shaped token around a JSON payload.
347    fn token_with_payload(payload: &str) -> String {
348        fn b64url(data: &[u8]) -> String {
349            const ALPHABET: &[u8] =
350                b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
351            let mut out = String::new();
352            for chunk in data.chunks(3) {
353                let mut buf = [0u8; 3];
354                buf[..chunk.len()].copy_from_slice(chunk);
355                let n = (u32::from(buf[0]) << 16) | (u32::from(buf[1]) << 8) | u32::from(buf[2]);
356                let chars = [(n >> 18) & 63, (n >> 12) & 63, (n >> 6) & 63, n & 63];
357                for (i, &c) in chars.iter().enumerate() {
358                    if i <= chunk.len() {
359                        out.push(ALPHABET[c as usize] as char);
360                    }
361                }
362            }
363            out
364        }
365        format!(
366            "{}.{}.sig",
367            b64url(br#"{"alg":"none"}"#),
368            b64url(payload.as_bytes())
369        )
370    }
371
372    /// The avatar-v0.1 descriptor envelope carries the SHARED `AvatarSpec` —
373    /// the same JSON the Unity package and the web Studio persist.
374    #[test]
375    fn descriptor_carries_the_shared_avatar_spec() {
376        let d: Descriptor = serde_json::from_str(
377            r##"{
378              "passport": "0.1",
379              "identity": { "id": "did:pixygon:abc", "name": "Anders" },
380              "avatar": {
381                "spec": { "bodyHeight": 0.7, "parts": { "Body": 11010001, "Jacket": 123456 } },
382                "colors": { "skin": "#c8956c" },
383                "assets": "https://api.pixygon.io/v1/avatar/assets"
384              }
385            }"##,
386        )
387        .unwrap();
388        let avatar = d.avatar.expect("avatar envelope");
389        let spec = avatar.spec.expect("shared spec");
390        assert_eq!(spec.body_height, 0.7);
391        assert_eq!(spec.get(infinite_avatar::AvatarSlot::Jacket), 123456);
392        assert_eq!(spec.get(infinite_avatar::AvatarSlot::Body), 11010001);
393        assert_eq!(
394            avatar.assets.as_deref(),
395            Some("https://api.pixygon.io/v1/avatar/assets")
396        );
397        // The legacy draft envelope still parses (spec absent, base/worn kept).
398        let legacy: Descriptor =
399            serde_json::from_str(r#"{ "avatar": { "base": 11010001, "worn": [21050001] } }"#)
400                .unwrap();
401        let a = legacy.avatar.unwrap();
402        assert!(a.spec.is_none());
403        assert_eq!(a.base, Some(11010001));
404    }
405
406    #[test]
407    fn decodes_the_spec_claims() {
408        let token = token_with_payload(
409            r#"{"iss":"https://api.pixygon.io/v1/passport",
410                "sub":"did:pixygon:6981e8ed",
411                "name":"Anders",
412                "avatar":"https://api.pixygon.io/v1/passport/avatar/did:pixygon:6981e8ed",
413                "scopes":["presence","commerce"],
414                "iat":1789000000,"exp":1789086400}"#,
415        );
416        let p = Passport::from_token(token).unwrap();
417        assert_eq!(p.sub(), Some("did:pixygon:6981e8ed"));
418        assert_eq!(p.display_name(), Some("Anders"));
419        assert_eq!(p.claims().scopes, ["presence", "commerce"]);
420        assert_eq!(
421            p.claims().avatar.as_deref(),
422            Some("https://api.pixygon.io/v1/passport/avatar/did:pixygon:6981e8ed")
423        );
424        assert_eq!(p.claims().exp, Some(1789086400));
425    }
426
427    #[test]
428    fn opaque_tokens_still_carry() {
429        // A dev relay running open accepts any string — the browser must too.
430        let p = Passport::from_token("not-a-jwt").unwrap();
431        assert_eq!(p.token(), "not-a-jwt");
432        assert_eq!(p.sub(), None);
433        assert_eq!(p.display_name(), None);
434    }
435
436    #[test]
437    fn empty_token_is_anonymous() {
438        assert!(Passport::from_token("").is_none());
439        assert!(Passport::from_token("   ").is_none());
440    }
441
442    #[test]
443    fn parses_the_descriptor_shape() {
444        let json = r##"{
445            "passport": "0.1",
446            "identity": { "id": "did:pixygon:6981e8ed", "name": "Anders" },
447            "avatar": {
448                "base": 11010001,
449                "worn": [21050001, 28010002],
450                "colors": { "skin": "#c8956c", "hair": "#2a1f1a" },
451                "assets": "https://cdn.pixygon.io/avatars/"
452            },
453            "consent": { "version": 3 },
454            "home": "thread://pixygon.io/the-archive"
455        }"##;
456        let d: Descriptor = serde_json::from_str(json).unwrap();
457        assert_eq!(d.identity.name, "Anders");
458        let a = d.avatar.unwrap();
459        assert_eq!(a.base, Some(11010001));
460        assert_eq!(a.worn, [21050001, 28010002]);
461        assert_eq!(a.colors.get("skin").map(String::as_str), Some("#c8956c"));
462        assert_eq!(d.home.as_deref(), Some("thread://pixygon.io/the-archive"));
463    }
464
465    #[test]
466    fn descriptor_name_wins_over_claim() {
467        let token = token_with_payload(r#"{"sub":"did:x:1","name":"OldName"}"#);
468        let mut p = Passport::from_token(token).unwrap();
469        assert_eq!(p.display_name(), Some("OldName"));
470        p.descriptor = Some(Descriptor {
471            identity: DescriptorIdentity {
472                id: "did:x:1".into(),
473                name: "Fresh".into(),
474            },
475            ..Default::default()
476        });
477        // The descriptor is fetched fresh each session — it is the later word.
478        assert_eq!(p.display_name(), Some("Fresh"));
479    }
480
481    #[test]
482    fn parses_descriptor_colors() {
483        let c = parse_color("#c8956c").unwrap();
484        assert!((c[0] - 200.0 / 255.0).abs() < 1e-6);
485        assert!((c[1] - 149.0 / 255.0).abs() < 1e-6);
486        assert!((c[2] - 108.0 / 255.0).abs() < 1e-6);
487        assert_eq!(c[3], 1.0);
488        assert!(parse_color("c8956c").is_none(), "requires the # prefix");
489        assert!(parse_color("#fff").is_none(), "short form unsupported");
490        assert!(parse_color("#zzzzzz").is_none());
491    }
492
493    #[test]
494    fn base64url_handles_padding_and_url_chars() {
495        assert_eq!(
496            base64url_decode("aGVsbG8").as_deref(),
497            Some(b"hello".as_slice())
498        );
499        assert_eq!(
500            base64url_decode("aGVsbG8=").as_deref(),
501            Some(b"hello".as_slice())
502        );
503        // '-' and '_' are the url-safe 62/63.
504        assert_eq!(
505            base64url_decode("-_8").as_deref(),
506            Some([0xfb, 0xff].as_slice())
507        );
508        assert!(base64url_decode("not base64!").is_none());
509    }
510}