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.

feat: implement GET /v1/devices/:id/relay for relay endpoint discovery

Adds device relay endpoint discovery so provisioned devices know where
to connect. Authenticated with device_token; returns relay_url,
websocket_url, and optional iroh_endpoint from config.

- Add IrohConfig.endpoint (Option<String>) to common config
- Add require_device_token auth helper scoped to device id + token hash
- Implement GET /v1/devices/:id/relay handler with 8 tests
- Add seed_device test helper to test_utils
- Add Bruno collection entry (seq 25)

authored by

Malpercio and committed by
Tangled
bb4d510b e43bd511

+582 -116
+15
bruno/get_device_relay.bru
··· 1 + meta { 2 + name: Get Device Relay 3 + type: http 4 + seq: 25 5 + } 6 + 7 + get { 8 + url: {{baseUrl}}/v1/devices/{{deviceId}}/relay 9 + body: none 10 + auth: bearer 11 + } 12 + 13 + auth:bearer { 14 + token: {{deviceToken}} 15 + }
+26 -2
crates/common/src/config.rs
··· 63 63 #[derive(Debug, Clone, Deserialize, Default)] 64 64 pub struct OAuthConfig {} 65 65 66 - /// Stub for future Iroh networking configuration. 66 + /// Iroh networking configuration. 67 67 #[derive(Debug, Clone, Deserialize, Default)] 68 - pub struct IrohConfig {} 68 + pub struct IrohConfig { 69 + /// Iroh node endpoint for NAT traversal. `None` when not configured. 70 + pub endpoint: Option<String>, 71 + } 69 72 70 73 /// OpenTelemetry telemetry configuration. 71 74 #[derive(Debug, Clone)] ··· 844 847 let err = apply_env_overrides(minimal_raw(), &env).unwrap_err(); 845 848 assert!(matches!(err, ConfigError::Invalid(_))); 846 849 assert!(err.to_string().contains("EZPDS_SIGNING_KEY_MASTER_KEY")); 850 + } 851 + 852 + #[test] 853 + fn iroh_endpoint_parses_from_toml() { 854 + let toml = r#" 855 + data_dir = "/var/pds" 856 + public_url = "https://pds.example.com" 857 + available_user_domains = ["example.com"] 858 + 859 + [iroh] 860 + endpoint = "abc123nodeid" 861 + "#; 862 + let raw: RawConfig = toml::from_str(toml).unwrap(); 863 + let config = validate_and_build(raw).unwrap(); 864 + assert_eq!(config.iroh.endpoint, Some("abc123nodeid".to_string())); 865 + } 866 + 867 + #[test] 868 + fn iroh_endpoint_defaults_to_none() { 869 + let config = validate_and_build(minimal_raw()).unwrap(); 870 + assert_eq!(config.iroh.endpoint, None); 847 871 } 848 872 849 873 #[test]
+3 -1
crates/relay/src/app.rs
··· 23 23 use crate::routes::create_handle::create_handle_handler; 24 24 use crate::routes::create_mobile_account::create_mobile_account; 25 25 use crate::routes::create_session::create_session; 26 - use crate::routes::delete_session::delete_session; 27 26 use crate::routes::create_signing_key::create_signing_key; 27 + use crate::routes::delete_session::delete_session; 28 28 use crate::routes::describe_server::describe_server; 29 + use crate::routes::get_device_relay::get_device_relay; 29 30 use crate::routes::get_did::get_did_handler; 30 31 use crate::routes::get_relay_signing_key::get_relay_signing_key; 31 32 use crate::routes::get_session::get_session; ··· 181 182 .route("/v1/accounts/mobile", post(create_mobile_account)) 182 183 .route("/v1/accounts/sessions", post(create_provisioning_session)) 183 184 .route("/v1/devices", post(register_device)) 185 + .route("/v1/devices/:id/relay", get(get_device_relay)) 184 186 .route("/v1/dids", post(create_did_handler)) 185 187 .route("/v1/dids/:did", get(get_did_handler)) 186 188 .route("/v1/handles", post(create_handle_handler))
+1 -4
crates/relay/src/auth/jwt.rs
··· 171 171 /// Validates signature, expiry, and audience (when `server_did` is configured). 172 172 /// Does NOT check that `scope == "com.atproto.refresh"` — callers are responsible 173 173 /// for that check so that the error message can be precise. 174 - pub fn verify_refresh_token( 175 - token: &str, 176 - state: &AppState, 177 - ) -> Result<RefreshTokenClaims, ApiError> { 174 + pub fn verify_refresh_token(token: &str, state: &AppState) -> Result<RefreshTokenClaims, ApiError> { 178 175 let decoding_key = DecodingKey::from_secret(&state.jwt_secret); 179 176 let mut validation = Validation::new(Algorithm::HS256); 180 177 match state.config.server_did.as_deref() {
+15 -23
crates/relay/src/routes/atproto_did.rs
··· 13 13 14 14 use crate::app::AppState; 15 15 16 - pub async fn atproto_did_handler( 17 - Host(host): Host, 18 - State(state): State<AppState>, 19 - ) -> Response { 16 + pub async fn atproto_did_handler(Host(host): Host, State(state): State<AppState>) -> Response { 20 17 // Strip port if present (e.g. "example.com:8080" → "example.com"). 21 18 let handle = host.split(':').next().unwrap_or(&host); 22 19 23 - let row: Option<(String,)> = 24 - match sqlx::query_as("SELECT did FROM handles WHERE handle = ?") 25 - .bind(handle) 26 - .fetch_optional(&state.db) 27 - .await 28 - { 29 - Ok(row) => row, 30 - Err(e) => { 31 - tracing::error!(error = %e, handle = %handle, "DB error in well-known atproto-did"); 32 - return ApiError::new(ErrorCode::InternalError, "handle lookup failed") 33 - .into_response(); 34 - } 35 - }; 20 + let row: Option<(String,)> = match sqlx::query_as("SELECT did FROM handles WHERE handle = ?") 21 + .bind(handle) 22 + .fetch_optional(&state.db) 23 + .await 24 + { 25 + Ok(row) => row, 26 + Err(e) => { 27 + tracing::error!(error = %e, handle = %handle, "DB error in well-known atproto-did"); 28 + return ApiError::new(ErrorCode::InternalError, "handle lookup failed").into_response(); 29 + } 30 + }; 36 31 37 32 match row { 38 - Some((did,)) => ( 39 - StatusCode::OK, 40 - [(header::CONTENT_TYPE, "text/plain")], 41 - did, 42 - ) 43 - .into_response(), 33 + Some((did,)) => { 34 + (StatusCode::OK, [(header::CONTENT_TYPE, "text/plain")], did).into_response() 35 + } 44 36 None => StatusCode::NOT_FOUND.into_response(), 45 37 } 46 38 }
+153
crates/relay/src/routes/auth.rs
··· 130 130 }) 131 131 } 132 132 133 + /// Authenticate a device Bearer token for a specific device ID. 134 + /// 135 + /// Extracts the Bearer token from the Authorization header, SHA-256 hashes it, and 136 + /// queries `devices WHERE id = ? AND device_token_hash = ?`. The `device_id` scope 137 + /// ensures that a token belonging to device A cannot authenticate requests for device B. 138 + /// 139 + /// # Errors 140 + /// Returns `ApiError::Unauthorized` if: 141 + /// - The Authorization header is missing or malformed 142 + /// - The token is not valid base64url 143 + /// - No device matches both the `device_id` and the token hash 144 + pub async fn require_device_token( 145 + headers: &HeaderMap, 146 + device_id: &str, 147 + db: &sqlx::SqlitePool, 148 + ) -> Result<(), ApiError> { 149 + use crate::routes::token::hash_bearer_token; 150 + 151 + let token = headers 152 + .get(axum::http::header::AUTHORIZATION) 153 + .and_then(|v| { 154 + v.to_str() 155 + .inspect_err(|_| { 156 + tracing::warn!( 157 + "Authorization header contains non-UTF-8 bytes; treating as absent" 158 + ); 159 + }) 160 + .ok() 161 + }) 162 + .and_then(|v| v.strip_prefix("Bearer ")) 163 + .ok_or_else(|| { 164 + ApiError::new( 165 + ErrorCode::Unauthorized, 166 + "missing or invalid Authorization header", 167 + ) 168 + })?; 169 + 170 + let token_hash = hash_bearer_token(token)?; 171 + 172 + let found: Option<(String,)> = 173 + sqlx::query_as("SELECT id FROM devices WHERE id = ? AND device_token_hash = ?") 174 + .bind(device_id) 175 + .bind(&token_hash) 176 + .fetch_optional(db) 177 + .await 178 + .map_err(|e| { 179 + tracing::error!(error = %e, "failed to query device token"); 180 + ApiError::new(ErrorCode::InternalError, "device lookup failed") 181 + })?; 182 + 183 + found 184 + .map(|_| ()) 185 + .ok_or_else(|| ApiError::new(ErrorCode::Unauthorized, "invalid device token")) 186 + } 187 + 133 188 /// Authenticate a promoted-account Bearer token. 134 189 /// 135 190 /// Extracts the Bearer token from the Authorization header, SHA-256 hashes the raw ··· 574 629 575 630 let err = require_session(&headers, &state.db).await.unwrap_err(); 576 631 assert_eq!(err.status_code(), 401); 632 + } 633 + 634 + // ── require_device_token tests ──────────────────────────────────────────── 635 + 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 + } 681 + 682 + fn bearer(token: &str) -> HeaderMap { 683 + let mut h = HeaderMap::new(); 684 + h.insert( 685 + axum::http::header::AUTHORIZATION, 686 + format!("Bearer {token}").parse().unwrap(), 687 + ); 688 + h 689 + } 690 + 691 + #[tokio::test] 692 + async fn device_token_missing_authorization_header_returns_401() { 693 + let state = test_state().await; 694 + let (device_id, _) = seed_device(&state.db).await; 695 + let err = require_device_token(&HeaderMap::new(), &device_id, &state.db) 696 + .await 697 + .unwrap_err(); 698 + assert_eq!(err.status_code(), 401); 699 + } 700 + 701 + #[tokio::test] 702 + async fn device_token_wrong_token_returns_401() { 703 + let state = test_state().await; 704 + let (device_id, _) = seed_device(&state.db).await; 705 + // Generate a fresh token that was never stored in DB 706 + let wrong_token = crate::routes::token::generate_token().plaintext; 707 + let err = require_device_token(&bearer(&wrong_token), &device_id, &state.db) 708 + .await 709 + .unwrap_err(); 710 + assert_eq!(err.status_code(), 401); 711 + } 712 + 713 + #[tokio::test] 714 + async fn device_token_valid_token_wrong_device_id_returns_401() { 715 + let state = test_state().await; 716 + let (_, token) = seed_device(&state.db).await; 717 + let err = require_device_token(&bearer(&token), "non-existent-device-id", &state.db) 718 + .await 719 + .unwrap_err(); 720 + assert_eq!(err.status_code(), 401); 721 + } 722 + 723 + #[tokio::test] 724 + async fn device_token_valid_token_and_device_id_returns_ok() { 725 + let state = test_state().await; 726 + let (device_id, token) = seed_device(&state.db).await; 727 + require_device_token(&bearer(&token), &device_id, &state.db) 728 + .await 729 + .expect("valid device token must succeed"); 577 730 } 578 731 }
+40 -26
crates/relay/src/routes/delete_session.rs
··· 40 40 )); 41 41 } 42 42 43 - let jti = claims.jti.ok_or_else(|| { 44 - ApiError::new(ErrorCode::InvalidToken, "invalid refresh token") 45 - })?; 43 + let jti = claims 44 + .jti 45 + .ok_or_else(|| ApiError::new(ErrorCode::InvalidToken, "invalid refresh token"))?; 46 46 47 47 // --- Look up the token — no expiry filter, revocation must always work --- 48 48 let session_id: Option<String> = ··· 142 142 ))) 143 143 .unwrap(); 144 144 let response = app(state.clone()).oneshot(request).await.unwrap(); 145 - assert_eq!(response.status(), StatusCode::OK, "createSession must succeed"); 145 + assert_eq!( 146 + response.status(), 147 + StatusCode::OK, 148 + "createSession must succeed" 149 + ); 146 150 body_json(response).await 147 151 } 148 152 ··· 217 221 .unwrap(); 218 222 assert_eq!(response.status(), StatusCode::OK); 219 223 220 - let session_count: i64 = 221 - sqlx::query_scalar("SELECT COUNT(*) FROM sessions WHERE id = ?") 222 - .bind(&session_id) 223 - .fetch_one(&db) 224 - .await 225 - .unwrap(); 224 + let session_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sessions WHERE id = ?") 225 + .bind(&session_id) 226 + .fetch_one(&db) 227 + .await 228 + .unwrap(); 226 229 assert_eq!(session_count, 0, "session must be deleted"); 227 230 228 231 let token_count: i64 = ··· 231 234 .fetch_one(&db) 232 235 .await 233 236 .unwrap(); 234 - assert_eq!(token_count, 0, "all refresh tokens for the session must be deleted"); 237 + assert_eq!( 238 + token_count, 0, 239 + "all refresh tokens for the session must be deleted" 240 + ); 235 241 } 236 242 237 243 #[tokio::test] ··· 341 347 "expired refresh token must still revoke the session" 342 348 ); 343 349 344 - let session_count: i64 = 345 - sqlx::query_scalar("SELECT COUNT(*) FROM sessions WHERE id = ?") 346 - .bind(&session_id) 347 - .fetch_one(&db) 348 - .await 349 - .unwrap(); 350 - assert_eq!(session_count, 0, "session must be deleted even with expired token"); 350 + let session_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sessions WHERE id = ?") 351 + .bind(&session_id) 352 + .fetch_one(&db) 353 + .await 354 + .unwrap(); 355 + assert_eq!( 356 + session_count, 0, 357 + "session must be deleted even with expired token" 358 + ); 351 359 } 352 360 353 361 // ── Idempotency ─────────────────────────────────────────────────────────── ··· 501 509 ) 502 510 .await 503 511 .unwrap(); 504 - assert_eq!(rotated.status(), StatusCode::OK, "refreshSession must succeed"); 512 + assert_eq!( 513 + rotated.status(), 514 + StatusCode::OK, 515 + "refreshSession must succeed" 516 + ); 505 517 506 518 let session_id: String = 507 519 sqlx::query_scalar("SELECT id FROM sessions WHERE did = 'did:plc:del8'") ··· 520 532 "deleteSession with rotated token must return 200" 521 533 ); 522 534 523 - let session_count: i64 = 524 - sqlx::query_scalar("SELECT COUNT(*) FROM sessions WHERE id = ?") 525 - .bind(&session_id) 526 - .fetch_one(&db) 527 - .await 528 - .unwrap(); 535 + let session_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sessions WHERE id = ?") 536 + .bind(&session_id) 537 + .fetch_one(&db) 538 + .await 539 + .unwrap(); 529 540 assert_eq!(session_count, 0, "session must be deleted"); 530 541 531 542 let token_count: i64 = ··· 534 545 .fetch_one(&db) 535 546 .await 536 547 .unwrap(); 537 - assert_eq!(token_count, 0, "all refresh tokens (including rotated) must be deleted"); 548 + assert_eq!( 549 + token_count, 0, 550 + "all refresh tokens (including rotated) must be deleted" 551 + ); 538 552 } 539 553 540 554 // ── Access token boundary ─────────────────────────────────────────────────
+207
crates/relay/src/routes/get_device_relay.rs
··· 1 + // pattern: Imperative Shell 2 + // 3 + // Gathers: Path param (device_id), Authorization header, AppState (config) 4 + // Processes: device_token auth → read relay URLs from config 5 + // Returns: JSON { relay_url, websocket_url, iroh_endpoint? } on success; ApiError on failure 6 + 7 + use axum::{ 8 + extract::{Path, State}, 9 + http::HeaderMap, 10 + response::Json, 11 + }; 12 + use serde::Serialize; 13 + 14 + use common::ApiError; 15 + 16 + use crate::app::AppState; 17 + use crate::routes::auth::require_device_token; 18 + 19 + #[derive(Serialize)] 20 + #[serde(rename_all = "camelCase")] 21 + pub struct GetDeviceRelayResponse { 22 + relay_url: String, 23 + websocket_url: String, 24 + #[serde(skip_serializing_if = "Option::is_none")] 25 + iroh_endpoint: Option<String>, 26 + } 27 + 28 + pub async fn get_device_relay( 29 + Path(device_id): Path<String>, 30 + State(state): State<AppState>, 31 + headers: HeaderMap, 32 + ) -> Result<Json<GetDeviceRelayResponse>, ApiError> { 33 + require_device_token(&headers, &device_id, &state.db).await?; 34 + 35 + let relay_url = state.config.public_url.clone(); 36 + let websocket_url = relay_url.replacen("https://", "wss://", 1); 37 + let iroh_endpoint = state.config.iroh.endpoint.clone(); 38 + 39 + Ok(Json(GetDeviceRelayResponse { 40 + relay_url, 41 + websocket_url, 42 + iroh_endpoint, 43 + })) 44 + } 45 + 46 + #[cfg(test)] 47 + mod tests { 48 + use axum::{ 49 + body::Body, 50 + http::{Request, StatusCode}, 51 + }; 52 + use std::sync::Arc; 53 + use tower::ServiceExt; 54 + 55 + use crate::app::{app, test_state}; 56 + use crate::routes::test_utils::{body_json, seed_device}; 57 + 58 + // ── Helpers ─────────────────────────────────────────────────────────────── 59 + 60 + fn get_device_relay(device_id: &str, token: &str) -> Request<Body> { 61 + Request::builder() 62 + .method("GET") 63 + .uri(format!("/v1/devices/{device_id}/relay")) 64 + .header("Authorization", format!("Bearer {token}")) 65 + .body(Body::empty()) 66 + .unwrap() 67 + } 68 + 69 + fn get_device_relay_no_auth(device_id: &str) -> Request<Body> { 70 + Request::builder() 71 + .method("GET") 72 + .uri(format!("/v1/devices/{device_id}/relay")) 73 + .body(Body::empty()) 74 + .unwrap() 75 + } 76 + 77 + // ── Happy path ──────────────────────────────────────────────────────────── 78 + 79 + #[tokio::test] 80 + async fn authenticated_device_returns_200() { 81 + let state = test_state().await; 82 + let (device_id, token) = seed_device(&state.db).await; 83 + 84 + let response = app(state) 85 + .oneshot(get_device_relay(&device_id, &token)) 86 + .await 87 + .unwrap(); 88 + 89 + assert_eq!(response.status(), StatusCode::OK); 90 + } 91 + 92 + #[tokio::test] 93 + async fn relay_url_matches_config_public_url() { 94 + let state = test_state().await; 95 + let (device_id, token) = seed_device(&state.db).await; 96 + let expected = state.config.public_url.clone(); 97 + 98 + let response = app(state) 99 + .oneshot(get_device_relay(&device_id, &token)) 100 + .await 101 + .unwrap(); 102 + 103 + let json = body_json(response).await; 104 + assert_eq!(json["relayUrl"], expected); 105 + } 106 + 107 + #[tokio::test] 108 + async fn websocket_url_uses_wss_scheme() { 109 + let state = test_state().await; 110 + let (device_id, token) = seed_device(&state.db).await; 111 + // public_url is "https://test.example.com" in test_state 112 + let expected_ws = "wss://test.example.com"; 113 + 114 + let response = app(state) 115 + .oneshot(get_device_relay(&device_id, &token)) 116 + .await 117 + .unwrap(); 118 + 119 + let json = body_json(response).await; 120 + assert_eq!(json["websocketUrl"], expected_ws); 121 + } 122 + 123 + #[tokio::test] 124 + async fn iroh_endpoint_absent_when_not_configured() { 125 + // IrohConfig.endpoint defaults to None — field must be omitted from JSON 126 + let state = test_state().await; 127 + let (device_id, token) = seed_device(&state.db).await; 128 + 129 + let response = app(state) 130 + .oneshot(get_device_relay(&device_id, &token)) 131 + .await 132 + .unwrap(); 133 + 134 + assert_eq!(response.status(), StatusCode::OK); 135 + let json = body_json(response).await; 136 + assert!( 137 + json["irohEndpoint"].is_null(), 138 + "irohEndpoint must be absent when not configured; got: {:?}", 139 + json["irohEndpoint"] 140 + ); 141 + } 142 + 143 + #[tokio::test] 144 + async fn iroh_endpoint_present_when_configured() { 145 + let base = test_state().await; 146 + let mut config = (*base.config).clone(); 147 + config.iroh.endpoint = Some("abc123nodeid".to_string()); 148 + let state = crate::app::AppState { 149 + config: Arc::new(config), 150 + ..base 151 + }; 152 + let (device_id, token) = seed_device(&state.db).await; 153 + 154 + let response = app(state) 155 + .oneshot(get_device_relay(&device_id, &token)) 156 + .await 157 + .unwrap(); 158 + 159 + let json = body_json(response).await; 160 + assert_eq!(json["irohEndpoint"], "abc123nodeid"); 161 + } 162 + 163 + // ── Auth failures ───────────────────────────────────────────────────────── 164 + 165 + #[tokio::test] 166 + async fn unauthenticated_request_returns_401() { 167 + let state = test_state().await; 168 + let (device_id, _) = seed_device(&state.db).await; 169 + 170 + let response = app(state) 171 + .oneshot(get_device_relay_no_auth(&device_id)) 172 + .await 173 + .unwrap(); 174 + 175 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 176 + } 177 + 178 + #[tokio::test] 179 + async fn wrong_device_token_returns_401() { 180 + let state = test_state().await; 181 + let (device_id, _) = seed_device(&state.db).await; 182 + let wrong_token = crate::routes::token::generate_token().plaintext; 183 + 184 + let response = app(state) 185 + .oneshot(get_device_relay(&device_id, &wrong_token)) 186 + .await 187 + .unwrap(); 188 + 189 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 190 + } 191 + 192 + #[tokio::test] 193 + async fn valid_token_for_different_device_returns_401() { 194 + // Token belongs to device A but path is device B — must be rejected 195 + let state = test_state().await; 196 + let (device_a_id, token_a) = seed_device(&state.db).await; 197 + let (device_b_id, _) = seed_device(&state.db).await; 198 + let _ = device_a_id; 199 + 200 + let response = app(state) 201 + .oneshot(get_device_relay(&device_b_id, &token_a)) 202 + .await 203 + .unwrap(); 204 + 205 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 206 + } 207 + }
+13 -37
crates/relay/src/routes/get_did.rs
··· 67 67 68 68 let doc: Value = response.json().await.map_err(|e| { 69 69 tracing::error!(did = %did, error = %e, "failed to parse plc.directory response"); 70 - ApiError::new(ErrorCode::PlcDirectoryError, "invalid response from plc.directory") 70 + ApiError::new( 71 + ErrorCode::PlcDirectoryError, 72 + "invalid response from plc.directory", 73 + ) 71 74 })?; 72 75 73 76 Ok(Json(doc)) ··· 125 128 }); 126 129 seed_did_document(&state.db, did, doc.clone()).await; 127 130 128 - let response = app(state) 129 - .oneshot(get_did_request(did)) 130 - .await 131 - .unwrap(); 131 + let response = app(state).oneshot(get_did_request(did)).await.unwrap(); 132 132 133 133 assert_eq!(response.status(), StatusCode::OK); 134 134 let body = body_json(response).await; ··· 151 151 .await 152 152 .unwrap(); 153 153 154 - let response = app(state) 155 - .oneshot(get_did_request(did)) 156 - .await 157 - .unwrap(); 154 + let response = app(state).oneshot(get_did_request(did)).await.unwrap(); 158 155 159 156 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); 160 157 let body = body_json(response).await; ··· 185 182 186 183 let state = test_state_with_plc_url(mock_server.uri()).await; 187 184 188 - let response = app(state) 189 - .oneshot(get_did_request(did)) 190 - .await 191 - .unwrap(); 185 + let response = app(state).oneshot(get_did_request(did)).await.unwrap(); 192 186 193 187 assert_eq!(response.status(), StatusCode::OK); 194 188 let body = body_json(response).await; ··· 210 204 211 205 let state = test_state_with_plc_url(mock_server.uri()).await; 212 206 213 - let response = app(state) 214 - .oneshot(get_did_request(did)) 215 - .await 216 - .unwrap(); 207 + let response = app(state).oneshot(get_did_request(did)).await.unwrap(); 217 208 218 209 assert_eq!(response.status(), StatusCode::NOT_FOUND); 219 210 let body = body_json(response).await; ··· 235 226 236 227 let state = test_state_with_plc_url(mock_server.uri()).await; 237 228 238 - let response = app(state) 239 - .oneshot(get_did_request(did)) 240 - .await 241 - .unwrap(); 229 + let response = app(state).oneshot(get_did_request(did)).await.unwrap(); 242 230 243 231 assert_eq!(response.status(), StatusCode::BAD_GATEWAY); 244 232 let body = body_json(response).await; ··· 260 248 261 249 let state = test_state_with_plc_url(mock_server.uri()).await; 262 250 263 - let response = app(state) 264 - .oneshot(get_did_request(did)) 265 - .await 266 - .unwrap(); 251 + let response = app(state).oneshot(get_did_request(did)).await.unwrap(); 267 252 268 253 assert_eq!(response.status(), StatusCode::BAD_GATEWAY); 269 254 let body = body_json(response).await; ··· 289 274 290 275 let state = test_state_with_plc_url(mock_server.uri()).await; 291 276 292 - let response = app(state) 293 - .oneshot(get_did_request(did)) 294 - .await 295 - .unwrap(); 277 + let response = app(state).oneshot(get_did_request(did)).await.unwrap(); 296 278 297 279 assert_eq!(response.status(), StatusCode::BAD_GATEWAY); 298 280 let body = body_json(response).await; ··· 341 323 let state = test_state_with_plc_url(mock_server.uri()).await; 342 324 seed_did_document(&state.db, did, local_doc).await; 343 325 344 - let response = app(state) 345 - .oneshot(get_did_request(did)) 346 - .await 347 - .unwrap(); 326 + let response = app(state).oneshot(get_did_request(did)).await.unwrap(); 348 327 349 328 assert_eq!(response.status(), StatusCode::OK); 350 329 let body = body_json(response).await; ··· 371 350 let plc_url_with_slash = format!("{}/", mock_server.uri()); 372 351 let state = test_state_with_plc_url(plc_url_with_slash).await; 373 352 374 - let response = app(state) 375 - .oneshot(get_did_request(did)) 376 - .await 377 - .unwrap(); 353 + let response = app(state).oneshot(get_did_request(did)).await.unwrap(); 378 354 379 355 assert_eq!(response.status(), StatusCode::OK); 380 356 }
+3 -2
crates/relay/src/routes/mod.rs
··· 1 - pub(crate) mod auth; 2 1 pub mod atproto_did; 2 + pub(crate) mod auth; 3 3 pub mod claim_codes; 4 - pub mod delete_session; 5 4 pub mod create_account; 6 5 pub mod create_did; 7 6 pub mod create_handle; 8 7 pub mod create_mobile_account; 9 8 pub mod create_session; 10 9 pub mod create_signing_key; 10 + pub mod delete_session; 11 11 pub mod describe_server; 12 + pub mod get_device_relay; 12 13 pub mod get_did; 13 14 pub mod get_relay_signing_key; 14 15 pub mod get_session;
+12 -3
crates/relay/src/routes/provisioning_session.rs
··· 51 51 // Check before any DB work to shed load on targeted accounts. 52 52 { 53 53 let mut attempts = state.failed_login_attempts.lock().map_err(|_| { 54 - tracing::error!(phase = "rate_limit_check", "failed_login_attempts mutex is poisoned"); 54 + tracing::error!( 55 + phase = "rate_limit_check", 56 + "failed_login_attempts mutex is poisoned" 57 + ); 55 58 ApiError::new(ErrorCode::InternalError, "internal error") 56 59 })?; 57 60 if is_rate_limited(&mut attempts, &payload.email) { ··· 335 338 .unwrap(); 336 339 337 340 let response = app(state) 338 - .oneshot(post_provisioning_session("nopass@example.com", "anypassword")) 341 + .oneshot(post_provisioning_session( 342 + "nopass@example.com", 343 + "anypassword", 344 + )) 339 345 .await 340 346 .unwrap(); 341 347 ··· 382 388 .unwrap(); 383 389 384 390 let response = app(state.clone()) 385 - .oneshot(post_provisioning_session("corrupt@example.com", "anypassword")) 391 + .oneshot(post_provisioning_session( 392 + "corrupt@example.com", 393 + "anypassword", 394 + )) 386 395 .await 387 396 .unwrap(); 388 397
+45 -18
crates/relay/src/routes/refresh_session.rs
··· 17 17 18 18 use crate::app::AppState; 19 19 use crate::auth::extract_bearer_token; 20 - use crate::auth::jwt::{issue_access_jwt, issue_refresh_jwt, parse_scope, verify_refresh_token, AuthScope}; 20 + use crate::auth::jwt::{ 21 + issue_access_jwt, issue_refresh_jwt, parse_scope, verify_refresh_token, AuthScope, 22 + }; 21 23 22 24 // ── Response type ──────────────────────────────────────────────────────────── 23 25 ··· 53 55 )); 54 56 } 55 57 56 - let jti = claims.jti.ok_or_else(|| { 57 - ApiError::new(ErrorCode::InvalidToken, "invalid refresh token") 58 - })?; 58 + let jti = claims 59 + .jti 60 + .ok_or_else(|| ApiError::new(ErrorCode::InvalidToken, "invalid refresh token"))?; 59 61 60 62 // --- Look up the refresh token in the DB --- 61 63 // `next_jti IS NULL` is not checked here — we need the row regardless to detect replays. ··· 73 75 })?; 74 76 75 77 let (did, session_id, next_jti) = row.ok_or_else(|| { 76 - ApiError::new(ErrorCode::InvalidToken, "refresh token not found or expired") 78 + ApiError::new( 79 + ErrorCode::InvalidToken, 80 + "refresh token not found or expired", 81 + ) 77 82 })?; 78 83 79 84 // --- Replay detection: next_jti being set means this token was already rotated --- ··· 422 427 .fetch_one(&db) 423 428 .await 424 429 .unwrap(); 425 - assert_eq!(next_jti_matches, 1, "old token's next_jti must point to the new jti"); 430 + assert_eq!( 431 + next_jti_matches, 1, 432 + "old token's next_jti must point to the new jti" 433 + ); 426 434 427 435 // New token must exist in the DB. 428 436 let new_exists: Option<String> = ··· 431 439 .fetch_optional(&db) 432 440 .await 433 441 .unwrap(); 434 - assert!(new_exists.is_some(), "new refresh token must be persisted in DB"); 442 + assert!( 443 + new_exists.is_some(), 444 + "new refresh token must be persisted in DB" 445 + ); 435 446 } 436 447 437 448 #[tokio::test] ··· 464 475 .unwrap(); 465 476 assert_eq!(resp2.status(), StatusCode::OK); 466 477 let json2 = body_json(resp2).await; 467 - assert!(json2["accessJwt"].as_str().is_some(), "second rotation must issue new accessJwt"); 468 - assert!(json2["refreshJwt"].as_str().is_some(), "second rotation must issue new refreshJwt"); 478 + assert!( 479 + json2["accessJwt"].as_str().is_some(), 480 + "second rotation must issue new accessJwt" 481 + ); 482 + assert!( 483 + json2["refreshJwt"].as_str().is_some(), 484 + "second rotation must issue new refreshJwt" 485 + ); 469 486 } 470 487 471 488 #[tokio::test] ··· 527 544 .oneshot(post_refresh_session(&refresh_jwt)) 528 545 .await 529 546 .unwrap(); 530 - assert_eq!(replay.status(), StatusCode::UNAUTHORIZED, "replay must be rejected"); 547 + assert_eq!( 548 + replay.status(), 549 + StatusCode::UNAUTHORIZED, 550 + "replay must be rejected" 551 + ); 531 552 let json = body_json(replay).await; 532 553 assert_eq!(json["error"]["code"], "INVALID_TOKEN"); 533 554 } ··· 567 588 .await 568 589 .unwrap(); 569 590 570 - assert_eq!(replay.status(), StatusCode::UNAUTHORIZED, "replay must return 401"); 591 + assert_eq!( 592 + replay.status(), 593 + StatusCode::UNAUTHORIZED, 594 + "replay must return 401" 595 + ); 571 596 let json = body_json(replay).await; 572 597 assert_eq!(json["error"]["code"], "INVALID_TOKEN"); 573 598 574 - let session_count: i64 = 575 - sqlx::query_scalar("SELECT COUNT(*) FROM sessions WHERE id = ?") 576 - .bind(&session_id) 577 - .fetch_one(&db) 578 - .await 579 - .unwrap(); 599 + let session_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sessions WHERE id = ?") 600 + .bind(&session_id) 601 + .fetch_one(&db) 602 + .await 603 + .unwrap(); 580 604 assert_eq!(session_count, 0, "session must be deleted on replay"); 581 605 582 606 let token_count: i64 = ··· 585 609 .fetch_one(&db) 586 610 .await 587 611 .unwrap(); 588 - assert_eq!(token_count, 0, "all refresh tokens for the session must be deleted on replay"); 612 + assert_eq!( 613 + token_count, 0, 614 + "all refresh tokens for the session must be deleted on replay" 615 + ); 589 616 } 590 617 591 618 // ── Error cases ───────────────────────────────────────────────────────────
+49
crates/relay/src/routes/test_utils.rs
··· 104 104 .expect("insert did_document"); 105 105 } 106 106 107 + /// Seed a device row with a fresh device token. Returns `(device_id, plaintext_token)`. 108 + /// 109 + /// Creates a claim code + pending account + device row in one shot. Each call 110 + /// generates unique IDs so the helper is safe to call multiple times on the same pool. 111 + pub async fn seed_device(db: &sqlx::SqlitePool) -> (String, String) { 112 + use crate::routes::token::generate_token; 113 + use uuid::Uuid; 114 + 115 + let claim_code = format!("TEST-{}", Uuid::new_v4()); 116 + sqlx::query( 117 + "INSERT INTO claim_codes (code, expires_at, created_at) \ 118 + VALUES (?, datetime('now', '+1 hour'), datetime('now'))", 119 + ) 120 + .bind(&claim_code) 121 + .execute(db) 122 + .await 123 + .unwrap(); 124 + 125 + let account_id = Uuid::new_v4().to_string(); 126 + sqlx::query( 127 + "INSERT INTO pending_accounts \ 128 + (id, email, handle, tier, claim_code, created_at) \ 129 + VALUES (?, ?, ?, 'free', ?, datetime('now'))", 130 + ) 131 + .bind(&account_id) 132 + .bind(format!("dev{}@example.com", &account_id[..8])) 133 + .bind(format!("dev{}.example.com", &account_id[..8])) 134 + .bind(&claim_code) 135 + .execute(db) 136 + .await 137 + .unwrap(); 138 + 139 + let device_id = Uuid::new_v4().to_string(); 140 + let token = generate_token(); 141 + sqlx::query( 142 + "INSERT INTO devices \ 143 + (id, account_id, platform, public_key, device_token_hash, created_at, last_seen_at) \ 144 + VALUES (?, ?, 'ios', 'test_pubkey', ?, datetime('now'), datetime('now'))", 145 + ) 146 + .bind(&device_id) 147 + .bind(&account_id) 148 + .bind(&token.hash) 149 + .execute(db) 150 + .await 151 + .unwrap(); 152 + 153 + (device_id, token.plaintext) 154 + } 155 + 107 156 /// Deserialise a response body as `serde_json::Value`, consuming the response. 108 157 pub async fn body_json(response: axum::response::Response) -> serde_json::Value { 109 158 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)