diff --git a/crates/client/src/workspace_product.rs b/crates/client/src/workspace_product.rs index 70b6a6af..bdc97f1f 100644 --- a/crates/client/src/workspace_product.rs +++ b/crates/client/src/workspace_product.rs @@ -12,8 +12,10 @@ use workspace_api::{ BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse, CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse, ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest, - ObjectiveStateRequest, ObjectiveSummary, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, - TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse, + ObjectiveStateRequest, ObjectiveSummary, PutRuntimeTrustKeyRequest, + RevokeRuntimeTrustKeyRequest, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, + TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail, + WorkspaceRuntimeResource, }; use crate::{BackendApiClient, BackendWorkspaceClientError}; @@ -241,6 +243,43 @@ impl BackendWorkspaceProductClient { ) } + pub fn list_runtimes( + &self, + ) -> Result, BackendWorkspaceClientError> { + self.get_json("/runtimes") + } + + pub fn runtime_detail( + &self, + runtime_id: &str, + ) -> Result { + self.get_json(&format!("/runtimes/{}", encode_path_segment(runtime_id))) + } + + pub fn put_runtime_trust_key( + &self, + runtime_id: &str, + request: &PutRuntimeTrustKeyRequest, + ) -> Result { + self.send_json( + Method::PUT, + &format!("/runtimes/{}/trust-key", encode_path_segment(runtime_id)), + Some(request), + ) + } + + pub fn revoke_runtime_trust_key( + &self, + runtime_id: &str, + request: &RevokeRuntimeTrustKeyRequest, + ) -> Result { + self.send_json( + Method::DELETE, + &format!("/runtimes/{}/trust-key", encode_path_segment(runtime_id)), + Some(request), + ) + } + pub fn memory_document(&self) -> Result { self.get_json("/memory") } diff --git a/crates/workspace-api/src/lib.rs b/crates/workspace-api/src/lib.rs index 8c8dd902..6cc62968 100644 --- a/crates/workspace-api/src/lib.rs +++ b/crates/workspace-api/src/lib.rs @@ -539,6 +539,7 @@ pub enum WorkspaceAuthConfig { pub struct WorkspacePermissionSummary { pub manage_repositories: bool, pub manage_secrets: bool, + pub manage_runtimes: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -1135,6 +1136,7 @@ pub struct ObjectiveLinkTicketRequest { } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum RuntimeSourceKind { EmbeddedWorkerRuntime, @@ -1142,6 +1144,7 @@ pub enum RuntimeSourceKind { } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum RuntimeSourceStatus { Active, @@ -1149,6 +1152,7 @@ pub enum RuntimeSourceStatus { } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum RuntimeIdentityAuthority { RuntimeRegistryProjection, @@ -1156,6 +1160,8 @@ pub enum RuntimeIdentityAuthority { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] pub struct RuntimeSourceSummary { pub kind: RuntimeSourceKind, pub status: RuntimeSourceStatus, @@ -1164,6 +1170,7 @@ pub struct RuntimeSourceSummary { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct RuntimeSummary { pub runtime_id: String, pub label: String, @@ -1180,6 +1187,8 @@ pub struct RuntimeSummary { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] pub struct RuntimeManagementSummary { pub built_in: bool, pub config_managed: bool, @@ -1189,12 +1198,119 @@ pub struct RuntimeManagementSummary { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct WorkspaceRuntimeResource { #[serde(flatten)] pub runtime: RuntimeSummary, pub management: RuntimeManagementSummary, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum RuntimeTrustKeyStatus { + Unconfigured, + Active, + Revoked, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct RuntimeTrustKeyState { + pub status: RuntimeTrustKeyStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub public_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fingerprint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(type = "number | null"))] + pub revision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub updated_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub revoked_at: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum RuntimeTrustAuditAction { + Created, + Replaced, + Reactivated, + Revoked, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct RuntimeTrustAuditEntry { + pub action: RuntimeTrustAuditAction, + pub actor_account_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub old_fingerprint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub new_fingerprint: Option, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub revision: u64, + pub at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceRuntimeDetail { + pub workspace_id: String, + pub runtime: WorkspaceRuntimeResource, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub endpoint: Option, + pub trust_key: RuntimeTrustKeyState, + #[serde(default)] + pub recent_audit: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PutRuntimeTrustKeyRequest { + pub public_key: String, + #[serde(default)] + #[cfg_attr(feature = "typescript", ts(type = "number | null"))] + pub expected_revision: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct RevokeRuntimeTrustKeyRequest { + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub expected_revision: u64, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum RuntimeTrustConflictKind { + StaleRevision, + FingerprintInUse, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct RuntimeTrustConflictResponse { + pub error: RuntimeTrustConflictKind, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub current_revision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_fingerprint: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct CreateRemoteRuntimeRequest { @@ -2394,6 +2510,22 @@ pub fn catalog_typescript() -> String { RepositoryListResponse::decl(&config), RepositoryDetailResponse::decl(&config), RepositoryLogResponse::decl(&config), + RuntimeSourceKind::decl(&config), + RuntimeSourceStatus::decl(&config), + RuntimeIdentityAuthority::decl(&config), + RuntimeSourceSummary::decl(&config), + RuntimeSummary::decl(&config), + RuntimeManagementSummary::decl(&config), + WorkspaceRuntimeResource::decl(&config), + RuntimeTrustKeyStatus::decl(&config), + RuntimeTrustKeyState::decl(&config), + RuntimeTrustAuditAction::decl(&config), + RuntimeTrustAuditEntry::decl(&config), + WorkspaceRuntimeDetail::decl(&config), + PutRuntimeTrustKeyRequest::decl(&config), + RevokeRuntimeTrustKeyRequest::decl(&config), + RuntimeTrustConflictKind::decl(&config), + RuntimeTrustConflictResponse::decl(&config), RuntimeConnectionTestStatus::decl(&config), RuntimeConnectionTestFailureKind::decl(&config), RuntimeConnectionTestResponse::decl(&config), @@ -3022,7 +3154,8 @@ mod tests { }}, "permissions": { "manage_repositories": true, - "manage_secrets": true + "manage_secrets": true, + "manage_runtimes": true }, "extension_points": { "store": "sqlite", @@ -3086,6 +3219,75 @@ mod tests { assert!(serde_json::from_value::(stale).is_err()); } + #[test] + fn runtime_detail_and_trust_mutations_are_closed_and_typed() { + let detail = serde_json::json!({ + "workspace_id": "workspace-test", + "runtime": { + "runtime_id": "runtime-test", + "label": "Runtime Test", + "kind": "remote_http", + "status": "active", + "source": { + "kind": "remote_http", + "status": "active", + "identity_authority": "runtime_registry_projection", + "note": "active" + }, + "host_ids": [], + "worker_creation_available": true, + "os": "linux", + "arch": "x86_64", + "diagnostics": [], + "management": { + "built_in": false, + "config_managed": true, + "removable": true, + "endpoint_configured": true, + "token_ref_configured": false + } + }, + "endpoint": "https://runtime.example", + "trust_key": { + "status": "active", + "public_key": "ssh-ed25519 AAAA runtime-test", + "fingerprint": "SHA256:test", + "revision": 2, + "created_at": "2026-09-01T12:00:00Z", + "updated_at": "2026-09-01T13:00:00Z" + }, + "recent_audit": [{ + "action": "replaced", + "actor_account_id": "account-owner", + "old_fingerprint": "SHA256:old", + "new_fingerprint": "SHA256:test", + "revision": 2, + "at": "2026-09-01T13:00:00Z" + }] + }); + let parsed: WorkspaceRuntimeDetail = serde_json::from_value(detail.clone()).unwrap(); + assert_eq!(serde_json::to_value(parsed).unwrap(), detail); + + let mut unknown = detail; + unknown["trust_key"]["private_key"] = serde_json::json!("forbidden"); + assert!(serde_json::from_value::(unknown).is_err()); + assert!( + serde_json::from_value::(serde_json::json!({ + "public_key": "key", + "expected_revision": 1, + "replace": true + })) + .is_err() + ); + assert!( + serde_json::from_value::(serde_json::json!({ + "expected_revision": 1, + "delete_runtime": true + })) + .is_err() + ); + } + #[test] fn runtime_connection_test_response_is_closed_and_typed() { let compatible = serde_json::json!({ diff --git a/crates/workspace-server/src/latest_schema.sql b/crates/workspace-server/src/latest_schema.sql index a7e5030f..e9f86a18 100644 --- a/crates/workspace-server/src/latest_schema.sql +++ b/crates/workspace-server/src/latest_schema.sql @@ -440,6 +440,7 @@ CREATE TABLE workspace_runtime_bindings ( base_url TEXT NOT NULL, public_key TEXT NOT NULL, public_key_fingerprint TEXT NOT NULL, + binding_revision INTEGER NOT NULL CHECK (binding_revision > 0), created_at TEXT NOT NULL, updated_at TEXT NOT NULL, revoked_at TEXT, @@ -447,6 +448,22 @@ CREATE TABLE workspace_runtime_bindings ( UNIQUE (workspace_id, public_key_fingerprint), FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT ); +CREATE TABLE workspace_runtime_binding_audit ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + actor_account_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('created', 'replaced', 'reactivated', 'revoked')), + old_fingerprint TEXT, + new_fingerprint TEXT, + binding_revision INTEGER NOT NULL CHECK (binding_revision > 0), + at TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, binding_revision), + FOREIGN KEY(workspace_id, runtime_id) + REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) ON DELETE RESTRICT, + FOREIGN KEY(actor_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT +); +CREATE INDEX idx_workspace_runtime_binding_audit_recent + ON workspace_runtime_binding_audit(workspace_id, runtime_id, binding_revision DESC); CREATE TABLE typed_ticket_artifacts ( workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, relative_path TEXT NOT NULL, content BLOB NOT NULL, PRIMARY KEY (workspace_id, ticket_id, relative_path), diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 7f1cc685..496750a1 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -120,6 +120,15 @@ pub enum Error { WorkspaceConfigConflict(String), #[error("Runtime binding conflict: {0}")] RuntimeBindingConflict(String), + #[error("Runtime binding revision conflict: expected {expected:?}, current {actual:?}")] + RuntimeBindingRevisionConflict { + expected: Option, + actual: Option, + }, + #[error("Runtime public key fingerprint is already bound in this Workspace: {fingerprint}")] + RuntimeBindingFingerprintConflict { fingerprint: String }, + #[error("Runtime binding was not found for {runtime_id}")] + RuntimeBindingNotFound { runtime_id: String }, #[error("Repository conflict: {0}")] RepositoryConflict(String), #[error("Registry inconsistency: {0}")] diff --git a/crates/workspace-server/src/main.rs b/crates/workspace-server/src/main.rs index b1e1d7f5..882d4436 100644 --- a/crates/workspace-server/src/main.rs +++ b/crates/workspace-server/src/main.rs @@ -324,6 +324,7 @@ fn run_trust_runtime_command(args: Vec) -> Result<(), Box, + runtime_binding_expectations: Arc>>, companion: Arc, orchestrator_spawn_lock: Arc>, orchestrator_attention_fingerprint: Arc>>, @@ -1575,6 +1578,7 @@ impl WorkspaceApi { base_url: "in-process://embedded".to_owned(), public_key: embedded_identity.public_key.clone(), public_key_fingerprint: String::new(), + binding_revision: 1, created_at: config.workspace_created_at.clone(), updated_at: config.workspace_created_at.clone(), revoked_at: None, @@ -1614,18 +1618,22 @@ impl WorkspaceApi { .then(|| (source.runtime_id.clone(), source.base_url.clone())) }) .collect::>(); - let expected_runtime_bindings = Arc::new( - store - .list_workspace_runtime_bindings(&config.workspace_id, false) - .await? - .into_iter() - .filter(|binding| binding.runtime_id != EMBEDDED_RUNTIME_ID) - .filter(|binding| { - configured_runtime_endpoints.get(&binding.runtime_id) == Some(&binding.base_url) - }) - .map(|binding| (binding.runtime_id.clone(), binding)) - .collect::>(), - ); + let expected_runtime_bindings = store + .list_workspace_runtime_bindings(&config.workspace_id, false) + .await? + .into_iter() + .filter(|binding| binding.runtime_id != EMBEDDED_RUNTIME_ID) + .filter(|binding| { + configured_runtime_endpoints.get(&binding.runtime_id) == Some(&binding.base_url) + }) + .map(|binding| { + ( + (binding.workspace_id.clone(), binding.runtime_id.clone()), + binding, + ) + }) + .collect::>(); + let workspace_id = config.workspace_id.clone(); let api = Self::new_with_execution_backend_and_broker( config, store, @@ -1634,15 +1642,34 @@ impl WorkspaceApi { Some(worker_remove_dispatcher), ) .await?; + *api.runtime_binding_expectations + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = expected_runtime_bindings; + let runtime_binding_expectations = Arc::clone(&api.runtime_binding_expectations); api.runtime.set_runtime_binding_gate(move |runtime_id| { - expected_runtime_bindings - .get(runtime_id) + runtime_binding_expectations + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(&(workspace_id.clone(), runtime_id.to_string())) .is_some_and(|expected| { runtime_binding_store .workspace_runtime_binding_matches(expected) .unwrap_or(false) }) }); + let active_expectations = api + .runtime_binding_expectations + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + for source in &api.config.remote_runtime_sources { + if !active_expectations + .contains_key(&(api.config.workspace_id.clone(), source.runtime_id.clone())) + { + api.runtime_subscription_broker + .unregister_runtime(&source.runtime_id); + } + } + drop(active_expectations); Ok(api) } @@ -1748,6 +1775,7 @@ impl WorkspaceApi { config, store, runtime, + runtime_binding_expectations: Arc::new(RwLock::new(HashMap::new())), companion, orchestrator_spawn_lock: Arc::new(std::sync::Mutex::new(())), orchestrator_attention_fingerprint: Arc::new(Mutex::new(None)), @@ -2648,7 +2676,11 @@ fn build_inner_router(api: WorkspaceApi) -> Router { ) .route( "/api/w/{workspace_id}/runtimes/{runtime_id}", - delete(scoped_delete_remote_runtime), + get(scoped_get_runtime_detail).delete(scoped_delete_remote_runtime), + ) + .route( + "/api/w/{workspace_id}/runtimes/{runtime_id}/trust-key", + put(scoped_put_runtime_trust_key).delete(scoped_revoke_runtime_trust_key), ) .route( "/api/w/{workspace_id}/runtimes/{runtime_id}/connection-tests", @@ -10956,11 +10988,225 @@ async fn scoped_create_remote_runtime( create_remote_runtime(State(api), Json(request)).await } +async fn scoped_get_runtime_detail( + State(api): State, + AxumPath(path): AxumPath, + Extension(actor): Extension, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let workspace = api + .store + .get_workspace(&path.workspace_id) + .await? + .ok_or(Error::WorkspaceIdMismatch)?; + let is_owner = workspace.owner_account_id == actor.account_id; + Ok(Json( + workspace_runtime_detail(&api, &path.workspace_id, &path.runtime_id, is_owner).await?, + )) +} + +async fn scoped_put_runtime_trust_key( + State(api): State, + AxumPath(path): AxumPath, + Extension(actor): Extension, + Json(request): Json, +) -> std::result::Result { + validate_workspace_scope(&api, &path.workspace_id)?; + require_workspace_owner(&api, &path.workspace_id, &actor, "Runtime trust changes").await?; + let actor_account_id = actor.account_id.clone(); + if path.runtime_id == EMBEDDED_WORKER_RUNTIME_ID { + return Err(settings_bad_request( + "embedded_runtime_trust_managed_internally", + "the embedded Runtime trust key is managed by Server identity authority", + )); + } + if request.expected_revision == Some(0) { + return Err(settings_bad_request( + "invalid_runtime_binding_revision", + "expected_revision must be greater than zero when provided", + )); + } + if request.public_key.len() > 16 * 1024 { + return Err(settings_bad_request( + "runtime_public_key_too_large", + "public_key must be at most 16384 bytes", + )); + } + let existing = api + .store + .get_workspace_runtime_binding(&path.workspace_id, &path.runtime_id) + .await?; + let source = api + .config + .remote_runtime_sources + .iter() + .find(|source| { + source.runtime_id == path.runtime_id + && source.workspace_id.as_deref() == Some(path.workspace_id.as_str()) + }) + .cloned(); + if let (Some(binding), Some(source)) = (&existing, &source) + && binding.base_url != source.base_url + { + return Err(settings_bad_request( + "runtime_endpoint_mismatch", + "the persisted Runtime endpoint no longer matches Server Runtime configuration; reconcile the endpoint before changing trust", + )); + } + let (display_name, base_url) = if let Some(binding) = &existing { + (binding.display_name.clone(), binding.base_url.clone()) + } else if let Some(source) = &source { + (source.display_name.clone(), source.base_url.clone()) + } else { + return Err(Error::UnknownRuntime(path.runtime_id.clone()).into()); + }; + let now = Utc::now().to_rfc3339(); + let record = WorkspaceRuntimeBinding { + workspace_id: path.workspace_id.clone(), + runtime_id: path.runtime_id.clone(), + display_name, + base_url, + public_key: request.public_key, + public_key_fingerprint: String::new(), + binding_revision: 1, + created_at: existing + .as_ref() + .map_or_else(|| now.clone(), |binding| binding.created_at.clone()), + updated_at: now, + revoked_at: None, + }; + let mutation = api + .store + .put_workspace_runtime_binding_key(record, request.expected_revision, &actor_account_id) + .await; + let (_, binding) = match mutation { + Ok(result) => result, + Err(error) => { + if let Some(response) = runtime_trust_conflict_response(&api, &path, &error).await { + return Ok(response); + } + return Err(error.into()); + } + }; + api.runtime_binding_expectations + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert( + (path.workspace_id.clone(), path.runtime_id.clone()), + binding, + ); + if let Some(source) = source { + api.runtime_subscription_broker + .register_remote_runtime(source); + } + Ok( + Json(workspace_runtime_detail(&api, &path.workspace_id, &path.runtime_id, true).await?) + .into_response(), + ) +} + +async fn scoped_revoke_runtime_trust_key( + State(api): State, + AxumPath(path): AxumPath, + Extension(actor): Extension, + Json(request): Json, +) -> std::result::Result { + validate_workspace_scope(&api, &path.workspace_id)?; + require_workspace_owner(&api, &path.workspace_id, &actor, "Runtime trust changes").await?; + let actor_account_id = actor.account_id.clone(); + if path.runtime_id == EMBEDDED_WORKER_RUNTIME_ID { + return Err(settings_bad_request( + "embedded_runtime_trust_managed_internally", + "the embedded Runtime trust key is managed by Server identity authority", + )); + } + if request.expected_revision == 0 { + return Err(settings_bad_request( + "invalid_runtime_binding_revision", + "expected_revision must be greater than zero", + )); + } + let now = Utc::now().to_rfc3339(); + let mutation = api + .store + .revoke_workspace_runtime_binding_key( + &path.workspace_id, + &path.runtime_id, + request.expected_revision, + &actor_account_id, + &now, + ) + .await; + let _ = match mutation { + Ok(result) => result, + Err(error) => { + if let Some(response) = runtime_trust_conflict_response(&api, &path, &error).await { + return Ok(response); + } + return Err(error.into()); + } + }; + api.runtime_binding_expectations + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&(path.workspace_id.clone(), path.runtime_id.clone())); + api.runtime_subscription_broker + .unregister_runtime(&path.runtime_id); + Ok( + Json(workspace_runtime_detail(&api, &path.workspace_id, &path.runtime_id, true).await?) + .into_response(), + ) +} + +async fn runtime_trust_conflict_response( + api: &WorkspaceApi, + path: &ScopedRuntimePath, + error: &Error, +) -> Option { + let kind = match error { + Error::RuntimeBindingRevisionConflict { .. } => RuntimeTrustConflictKind::StaleRevision, + Error::RuntimeBindingFingerprintConflict { .. } => { + RuntimeTrustConflictKind::FingerprintInUse + } + _ => return None, + }; + let current = api + .store + .get_workspace_runtime_binding(&path.workspace_id, &path.runtime_id) + .await + .ok() + .flatten(); + Some( + ( + StatusCode::CONFLICT, + Json(RuntimeTrustConflictResponse { + error: kind, + message: match kind { + RuntimeTrustConflictKind::StaleRevision => { + "the Runtime trust binding changed; reload before retrying".to_string() + } + RuntimeTrustConflictKind::FingerprintInUse => { + "the public key is already bound to another Runtime in this Workspace" + .to_string() + } + }, + current_revision: current.as_ref().map(|binding| binding.binding_revision), + current_fingerprint: current + .as_ref() + .map(|binding| binding.public_key_fingerprint.clone()), + }), + ) + .into_response(), + ) +} + async fn scoped_delete_remote_runtime( State(api): State, AxumPath(path): AxumPath, + Extension(actor): Extension, ) -> ApiResult { validate_workspace_scope(&api, &path.workspace_id)?; + require_workspace_owner(&api, &path.workspace_id, &actor, "Runtime removal").await?; delete_remote_runtime(State(api), AxumPath(path.runtime_id)).await } @@ -12211,6 +12457,7 @@ async fn get_workspace( permissions: WorkspacePermissionSummary { manage_repositories: is_owner, manage_secrets: is_owner, + manage_runtimes: is_owner, }, extension_points: WorkspaceExtensionPoints { store: "sqlite".to_string(), @@ -12451,7 +12698,7 @@ async fn create_remote_runtime( } Err(settings_bad_request( "runtime_public_key_required", - "remote Runtime registration requires an authenticated public key; use `yoi-server trust-runtime add` until the Workspace Runtime key API is available", + "remote Runtime registration requires an authenticated public key; configure it from the Runtime detail page after the Runtime endpoint is registered", )) } @@ -12469,8 +12716,13 @@ async fn delete_remote_runtime( .store .get_workspace_runtime_binding(&api.config.workspace_id, &runtime_id) .await? - .filter(|binding| binding.revoked_at.is_none()) .ok_or_else(|| Error::UnknownRuntime(runtime_id.clone()))?; + if binding.revoked_at.is_none() { + return Err(Error::RuntimeBindingConflict( + "runtime trust is still active; revoke this Workspace's trust key with an expected revision before removing the inactive registration".to_string(), + ) + .into()); + } match api .runtime .unregister_if_idle(&runtime_id, api.config.max_records.min(200)) @@ -12499,14 +12751,6 @@ async fn delete_remote_runtime( )); } } - let now = Utc::now().to_rfc3339(); - if !api - .store - .revoke_workspace_runtime_binding_record(&binding.workspace_id, &binding.runtime_id, &now) - .await? - { - return Err(Error::UnknownRuntime(runtime_id).into()); - } Ok(StatusCode::NO_CONTENT) } @@ -14488,6 +14732,133 @@ async fn workspace_runtime_resources_response( }) } +async fn workspace_runtime_detail( + api: &WorkspaceApi, + workspace_id: &str, + runtime_id: &str, + include_public_key: bool, +) -> ApiResult { + let binding = api + .store + .get_workspace_runtime_binding(workspace_id, runtime_id) + .await?; + let mut resource = workspace_runtime_resources_response(api, workspace_id) + .await? + .items + .into_iter() + .find(|resource| resource.runtime.runtime_id == runtime_id); + if resource.is_none() { + resource = binding.as_ref().map(|binding| WorkspaceRuntimeResource { + runtime: workspace_api::RuntimeSummary { + runtime_id: binding.runtime_id.clone(), + label: binding.display_name.clone(), + kind: "remote_http".to_string(), + status: "unavailable".to_string(), + source: workspace_api::RuntimeSourceSummary { + kind: workspace_api::RuntimeSourceKind::RemoteHttp, + status: workspace_api::RuntimeSourceStatus::Reserved, + identity_authority: + workspace_api::RuntimeIdentityAuthority::ServerRuntimeConfiguration, + note: "The Runtime trust binding is not active in the Runtime registry." + .to_string(), + }, + host_ids: Vec::new(), + worker_creation_available: false, + os: String::new(), + arch: String::new(), + diagnostics: Vec::new(), + }, + management: RuntimeManagementSummary { + built_in: false, + config_managed: true, + removable: false, + endpoint_configured: !binding.base_url.trim().is_empty(), + token_ref_configured: false, + }, + }); + } + let mut resource = resource.ok_or_else(|| Error::UnknownRuntime(runtime_id.to_string()))?; + if let Some(binding) = &binding { + resource.management.config_managed = true; + resource.management.endpoint_configured = !binding.base_url.trim().is_empty(); + } + let endpoint = binding + .as_ref() + .map(|binding| binding.base_url.clone()) + .or_else(|| { + api.config + .remote_runtime_sources + .iter() + .find(|source| { + source.runtime_id == runtime_id + && source.workspace_id.as_deref() == Some(workspace_id) + }) + .map(|source| source.base_url.clone()) + }); + let trust_key = binding.as_ref().map_or( + RuntimeTrustKeyState { + status: RuntimeTrustKeyStatus::Unconfigured, + public_key: None, + fingerprint: None, + revision: None, + created_at: None, + updated_at: None, + revoked_at: None, + }, + |binding| RuntimeTrustKeyState { + status: if binding.revoked_at.is_some() { + RuntimeTrustKeyStatus::Revoked + } else { + RuntimeTrustKeyStatus::Active + }, + public_key: include_public_key.then(|| binding.public_key.clone()), + fingerprint: Some(binding.public_key_fingerprint.clone()), + revision: Some(binding.binding_revision), + created_at: Some(binding.created_at.clone()), + updated_at: Some(binding.updated_at.clone()), + revoked_at: binding.revoked_at.clone(), + }, + ); + let recent_audit = api + .store + .list_workspace_runtime_binding_audit(workspace_id, runtime_id, 20) + .await? + .into_iter() + .map(project_runtime_trust_audit) + .collect::>>()?; + Ok(WorkspaceRuntimeDetail { + workspace_id: workspace_id.to_string(), + runtime: resource, + endpoint, + trust_key, + recent_audit, + }) +} + +fn project_runtime_trust_audit( + record: WorkspaceRuntimeBindingAuditRecord, +) -> Result { + let action = match record.action.as_str() { + "created" => RuntimeTrustAuditAction::Created, + "replaced" => RuntimeTrustAuditAction::Replaced, + "reactivated" => RuntimeTrustAuditAction::Reactivated, + "revoked" => RuntimeTrustAuditAction::Revoked, + other => { + return Err(Error::Store(format!( + "unsupported Runtime trust audit action {other}" + ))); + } + }; + Ok(RuntimeTrustAuditEntry { + action, + actor_account_id: record.actor_account_id, + old_fingerprint: record.old_fingerprint, + new_fingerprint: record.new_fingerprint, + revision: record.binding_revision, + at: record.at, + }) +} + fn validate_runtime_connection_request(request: &CreateRemoteRuntimeRequest) -> ApiResult<()> { validate_public_runtime_id(request.runtime_id.trim())?; let endpoint = request.endpoint.trim(); @@ -16040,6 +16411,8 @@ impl IntoResponse for ApiError { | Error::WorkdirAttachmentConflict(_) | Error::WorkspaceConfigConflict(_) | Error::RuntimeBindingConflict(_) + | Error::RuntimeBindingRevisionConflict { .. } + | Error::RuntimeBindingFingerprintConflict { .. } | Error::RepositoryConflict(_) => StatusCode::CONFLICT, Error::WorkerSourceIdentity(_) | Error::InvalidInput(_) => StatusCode::BAD_REQUEST, Error::InvalidRuntimeIdentifier { .. } | Error::ReservedWorkerName(_) => { @@ -16068,6 +16441,7 @@ impl IntoResponse for ApiError { | Error::UnknownRuntime(_) | Error::UnknownWorker { .. } | Error::UnknownRepository(_) + | Error::RuntimeBindingNotFound { .. } | Error::WorkspaceIdMismatch => StatusCode::NOT_FOUND, Error::RuntimeOperationFailed { code, .. } if code == "skill_not_found" => { StatusCode::NOT_FOUND @@ -16573,6 +16947,7 @@ mod tests { base_url: "https://runtime.test".to_owned(), public_key: identity.public_key.clone(), public_key_fingerprint: String::new(), + binding_revision: 1, created_at: "2026-01-01T00:00:00Z".to_owned(), updated_at: "2026-01-01T00:00:00Z".to_owned(), revoked_at: None, @@ -22068,6 +22443,166 @@ mod tests { assert_eq!(detail.provenance.id, "workspace:triage-errors"); } + #[tokio::test] + async fn runtime_trust_management_is_owner_only_revisioned_and_redacted() { + let temp = tempfile::tempdir().unwrap(); + let api = test_api(temp.path()).await; + let owner_account_id = format!("account-{TEST_WORKSPACE_ID}"); + let owner = RequestActor { + user_id: "owner-user".to_string(), + account_id: owner_account_id.clone(), + handle: "owner".to_string(), + display_name: "Owner".to_string(), + auth_method: ActorAuthMethod::BrowserSession, + }; + let non_owner = RequestActor { + user_id: "other-user".to_string(), + account_id: "other-account".to_string(), + handle: "other".to_string(), + display_name: "Other".to_string(), + auth_method: ActorAuthMethod::ApiToken, + }; + let first = worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-a").unwrap(); + let second = worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-a").unwrap(); + let third = worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-a").unwrap(); + let now = Utc::now().to_rfc3339(); + api.store + .put_workspace_runtime_binding_key( + WorkspaceRuntimeBinding { + workspace_id: TEST_WORKSPACE_ID.to_string(), + runtime_id: "runtime-a".to_string(), + display_name: "Runtime A".to_string(), + base_url: "https://runtime.example".to_string(), + public_key: first.public_key, + public_key_fingerprint: String::new(), + binding_revision: 1, + created_at: now.clone(), + updated_at: now, + revoked_at: None, + }, + None, + &owner_account_id, + ) + .await + .unwrap(); + + let Json(owner_detail) = scoped_get_runtime_detail( + State(api.clone()), + AxumPath(ScopedRuntimePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + runtime_id: "runtime-a".to_string(), + }), + Extension(owner.clone()), + ) + .await + .unwrap(); + assert!(owner_detail.trust_key.public_key.is_some()); + assert_eq!(owner_detail.trust_key.revision, Some(1)); + let Json(reader_detail) = scoped_get_runtime_detail( + State(api.clone()), + AxumPath(ScopedRuntimePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + runtime_id: "runtime-a".to_string(), + }), + Extension(non_owner.clone()), + ) + .await + .unwrap(); + assert!(reader_detail.trust_key.public_key.is_none()); + assert!(reader_detail.trust_key.fingerprint.is_some()); + + let response = scoped_put_runtime_trust_key( + State(api.clone()), + AxumPath(ScopedRuntimePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + runtime_id: "runtime-a".to_string(), + }), + Extension(owner.clone()), + Json(PutRuntimeTrustKeyRequest { + public_key: second.public_key, + expected_revision: Some(1), + }), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let detail: WorkspaceRuntimeDetail = serde_json::from_slice(&body).unwrap(); + assert_eq!(detail.trust_key.revision, Some(2)); + assert_eq!( + detail.recent_audit[0].action, + RuntimeTrustAuditAction::Replaced + ); + + let stale = scoped_put_runtime_trust_key( + State(api.clone()), + AxumPath(ScopedRuntimePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + runtime_id: "runtime-a".to_string(), + }), + Extension(owner.clone()), + Json(PutRuntimeTrustKeyRequest { + public_key: third.public_key, + expected_revision: Some(1), + }), + ) + .await + .unwrap(); + assert_eq!(stale.status(), StatusCode::CONFLICT); + let body = axum::body::to_bytes(stale.into_body(), usize::MAX) + .await + .unwrap(); + let conflict: RuntimeTrustConflictResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!(conflict.error, RuntimeTrustConflictKind::StaleRevision); + assert_eq!(conflict.current_revision, Some(2)); + + let denied = scoped_revoke_runtime_trust_key( + State(api.clone()), + AxumPath(ScopedRuntimePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + runtime_id: "runtime-a".to_string(), + }), + Extension(non_owner), + Json(RevokeRuntimeTrustKeyRequest { + expected_revision: 2, + }), + ) + .await + .unwrap_err(); + assert_eq!(denied.into_response().status(), StatusCode::FORBIDDEN); + + let revoked = scoped_revoke_runtime_trust_key( + State(api.clone()), + AxumPath(ScopedRuntimePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + runtime_id: "runtime-a".to_string(), + }), + Extension(owner), + Json(RevokeRuntimeTrustKeyRequest { + expected_revision: 2, + }), + ) + .await + .unwrap(); + assert_eq!(revoked.status(), StatusCode::OK); + let binding = api + .store + .get_workspace_runtime_binding(TEST_WORKSPACE_ID, "runtime-a") + .await + .unwrap() + .unwrap(); + assert_eq!(binding.binding_revision, 3); + assert!(binding.revoked_at.is_some()); + assert!( + !api.runtime_binding_expectations + .read() + .unwrap() + .contains_key(&(TEST_WORKSPACE_ID.to_string(), "runtime-a".to_string())) + ); + } + #[tokio::test] async fn repository_secret_management_is_owner_only() { let temp = tempfile::tempdir().unwrap(); @@ -22115,6 +22650,16 @@ mod tests { test_api_with_recording_backend(workspace_root).await.0 } + fn test_owner_actor() -> RequestActor { + RequestActor { + user_id: "owner-user".to_string(), + account_id: format!("account-{TEST_WORKSPACE_ID}"), + handle: "owner".to_string(), + display_name: "Owner".to_string(), + auth_method: ActorAuthMethod::BrowserSession, + } + } + fn test_repository_id(api: &WorkspaceApi) -> String { api.store .get_repository_by_key(TEST_WORKSPACE_ID, "test-repository") @@ -22602,6 +23147,7 @@ mod tests { base_url: "https://runtime.invalid".to_string(), public_key: identity.public_key.clone(), public_key_fingerprint: String::new(), + binding_revision: 1, created_at: "2026-08-11T00:00:00Z".to_string(), updated_at: "2026-08-11T00:00:00Z".to_string(), revoked_at: None, @@ -24088,6 +24634,7 @@ mod tests { .unwrap() .public_key, public_key_fingerprint: String::new(), + binding_revision: 1, created_at: "1".to_string(), updated_at: "1".to_string(), revoked_at: None, @@ -25205,6 +25752,7 @@ mod tests { base_url: "https://runtime.example.invalid".to_string(), public_key: identity.public_key, public_key_fingerprint: String::new(), + binding_revision: 1, created_at: "1".to_string(), updated_at: "1".to_string(), revoked_at: None, @@ -25222,7 +25770,7 @@ mod tests { ) .unwrap(), ); - let app = build_inner_router(api); + let app = build_inner_router(api.clone()).layer(Extension(test_owner_actor())); let runtimes_uri = format!("/api/w/{TEST_WORKSPACE_ID}/runtimes"); let initial = get_json(app.clone(), &runtimes_uri).await; @@ -25303,6 +25851,16 @@ mod tests { .expect("team runtime launch option"); assert_eq!(team_runtime["working_directory_required"], true); + api.store + .revoke_workspace_runtime_binding_key( + TEST_WORKSPACE_ID, + "team-runtime", + 1, + &format!("account-{TEST_WORKSPACE_ID}"), + &Utc::now().to_rfc3339(), + ) + .await + .unwrap(); let deleted = request_json( app.clone(), "DELETE", @@ -25354,6 +25912,7 @@ mod tests { .unwrap() .public_key, public_key_fingerprint: String::new(), + binding_revision: 1, created_at: "1".to_string(), updated_at: "1".to_string(), revoked_at: None, @@ -25370,7 +25929,17 @@ mod tests { ) .unwrap(), ); - let app = build_inner_router(api); + api.store + .revoke_workspace_runtime_binding_key( + TEST_WORKSPACE_ID, + "busy-runtime", + 1, + &format!("account-{TEST_WORKSPACE_ID}"), + &Utc::now().to_rfc3339(), + ) + .await + .unwrap(); + let app = build_inner_router(api).layer(Extension(test_owner_actor())); let workers = get_json(app.clone(), "/api/workers").await; assert!( workers["items"] diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index db52cae2..44de5aa5 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeSet, HashMap, HashSet}; +use std::collections::BTreeSet; use std::path::Path; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -15,9 +15,9 @@ use workspace_api::{RepositoryObservedStatus, RepositorySource}; use crate::{Error, Result}; -const PREVIOUS_SCHEMA_VERSION: i64 = 50; -const LATEST_SCHEMA_VERSION: i64 = 51; -const RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings"; +const PREVIOUS_SCHEMA_VERSION: i64 = 51; +const LATEST_SCHEMA_VERSION: i64 = 52; +const RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit"; const MIGRATIONS: &[Migration] = &[Migration { version: LATEST_SCHEMA_VERSION, @@ -106,6 +106,7 @@ pub struct WorkspaceRuntimeBinding { pub base_url: String, pub public_key: String, pub public_key_fingerprint: String, + pub binding_revision: u64, pub created_at: String, pub updated_at: String, pub revoked_at: Option, @@ -118,6 +119,27 @@ pub enum WorkspaceRuntimeBindingUpsert { Replaced, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WorkspaceRuntimeBindingMutation { + Created, + Unchanged, + Replaced, + Reactivated, + Revoked, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WorkspaceRuntimeBindingAuditRecord { + pub workspace_id: String, + pub runtime_id: String, + pub actor_account_id: String, + pub action: String, + pub old_fingerprint: Option, + pub new_fingerprint: Option, + pub binding_revision: u64, + pub at: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct AccountRecord { pub account_id: String, @@ -567,6 +589,26 @@ pub trait ControlPlaneStore: Send + Sync { runtime_id: &str, revoked_at: &str, ) -> Result; + async fn put_workspace_runtime_binding_key( + &self, + record: WorkspaceRuntimeBinding, + expected_revision: Option, + actor_account_id: &str, + ) -> Result<(WorkspaceRuntimeBindingMutation, WorkspaceRuntimeBinding)>; + async fn revoke_workspace_runtime_binding_key( + &self, + workspace_id: &str, + runtime_id: &str, + expected_revision: u64, + actor_account_id: &str, + revoked_at: &str, + ) -> Result<(WorkspaceRuntimeBindingMutation, WorkspaceRuntimeBinding)>; + async fn list_workspace_runtime_binding_audit( + &self, + workspace_id: &str, + runtime_id: &str, + limit: usize, + ) -> Result>; async fn consume_worker_mutation_source_jti( &self, workspace_id: &str, @@ -1379,13 +1421,13 @@ impl SqliteWorkspaceStore { self.with_conn(|conn| { let sql = if include_revoked { r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, created_at, updated_at, revoked_at + public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at FROM workspace_runtime_bindings WHERE workspace_id = ?1 ORDER BY runtime_id ASC"# } else { r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, created_at, updated_at, revoked_at + public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at FROM workspace_runtime_bindings WHERE workspace_id = ?1 AND revoked_at IS NULL ORDER BY runtime_id ASC"# @@ -1407,7 +1449,7 @@ impl SqliteWorkspaceStore { self.with_conn(|conn| { conn.query_row( r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, created_at, updated_at, revoked_at + public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at FROM workspace_runtime_bindings WHERE workspace_id = ?1 AND runtime_id = ?2"#, params![workspace_id, runtime_id], @@ -1433,7 +1475,7 @@ impl SqliteWorkspaceStore { let existing = tx .query_row( r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, created_at, updated_at, revoked_at + public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at FROM workspace_runtime_bindings WHERE workspace_id = ?1 AND runtime_id = ?2"#, params![record.workspace_id, record.runtime_id], @@ -1460,7 +1502,8 @@ impl SqliteWorkspaceStore { tx.execute( r#"UPDATE workspace_runtime_bindings SET display_name = ?3, base_url = ?4, public_key = ?5, - public_key_fingerprint = ?6, updated_at = ?7, revoked_at = ?8 + public_key_fingerprint = ?6, binding_revision = binding_revision + 1, + updated_at = ?7, revoked_at = ?8 WHERE workspace_id = ?1 AND runtime_id = ?2"#, params![ record.workspace_id, @@ -1480,8 +1523,8 @@ impl SqliteWorkspaceStore { tx.execute( r#"INSERT INTO workspace_runtime_bindings ( workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, created_at, updated_at, revoked_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)"#, + public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7, ?8, ?9)"#, params![ record.workspace_id, record.runtime_id, @@ -1512,13 +1555,311 @@ impl SqliteWorkspaceStore { self.with_conn(|conn| { let changed = conn.execute( r#"UPDATE workspace_runtime_bindings - SET revoked_at = ?3, updated_at = ?3 + SET revoked_at = ?3, updated_at = ?3, + binding_revision = binding_revision + 1 WHERE workspace_id = ?1 AND runtime_id = ?2 AND revoked_at IS NULL"#, params![workspace_id, runtime_id, revoked_at], )?; Ok(changed > 0) }) } + + pub fn put_workspace_runtime_binding_key( + &self, + mut record: WorkspaceRuntimeBinding, + expected_revision: Option, + actor_account_id: &str, + ) -> Result<(WorkspaceRuntimeBindingMutation, WorkspaceRuntimeBinding)> { + validate_identifier("workspace_id", &record.workspace_id)?; + validate_identifier("runtime_id", &record.runtime_id)?; + validate_identifier("actor_account_id", actor_account_id)?; + validate_non_empty("runtime display_name", &record.display_name)?; + validate_runtime_base_url(&record.base_url)?; + validate_non_empty("updated_at", &record.updated_at)?; + normalize_workspace_runtime_binding_key(&mut record)?; + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let existing = tx + .query_row( + r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, + public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at + FROM workspace_runtime_bindings + WHERE workspace_id = ?1 AND runtime_id = ?2"#, + params![record.workspace_id, record.runtime_id], + read_workspace_runtime_binding, + ) + .optional()?; + + if let Some(existing) = existing { + if existing.revoked_at.is_none() + && existing.public_key == record.public_key + && existing.public_key_fingerprint == record.public_key_fingerprint + { + tx.commit()?; + return Ok((WorkspaceRuntimeBindingMutation::Unchanged, existing)); + } + if expected_revision != Some(existing.binding_revision) { + return Err(Error::RuntimeBindingRevisionConflict { + expected: expected_revision, + actual: Some(existing.binding_revision), + }); + } + let fingerprint_owner = tx + .query_row( + r#"SELECT runtime_id FROM workspace_runtime_bindings + WHERE workspace_id = ?1 AND public_key_fingerprint = ?2 + AND runtime_id != ?3"#, + params![ + record.workspace_id, + record.public_key_fingerprint, + record.runtime_id + ], + |row| row.get::<_, String>(0), + ) + .optional()?; + if fingerprint_owner.is_some() { + return Err(Error::RuntimeBindingFingerprintConflict { + fingerprint: record.public_key_fingerprint, + }); + } + let action = if existing.revoked_at.is_some() { + WorkspaceRuntimeBindingMutation::Reactivated + } else { + WorkspaceRuntimeBindingMutation::Replaced + }; + let action_name = match action { + WorkspaceRuntimeBindingMutation::Reactivated => "reactivated", + WorkspaceRuntimeBindingMutation::Replaced => "replaced", + _ => unreachable!("action is selected above"), + }; + let next_revision = existing.binding_revision.checked_add(1).ok_or_else(|| { + Error::Store("Runtime binding revision overflow".to_string()) + })?; + tx.execute( + r#"UPDATE workspace_runtime_bindings + SET public_key = ?3, public_key_fingerprint = ?4, + binding_revision = ?5, updated_at = ?6, revoked_at = NULL + WHERE workspace_id = ?1 AND runtime_id = ?2"#, + params![ + record.workspace_id, + record.runtime_id, + record.public_key, + record.public_key_fingerprint, + next_revision, + record.updated_at, + ], + )?; + insert_workspace_runtime_binding_audit( + &tx, + &record.workspace_id, + &record.runtime_id, + actor_account_id, + action_name, + Some(&existing.public_key_fingerprint), + Some(&record.public_key_fingerprint), + next_revision, + &record.updated_at, + )?; + let updated = tx.query_row( + r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, + public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at + FROM workspace_runtime_bindings + WHERE workspace_id = ?1 AND runtime_id = ?2"#, + params![record.workspace_id, record.runtime_id], + read_workspace_runtime_binding, + )?; + tx.commit()?; + return Ok((action, updated)); + } + + if expected_revision.is_some() { + return Err(Error::RuntimeBindingRevisionConflict { + expected: expected_revision, + actual: None, + }); + } + let fingerprint_owner = tx + .query_row( + r#"SELECT runtime_id FROM workspace_runtime_bindings + WHERE workspace_id = ?1 AND public_key_fingerprint = ?2"#, + params![record.workspace_id, record.public_key_fingerprint], + |row| row.get::<_, String>(0), + ) + .optional()?; + if fingerprint_owner.is_some() { + return Err(Error::RuntimeBindingFingerprintConflict { + fingerprint: record.public_key_fingerprint, + }); + } + record.binding_revision = 1; + record.revoked_at = None; + tx.execute( + r#"INSERT INTO workspace_runtime_bindings ( + workspace_id, runtime_id, display_name, base_url, public_key, + public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7, ?8, NULL)"#, + params![ + record.workspace_id, + record.runtime_id, + record.display_name, + record.base_url, + record.public_key, + record.public_key_fingerprint, + record.created_at, + record.updated_at, + ], + )?; + insert_workspace_runtime_binding_audit( + &tx, + &record.workspace_id, + &record.runtime_id, + actor_account_id, + "created", + None, + Some(&record.public_key_fingerprint), + 1, + &record.updated_at, + )?; + tx.commit()?; + Ok((WorkspaceRuntimeBindingMutation::Created, record)) + }) + } + + pub fn revoke_workspace_runtime_binding_key( + &self, + workspace_id: &str, + runtime_id: &str, + expected_revision: u64, + actor_account_id: &str, + revoked_at: &str, + ) -> Result<(WorkspaceRuntimeBindingMutation, WorkspaceRuntimeBinding)> { + validate_identifier("workspace_id", workspace_id)?; + validate_identifier("runtime_id", runtime_id)?; + validate_identifier("actor_account_id", actor_account_id)?; + validate_non_empty("revoked_at", revoked_at)?; + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let existing = tx + .query_row( + r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, + public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at + FROM workspace_runtime_bindings + WHERE workspace_id = ?1 AND runtime_id = ?2"#, + params![workspace_id, runtime_id], + read_workspace_runtime_binding, + ) + .optional()? + .ok_or_else(|| Error::RuntimeBindingNotFound { + runtime_id: runtime_id.to_string(), + })?; + if existing.revoked_at.is_some() { + tx.commit()?; + return Ok((WorkspaceRuntimeBindingMutation::Unchanged, existing)); + } + if expected_revision != existing.binding_revision { + return Err(Error::RuntimeBindingRevisionConflict { + expected: Some(expected_revision), + actual: Some(existing.binding_revision), + }); + } + let next_revision = existing + .binding_revision + .checked_add(1) + .ok_or_else(|| Error::Store("Runtime binding revision overflow".to_string()))?; + tx.execute( + r#"UPDATE workspace_runtime_bindings + SET revoked_at = ?3, updated_at = ?3, binding_revision = ?4 + WHERE workspace_id = ?1 AND runtime_id = ?2"#, + params![workspace_id, runtime_id, revoked_at, next_revision], + )?; + insert_workspace_runtime_binding_audit( + &tx, + workspace_id, + runtime_id, + actor_account_id, + "revoked", + Some(&existing.public_key_fingerprint), + None, + next_revision, + revoked_at, + )?; + let updated = tx.query_row( + r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, + public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at + FROM workspace_runtime_bindings + WHERE workspace_id = ?1 AND runtime_id = ?2"#, + params![workspace_id, runtime_id], + read_workspace_runtime_binding, + )?; + tx.commit()?; + Ok((WorkspaceRuntimeBindingMutation::Revoked, updated)) + }) + } + + pub fn list_workspace_runtime_binding_audit( + &self, + workspace_id: &str, + runtime_id: &str, + limit: usize, + ) -> Result> { + validate_identifier("workspace_id", workspace_id)?; + validate_identifier("runtime_id", runtime_id)?; + let limit = limit.clamp(1, 50) as i64; + self.with_conn(|conn| { + let mut stmt = conn.prepare( + r#"SELECT workspace_id, runtime_id, actor_account_id, action, + old_fingerprint, new_fingerprint, binding_revision, at + FROM workspace_runtime_binding_audit + WHERE workspace_id = ?1 AND runtime_id = ?2 + ORDER BY binding_revision DESC + LIMIT ?3"#, + )?; + let rows = stmt.query_map(params![workspace_id, runtime_id, limit], |row| { + Ok(WorkspaceRuntimeBindingAuditRecord { + workspace_id: row.get(0)?, + runtime_id: row.get(1)?, + actor_account_id: row.get(2)?, + action: row.get(3)?, + old_fingerprint: row.get(4)?, + new_fingerprint: row.get(5)?, + binding_revision: row.get(6)?, + at: row.get(7)?, + }) + })?; + rows.collect::, _>>() + .map_err(Error::from) + }) + } +} + +fn insert_workspace_runtime_binding_audit( + tx: &rusqlite::Transaction<'_>, + workspace_id: &str, + runtime_id: &str, + actor_account_id: &str, + action: &str, + old_fingerprint: Option<&str>, + new_fingerprint: Option<&str>, + binding_revision: u64, + at: &str, +) -> Result<()> { + tx.execute( + r#"INSERT INTO workspace_runtime_binding_audit ( + workspace_id, runtime_id, actor_account_id, action, + old_fingerprint, new_fingerprint, binding_revision, at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)"#, + params![ + workspace_id, + runtime_id, + actor_account_id, + action, + old_fingerprint, + new_fingerprint, + binding_revision, + at, + ], + )?; + Ok(()) } #[async_trait] @@ -1887,6 +2228,52 @@ impl ControlPlaneStore for SqliteWorkspaceStore { ) } + async fn put_workspace_runtime_binding_key( + &self, + record: WorkspaceRuntimeBinding, + expected_revision: Option, + actor_account_id: &str, + ) -> Result<(WorkspaceRuntimeBindingMutation, WorkspaceRuntimeBinding)> { + SqliteWorkspaceStore::put_workspace_runtime_binding_key( + self, + record, + expected_revision, + actor_account_id, + ) + } + + async fn revoke_workspace_runtime_binding_key( + &self, + workspace_id: &str, + runtime_id: &str, + expected_revision: u64, + actor_account_id: &str, + revoked_at: &str, + ) -> Result<(WorkspaceRuntimeBindingMutation, WorkspaceRuntimeBinding)> { + SqliteWorkspaceStore::revoke_workspace_runtime_binding_key( + self, + workspace_id, + runtime_id, + expected_revision, + actor_account_id, + revoked_at, + ) + } + + async fn list_workspace_runtime_binding_audit( + &self, + workspace_id: &str, + runtime_id: &str, + limit: usize, + ) -> Result> { + SqliteWorkspaceStore::list_workspace_runtime_binding_audit( + self, + workspace_id, + runtime_id, + limit, + ) + } + async fn consume_worker_mutation_source_jti( &self, workspace_id: &str, @@ -5264,9 +5651,10 @@ fn read_workspace_runtime_binding( base_url: row.get(3)?, public_key: row.get(4)?, public_key_fingerprint: row.get(5)?, - created_at: row.get(6)?, - updated_at: row.get(7)?, - revoked_at: row.get(8)?, + binding_revision: row.get(6)?, + created_at: row.get(7)?, + updated_at: row.get(8)?, + revoked_at: row.get(9)?, }) } @@ -6018,234 +6406,42 @@ CREATE TABLE IF NOT EXISTS __yoi_schema_migrations ( Ok(()) } -fn migrate_workspace_runtime_bindings_v50_to_v51(conn: &Connection) -> Result<()> { - migrate_workspace_runtime_bindings_v50_to_v51_with_verifier( - conn, - verify_workspace_runtime_binding_schema, - ) -} - -fn migrate_workspace_runtime_bindings_v50_to_v51_with_verifier( - conn: &Connection, - verify: F, -) -> Result<()> -where - F: FnOnce(&Connection) -> Result<()>, -{ - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - let legacy_columns = table_columns(&tx, "trusted_runtime_records")? - .into_iter() - .collect::>(); - let expected_columns = [ - "runtime_id", - "display_name", - "base_url", - "public_key", - "created_at", - "updated_at", - "revoked_at", - "workspace_id", - ] - .into_iter() - .map(str::to_string) - .collect::>(); - if legacy_columns != expected_columns { +fn migrate_workspace_runtime_bindings_v51_to_v52(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if current != PREVIOUS_SCHEMA_VERSION { return Err(Error::Store(format!( - "schema-{PREVIOUS_SCHEMA_VERSION} trusted_runtime_records columns are not canonical" + "expected schema version {PREVIOUS_SCHEMA_VERSION} before {RUNTIME_BINDINGS_MIGRATION_NAME} migration, found {current}" ))); } - let mut bindings = Vec::new(); - { - let mut stmt = tx.prepare( - r#"SELECT runtime_id, workspace_id, display_name, base_url, public_key, - created_at, updated_at, revoked_at - FROM trusted_runtime_records ORDER BY runtime_id"#, - )?; - let rows = stmt.query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, Option>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - row.get::<_, String>(4)?, - row.get::<_, String>(5)?, - row.get::<_, String>(6)?, - row.get::<_, Option>(7)?, - )) - })?; - for row in rows { - let ( - runtime_id, - workspace_id, - display_name, - base_url, - public_key, - created_at, - updated_at, - revoked_at, - ) = row?; - let workspace_id = workspace_id.filter(|value| !value.trim().is_empty()).ok_or_else(|| { - Error::Store(format!( - "Runtime `{runtime_id}` has no persisted Workspace ownership; refusing to guess during schema-{PREVIOUS_SCHEMA_VERSION} migration" - )) - })?; - let workspace_exists = tx.query_row( - "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)", - params![workspace_id], - |row| row.get::<_, i64>(0), - )? != 0; - if !workspace_exists { - return Err(Error::Store(format!( - "Runtime `{runtime_id}` references unknown Workspace `{workspace_id}`" - ))); - } - let (public_key, fingerprint) = normalize_runtime_public_key(&public_key)?; - bindings.push(WorkspaceRuntimeBinding { - workspace_id, - runtime_id, - display_name, - base_url, - public_key, - public_key_fingerprint: fingerprint, - created_at, - updated_at, - revoked_at, - }); - } - } - - let mut binding_keys = HashSet::new(); - let mut trust_keys = HashSet::new(); - for binding in &bindings { - if !binding_keys.insert((binding.workspace_id.clone(), binding.runtime_id.clone())) { - return Err(Error::Store(format!( - "duplicate Runtime binding `{}/{}` in schema-{PREVIOUS_SCHEMA_VERSION}", - binding.workspace_id, binding.runtime_id - ))); - } - let fingerprint = binding.public_key_fingerprint.clone(); - if !trust_keys.insert((binding.workspace_id.clone(), fingerprint.clone())) { - return Err(Error::Store(format!( - "duplicate Runtime trust fingerprint `{fingerprint}` in Workspace `{}`", - binding.workspace_id - ))); - } - } - - let mut consumed_jtis = Vec::new(); - { - let runtime_workspaces = bindings - .iter() - .map(|binding| (binding.runtime_id.as_str(), binding.workspace_id.as_str())) - .collect::>(); - let mut stmt = tx.prepare( - "SELECT runtime_id, jti, expires_at, consumed_at FROM worker_mutation_source_proof_jtis", - )?; - let rows = stmt.query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, i64>(2)?, - row.get::<_, String>(3)?, - )) - })?; - for row in rows { - let (runtime_id, jti, expires_at, consumed_at) = row?; - let workspace_id = runtime_workspaces.get(runtime_id.as_str()).ok_or_else(|| { - Error::Store(format!( - "consumed Worker mutation proof for Runtime `{runtime_id}` has no provable Workspace binding" - )) - })?; - consumed_jtis.push(( - (*workspace_id).to_string(), - runtime_id, - jti, - expires_at, - consumed_at, - )); - } - } - + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; tx.execute_batch( r#" - ALTER TABLE worker_mutation_source_proof_jtis - RENAME TO worker_mutation_source_proof_jtis_v50; - ALTER TABLE trusted_runtime_records - RENAME TO trusted_runtime_records_v50; - - CREATE TABLE workspace_runtime_bindings ( + ALTER TABLE workspace_runtime_bindings + ADD COLUMN binding_revision INTEGER NOT NULL DEFAULT 1 CHECK (binding_revision > 0); + CREATE TABLE workspace_runtime_binding_audit ( workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, - display_name TEXT NOT NULL, - base_url TEXT NOT NULL, - public_key TEXT NOT NULL, - public_key_fingerprint TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - revoked_at TEXT, - PRIMARY KEY (workspace_id, runtime_id), - UNIQUE (workspace_id, public_key_fingerprint), - FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT - ); - CREATE INDEX idx_workspace_runtime_bindings_workspace - ON workspace_runtime_bindings(workspace_id, revoked_at, runtime_id); - CREATE TABLE worker_mutation_source_proof_jtis ( - workspace_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - jti TEXT NOT NULL, - expires_at INTEGER NOT NULL, - consumed_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, runtime_id, jti) + actor_account_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('created', 'replaced', 'reactivated', 'revoked')), + old_fingerprint TEXT, + new_fingerprint TEXT, + binding_revision INTEGER NOT NULL CHECK (binding_revision > 0), + at TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, binding_revision), + FOREIGN KEY(workspace_id, runtime_id) + REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) ON DELETE RESTRICT, + FOREIGN KEY(actor_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT ); + CREATE INDEX idx_workspace_runtime_binding_audit_recent + ON workspace_runtime_binding_audit(workspace_id, runtime_id, binding_revision DESC); "#, )?; - for binding in bindings { - tx.execute( - r#"INSERT INTO workspace_runtime_bindings ( - workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, created_at, updated_at, revoked_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)"#, - params![ - binding.workspace_id, - binding.runtime_id, - binding.display_name, - binding.base_url, - binding.public_key, - binding.public_key_fingerprint, - binding.created_at, - binding.updated_at, - binding.revoked_at, - ], - )?; - } - for (workspace_id, runtime_id, jti, expires_at, consumed_at) in consumed_jtis { - tx.execute( - "INSERT INTO worker_mutation_source_proof_jtis ( - workspace_id, runtime_id, jti, expires_at, consumed_at - ) VALUES (?1, ?2, ?3, ?4, ?5)", - params![workspace_id, runtime_id, jti, expires_at, consumed_at], - )?; - } - tx.execute_batch( - "DROP TABLE worker_mutation_source_proof_jtis_v50; - DROP TABLE trusted_runtime_records_v50;", - )?; - let foreign_key_failures = - tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { - row.get::<_, i64>(0) - })?; - if foreign_key_failures != 0 { - return Err(Error::Store(format!( - "schema-{LATEST_SCHEMA_VERSION} migration produced {foreign_key_failures} foreign-key violation(s)" - ))); - } - verify(&tx)?; + verify_workspace_runtime_binding_schema(&tx)?; tx.execute( "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", params![LATEST_SCHEMA_VERSION, RUNTIME_BINDINGS_MIGRATION_NAME], )?; - verify_current_schema_history(&tx)?; tx.commit()?; Ok(()) } @@ -6261,6 +6457,7 @@ fn verify_workspace_runtime_binding_schema(conn: &Connection) -> Result<()> { "base_url", "public_key", "public_key_fingerprint", + "binding_revision", "created_at", "updated_at", "revoked_at", @@ -6270,7 +6467,7 @@ fn verify_workspace_runtime_binding_schema(conn: &Connection) -> Result<()> { .collect::>(); if columns != expected { return Err(Error::Store( - "workspace_runtime_bindings schema does not match schema-51".to_string(), + "workspace_runtime_bindings schema does not match schema-52".to_string(), )); } let sql = conn.query_row( @@ -6296,6 +6493,37 @@ fn verify_workspace_runtime_binding_schema(conn: &Connection) -> Result<()> { "workspace_runtime_bindings is missing its Workspace lookup index".to_string(), )); } + let audit_columns = table_columns(conn, "workspace_runtime_binding_audit")? + .into_iter() + .collect::>(); + let expected_audit_columns = [ + "workspace_id", + "runtime_id", + "actor_account_id", + "action", + "old_fingerprint", + "new_fingerprint", + "binding_revision", + "at", + ] + .into_iter() + .map(str::to_string) + .collect::>(); + if audit_columns != expected_audit_columns { + return Err(Error::Store( + "workspace_runtime_binding_audit schema does not match schema-52".to_string(), + )); + } + let audit_index_exists = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = 'idx_workspace_runtime_binding_audit_recent')", + [], + |row| row.get::<_, i64>(0), + )? != 0; + if !audit_index_exists { + return Err(Error::Store( + "workspace_runtime_binding_audit recent index is missing".to_string(), + )); + } let jti_sql = conn.query_row( "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'worker_mutation_source_proof_jtis'", [], @@ -6309,7 +6537,7 @@ fn verify_workspace_runtime_binding_schema(conn: &Connection) -> Result<()> { } let mut stmt = conn.prepare( r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, created_at, updated_at, revoked_at + public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at FROM workspace_runtime_bindings"#, )?; let rows = stmt.query_map([], read_workspace_runtime_binding)?; @@ -6842,7 +7070,7 @@ fn apply_migrations(conn: &Connection) -> Result<()> { } PREVIOUS_SCHEMA_VERSION => { verify_previous_schema_history(conn)?; - migrate_workspace_runtime_bindings_v50_to_v51(conn)?; + migrate_workspace_runtime_bindings_v51_to_v52(conn)?; verify_current_schema_history(conn)?; verify_workspace_runtime_binding_schema(conn) } @@ -6917,7 +7145,7 @@ mod tests { .unwrap(); } - fn prepare_schema_v50(path: &Path, workspace_id: Option<&str>) { + fn prepare_schema_v51(path: &Path) { let conn = Connection::open(path).unwrap(); configure_sqlite(&conn).unwrap(); ticket::migrate_sqlite_ticket_schema(&conn).unwrap(); @@ -6925,28 +7153,12 @@ mod tests { create_latest_workspace_schema(&conn).unwrap(); conn.execute_batch( r#" - DROP TABLE worker_mutation_source_proof_jtis; - DROP TABLE workspace_runtime_bindings; - CREATE TABLE trusted_runtime_records ( - runtime_id TEXT PRIMARY KEY, - display_name TEXT NOT NULL, - base_url TEXT NOT NULL, - public_key TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - revoked_at TEXT, - workspace_id TEXT REFERENCES workspaces(workspace_id) ON DELETE RESTRICT - ); - CREATE TABLE worker_mutation_source_proof_jtis ( - runtime_id TEXT NOT NULL, - jti TEXT NOT NULL, - expires_at INTEGER NOT NULL, - consumed_at TEXT NOT NULL, - PRIMARY KEY (runtime_id, jti) - ); + DROP INDEX idx_workspace_runtime_binding_audit_recent; + DROP TABLE workspace_runtime_binding_audit; + ALTER TABLE workspace_runtime_bindings DROP COLUMN binding_revision; DELETE FROM __yoi_schema_migrations; INSERT INTO __yoi_schema_migrations(version, name) - VALUES (50, 'workspace schema baseline'); + VALUES (51, 'workspace schema baseline'); INSERT INTO accounts(account_id, kind, handle, display_name, created_at, updated_at) VALUES ('owner', 'user', 'owner', 'Owner', '1', '1'); INSERT INTO workspaces( @@ -6957,75 +7169,44 @@ mod tests { .unwrap(); let identity = worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-a").unwrap(); + let (_, fingerprint) = normalize_runtime_public_key(&identity.public_key).unwrap(); conn.execute( - r#"INSERT INTO trusted_runtime_records( - runtime_id, workspace_id, display_name, base_url, public_key, - created_at, updated_at, revoked_at - ) VALUES ('shared', ?1, 'Shared', 'https://runtime.test', ?2, '1', '1', NULL)"#, - params![workspace_id, identity.public_key], - ) - .unwrap(); - conn.execute( - r#"INSERT INTO worker_mutation_source_proof_jtis( - runtime_id, jti, expires_at, consumed_at - ) VALUES ('shared', 'jti-1', 10, '1')"#, - [], + r#"INSERT INTO workspace_runtime_bindings( + workspace_id, runtime_id, display_name, base_url, public_key, + public_key_fingerprint, created_at, updated_at, revoked_at + ) VALUES ('workspace-a', 'runtime-a', 'Runtime A', 'https://runtime.test', + ?1, ?2, '1', '1', NULL)"#, + params![identity.public_key, fingerprint], ) .unwrap(); } #[test] - fn schema_v50_runtime_trust_migrates_to_workspace_binding_atomically() { + fn schema_v51_runtime_binding_migrates_with_revision_and_empty_audit() { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("server.db"); - prepare_schema_v50(&path, Some("workspace-a")); - Connection::open(&path) - .unwrap() - .execute( - "UPDATE trusted_runtime_records SET revoked_at = '2' WHERE runtime_id = 'shared'", - [], - ) - .unwrap(); + prepare_schema_v51(&path); let store = SqliteWorkspaceStore::open(&path).unwrap(); let binding = store - .get_workspace_runtime_binding("workspace-a", "shared") + .get_workspace_runtime_binding("workspace-a", "runtime-a") .unwrap() .unwrap(); - assert_eq!(binding.revoked_at.as_deref(), Some("2")); - assert!(!binding.public_key.is_empty()); - assert!(binding.public_key_fingerprint.starts_with("sha256:")); + assert_eq!(binding.binding_revision, 1); + assert!( + store + .list_workspace_runtime_binding_audit("workspace-a", "runtime-a", 50) + .unwrap() + .is_empty() + ); store .with_conn(|conn| { - let jti_workspace: String = conn.query_row( - "SELECT workspace_id FROM worker_mutation_source_proof_jtis WHERE runtime_id = 'shared'", - [], - |row| row.get(0), - )?; - assert_eq!(jti_workspace, "workspace-a"); - let workspace_foreign_keys: i64 = conn.query_row( - "SELECT COUNT(*) FROM pragma_foreign_key_list('workspace_runtime_bindings') WHERE \"table\" = 'workspaces' AND \"from\" = 'workspace_id'", - [], - |row| row.get(0), - )?; - assert_eq!(workspace_foreign_keys, 1); - let unique_indexes: i64 = conn.query_row( - "SELECT COUNT(*) FROM pragma_index_list('workspace_runtime_bindings') WHERE \"unique\" = 1", - [], - |row| row.get(0), - )?; - assert!(unique_indexes >= 2); - let lookup_index: i64 = conn.query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'idx_workspace_runtime_bindings_workspace'", - [], - |row| row.get(0), - )?; - assert_eq!(lookup_index, 1); - let violations: i64 = conn.query_row( - "SELECT COUNT(*) FROM pragma_foreign_key_check", - [], - |row| row.get(0), - )?; + let version = current_schema_version(conn)?; + assert_eq!(version, LATEST_SCHEMA_VERSION); + let violations: i64 = + conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get(0) + })?; assert_eq!(violations, 0); Ok(()) }) @@ -7033,82 +7214,29 @@ mod tests { } #[test] - fn schema_v50_runtime_without_workspace_fails_without_partial_mutation() { + fn schema_v51_runtime_binding_migration_rolls_back_all_changes_on_failure() { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("server.db"); - prepare_schema_v50(&path, None); - - let error = match SqliteWorkspaceStore::open(&path) { - Ok(_) => panic!("missing Workspace ownership must fail migration"), - Err(error) => error, - }; - assert!(error.to_string().contains("refusing to guess"), "{error}"); + prepare_schema_v51(&path); let conn = Connection::open(&path).unwrap(); - let version: i64 = conn - .query_row( - "SELECT MAX(version) FROM __yoi_schema_migrations", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(version, PREVIOUS_SCHEMA_VERSION); - assert!( - !table_columns(&conn, "trusted_runtime_records") - .unwrap() - .is_empty() - ); - assert!( - table_columns(&conn, "workspace_runtime_bindings") - .unwrap() - .is_empty() - ); - } + conn.execute_batch( + "CREATE TABLE workspace_runtime_binding_audit (unexpected TEXT NOT NULL);", + ) + .unwrap(); + drop(conn); - #[test] - fn schema_v50_runtime_migration_rolls_back_when_final_verification_fails() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("server.db"); - prepare_schema_v50(&path, Some("workspace-a")); + let error = SqliteWorkspaceStore::open(&path) + .err() + .expect("migration must fail") + .to_string(); + assert!(error.contains("already exists"), "{error}"); let conn = Connection::open(&path).unwrap(); - configure_sqlite(&conn).unwrap(); - - let error = migrate_workspace_runtime_bindings_v50_to_v51_with_verifier(&conn, |_| { - Err(Error::Store( - "forced final verification failure".to_string(), - )) - }) - .unwrap_err(); assert!( - error - .to_string() - .contains("forced final verification failure") - ); - let version: i64 = conn - .query_row( - "SELECT MAX(version) FROM __yoi_schema_migrations", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(version, PREVIOUS_SCHEMA_VERSION); - assert!( - !table_columns(&conn, "trusted_runtime_records") + !table_columns(&conn, "workspace_runtime_bindings") .unwrap() - .is_empty() + .contains(&"binding_revision".to_string()) ); - assert!( - table_columns(&conn, "workspace_runtime_bindings") - .unwrap() - .is_empty() - ); - let jti: String = conn - .query_row( - "SELECT jti FROM worker_mutation_source_proof_jtis WHERE runtime_id = 'shared'", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(jti, "jti-1"); + assert_eq!(current_schema_version(&conn).unwrap(), 51); } #[test] @@ -7139,6 +7267,7 @@ mod tests { base_url: format!("https://{workspace_id}.runtime.test"), public_key: identity.public_key.clone(), public_key_fingerprint: String::new(), + binding_revision: 1, created_at: "1".to_string(), updated_at: "1".to_string(), revoked_at: None, @@ -7233,6 +7362,112 @@ mod tests { ); } + #[test] + fn runtime_binding_key_mutations_are_revisioned_idempotent_and_audited() { + let store = SqliteWorkspaceStore::in_memory().unwrap(); + store + .with_conn(|conn| { + conn.execute_batch( + r#" + INSERT INTO accounts(account_id, kind, handle, display_name, created_at, updated_at) + VALUES ('owner', 'user', 'owner', 'Owner', '1', '1'); + INSERT INTO workspaces(workspace_id, owner_account_id, display_name, state, created_at, updated_at) + VALUES ('workspace-a', 'owner', 'Workspace A', 'active', '1', '1'); + "#, + )?; + Ok(()) + }) + .unwrap(); + let first = worker_runtime::auth::RuntimeIdentityMaterial::generate("first").unwrap(); + let second = worker_runtime::auth::RuntimeIdentityMaterial::generate("second").unwrap(); + let binding = |public_key: String, at: &str| WorkspaceRuntimeBinding { + workspace_id: "workspace-a".to_string(), + runtime_id: "runtime-a".to_string(), + display_name: "Runtime A".to_string(), + base_url: "https://runtime.test".to_string(), + public_key, + public_key_fingerprint: String::new(), + binding_revision: 1, + created_at: at.to_string(), + updated_at: at.to_string(), + revoked_at: None, + }; + + let (created, created_binding) = store + .put_workspace_runtime_binding_key( + binding(first.public_key.clone(), "1"), + None, + "owner", + ) + .unwrap(); + assert_eq!(created, WorkspaceRuntimeBindingMutation::Created); + assert_eq!(created_binding.binding_revision, 1); + let (replayed, replayed_binding) = store + .put_workspace_runtime_binding_key(binding(first.public_key, "2"), None, "owner") + .unwrap(); + assert_eq!(replayed, WorkspaceRuntimeBindingMutation::Unchanged); + assert_eq!(replayed_binding.binding_revision, 1); + + let stale = store + .put_workspace_runtime_binding_key( + binding(second.public_key.clone(), "3"), + Some(0), + "owner", + ) + .unwrap_err(); + assert!(matches!( + stale, + Error::RuntimeBindingRevisionConflict { + expected: Some(0), + actual: Some(1) + } + )); + let (replaced, replaced_binding) = store + .put_workspace_runtime_binding_key( + binding(second.public_key.clone(), "3"), + Some(1), + "owner", + ) + .unwrap(); + assert_eq!(replaced, WorkspaceRuntimeBindingMutation::Replaced); + assert_eq!(replaced_binding.binding_revision, 2); + let (revoked, revoked_binding) = store + .revoke_workspace_runtime_binding_key("workspace-a", "runtime-a", 2, "owner", "4") + .unwrap(); + assert_eq!(revoked, WorkspaceRuntimeBindingMutation::Revoked); + assert_eq!(revoked_binding.binding_revision, 3); + assert_eq!(revoked_binding.revoked_at.as_deref(), Some("4")); + let (reactivated, reactivated_binding) = store + .put_workspace_runtime_binding_key( + binding(second.public_key.clone(), "5"), + Some(3), + "owner", + ) + .unwrap(); + assert_eq!(reactivated, WorkspaceRuntimeBindingMutation::Reactivated); + assert_eq!(reactivated_binding.binding_revision, 4); + + let mut duplicate = binding(second.public_key, "6"); + duplicate.runtime_id = "runtime-b".to_string(); + let duplicate_error = store + .put_workspace_runtime_binding_key(duplicate, None, "owner") + .unwrap_err(); + assert!(matches!( + duplicate_error, + Error::RuntimeBindingFingerprintConflict { .. } + )); + + let audit = store + .list_workspace_runtime_binding_audit("workspace-a", "runtime-a", 50) + .unwrap(); + assert_eq!(audit.len(), 4); + assert_eq!(audit[0].action, "reactivated"); + assert_eq!(audit[0].binding_revision, 4); + assert_eq!(audit[1].action, "revoked"); + assert_eq!(audit[2].action, "replaced"); + assert_eq!(audit[3].action, "created"); + } + #[test] fn embedded_runtime_binding_can_explicitly_rotate_restart_identity() { let store = SqliteWorkspaceStore::in_memory().unwrap(); @@ -7260,6 +7495,7 @@ mod tests { base_url: "in-process://embedded".to_string(), public_key, public_key_fingerprint: String::new(), + binding_revision: 1, created_at: "1".to_string(), updated_at: "1".to_string(), revoked_at: None, @@ -7289,7 +7525,7 @@ mod tests { let conn = Connection::open_in_memory().unwrap(); configure_sqlite(&conn).unwrap(); conn.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (49, 'legacy')", + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (50, 'legacy')", [], ) .unwrap(); @@ -8289,13 +8525,13 @@ INSERT INTO worker_registry ( let conn = Connection::open_in_memory().unwrap(); configure_sqlite(&conn).unwrap(); conn.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (52, 'future')", + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (53, 'future')", [], ) .unwrap(); let error = apply_migrations(&conn).unwrap_err().to_string(); - assert!(error.contains("schema version 52 is newer"), "{error}"); + assert!(error.contains("schema version 53 is newer"), "{error}"); assert!(error.contains("refusing to serve"), "{error}"); } diff --git a/web/workspace/deno.json b/web/workspace/deno.json index cf49316b..eff2525d 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@7.2.7 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts", + "test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts", "build": "deno run -A npm:vite@7.2.7 build", "preview": "deno run -A npm:vite@7.2.7 preview" }, diff --git a/web/workspace/src/lib/generated/workspace-api.ts b/web/workspace/src/lib/generated/workspace-api.ts index 5d96d4ea..f28915e7 100644 --- a/web/workspace/src/lib/generated/workspace-api.ts +++ b/web/workspace/src/lib/generated/workspace-api.ts @@ -47,6 +47,7 @@ export type WorkspaceAuthConfig = { export type WorkspacePermissionSummary = { manage_repositories: boolean; manage_secrets: boolean; + manage_runtimes: boolean; }; export type DiagnosticSeverity = "info" | "warning" | "error"; @@ -221,6 +222,107 @@ export type RepositoryLogResponse = { diagnostics: Array; }; +export type RuntimeSourceKind = "embedded_worker_runtime" | "remote_http"; + +export type RuntimeSourceStatus = "active" | "reserved"; + +export type RuntimeIdentityAuthority = + | "runtime_registry_projection" + | "server_runtime_configuration"; + +export type RuntimeSourceSummary = { + kind: RuntimeSourceKind; + status: RuntimeSourceStatus; + identity_authority: RuntimeIdentityAuthority; + note: string; +}; + +export type RuntimeSummary = { + runtime_id: string; + label: string; + kind: string; + status: string; + source: RuntimeSourceSummary; + host_ids: Array; + worker_creation_available: boolean; + os: string; + arch: string; + diagnostics: Array; +}; + +export type RuntimeManagementSummary = { + built_in: boolean; + config_managed: boolean; + removable: boolean; + endpoint_configured: boolean; + token_ref_configured: boolean; +}; + +export type WorkspaceRuntimeResource = { + management: RuntimeManagementSummary; + runtime_id: string; + label: string; + kind: string; + status: string; + source: RuntimeSourceSummary; + host_ids: Array; + worker_creation_available: boolean; + os: string; + arch: string; + diagnostics: Array; +}; + +export type RuntimeTrustKeyStatus = "unconfigured" | "active" | "revoked"; + +export type RuntimeTrustKeyState = { + status: RuntimeTrustKeyStatus; + public_key?: string | null; + fingerprint?: string | null; + revision?: number | null; + created_at?: string | null; + updated_at?: string | null; + revoked_at?: string | null; +}; + +export type RuntimeTrustAuditAction = + | "created" + | "replaced" + | "reactivated" + | "revoked"; + +export type RuntimeTrustAuditEntry = { + action: RuntimeTrustAuditAction; + actor_account_id: string; + old_fingerprint?: string | null; + new_fingerprint?: string | null; + revision: number; + at: string; +}; + +export type WorkspaceRuntimeDetail = { + workspace_id: string; + runtime: WorkspaceRuntimeResource; + endpoint?: string | null; + trust_key: RuntimeTrustKeyState; + recent_audit: Array; +}; + +export type PutRuntimeTrustKeyRequest = { + public_key: string; + expected_revision: number | null; +}; + +export type RevokeRuntimeTrustKeyRequest = { expected_revision: number }; + +export type RuntimeTrustConflictKind = "stale_revision" | "fingerprint_in_use"; + +export type RuntimeTrustConflictResponse = { + error: RuntimeTrustConflictKind; + message: string; + current_revision?: number; + current_fingerprint?: string | null; +}; + export type RuntimeConnectionTestStatus = "compatible" | "failed"; export type RuntimeConnectionTestFailureKind = diff --git a/web/workspace/src/lib/workspace/api/runtime-management.ts b/web/workspace/src/lib/workspace/api/runtime-management.ts new file mode 100644 index 00000000..944a5238 --- /dev/null +++ b/web/workspace/src/lib/workspace/api/runtime-management.ts @@ -0,0 +1,723 @@ +import type { + Diagnostic, + PutRuntimeTrustKeyRequest, + RevokeRuntimeTrustKeyRequest, + RuntimeIdentityAuthority, + RuntimeManagementSummary, + RuntimeSourceKind, + RuntimeSourceStatus, + RuntimeSourceSummary, + RuntimeTrustAuditAction, + RuntimeTrustAuditEntry, + RuntimeTrustConflictKind, + RuntimeTrustConflictResponse, + RuntimeTrustKeyState, + RuntimeTrustKeyStatus, + WorkspaceRuntimeDetail, + WorkspaceRuntimeResource, +} from "$lib/generated/workspace-api.ts"; +import type { ListResponse } from "$lib/workspace/sidebar/types"; +import { workspaceApiPath } from "./http.ts"; + +export type WorkspaceRuntimeList = ListResponse; + +const LIMITS = { + runtimeItems: 200, + auditEntries: 20, + hostIds: 128, + diagnostics: 64, + idBytes: 256, + labelBytes: 512, + kindBytes: 128, + statusBytes: 128, + noteBytes: 2_048, + endpointBytes: 4_096, + publicKeyBytes: 16 * 1_024, + fingerprintBytes: 512, + timestampBytes: 128, + diagnosticCodeBytes: 128, + diagnosticMessageBytes: 2_048, + conflictMessageBytes: 1_024, + responseBytes: 512 * 1_024, +} as const; + +const SOURCE_KINDS = new Set([ + "embedded_worker_runtime", + "remote_http", +]); +const SOURCE_STATUSES = new Set(["active", "reserved"]); +const IDENTITY_AUTHORITIES = new Set([ + "runtime_registry_projection", + "server_runtime_configuration", +]); +const DIAGNOSTIC_SEVERITIES = new Set(["info", "warning", "error"]); +const TRUST_STATUSES = new Set([ + "unconfigured", + "active", + "revoked", +]); +const AUDIT_ACTIONS = new Set([ + "created", + "replaced", + "reactivated", + "revoked", +]); +const CONFLICT_KINDS = new Set([ + "stale_revision", + "fingerprint_in_use", +]); + +const encoder = new TextEncoder(); +type JsonObject = Record; + +export class RuntimeManagementValidationError extends Error { + constructor(message: string) { + super(message.slice(0, 256)); + this.name = "RuntimeManagementValidationError"; + } +} + +export class RuntimeTrustConflictError extends Error { + readonly conflict: RuntimeTrustConflictResponse; + + constructor(conflict: RuntimeTrustConflictResponse) { + super(conflict.message); + this.name = "RuntimeTrustConflictError"; + this.conflict = conflict; + } +} + +export class RuntimeTrustRequestError extends Error { + readonly field: "public_key" | null; + + constructor(message: string, field: "public_key" | null = null) { + super(message.slice(0, 256)); + this.name = "RuntimeTrustRequestError"; + this.field = field; + } +} + +function fail(path: string, message: string): never { + throw new RuntimeManagementValidationError(`${path} ${message}`); +} + +function object(value: unknown, path: string): JsonObject { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return fail(path, "must be an object"); + } + return value as JsonObject; +} + +function exactKeys( + value: JsonObject, + required: readonly string[], + optional: readonly string[], + path: string, +): void { + const allowed = new Set([...required, ...optional]); + for (const key of Object.keys(value)) { + if (!allowed.has(key)) { + fail(`${path}.${key}`, "is not part of the wire contract"); + } + } + for (const key of required) { + if (!Object.hasOwn(value, key)) fail(`${path}.${key}`, "is required"); + } +} + +function array(value: unknown, path: string, max: number): unknown[] { + if (!Array.isArray(value)) return fail(path, "must be an array"); + if (value.length > max) { + return fail(path, `must contain at most ${max} items`); + } + return value; +} + +function boundedString( + value: unknown, + path: string, + maxBytes: number, + allowEmpty = false, +): string { + if (typeof value !== "string") return fail(path, "must be a string"); + if (!allowEmpty && value.length === 0) return fail(path, "must not be empty"); + if (encoder.encode(value).byteLength > maxBytes) { + return fail(path, `must be at most ${maxBytes} UTF-8 bytes`); + } + return value; +} + +function boolean(value: unknown, path: string): boolean { + if (typeof value !== "boolean") return fail(path, "must be a boolean"); + return value; +} + +function safeInteger(value: unknown, path: string, minimum = 0): number { + if ( + typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum + ) { + return fail(path, `must be a safe integer of at least ${minimum}`); + } + return value; +} + +function safeRevision(value: unknown, path: string): number { + return safeInteger(value, path, 1); +} + +function optionalNullableString( + value: unknown, + path: string, + maxBytes: number, + allowEmpty = false, +): string | null | undefined { + if (value === undefined || value === null) return value; + return boundedString(value, path, maxBytes, allowEmpty); +} + +function optionalRevision( + value: unknown, + path: string, +): number | undefined { + if (value === undefined || value === null) return undefined; + return safeRevision(value, path); +} + +function optionalNullableRevision( + value: unknown, + path: string, +): number | null | undefined { + if (value === undefined || value === null) return value; + return safeRevision(value, path); +} + +function timestamp(value: unknown, path: string): string { + const result = boundedString(value, path, LIMITS.timestampBytes); + if ( + !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/ + .test(result) + ) { + return fail(path, "must be an RFC 3339 timestamp"); + } + return result; +} + +function optionalNullableTimestamp( + value: unknown, + path: string, +): string | null | undefined { + if (value === undefined || value === null) return value; + return timestamp(value, path); +} + +function enumValue( + value: unknown, + path: string, + variants: ReadonlySet, +): T { + const result = boundedString(value, path, LIMITS.kindBytes); + if (!variants.has(result as T)) { + return fail(path, "contains an unknown enum value"); + } + return result as T; +} + +function diagnostic(value: unknown, path: string): Diagnostic { + const item = object(value, path); + exactKeys(item, ["code", "severity", "message"], [], path); + const severity = enumValue( + item.severity, + `${path}.severity`, + DIAGNOSTIC_SEVERITIES, + ) as Diagnostic["severity"]; + return { + code: boundedString(item.code, `${path}.code`, LIMITS.diagnosticCodeBytes), + severity, + message: boundedString( + item.message, + `${path}.message`, + LIMITS.diagnosticMessageBytes, + true, + ), + }; +} + +function runtimeSource(value: unknown, path: string): RuntimeSourceSummary { + const item = object(value, path); + exactKeys(item, ["kind", "status", "identity_authority", "note"], [], path); + return { + kind: enumValue(item.kind, `${path}.kind`, SOURCE_KINDS), + status: enumValue(item.status, `${path}.status`, SOURCE_STATUSES), + identity_authority: enumValue( + item.identity_authority, + `${path}.identity_authority`, + IDENTITY_AUTHORITIES, + ), + note: boundedString(item.note, `${path}.note`, LIMITS.noteBytes, true), + }; +} + +function runtimeManagement( + value: unknown, + path: string, +): RuntimeManagementSummary { + const item = object(value, path); + exactKeys( + item, + [ + "built_in", + "config_managed", + "removable", + "endpoint_configured", + "token_ref_configured", + ], + [], + path, + ); + return { + built_in: boolean(item.built_in, `${path}.built_in`), + config_managed: boolean(item.config_managed, `${path}.config_managed`), + removable: boolean(item.removable, `${path}.removable`), + endpoint_configured: boolean( + item.endpoint_configured, + `${path}.endpoint_configured`, + ), + token_ref_configured: boolean( + item.token_ref_configured, + `${path}.token_ref_configured`, + ), + }; +} + +function runtimeResource( + value: unknown, + path: string, +): WorkspaceRuntimeResource { + const item = object(value, path); + exactKeys( + item, + [ + "management", + "runtime_id", + "label", + "kind", + "status", + "source", + "host_ids", + "worker_creation_available", + "os", + "arch", + "diagnostics", + ], + [], + path, + ); + const hostIds = array(item.host_ids, `${path}.host_ids`, LIMITS.hostIds).map( + (entry, index) => + boundedString( + entry, + `${path}.host_ids[${index}]`, + LIMITS.idBytes, + ), + ); + if (new Set(hostIds).size !== hostIds.length) { + fail(`${path}.host_ids`, "must not contain duplicate IDs"); + } + return { + management: runtimeManagement(item.management, `${path}.management`), + runtime_id: boundedString( + item.runtime_id, + `${path}.runtime_id`, + LIMITS.idBytes, + ), + label: boundedString(item.label, `${path}.label`, LIMITS.labelBytes), + kind: boundedString(item.kind, `${path}.kind`, LIMITS.kindBytes), + status: boundedString(item.status, `${path}.status`, LIMITS.statusBytes), + source: runtimeSource(item.source, `${path}.source`), + host_ids: hostIds, + worker_creation_available: boolean( + item.worker_creation_available, + `${path}.worker_creation_available`, + ), + os: boundedString(item.os, `${path}.os`, LIMITS.kindBytes, true), + arch: boundedString(item.arch, `${path}.arch`, LIMITS.kindBytes, true), + diagnostics: array( + item.diagnostics, + `${path}.diagnostics`, + LIMITS.diagnostics, + ).map((entry, index) => diagnostic(entry, `${path}.diagnostics[${index}]`)), + }; +} + +function trustKey(value: unknown, path: string): RuntimeTrustKeyState { + const item = object(value, path); + exactKeys( + item, + ["status"], + [ + "public_key", + "fingerprint", + "revision", + "created_at", + "updated_at", + "revoked_at", + ], + path, + ); + const result: RuntimeTrustKeyState = { + status: enumValue(item.status, `${path}.status`, TRUST_STATUSES), + public_key: optionalNullableString( + item.public_key, + `${path}.public_key`, + LIMITS.publicKeyBytes, + ), + fingerprint: optionalNullableString( + item.fingerprint, + `${path}.fingerprint`, + LIMITS.fingerprintBytes, + ), + revision: optionalNullableRevision(item.revision, `${path}.revision`), + created_at: optionalNullableTimestamp( + item.created_at, + `${path}.created_at`, + ), + updated_at: optionalNullableTimestamp( + item.updated_at, + `${path}.updated_at`, + ), + revoked_at: optionalNullableTimestamp( + item.revoked_at, + `${path}.revoked_at`, + ), + }; + + const hasBinding = result.status !== "unconfigured"; + if ( + hasBinding && + (result.fingerprint == null || result.revision == null || + result.created_at == null || result.updated_at == null) + ) { + fail( + path, + "must include fingerprint, revision, created_at, and updated_at", + ); + } + if ( + !hasBinding && + Object.entries(result).some(([key, entry]) => + key !== "status" && entry != null + ) + ) { + fail(path, "must not include binding values while unconfigured"); + } + if (result.status === "revoked" && result.revoked_at == null) { + fail(`${path}.revoked_at`, "is required for a revoked key"); + } + if (result.status === "active" && result.revoked_at != null) { + fail(`${path}.revoked_at`, "must be absent for an active key"); + } + return result; +} + +function auditEntry(value: unknown, path: string): RuntimeTrustAuditEntry { + const item = object(value, path); + exactKeys( + item, + ["action", "actor_account_id", "revision", "at"], + ["old_fingerprint", "new_fingerprint"], + path, + ); + return { + action: enumValue(item.action, `${path}.action`, AUDIT_ACTIONS), + actor_account_id: boundedString( + item.actor_account_id, + `${path}.actor_account_id`, + LIMITS.idBytes, + ), + old_fingerprint: optionalNullableString( + item.old_fingerprint, + `${path}.old_fingerprint`, + LIMITS.fingerprintBytes, + ), + new_fingerprint: optionalNullableString( + item.new_fingerprint, + `${path}.new_fingerprint`, + LIMITS.fingerprintBytes, + ), + revision: safeRevision(item.revision, `${path}.revision`), + at: timestamp(item.at, `${path}.at`), + }; +} + +export function parseWorkspaceRuntimeList( + value: unknown, +): WorkspaceRuntimeList { + const response = object(value, "Runtime list response"); + exactKeys( + response, + ["workspace_id", "limit", "items", "source", "diagnostics"], + [], + "Runtime list response", + ); + const limit = safeInteger(response.limit, "Runtime list response.limit", 0); + if (limit > LIMITS.runtimeItems) { + fail( + "Runtime list response.limit", + `must not exceed ${LIMITS.runtimeItems}`, + ); + } + const items = array( + response.items, + "Runtime list response.items", + LIMITS.runtimeItems, + ).map((entry, index) => + runtimeResource(entry, `Runtime list response.items[${index}]`) + ); + if (items.length > limit) { + fail("Runtime list response.items", "must not exceed the declared limit"); + } + return { + workspace_id: boundedString( + response.workspace_id, + "Runtime list response.workspace_id", + LIMITS.idBytes, + ), + limit, + items, + source: boundedString( + response.source, + "Runtime list response.source", + LIMITS.kindBytes, + ), + diagnostics: array( + response.diagnostics, + "Runtime list response.diagnostics", + LIMITS.diagnostics, + ).map((entry, index) => + diagnostic(entry, `Runtime list response.diagnostics[${index}]`) + ), + }; +} + +export function parseWorkspaceRuntimeDetail( + value: unknown, +): WorkspaceRuntimeDetail { + const response = object(value, "Runtime detail response"); + exactKeys( + response, + ["workspace_id", "runtime", "trust_key", "recent_audit"], + ["endpoint"], + "Runtime detail response", + ); + return { + workspace_id: boundedString( + response.workspace_id, + "Runtime detail response.workspace_id", + LIMITS.idBytes, + ), + runtime: runtimeResource( + response.runtime, + "Runtime detail response.runtime", + ), + endpoint: optionalNullableString( + response.endpoint, + "Runtime detail response.endpoint", + LIMITS.endpointBytes, + ), + trust_key: trustKey( + response.trust_key, + "Runtime detail response.trust_key", + ), + recent_audit: array( + response.recent_audit, + "Runtime detail response.recent_audit", + LIMITS.auditEntries, + ).map((entry, index) => + auditEntry(entry, `Runtime detail response.recent_audit[${index}]`) + ), + }; +} + +export function parseRuntimeTrustConflict( + value: unknown, +): RuntimeTrustConflictResponse { + const response = object(value, "Runtime trust conflict"); + exactKeys( + response, + ["error", "message"], + ["current_revision", "current_fingerprint"], + "Runtime trust conflict", + ); + return { + error: enumValue( + response.error, + "Runtime trust conflict.error", + CONFLICT_KINDS, + ), + message: boundedString( + response.message, + "Runtime trust conflict.message", + LIMITS.conflictMessageBytes, + ), + current_revision: optionalRevision( + response.current_revision, + "Runtime trust conflict.current_revision", + ), + current_fingerprint: optionalNullableString( + response.current_fingerprint, + "Runtime trust conflict.current_fingerprint", + LIMITS.fingerprintBytes, + ), + }; +} + +function revisionForJson(revision: number | null): number | null { + if (revision === null) return null; + if (!Number.isSafeInteger(revision) || revision < 1) { + throw new RuntimeTrustRequestError( + "Runtime trust revision is not a safe integer", + ); + } + return revision; +} + +async function readBoundedJson(response: Response): Promise { + const contentLength = response.headers.get("content-length"); + if (contentLength !== null) { + const parsed = Number(contentLength); + if (Number.isFinite(parsed) && parsed > LIMITS.responseBytes) { + throw new RuntimeTrustRequestError( + "Runtime trust response exceeds its byte limit", + ); + } + } + const text = await response.text(); + if (encoder.encode(text).byteLength > LIMITS.responseBytes) { + throw new RuntimeTrustRequestError( + "Runtime trust response exceeds its byte limit", + ); + } + try { + return JSON.parse(text) as unknown; + } catch { + throw new RuntimeTrustRequestError( + "Runtime trust response is not valid JSON", + ); + } +} + +function requestErrorFrom( + value: unknown, + status: number, +): RuntimeTrustRequestError { + try { + const response = object(value, "Runtime trust error"); + exactKeys( + response, + ["error", "message", "diagnostics"], + [], + "Runtime trust error", + ); + const diagnostics = array( + response.diagnostics, + "Runtime trust error.diagnostics", + LIMITS.diagnostics, + ).map((entry, index) => + diagnostic(entry, `Runtime trust error.diagnostics[${index}]`) + ); + const message = boundedString( + response.message, + "Runtime trust error.message", + LIMITS.conflictMessageBytes, + ); + const field = diagnostics.some((entry) => + entry.code.startsWith("runtime_public_key_") + ) + ? "public_key" + : null; + return new RuntimeTrustRequestError(message, field); + } catch { + return new RuntimeTrustRequestError( + `Runtime trust request failed (${status})`, + ); + } +} + +async function finishMutation( + response: Response, + workspaceId: string, + runtimeId: string, +): Promise { + const payload = await readBoundedJson(response); + if (response.status === 409) { + try { + throw new RuntimeTrustConflictError(parseRuntimeTrustConflict(payload)); + } catch (error) { + if (error instanceof RuntimeTrustConflictError) throw error; + throw new RuntimeTrustRequestError( + "Runtime trust conflict response was invalid", + ); + } + } + if (!response.ok) throw requestErrorFrom(payload, response.status); + let detail: WorkspaceRuntimeDetail; + try { + detail = parseWorkspaceRuntimeDetail(payload); + } catch { + throw new RuntimeTrustRequestError("Runtime trust response was invalid"); + } + if ( + detail.workspace_id !== workspaceId || + detail.runtime.runtime_id !== runtimeId + ) { + throw new RuntimeTrustRequestError( + "Runtime trust response did not match the selected Runtime", + ); + } + return detail; +} + +export async function putRuntimeTrustKey( + workspaceId: string, + runtimeId: string, + request: PutRuntimeTrustKeyRequest, + fetchImpl: typeof fetch = fetch, +): Promise { + const response = await fetchImpl( + workspaceApiPath( + workspaceId, + `/runtimes/${encodeURIComponent(runtimeId)}/trust-key`, + ), + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + public_key: request.public_key, + expected_revision: revisionForJson(request.expected_revision), + }), + }, + ); + return await finishMutation(response, workspaceId, runtimeId); +} + +export async function revokeRuntimeTrustKey( + workspaceId: string, + runtimeId: string, + request: RevokeRuntimeTrustKeyRequest, + fetchImpl: typeof fetch = fetch, +): Promise { + const response = await fetchImpl( + workspaceApiPath( + workspaceId, + `/runtimes/${encodeURIComponent(runtimeId)}/trust-key`, + ), + { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + expected_revision: revisionForJson(request.expected_revision), + }), + }, + ); + return await finishMutation(response, workspaceId, runtimeId); +} diff --git a/web/workspace/src/lib/workspace/api/workspace-model.ts b/web/workspace/src/lib/workspace/api/workspace-model.ts index b6701aa0..2088a18f 100644 --- a/web/workspace/src/lib/workspace/api/workspace-model.ts +++ b/web/workspace/src/lib/workspace/api/workspace-model.ts @@ -367,13 +367,18 @@ function authConfig(value: unknown, path: string): WorkspaceAuthConfig { function permissions(value: unknown, path: string): WorkspacePermissionSummary { const item = object(value, path); - exactKeys(item, ["manage_repositories", "manage_secrets"], path); + exactKeys( + item, + ["manage_repositories", "manage_secrets", "manage_runtimes"], + path, + ); return { manage_repositories: boolean( item.manage_repositories, `${path}.manage_repositories`, ), manage_secrets: boolean(item.manage_secrets, `${path}.manage_secrets`), + manage_runtimes: boolean(item.manage_runtimes, `${path}.manage_runtimes`), }; } diff --git a/web/workspace/src/lib/workspace/styles/settings.css b/web/workspace/src/lib/workspace/styles/settings.css index ded7886b..e6f363b3 100644 --- a/web/workspace/src/lib/workspace/styles/settings.css +++ b/web/workspace/src/lib/workspace/styles/settings.css @@ -342,6 +342,196 @@ .settings-test-result.failed { border-inline-start: 3px solid var(--danger); } + + .runtime-detail-page { + display: grid; + gap: var(--space-5); + } + + .runtime-detail-section { + display: grid; + gap: var(--space-3); + padding-top: var(--space-4); + border-top: 1px solid var(--line); + } + + .runtime-detail-section h2, + .runtime-detail-section p { + margin: 0; + } + + .runtime-detail-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr)); + gap: var(--space-3) var(--space-5); + margin: 0; + } + + .runtime-detail-grid div { + min-width: 0; + } + + .runtime-detail-grid dt { + margin-bottom: var(--space-1); + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + } + + .runtime-detail-grid dd { + margin: 0; + color: var(--text-strong); + overflow-wrap: anywhere; + } + + .runtime-public-key-actions, + .runtime-revoke-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + flex-wrap: wrap; + } + + .runtime-public-key-actions { + justify-content: flex-start; + } + + .runtime-public-key-actions button, + .runtime-revoke-row button, + .runtime-trust-form button { + border: 0; + border-radius: 0.6rem; + padding: 0.5rem 0.75rem; + background: var(--accent); + color: var(--bg); + font-weight: 700; + cursor: pointer; + } + + .runtime-public-key-actions button.secondary { + border: 1px solid var(--line); + background: transparent; + color: var(--text-strong); + } + + .runtime-public-key-actions button:disabled, + .runtime-revoke-row button:disabled, + .runtime-trust-form button:disabled { + cursor: not-allowed; + opacity: 0.55; + } + + .runtime-public-key, + .runtime-trust-form textarea, + .runtime-trust-form input { + border: 1px solid var(--line); + border-radius: 0.5rem; + background: var(--bg-raised); + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 0.78rem; + } + + .runtime-public-key { + max-height: 14rem; + margin: 0; + padding: var(--space-3); + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + + .runtime-trust-form { + display: grid; + gap: var(--space-2); + max-width: 56rem; + } + + .runtime-trust-form label { + color: var(--text-muted); + font-size: 0.78rem; + font-weight: 700; + } + + .runtime-trust-form textarea, + .runtime-trust-form input { + width: 100%; + padding: 0.65rem 0.75rem; + } + + .runtime-trust-form textarea { + resize: vertical; + } + + .runtime-trust-form small { + color: var(--text-muted); + } + + .runtime-trust-form .field-error, + .runtime-detail-page .section-state.error { + color: var(--danger); + } + + .runtime-detail-page .section-state.success { + color: var(--success); + } + + .runtime-revoke-row { + padding-top: var(--space-3); + border-top: 1px solid var(--line); + } + + .runtime-revoke-row div { + display: grid; + gap: var(--space-1); + } + + .runtime-revoke-row p { + color: var(--text-muted); + } + + .runtime-revoke-row button.danger { + background: var(--danger); + } + + .runtime-audit-table-wrap { + overflow-x: auto; + } + + .runtime-audit-table { + width: 100%; + min-width: 48rem; + border-collapse: collapse; + } + + .runtime-audit-table th, + .runtime-audit-table td { + padding: 0.7rem 0.5rem; + border-bottom: 1px solid var(--line); + text-align: left; + vertical-align: top; + } + + .runtime-audit-table th { + color: var(--text-muted); + font-size: 0.72rem; + letter-spacing: 0.05em; + text-transform: uppercase; + } + + .runtime-audit-table code { + overflow-wrap: anywhere; + } + + @media (max-width: 760px) { + .runtime-revoke-row { + align-items: stretch; + } + } + .settings-page { display: grid; gap: var(--space-5); diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.svelte index 4e9380b0..35c87b2c 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.svelte @@ -1,9 +1,11 @@ + + + {data.runtimeDetail?.runtime.label ?? data.runtimeId} · Runtime Settings · Yoi Workspace + + + +
+
+
+ Runtimes +

{data.runtimeDetail?.runtime.label ?? data.runtimeId}

+

{data.runtimeId}

+
+ + Workdirs + +
+ + {#if data.runtimeDetailError} +

{data.runtimeDetailError}

+ {:else if !data.runtimeDetail} +

Loading Runtime…

+ {:else} + {@const detail = data.runtimeDetail} + {@const runtime = detail.runtime} + {@const trust = detail.trust_key} + {@const currentAction = trustAction(trust.status)} + +
+

Identity and binding

+
+
Runtime ID
{runtime.runtime_id}
+
Kind
{runtime.kind}
+
Endpoint
{detail.endpoint ?? 'Not configured'}
+
Status
{runtime.status}
+
Binding status
{trust.status}
+
Fingerprint
{trust.fingerprint ?? '—'}
+
Revision
{trust.revision?.toString() ?? '—'}
+
Created
{formatTimestamp(trust.created_at)}
+
Updated
{formatTimestamp(trust.updated_at)}
+
Revoked
{formatTimestamp(trust.revoked_at)}
+
+ {#if runtime.diagnostics.length > 0} +
    + {#each runtime.diagnostics as diagnostic} +
  • + {diagnostic.code} + {diagnostic.message} +
  • + {/each} +
+ {/if} +
+ + {#if data.workspace.permissions.manage_runtimes} +
+

Workspace trust

+ + {#if trust.public_key} +
+ + +
+ {#if revealPublicKey} +
{trust.public_key}
+ {/if} + {:else if trust.status !== 'unconfigured'} +

The public key was not included in this authorized response.

+ {/if} + +
+ + + + {#if currentAction !== 'create'} + + + Enter {trust.fingerprint ?? 'the current fingerprint'} exactly. + {/if} + + {#if fieldError} +

{fieldError}

+ {/if} +
+ +
+
+ +
+
+ Revoke Workspace trust +

Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.

+
+ +
+ + {#if requestError} + + {/if} + {#if successMessage} +

{successMessage}

+ {/if} +
+ {/if} + +
+

Recent trust audit

+ {#if detail.recent_audit.length === 0} +

No trust changes are recorded.

+ {:else} +
+ + + + + + {#each detail.recent_audit as entry} + + + + + + + + {/each} + +
ActionRevisionFingerprintActorTime
{entry.action}{entry.revision.toString()}{entry.new_fingerprint ?? entry.old_fingerprint ?? '—'}{entry.actor_account_id}{formatTimestamp(entry.at)}
+
+ {/if} +
+ {/if} +
diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.ts b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.ts new file mode 100644 index 00000000..100e82f5 --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.ts @@ -0,0 +1,31 @@ +import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; +import { parseWorkspaceRuntimeDetail } from "$lib/workspace/api/runtime-management"; +import type { PageLoad } from "./$types"; + +export const load: PageLoad = async ({ fetch, params }) => { + const detail = await loadJson( + fetch, + workspaceApiPath( + params.workspaceId, + `/runtimes/${encodeURIComponent(params.runtimeId)}`, + ), + undefined, + (value) => { + const response = parseWorkspaceRuntimeDetail(value); + if ( + response.workspace_id !== params.workspaceId || + response.runtime.runtime_id !== params.runtimeId + ) { + throw new Error("Runtime detail did not match the route"); + } + return response; + }, + ); + + return { + workspaceId: params.workspaceId, + runtimeId: params.runtimeId, + runtimeDetail: detail.data, + runtimeDetailError: detail.error, + }; +}; diff --git a/web/workspace/tests/runtime-management-source.test.ts b/web/workspace/tests/runtime-management-source.test.ts new file mode 100644 index 00000000..f455191e --- /dev/null +++ b/web/workspace/tests/runtime-management-source.test.ts @@ -0,0 +1,133 @@ +declare const Deno: { + test(name: string, fn: () => void | Promise): void; + readTextFile(path: URL): Promise; +}; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +Deno.test("Runtime Settings routes validate unknown JSON through the shared Runtime parser", async () => { + const [listLoader, detailLoader] = await Promise.all([ + Deno.readTextFile( + new URL( + "../src/routes/w/[workspaceId]/settings/runtimes/+page.ts", + import.meta.url, + ), + ), + Deno.readTextFile( + new URL( + "../src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.ts", + import.meta.url, + ), + ), + ]); + + assert( + listLoader.includes("parseWorkspaceRuntimeList(value)"), + "Runtime list loader should validate unknown JSON", + ); + assert( + detailLoader.includes("parseWorkspaceRuntimeDetail(value)"), + "Runtime detail loader should validate unknown JSON", + ); + for (const source of [listLoader, detailLoader]) { + assert( + !source.includes("loadJson<"), + "Runtime loaders must not cast response JSON to a handwritten DTO", + ); + } +}); + +Deno.test("Runtime list links to canonical detail and has no inline delete action", async () => { + const page = await Deno.readTextFile( + new URL( + "../src/routes/w/[workspaceId]/settings/runtimes/+page.svelte", + import.meta.url, + ), + ); + + assert( + page.includes( + "/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}", + ), + "Runtime name should link to canonical detail", + ); + assert( + page.includes("testRuntime(runtime)"), + "connection Test should remain available", + ); + assert(page.includes("Add Runtime"), "Add Runtime should remain available"); + assert( + page.includes("data.workspace.permissions.manage_runtimes"), + "Add Runtime should be hidden from non-owners", + ); + assert( + !page.includes("deleteRuntime"), + "inline Runtime delete logic must be removed", + ); + assert( + !page.includes(">Delete"), + "inline Runtime delete control must be removed", + ); +}); + +Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", async () => { + const page = await Deno.readTextFile( + new URL( + "../src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte", + import.meta.url, + ), + ); + + const ownerGate = page.indexOf("data.workspace.permissions.manage_runtimes"); + const reveal = page.indexOf("Reveal public key"); + const mutation = page.indexOf('id="runtime-public-key-input"'); + assert(ownerGate >= 0, "Runtime trust controls should use manage_runtimes"); + assert( + ownerGate < reveal && ownerGate < mutation, + "owner gate should wrap key controls", + ); + + for ( + const token of [ + "Create Workspace trust", + "Replace trusted key", + "Reactivate with this key", + "Confirm current fingerprint", + "Revoke Workspace trust", + "Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.", + "RuntimeTrustConflictError", + "await reloadAuthority()", + "busyAction !== null", + "Workdirs", + "Recent trust audit", + ] + ) { + assert(page.includes(token), `Runtime detail should include ${token}`); + } +}); + +Deno.test("Runtime detail uses flat sections instead of nested cards", async () => { + const [page, css] = await Promise.all([ + Deno.readTextFile( + new URL( + "../src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte", + import.meta.url, + ), + ), + Deno.readTextFile( + new URL("../src/lib/workspace/styles/settings.css", import.meta.url), + ), + ]); + + assert( + !page.includes('class="card"') && !page.includes("settings-card"), + "Runtime detail should not add card nesting", + ); + assert( + css.includes(".runtime-detail-section") && + css.includes("border-top: 1px solid var(--line)"), + "Runtime detail hierarchy should use flat section separators", + ); +}); diff --git a/web/workspace/tests/runtime-management.test.ts b/web/workspace/tests/runtime-management.test.ts new file mode 100644 index 00000000..cc938db9 --- /dev/null +++ b/web/workspace/tests/runtime-management.test.ts @@ -0,0 +1,227 @@ +declare const Deno: { + test(name: string, fn: () => void | Promise): void; +}; + +import { + parseRuntimeTrustConflict, + parseWorkspaceRuntimeDetail, + parseWorkspaceRuntimeList, + putRuntimeTrustKey, + RuntimeTrustConflictError, +} from "../src/lib/workspace/api/runtime-management.ts"; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +function assertThrows(operation: () => unknown, expected: string): void { + try { + operation(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes(expected)) return; + throw new Error( + `expected error containing ${expected}, received ${message}`, + ); + } + throw new Error("expected operation to throw"); +} + +function runtime() { + return { + management: { + built_in: false, + config_managed: true, + removable: false, + endpoint_configured: true, + token_ref_configured: false, + }, + runtime_id: "arcadia", + label: "Arcadia", + kind: "remote", + status: "started", + source: { + kind: "remote_http", + status: "active", + identity_authority: "server_runtime_configuration", + note: "Configured by Server authority", + }, + host_ids: ["host-a"], + worker_creation_available: true, + os: "linux", + arch: "x86_64", + diagnostics: [], + }; +} + +function detail() { + return { + workspace_id: "workspace-a", + runtime: runtime(), + endpoint: "https://runtime.example.test", + trust_key: { + status: "active", + public_key: "ssh-ed25519 AAAA-test", + fingerprint: "SHA256:current", + revision: 3, + created_at: "2026-09-01T12:00:00Z", + updated_at: "2026-09-01T13:00:00Z", + revoked_at: null, + }, + recent_audit: [{ + action: "created", + actor_account_id: "account-a", + old_fingerprint: null, + new_fingerprint: "SHA256:current", + revision: 3, + at: "2026-09-01T13:00:00Z", + }], + }; +} + +Deno.test("Runtime list and detail parsers return generated Runtime DTO shapes", () => { + const list = parseWorkspaceRuntimeList({ + workspace_id: "workspace-a", + limit: 200, + items: [runtime()], + source: "workspace-control-plane", + diagnostics: [], + }); + assert( + list.items[0]?.runtime_id === "arcadia", + "Runtime ID was not preserved", + ); + + const parsed = parseWorkspaceRuntimeDetail(detail()); + assert( + parsed.trust_key.revision === 3, + "revision was not preserved as a safe integer", + ); + assert( + parsed.recent_audit[0]?.revision === 3, + "audit revision was not normalized", + ); +}); + +Deno.test("Runtime validators reject unknown object keys and enum variants", () => { + assertThrows( + () => parseWorkspaceRuntimeDetail({ ...detail(), head_tree: "stale" }), + "head_tree is not part", + ); + + const futureSource = structuredClone(detail()); + futureSource.runtime.source.kind = "future_transport"; + assertThrows( + () => parseWorkspaceRuntimeDetail(futureSource), + "contains an unknown enum value", + ); + + assertThrows( + () => + parseRuntimeTrustConflict({ + error: "future_conflict", + message: "conflict", + current_revision: 4, + current_fingerprint: "SHA256:new", + }), + "contains an unknown enum value", + ); +}); + +Deno.test("Runtime validators reject unsafe revisions and bounded collection overflow", () => { + const unsafeRevision = structuredClone(detail()); + unsafeRevision.trust_key.revision = Number.MAX_SAFE_INTEGER + 1; + assertThrows( + () => parseWorkspaceRuntimeDetail(unsafeRevision), + "must be a safe integer", + ); + + const tooMuchAudit = structuredClone(detail()); + tooMuchAudit.recent_audit = Array.from( + { length: 21 }, + () => structuredClone(detail().recent_audit[0]), + ); + assertThrows( + () => parseWorkspaceRuntimeDetail(tooMuchAudit), + "must contain at most 20 items", + ); + + const tooManyItems = Array.from({ length: 201 }, () => runtime()); + assertThrows( + () => + parseWorkspaceRuntimeList({ + workspace_id: "workspace-a", + limit: 200, + items: tooManyItems, + source: "workspace-control-plane", + diagnostics: [], + }), + "must contain at most 200 items", + ); +}); + +Deno.test("Runtime detail rejects unbounded strings and incoherent trust state", () => { + const largeKey = structuredClone(detail()); + largeKey.trust_key.public_key = "x".repeat(16 * 1024 + 1); + assertThrows( + () => parseWorkspaceRuntimeDetail(largeKey), + "must be at most 16384 UTF-8 bytes", + ); + + const activeWithoutFingerprint = structuredClone(detail()) as Record< + string, + unknown + >; + (activeWithoutFingerprint.trust_key as Record).fingerprint = + null; + assertThrows( + () => parseWorkspaceRuntimeDetail(activeWithoutFingerprint), + "must include fingerprint", + ); +}); + +Deno.test("typed trust conflict is validated and preserves authoritative revision", async () => { + let sentBody: unknown = null; + const fetchImpl = ((_: RequestInfo | URL, init?: RequestInit) => { + sentBody = JSON.parse(String(init?.body)) as unknown; + return Promise.resolve( + new Response( + JSON.stringify({ + error: "stale_revision", + message: "Runtime trust changed", + current_revision: 4, + current_fingerprint: "SHA256:new", + }), + { status: 409, headers: { "content-type": "application/json" } }, + ), + ); + }) as typeof fetch; + + try { + await putRuntimeTrustKey( + "workspace-a", + "arcadia", + { public_key: "ssh-ed25519 AAAA-new", expected_revision: 3 }, + fetchImpl, + ); + throw new Error("expected mutation to reject"); + } catch (error) { + assert( + error instanceof RuntimeTrustConflictError, + "expected typed conflict", + ); + assert( + error.conflict.current_revision === 4, + "authoritative revision was lost", + ); + } + + assert( + JSON.stringify(sentBody) === + JSON.stringify({ + public_key: "ssh-ed25519 AAAA-new", + expected_revision: 3, + }), + "request should serialize the generated bigint revision as a safe JSON integer", + ); +});