From c8766e9fb950f492af772d5211443ba707365add Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 23 Sep 2026 02:40:51 +0900 Subject: [PATCH] feat: migrate auth and workspace catalog to ServerApi --- crates/client/src/backend_auth.rs | 101 +- crates/client/src/backend_workspace.rs | 102 +- crates/server-api/src/lib.rs | 433 ++++- crates/workspace-server/src/server.rs | 699 ++++--- openapi/server-api.json | 1680 ++++++++++++++++- .../src/lib/generated/repository-api.ts | 2 +- 6 files changed, 2638 insertions(+), 379 deletions(-) diff --git a/crates/client/src/backend_auth.rs b/crates/client/src/backend_auth.rs index 9d0f51ba0..9452ff6ba 100644 --- a/crates/client/src/backend_auth.rs +++ b/crates/client/src/backend_auth.rs @@ -1,9 +1,11 @@ use crate::BackendOrigin; -use serde::Deserialize; use std::fmt; use std::time::Duration; -use server_api::{DeviceLoginPollRequest, DeviceLoginPollStatus, DeviceLoginStartRequest}; +use server_api::{ + DeviceLoginPollRequest, DeviceLoginPollStatus, DeviceLoginStartRequest, RepositoryApiError, + ServerApiClient, +}; pub use server_api::{DeviceLoginPollResponse, DeviceLoginStartResponse}; #[derive(Debug, Clone, PartialEq, Eq)] @@ -19,21 +21,12 @@ impl BackendAuthTarget { .unwrap_or(base_url); Self { base_url } } - - fn api_url(&self, path: &str) -> String { - format!( - "{}{}", - self.base_url.trim_end_matches('/'), - path.strip_prefix('/') - .map(|path| format!("/{path}")) - .unwrap_or_else(|| path.to_string()) - ) - } } #[derive(Debug)] pub enum BackendAuthClientError { - Http(reqwest::Error), + InvalidTarget(String), + ServerApi(server_api::client_support::ClientError), BackendStatus { status: u16, body: String }, MissingAccessToken, } @@ -41,7 +34,8 @@ pub enum BackendAuthClientError { impl fmt::Display for BackendAuthClientError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Http(error) => write!(f, "Backend auth request failed: {error}"), + Self::InvalidTarget(message) => f.write_str(message), + Self::ServerApi(error) => write!(f, "{error}"), Self::BackendStatus { status, body } => { write!(f, "Backend auth returned HTTP {status}: {body}") } @@ -54,40 +48,38 @@ impl fmt::Display for BackendAuthClientError { impl std::error::Error for BackendAuthClientError {} -impl From for BackendAuthClientError { - fn from(value: reqwest::Error) -> Self { - Self::Http(value) - } +const AUTH_RESPONSE_LIMIT: usize = 2 * 1024 * 1024; + +fn auth_client(target: &BackendAuthTarget) -> Result { + ServerApiClient::builder(&target.base_url) + .map_err(|error| BackendAuthClientError::InvalidTarget(error.to_string()))? + .response_body_limit(AUTH_RESPONSE_LIMIT) + .build() + .map_err(|error| BackendAuthClientError::InvalidTarget(error.to_string())) } pub async fn start_device_login( target: &BackendAuthTarget, client_name: Option<&str>, ) -> Result { - let client = reqwest::Client::new(); - let response = client - .post(target.api_url("/api/auth/device-login/start")) - .json(&DeviceLoginStartRequest { + auth_client(target)? + .auth_device_login_start(DeviceLoginStartRequest { client_name: client_name.map(ToOwned::to_owned), }) - .send() - .await?; - parse_json_response(response).await + .await + .map_err(BackendAuthClientError::ServerApi) } pub async fn poll_device_login( target: &BackendAuthTarget, device_code: &str, ) -> Result { - let client = reqwest::Client::new(); - let response = client - .post(target.api_url("/api/auth/device-login/poll")) - .json(&DeviceLoginPollRequest { + auth_client(target)? + .auth_device_login_poll(DeviceLoginPollRequest { device_code: device_code.to_string(), }) - .send() - .await?; - parse_json_response(response).await + .await + .map_err(BackendAuthClientError::ServerApi) } fn device_login_poll_result( @@ -136,24 +128,13 @@ pub async fn wait_for_device_login( } } -async fn parse_json_response Deserialize<'de>>( - response: reqwest::Response, -) -> Result { - let status = response.status(); - if !status.is_success() { - let body = response.text().await.unwrap_or_default(); - return Err(BackendAuthClientError::BackendStatus { - status: status.as_u16(), - body, - }); - } - Ok(response.json::().await?) -} - #[cfg(test)] mod tests { use super::*; use server_api::DeviceAccessTokenType; + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::thread; fn poll_response(status: DeviceLoginPollStatus) -> DeviceLoginPollResponse { DeviceLoginPollResponse { @@ -163,6 +144,34 @@ mod tests { } } + #[tokio::test] + async fn device_login_start_uses_generated_server_api_client() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = vec![0; 4096]; + let read = stream.read(&mut request).unwrap(); + let request = String::from_utf8_lossy(&request[..read]); + assert!(request.starts_with("POST /api/auth/device-login/start HTTP/1.1")); + assert!(request.contains(r#"{"client_name":"yoi-cli"}"#)); + let body = r#"{"device_code":"device-secret","user_code":"ABCD-EFGH","verification_uri":"https://yoi.example/login/device","verification_uri_complete":"https://yoi.example/login/device?user_code=ABCD-EFGH","expires_in":600,"interval":5}"#; + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .unwrap(); + }); + + let response = start_device_login(&BackendAuthTarget::new(base_url), Some("yoi-cli")) + .await + .unwrap(); + assert_eq!(response.user_code, "ABCD-EFGH"); + handle.join().unwrap(); + } + #[test] fn device_login_start_response_enforces_shared_expiry_bounds() { let valid = serde_json::json!({ diff --git a/crates/client/src/backend_workspace.rs b/crates/client/src/backend_workspace.rs index edd6cc75a..d3aaaca6e 100644 --- a/crates/client/src/backend_workspace.rs +++ b/crates/client/src/backend_workspace.rs @@ -1,8 +1,7 @@ use crate::{BackendApiClient, BackendApiClientError}; -use reqwest::Method; use server_api::{ - InitialRepositoryIntent, RepositorySummary, WorkspaceCatalogListResponse, - WorkspaceCreateRequest, WorkspaceCreateResponse, WorkspaceSummary, + InitialRepositoryIntent, RepositorySummary, WorkspaceCreateRequest, WorkspaceCreateResponse, + WorkspaceListQuery, WorkspaceSummary, }; use std::fmt; @@ -31,7 +30,7 @@ pub enum BackendWorkspaceClientError { InvalidTarget(String), Api(BackendApiClientError), Http(reqwest::Error), - RepositoryApi(server_api::client_support::ClientError), + ServerApi(server_api::client_support::ClientError), } impl fmt::Display for BackendWorkspaceClientError { @@ -40,7 +39,7 @@ impl fmt::Display for BackendWorkspaceClientError { Self::InvalidTarget(message) => f.write_str(message), Self::Api(error) => write!(f, "{error}"), Self::Http(error) => write!(f, "{error}"), - Self::RepositoryApi(error) => write!(f, "{error}"), + Self::ServerApi(error) => write!(f, "{error}"), } } } @@ -65,11 +64,11 @@ impl From, ) -> Self { - Self::RepositoryApi(error) + Self::ServerApi(error) } } -fn repository_client_error( +fn server_client_error( backend: &BackendApiClient, error: server_api::client_support::ClientError, ) -> BackendWorkspaceClientError { @@ -88,16 +87,16 @@ fn repository_client_error( origin: backend.origin().clone(), }) } - _ => BackendWorkspaceClientError::RepositoryApi(error), + _ => BackendWorkspaceClientError::ServerApi(error), } } #[derive(Clone, Debug)] -struct RepositoryBearerAuthorizer { +struct ServerBearerAuthorizer { authorization: String, } -impl server_api::client_support::RequestAuthorizer for RepositoryBearerAuthorizer { +impl server_api::client_support::RequestAuthorizer for ServerBearerAuthorizer { fn authorize( &self, _request: server_api::client_support::AuthorizerRequest<'_>, @@ -113,20 +112,31 @@ impl server_api::client_support::RequestAuthorizer for RepositoryBearerAuthorize } } -const REPOSITORY_RESPONSE_LIMIT: usize = 16 * 1024 * 1024; +const SERVER_RESPONSE_LIMIT: usize = 16 * 1024 * 1024; + +fn server_api_client( + backend: &BackendApiClient, +) -> Result, BackendWorkspaceClientError> { + server_api::ServerApiClient::builder(backend.origin().as_str()) + .map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))? + .client(backend.asynchronous_client()) + .authorizer(ServerBearerAuthorizer { + authorization: backend.authorization_header_value(), + }) + .response_body_limit(SERVER_RESPONSE_LIMIT) + .build() + .map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string())) +} pub fn list_backend_workspaces_blocking( target: &BackendWorkspaceCatalogTarget, ) -> Result, BackendWorkspaceClientError> { let client = BackendApiClient::from_stored_token(&target.base_url)?; - let response = client - .blocking_request( - Method::GET, - &format!("/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}"), - )? - .send()?; - client.check_status(response.status())?; - Ok(response.json::()?.0) + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))?; + runtime.block_on(list_backend_workspaces_with_client(&client)) } pub fn list_backend_workspace_repositories_blocking( @@ -158,19 +168,10 @@ async fn list_backend_workspace_repositories_with_client( "Workspace id returned by Backend is invalid".to_string(), )); } - let client = server_api::ServerApiClient::builder(backend.origin().as_str()) - .map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))? - .client(backend.asynchronous_client()) - .authorizer(RepositoryBearerAuthorizer { - authorization: backend.authorization_header_value(), - }) - .response_body_limit(REPOSITORY_RESPONSE_LIMIT) - .build() - .map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))?; - let response = client + let response = server_api_client(backend)? .repository_list(workspace_id.to_owned()) .await - .map_err(|error| repository_client_error(backend, error))?; + .map_err(|error| server_client_error(backend, error))?; if response.workspace_id != workspace_id { return Err(BackendWorkspaceClientError::InvalidTarget( "Repository catalog response does not match the requested Workspace".to_string(), @@ -189,15 +190,13 @@ pub async fn list_backend_workspaces( async fn list_backend_workspaces_with_client( client: &BackendApiClient, ) -> Result, BackendWorkspaceClientError> { - let response = client - .request( - Method::GET, - &format!("/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}"), - )? - .send() - .await?; - client.check_status(response.status())?; - Ok(response.json::().await?.0) + let response = server_api_client(client)? + .workspace_catalog_list(WorkspaceListQuery { + limit: Some(DEFAULT_WORKSPACE_LIMIT as u32), + }) + .await + .map_err(|error| server_client_error(client, error))?; + Ok(response.0) } pub async fn create_backend_workspace( @@ -205,13 +204,10 @@ pub async fn create_backend_workspace( request: &CreateBackendWorkspaceRequest, ) -> Result { let client = BackendApiClient::from_stored_token(&target.base_url)?; - let response = client - .request(Method::POST, "/api/workspaces")? - .json(request) - .send() - .await?; - client.check_status(response.status())?; - Ok(response.json::().await?) + server_api_client(&client)? + .workspace_catalog_create(request.clone()) + .await + .map_err(|error| server_client_error(&client, error)) } #[cfg(test)] @@ -292,12 +288,12 @@ mod tests { write!( stream, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - REPOSITORY_RESPONSE_LIMIT + 1, + SERVER_RESPONSE_LIMIT + 1, ) .unwrap(); stream.flush().unwrap(); let chunk = [b' '; 8192]; - let mut remaining = REPOSITORY_RESPONSE_LIMIT + 1; + let mut remaining = SERVER_RESPONSE_LIMIT + 1; while remaining > 0 { let length = remaining.min(chunk.len()); if stream.write_all(&chunk[..length]).is_err() { @@ -315,10 +311,10 @@ mod tests { assert!( matches!( &error, - BackendWorkspaceClientError::RepositoryApi( + BackendWorkspaceClientError::ServerApi( server_api::client_support::ClientError::Failure( server_api::client_support::ClientFailure::ResponseTooLarge { - limit: REPOSITORY_RESPONSE_LIMIT + limit: SERVER_RESPONSE_LIMIT } ) ) @@ -329,11 +325,11 @@ mod tests { } #[test] - fn repository_client_preserves_authentication_error_taxonomy() { + fn server_client_preserves_authentication_error_taxonomy() { let client = BackendApiClient::from_access_token_for_test("https://backend.example.test", "token") .unwrap(); - let unauthorized = repository_client_error( + let unauthorized = server_client_error( &client, server_api::client_support::ClientError::public( reqwest::StatusCode::UNAUTHORIZED, @@ -349,7 +345,7 @@ mod tests { unauthorized, BackendWorkspaceClientError::Api(BackendApiClientError::Unauthorized { .. }) )); - let forbidden = repository_client_error( + let forbidden = server_client_error( &client, server_api::client_support::ClientError::public( reqwest::StatusCode::FORBIDDEN, diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 26e94a7f3..dd7166eab 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -22,6 +22,7 @@ pub type ServerApiClientError = client_support::ClientError; /// Error body shared by the existing repository routes and their generated adapters. #[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +#[serde(deny_unknown_fields)] pub struct RepositoryApiError { pub error: String, pub message: String, @@ -76,6 +77,28 @@ macro_rules! impl_openapi_schema { } impl_openapi_schema!( + AuthBootstrapUserRequest, + AuthPublicConfig, + AuthUserResponse, + DeviceLoginApproveRequest, + DeviceLoginApproveResponse, + DeviceLoginPollRequest, + DeviceLoginPollResponse, + DeviceLoginStartRequest, + DeviceLoginStartResponse, + HealthResponse, + PasskeyLoginOptionsRequest, + PasskeyLoginOptionsResponse, + PasskeyRegistrationOptionsRequest, + PasskeyRegistrationOptionsResponse, + WhoamiResponse, + WorkspaceCreateRequest, + WorkspaceCreateResponse, + WorkspaceDeletionOperationResponse, + WorkspaceDeletionPreflightResponse, + WorkspaceDeletionRequest, + WorkspaceListQuery, + WorkspaceCatalogListResponse, CreateWorkspaceRepositoryRequest, CreateWorkspaceRepositoryResponse, RepositoryApiError, @@ -92,6 +115,162 @@ pub struct WorkspaceWorkerSessionResponse { #[api(reqwest, axum, openapi)] pub trait ServerApi { + #[get("/health", status = 200, error_status = 400)] + async fn health(&self) -> Result; + + #[get("/api/auth/config", status = 200, error_status = 400)] + async fn auth_config(&self) -> Result; + + #[post( + "/api/auth/bootstrap-user", + status = 200, + error_status = 400, + additional_error_statuses = [500] + )] + async fn auth_bootstrap_user( + &self, + #[body] request: AuthBootstrapUserRequest, + ) -> Result; + + #[post( + "/api/auth/passkeys/registration/options", + status = 200, + error_status = 400, + additional_error_statuses = [500, 502] + )] + async fn auth_passkey_registration_options( + &self, + #[extension] context: ServerRequestContext, + #[body] request: PasskeyRegistrationOptionsRequest, + ) -> Result; + + #[post( + "/api/auth/passkeys/login/options", + status = 200, + error_status = 400, + additional_error_statuses = [500, 502] + )] + async fn auth_passkey_login_options( + &self, + #[extension] context: ServerRequestContext, + #[body] request: PasskeyLoginOptionsRequest, + ) -> Result; + + #[post( + "/api/auth/device-login/start", + status = 200, + error_status = 400, + additional_error_statuses = [500, 502] + )] + async fn auth_device_login_start( + &self, + #[body] request: DeviceLoginStartRequest, + ) -> Result; + + #[post( + "/api/auth/device-login/approve", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 500, 502], + bearer_auth = true, + browser_auth = true + )] + async fn auth_device_login_approve( + &self, + #[extension] context: ServerRequestContext, + #[body] request: DeviceLoginApproveRequest, + ) -> Result; + + #[post( + "/api/auth/device-login/poll", + status = 200, + error_status = 400, + additional_error_statuses = [500, 502] + )] + async fn auth_device_login_poll( + &self, + #[body] request: DeviceLoginPollRequest, + ) -> Result; + + #[get("/api/auth/whoami", status = 200, error_status = 400)] + async fn auth_whoami( + &self, + #[extension] context: ServerRequestContext, + ) -> Result; + + #[get( + "/api/workspaces", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 500], + bearer_auth = true, + browser_auth = true + )] + async fn workspace_catalog_list( + &self, + #[extension] context: ServerRequestContext, + #[query] query: WorkspaceListQuery, + ) -> Result; + + #[post( + "/api/workspaces", + status = 201, + alternate_status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 409, 500], + bearer_auth = true, + browser_auth = true + )] + async fn workspace_catalog_create( + &self, + #[extension] context: ServerRequestContext, + #[body] request: WorkspaceCreateRequest, + ) -> Result; + + #[get( + "/api/workspaces/{workspace_id}/deletion", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true + )] + async fn workspace_deletion_preflight( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + ) -> Result; + + #[post( + "/api/workspaces/{workspace_id}/deletion", + status = 202, + alternate_status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500], + bearer_auth = true, + browser_auth = true + )] + async fn workspace_deletion_start( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: WorkspaceDeletionRequest, + ) -> Result; + + #[get( + "/api/workspace-deletions/{operation_id}", + status = 200, + error_status = 404, + additional_error_statuses = [400, 401, 403, 500], + bearer_auth = true, + browser_auth = true + )] + async fn workspace_deletion_get( + &self, + #[extension] context: ServerRequestContext, + #[path] operation_id: String, + ) -> Result; + #[get( "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/session", status = 200, @@ -204,8 +383,30 @@ pub fn canonical_openapi_document() }) } +/// Server-local request context populated by authentication middleware. +/// +/// Generated clients and OpenAPI omit extension parameters; operation implementations use this +/// value instead of re-parsing transport headers inside domain handlers. +#[derive(Debug, Clone)] +pub struct ServerRequestContext { + pub actor: Option, + pub origin: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct HealthResponse { + pub status: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceListQuery { + pub limit: Option, +} + /// Public browser-authentication configuration. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct AuthPublicConfig { @@ -216,7 +417,7 @@ pub struct AuthPublicConfig { } /// Authentication method that established the current request actor. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum ActorAuthMethod { @@ -225,7 +426,7 @@ pub enum ActorAuthMethod { } /// Public user identity returned by authentication operations. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct AuthenticatedUser { @@ -236,7 +437,7 @@ pub struct AuthenticatedUser { } /// Authenticated actor returned by `GET /api/auth/whoami`. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct RequestActor { @@ -258,14 +459,14 @@ impl RequestActor { } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct WhoamiResponse { pub actor: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct AuthBootstrapUserRequest { @@ -275,14 +476,14 @@ pub struct AuthBootstrapUserRequest { pub display_name: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct AuthUserResponse { pub user: AuthenticatedUser, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct PasskeyRegistrationOptionsRequest { @@ -295,12 +496,13 @@ pub struct PasskeyRegistrationOptionsRequest { pub browser_origin: Option, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct PasskeyRegistrationOptionsResponse { pub challenge_id: String, #[cfg_attr(feature = "typescript", ts(type = "unknown"))] + #[schemars(with = "serde_json::Value")] pub public_key: CreationChallengeResponse, } @@ -313,7 +515,7 @@ pub struct PasskeyRegistrationCompleteRequest { pub credential: RegisterPublicKeyCredential, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct PasskeyLoginOptionsRequest { @@ -325,12 +527,13 @@ pub struct PasskeyLoginOptionsRequest { pub browser_origin: Option, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct PasskeyLoginOptionsResponse { pub challenge_id: String, #[cfg_attr(feature = "typescript", ts(type = "unknown"))] + #[schemars(with = "serde_json::Value")] pub public_key: RequestChallengeResponse, } @@ -343,7 +546,7 @@ pub struct PasskeyLoginCompleteRequest { pub credential: PublicKeyCredential, } -#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct DeviceLoginStartRequest { @@ -355,16 +558,19 @@ pub struct DeviceLoginStartRequest { const DEVICE_LOGIN_EXPIRES_IN_MAX_SECONDS: u64 = 24 * 60 * 60; const DEVICE_LOGIN_POLL_INTERVAL_MAX_SECONDS: u64 = 60; -#[derive(Clone, Serialize, PartialEq, Eq)] +#[derive(Clone, Serialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] pub struct DeviceLoginStartResponse { pub device_code: String, pub user_code: String, pub verification_uri: String, pub verification_uri_complete: String, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 1, max = 86400))] pub expires_in: u64, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 1, max = 60))] pub interval: u64, } @@ -420,21 +626,21 @@ impl std::fmt::Debug for DeviceLoginStartResponse { } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct DeviceLoginApproveRequest { pub user_code: String, } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum DeviceLoginApprovalStatus { Approved, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct DeviceLoginApproveResponse { @@ -442,20 +648,20 @@ pub struct DeviceLoginApproveResponse { pub user: AuthenticatedUser, } -#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct DeviceLoginPollRequest { pub device_code: String, } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub enum DeviceAccessTokenType { Bearer, } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum DeviceLoginPollStatus { @@ -466,7 +672,7 @@ pub enum DeviceLoginPollStatus { Consumed, } -#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct DeviceLoginPollResponse { @@ -680,7 +886,7 @@ impl RepositoryObservedStatus { } /// Public Workspace catalog item returned by `GET /api/workspaces`. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct WorkspaceSummary { @@ -696,12 +902,12 @@ pub struct WorkspaceSummary { /// /// The transparent newtype keeps the established top-level JSON array while making the /// complete list response a named cross-crate and generated-TypeScript authority. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct WorkspaceCatalogListResponse(pub Vec); /// Public Repository record embedded in Workspace creation responses. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct WorkspaceRepositoryRecord { @@ -712,6 +918,7 @@ pub struct WorkspaceRepositoryRecord { pub source: RepositorySource, pub default_ref: Option, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub source_revision: u64, pub source_fingerprint: String, pub observed_status: RepositoryObservedStatus, @@ -721,7 +928,7 @@ pub struct WorkspaceRepositoryRecord { } /// Initial Repository registration intent for Workspace creation. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct InitialRepositoryIntent { pub repository_key: String, @@ -731,7 +938,7 @@ pub struct InitialRepositoryIntent { } /// Request for atomically creating a Workspace and its initial Repository. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct WorkspaceCreateRequest { pub operation_key: String, @@ -740,18 +947,25 @@ pub struct WorkspaceCreateRequest { } /// Response returned after atomically creating a Workspace and its first Repository. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct WorkspaceCreateResponse { pub workspace: WorkspaceSummary, pub repository: WorkspaceRepositoryRecord, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub config_revision: u64, pub request_fingerprint: String, pub replayed: bool, } +impl api_macros::HttpSuccess for WorkspaceCreateResponse { + fn status_code(&self) -> u16 { + if self.replayed { 200 } else { 201 } + } +} + /// Browser authentication configuration exposed by the scoped Workspace summary. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] @@ -1016,7 +1230,7 @@ where } /// Lifecycle state for one durable Workspace deletion operation. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum WorkspaceDeletionState { @@ -1028,7 +1242,7 @@ pub enum WorkspaceDeletionState { } /// Stable category explaining why Workspace deletion cannot currently advance. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum WorkspaceDeletionBlockerKind { @@ -1042,8 +1256,9 @@ pub enum WorkspaceDeletionBlockerKind { } /// One bounded, user-actionable blocker returned by preflight or execution. -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] pub struct WorkspaceDeletionBlocker { pub kind: WorkspaceDeletionBlockerKind, pub resource_kind: Option, @@ -1052,27 +1267,34 @@ pub struct WorkspaceDeletionBlocker { } /// Workspace-owned resources summarized before destructive confirmation. -#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct WorkspaceDeletionResourceCounts { #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub workers: u64, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub workdirs: u64, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub repositories: u64, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub runtime_bindings: u64, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub secrets: u64, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub artifacts: u64, } /// Owner-only impact preview for deleting one Workspace. -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] pub struct WorkspaceDeletionPreflightResponse { pub workspace_id: String, pub display_name: String, @@ -1084,8 +1306,9 @@ pub struct WorkspaceDeletionPreflightResponse { } /// Idempotent request to start or resume Workspace deletion. -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] pub struct WorkspaceDeletionRequest { pub operation_id: String, pub expected_revision: String, @@ -1093,8 +1316,9 @@ pub struct WorkspaceDeletionRequest { } /// Durable deletion operation projection used by request responses and polling. -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] pub struct WorkspaceDeletionOperationResponse { pub operation_id: String, pub workspace_id: String, @@ -1109,6 +1333,16 @@ pub struct WorkspaceDeletionOperationResponse { pub completed_at: Option, } +impl api_macros::HttpSuccess for WorkspaceDeletionOperationResponse { + fn status_code(&self) -> u16 { + if self.state == WorkspaceDeletionState::Succeeded { + 200 + } else { + 202 + } + } +} + #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct WorkspaceDeletionBlockerWire { @@ -3881,7 +4115,6 @@ mod tests { #[test] fn worker_session_contract_and_flattened_availability_are_stable() { let operations = ServerApiMetadata::OPERATIONS; - assert_eq!(operations.len(), 6); let operation = operations .iter() .find(|operation| operation.operation_id == "worker_session") @@ -3904,6 +4137,69 @@ mod tests { assert_eq!(value["subject"]["kind"], "runtime_worker"); } + #[test] + fn auth_and_workspace_catalog_operations_are_in_server_api_metadata() { + let operations = ServerApiMetadata::OPERATIONS; + for (operation_id, method, path) in [ + ("health", HttpMethod::Get, "/health"), + ("auth_config", HttpMethod::Get, "/api/auth/config"), + ( + "auth_passkey_registration_options", + HttpMethod::Post, + "/api/auth/passkeys/registration/options", + ), + ( + "auth_passkey_login_options", + HttpMethod::Post, + "/api/auth/passkeys/login/options", + ), + ( + "auth_device_login_start", + HttpMethod::Post, + "/api/auth/device-login/start", + ), + ( + "auth_device_login_approve", + HttpMethod::Post, + "/api/auth/device-login/approve", + ), + ( + "auth_device_login_poll", + HttpMethod::Post, + "/api/auth/device-login/poll", + ), + ("auth_whoami", HttpMethod::Get, "/api/auth/whoami"), + ("workspace_catalog_list", HttpMethod::Get, "/api/workspaces"), + ( + "workspace_catalog_create", + HttpMethod::Post, + "/api/workspaces", + ), + ( + "workspace_deletion_preflight", + HttpMethod::Get, + "/api/workspaces/{workspace_id}/deletion", + ), + ( + "workspace_deletion_start", + HttpMethod::Post, + "/api/workspaces/{workspace_id}/deletion", + ), + ( + "workspace_deletion_get", + HttpMethod::Get, + "/api/workspace-deletions/{operation_id}", + ), + ] { + let operation = operations + .iter() + .find(|operation| operation.operation_id == operation_id) + .unwrap_or_else(|| panic!("missing ServerApi operation {operation_id}")); + assert_eq!(operation.method, method, "{operation_id}"); + assert_eq!(operation.path, path, "{operation_id}"); + } + } + #[test] fn worker_restore_state_round_trips_and_rejects_unknown_variants() { for (state, wire) in [ @@ -5248,6 +5544,75 @@ mod openapi_artifact_tests { ); } + #[test] + fn auth_and_workspace_catalog_contract_is_strict_and_excludes_cookie_routes() { + let document = canonical_openapi_document().expect("canonical OpenAPI contract must build"); + let value: serde_json::Value = + serde_json::from_str(&document.to_json().expect("document must serialize")) + .expect("document must be JSON"); + + for (path, method) in [ + ("/health", "get"), + ("/api/auth/config", "get"), + ("/api/auth/bootstrap-user", "post"), + ("/api/auth/passkeys/registration/options", "post"), + ("/api/auth/passkeys/login/options", "post"), + ("/api/auth/device-login/start", "post"), + ("/api/auth/device-login/approve", "post"), + ("/api/auth/device-login/poll", "post"), + ("/api/auth/whoami", "get"), + ("/api/workspaces", "get"), + ("/api/workspaces", "post"), + ("/api/workspaces/{workspace_id}/deletion", "get"), + ("/api/workspaces/{workspace_id}/deletion", "post"), + ("/api/workspace-deletions/{operation_id}", "get"), + ] { + assert!( + value["paths"][path][method].is_object(), + "missing {method} {path}" + ); + } + let workspace_catalog = &value["paths"]["/api/workspaces"]; + for method in ["get", "post"] { + assert_eq!( + workspace_catalog[method]["security"][0]["bearerAuth"], + serde_json::json!([]) + ); + assert_eq!( + workspace_catalog[method]["security"][1]["browserSession"], + serde_json::json!([]) + ); + } + for status in ["200", "201"] { + assert!(workspace_catalog["post"]["responses"][status].is_object()); + } + let deletion_start = &value["paths"]["/api/workspaces/{workspace_id}/deletion"]["post"]; + for status in ["200", "202", "409"] { + assert!(deletion_start["responses"][status].is_object()); + } + for path in [ + "/api/auth/passkeys/registration/complete", + "/api/auth/passkeys/login/complete", + "/api/auth/logout", + ] { + assert!( + value["paths"].get(path).is_none(), + "cookie response route {path} must remain outside ServerApi" + ); + } + for schema in [ + "RepositoryApiError", + "WorkspaceCreateRequest", + "WorkspaceDeletionRequest", + "DeviceLoginStartResponse", + ] { + assert_eq!( + value["components"]["schemas"][schema]["additionalProperties"], false, + "{schema} must reject unknown fields" + ); + } + } + #[test] fn repository_operations_are_in_the_canonical_contract() { let document = canonical_openapi_document().expect("canonical OpenAPI contract must build"); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 95af16ff7..0f203c680 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -1132,6 +1132,50 @@ impl From<&WorkspaceApi> for ServerAuthApi { } } +#[derive(Clone)] +enum ServerApiContractService { + Server(WorkspaceServerApi), + Workspace(WorkspaceApi), +} + +impl ServerApiContractService { + fn auth_api(&self) -> ServerAuthApi { + match self { + Self::Server(api) => ServerAuthApi { + config: api.template.as_ref().clone(), + store: api.store.clone(), + }, + Self::Workspace(api) => ServerAuthApi::from(api), + } + } + + fn server_api( + &self, + ) -> std::result::Result<&WorkspaceServerApi, server_api::RepositoryApiError> { + match self { + Self::Server(api) => Ok(api), + Self::Workspace(_) => Err(server_api::RepositoryApiError::new( + StatusCode::NOT_FOUND.as_u16(), + "Not Found", + "Server catalog operation is unavailable on a Workspace-local router", + Vec::new(), + )), + } + } + + fn workspace_api(&self) -> std::result::Result<&WorkspaceApi, server_api::RepositoryApiError> { + match self { + Self::Workspace(api) => Ok(api), + Self::Server(_) => Err(server_api::RepositoryApiError::new( + StatusCode::NOT_FOUND.as_u16(), + "Not Found", + "Workspace-local operation is unavailable on the Server catalog router", + Vec::new(), + )), + } + } +} + #[derive(Clone)] struct WorkerRemovalService { workspace_id: String, @@ -1990,173 +2034,6 @@ fn forbidden_server_response(message: &str) -> Response { .into_response() } -#[derive(Debug, Deserialize)] -struct WorkspaceListQuery { - limit: Option, -} - -async fn server_health() -> Json { - Json(serde_json::json!({ "status": "ok" })) -} - -async fn list_server_workspaces( - State(api): State, - headers: HeaderMap, - Query(query): Query, -) -> Response { - let owner_account_id = match resolve_server_actor(&api, &headers).await { - Ok(Some(actor)) => actor.account_id, - Ok(None) => match api.catalog.is_empty() { - Ok(true) => { - return Json(WorkspaceCatalogListResponse(Vec::new())).into_response(); - } - Ok(false) => return StatusCode::UNAUTHORIZED.into_response(), - Err(error) => return server_error_response(error), - }, - Err(error) => return server_error_response(error), - }; - match api - .catalog - .list(&owner_account_id, query.limit.unwrap_or(100)) - { - Ok(workspaces) => Json(WorkspaceCatalogListResponse( - workspaces.into_iter().map(workspace_summary).collect(), - )) - .into_response(), - Err(error) => server_error_response(error), - } -} - -async fn create_server_workspace( - State(api): State, - headers: HeaderMap, - Json(request): Json, -) -> Response { - let owner_account_id = match resolve_server_actor(&api, &headers).await { - Ok(Some(actor)) => actor.account_id, - Ok(None) => { - return forbidden_server_response("Workspace creation requires an authenticated owner"); - } - Err(error) => return server_error_response(error), - }; - let created = match api.catalog.create(request, owner_account_id) { - Ok(created) => created, - Err(error) => return server_error_response(error), - }; - if let Err(error) = api - .router_for_workspace(&created.workspace.workspace_id) - .await - { - return server_error_response(error); - } - let status = if created.replayed { - StatusCode::OK - } else { - StatusCode::CREATED - }; - (status, Json(workspace_create_response(created))).into_response() -} - -async fn preflight_server_workspace_deletion( - State(api): State, - AxumPath(workspace_id): AxumPath, - headers: HeaderMap, -) -> Response { - let actor_account_id = match resolve_server_actor(&api, &headers).await { - Ok(Some(actor)) => actor.account_id, - Ok(None) => return forbidden_server_response("Workspace deletion requires its owner"), - Err(error) => return server_error_response(error), - }; - match api - .workspace_deletion_preflight(&actor_account_id, &workspace_id) - .await - { - Ok(preflight) => Json(preflight).into_response(), - Err(error) => server_error_response(error), - } -} - -async fn start_server_workspace_deletion( - State(api): State, - AxumPath(workspace_id): AxumPath, - headers: HeaderMap, - Json(request): Json, -) -> Response { - let actor_account_id = match resolve_server_actor(&api, &headers).await { - Ok(Some(actor)) => actor.account_id, - Ok(None) => return forbidden_server_response("Workspace deletion requires its owner"), - Err(error) => return server_error_response(error), - }; - let mutation_lock = api.mutation_lock(&workspace_id).await; - let _mutation_guard = mutation_lock.lock().await; - let existing = match api - .store - .workspace_deletion_operation(&actor_account_id, &request.operation_id) - { - Ok(existing) => existing, - Err(error) => return server_error_response(error), - }; - if existing.is_none() { - let preflight = match api - .workspace_deletion_preflight(&actor_account_id, &workspace_id) - .await - { - Ok(preflight) => preflight, - Err(error) => return server_error_response(error), - }; - if !preflight.can_delete { - return (StatusCode::CONFLICT, Json(preflight)).into_response(); - } - } - let reservation = - match api - .store - .reserve_workspace_deletion(&actor_account_id, &workspace_id, &request) - { - Ok(reservation) => reservation, - Err(error) => return server_error_response(error), - }; - if let Some(handle) = api.hook_handles.lock().await.remove(&workspace_id) { - handle.abort(); - } - let operation = reservation.operation; - if operation.state != WorkspaceDeletionState::Succeeded { - api.schedule_workspace_deletion(request.operation_id).await; - } - let status = if operation.state == WorkspaceDeletionState::Succeeded { - StatusCode::OK - } else { - StatusCode::ACCEPTED - }; - (status, Json(operation)).into_response() -} - -async fn get_server_workspace_deletion( - State(api): State, - AxumPath(operation_id): AxumPath, - headers: HeaderMap, -) -> Response { - let actor_account_id = match resolve_server_actor(&api, &headers).await { - Ok(Some(actor)) => actor.account_id, - Ok(None) => { - return forbidden_server_response("Workspace deletion status requires its owner"); - } - Err(error) => return server_error_response(error), - }; - match api - .store - .workspace_deletion_operation(&actor_account_id, &operation_id) - { - Ok(Some(operation)) => Json(operation).into_response(), - Ok(None) => ( - StatusCode::NOT_FOUND, - "Workspace deletion operation not found", - ) - .into_response(), - Err(error) => server_error_response(error), - } -} - fn workspace_summary(record: WorkspaceRecord) -> WorkspaceSummary { WorkspaceSummary { workspace_id: record.workspace_id, @@ -2666,22 +2543,17 @@ pub async fn build_workspace_server_router( let api = WorkspaceServerApi::new(template, store); api.preload().await?; api.recover_workspace_deletions().await?; + let contract_service = ServerApiContractService::Server(api.clone()); let catalog = Router::new() - .route("/health", get(server_health)) - .route( - "/api/workspaces", - get(list_server_workspaces).post(create_server_workspace), - ) - .route( - "/api/workspaces/{workspace_id}/deletion", - get(preflight_server_workspace_deletion).post(start_server_workspace_deletion), - ) - .route( - "/api/workspace-deletions/{operation_id}", - get(get_server_workspace_deletion), - ) .fallback(dispatch_workspace_request) - .with_state(api.clone()); + .with_state(api.clone()) + .merge(server_api::server_api_axum::health(Arc::new( + contract_service.clone(), + ))) + .merge(generated_auth_contract_router(contract_service.clone())) + .merge(generated_workspace_catalog_contract_router( + contract_service, + )); Ok(auth .merge(catalog) .layer(axum::middleware::from_fn_with_state( @@ -3752,39 +3624,383 @@ fn configured_repository_from_record(record: RepositoryRecord) -> Result Router { Router::new() - .route("/api/auth/config", get(get_auth_config)) - .route("/api/auth/bootstrap-user", post(post_auth_bootstrap_user)) - .route( - "/api/auth/passkeys/registration/options", - post(post_passkey_registration_options), - ) .route( "/api/auth/passkeys/registration/complete", post(post_passkey_registration_complete), ) - .route( - "/api/auth/passkeys/login/options", - post(post_passkey_login_options), - ) .route( "/api/auth/passkeys/login/complete", post(post_passkey_login_complete), ) .route("/api/auth/logout", post(post_auth_logout)) - .route( - "/api/auth/device-login/start", - post(post_device_login_start), - ) - .route( - "/api/auth/device-login/approve", - post(post_device_login_approve), - ) - .route("/api/auth/device-login/poll", post(post_device_login_poll)) - .route("/api/auth/whoami", get(get_auth_whoami)) .with_state(api) } -impl server_api::ServerApi for WorkspaceApi { +async fn attach_server_origin_context(mut request: Request, next: Next) -> Response { + let actor = request.extensions().get::().cloned(); + let origin = request_origin(request.headers()); + request + .extensions_mut() + .insert(server_api::ServerRequestContext { actor, origin }); + next.run(request).await +} + +async fn attach_server_request_context( + State(service): State, + mut request: Request, + next: Next, +) -> Response { + let actor = if let Some(actor) = request.extensions().get::().cloned() { + Some(actor) + } else { + let auth = service.auth_api(); + match resolve_request_actor( + auth.store.as_ref(), + request.headers(), + &auth_public_config(&auth.config).cookie_name, + ) + .await + { + Ok(actor) => actor, + Err(error) => return server_error_response(error), + } + }; + let origin = request_origin(request.headers()); + request + .extensions_mut() + .insert(server_api::ServerRequestContext { actor, origin }); + next.run(request).await +} + +fn generated_auth_contract_router(service: ServerApiContractService) -> Router { + let service = Arc::new(service); + let origin_context = Router::new() + .merge(server_api::server_api_axum::auth_passkey_registration_options(service.clone())) + .merge(server_api::server_api_axum::auth_passkey_login_options( + service.clone(), + )) + .layer(middleware::from_fn(attach_server_origin_context)); + let actor_context = Router::new() + .merge(server_api::server_api_axum::auth_device_login_approve( + service.clone(), + )) + .merge(server_api::server_api_axum::auth_whoami(service.clone())) + .layer(middleware::from_fn_with_state( + service.as_ref().clone(), + attach_server_request_context, + )); + Router::new() + .merge(server_api::server_api_axum::auth_config(service.clone())) + .merge(server_api::server_api_axum::auth_bootstrap_user( + service.clone(), + )) + .merge(server_api::server_api_axum::auth_device_login_start( + service.clone(), + )) + .merge(server_api::server_api_axum::auth_device_login_poll(service)) + .merge(origin_context) + .merge(actor_context) +} + +fn generated_workspace_catalog_contract_router(service: ServerApiContractService) -> Router { + let service = Arc::new(service); + Router::new() + .merge(server_api::server_api_axum::workspace_catalog_list( + service.clone(), + )) + .merge(server_api::server_api_axum::workspace_catalog_create( + service.clone(), + )) + .merge(server_api::server_api_axum::workspace_deletion_preflight( + service.clone(), + )) + .merge(server_api::server_api_axum::workspace_deletion_start( + service.clone(), + )) + .merge(server_api::server_api_axum::workspace_deletion_get( + service.clone(), + )) + .layer(middleware::from_fn_with_state( + service.as_ref().clone(), + attach_server_request_context, + )) +} + +fn generated_workspace_contract_router(service: ServerApiContractService) -> Router { + let service = Arc::new(service); + Router::new() + .merge(server_api::server_api_axum::worker_session(service.clone())) + .merge(server_api::server_api_axum::repository_list( + service.clone(), + )) + .merge(server_api::server_api_axum::repository_list_alias( + service.clone(), + )) + .merge(server_api::server_api_axum::repository_detail( + service.clone(), + )) + .merge(server_api::server_api_axum::repository_detail_alias( + service.clone(), + )) + .merge(server_api::server_api_axum::repository_create(service)) +} + +impl server_api::ServerApi for ServerApiContractService { + async fn health( + &self, + ) -> std::result::Result { + Ok(server_api::HealthResponse { + status: "ok".to_string(), + }) + } + + async fn auth_config( + &self, + ) -> std::result::Result { + Ok(auth_public_config(&self.auth_api().config)) + } + + async fn auth_bootstrap_user( + &self, + request: AuthBootstrapUserRequest, + ) -> std::result::Result { + post_auth_bootstrap_user(State(self.auth_api()), Json(request)) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn auth_passkey_registration_options( + &self, + context: server_api::ServerRequestContext, + mut request: PasskeyRegistrationOptionsRequest, + ) -> std::result::Result + { + if request.browser_origin.is_none() { + request.browser_origin = context.origin; + } + post_passkey_registration_options(State(self.auth_api()), HeaderMap::new(), Json(request)) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn auth_passkey_login_options( + &self, + context: server_api::ServerRequestContext, + mut request: PasskeyLoginOptionsRequest, + ) -> std::result::Result { + if request.browser_origin.is_none() { + request.browser_origin = context.origin; + } + post_passkey_login_options(State(self.auth_api()), HeaderMap::new(), Json(request)) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn auth_device_login_start( + &self, + request: DeviceLoginStartRequest, + ) -> std::result::Result { + post_device_login_start(State(self.auth_api()), Json(request)) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn auth_device_login_approve( + &self, + context: server_api::ServerRequestContext, + request: DeviceLoginApproveRequest, + ) -> std::result::Result { + let actor = context.actor.ok_or_else(|| { + server_api::RepositoryApiError::new( + StatusCode::UNAUTHORIZED.as_u16(), + "Unauthorized", + "request requires a browser session or Bearer API token", + Vec::new(), + ) + })?; + approve_device_login(&self.auth_api(), actor, request) + .map_err(ApiError::into_repository_api_error) + } + + async fn auth_device_login_poll( + &self, + request: DeviceLoginPollRequest, + ) -> std::result::Result { + post_device_login_poll(State(self.auth_api()), Json(request)) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn auth_whoami( + &self, + context: server_api::ServerRequestContext, + ) -> std::result::Result { + Ok(WhoamiResponse { + actor: context.actor, + }) + } + + async fn workspace_catalog_list( + &self, + context: server_api::ServerRequestContext, + query: server_api::WorkspaceListQuery, + ) -> std::result::Result { + let api = self.server_api()?; + let Some(actor) = context.actor else { + return if api + .catalog + .is_empty() + .map_err(|error| ApiError::from(error).into_repository_api_error())? + { + Ok(WorkspaceCatalogListResponse(Vec::new())) + } else { + Err(server_api::RepositoryApiError::new( + StatusCode::UNAUTHORIZED.as_u16(), + "Unauthorized", + "authentication required", + Vec::new(), + )) + }; + }; + let workspaces = api + .catalog + .list( + &actor.account_id, + query.limit.unwrap_or(100).try_into().unwrap_or(200), + ) + .map_err(|error| ApiError::from(error).into_repository_api_error())?; + Ok(WorkspaceCatalogListResponse( + workspaces.into_iter().map(workspace_summary).collect(), + )) + } + + async fn workspace_catalog_create( + &self, + context: server_api::ServerRequestContext, + request: WorkspaceCreateRequest, + ) -> std::result::Result { + let api = self.server_api()?; + let actor = context.actor.ok_or_else(|| { + server_api::RepositoryApiError::new( + StatusCode::FORBIDDEN.as_u16(), + "Forbidden", + "Workspace creation requires an authenticated owner", + Vec::new(), + ) + })?; + let created = api + .catalog + .create(request, actor.account_id) + .map_err(|error| ApiError::from(error).into_repository_api_error())?; + api.router_for_workspace(&created.workspace.workspace_id) + .await + .map_err(|error| ApiError::from(error).into_repository_api_error())?; + Ok(workspace_create_response(created)) + } + + async fn workspace_deletion_preflight( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + ) -> std::result::Result + { + let api = self.server_api()?; + let actor = context.actor.ok_or_else(|| { + server_api::RepositoryApiError::new( + StatusCode::FORBIDDEN.as_u16(), + "Forbidden", + "Workspace deletion requires its owner", + Vec::new(), + ) + })?; + api.workspace_deletion_preflight(&actor.account_id, &workspace_id) + .await + .map_err(|error| ApiError::from(error).into_repository_api_error()) + } + + async fn workspace_deletion_start( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + request: WorkspaceDeletionRequest, + ) -> std::result::Result + { + let api = self.server_api()?; + let actor = context.actor.ok_or_else(|| { + server_api::RepositoryApiError::new( + StatusCode::FORBIDDEN.as_u16(), + "Forbidden", + "Workspace deletion requires its owner", + Vec::new(), + ) + })?; + let mutation_lock = api.mutation_lock(&workspace_id).await; + let _mutation_guard = mutation_lock.lock().await; + let existing = api + .store + .workspace_deletion_operation(&actor.account_id, &request.operation_id) + .map_err(|error| ApiError::from(error).into_repository_api_error())?; + if existing.is_none() { + let preflight = api + .workspace_deletion_preflight(&actor.account_id, &workspace_id) + .await + .map_err(|error| ApiError::from(error).into_repository_api_error())?; + if !preflight.can_delete { + return Err(server_api::RepositoryApiError::new( + StatusCode::CONFLICT.as_u16(), + "Conflict", + "Workspace deletion preflight is blocked", + Vec::new(), + )); + } + } + let reservation = api + .store + .reserve_workspace_deletion(&actor.account_id, &workspace_id, &request) + .map_err(|error| ApiError::from(error).into_repository_api_error())?; + if let Some(handle) = api.hook_handles.lock().await.remove(&workspace_id) { + handle.abort(); + } + let operation = reservation.operation; + if operation.state != WorkspaceDeletionState::Succeeded { + api.schedule_workspace_deletion(request.operation_id).await; + } + Ok(operation) + } + + async fn workspace_deletion_get( + &self, + context: server_api::ServerRequestContext, + operation_id: String, + ) -> std::result::Result + { + let api = self.server_api()?; + let actor = context.actor.ok_or_else(|| { + server_api::RepositoryApiError::new( + StatusCode::FORBIDDEN.as_u16(), + "Forbidden", + "Workspace deletion status requires its owner", + Vec::new(), + ) + })?; + api.store + .workspace_deletion_operation(&actor.account_id, &operation_id) + .map_err(|error| ApiError::from(error).into_repository_api_error())? + .ok_or_else(|| { + server_api::RepositoryApiError::new( + StatusCode::NOT_FOUND.as_u16(), + "Not Found", + "Workspace deletion operation not found", + Vec::new(), + ) + }) + } + async fn worker_session( &self, workspace_id: String, @@ -3792,7 +4008,14 @@ impl server_api::ServerApi for WorkspaceApi { worker_id: String, ) -> std::result::Result { - if workspace_id != self.config.workspace_id { + let api = self.workspace_api().map_err(|_| { + runtime_api::RuntimeApiError::new( + StatusCode::NOT_FOUND.as_u16(), + "workspace_not_found", + "Workspace was not found", + ) + })?; + if workspace_id != api.config.workspace_id { return Err(runtime_api::RuntimeApiError::new( StatusCode::NOT_FOUND.as_u16(), "workspace_not_found", @@ -3800,7 +4023,7 @@ impl server_api::ServerApi for WorkspaceApi { )); } let worker = RuntimeWorkerRef::new(runtime_id.clone(), worker_id.clone()); - let observation = self + let observation = api .runtime .worker_session(&workspace_id, &worker) .map_err(|_error| { @@ -3824,20 +4047,18 @@ impl server_api::ServerApi for WorkspaceApi { workspace_id: String, ) -> std::result::Result { - scoped_list_repositories( - State(self.clone()), - AxumPath(ScopedWorkspacePath { workspace_id }), - ) - .await - .map(|Json(response)| response) - .map_err(ApiError::into_repository_api_error) + let api = self.workspace_api()?.clone(); + scoped_list_repositories(State(api), AxumPath(ScopedWorkspacePath { workspace_id })) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) } async fn repository_list_alias( &self, ) -> std::result::Result { - list_repositories(State(self.clone())) + list_repositories(State(self.workspace_api()?.clone())) .await .map(|Json(response)| response) .map_err(ApiError::into_repository_api_error) @@ -3849,8 +4070,9 @@ impl server_api::ServerApi for WorkspaceApi { repository_key: String, ) -> std::result::Result { + let api = self.workspace_api()?.clone(); scoped_repository_detail( - State(self.clone()), + State(api), AxumPath(ScopedRepositoryPath { workspace_id, repository_key, @@ -3866,10 +4088,13 @@ impl server_api::ServerApi for WorkspaceApi { repository_key: String, ) -> std::result::Result { - repository_detail(State(self.clone()), AxumPath(repository_key)) - .await - .map(|Json(response)| response) - .map_err(ApiError::into_repository_api_error) + repository_detail( + State(self.workspace_api()?.clone()), + AxumPath(repository_key), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) } async fn repository_create( @@ -3880,7 +4105,7 @@ impl server_api::ServerApi for WorkspaceApi { ) -> std::result::Result { scoped_create_repository( - State(self.clone()), + State(self.workspace_api()?.clone()), AxumPath(ScopedWorkspacePath { workspace_id }), Extension(actor), Json(request), @@ -3892,7 +4117,9 @@ impl server_api::ServerApi for WorkspaceApi { } fn build_inner_router(api: WorkspaceApi) -> Router { - let auth = build_server_auth_router(ServerAuthApi::from(&api)); + let contract_service = ServerApiContractService::Workspace(api.clone()); + let auth = build_server_auth_router(ServerAuthApi::from(&api)) + .merge(generated_auth_contract_router(contract_service.clone())); let scoped_ticket_relations_query_path = format!("/api/w/{{workspace_id}}{TICKET_RELATIONS_QUERY_PATH}"); let scoped_ticket_orchestration_plans_query_path = @@ -4489,11 +4716,9 @@ fn build_inner_router(api: WorkspaceApi) -> Router { "/api/w/{workspace_id}/hosts/{host_id}/workers", get(scoped_list_host_workers), ) - .merge( - server_api::ServerApiAxum::router(api.clone()).with_state::(()), - ) .fallback(get(static_or_spa_fallback)) - .with_state(api); + .with_state(api) + .merge(generated_workspace_contract_router(contract_service)); auth.merge(workspace) .layer(middleware::from_fn(log_failed_api_response)) } @@ -15038,10 +15263,6 @@ async fn scoped_list_host_workers( list_host_workers(State(api), AxumPath(path.host_id)).await } -async fn get_auth_config(State(api): State) -> ApiResult> { - Ok(Json(auth_public_config(&api.config))) -} - async fn post_auth_bootstrap_user( State(api): State, Json(request): Json, @@ -15387,12 +15608,11 @@ async fn post_device_login_start( .into()) } -async fn post_device_login_approve( - State(api): State, - headers: HeaderMap, - Json(request): Json, -) -> ApiResult> { - let actor = require_actor(&api, &headers).await?; +fn approve_device_login( + api: &ServerAuthApi, + actor: RequestActor, + request: DeviceLoginApproveRequest, +) -> ApiResult { let flow = api .store .get_device_login_flow_by_user_code(&request.user_code.trim().to_ascii_uppercase())? @@ -15441,10 +15661,10 @@ async fn post_device_login_approve( ) .into()); } - Ok(Json(DeviceLoginApproveResponse { + Ok(DeviceLoginApproveResponse { status: DeviceLoginApprovalStatus::Approved, user: actor.user(), - })) + }) } async fn post_device_login_poll( @@ -15488,15 +15708,6 @@ async fn post_device_login_poll( })) } -async fn get_auth_whoami( - State(api): State, - headers: HeaderMap, -) -> ApiResult> { - Ok(Json(WhoamiResponse { - actor: resolve_actor(&api, &headers).await?, - })) -} - async fn post_auth_logout( State(api): State, headers: HeaderMap, diff --git a/openapi/server-api.json b/openapi/server-api.json index 4e45d76b0..cfc6c7ef9 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -1,6 +1,94 @@ { "components": { "schemas": { + "ActorAuthMethod": { + "description": "Authentication method that established the current request actor.", + "enum": [ + "browser_session", + "api_token" + ], + "type": "string" + }, + "AuthBootstrapUserRequest": { + "additionalProperties": false, + "properties": { + "display_name": { + "type": [ + "string", + "null" + ] + }, + "handle": { + "type": "string" + } + }, + "required": [ + "handle" + ], + "type": "object" + }, + "AuthPublicConfig": { + "additionalProperties": false, + "description": "Public browser-authentication configuration.", + "properties": { + "cookie_name": { + "type": "string" + }, + "origin": { + "type": "string" + }, + "public_base_url": { + "type": "string" + }, + "rp_id": { + "type": "string" + } + }, + "required": [ + "rp_id", + "origin", + "public_base_url", + "cookie_name" + ], + "type": "object" + }, + "AuthUserResponse": { + "additionalProperties": false, + "properties": { + "user": { + "$ref": "#/components/schemas/AuthenticatedUser" + } + }, + "required": [ + "user" + ], + "type": "object" + }, + "AuthenticatedUser": { + "additionalProperties": false, + "description": "Public user identity returned by authentication operations.", + "properties": { + "account_id": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "handle": { + "type": "string" + }, + "user_id": { + "type": "string" + } + }, + "required": [ + "user_id", + "account_id", + "handle", + "display_name" + ], + "type": "object" + }, "CreateWorkspaceRepositoryRequest": { "additionalProperties": false, "description": "Browser/user intent for registering one Repository in a Workspace.\n\nThe Server parses and canonicalizes `source`; callers cannot assert a\ntransport classification or supply credential material through this DTO.", @@ -44,6 +132,146 @@ ], "type": "object" }, + "DeviceAccessTokenType": { + "enum": [ + "Bearer" + ], + "type": "string" + }, + "DeviceLoginApprovalStatus": { + "enum": [ + "approved" + ], + "type": "string" + }, + "DeviceLoginApproveRequest": { + "additionalProperties": false, + "properties": { + "user_code": { + "type": "string" + } + }, + "required": [ + "user_code" + ], + "type": "object" + }, + "DeviceLoginApproveResponse": { + "additionalProperties": false, + "properties": { + "status": { + "$ref": "#/components/schemas/DeviceLoginApprovalStatus" + }, + "user": { + "$ref": "#/components/schemas/AuthenticatedUser" + } + }, + "required": [ + "status", + "user" + ], + "type": "object" + }, + "DeviceLoginPollRequest": { + "additionalProperties": false, + "properties": { + "device_code": { + "type": "string" + } + }, + "required": [ + "device_code" + ], + "type": "object" + }, + "DeviceLoginPollResponse": { + "additionalProperties": false, + "properties": { + "access_token": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/components/schemas/DeviceLoginPollStatus" + }, + "token_type": { + "anyOf": [ + { + "$ref": "#/components/schemas/DeviceAccessTokenType" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "DeviceLoginPollStatus": { + "enum": [ + "pending", + "approved", + "expired", + "denied", + "consumed" + ], + "type": "string" + }, + "DeviceLoginStartRequest": { + "additionalProperties": false, + "properties": { + "client_name": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "DeviceLoginStartResponse": { + "additionalProperties": false, + "properties": { + "device_code": { + "type": "string" + }, + "expires_in": { + "format": "uint64", + "maximum": 86400, + "minimum": 1, + "type": "integer" + }, + "interval": { + "format": "uint64", + "maximum": 60, + "minimum": 1, + "type": "integer" + }, + "user_code": { + "type": "string" + }, + "verification_uri": { + "type": "string" + }, + "verification_uri_complete": { + "type": "string" + } + }, + "required": [ + "device_code", + "user_code", + "verification_uri", + "verification_uri_complete", + "expires_in", + "interval" + ], + "type": "object" + }, "Diagnostic": { "additionalProperties": false, "properties": { @@ -123,7 +351,114 @@ ], "type": "object" }, + "HealthResponse": { + "additionalProperties": false, + "properties": { + "status": { + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "InitialRepositoryIntent": { + "additionalProperties": false, + "description": "Initial Repository registration intent for Workspace creation.", + "properties": { + "default_ref": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "repository_key": { + "type": "string" + }, + "uri": { + "type": "string" + } + }, + "required": [ + "repository_key", + "uri" + ], + "type": "object" + }, + "PasskeyLoginOptionsRequest": { + "additionalProperties": false, + "properties": { + "browser_origin": { + "type": [ + "string", + "null" + ] + }, + "handle": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "PasskeyLoginOptionsResponse": { + "additionalProperties": false, + "properties": { + "challenge_id": { + "type": "string" + }, + "public_key": true + }, + "required": [ + "challenge_id", + "public_key" + ], + "type": "object" + }, + "PasskeyRegistrationOptionsRequest": { + "additionalProperties": false, + "properties": { + "browser_origin": { + "type": [ + "string", + "null" + ] + }, + "display_name": { + "type": [ + "string", + "null" + ] + }, + "handle": { + "type": "string" + } + }, + "required": [ + "handle" + ], + "type": "object" + }, + "PasskeyRegistrationOptionsResponse": { + "additionalProperties": false, + "properties": { + "challenge_id": { + "type": "string" + }, + "public_key": true + }, + "required": [ + "challenge_id", + "public_key" + ], + "type": "object" + }, "RepositoryApiError": { + "additionalProperties": false, "description": "Error body shared by the existing repository routes and their generated adapters.", "properties": { "diagnostics": { @@ -334,6 +669,449 @@ ], "type": "object" }, + "RequestActor": { + "additionalProperties": false, + "description": "Authenticated actor returned by `GET /api/auth/whoami`.", + "properties": { + "account_id": { + "type": "string" + }, + "auth_method": { + "$ref": "#/components/schemas/ActorAuthMethod" + }, + "display_name": { + "type": "string" + }, + "handle": { + "type": "string" + }, + "user_id": { + "type": "string" + } + }, + "required": [ + "user_id", + "account_id", + "handle", + "display_name", + "auth_method" + ], + "type": "object" + }, + "WhoamiResponse": { + "additionalProperties": false, + "properties": { + "actor": { + "anyOf": [ + { + "$ref": "#/components/schemas/RequestActor" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "WorkspaceCatalogListResponse": { + "description": "Public response returned by `GET /api/workspaces`.\n\nThe transparent newtype keeps the established top-level JSON array while making the\ncomplete list response a named cross-crate and generated-TypeScript authority.", + "items": { + "$ref": "#/components/schemas/WorkspaceSummary" + }, + "type": "array" + }, + "WorkspaceCreateRequest": { + "additionalProperties": false, + "description": "Request for atomically creating a Workspace and its initial Repository.", + "properties": { + "display_name": { + "type": "string" + }, + "operation_key": { + "type": "string" + }, + "repository": { + "$ref": "#/components/schemas/InitialRepositoryIntent" + } + }, + "required": [ + "operation_key", + "display_name", + "repository" + ], + "type": "object" + }, + "WorkspaceCreateResponse": { + "additionalProperties": false, + "description": "Response returned after atomically creating a Workspace and its first Repository.", + "properties": { + "config_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "replayed": { + "type": "boolean" + }, + "repository": { + "$ref": "#/components/schemas/WorkspaceRepositoryRecord" + }, + "request_fingerprint": { + "type": "string" + }, + "workspace": { + "$ref": "#/components/schemas/WorkspaceSummary" + } + }, + "required": [ + "workspace", + "repository", + "config_revision", + "request_fingerprint", + "replayed" + ], + "type": "object" + }, + "WorkspaceDeletionBlocker": { + "additionalProperties": false, + "description": "One bounded, user-actionable blocker returned by preflight or execution.", + "properties": { + "kind": { + "$ref": "#/components/schemas/WorkspaceDeletionBlockerKind" + }, + "message": { + "type": "string" + }, + "resource_key": { + "type": [ + "string", + "null" + ] + }, + "resource_kind": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "message" + ], + "type": "object" + }, + "WorkspaceDeletionBlockerKind": { + "description": "Stable category explaining why Workspace deletion cannot currently advance.", + "enum": [ + "last_accessible_workspace", + "revision_conflict", + "dirty_workdir", + "worker_removal_blocked", + "workdir_removal_blocked", + "retention_hold", + "cleanup_unavailable" + ], + "type": "string" + }, + "WorkspaceDeletionOperationResponse": { + "additionalProperties": false, + "description": "Durable deletion operation projection used by request responses and polling.", + "properties": { + "blockers": { + "items": { + "$ref": "#/components/schemas/WorkspaceDeletionBlocker" + }, + "type": "array" + }, + "child_operation_ids": { + "items": { + "type": "string" + }, + "type": "array" + }, + "completed_at": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "failure_category": { + "type": [ + "string", + "null" + ] + }, + "operation_id": { + "type": "string" + }, + "resources": { + "$ref": "#/components/schemas/WorkspaceDeletionResourceCounts" + }, + "state": { + "$ref": "#/components/schemas/WorkspaceDeletionState" + }, + "updated_at": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "operation_id", + "workspace_id", + "display_name", + "state", + "resources", + "child_operation_ids", + "blockers", + "created_at", + "updated_at" + ], + "type": "object" + }, + "WorkspaceDeletionPreflightResponse": { + "additionalProperties": false, + "description": "Owner-only impact preview for deleting one Workspace.", + "properties": { + "blockers": { + "items": { + "$ref": "#/components/schemas/WorkspaceDeletionBlocker" + }, + "type": "array" + }, + "can_delete": { + "type": "boolean" + }, + "display_name": { + "type": "string" + }, + "expected_revision": { + "description": "Opaque persisted Workspace metadata revision used as a CAS fence.", + "type": "string" + }, + "resources": { + "$ref": "#/components/schemas/WorkspaceDeletionResourceCounts" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "display_name", + "expected_revision", + "can_delete", + "resources", + "blockers" + ], + "type": "object" + }, + "WorkspaceDeletionRequest": { + "additionalProperties": false, + "description": "Idempotent request to start or resume Workspace deletion.", + "properties": { + "confirmation": { + "type": "string" + }, + "expected_revision": { + "type": "string" + }, + "operation_id": { + "type": "string" + } + }, + "required": [ + "operation_id", + "expected_revision", + "confirmation" + ], + "type": "object" + }, + "WorkspaceDeletionResourceCounts": { + "additionalProperties": false, + "description": "Workspace-owned resources summarized before destructive confirmation.", + "properties": { + "artifacts": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "repositories": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "runtime_bindings": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "secrets": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "workdirs": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "workers": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "workers", + "workdirs", + "repositories", + "runtime_bindings", + "secrets", + "artifacts" + ], + "type": "object" + }, + "WorkspaceDeletionState": { + "description": "Lifecycle state for one durable Workspace deletion operation.", + "enum": [ + "queued", + "running", + "blocked", + "failed", + "succeeded" + ], + "type": "string" + }, + "WorkspaceListQuery": { + "additionalProperties": false, + "properties": { + "limit": { + "format": "uint32", + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "WorkspaceRepositoryRecord": { + "additionalProperties": false, + "description": "Public Repository record embedded in Workspace creation responses.", + "properties": { + "created_at": { + "type": "string" + }, + "default_ref": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "type": "string" + }, + "observed_at": { + "type": [ + "string", + "null" + ] + }, + "observed_status": { + "$ref": "#/components/schemas/RepositoryObservedStatus" + }, + "provider": { + "type": [ + "string", + "null" + ] + }, + "repository_key": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/RepositorySource" + }, + "source_fingerprint": { + "type": "string" + }, + "source_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "repository_key", + "kind", + "source", + "source_revision", + "source_fingerprint", + "observed_status", + "created_at", + "updated_at" + ], + "type": "object" + }, + "WorkspaceSummary": { + "additionalProperties": false, + "description": "Public Workspace catalog item returned by `GET /api/workspaces`.", + "properties": { + "created_at": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "owner_account_id": { + "type": "string" + }, + "state": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "owner_account_id", + "display_name", + "state", + "created_at", + "updated_at" + ], + "type": "object" + }, "string": { "type": "string" } @@ -353,10 +1131,424 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:bac8ba9340cf541183e96715dd4ccd8367f965cdcfc398e6d5091f26a903e1ef" + "x-yoi-source-digest": "sha256:b478a5e12b43d9ac1261ba31358a5b80bb31e398bd57eccbd797f5e92c6f48b7" }, "openapi": "3.1.0", "paths": { + "/api/auth/bootstrap-user": { + "post": { + "operationId": "auth_bootstrap_user", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthBootstrapUserRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthUserResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + } + } + }, + "/api/auth/config": { + "get": { + "operationId": "auth_config", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthPublicConfig" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + } + } + }, + "/api/auth/device-login/approve": { + "post": { + "operationId": "auth_device_login_approve", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceLoginApproveRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceLoginApproveResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/auth/device-login/poll": { + "post": { + "operationId": "auth_device_login_poll", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceLoginPollRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceLoginPollResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + } + } + }, + "/api/auth/device-login/start": { + "post": { + "operationId": "auth_device_login_start", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceLoginStartRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceLoginStartResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + } + } + }, + "/api/auth/passkeys/login/options": { + "post": { + "operationId": "auth_passkey_login_options", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyLoginOptionsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyLoginOptionsResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + } + } + }, + "/api/auth/passkeys/registration/options": { + "post": { + "operationId": "auth_passkey_registration_options", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyRegistrationOptionsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyRegistrationOptionsResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + } + } + }, + "/api/auth/whoami": { + "get": { + "operationId": "auth_whoami", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WhoamiResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + } + } + }, "/api/repositories": { "get": { "operationId": "repository_list_alias", @@ -847,6 +2039,492 @@ } ] } + }, + "/api/workspace-deletions/{operation_id}": { + "get": { + "operationId": "workspace_deletion_get", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "operation_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceDeletionOperationResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/workspaces": { + "get": { + "operationId": "workspace_catalog_list", + "parameters": [ + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/WorkspaceListQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceCatalogListResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + }, + "post": { + "operationId": "workspace_catalog_create", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceCreateResponse" + } + } + }, + "description": "Alternate successful response" + }, + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceCreateResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/workspaces/{workspace_id}/deletion": { + "get": { + "operationId": "workspace_deletion_preflight", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceDeletionPreflightResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + }, + "post": { + "operationId": "workspace_deletion_start", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceDeletionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceDeletionOperationResponse" + } + } + }, + "description": "Alternate successful response" + }, + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceDeletionOperationResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/health": { + "get": { + "operationId": "health", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + } + } } } } diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index aa6a289fa..93cb65c48 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:bac8ba9340cf541183e96715dd4ccd8367f965cdcfc398e6d5091f26a903e1ef) +// Input contract: 0.1.0 (sha256:b478a5e12b43d9ac1261ba31358a5b80bb31e398bd57eccbd797f5e92c6f48b7) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check