···11+// pattern: Imperative Shell
22+//
33+// Gathers: DB pool (via AppState)
44+// Processes: SELECT most recently created signing key
55+// Returns: JSON { keyId, publicKey, algorithm } on success; 503 if no key provisioned
66+77+use axum::{extract::State, response::Json};
88+use serde::Serialize;
99+1010+use common::{ApiError, ErrorCode};
1111+1212+use crate::app::AppState;
1313+1414+// Response uses camelCase per JSON API convention (keyId, publicKey).
1515+#[derive(Serialize)]
1616+#[serde(rename_all = "camelCase")]
1717+pub struct GetRelaySigningKeyResponse {
1818+ key_id: String,
1919+ public_key: String,
2020+ algorithm: String,
2121+}
2222+2323+pub async fn get_relay_signing_key(
2424+ State(state): State<AppState>,
2525+) -> Result<Json<GetRelaySigningKeyResponse>, ApiError> {
2626+ let row: Option<(String, String, String)> = sqlx::query_as(
2727+ "SELECT id, public_key, algorithm \
2828+ FROM relay_signing_keys \
2929+ ORDER BY created_at DESC \
3030+ LIMIT 1",
3131+ )
3232+ .fetch_optional(&state.db)
3333+ .await
3434+ .map_err(|e| {
3535+ tracing::error!(error = %e, "failed to query relay signing key");
3636+ ApiError::new(ErrorCode::InternalError, "failed to query signing key")
3737+ })?;
3838+3939+ let (id, public_key, algorithm) = row.ok_or_else(|| {
4040+ ApiError::new(ErrorCode::ServiceUnavailable, "no signing key provisioned")
4141+ })?;
4242+4343+ Ok(Json(GetRelaySigningKeyResponse {
4444+ key_id: id,
4545+ public_key,
4646+ algorithm,
4747+ }))
4848+}
4949+5050+#[cfg(test)]
5151+mod tests {
5252+ use axum::{
5353+ body::Body,
5454+ http::{Request, StatusCode},
5555+ };
5656+ use tower::ServiceExt;
5757+5858+ use crate::app::{app, test_state};
5959+6060+ /// Insert a signing key row directly into the test DB.
6161+ /// `created_at` is an ISO 8601 UTC string, e.g. `"2026-01-01T00:00:00"`.
6262+ ///
6363+ /// `private_key_encrypted` is a NOT NULL column, but the GET handler never reads it,
6464+ /// so any valid base64 value satisfies the constraint. The real format is
6565+ /// base64(nonce(12) || ciphertext(32) || tag(16)) = 80 base64 chars. The 84-char
6666+ /// placeholder below (60 zero-bytes base64-encoded + padding) is intentionally a
6767+ /// dummy — replace with a correct 80-char value if a test ever needs to read
6868+ /// private_key_encrypted back.
6969+ async fn insert_test_key(db: &sqlx::SqlitePool, key_id: &str, created_at: &str) {
7070+ sqlx::query(
7171+ "INSERT INTO relay_signing_keys \
7272+ (id, algorithm, public_key, private_key_encrypted, created_at) \
7373+ VALUES (?, 'p256', 'zTestPublicKey123', 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==', ?)",
7474+ )
7575+ .bind(key_id)
7676+ .bind(created_at)
7777+ .execute(db)
7878+ .await
7979+ .unwrap();
8080+ }
8181+8282+ /// Build a GET /v1/relay/keys request with no Authorization header (public endpoint).
8383+ fn get_keys() -> Request<Body> {
8484+ Request::builder()
8585+ .method("GET")
8686+ .uri("/v1/relay/keys")
8787+ .body(Body::empty())
8888+ .unwrap()
8989+ }
9090+9191+ // MM-146.AC1.3: Returns 503 when no signing key is provisioned.
9292+ #[tokio::test]
9393+ async fn get_relay_keys_returns_503_when_no_key_provisioned() {
9494+ let response = app(test_state().await).oneshot(get_keys()).await.unwrap();
9595+ assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
9696+ }
9797+9898+ // MM-146.AC1.1: Returns 200 with { keyId, publicKey, algorithm } when a key is provisioned.
9999+ #[tokio::test]
100100+ async fn get_relay_keys_returns_200_with_active_key() {
101101+ let state = test_state().await;
102102+ insert_test_key(&state.db, "did:key:zTestKey1", "2026-01-01T00:00:00").await;
103103+104104+ let response = app(state).oneshot(get_keys()).await.unwrap();
105105+106106+ assert_eq!(response.status(), StatusCode::OK);
107107+ let body = axum::body::to_bytes(response.into_body(), 4096)
108108+ .await
109109+ .unwrap();
110110+ let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
111111+ assert_eq!(json["keyId"], "did:key:zTestKey1");
112112+ assert_eq!(json["algorithm"], "p256");
113113+ assert!(json["publicKey"].is_string(), "publicKey must be present");
114114+ }
115115+116116+ // MM-146.AC1.2: Returns the most recently created key when multiple keys exist.
117117+ #[tokio::test]
118118+ async fn get_relay_keys_returns_most_recently_created_key() {
119119+ let state = test_state().await;
120120+ insert_test_key(&state.db, "did:key:zOlderKey", "2026-01-01T00:00:00").await;
121121+ insert_test_key(&state.db, "did:key:zNewerKey", "2026-01-02T00:00:00").await;
122122+123123+ let response = app(state).oneshot(get_keys()).await.unwrap();
124124+125125+ let body = axum::body::to_bytes(response.into_body(), 4096)
126126+ .await
127127+ .unwrap();
128128+ let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
129129+ assert_eq!(
130130+ json["keyId"], "did:key:zNewerKey",
131131+ "must return the key with the most recent created_at"
132132+ );
133133+ }
134134+135135+ // MM-146.AC1.4: Endpoint requires no authentication.
136136+ #[tokio::test]
137137+ async fn get_relay_keys_requires_no_authentication() {
138138+ // test_state() has no admin_token configured.
139139+ // get_keys() sends no Authorization header.
140140+ // If the endpoint incorrectly required auth, this would return 401 instead of 200.
141141+ let state = test_state().await;
142142+ insert_test_key(&state.db, "did:key:zPublicKey", "2026-01-01T00:00:00").await;
143143+144144+ let response = app(state).oneshot(get_keys()).await.unwrap();
145145+ assert_eq!(response.status(), StatusCode::OK);
146146+ }
147147+}
+1
crates/relay/src/routes/mod.rs
···66pub mod create_mobile_account;
77pub mod create_signing_key;
88pub mod describe_server;
99+pub mod get_relay_signing_key;
910pub mod health;
1011pub mod register_device;
1112pub mod resolve_handle;