feat: add Workspace Plugin catalog and immutable artifact authority

This commit is contained in:
2026-10-29 06:49:51 +09:00
parent 5087abc5ab
commit 7ad6030498
30 changed files with 5643 additions and 42 deletions
+4
View File
@@ -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"]
@@ -0,0 +1,6 @@
fn main() {
print!(
"{}",
server_api::normalized_typescript(server_api::plugin_api_typescript())
);
}
+81
View File
@@ -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<WorkingDirectoryRemovalResponse, RepositoryApiError>;
#[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<PluginUploadResponse, RepositoryApiError>;
#[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<PluginOperationResponse, RepositoryApiError>;
#[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<PluginListResponse, RepositoryApiError>;
#[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<PluginInstallation, RepositoryApiError>;
#[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<PluginOperationResponse, RepositoryApiError>;
#[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<PluginOperationResponse, RepositoryApiError>;
#[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<PluginOperationResponse, RepositoryApiError>;
#[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<PluginOperationResponse, RepositoryApiError>;
// 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.
+184
View File
@@ -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<String>,
pub limit: Option<u32>,
}
#[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<String>,
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<PluginHttpMethod>,
pub path_prefixes: Vec<String>,
}
#[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<String>,
pub package_digest: String,
pub artifact_digest: String,
pub artifact_size: u32,
pub schema_version: u32,
pub world: String,
pub required_imports: Vec<String>,
pub tools: Vec<PluginToolMetadata>,
pub connections: Vec<PluginConnectionMetadata>,
pub requested_grants: Vec<PluginGrantMetadata>,
pub config_schema: PluginSchemaContribution,
pub revoked: bool,
pub revocation_reason: Option<String>,
}
#[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<PluginArtifact>,
}
/// 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<PluginInstallationSummary>,
pub next_cursor: Option<String>,
}
#[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,
}
+66
View File
@@ -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::<crate::PluginInstallRequest>(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")
);
}
}