feat: migrate auth and workspace catalog to ServerApi
This commit is contained in:
@@ -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<RepositoryApiError>),
|
||||
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<reqwest::Error> 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, 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(
|
||||
target: &BackendAuthTarget,
|
||||
client_name: Option<&str>,
|
||||
) -> Result<DeviceLoginStartResponse, BackendAuthClientError> {
|
||||
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<DeviceLoginPollResponse, BackendAuthClientError> {
|
||||
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<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)]
|
||||
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!({
|
||||
|
||||
@@ -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<server_api::RepositoryApiError>),
|
||||
ServerApi(server_api::client_support::ClientError<server_api::RepositoryApiError>),
|
||||
}
|
||||
|
||||
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<server_api::client_support::ClientError<server_api::RepositoryApiError
|
||||
fn from(
|
||||
error: server_api::client_support::ClientError<server_api::RepositoryApiError>,
|
||||
) -> Self {
|
||||
Self::RepositoryApi(error)
|
||||
Self::ServerApi(error)
|
||||
}
|
||||
}
|
||||
|
||||
fn repository_client_error(
|
||||
fn server_client_error(
|
||||
backend: &BackendApiClient,
|
||||
error: server_api::client_support::ClientError<server_api::RepositoryApiError>,
|
||||
) -> 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<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(
|
||||
target: &BackendWorkspaceCatalogTarget,
|
||||
) -> Result<Vec<BackendWorkspace>, 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::<WorkspaceCatalogListResponse>()?.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<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
|
||||
let response = client
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}"),
|
||||
)?
|
||||
.send()
|
||||
.await?;
|
||||
client.check_status(response.status())?;
|
||||
Ok(response.json::<WorkspaceCatalogListResponse>().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<CreateBackendWorkspaceResponse, BackendWorkspaceClientError> {
|
||||
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::<CreateBackendWorkspaceResponse>().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,
|
||||
|
||||
+399
-34
@@ -22,6 +22,7 @@ pub type ServerApiClientError = client_support::ClientError<ServerApiError>;
|
||||
|
||||
/// 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<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(
|
||||
"/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<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.
|
||||
#[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<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 AuthBootstrapUserRequest {
|
||||
@@ -275,14 +476,14 @@ pub struct AuthBootstrapUserRequest {
|
||||
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))]
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
#[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<WorkspaceSummary>);
|
||||
|
||||
/// 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<String>,
|
||||
#[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<String>,
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
@@ -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<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 {
|
||||
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<Configu
|
||||
|
||||
fn build_server_auth_router(api: ServerAuthApi) -> 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::<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(
|
||||
&self,
|
||||
workspace_id: String,
|
||||
@@ -3792,7 +4008,14 @@ impl server_api::ServerApi for WorkspaceApi {
|
||||
worker_id: String,
|
||||
) -> 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(
|
||||
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<server_api::RepositoryListResponse, server_api::RepositoryApiError>
|
||||
{
|
||||
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<server_api::RepositoryListResponse, server_api::RepositoryApiError>
|
||||
{
|
||||
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<server_api::RepositoryDetailResponse, server_api::RepositoryApiError>
|
||||
{
|
||||
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<server_api::RepositoryDetailResponse, server_api::RepositoryApiError>
|
||||
{
|
||||
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<CreateWorkspaceRepositoryResponse, server_api::RepositoryApiError>
|
||||
{
|
||||
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::<WorkspaceApi>(()),
|
||||
)
|
||||
.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<ServerAuthApi>) -> ApiResult<Json<AuthPublicConfig>> {
|
||||
Ok(Json(auth_public_config(&api.config)))
|
||||
}
|
||||
|
||||
async fn post_auth_bootstrap_user(
|
||||
State(api): State<ServerAuthApi>,
|
||||
Json(request): Json<AuthBootstrapUserRequest>,
|
||||
@@ -15387,12 +15608,11 @@ async fn post_device_login_start(
|
||||
.into())
|
||||
}
|
||||
|
||||
async fn post_device_login_approve(
|
||||
State(api): State<ServerAuthApi>,
|
||||
headers: HeaderMap,
|
||||
Json(request): Json<DeviceLoginApproveRequest>,
|
||||
) -> ApiResult<Json<DeviceLoginApproveResponse>> {
|
||||
let actor = require_actor(&api, &headers).await?;
|
||||
fn approve_device_login(
|
||||
api: &ServerAuthApi,
|
||||
actor: RequestActor,
|
||||
request: DeviceLoginApproveRequest,
|
||||
) -> ApiResult<DeviceLoginApproveResponse> {
|
||||
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<ServerAuthApi>,
|
||||
headers: HeaderMap,
|
||||
) -> ApiResult<Json<WhoamiResponse>> {
|
||||
Ok(Json(WhoamiResponse {
|
||||
actor: resolve_actor(&api, &headers).await?,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn post_auth_logout(
|
||||
State(api): State<ServerAuthApi>,
|
||||
headers: HeaderMap,
|
||||
|
||||
+1679
-1
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user