1use std::collections::HashMap;
15use std::sync::mpsc::{channel, Receiver, TryRecvError};
16
17use infinite_avatar::AvatarSpec;
18use serde::Deserialize;
19
20#[derive(Debug, Clone, Default, Deserialize)]
24pub struct Claims {
25 #[serde(default)]
26 pub iss: String,
27 #[serde(default)]
29 pub sub: String,
30 #[serde(default)]
31 pub name: Option<String>,
32 #[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#[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#[derive(Debug, Clone, Default, Deserialize)]
72pub struct AvatarDescriptor {
73 #[serde(default)]
75 pub spec: Option<AvatarSpec>,
76 #[serde(default)]
77 pub colors: HashMap<String, String>,
78 #[serde(default)]
80 pub assets: Option<String>,
81 #[serde(default)]
84 pub base: Option<u32>,
85 #[serde(default)]
86 pub worn: Vec<u32>,
87}
88
89pub 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 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 pub fn from_env() -> Option<Self> {
121 Self::from_token(std::env::var("INFINITE_PASSPORT").unwrap_or_default())
122 }
123
124 pub fn token(&self) -> &str {
126 &self.token
127 }
128
129 pub fn claims(&self) -> &Claims {
130 &self.claims
131 }
132
133 pub fn sub(&self) -> Option<&str> {
135 (!self.claims.sub.is_empty()).then_some(self.claims.sub.as_str())
136 }
137
138 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 pub fn descriptor(&self) -> Option<&Descriptor> {
155 self.descriptor.as_ref()
156 }
157
158 pub fn avatar_spec(&self) -> Option<&AvatarSpec> {
161 self.descriptor.as_ref()?.avatar.as_ref()?.spec.as_ref()
162 }
163
164 pub fn descriptor_error(&self) -> Option<&str> {
167 self.error.as_deref()
168 }
169
170 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 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#[derive(Default)]
220pub struct DescriptorPool {
221 pending: Vec<(u32, Receiver<Result<Descriptor, String>>)>,
222 done: std::collections::HashSet<u32>,
224}
225
226impl DescriptorPool {
227 pub fn new() -> Self {
228 Self::default()
229 }
230
231 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 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 pub fn forget(&mut self, id: u32) {
265 self.done.remove(&id);
266 self.pending.retain(|(p, _)| *p != id);
267 }
268}
269
270pub 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
285fn 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
293fn 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
320fn 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 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 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 #[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 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 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 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 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}