feat: complete Epic 1 production foundation

This commit is contained in:
2026-08-25 01:24:11 +03:00
parent 767428436d
commit 182bde8ac0
298 changed files with 35719 additions and 5299 deletions
+2
View File
@@ -41,10 +41,12 @@ time.workspace = true
tokio = { workspace = true, features = ["fs"] }
tracing.workspace = true
tracing-subscriber.workspace = true
url.workspace = true
uuid.workspace = true
[dev-dependencies]
async-trait = "0.1"
crank-community-mcp = { path = "../../crates/crank-community-mcp" }
crank-test-support = { path = "../../crates/crank-test-support" }
metrics.workspace = true
metrics-util = "0.20.4"
+40 -7
View File
@@ -1,10 +1,15 @@
use axum::{
Router, middleware,
Router,
extract::DefaultBodyLimit,
middleware,
routing::{delete, get, post},
};
use crate::{
auth::{require_session, require_workspace_session},
auth::{
enforce_browser_security, require_csrf_for_browser_mutations, require_session,
require_workspace_session,
},
rate_limit::apply_api_rate_limit,
request_context::apply_request_context,
routes::{
@@ -15,14 +20,18 @@ use crate::{
list_agent_platform_api_keys, list_agents, preview_tool_search, publish_agent,
revoke_agent_platform_api_key, save_agent_bindings, unpublish_agent, update_agent,
},
auth::{change_password, get_profile, get_session, login, logout, update_profile},
auth::{
bootstrap_status, change_password, complete_bootstrap, get_profile, get_session, login,
logout, refresh_session_csrf, update_profile,
},
auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles},
capabilities::get_capabilities,
imports::{create_openapi_import, preview_openapi_import},
observability::{
get_agent_usage, get_approval, get_log, get_operation_usage, get_usage, list_approvals,
list_logs,
approve_approval, deny_approval, export_logs_csv, export_usage_csv, get_agent_usage,
get_approval, get_log, get_operation_usage, get_usage, list_approvals, list_logs,
},
onboarding::{get_onboarding, record_onboarding_event, reset_onboarding_selection},
operations::{
analyze_operation_quality, archive_operation, create_operation, create_version,
delete_operation, export_operation, generate_draft, get_operation,
@@ -48,7 +57,10 @@ pub fn build_app(state: AppState) -> Router {
"/operations/analyze-quality",
post(analyze_operation_quality),
)
.route("/operations/import", post(import_operation))
.route(
"/operations/import",
post(import_operation).layer(DefaultBodyLimit::max(256 * 1024)),
)
.route(
"/operations/{operation_id}",
get(get_operation)
@@ -126,12 +138,22 @@ pub fn build_app(state: AppState) -> Router {
.route("/secrets/{secret_id}/rotate", post(rotate_secret))
.route("/export", get(export_workspace))
.route("/logs", get(list_logs))
.route("/logs/export.csv", get(export_logs_csv))
.route("/logs/{log_id}", get(get_log))
.route("/approvals", get(list_approvals))
.route("/approvals/{approval_id}", get(get_approval))
.route("/approvals/{approval_id}/approve", post(approve_approval))
.route("/approvals/{approval_id}/deny", post(deny_approval))
.route("/usage/export.csv", get(export_usage_csv))
.route("/usage", get(get_usage))
.route("/usage/operations/{operation_id}", get(get_operation_usage))
.route("/usage/agents/{agent_id}", get(get_agent_usage));
.route("/usage/agents/{agent_id}", get(get_agent_usage))
.route("/onboarding", get(get_onboarding))
.route("/onboarding/events", post(record_onboarding_event))
.route(
"/onboarding/reset-selection",
post(reset_onboarding_selection),
);
let workspace_root_router = Router::new()
.route("/capabilities", get(get_capabilities))
@@ -157,6 +179,7 @@ pub fn build_app(state: AppState) -> Router {
let protected_auth_router = Router::new()
.route("/logout", post(logout))
.route("/session", get(get_session))
.route("/session/csrf", post(refresh_session_csrf))
.route("/profile", get(get_profile).patch(update_profile))
.route("/password", post(change_password))
.layer(middleware::from_fn_with_state(
@@ -170,10 +193,20 @@ pub fn build_app(state: AppState) -> Router {
.nest(
"/api/auth",
Router::new()
.route("/bootstrap/status", get(bootstrap_status))
.route("/bootstrap/complete", post(complete_bootstrap))
.route("/login", post(login))
.merge(protected_auth_router),
)
.nest("/api/admin", admin_router)
.layer(middleware::from_fn_with_state(
state.clone(),
require_csrf_for_browser_mutations,
))
.layer(middleware::from_fn_with_state(
state.clone(),
enforce_browser_security,
))
.layer(middleware::from_fn_with_state(
state.clone(),
apply_api_rate_limit,
+131 -3
View File
@@ -1,22 +1,24 @@
use axum::{
extract::{OriginalUri, Request, State},
http::{HeaderValue, Method, header},
middleware::Next,
response::Response,
};
use axum_extra::extract::cookie::{Cookie, CookieJar};
use crank_community_auth::{
cleared_session_cookie as build_cleared_session_cookie,
cleared_session_cookie as build_cleared_session_cookie, create_csrf_token as build_csrf_token,
create_session_cookie as build_session_cookie, hash_password as community_hash_password,
session_cookie as build_session_cookie_header,
};
use crank_core::{User, UserSessionId, WorkspaceId};
use crank_core::{MembershipRole, User, UserSessionId, WorkspaceId};
use crank_registry::WorkspaceMembershipRecord;
use serde::Serialize;
use crate::{error::ApiError, state::AppState};
pub use crank_community_auth::{
SESSION_COOKIE_NAME, SessionCookie, extract_session_token, hash_session_secret, verify_password,
SESSION_COOKIE_NAME, SessionCookie, create_csrf_token, extract_session_token, hash_csrf_token,
hash_session_secret, verify_password,
};
#[derive(Clone)]
@@ -53,6 +55,10 @@ pub fn create_session_cookie(settings: &AuthSettings) -> Result<SessionCookie, A
.map_err(|error| ApiError::internal(format!("failed to create session cookie: {error}")))
}
pub fn create_csrf_token_value() -> String {
build_csrf_token()
}
pub fn session_cookie(settings: &AuthSettings, token: &str) -> Cookie<'static> {
build_session_cookie_header(token, settings.cookie_secure, settings.session_ttl_hours)
}
@@ -90,11 +96,81 @@ pub async fn require_workspace_session(
if !has_access {
return Err(ApiError::forbidden("workspace access denied"));
}
if !matches!(
request.method(),
&axum::http::Method::GET | &axum::http::Method::HEAD
) && !session.memberships.iter().any(|membership| {
membership.workspace.id == workspace_id && membership.role == MembershipRole::Owner
}) {
return Err(ApiError::forbidden("workspace owner access required"));
}
request.extensions_mut().insert(session);
Ok(next.run(request).await)
}
pub async fn require_csrf_for_browser_mutations(
State(state): State<AppState>,
jar: CookieJar,
request: Request,
next: Next,
) -> Result<Response, ApiError> {
if !requires_csrf(request.method(), request.uri().path()) {
return Ok(next.run(request).await);
}
let (session_id, _session_value) = extract_session_token(&jar)
.ok_or_else(|| ApiError::unauthorized("authentication required"))?;
let token = request
.headers()
.get("x-csrf-token")
.and_then(|value| value.to_str().ok())
.filter(|value| valid_csrf_token(value))
.ok_or_else(|| ApiError::forbidden("csrf validation failed"))?;
let csrf_hash = hash_csrf_token(
&session_id,
token,
&state.service.auth_settings().session_secret,
);
if !state
.service
.verify_session_csrf(&session_id, &csrf_hash)
.await?
{
return Err(ApiError::forbidden("csrf validation failed"));
}
Ok(next.run(request).await)
}
pub async fn enforce_browser_security(
State(state): State<AppState>,
request: Request,
next: Next,
) -> Result<Response, ApiError> {
if let Some(origin) = request.headers().get(header::ORIGIN)
&& is_cross_origin(
origin,
request.headers().get(header::HOST),
if state.service.auth_settings().cookie_secure {
"https"
} else {
"http"
},
)
&& request.uri().path().starts_with("/api/")
{
return Err(ApiError::forbidden("cross-origin admin request denied"));
}
let mut response = next.run(request).await;
let headers = response.headers_mut();
headers.insert(
"x-content-type-options",
HeaderValue::from_static("nosniff"),
);
headers.insert("x-frame-options", HeaderValue::from_static("DENY"));
headers.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
Ok(response)
}
async fn resolve_authenticated_session(
state: AppState,
jar: &CookieJar,
@@ -122,3 +198,55 @@ fn workspace_id_from_path(path: &str) -> Option<WorkspaceId> {
Some(WorkspaceId::new(workspace_id))
}
fn requires_csrf(method: &Method, path: &str) -> bool {
if matches!(method, &Method::GET | &Method::HEAD | &Method::OPTIONS) {
return false;
}
if matches!(
path,
"/api/auth/login" | "/api/auth/bootstrap/complete" | "/api/auth/session/csrf"
) {
return false;
}
path.starts_with("/api/auth/") || path.starts_with("/api/admin/")
}
fn valid_csrf_token(value: &str) -> bool {
(32..=256).contains(&value.len())
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
}
fn is_cross_origin(
origin: &HeaderValue,
host: Option<&HeaderValue>,
expected_scheme: &str,
) -> bool {
let Some(host) = host.and_then(|value| value.to_str().ok()) else {
return true;
};
let Some(origin) = origin.to_str().ok() else {
return true;
};
let Ok(url) = url::Url::parse(origin) else {
return true;
};
url.host_str()
.zip(url.port_or_known_default())
.map(|(origin_host, origin_port)| {
let origin_authority = format!("{}://{origin_host}:{origin_port}", url.scheme());
let request_authority = if host.contains(':') {
format!("{expected_scheme}://{}", host.to_ascii_lowercase())
} else {
let default_port = if expected_scheme == "https" { 443 } else { 80 };
format!(
"{expected_scheme}://{}:{default_port}",
host.to_ascii_lowercase()
)
};
origin_authority.to_ascii_lowercase() != request_authority
})
.unwrap_or(true)
}
+758 -48
View File
@@ -1,15 +1,19 @@
use std::{process::ExitCode, time::Duration};
#[path = "crank_migrate/admin_auth_command.rs"]
mod admin_auth_command;
#[path = "crank_migrate/db_connect.rs"]
mod db_connect;
use crank_config::{ConfigSource, DatabaseSettings, parse_migrator};
use crank_config::{ConfigSource, parse_migrator};
use crank_registry::{
BackfillPolicy, MigrationApplyResult, MigrationAuthority, MigrationPreflight,
BackfillPolicy, MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate,
MasterKeyRotationRecord, MigrationApplyResult, MigrationAuthority, MigrationPreflight,
PostgresRegistry, RegistryError, SecretVersionRecord,
};
use crank_runtime::SecretCrypto;
use serde_json::json;
use sqlx::{
PgPool,
postgres::{PgConnectOptions, PgPoolOptions},
};
use std::{path::Path, process::ExitCode};
use time::OffsetDateTime;
const MASTER_KEY_ROTATION_PAGE_SIZE: i64 = 1_000;
#[tokio::main]
async fn main() -> ExitCode {
match run().await {
@@ -37,7 +41,6 @@ struct CliError {
recovery: &'static str,
version: Option<i64>,
}
impl CliError {
const fn new(code: &'static str, stage: &'static str, recovery: &'static str) -> Self {
Self {
@@ -47,7 +50,6 @@ impl CliError {
version: None,
}
}
fn from_migration(error: crank_registry::MigrationError) -> Self {
Self {
code: error.code(),
@@ -56,20 +58,87 @@ impl CliError {
version: error.version(),
}
}
fn from_registry(error: RegistryError) -> Self {
match error {
RegistryError::MasterKeyIdentityMismatch { .. } => Self::new(
"master_key_identity_mismatch",
"master_key.identity",
"use_matching_master_key",
),
RegistryError::InvalidMasterKeyIdentity => Self::new(
"master_key_identity_invalid",
"master_key.identity",
"verify_operator_input",
),
RegistryError::MasterKeyRotationInProgress => Self::new(
"master_key_rotation_in_progress",
"master_key.rotation",
"resume_verify_promote_or_abort_rotation",
),
RegistryError::MasterKeyRotationNotFound { .. } => Self::new(
"master_key_rotation_not_found",
"master_key.rotation",
"run_status",
),
RegistryError::MasterKeyRotationConflict => Self::new(
"master_key_rotation_conflict",
"master_key.rotation",
"run_status",
),
RegistryError::MasterKeyRotationVerificationFailed => Self::new(
"master_key_rotation_verification_failed",
"master_key.rotation",
"rerun_rotation_or_abort",
),
RegistryError::AdminBootstrapUnavailable => Self::new(
"admin_bootstrap_unavailable",
"admin_auth.bootstrap",
"use_existing_active_contract_or_wait_until_expired",
),
RegistryError::AdminBootstrapRejected => Self::new(
"admin_bootstrap_rejected",
"admin_auth.bootstrap",
"create_new_local_bootstrap_contract",
),
RegistryError::AdminRecoveryRejected => Self::new(
"admin_recovery_rejected",
"admin_auth.recovery",
"verify_local_inputs_and_master_key",
),
RegistryError::AdminLoginRateLimited { .. } => Self::new(
"admin_login_rate_limited",
"admin_auth.login",
"retry_after_delay",
),
RegistryError::AdminCsrfRejected => {
Self::new("admin_csrf_rejected", "admin_auth.csrf", "refresh_session")
}
RegistryError::Storage(_) => {
Self::new("storage_unavailable", "database.query", "contact_operator")
}
_ => Self::new("registry_error", "registry.operation", "contact_operator"),
}
}
}
async fn run() -> Result<ExitCode, CliError> {
let mut arguments = std::env::args().skip(1);
let requested = arguments.next();
let option = arguments.next();
if arguments.next().is_some() {
let args = std::env::args().skip(1).collect::<Vec<_>>();
if args.first().map(String::as_str) == Some("master-key") {
return run_master_key(&args[1..]).await;
}
if args.first().map(String::as_str) == Some("admin-auth") {
return admin_auth_command::run_admin_auth(&args[1..]).await;
}
let requested = args.first().map(String::as_str);
let option = args.get(1).map(String::as_str);
if args.len() > 2 {
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"run_preflight",
));
}
let command = match requested.as_deref() {
let command = match requested {
None | Some("preflight") => "preflight",
Some("plan") => "plan",
Some("apply") => "apply",
@@ -117,7 +186,7 @@ async fn run() -> Result<ExitCode, CliError> {
})
.collect::<Vec<_>>();
let plan = json!({ "schema_version": 1, "sequence": sequence });
if option.as_deref() == Some("--check") {
if option == Some("--check") {
let bytes = std::fs::read("docs/schemas/migration-sequence.json")
.map_err(|_| CliError::new("contract_drift", "plan.read", "contact_operator"))?;
if bytes.len() > 65_536 {
@@ -162,7 +231,7 @@ async fn run() -> Result<ExitCode, CliError> {
.map_err(|_| CliError::new("config_invalid", "config.source", "run_preflight"))?,
)
.map_err(|_| CliError::new("config_invalid", "config.validate", "run_preflight"))?;
let pool = connect(&config.database).await?;
let pool = db_connect::connect(&config.database).await?;
if command == "apply" {
let result = MigrationAuthority::apply(&pool)
@@ -204,39 +273,680 @@ async fn run() -> Result<ExitCode, CliError> {
}
}
async fn connect(config: &DatabaseSettings) -> Result<PgPool, CliError> {
let options = if let Some(url) = &config.url {
url.expose_secret()
.parse::<PgConnectOptions>()
.map_err(|_| CliError::new("config_invalid", "database.source", "run_preflight"))?
} else {
PgConnectOptions::new()
.host(&config.host)
.port(config.port)
.database(&config.database)
.username(&config.username)
.password(config.password.expose_secret())
async fn run_master_key(arguments: &[String]) -> Result<ExitCode, CliError> {
let Some(command) = arguments.first().map(String::as_str) else {
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"run_master_key_status",
));
};
for attempt in 1..=10 {
let result = PgPoolOptions::new()
.max_connections(config.pool.max_connections)
.min_connections(config.pool.min_connections)
.acquire_timeout(Duration::from_millis(config.pool.acquire_timeout_ms))
.idle_timeout(Duration::from_millis(config.pool.idle_timeout_ms))
.max_lifetime(Duration::from_millis(config.pool.max_lifetime_ms))
.connect_with(options.clone())
.await;
match result {
Ok(pool) => return Ok(pool),
Err(_) if attempt < 10 => {
tokio::time::sleep(Duration::from_secs(1)).await;
let options = MasterKeyOptions::parse(&arguments[1..])?;
let config = parse_migrator(
ConfigSource::from_os_for_migrator()
.map_err(|_| CliError::new("config_invalid", "config.source", "run_preflight"))?,
)
.map_err(|_| CliError::new("config_invalid", "config.validate", "run_preflight"))?;
let registry = db_connect::connect_registry(&config.database).await?;
match command {
"status" => {
ensure_no_options(&options)?;
let status = registry
.master_key_rotation_status()
.await
.map_err(CliError::from_registry)?;
println!(
"{}",
json!({
"status": "ok",
"active_epoch": status.active_identity.as_ref().map(|identity| identity.epoch),
"rotation_count": status.rotations.len(),
"rotations": status.rotations.iter().map(rotation_json).collect::<Vec<_>>(),
})
);
}
"preflight" => {
let preflight = master_key_preflight(&registry, &options).await?;
println!(
"{}",
json!({
"status": "preflight_ok",
"source_epoch": preflight.source_epoch,
"target_epoch": preflight.target_epoch,
"affected_secret_versions": preflight.affected_secret_versions,
"backup_ref": preflight.backup_ref.map(|_| "configured"),
})
);
}
"rotate" => {
let (rotation, current_crypto, target_crypto) =
if let Some(rotation) = active_rotation(&registry, &["running"]).await? {
let (current_crypto, target_crypto) =
rotation_crypto_for_resume(&registry, &options, &rotation).await?;
(rotation, current_crypto, target_crypto)
} else {
let preflight = master_key_preflight(&registry, &options).await?;
let now = OffsetDateTime::now_utc();
let rotation = registry
.begin_master_key_rotation(
preflight.source_epoch,
preflight.target_epoch,
preflight.target_crypto.master_key_fingerprint(),
preflight.backup_ref.as_deref(),
&now,
)
.await
.map_err(CliError::from_registry)?;
(rotation, preflight.current_crypto, preflight.target_crypto)
};
let processed = process_rotation_batch(
&registry,
&rotation,
&current_crypto,
&target_crypto,
options.max_versions,
)
.await?;
let status = if rotation.processed_secret_versions + processed
>= rotation.total_secret_versions
{
let now = OffsetDateTime::now_utc();
registry
.finish_master_key_rotation_batches(&rotation.id, &now)
.await
.map_err(CliError::from_registry)?
} else {
registry
.master_key_rotation_status()
.await
.map_err(CliError::from_registry)?
.rotations
.into_iter()
.find(|candidate| candidate.id == rotation.id)
.ok_or_else(|| {
CliError::new(
"master_key_rotation_not_found",
"master_key.rotation",
"run_status",
)
})?
};
println!(
"{}",
json!({
"status": status.state,
"rotation_id": status.id,
"source_epoch": status.source_epoch,
"target_epoch": status.target_epoch,
"processed_secret_versions": status.processed_secret_versions,
"total_secret_versions": status.total_secret_versions,
})
);
}
"verify" => {
let target_key = read_required_key_file(options.target_key_file.as_deref())?;
let rotation = active_rotation(&registry, &["verifying"])
.await?
.ok_or_else(|| {
CliError::new(
"master_key_rotation_conflict",
"master_key.rotation",
"run_rotate",
)
})?;
let target_crypto = SecretCrypto::with_epoch(&target_key, rotation.target_epoch)
.map_err(|_| {
CliError::new(
"master_key_invalid",
"master_key.input",
"verify_operator_input",
)
})?;
if target_crypto.master_key_fingerprint() != rotation.target_fingerprint {
return Err(CliError::new(
"master_key_identity_mismatch",
"master_key.identity",
"use_matching_target_key",
));
}
Err(_) => break,
let verified = verify_staged_targets(&registry, &rotation, &target_crypto).await?;
let now = OffsetDateTime::now_utc();
let status = registry
.verify_master_key_rotation(&rotation.id, verified, &now)
.await
.map_err(CliError::from_registry)?;
println!(
"{}",
json!({
"status": status.state,
"rotation_id": status.id,
"verified_secret_versions": status.verified_secret_versions,
"total_secret_versions": status.total_secret_versions,
})
);
}
"promote" => {
let target_key = read_required_key_file(options.target_key_file.as_deref())?;
let rotation = active_rotation(&registry, &["verified"])
.await?
.ok_or_else(|| {
CliError::new(
"master_key_rotation_conflict",
"master_key.rotation",
"run_verify",
)
})?;
let target_crypto = SecretCrypto::with_epoch(&target_key, rotation.target_epoch)
.map_err(|_| {
CliError::new(
"master_key_invalid",
"master_key.input",
"verify_operator_input",
)
})?;
if target_crypto.master_key_fingerprint() != rotation.target_fingerprint {
return Err(CliError::new(
"master_key_identity_mismatch",
"master_key.identity",
"use_matching_target_key",
));
}
verify_staged_targets(&registry, &rotation, &target_crypto).await?;
let now = OffsetDateTime::now_utc();
let status = registry
.promote_master_key_rotation(
&rotation.id,
MasterKeyIdentityCandidate {
epoch: rotation.target_epoch,
fingerprint: target_crypto.master_key_fingerprint(),
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &now,
},
&now,
)
.await
.map_err(CliError::from_registry)?;
println!(
"{}",
json!({
"status": status.state,
"rotation_id": status.id,
"active_epoch": status.target_epoch,
})
);
}
"abort" => {
let rotation_id = options
.rotation_id
.as_deref()
.ok_or_else(|| CliError::new("invalid_command", "cli.arguments", "run_status"))?;
let now = OffsetDateTime::now_utc();
let status = registry
.abort_master_key_rotation(rotation_id, &now)
.await
.map_err(CliError::from_registry)?;
println!(
"{}",
json!({
"status": status.state,
"rotation_id": status.id,
"active_epoch": status.source_epoch,
})
);
}
_ => {
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"run_master_key_status",
));
}
}
Err(CliError::new(
"storage_unavailable",
"database.connect",
"contact_operator",
))
Ok(ExitCode::SUCCESS)
}
#[derive(Default)]
struct MasterKeyOptions {
current_key_file: Option<String>,
target_key_file: Option<String>,
backup_ref: Option<String>,
rotation_id: Option<String>,
max_versions: Option<usize>,
}
impl MasterKeyOptions {
fn parse(arguments: &[String]) -> Result<Self, CliError> {
let mut options = Self::default();
let mut index = 0;
while index < arguments.len() {
let key = arguments[index].as_str();
let Some(value) = arguments.get(index + 1) else {
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"run_master_key_status",
));
};
match key {
"--current-key-file" => options.current_key_file = Some(value.clone()),
"--target-key-file" => options.target_key_file = Some(value.clone()),
"--backup-ref" => options.backup_ref = Some(value.clone()),
"--rotation-id" => options.rotation_id = Some(value.clone()),
"--max-versions" => {
let parsed = value.parse::<usize>().map_err(|_| {
CliError::new("invalid_command", "cli.arguments", "run_master_key_status")
})?;
if parsed == 0 || parsed > 10_000 {
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"run_master_key_status",
));
}
options.max_versions = Some(parsed);
}
_ => {
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"run_master_key_status",
));
}
}
index += 2;
}
Ok(options)
}
}
struct MasterKeyPreflight {
source_epoch: i64,
target_epoch: i64,
affected_secret_versions: usize,
backup_ref: Option<String>,
current_crypto: SecretCrypto,
target_crypto: SecretCrypto,
}
async fn master_key_preflight(
registry: &PostgresRegistry,
options: &MasterKeyOptions,
) -> Result<MasterKeyPreflight, CliError> {
let current_key = read_required_key_file(options.current_key_file.as_deref())?;
let target_key = read_required_key_file(options.target_key_file.as_deref())?;
let status = registry
.master_key_rotation_status()
.await
.map_err(CliError::from_registry)?;
if status.rotations.iter().any(|rotation| {
matches!(
rotation.state.as_str(),
"running" | "verifying" | "verified"
)
}) {
return Err(CliError::new(
"master_key_rotation_in_progress",
"master_key.rotation",
"resume_verify_promote_or_abort_rotation",
));
}
let active = status.active_identity.ok_or_else(|| {
CliError::new(
"master_key_identity_missing",
"master_key.identity",
"start_secret_process_once",
)
})?;
let current_crypto = SecretCrypto::with_epoch(&current_key, active.epoch).map_err(|_| {
CliError::new(
"master_key_invalid",
"master_key.input",
"verify_operator_input",
)
})?;
if current_crypto.master_key_fingerprint() != active.fingerprint {
return Err(CliError::new(
"master_key_identity_mismatch",
"master_key.identity",
"use_matching_current_key",
));
}
let target_epoch = active.epoch + 1;
let target_crypto = SecretCrypto::with_epoch(&target_key, target_epoch).map_err(|_| {
CliError::new(
"master_key_invalid",
"master_key.input",
"verify_operator_input",
)
})?;
if target_crypto.master_key_fingerprint() == current_crypto.master_key_fingerprint()
|| registry
.master_key_fingerprint_exists(target_crypto.master_key_fingerprint())
.await
.map_err(CliError::from_registry)?
{
return Err(CliError::new(
"master_key_rotation_conflict",
"master_key.rotation",
"choose_new_target_key",
));
}
let affected_secret_versions =
count_and_verify_current_versions(registry, active.epoch, &current_crypto).await?;
Ok(MasterKeyPreflight {
source_epoch: active.epoch,
target_epoch,
affected_secret_versions,
backup_ref: options.backup_ref.clone(),
current_crypto,
target_crypto,
})
}
async fn rotation_crypto_for_resume(
registry: &PostgresRegistry,
options: &MasterKeyOptions,
rotation: &MasterKeyRotationRecord,
) -> Result<(SecretCrypto, SecretCrypto), CliError> {
let current_key = read_required_key_file(options.current_key_file.as_deref())?;
let target_key = read_required_key_file(options.target_key_file.as_deref())?;
let active = registry
.active_master_key_identity()
.await
.map_err(CliError::from_registry)?
.ok_or_else(|| {
CliError::new(
"master_key_identity_missing",
"master_key.identity",
"start_secret_process_once",
)
})?;
let current_crypto =
SecretCrypto::with_epoch(&current_key, rotation.source_epoch).map_err(|_| {
CliError::new(
"master_key_invalid",
"master_key.input",
"verify_operator_input",
)
})?;
if active.epoch != rotation.source_epoch
|| active.fingerprint != current_crypto.master_key_fingerprint()
{
return Err(CliError::new(
"master_key_identity_mismatch",
"master_key.identity",
"use_matching_current_key",
));
}
let target_crypto =
SecretCrypto::with_epoch(&target_key, rotation.target_epoch).map_err(|_| {
CliError::new(
"master_key_invalid",
"master_key.input",
"verify_operator_input",
)
})?;
if target_crypto.master_key_fingerprint() != rotation.target_fingerprint {
return Err(CliError::new(
"master_key_identity_mismatch",
"master_key.identity",
"use_matching_target_key",
));
}
Ok((current_crypto, target_crypto))
}
async fn process_rotation_batch(
registry: &PostgresRegistry,
rotation: &MasterKeyRotationRecord,
current_crypto: &SecretCrypto,
target_crypto: &SecretCrypto,
max_versions: Option<usize>,
) -> Result<i64, CliError> {
let mut processed = 0_i64;
let mut after_secret_id: Option<String> = None;
let mut after_version: Option<u32> = None;
loop {
let versions = registry
.list_secret_versions_for_master_key_epoch_page(
rotation.source_epoch,
after_secret_id.as_deref(),
after_version,
MASTER_KEY_ROTATION_PAGE_SIZE,
)
.await
.map_err(CliError::from_registry)?;
if versions.is_empty() {
break;
}
for version in versions {
after_secret_id = Some(version.secret_version.secret_id.as_str().to_owned());
after_version = Some(version.secret_version.version);
if version.target_master_key_epoch == Some(rotation.target_epoch) {
continue;
}
if max_versions.is_some_and(|limit| processed as usize >= limit) {
return Ok(processed);
}
let plaintext = decrypt_current(&version, current_crypto)?;
let target_ciphertext = target_crypto.encrypt(&plaintext).map_err(|_| {
CliError::new(
"master_key_rotation_verification_failed",
"master_key.rotation",
"rerun_rotation_or_abort",
)
})?;
let now = OffsetDateTime::now_utc();
registry
.stage_master_key_rotation_ciphertext(
&rotation.id,
&version.secret_version.secret_id,
version.secret_version.version,
rotation.source_epoch,
&target_ciphertext,
target_crypto.key_version(),
rotation.target_epoch,
&now,
)
.await
.map_err(CliError::from_registry)?;
processed += 1;
}
}
Ok(processed)
}
async fn verify_staged_targets(
registry: &PostgresRegistry,
rotation: &MasterKeyRotationRecord,
target_crypto: &SecretCrypto,
) -> Result<i64, CliError> {
let mut verified = 0_i64;
let mut after_secret_id: Option<String> = None;
let mut after_version: Option<u32> = None;
loop {
let versions = registry
.list_target_secret_versions_for_master_key_rotation_page(
rotation.target_epoch,
after_secret_id.as_deref(),
after_version,
MASTER_KEY_ROTATION_PAGE_SIZE,
)
.await
.map_err(CliError::from_registry)?;
if versions.is_empty() {
break;
}
for version in versions {
after_secret_id = Some(version.secret_version.secret_id.as_str().to_owned());
after_version = Some(version.secret_version.version);
let ciphertext = version.target_ciphertext.as_deref().ok_or_else(|| {
CliError::new(
"master_key_rotation_verification_failed",
"master_key.rotation",
"rerun_rotation_or_abort",
)
})?;
let key_version = version.target_key_version.as_deref().ok_or_else(|| {
CliError::new(
"master_key_rotation_verification_failed",
"master_key.rotation",
"rerun_rotation_or_abort",
)
})?;
target_crypto
.decrypt_for_epoch(key_version, rotation.target_epoch, ciphertext)
.map_err(|_| {
CliError::new(
"master_key_rotation_verification_failed",
"master_key.rotation",
"rerun_rotation_or_abort",
)
})?;
verified += 1;
}
}
if verified != rotation.total_secret_versions {
return Err(CliError::new(
"master_key_rotation_verification_failed",
"master_key.rotation",
"rerun_rotation_or_abort",
));
}
Ok(verified)
}
async fn count_and_verify_current_versions(
registry: &PostgresRegistry,
epoch: i64,
current_crypto: &SecretCrypto,
) -> Result<usize, CliError> {
let mut count = 0_usize;
let mut after_secret_id: Option<String> = None;
let mut after_version: Option<u32> = None;
loop {
let versions = registry
.list_secret_versions_for_master_key_epoch_page(
epoch,
after_secret_id.as_deref(),
after_version,
MASTER_KEY_ROTATION_PAGE_SIZE,
)
.await
.map_err(CliError::from_registry)?;
if versions.is_empty() {
break;
}
for version in versions {
after_secret_id = Some(version.secret_version.secret_id.as_str().to_owned());
after_version = Some(version.secret_version.version);
decrypt_current(&version, current_crypto)?;
count += 1;
}
}
Ok(count)
}
fn decrypt_current(
version: &SecretVersionRecord,
current_crypto: &SecretCrypto,
) -> Result<serde_json::Value, CliError> {
current_crypto
.decrypt_for_epoch(
&version.secret_version.key_version,
version.master_key_epoch,
&version.secret_version.ciphertext,
)
.map_err(|_| {
CliError::new(
"master_key_rotation_verification_failed",
"master_key.rotation",
"rerun_rotation_or_abort",
)
})
}
async fn active_rotation(
registry: &PostgresRegistry,
allowed_states: &[&str],
) -> Result<Option<MasterKeyRotationRecord>, CliError> {
let status = registry
.master_key_rotation_status()
.await
.map_err(CliError::from_registry)?;
Ok(status
.rotations
.into_iter()
.find(|rotation| allowed_states.contains(&rotation.state.as_str())))
}
fn rotation_json(rotation: &MasterKeyRotationRecord) -> serde_json::Value {
json!({
"id": rotation.id,
"source_epoch": rotation.source_epoch,
"target_epoch": rotation.target_epoch,
"state": rotation.state,
"backup_ref": rotation.backup_ref.as_ref().map(|_| "configured"),
"checkpoint_secret_id": rotation.checkpoint_secret_id,
"total_secret_versions": rotation.total_secret_versions,
"processed_secret_versions": rotation.processed_secret_versions,
"verified_secret_versions": rotation.verified_secret_versions,
"failure_code": rotation.failure_code,
})
}
fn read_required_key_file(path: Option<&str>) -> Result<String, CliError> {
let path = path.ok_or_else(|| {
CliError::new("invalid_command", "cli.arguments", "run_master_key_status")
})?;
if path.len() > 512 || path.bytes().any(|byte| byte.is_ascii_control()) {
return Err(CliError::new(
"master_key_input_invalid",
"master_key.input",
"verify_operator_input",
));
}
let metadata = std::fs::metadata(Path::new(path)).map_err(|_| {
CliError::new(
"master_key_input_invalid",
"master_key.input",
"verify_operator_input",
)
})?;
if !metadata.is_file() || metadata.len() > 16_384 {
return Err(CliError::new(
"master_key_input_invalid",
"master_key.input",
"verify_operator_input",
));
}
let value = std::fs::read_to_string(Path::new(path)).map_err(|_| {
CliError::new(
"master_key_input_invalid",
"master_key.input",
"verify_operator_input",
)
})?;
if value.trim().is_empty() {
return Err(CliError::new(
"master_key_input_invalid",
"master_key.input",
"verify_operator_input",
));
}
Ok(value)
}
fn ensure_no_options(options: &MasterKeyOptions) -> Result<(), CliError> {
if options.current_key_file.is_some()
|| options.target_key_file.is_some()
|| options.backup_ref.is_some()
|| options.rotation_id.is_some()
|| options.max_versions.is_some()
{
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"run_master_key_status",
));
}
Ok(())
}
@@ -0,0 +1,342 @@
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use crank_community_auth::hash_password;
use crank_config::{ConfigSource, parse_migrator};
use crank_registry::{
CreateAdminBootstrapContractRequest, MASTER_KEY_CIPHER_CONTRACT, PostgresRegistry,
RecoverAdminPasswordRequest,
};
use crank_runtime::SecretCrypto;
use rand::RngExt;
use serde_json::json;
use sha2::{Digest, Sha256};
use std::{
path::{Path, PathBuf},
process::ExitCode,
};
use time::{Duration as TimeDuration, OffsetDateTime};
use super::{CliError, db_connect::connect_registry};
pub(super) async fn run_admin_auth(arguments: &[String]) -> Result<ExitCode, CliError> {
let Some(command) = arguments.first().map(String::as_str) else {
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"run_admin_auth_bootstrap_create",
));
};
let options = AdminAuthOptions::parse(&arguments[1..])?;
let config = parse_migrator(
ConfigSource::from_os_for_migrator()
.map_err(|_| CliError::new("config_invalid", "config.source", "run_preflight"))?,
)
.map_err(|_| CliError::new("config_invalid", "config.validate", "run_preflight"))?;
let registry = connect_registry(&config.database).await?;
match command {
"bootstrap-create" => create_bootstrap_contract(&registry, options).await?,
"bootstrap-complete" => complete_bootstrap_contract(&registry, options).await?,
"recover" => recover_admin_password(&registry, options).await?,
_ => {
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"run_admin_auth_bootstrap_create",
));
}
}
Ok(ExitCode::SUCCESS)
}
async fn complete_bootstrap_contract(
registry: &PostgresRegistry,
options: AdminAuthOptions,
) -> Result<(), CliError> {
let token_path = options
.token_file
.as_deref()
.ok_or_else(|| CliError::new("invalid_command", "cli.arguments", "provide_token_file"))?;
let password_path = options.password_file.as_deref().ok_or_else(|| {
CliError::new("invalid_command", "cli.arguments", "provide_password_file")
})?;
let pepper_path = options.password_pepper_file.as_deref().ok_or_else(|| {
CliError::new(
"invalid_command",
"cli.arguments",
"provide_password_pepper_file",
)
})?;
let token = read_secret_file(token_path, "admin_auth.bootstrap_token")?;
let password = read_secret_file(password_path, "admin_auth.password")?;
let pepper = read_secret_file(pepper_path, "admin_auth.password_pepper")?;
if !(32..=256).contains(&token.len())
|| !(12..=256).contains(&password.len())
|| pepper.is_empty()
|| pepper.len() > 1_024
{
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"provide_bounded_secret_files",
));
}
let token_hash = admin_auth_hash("bootstrap", &token);
let password_hash = hash_password(&password, &pepper).map_err(|_| {
CliError::new(
"admin_bootstrap_rejected",
"admin_auth.bootstrap",
"verify_local_inputs",
)
})?;
let now = OffsetDateTime::now_utc();
let user_id = registry
.consume_admin_bootstrap_contract(crank_registry::ConsumeAdminBootstrapContractRequest {
token_hash: &token_hash,
password_hash: &password_hash,
now: &now,
})
.await
.map_err(CliError::from_registry)?;
println!(
"{}",
json!({
"status": "bootstrap_completed",
"user_id": user_id.as_str()
})
);
Ok(())
}
async fn recover_admin_password(
registry: &PostgresRegistry,
options: AdminAuthOptions,
) -> Result<(), CliError> {
let email = options
.email
.as_deref()
.ok_or_else(|| CliError::new("invalid_command", "cli.arguments", "provide_email"))?;
let password_path = options.password_file.as_deref().ok_or_else(|| {
CliError::new("invalid_command", "cli.arguments", "provide_password_file")
})?;
let pepper_path = options.password_pepper_file.as_deref().ok_or_else(|| {
CliError::new(
"invalid_command",
"cli.arguments",
"provide_password_pepper_file",
)
})?;
let master_key_path = options.master_key_file.as_deref().ok_or_else(|| {
CliError::new(
"invalid_command",
"cli.arguments",
"provide_master_key_file",
)
})?;
let master_key = read_secret_file(master_key_path, "master_key.input")?;
let active = registry
.active_master_key_identity()
.await
.map_err(CliError::from_registry)?
.ok_or_else(|| {
CliError::new(
"master_key_identity_missing",
"master_key.identity",
"start_service_once_with_current_master_key",
)
})?;
let crypto = SecretCrypto::with_epoch(&master_key, active.epoch).map_err(|_| {
CliError::new(
"master_key_invalid",
"master_key.input",
"provide_current_master_key_file",
)
})?;
if active.cipher_contract != MASTER_KEY_CIPHER_CONTRACT
|| active.fingerprint != crypto.master_key_fingerprint()
{
return Err(CliError::new(
"master_key_identity_mismatch",
"master_key.identity",
"use_matching_master_key",
));
}
let password = read_secret_file(password_path, "admin_auth.password")?;
let pepper = read_secret_file(pepper_path, "admin_auth.password_pepper")?;
if !(12..=256).contains(&password.len()) || pepper.is_empty() || pepper.len() > 1_024 {
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"provide_bounded_secret_files",
));
}
let password_hash = hash_password(&password, &pepper).map_err(|_| {
CliError::new(
"admin_recovery_rejected",
"admin_auth.recovery",
"verify_local_inputs",
)
})?;
let audit_id = format!("audit_{}", uuid::Uuid::now_v7().simple());
let user_id = registry
.recover_admin_password(RecoverAdminPasswordRequest {
email,
password_hash: &password_hash,
audit_id: &audit_id,
})
.await
.map_err(CliError::from_registry)?;
println!(
"{}",
json!({
"status": "admin_recovered",
"user_id": user_id.as_str(),
"sessions_revoked": true,
"audit_id": audit_id
})
);
Ok(())
}
async fn create_bootstrap_contract(
registry: &PostgresRegistry,
options: AdminAuthOptions,
) -> Result<(), CliError> {
let email = options
.email
.as_deref()
.ok_or_else(|| CliError::new("invalid_command", "cli.arguments", "provide_email"))?;
let display_name = options.display_name.as_deref().unwrap_or("Crank Owner");
let ttl_seconds = options.ttl_seconds.unwrap_or(900);
if !(60..=86_400).contains(&ttl_seconds) {
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"set_ttl_between_60_and_86400",
));
}
let token = random_token();
let contract_id = format!("boot_{}", uuid::Uuid::now_v7().simple());
let token_hash = admin_auth_hash("bootstrap", &token);
let now = OffsetDateTime::now_utc();
let expires_at = now
.checked_add(TimeDuration::seconds(ttl_seconds))
.ok_or_else(|| CliError::new("invalid_command", "cli.arguments", "reduce_ttl"))?;
let contract = registry
.create_admin_bootstrap_contract(CreateAdminBootstrapContractRequest {
id: &contract_id,
token_hash: &token_hash,
email,
display_name,
expires_at: &expires_at,
})
.await
.map_err(CliError::from_registry)?;
println!(
"{}",
json!({
"status": "bootstrap_created",
"contract_id": contract.id,
"expires_at": contract.expires_at,
"bootstrap_token": token,
"warning": "copy_once_token_not_logged_by_services"
})
);
Ok(())
}
#[derive(Default)]
struct AdminAuthOptions {
email: Option<String>,
display_name: Option<String>,
ttl_seconds: Option<i64>,
token_file: Option<PathBuf>,
password_file: Option<PathBuf>,
password_pepper_file: Option<PathBuf>,
master_key_file: Option<PathBuf>,
}
impl AdminAuthOptions {
fn parse(arguments: &[String]) -> Result<Self, CliError> {
let mut options = Self::default();
let mut index = 0;
while index < arguments.len() {
let key = arguments[index].as_str();
let Some(value) = arguments.get(index + 1) else {
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"run_admin_auth_bootstrap_create",
));
};
match key {
"--email" => options.email = Some(value.clone()),
"--display-name" => options.display_name = Some(value.clone()),
"--ttl-seconds" => {
options.ttl_seconds = Some(value.parse::<i64>().map_err(|_| {
CliError::new("invalid_command", "cli.arguments", "set_ttl_seconds")
})?);
}
"--password-file" => options.password_file = Some(PathBuf::from(value)),
"--token-file" => options.token_file = Some(PathBuf::from(value)),
"--password-pepper-file" => {
options.password_pepper_file = Some(PathBuf::from(value));
}
"--master-key-file" => options.master_key_file = Some(PathBuf::from(value)),
_ => {
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"run_admin_auth_bootstrap_create",
));
}
}
index += 2;
}
Ok(options)
}
}
fn read_secret_file(path: &Path, stage: &'static str) -> Result<String, CliError> {
let metadata = std::fs::metadata(path)
.map_err(|_| CliError::new("invalid_command", stage, "provide_readable_secret_file"))?;
if !metadata.is_file() || metadata.len() > 8_192 {
return Err(CliError::new(
"invalid_command",
stage,
"provide_bounded_secret_file",
));
}
let bytes = std::fs::read(path)
.map_err(|_| CliError::new("invalid_command", stage, "provide_readable_secret_file"))?;
if bytes.len() > 8_192 {
return Err(CliError::new(
"invalid_command",
stage,
"provide_bounded_secret_file",
));
}
let mut value = String::from_utf8(bytes)
.map_err(|_| CliError::new("invalid_command", stage, "provide_utf8_secret_file"))?;
if value.ends_with("\r\n") {
value.truncate(value.len() - 2);
} else if value.ends_with('\n') {
value.truncate(value.len() - 1);
}
Ok(value)
}
fn random_token() -> String {
let mut bytes = [0_u8; 32];
rand::rng().fill(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
fn admin_auth_hash(scope: &str, token: &str) -> String {
let mut digest = Sha256::new();
digest.update(scope.as_bytes());
digest.update(b":");
digest.update(token.as_bytes());
URL_SAFE_NO_PAD.encode(digest.finalize())
}
@@ -0,0 +1,78 @@
use crank_config::DatabaseSettings;
use crank_registry::{PostgresPoolConfig, PostgresRegistry, RegistryError};
use sqlx::{
PgPool,
postgres::{PgConnectOptions, PgPoolOptions},
};
use std::time::Duration;
use super::CliError;
pub(super) async fn connect(config: &DatabaseSettings) -> Result<PgPool, CliError> {
let options = connect_options(config)?;
for attempt in 1..=10 {
let result = PgPoolOptions::new()
.max_connections(config.pool.max_connections)
.min_connections(config.pool.min_connections)
.acquire_timeout(Duration::from_millis(config.pool.acquire_timeout_ms))
.idle_timeout(Duration::from_millis(config.pool.idle_timeout_ms))
.max_lifetime(Duration::from_millis(config.pool.max_lifetime_ms))
.connect_with(options.clone())
.await;
match result {
Ok(pool) => return Ok(pool),
Err(_) if attempt < 10 => tokio::time::sleep(Duration::from_secs(1)).await,
Err(_) => break,
}
}
Err(CliError::new(
"storage_unavailable",
"database.connect",
"contact_operator",
))
}
pub(super) async fn connect_registry(
config: &DatabaseSettings,
) -> Result<PostgresRegistry, CliError> {
let options = connect_options(config)?;
let pool_config = PostgresPoolConfig {
max_connections: config.pool.max_connections,
min_connections: config.pool.min_connections,
acquire_timeout_ms: config.pool.acquire_timeout_ms,
idle_timeout_ms: config.pool.idle_timeout_ms,
max_lifetime_ms: config.pool.max_lifetime_ms,
};
for attempt in 1..=10 {
let result =
PostgresRegistry::connect_with_options_and_pool_config(options.clone(), pool_config)
.await;
match result {
Ok(registry) => return Ok(registry),
Err(RegistryError::Storage(_)) if attempt < 10 => {
tokio::time::sleep(Duration::from_secs(1)).await;
}
Err(error) => return Err(CliError::from_registry(error)),
}
}
Err(CliError::new(
"storage_unavailable",
"database.connect",
"contact_operator",
))
}
fn connect_options(config: &DatabaseSettings) -> Result<PgConnectOptions, CliError> {
if let Some(url) = &config.url {
url.expose_secret()
.parse::<PgConnectOptions>()
.map_err(|_| CliError::new("config_invalid", "database.source", "run_preflight"))
} else {
Ok(PgConnectOptions::new()
.host(&config.host)
.port(config.port)
.database(&config.database)
.username(&config.username)
.password(config.password.expose_secret()))
}
}
+168 -6
View File
@@ -1,13 +1,15 @@
use crank_core::{
AgentId, AgentStatus, ApprovalRequestStatus, AuthConfig, AuthKind, ExecutionMode, ExportMode,
GeneratedDraft, InvocationLevel, InvocationSource, InvocationStatus, OperationSecurityLevel,
OperationStatus, PlatformApiKeyKind, PlatformApiKeyScope, Protocol, SecretKind, Target,
ToolSelectionPolicy, UsagePeriod, WizardState, WorkspaceId, WorkspaceStatus,
GeneratedDraft, InvocationLevel, InvocationSource, InvocationStatus, OperationAvailability,
OperationSecurityLevel, OperationStatus, OperationVersionState, PlatformApiKeyKind,
PlatformApiKeyScope, Protocol, SecretKind, Target, ToolSelectionPolicy, UsagePeriod,
WizardState, WorkspaceId, WorkspaceStatus,
};
use crank_mapping::MappingSet;
use crank_registry::{
PlatformApiKeyRecord, RegistryOperation, UsageAgentBreakdown, UsageOperationBreakdown,
UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord,
InvocationLogRecord, PlatformApiKeyRecord, RegistryOperation, UsageAgentBreakdown,
UsageOperationBreakdown, UsageOutcomeGroup, UsageSummary, UsageTimelinePoint,
WorkspaceMembershipRecord, WorkspaceRecord,
};
use crank_schema::Schema;
use serde::{Deserialize, Serialize};
@@ -19,11 +21,23 @@ pub struct LoginPayload {
pub password: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct CompleteBootstrapPayload {
pub token: String,
pub password: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct BootstrapStatusResponse {
pub bootstrap_required: bool,
}
#[derive(Clone, Debug, Serialize)]
pub struct SessionResponse {
pub user: crank_core::User,
pub memberships: Vec<WorkspaceMembershipRecord>,
pub current_workspace_id: Option<String>,
pub csrf_token: String,
}
#[derive(Clone, Debug, Deserialize)]
@@ -80,12 +94,19 @@ pub struct PublishPayload {
pub struct TestRunPayload {
pub version: u32,
pub input: Value,
#[serde(default)]
pub confirmation_token: Option<String>,
#[serde(default)]
pub locale: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct TestRunResult {
pub ok: bool,
pub mode: ExecutionMode,
pub tested_version: u32,
pub request_id: String,
pub trace_id: String,
pub request_preview: Value,
pub response_preview: Value,
pub errors: Vec<Value>,
@@ -228,6 +249,7 @@ pub struct AgentSummaryView {
pub status: AgentStatus,
pub current_draft_version: u32,
pub latest_published_version: Option<u32>,
pub catalog_revision: i64,
pub created_at: String,
pub updated_at: String,
pub published_at: Option<String>,
@@ -266,6 +288,91 @@ fn default_platform_api_key_kind() -> PlatformApiKeyKind {
pub struct CreatedPlatformApiKeyResponse {
pub api_key: PlatformApiKeyRecord,
pub secret: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub connection: Option<EphemeralMcpConnection>,
}
#[derive(Clone, Debug, Serialize)]
pub struct EphemeralMcpClientConfig {
pub client: String,
pub config: Value,
}
#[derive(Clone, Debug, Serialize)]
pub struct EphemeralMcpConnection {
pub endpoint: String,
pub clients: Vec<EphemeralMcpClientConfig>,
pub secret_display: &'static str,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OnboardingEventPayload {
pub event: String,
pub idempotency_key: String,
pub expected_revision: i64,
#[serde(default)]
pub completed_steps: Option<Value>,
}
#[derive(Clone, Debug, Serialize)]
pub struct OnboardingStepView {
pub id: crank_core::OnboardingStepId,
pub completed: bool,
pub status: String,
pub action_code: String,
pub reason_code: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct OnboardingFirstCallEvidence {
pub log_id: String,
pub agent_id: String,
pub key_id: String,
pub operation_id: String,
pub operation_version: u32,
pub tool_name: String,
pub occurred_at: String,
pub request_id: Option<String>,
pub trace_id: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct OnboardingResponse {
pub schema_version: u16,
pub workspace_id: String,
pub revision: i64,
pub status: String,
pub completed: bool,
pub eligible_since: Option<String>,
pub steps: Vec<OnboardingStepView>,
pub operation_id: Option<String>,
pub operation_version: Option<u32>,
pub agent_id: Option<String>,
pub catalog_revision: Option<i64>,
pub platform_api_key_id: Option<String>,
pub mcp_endpoint: Option<String>,
pub first_call: Option<OnboardingFirstCallEvidence>,
}
#[derive(Clone, Debug, Serialize)]
pub struct OnboardingEventResponse {
pub accepted: bool,
#[serde(flatten)]
pub onboarding: OnboardingResponse,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ResetOnboardingSelectionPayload {
pub expected_revision: i64,
}
#[derive(Clone, Debug, Serialize)]
pub struct ResetOnboardingSelectionResponse {
pub selection_reset: bool,
#[serde(flatten)]
pub onboarding: OnboardingResponse,
}
#[derive(Clone, Debug, Serialize)]
@@ -285,11 +392,16 @@ pub struct WorkspaceCatalogSnapshotResponse {
#[derive(Clone, Debug, Deserialize)]
pub struct LogsQuery {
pub level: Option<InvocationLevel>,
pub status: Option<InvocationStatus>,
pub outcome_group: Option<UsageOutcomeGroup>,
pub search: Option<String>,
pub source: Option<InvocationSource>,
pub operation_id: Option<String>,
pub agent_id: Option<String>,
pub period: Option<UsagePeriod>,
pub created_after: Option<String>,
pub created_before: Option<String>,
pub cursor: Option<String>,
pub limit: Option<u32>,
}
@@ -299,10 +411,19 @@ pub struct ApprovalsQuery {
pub limit: Option<u32>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct ApprovalDecisionPayload {
pub approve: String,
#[serde(default)]
pub note: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct UsageRequestQuery {
pub period: Option<UsagePeriod>,
pub source: Option<InvocationSource>,
pub created_after: Option<String>,
pub created_before: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
@@ -311,6 +432,13 @@ pub struct UsageOverviewResponse {
pub timeline: Vec<UsageTimelinePoint>,
pub operations: Vec<UsageOperationBreakdown>,
pub agents: Vec<UsageAgentBreakdown>,
pub outcomes: Vec<crank_registry::UsageOutcomeBreakdown>,
}
#[derive(Clone, Debug, Serialize)]
pub struct LogsListResponse {
pub items: Vec<InvocationLogRecord>,
pub next_cursor: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
@@ -362,7 +490,35 @@ pub struct ExportQuery {
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct YamlOperationDocument {
pub format_version: String,
pub kind: String,
pub operation: PortableOperation,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct PortableOperation {
pub name: String,
pub display_name: String,
#[serde(default = "default_operation_category")]
pub category: String,
pub protocol: Protocol,
#[serde(default)]
pub security_level: OperationSecurityLevel,
pub target: Target,
pub input_schema: Schema,
pub output_schema: Schema,
pub input_mapping: MappingSet,
pub output_mapping: MappingSet,
pub execution_config: crank_core::ExecutionConfig,
pub tool_description: crank_core::ToolDescription,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LegacyYamlOperationDocument {
pub format_version: String,
pub kind: String,
pub operation: RegistryOperation,
@@ -473,6 +629,7 @@ pub struct OperationSummaryView {
pub status: OperationStatus,
pub current_draft_version: u32,
pub latest_published_version: Option<u32>,
pub can_delete: bool,
pub created_at: String,
pub updated_at: String,
pub published_at: Option<String>,
@@ -490,6 +647,7 @@ pub struct OperationDetailView {
pub protocol: Protocol,
pub security_level: OperationSecurityLevel,
pub status: OperationStatus,
pub availability: OperationAvailability,
pub current_draft_version: u32,
pub latest_published_version: Option<u32>,
pub created_at: String,
@@ -503,7 +661,7 @@ pub struct OperationDetailView {
#[derive(Clone, Debug, Serialize)]
pub struct VersionRef {
pub version: u32,
pub status: OperationStatus,
pub status: OperationVersionState,
}
#[derive(Clone, Debug, Serialize)]
@@ -558,6 +716,10 @@ pub(crate) struct InvocationRecordRequest<'a> {
pub message: String,
pub status_code: Option<u16>,
pub error_kind: Option<String>,
pub execution_stage: Option<crank_core::ExecutionStage>,
pub execution_error_code: Option<crank_core::ExecutionErrorCode>,
pub retryability: Option<crank_core::Retryability>,
pub outcome_certainty: Option<crank_core::OutcomeCertainty>,
pub duration_ms: u64,
pub request_preview: Value,
pub response_preview: Value,
+257 -114
View File
@@ -31,6 +31,11 @@ pub enum ApiError {
context: Option<Value>,
},
#[error("{message}")]
Unprocessable {
message: String,
context: Option<Value>,
},
#[error("{message}")]
NotFound {
message: String,
context: Option<Value>,
@@ -41,6 +46,16 @@ pub enum ApiError {
context: Option<Value>,
},
#[error("{message}")]
PayloadTooLarge {
message: String,
context: Option<Value>,
},
#[error("{message}")]
PreconditionRequired {
message: String,
context: Option<Value>,
},
#[error("{message}")]
RateLimited {
message: String,
context: Option<Value>,
@@ -95,6 +110,13 @@ impl ApiError {
}
}
pub(crate) fn unprocessable_with_context(message: impl Into<String>, context: Value) -> Self {
Self::Unprocessable {
message: message.into(),
context: Some(context),
}
}
pub(crate) fn not_found_with_context(message: impl Into<String>, context: Value) -> Self {
Self::NotFound {
message: message.into(),
@@ -109,25 +131,58 @@ impl ApiError {
}
}
pub(crate) fn payload_too_large_with_context(
message: impl Into<String>,
context: Value,
) -> Self {
Self::PayloadTooLarge {
message: message.into(),
context: Some(context),
}
}
pub(crate) fn precondition_required_with_context(
message: impl Into<String>,
context: Value,
) -> Self {
Self::PreconditionRequired {
message: message.into(),
context: Some(context),
}
}
fn status_code(&self) -> StatusCode {
match self {
Self::Unauthorized { .. } => StatusCode::UNAUTHORIZED,
Self::Forbidden { .. } => StatusCode::FORBIDDEN,
Self::Validation { .. } => StatusCode::BAD_REQUEST,
Self::Unprocessable { .. } => StatusCode::UNPROCESSABLE_ENTITY,
Self::NotFound { .. } => StatusCode::NOT_FOUND,
Self::Conflict { .. } => StatusCode::CONFLICT,
Self::PayloadTooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE,
Self::PreconditionRequired { .. } => StatusCode::PRECONDITION_REQUIRED,
Self::RateLimited { .. } => StatusCode::TOO_MANY_REQUESTS,
Self::Internal { .. } => StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn code(&self) -> &'static str {
pub(crate) fn code(&self) -> &str {
if let Some(code) = self
.context_ref()
.and_then(|context| context.get("error_code"))
.and_then(Value::as_str)
{
return code;
}
match self {
Self::Unauthorized { .. } => "unauthorized",
Self::Forbidden { .. } => "forbidden",
Self::Validation { .. } => "validation_error",
Self::Unprocessable { .. } => "unprocessable_entity",
Self::NotFound { .. } => "not_found",
Self::Conflict { .. } => "conflict",
Self::PayloadTooLarge { .. } => "payload_too_large",
Self::PreconditionRequired { .. } => "precondition_required",
Self::RateLimited { .. } => "rate_limited",
Self::Internal { .. } => "internal_error",
}
@@ -147,8 +202,11 @@ impl IntoResponse for ApiError {
Self::Unauthorized { .. }
| Self::Forbidden { .. }
| Self::Validation { .. }
| Self::Unprocessable { .. }
| Self::NotFound { .. }
| Self::Conflict { .. }
| Self::PayloadTooLarge { .. }
| Self::PreconditionRequired { .. }
| Self::RateLimited { .. } => {
warn!(
name: "admin.response.rejected",
@@ -182,17 +240,24 @@ impl IntoResponse for ApiError {
}
impl ApiError {
fn context(&self) -> Option<Value> {
fn context_ref(&self) -> Option<&Value> {
match self {
Self::Unauthorized { context, .. }
| Self::Forbidden { context, .. }
| Self::Validation { context, .. }
| Self::Unprocessable { context, .. }
| Self::NotFound { context, .. }
| Self::Conflict { context, .. }
| Self::PayloadTooLarge { context, .. }
| Self::PreconditionRequired { context, .. }
| Self::RateLimited { context, .. }
| Self::Internal { context, .. } => context.clone(),
| Self::Internal { context, .. } => context.as_ref(),
}
}
fn context(&self) -> Option<Value> {
self.context_ref().cloned()
}
}
impl From<RegistryError> for ApiError {
@@ -228,10 +293,41 @@ impl From<RegistryError> for ApiError {
format!("platform api key {key_id} was not found"),
json!({ "key_id": key_id }),
),
RegistryError::PlatformApiKeyInactive { key_id } => Self::conflict_with_context(
"platform api key is not active",
json!({
"key_id": key_id,
"error_code": "platform_api_key_not_active",
"recovery": "create_replacement_key"
}),
),
RegistryError::SecretNotFound { secret_id } => Self::not_found_with_context(
format!("secret {secret_id} was not found"),
json!({ "secret_id": secret_id }),
),
RegistryError::SecretInactive { secret_id } => Self::conflict_with_context(
"secret is not active",
json!({
"secret_id": secret_id,
"error_code": "secret_not_active",
"recovery": "rotate_or_replace_secret"
}),
),
RegistryError::SecretConcurrentUpdate { secret_id } => Self::conflict_with_context(
"secret was updated concurrently",
json!({
"secret_id": secret_id,
"error_code": "secret_concurrent_update",
"recovery": "reload"
}),
),
RegistryError::MasterKeyRotationInProgress => Self::conflict_with_context(
"master key rotation is in progress",
json!({
"error_code": "master_key_rotation_in_progress",
"recovery": "retry_after_rotation"
}),
),
RegistryError::InvocationLogNotFound { log_id } => Self::not_found_with_context(
format!("invocation log {log_id} was not found"),
json!({ "log_id": log_id }),
@@ -268,6 +364,57 @@ impl From<RegistryError> for ApiError {
json!({ "operation_id": operation_id }),
)
}
RegistryError::OperationArchived { operation_id } => Self::conflict_with_context(
format!("operation {operation_id} is archived"),
json!({ "operation_id": operation_id, "error_code": "operation_archived" }),
),
RegistryError::OperationStaleVersion {
operation_id,
expected,
actual,
} => Self::conflict_with_context(
format!("operation {operation_id} has a stale base version"),
json!({
"operation_id": operation_id,
"current_version": expected,
"provided_version": actual,
"error_code": "operation_stale_version",
"recovery": "reload"
}),
),
RegistryError::InvalidOperationTransition {
operation_id,
from,
action,
} => Self::conflict_with_context(
format!("operation {operation_id} cannot perform {action} from {from}"),
json!({
"operation_id": operation_id,
"state": from,
"action": action,
"error_code": "operation_invalid_transition"
}),
),
RegistryError::OperationDeleteForbidden { operation_id } => {
Self::conflict_with_context(
format!(
"operation {operation_id} cannot be deleted because durable history exists"
),
json!({
"operation_id": operation_id,
"error_code": "operation_delete_forbidden"
}),
)
}
RegistryError::OperationAuthProfileUnavailable { operation_id } => {
Self::unprocessable_with_context(
"operation auth profile reference is unavailable",
json!({
"operation_id": operation_id,
"error_code": "operation_auth_profile_invalid"
}),
)
}
RegistryError::AuthProfileNotFound { auth_profile_id } => Self::not_found_with_context(
format!("auth profile {auth_profile_id} was not found"),
json!({ "auth_profile_id": auth_profile_id }),
@@ -276,6 +423,17 @@ impl From<RegistryError> for ApiError {
format!("operation {operation_id} already exists"),
json!({ "operation_id": operation_id }),
),
RegistryError::PlatformApiKeyNameAlreadyExists { workspace_id, name } => {
Self::conflict_with_context(
"platform api key name already exists",
json!({
"workspace_id": workspace_id,
"name": name,
"error_code": "platform_api_key_name_conflict",
"recovery": "choose_different_name"
}),
)
}
RegistryError::WorkspaceSlugAlreadyExists { slug } => Self::conflict_with_context(
format!("workspace with slug {slug} already exists"),
json!({ "slug": slug }),
@@ -286,6 +444,8 @@ impl From<RegistryError> for ApiError {
json!({
"workspace_id": workspace_id,
"name": name,
"error_code": "secret_name_conflict",
"recovery": "choose_different_name"
}),
)
}
@@ -297,12 +457,29 @@ impl From<RegistryError> for ApiError {
json!({
"secret_id": secret_id,
"auth_profile_id": auth_profile_id,
"error_code": "secret_referenced_by_auth_profile",
"recovery": "remove_or_update_auth_profile_reference"
}),
),
RegistryError::UserEmailAlreadyExists { email } => Self::conflict_with_context(
format!("user with email {email} already exists"),
json!({ "email": email }),
),
RegistryError::AdminBootstrapUnavailable | RegistryError::AdminBootstrapRejected => {
Self::unauthorized("bootstrap request is invalid or expired")
}
RegistryError::AdminRecoveryRejected => Self::unauthorized("recovery request rejected"),
RegistryError::AdminLoginRateLimited { retry_after_ms } => {
Self::rate_limited_with_context(
"login temporarily unavailable",
json!({
"retry_after_ms": retry_after_ms.clamp(1, 300_000),
"error_code": "login_throttled",
"recovery": "retry_after_delay"
}),
)
}
RegistryError::AdminCsrfRejected => Self::forbidden("csrf validation failed"),
RegistryError::InvalidInitialVersion {
operation_id,
version,
@@ -337,6 +514,39 @@ impl From<RegistryError> for ApiError {
"actual": actual,
}),
),
RegistryError::ImmutableAgentVersion { agent_id, version } => {
Self::conflict_with_context(
"Agent Version is immutable; reload before editing",
json!({
"agent_id": agent_id,
"version": version,
"error_code": "agent_stale_revision",
"recovery": "reload"
}),
)
}
RegistryError::AgentStaleRevision { agent_id } => Self::conflict_with_context(
"Agent state changed; reload before retrying",
json!({
"agent_id": agent_id,
"error_code": "agent_stale_revision",
"recovery": "reload"
}),
),
RegistryError::InvalidAgentTransition {
agent_id,
from,
action,
} => Self::conflict_with_context(
format!("agent {agent_id} cannot transition from {from} using {action}"),
json!({
"agent_id": agent_id,
"from": from,
"action": action,
"error_code": "agent_invalid_transition",
"recovery": "reload"
}),
),
RegistryError::ImmutableOperationFieldChanged {
operation_id,
field,
@@ -347,6 +557,14 @@ impl From<RegistryError> for ApiError {
"field": field,
}),
),
RegistryError::OnboardingStaleRevision => Self::conflict_with_context(
"onboarding state changed; reload before retrying",
json!({"error_code":"onboarding_stale_revision","recovery":"reload"}),
),
RegistryError::OnboardingIncomplete => Self::unprocessable_with_context(
"onboarding domain steps are not complete",
json!({"error_code":"onboarding_incomplete"}),
),
RegistryError::InvalidEnumRepresentation { field } => Self::validation_with_context(
format!("unsupported enum representation for field {field}"),
json!({ "field": field }),
@@ -370,10 +588,18 @@ impl From<RegistryError> for ApiError {
format!("import job {job_id} was already applied with different parameters"),
json!({ "job_id": job_id }),
),
RegistryError::Storage(_) => Self::internal("registry operation failed"),
RegistryError::Migration(_)
| RegistryError::Storage(_)
| RegistryError::Serialization(_)
| RegistryError::InvalidCorrelationIdentity { .. } => Self::internal(value.to_string()),
| RegistryError::MasterKeyIdentityMismatch { .. }
| RegistryError::InvalidMasterKeyIdentity
| RegistryError::MasterKeyRotationNotFound { .. }
| RegistryError::MasterKeyRotationConflict
| RegistryError::MasterKeyRotationVerificationFailed
| RegistryError::InvalidCorrelationIdentity { .. }
| RegistryError::InvalidExecutionRecord { .. } => {
Self::internal("registry operation failed")
}
}
}
}
@@ -405,117 +631,34 @@ impl From<StorageError> for ApiError {
}
pub fn runtime_test_failure(error: &RuntimeError) -> Value {
let failure =
crank_runtime::normalize_runtime_error(error, &crank_core::CorrelationContext::generate());
execution_test_failure(&failure)
}
pub fn execution_test_failure(failure: &crank_core::ExecutionFailure) -> Value {
execution_test_failure_localized(failure, crank_core::ExecutionLocale::En)
}
pub fn execution_test_failure_localized(
failure: &crank_core::ExecutionFailure,
locale: crank_core::ExecutionLocale,
) -> Value {
let mut payload = json!({
"code": runtime_test_failure_code(error),
"message": safe_runtime_test_failure_message(error)
"code": failure.error_code().as_str(),
"message": failure.error_code().message(locale),
"stage": failure.stage().as_str(),
"retryability": failure.retryability().as_str(),
"outcome_certainty": failure.outcome_certainty().as_str(),
});
if let Some(context) = runtime_error_context(error) {
payload["context"] = context;
if let Some(status) = failure.upstream_status() {
payload["context"] = json!({ "upstream_status": status });
}
if let Some(challenge) = failure.confirmation() {
payload["context"] = json!({
"confirmation_token": challenge.token(),
"expires_in_ms": challenge.expires_in_ms(),
});
}
payload
}
fn safe_runtime_test_failure_message(error: &RuntimeError) -> &'static str {
match error {
RuntimeError::Schema(_) => "input schema validation failed",
RuntimeError::Mapping(_) => "input mapping failed",
RuntimeError::RestAdapter(_) | RuntimeError::ProtocolAdapter(_) => {
"upstream execution failed"
}
RuntimeError::UnsupportedProtocol { .. } => "operation protocol is unsupported",
RuntimeError::ConcurrencyLimitExceeded { .. } => "runtime concurrency limit exceeded",
RuntimeError::InvalidPreparedRequest { .. } => "prepared request is invalid",
RuntimeError::ConfirmationRequired { .. } => "operation confirmation is required",
RuntimeError::InvalidConfirmationToken { .. } => "confirmation token is invalid",
RuntimeError::ConfirmationStoreUnavailable { .. } => "confirmation store is unavailable",
RuntimeError::IdempotencyStoreUnavailable { .. } => "idempotency store is unavailable",
RuntimeError::IdempotencyInProgress { .. } => "idempotent execution is in progress",
RuntimeError::IdempotencyConflict { .. } => "idempotency key conflicts with the request",
RuntimeError::IdempotencyOutcomeUnknown { .. } => "previous execution outcome is unknown",
RuntimeError::UnsupportedExecutionMode { .. } => "execution mode is unsupported",
RuntimeError::MissingAuthProfile { .. } => "authorization profile is missing",
RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => {
"authorization secret is missing"
}
RuntimeError::InvalidAuthSecretValue { .. } => "authorization secret is invalid",
RuntimeError::SecretCrypto { .. } => "authorization secret processing failed",
}
}
fn runtime_test_failure_code(error: &RuntimeError) -> &'static str {
match error {
RuntimeError::Schema(_) => "runtime_schema_error",
RuntimeError::Mapping(_) => "runtime_mapping_error",
RuntimeError::RestAdapter(_) => "runtime_rest_error",
RuntimeError::ProtocolAdapter(_) => "runtime_adapter_error",
RuntimeError::UnsupportedProtocol { .. } => "runtime_protocol_error",
RuntimeError::ConcurrencyLimitExceeded { .. } => "runtime_overloaded",
RuntimeError::InvalidPreparedRequest { .. } => "runtime_request_error",
RuntimeError::ConfirmationRequired { .. } => "runtime_confirmation_required",
RuntimeError::InvalidConfirmationToken { .. } => "runtime_confirmation_error",
RuntimeError::ConfirmationStoreUnavailable { .. } => "runtime_confirmation_unavailable",
RuntimeError::IdempotencyStoreUnavailable { .. } => "runtime_idempotency_unavailable",
RuntimeError::IdempotencyInProgress { .. } => "runtime_idempotency_in_progress",
RuntimeError::IdempotencyConflict { .. } => "runtime_idempotency_conflict",
RuntimeError::IdempotencyOutcomeUnknown { .. } => "runtime_idempotency_outcome_unknown",
RuntimeError::UnsupportedExecutionMode { .. } => "runtime_streaming_mode_error",
RuntimeError::MissingAuthProfile { .. } => "runtime_auth_profile_error",
RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => {
"runtime_secret_error"
}
RuntimeError::InvalidAuthSecretValue { .. } => "runtime_secret_value_error",
RuntimeError::SecretCrypto { .. } => "runtime_secret_crypto_error",
}
}
pub fn runtime_error_context(error: &RuntimeError) -> Option<Value> {
match error {
RuntimeError::InvalidPreparedRequest { field, .. } => Some(json!({
"field": field,
})),
RuntimeError::ConfirmationRequired {
confirmation_token,
expires_in_ms,
safety_class,
..
} => Some(json!({
"confirmation_token": confirmation_token,
"expires_in_ms": expires_in_ms,
"safety_class": safety_class,
})),
RuntimeError::InvalidConfirmationToken { operation_id }
| RuntimeError::ConfirmationStoreUnavailable { operation_id }
| RuntimeError::IdempotencyStoreUnavailable { operation_id }
| RuntimeError::IdempotencyInProgress { operation_id }
| RuntimeError::IdempotencyConflict { operation_id }
| RuntimeError::IdempotencyOutcomeUnknown { operation_id } => Some(json!({
"operation_id": operation_id,
})),
RuntimeError::InvalidAuthSecretValue { secret_id, .. } => Some(json!({
"secret_id": secret_id,
})),
RuntimeError::SecretCrypto { .. } => None,
RuntimeError::MissingAuthProfile { auth_profile_id } => Some(json!({
"auth_profile_id": auth_profile_id,
})),
RuntimeError::MissingSecret { secret_id } => Some(json!({
"secret_id": secret_id,
})),
RuntimeError::MissingSecretVersion { secret_id, version } => Some(json!({
"secret_id": secret_id,
"version": version,
})),
RuntimeError::UnsupportedExecutionMode { operation_id, mode } => Some(json!({
"operation_id": operation_id,
"mode": mode,
})),
RuntimeError::UnsupportedProtocol { protocol } => Some(json!({
"protocol": protocol,
})),
RuntimeError::ConcurrencyLimitExceeded { kind, limit } => Some(json!({
"kind": kind,
"limit": limit,
})),
_ => None,
}
}
+108 -10
View File
@@ -17,7 +17,9 @@ use crank_observability::{
CriticalErrorCategory, MetricsConfig, ObservabilityConfig, ObservabilityLifecycle,
OtlpTraceConfig, RedactionLimits, SentryConfig, ServiceIdentity, capture_critical_error,
};
use crank_registry::{PostgresPoolConfig, PostgresRegistry};
use crank_registry::{
MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate, PostgresPoolConfig, PostgresRegistry,
};
use crank_runtime::{
RequestRateLimitConfig, RequestRateLimiter, RuntimeCacheConfig, RuntimeCacheStores,
RuntimeLimits, SecretCrypto,
@@ -65,6 +67,36 @@ fn safe_startup_diagnostic(error: &(dyn std::error::Error + 'static)) -> String
})
.to_string();
}
if let Some(crank_registry::RegistryError::MasterKeyIdentityMismatch { epoch }) =
cause.downcast_ref::<crank_registry::RegistryError>()
{
return serde_json::json!({
"status": "error",
"code": "master_key_identity_mismatch",
"stage": "startup.master_key_identity",
"version": epoch,
"recovery": "configure_same_master_key",
})
.to_string();
}
if cause
.downcast_ref::<crank_registry::RegistryError>()
.is_some_and(|error| {
matches!(
error,
crank_registry::RegistryError::InvalidMasterKeyIdentity
)
})
{
return serde_json::json!({
"status": "error",
"code": "master_key_identity_invalid",
"stage": "startup.master_key_identity",
"version": null,
"recovery": "contact_operator",
})
.to_string();
}
current = cause.source();
}
serde_json::json!({
@@ -149,7 +181,11 @@ async fn run(
cookie_secure: base_url.starts_with("https://"),
bootstrap_admin: BootstrapAdminConfig {
email: config.bootstrap_email.clone(),
password: config.bootstrap_password.expose_secret().to_owned(),
password: config
.bootstrap_password
.as_ref()
.map(|password| password.expose_secret().to_owned())
.unwrap_or_default(),
display_name: config.bootstrap_display_name.clone(),
},
};
@@ -163,10 +199,13 @@ async fn run(
config.rate_limit.requests_per_second,
config.rate_limit.burst,
)?;
let secret_crypto = SecretCrypto::new(config.runtime.master_key.expose_secret())?;
let outbound_http_policy = crank_runtime::OutboundHttpPolicy::try_new(
let secret_crypto =
verified_startup_secret_crypto(&registry, config.runtime.master_key.expose_secret())
.await?;
let outbound_http_policy = crank_runtime::OutboundHttpPolicy::try_new_with_limits(
config.runtime.outbound.allowed_hosts.clone(),
config.runtime.outbound.denied_hosts.clone(),
config.runtime.outbound.max_request_bytes,
config.runtime.outbound.max_response_bytes,
)?;
let runtime = crank_runtime::community_with_outbound_policy(outbound_http_policy.clone())
@@ -183,10 +222,10 @@ async fn run(
secret_crypto,
runtime,
)
.with_public_base_url(base_url)
.with_outbound_http_policy(outbound_http_policy)
.with_identity_provider(std::sync::Arc::new(identity_provider))
.build();
service.bootstrap_admin_user().await?;
if config.demo_seed {
service.seed_demo_assets().await?;
}
@@ -198,7 +237,7 @@ async fn run(
} else {
RequestRateLimiter::new(api_rate_limit)
},
trust_forwarded_headers: config.trust_forwarded_headers,
trusted_proxy_ips: config.trusted_proxy_ips.clone(),
};
let app = build_app(state);
let listener = TcpListener::bind(config.bind_addr).await?;
@@ -285,9 +324,10 @@ fn preflight_config(config: &AdminProcessConfig) -> Result<(), crank_config::Con
.map_err(|_| invalid("admin.rate_limit"))?;
SecretCrypto::new(config.runtime.master_key.expose_secret())
.map_err(|_| invalid("runtime.master_key"))?;
crank_runtime::OutboundHttpPolicy::try_new(
crank_runtime::OutboundHttpPolicy::try_new_with_limits(
config.runtime.outbound.allowed_hosts.clone(),
config.runtime.outbound.denied_hosts.clone(),
config.runtime.outbound.max_request_bytes,
config.runtime.outbound.max_response_bytes,
)
.map_err(|_| invalid("runtime.outbound"))?;
@@ -315,6 +355,53 @@ fn preflight_config(config: &AdminProcessConfig) -> Result<(), crank_config::Con
Ok(())
}
async fn verified_startup_secret_crypto(
registry: &PostgresRegistry,
master_key: &str,
) -> Result<SecretCrypto, Box<dyn std::error::Error>> {
let active = registry.active_master_key_identity().await?;
let secret_crypto = if let Some(identity) = active {
SecretCrypto::with_epoch(master_key, identity.epoch)?
} else {
let crypto = SecretCrypto::new(master_key)?;
let mut after_secret_id: Option<String> = None;
let mut after_version: Option<u32> = None;
loop {
let versions = registry
.list_secret_versions_for_master_key_epoch_page(
1,
after_secret_id.as_deref(),
after_version,
1_000,
)
.await?;
if versions.is_empty() {
break;
}
for version in versions {
after_secret_id = Some(version.secret_version.secret_id.as_str().to_owned());
after_version = Some(version.secret_version.version);
crypto.decrypt_for_epoch(
&version.secret_version.key_version,
version.master_key_epoch,
&version.secret_version.ciphertext,
)?;
}
}
crypto
};
let master_key_observed_at = time::OffsetDateTime::now_utc();
registry
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
epoch: secret_crypto.master_key_epoch(),
fingerprint: secret_crypto.master_key_fingerprint(),
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &master_key_observed_at,
})
.await?;
Ok(secret_crypto)
}
fn otlp_config(
config: &ObservabilitySettings,
) -> Result<OtlpTraceConfig, crank_observability::OtlpTraceConfigError> {
@@ -395,12 +482,23 @@ fn spawn_invocation_log_cleanup(service: admin_api::service::AdminService, reten
interval.tick().await;
let cutoff = time::OffsetDateTime::now_utc() - time::Duration::days(retention_days);
match service.cleanup_invocation_logs_before(cutoff).await {
Ok(removed) if removed > 0 => info!(
Ok(outcome) if outcome.deleted_records > 0 => info!(
name: "admin.invocation_log_cleanup.completed",
removed,
status = ?outcome.status,
removed = outcome.deleted_records,
requested_cutoff = %outcome.policy.requested_cutoff,
effective_cutoff = %outcome.policy.effective_cutoff,
preserved_usage_window_days = outcome.policy.preserved_usage_window_days,
"expired invocation logs removed"
),
Ok(_) => {}
Ok(outcome) => info!(
name: "admin.invocation_log_cleanup.noop",
status = ?outcome.status,
requested_cutoff = %outcome.policy.requested_cutoff,
effective_cutoff = %outcome.policy.effective_cutoff,
preserved_usage_window_days = outcome.policy.preserved_usage_window_days,
"no expired invocation logs removed"
),
Err(_) => warn!(
name: "admin.invocation_log_cleanup.failed",
error_category = "registry_cleanup",
+39 -15
View File
@@ -10,20 +10,23 @@ use crank_runtime::{RateLimitCheckError, RateLimitRejection};
use crate::{error::ApiError, state::AppState};
#[derive(Clone, Debug)]
pub struct ClientIdentityBucket(pub String);
pub async fn apply_api_rate_limit(
State(state): State<AppState>,
request: Request,
mut request: Request,
next: Next,
) -> Result<Response, ApiError> {
let peer_ip = request
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|ConnectInfo(address)| address.ip());
let key = rate_limit_key(
let key = client_rate_limit_key(
request.headers(),
request.uri().path(),
peer_ip,
state.trust_forwarded_headers,
&state.trusted_proxy_ips,
);
if let Err(error) = state.api_rate_limiter.check(&key).await {
return match error {
@@ -37,6 +40,8 @@ pub async fn apply_api_rate_limit(
};
}
request.extensions_mut().insert(ClientIdentityBucket(key));
Ok(next.run(request).await)
}
@@ -46,13 +51,15 @@ fn rejection_context(rejection: RateLimitRejection) -> serde_json::Value {
})
}
fn rate_limit_key(
pub fn client_rate_limit_key(
headers: &HeaderMap,
path: &str,
peer_ip: Option<IpAddr>,
trust_forwarded_headers: bool,
trusted_proxy_ips: &[IpAddr],
) -> String {
if trust_forwarded_headers && let Some(client_ip) = forwarded_client_ip(headers) {
if peer_ip.is_some_and(|peer_ip| trusted_proxy_ips.contains(&peer_ip))
&& let Some(client_ip) = forwarded_client_ip(headers)
{
return format!("ip:{client_ip}");
}
@@ -63,7 +70,8 @@ fn rate_limit_key(
format!("anonymous:{path}")
}
/// Resolves the client IP from proxy headers, assuming a single trusted proxy.
/// Resolves the client IP from proxy headers after the immediate peer was
/// matched against the trusted-proxy allowlist.
///
/// `X-Real-IP` is preferred because a trusted proxy (e.g. nginx) sets it to the
/// real peer address. For `X-Forwarded-For` the proxy *appends* the observed
@@ -96,7 +104,7 @@ mod tests {
use axum::http::{HeaderMap, HeaderValue, header::COOKIE};
use super::rate_limit_key;
use super::client_rate_limit_key;
fn peer() -> Option<IpAddr> {
Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)))
@@ -112,7 +120,7 @@ mod tests {
headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.5"));
assert_eq!(
rate_limit_key(&headers, "/api/auth/login", peer(), true),
client_rate_limit_key(&headers, "/api/auth/login", peer(), &[peer().unwrap()]),
"ip:10.0.0.5"
);
}
@@ -124,7 +132,23 @@ mod tests {
headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.9"));
assert_eq!(
rate_limit_key(&headers, "/api/auth/login", peer(), false),
client_rate_limit_key(&headers, "/api/auth/login", peer(), &[]),
"ip:203.0.113.7"
);
}
#[test]
fn ignores_forwarded_headers_from_unlisted_peer() {
let mut headers = HeaderMap::new();
headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.9"));
assert_eq!(
client_rate_limit_key(
&headers,
"/api/auth/login",
peer(),
&[IpAddr::V4(Ipv4Addr::new(198, 51, 100, 10))]
),
"ip:203.0.113.7"
);
}
@@ -139,7 +163,7 @@ mod tests {
headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.9"));
assert_eq!(
rate_limit_key(&headers, "/api/auth/login", peer(), true),
client_rate_limit_key(&headers, "/api/auth/login", peer(), &[peer().unwrap()]),
"ip:10.0.0.9"
);
}
@@ -155,7 +179,7 @@ mod tests {
);
assert_eq!(
rate_limit_key(&headers, "/api/auth/login", peer(), true),
client_rate_limit_key(&headers, "/api/auth/login", peer(), &[peer().unwrap()]),
"ip:10.0.0.6"
);
}
@@ -165,7 +189,7 @@ mod tests {
let headers = HeaderMap::new();
assert_eq!(
rate_limit_key(&headers, "/api/auth/login", peer(), true),
client_rate_limit_key(&headers, "/api/auth/login", peer(), &[peer().unwrap()]),
"ip:203.0.113.7"
);
}
@@ -180,7 +204,7 @@ mod tests {
);
assert_eq!(
rate_limit_key(&headers, "/api/auth/login", peer(), true),
client_rate_limit_key(&headers, "/api/auth/login", peer(), &[peer().unwrap()]),
"ip:203.0.113.7"
);
}
@@ -190,7 +214,7 @@ mod tests {
let headers = HeaderMap::new();
assert_eq!(
rate_limit_key(&headers, "/api/auth/login", None, false),
client_rate_limit_key(&headers, "/api/auth/login", None, &[]),
"anonymous:/api/auth/login"
);
}
+1
View File
@@ -5,6 +5,7 @@ pub mod auth_profiles;
pub mod capabilities;
pub mod imports;
pub mod observability;
pub mod onboarding;
pub mod operations;
pub mod secrets;
pub mod upstreams;
+92 -5
View File
@@ -1,15 +1,19 @@
use axum::{
Json,
Extension, Json,
extract::{Path, State},
http::{HeaderMap, header},
response::IntoResponse,
};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::{
auth::AuthenticatedSession,
error::ApiError,
request_context::RequestContext,
service::{
AgentCatalogPayload, AgentPayload, PlatformApiKeyPayload, PublishPayload,
ToolSearchPreviewPayload, UpdateAgentPayload,
AdminAuditContext, AgentCatalogPayload, AgentPayload, PlatformApiKeyPayload,
PublishPayload, ToolSearchPreviewPayload, UpdateAgentPayload,
},
state::AppState,
};
@@ -77,7 +81,7 @@ pub async fn create_agent(
pub async fn get_agent(
Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
) -> Result<impl IntoResponse, ApiError> {
let agent = state
.service
.get_agent(
@@ -85,20 +89,30 @@ pub async fn get_agent(
&path.agent_id.as_str().into(),
)
.await?;
Ok(Json(json!(agent)))
let etag = crate::service::AdminService::agent_state_etag(&agent);
let mut headers = HeaderMap::new();
headers.insert(
header::ETAG,
header::HeaderValue::from_str(&etag)
.map_err(|_| ApiError::internal("invalid agent etag"))?,
);
Ok((headers, Json(json!(agent))))
}
pub async fn update_agent(
Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<UpdateAgentPayload>,
) -> Result<Json<Value>, ApiError> {
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
let updated = state
.service
.update_agent(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
payload,
Some(&expected_state),
)
.await?;
Ok(Json(json!(updated)))
@@ -107,12 +121,15 @@ pub async fn update_agent(
pub async fn delete_agent(
Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, ApiError> {
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
let deleted = state
.service
.delete_agent(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
Some(&expected_state),
)
.await?;
Ok(Json(json!(deleted)))
@@ -136,30 +153,79 @@ pub async fn get_agent_version(
pub async fn save_agent_bindings(
Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<AgentCatalogPayload>,
) -> Result<Json<Value>, ApiError> {
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
let record = state
.service
.save_agent_bindings(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
payload,
Some(&expected_state),
)
.await?;
Ok(Json(json!(record)))
}
async fn require_agent_precondition(
state: &AppState,
path: &WorkspaceAgentPath,
headers: &HeaderMap,
) -> Result<crank_registry::AgentStateExpectation, ApiError> {
let agent = state
.service
.get_agent(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
)
.await?;
let expected = crate::service::AdminService::agent_state_etag(&agent);
let provided = headers
.get(header::IF_MATCH)
.and_then(|value| value.to_str().ok())
.ok_or_else(|| {
ApiError::precondition_required_with_context(
"If-Match is required for Agent mutation",
json!({
"error_code": "agent_precondition_required",
"current_version": agent.current_draft_version,
"latest_published_version": agent.latest_published_version,
"catalog_revision": agent.catalog_revision,
"recovery": "reload"
}),
)
})?;
if provided != expected {
return Err(ApiError::conflict_with_context(
"Agent state changed; reload before retrying",
json!({
"error_code": "agent_stale_revision",
"current_version": agent.current_draft_version,
"latest_published_version": agent.latest_published_version,
"catalog_revision": agent.catalog_revision,
"recovery": "reload"
}),
));
}
crate::service::AdminService::agent_state_expectation(&agent)
}
pub async fn publish_agent(
Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<PublishPayload>,
) -> Result<Json<Value>, ApiError> {
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
let published = state
.service
.publish_agent(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
payload.version,
Some(&expected_state),
)
.await?;
Ok(Json(json!(published)))
@@ -168,12 +234,15 @@ pub async fn publish_agent(
pub async fn unpublish_agent(
Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, ApiError> {
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
let updated = state
.service
.unpublish_agent(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
Some(&expected_state),
)
.await?;
Ok(Json(json!(updated)))
@@ -182,12 +251,15 @@ pub async fn unpublish_agent(
pub async fn archive_agent(
Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, ApiError> {
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
let updated = state
.service
.archive_agent(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
Some(&expected_state),
)
.await?;
Ok(Json(json!(updated)))
@@ -210,14 +282,19 @@ pub async fn list_agent_platform_api_keys(
pub async fn create_agent_platform_api_key(
Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
Extension(request_context): Extension<RequestContext>,
Json(payload): Json<PlatformApiKeyPayload>,
) -> Result<Json<Value>, ApiError> {
let audit_context =
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
let created = state
.service
.create_agent_platform_api_key(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
payload,
Some(&audit_context),
)
.await?;
Ok(Json(json!(created)))
@@ -226,13 +303,18 @@ pub async fn create_agent_platform_api_key(
pub async fn revoke_agent_platform_api_key(
Path(path): Path<WorkspaceAgentPlatformApiKeyPath>,
State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
Extension(request_context): Extension<RequestContext>,
) -> Result<axum::http::StatusCode, ApiError> {
let audit_context =
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
state
.service
.revoke_agent_platform_api_key(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
&path.key_id.as_str().into(),
Some(&audit_context),
)
.await?;
Ok(axum::http::StatusCode::NO_CONTENT)
@@ -241,13 +323,18 @@ pub async fn revoke_agent_platform_api_key(
pub async fn delete_agent_platform_api_key(
Path(path): Path<WorkspaceAgentPlatformApiKeyPath>,
State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
Extension(request_context): Extension<RequestContext>,
) -> Result<axum::http::StatusCode, ApiError> {
let audit_context =
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
state
.service
.delete_agent_platform_api_key(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
&path.key_id.as_str().into(),
Some(&audit_context),
)
.await?;
Ok(axum::http::StatusCode::NO_CONTENT)
+53 -2
View File
@@ -5,18 +5,52 @@ use serde_json::json;
use crate::{
auth::{AuthenticatedSession, cleared_session_cookie, extract_session_token, session_cookie},
error::ApiError,
rate_limit::ClientIdentityBucket,
service::{
ChangePasswordPayload, LoginPayload, UpdateCurrentWorkspacePayload, UpdateProfilePayload,
ChangePasswordPayload, CompleteBootstrapPayload, LoginPayload,
UpdateCurrentWorkspacePayload, UpdateProfilePayload,
},
state::AppState,
};
pub async fn bootstrap_status(
State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, ApiError> {
Ok(Json(serde_json::json!(
state.service.bootstrap_status().await?
)))
}
pub async fn complete_bootstrap(
State(state): State<AppState>,
jar: CookieJar,
Json(payload): Json<CompleteBootstrapPayload>,
) -> Result<impl IntoResponse, ApiError> {
let (session_data, session) = state.service.complete_bootstrap(payload).await?;
let cookie_value = format!(
"{}.{}",
session_data.session_id.as_str(),
session_data.value
);
let jar = jar.add(session_cookie(state.service.auth_settings(), &cookie_value));
Ok((jar, Json(serde_json::json!(session))))
}
pub async fn login(
State(state): State<AppState>,
jar: CookieJar,
client_bucket: Option<Extension<ClientIdentityBucket>>,
Json(payload): Json<LoginPayload>,
) -> Result<impl IntoResponse, ApiError> {
let (session_data, session) = state.service.login(payload).await?;
let client_bucket = client_bucket
.as_ref()
.map(|Extension(bucket)| bucket.0.as_str())
.unwrap_or("anonymous:/api/auth/login");
let (session_data, session) = state
.service
.login_with_client_bucket(payload, client_bucket)
.await?;
let cookie_value = format!(
"{}.{}",
session_data.session_id.as_str(),
@@ -54,6 +88,22 @@ pub async fn get_session(
Ok(Json(serde_json::json!(session)))
}
pub async fn refresh_session_csrf(
State(state): State<AppState>,
jar: CookieJar,
) -> Result<Json<serde_json::Value>, ApiError> {
let (session_id, session_value) = extract_session_token(&jar)
.ok_or_else(|| ApiError::unauthorized("authentication required"))?;
state
.service
.get_session(&session_id, &session_value)
.await?
.ok_or_else(|| ApiError::unauthorized("session is invalid or expired"))?;
let csrf_token = state.service.rotate_session_csrf_token(&session_id).await?;
Ok(Json(json!({ "csrf_token": csrf_token })))
}
pub async fn get_profile(
Extension(session): Extension<AuthenticatedSession>,
) -> Result<Json<serde_json::Value>, ApiError> {
@@ -73,6 +123,7 @@ pub async fn update_profile(
.service
.update_profile(
&session.user.id,
&session.session_id,
session.current_workspace_id.as_ref(),
payload,
)
+17 -3
View File
@@ -1,11 +1,17 @@
use axum::{
Json,
Extension, Json,
extract::{Path, State},
};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::{error::ApiError, service::AuthProfilePayload, state::AppState};
use crate::{
auth::AuthenticatedSession,
error::ApiError,
request_context::RequestContext,
service::{AdminAuditContext, AuthProfilePayload},
state::AppState,
};
#[derive(Deserialize)]
pub struct WorkspacePath {
@@ -32,11 +38,19 @@ pub async fn list_auth_profiles(
pub async fn create_auth_profile(
Path(path): Path<WorkspacePath>,
State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
Extension(request_context): Extension<RequestContext>,
Json(payload): Json<AuthProfilePayload>,
) -> Result<Json<Value>, ApiError> {
let audit_context =
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
let profile = state
.service
.create_auth_profile(&path.workspace_id.as_str().into(), payload)
.create_auth_profile(
&path.workspace_id.as_str().into(),
payload,
Some(&audit_context),
)
.await?;
Ok(Json(json!(profile)))
}
+81 -2
View File
@@ -1,13 +1,18 @@
use axum::{
Json,
extract::{Path, Query, State},
http::{
HeaderValue, StatusCode,
header::{CONTENT_DISPOSITION, CONTENT_TYPE},
},
response::{IntoResponse, Response},
};
use serde_json::{Value, json};
use crate::{
error::ApiError,
routes::access::WorkspacePath,
service::{ApprovalsQuery, LogsQuery, UsageRequestQuery},
service::{ApprovalDecisionPayload, ApprovalsQuery, LogsQuery, UsageRequestQuery},
state::AppState,
};
@@ -44,7 +49,49 @@ pub async fn list_logs(
.service
.list_logs(&path.workspace_id.as_str().into(), query)
.await?;
Ok(Json(json!({ "items": items })))
Ok(Json(json!(items)))
}
pub async fn export_logs_csv(
Path(path): Path<WorkspacePath>,
Query(query): Query<LogsQuery>,
State(state): State<AppState>,
) -> Result<Response, ApiError> {
let csv = state
.service
.export_logs_csv(&path.workspace_id.as_str().into(), query)
.await?;
let mut response = (StatusCode::OK, csv).into_response();
response.headers_mut().insert(
CONTENT_TYPE,
HeaderValue::from_static("text/csv; charset=utf-8"),
);
response.headers_mut().insert(
CONTENT_DISPOSITION,
HeaderValue::from_static("attachment; filename=\"crank-invocation-history.csv\""),
);
Ok(response)
}
pub async fn export_usage_csv(
Path(path): Path<WorkspacePath>,
Query(query): Query<UsageRequestQuery>,
State(state): State<AppState>,
) -> Result<Response, ApiError> {
let csv = state
.service
.export_usage_csv(&path.workspace_id.as_str().into(), query)
.await?;
let mut response = (StatusCode::OK, csv).into_response();
response.headers_mut().insert(
CONTENT_TYPE,
HeaderValue::from_static("text/csv; charset=utf-8"),
);
response.headers_mut().insert(
CONTENT_DISPOSITION,
HeaderValue::from_static("attachment; filename=\"crank-usage.csv\""),
);
Ok(response)
}
pub async fn get_log(
@@ -87,6 +134,38 @@ pub async fn get_approval(
Ok(Json(json!(item)))
}
pub async fn approve_approval(
Path(path): Path<WorkspaceApprovalPath>,
State(state): State<AppState>,
Json(payload): Json<ApprovalDecisionPayload>,
) -> Result<Json<Value>, ApiError> {
let item = state
.service
.approve_approval(
&path.workspace_id.as_str().into(),
&path.approval_id.as_str().into(),
payload,
)
.await?;
Ok(Json(json!(item)))
}
pub async fn deny_approval(
Path(path): Path<WorkspaceApprovalPath>,
State(state): State<AppState>,
Json(payload): Json<ApprovalDecisionPayload>,
) -> Result<Json<Value>, ApiError> {
let item = state
.service
.deny_approval(
&path.workspace_id.as_str().into(),
&path.approval_id.as_str().into(),
payload,
)
.await?;
Ok(Json(json!(item)))
}
pub async fn get_usage(
Path(path): Path<WorkspacePath>,
Query(query): Query<UsageRequestQuery>,
+59
View File
@@ -0,0 +1,59 @@
use axum::{
Json,
extract::{Path, State},
};
use crate::{
error::ApiError,
service::{
OnboardingEventPayload, OnboardingEventResponse, OnboardingResponse,
ResetOnboardingSelectionPayload, ResetOnboardingSelectionResponse,
},
state::AppState,
};
#[derive(serde::Deserialize)]
pub struct WorkspaceOnboardingPath {
pub workspace_id: String,
}
pub async fn reset_onboarding_selection(
Path(path): Path<WorkspaceOnboardingPath>,
State(state): State<AppState>,
Json(payload): Json<ResetOnboardingSelectionPayload>,
) -> Result<Json<ResetOnboardingSelectionResponse>, ApiError> {
Ok(Json(
state
.service
.reset_onboarding_selection(
&path.workspace_id.as_str().into(),
payload.expected_revision,
)
.await?,
))
}
pub async fn get_onboarding(
Path(path): Path<WorkspaceOnboardingPath>,
State(state): State<AppState>,
) -> Result<Json<OnboardingResponse>, ApiError> {
Ok(Json(
state
.service
.get_onboarding(&path.workspace_id.as_str().into())
.await?,
))
}
pub async fn record_onboarding_event(
Path(path): Path<WorkspaceOnboardingPath>,
State(state): State<AppState>,
Json(payload): Json<OnboardingEventPayload>,
) -> Result<Json<OnboardingEventResponse>, ApiError> {
Ok(Json(
state
.service
.record_onboarding_event(&path.workspace_id.as_str().into(), payload)
.await?,
))
}
+102 -7
View File
@@ -1,9 +1,10 @@
use axum::{
Json,
extract::{Extension, Path, Query, State},
extract::{Extension, Path, Query, State, rejection::StringRejection},
http::{HeaderMap, StatusCode, header},
response::IntoResponse,
};
use crank_registry::OperationStateExpectation;
use serde::Deserialize;
use serde_json::{Value, json};
@@ -79,7 +80,7 @@ pub async fn analyze_operation_quality(
pub async fn get_operation(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
) -> Result<impl IntoResponse, ApiError> {
let operation = state
.service
.get_operation(
@@ -87,20 +88,30 @@ pub async fn get_operation(
&path.operation_id.as_str().into(),
)
.await?;
Ok(Json(json!(operation)))
let etag = crate::service::AdminService::operation_state_etag(&operation);
let mut headers = HeaderMap::new();
headers.insert(
header::ETAG,
header::HeaderValue::from_str(&etag)
.map_err(|_| ApiError::internal("invalid operation etag"))?,
);
Ok((headers, Json(json!(operation))))
}
pub async fn update_operation(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<UpdateOperationPayload>,
) -> Result<Json<Value>, ApiError> {
let expected = require_operation_precondition(&state, &path, &headers).await?;
let result = state
.service
.update_operation(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
payload,
&expected,
)
.await?;
Ok(Json(json!(result)))
@@ -109,12 +120,15 @@ pub async fn update_operation(
pub async fn delete_operation(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, ApiError> {
let expected = require_operation_precondition(&state, &path, &headers).await?;
let result = state
.service
.delete_operation(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
&expected,
)
.await?;
Ok(Json(json!(result)))
@@ -123,7 +137,7 @@ pub async fn delete_operation(
pub async fn get_operation_version(
Path(path): Path<WorkspaceOperationVersionPath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
) -> Result<impl IntoResponse, ApiError> {
let version = state
.service
.get_operation_version(
@@ -132,20 +146,30 @@ pub async fn get_operation_version(
path.version,
)
.await?;
Ok(Json(json!(version)))
let etag = crate::service::AdminService::operation_version_etag(&version)?;
let mut headers = HeaderMap::new();
headers.insert(
header::ETAG,
header::HeaderValue::from_str(&etag)
.map_err(|_| ApiError::internal("invalid operation version etag"))?,
);
Ok((headers, Json(json!(version))))
}
pub async fn create_version(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<NewVersionPayload>,
) -> Result<Json<Value>, ApiError> {
let expected = require_operation_precondition(&state, &path, &headers).await?;
let created = state
.service
.create_version(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
payload,
&expected,
)
.await?;
Ok(Json(json!(created)))
@@ -154,14 +178,17 @@ pub async fn create_version(
pub async fn publish_operation(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<PublishPayload>,
) -> Result<Json<Value>, ApiError> {
let expected = require_operation_precondition(&state, &path, &headers).await?;
let published = state
.service
.publish_operation(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
payload.version,
&expected,
)
.await?;
Ok(Json(json!(published)))
@@ -170,17 +197,63 @@ pub async fn publish_operation(
pub async fn archive_operation(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, ApiError> {
let expected = require_operation_precondition(&state, &path, &headers).await?;
let archived = state
.service
.archive_operation(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
&expected,
)
.await?;
Ok(Json(json!(archived)))
}
async fn require_operation_precondition(
state: &AppState,
path: &WorkspaceOperationPath,
headers: &HeaderMap,
) -> Result<OperationStateExpectation, ApiError> {
let detail = state
.service
.get_operation(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
)
.await?;
let expected = crate::service::AdminService::operation_state_etag(&detail);
let provided = headers
.get(header::IF_MATCH)
.and_then(|value| value.to_str().ok())
.ok_or_else(|| {
ApiError::precondition_required_with_context(
"If-Match is required for Operation mutation",
json!({
"error_code": "operation_precondition_required",
"current_version": detail.current_draft_version,
"recovery": "reload"
}),
)
})?;
if provided != expected {
return Err(ApiError::conflict_with_context(
"Operation state changed; reload before retrying",
json!({
"error_code": "operation_stale_version",
"current_version": detail.current_draft_version,
"recovery": "reload"
}),
));
}
Ok(OperationStateExpectation {
current_draft_version: detail.current_draft_version,
status: detail.status,
latest_published_version: detail.latest_published_version,
})
}
pub async fn run_test(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
@@ -285,11 +358,33 @@ pub async fn import_operation(
Path(path): Path<WorkspacePath>,
Query(query): Query<ImportQuery>,
State(state): State<AppState>,
body: String,
headers: HeaderMap,
body: Result<String, StringRejection>,
) -> Result<Json<Value>, ApiError> {
let body = body.map_err(|rejection| match rejection {
StringRejection::InvalidUtf8(_) => ApiError::unprocessable_with_context(
"operation yaml is not valid UTF-8",
json!({ "error_code": "operation_yaml_invalid" }),
),
StringRejection::FailedToBufferBody(_) => ApiError::payload_too_large_with_context(
"operation yaml exceeds the 256 KiB limit",
json!({ "error_code": "operation_yaml_too_large" }),
),
_ => ApiError::unprocessable_with_context(
"operation yaml body is invalid",
json!({ "error_code": "operation_yaml_invalid" }),
),
})?;
let imported = state
.service
.import_operation(&path.workspace_id.as_str().into(), query, &body)
.import_operation(
&path.workspace_id.as_str().into(),
query,
&body,
headers
.get(header::IF_MATCH)
.and_then(|value| value.to_str().ok()),
)
.await?;
Ok(Json(json!(imported)))
}
+15 -1
View File
@@ -8,7 +8,8 @@ use serde_json::{Value, json};
use crate::{
auth::AuthenticatedSession,
error::ApiError,
service::{RotateSecretPayload, SecretPayload},
request_context::RequestContext,
service::{AdminAuditContext, RotateSecretPayload, SecretPayload},
state::AppState,
};
@@ -38,14 +39,18 @@ pub async fn create_secret(
Path(path): Path<WorkspacePath>,
State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
Extension(request_context): Extension<RequestContext>,
Json(payload): Json<SecretPayload>,
) -> Result<Json<Value>, ApiError> {
let audit_context =
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
let secret = state
.service
.create_secret(
&path.workspace_id.as_str().into(),
Some(&session.user.id),
payload,
Some(&audit_context),
)
.await?;
Ok(Json(json!(secret)))
@@ -69,8 +74,11 @@ pub async fn rotate_secret(
Path(path): Path<WorkspaceSecretPath>,
State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
Extension(request_context): Extension<RequestContext>,
Json(payload): Json<RotateSecretPayload>,
) -> Result<Json<Value>, ApiError> {
let audit_context =
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
let secret = state
.service
.rotate_secret(
@@ -78,6 +86,7 @@ pub async fn rotate_secret(
&path.secret_id.as_str().into(),
Some(&session.user.id),
payload,
Some(&audit_context),
)
.await?;
Ok(Json(json!(secret)))
@@ -86,12 +95,17 @@ pub async fn rotate_secret(
pub async fn delete_secret(
Path(path): Path<WorkspaceSecretPath>,
State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, ApiError> {
let audit_context =
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
state
.service
.delete_secret(
&path.workspace_id.as_str().into(),
&path.secret_id.as_str().into(),
Some(&audit_context),
)
.await?;
Ok(Json(json!({ "ok": true })))
+372 -97
View File
@@ -4,23 +4,26 @@ use std::sync::Arc;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use crank_core::{
AuditSink, AuthProfile, CapabilityProfile, CommunityCapabilityProfile, EditionCapabilities,
AuditActor, AuditEvent, AuditEventId, AuditSink, AuditTarget, AuditTargetKind, AuthProfile,
CapabilityProfile, CommunityCapabilityProfile, CorrelationContext, EditionCapabilities,
ExecutionMode, IdentityError, IdentityProvider, InvocationLog, InvocationLogId,
InvocationSource, NoopAuditSink, OperationSecurityLevel, OwnerOnlyPolicyEngine, PolicyEngine,
ProductEdition, Protocol, ToolQualityMappingRule, ToolQualityMappingSet, ToolQualitySchemaKind,
ToolQualitySchemaNode, UsagePeriod, WorkspaceId,
ProductEdition, Protocol, SecretStatus, ToolQualityMappingRule, ToolQualityMappingSet,
ToolQualitySchemaKind, ToolQualitySchemaNode, UsagePeriod, WorkspaceId,
};
use crank_mapping::{MappingRule, MappingSet};
use crank_registry::{
AgentSummary, CreateInvocationLogRequest, InvocationHistoryWriteOutcome, OperationAgentRef,
OperationSummary, OperationUsageSummary, PostgresRegistry, RegistryOperation, UsageBucket,
OperationSummary, OperationUsageSummary, PostgresRegistry, RegistryError, RegistryOperation,
UsageBucket,
};
use crank_runtime::{
OutboundHttpPolicy, PreparedRequest, ResolvedAuth, RuntimeError, RuntimeExecutor, SecretCrypto,
OutboundHttpPolicy, ResolvedAuth, RuntimeError, RuntimeExecutor, SecretCrypto,
};
use crank_schema::{Schema, SchemaKind};
use crank_trace::{DbOperation, ErrorCategory, Stage, StageOutcome, observe_db_query};
use serde_json::{Value, json};
use rand::RngExt;
use serde_json::json;
use sha2::{Digest, Sha256};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tracing::Instrument;
@@ -33,6 +36,7 @@ mod demo;
mod import_export;
mod imports;
mod observability;
mod onboarding;
mod operation_validation;
mod operations;
mod samples;
@@ -40,7 +44,11 @@ mod secrets;
mod upstreams;
mod workspaces;
use crate::{auth::AuthSettings, error::ApiError, storage::LocalArtifactStorage};
use crate::{
auth::{AuthSettings, AuthenticatedSession},
error::ApiError,
storage::LocalArtifactStorage,
};
use operation_validation::{
validate_approval_policy, validate_execution_timeout, validate_idempotency_policy,
validate_protocol_target, validate_response_cache_policy,
@@ -58,6 +66,7 @@ pub struct AdminService {
audit_sink: Arc<dyn AuditSink>,
capability_profile: Arc<dyn CapabilityProfile>,
outbound_http_policy: OutboundHttpPolicy,
public_base_url: String,
}
pub struct AdminServiceBuilder {
@@ -71,10 +80,46 @@ pub struct AdminServiceBuilder {
audit_sink: Option<Arc<dyn AuditSink>>,
capability_profile: Option<Arc<dyn CapabilityProfile>>,
outbound_http_policy: OutboundHttpPolicy,
public_base_url: String,
}
pub use crate::dto::*;
#[derive(Clone, Debug)]
pub struct AdminAuditContext {
actor: AuditActor,
request_id: String,
trace_id: String,
}
impl AdminAuditContext {
pub fn from_session_and_correlation(
session: &AuthenticatedSession,
correlation: &CorrelationContext,
) -> Self {
Self {
actor: AuditActor {
user_id: session.user.id.clone(),
email: session.user.email.clone(),
session_id: Some(session.session_id.clone()),
},
request_id: correlation.request_id().as_str().to_owned(),
trace_id: correlation.trace_id().as_str().to_owned(),
}
}
}
#[derive(Clone, Copy, Debug)]
pub(super) struct CredentialAuditRecord<'a> {
pub action: &'static str,
pub target_kind: AuditTargetKind,
pub workspace_id: &'a WorkspaceId,
pub target_id: &'a str,
pub credential_type: &'static str,
pub outcome: &'static str,
pub reason: &'a str,
}
impl AdminService {
pub async fn readiness(&self) -> Result<(), ApiError> {
self.registry.ping().await?;
@@ -132,6 +177,19 @@ impl AdminService {
}
}
fn runtime_credential_error_code(error: &RuntimeError) -> &'static str {
match error {
RuntimeError::AuthorizationStoreUnavailable => "authorization_store_unavailable",
RuntimeError::MissingAuthProfile { .. } => "auth_profile_not_found",
RuntimeError::InvalidAuthProfileConfig { .. } => "auth_profile_invalid",
RuntimeError::MissingSecret { .. } => "secret_not_found",
RuntimeError::MissingSecretVersion { .. } => "secret_version_not_found",
RuntimeError::InvalidAuthSecretValue { .. } => "secret_invalid",
RuntimeError::SecretCrypto { .. } => "secret_crypto_failed",
_ => "credential_resolution_failed",
}
}
impl AdminServiceBuilder {
pub fn new(
registry: PostgresRegistry,
@@ -151,6 +209,7 @@ impl AdminServiceBuilder {
audit_sink: None,
capability_profile: None,
outbound_http_policy: OutboundHttpPolicy::default(),
public_base_url: "http://localhost:3000".to_owned(),
}
}
@@ -164,6 +223,11 @@ impl AdminServiceBuilder {
self
}
pub fn with_public_base_url(mut self, public_base_url: String) -> Self {
self.public_base_url = public_base_url.trim_end_matches('/').to_owned();
self
}
#[allow(dead_code)]
pub fn with_policy_engine(mut self, policy_engine: Arc<dyn PolicyEngine>) -> Self {
self.policy_engine = Some(policy_engine);
@@ -201,6 +265,7 @@ impl AdminServiceBuilder {
.capability_profile
.unwrap_or_else(|| Arc::new(CommunityCapabilityProfile)),
outbound_http_policy: self.outbound_http_policy,
public_base_url: self.public_base_url,
}
}
}
@@ -254,10 +319,56 @@ impl AdminService {
self.capability_profile().capabilities()
}
async fn record_credential_audit(
&self,
audit_context: Option<&AdminAuditContext>,
record: CredentialAuditRecord<'_>,
) {
let Some(audit_context) = audit_context else {
return;
};
let event = AuditEvent {
id: AuditEventId::new(new_prefixed_id("audit")),
occurred_at: OffsetDateTime::now_utc(),
actor: audit_context.actor.clone(),
action: record.action.to_owned(),
target: AuditTarget {
workspace_id: record.workspace_id.clone(),
kind: record.target_kind,
id: record.target_id.to_owned(),
},
payload: json!({
"credential_type": record.credential_type,
"outcome": record.outcome,
"reason": record.reason,
"request_id": audit_context.request_id,
"trace_id": audit_context.trace_id
}),
source_ip: None,
user_agent: None,
};
if self.audit_sink.record(event).await.is_err() {
tracing::warn!(
name: "admin.credential_audit.lost",
action = record.action,
credential_type = record.credential_type,
outcome = record.outcome,
reason = record.reason,
request_id = %audit_context.request_id,
trace_id = %audit_context.trace_id,
error_code = "audit_sink_failed",
"credential audit event was not recorded"
);
}
}
async fn resolve_operation_auth(
&self,
workspace_id: &WorkspaceId,
execution_config: &crank_core::ExecutionConfig,
audit_context: Option<&AdminAuditContext>,
) -> Result<Option<ResolvedAuth>, RuntimeError> {
let Some(auth_profile_id) = execution_config.auth_profile_ref.as_ref() else {
return Ok(None);
@@ -271,15 +382,14 @@ impl AdminService {
.get_auth_profile(workspace_id, auth_profile_id),
)
.await
.map_err(|error| RuntimeError::SecretCrypto {
operation: "load auth profile",
details: error.to_string(),
})?
.ok_or_else(|| RuntimeError::MissingAuthProfile {
auth_profile_id: auth_profile_id.as_str().to_owned(),
})?;
.map_err(|_| RuntimeError::AuthorizationStoreUnavailable)?;
let Some(auth_profile) = auth_profile else {
return Err(RuntimeError::MissingAuthProfile {
auth_profile_id: auth_profile_id.as_str().to_owned(),
});
};
self.resolve_auth_profile(workspace_id, &auth_profile)
self.resolve_auth_profile(workspace_id, &auth_profile, audit_context)
.await
.map(Some)
}
@@ -292,6 +402,17 @@ impl AdminService {
ErrorCategory::Configuration.record(&span);
}
}
if let Err(error) = &result {
self.record_credential_resolution_failure(
audit_context,
workspace_id,
crank_core::AuditTargetKind::AuthProfile,
auth_profile_id.as_str(),
"auth_profile",
runtime_credential_error_code(error),
)
.await;
}
result
}
@@ -299,6 +420,7 @@ impl AdminService {
&self,
workspace_id: &WorkspaceId,
auth_profile: &AuthProfile,
audit_context: Option<&AdminAuditContext>,
) -> Result<ResolvedAuth, RuntimeError> {
let mut secrets = BTreeMap::new();
let used_at = OffsetDateTime::now_utc();
@@ -309,45 +431,156 @@ impl AdminService {
self.registry.get_secret(workspace_id, secret_id),
)
.await
.map_err(|error| RuntimeError::SecretCrypto {
operation: "load secret",
details: error.to_string(),
})?
.ok_or_else(|| RuntimeError::MissingSecret {
secret_id: secret_id.as_str().to_owned(),
})?;
.map_err(|_| RuntimeError::AuthorizationStoreUnavailable)?;
let Some(secret) = secret else {
let error = RuntimeError::MissingSecret {
secret_id: secret_id.as_str().to_owned(),
};
self.record_credential_resolution_failure(
audit_context,
workspace_id,
crank_core::AuditTargetKind::Secret,
secret_id.as_str(),
"secret",
runtime_credential_error_code(&error),
)
.await;
return Err(error);
};
if secret.secret.status != SecretStatus::Active {
let error = RuntimeError::InvalidAuthSecretValue {
secret_id: secret_id.as_str().to_owned(),
reason: "secret is not active".to_owned(),
};
self.record_credential_resolution_failure(
audit_context,
workspace_id,
crank_core::AuditTargetKind::Secret,
secret_id.as_str(),
"secret",
runtime_credential_error_code(&error),
)
.await;
return Err(error);
}
let version = observe_db_query(
DbOperation::SecretRead,
self.registry
.get_current_secret_version(workspace_id, secret_id),
)
.await
.map_err(|error| RuntimeError::SecretCrypto {
operation: "load current secret version",
details: error.to_string(),
})?
.ok_or_else(|| RuntimeError::MissingSecretVersion {
secret_id: secret_id.as_str().to_owned(),
version: secret.secret.current_version,
})?;
let plaintext = self.secret_crypto.decrypt(
.map_err(|_| RuntimeError::AuthorizationStoreUnavailable)?;
let Some(version) = version else {
let error = RuntimeError::MissingSecretVersion {
secret_id: secret_id.as_str().to_owned(),
version: secret.secret.current_version,
};
self.record_credential_resolution_failure(
audit_context,
workspace_id,
crank_core::AuditTargetKind::Secret,
secret_id.as_str(),
"secret",
runtime_credential_error_code(&error),
)
.await;
return Err(error);
};
let plaintext = self.secret_crypto.decrypt_for_epoch(
&version.secret_version.key_version,
version.master_key_epoch,
&version.secret_version.ciphertext,
)?;
);
let plaintext = match plaintext {
Ok(plaintext) => plaintext,
Err(error) => {
self.record_credential_resolution_failure(
audit_context,
workspace_id,
crank_core::AuditTargetKind::Secret,
secret_id.as_str(),
"secret",
runtime_credential_error_code(&error),
)
.await;
return Err(error);
}
};
observe_db_query(
DbOperation::SecretTouch,
self.registry
.touch_secret(workspace_id, secret_id, &used_at),
)
.await
.map_err(|error| RuntimeError::SecretCrypto {
operation: "touch secret",
details: error.to_string(),
})?;
.unwrap_or_else(|_| {
tracing::warn!(
name: "admin.secret.touch_failed",
secret_id = %secret_id.as_str(),
request_id = audit_context
.map(|context| context.request_id.as_str())
.unwrap_or("unavailable"),
trace_id = audit_context
.map(|context| context.trace_id.as_str())
.unwrap_or("unavailable"),
error_code = "secret_touch_failed",
"secret last-used metadata was not updated"
);
});
secrets.insert(secret_id.clone(), plaintext);
}
ResolvedAuth::from_profile(auth_profile, &secrets)
let result = ResolvedAuth::from_profile(auth_profile, &secrets);
if let Err(error) = &result {
self.record_credential_resolution_failure(
audit_context,
workspace_id,
crank_core::AuditTargetKind::AuthProfile,
auth_profile.id.as_str(),
"auth_profile",
runtime_credential_error_code(error),
)
.await;
}
result
}
async fn record_credential_resolution_failure(
&self,
audit_context: Option<&AdminAuditContext>,
workspace_id: &WorkspaceId,
target_kind: crank_core::AuditTargetKind,
target_id: &str,
credential_type: &'static str,
reason: &'static str,
) {
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.resolve_denied",
target_kind,
workspace_id,
target_id,
credential_type,
outcome: "failure",
reason,
},
)
.await;
tracing::warn!(
name: "admin.credential.resolve_denied",
credential_type,
target_kind = ?target_kind,
target_id,
outcome = "failure",
reason,
request_id = audit_context
.map(|context| context.request_id.as_str())
.unwrap_or("unavailable"),
trace_id = audit_context
.map(|context| context.trace_id.as_str())
.unwrap_or("unavailable"),
"credential resolution was denied"
);
}
fn validate_operation_payload(&self, payload: &OperationPayload) -> Result<(), ApiError> {
@@ -376,6 +609,70 @@ impl AdminService {
Ok(())
}
async fn validate_registry_operation_in_workspace(
&self,
workspace_id: &WorkspaceId,
operation: &RegistryOperation,
) -> Result<(), ApiError> {
self.validate_registry_operation(operation)?;
if let Some(auth_profile_id) = operation.execution_config.auth_profile_ref.as_ref() {
let Some(auth_profile) = self
.registry
.get_auth_profile(workspace_id, auth_profile_id)
.await?
else {
return Err(ApiError::unprocessable_with_context(
"operation auth profile reference is unavailable",
json!({ "error_code": "operation_auth_profile_invalid" }),
));
};
self.validate_auth_profile_secret_refs_for_execution(workspace_id, &auth_profile)
.await?;
}
Ok(())
}
async fn validate_auth_profile_secret_refs_for_execution(
&self,
workspace_id: &WorkspaceId,
auth_profile: &AuthProfile,
) -> Result<(), ApiError> {
if auth_profile.kind != auth_profile.config.kind() {
return Err(ApiError::unprocessable_with_context(
"operation auth profile kind and config do not match",
json!({ "error_code": "operation_auth_profile_invalid" }),
));
}
for secret_id in auth_profile.config.secret_ids() {
let Some(secret) = self.registry.get_secret(workspace_id, secret_id).await? else {
return Err(ApiError::unprocessable_with_context(
"operation auth profile secret reference is unavailable",
json!({ "error_code": "operation_auth_profile_invalid" }),
));
};
if secret.secret.status != SecretStatus::Active {
return Err(RegistryError::SecretInactive {
secret_id: secret_id.as_str().to_owned(),
}
.into());
}
if self
.registry
.get_current_secret_version(workspace_id, secret_id)
.await?
.is_none()
{
return Err(ApiError::unprocessable_with_context(
"operation auth profile secret has no current version",
json!({ "error_code": "operation_auth_profile_invalid" }),
));
}
}
Ok(())
}
fn validate_outbound_target(&self, target: &crank_core::Target) -> Result<(), ApiError> {
match target {
crank_core::Target::Rest(rest) => self
@@ -462,7 +759,9 @@ impl AdminService {
id: InvocationLogId::new(new_prefixed_id("log")),
workspace_id: request.workspace_id.clone(),
agent_id: request.agent_id.cloned(),
platform_api_key_id: None,
operation_id: request.operation.id.clone(),
operation_version: Some(request.operation.version),
source: request.source,
level: request.level,
status: request.status,
@@ -473,6 +772,10 @@ impl AdminService {
status_code: request.status_code,
duration_ms: request.duration_ms,
error_kind: request.error_kind,
execution_stage: request.execution_stage,
execution_error_code: request.execution_error_code,
retryability: request.retryability,
outcome_certainty: request.outcome_certainty,
request_preview: request.request_preview,
response_preview: request.response_preview,
created_at: OffsetDateTime::now_utc(),
@@ -553,29 +856,6 @@ fn invocation_status_label(status: crank_core::InvocationStatus) -> &'static str
}
}
fn build_request_preview(
mapping: &MappingSet,
input: &Value,
) -> Result<Value, crank_mapping::MappingError> {
let prepared = if mapping.is_empty() {
PreparedRequest::default()
} else {
let mapped = mapping.apply(&json!({ "mcp": input }))?;
PreparedRequest::from_mapping_output(&mapped).map_err(|error| {
crank_mapping::MappingError::InvalidJsonPath {
path: error.to_string(),
}
})?
};
Ok(json!({
"path": prepared.path_params,
"query": prepared.query_params,
"headers": prepared.headers,
"body": prepared.body.unwrap_or(Value::Null)
}))
}
fn validate_profile_display_name(value: &str) -> Result<String, ApiError> {
let display_name = value.trim();
if display_name.is_empty() {
@@ -616,7 +896,9 @@ fn new_prefixed_id(prefix: &str) -> String {
}
fn generate_access_secret(marker: &str) -> String {
let random = URL_SAFE_NO_PAD.encode(Uuid::now_v7().as_bytes());
let mut secret_bytes = [0_u8; 32];
rand::rng().fill(&mut secret_bytes);
let random = URL_SAFE_NO_PAD.encode(secret_bytes);
format!("{marker}{random}")
}
@@ -640,7 +922,7 @@ fn format_timestamp(timestamp: OffsetDateTime) -> String {
fn map_identity_error(error: IdentityError) -> ApiError {
match error {
IdentityError::BadCredentials => ApiError::unauthorized("invalid email or password"),
IdentityError::AccountDisabled => ApiError::forbidden("account is disabled"),
IdentityError::AccountDisabled => ApiError::unauthorized("invalid email or password"),
IdentityError::NotSupportedForProvider => ApiError::internal(
"password login is not supported by the configured identity provider",
),
@@ -648,32 +930,6 @@ fn map_identity_error(error: IdentityError) -> ApiError {
}
}
fn runtime_error_code(error: &RuntimeError) -> &'static str {
match error {
RuntimeError::Schema(_) => "schema_error",
RuntimeError::Mapping(_) => "mapping_error",
RuntimeError::InvalidPreparedRequest { .. } => "invalid_request",
RuntimeError::ConfirmationRequired { .. } => "confirmation_required",
RuntimeError::InvalidConfirmationToken { .. } => "invalid_confirmation_token",
RuntimeError::ConfirmationStoreUnavailable { .. } => "confirmation_unavailable",
RuntimeError::IdempotencyStoreUnavailable { .. } => "idempotency_unavailable",
RuntimeError::IdempotencyInProgress { .. } => "idempotency_in_progress",
RuntimeError::IdempotencyConflict { .. } => "idempotency_conflict",
RuntimeError::IdempotencyOutcomeUnknown { .. } => "idempotency_outcome_unknown",
RuntimeError::RestAdapter(_) => "rest_error",
RuntimeError::ProtocolAdapter(_) => "adapter_error",
RuntimeError::UnsupportedProtocol { .. } => "unsupported_protocol",
RuntimeError::ConcurrencyLimitExceeded { .. } => "runtime_overloaded",
RuntimeError::UnsupportedExecutionMode { .. } => "streaming_mode_error",
RuntimeError::MissingAuthProfile { .. } => "auth_profile_not_found",
RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => {
"secret_not_found"
}
RuntimeError::InvalidAuthSecretValue { .. } => "secret_value_error",
RuntimeError::SecretCrypto { .. } => "secret_crypto_error",
}
}
fn protocol_capability_view(
protocol: Protocol,
_edition: ProductEdition,
@@ -698,7 +954,9 @@ fn protocol_capability_view(
}
}
fn usage_window(period: UsagePeriod) -> Result<(UsagePeriod, String, UsageBucket), ApiError> {
fn usage_window(
period: UsagePeriod,
) -> Result<(UsagePeriod, String, String, UsageBucket), ApiError> {
let now = OffsetDateTime::now_utc();
let (start, bucket) = match period {
UsagePeriod::Last30Minutes => (
@@ -746,13 +1004,14 @@ fn usage_window(period: UsagePeriod) -> Result<(UsagePeriod, String, UsageBucket
),
};
Ok((
period,
start
.format(&Rfc3339)
.map_err(|error| ApiError::internal(error.to_string()))?,
bucket,
))
let created_after = start
.format(&Rfc3339)
.map_err(|error| ApiError::internal(error.to_string()))?;
let created_before = now
.format(&Rfc3339)
.map_err(|error| ApiError::internal(error.to_string()))?;
Ok((period, created_after, created_before, bucket))
}
fn today_start_utc() -> Result<String, ApiError> {
@@ -810,6 +1069,7 @@ fn map_agent_summary_view(summary: AgentSummary) -> AgentSummaryView {
status: summary.status,
current_draft_version: summary.current_draft_version,
latest_published_version: summary.latest_published_version,
catalog_revision: summary.catalog_revision,
created_at: format_timestamp(summary.created_at),
updated_at: format_timestamp(summary.updated_at),
published_at: summary.published_at.map(format_timestamp),
@@ -822,10 +1082,24 @@ fn map_agent_summary_view(summary: AgentSummary) -> AgentSummaryView {
}
}
fn agent_mcp_endpoint(workspace_slug: &str, agent_slug: &str) -> String {
pub(crate) fn agent_mcp_endpoint(workspace_slug: &str, agent_slug: &str) -> String {
format!("/mcp/v1/{workspace_slug}/{agent_slug}")
}
impl AdminService {
pub(crate) fn public_agent_mcp_endpoint(
&self,
workspace_slug: &str,
agent_slug: &str,
) -> String {
format!(
"{}{}",
self.public_base_url,
agent_mcp_endpoint(workspace_slug, agent_slug)
)
}
}
fn tool_quality_schema_node(schema: &Schema) -> ToolQualitySchemaNode {
ToolQualitySchemaNode {
kind: tool_quality_schema_kind(&schema.kind),
@@ -1013,6 +1287,7 @@ fn enrich_operation_summary(
status: summary.status,
current_draft_version: summary.current_draft_version,
latest_published_version: summary.latest_published_version,
can_delete: summary.can_delete,
created_at: format_timestamp(summary.created_at),
updated_at: format_timestamp(summary.updated_at),
published_at: summary.published_at.map(format_timestamp),
+155 -69
View File
@@ -5,11 +5,13 @@ use crank_core::{
ToolAccessMode, ToolSelectionPolicy, UsagePeriod, WorkspaceId, search_tool_catalog,
};
use crank_registry::{
AgentVersionRecord, CreateAgentDraftVersionRequest, CreateAgentRequest, PublishAgentRequest,
SaveAgentBindingsRequest, SaveAgentCatalogConfigRequest, UsageBucket, UsageQuery,
AgentStateExpectation, AgentVersionRecord, CreateAgentDraftVersionRequest, CreateAgentRequest,
PublishAgentRequest, SaveAgentCatalogConfigRequest, UpdateAgentSummaryRequest, UsageBucket,
UsageQuery,
};
use serde_json::json;
use time::OffsetDateTime;
use sha2::{Digest, Sha256};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tracing::{info, instrument};
use crate::{
@@ -17,12 +19,50 @@ use crate::{
service::{
AdminService, AgentCatalogPayload, AgentMutationResult, AgentPayload, AgentSummaryView,
CreatedAgentResponse, PublishAgentResponse, ToolSearchPreviewPayload, UpdateAgentPayload,
agent_mcp_endpoint, format_timestamp, map_agent_summary_view, new_prefixed_id,
agent_mcp_endpoint, format_timestamp, map_agent_summary_view, new_prefixed_id, now_string,
today_start_utc,
},
};
impl AdminService {
pub fn agent_state_etag(agent: &AgentSummaryView) -> String {
let policy =
serde_json::to_string(&agent.tool_selection_policy).unwrap_or_else(|_| "{}".to_owned());
let operation_ids = agent.operation_ids.join(",");
let material = format!(
"agent-state-v2\0{}\0{}\0{}\0{}\0{}\0{}\0{}\0{:?}\0{:?}\0{}\0{}\0{}\0{}\0{}",
agent.workspace_id,
agent.id,
agent.slug,
agent.display_name,
agent.description,
agent.updated_at,
agent.current_draft_version,
agent.status,
agent.latest_published_version,
agent.catalog_revision,
agent.operation_count,
operation_ids,
policy,
agent.key_count
);
format!("\"{:x}\"", Sha256::digest(material.as_bytes()))
}
pub fn agent_state_expectation(
agent: &AgentSummaryView,
) -> Result<AgentStateExpectation, ApiError> {
let updated_at = OffsetDateTime::parse(&agent.updated_at, &Rfc3339)
.map_err(|_| ApiError::internal("invalid agent state timestamp"))?;
Ok(AgentStateExpectation {
status: agent.status,
current_draft_version: agent.current_draft_version,
latest_published_version: agent.latest_published_version,
catalog_revision: agent.catalog_revision,
updated_at,
})
}
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str()))]
pub async fn preview_tool_search(
&self,
@@ -110,13 +150,16 @@ impl AdminService {
self.ensure_workspace_exists(workspace_id).await?;
let workspace = self.get_workspace(workspace_id).await?;
let summaries = self.registry.list_agents(workspace_id).await?;
let usage_start = today_start_utc()?;
let usage_end = now_string()?;
let usage = self
.registry
.list_usage_by_agent(UsageQuery {
workspace_id,
period: UsagePeriod::Last24Hours,
source: None,
created_after: &today_start_utc()?,
created_after: &usage_start,
created_before: &usage_end,
bucket: UsageBucket::Hour,
})
.await?;
@@ -189,6 +232,8 @@ impl AdminService {
.iter()
.map(|binding| binding.operation_id.as_str().to_owned())
.collect::<Vec<_>>();
let usage_start = today_start_utc()?;
let usage_end = now_string()?;
let usage = self
.registry
.get_usage_for_agent(
@@ -196,7 +241,8 @@ impl AdminService {
workspace_id,
period: UsagePeriod::Last24Hours,
source: None,
created_after: &today_start_utc()?,
created_after: &usage_start,
created_before: &usage_end,
bucket: UsageBucket::Hour,
},
agent_id,
@@ -317,6 +363,7 @@ impl AdminService {
workspace_id: &WorkspaceId,
agent_id: &AgentId,
payload: UpdateAgentPayload,
expected_state: Option<&AgentStateExpectation>,
) -> Result<AgentMutationResult, ApiError> {
let existing = self
.registry
@@ -328,6 +375,20 @@ impl AdminService {
json!({ "agent_id": agent_id.as_str() }),
)
})?;
if existing.status == AgentStatus::Published
&& (payload.slug != existing.slug
|| payload.display_name != existing.display_name
|| payload.description != existing.description)
{
return Err(ApiError::conflict_with_context(
"published Agent summary is immutable; unpublish before editing",
json!({
"agent_id": agent_id.as_str(),
"error_code": "agent_published_summary_immutable",
"recovery": "unpublish_edit_publish"
}),
));
}
if payload.slug != existing.slug
&& self
@@ -346,10 +407,13 @@ impl AdminService {
.update_agent_summary(
workspace_id,
agent_id,
&payload.slug,
&payload.display_name,
&payload.description,
&updated_at,
UpdateAgentSummaryRequest {
slug: &payload.slug,
display_name: &payload.display_name,
description: &payload.description,
updated_at: &updated_at,
expected_state,
},
)
.await?;
@@ -365,6 +429,7 @@ impl AdminService {
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
expected_state: Option<&AgentStateExpectation>,
) -> Result<AgentMutationResult, ApiError> {
let existing = self
.registry
@@ -376,8 +441,20 @@ impl AdminService {
json!({ "agent_id": agent_id.as_str() }),
)
})?;
if existing.latest_published_version.is_some() {
return Err(ApiError::conflict_with_context(
"published Agent cannot be deleted; archive or unpublish it instead",
json!({
"agent_id": agent_id.as_str(),
"error_code": "agent_delete_forbidden",
"recovery": "archive_or_unpublish"
}),
));
}
self.registry.delete_agent(workspace_id, agent_id).await?;
self.registry
.delete_agent(workspace_id, agent_id, expected_state)
.await?;
Ok(AgentMutationResult {
agent_id: agent_id.as_str().to_owned(),
@@ -392,6 +469,7 @@ impl AdminService {
workspace_id: &WorkspaceId,
agent_id: &AgentId,
payload: AgentCatalogPayload,
expected_state: Option<&AgentStateExpectation>,
) -> Result<AgentVersionRecord, ApiError> {
let current_version = self
.ensure_editable_agent_version(workspace_id, agent_id)
@@ -408,6 +486,8 @@ impl AdminService {
enabled: binding.enabled,
})
.collect::<Vec<_>>();
self.validate_exact_published_agent_bindings(workspace_id, &bindings)
.await?;
let tool_selection_policy = requested_policy
.unwrap_or_else(|| current_version.snapshot.tool_selection_policy.clone());
validate_tool_selection_policy(&tool_selection_policy, &bindings)?;
@@ -419,6 +499,7 @@ impl AdminService {
agent_version: current_version.version,
bindings: &bindings,
tool_selection_policy: &tool_selection_policy,
expected_state,
})
.await?;
info!(
@@ -474,19 +555,23 @@ impl AdminService {
workspace_id: &WorkspaceId,
agent_id: &AgentId,
version: u32,
expected_state: Option<&AgentStateExpectation>,
) -> Result<PublishAgentResponse, ApiError> {
let agent_version = self
.get_agent_version(workspace_id, agent_id, version)
.await?;
let published_bindings = self
.published_agent_bindings(workspace_id, &agent_version.bindings)
.await?;
validate_tool_selection_policy(
&agent_version.snapshot.tool_selection_policy,
&published_bindings,
&agent_version.bindings,
)?;
if published_bindings.is_empty() {
if agent_version
.bindings
.iter()
.filter(|binding| binding.enabled)
.count()
== 0
{
return Err(ApiError::conflict_with_context(
"agent cannot be published without published enabled tools",
json!({
@@ -497,36 +582,10 @@ impl AdminService {
));
}
self.validate_exact_published_agent_bindings(workspace_id, &agent_version.bindings)
.await?;
let published_at = OffsetDateTime::now_utc();
if published_bindings != agent_version.bindings {
let draft_version = AgentVersion {
agent_id: agent_id.clone(),
version: agent_version.version + 1,
status: AgentStatus::Draft,
instructions: agent_version.snapshot.instructions.clone(),
tool_selection_policy: agent_version.snapshot.tool_selection_policy.clone(),
created_at: published_at,
};
self.registry
.create_agent_draft_version(CreateAgentDraftVersionRequest {
workspace_id,
agent_id,
version: &draft_version,
bindings: &agent_version.bindings,
updated_at: &published_at,
})
.await?;
self.registry
.save_agent_bindings(SaveAgentBindingsRequest {
workspace_id,
agent_id,
agent_version: agent_version.version,
bindings: &published_bindings,
})
.await?;
}
self.registry
.publish_agent(PublishAgentRequest {
@@ -535,6 +594,7 @@ impl AdminService {
version,
published_at: &published_at,
published_by: None,
expected_state,
})
.await?;
info!(
@@ -552,41 +612,65 @@ impl AdminService {
})
}
async fn published_agent_bindings(
async fn validate_exact_published_agent_bindings(
&self,
workspace_id: &WorkspaceId,
bindings: &[AgentOperationBinding],
) -> Result<Vec<AgentOperationBinding>, ApiError> {
let mut published = Vec::new();
) -> Result<(), ApiError> {
for binding in bindings {
if !binding.enabled {
continue;
}
let Some(summary) = self
.registry
.get_operation_summary(workspace_id, &binding.operation_id)
.await?
else {
continue;
return Err(ApiError::not_found_with_context(
"operation was not found for Agent binding",
json!({
"error_code": "agent_binding_scope_denied",
"operation_id": binding.operation_id.as_str()
}),
));
};
if summary.status == crank_core::OperationStatus::Archived {
return Err(ApiError::unprocessable_with_context(
"archived operation cannot be added to an Agent binding",
json!({
"error_code": "agent_binding_archived_operation",
"operation_id": binding.operation_id.as_str()
}),
));
}
let Some(operation_version) = summary.latest_published_version else {
continue;
let Some(version) = self
.registry
.get_operation_version(
workspace_id,
&binding.operation_id,
binding.operation_version,
)
.await?
else {
return Err(ApiError::unprocessable_with_context(
"operation version is not published for Agent binding",
json!({
"error_code": "agent_binding_not_published",
"operation_id": binding.operation_id.as_str(),
"operation_version": binding.operation_version
}),
));
};
published.push(AgentOperationBinding {
operation_id: summary.id,
operation_version,
tool_name: binding.tool_name.clone(),
tool_title: binding.tool_title.clone(),
tool_description_override: binding.tool_description_override.clone(),
enabled: true,
});
if !version.snapshot.is_published() {
return Err(ApiError::unprocessable_with_context(
"operation version is not published for Agent binding",
json!({
"error_code": "agent_binding_not_published",
"operation_id": binding.operation_id.as_str(),
"operation_version": binding.operation_version
}),
));
}
}
Ok(published)
Ok(())
}
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str()))]
@@ -594,11 +678,12 @@ impl AdminService {
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
expected_state: Option<&AgentStateExpectation>,
) -> Result<AgentMutationResult, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
let updated_at = OffsetDateTime::now_utc();
self.registry
.unpublish_agent(workspace_id, agent_id, &updated_at)
.unpublish_agent(workspace_id, agent_id, &updated_at, expected_state)
.await?;
info!(
name: "admin.agent.unpublished",
@@ -618,11 +703,12 @@ impl AdminService {
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
expected_state: Option<&AgentStateExpectation>,
) -> Result<AgentMutationResult, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
let updated_at = OffsetDateTime::now_utc();
self.registry
.archive_agent(workspace_id, agent_id, &updated_at)
.archive_agent(workspace_id, agent_id, &updated_at, expected_state)
.await?;
info!(
name: "admin.agent.archived",
+300 -28
View File
@@ -6,12 +6,14 @@ use crank_registry::{CreatePlatformApiKeyRequest, PlatformApiKeyRecord};
use serde_json::json;
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tracing::instrument;
use url::Url;
use crate::{
error::ApiError,
service::{
AdminService, CreatedPlatformApiKeyResponse, PlatformApiKeyPayload, generate_access_secret,
hash_access_secret, new_prefixed_id,
AdminAuditContext, AdminService, CreatedPlatformApiKeyResponse, CredentialAuditRecord,
EphemeralMcpClientConfig, EphemeralMcpConnection, PlatformApiKeyPayload,
generate_access_secret, hash_access_secret, new_prefixed_id,
},
};
@@ -38,32 +40,84 @@ impl AdminService {
.await?)
}
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_name = %payload.name))]
#[instrument(skip(self, payload, audit_context), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_name = %payload.name))]
pub async fn create_agent_platform_api_key(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
payload: PlatformApiKeyPayload,
mut payload: PlatformApiKeyPayload,
audit_context: Option<&AdminAuditContext>,
) -> Result<CreatedPlatformApiKeyResponse, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
self.registry
let workspace = match self.get_workspace(workspace_id).await {
Ok(workspace) => workspace,
Err(error) => {
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.platform_api_key.create_failed",
target_kind: crank_core::AuditTargetKind::PlatformApiKey,
workspace_id,
target_id: "pending",
credential_type: "platform_api_key",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
};
let agent_result = match self
.registry
.get_agent_summary(workspace_id, agent_id)
.await?
.ok_or_else(|| {
.await
{
Ok(agent) => agent.ok_or_else(|| {
ApiError::not_found_with_context(
format!("agent {} was not found", agent_id.as_str()),
json!({ "agent_id": agent_id.as_str() }),
)
})?;
}),
Err(error) => Err(ApiError::from(error)),
};
let agent = match agent_result {
Ok(agent) => agent,
Err(error) => {
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.platform_api_key.create_failed",
target_kind: crank_core::AuditTargetKind::PlatformApiKey,
workspace_id,
target_id: "pending",
credential_type: "platform_api_key",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
};
validate_platform_api_key_payload(&payload)?;
let expires_at = match payload.expires_at.as_deref() {
Some(value) => Some(
OffsetDateTime::parse(value, &Rfc3339)
.map_err(|_| ApiError::validation("expires_at must be RFC3339 timestamp"))?,
),
None => None,
let expires_at = match validate_platform_api_key_payload(&mut payload) {
Ok(expires_at) => expires_at,
Err(error) => {
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.platform_api_key.create_failed",
target_kind: crank_core::AuditTargetKind::PlatformApiKey,
workspace_id,
target_id: "pending",
credential_type: payload.key_kind.audit_credential_type(),
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
};
let secret = generate_access_secret(payload.key_kind.secret_marker());
let api_key = PlatformApiKeyRecord {
@@ -83,55 +137,213 @@ impl AdminService {
},
};
self.registry
if let Err(error) = self
.registry
.create_platform_api_key(CreatePlatformApiKeyRequest {
api_key: &api_key.api_key,
secret_hash: &hash_access_secret(&secret),
})
.await?;
.await
{
let error = ApiError::from(error);
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.platform_api_key.create_failed",
target_kind: crank_core::AuditTargetKind::PlatformApiKey,
workspace_id,
target_id: api_key.api_key.id.as_str(),
credential_type: api_key.api_key.key_kind.audit_credential_type(),
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.platform_api_key.created",
target_kind: crank_core::AuditTargetKind::PlatformApiKey,
workspace_id,
target_id: api_key.api_key.id.as_str(),
credential_type: api_key.api_key.key_kind.audit_credential_type(),
outcome: "success",
reason: "credential_created",
},
)
.await;
Ok(CreatedPlatformApiKeyResponse { api_key, secret })
let connection = (api_key.api_key.key_kind == PlatformApiKeyKind::McpClient).then(|| {
let endpoint = self.public_agent_mcp_endpoint(&workspace.workspace.slug, &agent.slug);
EphemeralMcpConnection {
endpoint: endpoint.clone(),
clients: ephemeral_client_configs(&endpoint, &secret),
secret_display: "once",
}
});
Ok(CreatedPlatformApiKeyResponse {
api_key,
secret,
connection,
})
}
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_id = %key_id.as_str()))]
#[instrument(skip(self, audit_context), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_id = %key_id.as_str()))]
pub async fn revoke_agent_platform_api_key(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
key_id: &PlatformApiKeyId,
audit_context: Option<&AdminAuditContext>,
) -> Result<(), ApiError> {
self.registry
if let Err(error) = self
.registry
.revoke_platform_api_key_for_agent(
workspace_id,
agent_id,
key_id,
&OffsetDateTime::now_utc(),
)
.await?;
.await
{
let error = ApiError::from(error);
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.platform_api_key.revoke_failed",
target_kind: crank_core::AuditTargetKind::PlatformApiKey,
workspace_id,
target_id: key_id.as_str(),
credential_type: "platform_api_key",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.platform_api_key.revoked",
target_kind: crank_core::AuditTargetKind::PlatformApiKey,
workspace_id,
target_id: key_id.as_str(),
credential_type: "platform_api_key",
outcome: "success",
reason: "credential_revoked",
},
)
.await;
Ok(())
}
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_id = %key_id.as_str()))]
#[instrument(skip(self, audit_context), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), key_id = %key_id.as_str()))]
pub async fn delete_agent_platform_api_key(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
key_id: &PlatformApiKeyId,
audit_context: Option<&AdminAuditContext>,
) -> Result<(), ApiError> {
self.registry
if let Err(error) = self
.registry
.delete_platform_api_key_for_agent(workspace_id, agent_id, key_id)
.await?;
.await
{
let error = ApiError::from(error);
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.platform_api_key.delete_failed",
target_kind: crank_core::AuditTargetKind::PlatformApiKey,
workspace_id,
target_id: key_id.as_str(),
credential_type: "platform_api_key",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.platform_api_key.deleted",
target_kind: crank_core::AuditTargetKind::PlatformApiKey,
workspace_id,
target_id: key_id.as_str(),
credential_type: "platform_api_key",
outcome: "success",
reason: "credential_deleted",
},
)
.await;
Ok(())
}
}
fn validate_platform_api_key_payload(payload: &PlatformApiKeyPayload) -> Result<(), ApiError> {
fn ephemeral_client_configs(endpoint: &str, secret: &str) -> Vec<EphemeralMcpClientConfig> {
["claude_desktop", "cursor", "vscode"]
.into_iter()
.map(|client| EphemeralMcpClientConfig {
client: client.to_owned(),
config: json!({
"transport": "streamable_http",
"url": endpoint,
"headers": {"Authorization": format!("Bearer {secret}")}
}),
})
.collect()
}
trait PlatformApiKeyKindAuditExt {
fn audit_credential_type(self) -> &'static str;
}
impl PlatformApiKeyKindAuditExt for PlatformApiKeyKind {
fn audit_credential_type(self) -> &'static str {
match self {
PlatformApiKeyKind::McpClient => "mcp_client_key",
PlatformApiKeyKind::Approval => "approval_key",
}
}
}
fn validate_platform_api_key_payload(
payload: &mut PlatformApiKeyPayload,
) -> Result<Option<OffsetDateTime>, ApiError> {
const MAX_KEY_NAME_CHARS: usize = 128;
const MAX_SCOPES: usize = 8;
payload.name = payload.name.trim().to_owned();
if payload.name.trim().is_empty() {
return Err(ApiError::validation("key name is required"));
}
if payload.name.chars().count() > MAX_KEY_NAME_CHARS {
return Err(ApiError::validation(
"key name must be at most 128 characters",
));
}
if payload.scopes.is_empty() {
return Err(ApiError::validation("at least one key scope is required"));
}
if payload.scopes.len() > MAX_SCOPES {
return Err(ApiError::validation("too many key scopes"));
}
if payload
.scopes
.iter()
.enumerate()
.any(|(index, scope)| payload.scopes[..index].contains(scope))
{
return Err(ApiError::validation(
"key scopes must not contain duplicates",
));
}
let valid = payload.scopes.iter().all(|scope| match payload.key_kind {
PlatformApiKeyKind::McpClient => matches!(
@@ -156,6 +368,66 @@ fn validate_platform_api_key_payload(payload: &PlatformApiKeyPayload) -> Result<
"approval key can contain at most 20 allowed origins",
));
}
if payload.key_kind == PlatformApiKeyKind::Approval {
let mut normalized_origins = Vec::with_capacity(payload.allowed_origins.len());
for origin in &payload.allowed_origins {
normalized_origins.push(validate_approval_origin(origin)?);
}
if normalized_origins
.iter()
.enumerate()
.any(|(index, origin)| normalized_origins[..index].contains(origin))
{
return Err(ApiError::validation(
"allowed origins must not contain duplicates",
));
}
payload.allowed_origins = normalized_origins;
} else if !payload.allowed_origins.is_empty() {
return Err(ApiError::validation(
"allowed origins are only supported for approval keys",
));
}
Ok(())
let expires_at = payload
.expires_at
.as_deref()
.map(|value| {
OffsetDateTime::parse(value, &Rfc3339)
.map_err(|_| ApiError::validation("expires_at must be RFC3339 timestamp"))
})
.transpose()?;
if expires_at.is_some_and(|value| value <= OffsetDateTime::now_utc()) {
return Err(ApiError::validation("expires_at must be in the future"));
}
Ok(expires_at)
}
fn validate_approval_origin(origin: &str) -> Result<String, ApiError> {
const MAX_ORIGIN_LEN: usize = 2048;
if origin.is_empty() || origin.len() > MAX_ORIGIN_LEN {
return Err(ApiError::validation("allowed origin is invalid"));
}
if origin
.bytes()
.any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace())
{
return Err(ApiError::validation("allowed origin is invalid"));
}
let parsed =
Url::parse(origin).map_err(|_| ApiError::validation("allowed origin is invalid"))?;
if !matches!(parsed.scheme(), "http" | "https")
|| parsed.host_str().is_none()
|| !parsed.username().is_empty()
|| parsed.password().is_some()
|| parsed.path() != "/"
|| parsed.query().is_some()
|| parsed.fragment().is_some()
{
return Err(ApiError::validation("allowed origin is invalid"));
}
Ok(parsed.origin().ascii_serialization())
}
+238 -40
View File
@@ -1,20 +1,101 @@
use crank_core::{LoginOutcome, MembershipRole, UserSessionId, WorkspaceId};
use crank_core::{
LoginOutcome, MembershipRole, User, UserId, UserSessionId, UserStatus, WorkspaceId,
};
use crank_registry::{AdminSecurityAuditRequest, ConsumeAdminBootstrapContractRequest};
use serde_json::json;
use time::OffsetDateTime;
use tracing::instrument;
use crate::{
auth::{
AuthenticatedSession, SessionCookie, create_session_cookie, hash_password,
hash_session_secret, verify_password,
AuthenticatedSession, SessionCookie, create_csrf_token_value, create_session_cookie,
hash_csrf_token, hash_password, hash_session_secret, verify_password,
},
error::ApiError,
service::{
AdminService, ChangePasswordPayload, LoginPayload, SessionResponse, UpdateProfilePayload,
map_identity_error, validate_profile_display_name, validate_profile_email,
AdminService, BootstrapStatusResponse, ChangePasswordPayload, CompleteBootstrapPayload,
LoginPayload, SessionResponse, UpdateProfilePayload, hash_access_secret,
map_identity_error, new_prefixed_id, validate_profile_display_name, validate_profile_email,
},
};
impl AdminService {
pub async fn bootstrap_status(&self) -> Result<BootstrapStatusResponse, ApiError> {
Ok(BootstrapStatusResponse {
bootstrap_required: !self.registry.has_password_admin().await?,
})
}
pub async fn complete_bootstrap(
&self,
payload: CompleteBootstrapPayload,
) -> Result<(SessionCookie, SessionResponse), ApiError> {
if payload.token.len() < 32 || payload.token.len() > 256 {
self.record_admin_security_audit(None, "bootstrap_rejected", "rejected", "bootstrap")
.await;
return Err(Self::constant_bootstrap_error());
}
if payload.password.len() < 12 || payload.password.len() > 256 {
self.record_admin_security_audit(None, "bootstrap_rejected", "rejected", "bootstrap")
.await;
return Err(Self::constant_bootstrap_error());
}
let now = OffsetDateTime::now_utc();
let bootstrap_token_hash = hash_access_secret(&format!("bootstrap:{}", payload.token));
if !self
.registry
.admin_bootstrap_contract_is_consumable(&bootstrap_token_hash, &now)
.await?
{
self.record_admin_security_audit(None, "bootstrap_rejected", "rejected", "bootstrap")
.await;
return Err(Self::constant_bootstrap_error());
}
let password_hash = hash_password(&payload.password, &self.auth_settings.password_pepper)?;
let user_id = self
.registry
.consume_admin_bootstrap_contract(ConsumeAdminBootstrapContractRequest {
token_hash: &bootstrap_token_hash,
password_hash: &password_hash,
now: &now,
})
.await
.map_err(|_| Self::constant_bootstrap_error());
let user_id = match user_id {
Ok(user_id) => user_id,
Err(error) => {
self.record_admin_security_audit(
None,
"bootstrap_rejected",
"rejected",
"bootstrap",
)
.await;
return Err(error);
}
};
self.record_admin_security_audit(
Some(&user_id),
"bootstrap_completed",
"success",
"bootstrap",
)
.await;
let user = self
.registry
.get_auth_user_by_id(&user_id)
.await?
.ok_or_else(|| ApiError::internal("bootstrap user was not found"))?
.user;
self.create_session_for_user(user).await
}
fn constant_bootstrap_error() -> ApiError {
ApiError::unauthorized("bootstrap request is invalid or expired")
}
pub async fn bootstrap_admin_user(&self) -> Result<(), ApiError> {
let password_hash = hash_password(
&self.auth_settings.bootstrap_admin.password,
@@ -83,36 +164,99 @@ impl AdminService {
Ok(())
}
pub async fn verify_session_csrf(
&self,
session_id: &UserSessionId,
csrf_hash: &str,
) -> Result<bool, ApiError> {
Ok(self
.registry
.verify_session_csrf(session_id, csrf_hash)
.await?)
}
pub async fn login(
&self,
payload: LoginPayload,
) -> Result<(SessionCookie, SessionResponse), ApiError> {
let authenticated = self.authenticate_login(&payload).await?;
self.login_with_client_bucket(payload, "anonymous").await
}
pub async fn login_with_client_bucket(
&self,
payload: LoginPayload,
client_bucket: &str,
) -> Result<(SessionCookie, SessionResponse), ApiError> {
let scope_hash = hash_access_secret(&format!(
"login:{}:{}",
client_bucket,
payload.email.trim().to_ascii_lowercase()
));
if let Some(locked_until) = self
.registry
.login_backoff_locked_until(&scope_hash)
.await?
{
let retry_after_ms = (locked_until - OffsetDateTime::now_utc())
.whole_milliseconds()
.clamp(1, 300_000);
return Err(ApiError::rate_limited_with_context(
"login temporarily unavailable",
json!({
"retry_after_ms": retry_after_ms,
"error_code": "login_throttled",
"recovery": "retry_after_delay"
}),
));
}
let authenticated = match self.authenticate_login(&payload).await {
Ok(authenticated) => authenticated,
Err(error) => {
let _ = self
.registry
.record_login_failure(&scope_hash, &OffsetDateTime::now_utc())
.await;
self.record_admin_security_audit(None, "login_rejected", "rejected", "login")
.await;
return Err(error);
}
};
self.registry.reset_login_backoff(&scope_hash).await?;
self.record_admin_security_audit(
Some(&authenticated.user.id),
"login_succeeded",
"success",
"login",
)
.await;
self.create_session_for_user(authenticated.user).await
}
async fn create_session_for_user(
&self,
user: User,
) -> Result<(SessionCookie, SessionResponse), ApiError> {
let session_cookie = create_session_cookie(&self.auth_settings)?;
let csrf_token = create_csrf_token_value();
let secret_hash = hash_session_secret(
&session_cookie.session_id,
&session_cookie.value,
&self.auth_settings.session_secret,
);
let memberships = self
.registry
.list_workspaces_for_user(&authenticated.user.id)
.await?;
let csrf_hash = hash_csrf_token(
&session_cookie.session_id,
&csrf_token,
&self.auth_settings.session_secret,
);
let memberships = self.registry.list_workspaces_for_user(&user.id).await?;
let default_workspace_id = memberships
.iter()
.find(|membership| membership.workspace.id.as_str() == "ws_default")
.map(|membership| membership.workspace.id.as_str().to_owned());
let current_workspace_id = default_workspace_id.or_else(|| {
authenticated
.current_workspace_id
.as_ref()
.map(|workspace_id| workspace_id.as_str().to_owned())
.or_else(|| {
memberships
.first()
.map(|membership| membership.workspace.id.as_str().to_owned())
})
memberships
.first()
.map(|membership| membership.workspace.id.as_str().to_owned())
});
let current_workspace_ref = current_workspace_id
.as_ref()
@@ -120,9 +264,10 @@ impl AdminService {
self.registry
.create_user_session(
&session_cookie.session_id,
&authenticated.user.id,
&user.id,
current_workspace_ref.as_ref(),
&secret_hash,
Some(&csrf_hash),
&session_cookie.expires_at,
)
.await?;
@@ -130,9 +275,10 @@ impl AdminService {
Ok((
session_cookie,
SessionResponse {
user: authenticated.user,
user,
memberships,
current_workspace_id,
csrf_token,
},
))
}
@@ -158,7 +304,19 @@ impl AdminService {
.registry
.get_auth_user_by_email(&payload.email)
.await?
.ok_or_else(|| ApiError::unauthorized("invalid email or password"))?;
.ok_or_else(|| {
let _ = hash_password(&payload.password, &self.auth_settings.password_pepper);
ApiError::unauthorized("invalid email or password")
})?;
if user.user.status != UserStatus::Active {
let _ = verify_password(
&payload.password,
&self.auth_settings.password_pepper,
&user.password_hash,
);
return Err(ApiError::unauthorized("invalid email or password"));
}
if !verify_password(
&payload.password,
@@ -181,6 +339,8 @@ impl AdminService {
_session_value: &str,
) -> Result<(), ApiError> {
self.registry.revoke_user_session(session_id).await?;
self.record_admin_security_audit(None, "logout", "success", "session")
.await;
Ok(())
}
@@ -190,21 +350,23 @@ impl AdminService {
session_id: &UserSessionId,
session_value: &str,
) -> Result<Option<SessionResponse>, ApiError> {
Ok(self
.get_session(session_id, session_value)
.await?
.map(|session| SessionResponse {
user: session.user,
memberships: session.memberships,
current_workspace_id: session
.current_workspace_id
.map(|id| id.as_str().to_owned()),
}))
let Some(session) = self.get_session(session_id, session_value).await? else {
return Ok(None);
};
Ok(Some(SessionResponse {
user: session.user,
memberships: session.memberships,
current_workspace_id: session
.current_workspace_id
.map(|id| id.as_str().to_owned()),
csrf_token: String::new(),
}))
}
pub async fn update_profile(
&self,
user_id: &crank_core::UserId,
session_id: &UserSessionId,
current_workspace_id: Option<&WorkspaceId>,
payload: UpdateProfilePayload,
) -> Result<SessionResponse, ApiError> {
@@ -221,18 +383,19 @@ impl AdminService {
user,
memberships,
current_workspace_id: current_workspace_id.map(|id| id.as_str().to_owned()),
csrf_token: self.rotate_session_csrf_token(session_id).await?,
})
}
pub async fn change_password(
&self,
user_id: &crank_core::UserId,
current_session_id: &UserSessionId,
_current_session_id: &UserSessionId,
payload: ChangePasswordPayload,
) -> Result<(), ApiError> {
if payload.new_password.len() < 12 {
if payload.new_password.len() < 12 || payload.new_password.len() > 256 {
return Err(ApiError::validation(
"new password must be at least 12 characters long",
"new password must be between 12 and 256 characters long",
));
}
@@ -258,13 +421,48 @@ impl AdminService {
let password_hash =
hash_password(&payload.new_password, &self.auth_settings.password_pepper)?;
self.registry
.update_user_password_and_revoke_other_sessions(
user_id,
current_session_id,
&password_hash,
)
.update_user_password_and_revoke_all_sessions(user_id, &password_hash)
.await?;
self.record_admin_security_audit(Some(user_id), "password_rotated", "success", "password")
.await;
Ok(())
}
async fn record_admin_security_audit(
&self,
actor_user_id: Option<&UserId>,
action: &'static str,
outcome: &'static str,
source: &'static str,
) {
let (request_id, trace_id) = crank_observability::current_request_correlation();
let audit_id = new_prefixed_id("audit");
let _ = self
.registry
.record_admin_security_audit(AdminSecurityAuditRequest {
id: &audit_id,
action,
outcome,
actor_user_id,
session_id: None,
request_id: request_id.as_deref(),
trace_id: trace_id.as_deref(),
source,
})
.await;
}
pub async fn rotate_session_csrf_token(
&self,
session_id: &UserSessionId,
) -> Result<String, ApiError> {
let csrf_token = create_csrf_token_value();
let csrf_hash =
hash_csrf_token(session_id, &csrf_token, &self.auth_settings.session_secret);
self.registry
.update_user_session_csrf(session_id, &csrf_hash)
.await?;
Ok(csrf_token)
}
}
+61 -11
View File
@@ -1,9 +1,9 @@
use std::collections::BTreeMap;
use crank_core::{
AgentId, InvocationLevel, InvocationSource, InvocationStatus, MembershipRole, OperationId,
OperationSecurityLevel, PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus,
Protocol, Target, WizardState, WorkspaceId,
AgentId, AgentOperationBinding, InvocationLevel, InvocationSource, InvocationStatus,
MembershipRole, OperationId, OperationSecurityLevel, PlatformApiKeyKind, PlatformApiKeyScope,
PlatformApiKeyStatus, Protocol, Target, WizardState, WorkspaceId,
};
use crank_mapping::{JsonPathRoot, infer_mapping_from_samples};
use crank_mapping::{MappingRule, MappingSet};
@@ -87,8 +87,12 @@ impl AdminService {
async fn cleanup_legacy_demo_assets(&self, workspace_id: &WorkspaceId) -> Result<(), ApiError> {
for slug in ["revops-copilot", "support-triage"] {
if let Some(agent) = self.find_agent_by_slug(workspace_id, slug).await? {
self.delete_agent(workspace_id, &AgentId::new(agent.id.as_str().to_owned()))
.await?;
self.delete_agent(
workspace_id,
&AgentId::new(agent.id.as_str().to_owned()),
None,
)
.await?;
}
}
@@ -129,7 +133,10 @@ impl AdminService {
.await
{
Ok(()) => Ok(()),
Err(RegistryError::OperationHasPublishedAgentBindings { .. }) => {
Err(
RegistryError::OperationHasPublishedAgentBindings { .. }
| RegistryError::OperationDeleteForbidden { .. },
) => {
tracing::warn!(
name: "admin.demo_operation.cleanup_skipped",
operation_id = %operation_id.as_str(),
@@ -168,6 +175,7 @@ impl AdminService {
expires_at: None,
allowed_origins: Vec::new(),
},
None,
)
.await?
.api_key
@@ -175,7 +183,7 @@ impl AdminService {
};
if revoke && key.api_key.status != PlatformApiKeyStatus::Revoked {
self.revoke_agent_platform_api_key(workspace_id, agent_id, &key.api_key.id)
self.revoke_agent_platform_api_key(workspace_id, agent_id, &key.api_key.id, None)
.await?;
}
@@ -210,8 +218,17 @@ impl AdminService {
return Ok(());
}
self.publish_operation(workspace_id, &summary.id, summary.current_draft_version)
.await?;
self.publish_operation(
workspace_id,
&summary.id,
summary.current_draft_version,
&crank_registry::OperationStateExpectation {
current_draft_version: summary.current_draft_version,
status: summary.status,
latest_published_version: summary.latest_published_version,
},
)
.await?;
Ok(())
}
@@ -273,10 +290,15 @@ impl AdminService {
publish: bool,
) -> Result<(), ApiError> {
let summary = self.get_agent(workspace_id, agent_id).await?;
self.save_agent_bindings(workspace_id, agent_id, bindings.into())
let current = self
.get_agent_version(workspace_id, agent_id, summary.current_draft_version)
.await?;
if !demo_agent_bindings_match(&current.bindings, &bindings) {
self.save_agent_bindings(workspace_id, agent_id, bindings.into(), None)
.await?;
}
if publish && summary.latest_published_version.is_none() {
self.publish_agent(workspace_id, agent_id, summary.current_draft_version)
self.publish_agent(workspace_id, agent_id, summary.current_draft_version, None)
.await?;
}
@@ -294,11 +316,16 @@ impl AdminService {
.list_invocation_logs(ListInvocationLogsQuery {
workspace_id,
level: None,
status: None,
outcome_group: None,
search_text: None,
source: None,
operation_id: None,
agent_id: None,
created_after: None,
created_before: None,
cursor_created_at: None,
cursor_id: None,
limit: 1,
})
.await?
@@ -329,6 +356,10 @@ impl AdminService {
message: "Frankfurter returned latest exchange rate".to_owned(),
status_code: Some(200),
error_kind: None,
execution_stage: Some(crank_core::ExecutionStage::Runtime),
execution_error_code: None,
retryability: Some(crank_core::Retryability::Never),
outcome_certainty: Some(crank_core::OutcomeCertainty::Certain),
duration_ms: 124,
request_preview: json!({
"path": {},
@@ -343,6 +374,25 @@ impl AdminService {
}
}
fn demo_agent_bindings_match(
existing: &[AgentOperationBinding],
desired: &[AgentBindingPayload],
) -> bool {
if existing.len() != desired.len() {
return false;
}
let mut desired = desired.iter().collect::<Vec<_>>();
desired.sort_by(|left, right| left.tool_name.cmp(&right.tool_name));
existing.iter().zip(desired).all(|(existing, desired)| {
existing.operation_id.as_str() == desired.operation_id
&& existing.operation_version == desired.operation_version
&& existing.tool_name == desired.tool_name
&& existing.tool_title == desired.tool_title
&& existing.tool_description_override == desired.tool_description_override
&& existing.enabled == desired.enabled
})
}
fn demo_currency_agent_payload() -> AgentPayload {
AgentPayload {
slug: "currency-rates".to_owned(),
+497 -45
View File
@@ -1,16 +1,26 @@
use crank_core::{ConfigExport, WorkspaceId};
use crank_registry::RegistryOperation;
use crank_core::{OperationStatus, Target, WorkspaceId};
use crank_registry::OperationStateExpectation;
use serde::de::{self, Deserialize, Deserializer, MapAccess, SeqAccess, Visitor};
use serde_yaml::Value as YamlValue;
use std::fmt;
use tracing::{info, instrument};
use crate::{
error::ApiError,
import_guidance::import_guidance_warnings,
service::{
AdminService, ExportQuery, ImportMode, ImportQuery, ImportResponse, NewVersionPayload,
OperationPayload, YamlOperationDocument,
AdminService, ExportQuery, ImportMode, ImportQuery, ImportResponse,
LegacyYamlOperationDocument, NewVersionPayload, OperationPayload, PortableOperation,
YamlOperationDocument,
},
};
const MAX_YAML_BYTES: usize = 256 * 1024;
const MAX_YAML_LINE_OR_SCALAR_BYTES: usize = 64 * 1024;
const MAX_YAML_DEPTH: usize = 64;
const MAX_YAML_NODES: usize = 20_000;
const MAX_YAML_COLLECTION_ITEMS: usize = 4_096;
impl AdminService {
#[instrument(skip(self), fields(operation_id = %operation_id.as_str(), version = query.version.unwrap_or_default(), mode = ?query.mode))]
pub async fn export_operation(
@@ -30,19 +40,30 @@ impl AdminService {
let record = self
.get_operation_version(workspace_id, operation_id, version)
.await?;
let document = YamlOperationDocument {
format_version: "1".to_owned(),
kind: "operation".to_owned(),
operation: RegistryOperation {
config_export: Some(ConfigExport {
format_version: "1".to_owned(),
export_mode: query.mode,
let aggregate = self.get_operation(workspace_id, operation_id).await?;
let exportable = record.status == OperationStatus::Published
|| (aggregate.status != OperationStatus::Archived
&& record.status == OperationStatus::Draft
&& aggregate.current_draft_version == version);
if !exportable {
return Err(ApiError::conflict_with_context(
"only the active current Draft or a Published Version can be exported",
serde_json::json!({
"error_code": "operation_invalid_transition",
"version": version
}),
..record.snapshot
},
));
}
let operation = PortableOperation::from_registry(&record.snapshot);
reject_credential_material(&operation)?;
let document = YamlOperationDocument {
format_version: "2".to_owned(),
kind: "operation".to_owned(),
operation,
};
serde_yaml::to_string(&document).map_err(|error| ApiError::internal(error.to_string()))
serde_yaml::to_string(&document)
.map_err(|_| ApiError::internal("portable yaml serialization failed"))
}
#[instrument(skip(self, yaml_document), fields(mode = ?query.mode))]
@@ -51,29 +72,41 @@ impl AdminService {
workspace_id: &WorkspaceId,
query: ImportQuery,
yaml_document: &str,
if_match: Option<&str>,
) -> Result<ImportResponse, ApiError> {
let document: YamlOperationDocument = serde_yaml::from_str(yaml_document)
.map_err(|error| ApiError::validation(error.to_string()))?;
if document.kind != "operation" {
return Err(ApiError::validation("yaml kind must be operation"));
}
reject_yaml_anchors_and_aliases(yaml_document)?;
let parsed = parse_bounded_yaml(yaml_document)?;
let format_version = parsed
.as_mapping()
.and_then(|mapping| mapping.get(YamlValue::String("format_version".to_owned())))
.and_then(YamlValue::as_str)
.ok_or_else(yaml_invalid)?;
let payload = OperationPayload {
name: document.operation.name.clone(),
display_name: document.operation.display_name.clone(),
category: document.operation.category.clone(),
protocol: document.operation.protocol,
security_level: document.operation.security_level,
target: document.operation.target.clone(),
input_schema: document.operation.input_schema.clone(),
output_schema: document.operation.output_schema.clone(),
input_mapping: document.operation.input_mapping.clone(),
output_mapping: document.operation.output_mapping.clone(),
execution_config: document.operation.execution_config.clone(),
tool_description: document.operation.tool_description.clone(),
wizard_state: document.operation.wizard_state.clone(),
let (operation, warnings) = match format_version {
"2" => {
let document: YamlOperationDocument =
serde_yaml::from_value(parsed).map_err(|_| yaml_invalid())?;
if document.kind != "operation" || document.format_version != "2" {
return Err(yaml_unsupported());
}
(document.operation, Vec::new())
}
"1" => {
let document: LegacyYamlOperationDocument =
serde_yaml::from_value(parsed).map_err(|_| yaml_invalid())?;
if document.kind != "operation" || document.format_version != "1" {
return Err(yaml_unsupported());
}
let warnings = import_guidance_warnings(&document.operation);
(
PortableOperation::from_registry(&document.operation),
warnings,
)
}
_ => return Err(yaml_unsupported()),
};
let warnings = import_guidance_warnings(&document.operation);
reject_credential_material(&operation)?;
let payload = operation.to_payload();
match query.mode {
ImportMode::Create => {
@@ -88,9 +121,66 @@ impl AdminService {
}
ImportMode::Upsert => {
if let Some(existing) = self
.find_operation_by_name(workspace_id, &document.operation.name)
.find_operation_by_name(workspace_id, &operation.name)
.await?
{
if existing.status == OperationStatus::Archived {
return Err(crank_registry::RegistryError::OperationArchived {
operation_id: existing.id.as_str().to_owned(),
}
.into());
}
let current = self
.get_operation_version(
workspace_id,
&existing.id,
existing.current_draft_version,
)
.await?;
if operation.matches_registry(&current.snapshot) {
let expected_state = OperationStateExpectation {
current_draft_version: existing.current_draft_version,
status: existing.status,
latest_published_version: existing.latest_published_version,
};
self.registry
.verify_operation_state(workspace_id, &existing.id, &expected_state)
.await?;
return Ok(ImportResponse {
operation_id: existing.id.as_str().to_owned(),
workspace_id: workspace_id.as_str().to_owned(),
version: existing.current_draft_version,
import_mode: ImportMode::Upsert,
warnings,
});
}
let current_detail = self.get_operation(workspace_id, &existing.id).await?;
let expected_state = OperationStateExpectation {
current_draft_version: current_detail.current_draft_version,
status: current_detail.status,
latest_published_version: current_detail.latest_published_version,
};
let expected_etag = Self::operation_state_etag(&current_detail);
let provided_etag = if_match.ok_or_else(|| {
ApiError::precondition_required_with_context(
"If-Match is required when YAML upsert changes an existing Operation",
serde_json::json!({
"error_code": "operation_precondition_required",
"current_version": existing.current_draft_version,
"recovery": "reload"
}),
)
})?;
if provided_etag != expected_etag {
return Err(ApiError::conflict_with_context(
"Operation state changed; reload before retrying YAML upsert",
serde_json::json!({
"error_code": "operation_stale_version",
"current_version": existing.current_draft_version,
"recovery": "reload"
}),
));
}
let created = self
.create_version(
workspace_id,
@@ -99,9 +189,9 @@ impl AdminService {
operation: payload,
change_note: Some("yaml upsert".to_owned()),
},
&expected_state,
)
.await?;
let response = ImportResponse {
operation_id: created.operation_id,
workspace_id: created.workspace_id,
@@ -118,22 +208,384 @@ impl AdminService {
Ok(response)
} else {
let created = self.create_operation(workspace_id, payload).await?;
let response = ImportResponse {
Ok(ImportResponse {
operation_id: created.operation_id,
workspace_id: created.workspace_id,
version: created.version,
import_mode: ImportMode::Upsert,
warnings,
};
info!(
name: "admin.operation.imported",
operation_id = %response.operation_id,
version = response.version,
"operation imported by upsert"
);
Ok(response)
})
}
}
}
}
}
impl PortableOperation {
fn from_registry(operation: &crank_registry::RegistryOperation) -> Self {
Self {
name: operation.name.clone(),
display_name: operation.display_name.clone(),
category: operation.category.clone(),
protocol: operation.protocol,
security_level: operation.security_level,
target: operation.target.clone(),
input_schema: operation.input_schema.clone(),
output_schema: operation.output_schema.clone(),
input_mapping: operation.input_mapping.clone(),
output_mapping: operation.output_mapping.clone(),
execution_config: operation.execution_config.clone(),
tool_description: operation.tool_description.clone(),
}
}
fn to_payload(&self) -> OperationPayload {
OperationPayload {
name: self.name.clone(),
display_name: self.display_name.clone(),
category: self.category.clone(),
protocol: self.protocol,
security_level: self.security_level,
target: self.target.clone(),
input_schema: self.input_schema.clone(),
output_schema: self.output_schema.clone(),
input_mapping: self.input_mapping.clone(),
output_mapping: self.output_mapping.clone(),
execution_config: self.execution_config.clone(),
tool_description: self.tool_description.clone(),
wizard_state: None,
}
}
fn matches_registry(&self, operation: &crank_registry::RegistryOperation) -> bool {
self.name == operation.name
&& self.display_name == operation.display_name
&& self.category == operation.category
&& self.protocol == operation.protocol
&& self.security_level == operation.security_level
&& self.target == operation.target
&& self.input_schema == operation.input_schema
&& self.output_schema == operation.output_schema
&& self.input_mapping == operation.input_mapping
&& self.output_mapping == operation.output_mapping
&& self.execution_config == operation.execution_config
&& self.tool_description == operation.tool_description
}
}
fn parse_bounded_yaml(document: &str) -> Result<YamlValue, ApiError> {
if document.is_empty() {
return Err(yaml_invalid());
}
if document.len() > MAX_YAML_BYTES {
return Err(ApiError::payload_too_large_with_context(
"operation yaml exceeds the 256 KiB limit",
serde_json::json!({ "error_code": "operation_yaml_too_large" }),
));
}
if document
.lines()
.any(|line| line.len() > MAX_YAML_LINE_OR_SCALAR_BYTES)
{
return Err(yaml_unsupported());
}
let separators = document.lines().filter(|line| line.trim() == "---").count();
if separators > 1 || document.lines().any(|line| line.trim() == "...") {
return Err(yaml_unsupported());
}
DuplicateChecked::deserialize(serde_yaml::Deserializer::from_str(document))
.map_err(|_| yaml_invalid())?;
let parsed: YamlValue = serde_yaml::from_str(document).map_err(|_| yaml_invalid())?;
let mut nodes = 0;
validate_yaml_value(&parsed, 0, &mut nodes)?;
Ok(parsed)
}
/// A bounded preflight pass which rejects duplicate mapping keys before
/// `serde_yaml::Value` can collapse them. Values are deliberately discarded:
/// the authoritative typed parse follows only after this structural check.
struct DuplicateChecked;
impl<'de> Deserialize<'de> for DuplicateChecked {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_any(DuplicateCheckedVisitor)
}
}
struct DuplicateCheckedVisitor;
impl<'de> Visitor<'de> for DuplicateCheckedVisitor {
type Value = DuplicateChecked;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("a YAML value without duplicate mapping keys")
}
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: MapAccess<'de>,
{
let mut keys = Vec::<YamlValue>::new();
while let Some(key) = map.next_key::<YamlValue>()? {
if keys.iter().any(|existing| existing == &key) {
return Err(de::Error::custom("duplicate mapping key"));
}
keys.push(key);
map.next_value::<DuplicateChecked>()?;
}
Ok(DuplicateChecked)
}
fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
where
A: SeqAccess<'de>,
{
while sequence.next_element::<DuplicateChecked>()?.is_some() {}
Ok(DuplicateChecked)
}
fn visit_bool<E>(self, _: bool) -> Result<Self::Value, E> {
Ok(DuplicateChecked)
}
fn visit_i64<E>(self, _: i64) -> Result<Self::Value, E> {
Ok(DuplicateChecked)
}
fn visit_u64<E>(self, _: u64) -> Result<Self::Value, E> {
Ok(DuplicateChecked)
}
fn visit_f64<E>(self, _: f64) -> Result<Self::Value, E> {
Ok(DuplicateChecked)
}
fn visit_str<E>(self, _: &str) -> Result<Self::Value, E> {
Ok(DuplicateChecked)
}
fn visit_string<E>(self, _: String) -> Result<Self::Value, E> {
Ok(DuplicateChecked)
}
fn visit_none<E>(self) -> Result<Self::Value, E> {
Ok(DuplicateChecked)
}
fn visit_unit<E>(self) -> Result<Self::Value, E> {
Ok(DuplicateChecked)
}
fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
where
D: Deserializer<'de>,
{
DuplicateChecked::deserialize(deserializer)
}
fn visit_newtype_struct<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
where
D: Deserializer<'de>,
{
DuplicateChecked::deserialize(deserializer)
}
}
fn validate_yaml_value(value: &YamlValue, depth: usize, nodes: &mut usize) -> Result<(), ApiError> {
if depth > MAX_YAML_DEPTH {
return Err(yaml_unsupported());
}
*nodes = nodes.saturating_add(1);
if *nodes > MAX_YAML_NODES {
return Err(yaml_unsupported());
}
match value {
YamlValue::Sequence(values) => {
if values.len() > MAX_YAML_COLLECTION_ITEMS {
return Err(yaml_unsupported());
}
for value in values {
validate_yaml_value(value, depth + 1, nodes)?;
}
}
YamlValue::Mapping(values) => {
if values.len() > MAX_YAML_COLLECTION_ITEMS {
return Err(yaml_unsupported());
}
for (key, value) in values {
validate_yaml_value(key, depth + 1, nodes)?;
validate_yaml_value(value, depth + 1, nodes)?;
}
}
YamlValue::String(value) if value.len() > MAX_YAML_LINE_OR_SCALAR_BYTES => {
return Err(yaml_unsupported());
}
YamlValue::Tagged(_) => return Err(yaml_unsupported()),
_ => {}
}
Ok(())
}
fn reject_credential_material(operation: &PortableOperation) -> Result<(), ApiError> {
let sensitive_name = |name: &str| {
let normalized = name.to_ascii_lowercase();
let segments = normalized.split(['-', '_', '.']).collect::<Vec<_>>();
matches!(
normalized.as_str(),
"authorization" | "proxy-authorization" | "cookie" | "set-cookie"
) || segments
.iter()
.any(|segment| matches!(*segment, "auth" | "token" | "secret" | "cookie"))
|| segments.windows(2).any(|parts| parts == ["api", "key"])
};
let sensitive_value = |value: &str| {
let normalized = value.trim().to_ascii_lowercase();
normalized.starts_with("bearer ")
|| normalized.starts_with("basic ")
|| normalized.starts_with("digest ")
|| normalized.contains("api_key=")
|| normalized.contains("access_token=")
};
let target_headers = match &operation.target {
Target::Rest(target) => &target.static_headers,
};
if target_headers
.iter()
.any(|(name, value)| sensitive_name(name) || sensitive_value(value))
|| operation
.execution_config
.headers
.iter()
.any(|(name, value)| sensitive_name(name) || sensitive_value(value))
{
return Err(ApiError::unprocessable_with_context(
"portable yaml contains credential-bearing headers",
serde_json::json!({ "error_code": "operation_yaml_invalid" }),
));
}
Ok(())
}
fn reject_yaml_anchors_and_aliases(document: &str) -> Result<(), ApiError> {
let mut single_quote = false;
let mut double_quote = false;
let mut escaped = false;
let mut comment = false;
let mut previous = '\n';
let chars = document.chars().collect::<Vec<_>>();
for (index, character) in chars.iter().copied().enumerate() {
if character == '\n' {
comment = false;
previous = character;
continue;
}
if comment {
previous = character;
continue;
}
if double_quote {
if escaped {
escaped = false;
} else if character == '\\' {
escaped = true;
} else if character == '"' {
double_quote = false;
}
previous = character;
continue;
}
if single_quote {
if character == '\'' {
single_quote = false;
}
previous = character;
continue;
}
match character {
'#' => comment = true,
'"' => double_quote = true,
'\'' => single_quote = true,
'&' | '*'
if (previous.is_whitespace() || matches!(previous, ':' | '[' | '{' | ','))
&& chars.get(index + 1).is_some_and(|next| {
next.is_ascii_alphanumeric() || matches!(next, '_' | '-')
}) =>
{
return Err(yaml_unsupported());
}
_ => {}
}
previous = character;
}
Ok(())
}
fn yaml_invalid() -> ApiError {
ApiError::unprocessable_with_context(
"operation yaml is invalid",
serde_json::json!({ "error_code": "operation_yaml_invalid" }),
)
}
fn yaml_unsupported() -> ApiError {
ApiError::unprocessable_with_context(
"operation yaml uses an unsupported construct",
serde_json::json!({ "error_code": "operation_yaml_unsupported" }),
)
}
#[cfg(test)]
mod tests {
use super::parse_bounded_yaml;
use serde_json::Value;
#[test]
fn portable_v2_schema_freezes_the_closed_top_level_contract() {
let schema: Value = serde_json::from_str(include_str!(
"../../../../docs/schemas/operation-export-v2.schema.json"
))
.unwrap();
assert_eq!(schema["properties"]["format_version"]["const"], "2");
assert_eq!(schema["properties"]["kind"]["const"], "operation");
assert_eq!(schema["additionalProperties"], false);
assert_eq!(
schema["$defs"]["portable_operation"]["additionalProperties"],
false
);
assert_eq!(
schema["$defs"]["portable_operation"]["properties"]["security_level"]["enum"],
serde_json::json!(["standard"])
);
for name in [
"rest_target",
"schema",
"mapping_set",
"mapping_rule",
"execution_config",
"tool_description",
] {
assert_eq!(schema["$defs"][name]["additionalProperties"], false);
}
assert_eq!(
schema["$defs"]["portable_operation"]["properties"]["target"]["$ref"],
"#/$defs/rest_target"
);
}
#[test]
fn bounded_yaml_rejects_duplicate_keys_before_typed_parsing() {
let document =
"format_version: '2'\nkind: operation\nkind: attacker_override\noperation: {}\n";
let error = parse_bounded_yaml(document).expect_err("duplicate key must fail closed");
match error {
crate::error::ApiError::Unprocessable { context, .. } => assert_eq!(
context.and_then(|value| value["error_code"].as_str().map(str::to_owned)),
Some("operation_yaml_invalid".to_owned())
),
other => panic!("unexpected error: {other:?}"),
}
}
}
+466 -21
View File
@@ -1,28 +1,52 @@
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use crank_core::{
AgentId, ApprovalRequestId, ApprovalRequestStatus, InvocationLogId, OperationId, UsagePeriod,
WorkspaceId,
};
use crank_registry::{
ApprovalRequestRecord, ExpireApprovalRequest, InvocationLogRecord, ListApprovalRequestsQuery,
ListInvocationLogsQuery, UsageQuery, UsageRollupRecord,
ApprovalRequestRecord, DecideApprovalRequest, ExpireApprovalRequest, InvocationLogRecord,
InvocationRetentionOutcome, ListApprovalRequestsQuery, ListInvocationLogsQuery, UsageQuery,
UsageRollupRecord,
};
use serde_json::json;
use time::OffsetDateTime;
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tracing::instrument;
use crate::{
error::ApiError,
service::{
AdminService, ApprovalsQuery, LogsQuery, UsageOverviewResponse, UsageRequestQuery,
usage_window,
AdminService, ApprovalDecisionPayload, ApprovalsQuery, LogsListResponse, LogsQuery,
UsageOverviewResponse, UsageRequestQuery, usage_window,
},
};
const LOG_LIST_DEFAULT_LIMIT: u32 = 100;
const LOG_LIST_MAX_LIMIT: u32 = 200;
const LOG_CSV_MAX_ROWS: u32 = 1_000;
const MAX_EXPLICIT_USAGE_WINDOW_DAYS: i64 = 90;
#[derive(serde::Serialize, serde::Deserialize)]
struct LogsCursor {
created_at: String,
id: String,
}
fn safe_approval_record(mut record: ApprovalRequestRecord) -> ApprovalRequestRecord {
record.approval.request_payload =
crank_core::sanitize_invocation_preview(&record.approval.request_payload);
record.approval.response_payload = record
.approval
.response_payload
.as_ref()
.map(crank_core::sanitize_invocation_preview);
record
}
impl AdminService {
pub async fn cleanup_invocation_logs_before(
&self,
cutoff: OffsetDateTime,
) -> Result<u64, ApiError> {
) -> Result<InvocationRetentionOutcome, ApiError> {
self.registry
.delete_invocation_logs_before(cutoff)
.await
@@ -34,25 +58,45 @@ impl AdminService {
&self,
workspace_id: &WorkspaceId,
query: LogsQuery,
) -> Result<Vec<InvocationLogRecord>, ApiError> {
) -> Result<LogsListResponse, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
let operation_id = query.operation_id.as_deref().map(OperationId::new);
let agent_id = query.agent_id.as_deref().map(AgentId::new);
let (_, created_after, _) = usage_window(query.period.unwrap_or(UsagePeriod::Last7Days))?;
let (_, created_after, created_before, _) =
resolve_usage_window(query.period.unwrap_or(UsagePeriod::Last7Days), &query)?;
let (cursor_created_at, cursor_id) = decode_logs_cursor(query.cursor.as_deref())?;
let limit = query
.limit
.unwrap_or(LOG_LIST_DEFAULT_LIMIT)
.clamp(1, LOG_LIST_MAX_LIMIT);
self.registry
let mut items = self
.registry
.list_invocation_logs(ListInvocationLogsQuery {
workspace_id,
level: query.level,
status: query.status,
outcome_group: query.outcome_group,
search_text: query.search.as_deref(),
source: query.source,
operation_id: operation_id.as_ref(),
agent_id: agent_id.as_ref(),
created_after: Some(&created_after),
limit: query.limit.unwrap_or(100),
created_before: Some(&created_before),
cursor_created_at: cursor_created_at.as_deref(),
cursor_id: cursor_id.as_ref(),
limit: limit.saturating_add(1),
})
.await
.map_err(ApiError::from)
.await?;
let next_cursor = if items.len() > limit as usize {
items.truncate(limit as usize);
items.last().map(encode_logs_cursor).transpose()?
} else {
None
};
Ok(LogsListResponse { items, next_cursor })
}
#[instrument(skip(self))]
@@ -72,6 +116,69 @@ impl AdminService {
})
}
#[instrument(skip(self))]
pub async fn export_logs_csv(
&self,
workspace_id: &WorkspaceId,
mut query: LogsQuery,
) -> Result<String, ApiError> {
query.limit = Some(
query
.limit
.unwrap_or(LOG_CSV_MAX_ROWS)
.clamp(1, LOG_CSV_MAX_ROWS),
);
query.cursor = None;
let page = self.list_logs(workspace_id, query).await?;
let mut csv = String::from(
"created_at,level,status,source,agent,operation,operation_version,duration_ms,request_id,trace_id,stage,error_code,message,request_preview,response_preview\n",
);
for record in page.items {
let log = record.log;
let row = [
log.created_at
.format(&Rfc3339)
.map_err(|error| ApiError::internal(error.to_string()))?,
serialize_csv_text(log.level)?,
serialize_csv_text(log.status)?,
serialize_csv_text(log.source)?,
record
.agent_display_name
.or(record.agent_slug)
.unwrap_or_default(),
record.operation_display_name,
log.operation_version
.map(|value| value.to_string())
.unwrap_or_default(),
log.duration_ms.to_string(),
log.request_id.unwrap_or_default(),
log.trace_id.unwrap_or_default(),
log.execution_stage
.map(serialize_csv_text)
.transpose()?
.unwrap_or_default(),
log.execution_error_code
.map(serialize_csv_text)
.transpose()?
.unwrap_or_default(),
log.message,
serde_json::to_string(&log.request_preview)
.map_err(|error| ApiError::internal(error.to_string()))?,
serde_json::to_string(&log.response_preview)
.map_err(|error| ApiError::internal(error.to_string()))?,
];
csv.push_str(&row.into_iter().map(csv_cell).collect::<Vec<_>>().join(","));
csv.push('\n');
if csv.len() > 1_048_576 {
return Err(ApiError::unprocessable_with_context(
"logs CSV export exceeded the bounded response size",
json!({ "error_code": "logs_csv_too_large" }),
));
}
}
Ok(csv)
}
#[instrument(skip(self))]
pub async fn list_approvals(
&self,
@@ -92,7 +199,7 @@ impl AdminService {
for record in records {
let record = self.normalize_approval_record(record).await?;
if query.status.is_none() || record.approval.status == query.status.unwrap() {
normalized.push(record);
normalized.push(safe_approval_record(record));
}
}
@@ -117,7 +224,114 @@ impl AdminService {
)
})?;
self.normalize_approval_record(record).await
self.normalize_approval_record(record)
.await
.map(safe_approval_record)
}
#[instrument(skip(self, payload))]
pub async fn approve_approval(
&self,
workspace_id: &WorkspaceId,
approval_id: &ApprovalRequestId,
payload: ApprovalDecisionPayload,
) -> Result<ApprovalRequestRecord, ApiError> {
if !payload.approve.eq_ignore_ascii_case("yes") {
return Err(ApiError::unprocessable_with_context(
"approve must be yes for approval confirmation",
json!({ "error_code": "invalid_decision_payload" }),
));
}
self.decide_admin_approval(
workspace_id,
approval_id,
ApprovalRequestStatus::Approved,
Some(json!({"approve": "yes"})),
payload.note.as_deref(),
)
.await
}
#[instrument(skip(self, payload))]
pub async fn deny_approval(
&self,
workspace_id: &WorkspaceId,
approval_id: &ApprovalRequestId,
payload: ApprovalDecisionPayload,
) -> Result<ApprovalRequestRecord, ApiError> {
if !payload.approve.eq_ignore_ascii_case("no") {
return Err(ApiError::unprocessable_with_context(
"approve must be no for approval denial",
json!({ "error_code": "invalid_decision_payload" }),
));
}
self.decide_admin_approval(
workspace_id,
approval_id,
ApprovalRequestStatus::Denied,
Some(json!({"approve": "no"})),
payload.note.as_deref(),
)
.await
}
async fn decide_admin_approval(
&self,
workspace_id: &WorkspaceId,
approval_id: &ApprovalRequestId,
status: ApprovalRequestStatus,
response_payload: Option<serde_json::Value>,
decision_note: Option<&str>,
) -> Result<ApprovalRequestRecord, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
let current = self
.registry
.get_approval_request(workspace_id, approval_id)
.await?
.ok_or_else(|| {
ApiError::not_found_with_context(
format!("approval request {} was not found", approval_id.as_str()),
json!({ "approval_id": approval_id.as_str() }),
)
})?;
let normalized = self.normalize_approval_record(current).await?;
if normalized.approval.status != ApprovalRequestStatus::Pending {
return Err(ApiError::conflict_with_context(
"approval request is not pending",
json!({
"approval_id": approval_id.as_str(),
"status": normalized.approval.status,
"error_code": "approval_state_conflict"
}),
));
}
let decided = self
.registry
.decide_approval_request(DecideApprovalRequest {
workspace_id,
agent_id: &normalized.approval.agent_id,
approval_id,
operation_id: &normalized.approval.operation_id,
operation_version: normalized.approval.operation_version,
request_payload: &normalized.approval.request_payload,
status,
decided_at: OffsetDateTime::now_utc(),
decided_by_key_id: None,
response_payload,
decision_note,
})
.await?
.ok_or_else(|| {
ApiError::conflict_with_context(
"approval request state changed before decision was recorded",
json!({
"approval_id": approval_id.as_str(),
"error_code": "approval_state_conflict"
}),
)
})?;
Ok(safe_approval_record(decided))
}
async fn normalize_approval_record(
@@ -149,13 +363,14 @@ impl AdminService {
query: UsageRequestQuery,
) -> Result<UsageOverviewResponse, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
let (period, created_after, bucket) =
usage_window(query.period.unwrap_or(UsagePeriod::Last7Days))?;
let (period, created_after, created_before, bucket) =
resolve_usage_request_window(query.period.unwrap_or(UsagePeriod::Last7Days), &query)?;
let usage_query = UsageQuery {
workspace_id,
period,
source: query.source,
created_after: &created_after,
created_before: &created_before,
bucket,
};
@@ -168,16 +383,104 @@ impl AdminService {
.registry
.list_usage_by_operation(usage_query.clone())
.await?;
let agents = self.registry.list_usage_by_agent(usage_query).await?;
let agents = self
.registry
.list_usage_by_agent(usage_query.clone())
.await?;
let outcomes = self.registry.list_usage_outcomes(usage_query).await?;
Ok(UsageOverviewResponse {
summary,
timeline,
operations,
agents,
outcomes,
})
}
#[instrument(skip(self))]
pub async fn export_usage_csv(
&self,
workspace_id: &WorkspaceId,
query: UsageRequestQuery,
) -> Result<String, ApiError> {
let usage = self.get_usage_overview(workspace_id, query).await?;
let mut csv = String::from(
"kind,name,group,error_code,calls_total,calls_error,p50_ms,p95_ms,p99_ms\n",
);
for operation in usage.operations {
csv.push_str(
&[
"operation".to_owned(),
operation.operation_display_name,
String::new(),
String::new(),
operation.calls_total.to_string(),
operation.calls_error.to_string(),
operation.p50_ms.to_string(),
operation.p95_ms.to_string(),
operation.p99_ms.to_string(),
]
.into_iter()
.map(csv_cell)
.collect::<Vec<_>>()
.join(","),
);
csv.push('\n');
}
for agent in usage.agents {
csv.push_str(
&[
"agent".to_owned(),
agent.agent_display_name,
String::new(),
String::new(),
agent.calls_total.to_string(),
agent.calls_error.to_string(),
agent.p50_ms.to_string(),
agent.p95_ms.to_string(),
agent.p99_ms.to_string(),
]
.into_iter()
.map(csv_cell)
.collect::<Vec<_>>()
.join(","),
);
csv.push('\n');
}
for outcome in usage.outcomes {
csv.push_str(
&[
"outcome".to_owned(),
String::new(),
serialize_csv_text(outcome.group)?,
outcome
.execution_error_code
.map(serialize_csv_text)
.transpose()?
.unwrap_or_default(),
outcome.calls_total.to_string(),
String::new(),
outcome.p50_ms.to_string(),
outcome.p95_ms.to_string(),
outcome.p99_ms.to_string(),
]
.into_iter()
.map(csv_cell)
.collect::<Vec<_>>()
.join(","),
);
csv.push('\n');
}
if csv.len() > 1_048_576 {
return Err(ApiError::unprocessable_with_context(
"usage CSV export exceeded the bounded response size",
json!({ "error_code": "usage_csv_too_large" }),
));
}
Ok(csv)
}
#[instrument(skip(self))]
pub async fn get_operation_usage(
&self,
@@ -186,8 +489,8 @@ impl AdminService {
query: UsageRequestQuery,
) -> Result<UsageRollupRecord, ApiError> {
self.get_operation(workspace_id, operation_id).await?;
let (period, created_after, bucket) =
usage_window(query.period.unwrap_or(UsagePeriod::Last7Days))?;
let (period, created_after, created_before, bucket) =
resolve_usage_request_window(query.period.unwrap_or(UsagePeriod::Last7Days), &query)?;
self.registry
.get_usage_for_operation(
@@ -196,6 +499,7 @@ impl AdminService {
period,
source: query.source,
created_after: &created_after,
created_before: &created_before,
bucket,
},
operation_id,
@@ -220,8 +524,8 @@ impl AdminService {
query: UsageRequestQuery,
) -> Result<UsageRollupRecord, ApiError> {
self.get_agent(workspace_id, agent_id).await?;
let (period, created_after, bucket) =
usage_window(query.period.unwrap_or(UsagePeriod::Last7Days))?;
let (period, created_after, created_before, bucket) =
resolve_usage_request_window(query.period.unwrap_or(UsagePeriod::Last7Days), &query)?;
self.registry
.get_usage_for_agent(
@@ -230,6 +534,7 @@ impl AdminService {
period,
source: query.source,
created_after: &created_after,
created_before: &created_before,
bucket,
},
agent_id,
@@ -243,3 +548,143 @@ impl AdminService {
})
}
}
fn resolve_usage_window(
period: UsagePeriod,
query: &LogsQuery,
) -> Result<(UsagePeriod, String, String, crank_registry::UsageBucket), ApiError> {
resolve_explicit_or_period_window(
period,
query.created_after.as_deref(),
query.created_before.as_deref(),
)
}
fn resolve_usage_request_window(
period: UsagePeriod,
query: &UsageRequestQuery,
) -> Result<(UsagePeriod, String, String, crank_registry::UsageBucket), ApiError> {
resolve_explicit_or_period_window(
period,
query.created_after.as_deref(),
query.created_before.as_deref(),
)
}
fn resolve_explicit_or_period_window(
period: UsagePeriod,
created_after: Option<&str>,
created_before: Option<&str>,
) -> Result<(UsagePeriod, String, String, crank_registry::UsageBucket), ApiError> {
match (created_after, created_before) {
(None, None) => usage_window(period),
(Some(_), None) | (None, Some(_)) => Err(invalid_usage_window(
"usage window requires both created_after and created_before",
)),
(Some(after), Some(before)) => {
let after = OffsetDateTime::parse(after, &Rfc3339)
.map_err(|_| invalid_usage_window("created_after must be RFC3339 UTC"))?;
let before = OffsetDateTime::parse(before, &Rfc3339)
.map_err(|_| invalid_usage_window("created_before must be RFC3339 UTC"))?;
if after >= before {
return Err(invalid_usage_window(
"created_after must be before created_before",
));
}
let duration = before - after;
if duration > time::Duration::days(MAX_EXPLICIT_USAGE_WINDOW_DAYS) {
return Err(invalid_usage_window("usage window exceeds maximum range"));
}
let bucket = if duration <= time::Duration::days(1) {
crank_registry::UsageBucket::Hour
} else if duration <= time::Duration::days(14) {
crank_registry::UsageBucket::Day
} else if duration <= time::Duration::days(45) {
crank_registry::UsageBucket::Week
} else {
crank_registry::UsageBucket::Month
};
Ok((
period,
after
.format(&Rfc3339)
.map_err(|error| ApiError::internal(error.to_string()))?,
before
.format(&Rfc3339)
.map_err(|error| ApiError::internal(error.to_string()))?,
bucket,
))
}
}
}
fn invalid_usage_window(message: &'static str) -> ApiError {
ApiError::unprocessable_with_context(message, json!({ "error_code": "usage_window_invalid" }))
}
fn decode_logs_cursor(
cursor: Option<&str>,
) -> Result<(Option<String>, Option<InvocationLogId>), ApiError> {
let Some(cursor) = cursor else {
return Ok((None, None));
};
if cursor.len() > 512 {
return Err(invalid_logs_cursor());
}
let bytes = URL_SAFE_NO_PAD
.decode(cursor)
.map_err(|_| invalid_logs_cursor())?;
let decoded: LogsCursor = serde_json::from_slice(&bytes).map_err(|_| invalid_logs_cursor())?;
OffsetDateTime::parse(&decoded.created_at, &Rfc3339).map_err(|_| invalid_logs_cursor())?;
if decoded.id.is_empty()
|| decoded.id.len() > 128
|| decoded.id.contains(';')
|| decoded.id.contains(',')
{
return Err(invalid_logs_cursor());
}
Ok((
Some(decoded.created_at),
Some(InvocationLogId::new(decoded.id)),
))
}
fn encode_logs_cursor(record: &InvocationLogRecord) -> Result<String, ApiError> {
let cursor = LogsCursor {
created_at: record
.log
.created_at
.format(&Rfc3339)
.map_err(|error| ApiError::internal(error.to_string()))?,
id: record.log.id.as_str().to_owned(),
};
let encoded =
serde_json::to_vec(&cursor).map_err(|error| ApiError::internal(error.to_string()))?;
Ok(URL_SAFE_NO_PAD.encode(encoded))
}
fn invalid_logs_cursor() -> ApiError {
ApiError::unprocessable_with_context(
"logs cursor is invalid",
json!({ "error_code": "logs_cursor_invalid" }),
)
}
fn serialize_csv_text<T: serde::Serialize>(value: T) -> Result<String, ApiError> {
serde_json::to_value(value)
.map_err(|error| ApiError::internal(error.to_string()))?
.as_str()
.map(ToOwned::to_owned)
.ok_or_else(|| ApiError::internal("failed to serialize CSV enum value"))
}
fn csv_cell(mut value: String) -> String {
if value
.chars()
.next()
.is_some_and(|ch| matches!(ch, '=' | '+' | '-' | '@' | '\t' | '\r' | '\n'))
{
value.insert(0, '\'');
}
format!("\"{}\"", value.replace('"', "\"\""))
}
+268
View File
@@ -0,0 +1,268 @@
use crank_core::{
OnboardingProjection, OnboardingStepId, ProductEventId, ProductEventKind, WorkspaceId,
};
use crank_registry::{
OnboardingPresentationMilestone, RecordOnboardingMilestoneRequest, RegistryError,
};
use serde_json::json;
use time::OffsetDateTime;
use crate::{
error::ApiError,
service::{
AdminService, OnboardingEventPayload, OnboardingEventResponse, OnboardingFirstCallEvidence,
OnboardingResponse, OnboardingStepView, ResetOnboardingSelectionResponse, format_timestamp,
new_prefixed_id,
},
};
impl AdminService {
pub async fn get_onboarding(
&self,
workspace_id: &WorkspaceId,
) -> Result<OnboardingResponse, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
let projection = self
.registry
.ensure_onboarding_eligibility(workspace_id, OffsetDateTime::now_utc())
.await?;
self.map_onboarding_response(projection).await
}
pub async fn record_onboarding_event(
&self,
workspace_id: &WorkspaceId,
payload: OnboardingEventPayload,
) -> Result<OnboardingEventResponse, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
if payload.completed_steps.is_some() || payload.event == "completed" {
return Err(onboarding_event_not_allowed());
}
if payload.idempotency_key.is_empty() || payload.idempotency_key.len() > 256 {
return Err(ApiError::validation(
"idempotency_key must contain 1..256 bytes",
));
}
if payload.idempotency_key.starts_with("onboarding:") {
return Err(onboarding_event_not_allowed());
}
let (milestone, event_kind) = match payload.event.as_str() {
"started" => (
OnboardingPresentationMilestone::Started,
ProductEventKind::OnboardingStarted,
),
"resumed" => (
OnboardingPresentationMilestone::Resumed,
ProductEventKind::OnboardingResumed,
),
"dismissed" => (
OnboardingPresentationMilestone::Dismissed,
ProductEventKind::OnboardingDismissed,
),
"abandoned" => (
OnboardingPresentationMilestone::Abandoned,
ProductEventKind::OnboardingAbandoned,
),
_ => return Err(onboarding_event_not_allowed()),
};
// Presentation events must never exist outside the server-owned cohort,
// including a direct API call before the first UI snapshot GET.
let current = self
.registry
.ensure_onboarding_eligibility(workspace_id, OffsetDateTime::now_utc())
.await?;
let outcome = self
.registry
.record_onboarding_milestone(RecordOnboardingMilestoneRequest {
workspace_id,
event_id: &ProductEventId::new(new_prefixed_id("pe")),
milestone,
idempotency_key: &payload.idempotency_key,
expected_revision: payload.expected_revision,
occurred_at: OffsetDateTime::now_utc(),
eligible_since: None,
})
.await;
let outcome = match outcome {
Ok(outcome) => outcome,
Err(RegistryError::OnboardingStaleRevision) => {
let replay = self
.registry
.get_product_event_by_idempotency_key(workspace_id, &payload.idempotency_key)
.await?;
if replay
.as_ref()
.is_some_and(|record| record.event.kind == event_kind)
{
return Ok(OnboardingEventResponse {
accepted: false,
onboarding: self.map_onboarding_response(current).await?,
});
}
if replay.is_some() {
return Err(ApiError::conflict_with_context(
"idempotency key was already used for another onboarding event",
json!({"error_code":"onboarding_idempotency_conflict","recovery":"use_original_event"}),
));
}
return Err(ApiError::conflict_with_context(
"onboarding state changed; reload before retrying",
json!({"error_code":"onboarding_stale_revision","recovery":"reload"}),
));
}
Err(RegistryError::InvalidExecutionRecord {
field: "product_event.idempotency_conflict",
}) => {
return Err(ApiError::conflict_with_context(
"idempotency key was already used for another onboarding event",
json!({"error_code":"onboarding_idempotency_conflict","recovery":"use_original_event"}),
));
}
Err(other) => return Err(other.into()),
};
Ok(OnboardingEventResponse {
accepted: outcome.accepted,
onboarding: self.map_onboarding_response(outcome.projection).await?,
})
}
pub async fn reset_onboarding_selection(
&self,
workspace_id: &WorkspaceId,
expected_revision: i64,
) -> Result<ResetOnboardingSelectionResponse, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
let projection = self
.registry
.reset_onboarding_selection(workspace_id, expected_revision, OffsetDateTime::now_utc())
.await
.map_err(|error| match error {
RegistryError::OnboardingStaleRevision => ApiError::conflict_with_context(
"onboarding state changed; reload before resetting the selection",
json!({"error_code":"onboarding_stale_revision","recovery":"reload"}),
),
other => ApiError::from(other),
})?;
Ok(ResetOnboardingSelectionResponse {
selection_reset: true,
onboarding: self.map_onboarding_response(projection).await?,
})
}
async fn map_onboarding_response(
&self,
projection: OnboardingProjection,
) -> Result<OnboardingResponse, ApiError> {
let endpoint = if let Some(agent_id) = projection.agent_id.as_ref() {
let workspace = self.get_workspace(&projection.workspace_id).await?;
let agent = self
.registry
.get_agent_summary(&projection.workspace_id, agent_id)
.await?
.ok_or_else(|| ApiError::internal("onboarding agent disappeared"))?;
Some(self.public_agent_mcp_endpoint(&workspace.workspace.slug, &agent.slug))
} else {
None
};
let first_call = match (
projection.first_call_log_id.as_ref(),
projection.agent_id.as_ref(),
projection.platform_api_key_id.as_ref(),
projection.operation_id.as_ref(),
projection.operation_version,
projection.first_call_tool_name.as_ref(),
projection.first_call_at,
) {
(
Some(log_id),
Some(agent_id),
Some(key_id),
Some(operation_id),
Some(operation_version),
Some(tool_name),
Some(occurred_at),
) => Some(OnboardingFirstCallEvidence {
log_id: log_id.as_str().to_owned(),
agent_id: agent_id.as_str().to_owned(),
key_id: key_id.as_str().to_owned(),
operation_id: operation_id.as_str().to_owned(),
operation_version,
tool_name: tool_name.clone(),
occurred_at: format_timestamp(occurred_at),
request_id: projection.first_call_request_id.clone(),
trace_id: projection.first_call_trace_id.clone(),
}),
_ => None,
};
let first_incomplete = projection.steps.iter().position(|step| !step.completed);
Ok(OnboardingResponse {
schema_version: 1,
workspace_id: projection.workspace_id.as_str().to_owned(),
revision: projection.revision,
status: if projection.completed {
"complete".to_owned()
} else {
"in_progress".to_owned()
},
completed: projection.completed,
eligible_since: projection.eligible_since.map(format_timestamp),
steps: projection
.steps
.iter()
.enumerate()
.map(|(index, step)| {
let status = if step.completed {
"complete"
} else if projection.was_completed {
"regressed"
} else if Some(index) == first_incomplete {
"current"
} else {
"pending"
};
let reason_code = match status {
"complete" => "authoritative_evidence_present",
"regressed" => "authoritative_evidence_regressed",
"current" => "authoritative_evidence_missing",
_ => "prerequisite_incomplete",
};
OnboardingStepView {
id: step.id,
completed: step.completed,
status: status.to_owned(),
action_code: action_code(step.id).to_owned(),
reason_code: reason_code.to_owned(),
}
})
.collect(),
operation_id: projection.operation_id.map(|id| id.as_str().to_owned()),
operation_version: projection.operation_version,
agent_id: projection.agent_id.map(|id| id.as_str().to_owned()),
catalog_revision: projection.catalog_revision,
platform_api_key_id: projection
.platform_api_key_id
.map(|id| id.as_str().to_owned()),
mcp_endpoint: endpoint,
first_call,
})
}
}
fn action_code(step: OnboardingStepId) -> &'static str {
match step {
OnboardingStepId::Operation => "create_operation",
OnboardingStepId::Test => "test_operation",
OnboardingStepId::PublishOperation => "publish_operation",
OnboardingStepId::Agent => "publish_agent",
OnboardingStepId::Key => "create_mcp_key",
OnboardingStepId::McpConnection => "connect_mcp_client",
OnboardingStepId::FirstCall => "call_tool",
}
}
fn onboarding_event_not_allowed() -> ApiError {
ApiError::unprocessable_with_context(
"client cannot complete authoritative onboarding steps",
json!({"error_code":"onboarding_event_not_allowed"}),
)
}
+247 -97
View File
@@ -1,14 +1,17 @@
use crank_core::{
ConfigExport, ExecutionMode, ExportMode, InvocationLevel, InvocationSource, InvocationStatus,
OperationId, OperationStatus, Samples, WorkspaceId,
ConfigExport, ExecutionMode, ExecutionOrigin, ExportMode, InvocationLevel, InvocationSource,
InvocationStatus, OperationAvailability, OperationId, OperationStatus, OperationVersionState,
Samples, WorkspaceId,
};
use crank_registry::{
CreateVersionRequest, OperationVersionRecord, PublishRequest, RegistryOperation,
CreateVersionRequest, OperationStateExpectation, OperationVersionRecord, PublishRequest,
RegistryOperation,
};
use crank_runtime::{
RuntimeError, RuntimeExecutionRequest, RuntimeOperation, RuntimeRequestContext,
ExecutionAuthorization, RuntimeExecutionRequest, RuntimeOperation, RuntimeRequestContext,
};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use time::OffsetDateTime;
use tracing::{info, instrument};
@@ -18,13 +21,33 @@ use crate::{
AdminService, CreatedOperationResponse, InvocationRecordRequest, NewVersionPayload,
OperationDetailView, OperationMutationResult, OperationPayload, OperationSummaryView,
PublishResponse, TestRunPayload, TestRunResult, UpdateOperationPayload, VersionRef,
agent_ref_map, build_request_preview, default_usage_summary, enrich_operation_summary,
format_timestamp, new_prefixed_id, now_string, runtime_error_code, today_start_utc,
tool_quality_mapping_set, tool_quality_schema_node, usage_map,
agent_ref_map, default_usage_summary, enrich_operation_summary, format_timestamp,
new_prefixed_id, now_string, today_start_utc, tool_quality_mapping_set,
tool_quality_schema_node, usage_map,
},
};
impl AdminService {
pub fn operation_state_etag(detail: &OperationDetailView) -> String {
let material = format!(
"operation-state-v2\0{}\0{}\0{}\0{:?}\0{:?}",
detail.workspace_id,
detail.id,
detail.current_draft_version,
detail.status,
detail.latest_published_version
);
format!("\"{:x}\"", Sha256::digest(material.as_bytes()))
}
pub fn operation_version_etag(record: &OperationVersionRecord) -> Result<String, ApiError> {
let bytes = serde_json::to_vec(&record.snapshot)
.map_err(|_| ApiError::internal("operation version serialization failed"))?;
let mut digest = Sha256::new();
digest.update(b"operation-version-v2\0");
digest.update(bytes);
Ok(format!("\"{:x}\"", digest.finalize()))
}
pub async fn list_operations(
&self,
workspace_id: &WorkspaceId,
@@ -84,6 +107,11 @@ impl AdminService {
let refs = agent_ref_map(agent_refs)
.remove(operation_id.as_str())
.unwrap_or_default();
let current_version = self
.registry
.get_operation_version(workspace_id, operation_id, summary.current_draft_version)
.await?
.ok_or_else(|| ApiError::internal("current operation version is unavailable"))?;
Ok(OperationDetailView {
id: summary.id.as_str().to_owned(),
@@ -94,6 +122,11 @@ impl AdminService {
protocol: summary.protocol,
security_level: summary.security_level,
status: summary.status,
availability: if summary.status == OperationStatus::Archived {
OperationAvailability::Archived
} else {
OperationAvailability::Active
},
current_draft_version: summary.current_draft_version,
latest_published_version: summary.latest_published_version,
created_at: format_timestamp(summary.created_at),
@@ -101,11 +134,15 @@ impl AdminService {
published_at: summary.published_at.map(format_timestamp),
draft_version_ref: VersionRef {
version: summary.current_draft_version,
status: summary.status,
status: if current_version.status == OperationStatus::Published {
OperationVersionState::Published
} else {
OperationVersionState::Draft
},
},
published_version_ref: summary.latest_published_version.map(|version| VersionRef {
version,
status: OperationStatus::Published,
status: OperationVersionState::Published,
}),
agent_refs: refs,
})
@@ -155,6 +192,8 @@ impl AdminService {
}
let snapshot = self.new_operation_snapshot(payload)?;
self.validate_registry_operation_in_workspace(workspace_id, &snapshot)
.await?;
let operation_id = snapshot.id.clone();
self.registry
@@ -240,6 +279,7 @@ impl AdminService {
workspace_id: &WorkspaceId,
operation_id: &OperationId,
payload: NewVersionPayload,
expected_state: &OperationStateExpectation,
) -> Result<CreatedOperationResponse, ApiError> {
self.validate_operation_payload(&payload.operation)?;
@@ -254,7 +294,27 @@ impl AdminService {
)
})?;
let now = OffsetDateTime::now_utc();
let version = summary.current_draft_version + 1;
if summary.current_draft_version != expected_state.current_draft_version
|| summary.status != expected_state.status
|| summary.latest_published_version != expected_state.latest_published_version
{
return Err(crank_registry::RegistryError::OperationStaleVersion {
operation_id: operation_id.as_str().to_owned(),
expected: expected_state.current_draft_version,
actual: summary.current_draft_version,
}
.into());
}
let version = summary
.current_draft_version
.checked_add(1)
.filter(|value| i32::try_from(*value).is_ok())
.ok_or_else(|| {
ApiError::conflict_with_context(
"operation revision limit reached",
json!({ "error_code": "operation_revision_exhausted" }),
)
})?;
let snapshot = RegistryOperation {
id: operation_id.clone(),
name: payload.operation.name,
@@ -283,13 +343,19 @@ impl AdminService {
published_at: None,
};
self.validate_registry_operation_in_workspace(workspace_id, &snapshot)
.await?;
self.registry
.create_version(CreateVersionRequest {
workspace_id,
snapshot: &snapshot,
change_note: payload.change_note.as_deref(),
created_by: None,
})
.create_version_cas(
CreateVersionRequest {
workspace_id,
snapshot: &snapshot,
change_note: payload.change_note.as_deref(),
created_by: None,
},
expected_state,
)
.await?;
info!(
name: "admin.operation.version_created",
@@ -313,21 +379,20 @@ impl AdminService {
workspace_id: &WorkspaceId,
operation_id: &OperationId,
payload: UpdateOperationPayload,
expected_state: &OperationStateExpectation,
) -> Result<OperationMutationResult, ApiError> {
let existing = self
.get_operation_version(
workspace_id,
operation_id,
self.get_operation(workspace_id, operation_id)
.await?
.current_draft_version,
expected_state.current_draft_version,
)
.await?;
let updated_at = OffsetDateTime::now_utc();
let snapshot = RegistryOperation {
id: operation_id.clone(),
name: existing.snapshot.name,
name: existing.snapshot.name.clone(),
display_name: payload.display_name,
category: payload.category,
protocol: existing.snapshot.protocol,
@@ -341,24 +406,39 @@ impl AdminService {
output_mapping: payload.output_mapping,
execution_config: payload.execution_config,
tool_description: payload.tool_description,
samples: existing.snapshot.samples,
generated_draft: existing.snapshot.generated_draft,
config_export: existing.snapshot.config_export,
samples: existing.snapshot.samples.clone(),
generated_draft: existing.snapshot.generated_draft.clone(),
config_export: existing.snapshot.config_export.clone(),
wizard_state: payload.wizard_state,
created_at: existing.snapshot.created_at,
updated_at,
published_at: existing.snapshot.published_at,
};
self.validate_registry_operation(&snapshot)?;
self.validate_registry_operation_in_workspace(workspace_id, &snapshot)
.await?;
if existing.snapshot.portable_semantically_eq(&snapshot)
&& existing.snapshot.wizard_state == snapshot.wizard_state
{
self.registry
.verify_operation_state(workspace_id, operation_id, expected_state)
.await?;
return Ok(OperationMutationResult {
operation_id: operation_id.as_str().to_owned(),
workspace_id: workspace_id.as_str().to_owned(),
version: existing.version,
status: existing.status,
updated_at: format_timestamp(existing.snapshot.updated_at),
});
}
self.registry
.update_operation_draft(workspace_id, &snapshot)
.update_operation_draft_cas(workspace_id, &snapshot, expected_state)
.await?;
Ok(OperationMutationResult {
operation_id: operation_id.as_str().to_owned(),
workspace_id: workspace_id.as_str().to_owned(),
version: snapshot.version,
version: snapshot.version.saturating_add(1),
status: snapshot.status,
updated_at: format_timestamp(updated_at),
})
@@ -370,16 +450,56 @@ impl AdminService {
workspace_id: &WorkspaceId,
operation_id: &OperationId,
version: u32,
expected_state: &OperationStateExpectation,
) -> Result<PublishResponse, ApiError> {
let record = self
.get_operation_version(workspace_id, operation_id, version)
.await?;
self.validate_registry_operation_in_workspace(workspace_id, &record.snapshot)
.await?;
let mut findings = crank_core::analyze_tool_identity_quality(
&record.snapshot.name,
&record.snapshot.tool_description,
)
.findings;
findings.extend(
crank_core::analyze_tool_schema_quality(
"input_schema",
&tool_quality_schema_node(&record.snapshot.input_schema),
)
.findings,
);
findings.extend(
crank_core::analyze_tool_response_projection_quality(&tool_quality_mapping_set(
&record.snapshot.output_mapping,
))
.findings,
);
let report = crank_core::ToolQualityReport::new(findings);
if report.blocking {
return Err(ApiError::unprocessable_with_context(
"operation publish is blocked by quality findings",
json!({
"error_code": "operation_publish_blocked",
"findings": report.findings
}),
));
}
let published_at = OffsetDateTime::now_utc();
self.registry
.publish_operation(PublishRequest {
workspace_id,
operation_id,
version,
published_at: &published_at,
published_by: None,
})
.publish_operation_cas(
PublishRequest {
workspace_id,
operation_id,
version,
published_at: &published_at,
published_by: None,
},
expected_state,
)
.await?;
let authoritative = self
.get_operation_version(workspace_id, operation_id, version)
.await?;
info!(
name: "admin.operation.published",
@@ -392,7 +512,11 @@ impl AdminService {
operation_id: operation_id.as_str().to_owned(),
workspace_id: workspace_id.as_str().to_owned(),
published_version: version,
published_at: format_timestamp(published_at),
published_at: authoritative
.snapshot
.published_at
.map(format_timestamp)
.unwrap_or_else(|| format_timestamp(published_at)),
})
}
@@ -401,19 +525,21 @@ impl AdminService {
&self,
workspace_id: &WorkspaceId,
operation_id: &OperationId,
expected_state: &OperationStateExpectation,
) -> Result<OperationMutationResult, ApiError> {
let summary = self.get_operation(workspace_id, operation_id).await?;
let updated_at = OffsetDateTime::now_utc();
self.registry
.archive_operation(workspace_id, operation_id, &updated_at)
.archive_operation_cas(workspace_id, operation_id, &updated_at, expected_state)
.await?;
let authoritative = self.get_operation(workspace_id, operation_id).await?;
Ok(OperationMutationResult {
operation_id: operation_id.as_str().to_owned(),
workspace_id: workspace_id.as_str().to_owned(),
version: summary.current_draft_version,
status: OperationStatus::Archived,
updated_at: format_timestamp(updated_at),
updated_at: authoritative.updated_at,
})
}
@@ -422,11 +548,12 @@ impl AdminService {
&self,
workspace_id: &WorkspaceId,
operation_id: &OperationId,
expected_state: &OperationStateExpectation,
) -> Result<OperationMutationResult, ApiError> {
let summary = self.get_operation(workspace_id, operation_id).await?;
let updated_at = now_string()?;
self.registry
.delete_operation(workspace_id, operation_id)
.delete_operation_cas(workspace_id, operation_id, expected_state)
.await?;
Ok(OperationMutationResult {
@@ -448,70 +575,77 @@ impl AdminService {
) -> Result<TestRunResult, ApiError> {
let request_id = correlation.request_id().as_str();
let trace_id = correlation.trace_id().as_str();
let runtime_request_context = RuntimeRequestContext::from_correlation(correlation)
let mut runtime_request_context = RuntimeRequestContext::from_correlation(correlation)
.with_metering_context(workspace_id.clone(), None, InvocationSource::AdminTestRun);
if let Some(token) = payload.confirmation_token.as_deref() {
runtime_request_context = runtime_request_context.with_confirmation_token(token);
}
let locale = payload
.locale
.as_deref()
.filter(|value| {
value.eq_ignore_ascii_case("ru") || value.to_ascii_lowercase().starts_with("ru-")
})
.map_or(crank_core::ExecutionLocale::En, |_| {
crank_core::ExecutionLocale::Ru
});
let record = self
.get_operation_version(workspace_id, operation_id, payload.version)
.await?;
let summary = self.get_operation(workspace_id, operation_id).await?;
if summary.status == OperationStatus::Archived
|| record.status != OperationStatus::Draft
|| summary.current_draft_version != payload.version
{
return Err(ApiError::conflict_with_context(
"only the current saved Draft can be tested",
json!({
"operation_id": operation_id.as_str(),
"current_version": summary.current_draft_version,
"tested_version": payload.version,
"error_code": "operation_invalid_transition"
}),
));
}
let runtime = RuntimeOperation::from(record.snapshot.clone());
let mode = ExecutionMode::Unary;
let preview_span = crank_trace::Stage::RuntimeArgumentsMap.span();
let preview_result = preview_span
.in_scope(|| build_request_preview(&record.snapshot.input_mapping, &payload.input));
let request_preview = match preview_result {
Ok(preview) => preview,
Err(error) => {
crank_trace::StageOutcome::Error.record(&preview_span);
crank_trace::ErrorCategory::Mapping.record(&preview_span);
drop(preview_span);
self.record_invocation(InvocationRecordRequest {
workspace_id,
agent_id: None,
operation: &record.snapshot,
request_id: Some(request_id),
trace_id: Some(trace_id),
source: InvocationSource::AdminTestRun,
level: InvocationLevel::Error,
status: InvocationStatus::Error,
message: "mapping preview failed".to_owned(),
status_code: None,
error_kind: Some("mapping".to_owned()),
duration_ms: 0,
request_preview: Value::Null,
response_preview: Value::Null,
})
.await;
return Ok(TestRunResult {
ok: false,
mode,
request_preview: Value::Null,
response_preview: Value::Null,
errors: vec![crate::error::runtime_test_failure(&RuntimeError::Mapping(
error,
))],
});
}
};
crank_trace::StageOutcome::Success.record(&preview_span);
drop(preview_span);
let resolved_auth = self
.resolve_operation_auth(workspace_id, &runtime.execution_config)
.resolve_operation_auth(workspace_id, &runtime.execution_config, None)
.await;
let started_at = std::time::Instant::now();
match match resolved_auth {
let correlation = crank_core::CorrelationContext::new(
runtime_request_context.request_id.clone(),
runtime_request_context.trace_context.clone(),
);
let execution_result = match resolved_auth {
Ok(resolved_auth) => {
self.runtime
.execute_request(
RuntimeExecutionRequest::new(&runtime, &payload.input)
.with_optional_auth(resolved_auth.as_ref())
.with_context(&runtime_request_context),
)
.await
match RuntimeExecutionRequest::try_new(
workspace_id,
ExecutionOrigin::AdminDraft,
None,
&runtime,
&payload.input,
ExecutionAuthorization::Authorized,
resolved_auth.as_ref(),
&runtime_request_context,
std::time::Instant::now()
+ std::time::Duration::from_millis(
runtime.execution_config.timeout_ms.max(1),
),
) {
Ok(request) => self.runtime.execute_outcome(request).await,
Err(_) => Err(crank_core::ExecutionFailure::new(
crank_core::ExecutionErrorCode::RuntimeInternal,
correlation.clone(),
)),
}
}
Err(error) => Err(error),
} {
Ok(response_preview) => {
Err(error) => Err(crank_runtime::normalize_runtime_error(&error, &correlation)),
};
match execution_result {
Ok(success) => {
let response_preview = crank_core::sanitize_invocation_preview(&success.output);
let request_preview = success.request_preview;
let duration_ms =
u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
self.record_invocation(InvocationRecordRequest {
@@ -526,6 +660,10 @@ impl AdminService {
message: "admin test run completed".to_owned(),
status_code: None,
error_kind: None,
execution_stage: Some(crank_core::ExecutionStage::Runtime),
execution_error_code: None,
retryability: Some(crank_core::Retryability::Never),
outcome_certainty: Some(crank_core::OutcomeCertainty::Certain),
duration_ms,
request_preview: request_preview.clone(),
response_preview: response_preview.clone(),
@@ -534,12 +672,15 @@ impl AdminService {
Ok(TestRunResult {
ok: true,
mode,
tested_version: payload.version,
request_id: request_id.to_owned(),
trace_id: trace_id.to_owned(),
request_preview,
response_preview,
errors: Vec::new(),
})
}
Err(error) => {
Err(failure) => {
let duration_ms =
u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
self.record_invocation(InvocationRecordRequest {
@@ -551,20 +692,29 @@ impl AdminService {
source: InvocationSource::AdminTestRun,
level: InvocationLevel::Error,
status: InvocationStatus::Error,
message: error.to_string(),
status_code: None,
error_kind: Some(runtime_error_code(&error).to_owned()),
message: "runtime execution failed".to_owned(),
status_code: failure.upstream_status(),
error_kind: Some(failure.error_code().as_str().to_owned()),
execution_stage: Some(failure.stage()),
execution_error_code: Some(failure.error_code()),
retryability: Some(failure.retryability()),
outcome_certainty: Some(failure.outcome_certainty()),
duration_ms,
request_preview: request_preview.clone(),
request_preview: Value::Null,
response_preview: Value::Null,
})
.await;
Ok(TestRunResult {
ok: false,
mode,
request_preview,
tested_version: payload.version,
request_id: request_id.to_owned(),
trace_id: trace_id.to_owned(),
request_preview: Value::Null,
response_preview: Value::Null,
errors: vec![crate::error::runtime_test_failure(&error)],
errors: vec![crate::error::execution_test_failure_localized(
&failure, locale,
)],
})
}
}
+542 -34
View File
@@ -12,7 +12,8 @@ use tracing::{info, instrument};
use crate::{
error::ApiError,
service::{
AdminService, AuthProfilePayload, RotateSecretPayload, SecretPayload, new_prefixed_id,
AdminAuditContext, AdminService, AuthProfilePayload, CredentialAuditRecord,
RotateSecretPayload, SecretPayload, new_prefixed_id,
},
};
@@ -57,15 +58,46 @@ impl AdminService {
})
}
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), secret_name = %payload.name))]
#[instrument(skip(self, created_by, payload, audit_context), fields(workspace_id = %workspace_id.as_str(), secret_name = %payload.name))]
pub async fn create_secret(
&self,
workspace_id: &WorkspaceId,
created_by: Option<&UserId>,
payload: SecretPayload,
audit_context: Option<&AdminAuditContext>,
) -> Result<Secret, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
validate_secret_payload(&payload)?;
if let Err(error) = self.ensure_workspace_exists(workspace_id).await {
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.secret.create_failed",
target_kind: crank_core::AuditTargetKind::Secret,
workspace_id,
target_id: "pending",
credential_type: "secret",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
if let Err(error) = validate_secret_payload(&payload) {
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.secret.create_failed",
target_kind: crank_core::AuditTargetKind::Secret,
workspace_id,
target_id: "pending",
credential_type: "secret",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
let now = OffsetDateTime::now_utc();
let secret = Secret {
@@ -82,89 +114,322 @@ impl AdminService {
let ciphertext = self
.secret_crypto
.encrypt(&payload.value)
.map_err(|error| ApiError::internal(error.to_string()))?;
.map_err(|error| ApiError::internal(error.to_string()));
let ciphertext = match ciphertext {
Ok(ciphertext) => ciphertext,
Err(error) => {
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.secret.create_failed",
target_kind: crank_core::AuditTargetKind::Secret,
workspace_id,
target_id: secret.id.as_str(),
credential_type: "secret",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
};
self.registry
if let Err(error) = self
.registry
.create_secret(CreateSecretRequest {
secret: &secret,
ciphertext: &ciphertext,
key_version: self.secret_crypto.key_version(),
master_key_epoch: self.secret_crypto.master_key_epoch(),
created_by,
})
.await?;
.await
{
let error = ApiError::from(error);
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.secret.create_failed",
target_kind: crank_core::AuditTargetKind::Secret,
workspace_id,
target_id: secret.id.as_str(),
credential_type: "secret",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
info!(
name: "admin.secret.created",
secret_id = %secret.id.as_str(),
"secret created"
);
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.secret.created",
target_kind: crank_core::AuditTargetKind::Secret,
workspace_id,
target_id: secret.id.as_str(),
credential_type: "secret",
outcome: "success",
reason: "credential_created",
},
)
.await;
Ok(secret)
}
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), secret_id = %secret_id.as_str()))]
#[instrument(skip(self, created_by, payload, audit_context), fields(workspace_id = %workspace_id.as_str(), secret_id = %secret_id.as_str()))]
pub async fn rotate_secret(
&self,
workspace_id: &WorkspaceId,
secret_id: &SecretId,
created_by: Option<&UserId>,
payload: RotateSecretPayload,
audit_context: Option<&AdminAuditContext>,
) -> Result<Secret, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
if payload.value.is_null() {
return Err(ApiError::validation("secret value must not be null"));
if let Err(error) = self.ensure_workspace_exists(workspace_id).await {
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.secret.rotate_failed",
target_kind: crank_core::AuditTargetKind::Secret,
workspace_id,
target_id: secret_id.as_str(),
credential_type: "secret",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
let existing = match self.get_secret(workspace_id, secret_id).await {
Ok(secret) => secret,
Err(error) => {
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.secret.rotate_failed",
target_kind: crank_core::AuditTargetKind::Secret,
workspace_id,
target_id: secret_id.as_str(),
credential_type: "secret",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
};
if let Err(error) = validate_secret_value(existing.kind, &payload.value) {
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.secret.rotate_failed",
target_kind: crank_core::AuditTargetKind::Secret,
workspace_id,
target_id: secret_id.as_str(),
credential_type: "secret",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
let now = OffsetDateTime::now_utc();
let ciphertext = self
.secret_crypto
.encrypt(&payload.value)
.map_err(|error| ApiError::internal(error.to_string()))?;
self.registry
.map_err(|error| ApiError::internal(error.to_string()));
let ciphertext = match ciphertext {
Ok(ciphertext) => ciphertext,
Err(error) => {
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.secret.rotate_failed",
target_kind: crank_core::AuditTargetKind::Secret,
workspace_id,
target_id: secret_id.as_str(),
credential_type: "secret",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
};
let version = match self
.registry
.rotate_secret(RotateSecretRequest {
workspace_id,
secret_id,
ciphertext: &ciphertext,
key_version: self.secret_crypto.key_version(),
master_key_epoch: self.secret_crypto.master_key_epoch(),
created_at: &now,
updated_at: &now,
created_by,
})
.await?;
.await
{
Ok(version) => version,
Err(error) => {
let error = ApiError::from(error);
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.secret.rotate_failed",
target_kind: crank_core::AuditTargetKind::Secret,
workspace_id,
target_id: secret_id.as_str(),
credential_type: "secret",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
};
info!(
name: "admin.secret.rotated",
secret_id = %secret_id.as_str(),
"secret rotated"
);
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.secret.rotated",
target_kind: crank_core::AuditTargetKind::Secret,
workspace_id,
target_id: secret_id.as_str(),
credential_type: "secret",
outcome: "success",
reason: "credential_rotated",
},
)
.await;
self.get_secret(workspace_id, secret_id).await
Ok(Secret {
current_version: version.secret_version.version,
updated_at: now,
..existing
})
}
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), secret_id = %secret_id.as_str()))]
#[instrument(skip(self, audit_context), fields(workspace_id = %workspace_id.as_str(), secret_id = %secret_id.as_str()))]
pub async fn delete_secret(
&self,
workspace_id: &WorkspaceId,
secret_id: &SecretId,
audit_context: Option<&AdminAuditContext>,
) -> Result<(), ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
if let Some(profile) = self
if let Err(error) = self.ensure_workspace_exists(workspace_id).await {
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.secret.delete_failed",
target_kind: crank_core::AuditTargetKind::Secret,
workspace_id,
target_id: secret_id.as_str(),
credential_type: "secret",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
let referencing_profiles = match self
.registry
.list_auth_profiles_referencing_secret(workspace_id, secret_id)
.await?
.into_iter()
.next()
.await
{
Ok(profiles) => profiles,
Err(error) => {
let error = ApiError::from(error);
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.secret.delete_failed",
target_kind: crank_core::AuditTargetKind::Secret,
workspace_id,
target_id: secret_id.as_str(),
credential_type: "secret",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
};
if let Some(profile) = referencing_profiles.into_iter().next() {
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.secret.delete_denied",
target_kind: crank_core::AuditTargetKind::Secret,
workspace_id,
target_id: secret_id.as_str(),
credential_type: "secret",
outcome: "denied",
reason: "secret_referenced_by_auth_profile",
},
)
.await;
return Err(RegistryError::SecretReferencedByAuthProfile {
secret_id: secret_id.as_str().to_owned(),
auth_profile_id: profile.id.as_str().to_owned(),
}
.into());
}
self.registry.delete_secret(workspace_id, secret_id).await?;
if let Err(error) = self.registry.delete_secret(workspace_id, secret_id).await {
let error = ApiError::from(error);
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.secret.delete_failed",
target_kind: crank_core::AuditTargetKind::Secret,
workspace_id,
target_id: secret_id.as_str(),
credential_type: "secret",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
info!(
name: "admin.secret.deleted",
secret_id = %secret_id.as_str(),
"secret deleted"
);
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.secret.deleted",
target_kind: crank_core::AuditTargetKind::Secret,
workspace_id,
target_id: secret_id.as_str(),
credential_type: "secret",
outcome: "success",
reason: "credential_deleted",
},
)
.await;
Ok(())
}
@@ -185,39 +450,118 @@ impl AdminService {
})
}
#[instrument(skip(self, payload), fields(auth_profile_name = %payload.name, auth_kind = ?payload.kind))]
#[instrument(skip(self, payload, audit_context), fields(auth_profile_name = %payload.name, auth_kind = ?payload.kind))]
pub async fn create_auth_profile(
&self,
workspace_id: &WorkspaceId,
payload: AuthProfilePayload,
audit_context: Option<&AdminAuditContext>,
) -> Result<AuthProfile, ApiError> {
validate_auth_profile_kind(payload.kind, &payload.config)?;
self.ensure_workspace_exists(workspace_id).await?;
self.validate_auth_profile_secret_ids(workspace_id, &payload.config)
.await?;
if let Err(error) = validate_auth_profile_payload(&payload) {
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.auth_profile.create_failed",
target_kind: crank_core::AuditTargetKind::AuthProfile,
workspace_id,
target_id: "pending",
credential_type: "auth_profile",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
if let Err(error) = self.ensure_workspace_exists(workspace_id).await {
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.auth_profile.create_failed",
target_kind: crank_core::AuditTargetKind::AuthProfile,
workspace_id,
target_id: "pending",
credential_type: "auth_profile",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
if let Err(error) = self
.validate_auth_profile_secret_ids(workspace_id, &payload.config)
.await
{
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.auth_profile.create_failed",
target_kind: crank_core::AuditTargetKind::AuthProfile,
workspace_id,
target_id: "pending",
credential_type: "auth_profile",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
let now = OffsetDateTime::now_utc();
let profile = AuthProfile {
id: AuthProfileId::new(new_prefixed_id("auth")),
workspace_id: workspace_id.clone(),
name: payload.name,
name: payload.name.trim().to_owned(),
kind: payload.kind,
config: payload.config,
created_at: now,
updated_at: now,
};
self.registry
if let Err(error) = self
.registry
.save_auth_profile(SaveAuthProfileRequest {
workspace_id,
profile: &profile,
})
.await?;
.await
{
let error = ApiError::from(error);
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.auth_profile.create_failed",
target_kind: crank_core::AuditTargetKind::AuthProfile,
workspace_id,
target_id: profile.id.as_str(),
credential_type: "auth_profile",
outcome: "failure",
reason: error.code(),
},
)
.await;
return Err(error);
}
info!(
name: "admin.auth_profile.created",
auth_profile_id = %profile.id.as_str(),
"auth profile created"
);
self.record_credential_audit(
audit_context,
CredentialAuditRecord {
action: "credential.auth_profile.created",
target_kind: crank_core::AuditTargetKind::AuthProfile,
workspace_id,
target_id: profile.id.as_str(),
credential_type: "auth_profile",
outcome: "success",
reason: "credential_created",
},
)
.await;
Ok(profile)
}
@@ -228,13 +572,45 @@ impl AdminService {
config: &AuthConfig,
) -> Result<(), ApiError> {
for secret_id in config.secret_ids() {
self.get_secret(workspace_id, secret_id).await?;
let secret = self.get_secret(workspace_id, secret_id).await?;
if secret.status != SecretStatus::Active {
return Err(RegistryError::SecretInactive {
secret_id: secret_id.as_str().to_owned(),
}
.into());
}
}
Ok(())
}
}
fn validate_auth_profile_payload(payload: &AuthProfilePayload) -> Result<(), ApiError> {
const MAX_PROFILE_NAME_CHARS: usize = 128;
let profile_name = payload.name.trim();
if profile_name.is_empty() || profile_name.chars().count() > MAX_PROFILE_NAME_CHARS {
return Err(ApiError::validation(
"auth profile name must contain 1 to 128 characters",
));
}
if profile_name.chars().any(char::is_control) {
return Err(ApiError::validation(
"auth profile name contains unsupported characters",
));
}
validate_auth_profile_kind(payload.kind, &payload.config)?;
match &payload.config {
AuthConfig::Bearer(config) => validate_auth_header_name(&config.header_name)?,
AuthConfig::Basic(_) => {}
AuthConfig::ApiKeyHeader(config) => validate_auth_header_name(&config.header_name)?,
AuthConfig::ApiKeyQuery(config) => validate_auth_query_name(&config.param_name)?,
}
Ok(())
}
fn validate_auth_profile_kind(kind: AuthKind, config: &AuthConfig) -> Result<(), ApiError> {
let is_match = matches!(
(kind, config),
@@ -252,13 +628,145 @@ fn validate_auth_profile_kind(kind: AuthKind, config: &AuthConfig) -> Result<(),
}
fn validate_secret_payload(payload: &SecretPayload) -> Result<(), ApiError> {
if payload.name.trim().is_empty() {
return Err(ApiError::validation("secret name must not be empty"));
const MAX_SECRET_NAME_CHARS: usize = 128;
let name = payload.name.trim();
if name.is_empty() || name.chars().count() > MAX_SECRET_NAME_CHARS {
return Err(ApiError::validation(
"secret name must contain 1 to 128 characters",
));
}
if name.chars().any(char::is_control) {
return Err(ApiError::validation(
"secret name contains unsupported characters",
));
}
if payload.value.is_null() {
validate_secret_value(payload.kind, &payload.value)
}
fn validate_secret_value(
kind: crank_core::SecretKind,
value: &serde_json::Value,
) -> Result<(), ApiError> {
const MAX_SECRET_VALUE_BYTES: usize = 65_536;
const MAX_SECRET_STRING_BYTES: usize = 16_384;
if value.is_null() {
return Err(ApiError::validation("secret value must not be null"));
}
let encoded = serde_json::to_vec(value)
.map_err(|_| ApiError::validation("secret value must be valid JSON"))?;
if encoded.len() > MAX_SECRET_VALUE_BYTES {
return Err(ApiError::validation("secret value is too large"));
}
match kind {
crank_core::SecretKind::Token | crank_core::SecretKind::Header => {
if value.is_string() {
validate_non_empty_secret_string(value, "secret value", MAX_SECRET_STRING_BYTES)?;
} else {
let object = value.as_object().ok_or_else(|| {
ApiError::validation(
"token/header secret must be a string or a single token/value field",
)
})?;
let field = object
.get("token")
.or_else(|| object.get("value"))
.filter(|_| object.len() == 1)
.ok_or_else(|| {
ApiError::validation("token/header secret must contain only token or value")
})?;
validate_non_empty_secret_string(field, "secret value", MAX_SECRET_STRING_BYTES)?;
}
}
crank_core::SecretKind::UsernamePassword => {
let object = value.as_object().ok_or_else(|| {
ApiError::validation("username_password secret must contain username and password")
})?;
if object.len() != 2
|| !object.contains_key("username")
|| !object.contains_key("password")
{
return Err(ApiError::validation(
"username_password secret must contain only username and password",
));
}
validate_non_empty_secret_string(
&object["username"],
"secret username",
MAX_SECRET_STRING_BYTES,
)?;
validate_non_empty_secret_string(
&object["password"],
"secret password",
MAX_SECRET_STRING_BYTES,
)?;
}
crank_core::SecretKind::Generic => {}
}
Ok(())
}
fn validate_non_empty_secret_string(
value: &serde_json::Value,
field: &str,
max_bytes: usize,
) -> Result<(), ApiError> {
let value = value
.as_str()
.ok_or_else(|| ApiError::validation(format!("{field} must be a string")))?;
if value.is_empty() {
return Err(ApiError::validation(format!("{field} must not be empty")));
}
if value.len() > max_bytes {
return Err(ApiError::validation(format!("{field} is too large")));
}
Ok(())
}
fn validate_auth_header_name(value: &str) -> Result<(), ApiError> {
const MAX_HEADER_NAME_BYTES: usize = 128;
if value.is_empty()
|| value.len() > MAX_HEADER_NAME_BYTES
|| !value.bytes().all(|byte| {
byte.is_ascii_alphanumeric()
|| matches!(
byte,
b'!' | b'#'
| b'$'
| b'%'
| b'&'
| b'\''
| b'*'
| b'+'
| b'-'
| b'.'
| b'^'
| b'_'
| b'`'
| b'|'
| b'~'
)
})
{
return Err(ApiError::validation("auth header name is invalid"));
}
Ok(())
}
fn validate_auth_query_name(value: &str) -> Result<(), ApiError> {
const MAX_QUERY_NAME_BYTES: usize = 128;
if value.is_empty()
|| value.len() > MAX_QUERY_NAME_BYTES
|| !value.bytes().all(|byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b'[' | b']')
})
{
return Err(ApiError::validation("auth query parameter name is invalid"));
}
Ok(())
}
+1
View File
@@ -97,6 +97,7 @@ impl AdminService {
user,
memberships,
current_workspace_id: Some(workspace_id.as_str().to_owned()),
csrf_token: self.rotate_session_csrf_token(session_id).await?,
})
}
+5 -5
View File
@@ -1,14 +1,14 @@
use crate::service::AdminService;
use crank_runtime::RequestRateLimiter;
use std::net::IpAddr;
#[derive(Clone)]
pub struct AppState {
pub service: AdminService,
pub api_rate_limiter: RequestRateLimiter,
/// Whether to trust `X-Real-IP` / `X-Forwarded-For` for client identification.
/// Immediate peer IPs allowed to supply `X-Real-IP` / `X-Forwarded-For`.
///
/// Only enable when the service sits behind a trusted reverse proxy that
/// overwrites these headers (e.g. the bundled nginx). When disabled the
/// real TCP peer address is used, which a client cannot spoof.
pub trust_forwarded_headers: bool,
/// Only configure reverse proxies that overwrite these headers. When the
/// peer is absent or not listed the real TCP peer address is used.
pub trusted_proxy_ips: Vec<IpAddr>,
}
+98 -1
View File
@@ -1,12 +1,20 @@
use std::process::Command;
use crank_registry::{MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate, PostgresRegistry};
use crank_runtime::SecretCrypto;
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
const TEST_MASTER_KEY: &str = "test-master-key-00000000000000000000000000000000";
const REGISTERED_MASTER_KEY: &str = "registered-master-key-CANARY_SECRET_VALUE-00000000";
const WRONG_MASTER_KEY: &str = "wrong-master-key-CANARY_SECRET_VALUE-0000000000000";
fn run_with(entries: &[(&str, &str)]) -> String {
let mut command = Command::new(env!("CARGO_BIN_EXE_admin-api"));
for field in crank_config::field_registry() {
command.env_remove(field.env_name);
}
command.envs([
("CRANK_MASTER_KEY", "master"),
("CRANK_MASTER_KEY", TEST_MASTER_KEY),
("CRANK_SESSION_SECRET", "session"),
("CRANK_PASSWORD_PEPPER", "pepper"),
("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"),
@@ -69,3 +77,92 @@ async fn fresh_database_startup_is_read_only() {
.unwrap();
assert!(!present);
}
#[tokio::test]
async fn master_key_mismatch_blocks_startup_with_safe_diagnostic() {
let database_url = crank_test_support::postgres_schema_url("admin_master_key_mismatch").await;
let applied = Command::new(env!("CARGO_BIN_EXE_crank-migrate"))
.arg("apply")
.env("CRANK_DATABASE_URL", &database_url)
.output()
.expect("migration command executes");
assert!(
applied.status.success(),
"{}",
String::from_utf8_lossy(&applied.stderr)
);
let registry = PostgresRegistry::connect(&database_url).await.unwrap();
let crypto = SecretCrypto::new(REGISTERED_MASTER_KEY).unwrap();
registry
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
epoch: 1,
fingerprint: crypto.master_key_fingerprint(),
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &OffsetDateTime::parse("2026-08-21T00:00:00Z", &Rfc3339).unwrap(),
})
.await
.unwrap();
let stderr = run_with(&[
("CRANK_DATABASE_URL", &database_url),
("CRANK_MASTER_KEY", WRONG_MASTER_KEY),
]);
assert!(stderr.contains("master_key_identity_mismatch"), "{stderr}");
assert!(!stderr.contains("registered-master-key"));
assert!(!stderr.contains("wrong-master-key"));
}
#[tokio::test]
async fn populated_database_without_identity_rejects_wrong_first_key() {
let database_url =
crank_test_support::postgres_schema_url("admin_master_key_first_registration_wrong").await;
let applied = Command::new(env!("CARGO_BIN_EXE_crank-migrate"))
.arg("apply")
.env("CRANK_DATABASE_URL", &database_url)
.output()
.expect("migration command executes");
assert!(
applied.status.success(),
"{}",
String::from_utf8_lossy(&applied.stderr)
);
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
let old_crypto = SecretCrypto::new(REGISTERED_MASTER_KEY).unwrap();
let ciphertext = old_crypto
.encrypt(&serde_json::json!({"token": "CANARY_SECRET_VALUE"}))
.unwrap();
sqlx::query(
"insert into secrets (
id, workspace_id, name, kind, status, current_version, created_at, updated_at
) values (
'legacy_secret', 'ws_default', 'legacy secret', 'token', 'active', 1, now(), now()
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"insert into secret_versions (
secret_id, version, ciphertext, key_version, master_key_epoch, created_at, created_by
) values (
'legacy_secret', 1, $1, $2, 1, now(), null
)",
)
.bind(ciphertext)
.bind(old_crypto.key_version())
.execute(&pool)
.await
.unwrap();
let stderr = run_with(&[
("CRANK_DATABASE_URL", &database_url),
("CRANK_MASTER_KEY", WRONG_MASTER_KEY),
]);
assert!(stderr.contains("startup_failed"), "{stderr}");
assert!(!stderr.contains("CANARY_SECRET_VALUE"));
let identities: i64 = sqlx::query_scalar("select count(*) from master_key_identities")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(identities, 0);
}
+5
View File
@@ -1,8 +1,13 @@
mod integration {
mod agent_catalog;
mod auth_rate_limit;
mod common;
mod community_access_usage;
mod credential_lifecycle;
mod logs_usage;
mod onboarding;
mod openapi_import;
mod operation_lifecycle;
mod operations_agents;
mod request_context;
mod secrets_import_auth;
@@ -0,0 +1,310 @@
#![allow(dead_code, unused_imports)]
use super::common::*;
use serde_json::{Value, json};
use serial_test::serial;
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn binding_requires_exact_published_operation_version() {
let registry = test_registry().await;
let storage_root = test_storage_root("agent_catalog_binding_requires_published");
let upstream_base_url = spawn_upstream_server().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let draft_operation = assert_success_json(
client
.post(format!("{base_url}/operations"))
.json(&test_operation_payload(
&upstream_base_url,
"draft_not_bindable_tool",
))
.send()
.await
.unwrap(),
)
.await;
let draft_operation_id = draft_operation["operation_id"].as_str().unwrap().to_owned();
let agent = assert_success_json(
client
.post(format!("{base_url}/agents"))
.json(&json!({
"slug": "strict-catalog-agent",
"display_name": "Strict Catalog Agent",
"description": "Rejects draft bindings",
"instructions": {},
"tool_selection_policy": {}
}))
.send()
.await
.unwrap(),
)
.await;
let agent_id = agent["agent_id"].as_str().unwrap().to_owned();
let response = client
.post(format!("{base_url}/agents/{agent_id}/bindings"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&json!([
{
"operation_id": draft_operation_id,
"operation_version": 1,
"tool_name": "draft_not_bindable_tool",
"tool_title": "Draft should not bind",
"tool_description_override": null,
"enabled": true
}
]))
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY);
let body = response.json::<Value>().await.unwrap();
assert_eq!(
body["error"]["code"], "agent_binding_not_published",
"binding Draft Operation must fail before publish and not be silently filtered"
);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn stale_agent_revision_rejects_mutation() {
let registry = test_registry().await;
let storage_root = test_storage_root("agent_catalog_stale_revision");
let upstream_base_url = spawn_upstream_server().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let operation = assert_success_json(
client
.post(format!("{base_url}/operations"))
.json(&test_operation_payload(
&upstream_base_url,
"stale_agent_tool",
))
.send()
.await
.unwrap(),
)
.await;
let operation_id = operation["operation_id"].as_str().unwrap().to_owned();
assert_success_json(
client
.post(format!("{base_url}/operations/{operation_id}/publish"))
.header(
reqwest::header::IF_MATCH,
operation_etag(&client, &base_url, &operation_id).await,
)
.json(&json!({ "version": 1 }))
.send()
.await
.unwrap(),
)
.await;
let agent = assert_success_json(
client
.post(format!("{base_url}/agents"))
.json(&json!({
"slug": "stale-agent",
"display_name": "Stale Agent",
"description": "Stale revision test",
"instructions": {},
"tool_selection_policy": {}
}))
.send()
.await
.unwrap(),
)
.await;
let agent_id = agent["agent_id"].as_str().unwrap().to_owned();
assert_success_json(
client
.post(format!("{base_url}/agents/{agent_id}/bindings"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&json!([{
"operation_id": operation_id,
"operation_version": 1,
"tool_name": "stale_agent_tool",
"tool_title": "Stale Agent Tool",
"enabled": true
}]))
.send()
.await
.unwrap(),
)
.await;
assert_success_json(
client
.post(format!("{base_url}/agents/{agent_id}/publish"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&json!({ "version": 1 }))
.send()
.await
.unwrap(),
)
.await;
let response = client
.post(format!("{base_url}/agents/{agent_id}/bindings"))
.json(&json!([]))
.send()
.await
.unwrap();
assert_eq!(
response.status(),
reqwest::StatusCode::PRECONDITION_REQUIRED,
"Agent mutations after read must require current revision precondition"
);
let body = response.json::<Value>().await.unwrap();
assert_eq!(body["error"]["code"], "agent_precondition_required");
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn unpublishes_and_archives_agent() {
let registry = test_registry().await;
let storage_root = test_storage_root("agent_statuses");
let upstream_base_url = spawn_upstream_server().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let operation = assert_success_json(
client
.post(format!("{base_url}/operations"))
.json(&test_operation_payload(
&upstream_base_url,
"crm_create_lead_agent_status",
))
.send()
.await
.unwrap(),
)
.await;
let operation_id = operation["operation_id"].as_str().unwrap().to_owned();
client
.post(format!("{base_url}/operations/{operation_id}/publish"))
.header(
reqwest::header::IF_MATCH,
operation_etag(&client, &base_url, &operation_id).await,
)
.json(&json!({ "version": 1 }))
.send()
.await
.unwrap();
let created = assert_success_json(
client
.post(format!("{base_url}/agents"))
.json(&json!({
"slug": "sales-routing",
"display_name": "Sales Routing",
"description": "Routing agent",
"instructions": {},
"tool_selection_policy": {}
}))
.send()
.await
.unwrap(),
)
.await;
let agent_id = created["agent_id"].as_str().unwrap().to_owned();
client
.post(format!("{base_url}/agents/{agent_id}/bindings"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&json!([
{
"operation_id": operation_id,
"operation_version": 1,
"tool_name": "crm_create_lead_agent_status",
"tool_title": "Create Lead",
"tool_description_override": null,
"enabled": true
}
]))
.send()
.await
.unwrap();
client
.post(format!("{base_url}/agents/{agent_id}/publish"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&json!({ "version": 1 }))
.send()
.await
.unwrap();
let unpublished = assert_success_json(
client
.post(format!("{base_url}/agents/{agent_id}/unpublish"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&json!({}))
.send()
.await
.unwrap(),
)
.await;
assert_eq!(unpublished["agent_id"], agent_id);
let draft_detail = assert_success_json(
client
.get(format!("{base_url}/agents/{agent_id}"))
.send()
.await
.unwrap(),
)
.await;
assert_eq!(draft_detail["status"], "draft");
assert_eq!(draft_detail["latest_published_version"], 1);
let archived = assert_success_json(
client
.post(format!("{base_url}/agents/{agent_id}/archive"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&json!({}))
.send()
.await
.unwrap(),
)
.await;
assert_eq!(archived["agent_id"], agent_id);
let archived_detail = assert_success_json(
client
.get(format!("{base_url}/agents/{agent_id}"))
.send()
.await
.unwrap(),
)
.await;
assert_eq!(archived_detail["status"], "archived");
}
@@ -11,17 +11,22 @@ use std::{
use async_trait::async_trait;
use axum::{Json, Router, routing::post};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use crank_core::{
ExecutionConfig, HttpMethod, MembershipRole, OperationSecurityLevel, Protocol,
ResponseCachePolicy, RestTarget, SecretKind, Target, ToolDescription, WorkspaceId,
};
use crank_core::{IdentityError, IdentityProvider, IdentityProviderKind, LoginOutcome};
use crank_mapping::{MappingRule, MappingSet};
use crank_registry::PostgresRegistry;
use crank_registry::{
CreateAdminBootstrapContractRequest, MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate,
MigrationAuthority, PostgresRegistry,
};
use crank_runtime::SecretCrypto;
use crank_schema::{Schema, SchemaKind};
use serde_json::{Value, json};
use serial_test::serial;
use sha2::{Digest, Sha256};
use tokio::net::TcpListener;
use admin_api::{
@@ -36,7 +41,7 @@ const TEST_AUTH_EMAIL: &str = "owner@crank.local";
const TEST_AUTH_PASSWORD: &str = "test-password";
const TEST_PASSWORD_PEPPER: &str = "test-password-pepper";
const TEST_SESSION_SECRET: &str = "test-session-secret";
const TEST_MASTER_KEY: &str = "test-master-key";
const TEST_MASTER_KEY: &str = "test-master-key-00000000000000000000000000000000";
struct TestServer {
base_url: String,
@@ -92,6 +97,48 @@ impl IdentityProvider for RejectingIdentityProvider {
}
}
async fn empty_registry(name: &str) -> PostgresRegistry {
let database_url = crank_test_support::postgres_schema_url(name).await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
MigrationAuthority::apply(&pool).await.unwrap();
let registry = PostgresRegistry::connect(&database_url).await.unwrap();
let secret_crypto = test_secret_crypto();
registry
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
epoch: secret_crypto.master_key_epoch(),
fingerprint: secret_crypto.master_key_fingerprint(),
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &time::OffsetDateTime::now_utc(),
})
.await
.unwrap();
registry
}
fn bootstrap_token_hash(token: &str) -> String {
let digest = Sha256::digest(format!("bootstrap:{token}").as_bytes());
URL_SAFE_NO_PAD.encode(digest)
}
async fn login_client_without_csrf(root_url: &str) -> reqwest::Client {
let client = reqwest::Client::builder()
.cookie_store(true)
.build()
.unwrap();
client
.post(format!("{root_url}/api/auth/login"))
.json(&json!({
"email": TEST_AUTH_EMAIL,
"password": TEST_AUTH_PASSWORD,
}))
.send()
.await
.unwrap()
.error_for_status()
.unwrap();
client
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn rejects_rapid_login_requests_with_429() {
@@ -109,7 +156,7 @@ async fn rejects_rapid_login_requests_with_429() {
api_rate_limiter: crank_runtime::RequestRateLimiter::new(
crank_runtime::RequestRateLimitConfig::new(1, 1).unwrap(),
),
trust_forwarded_headers: false,
trusted_proxy_ips: Vec::new(),
});
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
let handle = tokio::spawn(async move {
@@ -195,3 +242,119 @@ async fn login_uses_identity_provider_when_configured() {
};
assert_eq!(error.to_string(), "invalid email or password");
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn bootstrap_token_creates_first_admin_once_and_never_replays() {
let registry = empty_registry("test_admin_api_bootstrap_token").await;
let token = "bootstrap-token-story-1-12-local-operator-only";
registry
.create_admin_bootstrap_contract(CreateAdminBootstrapContractRequest {
id: "boot_test_story_1_12",
token_hash: &bootstrap_token_hash(token),
email: "first-owner@crank.local",
display_name: "First Owner",
expires_at: &(time::OffsetDateTime::now_utc() + time::Duration::minutes(15)),
})
.await
.unwrap();
let storage_root = test_storage_root("bootstrap_token");
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let root_url = base_url
.as_ref()
.split("/api/admin/workspaces/")
.next()
.unwrap()
.to_owned();
let client = reqwest::Client::builder()
.cookie_store(true)
.build()
.unwrap();
let status = assert_success_json(
client
.get(format!("{root_url}/api/auth/bootstrap/status"))
.send()
.await
.unwrap(),
)
.await;
assert_eq!(status["bootstrap_required"], true);
let completed = assert_success_json(
client
.post(format!("{root_url}/api/auth/bootstrap/complete"))
.json(&json!({
"token": token,
"password": "first-owner-password-123"
}))
.send()
.await
.unwrap(),
)
.await;
assert_eq!(completed["user"]["email"], "first-owner@crank.local");
assert!(completed["csrf_token"].as_str().unwrap().len() >= 32);
let replay = client
.post(format!("{root_url}/api/auth/bootstrap/complete"))
.json(&json!({
"token": token,
"password": "second-password-should-not-work"
}))
.send()
.await
.unwrap();
assert_eq!(replay.status(), reqwest::StatusCode::UNAUTHORIZED);
let status_after = assert_success_json(
client
.get(format!("{root_url}/api/auth/bootstrap/status"))
.send()
.await
.unwrap(),
)
.await;
assert_eq!(status_after["bootstrap_required"], false);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn browser_mutations_require_csrf_and_reject_cross_origin_requests() {
let registry = test_registry().await;
let storage_root = test_storage_root("csrf_and_origin");
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let root_url = base_url
.as_ref()
.split("/api/admin/workspaces/")
.next()
.unwrap()
.to_owned();
let client = login_client_without_csrf(&root_url).await;
let missing_csrf = client
.patch(format!("{root_url}/api/auth/profile"))
.json(&json!({
"display_name": "Blocked Without CSRF",
"email": TEST_AUTH_EMAIL
}))
.send()
.await
.unwrap();
assert_eq!(missing_csrf.status(), reqwest::StatusCode::FORBIDDEN);
let missing_body = missing_csrf.text().await.unwrap();
assert!(!missing_body.contains(TEST_SESSION_SECRET));
assert!(!missing_body.contains(TEST_AUTH_PASSWORD));
let cross_origin = client
.get(format!("{root_url}/api/auth/profile"))
.header("origin", "https://attacker.example")
.send()
.await
.unwrap();
assert_eq!(cross_origin.status(), reqwest::StatusCode::FORBIDDEN);
let cross_origin_body = cross_origin.text().await.unwrap();
assert!(!cross_origin_body.contains(TEST_SESSION_SECRET));
assert!(!cross_origin_body.contains(TEST_AUTH_PASSWORD));
}
+102 -8
View File
@@ -10,12 +10,12 @@ use std::{
use async_trait::async_trait;
use axum::{Json, Router, routing::post};
use crank_core::{
ExecutionConfig, HttpMethod, MembershipRole, OperationSecurityLevel, Protocol,
AuditSink, ExecutionConfig, HttpMethod, MembershipRole, OperationSecurityLevel, Protocol,
ResponseCachePolicy, RestTarget, SecretKind, Target, ToolDescription, WorkspaceId,
};
use crank_core::{IdentityError, IdentityProvider, IdentityProviderKind, LoginOutcome};
use crank_mapping::{MappingRule, MappingSet};
use crank_registry::PostgresRegistry;
use crank_registry::{MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate, PostgresRegistry};
use crank_runtime::SecretCrypto;
use crank_schema::{Schema, SchemaKind};
use serde_json::{Value, json};
@@ -34,7 +34,7 @@ const TEST_AUTH_EMAIL: &str = "owner@crank.local";
const TEST_AUTH_PASSWORD: &str = "test-password";
const TEST_PASSWORD_PEPPER: &str = "test-password-pepper";
const TEST_SESSION_SECRET: &str = "test-session-secret";
const TEST_MASTER_KEY: &str = "test-master-key";
const TEST_MASTER_KEY: &str = "test-master-key-00000000000000000000000000000000";
pub(super) struct TestServer {
base_url: String,
@@ -104,7 +104,32 @@ pub(super) fn build_test_app(
api_rate_limiter: crank_runtime::RequestRateLimiter::new(
crank_runtime::RequestRateLimitConfig::new(10_000, 10_000).unwrap(),
),
trust_forwarded_headers: false,
trusted_proxy_ips: Vec::new(),
})
}
pub(super) fn build_test_app_with_audit_sink(
registry: PostgresRegistry,
storage_root: std::path::PathBuf,
audit_sink: Arc<dyn AuditSink>,
) -> Router {
let outbound_policy = crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]);
let runtime = crank_runtime::community_with_outbound_policy(outbound_policy.clone()).build();
build_app(AppState {
service: AdminServiceBuilder::new(
registry,
storage_root,
test_auth_settings(),
test_secret_crypto(),
runtime,
)
.with_outbound_http_policy(outbound_policy)
.with_audit_sink(audit_sink)
.build(),
api_rate_limiter: crank_runtime::RequestRateLimiter::new(
crank_runtime::RequestRateLimitConfig::new(10_000, 10_000).unwrap(),
),
trusted_proxy_ips: Vec::new(),
})
}
@@ -169,12 +194,13 @@ pub(super) async fn authorized_client(workspace_base_url: impl AsRef<str>) -> re
.split("/api/admin/workspaces/")
.next()
.unwrap();
let client = reqwest::Client::builder()
.cookie_store(true)
let cookie_jar = Arc::new(reqwest::cookie::Jar::default());
let login_client = reqwest::Client::builder()
.cookie_provider(cookie_jar.clone())
.build()
.unwrap();
client
let login = login_client
.post(format!("{root_url}/api/auth/login"))
.json(&json!({
"email": TEST_AUTH_EMAIL,
@@ -184,9 +210,67 @@ pub(super) async fn authorized_client(workspace_base_url: impl AsRef<str>) -> re
.await
.unwrap()
.error_for_status()
.unwrap()
.json::<Value>()
.await
.unwrap();
let csrf_token = login["csrf_token"]
.as_str()
.expect("login response must include csrf_token");
let mut default_headers = reqwest::header::HeaderMap::new();
default_headers.insert(
"x-csrf-token",
reqwest::header::HeaderValue::from_str(csrf_token).unwrap(),
);
client
reqwest::Client::builder()
.cookie_provider(cookie_jar)
.default_headers(default_headers)
.build()
.unwrap()
}
pub(super) async fn operation_etag(
client: &reqwest::Client,
workspace_base_url: impl AsRef<str>,
operation_id: &str,
) -> String {
let response = client
.get(format!(
"{}/operations/{operation_id}",
workspace_base_url.as_ref()
))
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::OK);
response
.headers()
.get(reqwest::header::ETAG)
.expect("operation detail must expose an ETag")
.to_str()
.unwrap()
.to_owned()
}
pub(super) async fn agent_etag(
client: &reqwest::Client,
workspace_base_url: impl AsRef<str>,
agent_id: &str,
) -> String {
let response = client
.get(format!("{}/agents/{agent_id}", workspace_base_url.as_ref()))
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::OK);
response
.headers()
.get(reqwest::header::ETAG)
.expect("agent detail must expose an ETag")
.to_str()
.unwrap()
.to_owned()
}
pub(super) async fn assert_success_json(response: reqwest::Response) -> Value {
@@ -216,6 +300,16 @@ pub(super) async fn test_registry() -> PostgresRegistry {
.await
.unwrap();
let registry = PostgresRegistry::connect(&database_url).await.unwrap();
let secret_crypto = test_secret_crypto();
registry
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
epoch: secret_crypto.master_key_epoch(),
fingerprint: secret_crypto.master_key_fingerprint(),
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &time::OffsetDateTime::now_utc(),
})
.await
.unwrap();
let password_hash = hash_password(TEST_AUTH_PASSWORD, TEST_PASSWORD_PEPPER).unwrap();
let user_id = registry
.upsert_bootstrap_user(TEST_AUTH_EMAIL, "Test Owner", &password_hash)
@@ -39,7 +39,7 @@ const TEST_AUTH_EMAIL: &str = "owner@crank.local";
const TEST_AUTH_PASSWORD: &str = "test-password";
const TEST_PASSWORD_PEPPER: &str = "test-password-pepper";
const TEST_SESSION_SECRET: &str = "test-session-secret";
const TEST_MASTER_KEY: &str = "test-master-key";
const TEST_MASTER_KEY: &str = "test-master-key-00000000000000000000000000000000";
struct TestServer {
base_url: String,
@@ -478,6 +478,11 @@ async fn seeds_demo_assets_for_live_ui() {
&WorkspaceId::new(DEFAULT_WORKSPACE_ID),
&smoke_operation_id,
smoke_operation.version,
&crank_registry::OperationStateExpectation {
current_draft_version: smoke_operation.version,
status: crank_core::OperationStatus::Draft,
latest_published_version: None,
},
)
.await
.unwrap();
@@ -508,6 +513,7 @@ async fn seeds_demo_assets_for_live_ui() {
enabled: true,
}]
.into(),
None,
)
.await
.unwrap();
@@ -516,6 +522,7 @@ async fn seeds_demo_assets_for_live_ui() {
&WorkspaceId::new(DEFAULT_WORKSPACE_ID),
&smoke_agent_id,
smoke_agent.version,
None,
)
.await
.unwrap();
@@ -573,17 +580,22 @@ async fn seeds_demo_assets_for_live_ui() {
&default_workspace_id,
admin_api::service::LogsQuery {
level: None,
status: None,
search: None,
source: None,
operation_id: None,
agent_id: None,
outcome_group: None,
created_after: None,
created_before: None,
period: None,
cursor: None,
limit: Some(20),
},
)
.await
.unwrap();
assert!(!logs.is_empty());
assert!(!logs.items.is_empty());
}
#[tokio::test(flavor = "multi_thread")]
@@ -646,9 +658,11 @@ async fn updates_profile_and_changes_password() {
updated_profile["user"]["email"],
"updated-owner@crank.local"
);
let updated_csrf_token = updated_profile["csrf_token"].as_str().unwrap();
let password_status = client
.post(format!("{root_url}/api/auth/password"))
.header("x-csrf-token", updated_csrf_token)
.json(&json!({
"current_password": TEST_AUTH_PASSWORD,
"new_password": "updated-password-123"
@@ -671,7 +685,7 @@ async fn updates_profile_and_changes_password() {
.await
.unwrap()
.status();
assert_eq!(current_session_status, reqwest::StatusCode::OK);
assert_eq!(current_session_status, reqwest::StatusCode::UNAUTHORIZED);
assert_eq!(other_session_status, reqwest::StatusCode::UNAUTHORIZED);
let relogin_client = reqwest::Client::builder()
@@ -741,87 +755,6 @@ async fn rejects_multi_workspace_session_switching_in_community() {
assert_eq!(session["current_workspace_id"], DEFAULT_WORKSPACE_ID);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn exposes_logs_and_usage_from_real_test_runs() {
let registry = test_registry().await;
let storage_root = test_storage_root("observability");
let upstream_base_url = spawn_upstream_server().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let created = client
.post(format!("{base_url}/operations"))
.json(&test_operation_payload(
&upstream_base_url,
"crm_observability",
))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let operation_id = created["operation_id"].as_str().unwrap().to_owned();
client
.post(format!("{base_url}/operations/{operation_id}/test-runs"))
.json(&json!({
"version": 1,
"input": { "email": "user@example.com" }
}))
.send()
.await
.unwrap();
let logs = client
.get(format!("{base_url}/logs?period=7d"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let log_id = logs["items"][0]["log"]["id"].as_str().unwrap().to_owned();
let log_detail = client
.get(format!("{base_url}/logs/{log_id}"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let usage = client
.get(format!("{base_url}/usage?period=7d"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let operation_usage = client
.get(format!(
"{base_url}/usage/operations/{operation_id}?period=7d"
))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(logs["items"][0]["log"]["source"], "admin_test_run");
assert_eq!(logs["items"][0]["operation_name"], "crm_observability");
assert_eq!(log_detail["log"]["status"], "ok");
assert_eq!(usage["summary"]["rollup"]["calls_total"], 1);
assert_eq!(usage["summary"]["rollup"]["calls_ok"], 1);
assert_eq!(
usage["operations"][0]["operation_name"],
"crm_observability"
);
assert_eq!(operation_usage["rollup"]["calls_total"], 1);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn preserves_request_id_for_test_run_invocations() {
@@ -0,0 +1,994 @@
#![allow(dead_code, unused_imports)]
use super::common::*;
use std::sync::{Arc, Mutex};
use axum::{Json, Router, http::HeaderMap, routing::post};
use crank_core::{AuditError, AuditEvent, AuditSink, PlatformApiKeyKind, SecretKind};
use serde_json::{Value, json};
use serial_test::serial;
use tokio::net::TcpListener;
#[derive(Clone, Default)]
struct RecordingAuditSink {
events: Arc<Mutex<Vec<AuditEvent>>>,
}
impl RecordingAuditSink {
fn events(&self) -> Vec<AuditEvent> {
self.events.lock().unwrap().clone()
}
}
#[async_trait::async_trait]
impl AuditSink for RecordingAuditSink {
async fn record(&self, event: AuditEvent) -> Result<(), AuditError> {
self.events.lock().unwrap().push(event);
Ok(())
}
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn machine_key_raw_value_is_create_only_and_duplicate_name_is_typed_conflict() {
let registry = test_registry().await;
let storage_root = test_storage_root("credential_lifecycle_keys");
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let agent_id = create_agent(&client, &base_url, "credential-lifecycle-agent").await;
let created = post_json(
&client,
format!("{base_url}/agents/{agent_id}/platform-api-keys"),
json!({"name": "primary-mcp-key", "key_kind": PlatformApiKeyKind::McpClient, "scopes": ["read", "write"]}),
)
.await;
let raw_key = created["secret"]
.as_str()
.expect("create response must disclose the raw key once")
.to_owned();
let random_part = raw_key
.strip_prefix("crk_")
.expect("MCP key must use the MCP bearer marker");
assert_eq!(random_part.len(), 43, "32 raw bytes in unpadded base64url");
assert!(
random_part
.bytes()
.all(|byte| { byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_') })
);
assert_eq!(
created["connection"]["endpoint"],
"http://localhost:3000/mcp/v1/default/credential-lifecycle-agent"
);
assert!(
created["connection"]["clients"]
.as_array()
.is_some_and(|clients| !clients.is_empty())
);
let approval = post_json(
&client,
format!("{base_url}/agents/{agent_id}/platform-api-keys"),
json!({"name": "approval-without-mcp-config", "key_kind": PlatformApiKeyKind::Approval, "scopes": ["read_pending"]}),
)
.await;
assert!(approval.get("connection").is_none());
let listed_text = client
.get(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert!(!listed_text.contains(&raw_key));
assert!(!listed_text.contains("secret_hash"));
assert!(!listed_text.contains("\"secret\""));
let duplicate = client
.post(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
.json(&json!({
"name": "primary-mcp-key",
"key_kind": PlatformApiKeyKind::McpClient,
"scopes": ["read"]
}))
.send()
.await
.unwrap();
assert_eq!(duplicate.status(), reqwest::StatusCode::CONFLICT);
let duplicate_body = duplicate.text().await.unwrap();
assert!(!duplicate_body.contains(&raw_key));
assert!(!duplicate_body.contains("secret_hash"));
assert!(!duplicate_body.contains("duplicate key value"));
let duplicate_json: Value = serde_json::from_str(&duplicate_body).unwrap();
assert_eq!(
duplicate_json["error"]["code"],
"platform_api_key_name_conflict"
);
for payload in [
json!({"name": "n".repeat(129), "key_kind": PlatformApiKeyKind::McpClient, "scopes": ["read"]}),
json!({"name": "duplicate-scope", "key_kind": PlatformApiKeyKind::McpClient, "scopes": ["read", "read"]}),
json!({"name": "past-expiry", "key_kind": PlatformApiKeyKind::McpClient, "scopes": ["read"], "expires_at": "2020-01-01T00:00:00Z"}),
] {
assert_post_error(
&client,
format!("{base_url}/agents/{agent_id}/platform-api-keys"),
payload,
reqwest::StatusCode::BAD_REQUEST,
"validation_error",
)
.await;
}
let canonical = post_json(
&client,
format!("{base_url}/agents/{agent_id}/platform-api-keys"),
json!({"name": "canonical-approval-origin", "key_kind": PlatformApiKeyKind::Approval, "scopes": ["read_pending"], "allowed_origins": ["HTTPS://Example.COM:443/", "http://EXAMPLE.com:80/"]}),
)
.await;
assert_eq!(
canonical["api_key"]["api_key"]["allowed_origins"],
json!(["https://example.com", "http://example.com"])
);
for (index, origin) in [
"https://[::1",
"https://example.test:bad/",
"https://user:pass@example.test/",
"https://example.test/path",
"https://example.test/?token=origin-canary",
"https://example.test/#fragment",
]
.into_iter()
.enumerate()
{
assert_post_error(
&client,
format!("{base_url}/agents/{agent_id}/platform-api-keys"),
json!({"name": format!("malformed-origin-{index}"), "key_kind": PlatformApiKeyKind::Approval, "scopes": ["read_pending"], "allowed_origins": [origin]}),
reqwest::StatusCode::BAD_REQUEST,
"validation_error",
)
.await;
}
assert_post_error(
&client,
format!("{base_url}/agents/{agent_id}/platform-api-keys"),
json!({"name": "duplicate-canonical-origin", "key_kind": PlatformApiKeyKind::Approval, "scopes": ["read_pending"], "allowed_origins": ["https://EXAMPLE.test:443/", "https://example.test/"]}),
reqwest::StatusCode::BAD_REQUEST,
"validation_error",
)
.await;
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn deleted_machine_key_name_can_be_reused_and_terminal_state_cannot_regress() {
let registry = test_registry().await;
let storage_root = test_storage_root("credential_lifecycle_key_reuse");
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let agent_id = create_agent(&client, &base_url, "credential-key-reuse-agent").await;
let first = assert_success_json(
client
.post(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
.json(&json!({
"name": "replaceable-key",
"key_kind": PlatformApiKeyKind::McpClient,
"scopes": ["read"]
}))
.send()
.await
.unwrap(),
)
.await;
let first_key_id = first["api_key"]["api_key"]["id"].as_str().unwrap();
let delete_response = client
.delete(format!(
"{base_url}/agents/{agent_id}/platform-api-keys/{first_key_id}"
))
.send()
.await
.unwrap();
assert_eq!(delete_response.status(), reqwest::StatusCode::NO_CONTENT);
let revoke_deleted = client
.post(format!(
"{base_url}/agents/{agent_id}/platform-api-keys/{first_key_id}/revoke"
))
.send()
.await
.unwrap();
assert_eq!(revoke_deleted.status(), reqwest::StatusCode::CONFLICT);
let revoke_body: Value = serde_json::from_str(&revoke_deleted.text().await.unwrap()).unwrap();
assert_eq!(revoke_body["error"]["code"], "platform_api_key_not_active");
let second = assert_success_json(
client
.post(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
.json(&json!({
"name": "replaceable-key",
"key_kind": PlatformApiKeyKind::McpClient,
"scopes": ["read"]
}))
.send()
.await
.unwrap(),
)
.await;
let second_key_id = second["api_key"]["api_key"]["id"].as_str().unwrap();
assert_ne!(first_key_id, second_key_id);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn secret_plaintext_is_write_only_and_auth_profile_stores_reference() {
let registry = test_registry().await;
let storage_root = test_storage_root("credential_lifecycle_secrets");
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let canary = "secret-canary-1-10-do-not-leak";
let created_response = client
.post(format!("{base_url}/secrets"))
.json(&json!({
"name": "crm-bearer-token",
"kind": SecretKind::Token,
"value": { "token": canary }
}))
.send()
.await
.unwrap();
let created_text = created_response.text().await.unwrap();
assert!(!created_text.contains(canary));
assert!(!created_text.contains("ciphertext"));
let created: Value = serde_json::from_str(&created_text).unwrap();
let secret_id = created["id"].as_str().unwrap();
let listed_text = client
.get(format!("{base_url}/secrets"))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert!(!listed_text.contains(canary));
assert!(!listed_text.contains("ciphertext"));
let fetched_text = client
.get(format!("{base_url}/secrets/{secret_id}"))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert!(!fetched_text.contains(canary));
assert!(!fetched_text.contains("ciphertext"));
let auth_profile_text = client
.post(format!("{base_url}/auth-profiles"))
.json(&json!({
"name": "crm-bearer",
"kind": "bearer",
"config": {
"bearer": {
"header_name": "Authorization",
"secret_id": secret_id
}
}
}))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert!(!auth_profile_text.contains(canary));
assert!(!auth_profile_text.contains("ciphertext"));
let auth_profile: Value = serde_json::from_str(&auth_profile_text).unwrap();
assert_eq!(auth_profile["config"]["bearer"]["secret_id"], secret_id);
for (name, kind, value) in [
("null-token", SecretKind::Token, Value::Null),
(
"wrong-token-shape",
SecretKind::Token,
json!({"token": "token", "extra": "field"}),
),
(
"wrong-header-shape",
SecretKind::Header,
json!({"header_name": "X-Token"}),
),
(
"wrong-username-password-shape",
SecretKind::UsernamePassword,
json!({"username": "user"}),
),
] {
assert_post_error(
&client,
format!("{base_url}/secrets"),
json!({"name": name, "kind": kind, "value": value}),
reqwest::StatusCode::BAD_REQUEST,
"validation_error",
)
.await;
}
assert_post_error(
&client,
format!("{base_url}/secrets"),
json!({"name": "oversized-secret", "kind": SecretKind::Generic, "value": "x".repeat(65_537)}),
reqwest::StatusCode::BAD_REQUEST,
"validation_error",
)
.await;
for value in [
Value::Null,
json!({"token": "token", "extra": "field"}),
json!("x".repeat(65_537)),
] {
assert_post_error(
&client,
format!("{base_url}/secrets/{secret_id}/rotate"),
json!({"value": value}),
reqwest::StatusCode::BAD_REQUEST,
"validation_error",
)
.await;
}
let current = assert_success_json(
client
.get(format!("{base_url}/secrets/{secret_id}"))
.send()
.await
.unwrap(),
)
.await;
assert_eq!(current["current_version"], 1);
for payload in [
json!({"name": "", "kind": "bearer", "config": {"bearer": {"header_name": "Authorization", "secret_id": secret_id}}}),
json!({"name": "n".repeat(129), "kind": "bearer", "config": {"bearer": {"header_name": "Authorization", "secret_id": secret_id}}}),
json!({"name": "invalid-header-name", "kind": "bearer", "config": {"bearer": {"header_name": "Bad Header", "secret_id": secret_id}}}),
json!({"name": "invalid-query-name", "kind": "api_key_query", "config": {"api_key_query": {"param_name": "bad name", "secret_id": secret_id}}}),
] {
assert_post_error(
&client,
format!("{base_url}/auth-profiles"),
payload,
reqwest::StatusCode::BAD_REQUEST,
"validation_error",
)
.await;
}
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn credential_mutations_emit_bounded_non_secret_audit_events() {
let registry = test_registry().await;
let storage_root = test_storage_root("credential_lifecycle_audit");
let audit_sink = RecordingAuditSink::default();
let base_url = spawn_admin_api(build_test_app_with_audit_sink(
registry,
storage_root,
Arc::new(audit_sink.clone()),
))
.await;
let client = authorized_client(&base_url).await;
let request_id = "req_credential_audit_1";
let traceparent = "00-11111111111111111111111111111111-2222222222222222-01";
let secret = assert_success_json(
client
.post(format!("{base_url}/secrets"))
.header("x-request-id", request_id)
.header("traceparent", traceparent)
.json(&json!({
"name": "audit-token",
"kind": SecretKind::Token,
"value": { "token": "audit-secret-canary" }
}))
.send()
.await
.unwrap(),
)
.await;
let secret_id = secret["id"].as_str().unwrap();
assert_success_json(
client
.post(format!("{base_url}/secrets/{secret_id}/rotate"))
.header("x-request-id", request_id)
.header("traceparent", traceparent)
.json(&json!({
"value": { "token": "audit-rotated-canary" }
}))
.send()
.await
.unwrap(),
)
.await;
let auth_profile = assert_success_json(
client
.post(format!("{base_url}/auth-profiles"))
.header("x-request-id", request_id)
.header("traceparent", traceparent)
.json(&json!({
"name": "audit-profile",
"kind": "bearer",
"config": {
"bearer": {
"header_name": "Authorization",
"secret_id": secret_id
}
}
}))
.send()
.await
.unwrap(),
)
.await;
let auth_profile_id = auth_profile["id"].as_str().unwrap();
let delete_denied = client
.delete(format!("{base_url}/secrets/{secret_id}"))
.header("x-request-id", request_id)
.header("traceparent", traceparent)
.send()
.await
.unwrap();
assert_eq!(delete_denied.status(), reqwest::StatusCode::CONFLICT);
let created_agent = assert_success_json(
client
.post(format!("{base_url}/agents"))
.header("x-request-id", request_id)
.header("traceparent", traceparent)
.json(&json!({
"slug": "credential-audit-agent",
"display_name": "Credential Audit Agent",
"description": "Agent for credential audit tests.",
"instructions": {},
"tool_selection_policy": {}
}))
.send()
.await
.unwrap(),
)
.await;
let agent_id = created_agent["agent_id"].as_str().unwrap();
let created_key = assert_success_json(
client
.post(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
.header("x-request-id", request_id)
.header("traceparent", traceparent)
.json(&json!({
"name": "audit-mcp-key",
"key_kind": PlatformApiKeyKind::McpClient,
"scopes": ["read"]
}))
.send()
.await
.unwrap(),
)
.await;
let raw_key = created_key["secret"].as_str().unwrap();
let key_id = created_key["api_key"]["api_key"]["id"].as_str().unwrap();
let duplicate_key = client
.post(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
.header("x-request-id", request_id)
.header("traceparent", traceparent)
.json(&json!({
"name": "audit-mcp-key",
"key_kind": PlatformApiKeyKind::McpClient,
"scopes": ["read"]
}))
.send()
.await
.unwrap();
assert_eq!(duplicate_key.status(), reqwest::StatusCode::CONFLICT);
let validation_key = client
.post(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
.header("x-request-id", request_id)
.header("traceparent", traceparent)
.json(&json!({
"name": "n".repeat(129),
"key_kind": PlatformApiKeyKind::McpClient,
"scopes": ["read"]
}))
.send()
.await
.unwrap();
assert_eq!(validation_key.status(), reqwest::StatusCode::BAD_REQUEST);
let invalid_profile = client
.post(format!("{base_url}/auth-profiles"))
.header("x-request-id", request_id)
.header("traceparent", traceparent)
.json(&json!({
"name": "audit-invalid-profile",
"kind": "bearer",
"config": {
"bearer": {
"header_name": "Authorization",
"secret_id": "secret_missing_for_audit"
}
}
}))
.send()
.await
.unwrap();
assert_eq!(invalid_profile.status(), reqwest::StatusCode::NOT_FOUND);
client
.post(format!(
"{base_url}/agents/{agent_id}/platform-api-keys/{key_id}/revoke"
))
.header("x-request-id", request_id)
.header("traceparent", traceparent)
.send()
.await
.unwrap();
let delete_response = client
.delete(format!(
"{base_url}/agents/{agent_id}/platform-api-keys/{key_id}"
))
.header("x-request-id", request_id)
.header("traceparent", traceparent)
.send()
.await
.unwrap();
assert_eq!(delete_response.status(), reqwest::StatusCode::NO_CONTENT);
let listed_after_delete = assert_success_json(
client
.get(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
.send()
.await
.unwrap(),
)
.await;
let deleted_metadata = listed_after_delete["items"]
.as_array()
.unwrap()
.iter()
.find(|item| item["api_key"]["id"] == key_id)
.expect("delete must preserve key metadata for provenance");
assert_eq!(deleted_metadata["api_key"]["status"], "deleted");
let listed_after_delete_text = serde_json::to_string(&listed_after_delete).unwrap();
assert!(!listed_after_delete_text.contains(raw_key));
let events = audit_sink.events();
let actions: Vec<&str> = events.iter().map(|event| event.action.as_str()).collect();
for expected in [
"credential.secret.created",
"credential.secret.rotated",
"credential.auth_profile.created",
"credential.secret.delete_denied",
"credential.platform_api_key.created",
"credential.platform_api_key.create_failed",
"credential.auth_profile.create_failed",
"credential.platform_api_key.revoked",
"credential.platform_api_key.deleted",
] {
assert!(
actions.contains(&expected),
"missing audit action {expected}"
);
}
let audit_json = serde_json::to_string(&events).unwrap();
assert!(audit_json.contains(request_id));
assert!(audit_json.contains("11111111111111111111111111111111"));
assert!(audit_json.contains(auth_profile_id));
assert!(!audit_json.contains("audit-secret-canary"));
assert!(!audit_json.contains("audit-rotated-canary"));
assert!(!audit_json.contains(raw_key));
assert!(!audit_json.contains("secret_hash"));
assert!(!audit_json.contains("ciphertext"));
assert!(!audit_json.contains("Bearer "));
for event in &events {
let reason = event.payload["reason"]
.as_str()
.expect("credential audit events must contain a reason code");
assert!(!reason.is_empty());
assert!(reason.len() <= 128, "audit reason must remain bounded");
}
assert!(events.iter().any(|event| {
event.action == "credential.platform_api_key.create_failed"
&& event.payload["reason"] == "validation_error"
}));
assert!(events.iter().any(|event| {
event.action == "credential.platform_api_key.create_failed"
&& event.payload["reason"] == "platform_api_key_name_conflict"
}));
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn disabled_secret_fails_closed_for_reference_rotation_and_execution() {
let registry = test_registry().await;
let registry_control = registry.clone();
let storage_root = test_storage_root("credential_lifecycle_disabled_secret");
let (upstream_base_url, observed_authorizations) = spawn_auth_capture_upstream().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let secret = assert_success_json(
client
.post(format!("{base_url}/secrets"))
.json(&json!({
"name": "disabled-token",
"kind": SecretKind::Token,
"value": { "token": "disabled-secret-canary" }
}))
.send()
.await
.unwrap(),
)
.await;
let secret_id = secret["id"].as_str().unwrap();
let auth_profile = assert_success_json(
client
.post(format!("{base_url}/auth-profiles"))
.json(&json!({
"name": "disabled-profile",
"kind": "bearer",
"config": {
"bearer": {
"header_name": "Authorization",
"secret_id": secret_id
}
}
}))
.send()
.await
.unwrap(),
)
.await;
let auth_profile_id = auth_profile["id"].as_str().unwrap();
let mut operation_payload = serde_json::to_value(test_operation_payload(
&upstream_base_url,
"disabled_secret_execution",
))
.unwrap();
operation_payload["execution_config"]["auth_profile_ref"] = json!(auth_profile_id);
let created_operation = assert_success_json(
client
.post(format!("{base_url}/operations"))
.json(&operation_payload)
.send()
.await
.unwrap(),
)
.await;
let operation_id = created_operation["operation_id"].as_str().unwrap();
sqlx::query("update secrets set status = 'disabled' where id = $1")
.bind(secret_id)
.execute(registry_control.pool())
.await
.unwrap();
let ref_disabled = client
.post(format!("{base_url}/auth-profiles"))
.json(&json!({
"name": "disabled-profile-new-ref",
"kind": "bearer",
"config": {
"bearer": {
"header_name": "Authorization",
"secret_id": secret_id
}
}
}))
.send()
.await
.unwrap();
assert_eq!(ref_disabled.status(), reqwest::StatusCode::CONFLICT);
let ref_disabled_body: Value =
serde_json::from_str(&ref_disabled.text().await.unwrap()).unwrap();
assert_eq!(ref_disabled_body["error"]["code"], "secret_not_active");
let rotate_disabled = client
.post(format!("{base_url}/secrets/{secret_id}/rotate"))
.json(&json!({
"value": { "token": "should-not-rotate" }
}))
.send()
.await
.unwrap();
assert_eq!(rotate_disabled.status(), reqwest::StatusCode::CONFLICT);
let rotate_body: Value = serde_json::from_str(&rotate_disabled.text().await.unwrap()).unwrap();
assert_eq!(rotate_body["error"]["code"], "secret_not_active");
let test_run = assert_success_json(
client
.post(format!("{base_url}/operations/{operation_id}/test-runs"))
.json(&json!({
"version": 1,
"input": { "email": "disabled@example.com" }
}))
.send()
.await
.unwrap(),
)
.await;
assert_eq!(test_run["ok"], false);
let body = serde_json::to_string(&test_run).unwrap();
assert!(!body.contains("disabled-secret-canary"));
assert!(observed_authorizations.lock().unwrap().is_empty());
let operation_get = client
.get(format!("{base_url}/operations/{operation_id}"))
.send()
.await
.unwrap();
let operation_etag = operation_get
.headers()
.get(reqwest::header::ETAG)
.unwrap()
.to_str()
.unwrap()
.to_owned();
let publish_disabled = client
.post(format!("{base_url}/operations/{operation_id}/publish"))
.header(reqwest::header::IF_MATCH, operation_etag)
.json(&json!({ "version": 1 }))
.send()
.await
.unwrap();
assert_eq!(publish_disabled.status(), reqwest::StatusCode::CONFLICT);
let publish_body: Value =
serde_json::from_str(&publish_disabled.text().await.unwrap()).unwrap();
assert_eq!(publish_body["error"]["code"], "secret_not_active");
assert!(observed_authorizations.lock().unwrap().is_empty());
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn concurrent_secret_rotation_serializes_versions_without_storage_conflict() {
let registry = test_registry().await;
let storage_root = test_storage_root("credential_lifecycle_concurrent_rotation");
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let secret = assert_success_json(
client
.post(format!("{base_url}/secrets"))
.json(&json!({
"name": "concurrent-token",
"kind": SecretKind::Token,
"value": { "token": "initial-concurrent-token" }
}))
.send()
.await
.unwrap(),
)
.await;
let secret_id = secret["id"].as_str().unwrap().to_owned();
let first = client
.post(format!("{base_url}/secrets/{secret_id}/rotate"))
.json(&json!({ "value": { "token": "concurrent-token-a" } }))
.send();
let second = client
.post(format!("{base_url}/secrets/{secret_id}/rotate"))
.json(&json!({ "value": { "token": "concurrent-token-b" } }))
.send();
let (first, second) = tokio::join!(first, second);
let first = assert_success_json(first.unwrap()).await;
let second = assert_success_json(second.unwrap()).await;
assert!(first["current_version"].as_u64().unwrap() >= 2);
assert!(second["current_version"].as_u64().unwrap() >= 2);
let current = assert_success_json(
client
.get(format!("{base_url}/secrets/{secret_id}"))
.send()
.await
.unwrap(),
)
.await;
assert_eq!(current["current_version"], 3);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn auth_profile_secret_rotation_changes_next_admin_test_execution() {
let registry = test_registry().await;
let storage_root = test_storage_root("credential_lifecycle_rotation");
let (upstream_base_url, observed_authorizations) = spawn_auth_capture_upstream().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let secret = assert_success_json(
client
.post(format!("{base_url}/secrets"))
.json(&json!({
"name": "crm-rotating-token",
"kind": SecretKind::Token,
"value": { "token": "initial-rotation-token" }
}))
.send()
.await
.unwrap(),
)
.await;
let secret_id = secret["id"].as_str().unwrap();
let auth_profile = assert_success_json(
client
.post(format!("{base_url}/auth-profiles"))
.json(&json!({
"name": "crm-rotating-bearer",
"kind": "bearer",
"config": {
"bearer": {
"header_name": "Authorization",
"secret_id": secret_id
}
}
}))
.send()
.await
.unwrap(),
)
.await;
let auth_profile_id = auth_profile["id"].as_str().unwrap();
let mut operation_payload = serde_json::to_value(test_operation_payload(
&upstream_base_url,
"crm_auth_rotation",
))
.unwrap();
operation_payload["execution_config"]["auth_profile_ref"] = json!(auth_profile_id);
let created_operation = assert_success_json(
client
.post(format!("{base_url}/operations"))
.json(&operation_payload)
.send()
.await
.unwrap(),
)
.await;
let operation_id = created_operation["operation_id"].as_str().unwrap();
let first_run = assert_success_json(
client
.post(format!("{base_url}/operations/{operation_id}/test-runs"))
.json(&json!({
"version": 1,
"input": { "email": "first@example.com" }
}))
.send()
.await
.unwrap(),
)
.await;
assert_eq!(first_run["ok"], true);
let rotated = assert_success_json(
client
.post(format!("{base_url}/secrets/{secret_id}/rotate"))
.json(&json!({
"value": { "token": "rotated-rotation-token" }
}))
.send()
.await
.unwrap(),
)
.await;
assert_eq!(rotated["current_version"], 2);
let second_run = assert_success_json(
client
.post(format!("{base_url}/operations/{operation_id}/test-runs"))
.json(&json!({
"version": 1,
"input": { "email": "second@example.com" }
}))
.send()
.await
.unwrap(),
)
.await;
assert_eq!(second_run["ok"], true);
let captured = observed_authorizations.lock().unwrap().clone();
assert_eq!(
captured,
vec![
Some("Bearer initial-rotation-token".to_owned()),
Some("Bearer rotated-rotation-token".to_owned())
]
);
let auth_profile_after = client
.get(format!("{base_url}/auth-profiles/{auth_profile_id}"))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert!(auth_profile_after.contains(secret_id));
assert!(!auth_profile_after.contains("initial-rotation-token"));
assert!(!auth_profile_after.contains("rotated-rotation-token"));
}
async fn spawn_auth_capture_upstream() -> (String, Arc<Mutex<Vec<Option<String>>>>) {
let observed_authorizations = Arc::new(Mutex::new(Vec::new()));
let captured = Arc::clone(&observed_authorizations);
let app = Router::new().route(
"/crm/leads",
post(move |headers: HeaderMap| {
let captured = Arc::clone(&captured);
async move {
let authorization = headers
.get("authorization")
.and_then(|value| value.to_str().ok())
.map(str::to_owned);
captured.lock().unwrap().push(authorization);
Json(json!({ "id": "lead_123" }))
}
}),
);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
(format!("http://{address}"), observed_authorizations)
}
async fn assert_error_code(response: reqwest::Response, status: reqwest::StatusCode, code: &str) {
assert_eq!(response.status(), status);
let body = response.text().await.unwrap();
let json: Value = serde_json::from_str(&body).unwrap();
assert_eq!(json["error"]["code"], code, "unexpected error body: {body}");
}
async fn post_json(client: &reqwest::Client, url: String, payload: Value) -> Value {
assert_success_json(client.post(url).json(&payload).send().await.unwrap()).await
}
async fn create_agent(client: &reqwest::Client, base_url: impl AsRef<str>, slug: &str) -> String {
let base_url = base_url.as_ref();
post_json(
client,
format!("{base_url}/agents"),
json!({"slug": slug, "display_name": slug, "description": "Credential lifecycle test agent.", "instructions": {}, "tool_selection_policy": {}}),
)
.await["agent_id"]
.as_str()
.unwrap()
.to_owned()
}
async fn assert_post_error(
client: &reqwest::Client,
url: String,
payload: Value,
status: reqwest::StatusCode,
code: &str,
) {
assert_error_code(
client.post(url).json(&payload).send().await.unwrap(),
status,
code,
)
.await;
}
@@ -0,0 +1,152 @@
#![allow(dead_code, unused_imports)]
use super::common::*;
use serde_json::{Value, json};
use serial_test::serial;
use time::{Duration, OffsetDateTime, format_description::well_known::Rfc3339};
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn exposes_logs_and_usage_from_real_test_runs() {
let registry = test_registry().await;
let storage_root = test_storage_root("observability");
let upstream_base_url = spawn_upstream_server().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let created = client
.post(format!("{base_url}/operations"))
.json(&test_operation_payload(
&upstream_base_url,
"crm_observability",
))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let operation_id = created["operation_id"].as_str().unwrap().to_owned();
client
.post(format!("{base_url}/operations/{operation_id}/test-runs"))
.json(&json!({
"version": 1,
"input": { "email": "user@example.com" }
}))
.send()
.await
.unwrap();
let logs = client
.get(format!("{base_url}/logs?period=7d"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert!(logs["items"].is_array());
assert!(logs.get("next_cursor").is_some());
let log_id = logs["items"][0]["log"]["id"].as_str().unwrap().to_owned();
let created_at = OffsetDateTime::parse(
logs["items"][0]["log"]["created_at"].as_str().unwrap(),
&Rfc3339,
)
.unwrap();
let log_detail = client
.get(format!("{base_url}/logs/{log_id}"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let csv = client
.get(format!("{base_url}/logs/export.csv?period=7d&status=ok"))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
let outcome_logs = client
.get(format!("{base_url}/logs?period=7d&outcome_group=success"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let created_after = (created_at - Duration::minutes(1))
.format(&Rfc3339)
.unwrap();
let created_before = (created_at + Duration::minutes(1))
.format(&Rfc3339)
.unwrap();
let usage = client
.get(format!("{base_url}/usage?period=7d"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let windowed_usage = client
.get(format!(
"{base_url}/usage?created_after={created_after}&created_before={created_before}"
))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let usage_csv = client
.get(format!("{base_url}/usage/export.csv?period=7d"))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
let operation_usage = client
.get(format!(
"{base_url}/usage/operations/{operation_id}?period=7d"
))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(logs["items"][0]["log"]["source"], "admin_test_run");
assert_eq!(logs["items"][0]["operation_name"], "crm_observability");
assert_eq!(log_detail["log"]["status"], "ok");
assert_eq!(log_detail["log"]["operation_version"], 1);
assert!(log_detail["log"]["request_id"].as_str().is_some());
assert!(
log_detail["log"]["trace_id"]
.as_str()
.is_some_and(|value| value.len() == 32)
);
assert!(csv.starts_with("created_at,level,status,source"));
assert!(csv.contains(log_detail["log"]["request_id"].as_str().unwrap()));
assert!(csv.contains(log_detail["log"]["trace_id"].as_str().unwrap()));
assert_eq!(outcome_logs["items"][0]["log"]["status"], "ok");
assert_eq!(usage["summary"]["rollup"]["calls_total"], 1);
assert_eq!(usage["summary"]["rollup"]["calls_ok"], 1);
assert_eq!(usage["outcomes"][0]["group"], "success");
assert_eq!(usage["outcomes"][0]["calls_total"], 1);
assert_eq!(windowed_usage["summary"]["rollup"]["calls_total"], 1);
assert!(usage_csv.starts_with("kind,name,group,error_code,calls_total"));
assert!(usage_csv.contains("operation"));
assert!(usage_csv.contains("outcome"));
assert_eq!(
usage["operations"][0]["operation_name"],
"crm_observability"
);
assert_eq!(operation_usage["rollup"]["calls_total"], 1);
}
@@ -0,0 +1,322 @@
#![allow(dead_code, unused_imports)]
use super::common::*;
use serde_json::{Value, json};
use serial_test::serial;
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn exposes_server_authoritative_onboarding_projection() {
let registry = test_registry().await;
let storage_root = test_storage_root("onboarding_projection");
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let response = client
.get(format!("{base_url}/onboarding"))
.send()
.await
.unwrap();
let status = response.status();
let body = response.json::<Value>().await.unwrap();
assert_eq!(status, reqwest::StatusCode::OK, "body={body}");
assert_eq!(body["schema_version"], 1);
assert_eq!(body["status"], "in_progress");
assert!(body["revision"].as_i64().is_some());
assert!(body["eligible_since"].as_str().is_some());
assert_eq!(body["workspace_id"], "ws_default");
assert_eq!(body["completed"], false);
assert_eq!(
body["steps"]
.as_array()
.unwrap()
.iter()
.map(|step| step["id"].as_str().unwrap())
.collect::<Vec<_>>(),
vec![
"operation",
"test",
"publish_operation",
"agent",
"key",
"mcp_connection",
"first_call",
]
);
assert!(
body["steps"]
.as_array()
.unwrap()
.iter()
.all(|step| step["completed"] == false)
);
assert_eq!(body["steps"][0]["status"], "current");
assert!(
body["steps"]
.as_array()
.unwrap()
.iter()
.skip(1)
.all(|step| step["status"] == "pending")
);
// The server-owned eligibility denominator is recorded on the first GET;
// a repeat is idempotent and cannot advance browser-owned completion.
let repeated = client
.get(format!("{base_url}/onboarding"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(repeated["revision"], body["revision"]);
assert_eq!(repeated["steps"], body["steps"]);
assert_eq!(repeated["eligible_since"], body["eligible_since"]);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn ux_milestones_are_idempotent_and_revision_guarded() {
let registry = test_registry().await;
let storage_root = test_storage_root("onboarding_events");
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let initial = client
.get(format!("{base_url}/onboarding"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let revision = initial["revision"].as_i64().unwrap();
let payload = json!({
"event": "started",
"idempotency_key": "onboarding-start-tab-a",
"expected_revision": revision,
});
let first = client
.post(format!("{base_url}/onboarding/events"))
.json(&payload)
.send()
.await
.unwrap();
let first_status = first.status();
let first = first.json::<Value>().await.unwrap();
assert_eq!(first_status, reqwest::StatusCode::OK, "body={first}");
assert_eq!(first["accepted"], true);
let accepted_revision = first["revision"].as_i64().unwrap();
assert_ne!(accepted_revision, revision);
let replay = client
.post(format!("{base_url}/onboarding/events"))
.json(&payload)
.send()
.await
.unwrap();
let replay_status = replay.status();
let replay = replay.json::<Value>().await.unwrap();
assert_eq!(replay_status, reqwest::StatusCode::OK, "body={replay}");
assert_eq!(replay["accepted"], false);
assert_eq!(replay["revision"], accepted_revision);
let semantic_conflict = client
.post(format!("{base_url}/onboarding/events"))
.json(&json!({
"event": "dismissed",
"idempotency_key": "onboarding-start-tab-a",
"expected_revision": revision,
}))
.send()
.await
.unwrap();
let semantic_conflict_status = semantic_conflict.status();
let semantic_conflict = semantic_conflict.json::<Value>().await.unwrap();
assert_eq!(
semantic_conflict_status,
reqwest::StatusCode::CONFLICT,
"body={semantic_conflict}"
);
assert_eq!(
semantic_conflict["error"]["code"],
"onboarding_idempotency_conflict"
);
let current_revision_conflict = client
.post(format!("{base_url}/onboarding/events"))
.json(&json!({
"event": "dismissed",
"idempotency_key": "onboarding-start-tab-a",
"expected_revision": accepted_revision,
}))
.send()
.await
.unwrap();
let current_revision_conflict_status = current_revision_conflict.status();
let current_revision_conflict = current_revision_conflict.json::<Value>().await.unwrap();
assert_eq!(
current_revision_conflict_status,
reqwest::StatusCode::CONFLICT,
"body={current_revision_conflict}"
);
assert_eq!(
current_revision_conflict["error"]["code"],
"onboarding_idempotency_conflict"
);
let stale = client
.post(format!("{base_url}/onboarding/events"))
.json(&json!({
"event": "dismissed",
"idempotency_key": "onboarding-dismiss-tab-b",
"expected_revision": revision,
}))
.send()
.await
.unwrap();
let stale_status = stale.status();
let stale = stale.json::<Value>().await.unwrap();
assert_eq!(stale_status, reqwest::StatusCode::CONFLICT, "body={stale}");
assert_eq!(stale["error"]["code"], "onboarding_stale_revision");
for payload in [
json!({
"event": "eligible",
"idempotency_key": "forged-eligibility",
"expected_revision": accepted_revision,
}),
json!({
"event": "completed",
"idempotency_key": "forged-completion",
"expected_revision": accepted_revision,
}),
json!({
"event": "started",
"idempotency_key": "forged-step",
"expected_revision": accepted_revision,
"completed_steps": ["operation", "first_call"],
}),
json!({
"event": "started",
"idempotency_key": "onboarding:completed:v1",
"expected_revision": accepted_revision,
}),
] {
let response = client
.post(format!("{base_url}/onboarding/events"))
.json(&payload)
.send()
.await
.unwrap();
let status = response.status();
let body = response.json::<Value>().await.unwrap();
assert_eq!(
status,
reqwest::StatusCode::UNPROCESSABLE_ENTITY,
"body={body}"
);
assert_eq!(body["error"]["code"], "onboarding_event_not_allowed");
}
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn direct_presentation_mutation_cannot_precede_server_eligibility() {
let registry = test_registry().await;
let storage_root = test_storage_root("onboarding_direct_event");
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let direct = client
.post(format!("{base_url}/onboarding/events"))
.json(&json!({
"event": "started",
"idempotency_key": "direct-before-snapshot",
"expected_revision": 0,
}))
.send()
.await
.unwrap();
let direct_status = direct.status();
let direct = direct.json::<Value>().await.unwrap();
assert_eq!(
direct_status,
reqwest::StatusCode::CONFLICT,
"body={direct}"
);
assert_eq!(direct["error"]["code"], "onboarding_stale_revision");
let snapshot = client
.get(format!("{base_url}/onboarding"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert!(snapshot["eligible_since"].as_str().is_some());
assert_eq!(snapshot["status"], "in_progress");
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn reset_selection_is_explicit_and_revision_guarded() {
let registry = test_registry().await;
let storage_root = test_storage_root("onboarding_reset_selection");
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let initial = client
.get(format!("{base_url}/onboarding"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let revision = initial["revision"].as_i64().unwrap();
let reset = client
.post(format!("{base_url}/onboarding/reset-selection"))
.json(&json!({"expected_revision": revision}))
.send()
.await
.unwrap();
let reset_status = reset.status();
let reset = reset.json::<Value>().await.unwrap();
assert_eq!(reset_status, reqwest::StatusCode::OK, "body={reset}");
assert_eq!(reset["selection_reset"], true);
assert_ne!(reset["revision"], initial["revision"]);
assert_eq!(reset["workspace_id"], initial["workspace_id"]);
let stale = client
.post(format!("{base_url}/onboarding/reset-selection"))
.json(&json!({"expected_revision": revision}))
.send()
.await
.unwrap();
let stale_status = stale.status();
let stale = stale.json::<Value>().await.unwrap();
assert_eq!(stale_status, reqwest::StatusCode::CONFLICT, "body={stale}");
assert_eq!(stale["error"]["code"], "onboarding_stale_revision");
let forged_selection = client
.post(format!("{base_url}/onboarding/reset-selection"))
.json(&json!({
"expected_revision": reset["revision"],
"operation_id": "op_client_selected",
}))
.send()
.await
.unwrap();
assert_eq!(
forged_selection.status(),
reqwest::StatusCode::UNPROCESSABLE_ENTITY
);
}
@@ -0,0 +1,412 @@
#![allow(dead_code, unused_imports)]
use super::common::*;
use serde_json::Value;
use serial_test::serial;
const WORKSPACE_ID: &str = "ws_default";
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn portable_yaml_v2_is_redacted_and_semantic_replay_is_a_noop() {
let registry = test_registry().await;
let storage_root = test_storage_root("operation-yaml-v2");
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let created = client
.post(format!("{base_url}/operations"))
.json(&test_operation_payload("http://127.0.0.1:9", "portable_v2"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let operation_id = created["operation_id"].as_str().unwrap();
let yaml = client
.get(format!("{base_url}/operations/{operation_id}/export"))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert!(yaml.contains("format_version: '2'") || yaml.contains("format_version: 2"));
let parsed: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
let operation = parsed["operation"].as_mapping().unwrap();
for forbidden in ["id", "status", "created_at", "wizard_state", "samples"] {
assert!(!operation.contains_key(serde_yaml::Value::String(forbidden.to_owned())));
}
let replay = client
.post(format!("{base_url}/operations/import?mode=upsert"))
.header("content-type", "application/yaml")
.body(yaml.clone())
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(replay["operation_id"], operation_id);
assert_eq!(replay["version"], 1);
let changed_yaml = yaml.replace(
"display_name: Create Lead",
"display_name: Portable Changed",
);
assert_ne!(changed_yaml, yaml);
let missing = client
.post(format!("{base_url}/operations/import?mode=upsert"))
.header("content-type", "application/yaml")
.body(changed_yaml.clone())
.send()
.await
.unwrap();
assert_eq!(missing.status(), reqwest::StatusCode::PRECONDITION_REQUIRED);
let missing = missing.json::<Value>().await.unwrap();
assert_eq!(missing["error"]["code"], "operation_precondition_required");
let changed = client
.post(format!("{base_url}/operations/import?mode=upsert"))
.header("content-type", "application/yaml")
.header(
reqwest::header::IF_MATCH,
operation_etag(&client, &base_url, operation_id).await,
)
.body(changed_yaml)
.send()
.await
.unwrap();
assert_eq!(changed.status(), reqwest::StatusCode::OK);
let changed = changed.json::<Value>().await.unwrap();
assert_eq!(changed["version"], 2);
let current_yaml = client
.get(format!("{base_url}/operations/{operation_id}/export"))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
let archived = client
.post(format!("{base_url}/operations/{operation_id}/archive"))
.header(
reqwest::header::IF_MATCH,
operation_etag(&client, &base_url, operation_id).await,
)
.send()
.await
.unwrap();
assert!(archived.status().is_success());
let replay = client
.post(format!("{base_url}/operations/import?mode=upsert"))
.header("content-type", "application/yaml")
.body(current_yaml)
.send()
.await
.unwrap();
assert_eq!(replay.status(), reqwest::StatusCode::CONFLICT);
assert!(replay.text().await.unwrap().contains("operation_archived"));
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn hostile_and_oversized_yaml_fail_before_mutation_without_echoing_canary() {
let registry = test_registry().await;
let storage_root = test_storage_root("operation-yaml-hostile");
let base_url = spawn_admin_api(build_test_app(registry.clone(), storage_root)).await;
let client = authorized_client(&base_url).await;
let canary = "yaml-secret-canary-never-reflect";
let hostile = format!(
"format_version: '2'\nkind: operation\noperation:\n name: bad\n unknown_secret: {canary}\n"
);
let response = client
.post(format!("{base_url}/operations/import"))
.header("content-type", "application/yaml")
.body(hostile)
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY);
let body = response.text().await.unwrap();
assert!(!body.contains(canary));
assert!(body.contains("operation_yaml_invalid"));
let body: Value = serde_json::from_str(&body).unwrap();
assert_eq!(body["error"]["code"], "operation_yaml_invalid");
let oversized = "x".repeat(256 * 1024 + 1);
let response = client
.post(format!("{base_url}/operations/import"))
.header("content-type", "application/yaml")
.body(oversized)
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::PAYLOAD_TOO_LARGE);
let count = registry
.list_operations(&crank_core::WorkspaceId::new(WORKSPACE_ID))
.await
.unwrap()
.len();
assert_eq!(count, 0);
let alias = "format_version: '2'\nkind: operation\noperation: &shared\n name: alias\n";
let response = client
.post(format!("{base_url}/operations/import"))
.header("content-type", "application/yaml")
.body(alias)
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY);
assert!(
response
.text()
.await
.unwrap()
.contains("operation_yaml_unsupported")
);
let response = client
.post(format!("{base_url}/operations/import"))
.header("content-type", "application/yaml")
.body(vec![0xff, 0xfe, 0xfd])
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY);
assert!(
response
.text()
.await
.unwrap()
.contains("operation_yaml_invalid")
);
let mut credential_payload = serde_json::to_value(test_operation_payload(
"http://127.0.0.1:9",
"portable_credential_header",
))
.unwrap();
credential_payload["target"]["static_headers"]["X-Auth-Token"] =
Value::String("credential-canary".to_owned());
let created = client
.post(format!("{base_url}/operations"))
.json(&credential_payload)
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let credential_operation_id = created["operation_id"].as_str().unwrap();
let response = client
.get(format!(
"{base_url}/operations/{credential_operation_id}/export"
))
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY);
assert!(!response.text().await.unwrap().contains("credential-canary"));
let mut portable = serde_json::to_value(test_operation_payload(
"http://127.0.0.1:9",
"unknown_nested_yaml",
))
.unwrap();
portable.as_object_mut().unwrap().remove("wizard_state");
portable["target"]["unknown_nested"] = Value::String(canary.to_owned());
let document = serde_yaml::to_string(&serde_json::json!({
"format_version": "2",
"kind": "operation",
"operation": portable
}))
.unwrap();
let response = client
.post(format!("{base_url}/operations/import"))
.header("content-type", "application/yaml")
.body(document)
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY);
assert!(!response.text().await.unwrap().contains(canary));
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn operation_mutations_require_identity_bound_etags_and_versions_are_stable() {
let registry = test_registry().await;
let storage_root = test_storage_root("operation-etag");
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let first = client
.post(format!("{base_url}/operations"))
.json(&test_operation_payload("http://127.0.0.1:9", "etag_first"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let second = client
.post(format!("{base_url}/operations"))
.json(&test_operation_payload("http://127.0.0.1:9", "etag_second"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let first_id = first["operation_id"].as_str().unwrap();
let second_id = second["operation_id"].as_str().unwrap();
let missing = client
.post(format!("{base_url}/operations/{first_id}/archive"))
.send()
.await
.unwrap();
assert_eq!(missing.status(), reqwest::StatusCode::PRECONDITION_REQUIRED);
let missing = missing.json::<Value>().await.unwrap();
assert_eq!(missing["error"]["code"], "operation_precondition_required");
let first_etag = operation_etag(&client, &base_url, first_id).await;
let replay = client
.post(format!("{base_url}/operations/{second_id}/archive"))
.header(reqwest::header::IF_MATCH, first_etag)
.send()
.await
.unwrap();
assert_eq!(replay.status(), reqwest::StatusCode::CONFLICT);
let replay = replay.json::<Value>().await.unwrap();
assert_eq!(replay["error"]["code"], "operation_stale_version");
let v1 = client
.get(format!("{base_url}/operations/{first_id}/versions/1"))
.send()
.await
.unwrap();
assert_eq!(v1.status(), reqwest::StatusCode::OK);
let v1_etag = v1.headers()[reqwest::header::ETAG]
.to_str()
.unwrap()
.to_owned();
let mut update =
serde_json::to_value(test_operation_payload("http://127.0.0.1:9", "etag_first")).unwrap();
update["display_name"] = Value::String("Changed Draft".to_owned());
let updated = client
.patch(format!("{base_url}/operations/{first_id}"))
.header(
reqwest::header::IF_MATCH,
operation_etag(&client, &base_url, first_id).await,
)
.json(&update)
.send()
.await
.unwrap();
assert_eq!(updated.status(), reqwest::StatusCode::OK);
let v1_after = client
.get(format!("{base_url}/operations/{first_id}/versions/1"))
.send()
.await
.unwrap();
assert_eq!(v1_after.status(), reqwest::StatusCode::OK);
assert_eq!(
v1_after.headers()[reqwest::header::ETAG].to_str().unwrap(),
v1_etag
);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn same_etag_concurrent_patches_have_exactly_one_winner() {
let registry = test_registry().await;
let storage_root = test_storage_root("operation-etag-race");
let base_url = spawn_admin_api(build_test_app(registry.clone(), storage_root)).await;
let client = authorized_client(&base_url).await;
let created = client
.post(format!("{base_url}/operations"))
.json(&test_operation_payload("http://127.0.0.1:9", "etag_race"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let operation_id = created["operation_id"].as_str().unwrap();
let etag = operation_etag(&client, &base_url, operation_id).await;
let mut first =
serde_json::to_value(test_operation_payload("http://127.0.0.1:9", "etag_race")).unwrap();
first["display_name"] = Value::String("first winner candidate".to_owned());
let mut second = first.clone();
second["display_name"] = Value::String("second winner candidate".to_owned());
let url = format!("{base_url}/operations/{operation_id}");
let first_request = client
.patch(&url)
.header(reqwest::header::IF_MATCH, &etag)
.json(&first)
.send();
let second_request = client
.patch(&url)
.header(reqwest::header::IF_MATCH, &etag)
.json(&second)
.send();
let (first_response, second_response) = tokio::join!(first_request, second_request);
let statuses = [
first_response.unwrap().status(),
second_response.unwrap().status(),
];
assert_eq!(
statuses.iter().filter(|status| status.is_success()).count(),
1
);
assert_eq!(
statuses
.iter()
.filter(|status| **status == reqwest::StatusCode::CONFLICT)
.count(),
1
);
let summary = registry
.get_operation_summary(
&crank_core::WorkspaceId::new(WORKSPACE_ID),
&crank_core::OperationId::new(operation_id),
)
.await
.unwrap()
.unwrap();
assert_eq!(summary.current_draft_version, 2);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn operation_rejects_unscoped_auth_profile_reference_before_save() {
let registry = test_registry().await;
let storage_root = test_storage_root("operation-auth-reference");
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let mut payload = serde_json::to_value(test_operation_payload(
"http://127.0.0.1:9",
"missing_auth_profile",
))
.unwrap();
payload["execution_config"]["auth_profile_ref"] =
Value::String("auth_foreign_or_missing".to_owned());
let response = client
.post(format!("{base_url}/operations"))
.json(&payload)
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY);
let body = response.text().await.unwrap();
assert!(body.contains("operation_auth_profile_invalid"));
assert!(!body.contains("auth_foreign_or_missing"));
}
@@ -36,7 +36,7 @@ const TEST_AUTH_EMAIL: &str = "owner@crank.local";
const TEST_AUTH_PASSWORD: &str = "test-password";
const TEST_PASSWORD_PEPPER: &str = "test-password-pepper";
const TEST_SESSION_SECRET: &str = "test-session-secret";
const TEST_MASTER_KEY: &str = "test-master-key";
const TEST_MASTER_KEY: &str = "test-master-key-00000000000000000000000000000000";
struct TestServer {
base_url: String,
@@ -123,15 +123,6 @@ async fn creates_publishes_and_tests_rest_operation() {
.json::<Value>()
.await
.unwrap();
let published = client
.post(format!("{base_url}/operations/{operation_id}/publish"))
.json(&json!({ "version": 1 }))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let test_run_response = client
.post(format!("{base_url}/operations/{operation_id}/test-runs"))
.header("x-request-id", "req_admin_test_run")
@@ -151,6 +142,19 @@ async fn creates_publishes_and_tests_rest_operation() {
"0af7651916cd43dd8448eb211c80319c"
);
let test_run = test_run_response.json::<Value>().await.unwrap();
let published = client
.post(format!("{base_url}/operations/{operation_id}/publish"))
.header(
reqwest::header::IF_MATCH,
operation_etag(&client, &base_url, &operation_id).await,
)
.json(&json!({ "version": 1 }))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(listed["items"][0]["name"], "crm_create_lead");
assert_eq!(
@@ -160,20 +164,24 @@ async fn creates_publishes_and_tests_rest_operation() {
assert_eq!(listed["items"][0]["target_action"], "POST");
assert_eq!(published["published_version"], 1);
assert_eq!(test_run["ok"], true);
assert_eq!(
test_run["request_preview"]["body"]["email"],
"user@example.com"
);
assert_eq!(test_run["request_preview"]["body_configured"], true);
let safe_preview = test_run["request_preview"].to_string();
assert!(!safe_preview.contains("user@example.com"));
assert_eq!(test_run["response_preview"]["id"], "lead_123");
let logs = registry
.list_invocation_logs(crank_registry::ListInvocationLogsQuery {
workspace_id: &WorkspaceId::new(DEFAULT_WORKSPACE_ID),
level: None,
status: None,
outcome_group: None,
search_text: None,
source: Some(crank_core::InvocationSource::AdminTestRun),
operation_id: Some(&crank_core::OperationId::new(&operation_id)),
agent_id: None,
created_after: None,
created_before: None,
cursor_created_at: None,
cursor_id: None,
limit: 10,
})
.await
@@ -267,6 +275,10 @@ async fn updates_archives_and_deletes_operation() {
let updated = client
.patch(format!("{base_url}/operations/{operation_id}"))
.header(
reqwest::header::IF_MATCH,
operation_etag(&client, &base_url, &operation_id).await,
)
.json(&json!({
"display_name": "Create Lead Updated",
"category": "marketing",
@@ -350,6 +362,10 @@ async fn updates_archives_and_deletes_operation() {
let archived = client
.post(format!("{base_url}/operations/{operation_id}/archive"))
.header(
reqwest::header::IF_MATCH,
operation_etag(&client, &base_url, &operation_id).await,
)
.send()
.await
.unwrap()
@@ -360,29 +376,29 @@ async fn updates_archives_and_deletes_operation() {
let deleted = client
.delete(format!("{base_url}/operations/{operation_id}"))
.header(
reqwest::header::IF_MATCH,
operation_etag(&client, &base_url, &operation_id).await,
)
.send()
.await
.unwrap();
assert_eq!(deleted.status(), reqwest::StatusCode::CONFLICT);
let deleted = deleted.json::<Value>().await.unwrap();
assert_eq!(
deleted["error"]["context"]["error_code"],
"operation_delete_forbidden"
);
let retained = client
.get(format!("{base_url}/operations/{operation_id}"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(deleted["operation_id"], operation_id);
let missing = client
.get(format!("{base_url}/operations/{operation_id}"))
.send()
.await
.unwrap();
let missing_status = missing.status();
let missing = missing.json::<Value>().await.unwrap();
assert_eq!(missing_status, reqwest::StatusCode::NOT_FOUND);
assert_eq!(missing["error"]["code"], "not_found");
assert_eq!(
missing["error"]["context"],
json!({
"operation_id": operation_id
})
);
assert_eq!(retained["status"], "archived");
}
#[tokio::test(flavor = "multi_thread")]
@@ -408,6 +424,10 @@ async fn creates_binds_and_publishes_agent() {
client
.post(format!("{base_url}/operations/{operation_id}/publish"))
.header(
reqwest::header::IF_MATCH,
operation_etag(&client, &base_url, &operation_id).await,
)
.json(&json!({ "version": 1 }))
.send()
.await
@@ -432,6 +452,10 @@ async fn creates_binds_and_publishes_agent() {
let bindings = client
.post(format!("{base_url}/agents/{agent_id}/bindings"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&json!([
{
"operation_id": operation_id,
@@ -449,6 +473,10 @@ async fn creates_binds_and_publishes_agent() {
.unwrap();
let published = client
.post(format!("{base_url}/agents/{agent_id}/publish"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&json!({ "version": 1 }))
.send()
.await
@@ -489,6 +517,10 @@ async fn saves_and_previews_versioned_agent_tool_search_policy() {
assert_success_json(
client
.post(format!("{base_url}/operations/{operation_id}/publish"))
.header(
reqwest::header::IF_MATCH,
operation_etag(&client, &base_url, &operation_id).await,
)
.json(&json!({"version": 1}))
.send()
.await
@@ -535,6 +567,10 @@ async fn saves_and_previews_versioned_agent_tool_search_policy() {
let saved = assert_success_json(
client
.post(format!("{base_url}/agents/{agent_id}/bindings"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&catalog)
.send()
.await
@@ -565,6 +601,10 @@ async fn saves_and_previews_versioned_agent_tool_search_policy() {
let published = assert_success_json(
client
.post(format!("{base_url}/agents/{agent_id}/publish"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&json!({"version": 1}))
.send()
.await
@@ -576,7 +616,7 @@ async fn saves_and_previews_versioned_agent_tool_search_policy() {
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn agent_publish_skips_draft_operation_bindings_and_preserves_draft() {
async fn agent_binding_rejects_draft_operation_before_publish() {
let registry = test_registry().await;
let storage_root = test_storage_root("agent_publish_filters_drafts");
let upstream_base_url = spawn_upstream_server().await;
@@ -604,6 +644,10 @@ async fn agent_publish_skips_draft_operation_bindings_and_preserves_draft() {
.post(format!(
"{base_url}/operations/{published_operation_id}/publish"
))
.header(
reqwest::header::IF_MATCH,
operation_etag(&client, &base_url, &published_operation_id).await,
)
.json(&json!({ "version": 1 }))
.send()
.await
@@ -642,9 +686,47 @@ async fn agent_publish_skips_draft_operation_bindings_and_preserves_draft() {
.await;
let agent_id = agent["agent_id"].as_str().unwrap().to_owned();
let rejected = client
.post(format!("{base_url}/agents/{agent_id}/bindings"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&json!([
{
"operation_id": published_operation_id,
"operation_version": 1,
"tool_name": "crm_published_tool",
"tool_title": "Published Tool",
"tool_description_override": null,
"enabled": true
},
{
"operation_id": draft_operation_id,
"operation_version": 1,
"tool_name": "crm_draft_tool",
"tool_title": "Draft Tool",
"tool_description_override": null,
"enabled": true
}
]))
.send()
.await
.unwrap();
assert_eq!(rejected.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY);
let rejected_body = rejected.json::<Value>().await.unwrap();
assert_eq!(
rejected_body["error"]["code"],
"agent_binding_not_published"
);
assert_success_json(
client
.post(format!("{base_url}/agents/{agent_id}/bindings"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&json!([
{
"operation_id": published_operation_id,
@@ -653,14 +735,6 @@ async fn agent_publish_skips_draft_operation_bindings_and_preserves_draft() {
"tool_title": "Published Tool",
"tool_description_override": null,
"enabled": true
},
{
"operation_id": draft_operation_id,
"operation_version": 1,
"tool_name": "crm_draft_tool",
"tool_title": "Draft Tool",
"tool_description_override": null,
"enabled": true
}
]))
.send()
@@ -672,6 +746,10 @@ async fn agent_publish_skips_draft_operation_bindings_and_preserves_draft() {
let published = assert_success_json(
client
.post(format!("{base_url}/agents/{agent_id}/publish"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&json!({ "version": 1 }))
.send()
.await
@@ -694,24 +772,15 @@ async fn agent_publish_skips_draft_operation_bindings_and_preserves_draft() {
.unwrap(),
)
.await;
let draft_version = assert_success_json(
client
.get(format!("{base_url}/agents/{agent_id}/versions/2"))
.send()
.await
.unwrap(),
)
.await;
assert_eq!(published["published_version"], 1);
assert_eq!(agent_detail["current_draft_version"], 2);
assert_eq!(agent_detail["current_draft_version"], 1);
assert_eq!(agent_detail["latest_published_version"], 1);
assert_eq!(published_version["bindings"].as_array().unwrap().len(), 1);
assert_eq!(
published_version["bindings"][0]["operation_id"],
published_operation_id
);
assert_eq!(draft_version["bindings"].as_array().unwrap().len(), 2);
}
#[tokio::test(flavor = "multi_thread")]
@@ -737,6 +806,10 @@ async fn updates_lists_and_deletes_agent() {
client
.post(format!("{base_url}/operations/{operation_id}/publish"))
.header(
reqwest::header::IF_MATCH,
operation_etag(&client, &base_url, &operation_id).await,
)
.json(&json!({ "version": 1 }))
.send()
.await
@@ -761,6 +834,10 @@ async fn updates_lists_and_deletes_agent() {
client
.post(format!("{base_url}/agents/{agent_id}/bindings"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&json!([
{
"operation_id": operation_id,
@@ -777,6 +854,10 @@ async fn updates_lists_and_deletes_agent() {
client
.post(format!("{base_url}/agents/{agent_id}/publish"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&json!({ "version": 1 }))
.send()
.await
@@ -800,6 +881,10 @@ async fn updates_lists_and_deletes_agent() {
let updated = client
.patch(format!("{base_url}/agents/{agent_id}"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.json(&json!({
"slug": "support-escalation",
"display_name": "Support Escalation",
@@ -807,11 +892,17 @@ async fn updates_lists_and_deletes_agent() {
}))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(updated["agent_id"], agent_id);
assert_eq!(
updated.status(),
reqwest::StatusCode::CONFLICT,
"published Agent summary/slug must not mutate published MCP endpoint identity"
);
let updated = updated.json::<Value>().await.unwrap();
assert_eq!(
updated["error"]["context"]["error_code"],
"agent_published_summary_immutable"
);
let detail = client
.get(format!("{base_url}/agents/{agent_id}"))
@@ -821,148 +912,35 @@ async fn updates_lists_and_deletes_agent() {
.json::<Value>()
.await
.unwrap();
assert_eq!(detail["slug"], "support-escalation");
assert_eq!(detail["display_name"], "Support Escalation");
assert_eq!(detail["slug"], "support-team");
assert_eq!(detail["display_name"], "Support Team");
assert_eq!(detail["operation_count"], 1);
assert_eq!(detail["mcp_endpoint"], "/mcp/v1/default/support-escalation");
assert_eq!(detail["mcp_endpoint"], "/mcp/v1/default/support-team");
let deleted = client
let delete_response = client
.delete(format!("{base_url}/agents/{agent_id}"))
.header(
reqwest::header::IF_MATCH,
agent_etag(&client, &base_url, &agent_id).await,
)
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(deleted["agent_id"], agent_id);
assert_eq!(
delete_response.status(),
reqwest::StatusCode::CONFLICT,
"published Agent must not be hard-deleted because immutable versions/history must remain"
);
let delete_body = delete_response.json::<Value>().await.unwrap();
assert_eq!(
delete_body["error"]["context"]["error_code"],
"agent_delete_forbidden"
);
let missing = client
let still_present = client
.get(format!("{base_url}/agents/{agent_id}"))
.send()
.await
.unwrap();
let missing_status = missing.status();
let missing = missing.json::<Value>().await.unwrap();
assert_eq!(missing_status, reqwest::StatusCode::NOT_FOUND);
assert_eq!(missing["error"]["code"], "not_found");
assert_eq!(
missing["error"]["context"],
json!({
"agent_id": agent_id
})
);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn unpublishes_and_archives_agent() {
let registry = test_registry().await;
let storage_root = test_storage_root("agent_statuses");
let upstream_base_url = spawn_upstream_server().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let operation = assert_success_json(
client
.post(format!("{base_url}/operations"))
.json(&test_operation_payload(
&upstream_base_url,
"crm_create_lead_agent_status",
))
.send()
.await
.unwrap(),
)
.await;
let operation_id = operation["operation_id"].as_str().unwrap().to_owned();
client
.post(format!("{base_url}/operations/{operation_id}/publish"))
.json(&json!({ "version": 1 }))
.send()
.await
.unwrap();
let created = assert_success_json(
client
.post(format!("{base_url}/agents"))
.json(&json!({
"slug": "sales-routing",
"display_name": "Sales Routing",
"description": "Routing agent",
"instructions": {},
"tool_selection_policy": {}
}))
.send()
.await
.unwrap(),
)
.await;
let agent_id = created["agent_id"].as_str().unwrap().to_owned();
client
.post(format!("{base_url}/agents/{agent_id}/bindings"))
.json(&json!([
{
"operation_id": operation_id,
"operation_version": 1,
"tool_name": "crm_create_lead_agent_status",
"tool_title": "Create Lead",
"tool_description_override": null,
"enabled": true
}
]))
.send()
.await
.unwrap();
client
.post(format!("{base_url}/agents/{agent_id}/publish"))
.json(&json!({ "version": 1 }))
.send()
.await
.unwrap();
let unpublished = assert_success_json(
client
.post(format!("{base_url}/agents/{agent_id}/unpublish"))
.json(&json!({}))
.send()
.await
.unwrap(),
)
.await;
assert_eq!(unpublished["agent_id"], agent_id);
let draft_detail = assert_success_json(
client
.get(format!("{base_url}/agents/{agent_id}"))
.send()
.await
.unwrap(),
)
.await;
assert_eq!(draft_detail["status"], "draft");
assert_eq!(draft_detail["latest_published_version"], 1);
let archived = assert_success_json(
client
.post(format!("{base_url}/agents/{agent_id}/archive"))
.json(&json!({}))
.send()
.await
.unwrap(),
)
.await;
assert_eq!(archived["agent_id"], agent_id);
let archived_detail = assert_success_json(
client
.get(format!("{base_url}/agents/{agent_id}"))
.send()
.await
.unwrap(),
)
.await;
assert_eq!(archived_detail["status"], "archived");
assert_eq!(still_present.status(), reqwest::StatusCode::OK);
}
@@ -36,7 +36,7 @@ const TEST_AUTH_EMAIL: &str = "owner@crank.local";
const TEST_AUTH_PASSWORD: &str = "test-password";
const TEST_PASSWORD_PEPPER: &str = "test-password-pepper";
const TEST_SESSION_SECRET: &str = "test-session-secret";
const TEST_MASTER_KEY: &str = "test-master-key";
const TEST_MASTER_KEY: &str = "test-master-key-00000000000000000000000000000000";
struct TestServer {
base_url: String,
@@ -168,7 +168,7 @@ async fn manages_auth_profiles_and_yaml_upsert() {
secret_id
);
assert_eq!(imported["operation_id"], operation_id);
assert_eq!(imported["version"], 2);
assert_eq!(imported["version"], 1);
assert_eq!(imported["import_mode"], "upsert");
}
@@ -379,7 +379,7 @@ async fn rejects_deleting_secret_referenced_by_auth_profile() {
let body = response.json::<Value>().await.unwrap();
assert_eq!(status, reqwest::StatusCode::CONFLICT);
assert_eq!(body["error"]["code"], "conflict");
assert_eq!(body["error"]["code"], "secret_referenced_by_auth_profile");
assert_eq!(
body["error"]["message"],
format!("secret {secret_id} is referenced by auth profile {auth_profile_id}")
+349 -2
View File
@@ -1,5 +1,14 @@
use std::process::{Command, Output};
use crank_community_auth::{hash_password, verify_password};
use crank_core::{Secret, SecretId, SecretKind, SecretStatus, UserSessionId, WorkspaceId};
use crank_registry::{
CreateSecretRequest, MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate, PostgresRegistry,
};
use crank_runtime::SecretCrypto;
use serde_json::json;
use time::{Duration as TimeDuration, OffsetDateTime, format_description::well_known::Rfc3339};
fn command(arguments: &[&str], database_url: Option<&str>) -> Output {
let mut command = Command::new(env!("CARGO_BIN_EXE_crank-migrate"));
command.args(arguments);
@@ -16,6 +25,10 @@ fn command(arguments: &[&str], database_url: Option<&str>) -> Output {
command.output().expect("migration command must run")
}
fn timestamp(value: &str) -> OffsetDateTime {
OffsetDateTime::parse(value, &Rfc3339).unwrap()
}
#[test]
fn plan_is_deterministic_and_committed_contract_is_current() {
let first = command(&["plan"], None);
@@ -27,7 +40,7 @@ fn plan_is_deterministic_and_committed_contract_is_current() {
);
assert_eq!(first.stdout, second.stdout);
let plan: serde_json::Value = serde_json::from_slice(&first.stdout).unwrap();
assert_eq!(plan["sequence"].as_array().unwrap().len(), 3);
assert_eq!(plan["sequence"].as_array().unwrap().len(), 11);
let checked = command(&["plan", "--check"], None);
assert!(
@@ -69,7 +82,147 @@ async fn database_only_config_can_apply_and_preflight_a_fresh_schema() {
);
let result: serde_json::Value = serde_json::from_slice(&preflight.stdout).unwrap();
assert_eq!(result["status"], "current");
assert_eq!(result["version"], 3);
assert_eq!(result["version"], 11);
}
#[tokio::test]
async fn admin_auth_bootstrap_create_outputs_bootstrap_secret_without_service_secrets() {
let database_url =
crank_test_support::postgres_schema_url("test_migration_admin_auth_bootstrap").await;
assert!(command(&["apply"], Some(&database_url)).status.success());
let created = command(
&[
"admin-auth",
"bootstrap-create",
"--email",
"operator@example.local",
"--display-name",
"Local Operator",
"--ttl-seconds",
"900",
],
Some(&database_url),
);
assert!(
created.status.success(),
"{}",
String::from_utf8_lossy(&created.stderr)
);
let payload: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap();
assert_eq!(payload["status"], "bootstrap_created");
assert!(
payload["contract_id"]
.as_str()
.unwrap()
.starts_with("boot_")
);
assert!(payload["bootstrap_token"].as_str().unwrap().len() >= 32);
let stdout = String::from_utf8(created.stdout).unwrap();
assert!(!stdout.contains("CRANK_SESSION_SECRET"));
assert!(!stdout.contains("CRANK_PASSWORD_PEPPER"));
let duplicate = command(
&[
"admin-auth",
"bootstrap-create",
"--email",
"operator@example.local",
],
Some(&database_url),
);
assert!(!duplicate.status.success());
let diagnostic: serde_json::Value = serde_json::from_slice(&duplicate.stderr).unwrap();
assert_eq!(diagnostic["code"], "admin_bootstrap_unavailable");
}
#[tokio::test]
async fn admin_auth_recovery_requires_master_key_and_revokes_sessions_without_secret_output() {
let database_url =
crank_test_support::postgres_schema_url("test_migration_admin_auth_recovery").await;
assert!(command(&["apply"], Some(&database_url)).status.success());
let registry = PostgresRegistry::connect(&database_url).await.unwrap();
let now = timestamp("2026-08-21T00:00:00Z");
let master_key = "recovery-master-key-CANARY_SECRET_VALUE-000000000";
let crypto = SecretCrypto::new(master_key).unwrap();
registry
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
epoch: crypto.master_key_epoch(),
fingerprint: crypto.master_key_fingerprint(),
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &now,
})
.await
.unwrap();
let pepper = "recovery-pepper-CANARY_SECRET_VALUE";
let old_password = "old-recovery-password-CANARY_SECRET_VALUE";
let old_hash = hash_password(old_password, pepper).unwrap();
let user_id = registry
.upsert_bootstrap_user("recover@example.local", "Recover Operator", &old_hash)
.await
.unwrap();
let session_id = UserSessionId::new("session_recovery_cli");
registry
.create_user_session(
&session_id,
&user_id,
Some(&WorkspaceId::new("ws_default")),
"session-secret-hash",
None,
&(OffsetDateTime::now_utc() + TimeDuration::hours(1)),
)
.await
.unwrap();
let key_dir =
std::env::temp_dir().join(format!("crank-admin-auth-recovery-{}", std::process::id()));
std::fs::create_dir_all(&key_dir).unwrap();
let password_path = key_dir.join("new-password.txt");
let pepper_path = key_dir.join("pepper.txt");
let master_key_path = key_dir.join("master.key");
let new_password = "new-recovery-password-CANARY_SECRET_VALUE";
std::fs::write(&password_path, format!("{new_password}\n")).unwrap();
std::fs::write(&pepper_path, format!("{pepper}\n")).unwrap();
std::fs::write(&master_key_path, format!("{master_key}\n")).unwrap();
let recovered = command(
&[
"admin-auth",
"recover",
"--email",
"recover@example.local",
"--password-file",
password_path.to_str().unwrap(),
"--password-pepper-file",
pepper_path.to_str().unwrap(),
"--master-key-file",
master_key_path.to_str().unwrap(),
],
Some(&database_url),
);
assert!(
recovered.status.success(),
"{}",
String::from_utf8_lossy(&recovered.stderr)
);
let stdout = String::from_utf8(recovered.stdout).unwrap();
assert!(stdout.contains("\"status\":\"admin_recovered\""));
assert!(!stdout.contains("CANARY_SECRET_VALUE"));
assert!(
registry
.get_user_session(&session_id, "session-secret-hash")
.await
.unwrap()
.is_none()
);
let user = registry
.get_auth_user_by_email("recover@example.local")
.await
.unwrap()
.unwrap();
assert!(verify_password(new_password, pepper, &user.password_hash));
assert!(!verify_password(old_password, pepper, &user.password_hash));
}
#[tokio::test]
@@ -88,3 +241,197 @@ async fn migration_error_json_preserves_affected_version() {
assert_eq!(diagnostic["code"], "checksum_mismatch");
assert_eq!(diagnostic["version"], 2);
}
#[tokio::test]
async fn master_key_rotation_command_is_resumable_and_redacted() {
let database_url =
crank_test_support::postgres_schema_url("test_master_key_rotation_cli").await;
assert!(command(&["apply"], Some(&database_url)).status.success());
let registry = PostgresRegistry::connect(&database_url).await.unwrap();
let now = timestamp("2026-08-21T00:00:00Z");
let current_key = "current-master-key-cli-canary-0000000000000000";
let target_key = "target-master-key-cli-canary-00000000000000000";
let current_crypto = SecretCrypto::new(current_key).unwrap();
registry
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
epoch: 1,
fingerprint: current_crypto.master_key_fingerprint(),
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &now,
})
.await
.unwrap();
insert_secret(&registry, "cli_secret_a", &current_crypto, &now).await;
insert_secret(&registry, "cli_secret_b", &current_crypto, &now).await;
let key_dir =
std::env::temp_dir().join(format!("crank-master-key-rotation-{}", std::process::id()));
std::fs::create_dir_all(&key_dir).unwrap();
let current_path = key_dir.join("current.key");
let target_path = key_dir.join("target.key");
std::fs::write(&current_path, current_key).unwrap();
std::fs::write(&target_path, target_key).unwrap();
let current_path = current_path.to_string_lossy().to_string();
let target_path = target_path.to_string_lossy().to_string();
let preflight = command(
&[
"master-key",
"preflight",
"--current-key-file",
&current_path,
"--target-key-file",
&target_path,
"--backup-ref",
"offline-backup-ref",
],
Some(&database_url),
);
assert!(
preflight.status.success(),
"{}",
String::from_utf8_lossy(&preflight.stderr)
);
assert_redacted(&preflight, current_key, target_key);
let preflight_json: serde_json::Value = serde_json::from_slice(&preflight.stdout).unwrap();
assert_eq!(preflight_json["status"], "preflight_ok");
assert_eq!(preflight_json["affected_secret_versions"], 2);
assert!(
registry
.master_key_rotation_status()
.await
.unwrap()
.rotations
.is_empty()
);
let partial = command(
&[
"master-key",
"rotate",
"--current-key-file",
&current_path,
"--target-key-file",
&target_path,
"--backup-ref",
"offline-backup-ref",
"--max-versions",
"1",
],
Some(&database_url),
);
assert!(
partial.status.success(),
"{}",
String::from_utf8_lossy(&partial.stderr)
);
assert_redacted(&partial, current_key, target_key);
let partial_json: serde_json::Value = serde_json::from_slice(&partial.stdout).unwrap();
assert_eq!(partial_json["status"], "running");
assert_eq!(partial_json["processed_secret_versions"], 1);
let resumed = command(
&[
"master-key",
"rotate",
"--current-key-file",
&current_path,
"--target-key-file",
&target_path,
"--backup-ref",
"offline-backup-ref",
],
Some(&database_url),
);
assert!(
resumed.status.success(),
"{}",
String::from_utf8_lossy(&resumed.stderr)
);
let resumed_json: serde_json::Value = serde_json::from_slice(&resumed.stdout).unwrap();
assert_eq!(resumed_json["status"], "verifying");
assert_eq!(resumed_json["processed_secret_versions"], 2);
let verified = command(
&["master-key", "verify", "--target-key-file", &target_path],
Some(&database_url),
);
assert!(
verified.status.success(),
"{}",
String::from_utf8_lossy(&verified.stderr)
);
let verified_json: serde_json::Value = serde_json::from_slice(&verified.stdout).unwrap();
assert_eq!(verified_json["status"], "verified");
let promoted = command(
&["master-key", "promote", "--target-key-file", &target_path],
Some(&database_url),
);
assert!(
promoted.status.success(),
"{}",
String::from_utf8_lossy(&promoted.stderr)
);
assert_redacted(&promoted, current_key, target_key);
let promoted_json: serde_json::Value = serde_json::from_slice(&promoted.stdout).unwrap();
assert_eq!(promoted_json["status"], "promoted");
assert_eq!(promoted_json["active_epoch"], 2);
let old_key_rejected = command(
&[
"master-key",
"preflight",
"--current-key-file",
&current_path,
"--target-key-file",
&target_path,
],
Some(&database_url),
);
assert!(!old_key_rejected.status.success());
assert_redacted(&old_key_rejected, current_key, target_key);
let diagnostic: serde_json::Value = serde_json::from_slice(&old_key_rejected.stderr).unwrap();
assert_eq!(diagnostic["code"], "master_key_identity_mismatch");
}
async fn insert_secret(
registry: &PostgresRegistry,
id: &str,
crypto: &SecretCrypto,
now: &OffsetDateTime,
) {
let ciphertext = crypto
.encrypt(&json!({ "token": format!("{id}_value") }))
.unwrap();
let secret = Secret {
id: SecretId::new(id),
workspace_id: WorkspaceId::new("ws_default"),
name: id.to_owned(),
kind: SecretKind::Token,
status: SecretStatus::Active,
current_version: 1,
created_at: *now,
updated_at: *now,
last_used_at: None,
};
registry
.create_secret(CreateSecretRequest {
secret: &secret,
ciphertext: &ciphertext,
key_version: crypto.key_version(),
master_key_epoch: crypto.master_key_epoch(),
created_by: None,
})
.await
.unwrap();
}
fn assert_redacted(output: &Output, current_key: &str, target_key: &str) {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(!stdout.contains(current_key));
assert!(!stdout.contains(target_key));
assert!(!stderr.contains(current_key));
assert!(!stderr.contains(target_key));
}
+2
View File
@@ -1,2 +1,4 @@
#[path = "unit/error.rs"]
mod error;
#[path = "unit/execution_parity.rs"]
mod execution_parity;
+55 -20
View File
@@ -1,6 +1,8 @@
use admin_api::error::{runtime_error_context, runtime_test_failure};
use admin_api::error::{
execution_test_failure, execution_test_failure_localized, runtime_test_failure,
};
use crank_core::{CorrelationContext, ExecutionErrorCode, ExecutionFailure, ExecutionLocale};
use crank_runtime::RuntimeError;
use serde_json::json;
#[test]
fn runtime_test_failure_includes_structured_context() {
@@ -9,23 +11,23 @@ fn runtime_test_failure_includes_structured_context() {
reason: "must be an object".to_owned(),
});
assert_eq!(payload["code"], "runtime_request_error");
assert_eq!(
payload["context"],
json!({
"field": "request.headers"
})
);
assert_eq!(payload["code"], "prepared_request_invalid");
assert_eq!(payload["stage"], "request_preparation");
assert_eq!(payload["retryability"], "never");
assert_eq!(payload["outcome_certainty"], "certain");
assert!(payload.get("context").is_none());
}
#[test]
fn runtime_error_context_does_not_expose_secret_crypto_details() {
let context = runtime_error_context(&RuntimeError::SecretCrypto {
let canary = "story18-secret-crypto-canary";
let payload = runtime_test_failure(&RuntimeError::SecretCrypto {
operation: "decode secret envelope",
details: "bad base64".to_owned(),
details: canary.to_owned(),
});
assert_eq!(context, None);
assert_eq!(payload["code"], "secret_invalid");
assert!(!payload.to_string().contains(canary));
}
#[test]
@@ -35,12 +37,45 @@ fn runtime_test_failure_includes_runtime_overload_context() {
limit: 16,
});
assert_eq!(payload["code"], "runtime_overloaded");
assert_eq!(
payload["context"],
json!({
"kind": "window",
"limit": 16
})
);
assert_eq!(payload["code"], "execution_overloaded");
assert_eq!(payload["stage"], "admission");
assert_eq!(payload["retryability"], "after_delay");
assert!(payload.get("context").is_none());
}
#[test]
fn every_execution_code_projects_the_canonical_semantics_without_raw_context() {
let canary = "story18-admin-projection-canary";
for code in ExecutionErrorCode::ALL {
let failure = ExecutionFailure::new(code, CorrelationContext::generate());
let payload = execution_test_failure(&failure);
assert_eq!(payload["code"], code.as_str());
assert_eq!(payload["stage"], code.stage().as_str());
assert_eq!(payload["retryability"], code.retryability().as_str());
assert_eq!(
payload["outcome_certainty"],
code.outcome_certainty().as_str()
);
assert!(!payload["message"].as_str().unwrap_or_default().is_empty());
assert!(!payload.to_string().contains(canary));
}
}
#[test]
fn confirmation_challenge_is_bounded_and_localized_at_the_admin_boundary() {
let failure = ExecutionFailure::new(
ExecutionErrorCode::ConfirmationRequired,
CorrelationContext::generate(),
)
.try_with_confirmation("confirmation-capability", 30_000)
.unwrap();
let payload = execution_test_failure_localized(&failure, ExecutionLocale::Ru);
assert_eq!(payload["message"], "Операция требует подтверждения.");
assert_eq!(
payload["context"]["confirmation_token"],
"confirmation-capability"
);
assert_eq!(payload["context"]["expires_in_ms"], 30_000);
assert!(!format!("{failure:?}").contains("confirmation-capability"));
}
@@ -0,0 +1,50 @@
use admin_api::error::execution_test_failure_localized;
use crank_community_mcp::tool_error::tool_error_contract_from_failure;
use crank_core::{
CorrelationContext, ExecutionErrorCode, ExecutionFailure, ExecutionLocale, Retryability,
};
#[test]
fn admin_and_mcp_project_every_execution_failure_with_identical_semantics() {
for locale in [ExecutionLocale::En, ExecutionLocale::Ru] {
for code in ExecutionErrorCode::ALL {
let failure = ExecutionFailure::new(code, CorrelationContext::generate());
let admin = execution_test_failure_localized(&failure, locale);
let mcp = tool_error_contract_from_failure(&failure, locale);
assert_eq!(admin["code"], mcp.error_code);
assert_eq!(admin["stage"], mcp.stage);
assert_eq!(admin["retryability"], mcp.retryability);
assert_eq!(admin["outcome_certainty"], mcp.outcome_certainty);
assert_eq!(admin["message"], mcp.message);
assert_eq!(
mcp.recoverable,
matches!(
code.retryability(),
Retryability::Safe
| Retryability::AfterDelay
| Retryability::RequiresConfirmation
)
);
}
}
}
#[test]
fn admin_and_mcp_preserve_dispatch_uncertainty_without_changing_the_code() {
for locale in [ExecutionLocale::En, ExecutionLocale::Ru] {
let failure = ExecutionFailure::new(
ExecutionErrorCode::UpstreamTransportError,
CorrelationContext::generate(),
)
.with_dispatch_uncertainty();
let admin = execution_test_failure_localized(&failure, locale);
let mcp = tool_error_contract_from_failure(&failure, locale);
assert_eq!(admin["retryability"], "manual_reconcile");
assert_eq!(admin["outcome_certainty"], "outcome_unknown");
assert_eq!(admin["retryability"], mcp.retryability);
assert_eq!(admin["outcome_certainty"], mcp.outcome_certainty);
assert!(!mcp.recoverable);
}
}
+87 -4
View File
@@ -13,7 +13,9 @@ use crank_observability::{
CriticalErrorCategory, MetricsConfig, ObservabilityConfig, ObservabilityLifecycle,
OtlpTraceConfig, RedactionLimits, SentryConfig, ServiceIdentity, capture_critical_error,
};
use crank_registry::{PostgresPoolConfig, PostgresRegistry};
use crank_registry::{
MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate, PostgresPoolConfig, PostgresRegistry,
};
use crank_runtime::{
RequestRateLimitConfig, RequestRateLimiter, RuntimeCacheConfig, RuntimeCacheStores,
RuntimeLimits, SecretCrypto,
@@ -62,6 +64,36 @@ fn safe_startup_diagnostic(error: &(dyn std::error::Error + 'static)) -> String
})
.to_string();
}
if let Some(crank_registry::RegistryError::MasterKeyIdentityMismatch { epoch }) =
cause.downcast_ref::<crank_registry::RegistryError>()
{
return serde_json::json!({
"status": "error",
"code": "master_key_identity_mismatch",
"stage": "startup.master_key_identity",
"version": epoch,
"recovery": "configure_same_master_key",
})
.to_string();
}
if cause
.downcast_ref::<crank_registry::RegistryError>()
.is_some_and(|error| {
matches!(
error,
crank_registry::RegistryError::InvalidMasterKeyIdentity
)
})
{
return serde_json::json!({
"status": "error",
"code": "master_key_identity_invalid",
"stage": "startup.master_key_identity",
"version": null,
"recovery": "contact_operator",
})
.to_string();
}
current = cause.source();
}
serde_json::json!({
@@ -145,10 +177,13 @@ async fn run(
spawn_postgres_pool_metrics(registry.pool().clone());
}
let session_store = PostgresTransportSessionStore::from_pool(registry.pool().clone()).await?;
let secret_crypto = SecretCrypto::new(config.runtime.master_key.expose_secret())?;
let outbound_http_policy = crank_runtime::OutboundHttpPolicy::try_new(
let secret_crypto =
verified_startup_secret_crypto(&registry, config.runtime.master_key.expose_secret())
.await?;
let outbound_http_policy = crank_runtime::OutboundHttpPolicy::try_new_with_limits(
config.runtime.outbound.allowed_hosts.clone(),
config.runtime.outbound.denied_hosts.clone(),
config.runtime.outbound.max_request_bytes,
config.runtime.outbound.max_response_bytes,
)?;
let runtime = crank_runtime::community_with_outbound_policy(outbound_http_policy)
@@ -275,9 +310,10 @@ fn preflight_config(config: &McpProcessConfig) -> Result<(), crank_config::Confi
.map_err(|_| invalid("mcp.rate_limit"))?;
SecretCrypto::new(config.runtime.master_key.expose_secret())
.map_err(|_| invalid("runtime.master_key"))?;
crank_runtime::OutboundHttpPolicy::try_new(
crank_runtime::OutboundHttpPolicy::try_new_with_limits(
config.runtime.outbound.allowed_hosts.clone(),
config.runtime.outbound.denied_hosts.clone(),
config.runtime.outbound.max_request_bytes,
config.runtime.outbound.max_response_bytes,
)
.map_err(|_| invalid("runtime.outbound"))?;
@@ -326,6 +362,53 @@ fn preflight_config(config: &McpProcessConfig) -> Result<(), crank_config::Confi
Ok(())
}
async fn verified_startup_secret_crypto(
registry: &PostgresRegistry,
master_key: &str,
) -> Result<SecretCrypto, Box<dyn std::error::Error>> {
let active = registry.active_master_key_identity().await?;
let secret_crypto = if let Some(identity) = active {
SecretCrypto::with_epoch(master_key, identity.epoch)?
} else {
let crypto = SecretCrypto::new(master_key)?;
let mut after_secret_id: Option<String> = None;
let mut after_version: Option<u32> = None;
loop {
let versions = registry
.list_secret_versions_for_master_key_epoch_page(
1,
after_secret_id.as_deref(),
after_version,
1_000,
)
.await?;
if versions.is_empty() {
break;
}
for version in versions {
after_secret_id = Some(version.secret_version.secret_id.as_str().to_owned());
after_version = Some(version.secret_version.version);
crypto.decrypt_for_epoch(
&version.secret_version.key_version,
version.master_key_epoch,
&version.secret_version.ciphertext,
)?;
}
}
crypto
};
let master_key_observed_at = time::OffsetDateTime::now_utc();
registry
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
epoch: secret_crypto.master_key_epoch(),
fingerprint: secret_crypto.master_key_fingerprint(),
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &master_key_observed_at,
})
.await?;
Ok(secret_crypto)
}
fn postgres_pool_config(
config: &DatabaseSettings,
) -> Result<PostgresPoolConfig, crank_registry::PostgresPoolConfigError> {
+37 -1
View File
@@ -1,11 +1,21 @@
use std::process::Command;
use crank_registry::{
MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate, MigrationAuthority, PostgresRegistry,
};
use crank_runtime::SecretCrypto;
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
const TEST_MASTER_KEY: &str = "test-master-key-00000000000000000000000000000000";
const REGISTERED_MASTER_KEY: &str = "registered-master-key-CANARY_SECRET_VALUE-00000000";
const WRONG_MASTER_KEY: &str = "wrong-master-key-CANARY_SECRET_VALUE-0000000000000";
fn run_with(entries: &[(&str, &str)]) -> String {
let mut command = Command::new(env!("CARGO_BIN_EXE_mcp-server"));
for field in crank_config::field_registry() {
command.env_remove(field.env_name);
}
command.env("CRANK_MASTER_KEY", "master");
command.env("CRANK_MASTER_KEY", TEST_MASTER_KEY);
for (name, value) in entries {
command.env(name, value);
}
@@ -63,3 +73,29 @@ async fn fresh_database_startup_is_read_only() {
.unwrap();
assert!(!present);
}
#[tokio::test]
async fn master_key_mismatch_blocks_startup_with_safe_diagnostic() {
let database_url = crank_test_support::postgres_schema_url("mcp_master_key_mismatch").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
MigrationAuthority::apply(&pool).await.unwrap();
let registry = PostgresRegistry::connect(&database_url).await.unwrap();
let crypto = SecretCrypto::new(REGISTERED_MASTER_KEY).unwrap();
registry
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
epoch: 1,
fingerprint: crypto.master_key_fingerprint(),
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &OffsetDateTime::parse("2026-08-21T00:00:00Z", &Rfc3339).unwrap(),
})
.await
.unwrap();
let stderr = run_with(&[
("CRANK_DATABASE_URL", &database_url),
("CRANK_MASTER_KEY", WRONG_MASTER_KEY),
]);
assert!(stderr.contains("master_key_identity_mismatch"), "{stderr}");
assert!(!stderr.contains("registered-master-key"));
assert!(!stderr.contains("wrong-master-key"));
}
+5
View File
@@ -114,11 +114,16 @@ async fn preserves_mcp_result_when_postgres_rejects_invocation_history() {
.list_invocation_logs(ListInvocationLogsQuery {
workspace_id: &WorkspaceId::new("ws_default"),
level: None,
status: None,
outcome_group: None,
search_text: None,
source: None,
operation_id: Some(&operation.id),
agent_id: None,
created_after: None,
created_before: None,
cursor_created_at: None,
cursor_id: None,
limit: 10,
})
.await
@@ -23,14 +23,14 @@ use crank_core::{
ApprovalRequestId, ApprovalRequestStatus, ExecutionConfig, HttpMethod, InvocationSource,
Operation, OperationApprovalMode, OperationApprovalPayloadPreviewMode, OperationApprovalPolicy,
OperationApprovalRiskLevel, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId,
PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription,
WorkspaceId,
PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget, Target,
ToolDescription, WorkspaceId,
};
use crank_mapping::{MappingRule, MappingSet};
use crank_registry::{
CreateAgentRequest, CreateApprovalRequest, CreatePlatformApiKeyRequest,
ListInvocationLogsQuery, PostgresRegistry, PublishAgentRequest, PublishRequest,
SaveAgentBindingsRequest,
CreateAgentDraftVersionRequest, CreateAgentRequest, CreateApprovalRequest,
CreatePlatformApiKeyRequest, ListInvocationLogsQuery, PostgresRegistry, PublishAgentRequest,
PublishRequest, SaveAgentBindingsRequest,
};
use crank_runtime::{
InMemoryCoordinationStateStore, RequestRateLimitConfig, RequestRateLimiter, RuntimeExecutor,
@@ -141,7 +141,7 @@ fn build_test_app_with_store(
registry,
refresh_interval,
public_base_url,
SecretCrypto::new("test-master-key").unwrap(),
SecretCrypto::new("test-master-key-00000000000000000000000000000000").unwrap(),
crank_runtime::community_with_outbound_policy(
crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]),
)
@@ -153,6 +153,17 @@ fn build_test_app_with_store(
)
}
async fn assert_revoked_mcp_request_is_denied(
response: reqwest::Response,
api_key: &str,
key_id: &PlatformApiKeyId,
) {
assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED);
let denied_body = response.text().await.unwrap();
assert!(!denied_body.contains(api_key));
assert!(!denied_body.contains(key_id.as_str()));
}
#[tokio::test]
async fn refreshes_published_tools_without_restart() {
let registry = test_registry().await;
@@ -204,12 +215,33 @@ async fn refreshes_published_tools_without_restart() {
})
.await
.unwrap();
let agent_id = test_agent_id("sales-refresh");
let draft_v2 = AgentVersion {
agent_id: agent_id.clone(),
version: 2,
status: AgentStatus::Draft,
instructions: json!({}),
tool_selection_policy: Default::default(),
created_at: OffsetDateTime::parse("2026-03-26T10:01:00Z", &Rfc3339).unwrap(),
};
registry
.save_agent_bindings(SaveAgentBindingsRequest {
.create_agent_draft_version(CreateAgentDraftVersionRequest {
workspace_id: &test_workspace_id(),
agent_id: &test_agent_id("sales-refresh"),
agent_version: 1,
agent_id: &agent_id,
version: &draft_v2,
bindings: &[binding_for_operation(&operation)],
updated_at: &OffsetDateTime::parse("2026-03-26T10:01:00Z", &Rfc3339).unwrap(),
})
.await
.unwrap();
registry
.publish_agent(PublishAgentRequest {
workspace_id: &test_workspace_id(),
agent_id: &agent_id,
version: 2,
published_at: &OffsetDateTime::parse("2026-03-26T10:02:00Z", &Rfc3339).unwrap(),
published_by: Some("alice"),
expected_state: None,
})
.await
.unwrap();
@@ -236,7 +268,7 @@ async fn refreshes_published_tools_without_restart() {
}
#[tokio::test]
async fn shares_published_catalog_snapshot_across_instances() {
async fn shared_catalog_cache_does_not_serve_stale_snapshot_after_unpublish() {
let registry = test_registry().await;
let upstream_base_url = spawn_upstream_server().await;
let operation = test_operation(&upstream_base_url, "crm_catalog_shared");
@@ -274,6 +306,7 @@ async fn shares_published_catalog_snapshot_across_instances() {
&test_workspace_id(),
&test_agent_id("sales-shared-catalog"),
&OffsetDateTime::parse("2026-03-26T10:05:00Z", &Rfc3339).unwrap(),
None,
)
.await
.unwrap();
@@ -283,13 +316,15 @@ async fn shares_published_catalog_snapshot_across_instances() {
Duration::from_secs(60),
coordination_store,
);
let tools_b = catalog_b
let error = catalog_b
.list_tools(test_workspace_slug(), "sales-shared-catalog")
.await
.unwrap();
.expect_err("unpublished Agent must not be served from shared cache");
assert_eq!(tools_b.len(), 1);
assert_eq!(tools_b[0].tool_name, operation.name);
assert!(matches!(
error,
crank_registry::RegistryError::PublishedAgentNotFound { .. }
));
}
#[tokio::test]
@@ -456,6 +491,135 @@ async fn rejects_initialize_with_key_from_different_agent() {
assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn revoked_mcp_key_stops_existing_session_without_restart() {
let registry = test_registry().await;
let upstream_base_url = spawn_upstream_server().await;
let operation = test_operation(&upstream_base_url, "crm_revoked_key");
registry
.create_operation(&test_workspace_id(), &operation, Some("alice"))
.await
.unwrap();
registry
.publish_operation(PublishRequest {
workspace_id: &test_workspace_id(),
operation_id: &operation.id,
version: 1,
published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
published_by: Some("alice"),
})
.await
.unwrap();
publish_agent_for_operation(&registry, &operation, "sales-revoked-key").await;
let api_key = create_platform_api_key(
&registry,
"sales-revoked-key",
"mcp-revoked-boundary",
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
)
.await;
let base_url = spawn_mcp_server(build_test_app(
registry.clone(),
Duration::from_millis(0),
Some("https://crank.example.com".to_owned()),
))
.await;
let client = reqwest::Client::new();
let mcp_url = agent_mcp_url(&base_url, "sales-revoked-key");
let initialized_session = initialize_session(&client, &mcp_url, &api_key).await;
let before_revoke = post_jsonrpc(
&client,
&mcp_url,
&api_key,
Some(&initialized_session),
json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
"params": {}
}),
)
.await;
assert_eq!(
before_revoke["result"]["tools"][0]["name"],
"crm_revoked_key"
);
let key_id = registry
.list_platform_api_keys_for_agent(&test_workspace_id(), &test_agent_id("sales-revoked-key"))
.await
.unwrap()
.into_iter()
.find(|record| record.api_key.name == "mcp-revoked-boundary")
.unwrap()
.api_key
.id;
registry
.revoke_platform_api_key_for_agent(
&test_workspace_id(),
&test_agent_id("sales-revoked-key"),
&key_id,
&OffsetDateTime::now_utc(),
)
.await
.unwrap();
let rejected_initialize = post_jsonrpc_response(
&client,
&mcp_url,
&api_key,
None,
None,
json!({
"jsonrpc": "2.0",
"id": 3,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25"
}
}),
)
.await;
assert_revoked_mcp_request_is_denied(rejected_initialize, &api_key, &key_id).await;
let rejected_tools_list = post_jsonrpc_response(
&client,
&mcp_url,
&api_key,
Some(&initialized_session),
None,
json!({
"jsonrpc": "2.0",
"id": 4,
"method": "tools/list",
"params": {}
}),
)
.await;
assert_revoked_mcp_request_is_denied(rejected_tools_list, &api_key, &key_id).await;
let rejected_tools_call = post_jsonrpc_response(
&client,
&mcp_url,
&api_key,
Some(&initialized_session),
None,
json!({
"jsonrpc": "2.0",
"id": 5,
"method": "tools/call",
"params": {
"name": "crm_revoked_key",
"arguments": {}
}
}),
)
.await;
assert_revoked_mcp_request_is_denied(rejected_tools_call, &api_key, &key_id).await;
}
#[tokio::test]
async fn rejects_initialize_without_platform_api_key() {
let registry = test_registry().await;
@@ -1,5 +1,8 @@
use super::*;
mod pending;
mod revocation;
#[tokio::test]
async fn approval_key_lists_and_decides_pending_requests() {
let registry = test_registry().await;
@@ -23,6 +26,8 @@ async fn approval_key_lists_and_decides_pending_requests() {
operation_version: 1,
status: ApprovalRequestStatus::Pending,
risk_level: OperationApprovalRiskLevel::Dangerous,
request_id: None,
trace_id: None,
request_payload: json!({"email": "ada@example.com"}),
response_payload: None,
created_at: OffsetDateTime::now_utc(),
@@ -164,11 +169,16 @@ async fn approval_key_lists_and_decides_pending_requests() {
.list_invocation_logs(ListInvocationLogsQuery {
workspace_id: &test_workspace_id(),
level: None,
status: None,
outcome_group: None,
search_text: None,
source: Some(InvocationSource::AgentToolCall),
operation_id: Some(&operation.id),
agent_id: Some(&test_agent_id("sales-human-approval")),
created_after: None,
created_before: None,
cursor_created_at: None,
cursor_id: None,
limit: 10,
})
.await
@@ -203,6 +213,8 @@ async fn approval_key_denies_without_executing_upstream() {
operation_version: 1,
status: ApprovalRequestStatus::Pending,
risk_level: OperationApprovalRiskLevel::Dangerous,
request_id: None,
trace_id: None,
request_payload: json!({"email": "deny@example.com"}),
response_payload: None,
created_at: OffsetDateTime::now_utc(),
@@ -265,11 +277,16 @@ async fn approval_key_denies_without_executing_upstream() {
.list_invocation_logs(ListInvocationLogsQuery {
workspace_id: &test_workspace_id(),
level: None,
status: None,
outcome_group: None,
search_text: None,
source: Some(InvocationSource::AgentToolCall),
operation_id: Some(&operation.id),
agent_id: Some(&test_agent_id("sales-human-deny")),
created_after: None,
created_before: None,
cursor_created_at: None,
cursor_id: None,
limit: 10,
})
.await
@@ -277,6 +294,112 @@ async fn approval_key_denies_without_executing_upstream() {
assert!(logs.is_empty());
}
#[tokio::test]
async fn approval_key_allowed_origins_are_enforced_at_approval_boundary() {
let registry = test_registry().await;
let upstream_base_url = spawn_upstream_server().await;
let operation = test_operation(&upstream_base_url, "crm_origin_guard");
registry
.create_operation(&test_workspace_id(), &operation, Some("alice"))
.await
.unwrap();
publish_agent_with_bindings(
&registry,
"sales-origin-guard",
vec![binding_for_operation(&operation)],
)
.await;
let approval = ApprovalRequest {
id: ApprovalRequestId::new("approval_origin_guard_01"),
workspace_id: test_workspace_id(),
agent_id: test_agent_id("sales-origin-guard"),
operation_id: operation.id.clone(),
operation_version: 1,
status: ApprovalRequestStatus::Pending,
risk_level: OperationApprovalRiskLevel::Dangerous,
request_id: None,
trace_id: None,
request_payload: json!({"email": "origin@example.com"}),
response_payload: None,
created_at: OffsetDateTime::now_utc(),
expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(5),
decided_at: None,
decided_by_key_id: None,
decision_note: None,
};
registry
.create_approval_request(CreateApprovalRequest {
approval: &approval,
})
.await
.unwrap();
let approval_secret = format!("crk_appr_origin_{}", uuid::Uuid::now_v7().simple());
let approval_key = PlatformApiKey {
id: PlatformApiKeyId::new("pk_approval_origin_guard"),
workspace_id: test_workspace_id(),
agent_id: Some(test_agent_id("sales-origin-guard")),
key_kind: PlatformApiKeyKind::Approval,
name: "approval-origin-guard".to_owned(),
prefix: approval_secret.chars().take(16).collect(),
scopes: vec![PlatformApiKeyScope::ReadPending],
status: PlatformApiKeyStatus::Active,
created_at: OffsetDateTime::now_utc(),
last_used_at: None,
expires_at: None,
allowed_origins: vec!["https://allowed.example.test".to_owned()],
};
registry
.create_platform_api_key(CreatePlatformApiKeyRequest {
api_key: &approval_key,
secret_hash: &hash_access_secret(&approval_secret),
})
.await
.unwrap();
let base_url = spawn_mcp_server(build_test_app(
registry.clone(),
Duration::from_millis(0),
Some("https://crank.example.com".to_owned()),
))
.await;
let client = reqwest::Client::new();
let approvals_url = format!(
"{}/approvals",
agent_mcp_url(&base_url, "sales-origin-guard")
);
let rejected = client
.get(&approvals_url)
.header(header::AUTHORIZATION, format!("Bearer {approval_secret}"))
.header(header::ORIGIN, "https://evil.example.test")
.send()
.await
.unwrap();
assert_eq!(rejected.status(), reqwest::StatusCode::FORBIDDEN);
let rejected_body = rejected.text().await.unwrap();
assert!(!rejected_body.contains("allowed.example.test"));
assert!(!rejected_body.contains("evil.example.test"));
assert!(!rejected_body.contains(&approval_secret));
let server_side_client = client
.get(&approvals_url)
.header(header::AUTHORIZATION, format!("Bearer {approval_secret}"))
.send()
.await
.unwrap();
assert_eq!(server_side_client.status(), reqwest::StatusCode::OK);
let accepted = client
.get(&approvals_url)
.header(header::AUTHORIZATION, format!("Bearer {approval_secret}"))
.header(header::ORIGIN, "https://allowed.example.test")
.send()
.await
.unwrap();
assert_eq!(accepted.status(), reqwest::StatusCode::OK);
}
#[tokio::test]
async fn approval_key_expires_without_executing_upstream() {
let registry = test_registry().await;
@@ -300,6 +423,8 @@ async fn approval_key_expires_without_executing_upstream() {
operation_version: 1,
status: ApprovalRequestStatus::Pending,
risk_level: OperationApprovalRiskLevel::Dangerous,
request_id: None,
trace_id: None,
request_payload: json!({"email": "expired@example.com"}),
response_payload: None,
created_at: OffsetDateTime::now_utc() - time::Duration::minutes(10),
@@ -349,11 +474,16 @@ async fn approval_key_expires_without_executing_upstream() {
.list_invocation_logs(ListInvocationLogsQuery {
workspace_id: &test_workspace_id(),
level: None,
status: None,
outcome_group: None,
search_text: None,
source: Some(InvocationSource::AgentToolCall),
operation_id: Some(&operation.id),
agent_id: Some(&test_agent_id("sales-human-expired")),
created_after: None,
created_before: None,
cursor_created_at: None,
cursor_id: None,
limit: 10,
})
.await
@@ -361,116 +491,6 @@ async fn approval_key_expires_without_executing_upstream() {
assert!(logs.is_empty());
}
#[tokio::test]
async fn tool_call_with_approval_policy_creates_pending_request() {
let registry = test_registry().await;
let upstream_base_url = spawn_upstream_server().await;
let mut operation = test_operation(&upstream_base_url, "crm_requires_human_approval");
operation.execution_config.approval_policy = Some(OperationApprovalPolicy {
required: true,
mode: OperationApprovalMode::Custom,
risk_level: OperationApprovalRiskLevel::Dangerous,
ttl_seconds: 300,
show_payload_preview: true,
payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson,
elicitation_message: None,
});
registry
.create_operation(&test_workspace_id(), &operation, Some("alice"))
.await
.unwrap();
registry
.publish_operation(PublishRequest {
workspace_id: &test_workspace_id(),
operation_id: &operation.id,
version: 1,
published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
published_by: Some("alice"),
})
.await
.unwrap();
publish_agent_for_operation(&registry, &operation, "sales-gated").await;
let api_key = create_platform_api_key(
&registry,
"sales-gated",
"mcp-gated",
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
)
.await;
let approval_key =
create_approval_platform_api_key(&registry, "sales-gated", "approval-gated").await;
let base_url = spawn_mcp_server(build_test_app(
registry,
Duration::from_millis(0),
Some("https://crank.example.com".to_owned()),
))
.await;
let client = reqwest::Client::new();
let mcp_url = agent_mcp_url(&base_url, "sales-gated");
let initialized_session = initialize_session(&client, &mcp_url, &api_key).await;
let tool_call = json!({
"jsonrpc": "2.0",
"id": 9,
"method": "tools/call",
"params": {
"name": "crm_requires_human_approval",
"arguments": {
"email": "ada@example.com"
}
}
});
let tool_result = post_jsonrpc(
&client,
&mcp_url,
&api_key,
Some(&initialized_session),
tool_call.clone(),
)
.await;
assert_eq!(
tool_result["result"]["structuredContent"]["status"],
"approval_required"
);
assert_eq!(tool_result["result"]["isError"], false);
let approval_id = tool_result["result"]["structuredContent"]["approval_id"]
.as_str()
.unwrap();
assert!(approval_id.starts_with("approval_"));
let repeated_tool_result = post_jsonrpc(
&client,
&mcp_url,
&api_key,
Some(&initialized_session),
tool_call,
)
.await;
assert_eq!(
repeated_tool_result["result"]["structuredContent"]["approval_id"], approval_id,
"deduplicated tools/call must return the persisted approval id",
);
let approvals_url = format!("{}/approvals", agent_mcp_url(&base_url, "sales-gated"));
let pending = client
.get(&approvals_url)
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(pending["items"].as_array().unwrap().len(), 1);
assert_eq!(pending["items"][0]["approval"]["id"], approval_id);
assert_eq!(
pending["items"][0]["approval"]["request_payload"]["email"],
"ada@example.com"
);
}
#[tokio::test]
async fn approval_http_endpoints_enforce_request_rate_limit() {
let registry = test_registry().await;
@@ -589,6 +609,8 @@ async fn recovery_does_not_repeat_interrupted_mutating_approval() {
operation_version: operation.version,
status: ApprovalRequestStatus::Pending,
risk_level: OperationApprovalRiskLevel::Dangerous,
request_id: None,
trace_id: None,
request_payload: json!({"email": "interrupted@example.com"}),
response_payload: None,
created_at: now - time::Duration::minutes(10),
@@ -609,9 +631,12 @@ async fn recovery_does_not_repeat_interrupted_mutating_approval() {
workspace_id: &approval.workspace_id,
agent_id: &approval.agent_id,
approval_id: &approval.id,
operation_id: &approval.operation_id,
operation_version: approval.operation_version,
request_payload: &approval.request_payload,
status: ApprovalRequestStatus::Approved,
decided_at: now - time::Duration::minutes(10),
decided_by_key_id: &approval_key_id,
decided_by_key_id: Some(&approval_key_id),
response_payload: Some(json!({"approve": "yes"})),
decision_note: None,
})
@@ -666,7 +691,7 @@ fn build_test_app_with_approval_recovery(registry: PostgresRegistry) -> Router {
registry,
Duration::from_millis(0),
Some("https://crank.example.com".to_owned()),
SecretCrypto::new("test-master-key").unwrap(),
SecretCrypto::new("test-master-key-00000000000000000000000000000000").unwrap(),
crank_runtime::community_with_outbound_policy(
crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]),
)
@@ -0,0 +1,151 @@
use super::*;
#[tokio::test]
async fn tool_call_with_approval_policy_creates_pending_request() {
let registry = test_registry().await;
let upstream_base_url = spawn_upstream_server().await;
let mut operation = test_operation(&upstream_base_url, "crm_requires_human_approval");
operation.execution_config.approval_policy = Some(OperationApprovalPolicy {
required: true,
mode: OperationApprovalMode::Custom,
risk_level: OperationApprovalRiskLevel::Dangerous,
ttl_seconds: 300,
show_payload_preview: true,
payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson,
elicitation_message: None,
});
registry
.create_operation(&test_workspace_id(), &operation, Some("alice"))
.await
.unwrap();
registry
.publish_operation(PublishRequest {
workspace_id: &test_workspace_id(),
operation_id: &operation.id,
version: 1,
published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
published_by: Some("alice"),
})
.await
.unwrap();
publish_agent_for_operation(&registry, &operation, "sales-gated").await;
let api_key = create_platform_api_key(
&registry,
"sales-gated",
"mcp-gated",
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
)
.await;
let approval_key =
create_approval_platform_api_key(&registry, "sales-gated", "approval-gated").await;
let base_url = spawn_mcp_server(build_test_app(
registry.clone(),
Duration::from_millis(0),
Some("https://crank.example.com".to_owned()),
))
.await;
let client = reqwest::Client::new();
let mcp_url = agent_mcp_url(&base_url, "sales-gated");
let initialized_session = initialize_session(&client, &mcp_url, &api_key).await;
let tool_call = json!({
"jsonrpc": "2.0",
"id": 9,
"method": "tools/call",
"params": {
"name": "crm_requires_human_approval",
"arguments": {
"email": "ada@example.com",
"api_key": "history-secret-canary"
}
}
});
let tool_result = post_jsonrpc(
&client,
&mcp_url,
&api_key,
Some(&initialized_session),
tool_call.clone(),
)
.await;
assert_eq!(
tool_result["result"]["structuredContent"]["status"],
"approval_required"
);
assert_eq!(tool_result["result"]["isError"], false);
let approval_id = tool_result["result"]["structuredContent"]["approval_id"]
.as_str()
.unwrap();
assert!(approval_id.starts_with("approval_"));
let repeated_tool_result = post_jsonrpc(
&client,
&mcp_url,
&api_key,
Some(&initialized_session),
tool_call,
)
.await;
assert_eq!(
repeated_tool_result["result"]["structuredContent"]["approval_id"], approval_id,
"deduplicated tools/call must return the persisted approval id",
);
let approvals_url = format!("{}/approvals", agent_mcp_url(&base_url, "sales-gated"));
let pending = client
.get(&approvals_url)
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(pending["items"].as_array().unwrap().len(), 1);
assert_eq!(pending["items"][0]["approval"]["id"], approval_id);
assert_eq!(
pending["items"][0]["approval"]["request_payload"]["email"],
"ada@example.com"
);
let pending_preview =
serde_json::to_string(&pending["items"][0]["approval"]["request_payload"]).unwrap();
assert!(!pending_preview.contains("history-secret-canary"));
assert!(pending_preview.contains("[REDACTED]"));
let logs = registry
.list_invocation_logs(ListInvocationLogsQuery {
workspace_id: &test_workspace_id(),
level: None,
status: None,
outcome_group: None,
search_text: None,
source: Some(InvocationSource::AgentToolCall),
operation_id: Some(&operation.id),
agent_id: Some(&test_agent_id("sales-gated")),
created_after: None,
created_before: None,
cursor_created_at: None,
cursor_id: None,
limit: 10,
})
.await
.unwrap();
assert!(!logs.is_empty());
let history_preview = serde_json::to_string(
&logs
.iter()
.map(|record| {
json!({
"request": record.log.request_preview,
"response": record.log.response_preview,
})
})
.collect::<Vec<_>>(),
)
.unwrap();
assert!(!history_preview.contains("history-secret-canary"));
assert!(!history_preview.contains("ada@example.com"));
assert!(history_preview.contains("approval_required"));
}
@@ -0,0 +1,137 @@
use super::*;
#[tokio::test]
async fn revoked_approval_key_stops_list_approve_and_deny_without_restart() {
let registry = test_registry().await;
let upstream_base_url = spawn_upstream_server().await;
let operation = test_operation(&upstream_base_url, "crm_approval_revoked");
registry
.create_operation(&test_workspace_id(), &operation, Some("alice"))
.await
.unwrap();
publish_agent_with_bindings(
&registry,
"sales-approval-revoked",
vec![binding_for_operation(&operation)],
)
.await;
let approval = ApprovalRequest {
id: ApprovalRequestId::new("approval_mcp_revoked_01"),
workspace_id: test_workspace_id(),
agent_id: test_agent_id("sales-approval-revoked"),
operation_id: operation.id.clone(),
operation_version: 1,
status: ApprovalRequestStatus::Pending,
risk_level: OperationApprovalRiskLevel::Dangerous,
request_id: None,
trace_id: None,
request_payload: json!({"email": "revoked@example.com"}),
response_payload: None,
created_at: OffsetDateTime::now_utc(),
expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(5),
decided_at: None,
decided_by_key_id: None,
decision_note: None,
};
registry
.create_approval_request(CreateApprovalRequest {
approval: &approval,
})
.await
.unwrap();
let approval_key = create_approval_platform_api_key(
&registry,
"sales-approval-revoked",
"approval-revoked-http",
)
.await;
let base_url = spawn_mcp_server(build_test_app(
registry.clone(),
Duration::from_millis(0),
Some("https://crank.example.com".to_owned()),
))
.await;
let client = reqwest::Client::new();
let approvals_url = format!(
"{}/approvals",
agent_mcp_url(&base_url, "sales-approval-revoked")
);
let approve_url = format!(
"{}/approvals/{}/approve",
agent_mcp_url(&base_url, "sales-approval-revoked"),
approval.id
);
let deny_url = format!(
"{}/approvals/{}/deny",
agent_mcp_url(&base_url, "sales-approval-revoked"),
approval.id
);
let before_revoke = client
.get(&approvals_url)
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
.send()
.await
.unwrap();
assert_eq!(before_revoke.status(), reqwest::StatusCode::OK);
let approval_key_id = registry
.list_platform_api_keys_for_agent(
&test_workspace_id(),
&test_agent_id("sales-approval-revoked"),
)
.await
.unwrap()
.into_iter()
.find(|record| record.api_key.name == "approval-revoked-http")
.unwrap()
.api_key
.id;
registry
.revoke_platform_api_key_for_agent(
&test_workspace_id(),
&test_agent_id("sales-approval-revoked"),
&approval_key_id,
&OffsetDateTime::now_utc(),
)
.await
.unwrap();
let denied_list = client
.get(&approvals_url)
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
.send()
.await
.unwrap();
assert_eq!(denied_list.status(), reqwest::StatusCode::UNAUTHORIZED);
let denied_approve = client
.post(&approve_url)
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
.json(&json!({ "approve": "yes" }))
.send()
.await
.unwrap();
assert_eq!(denied_approve.status(), reqwest::StatusCode::UNAUTHORIZED);
let denied_deny = client
.post(&deny_url)
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
.json(&json!({ "approve": "no" }))
.send()
.await
.unwrap();
assert_eq!(denied_deny.status(), reqwest::StatusCode::UNAUTHORIZED);
let current = registry
.get_approval_request_for_agent(
&test_workspace_id(),
&test_agent_id("sales-approval-revoked"),
&approval.id,
)
.await
.unwrap()
.unwrap();
assert_eq!(current.approval.status, ApprovalRequestStatus::Pending);
}
+2 -1
View File
@@ -134,7 +134,7 @@ fn build_test_app_with_store(
registry,
refresh_interval,
public_base_url,
SecretCrypto::new("test-master-key").unwrap(),
SecretCrypto::new("test-master-key-00000000000000000000000000000000").unwrap(),
crank_runtime::community_with_outbound_policy(
crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]),
)
@@ -417,6 +417,7 @@ pub(super) async fn publish_agent_with_policy(
version: 1,
published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
published_by: Some("alice"),
expected_state: None,
})
.await
.unwrap();
@@ -276,11 +276,16 @@ async fn exports_real_tool_stages_without_sensitive_data() {
.list_invocation_logs(ListInvocationLogsQuery {
workspace_id: &test_workspace_id(),
level: None,
status: None,
outcome_group: None,
search_text: None,
source: Some(InvocationSource::AgentToolCall),
operation_id: Some(&operation.id),
agent_id: None,
created_after: None,
created_before: None,
cursor_created_at: None,
cursor_id: None,
limit: 10,
})
.await
@@ -3,7 +3,8 @@ use super::common::*;
use std::time::Duration;
use crank_core::{
PlatformApiKeyScope, ToolAccessMode, ToolGroup, ToolSearchSettings, ToolSelectionPolicy,
AgentId, PlatformApiKeyScope, ToolAccessMode, ToolGroup, ToolSearchSettings,
ToolSelectionPolicy,
};
use crank_registry::PublishRequest;
use serde_json::json;
@@ -108,10 +109,16 @@ async fn search_mode_discovers_and_calls_tools_through_meta_tools() {
search["result"]["structuredContent"]["tools"][0]["name"],
"create_invoice"
);
assert_eq!(
search["result"]["structuredContent"]["catalog_revision"],
"agent-version-1"
assert!(
search["result"]["structuredContent"]["catalog_revision"]
.as_str()
.unwrap()
.starts_with("agent-version-1-catalog-revision-")
);
let catalog_revision = search["result"]["structuredContent"]["catalog_revision"]
.as_str()
.unwrap()
.to_owned();
let stale_call = post_jsonrpc(
&client,
@@ -131,7 +138,7 @@ async fn search_mode_discovers_and_calls_tools_through_meta_tools() {
assert_eq!(stale_call["result"]["isError"], true);
assert_eq!(
stale_call["result"]["structuredContent"]["error"]["code"],
"catalog_revision_changed"
"agent_catalog_result_stale"
);
let call = post_jsonrpc(
@@ -144,7 +151,7 @@ async fn search_mode_discovers_and_calls_tools_through_meta_tools() {
"params":{"name":"call_tool","arguments":{
"name":"create_invoice",
"arguments":{"email":"user@example.com"},
"catalog_revision":"agent-version-1"
"catalog_revision": catalog_revision
}}
}),
)
@@ -152,3 +159,116 @@ async fn search_mode_discovers_and_calls_tools_through_meta_tools() {
assert_eq!(call["result"]["isError"], false);
assert_eq!(call["result"]["structuredContent"]["id"], "lead_123");
}
#[tokio::test]
async fn stale_search_result_rejects_tool_call() {
let registry = test_registry().await;
let upstream_base_url = spawn_upstream_server().await;
let invoice = test_operation(&upstream_base_url, "stale_invoice_tool");
registry
.create_operation(&test_workspace_id(), &invoice, Some("alice"))
.await
.unwrap();
registry
.publish_operation(PublishRequest {
workspace_id: &test_workspace_id(),
operation_id: &invoice.id,
version: 1,
published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
published_by: Some("alice"),
})
.await
.unwrap();
publish_agent_with_policy(
&registry,
"stale-search-agent",
vec![binding_for_operation(&invoice)],
ToolSelectionPolicy {
mode: ToolAccessMode::Search,
groups: vec![ToolGroup {
id: "finance".to_owned(),
name: "Finance".to_owned(),
description: "Invoices and payments".to_owned(),
tool_names: vec![invoice.name.clone()],
}],
search: ToolSearchSettings { max_results: 5 },
},
)
.await;
let agent_id = AgentId::new("agent_stale-search-agent");
let api_key = create_platform_api_key(
&registry,
"stale-search-agent",
"mcp-stale-search",
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
)
.await;
let base_url = spawn_mcp_server(build_test_app(
registry.clone(),
Duration::from_millis(0),
Some("https://crank.example.com".to_owned()),
))
.await;
let client = reqwest::Client::new();
let mcp_url = agent_mcp_url(&base_url, "stale-search-agent");
let session = initialize_session(&client, &mcp_url, &api_key).await;
let search = post_jsonrpc(
&client,
&mcp_url,
&api_key,
Some(&session),
json!({
"jsonrpc":"2.0","id":3,"method":"tools/call",
"params":{"name":"search_tools","arguments":{"query":"invoice","group_ids":["finance"]}}
}),
)
.await;
let stale_revision = search["result"]["structuredContent"]["catalog_revision"]
.as_str()
.unwrap()
.to_owned();
registry
.unpublish_agent(
&test_workspace_id(),
&agent_id,
&OffsetDateTime::parse("2026-03-26T10:01:00Z", &Rfc3339).unwrap(),
None,
)
.await
.unwrap();
registry
.publish_agent(crank_registry::PublishAgentRequest {
workspace_id: &test_workspace_id(),
agent_id: &agent_id,
version: 1,
published_at: &OffsetDateTime::parse("2026-03-26T10:02:00Z", &Rfc3339).unwrap(),
published_by: Some("alice"),
expected_state: None,
})
.await
.unwrap();
let stale_call = post_jsonrpc(
&client,
&mcp_url,
&api_key,
Some(&session),
json!({
"jsonrpc":"2.0","id":4,"method":"tools/call",
"params":{"name":"call_tool","arguments":{
"name":"stale_invoice_tool",
"arguments":{"email":"user@example.com"},
"catalog_revision": stale_revision
}}
}),
)
.await;
assert_eq!(stale_call["result"]["isError"], true);
assert_eq!(
stale_call["result"]["structuredContent"]["error"]["code"],
"agent_catalog_result_stale"
);
}
@@ -140,7 +140,7 @@ fn build_test_app_with_store(
registry,
refresh_interval,
public_base_url,
SecretCrypto::new("test-master-key").unwrap(),
SecretCrypto::new("test-master-key-00000000000000000000000000000000").unwrap(),
crank_runtime::community_with_outbound_policy(
crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]),
)
@@ -234,11 +234,16 @@ async fn initializes_lists_and_calls_published_tool_via_mcp() {
.list_invocation_logs(ListInvocationLogsQuery {
workspace_id: &test_workspace_id(),
level: None,
status: None,
outcome_group: None,
search_text: None,
source: Some(crank_core::InvocationSource::AgentToolCall),
operation_id: Some(&operation.id),
agent_id: None,
created_after: None,
created_before: None,
cursor_created_at: None,
cursor_id: None,
limit: 10,
})
.await
@@ -333,11 +338,16 @@ async fn preserves_request_id_for_tool_call_invocations() {
.list_invocation_logs(ListInvocationLogsQuery {
workspace_id: &test_workspace_id(),
level: None,
status: None,
outcome_group: None,
search_text: None,
source: Some(crank_core::InvocationSource::AgentToolCall),
operation_id: Some(&operation.id),
agent_id: None,
created_after: None,
created_before: None,
cursor_created_at: None,
cursor_id: None,
limit: 10,
})
.await
@@ -432,11 +442,16 @@ async fn generates_request_id_for_tool_call_responses_and_logs() {
.list_invocation_logs(ListInvocationLogsQuery {
workspace_id: &test_workspace_id(),
level: None,
status: None,
outcome_group: None,
search_text: None,
source: Some(crank_core::InvocationSource::AgentToolCall),
operation_id: Some(&operation.id),
agent_id: None,
created_after: None,
created_before: None,
cursor_created_at: None,
cursor_id: None,
limit: 10,
})
.await
+321
View File
@@ -0,0 +1,321 @@
#[path = "integration/common.rs"]
mod common;
use std::time::Duration;
use crank_core::{
ExecutionStage, InvocationLevel, InvocationLog, InvocationLogId, InvocationSource,
InvocationStatus, OutcomeCertainty, PlatformApiKeyScope, ProductEventKind, Retryability,
};
use crank_registry::{
CreateInvocationLogRequest, InvocationHistoryWriteOutcome, ListInvocationLogsQuery,
ListProductEventsQuery, PublishRequest,
};
use serde_json::json;
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use common::{
agent_mcp_url, build_test_app, create_platform_api_key, initialize_session, post_jsonrpc,
publish_agent_for_operation, spawn_mcp_server, spawn_upstream_server, test_operation,
test_registry, test_workspace_id,
};
#[tokio::test]
async fn onboarding_completes_only_after_exact_key_successful_public_tool_call() {
let registry = test_registry().await;
let upstream_base_url = spawn_upstream_server().await;
let operation = test_operation(&upstream_base_url, "onboarding_first_call");
let mut operation_draft = operation.clone();
operation_draft.status = crank_core::OperationStatus::Draft;
operation_draft.published_at = None;
registry
.create_operation(
&test_workspace_id(),
&operation_draft,
Some("onboarding-test"),
)
.await
.unwrap();
let admin_test = InvocationLog {
id: InvocationLogId::new("log_onboarding_admin_test"),
workspace_id: test_workspace_id(),
agent_id: None,
platform_api_key_id: None,
operation_id: operation.id.clone(),
operation_version: Some(1),
source: InvocationSource::AdminTestRun,
level: InvocationLevel::Info,
status: InvocationStatus::Ok,
tool_name: operation.name.clone(),
message: "admin test succeeded".to_owned(),
request_id: Some("req_onboarding_admin_test".to_owned()),
trace_id: Some("4bf92f3577b34da6a3ce929d0e0e4736".to_owned()),
status_code: Some(200),
duration_ms: 1,
error_kind: None,
execution_stage: Some(ExecutionStage::Runtime),
execution_error_code: None,
retryability: Some(Retryability::Never),
outcome_certainty: Some(OutcomeCertainty::Certain),
request_preview: json!({}),
response_preview: json!({"ok": true}),
created_at: OffsetDateTime::parse("2026-08-23T07:59:00Z", &Rfc3339).unwrap(),
};
assert_eq!(
registry
.create_invocation_log(CreateInvocationLogRequest { log: &admin_test })
.await,
InvocationHistoryWriteOutcome::Recorded
);
registry
.publish_operation(PublishRequest {
workspace_id: &test_workspace_id(),
operation_id: &operation.id,
version: 1,
published_at: &OffsetDateTime::parse("2026-08-23T08:00:00Z", &Rfc3339).unwrap(),
published_by: Some("onboarding-test"),
})
.await
.unwrap();
publish_agent_for_operation(&registry, &operation, "onboarding-agent").await;
let selected_key_name = "onboarding-raw-key-canary";
let selected_key_id = format!("pk_{selected_key_name}");
let selected_key = create_platform_api_key(
&registry,
"onboarding-agent",
selected_key_name,
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
)
.await;
let other_key = create_platform_api_key(
&registry,
"onboarding-agent",
"onboarding-other",
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
)
.await;
let pre_call_projection = registry
.get_onboarding_projection(&test_workspace_id())
.await
.unwrap();
assert!(
pre_call_projection
.step(crank_core::OnboardingStepId::PublishOperation)
.is_some_and(|step| step.completed),
"fixture must expose a Published Operation: {pre_call_projection:#?}"
);
let base_url = spawn_mcp_server(build_test_app(
registry.clone(),
Duration::from_millis(0),
Some("https://crank.example.com".to_owned()),
))
.await;
let client = reqwest::Client::new();
let mcp_url = agent_mcp_url(&base_url, "onboarding-agent");
let selected_session = initialize_session(&client, &mcp_url, &selected_key).await;
let listed = post_jsonrpc(
&client,
&mcp_url,
&selected_key,
Some(&selected_session),
json!({"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}),
)
.await;
assert_eq!(listed["result"]["tools"][0]["name"], operation.name);
assert!(
exact_key_successes(&registry, &operation.id, &selected_key_id)
.await
.is_empty(),
"initialize and tools/list must not complete onboarding"
);
let failed = post_jsonrpc(
&client,
&mcp_url,
&selected_key,
Some(&selected_session),
json!({
"jsonrpc":"2.0",
"id":3,
"method":"tools/call",
"params":{"name":operation.name,"arguments":{}}
}),
)
.await;
assert_eq!(failed["result"]["isError"], true);
assert!(
exact_key_successes(&registry, &operation.id, &selected_key_id)
.await
.is_empty(),
"a failed tools/call must not complete onboarding"
);
let other_session = initialize_session(&client, &mcp_url, &other_key).await;
let other_success = post_jsonrpc(
&client,
&mcp_url,
&other_key,
Some(&other_session),
json!({
"jsonrpc":"2.0",
"id":4,
"method":"tools/call",
"params":{
"name":operation.name,
"arguments":{"email":"other@example.com"}
}
}),
)
.await;
assert_eq!(other_success["result"]["isError"], false);
assert!(
exact_key_successes(&registry, &operation.id, &selected_key_id)
.await
.is_empty(),
"a successful tools/call made with another key must not complete onboarding"
);
let selected_success = post_jsonrpc(
&client,
&mcp_url,
&selected_key,
Some(&selected_session),
json!({
"jsonrpc":"2.0",
"id":5,
"method":"tools/call",
"params":{
"name":operation.name,
"arguments":{"email":"selected@example.com"}
}
}),
)
.await;
assert_eq!(selected_success["result"]["isError"], false);
let exact_successes = exact_key_successes(&registry, &operation.id, &selected_key_id).await;
assert_eq!(
exact_successes.len(),
1,
"onboarding must complete from exactly one successful public tools/call made with the selected key"
);
assert_eq!(
exact_successes[0]["platform_api_key_id"], selected_key_id,
"successful invocation evidence must retain the typed key identity"
);
assert!(
exact_successes[0]["request_id"]
.as_str()
.is_some_and(|id| !id.is_empty())
);
assert!(
exact_successes[0]["trace_id"]
.as_str()
.is_some_and(|id| id.len() == 32)
);
// A direct MCP call can precede the first onboarding snapshot. The later
// server-owned eligibility write must repair the completion event without
// replacing the invocation timestamp that anchored the exact lineage.
let projection = registry
.ensure_onboarding_eligibility(&test_workspace_id(), OffsetDateTime::now_utc())
.await
.unwrap();
let operation_summary = registry
.get_operation_summary(&test_workspace_id(), &operation.id)
.await
.unwrap();
assert!(
projection.completed,
"projection did not complete: {projection:#?}; operation: {operation_summary:#?}"
);
let completion_events = registry
.list_product_events(ListProductEventsQuery {
workspace_id: &test_workspace_id(),
kind: Some(ProductEventKind::OnboardingCompleted),
created_after: OffsetDateTime::UNIX_EPOCH,
created_before: OffsetDateTime::now_utc() + time::Duration::minutes(1),
limit: 10,
})
.await
.unwrap();
assert_eq!(completion_events.len(), 1);
assert_eq!(
completion_events[0].event.occurred_at,
projection.first_call_at.unwrap()
);
let persisted_evidence = invocation_evidence(&registry, &operation.id).await;
assert!(!persisted_evidence.is_empty());
for evidence in persisted_evidence {
let serialized = serde_json::to_string(&evidence).unwrap();
assert!(
!serialized.contains(&selected_key),
"raw Bearer canary must never appear in serialized invocation evidence"
);
assert!(
!serialized.contains(&other_key),
"another raw Bearer secret must never appear in serialized invocation evidence"
);
for field in ["message", "request_preview", "response_preview"] {
let persisted_field = serde_json::to_string(&evidence[field]).unwrap();
assert!(
!persisted_field.contains(&selected_key),
"raw Bearer canary leaked into persisted {field}"
);
assert!(
!persisted_field.contains(&other_key),
"another raw Bearer secret leaked into persisted {field}"
);
}
}
}
async fn exact_key_successes(
registry: &crank_registry::PostgresRegistry,
operation_id: &crank_core::OperationId,
selected_key_id: &str,
) -> Vec<serde_json::Value> {
invocation_evidence(registry, operation_id)
.await
.into_iter()
.filter(|log| {
log.get("platform_api_key_id")
.and_then(serde_json::Value::as_str)
== Some(selected_key_id)
&& log.get("status").and_then(serde_json::Value::as_str) == Some("ok")
})
.collect()
}
async fn invocation_evidence(
registry: &crank_registry::PostgresRegistry,
operation_id: &crank_core::OperationId,
) -> Vec<serde_json::Value> {
registry
.list_invocation_logs(ListInvocationLogsQuery {
workspace_id: &test_workspace_id(),
level: None,
status: None,
outcome_group: None,
search_text: None,
source: Some(InvocationSource::AgentToolCall),
operation_id: Some(operation_id),
agent_id: None,
created_after: None,
created_before: None,
cursor_created_at: None,
cursor_id: None,
limit: 100,
})
.await
.unwrap()
.into_iter()
.map(|record| serde_json::to_value(record.log).unwrap())
.collect()
}
+20
View File
@@ -135,6 +135,26 @@
.refresh-btn:hover { color: var(--text-secondary); background: var(--bg-muted); }
.refresh-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.log-pagination {
display: flex;
justify-content: center;
padding: 12px 20px 18px;
}
.log-pagination .refresh-btn {
margin-left: 0;
}
.log-id-filter {
width: 180px;
margin: 0;
}
.approval-panel {
margin-bottom: 18px;
}
+82
View File
@@ -0,0 +1,82 @@
.onboarding-trigger {
position: fixed;
right: 22px;
bottom: 22px;
z-index: 780;
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 42px;
padding: 9px 14px;
border: 1px solid var(--border-color, #30363d);
border-radius: 999px;
background: var(--surface-raised, #161b22);
color: var(--text-primary, #f0f6fc);
box-shadow: 0 8px 28px rgba(0, 0, 0, .28);
cursor: pointer;
font: inherit;
font-weight: 600;
}
.onboarding-trigger:hover { border-color: var(--accent, #58a6ff); }
.onboarding-trigger:focus-visible,
.onboarding-panel button:focus-visible,
.onboarding-panel a:focus-visible { outline: 2px solid var(--accent, #58a6ff); outline-offset: 2px; }
.onboarding-panel {
position: fixed;
right: 22px;
bottom: 76px;
z-index: 790;
width: min(410px, calc(100vw - 28px));
max-height: min(700px, calc(100vh - 104px));
overflow: auto;
scroll-behavior: auto;
border: 1px solid var(--border-color, #30363d);
border-radius: 14px;
background: var(--surface-raised, #161b22);
color: var(--text-primary, #f0f6fc);
box-shadow: 0 18px 48px rgba(0, 0, 0, .4);
}
.onboarding-panel[hidden] { display: none; }
.onboarding-head { display: flex; justify-content: space-between; gap: 12px; padding: 17px 18px 12px; border-bottom: 1px solid var(--border-color, #30363d); }
.onboarding-title { margin: 0; font-size: 17px; }
.onboarding-subtitle { margin: 4px 0 0; color: var(--text-muted, #8b949e); font-size: 12px; }
.onboarding-head-actions { display: flex; gap: 4px; }
.onboarding-icon-button { border: 0; background: transparent; color: inherit; cursor: pointer; border-radius: 6px; min-width: 32px; min-height: 32px; }
.onboarding-body { padding: 14px 18px 18px; }
.onboarding-status { min-height: 20px; color: var(--text-muted, #8b949e); font-size: 12px; margin-bottom: 10px; }
.onboarding-error { padding: 12px; border: 1px solid var(--danger, #f85149); border-radius: 8px; color: var(--danger, #f85149); }
.onboarding-error-ids { margin-top: 6px; overflow-wrap: anywhere; font-size: 12px; }
.onboarding-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
.onboarding-step { display: grid; grid-template-columns: 26px 1fr; gap: 9px; padding: 10px; border: 1px solid var(--border-color, #30363d); border-radius: 9px; }
.onboarding-step[aria-current="step"] { border-color: var(--accent, #58a6ff); background: rgba(88, 166, 255, .08); }
.onboarding-step.is-regressed { border-color: var(--amber, #d29922); }
.onboarding-step-marker { display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; background: var(--surface-muted, #21262d); font-size: 12px; }
.onboarding-step.is-complete .onboarding-step-marker { background: var(--success, #3fb950); color: #07140a; }
.onboarding-step-title { font-weight: 600; font-size: 13px; }
.onboarding-step-reason { margin-top: 3px; color: var(--text-muted, #8b949e); font-size: 12px; }
.onboarding-step-action { margin-top: 8px; border: 0; border-radius: 7px; padding: 7px 10px; background: var(--accent, #58a6ff); color: #08111c; font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; }
.onboarding-completion { padding: 12px; border: 1px solid var(--success, #3fb950); border-radius: 9px; overflow-wrap: anywhere; }
.onboarding-completion a { color: var(--accent, #58a6ff); }
.onboarding-first-call-evidence { display: grid; grid-template-columns: max-content 1fr; gap: 4px 10px; margin: 10px 0; font-size: 12px; }
.onboarding-first-call-evidence dt { color: var(--text-muted, #8b949e); }
.onboarding-first-call-evidence dd { margin: 0; font-family: var(--font-mono, monospace); overflow-wrap: anywhere; }
.onboarding-deep-link-stale { position: fixed; left: 50%; top: 76px; z-index: 810; width: min(520px, calc(100vw - 28px)); transform: translateX(-50%); padding: 14px; border: 1px solid var(--amber, #d29922); border-radius: 10px; background: var(--surface-raised, #161b22); color: var(--text-primary, #f0f6fc); box-shadow: 0 12px 36px rgba(0, 0, 0, .35); }
.onboarding-footer { display: flex; justify-content: space-between; gap: 8px; margin-top: 12px; }
.onboarding-link-button { border: 0; background: transparent; color: var(--text-muted, #8b949e); text-decoration: underline; cursor: pointer; font: inherit; font-size: 12px; }
/* Active task surfaces own the pointer plane. The optional onboarding helper
must never cover drawer controls while guiding the user through that drawer. */
body:has(.drawer.open) .onboarding-trigger,
body:has(.drawer.open) .onboarding-panel { z-index: 149; }
@media (max-width: 640px) {
.onboarding-trigger { right: 14px; bottom: 14px; }
.onboarding-panel { right: 14px; bottom: 68px; }
}
@media (prefers-reduced-motion: reduce) {
.onboarding-panel, .onboarding-trigger { transition: none !important; animation: none !important; scroll-behavior: auto; }
}
+6 -4
View File
@@ -215,6 +215,7 @@
</button>
</div>
<div class="agent-card-date" style="margin-top:8px;" x-text="endpointHelpText(agent)"></div>
<div class="agent-card-date" style="margin-top:6px;" x-text="agentRevisionText(agent)"></div>
<!-- Footer -->
<div class="agent-card-footer">
@@ -224,15 +225,15 @@
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M11 2l3 3-8 8H3v-3l8-8z"/></svg>
<span data-i18n="agents.action.edit">Edit</span>
</button>
<button class="agent-action-btn" @click.stop="applyLifecycle(agent, lifecycleAction(agent).key)">
<button class="agent-action-btn" :disabled="lifecycleDisabled(agent)" @click.stop="applyLifecycle(agent, lifecycleAction(agent).key)">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 3.5v9l8-4.5-8-4.5z"/></svg>
<span x-text="lifecycleAction(agent).label"></span>
</button>
<button class="agent-action-btn" x-show="agent.raw_status !== 'archived'" @click.stop="applyLifecycle(agent, 'archive')">
<button class="agent-action-btn" x-show="agent.raw_status !== 'archived'" :disabled="lifecycleDisabled(agent)" @click.stop="applyLifecycle(agent, 'archive')">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 4.5h11v2l-1 6.5h-8l-1-6.5v-2z"/><path d="M5 4.5v-1A1.5 1.5 0 016.5 2h3A1.5 1.5 0 0111 3.5v1"/></svg>
<span data-i18n="agents.action.archive">Archive</span>
</button>
<button class="agent-action-btn danger" @click.stop="deleteAgent(agent.id)">
<button class="agent-action-btn danger" :disabled="lifecycleDisabled(agent)" @click.stop="deleteAgent(agent.id)">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h12M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M10 8v4M6 8v4"/><path d="M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9"/></svg>
<span data-i18n="agents.action.delete">Delete</span>
</button>
@@ -295,6 +296,7 @@
<div class="form-group">
<label class="form-label" data-i18n="agents.drawer.status">Status</label>
<div class="form-hint" x-show="drawerMode === 'edit'" x-text="drawerRevisionText()"></div>
<div class="agent-status-toggle">
<button class="agent-status-opt" :class="{ active: form.status === 'published' }" @click="form.status = 'published'">
<span class="status-dot" style="background: var(--success, #3fb950)"></span> <span data-i18n="agents.lifecycle.published">Published</span>
@@ -474,7 +476,7 @@
<div class="drawer-footer">
<button class="btn-ghost-sm" style="padding: 8px 16px; font-size: 13px;" @click="closeDrawer()" data-i18n="agents.drawer.cancel">Cancel</button>
<button class="btn-primary-sm" style="padding: 8px 20px; font-size: 13px;"
:disabled="!form.display_name.trim() || !form.slug.trim() || !catalogConfigValid"
:disabled="saving || !form.display_name.trim() || !form.slug.trim() || !catalogConfigValid"
@click="saveAgent()"
x-text="drawerMode === 'create' ? tKey('agents.drawer.create') : tKey('agents.drawer.save')">
Create agent
+22 -2
View File
@@ -251,10 +251,10 @@
<!-- ══ Create Key Modal ══ -->
<div class="modal-overlay" id="modal-create">
<div class="modal">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-create-title">
<div class="modal-header">
<span class="modal-title" id="modal-create-title" data-i18n="apikeys.modal.title">Create agent key</span>
<button class="modal-close" id="modal-close-btn" type="button">
<button class="modal-close" id="modal-close-btn" type="button" aria-label="Close">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/>
</svg>
@@ -277,6 +277,15 @@
<div style="display:flex;flex-direction:column;gap:8px;margin-top:2px;" id="scope-checkboxes">
</div>
</div>
<div class="callout warning" id="ambiguous-create-warning" data-testid="ambiguous-create-warning" hidden>
<div>
<strong data-i18n="apikeys.ambiguous.title">Creation result is uncertain.</strong>
<span data-i18n="apikeys.ambiguous.body">Key metadata was refreshed. Review the list before deliberately creating another key.</span>
<div style="margin-top:10px;">
<button class="btn-secondary" id="ambiguous-create-retry-btn" type="button" data-i18n="apikeys.ambiguous.retry">Refresh metadata and allow another attempt</button>
</div>
</div>
</div>
</div>
<div class="modal-body" id="modal-reveal-body" hidden>
<div class="callout warning" style="margin-bottom:16px;">
@@ -294,6 +303,13 @@
</svg>
</button>
</div>
<div class="callout warning" id="onboarding-clipboard-warning" data-testid="onboarding-clipboard-warning" hidden style="margin-top:12px;">
<div data-i18n="apikeys.modal.clipboard_warning">Your operating system clipboard is outside Crank control. Clear it after saving the key.</div>
</div>
<div id="onboarding-connection-config" data-testid="onboarding-connection-config" hidden style="margin-top:14px;">
<div class="field-label" data-i18n="apikeys.modal.connection_title">MCP connection configuration</div>
<div id="onboarding-connection-clients"></div>
</div>
</div>
<div class="modal-footer" id="modal-footer-create">
<button class="btn-secondary" id="modal-cancel-btn" type="button" data-i18n="apikeys.modal.cancel">Cancel</button>
@@ -305,6 +321,10 @@
</div>
</div>
<div class="callout warning" id="onboarding-key-lost" data-testid="onboarding-key-lost" hidden style="position:fixed;right:22px;bottom:82px;z-index:760;max-width:420px;">
<div data-i18n="apikeys.onboarding.key_lost">The key cannot be shown again. Create a new key and revoke the old one if its value was not saved.</div>
</div>
<!-- Template: scope checkbox row -->
<template id="tmpl-scope-checkbox">
<label class="scope-checkbox-label">
+7 -2
View File
@@ -30,17 +30,22 @@
</div>
<form id="login-form" novalidate>
<div class="field">
<div class="field" id="login-email-field">
<label class="field-label" for="email" data-i18n="login.email_label">Email address</label>
<input class="field-input" type="email" id="email" data-i18n-ph="login.email_placeholder" placeholder="you@acme.com" autocomplete="email" autofocus>
</div>
<div class="field" id="login-bootstrap-token-field" hidden>
<label class="field-label" for="bootstrap-token" data-i18n="login.bootstrap.token_label">Bootstrap token</label>
<input class="field-input" type="password" id="bootstrap-token" data-i18n-ph="login.bootstrap.token_placeholder" placeholder="Paste one-time bootstrap token" autocomplete="one-time-code"> <!-- community-scope: allow=one-time-token -->
</div>
<div class="field">
<label class="field-label" for="password" data-i18n="login.password">Password</label>
<input class="field-input" type="password" id="password" placeholder="••••••••" autocomplete="current-password">
</div>
<button class="btn-signin" type="submit" data-i18n="login.submit">Sign in</button>
<button class="btn-signin" id="login-submit" type="submit" data-i18n="login.submit">Sign in</button>
</form>
</div>
+33
View File
@@ -108,6 +108,31 @@
<div class="toolbar-sep"></div>
<select class="time-range-select" id="status-filter" data-i18n-title="logs.status_filter" title="Status">
<option value="all" selected data-i18n="logs.status.all">All statuses</option>
<option value="ok" data-i18n="logs.status.ok">Success</option>
<option value="error" data-i18n="logs.status.error">Error</option>
</select>
<select class="time-range-select" id="outcome-filter" data-i18n-title="logs.outcome_filter" title="Outcome">
<option value="all" selected data-i18n="logs.outcome.all">All outcomes</option>
<option value="success" data-i18n="usage.outcome.success">Success</option>
<option value="upstream" data-i18n="usage.outcome.upstream">Upstream</option>
<option value="client" data-i18n="usage.outcome.client">Client</option>
<option value="schema" data-i18n="usage.outcome.schema">Schema</option>
<option value="crank" data-i18n="usage.outcome.crank">Crank</option>
</select>
<div class="filter-bar-search log-id-filter">
<input type="text" id="operation-filter" data-i18n-ph="logs.filter.operation" placeholder="Operation ID" autocomplete="off" spellcheck="false">
</div>
<div class="filter-bar-search log-id-filter">
<input type="text" id="agent-filter" data-i18n-ph="logs.filter.agent" placeholder="Agent ID" autocomplete="off" spellcheck="false">
</div>
<div class="toolbar-sep"></div>
<button class="filter-chip active" data-level="all" id="chip-all" data-i18n="logs.level.all">All</button>
<button class="filter-chip" data-level="info" id="chip-info">
<span class="log-level info" style="padding:0 4px;font-size:10px;">INFO</span>
@@ -128,10 +153,18 @@
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M1.705 8.005a.75.75 0 01.834.656 5.5 5.5 0 009.592 2.97l-1.204-1.204a.25.25 0 01.177-.427h3.646a.25.25 0 01.25.25v3.646a.25.25 0 01-.427.177l-1.38-1.38A7.001 7.001 0 011.05 8.84a.75.75 0 01.656-.834zM8 2.5a5.487 5.487 0 00-4.131 1.869l1.204 1.204A.25.25 0 014.896 6H1.25A.25.25 0 011 5.75V2.104a.25.25 0 01.427-.177l1.38 1.38A7.001 7.001 0 0114.95 7.16a.75.75 0 01-1.49.178A5.501 5.501 0 008 2.5z"/></svg>
<span data-i18n="logs.refresh">Refresh</span>
</button>
<button class="refresh-btn" id="export-logs-btn" type="button">
<span data-i18n="logs.export">Export CSV</span>
</button>
</div>
<div class="log-list" id="log-list">
</div>
<div class="log-pagination">
<button class="refresh-btn" id="load-more-logs-btn" type="button" hidden>
<span data-i18n="logs.load_more">Load more</span>
</button>
</div>
</div>
</div>
+10
View File
@@ -147,6 +147,16 @@
</div>
</div>
<div class="section-card" style="margin-bottom:20px;">
<div class="section-card-header">
<div>
<div class="section-card-title" data-i18n="usage.outcomes.title">Outcomes</div>
<div class="section-card-subtitle" data-i18n="usage.outcomes.subtitle">Grouped by safe execution outcome.</div>
</div>
</div>
<div class="resource-list usage-card-list" id="usage-outcome-list" style="display:grid;padding:16px 20px;"></div>
</div>
<!-- Per-operation table -->
<div class="section-card">
<div class="section-card-header">
+11 -1
View File
@@ -131,7 +131,7 @@ tls:
<div class="section-divider-line"></div>
</div>
<div id="wizard-live-status" class="info-callout" hidden style="margin-bottom: 20px;">
<div id="wizard-live-status" class="info-callout" role="status" aria-live="polite" aria-atomic="true" hidden style="margin-bottom: 20px;">
<svg class="info-callout-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<circle cx="8" cy="8" r="7"/>
<path d="M8 11V8M8 5v-.5"/>
@@ -284,6 +284,16 @@ tls:
<button id="wizard-run-test" class="btn-primary-sm" type="button" data-i18n="wizard.step5.run_test">Запустить тест</button>
<button id="wizard-copy-test-response" class="btn-ghost-sm" type="button" data-i18n="wizard.step5.use_response_output">Использовать ответ как выходной пример</button>
</div>
<div id="wizard-test-correlation" class="info-callout" role="status" aria-live="polite" hidden>
<div class="info-callout-body">
<div><span data-i18n="wizard.test.request_id">Request ID</span>: <code id="wizard-test-request-id"></code></div>
<div><span data-i18n="wizard.test.trace_id">Trace ID</span>: <code id="wizard-test-trace-id"></code></div>
<div style="display:flex; gap:8px; flex-wrap:wrap; margin-top:8px;">
<button id="wizard-copy-request-id" class="btn-ghost-sm" type="button" data-i18n="wizard.test.copy_request_id">Copy Request ID</button>
<button id="wizard-copy-trace-id" class="btn-ghost-sm" type="button" data-i18n="wizard.test.copy_trace_id">Copy Trace ID</button>
</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" data-i18n="wizard.step5.request_preview">Предпросмотр API-запроса</label>
+4 -1
View File
@@ -341,9 +341,12 @@
<button class="row-btn row-btn-edit" :title="tKey('ops.action.edit')" @click="editOperation(op)">
<svg width="14" height="14"><use href="icons/general/edit.svg#icon"/></svg>
</button>
<button class="row-btn row-btn-delete" :title="tKey('ops.action.delete')" @click="deleteOperation(op.id)">
<button x-show="op.can_delete" class="row-btn row-btn-delete" :title="tKey('ops.action.delete')" @click="deleteOperation(op.id)">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h12M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M10 8v4M6 8v4"/><path d="M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9"/></svg>
</button>
<button x-show="op.raw_status !== 'archived' && !op.can_delete" class="row-btn" data-testid="operation-archive" :title="tKey('ops.action.archive')" @click="archiveOperation(op)">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M2 3h12v3H2zM3 6h10v7H3zM6 9h4"/></svg>
</button>
</div>
</td>
</tr>
+123 -18
View File
@@ -20,6 +20,7 @@ function mapAgent(agent) {
created_at: agent.created_at,
current_draft_version: agent.current_draft_version || 1,
latest_published_version: agent.latest_published_version,
catalog_revision: typeof agent.catalog_revision === 'number' ? agent.catalog_revision : 0,
mcp_endpoint: agent.mcp_endpoint || '',
};
@@ -60,6 +61,7 @@ document.addEventListener('alpine:init', function() {
drawerMode: 'create',
editingId: null,
saving: false,
lifecycleBusyId: null,
form: {
display_name: '',
@@ -79,6 +81,9 @@ document.addEventListener('alpine:init', function() {
searchPreviewItems: [],
searchPreviewLoading: false,
searchPreviewRan: false,
_loadGeneration: 0,
_mutationGeneration: 0,
_queryHydrated: false,
async init() {
var self = this;
@@ -88,6 +93,7 @@ document.addEventListener('alpine:init', function() {
this.workspaceId = workspace ? workspace.id : null;
await this.loadCapabilities();
await this.reload();
this.hydrateOnboardingQuery();
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape' && self.drawerOpen) {
@@ -100,6 +106,9 @@ document.addEventListener('alpine:init', function() {
});
window.addEventListener('crank:workspacechange', async function(event) {
self._loadGeneration += 1;
self._mutationGeneration += 1;
self._queryHydrated = false;
self.workspaceId = event.detail ? event.detail.id : null;
await self.loadCapabilities();
await self.reload();
@@ -119,6 +128,8 @@ document.addEventListener('alpine:init', function() {
},
async reload() {
var generation = ++this._loadGeneration;
var workspaceId = this.workspaceId;
this.loading = true;
this.loadError = '';
@@ -132,18 +143,39 @@ document.addEventListener('alpine:init', function() {
try {
var responses = await Promise.all([
window.CrankApi.listAgents(this.workspaceId),
window.CrankApi.listOperations(this.workspaceId),
window.CrankApi.listAgents(workspaceId),
window.CrankApi.listOperations(workspaceId),
]);
if (generation !== this._loadGeneration || workspaceId !== this.workspaceId) return;
this.agents = ((responses[0] && responses[0].items) || []).map(mapAgent);
this.operations = ((responses[1] && responses[1].items) || []).map(mapOperation);
} catch (error) {
if (generation !== this._loadGeneration || workspaceId !== this.workspaceId) return;
this.agents = [];
this.operations = [];
this.loadError = error.message || this.tKey('agents.error.load');
}
this.loading = false;
if (generation === this._loadGeneration && workspaceId === this.workspaceId) this.loading = false;
},
hydrateOnboardingQuery() {
if (this._queryHydrated) return;
this._queryHydrated = true;
var params = new URLSearchParams(window.location.search);
if (params.get('onboarding') !== '1' || params.get('action') !== 'create') return;
var operationId = params.get('operationId') || '';
var expectedVersion = Number(params.get('operationVersion') || 0);
var operation = this.operations.find(function(item) { return item.id === operationId; });
this.openCreate();
if (operation && operation.latest_published_version
&& (!expectedVersion || operation.latest_published_version === expectedVersion)) {
this.form.selectedOps = [operation.id];
}
setTimeout(function() {
var input = document.querySelector('.drawer input.form-input');
if (input) input.focus();
}, 0);
},
get filteredAgents() {
@@ -254,6 +286,9 @@ document.addEventListener('alpine:init', function() {
accessMode: 'direct',
groups: [],
searchMaxResults: 8,
catalogRevision: 0,
currentDraftVersion: 1,
latestPublishedVersion: null,
};
this.opSearch = '';
this.slugManuallyEdited = false;
@@ -283,6 +318,9 @@ document.addEventListener('alpine:init', function() {
searchMaxResults: policy.search && policy.search.max_results
? policy.search.max_results
: 8,
catalogRevision: agent.catalog_revision || 0,
currentDraftVersion: agent.current_draft_version || 1,
latestPublishedVersion: agent.latest_published_version,
};
this.opSearch = '';
this.slugManuallyEdited = true;
@@ -293,6 +331,7 @@ document.addEventListener('alpine:init', function() {
closeDrawer() {
this.drawerOpen = false;
this.saving = false;
this.lifecycleBusyId = null;
},
onNameInput(value) {
@@ -514,6 +553,8 @@ document.addEventListener('alpine:init', function() {
}
this.saving = true;
var mutationGeneration = ++this._mutationGeneration;
var workspaceId = this.workspaceId;
try {
var agentId = this.editingId;
@@ -524,7 +565,7 @@ document.addEventListener('alpine:init', function() {
var previousStatus = existingAgent ? (existingAgent.raw_status || 'draft') : 'draft';
if (this.drawerMode === 'create') {
var created = await window.CrankApi.createAgent(this.workspaceId, {
var created = await window.CrankApi.createAgent(workspaceId, {
slug: this.form.slug,
display_name: this.form.display_name,
description: this.form.description,
@@ -534,17 +575,17 @@ document.addEventListener('alpine:init', function() {
agentId = created.agent_id;
currentVersion = created.version || 1;
} else {
await window.CrankApi.updateAgent(this.workspaceId, this.editingId, {
await window.CrankApi.updateAgent(workspaceId, this.editingId, {
slug: this.form.slug,
display_name: this.form.display_name,
description: this.form.description,
});
var agent = await window.CrankApi.getAgent(this.workspaceId, this.editingId);
var agent = await window.CrankApi.getAgent(workspaceId, this.editingId);
currentVersion = agent.current_draft_version || 1;
}
var savedVersion = await window.CrankApi.saveAgentBindings(
this.workspaceId,
workspaceId,
agentId,
{
bindings: this.agentBindings(),
@@ -554,16 +595,19 @@ document.addEventListener('alpine:init', function() {
currentVersion = savedVersion.version || currentVersion;
if (this.form.status === 'published') {
await window.CrankApi.publishAgent(this.workspaceId, agentId, {
await window.CrankApi.publishAgent(workspaceId, agentId, {
version: currentVersion,
});
} else if (this.form.status === 'archived') {
await window.CrankApi.archiveAgent(this.workspaceId, agentId);
await window.CrankApi.archiveAgent(workspaceId, agentId);
} else if (this.drawerMode === 'edit' && previousStatus !== 'draft') {
await window.CrankApi.unpublishAgent(this.workspaceId, agentId);
await window.CrankApi.unpublishAgent(workspaceId, agentId);
}
if (mutationGeneration !== this._mutationGeneration || workspaceId !== this.workspaceId) return;
await this.reload();
if (mutationGeneration !== this._mutationGeneration || workspaceId !== this.workspaceId) return;
if (window.CrankUi) {
window.CrankUi.success(
this.tfKey('agents.toast.saved_message', {
@@ -576,10 +620,12 @@ document.addEventListener('alpine:init', function() {
);
}
this.closeDrawer();
if (window.CrankOnboarding) window.CrankOnboarding.signalRefresh();
} catch (error) {
if (mutationGeneration !== this._mutationGeneration || workspaceId !== this.workspaceId) return;
if (window.CrankUi) {
window.CrankUi.error(
error.message || this.tKey('agents.toast.save_error_message'),
this.agentMutationErrorMessage(error, this.tKey('agents.toast.save_error_message')),
this.tKey('agents.toast.save_error_title')
);
}
@@ -588,9 +634,11 @@ document.addEventListener('alpine:init', function() {
},
async deleteAgent(id) {
if (this.lifecycleBusyId) return;
if (!confirm(this.tKey('agents.toast.delete_confirm'))) return;
try {
this.lifecycleBusyId = id;
var agent = this.agents.find(function(item) { return item.id === id; });
await window.CrankApi.deleteAgent(this.workspaceId, id);
await this.reload();
@@ -603,29 +651,44 @@ document.addEventListener('alpine:init', function() {
} catch (error) {
if (window.CrankUi) {
window.CrankUi.error(
error.message || this.tKey('agents.toast.delete_error_message'),
this.agentMutationErrorMessage(error, this.tKey('agents.toast.delete_error_message')),
this.tKey('agents.toast.delete_error_title')
);
}
} finally {
this.lifecycleBusyId = null;
}
},
async applyLifecycle(agent, action) {
if (!this.workspaceId || !window.CrankApi) {
if (!this.workspaceId || !window.CrankApi || this.lifecycleBusyId) {
return;
}
var confirmKey = action === 'publish'
? 'agents.toast.lifecycle_publish_confirm'
: action === 'unpublish'
? 'agents.toast.lifecycle_unpublish_confirm'
: 'agents.toast.lifecycle_archive_confirm';
if (!confirm(this.tfKey(confirmKey, { name: agent.display_name }))) {
return;
}
try {
var mutationGeneration = ++this._mutationGeneration;
var workspaceId = this.workspaceId;
this.lifecycleBusyId = agent.id;
if (action === 'publish') {
await window.CrankApi.publishAgent(this.workspaceId, agent.id, {
await window.CrankApi.publishAgent(workspaceId, agent.id, {
version: agent.current_draft_version || 1,
});
} else if (action === 'unpublish') {
await window.CrankApi.unpublishAgent(this.workspaceId, agent.id);
await window.CrankApi.unpublishAgent(workspaceId, agent.id);
} else if (action === 'archive') {
await window.CrankApi.archiveAgent(this.workspaceId, agent.id);
await window.CrankApi.archiveAgent(workspaceId, agent.id);
}
if (mutationGeneration !== this._mutationGeneration || workspaceId !== this.workspaceId) return;
await this.reload();
if (mutationGeneration !== this._mutationGeneration || workspaceId !== this.workspaceId) return;
if (window.CrankUi) {
window.CrankUi.success(
action === 'publish'
@@ -636,13 +699,17 @@ document.addEventListener('alpine:init', function() {
this.tKey('agents.toast.lifecycle_title')
);
}
if (window.CrankOnboarding) window.CrankOnboarding.signalRefresh();
} catch (error) {
if (workspaceId !== this.workspaceId) return;
if (window.CrankUi) {
window.CrankUi.error(
error.message || this.tKey('agents.toast.lifecycle_error_message'),
this.agentMutationErrorMessage(error, this.tKey('agents.toast.lifecycle_error_message')),
this.tKey('agents.toast.lifecycle_error_title')
);
}
} finally {
this.lifecycleBusyId = null;
}
},
@@ -651,7 +718,7 @@ document.addEventListener('alpine:init', function() {
return { key: 'unpublish', label: this.tKey('agents.lifecycle.unpublish') };
}
if (agent.raw_status === 'archived') {
return { key: 'unpublish', label: this.tKey('agents.lifecycle.restore_draft') };
return { key: 'archive', label: this.tKey('agents.lifecycle.archived') };
}
return { key: 'publish', label: this.tKey('agents.lifecycle.publish') };
},
@@ -662,6 +729,44 @@ document.addEventListener('alpine:init', function() {
return this.tKey('agents.lifecycle.draft');
},
lifecycleDisabled(agent) {
return this.saving
|| agent.raw_status === 'archived'
|| (this.lifecycleBusyId && this.lifecycleBusyId !== agent.id);
},
agentRevisionText(agent) {
return this.tfKey('agents.card.revision', {
draft: agent.current_draft_version || 1,
published: agent.latest_published_version || '—',
revision: agent.catalog_revision || 0,
});
},
drawerRevisionText() {
if (this.drawerMode !== 'edit') return '';
return this.tfKey('agents.drawer.revision', {
draft: this.form.currentDraftVersion || 1,
published: this.form.latestPublishedVersion || '—',
revision: this.form.catalogRevision || 0,
});
},
agentMutationErrorMessage(error, fallback) {
var errorCode = error
&& error.payload
&& error.payload.error
&& error.payload.error.context
&& error.payload.error.context.error_code;
if (errorCode === 'agent_stale_revision' || errorCode === 'agent_precondition_required') {
return this.tKey('agents.toast.stale_message');
}
if (errorCode === 'agent_delete_forbidden') {
return this.tKey('agents.toast.delete_forbidden_message');
}
return error && error.message ? error.message : fallback;
},
mcpEndpoint(agent) {
if (agent.mcp_endpoint) return agent.mcp_endpoint;
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
+324 -52
View File
@@ -3,8 +3,15 @@ var AGENTS = [];
var currentWorkspaceId = null;
var currentAgentId = null;
var activeKeyKind = 'mcp_client';
var selectedScopes = new Set(['read']);
var selectedScopes = new Set(['read', 'write']);
var search = '';
var loadGeneration = 0;
var modalGeneration = 0;
var keyMutations = {};
var onboardingQueryHydrated = false;
var onboardingRevealWasCreated = false;
var createReconciliationRequired = false;
var revealedKeyGeneration = 0;
var SCOPES_BY_KIND = {
mcp_client: ['read', 'write', 'deploy'],
@@ -58,6 +65,7 @@ function mapAgentRecord(record) {
slug: record.slug,
displayName: record.display_name || record.slug || record.id,
mcpEndpoint: record.mcp_endpoint || '',
catalogRevision: Number(record.catalog_revision || 0),
};
}
@@ -67,11 +75,13 @@ function currentWorkspace() {
async function loadKeys() {
var workspace = currentWorkspace();
currentWorkspaceId = workspace ? workspace.id : null;
var workspaceId = workspace ? workspace.id : null;
var generation = ++loadGeneration;
currentWorkspaceId = workspaceId;
setTableLoading(true);
if (!currentWorkspaceId || !window.CrankApi) {
if (!workspaceId || !window.CrankApi) {
AGENTS = [];
KEYS = [];
currentAgentId = null;
@@ -79,11 +89,14 @@ async function loadKeys() {
setCreateButtonState();
setTableLoading(false);
renderTable(tKey('apikeys.error.api'));
return;
return false;
}
try {
var response = await window.CrankApi.listAgents(currentWorkspaceId);
var response = await window.CrankApi.listAgents(workspaceId);
if (generation !== loadGeneration || workspaceId !== (currentWorkspace() && currentWorkspace().id)) {
return;
}
AGENTS = ((response && response.items) || []).map(mapAgentRecord);
if (!AGENTS.length) {
currentAgentId = null;
@@ -92,21 +105,38 @@ async function loadKeys() {
setCreateButtonState();
setTableLoading(false);
renderTable();
return;
return true;
}
if (!currentAgentId || !AGENTS.some(function(agent) { return agent.id === currentAgentId; })) {
var onboardingParams = new URLSearchParams(window.location.search);
var requestedAgentId = onboardingParams.get('onboarding') === '1' ? onboardingParams.get('agentId') : '';
if (requestedAgentId && AGENTS.some(function(agent) { return agent.id === requestedAgentId; })) {
currentAgentId = requestedAgentId;
} else if (!currentAgentId || !AGENTS.some(function(agent) { return agent.id === currentAgentId; })) {
currentAgentId = AGENTS[0].id;
}
var agentId = currentAgentId;
renderAgentPicker();
var keysResponse = await window.CrankApi.listAgentPlatformApiKeys(
currentWorkspaceId,
currentAgentId
workspaceId,
agentId
);
if (
generation !== loadGeneration
|| workspaceId !== (currentWorkspace() && currentWorkspace().id)
|| agentId !== currentAgentId
) {
return;
}
KEYS = ((keysResponse && keysResponse.items) || []).map(mapKeyRecord);
setCreateButtonState();
setTableLoading(false);
renderTable();
hydrateOnboardingKeyQuery();
return true;
} catch (error) {
if (generation !== loadGeneration || workspaceId !== (currentWorkspace() && currentWorkspace().id)) {
return;
}
AGENTS = [];
KEYS = [];
currentAgentId = null;
@@ -114,6 +144,7 @@ async function loadKeys() {
setCreateButtonState();
setTableLoading(false);
renderTable(error.message || tKey('apikeys.error.load'));
return false;
}
}
@@ -133,48 +164,68 @@ function setTableLoading(on) {
async function revokeKey(id) {
if (!confirm(tKey('apikeys.confirm.revoke'))) return;
if (!currentAgentId) return;
if (keyMutations[id]) return;
try {
var workspaceId = currentWorkspaceId;
var agentId = currentAgentId;
var key = KEYS.find(function(item) { return item.id === id; });
await window.CrankApi.revokeAgentPlatformApiKey(currentWorkspaceId, currentAgentId, id);
await loadKeys();
if (window.CrankUi) {
keyMutations[id] = true;
renderTable();
await window.CrankApi.revokeAgentPlatformApiKey(workspaceId, agentId, id);
if (workspaceId === currentWorkspaceId && agentId === currentAgentId) {
await loadKeys();
}
if (window.CrankUi && workspaceId === currentWorkspaceId && agentId === currentAgentId) {
window.CrankUi.success(
tfKey('apikeys.toast.revoke_message', { name: key ? key.name : '' }),
tKey('apikeys.toast.revoke_title')
);
}
} catch (error) {
if (window.CrankUi) {
if (window.CrankUi && workspaceId === currentWorkspaceId && agentId === currentAgentId) {
window.CrankUi.error(
error.message || tKey('apikeys.toast.revoke_error_message'),
tKey('apikeys.toast.revoke_error_title')
);
}
} finally {
delete keyMutations[id];
renderTable();
}
}
async function deleteKey(id) {
if (!confirm(tKey('apikeys.confirm.delete'))) return;
if (!currentAgentId) return;
if (keyMutations[id]) return;
try {
var workspaceId = currentWorkspaceId;
var agentId = currentAgentId;
var key = KEYS.find(function(item) { return item.id === id; });
await window.CrankApi.deleteAgentPlatformApiKey(currentWorkspaceId, currentAgentId, id);
await loadKeys();
if (window.CrankUi) {
keyMutations[id] = true;
renderTable();
await window.CrankApi.deleteAgentPlatformApiKey(workspaceId, agentId, id);
if (workspaceId === currentWorkspaceId && agentId === currentAgentId) {
await loadKeys();
}
if (window.CrankUi && workspaceId === currentWorkspaceId && agentId === currentAgentId) {
window.CrankUi.success(
tfKey('apikeys.toast.delete_message', { name: key ? key.name : '' }),
tKey('apikeys.toast.delete_title')
);
}
} catch (error) {
if (window.CrankUi) {
if (window.CrankUi && workspaceId === currentWorkspaceId && agentId === currentAgentId) {
window.CrankUi.error(
error.message || tKey('apikeys.toast.delete_error_message'),
tKey('apikeys.toast.delete_error_title')
);
}
} finally {
delete keyMutations[id];
renderTable();
}
}
@@ -187,9 +238,84 @@ async function createKey(name, scopes) {
return {
rawKey: created.secret,
record: mapKeyRecord(created.api_key),
connection: created.connection || null,
};
}
function isOnboardingKeyQuery() {
var params = new URLSearchParams(window.location.search);
return params.get('onboarding') === '1' && params.get('action') === 'create';
}
function hydrateOnboardingKeyQuery() {
if (!isOnboardingKeyQuery() || onboardingQueryHydrated) return;
onboardingQueryHydrated = true;
var params = new URLSearchParams(window.location.search);
var selected = currentAgent();
var expectedRevision = Number(params.get('agentRevision') || 0);
if (!selected || (expectedRevision && selected.catalogRevision !== expectedRevision)) return;
var hasActiveKey = KEYS.some(function(key) { return key.status === 'active' && key.keyKind === 'mcp_client'; });
if (hasActiveKey) {
showLostKeyRecovery();
return;
}
activeKeyKind = 'mcp_client';
renderKeyKindTabs();
openModal();
}
function showLostKeyRecovery() {
var element = document.getElementById('onboarding-key-lost');
if (element) element.hidden = false;
}
function renderEphemeralConnection(connection) {
var root = document.getElementById('onboarding-connection-config');
var clients = document.getElementById('onboarding-connection-clients');
var warning = document.getElementById('onboarding-clipboard-warning');
if (!root || !clients || !warning) return;
clients.replaceChildren();
if (!connection || !connection.endpoint) {
root.hidden = true;
warning.hidden = true;
return;
}
var endpoint = document.createElement('code');
endpoint.textContent = connection.endpoint;
clients.appendChild(endpoint);
(connection.clients || []).forEach(function(client) {
var block = document.createElement('div');
block.style.marginTop = '10px';
var label = document.createElement('strong');
label.textContent = client.client;
var pre = document.createElement('pre');
pre.className = 'log-detail-block';
pre.textContent = JSON.stringify(client.config, null, 2);
var copy = document.createElement('button');
copy.type = 'button';
copy.className = 'btn-secondary';
copy.textContent = tKey('apikeys.modal.copy_config');
copy.addEventListener('click', async function() {
var value = pre.textContent;
copy.disabled = true;
try {
await copyEphemeralValue(value);
wipeRevealedKey();
} catch (_error) {
showClipboardFailure();
} finally {
copy.disabled = false;
}
});
block.appendChild(label);
block.appendChild(pre);
block.appendChild(copy);
clients.appendChild(block);
});
root.hidden = false;
warning.hidden = false;
}
function currentAgent() {
return AGENTS.find(function(agent) { return agent.id === currentAgentId; }) || null;
}
@@ -311,7 +437,7 @@ function renderTable(errorMessage) {
if (subtitle) {
var active = visibleKeys.filter(function(key) { return key.status === 'active'; }).length;
var revoked = visibleKeys.filter(function(key) { return key.status === 'revoked'; }).length;
var revoked = visibleKeys.filter(function(key) { return key.status !== 'active'; }).length;
subtitle.textContent = currentAgentId
? tfKey('apikeys.active.subtitle', { active: active, revoked: revoked })
: tKey('apikeys.agent.empty_hint');
@@ -325,7 +451,7 @@ function renderTable(errorMessage) {
errorCell.textContent = errorMessage;
errorRow.appendChild(errorCell);
tbody.appendChild(errorRow);
renderKeyCards([], errorMessage);
renderKeyCards([], errorMessage, visibleKeys.length);
return;
}
@@ -339,7 +465,7 @@ function renderTable(errorMessage) {
: tKey('apikeys.agent.empty_hint');
empty.appendChild(td);
tbody.appendChild(empty);
renderKeyCards(rows);
renderKeyCards(rows, null, visibleKeys.length);
return;
}
@@ -361,33 +487,42 @@ function renderTable(errorMessage) {
var badge = document.createElement('span');
badge.className = key.status === 'active' ? 'badge badge-active' : 'badge badge-revoked';
badge.textContent = key.status === 'active' ? tKey('apikeys.status.active') : tKey('apikeys.status.revoked');
badge.textContent = keyStatusLabel(key.status);
node.querySelector('.col-status').appendChild(badge);
var actionsActive = node.querySelector('.actions-active');
var actionsRevoked = node.querySelector('.actions-revoked');
if (key.status === 'active') {
actionsActive.querySelectorAll('button').forEach(function(button) {
button.disabled = Boolean(keyMutations[key.id]);
});
actionsActive.querySelector('[title=\"Copy key prefix\"]').addEventListener('click', function() {
copyPrefix(key.prefix);
});
actionsActive.querySelector('[title=\"Revoke key\"]').addEventListener('click', function() {
revokeKey(key.id);
});
} else {
} else if (key.status === 'revoked') {
actionsActive.hidden = true;
actionsRevoked.hidden = false;
actionsRevoked.querySelectorAll('button').forEach(function(button) {
button.disabled = Boolean(keyMutations[key.id]);
});
actionsRevoked.querySelector('[title=\"Delete\"]').addEventListener('click', function() {
deleteKey(key.id);
});
} else {
actionsActive.hidden = true;
actionsRevoked.hidden = true;
}
tbody.appendChild(node);
});
renderKeyCards(rows);
renderKeyCards(rows, null, visibleKeys.length);
}
function renderKeyCards(rows, errorMessage) {
function renderKeyCards(rows, errorMessage, visibleKeyCount) {
var cardList = document.getElementById('keys-card-list');
if (!cardList) return;
@@ -402,7 +537,7 @@ function renderKeyCards(rows, errorMessage) {
cardList.appendChild(
buildKeyCardMessage(
currentAgentId
? (visibleKeys.length ? tKey('apikeys.empty.search') : emptyTextForKind())
? (visibleKeyCount ? tKey('apikeys.empty.search') : emptyTextForKind())
: tKey('apikeys.agent.empty_hint'),
false
)
@@ -431,9 +566,7 @@ function renderKeyCards(rows, errorMessage) {
var statusBadge = document.createElement('span');
statusBadge.className = key.status === 'active' ? 'badge badge-active' : 'badge badge-revoked';
statusBadge.textContent = key.status === 'active'
? tKey('apikeys.status.active')
: tKey('apikeys.status.revoked');
statusBadge.textContent = keyStatusLabel(key.status);
actions.appendChild(statusBadge);
header.appendChild(headerMain);
header.appendChild(actions);
@@ -451,14 +584,14 @@ function renderKeyCards(rows, errorMessage) {
if (key.status === 'active') {
actionRow.appendChild(buildCardAction('apikeys.action.copy_prefix', function() {
copyPrefix(key.prefix);
}));
}, false, Boolean(keyMutations[key.id])));
actionRow.appendChild(buildCardAction('apikeys.action.revoke', function() {
revokeKey(key.id);
}, true));
} else {
}, true, Boolean(keyMutations[key.id])));
} else if (key.status === 'revoked') {
actionRow.appendChild(buildCardAction('apikeys.action.delete', function() {
deleteKey(key.id);
}, true));
}, true, Boolean(keyMutations[key.id])));
}
card.appendChild(actionRow);
@@ -477,6 +610,12 @@ function buildKeyCardMessage(text, isError) {
return card;
}
function keyStatusLabel(status) {
if (status === 'active') return tKey('apikeys.status.active');
if (status === 'deleted') return tKey('apikeys.status.deleted');
return tKey('apikeys.status.revoked');
}
function emptyTextForKind() {
return activeKeyKind === 'approval'
? tKey('apikeys.empty.approval')
@@ -497,25 +636,70 @@ function buildMetaItem(labelKey, valueText) {
return item;
}
function buildCardAction(labelKey, onClick, isDanger) {
function buildCardAction(labelKey, onClick, isDanger, disabled) {
var button = document.createElement('button');
button.type = 'button';
button.className = isDanger ? 'btn-secondary' : 'btn-secondary';
button.textContent = tKey(labelKey);
button.disabled = Boolean(disabled);
button.addEventListener('click', onClick);
return button;
}
var modal = document.getElementById('modal-create');
function wipeRevealedKey(expectedGeneration) {
if (expectedGeneration && revealedKeyGeneration && revealedKeyGeneration !== expectedGeneration) {
return;
}
var keyValue = document.getElementById('reveal-key-value');
if (keyValue) {
keyValue.textContent = '';
}
var clients = document.getElementById('onboarding-connection-clients');
if (clients) clients.replaceChildren();
var connection = document.getElementById('onboarding-connection-config');
if (connection) connection.hidden = true;
var warning = document.getElementById('onboarding-clipboard-warning');
if (warning) warning.hidden = true;
var copyButton = document.getElementById('copy-key-btn');
if (copyButton) {
copyButton.replaceChildren(
buildIconSvg(
(window.APP_BASE || '') + 'icons/general/copy.svg#icon',
14,
14
)
);
}
revealedKeyGeneration = 0;
}
function resetCreateButton() {
var button = document.getElementById('modal-confirm-btn');
if (!button) return;
button.disabled = !currentAgentId;
button.textContent = tKey('apikeys.modal.create');
}
function invalidateModalState() {
modalGeneration += 1;
wipeRevealedKey();
resetCreateButton();
createReconciliationRequired = false;
var warning = document.getElementById('ambiguous-create-warning');
if (warning) warning.hidden = true;
}
function openModal() {
if (!currentAgentId) return;
invalidateModalState();
document.getElementById('modal-form-body').hidden = false;
document.getElementById('modal-reveal-body').hidden = true;
document.getElementById('modal-footer-create').hidden = false;
document.getElementById('modal-footer-done').hidden = true;
document.getElementById('new-key-name').value = '';
selectedScopes = new Set([activeKeyKind === 'approval' ? 'approve' : 'read']);
selectedScopes = new Set(activeKeyKind === 'approval' ? ['approve'] : ['read', 'write']);
document.getElementById('modal-create-title').textContent = activeKeyKind === 'approval'
? tKey('apikeys.modal.title_approval')
: tKey('apikeys.modal.title_mcp');
@@ -529,6 +713,7 @@ function openModal() {
function closeModal() {
modal.classList.remove('open');
invalidateModalState();
}
document.getElementById('btn-create-key').addEventListener('click', openModal);
@@ -558,14 +743,34 @@ document.getElementById('modal-confirm-btn').addEventListener('click', async fun
try {
button.disabled = true;
button.textContent = tKey('apikeys.creating');
var requestGeneration = ++modalGeneration;
var workspaceId = currentWorkspaceId;
var agentId = currentAgentId;
var keyKind = activeKeyKind;
var created = await createKey(name, Array.from(selectedScopes));
if (
requestGeneration !== modalGeneration
|| workspaceId !== currentWorkspaceId
|| agentId !== currentAgentId
|| keyKind !== activeKeyKind
|| !modal.classList.contains('open')
) {
wipeRevealedKey(requestGeneration);
var reconciled = await loadKeys();
if (reconciled) showLostKeyRecovery();
return;
}
KEYS.unshift(created.record);
document.getElementById('reveal-key-value').textContent = created.rawKey;
revealedKeyGeneration = requestGeneration;
onboardingRevealWasCreated = true;
renderEphemeralConnection(keyKind === 'mcp_client' ? created.connection : null);
document.getElementById('modal-form-body').hidden = true;
document.getElementById('modal-reveal-body').hidden = false;
document.getElementById('modal-footer-create').hidden = true;
document.getElementById('modal-footer-done').hidden = false;
renderTable();
if (window.CrankOnboarding) window.CrankOnboarding.signalRefresh();
if (window.CrankUi) {
window.CrankUi.success(
tKey('apikeys.toast.create_message'),
@@ -573,38 +778,87 @@ document.getElementById('modal-confirm-btn').addEventListener('click', async fun
);
}
} catch (error) {
var ambiguous = !error.status || error.status === 408 || error.status >= 500;
if (ambiguous
&& workspaceId === currentWorkspaceId
&& agentId === currentAgentId
&& modal.classList.contains('open')) {
createReconciliationRequired = true;
var warning = document.getElementById('ambiguous-create-warning');
if (warning) warning.hidden = false;
await loadKeys();
}
if (window.CrankUi) {
window.CrankUi.error(
error.message || tKey('apikeys.toast.create_error_message'),
tKey('apikeys.toast.create_error_title')
ambiguous ? tKey('apikeys.ambiguous.body') : (error.message || tKey('apikeys.toast.create_error_message')),
ambiguous ? tKey('apikeys.ambiguous.title') : tKey('apikeys.toast.create_error_title')
);
}
} finally {
button.disabled = false;
button.textContent = tKey('apikeys.modal.create');
if (requestGeneration === modalGeneration && button && modal.classList.contains('open')) {
button.disabled = createReconciliationRequired || !currentAgentId;
button.textContent = createReconciliationRequired
? tKey('apikeys.ambiguous.blocked')
: tKey('apikeys.modal.create');
}
}
});
document.getElementById('modal-done-btn').addEventListener('click', closeModal);
document.getElementById('copy-key-btn').addEventListener('click', function() {
var value = document.getElementById('reveal-key-value').textContent;
if (navigator.clipboard) {
navigator.clipboard.writeText(value).catch(function() {});
function copyEphemeralValue(value) {
if (!value || !navigator.clipboard || typeof navigator.clipboard.writeText !== 'function') {
return Promise.reject(new Error('clipboard unavailable'));
}
this.replaceChildren(
buildIconSvg(
(window.APP_BASE || '') + 'icons/general/check.svg#icon',
13,
13
)
);
return navigator.clipboard.writeText(value);
}
function showClipboardFailure() {
if (window.CrankUi) {
window.CrankUi.info(
tKey('apikeys.toast.copy_message'),
tKey('apikeys.toast.copy_title')
window.CrankUi.error(
tKey('apikeys.toast.copy_error_message'),
tKey('apikeys.toast.copy_error_title')
);
}
}
document.getElementById('copy-key-btn').addEventListener('click', async function() {
var value = document.getElementById('reveal-key-value').textContent;
this.disabled = true;
try {
await copyEphemeralValue(value);
this.replaceChildren(
buildIconSvg(
(window.APP_BASE || '') + 'icons/general/check.svg#icon',
13,
13
)
);
if (window.CrankUi) {
window.CrankUi.info(
tKey('apikeys.toast.copy_message'),
tKey('apikeys.toast.copy_title')
);
}
wipeRevealedKey();
} catch (_error) {
showClipboardFailure();
} finally {
this.disabled = false;
}
});
document.getElementById('ambiguous-create-retry-btn').addEventListener('click', async function() {
var button = this;
button.disabled = true;
var reconciled = await loadKeys();
if (reconciled && currentAgentId && modal.classList.contains('open')) {
createReconciliationRequired = false;
document.getElementById('ambiguous-create-warning').hidden = true;
resetCreateButton();
document.getElementById('new-key-name').focus();
}
button.disabled = false;
});
document.getElementById('key-search').addEventListener('input', function() {
@@ -613,12 +867,14 @@ document.getElementById('key-search').addEventListener('input', function() {
});
document.getElementById('agent-select').addEventListener('change', async function() {
closeModal();
currentAgentId = this.value || null;
await loadKeys();
});
document.querySelectorAll('[data-key-kind]').forEach(function(button) {
button.addEventListener('click', function() {
closeModal();
activeKeyKind = this.dataset.keyKind || 'mcp_client';
search = '';
document.getElementById('key-search').value = '';
@@ -641,6 +897,22 @@ document.addEventListener('DOMContentLoaded', async function() {
await (window.whenWorkspacesReady ? window.whenWorkspacesReady() : Promise.resolve());
await loadKeys();
window.addEventListener('crank:workspacechange', function() {
keyMutations = {};
closeModal();
onboardingQueryHydrated = false;
loadKeys();
});
window.addEventListener('crank:langchange', function() {
closeModal();
if (onboardingRevealWasCreated && isOnboardingKeyQuery()) showLostKeyRecovery();
});
window.addEventListener('pagehide', function() {
closeModal();
});
window.addEventListener('pageshow', function(event) {
if (event.persisted) {
closeModal();
if (onboardingRevealWasCreated && isOnboardingKeyQuery()) showLostKeyRecovery();
}
});
});
+188 -5
View File
@@ -1,6 +1,8 @@
(function() {
var API_BASE = '/api/admin';
var AUTH_BASE = '/api/auth';
var operationEtags = Object.create(null);
var agentEtags = Object.create(null);
function headers(extra) {
return Object.assign({
@@ -8,6 +10,26 @@
}, extra || {});
}
function csrfExempt(path) {
return /\/api\/auth\/(?:login|bootstrap\/complete|session\/csrf)$/.test(path);
}
function attachCsrf(path, method, requestOptions) {
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS' || csrfExempt(path)) {
return;
}
if (!(path.indexOf('/api/auth/') === 0 || path.indexOf('/api/admin/') === 0)) {
return;
}
var token = window.CrankAuth && typeof window.CrankAuth.getCsrfToken === 'function'
? window.CrankAuth.getCsrfToken()
: '';
if (token) {
requestOptions.headers = headers(requestOptions.headers);
requestOptions.headers['x-csrf-token'] = token;
}
}
function attachCorrelation(error, response) {
var requestId = response.headers.get('x-request-id');
var traceId = response.headers.get('x-trace-id');
@@ -20,13 +42,88 @@
return error;
}
function operationMutationResource(path, method) {
if (!method || method === 'GET') {
return null;
}
var match = path.match(/^(\/api\/admin\/workspaces\/[^/]+\/operations\/[^/?]+)(?:\/(publish|archive|versions))?(?:\?.*)?$/);
if (!match) {
return null;
}
if (method === 'PATCH' || method === 'DELETE' || (method === 'POST' && match[2])) {
return match[1];
}
return null;
}
function operationDetailResource(path, method) {
if (method && method !== 'GET') {
return null;
}
var match = path.match(/^(\/api\/admin\/workspaces\/[^/]+\/operations\/[^/?]+)$/);
return match ? match[1] : null;
}
function agentMutationResource(path, method) {
if (!method || method === 'GET') {
return null;
}
var match = path.match(/^(\/api\/admin\/workspaces\/[^/]+\/agents\/[^/?]+)(?:\/(bindings|publish|unpublish|archive))?(?:\?.*)?$/);
if (!match) {
return null;
}
if (method === 'PATCH' || method === 'DELETE' || (method === 'POST' && match[2])) {
return match[1];
}
return null;
}
function agentDetailResource(path, method) {
if (method && method !== 'GET') {
return null;
}
var match = path.match(/^(\/api\/admin\/workspaces\/[^/]+\/agents\/[^/?]+)$/);
return match ? match[1] : null;
}
async function request(path, options) {
var response = await fetch(path, Object.assign({
var requestOptions = Object.assign({
credentials: 'same-origin',
headers: headers(),
}, options || {}));
}, options || {});
var method = (requestOptions.method || 'GET').toUpperCase();
attachCsrf(path, method, requestOptions);
var mutationResource = operationMutationResource(path, method);
var agentMutation = agentMutationResource(path, method);
if (mutationResource) {
if (!operationEtags[mutationResource]) {
await request(mutationResource);
}
requestOptions.headers = headers(requestOptions.headers);
requestOptions.headers['If-Match'] = operationEtags[mutationResource];
}
if (agentMutation) {
if (!agentEtags[agentMutation]) {
await request(agentMutation);
}
requestOptions.headers = headers(requestOptions.headers);
requestOptions.headers['If-Match'] = agentEtags[agentMutation];
}
var response = await fetch(path, requestOptions);
var detailResource = operationDetailResource(path, method);
var agentDetail = agentDetailResource(path, method);
var responseEtag = response.headers.get('etag');
if (detailResource && responseEtag) {
operationEtags[detailResource] = responseEtag;
}
if (agentDetail && responseEtag) {
agentEtags[agentDetail] = responseEtag;
}
if (response.status === 204) {
if (mutationResource) {
delete operationEtags[mutationResource];
}
return null;
}
@@ -40,6 +137,12 @@
}
if (!response.ok) {
if (mutationResource && (response.status === 409 || response.status === 428)) {
delete operationEtags[mutationResource];
}
if (agentMutation && (response.status === 409 || response.status === 428)) {
delete agentEtags[agentMutation];
}
if (response.status === 401 && window.CrankAuth && typeof window.CrankAuth.handleUnauthorized === 'function') {
window.CrankAuth.handleUnauthorized();
}
@@ -54,9 +157,22 @@
var error = new Error(message);
error.status = response.status;
error.payload = payload;
var errorCode = payload && payload.error && typeof payload.error === 'object'
? (payload.error.code || payload.error.error_code)
: payload && (payload.code || payload.error_code);
if (typeof errorCode === 'string' && /^[A-Za-z0-9._-]{1,128}$/.test(errorCode)) {
error.code = errorCode;
}
throw attachCorrelation(error, response);
}
if (mutationResource) {
delete operationEtags[mutationResource];
}
if (agentMutation) {
delete agentEtags[agentMutation];
}
if (text && payload === null) {
throw attachCorrelation(new Error('Backend returned a non-JSON response'), response);
}
@@ -137,6 +253,16 @@
}
window.CrankApi = {
getBootstrapStatus: function() {
return request(AUTH_BASE + '/bootstrap/status');
},
completeBootstrap: function(payload) {
return request(AUTH_BASE + '/bootstrap/complete', {
method: 'POST',
headers: headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify(payload),
});
},
login: function(payload) {
return request(AUTH_BASE + '/login', {
method: 'POST',
@@ -154,6 +280,13 @@
getSession: function() {
return request(AUTH_BASE + '/session');
},
refreshSessionCsrf: function() {
return request(AUTH_BASE + '/session/csrf', {
method: 'POST',
headers: headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify({}),
});
},
getProfile: function() {
return request(AUTH_BASE + '/profile');
},
@@ -180,6 +313,21 @@
getWorkspace: function(workspaceId) {
return get('/workspaces/' + encodeURIComponent(workspaceId));
},
getOnboarding: function(workspaceId) {
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/onboarding');
},
recordOnboardingEvent: function(workspaceId, payload, options) {
return request(API_BASE + '/workspaces/' + encodeURIComponent(workspaceId) + '/onboarding/events', Object.assign({}, options || {}, {
method: 'POST',
headers: headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify(payload),
}));
},
resetOnboardingSelection: function(workspaceId, expectedRevision) {
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/onboarding/reset-selection', {
expected_revision: expectedRevision,
});
},
updateWorkspace: function(workspaceId, payload) {
return patch('/workspaces/' + encodeURIComponent(workspaceId), payload);
},
@@ -238,15 +386,28 @@
}
);
},
importOperation: function(workspaceId, yamlDocument, mode) {
return request(
importOperation: async function(workspaceId, yamlDocument, mode, existingOperationId) {
var importHeaders = headers({ 'Content-Type': 'application/yaml' });
if (existingOperationId) {
var resource = API_BASE + '/workspaces/' + encodeURIComponent(workspaceId)
+ '/operations/' + encodeURIComponent(existingOperationId);
if (!operationEtags[resource]) {
await request(resource);
}
importHeaders['If-Match'] = operationEtags[resource];
}
var result = await request(
API_BASE + '/workspaces/' + encodeURIComponent(workspaceId) + '/operations/import' + query({ mode: mode }),
{
method: 'POST',
headers: headers({ 'Content-Type': 'application/yaml' }),
headers: importHeaders,
body: yamlDocument,
}
);
if (existingOperationId) {
delete operationEtags[resource];
}
return result;
},
previewOpenApiImport: function(workspaceId, documentText) {
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/imports/openapi/preview', {
@@ -331,6 +492,16 @@
listLogs: function(workspaceId, params) {
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/logs' + query(params));
},
exportLogsCsv: function(workspaceId, params) {
return requestText(
API_BASE + '/workspaces/' + encodeURIComponent(workspaceId) + '/logs/export.csv' + query(params)
);
},
exportUsageCsv: function(workspaceId, params) {
return requestText(
API_BASE + '/workspaces/' + encodeURIComponent(workspaceId) + '/usage/export.csv' + query(params)
);
},
getLog: function(workspaceId, logId) {
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/logs/' + encodeURIComponent(logId));
},
@@ -340,6 +511,18 @@
getApproval: function(workspaceId, approvalId) {
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/approvals/' + encodeURIComponent(approvalId));
},
approveApproval: function(workspaceId, approvalId, payload) {
return post(
'/workspaces/' + encodeURIComponent(workspaceId) + '/approvals/' + encodeURIComponent(approvalId) + '/approve',
payload || { approve: 'yes' }
);
},
denyApproval: function(workspaceId, approvalId, payload) {
return post(
'/workspaces/' + encodeURIComponent(workspaceId) + '/approvals/' + encodeURIComponent(approvalId) + '/deny',
payload || { approve: 'no' }
);
},
getUsageOverview: function(workspaceId, params) {
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/usage' + query(params));
},
+22
View File
@@ -175,6 +175,15 @@
}
sessionPromise = window.CrankApi.getSession()
.then(function(session) {
if (session && !session.csrf_token && window.CrankApi && typeof window.CrankApi.refreshSessionCsrf === 'function') {
return window.CrankApi.refreshSessionCsrf().then(function(csrf) {
session.csrf_token = csrf && csrf.csrf_token ? csrf.csrf_token : '';
return session;
});
}
return session;
})
.then(function(session) {
return replaceSession(session);
})
@@ -221,6 +230,15 @@
window.location.href = homeUrl();
}
async function completeBootstrap(token, password) {
var session = await window.CrankApi.completeBootstrap({
token: token,
password: password,
});
replaceSession(session);
window.location.href = homeUrl();
}
async function logout() {
try {
await window.CrankApi.logout();
@@ -243,12 +261,16 @@
fetchSession: fetchSession,
replaceSession: replaceSession,
getCachedSession: function() { return sessionCache; },
getCsrfToken: function() {
return sessionCache && sessionCache.csrf_token ? sessionCache.csrf_token : '';
},
renderShellIdentity: function() {
renderShellIdentity(sessionCache);
},
guardProtectedPage: guardProtectedPage,
guardLoginPage: guardLoginPage,
login: login,
completeBootstrap: completeBootstrap,
logout: logout,
handleUnauthorized: handleUnauthorized,
};
+36 -3
View File
@@ -44,6 +44,9 @@ function mapOperation(item) {
created_at: item.created_at,
updated_at: item.updated_at,
published_at: item.published_at,
current_draft_version: item.current_draft_version,
latest_published_version: item.latest_published_version,
can_delete: item.can_delete === true,
target_url: item.target_url || '',
method: item.target_action || '',
usage_summary: item.usage_summary || {
@@ -117,6 +120,7 @@ document.addEventListener('alpine:init', function() {
_agentsByOpIdCacheVersion: -1,
_activeAgentsCache: null,
_activeAgentsCacheVersion: -1,
_loadGeneration: 0,
async init() {
var self = this;
@@ -153,25 +157,31 @@ document.addEventListener('alpine:init', function() {
},
async reload() {
var generation = ++this._loadGeneration;
var requestedWorkspaceId = this.workspaceId;
this.loading = true;
this.loadError = '';
try {
var response = await window.CrankApi.listOperations(this.workspaceId);
var response = await window.CrankApi.listOperations(requestedWorkspaceId);
if (generation !== this._loadGeneration || requestedWorkspaceId !== this.workspaceId) return;
this.replaceOperations((response && response.items ? response.items : []).map(mapOperation));
this.categoryOptions = Array.from(new Set(this.operations.map(function(operation) {
return operation.category;
}).filter(Boolean))).sort();
this.stats = computeStats(this.operations);
} catch (error) {
if (generation !== this._loadGeneration || requestedWorkspaceId !== this.workspaceId) return;
this.replaceOperations([]);
this.categoryOptions = [];
this.stats = emptyStats();
this.loadError = error.message || 'Failed to load operations';
}
this.loading = false;
this.page = 1;
if (generation === this._loadGeneration && requestedWorkspaceId === this.workspaceId) {
this.loading = false;
this.page = 1;
}
},
replaceOperations(operations) {
@@ -453,6 +463,29 @@ document.addEventListener('alpine:init', function() {
}
},
async archiveOperation(operation) {
if (!operation || !confirm(this.tKey('ops.archive.confirm'))) return;
try {
await window.CrankApi.archiveOperation(this.workspaceId, operation.id);
await this.reload();
if (window.CrankUi) {
window.CrankUi.success(
this.tfKey('ops.archive.success.message', {
name: operation.display_name || operation.name
}),
this.tKey('ops.archive.success.title')
);
}
} catch (error) {
if (window.CrankUi) {
window.CrankUi.error(
error.message || this.tKey('ops.archive.error.message'),
this.tKey('ops.archive.error.title')
);
}
}
},
protocolLabel(operation) {
return operation.method ? ('REST · ' + operation.method) : 'REST';
},
+312 -2
View File
@@ -20,6 +20,43 @@ var TRANSLATIONS = {
'nav.user_fallback': 'Crank',
'nav.workspace_fallback': 'workspace',
'onboarding.title': 'Getting Started',
'onboarding.subtitle': 'A resumable path to your first real MCP tool call.',
'onboarding.loading': 'Checking authoritative progress…',
'onboarding.progress': '{count}/7 steps complete',
'onboarding.error': 'Getting Started progress is unavailable.',
'onboarding.retry': 'Retry',
'onboarding.refresh': 'Refresh progress',
'onboarding.dismiss': 'Dismiss',
'onboarding.resume': 'Resume Getting Started',
'onboarding.collapse': 'Collapse Getting Started checklist',
'onboarding.request_id': 'Request ID',
'onboarding.trace_id': 'Trace ID',
'onboarding.error_code': 'Error code',
'onboarding.progress_label': 'Getting Started progress',
'onboarding.action_needed': 'This is the next actionable step.',
'onboarding.regressed': 'The referenced object changed. Review and continue again.',
'onboarding.completed': 'Your first public MCP tool call is complete.',
'onboarding.open_invocation': 'Open invocation history',
'onboarding.tool': 'Tool',
'onboarding.timestamp': 'Timestamp',
'onboarding.deep_link_stale': 'This onboarding link references a changed Operation or Agent. Reset it and reselect the current published revision.',
'onboarding.reselect': 'Reset and reselect',
'onboarding.step.operation': 'Create an Operation',
'onboarding.step.test': 'Test the Operation',
'onboarding.step.publish_operation': 'Publish the Operation',
'onboarding.step.agent': 'Create and publish an Agent',
'onboarding.step.key': 'Create an MCP client key',
'onboarding.step.mcp_connection': 'Connect an MCP client',
'onboarding.step.first_call': 'Make the first tool call',
'onboarding.action.operation': 'Create Operation',
'onboarding.action.test': 'Open test',
'onboarding.action.publish_operation': 'Open publication',
'onboarding.action.agent': 'Create Agent',
'onboarding.action.key': 'Create key',
'onboarding.action.mcp_connection': 'Show connection',
'onboarding.action.first_call': 'Open Logs',
// Operations page
'ops.title': 'Operations',
'ops.subtitle': 'Catalog of tools. List of created MCP tools for API endpoints.',
@@ -37,6 +74,12 @@ var TRANSLATIONS = {
'ops.delete.error.message': 'Failed to delete operation',
'ops.action.edit': 'Edit operation',
'ops.action.delete':'Delete operation',
'ops.action.archive': 'Archive operation',
'ops.archive.confirm': 'Archive this operation? Existing published Agent snapshots remain available, but new publications and bindings will be blocked.',
'ops.archive.success.title': 'Operation archived',
'ops.archive.success.message': '{name} was archived.',
'ops.archive.error.title': 'Archive failed',
'ops.archive.error.message': 'Failed to archive operation',
'ops.stats.synced': 'Catalog synced with backend',
'ops.stats.none': 'No operations yet',
'ops.stats.share': '{value}% of total',
@@ -137,11 +180,16 @@ var TRANSLATIONS = {
'apikeys.modal.reveal_title': 'Copy this key now.',
'apikeys.modal.reveal_body': "It won't be shown again.",
'apikeys.modal.copy': 'Copy to clipboard',
'apikeys.modal.copy_config': 'Copy configuration',
'apikeys.modal.connection_title': 'MCP connection configuration',
'apikeys.modal.clipboard_warning': 'Your operating system clipboard is outside Crank control. Clear it after saving the key.',
'apikeys.onboarding.key_lost': 'The key cannot be shown again. Create a new key and revoke the old one if its value was not saved.',
'apikeys.modal.cancel': 'Cancel',
'apikeys.modal.create': 'Create key',
'apikeys.modal.done': "Done — I've copied the key",
'apikeys.status.active': 'Active',
'apikeys.status.revoked': 'Revoked',
'apikeys.status.deleted': 'Deleted',
'apikeys.last_used.never': 'Never',
'apikeys.empty.none': 'No API keys yet',
'apikeys.empty.approval': 'No approval keys yet. Create one only if this agent has tools that require human confirmation.',
@@ -167,12 +215,18 @@ var TRANSLATIONS = {
'apikeys.toast.create_error_message': 'Failed to create key',
'apikeys.toast.copy_title': 'Agent key copied',
'apikeys.toast.copy_message': 'Store the raw key securely. It cannot be revealed again.',
'apikeys.toast.copy_error_title': 'Clipboard write failed',
'apikeys.toast.copy_error_message': 'The one-time value is still visible. Copy it manually before closing this page.', // community-scope: allow=one-time-token
'apikeys.toast.prefix_title': 'Key prefix copied',
'apikeys.action.copy_prefix': 'Copy key prefix',
'apikeys.action.revoke': 'Revoke key',
'apikeys.action.delete': 'Delete',
'apikeys.creating': 'Creating…',
'apikeys.approval.warning': 'Do not pass this key to an LLM or MCP client. It is only for an external interface where a human confirms an action.',
'apikeys.ambiguous.title': 'Creation result is uncertain.',
'apikeys.ambiguous.body': 'Key metadata was refreshed. Review the list before deliberately creating another key.',
'apikeys.ambiguous.retry': 'Refresh metadata and allow another attempt',
'apikeys.ambiguous.blocked': 'Review metadata before retrying',
// Secrets page
'secrets.title': 'Secrets',
@@ -256,6 +310,16 @@ var TRANSLATIONS = {
'logs.live': 'Live',
'logs.paused': 'Paused',
'logs.refresh': 'Refresh',
'logs.export': 'Export CSV',
'logs.load_more': 'Load more',
'logs.status_filter': 'Status',
'logs.status.all': 'All statuses',
'logs.status.ok': 'Success',
'logs.status.error': 'Error',
'logs.outcome_filter': 'Outcome',
'logs.outcome.all': 'All outcomes',
'logs.filter.operation': 'Operation ID',
'logs.filter.agent': 'Agent ID',
'logs.range.30m': 'Last 30 min',
'logs.range.1h': 'Last hour',
'logs.range.6h': 'Last 6 hours',
@@ -281,6 +345,10 @@ var TRANSLATIONS = {
'logs.live.off.body': 'Automatic polling is paused.',
'logs.refresh.title': 'Logs refreshed',
'logs.refresh.body': 'The latest invocation records were loaded for the current workspace.',
'logs.export.done_title': 'Logs exported',
'logs.export.done_body': 'The filtered invocation history was exported as CSV.',
'logs.export.error_title': 'Export failed',
'logs.export.error_body': 'Failed to export invocation history.',
'approvals.title': 'Human confirmations',
'approvals.subtitle': 'Requests waiting for an external user decision and recent results.',
'approvals.refresh': 'Refresh',
@@ -289,6 +357,7 @@ var TRANSLATIONS = {
'approvals.loading': 'Loading confirmation requests…',
'approvals.empty': 'There are no confirmation requests yet.',
'approvals.error.load': 'Failed to load confirmation requests',
'approvals.error.decision': 'Failed to submit confirmation decision',
'approvals.untitled': 'Confirmation request',
'approvals.operation': 'Operation',
'approvals.agent': 'Agent',
@@ -296,6 +365,13 @@ var TRANSLATIONS = {
'approvals.updated_at': 'Updated',
'approvals.request': 'Request',
'approvals.response': 'Result',
'approvals.request_id': 'Request ID',
'approvals.trace_id': 'Trace ID',
'approvals.action.approve': 'Approve',
'approvals.action.deny': 'Deny',
'approvals.action.busy': 'Submitting…',
'approvals.confirm.approve': 'Approve this pending confirmation request?',
'approvals.confirm.deny': 'Deny this pending confirmation request?',
'approvals.status.pending': 'Pending',
'approvals.status.approved': 'Approved',
'approvals.status.denied': 'Denied',
@@ -328,6 +404,19 @@ var TRANSLATIONS = {
'usage.chart.error': 'Error',
'usage.chart.empty.title': 'No usage data yet',
'usage.chart.empty.sub': 'Invocation metrics will appear here after tests or published tool calls in the selected period.',
'usage.outcomes.title': 'Outcomes',
'usage.outcomes.subtitle': 'Grouped by safe execution outcome.',
'usage.outcomes.empty.title': 'No outcome breakdown yet',
'usage.outcomes.empty.sub': 'Outcome groups will appear after invocations are recorded.',
'usage.outcomes.p50': 'p50',
'usage.outcomes.p95': 'p95',
'usage.outcomes.p99': 'p99',
'usage.outcome.success': 'Success',
'usage.outcome.upstream': 'Upstream',
'usage.outcome.client': 'Client',
'usage.outcome.schema': 'Schema',
'usage.outcome.crank': 'Crank',
'usage.outcome.no_error_code': 'No error code',
'usage.table.title': 'By operation',
'usage.table.subtitle': 'Breakdown for {period}',
'usage.table.th.operation': 'Operation',
@@ -349,7 +438,9 @@ var TRANSLATIONS = {
'usage.export.empty.title': 'No usage data loaded',
'usage.export.empty.body': 'Load usage data before exporting the CSV snapshot.',
'usage.export.done.title': 'Usage exported',
'usage.export.done.body': 'The current usage snapshot was exported as CSV.',
'usage.export.done.body': 'The filtered usage dataset was exported as CSV.',
'usage.export.error.title': 'Export failed',
'usage.export.error.body': 'Failed to export usage data.',
'usage.chart.ok': '{count} ok',
'usage.chart.errors': '{count} errors',
'usage.chart.week': 'Wk {index}',
@@ -403,6 +494,7 @@ var TRANSLATIONS = {
'settings.security.change_password': 'Change password',
'settings.security.mismatch': 'New password and confirmation do not match.',
'settings.security.saved': 'Password updated.',
'settings.security.saved_relogin': 'Password updated. Please sign in again.',
'settings.security.save_error': 'Failed to change password',
'settings.capability.rest': 'REST / HTTP',
'settings.capability.standard': 'standard',
@@ -706,6 +798,36 @@ var TRANSLATIONS = {
'wizard.test.failed': 'Operation test returned errors',
'wizard.test.completed_body': 'The operation was executed successfully against the API.',
'wizard.test.failed_body': 'The operation was executed, but the API or validation returned errors.',
'execution.error.authorization_denied': 'Operation execution is denied.',
'execution.error.auth_profile_not_found': 'Authorization profile was not found.',
'execution.error.secret_not_found': 'Authorization secret was not found.',
'execution.error.secret_invalid': 'Authorization secret has an invalid format.',
'execution.error.input_schema_invalid': 'Input does not satisfy the operation schema.',
'execution.error.input_mapping_invalid': 'Input parameters could not be mapped.',
'execution.error.prepared_request_invalid': 'A valid upstream request could not be prepared.',
'execution.error.execution_overloaded': 'The service is temporarily overloaded.',
'execution.error.safety_store_unavailable': 'A mandatory safety store is unavailable.',
'execution.error.protocol_unsupported': 'The operation protocol is unsupported.',
'execution.error.execution_mode_unsupported': 'The execution mode is unsupported.',
'execution.error.adapter_configuration_invalid': 'The adapter configuration is invalid.',
'execution.error.outbound_target_rejected': 'The target was rejected by the safety policy.',
'execution.error.upstream_auth_error': 'The upstream API rejected authorization.',
'execution.error.upstream_not_found': 'The upstream resource was not found.',
'execution.error.upstream_rate_limited': 'The upstream API rate-limited the request.',
'execution.error.upstream_server_error': 'The upstream API is temporarily unavailable.',
'execution.error.upstream_status_error': 'The upstream API returned an error status.',
'execution.error.upstream_timeout': 'The upstream API timed out.',
'execution.error.upstream_transport_error': 'The upstream API could not be reached.',
'execution.error.upstream_response_too_large': 'The upstream response exceeded its limit.',
'execution.error.output_mapping_invalid': 'The upstream response could not be mapped.',
'execution.error.output_schema_invalid': 'The output does not satisfy the operation schema.',
'execution.error.persistence_unavailable': 'Mandatory result persistence is unavailable.',
'execution.error.runtime_internal': 'Internal execution failure.',
'execution.error.confirmation_required': 'The operation requires confirmation.',
'execution.error.confirmation_invalid': 'The confirmation is invalid or expired.',
'execution.error.idempotency_in_progress': 'The operation is already running for this key.',
'execution.error.idempotency_conflict': 'The idempotency key was used with different input.',
'execution.error.idempotency_outcome_unknown': 'The previous outcome is unknown; automatic retry is unsafe.',
'wizard.test.window_completed': 'Window test completed',
'wizard.test.window_completed_body': 'Collected a bounded result window.',
'wizard.test.window_truncated_note': 'Results were truncated.',
@@ -727,6 +849,7 @@ var TRANSLATIONS = {
'wizard.quality.blocked_title': 'Quality check found blocking issues',
'wizard.quality.blocked_body': 'Fix error-level findings before publishing.',
'wizard.quality.blocking_error': 'Fix blocking quality findings before publishing.',
'wizard.quality.import_findings_hint': 'These findings came from OpenAPI import. Run quality analysis to recalculate them for the current Draft.',
'wizard.quality.severity_error': 'Error',
'wizard.quality.severity_warning': 'Warning',
'wizard.quality.severity_info': 'Info',
@@ -743,6 +866,16 @@ var TRANSLATIONS = {
'wizard.yaml.loaded_body': 'The YAML configuration was loaded.',
'wizard.publish.done': 'Operation published',
'wizard.publish.done_body': 'Version {version} is now published and can be bound into agents.',
'wizard.publish.confirm': 'Publish the saved Draft as a new immutable version?',
'wizard.stale.title': 'Draft changed elsewhere',
'wizard.stale.body': 'Your edits are preserved in this browser. Reload the latest saved Draft before retrying.',
'wizard.test.correlation': 'Request ID: {requestId}\nTrace ID: {traceId}',
'wizard.test.request_id': 'Request ID',
'wizard.test.trace_id': 'Trace ID',
'wizard.test.copy_request_id': 'Copy Request ID',
'wizard.test.copy_trace_id': 'Copy Trace ID',
'wizard.test.id_copied': 'Support ID copied',
'wizard.test.id_copied_body': 'The identifier was copied without request or response payload.',
// Common buttons
'btn.save': 'Save changes',
@@ -785,6 +918,7 @@ var TRANSLATIONS = {
'agents.card.keys': 'keys',
'agents.card.calls_today': 'calls today',
'agents.card.created': 'Created {date}',
'agents.card.revision': 'Draft v{draft} · Published v{published} · Catalog rev {revision}',
'agents.card.copy_endpoint': 'Copy endpoint',
'agents.card.endpoint_help': 'Set this endpoint in the MCP client together with the API key.',
'agents.card.endpoint_help_community': 'Set this endpoint in the MCP client together with the API key.',
@@ -809,6 +943,7 @@ var TRANSLATIONS = {
'agents.drawer.optional': '(optional)',
'agents.drawer.required': 'required',
'agents.drawer.status': 'Status',
'agents.drawer.revision': 'Draft v{draft} · Published v{published} · Catalog rev {revision}',
'agents.drawer.endpoint': 'MCP endpoint',
'agents.drawer.slug_hint': 'Slug is used as part of the endpoint to identify the agent.',
'agents.drawer.placeholder.name': 'Customer Support',
@@ -867,12 +1002,17 @@ var TRANSLATIONS = {
'agents.toast.delete_message': '{name} was deleted.',
'agents.toast.delete_error_title': 'Delete failed',
'agents.toast.delete_error_message': 'Failed to delete agent',
'agents.toast.delete_forbidden_message': 'Published agents cannot be deleted. Archive or unpublish the agent first.',
'agents.toast.lifecycle_title': 'Agent lifecycle updated',
'agents.toast.lifecycle_publish_confirm': 'Publish {name}? The published MCP catalog will change.',
'agents.toast.lifecycle_unpublish_confirm': 'Unpublish {name}? Existing MCP clients will no longer see this agent catalog.',
'agents.toast.lifecycle_archive_confirm': 'Archive {name}? New publications and bindings will be blocked.',
'agents.toast.lifecycle_publish': '{name} was published.',
'agents.toast.lifecycle_unpublish': '{name} was returned to draft.',
'agents.toast.lifecycle_archive': '{name} was archived.',
'agents.toast.lifecycle_error_title': 'Lifecycle update failed',
'agents.toast.lifecycle_error_message': 'Failed to update agent lifecycle',
'agents.toast.stale_message': 'Agent changed in another request. Reload the drawer and retry.',
'agents.toast.endpoint_title': 'MCP endpoint copied',
// Demo content
@@ -889,9 +1029,24 @@ var TRANSLATIONS = {
'login.email_label': 'Email address',
'login.email_placeholder': 'you@acme.com',
'login.password_only': 'Sign in with email and password.',
'login.bootstrap.title': 'Create admin account',
'login.bootstrap.subtitle': 'Complete local first-run bootstrap',
'login.bootstrap.note': 'Enter the one-time token generated by crank-migrate and choose the first admin password.', // community-scope: allow=one-time-token
'login.bootstrap.token_label': 'Bootstrap token',
'login.bootstrap.token_placeholder': 'Paste one-time bootstrap token', // community-scope: allow=one-time-token
'login.bootstrap.submit': 'Create admin',
'login.loading': 'Signing in…',
'login.success': 'Signed in. Redirecting…',
'login.bootstrap.loading': 'Creating admin…',
'login.bootstrap.success': 'Admin account created. Redirecting…',
'login.error.required': 'Please enter your email and password.',
'login.bootstrap.error.required': 'Please enter the bootstrap token and password.',
'login.error.invalid': 'Invalid email or password. Please try again.',
'login.error.throttled': 'Too many attempts. Please wait and try again.',
'login.error.expired_session': 'Your session expired. Please sign in again.',
'login.error.generic': 'Unable to sign in right now. Please try again.',
'login.error.request_id': 'Request ID',
'login.error.trace_id': 'Trace ID',
},
ru: {
@@ -910,6 +1065,43 @@ var TRANSLATIONS = {
'nav.user_fallback': 'Crank',
'nav.workspace_fallback': 'workspace',
'onboarding.title': 'Начало работы',
'onboarding.subtitle': 'Возобновляемый путь к первому реальному вызову MCP-инструмента.',
'onboarding.loading': 'Проверяем подтверждённый прогресс…',
'onboarding.progress': 'Выполнено шагов: {count}/7',
'onboarding.error': 'Прогресс начала работы недоступен.',
'onboarding.retry': 'Повторить',
'onboarding.refresh': 'Обновить прогресс',
'onboarding.dismiss': 'Скрыть',
'onboarding.resume': 'Продолжить начало работы',
'onboarding.collapse': 'Свернуть чек-лист начала работы',
'onboarding.request_id': 'Request ID',
'onboarding.trace_id': 'Trace ID',
'onboarding.error_code': 'Код ошибки',
'onboarding.progress_label': 'Прогресс начала работы',
'onboarding.action_needed': 'Это следующий доступный шаг.',
'onboarding.regressed': 'Связанный объект изменился. Проверьте его и продолжите снова.',
'onboarding.completed': 'Первый публичный вызов MCP-инструмента выполнен.',
'onboarding.open_invocation': 'Открыть историю вызова',
'onboarding.tool': 'Инструмент',
'onboarding.timestamp': 'Время вызова',
'onboarding.deep_link_stale': 'Эта ссылка начала работы относится к изменённой операции или агенту. Сбросьте её и выберите актуальную опубликованную ревизию.',
'onboarding.reselect': 'Сбросить и выбрать заново',
'onboarding.step.operation': 'Создать операцию',
'onboarding.step.test': 'Протестировать операцию',
'onboarding.step.publish_operation': 'Опубликовать операцию',
'onboarding.step.agent': 'Создать и опубликовать агента',
'onboarding.step.key': 'Создать ключ MCP-клиента',
'onboarding.step.mcp_connection': 'Подключить MCP-клиент',
'onboarding.step.first_call': 'Выполнить первый вызов',
'onboarding.action.operation': 'Создать операцию',
'onboarding.action.test': 'Открыть тест',
'onboarding.action.publish_operation': 'Открыть публикацию',
'onboarding.action.agent': 'Создать агента',
'onboarding.action.key': 'Создать ключ',
'onboarding.action.mcp_connection': 'Показать подключение',
'onboarding.action.first_call': 'Открыть логи',
// Operations page
'ops.title': 'Операции',
'ops.subtitle': 'Каталог инструментов. Список созданных MCP инструментов на API эндпоинты.',
@@ -927,6 +1119,12 @@ var TRANSLATIONS = {
'ops.delete.error.message': 'Не удалось удалить операцию',
'ops.action.edit': 'Редактировать операцию',
'ops.action.delete':'Удалить операцию',
'ops.action.archive': 'Архивировать операцию',
'ops.archive.confirm': 'Архивировать операцию? Существующие опубликованные снимки агентов останутся доступны, но новые публикации и привязки будут запрещены.',
'ops.archive.success.title': 'Операция архивирована',
'ops.archive.success.message': 'Операция {name} архивирована.',
'ops.archive.error.title': 'Не удалось архивировать',
'ops.archive.error.message': 'Не удалось архивировать операцию',
'ops.stats.synced': 'Каталог синхронизирован с сервером',
'ops.stats.none': 'Операций пока нет',
'ops.stats.share': '{value}% от общего числа',
@@ -1027,11 +1225,16 @@ var TRANSLATIONS = {
'apikeys.modal.reveal_title': 'Скопируйте ключ сейчас.',
'apikeys.modal.reveal_body': 'Повторно он не будет показан.',
'apikeys.modal.copy': 'Скопировать',
'apikeys.modal.copy_config': 'Скопировать конфигурацию',
'apikeys.modal.connection_title': 'Конфигурация MCP-подключения',
'apikeys.modal.clipboard_warning': 'Системный буфер обмена находится вне контроля Crank. Очистите его после сохранения ключа.',
'apikeys.onboarding.key_lost': 'Ключ нельзя показать повторно. Создайте новый ключ и отзовите старый, если значение не было сохранено.',
'apikeys.modal.cancel': 'Отмена',
'apikeys.modal.create': 'Создать ключ',
'apikeys.modal.done': 'Готово — ключ скопирован',
'apikeys.status.active': 'Активен',
'apikeys.status.revoked': 'Отозван',
'apikeys.status.deleted': 'Удален',
'apikeys.last_used.never': 'Никогда',
'apikeys.empty.none': 'API-ключей пока нет',
'apikeys.empty.approval': 'Ключей подтверждения пока нет. Они нужны только агентам с инструментами, требующими подтверждения человеком.',
@@ -1057,12 +1260,18 @@ var TRANSLATIONS = {
'apikeys.toast.create_error_message': 'Не удалось создать ключ',
'apikeys.toast.copy_title': 'Ключ агента скопирован',
'apikeys.toast.copy_message': 'Сохраните исходный ключ в надежном месте. Повторно показать его нельзя.',
'apikeys.toast.copy_error_title': 'Не удалось записать в буфер обмена',
'apikeys.toast.copy_error_message': 'Одноразовое значение всё ещё показано. Скопируйте его вручную до закрытия страницы.',
'apikeys.toast.prefix_title': 'Префикс ключа скопирован',
'apikeys.action.copy_prefix': 'Скопировать префикс ключа',
'apikeys.action.revoke': 'Отозвать ключ',
'apikeys.action.delete': 'Удалить',
'apikeys.creating': 'Создание…',
'apikeys.approval.warning': 'Не передавайте этот ключ LLM или MCP-клиенту. Он нужен только внешнему интерфейсу, где человек подтверждает действие.',
'apikeys.ambiguous.title': 'Результат создания неизвестен.',
'apikeys.ambiguous.body': 'Метаданные ключей обновлены. Проверьте список перед осознанным созданием ещё одного ключа.',
'apikeys.ambiguous.retry': 'Обновить метаданные и разрешить новую попытку',
'apikeys.ambiguous.blocked': 'Проверьте метаданные перед повтором',
// Secrets page
'secrets.title': 'Секреты',
@@ -1148,6 +1357,16 @@ var TRANSLATIONS = {
'logs.live': 'Live',
'logs.paused': 'Пауза',
'logs.refresh': 'Обновить',
'logs.export': 'Экспорт CSV',
'logs.load_more': 'Загрузить ещё',
'logs.status_filter': 'Статус',
'logs.status.all': 'Все статусы',
'logs.status.ok': 'Успех',
'logs.status.error': 'Ошибка',
'logs.outcome_filter': 'Результат',
'logs.outcome.all': 'Все результаты',
'logs.filter.operation': 'ID операции',
'logs.filter.agent': 'ID агента',
'logs.range.30m': 'Последние 30 мин',
'logs.range.1h': 'Последний час',
'logs.range.6h': 'Последние 6 часов',
@@ -1173,6 +1392,10 @@ var TRANSLATIONS = {
'logs.live.off.body': 'Автоматический опрос остановлен.',
'logs.refresh.title': 'Логи обновлены',
'logs.refresh.body': 'Получены последние записи вызовов для текущего воркспейса.',
'logs.export.done_title': 'Логи экспортированы',
'logs.export.done_body': 'Отфильтрованная история вызовов экспортирована в CSV.',
'logs.export.error_title': 'Не удалось экспортировать',
'logs.export.error_body': 'Не удалось экспортировать историю вызовов.',
'approvals.title': 'Подтверждения человеком',
'approvals.subtitle': 'Заявки, которые ожидают решения пользователя, и последние результаты.',
'approvals.refresh': 'Обновить',
@@ -1181,6 +1404,7 @@ var TRANSLATIONS = {
'approvals.loading': 'Загрузка заявок на подтверждение…',
'approvals.empty': 'Заявок на подтверждение пока нет.',
'approvals.error.load': 'Не удалось загрузить заявки на подтверждение',
'approvals.error.decision': 'Не удалось отправить решение по заявке',
'approvals.untitled': 'Заявка на подтверждение',
'approvals.operation': 'Операция',
'approvals.agent': 'Агент',
@@ -1188,6 +1412,13 @@ var TRANSLATIONS = {
'approvals.updated_at': 'Обновлено',
'approvals.request': 'Запрос',
'approvals.response': 'Результат',
'approvals.request_id': 'Request ID',
'approvals.trace_id': 'Trace ID',
'approvals.action.approve': 'Подтвердить',
'approvals.action.deny': 'Отклонить',
'approvals.action.busy': 'Отправка…',
'approvals.confirm.approve': 'Подтвердить эту заявку?',
'approvals.confirm.deny': 'Отклонить эту заявку?',
'approvals.status.pending': 'Ожидает',
'approvals.status.approved': 'Подтверждено',
'approvals.status.denied': 'Отклонено',
@@ -1220,6 +1451,19 @@ var TRANSLATIONS = {
'usage.chart.error': 'Ошибка',
'usage.chart.empty.title': 'Данных по использованию пока нет',
'usage.chart.empty.sub': 'Метрики появятся здесь после тестов или вызовов опубликованных инструментов за выбранный период.',
'usage.outcomes.title': 'Исходы',
'usage.outcomes.subtitle': 'Группировка по безопасному исходу выполнения.',
'usage.outcomes.empty.title': 'Разбивки по исходам пока нет',
'usage.outcomes.empty.sub': 'Группы исходов появятся после записи вызовов.',
'usage.outcomes.p50': 'p50',
'usage.outcomes.p95': 'p95',
'usage.outcomes.p99': 'p99',
'usage.outcome.success': 'Успех',
'usage.outcome.upstream': 'Upstream',
'usage.outcome.client': 'Клиент',
'usage.outcome.schema': 'Схема',
'usage.outcome.crank': 'Crank',
'usage.outcome.no_error_code': 'Без error code',
'usage.table.title': 'По операциям',
'usage.table.subtitle': 'Разбивка за период: {period}',
'usage.table.th.operation': 'Операция',
@@ -1241,7 +1485,9 @@ var TRANSLATIONS = {
'usage.export.empty.title': 'Данные по использованию не загружены',
'usage.export.empty.body': 'Сначала загрузите данные использования, а потом экспортируйте снимок CSV.',
'usage.export.done.title': 'Использование экспортировано',
'usage.export.done.body': 'Текущий снимок использования экспортирован в CSV.',
'usage.export.done.body': 'Отфильтрованный набор использования экспортирован в CSV.',
'usage.export.error.title': 'Не удалось экспортировать',
'usage.export.error.body': 'Не удалось экспортировать данные использования.',
'usage.chart.ok': 'Успешных: {count}',
'usage.chart.errors': 'Ошибок: {count}',
'usage.chart.week': 'Нед. {index}',
@@ -1295,6 +1541,7 @@ var TRANSLATIONS = {
'settings.security.change_password': 'Сменить пароль',
'settings.security.mismatch': 'Новый пароль и подтверждение не совпадают.',
'settings.security.saved': 'Пароль обновлен.',
'settings.security.saved_relogin': 'Пароль обновлен. Войдите снова.',
'settings.security.save_error': 'Не удалось изменить пароль',
'settings.capability.rest': 'REST / HTTP',
'settings.capability.standard': 'standard',
@@ -1619,6 +1866,7 @@ var TRANSLATIONS = {
'wizard.quality.blocked_title': 'Проверка нашла блокирующие ошибки',
'wizard.quality.blocked_body': 'Исправьте замечания уровня «Ошибка» перед публикацией.',
'wizard.quality.blocking_error': 'Исправьте блокирующие замечания качества перед публикацией.',
'wizard.quality.import_findings_hint': 'Эти замечания получены при импорте OpenAPI. Запустите проверку качества, чтобы пересчитать их для текущего черновика.',
'wizard.quality.severity_error': 'Ошибка',
'wizard.quality.severity_warning': 'Предупреждение',
'wizard.quality.severity_info': 'Информация',
@@ -1635,6 +1883,16 @@ var TRANSLATIONS = {
'wizard.yaml.loaded_body': 'YAML-конфигурация загружена.',
'wizard.publish.done': 'Операция опубликована',
'wizard.publish.done_body': 'Версия {version} опубликована и теперь может быть привязана к агентам.',
'wizard.publish.confirm': 'Опубликовать сохранённый черновик как новую неизменяемую версию?',
'wizard.stale.title': 'Черновик изменён в другом окне',
'wizard.stale.body': 'Ваши правки сохранены в этом браузере. Перезагрузите последнюю сохранённую версию перед повторной попыткой.',
'wizard.test.correlation': 'Request ID: {requestId}\nTrace ID: {traceId}',
'wizard.test.request_id': 'Request ID',
'wizard.test.trace_id': 'Trace ID',
'wizard.test.copy_request_id': 'Копировать Request ID',
'wizard.test.copy_trace_id': 'Копировать Trace ID',
'wizard.test.id_copied': 'Идентификатор поддержки скопирован',
'wizard.test.id_copied_body': 'Идентификатор скопирован без содержимого запроса или ответа.',
// Common buttons
'btn.save': 'Сохранить',
@@ -1677,6 +1935,7 @@ var TRANSLATIONS = {
'agents.card.keys': 'ключей',
'agents.card.calls_today': 'вызовов сегодня',
'agents.card.created': 'Создан {date}',
'agents.card.revision': 'Черновик v{draft} · Опубликована v{published} · Ревизия каталога {revision}',
'agents.card.copy_endpoint': 'Скопировать endpoint',
'agents.card.endpoint_help': 'Данный эндпоинт требуется указать на стороне MCP клиента вместе с API ключом.',
'agents.card.endpoint_help_community': 'Данный эндпоинт требуется указать на стороне MCP клиента вместе с API ключом.',
@@ -1701,6 +1960,7 @@ var TRANSLATIONS = {
'agents.drawer.optional': '(необязательно)',
'agents.drawer.required': 'обязательно',
'agents.drawer.status': 'Статус',
'agents.drawer.revision': 'Черновик v{draft} · Опубликована v{published} · Ревизия каталога {revision}',
'agents.drawer.endpoint': 'MCP endpoint',
'agents.drawer.slug_hint': 'Slug используется как часть endpoint-а для идентификации агента.',
'agents.drawer.placeholder.name': 'Customer Support',
@@ -1759,12 +2019,17 @@ var TRANSLATIONS = {
'agents.toast.delete_message': 'Агент {name} удален.',
'agents.toast.delete_error_title': 'Не удалось удалить',
'agents.toast.delete_error_message': 'Не удалось удалить агента',
'agents.toast.delete_forbidden_message': 'Опубликованных агентов нельзя удалить. Сначала архивируйте или снимите агента с публикации.',
'agents.toast.lifecycle_title': 'Жизненный цикл агента обновлен',
'agents.toast.lifecycle_publish_confirm': 'Опубликовать {name}? Опубликованный MCP-каталог изменится.',
'agents.toast.lifecycle_unpublish_confirm': 'Снять {name} с публикации? Текущие MCP-клиенты больше не увидят этот каталог агента.',
'agents.toast.lifecycle_archive_confirm': 'Архивировать {name}? Новые публикации и привязки будут заблокированы.',
'agents.toast.lifecycle_publish': '{name} опубликован.',
'agents.toast.lifecycle_unpublish': '{name} возвращен в черновик.',
'agents.toast.lifecycle_archive': '{name} архивирован.',
'agents.toast.lifecycle_error_title': 'Не удалось обновить жизненный цикл',
'agents.toast.lifecycle_error_message': 'Не удалось обновить состояние агента',
'agents.toast.stale_message': 'Агент изменился в другом запросе. Перезагрузите форму и повторите действие.',
'agents.toast.endpoint_title': 'MCP endpoint скопирован',
// Demo content
@@ -1781,9 +2046,24 @@ var TRANSLATIONS = {
'login.email_label': 'Email адрес',
'login.email_placeholder': 'you@acme.com',
'login.password_only': 'Войдите с помощью email и пароля.',
'login.bootstrap.title': 'Создать администратора',
'login.bootstrap.subtitle': 'Завершите локальную первичную настройку',
'login.bootstrap.note': 'Введите одноразовый токен из crank-migrate и задайте первый пароль администратора.',
'login.bootstrap.token_label': 'Bootstrap token',
'login.bootstrap.token_placeholder': 'Вставьте одноразовый bootstrap token',
'login.bootstrap.submit': 'Создать администратора',
'login.loading': 'Входим…',
'login.success': 'Вход выполнен. Перенаправляем…',
'login.bootstrap.loading': 'Создаем администратора…',
'login.bootstrap.success': 'Администратор создан. Перенаправляем…',
'login.error.required': 'Введите email и пароль.',
'login.bootstrap.error.required': 'Введите bootstrap token и пароль.',
'login.error.invalid': 'Неверный email или пароль. Попробуйте еще раз.',
'login.error.throttled': 'Слишком много попыток. Подождите и попробуйте снова.',
'login.error.expired_session': 'Сессия истекла. Войдите снова.',
'login.error.generic': 'Сейчас не удается войти. Попробуйте еще раз.',
'login.error.request_id': 'Request ID',
'login.error.trace_id': 'Trace ID',
}
};
@@ -1793,6 +2073,36 @@ Object.assign(TRANSLATIONS.en, {
});
Object.assign(TRANSLATIONS.ru, {
'execution.error.authorization_denied': 'Выполнение операции запрещено.',
'execution.error.auth_profile_not_found': 'Профиль авторизации не найден.',
'execution.error.secret_not_found': 'Секрет авторизации не найден.',
'execution.error.secret_invalid': 'Секрет авторизации имеет неподходящий формат.',
'execution.error.input_schema_invalid': 'Входные параметры не прошли проверку схемы.',
'execution.error.input_mapping_invalid': 'Не удалось сопоставить входные параметры.',
'execution.error.prepared_request_invalid': 'Не удалось подготовить корректный API-запрос.',
'execution.error.execution_overloaded': 'Сервис временно перегружен.',
'execution.error.safety_store_unavailable': 'Обязательное хранилище безопасности недоступно.',
'execution.error.protocol_unsupported': 'Протокол операции не поддерживается.',
'execution.error.execution_mode_unsupported': 'Режим выполнения не поддерживается.',
'execution.error.adapter_configuration_invalid': 'Конфигурация адаптера некорректна.',
'execution.error.outbound_target_rejected': 'Целевой адрес отклонён политикой безопасности.',
'execution.error.upstream_auth_error': 'Внешний API отклонил авторизацию.',
'execution.error.upstream_not_found': 'Ресурс внешнего API не найден.',
'execution.error.upstream_rate_limited': 'Внешний API ограничил частоту запросов.',
'execution.error.upstream_server_error': 'Внешний API временно недоступен.',
'execution.error.upstream_status_error': 'Внешний API вернул ошибочный статус.',
'execution.error.upstream_timeout': 'Истекло время ожидания внешнего API.',
'execution.error.upstream_transport_error': 'Не удалось подключиться к внешнему API.',
'execution.error.upstream_response_too_large': 'Ответ внешнего API превышает лимит.',
'execution.error.output_mapping_invalid': 'Не удалось сопоставить ответ внешнего API.',
'execution.error.output_schema_invalid': 'Ответ не прошёл проверку схемы.',
'execution.error.persistence_unavailable': 'Обязательное сохранение результата недоступно.',
'execution.error.runtime_internal': 'Внутренняя ошибка выполнения.',
'execution.error.confirmation_required': 'Операция требует подтверждения.',
'execution.error.confirmation_invalid': 'Подтверждение недействительно или истекло.',
'execution.error.idempotency_in_progress': 'Операция с этим ключом уже выполняется.',
'execution.error.idempotency_conflict': 'Ключ идемпотентности использован с другими параметрами.',
'execution.error.idempotency_outcome_unknown': 'Результат предыдущего выполнения неизвестен; автоматический повтор запрещён.',
});
+73 -5
View File
@@ -3,6 +3,17 @@
return window.t ? t(key) : key;
}
function diagnosticSuffix(error) {
var parts = [];
if (error && error.requestId) {
parts.push(tKey('login.error.request_id') + ': ' + error.requestId);
}
if (error && error.traceId) {
parts.push(tKey('login.error.trace_id') + ': ' + error.traceId);
}
return parts.length ? ('\n' + parts.join('\n')) : '';
}
function showError(message) {
var errorElement = document.getElementById('login-error');
errorElement.classList.add('is-visible');
@@ -14,34 +25,91 @@
errorElement.classList.remove('is-visible');
}
function setBootstrapMode(enabled) {
var emailField = document.getElementById('login-email-field');
var tokenField = document.getElementById('login-bootstrap-token-field');
var heading = document.querySelector('.login-heading');
var subtitle = document.querySelector('.login-sub');
var note = document.querySelector('.login-note');
var submit = document.getElementById('login-submit');
var password = document.getElementById('password');
document.getElementById('login-form').dataset.bootstrap = enabled ? 'true' : 'false';
if (emailField) emailField.hidden = enabled;
if (tokenField) tokenField.hidden = !enabled;
if (heading) heading.textContent = tKey(enabled ? 'login.bootstrap.title' : 'login.title');
if (subtitle) subtitle.textContent = tKey(enabled ? 'login.bootstrap.subtitle' : 'login.subtitle');
if (note) note.textContent = tKey(enabled ? 'login.bootstrap.note' : 'login.password_only');
if (submit) submit.textContent = tKey(enabled ? 'login.bootstrap.submit' : 'login.submit');
if (password) password.setAttribute('autocomplete', enabled ? 'new-password' : 'current-password');
}
function initLoginPage() {
var inFlight = false;
window.CrankAuth.guardLoginPage().catch(function(error) {
if (window.CrankDiagnostics && typeof window.CrankDiagnostics.report === 'function') {
window.CrankDiagnostics.report('guard-login-page', error, 'login');
}
});
setBootstrapMode(false);
window.CrankApi.getBootstrapStatus()
.then(function(status) {
setBootstrapMode(Boolean(status && status.bootstrap_required));
})
.catch(function(error) {
if (window.CrankDiagnostics && typeof window.CrankDiagnostics.report === 'function') {
window.CrankDiagnostics.report('bootstrap-status', error, 'login');
}
});
document.getElementById('login-form').addEventListener('submit', async function(event) {
event.preventDefault();
if (inFlight) {
return;
}
var form = event.currentTarget;
var isBootstrap = event.currentTarget.dataset.bootstrap === 'true';
var email = document.getElementById('email').value.trim();
var token = document.getElementById('bootstrap-token').value.trim();
var password = document.getElementById('password').value;
var submit = document.getElementById('login-submit');
if (!email || !password) {
showError(tKey('login.error.required'));
if ((!isBootstrap && !email) || (isBootstrap && !token) || !password) {
showError(tKey(isBootstrap ? 'login.bootstrap.error.required' : 'login.error.required'));
return;
}
hideError();
inFlight = true;
if (submit) {
submit.disabled = true;
submit.textContent = tKey(isBootstrap ? 'login.bootstrap.loading' : 'login.loading');
}
try {
await window.CrankAuth.login(email, password);
if (isBootstrap) {
await window.CrankAuth.completeBootstrap(token, password);
} else {
await window.CrankAuth.login(email, password);
}
showError(tKey(isBootstrap ? 'login.bootstrap.success' : 'login.success'));
} catch (error) {
if (error && error.status === 401) {
showError(tKey('login.error.invalid'));
showError(tKey('login.error.invalid') + diagnosticSuffix(error));
return;
}
showError(tKey('login.error.generic'));
if (error && error.status === 429) {
showError(tKey('login.error.throttled') + diagnosticSuffix(error));
return;
}
showError(tKey('login.error.generic') + diagnosticSuffix(error));
} finally {
inFlight = false;
if (submit && form.isConnected) {
submit.disabled = false;
submit.textContent = tKey(isBootstrap ? 'login.bootstrap.submit' : 'login.submit');
}
}
});
}
+297 -14
View File
@@ -3,9 +3,14 @@ document.addEventListener('DOMContentLoaded', function () {
logs: [],
details: {},
level: 'all',
status: 'all',
outcomeGroup: 'all',
operationId: '',
agentId: '',
search: '',
period: '7d',
openId: null,
nextCursor: null,
liveMode: true,
timer: null,
searchTimer: null,
@@ -16,6 +21,10 @@ document.addEventListener('DOMContentLoaded', function () {
approvals: [],
approvalsLoading: false,
approvalsError: '',
approvalActionBusy: {},
approvalsEpoch: 0,
logsEpoch: 0,
exporting: false,
};
var logList = document.getElementById('log-list');
@@ -23,9 +32,21 @@ document.addEventListener('DOMContentLoaded', function () {
var approvalRefreshBtn = document.getElementById('approval-refresh-btn');
var logSearch = document.getElementById('log-search');
var refreshBtn = document.getElementById('refresh-btn');
var exportLogsBtn = document.getElementById('export-logs-btn');
var loadMoreBtn = document.getElementById('load-more-logs-btn');
var timeRangeSel = document.getElementById('time-range');
var statusFilter = document.getElementById('status-filter');
var outcomeFilter = document.getElementById('outcome-filter');
var operationFilter = document.getElementById('operation-filter');
var agentFilter = document.getElementById('agent-filter');
var liveDot = document.querySelector('.live-dot');
var liveLabel = document.querySelector('.live-label');
var initialParams = new URLSearchParams(window.location.search);
var deepLinkLogId = /^[A-Za-z0-9_-]{1,128}$/.test(initialParams.get('log_id') || '') ? initialParams.get('log_id') : '';
var deepLinkAgentId = /^[A-Za-z0-9_-]{1,128}$/.test(initialParams.get('agent_id') || '') ? initialParams.get('agent_id') : '';
var deepLinkOperationId = /^[A-Za-z0-9_-]{1,128}$/.test(initialParams.get('operation_id') || '') ? initialParams.get('operation_id') : '';
state.agentId = deepLinkAgentId;
state.operationId = deepLinkOperationId;
function tKey(key) {
return window.t ? t(key) : key;
@@ -118,6 +139,7 @@ document.addEventListener('DOMContentLoaded', function () {
status: log.status,
statusCode: log.status_code,
durationMs: log.duration_ms,
operationVersion: log.operation_version,
toolName: log.tool_name,
message: log.message,
operationName: record.operation_name,
@@ -127,7 +149,10 @@ document.addEventListener('DOMContentLoaded', function () {
requestPreview: log.request_preview,
responsePreview: log.response_preview,
errorKind: log.error_kind,
executionStage: log.execution_stage,
executionErrorCode: log.execution_error_code,
requestId: log.request_id,
traceId: log.trace_id,
};
}
@@ -142,6 +167,8 @@ document.addEventListener('DOMContentLoaded', function () {
riskLevel: approval.risk_level,
requestPayload: approval.request_payload,
responsePayload: approval.response_payload,
requestId: approval.request_id,
traceId: approval.trace_id,
createdAt: approval.created_at,
expiresAt: approval.expires_at,
decidedAt: approval.decided_at,
@@ -206,6 +233,17 @@ document.addEventListener('DOMContentLoaded', function () {
: tKey('approvals.updated_at') + ': ' + formatDateTime(item.decidedAt || item.createdAt);
card.appendChild(timing);
if (item.requestId || item.traceId) {
var correlation = element('div', 'approval-correlation');
if (item.requestId) {
correlation.appendChild(element('span', 'approval-correlation-id', tKey('approvals.request_id') + ': ' + item.requestId));
}
if (item.traceId) {
correlation.appendChild(element('span', 'approval-correlation-id', tKey('approvals.trace_id') + ': ' + item.traceId));
}
card.appendChild(correlation);
}
var payloadGrid = element('div', 'approval-payload-grid');
var requestBlock = element('div', 'approval-payload');
requestBlock.appendChild(element('div', 'approval-payload-label', tKey('approvals.request')));
@@ -228,6 +266,24 @@ document.addEventListener('DOMContentLoaded', function () {
card.appendChild(element('div', 'approval-note', item.note));
}
if (item.status === 'pending') {
var actions = element('div', 'approval-actions');
var busy = Boolean(state.approvalActionBusy[item.id]);
var approve = element('button', 'btn btn-primary btn-sm approval-approve', busy ? tKey('approvals.action.busy') : tKey('approvals.action.approve'));
approve.type = 'button';
approve.disabled = busy;
approve.dataset.approvalId = item.id;
approve.dataset.action = 'approve';
var deny = element('button', 'btn btn-secondary btn-sm approval-deny', busy ? tKey('approvals.action.busy') : tKey('approvals.action.deny'));
deny.type = 'button';
deny.disabled = busy;
deny.dataset.approvalId = item.id;
deny.dataset.action = 'deny';
actions.appendChild(approve);
actions.appendChild(deny);
card.appendChild(actions);
}
fragment.appendChild(card);
});
@@ -256,10 +312,13 @@ document.addEventListener('DOMContentLoaded', function () {
if (!state.logs.length) {
renderEmpty(
tKey('logs.empty.title'),
state.search || state.level !== 'all'
state.search || state.level !== 'all' || state.status !== 'all' || state.outcomeGroup !== 'all' || state.operationId || state.agentId
? tKey('logs.empty.filtered')
: tKey('logs.empty.initial')
);
if (loadMoreBtn) {
loadMoreBtn.hidden = true;
}
return;
}
@@ -347,7 +406,7 @@ document.addEventListener('DOMContentLoaded', function () {
responsePre.textContent = formatJson(detail.responsePreview);
expanded.appendChild(responsePre);
if (detail.errorKind || detail.requestId) {
if (detail.errorKind || detail.requestId || detail.traceId || detail.executionStage || detail.executionErrorCode || detail.operationVersion) {
var metaLabel = document.createElement('div');
metaLabel.className = 'log-detail-label';
metaLabel.textContent = tKey('logs.detail.meta');
@@ -357,7 +416,11 @@ document.addEventListener('DOMContentLoaded', function () {
metaPre.className = 'log-detail-block';
metaPre.textContent = formatJson({
request_id: detail.requestId || null,
trace_id: detail.traceId || null,
operation_version: detail.operationVersion || null,
error_kind: detail.errorKind || null,
execution_stage: detail.executionStage || null,
execution_error_code: detail.executionErrorCode || null,
source: detail.source,
status: detail.status,
});
@@ -370,6 +433,10 @@ document.addEventListener('DOMContentLoaded', function () {
logList.innerHTML = '';
logList.appendChild(fragment);
if (loadMoreBtn) {
loadMoreBtn.hidden = !state.nextCursor;
loadMoreBtn.disabled = state.loading;
}
logList.querySelectorAll('.log-entry').forEach(function (row) {
row.addEventListener('click', async function () {
@@ -383,21 +450,60 @@ document.addEventListener('DOMContentLoaded', function () {
});
}
function queryParams() {
function invalidateLogsRequest() {
state.logsEpoch += 1;
state.nextCursor = null;
}
function queryParams(options) {
options = options || {};
var params = {
period: state.period,
limit: 100,
};
if (options.includeLimit !== false) {
params.limit = 100;
}
if (state.level !== 'all') {
params.level = state.level;
}
if (state.search) {
params.search = state.search;
}
if (state.status !== 'all') {
params.status = state.status;
}
if (state.outcomeGroup !== 'all') {
params.outcome_group = state.outcomeGroup;
}
if (state.operationId) {
params.operation_id = state.operationId;
}
if (state.agentId) {
params.agent_id = state.agentId;
}
return params;
}
async function loadLogs() {
function bindTextFilter(input, key) {
if (!input) {
return;
}
input.value = state[key] || '';
input.addEventListener('input', function () {
state[key] = this.value.trim();
invalidateLogsRequest();
if (state.searchTimer) {
clearTimeout(state.searchTimer);
}
state.searchTimer = setTimeout(function () {
state.searchTimer = null;
loadLogs();
}, 250);
});
}
async function loadLogs(options) {
options = options || {};
if (!window.CrankApi) {
state.loadError = tKey('logs.error.api');
renderLogs();
@@ -411,21 +517,39 @@ document.addEventListener('DOMContentLoaded', function () {
return;
}
var workspaceId = state.workspaceId;
var epoch = ++state.logsEpoch;
var append = Boolean(options.append && state.nextCursor);
var params = queryParams();
if (append) {
params.cursor = state.nextCursor;
}
state.loading = true;
state.loadError = '';
renderLogs();
try {
var response = await window.CrankApi.listLogs(state.workspaceId, queryParams());
state.logs = (response && response.items ? response.items : []).map(normalizeLog);
var response = await window.CrankApi.listLogs(workspaceId, params);
if (epoch !== state.logsEpoch || workspaceId !== currentWorkspaceId()) {
return;
}
var loaded = (response && response.items ? response.items : []).map(normalizeLog);
state.logs = append ? state.logs.concat(loaded) : loaded;
state.nextCursor = response && response.next_cursor ? response.next_cursor : null;
if (state.openId && !state.logs.some(function (item) { return item.id === state.openId; })) {
state.openId = null;
}
} catch (error) {
if (epoch !== state.logsEpoch) {
return;
}
state.loadError = error.message || tKey('logs.error.load');
} finally {
state.loading = false;
renderLogs();
if (epoch === state.logsEpoch) {
state.loading = false;
renderLogs();
}
}
}
@@ -443,17 +567,54 @@ document.addEventListener('DOMContentLoaded', function () {
return;
}
var epoch = ++state.approvalsEpoch;
var workspaceId = state.workspaceId;
state.approvalsLoading = true;
state.approvalsError = '';
renderApprovals();
try {
var response = await window.CrankApi.listApprovals(state.workspaceId, { limit: 20 });
var response = await window.CrankApi.listApprovals(workspaceId, { limit: 20 });
if (epoch !== state.approvalsEpoch || workspaceId !== currentWorkspaceId()) {
return;
}
state.approvals = (response && response.items ? response.items : []).map(normalizeApproval);
} catch (error) {
if (epoch !== state.approvalsEpoch) {
return;
}
state.approvalsError = error.message || tKey('approvals.error.load');
} finally {
state.approvalsLoading = false;
if (epoch === state.approvalsEpoch) {
state.approvalsLoading = false;
renderApprovals();
}
}
}
async function decideApproval(approvalId, action) {
if (!window.CrankApi || !state.workspaceId || !approvalId) return;
if (state.approvalActionBusy[approvalId]) return;
var confirmKey = action === 'approve' ? 'approvals.confirm.approve' : 'approvals.confirm.deny';
if (!window.confirm(tKey(confirmKey))) return;
var workspaceId = state.workspaceId;
var epoch = ++state.approvalsEpoch;
state.approvalActionBusy[approvalId] = true;
state.approvalsError = '';
renderApprovals();
try {
if (action === 'approve') {
await window.CrankApi.approveApproval(workspaceId, approvalId, { approve: 'yes' });
} else {
await window.CrankApi.denyApproval(workspaceId, approvalId, { approve: 'no' });
}
if (workspaceId !== currentWorkspaceId() || epoch !== state.approvalsEpoch) return;
state.approvalActionBusy[approvalId] = false;
await loadApprovals();
} catch (error) {
if (workspaceId !== currentWorkspaceId() || epoch !== state.approvalsEpoch) return;
state.approvalsError = error.message || tKey('approvals.error.decision');
state.approvalActionBusy[approvalId] = false;
renderApprovals();
}
}
@@ -473,8 +634,13 @@ document.addEventListener('DOMContentLoaded', function () {
return;
}
var workspaceId = state.workspaceId;
var epoch = state.logsEpoch;
try {
var record = await window.CrankApi.getLog(state.workspaceId, logId);
var record = await window.CrankApi.getLog(workspaceId, logId);
if (epoch !== state.logsEpoch || workspaceId !== currentWorkspaceId()) {
return;
}
state.details[logId] = normalizeLog(record);
if (state.openId === logId) {
renderLogs();
@@ -483,6 +649,32 @@ document.addEventListener('DOMContentLoaded', function () {
}
}
async function loadExactLog(logId) {
var workspaceId = currentWorkspaceId();
var epoch = ++state.logsEpoch;
state.workspaceId = workspaceId;
state.loading = true;
state.loadError = '';
renderLogs();
try {
var record = await window.CrankApi.getLog(workspaceId, logId);
if (epoch !== state.logsEpoch || workspaceId !== currentWorkspaceId()) return;
var detail = normalizeLog(record);
state.logs = [detail];
state.details[logId] = detail;
state.openId = logId;
state.nextCursor = null;
} catch (error) {
if (epoch !== state.logsEpoch) return;
state.loadError = error.message || tKey('logs.error.load');
} finally {
if (epoch === state.logsEpoch) {
state.loading = false;
renderLogs();
}
}
}
function setLiveState() {
if (liveDot) {
liveDot.classList.toggle('is-paused', !state.liveMode);
@@ -524,9 +716,52 @@ document.addEventListener('DOMContentLoaded', function () {
}
}
async function exportLogsCsv() {
if (!window.CrankApi || state.exporting) {
return;
}
var workspaceId = currentWorkspaceId();
if (!workspaceId) {
if (window.CrankUi) {
window.CrankUi.error(tKey('logs.error.workspace'), tKey('logs.export.error_title'));
}
return;
}
state.exporting = true;
if (exportLogsBtn) {
exportLogsBtn.disabled = true;
}
try {
var csv = await window.CrankApi.exportLogsCsv(workspaceId, queryParams({ includeLimit: false }));
if (workspaceId !== currentWorkspaceId()) {
return;
}
var blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
var url = URL.createObjectURL(blob);
var link = document.createElement('a');
link.href = url;
link.download = 'crank-invocation-history.csv';
link.click();
URL.revokeObjectURL(url);
if (window.CrankUi) {
window.CrankUi.success(tKey('logs.export.done_body'), tKey('logs.export.done_title'));
}
} catch (error) {
if (window.CrankUi) {
window.CrankUi.error(error.message || tKey('logs.export.error_body'), tKey('logs.export.error_title'));
}
} finally {
state.exporting = false;
if (exportLogsBtn) {
exportLogsBtn.disabled = false;
}
}
}
document.querySelectorAll('.filter-chip[data-level]').forEach(function (button) {
button.addEventListener('click', function () {
state.level = this.getAttribute('data-level');
invalidateLogsRequest();
document.querySelectorAll('.filter-chip[data-level]').forEach(function (item) {
item.classList.remove('active');
});
@@ -538,6 +773,7 @@ document.addEventListener('DOMContentLoaded', function () {
if (logSearch) {
logSearch.addEventListener('input', function () {
state.search = this.value.trim();
invalidateLogsRequest();
if (state.searchTimer) {
clearTimeout(state.searchTimer);
}
@@ -548,8 +784,30 @@ document.addEventListener('DOMContentLoaded', function () {
});
}
if (statusFilter) {
statusFilter.value = state.status;
statusFilter.addEventListener('change', function () {
state.status = this.value || 'all';
invalidateLogsRequest();
loadLogs();
});
}
if (outcomeFilter) {
outcomeFilter.value = state.outcomeGroup;
outcomeFilter.addEventListener('change', function () {
state.outcomeGroup = this.value || 'all';
invalidateLogsRequest();
loadLogs();
});
}
bindTextFilter(operationFilter, 'operationId');
bindTextFilter(agentFilter, 'agentId');
if (refreshBtn) {
refreshBtn.addEventListener('click', function () {
invalidateLogsRequest();
loadLogs().then(function () {
if (!state.loadError && window.CrankUi) {
window.CrankUi.info(tKey('logs.refresh.body'), tKey('logs.refresh.title'));
@@ -558,6 +816,16 @@ document.addEventListener('DOMContentLoaded', function () {
});
}
if (exportLogsBtn) {
exportLogsBtn.addEventListener('click', exportLogsCsv);
}
if (loadMoreBtn) {
loadMoreBtn.addEventListener('click', function () {
loadLogs({ append: true });
});
}
if (approvalRefreshBtn) {
approvalRefreshBtn.addEventListener('click', function () {
loadApprovals().then(function () {
@@ -568,10 +836,19 @@ document.addEventListener('DOMContentLoaded', function () {
});
}
if (approvalList) {
approvalList.addEventListener('click', function (event) {
var button = event.target.closest('button[data-approval-id][data-action]');
if (!button) return;
decideApproval(button.dataset.approvalId, button.dataset.action);
});
}
if (timeRangeSel) {
timeRangeSel.value = state.period;
timeRangeSel.addEventListener('change', function () {
state.period = this.value;
invalidateLogsRequest();
loadLogs();
});
}
@@ -585,8 +862,10 @@ document.addEventListener('DOMContentLoaded', function () {
}
window.addEventListener('crank:workspacechange', function () {
state.logsEpoch += 1;
state.details = {};
state.openId = null;
state.nextCursor = null;
refreshOperationalData();
});
@@ -612,8 +891,12 @@ document.addEventListener('DOMContentLoaded', function () {
startPolling();
if (window.whenWorkspacesReady) {
window.whenWorkspacesReady().finally(refreshOperationalData);
window.whenWorkspacesReady().finally(function() {
if (deepLinkLogId) return Promise.all([loadExactLog(deepLinkLogId), loadApprovals()]);
return refreshOperationalData();
});
} else {
refreshOperationalData();
if (deepLinkLogId) Promise.all([loadExactLog(deepLinkLogId), loadApprovals()]);
else refreshOperationalData();
}
});
+488
View File
@@ -0,0 +1,488 @@
(function() {
var STEP_ORDER = ['operation', 'test', 'publish_operation', 'agent', 'key', 'mcp_connection', 'first_call'];
var state = {
epoch: 0,
workspaceId: null,
snapshot: null,
loading: false,
error: null,
open: false,
dismissed: false,
controller: null,
refreshPromise: null,
refreshQueued: false,
startedRequested: false,
};
var channel = null;
var trigger = null;
var panel = null;
var heading = null;
var subtitle = null;
var collapseButton = null;
var lastFocused = null;
function tKey(key) { return window.t ? window.t(key) : key; }
function currentWorkspace() { return window.getCurrentWorkspace ? window.getCurrentWorkspace() : null; }
function preferenceKey() { return 'crank_onboarding_ui:' + (state.workspaceId || 'none'); }
function safePreference() {
try { return JSON.parse(localStorage.getItem(preferenceKey())) || {}; } catch (_error) { return {}; }
}
function writePreference(value) {
try { localStorage.setItem(preferenceKey(), JSON.stringify(value)); } catch (_error) {}
}
function updatePreference(value) {
writePreference(Object.assign({}, safePreference(), value));
}
function injectStylesheet() {
if (document.querySelector('link[data-crank-onboarding-css]')) return;
var link = document.createElement('link');
link.rel = 'stylesheet';
link.href = (window.APP_BASE || '/') + 'css/onboarding.css';
link.dataset.crankOnboardingCss = 'true';
document.head.appendChild(link);
}
function button(label, testId, handler, className) {
var node = document.createElement('button');
node.type = 'button';
node.textContent = label;
if (testId) node.dataset.testid = testId;
node.className = className || 'onboarding-icon-button';
node.addEventListener('click', handler);
return node;
}
function normalizeStep(raw) {
var id = raw.id || raw.kind;
if (id === 'publish') id = 'publish_operation';
if (id === 'connection') id = 'mcp_connection';
var completed = raw.completed === true || raw.status === 'complete';
return {
id: id,
completed: completed,
status: raw.status || (completed ? 'complete' : 'pending'),
actionCode: raw.action_code || raw.action || '',
reasonCode: raw.reason_code || '',
};
}
function normalizedSteps() {
var raw = state.snapshot && Array.isArray(state.snapshot.steps) ? state.snapshot.steps : [];
var mapped = raw.map(normalizeStep);
return STEP_ORDER.map(function(id) {
return mapped.find(function(item) { return item.id === id; }) || { id: id, completed: false, status: 'pending', actionCode: '', reasonCode: '' };
});
}
function completedCount() { return normalizedSteps().filter(function(step) { return step.completed; }).length; }
function currentStep(steps) {
return steps.find(function(step) { return !step.completed && step.status === 'current'; })
|| steps.find(function(step) { return !step.completed; }) || null;
}
function stepLabel(id) { return tKey('onboarding.step.' + id); }
function actionLabel(id) { return tKey('onboarding.action.' + id); }
function deepLink(step) {
var snapshot = state.snapshot || {};
var returnTo = encodeURIComponent(window.location.pathname + window.location.search);
if (step.id === 'operation') return '/wizard/?onboarding=1&return=' + returnTo;
if (step.id === 'test' || step.id === 'publish_operation') {
if (!snapshot.operation_id) return '/wizard/?onboarding=1&return=' + returnTo;
return '/wizard/?mode=edit&operationId=' + encodeURIComponent(snapshot.operation_id)
+ '&onboarding=1&step=5&return=' + returnTo;
}
if (step.id === 'agent') {
return '/agents?onboarding=1&action=create&operationId=' + encodeURIComponent(snapshot.operation_id || '')
+ '&operationVersion=' + encodeURIComponent(snapshot.operation_version || '') + '&return=' + returnTo;
}
if (step.id === 'key' || step.id === 'mcp_connection') {
return '/api-keys?onboarding=1&action=create&agentId=' + encodeURIComponent(snapshot.agent_id || '')
+ '&agentRevision=' + encodeURIComponent(snapshot.catalog_revision || '') + '&return=' + returnTo;
}
if (step.id === 'first_call' && snapshot.first_call && snapshot.first_call.log_id) {
return '/logs?log_id=' + encodeURIComponent(snapshot.first_call.log_id);
}
return '/logs?agent_id=' + encodeURIComponent(snapshot.agent_id || '');
}
function navigate(step) { window.location.href = deepLink(step); }
function recordStarted() {
if (state.startedRequested || !state.snapshot || state.snapshot.completed) return;
state.startedRequested = true;
recordPresentation('started', { stable: true }).then(function(response) {
if (!response) state.startedRequested = false;
});
}
function setOpen(open) {
state.open = open;
panel.hidden = !open;
trigger.setAttribute('aria-expanded', String(open));
if (open) {
lastFocused = document.activeElement;
render();
var focusTarget = panel.querySelector('[aria-current="step"] .onboarding-step-action') || panel.querySelector('button');
if (focusTarget) focusTarget.focus();
recordStarted();
} else if (lastFocused && typeof lastFocused.focus === 'function') {
lastFocused.focus();
}
}
function render() {
if (!trigger || !panel) return;
var count = completedCount();
trigger.textContent = state.dismissed
? tKey('onboarding.resume')
: tKey('onboarding.title') + ' · ' + count + '/7';
trigger.setAttribute('aria-label', state.dismissed ? tKey('onboarding.resume') : trigger.textContent);
if (heading) heading.textContent = tKey('onboarding.title');
if (subtitle) subtitle.textContent = tKey('onboarding.subtitle');
if (collapseButton) collapseButton.setAttribute('aria-label', tKey('onboarding.collapse'));
var body = panel.querySelector('.onboarding-body');
var hadActionFocus = state.open
&& document.activeElement
&& body.contains(document.activeElement)
&& document.activeElement.classList.contains('onboarding-step-action');
body.replaceChildren();
var status = document.createElement('div');
status.className = 'onboarding-status';
status.setAttribute('role', 'status');
status.setAttribute('aria-live', 'polite');
status.id = 'crank-onboarding-progress';
status.textContent = state.loading ? tKey('onboarding.loading') : tKey('onboarding.progress').replace('{count}', String(count));
body.appendChild(status);
if (state.error) {
var error = document.createElement('div');
error.className = 'onboarding-error';
error.setAttribute('role', 'alert');
var message = document.createElement('div');
message.textContent = tKey('onboarding.error');
error.appendChild(message);
var errorCode = state.error.code
|| (state.error.payload && state.error.payload.error && (state.error.payload.error.code || state.error.payload.error.error_code))
|| (state.error.payload && (state.error.payload.code || state.error.payload.error_code));
var support = [
errorCode ? tKey('onboarding.error_code') + ': ' + errorCode : '',
state.error.requestId ? tKey('onboarding.request_id') + ': ' + state.error.requestId : '',
state.error.traceId ? tKey('onboarding.trace_id') + ': ' + state.error.traceId : '',
].filter(Boolean);
if (support.length) {
var supportIds = document.createElement('div');
supportIds.className = 'onboarding-error-ids';
supportIds.textContent = support.join(' · ');
error.appendChild(supportIds);
}
error.appendChild(button(tKey('onboarding.retry'), 'onboarding-retry', refresh, 'onboarding-step-action'));
body.appendChild(error);
if (state.focusError && state.open) {
state.focusError = false;
var retry = error.querySelector('[data-testid="onboarding-retry"]');
if (retry) retry.focus();
}
return;
}
if (!state.snapshot) return;
var steps = normalizedSteps();
var active = currentStep(steps);
if (state.snapshot.completed || !active) {
var complete = document.createElement('div');
complete.className = 'onboarding-completion';
complete.dataset.testid = 'onboarding-completion';
complete.textContent = tKey('onboarding.completed');
var firstCall = state.snapshot.first_call;
if (firstCall) {
var evidence = document.createElement('dl');
evidence.className = 'onboarding-first-call-evidence';
[
[tKey('onboarding.tool'), firstCall.tool_name],
[tKey('onboarding.timestamp'), firstCall.occurred_at],
[tKey('onboarding.request_id'), firstCall.request_id],
[tKey('onboarding.trace_id'), firstCall.trace_id],
].forEach(function(entry) {
if (!entry[1]) return;
var term = document.createElement('dt');
term.textContent = entry[0];
var value = document.createElement('dd');
value.textContent = entry[1];
evidence.appendChild(term);
evidence.appendChild(value);
});
complete.appendChild(evidence);
if (firstCall.log_id) {
var link = document.createElement('a');
link.href = '/logs?log_id=' + encodeURIComponent(firstCall.log_id);
link.textContent = tKey('onboarding.open_invocation');
complete.appendChild(link);
}
}
body.appendChild(complete);
} else {
var list = document.createElement('ol');
list.className = 'onboarding-list';
list.setAttribute('aria-label', tKey('onboarding.progress_label'));
list.setAttribute('aria-describedby', 'crank-onboarding-progress');
steps.forEach(function(step, index) {
var item = document.createElement('li');
item.className = 'onboarding-step' + (step.completed ? ' is-complete' : '') + (step.status === 'regressed' ? ' is-regressed' : '');
if (step === active) item.setAttribute('aria-current', 'step');
var marker = document.createElement('span');
marker.className = 'onboarding-step-marker';
marker.textContent = step.completed ? '✓' : String(index + 1);
marker.setAttribute('aria-hidden', 'true');
var content = document.createElement('div');
var title = document.createElement('div');
title.className = 'onboarding-step-title';
title.textContent = stepLabel(step.id);
content.appendChild(title);
if (step === active) {
var reason = document.createElement('div');
reason.className = 'onboarding-step-reason';
reason.textContent = tKey(step.status === 'regressed' ? 'onboarding.regressed' : 'onboarding.action_needed');
content.appendChild(reason);
content.appendChild(button(actionLabel(step.id), null, function() { navigate(step); }, 'onboarding-step-action'));
}
item.appendChild(marker);
item.appendChild(content);
list.appendChild(item);
});
body.appendChild(list);
}
var footer = document.createElement('div');
footer.className = 'onboarding-footer';
footer.appendChild(button(tKey('onboarding.refresh'), 'onboarding-refresh', refresh, 'onboarding-link-button'));
footer.appendChild(button(tKey('onboarding.dismiss'), 'onboarding-dismiss', dismiss, 'onboarding-link-button'));
body.appendChild(footer);
if (hadActionFocus) {
var replacementAction = panel.querySelector('[aria-current="step"] .onboarding-step-action');
if (replacementAction) replacementAction.focus();
}
}
function refresh() {
var workspace = currentWorkspace();
var workspaceId = workspace ? workspace.id : null;
if (state.refreshPromise && workspaceId === state.workspaceId) {
state.refreshQueued = true;
return state.refreshPromise;
}
if (workspaceId !== state.workspaceId) state.refreshQueued = false;
var promise = performRefresh(workspaceId);
state.refreshPromise = promise;
promise.finally(function() {
if (state.refreshPromise !== promise) return;
state.refreshPromise = null;
if (state.refreshQueued) {
state.refreshQueued = false;
refresh();
}
});
return promise;
}
async function performRefresh(workspaceId) {
var workspaceChanged = workspaceId !== state.workspaceId;
var epoch = ++state.epoch;
if (state.controller) state.controller.abort();
state.controller = typeof AbortController === 'function' ? new AbortController() : null;
state.workspaceId = workspaceId;
if (workspaceChanged) {
state.dismissed = Boolean(safePreference().dismissed);
state.startedRequested = false;
if (state.open) setOpen(false);
}
state.loading = true;
state.error = null;
state.focusError = false;
render();
if (!workspaceId || !window.CrankApi || typeof window.CrankApi.getOnboarding !== 'function') {
state.loading = false;
state.error = new Error(tKey('onboarding.error'));
render();
return;
}
try {
var snapshot = await window.CrankApi.getOnboarding(workspaceId);
if (epoch !== state.epoch || workspaceId !== (currentWorkspace() && currentWorkspace().id)) return;
state.snapshot = snapshot;
var preference = safePreference();
if (window.location.pathname === '/'
&& !preference.opened
&& !preference.dismissed
&& !snapshot.completed) {
updatePreference({ opened: true });
setOpen(true);
}
renderDeepLinkRecovery(snapshot);
} catch (error) {
if (epoch !== state.epoch) return;
state.error = error;
state.focusError = state.open;
} finally {
if (epoch === state.epoch) {
state.loading = false;
render();
}
}
}
async function recordPresentation(eventName, options) {
if (!state.snapshot || !state.workspaceId || !window.CrankApi.recordOnboardingEvent) return;
var expectedRevision = Number(state.snapshot.revision || 0);
var expectedEpoch = state.epoch;
var expectedWorkspaceId = state.workspaceId;
var key = options && options.stable
? ['ui', eventName, state.workspaceId, 'v1'].join(':')
: ['ui', eventName, state.workspaceId, expectedRevision].join(':');
try {
var response = await window.CrankApi.recordOnboardingEvent(state.workspaceId, {
event: eventName,
idempotency_key: key,
expected_revision: expectedRevision,
}, options && options.keepalive ? { keepalive: true } : null);
var currentRevision = Number(state.snapshot && state.snapshot.revision || 0);
if (response
&& expectedEpoch === state.epoch
&& expectedWorkspaceId === state.workspaceId
&& expectedWorkspaceId === (currentWorkspace() && currentWorkspace().id)
&& currentRevision === expectedRevision) {
state.snapshot = response;
render();
}
return response;
} catch (error) {
if (error.status === 409) await refresh();
return null;
}
}
async function dismiss() {
updatePreference({ dismissed: true, opened: true });
state.dismissed = true;
setOpen(false);
render();
await recordPresentation('dismissed');
await recordPresentation('abandoned', { stable: true });
}
function resume() {
updatePreference({ dismissed: false, opened: true });
state.dismissed = false;
setOpen(true);
recordPresentation('resumed');
}
function renderDeepLinkRecovery(snapshot) {
var params = new URLSearchParams(window.location.search);
if (params.get('onboarding') !== '1') return;
var stale = false;
if (window.location.pathname === '/agents') {
var expectedOperation = params.get('operationId') || '';
var expectedVersion = Number(params.get('operationVersion') || 0);
stale = Boolean(expectedOperation) && (
snapshot.operation_id !== expectedOperation
|| (expectedVersion && snapshot.operation_version !== expectedVersion)
);
} else if (window.location.pathname === '/api-keys') {
var expectedAgent = params.get('agentId') || '';
var expectedRevision = Number(params.get('agentRevision') || 0);
stale = Boolean(expectedAgent) && (
snapshot.agent_id !== expectedAgent
|| (expectedRevision && snapshot.catalog_revision !== expectedRevision)
);
}
var existing = document.getElementById('onboarding-deep-link-stale');
if (!stale) {
if (existing) existing.remove();
return;
}
if (existing) return;
var recovery = document.createElement('div');
recovery.id = 'onboarding-deep-link-stale';
recovery.className = 'onboarding-deep-link-stale';
recovery.setAttribute('role', 'alert');
recovery.dataset.testid = 'onboarding-deep-link-stale';
var message = document.createElement('div');
message.textContent = tKey('onboarding.deep_link_stale');
recovery.appendChild(message);
recovery.appendChild(button(tKey('onboarding.reselect'), 'onboarding-reselect', async function() {
var resetButton = this;
resetButton.disabled = true;
try {
if (window.CrankApi && typeof window.CrankApi.resetOnboardingSelection === 'function') {
await window.CrankApi.resetOnboardingSelection(state.workspaceId, Number(snapshot.revision || 0));
}
var clean = new URLSearchParams(window.location.search);
['onboarding', 'action', 'operationId', 'operationVersion', 'agentId', 'agentRevision', 'return'].forEach(function(key) {
clean.delete(key);
});
window.location.replace(window.location.pathname + (clean.toString() ? '?' + clean.toString() : ''));
} catch (error) {
resetButton.disabled = false;
state.error = error;
if (!state.open) setOpen(true);
render();
}
}, 'onboarding-step-action'));
document.body.appendChild(recovery);
}
function mount() {
if (document.getElementById('crank-onboarding-root')) return;
injectStylesheet();
var root = document.createElement('div');
root.id = 'crank-onboarding-root';
trigger = button(tKey('onboarding.title'), 'onboarding-trigger', function() {
if (state.dismissed) {
resume();
return;
}
setOpen(!state.open);
}, 'onboarding-trigger');
trigger.setAttribute('aria-controls', 'crank-onboarding-panel');
trigger.setAttribute('aria-expanded', 'false');
panel = document.createElement('aside');
panel.id = 'crank-onboarding-panel';
panel.className = 'onboarding-panel';
panel.dataset.testid = 'onboarding-checklist';
panel.setAttribute('aria-labelledby', 'crank-onboarding-title');
panel.hidden = true;
var head = document.createElement('div');
head.className = 'onboarding-head';
var headingWrap = document.createElement('div');
heading = document.createElement('h2');
heading.id = 'crank-onboarding-title';
heading.className = 'onboarding-title';
heading.textContent = tKey('onboarding.title');
subtitle = document.createElement('p');
subtitle.className = 'onboarding-subtitle';
subtitle.textContent = tKey('onboarding.subtitle');
headingWrap.appendChild(heading);
headingWrap.appendChild(subtitle);
var actions = document.createElement('div');
actions.className = 'onboarding-head-actions';
collapseButton = button('', 'onboarding-collapse', function() { setOpen(false); }, 'onboarding-icon-button');
collapseButton.setAttribute('aria-label', tKey('onboarding.collapse'));
actions.appendChild(collapseButton);
head.appendChild(headingWrap);
head.appendChild(actions);
var body = document.createElement('div');
body.className = 'onboarding-body';
panel.appendChild(head);
panel.appendChild(body);
root.appendChild(trigger);
root.appendChild(panel);
document.body.appendChild(root);
refresh();
}
function signalRefresh() {
if (channel) channel.postMessage({ type: 'refresh' });
try { localStorage.setItem('crank_onboarding_signal', String(Date.now())); } catch (_error) {}
}
window.CrankOnboarding = { refresh: refresh, signalRefresh: signalRefresh };
document.addEventListener('DOMContentLoaded', async function() {
await (window.whenWorkspacesReady ? window.whenWorkspacesReady() : Promise.resolve());
mount();
if (typeof BroadcastChannel === 'function') {
channel = new BroadcastChannel('crank-onboarding');
channel.addEventListener('message', function(event) {
if (event && event.data && event.data.type !== 'refresh') return;
refresh();
});
}
});
window.addEventListener('crank:workspacechange', function() {
state.snapshot = null;
refresh();
});
window.addEventListener('crank:sessionchange', refresh);
window.addEventListener('crank:langchange', function() { render(); });
window.addEventListener('storage', function(event) { if (event.key === 'crank_onboarding_signal') refresh(); });
document.addEventListener('visibilitychange', function() { if (!document.hidden) refresh(); });
document.addEventListener('keydown', function(event) { if (event.key === 'Escape' && state.open) setOpen(false); });
}());
+89 -15
View File
@@ -13,6 +13,10 @@ function initSecretsPage() {
error: '',
modalMode: 'create',
modalSecretId: null,
modalSubmitting: false,
deleting: {},
generation: 0,
modalGeneration: 0,
};
var modal = document.getElementById('secret-modal');
@@ -107,6 +111,10 @@ function initSecretsPage() {
}
function openModal(mode, secret) {
if (state.modalSubmitting) {
return;
}
state.modalGeneration += 1;
state.modalMode = mode;
state.modalSecretId = secret ? secret.id : null;
resetModalFields();
@@ -143,8 +151,16 @@ function initSecretsPage() {
function closeModal() {
modal.classList.remove('open');
state.modalGeneration += 1;
state.modalSubmitting = false;
state.modalMode = 'create';
state.modalSecretId = null;
resetModalFields();
modalName.disabled = false;
modalKind.disabled = false;
modalSubmit.disabled = state.modalSubmitting;
modalSubmit.textContent = tKey('secrets.modal.create_action');
renderSecrets();
}
function updateKindFields() {
@@ -290,6 +306,7 @@ function initSecretsPage() {
rotateButton.textContent = tKey('secrets.action.rotate');
rotateButton.setAttribute('data-testid', 'secret-rotate-action');
rotateButton.setAttribute('data-secret-id', secret.id);
rotateButton.disabled = Boolean(state.deleting[secret.id]) || state.modalSubmitting;
rotateButton.addEventListener('click', function () {
openModal('rotate', secret);
});
@@ -301,6 +318,7 @@ function initSecretsPage() {
deleteButton.textContent = tKey('secrets.action.delete');
deleteButton.setAttribute('data-testid', 'secret-delete-action');
deleteButton.setAttribute('data-secret-id', secret.id);
deleteButton.disabled = Boolean(state.deleting[secret.id]) || state.modalSubmitting;
deleteButton.addEventListener('click', async function () {
await deleteSecret(secret);
});
@@ -378,6 +396,7 @@ function initSecretsPage() {
rotateButton.className = 'btn-secondary';
rotateButton.type = 'button';
rotateButton.textContent = tKey('secrets.action.rotate');
rotateButton.disabled = Boolean(state.deleting[secret.id]) || state.modalSubmitting;
rotateButton.addEventListener('click', function() {
openModal('rotate', secret);
});
@@ -387,6 +406,7 @@ function initSecretsPage() {
deleteButton.className = 'btn-secondary';
deleteButton.type = 'button';
deleteButton.textContent = tKey('secrets.action.delete');
deleteButton.disabled = Boolean(state.deleting[secret.id]) || state.modalSubmitting;
deleteButton.addEventListener('click', async function() {
await deleteSecret(secret);
});
@@ -412,12 +432,14 @@ function initSecretsPage() {
}
async function load() {
state.workspaceId = currentWorkspaceId();
var workspaceId = currentWorkspaceId();
var generation = ++state.generation;
state.workspaceId = workspaceId;
state.loading = true;
state.error = '';
renderSecrets();
if (!state.workspaceId) {
if (!workspaceId) {
state.secrets = [];
state.profiles = [];
recomputeDerivedState();
@@ -429,49 +451,79 @@ function initSecretsPage() {
try {
var results = await Promise.all([
window.CrankApi.listSecrets(state.workspaceId),
window.CrankApi.listAuthProfiles(state.workspaceId),
window.CrankApi.listSecrets(workspaceId),
window.CrankApi.listAuthProfiles(workspaceId),
]);
if (generation !== state.generation || workspaceId !== currentWorkspaceId()) {
return;
}
state.secrets = (results[0] && results[0].items) || [];
state.profiles = (results[1] && results[1].items) || [];
recomputeDerivedState();
} catch (error) {
if (generation !== state.generation || workspaceId !== currentWorkspaceId()) {
return;
}
state.error = error.message || tKey('secrets.error.load');
state.secrets = [];
state.profiles = [];
recomputeDerivedState();
} finally {
if (generation !== state.generation || workspaceId !== currentWorkspaceId()) {
return;
}
state.loading = false;
renderSecrets();
}
}
async function deleteSecret(secret) {
if (state.deleting[secret.id]) {
return;
}
if (!confirm(tfKey('secrets.confirm.delete', { name: secret.name }))) {
return;
}
var workspaceId = state.workspaceId;
state.deleting[secret.id] = true;
renderSecrets();
try {
await window.CrankApi.deleteSecret(state.workspaceId, secret.id);
await load();
if (window.CrankUi) {
await window.CrankApi.deleteSecret(workspaceId, secret.id);
if (workspaceId === currentWorkspaceId()) {
await load();
}
if (window.CrankUi && workspaceId === currentWorkspaceId()) {
window.CrankUi.success(
tfKey('secrets.toast.delete_message', { name: secret.name }),
tKey('secrets.toast.delete_title')
);
}
} catch (error) {
if (window.CrankUi) {
if (window.CrankUi && workspaceId === currentWorkspaceId()) {
window.CrankUi.error(
error.message || tKey('secrets.toast.delete_error_message'),
tKey('secrets.toast.delete_error_title')
);
}
} finally {
delete state.deleting[secret.id];
if (workspaceId === currentWorkspaceId()) {
renderSecrets();
}
}
}
async function submitModal() {
if (state.modalSubmitting) {
return;
}
var kind = modalKind.value;
var value = buildSecretValue();
var workspaceId = state.workspaceId;
var secretId = state.modalSecretId;
var submissionGeneration = ++state.modalGeneration;
var submissionMode = state.modalMode;
state.modalSubmitting = true;
modalSubmit.disabled = true;
modalSubmit.textContent = state.modalMode === 'rotate'
? tKey('secrets.modal.rotating')
@@ -479,7 +531,16 @@ function initSecretsPage() {
try {
if (state.modalMode === 'rotate') {
await window.CrankApi.rotateSecret(state.workspaceId, state.modalSecretId, { value: value });
await window.CrankApi.rotateSecret(workspaceId, secretId, { value: value });
if (
submissionGeneration !== state.modalGeneration
|| workspaceId !== currentWorkspaceId()
|| state.modalSecretId !== secretId
|| submissionMode !== state.modalMode
|| !modal.classList.contains('open')
) {
return;
}
if (window.CrankUi) {
window.CrankUi.success(
tKey('secrets.toast.rotate_message'),
@@ -491,11 +552,19 @@ function initSecretsPage() {
if (!name) {
throw new Error(tKey('secrets.validation.name_required'));
}
await window.CrankApi.createSecret(state.workspaceId, {
await window.CrankApi.createSecret(workspaceId, {
name: name,
kind: kind,
value: value,
});
if (
submissionGeneration !== state.modalGeneration
|| workspaceId !== currentWorkspaceId()
|| submissionMode !== state.modalMode
|| !modal.classList.contains('open')
) {
return;
}
if (window.CrankUi) {
window.CrankUi.success(
tKey('secrets.toast.create_message'),
@@ -506,17 +575,20 @@ function initSecretsPage() {
closeModal();
await load();
} catch (error) {
if (window.CrankUi) {
if (window.CrankUi && submissionGeneration === state.modalGeneration) {
window.CrankUi.error(
error.message || tKey(state.modalMode === 'rotate' ? 'secrets.toast.rotate_error_message' : 'secrets.toast.create_error_message'),
tKey(state.modalMode === 'rotate' ? 'secrets.toast.rotate_error_title' : 'secrets.toast.create_error_title')
);
}
} finally {
modalSubmit.disabled = false;
modalSubmit.textContent = state.modalMode === 'rotate'
? tKey('secrets.modal.rotate_action')
: tKey('secrets.modal.create_action');
if (submissionGeneration === state.modalGeneration) {
state.modalSubmitting = false;
modalSubmit.disabled = false;
modalSubmit.textContent = state.modalMode === 'rotate'
? tKey('secrets.modal.rotate_action')
: tKey('secrets.modal.create_action');
}
}
}
@@ -539,6 +611,8 @@ function initSecretsPage() {
renderSecrets();
});
window.addEventListener('crank:workspacechange', function () {
closeModal();
state.deleting = {};
void load();
});
+14 -2
View File
@@ -209,10 +209,22 @@ function bindPasswordSave() {
document.getElementById('security-current-password').value = '';
document.getElementById('security-new-password').value = '';
document.getElementById('security-confirm-password').value = '';
setStatus('settings-password-status', tKey('settings.security.saved'), false);
setStatus('settings-password-status', tKey('settings.security.saved_relogin'), false);
button.textContent = tKey('settings.profile.saved');
setTimeout(function() {
if (window.CrankAuth && typeof window.CrankAuth.handleUnauthorized === 'function') {
window.CrankAuth.handleUnauthorized();
}
}, 600);
} catch (error) {
setStatus('settings-password-status', error.message || tKey('settings.security.save_error'), true);
var diagnostics = [];
if (error && error.requestId) diagnostics.push('Request ID: ' + error.requestId);
if (error && error.traceId) diagnostics.push('Trace ID: ' + error.traceId);
setStatus(
'settings-password-status',
tKey('settings.security.save_error') + (diagnostics.length ? ('\n' + diagnostics.join('\n')) : ''),
true
);
button.textContent = original;
} finally {
setTimeout(function() {
+110 -32
View File
@@ -11,6 +11,8 @@ document.addEventListener('DOMContentLoaded', function () {
},
loading: false,
loadError: '',
usageEpoch: 0,
exporting: false,
};
var periodSelect = document.getElementById('period');
@@ -20,6 +22,7 @@ document.addEventListener('DOMContentLoaded', function () {
var cardList = document.getElementById('usage-card-list');
var chartTemplate = document.getElementById('tmpl-chart-bar');
var rowTemplate = document.getElementById('tmpl-usage-row');
var outcomeList = document.getElementById('usage-outcome-list');
var subtitle = document.querySelector('.section-card-subtitle');
var statCards = document.querySelectorAll('.stats-grid .stat-card');
@@ -73,6 +76,12 @@ document.addEventListener('DOMContentLoaded', function () {
return 'REST';
}
function outcomeLabel(outcome) {
var key = 'usage.outcome.' + outcome;
var translated = tKey(key);
return translated === key ? outcome : translated;
}
function localizedUsageOperation(operation) {
if (!window.localizeDemoOperation) {
return operation;
@@ -439,6 +448,43 @@ document.addEventListener('DOMContentLoaded', function () {
return item;
}
function renderOutcomes() {
if (!outcomeList) {
return;
}
outcomeList.innerHTML = '';
var outcomes = state.usage ? (state.usage.outcomes || []) : [];
if (!outcomes.length) {
outcomeList.appendChild(buildEmptyState(
tKey('usage.outcomes.empty.title'),
tKey('usage.outcomes.empty.sub'),
true
));
return;
}
outcomes.forEach(function (outcome) {
var card = element('div', 'resource-card');
var header = element('div', 'resource-card-header');
var headerMain = element('div');
headerMain.appendChild(element('div', 'resource-card-title', outcomeLabel(outcome.group)));
headerMain.appendChild(element(
'div',
'resource-card-subtitle',
outcome.execution_error_code || tKey('usage.outcome.no_error_code')
));
header.appendChild(headerMain);
header.appendChild(element('span', 'badge', formatCount(outcome.calls_total)));
card.appendChild(header);
var metaGrid = element('div', 'resource-meta-grid');
metaGrid.appendChild(buildUsageMetaItem('usage.table.th.calls', formatCount(outcome.calls_total)));
metaGrid.appendChild(buildUsageMetaItem('usage.outcomes.p50', formatMs(outcome.p50_ms)));
metaGrid.appendChild(buildUsageMetaItem('usage.outcomes.p95', formatMs(outcome.p95_ms)));
metaGrid.appendChild(buildUsageMetaItem('usage.outcomes.p99', formatMs(outcome.p99_ms)));
card.appendChild(metaGrid);
outcomeList.appendChild(card);
});
}
function renderUsage() {
if (state.loading && !state.usage) {
renderEmpty(tKey('usage.loading.title'), tKey('usage.loading.sub'));
@@ -452,6 +498,7 @@ document.addEventListener('DOMContentLoaded', function () {
renderStats();
renderChart();
renderOutcomes();
renderTable();
if (subtitle) {
subtitle.textContent = tfKey('usage.table.subtitle', { period: periodLabel(state.period) });
@@ -472,24 +519,46 @@ document.addEventListener('DOMContentLoaded', function () {
return;
}
var workspaceId = state.workspaceId;
var epoch = ++state.usageEpoch;
state.loading = true;
state.loadError = '';
renderUsage();
try {
state.usage = await window.CrankApi.getUsageOverview(state.workspaceId, { period: state.period });
var usage = await window.CrankApi.getUsageOverview(workspaceId, { period: state.period });
if (epoch !== state.usageEpoch || workspaceId !== currentWorkspaceId()) {
return;
}
state.usage = usage;
recomputeDerivedState();
} catch (error) {
if (epoch !== state.usageEpoch) {
return;
}
state.loadError = error.message || tKey('usage.error.load');
state.usage = null;
recomputeDerivedState();
} finally {
state.loading = false;
renderUsage();
if (epoch === state.usageEpoch) {
state.loading = false;
renderUsage();
}
}
}
function exportCsv() {
function csvCell(value) {
var text = String(value === null || value === undefined ? '' : value);
if (/^[=+\-@\t\r\n]/.test(text)) {
text = "'" + text;
}
return '"' + text.replace(/"/g, '""') + '"';
}
async function exportCsv() {
if (!window.CrankApi || state.exporting) {
return;
}
if (!state.usage) {
if (window.CrankUi) {
window.CrankUi.info(tKey('usage.export.empty.body'), tKey('usage.export.empty.title'));
@@ -497,33 +566,39 @@ document.addEventListener('DOMContentLoaded', function () {
return;
}
var rows = [tKey('usage.csv.header')];
state.usage.operations.forEach(function (operation) {
var localizedOperation = localizedUsageOperation(operation);
var errorRate = operation.calls_total === 0
? 0
: ((operation.calls_error / operation.calls_total) * 100);
rows.push([
'"' + (localizedOperation.operation_display_name || operation.operation_display_name) + '"',
'"' + protocolLabel(operation.protocol) + '"',
operation.calls_total,
operation.calls_error,
errorRate.toFixed(2),
operation.p50_ms,
operation.p95_ms,
operation.p99_ms,
].join(','));
});
var blob = new Blob([rows.join('\r\n')], { type: 'text/csv' });
var url = URL.createObjectURL(blob);
var link = document.createElement('a');
link.href = url;
link.download = 'crank-usage-' + state.period + '.csv';
link.click();
URL.revokeObjectURL(url);
if (window.CrankUi) {
window.CrankUi.success(tKey('usage.export.done.body'), tKey('usage.export.done.title'));
var workspaceId = currentWorkspaceId();
if (!workspaceId) {
return;
}
var epoch = state.usageEpoch;
state.exporting = true;
if (exportBtn) {
exportBtn.disabled = true;
}
try {
var csv = await window.CrankApi.exportUsageCsv(workspaceId, { period: state.period });
if (epoch !== state.usageEpoch || workspaceId !== currentWorkspaceId()) {
return;
}
var blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
var url = URL.createObjectURL(blob);
var link = document.createElement('a');
link.href = url;
link.download = 'crank-usage-' + state.period + '.csv';
link.click();
URL.revokeObjectURL(url);
if (window.CrankUi) {
window.CrankUi.success(tKey('usage.export.done.body'), tKey('usage.export.done.title'));
}
} catch (error) {
if (window.CrankUi) {
window.CrankUi.error(error.message || tKey('usage.export.error.body'), tKey('usage.export.error.title'));
}
} finally {
state.exporting = false;
if (exportBtn) {
exportBtn.disabled = false;
}
}
}
@@ -539,7 +614,10 @@ document.addEventListener('DOMContentLoaded', function () {
exportBtn.addEventListener('click', exportCsv);
}
window.addEventListener('crank:workspacechange', loadUsage);
window.addEventListener('crank:workspacechange', function () {
state.usageEpoch += 1;
loadUsage();
});
if (window.whenWorkspacesReady) {
window.whenWorkspacesReady().finally(loadUsage);
+111 -10
View File
@@ -1,3 +1,25 @@
var wizardOperationLoadGeneration = 0;
var wizardLifecycleActionBusy = false;
var wizardTestConfirmationToken = null;
function operationErrorCode(error) {
return error && error.payload && error.payload.error
? error.payload.error.code
: '';
}
function showStaleOperationError(error) {
if (operationErrorCode(error) !== 'operation_stale_version'
&& operationErrorCode(error) !== 'operation_precondition_required') {
return false;
}
showWizardLiveStatus(tKey('wizard.stale.title'), tKey('wizard.stale.body'), true);
if (window.CrankUi) {
window.CrankUi.error(tKey('wizard.stale.body'), tKey('wizard.stale.title'));
}
return true;
}
function buildToolDescription() {
var snapshot = wizardCurrentVersion && wizardCurrentVersion.snapshot
? wizardCurrentVersion.snapshot
@@ -98,6 +120,7 @@ async function saveOperation(stayOnPage) {
try {
await persistCurrentDraft(stayOnPage);
} catch (error) {
if (showStaleOperationError(error)) return;
if (window.CrankUi) {
window.CrankUi.error(error.message || tKey('wizard.error.save'), tKey('wizard.error.save_title'));
}
@@ -106,14 +129,23 @@ async function saveOperation(stayOnPage) {
async function loadOperationForEdit() {
if (!wizardWorkspaceId || !wizardEditId) return;
var detail = await window.CrankApi.getOperation(wizardWorkspaceId, wizardEditId);
var generation = ++wizardOperationLoadGeneration;
var requestedWorkspaceId = wizardWorkspaceId;
var requestedOperationId = wizardEditId;
var detail = await window.CrankApi.getOperation(requestedWorkspaceId, requestedOperationId);
if (generation !== wizardOperationLoadGeneration
|| requestedWorkspaceId !== wizardWorkspaceId
|| requestedOperationId !== wizardEditId) return;
wizardProtocol = detail.protocol || 'rest';
await loadWizardPanels([3]);
var draftVersion = await window.CrankApi.getOperationVersion(
wizardWorkspaceId,
wizardEditId,
requestedWorkspaceId,
requestedOperationId,
detail.draft_version_ref.version
);
if (generation !== wizardOperationLoadGeneration
|| requestedWorkspaceId !== wizardWorkspaceId
|| requestedOperationId !== wizardEditId) return;
wizardCurrentOperation = detail;
wizardCurrentVersion = draftVersion;
prefillWizardFromEdit(detail, draftVersion);
@@ -150,6 +182,8 @@ function bindWizardLiveActions() {
bindLiveAction('wizard-run-test', tKey('wizard.busy.test'), runWizardTest);
bindLiveAction('wizard-run-quality', tKey('wizard.busy.quality'), analyzeWizardQuality);
bindClick('wizard-copy-test-response', copyTestResponseToOutputSample);
bindClick('wizard-copy-request-id', function() { copyCorrelationId('wizard-test-request-id'); });
bindClick('wizard-copy-trace-id', function() { copyCorrelationId('wizard-test-trace-id'); });
bindClick('wizard-copy-agent-preview', copyAgentFacingPreview);
bindLiveAction('wizard-export-yaml', tKey('wizard.busy.export_yaml'), exportWizardYaml);
bindLiveAction('wizard-import-yaml', tKey('wizard.busy.import_yaml'), importWizardYaml);
@@ -180,6 +214,7 @@ function bindClick(id, handler) {
function bindLiveAction(id, busyLabel, handler) {
var element = document.getElementById(id);
if (!element) return;
element.dataset.lifecycleAction = 'true';
element.addEventListener('click', function(event) {
event.preventDefault();
runWizardLiveAction(element, busyLabel, handler);
@@ -233,11 +268,17 @@ function bindApprovalPolicyControls() {
}
async function runWizardLiveAction(button, busyLabel, handler) {
if (!button || button.dataset.busy === 'true') {
if (!button || button.dataset.busy === 'true' || wizardLifecycleActionBusy) {
return;
}
var originalLabel = button.textContent;
wizardLifecycleActionBusy = true;
var lifecycleButtons = Array.from(document.querySelectorAll('[data-lifecycle-action="true"]'));
lifecycleButtons.forEach(function(element) {
element.disabled = true;
element.setAttribute('aria-busy', 'true');
});
button.dataset.busy = 'true';
button.disabled = true;
button.classList.add('is-busy');
@@ -246,6 +287,7 @@ async function runWizardLiveAction(button, busyLabel, handler) {
try {
await handler();
} catch (error) {
if (showStaleOperationError(error)) return;
showWizardLiveStatus(
tKey('wizard.live.failed_title'),
error && error.message ? error.message : tKey('wizard.live.failed_body'),
@@ -258,8 +300,12 @@ async function runWizardLiveAction(button, busyLabel, handler) {
);
}
} finally {
wizardLifecycleActionBusy = false;
lifecycleButtons.forEach(function(element) {
element.disabled = false;
element.removeAttribute('aria-busy');
});
button.dataset.busy = 'false';
button.disabled = false;
button.classList.remove('is-busy');
button.textContent = originalLabel;
}
@@ -515,6 +561,15 @@ function copyAgentFacingPreview() {
}
}
function copyCorrelationId(elementId) {
var element = document.getElementById(elementId);
var value = element ? element.textContent : '';
if (!value || !navigator.clipboard || !navigator.clipboard.writeText) return;
navigator.clipboard.writeText(value).then(function() {
showWizardLiveStatus(tKey('wizard.test.id_copied'), tKey('wizard.test.id_copied_body'));
});
}
function severityLabel(severity) {
if (severity === 'error') return tKey('wizard.quality.severity_error');
if (severity === 'warning') return tKey('wizard.quality.severity_warning');
@@ -637,7 +692,7 @@ function renderImportQualityFindings(versionDocument) {
var empty = document.getElementById('wizard-quality-empty');
if (empty) {
empty.textContent = 'Рекомендации из OpenAPI import. Запустите проверку качества, чтобы пересчитать их по текущему черновику.';
empty.textContent = tKey('wizard.quality.import_findings_hint');
empty.hidden = false;
}
}
@@ -679,7 +734,12 @@ async function importWizardYaml() {
showWizardLiveStatus(tKey('wizard.yaml.none'), tKey('wizard.yaml.none_body'), true);
return;
}
var imported = await window.CrankApi.importOperation(wizardWorkspaceId, yamlDocument, 'upsert');
var imported = await window.CrankApi.importOperation(
wizardWorkspaceId,
yamlDocument,
'upsert',
wizardEditId
);
if (Array.isArray(imported.warnings) && imported.warnings.length > 0) {
sessionStorage.setItem('crank_import_guidance', JSON.stringify(imported.warnings));
}
@@ -688,6 +748,7 @@ async function importWizardYaml() {
}
async function publishWizardOperation() {
if (!confirm(tKey('wizard.publish.confirm'))) return;
var report = await analyzeWizardQuality();
if (report.blocking) {
throw new Error(tKey('wizard.quality.blocking_error'));
@@ -703,6 +764,7 @@ async function publishWizardOperation() {
tKey('wizard.publish.done'),
tfKey('wizard.publish.done_body', { version: published.published_version })
);
if (window.CrankOnboarding) window.CrankOnboarding.signalRefresh();
}
function handleImportYamlFileSelection(event) {
@@ -724,6 +786,17 @@ function describeWizardTestResult(result) {
};
}
function localizeWizardTestErrors(errors) {
return (Array.isArray(errors) ? errors : []).map(function(error) {
if (!error || !error.code) return error;
var key = 'execution.error.' + error.code;
var localized = tKey(key);
return Object.assign({}, error, {
message: localized === key ? error.message : localized,
});
});
}
async function uploadInputSampleFromWizard() {
await persistCurrentDraft(true);
await window.CrankApi.uploadInputSample(
@@ -759,21 +832,49 @@ async function generateDraftFromWizard() {
}
async function runWizardTest() {
var generation = wizardOperationLoadGeneration;
var requestedWorkspaceId = wizardWorkspaceId;
var requestedOperationId = wizardEditId;
await persistCurrentDraft(true);
var result = await window.CrankApi.runOperationTest(wizardWorkspaceId, wizardEditId, {
if (generation !== wizardOperationLoadGeneration
|| requestedWorkspaceId !== wizardWorkspaceId
|| requestedOperationId !== wizardEditId) return;
var result = await window.CrankApi.runOperationTest(requestedWorkspaceId, requestedOperationId, {
version: currentDraftVersion(),
input: parseStructuredText(textValue('wizard-test-input')),
confirmation_token: wizardTestConfirmationToken,
locale: localStorage.getItem('crank_lang') || 'en',
});
if (generation !== wizardOperationLoadGeneration
|| requestedWorkspaceId !== wizardWorkspaceId
|| requestedOperationId !== wizardEditId) return;
var confirmationError = Array.isArray(result.errors)
? result.errors.find(function(error) { return error && error.code === 'confirmation_required'; })
: null;
wizardTestConfirmationToken = confirmationError && confirmationError.context
? confirmationError.context.confirmation_token || null
: null;
wizardTestResponsePreview = result.response_preview;
setTextareaValue('wizard-test-request-preview', result.request_preview);
setTextareaValue('wizard-test-response-preview', result.response_preview);
setTextareaValue('wizard-test-errors', result.errors && result.errors.length ? result.errors : []);
setTextareaValue('wizard-test-errors', localizeWizardTestErrors(result.errors));
var status = describeWizardTestResult(result);
var requestId = document.getElementById('wizard-test-request-id');
var traceId = document.getElementById('wizard-test-trace-id');
var correlationRoot = document.getElementById('wizard-test-correlation');
if (requestId) requestId.textContent = result.request_id || '';
if (traceId) traceId.textContent = result.trace_id || '';
if (correlationRoot) correlationRoot.hidden = !(result.request_id && result.trace_id);
var correlation = tfKey('wizard.test.correlation', {
requestId: result.request_id || '—',
traceId: result.trace_id || '—'
});
showWizardLiveStatus(
status.title,
status.body,
status.body + '\n' + correlation,
status.isError
);
if (window.CrankOnboarding) window.CrankOnboarding.signalRefresh();
}
function copyTestResponseToOutputSample() {
-2
View File
@@ -213,8 +213,6 @@ function parseExecutionConfig(text) {
retry_policy: retry && retry.max_attempts ? { max_attempts: Number(retry.max_attempts) } : null,
auth_profile_ref: authProfileRef,
headers: headers,
protocol_options: null,
streaming: null,
};
return config;
+13 -6
View File
@@ -35,6 +35,9 @@ var selectedUpstreamId = window.selectedUpstreamId;
var editingUpstreamId = window.editingUpstreamId;
async function initWizardPage() {
var initialParams = new URLSearchParams(window.location.search);
var returnContext = initialParams.get('return') || '';
if (!/^\/[A-Za-z0-9/_?&=.%~-]{0,512}$/.test(returnContext)) returnContext = '';
renderSidebarBrand('create');
document.querySelector('.btn-continue').addEventListener('click', function() {
currentStep = window.currentStep || 1;
@@ -57,14 +60,14 @@ async function initWizardPage() {
var backToCatalog = document.getElementById('back-to-catalog');
if (backToCatalog) {
backToCatalog.addEventListener('click', function() {
window.location.href = (window.CrankRoutes && window.CrankRoutes.home) || '/';
window.location.href = returnContext || ((window.CrankRoutes && window.CrankRoutes.home) || '/');
});
}
var closeBtn = document.querySelector('.progress-close');
if (closeBtn) {
closeBtn.addEventListener('click', function() {
window.location.href = (window.CrankRoutes && window.CrankRoutes.home) || '/';
window.location.href = returnContext || ((window.CrankRoutes && window.CrankRoutes.home) || '/');
});
}
@@ -132,10 +135,11 @@ async function initWizardPage() {
});
}
var params = new URLSearchParams(window.location.search);
if (params.get('mode') === 'edit' && params.get('operationId')) {
var params = initialParams;
var requestedOperationId = params.get('operationId') || '';
if (params.get('mode') === 'edit' && /^[A-Za-z0-9_-]{1,128}$/.test(requestedOperationId)) {
wizardMode = 'edit';
wizardEditId = params.get('operationId');
wizardEditId = requestedOperationId;
window.wizardMode = wizardMode;
window.wizardEditId = wizardEditId;
document.title = 'Crank — ' + tKey('wizard.progress.edit');
@@ -144,7 +148,10 @@ async function initWizardPage() {
}
updateWizardProtocolVisibility();
_doGoToStep(1);
var requestedStep = Number(params.get('step') || 1);
_doGoToStep(Number.isInteger(requestedStep) && requestedStep >= 1 && requestedStep <= TOTAL_STEPS ? requestedStep : 1);
window.CrankWizardReady = true;
document.dispatchEvent(new CustomEvent('crank:wizard-ready'));
}
function renderEditionCapabilityHints(capabilities) {
+1
View File
@@ -29,6 +29,7 @@ const BUNDLES = {
'js/api.js',
'js/ui-feedback.js',
'js/auth.js',
'js/onboarding.js',
],
footer: 'window.CrankAuth.guardProtectedPage();\n',
},
@@ -0,0 +1,43 @@
const http = require('http');
const PORT = Number(process.env.CRANK_E2E_STREAM_FIXTURE_PORT || 3310);
function send(response, status, body) {
const payload = JSON.stringify(body);
response.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(payload),
'Cache-Control': 'no-store',
});
response.end(payload);
}
const server = http.createServer((request, response) => {
const url = new URL(request.url, `http://127.0.0.1:${PORT}`);
if (request.method !== 'GET') {
send(response, 405, { error: 'method_not_allowed' });
return;
}
if (url.pathname === '/health') {
send(response, 200, { status: 'ok' });
return;
}
if (url.pathname === '/rates') {
send(response, 200, {
amount: 1,
base: url.searchParams.get('base') || 'USD',
date: '2026-08-24',
rates: { EUR: 0.91 },
});
return;
}
if (url.pathname === '/upstream-error') {
send(response, 503, { error: 'fixture_upstream_unavailable' });
return;
}
send(response, 404, { error: 'fixture_not_found' });
});
server.listen(PORT, '127.0.0.1', () => {
console.log(`Playwright HTTP fixture listening on http://127.0.0.1:${PORT}`);
});
+45 -6
View File
@@ -59,12 +59,17 @@ cleanup() {
kill "$(cat "$TMP_DIR/ui-server.pid")" >/dev/null 2>&1 || true
rm -f "$TMP_DIR/ui-server.pid"
fi
if [[ -f "$TMP_DIR/http-fixture.pid" ]]; then
kill "$(cat "$TMP_DIR/http-fixture.pid")" >/dev/null 2>&1 || true
rm -f "$TMP_DIR/http-fixture.pid"
fi
if [[ "$USE_EXTERNAL_POSTGRES" != "1" ]]; then
docker rm -f "$POSTGRES_CONTAINER" >/dev/null 2>&1 || true
fi
kill_port_processes "$UI_PORT"
kill_port_processes "$ADMIN_PORT"
kill_port_processes "$MCP_PORT"
kill_port_processes "$STREAM_FIXTURE_PORT"
}
trap cleanup EXIT
@@ -118,27 +123,62 @@ export CRANK_STORAGE_ROOT="$TMP_DIR/storage"
export CRANK_ADMIN_BIND="0.0.0.0:$ADMIN_PORT"
export CRANK_MCP_BIND="0.0.0.0:$MCP_PORT"
export CRANK_MCP_REFRESH_MS="1000"
export CRANK_OUTBOUND_ALLOWED_HOSTS="127.0.0.1"
export CRANK_LOG_LEVEL="info"
export CRANK_SESSION_SECRET="e2e-session-secret"
export CRANK_PASSWORD_PEPPER="e2e-password-pepper"
export CRANK_MASTER_KEY="0000000000000000000000000000000000000000000000000000000000000000"
export CRANK_SESSION_TTL_HOURS="24"
export CRANK_BOOTSTRAP_ADMIN_EMAIL="$ADMIN_EMAIL"
export CRANK_BOOTSTRAP_ADMIN_PASSWORD="$ADMIN_PASSWORD"
export CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME="Crank E2E"
export CRANK_DEMO_SEED="true"
export CRANK_BASE_URL="http://127.0.0.1:$UI_PORT"
mkdir -p "$CRANK_STORAGE_ROOT"
(
cd "$ROOT_DIR/apps/ui"
exec node scripts/playwright-http-fixture.js >"$LOG_DIR/http-fixture.log" 2>&1
) &
echo $! > "$TMP_DIR/http-fixture.pid"
until curl -fsS "http://127.0.0.1:$STREAM_FIXTURE_PORT/health" >/dev/null 2>&1; do
sleep 1
done
(
cd "$ROOT_DIR"
cargo run -p admin-api --bin crank-migrate -- apply >"$LOG_DIR/migrate.log" 2>&1
)
BOOTSTRAP_JSON="$(
cd "$ROOT_DIR"
cargo run -p admin-api --bin crank-migrate -- admin-auth bootstrap-create \
--email "$ADMIN_EMAIL" \
--display-name "Crank E2E" \
>"$LOG_DIR/bootstrap-create.log" 2>&1
tail -n 1 "$LOG_DIR/bootstrap-create.log"
)"
BOOTSTRAP_TOKEN="$("$PYTHON_BIN" - <<'PY' "$BOOTSTRAP_JSON"
import json, sys
print(json.loads(sys.argv[1])["bootstrap_token"])
PY
)"
printf '%s' "$BOOTSTRAP_TOKEN" >"$TMP_DIR/bootstrap-token.txt"
printf '%s' "$ADMIN_PASSWORD" >"$TMP_DIR/admin-password.txt"
printf '%s' "$CRANK_PASSWORD_PEPPER" >"$TMP_DIR/password-pepper.txt"
(
cd "$ROOT_DIR"
exec env -u CRANK_MCP_BIND -u CRANK_MCP_REFRESH_MS \
cargo run -p admin-api --bin admin-api >"$LOG_DIR/admin-api.log" 2>&1
cargo run -p admin-api --bin crank-migrate -- admin-auth bootstrap-complete \
--token-file "$TMP_DIR/bootstrap-token.txt" \
--password-file "$TMP_DIR/admin-password.txt" \
--password-pepper-file "$TMP_DIR/password-pepper.txt" \
>"$LOG_DIR/bootstrap-complete.log" 2>&1
)
(
cd "$ROOT_DIR"
exec env -u CRANK_MCP_BIND -u CRANK_MCP_REFRESH_MS CRANK_DEMO_SEED=true \
cargo run -p admin-api --bin admin-api >>"$LOG_DIR/admin-api.log" 2>&1
) &
echo $! > "$TMP_DIR/admin-api.pid"
@@ -150,8 +190,7 @@ done
cd "$ROOT_DIR"
exec env -u CRANK_ADMIN_BIND -u CRANK_STORAGE_ROOT -u CRANK_SESSION_SECRET \
-u CRANK_PASSWORD_PEPPER -u CRANK_SESSION_TTL_HOURS \
-u CRANK_BOOTSTRAP_ADMIN_EMAIL -u CRANK_BOOTSTRAP_ADMIN_PASSWORD \
-u CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME -u CRANK_DEMO_SEED \
-u CRANK_BOOTSTRAP_ADMIN_EMAIL -u CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME -u CRANK_DEMO_SEED \
cargo run -p mcp-server >"$LOG_DIR/mcp-server.log" 2>&1
) &
echo $! > "$TMP_DIR/mcp-server.pid"
+39 -7
View File
@@ -7,7 +7,9 @@ const { URL } = require('url');
const ROOT_DIR = path.resolve(__dirname, '..', 'dist');
const UI_PORT = Number(process.env.CRANK_E2E_UI_PORT || 3300);
const ADMIN_PORT = Number(process.env.CRANK_E2E_ADMIN_PORT || 3301);
const MCP_PORT = Number(process.env.CRANK_E2E_MCP_PORT || 3302);
const ADMIN_BASE = new URL(`http://127.0.0.1:${ADMIN_PORT}`);
const MCP_BASE = new URL(`http://127.0.0.1:${MCP_PORT}`);
const MIME_TYPES = {
'.css': 'text/css; charset=utf-8',
@@ -91,6 +93,9 @@ function serveFile(filePath, response) {
}
fs.stat(normalized, (error, stats) => {
if (response.destroyed || response.writableEnded) {
return;
}
if (error || !stats.isFile()) {
sendNotFound(response);
return;
@@ -103,19 +108,32 @@ function serveFile(filePath, response) {
'Cache-Control': 'no-store',
});
pipeline(fs.createReadStream(normalized), response, () => {});
if (response.destroyed || response.writableEnded) {
return;
}
var fileStream = fs.createReadStream(normalized);
fileStream.on('error', function() {
if (!response.destroyed && !response.writableEnded) response.destroy();
});
response.on('close', function() {
if (!fileStream.destroyed) fileStream.destroy();
});
try {
fileStream.pipe(response);
} catch (_error) {
fileStream.destroy();
}
});
}
function proxyRequest(request, response) {
const target = new URL(request.url, ADMIN_BASE);
function proxyRequest(request, response, base, targetPath) {
const target = new URL(targetPath || request.url, base);
const proxy = http.request(
target,
{
method: request.method,
headers: {
...request.headers,
host: `127.0.0.1:${ADMIN_PORT}`,
},
},
(proxyResponse) => {
@@ -130,16 +148,25 @@ function proxyRequest(request, response) {
response.on('close', function() {
if (!proxyResponse.destroyed) proxyResponse.destroy();
});
proxyResponse.pipe(response);
try {
proxyResponse.pipe(response);
} catch (_error) {
proxyResponse.destroy();
}
},
);
proxy.on('error', () => {
if (response.destroyed || response.writableEnded) return;
response.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' });
response.end('Bad Gateway');
});
pipeline(request, proxy, () => {});
try {
pipeline(request, proxy, () => {});
} catch (_error) {
proxy.destroy();
}
}
const server = http.createServer((request, response) => {
@@ -189,7 +216,12 @@ const server = http.createServer((request, response) => {
}
if (urlPath.startsWith('/api/auth/') || urlPath.startsWith('/api/admin/')) {
proxyRequest(request, response);
proxyRequest(request, response, ADMIN_BASE);
return;
}
if (urlPath.startsWith('/mcp/')) {
proxyRequest(request, response, MCP_BASE, request.url.slice('/mcp'.length));
return;
}
+42 -1
View File
@@ -11,6 +11,9 @@ test('agents page shows demo cards and edit drawer opens', async ({ page }) => {
await expect(page.locator('.agent-card-date').first()).toContainText(
localized('Set this endpoint', 'Данный эндпоинт')
);
await expect(page.locator('.agent-card-date').filter({
hasText: localized('Catalog rev', 'Ревизия каталога'),
}).first()).toBeVisible();
await page.getByRole('button', { name: localized('New agent', 'Новый агент') }).click();
await expect(page.locator('.drawer-title')).toHaveText(localized('New agent', 'Новый агент'));
await expect(page.locator('.drawer-subtitle')).toContainText(
@@ -40,7 +43,45 @@ test('agent drawer configures on-demand tool discovery and catalog sections', as
await expect(group.locator('input').nth(1)).toHaveValue('finance');
await expect(page.locator('.tool-search-preview')).toBeVisible();
await page.locator('.tool-group-chip').first().click();
await page.locator('.tool-search-preview input').fill('currency rate');
await page.locator('.tool-search-preview input').fill('frankfurter_latest_rate');
await page.locator('.tool-search-preview .btn-primary-sm').click();
await expect(page.locator('.tool-search-result').first()).toBeVisible();
});
test('agent lifecycle stale conflict shows localized recovery message', async ({ page }) => {
await login(page);
await page.goto('/agents');
await expect(page.locator('.agent-card').first()).toBeVisible();
await page.route(/\/api\/admin\/workspaces\/[^/]+\/agents\/[^/]+\/unpublish$/, async (route) => {
await route.fulfill({
status: 409,
contentType: 'application/json',
headers: {
'x-request-id': '01a00000-0000-7000-8000-000000000001',
'x-trace-id': '01a00000000070008000000000000001',
},
body: JSON.stringify({
error: {
code: 'conflict',
message: 'agent changed',
context: {
error_code: 'agent_stale_revision',
},
},
}),
});
});
page.once('dialog', async (dialog) => {
expect(dialog.message()).toMatch(localized('Unpublish', 'Снять'));
await dialog.accept();
});
await page.getByRole('button', { name: localized('Unpublish', 'Снять с публикации') }).first().click();
await expect(page.locator('.toast-error')).toContainText(
localized(
'Agent changed in another request. Reload the drawer and retry.',
'Агент изменился в другом запросе. Перезагрузите форму и повторите действие.',
),
);
});
+262 -1
View File
@@ -1,7 +1,8 @@
const { test, expect } = require('@playwright/test');
const { createAgent, getCurrentWorkspace, login, localized, uniqueName } = require('./helpers');
const { browserJson, createAgent, getCurrentWorkspace, login, localized, uniqueName } = require('./helpers');
test('api keys page opens create key flow', async ({ page }) => {
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await login(page);
const workspace = await getCurrentWorkspace(page);
await createAgent(page, workspace.id, {
@@ -23,10 +24,21 @@ test('api keys page opens create key flow', async ({ page }) => {
await page.locator('#btn-create-key').click();
await expect(page.locator('#modal-create')).toHaveClass(/open/);
await expect(page.locator('.modal-title')).toHaveText(localized('Create MCP client key', 'Создать ключ MCP-клиента'));
await expect(page.locator('[data-scope="read"]')).toBeChecked();
await expect(page.locator('[data-scope="write"]')).toBeChecked();
await page.locator('#new-key-name').fill(`playwright-${Date.now()}`);
await page.locator('#modal-confirm-btn').click();
await expect(page.locator('#modal-reveal-body')).toContainText(localized('Copy this key now', 'Скопируйте этот ключ сейчас'));
await expect(page.locator('#reveal-key-value')).toContainText('crk_');
const config = page.getByTestId('onboarding-connection-config');
await expect(config).toBeVisible();
await config.getByRole('button', { name: localized('Copy configuration', 'Скопировать конфигурацию') }).first().click();
await expect(page.locator('#reveal-key-value')).toHaveText('');
await expect(config).toBeHidden();
await expect(page.locator('#onboarding-connection-clients')).toBeEmpty();
await page.locator('#modal-done-btn').click();
await expect(page.locator('#modal-create')).not.toHaveClass(/open/);
await expect(page.locator('#reveal-key-value')).toHaveText('');
await page.locator('#key-kind-approval').click();
await expect(page.locator('#key-kind-hint')).toContainText(
@@ -43,4 +55,253 @@ test('api keys page opens create key flow', async ({ page }) => {
await page.locator('#new-key-name').fill(`playwright-approval-${Date.now()}`);
await page.locator('#modal-confirm-btn').click();
await expect(page.locator('#reveal-key-value')).toContainText('crk_appr_');
await expect(page.locator('#onboarding-connection-config')).toBeHidden();
const approvalRawKey = await page.locator('#reveal-key-value').textContent();
await page.goto('/agents');
await page.goBack();
await expect(page.locator('#reveal-key-value')).not.toContainText(approvalRawKey || 'crk_appr_');
});
test('failed clipboard write preserves one-time key material', async ({ page }) => { // community-scope: allow=one-time-token
await login(page);
const workspace = await getCurrentWorkspace(page);
await createAgent(page, workspace.id, {
slug: uniqueName('playwright_clipboard_agent'),
display_name: 'Playwright Clipboard Agent',
description: 'Agent for clipboard failure recovery.',
instructions: {},
tool_selection_policy: {},
});
await page.addInitScript(() => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: () => Promise.reject(new Error('clipboard denied')) },
});
});
await page.goto('/api-keys');
await page.locator('#btn-create-key').click();
await page.locator('#new-key-name').fill(uniqueName('clipboard_failure'));
await page.locator('#modal-confirm-btn').click();
await expect(page.locator('#reveal-key-value')).toHaveText(/^crk_/);
const rawKey = await page.locator('#reveal-key-value').textContent();
expect(rawKey).toMatch(/^crk_/);
await page.locator('#copy-key-btn').click();
await expect(page.locator('#reveal-key-value')).toHaveText(rawKey);
await expect(page.locator('#modal-reveal-body')).toContainText(
localized('Copy this key now', 'Скопируйте ключ сейчас')
);
const config = page.getByTestId('onboarding-connection-config');
await expect(config).toBeVisible();
await config.getByRole('button', { name: localized('Copy configuration', 'Скопировать конфигурацию') }).first().click();
await expect(page.locator('#reveal-key-value')).toHaveText(rawKey);
await expect(config).toBeVisible();
await expect(page.locator('#onboarding-connection-clients')).not.toBeEmpty();
});
test('ambiguous create failure reconciles metadata before deliberate retry', async ({ page }) => {
await login(page);
const workspace = await getCurrentWorkspace(page);
await createAgent(page, workspace.id, {
slug: uniqueName('playwright_ambiguous_agent'),
display_name: 'Playwright Ambiguous Agent',
description: 'Agent for ambiguous key creation recovery.',
instructions: {},
tool_selection_policy: {},
});
let failCreate = true;
await page.route(/\/api\/admin\/workspaces\/[^/]+\/agents\/[^/]+\/platform-api-keys$/, async (route) => {
if (route.request().method() === 'POST' && failCreate) {
failCreate = false;
await route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'key_create_outcome_unknown', message: 'unknown' } }),
});
return;
}
await route.continue();
});
await page.goto('/api-keys');
await page.locator('#btn-create-key').click();
await page.locator('#new-key-name').fill(uniqueName('ambiguous_key'));
await page.locator('#modal-confirm-btn').click();
await expect(page.getByTestId('ambiguous-create-warning')).toBeVisible();
await expect(page.locator('#modal-confirm-btn')).toBeDisabled();
await page.locator('#ambiguous-create-retry-btn').click();
await expect(page.getByTestId('ambiguous-create-warning')).toBeHidden();
await expect(page.locator('#modal-confirm-btn')).toBeEnabled();
});
test('stale create response reconciles metadata without changing a newer modal generation', async ({ page }) => {
await login(page);
const workspace = await getCurrentWorkspace(page);
const agent = await createAgent(page, workspace.id, {
slug: uniqueName('playwright_keys_stale_agent'),
display_name: 'Playwright Keys Stale Agent',
description: 'Agent for API key stale response test.',
instructions: {},
tool_selection_policy: {},
});
let releaseFirstCreate;
let releaseSecondCreate;
const firstCreateReleased = new Promise((resolve) => {
releaseFirstCreate = resolve;
});
const secondCreateReleased = new Promise((resolve) => {
releaseSecondCreate = resolve;
});
let createCount = 0;
await page.route(/\/api\/admin\/workspaces\/[^/]+\/agents\/[^/]+\/platform-api-keys$/, async (route) => {
if (route.request().method() !== 'POST') {
await route.continue();
return;
}
const requestNumber = ++createCount;
await (requestNumber === 1 ? firstCreateReleased : secondCreateReleased);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
secret: requestNumber === 1
? 'crk_ui_stale_canary_should_not_render'
: 'crk_ui_current_canary_may_render',
api_key: {
api_key: {
id: requestNumber === 1 ? 'pk_ui_stale' : 'pk_ui_current',
workspace_id: workspace.id,
agent_id: agent.agent_id,
key_kind: 'mcp_client',
name: requestNumber === 1 ? 'ui-stale-key' : 'ui-current-key',
prefix: requestNumber === 1 ? 'crk_ui_stale' : 'crk_ui_current',
scopes: ['read'],
status: 'active',
created_at: '2026-08-15T00:00:00Z',
last_used_at: null,
expires_at: null,
allowed_origins: [],
},
},
}),
});
});
await page.goto('/api-keys');
await page.locator('#btn-create-key').click();
await page.locator('#new-key-name').fill(uniqueName('ui_stale_key'));
await page.locator('#modal-confirm-btn').click();
await expect(page.locator('#modal-confirm-btn')).toBeDisabled();
await page.locator('#modal-cancel-btn').click();
await expect(page.locator('#modal-create')).not.toHaveClass(/open/);
await page.locator('#btn-create-key').click();
await page.locator('#new-key-name').fill(uniqueName('ui_current_key'));
await page.locator('#modal-confirm-btn').click();
const confirm = page.locator('#modal-confirm-btn');
await expect(confirm).toBeDisabled();
await expect(confirm).toContainText(localized('Creating', 'Создание'));
releaseFirstCreate();
await expect(page.getByTestId('onboarding-key-lost')).toBeVisible();
await expect(confirm).toBeDisabled();
await expect(confirm).toContainText(localized('Creating', 'Создание'));
releaseSecondCreate();
await expect(page.locator('#modal-reveal-body')).toBeVisible();
await expect(page.locator('#reveal-key-value')).not.toContainText('crk_ui_stale_canary_should_not_render');
await expect(page.locator('#reveal-key-value')).toContainText('crk_ui_current_canary_may_render');
});
test('duplicate key revoke and delete clicks send one request while the first request is pending', async ({ page }) => {
await page.addInitScript(() => {
window.confirm = () => true;
});
await login(page);
const workspace = await getCurrentWorkspace(page);
const agent = await createAgent(page, workspace.id, {
slug: uniqueName('playwright_key_mutation_agent'),
display_name: 'Playwright Key Mutation Agent',
description: 'Agent for duplicate key mutation guards.',
instructions: {},
tool_selection_policy: {},
});
const revokeName = uniqueName('revoke_once');
const deleteName = uniqueName('delete_once');
await browserJson(
page,
'POST',
`/api/admin/workspaces/${encodeURIComponent(workspace.id)}/agents/${encodeURIComponent(agent.agent_id)}/platform-api-keys`,
{ name: revokeName, key_kind: 'mcp_client', scopes: ['read'] },
);
const revokedKey = await browserJson(
page,
'POST',
`/api/admin/workspaces/${encodeURIComponent(workspace.id)}/agents/${encodeURIComponent(agent.agent_id)}/platform-api-keys`,
{ name: deleteName, key_kind: 'mcp_client', scopes: ['read'] },
);
const revokedKeyId = revokedKey.api_key.api_key.id;
await browserJson(
page,
'POST',
`/api/admin/workspaces/${encodeURIComponent(workspace.id)}/agents/${encodeURIComponent(agent.agent_id)}/platform-api-keys/${encodeURIComponent(revokedKeyId)}/revoke`,
{},
[204],
);
let releaseRevoke;
const revokeReleased = new Promise((resolve) => {
releaseRevoke = resolve;
});
let revokeRequests = 0;
let releaseDelete;
const deleteReleased = new Promise((resolve) => {
releaseDelete = resolve;
});
let deleteRequests = 0;
await page.route(/\/api\/admin\/workspaces\/[^/]+\/agents\/[^/]+\/platform-api-keys\/[^/]+(?:\/revoke)?$/, async (route) => {
const method = route.request().method();
if (method === 'POST' && /\/revoke$/.test(route.request().url())) {
revokeRequests += 1;
await revokeReleased;
await route.fulfill({ status: 204 });
return;
}
if (method === 'DELETE') {
deleteRequests += 1;
await deleteReleased;
await route.fulfill({ status: 204 });
return;
}
await route.continue();
});
await page.goto('/api-keys');
await page.locator('#agent-select').selectOption(agent.agent_id);
await expect(page.locator('#keys-tbody')).toContainText(revokeName);
await expect(page.locator('#keys-tbody')).toContainText(deleteName);
await page.evaluate((name) => {
const row = Array.from(document.querySelectorAll('#keys-tbody tr'))
.find((candidate) => candidate.textContent.includes(name));
const button = row.querySelector('[title="Revoke key"]');
button.click();
button.click();
}, revokeName);
await expect.poll(() => revokeRequests).toBe(1);
releaseRevoke();
await expect(page.locator('#keys-tbody')).toContainText(revokeName);
const deleteRow = page.locator('#keys-tbody tr').filter({ hasText: deleteName }).first();
const deleteButton = deleteRow.locator('[title="Delete"]');
await expect(deleteRow).toBeVisible();
await expect(deleteButton).toBeEnabled();
await deleteButton.evaluate((button) => {
button.click();
button.click();
});
await expect.poll(() => deleteRequests).toBe(1);
releaseDelete();
await expect(page.locator('#keys-tbody')).toContainText(deleteName);
});
+90
View File
@@ -0,0 +1,90 @@
const { test, expect } = require('@playwright/test');
const { login, localized } = require('./helpers');
test('logs approval panel renders safe pending approval metadata', async ({ page }) => {
await login(page);
let approveCalled = false;
await page.route(/\/api\/admin\/workspaces\/[^/]+\/approvals(?:\?.*)?$/, async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [
{
approval: {
id: 'approval_ui_safe_summary',
agent_id: 'agent_sales',
operation_id: 'op_charge_customer',
operation_version: 3,
status: 'pending',
risk_level: 'dangerous',
request_id: 'req_approval_ui',
trace_id: '0af7651916cd43dd8448eb211c80319c',
request_payload: {
email: 'customer@example.com',
api_key: '[REDACTED]',
},
response_payload: null,
created_at: '2026-08-22T10:00:00Z',
expires_at: '2026-08-22T10:05:00Z',
decided_at: null,
decision_note: null,
},
},
],
}),
});
});
await page.route(/\/api\/admin\/workspaces\/[^/]+\/approvals\/approval_ui_safe_summary\/approve$/, async (route) => {
approveCalled = true;
const body = JSON.parse(route.request().postData() || '{}');
expect(body).toEqual({ approve: 'yes' });
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
approval: {
id: 'approval_ui_safe_summary',
agent_id: 'agent_sales',
operation_id: 'op_charge_customer',
operation_version: 3,
status: 'approved',
risk_level: 'dangerous',
request_id: 'req_approval_ui',
trace_id: '0af7651916cd43dd8448eb211c80319c',
request_payload: { email: 'customer@example.com', api_key: '[REDACTED]' },
response_payload: { approve: 'yes' },
created_at: '2026-08-22T10:00:00Z',
expires_at: '2026-08-22T10:05:00Z',
decided_at: '2026-08-22T10:01:00Z',
decision_note: null,
},
}),
});
});
page.on('dialog', async (dialog) => {
expect(dialog.message()).toMatch(localized(
'Approve this pending confirmation request?',
'Подтвердить эту заявку?',
));
await dialog.accept();
});
await page.goto('/logs');
await expect(page.locator('.approval-panel-title')).toHaveText(
localized('Human confirmations', 'Подтверждения человеком'),
);
await expect(page.locator('#approval-list')).toContainText('approval_ui_safe_summary');
await expect(page.locator('#approval-list')).toContainText('op_charge_customer v3');
await expect(page.locator('#approval-list')).toContainText('agent_sales');
await expect(page.locator('#approval-list')).toContainText('customer@example.com');
await expect(page.locator('#approval-list')).toContainText('[REDACTED]');
await expect(page.locator('#approval-list')).toContainText('req_approval_ui');
await expect(page.locator('#approval-list')).toContainText('0af7651916cd43dd8448eb211c80319c');
await expect(page.locator('.approval-approve')).toHaveText(localized('Approve', 'Подтвердить'));
await expect(page.locator('.approval-deny')).toHaveText(localized('Deny', 'Отклонить'));
await expect(page.locator('#approval-list')).not.toContainText('SECRET_APPROVAL_CANARY');
await page.locator('.approval-approve').click();
await expect.poll(() => approveCalled).toBe(true);
});
+19 -5
View File
@@ -28,6 +28,14 @@ async function browserJson(page, method, urlPath, body, okStatuses = [200]) {
request.headers['Content-Type'] = 'application/json';
request.body = JSON.stringify(body);
}
if (!['GET', 'HEAD', 'OPTIONS'].includes(method.toUpperCase())) {
let csrfToken = window.CrankAuth && window.CrankAuth.getCsrfToken();
if (!csrfToken && window.CrankApi && window.CrankApi.refreshSessionCsrf) {
const csrf = await window.CrankApi.refreshSessionCsrf();
csrfToken = csrf && csrf.csrf_token;
}
if (csrfToken) request.headers['X-CSRF-Token'] = csrfToken;
}
const result = await fetch(urlPath, request);
const text = await result.text();
@@ -66,11 +74,17 @@ async function getCurrentWorkspace(page) {
}
async function createAgent(page, workspaceId, payload) {
return browserJson(
page,
'POST',
`/api/admin/workspaces/${encodeURIComponent(workspaceId)}/agents`,
payload,
return page.evaluate(
async ({ workspaceId, payload }) => {
var session = await window.CrankApi.getSession();
if (!session.csrf_token) {
var csrf = await window.CrankApi.refreshSessionCsrf();
session.csrf_token = csrf.csrf_token;
}
window.CrankAuth.replaceSession(session);
return window.CrankApi.createAgent(workspaceId, payload);
},
{ workspaceId, payload },
);
}
+110
View File
@@ -1,6 +1,43 @@
const { test, expect } = require('@playwright/test');
const { ADMIN_EMAIL, ADMIN_PASSWORD, localized } = require('./helpers');
const SESSION_FIXTURE = {
user: {
id: 'user-test',
email: ADMIN_EMAIL,
display_name: 'Crank Owner',
},
memberships: [
{
role: 'owner',
workspace: {
id: 'workspace-test',
slug: 'default',
name: 'Default Workspace',
},
},
],
current_workspace_id: 'workspace-test',
csrf_token: 'csrf_test_token',
};
async function stubLoginPageSession(page, bootstrapRequired) {
await page.route('**/api/auth/session', async (route) => {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: { message: 'unauthorized' } }),
});
});
await page.route('**/api/auth/bootstrap/status', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ bootstrap_required: bootstrapRequired }),
});
});
}
test('login page rejects invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.locator('#email').fill(ADMIN_EMAIL);
@@ -10,6 +47,29 @@ test('login page rejects invalid credentials', async ({ page }) => {
});
test('login page signs in and redirects to operations', async ({ page }) => {
let loggedIn = false;
await page.route('**/api/auth/session', async (route) => {
await route.fulfill({
status: loggedIn ? 200 : 401,
contentType: 'application/json',
body: JSON.stringify(loggedIn ? SESSION_FIXTURE : { error: { message: 'unauthorized' } }),
});
});
await page.route('**/api/auth/bootstrap/status', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ bootstrap_required: false }),
});
});
await page.route('**/api/auth/login', async (route) => {
loggedIn = true;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(SESSION_FIXTURE),
});
});
await page.goto('/login');
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
@@ -17,3 +77,53 @@ test('login page signs in and redirects to operations', async ({ page }) => {
await expect(page).toHaveURL(/\/$/);
await expect(page.locator('.page-title, .page-heading').first()).toHaveText(localized('Operations', 'Операции'));
});
test('login page switches to bootstrap mode and submits one-time token', async ({ page }) => { // community-scope: allow=one-time-token
await stubLoginPageSession(page, true);
let completePayload = null;
await page.route('**/api/auth/bootstrap/complete', async (route) => {
completePayload = route.request().postDataJSON();
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(SESSION_FIXTURE),
});
});
await page.goto('/login');
await expect(page.locator('#login-form')).toHaveAttribute('data-bootstrap', 'true');
await expect(page.locator('#login-email-field')).toBeHidden();
await expect(page.locator('#login-bootstrap-token-field')).toBeVisible();
await page.locator('#bootstrap-token').fill('boot_test_one_time_token'); // community-scope: allow=one-time-token
await page.locator('#password').fill('new-admin-password');
await page.locator('.btn-signin').click();
await expect.poll(() => completePayload).toMatchObject({
token: 'boot_test_one_time_token', // community-scope: allow=one-time-token
password: 'new-admin-password',
});
});
test('CrankApi attaches csrf token to unsafe auth requests', async ({ page }) => {
await stubLoginPageSession(page, false);
let csrfHeader = null;
await page.route('**/api/auth/password', async (route) => {
csrfHeader = route.request().headers()['x-csrf-token'] || null;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true }),
});
});
await page.goto('/login');
await page.evaluate((session) => {
window.CrankAuth.replaceSession(session);
}, SESSION_FIXTURE);
await page.evaluate(() => window.CrankApi.changePassword({
current_password: 'old-password',
new_password: 'new-password',
}));
await expect.poll(() => csrfHeader).toBe('csrf_test_token');
});
+6
View File
@@ -6,11 +6,17 @@ test('logs and usage pages show seeded data', async ({ page }) => {
await page.goto('/logs');
await expect(page.locator('.page-title')).toHaveText(localized('Logs', 'Логи'));
await expect(page.locator('#status-filter')).toBeVisible();
await expect(page.locator('#export-logs-btn')).toBeVisible();
await expect(page.locator('#log-list')).toBeVisible();
await expect(page.locator('#log-list').locator('.empty-state, .log-entry, .log-row, .log-item').first()).toBeVisible();
await page.locator('#status-filter').selectOption('ok');
await expect(page.locator('#log-list').locator('.empty-state, .log-entry, .log-row, .log-item').first()).toBeVisible();
await page.goto('/usage');
await expect(page.locator('.page-title')).toHaveText(localized('Usage', 'Использование'));
await expect(page.locator('#chart-bars .chart-col')).toHaveCount(7);
await expect(page.locator('#usage-outcome-list')).toBeVisible();
await expect(page.locator('#usage-outcome-list').locator('.empty-state, .resource-card').first()).toBeVisible();
await expect(page.locator('#usage-tbody tr')).toHaveCount(1);
});
+637
View File
@@ -0,0 +1,637 @@
const { test, expect } = require('@playwright/test');
const {
browserJson,
createAgent,
getCurrentWorkspace,
login,
uniqueName,
} = require('./helpers');
function onboardingSnapshot(workspaceId, overrides = {}) {
return {
schema_version: 1,
workspace_id: workspaceId,
revision: 1,
status: 'in_progress',
dismissed: false,
eligible_since: '2026-08-23T08:00:00Z',
started_at: '2026-08-23T08:01:00Z',
completed_at: null,
steps: [
{ kind: 'operation', status: 'complete', action: 'open_operation' },
{ kind: 'test', status: 'complete', action: 'open_operation' },
{ kind: 'publish', status: 'complete', action: 'open_operation' },
{ kind: 'agent', status: 'current', action: 'create_agent' },
{ kind: 'key', status: 'pending', action: 'create_key' },
{ kind: 'connection', status: 'pending', action: 'show_connection' },
{ kind: 'first_call', status: 'pending', action: 'open_logs' },
],
operation: {
id: 'op_onboarding_contract',
published_version: 1,
last_test: {
status: 'ok',
request_id: 'req_onboarding_test',
trace_id: '0123456789abcdef0123456789abcdef',
occurred_at: '2026-08-23T08:02:00Z',
},
},
agent: null,
key: null,
connection: { status: 'pending', reason_code: 'key_required' },
first_call: null,
...overrides,
};
}
async function routeOnboarding(page, responder) {
await page.route(/\/api\/admin\/workspaces\/[^/]+\/onboarding(?:\/events)?(?:\?.*)?$/, responder);
}
async function parseMcpResponse(response) {
const text = await response.text();
const dataLine = text.split(/\r?\n/).find((line) => line.startsWith('data:'));
return JSON.parse(dataLine ? dataLine.slice(5).trim() : text);
}
async function mcpPost(request, endpoint, key, payload, sessionId) {
const headers = {
Authorization: `Bearer ${key}`,
Accept: 'application/json, text/event-stream',
'Content-Type': 'application/json',
};
if (sessionId) {
headers['MCP-Session-Id'] = sessionId;
headers['MCP-Protocol-Version'] = '2025-06-18';
}
return request.post(endpoint, { headers, data: payload });
}
test('optional Getting Started checklist is server-derived, resumable, bilingual and accessible', async ({ page }) => {
await login(page);
const workspace = await getCurrentWorkspace(page);
let snapshotRequests = 0;
const presentationEvents = [];
await routeOnboarding(page, async (route) => {
snapshotRequests += 1;
if (route.request().method() === 'POST') {
presentationEvents.push((await route.request().postDataJSON()).event);
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(onboardingSnapshot(workspace.id)),
});
});
await page.goto('/');
const trigger = page.getByTestId('onboarding-trigger');
await expect(trigger).toContainText(/Getting Started|Начало работы/i);
const checklist = page.getByTestId('onboarding-checklist');
if (!await checklist.isVisible()) await trigger.click();
await expect(checklist).toBeVisible();
await expect.poll(() => presentationEvents).toContain('started');
await expect(checklist.locator('ol > li')).toHaveCount(7);
await expect(checklist.locator('[aria-current="step"]')).toContainText(/Agent|Агент/i);
await expect(checklist.getByRole('status')).toContainText(/3\s*\/\s*7/);
await expect(checklist.getByRole('button', { name: /Create agent|Создать агента/i })).toBeFocused();
await page.getByTestId('onboarding-collapse').click();
await expect(checklist).toBeHidden();
await page.reload();
await expect(page.getByTestId('onboarding-trigger')).toBeVisible();
expect(snapshotRequests).toBeGreaterThanOrEqual(2);
await page.getByTestId('onboarding-trigger').click();
await page.getByTestId('onboarding-dismiss').click();
await expect(checklist).toBeHidden();
await expect(page.getByTestId('onboarding-trigger')).toContainText(/Resume Getting Started|Продолжить начало работы/i);
await expect.poll(() => presentationEvents).toEqual(expect.arrayContaining(['dismissed', 'abandoned']));
await page.reload();
await expect(page.getByTestId('onboarding-trigger')).toContainText(/Resume Getting Started|Продолжить начало работы/i);
await page.getByTestId('onboarding-trigger').click();
await expect(checklist).toBeVisible();
await expect(page.getByTestId('onboarding-trigger')).toContainText(/Getting Started|Начало работы/i);
await page.evaluate(() => localStorage.setItem('crank_lang', 'ru'));
await page.reload();
await expect(page.getByTestId('onboarding-trigger')).toContainText('Начало работы');
await page.getByTestId('onboarding-trigger').click();
await expect(page.getByTestId('onboarding-checklist')).toContainText(/агент/i);
await page.evaluate(() => window.setLang('en'));
await expect(page.getByTestId('onboarding-checklist').locator('.onboarding-title')).toHaveText('Getting Started');
await expect(page.getByTestId('onboarding-collapse')).toHaveAttribute('aria-label', /Collapse Getting Started/i);
await page.keyboard.press('Escape');
await expect(page.getByTestId('onboarding-checklist')).toBeHidden();
await expect(page.getByTestId('onboarding-trigger')).toBeFocused();
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.getByTestId('onboarding-trigger').click();
await expect(page.getByTestId('onboarding-checklist')).toHaveCSS('scroll-behavior', 'auto');
});
test('onboarding error exposes safe support correlation and retries without a client completion bypass', async ({ page }) => {
await login(page);
const workspace = await getCurrentWorkspace(page);
let fail = false;
let calls = 0;
await routeOnboarding(page, async (route) => {
calls += 1;
if (fail) {
await route.fulfill({
status: 503,
contentType: 'application/json',
headers: {
'x-request-id': 'req_onboarding_retry',
'x-trace-id': '0123456789abcdef0123456789abcdef',
},
body: JSON.stringify({ error: { code: 'onboarding_unavailable', message: 'unavailable' } }),
});
return;
}
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(onboardingSnapshot(workspace.id, { revision: 2 })) });
});
await page.goto('/');
await page.getByTestId('onboarding-trigger').click();
fail = true;
await page.getByTestId('onboarding-refresh').click();
const alert = page.getByRole('alert');
await expect(alert).toContainText('onboarding_unavailable');
await expect(alert).toContainText('req_onboarding_retry');
await expect(alert).toContainText('0123456789abcdef0123456789abcdef');
await expect(page.getByTestId('onboarding-retry')).toBeFocused();
fail = false;
await page.getByTestId('onboarding-retry').click();
await expect(page.getByTestId('onboarding-checklist').locator('ol > li')).toHaveCount(7);
expect(calls).toBeGreaterThanOrEqual(2);
});
test('validation, upstream and compatibility errors recover from the last authoritative onboarding step', async ({ page }) => {
await login(page);
const workspace = await getCurrentWorkspace(page);
const failures = [
{ status: 422, code: 'onboarding_validation_failed' },
{ status: 502, code: 'onboarding_upstream_unavailable' },
{ status: 426, code: 'onboarding_client_incompatible' },
];
let failureIndex = -1;
await routeOnboarding(page, async (route) => {
if (failureIndex >= 0) {
const failure = failures[failureIndex];
await route.fulfill({
status: failure.status,
contentType: 'application/json',
headers: {
'x-request-id': `req_onboarding_${failure.status}`,
'x-trace-id': '0123456789abcdef0123456789abcdef',
},
body: JSON.stringify({ error: { code: failure.code, message: 'safe failure' } }),
});
failureIndex = -1;
return;
}
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(onboardingSnapshot(workspace.id, { revision: 3 })) });
});
await page.goto('/');
await page.getByTestId('onboarding-trigger').click();
for (let index = 0; index < failures.length; index += 1) {
failureIndex = index;
await page.getByTestId('onboarding-refresh').click();
const alert = page.getByRole('alert');
await expect(alert).toContainText(failures[index].code);
await expect(alert).toContainText(`req_onboarding_${failures[index].status}`);
await expect(alert).toContainText('0123456789abcdef0123456789abcdef');
await page.getByTestId('onboarding-retry').click();
await expect(page.getByTestId('onboarding-trigger')).toContainText(/3\s*\/\s*7/);
await expect(page.getByTestId('onboarding-checklist').locator('ol > li')).toHaveCount(7);
await expect(page.getByTestId('onboarding-completion')).toHaveCount(0);
}
});
test('authentication recovery returns to an incomplete authoritative onboarding projection', async ({ page }) => {
await login(page);
const workspace = await getCurrentWorkspace(page);
let rejectOnce = false;
await routeOnboarding(page, async (route) => {
if (rejectOnce) {
rejectOnce = false;
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'onboarding_auth_required', message: 'sign in again' } }),
});
return;
}
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(onboardingSnapshot(workspace.id, { revision: 4 })) });
});
await page.goto('/');
await page.getByTestId('onboarding-trigger').click();
rejectOnce = true;
await page.getByTestId('onboarding-refresh').click();
await expect(page).toHaveURL(/\/login/);
await page.context().clearCookies();
await login(page);
await expect(page.getByTestId('onboarding-trigger')).toContainText(/3\s*\/\s*7/);
await expect(page.getByTestId('onboarding-completion')).toHaveCount(0);
});
test('a BroadcastChannel refresh updates a second open tab from server progress', async ({ page, context }) => {
await login(page);
const workspace = await getCurrentWorkspace(page);
var revision = 1;
const respond = async (route) => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(onboardingSnapshot(workspace.id, {
revision,
steps: revision === 1 ? onboardingSnapshot(workspace.id).steps : onboardingSnapshot(workspace.id).steps.map((step) => ({ ...step, status: 'complete' })),
})),
});
await routeOnboarding(page, respond);
await page.goto('/');
await expect(page.getByTestId('onboarding-trigger')).toContainText(/3\s*\/\s*7/);
const other = await context.newPage();
await routeOnboarding(other, respond);
await other.goto('/');
await expect(other.locator('.page-title, .page-heading').first()).toBeVisible();
revision = 2;
await other.evaluate(() => window.CrankOnboarding.signalRefresh());
await expect(page.getByTestId('onboarding-trigger')).toContainText(/7\s*\/\s*7/);
await other.close();
});
test('bursty browser refresh signals coalesce into one request plus one trailing refresh', async ({ page }) => {
await login(page);
const workspace = await getCurrentWorkspace(page);
let requestNumber = 0;
let releaseRefresh;
const refreshReleased = new Promise((resolve) => { releaseRefresh = resolve; });
await routeOnboarding(page, async (route) => {
if (route.request().method() !== 'GET') {
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(onboardingSnapshot(workspace.id, { revision: 1 })) });
return;
}
requestNumber += 1;
if (requestNumber === 1) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(onboardingSnapshot(workspace.id, { revision: 1 })),
});
return;
}
if (requestNumber === 2) await refreshReleased;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(onboardingSnapshot(workspace.id, {
revision: 2,
steps: onboardingSnapshot(workspace.id).steps.map((step) => ({ ...step, status: 'complete' })),
status: 'complete',
completed_at: '2026-08-23T08:10:00Z',
first_call: {
log_id: 'log_newest',
tool_name: 'frankfurter_latest_rate',
request_id: 'req_newest',
trace_id: 'abcdefabcdefabcdefabcdefabcdefab',
occurred_at: '2026-08-23T08:10:00Z',
},
})),
});
});
await page.goto('/');
if (!await page.getByTestId('onboarding-checklist').isVisible()) {
await page.getByTestId('onboarding-trigger').click();
}
await page.evaluate(() => {
window.CrankOnboarding.refresh();
window.CrankOnboarding.refresh();
window.CrankOnboarding.refresh();
window.CrankOnboarding.refresh();
});
await expect.poll(() => requestNumber).toBe(2);
releaseRefresh();
await expect(page.getByTestId('onboarding-trigger')).toContainText(/7\s*\/\s*7/);
await expect(page.getByTestId('onboarding-completion')).toContainText('req_newest');
await expect(page.getByTestId('onboarding-completion')).toContainText('frankfurter_latest_rate');
await expect(page.getByTestId('onboarding-completion')).toContainText('2026-08-23T08:10:00Z');
await expect.poll(() => requestNumber).toBe(3);
});
test('stale onboarding deep link requires server reset before explicit reselection', async ({ page }) => {
await login(page);
const workspace = await getCurrentWorkspace(page);
let resetPayload = null;
await routeOnboarding(page, async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(onboardingSnapshot(workspace.id, {
revision: 17,
operation_id: 'op_current',
operation_version: 4,
agent_id: 'agent_current',
catalog_revision: 9,
})),
});
});
await page.route(/\/api\/admin\/workspaces\/[^/]+\/onboarding\/reset-selection$/, async (route) => {
resetPayload = await route.request().postDataJSON();
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(onboardingSnapshot(workspace.id, { revision: 18 })),
});
});
await page.goto('/agents?onboarding=1&action=create&operationId=op_stale&operationVersion=3');
const recovery = page.getByTestId('onboarding-deep-link-stale');
await expect(recovery).toBeVisible();
await expect(recovery).toContainText(/changed Operation|изменённой операции/i);
await page.getByTestId('onboarding-reselect').click();
await expect.poll(() => resetPayload).toEqual({ expected_revision: 17 });
await expect(page).toHaveURL('/agents');
});
test('one-time key connection config is never recoverable after the reveal is lost', async ({ page }) => { // community-scope: allow=one-time-token
await login(page);
const workspace = await getCurrentWorkspace(page);
const agent = await createAgent(page, workspace.id, {
slug: uniqueName('onboarding_lost_key'),
display_name: 'Onboarding lost key Agent',
description: 'Proves one-time onboarding key handling.', // community-scope: allow=one-time-token
instructions: {},
tool_selection_policy: { mode: 'direct', groups: [], search: { max_results: 8 } },
});
await page.goto(`/api-keys?onboarding=1&action=create&agentId=${encodeURIComponent(agent.agent_id)}`);
await page.waitForFunction(() => Boolean(window.CrankAuth && window.CrankAuth.getCsrfToken()));
await expect(page.locator('#agent-select')).toHaveValue(agent.agent_id);
await expect(page.locator('#modal-create')).toHaveClass(/open/);
await page.locator('#new-key-name').fill(uniqueName('onboarding_client'));
await page.locator('#modal-confirm-btn').click();
const revealKey = page.locator('#reveal-key-value');
await expect(revealKey).toHaveText(/^crk_/);
const rawKey = await revealKey.textContent();
await expect(page.getByTestId('onboarding-connection-config')).toContainText('/mcp/v1/');
await expect(page.getByTestId('onboarding-clipboard-warning')).toBeVisible();
const leakedBeforeNavigation = await page.evaluate((secret) => ({
local: Object.values(localStorage).some((value) => String(value).includes(secret)),
session: Object.values(sessionStorage).some((value) => String(value).includes(secret)),
url: location.href.includes(secret),
}), rawKey);
expect(leakedBeforeNavigation).toEqual({ local: false, session: false, url: false });
await page.goto('/agents');
await page.goBack();
await expect(page.locator('body')).not.toContainText(rawKey);
await expect(page.getByTestId('onboarding-key-lost')).toContainText(/create|rotate|созда|ротац/i);
await expect(page.getByTestId('onboarding-connection-config')).toBeHidden();
await expect(page.locator('#onboarding-connection-clients')).toBeEmpty();
});
test('first value is proven only by a real tools/call through the public MCP endpoint', async ({ page, request }) => {
test.setTimeout(90_000);
await login(page);
const workspace = await getCurrentWorkspace(page);
await page.waitForFunction(() => Boolean(window.CrankAuth && window.CrankAuth.getCsrfToken()));
const suffix = uniqueName('mcp').replace(/_/g, '-');
const operationName = uniqueName('onboarding_real_rest');
const agentSlug = `onboarding-real-${suffix}`.toLowerCase().replace(/[^a-z0-9-]/g, '-');
// Exercise the actual wizard controls and lifecycle buttons. This is not an
// API provisioning shortcut: every mutation below is initiated by visible UI.
await page.goto('/wizard/?onboarding=1');
await page.waitForFunction(() => window.CrankWizardReady === true && window.currentStep === 1);
await page.locator('[data-testid="wizard-protocol-rest"]').click();
await page.locator('#btn-continue').click();
await expect(page.locator('#upstream-new-trigger')).toBeVisible();
await page.locator('#upstream-new-trigger').click();
await page.locator('#new-upstream-name').fill(`onboarding-${suffix}`);
await page.locator('#new-upstream-url').fill('http://127.0.0.1:3310');
await page.locator('#new-upstream-static-headers').fill('{"Accept":"application/json"}');
await page.locator('[data-wizard-action="save-upstream"]').click();
await expect(page.locator('#upstream-preview-url')).toHaveText('http://127.0.0.1:3310');
await page.locator('#endpoint-path').fill('/rates');
await page.locator('#btn-continue').click();
await page.locator('.method-card[data-method="GET"]').click();
await page.locator('#btn-continue').click();
await page.locator('#tool-name').fill(operationName);
await page.locator('#tool-display-name').fill('Deterministic onboarding REST Operation');
await page.locator('#tool-title').fill('Deterministic onboarding REST Operation');
await page.locator('#tool-description').fill(
'A local deterministic REST target exercised through the complete onboarding UI flow.',
);
await page.locator('#tool-input-schema').fill(JSON.stringify({
type: 'object',
properties: {
base: { type: 'string', description: 'Base currency.' },
quote: { type: 'string', description: 'Quote currency.' },
},
required: ['base', 'quote'],
}));
await page.locator('#tool-output-schema').fill(JSON.stringify({
type: 'object',
properties: { base: { type: 'string', description: 'Returned base currency.' } },
required: ['base'],
}));
await page.locator('#btn-continue').click();
await page.locator('details.advanced-mapping-details').filter({ has: page.locator('#tool-input-mapping') }).locator('summary').click();
await page.locator('#tool-input-mapping').fill(JSON.stringify({
'query.base': '$.input.base',
'query.quote': '$.input.quote',
}));
await page.locator('details.advanced-mapping-details').filter({ has: page.locator('#tool-output-mapping') }).locator('summary').click();
await page.locator('#tool-output-mapping').fill(JSON.stringify({
base: '$.response.body.base',
}));
await page.evaluate(() => window.CrankWizardMapping.renderFromEditors());
await page.locator('#tool-exec-config').fill('{"timeout_ms":2000,"headers":{}}');
await page.locator('#wizard-test-input').fill('{"base":"USD","quote":"EUR"}');
const createOperationResponse = page.waitForResponse((response) => (
response.request().method() === 'POST'
&& /\/api\/admin\/workspaces\/[^/]+\/operations$/.test(response.url())
));
await page.locator('.btn-save-draft').click();
const createdOperation = await (await createOperationResponse).json();
const operationId = createdOperation.operation_id;
await expect(page).toHaveURL(new RegExp(`operationId=${operationId}`));
const testRunResponse = page.waitForResponse((response) => (
response.request().method() === 'POST'
&& response.url().includes(`/operations/${operationId}/test-runs`)
));
await page.locator('#wizard-run-test').click();
const testRun = await (await testRunResponse).json();
expect(testRun.ok).toBe(true);
await expect(page.locator('#wizard-test-request-id')).not.toHaveText('');
page.once('dialog', (dialog) => dialog.accept());
const publishOperationResponse = page.waitForResponse((response) => (
response.request().method() === 'POST'
&& response.url().includes(`/operations/${operationId}/publish`)
));
await page.locator('#wizard-publish-operation').click();
const published = await (await publishOperationResponse).json();
expect(published.published_version).toBe(1);
// Continue through the actual onboarding Agent drawer. The deep link selects
// the exact published Operation/version; the visible Create action publishes it.
await page.goto(`/agents?onboarding=1&action=create&operationId=${encodeURIComponent(operationId)}&operationVersion=1`);
await expect(page.locator('.drawer')).toHaveClass(/open/);
const identityInputs = page.locator('.drawer-section').first().locator('input.form-input');
await identityInputs.nth(0).fill('Onboarding real public MCP Agent');
await identityInputs.nth(1).fill(agentSlug);
await page.locator('.drawer-section').first().locator('textarea').fill(
'Real public MCP onboarding acceptance flow.',
);
await expect(page.locator('.ops-picker-item.selected')).toContainText(operationName);
const createAgentResponse = page.waitForResponse((response) => (
response.request().method() === 'POST'
&& /\/api\/admin\/workspaces\/[^/]+\/agents$/.test(response.url())
));
const bindAgentResponse = page.waitForResponse((response) => (
response.request().method() === 'POST' && /\/agents\/[^/]+\/bindings$/.test(response.url())
));
const publishAgentResponse = page.waitForResponse((response) => (
response.request().method() === 'POST' && /\/agents\/[^/]+\/publish$/.test(response.url())
));
await page.locator('.drawer-footer .btn-primary-sm').click();
const createdResponse = await createAgentResponse;
const bindingResponse = await bindAgentResponse;
expect(bindingResponse.ok(), await bindingResponse.text()).toBe(true);
const agentPublishResponse = await publishAgentResponse;
expect(agentPublishResponse.ok(), await agentPublishResponse.text()).toBe(true);
const createdAgent = await createdResponse.json();
const provisioned = {
agentId: createdAgent.agent_id,
operationId,
operationVersion: 1,
toolName: operationName,
};
await expect(page.locator('.drawer')).not.toHaveClass(/open/);
// The key is likewise created through the visible onboarding key flow.
await page.goto(`/api-keys?onboarding=1&action=create&agentId=${encodeURIComponent(provisioned.agentId)}`);
await expect(page.locator('#agent-select')).toHaveValue(provisioned.agentId);
await expect(page.locator('#modal-create')).toHaveClass(/open/);
await expect(page.locator('[data-scope="read"]')).toBeChecked();
await expect(page.locator('[data-scope="write"]')).toBeChecked();
await page.locator('#new-key-name').fill(uniqueName('onboarding_real_key'));
const keyResponsePromise = page.waitForResponse((response) => (
response.request().method() === 'POST'
&& /\/platform-api-keys$/.test(response.url())
));
await page.locator('#modal-confirm-btn').click();
const keyResponse = await keyResponsePromise;
expect(keyResponse.ok()).toBe(true);
const keyBody = await keyResponse.json();
provisioned.key = await page.locator('#reveal-key-value').textContent();
provisioned.keyId = keyBody.api_key.id || keyBody.api_key.api_key.id;
provisioned.endpoint = await page.locator('#onboarding-connection-clients > code').textContent();
expect(provisioned.key).toMatch(/^crk_/);
await expect(page.getByTestId('onboarding-connection-config')).toContainText(provisioned.endpoint);
expect(provisioned.endpoint).toContain('/mcp/v1/');
const initialize = await mcpPost(request, provisioned.endpoint, provisioned.key, {
jsonrpc: '2.0', id: 1, method: 'initialize', params: {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'crank-onboarding-playwright', version: '1.0.0' },
},
});
const initializeBody = await parseMcpResponse(initialize);
expect(initialize.ok(), JSON.stringify(initializeBody)).toBe(true);
expect(initializeBody.result.protocolVersion).toBe('2025-06-18');
const sessionId = initialize.headers()['mcp-session-id'];
expect(sessionId).toBeTruthy();
const initialized = await mcpPost(request, provisioned.endpoint, provisioned.key, {
jsonrpc: '2.0', method: 'notifications/initialized', params: {},
}, sessionId);
expect([200, 202]).toContain(initialized.status());
const listed = await mcpPost(request, provisioned.endpoint, provisioned.key, {
jsonrpc: '2.0', id: 2, method: 'tools/list', params: {},
}, sessionId);
expect(listed.ok()).toBe(true);
const listedBody = await parseMcpResponse(listed);
expect(listedBody.result.tools.map((tool) => tool.name)).toContain(provisioned.toolName);
const called = await mcpPost(request, provisioned.endpoint, provisioned.key, {
jsonrpc: '2.0', id: 3, method: 'tools/call', params: {
name: provisioned.toolName,
arguments: { base: 'USD', quote: 'EUR' },
},
}, sessionId);
expect(called.ok()).toBe(true);
const calledBody = await parseMcpResponse(called);
expect(calledBody.error).toBeUndefined();
expect(calledBody.result.isError).not.toBe(true);
// This is deliberately the real authoritative endpoint. The test never posts a
// browser-only "complete" flag and never calls a test-only onboarding bypass.
const progress = await browserJson(
page,
'GET',
`/api/admin/workspaces/${encodeURIComponent(workspace.id)}/onboarding`,
);
expect(progress.status).toBe('complete');
expect(progress.agent_id).toBe(provisioned.agentId);
expect(progress.platform_api_key_id).toBe(provisioned.keyId);
expect(progress.operation_id).toBe(provisioned.operationId);
expect(progress.operation_version).toBe(provisioned.operationVersion);
expect(progress.first_call.agent_id).toBe(provisioned.agentId);
expect(progress.first_call.key_id).toBe(provisioned.keyId);
expect(progress.first_call.operation_id).toBe(provisioned.operationId);
expect(progress.first_call.operation_version).toBe(provisioned.operationVersion);
expect(progress.first_call.tool_name).toBe(provisioned.toolName);
expect(progress.first_call.request_id).toBeTruthy();
expect(progress.first_call.trace_id).toMatch(/^[0-9a-f]{32}$/);
await page.goto('/');
await expect(page.getByTestId('onboarding-trigger')).toContainText(/7\s*\/\s*7/);
await page.getByTestId('onboarding-trigger').click();
const completion = page.getByTestId('onboarding-completion');
await expect(completion).toContainText(provisioned.toolName);
await expect(completion).toContainText(progress.first_call.occurred_at);
await expect(completion).toContainText(progress.first_call.request_id);
await expect(completion).toContainText(progress.first_call.trace_id);
await page.goto(`/logs?log_id=${encodeURIComponent(progress.first_call.log_id)}`);
await expect(page.locator(`[data-id="${progress.first_call.log_id}"]`)).toBeVisible();
await expect(page.locator(`[data-exp="${progress.first_call.log_id}"]`)).toContainText(progress.first_call.trace_id);
await page.waitForFunction(() => Boolean(window.CrankAuth && window.CrankAuth.getCsrfToken()));
const revokeEvidence = await page.evaluate(async ({ workspaceId, agentId, keyId }) => {
const before = await window.CrankApi.listAgentPlatformApiKeys(workspaceId, agentId);
await window.CrankApi.revokeAgentPlatformApiKey(workspaceId, agentId, keyId);
const after = await window.CrankApi.listAgentPlatformApiKeys(workspaceId, agentId);
const onboarding = await window.CrankApi.getOnboarding(workspaceId);
return {
beforeCount: before.items.length,
afterCount: after.items.length,
revoked: after.items.find((item) => (item.api_key || item).id === keyId),
onboarding,
};
}, { workspaceId: workspace.id, agentId: provisioned.agentId, keyId: provisioned.keyId });
expect(revokeEvidence.afterCount).toBe(revokeEvidence.beforeCount);
expect((revokeEvidence.revoked.api_key || revokeEvidence.revoked).status).toBe('revoked');
expect(revokeEvidence.onboarding.status).not.toBe('complete');
const keyStep = revokeEvidence.onboarding.steps.find((step) => step.id === 'key');
expect(keyStep.completed).toBe(false);
expect(keyStep.status).toBe('regressed');
});
@@ -0,0 +1,190 @@
const { test, expect } = require('@playwright/test');
const { getCurrentWorkspace, login, uniqueName } = require('./helpers');
test('Operation lifecycle keeps published versions immutable and exposes safe correlation', async ({ page }) => {
await login(page);
const workspace = await getCurrentWorkspace(page);
const name = uniqueName('immutable_lifecycle');
const evidence = await page.evaluate(async ({ workspaceId, operationName }) => {
const schema = (field, description) => ({
type: 'object',
description,
required: true,
nullable: false,
fields: {
[field]: {
type: 'string',
description: `${field} value used by the lifecycle acceptance test`,
required: true,
nullable: false,
},
},
});
const payload = {
name: operationName,
display_name: 'Immutable lifecycle acceptance Operation',
category: 'quality',
protocol: 'rest',
security_level: 'standard',
target: {
kind: 'rest',
base_url: 'https://httpbin.org',
method: 'GET',
path_template: '/anything',
static_headers: {},
},
input_schema: schema('input', 'Lifecycle input contract'),
output_schema: schema('output', 'Lifecycle output contract'),
input_mapping: {
rules: [{
source: '$.mcp.input',
target: '$.request.query.input',
required: true,
}],
},
output_mapping: {
rules: [{
source: '$.response.body.output',
target: '$.output.output',
required: true,
}],
},
execution_config: { timeout_ms: 1000, headers: {} },
tool_description: {
title: 'Immutable lifecycle acceptance Operation',
description: 'Exercises immutable Draft, Published Version, YAML, archive and correlation behavior.',
tags: ['quality', 'lifecycle'],
examples: [],
},
wizard_state: null,
};
const created = await window.CrankApi.createOperation(workspaceId, payload);
const operationId = created.operation_id;
await window.CrankApi.getOperation(workspaceId, operationId);
let testResult;
try {
testResult = await window.CrankApi.runOperationTest(workspaceId, operationId, {
version: 1,
input: { input: 'acceptance' },
});
} catch (error) {
testResult = {
tested_version: 1,
request_id: error.requestId,
trace_id: error.traceId,
rejected_safely: true,
};
}
const published = await window.CrankApi.publishOperation(workspaceId, operationId, 1);
const publishedV1 = await window.CrankApi.getOperationVersion(workspaceId, operationId, 1);
await window.CrankApi.getOperation(workspaceId, operationId);
const changedPayload = JSON.parse(JSON.stringify(payload));
changedPayload.display_name = 'Changed Draft after publication';
changedPayload.tool_description.title = 'Changed Draft after publication';
const changed = await window.CrankApi.updateOperation(workspaceId, operationId, changedPayload);
const publishedV1AfterEdit = await window.CrankApi.getOperationVersion(workspaceId, operationId, 1);
const yamlV1 = await window.CrankApi.exportOperation(workspaceId, operationId, { version: 1 });
await window.CrankApi.getOperation(workspaceId, operationId);
const imported = await window.CrankApi.importOperation(
workspaceId,
yamlV1,
'upsert',
operationId,
);
await window.CrankApi.getOperation(workspaceId, operationId);
const archived = await window.CrankApi.archiveOperation(workspaceId, operationId);
const publishedV1AfterArchive = await window.CrankApi.getOperationVersion(workspaceId, operationId, 1);
return {
operationId,
testResult,
published,
changed,
imported,
archived,
originalSnapshot: publishedV1.snapshot,
afterEditSnapshot: publishedV1AfterEdit.snapshot,
afterArchiveSnapshot: publishedV1AfterArchive.snapshot,
yamlV1,
};
}, { workspaceId: workspace.id, operationName: name });
expect(evidence.testResult.tested_version).toBe(1);
expect(evidence.testResult.request_id).toMatch(/^[!-~]{1,128}$/);
expect(evidence.testResult.trace_id).toMatch(/^[0-9a-f]{32}$/);
expect(evidence.published.published_version).toBe(1);
expect(evidence.changed.version).toBe(2);
expect(evidence.imported.version).toBe(3);
expect(evidence.archived.status).toBe('archived');
expect(evidence.afterEditSnapshot).toEqual(evidence.originalSnapshot);
expect(evidence.afterArchiveSnapshot).toEqual(evidence.originalSnapshot);
expect(evidence.yamlV1).toContain("format_version: '2'");
for (const forbidden of ['wizard_state:', 'created_at:', 'published_at:', 'operation_id:']) {
expect(evidence.yamlV1).not.toContain(forbidden);
}
});
for (const locale of ['ru', 'en']) {
test(`catalog lifecycle actions are state-aware in ${locale}`, async ({ page }) => {
await login(page);
await page.evaluate((language) => localStorage.setItem('crank_lang', language), locale);
const workspace = await getCurrentWorkspace(page);
const name = uniqueName(`catalog_lifecycle_${locale}`);
const operation = await page.evaluate(async ({ workspaceId, operationName, language }) => {
const schema = {
type: 'object',
description: 'Catalog lifecycle UI contract',
required: true,
nullable: false,
fields: {},
};
const created = await window.CrankApi.createOperation(workspaceId, {
name: operationName,
display_name: `Catalog lifecycle ${language}`,
category: 'quality',
protocol: 'rest',
security_level: 'standard',
target: { kind: 'rest', base_url: 'https://example.test', method: 'GET', path_template: '/', static_headers: {} },
input_schema: schema,
output_schema: schema,
input_mapping: { rules: [] },
output_mapping: { rules: [] },
execution_config: { timeout_ms: 1000, headers: {} },
tool_description: {
title: `Catalog lifecycle ${language}`,
description: 'A sufficiently detailed description for lifecycle UI acceptance.',
tags: ['quality'],
examples: [],
},
wizard_state: null,
});
await window.CrankApi.getOperation(workspaceId, created.operation_id);
await window.CrankApi.publishOperation(workspaceId, created.operation_id, 1);
return created;
}, { workspaceId: workspace.id, operationName: name, language: locale });
await page.goto('/');
await page.getByPlaceholder(locale === 'ru' ? 'Поиск операций' : 'Search operations').fill(name);
const row = page.locator('tbody tr').filter({ hasText: name });
await expect(row).toHaveCount(1);
await expect(row.locator('.row-btn-delete')).toBeHidden();
await expect(row.locator('.row-btn-edit')).toBeVisible();
await row.locator('.row-btn-edit').click();
await expect(page).toHaveURL(new RegExp(`/wizard/\\?mode=edit&operationId=${operation.operation_id}`));
await expect(page.locator('.wizard-shell')).toBeVisible();
await expect(page.locator('#back-to-catalog')).toBeVisible();
await page.locator('#back-to-catalog').click();
await expect(page).toHaveURL(/\/$/);
await page.getByPlaceholder(locale === 'ru' ? 'Поиск операций' : 'Search operations').fill(name);
const refreshedRow = page.locator('tbody tr').filter({ hasText: name });
page.once('dialog', (dialog) => dialog.accept());
await refreshedRow.locator('[data-testid="operation-archive"]').click();
await expect(refreshedRow).toContainText(locale === 'ru' ? 'Неактивные' : 'Inactive');
await expect(refreshedRow.locator('.row-btn-delete')).toBeHidden();
});
}
+2 -1
View File
@@ -6,7 +6,8 @@ test('operations page shows demo catalog and filter works', async ({ page }) =>
await expect(page.locator('.page-heading')).toHaveText(localized('Operations', 'Операции'));
await expect(page.locator('.ws-switcher-trigger')).toBeVisible();
await expect(page.locator('#ws-dropdown')).toHaveCount(0);
await expect(page.locator('tbody tr')).toHaveCount(1);
await expect(page.getByText(localized('Loading operations', 'Загрузка операций'))).toBeHidden();
expect(await page.locator('tbody tr').count()).toBeGreaterThanOrEqual(1);
await page.getByPlaceholder(localized('Search operations', 'Поиск операций')).fill('frankfurter');
await expect(page.locator('tbody tr')).toHaveCount(1);
await expect(page.locator('tbody tr').first()).toContainText(/frankfurter_latest_rate/i);
+131
View File
@@ -84,3 +84,134 @@ test('generic json secret modal stays inside compact viewport', async ({ page })
await expect(page.locator('[data-testid="secret-name-input"]')).toBeVisible();
await expect(page.locator('[data-testid="secret-submit-button"]')).toBeVisible();
});
test('secret modal clears sensitive values and locks duplicate submit while saving', async ({ page }) => {
await login(page);
await page.goto('/secrets');
await page.locator('[data-testid="secret-create-button"]').click();
await page.locator('[data-testid="secret-name-input"]').fill(uniqueName('ui_secret_guard'));
await page.locator('[data-testid="secret-value-input"]').fill('ui-secret-canary-close');
await page.locator('#secret-modal-cancel-btn').click();
await page.locator('[data-testid="secret-create-button"]').click();
await expect(page.locator('[data-testid="secret-name-input"]')).toHaveValue('');
await expect(page.locator('[data-testid="secret-value-input"]')).toHaveValue('');
let releaseCreate;
const createReleased = new Promise((resolve) => {
releaseCreate = resolve;
});
let createPosts = 0;
await page.route(/\/api\/admin\/workspaces\/[^/]+\/secrets$/, async (route) => {
if (route.request().method() !== 'POST') {
await route.continue();
return;
}
createPosts += 1;
await createReleased;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: 'secret_ui_guard',
workspace_id: 'ws_test',
name: 'ui_secret_guard',
kind: 'token',
status: 'active',
current_version: 1,
created_at: '2026-08-15T00:00:00Z',
updated_at: '2026-08-15T00:00:00Z',
last_used_at: null,
}),
});
});
await page.locator('[data-testid="secret-name-input"]').fill(uniqueName('ui_secret_guard'));
await page.locator('[data-testid="secret-value-input"]').fill('ui-secret-canary-submit');
const submit = page.locator('[data-testid="secret-submit-button"]');
await submit.click();
await expect(submit).toBeDisabled();
await submit.click({ force: true });
expect(createPosts).toBe(1);
releaseCreate();
await expect(page.locator('[data-testid="secret-create-modal"]')).toBeHidden();
expect(createPosts).toBe(1);
});
test('stale secret rotation cannot close or report success for a newer modal context', async ({ page }) => {
await login(page);
const workspace = await getCurrentWorkspace(page);
const firstName = uniqueName('rotate_stale_first');
const secondName = uniqueName('rotate_stale_second');
const firstSecret = await browserJson(
page,
'POST',
`/api/admin/workspaces/${encodeURIComponent(workspace.id)}/secrets`,
{ name: firstName, kind: 'token', value: 'rotate-stale-first-value' },
);
await browserJson(
page,
'POST',
`/api/admin/workspaces/${encodeURIComponent(workspace.id)}/secrets`,
{ name: secondName, kind: 'token', value: 'rotate-stale-second-value' },
);
let releaseRotate;
const rotateReleased = new Promise((resolve) => {
releaseRotate = resolve;
});
let secretListRequests = 0;
await page.route(/\/api\/admin\/workspaces\/[^/]+\/secrets(?:\/[^/]+\/rotate)?$/, async (route) => {
const request = route.request();
if (request.method() === 'GET') {
secretListRequests += 1;
await route.continue();
return;
}
if (request.method() === 'POST' && /\/rotate$/.test(request.url())) {
await rotateReleased;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: firstSecret.id,
workspace_id: workspace.id,
name: firstName,
kind: 'token',
status: 'active',
current_version: 2,
created_at: '2026-08-15T00:00:00Z',
updated_at: '2026-08-15T00:00:00Z',
last_used_at: null,
}),
});
return;
}
await route.continue();
});
await page.goto('/secrets');
const firstRow = page.locator('#secrets-tbody tr').filter({ hasText: firstName }).first();
const secondRow = page.locator('#secrets-tbody tr').filter({ hasText: secondName }).first();
await expect(firstRow).toBeVisible();
await expect(secondRow).toBeVisible();
const listRequestsBeforeRelease = secretListRequests;
await firstRow.getByTestId('secret-rotate-action').click();
await page.locator('[data-testid="secret-value-input"]').fill('rotate-stale-first-new-value');
await page.locator('[data-testid="secret-submit-button"]').click();
await expect(page.locator('[data-testid="secret-submit-button"]')).toBeDisabled();
await page.locator('#secret-modal-cancel-btn').click();
await expect(page.getByTestId('secret-rotate-modal')).toBeHidden();
await secondRow.getByTestId('secret-rotate-action').click();
await expect(page.getByTestId('secret-rotate-modal')).toBeVisible();
await expect(page.locator('[data-testid="secret-name-input"]')).toHaveValue(secondName);
releaseRotate();
await page.waitForTimeout(150);
await expect(page.getByTestId('secret-rotate-modal')).toBeVisible();
await expect(page.locator('[data-testid="secret-name-input"]')).toHaveValue(secondName);
await expect(page.locator('.toast-success')).toHaveCount(0);
expect(secretListRequests).toBe(listRequestsBeforeRelease);
});
+2 -9
View File
@@ -164,7 +164,6 @@ test('wizard builds visual request mappings from JSON sample and path params', a
await page.evaluate(() => window.CrankWizardShell.goToStep(2));
await expect(page.locator('#step-panel-2')).toBeVisible();
await page.locator('#endpoint-path').fill('/rates/{date}');
await page.evaluate(() => window.CrankWizardShell.goToStep(3));
await expect(page.locator('#step-panel-3-rest')).toBeVisible();
await page.locator('.method-card[data-method="GET"]').click();
@@ -356,8 +355,6 @@ test('wizard edit mode hydrates fields from operation version snapshot', async (
retry_policy: { max_attempts: 2 },
auth_profile_ref: null,
headers: {},
protocol_options: null,
streaming: null,
},
tool_description: {
title: 'Получить историю курсов за месяц',
@@ -571,8 +568,6 @@ test('wizard shows agent-facing MCP preview from current draft fields', async ({
retry_policy: null,
auth_profile_ref: null,
headers: {},
protocol_options: null,
streaming: null,
approval_policy: {
required: true,
risk_level: 'financial',
@@ -655,6 +650,7 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
`/api/admin/workspaces/${encodeURIComponent(workspace.id)}/operations/${operationId}`,
async (route) => {
if (route.request().method() === 'PATCH') {
expect(route.request().headers()['if-match']).toBe('"operation-etag-v3"');
updatePayload = route.request().postDataJSON();
await route.fulfill({
contentType: 'application/json',
@@ -671,6 +667,7 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
await route.fulfill({
contentType: 'application/json',
headers: { ETag: '"operation-etag-v3"' },
body: JSON.stringify({
id: operationId,
workspace_id: workspace.id,
@@ -793,8 +790,6 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
retry_policy: null,
auth_profile_ref: null,
headers: {},
protocol_options: null,
streaming: null,
approval_policy: {
required: true,
risk_level: 'financial',
@@ -877,8 +872,6 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
retry_policy: null,
auth_profile_ref: null,
headers: {},
protocol_options: null,
streaming: null,
approval_policy: {
required: true,
mode: 'custom',