A lexicon-driven AppView for ATProto. happyview.dev
backfill firehose jetstream atproto appview oauth lexicon
8
fork

Configure Feed

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

feat(oauth): move third party api client management into built-in XRPCs

Trezy fde532dc cdb69c4b

+1146 -319
+1 -1
.env.example
··· 7 7 # POSTGRES_DB=happyview 8 8 9 9 # HappyView 10 - PUBLIC_URL=http://localhost:3000 10 + PUBLIC_URL=http://127.0.0.1:3000 11 11 SESSION_SECRET=change-me-in-production 12 12 RELAY_URL=https://relay1.us-east.bsky.network 13 13 PORT=3000
+254
src/dev_happyview/create_client.rs
··· 1 + use axum::Json; 2 + use axum::extract::State; 3 + use axum::http::StatusCode; 4 + use axum::response::{IntoResponse, Response}; 5 + use rand::Rng; 6 + use sha2::{Digest, Sha256}; 7 + use uuid::Uuid; 8 + 9 + use super::CreateApiClientInput; 10 + use crate::AppState; 11 + use crate::auth::XrpcClaims; 12 + use crate::db::{adapt_sql, now_rfc3339}; 13 + use crate::error::AppError; 14 + use crate::event_log::{EventLog, Severity, log_event}; 15 + use crate::rate_limit::CheckResult; 16 + 17 + pub async fn create_api_client( 18 + State(state): State<AppState>, 19 + xrpc_claims: XrpcClaims, 20 + Json(input): Json<CreateApiClientInput>, 21 + ) -> Result<Response, AppError> { 22 + // 1. Require DPoP auth 23 + let claims = xrpc_claims 24 + .0 25 + .ok_or_else(|| AppError::Auth("createApiClient requires DPoP authentication".into()))?; 26 + 27 + // 2. Rate-limit the request (procedure type) 28 + let check = if let Some(client_key) = claims.client_key() { 29 + let cost = state 30 + .rate_limiter 31 + .default_cost_for_type(client_key, "procedure"); 32 + Some(state.rate_limiter.check(client_key, cost)) 33 + } else { 34 + None 35 + }; 36 + 37 + if let Some(CheckResult::Limited { 38 + retry_after, 39 + limit, 40 + reset, 41 + }) = check 42 + { 43 + return Err(AppError::RateLimited { 44 + retry_after, 45 + limit, 46 + reset, 47 + }); 48 + } 49 + 50 + // 3. Get client_key from claims, resolve parent client, verify top-level 51 + let client_key_str = claims 52 + .client_key() 53 + .ok_or_else(|| AppError::Auth("createApiClient requires an API client key".into()))?; 54 + 55 + let parent_client = crate::oauth::client_auth::resolve_client_by_key( 56 + &state.db, 57 + state.db_backend, 58 + client_key_str, 59 + ) 60 + .await 61 + .map_err(|_| AppError::Auth("Invalid client".into()))?; 62 + 63 + let parent_check_sql = adapt_sql( 64 + "SELECT parent_client_id, created_by FROM api_clients WHERE id = ?", 65 + state.db_backend, 66 + ); 67 + let parent_row: Option<(Option<String>, String)> = sqlx::query_as(&parent_check_sql) 68 + .bind(&parent_client.id) 69 + .fetch_optional(&state.db) 70 + .await 71 + .map_err(|e| AppError::Internal(format!("failed to check parent status: {e}")))?; 72 + 73 + match parent_row { 74 + Some((Some(_), _)) => { 75 + return Err(AppError::Forbidden( 76 + "Child clients cannot create API clients".into(), 77 + )); 78 + } 79 + Some((None, _)) => { /* ok, top-level */ } 80 + None => return Err(AppError::Auth("Invalid client".into())), 81 + }; 82 + 83 + let user_did = claims.did().to_string(); 84 + 85 + // 4. Validate client_type 86 + if input.client_type != "confidential" && input.client_type != "public" { 87 + return Err(AppError::BadRequest( 88 + "client_type must be 'confidential' or 'public'".into(), 89 + )); 90 + } 91 + 92 + // 5. Check for duplicate client_id_url 93 + let dup_check_sql = adapt_sql( 94 + "SELECT id FROM api_clients WHERE client_id_url = ?", 95 + state.db_backend, 96 + ); 97 + let dup: Option<(String,)> = sqlx::query_as(&dup_check_sql) 98 + .bind(&input.client_id_url) 99 + .fetch_optional(&state.db) 100 + .await 101 + .map_err(|e| AppError::Internal(format!("failed to check client_id_url: {e}")))?; 102 + 103 + if dup.is_some() { 104 + return Err(AppError::Conflict( 105 + "client_id_url already registered".into(), 106 + )); 107 + } 108 + 109 + // 6. Generate `hvc_` client key and optional `hvs_` client secret 110 + let mut random_bytes = [0u8; 16]; 111 + rand::rng().fill(&mut random_bytes); 112 + let child_client_key = format!("hvc_{}", hex::encode(random_bytes)); 113 + 114 + let (client_secret, client_secret_hash) = if input.client_type == "confidential" { 115 + let mut secret_bytes = [0u8; 32]; 116 + rand::rng().fill(&mut secret_bytes); 117 + let secret = format!("hvs_{}", hex::encode(secret_bytes)); 118 + let hash = hex::encode(Sha256::digest(secret.as_bytes())); 119 + (Some(secret), hash) 120 + } else { 121 + (None, String::new()) 122 + }; 123 + 124 + // 7. Insert into `api_clients` table 125 + let id = Uuid::new_v4().to_string(); 126 + let now = now_rfc3339(); 127 + let redirect_uris_json = 128 + serde_json::to_string(&input.redirect_uris).unwrap_or_else(|_| "[]".to_string()); 129 + let allowed_origins_json = input 130 + .allowed_origins 131 + .as_ref() 132 + .map(|origins| serde_json::to_string(origins).unwrap_or_else(|_| "[]".to_string())); 133 + 134 + let insert_sql = adapt_sql( 135 + "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, rate_limit_capacity, rate_limit_refill_rate, client_type, allowed_origins, is_active, created_by, created_at, updated_at, parent_client_id, owner_did) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, 1, ?, ?, ?, ?, ?)", 136 + state.db_backend, 137 + ); 138 + sqlx::query(&insert_sql) 139 + .bind(&id) 140 + .bind(&child_client_key) 141 + .bind(&client_secret_hash) 142 + .bind(&input.name) 143 + .bind(&input.client_id_url) 144 + .bind(&input.client_uri) 145 + .bind(&redirect_uris_json) 146 + .bind(&input.scopes) 147 + .bind(&input.client_type) 148 + .bind(&allowed_origins_json) 149 + .bind(&user_did) // created_by 150 + .bind(&now) // created_at 151 + .bind(&now) // updated_at 152 + .bind(&parent_client.id) // parent_client_id 153 + .bind(&user_did) // owner_did 154 + .execute(&state.db) 155 + .await 156 + .map_err(|e| AppError::Internal(format!("failed to create api client: {e}")))?; 157 + 158 + // 8. Register with OAuth registry and rate limiter 159 + let oauth_params = crate::auth::client_registry::ApiClientOAuthParams { 160 + plc_url: state.config.plc_url.clone(), 161 + state_store: state.oauth_state_store.clone(), 162 + session_store_pool: state.db.clone(), 163 + db_backend: state.db_backend, 164 + }; 165 + if let Err(e) = state.oauth.register_api_client( 166 + &input.client_id_url, 167 + &input.client_uri, 168 + input.redirect_uris.clone(), 169 + &input.scopes, 170 + &oauth_params, 171 + ) { 172 + tracing::warn!( 173 + client_id = %input.client_id_url, 174 + error = %e, 175 + "OAuth client registration failed (DB row created)" 176 + ); 177 + } 178 + 179 + state.rate_limiter.register_client_identity( 180 + child_client_key.clone(), 181 + crate::rate_limit::ClientIdentity { 182 + secret_hash: client_secret_hash.clone(), 183 + client_uri: input.client_uri.clone(), 184 + }, 185 + ); 186 + 187 + let defaults = state.rate_limiter.defaults(); 188 + state.rate_limiter.register_client_config( 189 + child_client_key.clone(), 190 + crate::rate_limit::RateLimitConfig { 191 + capacity: state.config.default_rate_limit_capacity, 192 + refill_rate: state.config.default_rate_limit_refill_rate, 193 + default_query_cost: defaults.query_cost, 194 + default_procedure_cost: defaults.procedure_cost, 195 + default_proxy_cost: defaults.proxy_cost, 196 + }, 197 + ); 198 + 199 + // 9. Log event 200 + log_event( 201 + &state.db, 202 + EventLog { 203 + event_type: "api_client.created".to_string(), 204 + severity: Severity::Info, 205 + actor_did: Some(user_did.clone()), 206 + subject: Some(input.name.clone()), 207 + detail: serde_json::json!({ 208 + "client_key": child_client_key, 209 + "client_id_url": input.client_id_url, 210 + "parent_client_id": parent_client.id, 211 + "self_service": true, 212 + }), 213 + }, 214 + state.db_backend, 215 + ) 216 + .await; 217 + 218 + // 10. Return { client: ApiClientView, clientSecret?: string } 219 + let view = super::ApiClientView { 220 + id, 221 + name: input.name, 222 + client_key: child_client_key, 223 + client_id_url: input.client_id_url, 224 + client_uri: input.client_uri, 225 + redirect_uris: input.redirect_uris, 226 + client_type: input.client_type, 227 + scopes: input.scopes, 228 + allowed_origins: input.allowed_origins.unwrap_or_default(), 229 + is_active: true, 230 + created_at: now, 231 + }; 232 + 233 + let mut body = serde_json::json!({ "client": view }); 234 + if let Some(ref secret) = client_secret { 235 + body["clientSecret"] = serde_json::json!(secret); 236 + } 237 + 238 + let mut response = Json(body).into_response(); 239 + *response.status_mut() = StatusCode::CREATED; 240 + 241 + if let Some(CheckResult::Allowed { 242 + remaining, 243 + limit, 244 + reset, 245 + }) = check 246 + { 247 + let h = response.headers_mut(); 248 + h.insert("RateLimit-Limit", limit.into()); 249 + h.insert("RateLimit-Remaining", remaining.into()); 250 + h.insert("RateLimit-Reset", reset.into()); 251 + } 252 + 253 + Ok(response) 254 + }
+132
src/dev_happyview/delete_client.rs
··· 1 + use axum::Json; 2 + use axum::extract::State; 3 + use axum::response::{IntoResponse, Response}; 4 + 5 + use super::DeleteApiClientInput; 6 + use crate::AppState; 7 + use crate::auth::XrpcClaims; 8 + use crate::db::adapt_sql; 9 + use crate::error::AppError; 10 + use crate::event_log::{EventLog, Severity, log_event}; 11 + use crate::rate_limit::CheckResult; 12 + 13 + pub async fn delete_api_client( 14 + State(state): State<AppState>, 15 + xrpc_claims: XrpcClaims, 16 + Json(input): Json<DeleteApiClientInput>, 17 + ) -> Result<Response, AppError> { 18 + // 1. Require DPoP auth 19 + let claims = xrpc_claims 20 + .0 21 + .ok_or_else(|| AppError::Auth("deleteApiClient requires DPoP authentication".into()))?; 22 + 23 + // 2. Rate-limit the request (procedure type) 24 + let check = if let Some(client_key) = claims.client_key() { 25 + let cost = state 26 + .rate_limiter 27 + .default_cost_for_type(client_key, "procedure"); 28 + Some(state.rate_limiter.check(client_key, cost)) 29 + } else { 30 + None 31 + }; 32 + 33 + if let Some(CheckResult::Limited { 34 + retry_after, 35 + limit, 36 + reset, 37 + }) = check 38 + { 39 + return Err(AppError::RateLimited { 40 + retry_after, 41 + limit, 42 + reset, 43 + }); 44 + } 45 + 46 + let user_did = claims.did().to_string(); 47 + let id = input.id; 48 + 49 + // 3. Look up client_id_url and client_key before deleting (scoped to owner) 50 + let lookup_sql = adapt_sql( 51 + "SELECT client_id_url, client_key FROM api_clients WHERE id = ? AND owner_did = ?", 52 + state.db_backend, 53 + ); 54 + let client_info: Option<(String, String)> = sqlx::query_as(&lookup_sql) 55 + .bind(&id) 56 + .bind(&user_did) 57 + .fetch_optional(&state.db) 58 + .await 59 + .map_err(|e| AppError::Internal(format!("failed to look up api client: {e}")))?; 60 + 61 + // 4. Look up child clients before deleting (ON DELETE CASCADE will remove DB rows) 62 + let children_sql = adapt_sql( 63 + "SELECT client_id_url, client_key FROM api_clients WHERE parent_client_id = ?", 64 + state.db_backend, 65 + ); 66 + let children: Vec<(String, String)> = sqlx::query_as(&children_sql) 67 + .bind(&id) 68 + .fetch_all(&state.db) 69 + .await 70 + .unwrap_or_default(); 71 + 72 + // 5. Delete from DB — scoped to owner_did so users cannot delete others' clients 73 + let delete_sql = adapt_sql( 74 + "DELETE FROM api_clients WHERE id = ? AND owner_did = ?", 75 + state.db_backend, 76 + ); 77 + let result = sqlx::query(&delete_sql) 78 + .bind(&id) 79 + .bind(&user_did) 80 + .execute(&state.db) 81 + .await 82 + .map_err(|e| AppError::Internal(format!("failed to delete api client: {e}")))?; 83 + 84 + if result.rows_affected() == 0 { 85 + return Err(AppError::NotFound(format!("api client '{id}' not found"))); 86 + } 87 + 88 + // 6. Remove parent from OAuth registry, rate limiter, and client identities 89 + if let Some((url, key)) = client_info { 90 + state.oauth.remove(&url); 91 + state.rate_limiter.remove_client_config(&key); 92 + state.rate_limiter.remove_client_identity(&key); 93 + } 94 + 95 + // 7. Remove child clients from in-memory registries (DB rows already cascaded) 96 + for (child_url, child_key) in &children { 97 + state.oauth.remove(child_url); 98 + state.rate_limiter.remove_client_config(child_key); 99 + state.rate_limiter.remove_client_identity(child_key); 100 + } 101 + 102 + // 8. Log event 103 + log_event( 104 + &state.db, 105 + EventLog { 106 + event_type: "api_client.deleted".to_string(), 107 + severity: Severity::Info, 108 + actor_did: Some(user_did.clone()), 109 + subject: Some(id), 110 + detail: serde_json::json!({}), 111 + }, 112 + state.db_backend, 113 + ) 114 + .await; 115 + 116 + // 9. Return `{}` 117 + let mut response = Json(serde_json::json!({})).into_response(); 118 + 119 + if let Some(CheckResult::Allowed { 120 + remaining, 121 + limit, 122 + reset, 123 + }) = check 124 + { 125 + let h = response.headers_mut(); 126 + h.insert("RateLimit-Limit", limit.into()); 127 + h.insert("RateLimit-Remaining", remaining.into()); 128 + h.insert("RateLimit-Reset", reset.into()); 129 + } 130 + 131 + Ok(response) 132 + }
+84
src/dev_happyview/get_client.rs
··· 1 + use axum::Json; 2 + use axum::extract::{Query, State}; 3 + use axum::response::{IntoResponse, Response}; 4 + use serde::Deserialize; 5 + 6 + use super::row_to_view; 7 + use crate::AppState; 8 + use crate::auth::XrpcClaims; 9 + use crate::db::adapt_sql; 10 + use crate::error::AppError; 11 + use crate::rate_limit::CheckResult; 12 + 13 + #[derive(Debug, Deserialize)] 14 + pub struct GetApiClientParams { 15 + pub id: String, 16 + } 17 + 18 + pub async fn get_api_client( 19 + State(state): State<AppState>, 20 + xrpc_claims: XrpcClaims, 21 + Query(params): Query<GetApiClientParams>, 22 + ) -> Result<Response, AppError> { 23 + let claims = xrpc_claims 24 + .0 25 + .ok_or_else(|| AppError::Auth("getApiClient requires DPoP authentication".into()))?; 26 + 27 + let check = if let Some(client_key) = claims.client_key() { 28 + let cost = state 29 + .rate_limiter 30 + .default_cost_for_type(client_key, "query"); 31 + Some(state.rate_limiter.check(client_key, cost)) 32 + } else { 33 + None 34 + }; 35 + 36 + if let Some(CheckResult::Limited { 37 + retry_after, 38 + limit, 39 + reset, 40 + }) = check 41 + { 42 + return Err(AppError::RateLimited { 43 + retry_after, 44 + limit, 45 + reset, 46 + }); 47 + } 48 + 49 + let did = claims.did(); 50 + 51 + let sql = adapt_sql( 52 + "SELECT id, client_key, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, is_active, created_at \ 53 + FROM api_clients \ 54 + WHERE id = $1 AND owner_did = $2", 55 + state.db_backend, 56 + ); 57 + 58 + let row = sqlx::query(&sql) 59 + .bind(&params.id) 60 + .bind(did) 61 + .fetch_optional(&state.db) 62 + .await 63 + .map_err(|e| AppError::Internal(format!("failed to get client: {e}")))? 64 + .ok_or_else(|| AppError::NotFound("API client not found".into()))?; 65 + 66 + let client = 67 + row_to_view(&row).map_err(|e| AppError::Internal(format!("failed to read client: {e}")))?; 68 + 69 + let mut response = Json(serde_json::json!({ "client": client })).into_response(); 70 + 71 + if let Some(CheckResult::Allowed { 72 + remaining, 73 + limit, 74 + reset, 75 + }) = check 76 + { 77 + let h = response.headers_mut(); 78 + h.insert("RateLimit-Limit", limit.into()); 79 + h.insert("RateLimit-Remaining", remaining.into()); 80 + h.insert("RateLimit-Reset", reset.into()); 81 + } 82 + 83 + Ok(response) 84 + }
+98
src/dev_happyview/list_clients.rs
··· 1 + use axum::Json; 2 + use axum::extract::State; 3 + use axum::response::{IntoResponse, Response}; 4 + 5 + use super::row_to_view; 6 + use crate::AppState; 7 + use crate::auth::XrpcClaims; 8 + use crate::db::adapt_sql; 9 + use crate::error::AppError; 10 + use crate::rate_limit::CheckResult; 11 + 12 + pub async fn list_api_clients( 13 + State(state): State<AppState>, 14 + xrpc_claims: XrpcClaims, 15 + ) -> Result<Response, AppError> { 16 + let claims = xrpc_claims 17 + .0 18 + .ok_or_else(|| AppError::Auth("listApiClients requires DPoP authentication".into()))?; 19 + 20 + let check = if let Some(client_key) = claims.client_key() { 21 + let cost = state 22 + .rate_limiter 23 + .default_cost_for_type(client_key, "query"); 24 + Some(state.rate_limiter.check(client_key, cost)) 25 + } else { 26 + None 27 + }; 28 + 29 + if let Some(CheckResult::Limited { 30 + retry_after, 31 + limit, 32 + reset, 33 + }) = check 34 + { 35 + return Err(AppError::RateLimited { 36 + retry_after, 37 + limit, 38 + reset, 39 + }); 40 + } 41 + 42 + let did = claims.did(); 43 + 44 + // Reject requests from child clients 45 + if let Some(client_key) = claims.client_key() { 46 + let sql = adapt_sql( 47 + "SELECT parent_client_id FROM api_clients WHERE client_key = $1", 48 + state.db_backend, 49 + ); 50 + let parent_check: Option<Option<String>> = sqlx::query_scalar(&sql) 51 + .bind(client_key) 52 + .fetch_optional(&state.db) 53 + .await 54 + .map_err(|e| AppError::Internal(format!("client lookup failed: {e}")))?; 55 + 56 + if let Some(Some(_)) = parent_check { 57 + return Err(AppError::Auth( 58 + "child clients cannot manage API clients".into(), 59 + )); 60 + } 61 + } 62 + 63 + // Fetch all clients owned by the authenticated user 64 + let sql = adapt_sql( 65 + "SELECT id, client_key, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, is_active, created_at \ 66 + FROM api_clients \ 67 + WHERE owner_did = $1 \ 68 + ORDER BY created_at DESC", 69 + state.db_backend, 70 + ); 71 + 72 + let rows = sqlx::query(&sql) 73 + .bind(did) 74 + .fetch_all(&state.db) 75 + .await 76 + .map_err(|e| AppError::Internal(format!("failed to list clients: {e}")))?; 77 + 78 + let clients: Vec<_> = rows 79 + .iter() 80 + .filter_map(|row| row_to_view(row).ok()) 81 + .collect(); 82 + 83 + let mut response = Json(serde_json::json!({ "clients": clients })).into_response(); 84 + 85 + if let Some(CheckResult::Allowed { 86 + remaining, 87 + limit, 88 + reset, 89 + }) = check 90 + { 91 + let h = response.headers_mut(); 92 + h.insert("RateLimit-Limit", limit.into()); 93 + h.insert("RateLimit-Remaining", remaining.into()); 94 + h.insert("RateLimit-Reset", reset.into()); 95 + } 96 + 97 + Ok(response) 98 + }
+99
src/dev_happyview/mod.rs
··· 1 + pub mod create_client; 2 + pub mod delete_client; 3 + pub mod get_client; 4 + pub mod list_clients; 5 + 6 + pub use create_client::create_api_client; 7 + pub use delete_client::delete_api_client; 8 + pub use get_client::get_api_client; 9 + pub use list_clients::list_api_clients; 10 + 11 + use serde::{Deserialize, Serialize}; 12 + 13 + #[derive(Debug, Serialize)] 14 + #[serde(rename_all = "camelCase")] 15 + pub struct ApiClientView { 16 + pub id: String, 17 + pub name: String, 18 + pub client_key: String, 19 + pub client_id_url: String, 20 + pub client_uri: String, 21 + pub redirect_uris: Vec<String>, 22 + pub client_type: String, 23 + pub scopes: String, 24 + pub allowed_origins: Vec<String>, 25 + pub is_active: bool, 26 + pub created_at: String, 27 + } 28 + 29 + #[derive(Debug, Serialize)] 30 + #[serde(rename_all = "camelCase")] 31 + pub struct ApiClientViewWithSecret { 32 + #[serde(flatten)] 33 + pub client: ApiClientView, 34 + #[serde(skip_serializing_if = "Option::is_none")] 35 + pub client_secret: Option<String>, 36 + } 37 + 38 + #[derive(Debug, Deserialize)] 39 + #[serde(rename_all = "camelCase")] 40 + pub struct CreateApiClientInput { 41 + pub name: String, 42 + pub client_id_url: String, 43 + pub client_uri: String, 44 + pub redirect_uris: Vec<String>, 45 + #[serde(default = "default_client_type")] 46 + pub client_type: String, 47 + #[serde(default = "default_scopes")] 48 + pub scopes: String, 49 + pub allowed_origins: Option<Vec<String>>, 50 + } 51 + 52 + fn default_client_type() -> String { 53 + "confidential".to_string() 54 + } 55 + 56 + fn default_scopes() -> String { 57 + "atproto".to_string() 58 + } 59 + 60 + #[derive(Debug, Deserialize)] 61 + #[serde(rename_all = "camelCase")] 62 + pub struct DeleteApiClientInput { 63 + pub id: String, 64 + } 65 + 66 + /// Build an ApiClientView from a database row. 67 + /// 68 + /// Note: `is_active` is stored as i32 in the database (SQLite/Postgres compat via AnyPool). 69 + /// The existing codebase uses `row.get()` with the `sqlx::Row` trait. We follow the same 70 + /// pattern here but use `try_get` to return a Result rather than panic on missing columns. 71 + pub fn row_to_view(row: &sqlx::any::AnyRow) -> Result<ApiClientView, sqlx::Error> { 72 + use sqlx::Row; 73 + 74 + let redirect_uris_raw: String = row.try_get("redirect_uris")?; 75 + let redirect_uris: Vec<String> = serde_json::from_str(&redirect_uris_raw).unwrap_or_default(); 76 + 77 + let allowed_origins_raw: Option<String> = row.try_get("allowed_origins")?; 78 + let allowed_origins: Vec<String> = allowed_origins_raw 79 + .and_then(|s| serde_json::from_str(&s).ok()) 80 + .unwrap_or_default(); 81 + 82 + // is_active is stored as i32 (0/1) for SQLite/Postgres compatibility, 83 + // matching the pattern used throughout admin/api_clients.rs. 84 + let is_active_raw: i32 = row.try_get("is_active")?; 85 + 86 + Ok(ApiClientView { 87 + id: row.try_get("id")?, 88 + name: row.try_get("name")?, 89 + client_key: row.try_get("client_key")?, 90 + client_id_url: row.try_get("client_id_url")?, 91 + client_uri: row.try_get("client_uri")?, 92 + redirect_uris, 93 + client_type: row.try_get("client_type")?, 94 + scopes: row.try_get("scopes")?, 95 + allowed_origins, 96 + is_active: is_active_raw != 0, 97 + created_at: row.try_get("created_at")?, 98 + }) 99 + }
+1
src/lib.rs
··· 2 2 pub mod auth; 3 3 pub mod config; 4 4 pub mod db; 5 + pub mod dev_happyview; 5 6 pub mod dns; 6 7 pub mod domain; 7 8 pub mod domain_middleware;
-1
src/oauth/mod.rs
··· 3 3 pub mod keys; 4 4 pub mod pds_write; 5 5 pub mod routes; 6 - pub(crate) mod self_service; 7 6 pub mod sessions;
-4
src/oauth/routes.rs
··· 18 18 .route("/dpop-keys", post(provision_dpop_key)) 19 19 .route("/sessions", post(register_session)) 20 20 .route("/sessions/{did}", delete(delete_session)) 21 - .route( 22 - "/api-clients", 23 - post(super::self_service::create_child_api_client), 24 - ) 25 21 } 26 22 27 23 // --- Request / response types ---
-313
src/oauth/self_service.rs
··· 1 - use axum::Json; 2 - use axum::extract::State; 3 - use axum::http::StatusCode; 4 - use hex; 5 - use rand::Rng; 6 - use serde::Deserialize; 7 - use sha2::{Digest, Sha256}; 8 - use uuid::Uuid; 9 - 10 - use crate::AppState; 11 - use crate::admin::types::CreateApiClientResponse; 12 - use crate::db::{adapt_sql, now_rfc3339}; 13 - use crate::error::AppError; 14 - use crate::event_log::{EventLog, Severity, log_event}; 15 - 16 - use super::client_auth; 17 - use super::sessions; 18 - 19 - #[derive(Deserialize)] 20 - struct CreateChildApiClientBody { 21 - name: String, 22 - client_id_url: String, 23 - client_uri: String, 24 - redirect_uris: Vec<String>, 25 - #[serde(default = "default_scopes")] 26 - scopes: String, 27 - #[serde(default = "default_client_type")] 28 - client_type: String, 29 - allowed_origins: Option<Vec<String>>, 30 - } 31 - 32 - fn default_scopes() -> String { 33 - "atproto".to_string() 34 - } 35 - 36 - fn default_client_type() -> String { 37 - "confidential".to_string() 38 - } 39 - 40 - /// POST /oauth/api-clients — create a child API client (self-service). 41 - /// 42 - /// Authenticated via DPoP (`Authorization: DPoP <token>` + `DPoP` proof + `X-Client-Key`). 43 - /// Only top-level (admin-created) API clients can create children. 44 - pub(super) async fn create_child_api_client( 45 - State(state): State<AppState>, 46 - req: axum::extract::Request, 47 - ) -> Result<(StatusCode, Json<CreateApiClientResponse>), AppError> { 48 - use axum::extract::FromRequest; 49 - 50 - let client_key_header = req 51 - .headers() 52 - .get("x-client-key") 53 - .and_then(|v| v.to_str().ok()) 54 - .ok_or_else(|| AppError::Auth("Missing client identification".into()))? 55 - .to_string(); 56 - 57 - let auth_header = req 58 - .headers() 59 - .get("authorization") 60 - .and_then(|v| v.to_str().ok()) 61 - .ok_or_else(|| AppError::Auth("Authorization header required".into()))? 62 - .to_string(); 63 - 64 - let access_token = auth_header 65 - .strip_prefix("DPoP ") 66 - .ok_or_else(|| AppError::Auth("DPoP authorization scheme required".into()))?; 67 - 68 - let dpop_proof = req 69 - .headers() 70 - .get("dpop") 71 - .and_then(|v| v.to_str().ok()) 72 - .ok_or_else(|| AppError::Auth("DPoP proof header required".into()))? 73 - .to_string(); 74 - 75 - let scheme = if state.config.public_url.starts_with("https") { 76 - "https" 77 - } else { 78 - "http" 79 - }; 80 - let host = req 81 - .headers() 82 - .get("host") 83 - .and_then(|v| v.to_str().ok()) 84 - .unwrap_or("localhost") 85 - .to_string(); 86 - let request_path = req 87 - .extensions() 88 - .get::<axum::extract::OriginalUri>() 89 - .map(|u| u.0.path().to_string()) 90 - .unwrap_or_else(|| req.uri().path().to_string()); 91 - 92 - let body: CreateChildApiClientBody = 93 - Json::<CreateChildApiClientBody>::from_request(req, &state) 94 - .await 95 - .map_err(|e| AppError::BadRequest(format!("invalid request body: {e}")))? 96 - .0; 97 - 98 - if body.client_type != "confidential" && body.client_type != "public" { 99 - return Err(AppError::BadRequest("Invalid client_type".into())); 100 - } 101 - 102 - let encryption_key = state 103 - .config 104 - .token_encryption_key 105 - .as_ref() 106 - .ok_or_else(|| AppError::Internal("TOKEN_ENCRYPTION_KEY not configured".into()))?; 107 - 108 - // Resolve the parent API client. 109 - let parent_client = 110 - client_auth::resolve_client_by_key(&state.db, state.db_backend, &client_key_header) 111 - .await 112 - .map_err(|_| AppError::Auth("Invalid client".into()))?; 113 - 114 - // Verify the client is a top-level client (no parent) and fetch its creator. 115 - let parent_check_sql = adapt_sql( 116 - "SELECT parent_client_id, created_by FROM api_clients WHERE id = ?", 117 - state.db_backend, 118 - ); 119 - let parent_row: Option<(Option<String>, String)> = sqlx::query_as(&parent_check_sql) 120 - .bind(&parent_client.id) 121 - .fetch_optional(&state.db) 122 - .await 123 - .map_err(|e| AppError::Internal(format!("failed to check parent status: {e}")))?; 124 - 125 - let parent_created_by = match parent_row { 126 - Some((Some(_), _)) => { 127 - return Err(AppError::Forbidden( 128 - "Child clients cannot create API clients".into(), 129 - )); 130 - } 131 - Some((None, created_by)) => created_by, 132 - None => return Err(AppError::Auth("Invalid client".into())), 133 - }; 134 - 135 - // Validate the DPoP proof and resolve the authenticated user. 136 - let session = sessions::get_dpop_session_by_token_hash( 137 - &state.db, 138 - state.db_backend, 139 - encryption_key, 140 - &parent_client.id, 141 - access_token, 142 - ) 143 - .await?; 144 - 145 - if let Some(ref expires_at) = session.token_expires_at 146 - && let Ok(exp) = chrono::DateTime::parse_from_rfc3339(expires_at) 147 - && exp < chrono::Utc::now() 148 - { 149 - return Err(AppError::Auth("token_expired".into())); 150 - } 151 - 152 - let thumbprint = 153 - super::keys::get_dpop_key_thumbprint(&state.db, state.db_backend, &session.dpop_key_id) 154 - .await?; 155 - 156 - let request_url = format!("{}://{}{}", scheme, host, request_path); 157 - super::dpop_proof::validate_dpop_proof( 158 - &dpop_proof, 159 - "POST", 160 - &request_url, 161 - access_token, 162 - &thumbprint, 163 - )?; 164 - 165 - let user_did = &session.user_did; 166 - 167 - // Verify the parent client's owner exists in the users table. 168 - let user_check_sql = adapt_sql("SELECT id FROM users WHERE did = ?", state.db_backend); 169 - let user_exists: Option<(String,)> = sqlx::query_as(&user_check_sql) 170 - .bind(&parent_created_by) 171 - .fetch_optional(&state.db) 172 - .await 173 - .map_err(|e| AppError::Internal(format!("failed to check user: {e}")))?; 174 - 175 - if user_exists.is_none() { 176 - return Err(AppError::Forbidden("Parent client owner not found".into())); 177 - } 178 - 179 - // Check for duplicate client_id_url. 180 - let dup_check_sql = adapt_sql( 181 - "SELECT id FROM api_clients WHERE client_id_url = ?", 182 - state.db_backend, 183 - ); 184 - let dup: Option<(String,)> = sqlx::query_as(&dup_check_sql) 185 - .bind(&body.client_id_url) 186 - .fetch_optional(&state.db) 187 - .await 188 - .map_err(|e| AppError::Internal(format!("failed to check client_id_url: {e}")))?; 189 - 190 - if dup.is_some() { 191 - return Err(AppError::Conflict( 192 - "client_id_url already registered".into(), 193 - )); 194 - } 195 - 196 - // Generate the client key and secret. 197 - let mut random_bytes = [0u8; 16]; 198 - rand::rng().fill(&mut random_bytes); 199 - let child_client_key = format!("hvc_{}", hex::encode(random_bytes)); 200 - 201 - let (client_secret, client_secret_hash) = if body.client_type == "confidential" { 202 - let mut secret_bytes = [0u8; 32]; 203 - rand::rng().fill(&mut secret_bytes); 204 - let secret = format!("hvs_{}", hex::encode(secret_bytes)); 205 - let hash = hex::encode(Sha256::digest(secret.as_bytes())); 206 - (Some(secret), hash) 207 - } else { 208 - (None, String::new()) 209 - }; 210 - 211 - let id = Uuid::new_v4().to_string(); 212 - let now = now_rfc3339(); 213 - let redirect_uris_json = 214 - serde_json::to_string(&body.redirect_uris).unwrap_or_else(|_| "[]".to_string()); 215 - let allowed_origins_json = body 216 - .allowed_origins 217 - .as_ref() 218 - .map(|origins| serde_json::to_string(origins).unwrap_or_else(|_| "[]".to_string())); 219 - 220 - let insert_sql = adapt_sql( 221 - "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, rate_limit_capacity, rate_limit_refill_rate, client_type, allowed_origins, is_active, created_by, created_at, updated_at, parent_client_id, owner_did) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, 1, ?, ?, ?, ?, ?)", 222 - state.db_backend, 223 - ); 224 - 225 - sqlx::query(&insert_sql) 226 - .bind(&id) 227 - .bind(&child_client_key) 228 - .bind(&client_secret_hash) 229 - .bind(&body.name) 230 - .bind(&body.client_id_url) 231 - .bind(&body.client_uri) 232 - .bind(&redirect_uris_json) 233 - .bind(&body.scopes) 234 - .bind(&body.client_type) 235 - .bind(&allowed_origins_json) 236 - .bind(user_did) 237 - .bind(&now) 238 - .bind(&now) 239 - .bind(&parent_client.id) 240 - .bind(user_did) 241 - .execute(&state.db) 242 - .await 243 - .map_err(|e| AppError::Internal(format!("failed to create child api client: {e}")))?; 244 - 245 - // Register the new client in the OAuth registry. 246 - let oauth_params = crate::auth::client_registry::ApiClientOAuthParams { 247 - plc_url: state.config.plc_url.clone(), 248 - state_store: state.oauth_state_store.clone(), 249 - session_store_pool: state.db.clone(), 250 - db_backend: state.db_backend, 251 - }; 252 - if let Err(e) = state.oauth.register_api_client( 253 - &body.client_id_url, 254 - &body.client_uri, 255 - body.redirect_uris.clone(), 256 - &body.scopes, 257 - &oauth_params, 258 - ) { 259 - tracing::warn!(client_id = %body.client_id_url, error = %e, "OAuth client registration failed (DB row created)"); 260 - } 261 - 262 - // Register the client identity for request validation. 263 - state.rate_limiter.register_client_identity( 264 - child_client_key.clone(), 265 - crate::rate_limit::ClientIdentity { 266 - secret_hash: client_secret_hash.clone(), 267 - client_uri: body.client_uri.clone(), 268 - }, 269 - ); 270 - 271 - // Register the child with its own rate limit bucket using instance defaults. 272 - let defaults = state.rate_limiter.defaults(); 273 - state.rate_limiter.register_client_config( 274 - child_client_key.clone(), 275 - crate::rate_limit::RateLimitConfig { 276 - capacity: state.config.default_rate_limit_capacity, 277 - refill_rate: state.config.default_rate_limit_refill_rate, 278 - default_query_cost: defaults.query_cost, 279 - default_procedure_cost: defaults.procedure_cost, 280 - default_proxy_cost: defaults.proxy_cost, 281 - }, 282 - ); 283 - 284 - log_event( 285 - &state.db, 286 - EventLog { 287 - event_type: "api_client.created".to_string(), 288 - severity: Severity::Info, 289 - actor_did: Some(user_did.clone()), 290 - subject: Some(body.name.clone()), 291 - detail: serde_json::json!({ 292 - "client_key": child_client_key, 293 - "client_id_url": body.client_id_url, 294 - "parent_client_id": parent_client.id, 295 - "self_service": true, 296 - }), 297 - }, 298 - state.db_backend, 299 - ) 300 - .await; 301 - 302 - Ok(( 303 - StatusCode::CREATED, 304 - Json(CreateApiClientResponse { 305 - id, 306 - client_key: child_client_key, 307 - client_secret, 308 - name: body.name, 309 - client_id_url: body.client_id_url, 310 - client_type: body.client_type, 311 - }), 312 - )) 313 - }
+16
src/server.rs
··· 72 72 "/xrpc/com.atproto.repo.uploadBlob", 73 73 post(repo::upload_blob).layer(DefaultBodyLimit::max(50 * 1024 * 1024)), 74 74 ) 75 + .route( 76 + "/xrpc/dev.happyview.listApiClients", 77 + get(crate::dev_happyview::list_api_clients), 78 + ) 79 + .route( 80 + "/xrpc/dev.happyview.getApiClient", 81 + get(crate::dev_happyview::get_api_client), 82 + ) 83 + .route( 84 + "/xrpc/dev.happyview.createApiClient", 85 + post(crate::dev_happyview::create_api_client), 86 + ) 87 + .route( 88 + "/xrpc/dev.happyview.deleteApiClient", 89 + post(crate::dev_happyview::delete_api_client), 90 + ) 75 91 // Catch-all for dynamically registered lexicons 76 92 .route("/xrpc/{method}", get(xrpc::xrpc_get).post(xrpc::xrpc_post)) 77 93 .route("/config", get(config_endpoint))
+461
tests/dev_happyview.rs
··· 1 + mod common; 2 + 3 + use axum::body::Body; 4 + use axum::http::{Request, StatusCode}; 5 + use happyview::oauth::pds_write::generate_dpop_proof; 6 + use http_body_util::BodyExt; 7 + use serde_json::{Value, json}; 8 + use serial_test::serial; 9 + use tower::ServiceExt; 10 + 11 + // --------------------------------------------------------------------------- 12 + // Helpers 13 + // --------------------------------------------------------------------------- 14 + 15 + async fn response_json(resp: axum::response::Response) -> Value { 16 + let body = resp.into_body().collect().await.unwrap().to_bytes(); 17 + serde_json::from_slice(&body).unwrap_or(json!(null)) 18 + } 19 + 20 + fn post_json_with_headers(uri: &str, body: &Value, headers: Vec<(&str, &str)>) -> Request<Body> { 21 + let mut builder = Request::builder() 22 + .method("POST") 23 + .uri(uri) 24 + .header("content-type", "application/json") 25 + .header("host", "127.0.0.1:0"); 26 + for (name, value) in headers { 27 + builder = builder.header(name, value); 28 + } 29 + builder 30 + .body(Body::from(serde_json::to_vec(body).unwrap())) 31 + .unwrap() 32 + } 33 + 34 + /// Set up a full DPoP session and return `(client_key, dpop_key, access_token)`. 35 + async fn setup_dpop_session(app: &common::app::TestApp, user_did: &str) -> (String, Value, String) { 36 + let (client_key, client_secret, _id) = app.create_api_client("confidential", None).await; 37 + 38 + // 1. Provision DPoP key 39 + let key_req = post_json_with_headers( 40 + "/oauth/dpop-keys", 41 + &json!({}), 42 + vec![ 43 + ("x-client-key", &client_key), 44 + ("x-client-secret", &client_secret), 45 + ], 46 + ); 47 + let key_resp = app.router.clone().oneshot(key_req).await.unwrap(); 48 + assert_eq!( 49 + key_resp.status(), 50 + StatusCode::CREATED, 51 + "dpop key provisioning failed" 52 + ); 53 + let key_body = response_json(key_resp).await; 54 + let provision_id = key_body["provision_id"].as_str().unwrap().to_string(); 55 + let dpop_key = key_body["dpop_key"].clone(); 56 + 57 + // 2. Register session 58 + let access_token = format!("test-access-{}", uuid::Uuid::new_v4()); 59 + let session_req = post_json_with_headers( 60 + "/oauth/sessions", 61 + &json!({ 62 + "provision_id": provision_id, 63 + "did": user_did, 64 + "access_token": &access_token, 65 + "scopes": "atproto", 66 + "pds_url": "https://pds.example.com", 67 + }), 68 + vec![ 69 + ("x-client-key", &client_key), 70 + ("x-client-secret", &client_secret), 71 + ], 72 + ); 73 + let session_resp = app.router.clone().oneshot(session_req).await.unwrap(); 74 + assert_eq!( 75 + session_resp.status(), 76 + StatusCode::CREATED, 77 + "session registration failed" 78 + ); 79 + 80 + (client_key, dpop_key, access_token) 81 + } 82 + 83 + // --------------------------------------------------------------------------- 84 + // listApiClients tests 85 + // --------------------------------------------------------------------------- 86 + 87 + /// Unauthenticated request (no Authorization header) should be rejected. 88 + #[tokio::test] 89 + #[serial] 90 + async fn list_api_clients_unauthenticated_returns_non_200() { 91 + let app = common::app::TestApp::new_with_encryption().await; 92 + 93 + let req = Request::builder() 94 + .method("GET") 95 + .uri("/xrpc/dev.happyview.listApiClients") 96 + .header("host", "127.0.0.1:0") 97 + .header("x-client-key", "hvc_fake") 98 + .body(Body::empty()) 99 + .unwrap(); 100 + 101 + let resp = app.router.clone().oneshot(req).await.unwrap(); 102 + // Handler requires DPoP auth — anonymous access should be rejected (non-200) 103 + assert_ne!( 104 + resp.status(), 105 + StatusCode::OK, 106 + "unauthenticated request should not return 200" 107 + ); 108 + } 109 + 110 + /// DPoP-authenticated request returns 200 with a `clients` array. 111 + #[tokio::test] 112 + #[serial] 113 + async fn list_api_clients_authenticated_returns_200_with_clients_array() { 114 + let app = common::app::TestApp::new_with_encryption().await; 115 + let user_did = "did:plc:testowner"; 116 + let (client_key, dpop_key, access_token) = setup_dpop_session(&app, user_did).await; 117 + 118 + let request_url = "http://127.0.0.1:0/xrpc/dev.happyview.listApiClients"; 119 + let proof = generate_dpop_proof(&dpop_key, "GET", request_url, &access_token, None) 120 + .expect("failed to generate DPoP proof"); 121 + 122 + let req = Request::builder() 123 + .method("GET") 124 + .uri("/xrpc/dev.happyview.listApiClients") 125 + .header("host", "127.0.0.1:0") 126 + .header("x-client-key", &client_key) 127 + .header("authorization", format!("DPoP {}", access_token)) 128 + .header("dpop", &proof) 129 + .body(Body::empty()) 130 + .unwrap(); 131 + 132 + let resp = app.router.clone().oneshot(req).await.unwrap(); 133 + assert_eq!(resp.status(), StatusCode::OK); 134 + 135 + let body = response_json(resp).await; 136 + assert!( 137 + body["clients"].is_array(), 138 + "response should contain a 'clients' array, got: {body}" 139 + ); 140 + } 141 + 142 + // --------------------------------------------------------------------------- 143 + // getApiClient tests 144 + // --------------------------------------------------------------------------- 145 + 146 + /// Authenticated request for a nonexistent client ID returns 404. 147 + #[tokio::test] 148 + #[serial] 149 + async fn get_api_client_not_found() { 150 + let app = common::app::TestApp::new_with_encryption().await; 151 + let user_did = "did:plc:testowner404"; 152 + let (client_key, dpop_key, access_token) = setup_dpop_session(&app, user_did).await; 153 + 154 + let request_url = "http://127.0.0.1:0/xrpc/dev.happyview.getApiClient?id=nonexistent-id"; 155 + let proof = generate_dpop_proof(&dpop_key, "GET", request_url, &access_token, None) 156 + .expect("failed to generate DPoP proof"); 157 + 158 + let req = Request::builder() 159 + .method("GET") 160 + .uri("/xrpc/dev.happyview.getApiClient?id=nonexistent-id") 161 + .header("host", "127.0.0.1:0") 162 + .header("x-client-key", &client_key) 163 + .header("authorization", format!("DPoP {}", access_token)) 164 + .header("dpop", &proof) 165 + .body(Body::empty()) 166 + .unwrap(); 167 + 168 + let resp = app.router.clone().oneshot(req).await.unwrap(); 169 + assert_eq!(resp.status(), StatusCode::NOT_FOUND); 170 + } 171 + 172 + // --------------------------------------------------------------------------- 173 + // createApiClient tests 174 + // --------------------------------------------------------------------------- 175 + 176 + /// DPoP-authenticated request creates a confidential client and returns 177 + /// clientKey (hvc_) and clientSecret (hvs_) in the response. 178 + #[tokio::test] 179 + #[serial] 180 + async fn create_api_client_via_xrpc() { 181 + let app = common::app::TestApp::new_with_encryption().await; 182 + let user_did = "did:plc:testcreator"; 183 + let (client_key, dpop_key, access_token) = setup_dpop_session(&app, user_did).await; 184 + 185 + let request_url = "http://127.0.0.1:0/xrpc/dev.happyview.createApiClient"; 186 + let proof = generate_dpop_proof(&dpop_key, "POST", request_url, &access_token, None) 187 + .expect("failed to generate DPoP proof"); 188 + 189 + let body = json!({ 190 + "name": "My Confidential Client", 191 + "clientIdUrl": "https://myapp.example.com/oauth/client", 192 + "clientUri": "https://myapp.example.com", 193 + "redirectUris": ["https://myapp.example.com/callback"], 194 + "clientType": "confidential", 195 + }); 196 + 197 + let req = post_json_with_headers( 198 + "/xrpc/dev.happyview.createApiClient", 199 + &body, 200 + vec![ 201 + ("x-client-key", &client_key), 202 + ("authorization", &format!("DPoP {}", access_token)), 203 + ("dpop", &proof), 204 + ], 205 + ); 206 + 207 + let resp = app.router.clone().oneshot(req).await.unwrap(); 208 + assert_eq!(resp.status(), StatusCode::CREATED, "expected 201 CREATED"); 209 + 210 + let resp_body = response_json(resp).await; 211 + let client_key_val = resp_body["clientKey"].as_str().unwrap_or(""); 212 + assert!( 213 + client_key_val.starts_with("hvc_"), 214 + "clientKey should start with 'hvc_', got: {client_key_val}" 215 + ); 216 + 217 + let client_secret_val = resp_body["clientSecret"].as_str().unwrap_or(""); 218 + assert!( 219 + client_secret_val.starts_with("hvs_"), 220 + "clientSecret should start with 'hvs_', got: {client_secret_val}" 221 + ); 222 + 223 + assert_eq!( 224 + resp_body["clientType"].as_str().unwrap_or(""), 225 + "confidential" 226 + ); 227 + } 228 + 229 + /// Creating a public client returns no clientSecret in the response. 230 + #[tokio::test] 231 + #[serial] 232 + async fn create_api_client_public_no_secret() { 233 + let app = common::app::TestApp::new_with_encryption().await; 234 + let user_did = "did:plc:testcreatorpublic"; 235 + let (client_key, dpop_key, access_token) = setup_dpop_session(&app, user_did).await; 236 + 237 + let request_url = "http://127.0.0.1:0/xrpc/dev.happyview.createApiClient"; 238 + let proof = generate_dpop_proof(&dpop_key, "POST", request_url, &access_token, None) 239 + .expect("failed to generate DPoP proof"); 240 + 241 + let body = json!({ 242 + "name": "My Public Client", 243 + "clientIdUrl": "https://pubapp.example.com/oauth/client", 244 + "clientUri": "https://pubapp.example.com", 245 + "redirectUris": ["https://pubapp.example.com/callback"], 246 + "clientType": "public", 247 + }); 248 + 249 + let req = post_json_with_headers( 250 + "/xrpc/dev.happyview.createApiClient", 251 + &body, 252 + vec![ 253 + ("x-client-key", &client_key), 254 + ("authorization", &format!("DPoP {}", access_token)), 255 + ("dpop", &proof), 256 + ], 257 + ); 258 + 259 + let resp = app.router.clone().oneshot(req).await.unwrap(); 260 + assert_eq!(resp.status(), StatusCode::CREATED, "expected 201 CREATED"); 261 + 262 + let resp_body = response_json(resp).await; 263 + let client_key_val = resp_body["clientKey"].as_str().unwrap_or(""); 264 + assert!( 265 + client_key_val.starts_with("hvc_"), 266 + "clientKey should start with 'hvc_', got: {client_key_val}" 267 + ); 268 + 269 + assert!( 270 + resp_body["clientSecret"].is_null(), 271 + "public client should have no clientSecret, got: {}", 272 + resp_body["clientSecret"] 273 + ); 274 + 275 + assert_eq!(resp_body["clientType"].as_str().unwrap_or(""), "public"); 276 + } 277 + 278 + // --------------------------------------------------------------------------- 279 + // deleteApiClient tests 280 + // --------------------------------------------------------------------------- 281 + 282 + /// Create a client, delete it, then verify a GET returns 404. 283 + #[tokio::test] 284 + #[serial] 285 + async fn delete_api_client_success() { 286 + let app = common::app::TestApp::new_with_encryption().await; 287 + let user_did = "did:plc:testownerdelete"; 288 + let (client_key, dpop_key, access_token) = setup_dpop_session(&app, user_did).await; 289 + 290 + // Insert a client owned by user_did. 291 + let client_id = uuid::Uuid::new_v4().to_string(); 292 + let now = happyview::db::now_rfc3339(); 293 + let sql = happyview::db::adapt_sql( 294 + "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, is_active, created_by, created_at, updated_at, owner_did) \ 295 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)", 296 + app.state.db_backend, 297 + ); 298 + sqlx::query(&sql) 299 + .bind(&client_id) 300 + .bind("hvc_delete_test_key") 301 + .bind("dummyhash") 302 + .bind("to-be-deleted") 303 + .bind("https://delete.example.com/oauth/abc") 304 + .bind("https://delete.example.com") 305 + .bind("[]") 306 + .bind("atproto") 307 + .bind("confidential") 308 + .bind::<Option<String>>(None) 309 + .bind(user_did) 310 + .bind(&now) 311 + .bind(&now) 312 + .bind(user_did) 313 + .execute(&app.state.db) 314 + .await 315 + .expect("failed to insert client for deletion test"); 316 + 317 + // Delete the client via XRPC. 318 + let delete_url = "http://127.0.0.1:0/xrpc/dev.happyview.deleteApiClient"; 319 + let delete_proof = generate_dpop_proof(&dpop_key, "POST", delete_url, &access_token, None) 320 + .expect("failed to generate DPoP proof for delete"); 321 + 322 + let delete_req = post_json_with_headers( 323 + "/xrpc/dev.happyview.deleteApiClient", 324 + &json!({ "id": client_id }), 325 + vec![ 326 + ("x-client-key", &client_key), 327 + ("authorization", &format!("DPoP {}", access_token)), 328 + ("dpop", &delete_proof), 329 + ], 330 + ); 331 + 332 + let delete_resp = app.router.clone().oneshot(delete_req).await.unwrap(); 333 + assert_eq!( 334 + delete_resp.status(), 335 + StatusCode::OK, 336 + "expected 200 OK on delete" 337 + ); 338 + 339 + // Verify GET now returns 404. 340 + let get_uri = format!("/xrpc/dev.happyview.getApiClient?id={}", client_id); 341 + let get_url = format!("http://127.0.0.1:0{}", get_uri); 342 + let get_proof = generate_dpop_proof(&dpop_key, "GET", &get_url, &access_token, None) 343 + .expect("failed to generate DPoP proof for get"); 344 + 345 + let get_req = Request::builder() 346 + .method("GET") 347 + .uri(&get_uri) 348 + .header("host", "127.0.0.1:0") 349 + .header("x-client-key", &client_key) 350 + .header("authorization", format!("DPoP {}", access_token)) 351 + .header("dpop", &get_proof) 352 + .body(Body::empty()) 353 + .unwrap(); 354 + 355 + let get_resp = app.router.clone().oneshot(get_req).await.unwrap(); 356 + assert_eq!( 357 + get_resp.status(), 358 + StatusCode::NOT_FOUND, 359 + "client should be gone after deletion" 360 + ); 361 + } 362 + 363 + /// Attempting to delete a nonexistent client returns 404. 364 + #[tokio::test] 365 + #[serial] 366 + async fn delete_api_client_not_found() { 367 + let app = common::app::TestApp::new_with_encryption().await; 368 + let user_did = "did:plc:testownerdel404"; 369 + let (client_key, dpop_key, access_token) = setup_dpop_session(&app, user_did).await; 370 + 371 + let delete_url = "http://127.0.0.1:0/xrpc/dev.happyview.deleteApiClient"; 372 + let delete_proof = generate_dpop_proof(&dpop_key, "POST", delete_url, &access_token, None) 373 + .expect("failed to generate DPoP proof"); 374 + 375 + let delete_req = post_json_with_headers( 376 + "/xrpc/dev.happyview.deleteApiClient", 377 + &json!({ "id": "nonexistent-id-12345" }), 378 + vec![ 379 + ("x-client-key", &client_key), 380 + ("authorization", &format!("DPoP {}", access_token)), 381 + ("dpop", &delete_proof), 382 + ], 383 + ); 384 + 385 + let resp = app.router.clone().oneshot(delete_req).await.unwrap(); 386 + assert_eq!( 387 + resp.status(), 388 + StatusCode::NOT_FOUND, 389 + "deleting nonexistent client should return 404" 390 + ); 391 + } 392 + 393 + /// Authenticated request returns 200 with the matching client. 394 + #[tokio::test] 395 + #[serial] 396 + async fn get_api_client_returns_client() { 397 + let app = common::app::TestApp::new_with_encryption().await; 398 + let user_did = "did:plc:testownerget"; 399 + let (client_key, dpop_key, access_token) = setup_dpop_session(&app, user_did).await; 400 + 401 + // Insert a child client owned by user_did directly. 402 + let client_id = uuid::Uuid::new_v4().to_string(); 403 + let now = happyview::db::now_rfc3339(); 404 + let sql = happyview::db::adapt_sql( 405 + "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, is_active, created_by, created_at, updated_at, owner_did) \ 406 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)", 407 + app.state.db_backend, 408 + ); 409 + sqlx::query(&sql) 410 + .bind(&client_id) 411 + .bind("hvc_owned_test_key") 412 + .bind("dummyhash") 413 + .bind("owned-client") 414 + .bind("https://owned.example.com/oauth/abc") 415 + .bind("https://owned.example.com") 416 + .bind("[]") 417 + .bind("atproto") 418 + .bind("confidential") 419 + .bind::<Option<String>>(None) 420 + .bind(user_did) 421 + .bind(&now) 422 + .bind(&now) 423 + .bind(user_did) 424 + .execute(&app.state.db) 425 + .await 426 + .expect("failed to insert owned client"); 427 + 428 + let uri = format!("/xrpc/dev.happyview.getApiClient?id={}", client_id); 429 + let request_url = format!("http://127.0.0.1:0{}", uri); 430 + let proof = generate_dpop_proof(&dpop_key, "GET", &request_url, &access_token, None) 431 + .expect("failed to generate DPoP proof"); 432 + 433 + let req = Request::builder() 434 + .method("GET") 435 + .uri(&uri) 436 + .header("host", "127.0.0.1:0") 437 + .header("x-client-key", &client_key) 438 + .header("authorization", format!("DPoP {}", access_token)) 439 + .header("dpop", &proof) 440 + .body(Body::empty()) 441 + .unwrap(); 442 + 443 + let resp = app.router.clone().oneshot(req).await.unwrap(); 444 + assert_eq!(resp.status(), StatusCode::OK); 445 + 446 + let body = response_json(resp).await; 447 + assert!( 448 + body["client"].is_object(), 449 + "response should contain a 'client' object, got: {body}" 450 + ); 451 + assert_eq!( 452 + body["client"]["id"].as_str().unwrap(), 453 + client_id, 454 + "returned client id should match" 455 + ); 456 + assert_eq!( 457 + body["client"]["name"].as_str().unwrap(), 458 + "owned-client", 459 + "returned client name should match" 460 + ); 461 + }