feat: migrate auth and workspace catalog to ServerApi

This commit is contained in:
2026-09-23 02:40:51 +09:00
parent 10b3740696
commit c8766e9fb9
6 changed files with 2638 additions and 379 deletions
+55 -46
View File
@@ -1,9 +1,11 @@
use crate::BackendOrigin; use crate::BackendOrigin;
use serde::Deserialize;
use std::fmt; use std::fmt;
use std::time::Duration; use std::time::Duration;
use server_api::{DeviceLoginPollRequest, DeviceLoginPollStatus, DeviceLoginStartRequest}; use server_api::{
DeviceLoginPollRequest, DeviceLoginPollStatus, DeviceLoginStartRequest, RepositoryApiError,
ServerApiClient,
};
pub use server_api::{DeviceLoginPollResponse, DeviceLoginStartResponse}; pub use server_api::{DeviceLoginPollResponse, DeviceLoginStartResponse};
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -19,21 +21,12 @@ impl BackendAuthTarget {
.unwrap_or(base_url); .unwrap_or(base_url);
Self { 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)] #[derive(Debug)]
pub enum BackendAuthClientError { pub enum BackendAuthClientError {
Http(reqwest::Error), InvalidTarget(String),
ServerApi(server_api::client_support::ClientError<RepositoryApiError>),
BackendStatus { status: u16, body: String }, BackendStatus { status: u16, body: String },
MissingAccessToken, MissingAccessToken,
} }
@@ -41,7 +34,8 @@ pub enum BackendAuthClientError {
impl fmt::Display for BackendAuthClientError { impl fmt::Display for BackendAuthClientError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { 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 } => { Self::BackendStatus { status, body } => {
write!(f, "Backend auth returned HTTP {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 std::error::Error for BackendAuthClientError {}
impl From<reqwest::Error> for BackendAuthClientError { const AUTH_RESPONSE_LIMIT: usize = 2 * 1024 * 1024;
fn from(value: reqwest::Error) -> Self {
Self::Http(value) fn auth_client(target: &BackendAuthTarget) -> Result<ServerApiClient, BackendAuthClientError> {
} 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( pub async fn start_device_login(
target: &BackendAuthTarget, target: &BackendAuthTarget,
client_name: Option<&str>, client_name: Option<&str>,
) -> Result<DeviceLoginStartResponse, BackendAuthClientError> { ) -> Result<DeviceLoginStartResponse, BackendAuthClientError> {
let client = reqwest::Client::new(); auth_client(target)?
let response = client .auth_device_login_start(DeviceLoginStartRequest {
.post(target.api_url("/api/auth/device-login/start"))
.json(&DeviceLoginStartRequest {
client_name: client_name.map(ToOwned::to_owned), client_name: client_name.map(ToOwned::to_owned),
}) })
.send() .await
.await?; .map_err(BackendAuthClientError::ServerApi)
parse_json_response(response).await
} }
pub async fn poll_device_login( pub async fn poll_device_login(
target: &BackendAuthTarget, target: &BackendAuthTarget,
device_code: &str, device_code: &str,
) -> Result<DeviceLoginPollResponse, BackendAuthClientError> { ) -> Result<DeviceLoginPollResponse, BackendAuthClientError> {
let client = reqwest::Client::new(); auth_client(target)?
let response = client .auth_device_login_poll(DeviceLoginPollRequest {
.post(target.api_url("/api/auth/device-login/poll"))
.json(&DeviceLoginPollRequest {
device_code: device_code.to_string(), device_code: device_code.to_string(),
}) })
.send() .await
.await?; .map_err(BackendAuthClientError::ServerApi)
parse_json_response(response).await
} }
fn device_login_poll_result( fn device_login_poll_result(
@@ -136,24 +128,13 @@ pub async fn wait_for_device_login(
} }
} }
async fn parse_json_response<T: for<'de> Deserialize<'de>>(
response: reqwest::Response,
) -> Result<T, BackendAuthClientError> {
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::<T>().await?)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use server_api::DeviceAccessTokenType; use server_api::DeviceAccessTokenType;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread;
fn poll_response(status: DeviceLoginPollStatus) -> DeviceLoginPollResponse { fn poll_response(status: DeviceLoginPollStatus) -> DeviceLoginPollResponse {
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] #[test]
fn device_login_start_response_enforces_shared_expiry_bounds() { fn device_login_start_response_enforces_shared_expiry_bounds() {
let valid = serde_json::json!({ let valid = serde_json::json!({
+49 -53
View File
@@ -1,8 +1,7 @@
use crate::{BackendApiClient, BackendApiClientError}; use crate::{BackendApiClient, BackendApiClientError};
use reqwest::Method;
use server_api::{ use server_api::{
InitialRepositoryIntent, RepositorySummary, WorkspaceCatalogListResponse, InitialRepositoryIntent, RepositorySummary, WorkspaceCreateRequest, WorkspaceCreateResponse,
WorkspaceCreateRequest, WorkspaceCreateResponse, WorkspaceSummary, WorkspaceListQuery, WorkspaceSummary,
}; };
use std::fmt; use std::fmt;
@@ -31,7 +30,7 @@ pub enum BackendWorkspaceClientError {
InvalidTarget(String), InvalidTarget(String),
Api(BackendApiClientError), Api(BackendApiClientError),
Http(reqwest::Error), Http(reqwest::Error),
RepositoryApi(server_api::client_support::ClientError<server_api::RepositoryApiError>), ServerApi(server_api::client_support::ClientError<server_api::RepositoryApiError>),
} }
impl fmt::Display for BackendWorkspaceClientError { impl fmt::Display for BackendWorkspaceClientError {
@@ -40,7 +39,7 @@ impl fmt::Display for BackendWorkspaceClientError {
Self::InvalidTarget(message) => f.write_str(message), Self::InvalidTarget(message) => f.write_str(message),
Self::Api(error) => write!(f, "{error}"), Self::Api(error) => write!(f, "{error}"),
Self::Http(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<server_api::client_support::ClientError<server_api::RepositoryApiError
fn from( fn from(
error: server_api::client_support::ClientError<server_api::RepositoryApiError>, error: server_api::client_support::ClientError<server_api::RepositoryApiError>,
) -> Self { ) -> Self {
Self::RepositoryApi(error) Self::ServerApi(error)
} }
} }
fn repository_client_error( fn server_client_error(
backend: &BackendApiClient, backend: &BackendApiClient,
error: server_api::client_support::ClientError<server_api::RepositoryApiError>, error: server_api::client_support::ClientError<server_api::RepositoryApiError>,
) -> BackendWorkspaceClientError { ) -> BackendWorkspaceClientError {
@@ -88,16 +87,16 @@ fn repository_client_error(
origin: backend.origin().clone(), origin: backend.origin().clone(),
}) })
} }
_ => BackendWorkspaceClientError::RepositoryApi(error), _ => BackendWorkspaceClientError::ServerApi(error),
} }
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
struct RepositoryBearerAuthorizer { struct ServerBearerAuthorizer {
authorization: String, authorization: String,
} }
impl server_api::client_support::RequestAuthorizer for RepositoryBearerAuthorizer { impl server_api::client_support::RequestAuthorizer for ServerBearerAuthorizer {
fn authorize( fn authorize(
&self, &self,
_request: server_api::client_support::AuthorizerRequest<'_>, _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<server_api::ServerApiClient<ServerBearerAuthorizer>, 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( pub fn list_backend_workspaces_blocking(
target: &BackendWorkspaceCatalogTarget, target: &BackendWorkspaceCatalogTarget,
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> { ) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
let client = BackendApiClient::from_stored_token(&target.base_url)?; let client = BackendApiClient::from_stored_token(&target.base_url)?;
let response = client let runtime = tokio::runtime::Builder::new_current_thread()
.blocking_request( .enable_all()
Method::GET, .build()
&format!("/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}"), .map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))?;
)? runtime.block_on(list_backend_workspaces_with_client(&client))
.send()?;
client.check_status(response.status())?;
Ok(response.json::<WorkspaceCatalogListResponse>()?.0)
} }
pub fn list_backend_workspace_repositories_blocking( 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(), "Workspace id returned by Backend is invalid".to_string(),
)); ));
} }
let client = server_api::ServerApiClient::builder(backend.origin().as_str()) let response = server_api_client(backend)?
.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
.repository_list(workspace_id.to_owned()) .repository_list(workspace_id.to_owned())
.await .await
.map_err(|error| repository_client_error(backend, error))?; .map_err(|error| server_client_error(backend, error))?;
if response.workspace_id != workspace_id { if response.workspace_id != workspace_id {
return Err(BackendWorkspaceClientError::InvalidTarget( return Err(BackendWorkspaceClientError::InvalidTarget(
"Repository catalog response does not match the requested Workspace".to_string(), "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( async fn list_backend_workspaces_with_client(
client: &BackendApiClient, client: &BackendApiClient,
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> { ) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
let response = client let response = server_api_client(client)?
.request( .workspace_catalog_list(WorkspaceListQuery {
Method::GET, limit: Some(DEFAULT_WORKSPACE_LIMIT as u32),
&format!("/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}"), })
)? .await
.send() .map_err(|error| server_client_error(client, error))?;
.await?; Ok(response.0)
client.check_status(response.status())?;
Ok(response.json::<WorkspaceCatalogListResponse>().await?.0)
} }
pub async fn create_backend_workspace( pub async fn create_backend_workspace(
@@ -205,13 +204,10 @@ pub async fn create_backend_workspace(
request: &CreateBackendWorkspaceRequest, request: &CreateBackendWorkspaceRequest,
) -> Result<CreateBackendWorkspaceResponse, BackendWorkspaceClientError> { ) -> Result<CreateBackendWorkspaceResponse, BackendWorkspaceClientError> {
let client = BackendApiClient::from_stored_token(&target.base_url)?; let client = BackendApiClient::from_stored_token(&target.base_url)?;
let response = client server_api_client(&client)?
.request(Method::POST, "/api/workspaces")? .workspace_catalog_create(request.clone())
.json(request) .await
.send() .map_err(|error| server_client_error(&client, error))
.await?;
client.check_status(response.status())?;
Ok(response.json::<CreateBackendWorkspaceResponse>().await?)
} }
#[cfg(test)] #[cfg(test)]
@@ -292,12 +288,12 @@ mod tests {
write!( write!(
stream, stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", "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(); .unwrap();
stream.flush().unwrap(); stream.flush().unwrap();
let chunk = [b' '; 8192]; let chunk = [b' '; 8192];
let mut remaining = REPOSITORY_RESPONSE_LIMIT + 1; let mut remaining = SERVER_RESPONSE_LIMIT + 1;
while remaining > 0 { while remaining > 0 {
let length = remaining.min(chunk.len()); let length = remaining.min(chunk.len());
if stream.write_all(&chunk[..length]).is_err() { if stream.write_all(&chunk[..length]).is_err() {
@@ -315,10 +311,10 @@ mod tests {
assert!( assert!(
matches!( matches!(
&error, &error,
BackendWorkspaceClientError::RepositoryApi( BackendWorkspaceClientError::ServerApi(
server_api::client_support::ClientError::Failure( server_api::client_support::ClientError::Failure(
server_api::client_support::ClientFailure::ResponseTooLarge { server_api::client_support::ClientFailure::ResponseTooLarge {
limit: REPOSITORY_RESPONSE_LIMIT limit: SERVER_RESPONSE_LIMIT
} }
) )
) )
@@ -329,11 +325,11 @@ mod tests {
} }
#[test] #[test]
fn repository_client_preserves_authentication_error_taxonomy() { fn server_client_preserves_authentication_error_taxonomy() {
let client = let client =
BackendApiClient::from_access_token_for_test("https://backend.example.test", "token") BackendApiClient::from_access_token_for_test("https://backend.example.test", "token")
.unwrap(); .unwrap();
let unauthorized = repository_client_error( let unauthorized = server_client_error(
&client, &client,
server_api::client_support::ClientError::public( server_api::client_support::ClientError::public(
reqwest::StatusCode::UNAUTHORIZED, reqwest::StatusCode::UNAUTHORIZED,
@@ -349,7 +345,7 @@ mod tests {
unauthorized, unauthorized,
BackendWorkspaceClientError::Api(BackendApiClientError::Unauthorized { .. }) BackendWorkspaceClientError::Api(BackendApiClientError::Unauthorized { .. })
)); ));
let forbidden = repository_client_error( let forbidden = server_client_error(
&client, &client,
server_api::client_support::ClientError::public( server_api::client_support::ClientError::public(
reqwest::StatusCode::FORBIDDEN, reqwest::StatusCode::FORBIDDEN,
+399 -34
View File
@@ -22,6 +22,7 @@ pub type ServerApiClientError = client_support::ClientError<ServerApiError>;
/// Error body shared by the existing repository routes and their generated adapters. /// Error body shared by the existing repository routes and their generated adapters.
#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] #[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)]
#[serde(deny_unknown_fields)]
pub struct RepositoryApiError { pub struct RepositoryApiError {
pub error: String, pub error: String,
pub message: String, pub message: String,
@@ -76,6 +77,28 @@ macro_rules! impl_openapi_schema {
} }
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, CreateWorkspaceRepositoryRequest,
CreateWorkspaceRepositoryResponse, CreateWorkspaceRepositoryResponse,
RepositoryApiError, RepositoryApiError,
@@ -92,6 +115,162 @@ pub struct WorkspaceWorkerSessionResponse {
#[api(reqwest, axum, openapi)] #[api(reqwest, axum, openapi)]
pub trait ServerApi { pub trait ServerApi {
#[get("/health", status = 200, error_status = 400)]
async fn health(&self) -> Result<HealthResponse, RepositoryApiError>;
#[get("/api/auth/config", status = 200, error_status = 400)]
async fn auth_config(&self) -> Result<AuthPublicConfig, RepositoryApiError>;
#[post(
"/api/auth/bootstrap-user",
status = 200,
error_status = 400,
additional_error_statuses = [500]
)]
async fn auth_bootstrap_user(
&self,
#[body] request: AuthBootstrapUserRequest,
) -> Result<AuthUserResponse, RepositoryApiError>;
#[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<PasskeyRegistrationOptionsResponse, RepositoryApiError>;
#[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<PasskeyLoginOptionsResponse, RepositoryApiError>;
#[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<DeviceLoginStartResponse, RepositoryApiError>;
#[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<DeviceLoginApproveResponse, RepositoryApiError>;
#[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<DeviceLoginPollResponse, RepositoryApiError>;
#[get("/api/auth/whoami", status = 200, error_status = 400)]
async fn auth_whoami(
&self,
#[extension] context: ServerRequestContext,
) -> Result<WhoamiResponse, RepositoryApiError>;
#[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<WorkspaceCatalogListResponse, RepositoryApiError>;
#[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<WorkspaceCreateResponse, RepositoryApiError>;
#[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<WorkspaceDeletionPreflightResponse, RepositoryApiError>;
#[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<WorkspaceDeletionOperationResponse, RepositoryApiError>;
#[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<WorkspaceDeletionOperationResponse, RepositoryApiError>;
#[get( #[get(
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/session", "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/session",
status = 200, 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<RequestActor>,
pub origin: Option<String>,
}
#[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<u32>,
}
/// Public browser-authentication configuration. /// 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))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct AuthPublicConfig { pub struct AuthPublicConfig {
@@ -216,7 +417,7 @@ pub struct AuthPublicConfig {
} }
/// Authentication method that established the current request actor. /// 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))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum ActorAuthMethod { pub enum ActorAuthMethod {
@@ -225,7 +426,7 @@ pub enum ActorAuthMethod {
} }
/// Public user identity returned by authentication operations. /// 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))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct AuthenticatedUser { pub struct AuthenticatedUser {
@@ -236,7 +437,7 @@ pub struct AuthenticatedUser {
} }
/// Authenticated actor returned by `GET /api/auth/whoami`. /// 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))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct RequestActor { 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))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct WhoamiResponse { pub struct WhoamiResponse {
pub actor: Option<RequestActor>, pub actor: Option<RequestActor>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct AuthBootstrapUserRequest { pub struct AuthBootstrapUserRequest {
@@ -275,14 +476,14 @@ pub struct AuthBootstrapUserRequest {
pub display_name: Option<String>, pub display_name: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct AuthUserResponse { pub struct AuthUserResponse {
pub user: AuthenticatedUser, 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))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct PasskeyRegistrationOptionsRequest { pub struct PasskeyRegistrationOptionsRequest {
@@ -295,12 +496,13 @@ pub struct PasskeyRegistrationOptionsRequest {
pub browser_origin: Option<String>, pub browser_origin: Option<String>,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct PasskeyRegistrationOptionsResponse { pub struct PasskeyRegistrationOptionsResponse {
pub challenge_id: String, pub challenge_id: String,
#[cfg_attr(feature = "typescript", ts(type = "unknown"))] #[cfg_attr(feature = "typescript", ts(type = "unknown"))]
#[schemars(with = "serde_json::Value")]
pub public_key: CreationChallengeResponse, pub public_key: CreationChallengeResponse,
} }
@@ -313,7 +515,7 @@ pub struct PasskeyRegistrationCompleteRequest {
pub credential: RegisterPublicKeyCredential, 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))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct PasskeyLoginOptionsRequest { pub struct PasskeyLoginOptionsRequest {
@@ -325,12 +527,13 @@ pub struct PasskeyLoginOptionsRequest {
pub browser_origin: Option<String>, pub browser_origin: Option<String>,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct PasskeyLoginOptionsResponse { pub struct PasskeyLoginOptionsResponse {
pub challenge_id: String, pub challenge_id: String,
#[cfg_attr(feature = "typescript", ts(type = "unknown"))] #[cfg_attr(feature = "typescript", ts(type = "unknown"))]
#[schemars(with = "serde_json::Value")]
pub public_key: RequestChallengeResponse, pub public_key: RequestChallengeResponse,
} }
@@ -343,7 +546,7 @@ pub struct PasskeyLoginCompleteRequest {
pub credential: PublicKeyCredential, pub credential: PublicKeyCredential,
} }
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct DeviceLoginStartRequest { pub struct DeviceLoginStartRequest {
@@ -355,16 +558,19 @@ pub struct DeviceLoginStartRequest {
const DEVICE_LOGIN_EXPIRES_IN_MAX_SECONDS: u64 = 24 * 60 * 60; const DEVICE_LOGIN_EXPIRES_IN_MAX_SECONDS: u64 = 24 * 60 * 60;
const DEVICE_LOGIN_POLL_INTERVAL_MAX_SECONDS: u64 = 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))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct DeviceLoginStartResponse { pub struct DeviceLoginStartResponse {
pub device_code: String, pub device_code: String,
pub user_code: String, pub user_code: String,
pub verification_uri: String, pub verification_uri: String,
pub verification_uri_complete: String, pub verification_uri_complete: String,
#[cfg_attr(feature = "typescript", ts(type = "number"))] #[cfg_attr(feature = "typescript", ts(type = "number"))]
#[schemars(range(min = 1, max = 86400))]
pub expires_in: u64, pub expires_in: u64,
#[cfg_attr(feature = "typescript", ts(type = "number"))] #[cfg_attr(feature = "typescript", ts(type = "number"))]
#[schemars(range(min = 1, max = 60))]
pub interval: u64, 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))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct DeviceLoginApproveRequest { pub struct DeviceLoginApproveRequest {
pub user_code: String, 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))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum DeviceLoginApprovalStatus { pub enum DeviceLoginApprovalStatus {
Approved, Approved,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct DeviceLoginApproveResponse { pub struct DeviceLoginApproveResponse {
@@ -442,20 +648,20 @@ pub struct DeviceLoginApproveResponse {
pub user: AuthenticatedUser, pub user: AuthenticatedUser,
} }
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct DeviceLoginPollRequest { pub struct DeviceLoginPollRequest {
pub device_code: String, 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))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub enum DeviceAccessTokenType { pub enum DeviceAccessTokenType {
Bearer, 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))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum DeviceLoginPollStatus { pub enum DeviceLoginPollStatus {
@@ -466,7 +672,7 @@ pub enum DeviceLoginPollStatus {
Consumed, Consumed,
} }
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct DeviceLoginPollResponse { pub struct DeviceLoginPollResponse {
@@ -680,7 +886,7 @@ impl RepositoryObservedStatus {
} }
/// Public Workspace catalog item returned by `GET /api/workspaces`. /// 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))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct WorkspaceSummary { pub struct WorkspaceSummary {
@@ -696,12 +902,12 @@ pub struct WorkspaceSummary {
/// ///
/// The transparent newtype keeps the established top-level JSON array while making the /// The transparent newtype keeps the established top-level JSON array while making the
/// complete list response a named cross-crate and generated-TypeScript authority. /// 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))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct WorkspaceCatalogListResponse(pub Vec<WorkspaceSummary>); pub struct WorkspaceCatalogListResponse(pub Vec<WorkspaceSummary>);
/// Public Repository record embedded in Workspace creation responses. /// 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))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct WorkspaceRepositoryRecord { pub struct WorkspaceRepositoryRecord {
@@ -712,6 +918,7 @@ pub struct WorkspaceRepositoryRecord {
pub source: RepositorySource, pub source: RepositorySource,
pub default_ref: Option<String>, pub default_ref: Option<String>,
#[cfg_attr(feature = "typescript", ts(type = "number"))] #[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_revision: u64,
pub source_fingerprint: String, pub source_fingerprint: String,
pub observed_status: RepositoryObservedStatus, pub observed_status: RepositoryObservedStatus,
@@ -721,7 +928,7 @@ pub struct WorkspaceRepositoryRecord {
} }
/// Initial Repository registration intent for Workspace creation. /// 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)] #[serde(deny_unknown_fields)]
pub struct InitialRepositoryIntent { pub struct InitialRepositoryIntent {
pub repository_key: String, pub repository_key: String,
@@ -731,7 +938,7 @@ pub struct InitialRepositoryIntent {
} }
/// Request for atomically creating a Workspace and its initial Repository. /// 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)] #[serde(deny_unknown_fields)]
pub struct WorkspaceCreateRequest { pub struct WorkspaceCreateRequest {
pub operation_key: String, pub operation_key: String,
@@ -740,18 +947,25 @@ pub struct WorkspaceCreateRequest {
} }
/// Response returned after atomically creating a Workspace and its first Repository. /// 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))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct WorkspaceCreateResponse { pub struct WorkspaceCreateResponse {
pub workspace: WorkspaceSummary, pub workspace: WorkspaceSummary,
pub repository: WorkspaceRepositoryRecord, pub repository: WorkspaceRepositoryRecord,
#[cfg_attr(feature = "typescript", ts(type = "number"))] #[cfg_attr(feature = "typescript", ts(type = "number"))]
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub config_revision: u64, pub config_revision: u64,
pub request_fingerprint: String, pub request_fingerprint: String,
pub replayed: bool, 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. /// Browser authentication configuration exposed by the scoped Workspace summary.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
@@ -1016,7 +1230,7 @@ where
} }
/// Lifecycle state for one durable Workspace deletion operation. /// 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))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum WorkspaceDeletionState { pub enum WorkspaceDeletionState {
@@ -1028,7 +1242,7 @@ pub enum WorkspaceDeletionState {
} }
/// Stable category explaining why Workspace deletion cannot currently advance. /// 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))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum WorkspaceDeletionBlockerKind { pub enum WorkspaceDeletionBlockerKind {
@@ -1042,8 +1256,9 @@ pub enum WorkspaceDeletionBlockerKind {
} }
/// One bounded, user-actionable blocker returned by preflight or execution. /// 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))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceDeletionBlocker { pub struct WorkspaceDeletionBlocker {
pub kind: WorkspaceDeletionBlockerKind, pub kind: WorkspaceDeletionBlockerKind,
pub resource_kind: Option<String>, pub resource_kind: Option<String>,
@@ -1052,27 +1267,34 @@ pub struct WorkspaceDeletionBlocker {
} }
/// Workspace-owned resources summarized before destructive confirmation. /// 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))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct WorkspaceDeletionResourceCounts { pub struct WorkspaceDeletionResourceCounts {
#[cfg_attr(feature = "typescript", ts(type = "number"))] #[cfg_attr(feature = "typescript", ts(type = "number"))]
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub workers: u64, pub workers: u64,
#[cfg_attr(feature = "typescript", ts(type = "number"))] #[cfg_attr(feature = "typescript", ts(type = "number"))]
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub workdirs: u64, pub workdirs: u64,
#[cfg_attr(feature = "typescript", ts(type = "number"))] #[cfg_attr(feature = "typescript", ts(type = "number"))]
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub repositories: u64, pub repositories: u64,
#[cfg_attr(feature = "typescript", ts(type = "number"))] #[cfg_attr(feature = "typescript", ts(type = "number"))]
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub runtime_bindings: u64, pub runtime_bindings: u64,
#[cfg_attr(feature = "typescript", ts(type = "number"))] #[cfg_attr(feature = "typescript", ts(type = "number"))]
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub secrets: u64, pub secrets: u64,
#[cfg_attr(feature = "typescript", ts(type = "number"))] #[cfg_attr(feature = "typescript", ts(type = "number"))]
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub artifacts: u64, pub artifacts: u64,
} }
/// Owner-only impact preview for deleting one Workspace. /// 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))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceDeletionPreflightResponse { pub struct WorkspaceDeletionPreflightResponse {
pub workspace_id: String, pub workspace_id: String,
pub display_name: String, pub display_name: String,
@@ -1084,8 +1306,9 @@ pub struct WorkspaceDeletionPreflightResponse {
} }
/// Idempotent request to start or resume Workspace deletion. /// 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))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceDeletionRequest { pub struct WorkspaceDeletionRequest {
pub operation_id: String, pub operation_id: String,
pub expected_revision: String, pub expected_revision: String,
@@ -1093,8 +1316,9 @@ pub struct WorkspaceDeletionRequest {
} }
/// Durable deletion operation projection used by request responses and polling. /// 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))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceDeletionOperationResponse { pub struct WorkspaceDeletionOperationResponse {
pub operation_id: String, pub operation_id: String,
pub workspace_id: String, pub workspace_id: String,
@@ -1109,6 +1333,16 @@ pub struct WorkspaceDeletionOperationResponse {
pub completed_at: Option<String>, pub completed_at: Option<String>,
} }
impl api_macros::HttpSuccess for WorkspaceDeletionOperationResponse {
fn status_code(&self) -> u16 {
if self.state == WorkspaceDeletionState::Succeeded {
200
} else {
202
}
}
}
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct WorkspaceDeletionBlockerWire { struct WorkspaceDeletionBlockerWire {
@@ -3881,7 +4115,6 @@ mod tests {
#[test] #[test]
fn worker_session_contract_and_flattened_availability_are_stable() { fn worker_session_contract_and_flattened_availability_are_stable() {
let operations = ServerApiMetadata::OPERATIONS; let operations = ServerApiMetadata::OPERATIONS;
assert_eq!(operations.len(), 6);
let operation = operations let operation = operations
.iter() .iter()
.find(|operation| operation.operation_id == "worker_session") .find(|operation| operation.operation_id == "worker_session")
@@ -3904,6 +4137,69 @@ mod tests {
assert_eq!(value["subject"]["kind"], "runtime_worker"); 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] #[test]
fn worker_restore_state_round_trips_and_rejects_unknown_variants() { fn worker_restore_state_round_trips_and_rejects_unknown_variants() {
for (state, wire) in [ 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] #[test]
fn repository_operations_are_in_the_canonical_contract() { fn repository_operations_are_in_the_canonical_contract() {
let document = canonical_openapi_document().expect("canonical OpenAPI contract must build"); let document = canonical_openapi_document().expect("canonical OpenAPI contract must build");
+449 -238
View File
@@ -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)] #[derive(Clone)]
struct WorkerRemovalService { struct WorkerRemovalService {
workspace_id: String, workspace_id: String,
@@ -1990,173 +2034,6 @@ fn forbidden_server_response(message: &str) -> Response {
.into_response() .into_response()
} }
#[derive(Debug, Deserialize)]
struct WorkspaceListQuery {
limit: Option<usize>,
}
async fn server_health() -> Json<serde_json::Value> {
Json(serde_json::json!({ "status": "ok" }))
}
async fn list_server_workspaces(
State(api): State<WorkspaceServerApi>,
headers: HeaderMap,
Query(query): Query<WorkspaceListQuery>,
) -> 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<WorkspaceServerApi>,
headers: HeaderMap,
Json(request): Json<WorkspaceCreateRequest>,
) -> 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<WorkspaceServerApi>,
AxumPath(workspace_id): AxumPath<String>,
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<WorkspaceServerApi>,
AxumPath(workspace_id): AxumPath<String>,
headers: HeaderMap,
Json(request): Json<WorkspaceDeletionRequest>,
) -> 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<WorkspaceServerApi>,
AxumPath(operation_id): AxumPath<String>,
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 { fn workspace_summary(record: WorkspaceRecord) -> WorkspaceSummary {
WorkspaceSummary { WorkspaceSummary {
workspace_id: record.workspace_id, workspace_id: record.workspace_id,
@@ -2666,22 +2543,17 @@ pub async fn build_workspace_server_router(
let api = WorkspaceServerApi::new(template, store); let api = WorkspaceServerApi::new(template, store);
api.preload().await?; api.preload().await?;
api.recover_workspace_deletions().await?; api.recover_workspace_deletions().await?;
let contract_service = ServerApiContractService::Server(api.clone());
let catalog = Router::new() 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) .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 Ok(auth
.merge(catalog) .merge(catalog)
.layer(axum::middleware::from_fn_with_state( .layer(axum::middleware::from_fn_with_state(
@@ -3752,39 +3624,383 @@ fn configured_repository_from_record(record: RepositoryRecord) -> Result<Configu
fn build_server_auth_router(api: ServerAuthApi) -> Router { fn build_server_auth_router(api: ServerAuthApi) -> Router {
Router::new() 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( .route(
"/api/auth/passkeys/registration/complete", "/api/auth/passkeys/registration/complete",
post(post_passkey_registration_complete), post(post_passkey_registration_complete),
) )
.route(
"/api/auth/passkeys/login/options",
post(post_passkey_login_options),
)
.route( .route(
"/api/auth/passkeys/login/complete", "/api/auth/passkeys/login/complete",
post(post_passkey_login_complete), post(post_passkey_login_complete),
) )
.route("/api/auth/logout", post(post_auth_logout)) .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) .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::<RequestActor>().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<ServerApiContractService>,
mut request: Request,
next: Next,
) -> Response {
let actor = if let Some(actor) = request.extensions().get::<RequestActor>().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<server_api::HealthResponse, server_api::RepositoryApiError> {
Ok(server_api::HealthResponse {
status: "ok".to_string(),
})
}
async fn auth_config(
&self,
) -> std::result::Result<AuthPublicConfig, server_api::RepositoryApiError> {
Ok(auth_public_config(&self.auth_api().config))
}
async fn auth_bootstrap_user(
&self,
request: AuthBootstrapUserRequest,
) -> std::result::Result<AuthUserResponse, server_api::RepositoryApiError> {
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<PasskeyRegistrationOptionsResponse, server_api::RepositoryApiError>
{
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<PasskeyLoginOptionsResponse, server_api::RepositoryApiError> {
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<DeviceLoginStartResponse, server_api::RepositoryApiError> {
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<DeviceLoginApproveResponse, server_api::RepositoryApiError> {
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<DeviceLoginPollResponse, server_api::RepositoryApiError> {
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<WhoamiResponse, server_api::RepositoryApiError> {
Ok(WhoamiResponse {
actor: context.actor,
})
}
async fn workspace_catalog_list(
&self,
context: server_api::ServerRequestContext,
query: server_api::WorkspaceListQuery,
) -> std::result::Result<WorkspaceCatalogListResponse, server_api::RepositoryApiError> {
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<WorkspaceCreateResponse, server_api::RepositoryApiError> {
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<WorkspaceDeletionPreflightResponse, server_api::RepositoryApiError>
{
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<WorkspaceDeletionOperationResponse, server_api::RepositoryApiError>
{
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<WorkspaceDeletionOperationResponse, server_api::RepositoryApiError>
{
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( async fn worker_session(
&self, &self,
workspace_id: String, workspace_id: String,
@@ -3792,7 +4008,14 @@ impl server_api::ServerApi for WorkspaceApi {
worker_id: String, worker_id: String,
) -> std::result::Result<server_api::WorkspaceWorkerSessionResponse, server_api::ServerApiError> ) -> std::result::Result<server_api::WorkspaceWorkerSessionResponse, server_api::ServerApiError>
{ {
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( return Err(runtime_api::RuntimeApiError::new(
StatusCode::NOT_FOUND.as_u16(), StatusCode::NOT_FOUND.as_u16(),
"workspace_not_found", "workspace_not_found",
@@ -3800,7 +4023,7 @@ impl server_api::ServerApi for WorkspaceApi {
)); ));
} }
let worker = RuntimeWorkerRef::new(runtime_id.clone(), worker_id.clone()); let worker = RuntimeWorkerRef::new(runtime_id.clone(), worker_id.clone());
let observation = self let observation = api
.runtime .runtime
.worker_session(&workspace_id, &worker) .worker_session(&workspace_id, &worker)
.map_err(|_error| { .map_err(|_error| {
@@ -3824,10 +4047,8 @@ impl server_api::ServerApi for WorkspaceApi {
workspace_id: String, workspace_id: String,
) -> std::result::Result<server_api::RepositoryListResponse, server_api::RepositoryApiError> ) -> std::result::Result<server_api::RepositoryListResponse, server_api::RepositoryApiError>
{ {
scoped_list_repositories( let api = self.workspace_api()?.clone();
State(self.clone()), scoped_list_repositories(State(api), AxumPath(ScopedWorkspacePath { workspace_id }))
AxumPath(ScopedWorkspacePath { workspace_id }),
)
.await .await
.map(|Json(response)| response) .map(|Json(response)| response)
.map_err(ApiError::into_repository_api_error) .map_err(ApiError::into_repository_api_error)
@@ -3837,7 +4058,7 @@ impl server_api::ServerApi for WorkspaceApi {
&self, &self,
) -> std::result::Result<server_api::RepositoryListResponse, server_api::RepositoryApiError> ) -> std::result::Result<server_api::RepositoryListResponse, server_api::RepositoryApiError>
{ {
list_repositories(State(self.clone())) list_repositories(State(self.workspace_api()?.clone()))
.await .await
.map(|Json(response)| response) .map(|Json(response)| response)
.map_err(ApiError::into_repository_api_error) .map_err(ApiError::into_repository_api_error)
@@ -3849,8 +4070,9 @@ impl server_api::ServerApi for WorkspaceApi {
repository_key: String, repository_key: String,
) -> std::result::Result<server_api::RepositoryDetailResponse, server_api::RepositoryApiError> ) -> std::result::Result<server_api::RepositoryDetailResponse, server_api::RepositoryApiError>
{ {
let api = self.workspace_api()?.clone();
scoped_repository_detail( scoped_repository_detail(
State(self.clone()), State(api),
AxumPath(ScopedRepositoryPath { AxumPath(ScopedRepositoryPath {
workspace_id, workspace_id,
repository_key, repository_key,
@@ -3866,7 +4088,10 @@ impl server_api::ServerApi for WorkspaceApi {
repository_key: String, repository_key: String,
) -> std::result::Result<server_api::RepositoryDetailResponse, server_api::RepositoryApiError> ) -> std::result::Result<server_api::RepositoryDetailResponse, server_api::RepositoryApiError>
{ {
repository_detail(State(self.clone()), AxumPath(repository_key)) repository_detail(
State(self.workspace_api()?.clone()),
AxumPath(repository_key),
)
.await .await
.map(|Json(response)| response) .map(|Json(response)| response)
.map_err(ApiError::into_repository_api_error) .map_err(ApiError::into_repository_api_error)
@@ -3880,7 +4105,7 @@ impl server_api::ServerApi for WorkspaceApi {
) -> std::result::Result<CreateWorkspaceRepositoryResponse, server_api::RepositoryApiError> ) -> std::result::Result<CreateWorkspaceRepositoryResponse, server_api::RepositoryApiError>
{ {
scoped_create_repository( scoped_create_repository(
State(self.clone()), State(self.workspace_api()?.clone()),
AxumPath(ScopedWorkspacePath { workspace_id }), AxumPath(ScopedWorkspacePath { workspace_id }),
Extension(actor), Extension(actor),
Json(request), Json(request),
@@ -3892,7 +4117,9 @@ impl server_api::ServerApi for WorkspaceApi {
} }
fn build_inner_router(api: WorkspaceApi) -> Router { 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 = let scoped_ticket_relations_query_path =
format!("/api/w/{{workspace_id}}{TICKET_RELATIONS_QUERY_PATH}"); format!("/api/w/{{workspace_id}}{TICKET_RELATIONS_QUERY_PATH}");
let scoped_ticket_orchestration_plans_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", "/api/w/{workspace_id}/hosts/{host_id}/workers",
get(scoped_list_host_workers), get(scoped_list_host_workers),
) )
.merge(
server_api::ServerApiAxum::router(api.clone()).with_state::<WorkspaceApi>(()),
)
.fallback(get(static_or_spa_fallback)) .fallback(get(static_or_spa_fallback))
.with_state(api); .with_state(api)
.merge(generated_workspace_contract_router(contract_service));
auth.merge(workspace) auth.merge(workspace)
.layer(middleware::from_fn(log_failed_api_response)) .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 list_host_workers(State(api), AxumPath(path.host_id)).await
} }
async fn get_auth_config(State(api): State<ServerAuthApi>) -> ApiResult<Json<AuthPublicConfig>> {
Ok(Json(auth_public_config(&api.config)))
}
async fn post_auth_bootstrap_user( async fn post_auth_bootstrap_user(
State(api): State<ServerAuthApi>, State(api): State<ServerAuthApi>,
Json(request): Json<AuthBootstrapUserRequest>, Json(request): Json<AuthBootstrapUserRequest>,
@@ -15387,12 +15608,11 @@ async fn post_device_login_start(
.into()) .into())
} }
async fn post_device_login_approve( fn approve_device_login(
State(api): State<ServerAuthApi>, api: &ServerAuthApi,
headers: HeaderMap, actor: RequestActor,
Json(request): Json<DeviceLoginApproveRequest>, request: DeviceLoginApproveRequest,
) -> ApiResult<Json<DeviceLoginApproveResponse>> { ) -> ApiResult<DeviceLoginApproveResponse> {
let actor = require_actor(&api, &headers).await?;
let flow = api let flow = api
.store .store
.get_device_login_flow_by_user_code(&request.user_code.trim().to_ascii_uppercase())? .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()); .into());
} }
Ok(Json(DeviceLoginApproveResponse { Ok(DeviceLoginApproveResponse {
status: DeviceLoginApprovalStatus::Approved, status: DeviceLoginApprovalStatus::Approved,
user: actor.user(), user: actor.user(),
})) })
} }
async fn post_device_login_poll( async fn post_device_login_poll(
@@ -15488,15 +15708,6 @@ async fn post_device_login_poll(
})) }))
} }
async fn get_auth_whoami(
State(api): State<ServerAuthApi>,
headers: HeaderMap,
) -> ApiResult<Json<WhoamiResponse>> {
Ok(Json(WhoamiResponse {
actor: resolve_actor(&api, &headers).await?,
}))
}
async fn post_auth_logout( async fn post_auth_logout(
State(api): State<ServerAuthApi>, State(api): State<ServerAuthApi>,
headers: HeaderMap, headers: HeaderMap,
+1679 -1
View File
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit. // @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // 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 // 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 // Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check