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: allow first party API clients to child API clients

Trezy cdb69c4b ce54c9fd

+1575 -144
+5
migrations/postgres/20260425000000_api_clients_add_parent_and_owner.sql
··· 1 + ALTER TABLE api_clients ADD COLUMN parent_client_id TEXT REFERENCES api_clients(id) ON DELETE CASCADE; 2 + ALTER TABLE api_clients ADD COLUMN owner_did TEXT; 3 + 4 + CREATE INDEX idx_api_clients_parent_id ON api_clients(parent_client_id); 5 + CREATE INDEX idx_api_clients_owner_did ON api_clients(owner_did);
+5
migrations/sqlite/20260425000000_api_clients_add_parent_and_owner.sql
··· 1 + ALTER TABLE api_clients ADD COLUMN parent_client_id TEXT REFERENCES api_clients(id) ON DELETE CASCADE; 2 + ALTER TABLE api_clients ADD COLUMN owner_did TEXT; 3 + 4 + CREATE INDEX idx_api_clients_parent_id ON api_clients(parent_client_id); 5 + CREATE INDEX idx_api_clients_owner_did ON api_clients(owner_did);
+1
packages/docs/docs/getting-started/authentication.md
··· 318 318 - [Permissions](../guides/admin/permissions.md) — full list of permissions and what each one grants 319 319 - [API Keys](../guides/admin/api-keys.md) — create scoped admin API keys for automation 320 320 - [Admin API — API Clients](../reference/admin/api-clients.md) — register API clients and configure rate limits 321 + - [Self-Service API Clients](../reference/oauth/api-clients.md) — let third-party apps create child API clients programmatically
+1 -1
packages/docs/docs/getting-started/deployment/docker.md
··· 9 9 ## 1. Clone and configure 10 10 11 11 ```sh 12 - git clone https://github.com/gamesgamesgamesgamesgames/happyview.git 12 + git clone git@tangled.org:gamesgamesgamesgames.games/happyview 13 13 cd happyview 14 14 cp .env.example .env 15 15 ```
+1 -1
packages/docs/docs/getting-started/deployment/other.md
··· 10 10 ## 1. Clone and configure 11 11 12 12 ```sh 13 - git clone https://github.com/gamesgamesgamesgamesgames/happyview.git 13 + git clone git@tangled.org:gamesgamesgamesgames.games/happyview 14 14 cd happyview 15 15 cp .env.example .env 16 16 ```
+2 -2
packages/docs/docs/guides/features/developing-plugins.md
··· 2 2 3 3 This guide covers how to build your own HappyView WASM plugins. For installing and configuring plugins, see the [Plugins guide](plugins.md). 4 4 5 - See the [happyview-plugins](https://github.com/gamesgamesgamesgamesgames/happyview-plugins) repository for examples and the plugin SDK. 5 + See the [happyview-plugins](https://tangled.org/gamesgamesgamesgames.games/happyview-plugins) repository for examples and the plugin SDK. 6 6 7 7 ## Plugin Manifest 8 8 ··· 99 99 100 100 ## Next steps 101 101 102 - - [Official plugins repository](https://github.com/gamesgamesgamesgamesgames/happyview-plugins) — ready-to-use plugins and the plugin SDK 102 + - [Official plugins repository](https://tangled.org/gamesgamesgamesgames.games/happyview-plugins) — ready-to-use plugins and the plugin SDK 103 103 - [Plugins guide](plugins.md) — install and configure plugins 104 104 - [API Keys](../admin/api-keys.md) — authenticate programmatic access to admin endpoints 105 105 - [Permissions](../admin/permissions.md) — configure user access to plugin management
+2 -2
packages/docs/docs/guides/features/plugins.md
··· 2 2 3 3 HappyView uses WASM plugins to extend its functionality. Plugins can integrate with external platforms, sync data to users' atproto identities, and more. Auth plugins — the first supported plugin type — enable users to link accounts from platforms like Steam, Xbox, itch.io, and others, then sync data like game libraries. 4 4 5 - Official plugins for Steam, Xbox, itch.io, and other platforms are available in the [happyview-plugins](https://github.com/gamesgamesgamesgamesgames/happyview-plugins) repository. 5 + Official plugins for Steam, Xbox, itch.io, and other platforms are available in the [happyview-plugins](https://tangled.org/gamesgamesgamesgames.games/happyview-plugins) repository. 6 6 7 7 ## Installing Plugins 8 8 ··· 74 74 ## Next steps 75 75 76 76 - [Developing Plugins](developing-plugins.md) — create your own plugins with the WASM plugin API 77 - - [Official plugins repository](https://github.com/gamesgamesgamesgamesgames/happyview-plugins) — ready-to-use plugins for Steam, Xbox, itch.io, and more 77 + - [Official plugins repository](https://tangled.org/gamesgamesgamesgames.games/happyview-plugins) — ready-to-use plugins for Steam, Xbox, itch.io, and more 78 78 - [API Keys](../admin/api-keys.md) — authenticate programmatic access to admin endpoints 79 79 - [Permissions](../admin/permissions.md) — configure user access to plugin management
+1 -1
packages/docs/docs/guides/upgrading-to-v2.md
··· 1 1 # Migrating from v1 2 2 3 - v2 consolidates HappyView, [Tap](https://github.com/bluesky-social/indigo/tree/main/cmd/tap), and [AIP](https://github.com/graze-social/aip) into a single binary. Real-time indexing, backfill, and OAuth are now built in — there are no companion services to deploy. 3 + v2 consolidates HappyView, [Tap](https://github.com/bluesky-social/indigo/tree/main/cmd/tap), and [AIP](https://tangled.org/gamesgamesgamesgames.games/aip) into a single binary. Real-time indexing, backfill, and OAuth are now built in — there are no companion services to deploy. 4 4 5 5 This guide covers every breaking change and the steps to migrate. 6 6
+7 -1
packages/docs/docs/reference/admin/api-clients.md
··· 6 6 7 7 Each client has an `hvc_`-prefixed client key and an `hvs_`-prefixed client secret. The secret is only returned at creation and is sha256-hashed in the database. Server-to-server callers pass the secret as `X-Client-Secret`. Browser callers use the `Origin` header, which is matched against the client's `client_uri`. Mismatches currently log warnings rather than rejecting the request, but rate limiting applies either way. See [Authentication — XRPC](../../getting-started/authentication.md#xrpc-api-client-identification) for the client-side view, and the [API Keys guide](../../guides/admin/api-keys.md) for how admin API keys differ from API clients. 8 8 9 + :::tip Self-service API clients 10 + Third-party apps can also create **child API clients** programmatically via the [self-service endpoint](../oauth/api-clients.md), without needing admin access. 11 + ::: 12 + 9 13 ```sh 10 14 # All examples assume $TOKEN is an API key (hv_...) 11 15 AUTH="Authorization: Bearer $TOKEN" ··· 40 44 "is_active": true, 41 45 "created_by": "did:plc:...", 42 46 "created_at": "2026-04-13T12:00:00Z", 43 - "updated_at": "2026-04-13T12:00:00Z" 47 + "updated_at": "2026-04-13T12:00:00Z", 48 + "parent_client_id": null, 49 + "owner_did": null 44 50 } 45 51 ] 46 52 ```
+82
packages/docs/docs/reference/oauth/api-clients.md
··· 1 + # OAuth API: Self-Service API Clients 2 + 3 + Third-party applications can create child API clients on behalf of authenticated users via `POST /oauth/api-clients`. A child client is always tied to exactly one parent — the admin-created top-level API client that made the request. Only one level of nesting is allowed; child clients cannot create further children. Each child client gets its own rate limit bucket with instance default settings. 4 + 5 + The endpoint uses [DPoP authentication](../../getting-started/authentication.md#authenticating-users-for-procedures). See the [admin API client docs](../admin/api-clients.md) for managing clients through the admin API, and the [API Clients guide](../../guides/features/api-clients.md) for an overview of how API clients work in HappyView. 6 + 7 + ## Create a child client 8 + 9 + ``` 10 + POST /oauth/api-clients 11 + ``` 12 + 13 + Requires three headers: 14 + 15 + | Header | Value | 16 + | --------------- | ------------------------------------------------------------ | 17 + | `Authorization` | `DPoP <access_token>` | 18 + | `DPoP` | A DPoP proof JWT (method: `POST`, htu: the full request URL) | 19 + | `X-Client-Key` | The parent client's `client_key` | 20 + 21 + The access token must belong to a valid DPoP session for the parent client. The parent client's owner (its `created_by` DID) must exist in the HappyView `users` table. 22 + 23 + ```sh 24 + curl -X POST https://happyview.example.com/oauth/api-clients \ 25 + -H "X-Client-Key: hvc_parent_key" \ 26 + -H "Authorization: DPoP eyJhbG..." \ 27 + -H "DPoP: eyJhbG..." \ 28 + -H "Content-Type: application/json" \ 29 + -d '{ 30 + "name": "My Child App", 31 + "client_id_url": "https://child.example.com/client-metadata.json", 32 + "client_uri": "https://child.example.com", 33 + "redirect_uris": ["https://child.example.com/callback"], 34 + "client_type": "confidential" 35 + }' 36 + ``` 37 + 38 + | Field | Type | Required | Description | 39 + | ----------------- | -------- | -------- | ------------------------------------------------ | 40 + | `name` | string | yes | Display name for the child client | 41 + | `client_id_url` | string | yes | Unique OAuth client ID URL | 42 + | `client_uri` | string | yes | The client's homepage URL | 43 + | `redirect_uris` | string[] | yes | OAuth redirect URIs | 44 + | `scopes` | string | no | Space-separated OAuth scopes (default `"atproto"`) | 45 + | `client_type` | string | no | `"confidential"` or `"public"` (default `"confidential"`) | 46 + | `allowed_origins` | string[] | no | CORS allowed origins | 47 + 48 + **Response**: `201 Created` 49 + 50 + ```json 51 + { 52 + "id": "550e8400-e29b-41d4-a716-446655440000", 53 + "client_key": "hvc_a1b2c3d4e5f6...", 54 + "client_secret": "hvs_f6e5d4c3b2a1...", 55 + "name": "My Child App", 56 + "client_id_url": "https://child.example.com/client-metadata.json", 57 + "client_type": "confidential" 58 + } 59 + ``` 60 + 61 + The `client_secret` is only present for confidential clients and is only returned in this response — store it securely. It is stored as a SHA-256 hash and cannot be retrieved again. 62 + 63 + ## Errors 64 + 65 + | Status | Error | Cause | 66 + | ------ | ---------------------------------------- | ------------------------------------------------------------------ | 67 + | 400 | `Invalid client_type` | `client_type` is not `"confidential"` or `"public"` | 68 + | 400 | `invalid request body` | Missing required fields or malformed JSON | 69 + | 401 | `Missing client identification` | `X-Client-Key` header is absent | 70 + | 401 | `DPoP authorization scheme required` | `Authorization` header doesn't start with `DPoP ` | 71 + | 401 | `DPoP proof header required` | `DPoP` header is absent | 72 + | 401 | `token_expired` | The access token has expired | 73 + | 401 | `Invalid client` | `X-Client-Key` doesn't match a known client | 74 + | 403 | `Child clients cannot create API clients` | The calling client is itself a child | 75 + | 403 | `Parent client owner not found` | The parent client's `created_by` DID is not in the `users` table | 76 + | 409 | `client_id_url already registered` | Another client already uses that `client_id_url` | 77 + 78 + ## Operational notes 79 + 80 + Each child client gets its own rate limit bucket using the instance's default capacity and refill rate (`DEFAULT_RATE_LIMIT_CAPACITY` / `DEFAULT_RATE_LIMIT_REFILL_RATE`). Deactivating or deleting a parent via the [admin API](../admin/api-clients.md) cascades to all its children. 81 + 82 + The admin API clients list (`GET /admin/api-clients`) returns `parent_client_id` and `owner_did` fields for each client and supports `?parent_id=` filtering. The dashboard's API Clients table shows these as "Parent Client" and "Owner" columns.
+2 -2
packages/docs/docusaurus.config.ts
··· 52 52 label: "Docs", 53 53 }, 54 54 { 55 - href: "https://github.com/gamesgamesgamesgamesgames/happyview", 56 - label: "GitHub", 55 + href: "https://tangled.org/gamesgamesgamesgames.games/happyview", 56 + label: "Tangled", 57 57 position: "right", 58 58 }, 59 59 ],
+11
packages/docs/sidebars.ts
··· 381 381 }, 382 382 { 383 383 type: "category", 384 + label: "OAuth API", 385 + items: [ 386 + { 387 + type: "doc", 388 + id: "reference/oauth/api-clients", 389 + label: "Self-Service API Clients", 390 + }, 391 + ], 392 + }, 393 + { 394 + type: "category", 384 395 label: "Lua API", 385 396 items: [ 386 397 {
+129 -115
src/admin/api_clients.rs
··· 1 1 use axum::Json; 2 - use axum::extract::{Path, State}; 2 + use axum::extract::{Path, Query, State}; 3 3 use axum::http::StatusCode; 4 4 use hex; 5 5 use rand::Rng; 6 + use serde::Deserialize; 6 7 use sha2::{Digest, Sha256}; 8 + use sqlx::Row; 7 9 use uuid::Uuid; 8 10 9 11 use crate::AppState; ··· 16 18 use super::types::{ 17 19 ApiClientSummary, CreateApiClientBody, CreateApiClientResponse, UpdateApiClientBody, 18 20 }; 21 + 22 + #[derive(Deserialize)] 23 + pub(super) struct ListApiClientsQuery { 24 + pub(super) parent_id: Option<String>, 25 + } 19 26 20 27 /// POST /admin/api-clients — create a new API client. 21 28 pub(super) async fn create_api_client( ··· 152 159 pub(super) async fn list_api_clients( 153 160 State(state): State<AppState>, 154 161 auth: UserAuth, 162 + Query(query): Query<ListApiClientsQuery>, 155 163 ) -> Result<Json<Vec<ApiClientSummary>>, AppError> { 156 164 auth.require(Permission::ApiClientsView).await?; 157 165 158 - let select_sql = adapt_sql( 159 - "SELECT id, client_key, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, rate_limit_capacity, rate_limit_refill_rate, is_active, created_by, created_at, updated_at FROM api_clients ORDER BY created_at DESC", 160 - state.db_backend, 161 - ); 166 + let (select_sql, parent_filter) = if let Some(ref parent_id) = query.parent_id { 167 + ( 168 + adapt_sql( 169 + "SELECT id, client_key, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, rate_limit_capacity, rate_limit_refill_rate, is_active, created_by, created_at, updated_at, parent_client_id, owner_did FROM api_clients WHERE parent_client_id = ? ORDER BY created_at DESC", 170 + state.db_backend, 171 + ), 172 + Some(parent_id.clone()), 173 + ) 174 + } else { 175 + ( 176 + adapt_sql( 177 + "SELECT id, client_key, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, rate_limit_capacity, rate_limit_refill_rate, is_active, created_by, created_at, updated_at, parent_client_id, owner_did FROM api_clients ORDER BY created_at DESC", 178 + state.db_backend, 179 + ), 180 + None, 181 + ) 182 + }; 183 + 184 + let q = sqlx::query(&select_sql); 185 + let q = if let Some(ref pid) = parent_filter { 186 + q.bind(pid) 187 + } else { 188 + q 189 + }; 162 190 163 - #[allow(clippy::type_complexity)] 164 - let rows: Vec<( 165 - String, 166 - String, 167 - String, 168 - String, 169 - String, 170 - String, 171 - String, 172 - String, 173 - Option<String>, 174 - Option<i32>, 175 - Option<f64>, 176 - i32, 177 - String, 178 - String, 179 - String, 180 - )> = sqlx::query_as(&select_sql) 191 + let rows = q 181 192 .fetch_all(&state.db) 182 193 .await 183 194 .map_err(|e| AppError::Internal(format!("failed to list api clients: {e}")))?; 184 195 185 196 let clients: Vec<ApiClientSummary> = rows 186 197 .into_iter() 187 - .map( 188 - |( 189 - id, 190 - client_key, 191 - name, 192 - client_id_url, 193 - client_uri, 194 - redirect_uris_json, 195 - scopes, 196 - client_type, 197 - allowed_origins_json, 198 - rate_limit_capacity, 199 - rate_limit_refill_rate, 200 - is_active, 201 - created_by, 202 - created_at, 203 - updated_at, 204 - )| { 205 - let redirect_uris: Vec<String> = 206 - serde_json::from_str(&redirect_uris_json).unwrap_or_default(); 207 - let allowed_origins: Option<Vec<String>> = allowed_origins_json 208 - .as_deref() 209 - .and_then(|j| serde_json::from_str(j).ok()); 210 - ApiClientSummary { 211 - id, 212 - client_key, 213 - name, 214 - client_id_url, 215 - client_uri, 216 - redirect_uris, 217 - scopes, 218 - client_type, 219 - allowed_origins, 220 - rate_limit_capacity, 221 - rate_limit_refill_rate, 222 - is_active: is_active != 0, 223 - created_by, 224 - created_at, 225 - updated_at, 226 - } 227 - }, 228 - ) 198 + .map(|row| { 199 + let redirect_uris_json: String = row.get("redirect_uris"); 200 + let allowed_origins_json: Option<String> = row.get("allowed_origins"); 201 + let is_active: i32 = row.get("is_active"); 202 + let redirect_uris: Vec<String> = 203 + serde_json::from_str(&redirect_uris_json).unwrap_or_default(); 204 + let allowed_origins: Option<Vec<String>> = allowed_origins_json 205 + .as_deref() 206 + .and_then(|j| serde_json::from_str(j).ok()); 207 + ApiClientSummary { 208 + id: row.get("id"), 209 + client_key: row.get("client_key"), 210 + name: row.get("name"), 211 + client_id_url: row.get("client_id_url"), 212 + client_uri: row.get("client_uri"), 213 + redirect_uris, 214 + scopes: row.get("scopes"), 215 + client_type: row.get("client_type"), 216 + allowed_origins, 217 + rate_limit_capacity: row.get("rate_limit_capacity"), 218 + rate_limit_refill_rate: row.get("rate_limit_refill_rate"), 219 + is_active: is_active != 0, 220 + created_by: row.get("created_by"), 221 + created_at: row.get("created_at"), 222 + updated_at: row.get("updated_at"), 223 + parent_client_id: row.get("parent_client_id"), 224 + owner_did: row.get("owner_did"), 225 + } 226 + }) 229 227 .collect(); 230 228 231 229 Ok(Json(clients)) ··· 240 238 auth.require(Permission::ApiClientsView).await?; 241 239 242 240 let select_sql = adapt_sql( 243 - "SELECT id, client_key, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, rate_limit_capacity, rate_limit_refill_rate, is_active, created_by, created_at, updated_at FROM api_clients WHERE id = ?", 241 + "SELECT id, client_key, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, rate_limit_capacity, rate_limit_refill_rate, is_active, created_by, created_at, updated_at, parent_client_id, owner_did FROM api_clients WHERE id = ?", 244 242 state.db_backend, 245 243 ); 246 244 247 - type GetRow = ( 248 - String, 249 - String, 250 - String, 251 - String, 252 - String, 253 - String, 254 - String, 255 - String, 256 - Option<String>, 257 - Option<i32>, 258 - Option<f64>, 259 - i32, 260 - String, 261 - String, 262 - String, 263 - ); 264 - let row: Option<GetRow> = sqlx::query_as(&select_sql) 245 + let row = sqlx::query(&select_sql) 265 246 .bind(&id) 266 247 .fetch_optional(&state.db) 267 248 .await 268 249 .map_err(|e| AppError::Internal(format!("failed to get api client: {e}")))?; 269 250 270 - let Some(( 271 - id, 272 - client_key, 273 - name, 274 - client_id_url, 275 - client_uri, 276 - redirect_uris_json, 277 - scopes, 278 - client_type, 279 - allowed_origins_json, 280 - rate_limit_capacity, 281 - rate_limit_refill_rate, 282 - is_active, 283 - created_by, 284 - created_at, 285 - updated_at, 286 - )) = row 287 - else { 251 + let Some(row) = row else { 288 252 return Err(AppError::NotFound(format!("api client '{id}' not found"))); 289 253 }; 290 254 255 + let redirect_uris_json: String = row.get("redirect_uris"); 256 + let allowed_origins_json: Option<String> = row.get("allowed_origins"); 257 + let is_active: i32 = row.get("is_active"); 291 258 let redirect_uris: Vec<String> = serde_json::from_str(&redirect_uris_json).unwrap_or_default(); 292 259 let allowed_origins: Option<Vec<String>> = allowed_origins_json 293 260 .as_deref() 294 261 .and_then(|j| serde_json::from_str(j).ok()); 295 262 296 263 Ok(Json(ApiClientSummary { 297 - id, 298 - client_key, 299 - name, 300 - client_id_url, 301 - client_uri, 264 + id: row.get("id"), 265 + client_key: row.get("client_key"), 266 + name: row.get("name"), 267 + client_id_url: row.get("client_id_url"), 268 + client_uri: row.get("client_uri"), 302 269 redirect_uris, 303 - scopes, 304 - client_type, 270 + scopes: row.get("scopes"), 271 + client_type: row.get("client_type"), 305 272 allowed_origins, 306 - rate_limit_capacity, 307 - rate_limit_refill_rate, 273 + rate_limit_capacity: row.get("rate_limit_capacity"), 274 + rate_limit_refill_rate: row.get("rate_limit_refill_rate"), 308 275 is_active: is_active != 0, 309 - created_by, 310 - created_at, 311 - updated_at, 276 + created_by: row.get("created_by"), 277 + created_at: row.get("created_at"), 278 + updated_at: row.get("updated_at"), 279 + parent_client_id: row.get("parent_client_id"), 280 + owner_did: row.get("owner_did"), 312 281 })) 313 282 } 314 283 ··· 456 425 } else { 457 426 state.rate_limiter.remove_client_identity(&client_key); 458 427 state.rate_limiter.remove_client_config(&client_key); 428 + 429 + // Cascade deactivation to child clients. 430 + let deactivate_children_sql = adapt_sql( 431 + "UPDATE api_clients SET is_active = 0, updated_at = ? WHERE parent_client_id = ? AND is_active = 1", 432 + state.db_backend, 433 + ); 434 + let _ = sqlx::query(&deactivate_children_sql) 435 + .bind(&now) 436 + .bind(&id) 437 + .execute(&state.db) 438 + .await; 439 + 440 + let children_sql = adapt_sql( 441 + "SELECT client_id_url, client_key FROM api_clients WHERE parent_client_id = ?", 442 + state.db_backend, 443 + ); 444 + if let Ok(children) = sqlx::query_as::<_, (String, String)>(&children_sql) 445 + .bind(&id) 446 + .fetch_all(&state.db) 447 + .await 448 + { 449 + for (child_url, child_key) in children { 450 + state.oauth.remove(&child_url); 451 + state.rate_limiter.remove_client_config(&child_key); 452 + state.rate_limiter.remove_client_identity(&child_key); 453 + } 454 + } 459 455 } 460 456 461 457 log_event( ··· 493 489 .await 494 490 .map_err(|e| AppError::Internal(format!("failed to look up api client: {e}")))?; 495 491 492 + // Look up child clients before deleting (ON DELETE CASCADE will remove DB rows). 493 + let children_sql = adapt_sql( 494 + "SELECT client_id_url, client_key FROM api_clients WHERE parent_client_id = ?", 495 + state.db_backend, 496 + ); 497 + let children: Vec<(String, String)> = sqlx::query_as(&children_sql) 498 + .bind(&id) 499 + .fetch_all(&state.db) 500 + .await 501 + .unwrap_or_default(); 502 + 496 503 let delete_sql = adapt_sql("DELETE FROM api_clients WHERE id = ?", state.db_backend); 497 504 498 505 let result = sqlx::query(&delete_sql) ··· 505 512 return Err(AppError::NotFound(format!("api client '{id}' not found"))); 506 513 } 507 514 508 - // Remove from OAuth registry, rate limiter, and client identities. 515 + // Remove parent from OAuth registry, rate limiter, and client identities. 509 516 if let Some((url, key)) = client_info { 510 517 state.oauth.remove(&url); 511 518 state.rate_limiter.remove_client_config(&key); 512 519 state.rate_limiter.remove_client_identity(&key); 520 + } 521 + 522 + // Remove child clients from in-memory registries (DB rows already cascaded). 523 + for (child_url, child_key) in &children { 524 + state.oauth.remove(child_url); 525 + state.rate_limiter.remove_client_config(child_key); 526 + state.rate_limiter.remove_client_identity(child_key); 513 527 } 514 528 515 529 log_event(
+1 -1
src/admin/mod.rs
··· 14 14 mod script_variables; 15 15 pub mod settings; 16 16 mod stats; 17 - mod types; 17 + pub(crate) mod types; 18 18 mod users; 19 19 20 20 use axum::Router;
+9 -7
src/admin/types.rs
··· 398 398 pub(super) created_by: String, 399 399 pub(super) created_at: String, 400 400 pub(super) updated_at: String, 401 + pub(super) parent_client_id: Option<String>, 402 + pub(super) owner_did: Option<String>, 401 403 } 402 404 403 405 #[derive(Serialize)] 404 - pub(super) struct CreateApiClientResponse { 405 - pub(super) id: String, 406 - pub(super) client_key: String, 406 + pub(crate) struct CreateApiClientResponse { 407 + pub(crate) id: String, 408 + pub(crate) client_key: String, 407 409 #[serde(skip_serializing_if = "Option::is_none")] 408 - pub(super) client_secret: Option<String>, 409 - pub(super) name: String, 410 - pub(super) client_id_url: String, 411 - pub(super) client_type: String, 410 + pub(crate) client_secret: Option<String>, 411 + pub(crate) name: String, 412 + pub(crate) client_id_url: String, 413 + pub(crate) client_type: String, 412 414 }
+3 -1
src/error.rs
··· 51 51 AuthDpopNonce(String), 52 52 BadGateway(String), 53 53 BadRequest(String), 54 + Conflict(String), 54 55 Forbidden(String), 55 56 InsufficientPermissions(String), 56 57 Internal(String), ··· 76 77 AppError::AuthDpopNonce(nonce) => write!(f, "auth error: use_dpop_nonce ({nonce})"), 77 78 AppError::BadGateway(msg) => write!(f, "bad gateway: {msg}"), 78 79 AppError::BadRequest(msg) => write!(f, "bad request: {msg}"), 80 + AppError::Conflict(msg) => write!(f, "conflict: {msg}"), 79 81 AppError::Forbidden(msg) => write!(f, "forbidden: {msg}"), 80 82 AppError::InsufficientPermissions(perm) => write!(f, "Missing permission: {perm}"), 81 83 AppError::Internal(msg) => write!(f, "internal error: {msg}"), ··· 170 172 AppError::Auth(msg) => (StatusCode::UNAUTHORIZED, msg.clone()), 171 173 AppError::BadGateway(msg) => (StatusCode::BAD_GATEWAY, msg.clone()), 172 174 AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()), 173 - 175 + AppError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()), 174 176 AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg.clone()), 175 177 AppError::Internal(msg) => { 176 178 tracing::error!("{msg}");
+26 -8
src/main.rs
··· 272 272 273 273 // Load per-client rate limit configs and identities from api_clients table. 274 274 { 275 - type ClientRow = (String, String, String, Option<i32>, Option<f64>); 275 + type ClientRow = ( 276 + String, 277 + String, 278 + String, 279 + Option<i32>, 280 + Option<f64>, 281 + Option<String>, 282 + ); 276 283 let client_rows: Vec<ClientRow> = sqlx::query_as( 277 - "SELECT client_key, client_secret_hash, client_uri, rate_limit_capacity, rate_limit_refill_rate FROM api_clients WHERE is_active = 1", 284 + "SELECT client_key, client_secret_hash, client_uri, rate_limit_capacity, rate_limit_refill_rate, parent_client_id FROM api_clients WHERE is_active = 1", 278 285 ) 279 286 .fetch_all(&db_pool) 280 287 .await 281 288 .unwrap_or_default(); 282 289 283 - for (client_key, secret_hash, client_uri, capacity, refill_rate) in client_rows { 290 + for (client_key, secret_hash, client_uri, capacity, refill_rate, _) in &client_rows { 284 291 rate_limiter.register_client_identity( 285 292 client_key.clone(), 286 293 happyview::rate_limit::ClientIdentity { 287 - secret_hash, 288 - client_uri, 294 + secret_hash: secret_hash.clone(), 295 + client_uri: client_uri.clone(), 289 296 }, 290 297 ); 291 298 if let (Some(cap), Some(refill)) = (capacity, refill_rate) { 292 299 rate_limiter.register_client_config( 293 - client_key, 300 + client_key.clone(), 301 + happyview::rate_limit::RateLimitConfig { 302 + capacity: *cap as u32, 303 + refill_rate: *refill, 304 + default_query_cost: defaults.query_cost, 305 + default_procedure_cost: defaults.procedure_cost, 306 + default_proxy_cost: defaults.proxy_cost, 307 + }, 308 + ); 309 + } else { 310 + rate_limiter.register_client_config( 311 + client_key.clone(), 294 312 happyview::rate_limit::RateLimitConfig { 295 - capacity: cap as u32, 296 - refill_rate: refill, 313 + capacity: config.default_rate_limit_capacity, 314 + refill_rate: config.default_rate_limit_refill_rate, 297 315 default_query_cost: defaults.query_cost, 298 316 default_procedure_cost: defaults.procedure_cost, 299 317 default_proxy_cost: defaults.proxy_cost,
+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; 6 7 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 + ) 21 25 } 22 26 23 27 // --- 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 + }
+12
src/rate_limit.rs
··· 170 170 self.client_configs.insert(client_key, config); 171 171 } 172 172 173 + pub fn get_client_config(&self, client_key: &str) -> Option<RateLimitConfig> { 174 + self.client_configs 175 + .get(client_key) 176 + .map(|cfg| RateLimitConfig { 177 + capacity: cfg.capacity, 178 + refill_rate: cfg.refill_rate, 179 + default_query_cost: cfg.default_query_cost, 180 + default_procedure_cost: cfg.default_procedure_cost, 181 + default_proxy_cost: cfg.default_proxy_cost, 182 + }) 183 + } 184 + 173 185 pub fn remove_client_config(&self, client_key: &str) { 174 186 self.client_configs.remove(client_key); 175 187 }
+4 -1
tests/common/db.rs
··· 20 20 match backend { 21 21 DatabaseBackend::Postgres => { 22 22 sqlx::query( 23 - "TRUNCATE records, lexicons, backfill_jobs, users, user_permissions, api_keys, event_logs, script_variables, dead_letter_hooks, record_refs, labeler_subscriptions, labels, instance_settings, domains RESTART IDENTITY CASCADE", 23 + "TRUNCATE records, lexicons, backfill_jobs, users, user_permissions, api_keys, event_logs, script_variables, dead_letter_hooks, record_refs, labeler_subscriptions, labels, instance_settings, domains, dpop_sessions, dpop_keys, api_clients RESTART IDENTITY CASCADE", 24 24 ) 25 25 .execute(pool) 26 26 .await ··· 28 28 } 29 29 DatabaseBackend::Sqlite => { 30 30 let tables = [ 31 + "dpop_sessions", 32 + "dpop_keys", 33 + "api_clients", 31 34 "records", 32 35 "lexicons", 33 36 "backfill_jobs",
+940
tests/e2e_api_clients.rs
··· 2 2 3 3 use axum::body::Body; 4 4 use axum::http::{Request, StatusCode}; 5 + use happyview::db::{adapt_sql, now_rfc3339}; 6 + use happyview::oauth::pds_write::generate_dpop_proof; 5 7 use http_body_util::BodyExt; 6 8 use serde_json::{Value, json}; 7 9 use serial_test::serial; 10 + use sha2::Digest; 8 11 use tower::ServiceExt; 9 12 10 13 use common::app::TestApp; ··· 641 644 assert_eq!(json["rate_limit_capacity"], 50); 642 645 assert_eq!(json["rate_limit_refill_rate"], 1.5); 643 646 } 647 + 648 + // --------------------------------------------------------------------------- 649 + // Self-service API client creation (POST /oauth/api-clients) 650 + // --------------------------------------------------------------------------- 651 + 652 + /// Helper to make a POST request with JSON body and extra headers. 653 + fn post_json_with_headers( 654 + uri: &str, 655 + body: &serde_json::Value, 656 + headers: Vec<(&str, &str)>, 657 + ) -> Request<Body> { 658 + let mut builder = Request::builder() 659 + .method("POST") 660 + .uri(uri) 661 + .header("content-type", "application/json") 662 + .header("host", "127.0.0.1:0"); 663 + for (name, value) in headers { 664 + builder = builder.header(name, value); 665 + } 666 + builder 667 + .body(Body::from(serde_json::to_vec(body).unwrap())) 668 + .unwrap() 669 + } 670 + 671 + /// Parse a response body as JSON, returning `null` on empty/invalid bodies. 672 + async fn response_json(resp: axum::response::Response) -> Value { 673 + let body = resp.into_body().collect().await.unwrap().to_bytes(); 674 + serde_json::from_slice(&body).unwrap_or(json!(null)) 675 + } 676 + 677 + /// Runs the full DPoP provisioning flow and returns the values needed to call 678 + /// the self-service endpoint: 679 + /// `(client_key, dpop_key_json, access_token)` 680 + /// 681 + /// `user_did` is the DID that will be associated with the DPoP session. 682 + async fn setup_dpop_session(app: &TestApp, user_did: &str) -> (String, Value, String) { 683 + let (client_key, client_secret, _id) = app.create_api_client("confidential", None).await; 684 + 685 + // 1. Provision DPoP key 686 + let key_req = post_json_with_headers( 687 + "/oauth/dpop-keys", 688 + &json!({}), 689 + vec![ 690 + ("x-client-key", &client_key), 691 + ("x-client-secret", &client_secret), 692 + ], 693 + ); 694 + let key_resp = app.router.clone().oneshot(key_req).await.unwrap(); 695 + assert_eq!( 696 + key_resp.status(), 697 + StatusCode::CREATED, 698 + "dpop key provisioning failed" 699 + ); 700 + let key_body = response_json(key_resp).await; 701 + let provision_id = key_body["provision_id"].as_str().unwrap().to_string(); 702 + let dpop_key = key_body["dpop_key"].clone(); 703 + 704 + // 2. Register session 705 + let access_token = format!("test-access-{}", uuid::Uuid::new_v4()); 706 + let session_req = post_json_with_headers( 707 + "/oauth/sessions", 708 + &json!({ 709 + "provision_id": provision_id, 710 + "did": user_did, 711 + "access_token": &access_token, 712 + "scopes": "atproto", 713 + "pds_url": "https://pds.example.com", 714 + }), 715 + vec![ 716 + ("x-client-key", &client_key), 717 + ("x-client-secret", &client_secret), 718 + ], 719 + ); 720 + let session_resp = app.router.clone().oneshot(session_req).await.unwrap(); 721 + assert_eq!( 722 + session_resp.status(), 723 + StatusCode::CREATED, 724 + "session registration failed" 725 + ); 726 + 727 + (client_key, dpop_key, access_token) 728 + } 729 + 730 + /// Build a self-service POST /oauth/api-clients request with full DPoP auth. 731 + fn self_service_request( 732 + client_key: &str, 733 + access_token: &str, 734 + dpop_proof: &str, 735 + body: &Value, 736 + ) -> Request<Body> { 737 + Request::builder() 738 + .method("POST") 739 + .uri("/oauth/api-clients") 740 + .header("host", "127.0.0.1:0") 741 + .header("content-type", "application/json") 742 + .header("x-client-key", client_key) 743 + .header("authorization", format!("DPoP {}", access_token)) 744 + .header("dpop", dpop_proof) 745 + .body(Body::from(serde_json::to_vec(body).unwrap())) 746 + .unwrap() 747 + } 748 + 749 + // --------------------------------------------------------------------------- 750 + // Happy path 751 + // --------------------------------------------------------------------------- 752 + 753 + #[tokio::test] 754 + #[serial] 755 + #[ignore] 756 + async fn test_self_service_create_confidential_child_client() { 757 + let app = TestApp::new_with_encryption().await; 758 + let (client_key, dpop_key, access_token) = setup_dpop_session(&app, "did:plc:testadmin").await; 759 + 760 + let request_url = "http://127.0.0.1:0/oauth/api-clients"; 761 + let proof = generate_dpop_proof(&dpop_key, "POST", request_url, &access_token, None) 762 + .expect("failed to generate DPoP proof"); 763 + 764 + let body = json!({ 765 + "name": "My Confidential Child", 766 + "client_id_url": "https://child-confidential.example.com/oauth-client-metadata.json", 767 + "client_uri": "https://child-confidential.example.com", 768 + "redirect_uris": ["https://child-confidential.example.com/callback"], 769 + "scopes": "atproto", 770 + "client_type": "confidential" 771 + }); 772 + 773 + let req = self_service_request(&client_key, &access_token, &proof, &body); 774 + let resp = app.router.clone().oneshot(req).await.unwrap(); 775 + 776 + assert_eq!(resp.status(), StatusCode::CREATED); 777 + let json = response_json(resp).await; 778 + 779 + // Verify all expected fields 780 + assert!(json["id"].as_str().is_some(), "response should have id"); 781 + let key = json["client_key"].as_str().unwrap(); 782 + assert!(key.starts_with("hvc_"), "client_key should start with hvc_"); 783 + let secret = json["client_secret"].as_str().unwrap(); 784 + assert!( 785 + secret.starts_with("hvs_"), 786 + "client_secret should start with hvs_" 787 + ); 788 + assert_eq!(json["name"], "My Confidential Child"); 789 + assert_eq!( 790 + json["client_id_url"], 791 + "https://child-confidential.example.com/oauth-client-metadata.json" 792 + ); 793 + assert_eq!(json["client_type"], "confidential"); 794 + } 795 + 796 + #[tokio::test] 797 + #[serial] 798 + #[ignore] 799 + async fn test_self_service_create_public_child_client() { 800 + let app = TestApp::new_with_encryption().await; 801 + let (client_key, dpop_key, access_token) = setup_dpop_session(&app, "did:plc:testadmin").await; 802 + 803 + let request_url = "http://127.0.0.1:0/oauth/api-clients"; 804 + let proof = generate_dpop_proof(&dpop_key, "POST", request_url, &access_token, None) 805 + .expect("failed to generate DPoP proof"); 806 + 807 + let body = json!({ 808 + "name": "My Public Child", 809 + "client_id_url": "https://child-public.example.com/oauth-client-metadata.json", 810 + "client_uri": "https://child-public.example.com", 811 + "redirect_uris": ["https://child-public.example.com/callback"], 812 + "scopes": "atproto", 813 + "client_type": "public" 814 + }); 815 + 816 + let req = self_service_request(&client_key, &access_token, &proof, &body); 817 + let resp = app.router.clone().oneshot(req).await.unwrap(); 818 + 819 + assert_eq!(resp.status(), StatusCode::CREATED); 820 + let json = response_json(resp).await; 821 + 822 + assert!(json["id"].as_str().is_some(), "response should have id"); 823 + let key = json["client_key"].as_str().unwrap(); 824 + assert!(key.starts_with("hvc_"), "client_key should start with hvc_"); 825 + assert_eq!(json["name"], "My Public Child"); 826 + assert_eq!( 827 + json["client_id_url"], 828 + "https://child-public.example.com/oauth-client-metadata.json" 829 + ); 830 + assert_eq!(json["client_type"], "public"); 831 + // Public clients should NOT have a client_secret 832 + assert!( 833 + json["client_secret"].is_null(), 834 + "public client should not have a client_secret" 835 + ); 836 + } 837 + 838 + // --------------------------------------------------------------------------- 839 + // Error cases 840 + // --------------------------------------------------------------------------- 841 + 842 + #[tokio::test] 843 + #[serial] 844 + #[ignore] 845 + async fn test_self_service_child_cannot_create_children() { 846 + let app = TestApp::new_with_encryption().await; 847 + 848 + // Create a parent client via the helper (top-level, no parent_client_id). 849 + let (_parent_key, _parent_secret, parent_id) = 850 + app.create_api_client("confidential", None).await; 851 + 852 + // Insert a child client directly in the DB with parent_client_id set. 853 + let child_key = format!("hvc_{}", hex::encode([0xAAu8; 16])); 854 + let child_secret = format!("hvs_{}", hex::encode([0xBBu8; 32])); 855 + let child_secret_hash = hex::encode(sha2::Sha256::digest(child_secret.as_bytes())); 856 + let child_id = uuid::Uuid::new_v4().to_string(); 857 + let now = now_rfc3339(); 858 + 859 + let sql = adapt_sql( 860 + "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, is_active, created_by, created_at, updated_at, parent_client_id, owner_did) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)", 861 + app.state.db_backend, 862 + ); 863 + sqlx::query(&sql) 864 + .bind(&child_id) 865 + .bind(&child_key) 866 + .bind(&child_secret_hash) 867 + .bind("child-client") 868 + .bind("https://child-no-nest.example.com/oauth-client-metadata.json") 869 + .bind("https://child-no-nest.example.com") 870 + .bind("[]") 871 + .bind("atproto") 872 + .bind("confidential") 873 + .bind("did:plc:testadmin") 874 + .bind(&now) 875 + .bind(&now) 876 + .bind(&parent_id) 877 + .bind("did:plc:testadmin") 878 + .execute(&app.state.db) 879 + .await 880 + .expect("failed to insert child client"); 881 + 882 + // The endpoint checks parent_client_id IS NULL at step 6, BEFORE DPoP 883 + // validation. So we just need the DPoP headers to exist — they do not 884 + // need to be cryptographically valid. 885 + let body = json!({ 886 + "name": "Grandchild", 887 + "client_id_url": "https://grandchild.example.com/oauth-client-metadata.json", 888 + "client_uri": "https://grandchild.example.com", 889 + "redirect_uris": ["https://grandchild.example.com/callback"], 890 + "scopes": "atproto", 891 + "client_type": "confidential" 892 + }); 893 + 894 + let req = Request::builder() 895 + .method("POST") 896 + .uri("/oauth/api-clients") 897 + .header("host", "127.0.0.1:0") 898 + .header("content-type", "application/json") 899 + .header("x-client-key", &child_key) 900 + .header("authorization", "DPoP fake-token") 901 + .header("dpop", "fake-proof") 902 + .body(Body::from(serde_json::to_vec(&body).unwrap())) 903 + .unwrap(); 904 + 905 + let resp = app.router.clone().oneshot(req).await.unwrap(); 906 + assert_eq!(resp.status(), StatusCode::FORBIDDEN); 907 + } 908 + 909 + #[tokio::test] 910 + #[serial] 911 + #[ignore] 912 + async fn test_self_service_duplicate_client_id_url() { 913 + let app = TestApp::new_with_encryption().await; 914 + let (client_key, dpop_key, access_token) = setup_dpop_session(&app, "did:plc:testadmin").await; 915 + 916 + let shared_url = "https://dup-test.example.com/oauth-client-metadata.json"; 917 + 918 + // First creation should succeed. 919 + let request_url = "http://127.0.0.1:0/oauth/api-clients"; 920 + let proof1 = 921 + generate_dpop_proof(&dpop_key, "POST", request_url, &access_token, None).expect("proof 1"); 922 + 923 + let body = json!({ 924 + "name": "First Child", 925 + "client_id_url": shared_url, 926 + "client_uri": "https://dup-test.example.com", 927 + "redirect_uris": ["https://dup-test.example.com/callback"], 928 + "scopes": "atproto", 929 + "client_type": "confidential" 930 + }); 931 + 932 + let req1 = self_service_request(&client_key, &access_token, &proof1, &body); 933 + let resp1 = app.router.clone().oneshot(req1).await.unwrap(); 934 + assert_eq!(resp1.status(), StatusCode::CREATED); 935 + 936 + // Second creation with the same client_id_url should fail with 409. 937 + let proof2 = 938 + generate_dpop_proof(&dpop_key, "POST", request_url, &access_token, None).expect("proof 2"); 939 + 940 + let body2 = json!({ 941 + "name": "Second Child", 942 + "client_id_url": shared_url, 943 + "client_uri": "https://dup-test2.example.com", 944 + "redirect_uris": ["https://dup-test2.example.com/callback"], 945 + "scopes": "atproto", 946 + "client_type": "confidential" 947 + }); 948 + 949 + let req2 = self_service_request(&client_key, &access_token, &proof2, &body2); 950 + let resp2 = app.router.clone().oneshot(req2).await.unwrap(); 951 + assert_eq!(resp2.status(), StatusCode::CONFLICT); 952 + } 953 + 954 + #[tokio::test] 955 + #[serial] 956 + #[ignore] 957 + async fn test_self_service_missing_client_key() { 958 + let app = TestApp::new_with_encryption().await; 959 + 960 + let body = json!({ 961 + "name": "No Key Client", 962 + "client_id_url": "https://nokey.example.com/oauth-client-metadata.json", 963 + "client_uri": "https://nokey.example.com", 964 + "redirect_uris": ["https://nokey.example.com/callback"], 965 + "scopes": "atproto", 966 + "client_type": "confidential" 967 + }); 968 + 969 + // No x-client-key header at all. 970 + let req = Request::builder() 971 + .method("POST") 972 + .uri("/oauth/api-clients") 973 + .header("host", "127.0.0.1:0") 974 + .header("content-type", "application/json") 975 + .header("authorization", "DPoP fake-token") 976 + .header("dpop", "fake-proof") 977 + .body(Body::from(serde_json::to_vec(&body).unwrap())) 978 + .unwrap(); 979 + 980 + let resp = app.router.clone().oneshot(req).await.unwrap(); 981 + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); 982 + } 983 + 984 + #[tokio::test] 985 + #[serial] 986 + #[ignore] 987 + async fn test_self_service_invalid_client_type() { 988 + let app = TestApp::new_with_encryption().await; 989 + 990 + // Step 3 (client_type validation) happens before step 5 (client resolution). 991 + // The request just needs the required headers to exist. 992 + let body = json!({ 993 + "name": "Invalid Type Client", 994 + "client_id_url": "https://badtype.example.com/oauth-client-metadata.json", 995 + "client_uri": "https://badtype.example.com", 996 + "redirect_uris": ["https://badtype.example.com/callback"], 997 + "scopes": "atproto", 998 + "client_type": "invalid" 999 + }); 1000 + 1001 + let req = Request::builder() 1002 + .method("POST") 1003 + .uri("/oauth/api-clients") 1004 + .header("host", "127.0.0.1:0") 1005 + .header("content-type", "application/json") 1006 + .header("x-client-key", "hvc_doesnotmatter") 1007 + .header("authorization", "DPoP fake-token") 1008 + .header("dpop", "fake-proof") 1009 + .body(Body::from(serde_json::to_vec(&body).unwrap())) 1010 + .unwrap(); 1011 + 1012 + let resp = app.router.clone().oneshot(req).await.unwrap(); 1013 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 1014 + } 1015 + 1016 + #[tokio::test] 1017 + #[serial] 1018 + #[ignore] 1019 + async fn test_self_service_parent_owner_not_in_users() { 1020 + let app = TestApp::new_with_encryption().await; 1021 + 1022 + // Create a parent API client whose created_by DID is NOT in the users table. 1023 + let orphan_did = "did:plc:orphan"; 1024 + let (client_key, client_secret, _id) = { 1025 + use happyview::db::{adapt_sql, now_rfc3339}; 1026 + use rand::RngCore; 1027 + use sha2::{Digest, Sha256}; 1028 + 1029 + let mut key_bytes = [0u8; 16]; 1030 + rand::rng().fill_bytes(&mut key_bytes); 1031 + let client_key = format!("hvc_{}", hex::encode(key_bytes)); 1032 + 1033 + let mut secret_bytes = [0u8; 32]; 1034 + rand::rng().fill_bytes(&mut secret_bytes); 1035 + let client_secret = format!("hvs_{}", hex::encode(secret_bytes)); 1036 + let secret_hash = hex::encode(Sha256::digest(client_secret.as_bytes())); 1037 + 1038 + let id = uuid::Uuid::new_v4().to_string(); 1039 + let now = now_rfc3339(); 1040 + 1041 + let sql = adapt_sql( 1042 + "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) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)", 1043 + app.state.db_backend, 1044 + ); 1045 + 1046 + sqlx::query(&sql) 1047 + .bind(&id) 1048 + .bind(&client_key) 1049 + .bind(&secret_hash) 1050 + .bind("orphan-client") 1051 + .bind(format!("https://orphan.example.com/oauth/{}", &id[..8])) 1052 + .bind("https://orphan.example.com") 1053 + .bind("[]") 1054 + .bind("atproto") 1055 + .bind("confidential") 1056 + .bind(None::<String>) 1057 + .bind(orphan_did) 1058 + .bind(&now) 1059 + .bind(&now) 1060 + .execute(&app.state.db) 1061 + .await 1062 + .expect("failed to create orphan API client"); 1063 + 1064 + app.state.rate_limiter.register_client_identity( 1065 + client_key.clone(), 1066 + happyview::rate_limit::ClientIdentity { 1067 + secret_hash, 1068 + client_uri: "https://orphan.example.com".to_string(), 1069 + }, 1070 + ); 1071 + 1072 + (client_key, client_secret, id) 1073 + }; 1074 + 1075 + // Provision a DPoP key and session using this orphan parent client. 1076 + let key_req = post_json_with_headers( 1077 + "/oauth/dpop-keys", 1078 + &json!({}), 1079 + vec![ 1080 + ("x-client-key", &client_key), 1081 + ("x-client-secret", &client_secret), 1082 + ], 1083 + ); 1084 + let key_resp = app.router.clone().oneshot(key_req).await.unwrap(); 1085 + assert_eq!(key_resp.status(), StatusCode::CREATED); 1086 + let key_body = response_json(key_resp).await; 1087 + let provision_id = key_body["provision_id"].as_str().unwrap().to_string(); 1088 + let dpop_key = key_body["dpop_key"].clone(); 1089 + 1090 + let access_token = format!("test-access-{}", uuid::Uuid::new_v4()); 1091 + let session_req = post_json_with_headers( 1092 + "/oauth/sessions", 1093 + &json!({ 1094 + "provision_id": provision_id, 1095 + "did": "did:plc:sessionuser", 1096 + "access_token": &access_token, 1097 + "scopes": "atproto", 1098 + "pds_url": "https://pds.example.com", 1099 + }), 1100 + vec![ 1101 + ("x-client-key", &client_key), 1102 + ("x-client-secret", &client_secret), 1103 + ], 1104 + ); 1105 + let session_resp = app.router.clone().oneshot(session_req).await.unwrap(); 1106 + assert_eq!(session_resp.status(), StatusCode::CREATED); 1107 + 1108 + let request_url = "http://127.0.0.1:0/oauth/api-clients"; 1109 + let proof = generate_dpop_proof(&dpop_key, "POST", request_url, &access_token, None) 1110 + .expect("failed to generate DPoP proof"); 1111 + 1112 + let body = json!({ 1113 + "name": "Orphan Owner Child", 1114 + "client_id_url": "https://orphan-child.example.com/oauth-client-metadata.json", 1115 + "client_uri": "https://orphan-child.example.com", 1116 + "redirect_uris": ["https://orphan-child.example.com/callback"], 1117 + "scopes": "atproto", 1118 + "client_type": "confidential" 1119 + }); 1120 + 1121 + let req = self_service_request(&client_key, &access_token, &proof, &body); 1122 + let resp = app.router.clone().oneshot(req).await.unwrap(); 1123 + assert_eq!(resp.status(), StatusCode::FORBIDDEN); 1124 + } 1125 + 1126 + // --------------------------------------------------------------------------- 1127 + // Cascade: deactivate / delete parent cascades to children 1128 + // --------------------------------------------------------------------------- 1129 + 1130 + #[tokio::test] 1131 + #[serial] 1132 + #[ignore] 1133 + async fn test_deactivate_parent_cascades_to_children() { 1134 + let app = TestApp::new().await; 1135 + 1136 + // Create parent via admin API 1137 + let parent_body = json!({ 1138 + "name": "Cascade Parent", 1139 + "client_id_url": "https://cascade-deactivate-parent.example.com/oauth-client-metadata.json", 1140 + "client_uri": "https://cascade-deactivate-parent.example.com", 1141 + "redirect_uris": ["https://happyview.example.com/auth/callback"], 1142 + "scopes": "atproto" 1143 + }); 1144 + let create_resp = app 1145 + .router 1146 + .clone() 1147 + .oneshot(admin_post( 1148 + "/admin/api-clients", 1149 + app.admin_cookie(), 1150 + &parent_body, 1151 + )) 1152 + .await 1153 + .unwrap(); 1154 + assert_eq!(create_resp.status(), StatusCode::CREATED); 1155 + let created = json_body(create_resp).await; 1156 + let parent_id = created["id"].as_str().unwrap().to_string(); 1157 + 1158 + // Insert child directly in DB 1159 + let child_id = uuid::Uuid::new_v4().to_string(); 1160 + let child_key = format!("hvc_{}", hex::encode([0xCCu8; 16])); 1161 + let child_hash = hex::encode(sha2::Sha256::digest("hvs_fake_cc".as_bytes())); 1162 + let now = now_rfc3339(); 1163 + 1164 + let sql = adapt_sql( 1165 + "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, is_active, created_by, created_at, updated_at, parent_client_id, owner_did) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)", 1166 + app.state.db_backend, 1167 + ); 1168 + sqlx::query(&sql) 1169 + .bind(&child_id) 1170 + .bind(&child_key) 1171 + .bind(&child_hash) 1172 + .bind("Cascade Child") 1173 + .bind("https://cascade-deactivate-child.example.com/metadata.json") 1174 + .bind("https://cascade-deactivate-child.example.com") 1175 + .bind("[]") 1176 + .bind("atproto") 1177 + .bind("confidential") 1178 + .bind("did:plc:testadmin") 1179 + .bind(&now) 1180 + .bind(&now) 1181 + .bind(&parent_id) 1182 + .bind("did:plc:testadmin") 1183 + .execute(&app.state.db) 1184 + .await 1185 + .expect("failed to insert child"); 1186 + 1187 + // Deactivate the parent 1188 + let deactivate_resp = app 1189 + .router 1190 + .clone() 1191 + .oneshot(admin_put( 1192 + &format!("/admin/api-clients/{parent_id}"), 1193 + app.admin_cookie(), 1194 + &json!({"is_active": false}), 1195 + )) 1196 + .await 1197 + .unwrap(); 1198 + assert_eq!(deactivate_resp.status(), StatusCode::NO_CONTENT); 1199 + 1200 + // Verify parent is deactivated 1201 + let parent_get = app 1202 + .router 1203 + .clone() 1204 + .oneshot(admin_get( 1205 + &format!("/admin/api-clients/{parent_id}"), 1206 + app.admin_cookie(), 1207 + )) 1208 + .await 1209 + .unwrap(); 1210 + assert_eq!(parent_get.status(), StatusCode::OK); 1211 + let parent_json = json_body(parent_get).await; 1212 + assert_eq!( 1213 + parent_json["is_active"], false, 1214 + "parent should be deactivated" 1215 + ); 1216 + 1217 + // Verify child is also deactivated 1218 + let child_get = app 1219 + .router 1220 + .clone() 1221 + .oneshot(admin_get( 1222 + &format!("/admin/api-clients/{child_id}"), 1223 + app.admin_cookie(), 1224 + )) 1225 + .await 1226 + .unwrap(); 1227 + assert_eq!(child_get.status(), StatusCode::OK); 1228 + let child_json = json_body(child_get).await; 1229 + assert_eq!( 1230 + child_json["is_active"], false, 1231 + "child should be deactivated by cascade" 1232 + ); 1233 + } 1234 + 1235 + #[tokio::test] 1236 + #[serial] 1237 + #[ignore] 1238 + async fn test_delete_parent_cascades_to_children() { 1239 + let app = TestApp::new().await; 1240 + 1241 + // Create parent via admin API 1242 + let parent_body = json!({ 1243 + "name": "Delete Parent", 1244 + "client_id_url": "https://cascade-delete-parent.example.com/oauth-client-metadata.json", 1245 + "client_uri": "https://cascade-delete-parent.example.com", 1246 + "redirect_uris": ["https://happyview.example.com/auth/callback"], 1247 + "scopes": "atproto" 1248 + }); 1249 + let create_resp = app 1250 + .router 1251 + .clone() 1252 + .oneshot(admin_post( 1253 + "/admin/api-clients", 1254 + app.admin_cookie(), 1255 + &parent_body, 1256 + )) 1257 + .await 1258 + .unwrap(); 1259 + assert_eq!(create_resp.status(), StatusCode::CREATED); 1260 + let created = json_body(create_resp).await; 1261 + let parent_id = created["id"].as_str().unwrap().to_string(); 1262 + 1263 + // Insert child directly in DB 1264 + let child_id = uuid::Uuid::new_v4().to_string(); 1265 + let child_key = format!("hvc_{}", hex::encode([0xDDu8; 16])); 1266 + let child_hash = hex::encode(sha2::Sha256::digest("hvs_fake_dd".as_bytes())); 1267 + let now = now_rfc3339(); 1268 + 1269 + let sql = adapt_sql( 1270 + "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, is_active, created_by, created_at, updated_at, parent_client_id, owner_did) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)", 1271 + app.state.db_backend, 1272 + ); 1273 + sqlx::query(&sql) 1274 + .bind(&child_id) 1275 + .bind(&child_key) 1276 + .bind(&child_hash) 1277 + .bind("Delete Child") 1278 + .bind("https://cascade-delete-child.example.com/metadata.json") 1279 + .bind("https://cascade-delete-child.example.com") 1280 + .bind("[]") 1281 + .bind("atproto") 1282 + .bind("confidential") 1283 + .bind("did:plc:testadmin") 1284 + .bind(&now) 1285 + .bind(&now) 1286 + .bind(&parent_id) 1287 + .bind("did:plc:testadmin") 1288 + .execute(&app.state.db) 1289 + .await 1290 + .expect("failed to insert child"); 1291 + 1292 + // Delete the parent 1293 + let delete_resp = app 1294 + .router 1295 + .clone() 1296 + .oneshot(admin_delete( 1297 + &format!("/admin/api-clients/{parent_id}"), 1298 + app.admin_cookie(), 1299 + )) 1300 + .await 1301 + .unwrap(); 1302 + assert_eq!(delete_resp.status(), StatusCode::NO_CONTENT); 1303 + 1304 + // Verify parent is gone 1305 + let parent_get = app 1306 + .router 1307 + .clone() 1308 + .oneshot(admin_get( 1309 + &format!("/admin/api-clients/{parent_id}"), 1310 + app.admin_cookie(), 1311 + )) 1312 + .await 1313 + .unwrap(); 1314 + assert_eq!(parent_get.status(), StatusCode::NOT_FOUND); 1315 + 1316 + // Verify child is also gone (ON DELETE CASCADE) 1317 + let child_get = app 1318 + .router 1319 + .clone() 1320 + .oneshot(admin_get( 1321 + &format!("/admin/api-clients/{child_id}"), 1322 + app.admin_cookie(), 1323 + )) 1324 + .await 1325 + .unwrap(); 1326 + assert_eq!(child_get.status(), StatusCode::NOT_FOUND); 1327 + } 1328 + 1329 + // --------------------------------------------------------------------------- 1330 + // List filtering: parent_id query param and response fields 1331 + // --------------------------------------------------------------------------- 1332 + 1333 + #[tokio::test] 1334 + #[serial] 1335 + #[ignore] 1336 + async fn test_list_api_clients_filter_by_parent() { 1337 + let app = TestApp::new().await; 1338 + 1339 + // Create two parents via admin API 1340 + let parent1_body = json!({ 1341 + "name": "Filter Parent 1", 1342 + "client_id_url": "https://filter-parent-1.example.com/oauth-client-metadata.json", 1343 + "client_uri": "https://filter-parent-1.example.com", 1344 + "redirect_uris": ["https://happyview.example.com/auth/callback"], 1345 + "scopes": "atproto" 1346 + }); 1347 + let parent2_body = json!({ 1348 + "name": "Filter Parent 2", 1349 + "client_id_url": "https://filter-parent-2.example.com/oauth-client-metadata.json", 1350 + "client_uri": "https://filter-parent-2.example.com", 1351 + "redirect_uris": ["https://happyview.example.com/auth/callback"], 1352 + "scopes": "atproto" 1353 + }); 1354 + 1355 + let p1_resp = app 1356 + .router 1357 + .clone() 1358 + .oneshot(admin_post( 1359 + "/admin/api-clients", 1360 + app.admin_cookie(), 1361 + &parent1_body, 1362 + )) 1363 + .await 1364 + .unwrap(); 1365 + assert_eq!(p1_resp.status(), StatusCode::CREATED); 1366 + let p1 = json_body(p1_resp).await; 1367 + let parent1_id = p1["id"].as_str().unwrap().to_string(); 1368 + 1369 + let p2_resp = app 1370 + .router 1371 + .clone() 1372 + .oneshot(admin_post( 1373 + "/admin/api-clients", 1374 + app.admin_cookie(), 1375 + &parent2_body, 1376 + )) 1377 + .await 1378 + .unwrap(); 1379 + assert_eq!(p2_resp.status(), StatusCode::CREATED); 1380 + let p2 = json_body(p2_resp).await; 1381 + let parent2_id = p2["id"].as_str().unwrap().to_string(); 1382 + 1383 + // Insert a child under parent 1 1384 + let child1_id = uuid::Uuid::new_v4().to_string(); 1385 + let child1_key = format!("hvc_{}", hex::encode([0xE1u8; 16])); 1386 + let child1_hash = hex::encode(sha2::Sha256::digest("hvs_fake_e1".as_bytes())); 1387 + let now = now_rfc3339(); 1388 + 1389 + let sql = adapt_sql( 1390 + "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, is_active, created_by, created_at, updated_at, parent_client_id, owner_did) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)", 1391 + app.state.db_backend, 1392 + ); 1393 + sqlx::query(&sql) 1394 + .bind(&child1_id) 1395 + .bind(&child1_key) 1396 + .bind(&child1_hash) 1397 + .bind("Child of Parent 1") 1398 + .bind("https://filter-child-1.example.com/metadata.json") 1399 + .bind("https://filter-child-1.example.com") 1400 + .bind("[]") 1401 + .bind("atproto") 1402 + .bind("confidential") 1403 + .bind("did:plc:testadmin") 1404 + .bind(&now) 1405 + .bind(&now) 1406 + .bind(&parent1_id) 1407 + .bind("did:plc:testadmin") 1408 + .execute(&app.state.db) 1409 + .await 1410 + .expect("failed to insert child1"); 1411 + 1412 + // Insert a child under parent 2 1413 + let child2_id = uuid::Uuid::new_v4().to_string(); 1414 + let child2_key = format!("hvc_{}", hex::encode([0xE2u8; 16])); 1415 + let child2_hash = hex::encode(sha2::Sha256::digest("hvs_fake_e2".as_bytes())); 1416 + 1417 + let sql2 = adapt_sql( 1418 + "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, is_active, created_by, created_at, updated_at, parent_client_id, owner_did) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)", 1419 + app.state.db_backend, 1420 + ); 1421 + sqlx::query(&sql2) 1422 + .bind(&child2_id) 1423 + .bind(&child2_key) 1424 + .bind(&child2_hash) 1425 + .bind("Child of Parent 2") 1426 + .bind("https://filter-child-2.example.com/metadata.json") 1427 + .bind("https://filter-child-2.example.com") 1428 + .bind("[]") 1429 + .bind("atproto") 1430 + .bind("confidential") 1431 + .bind("did:plc:testadmin") 1432 + .bind(&now) 1433 + .bind(&now) 1434 + .bind(&parent2_id) 1435 + .bind("did:plc:testadmin") 1436 + .execute(&app.state.db) 1437 + .await 1438 + .expect("failed to insert child2"); 1439 + 1440 + // Filter by parent1_id — should return only child1 1441 + let resp = app 1442 + .router 1443 + .clone() 1444 + .oneshot(admin_get( 1445 + &format!("/admin/api-clients?parent_id={parent1_id}"), 1446 + app.admin_cookie(), 1447 + )) 1448 + .await 1449 + .unwrap(); 1450 + assert_eq!(resp.status(), StatusCode::OK); 1451 + let arr = json_body(resp).await; 1452 + let items = arr.as_array().expect("response should be an array"); 1453 + assert_eq!( 1454 + items.len(), 1455 + 1, 1456 + "should return exactly one child for parent 1" 1457 + ); 1458 + assert_eq!(items[0]["id"], child1_id); 1459 + assert_eq!(items[0]["name"], "Child of Parent 1"); 1460 + 1461 + // Filter by parent2_id — should return only child2 1462 + let resp2 = app 1463 + .router 1464 + .clone() 1465 + .oneshot(admin_get( 1466 + &format!("/admin/api-clients?parent_id={parent2_id}"), 1467 + app.admin_cookie(), 1468 + )) 1469 + .await 1470 + .unwrap(); 1471 + assert_eq!(resp2.status(), StatusCode::OK); 1472 + let arr2 = json_body(resp2).await; 1473 + let items2 = arr2.as_array().expect("response should be an array"); 1474 + assert_eq!( 1475 + items2.len(), 1476 + 1, 1477 + "should return exactly one child for parent 2" 1478 + ); 1479 + assert_eq!(items2[0]["id"], child2_id); 1480 + assert_eq!(items2[0]["name"], "Child of Parent 2"); 1481 + } 1482 + 1483 + #[tokio::test] 1484 + #[serial] 1485 + #[ignore] 1486 + async fn test_list_api_clients_includes_parent_and_owner_fields() { 1487 + let app = TestApp::new().await; 1488 + 1489 + // Create a top-level parent via admin API 1490 + let parent_body = json!({ 1491 + "name": "Fields Parent", 1492 + "client_id_url": "https://fields-parent.example.com/oauth-client-metadata.json", 1493 + "client_uri": "https://fields-parent.example.com", 1494 + "redirect_uris": ["https://happyview.example.com/auth/callback"], 1495 + "scopes": "atproto" 1496 + }); 1497 + let create_resp = app 1498 + .router 1499 + .clone() 1500 + .oneshot(admin_post( 1501 + "/admin/api-clients", 1502 + app.admin_cookie(), 1503 + &parent_body, 1504 + )) 1505 + .await 1506 + .unwrap(); 1507 + assert_eq!(create_resp.status(), StatusCode::CREATED); 1508 + let created = json_body(create_resp).await; 1509 + let parent_id = created["id"].as_str().unwrap().to_string(); 1510 + 1511 + // Insert a child with owner_did set 1512 + let child_id = uuid::Uuid::new_v4().to_string(); 1513 + let child_key = format!("hvc_{}", hex::encode([0xFFu8; 16])); 1514 + let child_hash = hex::encode(sha2::Sha256::digest("hvs_fake_ff".as_bytes())); 1515 + let now = now_rfc3339(); 1516 + let owner_did = "did:plc:childowner"; 1517 + 1518 + let sql = adapt_sql( 1519 + "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, is_active, created_by, created_at, updated_at, parent_client_id, owner_did) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)", 1520 + app.state.db_backend, 1521 + ); 1522 + sqlx::query(&sql) 1523 + .bind(&child_id) 1524 + .bind(&child_key) 1525 + .bind(&child_hash) 1526 + .bind("Fields Child") 1527 + .bind("https://fields-child.example.com/metadata.json") 1528 + .bind("https://fields-child.example.com") 1529 + .bind("[]") 1530 + .bind("atproto") 1531 + .bind("confidential") 1532 + .bind("did:plc:testadmin") 1533 + .bind(&now) 1534 + .bind(&now) 1535 + .bind(&parent_id) 1536 + .bind(owner_did) 1537 + .execute(&app.state.db) 1538 + .await 1539 + .expect("failed to insert child"); 1540 + 1541 + // List all clients 1542 + let resp = app 1543 + .router 1544 + .clone() 1545 + .oneshot(admin_get("/admin/api-clients", app.admin_cookie())) 1546 + .await 1547 + .unwrap(); 1548 + assert_eq!(resp.status(), StatusCode::OK); 1549 + let arr = json_body(resp).await; 1550 + let items = arr.as_array().expect("response should be an array"); 1551 + assert_eq!(items.len(), 2, "should have parent + child"); 1552 + 1553 + // Find the parent in the list 1554 + let parent_item = items 1555 + .iter() 1556 + .find(|c| c["id"].as_str() == Some(&parent_id)) 1557 + .expect("parent should be in list"); 1558 + // Top-level client has null parent_client_id and null owner_did 1559 + assert!( 1560 + parent_item["parent_client_id"].is_null(), 1561 + "top-level client should have null parent_client_id" 1562 + ); 1563 + assert!( 1564 + parent_item["owner_did"].is_null(), 1565 + "admin-created client should have null owner_did" 1566 + ); 1567 + 1568 + // Find the child in the list 1569 + let child_item = items 1570 + .iter() 1571 + .find(|c| c["id"].as_str() == Some(&child_id)) 1572 + .expect("child should be in list"); 1573 + assert_eq!( 1574 + child_item["parent_client_id"].as_str(), 1575 + Some(parent_id.as_str()), 1576 + "child should reference its parent" 1577 + ); 1578 + assert_eq!( 1579 + child_item["owner_did"].as_str(), 1580 + Some(owner_did), 1581 + "child should have owner_did set" 1582 + ); 1583 + }
+11 -1
web/src/app/dashboard/settings/api-clients/page.tsx
··· 158 158 <TableHead>Scopes</TableHead> 159 159 <TableHead>Status</TableHead> 160 160 <TableHead>Created</TableHead> 161 + <TableHead>Parent Client</TableHead> 162 + <TableHead>Owner</TableHead> 161 163 <TableHead className="w-10 sticky right-0 bg-inherit z-[1]" /> 162 164 </TableRow> 163 165 </TableHeader> ··· 165 167 {clients.length === 0 && ( 166 168 <TableRow> 167 169 <TableCell 168 - colSpan={8} 170 + colSpan={10} 169 171 className="text-muted-foreground text-center" 170 172 > 171 173 No API clients yet. ··· 203 205 </TableCell> 204 206 <TableCell> 205 207 {new Date(client.created_at).toLocaleString()} 208 + </TableCell> 209 + <TableCell className="text-sm"> 210 + {client.parent_client_id 211 + ? clients.find((c) => c.id === client.parent_client_id)?.name ?? client.parent_client_id 212 + : "—"} 213 + </TableCell> 214 + <TableCell className="text-sm max-w-48 truncate"> 215 + {client.owner_did ?? "—"} 206 216 </TableCell> 207 217 <TableCell className="w-10 sticky right-0 bg-inherit z-[1]"> 208 218 <div className="flex gap-1">
+2
web/src/types/api-clients.ts
··· 14 14 created_by: string 15 15 created_at: string 16 16 updated_at: string 17 + parent_client_id: string | null 18 + owner_did: string | null 17 19 } 18 20 19 21 export interface CreateApiClientResponse {