diff --git a/crates/server-api/Cargo.toml b/crates/server-api/Cargo.toml index 1d5a2ba8a..dfc34d4a3 100644 --- a/crates/server-api/Cargo.toml +++ b/crates/server-api/Cargo.toml @@ -60,3 +60,7 @@ required-features = ["typescript"] [[example]] name = "generate_drive_api_types" required-features = ["typescript"] + +[[example]] +name = "generate_plugin_api_types" +required-features = ["typescript"] diff --git a/crates/server-api/examples/generate_plugin_api_types.rs b/crates/server-api/examples/generate_plugin_api_types.rs new file mode 100644 index 000000000..4823672de --- /dev/null +++ b/crates/server-api/examples/generate_plugin_api_types.rs @@ -0,0 +1,6 @@ +fn main() { + print!( + "{}", + server_api::normalized_typescript(server_api::plugin_api_typescript()) + ); +} diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index b7a8e421f..17eb9dd12 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -9,7 +9,12 @@ pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, Web pub mod authorization; mod drive; mod notification; +mod plugin; +mod plugin_types; pub use notification::*; +pub use plugin::*; +#[cfg(feature = "typescript")] +pub use plugin_types::plugin_api_typescript; mod ticket_browse; pub use ticket_browse::*; pub mod repository_openapi_typescript; @@ -205,6 +210,15 @@ macro_rules! impl_openapi_schema { } impl_openapi_schema!( + PluginInstallRequest, + PluginAddArtifactRequest, + PluginRevokeRequest, + PluginUninstallRequest, + PluginListRequest, + PluginUploadResponse, + PluginInstallation, + PluginListResponse, + PluginOperationResponse, SubjectPermissionsResponse, SubjectPermissionsMutationResponse, SubjectPermissionInterruption, @@ -2890,6 +2904,73 @@ pub trait ServerApi { #[path] working_directory_id: String, #[body] request: WorkingDirectoryRemovalRequest, ) -> Result; + #[post("/api/w/{workspace_id}/plugin-uploads", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)] + async fn plugin_upload( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[binary] body: BinaryBody, + ) -> Result; + + #[post("/api/w/{workspace_id}/plugins", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)] + async fn plugin_install( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[body] request: PluginInstallRequest, + ) -> Result; + + #[get("/api/w/{workspace_id}/plugins", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true)] + async fn plugin_list( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[query] query: PluginListRequest, + ) -> Result; + + #[get("/api/w/{workspace_id}/plugins/{installation_id}", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true)] + async fn plugin_show( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[path] installation_id: String, + ) -> Result; + + #[post("/api/w/{workspace_id}/plugins/{installation_id}/artifacts", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)] + async fn plugin_artifact_add( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[path] installation_id: String, + #[body] request: PluginAddArtifactRequest, + ) -> Result; + + #[post("/api/w/{workspace_id}/plugins/{installation_id}/revoke", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)] + async fn plugin_revoke( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[path] installation_id: String, + #[body] request: PluginRevokeRequest, + ) -> Result; + + #[post("/api/w/{workspace_id}/plugins/{installation_id}/uninstall", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)] + async fn plugin_uninstall( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[path] installation_id: String, + #[body] request: PluginUninstallRequest, + ) -> Result; + + #[get("/api/w/{workspace_id}/plugin-operations/{operation_id}", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true)] + async fn plugin_operation( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[path] operation_id: String, + ) -> Result; + // Ordinary operations accept member browser sessions or trusted Worker request // context. The service resolves Worker grants; no operation accepts an actor or // grant ID from the caller. Grant management is Workspace-owner-only. diff --git a/crates/server-api/src/plugin.rs b/crates/server-api/src/plugin.rs new file mode 100644 index 000000000..7fdf34c6f --- /dev/null +++ b/crates/server-api/src/plugin.rs @@ -0,0 +1,184 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PluginInstallRequest { + pub upload_id: String, + pub alias: String, + pub operation_key: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PluginAddArtifactRequest { + pub upload_id: String, + pub expected_last_operation: String, + pub operation_key: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PluginRevokeRequest { + pub artifact_digest: String, + pub expected_last_operation: String, + pub reason: String, + pub operation_key: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PluginUninstallRequest { + pub expected_last_operation: String, + pub operation_key: String, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PluginListRequest { + pub cursor: Option, + pub limit: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PluginSchemaContribution { + pub identity: String, + pub version: String, + pub digest: String, + pub namespace: String, + pub dcdl: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PluginToolMetadata { + pub name: String, + pub description: String, + #[cfg_attr(feature = "typescript", ts(type = "unknown"))] + pub input_schema: serde_json::Value, + #[cfg_attr(feature = "typescript", ts(type = "unknown"))] + pub output_schema: serde_json::Value, + pub error_codes: Vec, + pub external_write: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "UPPERCASE")] +pub enum PluginHttpMethod { + Get, + Head, + Post, + Put, + Patch, + Delete, + Options, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PluginConnectionMetadata { + pub name: String, + pub methods: Vec, + pub path_prefixes: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum PluginGrantMetadata { + Logging, + Https { slot: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PluginArtifact { + pub package_id: String, + pub name: String, + pub version: String, + pub description: Option, + pub package_digest: String, + pub artifact_digest: String, + pub artifact_size: u32, + pub schema_version: u32, + pub world: String, + pub required_imports: Vec, + pub tools: Vec, + pub connections: Vec, + pub requested_grants: Vec, + pub config_schema: PluginSchemaContribution, + pub revoked: bool, + pub revocation_reason: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PluginUploadResponse { + pub upload_id: String, + pub expires_at: String, + pub artifact: PluginArtifact, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PluginInstallation { + pub installation_id: String, + pub alias: String, + pub package_id: String, + pub last_operation: String, + pub uninstalled: bool, + pub artifacts: Vec, +} + +/// Catalog pages omit artifact/schema payloads so the item bound also bounds +/// response work. Exact immutable metadata is available from installation show. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PluginInstallationSummary { + pub installation_id: String, + pub alias: String, + pub package_id: String, + pub last_operation: String, + pub uninstalled: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PluginListResponse { + pub installations: Vec, + pub next_cursor: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum PluginOperationKind { + Install, + AddArtifact, + Revoke, + Uninstall, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PluginOperationResponse { + pub operation_id: String, + pub kind: PluginOperationKind, + pub installation: PluginInstallation, +} diff --git a/crates/server-api/src/plugin_types.rs b/crates/server-api/src/plugin_types.rs new file mode 100644 index 000000000..5799f501b --- /dev/null +++ b/crates/server-api/src/plugin_types.rs @@ -0,0 +1,66 @@ +//! Generated management types share the exact DTOs used by the HTTP declaration. +#[cfg(feature = "typescript")] +pub fn plugin_api_typescript() -> String { + use crate::*; + use ts_rs::TS; + let config = ts_rs::Config::default(); + let declarations = [ + PluginInstallRequest::decl(&config), + PluginAddArtifactRequest::decl(&config), + PluginRevokeRequest::decl(&config), + PluginUninstallRequest::decl(&config), + PluginListRequest::decl(&config), + PluginSchemaContribution::decl(&config), + PluginToolMetadata::decl(&config), + PluginHttpMethod::decl(&config), + PluginConnectionMetadata::decl(&config), + PluginGrantMetadata::decl(&config), + PluginArtifact::decl(&config), + PluginUploadResponse::decl(&config), + PluginInstallation::decl(&config), + PluginInstallationSummary::decl(&config), + PluginListResponse::decl(&config), + PluginOperationKind::decl(&config), + PluginOperationResponse::decl(&config), + ]; + format!( + "// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_plugin_api_types > web/workspace/src/lib/generated/plugin-api.ts\n\n{}\n", + declarations.map(|d| format!("export {d}")).join("\n\n") + ) +} + +#[cfg(test)] +mod tests { + #[test] + fn plugin_routes_use_binary_upload_and_closed_management_contracts() { + let document = crate::canonical_openapi_document().unwrap(); + let value: serde_json::Value = serde_json::from_str(&document.to_json().unwrap()).unwrap(); + let upload = &value["paths"]["/api/w/{workspace_id}/plugin-uploads"]["post"]; + assert!(upload["requestBody"]["content"]["application/octet-stream"].is_object()); + assert!(upload["requestBody"]["content"]["application/json"].is_null()); + for path in [ + "plugins", + "plugins/{installation_id}", + "plugin-operations/{operation_id}", + ] { + assert!(value["paths"][format!("/api/w/{{workspace_id}}/{path}")]["get"].is_object()); + } + assert!( + serde_json::from_value::(serde_json::json!({ + "upload_id":"x", "alias":"a", "operation_key":"k", "role":"owner" + })) + .is_err() + ); + assert!(value["paths"]["/api/w/{workspace_id}/plugins/{installation_id}/enable"].is_null()); + assert!(value["paths"]["/api/w/{workspace_id}/plugins/{installation_id}/data"].is_null()); + } + + #[cfg(feature = "typescript")] + #[test] + fn generated_plugin_types_are_current() { + assert_eq!( + crate::normalized_typescript(super::plugin_api_typescript()), + include_str!("../../../web/workspace/src/lib/generated/plugin-api.ts") + ); + } +} diff --git a/crates/workspace-server/src/assignment_tracking_tests.rs b/crates/workspace-server/src/assignment_tracking_tests.rs index 73b3445f0..284072ce6 100644 --- a/crates/workspace-server/src/assignment_tracking_tests.rs +++ b/crates/workspace-server/src/assignment_tracking_tests.rs @@ -479,7 +479,8 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip (102, "Durable failed Subject creation recovery".into()), (103, "Passkey metadata and atomic addition results".into()), (104, "Converge Passkey results and Job input retention".into()), - (105, "Durable Subject deletion snapshots and Job admission fences".into()) + (105, "Durable Subject deletion snapshots and Job admission fences".into()), + (106, crate::plugin_catalog::MIGRATION_NAME.into()) ] ); for (table, rows) in retained.iter().zip(original.iter()) { diff --git a/crates/workspace-server/src/implementation_result_tests.rs b/crates/workspace-server/src/implementation_result_tests.rs index 216c55787..2315a9a9f 100644 --- a/crates/workspace-server/src/implementation_result_tests.rs +++ b/crates/workspace-server/src/implementation_result_tests.rs @@ -368,10 +368,13 @@ async fn implementation_result_rejects_forged_evidence_and_rolls_back_without_no } // Preserve seeded assignment evidence while rebuilding the actual pre-business -// capture composition. Changing only a receipt leaves schema105 admission fences +// capture composition. Changing only a receipt leaves current admission fences // and erasure guards in an otherwise legacy fixture. fn reconstruct_implementation_schema96(conn: &Connection) -> Result<()> { - assert_eq!(current_schema_version(conn)?, 105); + assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION); + conn.execute_batch("DROP TABLE plugin_references; DROP TABLE plugin_operations; + DROP TABLE plugin_uploads; DROP TABLE plugin_artifacts; DROP TABLE plugin_installations; + UPDATE __yoi_schema_migrations SET version=105 WHERE version=106;")?; let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("retained96.db"); prepare_retained_schema(&path, 96); diff --git a/crates/workspace-server/src/latest_schema.sql b/crates/workspace-server/src/latest_schema.sql index 01ebf25b5..1f9f2fbc7 100644 --- a/crates/workspace-server/src/latest_schema.sql +++ b/crates/workspace-server/src/latest_schema.sql @@ -1,4 +1,5 @@ -- Canonical composed Workspace Server schema. New databases are created directly at this generation. +-- Plugin catalog schema is composed by store.rs. -- Ticket creates its standalone schema first; replace those empty tables with the composed authority. DROP TABLE typed_ticket_event_attributes; DROP TABLE typed_ticket_event_references; diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index c4a95e8c0..71b93642d 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -12,6 +12,7 @@ pub mod hosts; pub mod memory_backend; pub mod memory_staging; pub mod observation; +pub mod plugin_catalog; pub mod profile_settings; pub mod prompt_settings; pub mod records; @@ -161,6 +162,8 @@ pub enum Error { WorkerSourceIdentity(String), #[error("Workspace signing identity error ({code}): {message}")] WorkspaceSigningIdentity { code: String, message: String }, + #[error("Plugin catalog error: {0}")] + PluginCatalog(#[from] plugin_catalog::PluginCatalogError), #[error("Workspace Drive error: {0}")] Drive(#[from] workspace_drive::Error), #[error("DB execution capacity exceeded")] diff --git a/crates/workspace-server/src/notification_routing_tests.rs b/crates/workspace-server/src/notification_routing_tests.rs index 3647ab75a..f88b7e42c 100644 --- a/crates/workspace-server/src/notification_routing_tests.rs +++ b/crates/workspace-server/src/notification_routing_tests.rs @@ -851,11 +851,11 @@ async fn notification_routing_fresh_and_upgraded_schema_and_guards_match() { }; assert_eq!(current_schema_version(&conn).unwrap(), 98); drop(conn); - // Compare the complete current composition, not upgraded98 against fresh105. + // Compare the complete current composition, not upgraded98 against fresh current schema. let upgraded_store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); let upgraded = upgraded_store .with_conn(|conn| { - assert_eq!(current_schema_version(conn)?, 105); + assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION); Ok(snapshot(conn)) }) .unwrap(); diff --git a/crates/workspace-server/src/plugin_catalog.rs b/crates/workspace-server/src/plugin_catalog.rs new file mode 100644 index 000000000..ff7c211b4 --- /dev/null +++ b/crates/workspace-server/src/plugin_catalog.rs @@ -0,0 +1,733 @@ +//! Workspace catalog authority. The authenticated service checks current read/manage +//! permission before entering these methods; neither a digest nor an actor string is +//! an authorization credential. Blob publication, retention and GC share SQLite's +//! immediate write transaction, including across independent Server connections. +use crate::{Error, Result, store::SqliteWorkspaceStore}; +use chrono::{Duration, Utc}; +use manifest::plugin::{self, PluginPackageLimits, PluginSourceKind}; +use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, params}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use server_api::*; +use sha2::{Digest, Sha256}; +use std::{ + collections::BTreeSet, + fs, + io::{Read, Write}, + path::{Path, PathBuf}, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum PluginCatalogError { + #[error("invalid Plugin catalog request")] + Invalid, + #[error("Plugin catalog resource not found")] + NotFound, + #[error("Plugin catalog operation conflicts with observed state")] + Conflict, + #[error("Plugin installation has retained references")] + Referenced, + #[error("Plugin package exceeds size limits")] + TooLarge, + #[error("Plugin storage quota exceeded")] + Quota, + #[error("Plugin artifact storage unavailable")] + Unavailable, +} +impl PluginCatalogError { + pub fn status(self) -> u16 { + match self { + Self::Invalid => 400, + Self::NotFound => 404, + Self::Conflict | Self::Referenced => 409, + Self::TooLarge => 413, + Self::Quota => 429, + Self::Unavailable => 503, + } + } + pub fn code(self) -> &'static str { + match self { + Self::Invalid => "plugin_invalid", + Self::NotFound => "plugin_not_found", + Self::Conflict => "plugin_conflict", + Self::Referenced => "plugin_referenced", + Self::TooLarge => "plugin_too_large", + Self::Quota => "plugin_quota", + Self::Unavailable => "plugin_unavailable", + } + } +} + +const WORKSPACE_BYTES: i64 = 1024 * 1024 * 1024; +const ACTOR_UPLOAD_BYTES: i64 = 256 * 1024 * 1024; +const MAX_UPLOADS: i64 = 128; +const MAX_ACTOR_UPLOADS: i64 = 32; +const MAX_INSTALLATIONS: i64 = 128; +const MAX_ARTIFACTS: i64 = 64; + +pub(crate) const MIGRATION_NAME: &str = + "Workspace Plugin catalog and immutable artifact references"; +pub(crate) fn migrate(conn: &Connection) -> Result<()> { + let version: i64 = conn.query_row( + "SELECT max(version) FROM __yoi_schema_migrations", + [], + |r| r.get(0), + )?; + if version != 105 { + return Err(Error::Store( + "Plugin catalog migration requires schema 105".into(), + )); + } + let tx = conn.unchecked_transaction()?; + tx.execute_batch(include_str!("plugin_catalog_schema.sql"))?; + tx.execute( + "INSERT INTO __yoi_schema_migrations(version,name) VALUES(106,?1)", + [MIGRATION_NAME], + )?; + verify(&tx)?; + tx.commit()?; + Ok(()) +} +pub(crate) fn verify(conn: &Connection) -> Result<()> { + let expected = Connection::open_in_memory()?; + expected.execute_batch(include_str!("plugin_catalog_schema.sql"))?; + let objects = expected.prepare("SELECT name,sql FROM sqlite_schema WHERE sql IS NOT NULL AND tbl_name LIKE 'plugin_%' ORDER BY name")? + .query_map([], |r| Ok((r.get::<_,String>(0)?, r.get::<_,String>(1)?)))? + .collect::>>()?; + for (name, sql) in objects { + let actual = conn + .query_row( + "SELECT sql FROM sqlite_schema WHERE name=?1", + [&name], + |r| r.get::<_, String>(0), + ) + .optional()?; + if actual.as_deref() != Some(sql.as_str()) { + return Err(Error::Store( + "Plugin catalog storage schema has drifted".into(), + )); + } + } + Ok(()) +} +fn fail(error: PluginCatalogError) -> Result { + Err(Error::PluginCatalog(error)) +} +fn encode(value: &T) -> Result { + serde_json::to_string(value).map_err(|_| PluginCatalogError::Unavailable.into()) +} +fn decode(value: &str) -> Result { + serde_json::from_str(value).map_err(|_| PluginCatalogError::Unavailable.into()) +} +fn hash(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} +fn id(prefix: &str) -> String { + format!("{prefix}-{}", uuid::Uuid::now_v7()) +} +fn bounded(value: &str, max: usize) -> bool { + !value.is_empty() && value.len() <= max && !value.chars().any(char::is_control) +} +fn digest(value: &str) -> bool { + value.strip_prefix("sha256:").is_some_and(hex_digest) +} +fn hex_digest(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} +fn workspace_exists(conn: &Connection, workspace: &str) -> Result<()> { + if !conn.query_row( + "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id=?1)", + [workspace], + |r| r.get::<_, bool>(0), + )? { + return fail(PluginCatalogError::NotFound); + } + Ok(()) +} +fn validate_identity(actor: &str, key: &str) -> Result<()> { + if !bounded(actor, 512) || !bounded(key, 128) { + return fail(PluginCatalogError::Invalid); + } + Ok(()) +} +fn storage(result: std::io::Result) -> Result { + result.map_err(|_| PluginCatalogError::Unavailable.into()) +} +fn blob_path(root: &Path, artifact: &str) -> Result { + if !digest(artifact) { + return fail(PluginCatalogError::Invalid); + } + Ok(root.join("blobs").join(&artifact[7..])) +} +struct PendingFile(PathBuf); +impl Drop for PendingFile { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } +} +fn read_blob(root: &Path, artifact: &str, size: u64) -> Result> { + let path = blob_path(root, artifact)?; + let metadata = storage(fs::symlink_metadata(&path))?; + if !metadata.is_file() + || metadata.len() != size + || size > PluginPackageLimits::default().max_package_size_bytes + { + return fail(PluginCatalogError::Unavailable); + } + let file = storage(fs::File::open(path))?; + let mut bytes = Vec::new(); + storage(file.take(size + 1).read_to_end(&mut bytes))?; + if bytes.len() as u64 != size || format!("sha256:{}", hash(&bytes)) != artifact { + return fail(PluginCatalogError::Unavailable); + } + Ok(bytes) +} +// Caller holds SQLite write ownership until the upload reference commits. GC +// cannot unlink a candidate between publication and the first durable reference. +fn publish_blob(root: &Path, artifact: &str, bytes: &[u8]) -> Result<()> { + let path = blob_path(root, artifact)?; + storage(fs::create_dir_all(root.join("blobs")))?; + if path.exists() { + read_blob(root, artifact, bytes.len() as u64)?; + return Ok(()); + } + let pending = PendingFile(root.join("blobs").join(id(".pending"))); + let mut file = storage( + fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&pending.0), + )?; + storage(file.write_all(bytes))?; + storage(file.sync_all())?; + match fs::hard_link(&pending.0, &path) { + Ok(()) => (), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + read_blob(root, artifact, bytes.len() as u64)?; + } + Err(_) => return fail(PluginCatalogError::Unavailable), + } + storage(fs::File::open(root.join("blobs")))? + .sync_all() + .map_err(|_| Error::PluginCatalog(PluginCatalogError::Unavailable))?; + Ok(()) +} +fn artifact_metadata( + package: &plugin::MaterializedPluginPackage, + canonical: &plugin::CanonicalPluginArtifact, +) -> Result { + let m = &package.package.manifest; + let schema_digest = format!("sha256:{}", hash(m.config_schema.as_bytes())); + Ok(PluginArtifact { + package_id: m.id.clone(), + name: m.name.clone(), + version: m.version.clone(), + description: m.description.clone(), + package_digest: canonical.package_digest.clone(), + artifact_digest: canonical.artifact_digest.clone(), + artifact_size: u32::try_from(canonical.artifact_size) + .map_err(|_| PluginCatalogError::TooLarge)?, + schema_version: m.schema_version, + world: m.runtime.world.clone(), + required_imports: m.runtime.required_imports.clone(), + tools: m + .tools + .iter() + .map(|t| PluginToolMetadata { + name: t.name.clone(), + description: t.description.clone(), + input_schema: t.input_schema.clone(), + output_schema: t.output_schema.clone(), + error_codes: t.error_codes.clone(), + external_write: t.external_write, + }) + .collect(), + connections: decode(&encode(&m.connections)?)?, + requested_grants: decode(&encode(&m.requested_grants)?)?, + config_schema: PluginSchemaContribution { + identity: format!("{}/config", m.id), + version: m.version.clone(), + namespace: format!("{}/config/{}/{}", m.id, m.version, schema_digest), + digest: schema_digest, + dcdl: m.config_schema.clone(), + }, + revoked: false, + revocation_reason: None, + }) +} +fn load_artifact( + conn: &Connection, + workspace: &str, + installation: &str, + artifact: &str, +) -> Result { + let row=conn.query_row("SELECT metadata_json,revoked,revocation_reason FROM plugin_artifacts WHERE workspace_id=?1 AND installation_id=?2 AND artifact_digest=?3",params![workspace,installation,artifact],|r|Ok((r.get::<_,String>(0)?,r.get::<_,bool>(1)?,r.get::<_,Option>(2)?))).optional()?; + let Some((json, revoked, reason)) = row else { + return fail(PluginCatalogError::NotFound); + }; + let mut result: PluginArtifact = decode(&json)?; + result.revoked = revoked; + result.revocation_reason = reason; + Ok(result) +} +fn load_installation( + conn: &Connection, + workspace: &str, + installation: &str, +) -> Result { + let row=conn.query_row("SELECT alias,package_id,last_operation,uninstalled FROM plugin_installations WHERE workspace_id=?1 AND installation_id=?2",params![workspace,installation],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,r.get::<_,String>(2)?,r.get::<_,bool>(3)?))).optional()?; + let Some((alias, package_id, last_operation, uninstalled)) = row else { + return fail(PluginCatalogError::NotFound); + }; + let digests=conn.prepare("SELECT artifact_digest FROM plugin_artifacts WHERE workspace_id=?1 AND installation_id=?2 ORDER BY artifact_digest")?.query_map(params![workspace,installation],|r|r.get::<_,String>(0))?.collect::>>()?; + let artifacts = digests + .iter() + .map(|d| load_artifact(conn, workspace, installation, d)) + .collect::>>()?; + Ok(PluginInstallation { + installation_id: installation.into(), + alias, + package_id, + last_operation, + uninstalled, + artifacts, + }) +} +fn compare_installation( + conn: &Connection, + workspace: &str, + installation: &str, + expected: &str, +) -> Result { + let current = load_installation(conn, workspace, installation)?; + if current.uninstalled { + return fail(PluginCatalogError::NotFound); + } + if current.last_operation != expected { + return fail(PluginCatalogError::Conflict); + } + Ok(current) +} +fn load_upload( + conn: &Connection, + workspace: &str, + actor: &str, + upload: &str, +) -> Result<(PluginArtifact, i64)> { + let row=conn.query_row("SELECT metadata_json,expires_at FROM plugin_uploads WHERE workspace_id=?1 AND actor=?2 AND upload_id=?3",params![workspace,actor,upload],|r|Ok((r.get::<_,String>(0)?,r.get::<_,i64>(1)?))).optional()?; + let Some((json, expires)) = row else { + return fail(PluginCatalogError::NotFound); + }; + Ok((decode(&json)?, expires)) +} +fn kind_name(kind: PluginOperationKind) -> &'static str { + match kind { + PluginOperationKind::Install => "install", + PluginOperationKind::AddArtifact => "add_artifact", + PluginOperationKind::Revoke => "revoke", + PluginOperationKind::Uninstall => "uninstall", + } +} +fn replay( + conn: &Connection, + workspace: &str, + actor: &str, + kind: PluginOperationKind, + key: &str, + fingerprint: &str, +) -> Result> { + let saved=conn.query_row("SELECT fingerprint,result_json FROM plugin_operations WHERE workspace_id=?1 AND actor=?2 AND kind=?3 AND operation_key=?4",params![workspace,actor,kind_name(kind),key],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?))).optional()?; + if let Some((old, json)) = saved { + if old != fingerprint { + return fail(PluginCatalogError::Conflict); + } + return Ok(Some(decode(&json)?)); + } + Ok(None) +} +fn save_operation( + tx: &Transaction<'_>, + workspace: &str, + actor: &str, + key: &str, + fingerprint: &str, + kind: PluginOperationKind, + operation: &str, + installation: &str, +) -> Result { + tx.execute("UPDATE plugin_installations SET last_operation=?3 WHERE workspace_id=?1 AND installation_id=?2",params![workspace,installation,operation])?; + let result = PluginOperationResponse { + operation_id: operation.into(), + kind, + installation: load_installation(tx, workspace, installation)?, + }; + tx.execute("INSERT INTO plugin_operations(workspace_id,operation_id,actor,kind,operation_key,fingerprint,result_json) VALUES(?1,?2,?3,?4,?5,?6,?7)",params![workspace,operation,actor,kind_name(kind),key,fingerprint,encode(&result)?])?; + Ok(result) +} +fn add_artifact( + tx: &Transaction<'_>, + workspace: &str, + installation: &str, + artifact: &PluginArtifact, +) -> Result<()> { + let existing=tx.query_row("SELECT artifact_digest,package_digest,revoked FROM plugin_artifacts WHERE workspace_id=?1 AND installation_id=?2 AND version=?3",params![workspace,installation,artifact.version],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,r.get::<_,bool>(2)?))).optional()?; + if let Some((old, package, revoked)) = existing { + if old != artifact.artifact_digest || package != artifact.package_digest || revoked { + return fail(PluginCatalogError::Conflict); + } + return Ok(()); + } + let count: i64 = tx.query_row( + "SELECT count(*) FROM plugin_artifacts WHERE workspace_id=?1 AND installation_id=?2", + params![workspace, installation], + |r| r.get(0), + )?; + if count >= MAX_ARTIFACTS { + return fail(PluginCatalogError::Quota); + } + check_workspace_bytes(tx, workspace, artifact.artifact_size as i64)?; + tx.execute("INSERT INTO plugin_artifacts(workspace_id,installation_id,artifact_digest,package_digest,version,artifact_size,metadata_json) VALUES(?1,?2,?3,?4,?5,?6,?7)",params![workspace,installation,artifact.artifact_digest,artifact.package_digest,artifact.version,artifact.artifact_size as i64,encode(artifact)?])?; + Ok(()) +} +fn check_workspace_bytes(conn: &Connection, workspace: &str, additional: i64) -> Result<()> { + let bytes:i64=conn.query_row("SELECT (SELECT coalesce(sum(a.artifact_size),0) FROM plugin_artifacts a JOIN plugin_installations i USING(workspace_id,installation_id) WHERE a.workspace_id=?1 AND i.uninstalled=0) + (SELECT coalesce(sum(artifact_size),0) FROM plugin_uploads WHERE workspace_id=?1 AND expires_at>?2)",params![workspace,Utc::now().timestamp()],|r|r.get(0))?; + if bytes + additional > WORKSPACE_BYTES { + return fail(PluginCatalogError::Quota); + } + Ok(()) +} + +impl SqliteWorkspaceStore { + pub fn plugin_upload( + &self, + root: &Path, + workspace: &str, + actor: &str, + bytes: &[u8], + ) -> Result { + if !bounded(actor, 512) { + return fail(PluginCatalogError::Invalid); + } + let limits = PluginPackageLimits::default(); + if bytes.len() as u64 > limits.max_package_size_bytes { + return fail(PluginCatalogError::TooLarge); + } + let package = plugin::read_plugin_package_bytes(bytes, PluginSourceKind::Explicit, &limits) + .map_err(|e| { + if e.kind == plugin::PluginDiagnosticKind::Bounds { + PluginCatalogError::TooLarge + } else { + PluginCatalogError::Invalid + } + })?; + let canonical = plugin::pack_plugin_package(&package, &limits) + .map_err(|_| PluginCatalogError::Invalid)?; + let artifact = artifact_metadata(&package, &canonical)?; + self.with_conn_mut(|conn| { + let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + workspace_exists(&tx,workspace)?; + let now=Utc::now(); + let (count,own,own_bytes):(i64,i64,i64)=tx.query_row("SELECT count(*),coalesce(sum(actor=?2),0),coalesce(sum(CASE WHEN actor=?2 THEN artifact_size ELSE 0 END),0) FROM plugin_uploads WHERE workspace_id=?1 AND expires_at>?3",params![workspace,actor,now.timestamp()],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?)))?; + if count>=MAX_UPLOADS || own>=MAX_ACTOR_UPLOADS || own_bytes+canonical.artifact_size as i64>ACTOR_UPLOAD_BYTES {return fail(PluginCatalogError::Quota);} + check_workspace_bytes(&tx,workspace,canonical.artifact_size as i64)?; + publish_blob(root,&canonical.artifact_digest,&canonical.bytes)?; + let upload_id=id("pu"); let expires=now+Duration::hours(24); + tx.execute("INSERT INTO plugin_uploads(workspace_id,upload_id,actor,expires_at,artifact_digest,artifact_size,metadata_json) VALUES(?1,?2,?3,?4,?5,?6,?7)",params![workspace,upload_id,actor,expires.timestamp(),artifact.artifact_digest,artifact.artifact_size as i64,encode(&artifact)?])?; + tx.commit()?; + Ok(PluginUploadResponse{upload_id,expires_at:expires.to_rfc3339_opts(chrono::SecondsFormat::Secs,true),artifact:artifact.clone()}) + }) + } + pub fn plugin_install( + &self, + workspace: &str, + actor: &str, + request: &PluginInstallRequest, + ) -> Result { + validate_identity(actor, &request.operation_key)?; + if !bounded(&request.alias, 128) || request.alias.trim() != request.alias { + return fail(PluginCatalogError::Invalid); + } + self.with_conn_mut(|conn| { + let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let (artifact,expires)=load_upload(&tx,workspace,actor,&request.upload_id)?; + let fingerprint=hash(encode(&(request,&artifact.package_digest,&artifact.artifact_digest))?.as_bytes()); + if let Some(saved)=replay(&tx,workspace,actor,PluginOperationKind::Install,&request.operation_key,&fingerprint)? {return Ok(saved);} + if expires<=Utc::now().timestamp() {return fail(PluginCatalogError::NotFound);} + let (count,alias):(i64,bool)=tx.query_row("SELECT count(*),coalesce(max(alias=?2),0) FROM plugin_installations WHERE workspace_id=?1 AND uninstalled=0",params![workspace,request.alias],|r|Ok((r.get(0)?,r.get(1)?)))?; + if alias {return fail(PluginCatalogError::Conflict);} + if count>=MAX_INSTALLATIONS {return fail(PluginCatalogError::Quota);} + let installation=id("pi"); let operation=id("po"); + tx.execute("INSERT INTO plugin_installations(workspace_id,installation_id,alias,package_id,last_operation) VALUES(?1,?2,?3,?4,?5)",params![workspace,installation,request.alias,artifact.package_id,operation])?; + add_artifact(&tx,workspace,&installation,&artifact)?; + let result=save_operation(&tx,workspace,actor,&request.operation_key,&fingerprint,PluginOperationKind::Install,&operation,&installation)?; + tx.commit()?; Ok(result) + }) + } + pub fn plugin_add_artifact( + &self, + workspace: &str, + actor: &str, + installation: &str, + request: &PluginAddArtifactRequest, + ) -> Result { + validate_identity(actor, &request.operation_key)?; + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let (artifact, expires) = load_upload(&tx, workspace, actor, &request.upload_id)?; + let fingerprint = hash( + encode(&( + installation, + request, + &artifact.package_digest, + &artifact.artifact_digest, + ))? + .as_bytes(), + ); + if let Some(saved) = replay( + &tx, + workspace, + actor, + PluginOperationKind::AddArtifact, + &request.operation_key, + &fingerprint, + )? { + return Ok(saved); + } + if expires <= Utc::now().timestamp() { + return fail(PluginCatalogError::NotFound); + } + let current = compare_installation( + &tx, + workspace, + installation, + &request.expected_last_operation, + )?; + if current.package_id != artifact.package_id { + return fail(PluginCatalogError::Conflict); + } + add_artifact(&tx, workspace, installation, &artifact)?; + let result = save_operation( + &tx, + workspace, + actor, + &request.operation_key, + &fingerprint, + PluginOperationKind::AddArtifact, + &id("po"), + installation, + )?; + tx.commit()?; + Ok(result) + }) + } + pub fn plugin_revoke( + &self, + workspace: &str, + actor: &str, + installation: &str, + request: &PluginRevokeRequest, + ) -> Result { + validate_identity(actor, &request.operation_key)?; + if !digest(&request.artifact_digest) || !bounded(&request.reason, 1024) { + return fail(PluginCatalogError::Invalid); + } + self.with_conn_mut(|conn| { + let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let fingerprint=hash(encode(&(installation,request))?.as_bytes()); + if let Some(saved)=replay(&tx,workspace,actor,PluginOperationKind::Revoke,&request.operation_key,&fingerprint)? {return Ok(saved);} + compare_installation(&tx,workspace,installation,&request.expected_last_operation)?; + let artifact=load_artifact(&tx,workspace,installation,&request.artifact_digest)?; + if artifact.revoked {return fail(PluginCatalogError::Conflict);} + tx.execute("UPDATE plugin_artifacts SET revoked=1,revocation_reason=?4 WHERE workspace_id=?1 AND installation_id=?2 AND artifact_digest=?3",params![workspace,installation,request.artifact_digest,request.reason])?; + let result=save_operation(&tx,workspace,actor,&request.operation_key,&fingerprint,PluginOperationKind::Revoke,&id("po"),installation)?; + tx.commit()?; Ok(result) + }) + } + pub fn plugin_uninstall( + &self, + workspace: &str, + actor: &str, + installation: &str, + request: &PluginUninstallRequest, + ) -> Result { + validate_identity(actor, &request.operation_key)?; + self.with_conn_mut(|conn| { + let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let fingerprint=hash(encode(&(installation,request))?.as_bytes()); + if let Some(saved)=replay(&tx,workspace,actor,PluginOperationKind::Uninstall,&request.operation_key,&fingerprint)? {return Ok(saved);} + compare_installation(&tx,workspace,installation,&request.expected_last_operation)?; + let referenced:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM plugin_references WHERE workspace_id=?1 AND installation_id=?2)",params![workspace,installation],|r|r.get(0))?; + if referenced {return fail(PluginCatalogError::Referenced);} + tx.execute("UPDATE plugin_installations SET uninstalled=1 WHERE workspace_id=?1 AND installation_id=?2",params![workspace,installation])?; + let result=save_operation(&tx,workspace,actor,&request.operation_key,&fingerprint,PluginOperationKind::Uninstall,&id("po"),installation)?; + tx.commit()?; Ok(result) + }) + } + pub fn plugin_show(&self, workspace: &str, installation: &str) -> Result { + self.with_conn(|conn| { + let tx = conn.unchecked_transaction()?; + let result = load_installation(&tx, workspace, installation)?; + tx.commit()?; + Ok(result) + }) + } + pub fn plugin_operation( + &self, + workspace: &str, + operation: &str, + ) -> Result { + self.with_conn(|conn| { + let json=conn.query_row("SELECT result_json FROM plugin_operations WHERE workspace_id=?1 AND operation_id=?2",params![workspace,operation],|r|r.get::<_,String>(0)).optional()?; + match json {Some(json)=>decode(&json),None=>fail(PluginCatalogError::NotFound)} + }) + } + pub fn plugin_list( + &self, + workspace: &str, + request: &PluginListRequest, + ) -> Result { + let limit = request.limit.unwrap_or(50); + if limit == 0 || limit > 100 { + return fail(PluginCatalogError::Invalid); + } + self.with_conn(|conn| { + let tx = conn.unchecked_transaction()?; + let conn = &tx; + workspace_exists(conn,workspace)?; + let after=if let Some(cursor)=&request.cursor { + // Server generated operation identity is an opaque cursor scoped by + // a persisted membership check, never interpreted as an arbitrary offset. + let value=conn.query_row("SELECT json_extract(result_json,'$.installation.installation_id') FROM plugin_operations WHERE workspace_id=?1 AND operation_id=?2",params![workspace,cursor],|r|r.get::<_,String>(0)).optional()?; + value.ok_or(PluginCatalogError::Invalid)? + } else {String::new()}; + let mut installations=conn.prepare("SELECT installation_id,alias,package_id,last_operation,uninstalled FROM plugin_installations WHERE workspace_id=?1 AND installation_id>?2 ORDER BY installation_id LIMIT ?3")?.query_map(params![workspace,after,limit+1],|r|Ok(PluginInstallationSummary{installation_id:r.get(0)?,alias:r.get(1)?,package_id:r.get(2)?,last_operation:r.get(3)?,uninstalled:r.get(4)?}))?.collect::>>()?; + let more=installations.len()>limit as usize; + installations.truncate(limit as usize); + let next_cursor=if more {installations.last().map(|i|i.last_operation.clone())} else {None}; + tx.commit()?; + Ok(PluginListResponse{installations,next_cursor}) + }) + } + pub fn plugin_artifact_bytes( + &self, + root: &Path, + workspace: &str, + installation: &str, + artifact_digest: &str, + ) -> Result> { + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let current = load_installation(&tx, workspace, installation)?; + if current.uninstalled { + return fail(PluginCatalogError::NotFound); + } + let artifact = load_artifact(&tx, workspace, installation, artifact_digest)?; + if artifact.revoked { + return fail(PluginCatalogError::Conflict); + } + let bytes = read_blob(root, artifact_digest, u64::from(artifact.artifact_size))?; + tx.commit()?; + Ok(bytes) + }) + } + pub fn plugin_gc(&self, root: &Path) -> Result { + self.with_conn_mut(|conn| { + let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let retained=tx.prepare("SELECT a.artifact_digest FROM plugin_artifacts a JOIN plugin_installations i USING(workspace_id,installation_id) WHERE i.uninstalled=0 UNION SELECT artifact_digest FROM plugin_uploads WHERE expires_at>?1")?.query_map([Utc::now().timestamp()],|r|r.get::<_,String>(0))?.collect::>>()?; + let entries=match fs::read_dir(root.join("blobs")) {Ok(entries)=>entries,Err(e) if e.kind()==std::io::ErrorKind::NotFound=>return Ok(0),Err(_)=>return fail(PluginCatalogError::Unavailable)}; + let mut removed=0; + for entry in entries { + let entry=storage(entry)?; let name=entry.file_name(); let Some(name)=name.to_str() else {continue;}; + if (hex_digest(name) || name.starts_with(".pending-")) && !retained.contains(&format!("sha256:{name}")) { + storage(fs::remove_file(entry.path()))?; removed+=1; + } + } + storage(fs::File::open(root.join("blobs")))?.sync_all().map_err(|_|Error::PluginCatalog(PluginCatalogError::Unavailable))?; + tx.commit()?; Ok(removed) + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PluginReferenceKind { + Config, + Pending, + Active, + Restorable, +} +impl PluginReferenceKind { + fn name(self) -> &'static str { + match self { + Self::Config => "config", + Self::Pending => "pending", + Self::Active => "active", + Self::Restorable => "restorable", + } + } +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginReference { + pub kind: PluginReferenceKind, + pub owner: String, + pub installation_id: String, + pub expected_last_operation: String, + pub package_digest: String, + pub artifact_digest: String, +} +/// Called inside the config/plan owner's transaction. The same write transaction +/// must commit that owner's content CAS and its reference changes. This function +/// does not persist a second copy of config or policy. +pub fn plugin_artifact_admit( + tx: &Transaction<'_>, + workspace: &str, + installation: &str, + expected_last_operation: &str, + package_digest: &str, + artifact_digest: &str, +) -> Result { + compare_installation(tx, workspace, installation, expected_last_operation)?; + let artifact = load_artifact(tx, workspace, installation, artifact_digest)?; + if artifact.revoked || artifact.package_digest != package_digest { + return fail(PluginCatalogError::Conflict); + } + Ok(artifact) +} +pub fn plugin_reference_put( + tx: &Transaction<'_>, + workspace: &str, + reference: &PluginReference, +) -> Result<()> { + if !bounded(&reference.owner, 512) { + return fail(PluginCatalogError::Invalid); + } + plugin_artifact_admit( + tx, + workspace, + &reference.installation_id, + &reference.expected_last_operation, + &reference.package_digest, + &reference.artifact_digest, + )?; + tx.execute("INSERT INTO plugin_references(workspace_id,kind,owner,installation_id,artifact_digest) VALUES(?1,?2,?3,?4,?5) ON CONFLICT DO NOTHING",params![workspace,reference.kind.name(),reference.owner,reference.installation_id,reference.artifact_digest])?; + Ok(()) +} +pub fn plugin_reference_remove( + tx: &Transaction<'_>, + workspace: &str, + kind: PluginReferenceKind, + owner: &str, + installation: &str, + artifact_digest: &str, +) -> Result<()> { + tx.execute("DELETE FROM plugin_references WHERE workspace_id=?1 AND kind=?2 AND owner=?3 AND installation_id=?4 AND artifact_digest=?5",params![workspace,kind.name(),owner,installation,artifact_digest])?; + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/workspace-server/src/plugin_catalog/migration_tests.rs b/crates/workspace-server/src/plugin_catalog/migration_tests.rs new file mode 100644 index 000000000..839bede2b --- /dev/null +++ b/crates/workspace-server/src/plugin_catalog/migration_tests.rs @@ -0,0 +1,81 @@ +//! Exercise production migration/open against retained SQLite fixtures, including +//! the oldest retained transition, marker failure rollback and real FK/CHECKs. +use super::*; + +fn catalog_schema(conn: &Connection) -> Vec<(String, String)> { + conn.prepare("SELECT name,sql FROM sqlite_schema WHERE sql IS NOT NULL AND tbl_name LIKE 'plugin_%' ORDER BY name").unwrap().query_map([],|r|Ok((r.get(0)?,r.get(1)?))).unwrap().collect::>>().unwrap() +} +#[test] +fn plugin_catalog_migration_matches_fresh_schema_from_oldest_retained_and_105() { + let dir = tempfile::tempdir().unwrap(); + let fresh = SqliteWorkspaceStore::open_for_test(dir.path().join("fresh.db")).unwrap(); + let canonical = fresh.with_conn(|c| Ok(catalog_schema(c))).unwrap(); + for version in [50, 105] { + let path = dir.path().join(format!("v{version}.db")); + if version == 50 { + tests::prepare_schema_v50(&path, Some("workspace-a")); + } else { + tests::prepare_retained_schema(&path, version); + } + let conn = Connection::open(&path).unwrap(); + configure_sqlite(&conn).unwrap(); + let before = workspace_schema_migration_history(&conn).unwrap(); + conn.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('plugin-owner','user','plugin-owner','Owner','1','1'); INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('plugin-workspace','plugin-owner','Plugins','active','1','1');").unwrap(); + drop(conn); + let migrated = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + migrated.with_conn(|c| { + assert_eq!(catalog_schema(c),canonical); + let after=workspace_schema_migration_history(c)?; + assert!(after.starts_with(&before)); + assert_eq!(after.last().unwrap(),&(106,crate::plugin_catalog::MIGRATION_NAME.into())); + assert_eq!(c.query_row("SELECT display_name FROM workspaces WHERE workspace_id='plugin-workspace'",[],|r|r.get::<_,String>(0))?,"Plugins"); + assert_eq!(c.query_row("SELECT count(*) FROM pragma_foreign_key_check",[],|r|r.get::<_,i64>(0))?,0); + assert!(c.execute("INSERT INTO plugin_installations VALUES('absent','i','alias','p','operation',0)",[]).is_err()); + c.execute("INSERT INTO plugin_installations VALUES('plugin-workspace','i','alias','p','operation',0)",[])?; + assert!(c.execute("INSERT INTO plugin_installations VALUES('plugin-workspace','other','alias','p','operation',0)",[]).is_err()); + assert!(c.execute("INSERT INTO plugin_installations VALUES('plugin-workspace','other','other','p','operation',2)",[]).is_err()); + assert!(c.execute("INSERT INTO plugin_references VALUES('plugin-workspace','active','owner','i','unknown')",[]).is_err()); + Ok(()) + }).unwrap(); + } +} +#[test] +fn plugin_catalog_migration_marker_failure_rolls_back_then_retries_without_changing_applied() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("retained.db"); + tests::prepare_retained_schema(&path, 105); + let conn = Connection::open(&path).unwrap(); + configure_sqlite(&conn).unwrap(); + let before = workspace_schema_migration_history(&conn).unwrap(); + conn.execute_batch("CREATE TRIGGER interrupt_plugin_migration BEFORE INSERT ON __yoi_schema_migrations WHEN NEW.version=106 BEGIN SELECT RAISE(ABORT,'injected plugin migration interruption'); END;").unwrap(); + assert!(SqliteWorkspaceStore::open_for_test(&path).is_err()); + assert_eq!(current_schema_version(&conn).unwrap(), 105); + assert_eq!(workspace_schema_migration_history(&conn).unwrap(), before); + assert!(catalog_schema(&conn).is_empty()); + conn.execute_batch("DROP TRIGGER interrupt_plugin_migration") + .unwrap(); + let migrated = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + migrated + .with_conn(|c| { + assert_eq!(current_schema_version(c)?, 106); + crate::plugin_catalog::verify(c)?; + Ok(()) + }) + .unwrap(); +} +#[test] +fn plugin_catalog_startup_rejects_drifted_reference_fence() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("fresh.db"); + let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + store + .with_conn(|c| { + c.execute_batch("DROP TRIGGER plugin_reference_live")?; + Ok(()) + }) + .unwrap(); + drop(store); + assert!( + matches!(SqliteWorkspaceStore::open_for_test(path),Err(Error::Store(message)) if message.contains("Plugin catalog storage schema has drifted")) + ); +} diff --git a/crates/workspace-server/src/plugin_catalog/tests.rs b/crates/workspace-server/src/plugin_catalog/tests.rs new file mode 100644 index 000000000..da893a6a4 --- /dev/null +++ b/crates/workspace-server/src/plugin_catalog/tests.rs @@ -0,0 +1,911 @@ +//! Real SQLite plus disk-component tests. Only authentication/HTTP and execution +//! are outside this suite; packages use the checked-in real Rust PDK Component. +use super::*; +use crate::store::{ControlPlaneStore, WorkspaceRecord}; +use tempfile::{TempDir, tempdir}; + +struct Fixture { + dir: TempDir, + store: SqliteWorkspaceStore, + bytes: Vec, +} +impl Fixture { + async fn new() -> Self { + let dir = tempdir().unwrap(); + let store = SqliteWorkspaceStore::open_for_test(dir.path().join("server.db")).unwrap(); + for workspace in ["a", "b"] { + store + .upsert_workspace(&WorkspaceRecord { + workspace_id: workspace.into(), + owner_account_id: "owner-account".into(), + display_name: workspace.into(), + state: "active".into(), + created_at: "2026-10-01T00:00:00Z".into(), + updated_at: "2026-10-01T00:00:00Z".into(), + }) + .await + .unwrap(); + } + Self { + dir, + store, + bytes: package("0.1.0", ""), + } + } + fn root(&self) -> PathBuf { + self.dir.path().join("artifacts") + } + fn upload(&self) -> PluginUploadResponse { + self.store + .plugin_upload(&self.root(), "a", "actor", &self.bytes) + .unwrap() + } + fn install(&self, upload: &PluginUploadResponse, alias: &str) -> PluginOperationResponse { + self.store + .plugin_install( + "a", + "actor", + &PluginInstallRequest { + upload_id: upload.upload_id.clone(), + alias: alias.into(), + operation_key: alias.into(), + }, + ) + .unwrap() + } + fn expire(&self) { + self.store + .with_conn(|c| { + c.execute("UPDATE plugin_uploads SET expires_at=0", [])?; + Ok(()) + }) + .unwrap(); + } + fn count(&self, table: &str) -> i64 { + self.store + .with_conn(|c| { + Ok(c.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))?) + }) + .unwrap() + } +} +fn package(version: &str, extra: &str) -> Vec { + let dir = tempdir().unwrap(); + let manifest = + include_str!("../../../../resources/plugin/templates/rust-component-tool/plugin.toml") + .replace("version = \"0.1.0\"", &format!("version = \"{version}\"")); + fs::write(dir.path().join("plugin.toml"), manifest).unwrap(); + fs::write( + dir.path().join("plugin.component.wasm"), + include_bytes!("../../../plugin-pdk/tests/fixtures/tool-v2.component.wasm"), + ) + .unwrap(); + if !extra.is_empty() { + fs::write(dir.path().join("extra.txt"), extra).unwrap(); + } + let parsed = plugin::read_plugin_directory( + dir.path(), + PluginSourceKind::Explicit, + &PluginPackageLimits::default(), + ) + .unwrap(); + plugin::pack_plugin_package(&parsed, &PluginPackageLimits::default()) + .unwrap() + .bytes +} +fn error(result: Result, expected: PluginCatalogError) { + assert!( + matches!(result,Err(Error::PluginCatalog(actual)) if actual==expected), + "expected {expected:?}, got {result:?}" + ); +} +fn reference(result: &PluginOperationResponse, kind: PluginReferenceKind) -> PluginReference { + let artifact = &result.installation.artifacts[0]; + PluginReference { + kind, + owner: "owner/config-or-plan".into(), + installation_id: result.installation.installation_id.clone(), + expected_last_operation: result.operation_id.clone(), + package_digest: artifact.package_digest.clone(), + artifact_digest: artifact.artifact_digest.clone(), + } +} +fn uninstall_request(result: &PluginOperationResponse, key: &str) -> PluginUninstallRequest { + PluginUninstallRequest { + expected_last_operation: result.operation_id.clone(), + operation_key: key.into(), + } +} + +#[tokio::test] +async fn validated_upload_is_not_install_and_exact_published_bytes_survive_restart() { + let f = Fixture::new().await; + let upload = f.upload(); + assert_eq!(f.count("plugin_installations"), 0); + let installed = f.install(&upload, "echo"); + let installation = &installed.installation.installation_id; + let artifact = &upload.artifact; + assert_ne!(artifact.package_digest, artifact.artifact_digest); + assert_eq!( + artifact.config_schema.digest, + format!("sha256:{}", hash(artifact.config_schema.dcdl.as_bytes())) + ); + let fetched = f + .store + .plugin_artifact_bytes(&f.root(), "a", installation, &artifact.artifact_digest) + .unwrap(); + assert_eq!(fetched, f.bytes); + assert_eq!(fetched.len() as u64, u64::from(artifact.artifact_size)); + plugin::verify_plugin_artifact( + &fetched, + &artifact.package_digest, + &artifact.artifact_digest, + u64::from(artifact.artifact_size), + &PluginPackageLimits::default(), + ) + .unwrap(); + let reopened = SqliteWorkspaceStore::open_for_test(f.dir.path().join("server.db")).unwrap(); + assert_eq!( + reopened.plugin_show("a", installation).unwrap(), + installed.installation + ); + assert_eq!( + reopened + .plugin_artifact_bytes(&f.root(), "a", installation, &artifact.artifact_digest) + .unwrap(), + fetched + ); + error( + reopened.plugin_artifact_bytes(&f.root(), "b", installation, &artifact.artifact_digest), + PluginCatalogError::NotFound, + ); + error( + reopened.plugin_operation("b", &installed.operation_id), + PluginCatalogError::NotFound, + ); + error( + reopened.plugin_show("b", installation), + PluginCatalogError::NotFound, + ); +} + +#[tokio::test] +async fn replay_returns_original_receipt_not_latest_catalog_and_survives_uninstall() { + let f = Fixture::new().await; + let upload = f.upload(); + let first = f.install(&upload, "echo"); + let mut request = PluginInstallRequest { + upload_id: upload.upload_id.clone(), + alias: "echo".into(), + operation_key: "echo".into(), + }; + assert_eq!( + f.store.plugin_install("a", "actor", &request).unwrap(), + first + ); + request.alias = "changed".into(); + error( + f.store.plugin_install("a", "actor", &request), + PluginCatalogError::Conflict, + ); + request.alias = "echo".into(); + error( + f.store.plugin_install("a", "other-actor", &request), + PluginCatalogError::NotFound, + ); + let next = f + .store + .plugin_upload(&f.root(), "a", "actor", &package("0.2.0", "")) + .unwrap(); + let added = f + .store + .plugin_add_artifact( + "a", + "actor", + &first.installation.installation_id, + &PluginAddArtifactRequest { + upload_id: next.upload_id, + expected_last_operation: first.operation_id.clone(), + operation_key: "add".into(), + }, + ) + .unwrap(); + assert_eq!( + f.store.plugin_install("a", "actor", &request).unwrap(), + first + ); + error( + f.store.plugin_uninstall( + "a", + "actor", + &first.installation.installation_id, + &uninstall_request(&first, "remove"), + ), + PluginCatalogError::Conflict, + ); + let uninstalled = f + .store + .plugin_uninstall( + "a", + "actor", + &first.installation.installation_id, + &uninstall_request(&added, "remove"), + ) + .unwrap(); + assert!(uninstalled.installation.uninstalled); + f.expire(); + f.store.plugin_gc(&f.root()).unwrap(); + assert_eq!( + f.store.plugin_install("a", "actor", &request).unwrap(), + first + ); + assert_eq!( + f.store + .plugin_uninstall( + "a", + "actor", + &first.installation.installation_id, + &uninstall_request(&added, "remove") + ) + .unwrap(), + uninstalled + ); + assert_eq!( + f.store.plugin_operation("a", &first.operation_id).unwrap(), + first + ); + assert_eq!(f.count("plugin_operations"), 3); + assert_eq!(f.count("plugin_artifacts"), 2); +} + +#[tokio::test] +async fn version_collision_and_revoke_are_not_overwrite_or_reactivation() { + let f = Fixture::new().await; + let upload = f.upload(); + let first = f.install(&upload, "echo"); + let installation = &first.installation.installation_id; + let collision = f + .store + .plugin_upload( + &f.root(), + "a", + "actor", + &package("0.1.0", "different bytes"), + ) + .unwrap(); + error( + f.store.plugin_add_artifact( + "a", + "actor", + installation, + &PluginAddArtifactRequest { + upload_id: collision.upload_id, + expected_last_operation: first.operation_id.clone(), + operation_key: "collide".into(), + }, + ), + PluginCatalogError::Conflict, + ); + let revoked = f + .store + .plugin_revoke( + "a", + "actor", + installation, + &PluginRevokeRequest { + artifact_digest: upload.artifact.artifact_digest.clone(), + expected_last_operation: first.operation_id.clone(), + reason: "withdrawn".into(), + operation_key: "revoke".into(), + }, + ) + .unwrap(); + assert!(revoked.installation.artifacts[0].revoked); + let add = PluginAddArtifactRequest { + upload_id: upload.upload_id.clone(), + expected_last_operation: revoked.operation_id.clone(), + operation_key: "reactivate".into(), + }; + error( + f.store + .plugin_add_artifact("a", "actor", installation, &add), + PluginCatalogError::Conflict, + ); + error( + f.store.plugin_artifact_bytes( + &f.root(), + "a", + installation, + &upload.artifact.artifact_digest, + ), + PluginCatalogError::Conflict, + ); + let mut pin = reference(&revoked, PluginReferenceKind::Restorable); + f.store + .with_conn_mut(|c| { + let tx = c.transaction()?; + error( + plugin_reference_put(&tx, "a", &pin), + PluginCatalogError::Conflict, + ); + Ok(()) + }) + .unwrap(); + let other = f.install(&upload, "new-installation"); + assert_ne!(other.installation.installation_id, *installation); + pin.installation_id = other.installation.installation_id.clone(); + pin.expected_last_operation = other.operation_id; + f.store + .with_conn_mut(|c| { + let tx = c.transaction()?; + plugin_reference_put(&tx, "a", &pin)?; + tx.commit()?; + Ok(()) + }) + .unwrap(); + let rejected=f.store.with_conn(|c|{c.execute("UPDATE plugin_artifacts SET revoked=0,revocation_reason=NULL WHERE workspace_id='a' AND installation_id=?1",[installation])?;Ok(())}); + assert!(matches!(rejected, Err(Error::Sqlite(_)))); +} + +#[tokio::test] +async fn references_compose_with_owner_transaction_rollback_and_fence_all_lifetimes() { + let f = Fixture::new().await; + let upload = f.upload(); + let first = f.install(&upload, "echo"); + let installation = &first.installation.installation_id; + f.store + .with_conn_mut(|c| { + c.execute( + "CREATE TABLE fixture_config_commits (content_digest TEXT PRIMARY KEY)", + [], + )?; + let tx = c.transaction_with_behavior(TransactionBehavior::Immediate)?; + tx.execute( + "INSERT INTO fixture_config_commits VALUES ('uncommitted')", + [], + )?; + plugin_reference_put(&tx, "a", &reference(&first, PluginReferenceKind::Config))?; + tx.rollback()?; + Ok(()) + }) + .unwrap(); + assert_eq!(f.count("plugin_references"), 0); + assert_eq!(f.count("fixture_config_commits"), 0); + for kind in [ + PluginReferenceKind::Config, + PluginReferenceKind::Pending, + PluginReferenceKind::Active, + PluginReferenceKind::Restorable, + ] { + f.store + .with_conn_mut(|c| { + let tx = c.transaction_with_behavior(TransactionBehavior::Immediate)?; + plugin_reference_put(&tx, "a", &reference(&first, kind))?; + tx.commit()?; + Ok(()) + }) + .unwrap(); + error( + f.store.plugin_uninstall( + "a", + "actor", + installation, + &uninstall_request(&first, "remove"), + ), + PluginCatalogError::Referenced, + ); + } + let mut stale = reference(&first, PluginReferenceKind::Config); + stale.expected_last_operation = "stale".into(); + f.store + .with_conn_mut(|c| { + let tx = c.transaction()?; + error( + plugin_reference_put(&tx, "a", &stale), + PluginCatalogError::Conflict, + ); + stale.expected_last_operation = first.operation_id.clone(); + stale.package_digest = "0".repeat(64); + error( + plugin_reference_put(&tx, "a", &stale), + PluginCatalogError::Conflict, + ); + error( + plugin_reference_put(&tx, "b", &reference(&first, PluginReferenceKind::Config)), + PluginCatalogError::NotFound, + ); + Ok(()) + }) + .unwrap(); + let revoked = f + .store + .plugin_revoke( + "a", + "actor", + installation, + &PluginRevokeRequest { + artifact_digest: upload.artifact.artifact_digest.clone(), + expected_last_operation: first.operation_id.clone(), + operation_key: "revoke".into(), + reason: "withdrawn".into(), + }, + ) + .unwrap(); + assert_eq!(f.count("plugin_references"), 4); + error( + f.store.plugin_uninstall( + "a", + "actor", + installation, + &uninstall_request(&revoked, "remove"), + ), + PluginCatalogError::Referenced, + ); + f.store + .with_conn_mut(|c| { + let tx = c.transaction()?; + for kind in [ + PluginReferenceKind::Config, + PluginReferenceKind::Pending, + PluginReferenceKind::Active, + PluginReferenceKind::Restorable, + ] { + plugin_reference_remove( + &tx, + "a", + kind, + "owner/config-or-plan", + installation, + &upload.artifact.artifact_digest, + )?; + } + tx.commit()?; + Ok(()) + }) + .unwrap(); + let removed = f + .store + .plugin_uninstall( + "a", + "actor", + installation, + &uninstall_request(&revoked, "remove"), + ) + .unwrap(); + f.store + .with_conn_mut(|c| { + let tx = c.transaction()?; + let mut pin = reference(&removed, PluginReferenceKind::Pending); + pin.expected_last_operation = removed.operation_id.clone(); + error( + plugin_reference_put(&tx, "a", &pin), + PluginCatalogError::NotFound, + ); + Ok(()) + }) + .unwrap(); +} + +#[tokio::test] +async fn database_failure_rolls_back_domain_and_receipt_then_exact_retry_succeeds() { + let f = Fixture::new().await; + let upload = f.upload(); + f.store.with_conn(|c|{c.execute_batch("CREATE TRIGGER fail_plugin_receipt BEFORE INSERT ON plugin_operations BEGIN SELECT RAISE(ABORT,'injected receipt failure'); END;")?;Ok(())}).unwrap(); + let request = PluginInstallRequest { + upload_id: upload.upload_id, + alias: "echo".into(), + operation_key: "install".into(), + }; + assert!(matches!( + f.store.plugin_install("a", "actor", &request), + Err(Error::Sqlite(_)) + )); + assert_eq!(f.count("plugin_installations"), 0); + assert_eq!(f.count("plugin_artifacts"), 0); + assert_eq!(f.count("plugin_operations"), 0); + f.store + .with_conn(|c| { + c.execute_batch("DROP TRIGGER fail_plugin_receipt")?; + Ok(()) + }) + .unwrap(); + let installed = f.store.plugin_install("a", "actor", &request).unwrap(); + assert_eq!( + f.store.plugin_install("a", "actor", &request).unwrap(), + installed + ); +} + +#[tokio::test] +async fn upload_and_publish_failures_leave_no_authority_and_restart_gc_recovers_orphans() { + let f = Fixture::new().await; + for bytes in [&b"not a zip"[..], &f.bytes[..f.bytes.len() - 2]] { + error( + f.store.plugin_upload(&f.root(), "a", "actor", bytes), + PluginCatalogError::Invalid, + ); + } + error( + f.store.plugin_upload( + &f.root(), + "a", + "actor", + &vec![0; PluginPackageLimits::default().max_package_size_bytes as usize + 1], + ), + PluginCatalogError::TooLarge, + ); + assert_eq!(f.count("plugin_uploads"), 0); + fs::write(f.dir.path().join("blocked"), "not a directory").unwrap(); + error( + f.store + .plugin_upload(&f.dir.path().join("blocked"), "a", "actor", &f.bytes), + PluginCatalogError::Unavailable, + ); + f.store.with_conn(|c|{c.execute_batch("CREATE TRIGGER fail_upload_commit BEFORE INSERT ON plugin_uploads BEGIN SELECT RAISE(ABORT,'injected upload DB failure'); END;")?;Ok(())}).unwrap(); + assert!(matches!( + f.store.plugin_upload(&f.root(), "a", "actor", &f.bytes), + Err(Error::Sqlite(_)) + )); + assert_eq!(f.count("plugin_uploads"), 0); + assert_eq!(fs::read_dir(f.root().join("blobs")).unwrap().count(), 1); + fs::write(f.root().join("blobs/.pending-interrupted"), "partial").unwrap(); + let reopened = SqliteWorkspaceStore::open_for_test(f.dir.path().join("server.db")).unwrap(); + assert_eq!(reopened.plugin_gc(&f.root()).unwrap(), 2); + assert_eq!(fs::read_dir(f.root().join("blobs")).unwrap().count(), 0); + f.store + .with_conn(|c| { + c.execute_batch("DROP TRIGGER fail_upload_commit")?; + Ok(()) + }) + .unwrap(); + let upload = f.upload(); + let first = f.install(&upload, "echo"); + fs::write( + blob_path(&f.root(), &upload.artifact.artifact_digest).unwrap(), + "corrupt", + ) + .unwrap(); + error( + f.store.plugin_artifact_bytes( + &f.root(), + "a", + &first.installation.installation_id, + &upload.artifact.artifact_digest, + ), + PluginCatalogError::Unavailable, + ); + error( + f.store.plugin_upload(&f.root(), "a", "actor", &f.bytes), + PluginCatalogError::Unavailable, + ); +} + +#[tokio::test] +async fn upload_actor_workspace_expiry_and_quotas_are_enforced_without_partial_rows() { + let f = Fixture::new().await; + let upload = f.upload(); + let request = PluginInstallRequest { + upload_id: upload.upload_id.clone(), + alias: "echo".into(), + operation_key: "install".into(), + }; + error( + f.store.plugin_install("a", "other", &request), + PluginCatalogError::NotFound, + ); + error( + f.store.plugin_install("b", "actor", &request), + PluginCatalogError::NotFound, + ); + f.expire(); + error( + f.store.plugin_install("a", "actor", &request), + PluginCatalogError::NotFound, + ); + assert_eq!(f.count("plugin_installations"), 0); + for _ in 0..MAX_ACTOR_UPLOADS { + f.upload(); + } + error( + f.store.plugin_upload(&f.root(), "a", "actor", &f.bytes), + PluginCatalogError::Quota, + ); + f.store + .plugin_upload(&f.root(), "a", "other", &f.bytes) + .unwrap(); + f.store + .plugin_upload(&f.root(), "b", "actor", &f.bytes) + .unwrap(); + f.expire(); + f.upload(); + assert_eq!(f.count("plugin_operations"), 0); +} + +#[tokio::test] +async fn global_gc_keeps_other_workspace_blob_and_historical_receipts_after_last_reference() { + let f = Fixture::new().await; + let a_upload = f.upload(); + let a = f.install(&a_upload, "echo"); + let b_upload = f + .store + .plugin_upload(&f.root(), "b", "actor", &f.bytes) + .unwrap(); + let b = f + .store + .plugin_install( + "b", + "actor", + &PluginInstallRequest { + upload_id: b_upload.upload_id, + alias: "echo".into(), + operation_key: "install".into(), + }, + ) + .unwrap(); + f.store + .plugin_uninstall( + "a", + "actor", + &a.installation.installation_id, + &uninstall_request(&a, "remove"), + ) + .unwrap(); + f.expire(); + assert_eq!(f.store.plugin_gc(&f.root()).unwrap(), 0); + assert_eq!( + f.store + .plugin_artifact_bytes( + &f.root(), + "b", + &b.installation.installation_id, + &a_upload.artifact.artifact_digest + ) + .unwrap(), + f.bytes + ); + f.store + .plugin_uninstall( + "b", + "actor", + &b.installation.installation_id, + &uninstall_request(&b, "remove"), + ) + .unwrap(); + assert_eq!(f.store.plugin_gc(&f.root()).unwrap(), 1); + assert_eq!(f.store.plugin_operation("a", &a.operation_id).unwrap(), a); + assert_eq!(f.count("plugin_artifacts"), 2); +} + +#[tokio::test] +async fn independent_connections_cannot_uninstall_or_gc_across_a_reference_transaction() { + let f = Fixture::new().await; + let upload = f.upload(); + let first = f.install(&upload, "echo"); + f.expire(); + let second = SqliteWorkspaceStore::open_for_test(f.dir.path().join("server.db")).unwrap(); + second + .with_conn(|c| { + c.busy_timeout(std::time::Duration::ZERO)?; + Ok(()) + }) + .unwrap(); + f.store.with_conn_mut(|c|{ + let tx=c.transaction_with_behavior(TransactionBehavior::Immediate)?; + plugin_reference_put(&tx,"a",&reference(&first,PluginReferenceKind::Pending))?; + let denied=second.plugin_uninstall("a","actor",&first.installation.installation_id,&uninstall_request(&first,"remove")); + assert!(matches!(denied,Err(Error::Sqlite(rusqlite::Error::SqliteFailure(e,_))) if e.code==rusqlite::ErrorCode::DatabaseBusy)); + assert!(matches!(second.plugin_gc(&f.root()),Err(Error::Sqlite(rusqlite::Error::SqliteFailure(e,_))) if e.code==rusqlite::ErrorCode::DatabaseBusy)); + assert!(blob_path(&f.root(),&upload.artifact.artifact_digest)?.exists()); + tx.commit()?;Ok(()) + }).unwrap(); + error( + second.plugin_uninstall( + "a", + "actor", + &first.installation.installation_id, + &uninstall_request(&first, "remove"), + ), + PluginCatalogError::Referenced, + ); + assert_eq!(second.plugin_gc(&f.root()).unwrap(), 0); + // Reverse ordering: after removal commits, a stale plan cannot recreate a pin. + f.store + .with_conn_mut(|c| { + let tx = c.transaction()?; + plugin_reference_remove( + &tx, + "a", + PluginReferenceKind::Pending, + "owner/config-or-plan", + &first.installation.installation_id, + &upload.artifact.artifact_digest, + )?; + tx.commit()?; + Ok(()) + }) + .unwrap(); + second + .plugin_uninstall( + "a", + "actor", + &first.installation.installation_id, + &uninstall_request(&first, "remove"), + ) + .unwrap(); + f.store + .with_conn_mut(|c| { + let tx = c.transaction()?; + error( + plugin_reference_put(&tx, "a", &reference(&first, PluginReferenceKind::Active)), + PluginCatalogError::NotFound, + ); + Ok(()) + }) + .unwrap(); + assert_eq!(f.store.plugin_gc(&f.root()).unwrap(), 1); +} + +#[tokio::test] +async fn bounded_opaque_list_cursor_is_workspace_bound_and_survives_catalog_changes() { + let f = Fixture::new().await; + let upload = f.upload(); + let first = f.install(&upload, "one"); + f.install(&upload, "two"); + let page = f + .store + .plugin_list( + "a", + &PluginListRequest { + cursor: None, + limit: Some(1), + }, + ) + .unwrap(); + assert_eq!(page.installations.len(), 1); + assert!(page.next_cursor.is_some()); + f.store + .plugin_uninstall( + "a", + "actor", + &first.installation.installation_id, + &uninstall_request(&first, "remove"), + ) + .unwrap(); + let next = f + .store + .plugin_list( + "a", + &PluginListRequest { + cursor: page.next_cursor.clone(), + limit: Some(1), + }, + ) + .unwrap(); + assert_eq!(next.installations.len(), 1); + assert!(next.next_cursor.is_none()); + error( + f.store.plugin_list( + "b", + &PluginListRequest { + cursor: page.next_cursor, + limit: Some(1), + }, + ), + PluginCatalogError::Invalid, + ); + error( + f.store.plugin_list( + "a", + &PluginListRequest { + cursor: None, + limit: Some(101), + }, + ), + PluginCatalogError::Invalid, + ); + error( + f.store.plugin_list( + "a", + &PluginListRequest { + cursor: Some("nonsense".into()), + limit: None, + }, + ), + PluginCatalogError::Invalid, + ); +} + +#[tokio::test] +async fn destructive_mutations_roll_back_when_their_atomic_receipt_cannot_commit() { + let f = Fixture::new().await; + let upload = f.upload(); + let installed = f.install(&upload, "echo"); + let installation = &installed.installation.installation_id; + f.store.with_conn(|c| { c.execute_batch("CREATE TRIGGER fail_receipt BEFORE INSERT ON plugin_operations BEGIN SELECT RAISE(ABORT,'receipt storage failure'); END;")?; Ok(()) }).unwrap(); + let revoke = PluginRevokeRequest { + artifact_digest: upload.artifact.artifact_digest, + expected_last_operation: installed.operation_id.clone(), + reason: "withdrawn".into(), + operation_key: "revoke".into(), + }; + assert!(matches!( + f.store.plugin_revoke("a", "actor", installation, &revoke), + Err(Error::Sqlite(_)) + )); + assert_eq!( + f.store.plugin_show("a", installation).unwrap(), + installed.installation + ); + let uninstall = uninstall_request(&installed, "remove"); + assert!(matches!( + f.store + .plugin_uninstall("a", "actor", installation, &uninstall), + Err(Error::Sqlite(_)) + )); + assert_eq!( + f.store.plugin_show("a", installation).unwrap(), + installed.installation + ); + assert_eq!(f.count("plugin_operations"), 1); + f.store + .with_conn(|c| { + c.execute_batch("DROP TRIGGER fail_receipt")?; + Ok(()) + }) + .unwrap(); + let revoked = f + .store + .plugin_revoke("a", "actor", installation, &revoke) + .unwrap(); + assert_eq!( + f.store + .plugin_revoke("a", "actor", installation, &revoke) + .unwrap(), + revoked + ); + error( + f.store + .plugin_uninstall("a", "actor", installation, &uninstall), + PluginCatalogError::Conflict, + ); + let mut changed = revoke.clone(); + changed.reason = "changed reason".into(); + error( + f.store.plugin_revoke("a", "actor", installation, &changed), + PluginCatalogError::Conflict, + ); +} + +#[tokio::test] +async fn upload_workspace_quota_counts_all_actors_and_installation_quota_is_atomic() { + let f = Fixture::new().await; + let upload = f.upload(); + // Clone real immutable upload references to the same published bytes to put + // accounting precisely at its global boundary without allocating duplicate blobs. + f.store.with_conn(|c| { + for n in 1..MAX_UPLOADS { + c.execute("INSERT INTO plugin_uploads SELECT workspace_id,?1,?2,expires_at,artifact_digest,artifact_size,metadata_json FROM plugin_uploads WHERE upload_id=?3",params![format!("upload-{n}"),format!("actor-{n}"),upload.upload_id])?; + } + Ok(()) + }).unwrap(); + error( + f.store + .plugin_upload(&f.root(), "a", "fresh-actor", &f.bytes), + PluginCatalogError::Quota, + ); + assert_eq!(f.count("plugin_uploads"), MAX_UPLOADS); + for n in 0..MAX_INSTALLATIONS { + f.install(&upload, &format!("install-{n}")); + } + let request = PluginInstallRequest { + upload_id: upload.upload_id, + alias: "excess".into(), + operation_key: "excess".into(), + }; + error( + f.store.plugin_install("a", "actor", &request), + PluginCatalogError::Quota, + ); + assert_eq!(f.count("plugin_installations"), MAX_INSTALLATIONS); + assert_eq!(f.count("plugin_operations"), MAX_INSTALLATIONS); +} diff --git a/crates/workspace-server/src/plugin_catalog_schema.sql b/crates/workspace-server/src/plugin_catalog_schema.sql new file mode 100644 index 000000000..c9e1efb68 --- /dev/null +++ b/crates/workspace-server/src/plugin_catalog_schema.sql @@ -0,0 +1,65 @@ +CREATE TABLE plugin_installations ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + installation_id TEXT NOT NULL, + alias TEXT NOT NULL CHECK(length(alias) BETWEEN 1 AND 128), + package_id TEXT NOT NULL, + last_operation TEXT NOT NULL, + uninstalled INTEGER NOT NULL DEFAULT 0 CHECK(uninstalled IN (0,1)), + PRIMARY KEY(workspace_id, installation_id) +); +CREATE UNIQUE INDEX plugin_live_alias ON plugin_installations(workspace_id, alias) WHERE uninstalled=0; +CREATE TABLE plugin_artifacts ( + workspace_id TEXT NOT NULL, + installation_id TEXT NOT NULL, + artifact_digest TEXT NOT NULL CHECK(length(artifact_digest)=71 AND substr(artifact_digest,1,7)='sha256:' AND substr(artifact_digest,8) NOT GLOB '*[^0-9a-f]*'), + package_digest TEXT NOT NULL CHECK(length(package_digest)=71 AND substr(package_digest,1,7)='sha256:' AND substr(package_digest,8) NOT GLOB '*[^0-9a-f]*'), + version TEXT NOT NULL, + artifact_size INTEGER NOT NULL CHECK(artifact_size > 0), + metadata_json TEXT NOT NULL CHECK(json_valid(metadata_json)), + revoked INTEGER NOT NULL DEFAULT 0 CHECK(revoked IN (0,1)), + revocation_reason TEXT, + CHECK((revoked=0 AND revocation_reason IS NULL) OR (revoked=1 AND revocation_reason IS NOT NULL)), + PRIMARY KEY(workspace_id, installation_id, artifact_digest), + UNIQUE(workspace_id, installation_id, version), + FOREIGN KEY(workspace_id, installation_id) REFERENCES plugin_installations(workspace_id, installation_id) ON DELETE CASCADE +); +CREATE TABLE plugin_uploads ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + upload_id TEXT NOT NULL, + actor TEXT NOT NULL, + expires_at INTEGER NOT NULL, + artifact_digest TEXT NOT NULL CHECK(length(artifact_digest)=71 AND substr(artifact_digest,1,7)='sha256:' AND substr(artifact_digest,8) NOT GLOB '*[^0-9a-f]*'), + artifact_size INTEGER NOT NULL CHECK(artifact_size > 0), + metadata_json TEXT NOT NULL CHECK(json_valid(metadata_json)), + PRIMARY KEY(workspace_id, upload_id) +); +CREATE INDEX plugin_upload_expiry ON plugin_uploads(expires_at); +CREATE TABLE plugin_operations ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + operation_id TEXT NOT NULL, + actor TEXT NOT NULL, + kind TEXT NOT NULL CHECK(kind IN ('install','add_artifact','revoke','uninstall')), + operation_key TEXT NOT NULL CHECK(length(operation_key) BETWEEN 1 AND 128), + fingerprint TEXT NOT NULL CHECK(length(fingerprint)=64), + result_json TEXT NOT NULL CHECK(json_valid(result_json)), + PRIMARY KEY(workspace_id, operation_id), + UNIQUE(workspace_id, actor, kind, operation_key) +); +CREATE TABLE plugin_references ( + workspace_id TEXT NOT NULL, + kind TEXT NOT NULL CHECK(kind IN ('config','pending','active','restorable')), + owner TEXT NOT NULL CHECK(length(owner) BETWEEN 1 AND 512), + installation_id TEXT NOT NULL, + artifact_digest TEXT NOT NULL, + PRIMARY KEY(workspace_id, kind, owner, installation_id, artifact_digest), + FOREIGN KEY(workspace_id, installation_id, artifact_digest) REFERENCES plugin_artifacts(workspace_id, installation_id, artifact_digest) ON DELETE RESTRICT +); +CREATE TRIGGER plugin_revocation_irreversible BEFORE UPDATE OF revoked ON plugin_artifacts +WHEN OLD.revoked=1 AND NEW.revoked<>1 BEGIN SELECT RAISE(ABORT,'plugin revocation is irreversible'); END; +CREATE TRIGGER plugin_reference_live BEFORE INSERT ON plugin_references +WHEN NOT EXISTS(SELECT 1 FROM plugin_artifacts a JOIN plugin_installations i USING(workspace_id, installation_id) + WHERE a.workspace_id=NEW.workspace_id AND a.installation_id=NEW.installation_id AND a.artifact_digest=NEW.artifact_digest AND a.revoked=0 AND i.uninstalled=0) +BEGIN SELECT RAISE(ABORT,'plugin reference target unavailable'); END; +CREATE TRIGGER plugin_uninstall_referenced BEFORE UPDATE OF uninstalled ON plugin_installations +WHEN NEW.uninstalled=1 AND EXISTS(SELECT 1 FROM plugin_references r WHERE r.workspace_id=NEW.workspace_id AND r.installation_id=NEW.installation_id) +BEGIN SELECT RAISE(ABORT,'plugin installation referenced'); END; diff --git a/crates/workspace-server/src/schema_convergence_tests.rs b/crates/workspace-server/src/schema_convergence_tests.rs index a918b0950..19bc536de 100644 --- a/crates/workspace-server/src/schema_convergence_tests.rs +++ b/crates/workspace-server/src/schema_convergence_tests.rs @@ -100,6 +100,10 @@ mod schema_convergence { version: 105, name: "Durable Subject deletion snapshots and Job admission fences".into(), }, + WorkspaceSchemaMigrationStep { + version: 106, + name: crate::plugin_catalog::MIGRATION_NAME.into(), + }, ] ); assert_eq!( @@ -116,6 +120,7 @@ mod schema_convergence { let mut expected = history.clone(); expected.push((104, "Converge Passkey results and Job input retention".into())); expected.push((105, "Durable Subject deletion snapshots and Job admission fences".into())); + expected.push((106, crate::plugin_catalog::MIGRATION_NAME.into())); assert_eq!(workspace_schema_migration_history(conn)?, expected); for table in ["passkey_credentials", "passkey_metadata", "passkey_addition_receipts", "backend_jobs", "backend_job_attempts"] { assert_eq!(state_contract_schema_signature(conn, table), fresh.with_conn(|c| Ok(state_contract_schema_signature(c, table)))?, "{table}"); @@ -145,7 +150,7 @@ mod schema_convergence { assert_eq!(feature_rows(conn, job_variant), rows); assert_eq!( workspace_schema_migration_history(conn)?.len(), - history.len() + 2 + history.len() + plan.migrations.len() ); Ok(()) }) @@ -183,7 +188,7 @@ mod schema_convergence { upgraded .with_conn(|conn| { assert_eq!(feature_rows(conn, job_variant), rows); - assert_eq!(current_schema_version(conn)?, 105); + assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION); Ok(()) }) .unwrap(); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 74e3a488b..bb01ce571 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -46,6 +46,8 @@ use worker_protocol::*; mod drive; #[path = "server_drive_grants.rs"] mod drive_grants; +#[path = "server_plugin.rs"] +mod plugin; #[path = "server_workspace_config.rs"] mod workspace_config; use std::collections::{BTreeMap, HashMap, HashSet}; @@ -3418,11 +3420,12 @@ async fn authorize_scoped_workspace_request( .map_err(|error| { runtime_request_proof_rejection(&method, &request_path, workspace_id, &error) })?; - if is_workspace_config_editor_path(&request_path) { + if is_workspace_config_editor_path(&request_path) || plugin::is_catalog_path(&request_path) + { return Err(repository_api_rejection( &request_path, StatusCode::FORBIDDEN, - "Runtime configuration editor access denied", + "Runtime user-management access denied", )); } request.extensions_mut().insert(source); @@ -3576,11 +3579,12 @@ async fn authorize_workspace_api_request( ); } }; - if is_workspace_config_editor_path(&request_path) { + if is_workspace_config_editor_path(&request_path) || plugin::is_catalog_path(&request_path) + { return repository_api_rejection( &request_path, StatusCode::FORBIDDEN, - "Runtime configuration editor access denied", + "Runtime user-management access denied", ); } request.extensions_mut().insert(source); @@ -4358,6 +4362,8 @@ impl WorkspaceApi { runtime_subscription_broker .register_embedded_runtime(embedded_runtime_id, embedded_subscription_runtime); let database_path = config.database_path.clone(); + let plugin_root = + ServerConfig::server_data_root_for_data_dir(&config.data_root).join("plugin-artifacts"); let executor = store.db_executor(); let config_executor = executor.clone(); let (config_store, repository_secrets) = executor @@ -4367,6 +4373,7 @@ impl WorkspaceApi { .open_store(&database_path)? .with_db_executor(config_executor), ); + config_store.plugin_gc(&plugin_root)?; let repository_secrets = Arc::new(RepositorySecretService::open( config_store.clone(), &database_path, @@ -7603,6 +7610,7 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::current_worker_workdir_catalog( service.clone(), )) + .merge(plugin::router(service.clone())) .merge(server_api::server_api_axum::current_worker_workspace_config_get(service.clone())) .merge(server_api::server_api_axum::current_worker_workspace_config_attach(service.clone())) .merge( @@ -13318,6 +13326,94 @@ impl server_api::ServerApi for ServerApiContractService { .map_err(ApiError::into_repository_api_error) } + async fn plugin_upload( + &self, + actor: RequestActor, + workspace_id: String, + body: server_api::BinaryBody, + ) -> std::result::Result { + let api = self.workspace_api()?; + plugin::upload(api, actor, workspace_id, body).await + } + + async fn plugin_install( + &self, + actor: RequestActor, + workspace_id: String, + request: server_api::PluginInstallRequest, + ) -> std::result::Result + { + let api = self.workspace_api()?; + plugin::install(api, actor, workspace_id, request).await + } + + async fn plugin_list( + &self, + actor: RequestActor, + workspace_id: String, + query: server_api::PluginListRequest, + ) -> std::result::Result { + let api = self.workspace_api()?; + plugin::list(api, actor, workspace_id, query).await + } + + async fn plugin_show( + &self, + actor: RequestActor, + workspace_id: String, + installation_id: String, + ) -> std::result::Result { + let api = self.workspace_api()?; + plugin::show(api, actor, workspace_id, installation_id).await + } + + async fn plugin_artifact_add( + &self, + actor: RequestActor, + workspace_id: String, + installation_id: String, + request: server_api::PluginAddArtifactRequest, + ) -> std::result::Result + { + let api = self.workspace_api()?; + plugin::artifact_add(api, actor, workspace_id, installation_id, request).await + } + + async fn plugin_revoke( + &self, + actor: RequestActor, + workspace_id: String, + installation_id: String, + request: server_api::PluginRevokeRequest, + ) -> std::result::Result + { + let api = self.workspace_api()?; + plugin::revoke(api, actor, workspace_id, installation_id, request).await + } + + async fn plugin_uninstall( + &self, + actor: RequestActor, + workspace_id: String, + installation_id: String, + request: server_api::PluginUninstallRequest, + ) -> std::result::Result + { + let api = self.workspace_api()?; + plugin::uninstall(api, actor, workspace_id, installation_id, request).await + } + + async fn plugin_operation( + &self, + actor: RequestActor, + workspace_id: String, + operation_id: String, + ) -> std::result::Result + { + let api = self.workspace_api()?; + plugin::operation(api, actor, workspace_id, operation_id).await + } + async fn drive_root( &self, context: server_api::ServerRequestContext, @@ -38064,6 +38160,10 @@ mod tests { include!("server_ticket_case_signed_tests.rs"); } + mod plugin_tests { + include!("server_plugin_tests.rs"); + } + mod ticket_tags_tests { include!("server_ticket_tags_tests.rs"); } diff --git a/crates/workspace-server/src/server_plugin.rs b/crates/workspace-server/src/server_plugin.rs new file mode 100644 index 000000000..40be94954 --- /dev/null +++ b/crates/workspace-server/src/server_plugin.rs @@ -0,0 +1,226 @@ +//! Human catalog management is separate from Runtime artifact delivery and execution grants. +//! The outer dispatcher authenticates each request. Mutations additionally require the +//! Workspace owner account; authenticated readers follow the existing Workspace read policy. +//! Neither model text nor a Runtime workspace.request proof can manufacture a human actor. +use super::*; +use server_api::*; + +type CatalogResult = std::result::Result; + +pub(super) fn router(service: Arc) -> Router { + Router::new() + .merge( + server_api_axum::plugin_upload(service.clone()).layer(DefaultBodyLimit::max( + manifest::plugin::PluginPackageLimits::default().max_package_size_bytes as usize, + )), + ) + .merge(server_api_axum::plugin_install(service.clone())) + .merge(server_api_axum::plugin_list(service.clone())) + .merge(server_api_axum::plugin_show(service.clone())) + .merge(server_api_axum::plugin_artifact_add(service.clone())) + .merge(server_api_axum::plugin_revoke(service.clone())) + .merge(server_api_axum::plugin_uninstall(service.clone())) + .merge(server_api_axum::plugin_operation(service)) + .layer(middleware::from_fn(normalize_rejections)) +} + +async fn normalize_rejections(request: Request, next: Next) -> Response { + let response = next.run(request).await; + // The management contract classifies malformed DTOs/queries as invalid=400, + // including Axum's JSON data/media rejections. Preserve the common envelope + // without reflecting attacker-supplied JSON values or transport diagnostics. + if matches!( + response.status(), + StatusCode::BAD_REQUEST + | StatusCode::UNPROCESSABLE_ENTITY + | StatusCode::UNSUPPORTED_MEDIA_TYPE + ) { + ( + StatusCode::BAD_REQUEST, + Json(RepositoryApiError::new( + 400, + "plugin_invalid", + "Invalid Plugin catalog request", + vec![], + )), + ) + .into_response() + } else { + response + } +} + +pub(super) fn is_catalog_path(path: &str) -> bool { + path.strip_prefix("/api/w/") + .and_then(|p| p.split_once('/')) + .is_some_and(|(_, resource)| { + matches!(resource, "plugins" | "plugin-uploads") + || resource.starts_with("plugins/") + || resource.starts_with("plugin-operations/") + }) +} + +fn failure(error: Error) -> RepositoryApiError { + let (status, code) = match error { + Error::PluginCatalog(error) => (error.status(), error.code()), + Error::WorkspacePermissionDenied(_) => (403, "access_denied"), + Error::WorkspaceIdMismatch => (404, "not_found"), + Error::DbCapacityExceeded => (503, "busy"), + // A storage failure may be a lost acknowledgement. Do not assert rollback + // or encourage a fresh operation key; the caller can reconcile its receipt. + _ => (503, "catalog_outcome_unknown"), + }; + RepositoryApiError::new(status, code, format!("Plugin catalog: {code}"), vec![]) +} + +async fn authorize( + api: &WorkspaceApi, + workspace: &str, + actor: &RequestActor, + manage: bool, +) -> CatalogResult<()> { + validate_workspace_scope(api, workspace).map_err(|e| failure(e.error))?; + if manage { + require_workspace_owner(api, workspace, actor, "Plugin management") + .await + .map_err(|e| failure(e.error))?; + } + Ok(()) +} + +fn actor_identity(actor: &RequestActor) -> String { + // Account alone is not a caller: two users of the same management account + // must not consume one another's temporary uploads or operation keys. + serde_json::to_string(&(&actor.account_id, &actor.user_id)) + .expect("string tuple is serializable") +} + +pub(super) async fn upload( + api: &WorkspaceApi, + actor: RequestActor, + workspace: String, + body: BinaryBody, +) -> CatalogResult { + authorize(api, &workspace, &actor, true).await?; + let root = + ServerConfig::server_data_root_for_data_dir(&api.config.data_root).join("plugin-artifacts"); + let store = api.config_store.clone(); + let actor = actor_identity(&actor); + let executor = store.db_executor(); + executor + .run(move || store.plugin_upload(&root, &workspace, &actor, body.as_ref())) + .await + .map_err(failure) +} + +pub(super) async fn install( + api: &WorkspaceApi, + actor: RequestActor, + workspace: String, + request: PluginInstallRequest, +) -> CatalogResult { + authorize(api, &workspace, &actor, true).await?; + let store = api.config_store.clone(); + let actor = actor_identity(&actor); + let executor = store.db_executor(); + executor + .run(move || store.plugin_install(&workspace, &actor, &request)) + .await + .map_err(failure) +} + +pub(super) async fn list( + api: &WorkspaceApi, + actor: RequestActor, + workspace: String, + query: PluginListRequest, +) -> CatalogResult { + authorize(api, &workspace, &actor, false).await?; + let store = api.config_store.clone(); + let executor = store.db_executor(); + executor + .run(move || store.plugin_list(&workspace, &query)) + .await + .map_err(failure) +} + +pub(super) async fn show( + api: &WorkspaceApi, + actor: RequestActor, + workspace: String, + installation_id: String, +) -> CatalogResult { + authorize(api, &workspace, &actor, false).await?; + let store = api.config_store.clone(); + let executor = store.db_executor(); + executor + .run(move || store.plugin_show(&workspace, &installation_id)) + .await + .map_err(failure) +} + +pub(super) async fn artifact_add( + api: &WorkspaceApi, + actor: RequestActor, + workspace: String, + installation_id: String, + request: PluginAddArtifactRequest, +) -> CatalogResult { + authorize(api, &workspace, &actor, true).await?; + let store = api.config_store.clone(); + let actor = actor_identity(&actor); + let executor = store.db_executor(); + executor + .run(move || store.plugin_add_artifact(&workspace, &actor, &installation_id, &request)) + .await + .map_err(failure) +} + +pub(super) async fn revoke( + api: &WorkspaceApi, + actor: RequestActor, + workspace: String, + installation_id: String, + request: PluginRevokeRequest, +) -> CatalogResult { + authorize(api, &workspace, &actor, true).await?; + let store = api.config_store.clone(); + let actor = actor_identity(&actor); + let executor = store.db_executor(); + executor + .run(move || store.plugin_revoke(&workspace, &actor, &installation_id, &request)) + .await + .map_err(failure) +} + +pub(super) async fn uninstall( + api: &WorkspaceApi, + actor: RequestActor, + workspace: String, + installation_id: String, + request: PluginUninstallRequest, +) -> CatalogResult { + authorize(api, &workspace, &actor, true).await?; + let store = api.config_store.clone(); + let actor = actor_identity(&actor); + let executor = store.db_executor(); + executor + .run(move || store.plugin_uninstall(&workspace, &actor, &installation_id, &request)) + .await + .map_err(failure) +} + +pub(super) async fn operation( + api: &WorkspaceApi, + actor: RequestActor, + workspace: String, + operation_id: String, +) -> CatalogResult { + authorize(api, &workspace, &actor, false).await?; + let store = api.config_store.clone(); + let executor = store.db_executor(); + executor + .run(move || store.plugin_operation(&workspace, &operation_id)) + .await + .map_err(failure) +} diff --git a/crates/workspace-server/src/server_plugin_tests.rs b/crates/workspace-server/src/server_plugin_tests.rs new file mode 100644 index 000000000..aeec3c00d --- /dev/null +++ b/crates/workspace-server/src/server_plugin_tests.rs @@ -0,0 +1,414 @@ +use super::*; + +// Generated routes -> real token resolver and Workspace admission -> real catalog +// SQLite/blob storage. The unrelated Worker executor is the existing test backend. +fn package_bytes(version: &str) -> Vec { + use manifest::plugin::*; + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("plugin.toml"), + include_str!("../../../resources/plugin/templates/rust-component-tool/plugin.toml") + .replace("0.1.0", version), + ) + .unwrap(); + fs::write( + dir.path().join("plugin.component.wasm"), + include_bytes!("../../plugin-pdk/tests/fixtures/tool-v2.component.wasm"), + ) + .unwrap(); + let limits = PluginPackageLimits::default(); + pack_plugin_package( + &read_plugin_directory(dir.path(), PluginSourceKind::Explicit, &limits).unwrap(), + &limits, + ) + .unwrap() + .bytes +} + +async fn request( + app: Router, + token: Option<&str>, + method: &str, + path: &str, + body: Vec, + binary: bool, + expected: StatusCode, +) -> Value { + let mut req = Request::builder().method(method).uri(path).header( + CONTENT_TYPE, + if binary { + "application/octet-stream" + } else { + "application/json" + }, + ); + if let Some(token) = token { + req = req.header("authorization", format!("Bearer {token}")); + } + let response = app + .oneshot(req.body(Body::from(body)).unwrap()) + .await + .unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 32 * 1024 * 1024) + .await + .unwrap(); + assert_eq!( + status, + expected, + "{method} {path}: {}", + String::from_utf8_lossy(&bytes) + ); + serde_json::from_slice(&bytes).unwrap() +} +async fn json(app: Router, token: &str, path: &str, body: Value, expected: StatusCode) -> Value { + request( + app, + Some(token), + "POST", + path, + serde_json::to_vec(&body).unwrap(), + false, + expected, + ) + .await +} +async fn get(app: Router, token: Option<&str>, path: &str, expected: StatusCode) -> Value { + request(app, token, "GET", path, vec![], false, expected).await +} + +#[tokio::test] +async fn authenticated_catalog_lifecycle_replay_cas_and_current_read_authority() { + let dir = tempfile::tempdir().unwrap(); + let api = test_api(dir.path()).await; + let owner = seed_test_workspace_owner_token(api.store.as_ref(), "plugin-owner").await; + let reader = seed_test_api_token(api.store.as_ref(), "plugin-reader"); + let app = build_router(api.clone()); + let prefix = format!("/api/w/{TEST_WORKSPACE_ID}"); + let catalog = format!("{prefix}/plugins"); + let upload_path = format!("{prefix}/plugin-uploads"); + get(app.clone(), None, &catalog, StatusCode::UNAUTHORIZED).await; + get(app.clone(), Some(&reader), &catalog, StatusCode::OK).await; + request( + app.clone(), + Some(&reader), + "POST", + &upload_path, + package_bytes("0.1.0"), + true, + StatusCode::FORBIDDEN, + ) + .await; + let upload = request( + app.clone(), + Some(&owner), + "POST", + &upload_path, + package_bytes("0.1.0"), + true, + StatusCode::OK, + ) + .await; + assert_ne!( + upload["artifact"]["package_digest"], + upload["artifact"]["artifact_digest"] + ); + let install = serde_json::json!({"upload_id":upload["upload_id"],"alias":"primary","operation_key":"install"}); + let original = json( + app.clone(), + &owner, + &catalog, + install.clone(), + StatusCode::OK, + ) + .await; + assert_eq!( + json( + app.clone(), + &owner, + &catalog, + install.clone(), + StatusCode::OK + ) + .await, + original + ); + let mut changed = install.clone(); + changed["alias"] = "other".into(); + json(app.clone(), &owner, &catalog, changed, StatusCode::CONFLICT).await; + let id = original["installation"]["installation_id"] + .as_str() + .unwrap(); + let detail_path = format!("{catalog}/{id}"); + let observed = get(app.clone(), Some(&reader), &detail_path, StatusCode::OK).await; + assert_eq!(observed["last_operation"], original["operation_id"]); + get( + app.clone(), + Some(&reader), + &format!( + "{prefix}/plugin-operations/{}", + original["operation_id"].as_str().unwrap() + ), + StatusCode::OK, + ) + .await; + get( + app.clone(), + Some(&owner), + &format!("/api/w/another/plugins/{id}"), + StatusCode::NOT_FOUND, + ) + .await; + get( + app.clone(), + Some(&owner), + &format!( + "{prefix}/plugins/{}", + upload["artifact"]["artifact_digest"].as_str().unwrap() + ), + StatusCode::NOT_FOUND, + ) + .await; + let next_upload = request( + app.clone(), + Some(&owner), + "POST", + &upload_path, + package_bytes("0.2.0"), + true, + StatusCode::OK, + ) + .await; + let add = json(app.clone(), &owner, &format!("{detail_path}/artifacts"), serde_json::json!({"upload_id":next_upload["upload_id"],"expected_last_operation":observed["last_operation"],"operation_key":"add"}), StatusCode::OK).await; + json(app.clone(), &owner, &format!("{detail_path}/uninstall"), serde_json::json!({"expected_last_operation":observed["last_operation"],"operation_key":"stale-delete"}), StatusCode::CONFLICT).await; + let revoke = json(app.clone(), &owner, &format!("{detail_path}/revoke"), serde_json::json!({"expected_last_operation":add["operation_id"],"operation_key":"revoke","artifact_digest":upload["artifact"]["artifact_digest"],"reason":"withdrawn"}), StatusCode::OK).await; + assert!( + revoke["installation"]["artifacts"] + .as_array() + .unwrap() + .iter() + .any(|a| a["revoked"] == true) + ); + let removed = json(app.clone(), &owner, &format!("{detail_path}/uninstall"), serde_json::json!({"expected_last_operation":revoke["operation_id"],"operation_key":"remove"}), StatusCode::OK).await; + assert_eq!(removed["installation"]["uninstalled"], true); + // Historical receipts remain historical after catalog movement. + assert_eq!( + json( + app.clone(), + &owner, + &catalog, + install.clone(), + StatusCode::OK + ) + .await, + original + ); + api.config_store + .with_conn(|conn| { + conn.execute( + "UPDATE api_tokens SET revoked_at='2026-01-01' WHERE token_hash=?1", + [crate::auth::token_hash(&owner)], + )?; + Ok(()) + }) + .unwrap(); + json( + app.clone(), + &owner, + &catalog, + install, + StatusCode::UNAUTHORIZED, + ) + .await; + shutdown_workspace_feature_storage(&api).await.unwrap(); +} + +#[tokio::test] +async fn catalog_rejects_invalid_uploads_self_reported_roles_and_runtime_proofs() { + let dir = tempfile::tempdir().unwrap(); + let mut api = test_api(dir.path()).await; + let token = seed_test_workspace_owner_token(api.store.as_ref(), "plugin-input").await; + let identity = RuntimeIdentityMaterial::generate("runtime-plugin-input").unwrap(); + configure_runtime_request_auth(&mut api, &identity, "runtime-plugin-input"); + let app = build_router(api.clone()); + let prefix = format!("/api/w/{TEST_WORKSPACE_ID}"); + request( + app.clone(), + Some(&token), + "POST", + &format!("{prefix}/plugin-uploads"), + b"partial zip".to_vec(), + true, + StatusCode::BAD_REQUEST, + ) + .await; + let max = manifest::plugin::PluginPackageLimits::default().max_package_size_bytes as usize; + request( + app.clone(), + Some(&token), + "POST", + &format!("{prefix}/plugin-uploads"), + vec![0; max + 1], + true, + StatusCode::PAYLOAD_TOO_LARGE, + ) + .await; + json(app.clone(), &token, &format!("{prefix}/plugins"), serde_json::json!({"upload_id":"missing","alias":"bad","operation_key":"bad","role":"owner"}), StatusCode::BAD_REQUEST).await; + let response = app + .clone() + .oneshot(runtime_source_request( + &identity, + None, + "GET", + &format!("{prefix}/plugins"), + vec![], + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!( + get( + app, + Some(&token), + &format!("{prefix}/plugins"), + StatusCode::OK + ) + .await["installations"], + serde_json::json!([]) + ); + shutdown_workspace_feature_storage(&api).await.unwrap(); +} + +#[tokio::test] +async fn generated_client_and_interrupted_binary_transport_use_real_catalog_authority() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let dir = tempfile::tempdir().unwrap(); + let api = test_api(dir.path()).await; + let token = seed_test_workspace_owner_token(api.store.as_ref(), "plugin-transport").await; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let app = build_router(api.clone()); + let task = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let mut stream = tokio::net::TcpStream::connect(address).await.unwrap(); + let header = format!( + "POST /api/w/{TEST_WORKSPACE_ID}/plugin-uploads HTTP/1.1\r\nHost: {address}\r\nAuthorization: Bearer {token}\r\nContent-Type: application/octet-stream\r\nContent-Length: 2000\r\nConnection: close\r\n\r\npartial" + ); + stream.write_all(header.as_bytes()).await.unwrap(); + stream.shutdown().await.unwrap(); + let mut response = Vec::new(); + tokio::time::timeout( + std::time::Duration::from_secs(5), + stream.read_to_end(&mut response), + ) + .await + .unwrap() + .unwrap(); + assert_eq!( + api.config_store + .with_conn(|conn| Ok(conn.query_row( + "SELECT count(*) FROM plugin_uploads", + [], + |r| r.get::<_, u64>(0) + )?)) + .unwrap(), + 0 + ); + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::AUTHORIZATION, + format!("Bearer {token}").parse().unwrap(), + ); + let client = server_api::ServerApiClient::builder(format!("http://{address}")) + .unwrap() + .client( + reqwest::Client::builder() + .default_headers(headers) + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(), + ) + .build() + .unwrap(); + let upload = client + .plugin_upload(TEST_WORKSPACE_ID.into(), package_bytes("0.1.0").into()) + .await + .unwrap(); + let installed = client + .plugin_install( + TEST_WORKSPACE_ID.into(), + server_api::PluginInstallRequest { + upload_id: upload.upload_id, + alias: "transport".into(), + operation_key: "transport-install".into(), + }, + ) + .await + .unwrap(); + let observed = client + .plugin_show( + TEST_WORKSPACE_ID.into(), + installed.installation.installation_id, + ) + .await + .unwrap(); + assert_eq!(observed.last_operation, installed.operation_id); + assert_eq!(observed.artifacts[0], upload.artifact); + task.abort(); + let _ = task.await; + shutdown_workspace_feature_storage(&api).await.unwrap(); +} + +#[tokio::test] +async fn api_restart_recovers_orphan_publication_and_preserves_committed_catalog() { + let dir = tempfile::tempdir().unwrap(); + let api = test_api(dir.path()).await; + let token = seed_test_workspace_owner_token(api.store.as_ref(), "plugin-restart").await; + let app = build_router(api.clone()); + let prefix = format!("/api/w/{TEST_WORKSPACE_ID}"); + let upload = request( + app.clone(), + Some(&token), + "POST", + &format!("{prefix}/plugin-uploads"), + package_bytes("0.1.0"), + true, + StatusCode::OK, + ) + .await; + let installed = json(app.clone(),&token,&format!("{prefix}/plugins"),serde_json::json!({"upload_id":upload["upload_id"],"alias":"restart","operation_key":"restart-install"}),StatusCode::OK).await; + let root = ServerConfig::server_data_root_for_data_dir(&api.config.data_root) + .join("plugin-artifacts/blobs"); + let orphan = root.join("f".repeat(64)); + let partial = root.join(".pending-interrupted-restart"); + fs::write(&orphan, b"published without committed reference").unwrap(); + fs::write(&partial, b"partial").unwrap(); + drop(app); + shutdown_workspace_feature_storage(&api).await.unwrap(); + drop(api); + let reopened = test_api(dir.path()).await; + assert!(!orphan.exists()); + assert!(!partial.exists()); + let observed = reopened + .config_store + .plugin_show( + TEST_WORKSPACE_ID, + installed["installation"]["installation_id"] + .as_str() + .unwrap(), + ) + .unwrap(); + assert_eq!(observed.last_operation, installed["operation_id"]); + let bytes = reopened + .config_store + .plugin_artifact_bytes( + root.parent().unwrap(), + TEST_WORKSPACE_ID, + &observed.installation_id, + &observed.artifacts[0].artifact_digest, + ) + .unwrap(); + assert_eq!(bytes, package_bytes("0.1.0")); + shutdown_workspace_feature_storage(&reopened).await.unwrap(); +} diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 1663312cc..d07e1997b 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -127,7 +127,7 @@ pub use ticket::{ }; const OLDEST_SCHEMA_VERSION: i64 = 50; -const LATEST_SCHEMA_VERSION: i64 = 105; +const LATEST_SCHEMA_VERSION: i64 = 106; const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants"; const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs"; const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline"; @@ -460,6 +460,11 @@ const MIGRATIONS: &[Migration] = &[ name: "Durable Subject deletion snapshots and Job admission fences", apply: subject_deletion_store::migrate, }, + Migration { + version: 106, + name: crate::plugin_catalog::MIGRATION_NAME, + apply: crate::plugin_catalog::migrate, + }, ]; // Both schema-103 identities shipped. Keep the alternate implementation and accept @@ -1334,6 +1339,64 @@ pub struct HostingAdminInitialBearer { #[async_trait] pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { + fn plugin_upload( + &self, + root: &Path, + workspace: &str, + actor: &str, + bytes: &[u8], + ) -> Result; + fn plugin_install( + &self, + workspace: &str, + actor: &str, + request: &server_api::PluginInstallRequest, + ) -> Result; + fn plugin_add_artifact( + &self, + workspace: &str, + actor: &str, + installation: &str, + request: &server_api::PluginAddArtifactRequest, + ) -> Result; + fn plugin_revoke( + &self, + workspace: &str, + actor: &str, + installation: &str, + request: &server_api::PluginRevokeRequest, + ) -> Result; + fn plugin_uninstall( + &self, + workspace: &str, + actor: &str, + installation: &str, + request: &server_api::PluginUninstallRequest, + ) -> Result; + fn plugin_show( + &self, + workspace: &str, + installation: &str, + ) -> Result; + fn plugin_list( + &self, + workspace: &str, + request: &server_api::PluginListRequest, + ) -> Result; + fn plugin_operation( + &self, + workspace: &str, + operation: &str, + ) -> Result; + fn plugin_artifact_bytes( + &self, + root: &Path, + workspace: &str, + installation: &str, + artifact_digest: &str, + ) -> Result>; + fn plugin_gc(&self, root: &Path) -> Result; + /// Bounded owner for whole synchronous operations from async callers. Move owned inputs /// and services into one closure covering connection/mutex waits, source authorization, /// receipts, CAS, audit, commit and ordered publication. Do not split checks around awaits @@ -4670,6 +4733,90 @@ fn insert_workspace_runtime_binding_audit( #[async_trait] impl ControlPlaneStore for SqliteWorkspaceStore { + fn plugin_upload( + &self, + root: &Path, + workspace: &str, + actor: &str, + bytes: &[u8], + ) -> Result { + SqliteWorkspaceStore::plugin_upload(self, root, workspace, actor, bytes) + } + fn plugin_install( + &self, + workspace: &str, + actor: &str, + request: &server_api::PluginInstallRequest, + ) -> Result { + SqliteWorkspaceStore::plugin_install(self, workspace, actor, request) + } + fn plugin_add_artifact( + &self, + workspace: &str, + actor: &str, + installation: &str, + request: &server_api::PluginAddArtifactRequest, + ) -> Result { + SqliteWorkspaceStore::plugin_add_artifact(self, workspace, actor, installation, request) + } + fn plugin_revoke( + &self, + workspace: &str, + actor: &str, + installation: &str, + request: &server_api::PluginRevokeRequest, + ) -> Result { + SqliteWorkspaceStore::plugin_revoke(self, workspace, actor, installation, request) + } + fn plugin_uninstall( + &self, + workspace: &str, + actor: &str, + installation: &str, + request: &server_api::PluginUninstallRequest, + ) -> Result { + SqliteWorkspaceStore::plugin_uninstall(self, workspace, actor, installation, request) + } + fn plugin_show( + &self, + workspace: &str, + installation: &str, + ) -> Result { + SqliteWorkspaceStore::plugin_show(self, workspace, installation) + } + fn plugin_list( + &self, + workspace: &str, + request: &server_api::PluginListRequest, + ) -> Result { + SqliteWorkspaceStore::plugin_list(self, workspace, request) + } + fn plugin_operation( + &self, + workspace: &str, + operation: &str, + ) -> Result { + SqliteWorkspaceStore::plugin_operation(self, workspace, operation) + } + fn plugin_artifact_bytes( + &self, + root: &Path, + workspace: &str, + installation: &str, + artifact_digest: &str, + ) -> Result> { + SqliteWorkspaceStore::plugin_artifact_bytes( + self, + root, + workspace, + installation, + artifact_digest, + ) + } + fn plugin_gc(&self, root: &Path) -> Result { + SqliteWorkspaceStore::plugin_gc(self, root) + } + fn db_executor(&self) -> crate::db_executor::DbExecutor { self.db_executor.clone() } @@ -15639,6 +15786,10 @@ fn create_workspace_schema_structure_from_sql( conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?; conn.execute_batch( &schema_sql + .replace( + "-- Plugin catalog schema is composed by store.rs.", + include_str!("plugin_catalog_schema.sql"), + ) .replace( "-- Ticket-owned assignment schema is composed by store.rs.", ticket::TICKET_ASSIGNMENT_SCHEMA_SQL, @@ -16648,6 +16799,7 @@ fn apply_migrations_with_secret_source( } verify_schema_history(conn, LATEST_SCHEMA_VERSION)?; + crate::plugin_catalog::verify(conn)?; verify_workspace_runtime_binding_schema(conn)?; verify_workspace_runtime_verification_schema(conn)?; verify_workspace_deletion_schema(conn)?; @@ -19092,7 +19244,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx fixture.commit().unwrap(); } - fn prepare_schema_v50(path: &Path, workspace_id: Option<&str>) { + pub(super) fn prepare_schema_v50(path: &Path, workspace_id: Option<&str>) { prepare_schema_v52(path); let conn = Connection::open(path).unwrap(); configure_sqlite(&conn).unwrap(); @@ -19341,6 +19493,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx WorkspaceSchemaMigrationStep {version:103, name:"Passkey metadata and atomic addition results".into()}, WorkspaceSchemaMigrationStep {version:104, name:"Converge Passkey results and Job input retention".into()}, WorkspaceSchemaMigrationStep {version:105, name:"Durable Subject deletion snapshots and Job admission fences".into()}, + WorkspaceSchemaMigrationStep {version:106, name:crate::plugin_catalog::MIGRATION_NAME.into()}, ] ); @@ -19456,6 +19609,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx (103, "Passkey metadata and atomic addition results".to_string()), (104, "Converge Passkey results and Job input retention".to_string()), (105, "Durable Subject deletion snapshots and Job admission fences".to_string()), +(106, crate::plugin_catalog::MIGRATION_NAME.to_string()), ] ); assert!(!table_exists(conn, "trusted_runtime_records")?); @@ -22742,7 +22896,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx (102, "Durable failed Subject creation recovery".into()), (103, "Passkey metadata and atomic addition results".into()), (104, "Converge Passkey results and Job input retention".into()), - (105, "Durable Subject deletion snapshots and Job admission fences".into())]); + (105, "Durable Subject deletion snapshots and Job admission fences".into()), + (106, crate::plugin_catalog::MIGRATION_NAME.into())]); for (table, rows) in unchanged.iter().zip(before.iter()) { assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}"); } @@ -26936,6 +27091,7 @@ INSERT INTO worker_registry ( .unwrap(); store.with_conn(|conn| { // This fixture deliberately reconstructs schema 102 from fresh. + conn.execute_batch("DROP TABLE plugin_references; DROP TABLE plugin_artifacts; DROP TABLE plugin_installations; DROP TABLE plugin_uploads; DROP TABLE plugin_operations;")?; // Remove the newer Subject fence composition, not just its receipt. let triggers=conn.prepare("SELECT name,tbl_name FROM sqlite_schema WHERE type='trigger' AND tbl_name LIKE 'notification_%' AND name NOT LIKE 'subject_deleted_%' AND sql LIKE '%subject_deletion_jobs%'")? .query_map([],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?)))?.collect::>>()?; @@ -26947,7 +27103,7 @@ INSERT INTO worker_registry ( DROP TRIGGER passkey_metadata_insert; DROP TABLE passkey_addition_receipts; DROP TABLE passkey_metadata; - UPDATE __yoi_schema_migrations SET version=102 WHERE version=105; + UPDATE __yoi_schema_migrations SET version=102 WHERE version=106; CREATE TRIGGER backend_job_input_retirement BEFORE UPDATE ON backend_job_attempts BEGIN SELECT RAISE(ABORT,'collision'); END;")?; Ok(()) }).unwrap(); drop(store); @@ -28259,3 +28415,7 @@ mod repository_key_migration_tests; #[cfg(test)] #[path = "frozen_runtime_binding_tests.rs"] mod frozen_runtime_binding_tests; + +#[cfg(test)] +#[path = "plugin_catalog/migration_tests.rs"] +mod plugin_catalog_migration_tests; diff --git a/crates/workspace-server/src/workspace_deletion.rs b/crates/workspace-server/src/workspace_deletion.rs index 6cea92adb..b2519847f 100644 --- a/crates/workspace-server/src/workspace_deletion.rs +++ b/crates/workspace-server/src/workspace_deletion.rs @@ -16,6 +16,11 @@ use crate::{Error, Result}; /// Explicit domain-owned purge inventory. The deletion operation tombstone is intentionally /// excluded so retries and audit remain available after the Workspace row is gone. const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[ + "plugin_references", + "plugin_operations", + "plugin_uploads", + "plugin_artifacts", + "plugin_installations", "artifacts", "audit_events", "backend_job_deliveries", @@ -1283,6 +1288,84 @@ mod tests { } } + #[test] + fn workspace_purge_removes_plugin_references_and_catalog_without_cross_workspace_loss() { + let (store, owner, workspace_id) = setup(); + for workspace in ["workspace-a", "workspace-b"] { + store.with_conn(|conn| { + let digest = format!("sha256:{}", "a".repeat(64)); + conn.execute("INSERT INTO plugin_installations VALUES(?1,'installation','alias','package','operation',0)", [workspace])?; + conn.execute("INSERT INTO plugin_artifacts VALUES(?1,'installation',?2,?2,'1',1,'{}',0,NULL)", params![workspace,digest])?; + conn.execute("INSERT INTO plugin_uploads VALUES(?1,'upload','actor',9999999999,?2,1,'{}')", params![workspace,digest])?; + conn.execute("INSERT INTO plugin_operations VALUES(?1,'operation','actor','install','key',?2,'{}')", params![workspace,"f".repeat(64)])?; + for kind in ["config","pending","active","restorable"] { + conn.execute("INSERT INTO plugin_references VALUES(?1,?2,'owner','installation',?3)", params![workspace,kind,digest])?; + } + Ok(()) + }).unwrap(); + } + let counts = |workspace: &str| { + store + .with_conn(|conn| { + [ + "plugin_references", + "plugin_operations", + "plugin_uploads", + "plugin_artifacts", + "plugin_installations", + ] + .map(|table| { + Ok(conn.query_row( + &format!("SELECT count(*) FROM {table} WHERE workspace_id=?1"), + [workspace], + |r| r.get::<_, i64>(0), + )?) + }) + .into_iter() + .collect::>>() + }) + .unwrap() + }; + let other = counts("workspace-b"); + assert_eq!(counts("workspace-a"), vec![4, 1, 1, 1, 1]); + let preflight = store + .workspace_deletion_preflight(&owner, &workspace_id) + .unwrap(); + let operation = store + .reserve_workspace_deletion( + &owner, + &workspace_id, + &WorkspaceDeletionRequest { + operation_id: "purge-plugins".into(), + expected_workspace_updated_at: preflight.expected_workspace_updated_at, + confirmation: "Alpha".into(), + }, + ) + .unwrap() + .operation; + assert_eq!( + store + .finalize_workspace_deletion(&operation.operation_id) + .unwrap() + .state, + WorkspaceDeletionState::Succeeded + ); + assert_eq!(counts("workspace-a"), vec![0; 5]); + assert_eq!(counts("workspace-b"), other); + store + .with_conn(|conn| { + assert_eq!( + conn.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |r| r + .get::<_, i64>( + 0 + ))?, + 0 + ); + Ok(()) + }) + .unwrap(); + } + #[test] fn workspace_purge_removes_subject_deletion_snapshots_and_preserves_other_workspace() { let (store, owner, workspace_id) = setup(); diff --git a/crates/yoi/src/cli_connection.rs b/crates/yoi/src/cli_connection.rs index 109dd8aa2..09dfd03d4 100644 --- a/crates/yoi/src/cli_connection.rs +++ b/crates/yoi/src/cli_connection.rs @@ -30,6 +30,7 @@ pub(crate) enum CliCommand { Ticket, Objective, Plugin, + PluginCatalog, Mcp, Session, Subject, @@ -49,7 +50,8 @@ impl CliCommand { CliCommand::WorkerCleanup => "yoi worker management", CliCommand::Ticket => "yoi ticket", CliCommand::Objective => "yoi objective", - CliCommand::Plugin => "yoi plugin", + CliCommand::Plugin => "yoi plugin authoring", + CliCommand::PluginCatalog => "yoi plugin catalog", CliCommand::Mcp => "yoi mcp", CliCommand::Session => "yoi session", CliCommand::Subject => "yoi subject", @@ -65,7 +67,7 @@ impl CliCommand { | CliCommand::Panel | CliCommand::Ticket | CliCommand::Objective => CliConnectionRequirement::ConnectionAware, - CliCommand::Login => CliConnectionRequirement::BackendOnly, + CliCommand::Login | CliCommand::PluginCatalog => CliConnectionRequirement::BackendOnly, CliCommand::Keys | CliCommand::SetupModel | CliCommand::Serve @@ -317,10 +319,12 @@ mod tests { command.display_name() ); } - assert_eq!( - CliCommand::Login.connection_requirement(), - CliConnectionRequirement::BackendOnly - ); + for command in [CliCommand::Login, CliCommand::PluginCatalog] { + assert_eq!( + command.connection_requirement(), + CliConnectionRequirement::BackendOnly + ); + } for command in [ CliCommand::Keys, diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 1d3a5128e..2212c173c 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -44,6 +44,11 @@ enum Mode { WorkersHelp, Mcp(mcp_cli::McpCliCommand), Plugin(plugin_cli::PluginCliCommand), + PluginCatalog { + command: plugin_cli::PluginCatalogCommand, + args: plugin_cli::PluginCliArgs, + target: client::ResolvedTarget, + }, Objective { cli: objective_cli::ObjectiveCli, target: client::ResolvedTarget, @@ -184,6 +189,19 @@ async fn run(mode: Mode) -> ExitCode { ExitCode::FAILURE } }, + Mode::PluginCatalog { + command, + args, + target, + } => { + let output = plugin_cli::run_catalog(command, args, target).await; + print!("{}", output.stdout); + if output.success { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + } + } Mode::Objective { cli, target } => { match tokio::task::spawn_blocking(move || objective_cli::run(cli, target)).await { Ok(Ok(output)) => { @@ -612,9 +630,38 @@ fn parse_args_slice_with_connection_resolver( )?)); } "plugin" => { - let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Plugin)?; - let plugin_cli = parse_plugin_args(&args[1..])?; - return Ok(Mode::Plugin(plugin_cli)); + let cli = parse_plugin_args(&args[1..])?; + if let plugin_cli::PluginCliCommand::Catalog { command, args } = cli { + if target_selection.explicit_local { + return Err(ParseError("yoi plugin catalog requires a Backend Workspace target and cannot use --local".into())); + } + let target = resolve_tui_target( + connection_resolver, + CliCommand::PluginCatalog, + &target_selection, + ¤t_dir()?, + )? + .resolve() + .map_err(|error| ParseError(error.to_string()))?; + if matches!(target, client::ResolvedTarget::Standalone) { + return Err(ParseError( + "yoi plugin catalog requires a Backend Workspace target".into(), + )); + } + return Ok(Mode::PluginCatalog { + command, + args, + target, + }); + } + if target_selection.backend_url.is_some() || target_selection.workspace_id.is_some() { + return Err(backend_target_option_error_for_local_command( + CliCommand::Plugin, + "--backend/--workspace-id", + )); + } + // Explicit-path authoring has no connection or Workspace dependency. + return Ok(Mode::Plugin(cli)); } "login" => { if target_selection.explicit_local { @@ -1738,7 +1785,8 @@ async fn run_login(backend_url: &str, no_wait: bool) -> Result<(), ParseError> { fn parse_plugin_args(args: &[String]) -> Result { let Some((subcommand, rest)) = args.split_first() else { return Err(ParseError( - "yoi plugin requires `new`, `check`, or `pack`".to_string(), + "yoi plugin requires an authoring or catalog command; see `yoi plugin --help`" + .to_string(), )); }; match subcommand.as_str() { @@ -1790,6 +1838,16 @@ fn parse_plugin_args(args: &[String]) -> Result { + if rest + .iter() + .any(|arg| matches!(arg.as_str(), "--help" | "-h")) + { + return Err(ParseError(plugin_usage().to_string())); + } + plugin_cli::parse_catalog_args(subcommand, rest).map_err(ParseError) + } "--help" | "-h" => Err(ParseError(plugin_usage().to_string())), other => Err(ParseError(format!( "unknown yoi plugin subcommand `{other}`" @@ -1847,7 +1905,7 @@ fn parse_plugin_pack_args( } fn plugin_usage() -> &'static str { - "usage: yoi plugin new [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]" + "usage: yoi plugin new [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n\nCatalog commands require a Backend Workspace target (select --backend/--workspace-id before `plugin`):\n yoi [TARGET] plugin upload [--json]\n yoi [TARGET] plugin install --upload-id --alias --operation-key [--json]\n yoi [TARGET] plugin list [--cursor ] [--limit <1..100>] [--json]\n yoi [TARGET] plugin show [--json]\n yoi [TARGET] plugin add-artifact --upload-id --expected-last-operation-id --operation-key [--json]\n yoi [TARGET] plugin revoke --artifact-digest --reason --expected-last-operation-id --operation-key [--json]\n yoi [TARGET] plugin uninstall --expected-last-operation-id --operation-key [--json]\n yoi [TARGET] plugin operation [--json]\nUpload/install only register artifacts; they do not enable or start Workers. Use the observed last_operation from show; a replay receipt is not a fresh observation. Reconcile uncertain mutations before retrying with the same key and exact input." } fn parse_mcp_args(args: &[String]) -> Result { @@ -2083,7 +2141,7 @@ Host commands: worker delete/prune Safely clean up legacy Worker metadata only ticket Manage Tickets through a Backend target objective Manage Objectives through a Backend target - plugin Author/check/pack explicit Plugin packages + plugin Author packages or manage Backend Plugin catalog mcp Inspect configured MCP servers session Inspect/prune Standalone session logs --local subject Explicitly create/list local Subjects (create , list) @@ -2514,11 +2572,9 @@ backend = "shared" assert!(TOP_LEVEL_HELP.contains("default_connection = \"local\"")); assert!(TOP_LEVEL_HELP.contains("yoi-server")); assert!(TOP_LEVEL_HELP.contains("yoi-runtime")); - assert!( - TOP_LEVEL_HELP.contains( - "plugin Author/check/pack explicit Plugin packages" - ) - ); + assert!(TOP_LEVEL_HELP.contains( + "plugin Author packages or manage Backend Plugin catalog" + )); assert!(!TOP_LEVEL_HELP.contains("Build/check/list/show plugins")); assert!(TOP_LEVEL_HELP.contains("yoi workspace create")); assert!(!TOP_LEVEL_HELP.contains("yoi workspace serve")); @@ -3808,10 +3864,8 @@ backend = "shared" } #[test] - fn plugin_cli_rejects_ambient_catalog_commands_and_options() { + fn plugin_cli_rejects_ambient_catalog_options() { for args in [ - vec!["plugin", "list"], - vec!["plugin", "show", "project:echo"], vec!["plugin", "check", "plugin", "--workspace=/tmp/ws"], vec!["plugin", "check", "plugin", "--profile", "project:inspect"], ] { @@ -3831,6 +3885,111 @@ backend = "shared" } } + #[test] + fn plugin_authoring_never_resolves_a_connection_or_workspace() { + struct NoConnection; + impl CliConnectionResolver for NoConnection { + fn resolve_connection( + &self, + _: CliCommand, + _: cli_connection::CliConnectionInput<'_>, + ) -> Result, ParseError> { + panic!("offline Plugin authoring must not resolve a connection") + } + } + for args in [ + vec!["plugin", "new", "rust-component-tool", "destination"], + vec!["plugin", "check", "explicit.yoi-plugin"], + vec![ + "--local", + "plugin", + "pack", + "explicit", + "--output=explicit.yoi-plugin", + ], + ] { + let argv = args.iter().map(|s| s.to_string()).collect::>(); + assert!(matches!( + parse_args_slice_with_connection_resolver(&argv, &NoConnection).unwrap(), + Mode::Plugin(_) + )); + } + } + + #[test] + fn plugin_catalog_uses_backend_workspace_selection_and_rejects_local() { + let resolver = DefaultBackendCliConnectionResolver { + backend_url: "http://selected.example", + }; + for args in [ + vec!["plugin", "list"], + vec![ + "--backend", + "http://selected.example", + "plugin", + "show", + "installation", + ], + vec![ + "--workspace-id", + "explicit-workspace", + "plugin", + "operation", + "operation", + ], + ] { + let argv = args.iter().map(|s| s.to_string()).collect::>(); + match parse_args_slice_with_connection_resolver(&argv, &resolver).unwrap() { + Mode::PluginCatalog { + target: + client::ResolvedTarget::Backend { + base_url, + workspace_id, + }, + .. + } => { + assert_eq!(base_url, "http://selected.example"); + assert_eq!( + workspace_id, + if args.contains(&"--workspace-id") { + "explicit-workspace" + } else { + "workspace-from-backend" + } + ); + } + other => panic!("expected Backend Plugin catalog mode, got {other:?}"), + } + } + let argv = ["--local", "plugin", "list"].map(str::to_string); + assert!( + parse_args_slice_with_connection_resolver(&argv, &resolver) + .unwrap_err() + .0 + .contains("requires a Backend") + ); + } + + #[test] + fn plugin_explicit_workspace_does_not_discover_repository_and_offline_backend_is_not_local() { + let resolver = FixedCliConnectionResolver { + backend_url: "http://selected.example", + }; + let argv = ["--workspace-id", "explicit", "plugin", "list"].map(str::to_string); + // This resolver has no repository discovery implementation. + assert!(matches!( + parse_args_slice_with_connection_resolver(&argv, &resolver).unwrap(), + Mode::PluginCatalog { .. } + )); + let argv = ["plugin", "list"].map(str::to_string); + assert!( + parse_args_slice_with_connection_resolver(&argv, &OfflineBackendCliConnectionResolver) + .unwrap_err() + .0 + .contains("Backend is offline") + ); + } + #[test] fn parse_mcp_commands() { match parse_args_from(["mcp", "list", "--workspace=/tmp/ws", "--json"]).unwrap() { diff --git a/crates/yoi/src/plugin_cli.rs b/crates/yoi/src/plugin_cli.rs index ee82cdc68..ea7d2c3ac 100644 --- a/crates/yoi/src/plugin_cli.rs +++ b/crates/yoi/src/plugin_cli.rs @@ -1,7 +1,7 @@ use std::error::Error; use std::fmt::Write as _; use std::fs; -use std::io; +use std::io::{self, Read as _}; use std::path::{Path, PathBuf}; use manifest::plugin::{ @@ -38,6 +38,360 @@ pub(crate) enum PluginCliCommand { output: Option, args: PluginCliArgs, }, + Catalog { + command: PluginCatalogCommand, + args: PluginCliArgs, + }, +} + +#[derive(Clone, Debug)] +pub(crate) enum PluginCatalogCommand { + Upload(PathBuf), + Install(server_api::PluginInstallRequest), + List(server_api::PluginListRequest), + Show(String), + AddArtifact { + installation_id: String, + request: server_api::PluginAddArtifactRequest, + }, + Revoke { + installation_id: String, + request: server_api::PluginRevokeRequest, + }, + Uninstall { + installation_id: String, + request: server_api::PluginUninstallRequest, + }, + Operation(String), +} + +pub(crate) fn parse_catalog_args( + subcommand: &str, + args: &[String], +) -> std::result::Result { + let allowed: &[&str] = match subcommand { + "upload" | "show" | "operation" => &[], + "install" => &["--upload-id", "--alias", "--operation-key"], + "list" => &["--cursor", "--limit"], + "add-artifact" => &[ + "--upload-id", + "--expected-last-operation-id", + "--operation-key", + ], + "revoke" => &[ + "--artifact-digest", + "--reason", + "--expected-last-operation-id", + "--operation-key", + ], + "uninstall" => &["--expected-last-operation-id", "--operation-key"], + _ => return Err(format!("unknown yoi plugin subcommand `{subcommand}`")), + }; + let mut options = std::collections::BTreeMap::new(); + let mut positional = Vec::new(); + let mut json = false; + let mut index = 0; + while index < args.len() { + let arg = &args[index]; + if arg == "--json" { + json = true; + } else if arg.starts_with('-') { + let (name, inline) = arg + .split_once('=') + .map_or((arg.as_str(), None), |(k, v)| (k, Some(v))); + if !allowed.contains(&name) { + return Err(format!("unknown yoi plugin {subcommand} option `{name}`")); + } + let value = match inline { + Some(value) => value, + None => { + index += 1; + args.get(index) + .map(String::as_str) + .ok_or_else(|| format!("{name} requires a value"))? + } + }; + if value.trim().is_empty() || value.starts_with('-') { + return Err(format!("{name} requires a nonempty value")); + } + if options + .insert(name.to_string(), value.to_string()) + .is_some() + { + return Err(format!("{name} must not be provided more than once")); + } + } else { + positional.push(arg.clone()); + } + index += 1; + } + let required = |name: &str| -> std::result::Result { + options + .get(name) + .cloned() + .ok_or_else(|| format!("yoi plugin {subcommand} requires {name}")) + }; + let one_positional = || -> std::result::Result { + match positional.as_slice() { + [value] if !value.trim().is_empty() => Ok(value.clone()), + _ => Err(format!( + "yoi plugin {subcommand} requires exactly one {}", + if subcommand == "upload" { + "package path" + } else if subcommand == "operation" { + "operation ID" + } else { + "installation ID" + } + )), + } + }; + if matches!(subcommand, "install" | "list") && !positional.is_empty() { + return Err(format!( + "yoi plugin {subcommand} accepts no positional arguments" + )); + } + let command = match subcommand { + "upload" => PluginCatalogCommand::Upload(PathBuf::from(one_positional()?)), + "install" => PluginCatalogCommand::Install(server_api::PluginInstallRequest { + upload_id: required("--upload-id")?, + alias: required("--alias")?, + operation_key: required("--operation-key")?, + }), + "list" => { + let limit = options + .get("--limit") + .map(|value| { + value + .parse::() + .ok() + .filter(|n| (1..=100).contains(n)) + .ok_or_else(|| "--limit must be between 1 and 100".to_string()) + }) + .transpose()?; + PluginCatalogCommand::List(server_api::PluginListRequest { + cursor: options.get("--cursor").cloned(), + limit, + }) + } + "show" => PluginCatalogCommand::Show(one_positional()?), + "add-artifact" => PluginCatalogCommand::AddArtifact { + installation_id: one_positional()?, + request: server_api::PluginAddArtifactRequest { + upload_id: required("--upload-id")?, + expected_last_operation: required("--expected-last-operation-id")?, + operation_key: required("--operation-key")?, + }, + }, + "revoke" => PluginCatalogCommand::Revoke { + installation_id: one_positional()?, + request: server_api::PluginRevokeRequest { + artifact_digest: required("--artifact-digest")?, + reason: required("--reason")?, + expected_last_operation: required("--expected-last-operation-id")?, + operation_key: required("--operation-key")?, + }, + }, + "uninstall" => PluginCatalogCommand::Uninstall { + installation_id: one_positional()?, + request: server_api::PluginUninstallRequest { + expected_last_operation: required("--expected-last-operation-id")?, + operation_key: required("--operation-key")?, + }, + }, + "operation" => PluginCatalogCommand::Operation(one_positional()?), + _ => unreachable!("validated subcommand"), + }; + Ok(PluginCliCommand::Catalog { + command, + args: PluginCliArgs { json }, + }) +} + +// Use the shared credential resolver without exposing token storage or adding a +// second login path. The generated client owns path encoding and response bounds. +struct PluginAuthorizer(client::BackendApiClient); + +impl server_api::client_support::RequestAuthorizer for PluginAuthorizer { + fn authorize( + &self, + request: server_api::client_support::AuthorizerRequest<'_>, + ) -> std::result::Result< + reqwest::header::HeaderMap, + server_api::client_support::AuthorizationError, + > { + let prepared = self + .0 + .request(request.method.clone(), request.path_and_query) + .and_then(|builder| builder.build().map_err(client::BackendApiClientError::Http)) + .map_err(|_| server_api::client_support::AuthorizationError::new())?; + Ok(prepared.headers().clone()) + } +} + +#[derive(Debug, Serialize)] +struct CatalogFailure { + status: Option, + error: String, + message: String, + diagnostics: Vec, +} + +impl CatalogFailure { + fn local(error: impl std::fmt::Display) -> Self { + Self { + status: None, + error: "plugin_cli".into(), + message: error.to_string(), + diagnostics: Vec::new(), + } + } +} + +impl From> + for CatalogFailure +{ + fn from( + error: server_api::client_support::ClientError, + ) -> Self { + match error { + server_api::client_support::ClientError::Public { status, error } => Self { + status: Some(status.as_u16()), + error: error.error, + message: error.message, + diagnostics: error.diagnostics, + }, + server_api::client_support::ClientError::Failure(error) => Self::local(error), + } + } +} + +pub(crate) struct CatalogOutput { + pub success: bool, + pub stdout: String, +} + +pub(crate) async fn run_catalog( + command: PluginCatalogCommand, + args: PluginCliArgs, + target: client::ResolvedTarget, +) -> CatalogOutput { + let result = async { + let client::ResolvedTarget::Backend { base_url, workspace_id } = target else { + return Err(CatalogFailure::local("Plugin catalog requires a Backend Workspace target; use --backend and --workspace-id")); + }; + let backend = client::BackendApiClient::from_stored_token(&base_url).map_err(CatalogFailure::local)?; + // No redirect may forward a bearer credential to another origin. + let http = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none()) + .build().map_err(CatalogFailure::local)?; + let api = server_api::ServerApiClient::builder(backend.origin().as_str()) + .map_err(CatalogFailure::local)?.client(http).authorizer(PluginAuthorizer(backend)) + .response_body_limit(16 * 1024 * 1024).build().map_err(CatalogFailure::local)?; + execute_catalog(command, &api, workspace_id).await + }.await; + catalog_output(result, args.json) +} + +fn catalog_output( + result: std::result::Result, + json: bool, +) -> CatalogOutput { + let (success, value) = match result { + Ok(value) => (true, value), + Err(error) => ( + false, + serde_json::json!({ + "status": "failed", "diagnostic": error, + "reconciliation": "A failed response does not prove a mutation was not committed. Reconcile the saved operation; retry only with the same operation key and exact input. Do not refresh expected-last-operation-id for a replay." + }), + ), + }; + let rendered = serde_json::to_string_pretty(&value).expect("JSON value is serializable"); + let stdout = if json { + format!("{rendered}\n") + } else { + format!( + "plugin catalog: {}\n{rendered}\n", + if success { + "backend response (registration does not enable or start a Worker)" + } else { + "failed" + } + ) + }; + CatalogOutput { success, stdout } +} + +async fn execute_catalog( + command: PluginCatalogCommand, + api: &server_api::ServerApiClient, + workspace_id: String, +) -> std::result::Result { + let value = match command { + PluginCatalogCommand::Upload(input) => { + let bytes = read_upload(&input).map_err(CatalogFailure::local)?; + serde_json::to_value(api.plugin_upload(workspace_id, bytes.into()).await?) + } + PluginCatalogCommand::Install(request) => { + serde_json::to_value(api.plugin_install(workspace_id, request).await?) + } + PluginCatalogCommand::List(query) => { + serde_json::to_value(api.plugin_list(workspace_id, query).await?) + } + PluginCatalogCommand::Show(id) => { + serde_json::to_value(api.plugin_show(workspace_id, id).await?) + } + PluginCatalogCommand::AddArtifact { + installation_id, + request, + } => serde_json::to_value( + api.plugin_artifact_add(workspace_id, installation_id, request) + .await?, + ), + PluginCatalogCommand::Revoke { + installation_id, + request, + } => serde_json::to_value( + api.plugin_revoke(workspace_id, installation_id, request) + .await?, + ), + PluginCatalogCommand::Uninstall { + installation_id, + request, + } => serde_json::to_value( + api.plugin_uninstall(workspace_id, installation_id, request) + .await?, + ), + PluginCatalogCommand::Operation(id) => { + serde_json::to_value(api.plugin_operation(workspace_id, id).await?) + } + }; + value.map_err(CatalogFailure::local) +} + +fn read_upload(input: &Path) -> Result> { + let limit = PluginPackageLimits::default().max_package_size_bytes; + let file = fs::File::open(input)?; + let metadata = file.metadata()?; + if !metadata.is_file() { + return Err( + "Plugin upload requires an explicit .yoi-plugin regular file; pack directories first" + .into(), + ); + } + if metadata.len() > limit { + return Err( + format!("Plugin upload exceeds the shared package limit of {limit} bytes").into(), + ); + } + let mut bytes = Vec::new(); + file.take(limit + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 > limit { + return Err( + format!("Plugin upload exceeds the shared package limit of {limit} bytes").into(), + ); + } + Ok(bytes) } pub(crate) fn run(command: PluginCliCommand) -> Result<()> { @@ -62,6 +416,9 @@ pub(crate) fn run(command: PluginCliCommand) -> Result<()> { output, args, } => render_pack(&input, output.as_deref(), &args)?, + PluginCliCommand::Catalog { .. } => { + return Err("Plugin catalog requires Backend dispatch".into()); + } }; print!("{rendered}"); Ok(()) @@ -571,6 +928,422 @@ mod tests { use super::*; use tempfile::tempdir; + fn catalog_command(subcommand: &str, args: &[&str]) -> PluginCatalogCommand { + let args = args.iter().map(|s| s.to_string()).collect::>(); + match parse_catalog_args(subcommand, &args).unwrap() { + PluginCliCommand::Catalog { command, .. } => command, + _ => panic!("expected catalog command"), + } + } + + #[test] + fn catalog_parser_requires_explicit_mutation_keys_and_observed_operations() { + for (subcommand, args, missing) in [ + ( + "install", + vec!["--upload-id", "upload", "--alias", "alias"], + "--operation-key", + ), + ( + "add-artifact", + vec![ + "installation", + "--upload-id", + "upload", + "--operation-key", + "key", + ], + "--expected-last-operation-id", + ), + ( + "revoke", + vec![ + "installation", + "--artifact-digest", + "sha256:artifact", + "--reason", + "reason", + "--operation-key", + "key", + ], + "--expected-last-operation-id", + ), + ( + "uninstall", + vec!["installation", "--operation-key", "key"], + "--expected-last-operation-id", + ), + ( + "uninstall", + vec!["installation", "--expected-last-operation-id", "observed"], + "--operation-key", + ), + ] { + let args = args.into_iter().map(str::to_string).collect::>(); + assert!( + parse_catalog_args(subcommand, &args) + .unwrap_err() + .contains(missing) + ); + } + for args in [ + vec![ + "installation", + "--operation-key=", + "--expected-last-operation-id=observed", + ], + vec![ + "installation", + "--operation-key=key", + "--operation-key=other", + "--expected-last-operation-id=observed", + ], + vec![ + "installation", + "--operation-key=key", + "--expected-last-operation-id= ", + ], + vec![ + "installation", + "--operation-key=key", + "--expected-last-operation-id=observed", + "--operator", + ], + ] { + let args = args.into_iter().map(str::to_string).collect::>(); + assert!(parse_catalog_args("uninstall", &args).is_err()); + } + for limit in ["0", "101", "-1", "not-a-number"] { + assert!(parse_catalog_args("list", &[format!("--limit={limit}")]).is_err()); + } + assert!(parse_catalog_args("list", &["extra".into()]).is_err()); + assert!(parse_catalog_args("show", &[]).is_err()); + assert!(parse_catalog_args("operation", &["one".into(), "two".into()]).is_err()); + } + + #[test] + fn upload_reads_explicit_regular_bytes_with_the_shared_package_bound() { + let dir = tempdir().unwrap(); + let input = dir.path().join("package.yoi-plugin"); + let bytes = b"exact uploaded bytes\0"; + fs::write(&input, bytes).unwrap(); + assert_eq!(read_upload(&input).unwrap(), bytes); + assert!( + read_upload(dir.path()) + .unwrap_err() + .to_string() + .contains("regular file") + ); + let file = fs::File::create(&input).unwrap(); + file.set_len(PluginPackageLimits::default().max_package_size_bytes + 1) + .unwrap(); + assert!( + read_upload(&input) + .unwrap_err() + .to_string() + .contains("shared package limit") + ); + } + + // The real argv parser, generated HTTP client and output conversion are connected + // here. Only the remote Server and credential issuance are replaced; this does + // not attest Server authentication, catalog persistence or domain validation. + mod catalog_http { + use super::*; + use std::time::Duration; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + struct TestAuthorizer; + impl server_api::client_support::RequestAuthorizer for TestAuthorizer { + fn authorize( + &self, + _: server_api::client_support::AuthorizerRequest<'_>, + ) -> std::result::Result< + reqwest::header::HeaderMap, + server_api::client_support::AuthorizationError, + > { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::AUTHORIZATION, + "Bearer test-only".parse().unwrap(), + ); + Ok(headers) + } + } + + async fn server( + status: u16, + response: serde_json::Value, + ) -> (String, tokio::task::JoinHandle<(String, Vec)>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let task = tokio::spawn(async move { + tokio::time::timeout(Duration::from_secs(10), async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + let (headers, body_start, content_len) = loop { + let mut buf = [0; 4096]; + let len = stream.read(&mut buf).await.unwrap(); + assert_ne!(len, 0, "request ended before headers"); + request.extend_from_slice(&buf[..len]); + if let Some(end) = request.windows(4).position(|b| b == b"\r\n\r\n") { + let headers = String::from_utf8(request[..end].to_vec()).unwrap(); + let content_len = headers.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length").then(|| value.trim().parse::().unwrap()) + }).unwrap_or(0); + break (headers, end + 4, content_len); + } + }; + while request.len() - body_start < content_len { + let mut buf = [0; 4096]; + let len = stream.read(&mut buf).await.unwrap(); + assert_ne!(len, 0, "request ended before body"); + request.extend_from_slice(&buf[..len]); + } + let body = serde_json::to_vec(&response).unwrap(); + stream.write_all(format!("HTTP/1.1 {status} Response\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()).as_bytes()).await.unwrap(); + stream.write_all(&body).await.unwrap(); + (headers, request[body_start..].to_vec()) + }).await.expect("bounded mock Server request") + }); + (origin, task) + } + + fn installation() -> serde_json::Value { + serde_json::json!({"installation_id":"inst/one", "alias":"alias", "package_id":"example", "last_operation":"saved-operation", "uninstalled":false, "artifacts":[]}) + } + + #[tokio::test] + async fn catalog_commands_send_exact_workspace_paths_query_and_caller_mutation_inputs() { + let dir = tempdir().unwrap(); + let package = dir.path().join("explicit.yoi-plugin"); + fs::write(&package, b"exact\0archive bytes").unwrap(); + let upload_path = package.to_str().unwrap(); + let operation = |kind| serde_json::json!({"operation_id":"saved-operation", "kind":kind, "installation":installation()}); + let mut summary = installation(); + summary.as_object_mut().unwrap().remove("artifacts"); + let upload = serde_json::json!({"upload_id":"upload", "expires_at":"2026-10-30T00:00:00Z", "artifact":{ + "package_id":"example", "name":"Example", "version":"1.0.0", "description":null, + "package_digest":"sha256:logical", "artifact_digest":"sha256:archive", "artifact_size":19, + "schema_version":2, "world":"yoi:plugin/tool-plugin@2.0.0", "required_imports":[], "tools":[], + "connections":[], "requested_grants":[], "config_schema":{"identity":"example", "version":"1", "digest":"sha256:schema", "namespace":"example", "dcdl":"schema"}, + "revoked":false, "revocation_reason":null + }}); + let cases = vec![ + ( + "upload", + vec![upload_path], + "POST", + "/api/w/ws%20space/plugin-uploads", + None, + upload, + ), + ( + "install", + vec![ + "--upload-id=upload", + "--alias=alias", + "--operation-key=exact-key", + ], + "POST", + "/api/w/ws%20space/plugins", + Some( + serde_json::json!({"upload_id":"upload", "alias":"alias", "operation_key":"exact-key"}), + ), + operation("install"), + ), + ( + "list", + vec!["--cursor=next /+=", "--limit=7"], + "GET", + "/api/w/ws%20space/plugins", + None, + serde_json::json!({"installations":[summary], "next_cursor":"opaque-next"}), + ), + ( + "show", + vec!["inst/one"], + "GET", + "/api/w/ws%20space/plugins/inst%2Fone", + None, + installation(), + ), + ( + "add-artifact", + vec![ + "inst/one", + "--upload-id=upload", + "--expected-last-operation-id=observed-operation", + "--operation-key=exact-key", + ], + "POST", + "/api/w/ws%20space/plugins/inst%2Fone/artifacts", + Some( + serde_json::json!({"upload_id":"upload", "expected_last_operation":"observed-operation", "operation_key":"exact-key"}), + ), + operation("add_artifact"), + ), + ( + "revoke", + vec![ + "inst/one", + "--artifact-digest=sha256:archive", + "--reason=explicit reason", + "--expected-last-operation-id=observed-operation", + "--operation-key=exact-key", + ], + "POST", + "/api/w/ws%20space/plugins/inst%2Fone/revoke", + Some( + serde_json::json!({"artifact_digest":"sha256:archive", "reason":"explicit reason", "expected_last_operation":"observed-operation", "operation_key":"exact-key"}), + ), + operation("revoke"), + ), + ( + "uninstall", + vec![ + "inst/one", + "--expected-last-operation-id=observed-operation", + "--operation-key=exact-key", + ], + "POST", + "/api/w/ws%20space/plugins/inst%2Fone/uninstall", + Some( + serde_json::json!({"expected_last_operation":"observed-operation", "operation_key":"exact-key"}), + ), + operation("uninstall"), + ), + ( + "operation", + vec!["saved/operation"], + "GET", + "/api/w/ws%20space/plugin-operations/saved%2Foperation", + None, + operation("install"), + ), + ]; + for (subcommand, args, method, path, expected_body, response) in cases { + let (origin, captured) = server(200, response.clone()).await; + let api = server_api::ServerApiClient::builder(origin.as_str()) + .unwrap() + .authorizer(TestAuthorizer) + .build() + .unwrap(); + let result = + execute_catalog(catalog_command(subcommand, &args), &api, "ws space".into()) + .await + .unwrap(); + assert_eq!( + result, response, + "{subcommand} output must retain authoritative metadata" + ); + let output = catalog_output(Ok(result), true); + assert!(output.success); + assert_eq!( + serde_json::from_str::(&output.stdout).unwrap(), + response + ); + let (headers, body) = captured.await.unwrap(); + let mut start = headers.lines().next().unwrap().split_whitespace(); + assert_eq!(start.next(), Some(method)); + let url = + reqwest::Url::parse(&format!("{origin}{}", start.next().unwrap())).unwrap(); + assert_eq!(url.path(), path, "{subcommand}"); + assert!( + headers + .to_ascii_lowercase() + .contains("authorization: bearer test-only") + ); + if subcommand == "list" { + let query = url + .query_pairs() + .collect::>(); + assert_eq!(query.get("cursor").unwrap(), "next /+="); + assert_eq!(query.get("limit").unwrap(), "7"); + } + if subcommand == "upload" { + assert_eq!(body, b"exact\0archive bytes"); + assert!(headers.contains("application/octet-stream")); + } else if let Some(expected) = expected_body { + assert_eq!( + serde_json::from_slice::(&body).unwrap(), + expected + ); + } else { + assert!(body.is_empty()); + } + } + } + + #[tokio::test] + async fn catalog_rejections_keep_concrete_diagnostics_without_retry() { + for (status, code) in [ + (401, "unauthenticated"), + (403, "forbidden"), + (409, "plugin_operation_conflict"), + (413, "plugin_upload_too_large"), + ] { + let diagnostics = serde_json::json!([{"code":"catalog_rejected", "severity":"error", "message":"safe blocker detail"}]); + let response = serde_json::json!({"error":code,"message":"concrete rejection detail","diagnostics":diagnostics}); + let (origin, captured) = server(status, response).await; + let api = server_api::ServerApiClient::builder(origin.as_str()) + .unwrap() + .authorizer(TestAuthorizer) + .build() + .unwrap(); + let result = execute_catalog( + catalog_command( + "uninstall", + &[ + "inst", + "--expected-last-operation-id=observed", + "--operation-key=exact", + ], + ), + &api, + "ws".into(), + ) + .await; + let output = catalog_output(result, true); + assert!(!output.success); + let report: serde_json::Value = serde_json::from_str(&output.stdout).unwrap(); + assert_eq!(report["diagnostic"]["status"], status); + assert_eq!(report["diagnostic"]["error"], code); + assert_eq!(report["diagnostic"]["message"], "concrete rejection detail"); + assert_eq!(report["diagnostic"]["diagnostics"], diagnostics); + assert!( + report["reconciliation"] + .as_str() + .unwrap() + .contains("same operation key and exact input") + ); + let (_, body) = captured.await.unwrap(); + let body: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(body["expected_last_operation"], "observed"); + assert_eq!(body["operation_key"], "exact"); + } + } + } + + #[tokio::test] + async fn catalog_standalone_target_is_rejected_with_json_diagnostic() { + let output = run_catalog( + catalog_command("list", &[]), + PluginCliArgs { json: true }, + client::ResolvedTarget::Standalone, + ) + .await; + assert!(!output.success); + let report: serde_json::Value = serde_json::from_str(&output.stdout).unwrap(); + assert!( + report["diagnostic"]["message"] + .as_str() + .unwrap() + .contains("Backend Workspace target") + ); + } + fn built_tool_package(destination: &Path) { render_new( "rust-component-tool", diff --git a/openapi/server-api.json b/openapi/server-api.json index ee8c88694..ad01a6caf 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -7359,6 +7359,463 @@ } ] }, + "PluginAddArtifactRequest": { + "additionalProperties": false, + "properties": { + "expected_last_operation": { + "type": "string" + }, + "operation_key": { + "type": "string" + }, + "upload_id": { + "type": "string" + } + }, + "required": [ + "upload_id", + "expected_last_operation", + "operation_key" + ], + "type": "object" + }, + "PluginArtifact": { + "additionalProperties": false, + "properties": { + "artifact_digest": { + "type": "string" + }, + "artifact_size": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "config_schema": { + "$ref": "#/components/schemas/PluginSchemaContribution" + }, + "connections": { + "items": { + "$ref": "#/components/schemas/PluginConnectionMetadata" + }, + "type": "array" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "package_digest": { + "type": "string" + }, + "package_id": { + "type": "string" + }, + "requested_grants": { + "items": { + "$ref": "#/components/schemas/PluginGrantMetadata" + }, + "type": "array" + }, + "required_imports": { + "items": { + "type": "string" + }, + "type": "array" + }, + "revocation_reason": { + "type": [ + "string", + "null" + ] + }, + "revoked": { + "type": "boolean" + }, + "schema_version": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "tools": { + "items": { + "$ref": "#/components/schemas/PluginToolMetadata" + }, + "type": "array" + }, + "version": { + "type": "string" + }, + "world": { + "type": "string" + } + }, + "required": [ + "package_id", + "name", + "version", + "package_digest", + "artifact_digest", + "artifact_size", + "schema_version", + "world", + "required_imports", + "tools", + "connections", + "requested_grants", + "config_schema", + "revoked" + ], + "type": "object" + }, + "PluginConnectionMetadata": { + "additionalProperties": false, + "properties": { + "methods": { + "items": { + "$ref": "#/components/schemas/PluginHttpMethod" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "path_prefixes": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "name", + "methods", + "path_prefixes" + ], + "type": "object" + }, + "PluginGrantMetadata": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "logging", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "https", + "type": "string" + }, + "slot": { + "type": "string" + } + }, + "required": [ + "kind", + "slot" + ], + "type": "object" + } + ] + }, + "PluginHttpMethod": { + "enum": [ + "GET", + "HEAD", + "POST", + "PUT", + "PATCH", + "DELETE", + "OPTIONS" + ], + "type": "string" + }, + "PluginInstallRequest": { + "additionalProperties": false, + "properties": { + "alias": { + "type": "string" + }, + "operation_key": { + "type": "string" + }, + "upload_id": { + "type": "string" + } + }, + "required": [ + "upload_id", + "alias", + "operation_key" + ], + "type": "object" + }, + "PluginInstallation": { + "additionalProperties": false, + "properties": { + "alias": { + "type": "string" + }, + "artifacts": { + "items": { + "$ref": "#/components/schemas/PluginArtifact" + }, + "type": "array" + }, + "installation_id": { + "type": "string" + }, + "last_operation": { + "type": "string" + }, + "package_id": { + "type": "string" + }, + "uninstalled": { + "type": "boolean" + } + }, + "required": [ + "installation_id", + "alias", + "package_id", + "last_operation", + "uninstalled", + "artifacts" + ], + "type": "object" + }, + "PluginInstallationSummary": { + "additionalProperties": false, + "description": "Catalog pages omit artifact/schema payloads so the item bound also bounds\nresponse work. Exact immutable metadata is available from installation show.", + "properties": { + "alias": { + "type": "string" + }, + "installation_id": { + "type": "string" + }, + "last_operation": { + "type": "string" + }, + "package_id": { + "type": "string" + }, + "uninstalled": { + "type": "boolean" + } + }, + "required": [ + "installation_id", + "alias", + "package_id", + "last_operation", + "uninstalled" + ], + "type": "object" + }, + "PluginListRequest": { + "additionalProperties": false, + "properties": { + "cursor": { + "type": [ + "string", + "null" + ] + }, + "limit": { + "format": "uint32", + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "PluginListResponse": { + "additionalProperties": false, + "properties": { + "installations": { + "items": { + "$ref": "#/components/schemas/PluginInstallationSummary" + }, + "type": "array" + }, + "next_cursor": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "installations" + ], + "type": "object" + }, + "PluginOperationKind": { + "enum": [ + "install", + "add_artifact", + "revoke", + "uninstall" + ], + "type": "string" + }, + "PluginOperationResponse": { + "additionalProperties": false, + "properties": { + "installation": { + "$ref": "#/components/schemas/PluginInstallation" + }, + "kind": { + "$ref": "#/components/schemas/PluginOperationKind" + }, + "operation_id": { + "type": "string" + } + }, + "required": [ + "operation_id", + "kind", + "installation" + ], + "type": "object" + }, + "PluginRevokeRequest": { + "additionalProperties": false, + "properties": { + "artifact_digest": { + "type": "string" + }, + "expected_last_operation": { + "type": "string" + }, + "operation_key": { + "type": "string" + }, + "reason": { + "type": "string" + } + }, + "required": [ + "artifact_digest", + "expected_last_operation", + "reason", + "operation_key" + ], + "type": "object" + }, + "PluginSchemaContribution": { + "additionalProperties": false, + "properties": { + "dcdl": { + "type": "string" + }, + "digest": { + "type": "string" + }, + "identity": { + "type": "string" + }, + "namespace": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "identity", + "version", + "digest", + "namespace", + "dcdl" + ], + "type": "object" + }, + "PluginToolMetadata": { + "additionalProperties": false, + "properties": { + "description": { + "type": "string" + }, + "error_codes": { + "items": { + "type": "string" + }, + "type": "array" + }, + "external_write": { + "type": "boolean" + }, + "input_schema": true, + "name": { + "type": "string" + }, + "output_schema": true + }, + "required": [ + "name", + "description", + "input_schema", + "output_schema", + "error_codes", + "external_write" + ], + "type": "object" + }, + "PluginUninstallRequest": { + "additionalProperties": false, + "properties": { + "expected_last_operation": { + "type": "string" + }, + "operation_key": { + "type": "string" + } + }, + "required": [ + "expected_last_operation", + "operation_key" + ], + "type": "object" + }, + "PluginUploadResponse": { + "additionalProperties": false, + "properties": { + "artifact": { + "$ref": "#/components/schemas/PluginArtifact" + }, + "expires_at": { + "type": "string" + }, + "upload_id": { + "type": "string" + } + }, + "required": [ + "upload_id", + "expires_at", + "artifact" + ], + "type": "object" + }, "ProfileSettingsResponse": { "additionalProperties": false, "description": "Read-only Profile catalog projected from one active Workspace config tree.", @@ -18605,7 +19062,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:1f5ae1d92ac04d33a05b9d4a34252e017e668d5c33520490bdfff7a6cde1e3cb" + "x-yoi-source-digest": "sha256:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f" }, "openapi": "3.1.0", "paths": { @@ -28002,6 +28459,1051 @@ ] } }, + "/api/w/{workspace_id}/plugin-operations/{operation_id}": { + "get": { + "operationId": "plugin_operation", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "operation_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PluginOperationResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/plugin-uploads": { + "post": { + "operationId": "plugin_upload", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PluginUploadResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/plugins": { + "get": { + "operationId": "plugin_list", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/PluginListRequest" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PluginListResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + }, + "post": { + "operationId": "plugin_install", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PluginInstallRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PluginOperationResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/plugins/{installation_id}": { + "get": { + "operationId": "plugin_show", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "installation_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PluginInstallation" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/plugins/{installation_id}/artifacts": { + "post": { + "operationId": "plugin_artifact_add", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "installation_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PluginAddArtifactRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PluginOperationResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/plugins/{installation_id}/revoke": { + "post": { + "operationId": "plugin_revoke", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "installation_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PluginRevokeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PluginOperationResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/plugins/{installation_id}/uninstall": { + "post": { + "operationId": "plugin_uninstall", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "installation_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PluginUninstallRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PluginOperationResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, "/api/w/{workspace_id}/repositories": { "get": { "operationId": "repository_list", diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index 364e487da..e99726a94 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:1f5ae1d92ac04d33a05b9d4a34252e017e668d5c33520490bdfff7a6cde1e3cb) +// Input contract: 0.1.0 (sha256:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/plugin-api.ts b/web/workspace/src/lib/generated/plugin-api.ts new file mode 100644 index 000000000..b57411c3d --- /dev/null +++ b/web/workspace/src/lib/generated/plugin-api.ts @@ -0,0 +1,36 @@ +// Generated from server-api. Do not edit by hand. +// Regenerate: cargo run -q -p server-api --features typescript --example generate_plugin_api_types > web/workspace/src/lib/generated/plugin-api.ts + +export type PluginInstallRequest = { upload_id: string, alias: string, operation_key: string, }; + +export type PluginAddArtifactRequest = { upload_id: string, expected_last_operation: string, operation_key: string, }; + +export type PluginRevokeRequest = { artifact_digest: string, expected_last_operation: string, reason: string, operation_key: string, }; + +export type PluginUninstallRequest = { expected_last_operation: string, operation_key: string, }; + +export type PluginListRequest = { cursor: string | null, limit: number | null, }; + +export type PluginSchemaContribution = { identity: string, version: string, digest: string, namespace: string, dcdl: string, }; + +export type PluginToolMetadata = { name: string, description: string, input_schema: unknown, output_schema: unknown, error_codes: Array, external_write: boolean, }; + +export type PluginHttpMethod = "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS"; + +export type PluginConnectionMetadata = { name: string, methods: Array, path_prefixes: Array, }; + +export type PluginGrantMetadata = { "kind": "logging" } | { "kind": "https", slot: string, }; + +export type PluginArtifact = { package_id: string, name: string, version: string, description: string | null, package_digest: string, artifact_digest: string, artifact_size: number, schema_version: number, world: string, required_imports: Array, tools: Array, connections: Array, requested_grants: Array, config_schema: PluginSchemaContribution, revoked: boolean, revocation_reason: string | null, }; + +export type PluginUploadResponse = { upload_id: string, expires_at: string, artifact: PluginArtifact, }; + +export type PluginInstallation = { installation_id: string, alias: string, package_id: string, last_operation: string, uninstalled: boolean, artifacts: Array, }; + +export type PluginInstallationSummary = { installation_id: string, alias: string, package_id: string, last_operation: string, uninstalled: boolean, }; + +export type PluginListResponse = { installations: Array, next_cursor: string | null, }; + +export type PluginOperationKind = "install" | "add_artifact" | "revoke" | "uninstall"; + +export type PluginOperationResponse = { operation_id: string, kind: PluginOperationKind, installation: PluginInstallation, }; diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index 822cae0a9..f71281eb9 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:1f5ae1d92ac04d33a05b9d4a34252e017e668d5c33520490bdfff7a6cde1e3cb) +// Input contract: 0.1.0 (sha256:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index af496ac91..efa2e84e4 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:1f5ae1d92ac04d33a05b9d4a34252e017e668d5c33520490bdfff7a6cde1e3cb) +// Input contract: 0.1.0 (sha256:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 8a4ad6d20..b6953e4d0 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:1f5ae1d92ac04d33a05b9d4a34252e017e668d5c33520490bdfff7a6cde1e3cb) +// Input contract: 0.1.0 (sha256:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 8743febe4..ba17a053c 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:1f5ae1d92ac04d33a05b9d4a34252e017e668d5c33520490bdfff7a6cde1e3cb) +// Input contract: 0.1.0 (sha256:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check