Highly ambitious ATProtocol AppView service and sdks

refactor api endpoints to more clear xrpc pattern, add procedure and query lexicons for slice endpoints, update codegen for query and procedure lexicons, update frontend to use the new codegen

Changed files
+3633 -2746
api
frontend
src
features
auth
docs
slices
codegen
jetstream
oauth
templates
records
sync
templates
fragments
sync-logs
templates
waitlist
lib
routes
shared
fragments
lexicons
packages
-47
api/src/api/actors.rs
··· 1 - use axum::{ 2 - extract::State, 3 - http::StatusCode, 4 - response::Json, 5 - Json as ExtractJson, 6 - }; 7 - use serde::{Deserialize, Serialize}; 8 - use std::collections::HashMap; 9 - use crate::{AppState, models::{Actor, WhereCondition}}; 10 - 11 - #[derive(Deserialize)] 12 - #[serde(rename_all = "camelCase")] 13 - pub struct GetActorsParams { 14 - pub slice: String, 15 - pub limit: Option<i32>, 16 - pub cursor: Option<String>, 17 - #[serde(rename = "where")] 18 - pub where_conditions: Option<HashMap<String, WhereCondition>>, 19 - } 20 - 21 - #[derive(Serialize)] 22 - #[serde(rename_all = "camelCase")] 23 - pub struct GetActorsResponse { 24 - pub actors: Vec<Actor>, 25 - pub cursor: Option<String>, 26 - } 27 - 28 - pub async fn get_actors( 29 - State(state): State<AppState>, 30 - ExtractJson(params): ExtractJson<GetActorsParams>, 31 - ) -> Result<Json<GetActorsResponse>, StatusCode> { 32 - match state.database.get_slice_actors( 33 - &params.slice, 34 - params.limit, 35 - params.cursor.as_deref(), 36 - params.where_conditions.as_ref(), 37 - ).await { 38 - Ok((actors, cursor)) => { 39 - let response = GetActorsResponse { 40 - actors, 41 - cursor, 42 - }; 43 - Ok(Json(response)) 44 - }, 45 - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), 46 - } 47 - }
-34
api/src/api/jetstream.rs
··· 1 - use axum::{ 2 - extract::State, 3 - http::StatusCode, 4 - response::Json as ResponseJson, 5 - }; 6 - use serde::Serialize; 7 - use std::sync::atomic::Ordering; 8 - use crate::AppState; 9 - 10 - #[derive(Serialize)] 11 - #[serde(rename_all = "camelCase")] 12 - pub struct JetstreamStatusResponse { 13 - connected: bool, 14 - status: String, 15 - error: Option<String>, 16 - } 17 - 18 - pub async fn get_jetstream_status( 19 - State(state): State<AppState>, 20 - ) -> Result<ResponseJson<JetstreamStatusResponse>, StatusCode> { 21 - let connected = state.jetstream_connected.load(Ordering::Relaxed); 22 - 23 - let (status, error) = if connected { 24 - ("Connected".to_string(), None) 25 - } else { 26 - ("Disconnected".to_string(), Some("Jetstream consumer is not connected".to_string())) 27 - }; 28 - 29 - Ok(ResponseJson(JetstreamStatusResponse { 30 - connected, 31 - status, 32 - error, 33 - })) 34 - }
-59
api/src/api/jobs.rs
··· 1 - use axum::{ 2 - extract::{Query, State}, 3 - http::StatusCode, 4 - response::Json, 5 - }; 6 - use serde::Deserialize; 7 - use uuid::Uuid; 8 - use crate::AppState; 9 - use crate::jobs; 10 - 11 - /// Query parameters for getting job status 12 - #[derive(Debug, Deserialize)] 13 - #[serde(rename_all = "camelCase")] 14 - pub struct GetJobStatusQuery { 15 - pub job_id: Uuid, 16 - } 17 - 18 - /// Query parameters for getting slice job history 19 - #[derive(Debug, Deserialize)] 20 - #[serde(rename_all = "camelCase")] 21 - pub struct GetSliceJobHistoryQuery { 22 - pub user_did: String, 23 - pub slice_uri: String, 24 - pub limit: Option<i64>, 25 - } 26 - 27 - /// Get the status of a specific job by ID (XRPC style) 28 - pub async fn get_job_status( 29 - State(state): State<AppState>, 30 - Query(query): Query<GetJobStatusQuery>, 31 - ) -> Result<Json<jobs::JobStatus>, StatusCode> { 32 - match jobs::get_job_status(&state.database_pool, query.job_id).await { 33 - Ok(Some(status)) => Ok(Json(status)), 34 - Ok(None) => Err(StatusCode::NOT_FOUND), 35 - Err(e) => { 36 - tracing::error!("Failed to get job status: {}", e); 37 - Err(StatusCode::INTERNAL_SERVER_ERROR) 38 - } 39 - } 40 - } 41 - 42 - /// Get job history for a specific slice (XRPC style) 43 - pub async fn get_slice_job_history( 44 - State(state): State<AppState>, 45 - Query(query): Query<GetSliceJobHistoryQuery>, 46 - ) -> Result<Json<Vec<jobs::JobStatus>>, StatusCode> { 47 - match jobs::get_slice_job_history( 48 - &state.database_pool, 49 - &query.user_did, 50 - &query.slice_uri, 51 - query.limit 52 - ).await { 53 - Ok(history) => Ok(Json(history)), 54 - Err(e) => { 55 - tracing::error!("Failed to get slice job history: {}", e); 56 - Err(StatusCode::INTERNAL_SERVER_ERROR) 57 - } 58 - } 59 - }
-65
api/src/api/logs.rs
··· 1 - use axum::{ 2 - extract::{Query, State}, 3 - http::StatusCode, 4 - response::Json, 5 - }; 6 - use serde::{Deserialize, Serialize}; 7 - use uuid::Uuid; 8 - 9 - use crate::{AppState, logging::{get_sync_job_logs, get_jetstream_logs, LogEntry}}; 10 - 11 - #[derive(Debug, Deserialize)] 12 - pub struct LogsQuery { 13 - pub limit: Option<i64>, 14 - pub slice: Option<String>, 15 - } 16 - 17 - #[derive(Debug, Serialize)] 18 - pub struct LogsResponse { 19 - pub logs: Vec<LogEntry>, 20 - } 21 - 22 - #[derive(Debug, Deserialize)] 23 - #[serde(rename_all = "camelCase")] 24 - pub struct LogsQueryWithJobId { 25 - pub job_id: Uuid, 26 - pub limit: Option<i64>, 27 - } 28 - 29 - /// Get logs for a specific sync job 30 - pub async fn get_sync_job_logs_handler( 31 - State(state): State<AppState>, 32 - Query(params): Query<LogsQueryWithJobId>, 33 - ) -> Result<Json<LogsResponse>, StatusCode> { 34 - match get_sync_job_logs(&state.database_pool, params.job_id, params.limit).await { 35 - Ok(logs) => Ok(Json(LogsResponse { logs })), 36 - Err(e) => { 37 - tracing::error!("Failed to get sync job logs: {}", e); 38 - Err(StatusCode::INTERNAL_SERVER_ERROR) 39 - } 40 - } 41 - } 42 - 43 - /// Get jetstream logs 44 - pub async fn get_jetstream_logs_handler( 45 - State(state): State<AppState>, 46 - Query(params): Query<LogsQuery>, 47 - ) -> Result<Json<LogsResponse>, StatusCode> { 48 - // Debug logging to see what slice filter is being used 49 - if let Some(slice) = &params.slice { 50 - tracing::info!("Filtering jetstream logs by slice: {}", slice); 51 - } else { 52 - tracing::info!("No slice filter applied - returning all jetstream logs"); 53 - } 54 - 55 - match get_jetstream_logs(&state.database_pool, params.slice.as_deref(), params.limit).await { 56 - Ok(logs) => { 57 - tracing::info!("Returning {} jetstream logs", logs.len()); 58 - Ok(Json(LogsResponse { logs })) 59 - }, 60 - Err(e) => { 61 - tracing::error!("Failed to get jetstream logs: {}", e); 62 - Err(StatusCode::INTERNAL_SERVER_ERROR) 63 - } 64 - } 65 - }
-10
api/src/api/mod.rs
··· 1 - pub mod actors; 2 - pub mod jetstream; 3 - pub mod jobs; 4 - pub mod logs; 5 - pub mod oauth; 6 1 pub mod openapi; 7 - pub mod records; 8 - pub mod sparkline; 9 - pub mod stats; 10 - pub mod sync; 11 - pub mod upload_blob; 12 2 pub mod xrpc_dynamic;
-524
api/src/api/oauth.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<serde_json::Value>, 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 - 155 - // Try to parse the error response as JSON to get more details 156 - let detailed_error = if let Ok(error_json) = serde_json::from_str::<serde_json::Value>(&error_text) { 157 - if let Some(error_desc) = error_json.get("error_description").and_then(|v| v.as_str()) { 158 - error_desc.to_string() 159 - } else if let Some(error) = error_json.get("error").and_then(|v| v.as_str()) { 160 - error.to_string() 161 - } else { 162 - error_text 163 - } 164 - } else { 165 - error_text 166 - }; 167 - 168 - // Return success=false with detailed error message instead of HTTP error 169 - return Ok(Json(serde_json::json!({ 170 - "success": false, 171 - "message": detailed_error 172 - }))); 173 - } 174 - 175 - tracing::debug!("Parsing AIP response JSON..."); 176 - 177 - // Get the response body as text first to debug what we're receiving 178 - let response_body = aip_response 179 - .text() 180 - .await 181 - .map_err(|e| AppError::Internal(format!("Failed to get response body: {}", e)))?; 182 - 183 - tracing::debug!("AIP response body: {}", response_body); 184 - 185 - // Try to parse the JSON from the text 186 - let aip_client: AipClientRegistrationResponse = serde_json::from_str(&response_body) 187 - .map_err(|e| { 188 - tracing::error!("Failed to parse AIP response JSON: {}", e); 189 - tracing::error!("Raw response was: {}", response_body); 190 - AppError::Internal(format!("Failed to parse AIP response: {}", e)) 191 - })?; 192 - 193 - tracing::debug!("Successfully parsed AIP response, client_id: {}", aip_client.client_id); 194 - 195 - // Store the client info in our database 196 - tracing::debug!("Storing OAuth client in database..."); 197 - let oauth_client = state.database 198 - .create_oauth_client( 199 - &request.slice_uri, 200 - &aip_client.client_id, 201 - aip_client.registration_access_token.as_deref(), 202 - &user_did, 203 - ) 204 - .await 205 - .map_err(|e| { 206 - tracing::error!("Failed to store OAuth client in database: {}", e); 207 - AppError::Internal(format!("Failed to store OAuth client: {}", e)) 208 - })?; 209 - 210 - tracing::debug!("Successfully stored OAuth client in database"); 211 - 212 - // Return the full client details from AIP 213 - let response = OAuthClientDetails { 214 - client_id: aip_client.client_id, 215 - client_secret: aip_client.client_secret, 216 - client_name: aip_client.client_name, 217 - redirect_uris: aip_client.redirect_uris, 218 - grant_types: aip_client.grant_types, 219 - response_types: aip_client.response_types, 220 - scope: aip_client.scope, 221 - client_uri: aip_client.client_uri, 222 - logo_uri: aip_client.logo_uri, 223 - tos_uri: aip_client.tos_uri, 224 - policy_uri: aip_client.policy_uri, 225 - created_at: oauth_client.created_at, 226 - created_by_did: oauth_client.created_by_did, 227 - }; 228 - 229 - Ok(Json(serde_json::to_value(response).unwrap())) 230 - } 231 - 232 - pub async fn get_oauth_clients( 233 - State(state): State<AppState>, 234 - headers: HeaderMap, 235 - Query(params): Query<GetOAuthClientsQuery>, 236 - ) -> Result<Json<ListOAuthClientsResponse>, AppError> { 237 - tracing::debug!("get_oauth_clients called with slice parameter: {}", params.slice); 238 - 239 - // Log all headers for debugging 240 - tracing::debug!("Request headers: {:?}", headers); 241 - 242 - // Extract and verify authentication 243 - let token = auth::extract_bearer_token(&headers) 244 - .map_err(|e| { 245 - tracing::error!("Failed to extract bearer token: {:?}", e); 246 - AppError::BadRequest("Missing or invalid Authorization header".to_string()) 247 - })?; 248 - 249 - tracing::debug!("Extracted bearer token (first 20 chars): {}...", 250 - if token.len() > 20 { &token[..20] } else { &token }); 251 - 252 - auth::verify_oauth_token(&token, &state.config.auth_base_url).await 253 - .map_err(|e| { 254 - tracing::error!("OAuth token verification failed: {:?}", e); 255 - AppError::BadRequest("Invalid or expired access token".to_string()) 256 - })?; 257 - 258 - tracing::debug!("OAuth token verification successful"); 259 - 260 - // Get clients from our database 261 - let clients = state.database 262 - .get_oauth_clients_for_slice(&params.slice) 263 - .await 264 - .map_err(|e| AppError::Internal(format!("Failed to fetch OAuth clients: {}", e)))?; 265 - 266 - tracing::debug!("Found {} OAuth clients in database for slice: {}", clients.len(), params.slice); 267 - 268 - if clients.is_empty() { 269 - return Ok(Json(ListOAuthClientsResponse { clients: vec![] })); 270 - } 271 - 272 - // Fetch detailed info from AIP for each client 273 - let aip_base_url = &state.config.auth_base_url; 274 - let client = Client::new(); 275 - let mut client_details = Vec::new(); 276 - 277 - for oauth_client in clients { 278 - // Fetch client details from AIP 279 - let aip_url = format!("{}/oauth/clients/{}", aip_base_url, oauth_client.client_id); 280 - tracing::debug!("Fetching client details from AIP: {}", aip_url); 281 - 282 - // Use registration access token if available for authentication 283 - let mut request_builder = client.get(&aip_url); 284 - if let Some(token) = &oauth_client.registration_access_token { 285 - request_builder = request_builder.bearer_auth(token); 286 - tracing::debug!("Using registration access token for authentication"); 287 - } else { 288 - tracing::debug!("No registration access token available"); 289 - } 290 - 291 - let aip_response = request_builder.send().await; 292 - 293 - match aip_response { 294 - Ok(response) => { 295 - let status = response.status(); 296 - tracing::debug!("AIP response status for {}: {}", oauth_client.client_id, status); 297 - 298 - if status.is_success() { 299 - // Get the response body as text first to log it 300 - match response.text().await { 301 - Ok(response_text) => { 302 - tracing::debug!("AIP response body for {}: {}", oauth_client.client_id, response_text); 303 - 304 - // Try to parse the JSON 305 - match serde_json::from_str::<AipClientRegistrationResponse>(&response_text) { 306 - Ok(aip_client) => { 307 - tracing::debug!("Successfully parsed AIP client details for {}", oauth_client.client_id); 308 - client_details.push(OAuthClientDetails { 309 - client_id: aip_client.client_id, 310 - client_secret: aip_client.client_secret, 311 - client_name: aip_client.client_name, 312 - redirect_uris: aip_client.redirect_uris, 313 - grant_types: aip_client.grant_types, 314 - response_types: aip_client.response_types, 315 - scope: aip_client.scope, 316 - client_uri: aip_client.client_uri, 317 - logo_uri: aip_client.logo_uri, 318 - tos_uri: aip_client.tos_uri, 319 - policy_uri: aip_client.policy_uri, 320 - created_at: oauth_client.created_at, 321 - created_by_did: oauth_client.created_by_did, 322 - }); 323 - } 324 - Err(parse_error) => { 325 - tracing::error!("Failed to parse AIP client JSON for {}: {}", oauth_client.client_id, parse_error); 326 - } 327 - } 328 - } 329 - Err(text_error) => { 330 - tracing::error!("Failed to get AIP response text for {}: {}", oauth_client.client_id, text_error); 331 - } 332 - } 333 - } else { 334 - // Handle non-success status codes 335 - match response.text().await { 336 - Ok(error_text) => { 337 - tracing::error!("AIP client fetch failed with status {} for {}: {}", status, oauth_client.client_id, error_text); 338 - } 339 - Err(_) => { 340 - tracing::error!("AIP client fetch failed with status {} for {}", status, oauth_client.client_id); 341 - } 342 - } 343 - } 344 - } 345 - Err(e) => { 346 - tracing::error!("AIP client fetch error for {}: {}", oauth_client.client_id, e); 347 - // If we can't fetch from AIP, create a minimal response 348 - client_details.push(OAuthClientDetails { 349 - client_id: oauth_client.client_id.clone(), 350 - client_secret: None, 351 - client_name: "Unknown".to_string(), 352 - redirect_uris: vec![], 353 - grant_types: vec!["authorization_code".to_string()], 354 - response_types: vec!["code".to_string()], 355 - scope: None, 356 - client_uri: None, 357 - logo_uri: None, 358 - tos_uri: None, 359 - policy_uri: None, 360 - created_at: oauth_client.created_at, 361 - created_by_did: oauth_client.created_by_did, 362 - }); 363 - } 364 - } 365 - } 366 - 367 - Ok(Json(ListOAuthClientsResponse { clients: client_details })) 368 - } 369 - 370 - pub async fn update_oauth_client( 371 - State(state): State<AppState>, 372 - headers: HeaderMap, 373 - ExtractJson(request): ExtractJson<UpdateOAuthClientRequest>, 374 - ) -> Result<Json<serde_json::Value>, AppError> { 375 - let client_id = request.client_id.clone(); 376 - 377 - // Extract and verify authentication 378 - let token = auth::extract_bearer_token(&headers) 379 - .map_err(|_| AppError::BadRequest("Missing or invalid Authorization header".to_string()))?; 380 - auth::verify_oauth_token(&token, &state.config.auth_base_url).await 381 - .map_err(|_| AppError::BadRequest("Invalid or expired access token".to_string()))?; 382 - 383 - // Get the client from our database to get the registration access token 384 - let oauth_client = state.database 385 - .get_oauth_client_by_id(&client_id) 386 - .await 387 - .map_err(|e| AppError::Internal(format!("Failed to fetch OAuth client: {}", e)))? 388 - .ok_or_else(|| AppError::NotFound("OAuth client not found".to_string()))?; 389 - 390 - let registration_token = oauth_client.registration_access_token 391 - .ok_or_else(|| AppError::Internal("Client missing registration access token".to_string()))?; 392 - 393 - // Build AIP update request 394 - let aip_request = AipClientRegistrationRequest { 395 - client_name: request.client_name.unwrap_or_default(), 396 - redirect_uris: request.redirect_uris.unwrap_or_default(), 397 - grant_types: None, // Keep existing 398 - response_types: None, // Keep existing 399 - scope: request.scope, 400 - client_uri: request.client_uri, 401 - logo_uri: request.logo_uri, 402 - tos_uri: request.tos_uri, 403 - policy_uri: request.policy_uri, 404 - }; 405 - 406 - let aip_base_url = &state.config.auth_base_url; 407 - let client = Client::new(); 408 - let update_url = format!("{}/oauth/clients/{}", aip_base_url, client_id); 409 - 410 - tracing::debug!("Updating OAuth client at: {}", update_url); 411 - tracing::debug!("Sending AIP update request: {:?}", aip_request); 412 - 413 - let aip_response = client 414 - .put(&update_url) 415 - .bearer_auth(&registration_token) 416 - .json(&aip_request) 417 - .send() 418 - .await 419 - .map_err(|e| AppError::Internal(format!("Failed to update client with AIP: {}", e)))?; 420 - 421 - let status = aip_response.status(); 422 - tracing::debug!("AIP update response status: {}", status); 423 - 424 - if !status.is_success() { 425 - let error_text = aip_response.text().await.unwrap_or_else(|_| "Unknown error".to_string()); 426 - tracing::error!("AIP update failed with status {}: {}", status, error_text); 427 - 428 - // Try to parse the error response as JSON to get more details 429 - let detailed_error = if let Ok(error_json) = serde_json::from_str::<serde_json::Value>(&error_text) { 430 - if let Some(error_desc) = error_json.get("error_description").and_then(|v| v.as_str()) { 431 - error_desc.to_string() 432 - } else if let Some(error) = error_json.get("error").and_then(|v| v.as_str()) { 433 - error.to_string() 434 - } else { 435 - error_text 436 - } 437 - } else { 438 - error_text 439 - }; 440 - 441 - // Return success=false with detailed error message instead of HTTP error 442 - return Ok(Json(serde_json::json!({ 443 - "success": false, 444 - "message": detailed_error 445 - }))); 446 - } 447 - 448 - // Parse the response 449 - let response_body = aip_response 450 - .text() 451 - .await 452 - .map_err(|e| AppError::Internal(format!("Failed to get response body: {}", e)))?; 453 - 454 - tracing::debug!("AIP update response body: {}", response_body); 455 - 456 - let aip_client: AipClientRegistrationResponse = serde_json::from_str(&response_body) 457 - .map_err(|e| { 458 - tracing::error!("Failed to parse AIP response JSON: {}", e); 459 - AppError::Internal(format!("Failed to parse AIP response: {}", e)) 460 - })?; 461 - 462 - // Return the updated client details 463 - let response = OAuthClientDetails { 464 - client_id: aip_client.client_id, 465 - client_secret: aip_client.client_secret, 466 - client_name: aip_client.client_name, 467 - redirect_uris: aip_client.redirect_uris, 468 - grant_types: aip_client.grant_types, 469 - response_types: aip_client.response_types, 470 - scope: aip_client.scope, 471 - client_uri: aip_client.client_uri, 472 - logo_uri: aip_client.logo_uri, 473 - tos_uri: aip_client.tos_uri, 474 - policy_uri: aip_client.policy_uri, 475 - created_at: oauth_client.created_at, 476 - created_by_did: oauth_client.created_by_did, 477 - }; 478 - 479 - Ok(Json(serde_json::to_value(response).unwrap())) 480 - } 481 - 482 - pub async fn delete_oauth_client( 483 - State(state): State<AppState>, 484 - headers: HeaderMap, 485 - ExtractJson(request): ExtractJson<DeleteOAuthClientRequest>, 486 - ) -> Result<Json<DeleteOAuthClientResponse>, AppError> { 487 - let client_id = request.client_id; 488 - // Extract and verify authentication 489 - let token = auth::extract_bearer_token(&headers) 490 - .map_err(|_| AppError::BadRequest("Missing or invalid Authorization header".to_string()))?; 491 - auth::verify_oauth_token(&token, &state.config.auth_base_url).await 492 - .map_err(|_| AppError::BadRequest("Invalid or expired access token".to_string()))?; 493 - 494 - // Get the client from our database first 495 - let oauth_client = state.database 496 - .get_oauth_client_by_id(&client_id) 497 - .await 498 - .map_err(|e| AppError::Internal(format!("Failed to fetch OAuth client: {}", e)))? 499 - .ok_or_else(|| AppError::NotFound("OAuth client not found".to_string()))?; 500 - 501 - // Delete from AIP if we have a registration access token 502 - if let Some(registration_token) = &oauth_client.registration_access_token { 503 - let aip_base_url = &state.config.auth_base_url; 504 - 505 - let client = Client::new(); 506 - let _aip_response = client 507 - .delete(&format!("{}/oauth/clients/{}", aip_base_url, client_id)) 508 - .bearer_auth(registration_token) 509 - .send() 510 - .await; 511 - // We continue even if AIP deletion fails, as we want to clean up our database 512 - } 513 - 514 - // Delete from our database 515 - state.database 516 - .delete_oauth_client(&client_id) 517 - .await 518 - .map_err(|e| AppError::Internal(format!("Failed to delete OAuth client: {}", e)))?; 519 - 520 - Ok(Json(DeleteOAuthClientResponse { 521 - success: true, 522 - message: format!("OAuth client {} deleted successfully", client_id), 523 - })) 524 - }
+14 -24
api/src/api/openapi.rs
··· 787 787 fn create_record_schema_from_lexicon(lexicon_data: Option<&serde_json::Value>, slice_uri: &str) -> OpenApiSchema { 788 788 if let Some(lexicon) = lexicon_data { 789 789 // Get the definitions object directly (it's already parsed JSON, not a string) 790 - if let Some(definitions) = lexicon.get("defs") { 791 - if let Some(main_def) = definitions.get("main") { 792 - if let Some(record_def) = main_def.get("record") { 793 - if let Some(properties) = record_def.get("properties") { 790 + if let Some(definitions) = lexicon.get("defs") 791 + && let Some(main_def) = definitions.get("main") 792 + && let Some(record_def) = main_def.get("record") 793 + && let Some(properties) = record_def.get("properties") { 794 794 // Convert lexicon properties to OpenAPI schema properties 795 795 let mut openapi_props = HashMap::new(); 796 796 let mut required_fields = Vec::new(); 797 797 798 798 // Get required fields from record level 799 - if let Some(required_array) = record_def.get("required") { 800 - if let Some(required_list) = required_array.as_array() { 799 + if let Some(required_array) = record_def.get("required") 800 + && let Some(required_list) = required_array.as_array() { 801 801 for req_field in required_list { 802 802 if let Some(field_name) = req_field.as_str() { 803 803 required_fields.push(field_name.to_string()); 804 804 } 805 805 } 806 806 } 807 - } 808 807 809 808 let default_example = if let Some(props_obj) = properties.as_object() { 810 809 for (prop_name, prop_def) in props_obj { ··· 839 838 default: default_example, 840 839 }; 841 840 } 842 - } 843 - } 844 - } 845 841 } 846 842 847 843 // Fallback to generic object schema (without rkey - that's a separate request parameter) ··· 938 934 default: None, 939 935 }), 940 936 "array" => { 941 - if let Some(items_def) = prop_def.get("items") { 942 - if let Some(items_schema) = convert_lexicon_property_to_openapi(items_def) { 937 + if let Some(items_def) = prop_def.get("items") 938 + && let Some(items_schema) = convert_lexicon_property_to_openapi(items_def) { 943 939 return Some(OpenApiSchema { 944 940 schema_type: "array".to_string(), 945 941 format: None, ··· 949 945 default: None, 950 946 }); 951 947 } 952 - } 953 948 Some(OpenApiSchema { 954 949 schema_type: "array".to_string(), 955 950 format: None, ··· 1130 1125 where_conditions.insert("indexedAt".to_string(), where_condition_schema.clone()); 1131 1126 1132 1127 // Extract fields from lexicon if available 1133 - if let Some(lexicon) = lexicon_data { 1134 - if let Some(definitions) = lexicon.get("definitions").or_else(|| lexicon.get("defs")) { 1135 - if let Some(main_def) = definitions.get("main") { 1136 - if let Some(record_def) = main_def.get("record") { 1137 - if let Some(record_properties) = record_def.get("properties") { 1138 - if let Some(props_obj) = record_properties.as_object() { 1128 + if let Some(lexicon) = lexicon_data 1129 + && let Some(definitions) = lexicon.get("definitions").or_else(|| lexicon.get("defs")) 1130 + && let Some(main_def) = definitions.get("main") 1131 + && let Some(record_def) = main_def.get("record") 1132 + && let Some(record_properties) = record_def.get("properties") 1133 + && let Some(props_obj) = record_properties.as_object() { 1139 1134 for field_name in props_obj.keys() { 1140 1135 where_conditions.insert(field_name.clone(), where_condition_schema.clone()); 1141 1136 } 1142 1137 } 1143 - } 1144 - } 1145 - } 1146 - } 1147 - } 1148 1138 1149 1139 properties.insert("where".to_string(), OpenApiSchema { 1150 1140 schema_type: "object".to_string(),
-49
api/src/api/records.rs
··· 1 - use axum::{ 2 - extract::State, 3 - http::StatusCode, 4 - response::Json, 5 - }; 6 - use crate::models::{SliceRecordsParams, SliceRecordsOutput, IndexedRecord}; 7 - use crate::AppState; 8 - 9 - pub async fn get_records( 10 - State(state): State<AppState>, 11 - axum::extract::Json(params): axum::extract::Json<SliceRecordsParams>, 12 - ) -> Result<Json<SliceRecordsOutput>, StatusCode> { 13 - match get_slice_records(&state, &params).await { 14 - Ok(output) => Ok(Json(output)), 15 - Err(_) => Ok(Json(SliceRecordsOutput { 16 - success: false, 17 - records: vec![], 18 - cursor: None, 19 - message: Some("Failed to get slice records".to_string()), 20 - })), 21 - } 22 - } 23 - 24 - async fn get_slice_records(state: &AppState, params: &SliceRecordsParams) -> Result<SliceRecordsOutput, Box<dyn std::error::Error + Send + Sync>> { 25 - let (records, cursor) = state.database.get_slice_collections_records( 26 - &params.slice, 27 - params.limit, 28 - params.cursor.as_deref(), 29 - params.sort_by.as_ref(), 30 - params.where_clause.as_ref(), 31 - ).await?; 32 - 33 - // Transform Record to IndexedRecord for the response 34 - let indexed_records: Vec<IndexedRecord> = records.into_iter().map(|record| IndexedRecord { 35 - uri: record.uri, 36 - cid: record.cid, 37 - did: record.did, 38 - collection: record.collection, 39 - value: record.json, 40 - indexed_at: record.indexed_at.to_rfc3339(), 41 - }).collect(); 42 - 43 - Ok(SliceRecordsOutput { 44 - success: true, 45 - records: indexed_records, 46 - cursor, 47 - message: None, 48 - }) 49 - }
-50
api/src/api/sparkline.rs
··· 1 - use axum::{ 2 - extract::State, 3 - http::StatusCode, 4 - response::Json, 5 - }; 6 - use crate::models::{GetSparklinesParams, GetSparklinesOutput}; 7 - use crate::AppState; 8 - 9 - pub async fn batch_sparkline( 10 - State(state): State<AppState>, 11 - axum::extract::Json(params): axum::extract::Json<GetSparklinesParams>, 12 - ) -> Result<Json<GetSparklinesOutput>, StatusCode> { 13 - match get_batch_sparkline_data(&state, &params).await { 14 - Ok(output) => Ok(Json(output)), 15 - Err(_) => Ok(Json(GetSparklinesOutput { 16 - success: false, 17 - sparklines: std::collections::HashMap::new(), 18 - message: Some("Failed to get batch sparkline data".to_string()), 19 - })), 20 - } 21 - } 22 - 23 - async fn get_batch_sparkline_data( 24 - state: &AppState, 25 - params: &GetSparklinesParams, 26 - ) -> Result<GetSparklinesOutput, Box<dyn std::error::Error + Send + Sync>> { 27 - let interval = params.interval.as_deref().unwrap_or("hour"); 28 - let duration = params.duration.as_deref().unwrap_or("24h"); 29 - 30 - // Parse duration 31 - let duration_hours = match duration { 32 - "1h" => 1, 33 - "24h" => 24, 34 - "7d" => 24 * 7, 35 - "30d" => 24 * 30, 36 - _ => 24, // default to 24h 37 - }; 38 - 39 - let sparklines = state.database.get_batch_sparkline_data( 40 - &params.slices, 41 - interval, 42 - duration_hours, 43 - ).await?; 44 - 45 - Ok(GetSparklinesOutput { 46 - success: true, 47 - sparklines, 48 - message: None, 49 - }) 50 - }
-46
api/src/api/stats.rs
··· 1 - use axum::{ 2 - extract::State, 3 - http::StatusCode, 4 - response::Json, 5 - }; 6 - use crate::models::{SliceStatsOutput, SliceStatsParams}; 7 - use crate::AppState; 8 - 9 - pub async fn stats( 10 - State(state): State<AppState>, 11 - axum::extract::Json(params): axum::extract::Json<SliceStatsParams>, 12 - ) -> Result<Json<SliceStatsOutput>, StatusCode> { 13 - match get_slice_stats(&state, &params.slice).await { 14 - Ok(stats) => Ok(Json(stats)), 15 - Err(_) => Ok(Json(SliceStatsOutput { 16 - success: false, 17 - collections: vec![], 18 - collection_stats: vec![], 19 - total_lexicons: 0, 20 - total_records: 0, 21 - total_actors: 0, 22 - message: Some("Failed to get slice statistics".to_string()), 23 - })), 24 - } 25 - } 26 - 27 - async fn get_slice_stats(state: &AppState, slice_uri: &str) -> Result<SliceStatsOutput, Box<dyn std::error::Error + Send + Sync>> { 28 - // Get all the slice-specific data in parallel 29 - let (collections, collection_stats, total_lexicons, total_records, total_actors) = tokio::try_join!( 30 - state.database.get_slice_collections_list(slice_uri), 31 - state.database.get_slice_collection_stats(slice_uri), 32 - state.database.get_slice_lexicon_count(slice_uri), 33 - state.database.get_slice_total_records(slice_uri), 34 - state.database.get_slice_total_actors(slice_uri), 35 - )?; 36 - 37 - Ok(SliceStatsOutput { 38 - success: true, 39 - collections, 40 - collection_stats, 41 - total_lexicons, 42 - total_records, 43 - total_actors, 44 - message: None, 45 - }) 46 - }
-146
api/src/api/sync.rs
··· 1 - use crate::AppState; 2 - use crate::auth; 3 - use crate::jobs; 4 - use crate::models::BulkSyncParams; 5 - use axum::{ 6 - extract::State, 7 - http::{HeaderMap, StatusCode}, 8 - response::Json, 9 - }; 10 - use serde::{Deserialize, Serialize}; 11 - use tracing::{info, warn}; 12 - use uuid::Uuid; 13 - 14 - #[derive(Debug, Deserialize)] 15 - #[serde(rename_all = "camelCase")] 16 - pub struct SyncRequest { 17 - #[serde(flatten)] 18 - pub params: BulkSyncParams, 19 - pub slice: String, 20 - } 21 - 22 - #[derive(Debug, Serialize)] 23 - #[serde(rename_all = "camelCase")] 24 - pub struct SyncJobResponse { 25 - pub success: bool, 26 - pub job_id: Option<Uuid>, 27 - pub message: String, 28 - } 29 - 30 - pub async fn sync( 31 - State(state): State<AppState>, 32 - headers: HeaderMap, 33 - axum::extract::Json(request): axum::extract::Json<SyncRequest>, 34 - ) -> Result<Json<SyncJobResponse>, StatusCode> { 35 - let token = auth::extract_bearer_token(&headers)?; 36 - let user_info = auth::verify_oauth_token(&token, &state.config.auth_base_url).await?; 37 - 38 - let user_did = user_info.sub; 39 - let slice_uri = request.slice; 40 - 41 - match jobs::enqueue_sync_job(&state.database_pool, user_did, slice_uri, request.params).await { 42 - Ok(job_id) => Ok(Json(SyncJobResponse { 43 - success: true, 44 - job_id: Some(job_id), 45 - message: format!("Sync job {} enqueued successfully", job_id), 46 - })), 47 - Err(e) => { 48 - tracing::error!("Failed to enqueue sync job: {}", e); 49 - Ok(Json(SyncJobResponse { 50 - success: false, 51 - job_id: None, 52 - message: format!("Failed to enqueue sync job: {}", e), 53 - })) 54 - } 55 - } 56 - } 57 - 58 - #[derive(Deserialize)] 59 - #[serde(rename_all = "camelCase")] 60 - pub struct SyncUserCollectionsRequest { 61 - pub slice: String, 62 - #[serde(default = "default_timeout")] 63 - pub timeout_seconds: u64, 64 - } 65 - 66 - fn default_timeout() -> u64 { 67 - 30 68 - } 69 - 70 - pub async fn sync_user_collections( 71 - State(state): State<AppState>, 72 - headers: HeaderMap, 73 - Json(request): Json<SyncUserCollectionsRequest>, 74 - ) -> Result<Json<crate::sync::SyncUserCollectionsResult>, (StatusCode, Json<serde_json::Value>)> { 75 - let token = auth::extract_bearer_token(&headers).map_err(|e| { 76 - ( 77 - StatusCode::UNAUTHORIZED, 78 - Json(serde_json::json!({ 79 - "error": "AuthenticationRequired", 80 - "message": format!("Bearer token required: {}", e) 81 - })), 82 - ) 83 - })?; 84 - 85 - let user_info = auth::verify_oauth_token(&token, &state.config.auth_base_url) 86 - .await 87 - .map_err(|e| { 88 - ( 89 - StatusCode::UNAUTHORIZED, 90 - Json(serde_json::json!({ 91 - "error": "InvalidToken", 92 - "message": format!("Token verification failed: {}", e) 93 - })), 94 - ) 95 - })?; 96 - 97 - let user_did = user_info.did.unwrap_or(user_info.sub); 98 - 99 - info!( 100 - "🔄 Starting user collections sync for {} on slice {} (timeout: {}s)", 101 - user_did, request.slice, request.timeout_seconds 102 - ); 103 - 104 - if request.timeout_seconds > 300 { 105 - return Err(( 106 - StatusCode::BAD_REQUEST, 107 - Json(serde_json::json!({ 108 - "error": "InvalidTimeout", 109 - "message": "Maximum timeout is 300 seconds (5 minutes)" 110 - })), 111 - )); 112 - } 113 - 114 - let sync_service = 115 - crate::sync::SyncService::new(state.database.clone(), state.config.relay_endpoint.clone()); 116 - 117 - match sync_service 118 - .sync_user_collections(&user_did, &request.slice, request.timeout_seconds) 119 - .await 120 - { 121 - Ok(result) => { 122 - if result.timed_out { 123 - info!( 124 - "⏰ Sync timed out for user {}, suggesting async job", 125 - user_did 126 - ); 127 - } else { 128 - info!( 129 - "✅ Sync completed for user {}: {} repos, {} records", 130 - user_did, result.repos_processed, result.records_synced 131 - ); 132 - } 133 - Ok(Json(result)) 134 - } 135 - Err(e) => { 136 - warn!("❌ Sync failed for user {}: {}", user_did, e); 137 - Err(( 138 - StatusCode::INTERNAL_SERVER_ERROR, 139 - Json(serde_json::json!({ 140 - "error": "SyncFailed", 141 - "message": format!("Sync operation failed: {}", e) 142 - })), 143 - )) 144 - } 145 - } 146 - }
-73
api/src/api/upload_blob.rs
··· 1 - use axum::{ 2 - extract::{State, Request}, 3 - http::StatusCode, 4 - response::Json, 5 - }; 6 - use serde::{Deserialize, Serialize}; 7 - use crate::atproto_extensions::upload_blob as atproto_upload_blob; 8 - use axum::body::to_bytes; 9 - 10 - use crate::auth::{extract_bearer_token, verify_oauth_token, get_atproto_auth_for_user}; 11 - use crate::AppState; 12 - 13 - // We need to use atproto-client's internal HTTP mechanism instead of manual DPoP 14 - // Let me try a different approach - use the same HTTP client that create_record uses 15 - 16 - #[derive(Serialize, Deserialize)] 17 - pub struct BlobRef { 18 - #[serde(rename = "$type")] 19 - pub blob_type: String, 20 - pub r#ref: String, 21 - #[serde(rename = "mimeType")] 22 - pub mime_type: String, 23 - pub size: u64, 24 - } 25 - 26 - // Handler for com.atproto.repo.uploadBlob 27 - pub async fn upload_blob( 28 - State(state): State<AppState>, 29 - request: Request, 30 - ) -> Result<Json<serde_json::Value>, StatusCode> { 31 - 32 - // Extract headers from the request 33 - let headers = request.headers().clone(); 34 - 35 - // Extract and verify OAuth token 36 - let token = extract_bearer_token(&headers)?; 37 - let _user_info = verify_oauth_token(&token, &state.config.auth_base_url).await?; 38 - 39 - // Get AT Protocol DPoP auth and PDS URL 40 - let (dpop_auth, pds_url) = get_atproto_auth_for_user(&token, &state.config.auth_base_url).await?; 41 - 42 - // Get mime type from Content-Type header 43 - let mime_type = headers 44 - .get("content-type") 45 - .and_then(|v| v.to_str().ok()) 46 - .unwrap_or("application/octet-stream") 47 - .to_string(); 48 - 49 - // Extract binary data from request body 50 - let body = request.into_body(); 51 - let blob_data = to_bytes(body, usize::MAX) 52 - .await 53 - .map_err(|_| StatusCode::BAD_REQUEST)? 54 - .to_vec(); 55 - 56 - // Create HTTP client (same as dynamic handlers) 57 - let http_client = reqwest::Client::new(); 58 - 59 - // Use our atproto extension that follows the same pattern as create_record, put_record, etc. 60 - let upload_result = atproto_upload_blob( 61 - &http_client, 62 - &dpop_auth, 63 - &pds_url, 64 - blob_data, 65 - &mime_type 66 - ).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 67 - 68 - // Convert to the expected JSON response format 69 - let upload_response = serde_json::to_value(upload_result).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 70 - 71 - // Return the blob reference 72 - Ok(Json(upload_response)) 73 - }
-4
api/src/api/xrpc_dynamic.rs
··· 242 242 .collect(); 243 243 244 244 let output = SliceRecordsOutput { 245 - success: true, 246 245 records: indexed_records, 247 246 cursor, 248 - message: None, 249 247 }; 250 248 251 249 Ok(Json( ··· 369 367 .collect(); 370 368 371 369 let output = SliceRecordsOutput { 372 - success: true, 373 370 records: indexed_records, 374 371 cursor, 375 - message: None, 376 372 }; 377 373 378 374 Ok(Json(serde_json::to_value(output).map_err(|_| {
+1 -1
api/src/atproto_extensions.rs
··· 122 122 .body(data) 123 123 .send() 124 124 .await 125 - .map_err(|e| BlobUploadError::HttpRequest(e))?; 125 + .map_err(BlobUploadError::HttpRequest)?; 126 126 127 127 if !http_response.status().is_success() { 128 128 let status = http_response.status();
+2 -2
api/src/auth.rs
··· 80 80 // Extract PDS URL from session 81 81 let pds_url = session_data["pds_endpoint"] 82 82 .as_str() 83 - .ok_or_else(|| { 83 + .ok_or({ 84 84 StatusCode::INTERNAL_SERVER_ERROR 85 85 })? 86 86 .to_string(); ··· 89 89 // Extract AT Protocol access token from session data 90 90 let atproto_access_token = session_data["access_token"] 91 91 .as_str() 92 - .ok_or_else(|| { 92 + .ok_or({ 93 93 StatusCode::INTERNAL_SERVER_ERROR 94 94 })? 95 95 .to_string();
+8 -10
api/src/database.rs
··· 150 150 ) -> sqlx::query::QueryAs<'q, sqlx::Postgres, Record, sqlx::postgres::PgArguments> { 151 151 if let Some(clause) = where_clause { 152 152 // Bind AND condition parameters 153 - for (_, condition) in &clause.conditions { 153 + for condition in clause.conditions.values() { 154 154 query_builder = bind_single_condition(query_builder, condition); 155 155 } 156 156 157 157 // Bind OR condition parameters 158 158 if let Some(or_conditions) = &clause.or_conditions { 159 - for (_, condition) in or_conditions { 159 + for condition in or_conditions.values() { 160 160 query_builder = bind_single_condition(query_builder, condition); 161 161 } 162 162 } ··· 379 379 .bind(&record.did) 380 380 .bind(&record.collection) 381 381 .bind(&record.json) 382 - .bind(&record.indexed_at) 382 + .bind(record.indexed_at) 383 383 .bind(&record.slice_uri); 384 384 } 385 385 ··· 885 885 .as_ref() 886 886 .and_then(|wc| wc.conditions.get("collection")) 887 887 .and_then(|c| c.eq.as_ref()) 888 - .and_then(|v| v.as_str()) 889 - .map_or(false, |s| s == "network.slices.lexicon"); 888 + .and_then(|v| v.as_str()) == Some("network.slices.lexicon"); 890 889 891 890 if is_lexicon { 892 891 where_clauses.push(format!("json->>'slice' = ${}", param_count)); ··· 972 971 .as_ref() 973 972 .and_then(|wc| wc.conditions.get("collection")) 974 973 .and_then(|c| c.eq.as_ref()) 975 - .and_then(|v| v.as_str()) 976 - .map_or(false, |s| s == "network.slices.lexicon"); 974 + .and_then(|v| v.as_str()) == Some("network.slices.lexicon"); 977 975 978 976 if is_lexicon { 979 977 where_clauses.push(format!("json->>'slice' = ${}", param_count)); ··· 1009 1007 // Bind where condition values using helper 1010 1008 if let Some(clause) = where_clause { 1011 1009 // Bind AND condition parameters 1012 - for (_, condition) in &clause.conditions { 1010 + for condition in clause.conditions.values() { 1013 1011 if let Some(eq_value) = &condition.eq { 1014 1012 if let Some(str_val) = eq_value.as_str() { 1015 1013 query_builder = query_builder.bind(str_val); ··· 1031 1029 1032 1030 // Bind OR condition parameters 1033 1031 if let Some(or_conditions) = &clause.or_conditions { 1034 - for (_, condition) in or_conditions { 1032 + for condition in or_conditions.values() { 1035 1033 if let Some(eq_value) = &condition.eq { 1036 1034 if let Some(str_val) = eq_value.as_str() { 1037 1035 query_builder = query_builder.bind(str_val); ··· 1096 1094 .bind(&record.did) 1097 1095 .bind(&record.collection) 1098 1096 .bind(&record.json) 1099 - .bind(&record.indexed_at) 1097 + .bind(record.indexed_at) 1100 1098 .bind(&record.slice_uri) 1101 1099 .fetch_one(&self.pool) 1102 1100 .await?;
+29 -8
api/src/errors.rs
··· 58 58 59 59 #[error("error-slices-app-6 Bad request: {0}")] 60 60 BadRequest(String), 61 + 62 + #[error("error-slices-app-7 Authentication required: {0}")] 63 + AuthRequired(String), 64 + 65 + #[error("error-slices-app-8 Forbidden: {0}")] 66 + Forbidden(String), 67 + } 68 + 69 + impl From<StatusCode> for AppError { 70 + fn from(status: StatusCode) -> Self { 71 + match status { 72 + StatusCode::BAD_REQUEST => AppError::BadRequest("Bad request".to_string()), 73 + StatusCode::UNAUTHORIZED => AppError::AuthRequired("Authentication required".to_string()), 74 + StatusCode::FORBIDDEN => AppError::Forbidden("Forbidden".to_string()), 75 + StatusCode::NOT_FOUND => AppError::NotFound("Not found".to_string()), 76 + _ => AppError::Internal(format!("HTTP error: {}", status)), 77 + } 78 + } 61 79 } 62 80 63 81 #[derive(Error, Debug)] ··· 72 90 73 91 impl IntoResponse for AppError { 74 92 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()), 93 + let (status, error_name, error_message) = match &self { 94 + AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "BadRequest", msg.clone()), 95 + AppError::NotFound(msg) => (StatusCode::NOT_FOUND, "NotFound", msg.clone()), 96 + AppError::AuthRequired(msg) => (StatusCode::UNAUTHORIZED, "AuthenticationRequired", msg.clone()), 97 + AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, "Forbidden", msg.clone()), 98 + AppError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, "InternalServerError", msg.clone()), 99 + AppError::DatabaseConnection(e) => (StatusCode::INTERNAL_SERVER_ERROR, "InternalServerError", e.to_string()), 100 + AppError::Migration(e) => (StatusCode::INTERNAL_SERVER_ERROR, "InternalServerError", e.to_string()), 101 + AppError::ServerBind(e) => (StatusCode::INTERNAL_SERVER_ERROR, "InternalServerError", e.to_string()), 82 102 }; 83 103 84 104 let body = Json(serde_json::json!({ 85 - "error": error_message 105 + "error": error_name, 106 + "message": error_message 86 107 })); 87 108 88 109 (status, body).into_response()
+1 -1
api/src/jetstream.rs
··· 440 440 "uri": uri, 441 441 "rows_affected": rows_affected 442 442 })), 443 - Some(&slice_uri) 443 + Some(slice_uri) 444 444 ); 445 445 } 446 446
+1 -1
api/src/jobs.rs
··· 205 205 ); 206 206 207 207 let collections_json = serde_json::to_value(&result.collections_synced) 208 - .map_err(|e| sqlx::Error::Protocol(format!("Failed to serialize collections: {}", e).into()))?; 208 + .map_err(|e| sqlx::Error::Protocol(format!("Failed to serialize collections: {}", e)))?; 209 209 210 210 sqlx::query!( 211 211 r#"
+2 -2
api/src/logging.rs
··· 233 233 for entry in batch.iter() { 234 234 sqlx_query = sqlx_query 235 235 .bind(&entry.log_type) 236 - .bind(&entry.job_id) 236 + .bind(entry.job_id) 237 237 .bind(&entry.user_did) 238 238 .bind(&entry.slice_uri) 239 239 .bind(&entry.level) 240 240 .bind(&entry.message) 241 241 .bind(&entry.metadata) 242 - .bind(&entry.created_at); 242 + .bind(entry.created_at); 243 243 } 244 244 245 245 // Execute batch insert
+18 -18
api/src/main.rs
··· 9 9 mod logging; 10 10 mod models; 11 11 mod sync; 12 + mod xrpc; 12 13 13 14 use axum::{ 14 15 Router, ··· 20 21 use std::sync::atomic::AtomicBool; 21 22 use tower_http::{cors::CorsLayer, trace::TraceLayer}; 22 23 use tracing::info; 23 - use tracing_subscriber; 24 24 25 25 use crate::database::Database; 26 26 use crate::errors::AppError; ··· 277 277 // XRPC endpoints 278 278 .route( 279 279 "/xrpc/com.atproto.repo.uploadBlob", 280 - post(api::upload_blob::upload_blob), 280 + post(xrpc::com::atproto::repo::upload_blob::handler), 281 281 ) 282 282 .route( 283 283 "/xrpc/network.slices.slice.startSync", 284 - post(api::sync::sync), 284 + post(xrpc::network::slices::slice::start_sync::handler), 285 285 ) 286 286 .route( 287 287 "/xrpc/network.slices.slice.syncUserCollections", 288 - post(api::sync::sync_user_collections), 288 + post(xrpc::network::slices::slice::sync_user_collections::handler), 289 289 ) 290 290 .route( 291 291 "/xrpc/network.slices.slice.getJobStatus", 292 - get(api::jobs::get_job_status), 292 + get(xrpc::network::slices::slice::get_job_status::handler), 293 293 ) 294 294 .route( 295 295 "/xrpc/network.slices.slice.getJobHistory", 296 - get(api::jobs::get_slice_job_history), 296 + get(xrpc::network::slices::slice::get_job_history::handler), 297 297 ) 298 298 .route( 299 299 "/xrpc/network.slices.slice.getJobLogs", 300 - get(api::logs::get_sync_job_logs_handler), 300 + get(xrpc::network::slices::slice::get_job_logs::handler), 301 301 ) 302 302 .route( 303 303 "/xrpc/network.slices.slice.getJetstreamLogs", 304 - get(api::logs::get_jetstream_logs_handler), 304 + get(xrpc::network::slices::slice::get_jetstream_logs::handler), 305 305 ) 306 306 .route( 307 307 "/xrpc/network.slices.slice.stats", 308 - post(api::stats::stats), 308 + get(xrpc::network::slices::slice::stats::handler), 309 309 ) 310 310 .route( 311 311 "/xrpc/network.slices.slice.getSparklines", 312 - post(api::sparkline::batch_sparkline), 312 + post(xrpc::network::slices::slice::get_sparklines::handler), 313 313 ) 314 314 .route( 315 315 "/xrpc/network.slices.slice.getSliceRecords", 316 - post(api::records::get_records), 316 + post(xrpc::network::slices::slice::get_slice_records::handler), 317 317 ) 318 318 .route( 319 319 "/xrpc/network.slices.slice.openapi", 320 - get(api::openapi::get_openapi_spec), 320 + get(xrpc::network::slices::slice::openapi::handler), 321 321 ) 322 322 .route( 323 323 "/xrpc/network.slices.slice.getJetstreamStatus", 324 - get(api::jetstream::get_jetstream_status), 324 + get(xrpc::network::slices::slice::get_jetstream_status::handler), 325 325 ) 326 326 .route( 327 327 "/xrpc/network.slices.slice.getActors", 328 - post(api::actors::get_actors), 328 + post(xrpc::network::slices::slice::get_actors::handler), 329 329 ) 330 330 .route( 331 331 "/xrpc/network.slices.slice.createOAuthClient", 332 - post(api::oauth::create_oauth_client), 332 + post(xrpc::network::slices::slice::create_oauth_client::handler), 333 333 ) 334 334 .route( 335 335 "/xrpc/network.slices.slice.getOAuthClients", 336 - get(api::oauth::get_oauth_clients), 336 + get(xrpc::network::slices::slice::get_oauth_clients::handler), 337 337 ) 338 338 .route( 339 339 "/xrpc/network.slices.slice.updateOAuthClient", 340 - post(api::oauth::update_oauth_client), 340 + post(xrpc::network::slices::slice::update_oauth_client::handler), 341 341 ) 342 342 .route( 343 343 "/xrpc/network.slices.slice.deleteOAuthClient", 344 - post(api::oauth::delete_oauth_client), 344 + post(xrpc::network::slices::slice::delete_oauth_client::handler), 345 345 ) 346 346 // Dynamic collection-specific XRPC endpoints (wildcard routes must come last) 347 347 .route(
-111
api/src/models.rs
··· 26 26 pub indexed_at: String, 27 27 } 28 28 29 - #[derive(Debug, Serialize, Deserialize)] 30 - #[serde(rename_all = "camelCase")] 31 - pub struct ListRecordsOutput { 32 - pub records: Vec<IndexedRecord>, 33 - pub cursor: Option<String>, 34 - } 35 - 36 29 #[derive(Debug, Clone, Serialize, Deserialize)] 37 30 #[serde(rename_all = "camelCase")] 38 31 pub struct BulkSyncParams { ··· 43 36 pub skip_validation: Option<bool>, 44 37 } 45 38 46 - #[derive(Debug, Serialize, Deserialize)] 47 - #[serde(rename_all = "camelCase")] 48 - pub struct BulkSyncOutput { 49 - pub success: bool, 50 - pub total_records: i64, 51 - pub collections_synced: Vec<String>, 52 - pub repos_processed: i64, 53 - pub message: String, 54 - } 55 - 56 39 #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] 57 40 #[serde(rename_all = "camelCase")] 58 41 pub struct Actor { ··· 72 55 73 56 #[derive(Debug, Serialize, Deserialize)] 74 57 #[serde(rename_all = "camelCase")] 75 - pub struct SliceStatsParams { 76 - pub slice: String, 77 - } 78 - 79 - #[derive(Debug, Serialize, Deserialize)] 80 - #[serde(rename_all = "camelCase")] 81 - pub struct SliceStatsOutput { 82 - pub success: bool, 83 - pub collections: Vec<String>, 84 - pub collection_stats: Vec<CollectionStats>, 85 - pub total_lexicons: i64, 86 - pub total_records: i64, 87 - pub total_actors: i64, 88 - pub message: Option<String>, 89 - } 90 - 91 - #[derive(Debug, Serialize, Deserialize)] 92 - #[serde(rename_all = "camelCase")] 93 58 pub struct WhereCondition { 94 59 pub eq: Option<Value>, 95 60 #[serde(rename = "in")] ··· 128 93 #[derive(Debug, Serialize, Deserialize)] 129 94 #[serde(rename_all = "camelCase")] 130 95 pub struct SliceRecordsOutput { 131 - pub success: bool, 132 96 pub records: Vec<IndexedRecord>, 133 97 pub cursor: Option<String>, 134 - pub message: Option<String>, 135 98 } 136 99 137 100 #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] ··· 147 110 148 111 #[derive(Debug, Serialize, Deserialize)] 149 112 #[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 - } 206 - 207 - 208 - #[derive(Debug, Serialize, Deserialize)] 209 - #[serde(rename_all = "camelCase")] 210 113 pub struct SparklinePoint { 211 114 pub timestamp: String, // ISO 8601 212 115 pub count: i64, 213 116 } 214 117 215 - #[derive(Debug, Serialize, Deserialize)] 216 - #[serde(rename_all = "camelCase")] 217 - pub struct GetSparklinesParams { 218 - pub slices: Vec<String>, 219 - pub interval: Option<String>, // "hour", "day", "minute" - defaults to "hour" 220 - pub duration: Option<String>, // "24h", "7d", "30d" - defaults to "24h" 221 - } 222 118 223 - #[derive(Debug, Serialize, Deserialize)] 224 - #[serde(rename_all = "camelCase")] 225 - pub struct GetSparklinesOutput { 226 - pub success: bool, 227 - pub sparklines: std::collections::HashMap<String, Vec<SparklinePoint>>, 228 - pub message: Option<String>, 229 - }
+3 -4
api/src/sync.rs
··· 192 192 for collection in &all_collections { 193 193 requests_by_pds 194 194 .entry(pds_url.clone()) 195 - .or_insert_with(Vec::new) 195 + .or_default() 196 196 .push((repo.clone(), collection.clone())); 197 197 } 198 198 } ··· 535 535 536 536 for atproto_record in list_response.records { 537 537 // Check if we already have this record with the same CID 538 - if let Some(existing_cid) = existing_cids.get(&atproto_record.uri) { 539 - if existing_cid == &atproto_record.cid { 538 + if let Some(existing_cid) = existing_cids.get(&atproto_record.uri) 539 + && existing_cid == &atproto_record.cid { 540 540 // Record unchanged, skip it 541 541 skipped_count += 1; 542 542 continue; 543 543 } 544 - } 545 544 546 545 // Record is new or changed, include it 547 546 let record = Record {
+1
api/src/xrpc/com/atproto/mod.rs
··· 1 + pub mod repo;
+1
api/src/xrpc/com/atproto/repo/mod.rs
··· 1 + pub mod upload_blob;
+39
api/src/xrpc/com/atproto/repo/upload_blob.rs
··· 1 + use axum::{extract::{Request, State}, response::Json}; 2 + use axum::body::to_bytes; 3 + use crate::{AppState, auth, atproto_extensions::upload_blob as atproto_upload_blob, errors::AppError}; 4 + 5 + pub async fn handler( 6 + State(state): State<AppState>, 7 + request: Request, 8 + ) -> Result<Json<serde_json::Value>, AppError> { 9 + let headers = request.headers().clone(); 10 + 11 + let token = auth::extract_bearer_token(&headers)?; 12 + let _user_info = auth::verify_oauth_token(&token, &state.config.auth_base_url).await?; 13 + 14 + let (dpop_auth, pds_url) = 15 + auth::get_atproto_auth_for_user(&token, &state.config.auth_base_url).await?; 16 + 17 + let mime_type = headers 18 + .get("content-type") 19 + .and_then(|v| v.to_str().ok()) 20 + .unwrap_or("application/octet-stream") 21 + .to_string(); 22 + 23 + let body = request.into_body(); 24 + let blob_data = to_bytes(body, usize::MAX) 25 + .await 26 + .map_err(|_| AppError::BadRequest("Invalid request body".to_string()))? 27 + .to_vec(); 28 + 29 + let http_client = reqwest::Client::new(); 30 + 31 + let upload_result = atproto_upload_blob(&http_client, &dpop_auth, &pds_url, blob_data, &mime_type) 32 + .await 33 + .map_err(|e| AppError::Internal(format!("Failed to upload blob: {}", e)))?; 34 + 35 + let upload_response = serde_json::to_value(upload_result) 36 + .map_err(|e| AppError::Internal(format!("Failed to serialize response: {}", e)))?; 37 + 38 + Ok(Json(upload_response)) 39 + }
+1
api/src/xrpc/com/mod.rs
··· 1 + pub mod atproto;
+2
api/src/xrpc/mod.rs
··· 1 + pub mod com; 2 + pub mod network;
+1
api/src/xrpc/network/mod.rs
··· 1 + pub mod slices;
+1
api/src/xrpc/network/slices/mod.rs
··· 1 + pub mod slice;
+176
api/src/xrpc/network/slices/slice/create_oauth_client.rs
··· 1 + use axum::{extract::State, http::HeaderMap, response::Json}; 2 + use reqwest::Client; 3 + use serde::{Deserialize, Serialize}; 4 + use crate::{AppState, auth, errors::AppError}; 5 + 6 + #[derive(Debug, Deserialize)] 7 + #[serde(rename_all = "camelCase")] 8 + pub struct Params { 9 + pub slice_uri: String, 10 + pub client_name: String, 11 + pub redirect_uris: Vec<String>, 12 + pub grant_types: Option<Vec<String>>, 13 + pub response_types: Option<Vec<String>>, 14 + pub scope: Option<String>, 15 + pub client_uri: Option<String>, 16 + pub logo_uri: Option<String>, 17 + pub tos_uri: Option<String>, 18 + pub policy_uri: Option<String>, 19 + } 20 + 21 + #[derive(Debug, Serialize)] 22 + #[serde(rename_all = "camelCase")] 23 + pub struct Output { 24 + pub client_id: String, 25 + pub client_secret: Option<String>, 26 + pub client_name: String, 27 + pub redirect_uris: Vec<String>, 28 + pub grant_types: Vec<String>, 29 + pub response_types: Vec<String>, 30 + pub scope: Option<String>, 31 + pub client_uri: Option<String>, 32 + pub logo_uri: Option<String>, 33 + pub tos_uri: Option<String>, 34 + pub policy_uri: Option<String>, 35 + pub created_at: chrono::DateTime<chrono::Utc>, 36 + pub created_by_did: String, 37 + } 38 + 39 + #[derive(Debug, Serialize, Deserialize)] 40 + #[serde(rename_all = "snake_case")] 41 + struct AipClientRequest { 42 + pub client_name: String, 43 + pub redirect_uris: Vec<String>, 44 + #[serde(skip_serializing_if = "Option::is_none")] 45 + pub grant_types: Option<Vec<String>>, 46 + #[serde(skip_serializing_if = "Option::is_none")] 47 + pub response_types: Option<Vec<String>>, 48 + #[serde(skip_serializing_if = "Option::is_none")] 49 + pub scope: Option<String>, 50 + #[serde(skip_serializing_if = "Option::is_none")] 51 + pub client_uri: Option<String>, 52 + #[serde(skip_serializing_if = "Option::is_none")] 53 + pub logo_uri: Option<String>, 54 + #[serde(skip_serializing_if = "Option::is_none")] 55 + pub tos_uri: Option<String>, 56 + #[serde(skip_serializing_if = "Option::is_none")] 57 + pub policy_uri: Option<String>, 58 + } 59 + 60 + #[derive(Serialize, Deserialize)] 61 + #[serde(rename_all = "snake_case")] 62 + struct AipClientResponse { 63 + pub client_id: String, 64 + pub client_secret: Option<String>, 65 + pub registration_access_token: Option<String>, 66 + pub client_name: String, 67 + pub redirect_uris: Vec<String>, 68 + pub grant_types: Vec<String>, 69 + pub response_types: Vec<String>, 70 + pub scope: Option<String>, 71 + pub client_uri: Option<String>, 72 + pub logo_uri: Option<String>, 73 + pub tos_uri: Option<String>, 74 + pub policy_uri: Option<String>, 75 + } 76 + 77 + pub async fn handler( 78 + State(state): State<AppState>, 79 + headers: HeaderMap, 80 + Json(params): Json<Params>, 81 + ) -> Result<Json<Output>, AppError> { 82 + if params.client_name.trim().is_empty() { 83 + return Err(AppError::BadRequest("Client name cannot be empty".to_string())); 84 + } 85 + 86 + if params.redirect_uris.is_empty() { 87 + return Err(AppError::BadRequest( 88 + "At least one redirect URI is required".to_string(), 89 + )); 90 + } 91 + 92 + for uri in &params.redirect_uris { 93 + if !uri.starts_with("http://") && !uri.starts_with("https://") { 94 + return Err(AppError::BadRequest(format!( 95 + "Redirect URI must use HTTP or HTTPS: {}", 96 + uri 97 + ))); 98 + } 99 + if uri.trim().is_empty() { 100 + return Err(AppError::BadRequest("Redirect URI cannot be empty".to_string())); 101 + } 102 + } 103 + 104 + let token = auth::extract_bearer_token(&headers)?; 105 + let user_info = auth::verify_oauth_token(&token, &state.config.auth_base_url).await?; 106 + 107 + let user_did = user_info.sub; 108 + 109 + let client = Client::new(); 110 + let aip_request = AipClientRequest { 111 + client_name: params.client_name.clone(), 112 + redirect_uris: params.redirect_uris.clone(), 113 + grant_types: params.grant_types.clone(), 114 + response_types: params.response_types.clone(), 115 + scope: params.scope.clone(), 116 + client_uri: params.client_uri.clone(), 117 + logo_uri: params.logo_uri.clone(), 118 + tos_uri: params.tos_uri.clone(), 119 + policy_uri: params.policy_uri.clone(), 120 + }; 121 + 122 + let registration_url = format!("{}/oauth/clients/register", state.config.auth_base_url); 123 + 124 + let aip_response = client 125 + .post(&registration_url) 126 + .json(&aip_request) 127 + .send() 128 + .await 129 + .map_err(|e| AppError::Internal(format!("Failed to register client with AIP: {}", e)))?; 130 + 131 + if !aip_response.status().is_success() { 132 + let error_text = aip_response 133 + .text() 134 + .await 135 + .unwrap_or_else(|_| "Unknown error".to_string()); 136 + return Err(AppError::Internal(format!( 137 + "AIP registration failed: {}", 138 + error_text 139 + ))); 140 + } 141 + 142 + let response_body = aip_response 143 + .text() 144 + .await 145 + .map_err(|e| AppError::Internal(format!("Failed to get response body: {}", e)))?; 146 + 147 + let aip_client: AipClientResponse = serde_json::from_str(&response_body) 148 + .map_err(|e| AppError::Internal(format!("Failed to parse AIP response: {}", e)))?; 149 + 150 + let oauth_client = state 151 + .database 152 + .create_oauth_client( 153 + &params.slice_uri, 154 + &aip_client.client_id, 155 + aip_client.registration_access_token.as_deref(), 156 + &user_did, 157 + ) 158 + .await 159 + .map_err(|e| AppError::Internal(format!("Failed to store OAuth client: {}", e)))?; 160 + 161 + Ok(Json(Output { 162 + client_id: aip_client.client_id, 163 + client_secret: aip_client.client_secret, 164 + client_name: aip_client.client_name, 165 + redirect_uris: aip_client.redirect_uris, 166 + grant_types: aip_client.grant_types, 167 + response_types: aip_client.response_types, 168 + scope: aip_client.scope, 169 + client_uri: aip_client.client_uri, 170 + logo_uri: aip_client.logo_uri, 171 + tos_uri: aip_client.tos_uri, 172 + policy_uri: aip_client.policy_uri, 173 + created_at: oauth_client.created_at, 174 + created_by_did: oauth_client.created_by_did, 175 + })) 176 + }
+54
api/src/xrpc/network/slices/slice/delete_oauth_client.rs
··· 1 + use axum::{extract::State, http::HeaderMap, response::Json}; 2 + use reqwest::Client; 3 + use serde::{Deserialize, Serialize}; 4 + use crate::{AppState, auth, errors::AppError}; 5 + 6 + #[derive(Debug, Deserialize)] 7 + #[serde(rename_all = "camelCase")] 8 + pub struct Params { 9 + pub client_id: String, 10 + } 11 + 12 + #[derive(Debug, Serialize)] 13 + #[serde(rename_all = "camelCase")] 14 + pub struct Output { 15 + pub message: String, 16 + } 17 + 18 + pub async fn handler( 19 + State(state): State<AppState>, 20 + headers: HeaderMap, 21 + Json(params): Json<Params>, 22 + ) -> Result<Json<Output>, AppError> { 23 + let token = auth::extract_bearer_token(&headers)?; 24 + auth::verify_oauth_token(&token, &state.config.auth_base_url).await?; 25 + 26 + let oauth_client = state 27 + .database 28 + .get_oauth_client_by_id(&params.client_id) 29 + .await 30 + .map_err(|e| AppError::Internal(format!("Failed to fetch OAuth client: {}", e)))? 31 + .ok_or_else(|| AppError::NotFound("OAuth client not found".to_string()))?; 32 + 33 + if let Some(registration_token) = &oauth_client.registration_access_token { 34 + let client = Client::new(); 35 + let _aip_response = client 36 + .delete(format!( 37 + "{}/oauth/clients/{}", 38 + state.config.auth_base_url, params.client_id 39 + )) 40 + .bearer_auth(registration_token) 41 + .send() 42 + .await; 43 + } 44 + 45 + state 46 + .database 47 + .delete_oauth_client(&params.client_id) 48 + .await 49 + .map_err(|e| AppError::Internal(format!("Failed to delete OAuth client: {}", e)))?; 50 + 51 + Ok(Json(Output { 52 + message: format!("OAuth client {} deleted successfully", params.client_id), 53 + })) 54 + }
+39
api/src/xrpc/network/slices/slice/get_actors.rs
··· 1 + use axum::{extract::State, response::Json}; 2 + use serde::{Deserialize, Serialize}; 3 + use std::collections::HashMap; 4 + use crate::{AppState, errors::AppError, models::{Actor, WhereCondition}}; 5 + 6 + #[derive(Debug, Deserialize)] 7 + #[serde(rename_all = "camelCase")] 8 + pub struct Params { 9 + pub slice: String, 10 + pub limit: Option<i32>, 11 + pub cursor: Option<String>, 12 + #[serde(rename = "where")] 13 + pub where_conditions: Option<HashMap<String, WhereCondition>>, 14 + } 15 + 16 + #[derive(Debug, Serialize)] 17 + #[serde(rename_all = "camelCase")] 18 + pub struct Output { 19 + pub actors: Vec<Actor>, 20 + pub cursor: Option<String>, 21 + } 22 + 23 + pub async fn handler( 24 + State(state): State<AppState>, 25 + Json(params): Json<Params>, 26 + ) -> Result<Json<Output>, AppError> { 27 + let (actors, cursor) = state 28 + .database 29 + .get_slice_actors( 30 + &params.slice, 31 + params.limit, 32 + params.cursor.as_deref(), 33 + params.where_conditions.as_ref(), 34 + ) 35 + .await 36 + .map_err(|e| AppError::Internal(format!("Failed to fetch actors: {}", e)))?; 37 + 38 + Ok(Json(Output { actors, cursor })) 39 + }
+25
api/src/xrpc/network/slices/slice/get_jetstream_logs.rs
··· 1 + use axum::{extract::{Query, State}, response::Json}; 2 + use serde::{Deserialize, Serialize}; 3 + use crate::{AppState, errors::AppError, logging::{get_jetstream_logs, LogEntry}}; 4 + 5 + #[derive(Debug, Deserialize)] 6 + pub struct Params { 7 + pub limit: Option<i64>, 8 + pub slice: Option<String>, 9 + } 10 + 11 + #[derive(Debug, Serialize)] 12 + pub struct Output { 13 + pub logs: Vec<LogEntry>, 14 + } 15 + 16 + pub async fn handler( 17 + State(state): State<AppState>, 18 + Query(params): Query<Params>, 19 + ) -> Result<Json<Output>, AppError> { 20 + let logs = get_jetstream_logs(&state.database_pool, params.slice.as_deref(), params.limit) 21 + .await 22 + .map_err(|e| AppError::Internal(format!("Failed to get jetstream logs: {}", e)))?; 23 + 24 + Ok(Json(Output { logs })) 25 + }
+17
api/src/xrpc/network/slices/slice/get_jetstream_status.rs
··· 1 + use axum::{extract::State, response::Json}; 2 + use serde::Serialize; 3 + use crate::AppState; 4 + 5 + #[derive(Serialize)] 6 + #[serde(rename_all = "camelCase")] 7 + pub struct Output { 8 + pub connected: bool, 9 + } 10 + 11 + pub async fn handler(State(state): State<AppState>) -> Json<Output> { 12 + let connected = state 13 + .jetstream_connected 14 + .load(std::sync::atomic::Ordering::Relaxed); 15 + 16 + Json(Output { connected }) 17 + }
+26
api/src/xrpc/network/slices/slice/get_job_history.rs
··· 1 + use axum::{extract::{Query, State}, response::Json}; 2 + use serde::Deserialize; 3 + use crate::{AppState, errors::AppError, jobs}; 4 + 5 + #[derive(Debug, Deserialize)] 6 + #[serde(rename_all = "camelCase")] 7 + pub struct Params { 8 + pub user_did: String, 9 + pub slice_uri: String, 10 + pub limit: Option<i64>, 11 + } 12 + 13 + pub async fn handler( 14 + State(state): State<AppState>, 15 + Query(params): Query<Params>, 16 + ) -> Result<Json<Vec<jobs::JobStatus>>, AppError> { 17 + jobs::get_slice_job_history( 18 + &state.database_pool, 19 + &params.user_did, 20 + &params.slice_uri, 21 + params.limit, 22 + ) 23 + .await 24 + .map(Json) 25 + .map_err(|e| AppError::Internal(format!("Failed to get slice job history: {}", e))) 26 + }
+27
api/src/xrpc/network/slices/slice/get_job_logs.rs
··· 1 + use axum::{extract::{Query, State}, response::Json}; 2 + use serde::{Deserialize, Serialize}; 3 + use uuid::Uuid; 4 + use crate::{AppState, errors::AppError, logging::{get_sync_job_logs, LogEntry}}; 5 + 6 + #[derive(Debug, Deserialize)] 7 + #[serde(rename_all = "camelCase")] 8 + pub struct Params { 9 + pub job_id: Uuid, 10 + pub limit: Option<i64>, 11 + } 12 + 13 + #[derive(Debug, Serialize)] 14 + pub struct Output { 15 + pub logs: Vec<LogEntry>, 16 + } 17 + 18 + pub async fn handler( 19 + State(state): State<AppState>, 20 + Query(params): Query<Params>, 21 + ) -> Result<Json<Output>, AppError> { 22 + let logs = get_sync_job_logs(&state.database_pool, params.job_id, params.limit) 23 + .await 24 + .map_err(|e| AppError::Internal(format!("Failed to get sync job logs: {}", e)))?; 25 + 26 + Ok(Json(Output { logs })) 27 + }
+21
api/src/xrpc/network/slices/slice/get_job_status.rs
··· 1 + use axum::{extract::{Query, State}, response::Json}; 2 + use serde::Deserialize; 3 + use uuid::Uuid; 4 + use crate::{AppState, errors::AppError, jobs}; 5 + 6 + #[derive(Debug, Deserialize)] 7 + #[serde(rename_all = "camelCase")] 8 + pub struct Params { 9 + pub job_id: Uuid, 10 + } 11 + 12 + pub async fn handler( 13 + State(state): State<AppState>, 14 + Query(params): Query<Params>, 15 + ) -> Result<Json<jobs::JobStatus>, AppError> { 16 + match jobs::get_job_status(&state.database_pool, params.job_id).await { 17 + Ok(Some(status)) => Ok(Json(status)), 18 + Ok(None) => Err(AppError::NotFound(format!("Job {} not found", params.job_id))), 19 + Err(e) => Err(AppError::Internal(format!("Failed to get job status: {}", e))), 20 + } 21 + }
+126
api/src/xrpc/network/slices/slice/get_oauth_clients.rs
··· 1 + use axum::{extract::{Query, State}, http::HeaderMap, response::Json}; 2 + use reqwest::Client; 3 + use serde::{Deserialize, Serialize}; 4 + use crate::{AppState, auth, errors::AppError}; 5 + 6 + #[derive(Debug, Deserialize)] 7 + #[serde(rename_all = "camelCase")] 8 + pub struct Params { 9 + pub slice: String, 10 + } 11 + 12 + #[derive(Debug, Serialize)] 13 + #[serde(rename_all = "camelCase")] 14 + pub struct OAuthClientDetails { 15 + pub client_id: String, 16 + pub client_secret: Option<String>, 17 + pub client_name: String, 18 + pub redirect_uris: Vec<String>, 19 + pub grant_types: Vec<String>, 20 + pub response_types: Vec<String>, 21 + pub scope: Option<String>, 22 + pub client_uri: Option<String>, 23 + pub logo_uri: Option<String>, 24 + pub tos_uri: Option<String>, 25 + pub policy_uri: Option<String>, 26 + pub created_at: chrono::DateTime<chrono::Utc>, 27 + pub created_by_did: String, 28 + } 29 + 30 + #[derive(Debug, Serialize)] 31 + #[serde(rename_all = "camelCase")] 32 + pub struct Output { 33 + pub clients: Vec<OAuthClientDetails>, 34 + } 35 + 36 + #[derive(Serialize, Deserialize)] 37 + #[serde(rename_all = "snake_case")] 38 + struct AipClientResponse { 39 + pub client_id: String, 40 + pub client_secret: Option<String>, 41 + pub client_name: String, 42 + pub redirect_uris: Vec<String>, 43 + pub grant_types: Vec<String>, 44 + pub response_types: Vec<String>, 45 + pub scope: Option<String>, 46 + pub client_uri: Option<String>, 47 + pub logo_uri: Option<String>, 48 + pub tos_uri: Option<String>, 49 + pub policy_uri: Option<String>, 50 + } 51 + 52 + pub async fn handler( 53 + State(state): State<AppState>, 54 + headers: HeaderMap, 55 + Query(params): Query<Params>, 56 + ) -> Result<Json<Output>, AppError> { 57 + let token = auth::extract_bearer_token(&headers)?; 58 + auth::verify_oauth_token(&token, &state.config.auth_base_url).await?; 59 + 60 + let clients = state 61 + .database 62 + .get_oauth_clients_for_slice(&params.slice) 63 + .await 64 + .map_err(|e| AppError::Internal(format!("Failed to fetch OAuth clients: {}", e)))?; 65 + 66 + if clients.is_empty() { 67 + return Ok(Json(Output { clients: vec![] })); 68 + } 69 + 70 + let aip_base_url = &state.config.auth_base_url; 71 + let client = Client::new(); 72 + let mut client_details = Vec::new(); 73 + 74 + for oauth_client in clients { 75 + let aip_url = format!("{}/oauth/clients/{}", aip_base_url, oauth_client.client_id); 76 + 77 + let mut request_builder = client.get(&aip_url); 78 + if let Some(token) = &oauth_client.registration_access_token { 79 + request_builder = request_builder.bearer_auth(token); 80 + } 81 + 82 + match request_builder.send().await { 83 + Ok(response) if response.status().is_success() => { 84 + if let Ok(response_text) = response.text().await 85 + && let Ok(aip_client) = serde_json::from_str::<AipClientResponse>(&response_text) { 86 + client_details.push(OAuthClientDetails { 87 + client_id: aip_client.client_id, 88 + client_secret: aip_client.client_secret, 89 + client_name: aip_client.client_name, 90 + redirect_uris: aip_client.redirect_uris, 91 + grant_types: aip_client.grant_types, 92 + response_types: aip_client.response_types, 93 + scope: aip_client.scope, 94 + client_uri: aip_client.client_uri, 95 + logo_uri: aip_client.logo_uri, 96 + tos_uri: aip_client.tos_uri, 97 + policy_uri: aip_client.policy_uri, 98 + created_at: oauth_client.created_at, 99 + created_by_did: oauth_client.created_by_did, 100 + }); 101 + } 102 + } 103 + _ => { 104 + client_details.push(OAuthClientDetails { 105 + client_id: oauth_client.client_id, 106 + client_secret: None, 107 + client_name: "Unknown".to_string(), 108 + redirect_uris: vec![], 109 + grant_types: vec!["authorization_code".to_string()], 110 + response_types: vec!["code".to_string()], 111 + scope: None, 112 + client_uri: None, 113 + logo_uri: None, 114 + tos_uri: None, 115 + policy_uri: None, 116 + created_at: oauth_client.created_at, 117 + created_by_did: oauth_client.created_by_did, 118 + }); 119 + } 120 + } 121 + } 122 + 123 + Ok(Json(Output { 124 + clients: client_details, 125 + })) 126 + }
+67
api/src/xrpc/network/slices/slice/get_slice_records.rs
··· 1 + use axum::{extract::State, response::Json}; 2 + use serde::{Deserialize, Serialize}; 3 + use serde_json::Value; 4 + use crate::{AppState, errors::AppError, models::{WhereClause, SortField}}; 5 + 6 + #[derive(Debug, Deserialize)] 7 + #[serde(rename_all = "camelCase")] 8 + pub struct Params { 9 + pub slice: String, 10 + pub limit: Option<i32>, 11 + pub cursor: Option<String>, 12 + #[serde(rename = "where")] 13 + pub where_clause: Option<WhereClause>, 14 + pub sort_by: Option<Vec<SortField>>, 15 + } 16 + 17 + #[derive(Debug, Serialize)] 18 + #[serde(rename_all = "camelCase")] 19 + pub struct IndexedRecord { 20 + pub uri: String, 21 + pub cid: String, 22 + pub did: String, 23 + pub collection: String, 24 + pub value: Value, 25 + pub indexed_at: String, 26 + } 27 + 28 + #[derive(Debug, Serialize)] 29 + #[serde(rename_all = "camelCase")] 30 + pub struct Output { 31 + pub records: Vec<IndexedRecord>, 32 + pub cursor: Option<String>, 33 + } 34 + 35 + pub async fn handler( 36 + State(state): State<AppState>, 37 + Json(params): Json<Params>, 38 + ) -> Result<Json<Output>, AppError> { 39 + let (records, cursor) = state 40 + .database 41 + .get_slice_collections_records( 42 + &params.slice, 43 + params.limit, 44 + params.cursor.as_deref(), 45 + params.sort_by.as_ref(), 46 + params.where_clause.as_ref(), 47 + ) 48 + .await 49 + .map_err(|e| AppError::Internal(format!("Failed to get slice records: {}", e)))?; 50 + 51 + let indexed_records: Vec<IndexedRecord> = records 52 + .into_iter() 53 + .map(|record| IndexedRecord { 54 + uri: record.uri, 55 + cid: record.cid, 56 + did: record.did, 57 + collection: record.collection, 58 + value: record.json, 59 + indexed_at: record.indexed_at.to_rfc3339(), 60 + }) 61 + .collect(); 62 + 63 + Ok(Json(Output { 64 + records: indexed_records, 65 + cursor, 66 + })) 67 + }
+54
api/src/xrpc/network/slices/slice/get_sparklines.rs
··· 1 + use axum::{extract::State, response::Json}; 2 + use serde::{Deserialize, Serialize}; 3 + use crate::{AppState, errors::AppError, models::SparklinePoint}; 4 + 5 + #[derive(Debug, Deserialize)] 6 + #[serde(rename_all = "camelCase")] 7 + pub struct Params { 8 + pub slices: Vec<String>, 9 + pub interval: Option<String>, 10 + pub duration: Option<String>, 11 + } 12 + 13 + #[derive(Debug, Serialize)] 14 + #[serde(rename_all = "camelCase")] 15 + pub struct SparklineEntry { 16 + pub slice_uri: String, 17 + pub points: Vec<SparklinePoint>, 18 + } 19 + 20 + #[derive(Debug, Serialize)] 21 + #[serde(rename_all = "camelCase")] 22 + pub struct Output { 23 + pub sparklines: Vec<SparklineEntry>, 24 + } 25 + 26 + pub async fn handler( 27 + State(state): State<AppState>, 28 + Json(params): Json<Params>, 29 + ) -> Result<Json<Output>, AppError> { 30 + let interval = params.interval.as_deref().unwrap_or("hour"); 31 + let duration = params.duration.as_deref().unwrap_or("24h"); 32 + 33 + let duration_hours = match duration { 34 + "1h" => 1, 35 + "24h" => 24, 36 + "7d" => 24 * 7, 37 + "30d" => 24 * 30, 38 + _ => 24, 39 + }; 40 + 41 + let sparklines_map = state 42 + .database 43 + .get_batch_sparkline_data(&params.slices, interval, duration_hours) 44 + .await 45 + .map_err(|e| AppError::Internal(format!("Failed to get sparkline data: {}", e)))?; 46 + 47 + // Convert HashMap to array of SparklineEntry 48 + let sparklines = sparklines_map 49 + .into_iter() 50 + .map(|(slice_uri, points)| SparklineEntry { slice_uri, points }) 51 + .collect(); 52 + 53 + Ok(Json(Output { sparklines })) 54 + }
+16
api/src/xrpc/network/slices/slice/mod.rs
··· 1 + pub mod create_oauth_client; 2 + pub mod delete_oauth_client; 3 + pub mod get_actors; 4 + pub mod get_jetstream_logs; 5 + pub mod get_jetstream_status; 6 + pub mod get_job_history; 7 + pub mod get_job_logs; 8 + pub mod get_job_status; 9 + pub mod get_oauth_clients; 10 + pub mod get_slice_records; 11 + pub mod get_sparklines; 12 + pub mod openapi; 13 + pub mod start_sync; 14 + pub mod stats; 15 + pub mod sync_user_collections; 16 + pub mod update_oauth_client;
+1
api/src/xrpc/network/slices/slice/openapi.rs
··· 1 + pub use crate::api::openapi::get_openapi_spec as handler;
+40
api/src/xrpc/network/slices/slice/start_sync.rs
··· 1 + use axum::{extract::State, http::HeaderMap, response::Json}; 2 + use serde::{Deserialize, Serialize}; 3 + use uuid::Uuid; 4 + use crate::{AppState, auth, errors::AppError, jobs, models::BulkSyncParams}; 5 + 6 + #[derive(Debug, Deserialize)] 7 + #[serde(rename_all = "camelCase")] 8 + pub struct Params { 9 + #[serde(flatten)] 10 + pub sync_params: BulkSyncParams, 11 + pub slice: String, 12 + } 13 + 14 + #[derive(Debug, Serialize)] 15 + #[serde(rename_all = "camelCase")] 16 + pub struct Output { 17 + pub job_id: Uuid, 18 + pub message: String, 19 + } 20 + 21 + pub async fn handler( 22 + State(state): State<AppState>, 23 + headers: HeaderMap, 24 + Json(params): Json<Params>, 25 + ) -> Result<Json<Output>, AppError> { 26 + let token = auth::extract_bearer_token(&headers)?; 27 + let user_info = auth::verify_oauth_token(&token, &state.config.auth_base_url).await?; 28 + 29 + let user_did = user_info.sub; 30 + let slice_uri = params.slice; 31 + 32 + let job_id = jobs::enqueue_sync_job(&state.database_pool, user_did, slice_uri.clone(), params.sync_params) 33 + .await 34 + .map_err(|e| AppError::Internal(format!("Failed to enqueue sync job: {}", e)))?; 35 + 36 + Ok(Json(Output { 37 + job_id, 38 + message: format!("Sync job {} enqueued successfully", job_id), 39 + })) 40 + }
+48
api/src/xrpc/network/slices/slice/stats.rs
··· 1 + use axum::{extract::{State, Query}, response::Json}; 2 + use serde::{Deserialize, Serialize}; 3 + use crate::{AppState, errors::AppError, models::CollectionStats}; 4 + 5 + #[derive(Debug, Deserialize)] 6 + #[serde(rename_all = "camelCase")] 7 + pub struct Params { 8 + pub slice: String, 9 + } 10 + 11 + #[derive(Debug, Serialize)] 12 + #[serde(rename_all = "camelCase")] 13 + pub struct Output { 14 + pub collections: Vec<String>, 15 + pub collection_stats: Vec<CollectionStats>, 16 + pub total_lexicons: i64, 17 + pub total_records: i64, 18 + pub total_actors: i64, 19 + } 20 + 21 + pub async fn handler( 22 + State(state): State<AppState>, 23 + Query(params): Query<Params>, 24 + ) -> Result<Json<Output>, AppError> { 25 + let (collections, collection_stats, total_lexicons, total_records, total_actors) = tokio::try_join!( 26 + state.database.get_slice_collections_list(&params.slice), 27 + state.database.get_slice_collection_stats(&params.slice), 28 + state.database.get_slice_lexicon_count(&params.slice), 29 + state.database.get_slice_total_records(&params.slice), 30 + state.database.get_slice_total_actors(&params.slice), 31 + ) 32 + .map_err(|e| AppError::Internal(format!("Failed to get slice statistics: {}", e)))?; 33 + 34 + Ok(Json(Output { 35 + collections, 36 + collection_stats: collection_stats 37 + .into_iter() 38 + .map(|cs| CollectionStats { 39 + collection: cs.collection, 40 + record_count: cs.record_count, 41 + unique_actors: cs.unique_actors, 42 + }) 43 + .collect(), 44 + total_lexicons, 45 + total_records, 46 + total_actors, 47 + })) 48 + }
+60
api/src/xrpc/network/slices/slice/sync_user_collections.rs
··· 1 + use axum::{extract::State, http::HeaderMap, response::Json}; 2 + use serde::Deserialize; 3 + use crate::{AppState, auth, errors::AppError}; 4 + 5 + #[derive(Debug, Deserialize)] 6 + #[serde(rename_all = "camelCase")] 7 + pub struct Params { 8 + pub slice: String, 9 + #[serde(default = "default_timeout")] 10 + pub timeout_seconds: u64, 11 + } 12 + 13 + fn default_timeout() -> u64 { 14 + 30 15 + } 16 + 17 + pub async fn handler( 18 + State(state): State<AppState>, 19 + headers: HeaderMap, 20 + Json(params): Json<Params>, 21 + ) -> Result<Json<crate::sync::SyncUserCollectionsResult>, AppError> { 22 + let token = auth::extract_bearer_token(&headers)?; 23 + let user_info = auth::verify_oauth_token(&token, &state.config.auth_base_url).await?; 24 + 25 + let user_did = user_info.did.unwrap_or(user_info.sub); 26 + 27 + if params.timeout_seconds > 300 { 28 + return Err(AppError::BadRequest( 29 + "Maximum timeout is 300 seconds (5 minutes)".to_string(), 30 + )); 31 + } 32 + 33 + tracing::info!( 34 + "🔄 Starting user collections sync for {} on slice {} (timeout: {}s)", 35 + user_did, 36 + params.slice, 37 + params.timeout_seconds 38 + ); 39 + 40 + let sync_service = 41 + crate::sync::SyncService::new(state.database.clone(), state.config.relay_endpoint.clone()); 42 + 43 + let result = sync_service 44 + .sync_user_collections(&user_did, &params.slice, params.timeout_seconds) 45 + .await 46 + .map_err(|e| AppError::Internal(format!("Sync operation failed: {}", e)))?; 47 + 48 + if result.timed_out { 49 + tracing::info!("⏰ Sync timed out for user {}, suggesting async job", user_did); 50 + } else { 51 + tracing::info!( 52 + "✅ Sync completed for user {}: {} repos, {} records", 53 + user_did, 54 + result.repos_processed, 55 + result.records_synced 56 + ); 57 + } 58 + 59 + Ok(Json(result)) 60 + }
+144
api/src/xrpc/network/slices/slice/update_oauth_client.rs
··· 1 + use axum::{extract::State, http::HeaderMap, response::Json}; 2 + use reqwest::Client; 3 + use serde::{Deserialize, Serialize}; 4 + use crate::{AppState, auth, errors::AppError}; 5 + 6 + #[derive(Debug, Deserialize)] 7 + #[serde(rename_all = "camelCase")] 8 + pub struct Params { 9 + pub client_id: String, 10 + pub client_name: Option<String>, 11 + pub redirect_uris: Option<Vec<String>>, 12 + pub scope: Option<String>, 13 + pub client_uri: Option<String>, 14 + pub logo_uri: Option<String>, 15 + pub tos_uri: Option<String>, 16 + pub policy_uri: Option<String>, 17 + } 18 + 19 + #[derive(Debug, Serialize)] 20 + #[serde(rename_all = "camelCase")] 21 + pub struct Output { 22 + pub client_id: String, 23 + pub client_secret: Option<String>, 24 + pub client_name: String, 25 + pub redirect_uris: Vec<String>, 26 + pub grant_types: Vec<String>, 27 + pub response_types: Vec<String>, 28 + pub scope: Option<String>, 29 + pub client_uri: Option<String>, 30 + pub logo_uri: Option<String>, 31 + pub tos_uri: Option<String>, 32 + pub policy_uri: Option<String>, 33 + pub created_at: chrono::DateTime<chrono::Utc>, 34 + pub created_by_did: String, 35 + } 36 + 37 + #[derive(Debug, Serialize)] 38 + #[serde(rename_all = "snake_case")] 39 + struct AipClientRequest { 40 + pub client_name: String, 41 + pub redirect_uris: Vec<String>, 42 + #[serde(skip_serializing_if = "Option::is_none")] 43 + pub scope: Option<String>, 44 + #[serde(skip_serializing_if = "Option::is_none")] 45 + pub client_uri: Option<String>, 46 + #[serde(skip_serializing_if = "Option::is_none")] 47 + pub logo_uri: Option<String>, 48 + #[serde(skip_serializing_if = "Option::is_none")] 49 + pub tos_uri: Option<String>, 50 + #[serde(skip_serializing_if = "Option::is_none")] 51 + pub policy_uri: Option<String>, 52 + } 53 + 54 + #[derive(Deserialize)] 55 + #[serde(rename_all = "snake_case")] 56 + struct AipClientResponse { 57 + pub client_id: String, 58 + pub client_secret: Option<String>, 59 + pub client_name: String, 60 + pub redirect_uris: Vec<String>, 61 + pub grant_types: Vec<String>, 62 + pub response_types: Vec<String>, 63 + pub scope: Option<String>, 64 + pub client_uri: Option<String>, 65 + pub logo_uri: Option<String>, 66 + pub tos_uri: Option<String>, 67 + pub policy_uri: Option<String>, 68 + } 69 + 70 + pub async fn handler( 71 + State(state): State<AppState>, 72 + headers: HeaderMap, 73 + Json(params): Json<Params>, 74 + ) -> Result<Json<Output>, AppError> { 75 + let token = auth::extract_bearer_token(&headers)?; 76 + auth::verify_oauth_token(&token, &state.config.auth_base_url).await?; 77 + 78 + let oauth_client = state 79 + .database 80 + .get_oauth_client_by_id(&params.client_id) 81 + .await 82 + .map_err(|e| AppError::Internal(format!("Failed to fetch OAuth client: {}", e)))? 83 + .ok_or_else(|| AppError::NotFound("OAuth client not found".to_string()))?; 84 + 85 + let registration_token = oauth_client 86 + .registration_access_token 87 + .ok_or_else(|| AppError::Internal("Client missing registration access token".to_string()))?; 88 + 89 + let aip_request = AipClientRequest { 90 + client_name: params.client_name.unwrap_or_default(), 91 + redirect_uris: params.redirect_uris.unwrap_or_default(), 92 + scope: params.scope, 93 + client_uri: params.client_uri, 94 + logo_uri: params.logo_uri, 95 + tos_uri: params.tos_uri, 96 + policy_uri: params.policy_uri, 97 + }; 98 + 99 + let client = Client::new(); 100 + let update_url = format!( 101 + "{}/oauth/clients/{}", 102 + state.config.auth_base_url, params.client_id 103 + ); 104 + 105 + let aip_response = client 106 + .put(&update_url) 107 + .bearer_auth(&registration_token) 108 + .json(&aip_request) 109 + .send() 110 + .await 111 + .map_err(|e| AppError::Internal(format!("Failed to update client with AIP: {}", e)))?; 112 + 113 + if !aip_response.status().is_success() { 114 + let error_text = aip_response 115 + .text() 116 + .await 117 + .unwrap_or_else(|_| "Unknown error".to_string()); 118 + return Err(AppError::Internal(format!("AIP update failed: {}", error_text))); 119 + } 120 + 121 + let response_body = aip_response 122 + .text() 123 + .await 124 + .map_err(|e| AppError::Internal(format!("Failed to get response body: {}", e)))?; 125 + 126 + let aip_client: AipClientResponse = serde_json::from_str(&response_body) 127 + .map_err(|e| AppError::Internal(format!("Failed to parse AIP response: {}", e)))?; 128 + 129 + Ok(Json(Output { 130 + client_id: aip_client.client_id, 131 + client_secret: aip_client.client_secret, 132 + client_name: aip_client.client_name, 133 + redirect_uris: aip_client.redirect_uris, 134 + grant_types: aip_client.grant_types, 135 + response_types: aip_client.response_types, 136 + scope: aip_client.scope, 137 + client_uri: aip_client.client_uri, 138 + logo_uri: aip_client.logo_uri, 139 + tos_uri: aip_client.tos_uri, 140 + policy_uri: aip_client.policy_uri, 141 + created_at: oauth_client.created_at, 142 + created_by_did: oauth_client.created_by_did, 143 + })) 144 + }
+74
deno.lock
··· 2 2 "version": "5", 3 3 "specifiers": { 4 4 "jsr:@shikijs/shiki@^3.7.0": "3.7.0", 5 + "jsr:@std/assert@*": "1.0.14", 6 + "jsr:@std/cli@^1.0.21": "1.0.22", 7 + "jsr:@std/cli@^1.0.22": "1.0.22", 8 + "jsr:@std/encoding@^1.0.10": "1.0.10", 9 + "jsr:@std/fmt@^1.0.2": "1.0.8", 10 + "jsr:@std/fmt@^1.0.8": "1.0.8", 11 + "jsr:@std/fs@^1.0.19": "1.0.19", 12 + "jsr:@std/fs@^1.0.4": "1.0.19", 13 + "jsr:@std/html@^1.0.4": "1.0.4", 14 + "jsr:@std/http@^1.0.17": "1.0.20", 15 + "jsr:@std/internal@^1.0.10": "1.0.10", 16 + "jsr:@std/internal@^1.0.9": "1.0.10", 17 + "jsr:@std/media-types@^1.1.0": "1.1.0", 18 + "jsr:@std/net@^1.0.4": "1.0.6", 19 + "jsr:@std/path@^1.0.6": "1.1.2", 20 + "jsr:@std/path@^1.1.1": "1.1.2", 21 + "jsr:@std/streams@^1.0.10": "1.0.12", 5 22 "npm:@shikijs/core@^3.7.0": "3.13.0", 6 23 "npm:@shikijs/engine-oniguruma@^3.7.0": "3.13.0", 7 24 "npm:@shikijs/types@^3.7.0": "3.13.0", ··· 29 46 "npm:@shikijs/types", 30 47 "npm:shiki" 31 48 ] 49 + }, 50 + "@std/assert@1.0.14": { 51 + "integrity": "68d0d4a43b365abc927f45a9b85c639ea18a9fab96ad92281e493e4ed84abaa4", 52 + "dependencies": [ 53 + "jsr:@std/internal@^1.0.10" 54 + ] 55 + }, 56 + "@std/cli@1.0.22": { 57 + "integrity": "50d1e4f87887cb8a8afa29b88505ab5081188f5cad3985460c3b471fa49ff21a" 58 + }, 59 + "@std/encoding@1.0.10": { 60 + "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" 61 + }, 62 + "@std/fmt@1.0.8": { 63 + "integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7" 64 + }, 65 + "@std/fs@1.0.19": { 66 + "integrity": "051968c2b1eae4d2ea9f79a08a3845740ef6af10356aff43d3e2ef11ed09fb06", 67 + "dependencies": [ 68 + "jsr:@std/internal@^1.0.9", 69 + "jsr:@std/path@^1.1.1" 70 + ] 71 + }, 72 + "@std/html@1.0.4": { 73 + "integrity": "eff3497c08164e6ada49b7f81a28b5108087033823153d065e3f89467dd3d50e" 74 + }, 75 + "@std/http@1.0.20": { 76 + "integrity": "b5cc33fc001bccce65ed4c51815668c9891c69ccd908295997e983d8f56070a1", 77 + "dependencies": [ 78 + "jsr:@std/cli@^1.0.21", 79 + "jsr:@std/encoding", 80 + "jsr:@std/fmt@^1.0.8", 81 + "jsr:@std/fs@^1.0.19", 82 + "jsr:@std/html", 83 + "jsr:@std/media-types", 84 + "jsr:@std/net", 85 + "jsr:@std/path@^1.1.1", 86 + "jsr:@std/streams" 87 + ] 88 + }, 89 + "@std/internal@1.0.10": { 90 + "integrity": "e3be62ce42cab0e177c27698e5d9800122f67b766a0bea6ca4867886cbde8cf7" 91 + }, 92 + "@std/media-types@1.1.0": { 93 + "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" 94 + }, 95 + "@std/net@1.0.6": { 96 + "integrity": "110735f93e95bb9feb95790a8b1d1bf69ec0dc74f3f97a00a76ea5efea25500c" 97 + }, 98 + "@std/path@1.1.2": { 99 + "integrity": "c0b13b97dfe06546d5e16bf3966b1cadf92e1cc83e56ba5476ad8b498d9e3038", 100 + "dependencies": [ 101 + "jsr:@std/internal@^1.0.10" 102 + ] 103 + }, 104 + "@std/streams@1.0.12": { 105 + "integrity": "ae925fa1dc459b1abf5cbaa28cc5c7b0485853af3b2a384b0dc22d86e59dfbf4" 32 106 } 33 107 }, 34 108 "npm": {
+401 -298
frontend/src/client.ts
··· 1 1 // Generated TypeScript client for AT Protocol records 2 - // Generated at: 2025-09-24 17:50:48 UTC 3 - // Lexicons: 25 2 + // Generated at: 2025-09-26 18:40:59 UTC 3 + // Lexicons: 40 4 4 5 5 /** 6 6 * @example Usage ··· 53 53 type AuthProvider, 54 54 type BlobRef, 55 55 type CountRecordsResponse, 56 - type GetActorsParams, 57 - type GetActorsResponse, 58 56 type GetRecordParams, 59 57 type GetRecordsResponse, 60 58 type IndexedRecordFields, 61 59 type RecordResponse, 62 - type SliceLevelRecordsParams, 63 - type SliceRecordsOutput, 64 60 SlicesClient, 65 61 type SortField, 66 62 type WhereCondition, 67 63 } from "@slices/client"; 68 64 import type { OAuthClient } from "@slices/oauth"; 69 - 70 - export interface BulkSyncParams { 71 - collections?: string[]; 72 - externalCollections?: string[]; 73 - repos?: string[]; 74 - limitPerRepo?: number; 75 - } 76 - 77 - export interface BulkSyncOutput { 78 - success: boolean; 79 - totalRecords: number; 80 - collectionsSynced: string[]; 81 - reposProcessed: number; 82 - message: string; 83 - } 84 - 85 - export interface SyncJobResponse { 86 - success: boolean; 87 - jobId?: string; 88 - message: string; 89 - } 90 - 91 - export interface SyncJobResult { 92 - success: boolean; 93 - totalRecords: number; 94 - collectionsSynced: string[]; 95 - reposProcessed: number; 96 - message: string; 97 - } 98 - 99 - export interface JobStatus { 100 - jobId: string; 101 - status: string; 102 - createdAt: string; 103 - startedAt?: string; 104 - completedAt?: string; 105 - result?: SyncJobResult; 106 - error?: string; 107 - retryCount: number; 108 - } 109 - 110 - export interface GetJobStatusParams { 111 - jobId: string; 112 - } 113 - 114 - export interface GetJobHistoryParams { 115 - userDid: string; 116 - sliceUri: string; 117 - limit?: number; 118 - } 119 - 120 - export type GetJobHistoryResponse = JobStatus[]; 121 - 122 - export interface GetJobLogsParams { 123 - jobId: string; 124 - limit?: number; 125 - } 126 - 127 - export interface GetJobLogsResponse { 128 - logs: LogEntry[]; 129 - } 130 - 131 - export interface GetJetstreamLogsParams { 132 - limit?: number; 133 - } 134 - 135 - export interface GetJetstreamLogsResponse { 136 - logs: LogEntry[]; 137 - } 138 - 139 - export interface LogEntry { 140 - id: number; 141 - createdAt: string; 142 - logType: string; 143 - jobId?: string; 144 - userDid?: string; 145 - sliceUri?: string; 146 - level: string; 147 - message: string; 148 - metadata?: Record<string, unknown>; 149 - } 150 - 151 - export interface SyncUserCollectionsRequest { 152 - slice: string; 153 - timeoutSeconds?: number; 154 - } 155 - 156 - export interface SyncUserCollectionsResult { 157 - success: boolean; 158 - reposProcessed: number; 159 - recordsSynced: number; 160 - timedOut: boolean; 161 - message: string; 162 - } 163 - 164 - export interface JetstreamStatusResponse { 165 - connected: boolean; 166 - status: string; 167 - error?: string; 168 - } 169 - 170 - export interface CollectionStats { 171 - collection: string; 172 - recordCount: number; 173 - uniqueActors: number; 174 - } 175 - 176 - export interface SliceStatsParams { 177 - slice: string; 178 - } 179 - 180 - export interface SliceStatsOutput { 181 - success: boolean; 182 - collections: string[]; 183 - collectionStats: CollectionStats[]; 184 - totalLexicons: number; 185 - totalRecords: number; 186 - totalActors: number; 187 - message?: string; 188 - } 189 - 190 - export interface GetSparklinesParams { 191 - slices: string[]; 192 - interval?: string; 193 - duration?: string; 194 - } 195 - 196 - export interface GetSparklinesOutput { 197 - success: boolean; 198 - sparklines: Record<string, NetworkSlicesSliceDefs["SparklinePoint"][]>; 199 - message?: string; 200 - } 201 - 202 - export interface CreateOAuthClientRequest { 203 - clientName: string; 204 - redirectUris: string[]; 205 - grantTypes?: string[]; 206 - responseTypes?: string[]; 207 - scope?: string; 208 - clientUri?: string; 209 - logoUri?: string; 210 - tosUri?: string; 211 - policyUri?: string; 212 - } 213 - 214 - export interface OAuthClientDetails { 215 - clientId: string; 216 - clientSecret?: string; 217 - clientName: string; 218 - redirectUris: string[]; 219 - grantTypes: string[]; 220 - responseTypes: string[]; 221 - scope?: string; 222 - clientUri?: string; 223 - logoUri?: string; 224 - tosUri?: string; 225 - policyUri?: string; 226 - createdAt: string; 227 - createdByDid: string; 228 - } 229 - 230 - export interface ListOAuthClientsResponse { 231 - clients: OAuthClientDetails[]; 232 - } 233 - 234 - export interface UpdateOAuthClientRequest { 235 - clientId: string; 236 - clientName?: string; 237 - redirectUris?: string[]; 238 - scope?: string; 239 - clientUri?: string; 240 - logoUri?: string; 241 - tosUri?: string; 242 - policyUri?: string; 243 - } 244 - 245 - export interface DeleteOAuthClientResponse { 246 - success: boolean; 247 - message: string; 248 - } 249 - 250 - export interface OAuthOperationError { 251 - success: false; 252 - message: string; 253 - } 254 65 255 66 export type AppBskyGraphDefsListPurpose = 256 67 | "app.bsky.graph.defs#modlist" ··· 1144 955 | "createdAt" 1145 956 | "expiresAt"; 1146 957 958 + export interface NetworkSlicesSliceSyncUserCollectionsInput { 959 + slice: string; 960 + timeoutSeconds?: number; 961 + } 962 + 963 + export interface NetworkSlicesSliceSyncUserCollectionsOutput { 964 + reposProcessed: number; 965 + recordsSynced: number; 966 + timedOut: boolean; 967 + } 968 + 1147 969 export interface NetworkSlicesSliceDefsSliceView { 1148 970 uri: string; 1149 971 cid: string; ··· 1169 991 count: number; 1170 992 } 1171 993 994 + export interface NetworkSlicesSliceGetSparklinesInput { 995 + slices: string[]; 996 + interval?: string; 997 + duration?: string; 998 + } 999 + 1000 + export interface NetworkSlicesSliceGetSparklinesOutput { 1001 + sparklines: NetworkSlicesSliceGetSparklines["SparklineEntry"][]; 1002 + } 1003 + 1004 + export interface NetworkSlicesSliceGetSparklinesSparklineEntry { 1005 + /** AT-URI of the slice */ 1006 + sliceUri: string; 1007 + /** Array of sparkline data points */ 1008 + points: NetworkSlicesSliceDefs["SparklinePoint"][]; 1009 + } 1010 + 1011 + export interface NetworkSlicesSliceGetJobLogsParams { 1012 + jobId: string; 1013 + limit?: number; 1014 + } 1015 + 1016 + export interface NetworkSlicesSliceGetJobLogsOutput { 1017 + logs: NetworkSlicesSliceGetJobLogs["LogEntry"][]; 1018 + } 1019 + 1020 + export interface NetworkSlicesSliceGetJobLogsLogEntry { 1021 + /** Log entry ID */ 1022 + id: number; 1023 + /** When the log entry was created */ 1024 + createdAt: string; 1025 + /** Type of log entry */ 1026 + logType: string; 1027 + /** UUID of related job if applicable */ 1028 + jobId?: string; 1029 + /** DID of related user if applicable */ 1030 + userDid?: string; 1031 + /** AT-URI of related slice if applicable */ 1032 + sliceUri?: string; 1033 + /** Log level */ 1034 + level: string; 1035 + /** Log message */ 1036 + message: string; 1037 + /** Additional metadata associated with the log entry */ 1038 + metadata?: unknown; 1039 + } 1040 + 1041 + export interface NetworkSlicesSliceGetJetstreamStatusOutput { 1042 + connected: boolean; 1043 + } 1044 + 1172 1045 export interface NetworkSlicesSlice { 1173 1046 /** Name of the slice */ 1174 1047 name: string; ··· 1180 1053 1181 1054 export type NetworkSlicesSliceSortFields = "name" | "domain" | "createdAt"; 1182 1055 1056 + export interface NetworkSlicesSliceGetJobStatusParams { 1057 + jobId: string; 1058 + } 1059 + 1060 + export type NetworkSlicesSliceGetJobStatusOutput = 1061 + NetworkSlicesSliceGetJobStatus["JobStatus"]; 1062 + 1063 + export interface NetworkSlicesSliceGetJobStatusJobStatus { 1064 + /** UUID of the job */ 1065 + jobId: string; 1066 + /** Current status of the job */ 1067 + status: string; 1068 + /** When the job was created */ 1069 + createdAt: string; 1070 + /** When the job started executing */ 1071 + startedAt?: string; 1072 + /** When the job completed */ 1073 + completedAt?: string; 1074 + /** Job result if completed successfully */ 1075 + result?: NetworkSlicesSliceGetJobStatus["SyncJobResult"]; 1076 + /** Error message if job failed */ 1077 + error?: string; 1078 + /** Number of times the job has been retried */ 1079 + retryCount: number; 1080 + } 1081 + 1082 + export interface NetworkSlicesSliceGetJobStatusSyncJobResult { 1083 + /** Whether the sync job completed successfully */ 1084 + success: boolean; 1085 + /** Total number of records synced */ 1086 + totalRecords: number; 1087 + /** List of collection NSIDs that were synced */ 1088 + collectionsSynced: string[]; 1089 + /** Number of repositories processed */ 1090 + reposProcessed: number; 1091 + /** Human-readable message about the job completion */ 1092 + message: string; 1093 + } 1094 + 1095 + export interface NetworkSlicesSliceGetActorsInput { 1096 + slice: string; 1097 + limit?: number; 1098 + cursor?: string; 1099 + where?: unknown; 1100 + } 1101 + 1102 + export interface NetworkSlicesSliceGetActorsOutput { 1103 + actors: NetworkSlicesSliceGetActors["Actor"][]; 1104 + cursor?: string; 1105 + } 1106 + 1107 + export interface NetworkSlicesSliceGetActorsActor { 1108 + /** Decentralized identifier of the actor */ 1109 + did: string; 1110 + /** Human-readable handle of the actor */ 1111 + handle?: string; 1112 + /** AT-URI of the slice this actor is indexed in */ 1113 + sliceUri: string; 1114 + /** When this actor was indexed */ 1115 + indexedAt: string; 1116 + } 1117 + 1118 + export interface NetworkSlicesSliceDeleteOAuthClientInput { 1119 + clientId: string; 1120 + } 1121 + 1122 + export interface NetworkSlicesSliceDeleteOAuthClientOutput { 1123 + message: string; 1124 + } 1125 + 1126 + export interface NetworkSlicesSliceCreateOAuthClientInput { 1127 + sliceUri: string; 1128 + clientName: string; 1129 + redirectUris: string[]; 1130 + grantTypes?: string[]; 1131 + responseTypes?: string[]; 1132 + scope?: string; 1133 + clientUri?: string; 1134 + logoUri?: string; 1135 + tosUri?: string; 1136 + policyUri?: string; 1137 + } 1138 + 1139 + export type NetworkSlicesSliceCreateOAuthClientOutput = 1140 + NetworkSlicesSliceGetOAuthClients["OauthClientDetails"]; 1141 + 1142 + export interface NetworkSlicesSliceGetJetstreamLogsParams { 1143 + slice?: string; 1144 + limit?: number; 1145 + } 1146 + 1147 + export interface NetworkSlicesSliceGetJetstreamLogsOutput { 1148 + logs: NetworkSlicesSliceGetJobLogs["LogEntry"][]; 1149 + } 1150 + 1151 + export interface NetworkSlicesSliceGetSliceRecordsInput { 1152 + slice: string; 1153 + limit?: number; 1154 + cursor?: string; 1155 + where?: unknown; 1156 + sortBy?: unknown; 1157 + } 1158 + 1159 + export interface NetworkSlicesSliceGetSliceRecordsOutput { 1160 + records: NetworkSlicesSliceGetSliceRecords["IndexedRecord"][]; 1161 + cursor?: string; 1162 + } 1163 + 1164 + export interface NetworkSlicesSliceGetSliceRecordsIndexedRecord { 1165 + /** AT-URI of the record */ 1166 + uri: string; 1167 + /** Content identifier of the record */ 1168 + cid: string; 1169 + /** DID of the record creator */ 1170 + did: string; 1171 + /** NSID of the collection this record belongs to */ 1172 + collection: string; 1173 + /** The record value/content */ 1174 + value: unknown; 1175 + /** When this record was indexed */ 1176 + indexedAt: string; 1177 + } 1178 + 1179 + export interface NetworkSlicesSliceStartSyncInput { 1180 + slice: string; 1181 + collections?: string[]; 1182 + externalCollections?: string[]; 1183 + repos?: string[]; 1184 + limitPerRepo?: number; 1185 + skipValidation?: boolean; 1186 + } 1187 + 1188 + export interface NetworkSlicesSliceStartSyncOutput { 1189 + jobId: string; 1190 + message: string; 1191 + } 1192 + 1193 + export interface NetworkSlicesSliceGetJobHistoryParams { 1194 + userDid: string; 1195 + sliceUri: string; 1196 + limit?: number; 1197 + } 1198 + 1199 + export type NetworkSlicesSliceGetJobHistoryOutput = 1200 + NetworkSlicesSliceGetJobStatus["JobStatus"][]; 1201 + 1202 + export interface NetworkSlicesSliceStatsParams { 1203 + slice: string; 1204 + } 1205 + 1206 + export interface NetworkSlicesSliceStatsOutput { 1207 + collections: string[]; 1208 + collectionStats: NetworkSlicesSliceStats["CollectionStats"][]; 1209 + totalLexicons: number; 1210 + totalRecords: number; 1211 + totalActors: number; 1212 + } 1213 + 1214 + export interface NetworkSlicesSliceStatsCollectionStats { 1215 + /** Collection NSID */ 1216 + collection: string; 1217 + /** Number of records in this collection */ 1218 + recordCount: number; 1219 + /** Number of unique actors with records in this collection */ 1220 + uniqueActors: number; 1221 + } 1222 + 1223 + export interface NetworkSlicesSliceUpdateOAuthClientInput { 1224 + clientId: string; 1225 + clientName?: string; 1226 + redirectUris?: string[]; 1227 + scope?: string; 1228 + clientUri?: string; 1229 + logoUri?: string; 1230 + tosUri?: string; 1231 + policyUri?: string; 1232 + } 1233 + 1234 + export type NetworkSlicesSliceUpdateOAuthClientOutput = 1235 + NetworkSlicesSliceGetOAuthClients["OauthClientDetails"]; 1236 + 1237 + export interface NetworkSlicesSliceGetOAuthClientsParams { 1238 + slice: string; 1239 + } 1240 + 1241 + export interface NetworkSlicesSliceGetOAuthClientsOutput { 1242 + clients: NetworkSlicesSliceGetOAuthClients["OauthClientDetails"][]; 1243 + } 1244 + 1245 + export interface NetworkSlicesSliceGetOAuthClientsOauthClientDetails { 1246 + /** OAuth client ID */ 1247 + clientId: string; 1248 + /** OAuth client secret (only returned on creation) */ 1249 + clientSecret?: string; 1250 + /** Human-readable name of the OAuth client */ 1251 + clientName: string; 1252 + /** Allowed redirect URIs for OAuth flow */ 1253 + redirectUris: string[]; 1254 + /** Allowed OAuth grant types */ 1255 + grantTypes: string[]; 1256 + /** Allowed OAuth response types */ 1257 + responseTypes: string[]; 1258 + /** OAuth scope */ 1259 + scope?: string; 1260 + /** URI of the client application */ 1261 + clientUri?: string; 1262 + /** URI of the client logo */ 1263 + logoUri?: string; 1264 + /** URI of the terms of service */ 1265 + tosUri?: string; 1266 + /** URI of the privacy policy */ 1267 + policyUri?: string; 1268 + /** When the OAuth client was created */ 1269 + createdAt: string; 1270 + /** DID of the user who created this client */ 1271 + createdByDid: string; 1272 + } 1273 + 1183 1274 export interface NetworkSlicesLexicon { 1184 1275 /** Namespaced identifier for the lexicon */ 1185 1276 nsid: string; ··· 1438 1529 export interface NetworkSlicesSliceDefs { 1439 1530 readonly SliceView: NetworkSlicesSliceDefsSliceView; 1440 1531 readonly SparklinePoint: NetworkSlicesSliceDefsSparklinePoint; 1532 + } 1533 + 1534 + export interface NetworkSlicesSliceGetSparklines { 1535 + readonly SparklineEntry: NetworkSlicesSliceGetSparklinesSparklineEntry; 1536 + } 1537 + 1538 + export interface NetworkSlicesSliceGetJobLogs { 1539 + readonly LogEntry: NetworkSlicesSliceGetJobLogsLogEntry; 1540 + } 1541 + 1542 + export interface NetworkSlicesSliceGetJobStatus { 1543 + readonly JobStatus: NetworkSlicesSliceGetJobStatusJobStatus; 1544 + readonly SyncJobResult: NetworkSlicesSliceGetJobStatusSyncJobResult; 1545 + } 1546 + 1547 + export interface NetworkSlicesSliceGetActors { 1548 + readonly Actor: NetworkSlicesSliceGetActorsActor; 1549 + } 1550 + 1551 + export interface NetworkSlicesSliceGetSliceRecords { 1552 + readonly IndexedRecord: NetworkSlicesSliceGetSliceRecordsIndexedRecord; 1553 + } 1554 + 1555 + export interface NetworkSlicesSliceStats { 1556 + readonly CollectionStats: NetworkSlicesSliceStatsCollectionStats; 1557 + } 1558 + 1559 + export interface NetworkSlicesSliceGetOAuthClients { 1560 + readonly OauthClientDetails: 1561 + NetworkSlicesSliceGetOAuthClientsOauthClientDetails; 1441 1562 } 1442 1563 1443 1564 export interface NetworkSlicesActorDefs { ··· 2073 2194 return await this.client.deleteRecord("network.slices.slice", rkey); 2074 2195 } 2075 2196 2076 - async stats(params: SliceStatsParams): Promise<SliceStatsOutput> { 2077 - return await this.client.makeRequest<SliceStatsOutput>( 2078 - "network.slices.slice.stats", 2079 - "POST", 2080 - params, 2081 - ); 2197 + async syncUserCollections( 2198 + input: NetworkSlicesSliceSyncUserCollectionsInput, 2199 + ): Promise<NetworkSlicesSliceSyncUserCollectionsOutput> { 2200 + return await this.client.makeRequest< 2201 + NetworkSlicesSliceSyncUserCollectionsOutput 2202 + >("network.slices.slice.syncUserCollections", "POST", input); 2082 2203 } 2083 2204 2084 2205 async getSparklines( 2085 - params: GetSparklinesParams, 2086 - ): Promise<GetSparklinesOutput> { 2087 - return await this.client.makeRequest<GetSparklinesOutput>( 2206 + input: NetworkSlicesSliceGetSparklinesInput, 2207 + ): Promise<NetworkSlicesSliceGetSparklinesOutput> { 2208 + return await this.client.makeRequest<NetworkSlicesSliceGetSparklinesOutput>( 2088 2209 "network.slices.slice.getSparklines", 2089 2210 "POST", 2090 - params, 2211 + input, 2091 2212 ); 2092 2213 } 2093 2214 2094 - async getSliceRecords<T = Record<string, unknown>>( 2095 - params: Omit<SliceLevelRecordsParams<T>, "slice">, 2096 - ): Promise<SliceRecordsOutput<T>> { 2097 - // Combine where and orWhere into the expected backend format 2098 - const whereClause: Record<string, unknown> = params?.where 2099 - ? { ...params.where } 2100 - : {}; 2101 - if (params?.orWhere) { 2102 - whereClause.$or = params.orWhere; 2103 - } 2104 - 2105 - const requestParams = { 2106 - ...params, 2107 - where: Object.keys(whereClause).length > 0 ? whereClause : undefined, 2108 - orWhere: undefined, // Remove orWhere as it's now in where.$or 2109 - slice: this.client.sliceUri, 2110 - }; 2111 - return await this.client.makeRequest<SliceRecordsOutput<T>>( 2112 - "network.slices.slice.getSliceRecords", 2113 - "POST", 2114 - requestParams, 2115 - ); 2116 - } 2117 - 2118 - async getActors(params?: GetActorsParams): Promise<GetActorsResponse> { 2119 - const requestParams = { ...params, slice: this.client.sliceUri }; 2120 - return await this.client.makeRequest<GetActorsResponse>( 2121 - "network.slices.slice.getActors", 2122 - "POST", 2123 - requestParams, 2215 + async getJobLogs( 2216 + params?: NetworkSlicesSliceGetJobLogsParams, 2217 + ): Promise<NetworkSlicesSliceGetJobLogsOutput> { 2218 + return await this.client.makeRequest<NetworkSlicesSliceGetJobLogsOutput>( 2219 + "network.slices.slice.getJobLogs", 2220 + "GET", 2221 + params, 2124 2222 ); 2125 2223 } 2126 2224 2127 - async startSync(params: BulkSyncParams): Promise<SyncJobResponse> { 2128 - const requestParams = { ...params, slice: this.client.sliceUri }; 2129 - return await this.client.makeRequest<SyncJobResponse>( 2130 - "network.slices.slice.startSync", 2131 - "POST", 2132 - requestParams, 2133 - ); 2225 + async getJetstreamStatus(): Promise< 2226 + NetworkSlicesSliceGetJetstreamStatusOutput 2227 + > { 2228 + return await this.client.makeRequest< 2229 + NetworkSlicesSliceGetJetstreamStatusOutput 2230 + >("network.slices.slice.getJetstreamStatus", "GET", {}); 2134 2231 } 2135 2232 2136 - async getJobStatus(params: GetJobStatusParams): Promise<JobStatus> { 2137 - return await this.client.makeRequest<JobStatus>( 2233 + async getJobStatus( 2234 + params?: NetworkSlicesSliceGetJobStatusParams, 2235 + ): Promise<NetworkSlicesSliceGetJobStatusOutput> { 2236 + return await this.client.makeRequest<NetworkSlicesSliceGetJobStatusOutput>( 2138 2237 "network.slices.slice.getJobStatus", 2139 2238 "GET", 2140 2239 params, 2141 2240 ); 2142 2241 } 2143 2242 2144 - async getJobHistory( 2145 - params: GetJobHistoryParams, 2146 - ): Promise<GetJobHistoryResponse> { 2147 - return await this.client.makeRequest<GetJobHistoryResponse>( 2148 - "network.slices.slice.getJobHistory", 2149 - "GET", 2150 - params, 2243 + async getActors( 2244 + input: NetworkSlicesSliceGetActorsInput, 2245 + ): Promise<NetworkSlicesSliceGetActorsOutput> { 2246 + return await this.client.makeRequest<NetworkSlicesSliceGetActorsOutput>( 2247 + "network.slices.slice.getActors", 2248 + "POST", 2249 + input, 2151 2250 ); 2152 2251 } 2153 2252 2154 - async getJobLogs(params: GetJobLogsParams): Promise<GetJobLogsResponse> { 2155 - return await this.client.makeRequest<GetJobLogsResponse>( 2156 - "network.slices.slice.getJobLogs", 2157 - "GET", 2158 - params, 2159 - ); 2253 + async deleteOAuthClient( 2254 + input: NetworkSlicesSliceDeleteOAuthClientInput, 2255 + ): Promise<NetworkSlicesSliceDeleteOAuthClientOutput> { 2256 + return await this.client.makeRequest< 2257 + NetworkSlicesSliceDeleteOAuthClientOutput 2258 + >("network.slices.slice.deleteOAuthClient", "POST", input); 2160 2259 } 2161 2260 2162 - async getJetstreamStatus(): Promise<JetstreamStatusResponse> { 2163 - return await this.client.makeRequest<JetstreamStatusResponse>( 2164 - "network.slices.slice.getJetstreamStatus", 2165 - "GET", 2166 - ); 2261 + async createOAuthClient( 2262 + input: NetworkSlicesSliceCreateOAuthClientInput, 2263 + ): Promise<NetworkSlicesSliceCreateOAuthClientOutput> { 2264 + return await this.client.makeRequest< 2265 + NetworkSlicesSliceCreateOAuthClientOutput 2266 + >("network.slices.slice.createOAuthClient", "POST", input); 2167 2267 } 2168 2268 2169 2269 async getJetstreamLogs( 2170 - params: GetJetstreamLogsParams, 2171 - ): Promise<GetJetstreamLogsResponse> { 2172 - const requestParams = { ...params, slice: this.client.sliceUri }; 2173 - return await this.client.makeRequest<GetJetstreamLogsResponse>( 2174 - "network.slices.slice.getJetstreamLogs", 2175 - "GET", 2176 - requestParams, 2177 - ); 2270 + params?: NetworkSlicesSliceGetJetstreamLogsParams, 2271 + ): Promise<NetworkSlicesSliceGetJetstreamLogsOutput> { 2272 + return await this.client.makeRequest< 2273 + NetworkSlicesSliceGetJetstreamLogsOutput 2274 + >("network.slices.slice.getJetstreamLogs", "GET", params); 2275 + } 2276 + 2277 + async getSliceRecords( 2278 + input: NetworkSlicesSliceGetSliceRecordsInput, 2279 + ): Promise<NetworkSlicesSliceGetSliceRecordsOutput> { 2280 + return await this.client.makeRequest< 2281 + NetworkSlicesSliceGetSliceRecordsOutput 2282 + >("network.slices.slice.getSliceRecords", "POST", input); 2178 2283 } 2179 2284 2180 - async syncUserCollections( 2181 - params?: SyncUserCollectionsRequest, 2182 - ): Promise<SyncUserCollectionsResult> { 2183 - const requestParams = { slice: this.client.sliceUri, ...params }; 2184 - return await this.client.makeRequest<SyncUserCollectionsResult>( 2185 - "network.slices.slice.syncUserCollections", 2285 + async startSync( 2286 + input: NetworkSlicesSliceStartSyncInput, 2287 + ): Promise<NetworkSlicesSliceStartSyncOutput> { 2288 + return await this.client.makeRequest<NetworkSlicesSliceStartSyncOutput>( 2289 + "network.slices.slice.startSync", 2186 2290 "POST", 2187 - requestParams, 2291 + input, 2188 2292 ); 2189 2293 } 2190 2294 2191 - async createOAuthClient( 2192 - params: CreateOAuthClientRequest, 2193 - ): Promise<OAuthClientDetails | OAuthOperationError> { 2194 - const requestParams = { ...params, sliceUri: this.client.sliceUri }; 2195 - return await this.client.makeRequest< 2196 - OAuthClientDetails | OAuthOperationError 2197 - >("network.slices.slice.createOAuthClient", "POST", requestParams); 2295 + async getJobHistory( 2296 + params?: NetworkSlicesSliceGetJobHistoryParams, 2297 + ): Promise<NetworkSlicesSliceGetJobHistoryOutput> { 2298 + return await this.client.makeRequest<NetworkSlicesSliceGetJobHistoryOutput>( 2299 + "network.slices.slice.getJobHistory", 2300 + "GET", 2301 + params, 2302 + ); 2198 2303 } 2199 2304 2200 - async getOAuthClients(): Promise<ListOAuthClientsResponse> { 2201 - const requestParams = { slice: this.client.sliceUri }; 2202 - return await this.client.makeRequest<ListOAuthClientsResponse>( 2203 - "network.slices.slice.getOAuthClients", 2305 + async stats( 2306 + params?: NetworkSlicesSliceStatsParams, 2307 + ): Promise<NetworkSlicesSliceStatsOutput> { 2308 + return await this.client.makeRequest<NetworkSlicesSliceStatsOutput>( 2309 + "network.slices.slice.stats", 2204 2310 "GET", 2205 - requestParams, 2311 + params, 2206 2312 ); 2207 2313 } 2208 2314 2209 2315 async updateOAuthClient( 2210 - params: UpdateOAuthClientRequest, 2211 - ): Promise<OAuthClientDetails | OAuthOperationError> { 2212 - const requestParams = { ...params, sliceUri: this.client.sliceUri }; 2316 + input: NetworkSlicesSliceUpdateOAuthClientInput, 2317 + ): Promise<NetworkSlicesSliceUpdateOAuthClientOutput> { 2213 2318 return await this.client.makeRequest< 2214 - OAuthClientDetails | OAuthOperationError 2215 - >("network.slices.slice.updateOAuthClient", "POST", requestParams); 2319 + NetworkSlicesSliceUpdateOAuthClientOutput 2320 + >("network.slices.slice.updateOAuthClient", "POST", input); 2216 2321 } 2217 2322 2218 - async deleteOAuthClient( 2219 - clientId: string, 2220 - ): Promise<DeleteOAuthClientResponse> { 2221 - return await this.client.makeRequest<DeleteOAuthClientResponse>( 2222 - "network.slices.slice.deleteOAuthClient", 2223 - "POST", 2224 - { clientId }, 2225 - ); 2323 + async getOAuthClients( 2324 + params?: NetworkSlicesSliceGetOAuthClientsParams, 2325 + ): Promise<NetworkSlicesSliceGetOAuthClientsOutput> { 2326 + return await this.client.makeRequest< 2327 + NetworkSlicesSliceGetOAuthClientsOutput 2328 + >("network.slices.slice.getOAuthClients", "GET", params); 2226 2329 } 2227 2330 } 2228 2331
+6 -2
frontend/src/features/auth/handlers.tsx
··· 210 210 // Sync external collections first to ensure actor records are populated 211 211 try { 212 212 if (userInfo?.sub) { 213 - await sessionClient.network.slices.slice.syncUserCollections(); 213 + await sessionClient.network.slices.slice.syncUserCollections({ 214 + slice: SLICE_URI!, 215 + }); 214 216 } 215 217 } catch (error) { 216 218 console.error("Error during external collections sync:", error); ··· 427 429 428 430 // Sync user collections to populate their Bluesky profile data 429 431 try { 430 - await sessionClient.network.slices.slice.syncUserCollections(); 432 + await sessionClient.network.slices.slice.syncUserCollections({ 433 + slice: SLICE_URI!, 434 + }); 431 435 } catch (syncError) { 432 436 console.error( 433 437 "Failed to sync user collections for waitlist user:",
+1 -5
frontend/src/features/docs/handlers.tsx
··· 257 257 async function handleDocsIndex(request: Request): Promise<Response> { 258 258 const { currentUser } = await withAuth(request); 259 259 return renderHTML( 260 - <DocsIndexPage 261 - docs={AVAILABLE_DOCS} 262 - categories={DOCS_CATEGORIES} 263 - currentUser={currentUser} 264 - /> 260 + <DocsIndexPage categories={DOCS_CATEGORIES} currentUser={currentUser} /> 265 261 ); 266 262 } 267 263
+30 -10
frontend/src/features/docs/templates/DocsIndexPage.tsx
··· 14 14 } 15 15 16 16 interface DocsIndexPageProps { 17 - docs: DocItem[]; 18 17 categories: DocCategory[]; 19 18 currentUser?: AuthenticatedUser; 20 19 } 21 20 22 - export function DocsIndexPage({ docs, categories, currentUser }: DocsIndexPageProps) { 21 + export function DocsIndexPage({ categories, currentUser }: DocsIndexPageProps) { 23 22 return ( 24 23 <Layout title="Documentation - Slices" currentUser={currentUser}> 25 24 <div className="py-8 px-4 max-w-6xl mx-auto"> ··· 27 26 <Text as="h1" size="3xl" className="font-bold mb-4"> 28 27 Slices Documentation 29 28 </Text> 30 - <Text as="p" size="lg" variant="secondary" className="leading-relaxed"> 31 - Learn how to build AT Protocol applications with Slices. These guides cover everything from basic concepts to advanced usage patterns. 29 + <Text 30 + as="p" 31 + size="lg" 32 + variant="secondary" 33 + className="leading-relaxed" 34 + > 35 + Learn how to build AT Protocol applications with Slices. These 36 + guides cover everything from basic concepts to advanced usage 37 + patterns. 32 38 </Text> 33 39 </div> 34 40 35 41 <div className="space-y-16"> 36 42 {categories.map((category) => ( 37 43 <section key={category.category}> 38 - <Text as="h2" size="2xl" className="font-bold mb-8 text-zinc-900 dark:text-white"> 44 + <Text 45 + as="h2" 46 + size="2xl" 47 + className="font-bold mb-8 text-zinc-900 dark:text-white" 48 + > 39 49 {category.category} 40 50 </Text> 41 51 <div className="space-y-6"> 42 52 {category.docs.map((doc) => ( 43 - <div key={doc.slug} className="border-b border-zinc-200 dark:border-zinc-700 pb-6"> 53 + <div 54 + key={doc.slug} 55 + className="border-b border-zinc-200 dark:border-zinc-700 pb-6" 56 + > 44 57 <a 45 58 href={`/docs/${doc.slug}`} 46 59 className="block group hover:no-underline" 47 60 > 48 - <Text as="h3" size="lg" className="font-semibold mb-3 text-blue-600 dark:text-blue-400 group-hover:text-blue-700 dark:group-hover:text-blue-300 transition-colors underline decoration-blue-600 dark:decoration-blue-400"> 61 + <Text 62 + as="h3" 63 + size="lg" 64 + className="font-semibold mb-3 text-blue-600 dark:text-blue-400 group-hover:text-blue-700 dark:group-hover:text-blue-300 transition-colors underline decoration-blue-600 dark:decoration-blue-400" 65 + > 49 66 {doc.title} 50 67 </Text> 51 - <Text as="p" variant="secondary" className="leading-relaxed text-base"> 68 + <Text 69 + as="p" 70 + variant="secondary" 71 + className="leading-relaxed text-base" 72 + > 52 73 {doc.description} 53 74 </Text> 54 75 </a> ··· 58 79 </section> 59 80 ))} 60 81 </div> 61 - 62 82 </div> 63 83 </Layout> 64 84 ); 65 - } 85 + }
+7 -12
frontend/src/features/docs/templates/DocsPage.tsx
··· 34 34 title, 35 35 content, 36 36 headers, 37 - docs, 38 - categories, 39 - currentSlug, 40 37 currentUser, 41 38 }: DocsPageProps) { 42 39 return ( 43 - <Layout 44 - title={`${title} - Slices`} 45 - currentUser={currentUser} 46 - > 40 + <Layout title={`${title} - Slices`} currentUser={currentUser}> 47 41 <div className="py-8 px-4 max-w-6xl mx-auto relative"> 48 42 {/* Breadcrumb */} 49 43 <Breadcrumb 50 - items={[ 51 - { label: "Documentation", href: "/docs" }, 52 - { label: title } 53 - ]} 44 + items={[{ label: "Documentation", href: "/docs" }, { label: title }]} 54 45 /> 55 46 56 47 {/* Two-column layout */} ··· 69 60 {headers.length > 0 && ( 70 61 <aside className="hidden lg:flex w-64 flex-shrink-0 relative"> 71 62 <div className="sticky top-1/2 -translate-y-1/2 w-64 max-h-[60vh] overflow-y-auto bg-zinc-50 dark:bg-zinc-900/50 border border-zinc-200 dark:border-zinc-700 rounded-lg p-4 shadow-sm"> 72 - <Text as="h3" size="sm" className="font-semibold mb-4 text-zinc-900 dark:text-white"> 63 + <Text 64 + as="h3" 65 + size="sm" 66 + className="font-semibold mb-4 text-zinc-900 dark:text-white" 67 + > 73 68 On This Page 74 69 </Text> 75 70 <nav>
-4
frontend/src/features/slices/codegen/handlers.tsx
··· 6 6 import { extractSliceParams } from "../../../utils/slice-params.ts"; 7 7 import { SliceCodegenPage } from "./templates/SliceCodegenPage.tsx"; 8 8 import { generateTypeScript } from "@slices/codegen"; 9 - import { SLICE_URI } from "../../../config.ts"; 10 9 11 10 async function handleSliceCodegenPage( 12 11 req: Request, ··· 57 56 }); 58 57 59 58 // Generate TypeScript client using convenience function 60 - // If this is the main slice (SLICE_URI), include slices client functionality 61 - const excludeSlicesClient = context.sliceContext!.sliceUri !== SLICE_URI; 62 59 generatedCode = await generateTypeScript(lexicons, { 63 60 sliceUri: context.sliceContext!.sliceUri, 64 - excludeSlicesClient, 65 61 }); 66 62 } catch (e) { 67 63 console.error("Codegen error:", e);
+17 -32
frontend/src/features/slices/jetstream/handlers.tsx
··· 13 13 import { JetstreamLogs } from "./templates/fragments/JetstreamLogs.tsx"; 14 14 import { JetstreamStatus } from "./templates/fragments/JetstreamStatus.tsx"; 15 15 import { JetstreamStatusDisplay } from "./templates/fragments/JetstreamStatusDisplay.tsx"; 16 - import { buildSliceUrl } from "../../../utils/slice-params.ts"; 17 - import type { LogEntry } from "../../../client.ts"; 16 + import { NetworkSlicesSliceGetJobLogsLogEntry } from "../../../client.ts"; 17 + import { buildSliceUri } from "../../../utils/at-uri.ts"; 18 18 19 19 async function handleJetstreamLogs( 20 20 req: Request, ··· 36 36 // Use the slice-specific client 37 37 const sliceClient = getSliceClient(context, sliceId); 38 38 39 + // Build slice URI from the user's DID and sliceId 40 + const sliceUri = buildSliceUri(context.currentUser.sub!, sliceId); 41 + 39 42 // Get Jetstream logs 40 43 const result = await sliceClient.network.slices.slice.getJetstreamLogs({ 44 + slice: sliceUri, 41 45 limit: 100, 42 46 }); 43 47 ··· 85 89 try { 86 90 // Extract parameters from query 87 91 const url = new URL(req.url); 88 - const sliceId = url.searchParams.get("sliceId"); 89 - const handle = url.searchParams.get("handle"); 90 92 const isCompact = url.searchParams.get("compact") === "true"; 93 + const sliceId = url.searchParams.get("sliceId") || undefined; 94 + const handle = url.searchParams.get("handle") || undefined; 91 95 92 96 // Fetch jetstream status using the public client 93 97 const data = await publicClient.network.slices.slice.getJetstreamStatus(); ··· 99 103 ); 100 104 } 101 105 102 - // Generate jetstream URL if we have both handle and sliceId 103 - const jetstreamUrl = 104 - handle && sliceId 105 - ? buildSliceUrl(handle, sliceId, "jetstream") 106 - : undefined; 107 - 108 106 // Render full version for main page 109 107 return renderHTML( 110 108 <JetstreamStatus 111 109 connected={data.connected} 112 - status={data.status} 113 - error={data.error} 114 - jetstreamUrl={jetstreamUrl} 110 + sliceId={sliceId} 111 + handle={handle} 115 112 /> 116 113 ); 117 - } catch (error) { 114 + } catch (_error) { 118 115 // Extract parameters for error case too 119 116 const url = new URL(req.url); 120 - const sliceId = url.searchParams.get("sliceId"); 121 - const handle = url.searchParams.get("handle"); 122 117 const isCompact = url.searchParams.get("compact") === "true"; 118 + const sliceId = url.searchParams.get("sliceId") || undefined; 119 + const handle = url.searchParams.get("handle") || undefined; 123 120 124 121 // Render compact error version 125 122 if (isCompact) { 126 - return renderHTML( 127 - <JetstreamStatusDisplay connected={false} isCompact /> 128 - ); 123 + return renderHTML(<JetstreamStatusDisplay connected={false} isCompact />); 129 124 } 130 125 131 - // Generate jetstream URL if we have both handle and sliceId 132 - const jetstreamUrl = 133 - handle && sliceId 134 - ? buildSliceUrl(handle, sliceId, "jetstream") 135 - : undefined; 136 - 137 126 // Fallback to disconnected state on error for full version 138 127 return renderHTML( 139 - <JetstreamStatus 140 - connected={false} 141 - status="Connection error" 142 - error={error instanceof Error ? error.message : "Unknown error"} 143 - jetstreamUrl={jetstreamUrl} 144 - /> 128 + <JetstreamStatus connected={false} sliceId={sliceId} handle={handle} /> 145 129 ); 146 130 } 147 131 } ··· 166 150 if (accessError) return accessError; 167 151 168 152 // Fetch Jetstream logs 169 - let logs: LogEntry[] = []; 153 + let logs: NetworkSlicesSliceGetJobLogsLogEntry[] = []; 170 154 171 155 try { 172 156 const sliceClient = getSliceClient(authContext, sliceParams.sliceId); 173 157 174 158 const logsResult = await sliceClient.network.slices.slice.getJetstreamLogs({ 159 + slice: context.sliceContext!.sliceUri, 175 160 limit: 100, 176 161 }); 177 162 logs = logsResult.logs.sort(
+2 -2
frontend/src/features/slices/jetstream/templates/JetstreamLogsPage.tsx
··· 1 1 import type { 2 - LogEntry, 3 2 NetworkSlicesSliceDefsSliceView, 3 + NetworkSlicesSliceGetJobLogsLogEntry, 4 4 } from "../../../../client.ts"; 5 5 import { SliceLogPage } from "../../shared/fragments/SliceLogPage.tsx"; 6 6 import { JetstreamLogs } from "./fragments/JetstreamLogs.tsx"; ··· 10 10 11 11 interface JetstreamLogsPageProps { 12 12 slice: NetworkSlicesSliceDefsSliceView; 13 - logs: LogEntry[]; 13 + logs: NetworkSlicesSliceGetJobLogsLogEntry[]; 14 14 sliceId: string; 15 15 currentUser?: AuthenticatedUser; 16 16 }
+2 -2
frontend/src/features/slices/jetstream/templates/fragments/JetstreamLogs.tsx
··· 1 - import type { LogEntry } from "../../../../../client.ts"; 2 1 import { formatTimestamp } from "../../../../../utils/time.ts"; 3 2 import { LogViewer } from "../../../../../shared/fragments/LogViewer.tsx"; 3 + import { NetworkSlicesSliceGetJobLogsLogEntry } from "../../../../../client.ts"; 4 4 5 5 interface JetstreamLogsProps { 6 - logs: LogEntry[]; 6 + logs: NetworkSlicesSliceGetJobLogsLogEntry[]; 7 7 } 8 8 9 9 export function JetstreamLogs({ logs }: JetstreamLogsProps) {
+39 -32
frontend/src/features/slices/jetstream/templates/fragments/JetstreamStatus.tsx
··· 1 - import { Button } from "../../../../../shared/fragments/Button.tsx"; 2 1 import { Card } from "../../../../../shared/fragments/Card.tsx"; 3 2 import { Text } from "../../../../../shared/fragments/Text.tsx"; 3 + import { Button } from "../../../../../shared/fragments/Button.tsx"; 4 4 5 5 interface JetstreamStatusProps { 6 6 connected: boolean; 7 - status: string; 8 - error?: string; 9 - jetstreamUrl?: string; 7 + sliceId?: string; 8 + handle?: string; 10 9 } 11 10 12 - export function JetstreamStatus({ 13 - connected, 14 - status, 15 - error, 16 - jetstreamUrl, 17 - }: JetstreamStatusProps) { 11 + export function JetstreamStatus({ connected, sliceId, handle }: JetstreamStatusProps) { 12 + const showViewLogs = sliceId && handle; 13 + 18 14 if (connected) { 19 15 return ( 20 - <Card padding="sm" className="bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800 mb-6"> 16 + <Card 17 + padding="sm" 18 + className="bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800 mb-6" 19 + > 21 20 <div className="flex items-center justify-between"> 22 21 <div className="flex items-center"> 23 - <div className="w-3 h-3 bg-green-500 rounded-full mr-3 animate-pulse"> 24 - </div> 22 + <div className="w-3 h-3 bg-green-500 rounded-full mr-3 animate-pulse"></div> 25 23 <div> 26 - <Text as="h3" size="sm" variant="success" className="font-semibold block"> 24 + <Text 25 + as="h3" 26 + size="sm" 27 + variant="success" 28 + className="font-semibold block" 29 + > 27 30 ✈️ Jetstream Connected 28 31 </Text> 29 32 <Text as="p" size="xs" variant="success"> 30 - Real-time indexing active - new records are automatically 31 - indexed 33 + Real-time indexing active - new records are automatically indexed 32 34 </Text> 33 35 </div> 34 36 </div> 35 - <div className="flex items-center gap-3"> 36 - {jetstreamUrl && ( 37 + {showViewLogs && ( 38 + <div className="flex items-center gap-3"> 37 39 <Button 38 - href={jetstreamUrl} 40 + href={`/profile/${handle}/slice/${sliceId}/jetstream`} 39 41 variant="success" 40 42 size="sm" 41 43 className="whitespace-nowrap" 42 44 > 43 45 View Logs 44 46 </Button> 45 - )} 46 - </div> 47 + </div> 48 + )} 47 49 </div> 48 50 </Card> 49 51 ); 50 52 } else { 51 53 return ( 52 - <Card padding="sm" className="bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800 mb-6"> 54 + <Card 55 + padding="sm" 56 + className="bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800 mb-6" 57 + > 53 58 <div className="flex items-center justify-between"> 54 59 <div className="flex items-center"> 55 60 <div className="w-3 h-3 bg-red-500 rounded-full mr-3"></div> 56 61 <div> 57 - <Text as="h3" size="sm" variant="error" className="font-semibold block"> 62 + <Text 63 + as="h3" 64 + size="sm" 65 + variant="error" 66 + className="font-semibold block" 67 + > 58 68 🌊 Jetstream Disconnected 59 69 </Text> 60 70 <Text as="p" size="xs" variant="error"> 61 - Real-time indexing not active - {status} 71 + Real-time indexing not active 62 72 </Text> 63 - {error && ( 64 - <Text as="p" size="xs" variant="error" className="mt-1">Error: {error}</Text> 65 - )} 66 73 </div> 67 74 </div> 68 - <div className="flex items-center gap-3"> 69 - {jetstreamUrl && ( 75 + {showViewLogs && ( 76 + <div className="flex items-center gap-3"> 70 77 <Button 71 - href={jetstreamUrl} 78 + href={`/profile/${handle}/slice/${sliceId}/jetstream`} 72 79 variant="danger" 73 80 size="sm" 74 81 className="whitespace-nowrap" 75 82 > 76 83 View Logs 77 84 </Button> 78 - )} 79 - </div> 85 + </div> 86 + )} 80 87 </div> 81 88 </Card> 82 89 );
+51 -78
frontend/src/features/slices/oauth/handlers.tsx
··· 6 6 requireSliceAccess, 7 7 withSliceAccess, 8 8 } from "../../../routes/slice-middleware.ts"; 9 - import { 10 - extractSliceParams, 11 - } from "../../../utils/slice-params.ts"; 9 + import { extractSliceParams } from "../../../utils/slice-params.ts"; 12 10 import { renderHTML } from "../../../utils/render.tsx"; 13 11 import { SliceOAuthPage } from "./templates/SliceOAuthPage.tsx"; 14 12 import { OAuthClientModal } from "./templates/fragments/OAuthClientModal.tsx"; ··· 32 30 sliceUri={sliceUri} 33 31 mode="new" 34 32 clientData={undefined} 35 - />, 33 + /> 36 34 ); 37 35 } catch (error) { 38 36 console.error("Error showing new OAuth client modal:", error); ··· 52 50 Close 53 51 </button> 54 52 </div>, 55 - { status: 500 }, 53 + { status: 500 } 56 54 ); 57 55 } 58 56 } ··· 81 79 .map((uri) => uri.trim()) 82 80 .filter((uri) => uri.length > 0); 83 81 84 - // Register new OAuth client via backend API 85 82 const sliceClient = getSliceClient(context, sliceId); 86 - const result = await sliceClient.network.slices.slice.createOAuthClient({ 83 + await sliceClient.network.slices.slice.createOAuthClient({ 84 + sliceUri: buildAtUri({ 85 + did: context.currentUser.sub!, 86 + collection: "network.slices.slice", 87 + rkey: sliceId, 88 + }), 87 89 clientName, 88 90 redirectUris, 89 91 scope: scope || undefined, ··· 93 95 policyUri: policyUri || undefined, 94 96 }); 95 97 96 - // Check if the result indicates success/failure 97 - if (typeof result === 'object' && 'success' in result && result.success === false) { 98 - return renderHTML( 99 - <OAuthResult 100 - success={false} 101 - message={result.message || "OAuth client registration failed"} 102 - /> 103 - ); 104 - } 105 - 106 98 return renderHTML( 107 - <OAuthResult 108 - success 109 - message="OAuth client registered successfully" 110 - /> 99 + <OAuthResult success message="OAuth client registered successfully" /> 111 100 ); 112 101 } catch (error) { 113 102 console.error("Error registering OAuth client:", error); ··· 125 114 // If we can't parse JSON, try to extract from the error message 126 115 const errorStr = error.message; 127 116 if (errorStr.includes("Invalid redirect URI")) { 128 - errorMessage = "Invalid redirect URI format. Please ensure all redirect URIs use proper HTTP/HTTPS format."; 117 + errorMessage = 118 + "Invalid redirect URI format. Please ensure all redirect URIs use proper HTTP/HTTPS format."; 129 119 } else if (errorStr.includes("Bad Request")) { 130 120 errorMessage = errorStr; 131 121 } 132 122 } 133 123 } 134 124 135 - return renderHTML( 136 - <OAuthResult 137 - success={false} 138 - message={errorMessage} 139 - /> 140 - ); 125 + return renderHTML(<OAuthResult success={false} message={errorMessage} />); 141 126 } 142 127 } 143 128 ··· 154 139 try { 155 140 // Delete OAuth client via backend API 156 141 const sliceClient = getSliceClient(context, sliceId); 157 - await sliceClient.network.slices.slice.deleteOAuthClient(clientId); 142 + await sliceClient.network.slices.slice.deleteOAuthClient({ 143 + clientId, 144 + }); 158 145 159 146 return renderHTML(<OAuthDeleteResult success />); 160 147 } catch (error) { ··· 164 151 success={false} 165 152 error={error instanceof Error ? error.message : String(error)} 166 153 />, 167 - { status: 500 }, 154 + { status: 500 } 168 155 ); 169 156 } 170 157 } ··· 180 167 const clientId = decodeURIComponent(pathParts[5]); 181 168 182 169 try { 183 - // Fetch OAuth client details via backend API 184 - const sliceClient = getSliceClient(context, sliceId); 185 - const clientsResponse = await sliceClient.network.slices.slice 186 - .getOAuthClients(); 187 - const clientData = clientsResponse.clients.find( 188 - (c) => c.clientId === clientId, 189 - ); 190 - 170 + // Construct sliceUri first 191 171 const sliceUri = buildAtUri({ 192 172 did: context.currentUser.sub!, 193 173 collection: "network.slices.slice", 194 174 rkey: sliceId, 195 175 }); 196 176 177 + // Fetch OAuth client details via backend API 178 + const sliceClient = getSliceClient(context, sliceId); 179 + const clientsResponse = 180 + await sliceClient.network.slices.slice.getOAuthClients({ 181 + slice: sliceUri, 182 + }); 183 + const clientData = clientsResponse.clients.find( 184 + (c) => c.clientId === clientId 185 + ); 186 + 197 187 return renderHTML( 198 188 <OAuthClientModal 199 189 sliceId={sliceId} 200 190 sliceUri={sliceUri} 201 191 mode="view" 202 192 clientData={clientData} 203 - />, 193 + /> 204 194 ); 205 195 } catch (error) { 206 196 console.error("Error fetching OAuth client:", error); ··· 220 210 Close 221 211 </button> 222 212 </div>, 223 - { status: 500 }, 213 + { status: 500 } 224 214 ); 225 215 } 226 216 } ··· 251 241 .map((uri) => uri.trim()) 252 242 .filter((uri) => uri.length > 0); 253 243 254 - // Update OAuth client via backend API 255 244 const sliceClient = getSliceClient(context, sliceId); 256 - const result = await sliceClient.network.slices.slice 257 - .updateOAuthClient({ 258 - clientId, 259 - clientName: clientName || undefined, 260 - redirectUris: redirectUris.length > 0 ? redirectUris : undefined, 261 - scope: scope || undefined, 262 - clientUri: clientUri || undefined, 263 - logoUri: logoUri || undefined, 264 - tosUri: tosUri || undefined, 265 - policyUri: policyUri || undefined, 266 - }); 267 - 268 - // Check if the result indicates success/failure 269 - if (typeof result === 'object' && 'success' in result && result.success === false) { 270 - return renderHTML( 271 - <OAuthResult 272 - success={false} 273 - message={result.message || "OAuth client update failed"} 274 - /> 275 - ); 276 - } 245 + await sliceClient.network.slices.slice.updateOAuthClient({ 246 + clientId, 247 + clientName: clientName || undefined, 248 + redirectUris: redirectUris.length > 0 ? redirectUris : undefined, 249 + scope: scope || undefined, 250 + clientUri: clientUri || undefined, 251 + logoUri: logoUri || undefined, 252 + tosUri: tosUri || undefined, 253 + policyUri: policyUri || undefined, 254 + }); 277 255 278 256 return renderHTML( 279 - <OAuthResult 280 - success 281 - message="OAuth client updated successfully" 282 - /> 257 + <OAuthResult success message="OAuth client updated successfully" /> 283 258 ); 284 259 } catch (error) { 285 260 console.error("Error updating OAuth client:", error); ··· 297 272 // If we can't parse JSON, try to extract from the error message 298 273 const errorStr = error.message; 299 274 if (errorStr.includes("Invalid redirect URI")) { 300 - errorMessage = "Invalid redirect URI format. Please ensure all redirect URIs use proper HTTP/HTTPS format."; 275 + errorMessage = 276 + "Invalid redirect URI format. Please ensure all redirect URIs use proper HTTP/HTTPS format."; 301 277 } else if (errorStr.includes("Bad Request")) { 302 278 errorMessage = errorStr; 303 279 } 304 280 } 305 281 } 306 282 307 - return renderHTML( 308 - <OAuthResult 309 - success={false} 310 - message={errorMessage} 311 - /> 312 - ); 283 + return renderHTML(<OAuthResult success={false} message={errorMessage} />); 313 284 } 314 285 } 315 286 316 287 async function handleSliceOAuthPage( 317 288 req: Request, 318 - params?: URLPatternResult, 289 + params?: URLPatternResult 319 290 ): Promise<Response> { 320 291 const authContext = await withAuth(req); 321 292 const sliceParams = extractSliceParams(params); ··· 327 298 const context = await withSliceAccess( 328 299 authContext, 329 300 sliceParams.handle, 330 - sliceParams.sliceId, 301 + sliceParams.sliceId 331 302 ); 332 303 const accessError = requireSliceAccess(context); 333 304 if (accessError) return accessError; ··· 344 315 let errorMessage = null; 345 316 346 317 try { 347 - const oauthClientsResponse = await sliceClient.network.slices.slice 348 - .getOAuthClients(); 318 + const oauthClientsResponse = 319 + await sliceClient.network.slices.slice.getOAuthClients({ 320 + slice: context.sliceContext!.sliceUri, 321 + }); 349 322 clientsWithDetails = oauthClientsResponse.clients.map((client) => ({ 350 323 clientId: client.clientId, 351 324 createdAt: new Date().toISOString(), // Backend should provide this ··· 365 338 currentUser={authContext.currentUser} 366 339 error={errorMessage} 367 340 hasSliceAccess={context.sliceContext?.hasAccess} 368 - />, 341 + /> 369 342 ); 370 343 } 371 344
+2 -2
frontend/src/features/slices/oauth/templates/fragments/OAuthClientModal.tsx
··· 1 - import { OAuthClientDetails } from "../../../../../client.ts"; 2 1 import { Button } from "../../../../../shared/fragments/Button.tsx"; 3 2 import { Input } from "../../../../../shared/fragments/Input.tsx"; 4 3 import { Textarea } from "../../../../../shared/fragments/Textarea.tsx"; 5 4 import { Modal } from "../../../../../shared/fragments/Modal.tsx"; 6 5 import { Text } from "../../../../../shared/fragments/Text.tsx"; 6 + import { NetworkSlicesSliceGetOAuthClientsOauthClientDetails } from "../../../../../client.ts"; 7 7 8 8 interface OAuthClientModalProps { 9 9 sliceId: string; 10 10 sliceUri: string; 11 11 mode: "new" | "view"; 12 - clientData?: OAuthClientDetails; 12 + clientData?: NetworkSlicesSliceGetOAuthClientsOauthClientDetails; 13 13 } 14 14 15 15 export function OAuthClientModal({
+14 -13
frontend/src/features/slices/records/handlers.tsx
··· 8 8 withSliceAccess, 9 9 } from "../../../routes/slice-middleware.ts"; 10 10 import { extractSliceParams } from "../../../utils/slice-params.ts"; 11 - import type { IndexedRecord } from "@slices/client"; 11 + import type { NetworkSlicesSliceGetSliceRecordsIndexedRecord } from "../../../client.ts"; 12 12 import { RecordsList } from "./templates/fragments/RecordsList.tsx"; 13 13 import { Card } from "../../../shared/fragments/Card.tsx"; 14 14 import { EmptyState } from "../../../shared/fragments/EmptyState.tsx"; ··· 50 50 const searchQuery = url.searchParams.get("search") || ""; 51 51 52 52 // Fetch real records if a collection is selected 53 - let records: Array<IndexedRecord & { pretty_value: string }> = []; 53 + let records: Array< 54 + NetworkSlicesSliceGetSliceRecordsIndexedRecord & { pretty_value: string } 55 + > = []; 54 56 55 57 if ( 56 58 (selectedCollection || (searchQuery && searchQuery.trim() !== "")) && ··· 64 66 ); 65 67 const recordsResult = 66 68 await sliceClient.network.slices.slice.getSliceRecords({ 69 + slice: context.sliceContext.sliceUri, 67 70 where: { 68 71 ...(selectedCollection && { 69 72 collection: { eq: selectedCollection }, ··· 75 78 limit: 20, 76 79 }); 77 80 78 - if (recordsResult.success) { 79 - records = recordsResult.records.map((record) => ({ 80 - uri: record.uri, 81 - indexedAt: record.indexedAt, 82 - collection: record.collection, 83 - did: record.did, 84 - cid: record.cid, 85 - value: record.value, 86 - pretty_value: JSON.stringify(record.value, null, 2), 87 - })); 88 - } 81 + records = recordsResult.records.map((record) => ({ 82 + uri: record.uri, 83 + indexedAt: record.indexedAt, 84 + collection: record.collection, 85 + did: record.did, 86 + cid: record.cid, 87 + value: record.value, 88 + pretty_value: JSON.stringify(record.value, null, 2), 89 + })); 89 90 } catch (error) { 90 91 console.error("Failed to fetch records:", error); 91 92 }
+2 -3
frontend/src/features/slices/records/templates/SliceRecordsPage.tsx
··· 7 7 import { Database } from "lucide-preact"; 8 8 import { buildSliceUrlFromView } from "../../../../utils/slice-params.ts"; 9 9 import type { AuthenticatedUser } from "../../../../routes/middleware.ts"; 10 - import type { NetworkSlicesSliceDefsSliceView } from "../../../../client.ts"; 11 - import { IndexedRecord } from "@slices/client"; 10 + import type { NetworkSlicesSliceDefsSliceView, NetworkSlicesSliceGetSliceRecordsIndexedRecord } from "../../../../client.ts"; 12 11 13 - interface Record extends IndexedRecord { 12 + interface Record extends NetworkSlicesSliceGetSliceRecordsIndexedRecord { 14 13 pretty_value?: string; 15 14 } 16 15
+2 -2
frontend/src/features/slices/records/templates/fragments/RecordsList.tsx
··· 1 - import { IndexedRecord } from "@slices/client"; 2 1 import { Card } from "../../../../../shared/fragments/Card.tsx"; 3 2 import { Text } from "../../../../../shared/fragments/Text.tsx"; 3 + import type { NetworkSlicesSliceGetSliceRecordsIndexedRecord } from "../../../../../client.ts"; 4 4 5 - interface Record extends IndexedRecord { 5 + interface Record extends NetworkSlicesSliceGetSliceRecordsIndexedRecord { 6 6 pretty_value?: string; 7 7 } 8 8
+2 -2
frontend/src/features/slices/sync-logs/templates/SyncJobLogs.tsx
··· 1 - import type { LogEntry } from "../../../../client.ts"; 1 + import { NetworkSlicesSliceGetJobLogsLogEntry } from "../../../../client.ts"; 2 2 import { LogViewer } from "../../../../shared/fragments/LogViewer.tsx"; 3 3 4 4 interface SyncJobLogsProps { 5 - logs: LogEntry[]; 5 + logs: NetworkSlicesSliceGetJobLogsLogEntry[]; 6 6 } 7 7 8 8 export function SyncJobLogs({ logs }: SyncJobLogsProps) {
+8 -19
frontend/src/features/slices/sync/handlers.tsx
··· 64 64 } 65 65 66 66 const sliceClient = getSliceClient(context, sliceId); 67 - const syncJobResponse = await sliceClient.network.slices.slice.startSync({ 67 + await sliceClient.network.slices.slice.startSync({ 68 + slice: buildSliceUri(context.currentUser.sub!, sliceId), 68 69 collections: collections.length > 0 ? collections : undefined, 69 70 externalCollections: 70 71 externalCollections.length > 0 ? externalCollections : undefined, 71 72 repos: repos.length > 0 ? repos : undefined, 72 73 }); 73 74 74 - if (syncJobResponse.success) { 75 - // Get the user's handle for the redirect 76 - const handle = context.currentUser?.handle; 77 - if (!handle) { 78 - throw new Error("Unable to determine user handle"); 79 - } 75 + const handle = context.currentUser?.handle; 76 + if (!handle) { 77 + throw new Error("Unable to determine user handle"); 78 + } 80 79 81 - // Redirect to the sync page to show the job started 82 - const redirectUrl = buildSliceUrl(handle, sliceId, "sync"); 83 - return hxRedirect(redirectUrl); 84 - } else { 85 - return renderHTML( 86 - <SyncResult 87 - success={false} 88 - message={syncJobResponse.message} 89 - error={syncJobResponse.message} 90 - /> 91 - ); 92 - } 80 + const redirectUrl = buildSliceUrl(handle, sliceId, "sync"); 81 + return hxRedirect(redirectUrl); 93 82 } catch (error) { 94 83 console.error("Failed to start sync:", error); 95 84 const errorMessage = error instanceof Error ? error.message : String(error);
+2 -18
frontend/src/features/slices/sync/templates/fragments/JobHistory.tsx
··· 5 5 import { Text } from "../../../../../shared/fragments/Text.tsx"; 6 6 import { timeAgo } from "../../../../../utils/time.ts"; 7 7 import { buildSliceUrl } from "../../../../../utils/slice-params.ts"; 8 - 9 - interface JobResult { 10 - success: boolean; 11 - totalRecords: number; 12 - collectionsSynced: string[]; 13 - reposProcessed: number; 14 - message: string; 15 - } 16 - 17 - interface JobHistoryItem { 18 - jobId: string; 19 - status: string; 20 - createdAt: string; 21 - completedAt?: string; 22 - result?: JobResult; 23 - error?: string; 24 - } 8 + import { NetworkSlicesSliceGetJobHistoryOutput } from "../../../../../client.ts"; 25 9 26 10 interface JobHistoryProps { 27 - jobs: JobHistoryItem[]; 11 + jobs: NetworkSlicesSliceGetJobHistoryOutput; 28 12 sliceId: string; 29 13 handle?: string; 30 14 }
+6 -4
frontend/src/features/slices/waitlist/api.ts
··· 85 85 try { 86 86 // Fetch actors to get handles 87 87 const actorsResponse = await client.network.slices.slice.getActors({ 88 + slice: sliceUri, 88 89 where: { 89 - did: { in: dids } 90 - } 90 + did: { in: dids }, 91 + }, 91 92 }); 92 93 93 94 // Create a map of DIDs to handles ··· 154 155 try { 155 156 // Fetch actors to get handles 156 157 const actorsResponse = await client.network.slices.slice.getActors({ 158 + slice: sliceUri, 157 159 where: { 158 - did: { in: dids } 159 - } 160 + did: { in: dids }, 161 + }, 160 162 }); 161 163 162 164 // Create a map of DIDs to handles
+12 -10
frontend/src/lib/api.ts
··· 5 5 NetworkSlicesSlice, 6 6 NetworkSlicesSliceDefsSliceView, 7 7 NetworkSlicesSliceDefsSparklinePoint, 8 - SliceStatsOutput, 8 + NetworkSlicesSliceStatsOutput, 9 9 } from "../client.ts"; 10 10 import { recordBlobToCdnUrl, RecordResponse } from "@slices/client"; 11 11 ··· 20 20 slices: sliceUris, 21 21 duration: "24h", 22 22 }); 23 - if (sparklinesResponse.success) { 24 - return sparklinesResponse.sparklines; 23 + 24 + // Convert array of sparklineEntry objects to a map 25 + const sparklinesMap: Record<string, NetworkSlicesSliceDefsSparklinePoint[]> = {}; 26 + for (const entry of sparklinesResponse.sparklines) { 27 + sparklinesMap[entry.sliceUri] = entry.points; 25 28 } 29 + return sparklinesMap; 26 30 } catch (error) { 27 31 console.warn("Failed to fetch batch sparkline data:", error); 28 32 } ··· 32 36 async function fetchStatsForSlice( 33 37 client: AtProtoClient, 34 38 sliceUri: string 35 - ): Promise<SliceStatsOutput | null> { 39 + ): Promise<NetworkSlicesSliceStatsOutput | null> { 36 40 try { 37 41 const statsResponse = await client.network.slices.slice.stats({ 38 42 slice: sliceUri, 39 43 }); 40 - if (statsResponse.success) { 41 - return statsResponse; 42 - } 44 + return statsResponse; 43 45 } catch (error) { 44 46 console.warn("Failed to fetch stats for slice:", sliceUri, error); 45 47 } ··· 49 51 async function fetchStatsForSlices( 50 52 client: AtProtoClient, 51 53 sliceUris: string[] 52 - ): Promise<Record<string, SliceStatsOutput | null>> { 53 - const statsMap: Record<string, SliceStatsOutput | null> = {}; 54 + ): Promise<Record<string, NetworkSlicesSliceStatsOutput | null>> { 55 + const statsMap: Record<string, NetworkSlicesSliceStatsOutput | null> = {}; 54 56 55 57 // Fetch stats for each slice individually (no batch stats endpoint yet) 56 58 await Promise.all( ··· 118 120 sliceRecord: RecordResponse<NetworkSlicesSlice>, 119 121 creator: NetworkSlicesActorDefsProfileViewBasic, 120 122 sparkline?: NetworkSlicesSliceDefsSparklinePoint[], 121 - stats?: SliceStatsOutput | null 123 + stats?: NetworkSlicesSliceStatsOutput | null 122 124 ): NetworkSlicesSliceDefsSliceView { 123 125 return { 124 126 uri: sliceRecord.uri,
-4
frontend/src/routes/slice-middleware.ts
··· 123 123 slice: sliceUri, 124 124 }); 125 125 126 - if (!stats.success) { 127 - return defaultStats; 128 - } 129 - 130 126 return { 131 127 totalRecords: stats.totalRecords, 132 128 totalActors: stats.totalActors,
+19 -7
frontend/src/shared/fragments/LogViewer.tsx
··· 1 - import type { LogEntry } from "../../client.ts"; 2 1 import { LogLevelBadge } from "./LogLevelBadge.tsx"; 3 2 import { Text } from "./Text.tsx"; 4 3 import { Card } from "./Card.tsx"; 4 + import { NetworkSlicesSliceGetJobLogsLogEntry } from "../../client.ts"; 5 5 6 6 interface LogViewerProps { 7 - logs: LogEntry[]; 7 + logs: NetworkSlicesSliceGetJobLogsLogEntry[]; 8 8 emptyMessage?: string; 9 9 formatTimestamp?: (timestamp: string) => string; 10 10 } ··· 17 17 if (logs.length === 0) { 18 18 return ( 19 19 <div className="p-8 text-center"> 20 - <Text as="p" variant="muted">{emptyMessage}</Text> 20 + <Text as="p" variant="muted"> 21 + {emptyMessage} 22 + </Text> 21 23 </div> 22 24 ); 23 25 } ··· 43 45 Warnings: <strong>{warnCount}</strong> 44 46 </Text> 45 47 )} 46 - <Text as="span" size="sm" className="text-blue-600 dark:text-blue-400"> 48 + <Text 49 + as="span" 50 + size="sm" 51 + className="text-blue-600 dark:text-blue-400" 52 + > 47 53 Info: <strong>{infoCount}</strong> 48 54 </Text> 49 55 </div> ··· 61 67 </Text> 62 68 <LogLevelBadge level={log.level} /> 63 69 <div className="flex-1"> 64 - <Text as="div" size="sm">{log.message}</Text> 70 + <Text as="div" size="sm"> 71 + {log.message} 72 + </Text> 65 73 {log.metadata && Object.keys(log.metadata).length > 0 && ( 66 74 <details className="mt-2"> 67 75 <summary ··· 69 77 /* @ts-ignore - Hyperscript attribute */ 70 78 _="on click toggle .hidden on next <pre/>" 71 79 > 72 - <Text as="span" size="xs" variant="muted">View metadata</Text> 80 + <Text as="span" size="xs" variant="muted"> 81 + View metadata 82 + </Text> 73 83 </summary> 74 84 <pre className="mt-2 p-2 bg-zinc-100 dark:bg-zinc-800 rounded text-xs overflow-x-auto break-words whitespace-pre-wrap hidden"> 75 - <Text as="span" size="xs">{JSON.stringify(log.metadata, null, 2)}</Text> 85 + <Text as="span" size="xs"> 86 + {JSON.stringify(log.metadata, null, 2)} 87 + </Text> 76 88 </pre> 77 89 </details> 78 90 )}
+82
lexicons/network/slices/slice/createOAuthClient.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "network.slices.slice.createOAuthClient", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Register a new OAuth client for a slice. Requires authentication.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["sliceUri", "clientName", "redirectUris"], 13 + "properties": { 14 + "sliceUri": { 15 + "type": "string", 16 + "description": "AT-URI of the slice to register the OAuth client for" 17 + }, 18 + "clientName": { 19 + "type": "string", 20 + "description": "Human-readable name of the OAuth client", 21 + "maxLength": 256 22 + }, 23 + "redirectUris": { 24 + "type": "array", 25 + "description": "Allowed redirect URIs for OAuth flow (must use HTTP or HTTPS)", 26 + "minLength": 1, 27 + "items": { 28 + "type": "string", 29 + "format": "uri" 30 + } 31 + }, 32 + "grantTypes": { 33 + "type": "array", 34 + "description": "OAuth grant types", 35 + "items": { 36 + "type": "string" 37 + } 38 + }, 39 + "responseTypes": { 40 + "type": "array", 41 + "description": "OAuth response types", 42 + "items": { 43 + "type": "string" 44 + } 45 + }, 46 + "scope": { 47 + "type": "string", 48 + "description": "OAuth scope" 49 + }, 50 + "clientUri": { 51 + "type": "string", 52 + "format": "uri", 53 + "description": "URI of the client application" 54 + }, 55 + "logoUri": { 56 + "type": "string", 57 + "format": "uri", 58 + "description": "URI of the client logo" 59 + }, 60 + "tosUri": { 61 + "type": "string", 62 + "format": "uri", 63 + "description": "URI of the terms of service" 64 + }, 65 + "policyUri": { 66 + "type": "string", 67 + "format": "uri", 68 + "description": "URI of the privacy policy" 69 + } 70 + } 71 + } 72 + }, 73 + "output": { 74 + "encoding": "application/json", 75 + "schema": { 76 + "type": "ref", 77 + "ref": "network.slices.slice.getOAuthClients#oauthClientDetails" 78 + } 79 + } 80 + } 81 + } 82 + }
+36
lexicons/network/slices/slice/deleteOAuthClient.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "network.slices.slice.deleteOAuthClient", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Delete an OAuth client. Requires authentication.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["clientId"], 13 + "properties": { 14 + "clientId": { 15 + "type": "string", 16 + "description": "OAuth client ID to delete" 17 + } 18 + } 19 + } 20 + }, 21 + "output": { 22 + "encoding": "application/json", 23 + "schema": { 24 + "type": "object", 25 + "required": ["message"], 26 + "properties": { 27 + "message": { 28 + "type": "string", 29 + "description": "Success confirmation message" 30 + } 31 + } 32 + } 33 + } 34 + } 35 + } 36 + }
+83
lexicons/network/slices/slice/getActors.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "network.slices.slice.getActors", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Get actors (users) indexed in a slice with optional filtering and pagination.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["slice"], 13 + "properties": { 14 + "slice": { 15 + "type": "string", 16 + "description": "AT-URI of the slice to query" 17 + }, 18 + "limit": { 19 + "type": "integer", 20 + "description": "Maximum number of actors to return", 21 + "default": 50, 22 + "minimum": 1, 23 + "maximum": 100 24 + }, 25 + "cursor": { 26 + "type": "string", 27 + "description": "Pagination cursor from previous response" 28 + }, 29 + "where": { 30 + "type": "unknown", 31 + "description": "Flexible filtering conditions for querying actors" 32 + } 33 + } 34 + } 35 + }, 36 + "output": { 37 + "encoding": "application/json", 38 + "schema": { 39 + "type": "object", 40 + "required": ["actors"], 41 + "properties": { 42 + "actors": { 43 + "type": "array", 44 + "items": { 45 + "type": "ref", 46 + "ref": "#actor" 47 + } 48 + }, 49 + "cursor": { 50 + "type": "string", 51 + "description": "Pagination cursor for next page" 52 + } 53 + } 54 + } 55 + } 56 + }, 57 + "actor": { 58 + "type": "object", 59 + "required": ["did", "sliceUri", "indexedAt"], 60 + "properties": { 61 + "did": { 62 + "type": "string", 63 + "format": "did", 64 + "description": "Decentralized identifier of the actor" 65 + }, 66 + "handle": { 67 + "type": "string", 68 + "format": "handle", 69 + "description": "Human-readable handle of the actor" 70 + }, 71 + "sliceUri": { 72 + "type": "string", 73 + "description": "AT-URI of the slice this actor is indexed in" 74 + }, 75 + "indexedAt": { 76 + "type": "string", 77 + "format": "datetime", 78 + "description": "When this actor was indexed" 79 + } 80 + } 81 + } 82 + } 83 + }
+42
lexicons/network/slices/slice/getJetstreamLogs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "network.slices.slice.getJetstreamLogs", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get logs from the Jetstream real-time indexing service, optionally filtered by slice.", 8 + "parameters": { 9 + "type": "params", 10 + "properties": { 11 + "slice": { 12 + "type": "string", 13 + "description": "Optional slice AT-URI to filter logs by" 14 + }, 15 + "limit": { 16 + "type": "integer", 17 + "description": "Maximum number of log entries to return", 18 + "default": 100, 19 + "minimum": 1, 20 + "maximum": 1000 21 + } 22 + } 23 + }, 24 + "output": { 25 + "encoding": "application/json", 26 + "schema": { 27 + "type": "object", 28 + "required": ["logs"], 29 + "properties": { 30 + "logs": { 31 + "type": "array", 32 + "items": { 33 + "type": "ref", 34 + "ref": "network.slices.slice.getJobLogs#logEntry" 35 + } 36 + } 37 + } 38 + } 39 + } 40 + } 41 + } 42 + }
+27
lexicons/network/slices/slice/getJetstreamStatus.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "network.slices.slice.getJetstreamStatus", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get the current connection status of the Jetstream real-time indexing service.", 8 + "parameters": { 9 + "type": "params", 10 + "properties": {} 11 + }, 12 + "output": { 13 + "encoding": "application/json", 14 + "schema": { 15 + "type": "object", 16 + "required": ["connected"], 17 + "properties": { 18 + "connected": { 19 + "type": "boolean", 20 + "description": "Whether Jetstream is currently connected and receiving events" 21 + } 22 + } 23 + } 24 + } 25 + } 26 + } 27 + }
+42
lexicons/network/slices/slice/getJobHistory.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "network.slices.slice.getJobHistory", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get the sync job history for a user and slice combination.", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["userDid", "sliceUri"], 11 + "properties": { 12 + "userDid": { 13 + "type": "string", 14 + "format": "did", 15 + "description": "DID of the user" 16 + }, 17 + "sliceUri": { 18 + "type": "string", 19 + "description": "AT-URI of the slice" 20 + }, 21 + "limit": { 22 + "type": "integer", 23 + "description": "Maximum number of jobs to return", 24 + "default": 10, 25 + "minimum": 1, 26 + "maximum": 100 27 + } 28 + } 29 + }, 30 + "output": { 31 + "encoding": "application/json", 32 + "schema": { 33 + "type": "array", 34 + "items": { 35 + "type": "ref", 36 + "ref": "network.slices.slice.getJobStatus#jobStatus" 37 + } 38 + } 39 + } 40 + } 41 + } 42 + }
+89
lexicons/network/slices/slice/getJobLogs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "network.slices.slice.getJobLogs", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get logs for a specific sync job.", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["jobId"], 11 + "properties": { 12 + "jobId": { 13 + "type": "string", 14 + "description": "UUID of the sync job" 15 + }, 16 + "limit": { 17 + "type": "integer", 18 + "description": "Maximum number of log entries to return", 19 + "default": 100, 20 + "minimum": 1, 21 + "maximum": 1000 22 + } 23 + } 24 + }, 25 + "output": { 26 + "encoding": "application/json", 27 + "schema": { 28 + "type": "object", 29 + "required": ["logs"], 30 + "properties": { 31 + "logs": { 32 + "type": "array", 33 + "items": { 34 + "type": "ref", 35 + "ref": "#logEntry" 36 + } 37 + } 38 + } 39 + } 40 + } 41 + }, 42 + "logEntry": { 43 + "type": "object", 44 + "required": ["id", "createdAt", "logType", "level", "message"], 45 + "properties": { 46 + "id": { 47 + "type": "integer", 48 + "description": "Log entry ID" 49 + }, 50 + "createdAt": { 51 + "type": "string", 52 + "format": "datetime", 53 + "description": "When the log entry was created" 54 + }, 55 + "logType": { 56 + "type": "string", 57 + "description": "Type of log entry", 58 + "enum": ["sync_job", "jetstream", "system"] 59 + }, 60 + "jobId": { 61 + "type": "string", 62 + "description": "UUID of related job if applicable" 63 + }, 64 + "userDid": { 65 + "type": "string", 66 + "format": "did", 67 + "description": "DID of related user if applicable" 68 + }, 69 + "sliceUri": { 70 + "type": "string", 71 + "description": "AT-URI of related slice if applicable" 72 + }, 73 + "level": { 74 + "type": "string", 75 + "description": "Log level", 76 + "enum": ["debug", "info", "warn", "error"] 77 + }, 78 + "message": { 79 + "type": "string", 80 + "description": "Log message" 81 + }, 82 + "metadata": { 83 + "type": "unknown", 84 + "description": "Additional metadata associated with the log entry" 85 + } 86 + } 87 + } 88 + } 89 + }
+100
lexicons/network/slices/slice/getJobStatus.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "network.slices.slice.getJobStatus", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get the status of a sync job by its ID.", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["jobId"], 11 + "properties": { 12 + "jobId": { 13 + "type": "string", 14 + "description": "UUID of the sync job" 15 + } 16 + } 17 + }, 18 + "output": { 19 + "encoding": "application/json", 20 + "schema": { 21 + "type": "ref", 22 + "ref": "#jobStatus" 23 + } 24 + } 25 + }, 26 + "jobStatus": { 27 + "type": "object", 28 + "required": ["jobId", "status", "createdAt", "retryCount"], 29 + "properties": { 30 + "jobId": { 31 + "type": "string", 32 + "description": "UUID of the job" 33 + }, 34 + "status": { 35 + "type": "string", 36 + "description": "Current status of the job", 37 + "enum": ["pending", "running", "completed", "failed"] 38 + }, 39 + "createdAt": { 40 + "type": "string", 41 + "format": "datetime", 42 + "description": "When the job was created" 43 + }, 44 + "startedAt": { 45 + "type": "string", 46 + "format": "datetime", 47 + "description": "When the job started executing" 48 + }, 49 + "completedAt": { 50 + "type": "string", 51 + "format": "datetime", 52 + "description": "When the job completed" 53 + }, 54 + "result": { 55 + "type": "ref", 56 + "ref": "#syncJobResult", 57 + "description": "Job result if completed successfully" 58 + }, 59 + "error": { 60 + "type": "string", 61 + "description": "Error message if job failed" 62 + }, 63 + "retryCount": { 64 + "type": "integer", 65 + "description": "Number of times the job has been retried" 66 + } 67 + } 68 + }, 69 + "syncJobResult": { 70 + "type": "object", 71 + "required": ["success", "totalRecords", "collectionsSynced", "reposProcessed", "message"], 72 + "properties": { 73 + "success": { 74 + "type": "boolean", 75 + "description": "Whether the sync job completed successfully" 76 + }, 77 + "totalRecords": { 78 + "type": "integer", 79 + "description": "Total number of records synced" 80 + }, 81 + "collectionsSynced": { 82 + "type": "array", 83 + "description": "List of collection NSIDs that were synced", 84 + "items": { 85 + "type": "string", 86 + "format": "nsid" 87 + } 88 + }, 89 + "reposProcessed": { 90 + "type": "integer", 91 + "description": "Number of repositories processed" 92 + }, 93 + "message": { 94 + "type": "string", 95 + "description": "Human-readable message about the job completion" 96 + } 97 + } 98 + } 99 + } 100 + }
+110
lexicons/network/slices/slice/getOAuthClients.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "network.slices.slice.getOAuthClients", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get all OAuth clients registered for a slice. Requires authentication.", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["slice"], 11 + "properties": { 12 + "slice": { 13 + "type": "string", 14 + "description": "AT-URI of the slice to get OAuth clients for" 15 + } 16 + } 17 + }, 18 + "output": { 19 + "encoding": "application/json", 20 + "schema": { 21 + "type": "object", 22 + "required": ["clients"], 23 + "properties": { 24 + "clients": { 25 + "type": "array", 26 + "items": { 27 + "type": "ref", 28 + "ref": "#oauthClientDetails" 29 + } 30 + } 31 + } 32 + } 33 + } 34 + }, 35 + "oauthClientDetails": { 36 + "type": "object", 37 + "required": ["clientId", "clientName", "redirectUris", "grantTypes", "responseTypes", "createdAt", "createdByDid"], 38 + "properties": { 39 + "clientId": { 40 + "type": "string", 41 + "description": "OAuth client ID" 42 + }, 43 + "clientSecret": { 44 + "type": "string", 45 + "description": "OAuth client secret (only returned on creation)" 46 + }, 47 + "clientName": { 48 + "type": "string", 49 + "description": "Human-readable name of the OAuth client" 50 + }, 51 + "redirectUris": { 52 + "type": "array", 53 + "description": "Allowed redirect URIs for OAuth flow", 54 + "items": { 55 + "type": "string", 56 + "format": "uri" 57 + } 58 + }, 59 + "grantTypes": { 60 + "type": "array", 61 + "description": "Allowed OAuth grant types", 62 + "items": { 63 + "type": "string" 64 + } 65 + }, 66 + "responseTypes": { 67 + "type": "array", 68 + "description": "Allowed OAuth response types", 69 + "items": { 70 + "type": "string" 71 + } 72 + }, 73 + "scope": { 74 + "type": "string", 75 + "description": "OAuth scope" 76 + }, 77 + "clientUri": { 78 + "type": "string", 79 + "format": "uri", 80 + "description": "URI of the client application" 81 + }, 82 + "logoUri": { 83 + "type": "string", 84 + "format": "uri", 85 + "description": "URI of the client logo" 86 + }, 87 + "tosUri": { 88 + "type": "string", 89 + "format": "uri", 90 + "description": "URI of the terms of service" 91 + }, 92 + "policyUri": { 93 + "type": "string", 94 + "format": "uri", 95 + "description": "URI of the privacy policy" 96 + }, 97 + "createdAt": { 98 + "type": "string", 99 + "format": "datetime", 100 + "description": "When the OAuth client was created" 101 + }, 102 + "createdByDid": { 103 + "type": "string", 104 + "format": "did", 105 + "description": "DID of the user who created this client" 106 + } 107 + } 108 + } 109 + } 110 + }
+97
lexicons/network/slices/slice/getSliceRecords.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "network.slices.slice.getSliceRecords", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Query records across all collections in a slice with filtering, sorting, and pagination.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["slice"], 13 + "properties": { 14 + "slice": { 15 + "type": "string", 16 + "description": "AT-URI of the slice to query" 17 + }, 18 + "limit": { 19 + "type": "integer", 20 + "description": "Maximum number of records to return", 21 + "default": 50, 22 + "minimum": 1, 23 + "maximum": 100 24 + }, 25 + "cursor": { 26 + "type": "string", 27 + "description": "Pagination cursor from previous response" 28 + }, 29 + "where": { 30 + "type": "unknown", 31 + "description": "Flexible filtering conditions for querying records" 32 + }, 33 + "sortBy": { 34 + "type": "unknown", 35 + "description": "Sorting configuration for result ordering" 36 + } 37 + } 38 + } 39 + }, 40 + "output": { 41 + "encoding": "application/json", 42 + "schema": { 43 + "type": "object", 44 + "required": ["records"], 45 + "properties": { 46 + "records": { 47 + "type": "array", 48 + "items": { 49 + "type": "ref", 50 + "ref": "#indexedRecord" 51 + } 52 + }, 53 + "cursor": { 54 + "type": "string", 55 + "description": "Pagination cursor for next page" 56 + } 57 + } 58 + } 59 + } 60 + }, 61 + "indexedRecord": { 62 + "type": "object", 63 + "required": ["uri", "cid", "did", "collection", "value", "indexedAt"], 64 + "properties": { 65 + "uri": { 66 + "type": "string", 67 + "format": "at-uri", 68 + "description": "AT-URI of the record" 69 + }, 70 + "cid": { 71 + "type": "string", 72 + "format": "cid", 73 + "description": "Content identifier of the record" 74 + }, 75 + "did": { 76 + "type": "string", 77 + "format": "did", 78 + "description": "DID of the record creator" 79 + }, 80 + "collection": { 81 + "type": "string", 82 + "format": "nsid", 83 + "description": "NSID of the collection this record belongs to" 84 + }, 85 + "value": { 86 + "type": "unknown", 87 + "description": "The record value/content" 88 + }, 89 + "indexedAt": { 90 + "type": "string", 91 + "format": "datetime", 92 + "description": "When this record was indexed" 93 + } 94 + } 95 + } 96 + } 97 + }
+73
lexicons/network/slices/slice/getSparklines.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "network.slices.slice.getSparklines", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Get time-series sparkline data for multiple slices showing record indexing activity over time.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["slices"], 13 + "properties": { 14 + "slices": { 15 + "type": "array", 16 + "description": "Array of slice AT-URIs to get sparkline data for", 17 + "items": { 18 + "type": "string" 19 + } 20 + }, 21 + "interval": { 22 + "type": "string", 23 + "description": "Time interval for data points", 24 + "default": "hour", 25 + "enum": ["minute", "hour", "day"] 26 + }, 27 + "duration": { 28 + "type": "string", 29 + "description": "Time range to fetch data for", 30 + "default": "24h", 31 + "enum": ["1h", "24h", "7d", "30d"] 32 + } 33 + } 34 + } 35 + }, 36 + "output": { 37 + "encoding": "application/json", 38 + "schema": { 39 + "type": "object", 40 + "required": ["sparklines"], 41 + "properties": { 42 + "sparklines": { 43 + "type": "array", 44 + "description": "Array of slice sparkline data entries", 45 + "items": { 46 + "type": "ref", 47 + "ref": "#sparklineEntry" 48 + } 49 + } 50 + } 51 + } 52 + } 53 + }, 54 + "sparklineEntry": { 55 + "type": "object", 56 + "required": ["sliceUri", "points"], 57 + "properties": { 58 + "sliceUri": { 59 + "type": "string", 60 + "description": "AT-URI of the slice" 61 + }, 62 + "points": { 63 + "type": "array", 64 + "description": "Array of sparkline data points", 65 + "items": { 66 + "type": "ref", 67 + "ref": "network.slices.slice.defs#sparklinePoint" 68 + } 69 + } 70 + } 71 + } 72 + } 73 + }
+73
lexicons/network/slices/slice/startSync.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "network.slices.slice.startSync", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Start a background sync job to index AT Protocol records into a slice. Requires authentication.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["slice"], 13 + "properties": { 14 + "slice": { 15 + "type": "string", 16 + "description": "AT-URI of the slice to sync data into" 17 + }, 18 + "collections": { 19 + "type": "array", 20 + "description": "List of collection NSIDs to sync (primary collections matching slice domain)", 21 + "items": { 22 + "type": "string", 23 + "format": "nsid" 24 + } 25 + }, 26 + "externalCollections": { 27 + "type": "array", 28 + "description": "List of external collection NSIDs to sync (collections outside slice domain)", 29 + "items": { 30 + "type": "string", 31 + "format": "nsid" 32 + } 33 + }, 34 + "repos": { 35 + "type": "array", 36 + "description": "List of specific repository DIDs to sync from", 37 + "items": { 38 + "type": "string", 39 + "format": "did" 40 + } 41 + }, 42 + "limitPerRepo": { 43 + "type": "integer", 44 + "description": "Maximum number of records to sync per repository" 45 + }, 46 + "skipValidation": { 47 + "type": "boolean", 48 + "description": "Skip lexicon validation during sync", 49 + "default": false 50 + } 51 + } 52 + } 53 + }, 54 + "output": { 55 + "encoding": "application/json", 56 + "schema": { 57 + "type": "object", 58 + "required": ["jobId", "message"], 59 + "properties": { 60 + "jobId": { 61 + "type": "string", 62 + "description": "UUID of the enqueued sync job" 63 + }, 64 + "message": { 65 + "type": "string", 66 + "description": "Success message confirming job enqueue" 67 + } 68 + } 69 + } 70 + } 71 + } 72 + } 73 + }
+76
lexicons/network/slices/slice/stats.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "network.slices.slice.stats", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get statistics for a slice including collection counts, record counts, and actor counts.", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["slice"], 11 + "properties": { 12 + "slice": { 13 + "type": "string", 14 + "description": "AT-URI of the slice to get statistics for" 15 + } 16 + } 17 + }, 18 + "output": { 19 + "encoding": "application/json", 20 + "schema": { 21 + "type": "object", 22 + "required": ["collections", "collectionStats", "totalLexicons", "totalRecords", "totalActors"], 23 + "properties": { 24 + "collections": { 25 + "type": "array", 26 + "description": "List of collection NSIDs indexed in this slice", 27 + "items": { 28 + "type": "string", 29 + "format": "nsid" 30 + } 31 + }, 32 + "collectionStats": { 33 + "type": "array", 34 + "description": "Per-collection statistics", 35 + "items": { 36 + "type": "ref", 37 + "ref": "#collectionStats" 38 + } 39 + }, 40 + "totalLexicons": { 41 + "type": "integer", 42 + "description": "Total number of lexicons defined for this slice" 43 + }, 44 + "totalRecords": { 45 + "type": "integer", 46 + "description": "Total number of records indexed in this slice" 47 + }, 48 + "totalActors": { 49 + "type": "integer", 50 + "description": "Total number of unique actors indexed in this slice" 51 + } 52 + } 53 + } 54 + } 55 + }, 56 + "collectionStats": { 57 + "type": "object", 58 + "required": ["collection", "recordCount", "uniqueActors"], 59 + "properties": { 60 + "collection": { 61 + "type": "string", 62 + "format": "nsid", 63 + "description": "Collection NSID" 64 + }, 65 + "recordCount": { 66 + "type": "integer", 67 + "description": "Number of records in this collection" 68 + }, 69 + "uniqueActors": { 70 + "type": "integer", 71 + "description": "Number of unique actors with records in this collection" 72 + } 73 + } 74 + } 75 + } 76 + }
+51
lexicons/network/slices/slice/syncUserCollections.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "network.slices.slice.syncUserCollections", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Synchronously sync the authenticated user's collections into a slice with a configurable timeout. Requires authentication.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["slice"], 13 + "properties": { 14 + "slice": { 15 + "type": "string", 16 + "description": "AT-URI of the slice to sync user data into" 17 + }, 18 + "timeoutSeconds": { 19 + "type": "integer", 20 + "description": "Timeout in seconds for the sync operation", 21 + "default": 30, 22 + "minimum": 1, 23 + "maximum": 300 24 + } 25 + } 26 + } 27 + }, 28 + "output": { 29 + "encoding": "application/json", 30 + "schema": { 31 + "type": "object", 32 + "required": ["reposProcessed", "recordsSynced", "timedOut"], 33 + "properties": { 34 + "reposProcessed": { 35 + "type": "integer", 36 + "description": "Number of repositories processed during sync" 37 + }, 38 + "recordsSynced": { 39 + "type": "integer", 40 + "description": "Number of records successfully synced" 41 + }, 42 + "timedOut": { 43 + "type": "boolean", 44 + "description": "Whether the sync operation exceeded the timeout" 45 + } 46 + } 47 + } 48 + } 49 + } 50 + } 51 + }
+67
lexicons/network/slices/slice/updateOAuthClient.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "network.slices.slice.updateOAuthClient", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Update an existing OAuth client. Requires authentication.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["clientId"], 13 + "properties": { 14 + "clientId": { 15 + "type": "string", 16 + "description": "OAuth client ID to update" 17 + }, 18 + "clientName": { 19 + "type": "string", 20 + "description": "New human-readable name of the OAuth client", 21 + "maxLength": 256 22 + }, 23 + "redirectUris": { 24 + "type": "array", 25 + "description": "New allowed redirect URIs for OAuth flow", 26 + "items": { 27 + "type": "string", 28 + "format": "uri" 29 + } 30 + }, 31 + "scope": { 32 + "type": "string", 33 + "description": "New OAuth scope" 34 + }, 35 + "clientUri": { 36 + "type": "string", 37 + "format": "uri", 38 + "description": "New URI of the client application" 39 + }, 40 + "logoUri": { 41 + "type": "string", 42 + "format": "uri", 43 + "description": "New URI of the client logo" 44 + }, 45 + "tosUri": { 46 + "type": "string", 47 + "format": "uri", 48 + "description": "New URI of the terms of service" 49 + }, 50 + "policyUri": { 51 + "type": "string", 52 + "format": "uri", 53 + "description": "New URI of the privacy policy" 54 + } 55 + } 56 + } 57 + }, 58 + "output": { 59 + "encoding": "application/json", 60 + "schema": { 61 + "type": "ref", 62 + "ref": "network.slices.slice.getOAuthClients#oauthClientDetails" 63 + } 64 + } 65 + } 66 + } 67 + }
+1 -8
packages/cli/src/commands/codegen.ts
··· 21 21 --lexicons <PATH> Directory containing lexicon files (default: ./lexicons or from slices.json) 22 22 --output <PATH> Output file path (default: ./generated_client.ts or from slices.json) 23 23 --slice <SLICE_URI> Target slice URI (required, or from slices.json) 24 - --include-slices Include Slices XRPC methods 25 24 -h, --help Show this help message 26 25 27 26 EXAMPLES: 28 27 slices codegen --slice at://did:plc:example/slice 29 28 slices codegen --lexicons ./my-lexicons --output ./src/client.ts --slice at://did:plc:example/slice 30 - slices codegen --include-slices --slice at://did:plc:example/slice 31 29 slices codegen # Uses config from slices.json 32 30 `); 33 31 } ··· 69 67 const lexiconsPath = resolve(mergedConfig.lexiconPath!); 70 68 const outputPath = resolve(mergedConfig.clientOutputPath!); 71 69 const sliceUri = mergedConfig.slice!; 72 - const excludeSlices = !args["include-slices"] as boolean; 73 70 74 71 try { 75 72 const lexiconFiles = await findLexiconFiles(lexiconsPath); ··· 98 95 99 96 const generatedCode = await generateTypeScript(validLexicons, { 100 97 sliceUri, 101 - excludeSlicesClient: excludeSlices, 102 98 }); 103 99 104 100 const outputDir = dirname(outputPath); ··· 107 103 await Deno.writeTextFile(outputPath, generatedCode); 108 104 109 105 logger.success(`Generated client: ${outputPath}`); 110 - 111 - if (!excludeSlices) { 112 - logger.info("Includes network.slices XRPC client methods"); 113 - } 106 + logger.info("Includes XRPC client methods"); 114 107 } catch (error) { 115 108 const err = error as Error; 116 109 logger.error(`Code generation failed: ${err.message}`);
+401 -298
packages/cli/src/generated_client.ts
··· 1 1 // Generated TypeScript client for AT Protocol records 2 - // Generated at: 2025-09-24 17:50:27 UTC 3 - // Lexicons: 25 2 + // Generated at: 2025-09-26 18:21:58 UTC 3 + // Lexicons: 40 4 4 5 5 /** 6 6 * @example Usage ··· 53 53 type AuthProvider, 54 54 type BlobRef, 55 55 type CountRecordsResponse, 56 - type GetActorsParams, 57 - type GetActorsResponse, 58 56 type GetRecordParams, 59 57 type GetRecordsResponse, 60 58 type IndexedRecordFields, 61 59 type RecordResponse, 62 - type SliceLevelRecordsParams, 63 - type SliceRecordsOutput, 64 60 SlicesClient, 65 61 type SortField, 66 62 type WhereCondition, 67 63 } from "@slices/client"; 68 64 import type { OAuthClient } from "@slices/oauth"; 69 - 70 - export interface BulkSyncParams { 71 - collections?: string[]; 72 - externalCollections?: string[]; 73 - repos?: string[]; 74 - limitPerRepo?: number; 75 - } 76 - 77 - export interface BulkSyncOutput { 78 - success: boolean; 79 - totalRecords: number; 80 - collectionsSynced: string[]; 81 - reposProcessed: number; 82 - message: string; 83 - } 84 - 85 - export interface SyncJobResponse { 86 - success: boolean; 87 - jobId?: string; 88 - message: string; 89 - } 90 - 91 - export interface SyncJobResult { 92 - success: boolean; 93 - totalRecords: number; 94 - collectionsSynced: string[]; 95 - reposProcessed: number; 96 - message: string; 97 - } 98 - 99 - export interface JobStatus { 100 - jobId: string; 101 - status: string; 102 - createdAt: string; 103 - startedAt?: string; 104 - completedAt?: string; 105 - result?: SyncJobResult; 106 - error?: string; 107 - retryCount: number; 108 - } 109 - 110 - export interface GetJobStatusParams { 111 - jobId: string; 112 - } 113 - 114 - export interface GetJobHistoryParams { 115 - userDid: string; 116 - sliceUri: string; 117 - limit?: number; 118 - } 119 - 120 - export type GetJobHistoryResponse = JobStatus[]; 121 - 122 - export interface GetJobLogsParams { 123 - jobId: string; 124 - limit?: number; 125 - } 126 - 127 - export interface GetJobLogsResponse { 128 - logs: LogEntry[]; 129 - } 130 - 131 - export interface GetJetstreamLogsParams { 132 - limit?: number; 133 - } 134 - 135 - export interface GetJetstreamLogsResponse { 136 - logs: LogEntry[]; 137 - } 138 - 139 - export interface LogEntry { 140 - id: number; 141 - createdAt: string; 142 - logType: string; 143 - jobId?: string; 144 - userDid?: string; 145 - sliceUri?: string; 146 - level: string; 147 - message: string; 148 - metadata?: Record<string, unknown>; 149 - } 150 - 151 - export interface SyncUserCollectionsRequest { 152 - slice: string; 153 - timeoutSeconds?: number; 154 - } 155 - 156 - export interface SyncUserCollectionsResult { 157 - success: boolean; 158 - reposProcessed: number; 159 - recordsSynced: number; 160 - timedOut: boolean; 161 - message: string; 162 - } 163 - 164 - export interface JetstreamStatusResponse { 165 - connected: boolean; 166 - status: string; 167 - error?: string; 168 - } 169 - 170 - export interface CollectionStats { 171 - collection: string; 172 - recordCount: number; 173 - uniqueActors: number; 174 - } 175 - 176 - export interface SliceStatsParams { 177 - slice: string; 178 - } 179 - 180 - export interface SliceStatsOutput { 181 - success: boolean; 182 - collections: string[]; 183 - collectionStats: CollectionStats[]; 184 - totalLexicons: number; 185 - totalRecords: number; 186 - totalActors: number; 187 - message?: string; 188 - } 189 - 190 - export interface GetSparklinesParams { 191 - slices: string[]; 192 - interval?: string; 193 - duration?: string; 194 - } 195 - 196 - export interface GetSparklinesOutput { 197 - success: boolean; 198 - sparklines: Record<string, NetworkSlicesSliceDefs["SparklinePoint"][]>; 199 - message?: string; 200 - } 201 - 202 - export interface CreateOAuthClientRequest { 203 - clientName: string; 204 - redirectUris: string[]; 205 - grantTypes?: string[]; 206 - responseTypes?: string[]; 207 - scope?: string; 208 - clientUri?: string; 209 - logoUri?: string; 210 - tosUri?: string; 211 - policyUri?: string; 212 - } 213 - 214 - export interface OAuthClientDetails { 215 - clientId: string; 216 - clientSecret?: string; 217 - clientName: string; 218 - redirectUris: string[]; 219 - grantTypes: string[]; 220 - responseTypes: string[]; 221 - scope?: string; 222 - clientUri?: string; 223 - logoUri?: string; 224 - tosUri?: string; 225 - policyUri?: string; 226 - createdAt: string; 227 - createdByDid: string; 228 - } 229 - 230 - export interface ListOAuthClientsResponse { 231 - clients: OAuthClientDetails[]; 232 - } 233 - 234 - export interface UpdateOAuthClientRequest { 235 - clientId: string; 236 - clientName?: string; 237 - redirectUris?: string[]; 238 - scope?: string; 239 - clientUri?: string; 240 - logoUri?: string; 241 - tosUri?: string; 242 - policyUri?: string; 243 - } 244 - 245 - export interface DeleteOAuthClientResponse { 246 - success: boolean; 247 - message: string; 248 - } 249 - 250 - export interface OAuthOperationError { 251 - success: false; 252 - message: string; 253 - } 254 65 255 66 export type AppBskyGraphDefsListPurpose = 256 67 | "app.bsky.graph.defs#modlist" ··· 1144 955 | "createdAt" 1145 956 | "expiresAt"; 1146 957 958 + export interface NetworkSlicesSliceSyncUserCollectionsInput { 959 + slice: string; 960 + timeoutSeconds?: number; 961 + } 962 + 963 + export interface NetworkSlicesSliceSyncUserCollectionsOutput { 964 + reposProcessed: number; 965 + recordsSynced: number; 966 + timedOut: boolean; 967 + } 968 + 1147 969 export interface NetworkSlicesSliceDefsSliceView { 1148 970 uri: string; 1149 971 cid: string; ··· 1169 991 count: number; 1170 992 } 1171 993 994 + export interface NetworkSlicesSliceGetSparklinesInput { 995 + slices: string[]; 996 + interval?: string; 997 + duration?: string; 998 + } 999 + 1000 + export interface NetworkSlicesSliceGetSparklinesOutput { 1001 + sparklines: NetworkSlicesSliceGetSparklines["SparklineEntry"][]; 1002 + } 1003 + 1004 + export interface NetworkSlicesSliceGetSparklinesSparklineEntry { 1005 + /** AT-URI of the slice */ 1006 + sliceUri: string; 1007 + /** Array of sparkline data points */ 1008 + points: NetworkSlicesSliceDefs["SparklinePoint"][]; 1009 + } 1010 + 1011 + export interface NetworkSlicesSliceGetJobLogsParams { 1012 + jobId: string; 1013 + limit?: number; 1014 + } 1015 + 1016 + export interface NetworkSlicesSliceGetJobLogsOutput { 1017 + logs: NetworkSlicesSliceGetJobLogs["LogEntry"][]; 1018 + } 1019 + 1020 + export interface NetworkSlicesSliceGetJobLogsLogEntry { 1021 + /** Log entry ID */ 1022 + id: number; 1023 + /** When the log entry was created */ 1024 + createdAt: string; 1025 + /** Type of log entry */ 1026 + logType: string; 1027 + /** UUID of related job if applicable */ 1028 + jobId?: string; 1029 + /** DID of related user if applicable */ 1030 + userDid?: string; 1031 + /** AT-URI of related slice if applicable */ 1032 + sliceUri?: string; 1033 + /** Log level */ 1034 + level: string; 1035 + /** Log message */ 1036 + message: string; 1037 + /** Additional metadata associated with the log entry */ 1038 + metadata?: unknown; 1039 + } 1040 + 1041 + export interface NetworkSlicesSliceGetJetstreamStatusOutput { 1042 + connected: boolean; 1043 + } 1044 + 1172 1045 export interface NetworkSlicesSlice { 1173 1046 /** Name of the slice */ 1174 1047 name: string; ··· 1180 1053 1181 1054 export type NetworkSlicesSliceSortFields = "name" | "domain" | "createdAt"; 1182 1055 1056 + export interface NetworkSlicesSliceGetJobStatusParams { 1057 + jobId: string; 1058 + } 1059 + 1060 + export type NetworkSlicesSliceGetJobStatusOutput = 1061 + NetworkSlicesSliceGetJobStatus["JobStatus"]; 1062 + 1063 + export interface NetworkSlicesSliceGetJobStatusJobStatus { 1064 + /** UUID of the job */ 1065 + jobId: string; 1066 + /** Current status of the job */ 1067 + status: string; 1068 + /** When the job was created */ 1069 + createdAt: string; 1070 + /** When the job started executing */ 1071 + startedAt?: string; 1072 + /** When the job completed */ 1073 + completedAt?: string; 1074 + /** Job result if completed successfully */ 1075 + result?: NetworkSlicesSliceGetJobStatus["SyncJobResult"]; 1076 + /** Error message if job failed */ 1077 + error?: string; 1078 + /** Number of times the job has been retried */ 1079 + retryCount: number; 1080 + } 1081 + 1082 + export interface NetworkSlicesSliceGetJobStatusSyncJobResult { 1083 + /** Whether the sync job completed successfully */ 1084 + success: boolean; 1085 + /** Total number of records synced */ 1086 + totalRecords: number; 1087 + /** List of collection NSIDs that were synced */ 1088 + collectionsSynced: string[]; 1089 + /** Number of repositories processed */ 1090 + reposProcessed: number; 1091 + /** Human-readable message about the job completion */ 1092 + message: string; 1093 + } 1094 + 1095 + export interface NetworkSlicesSliceGetActorsInput { 1096 + slice: string; 1097 + limit?: number; 1098 + cursor?: string; 1099 + where?: unknown; 1100 + } 1101 + 1102 + export interface NetworkSlicesSliceGetActorsOutput { 1103 + actors: NetworkSlicesSliceGetActors["Actor"][]; 1104 + cursor?: string; 1105 + } 1106 + 1107 + export interface NetworkSlicesSliceGetActorsActor { 1108 + /** Decentralized identifier of the actor */ 1109 + did: string; 1110 + /** Human-readable handle of the actor */ 1111 + handle?: string; 1112 + /** AT-URI of the slice this actor is indexed in */ 1113 + sliceUri: string; 1114 + /** When this actor was indexed */ 1115 + indexedAt: string; 1116 + } 1117 + 1118 + export interface NetworkSlicesSliceDeleteOAuthClientInput { 1119 + clientId: string; 1120 + } 1121 + 1122 + export interface NetworkSlicesSliceDeleteOAuthClientOutput { 1123 + message: string; 1124 + } 1125 + 1126 + export interface NetworkSlicesSliceCreateOAuthClientInput { 1127 + sliceUri: string; 1128 + clientName: string; 1129 + redirectUris: string[]; 1130 + grantTypes?: string[]; 1131 + responseTypes?: string[]; 1132 + scope?: string; 1133 + clientUri?: string; 1134 + logoUri?: string; 1135 + tosUri?: string; 1136 + policyUri?: string; 1137 + } 1138 + 1139 + export type NetworkSlicesSliceCreateOAuthClientOutput = 1140 + NetworkSlicesSliceGetOAuthClients["OauthClientDetails"]; 1141 + 1142 + export interface NetworkSlicesSliceGetJetstreamLogsParams { 1143 + slice?: string; 1144 + limit?: number; 1145 + } 1146 + 1147 + export interface NetworkSlicesSliceGetJetstreamLogsOutput { 1148 + logs: NetworkSlicesSliceGetJobLogs["LogEntry"][]; 1149 + } 1150 + 1151 + export interface NetworkSlicesSliceGetSliceRecordsInput { 1152 + slice: string; 1153 + limit?: number; 1154 + cursor?: string; 1155 + where?: unknown; 1156 + sortBy?: unknown; 1157 + } 1158 + 1159 + export interface NetworkSlicesSliceGetSliceRecordsOutput { 1160 + records: NetworkSlicesSliceGetSliceRecords["IndexedRecord"][]; 1161 + cursor?: string; 1162 + } 1163 + 1164 + export interface NetworkSlicesSliceGetSliceRecordsIndexedRecord { 1165 + /** AT-URI of the record */ 1166 + uri: string; 1167 + /** Content identifier of the record */ 1168 + cid: string; 1169 + /** DID of the record creator */ 1170 + did: string; 1171 + /** NSID of the collection this record belongs to */ 1172 + collection: string; 1173 + /** The record value/content */ 1174 + value: unknown; 1175 + /** When this record was indexed */ 1176 + indexedAt: string; 1177 + } 1178 + 1179 + export interface NetworkSlicesSliceStartSyncInput { 1180 + slice: string; 1181 + collections?: string[]; 1182 + externalCollections?: string[]; 1183 + repos?: string[]; 1184 + limitPerRepo?: number; 1185 + skipValidation?: boolean; 1186 + } 1187 + 1188 + export interface NetworkSlicesSliceStartSyncOutput { 1189 + jobId: string; 1190 + message: string; 1191 + } 1192 + 1193 + export interface NetworkSlicesSliceGetJobHistoryParams { 1194 + userDid: string; 1195 + sliceUri: string; 1196 + limit?: number; 1197 + } 1198 + 1199 + export type NetworkSlicesSliceGetJobHistoryOutput = 1200 + NetworkSlicesSliceGetJobStatus["JobStatus"][]; 1201 + 1202 + export interface NetworkSlicesSliceStatsParams { 1203 + slice: string; 1204 + } 1205 + 1206 + export interface NetworkSlicesSliceStatsOutput { 1207 + collections: string[]; 1208 + collectionStats: NetworkSlicesSliceStats["CollectionStats"][]; 1209 + totalLexicons: number; 1210 + totalRecords: number; 1211 + totalActors: number; 1212 + } 1213 + 1214 + export interface NetworkSlicesSliceStatsCollectionStats { 1215 + /** Collection NSID */ 1216 + collection: string; 1217 + /** Number of records in this collection */ 1218 + recordCount: number; 1219 + /** Number of unique actors with records in this collection */ 1220 + uniqueActors: number; 1221 + } 1222 + 1223 + export interface NetworkSlicesSliceUpdateOAuthClientInput { 1224 + clientId: string; 1225 + clientName?: string; 1226 + redirectUris?: string[]; 1227 + scope?: string; 1228 + clientUri?: string; 1229 + logoUri?: string; 1230 + tosUri?: string; 1231 + policyUri?: string; 1232 + } 1233 + 1234 + export type NetworkSlicesSliceUpdateOAuthClientOutput = 1235 + NetworkSlicesSliceGetOAuthClients["OauthClientDetails"]; 1236 + 1237 + export interface NetworkSlicesSliceGetOAuthClientsParams { 1238 + slice: string; 1239 + } 1240 + 1241 + export interface NetworkSlicesSliceGetOAuthClientsOutput { 1242 + clients: NetworkSlicesSliceGetOAuthClients["OauthClientDetails"][]; 1243 + } 1244 + 1245 + export interface NetworkSlicesSliceGetOAuthClientsOauthClientDetails { 1246 + /** OAuth client ID */ 1247 + clientId: string; 1248 + /** OAuth client secret (only returned on creation) */ 1249 + clientSecret?: string; 1250 + /** Human-readable name of the OAuth client */ 1251 + clientName: string; 1252 + /** Allowed redirect URIs for OAuth flow */ 1253 + redirectUris: string[]; 1254 + /** Allowed OAuth grant types */ 1255 + grantTypes: string[]; 1256 + /** Allowed OAuth response types */ 1257 + responseTypes: string[]; 1258 + /** OAuth scope */ 1259 + scope?: string; 1260 + /** URI of the client application */ 1261 + clientUri?: string; 1262 + /** URI of the client logo */ 1263 + logoUri?: string; 1264 + /** URI of the terms of service */ 1265 + tosUri?: string; 1266 + /** URI of the privacy policy */ 1267 + policyUri?: string; 1268 + /** When the OAuth client was created */ 1269 + createdAt: string; 1270 + /** DID of the user who created this client */ 1271 + createdByDid: string; 1272 + } 1273 + 1183 1274 export interface NetworkSlicesLexicon { 1184 1275 /** Namespaced identifier for the lexicon */ 1185 1276 nsid: string; ··· 1438 1529 export interface NetworkSlicesSliceDefs { 1439 1530 readonly SliceView: NetworkSlicesSliceDefsSliceView; 1440 1531 readonly SparklinePoint: NetworkSlicesSliceDefsSparklinePoint; 1532 + } 1533 + 1534 + export interface NetworkSlicesSliceGetSparklines { 1535 + readonly SparklineEntry: NetworkSlicesSliceGetSparklinesSparklineEntry; 1536 + } 1537 + 1538 + export interface NetworkSlicesSliceGetJobLogs { 1539 + readonly LogEntry: NetworkSlicesSliceGetJobLogsLogEntry; 1540 + } 1541 + 1542 + export interface NetworkSlicesSliceGetJobStatus { 1543 + readonly JobStatus: NetworkSlicesSliceGetJobStatusJobStatus; 1544 + readonly SyncJobResult: NetworkSlicesSliceGetJobStatusSyncJobResult; 1545 + } 1546 + 1547 + export interface NetworkSlicesSliceGetActors { 1548 + readonly Actor: NetworkSlicesSliceGetActorsActor; 1549 + } 1550 + 1551 + export interface NetworkSlicesSliceGetSliceRecords { 1552 + readonly IndexedRecord: NetworkSlicesSliceGetSliceRecordsIndexedRecord; 1553 + } 1554 + 1555 + export interface NetworkSlicesSliceStats { 1556 + readonly CollectionStats: NetworkSlicesSliceStatsCollectionStats; 1557 + } 1558 + 1559 + export interface NetworkSlicesSliceGetOAuthClients { 1560 + readonly OauthClientDetails: 1561 + NetworkSlicesSliceGetOAuthClientsOauthClientDetails; 1441 1562 } 1442 1563 1443 1564 export interface NetworkSlicesActorDefs { ··· 2073 2194 return await this.client.deleteRecord("network.slices.slice", rkey); 2074 2195 } 2075 2196 2076 - async stats(params: SliceStatsParams): Promise<SliceStatsOutput> { 2077 - return await this.client.makeRequest<SliceStatsOutput>( 2078 - "network.slices.slice.stats", 2079 - "POST", 2080 - params, 2081 - ); 2197 + async syncUserCollections( 2198 + input: NetworkSlicesSliceSyncUserCollectionsInput, 2199 + ): Promise<NetworkSlicesSliceSyncUserCollectionsOutput> { 2200 + return await this.client.makeRequest< 2201 + NetworkSlicesSliceSyncUserCollectionsOutput 2202 + >("network.slices.slice.syncUserCollections", "POST", input); 2082 2203 } 2083 2204 2084 2205 async getSparklines( 2085 - params: GetSparklinesParams, 2086 - ): Promise<GetSparklinesOutput> { 2087 - return await this.client.makeRequest<GetSparklinesOutput>( 2206 + input: NetworkSlicesSliceGetSparklinesInput, 2207 + ): Promise<NetworkSlicesSliceGetSparklinesOutput> { 2208 + return await this.client.makeRequest<NetworkSlicesSliceGetSparklinesOutput>( 2088 2209 "network.slices.slice.getSparklines", 2089 2210 "POST", 2090 - params, 2211 + input, 2091 2212 ); 2092 2213 } 2093 2214 2094 - async getSliceRecords<T = Record<string, unknown>>( 2095 - params: Omit<SliceLevelRecordsParams<T>, "slice">, 2096 - ): Promise<SliceRecordsOutput<T>> { 2097 - // Combine where and orWhere into the expected backend format 2098 - const whereClause: Record<string, unknown> = params?.where 2099 - ? { ...params.where } 2100 - : {}; 2101 - if (params?.orWhere) { 2102 - whereClause.$or = params.orWhere; 2103 - } 2104 - 2105 - const requestParams = { 2106 - ...params, 2107 - where: Object.keys(whereClause).length > 0 ? whereClause : undefined, 2108 - orWhere: undefined, // Remove orWhere as it's now in where.$or 2109 - slice: this.client.sliceUri, 2110 - }; 2111 - return await this.client.makeRequest<SliceRecordsOutput<T>>( 2112 - "network.slices.slice.getSliceRecords", 2113 - "POST", 2114 - requestParams, 2115 - ); 2116 - } 2117 - 2118 - async getActors(params?: GetActorsParams): Promise<GetActorsResponse> { 2119 - const requestParams = { ...params, slice: this.client.sliceUri }; 2120 - return await this.client.makeRequest<GetActorsResponse>( 2121 - "network.slices.slice.getActors", 2122 - "POST", 2123 - requestParams, 2215 + async getJobLogs( 2216 + params?: NetworkSlicesSliceGetJobLogsParams, 2217 + ): Promise<NetworkSlicesSliceGetJobLogsOutput> { 2218 + return await this.client.makeRequest<NetworkSlicesSliceGetJobLogsOutput>( 2219 + "network.slices.slice.getJobLogs", 2220 + "GET", 2221 + params, 2124 2222 ); 2125 2223 } 2126 2224 2127 - async startSync(params: BulkSyncParams): Promise<SyncJobResponse> { 2128 - const requestParams = { ...params, slice: this.client.sliceUri }; 2129 - return await this.client.makeRequest<SyncJobResponse>( 2130 - "network.slices.slice.startSync", 2131 - "POST", 2132 - requestParams, 2133 - ); 2225 + async getJetstreamStatus(): Promise< 2226 + NetworkSlicesSliceGetJetstreamStatusOutput 2227 + > { 2228 + return await this.client.makeRequest< 2229 + NetworkSlicesSliceGetJetstreamStatusOutput 2230 + >("network.slices.slice.getJetstreamStatus", "GET", {}); 2134 2231 } 2135 2232 2136 - async getJobStatus(params: GetJobStatusParams): Promise<JobStatus> { 2137 - return await this.client.makeRequest<JobStatus>( 2233 + async getJobStatus( 2234 + params?: NetworkSlicesSliceGetJobStatusParams, 2235 + ): Promise<NetworkSlicesSliceGetJobStatusOutput> { 2236 + return await this.client.makeRequest<NetworkSlicesSliceGetJobStatusOutput>( 2138 2237 "network.slices.slice.getJobStatus", 2139 2238 "GET", 2140 2239 params, 2141 2240 ); 2142 2241 } 2143 2242 2144 - async getJobHistory( 2145 - params: GetJobHistoryParams, 2146 - ): Promise<GetJobHistoryResponse> { 2147 - return await this.client.makeRequest<GetJobHistoryResponse>( 2148 - "network.slices.slice.getJobHistory", 2149 - "GET", 2150 - params, 2243 + async getActors( 2244 + input: NetworkSlicesSliceGetActorsInput, 2245 + ): Promise<NetworkSlicesSliceGetActorsOutput> { 2246 + return await this.client.makeRequest<NetworkSlicesSliceGetActorsOutput>( 2247 + "network.slices.slice.getActors", 2248 + "POST", 2249 + input, 2151 2250 ); 2152 2251 } 2153 2252 2154 - async getJobLogs(params: GetJobLogsParams): Promise<GetJobLogsResponse> { 2155 - return await this.client.makeRequest<GetJobLogsResponse>( 2156 - "network.slices.slice.getJobLogs", 2157 - "GET", 2158 - params, 2159 - ); 2253 + async deleteOAuthClient( 2254 + input: NetworkSlicesSliceDeleteOAuthClientInput, 2255 + ): Promise<NetworkSlicesSliceDeleteOAuthClientOutput> { 2256 + return await this.client.makeRequest< 2257 + NetworkSlicesSliceDeleteOAuthClientOutput 2258 + >("network.slices.slice.deleteOAuthClient", "POST", input); 2160 2259 } 2161 2260 2162 - async getJetstreamStatus(): Promise<JetstreamStatusResponse> { 2163 - return await this.client.makeRequest<JetstreamStatusResponse>( 2164 - "network.slices.slice.getJetstreamStatus", 2165 - "GET", 2166 - ); 2261 + async createOAuthClient( 2262 + input: NetworkSlicesSliceCreateOAuthClientInput, 2263 + ): Promise<NetworkSlicesSliceCreateOAuthClientOutput> { 2264 + return await this.client.makeRequest< 2265 + NetworkSlicesSliceCreateOAuthClientOutput 2266 + >("network.slices.slice.createOAuthClient", "POST", input); 2167 2267 } 2168 2268 2169 2269 async getJetstreamLogs( 2170 - params: GetJetstreamLogsParams, 2171 - ): Promise<GetJetstreamLogsResponse> { 2172 - const requestParams = { ...params, slice: this.client.sliceUri }; 2173 - return await this.client.makeRequest<GetJetstreamLogsResponse>( 2174 - "network.slices.slice.getJetstreamLogs", 2175 - "GET", 2176 - requestParams, 2177 - ); 2270 + params?: NetworkSlicesSliceGetJetstreamLogsParams, 2271 + ): Promise<NetworkSlicesSliceGetJetstreamLogsOutput> { 2272 + return await this.client.makeRequest< 2273 + NetworkSlicesSliceGetJetstreamLogsOutput 2274 + >("network.slices.slice.getJetstreamLogs", "GET", params); 2275 + } 2276 + 2277 + async getSliceRecords( 2278 + input: NetworkSlicesSliceGetSliceRecordsInput, 2279 + ): Promise<NetworkSlicesSliceGetSliceRecordsOutput> { 2280 + return await this.client.makeRequest< 2281 + NetworkSlicesSliceGetSliceRecordsOutput 2282 + >("network.slices.slice.getSliceRecords", "POST", input); 2178 2283 } 2179 2284 2180 - async syncUserCollections( 2181 - params?: SyncUserCollectionsRequest, 2182 - ): Promise<SyncUserCollectionsResult> { 2183 - const requestParams = { slice: this.client.sliceUri, ...params }; 2184 - return await this.client.makeRequest<SyncUserCollectionsResult>( 2185 - "network.slices.slice.syncUserCollections", 2285 + async startSync( 2286 + input: NetworkSlicesSliceStartSyncInput, 2287 + ): Promise<NetworkSlicesSliceStartSyncOutput> { 2288 + return await this.client.makeRequest<NetworkSlicesSliceStartSyncOutput>( 2289 + "network.slices.slice.startSync", 2186 2290 "POST", 2187 - requestParams, 2291 + input, 2188 2292 ); 2189 2293 } 2190 2294 2191 - async createOAuthClient( 2192 - params: CreateOAuthClientRequest, 2193 - ): Promise<OAuthClientDetails | OAuthOperationError> { 2194 - const requestParams = { ...params, sliceUri: this.client.sliceUri }; 2195 - return await this.client.makeRequest< 2196 - OAuthClientDetails | OAuthOperationError 2197 - >("network.slices.slice.createOAuthClient", "POST", requestParams); 2295 + async getJobHistory( 2296 + params?: NetworkSlicesSliceGetJobHistoryParams, 2297 + ): Promise<NetworkSlicesSliceGetJobHistoryOutput> { 2298 + return await this.client.makeRequest<NetworkSlicesSliceGetJobHistoryOutput>( 2299 + "network.slices.slice.getJobHistory", 2300 + "GET", 2301 + params, 2302 + ); 2198 2303 } 2199 2304 2200 - async getOAuthClients(): Promise<ListOAuthClientsResponse> { 2201 - const requestParams = { slice: this.client.sliceUri }; 2202 - return await this.client.makeRequest<ListOAuthClientsResponse>( 2203 - "network.slices.slice.getOAuthClients", 2305 + async stats( 2306 + params?: NetworkSlicesSliceStatsParams, 2307 + ): Promise<NetworkSlicesSliceStatsOutput> { 2308 + return await this.client.makeRequest<NetworkSlicesSliceStatsOutput>( 2309 + "network.slices.slice.stats", 2204 2310 "GET", 2205 - requestParams, 2311 + params, 2206 2312 ); 2207 2313 } 2208 2314 2209 2315 async updateOAuthClient( 2210 - params: UpdateOAuthClientRequest, 2211 - ): Promise<OAuthClientDetails | OAuthOperationError> { 2212 - const requestParams = { ...params, sliceUri: this.client.sliceUri }; 2316 + input: NetworkSlicesSliceUpdateOAuthClientInput, 2317 + ): Promise<NetworkSlicesSliceUpdateOAuthClientOutput> { 2213 2318 return await this.client.makeRequest< 2214 - OAuthClientDetails | OAuthOperationError 2215 - >("network.slices.slice.updateOAuthClient", "POST", requestParams); 2319 + NetworkSlicesSliceUpdateOAuthClientOutput 2320 + >("network.slices.slice.updateOAuthClient", "POST", input); 2216 2321 } 2217 2322 2218 - async deleteOAuthClient( 2219 - clientId: string, 2220 - ): Promise<DeleteOAuthClientResponse> { 2221 - return await this.client.makeRequest<DeleteOAuthClientResponse>( 2222 - "network.slices.slice.deleteOAuthClient", 2223 - "POST", 2224 - { clientId }, 2225 - ); 2323 + async getOAuthClients( 2324 + params?: NetworkSlicesSliceGetOAuthClientsParams, 2325 + ): Promise<NetworkSlicesSliceGetOAuthClientsOutput> { 2326 + return await this.client.makeRequest< 2327 + NetworkSlicesSliceGetOAuthClientsOutput 2328 + >("network.slices.slice.getOAuthClients", "GET", params); 2226 2329 } 2227 2330 } 2228 2331
+13 -11
packages/client/src/mod.ts
··· 26 26 } 27 27 28 28 export interface CountRecordsResponse { 29 - success: boolean; 30 29 count: number; 31 - message?: string; 32 30 } 33 31 34 32 export interface GetRecordParams { ··· 105 103 } 106 104 107 105 export interface SliceRecordsOutput<T = Record<string, unknown>> { 108 - success: boolean; 109 106 records: IndexedRecord<T>[]; 110 107 cursor?: string; 111 - message?: string; 112 108 } 113 109 114 110 // Blob upload interfaces ··· 199 195 200 196 if (httpMethod === "GET" && params) { 201 197 const searchParams = new URLSearchParams(); 202 - Object.entries(params).forEach(([key, value]) => { 198 + Object.entries(params as Record<string, unknown>).forEach(([key, value]) => { 203 199 if (value !== undefined && value !== null) { 204 200 searchParams.append(key, String(value)); 205 201 } ··· 251 247 252 248 try { 253 249 const errorBody = await response.json(); 254 - if (errorBody?.message) { 255 - errorMessage += ` - ${errorBody.message}`; 250 + // XRPC-style error format: { error: "ErrorName", message: "details" } 251 + if (errorBody?.error && errorBody?.message) { 252 + errorMessage = `${errorBody.error}: ${errorBody.message}`; 253 + } else if (errorBody?.message) { 254 + errorMessage = errorBody.message; 256 255 } else if (errorBody?.error) { 257 - errorMessage += ` - ${errorBody.error}`; 256 + errorMessage = errorBody.error; 258 257 } 259 258 } catch { 260 259 // If we can't parse the response body, just use the status message ··· 355 354 let errorMessage = `Blob upload failed: ${response.status} ${response.statusText}`; 356 355 try { 357 356 const errorBody = await response.json(); 358 - if (errorBody?.message) { 359 - errorMessage += ` - ${errorBody.message}`; 357 + // XRPC-style error format: { error: "ErrorName", message: "details" } 358 + if (errorBody?.error && errorBody?.message) { 359 + errorMessage = `${errorBody.error}: ${errorBody.message}`; 360 + } else if (errorBody?.message) { 361 + errorMessage = errorBody.message; 360 362 } else if (errorBody?.error) { 361 - errorMessage += ` - ${errorBody.error}`; 363 + errorMessage = errorBody.error; 362 364 } 363 365 } catch { 364 366 // If we can't parse the response body, just use the status message
+181 -206
packages/codegen/src/client.ts
··· 4 4 import { nsidToPascalCase, capitalizeFirst } from "./mod.ts"; 5 5 6 6 interface NestedStructure { 7 - [key: string]: NestedStructure | string | undefined; 7 + [key: string]: NestedStructure | string | QueryProcedureInfo[] | undefined; 8 8 _recordType?: string; 9 9 _collectionPath?: string; 10 + _queryProcedures?: QueryProcedureInfo[]; 11 + } 12 + 13 + interface QueryProcedureInfo { 14 + nsid: string; 15 + type: "query" | "procedure"; 16 + methodName: string; 17 + parametersType?: string; 18 + inputType?: string; 19 + outputType?: string; 10 20 } 11 21 12 22 interface PropertyInfo { ··· 22 32 23 33 export function generateClient( 24 34 sourceFile: SourceFile, 25 - lexicons: Lexicon[], 26 - excludeSlicesClient: boolean 35 + lexicons: Lexicon[] 27 36 ): void { 28 37 // Create nested structure from lexicons 29 38 const nestedStructure: NestedStructure = {}; 30 39 31 40 for (const lexicon of lexicons) { 32 41 if (lexicon.definitions && typeof lexicon.definitions === "object") { 33 - for (const [, defValue] of Object.entries(lexicon.definitions)) { 34 - if (defValue.type === "record" && defValue.record) { 35 - const parts = lexicon.id.split("."); 36 - let current = nestedStructure; 42 + // Check if this lexicon has any records, queries, or procedures 43 + const hasRecordsOrEndpoints = Object.values(lexicon.definitions).some( 44 + (defValue) => 45 + (defValue.type === "record" && defValue.record) || 46 + defValue.type === "query" || 47 + defValue.type === "procedure" 48 + ); 49 + 50 + // Only build nested structure for lexicons that have records 51 + if (hasRecordsOrEndpoints) { 52 + for (const [_, defValue] of Object.entries(lexicon.definitions)) { 53 + if (defValue.type === "record" && defValue.record) { 54 + const parts = lexicon.id.split("."); 55 + let current = nestedStructure; 37 56 38 - // Build nested structure 39 - for (const part of parts) { 40 - if (!current[part]) { 41 - current[part] = {}; 57 + // Build nested structure 58 + for (const part of parts) { 59 + if (!current[part]) { 60 + current[part] = {}; 61 + } 62 + current = current[part] as NestedStructure; 42 63 } 43 - current = current[part] as NestedStructure; 64 + 65 + // Add the record interface name and store collection path 66 + current._recordType = nsidToPascalCase(lexicon.id); 67 + current._collectionPath = lexicon.id; 44 68 } 69 + } 70 + } 71 + } 72 + } 45 73 46 - // Add the record interface name and store collection path 47 - current._recordType = nsidToPascalCase(lexicon.id); 48 - current._collectionPath = lexicon.id; 74 + // Add query/procedure methods to the appropriate parent clients 75 + function addQueryProcedureMethods( 76 + obj: NestedStructure, 77 + lexicons: Lexicon[] 78 + ): void { 79 + // Group query/procedure endpoints by their parent collection path 80 + const endpointsByParent = new Map<string, QueryProcedureInfo[]>(); 81 + 82 + for (const lexicon of lexicons) { 83 + if (lexicon.definitions && typeof lexicon.definitions === "object") { 84 + for (const [_, defValue] of Object.entries(lexicon.definitions)) { 85 + if (defValue.type === "query" || defValue.type === "procedure") { 86 + const parts = lexicon.id.split("."); 87 + const methodName = parts[parts.length - 1]; 88 + 89 + // Find the parent collection by removing the method name 90 + // e.g., "network.slices.slice.getJobStatus" -> "network.slices.slice" 91 + const parentPath = parts.slice(0, -1).join("."); 92 + 93 + const queryProcedureInfo: QueryProcedureInfo = { 94 + nsid: lexicon.id, 95 + type: defValue.type as "query" | "procedure", 96 + methodName, 97 + parametersType: 98 + defValue.parameters?.properties && 99 + Object.keys(defValue.parameters.properties).length > 0 100 + ? `${nsidToPascalCase(lexicon.id)}Params` 101 + : undefined, 102 + inputType: defValue.input 103 + ? `${nsidToPascalCase(lexicon.id)}Input` 104 + : undefined, 105 + outputType: defValue.output 106 + ? `${nsidToPascalCase(lexicon.id)}Output` 107 + : undefined, 108 + }; 109 + 110 + if (!endpointsByParent.has(parentPath)) { 111 + endpointsByParent.set(parentPath, []); 112 + } 113 + endpointsByParent.get(parentPath)!.push(queryProcedureInfo); 114 + } 49 115 } 50 116 } 51 117 } 118 + 119 + // Add endpoints to their respective parent clients 120 + function addEndpointsToNode( 121 + current: NestedStructure, 122 + path: string[], 123 + fullPath: string 124 + ): void { 125 + if (path.length === 0) { 126 + // We've reached the target node, add the endpoints if this has a record type 127 + const endpoints = endpointsByParent.get(fullPath); 128 + if (endpoints && current._recordType) { 129 + if (!current._queryProcedures) { 130 + current._queryProcedures = []; 131 + } 132 + current._queryProcedures.push(...endpoints); 133 + } 134 + return; 135 + } 136 + 137 + const [head, ...tail] = path; 138 + if (current[head]) { 139 + addEndpointsToNode(current[head] as NestedStructure, tail, fullPath); 140 + } 141 + } 142 + 143 + // Add endpoints to their parent nodes 144 + for (const parentPath of endpointsByParent.keys()) { 145 + const pathParts = parentPath.split("."); 146 + addEndpointsToNode(obj, pathParts, parentPath); 147 + } 52 148 } 149 + 150 + // Add query/procedure methods before generating classes 151 + addQueryProcedureMethods(nestedStructure, lexicons); 53 152 54 153 // Generate nested class structure 55 154 function generateNestedClass( ··· 128 227 }); 129 228 } else if (key === "_collectionPath") { 130 229 collectionPath = value as string; 230 + } else if (key === "_queryProcedures") { 231 + // Add query and procedure methods 232 + const queryProcedures = value as QueryProcedureInfo[]; 233 + for (const qp of queryProcedures) { 234 + if (qp.type === "query") { 235 + // Generate query method (GET) 236 + const parameters = []; 237 + if (qp.parametersType) { 238 + parameters.push({ 239 + name: "params", 240 + type: qp.parametersType, 241 + hasQuestionToken: true, 242 + }); 243 + } 244 + methods.push({ 245 + name: qp.methodName, 246 + parameters, 247 + returnType: `Promise<${qp.outputType || "void"}>`, 248 + }); 249 + } else if (qp.type === "procedure") { 250 + // Generate procedure method (POST) 251 + const parameters = []; 252 + if (qp.inputType) { 253 + parameters.push({ 254 + name: "input", 255 + type: qp.inputType, 256 + }); 257 + } else if (qp.parametersType) { 258 + parameters.push({ 259 + name: "params", 260 + type: qp.parametersType, 261 + }); 262 + } 263 + methods.push({ 264 + name: qp.methodName, 265 + parameters, 266 + returnType: `Promise<${qp.outputType || "void"}>`, 267 + }); 268 + } 269 + } 131 270 } else if (typeof value === "object" && Object.keys(value).length > 0) { 132 271 // Add nested property with PascalCase class name 133 272 const nestedClassName = `${capitalizeFirst(key)}${className}`; ··· 246 385 methodDecl.addStatements([ 247 386 `return await ${clientRef}.countRecords('${collectionPath}', params);`, 248 387 ]); 388 + } else { 389 + // Handle query and procedure methods 390 + const queryProcedures = obj._queryProcedures || []; 391 + const matchingQP = queryProcedures.find( 392 + (qp) => qp.methodName === method.name 393 + ); 394 + 395 + if (matchingQP) { 396 + if (matchingQP.type === "query") { 397 + // Query methods use GET with query parameters 398 + const paramArg = method.parameters.length > 0 ? "params" : "{}"; 399 + methodDecl.addStatements([ 400 + `return await ${clientRef}.makeRequest<${ 401 + matchingQP.outputType || "void" 402 + }>('${matchingQP.nsid}', 'GET', ${paramArg});`, 403 + ]); 404 + } else if (matchingQP.type === "procedure") { 405 + // Procedure methods use POST with body 406 + const paramArg = 407 + method.parameters.length > 0 ? method.parameters[0].name : "{}"; 408 + methodDecl.addStatements([ 409 + `return await ${clientRef}.makeRequest<${ 410 + matchingQP.outputType || "void" 411 + }>('${matchingQP.nsid}', 'POST', ${paramArg});`, 412 + ]); 413 + } 414 + } 249 415 } 250 - } 251 - 252 - // Add network.slices.slice specific methods when not excluding slices client 253 - if ( 254 - !excludeSlicesClient && 255 - currentPath.length === 3 && 256 - currentPath[0] === "network" && 257 - currentPath[1] === "slices" && 258 - currentPath[2] === "slice" 259 - ) { 260 - classDeclaration.addMethod({ 261 - name: "stats", 262 - parameters: [{ name: "params", type: "SliceStatsParams" }], 263 - returnType: "Promise<SliceStatsOutput>", 264 - isAsync: true, 265 - statements: [ 266 - `return await this.client.makeRequest<SliceStatsOutput>('network.slices.slice.stats', 'POST', params);`, 267 - ], 268 - }); 269 - 270 - classDeclaration.addMethod({ 271 - name: "getSparklines", 272 - parameters: [{ name: "params", type: "GetSparklinesParams" }], 273 - returnType: "Promise<GetSparklinesOutput>", 274 - isAsync: true, 275 - statements: [ 276 - `return await this.client.makeRequest<GetSparklinesOutput>('network.slices.slice.getSparklines', 'POST', params);`, 277 - ], 278 - }); 279 - 280 - classDeclaration.addMethod({ 281 - name: "getSliceRecords", 282 - typeParameters: [{ name: "T", default: "Record<string, unknown>" }], 283 - parameters: [ 284 - { 285 - name: "params", 286 - type: "Omit<SliceLevelRecordsParams<T>, 'slice'>", 287 - }, 288 - ], 289 - returnType: "Promise<SliceRecordsOutput<T>>", 290 - isAsync: true, 291 - statements: [ 292 - `// Combine where and orWhere into the expected backend format`, 293 - `const whereClause: Record<string, unknown> = params?.where ? { ...params.where } : {};`, 294 - `if (params?.orWhere) {`, 295 - ` whereClause.$or = params.orWhere;`, 296 - `}`, 297 - `const requestParams = {`, 298 - ` ...params,`, 299 - ` where: Object.keys(whereClause).length > 0 ? whereClause : undefined,`, 300 - ` orWhere: undefined, // Remove orWhere as it's now in where.$or`, 301 - ` slice: this.client.sliceUri`, 302 - `};`, 303 - `return await this.client.makeRequest<SliceRecordsOutput<T>>('network.slices.slice.getSliceRecords', 'POST', requestParams);`, 304 - ], 305 - }); 306 - 307 - classDeclaration.addMethod({ 308 - name: "getActors", 309 - parameters: [ 310 - { name: "params", type: "GetActorsParams", hasQuestionToken: true }, 311 - ], 312 - returnType: "Promise<GetActorsResponse>", 313 - isAsync: true, 314 - statements: [ 315 - `const requestParams = { ...params, slice: this.client.sliceUri };`, 316 - `return await this.client.makeRequest<GetActorsResponse>('network.slices.slice.getActors', 'POST', requestParams);`, 317 - ], 318 - }); 319 - 320 - // Add sync methods 321 - classDeclaration.addMethod({ 322 - name: "startSync", 323 - parameters: [{ name: "params", type: "BulkSyncParams" }], 324 - returnType: "Promise<SyncJobResponse>", 325 - isAsync: true, 326 - statements: [ 327 - `const requestParams = { ...params, slice: this.client.sliceUri };`, 328 - `return await this.client.makeRequest<SyncJobResponse>('network.slices.slice.startSync', 'POST', requestParams);`, 329 - ], 330 - }); 331 - 332 - classDeclaration.addMethod({ 333 - name: "getJobStatus", 334 - parameters: [{ name: "params", type: "GetJobStatusParams" }], 335 - returnType: "Promise<JobStatus>", 336 - isAsync: true, 337 - statements: [ 338 - `return await this.client.makeRequest<JobStatus>('network.slices.slice.getJobStatus', 'GET', params);`, 339 - ], 340 - }); 341 - 342 - classDeclaration.addMethod({ 343 - name: "getJobHistory", 344 - parameters: [{ name: "params", type: "GetJobHistoryParams" }], 345 - returnType: "Promise<GetJobHistoryResponse>", 346 - isAsync: true, 347 - statements: [ 348 - `return await this.client.makeRequest<GetJobHistoryResponse>('network.slices.slice.getJobHistory', 'GET', params);`, 349 - ], 350 - }); 351 - 352 - classDeclaration.addMethod({ 353 - name: "getJobLogs", 354 - parameters: [{ name: "params", type: "GetJobLogsParams" }], 355 - returnType: "Promise<GetJobLogsResponse>", 356 - isAsync: true, 357 - statements: [ 358 - `return await this.client.makeRequest<GetJobLogsResponse>('network.slices.slice.getJobLogs', 'GET', params);`, 359 - ], 360 - }); 361 - 362 - classDeclaration.addMethod({ 363 - name: "getJetstreamStatus", 364 - returnType: "Promise<JetstreamStatusResponse>", 365 - isAsync: true, 366 - statements: [ 367 - `return await this.client.makeRequest<JetstreamStatusResponse>('network.slices.slice.getJetstreamStatus', 'GET');`, 368 - ], 369 - }); 370 - 371 - classDeclaration.addMethod({ 372 - name: "getJetstreamLogs", 373 - parameters: [{ name: "params", type: "GetJetstreamLogsParams" }], 374 - returnType: "Promise<GetJetstreamLogsResponse>", 375 - isAsync: true, 376 - statements: [ 377 - `const requestParams = { ...params, slice: this.client.sliceUri };`, 378 - `return await this.client.makeRequest<GetJetstreamLogsResponse>('network.slices.slice.getJetstreamLogs', 'GET', requestParams);`, 379 - ], 380 - }); 381 - 382 - classDeclaration.addMethod({ 383 - name: "syncUserCollections", 384 - parameters: [ 385 - { 386 - name: "params", 387 - type: "SyncUserCollectionsRequest", 388 - hasQuestionToken: true, 389 - }, 390 - ], 391 - returnType: "Promise<SyncUserCollectionsResult>", 392 - isAsync: true, 393 - statements: [ 394 - `const requestParams = { slice: this.client.sliceUri, ...params };`, 395 - `return await this.client.makeRequest<SyncUserCollectionsResult>('network.slices.slice.syncUserCollections', 'POST', requestParams);`, 396 - ], 397 - }); 398 - 399 - // Add OAuth client management methods 400 - classDeclaration.addMethod({ 401 - name: "createOAuthClient", 402 - parameters: [{ name: "params", type: "CreateOAuthClientRequest" }], 403 - returnType: "Promise<OAuthClientDetails | OAuthOperationError>", 404 - isAsync: true, 405 - statements: [ 406 - `const requestParams = { ...params, sliceUri: this.client.sliceUri };`, 407 - `return await this.client.makeRequest<OAuthClientDetails | OAuthOperationError>('network.slices.slice.createOAuthClient', 'POST', requestParams);`, 408 - ], 409 - }); 410 - 411 - classDeclaration.addMethod({ 412 - name: "getOAuthClients", 413 - returnType: "Promise<ListOAuthClientsResponse>", 414 - isAsync: true, 415 - statements: [ 416 - `const requestParams = { slice: this.client.sliceUri };`, 417 - `return await this.client.makeRequest<ListOAuthClientsResponse>('network.slices.slice.getOAuthClients', 'GET', requestParams);`, 418 - ], 419 - }); 420 - 421 - classDeclaration.addMethod({ 422 - name: "updateOAuthClient", 423 - parameters: [{ name: "params", type: "UpdateOAuthClientRequest" }], 424 - returnType: "Promise<OAuthClientDetails | OAuthOperationError>", 425 - isAsync: true, 426 - statements: [ 427 - `const requestParams = { ...params, sliceUri: this.client.sliceUri };`, 428 - `return await this.client.makeRequest<OAuthClientDetails | OAuthOperationError>('network.slices.slice.updateOAuthClient', 'POST', requestParams);`, 429 - ], 430 - }); 431 - 432 - classDeclaration.addMethod({ 433 - name: "deleteOAuthClient", 434 - parameters: [{ name: "clientId", type: "string" }], 435 - returnType: "Promise<DeleteOAuthClientResponse>", 436 - isAsync: true, 437 - statements: [ 438 - `return await this.client.makeRequest<DeleteOAuthClientResponse>('network.slices.slice.deleteOAuthClient', 'POST', { clientId });`, 439 - ], 440 - }); 441 416 } 442 417 } 443 418 }
+110 -292
packages/codegen/src/interfaces.ts
··· 547 547 548 548 // Base interfaces are imported from @slices/client, only add network.slices specific interfaces 549 549 function addBaseInterfaces( 550 + _sourceFile: SourceFile 551 + ): void { 552 + // All interfaces are now generated from lexicons 553 + // This function is kept for future extensibility if needed 554 + } 555 + 556 + // Generate interfaces for query and procedure parameters/input/output 557 + function generateQueryProcedureInterfaces( 550 558 sourceFile: SourceFile, 551 - excludeSlicesClient: boolean 559 + lexicon: Lexicon, 560 + defValue: LexiconDefinition, 561 + lexicons: Lexicon[], 562 + type: "query" | "procedure" 552 563 ): void { 553 - // Only generate network.slices specific interfaces when not excluding slices client 554 - if (!excludeSlicesClient) { 555 - // Codegen XRPC interfaces 564 + const baseName = nsidToPascalCase(lexicon.id); 556 565 557 - // Sync interfaces 558 - sourceFile.addInterface({ 559 - name: "BulkSyncParams", 560 - isExported: true, 561 - properties: [ 562 - { name: "collections", type: "string[]", hasQuestionToken: true }, 563 - { 564 - name: "externalCollections", 565 - type: "string[]", 566 - hasQuestionToken: true, 567 - }, 568 - { name: "repos", type: "string[]", hasQuestionToken: true }, 569 - { name: "limitPerRepo", type: "number", hasQuestionToken: true }, 570 - ], 571 - }); 572 - 573 - sourceFile.addInterface({ 574 - name: "BulkSyncOutput", 575 - isExported: true, 576 - properties: [ 577 - { name: "success", type: "boolean" }, 578 - { name: "totalRecords", type: "number" }, 579 - { name: "collectionsSynced", type: "string[]" }, 580 - { name: "reposProcessed", type: "number" }, 581 - { name: "message", type: "string" }, 582 - ], 583 - }); 584 - 585 - // Job queue interfaces 586 - sourceFile.addInterface({ 587 - name: "SyncJobResponse", 588 - isExported: true, 589 - properties: [ 590 - { name: "success", type: "boolean" }, 591 - { name: "jobId", type: "string", hasQuestionToken: true }, 592 - { name: "message", type: "string" }, 593 - ], 594 - }); 595 - 596 - sourceFile.addInterface({ 597 - name: "SyncJobResult", 598 - isExported: true, 599 - properties: [ 600 - { name: "success", type: "boolean" }, 601 - { name: "totalRecords", type: "number" }, 602 - { name: "collectionsSynced", type: "string[]" }, 603 - { name: "reposProcessed", type: "number" }, 604 - { name: "message", type: "string" }, 605 - ], 606 - }); 607 - 608 - sourceFile.addInterface({ 609 - name: "JobStatus", 610 - isExported: true, 611 - properties: [ 612 - { name: "jobId", type: "string" }, 613 - { name: "status", type: "string" }, 614 - { name: "createdAt", type: "string" }, 615 - { name: "startedAt", type: "string", hasQuestionToken: true }, 616 - { name: "completedAt", type: "string", hasQuestionToken: true }, 617 - { name: "result", type: "SyncJobResult", hasQuestionToken: true }, 618 - { name: "error", type: "string", hasQuestionToken: true }, 619 - { name: "retryCount", type: "number" }, 620 - ], 621 - }); 622 - 623 - sourceFile.addInterface({ 624 - name: "GetJobStatusParams", 625 - isExported: true, 626 - properties: [{ name: "jobId", type: "string" }], 627 - }); 628 - 629 - sourceFile.addInterface({ 630 - name: "GetJobHistoryParams", 631 - isExported: true, 632 - properties: [ 633 - { name: "userDid", type: "string" }, 634 - { name: "sliceUri", type: "string" }, 635 - { name: "limit", type: "number", hasQuestionToken: true }, 636 - ], 637 - }); 638 - 639 - sourceFile.addTypeAlias({ 640 - name: "GetJobHistoryResponse", 641 - isExported: true, 642 - type: "JobStatus[]", 643 - }); 644 - 645 - sourceFile.addInterface({ 646 - name: "GetJobLogsParams", 647 - isExported: true, 648 - properties: [ 649 - { name: "jobId", type: "string" }, 650 - { name: "limit", type: "number", hasQuestionToken: true }, 651 - ], 652 - }); 653 - 654 - sourceFile.addInterface({ 655 - name: "GetJobLogsResponse", 656 - isExported: true, 657 - properties: [{ name: "logs", type: "LogEntry[]" }], 658 - }); 659 - 660 - sourceFile.addInterface({ 661 - name: "GetJetstreamLogsParams", 662 - isExported: true, 663 - properties: [{ name: "limit", type: "number", hasQuestionToken: true }], 664 - }); 665 - 666 - sourceFile.addInterface({ 667 - name: "GetJetstreamLogsResponse", 668 - isExported: true, 669 - properties: [{ name: "logs", type: "LogEntry[]" }], 670 - }); 671 - 672 - sourceFile.addInterface({ 673 - name: "LogEntry", 674 - isExported: true, 675 - properties: [ 676 - { name: "id", type: "number" }, 677 - { name: "createdAt", type: "string" }, 678 - { name: "logType", type: "string" }, 679 - { name: "jobId", type: "string", hasQuestionToken: true }, 680 - { name: "userDid", type: "string", hasQuestionToken: true }, 681 - { name: "sliceUri", type: "string", hasQuestionToken: true }, 682 - { name: "level", type: "string" }, 683 - { name: "message", type: "string" }, 684 - { 685 - name: "metadata", 686 - type: "Record<string, unknown>", 687 - hasQuestionToken: true, 688 - }, 689 - ], 690 - }); 691 - 692 - // Sync user collections interfaces 693 - sourceFile.addInterface({ 694 - name: "SyncUserCollectionsRequest", 695 - isExported: true, 696 - properties: [ 697 - { name: "slice", type: "string" }, 698 - { name: "timeoutSeconds", type: "number", hasQuestionToken: true }, 699 - ], 700 - }); 701 - 702 - sourceFile.addInterface({ 703 - name: "SyncUserCollectionsResult", 704 - isExported: true, 705 - properties: [ 706 - { name: "success", type: "boolean" }, 707 - { name: "reposProcessed", type: "number" }, 708 - { name: "recordsSynced", type: "number" }, 709 - { name: "timedOut", type: "boolean" }, 710 - { name: "message", type: "string" }, 711 - ], 712 - }); 713 - 714 - sourceFile.addInterface({ 715 - name: "JetstreamStatusResponse", 716 - isExported: true, 717 - properties: [ 718 - { name: "connected", type: "boolean" }, 719 - { name: "status", type: "string" }, 720 - { name: "error", type: "string", hasQuestionToken: true }, 721 - ], 722 - }); 723 - 724 - sourceFile.addInterface({ 725 - name: "CollectionStats", 726 - isExported: true, 727 - properties: [ 728 - { name: "collection", type: "string" }, 729 - { name: "recordCount", type: "number" }, 730 - { name: "uniqueActors", type: "number" }, 731 - ], 732 - }); 733 - 734 - sourceFile.addInterface({ 735 - name: "SliceStatsParams", 736 - isExported: true, 737 - properties: [{ name: "slice", type: "string" }], 738 - }); 739 - 740 - sourceFile.addInterface({ 741 - name: "SliceStatsOutput", 742 - isExported: true, 743 - properties: [ 744 - { name: "success", type: "boolean" }, 745 - { name: "collections", type: "string[]" }, 746 - { name: "collectionStats", type: "CollectionStats[]" }, 747 - { name: "totalLexicons", type: "number" }, 748 - { name: "totalRecords", type: "number" }, 749 - { name: "totalActors", type: "number" }, 750 - { name: "message", type: "string", hasQuestionToken: true }, 751 - ], 752 - }); 566 + // Generate parameters interface if present and has properties 567 + if (defValue.parameters?.properties && Object.keys(defValue.parameters.properties).length > 0) { 568 + const interfaceName = `${baseName}Params`; 569 + const properties = Object.entries(defValue.parameters.properties).map( 570 + ([propName, propDef]) => ({ 571 + name: propName, 572 + type: convertLexiconTypeToTypeScript( 573 + propDef, 574 + lexicon.id, 575 + propName, 576 + lexicons 577 + ), 578 + hasQuestionToken: !(defValue.parameters?.required || []).includes(propName), 579 + }) 580 + ); 753 581 754 582 sourceFile.addInterface({ 755 - name: "GetSparklinesParams", 583 + name: interfaceName, 756 584 isExported: true, 757 - properties: [ 758 - { name: "slices", type: "string[]" }, 759 - { name: "interval", type: "string", hasQuestionToken: true }, 760 - { name: "duration", type: "string", hasQuestionToken: true }, 761 - ], 585 + properties, 762 586 }); 587 + } 763 588 764 - sourceFile.addInterface({ 765 - name: "GetSparklinesOutput", 766 - isExported: true, 767 - properties: [ 768 - { name: "success", type: "boolean" }, 769 - { 770 - name: "sparklines", 771 - type: `Record<string, NetworkSlicesSliceDefs["SparklinePoint"][]>`, 772 - }, 773 - { name: "message", type: "string", hasQuestionToken: true }, 774 - ], 775 - }); 589 + // Generate input interface for procedures 590 + if (type === "procedure" && defValue.input?.schema) { 591 + const interfaceName = `${baseName}Input`; 776 592 777 - // OAuth client interfaces 778 - sourceFile.addInterface({ 779 - name: "CreateOAuthClientRequest", 780 - isExported: true, 781 - properties: [ 782 - { name: "clientName", type: "string" }, 783 - { name: "redirectUris", type: "string[]" }, 784 - { name: "grantTypes", type: "string[]", hasQuestionToken: true }, 785 - { name: "responseTypes", type: "string[]", hasQuestionToken: true }, 786 - { name: "scope", type: "string", hasQuestionToken: true }, 787 - { name: "clientUri", type: "string", hasQuestionToken: true }, 788 - { name: "logoUri", type: "string", hasQuestionToken: true }, 789 - { name: "tosUri", type: "string", hasQuestionToken: true }, 790 - { name: "policyUri", type: "string", hasQuestionToken: true }, 791 - ], 792 - }); 593 + if (defValue.input?.schema?.type === "object" && defValue.input.schema.properties) { 594 + const properties = Object.entries(defValue.input.schema.properties).map( 595 + ([propName, propDef]) => ({ 596 + name: propName, 597 + type: convertLexiconTypeToTypeScript( 598 + propDef, 599 + lexicon.id, 600 + propName, 601 + lexicons 602 + ), 603 + hasQuestionToken: !((defValue.input?.schema?.required as string[]) || []).includes(propName), 604 + }) 605 + ); 793 606 794 - sourceFile.addInterface({ 795 - name: "OAuthClientDetails", 796 - isExported: true, 797 - properties: [ 798 - { name: "clientId", type: "string" }, 799 - { name: "clientSecret", type: "string", hasQuestionToken: true }, 800 - { name: "clientName", type: "string" }, 801 - { name: "redirectUris", type: "string[]" }, 802 - { name: "grantTypes", type: "string[]" }, 803 - { name: "responseTypes", type: "string[]" }, 804 - { name: "scope", type: "string", hasQuestionToken: true }, 805 - { name: "clientUri", type: "string", hasQuestionToken: true }, 806 - { name: "logoUri", type: "string", hasQuestionToken: true }, 807 - { name: "tosUri", type: "string", hasQuestionToken: true }, 808 - { name: "policyUri", type: "string", hasQuestionToken: true }, 809 - { name: "createdAt", type: "string" }, 810 - { name: "createdByDid", type: "string" }, 811 - ], 812 - }); 607 + sourceFile.addInterface({ 608 + name: interfaceName, 609 + isExported: true, 610 + properties, 611 + }); 612 + } 613 + } 813 614 814 - sourceFile.addInterface({ 815 - name: "ListOAuthClientsResponse", 816 - isExported: true, 817 - properties: [{ name: "clients", type: "OAuthClientDetails[]" }], 818 - }); 615 + // Generate output interface if present 616 + if (defValue.output?.schema) { 617 + const interfaceName = `${baseName}Output`; 819 618 820 - sourceFile.addInterface({ 821 - name: "UpdateOAuthClientRequest", 822 - isExported: true, 823 - properties: [ 824 - { name: "clientId", type: "string" }, 825 - { name: "clientName", type: "string", hasQuestionToken: true }, 826 - { name: "redirectUris", type: "string[]", hasQuestionToken: true }, 827 - { name: "scope", type: "string", hasQuestionToken: true }, 828 - { name: "clientUri", type: "string", hasQuestionToken: true }, 829 - { name: "logoUri", type: "string", hasQuestionToken: true }, 830 - { name: "tosUri", type: "string", hasQuestionToken: true }, 831 - { name: "policyUri", type: "string", hasQuestionToken: true }, 832 - ], 833 - }); 619 + if (defValue.output?.schema?.type === "object" && defValue.output.schema.properties) { 620 + const properties = Object.entries(defValue.output.schema.properties).map( 621 + ([propName, propDef]) => ({ 622 + name: propName, 623 + type: convertLexiconTypeToTypeScript( 624 + propDef, 625 + lexicon.id, 626 + propName, 627 + lexicons 628 + ), 629 + hasQuestionToken: !((defValue.output?.schema?.required as string[]) || []).includes(propName), 630 + }) 631 + ); 834 632 835 - sourceFile.addInterface({ 836 - name: "DeleteOAuthClientResponse", 837 - isExported: true, 838 - properties: [ 839 - { name: "success", type: "boolean" }, 840 - { name: "message", type: "string" }, 841 - ], 842 - }); 633 + sourceFile.addInterface({ 634 + name: interfaceName, 635 + isExported: true, 636 + properties, 637 + }); 638 + } else { 639 + // Handle non-object output schemas (like refs) by creating a type alias 640 + const outputType = convertLexiconTypeToTypeScript( 641 + defValue.output.schema, 642 + lexicon.id, 643 + undefined, 644 + lexicons 645 + ); 843 646 844 - sourceFile.addInterface({ 845 - name: "OAuthOperationError", 846 - isExported: true, 847 - properties: [ 848 - { name: "success", type: "false" }, 849 - { name: "message", type: "string" }, 850 - ], 851 - }); 647 + sourceFile.addTypeAlias({ 648 + name: interfaceName, 649 + isExported: true, 650 + type: outputType, 651 + }); 652 + } 852 653 } 853 654 } 854 655 855 656 export function generateInterfaces( 856 657 sourceFile: SourceFile, 857 - lexicons: Lexicon[], 858 - excludeSlicesClient: boolean 658 + lexicons: Lexicon[] 859 659 ): void { 860 - // Base interfaces are imported from @slices/client, only add network.slices specific interfaces 861 - addBaseInterfaces(sourceFile, excludeSlicesClient); 660 + // Base interfaces are imported from @slices/client, only add custom interfaces 661 + addBaseInterfaces(sourceFile); 862 662 863 663 // Generate type aliases for string fields with knownValues 864 664 generateKnownValuesTypes(sourceFile, lexicons); ··· 896 696 break; 897 697 case "token": 898 698 generateTokenType(sourceFile, lexicon, defKey); 699 + break; 700 + case "query": 701 + generateQueryProcedureInterfaces( 702 + sourceFile, 703 + lexicon, 704 + defValue, 705 + lexicons, 706 + "query" 707 + ); 708 + break; 709 + case "procedure": 710 + generateQueryProcedureInterfaces( 711 + sourceFile, 712 + lexicon, 713 + defValue, 714 + lexicons, 715 + "procedure" 716 + ); 899 717 break; 900 718 } 901 719 }
+25 -14
packages/codegen/src/mod.ts
··· 19 19 required?: string[]; 20 20 } 21 21 22 + export interface LexiconParameters { 23 + type: "params"; 24 + properties?: Record<string, LexiconProperty>; 25 + required?: string[]; 26 + } 27 + 28 + export interface LexiconIO { 29 + encoding: string; 30 + schema?: LexiconProperty; 31 + } 32 + 22 33 export interface LexiconDefinition { 23 34 type: string; 35 + description?: string; 36 + // Record type fields 24 37 record?: LexiconRecord; 38 + key?: string; 39 + // Query/Procedure fields 40 + parameters?: LexiconParameters; 41 + input?: LexiconIO; 42 + output?: LexiconIO; 43 + // Generic schema fields 25 44 properties?: Record<string, LexiconProperty>; 26 45 required?: string[]; 27 46 refs?: string[]; ··· 39 58 40 59 export interface GenerateOptions { 41 60 sliceUri: string; 42 - excludeSlicesClient?: boolean; 43 61 } 44 62 45 63 // Normalize lexicons to use consistent property names ··· 268 286 269 287 export function generateHeaderComment( 270 288 lexicons: Lexicon[], 271 - usageExample: string, 272 - excludeSlicesClient: boolean 289 + usageExample: string 273 290 ): string { 274 291 return `// Generated TypeScript client for AT Protocol records 275 292 // Generated at: ${new Date().toISOString().slice(0, 19).replace("T", " ")} UTC ··· 277 294 278 295 ${usageExample} 279 296 280 - ${ 281 - excludeSlicesClient 282 - ? 'import { SlicesClient, type RecordResponse, type GetRecordsResponse, type CountRecordsResponse, type GetRecordParams, type WhereCondition, type IndexedRecordFields, type SortField, type BlobRef, type AuthProvider } from "@slices/client";\nimport type { OAuthClient } from "@slices/oauth";' 283 - : 'import { SlicesClient, type RecordResponse, type GetRecordsResponse, type CountRecordsResponse, type GetRecordParams, type WhereCondition, type IndexedRecordFields, type SortField, type GetActorsParams, type GetActorsResponse, type BlobRef, type SliceLevelRecordsParams, type SliceRecordsOutput, type AuthProvider } from "@slices/client";\nimport type { OAuthClient } from "@slices/oauth";' 284 - } 297 + import { SlicesClient, type RecordResponse, type GetRecordsResponse, type CountRecordsResponse, type GetRecordParams, type WhereCondition, type IndexedRecordFields, type SortField, type BlobRef, type AuthProvider } from "@slices/client"; 298 + import type { OAuthClient } from "@slices/oauth"; 285 299 286 300 `; 287 301 } ··· 338 352 ); 339 353 const headerComment = generateHeaderComment( 340 354 normalizedLexicons, 341 - usageExample, 342 - options.excludeSlicesClient || false 355 + usageExample 343 356 ); 344 357 345 358 // Add header comment and imports to the source file first ··· 348 361 // Generate interfaces and client 349 362 generateInterfaces( 350 363 sourceFile, 351 - normalizedLexicons, 352 - options.excludeSlicesClient || false 364 + normalizedLexicons 353 365 ); 354 366 generateClient( 355 367 sourceFile, 356 - normalizedLexicons, 357 - options.excludeSlicesClient || false 368 + normalizedLexicons 358 369 ); 359 370 360 371 // Get the generated code
+40 -26
packages/codegen/tests/client_test.ts
··· 28 28 }, 29 29 ]; 30 30 31 - generateClient(sourceFile, lexicons, true); 31 + generateClient(sourceFile, lexicons); 32 32 const result = sourceFile.getFullText(); 33 33 34 34 // Should create main AtProtoClient class ··· 36 36 37 37 // Should create nested class structure 38 38 assertStringIncludes(result, "class ComClient"); 39 - assertStringIncludes(result, "class ExampleComClient"); 39 + assertStringIncludes(result, "class PostExampleComClient"); 40 40 41 41 // Should have nested properties 42 42 assertStringIncludes(result, "readonly com: ComClient;"); 43 - assertStringIncludes(result, "readonly example: ExampleComClient;"); 43 + assertStringIncludes(result, "readonly post: PostExampleComClient;"); 44 44 45 45 // Should have OAuth client property 46 - assertStringIncludes(result, "readonly oauth?: OAuthClient;"); 46 + assertStringIncludes(result, "readonly oauth?: OAuthClient | AuthProvider;"); 47 47 }); 48 48 49 49 Deno.test("generateClient - creates CRUD methods for records", () => { ··· 68 68 }, 69 69 ]; 70 70 71 - generateClient(sourceFile, lexicons, true); 71 + generateClient(sourceFile, lexicons); 72 72 const result = sourceFile.getFullText(); 73 73 74 74 // Should create CRUD methods ··· 104 104 }, 105 105 ]; 106 106 107 - generateClient(sourceFile, lexicons, true); 107 + generateClient(sourceFile, lexicons); 108 108 const result = sourceFile.getFullText(); 109 109 110 110 // Main client constructor 111 - assertStringIncludes(result, "constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient)"); 111 + assertStringIncludes(result, "constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient | AuthProvider)"); 112 112 assertStringIncludes(result, "super(baseUrl, sliceUri, oauthClient);"); 113 113 114 114 // Nested class constructors ··· 116 116 assertStringIncludes(result, "this.client = client;"); 117 117 }); 118 118 119 - Deno.test("generateClient - includes network.slices specific methods when not excluded", () => { 119 + Deno.test("generateClient - creates client for network.slices lexicons", () => { 120 120 const project = createTestProject(); 121 121 const sourceFile = project.createSourceFile("test.ts", ""); 122 122 ··· 135 135 }, 136 136 ]; 137 137 138 - generateClient(sourceFile, lexicons, false); 138 + generateClient(sourceFile, lexicons); 139 139 const result = sourceFile.getFullText(); 140 140 141 - // Should include network.slices specific methods 142 - assertStringIncludes(result, "async stats("); 143 - assertStringIncludes(result, "async getSparklines("); 144 - assertStringIncludes(result, "async getSliceRecords<T = Record<string, unknown>>("); 145 - assertStringIncludes(result, "async getActors("); 146 - assertStringIncludes(result, "async startSync("); 147 - assertStringIncludes(result, "async getJobStatus("); 148 - assertStringIncludes(result, "async createOAuthClient("); 149 - assertStringIncludes(result, "async syncUserCollections("); 141 + // Should create proper client structure for network.slices 142 + assertStringIncludes(result, "export class AtProtoClient extends SlicesClient"); 143 + assertStringIncludes(result, "class NetworkClient"); 144 + assertStringIncludes(result, "class SlicesNetworkClient"); 145 + assertStringIncludes(result, "readonly network: NetworkClient;"); 146 + assertStringIncludes(result, "readonly slices: SlicesNetworkClient;"); 147 + 148 + // Should include standard CRUD methods 149 + assertStringIncludes(result, "async getRecords("); 150 + assertStringIncludes(result, "async createRecord("); 150 151 }); 151 152 152 - Deno.test("generateClient - excludes network.slices methods when excluded", () => { 153 + Deno.test("generateClient - handles mixed lexicon types", () => { 153 154 const project = createTestProject(); 154 155 const sourceFile = project.createSourceFile("test.ts", ""); 155 156 156 157 const lexicons: Lexicon[] = [ 157 158 { 159 + id: "app.bsky.feed.post", 160 + definitions: { 161 + main: { 162 + type: "record", 163 + record: { 164 + type: "record", 165 + properties: { text: { type: "string" } }, 166 + }, 167 + }, 168 + }, 169 + }, 170 + { 158 171 id: "network.slices.slice", 159 172 definitions: { 160 173 main: { ··· 168 181 }, 169 182 ]; 170 183 171 - generateClient(sourceFile, lexicons, true); 184 + generateClient(sourceFile, lexicons); 172 185 const result = sourceFile.getFullText(); 173 186 174 - // Should not include network.slices specific methods when excluded 175 - assertEquals(result.includes("async codegen("), false); 176 - assertEquals(result.includes("async getSliceRecords<T"), false); 177 - assertEquals(result.includes("async createOAuthClient("), false); 187 + // Should include both app.bsky and network.slices clients 188 + assertStringIncludes(result, "class AppClient"); 189 + assertStringIncludes(result, "class NetworkClient"); 190 + assertStringIncludes(result, "readonly app: AppClient;"); 191 + assertStringIncludes(result, "readonly network: NetworkClient;"); 178 192 }); 179 193 180 194 Deno.test("generateClient - handles deep nesting correctly", () => { ··· 208 222 }, 209 223 ]; 210 224 211 - generateClient(sourceFile, lexicons, true); 225 + generateClient(sourceFile, lexicons); 212 226 const result = sourceFile.getFullText(); 213 227 214 228 // Should create proper nesting: app.bsky.feed and app.bsky.actor ··· 240 254 }, 241 255 ]; 242 256 243 - generateClient(sourceFile, lexicons, true); 257 + generateClient(sourceFile, lexicons); 244 258 const result = sourceFile.getFullText(); 245 259 246 260 // Should not create any client classes for non-record lexicons
+11 -20
packages/codegen/tests/integration_test.ts
··· 60 60 61 61 const result = await generateTypeScript(lexicons, { 62 62 sliceUri: "at://did:example/com.example.slice/abc123", 63 - excludeSlicesClient: false, 64 63 }); 65 64 66 65 // Should include header comment with usage example ··· 91 90 assertStringIncludes(result, "async getRecords("); 92 91 assertStringIncludes(result, "async createRecord("); 93 92 94 - assertStringIncludes(result, "async getSliceRecords<T"); 95 - 96 - assertStringIncludes(result, "export interface BulkSyncParams"); 93 + // Should include standard client methods for all lexicons 97 94 98 95 // Code should be formatted (no obvious formatting issues) 99 96 assertEquals(result.includes(" ;"), false); // Double spaces before semicolons 100 97 assertEquals(result.includes("\t\t\t"), false); // Triple tabs 101 98 }); 102 99 103 - Deno.test("generateTypeScript - excludes slices client correctly", async () => { 100 + Deno.test("generateTypeScript - generates client for standard lexicons", async () => { 104 101 const lexicons: Lexicon[] = [ 105 102 { 106 103 id: "app.bsky.feed.post", ··· 120 117 121 118 const result = await generateTypeScript(lexicons, { 122 119 sliceUri: "at://test/slice", 123 - excludeSlicesClient: true, 124 120 }); 125 121 126 - // Should not include slice-specific imports 127 - assertEquals(result.includes("SliceLevelRecordsParams"), false); 128 - assertEquals(result.includes("GetActorsParams"), false); 129 - 130 - assertEquals(result.includes("BulkSyncParams"), false); 131 - 132 - assertEquals(result.includes("async getSliceRecords<T"), false); 133 - 134 - // Should still include basic functionality 122 + // Should include basic functionality 135 123 assertStringIncludes(result, "export interface AppBskyFeedPost"); 136 124 assertStringIncludes(result, "export class AtProtoClient"); 137 125 assertStringIncludes(result, "async getRecords("); 126 + assertStringIncludes(result, "text?: string;"); 127 + 128 + // Should include imports 129 + assertStringIncludes(result, 'SlicesClient'); 130 + assertStringIncludes(result, 'OAuthClient'); 138 131 }); 139 132 140 133 Deno.test("generateTypeScript - handles empty lexicons", async () => { 141 134 const result = await generateTypeScript([], { 142 135 sliceUri: "at://test/slice", 143 - excludeSlicesClient: false, 144 136 }); 145 137 146 138 // Should include basic structure even with no lexicons ··· 148 140 assertStringIncludes(result, "Lexicons: 0"); 149 141 assertStringIncludes(result, 'SlicesClient'); 150 142 151 - // Should include network.slices interfaces 152 - assertStringIncludes(result, "export interface BulkSyncParams"); 143 + // Should include imports even with no lexicons 144 + assertStringIncludes(result, "@slices/client"); 153 145 154 146 // Should not create any client class (no records to work with) 155 147 assertEquals(result.includes("export class AtProtoClient"), false); ··· 175 167 176 168 const result = await generateTypeScript(lexicons, { 177 169 sliceUri: "at://did:example/slice/123", 178 - excludeSlicesClient: false, 179 170 }); 180 171 181 172 // Should create usage example with the first non-network.slices lexicon 182 173 assertStringIncludes(result, "client.social.app.post.getRecords()"); 183 174 assertStringIncludes(result, "at://did:example/slice/123"); 184 175 assertStringIncludes(result, "social.app.post"); 185 - assertStringIncludes(result, "getSliceRecords<SocialAppPost>"); 176 + assertStringIncludes(result, "getRecords()"); 186 177 });
+32 -16
packages/codegen/tests/interfaces_test.ts
··· 31 31 }, 32 32 ]; 33 33 34 - generateInterfaces(sourceFile, lexicons, true); 34 + generateInterfaces(sourceFile, lexicons); 35 35 const result = sourceFile.getFullText(); 36 36 37 37 // Should create interface for the record ··· 65 65 }, 66 66 ]; 67 67 68 - generateInterfaces(sourceFile, lexicons, true); 68 + generateInterfaces(sourceFile, lexicons); 69 69 const result = sourceFile.getFullText(); 70 70 71 71 assertStringIncludes(result, "export interface AppBskyEmbedDefsAspectRatio"); ··· 98 98 }, 99 99 ]; 100 100 101 - generateInterfaces(sourceFile, lexicons, true); 101 + generateInterfaces(sourceFile, lexicons); 102 102 const result = sourceFile.getFullText(); 103 103 104 104 assertStringIncludes(result, "export type AppBskyEmbedDefsView"); ··· 132 132 }, 133 133 ]; 134 134 135 - generateInterfaces(sourceFile, lexicons, true); 135 + generateInterfaces(sourceFile, lexicons); 136 136 const result = sourceFile.getFullText(); 137 137 138 138 assertStringIncludes(result, "export type ComExamplePostStatus"); ··· 162 162 }, 163 163 ]; 164 164 165 - generateInterfaces(sourceFile, lexicons, true); 165 + generateInterfaces(sourceFile, lexicons); 166 166 const result = sourceFile.getFullText(); 167 167 168 168 // Should create namespace interface for multiple definitions ··· 171 171 assertStringIncludes(result, "readonly View: AppBskyEmbedDefsView;"); 172 172 }); 173 173 174 - Deno.test("generateInterfaces - includes network.slices interfaces when not excluded", () => { 174 + Deno.test("generateInterfaces - generates from network.slices lexicons", () => { 175 175 const project = createTestProject(); 176 176 const sourceFile = project.createSourceFile("test.ts", ""); 177 177 178 - generateInterfaces(sourceFile, [], false); 178 + const lexicons = [ 179 + { 180 + id: "network.slices.slice", 181 + definitions: { 182 + main: { 183 + type: "record", 184 + record: { 185 + type: "record", 186 + properties: { 187 + name: { type: "string" }, 188 + }, 189 + required: ["name"], 190 + }, 191 + }, 192 + }, 193 + }, 194 + ]; 195 + 196 + generateInterfaces(sourceFile, lexicons); 179 197 const result = sourceFile.getFullText(); 180 198 181 - assertStringIncludes(result, "export interface BulkSyncParams"); 182 - assertStringIncludes(result, "export interface JobStatus"); 183 - assertStringIncludes(result, "export interface OAuthClientDetails"); 199 + assertStringIncludes(result, "export interface NetworkSlicesSlice"); 200 + assertStringIncludes(result, "name: string;"); 184 201 }); 185 202 186 - Deno.test("generateInterfaces - excludes network.slices interfaces when excluded", () => { 203 + Deno.test("generateInterfaces - generates empty output for empty lexicons", () => { 187 204 const project = createTestProject(); 188 205 const sourceFile = project.createSourceFile("test.ts", ""); 189 206 190 - generateInterfaces(sourceFile, [], true); 207 + generateInterfaces(sourceFile, []); 191 208 const result = sourceFile.getFullText(); 192 209 193 - assertEquals(result.includes("CodegenXrpcRequest"), false); 194 - assertEquals(result.includes("BulkSyncParams"), false); 195 - assertEquals(result.includes("JobStatus"), false); 210 + // Should have minimal output for empty lexicons 211 + assertEquals(result.trim(), ""); 196 212 }); 197 213 198 214 Deno.test("generateInterfaces - handles single definition lexicons", () => { ··· 215 231 }, 216 232 ]; 217 233 218 - generateInterfaces(sourceFile, lexicons, true); 234 + generateInterfaces(sourceFile, lexicons); 219 235 const result = sourceFile.getFullText(); 220 236 221 237 // Should use clean name for single definition
+6 -5
packages/codegen/tests/utils_test.ts
··· 145 145 ]; 146 146 147 147 const usageExample = "/** Example usage */"; 148 - const result = generateHeaderComment(lexicons, usageExample, false); 148 + const result = generateHeaderComment(lexicons, usageExample); 149 149 150 150 assertEquals(typeof result, "string"); 151 151 assertEquals(result.includes("Generated TypeScript client"), true); ··· 155 155 assertEquals(result.includes("@slices/oauth"), true); 156 156 }); 157 157 158 - Deno.test("generateHeaderComment - excludes slices client imports", () => { 158 + Deno.test("generateHeaderComment - generates consistent imports", () => { 159 159 const lexicons: Lexicon[] = []; 160 160 const usageExample = "/** Example */"; 161 - const result = generateHeaderComment(lexicons, usageExample, true); 161 + const result = generateHeaderComment(lexicons, usageExample); 162 162 163 - assertEquals(result.includes("SliceLevelRecordsParams"), false); 164 - assertEquals(result.includes("GetActorsParams"), false); 163 + assertEquals(result.includes("SlicesClient"), true); 164 + assertEquals(result.includes("@slices/client"), true); 165 + assertEquals(result.includes("@slices/oauth"), true); 165 166 });