From e4040924a5d11e9a7883081dc4c0404273b3573a Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 13 Apr 2026 09:50:18 -0500 Subject: [PATCH] feat: add API clients --- .../20260412300000_api_clients_add_secret.sql | 1 + .../20260412300000_api_clients_add_secret.sql | 1 + src/admin/api_clients.rs | 37 +- src/admin/types.rs | 1 + src/config.rs | 12 + src/lua/atproto_api.rs | 2 + src/lua/db_api.rs | 2 + src/lua/execute.rs | 2 + src/lua/http_api.rs | 2 + src/main.rs | 34 +- src/rate_limit.rs | 51 ++ src/server.rs | 22 +- src/xrpc/mod.rs | 102 ++- tests/common/app.rs | 2 + tests/lua_atproto_api.rs | 2 + tests/lua_db_api.rs | 2 + .../dashboard/settings/api-clients/page.tsx | 745 ++++++++++++++++++ .../app/dashboard/settings/api-keys/page.tsx | 2 +- web/src/app/dashboard/settings/page.tsx | 4 +- .../dashboard/settings/rate-limits/page.tsx | 447 ----------- web/src/app/dashboard/settings/users/page.tsx | 2 +- web/src/components/app-sidebar.tsx | 4 +- web/src/lib/api.ts | 60 +- web/src/lib/config-context.tsx | 14 +- web/src/types/api-clients.ts | 23 + web/src/types/rate-limits.ts | 16 - 26 files changed, 1058 insertions(+), 534 deletions(-) create mode 100644 migrations/postgres/20260412300000_api_clients_add_secret.sql create mode 100644 migrations/sqlite/20260412300000_api_clients_add_secret.sql create mode 100644 web/src/app/dashboard/settings/api-clients/page.tsx delete mode 100644 web/src/app/dashboard/settings/rate-limits/page.tsx create mode 100644 web/src/types/api-clients.ts delete mode 100644 web/src/types/rate-limits.ts diff --git a/migrations/postgres/20260412300000_api_clients_add_secret.sql b/migrations/postgres/20260412300000_api_clients_add_secret.sql new file mode 100644 index 0000000..a3926a4 --- /dev/null +++ b/migrations/postgres/20260412300000_api_clients_add_secret.sql @@ -0,0 +1 @@ +ALTER TABLE api_clients ADD COLUMN client_secret_hash TEXT NOT NULL DEFAULT ''; diff --git a/migrations/sqlite/20260412300000_api_clients_add_secret.sql b/migrations/sqlite/20260412300000_api_clients_add_secret.sql new file mode 100644 index 0000000..a3926a4 --- /dev/null +++ b/migrations/sqlite/20260412300000_api_clients_add_secret.sql @@ -0,0 +1 @@ +ALTER TABLE api_clients ADD COLUMN client_secret_hash TEXT NOT NULL DEFAULT ''; diff --git a/src/admin/api_clients.rs b/src/admin/api_clients.rs index 61f95fa..4597158 100644 --- a/src/admin/api_clients.rs +++ b/src/admin/api_clients.rs @@ -3,6 +3,7 @@ use axum::extract::{Path, State}; use axum::http::StatusCode; use hex; use rand::Rng; +use sha2::{Digest, Sha256}; use uuid::Uuid; use crate::AppState; @@ -29,19 +30,26 @@ pub(super) async fn create_api_client( rand::rng().fill(&mut random_bytes); let client_key = format!("hvc_{}", hex::encode(random_bytes)); + // Generate the client secret: "hvs_" + 64 random hex chars. + let mut secret_bytes = [0u8; 32]; + rand::rng().fill(&mut secret_bytes); + let client_secret = format!("hvs_{}", hex::encode(secret_bytes)); + let client_secret_hash = hex::encode(Sha256::digest(client_secret.as_bytes())); + let id = Uuid::new_v4().to_string(); let now = now_rfc3339(); let redirect_uris_json = serde_json::to_string(&body.redirect_uris).unwrap_or_else(|_| "[]".to_string()); let insert_sql = adapt_sql( - "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, ?, ?, ?)", + "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, ?, ?, ?)", state.db_backend, ); sqlx::query(&insert_sql) .bind(&id) .bind(&client_key) + .bind(&client_secret_hash) .bind(&body.name) .bind(&body.client_id_url) .bind(&body.client_uri) @@ -73,6 +81,15 @@ pub(super) async fn create_api_client( tracing::warn!(client_id = %body.client_id_url, error = %e, "OAuth client registration failed (DB row created)"); } + // Register the client identity for request validation. + state.rate_limiter.register_client_identity( + client_key.clone(), + crate::rate_limit::ClientIdentity { + secret_hash: client_secret_hash, + client_uri: body.client_uri.clone(), + }, + ); + // Register per-client rate limit config if overrides are set. if let (Some(capacity), Some(refill_rate)) = (body.rate_limit_capacity, body.rate_limit_refill_rate) @@ -111,6 +128,7 @@ pub(super) async fn create_api_client( Json(CreateApiClientResponse { id, client_key, + client_secret, name: body.name, client_id_url: body.client_id_url, }), @@ -274,7 +292,7 @@ pub(super) async fn update_api_client( // Read current values let select_sql = adapt_sql( - "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 = ?", + "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 = ?", state.db_backend, ); @@ -285,6 +303,7 @@ pub(super) async fn update_api_client( String, String, String, + String, Option, Option, i32, @@ -297,6 +316,7 @@ pub(super) async fn update_api_client( let Some(( client_key, + client_secret_hash, cur_name, client_id_url, cur_client_uri, @@ -367,8 +387,15 @@ pub(super) async fn update_api_client( state.oauth.remove(&client_id_url); } - // Update per-client rate limit config. + // Update client identity and per-client rate limit config. if is_active != 0 { + state.rate_limiter.register_client_identity( + client_key.clone(), + crate::rate_limit::ClientIdentity { + secret_hash: client_secret_hash, + client_uri: client_uri.clone(), + }, + ); if let (Some(cap), Some(refill)) = (capacity, refill_rate) { let global = state.rate_limiter.global_config(); state.rate_limiter.register_client_config( @@ -386,6 +413,7 @@ pub(super) async fn update_api_client( state.rate_limiter.remove_client_config(&client_key); } } else { + state.rate_limiter.remove_client_identity(&client_key); state.rate_limiter.remove_client_config(&client_key); } @@ -436,10 +464,11 @@ pub(super) async fn delete_api_client( return Err(AppError::NotFound(format!("api client '{id}' not found"))); } - // Remove from OAuth registry and rate limiter. + // Remove from OAuth registry, rate limiter, and client identities. if let Some((url, key)) = client_info { state.oauth.remove(&url); state.rate_limiter.remove_client_config(&key); + state.rate_limiter.remove_client_identity(&key); } log_event( diff --git a/src/admin/types.rs b/src/admin/types.rs index 21e4de4..3223a89 100644 --- a/src/admin/types.rs +++ b/src/admin/types.rs @@ -348,6 +348,7 @@ pub(super) struct ApiClientSummary { pub(super) struct CreateApiClientResponse { pub(super) id: String, pub(super) client_key: String, + pub(super) client_secret: String, pub(super) name: String, pub(super) client_id_url: String, } diff --git a/src/config.rs b/src/config.rs index cc40763..a1e2f8e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -21,6 +21,8 @@ pub struct Config { pub tos_uri: Option, pub policy_uri: Option, pub token_encryption_key: Option<[u8; 32]>, + pub default_rate_limit_capacity: u32, + pub default_rate_limit_refill_rate: f64, } impl Config { @@ -62,6 +64,14 @@ impl Config { .ok() .and_then(|bytes| bytes.try_into().ok()) }), + default_rate_limit_capacity: env::var("DEFAULT_RATE_LIMIT_CAPACITY") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(100), + default_rate_limit_refill_rate: env::var("DEFAULT_RATE_LIMIT_REFILL_RATE") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(2.0), } } @@ -126,6 +136,8 @@ mod tests { tos_uri: None, policy_uri: None, token_encryption_key: None, + default_rate_limit_capacity: 100, + default_rate_limit_refill_rate: 2.0, }; assert_eq!( config.listen_addr(), diff --git a/src/lua/atproto_api.rs b/src/lua/atproto_api.rs index 4491830..bf41c5f 100644 --- a/src/lua/atproto_api.rs +++ b/src/lua/atproto_api.rs @@ -287,6 +287,8 @@ mod tests { tos_uri: None, policy_uri: None, token_encryption_key: None, + default_rate_limit_capacity: 100, + default_rate_limit_refill_rate: 2.0, }; let (tx, _) = watch::channel(vec![]); let (labeler_tx, _) = watch::channel(()); diff --git a/src/lua/db_api.rs b/src/lua/db_api.rs index bbc7f8d..a19c93f 100644 --- a/src/lua/db_api.rs +++ b/src/lua/db_api.rs @@ -638,6 +638,8 @@ mod tests { tos_uri: None, policy_uri: None, token_encryption_key: None, + default_rate_limit_capacity: 100, + default_rate_limit_refill_rate: 2.0, }; let (tx, _) = watch::channel(vec![]); let (labeler_tx, _) = watch::channel(()); diff --git a/src/lua/execute.rs b/src/lua/execute.rs index f4ac7ae..8f59404 100644 --- a/src/lua/execute.rs +++ b/src/lua/execute.rs @@ -964,6 +964,8 @@ mod tests { tos_uri: None, policy_uri: None, token_encryption_key: None, + default_rate_limit_capacity: 100, + default_rate_limit_refill_rate: 2.0, }; let (tx, _) = watch::channel(vec![]); let (labeler_tx, _) = watch::channel(()); diff --git a/src/lua/http_api.rs b/src/lua/http_api.rs index b33b80e..4b4642b 100644 --- a/src/lua/http_api.rs +++ b/src/lua/http_api.rs @@ -104,6 +104,8 @@ mod tests { tos_uri: None, policy_uri: None, token_encryption_key: None, + default_rate_limit_capacity: 100, + default_rate_limit_refill_rate: 2.0, }; let (tx, _) = watch::channel(vec![]); let (labeler_tx, _) = watch::channel(()); diff --git a/src/main.rs b/src/main.rs index 670ad1e..c25d97f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -269,27 +269,37 @@ async fn main() { let rate_limiter = RateLimiter::new(rl_state.enabled, rl_state.global); tokio::spawn(rate_limiter.clone().spawn_cleanup()); - // Load per-client rate limit configs from api_clients table. + // Load per-client rate limit configs and identities from api_clients table. { - let client_configs: Vec<(String, i32, f64)> = sqlx::query_as( - "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", + type ClientRow = (String, String, String, Option, Option); + let client_rows: Vec = sqlx::query_as( + "SELECT client_key, client_secret_hash, client_uri, rate_limit_capacity, rate_limit_refill_rate FROM api_clients WHERE is_active = 1", ) .fetch_all(&db_pool) .await .unwrap_or_default(); let global = rate_limiter.global_config(); - for (client_key, capacity, refill_rate) in client_configs { - rate_limiter.register_client_config( - client_key, - RateLimitConfig { - capacity: capacity as u32, - refill_rate, - default_query_cost: global.default_query_cost, - default_procedure_cost: global.default_procedure_cost, - default_proxy_cost: global.default_proxy_cost, + for (client_key, secret_hash, client_uri, capacity, refill_rate) in client_rows { + rate_limiter.register_client_identity( + client_key.clone(), + happyview::rate_limit::ClientIdentity { + secret_hash, + client_uri, }, ); + if let (Some(cap), Some(refill)) = (capacity, refill_rate) { + rate_limiter.register_client_config( + client_key, + RateLimitConfig { + capacity: cap as u32, + refill_rate: refill, + default_query_cost: global.default_query_cost, + default_procedure_cost: global.default_procedure_cost, + default_proxy_cost: global.default_proxy_cost, + }, + ); + } } } diff --git a/src/rate_limit.rs b/src/rate_limit.rs index b3a9e03..eb3453b 100644 --- a/src/rate_limit.rs +++ b/src/rate_limit.rs @@ -35,12 +35,22 @@ struct TokenBucket { last_access: Instant, } +/// Metadata about a registered API client, used for request validation. +pub struct ClientIdentity { + /// SHA-256 hash of the client secret + pub secret_hash: String, + /// The client's registered URI (for Origin header validation) + pub client_uri: String, +} + pub struct RateLimiter { enabled: AtomicBool, buckets: DashMap, global_config: ArcSwap, /// Per-client config overrides, keyed by client_key (e.g. "hvc_...") client_configs: DashMap, + /// Registered client identities, keyed by client_key + client_identities: DashMap, } pub struct RateLimiterState { @@ -62,6 +72,7 @@ impl RateLimiter { buckets: DashMap::new(), global_config: ArcSwap::new(Arc::new(global)), client_configs: DashMap::new(), + client_identities: DashMap::new(), }) } @@ -164,6 +175,46 @@ impl RateLimiter { self.client_configs.remove(client_key); } + /// Register a client identity (key, secret hash, and client URI). + pub fn register_client_identity(&self, client_key: String, identity: ClientIdentity) { + self.client_identities.insert(client_key, identity); + } + + /// Remove a client identity. + pub fn remove_client_identity(&self, client_key: &str) { + self.client_identities.remove(client_key); + } + + /// Validate a client key + secret combination. Returns true if the secret + /// hash matches the stored hash for this client key. + pub fn validate_client_secret(&self, client_key: &str, secret: &str) -> bool { + use sha2::{Digest, Sha256}; + if let Some(identity) = self.client_identities.get(client_key) { + let hash = hex::encode(Sha256::digest(secret.as_bytes())); + hash == identity.secret_hash + } else { + false + } + } + + /// Validate a client key + origin combination. Returns true if the origin + /// matches the registered client_uri for this client key. + pub fn validate_client_origin(&self, client_key: &str, origin: &str) -> bool { + if let Some(identity) = self.client_identities.get(client_key) { + // Compare origins: strip trailing slash for consistency + let registered = identity.client_uri.trim_end_matches('/'); + let provided = origin.trim_end_matches('/'); + registered == provided + } else { + false + } + } + + /// Check whether a client key is registered. + pub fn is_valid_client_key(&self, client_key: &str) -> bool { + self.client_identities.contains_key(client_key) + } + pub async fn spawn_cleanup(self: Arc) { let interval = tokio::time::Duration::from_secs(60); let stale_threshold = std::time::Duration::from_secs(300); // 5 minutes diff --git a/src/server.rs b/src/server.rs index c1b5950..21bbc5b 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,5 +1,4 @@ use axum::extract::{DefaultBodyLimit, State}; -use axum::http::HeaderMap; use axum::http::{Method, header}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; @@ -83,7 +82,13 @@ pub fn router(state: AppState) -> Router { CorsLayer::new() .allow_origin(tower_http::cors::AllowOrigin::mirror_request()) .allow_methods([Method::GET, Method::POST, Method::OPTIONS]) - .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION, header::COOKIE]) + .allow_headers([ + header::CONTENT_TYPE, + header::AUTHORIZATION, + header::COOKIE, + axum::http::HeaderName::from_static("x-client-key"), + axum::http::HeaderName::from_static("x-client-secret"), + ]) .allow_credentials(true), ) .with_state(state) @@ -101,6 +106,8 @@ async fn config_endpoint(State(state): State) -> Json) -> Json, - claims: Claims, - _headers: HeaderMap, -) -> Result { - let rate_key = claims.did().to_string(); +async fn get_profile(State(state): State, claims: Claims) -> Result { + let rate_key = claims + .client_key() + .map(|k| k.to_string()) + .unwrap_or_else(|| claims.did().to_string()); let check = state .rate_limiter .check(&rate_key, state.rate_limiter.default_cost_for_type("query")); diff --git a/src/xrpc/mod.rs b/src/xrpc/mod.rs index 55b291b..874f074 100644 --- a/src/xrpc/mod.rs +++ b/src/xrpc/mod.rs @@ -154,6 +154,92 @@ async fn proxy_to_authority( .unwrap()) } +/// Extract the API client key from the request for rate limiting. +/// +/// Every request must carry a client key. Returns an error when none is +/// found so the caller can reject the request with 401. +/// +/// Resolution order: +/// 1. Session cookie (`client_key` field in Claims) +/// 2. `X-Client-Key` header +/// 3. `client_key` query parameter +/// +/// Security validation (Origin / secret) is logged as warnings but does +/// not reject the request — the key is always used as the rate-limit +/// bucket regardless. +fn resolve_client_key( + state: &AppState, + claims: Option<&Claims>, + parts: &Parts, + query_params: &std::collections::HashMap, +) -> Result { + // 1. Try session cookie + let client_key = claims + .and_then(|c| c.client_key().map(|k| k.to_string())) + // 2. Try X-Client-Key header + .or_else(|| { + parts + .headers + .get("x-client-key") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + }) + // 3. Try client_key query param + .or_else(|| { + query_params + .get("client_key") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .ok_or_else(|| { + AppError::Auth( + "Missing client identification. Provide an X-Client-Key header or client_key query parameter.".into(), + ) + })?; + + // Log validation warnings but always return the key for rate limiting. + if !state.rate_limiter.is_valid_client_key(&client_key) { + tracing::warn!("Unknown client key: {client_key}"); + return Ok(client_key); + } + + // If the key came from a session cookie, it was already validated at login time. + let from_session = claims + .and_then(|c| c.client_key()) + .map(|k| k == client_key) + .unwrap_or(false); + + if !from_session { + let origin = parts.headers.get("origin").and_then(|v| v.to_str().ok()); + + if let Some(origin) = origin { + if !state + .rate_limiter + .validate_client_origin(&client_key, origin) + { + tracing::warn!("Origin mismatch for client {client_key}: got {origin}"); + } + } else { + let secret = parts + .headers + .get("x-client-secret") + .and_then(|v| v.to_str().ok()); + + match secret { + Some(s) if state.rate_limiter.validate_client_secret(&client_key, s) => {} + Some(_) => { + tracing::warn!("Invalid client secret for {client_key}"); + } + None => { + tracing::warn!("No Origin or X-Client-Secret for client {client_key}"); + } + } + } + } + + Ok(client_key) +} + /// Apply rate limit headers to a response. fn apply_rate_limit_headers(response: &mut Response, remaining: u32, limit: u32, reset: u64) { let headers = response.headers_mut(); @@ -173,11 +259,7 @@ pub async fn xrpc_get( let mut params = parse_query_params(&raw_query); let claims = Claims::from_request_parts(&mut parts, &state).await.ok(); - // Rate limit check — keyed by client_key for API client requests, "anonymous" otherwise - let rate_key = claims - .as_ref() - .and_then(|c| c.client_key().map(|k| k.to_string())) - .unwrap_or_else(|| "anonymous".to_string()); + let rate_key = resolve_client_key(&state, claims.as_ref(), &parts, ¶ms)?; let lexicon = state.lexicons.get(&method).await; @@ -252,16 +334,14 @@ pub async fn xrpc_post( State(state): State, Path(method): Path, RawQuery(raw_query): RawQuery, - claims: Claims, - _headers: axum::http::HeaderMap, + mut parts: Parts, Json(body): Json, ) -> Result { let raw_query = raw_query.unwrap_or_default(); let mut params = parse_query_params(&raw_query); - let rate_key = claims - .client_key() - .map(|k| k.to_string()) - .unwrap_or_else(|| "anonymous".to_string()); + let claims = Claims::from_request_parts(&mut parts, &state).await?; + + let rate_key = resolve_client_key(&state, Some(&claims), &parts, ¶ms)?; let lexicon = state.lexicons.get(&method).await; diff --git a/tests/common/app.rs b/tests/common/app.rs index da0dab8..eb204c0 100644 --- a/tests/common/app.rs +++ b/tests/common/app.rs @@ -51,6 +51,8 @@ impl TestApp { tos_uri: None, policy_uri: None, token_encryption_key: None, + default_rate_limit_capacity: 100, + default_rate_limit_refill_rate: 2.0, }; let sql = adapt_sql( diff --git a/tests/lua_atproto_api.rs b/tests/lua_atproto_api.rs index 8f28964..9636bbc 100644 --- a/tests/lua_atproto_api.rs +++ b/tests/lua_atproto_api.rs @@ -33,6 +33,8 @@ async fn test_state_with_pool(pool: sqlx::AnyPool, backend: DatabaseBackend) -> tos_uri: None, policy_uri: None, token_encryption_key: None, + default_rate_limit_capacity: 100, + default_rate_limit_refill_rate: 2.0, }; let (tx, _) = watch::channel(vec![]); let (labeler_tx, _) = watch::channel(()); diff --git a/tests/lua_db_api.rs b/tests/lua_db_api.rs index 8bb6edd..b335db0 100644 --- a/tests/lua_db_api.rs +++ b/tests/lua_db_api.rs @@ -36,6 +36,8 @@ async fn test_state_with_pool(pool: sqlx::AnyPool, backend: DatabaseBackend) -> tos_uri: None, policy_uri: None, token_encryption_key: None, + default_rate_limit_capacity: 100, + default_rate_limit_refill_rate: 2.0, }; let (tx, _) = watch::channel(vec![]); let (labeler_tx, _) = watch::channel(()); diff --git a/web/src/app/dashboard/settings/api-clients/page.tsx b/web/src/app/dashboard/settings/api-clients/page.tsx new file mode 100644 index 0000000..438f0ea --- /dev/null +++ b/web/src/app/dashboard/settings/api-clients/page.tsx @@ -0,0 +1,745 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { Copy, Check, Trash2, X } from "lucide-react"; + +import { useConfig } from "@/lib/config-context"; +import { useCurrentUser } from "@/hooks/use-current-user"; +import { + getApiClients, + createApiClient, + updateApiClient, + deleteApiClient, +} from "@/lib/api"; +import type { ApiClientSummary, CreateApiClientResponse } from "@/types/api-clients"; +import { SiteHeader } from "@/components/site-header"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { + ResponsiveDialog, + ResponsiveDialogClose, + ResponsiveDialogContent, + ResponsiveDialogDescription, + ResponsiveDialogFooter, + ResponsiveDialogHeader, + ResponsiveDialogTitle, + ResponsiveDialogTrigger, +} from "@/components/ui/responsive-dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +function MultiInput({ + values, + onChange, + placeholder, + readonlyValues = [], + id, +}: { + values: string[]; + onChange: (values: string[]) => void; + placeholder?: string; + readonlyValues?: string[]; + id?: string; +}) { + function handleChange(index: number, value: string) { + const next = [...values]; + next[index] = value; + // If user typed into the last input, add an empty one + if (index === values.length - 1 && value.trim() !== "") { + next.push(""); + } + onChange(next); + } + + function handleRemove(index: number) { + const next = values.filter((_, i) => i !== index); + // Always keep at least one empty input + if (next.length === 0 || next[next.length - 1].trim() !== "") { + next.push(""); + } + onChange(next); + } + + function handleKeyDown(index: number, e: React.KeyboardEvent) { + if (e.key === "Backspace" && values[index] === "" && values.length > 1) { + e.preventDefault(); + handleRemove(index); + } + } + + return ( +
+ {readonlyValues.map((val, i) => ( + + ))} + {values.map((val, index) => ( +
+ handleChange(index, e.target.value)} + onKeyDown={(e) => handleKeyDown(index, e)} + placeholder={placeholder} + className="font-mono text-sm" + /> + {values.length > 1 && val.trim() !== "" && ( + + )} +
+ ))} +
+ ); +} + +export default function ApiClientsPage() { + const { hasPermission } = useCurrentUser(); + const [clients, setClients] = useState([]); + const [error, setError] = useState(null); + + const load = useCallback(() => { + getApiClients() + .then(setClients) + .catch((e) => setError(e.message)); + }, []); + + useEffect(() => { + load(); + }, [load]); + + return ( + <> + +
+ {error &&

{error}

} + +
+
+

API Clients

+

+ Registered applications that authenticate through this AppView. +

+
+ {hasPermission("api-clients:create") && ( + + )} +
+ +
+ + + + Name + Client Key + Client ID URL + Scopes + Status + Created + + + + + {clients.length === 0 && ( + + + No API clients yet. + + + )} + {clients.map((client) => ( + + {client.name} + + {client.client_key.slice(0, 12)}... + + + {client.client_id_url} + + + {client.scopes} + + + + {client.is_active ? "Active" : "Inactive"} + + + + {new Date(client.created_at).toLocaleString()} + + +
+ {hasPermission("api-clients:edit") && ( + + )} + {hasPermission("api-clients:delete") && ( + + )} +
+
+
+ ))} +
+
+
+
+ + ); +} + +function CreateApiClientDialog({ onSuccess }: { onSuccess: () => void }) { + const config = useConfig(); + const happyviewCallbackUri = `${config.public_url.replace(/\/$/, "")}/auth/callback`; + + const [name, setName] = useState(""); + const [clientIdUrl, setClientIdUrl] = useState(""); + const [clientUri, setClientUri] = useState(""); + const [redirectUris, setRedirectUris] = useState([""]); + const [scopes, setScopes] = useState([""]); + const [rateLimitCapacity, setRateLimitCapacity] = useState( + String(config.default_rate_limit_capacity) + ); + const [rateLimitRefillRate, setRateLimitRefillRate] = useState( + String(config.default_rate_limit_refill_rate) + ); + const [error, setError] = useState(null); + const [open, setOpen] = useState(false); + const [created, setCreated] = useState(null); + const [copiedField, setCopiedField] = useState(null); + + function handleOpenChange(nextOpen: boolean) { + setOpen(nextOpen); + if (!nextOpen) { + setName(""); + setClientIdUrl(""); + setClientUri(""); + setRedirectUris([""]); + setScopes([""]); + setRateLimitCapacity(String(config.default_rate_limit_capacity)); + setRateLimitRefillRate(String(config.default_rate_limit_refill_rate)); + setError(null); + if (created) { + setCreated(null); + onSuccess(); + } + } + } + + async function handleCopy(value: string, field: string) { + await navigator.clipboard.writeText(value); + setCopiedField(field); + setTimeout(() => setCopiedField(null), 2000); + } + + async function handleCreate() { + setError(null); + const extraUris = redirectUris.map((u) => u.trim()).filter(Boolean); + const allUris = [happyviewCallbackUri, ...extraUris]; + const extraScopes = scopes.map((s) => s.trim()).filter(Boolean); + const allScopes = ["atproto", ...extraScopes].join(" "); + + if (!name.trim() || !clientIdUrl.trim() || !clientUri.trim()) { + setError("Name, Client ID URL, and Client URI are required."); + return; + } + if (!rateLimitCapacity || !rateLimitRefillRate) { + setError("Rate limit capacity and refill rate are required."); + return; + } + try { + const result = await createApiClient({ + name: name.trim(), + client_id_url: clientIdUrl.trim(), + client_uri: clientUri.trim(), + redirect_uris: allUris, + scopes: allScopes, + rate_limit_capacity: Number(rateLimitCapacity), + rate_limit_refill_rate: Number(rateLimitRefillRate), + }); + setCreated(result); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } + } + + return ( + + + + + + + + {created ? "API Client Created" : "Create API Client"} + + + {created + ? "Save the credentials below. The secret will not be shown again." + : "Register a new application that authenticates through this AppView."} + + + + {created ? ( +
+
+ +
+ + +
+

+ Public identifier. Send as the X-Client-Key header + or client_key query parameter. +

+
+
+ +
+ + +
+

+ Keep this secret. Send as the X-Client-Secret header + for server-to-server requests. Browser requests are validated by Origin instead. +

+
+
+ ) : ( +
+ {error &&

{error}

} +
+ Application +
+ + setName(e.target.value)} + placeholder="My App" + /> +
+
+ + setClientIdUrl(e.target.value)} + placeholder="https://example.com/oauth-client-metadata.json" + className="font-mono text-sm" + /> +

+ The URL where the client metadata JSON is served. +

+
+
+ + setClientUri(e.target.value)} + placeholder="https://example.com" + className="font-mono text-sm" + /> +
+
+
+ Redirect URIs +

+ URLs that the authorization server may redirect to after authentication. + The AppView callback is always included. +

+ +
+
+ Scopes +

+ OAuth scopes this client is allowed to request. The atproto scope + is always required. +

+ +
+
+ Rate Limiting +

+ Each client gets a token bucket. Requests consume tokens and the bucket + refills over time. When the bucket is empty, requests are rejected until + tokens replenish. +

+
+
+ + setRateLimitCapacity(e.target.value)} + /> +

+ Maximum number of tokens. This is the burst limit. +

+
+
+ + setRateLimitRefillRate(e.target.value)} + /> +

+ Tokens added per second. +

+
+
+
+
+ )} + + + + + + {!created && ( + + )} + +
+
+ ); +} + +function EditApiClientDialog({ + client, + onSuccess, +}: { + client: ApiClientSummary; + onSuccess: () => void; +}) { + const config = useConfig(); + const happyviewCallbackUri = `${config.public_url.replace(/\/$/, "")}/auth/callback`; + + // Parse existing redirect URIs: separate the HappyView callback from user-added ones + function parseRedirectUris(uris: string[]): string[] { + const filtered = uris.filter((u) => u !== happyviewCallbackUri); + return filtered.length > 0 ? [...filtered, ""] : [""]; + } + + // Parse existing scopes: separate "atproto" from user-added ones + function parseScopes(scopeStr: string): string[] { + const parts = scopeStr.split(/\s+/).filter((s) => s && s !== "atproto"); + return parts.length > 0 ? [...parts, ""] : [""]; + } + + const [name, setName] = useState(client.name); + const [redirectUris, setRedirectUris] = useState( + parseRedirectUris(client.redirect_uris) + ); + const [scopes, setScopes] = useState(parseScopes(client.scopes)); + const [isActive, setIsActive] = useState(client.is_active); + const [rateLimitCapacity, setRateLimitCapacity] = useState( + String(client.rate_limit_capacity ?? config.default_rate_limit_capacity) + ); + const [rateLimitRefillRate, setRateLimitRefillRate] = useState( + String(client.rate_limit_refill_rate ?? config.default_rate_limit_refill_rate) + ); + const [error, setError] = useState(null); + const [open, setOpen] = useState(false); + const [saving, setSaving] = useState(false); + + function handleOpenChange(nextOpen: boolean) { + setOpen(nextOpen); + if (nextOpen) { + setName(client.name); + setRedirectUris(parseRedirectUris(client.redirect_uris)); + setScopes(parseScopes(client.scopes)); + setIsActive(client.is_active); + setRateLimitCapacity( + String(client.rate_limit_capacity ?? config.default_rate_limit_capacity) + ); + setRateLimitRefillRate( + String(client.rate_limit_refill_rate ?? config.default_rate_limit_refill_rate) + ); + setError(null); + } + } + + async function handleSave() { + setError(null); + if (!rateLimitCapacity || !rateLimitRefillRate) { + setError("Rate limit capacity and refill rate are required."); + return; + } + setSaving(true); + try { + const extraUris = redirectUris.map((u) => u.trim()).filter(Boolean); + const allUris = [happyviewCallbackUri, ...extraUris]; + const extraScopes = scopes.map((s) => s.trim()).filter(Boolean); + const allScopes = ["atproto", ...extraScopes].join(" "); + + await updateApiClient(client.id, { + name: name.trim() || undefined, + redirect_uris: allUris, + scopes: allScopes, + is_active: isActive, + rate_limit_capacity: Number(rateLimitCapacity), + rate_limit_refill_rate: Number(rateLimitRefillRate), + }); + setOpen(false); + onSuccess(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setSaving(false); + } + } + + return ( + + + + + + + Edit API Client + + Update settings for “{client.name}”. + + +
+ {error &&

{error}

} +
+ Application +
+ + setName(e.target.value)} + /> +
+
+ + +
+
+
+ Redirect URIs +

+ URLs that the authorization server may redirect to after authentication. + The AppView callback is always included. +

+ +
+
+ Scopes +

+ OAuth scopes this client is allowed to request. The atproto scope + is always required. +

+ +
+
+ Rate Limiting +

+ Each client gets a token bucket. Requests consume tokens and the bucket + refills over time. When the bucket is empty, requests are rejected until + tokens replenish. +

+
+
+ + setRateLimitCapacity(e.target.value)} + /> +

+ Maximum number of tokens. This is the burst limit. +

+
+
+ + setRateLimitRefillRate(e.target.value)} + /> +

+ Tokens added per second. +

+
+
+
+
+ + + + + + +
+
+ ); +} + +function DeleteApiClientDialog({ + client, + onSuccess, +}: { + client: ApiClientSummary; + onSuccess: () => void; +}) { + const [open, setOpen] = useState(false); + const [deleting, setDeleting] = useState(false); + + async function handleConfirm() { + setDeleting(true); + try { + await deleteApiClient(client.id); + setOpen(false); + onSuccess(); + } finally { + setDeleting(false); + } + } + + return ( + + + + + + + Delete API Client + + This will permanently delete “{client.name}” and revoke its + OAuth identity. Any applications using this client will lose the ability + to authenticate. + + + + + + + + + + + ); +} diff --git a/web/src/app/dashboard/settings/api-keys/page.tsx b/web/src/app/dashboard/settings/api-keys/page.tsx index 7853c2a..d310b2a 100644 --- a/web/src/app/dashboard/settings/api-keys/page.tsx +++ b/web/src/app/dashboard/settings/api-keys/page.tsx @@ -46,7 +46,7 @@ const PERMISSION_CATEGORIES: Record = { Users: ["users:create", "users:read", "users:update", "users:delete"], "API Keys": ["api-keys:create", "api-keys:read", "api-keys:delete"], Backfill: ["backfill:create", "backfill:read"], - "Rate Limits": ["rate-limits:read", "rate-limits:create", "rate-limits:delete"], + "API Clients": ["api-clients:view", "api-clients:create", "api-clients:edit", "api-clients:delete"], System: ["stats:read", "events:read"], }; diff --git a/web/src/app/dashboard/settings/page.tsx b/web/src/app/dashboard/settings/page.tsx index c86cc49..1aca224 100644 --- a/web/src/app/dashboard/settings/page.tsx +++ b/web/src/app/dashboard/settings/page.tsx @@ -16,8 +16,8 @@ export default function SettingsPage() { router.replace("/dashboard/settings/env-variables"); } else if (hasPermission("api-keys:read")) { router.replace("/dashboard/settings/api-keys"); - } else if (hasPermission("rate-limits:read")) { - router.replace("/dashboard/settings/rate-limits"); + } else if (hasPermission("api-clients:view")) { + router.replace("/dashboard/settings/api-clients"); } }, [router, hasPermission]); diff --git a/web/src/app/dashboard/settings/rate-limits/page.tsx b/web/src/app/dashboard/settings/rate-limits/page.tsx deleted file mode 100644 index acf5005..0000000 --- a/web/src/app/dashboard/settings/rate-limits/page.tsx +++ /dev/null @@ -1,447 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useState } from "react"; -import { Trash2 } from "lucide-react"; - -import { useCurrentUser } from "@/hooks/use-current-user"; -import { - getRateLimits, - upsertRateLimit, - setRateLimitEnabled, - addAllowlistEntry, - removeAllowlistEntry, -} from "@/lib/api"; -import type { AllowlistEntry } from "@/types/rate-limits"; -import { SiteHeader } from "@/components/site-header"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Switch } from "@/components/ui/switch"; -import { - ResponsiveDialog, - ResponsiveDialogClose, - ResponsiveDialogContent, - ResponsiveDialogDescription, - ResponsiveDialogFooter, - ResponsiveDialogHeader, - ResponsiveDialogTitle, - ResponsiveDialogTrigger, -} from "@/components/ui/responsive-dialog"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; - -export default function RateLimitsPage() { - const { hasPermission } = useCurrentUser(); - const [enabled, setEnabled] = useState(false); - const [capacity, setCapacity] = useState(""); - const [refillRate, setRefillRate] = useState(""); - const [defaultQueryCost, setDefaultQueryCost] = useState(""); - const [defaultProcedureCost, setDefaultProcedureCost] = useState(""); - const [defaultProxyCost, setDefaultProxyCost] = useState(""); - const [allowlist, setAllowlist] = useState([]); - const [error, setError] = useState(null); - const [toggling, setToggling] = useState(false); - const [saving, setSaving] = useState(false); - - // Track original values for dirty detection - const [origCapacity, setOrigCapacity] = useState(""); - const [origRefillRate, setOrigRefillRate] = useState(""); - const [origQueryCost, setOrigQueryCost] = useState(""); - const [origProcedureCost, setOrigProcedureCost] = useState(""); - const [origProxyCost, setOrigProxyCost] = useState(""); - - const load = useCallback(() => { - getRateLimits() - .then((data) => { - setEnabled(data.enabled); - setCapacity(String(data.capacity)); - setRefillRate(String(data.refill_rate)); - setDefaultQueryCost(String(data.default_query_cost)); - setDefaultProcedureCost(String(data.default_procedure_cost)); - setDefaultProxyCost(String(data.default_proxy_cost)); - setOrigCapacity(String(data.capacity)); - setOrigRefillRate(String(data.refill_rate)); - setOrigQueryCost(String(data.default_query_cost)); - setOrigProcedureCost(String(data.default_procedure_cost)); - setOrigProxyCost(String(data.default_proxy_cost)); - setAllowlist(data.allowlist); - }) - .catch((e) => setError(e.message)); - }, []); - - useEffect(() => { - load(); - }, [load]); - - const isDirty = - capacity !== origCapacity || - refillRate !== origRefillRate || - defaultQueryCost !== origQueryCost || - defaultProcedureCost !== origProcedureCost || - defaultProxyCost !== origProxyCost; - - async function handleToggleEnabled(checked: boolean) { - setToggling(true); - try { - await setRateLimitEnabled({ enabled: checked }); - setEnabled(checked); - } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)); - } finally { - setToggling(false); - } - } - - async function handleSave() { - setError(null); - const cap = Number(capacity); - const rate = Number(refillRate); - const qc = Number(defaultQueryCost); - const pc = Number(defaultProcedureCost); - const xc = Number(defaultProxyCost); - if (!cap || cap <= 0 || !rate || rate <= 0) { - setError("Capacity and refill rate must be positive numbers."); - return; - } - if (qc < 0 || pc < 0 || xc < 0) { - setError("Default costs must be non-negative."); - return; - } - setSaving(true); - try { - await upsertRateLimit({ - capacity: cap, - refill_rate: rate, - default_query_cost: qc || 1, - default_procedure_cost: pc || 1, - default_proxy_cost: xc || 1, - }); - load(); - } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)); - } finally { - setSaving(false); - } - } - - async function handleRemoveAllowlistEntry(id: number) { - try { - await removeAllowlistEntry(id); - load(); - } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)); - } - } - - const canEdit = hasPermission("rate-limits:create"); - - return ( - <> - -
- {error &&

{error}

} - - {/* Global toggle */} -
- - -
- - {/* Global bucket + Default costs */} -
-
-

Global Bucket & Default Costs

-

- Configure the shared token bucket and default costs by request type. -

-
- -
-
- - setCapacity(e.target.value)} - disabled={!canEdit} - /> -
-
- - setRefillRate(e.target.value)} - disabled={!canEdit} - /> -
-
- - setDefaultQueryCost(e.target.value)} - disabled={!canEdit} - /> -
-
- - setDefaultProcedureCost(e.target.value)} - disabled={!canEdit} - /> -
-
- - setDefaultProxyCost(e.target.value)} - disabled={!canEdit} - /> -
-
- - {canEdit && ( -
- -
- )} -
- - {/* IP allowlist */} -
-
-
-

IP Allowlist

-

- IPs or CIDRs that bypass rate limiting. -

-
- {canEdit && ( - - )} -
- -
- - - - CIDR - Note - Created - - - - - {allowlist.length === 0 && ( - - - No allowlist entries yet. - - - )} - {allowlist.map((entry) => ( - - - {entry.cidr} - - - {entry.note ?? "\u2014"} - - - {new Date(entry.created_at).toLocaleString()} - - - {hasPermission("rate-limits:delete") && ( - handleRemoveAllowlistEntry(entry.id)} - /> - )} - - - ))} - -
-
-
-
- - ); -} - -function AddAllowlistDialog({ - onSuccess, -}: { - onSuccess: () => void; -}) { - const [cidr, setCidr] = useState(""); - const [note, setNote] = useState(""); - const [error, setError] = useState(null); - const [open, setOpen] = useState(false); - - async function handleAdd() { - setError(null); - try { - const body: { cidr: string; note?: string } = { cidr: cidr.trim() }; - if (note.trim()) body.note = note.trim(); - await addAllowlistEntry(body); - setCidr(""); - setNote(""); - setOpen(false); - onSuccess(); - } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)); - } - } - - return ( - { - setOpen(o); - if (o) { - setCidr(""); - setNote(""); - setError(null); - } - }} - > - - - - - - Add Allowlist Entry - - Add an IP or CIDR range that bypasses rate limiting. - - -
- {error &&

{error}

} -
- - setCidr(e.target.value)} - placeholder="10.0.0.0/8" - className="font-mono" - /> -
-
- - setNote(e.target.value)} - placeholder="Internal network" - /> -
-
- - - - - - -
-
- ); -} - -function DeleteConfirmDialog({ - title, - description, - onConfirm, -}: { - title: string; - description: string; - onConfirm: () => void; -}) { - const [open, setOpen] = useState(false); - const [deleting, setDeleting] = useState(false); - - async function handleConfirm() { - setDeleting(true); - try { - await onConfirm(); - setOpen(false); - } finally { - setDeleting(false); - } - } - - return ( - - - - - - - {title} - - {description} - - - - - - - - - - - ); -} diff --git a/web/src/app/dashboard/settings/users/page.tsx b/web/src/app/dashboard/settings/users/page.tsx index 119345c..84c6d45 100644 --- a/web/src/app/dashboard/settings/users/page.tsx +++ b/web/src/app/dashboard/settings/users/page.tsx @@ -55,7 +55,7 @@ const PERMISSION_CATEGORIES: Record = { Users: ["users:create", "users:read", "users:update", "users:delete"], "API Keys": ["api-keys:create", "api-keys:read", "api-keys:delete"], Backfill: ["backfill:create", "backfill:read"], - "Rate Limits": ["rate-limits:read", "rate-limits:create", "rate-limits:delete"], + "API Clients": ["api-clients:view", "api-clients:create", "api-clients:edit", "api-clients:delete"], Plugins: ["plugins:read", "plugins:create", "plugins:delete"], System: ["stats:read", "events:read"], }; diff --git a/web/src/components/app-sidebar.tsx b/web/src/components/app-sidebar.tsx index 230ed23..b4d42f5 100644 --- a/web/src/components/app-sidebar.tsx +++ b/web/src/components/app-sidebar.tsx @@ -13,11 +13,11 @@ import { IconVariable, IconTag, IconChevronRight, - IconShield, IconLink, IconPuzzle, IconLockAccess, IconInfoCircle, + IconApps, } from "@tabler/icons-react" import Image from "next/image" import Link from "next/link" @@ -59,8 +59,8 @@ const settingsSubItems = [ { title: "Plugins", url: "/dashboard/settings/plugins", icon: IconPuzzle, requiredPermissions: ["plugins:read"] }, { title: "ENV Variables", url: "/dashboard/settings/env-variables", icon: IconVariable, requiredPermissions: ["script-variables:read"] }, { title: "API Keys", url: "/dashboard/settings/api-keys", icon: IconKey, requiredPermissions: ["api-keys:read"] }, + { title: "API Clients", url: "/dashboard/settings/api-clients", icon: IconApps, requiredPermissions: ["api-clients:view"] }, { title: "Labelers", url: "/dashboard/settings/labelers", icon: IconTag, requiredPermissions: ["labelers:read"] }, - { title: "Rate Limits", url: "/dashboard/settings/rate-limits", icon: IconShield, requiredPermissions: ["rate-limits:read"] }, { title: "OAuth", url: "/dashboard/settings/oauth", icon: IconLockAccess, requiredPermissions: ["settings:manage"] }, ] as const diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index c858a5c..667764f 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -9,7 +9,7 @@ import type { AdminListRecordsResponse } from "@/types/records" import type { EventsListResponse } from "@/types/events" import type { ScriptVariableSummary } from "@/types/script-variables" import type { LabelerSummary } from "@/types/labelers" -import type { RateLimitsResponse } from "@/types/rate-limits" +import type { ApiClientSummary, CreateApiClientResponse } from "@/types/api-clients" import type { SettingEntry } from "@/types/settings" import type { ExternalProvider, @@ -32,7 +32,7 @@ export type { EventLogEntry, EventsListResponse } from "@/types/events" export type { ScriptVariableSummary } from "@/types/script-variables" export type { LabelerSummary } from "@/types/labelers" export type { RecordLabel } from "@/types/records" -export type { AllowlistEntry, RateLimitsResponse } from "@/types/rate-limits" +export type { ApiClientSummary, CreateApiClientResponse } from "@/types/api-clients" export type { SettingEntry, OAuthSettings } from "@/types/settings" export { OAUTH_SETTING_KEYS } from "@/types/settings" export type { @@ -364,48 +364,52 @@ export function deleteLabeler( }) } -// Rate Limits -export function getRateLimits() { - return apiFetch("/admin/rate-limits") +// API Clients +export function getApiClients() { + return apiFetch("/admin/api-clients") } -export function upsertRateLimit( +export function getApiClient(id: string) { + return apiFetch(`/admin/api-clients/${encodeURIComponent(id)}`) +} + +export function createApiClient( body: { - capacity: number - refill_rate: number - default_query_cost: number - default_procedure_cost: number - default_proxy_cost: number + name: string + client_id_url: string + client_uri: string + redirect_uris: string[] + scopes?: string + rate_limit_capacity: number + rate_limit_refill_rate: number } ) { - return apiFetch("/admin/rate-limits", { + return apiFetch("/admin/api-clients", { method: "POST", body: JSON.stringify(body), }) } -export function setRateLimitEnabled( - body: { enabled: boolean } +export function updateApiClient( + id: string, + body: { + name?: string + client_uri?: string + redirect_uris?: string[] + scopes?: string + rate_limit_capacity?: number + rate_limit_refill_rate?: number + is_active?: boolean + } ) { - return apiFetch("/admin/rate-limits/enabled", { + return apiFetch(`/admin/api-clients/${encodeURIComponent(id)}`, { method: "PUT", body: JSON.stringify(body), }) } -export function addAllowlistEntry( - body: { cidr: string; note?: string } -) { - return apiFetch("/admin/rate-limits/allowlist", { - method: "POST", - body: JSON.stringify(body), - }) -} - -export function removeAllowlistEntry( - id: number -) { - return apiFetch(`/admin/rate-limits/allowlist/${encodeURIComponent(id)}`, { +export function deleteApiClient(id: string) { + return apiFetch(`/admin/api-clients/${encodeURIComponent(id)}`, { method: "DELETE", }) } diff --git a/web/src/lib/config-context.tsx b/web/src/lib/config-context.tsx index 67bf214..8251d22 100644 --- a/web/src/lib/config-context.tsx +++ b/web/src/lib/config-context.tsx @@ -4,9 +4,15 @@ import { createContext, useContext, useEffect, useState } from "react" interface ConfigContextType { public_url: string + default_rate_limit_capacity: number + default_rate_limit_refill_rate: number } -const ConfigContext = createContext({ public_url: "" }) +const ConfigContext = createContext({ + public_url: "", + default_rate_limit_capacity: 100, + default_rate_limit_refill_rate: 2.0, +}) export function ConfigProvider({ children }: { children: React.ReactNode }) { const [config, setConfig] = useState(null) @@ -19,7 +25,11 @@ export function ConfigProvider({ children }: { children: React.ReactNode }) { return res.json() }) .then((data) => { - setConfig({ public_url: data.public_url }) + setConfig({ + public_url: data.public_url, + default_rate_limit_capacity: data.default_rate_limit_capacity, + default_rate_limit_refill_rate: data.default_rate_limit_refill_rate, + }) }) .catch((e) => setError(e.message)) }, []) diff --git a/web/src/types/api-clients.ts b/web/src/types/api-clients.ts new file mode 100644 index 0000000..7057c4b --- /dev/null +++ b/web/src/types/api-clients.ts @@ -0,0 +1,23 @@ +export interface ApiClientSummary { + id: string + client_key: string + name: string + client_id_url: string + client_uri: string + redirect_uris: string[] + scopes: string + rate_limit_capacity: number | null + rate_limit_refill_rate: number | null + is_active: boolean + created_by: string + created_at: string + updated_at: string +} + +export interface CreateApiClientResponse { + id: string + client_key: string + client_secret: string + name: string + client_id_url: string +} \ No newline at end of file diff --git a/web/src/types/rate-limits.ts b/web/src/types/rate-limits.ts deleted file mode 100644 index 93d9d14..0000000 --- a/web/src/types/rate-limits.ts +++ /dev/null @@ -1,16 +0,0 @@ -export interface AllowlistEntry { - id: number - cidr: string - note: string | null - created_at: string -} - -export interface RateLimitsResponse { - enabled: boolean - capacity: number - refill_rate: number - default_query_cost: number - default_procedure_cost: number - default_proxy_cost: number - allowlist: AllowlistEntry[] -} -- 2.51.2