Highly ambitious ATProtocol AppView service and sdks

oauth client management almost there, still some tidying up to do

+13
api/migrations/010_oauth_clients.sql
··· 1 + -- Create oauth_clients table 2 + CREATE TABLE oauth_clients ( 3 + id SERIAL PRIMARY KEY, 4 + slice_uri TEXT NOT NULL, 5 + client_id TEXT UNIQUE NOT NULL, 6 + registration_access_token TEXT, 7 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 8 + created_by_did TEXT NOT NULL 9 + ); 10 + 11 + -- Create indexes for efficient lookups 12 + CREATE INDEX idx_oauth_clients_slice_uri ON oauth_clients (slice_uri); 13 + CREATE INDEX idx_oauth_clients_client_id ON oauth_clients (client_id);
+112
api/scripts/generate_typescript.ts
··· 666 666 }, 667 667 ], 668 668 }); 669 + 670 + // OAuth client interfaces 671 + sourceFile.addInterface({ 672 + name: "CreateOAuthClientRequest", 673 + isExported: true, 674 + properties: [ 675 + { name: "clientName", type: "string" }, 676 + { name: "redirectUris", type: "string[]" }, 677 + { name: "grantTypes", type: "string[]", hasQuestionToken: true }, 678 + { name: "responseTypes", type: "string[]", hasQuestionToken: true }, 679 + { name: "scope", type: "string", hasQuestionToken: true }, 680 + { name: "clientUri", type: "string", hasQuestionToken: true }, 681 + { name: "logoUri", type: "string", hasQuestionToken: true }, 682 + { name: "tosUri", type: "string", hasQuestionToken: true }, 683 + { name: "policyUri", type: "string", hasQuestionToken: true }, 684 + ], 685 + }); 686 + 687 + sourceFile.addInterface({ 688 + name: "OAuthClientDetails", 689 + isExported: true, 690 + properties: [ 691 + { name: "clientId", type: "string" }, 692 + { name: "clientSecret", type: "string", hasQuestionToken: true }, 693 + { name: "clientName", type: "string" }, 694 + { name: "redirectUris", type: "string[]" }, 695 + { name: "grantTypes", type: "string[]" }, 696 + { name: "responseTypes", type: "string[]" }, 697 + { name: "scope", type: "string", hasQuestionToken: true }, 698 + { name: "clientUri", type: "string", hasQuestionToken: true }, 699 + { name: "logoUri", type: "string", hasQuestionToken: true }, 700 + { name: "tosUri", type: "string", hasQuestionToken: true }, 701 + { name: "policyUri", type: "string", hasQuestionToken: true }, 702 + { name: "createdAt", type: "string" }, 703 + { name: "createdByDid", type: "string" }, 704 + ], 705 + }); 706 + 707 + sourceFile.addInterface({ 708 + name: "ListOAuthClientsResponse", 709 + isExported: true, 710 + properties: [ 711 + { name: "clients", type: "OAuthClientDetails[]" }, 712 + ], 713 + }); 714 + 715 + sourceFile.addInterface({ 716 + name: "UpdateOAuthClientRequest", 717 + isExported: true, 718 + properties: [ 719 + { name: "clientId", type: "string" }, 720 + { name: "clientName", type: "string", hasQuestionToken: true }, 721 + { name: "redirectUris", type: "string[]", hasQuestionToken: true }, 722 + { name: "scope", type: "string", hasQuestionToken: true }, 723 + { name: "clientUri", type: "string", hasQuestionToken: true }, 724 + { name: "logoUri", type: "string", hasQuestionToken: true }, 725 + { name: "tosUri", type: "string", hasQuestionToken: true }, 726 + { name: "policyUri", type: "string", hasQuestionToken: true }, 727 + ], 728 + }); 729 + 730 + sourceFile.addInterface({ 731 + name: "DeleteOAuthClientResponse", 732 + isExported: true, 733 + properties: [ 734 + { name: "success", type: "boolean" }, 735 + { name: "message", type: "string" }, 736 + ], 737 + }); 669 738 } 670 739 671 740 // Convert lexicon type to TypeScript type ··· 1702 1771 statements: [ 1703 1772 `const requestParams = { slice: this.sliceUri, ...params };`, 1704 1773 `return await this.makeRequest<SyncUserCollectionsResult>('social.slices.slice.syncUserCollections', 'POST', requestParams);`, 1774 + ], 1775 + }); 1776 + 1777 + // Add OAuth client management methods 1778 + classDeclaration.addMethod({ 1779 + name: "createOAuthClient", 1780 + parameters: [{ name: "params", type: "CreateOAuthClientRequest" }], 1781 + returnType: "Promise<OAuthClientDetails>", 1782 + isAsync: true, 1783 + statements: [ 1784 + `const requestParams = { ...params, sliceUri: this.sliceUri };`, 1785 + `return await this.makeRequest<OAuthClientDetails>('social.slices.slice.createOAuthClient', 'POST', requestParams);`, 1786 + ], 1787 + }); 1788 + 1789 + classDeclaration.addMethod({ 1790 + name: "getOAuthClients", 1791 + returnType: "Promise<ListOAuthClientsResponse>", 1792 + isAsync: true, 1793 + statements: [ 1794 + `const requestParams = { slice: this.sliceUri };`, 1795 + `return await this.makeRequest<ListOAuthClientsResponse>('social.slices.slice.getOAuthClients', 'GET', requestParams);`, 1796 + ], 1797 + }); 1798 + 1799 + classDeclaration.addMethod({ 1800 + name: "updateOAuthClient", 1801 + parameters: [{ name: "params", type: "UpdateOAuthClientRequest" }], 1802 + returnType: "Promise<OAuthClientDetails>", 1803 + isAsync: true, 1804 + statements: [ 1805 + `const requestParams = { ...params, sliceUri: this.sliceUri };`, 1806 + `return await this.makeRequest<OAuthClientDetails>('social.slices.slice.updateOAuthClient', 'POST', requestParams);`, 1807 + ], 1808 + }); 1809 + 1810 + classDeclaration.addMethod({ 1811 + name: "deleteOAuthClient", 1812 + parameters: [{ name: "clientId", type: "string" }], 1813 + returnType: "Promise<DeleteOAuthClientResponse>", 1814 + isAsync: true, 1815 + statements: [ 1816 + `return await this.makeRequest<DeleteOAuthClientResponse>('social.slices.slice.deleteOAuthClient', 'POST', { clientId });`, 1705 1817 ], 1706 1818 }); 1707 1819 }
+77 -1
api/src/database.rs
··· 2 2 use base64::{Engine as _, engine::general_purpose}; 3 3 4 4 use crate::errors::DatabaseError; 5 - use crate::models::{Actor, CollectionStats, IndexedRecord, Record, WhereCondition, WhereClause, SortField}; 5 + use crate::models::{Actor, CollectionStats, IndexedRecord, Record, WhereCondition, WhereClause, SortField, OAuthClient}; 6 6 use std::collections::HashMap; 7 7 8 8 ··· 1072 1072 .await?; 1073 1073 1074 1074 Ok(row.and_then(|r| r.domain)) 1075 + } 1076 + 1077 + pub async fn create_oauth_client( 1078 + &self, 1079 + slice_uri: &str, 1080 + client_id: &str, 1081 + registration_access_token: Option<&str>, 1082 + created_by_did: &str, 1083 + ) -> Result<OAuthClient, DatabaseError> { 1084 + let client = sqlx::query_as!( 1085 + OAuthClient, 1086 + r#" 1087 + INSERT INTO oauth_clients (slice_uri, client_id, registration_access_token, created_by_did) 1088 + VALUES ($1, $2, $3, $4) 1089 + RETURNING id, slice_uri, client_id, registration_access_token, created_at as "created_at!", created_by_did 1090 + "#, 1091 + slice_uri, 1092 + client_id, 1093 + registration_access_token, 1094 + created_by_did 1095 + ) 1096 + .fetch_one(&self.pool) 1097 + .await?; 1098 + 1099 + Ok(client) 1100 + } 1101 + 1102 + pub async fn get_oauth_clients_for_slice(&self, slice_uri: &str) -> Result<Vec<OAuthClient>, DatabaseError> { 1103 + let clients = sqlx::query_as!( 1104 + OAuthClient, 1105 + r#" 1106 + SELECT id, slice_uri, client_id, registration_access_token, created_at as "created_at!", created_by_did 1107 + FROM oauth_clients 1108 + WHERE slice_uri = $1 1109 + ORDER BY created_at DESC 1110 + "#, 1111 + slice_uri 1112 + ) 1113 + .fetch_all(&self.pool) 1114 + .await?; 1115 + 1116 + Ok(clients) 1117 + } 1118 + 1119 + pub async fn get_oauth_client_by_id(&self, client_id: &str) -> Result<Option<OAuthClient>, DatabaseError> { 1120 + let client = sqlx::query_as!( 1121 + OAuthClient, 1122 + r#" 1123 + SELECT id, slice_uri, client_id, registration_access_token, created_at as "created_at!", created_by_did 1124 + FROM oauth_clients 1125 + WHERE client_id = $1 1126 + "#, 1127 + client_id 1128 + ) 1129 + .fetch_optional(&self.pool) 1130 + .await?; 1131 + 1132 + Ok(client) 1133 + } 1134 + 1135 + pub async fn delete_oauth_client(&self, client_id: &str) -> Result<(), DatabaseError> { 1136 + let result = sqlx::query!( 1137 + r#" 1138 + DELETE FROM oauth_clients 1139 + WHERE client_id = $1 1140 + "#, 1141 + client_id 1142 + ) 1143 + .execute(&self.pool) 1144 + .await?; 1145 + 1146 + if result.rows_affected() == 0 { 1147 + return Err(DatabaseError::RecordNotFound { uri: client_id.to_string() }); 1148 + } 1149 + 1150 + Ok(()) 1075 1151 } 1076 1152 1077 1153 }
+33
api/src/errors.rs
··· 1 1 use thiserror::Error; 2 + use axum::{ 3 + http::StatusCode, 4 + response::{IntoResponse, Response}, 5 + Json, 6 + }; 2 7 3 8 4 9 #[derive(Error, Debug)] ··· 44 49 45 50 #[error("error-slice-app-3 Server bind failed: {0}")] 46 51 ServerBind(#[from] std::io::Error), 52 + 53 + #[error("error-slice-app-4 Internal server error: {0}")] 54 + Internal(String), 55 + 56 + #[error("error-slice-app-5 Resource not found: {0}")] 57 + NotFound(String), 58 + 59 + #[error("error-slice-app-6 Bad request: {0}")] 60 + BadRequest(String), 47 61 } 48 62 49 63 #[derive(Error, Debug)] ··· 56 70 57 71 } 58 72 73 + impl IntoResponse for AppError { 74 + fn into_response(self) -> Response { 75 + let (status, error_message) = match self { 76 + AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg), 77 + AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg), 78 + AppError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg), 79 + AppError::DatabaseConnection(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), 80 + AppError::Migration(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), 81 + AppError::ServerBind(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), 82 + }; 83 + 84 + let body = Json(serde_json::json!({ 85 + "error": error_message 86 + })); 87 + 88 + (status, body).into_response() 89 + } 90 + } 91 +
+488
api/src/handler_oauth_clients.rs
··· 1 + use axum::{ 2 + extract::{Query, State}, 3 + http::HeaderMap, 4 + response::Json, 5 + Json as ExtractJson, 6 + }; 7 + use serde::{Deserialize, Serialize}; 8 + use reqwest::Client; 9 + 10 + use crate::{ 11 + AppState, 12 + auth, 13 + errors::AppError, 14 + models::{ 15 + CreateOAuthClientRequest, OAuthClientDetails, ListOAuthClientsResponse, 16 + UpdateOAuthClientRequest, DeleteOAuthClientResponse 17 + }, 18 + }; 19 + 20 + #[derive(Deserialize)] 21 + #[serde(rename_all = "camelCase")] 22 + pub struct GetOAuthClientsQuery { 23 + pub slice: String, 24 + } 25 + 26 + #[derive(Deserialize)] 27 + #[serde(rename_all = "camelCase")] 28 + pub struct DeleteOAuthClientRequest { 29 + pub client_id: String, 30 + } 31 + 32 + #[derive(Debug, Serialize, Deserialize)] 33 + #[serde(rename_all = "snake_case")] 34 + struct AipClientRegistrationRequest { 35 + pub client_name: String, 36 + pub redirect_uris: Vec<String>, 37 + #[serde(skip_serializing_if = "Option::is_none")] 38 + pub grant_types: Option<Vec<String>>, 39 + #[serde(skip_serializing_if = "Option::is_none")] 40 + pub response_types: Option<Vec<String>>, 41 + #[serde(skip_serializing_if = "Option::is_none")] 42 + pub scope: Option<String>, 43 + #[serde(skip_serializing_if = "Option::is_none")] 44 + pub client_uri: Option<String>, 45 + #[serde(skip_serializing_if = "Option::is_none")] 46 + pub logo_uri: Option<String>, 47 + #[serde(skip_serializing_if = "Option::is_none")] 48 + pub tos_uri: Option<String>, 49 + #[serde(skip_serializing_if = "Option::is_none")] 50 + pub policy_uri: Option<String>, 51 + } 52 + 53 + #[derive(Serialize, Deserialize)] 54 + #[serde(rename_all = "snake_case")] 55 + struct AipClientRegistrationResponse { 56 + pub client_id: String, 57 + #[serde(skip_serializing_if = "Option::is_none")] 58 + pub client_secret: Option<String>, 59 + #[serde(skip_serializing_if = "Option::is_none")] 60 + pub registration_access_token: Option<String>, 61 + pub client_name: String, 62 + pub redirect_uris: Vec<String>, 63 + pub grant_types: Vec<String>, 64 + pub response_types: Vec<String>, 65 + #[serde(skip_serializing_if = "Option::is_none")] 66 + pub scope: Option<String>, 67 + #[serde(skip_serializing_if = "Option::is_none")] 68 + pub client_uri: Option<String>, 69 + #[serde(skip_serializing_if = "Option::is_none")] 70 + pub logo_uri: Option<String>, 71 + #[serde(skip_serializing_if = "Option::is_none")] 72 + pub tos_uri: Option<String>, 73 + #[serde(skip_serializing_if = "Option::is_none")] 74 + pub policy_uri: Option<String>, 75 + // Additional fields that AIP returns but we don't need to send 76 + #[serde(skip_serializing_if = "Option::is_none")] 77 + pub token_endpoint_auth_method: Option<String>, 78 + #[serde(skip_serializing_if = "Option::is_none")] 79 + pub registration_client_uri: Option<String>, 80 + #[serde(skip_serializing_if = "Option::is_none")] 81 + pub client_id_issued_at: Option<i64>, 82 + #[serde(skip_serializing_if = "Option::is_none")] 83 + pub client_secret_expires_at: Option<i64>, 84 + } 85 + 86 + pub async fn create_oauth_client( 87 + State(state): State<AppState>, 88 + headers: HeaderMap, 89 + ExtractJson(request): ExtractJson<CreateOAuthClientRequest>, 90 + ) -> Result<Json<OAuthClientDetails>, AppError> { 91 + // Debug log the incoming request 92 + tracing::debug!("Incoming OAuth client registration request: {:?}", request); 93 + 94 + // Validate request 95 + if request.client_name.trim().is_empty() { 96 + return Err(AppError::BadRequest("Client name cannot be empty".to_string())); 97 + } 98 + 99 + if request.redirect_uris.is_empty() { 100 + return Err(AppError::BadRequest("At least one redirect URI is required".to_string())); 101 + } 102 + 103 + // Validate redirect URIs have basic URL format 104 + for uri in &request.redirect_uris { 105 + if !uri.starts_with("http://") && !uri.starts_with("https://") { 106 + return Err(AppError::BadRequest(format!("Redirect URI must use HTTP or HTTPS: {}", uri))); 107 + } 108 + if uri.trim().is_empty() { 109 + return Err(AppError::BadRequest("Redirect URI cannot be empty".to_string())); 110 + } 111 + } 112 + 113 + // Extract and verify authentication 114 + let token = auth::extract_bearer_token(&headers) 115 + .map_err(|_| AppError::BadRequest("Missing or invalid Authorization header".to_string()))?; 116 + let user_info = auth::verify_oauth_token(&token, &state.config.auth_base_url).await 117 + .map_err(|_| AppError::BadRequest("Invalid or expired access token".to_string()))?; 118 + 119 + let user_did = user_info.sub; 120 + 121 + // Register client with AIP 122 + let aip_base_url = &state.config.auth_base_url; 123 + 124 + let client = Client::new(); 125 + let aip_request = AipClientRegistrationRequest { 126 + client_name: request.client_name.clone(), 127 + redirect_uris: request.redirect_uris.clone(), 128 + grant_types: request.grant_types.clone(), 129 + response_types: request.response_types.clone(), 130 + scope: request.scope.clone(), 131 + client_uri: request.client_uri.clone(), 132 + logo_uri: request.logo_uri.clone(), 133 + tos_uri: request.tos_uri.clone(), 134 + policy_uri: request.policy_uri.clone(), 135 + }; 136 + 137 + let registration_url = format!("{}/oauth/clients/register", aip_base_url); 138 + tracing::debug!("Attempting to register OAuth client at: {}", registration_url); 139 + tracing::debug!("Sending AIP request: {:?}", aip_request); 140 + 141 + let aip_response = client 142 + .post(&registration_url) 143 + .json(&aip_request) 144 + .send() 145 + .await 146 + .map_err(|e| AppError::Internal(format!("Failed to register client with AIP: {}", e)))?; 147 + 148 + let status = aip_response.status(); 149 + tracing::debug!("AIP registration response status: {}", status); 150 + 151 + if !status.is_success() { 152 + let error_text = aip_response.text().await.unwrap_or_else(|_| "Unknown error".to_string()); 153 + tracing::error!("AIP registration failed with status {}: {}", status, error_text); 154 + return Err(AppError::Internal(format!("AIP registration failed with status {}: {}", status, error_text))); 155 + } 156 + 157 + tracing::debug!("Parsing AIP response JSON..."); 158 + 159 + // Get the response body as text first to debug what we're receiving 160 + let response_body = aip_response 161 + .text() 162 + .await 163 + .map_err(|e| AppError::Internal(format!("Failed to get response body: {}", e)))?; 164 + 165 + tracing::debug!("AIP response body: {}", response_body); 166 + 167 + // Try to parse the JSON from the text 168 + let aip_client: AipClientRegistrationResponse = serde_json::from_str(&response_body) 169 + .map_err(|e| { 170 + tracing::error!("Failed to parse AIP response JSON: {}", e); 171 + tracing::error!("Raw response was: {}", response_body); 172 + AppError::Internal(format!("Failed to parse AIP response: {}", e)) 173 + })?; 174 + 175 + tracing::debug!("Successfully parsed AIP response, client_id: {}", aip_client.client_id); 176 + 177 + // Store the client info in our database 178 + tracing::debug!("Storing OAuth client in database..."); 179 + let oauth_client = state.database 180 + .create_oauth_client( 181 + &request.slice_uri, 182 + &aip_client.client_id, 183 + aip_client.registration_access_token.as_deref(), 184 + &user_did, 185 + ) 186 + .await 187 + .map_err(|e| { 188 + tracing::error!("Failed to store OAuth client in database: {}", e); 189 + AppError::Internal(format!("Failed to store OAuth client: {}", e)) 190 + })?; 191 + 192 + tracing::debug!("Successfully stored OAuth client in database"); 193 + 194 + // Return the full client details from AIP 195 + let response = OAuthClientDetails { 196 + client_id: aip_client.client_id, 197 + client_secret: aip_client.client_secret, 198 + client_name: aip_client.client_name, 199 + redirect_uris: aip_client.redirect_uris, 200 + grant_types: aip_client.grant_types, 201 + response_types: aip_client.response_types, 202 + scope: aip_client.scope, 203 + client_uri: aip_client.client_uri, 204 + logo_uri: aip_client.logo_uri, 205 + tos_uri: aip_client.tos_uri, 206 + policy_uri: aip_client.policy_uri, 207 + created_at: oauth_client.created_at, 208 + created_by_did: oauth_client.created_by_did, 209 + }; 210 + 211 + Ok(Json(response)) 212 + } 213 + 214 + pub async fn get_oauth_clients( 215 + State(state): State<AppState>, 216 + headers: HeaderMap, 217 + Query(params): Query<GetOAuthClientsQuery>, 218 + ) -> Result<Json<ListOAuthClientsResponse>, AppError> { 219 + tracing::debug!("get_oauth_clients called with slice parameter: {}", params.slice); 220 + 221 + // Log all headers for debugging 222 + tracing::debug!("Request headers: {:?}", headers); 223 + 224 + // Extract and verify authentication 225 + let token = auth::extract_bearer_token(&headers) 226 + .map_err(|e| { 227 + tracing::error!("Failed to extract bearer token: {:?}", e); 228 + AppError::BadRequest("Missing or invalid Authorization header".to_string()) 229 + })?; 230 + 231 + tracing::debug!("Extracted bearer token (first 20 chars): {}...", 232 + if token.len() > 20 { &token[..20] } else { &token }); 233 + 234 + auth::verify_oauth_token(&token, &state.config.auth_base_url).await 235 + .map_err(|e| { 236 + tracing::error!("OAuth token verification failed: {:?}", e); 237 + AppError::BadRequest("Invalid or expired access token".to_string()) 238 + })?; 239 + 240 + tracing::debug!("OAuth token verification successful"); 241 + 242 + // Get clients from our database 243 + let clients = state.database 244 + .get_oauth_clients_for_slice(&params.slice) 245 + .await 246 + .map_err(|e| AppError::Internal(format!("Failed to fetch OAuth clients: {}", e)))?; 247 + 248 + tracing::debug!("Found {} OAuth clients in database for slice: {}", clients.len(), params.slice); 249 + 250 + if clients.is_empty() { 251 + return Ok(Json(ListOAuthClientsResponse { clients: vec![] })); 252 + } 253 + 254 + // Fetch detailed info from AIP for each client 255 + let aip_base_url = &state.config.auth_base_url; 256 + let client = Client::new(); 257 + let mut client_details = Vec::new(); 258 + 259 + for oauth_client in clients { 260 + // Fetch client details from AIP 261 + let aip_url = format!("{}/oauth/clients/{}", aip_base_url, oauth_client.client_id); 262 + tracing::debug!("Fetching client details from AIP: {}", aip_url); 263 + 264 + // Use registration access token if available for authentication 265 + let mut request_builder = client.get(&aip_url); 266 + if let Some(token) = &oauth_client.registration_access_token { 267 + request_builder = request_builder.bearer_auth(token); 268 + tracing::debug!("Using registration access token for authentication"); 269 + } else { 270 + tracing::debug!("No registration access token available"); 271 + } 272 + 273 + let aip_response = request_builder.send().await; 274 + 275 + match aip_response { 276 + Ok(response) => { 277 + let status = response.status(); 278 + tracing::debug!("AIP response status for {}: {}", oauth_client.client_id, status); 279 + 280 + if status.is_success() { 281 + // Get the response body as text first to log it 282 + match response.text().await { 283 + Ok(response_text) => { 284 + tracing::debug!("AIP response body for {}: {}", oauth_client.client_id, response_text); 285 + 286 + // Try to parse the JSON 287 + match serde_json::from_str::<AipClientRegistrationResponse>(&response_text) { 288 + Ok(aip_client) => { 289 + tracing::debug!("Successfully parsed AIP client details for {}", oauth_client.client_id); 290 + client_details.push(OAuthClientDetails { 291 + client_id: aip_client.client_id, 292 + client_secret: aip_client.client_secret, 293 + client_name: aip_client.client_name, 294 + redirect_uris: aip_client.redirect_uris, 295 + grant_types: aip_client.grant_types, 296 + response_types: aip_client.response_types, 297 + scope: aip_client.scope, 298 + client_uri: aip_client.client_uri, 299 + logo_uri: aip_client.logo_uri, 300 + tos_uri: aip_client.tos_uri, 301 + policy_uri: aip_client.policy_uri, 302 + created_at: oauth_client.created_at, 303 + created_by_did: oauth_client.created_by_did, 304 + }); 305 + } 306 + Err(parse_error) => { 307 + tracing::error!("Failed to parse AIP client JSON for {}: {}", oauth_client.client_id, parse_error); 308 + } 309 + } 310 + } 311 + Err(text_error) => { 312 + tracing::error!("Failed to get AIP response text for {}: {}", oauth_client.client_id, text_error); 313 + } 314 + } 315 + } else { 316 + // Handle non-success status codes 317 + match response.text().await { 318 + Ok(error_text) => { 319 + tracing::error!("AIP client fetch failed with status {} for {}: {}", status, oauth_client.client_id, error_text); 320 + } 321 + Err(_) => { 322 + tracing::error!("AIP client fetch failed with status {} for {}", status, oauth_client.client_id); 323 + } 324 + } 325 + } 326 + } 327 + Err(e) => { 328 + tracing::error!("AIP client fetch error for {}: {}", oauth_client.client_id, e); 329 + // If we can't fetch from AIP, create a minimal response 330 + client_details.push(OAuthClientDetails { 331 + client_id: oauth_client.client_id.clone(), 332 + client_secret: None, 333 + client_name: "Unknown".to_string(), 334 + redirect_uris: vec![], 335 + grant_types: vec!["authorization_code".to_string()], 336 + response_types: vec!["code".to_string()], 337 + scope: None, 338 + client_uri: None, 339 + logo_uri: None, 340 + tos_uri: None, 341 + policy_uri: None, 342 + created_at: oauth_client.created_at, 343 + created_by_did: oauth_client.created_by_did, 344 + }); 345 + } 346 + } 347 + } 348 + 349 + Ok(Json(ListOAuthClientsResponse { clients: client_details })) 350 + } 351 + 352 + pub async fn update_oauth_client( 353 + State(state): State<AppState>, 354 + headers: HeaderMap, 355 + ExtractJson(request): ExtractJson<UpdateOAuthClientRequest>, 356 + ) -> Result<Json<OAuthClientDetails>, AppError> { 357 + let client_id = request.client_id.clone(); 358 + 359 + // Extract and verify authentication 360 + let token = auth::extract_bearer_token(&headers) 361 + .map_err(|_| AppError::BadRequest("Missing or invalid Authorization header".to_string()))?; 362 + auth::verify_oauth_token(&token, &state.config.auth_base_url).await 363 + .map_err(|_| AppError::BadRequest("Invalid or expired access token".to_string()))?; 364 + 365 + // Get the client from our database to get the registration access token 366 + let oauth_client = state.database 367 + .get_oauth_client_by_id(&client_id) 368 + .await 369 + .map_err(|e| AppError::Internal(format!("Failed to fetch OAuth client: {}", e)))? 370 + .ok_or_else(|| AppError::NotFound("OAuth client not found".to_string()))?; 371 + 372 + let registration_token = oauth_client.registration_access_token 373 + .ok_or_else(|| AppError::Internal("Client missing registration access token".to_string()))?; 374 + 375 + // Build AIP update request 376 + let aip_request = AipClientRegistrationRequest { 377 + client_name: request.client_name.unwrap_or_default(), 378 + redirect_uris: request.redirect_uris.unwrap_or_default(), 379 + grant_types: None, // Keep existing 380 + response_types: None, // Keep existing 381 + scope: request.scope, 382 + client_uri: request.client_uri, 383 + logo_uri: request.logo_uri, 384 + tos_uri: request.tos_uri, 385 + policy_uri: request.policy_uri, 386 + }; 387 + 388 + let aip_base_url = &state.config.auth_base_url; 389 + let client = Client::new(); 390 + let update_url = format!("{}/oauth/clients/{}", aip_base_url, client_id); 391 + 392 + tracing::debug!("Updating OAuth client at: {}", update_url); 393 + tracing::debug!("Sending AIP update request: {:?}", aip_request); 394 + 395 + let aip_response = client 396 + .put(&update_url) 397 + .bearer_auth(&registration_token) 398 + .json(&aip_request) 399 + .send() 400 + .await 401 + .map_err(|e| AppError::Internal(format!("Failed to update client with AIP: {}", e)))?; 402 + 403 + let status = aip_response.status(); 404 + tracing::debug!("AIP update response status: {}", status); 405 + 406 + if !status.is_success() { 407 + let error_text = aip_response.text().await.unwrap_or_else(|_| "Unknown error".to_string()); 408 + tracing::error!("AIP update failed with status {}: {}", status, error_text); 409 + return Err(AppError::Internal(format!("AIP update failed with status {}: {}", status, error_text))); 410 + } 411 + 412 + // Parse the response 413 + let response_body = aip_response 414 + .text() 415 + .await 416 + .map_err(|e| AppError::Internal(format!("Failed to get response body: {}", e)))?; 417 + 418 + tracing::debug!("AIP update response body: {}", response_body); 419 + 420 + let aip_client: AipClientRegistrationResponse = serde_json::from_str(&response_body) 421 + .map_err(|e| { 422 + tracing::error!("Failed to parse AIP response JSON: {}", e); 423 + AppError::Internal(format!("Failed to parse AIP response: {}", e)) 424 + })?; 425 + 426 + // Return the updated client details 427 + let response = OAuthClientDetails { 428 + client_id: aip_client.client_id, 429 + client_secret: aip_client.client_secret, 430 + client_name: aip_client.client_name, 431 + redirect_uris: aip_client.redirect_uris, 432 + grant_types: aip_client.grant_types, 433 + response_types: aip_client.response_types, 434 + scope: aip_client.scope, 435 + client_uri: aip_client.client_uri, 436 + logo_uri: aip_client.logo_uri, 437 + tos_uri: aip_client.tos_uri, 438 + policy_uri: aip_client.policy_uri, 439 + created_at: oauth_client.created_at, 440 + created_by_did: oauth_client.created_by_did, 441 + }; 442 + 443 + Ok(Json(response)) 444 + } 445 + 446 + pub async fn delete_oauth_client( 447 + State(state): State<AppState>, 448 + headers: HeaderMap, 449 + ExtractJson(request): ExtractJson<DeleteOAuthClientRequest>, 450 + ) -> Result<Json<DeleteOAuthClientResponse>, AppError> { 451 + let client_id = request.client_id; 452 + // Extract and verify authentication 453 + let token = auth::extract_bearer_token(&headers) 454 + .map_err(|_| AppError::BadRequest("Missing or invalid Authorization header".to_string()))?; 455 + auth::verify_oauth_token(&token, &state.config.auth_base_url).await 456 + .map_err(|_| AppError::BadRequest("Invalid or expired access token".to_string()))?; 457 + 458 + // Get the client from our database first 459 + let oauth_client = state.database 460 + .get_oauth_client_by_id(&client_id) 461 + .await 462 + .map_err(|e| AppError::Internal(format!("Failed to fetch OAuth client: {}", e)))? 463 + .ok_or_else(|| AppError::NotFound("OAuth client not found".to_string()))?; 464 + 465 + // Delete from AIP if we have a registration access token 466 + if let Some(registration_token) = &oauth_client.registration_access_token { 467 + let aip_base_url = &state.config.auth_base_url; 468 + 469 + let client = Client::new(); 470 + let _aip_response = client 471 + .delete(&format!("{}/oauth/clients/{}", aip_base_url, client_id)) 472 + .bearer_auth(registration_token) 473 + .send() 474 + .await; 475 + // We continue even if AIP deletion fails, as we want to clean up our database 476 + } 477 + 478 + // Delete from our database 479 + state.database 480 + .delete_oauth_client(&client_id) 481 + .await 482 + .map_err(|e| AppError::Internal(format!("Failed to delete OAuth client: {}", e)))?; 483 + 484 + Ok(Json(DeleteOAuthClientResponse { 485 + success: true, 486 + message: format!("OAuth client {} deleted successfully", client_id), 487 + })) 488 + }
+18
api/src/main.rs
··· 8 8 mod handler_jetstream_status; 9 9 mod handler_jobs; 10 10 mod handler_logs; 11 + mod handler_oauth_clients; 11 12 mod handler_openapi_spec; 12 13 mod handler_stats; 13 14 mod handler_sync; ··· 358 359 .route( 359 360 "/xrpc/social.slices.slice.getActors", 360 361 post(handler_get_actors::get_actors), 362 + ) 363 + // OAuth client management endpoints 364 + .route( 365 + "/xrpc/social.slices.slice.createOAuthClient", 366 + post(handler_oauth_clients::create_oauth_client), 367 + ) 368 + .route( 369 + "/xrpc/social.slices.slice.getOAuthClients", 370 + get(handler_oauth_clients::get_oauth_clients), 371 + ) 372 + .route( 373 + "/xrpc/social.slices.slice.updateOAuthClient", 374 + post(handler_oauth_clients::update_oauth_client), 375 + ) 376 + .route( 377 + "/xrpc/social.slices.slice.deleteOAuthClient", 378 + post(handler_oauth_clients::delete_oauth_client), 361 379 ) 362 380 // Dynamic collection-specific XRPC endpoints (wildcard routes must come last) 363 381 .route(
+70
api/src/models.rs
··· 133 133 pub cursor: Option<String>, 134 134 pub message: Option<String>, 135 135 } 136 + 137 + #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] 138 + #[serde(rename_all = "camelCase")] 139 + pub struct OAuthClient { 140 + pub id: i32, 141 + pub slice_uri: String, 142 + pub client_id: String, 143 + pub registration_access_token: Option<String>, 144 + pub created_at: DateTime<Utc>, 145 + pub created_by_did: String, 146 + } 147 + 148 + #[derive(Debug, Serialize, Deserialize)] 149 + #[serde(rename_all = "camelCase")] 150 + pub struct CreateOAuthClientRequest { 151 + pub slice_uri: String, 152 + pub client_name: String, 153 + pub redirect_uris: Vec<String>, 154 + pub grant_types: Option<Vec<String>>, 155 + pub response_types: Option<Vec<String>>, 156 + pub scope: Option<String>, 157 + pub client_uri: Option<String>, 158 + pub logo_uri: Option<String>, 159 + pub tos_uri: Option<String>, 160 + pub policy_uri: Option<String>, 161 + } 162 + 163 + #[derive(Debug, Serialize, Deserialize)] 164 + #[serde(rename_all = "camelCase")] 165 + pub struct OAuthClientDetails { 166 + pub client_id: String, 167 + pub client_secret: Option<String>, 168 + pub client_name: String, 169 + pub redirect_uris: Vec<String>, 170 + pub grant_types: Vec<String>, 171 + pub response_types: Vec<String>, 172 + pub scope: Option<String>, 173 + pub client_uri: Option<String>, 174 + pub logo_uri: Option<String>, 175 + pub tos_uri: Option<String>, 176 + pub policy_uri: Option<String>, 177 + pub created_at: DateTime<Utc>, 178 + pub created_by_did: String, 179 + } 180 + 181 + #[derive(Debug, Serialize, Deserialize)] 182 + #[serde(rename_all = "camelCase")] 183 + pub struct ListOAuthClientsResponse { 184 + pub clients: Vec<OAuthClientDetails>, 185 + } 186 + 187 + #[derive(Debug, Serialize, Deserialize)] 188 + #[serde(rename_all = "camelCase")] 189 + pub struct UpdateOAuthClientRequest { 190 + pub client_id: String, 191 + pub client_name: Option<String>, 192 + pub redirect_uris: Option<Vec<String>>, 193 + pub scope: Option<String>, 194 + pub client_uri: Option<String>, 195 + pub logo_uri: Option<String>, 196 + pub tos_uri: Option<String>, 197 + pub policy_uri: Option<String>, 198 + } 199 + 200 + #[derive(Debug, Serialize, Deserialize)] 201 + #[serde(rename_all = "camelCase")] 202 + pub struct DeleteOAuthClientResponse { 203 + pub success: bool, 204 + pub message: String, 205 + }
+3 -1
docker-compose.yml
··· 29 29 - default 30 30 31 31 aip: 32 - image: ghcr.io/bigmoves/aip/aip-sqlite:main-7b2c1ee 32 + image: ghcr.io/bigmoves/aip/aip-sqlite:main-5bbc55c 33 33 environment: 34 34 EXTERNAL_BASE: "${AIP_EXTERNAL_BASE:-http://localhost:8081}" 35 35 HTTP_PORT: "8081" ··· 40 40 ADMIN_DIDS: "did:plc:bcgltzqazw5tb6k2g3ttenbj" 41 41 DPOP_NONCE_SEED: "local-dev-nonce-seed" 42 42 RUST_LOG: "aip=trace,sqlx=debug,tower_http=debug,atproto_identity=debug,atproto_oauth=debug" 43 + ATPROTO_OAUTH_SIGNING_KEYS: "z42tzC26Phdvnzmm7mVgLVgH6cDy3i1A2UcH8m6XbgKVJ4zk" 44 + OAUTH_SIGNING_KEYS: "z42tzC26Phdvnzmm7mVgLVgH6cDy3i1A2UcH8m6XbgKVJ4zk" 43 45 ports: 44 46 - "8081:8081" 45 47 volumes:
+65
frontend/deno.lock
··· 450 450 "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==" 451 451 } 452 452 }, 453 + "redirects": { 454 + "https://esm.sh/@atproto/crypto": "https://esm.sh/@atproto/crypto@0.4.4", 455 + "https://esm.sh/@noble/curves@^1.7.0/p256?target=denonext": "https://esm.sh/@noble/curves@1.9.7/p256?target=denonext", 456 + "https://esm.sh/@noble/curves@^1.7.0/secp256k1?target=denonext": "https://esm.sh/@noble/curves@1.9.7/secp256k1?target=denonext", 457 + "https://esm.sh/@noble/hashes@^1.6.1/sha256?target=denonext": "https://esm.sh/@noble/hashes@1.8.0/sha256?target=denonext", 458 + "https://esm.sh/@noble/hashes@^1.6.1/utils?target=denonext": "https://esm.sh/@noble/hashes@1.8.0/utils?target=denonext", 459 + "https://esm.sh/multiformats@^9.4.2/basics?target=denonext": "https://esm.sh/multiformats@9.9.0/basics?target=denonext" 460 + }, 461 + "remote": { 462 + "https://deno.land/std@0.208.0/encoding/_util.ts": "f368920189c4fe6592ab2e93bd7ded8f3065b84f95cd3e036a4a10a75649dcba", 463 + "https://deno.land/std@0.208.0/encoding/base64.ts": "81c0ecff5ccb402def58ca03d8bd245bd01da15a077d3362b568e991aa10f4d9", 464 + "https://deno.land/std@0.208.0/encoding/base64url.ts": "b15a4c8c988362536334a31dbb75a2429346f8c0f9e2662f961567c5dd2f58e1", 465 + "https://esm.sh/@atproto/crypto@0.4.4": "1baac5a764fe567bb3d4e9e96c24a48d3f53f50c27cb49d379aa438d1025bed1", 466 + "https://esm.sh/@atproto/crypto@0.4.4/denonext/crypto.mjs": "aabf92bb5f30066fe9c6f8c57fe3bd34ef36fd8a5c0c241232898d9cffb505ca", 467 + "https://esm.sh/@noble/curves@1.9.7/denonext/_shortw_utils.mjs": "d62ddfbe9cb2215269d9b56447c5aecc342999419cb9f6bd66e991ee7bc28d35", 468 + "https://esm.sh/@noble/curves@1.9.7/denonext/abstract/curve.mjs": "ca7c724c2cf4e0cd5f9d0c3eed4b3081cdb8c017e12638a2722fd76922ff9c44", 469 + "https://esm.sh/@noble/curves@1.9.7/denonext/abstract/hash-to-curve.mjs": "bac93e692bd50bb338d104a59b841acb2a6246d131122914b9e78e029abb0d63", 470 + "https://esm.sh/@noble/curves@1.9.7/denonext/abstract/modular.mjs": "cfa09121cbd673d611b9f696ed8119c10df4fa0fbaecbca19233909f7c6f504d", 471 + "https://esm.sh/@noble/curves@1.9.7/denonext/abstract/weierstrass.mjs": "28c5cbd946e5dfed647201f66148c1c383c26185c35434872c2b9b512c7e3957", 472 + "https://esm.sh/@noble/curves@1.9.7/denonext/nist.mjs": "235e7e9002fda632292709284783e3265444c90b19fedb1a9a48d121d979bd08", 473 + "https://esm.sh/@noble/curves@1.9.7/denonext/p256.mjs": "7840b9d0fd088d3a4e9727883b57a8db59f4815662b9b589c19555fde2580eea", 474 + "https://esm.sh/@noble/curves@1.9.7/denonext/secp256k1.mjs": "942283ed893724ae84fb8d3764f79695ca0883d7575afd718c889a09d446b173", 475 + "https://esm.sh/@noble/curves@1.9.7/denonext/utils.mjs": "78800caea02fb59f99fe68775bcde329357138874dcdb4e6d2f56e76b4eeeaf0", 476 + "https://esm.sh/@noble/curves@1.9.7/p256?target=denonext": "4719247c47798f580549d2f852f28adddfa599056bb7b0166e58ada5bf038aec", 477 + "https://esm.sh/@noble/curves@1.9.7/secp256k1?target=denonext": "3e69e23e4c33f1c76f0066344fc77682c059ec53959aa4c03b5d48b4b63ccd2b", 478 + "https://esm.sh/@noble/hashes@1.8.0/denonext/_md.mjs": "e30debbd8e964d8bd4ae9c807c75601e3ef7ec73eaed005f703709f64a4b2257", 479 + "https://esm.sh/@noble/hashes@1.8.0/denonext/crypto.mjs": "cabc4468470c6f6d15891c4a6037aebed19289e1a12eb9905c5579c26767a721", 480 + "https://esm.sh/@noble/hashes@1.8.0/denonext/hmac.mjs": "f6e5f679509f87084ebfc0e0334a8ce28788cf94868a61807420618dfc392325", 481 + "https://esm.sh/@noble/hashes@1.8.0/denonext/sha2.mjs": "1bf8c30d97f7fe1546e2fed6777a0da85e78d6e961eeae0e0b4437dabf016b7a", 482 + "https://esm.sh/@noble/hashes@1.8.0/denonext/sha256.mjs": "43f16ee7a29cabde4a66d24226da441240f587dc34c568661a1b3f392fda177a", 483 + "https://esm.sh/@noble/hashes@1.8.0/denonext/utils.mjs": "18b15a49e98c9e136e4bfcdce69b9461630b7dc3389ba932f535ad4a47221b08", 484 + "https://esm.sh/@noble/hashes@1.8.0/sha256?target=denonext": "1804d21efec0beea92617d60ab84cd45d16d58bc709830df4da1be95a8fc69fc", 485 + "https://esm.sh/@noble/hashes@1.8.0/utils?target=denonext": "18820d033cf3fe7481337101dd51a7bb50bd4a0d9ee8fe97fe9b9e5af8bfd5ee", 486 + "https://esm.sh/multiformats@9.9.0/basics?target=denonext": "97fa53d99c2a3aa56367e5e2a34ab3d1b3dc496e4a4fe490460d2955de707361", 487 + "https://esm.sh/multiformats@9.9.0/denonext/bases/base10.mjs": "07ac037675bfbbf0621e7f8fd3cfeb242d1ab0955d7e965f4f3a2daa1c369b85", 488 + "https://esm.sh/multiformats@9.9.0/denonext/bases/base16.mjs": "7f0c9b5860c52b54170cbc8b058fe46eee1b81f52d0908055d38d2a63ec8b721", 489 + "https://esm.sh/multiformats@9.9.0/denonext/bases/base2.mjs": "46527ded4d9b868600b2fd8902398a31226655790bd3c5f61ffa0bd8737b0698", 490 + "https://esm.sh/multiformats@9.9.0/denonext/bases/base256emoji.mjs": "7c16b9576b295024837fe5d192f0b854951ac3a7c0be1be8a3c91d5f62505066", 491 + "https://esm.sh/multiformats@9.9.0/denonext/bases/base32.mjs": "2c42d149c299e8b8934e51ccb284b01b113d3fe432177a55b7a781d10cfbe5b2", 492 + "https://esm.sh/multiformats@9.9.0/denonext/bases/base36.mjs": "52dfe773e2d2650ed87c7c353e909a0d710ed83cb01eb553858eae4879a6664d", 493 + "https://esm.sh/multiformats@9.9.0/denonext/bases/base58.mjs": "d45f93c89f6f8a05c7ddc132c99a2bc866d1de2e8475747b6722f6322482feab", 494 + "https://esm.sh/multiformats@9.9.0/denonext/bases/base64.mjs": "1365d8ab96a8998be1663e7277eebc58d3207f785c9ab53533966626f153461b", 495 + "https://esm.sh/multiformats@9.9.0/denonext/bases/base8.mjs": "9336d2259eb06c31fd0d1e7e5ae36e0826f39b2f73639849a403933348c7526c", 496 + "https://esm.sh/multiformats@9.9.0/denonext/bases/identity.mjs": "28acc5f7d4dfe7d5647e1c8d250540a50ba6397ff4f375bc41dc37fc5d5de510", 497 + "https://esm.sh/multiformats@9.9.0/denonext/basics.mjs": "edd1f5f7171a026940535586a6682c88f036b972a0158d10db2a5936239182ec", 498 + "https://esm.sh/multiformats@9.9.0/denonext/cid.mjs": "1945384d570468b0bb5bd0f394184835038d5778d701b50b6b7eb7273748d288", 499 + "https://esm.sh/multiformats@9.9.0/denonext/codecs/json.mjs": "a31ef601f2480daa1ed34393e91f937cef522517ca7d934b86939f8d761fe077", 500 + "https://esm.sh/multiformats@9.9.0/denonext/codecs/raw.mjs": "6d6b44e3bea526dd9930d61631709f45e9f10bf2e190dc19168e9b0297fb30dd", 501 + "https://esm.sh/multiformats@9.9.0/denonext/esm/src/bases/base.mjs": "f0057681c3f918b72d77de38d89d1f532ae4fc78b86f35db7f617c81e7ac504f", 502 + "https://esm.sh/multiformats@9.9.0/denonext/esm/src/bytes.mjs": "d2fa273fd87212f525dcd3863af8a3d2ca1e3ee42aca3eaee5ddd4ad6e43ba04", 503 + "https://esm.sh/multiformats@9.9.0/denonext/esm/src/varint.mjs": "5ea3af2ab0109f1e9f4f56fe35883f593f6b047899ada90095a5ed518d635899", 504 + "https://esm.sh/multiformats@9.9.0/denonext/hashes/digest.mjs": "fc07873514b182ae0897159ebdcad3dd3ceced04740aa76caf4b2e796ff6ca25", 505 + "https://esm.sh/multiformats@9.9.0/denonext/hashes/hasher.mjs": "44bfd064461927c2e8f161db65053d711e59e3df4b55b76bb3f9bf4c316492ad", 506 + "https://esm.sh/multiformats@9.9.0/denonext/hashes/identity.mjs": "43eaf0a3160c8344e2f70387d0a124f3f21363d82d3e246a7a7df4053c6199e4", 507 + "https://esm.sh/multiformats@9.9.0/denonext/hashes/sha2.mjs": "a0fd2e20d8753f6ca30db815bb7f20a57e2a23dd691384b11d07b5a52dbec74f", 508 + "https://esm.sh/multiformats@9.9.0/denonext/multiformats.mjs": "7449f492d80b1dcffcbbfb5599707e5f9c0e5a54694532f330b0f64defe27809", 509 + "https://esm.sh/uint8arrays@3.0.0/denonext/compare.mjs": "2b8ed67b92836546e504b87733eacd3f0569050ea645920d3f2503e8e335cd01", 510 + "https://esm.sh/uint8arrays@3.0.0/denonext/concat.mjs": "ce3cfbcdd3cc6d1d9fa75f13963c7398a377d8a1dbd6302820f2058f42545fc7", 511 + "https://esm.sh/uint8arrays@3.0.0/denonext/equals.mjs": "2c9a9504f97abc172b755fb5069cb9b69aac2e177ad4bc2cf9afdd6a4a322694", 512 + "https://esm.sh/uint8arrays@3.0.0/denonext/esm/src/util/bases.mjs": "03de06f47b410a6ed09da73d6e58696ca34ac4e51bce4a0bcfac5cf88c23b8da", 513 + "https://esm.sh/uint8arrays@3.0.0/denonext/from-string.mjs": "6df06b3ed43db82fa33500d4ab01a97ae4ecef234f76e06c1774b915d913bbe3", 514 + "https://esm.sh/uint8arrays@3.0.0/denonext/to-string.mjs": "bafce61afad1706118c9c6bd08d6d2f53dada2c0892b8fe14da8124e5951390c", 515 + "https://esm.sh/uint8arrays@3.0.0/denonext/uint8arrays.mjs": "2627bbf05b4b496ffe067a4f090dd91d4aa7bc8c815d36f1e543531fb0d9342f", 516 + "https://esm.sh/uint8arrays@3.0.0/denonext/xor.mjs": "cc93f46198dca299adc26ae5344768914923bbc88e78eefd28699d0ec28c8e71" 517 + }, 453 518 "workspace": { 454 519 "dependencies": [ 455 520 "jsr:@slices/oauth@~0.3.2",
+90 -1
frontend/src/client.ts
··· 1 1 // Generated TypeScript client for AT Protocol records 2 - // Generated at: 2025-09-04 05:32:23 UTC 2 + // Generated at: 2025-09-07 23:17:29 UTC 3 3 // Lexicons: 6 4 4 5 5 /** ··· 304 304 params?: Omit<SliceRecordsParams<TSortField>, "slice"> 305 305 ): Promise<GetRecordsResponse<T>>; 306 306 getRecord(params: GetRecordParams): Promise<RecordResponse<T>>; 307 + } 308 + 309 + export interface CreateOAuthClientRequest { 310 + clientName: string; 311 + redirectUris: string[]; 312 + grantTypes?: string[]; 313 + responseTypes?: string[]; 314 + scope?: string; 315 + clientUri?: string; 316 + logoUri?: string; 317 + tosUri?: string; 318 + policyUri?: string; 319 + } 320 + 321 + export interface OAuthClientDetails { 322 + clientId: string; 323 + clientSecret?: string; 324 + clientName: string; 325 + redirectUris: string[]; 326 + grantTypes: string[]; 327 + responseTypes: string[]; 328 + scope?: string; 329 + clientUri?: string; 330 + logoUri?: string; 331 + tosUri?: string; 332 + policyUri?: string; 333 + createdAt: string; 334 + createdByDid: string; 335 + } 336 + 337 + export interface ListOAuthClientsResponse { 338 + clients: OAuthClientDetails[]; 339 + } 340 + 341 + export interface UpdateOAuthClientRequest { 342 + clientId: string; 343 + clientName?: string; 344 + redirectUris?: string[]; 345 + scope?: string; 346 + clientUri?: string; 347 + logoUri?: string; 348 + tosUri?: string; 349 + policyUri?: string; 350 + } 351 + 352 + export interface DeleteOAuthClientResponse { 353 + success: boolean; 354 + message: string; 307 355 } 308 356 309 357 export interface AppBskyActorProfile { ··· 982 1030 "social.slices.slice.syncUserCollections", 983 1031 "POST", 984 1032 requestParams 1033 + ); 1034 + } 1035 + 1036 + async createOAuthClient( 1037 + params: CreateOAuthClientRequest 1038 + ): Promise<OAuthClientDetails> { 1039 + const requestParams = { ...params, sliceUri: this.sliceUri }; 1040 + return await this.makeRequest<OAuthClientDetails>( 1041 + "social.slices.slice.createOAuthClient", 1042 + "POST", 1043 + requestParams 1044 + ); 1045 + } 1046 + 1047 + async getOAuthClients(): Promise<ListOAuthClientsResponse> { 1048 + const requestParams = { slice: this.sliceUri }; 1049 + return await this.makeRequest<ListOAuthClientsResponse>( 1050 + "social.slices.slice.getOAuthClients", 1051 + "GET", 1052 + requestParams 1053 + ); 1054 + } 1055 + 1056 + async updateOAuthClient( 1057 + params: UpdateOAuthClientRequest 1058 + ): Promise<OAuthClientDetails> { 1059 + const requestParams = { ...params, sliceUri: this.sliceUri }; 1060 + return await this.makeRequest<OAuthClientDetails>( 1061 + "social.slices.slice.updateOAuthClient", 1062 + "POST", 1063 + requestParams 1064 + ); 1065 + } 1066 + 1067 + async deleteOAuthClient( 1068 + clientId: string 1069 + ): Promise<DeleteOAuthClientResponse> { 1070 + return await this.makeRequest<DeleteOAuthClientResponse>( 1071 + "social.slices.slice.deleteOAuthClient", 1072 + "POST", 1073 + { clientId } 985 1074 ); 986 1075 } 987 1076 }
+370
frontend/src/components/OAuthClientModal.tsx
··· 1 + import { OAuthClientDetails } from "../client.ts"; 2 + 3 + interface OAuthClientModalProps { 4 + sliceId: string; 5 + sliceUri: string; 6 + mode: "new" | "view"; 7 + clientData?: OAuthClientDetails; 8 + } 9 + 10 + export function OAuthClientModal({ 11 + sliceId, 12 + sliceUri, 13 + mode, 14 + clientData, 15 + }: OAuthClientModalProps) { 16 + if (mode === "view" && clientData) { 17 + return ( 18 + <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> 19 + <div className="bg-white rounded-lg p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto"> 20 + <form 21 + hx-post={`/api/slices/${sliceId}/oauth/${encodeURIComponent(clientData.clientId)}/update`} 22 + hx-target="#modal-container" 23 + hx-swap="outerHTML" 24 + > 25 + <div className="flex justify-between items-start mb-4"> 26 + <h2 className="text-2xl font-semibold">OAuth Client Details</h2> 27 + <button 28 + type="button" 29 + _="on click set #modal-container's innerHTML to ''" 30 + className="text-gray-400 hover:text-gray-600" 31 + > 32 + 33 + </button> 34 + </div> 35 + 36 + <div className="space-y-4"> 37 + {/* Client ID - Read-only */} 38 + <div> 39 + <label className="block text-sm font-medium text-gray-700 mb-1"> 40 + Client ID 41 + </label> 42 + <div className="font-mono text-sm bg-gray-100 p-2 rounded border"> 43 + {clientData.clientId} 44 + </div> 45 + </div> 46 + 47 + {/* Client Secret - Read-only, only shown once */} 48 + {clientData.clientSecret && ( 49 + <div> 50 + <label className="block text-sm font-medium text-gray-700 mb-1"> 51 + Client Secret 52 + </label> 53 + <div className="font-mono text-sm bg-yellow-50 border border-yellow-200 p-2 rounded"> 54 + <div className="text-yellow-800 text-xs mb-1">⚠️ Save this secret - it won't be shown again</div> 55 + {clientData.clientSecret} 56 + </div> 57 + </div> 58 + )} 59 + 60 + {/* Client Name - Editable */} 61 + <div> 62 + <label 63 + htmlFor="clientName" 64 + className="block text-sm font-medium text-gray-700 mb-1" 65 + > 66 + Client Name <span className="text-red-500">*</span> 67 + </label> 68 + <input 69 + type="text" 70 + id="clientName" 71 + name="clientName" 72 + required 73 + defaultValue={clientData.clientName} 74 + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 75 + /> 76 + </div> 77 + 78 + {/* Redirect URIs - Editable */} 79 + <div> 80 + <label 81 + htmlFor="redirectUris" 82 + className="block text-sm font-medium text-gray-700 mb-1" 83 + > 84 + Redirect URIs <span className="text-red-500">*</span> 85 + </label> 86 + <textarea 87 + id="redirectUris" 88 + name="redirectUris" 89 + required 90 + rows={3} 91 + defaultValue={clientData.redirectUris.join('\n')} 92 + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 93 + /> 94 + <p className="text-sm text-gray-500 mt-1"> 95 + Enter one redirect URI per line 96 + </p> 97 + </div> 98 + 99 + {/* Scope - Editable */} 100 + <div> 101 + <label 102 + htmlFor="scope" 103 + className="block text-sm font-medium text-gray-700 mb-1" 104 + > 105 + Scope 106 + </label> 107 + <input 108 + type="text" 109 + id="scope" 110 + name="scope" 111 + defaultValue={clientData.scope || ''} 112 + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 113 + placeholder="atproto:atproto" 114 + /> 115 + </div> 116 + 117 + {/* Client URI - Editable */} 118 + <div> 119 + <label 120 + htmlFor="clientUri" 121 + className="block text-sm font-medium text-gray-700 mb-1" 122 + > 123 + Client URI 124 + </label> 125 + <input 126 + type="url" 127 + id="clientUri" 128 + name="clientUri" 129 + defaultValue={clientData.clientUri || ''} 130 + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 131 + placeholder="https://example.com" 132 + /> 133 + </div> 134 + 135 + {/* Logo URI - Editable */} 136 + <div> 137 + <label 138 + htmlFor="logoUri" 139 + className="block text-sm font-medium text-gray-700 mb-1" 140 + > 141 + Logo URI 142 + </label> 143 + <input 144 + type="url" 145 + id="logoUri" 146 + name="logoUri" 147 + defaultValue={clientData.logoUri || ''} 148 + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 149 + placeholder="https://example.com/logo.png" 150 + /> 151 + </div> 152 + 153 + {/* Terms of Service URI - Editable */} 154 + <div> 155 + <label 156 + htmlFor="tosUri" 157 + className="block text-sm font-medium text-gray-700 mb-1" 158 + > 159 + Terms of Service URI 160 + </label> 161 + <input 162 + type="url" 163 + id="tosUri" 164 + name="tosUri" 165 + defaultValue={clientData.tosUri || ''} 166 + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 167 + placeholder="https://example.com/terms" 168 + /> 169 + </div> 170 + 171 + {/* Privacy Policy URI - Editable */} 172 + <div> 173 + <label 174 + htmlFor="policyUri" 175 + className="block text-sm font-medium text-gray-700 mb-1" 176 + > 177 + Privacy Policy URI 178 + </label> 179 + <input 180 + type="url" 181 + id="policyUri" 182 + name="policyUri" 183 + defaultValue={clientData.policyUri || ''} 184 + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 185 + placeholder="https://example.com/privacy" 186 + /> 187 + </div> 188 + 189 + <div className="flex justify-end gap-3 mt-6"> 190 + <button 191 + type="button" 192 + _="on click set #modal-container's innerHTML to ''" 193 + className="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300" 194 + > 195 + Cancel 196 + </button> 197 + <button 198 + type="submit" 199 + className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700" 200 + > 201 + Update Client 202 + </button> 203 + </div> 204 + </div> 205 + </form> 206 + </div> 207 + </div> 208 + ); 209 + } 210 + 211 + return ( 212 + <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> 213 + <div className="bg-white rounded-lg p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto"> 214 + <form 215 + hx-post={`/api/slices/${sliceId}/oauth/register`} 216 + hx-target="#modal-container" 217 + hx-swap="outerHTML" 218 + > 219 + <input type="hidden" name="sliceUri" value={sliceUri} /> 220 + 221 + <div className="flex justify-between items-start mb-4"> 222 + <h2 className="text-2xl font-semibold">Register OAuth Client</h2> 223 + <button 224 + type="button" 225 + _="on click set #modal-container's innerHTML to ''" 226 + className="text-gray-400 hover:text-gray-600" 227 + > 228 + 229 + </button> 230 + </div> 231 + 232 + <div className="space-y-4"> 233 + <div> 234 + <label 235 + htmlFor="clientName" 236 + className="block text-sm font-medium text-gray-700 mb-1" 237 + > 238 + Client Name <span className="text-red-500">*</span> 239 + </label> 240 + <input 241 + type="text" 242 + id="clientName" 243 + name="clientName" 244 + required 245 + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 246 + placeholder="My Application" 247 + /> 248 + </div> 249 + 250 + <div> 251 + <label 252 + htmlFor="redirectUris" 253 + className="block text-sm font-medium text-gray-700 mb-1" 254 + > 255 + Redirect URIs <span className="text-red-500">*</span> 256 + </label> 257 + <textarea 258 + id="redirectUris" 259 + name="redirectUris" 260 + required 261 + rows={3} 262 + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 263 + placeholder="https://example.com/callback&#10;https://localhost:3000/callback" 264 + /> 265 + <p className="text-sm text-gray-500 mt-1"> 266 + Enter one redirect URI per line 267 + </p> 268 + </div> 269 + 270 + <div> 271 + <label 272 + htmlFor="scope" 273 + className="block text-sm font-medium text-gray-700 mb-1" 274 + > 275 + Scope 276 + </label> 277 + <input 278 + type="text" 279 + id="scope" 280 + name="scope" 281 + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 282 + placeholder="atproto:atproto" 283 + /> 284 + </div> 285 + 286 + <div> 287 + <label 288 + htmlFor="clientUri" 289 + className="block text-sm font-medium text-gray-700 mb-1" 290 + > 291 + Client URI 292 + </label> 293 + <input 294 + type="url" 295 + id="clientUri" 296 + name="clientUri" 297 + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 298 + placeholder="https://example.com" 299 + /> 300 + </div> 301 + 302 + <div> 303 + <label 304 + htmlFor="logoUri" 305 + className="block text-sm font-medium text-gray-700 mb-1" 306 + > 307 + Logo URI 308 + </label> 309 + <input 310 + type="url" 311 + id="logoUri" 312 + name="logoUri" 313 + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 314 + placeholder="https://example.com/logo.png" 315 + /> 316 + </div> 317 + 318 + <div> 319 + <label 320 + htmlFor="tosUri" 321 + className="block text-sm font-medium text-gray-700 mb-1" 322 + > 323 + Terms of Service URI 324 + </label> 325 + <input 326 + type="url" 327 + id="tosUri" 328 + name="tosUri" 329 + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 330 + placeholder="https://example.com/terms" 331 + /> 332 + </div> 333 + 334 + <div> 335 + <label 336 + htmlFor="policyUri" 337 + className="block text-sm font-medium text-gray-700 mb-1" 338 + > 339 + Privacy Policy URI 340 + </label> 341 + <input 342 + type="url" 343 + id="policyUri" 344 + name="policyUri" 345 + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 346 + placeholder="https://example.com/privacy" 347 + /> 348 + </div> 349 + 350 + <div className="flex justify-end gap-3 mt-6"> 351 + <button 352 + type="button" 353 + _="on click set #modal-container's innerHTML to ''" 354 + className="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300" 355 + > 356 + Cancel 357 + </button> 358 + <button 359 + type="submit" 360 + className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700" 361 + > 362 + Register Client 363 + </button> 364 + </div> 365 + </div> 366 + </form> 367 + </div> 368 + </div> 369 + ); 370 + }
+17
frontend/src/components/OAuthDeleteResult.tsx
··· 1 + interface OAuthDeleteResultProps { 2 + success: boolean; 3 + error?: string; 4 + } 5 + 6 + export function OAuthDeleteResult({ success, error }: OAuthDeleteResultProps) { 7 + if (!success) { 8 + return ( 9 + <div className="text-red-600"> 10 + Failed to delete OAuth client{error ? `: ${error}` : ""} 11 + </div> 12 + ); 13 + } 14 + 15 + // Return empty for successful deletion (removes the row) 16 + return null; 17 + }
+55
frontend/src/components/OAuthRegistrationResult.tsx
··· 1 + interface OAuthRegistrationResultProps { 2 + success: boolean; 3 + sliceId: string; 4 + clientId?: string; 5 + registrationToken?: string; 6 + error?: string; 7 + } 8 + 9 + export function OAuthRegistrationResult({ 10 + success, 11 + sliceId, 12 + clientId, 13 + registrationToken, 14 + error, 15 + }: OAuthRegistrationResultProps) { 16 + if (!success) { 17 + return ( 18 + <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded"> 19 + ❌ Failed to register OAuth client: {error} 20 + </div> 21 + ); 22 + } 23 + 24 + return ( 25 + <div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4"> 26 + <div class="font-semibold mb-2"> 27 + ✅ OAuth client registered successfully! 28 + </div> 29 + <div class="mb-2"> 30 + <span class="font-medium">Client ID:</span>{" "} 31 + <code class="bg-green-200 px-1 rounded">{clientId}</code> 32 + </div> 33 + {registrationToken && ( 34 + <div class="bg-yellow-50 border border-yellow-400 text-yellow-800 p-3 rounded mb-3"> 35 + <div class="font-semibold mb-1"> 36 + ⚠️ Important: Save this registration access token 37 + </div> 38 + <div class="text-sm mb-2"> 39 + This token won't be shown again. Store it securely to manage this 40 + client. 41 + </div> 42 + <code class="block bg-yellow-100 p-2 rounded text-xs break-all"> 43 + {registrationToken} 44 + </code> 45 + </div> 46 + )} 47 + <a 48 + href={`/slices/${sliceId}/oauth`} 49 + class="inline-block mt-4 px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 text-decoration-none" 50 + > 51 + Continue 52 + </a> 53 + </div> 54 + ); 55 + }
+1
frontend/src/components/SliceTabs.tsx
··· 11 11 { id: "records", name: "Records", href: `/slices/${sliceId}/records` }, 12 12 { id: "sync", name: "Sync", href: `/slices/${sliceId}/sync` }, 13 13 { id: "codegen", name: "Code Gen", href: `/slices/${sliceId}/codegen` }, 14 + { id: "oauth", name: "OAuth Clients", href: `/slices/${sliceId}/oauth` }, 14 15 { id: "settings", name: "Settings", href: `/slices/${sliceId}/settings` }, 15 16 ]; 16 17 }
+174
frontend/src/pages/SliceOAuthPage.tsx
··· 1 + import { Layout } from "../components/Layout.tsx"; 2 + import { SliceTabs } from "../components/SliceTabs.tsx"; 3 + 4 + interface OAuthClient { 5 + clientId: string; 6 + createdAt: string; 7 + clientName?: string; 8 + redirectUris?: string[]; 9 + } 10 + 11 + interface SliceOAuthPageProps { 12 + sliceName?: string; 13 + sliceId?: string; 14 + clients?: OAuthClient[]; 15 + currentUser?: { handle?: string; isAuthenticated: boolean }; 16 + error?: string | null; 17 + success?: string | null; 18 + } 19 + 20 + export function SliceOAuthPage({ 21 + sliceName = "My Slice", 22 + sliceId = "example", 23 + clients = [], 24 + currentUser, 25 + error = null, 26 + success = null, 27 + }: SliceOAuthPageProps) { 28 + return ( 29 + <Layout title={`${sliceName} - OAuth Clients`} currentUser={currentUser}> 30 + <div> 31 + <div className="flex items-center justify-between mb-8"> 32 + <div className="flex items-center"> 33 + <a href="/" className="text-blue-600 hover:text-blue-800 mr-4"> 34 + ← Back to Slices 35 + </a> 36 + <h1 className="text-3xl font-bold text-gray-800">{sliceName}</h1> 37 + </div> 38 + </div> 39 + 40 + {/* Tab Navigation */} 41 + <SliceTabs sliceId={sliceId} currentTab="oauth" /> 42 + 43 + {/* Success Message */} 44 + {success && ( 45 + <div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4"> 46 + ✅ {success} 47 + </div> 48 + )} 49 + 50 + {/* Error Message */} 51 + {error && ( 52 + <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4"> 53 + ❌ {error} 54 + </div> 55 + )} 56 + 57 + {/* OAuth Clients Content */} 58 + <div className="bg-white rounded-lg shadow-md p-6"> 59 + <div className="flex justify-between items-center mb-6"> 60 + <h2 className="text-2xl font-semibold text-gray-800"> 61 + OAuth Clients 62 + </h2> 63 + <button 64 + type="button" 65 + hx-get={`/api/slices/${sliceId}/oauth/new`} 66 + hx-target="#modal-container" 67 + hx-swap="innerHTML" 68 + className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition" 69 + > 70 + Register New Client 71 + </button> 72 + </div> 73 + 74 + {clients.length === 0 ? ( 75 + <div className="text-center py-12"> 76 + <p className="text-gray-600 mb-4"> 77 + No OAuth clients registered for this slice. 78 + </p> 79 + <button 80 + type="button" 81 + hx-get={`/api/slices/${sliceId}/oauth/new`} 82 + hx-target="#modal-container" 83 + hx-swap="innerHTML" 84 + className="text-blue-600 hover:text-blue-800" 85 + > 86 + Register your first OAuth client 87 + </button> 88 + </div> 89 + ) : ( 90 + <div className="overflow-x-auto"> 91 + <table className="w-full"> 92 + <thead> 93 + <tr className="border-b"> 94 + <th className="text-left py-2 px-4">Client ID</th> 95 + <th className="text-left py-2 px-4">Name</th> 96 + <th className="text-left py-2 px-4">Redirect URIs</th> 97 + <th className="text-left py-2 px-4">Created</th> 98 + <th className="text-left py-2 px-4">Actions</th> 99 + </tr> 100 + </thead> 101 + <tbody> 102 + {clients.map((client) => ( 103 + <tr 104 + key={client.clientId} 105 + className="border-b hover:bg-gray-50" 106 + > 107 + <td className="py-3 px-4 font-mono text-sm"> 108 + {client.clientId} 109 + </td> 110 + <td className="py-3 px-4"> 111 + {client.clientName || "Loading..."} 112 + </td> 113 + <td className="py-3 px-4"> 114 + {client.redirectUris ? ( 115 + <div className="text-sm"> 116 + {client.redirectUris.slice(0, 2).map((uri, idx) => ( 117 + <div key={idx} className="truncate max-w-xs"> 118 + {uri} 119 + </div> 120 + ))} 121 + {client.redirectUris.length > 2 && ( 122 + <div className="text-gray-500"> 123 + +{client.redirectUris.length - 2} more 124 + </div> 125 + )} 126 + </div> 127 + ) : ( 128 + <span className="text-gray-400">Loading...</span> 129 + )} 130 + </td> 131 + <td className="py-3 px-4 text-sm text-gray-600"> 132 + {new Date(client.createdAt).toLocaleDateString()} 133 + </td> 134 + <td className="py-3 px-4"> 135 + <div className="flex gap-2"> 136 + <button 137 + type="button" 138 + hx-get={`/api/slices/${sliceId}/oauth/${encodeURIComponent( 139 + client.clientId 140 + )}/view`} 141 + hx-target="#modal-container" 142 + hx-swap="innerHTML" 143 + className="text-blue-600 hover:text-blue-800 text-sm" 144 + > 145 + View 146 + </button> 147 + <button 148 + type="button" 149 + hx-delete={`/api/slices/${sliceId}/oauth/${encodeURIComponent( 150 + client.clientId 151 + )}`} 152 + hx-confirm="Are you sure you want to delete this OAuth client?" 153 + hx-target="closest tr" 154 + hx-swap="outerHTML" 155 + className="text-red-600 hover:text-red-800 text-sm" 156 + > 157 + Delete 158 + </button> 159 + </div> 160 + </td> 161 + </tr> 162 + ))} 163 + </tbody> 164 + </table> 165 + </div> 166 + )} 167 + </div> 168 + 169 + {/* Modal Container */} 170 + <div id="modal-container"></div> 171 + </div> 172 + </Layout> 173 + ); 174 + }
+93 -8
frontend/src/routes/pages.tsx
··· 13 13 import { SliceCodegenPage } from "../pages/SliceCodegenPage.tsx"; 14 14 import { SliceApiDocsPage } from "../pages/SliceApiDocsPage.tsx"; 15 15 import { SliceSettingsPage } from "../pages/SliceSettingsPage.tsx"; 16 + import { SliceOAuthPage } from "../pages/SliceOAuthPage.tsx"; 16 17 import { SyncJobLogsPage } from "../pages/SyncJobLogsPage.tsx"; 17 18 import { JetstreamLogsPage } from "../pages/JetstreamLogsPage.tsx"; 18 19 import { SettingsPage } from "../pages/SettingsPage.tsx"; ··· 21 22 async function handleIndexPage(req: Request): Promise<Response> { 22 23 const context = await withAuth(req); 23 24 24 - // Slice list page - get real slices from AT Protocol 25 25 let slices: Array<{ id: string; name: string; createdAt: string }> = []; 26 26 27 27 if (context.currentUser.isAuthenticated) { ··· 92 92 return Response.redirect(new URL("/", req.url), 302); 93 93 } 94 94 95 - // Get real slice data from AT Protocol 96 95 let sliceData = { 97 96 sliceId, 98 97 sliceName: "Unknown Slice", ··· 104 103 105 104 if (context.currentUser.isAuthenticated) { 106 105 try { 107 - // Construct the full URI for this slice using AT Protocol helpers 108 106 const sliceUri = buildAtUri({ 109 107 did: context.currentUser.sub || "unknown", 110 108 collection: "social.slices.slice", ··· 117 115 atprotoClient.social.slices.slice.stats({ slice: sliceUri }), 118 116 ]); 119 117 120 - // Transform collection stats to match the interface 121 118 const collections = stats.success 122 119 ? stats.collectionStats.map((stat) => ({ 123 120 name: stat.collection, ··· 224 221 const selectedCollection = url.searchParams.get("collection") || ""; 225 222 const selectedAuthor = url.searchParams.get("author") || ""; 226 223 const searchQuery = url.searchParams.get("search") || ""; 227 - 228 224 229 225 // Fetch real records if a collection is selected 230 226 let records: Array<{ ··· 582 578 583 579 try { 584 580 const sliceClient = getSliceClient(context, sliceId); 585 - 586 - const logsResult = await sliceClient.social.slices.slice.getJetstreamLogs({ limit: 100 }); 587 - logs = logsResult.logs.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); 581 + 582 + const logsResult = await sliceClient.social.slices.slice.getJetstreamLogs({ 583 + limit: 100, 584 + }); 585 + logs = logsResult.logs.sort( 586 + (a, b) => 587 + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() 588 + ); 588 589 } catch (error) { 589 590 console.error("Failed to fetch Jetstream logs:", error); 590 591 } ··· 607 608 }); 608 609 } 609 610 611 + async function handleSliceOAuthPage( 612 + req: Request, 613 + params?: URLPatternResult 614 + ): Promise<Response> { 615 + const context = await withAuth(req); 616 + if (!context.currentUser.isAuthenticated) { 617 + return new Response("", { 618 + status: 302, 619 + headers: { location: "/login" }, 620 + }); 621 + } 622 + 623 + const sliceId = params?.pathname.groups.id; 624 + if (!sliceId) { 625 + return new Response("Invalid slice ID", { status: 400 }); 626 + } 627 + 628 + // Get the slice record first (separate from OAuth clients) 629 + const sliceUri = buildAtUri({ 630 + did: context.currentUser.sub!, 631 + collection: "social.slices.slice", 632 + rkey: sliceId, 633 + }); 634 + 635 + const sliceClient = getSliceClient(context, sliceId); 636 + 637 + let slice; 638 + try { 639 + slice = await atprotoClient.social.slices.slice.getRecord({ 640 + uri: sliceUri, 641 + }); 642 + } catch (error) { 643 + console.error("Error fetching slice:", error); 644 + return new Response("Slice not found", { status: 404 }); 645 + } 646 + 647 + // Try to fetch OAuth clients 648 + let clientsWithDetails: { 649 + clientId: string; 650 + createdAt: string; 651 + clientName?: string; 652 + redirectUris?: string[]; 653 + }[] = []; 654 + let errorMessage = null; 655 + 656 + try { 657 + const oauthClientsResponse = 658 + await sliceClient.social.slices.slice.getOAuthClients(); 659 + console.log("Fetched OAuth clients:", oauthClientsResponse.clients); 660 + clientsWithDetails = oauthClientsResponse.clients.map((client) => ({ 661 + clientId: client.clientId, 662 + createdAt: new Date().toISOString(), // Backend should provide this 663 + clientName: client.clientName, 664 + redirectUris: client.redirectUris, 665 + })); 666 + } catch (oauthError) { 667 + console.error("Error fetching OAuth clients:", oauthError); 668 + errorMessage = "Failed to fetch OAuth clients"; 669 + } 670 + 671 + const html = render( 672 + <SliceOAuthPage 673 + sliceName={slice.value.name} 674 + sliceId={sliceId} 675 + clients={clientsWithDetails} 676 + currentUser={context.currentUser} 677 + error={errorMessage} 678 + /> 679 + ); 680 + 681 + const responseHeaders: Record<string, string> = { 682 + "content-type": "text/html", 683 + }; 684 + 685 + return new Response(`<!DOCTYPE html>${html}`, { 686 + status: 200, 687 + headers: responseHeaders, 688 + }); 689 + } 690 + 610 691 export const pageRoutes: Route[] = [ 611 692 { 612 693 pattern: new URLPattern({ pathname: "/" }), ··· 635 716 { 636 717 pattern: new URLPattern({ pathname: "/slices/:id/jetstream/logs" }), 637 718 handler: handleJetstreamLogsPage, 719 + }, 720 + { 721 + pattern: new URLPattern({ pathname: "/slices/:id/oauth" }), 722 + handler: handleSliceOAuthPage, 638 723 }, 639 724 { 640 725 pattern: new URLPattern({ pathname: "/slices/:id/:tab" }),
+401 -93
frontend/src/routes/slices.tsx
··· 21 21 import { JetstreamLogs } from "../components/JetstreamLogs.tsx"; 22 22 import { buildAtUri } from "../utils/at-uri.ts"; 23 23 import { Layout } from "../components/Layout.tsx"; 24 + import { OAuthClientModal } from "../components/OAuthClientModal.tsx"; 25 + import { OAuthRegistrationResult } from "../components/OAuthRegistrationResult.tsx"; 26 + import { OAuthDeleteResult } from "../components/OAuthDeleteResult.tsx"; 24 27 25 28 async function handleCreateSlice(req: Request): Promise<Response> { 26 29 const context = await withAuth(req); ··· 757 760 758 761 const sliceId = params?.pathname.groups.id; 759 762 const jobId = params?.pathname.groups.jobId; 760 - 763 + 761 764 if (!sliceId || !jobId) { 762 765 const html = render( 763 766 <div className="p-8 text-center text-red-600"> ··· 913 916 } 914 917 } 915 918 916 - export const sliceRoutes: Route[] = [ 917 - { 918 - method: "POST", 919 - pattern: new URLPattern({ pathname: "/slices" }), 920 - handler: handleCreateSlice, 921 - }, 922 - { 923 - method: "PUT", 924 - pattern: new URLPattern({ pathname: "/api/slices/:id/settings" }), 925 - handler: handleUpdateSliceSettings, 926 - }, 927 - { 928 - method: "DELETE", 929 - pattern: new URLPattern({ pathname: "/api/slices/:id" }), 930 - handler: handleDeleteSlice, 931 - }, 932 - { 933 - method: "POST", 934 - pattern: new URLPattern({ pathname: "/api/lexicons" }), 935 - handler: handleCreateLexicon, 936 - }, 937 - { 938 - method: "GET", 939 - pattern: new URLPattern({ pathname: "/api/slices/:id/lexicons/list" }), 940 - handler: handleListLexicons, 941 - }, 942 - { 943 - method: "GET", 944 - pattern: new URLPattern({ 945 - pathname: "/api/slices/:id/lexicons/:rkey/view", 946 - }), 947 - handler: handleViewLexicon, 948 - }, 949 - { 950 - method: "DELETE", 951 - pattern: new URLPattern({ pathname: "/api/slices/:id/lexicons/:rkey" }), 952 - handler: handleDeleteLexicon, 953 - }, 954 - { 955 - method: "POST", 956 - pattern: new URLPattern({ pathname: "/api/slices/:id/codegen" }), 957 - handler: handleSliceCodegen, 958 - }, 959 - { 960 - method: "POST", 961 - pattern: new URLPattern({ pathname: "/api/slices/:id/lexicons" }), 962 - handler: handleCreateLexicon, 963 - }, 964 - { 965 - method: "PUT", 966 - pattern: new URLPattern({ pathname: "/api/profile" }), 967 - handler: handleUpdateProfile, 968 - }, 969 - { 970 - method: "POST", 971 - pattern: new URLPattern({ pathname: "/api/slices/:id/sync" }), 972 - handler: handleSliceSync, 973 - }, 974 - { 975 - method: "GET", 976 - pattern: new URLPattern({ pathname: "/api/slices/:id/job-history" }), 977 - handler: handleJobHistory, 978 - }, 979 - { 980 - method: "GET", 981 - pattern: new URLPattern({ pathname: "/api/slices/:id/sync/logs/:jobId" }), 982 - handler: handleSyncJobLogs, 983 - }, 984 - { 985 - method: "GET", 986 - pattern: new URLPattern({ pathname: "/api/jetstream/status" }), 987 - handler: handleJetstreamStatus, 988 - }, 989 - { 990 - method: "GET", 991 - pattern: new URLPattern({ pathname: "/api/slices/:id/jetstream/logs" }), 992 - handler: handleJetstreamLogs, 993 - }, 994 - ]; 919 + async function handleOAuthClientNew(req: Request): Promise<Response> { 920 + const context = await withAuth(req); 921 + const authResponse = requireAuth(context); 922 + if (authResponse) return authResponse; 923 + 924 + const url = new URL(req.url); 925 + const sliceId = url.pathname.split("/")[3]; 926 + 927 + try { 928 + // Build the slice URI 929 + const sliceUri = buildSliceUri(context.currentUser.sub!, sliceId); 930 + 931 + const html = render( 932 + <OAuthClientModal sliceId={sliceId} sliceUri={sliceUri} mode="new" /> 933 + ); 934 + 935 + return new Response(html, { 936 + status: 200, 937 + headers: { "content-type": "text/html" }, 938 + }); 939 + } catch (error) { 940 + console.error("Error:", error); 941 + return new Response("Failed to load modal", { status: 500 }); 942 + } 943 + } 944 + 945 + async function handleOAuthClientRegister(req: Request): Promise<Response> { 946 + const context = await withAuth(req); 947 + const authResponse = requireAuth(context); 948 + if (authResponse) return authResponse; 949 + 950 + const url = new URL(req.url); 951 + const sliceId = url.pathname.split("/")[3]; 952 + 953 + try { 954 + const formData = await req.formData(); 955 + const sliceUri = formData.get("sliceUri") as string; 956 + const clientName = formData.get("clientName") as string; 957 + const redirectUris = (formData.get("redirectUris") as string) 958 + .split("\n") 959 + .map((uri) => uri.trim()) 960 + .filter((uri) => uri.length > 0); 961 + const scope = (formData.get("scope") as string) || undefined; 962 + const clientUri = (formData.get("clientUri") as string) || undefined; 963 + const logoUri = (formData.get("logoUri") as string) || undefined; 964 + const tosUri = (formData.get("tosUri") as string) || undefined; 965 + const policyUri = (formData.get("policyUri") as string) || undefined; 966 + 967 + // Create OAuth client via backend API 968 + const sliceClient = getSliceClient(context, sliceId); 969 + const clientDetails = 970 + await sliceClient.social.slices.slice.createOAuthClient({ 971 + clientName, 972 + redirectUris, 973 + grantTypes: ["authorization_code"], 974 + responseTypes: ["code"], 975 + ...(scope && { scope }), 976 + ...(clientUri && { clientUri }), 977 + ...(logoUri && { logoUri }), 978 + ...(tosUri && { tosUri }), 979 + ...(policyUri && { policyUri }), 980 + }); 981 + 982 + // Return success response using JSX component 983 + const html = render( 984 + <OAuthRegistrationResult 985 + success 986 + sliceId={sliceId} 987 + clientId={clientDetails.clientId} 988 + /> 989 + ); 990 + 991 + return new Response(html, { 992 + status: 200, 993 + headers: { "content-type": "text/html" }, 994 + }); 995 + } catch (error) { 996 + console.error("Error registering OAuth client:", error); 997 + const html = render( 998 + <OAuthRegistrationResult 999 + success={false} 1000 + sliceId={sliceId} 1001 + error={error instanceof Error ? error.message : String(error)} 1002 + /> 1003 + ); 1004 + 1005 + return new Response(html, { 1006 + status: 200, 1007 + headers: { "content-type": "text/html" }, 1008 + }); 1009 + } 1010 + } 1011 + 1012 + async function handleOAuthClientDelete(req: Request): Promise<Response> { 1013 + const context = await withAuth(req); 1014 + const authResponse = requireAuth(context); 1015 + if (authResponse) return authResponse; 1016 + 1017 + const url = new URL(req.url); 1018 + const pathParts = url.pathname.split("/"); 1019 + const sliceId = pathParts[3]; 1020 + const clientId = decodeURIComponent(pathParts[pathParts.length - 1]); 1021 + 1022 + try { 1023 + const sliceClient = getSliceClient(context, sliceId); 1024 + 1025 + // Delete the OAuth client via backend API 1026 + await sliceClient.social.slices.slice.deleteOAuthClient(clientId); 1027 + 1028 + // Return empty response to remove the row 1029 + const html = render(<OAuthDeleteResult success />); 1030 + return new Response(html || "", { 1031 + status: 200, 1032 + headers: { "content-type": "text/html" }, 1033 + }); 1034 + } catch (error) { 1035 + console.error("Error deleting OAuth client:", error); 1036 + const html = render( 1037 + <OAuthDeleteResult 1038 + success={false} 1039 + error={error instanceof Error ? error.message : String(error)} 1040 + /> 1041 + ); 1042 + return new Response(html || "", { 1043 + status: 200, 1044 + headers: { "content-type": "text/html" }, 1045 + }); 1046 + } 1047 + } 1048 + 1049 + async function handleOAuthClientView(req: Request): Promise<Response> { 1050 + const context = await withAuth(req); 1051 + const authResponse = requireAuth(context); 1052 + if (authResponse) return authResponse; 1053 + 1054 + const url = new URL(req.url); 1055 + const pathParts = url.pathname.split("/"); 1056 + const sliceId = pathParts[3]; 1057 + const clientId = decodeURIComponent(pathParts[5]); 1058 + 1059 + try { 1060 + const sliceClient = getSliceClient(context, sliceId); 1061 + 1062 + // Get OAuth clients to find the specific one 1063 + const clients = await sliceClient.social.slices.slice.getOAuthClients(); 1064 + const client = clients.clients.find((c) => c.clientId === clientId); 1065 + 1066 + if (!client) { 1067 + const html = render( 1068 + <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4"> 1069 + <div className="bg-white rounded-lg shadow-lg p-6 max-w-lg w-full"> 1070 + <h2 className="text-xl font-semibold text-gray-800 mb-4"> 1071 + OAuth Client Not Found 1072 + </h2> 1073 + <p className="text-gray-600 mb-4"> 1074 + The requested OAuth client could not be found. 1075 + </p> 1076 + <button 1077 + type="button" 1078 + _="on click set #modal-container's innerHTML to ''" 1079 + className="bg-gray-500 text-white px-4 py-2 rounded-lg hover:bg-gray-600 transition" 1080 + > 1081 + Close 1082 + </button> 1083 + </div> 1084 + </div> 1085 + ); 1086 + return new Response(html || "", { 1087 + status: 404, 1088 + headers: { "content-type": "text/html" }, 1089 + }); 1090 + } 1091 + 1092 + const sliceUri = `at://did:plc:bcgltzqazw5tb6k2g3ttenbj/social.slices.slice/${sliceId}`; 1093 + const html = render( 1094 + <OAuthClientModal 1095 + sliceId={sliceId} 1096 + sliceUri={sliceUri} 1097 + mode="view" 1098 + clientData={client} 1099 + /> 1100 + ); 1101 + 1102 + return new Response(html || "", { 1103 + status: 200, 1104 + headers: { "content-type": "text/html" }, 1105 + }); 1106 + } catch (error) { 1107 + console.error("Error viewing OAuth client:", error); 1108 + const html = render( 1109 + <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4"> 1110 + <div className="bg-white rounded-lg shadow-lg p-6 max-w-lg w-full"> 1111 + <h2 className="text-xl font-semibold text-gray-800 mb-4">Error</h2> 1112 + <p className="text-gray-600 mb-4"> 1113 + Failed to load OAuth client details:{" "} 1114 + {error instanceof Error ? error.message : String(error)} 1115 + </p> 1116 + <button 1117 + type="button" 1118 + _="on click set #modal-container's innerHTML to ''" 1119 + className="bg-gray-500 text-white px-4 py-2 rounded-lg hover:bg-gray-600 transition" 1120 + > 1121 + Close 1122 + </button> 1123 + </div> 1124 + </div> 1125 + ); 1126 + return new Response(html || "", { 1127 + status: 500, 1128 + headers: { "content-type": "text/html" }, 1129 + }); 1130 + } 1131 + } 1132 + 1133 + async function handleOAuthClientUpdate(req: Request): Promise<Response> { 1134 + const context = await withAuth(req); 1135 + const authResponse = requireAuth(context); 1136 + if (authResponse) return authResponse; 1137 + 1138 + const url = new URL(req.url); 1139 + const pathParts = url.pathname.split("/"); 1140 + const sliceId = pathParts[3]; 1141 + const clientId = decodeURIComponent(pathParts[5]); 1142 + 1143 + try { 1144 + const formData = await req.formData(); 1145 + const clientName = formData.get("clientName") as string; 1146 + const redirectUrisText = formData.get("redirectUris") as string; 1147 + const scope = formData.get("scope") as string; 1148 + const clientUri = formData.get("clientUri") as string; 1149 + const logoUri = formData.get("logoUri") as string; 1150 + const tosUri = formData.get("tosUri") as string; 1151 + const policyUri = formData.get("policyUri") as string; 1152 + 1153 + // Parse redirect URIs (split by lines and filter empty) 1154 + const redirectUris = redirectUrisText 1155 + .split("\n") 1156 + .map((uri) => uri.trim()) 1157 + .filter((uri) => uri.length > 0); 1158 + 1159 + // Update OAuth client via backend API 1160 + const sliceClient = getSliceClient(context, sliceId); 1161 + const updatedClient = 1162 + await sliceClient.social.slices.slice.updateOAuthClient({ 1163 + clientId, 1164 + clientName: clientName || undefined, 1165 + redirectUris: redirectUris.length > 0 ? redirectUris : undefined, 1166 + scope: scope || undefined, 1167 + clientUri: clientUri || undefined, 1168 + logoUri: logoUri || undefined, 1169 + tosUri: tosUri || undefined, 1170 + policyUri: policyUri || undefined, 1171 + }); 1172 + 1173 + const sliceUri = `at://did:plc:bcgltzqazw5tb6k2g3ttenbj/social.slices.slice/${sliceId}`; 1174 + const html = render( 1175 + <OAuthClientModal 1176 + sliceId={sliceId} 1177 + sliceUri={sliceUri} 1178 + mode="view" 1179 + clientData={updatedClient} 1180 + /> 1181 + ); 1182 + return new Response(html, { 1183 + status: 200, 1184 + headers: { "content-type": "text/html" }, 1185 + }); 1186 + } catch (error) { 1187 + console.error("Error updating OAuth client:", error); 1188 + const html = render( 1189 + <OAuthDeleteResult 1190 + success={false} 1191 + error={error instanceof Error ? error.message : String(error)} 1192 + /> 1193 + ); 1194 + return new Response(html, { 1195 + status: 500, 1196 + headers: { "content-type": "text/html" }, 1197 + }); 1198 + } 1199 + } 995 1200 996 1201 async function handleJetstreamLogs( 997 1202 req: Request, ··· 1004 1209 const sliceId = params?.pathname.groups.id; 1005 1210 if (!sliceId) { 1006 1211 const html = render( 1007 - <div className="p-8 text-center text-red-600"> 1008 - ❌ Invalid slice ID 1009 - </div> 1212 + <div className="p-8 text-center text-red-600">❌ Invalid slice ID</div> 1010 1213 ); 1011 1214 return new Response(html, { 1012 1215 status: 400, ··· 1024 1227 }); 1025 1228 1026 1229 const logs = result?.logs || []; 1027 - 1028 - // Sort logs in descending order (newest first) 1029 - const sortedLogs = logs.sort((a, b) => 1030 - new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() 1230 + 1231 + // Sort logs in descending order (newest first) 1232 + const sortedLogs = logs.sort( 1233 + (a, b) => 1234 + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() 1031 1235 ); 1032 1236 1033 1237 // Render the log content ··· 1067 1271 } 1068 1272 } 1069 1273 1070 - 1071 1274 async function handleJetstreamStatus( 1072 1275 req: Request, 1073 1276 _params?: URLPatternResult ··· 1077 1280 const url = new URL(req.url); 1078 1281 const sliceId = url.searchParams.get("sliceId"); 1079 1282 const isCompact = url.searchParams.get("compact") === "true"; 1080 - 1283 + 1081 1284 // Fetch jetstream status using the atproto client 1082 1285 const data = await atprotoClient.social.slices.slice.getJetstreamStatus(); 1083 1286 ··· 1098 1301 )} 1099 1302 </div> 1100 1303 ); 1101 - 1304 + 1102 1305 return new Response(html, { 1103 1306 status: 200, 1104 1307 headers: { "content-type": "text/html" }, ··· 1124 1327 const url = new URL(req.url); 1125 1328 const sliceId = url.searchParams.get("sliceId"); 1126 1329 const isCompact = url.searchParams.get("compact") === "true"; 1127 - 1330 + 1128 1331 // Render compact error version 1129 1332 if (isCompact) { 1130 1333 const html = render( ··· 1133 1336 <span className="text-red-700">Jetstream Offline</span> 1134 1337 </div> 1135 1338 ); 1136 - 1339 + 1137 1340 return new Response(html, { 1138 1341 status: 200, 1139 1342 headers: { "content-type": "text/html" }, 1140 1343 }); 1141 1344 } 1142 - 1345 + 1143 1346 // Fallback to disconnected state on error for full version 1144 1347 const html = render( 1145 1348 <JetstreamStatus ··· 1156 1359 }); 1157 1360 } 1158 1361 } 1362 + 1363 + export const sliceRoutes: Route[] = [ 1364 + { 1365 + method: "POST", 1366 + pattern: new URLPattern({ pathname: "/slices" }), 1367 + handler: handleCreateSlice, 1368 + }, 1369 + { 1370 + method: "PUT", 1371 + pattern: new URLPattern({ pathname: "/api/slices/:id/settings" }), 1372 + handler: handleUpdateSliceSettings, 1373 + }, 1374 + { 1375 + method: "DELETE", 1376 + pattern: new URLPattern({ pathname: "/api/slices/:id" }), 1377 + handler: handleDeleteSlice, 1378 + }, 1379 + { 1380 + method: "POST", 1381 + pattern: new URLPattern({ pathname: "/api/lexicons" }), 1382 + handler: handleCreateLexicon, 1383 + }, 1384 + { 1385 + method: "GET", 1386 + pattern: new URLPattern({ pathname: "/api/slices/:id/lexicons/list" }), 1387 + handler: handleListLexicons, 1388 + }, 1389 + { 1390 + method: "GET", 1391 + pattern: new URLPattern({ 1392 + pathname: "/api/slices/:id/lexicons/:rkey/view", 1393 + }), 1394 + handler: handleViewLexicon, 1395 + }, 1396 + { 1397 + method: "DELETE", 1398 + pattern: new URLPattern({ pathname: "/api/slices/:id/lexicons/:rkey" }), 1399 + handler: handleDeleteLexicon, 1400 + }, 1401 + { 1402 + method: "POST", 1403 + pattern: new URLPattern({ pathname: "/api/slices/:id/codegen" }), 1404 + handler: handleSliceCodegen, 1405 + }, 1406 + { 1407 + method: "POST", 1408 + pattern: new URLPattern({ pathname: "/api/slices/:id/lexicons" }), 1409 + handler: handleCreateLexicon, 1410 + }, 1411 + { 1412 + method: "PUT", 1413 + pattern: new URLPattern({ pathname: "/api/profile" }), 1414 + handler: handleUpdateProfile, 1415 + }, 1416 + { 1417 + method: "POST", 1418 + pattern: new URLPattern({ pathname: "/api/slices/:id/sync" }), 1419 + handler: handleSliceSync, 1420 + }, 1421 + { 1422 + method: "GET", 1423 + pattern: new URLPattern({ pathname: "/api/slices/:id/job-history" }), 1424 + handler: handleJobHistory, 1425 + }, 1426 + { 1427 + method: "GET", 1428 + pattern: new URLPattern({ pathname: "/api/slices/:id/sync/logs/:jobId" }), 1429 + handler: handleSyncJobLogs, 1430 + }, 1431 + { 1432 + method: "GET", 1433 + pattern: new URLPattern({ pathname: "/api/jetstream/status" }), 1434 + handler: handleJetstreamStatus, 1435 + }, 1436 + { 1437 + method: "GET", 1438 + pattern: new URLPattern({ pathname: "/api/slices/:id/jetstream/logs" }), 1439 + handler: handleJetstreamLogs, 1440 + }, 1441 + { 1442 + method: "GET", 1443 + pattern: new URLPattern({ pathname: "/api/slices/:id/oauth/new" }), 1444 + handler: handleOAuthClientNew, 1445 + }, 1446 + { 1447 + method: "POST", 1448 + pattern: new URLPattern({ pathname: "/api/slices/:id/oauth/register" }), 1449 + handler: handleOAuthClientRegister, 1450 + }, 1451 + { 1452 + method: "GET", 1453 + pattern: new URLPattern({ pathname: "/api/slices/:id/oauth/:uri/view" }), 1454 + handler: handleOAuthClientView, 1455 + }, 1456 + { 1457 + method: "POST", 1458 + pattern: new URLPattern({ pathname: "/api/slices/:id/oauth/:uri/update" }), 1459 + handler: handleOAuthClientUpdate, 1460 + }, 1461 + { 1462 + method: "DELETE", 1463 + pattern: new URLPattern({ pathname: "/api/slices/:id/oauth/:uri" }), 1464 + handler: handleOAuthClientDelete, 1465 + }, 1466 + ];