From d98aa83b37da6c5bde1a93d71ce8fc0f37430e25 Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Sun, 3 May 2026 15:49:07 +0000 Subject: [PATCH] api: expose community edition capabilities --- TASKS.md | 5 +-- apps/admin-api/src/app.rs | 39 ++++++++++++++++++++ apps/admin-api/src/routes.rs | 1 + apps/admin-api/src/routes/capabilities.rs | 8 +++++ apps/admin-api/src/service.rs | 30 ++++++++++++---- apps/ui/html/settings.html | 2 +- apps/ui/js/api.js | 3 ++ apps/ui/js/i18n.js | 34 ++++++++++++++++++ apps/ui/js/settings.js | 41 +++++++++++++++++++++ crates/crank-core/src/edition.rs | 43 +++++++++++++++++++++++ crates/crank-core/src/lib.rs | 4 +++ docs/admin-api.md | 15 ++++++++ 12 files changed, 215 insertions(+), 10 deletions(-) create mode 100644 apps/admin-api/src/routes/capabilities.rs create mode 100644 crates/crank-core/src/edition.rs diff --git a/TASKS.md b/TASKS.md index 70d70ff..0d8c5de 100644 --- a/TASKS.md +++ b/TASKS.md @@ -29,8 +29,9 @@ Implementation slices: 4. Зафиксировать Community defaults в docs и runtime config. Progress: -- pending: define shared capability payload in `crank-core` and `admin-api`; -- pending: expose Community capability defaults to UI; +- done: shared capability payload exists in `crank-core` and `admin-api`; +- done: authenticated `/api/admin/capabilities` exposes Community defaults to UI; +- in_progress: first UI consumer uses capability payload in `Settings`; - pending: gate premium protocols and security levels in wizard and agents flows. DoD: diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs index 59d6f2a..eb0f263 100644 --- a/apps/admin-api/src/app.rs +++ b/apps/admin-api/src/app.rs @@ -23,6 +23,7 @@ use crate::{ update_profile, }, auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles}, + capabilities::get_capabilities, observability::{get_agent_usage, get_log, get_operation_usage, get_usage, list_logs}, operations::{ archive_operation, create_operation, create_version, delete_operation, @@ -167,6 +168,7 @@ pub fn build_app(state: AppState) -> Router { .route("/async-jobs/{job_id}/result", get(get_async_job_result)); let workspace_root_router = Router::new() + .route("/capabilities", get(get_capabilities)) .route("/workspaces", get(list_workspaces).post(create_workspace)) .layer(middleware::from_fn_with_state( state.clone(), @@ -999,6 +1001,43 @@ mod tests { assert_eq!(delete_status, reqwest::StatusCode::NO_CONTENT); } + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn returns_community_capabilities() { + let registry = test_registry().await; + let storage_root = test_storage_root("community_capabilities"); + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + let root_url = base_url + .as_ref() + .split("/api/admin/workspaces/") + .next() + .unwrap(); + + let response = assert_success_json( + client + .get(format!("{root_url}/api/admin/capabilities")) + .send() + .await + .unwrap(), + ) + .await; + + assert_eq!(response["edition"], "community"); + assert_eq!( + response["supported_protocols"], + json!(["rest", "graphql", "grpc"]) + ); + assert_eq!(response["supported_security_levels"], json!(["standard"])); + assert_eq!( + response["machine_access_modes"], + json!(["static_agent_key"]) + ); + assert_eq!(response["limits"]["max_workspaces"], 1); + assert_eq!(response["limits"]["max_users_per_workspace"], 1); + assert_eq!(response["limits"]["max_agents_per_workspace"], 1); + } + #[tokio::test(flavor = "multi_thread")] #[serial] async fn manages_workspace_access_lifecycle() { diff --git a/apps/admin-api/src/routes.rs b/apps/admin-api/src/routes.rs index f53ac88..ff76237 100644 --- a/apps/admin-api/src/routes.rs +++ b/apps/admin-api/src/routes.rs @@ -2,6 +2,7 @@ pub mod access; pub mod agents; pub mod auth; pub mod auth_profiles; +pub mod capabilities; pub mod observability; pub mod operations; pub mod secrets; diff --git a/apps/admin-api/src/routes/capabilities.rs b/apps/admin-api/src/routes/capabilities.rs new file mode 100644 index 0000000..656f75c --- /dev/null +++ b/apps/admin-api/src/routes/capabilities.rs @@ -0,0 +1,8 @@ +use axum::{Json, extract::State}; +use serde_json::json; + +use crate::state::AppState; + +pub async fn get_capabilities(State(state): State) -> Json { + Json(json!(state.service.get_capabilities().await)) +} diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index 2563fb6..2cf15f7 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -9,13 +9,15 @@ use crank_adapter_grpc::test_support as grpc_test_support; use crank_adapter_soap::{SoapServiceSummary, inspect_wsdl}; use crank_core::{ Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AggregationMode, - AsyncJobHandle, AuthConfig, AuthKind, AuthProfile, AuthProfileId, ConfigExport, ExecutionMode, - ExportMode, GeneratedDraft, GeneratedDraftStatus, InvitationId, InvitationStatus, - InvitationToken, InvocationLevel, InvocationLog, InvocationLogId, InvocationSource, - InvocationStatus, JobStatus, MembershipRole, OperationId, OperationStatus, PlatformApiKey, - PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, SampleId, Samples, - Secret, SecretId, SecretKind, SecretStatus, StreamSession, StreamStatus, Target, - TransportBehavior, UsagePeriod, UserId, UserSessionId, Workspace, WorkspaceId, WorkspaceStatus, + AsyncJobHandle, AuthConfig, AuthKind, AuthProfile, AuthProfileId, ConfigExport, + EditionCapabilities, EditionLimits, ExecutionMode, ExportMode, GeneratedDraft, + GeneratedDraftStatus, InvitationId, InvitationStatus, InvitationToken, InvocationLevel, + InvocationLog, InvocationLogId, InvocationSource, InvocationStatus, JobStatus, + MachineAccessMode, MembershipRole, OperationId, OperationSecurityLevel, OperationStatus, + PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus, ProductEdition, + Protocol, SampleId, Samples, Secret, SecretId, SecretKind, SecretStatus, StreamSession, + StreamStatus, Target, TransportBehavior, UsagePeriod, UserId, UserSessionId, Workspace, + WorkspaceId, WorkspaceStatus, }; use crank_mapping::{JsonPathRoot, MappingSet, infer_mapping_from_samples}; use crank_proto::{ProtoService, services_from_descriptor_set_bytes}; @@ -1566,6 +1568,20 @@ impl AdminService { .collect() } + pub async fn get_capabilities(&self) -> EditionCapabilities { + EditionCapabilities { + edition: ProductEdition::Community, + supported_protocols: vec![Protocol::Rest, Protocol::Graphql, Protocol::Grpc], + supported_security_levels: vec![OperationSecurityLevel::Standard], + machine_access_modes: vec![MachineAccessMode::StaticAgentKey], + limits: EditionLimits { + max_workspaces: Some(1), + max_users_per_workspace: Some(1), + max_agents_per_workspace: Some(1), + }, + } + } + #[instrument(skip(self))] pub async fn list_stream_sessions( &self, diff --git a/apps/ui/html/settings.html b/apps/ui/html/settings.html index f426d1e..ee36ee1 100644 --- a/apps/ui/html/settings.html +++ b/apps/ui/html/settings.html @@ -274,7 +274,7 @@
- Available later. Two-factor authentication, passkeys, and multi-session controls will be added later. The current live flow uses password sign-in with one HttpOnly browser session. + Available later. Two-factor authentication, passkeys, and multi-session controls will be added later. The current live flow uses password sign-in with one HttpOnly browser session.
diff --git a/apps/ui/js/api.js b/apps/ui/js/api.js index 19f2527..d04f0b9 100644 --- a/apps/ui/js/api.js +++ b/apps/ui/js/api.js @@ -172,6 +172,9 @@ listWorkspaces: function() { return get('/workspaces'); }, + getCapabilities: function() { + return get('/capabilities'); + }, createWorkspace: function(payload) { return post('/workspaces', payload); }, diff --git a/apps/ui/js/i18n.js b/apps/ui/js/i18n.js index b1dfdb4..52fde34 100644 --- a/apps/ui/js/i18n.js +++ b/apps/ui/js/i18n.js @@ -406,6 +406,23 @@ var TRANSLATIONS = { 'settings.security.capabilities_title': 'More security options', 'settings.security.capabilities_body': 'Two-factor authentication, passkeys, and multi-session controls will be added later. The current live flow uses password sign-in with one HttpOnly browser session.', 'settings.security.not_available': 'Available later.', + 'settings.security.capability_title_community': 'Community build', + 'settings.security.capability_title_enterprise': 'Enterprise build', + 'settings.security.capability_title_cloud': 'Cloud build', + 'settings.security.capability_summary': 'This build supports {protocols}, {access_modes}, and {security_levels} operation security. Limits: {max_workspaces} workspace, {max_users} user, {max_agents} AI agent. Short-lived tokens, one-time tokens, and advanced governance require a commercial edition.', + 'settings.capability.rest': 'REST / HTTP', + 'settings.capability.graphql': 'GraphQL', + 'settings.capability.grpc': 'gRPC unary', + 'settings.capability.websocket': 'WebSocket', + 'settings.capability.soap': 'SOAP', + 'settings.capability.standard': 'standard', + 'settings.capability.elevated': 'elevated', + 'settings.capability.strict': 'strict', + 'settings.capability.static_agent_key': 'static agent keys', + 'settings.capability.short_lived_token': 'short-lived tokens', + 'settings.capability.one_time_token': 'one-time tokens', + 'settings.capability.unlimited': 'unlimited', + 'settings.capability.none': 'none', 'settings.session.title': 'Current session', 'settings.session.browser': 'Browser session', 'settings.session.current': 'current', @@ -1433,6 +1450,23 @@ var TRANSLATIONS = { 'settings.security.capabilities_title': 'Дополнительные параметры безопасности', 'settings.security.capabilities_body': 'Двухфакторная аутентификация, passkeys и управление несколькими сессиями появятся позже. Сейчас доступен вход по паролю с одной HttpOnly browser session.', 'settings.security.not_available': 'Появится позже.', + 'settings.security.capability_title_community': 'Редакция Community', + 'settings.security.capability_title_enterprise': 'Редакция Enterprise', + 'settings.security.capability_title_cloud': 'Редакция Cloud', + 'settings.security.capability_summary': 'В этой сборке доступны {protocols}, {access_modes} и уровень защиты операций {security_levels}. Лимиты: {max_workspaces} воркспейс, {max_users} пользователь, {max_agents} AI-агент. Короткоживущие токены, одноразовые токены и расширенное управление доступом требуют коммерческую редакцию.', + 'settings.capability.rest': 'REST / HTTP', + 'settings.capability.graphql': 'GraphQL', + 'settings.capability.grpc': 'gRPC unary', + 'settings.capability.websocket': 'WebSocket', + 'settings.capability.soap': 'SOAP', + 'settings.capability.standard': 'standard', + 'settings.capability.elevated': 'elevated', + 'settings.capability.strict': 'strict', + 'settings.capability.static_agent_key': 'статические ключи агентов', + 'settings.capability.short_lived_token': 'короткоживущие токены', + 'settings.capability.one_time_token': 'одноразовые токены', + 'settings.capability.unlimited': 'без лимита', + 'settings.capability.none': 'нет', 'settings.session.title': 'Текущая сессия', 'settings.session.browser': 'Сессия браузера', 'settings.session.current': 'текущая', diff --git a/apps/ui/js/settings.js b/apps/ui/js/settings.js index cb40a30..7f23f4e 100644 --- a/apps/ui/js/settings.js +++ b/apps/ui/js/settings.js @@ -1,4 +1,5 @@ var settingsSession = null; +var settingsCapabilities = null; function tKey(key) { return typeof t === 'function' ? t(key) : key; } @@ -70,6 +71,33 @@ function populateCurrentSession(session) { }); } +function capabilityLabel(key) { + return tKey('settings.capability.' + key); +} + +function populateCapabilities(capabilities) { + var title = document.getElementById('settings-security-capability-title'); + var body = document.getElementById('settings-security-capability-body'); + if (!title || !body || !capabilities) { + return; + } + + var protocols = (capabilities.supported_protocols || []).map(capabilityLabel).join(', '); + var accessModes = (capabilities.machine_access_modes || []).map(capabilityLabel).join(', '); + var securityLevels = (capabilities.supported_security_levels || []).map(capabilityLabel).join(', '); + var limits = capabilities.limits || {}; + + title.textContent = tKey('settings.security.capability_title_' + capabilities.edition); + body.textContent = tfKey('settings.security.capability_summary', { + protocols: protocols || capabilityLabel('none'), + access_modes: accessModes || capabilityLabel('none'), + security_levels: securityLevels || capabilityLabel('none'), + max_workspaces: limits.max_workspaces == null ? capabilityLabel('unlimited') : String(limits.max_workspaces), + max_users: limits.max_users_per_workspace == null ? capabilityLabel('unlimited') : String(limits.max_users_per_workspace), + max_agents: limits.max_agents_per_workspace == null ? capabilityLabel('unlimited') : String(limits.max_agents_per_workspace), + }); +} + function populateProfile(session) { if (!session || !session.user) { return; @@ -101,6 +129,18 @@ async function loadProfile() { populateProfile(settingsSession); } +async function loadCapabilities() { + if (!window.CrankApi || typeof window.CrankApi.getCapabilities !== 'function') { + return; + } + + try { + settingsCapabilities = await window.CrankApi.getCapabilities(); + populateCapabilities(settingsCapabilities); + } catch (_error) { + } +} + function bindProfileSave() { var button = document.getElementById('settings-profile-save-btn'); if (!button || button.dataset.bound === 'true') { @@ -330,6 +370,7 @@ document.addEventListener('DOMContentLoaded', function () { injectLanguageSwitcher(); bindSectionNavigation(); loadProfile(); + loadCapabilities(); bindProfileSave(); bindPasswordSave(); loadWorkspaceSettings(); diff --git a/crates/crank-core/src/edition.rs b/crates/crank-core/src/edition.rs new file mode 100644 index 0000000..667dba2 --- /dev/null +++ b/crates/crank-core/src/edition.rs @@ -0,0 +1,43 @@ +use serde::{Deserialize, Serialize}; + +use crate::Protocol; + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ProductEdition { + Community, + Enterprise, + Cloud, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum MachineAccessMode { + StaticAgentKey, + ShortLivedToken, + OneTimeToken, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum OperationSecurityLevel { + Standard, + Elevated, + Strict, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct EditionLimits { + pub max_workspaces: Option, + pub max_users_per_workspace: Option, + pub max_agents_per_workspace: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct EditionCapabilities { + pub edition: ProductEdition, + pub supported_protocols: Vec, + pub supported_security_levels: Vec, + pub machine_access_modes: Vec, + pub limits: EditionLimits, +} diff --git a/crates/crank-core/src/lib.rs b/crates/crank-core/src/lib.rs index a62ef21..272fce2 100644 --- a/crates/crank-core/src/lib.rs +++ b/crates/crank-core/src/lib.rs @@ -1,6 +1,7 @@ pub mod access; pub mod agent; pub mod auth; +pub mod edition; pub mod ids; pub mod observability; pub mod operation; @@ -20,6 +21,9 @@ pub use auth::{ ApiKeyHeaderAuthConfig, ApiKeyQueryAuthConfig, AuthConfig, AuthProfile, BasicAuthConfig, BearerAuthConfig, }; +pub use edition::{ + EditionCapabilities, EditionLimits, MachineAccessMode, OperationSecurityLevel, ProductEdition, +}; pub use ids::{ AgentId, AsyncJobId, AuthProfileId, DescriptorId, InvitationId, InvocationLogId, OperationId, PlatformApiKeyId, SampleId, SecretId, StreamSessionId, ToolId, UserId, UserSessionId, diff --git a/docs/admin-api.md b/docs/admin-api.md index 093d3df..6fa581d 100644 --- a/docs/admin-api.md +++ b/docs/admin-api.md @@ -50,6 +50,21 @@ ## 5. Группы endpoints +### 5.0. Build capabilities + +- `GET /api/admin/capabilities` + +Контракт: + +- endpoint возвращает capability payload текущей сборки; +- payload описывает: + - `edition` + - `supported_protocols` + - `supported_security_levels` + - `machine_access_modes` + - `limits` +- UI использует этот ответ для edition-aware gating и честного product copy. + ### 5.1. Workspaces and members - `GET /api/admin/workspaces`