api: expose community edition capabilities

This commit is contained in:
a.tolmachev
2026-05-03 15:49:07 +00:00
parent 6fd62df2a8
commit d98aa83b37
12 changed files with 215 additions and 10 deletions
+3 -2
View File
@@ -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:
+39
View File
@@ -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() {
+1
View File
@@ -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;
@@ -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<AppState>) -> Json<serde_json::Value> {
Json(json!(state.service.get_capabilities().await))
}
+23 -7
View File
@@ -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,
+1 -1
View File
@@ -274,7 +274,7 @@
<polygon points="8,1.5 15.5,14.5 0.5,14.5" fill="none"/><path d="M8 6v4M8 11.5v.5"/>
</svg>
<div>
<strong data-i18n="settings.security.not_available">Available later.</strong> <span data-i18n="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.</span>
<strong id="settings-security-capability-title" data-i18n="settings.security.not_available">Available later.</strong> <span id="settings-security-capability-body" data-i18n="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.</span>
</div>
</div>
</div>
+3
View File
@@ -172,6 +172,9 @@
listWorkspaces: function() {
return get('/workspaces');
},
getCapabilities: function() {
return get('/capabilities');
},
createWorkspace: function(payload) {
return post('/workspaces', payload);
},
+34
View File
@@ -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': 'текущая',
+41
View File
@@ -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();
+43
View File
@@ -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<u32>,
pub max_users_per_workspace: Option<u32>,
pub max_agents_per_workspace: Option<u32>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct EditionCapabilities {
pub edition: ProductEdition,
pub supported_protocols: Vec<Protocol>,
pub supported_security_levels: Vec<OperationSecurityLevel>,
pub machine_access_modes: Vec<MachineAccessMode>,
pub limits: EditionLimits,
}
+4
View File
@@ -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,
+15
View File
@@ -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`