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
+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");