feat: add Workspace Plugin catalog and immutable artifact authority
This commit is contained in:
@@ -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())
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user