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 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!({
+49 -53
View File
@@ -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
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.
#[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");
+455 -244
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)]
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
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.
// 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