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(relay): add GET /v1/relay/keys endpoint to expose active signing key

authored by

Malpercio and committed by
Tangled
4197999e 25a0531a

+164 -1
+11
bruno/get_relay_keys.bru
··· 1 + meta { 2 + name: Get Relay Keys 3 + type: http 4 + seq: 11 5 + } 6 + 7 + get { 8 + url: {{baseUrl}}/v1/relay/keys 9 + body: none 10 + auth: none 11 + }
+5 -1
crates/relay/src/app.rs
··· 20 20 use crate::routes::create_mobile_account::create_mobile_account; 21 21 use crate::routes::create_signing_key::create_signing_key; 22 22 use crate::routes::describe_server::describe_server; 23 + use crate::routes::get_relay_signing_key::get_relay_signing_key; 23 24 use crate::routes::health::health; 24 25 use crate::routes::register_device::register_device; 25 26 use crate::routes::resolve_handle::resolve_handle_handler; ··· 119 120 .route("/v1/devices", post(register_device)) 120 121 .route("/v1/dids", post(create_did_handler)) 121 122 .route("/v1/handles", post(create_handle_handler)) 122 - .route("/v1/relay/keys", post(create_signing_key)) 123 + .route( 124 + "/v1/relay/keys", 125 + get(get_relay_signing_key).post(create_signing_key), 126 + ) 123 127 .layer(CorsLayer::permissive()) 124 128 .layer(TraceLayer::new_for_http().make_span_with(OtelMakeSpan)) 125 129 .with_state(state)
+147
crates/relay/src/routes/get_relay_signing_key.rs
··· 1 + // pattern: Imperative Shell 2 + // 3 + // Gathers: DB pool (via AppState) 4 + // Processes: SELECT most recently created signing key 5 + // Returns: JSON { keyId, publicKey, algorithm } on success; 503 if no key provisioned 6 + 7 + use axum::{extract::State, response::Json}; 8 + use serde::Serialize; 9 + 10 + use common::{ApiError, ErrorCode}; 11 + 12 + use crate::app::AppState; 13 + 14 + // Response uses camelCase per JSON API convention (keyId, publicKey). 15 + #[derive(Serialize)] 16 + #[serde(rename_all = "camelCase")] 17 + pub struct GetRelaySigningKeyResponse { 18 + key_id: String, 19 + public_key: String, 20 + algorithm: String, 21 + } 22 + 23 + pub async fn get_relay_signing_key( 24 + State(state): State<AppState>, 25 + ) -> Result<Json<GetRelaySigningKeyResponse>, ApiError> { 26 + let row: Option<(String, String, String)> = sqlx::query_as( 27 + "SELECT id, public_key, algorithm \ 28 + FROM relay_signing_keys \ 29 + ORDER BY created_at DESC \ 30 + LIMIT 1", 31 + ) 32 + .fetch_optional(&state.db) 33 + .await 34 + .map_err(|e| { 35 + tracing::error!(error = %e, "failed to query relay signing key"); 36 + ApiError::new(ErrorCode::InternalError, "failed to query signing key") 37 + })?; 38 + 39 + let (id, public_key, algorithm) = row.ok_or_else(|| { 40 + ApiError::new(ErrorCode::ServiceUnavailable, "no signing key provisioned") 41 + })?; 42 + 43 + Ok(Json(GetRelaySigningKeyResponse { 44 + key_id: id, 45 + public_key, 46 + algorithm, 47 + })) 48 + } 49 + 50 + #[cfg(test)] 51 + mod tests { 52 + use axum::{ 53 + body::Body, 54 + http::{Request, StatusCode}, 55 + }; 56 + use tower::ServiceExt; 57 + 58 + use crate::app::{app, test_state}; 59 + 60 + /// Insert a signing key row directly into the test DB. 61 + /// `created_at` is an ISO 8601 UTC string, e.g. `"2026-01-01T00:00:00"`. 62 + /// 63 + /// `private_key_encrypted` is a NOT NULL column, but the GET handler never reads it, 64 + /// so any valid base64 value satisfies the constraint. The real format is 65 + /// base64(nonce(12) || ciphertext(32) || tag(16)) = 80 base64 chars. The 84-char 66 + /// placeholder below (60 zero-bytes base64-encoded + padding) is intentionally a 67 + /// dummy — replace with a correct 80-char value if a test ever needs to read 68 + /// private_key_encrypted back. 69 + async fn insert_test_key(db: &sqlx::SqlitePool, key_id: &str, created_at: &str) { 70 + sqlx::query( 71 + "INSERT INTO relay_signing_keys \ 72 + (id, algorithm, public_key, private_key_encrypted, created_at) \ 73 + VALUES (?, 'p256', 'zTestPublicKey123', 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==', ?)", 74 + ) 75 + .bind(key_id) 76 + .bind(created_at) 77 + .execute(db) 78 + .await 79 + .unwrap(); 80 + } 81 + 82 + /// Build a GET /v1/relay/keys request with no Authorization header (public endpoint). 83 + fn get_keys() -> Request<Body> { 84 + Request::builder() 85 + .method("GET") 86 + .uri("/v1/relay/keys") 87 + .body(Body::empty()) 88 + .unwrap() 89 + } 90 + 91 + // MM-146.AC1.3: Returns 503 when no signing key is provisioned. 92 + #[tokio::test] 93 + async fn get_relay_keys_returns_503_when_no_key_provisioned() { 94 + let response = app(test_state().await).oneshot(get_keys()).await.unwrap(); 95 + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); 96 + } 97 + 98 + // MM-146.AC1.1: Returns 200 with { keyId, publicKey, algorithm } when a key is provisioned. 99 + #[tokio::test] 100 + async fn get_relay_keys_returns_200_with_active_key() { 101 + let state = test_state().await; 102 + insert_test_key(&state.db, "did:key:zTestKey1", "2026-01-01T00:00:00").await; 103 + 104 + let response = app(state).oneshot(get_keys()).await.unwrap(); 105 + 106 + assert_eq!(response.status(), StatusCode::OK); 107 + let body = axum::body::to_bytes(response.into_body(), 4096) 108 + .await 109 + .unwrap(); 110 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 111 + assert_eq!(json["keyId"], "did:key:zTestKey1"); 112 + assert_eq!(json["algorithm"], "p256"); 113 + assert!(json["publicKey"].is_string(), "publicKey must be present"); 114 + } 115 + 116 + // MM-146.AC1.2: Returns the most recently created key when multiple keys exist. 117 + #[tokio::test] 118 + async fn get_relay_keys_returns_most_recently_created_key() { 119 + let state = test_state().await; 120 + insert_test_key(&state.db, "did:key:zOlderKey", "2026-01-01T00:00:00").await; 121 + insert_test_key(&state.db, "did:key:zNewerKey", "2026-01-02T00:00:00").await; 122 + 123 + let response = app(state).oneshot(get_keys()).await.unwrap(); 124 + 125 + let body = axum::body::to_bytes(response.into_body(), 4096) 126 + .await 127 + .unwrap(); 128 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 129 + assert_eq!( 130 + json["keyId"], "did:key:zNewerKey", 131 + "must return the key with the most recent created_at" 132 + ); 133 + } 134 + 135 + // MM-146.AC1.4: Endpoint requires no authentication. 136 + #[tokio::test] 137 + async fn get_relay_keys_requires_no_authentication() { 138 + // test_state() has no admin_token configured. 139 + // get_keys() sends no Authorization header. 140 + // If the endpoint incorrectly required auth, this would return 401 instead of 200. 141 + let state = test_state().await; 142 + insert_test_key(&state.db, "did:key:zPublicKey", "2026-01-01T00:00:00").await; 143 + 144 + let response = app(state).oneshot(get_keys()).await.unwrap(); 145 + assert_eq!(response.status(), StatusCode::OK); 146 + } 147 + }
+1
crates/relay/src/routes/mod.rs
··· 6 6 pub mod create_mobile_account; 7 7 pub mod create_signing_key; 8 8 pub mod describe_server; 9 + pub mod get_relay_signing_key; 9 10 pub mod health; 10 11 pub mod register_device; 11 12 pub mod resolve_handle;