An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: address PR review feedback for get_device_relay

- Remove duplicate seed_device from auth.rs tests; use shared test_utils version
- Update crates/relay/CLAUDE.md: add get_device_relay.rs row, list require_device_token in auth.rs entry
- Reject empty string iroh.endpoint in validate_and_build (matches telemetry guard pattern)
- Add EZPDS_IROH_ENDPOINT env var override in apply_env_overrides
- Add tracing::debug\!(device_id) on require_device_token None path
- Include device_id in non-UTF-8 header warning
- Add device_token_malformed_base64_returns_401 test
- Add debug_assert\! + invariant test for https:// → wss:// derivation
- Tighten iroh_endpoint_absent test to check key absence, not json[key].is_null()

authored by

Malpercio and committed by
Tangled
f36185f6 bb4d510b

+79 -50
+29
crates/common/src/config.rs
··· 238 238 if let Some(v) = env.get("OTEL_SERVICE_NAME") { 239 239 raw.telemetry.service_name = Some(v.clone()); 240 240 } 241 + if let Some(v) = env.get("EZPDS_IROH_ENDPOINT") { 242 + raw.iroh.endpoint = Some(v.clone()); 243 + } 241 244 if let Some(v) = env.get("EZPDS_ADMIN_TOKEN") { 242 245 raw.admin_token = Some(v.clone()); 243 246 } ··· 332 335 .service_name 333 336 .unwrap_or(telemetry_defaults.service_name), 334 337 }; 338 + 339 + if raw.iroh.endpoint.as_deref() == Some("") { 340 + return Err(ConfigError::Invalid( 341 + "iroh.endpoint must not be empty".to_string(), 342 + )); 343 + } 335 344 336 345 Ok(Config { 337 346 bind_address, ··· 868 877 fn iroh_endpoint_defaults_to_none() { 869 878 let config = validate_and_build(minimal_raw()).unwrap(); 870 879 assert_eq!(config.iroh.endpoint, None); 880 + } 881 + 882 + #[test] 883 + fn env_override_iroh_endpoint() { 884 + let env = HashMap::from([("EZPDS_IROH_ENDPOINT".to_string(), "nodeabc123".to_string())]); 885 + let raw = apply_env_overrides(minimal_raw(), &env).unwrap(); 886 + let config = validate_and_build(raw).unwrap(); 887 + assert_eq!(config.iroh.endpoint, Some("nodeabc123".to_string())); 888 + } 889 + 890 + #[test] 891 + fn iroh_endpoint_empty_string_returns_error() { 892 + let mut raw = minimal_raw(); 893 + raw.iroh.endpoint = Some(String::new()); 894 + let err = validate_and_build(raw).unwrap_err(); 895 + assert!(matches!(err, ConfigError::Invalid(_))); 896 + assert!( 897 + err.to_string().contains("iroh.endpoint"), 898 + "error message must mention iroh.endpoint" 899 + ); 871 900 } 872 901 873 902 #[test]
+3 -2
crates/relay/CLAUDE.md
··· 1 1 # Relay Crate 2 2 3 - Last verified: 2026-03-25 3 + Last verified: 2026-03-26 4 4 5 5 ## Purpose 6 6 ··· 75 75 | `create_mobile_account.rs` | `POST /v1/accounts/mobile` | 76 76 | `create_signing_key.rs` | `POST /v1/signing-keys` | 77 77 | `register_device.rs` | `POST /v1/devices` | 78 + | `get_device_relay.rs` | `GET /v1/devices/:id/relay` | 78 79 | `describe_server.rs` | `GET /xrpc/com.atproto.server.describeServer` | 79 80 | `resolve_handle.rs` | `GET /xrpc/com.atproto.identity.resolveHandle` | 80 81 | `claim_codes.rs` | Claim code management | 81 82 | `get_relay_signing_key.rs` | `GET /v1/signing-keys` | 82 83 | `health.rs` | `GET /health` | 83 - | `auth.rs` | Route-level auth middleware (`require_session`, `require_pending_session`) | 84 + | `auth.rs` | Route-level auth middleware (`require_admin_token`, `require_pending_session`, `require_session`, `require_device_token`) | 84 85 | `token.rs` | Bearer token generation helpers | 85 86 86 87 ## Hard Rules
+17 -45
crates/relay/src/routes/auth.rs
··· 154 154 v.to_str() 155 155 .inspect_err(|_| { 156 156 tracing::warn!( 157 + device_id = %device_id, 157 158 "Authorization header contains non-UTF-8 bytes; treating as absent" 158 159 ); 159 160 }) ··· 179 180 tracing::error!(error = %e, "failed to query device token"); 180 181 ApiError::new(ErrorCode::InternalError, "device lookup failed") 181 182 })?; 183 + 184 + if found.is_none() { 185 + tracing::debug!(device_id = %device_id, "no device matched id+token_hash"); 186 + } 182 187 183 188 found 184 189 .map(|_| ()) ··· 633 638 634 639 // ── require_device_token tests ──────────────────────────────────────────── 635 640 636 - /// Seed a device row and return (device_id, plaintext_token). 637 - async fn seed_device(db: &sqlx::SqlitePool) -> (String, String) { 638 - use crate::routes::token::generate_token; 639 - use uuid::Uuid; 640 - 641 - let claim_code = format!("TEST-{}", Uuid::new_v4()); 642 - sqlx::query( 643 - "INSERT INTO claim_codes (code, expires_at, created_at) \ 644 - VALUES (?, datetime('now', '+1 hour'), datetime('now'))", 645 - ) 646 - .bind(&claim_code) 647 - .execute(db) 648 - .await 649 - .unwrap(); 650 - 651 - let account_id = Uuid::new_v4().to_string(); 652 - sqlx::query( 653 - "INSERT INTO pending_accounts \ 654 - (id, email, handle, tier, claim_code, created_at) \ 655 - VALUES (?, ?, ?, 'free', ?, datetime('now'))", 656 - ) 657 - .bind(&account_id) 658 - .bind(format!("test{}@example.com", &account_id[..8])) 659 - .bind(format!("test{}.example.com", &account_id[..8])) 660 - .bind(&claim_code) 661 - .execute(db) 662 - .await 663 - .unwrap(); 664 - 665 - let device_id = Uuid::new_v4().to_string(); 666 - let token = generate_token(); 667 - sqlx::query( 668 - "INSERT INTO devices \ 669 - (id, account_id, platform, public_key, device_token_hash, created_at, last_seen_at) \ 670 - VALUES (?, ?, 'ios', 'test_pubkey', ?, datetime('now'), datetime('now'))", 671 - ) 672 - .bind(&device_id) 673 - .bind(&account_id) 674 - .bind(&token.hash) 675 - .execute(db) 676 - .await 677 - .unwrap(); 678 - 679 - (device_id, token.plaintext) 680 - } 641 + use crate::routes::test_utils::seed_device; 681 642 682 643 fn bearer(token: &str) -> HeaderMap { 683 644 let mut h = HeaderMap::new(); ··· 727 688 require_device_token(&bearer(&token), &device_id, &state.db) 728 689 .await 729 690 .expect("valid device token must succeed"); 691 + } 692 + 693 + #[tokio::test] 694 + async fn device_token_malformed_base64_returns_401() { 695 + // "!!!" is not valid base64url — hash_bearer_token must reject it before any DB query. 696 + let state = test_state().await; 697 + let (device_id, _) = seed_device(&state.db).await; 698 + let err = require_device_token(&bearer("!!!not-base64url!!!"), &device_id, &state.db) 699 + .await 700 + .unwrap_err(); 701 + assert_eq!(err.status_code(), 401); 730 702 } 731 703 }
+30 -3
crates/relay/src/routes/get_device_relay.rs
··· 33 33 require_device_token(&headers, &device_id, &state.db).await?; 34 34 35 35 let relay_url = state.config.public_url.clone(); 36 + // validate_and_build enforces public_url.starts_with("https://"), so this substitution 37 + // always produces a wss:// URL. The assert catches any future relaxation of that invariant. 38 + debug_assert!( 39 + relay_url.starts_with("https://"), 40 + "public_url must start with https://, got: {relay_url:?}" 41 + ); 36 42 let websocket_url = relay_url.replacen("https://", "wss://", 1); 37 43 let iroh_endpoint = state.config.iroh.endpoint.clone(); 38 44 ··· 134 140 assert_eq!(response.status(), StatusCode::OK); 135 141 let json = body_json(response).await; 136 142 assert!( 137 - json["irohEndpoint"].is_null(), 138 - "irohEndpoint must be absent when not configured; got: {:?}", 139 - json["irohEndpoint"] 143 + !json.as_object().unwrap().contains_key("irohEndpoint"), 144 + "irohEndpoint key must be absent from JSON when not configured; got: {json}" 140 145 ); 141 146 } 142 147 ··· 158 163 159 164 let json = body_json(response).await; 160 165 assert_eq!(json["irohEndpoint"], "abc123nodeid"); 166 + } 167 + 168 + #[tokio::test] 169 + async fn websocket_url_is_derived_from_relay_url_by_replacing_https_scheme() { 170 + // Documents the invariant: websocketUrl is always relay_url with https:// → wss://. 171 + // validate_and_build requires public_url to start with https://, so this is safe. 172 + let state = test_state().await; 173 + let (device_id, token) = seed_device(&state.db).await; 174 + 175 + let response = app(state.clone()) 176 + .oneshot(get_device_relay(&device_id, &token)) 177 + .await 178 + .unwrap(); 179 + 180 + let json = body_json(response).await; 181 + let relay_url = json["relayUrl"].as_str().unwrap(); 182 + let websocket_url = json["websocketUrl"].as_str().unwrap(); 183 + assert!( 184 + relay_url.starts_with("https://"), 185 + "relayUrl must start with https://" 186 + ); 187 + assert_eq!(websocket_url, relay_url.replacen("https://", "wss://", 1)); 161 188 } 162 189 163 190 // ── Auth failures ─────────────────────────────────────────────────────────