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: add API clients

Trezy e4040924 e5cfc1f7

+1058 -534
+1
migrations/postgres/20260412300000_api_clients_add_secret.sql
··· 1 + ALTER TABLE api_clients ADD COLUMN client_secret_hash TEXT NOT NULL DEFAULT '';
+1
migrations/sqlite/20260412300000_api_clients_add_secret.sql
··· 1 + ALTER TABLE api_clients ADD COLUMN client_secret_hash TEXT NOT NULL DEFAULT '';
+33 -4
src/admin/api_clients.rs
··· 3 3 use axum::http::StatusCode; 4 4 use hex; 5 5 use rand::Rng; 6 + use sha2::{Digest, Sha256}; 6 7 use uuid::Uuid; 7 8 8 9 use crate::AppState; ··· 29 30 rand::rng().fill(&mut random_bytes); 30 31 let client_key = format!("hvc_{}", hex::encode(random_bytes)); 31 32 33 + // Generate the client secret: "hvs_" + 64 random hex chars. 34 + let mut secret_bytes = [0u8; 32]; 35 + rand::rng().fill(&mut secret_bytes); 36 + let client_secret = format!("hvs_{}", hex::encode(secret_bytes)); 37 + let client_secret_hash = hex::encode(Sha256::digest(client_secret.as_bytes())); 38 + 32 39 let id = Uuid::new_v4().to_string(); 33 40 let now = now_rfc3339(); 34 41 let redirect_uris_json = 35 42 serde_json::to_string(&body.redirect_uris).unwrap_or_else(|_| "[]".to_string()); 36 43 37 44 let insert_sql = adapt_sql( 38 - "INSERT INTO api_clients (id, client_key, name, client_id_url, client_uri, redirect_uris, scopes, rate_limit_capacity, rate_limit_refill_rate, is_active, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)", 45 + "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, is_active, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)", 39 46 state.db_backend, 40 47 ); 41 48 42 49 sqlx::query(&insert_sql) 43 50 .bind(&id) 44 51 .bind(&client_key) 52 + .bind(&client_secret_hash) 45 53 .bind(&body.name) 46 54 .bind(&body.client_id_url) 47 55 .bind(&body.client_uri) ··· 72 80 ) { 73 81 tracing::warn!(client_id = %body.client_id_url, error = %e, "OAuth client registration failed (DB row created)"); 74 82 } 83 + 84 + // Register the client identity for request validation. 85 + state.rate_limiter.register_client_identity( 86 + client_key.clone(), 87 + crate::rate_limit::ClientIdentity { 88 + secret_hash: client_secret_hash, 89 + client_uri: body.client_uri.clone(), 90 + }, 91 + ); 75 92 76 93 // Register per-client rate limit config if overrides are set. 77 94 if let (Some(capacity), Some(refill_rate)) = ··· 111 128 Json(CreateApiClientResponse { 112 129 id, 113 130 client_key, 131 + client_secret, 114 132 name: body.name, 115 133 client_id_url: body.client_id_url, 116 134 }), ··· 274 292 275 293 // Read current values 276 294 let select_sql = adapt_sql( 277 - "SELECT client_key, name, client_id_url, client_uri, redirect_uris, scopes, rate_limit_capacity, rate_limit_refill_rate, is_active FROM api_clients WHERE id = ?", 295 + "SELECT client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, rate_limit_capacity, rate_limit_refill_rate, is_active FROM api_clients WHERE id = ?", 278 296 state.db_backend, 279 297 ); 280 298 281 299 type UpdateRow = ( 300 + String, 282 301 String, 283 302 String, 284 303 String, ··· 297 316 298 317 let Some(( 299 318 client_key, 319 + client_secret_hash, 300 320 cur_name, 301 321 client_id_url, 302 322 cur_client_uri, ··· 367 387 state.oauth.remove(&client_id_url); 368 388 } 369 389 370 - // Update per-client rate limit config. 390 + // Update client identity and per-client rate limit config. 371 391 if is_active != 0 { 392 + state.rate_limiter.register_client_identity( 393 + client_key.clone(), 394 + crate::rate_limit::ClientIdentity { 395 + secret_hash: client_secret_hash, 396 + client_uri: client_uri.clone(), 397 + }, 398 + ); 372 399 if let (Some(cap), Some(refill)) = (capacity, refill_rate) { 373 400 let global = state.rate_limiter.global_config(); 374 401 state.rate_limiter.register_client_config( ··· 386 413 state.rate_limiter.remove_client_config(&client_key); 387 414 } 388 415 } else { 416 + state.rate_limiter.remove_client_identity(&client_key); 389 417 state.rate_limiter.remove_client_config(&client_key); 390 418 } 391 419 ··· 436 464 return Err(AppError::NotFound(format!("api client '{id}' not found"))); 437 465 } 438 466 439 - // Remove from OAuth registry and rate limiter. 467 + // Remove from OAuth registry, rate limiter, and client identities. 440 468 if let Some((url, key)) = client_info { 441 469 state.oauth.remove(&url); 442 470 state.rate_limiter.remove_client_config(&key); 471 + state.rate_limiter.remove_client_identity(&key); 443 472 } 444 473 445 474 log_event(
+1
src/admin/types.rs
··· 348 348 pub(super) struct CreateApiClientResponse { 349 349 pub(super) id: String, 350 350 pub(super) client_key: String, 351 + pub(super) client_secret: String, 351 352 pub(super) name: String, 352 353 pub(super) client_id_url: String, 353 354 }
+12
src/config.rs
··· 21 21 pub tos_uri: Option<String>, 22 22 pub policy_uri: Option<String>, 23 23 pub token_encryption_key: Option<[u8; 32]>, 24 + pub default_rate_limit_capacity: u32, 25 + pub default_rate_limit_refill_rate: f64, 24 26 } 25 27 26 28 impl Config { ··· 62 64 .ok() 63 65 .and_then(|bytes| bytes.try_into().ok()) 64 66 }), 67 + default_rate_limit_capacity: env::var("DEFAULT_RATE_LIMIT_CAPACITY") 68 + .ok() 69 + .and_then(|v| v.parse().ok()) 70 + .unwrap_or(100), 71 + default_rate_limit_refill_rate: env::var("DEFAULT_RATE_LIMIT_REFILL_RATE") 72 + .ok() 73 + .and_then(|v| v.parse().ok()) 74 + .unwrap_or(2.0), 65 75 } 66 76 } 67 77 ··· 126 136 tos_uri: None, 127 137 policy_uri: None, 128 138 token_encryption_key: None, 139 + default_rate_limit_capacity: 100, 140 + default_rate_limit_refill_rate: 2.0, 129 141 }; 130 142 assert_eq!( 131 143 config.listen_addr(),
+2
src/lua/atproto_api.rs
··· 287 287 tos_uri: None, 288 288 policy_uri: None, 289 289 token_encryption_key: None, 290 + default_rate_limit_capacity: 100, 291 + default_rate_limit_refill_rate: 2.0, 290 292 }; 291 293 let (tx, _) = watch::channel(vec![]); 292 294 let (labeler_tx, _) = watch::channel(());
+2
src/lua/db_api.rs
··· 638 638 tos_uri: None, 639 639 policy_uri: None, 640 640 token_encryption_key: None, 641 + default_rate_limit_capacity: 100, 642 + default_rate_limit_refill_rate: 2.0, 641 643 }; 642 644 let (tx, _) = watch::channel(vec![]); 643 645 let (labeler_tx, _) = watch::channel(());
+2
src/lua/execute.rs
··· 964 964 tos_uri: None, 965 965 policy_uri: None, 966 966 token_encryption_key: None, 967 + default_rate_limit_capacity: 100, 968 + default_rate_limit_refill_rate: 2.0, 967 969 }; 968 970 let (tx, _) = watch::channel(vec![]); 969 971 let (labeler_tx, _) = watch::channel(());
+2
src/lua/http_api.rs
··· 104 104 tos_uri: None, 105 105 policy_uri: None, 106 106 token_encryption_key: None, 107 + default_rate_limit_capacity: 100, 108 + default_rate_limit_refill_rate: 2.0, 107 109 }; 108 110 let (tx, _) = watch::channel(vec![]); 109 111 let (labeler_tx, _) = watch::channel(());
+22 -12
src/main.rs
··· 269 269 let rate_limiter = RateLimiter::new(rl_state.enabled, rl_state.global); 270 270 tokio::spawn(rate_limiter.clone().spawn_cleanup()); 271 271 272 - // Load per-client rate limit configs from api_clients table. 272 + // Load per-client rate limit configs and identities from api_clients table. 273 273 { 274 - let client_configs: Vec<(String, i32, f64)> = sqlx::query_as( 275 - "SELECT client_key, rate_limit_capacity, rate_limit_refill_rate FROM api_clients WHERE is_active = 1 AND rate_limit_capacity IS NOT NULL AND rate_limit_refill_rate IS NOT NULL", 274 + type ClientRow = (String, String, String, Option<i32>, Option<f64>); 275 + let client_rows: Vec<ClientRow> = sqlx::query_as( 276 + "SELECT client_key, client_secret_hash, client_uri, rate_limit_capacity, rate_limit_refill_rate FROM api_clients WHERE is_active = 1", 276 277 ) 277 278 .fetch_all(&db_pool) 278 279 .await 279 280 .unwrap_or_default(); 280 281 281 282 let global = rate_limiter.global_config(); 282 - for (client_key, capacity, refill_rate) in client_configs { 283 - rate_limiter.register_client_config( 284 - client_key, 285 - RateLimitConfig { 286 - capacity: capacity as u32, 287 - refill_rate, 288 - default_query_cost: global.default_query_cost, 289 - default_procedure_cost: global.default_procedure_cost, 290 - default_proxy_cost: global.default_proxy_cost, 283 + for (client_key, secret_hash, client_uri, capacity, refill_rate) in client_rows { 284 + rate_limiter.register_client_identity( 285 + client_key.clone(), 286 + happyview::rate_limit::ClientIdentity { 287 + secret_hash, 288 + client_uri, 291 289 }, 292 290 ); 291 + if let (Some(cap), Some(refill)) = (capacity, refill_rate) { 292 + rate_limiter.register_client_config( 293 + client_key, 294 + RateLimitConfig { 295 + capacity: cap as u32, 296 + refill_rate: refill, 297 + default_query_cost: global.default_query_cost, 298 + default_procedure_cost: global.default_procedure_cost, 299 + default_proxy_cost: global.default_proxy_cost, 300 + }, 301 + ); 302 + } 293 303 } 294 304 } 295 305
+51
src/rate_limit.rs
··· 35 35 last_access: Instant, 36 36 } 37 37 38 + /// Metadata about a registered API client, used for request validation. 39 + pub struct ClientIdentity { 40 + /// SHA-256 hash of the client secret 41 + pub secret_hash: String, 42 + /// The client's registered URI (for Origin header validation) 43 + pub client_uri: String, 44 + } 45 + 38 46 pub struct RateLimiter { 39 47 enabled: AtomicBool, 40 48 buckets: DashMap<String, TokenBucket>, 41 49 global_config: ArcSwap<RateLimitConfig>, 42 50 /// Per-client config overrides, keyed by client_key (e.g. "hvc_...") 43 51 client_configs: DashMap<String, RateLimitConfig>, 52 + /// Registered client identities, keyed by client_key 53 + client_identities: DashMap<String, ClientIdentity>, 44 54 } 45 55 46 56 pub struct RateLimiterState { ··· 62 72 buckets: DashMap::new(), 63 73 global_config: ArcSwap::new(Arc::new(global)), 64 74 client_configs: DashMap::new(), 75 + client_identities: DashMap::new(), 65 76 }) 66 77 } 67 78 ··· 162 173 /// Remove a per-client rate limit config override. 163 174 pub fn remove_client_config(&self, client_key: &str) { 164 175 self.client_configs.remove(client_key); 176 + } 177 + 178 + /// Register a client identity (key, secret hash, and client URI). 179 + pub fn register_client_identity(&self, client_key: String, identity: ClientIdentity) { 180 + self.client_identities.insert(client_key, identity); 181 + } 182 + 183 + /// Remove a client identity. 184 + pub fn remove_client_identity(&self, client_key: &str) { 185 + self.client_identities.remove(client_key); 186 + } 187 + 188 + /// Validate a client key + secret combination. Returns true if the secret 189 + /// hash matches the stored hash for this client key. 190 + pub fn validate_client_secret(&self, client_key: &str, secret: &str) -> bool { 191 + use sha2::{Digest, Sha256}; 192 + if let Some(identity) = self.client_identities.get(client_key) { 193 + let hash = hex::encode(Sha256::digest(secret.as_bytes())); 194 + hash == identity.secret_hash 195 + } else { 196 + false 197 + } 198 + } 199 + 200 + /// Validate a client key + origin combination. Returns true if the origin 201 + /// matches the registered client_uri for this client key. 202 + pub fn validate_client_origin(&self, client_key: &str, origin: &str) -> bool { 203 + if let Some(identity) = self.client_identities.get(client_key) { 204 + // Compare origins: strip trailing slash for consistency 205 + let registered = identity.client_uri.trim_end_matches('/'); 206 + let provided = origin.trim_end_matches('/'); 207 + registered == provided 208 + } else { 209 + false 210 + } 211 + } 212 + 213 + /// Check whether a client key is registered. 214 + pub fn is_valid_client_key(&self, client_key: &str) -> bool { 215 + self.client_identities.contains_key(client_key) 165 216 } 166 217 167 218 pub async fn spawn_cleanup(self: Arc<Self>) {
+14 -8
src/server.rs
··· 1 1 use axum::extract::{DefaultBodyLimit, State}; 2 - use axum::http::HeaderMap; 3 2 use axum::http::{Method, header}; 4 3 use axum::response::{IntoResponse, Response}; 5 4 use axum::routing::{get, post}; ··· 83 82 CorsLayer::new() 84 83 .allow_origin(tower_http::cors::AllowOrigin::mirror_request()) 85 84 .allow_methods([Method::GET, Method::POST, Method::OPTIONS]) 86 - .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION, header::COOKIE]) 85 + .allow_headers([ 86 + header::CONTENT_TYPE, 87 + header::AUTHORIZATION, 88 + header::COOKIE, 89 + axum::http::HeaderName::from_static("x-client-key"), 90 + axum::http::HeaderName::from_static("x-client-secret"), 91 + ]) 87 92 .allow_credentials(true), 88 93 ) 89 94 .with_state(state) ··· 101 106 "jetstream_url": state.config.jetstream_url, 102 107 "relay_url": state.config.relay_url, 103 108 "plc_url": state.config.plc_url, 109 + "default_rate_limit_capacity": state.config.default_rate_limit_capacity, 110 + "default_rate_limit_refill_rate": state.config.default_rate_limit_refill_rate, 104 111 })) 105 112 } 106 113 ··· 161 168 Json(metadata) 162 169 } 163 170 164 - async fn get_profile( 165 - State(state): State<AppState>, 166 - claims: Claims, 167 - _headers: HeaderMap, 168 - ) -> Result<Response, AppError> { 169 - let rate_key = claims.did().to_string(); 171 + async fn get_profile(State(state): State<AppState>, claims: Claims) -> Result<Response, AppError> { 172 + let rate_key = claims 173 + .client_key() 174 + .map(|k| k.to_string()) 175 + .unwrap_or_else(|| claims.did().to_string()); 170 176 let check = state 171 177 .rate_limiter 172 178 .check(&rate_key, state.rate_limiter.default_cost_for_type("query"));
+91 -11
src/xrpc/mod.rs
··· 154 154 .unwrap()) 155 155 } 156 156 157 + /// Extract the API client key from the request for rate limiting. 158 + /// 159 + /// Every request must carry a client key. Returns an error when none is 160 + /// found so the caller can reject the request with 401. 161 + /// 162 + /// Resolution order: 163 + /// 1. Session cookie (`client_key` field in Claims) 164 + /// 2. `X-Client-Key` header 165 + /// 3. `client_key` query parameter 166 + /// 167 + /// Security validation (Origin / secret) is logged as warnings but does 168 + /// not reject the request — the key is always used as the rate-limit 169 + /// bucket regardless. 170 + fn resolve_client_key( 171 + state: &AppState, 172 + claims: Option<&Claims>, 173 + parts: &Parts, 174 + query_params: &std::collections::HashMap<String, serde_json::Value>, 175 + ) -> Result<String, AppError> { 176 + // 1. Try session cookie 177 + let client_key = claims 178 + .and_then(|c| c.client_key().map(|k| k.to_string())) 179 + // 2. Try X-Client-Key header 180 + .or_else(|| { 181 + parts 182 + .headers 183 + .get("x-client-key") 184 + .and_then(|v| v.to_str().ok()) 185 + .map(|s| s.to_string()) 186 + }) 187 + // 3. Try client_key query param 188 + .or_else(|| { 189 + query_params 190 + .get("client_key") 191 + .and_then(|v| v.as_str()) 192 + .map(|s| s.to_string()) 193 + }) 194 + .ok_or_else(|| { 195 + AppError::Auth( 196 + "Missing client identification. Provide an X-Client-Key header or client_key query parameter.".into(), 197 + ) 198 + })?; 199 + 200 + // Log validation warnings but always return the key for rate limiting. 201 + if !state.rate_limiter.is_valid_client_key(&client_key) { 202 + tracing::warn!("Unknown client key: {client_key}"); 203 + return Ok(client_key); 204 + } 205 + 206 + // If the key came from a session cookie, it was already validated at login time. 207 + let from_session = claims 208 + .and_then(|c| c.client_key()) 209 + .map(|k| k == client_key) 210 + .unwrap_or(false); 211 + 212 + if !from_session { 213 + let origin = parts.headers.get("origin").and_then(|v| v.to_str().ok()); 214 + 215 + if let Some(origin) = origin { 216 + if !state 217 + .rate_limiter 218 + .validate_client_origin(&client_key, origin) 219 + { 220 + tracing::warn!("Origin mismatch for client {client_key}: got {origin}"); 221 + } 222 + } else { 223 + let secret = parts 224 + .headers 225 + .get("x-client-secret") 226 + .and_then(|v| v.to_str().ok()); 227 + 228 + match secret { 229 + Some(s) if state.rate_limiter.validate_client_secret(&client_key, s) => {} 230 + Some(_) => { 231 + tracing::warn!("Invalid client secret for {client_key}"); 232 + } 233 + None => { 234 + tracing::warn!("No Origin or X-Client-Secret for client {client_key}"); 235 + } 236 + } 237 + } 238 + } 239 + 240 + Ok(client_key) 241 + } 242 + 157 243 /// Apply rate limit headers to a response. 158 244 fn apply_rate_limit_headers(response: &mut Response, remaining: u32, limit: u32, reset: u64) { 159 245 let headers = response.headers_mut(); ··· 173 259 let mut params = parse_query_params(&raw_query); 174 260 let claims = Claims::from_request_parts(&mut parts, &state).await.ok(); 175 261 176 - // Rate limit check — keyed by client_key for API client requests, "anonymous" otherwise 177 - let rate_key = claims 178 - .as_ref() 179 - .and_then(|c| c.client_key().map(|k| k.to_string())) 180 - .unwrap_or_else(|| "anonymous".to_string()); 262 + let rate_key = resolve_client_key(&state, claims.as_ref(), &parts, &params)?; 181 263 182 264 let lexicon = state.lexicons.get(&method).await; 183 265 ··· 252 334 State(state): State<AppState>, 253 335 Path(method): Path<String>, 254 336 RawQuery(raw_query): RawQuery, 255 - claims: Claims, 256 - _headers: axum::http::HeaderMap, 337 + mut parts: Parts, 257 338 Json(body): Json<serde_json::Value>, 258 339 ) -> Result<Response, AppError> { 259 340 let raw_query = raw_query.unwrap_or_default(); 260 341 let mut params = parse_query_params(&raw_query); 261 - let rate_key = claims 262 - .client_key() 263 - .map(|k| k.to_string()) 264 - .unwrap_or_else(|| "anonymous".to_string()); 342 + let claims = Claims::from_request_parts(&mut parts, &state).await?; 343 + 344 + let rate_key = resolve_client_key(&state, Some(&claims), &parts, &params)?; 265 345 266 346 let lexicon = state.lexicons.get(&method).await; 267 347
+2
tests/common/app.rs
··· 51 51 tos_uri: None, 52 52 policy_uri: None, 53 53 token_encryption_key: None, 54 + default_rate_limit_capacity: 100, 55 + default_rate_limit_refill_rate: 2.0, 54 56 }; 55 57 56 58 let sql = adapt_sql(
+2
tests/lua_atproto_api.rs
··· 33 33 tos_uri: None, 34 34 policy_uri: None, 35 35 token_encryption_key: None, 36 + default_rate_limit_capacity: 100, 37 + default_rate_limit_refill_rate: 2.0, 36 38 }; 37 39 let (tx, _) = watch::channel(vec![]); 38 40 let (labeler_tx, _) = watch::channel(());
+2
tests/lua_db_api.rs
··· 36 36 tos_uri: None, 37 37 policy_uri: None, 38 38 token_encryption_key: None, 39 + default_rate_limit_capacity: 100, 40 + default_rate_limit_refill_rate: 2.0, 39 41 }; 40 42 let (tx, _) = watch::channel(vec![]); 41 43 let (labeler_tx, _) = watch::channel(());
+745
web/src/app/dashboard/settings/api-clients/page.tsx
··· 1 + "use client"; 2 + 3 + import { useCallback, useEffect, useState } from "react"; 4 + import { Copy, Check, Trash2, X } from "lucide-react"; 5 + 6 + import { useConfig } from "@/lib/config-context"; 7 + import { useCurrentUser } from "@/hooks/use-current-user"; 8 + import { 9 + getApiClients, 10 + createApiClient, 11 + updateApiClient, 12 + deleteApiClient, 13 + } from "@/lib/api"; 14 + import type { ApiClientSummary, CreateApiClientResponse } from "@/types/api-clients"; 15 + import { SiteHeader } from "@/components/site-header"; 16 + import { Badge } from "@/components/ui/badge"; 17 + import { Button } from "@/components/ui/button"; 18 + import { Input } from "@/components/ui/input"; 19 + import { Label } from "@/components/ui/label"; 20 + import { Switch } from "@/components/ui/switch"; 21 + import { 22 + ResponsiveDialog, 23 + ResponsiveDialogClose, 24 + ResponsiveDialogContent, 25 + ResponsiveDialogDescription, 26 + ResponsiveDialogFooter, 27 + ResponsiveDialogHeader, 28 + ResponsiveDialogTitle, 29 + ResponsiveDialogTrigger, 30 + } from "@/components/ui/responsive-dialog"; 31 + import { 32 + Table, 33 + TableBody, 34 + TableCell, 35 + TableHead, 36 + TableHeader, 37 + TableRow, 38 + } from "@/components/ui/table"; 39 + 40 + function MultiInput({ 41 + values, 42 + onChange, 43 + placeholder, 44 + readonlyValues = [], 45 + id, 46 + }: { 47 + values: string[]; 48 + onChange: (values: string[]) => void; 49 + placeholder?: string; 50 + readonlyValues?: string[]; 51 + id?: string; 52 + }) { 53 + function handleChange(index: number, value: string) { 54 + const next = [...values]; 55 + next[index] = value; 56 + // If user typed into the last input, add an empty one 57 + if (index === values.length - 1 && value.trim() !== "") { 58 + next.push(""); 59 + } 60 + onChange(next); 61 + } 62 + 63 + function handleRemove(index: number) { 64 + const next = values.filter((_, i) => i !== index); 65 + // Always keep at least one empty input 66 + if (next.length === 0 || next[next.length - 1].trim() !== "") { 67 + next.push(""); 68 + } 69 + onChange(next); 70 + } 71 + 72 + function handleKeyDown(index: number, e: React.KeyboardEvent<HTMLInputElement>) { 73 + if (e.key === "Backspace" && values[index] === "" && values.length > 1) { 74 + e.preventDefault(); 75 + handleRemove(index); 76 + } 77 + } 78 + 79 + return ( 80 + <div className="flex flex-col gap-1.5"> 81 + {readonlyValues.map((val, i) => ( 82 + <Input 83 + key={`readonly-${i}`} 84 + value={val} 85 + readOnly 86 + className="font-mono text-sm bg-muted" 87 + /> 88 + ))} 89 + {values.map((val, index) => ( 90 + <div key={index} className="flex gap-1.5"> 91 + <Input 92 + id={index === 0 ? id : undefined} 93 + value={val} 94 + onChange={(e) => handleChange(index, e.target.value)} 95 + onKeyDown={(e) => handleKeyDown(index, e)} 96 + placeholder={placeholder} 97 + className="font-mono text-sm" 98 + /> 99 + {values.length > 1 && val.trim() !== "" && ( 100 + <Button 101 + type="button" 102 + variant="ghost" 103 + size="icon" 104 + className="shrink-0 size-9 text-muted-foreground hover:text-destructive" 105 + onClick={() => handleRemove(index)} 106 + > 107 + <X className="size-4" /> 108 + </Button> 109 + )} 110 + </div> 111 + ))} 112 + </div> 113 + ); 114 + } 115 + 116 + export default function ApiClientsPage() { 117 + const { hasPermission } = useCurrentUser(); 118 + const [clients, setClients] = useState<ApiClientSummary[]>([]); 119 + const [error, setError] = useState<string | null>(null); 120 + 121 + const load = useCallback(() => { 122 + getApiClients() 123 + .then(setClients) 124 + .catch((e) => setError(e.message)); 125 + }, []); 126 + 127 + useEffect(() => { 128 + load(); 129 + }, [load]); 130 + 131 + return ( 132 + <> 133 + <SiteHeader title="API Clients" /> 134 + <div className="flex flex-1 flex-col gap-4 p-4 md:p-6"> 135 + {error && <p className="text-destructive text-sm">{error}</p>} 136 + 137 + <div className="flex items-center justify-between"> 138 + <div> 139 + <h2 className="text-lg font-semibold">API Clients</h2> 140 + <p className="text-muted-foreground text-sm"> 141 + Registered applications that authenticate through this AppView. 142 + </p> 143 + </div> 144 + {hasPermission("api-clients:create") && ( 145 + <CreateApiClientDialog onSuccess={load} /> 146 + )} 147 + </div> 148 + 149 + <div className="overflow-clip rounded-lg border"> 150 + <Table> 151 + <TableHeader> 152 + <TableRow> 153 + <TableHead>Name</TableHead> 154 + <TableHead>Client Key</TableHead> 155 + <TableHead>Client ID URL</TableHead> 156 + <TableHead>Scopes</TableHead> 157 + <TableHead>Status</TableHead> 158 + <TableHead>Created</TableHead> 159 + <TableHead className="w-10 sticky right-0 bg-inherit z-[1]" /> 160 + </TableRow> 161 + </TableHeader> 162 + <TableBody> 163 + {clients.length === 0 && ( 164 + <TableRow> 165 + <TableCell 166 + colSpan={7} 167 + className="text-muted-foreground text-center" 168 + > 169 + No API clients yet. 170 + </TableCell> 171 + </TableRow> 172 + )} 173 + {clients.map((client) => ( 174 + <TableRow 175 + key={client.id} 176 + className={!client.is_active ? "opacity-50" : undefined} 177 + > 178 + <TableCell className="font-medium">{client.name}</TableCell> 179 + <TableCell className="font-mono text-sm"> 180 + {client.client_key.slice(0, 12)}... 181 + </TableCell> 182 + <TableCell className="max-w-48 truncate text-sm"> 183 + {client.client_id_url} 184 + </TableCell> 185 + <TableCell> 186 + <Badge variant="secondary">{client.scopes}</Badge> 187 + </TableCell> 188 + <TableCell> 189 + <Badge variant={client.is_active ? "default" : "outline"}> 190 + {client.is_active ? "Active" : "Inactive"} 191 + </Badge> 192 + </TableCell> 193 + <TableCell> 194 + {new Date(client.created_at).toLocaleString()} 195 + </TableCell> 196 + <TableCell className="w-10 sticky right-0 bg-inherit z-[1]"> 197 + <div className="flex gap-1"> 198 + {hasPermission("api-clients:edit") && ( 199 + <EditApiClientDialog client={client} onSuccess={load} /> 200 + )} 201 + {hasPermission("api-clients:delete") && ( 202 + <DeleteApiClientDialog client={client} onSuccess={load} /> 203 + )} 204 + </div> 205 + </TableCell> 206 + </TableRow> 207 + ))} 208 + </TableBody> 209 + </Table> 210 + </div> 211 + </div> 212 + </> 213 + ); 214 + } 215 + 216 + function CreateApiClientDialog({ onSuccess }: { onSuccess: () => void }) { 217 + const config = useConfig(); 218 + const happyviewCallbackUri = `${config.public_url.replace(/\/$/, "")}/auth/callback`; 219 + 220 + const [name, setName] = useState(""); 221 + const [clientIdUrl, setClientIdUrl] = useState(""); 222 + const [clientUri, setClientUri] = useState(""); 223 + const [redirectUris, setRedirectUris] = useState<string[]>([""]); 224 + const [scopes, setScopes] = useState<string[]>([""]); 225 + const [rateLimitCapacity, setRateLimitCapacity] = useState( 226 + String(config.default_rate_limit_capacity) 227 + ); 228 + const [rateLimitRefillRate, setRateLimitRefillRate] = useState( 229 + String(config.default_rate_limit_refill_rate) 230 + ); 231 + const [error, setError] = useState<string | null>(null); 232 + const [open, setOpen] = useState(false); 233 + const [created, setCreated] = useState<CreateApiClientResponse | null>(null); 234 + const [copiedField, setCopiedField] = useState<string | null>(null); 235 + 236 + function handleOpenChange(nextOpen: boolean) { 237 + setOpen(nextOpen); 238 + if (!nextOpen) { 239 + setName(""); 240 + setClientIdUrl(""); 241 + setClientUri(""); 242 + setRedirectUris([""]); 243 + setScopes([""]); 244 + setRateLimitCapacity(String(config.default_rate_limit_capacity)); 245 + setRateLimitRefillRate(String(config.default_rate_limit_refill_rate)); 246 + setError(null); 247 + if (created) { 248 + setCreated(null); 249 + onSuccess(); 250 + } 251 + } 252 + } 253 + 254 + async function handleCopy(value: string, field: string) { 255 + await navigator.clipboard.writeText(value); 256 + setCopiedField(field); 257 + setTimeout(() => setCopiedField(null), 2000); 258 + } 259 + 260 + async function handleCreate() { 261 + setError(null); 262 + const extraUris = redirectUris.map((u) => u.trim()).filter(Boolean); 263 + const allUris = [happyviewCallbackUri, ...extraUris]; 264 + const extraScopes = scopes.map((s) => s.trim()).filter(Boolean); 265 + const allScopes = ["atproto", ...extraScopes].join(" "); 266 + 267 + if (!name.trim() || !clientIdUrl.trim() || !clientUri.trim()) { 268 + setError("Name, Client ID URL, and Client URI are required."); 269 + return; 270 + } 271 + if (!rateLimitCapacity || !rateLimitRefillRate) { 272 + setError("Rate limit capacity and refill rate are required."); 273 + return; 274 + } 275 + try { 276 + const result = await createApiClient({ 277 + name: name.trim(), 278 + client_id_url: clientIdUrl.trim(), 279 + client_uri: clientUri.trim(), 280 + redirect_uris: allUris, 281 + scopes: allScopes, 282 + rate_limit_capacity: Number(rateLimitCapacity), 283 + rate_limit_refill_rate: Number(rateLimitRefillRate), 284 + }); 285 + setCreated(result); 286 + } catch (e: unknown) { 287 + setError(e instanceof Error ? e.message : String(e)); 288 + } 289 + } 290 + 291 + return ( 292 + <ResponsiveDialog open={open} onOpenChange={handleOpenChange}> 293 + <ResponsiveDialogTrigger asChild> 294 + <Button>Create API Client</Button> 295 + </ResponsiveDialogTrigger> 296 + <ResponsiveDialogContent> 297 + <ResponsiveDialogHeader> 298 + <ResponsiveDialogTitle> 299 + {created ? "API Client Created" : "Create API Client"} 300 + </ResponsiveDialogTitle> 301 + <ResponsiveDialogDescription> 302 + {created 303 + ? "Save the credentials below. The secret will not be shown again." 304 + : "Register a new application that authenticates through this AppView."} 305 + </ResponsiveDialogDescription> 306 + </ResponsiveDialogHeader> 307 + 308 + {created ? ( 309 + <div className="flex flex-col gap-4"> 310 + <div className="flex flex-col gap-2"> 311 + <Label>Client Key</Label> 312 + <div className="flex gap-2"> 313 + <Input 314 + readOnly 315 + value={created.client_key} 316 + className="font-mono text-sm" 317 + /> 318 + <Button 319 + variant="outline" 320 + size="icon" 321 + onClick={() => handleCopy(created.client_key, "key")} 322 + title="Copy to clipboard" 323 + > 324 + {copiedField === "key" ? ( 325 + <Check className="size-4" /> 326 + ) : ( 327 + <Copy className="size-4" /> 328 + )} 329 + </Button> 330 + </div> 331 + <p className="text-muted-foreground text-xs"> 332 + Public identifier. Send as the <code className="bg-muted px-1 rounded">X-Client-Key</code> header 333 + or <code className="bg-muted px-1 rounded">client_key</code> query parameter. 334 + </p> 335 + </div> 336 + <div className="flex flex-col gap-2"> 337 + <Label>Client Secret</Label> 338 + <div className="flex gap-2"> 339 + <Input 340 + readOnly 341 + value={created.client_secret} 342 + className="font-mono text-sm" 343 + /> 344 + <Button 345 + variant="outline" 346 + size="icon" 347 + onClick={() => handleCopy(created.client_secret, "secret")} 348 + title="Copy to clipboard" 349 + > 350 + {copiedField === "secret" ? ( 351 + <Check className="size-4" /> 352 + ) : ( 353 + <Copy className="size-4" /> 354 + )} 355 + </Button> 356 + </div> 357 + <p className="text-muted-foreground text-xs"> 358 + Keep this secret. Send as the <code className="bg-muted px-1 rounded">X-Client-Secret</code> header 359 + for server-to-server requests. Browser requests are validated by Origin instead. 360 + </p> 361 + </div> 362 + </div> 363 + ) : ( 364 + <div className="flex flex-col gap-4 max-h-[60vh] overflow-y-auto"> 365 + {error && <p className="text-destructive text-sm">{error}</p>} 366 + <fieldset className="flex flex-col gap-3 rounded-lg border p-4"> 367 + <legend className="text-sm font-medium px-1">Application</legend> 368 + <div className="flex flex-col gap-2"> 369 + <Label htmlFor="client-name">Name</Label> 370 + <Input 371 + id="client-name" 372 + value={name} 373 + onChange={(e) => setName(e.target.value)} 374 + placeholder="My App" 375 + /> 376 + </div> 377 + <div className="flex flex-col gap-2"> 378 + <Label htmlFor="client-id-url">Client ID URL</Label> 379 + <Input 380 + id="client-id-url" 381 + value={clientIdUrl} 382 + onChange={(e) => setClientIdUrl(e.target.value)} 383 + placeholder="https://example.com/oauth-client-metadata.json" 384 + className="font-mono text-sm" 385 + /> 386 + <p className="text-muted-foreground text-xs"> 387 + The URL where the client metadata JSON is served. 388 + </p> 389 + </div> 390 + <div className="flex flex-col gap-2"> 391 + <Label htmlFor="client-uri">Client URI</Label> 392 + <Input 393 + id="client-uri" 394 + value={clientUri} 395 + onChange={(e) => setClientUri(e.target.value)} 396 + placeholder="https://example.com" 397 + className="font-mono text-sm" 398 + /> 399 + </div> 400 + </fieldset> 401 + <fieldset className="flex flex-col gap-3 rounded-lg border p-4"> 402 + <legend className="text-sm font-medium px-1">Redirect URIs</legend> 403 + <p className="text-muted-foreground text-xs"> 404 + URLs that the authorization server may redirect to after authentication. 405 + The AppView callback is always included. 406 + </p> 407 + <MultiInput 408 + id="redirect-uris" 409 + values={redirectUris} 410 + onChange={setRedirectUris} 411 + placeholder="https://example.com/auth/callback" 412 + readonlyValues={[happyviewCallbackUri]} 413 + /> 414 + </fieldset> 415 + <fieldset className="flex flex-col gap-3 rounded-lg border p-4"> 416 + <legend className="text-sm font-medium px-1">Scopes</legend> 417 + <p className="text-muted-foreground text-xs"> 418 + OAuth scopes this client is allowed to request. The <code className="bg-muted px-1 rounded">atproto</code> scope 419 + is always required. 420 + </p> 421 + <MultiInput 422 + id="scopes" 423 + values={scopes} 424 + onChange={setScopes} 425 + placeholder="scope.name" 426 + readonlyValues={["atproto"]} 427 + /> 428 + </fieldset> 429 + <fieldset className="flex flex-col gap-3 rounded-lg border p-4"> 430 + <legend className="text-sm font-medium px-1">Rate Limiting</legend> 431 + <p className="text-muted-foreground text-xs"> 432 + Each client gets a token bucket. Requests consume tokens and the bucket 433 + refills over time. When the bucket is empty, requests are rejected until 434 + tokens replenish. 435 + </p> 436 + <div className="grid grid-cols-2 gap-4"> 437 + <div className="flex flex-col gap-2"> 438 + <Label htmlFor="rl-capacity">Bucket Size</Label> 439 + <Input 440 + id="rl-capacity" 441 + type="number" 442 + min={1} 443 + value={rateLimitCapacity} 444 + onChange={(e) => setRateLimitCapacity(e.target.value)} 445 + /> 446 + <p className="text-muted-foreground text-xs"> 447 + Maximum number of tokens. This is the burst limit. 448 + </p> 449 + </div> 450 + <div className="flex flex-col gap-2"> 451 + <Label htmlFor="rl-refill">Refill Rate</Label> 452 + <Input 453 + id="rl-refill" 454 + type="number" 455 + min={0.01} 456 + step="any" 457 + value={rateLimitRefillRate} 458 + onChange={(e) => setRateLimitRefillRate(e.target.value)} 459 + /> 460 + <p className="text-muted-foreground text-xs"> 461 + Tokens added per second. 462 + </p> 463 + </div> 464 + </div> 465 + </fieldset> 466 + </div> 467 + )} 468 + 469 + <ResponsiveDialogFooter> 470 + <ResponsiveDialogClose asChild> 471 + <Button variant={created ? "default" : "outline"}> 472 + {created ? "Done" : "Cancel"} 473 + </Button> 474 + </ResponsiveDialogClose> 475 + {!created && ( 476 + <Button onClick={handleCreate} disabled={!name.trim()}> 477 + Create 478 + </Button> 479 + )} 480 + </ResponsiveDialogFooter> 481 + </ResponsiveDialogContent> 482 + </ResponsiveDialog> 483 + ); 484 + } 485 + 486 + function EditApiClientDialog({ 487 + client, 488 + onSuccess, 489 + }: { 490 + client: ApiClientSummary; 491 + onSuccess: () => void; 492 + }) { 493 + const config = useConfig(); 494 + const happyviewCallbackUri = `${config.public_url.replace(/\/$/, "")}/auth/callback`; 495 + 496 + // Parse existing redirect URIs: separate the HappyView callback from user-added ones 497 + function parseRedirectUris(uris: string[]): string[] { 498 + const filtered = uris.filter((u) => u !== happyviewCallbackUri); 499 + return filtered.length > 0 ? [...filtered, ""] : [""]; 500 + } 501 + 502 + // Parse existing scopes: separate "atproto" from user-added ones 503 + function parseScopes(scopeStr: string): string[] { 504 + const parts = scopeStr.split(/\s+/).filter((s) => s && s !== "atproto"); 505 + return parts.length > 0 ? [...parts, ""] : [""]; 506 + } 507 + 508 + const [name, setName] = useState(client.name); 509 + const [redirectUris, setRedirectUris] = useState<string[]>( 510 + parseRedirectUris(client.redirect_uris) 511 + ); 512 + const [scopes, setScopes] = useState<string[]>(parseScopes(client.scopes)); 513 + const [isActive, setIsActive] = useState(client.is_active); 514 + const [rateLimitCapacity, setRateLimitCapacity] = useState( 515 + String(client.rate_limit_capacity ?? config.default_rate_limit_capacity) 516 + ); 517 + const [rateLimitRefillRate, setRateLimitRefillRate] = useState( 518 + String(client.rate_limit_refill_rate ?? config.default_rate_limit_refill_rate) 519 + ); 520 + const [error, setError] = useState<string | null>(null); 521 + const [open, setOpen] = useState(false); 522 + const [saving, setSaving] = useState(false); 523 + 524 + function handleOpenChange(nextOpen: boolean) { 525 + setOpen(nextOpen); 526 + if (nextOpen) { 527 + setName(client.name); 528 + setRedirectUris(parseRedirectUris(client.redirect_uris)); 529 + setScopes(parseScopes(client.scopes)); 530 + setIsActive(client.is_active); 531 + setRateLimitCapacity( 532 + String(client.rate_limit_capacity ?? config.default_rate_limit_capacity) 533 + ); 534 + setRateLimitRefillRate( 535 + String(client.rate_limit_refill_rate ?? config.default_rate_limit_refill_rate) 536 + ); 537 + setError(null); 538 + } 539 + } 540 + 541 + async function handleSave() { 542 + setError(null); 543 + if (!rateLimitCapacity || !rateLimitRefillRate) { 544 + setError("Rate limit capacity and refill rate are required."); 545 + return; 546 + } 547 + setSaving(true); 548 + try { 549 + const extraUris = redirectUris.map((u) => u.trim()).filter(Boolean); 550 + const allUris = [happyviewCallbackUri, ...extraUris]; 551 + const extraScopes = scopes.map((s) => s.trim()).filter(Boolean); 552 + const allScopes = ["atproto", ...extraScopes].join(" "); 553 + 554 + await updateApiClient(client.id, { 555 + name: name.trim() || undefined, 556 + redirect_uris: allUris, 557 + scopes: allScopes, 558 + is_active: isActive, 559 + rate_limit_capacity: Number(rateLimitCapacity), 560 + rate_limit_refill_rate: Number(rateLimitRefillRate), 561 + }); 562 + setOpen(false); 563 + onSuccess(); 564 + } catch (e: unknown) { 565 + setError(e instanceof Error ? e.message : String(e)); 566 + } finally { 567 + setSaving(false); 568 + } 569 + } 570 + 571 + return ( 572 + <ResponsiveDialog open={open} onOpenChange={handleOpenChange}> 573 + <ResponsiveDialogTrigger asChild> 574 + <Button variant="outline" size="sm"> 575 + Edit 576 + </Button> 577 + </ResponsiveDialogTrigger> 578 + <ResponsiveDialogContent> 579 + <ResponsiveDialogHeader> 580 + <ResponsiveDialogTitle>Edit API Client</ResponsiveDialogTitle> 581 + <ResponsiveDialogDescription> 582 + Update settings for &ldquo;{client.name}&rdquo;. 583 + </ResponsiveDialogDescription> 584 + </ResponsiveDialogHeader> 585 + <div className="flex flex-col gap-4 max-h-[60vh] overflow-y-auto"> 586 + {error && <p className="text-destructive text-sm">{error}</p>} 587 + <fieldset className="flex flex-col gap-3 rounded-lg border p-4"> 588 + <legend className="text-sm font-medium px-1">Application</legend> 589 + <div className="flex flex-col gap-2"> 590 + <Label htmlFor="edit-name">Name</Label> 591 + <Input 592 + id="edit-name" 593 + value={name} 594 + onChange={(e) => setName(e.target.value)} 595 + /> 596 + </div> 597 + <div className="flex items-center gap-3"> 598 + <Switch 599 + id="edit-active" 600 + checked={isActive} 601 + onCheckedChange={setIsActive} 602 + /> 603 + <Label htmlFor="edit-active" className="cursor-pointer">Active</Label> 604 + </div> 605 + </fieldset> 606 + <fieldset className="flex flex-col gap-3 rounded-lg border p-4"> 607 + <legend className="text-sm font-medium px-1">Redirect URIs</legend> 608 + <p className="text-muted-foreground text-xs"> 609 + URLs that the authorization server may redirect to after authentication. 610 + The AppView callback is always included. 611 + </p> 612 + <MultiInput 613 + id="edit-redirect-uris" 614 + values={redirectUris} 615 + onChange={setRedirectUris} 616 + placeholder="https://example.com/auth/callback" 617 + readonlyValues={[happyviewCallbackUri]} 618 + /> 619 + </fieldset> 620 + <fieldset className="flex flex-col gap-3 rounded-lg border p-4"> 621 + <legend className="text-sm font-medium px-1">Scopes</legend> 622 + <p className="text-muted-foreground text-xs"> 623 + OAuth scopes this client is allowed to request. The <code className="bg-muted px-1 rounded">atproto</code> scope 624 + is always required. 625 + </p> 626 + <MultiInput 627 + id="edit-scopes" 628 + values={scopes} 629 + onChange={setScopes} 630 + placeholder="scope.name" 631 + readonlyValues={["atproto"]} 632 + /> 633 + </fieldset> 634 + <fieldset className="flex flex-col gap-3 rounded-lg border p-4"> 635 + <legend className="text-sm font-medium px-1">Rate Limiting</legend> 636 + <p className="text-muted-foreground text-xs"> 637 + Each client gets a token bucket. Requests consume tokens and the bucket 638 + refills over time. When the bucket is empty, requests are rejected until 639 + tokens replenish. 640 + </p> 641 + <div className="grid grid-cols-2 gap-4"> 642 + <div className="flex flex-col gap-2"> 643 + <Label htmlFor="edit-rl-capacity">Bucket Size</Label> 644 + <Input 645 + id="edit-rl-capacity" 646 + type="number" 647 + min={1} 648 + value={rateLimitCapacity} 649 + onChange={(e) => setRateLimitCapacity(e.target.value)} 650 + /> 651 + <p className="text-muted-foreground text-xs"> 652 + Maximum number of tokens. This is the burst limit. 653 + </p> 654 + </div> 655 + <div className="flex flex-col gap-2"> 656 + <Label htmlFor="edit-rl-refill">Refill Rate</Label> 657 + <Input 658 + id="edit-rl-refill" 659 + type="number" 660 + min={0.01} 661 + step="any" 662 + value={rateLimitRefillRate} 663 + onChange={(e) => setRateLimitRefillRate(e.target.value)} 664 + /> 665 + <p className="text-muted-foreground text-xs"> 666 + Tokens added per second. 667 + </p> 668 + </div> 669 + </div> 670 + </fieldset> 671 + </div> 672 + <ResponsiveDialogFooter> 673 + <ResponsiveDialogClose asChild> 674 + <Button variant="outline">Cancel</Button> 675 + </ResponsiveDialogClose> 676 + <Button onClick={handleSave} disabled={saving}> 677 + {saving ? "Saving..." : "Save"} 678 + </Button> 679 + </ResponsiveDialogFooter> 680 + </ResponsiveDialogContent> 681 + </ResponsiveDialog> 682 + ); 683 + } 684 + 685 + function DeleteApiClientDialog({ 686 + client, 687 + onSuccess, 688 + }: { 689 + client: ApiClientSummary; 690 + onSuccess: () => void; 691 + }) { 692 + const [open, setOpen] = useState(false); 693 + const [deleting, setDeleting] = useState(false); 694 + 695 + async function handleConfirm() { 696 + setDeleting(true); 697 + try { 698 + await deleteApiClient(client.id); 699 + setOpen(false); 700 + onSuccess(); 701 + } finally { 702 + setDeleting(false); 703 + } 704 + } 705 + 706 + return ( 707 + <ResponsiveDialog open={open} onOpenChange={setOpen}> 708 + <ResponsiveDialogTrigger asChild> 709 + <Button 710 + variant="ghost" 711 + size="icon" 712 + className="size-8 text-muted-foreground hover:text-destructive" 713 + title="Delete" 714 + aria-label="Delete" 715 + > 716 + <Trash2 className="size-4" /> 717 + </Button> 718 + </ResponsiveDialogTrigger> 719 + <ResponsiveDialogContent> 720 + <ResponsiveDialogHeader> 721 + <ResponsiveDialogTitle>Delete API Client</ResponsiveDialogTitle> 722 + <ResponsiveDialogDescription> 723 + This will permanently delete &ldquo;{client.name}&rdquo; and revoke its 724 + OAuth identity. Any applications using this client will lose the ability 725 + to authenticate. 726 + </ResponsiveDialogDescription> 727 + </ResponsiveDialogHeader> 728 + <ResponsiveDialogFooter> 729 + <ResponsiveDialogClose asChild> 730 + <Button variant="outline" disabled={deleting}> 731 + Cancel 732 + </Button> 733 + </ResponsiveDialogClose> 734 + <Button 735 + variant="destructive" 736 + disabled={deleting} 737 + onClick={handleConfirm} 738 + > 739 + {deleting ? "Deleting..." : "Delete"} 740 + </Button> 741 + </ResponsiveDialogFooter> 742 + </ResponsiveDialogContent> 743 + </ResponsiveDialog> 744 + ); 745 + }
+1 -1
web/src/app/dashboard/settings/api-keys/page.tsx
··· 46 46 Users: ["users:create", "users:read", "users:update", "users:delete"], 47 47 "API Keys": ["api-keys:create", "api-keys:read", "api-keys:delete"], 48 48 Backfill: ["backfill:create", "backfill:read"], 49 - "Rate Limits": ["rate-limits:read", "rate-limits:create", "rate-limits:delete"], 49 + "API Clients": ["api-clients:view", "api-clients:create", "api-clients:edit", "api-clients:delete"], 50 50 System: ["stats:read", "events:read"], 51 51 }; 52 52
+2 -2
web/src/app/dashboard/settings/page.tsx
··· 16 16 router.replace("/dashboard/settings/env-variables"); 17 17 } else if (hasPermission("api-keys:read")) { 18 18 router.replace("/dashboard/settings/api-keys"); 19 - } else if (hasPermission("rate-limits:read")) { 20 - router.replace("/dashboard/settings/rate-limits"); 19 + } else if (hasPermission("api-clients:view")) { 20 + router.replace("/dashboard/settings/api-clients"); 21 21 } 22 22 }, [router, hasPermission]); 23 23
-447
web/src/app/dashboard/settings/rate-limits/page.tsx
··· 1 - "use client"; 2 - 3 - import { useCallback, useEffect, useState } from "react"; 4 - import { Trash2 } from "lucide-react"; 5 - 6 - import { useCurrentUser } from "@/hooks/use-current-user"; 7 - import { 8 - getRateLimits, 9 - upsertRateLimit, 10 - setRateLimitEnabled, 11 - addAllowlistEntry, 12 - removeAllowlistEntry, 13 - } from "@/lib/api"; 14 - import type { AllowlistEntry } from "@/types/rate-limits"; 15 - import { SiteHeader } from "@/components/site-header"; 16 - import { Button } from "@/components/ui/button"; 17 - import { Input } from "@/components/ui/input"; 18 - import { Label } from "@/components/ui/label"; 19 - import { Switch } from "@/components/ui/switch"; 20 - import { 21 - ResponsiveDialog, 22 - ResponsiveDialogClose, 23 - ResponsiveDialogContent, 24 - ResponsiveDialogDescription, 25 - ResponsiveDialogFooter, 26 - ResponsiveDialogHeader, 27 - ResponsiveDialogTitle, 28 - ResponsiveDialogTrigger, 29 - } from "@/components/ui/responsive-dialog"; 30 - import { 31 - Table, 32 - TableBody, 33 - TableCell, 34 - TableHead, 35 - TableHeader, 36 - TableRow, 37 - } from "@/components/ui/table"; 38 - 39 - export default function RateLimitsPage() { 40 - const { hasPermission } = useCurrentUser(); 41 - const [enabled, setEnabled] = useState(false); 42 - const [capacity, setCapacity] = useState(""); 43 - const [refillRate, setRefillRate] = useState(""); 44 - const [defaultQueryCost, setDefaultQueryCost] = useState(""); 45 - const [defaultProcedureCost, setDefaultProcedureCost] = useState(""); 46 - const [defaultProxyCost, setDefaultProxyCost] = useState(""); 47 - const [allowlist, setAllowlist] = useState<AllowlistEntry[]>([]); 48 - const [error, setError] = useState<string | null>(null); 49 - const [toggling, setToggling] = useState(false); 50 - const [saving, setSaving] = useState(false); 51 - 52 - // Track original values for dirty detection 53 - const [origCapacity, setOrigCapacity] = useState(""); 54 - const [origRefillRate, setOrigRefillRate] = useState(""); 55 - const [origQueryCost, setOrigQueryCost] = useState(""); 56 - const [origProcedureCost, setOrigProcedureCost] = useState(""); 57 - const [origProxyCost, setOrigProxyCost] = useState(""); 58 - 59 - const load = useCallback(() => { 60 - getRateLimits() 61 - .then((data) => { 62 - setEnabled(data.enabled); 63 - setCapacity(String(data.capacity)); 64 - setRefillRate(String(data.refill_rate)); 65 - setDefaultQueryCost(String(data.default_query_cost)); 66 - setDefaultProcedureCost(String(data.default_procedure_cost)); 67 - setDefaultProxyCost(String(data.default_proxy_cost)); 68 - setOrigCapacity(String(data.capacity)); 69 - setOrigRefillRate(String(data.refill_rate)); 70 - setOrigQueryCost(String(data.default_query_cost)); 71 - setOrigProcedureCost(String(data.default_procedure_cost)); 72 - setOrigProxyCost(String(data.default_proxy_cost)); 73 - setAllowlist(data.allowlist); 74 - }) 75 - .catch((e) => setError(e.message)); 76 - }, []); 77 - 78 - useEffect(() => { 79 - load(); 80 - }, [load]); 81 - 82 - const isDirty = 83 - capacity !== origCapacity || 84 - refillRate !== origRefillRate || 85 - defaultQueryCost !== origQueryCost || 86 - defaultProcedureCost !== origProcedureCost || 87 - defaultProxyCost !== origProxyCost; 88 - 89 - async function handleToggleEnabled(checked: boolean) { 90 - setToggling(true); 91 - try { 92 - await setRateLimitEnabled({ enabled: checked }); 93 - setEnabled(checked); 94 - } catch (e: unknown) { 95 - setError(e instanceof Error ? e.message : String(e)); 96 - } finally { 97 - setToggling(false); 98 - } 99 - } 100 - 101 - async function handleSave() { 102 - setError(null); 103 - const cap = Number(capacity); 104 - const rate = Number(refillRate); 105 - const qc = Number(defaultQueryCost); 106 - const pc = Number(defaultProcedureCost); 107 - const xc = Number(defaultProxyCost); 108 - if (!cap || cap <= 0 || !rate || rate <= 0) { 109 - setError("Capacity and refill rate must be positive numbers."); 110 - return; 111 - } 112 - if (qc < 0 || pc < 0 || xc < 0) { 113 - setError("Default costs must be non-negative."); 114 - return; 115 - } 116 - setSaving(true); 117 - try { 118 - await upsertRateLimit({ 119 - capacity: cap, 120 - refill_rate: rate, 121 - default_query_cost: qc || 1, 122 - default_procedure_cost: pc || 1, 123 - default_proxy_cost: xc || 1, 124 - }); 125 - load(); 126 - } catch (e: unknown) { 127 - setError(e instanceof Error ? e.message : String(e)); 128 - } finally { 129 - setSaving(false); 130 - } 131 - } 132 - 133 - async function handleRemoveAllowlistEntry(id: number) { 134 - try { 135 - await removeAllowlistEntry(id); 136 - load(); 137 - } catch (e: unknown) { 138 - setError(e instanceof Error ? e.message : String(e)); 139 - } 140 - } 141 - 142 - const canEdit = hasPermission("rate-limits:create"); 143 - 144 - return ( 145 - <> 146 - <SiteHeader title="Rate Limits" /> 147 - <div className="flex flex-1 flex-col gap-6 p-4 md:p-6"> 148 - {error && <p className="text-destructive text-sm">{error}</p>} 149 - 150 - {/* Global toggle */} 151 - <div className="flex items-center gap-3"> 152 - <Switch 153 - id="rate-limit-enabled" 154 - checked={enabled} 155 - disabled={toggling || !canEdit} 156 - onCheckedChange={handleToggleEnabled} 157 - /> 158 - <Label htmlFor="rate-limit-enabled" className="cursor-pointer"> 159 - Rate limiting enabled 160 - </Label> 161 - </div> 162 - 163 - {/* Global bucket + Default costs */} 164 - <div className="flex flex-col gap-4"> 165 - <div> 166 - <h2 className="text-lg font-semibold">Global Bucket & Default Costs</h2> 167 - <p className="text-muted-foreground text-sm"> 168 - Configure the shared token bucket and default costs by request type. 169 - </p> 170 - </div> 171 - 172 - <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5"> 173 - <div className="flex flex-col gap-2"> 174 - <Label htmlFor="capacity">Capacity</Label> 175 - <Input 176 - id="capacity" 177 - type="number" 178 - min={1} 179 - value={capacity} 180 - onChange={(e) => setCapacity(e.target.value)} 181 - disabled={!canEdit} 182 - /> 183 - </div> 184 - <div className="flex flex-col gap-2"> 185 - <Label htmlFor="refill-rate">Refill Rate (tokens/sec)</Label> 186 - <Input 187 - id="refill-rate" 188 - type="number" 189 - min={0.01} 190 - step="any" 191 - value={refillRate} 192 - onChange={(e) => setRefillRate(e.target.value)} 193 - disabled={!canEdit} 194 - /> 195 - </div> 196 - <div className="flex flex-col gap-2"> 197 - <Label htmlFor="query-cost">Default Query Cost</Label> 198 - <Input 199 - id="query-cost" 200 - type="number" 201 - min={0} 202 - value={defaultQueryCost} 203 - onChange={(e) => setDefaultQueryCost(e.target.value)} 204 - disabled={!canEdit} 205 - /> 206 - </div> 207 - <div className="flex flex-col gap-2"> 208 - <Label htmlFor="procedure-cost">Default Procedure Cost</Label> 209 - <Input 210 - id="procedure-cost" 211 - type="number" 212 - min={0} 213 - value={defaultProcedureCost} 214 - onChange={(e) => setDefaultProcedureCost(e.target.value)} 215 - disabled={!canEdit} 216 - /> 217 - </div> 218 - <div className="flex flex-col gap-2"> 219 - <Label htmlFor="proxy-cost">Default Proxy Cost</Label> 220 - <Input 221 - id="proxy-cost" 222 - type="number" 223 - min={0} 224 - value={defaultProxyCost} 225 - onChange={(e) => setDefaultProxyCost(e.target.value)} 226 - disabled={!canEdit} 227 - /> 228 - </div> 229 - </div> 230 - 231 - {canEdit && ( 232 - <div> 233 - <Button onClick={handleSave} disabled={!isDirty || saving}> 234 - {saving ? "Saving..." : "Save"} 235 - </Button> 236 - </div> 237 - )} 238 - </div> 239 - 240 - {/* IP allowlist */} 241 - <div className="flex flex-col gap-4"> 242 - <div className="flex items-center justify-between"> 243 - <div> 244 - <h2 className="text-lg font-semibold">IP Allowlist</h2> 245 - <p className="text-muted-foreground text-sm"> 246 - IPs or CIDRs that bypass rate limiting. 247 - </p> 248 - </div> 249 - {canEdit && ( 250 - <AddAllowlistDialog onSuccess={load} /> 251 - )} 252 - </div> 253 - 254 - <div className="overflow-clip rounded-lg border"> 255 - <Table> 256 - <TableHeader> 257 - <TableRow> 258 - <TableHead>CIDR</TableHead> 259 - <TableHead>Note</TableHead> 260 - <TableHead>Created</TableHead> 261 - <TableHead className="w-10 sticky right-0 bg-inherit z-[1]" /> 262 - </TableRow> 263 - </TableHeader> 264 - <TableBody> 265 - {allowlist.length === 0 && ( 266 - <TableRow> 267 - <TableCell 268 - colSpan={4} 269 - className="text-muted-foreground text-center" 270 - > 271 - No allowlist entries yet. 272 - </TableCell> 273 - </TableRow> 274 - )} 275 - {allowlist.map((entry) => ( 276 - <TableRow key={entry.id}> 277 - <TableCell className="font-mono text-sm"> 278 - {entry.cidr} 279 - </TableCell> 280 - <TableCell className="text-sm"> 281 - {entry.note ?? "\u2014"} 282 - </TableCell> 283 - <TableCell> 284 - {new Date(entry.created_at).toLocaleString()} 285 - </TableCell> 286 - <TableCell className="w-10 sticky right-0 bg-inherit z-[1]"> 287 - {hasPermission("rate-limits:delete") && ( 288 - <DeleteConfirmDialog 289 - title="Remove entry?" 290 - description={`This will remove "${entry.cidr}" from the allowlist.`} 291 - onConfirm={() => handleRemoveAllowlistEntry(entry.id)} 292 - /> 293 - )} 294 - </TableCell> 295 - </TableRow> 296 - ))} 297 - </TableBody> 298 - </Table> 299 - </div> 300 - </div> 301 - </div> 302 - </> 303 - ); 304 - } 305 - 306 - function AddAllowlistDialog({ 307 - onSuccess, 308 - }: { 309 - onSuccess: () => void; 310 - }) { 311 - const [cidr, setCidr] = useState(""); 312 - const [note, setNote] = useState(""); 313 - const [error, setError] = useState<string | null>(null); 314 - const [open, setOpen] = useState(false); 315 - 316 - async function handleAdd() { 317 - setError(null); 318 - try { 319 - const body: { cidr: string; note?: string } = { cidr: cidr.trim() }; 320 - if (note.trim()) body.note = note.trim(); 321 - await addAllowlistEntry(body); 322 - setCidr(""); 323 - setNote(""); 324 - setOpen(false); 325 - onSuccess(); 326 - } catch (e: unknown) { 327 - setError(e instanceof Error ? e.message : String(e)); 328 - } 329 - } 330 - 331 - return ( 332 - <ResponsiveDialog 333 - open={open} 334 - onOpenChange={(o) => { 335 - setOpen(o); 336 - if (o) { 337 - setCidr(""); 338 - setNote(""); 339 - setError(null); 340 - } 341 - }} 342 - > 343 - <ResponsiveDialogTrigger asChild> 344 - <Button>Add Entry</Button> 345 - </ResponsiveDialogTrigger> 346 - <ResponsiveDialogContent> 347 - <ResponsiveDialogHeader> 348 - <ResponsiveDialogTitle>Add Allowlist Entry</ResponsiveDialogTitle> 349 - <ResponsiveDialogDescription> 350 - Add an IP or CIDR range that bypasses rate limiting. 351 - </ResponsiveDialogDescription> 352 - </ResponsiveDialogHeader> 353 - <div className="flex flex-col gap-4"> 354 - {error && <p className="text-destructive text-sm">{error}</p>} 355 - <div className="flex flex-col gap-2"> 356 - <Label htmlFor="allowlist-cidr">CIDR</Label> 357 - <Input 358 - id="allowlist-cidr" 359 - value={cidr} 360 - onChange={(e) => setCidr(e.target.value)} 361 - placeholder="10.0.0.0/8" 362 - className="font-mono" 363 - /> 364 - </div> 365 - <div className="flex flex-col gap-2"> 366 - <Label htmlFor="allowlist-note">Note (optional)</Label> 367 - <Input 368 - id="allowlist-note" 369 - value={note} 370 - onChange={(e) => setNote(e.target.value)} 371 - placeholder="Internal network" 372 - /> 373 - </div> 374 - </div> 375 - <ResponsiveDialogFooter> 376 - <ResponsiveDialogClose asChild> 377 - <Button variant="outline">Cancel</Button> 378 - </ResponsiveDialogClose> 379 - <Button onClick={handleAdd} disabled={!cidr.trim()}> 380 - Add 381 - </Button> 382 - </ResponsiveDialogFooter> 383 - </ResponsiveDialogContent> 384 - </ResponsiveDialog> 385 - ); 386 - } 387 - 388 - function DeleteConfirmDialog({ 389 - title, 390 - description, 391 - onConfirm, 392 - }: { 393 - title: string; 394 - description: string; 395 - onConfirm: () => void; 396 - }) { 397 - const [open, setOpen] = useState(false); 398 - const [deleting, setDeleting] = useState(false); 399 - 400 - async function handleConfirm() { 401 - setDeleting(true); 402 - try { 403 - await onConfirm(); 404 - setOpen(false); 405 - } finally { 406 - setDeleting(false); 407 - } 408 - } 409 - 410 - return ( 411 - <ResponsiveDialog open={open} onOpenChange={setOpen}> 412 - <ResponsiveDialogTrigger asChild> 413 - <Button 414 - variant="ghost" 415 - size="icon" 416 - className="size-8 text-muted-foreground hover:text-destructive" 417 - title="Delete" 418 - aria-label="Delete" 419 - > 420 - <Trash2 className="size-4" /> 421 - </Button> 422 - </ResponsiveDialogTrigger> 423 - <ResponsiveDialogContent> 424 - <ResponsiveDialogHeader> 425 - <ResponsiveDialogTitle>{title}</ResponsiveDialogTitle> 426 - <ResponsiveDialogDescription> 427 - {description} 428 - </ResponsiveDialogDescription> 429 - </ResponsiveDialogHeader> 430 - <ResponsiveDialogFooter> 431 - <ResponsiveDialogClose asChild> 432 - <Button variant="outline" disabled={deleting}> 433 - Cancel 434 - </Button> 435 - </ResponsiveDialogClose> 436 - <Button 437 - variant="destructive" 438 - disabled={deleting} 439 - onClick={handleConfirm} 440 - > 441 - {deleting ? "Deleting..." : "Delete"} 442 - </Button> 443 - </ResponsiveDialogFooter> 444 - </ResponsiveDialogContent> 445 - </ResponsiveDialog> 446 - ); 447 - }
+1 -1
web/src/app/dashboard/settings/users/page.tsx
··· 55 55 Users: ["users:create", "users:read", "users:update", "users:delete"], 56 56 "API Keys": ["api-keys:create", "api-keys:read", "api-keys:delete"], 57 57 Backfill: ["backfill:create", "backfill:read"], 58 - "Rate Limits": ["rate-limits:read", "rate-limits:create", "rate-limits:delete"], 58 + "API Clients": ["api-clients:view", "api-clients:create", "api-clients:edit", "api-clients:delete"], 59 59 Plugins: ["plugins:read", "plugins:create", "plugins:delete"], 60 60 System: ["stats:read", "events:read"], 61 61 };
+2 -2
web/src/components/app-sidebar.tsx
··· 13 13 IconVariable, 14 14 IconTag, 15 15 IconChevronRight, 16 - IconShield, 17 16 IconLink, 18 17 IconPuzzle, 19 18 IconLockAccess, 20 19 IconInfoCircle, 20 + IconApps, 21 21 } from "@tabler/icons-react" 22 22 import Image from "next/image" 23 23 import Link from "next/link" ··· 59 59 { title: "Plugins", url: "/dashboard/settings/plugins", icon: IconPuzzle, requiredPermissions: ["plugins:read"] }, 60 60 { title: "ENV Variables", url: "/dashboard/settings/env-variables", icon: IconVariable, requiredPermissions: ["script-variables:read"] }, 61 61 { title: "API Keys", url: "/dashboard/settings/api-keys", icon: IconKey, requiredPermissions: ["api-keys:read"] }, 62 + { title: "API Clients", url: "/dashboard/settings/api-clients", icon: IconApps, requiredPermissions: ["api-clients:view"] }, 62 63 { title: "Labelers", url: "/dashboard/settings/labelers", icon: IconTag, requiredPermissions: ["labelers:read"] }, 63 - { title: "Rate Limits", url: "/dashboard/settings/rate-limits", icon: IconShield, requiredPermissions: ["rate-limits:read"] }, 64 64 { title: "OAuth", url: "/dashboard/settings/oauth", icon: IconLockAccess, requiredPermissions: ["settings:manage"] }, 65 65 ] as const 66 66
+32 -28
web/src/lib/api.ts
··· 9 9 import type { EventsListResponse } from "@/types/events" 10 10 import type { ScriptVariableSummary } from "@/types/script-variables" 11 11 import type { LabelerSummary } from "@/types/labelers" 12 - import type { RateLimitsResponse } from "@/types/rate-limits" 12 + import type { ApiClientSummary, CreateApiClientResponse } from "@/types/api-clients" 13 13 import type { SettingEntry } from "@/types/settings" 14 14 import type { 15 15 ExternalProvider, ··· 32 32 export type { ScriptVariableSummary } from "@/types/script-variables" 33 33 export type { LabelerSummary } from "@/types/labelers" 34 34 export type { RecordLabel } from "@/types/records" 35 - export type { AllowlistEntry, RateLimitsResponse } from "@/types/rate-limits" 35 + export type { ApiClientSummary, CreateApiClientResponse } from "@/types/api-clients" 36 36 export type { SettingEntry, OAuthSettings } from "@/types/settings" 37 37 export { OAUTH_SETTING_KEYS } from "@/types/settings" 38 38 export type { ··· 364 364 }) 365 365 } 366 366 367 - // Rate Limits 368 - export function getRateLimits() { 369 - return apiFetch<RateLimitsResponse>("/admin/rate-limits") 367 + // API Clients 368 + export function getApiClients() { 369 + return apiFetch<ApiClientSummary[]>("/admin/api-clients") 370 370 } 371 371 372 - export function upsertRateLimit( 372 + export function getApiClient(id: string) { 373 + return apiFetch<ApiClientSummary>(`/admin/api-clients/${encodeURIComponent(id)}`) 374 + } 375 + 376 + export function createApiClient( 373 377 body: { 374 - capacity: number 375 - refill_rate: number 376 - default_query_cost: number 377 - default_procedure_cost: number 378 - default_proxy_cost: number 378 + name: string 379 + client_id_url: string 380 + client_uri: string 381 + redirect_uris: string[] 382 + scopes?: string 383 + rate_limit_capacity: number 384 + rate_limit_refill_rate: number 379 385 } 380 386 ) { 381 - return apiFetch("/admin/rate-limits", { 387 + return apiFetch<CreateApiClientResponse>("/admin/api-clients", { 382 388 method: "POST", 383 389 body: JSON.stringify(body), 384 390 }) 385 391 } 386 392 387 - export function setRateLimitEnabled( 388 - body: { enabled: boolean } 393 + export function updateApiClient( 394 + id: string, 395 + body: { 396 + name?: string 397 + client_uri?: string 398 + redirect_uris?: string[] 399 + scopes?: string 400 + rate_limit_capacity?: number 401 + rate_limit_refill_rate?: number 402 + is_active?: boolean 403 + } 389 404 ) { 390 - return apiFetch("/admin/rate-limits/enabled", { 405 + return apiFetch(`/admin/api-clients/${encodeURIComponent(id)}`, { 391 406 method: "PUT", 392 407 body: JSON.stringify(body), 393 408 }) 394 409 } 395 410 396 - export function addAllowlistEntry( 397 - body: { cidr: string; note?: string } 398 - ) { 399 - return apiFetch("/admin/rate-limits/allowlist", { 400 - method: "POST", 401 - body: JSON.stringify(body), 402 - }) 403 - } 404 - 405 - export function removeAllowlistEntry( 406 - id: number 407 - ) { 408 - return apiFetch(`/admin/rate-limits/allowlist/${encodeURIComponent(id)}`, { 411 + export function deleteApiClient(id: string) { 412 + return apiFetch(`/admin/api-clients/${encodeURIComponent(id)}`, { 409 413 method: "DELETE", 410 414 }) 411 415 }
+12 -2
web/src/lib/config-context.tsx
··· 4 4 5 5 interface ConfigContextType { 6 6 public_url: string 7 + default_rate_limit_capacity: number 8 + default_rate_limit_refill_rate: number 7 9 } 8 10 9 - const ConfigContext = createContext<ConfigContextType>({ public_url: "" }) 11 + const ConfigContext = createContext<ConfigContextType>({ 12 + public_url: "", 13 + default_rate_limit_capacity: 100, 14 + default_rate_limit_refill_rate: 2.0, 15 + }) 10 16 11 17 export function ConfigProvider({ children }: { children: React.ReactNode }) { 12 18 const [config, setConfig] = useState<ConfigContextType | null>(null) ··· 19 25 return res.json() 20 26 }) 21 27 .then((data) => { 22 - setConfig({ public_url: data.public_url }) 28 + setConfig({ 29 + public_url: data.public_url, 30 + default_rate_limit_capacity: data.default_rate_limit_capacity, 31 + default_rate_limit_refill_rate: data.default_rate_limit_refill_rate, 32 + }) 23 33 }) 24 34 .catch((e) => setError(e.message)) 25 35 }, [])
+23
web/src/types/api-clients.ts
··· 1 + export interface ApiClientSummary { 2 + id: string 3 + client_key: string 4 + name: string 5 + client_id_url: string 6 + client_uri: string 7 + redirect_uris: string[] 8 + scopes: string 9 + rate_limit_capacity: number | null 10 + rate_limit_refill_rate: number | null 11 + is_active: boolean 12 + created_by: string 13 + created_at: string 14 + updated_at: string 15 + } 16 + 17 + export interface CreateApiClientResponse { 18 + id: string 19 + client_key: string 20 + client_secret: string 21 + name: string 22 + client_id_url: string 23 + }
-16
web/src/types/rate-limits.ts
··· 1 - export interface AllowlistEntry { 2 - id: number 3 - cidr: string 4 - note: string | null 5 - created_at: string 6 - } 7 - 8 - export interface RateLimitsResponse { 9 - enabled: boolean 10 - capacity: number 11 - refill_rate: number 12 - default_query_cost: number 13 - default_procedure_cost: number 14 - default_proxy_cost: number 15 - allowlist: AllowlistEntry[] 16 - }