pub(crate) mod embedded;
use std::collections::{BTreeSet, HashMap, VecDeque};
use std::convert::Infallible;
use std::fs;
use std::path::{Path as FsPath, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use axum::body::{Body, Bytes};
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Form, Multipart, Path, Query, State};
use axum::http::{
HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri, header::CACHE_CONTROL,
header::CONTENT_DISPOSITION, header::CONTENT_TYPE, header::HOST, header::LOCATION,
header::SET_COOKIE,
};
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::{Html, IntoResponse, Redirect, Response};
use axum::routing::{any, delete, get, post, put};
use axum::{Json, Router};
use rand::RngExt as _;
use serde::Deserialize;
use serde_json::{Value, json};
use swc_common::{FileName, SourceMap, sync::Lrc};
use swc_ecma_ast::{Callee, Expr, ModuleDecl, ModuleItem};
use swc_ecma_parser::{Parser, StringInput, Syntax, TsSyntax, lexer::Lexer};
use crate::automaton::infra::assistant_config::load_project_assistant_llm;
use crate::infra::execution::placement::{ProjectRuntimeMode, ProjectRuntimePlacementTarget};
use crate::infra::mem::subscriber::KvSubscriber;
use crate::infra::scheduler::PipelineScheduler;
use crate::infra::ws_client::WsClientManager;
use crate::language::{DenoSandboxEngine, LanguageEngine, NoopLanguageEngine};
use crate::pipeline::model::{ExecuteOptions, ExecutionBus};
use crate::pipeline::{BasicPipelineEngine, PipelineContext, PipelineEngine, PipelineGraph};
use crate::platform::error::PlatformError;
use crate::platform::model::NodePackageManifest;
use crate::platform::model::{
ClusterWorkerHeartbeatRequest, ClusterWorkerRegisterRequest, CreateMarketplaceTokenRequest,
CreateProjectDocFolderRequest, CreateProjectRequest, CreateSimpleTableRequest,
CreateUserRequest, DeletePipelineRequest, DescribeProjectDbConnectionRequest,
ExecutePipelineRequest, GitCommitRequest, LoginRequest, McpSessionCreateRequest,
McpSessionToggleRequest, PipelineExecuteTrigger, PipelineInvocationEntry,
PipelineLocateRequest, ProjectAccessSubject, ProjectCapability, ProjectDocItem,
ProjectDocMoveRequest, ProjectOperationKind, ProjectRuntimeMaterializationRequest,
ProjectTransferArtifactKind, QueryProjectDbConnectionRequest, TemplateCompileRequest,
TemplateCompileResponse, TemplateCreateRequest, TemplateDiagnostic, TemplateMoveRequest,
TemplateSaveRequest, TestProjectDbConnectionRequest, UpdateSettingsSectionRequest,
UpdateSimpleTableRequest, UpsertPipelineDefinitionRequest, UpsertProjectAssistantConfigRequest,
UpsertProjectCredentialRequest, UpsertProjectDbConnectionRequest, UpsertProjectDocRequest,
slug_segment,
};
use crate::platform::sekejap;
use crate::platform::services::PlatformService;
use crate::platform::services::marketplace::RemoteMarketplacePublishRequest;
use crate::platform::services::node_registry::NodeRegistryService;
use crate::rwe::{
CompiledScript, CompiledTemplate, ReactiveWebEngine, ReactiveWebOptions, RenderContext,
RenderScriptCache, ScriptCacheConfig, TemplateOptions, TemplateSource,
resolve_engine_or_default,
};
use crate::version::APP_VERSION;
use embedded::{PLATFORM_TEMPLATE_ASSETS, platform_library_asset, platform_node_icon_asset};
const LOGIN_PATH: &str = "/login";
const HOME_PATH: &str = "/home";
const SESSION_COOKIE_NAME: &str = "zebflow_session";
const SESSION_TTL_SECS: i64 = 86_400;
const INTERNAL_CLUSTER_TOKEN_HEADER: &str = "x-zebflow-cluster-token";
const BRAND_LOGO_SVG: &[u8] = include_bytes!("assets/branding/logo.svg");
const BRAND_LOGO_PNG: &[u8] = include_bytes!("assets/branding/logo.png");
const PLATFORM_MAIN_CSS: &str = concat!(
include_str!("templates/styles/main.css"),
"\n\n",
include_str!("templates/pages/project-studio/styles.css"),
);
const PLATFORM_DB_SUITE_CSS: &str = include_str!("templates/styles/db-suite.css");
const PLATFORM_DB_CONNECTIONS_CSS: &str = include_str!("templates/styles/db-connections.css");
fn header_first_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.split(',').next())
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn platform_base_url(headers: &HeaderMap) -> String {
if let Ok(value) = std::env::var("ZEBFLOW_PLATFORM_BASE_URL") {
let value = value.trim().trim_end_matches('/');
if !value.is_empty() {
return value.to_string();
}
}
let proto = header_first_value(headers, "x-forwarded-proto").unwrap_or("http");
let host = header_first_value(headers, "x-forwarded-host")
.or_else(|| headers.get(HOST).and_then(|value| value.to_str().ok()))
.unwrap_or("localhost:10610")
.trim();
format!("{}://{}", proto, host)
.trim_end_matches('/')
.to_string()
}
#[cfg(debug_assertions)]
static DEV_DIRTY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
#[cfg(debug_assertions)]
const TEMPLATE_SOURCE_DIR: &str =
concat!(env!("CARGO_MANIFEST_DIR"), "/src/platform/web/templates");
const PAGE_DEFS: &[(&str, &str, &str)] = &[
("platform-login", "platform.login", "pages/login/page.tsx"),
("platform-home", "platform.home", "pages/home/page.tsx"),
(
"platform-marketplace",
"platform.marketplace",
"pages/marketplace/page.tsx",
),
(
"platform-project-pipelines",
"platform.project.pipelines",
"pages/project-studio/pipelines/page.tsx",
),
(
"platform-project-editor",
"platform.project.editor",
"pages/project-studio/pipelines/registry/page.tsx",
),
(
"platform-project-section",
"platform.project.section",
"pages/project-studio/files/page.tsx",
),
(
"platform-project-dashboard",
"platform.project.dashboard",
"pages/project-studio/dashboard/page.tsx",
),
(
"platform-project-settings",
"platform.project.settings",
"pages/project-studio/settings/page.tsx",
),
(
"platform-project-infrastructure",
"platform.project.infrastructure",
"pages/project-studio/infrastructure/page.tsx",
),
(
"platform-project-credentials",
"platform.project.credentials",
"pages/project-studio/credentials/page.tsx",
),
(
"platform-project-tables",
"platform.project.tables",
"pages/project-studio/connections/page.tsx",
),
(
"platform-project-table-connection",
"platform.project.table_connection",
"pages/project-studio/connections/db/connection/page.tsx",
),
(
"platform-project-table-connection-postgresql",
"platform.project.table_connection.postgresql",
"pages/project-studio/connections/db/postgresql/page.tsx",
),
(
"platform-project-table-connection-sekejap",
"platform.project.table_connection.sekejap",
"pages/project-studio/connections/db/sekejap/page.tsx",
),
(
"platform-project-table-connection-mapserver",
"platform.project.table_connection.mapserver",
"pages/project-studio/connections/db/mapserver/page.tsx",
),
(
"platform-project-marketplace",
"platform.project.marketplace",
"pages/project-studio/marketplace/page.tsx",
),
(
"platform-design-system",
"platform.dev.design_system",
"pages/dev/design-system/page.tsx",
),
(
"platform-project-settings-clone-ui-preview",
"platform.project.settings.clone_ui_preview",
"pages/project-studio/settings/clone/ui/preview/page.tsx",
),
];
#[derive(Clone)]
struct PlatformFrontend {
rwe: Arc<dyn ReactiveWebEngine>,
language: Arc<dyn LanguageEngine>,
#[cfg_attr(debug_assertions, allow(dead_code))]
pages: Arc<std::collections::BTreeMap<&'static str, CompiledTemplate>>,
#[cfg_attr(not(debug_assertions), allow(dead_code))]
template_root: PathBuf,
#[cfg_attr(not(debug_assertions), allow(dead_code))]
options: ReactiveWebOptions,
}
#[derive(Clone)]
struct PlatformWebSession {
owner: String,
expires_at: i64,
}
#[derive(Clone)]
pub struct PlatformAppState {
pub platform: Arc<PlatformService>,
http_client: reqwest::Client,
frontend: PlatformFrontend,
render_script_cache: Option<Arc<RenderScriptCache>>,
template_cache: crate::pipeline::engines::basic::TemplateCache,
scheduler: Arc<PipelineScheduler>,
kv_subscriber: Arc<KvSubscriber>,
ws_client_manager: Arc<WsClientManager>,
preview_registry: Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
sessions: Arc<std::sync::Mutex<HashMap<String, PlatformWebSession>>>,
oauth_state_secret: [u8; 32],
}
pub async fn router(platform: Arc<PlatformService>) -> Router {
let frontend = build_frontend(&platform.config.data_root).unwrap_or_else(|err| {
panic!("failed building platform frontend templates: {err}");
});
#[cfg(debug_assertions)]
{
let watcher_template_root = frontend.template_root.clone();
tokio::spawn(dev_template_watcher(watcher_template_root));
}
let render_script_cache = build_render_script_cache(&platform.config.data_root);
let sched_engine = Arc::new(
BasicPipelineEngine::new(
Arc::new(DenoSandboxEngine::default()),
crate::rwe::resolve_engine_or_default(None),
Some(platform.credentials.clone()),
)
.with_platform(platform.clone())
.with_ws_hub(platform.ws_hub.clone())
.with_state_bus(platform.state_bus.clone())
.with_data_root(platform.config.data_root.clone()),
);
let scheduler = PipelineScheduler::start(
platform.pipeline_runtime.clone(),
sched_engine.clone(),
platform.pipeline_hits.clone(),
platform.data.clone(),
platform.zebflow_cfg.clone(),
)
.await
.unwrap_or_else(|e| {
panic!("failed starting pipeline scheduler: {e}");
});
println!("✅ Pipeline scheduler started");
let kv_subscriber = Arc::new(KvSubscriber::new(
platform.state_bus.clone(),
platform.pipeline_runtime.clone(),
sched_engine.clone(),
platform.pipeline_hits.clone(),
platform.data.clone(),
platform.zebflow_cfg.clone(),
));
kv_subscriber.register_all().await;
println!("✅ KV subscriber started");
let sessions: Arc<std::sync::Mutex<HashMap<String, PlatformWebSession>>> =
Arc::new(std::sync::Mutex::new(HashMap::new()));
let ws_client_manager = Arc::new(WsClientManager::new(
platform.pipeline_runtime.clone(),
sched_engine,
platform.pipeline_hits.clone(),
platform.data.clone(),
platform.zebflow_cfg.clone(),
));
ws_client_manager.register_all().await;
println!("✅ WS client manager started");
let template_cache = crate::pipeline::engines::basic::new_template_cache();
let mcp_service =
crate::platform::mcp::build_mcp_service(platform.clone(), template_cache.clone());
let router = Router::new()
.route("/health", get(health_handler))
.route("/ready", get(ready_handler))
.route(
"/.well-known/oauth-protected-resource",
get(oauth_protected_resource_handler),
)
.route(
"/.well-known/oauth-authorization-server",
get(oauth_authorization_server_handler),
)
.route("/oauth/callback", get(oauth2_callback_handler))
.route("/", get(root_redirect))
.route("/assets/branding/{asset}", get(branding_asset))
.route("/assets/platform/{asset}", get(platform_asset))
.route("/assets/node-icons/{*path}", get(node_icon_asset))
.route("/assets/rwe/scripts/{hash}", get(rwe_script_asset))
.route(
"/assets/{owner}/{project}/rwe/scripts/{hash}",
get(project_rwe_script_asset),
)
.route(
"/assets/{owner}/{project}/libraries/{*path}",
get(project_scoped_library_asset),
)
.route("/assets/{owner}/{project}/{*path}", get(project_asset))
.route("/assets/libraries/{*path}", get(library_asset))
.route(
"/p/{owner}/{project}/assets/{*path}",
get(project_static_asset),
)
.route("/fs/{owner}/{project}/{*path}", get(project_fs_serve))
.route("/login", get(login_page).post(login_submit))
.route("/logout", post(logout_submit))
.route("/home", get(home_page))
.route("/marketplace", get(platform_marketplace_page))
.route("/dev/design-system", get(design_system_page))
.route("/docs/node", get(docs_node_contract))
.route("/docs/operation", get(docs_operation_contract))
.route("/home/projects/create", post(home_create_project_submit))
.route("/home/projects/clone", post(home_clone_project_submit))
.route("/projects/{owner}/{project}", get(project_root_page))
.route(
"/projects/{owner}/{project}/pipelines/{tab}",
get(project_pipelines_page),
)
.route(
"/projects/{owner}/{project}/dashboard",
get(project_dashboard_page),
)
.route(
"/projects/{owner}/{project}/marketplace",
get(project_marketplace_page),
)
.route(
"/projects/{owner}/{project}/marketplace/{tab}",
get(project_marketplace_tab_page),
)
.route(
"/projects/{owner}/{project}/credentials",
get(project_credentials_page),
)
.route(
"/projects/{owner}/{project}/db/connections",
get(project_db_connections_page),
)
.route(
"/projects/{owner}/{project}/db/{db_kind}/{connection}",
get(project_db_suite_redirect_page),
)
.route(
"/projects/{owner}/{project}/db/{db_kind}/{connection}/{tab}",
get(project_db_suite_page),
)
.route("/projects/{owner}/{project}/files", get(project_files_page))
.route(
"/projects/{owner}/{project}/files/{tab}",
get(project_files_tab_page),
)
.route("/projects/{owner}/{project}/todo", get(project_todo_page))
.route(
"/projects/{owner}/{project}/settings/clone/ui/preview",
get(project_settings_clone_ui_preview_page),
)
.route(
"/projects/{owner}/{project}/settings",
get(project_settings_page),
)
.route(
"/projects/{owner}/{project}/settings/{tab}",
get(project_settings_tab_page),
)
.route(
"/projects/{owner}/{project}/infrastructure",
get(project_infrastructure_page),
)
.route(
"/projects/{owner}/{project}/editor",
get(project_editor_page),
)
.route("/api/meta", get(api_meta))
.route("/api/system/info", get(api_system_info))
.route(
"/api/admin/db/collections",
get(api_admin_db_list_collections),
)
.route("/api/admin/db/query", post(api_admin_db_query))
.route(
"/api/admin/db/node/{slug}",
get(api_admin_db_get_node).delete(api_admin_db_delete_node),
)
.route(
"/api/projects/{owner}/{project}/nodes",
get(api_list_node_definitions),
)
.route(
"/api/projects/{owner}/{project}/nodes/by-kind/{kind}",
get(api_get_node_definition),
)
.route(
"/api/projects/{owner}/{project}/nodes/install",
post(api_install_node_package),
)
.route(
"/api/projects/{owner}/{project}/nodes/uninstall/{kind}",
delete(api_uninstall_node_package),
)
.route(
"/api/projects/{owner}/{project}/nodes/icon/{kind}",
get(api_node_icon),
)
.route("/api/users", get(api_list_users).post(api_create_user))
.route("/api/cluster/workers", get(api_cluster_workers))
.route(
"/api/internal/cluster/workers/register",
post(api_internal_cluster_register_worker),
)
.route(
"/api/internal/cluster/workers/heartbeat",
post(api_internal_cluster_worker_heartbeat),
)
.route(
"/api/internal/runtime/materialize",
post(api_internal_runtime_materialize_project),
)
.route(
"/api/internal/runtime/execute/{owner}/{project}",
post(api_internal_runtime_execute_pipeline),
)
.route(
"/api/internal/runtime/webhook/{owner}/{project}",
any(api_internal_runtime_webhook_root),
)
.route(
"/api/internal/runtime/webhook/{owner}/{project}/{*tail}",
any(api_internal_runtime_webhook),
)
.route(
"/api/internal/project-transfer/{owner}/{project}/export/{kind}",
post(api_internal_project_transfer_export),
)
.route(
"/api/internal/project-transfer/{owner}/{project}/import/{kind}",
post(api_internal_project_transfer_import),
)
.route(
"/api/users/{owner}/projects",
get(api_list_projects).post(api_create_project),
)
.route(
"/api/users/{owner}/marketplace/repositories",
get(api_list_platform_marketplace_repositories)
.post(api_upsert_platform_marketplace_repository),
)
.route(
"/api/platform/marketplace/repositories",
get(api_list_platform_marketplace_sources).post(api_upsert_platform_marketplace_source),
)
.route(
"/api/platform/marketplace/service",
get(api_get_platform_marketplace_service).post(api_configure_platform_marketplace_service),
)
.route(
"/api/platform/marketplace/publishers",
get(api_list_platform_marketplace_publishers).post(api_upsert_platform_marketplace_publisher),
)
.route(
"/api/platform/marketplace/tokens",
get(api_list_platform_marketplace_tokens).post(api_create_platform_marketplace_token),
)
.route(
"/api/platform/marketplace/tokens/{token_id}",
delete(api_delete_platform_marketplace_token),
)
.route(
"/api/platform/marketplace/publishers/{publisher_id}",
delete(api_delete_platform_marketplace_publisher),
)
.route(
"/api/users/{owner}/marketplace/repositories/{repository_id}",
delete(api_delete_platform_marketplace_repository),
)
.route(
"/api/platform/marketplace/repositories/{repository_id}",
delete(api_delete_platform_marketplace_source),
)
.route(
"/api/users/{owner}/marketplace/assets",
get(api_list_platform_marketplace_assets),
)
.route(
"/api/platform/marketplace/assets",
get(api_list_platform_marketplace_apps),
)
.route(
"/api/users/{owner}/marketplace/install",
post(api_install_platform_marketplace_project),
)
.route(
"/api/platform/marketplace/install",
post(api_install_platform_marketplace_app),
)
.route(
"/api/users/{owner}/projects/{project}",
delete(api_delete_project),
)
.route(
"/api/projects/{owner}/{project}/pipelines/registry",
get(api_pipeline_registry),
)
.route(
"/api/projects/{owner}/{project}/pipelines",
get(api_list_pipelines),
)
.route(
"/api/projects/{owner}/{project}/pipelines/by-id",
get(api_get_pipeline_by_id),
)
.route(
"/api/projects/{owner}/{project}/pipelines/definition",
post(api_upsert_pipeline_definition).delete(api_delete_pipeline_definition),
)
.route(
"/api/projects/{owner}/{project}/pipelines/lock-toggle",
post(api_pipeline_lock_toggle),
)
.route(
"/api/projects/{owner}/{project}/templates/lock-toggle",
post(api_template_lock_toggle),
)
.route(
"/api/projects/{owner}/{project}/git/status",
get(api_repo_git_status),
)
.route(
"/api/projects/{owner}/{project}/git/health",
get(api_repo_git_health),
)
.route(
"/api/projects/{owner}/{project}/git/repair",
post(api_repo_git_repair),
)
.route(
"/api/projects/{owner}/{project}/git/commit",
post(api_git_commit),
)
.route(
"/api/projects/{owner}/{project}/git/remote",
get(api_git_get_remote).put(api_git_put_remote),
)
.route(
"/api/projects/{owner}/{project}/git/branches",
get(api_git_list_branches).post(api_git_checkout_branch),
)
.route(
"/api/projects/{owner}/{project}/pipelines/activate",
post(api_activate_pipeline_definition),
)
.route(
"/api/projects/{owner}/{project}/pipelines/deactivate",
post(api_deactivate_pipeline_definition),
)
.route(
"/api/projects/{owner}/{project}/pipelines/execute",
post(api_execute_pipeline),
)
.route(
"/api/projects/{owner}/{project}/pipelines/dsl",
post(api_execute_pipeline_dsl),
)
.route(
"/api/projects/{owner}/{project}/pipelines/hits",
get(api_pipeline_hits),
)
.route(
"/api/projects/{owner}/{project}/pipelines/invocations",
get(api_pipeline_invocations),
)
.route(
"/api/projects/{owner}/{project}/templates/workspace",
get(api_template_workspace),
)
.route(
"/api/projects/{owner}/{project}/templates/search",
get(api_template_search),
)
.route(
"/api/projects/{owner}/{project}/templates/pages",
get(api_template_pages),
)
.route(
"/api/projects/{owner}/{project}/templates/file",
get(api_template_file)
.put(api_template_save)
.delete(api_template_delete),
)
.route(
"/api/projects/{owner}/{project}/templates/outline",
get(api_template_outline),
)
.route(
"/api/projects/{owner}/{project}/templates/create",
post(api_template_create),
)
.route(
"/api/projects/{owner}/{project}/templates/move",
post(api_template_move),
)
.route(
"/api/projects/{owner}/{project}/templates/git-status",
get(api_template_git_status),
)
.route(
"/api/projects/{owner}/{project}/templates/diagnostics",
post(api_template_diagnostics),
)
.route(
"/api/projects/{owner}/{project}/files/list",
get(api_files_list),
)
.route(
"/api/projects/{owner}/{project}/files/mkdir",
post(api_files_mkdir),
)
.route(
"/api/projects/{owner}/{project}/files/upload",
post(api_files_upload)
.layer(axum::extract::DefaultBodyLimit::max(1024 * 1024 * 1024)),
)
.route(
"/api/projects/{owner}/{project}/files/rm",
post(api_files_rm),
)
.route(
"/api/projects/{owner}/{project}/credential-types",
get(api_list_credential_types),
)
.route(
"/api/projects/{owner}/{project}/credentials",
get(api_list_credentials).post(api_upsert_credential),
)
.route(
"/api/projects/{owner}/{project}/credentials/{credential_id}",
get(api_get_credential)
.put(api_upsert_credential_by_path)
.delete(api_delete_credential),
)
.route(
"/api/projects/{owner}/{project}/credentials/{credential_id}/oauth/authorize",
get(api_oauth2_authorize),
)
.route(
"/api/projects/{owner}/{project}/assistant/config",
get(api_get_project_assistant_config).put(api_upsert_project_assistant_config),
)
.route(
"/api/projects/{owner}/{project}/settings/logs/invocations",
get(api_project_invocation_log_stats).delete(api_clear_project_invocation_logs),
)
.route(
"/api/projects/{owner}/{project}/settings/{section}",
get(api_get_settings_section).put(api_upsert_settings_section),
)
.route(
"/api/projects/{owner}/{project}/rwe/libraries",
get(api_list_rwe_libraries),
)
.route(
"/api/projects/{owner}/{project}/rwe/libraries/enable",
post(api_enable_rwe_library),
)
.route(
"/api/projects/{owner}/{project}/rwe/libraries/disable",
delete(api_disable_rwe_library),
)
.route(
"/api/projects/{owner}/{project}/rwe/cache/clear",
post(api_rwe_cache_clear),
)
.route(
"/api/projects/{owner}/{project}/assistant/chat",
post(api_project_assistant_chat),
)
.route(
"/api/projects/{owner}/{project}/db/connections",
get(api_list_db_connections).post(api_upsert_db_connection),
)
.route(
"/api/projects/{owner}/{project}/tables",
get(api_list_simple_tables).post(api_create_simple_table),
)
.route(
"/api/projects/{owner}/{project}/tables/schema/export",
get(api_export_sekejap_schema),
)
.route(
"/api/projects/{owner}/{project}/tables/schema/sync",
post(api_sync_sekejap_schema),
)
.route(
"/api/projects/{owner}/{project}/tables/{table}",
put(api_update_simple_table).delete(api_delete_simple_table),
)
.route(
"/api/projects/{owner}/{project}/db/connections/{connection_id}/describe",
get(api_describe_db_connection),
)
.route(
"/api/projects/{owner}/{project}/db/connections/{connection_id}/schemas",
get(api_list_db_connection_schemas),
)
.route(
"/api/projects/{owner}/{project}/db/connections/{connection_id}/tables",
get(api_list_db_connection_tables),
)
.route(
"/api/projects/{owner}/{project}/db/connections/{connection_id}/functions",
get(api_list_db_connection_functions),
)
.route(
"/api/projects/{owner}/{project}/db/connections/{connection_id}/query",
post(api_query_db_connection),
)
.route(
"/api/projects/{owner}/{project}/db/connections/{connection_id}/table-preview",
get(api_preview_db_connection_table),
)
.route(
"/api/projects/{owner}/{project}/db/connections/{connection_slug}",
get(api_get_db_connection)
.put(api_upsert_db_connection_by_path)
.delete(api_delete_db_connection),
)
.route(
"/api/projects/{owner}/{project}/db/connections/test",
post(api_test_db_connection),
)
.route(
"/api/projects/{owner}/{project}/marketplace/assets",
get(api_list_marketplace_assets),
)
.route(
"/api/projects/{owner}/{project}/help",
get(api_get_project_help),
)
.route(
"/api/projects/{owner}/{project}/marketplace/remote/assets",
get(api_list_remote_marketplace_assets),
)
.route(
"/api/projects/{owner}/{project}/marketplace/remote/assets/{package_id}/{version}",
get(api_get_remote_marketplace_asset),
)
.route(
"/api/projects/{owner}/{project}/marketplace/remote/assets/{package_id}/{version}/artifact",
get(api_get_remote_marketplace_artifact),
)
.route(
"/api/marketplace/remote/assets",
get(api_list_public_marketplace_assets),
)
.route(
"/api/marketplace/remote/assets/{package_id}/{version}",
get(api_get_public_marketplace_asset),
)
.route(
"/api/marketplace/remote/assets/{package_id}/{version}/artifact",
get(api_get_public_marketplace_artifact),
)
.route(
"/api/projects/{owner}/{project}/marketplace/assets/mine",
get(api_list_my_marketplace_assets),
)
.route(
"/api/projects/{owner}/{project}/marketplace/publish-sources",
get(api_list_marketplace_publish_sources),
)
.route(
"/api/projects/{owner}/{project}/marketplace/publish-preview",
get(api_preview_marketplace_publish_source),
)
.route(
"/api/projects/{owner}/{project}/marketplace/assets/preview",
get(api_preview_marketplace_publish_source),
)
.route(
"/api/projects/{owner}/{project}/marketplace/assets/publish",
post(api_publish_marketplace_asset),
)
.route(
"/api/projects/{owner}/{project}/marketplace/assets/{package_id}/{version}/add",
post(api_install_marketplace_asset),
)
.route(
"/api/projects/{owner}/{project}/marketplace/remote/assets/publish",
post(api_remote_publish_marketplace_asset),
)
.route(
"/api/projects/{owner}/{project}/marketplace/tokens",
get(api_list_marketplace_tokens).post(api_create_marketplace_token),
)
.route(
"/api/projects/{owner}/{project}/marketplace/publishers",
get(api_list_marketplace_publishers).post(api_upsert_marketplace_publisher),
)
.route(
"/api/projects/{owner}/{project}/marketplace/repositories",
get(api_list_marketplace_repositories).post(api_upsert_marketplace_repository),
)
.route(
"/api/projects/{owner}/{project}/marketplace/repositories/{repository_id}/packs/{package_id}/{version}/add",
post(api_install_remote_marketplace_pack),
)
.route(
"/api/projects/{owner}/{project}/marketplace/repositories/{repository_id}",
delete(api_delete_marketplace_repository),
)
.route(
"/api/projects/{owner}/{project}/marketplace/producer",
post(api_set_marketplace_producer_mode),
)
.route(
"/api/projects/{owner}/{project}/marketplace/tokens/{token_id}",
delete(api_delete_marketplace_token),
)
.route(
"/api/projects/{owner}/{project}/marketplace/publishers/{publisher_id}",
delete(api_delete_marketplace_publisher),
)
.route(
"/api/projects/{owner}/{project}/docs",
get(api_list_project_docs).post(api_upsert_project_doc),
)
.route(
"/api/projects/{owner}/{project}/docs/file",
get(api_read_project_doc)
.put(api_upsert_project_doc_file)
.delete(api_delete_project_doc_file),
)
.route(
"/api/projects/{owner}/{project}/docs/folder",
post(api_create_project_doc_folder),
)
.route(
"/api/projects/{owner}/{project}/docs/move",
post(api_move_project_doc_entry),
)
.route(
"/api/projects/{owner}/{project}/docs/entry",
delete(api_delete_project_doc_entry),
)
.route(
"/api/projects/{owner}/{project}/agent-docs",
get(api_list_agent_docs),
)
.route(
"/api/projects/{owner}/{project}/agent-docs/file",
get(api_read_agent_doc).put(api_upsert_agent_doc_file),
)
.route(
"/api/projects/{owner}/{project}/mcp/session",
get(api_get_mcp_session)
.post(api_create_mcp_session)
.put(api_toggle_mcp_session)
.delete(api_revoke_mcp_session),
)
.route(
"/api/projects/{owner}/{project}/mcp/session/reset-token",
post(api_reset_mcp_session_token),
)
.route(
"/api/projects/{owner}/{project}/assets/prepare",
post(api_prepare_project_assets),
)
.route(
"/api/projects/{owner}/{project}/assets",
get(api_list_assets)
.post(api_upload_asset)
.layer(axum::extract::DefaultBodyLimit::max(1024 * 1024 * 1024)),
)
.route(
"/api/projects/{owner}/{project}/assets/{*path}",
delete(api_delete_asset),
)
.route(
"/api/projects/{owner}/{project}/mapserver/{instance}/sources",
get(api_mapserver_sources_list),
)
.route(
"/api/projects/{owner}/{project}/mapserver/{instance}/layers",
get(api_mapserver_layers_list).post(api_mapserver_layers_publish),
)
.route(
"/api/projects/{owner}/{project}/mapserver/{instance}/layers/{layer_id}",
delete(api_mapserver_layers_delete),
)
.route(
"/api/projects/{owner}/{project}/install/catalog/ui",
get(api_list_ui_catalog),
)
.route(
"/api/projects/{owner}/{project}/install/ui",
post(api_install_ui_components),
)
.route(
"/api/projects/{owner}/{project}/reindex",
post(api_reindex_project),
)
.nest("/api/projects/{owner}/{project}/mcp", mcp_service)
.route(
"/wh/{owner}/{project}",
any(public_webhook_ingress_root)
.layer(axum::extract::DefaultBodyLimit::max(1024 * 1024 * 1024)),
)
.route(
"/wh/{owner}/{project}/",
any(public_webhook_ingress_root)
.layer(axum::extract::DefaultBodyLimit::max(1024 * 1024 * 1024)),
)
.route(
"/wh/{owner}/{project}/{*tail}",
any(public_webhook_ingress)
.layer(axum::extract::DefaultBodyLimit::max(1024 * 1024 * 1024)),
)
.route("/ms/{owner}/{project}", get(public_mapserver_ingress_root))
.route("/ms/{owner}/{project}/", get(public_mapserver_ingress_root))
.route("/ms/{owner}/{project}/{*tail}", get(public_mapserver_ingress))
.route(
"/ws/{owner}/{project}/rooms/{room_id}",
get(ws_room_handler),
)
.route(
"/api/projects/{owner}/{project}/preview/toggle",
post(api_preview_toggle),
)
.route(
"/api/projects/{owner}/{project}/preview/status",
get(api_preview_status),
)
.route(
"/api/projects/{owner}/{project}/runtime",
get(api_project_runtime_status),
)
.route(
"/api/projects/{owner}/{project}/runtime/sync",
post(api_project_runtime_sync),
)
.route(
"/api/projects/{owner}/{project}/transfer/operations",
get(api_project_transfer_operations),
)
.route(
"/api/projects/{owner}/{project}/transfer/export/{kind}",
post(api_project_transfer_export),
)
.route(
"/api/projects/{owner}/{project}/transfer/import/{kind}",
post(api_project_transfer_import)
.layer(axum::extract::DefaultBodyLimit::max(1024 * 1024 * 1024)),
)
.route(
"/api/projects/{owner}/{project}/transfer/download/{operation_id}",
get(api_project_transfer_download),
)
.route(
"/api/projects/{owner}/{project}/transfer/owner",
post(api_transfer_project_owner),
)
.route("/preview/{owner}/{project}", get(preview_page))
.route("/ws/preview/{owner}/{project}", get(ws_preview_handler));
let app_state = PlatformAppState {
platform,
http_client: reqwest::Client::new(),
frontend,
render_script_cache,
template_cache,
scheduler,
kv_subscriber,
ws_client_manager,
preview_registry: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
sessions,
oauth_state_secret: {
let mut bytes = [0u8; 32];
rand::rng().fill(&mut bytes);
bytes
},
};
if app_state.platform.cluster_bootstrap.is_worker() {
tokio::spawn(cluster_worker_registration_loop(app_state.clone()));
}
let router = router.with_state(app_state);
#[cfg(debug_assertions)]
let router = router.route("/__dev/reload", get(dev_reload_sse));
router
}
fn build_render_script_cache(data_root: &FsPath) -> Option<Arc<RenderScriptCache>> {
let root = data_root.join("platform").join("rwe-script-cache");
let cfg = ScriptCacheConfig::new(root, 8 * 1024 * 1024);
match RenderScriptCache::new(cfg) {
Ok(cache) => Some(Arc::new(cache)),
Err(err) => {
eprintln!("warning: failed creating RWE script cache: {err}");
None
}
}
}
fn build_frontend(data_root: &FsPath) -> Result<PlatformFrontend, PlatformError> {
let rwe_engine_id = std::env::var("ZEBFLOW_PLATFORM_RWE_ENGINE_ID").ok();
let rwe: Arc<dyn ReactiveWebEngine> = resolve_engine_or_default(rwe_engine_id.as_deref());
let language: Arc<dyn LanguageEngine> = Arc::new(NoopLanguageEngine);
let template_root = materialize_platform_template_root(data_root)?;
let options = ReactiveWebOptions {
load_scripts: vec!["/assets/platform/*".to_string()],
allow_list: crate::rwe::ResourceAllowList {
scripts: vec!["/assets/platform/*".to_string()],
urls: vec!["/assets/platform/*".to_string()],
..Default::default()
},
templates: TemplateOptions {
template_root: Some(template_root.clone()),
style_entries: Vec::new(),
},
processors: vec!["tailwind".to_string()],
..Default::default()
};
let mut pages = std::collections::BTreeMap::new();
for &(key, id, rel_path) in PAGE_DEFS {
pages.insert(
key,
compile_page(
rwe.as_ref(),
language.as_ref(),
id,
&template_root,
rel_path,
options.clone(),
)?,
);
}
Ok(PlatformFrontend {
rwe,
language,
pages: Arc::new(pages),
template_root,
options,
})
}
#[cfg(debug_assertions)]
async fn dev_template_watcher(temp_root: PathBuf) {
use std::collections::HashMap;
use std::sync::atomic::Ordering;
let source_dir = FsPath::new(TEMPLATE_SOURCE_DIR);
let mut known: HashMap<PathBuf, std::time::SystemTime> = HashMap::new();
dev_collect_mtimes(source_dir, &mut known);
let mut interval = tokio::time::interval(Duration::from_millis(500));
interval.tick().await;
loop {
interval.tick().await;
let mut current: HashMap<PathBuf, std::time::SystemTime> = HashMap::new();
dev_collect_mtimes(source_dir, &mut current);
let changed: Vec<PathBuf> = current
.iter()
.filter(|(p, mtime)| known.get(*p) != Some(mtime))
.map(|(p, _)| p.clone())
.collect();
if changed.is_empty() {
continue;
}
for src in &changed {
let Ok(rel) = src.strip_prefix(source_dir) else {
continue;
};
let dest = temp_root.join(rel);
if let Some(parent) = dest.parent() {
let _ = fs::create_dir_all(parent);
}
let _ = fs::copy(src, &dest);
}
let _ = crate::rwe::core::prepare_template_root(&temp_root);
DEV_DIRTY.store(true, Ordering::Relaxed);
known = current;
println!(
"🔄 [dev] {} template file(s) changed — signalling reload",
changed.len()
);
}
}
#[cfg(debug_assertions)]
fn dev_collect_mtimes(
dir: &FsPath,
out: &mut std::collections::HashMap<PathBuf, std::time::SystemTime>,
) {
let Ok(rd) = fs::read_dir(dir) else { return };
for entry in rd.flatten() {
let p = entry.path();
if p.is_dir() {
dev_collect_mtimes(&p, out);
} else if let Ok(meta) = fs::metadata(&p) {
if let Ok(mtime) = meta.modified() {
out.insert(p, mtime);
}
}
}
}
#[cfg(debug_assertions)]
async fn dev_reload_sse() -> Sse<impl futures::Stream<Item = Result<Event, Infallible>>> {
use std::sync::atomic::Ordering;
let stream = async_stream::stream! {
let mut interval = tokio::time::interval(Duration::from_millis(250));
loop {
interval.tick().await;
if DEV_DIRTY.swap(false, Ordering::Relaxed) {
yield Ok::<Event, Infallible>(Event::default().data("reload"));
}
}
};
Sse::new(stream).keep_alive(
KeepAlive::new()
.interval(Duration::from_secs(15))
.text("keep-alive"),
)
}
fn compile_page(
rwe: &dyn ReactiveWebEngine,
language: &dyn LanguageEngine,
id: &str,
template_root: &FsPath,
relative_path: &str,
options: ReactiveWebOptions,
) -> Result<CompiledTemplate, PlatformError> {
let page_path = template_root.join(relative_path);
let markup = fs::read_to_string(&page_path).map_err(|err| {
PlatformError::new(
"PLATFORM_RWE_SOURCE_READ",
format!("failed reading '{}': {err}", page_path.display()),
)
})?;
rwe.compile_template(
&TemplateSource {
id: id.to_string(),
source_path: Some(page_path),
markup,
},
language,
&options,
)
.map_err(|e| PlatformError::new("PLATFORM_RWE_COMPILE", e.to_string()))
}
fn materialize_platform_template_root(_data_root: &FsPath) -> Result<PathBuf, PlatformError> {
let root = std::env::temp_dir()
.join("zebflow-platform")
.join(env!("CARGO_PKG_VERSION"));
let sentinel = root.join(".materialized");
let needs_extract = cfg!(debug_assertions) || !sentinel.exists();
if needs_extract {
let asset_paths: std::collections::HashSet<PathBuf> = PLATFORM_TEMPLATE_ASSETS
.iter()
.map(|a| root.join(a.path))
.collect();
if cfg!(debug_assertions) && root.exists() {
fn collect_files(dir: &FsPath, out: &mut Vec<PathBuf>) {
let Ok(rd) = fs::read_dir(dir) else { return };
for entry in rd.flatten() {
let p = entry.path();
if p.is_dir() {
collect_files(&p, out);
} else {
out.push(p);
}
}
}
let mut existing = Vec::new();
collect_files(&root, &mut existing);
for stale in existing.into_iter().filter(|p| !asset_paths.contains(p)) {
let _ = fs::remove_file(&stale);
}
}
for asset in PLATFORM_TEMPLATE_ASSETS {
let full = root.join(asset.path);
if let Some(parent) = full.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&full, asset.bytes)?;
}
crate::rwe::core::prepare_template_root(&root)
.map_err(|e| PlatformError::new("PLATFORM_TEMPLATE_REWRITE", e.message))?;
if !cfg!(debug_assertions) {
fs::write(&sentinel, b"")
.map_err(|e| PlatformError::new("PLATFORM_TEMPLATE_SENTINEL", e.to_string()))?;
}
}
Ok(root)
}
fn render_page(
state: &PlatformAppState,
page: &'static str,
route: &str,
input: Value,
) -> Result<String, PlatformError> {
#[cfg(debug_assertions)]
let _compiled_owned: CompiledTemplate;
let compiled: &CompiledTemplate;
#[cfg(debug_assertions)]
{
let &(_, id, rel_path) = PAGE_DEFS
.iter()
.find(|&&(key, _, _)| key == page)
.ok_or_else(|| PlatformError::new("PLATFORM_RWE_PAGE_MISSING", page))?;
_compiled_owned = compile_page(
state.frontend.rwe.as_ref(),
state.frontend.language.as_ref(),
id,
&state.frontend.template_root,
rel_path,
state.frontend.options.clone(),
)?;
compiled = &_compiled_owned;
}
#[cfg(not(debug_assertions))]
{
compiled = state
.frontend
.pages
.get(page)
.ok_or_else(|| PlatformError::new("PLATFORM_RWE_PAGE_MISSING", page))?;
}
let out = state
.frontend
.rwe
.render(
compiled,
input,
state.frontend.language.as_ref(),
&RenderContext {
route: route.to_string(),
request_id: format!("zebflow-{page}"),
metadata: json!({"zebflow": true}),
enabled_libraries: Vec::new(),
},
)
.map_err(|e| PlatformError::new("PLATFORM_RWE_RENDER", e.to_string()))?;
let mut html = out.html;
html = ensure_meta_charset(html);
if let Some(css) = out.hydration_payload.get("css").and_then(Value::as_str)
&& !css.trim().is_empty()
{
let style_block = format!("<style data-rwe-tw>{css}</style>");
if let Some(pos) = html.find("</head>") {
html.insert_str(pos, &style_block);
} else {
html = format!("{style_block}{html}");
}
}
html = ensure_stylesheet_link(html, "/assets/platform/main.css");
if html.contains("data-db-suite=\"true\"") {
html = ensure_stylesheet_link(html, "/assets/platform/db-suite.css");
}
if html.contains("devicon-") {
html = ensure_stylesheet_link(html, "/assets/libraries/zeb/icons/0.1/runtime/devicons.css");
}
let final_html = externalize_rwe_scripts(state, html.as_str(), &out.compiled_scripts, None);
#[cfg(debug_assertions)]
let final_html = {
inject_before_body_end(
&final_html,
"<script>(function(){new EventSource('/__dev/reload').onmessage=function(){location.reload()}})();</script>",
)
};
Ok(final_html)
}
fn ensure_meta_charset(mut html: String) -> String {
if html.contains("<meta charset") || html.contains("<meta http-equiv=\"Content-Type\"") {
return html;
}
let tag = "<meta charset=\"utf-8\">";
if let Some(pos) = html.find("<head>") {
html.insert_str(pos + "<head>".len(), tag);
} else if let Some(pos) = html.find("</head>") {
html.insert_str(pos, tag);
} else {
html = format!("{tag}{html}");
}
html
}
fn ensure_stylesheet_link(mut html: String, href: &str) -> String {
if html.contains(href) {
return html;
}
let link = format!("<link rel=\"stylesheet\" href=\"{href}\">");
if let Some(pos) = html.find("</head>") {
html.insert_str(pos, &link);
return html;
}
format!("{link}{html}")
}
fn insert_project_theme_block(mut html: String, style_block: &str) -> String {
let pos = html
.find("<style data-rwe-style")
.or_else(|| html.find("<style data-rwe-tw"))
.or_else(|| html.find("</head>"));
if let Some(pos) = pos {
html.insert_str(pos, style_block);
return html;
}
format!("{style_block}{html}")
}
fn externalize_rwe_scripts(
state: &PlatformAppState,
html: &str,
compiled_scripts: &[CompiledScript],
project_scope: Option<(&str, &str)>,
) -> String {
let deployment_asset_base: Option<String> = project_scope.and_then(|(owner, project)| {
state
.platform
.zebflow_cfg
.read_or_default(owner, project)
.configs
.rwe
.deployment_asset_base
});
let rewrite_assets = |s: String| -> String {
match (project_scope, &deployment_asset_base) {
(Some((owner, project)), Some(base)) => {
let from = format!("/assets/{}/{}/", owner, project);
let to = format!("{}/", base.trim_end_matches('/'));
s.replace(&from, &to)
}
_ => s,
}
};
let Some(cache) = &state.render_script_cache else {
return rewrite_assets(html.to_string());
};
if compiled_scripts.is_empty() {
return rewrite_assets(html.to_string());
}
let mut script_tags = String::new();
for script in compiled_scripts {
if script.content_hash.trim().is_empty() {
continue;
}
let maybe_patched: Option<CompiledScript> = if let Some(ref base) = deployment_asset_base {
const LIB_FROM: &str = "/assets/libraries/";
if script.content.contains(LIB_FROM) {
use sha2::{Digest, Sha256};
let lib_to = format!("{}/libraries/", base.trim_end_matches('/'));
let new_content = script.content.replace(LIB_FROM, &lib_to);
let new_hash = hex::encode(Sha256::digest(new_content.as_bytes()));
Some(CompiledScript {
content: new_content,
content_hash: new_hash,
..script.clone()
})
} else {
None
}
} else {
None
};
let script = maybe_patched.as_ref().unwrap_or(script);
let store_res = match project_scope {
Some((owner, project)) => cache.store_scoped(owner, project, script),
None => cache.store(script),
};
if store_res.is_err() {
continue;
}
let role = if script.id == "runtime" {
"runtime"
} else {
"page"
};
let src = if let Some(ref base) = deployment_asset_base {
format!("{base}/rwe/scripts/{}", script.content_hash)
} else {
match project_scope {
Some((owner, project)) => {
format!(
"/assets/{owner}/{project}/rwe/scripts/{}",
script.content_hash
)
}
None => format!("/assets/rwe/scripts/{}", script.content_hash),
}
};
script_tags.push_str(&format!(
"<script type=\"module\" defer data-rwe-external=\"{}\" src=\"{}\"></script>",
role, src
));
}
if script_tags.is_empty() {
return rewrite_assets(html.to_string());
}
let stripped = strip_inline_runtime_bundle(html);
rewrite_assets(inject_before_body_end(&stripped, &script_tags))
}
fn strip_inline_runtime_bundle(html: &str) -> String {
let marker = "<script data-rwe-runtime=";
let Some(start) = html.find(marker) else {
return html.to_string();
};
let Some(end_rel) = html[start..].find("</script>") else {
return html.to_string();
};
let end = start + end_rel + "</script>".len();
let mut out = String::with_capacity(html.len());
out.push_str(&html[..start]);
out.push_str(&html[end..]);
out
}
fn inject_before_body_end(html: &str, chunk: &str) -> String {
if let Some(idx) = html.rfind("</body>") {
let mut out = String::with_capacity(html.len() + chunk.len());
out.push_str(&html[..idx]);
out.push_str(chunk);
out.push_str(&html[idx..]);
out
} else {
let mut out = String::with_capacity(html.len() + chunk.len());
out.push_str(html);
out.push_str(chunk);
out
}
}
async fn health_handler() -> impl IntoResponse {
Json(json!({"status": "ok", "version": APP_VERSION}))
}
async fn ready_handler() -> impl IntoResponse {
use crate::rwe::core::deno_worker;
if deno_worker::is_pool_ready() {
(StatusCode::OK, Json(json!({"status": "ready"}))).into_response()
} else {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({"status": "not_ready"})),
)
.into_response()
}
}
async fn oauth_protected_resource_handler(headers: HeaderMap) -> impl IntoResponse {
let host = headers
.get("host")
.and_then(|v| v.to_str().ok())
.unwrap_or("localhost");
let scheme = if host.starts_with("localhost") || host.starts_with("127.") {
"http"
} else {
"https"
};
Json(json!({
"resource": format!("{}://{}", scheme, host),
"bearer_methods_supported": ["header"]
}))
}
async fn oauth_authorization_server_handler(headers: HeaderMap) -> impl IntoResponse {
let host = headers
.get("host")
.and_then(|v| v.to_str().ok())
.unwrap_or("localhost");
let scheme = if host.starts_with("localhost") || host.starts_with("127.") {
"http"
} else {
"https"
};
Json(json!({
"issuer": format!("{}://{}", scheme, host),
"token_endpoint": format!("{}://{}/api/mcp/token", scheme, host),
"response_types_supported": ["token"]
}))
}
async fn root_redirect() -> Redirect {
Redirect::to(LOGIN_PATH)
}
#[derive(Debug, Clone, Default, Deserialize)]
struct PipelineRegistryQuery {
#[serde(rename = "type")]
editor_type: Option<String>,
path: Option<String>,
file: Option<String>,
line: Option<u32>,
scope: Option<String>,
id: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PipelineRegistryScope {
Path,
Project,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct PipelineListQuery {
path: Option<String>,
recursive: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct PipelineByIdQuery {
id: Option<String>,
include_source: Option<bool>,
include_active_source: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct UnifiedEditorQuery {
#[serde(rename = "type")]
editor_type: Option<String>,
path: Option<String>,
file: Option<String>,
line: Option<u32>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct TemplatePathQuery {
path: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct DocPathQuery {
path: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct DbSuiteQuery {
table: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct DbDescribeQuery {
scope: Option<String>,
schema: Option<String>,
include_system: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct DbObjectListQuery {
schema: Option<String>,
include_system: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct DbTablePreviewQuery {
table: Option<String>,
limit: Option<usize>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
struct PrepareProjectAssetsRequest {
library: String,
version: String,
entries: Vec<String>,
}
impl Default for PrepareProjectAssetsRequest {
fn default() -> Self {
Self {
library: "zeb/threejs".to_string(),
version: "0.1".to_string(),
entries: Vec::new(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
struct AssistantChatRequest {
message: String,
history: Vec<AssistantChatMessage>,
use_high_model: bool,
current_page: Option<String>,
client_time: Option<String>,
}
impl Default for AssistantChatRequest {
fn default() -> Self {
Self {
message: String::new(),
history: Vec::new(),
use_high_model: false,
current_page: None,
client_time: None,
}
}
}
#[derive(Debug, Clone, Deserialize)]
struct AssistantChatMessage {
role: String,
content: String,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct ProjectAssetChunkItem {
chunk_id: String,
module_count: usize,
modules: Vec<String>,
path: String,
url: String,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct ProjectAssetEntryItem {
entry: String,
path: String,
url: String,
chunks: Vec<ProjectAssetChunkItem>,
imports: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct ProjectAssetLibraryItem {
library: String,
version: String,
entries: Vec<ProjectAssetEntryItem>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct ProjectAssetManifest {
schema_version: String,
owner: String,
project: String,
generated_at: i64,
strategy: String,
libraries: Vec<ProjectAssetLibraryItem>,
}
async fn branding_asset(Path(asset): Path<String>) -> Response {
match asset.as_str() {
"logo.svg" => asset_response("image/svg+xml; charset=utf-8", BRAND_LOGO_SVG),
"logo.png" => asset_response("image/png", BRAND_LOGO_PNG),
_ => (StatusCode::NOT_FOUND, "asset not found").into_response(),
}
}
async fn platform_asset(Path(asset): Path<String>) -> Response {
#[cfg(debug_assertions)]
match asset.as_str() {
"main.css" => {
let css = [
fs::read_to_string(format!("{TEMPLATE_SOURCE_DIR}/styles/main.css")),
fs::read_to_string(format!(
"{TEMPLATE_SOURCE_DIR}/pages/project-studio/styles.css"
)),
]
.map(|r| r.unwrap_or_default())
.join("\n\n");
return asset_response("text/css; charset=utf-8", css.as_bytes());
}
"db-suite.css" => {
let css = fs::read_to_string(format!("{TEMPLATE_SOURCE_DIR}/styles/db-suite.css"))
.unwrap_or_default();
return asset_response("text/css; charset=utf-8", css.as_bytes());
}
"db-connections.css" => {
let css =
fs::read_to_string(format!("{TEMPLATE_SOURCE_DIR}/styles/db-connections.css"))
.unwrap_or_default();
return asset_response("text/css; charset=utf-8", css.as_bytes());
}
_ => {}
}
match asset.as_str() {
"main.css" => asset_response("text/css; charset=utf-8", PLATFORM_MAIN_CSS.as_bytes()),
"db-suite.css" => {
asset_response("text/css; charset=utf-8", PLATFORM_DB_SUITE_CSS.as_bytes())
}
"db-connections.css" => asset_response(
"text/css; charset=utf-8",
PLATFORM_DB_CONNECTIONS_CSS.as_bytes(),
),
_ => (StatusCode::NOT_FOUND, "asset not found").into_response(),
}
}
async fn node_icon_asset(Path(path): Path<String>) -> Response {
let normalized = path.trim_start_matches('/').replace('\\', "/");
#[cfg(debug_assertions)]
{
let source_path = FsPath::new(env!("CARGO_MANIFEST_DIR"))
.join("src/platform/web/assets/node-icons")
.join(&normalized);
if source_path.is_file() {
match fs::read(&source_path) {
Ok(bytes) => {
return asset_response(content_type_for_path(FsPath::new(&normalized)), &bytes);
}
Err(_) => return (StatusCode::NOT_FOUND, "asset not found").into_response(),
}
}
}
match platform_node_icon_asset(&normalized) {
Some(bytes) => asset_response(content_type_for_path(FsPath::new(&normalized)), bytes),
None => (StatusCode::NOT_FOUND, "asset not found").into_response(),
}
}
async fn library_asset(Path(path): Path<String>) -> Response {
let normalized = path.trim_start_matches('/').replace('\\', "/");
match platform_library_asset(&normalized) {
Some(bytes) => asset_response(content_type_for_path(FsPath::new(&normalized)), bytes),
None => (StatusCode::NOT_FOUND, "asset not found").into_response(),
}
}
async fn project_scoped_library_asset(
Path((_, _, path)): Path<(String, String, String)>,
) -> Response {
let normalized = path.trim_start_matches('/').replace('\\', "/");
match platform_library_asset(&normalized) {
Some(bytes) => asset_response(content_type_for_path(FsPath::new(&normalized)), bytes),
None => (StatusCode::NOT_FOUND, "asset not found").into_response(),
}
}
async fn rwe_script_asset(
State(state): State<PlatformAppState>,
Path(hash): Path<String>,
) -> Response {
if !hash
.bytes()
.all(|ch| ch.is_ascii_hexdigit() || ch == b'-' || ch == b'_')
{
return (StatusCode::BAD_REQUEST, "invalid script hash").into_response();
}
let Some(cache) = &state.render_script_cache else {
return (StatusCode::NOT_FOUND, "script cache unavailable").into_response();
};
let Some(content) = cache.get(&hash).ok().flatten() else {
return (StatusCode::NOT_FOUND, "script not found").into_response();
};
let mut resp = Response::new(Body::from(content));
*resp.status_mut() = StatusCode::OK;
resp.headers_mut().insert(
CONTENT_TYPE,
HeaderValue::from_static("text/javascript; charset=utf-8"),
);
resp.headers_mut().insert(
CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"),
);
resp
}
async fn project_rwe_script_asset(
State(state): State<PlatformAppState>,
headers: HeaderMap,
uri: Uri,
Path((owner, project, hash)): Path<(String, String, String)>,
) -> Response {
if !hash
.bytes()
.all(|ch| ch.is_ascii_hexdigit() || ch == b'-' || ch == b'_')
{
return (StatusCode::BAD_REQUEST, "invalid script hash").into_response();
}
let valid_segment = |value: &str| {
value
.bytes()
.all(|ch| ch.is_ascii_alphanumeric() || ch == b'-' || ch == b'_')
};
if !valid_segment(&owner) || !valid_segment(&project) {
return (StatusCode::BAD_REQUEST, "invalid project scope").into_response();
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::GET,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
let Some(cache) = &state.render_script_cache else {
return (StatusCode::NOT_FOUND, "script cache unavailable").into_response();
};
let scoped = cache.get_scoped(&owner, &project, &hash).ok().flatten();
let global = cache.get(&hash).ok().flatten();
let Some(content) = scoped.or(global) else {
return (StatusCode::NOT_FOUND, "script not found").into_response();
};
let mut resp = Response::new(Body::from(content));
*resp.status_mut() = StatusCode::OK;
resp.headers_mut().insert(
CONTENT_TYPE,
HeaderValue::from_static("text/javascript; charset=utf-8"),
);
resp.headers_mut().insert(
CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"),
);
resp
}
async fn project_asset(
State(state): State<PlatformAppState>,
headers: HeaderMap,
uri: Uri,
Path((owner, project, path)): Path<(String, String, String)>,
) -> Response {
let valid_segment = |value: &str| {
value
.bytes()
.all(|ch| ch.is_ascii_alphanumeric() || ch == b'-' || ch == b'_')
};
if !valid_segment(&owner) || !valid_segment(&project) {
return (StatusCode::BAD_REQUEST, "invalid project scope").into_response();
}
let normalized = path.trim_start_matches('/').replace('\\', "/");
if normalized.is_empty() || normalized.contains("..") {
return (StatusCode::BAD_REQUEST, "invalid asset path").into_response();
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::GET,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
let layout = match state.platform.file.ensure_project_layout(&owner, &project) {
Ok(layout) => layout,
Err(err) => return internal_error(err),
};
let root = layout.data_runtime_dir.join("web-assets");
let abs = root.join(&normalized);
if !abs.starts_with(&root) {
return (StatusCode::BAD_REQUEST, "invalid asset path").into_response();
}
let (serve_bytes, serve_path) = if abs.is_file() {
match std::fs::read(&abs) {
Ok(b) => (b, abs),
Err(err) => {
return internal_error(PlatformError::new("PLATFORM_ASSET_READ", err.to_string()));
}
}
} else {
let static_root = layout.repo_pipelines_dir.join("assets");
let static_abs = static_root.join(&normalized);
if !static_abs.starts_with(&static_root) || !static_abs.is_file() {
return (StatusCode::NOT_FOUND, "asset not found").into_response();
}
match std::fs::read(&static_abs) {
Ok(b) => (b, static_abs),
Err(err) => {
return internal_error(PlatformError::new(
"PLATFORM_STATIC_ASSET_READ",
err.to_string(),
));
}
}
};
let mut resp = Response::new(Body::from(serve_bytes));
*resp.status_mut() = StatusCode::OK;
if let Ok(v) = HeaderValue::from_str(content_type_for_path(&serve_path)) {
resp.headers_mut().insert(CONTENT_TYPE, v);
}
resp.headers_mut().insert(
CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"),
);
resp
}
async fn project_static_asset(
State(state): State<PlatformAppState>,
headers: HeaderMap,
uri: Uri,
Path((owner, project, path)): Path<(String, String, String)>,
) -> Response {
let valid_segment = |value: &str| {
value
.bytes()
.all(|ch| ch.is_ascii_alphanumeric() || ch == b'-' || ch == b'_')
};
if !valid_segment(&owner) || !valid_segment(&project) {
return (StatusCode::BAD_REQUEST, "invalid project scope").into_response();
}
let normalized = path.trim_start_matches('/').replace('\\', "/");
if normalized.is_empty() || normalized.contains("..") {
return (StatusCode::BAD_REQUEST, "invalid asset path").into_response();
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::GET,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
let layout = match state.platform.file.ensure_project_layout(&owner, &project) {
Ok(layout) => layout,
Err(err) => return internal_error(err),
};
let assets_root = layout.repo_pipelines_dir.join("assets");
let abs = assets_root.join(&normalized);
if !abs.starts_with(&assets_root) {
return (StatusCode::BAD_REQUEST, "invalid asset path").into_response();
}
if !abs.is_file() {
return (StatusCode::NOT_FOUND, "asset not found").into_response();
}
let bytes = match std::fs::read(&abs) {
Ok(bytes) => bytes,
Err(err) => {
return internal_error(PlatformError::new(
"PLATFORM_STATIC_ASSET_READ",
err.to_string(),
));
}
};
let mut resp = Response::new(Body::from(bytes));
*resp.status_mut() = StatusCode::OK;
if let Ok(v) = HeaderValue::from_str(content_type_for_path(&abs)) {
resp.headers_mut().insert(CONTENT_TYPE, v);
}
resp
}
async fn project_fs_serve(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Query(params): Query<std::collections::HashMap<String, String>>,
Path((owner, project, path)): Path<(String, String, String)>,
uri: Uri,
) -> Response {
let valid_segment = |value: &str| {
value
.bytes()
.all(|ch| ch.is_ascii_alphanumeric() || ch == b'-' || ch == b'_')
};
if !valid_segment(&owner) || !valid_segment(&project) {
return (StatusCode::BAD_REQUEST, "invalid project scope").into_response();
}
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::FilesRead,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::GET,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
let layout = match state.platform.file.ensure_project_layout(&owner, &project) {
Ok(layout) => layout,
Err(err) => return internal_error(err),
};
let zebfs = crate::zebfs::LocalZebFs::new(layout.files_dir);
let object = match zebfs.get(&path) {
Ok(object) => object,
Err(err) if err.code == "ZEBFS_NOT_FOUND" => {
return (StatusCode::NOT_FOUND, "object not found").into_response();
}
Err(err) if err.code == "ZEBFS_INVALID_PATH" => {
return (StatusCode::BAD_REQUEST, "invalid object path").into_response();
}
Err(err) => return internal_error(PlatformError::new(err.code, err.message)),
};
let mut resp = Response::new(Body::from(object.bytes));
*resp.status_mut() = StatusCode::OK;
let content_type = content_type_for_path(FsPath::new(&object.path));
if let Ok(v) = HeaderValue::from_str(content_type) {
resp.headers_mut().insert(CONTENT_TYPE, v);
}
let download_requested = query_flag_enabled(params.get("download").map(String::as_str));
if let Some(disposition) =
file_content_disposition(FsPath::new(&object.path), content_type, download_requested)
{
resp.headers_mut().insert(CONTENT_DISPOSITION, disposition);
}
if let Ok(v) = HeaderValue::from_str("no-cache") {
resp.headers_mut()
.insert(axum::http::header::CACHE_CONTROL, v);
}
resp
}
fn asset_response(content_type: &'static str, bytes: &[u8]) -> Response {
let mut resp = Response::new(Body::from(bytes.to_vec()));
*resp.status_mut() = StatusCode::OK;
if let Ok(v) = HeaderValue::from_str(content_type) {
resp.headers_mut().insert(CONTENT_TYPE, v);
}
resp
}
fn query_flag_enabled(value: Option<&str>) -> bool {
matches!(
value.map(str::trim),
Some("1")
| Some("true")
| Some("TRUE")
| Some("yes")
| Some("YES")
| Some("on")
| Some("ON")
)
}
fn file_content_disposition(
path: &FsPath,
content_type: &'static str,
download_requested: bool,
) -> Option<HeaderValue> {
let file_name = path.file_name()?.to_string_lossy().replace('"', "");
let disposition_kind = if download_requested {
"attachment"
} else if is_inline_file_content_type(content_type) {
"inline"
} else {
return None;
};
HeaderValue::from_str(&format!("{disposition_kind}; filename=\"{file_name}\"")).ok()
}
fn is_inline_file_content_type(content_type: &str) -> bool {
matches!(
content_type,
"text/javascript; charset=utf-8"
| "application/json; charset=utf-8"
| "application/geo+json; charset=utf-8"
| "application/xml; charset=utf-8"
| "text/css; charset=utf-8"
| "image/svg+xml; charset=utf-8"
| "image/png"
| "image/jpeg"
| "image/gif"
| "image/webp"
| "image/x-icon"
| "application/pdf"
| "video/mp4"
| "audio/mpeg"
| "text/plain; charset=utf-8"
| "text/csv; charset=utf-8"
| "text/markdown; charset=utf-8"
| "application/yaml; charset=utf-8"
)
}
async fn login_page(State(state): State<PlatformAppState>) -> Response {
match render_login_page(&state, None, StatusCode::OK) {
Ok(resp) => resp,
Err(err) => internal_error(err),
}
}
fn render_login_page(
state: &PlatformAppState,
error: Option<&str>,
status: StatusCode,
) -> Result<Response, PlatformError> {
let html = render_page(
state,
"platform-login",
"/login",
json!({
"seo": {
"title": "Zebflow Platform Login",
"description": "Login page for Zebflow platform"
},
"error": error.unwrap_or(""),
"default_identifier": state.platform.config.default_owner,
"app_version": APP_VERSION,
}),
)?;
Ok((status, Html(html)).into_response())
}
async fn login_submit(
State(state): State<PlatformAppState>,
Form(req): Form<LoginRequest>,
) -> Response {
match state.platform.auth.login(&req.identifier, &req.password) {
Ok(Some(session)) => {
let mut resp = Redirect::to(HOME_PATH).into_response();
let token = issue_session(&state, &session.owner);
let cookie = session_cookie_header(&token, SESSION_TTL_SECS);
if let Ok(v) = HeaderValue::from_str(&cookie) {
resp.headers_mut().insert(SET_COOKIE, v);
}
resp
}
Ok(None) => {
match render_login_page(
&state,
Some("invalid credentials"),
StatusCode::UNAUTHORIZED,
) {
Ok(resp) => resp,
Err(err) => internal_error(err),
}
}
Err(err) => internal_error(err),
}
}
async fn logout_submit(State(state): State<PlatformAppState>, headers: HeaderMap) -> Response {
if let Some(token) = session_token(&headers)
&& let Ok(mut sessions) = state.sessions.lock()
{
sessions.remove(&token);
}
let mut resp = Redirect::to(LOGIN_PATH).into_response();
if let Ok(v) = HeaderValue::from_str(&session_cookie_header("", 0)) {
resp.headers_mut().insert(SET_COOKIE, v);
}
resp
}
async fn home_page(State(state): State<PlatformAppState>, headers: HeaderMap) -> Response {
let Some(owner) = session_owner(&state, &headers) else {
return Redirect::to(LOGIN_PATH).into_response();
};
if let Some(source_owner) = platform_marketplace_source_owner(&state)
&& let Err(err) = state
.platform
.marketplace
.ensure_default_platform_repository(&source_owner)
{
return internal_error(err);
}
match state.platform.projects.list_projects(&owner) {
Ok(items) => {
let known_workers = state
.platform
.cluster_registry
.snapshot()
.map(|snapshot| snapshot.workers)
.unwrap_or_default();
let all_projects = state
.platform
.data
.list_users()
.unwrap_or_default()
.into_iter()
.flat_map(|user| {
state
.platform
.projects
.list_projects(&user.owner)
.unwrap_or_default()
.into_iter()
.map(move |project| (user.owner.clone(), project.project.clone()))
})
.collect::<Vec<_>>();
let mut hosted_projects: std::collections::BTreeMap<String, Vec<String>> =
std::collections::BTreeMap::new();
let local_office_id = state.platform.cluster_bootstrap.node_id();
for (project_owner, project_slug) in &all_projects {
let placement = state
.platform
.cluster_placement
.get(project_owner, project_slug)
.ok()
.flatten();
let target_key = match placement.as_ref() {
Some(value) if value.target == ProjectRuntimePlacementTarget::Worker => value
.worker_id
.clone()
.unwrap_or_else(|| "__dangling__".to_string()),
_ => local_office_id.clone(),
};
hosted_projects
.entry(target_key)
.or_default()
.push(format!("{project_owner}/{project_slug}"));
}
let now = chrono::Utc::now().timestamp();
let mut offices = vec![json!({
"id": local_office_id,
"label": state.platform.cluster_bootstrap.node_label(),
"role": if state.platform.cluster_bootstrap.is_master() {
"Managing office"
} else if state.platform.cluster_bootstrap.is_worker() {
"Office"
} else {
"Self-managed office"
},
"availability": "online",
"resource_state": "local runtime available",
"address": state.platform.cluster_bootstrap.advertise_url().map(str::to_string),
"version": APP_VERSION,
"last_seen": "Current process",
"hosted_project_count": hosted_projects
.get(&state.platform.cluster_bootstrap.node_id())
.map(|items| items.len())
.unwrap_or(0usize),
"hosted_projects": hosted_projects
.get(&state.platform.cluster_bootstrap.node_id())
.cloned()
.unwrap_or_default(),
"capabilities": vec!["resident".to_string()],
})];
let known_worker_ids = known_workers
.iter()
.map(|worker| worker.node_id.clone())
.collect::<std::collections::BTreeSet<_>>();
for worker in known_workers {
let worker_id = worker.node_id.clone();
let worker_label = worker.label.clone();
let worker_status = worker.status.clone();
let worker_base_url = worker.base_url.clone();
let heartbeat_age = if worker.last_heartbeat_at > 0 {
now.saturating_sub(worker.last_heartbeat_at)
} else {
i64::MAX
};
let availability = if heartbeat_age > 45 {
"offline"
} else {
"online"
};
let mut capabilities = Vec::new();
if worker.capabilities.supports_resident {
capabilities.push("resident".to_string());
}
if worker.capabilities.supports_k8s_job {
capabilities.push("k8s-job".to_string());
}
if worker.capabilities.supports_spark_submit {
capabilities.push("spark-submit".to_string());
}
capabilities.extend(
worker
.capabilities
.tags
.iter()
.filter(|tag| !tag.starts_with("app_version:"))
.cloned(),
);
let version = worker
.capabilities
.tags
.iter()
.find_map(|tag| tag.strip_prefix("app_version:"))
.map(str::to_string);
let last_seen = if heartbeat_age == i64::MAX {
"No heartbeat yet".to_string()
} else if heartbeat_age <= 1 {
"just now".to_string()
} else {
format!("{heartbeat_age}s ago")
};
offices.push(json!({
"id": worker_id,
"label": worker_label,
"role": "Joined office",
"availability": availability,
"resource_state": if worker_status.trim().is_empty() {
if availability == "online" {
"healthy".to_string()
} else {
"heartbeat stale".to_string()
}
} else {
worker_status
},
"address": if worker_base_url.trim().is_empty() { None::<String> } else { Some(worker_base_url) },
"version": version,
"last_seen": last_seen,
"hosted_project_count": hosted_projects.get(&worker_id).map(|items| items.len()).unwrap_or(0usize),
"hosted_projects": hosted_projects.get(&worker_id).cloned().unwrap_or_default(),
"capabilities": capabilities,
}));
}
for (office_id, projects) in &hosted_projects {
if office_id == "__dangling__"
|| office_id == &state.platform.cluster_bootstrap.node_id()
|| known_worker_ids.contains(office_id)
{
continue;
}
offices.push(json!({
"id": office_id,
"label": office_id,
"role": "Missing office",
"availability": "dangling",
"resource_state": "project placement exists but office is not registered",
"address": serde_json::Value::Null,
"version": serde_json::Value::Null,
"last_seen": "No active registration",
"hosted_project_count": projects.len(),
"hosted_projects": projects,
"capabilities": Vec::<String>::new(),
}));
}
let runtime_targets = state
.platform
.cluster_registry
.runtime_target_options()
.unwrap_or_else(|_| {
vec![crate::platform::model::ClusterRuntimeTargetOption {
value: "local".to_string(),
label: "Local office".to_string(),
description: "Run inside the current self-controlled office.".to_string(),
}]
});
let projects = items
.into_iter()
.map(|item| home_project_card_json(&state, &owner, &item))
.collect::<Vec<_>>();
match render_page(
&state,
"platform-home",
"/home",
json!({
"seo": {
"title": "Zebflow Platform Home",
"description": "Project list"
},
"owner": owner,
"projects": projects,
"marketplace_api": {
"service": "/api/platform/marketplace/service",
"repositories": format!("/api/users/{}/marketplace/repositories", owner),
"assets": format!("/api/users/{}/marketplace/assets", owner),
"install": format!("/api/users/{}/marketplace/install", owner),
},
"offices": offices,
"runtime_targets": runtime_targets,
"app_version": APP_VERSION,
}),
) {
Ok(html) => Html(html).into_response(),
Err(err) => internal_error(err),
}
}
Err(err) => internal_error(err),
}
}
async fn platform_marketplace_page(
State(state): State<PlatformAppState>,
headers: HeaderMap,
) -> Response {
let Some(owner) = session_owner(&state, &headers) else {
return Redirect::to(LOGIN_PATH).into_response();
};
let is_superadmin = is_superadmin_owner(&state, &owner);
let service = match state.platform.marketplace.get_default_service_instance() {
Ok(service) => service,
Err(err) => return internal_error(err),
};
let (source_owner, source_rows) = match visible_platform_marketplace_sources(&state, &owner) {
Ok(value) => value,
Err(err) => return internal_error(err),
};
let repositories = source_rows
.clone()
.into_iter()
.map(platform_marketplace_repository_json)
.collect::<Vec<_>>();
let remote_apps = state
.platform
.marketplace
.fetch_platform_remote_app_rows_from_repositories(&state.http_client, source_rows)
.await
.unwrap_or_default();
let local_packages = match state.platform.marketplace.list_asset_packages() {
Ok(items) => items
.into_iter()
.filter(|item| item.visibility == "public" || item.visibility == "unlisted")
.map(|item| {
json!({
"package_id": item.package_id,
"title": item.title,
"description": item.description,
"asset_kind": item.asset_kind,
"publisher_id": item.publisher_id,
"publisher_display_name": item.publisher_display_name,
"visibility": item.visibility,
"updated_at": item.updated_at,
})
})
.collect::<Vec<_>>(),
Err(_) => Vec::new(),
};
let projects = match state.platform.projects.list_projects(&owner) {
Ok(items) => items
.into_iter()
.map(|item| {
let producer_enabled =
is_project_marketplace_producer_enabled(&state, &owner, &item.project);
json!({
"owner": item.owner,
"project": item.project,
"title": item.title,
"marketplace_href": format!("/projects/{owner}/{}/marketplace", item.project),
"producer_enabled": producer_enabled,
})
})
.collect::<Vec<_>>(),
Err(err) => return internal_error(err),
};
let publishers = if is_superadmin {
match state
.platform
.marketplace
.list_publishers(&source_owner, "platform")
{
Ok(items) => items
.into_iter()
.map(marketplace_publisher_json)
.collect::<Vec<_>>(),
Err(_) => Vec::new(),
}
} else {
Vec::new()
};
let tokens = if is_superadmin {
match state.platform.marketplace.list_all_tokens() {
Ok(items) => items
.into_iter()
.map(marketplace_token_json)
.collect::<Vec<_>>(),
Err(_) => Vec::new(),
}
} else {
Vec::new()
};
let offices = platform_office_rows(&state);
match render_page(
&state,
"platform-marketplace",
"/marketplace",
json!({
"seo": {
"title": "Zebflow Marketplace",
"description": "Platform marketplace management and app explorer."
},
"owner": owner,
"source_owner": source_owner,
"is_superadmin": is_superadmin,
"service": service,
"repositories": repositories,
"remote_apps": remote_apps,
"local_packages": local_packages,
"projects": projects,
"publishers": publishers,
"tokens": tokens,
"offices": offices,
"marketplace_api": {
"service": "/api/platform/marketplace/service",
"repositories": "/api/platform/marketplace/repositories",
"assets": "/api/platform/marketplace/assets",
"install": "/api/platform/marketplace/install",
"publishers": "/api/platform/marketplace/publishers",
"tokens": "/api/platform/marketplace/tokens"
},
"app_version": APP_VERSION,
}),
) {
Ok(html) => Html(html).into_response(),
Err(err) => internal_error(err),
}
}
fn home_project_card_json(
state: &PlatformAppState,
owner: &str,
item: &crate::platform::model::PlatformProject,
) -> Value {
let item_owner = if item.owner.trim().is_empty() {
owner.to_string()
} else {
item.owner.clone()
};
let placement = state
.platform
.cluster_placement
.get(&item_owner, &item.project)
.ok()
.flatten();
let runtime_mode = placement
.as_ref()
.map(|value| value.mode.to_string())
.unwrap_or_else(|| {
state
.platform
.zebflow_cfg
.get_runtime_profile(&item_owner, &item.project)
.mode
.to_string()
});
let runtime_summary = state
.platform
.cluster_placement
.describe(placement.as_ref());
let (office_label, office_url) = match placement.as_ref() {
Some(value) if value.target == ProjectRuntimePlacementTarget::Worker => {
if let Some(worker_id) = value.worker_id.as_deref() {
match state.platform.cluster_registry.get_worker(worker_id) {
Ok(Some(worker)) => (
worker.label,
if worker.base_url.trim().is_empty() {
None
} else {
Some(worker.base_url)
},
),
_ => (worker_id.to_string(), None),
}
} else {
("Remote office".to_string(), None)
}
}
_ => (
state.platform.cluster_bootstrap.node_label(),
state
.platform
.cluster_bootstrap
.advertise_url()
.map(str::to_string),
),
};
let marketplace_cfg = state
.platform
.zebflow_cfg
.get_marketplace_distribution(&item_owner, &item.project);
let open_app_path = resolve_project_marketplace_entry_path(
&item_owner,
&item.project,
&marketplace_cfg.entry_url,
marketplace_cfg.as_app,
);
json!({
"owner": item_owner,
"project": item.project,
"title": item.title,
"path": format!("/projects/{}/{}", item_owner, item.project),
"edit_path": format!("/projects/{}/{}", item_owner, item.project),
"open_app_path": open_app_path,
"is_app": marketplace_cfg.as_app,
"runtime_mode": runtime_mode,
"runtime_summary": runtime_summary,
"office_label": office_label,
"office_url": office_url,
})
}
fn platform_office_rows(state: &PlatformAppState) -> Vec<Value> {
let mut rows = vec![json!({
"id": state.platform.cluster_bootstrap.node_id(),
"label": state.platform.cluster_bootstrap.node_label(),
})];
let workers = state
.platform
.cluster_registry
.snapshot()
.map(|snapshot| snapshot.workers)
.unwrap_or_default();
for worker in workers {
rows.push(json!({
"id": worker.node_id,
"label": worker.label,
}));
}
rows
}
fn resolve_project_marketplace_entry_path(
owner: &str,
project: &str,
entry_url: &str,
as_app: bool,
) -> Option<String> {
if !as_app {
return None;
}
let entry = entry_url.trim();
if entry.is_empty() {
return None;
}
if entry.starts_with("http://") || entry.starts_with("https://") {
return Some(entry.to_string());
}
let suffix = if entry.starts_with('/') {
entry.to_string()
} else {
format!("/{entry}")
};
Some(format!("/wh/{owner}/{project}{suffix}"))
}
fn is_project_marketplace_producer_enabled(
state: &PlatformAppState,
owner: &str,
project: &str,
) -> bool {
let cfg = state
.platform
.zebflow_cfg
.get_marketplace_distribution(owner, project);
cfg.producer_enabled && owner == state.platform.config.default_owner
}
fn can_manage_project_marketplace_producer(state: &PlatformAppState, owner: &str) -> bool {
owner == state.platform.config.default_owner
}
fn require_project_marketplace_producer(
state: &PlatformAppState,
owner: &str,
project: &str,
) -> Result<(), Response> {
if is_project_marketplace_producer_enabled(state, owner, project) {
Ok(())
} else {
Err((
StatusCode::FORBIDDEN,
Json(json!({
"ok": false,
"error": "Marketplace producer mode is disabled for this project",
"code": "MARKETPLACE_PRODUCER_DISABLED"
})),
)
.into_response())
}
}
async fn design_system_page(State(state): State<PlatformAppState>, headers: HeaderMap) -> Response {
let Some(_owner) = session_owner(&state, &headers) else {
return Redirect::to(LOGIN_PATH).into_response();
};
match render_page(
&state,
"platform-design-system",
"/dev/design-system",
json!({
"seo": {
"title": "Design System · Zebflow",
"description": "Platform UI reference for platform developers and agents",
},
}),
) {
Ok(html) => Html(html).into_response(),
Err(err) => internal_error(err),
}
}
async fn home_create_project_submit(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Form(req): Form<CreateProjectRequest>,
) -> Response {
let Some(owner) = session_owner(&state, &headers) else {
return Redirect::to(LOGIN_PATH).into_response();
};
match state
.platform
.projects
.create_or_update_project(&owner, &req)
{
Ok((project, _layout)) => {
match finalize_project_runtime_setup(&state, &owner, &project.project, &req.runtime)
.await
{
Ok(_) => Redirect::to(HOME_PATH).into_response(),
Err(err) => internal_error(err),
}
}
Err(err) => internal_error(err),
}
}
fn default_branch_main() -> String {
"main".to_string()
}
#[derive(serde::Deserialize)]
struct CloneProjectFormRequest {
project: String,
#[serde(default)]
title: String,
provider: String,
#[serde(default)]
instance_url: String,
repo_url: String,
username: String,
token: String,
#[serde(default)]
git_name: String,
#[serde(default)]
git_email: String,
#[serde(default = "default_branch_main")]
remote_branch: String,
#[serde(default)]
local_branch: String,
#[serde(default)]
runtime_mode: Option<ProjectRuntimeMode>,
#[serde(default)]
placement_worker_id: Option<String>,
}
async fn home_clone_project_submit(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Form(req): Form<CloneProjectFormRequest>,
) -> Response {
let Some(owner) = session_owner(&state, &headers) else {
return Redirect::to(LOGIN_PATH).into_response();
};
let project = crate::platform::model::slug_segment(&req.project);
if project.is_empty() {
return (StatusCode::BAD_REQUEST, "Project slug is required").into_response();
}
let repo_dir = state
.platform
.config
.data_root
.join("users")
.join(crate::platform::model::slug_segment(&owner))
.join(&project)
.join("repo");
let auth_url = match build_git_clone_auth_url(
&req.provider,
&req.instance_url,
&req.repo_url,
&req.username,
&req.token,
) {
Ok(u) => u,
Err(e) => return (StatusCode::BAD_REQUEST, e).into_response(),
};
if let Some(parent) = repo_dir.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
return internal_error(crate::platform::error::PlatformError::new(
"CLONE_MKDIR",
e.to_string(),
));
}
}
let remote_branch = {
let b = req.remote_branch.trim().to_string();
if b.is_empty() { "main".to_string() } else { b }
};
let mut clone_cmd = std::process::Command::new("git");
clone_cmd.arg("clone");
if !remote_branch.is_empty() {
clone_cmd.arg("-b").arg(&remote_branch);
}
clone_cmd.arg("--").arg(&auth_url).arg(&repo_dir);
let clone_out = clone_cmd.output();
match clone_out {
Err(e) => {
return internal_error(crate::platform::error::PlatformError::new(
"CLONE_SPAWN",
e.to_string(),
));
}
Ok(out) if !out.status.success() => {
let stderr =
scrub_token_from_str(String::from_utf8_lossy(&out.stderr).as_ref(), &req.token);
let msg = format!("git clone failed: {stderr}");
return (StatusCode::BAD_REQUEST, msg).into_response();
}
Ok(_) => {}
}
let effective_local = {
let lb = req.local_branch.trim().to_string();
if lb.is_empty() {
remote_branch.clone()
} else {
lb
}
};
if effective_local != remote_branch {
let _ = std::process::Command::new("git")
.arg("-C")
.arg(&repo_dir)
.arg("branch")
.arg("-m")
.arg(&remote_branch)
.arg(&effective_local)
.output();
}
let has_commits = std::process::Command::new("git")
.arg("-C")
.arg(&repo_dir)
.arg("rev-parse")
.arg("HEAD")
.status()
.map(|s| s.success())
.unwrap_or(false);
let title = if req.title.trim().is_empty() {
project.clone()
} else {
req.title.clone()
};
let create_req = crate::platform::model::CreateProjectRequest {
project: project.clone(),
title: Some(title.clone()),
local_branch: None, runtime: crate::platform::model::ProjectRuntimeSelectionRequest {
runtime_mode: req.runtime_mode,
placement_worker_id: req.placement_worker_id.clone(),
},
};
if let Err(e) = state
.platform
.projects
.create_or_update_project(&owner, &create_req)
{
return internal_error(e);
}
let cred_id = format!("{}-origin", req.provider);
let resolved_git = state
.platform
.git_identity
.resolve_for_actor(Some(&owner), &project);
let git_name = if req.git_name.trim().is_empty() {
resolved_git.name.clone()
} else {
req.git_name.trim().to_string()
};
let git_email = if req.git_email.trim().is_empty() {
resolved_git.email.clone()
} else {
req.git_email.trim().to_string()
};
let _ = state.platform.zebflow_cfg.update(&owner, &project, |cfg| {
cfg.configs.git.remote.credential_id = cred_id.clone();
cfg.configs.git.remote.repo_url = req.repo_url.clone();
cfg.configs.git.remote.branch = effective_local.clone();
});
let cred_title = format!("{} origin", req.provider);
let secret = serde_json::json!({
"username": req.username,
"token": req.token,
"git_name": git_name.clone(),
"git_email": git_email.clone(),
"repo_url": req.repo_url,
"instance_url": req.instance_url,
});
let upsert_req = UpsertProjectCredentialRequest {
credential_id: cred_id.clone(),
title: cred_title,
kind: req.provider.clone(),
secret,
notes: format!("Auto-created on project clone from {}", req.repo_url),
};
let _ = state
.platform
.credentials
.upsert_project_credential(&owner, &project, &upsert_req);
if !has_commits {
let project_slug = crate::platform::model::slug_segment(&project);
let identity_fallback_name = if git_name.is_empty() {
project_slug.clone()
} else {
git_name.clone()
};
let identity_fallback_email = if git_email.is_empty() {
format!("{project_slug}@zebflow.local")
} else {
git_email.clone()
};
let _ = std::process::Command::new("git")
.arg("-C")
.arg(&repo_dir)
.arg("add")
.arg("-A")
.output();
let commit_out = std::process::Command::new("git")
.arg("-c")
.arg(format!("user.name={identity_fallback_name}"))
.arg("-c")
.arg(format!("user.email={identity_fallback_email}"))
.arg("-C")
.arg(&repo_dir)
.arg("commit")
.arg("-m")
.arg("Initial commit")
.output();
if commit_out.map(|o| o.status.success()).unwrap_or(false) {
let push_out = std::process::Command::new("git")
.arg("-C")
.arg(&repo_dir)
.arg("push")
.arg("--set-upstream")
.arg(&auth_url)
.arg(format!("HEAD:{}", effective_local))
.output();
if let Ok(ref o) = push_out {
if !o.status.success() {
let stderr =
scrub_token_from_str(&String::from_utf8_lossy(&o.stderr), &req.token);
return (
StatusCode::BAD_REQUEST,
format!("Initial push to empty repo failed: {stderr}"),
)
.into_response();
}
}
}
}
if let Err(err) =
finalize_project_runtime_setup(&state, &owner, &project, &create_req.runtime).await
{
return internal_error(err);
}
Redirect::to(HOME_PATH).into_response()
}
fn build_git_clone_auth_url(
_provider: &str,
instance_url: &str,
repo_url: &str,
username: &str,
token: &str,
) -> Result<String, String> {
let enc_user = percent_encode_userinfo(username);
let enc_token = percent_encode_userinfo(token);
let base = if repo_url.starts_with("http://") || repo_url.starts_with("https://") {
repo_url.to_string()
} else if !instance_url.is_empty() {
let base = instance_url.trim_end_matches('/');
let path = repo_url.trim_start_matches('/');
format!("{}/{}", base, path)
} else {
return Err(format!("Invalid repo URL: {repo_url}"));
};
if let Some(after_scheme) = base.strip_prefix("https://") {
Ok(format!(
"https://{}:{}@{}",
enc_user, enc_token, after_scheme
))
} else if let Some(after_scheme) = base.strip_prefix("http://") {
Ok(format!(
"http://{}:{}@{}",
enc_user, enc_token, after_scheme
))
} else {
Err(format!(
"Repo URL must start with https:// or http://: {base}"
))
}
}
fn percent_encode_userinfo(s: &str) -> String {
s.chars()
.flat_map(|c| {
if c.is_ascii_alphanumeric()
|| matches!(
c,
'-' | '.' | '_' | '~' | '!' | '$' | '\'' | '(' | ')' | '*' | '+' | ',' | ';'
)
{
vec![c as u8]
} else {
format!("%{:02X}", c as u32).into_bytes()
}
})
.map(|b| b as char)
.collect()
}
fn scrub_token_from_str(s: &str, token: &str) -> String {
if token.is_empty() {
return s.to_string();
}
s.replace(token, "***")
}
fn cluster_internal_token_value(state: &PlatformAppState) -> Result<&str, PlatformError> {
state
.platform
.cluster_bootstrap
.join_token()
.ok_or_else(|| {
PlatformError::new(
"CLUSTER_TOKEN_MISSING",
"cluster join token is not configured for this process",
)
})
}
fn has_valid_cluster_token(state: &PlatformAppState, headers: &HeaderMap) -> bool {
let Some(expected) = state.platform.cluster_bootstrap.join_token() else {
return false;
};
headers
.get(INTERNAL_CLUSTER_TOKEN_HEADER)
.and_then(|value| value.to_str().ok())
.map(|value| value == expected)
.unwrap_or(false)
}
fn require_cluster_internal_token(
state: &PlatformAppState,
headers: &HeaderMap,
) -> Result<(), Response> {
if has_valid_cluster_token(state, headers) {
return Ok(());
}
Err((
StatusCode::UNAUTHORIZED,
Json(json!({
"ok": false,
"error": {
"code": "CLUSTER_TOKEN_INVALID",
"message": "cluster internal token missing or invalid"
}
})),
)
.into_response())
}
async fn finalize_project_runtime_setup(
state: &PlatformAppState,
owner: &str,
project: &str,
selection: &crate::platform::model::ProjectRuntimeSelectionRequest,
) -> Result<crate::infra::execution::placement::ProjectRuntimePlacement, PlatformError> {
let previous_placement = state.platform.cluster_placement.get(owner, project)?;
if let Some(mode) = selection.runtime_mode {
state.platform.zebflow_cfg.update(owner, project, |cfg| {
cfg.configs.runtime.mode = mode;
})?;
}
state
.platform
.cluster_runtime_sync
.refresh_local_repo_state(owner, project)?;
let runtime_profile = state
.platform
.zebflow_cfg
.get_runtime_profile(owner, project);
let placement = state.platform.cluster_placement.assign_for_project(
owner,
project,
&runtime_profile,
selection,
)?;
if placement.target == ProjectRuntimePlacementTarget::Worker {
let previous_worker_id = previous_placement
.as_ref()
.filter(|previous| previous.target == ProjectRuntimePlacementTarget::Worker)
.and_then(|previous| previous.worker_id.as_deref());
let next_worker_id = placement.worker_id.as_deref();
match (previous_worker_id, next_worker_id) {
(Some(previous_worker_id), Some(next_worker_id))
if previous_worker_id != next_worker_id =>
{
transfer_project_between_remote_workers(
state,
owner,
project,
previous_worker_id,
next_worker_id,
)
.await?;
}
_ => sync_project_to_remote_worker(state, owner, project, &placement).await?,
}
}
Ok(placement)
}
async fn transfer_project_between_remote_workers(
state: &PlatformAppState,
owner: &str,
project: &str,
from_worker_id: &str,
to_worker_id: &str,
) -> Result<(), PlatformError> {
let from_worker = state
.platform
.cluster_registry
.get_worker(from_worker_id)?
.ok_or_else(|| {
PlatformError::new(
"CLUSTER_WORKER_UNKNOWN",
format!("source office '{}' is not registered", from_worker_id),
)
})?;
let to_worker = state
.platform
.cluster_registry
.get_worker(to_worker_id)?
.ok_or_else(|| {
PlatformError::new(
"CLUSTER_WORKER_UNKNOWN",
format!("target office '{}' is not registered", to_worker_id),
)
})?;
let kind = ProjectTransferArtifactKind::Bundle;
materialize_project_to_remote_worker(state, owner, project, to_worker_id).await?;
let token = cluster_internal_token_value(state)?;
let export_url = format!(
"{}/api/internal/project-transfer/{}/{}/export/{}",
from_worker.base_url.trim_end_matches('/'),
owner,
project,
kind.key()
);
let export_response = state
.http_client
.post(export_url)
.header(INTERNAL_CLUSTER_TOKEN_HEADER, token)
.send()
.await
.map_err(|err| {
PlatformError::new(
"PROJECT_TRANSFER_EXPORT",
format!(
"failed exporting project from office '{}': {err}",
from_worker_id
),
)
})?;
if !export_response.status().is_success() {
let status = export_response.status();
let body = export_response.text().await.unwrap_or_default();
return Err(PlatformError::new(
"PROJECT_TRANSFER_EXPORT",
format!(
"office '{}' rejected project export with {status}: {body}",
from_worker_id
),
));
}
let archive = export_response
.bytes()
.await
.map_err(|err| PlatformError::new("PROJECT_TRANSFER_EXPORT", err.to_string()))?;
let import_url = format!(
"{}/api/internal/project-transfer/{}/{}/import/{}",
to_worker.base_url.trim_end_matches('/'),
owner,
project,
kind.key()
);
let import_response = state
.http_client
.post(import_url)
.header(INTERNAL_CLUSTER_TOKEN_HEADER, token)
.header(CONTENT_TYPE, "application/x-tar")
.body(archive)
.send()
.await
.map_err(|err| {
PlatformError::new(
"PROJECT_TRANSFER_IMPORT",
format!(
"failed importing project into office '{}': {err}",
to_worker_id
),
)
})?;
if import_response.status().is_success() {
return Ok(());
}
let status = import_response.status();
let body = import_response.text().await.unwrap_or_default();
Err(PlatformError::new(
"PROJECT_TRANSFER_IMPORT",
format!(
"office '{}' rejected project import with {status}: {body}",
to_worker_id
),
))
}
async fn materialize_project_to_remote_worker(
state: &PlatformAppState,
owner: &str,
project: &str,
worker_id: &str,
) -> Result<(), PlatformError> {
state
.platform
.cluster_runtime_sync
.refresh_local_repo_state(owner, project)?;
let worker = state
.platform
.cluster_registry
.get_worker(worker_id)?
.ok_or_else(|| {
PlatformError::new(
"CLUSTER_WORKER_UNKNOWN",
format!("office '{}' is not registered", worker_id),
)
})?;
if worker.base_url.trim().is_empty() {
return Err(PlatformError::new(
"CLUSTER_WORKER_UNREACHABLE",
format!("office '{}' has no advertised base URL", worker_id),
));
}
let token = cluster_internal_token_value(state)?;
let request = state
.platform
.cluster_runtime_sync
.build_materialization_request(
owner,
project,
state
.platform
.data
.list_project_credentials(owner, project)?,
state
.platform
.data
.list_project_db_connections(owner, project)?,
)?;
let url = format!(
"{}/api/internal/runtime/materialize",
worker.base_url.trim_end_matches('/')
);
let response = state
.http_client
.post(url)
.header(INTERNAL_CLUSTER_TOKEN_HEADER, token)
.json(&request)
.send()
.await
.map_err(|err| {
PlatformError::new(
"CLUSTER_WORKER_SYNC",
format!("failed syncing project to office '{}': {err}", worker_id),
)
})?;
if response.status().is_success() {
return Ok(());
}
let status = response.status();
let body = response
.text()
.await
.unwrap_or_else(|_| "office returned an unreadable error response".to_string());
Err(PlatformError::new(
"CLUSTER_WORKER_SYNC",
format!(
"office '{}' rejected runtime sync with {}: {}",
worker_id, status, body
),
))
}
async fn sync_project_to_remote_worker(
state: &PlatformAppState,
owner: &str,
project: &str,
placement: &crate::infra::execution::placement::ProjectRuntimePlacement,
) -> Result<(), PlatformError> {
state
.platform
.cluster_runtime_sync
.refresh_local_repo_state(owner, project)?;
let worker_id = placement.worker_id.as_deref().ok_or_else(|| {
PlatformError::new(
"CLUSTER_WORKER_UNKNOWN",
"placement does not include an office id",
)
})?;
let worker = state
.platform
.cluster_registry
.get_worker(worker_id)?
.ok_or_else(|| {
PlatformError::new(
"CLUSTER_WORKER_UNKNOWN",
format!("office '{}' is not registered", worker_id),
)
})?;
if worker.base_url.trim().is_empty() {
return Err(PlatformError::new(
"CLUSTER_WORKER_UNREACHABLE",
format!("office '{}' has no advertised base URL", worker_id),
));
}
let token = cluster_internal_token_value(state)?;
let request = state
.platform
.cluster_runtime_sync
.build_materialization_request(
owner,
project,
state
.platform
.data
.list_project_credentials(owner, project)?,
state
.platform
.data
.list_project_db_connections(owner, project)?,
)?;
let url = format!(
"{}/api/internal/runtime/materialize",
worker.base_url.trim_end_matches('/')
);
let response = state
.http_client
.post(url)
.header(INTERNAL_CLUSTER_TOKEN_HEADER, token)
.json(&request)
.send()
.await
.map_err(|err| {
PlatformError::new(
"CLUSTER_WORKER_SYNC",
format!("failed syncing project to office '{}': {err}", worker_id),
)
})?;
if response.status().is_success() {
return Ok(());
}
let status = response.status();
let body = response
.text()
.await
.unwrap_or_else(|_| "office returned an unreadable error response".to_string());
Err(PlatformError::new(
"CLUSTER_WORKER_SYNC",
format!(
"office '{}' rejected runtime sync with {}: {}",
worker_id, status, body
),
))
}
fn cluster_runtime_summary(
state: &PlatformAppState,
owner: &str,
project: &str,
) -> Result<Value, PlatformError> {
let placement = state.platform.cluster_placement.get(owner, project)?;
let workers = state.platform.cluster_registry.snapshot()?.workers;
Ok(json!({
"placement": placement,
"workers": workers,
"summary": state.platform.cluster_placement.describe(placement.as_ref()),
}))
}
async fn reqwest_response_to_axum(response: reqwest::Response) -> Response {
let status = response.status();
let headers = response.headers().clone();
let body = response.bytes().await.unwrap_or_default();
let mut axum_response = Response::new(Body::from(body));
*axum_response.status_mut() =
StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
for (name, value) in headers.iter() {
if name.as_str().eq_ignore_ascii_case("transfer-encoding")
|| name.as_str().eq_ignore_ascii_case("content-length")
|| name.as_str().eq_ignore_ascii_case("connection")
{
continue;
}
axum_response
.headers_mut()
.insert(name.clone(), value.clone());
}
axum_response
}
async fn forward_runtime_execute_to_worker(
state: &PlatformAppState,
owner: &str,
project: &str,
req: &ExecutePipelineRequest,
worker_id: &str,
) -> Result<Response, PlatformError> {
let worker = state
.platform
.cluster_registry
.get_worker(worker_id)?
.ok_or_else(|| {
PlatformError::new(
"CLUSTER_WORKER_UNKNOWN",
format!("office '{}' not found", worker_id),
)
})?;
let token = cluster_internal_token_value(state)?;
let url = format!(
"{}/api/internal/runtime/execute/{}/{}",
worker.base_url.trim_end_matches('/'),
owner,
project
);
let response = state
.http_client
.post(url)
.header(INTERNAL_CLUSTER_TOKEN_HEADER, token)
.json(req)
.send()
.await
.map_err(|err| PlatformError::new("CLUSTER_WORKER_PROXY", err.to_string()))?;
Ok(reqwest_response_to_axum(response).await)
}
async fn forward_runtime_webhook_to_worker(
state: &PlatformAppState,
owner: &str,
project: &str,
tail: &str,
method: &Method,
uri: &Uri,
headers: &HeaderMap,
body: &Bytes,
worker_id: &str,
) -> Result<Response, PlatformError> {
let worker = state
.platform
.cluster_registry
.get_worker(worker_id)?
.ok_or_else(|| {
PlatformError::new(
"CLUSTER_WORKER_UNKNOWN",
format!("office '{}' not found", worker_id),
)
})?;
let token = cluster_internal_token_value(state)?;
let mut url = format!(
"{}/api/internal/runtime/webhook/{}/{}",
worker.base_url.trim_end_matches('/'),
owner,
project
);
let tail = tail.trim_matches('/');
if !tail.is_empty() {
url.push('/');
url.push_str(tail);
}
if let Some(query) = uri.query() {
url.push('?');
url.push_str(query);
}
let reqwest_method =
reqwest::Method::from_bytes(method.as_str().as_bytes()).map_err(|err| {
PlatformError::new(
"CLUSTER_WORKER_PROXY",
format!("unsupported HTTP method '{}': {err}", method.as_str()),
)
})?;
let mut request = state
.http_client
.request(reqwest_method, url)
.header(INTERNAL_CLUSTER_TOKEN_HEADER, token);
for (name, value) in headers.iter() {
if name.as_str().eq_ignore_ascii_case("host")
|| name.as_str().eq_ignore_ascii_case("content-length")
|| name
.as_str()
.eq_ignore_ascii_case(INTERNAL_CLUSTER_TOKEN_HEADER)
{
continue;
}
request = request.header(name, value);
}
let response = request
.body(body.clone())
.send()
.await
.map_err(|err| PlatformError::new("CLUSTER_WORKER_PROXY", err.to_string()))?;
Ok(reqwest_response_to_axum(response).await)
}
fn parse_project_transfer_kind(raw: &str) -> Result<ProjectTransferArtifactKind, Response> {
match raw.trim().to_ascii_lowercase().as_str() {
"bundle" => Ok(ProjectTransferArtifactKind::Bundle),
"files" => Ok(ProjectTransferArtifactKind::Files),
_ => Err((
StatusCode::BAD_REQUEST,
Json(json!({
"ok": false,
"error": {
"code": "PROJECT_TRANSFER_KIND_INVALID",
"message": "kind must be 'bundle' or 'files'"
}
})),
)
.into_response()),
}
}
fn remote_project_worker_id(
state: &PlatformAppState,
owner: &str,
project: &str,
) -> Result<Option<String>, PlatformError> {
let placement = state.platform.cluster_placement.get(owner, project)?;
let worker_id = match placement {
Some(record) if record.target == ProjectRuntimePlacementTarget::Worker => record.worker_id,
_ => None,
};
if worker_id.as_deref() == local_office_id(state).as_deref() {
return Ok(None);
}
Ok(worker_id)
}
async fn forward_project_api_request_to_worker(
state: &PlatformAppState,
uri: &Uri,
method: &Method,
headers: &HeaderMap,
body: Bytes,
worker_id: &str,
) -> Result<Response, PlatformError> {
let worker = state
.platform
.cluster_registry
.get_worker(worker_id)?
.ok_or_else(|| {
PlatformError::new(
"CLUSTER_WORKER_UNKNOWN",
format!("office '{}' not found", worker_id),
)
})?;
let token = cluster_internal_token_value(state)?;
let mut url = format!("{}{}", worker.base_url.trim_end_matches('/'), uri.path());
if let Some(query) = uri.query() {
url.push('?');
url.push_str(query);
}
let reqwest_method =
reqwest::Method::from_bytes(method.as_str().as_bytes()).map_err(|err| {
PlatformError::new(
"CLUSTER_WORKER_PROXY",
format!("unsupported HTTP method '{}': {err}", method.as_str()),
)
})?;
let mut request = state
.http_client
.request(reqwest_method, url)
.header(INTERNAL_CLUSTER_TOKEN_HEADER, token);
for (name, value) in headers.iter() {
if name.as_str().eq_ignore_ascii_case("host")
|| name.as_str().eq_ignore_ascii_case("content-length")
|| name.as_str().eq_ignore_ascii_case("cookie")
|| name
.as_str()
.eq_ignore_ascii_case(INTERNAL_CLUSTER_TOKEN_HEADER)
{
continue;
}
request = request.header(name, value);
}
let response = request
.body(body)
.send()
.await
.map_err(|err| PlatformError::new("CLUSTER_WORKER_PROXY", err.to_string()))?;
Ok(reqwest_response_to_axum(response).await)
}
async fn forward_project_json_request_to_worker<T: serde::Serialize>(
state: &PlatformAppState,
uri: &Uri,
headers: &HeaderMap,
method: Method,
payload: &T,
worker_id: &str,
) -> Result<Response, PlatformError> {
forward_project_api_request_to_worker(
state,
uri,
&method,
headers,
Bytes::from(serde_json::to_vec(payload)?),
worker_id,
)
.await
}
async fn maybe_forward_project_page_to_worker(
state: &PlatformAppState,
headers: &HeaderMap,
uri: &Uri,
owner: &str,
project: &str,
) -> Result<Option<Response>, PlatformError> {
let Some(worker_id) = remote_project_worker_id(state, owner, project)? else {
return Ok(None);
};
forward_project_page_request_to_worker(state, uri, headers, owner, project, &worker_id)
.await
.map(Some)
}
async fn forward_project_page_request_to_worker(
state: &PlatformAppState,
uri: &Uri,
headers: &HeaderMap,
owner: &str,
project: &str,
worker_id: &str,
) -> Result<Response, PlatformError> {
let worker = state
.platform
.cluster_registry
.get_worker(worker_id)?
.ok_or_else(|| {
PlatformError::new(
"CLUSTER_WORKER_UNKNOWN",
format!("office '{}' not found", worker_id),
)
})?;
let token = cluster_internal_token_value(state)?;
let mut url = format!("{}{}", worker.base_url.trim_end_matches('/'), uri.path());
if let Some(query) = uri.query() {
url.push('?');
url.push_str(query);
}
let mut request = state
.http_client
.get(url)
.header(INTERNAL_CLUSTER_TOKEN_HEADER, token);
for (name, value) in headers.iter() {
if name.as_str().eq_ignore_ascii_case("host")
|| name.as_str().eq_ignore_ascii_case("content-length")
|| name.as_str().eq_ignore_ascii_case("cookie")
|| name
.as_str()
.eq_ignore_ascii_case(INTERNAL_CLUSTER_TOKEN_HEADER)
{
continue;
}
request = request.header(name, value);
}
let response = request
.send()
.await
.map_err(|err| PlatformError::new("CLUSTER_WORKER_PROXY", err.to_string()))?;
let status = response.status();
let headers = response.headers().clone();
let is_html = headers
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.to_ascii_lowercase().starts_with("text/html"));
let body = response.bytes().await.unwrap_or_default();
let body = if is_html {
let html = String::from_utf8_lossy(&body);
Bytes::from(html.replace(
"/assets/rwe/scripts/",
&format!("/assets/{owner}/{project}/rwe/scripts/"),
))
} else {
body
};
let mut axum_response = Response::new(Body::from(body));
*axum_response.status_mut() =
StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
for (name, value) in headers.iter() {
if name.as_str().eq_ignore_ascii_case("transfer-encoding")
|| name.as_str().eq_ignore_ascii_case("content-length")
|| name.as_str().eq_ignore_ascii_case("connection")
{
continue;
}
axum_response
.headers_mut()
.insert(name.clone(), value.clone());
}
Ok(axum_response)
}
async fn list_project_docs_for_page(
state: &PlatformAppState,
owner: &str,
project: &str,
) -> Result<Vec<ProjectDocItem>, PlatformError> {
let Some(worker_id) = remote_project_worker_id(state, owner, project)? else {
return state.platform.projects.list_project_docs(owner, project);
};
let worker = state
.platform
.cluster_registry
.get_worker(&worker_id)?
.ok_or_else(|| {
PlatformError::new(
"CLUSTER_WORKER_UNKNOWN",
format!("office '{}' not found", worker_id),
)
})?;
let token = cluster_internal_token_value(state)?;
let url = format!(
"{}/api/projects/{}/{}/docs",
worker.base_url.trim_end_matches('/'),
owner,
project
);
let response = state
.http_client
.get(url)
.header(INTERNAL_CLUSTER_TOKEN_HEADER, token)
.send()
.await
.map_err(|err| PlatformError::new("CLUSTER_WORKER_PROXY", err.to_string()))?;
let status = response.status();
let body = response
.bytes()
.await
.map_err(|err| PlatformError::new("CLUSTER_WORKER_PROXY", err.to_string()))?;
if !status.is_success() {
return Err(PlatformError::new(
"CLUSTER_WORKER_PROXY",
format!(
"office '{}' rejected docs list with {}: {}",
worker_id,
status,
String::from_utf8_lossy(&body)
),
));
}
let payload: Value = serde_json::from_slice(&body)?;
serde_json::from_value(
payload
.get("items")
.cloned()
.unwrap_or(Value::Array(vec![])),
)
.map_err(PlatformError::from)
}
async fn read_project_doc_for_page(
state: &PlatformAppState,
owner: &str,
project: &str,
path: &str,
) -> Result<String, PlatformError> {
let Some(worker_id) = remote_project_worker_id(state, owner, project)? else {
return state
.platform
.projects
.read_project_doc(owner, project, path);
};
let worker = state
.platform
.cluster_registry
.get_worker(&worker_id)?
.ok_or_else(|| {
PlatformError::new(
"CLUSTER_WORKER_UNKNOWN",
format!("office '{}' not found", worker_id),
)
})?;
let token = cluster_internal_token_value(state)?;
let url = format!(
"{}/api/projects/{}/{}/docs/file",
worker.base_url.trim_end_matches('/'),
owner,
project
);
let response = state
.http_client
.get(url)
.query(&[("path", path)])
.header(INTERNAL_CLUSTER_TOKEN_HEADER, token)
.send()
.await
.map_err(|err| PlatformError::new("CLUSTER_WORKER_PROXY", err.to_string()))?;
let status = response.status();
let body = response
.bytes()
.await
.map_err(|err| PlatformError::new("CLUSTER_WORKER_PROXY", err.to_string()))?;
if !status.is_success() {
return Err(PlatformError::new(
"CLUSTER_WORKER_PROXY",
format!(
"office '{}' rejected doc read '{}' with {}: {}",
worker_id,
path,
status,
String::from_utf8_lossy(&body)
),
));
}
let payload: Value = serde_json::from_slice(&body)?;
Ok(payload
.get("doc")
.and_then(|doc| doc.get("content"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_string())
}
async fn cluster_worker_registration_loop(state: PlatformAppState) {
let Some(master_url) = state
.platform
.cluster_bootstrap
.master_url()
.map(str::to_string)
else {
eprintln!("Zebflow office: controller_url missing; office registration loop disabled");
return;
};
let Ok(token) = cluster_internal_token_value(&state).map(str::to_string) else {
eprintln!("Zebflow office: cluster token missing; office registration loop disabled");
return;
};
let node_id = state.platform.cluster_bootstrap.node_id();
let label = state.platform.cluster_bootstrap.node_label();
let Some(base_url) = state
.platform
.cluster_bootstrap
.advertise_url()
.map(str::to_string)
else {
eprintln!("Zebflow office: advertise_url missing; office registration loop disabled");
return;
};
let register_url = format!(
"{}/api/internal/cluster/workers/register",
master_url.trim_end_matches('/')
);
let heartbeat_url = format!(
"{}/api/internal/cluster/workers/heartbeat",
master_url.trim_end_matches('/')
);
let capabilities = crate::infra::execution::runner::RunnerCapabilities {
tags: vec![format!("app_version:{APP_VERSION}")],
supports_resident: true,
..Default::default()
};
loop {
let register_request = ClusterWorkerRegisterRequest {
node_id: node_id.clone(),
label: label.clone(),
base_url: base_url.clone(),
capabilities: capabilities.clone(),
};
if let Err(err) = state
.http_client
.post(®ister_url)
.header(INTERNAL_CLUSTER_TOKEN_HEADER, &token)
.json(®ister_request)
.send()
.await
{
eprintln!("Zebflow office register failed: {err}");
tokio::time::sleep(Duration::from_secs(10)).await;
continue;
}
let heartbeat_request = ClusterWorkerHeartbeatRequest {
node_id: node_id.clone(),
status: "online".to_string(),
base_url: base_url.clone(),
capabilities: capabilities.clone(),
};
if let Err(err) = state
.http_client
.post(&heartbeat_url)
.header(INTERNAL_CLUSTER_TOKEN_HEADER, &token)
.json(&heartbeat_request)
.send()
.await
{
eprintln!("Zebflow office heartbeat failed: {err}");
}
tokio::time::sleep(Duration::from_secs(15)).await;
}
}
async fn project_root_page(
State(state): State<PlatformAppState>,
headers: HeaderMap,
uri: Uri,
Path((owner, project)): Path<(String, String)>,
Query(query): Query<PipelineRegistryQuery>,
) -> Response {
render_project_pipelines_with_tab(
state,
headers,
uri,
owner,
project,
"registry",
query.path.as_deref(),
None,
query.editor_type.as_deref(),
query.file.as_deref(),
query.line,
)
.await
}
async fn project_pipelines_page(
State(state): State<PlatformAppState>,
headers: HeaderMap,
uri: Uri,
Path((owner, project, tab)): Path<(String, String, String)>,
Query(query): Query<PipelineRegistryQuery>,
) -> Response {
render_project_pipelines_with_tab(
state,
headers,
uri,
owner,
project,
&tab,
query.path.as_deref(),
query.id.as_deref(),
query.editor_type.as_deref(),
query.file.as_deref(),
query.line,
)
.await
}
async fn render_project_pipelines_with_tab(
state: PlatformAppState,
headers: HeaderMap,
uri: Uri,
owner: String,
project: String,
tab: &str,
registry_path: Option<&str>,
editor_id: Option<&str>,
registry_type: Option<&str>,
registry_file: Option<&str>,
registry_line: Option<u32>,
) -> Response {
if let Err(response) = require_project_page_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesRead,
) {
return response;
}
match maybe_forward_project_page_to_worker(&state, &headers, &uri, &owner, &project).await {
Ok(Some(response)) => return response,
Ok(None) => {}
Err(err) => return internal_error(err),
}
let is_registry = tab == "registry";
let is_editor = tab == "editor";
if is_registry {
let query = UnifiedEditorQuery {
editor_type: registry_type.map(str::to_string),
path: registry_path.map(str::to_string),
file: registry_file.map(str::to_string),
line: registry_line,
};
return render_project_editor(state, headers, uri, owner, project, query, "registry").await;
}
let tab_payload = if is_registry {
Some((
"registry",
"Pipeline Registry",
"Browse pipelines by project path.",
Vec::new(),
))
} else if is_editor {
Some((
"editor",
"Pipeline Editor",
"Create and edit pipeline graph + node configuration.",
Vec::new(),
))
} else {
pipeline_tab_payload(tab)
};
let Some((tab_key, tab_title, tab_desc, items)) = tab_payload else {
return (
StatusCode::NOT_FOUND,
Html("pipeline tab not found".to_string()),
)
.into_response();
};
match state.platform.projects.get_project(&owner, &project) {
Ok(Some(info)) => {
let nav = nav_classes(&owner, &project, "pipelines", Some(tab_key));
let route = format!("/projects/{owner}/{project}/pipelines/{tab_key}");
let editor_base = format!("/projects/{owner}/{project}/pipelines/registry");
let route_base = format!("/projects/{owner}/{project}/pipelines/registry");
let pipeline_items = if is_registry || is_editor {
items
} else {
let trigger_filter = match tab_key {
"webhooks" => Some("webhook"),
"schedules" => Some("schedule"),
"manual" => Some("manual"),
"functions" => Some("function"),
_ => None,
};
match state
.platform
.projects
.list_pipeline_meta_rows(&owner, &project)
{
Ok(rows) => rows
.into_iter()
.filter(|meta| {
trigger_filter
.map(|wanted| meta.trigger_kind.eq_ignore_ascii_case(wanted))
.unwrap_or(true)
})
.map(|meta| {
let file_rel_path = meta.file_rel_path.clone();
let virtual_path = crate::platform::model::normalize_virtual_path(
&meta.virtual_path,
);
let (webhook_path, webhook_method) = if tab_key == "webhooks" {
match state.platform.projects.read_pipeline_source(
&owner,
&project,
&file_rel_path,
) {
Ok(source) => crate::platform::services::project::first_webhook_trigger_from_source(&source)
.map_or((None, None), |(path, method)| {
(Some(path), Some(method))
}),
Err(_) => (None, None),
}
} else {
(None, None)
};
json!({
"name": meta.name,
"title": meta.title,
"description": meta.description,
"trigger_kind": meta.trigger_kind,
"virtual_path": virtual_path,
"file_rel_path": file_rel_path,
"editor_href": format!("{editor_base}?type=pipeline&path={virtual_path}&file={file_rel_path}"),
"webhook_path": webhook_path.unwrap_or_else(|| "/".to_string()),
"webhook_method": webhook_method.unwrap_or_else(|| "GET".to_string()),
})
})
.collect::<Vec<_>>(),
Err(err) => return internal_error(err),
}
};
let registry_git_map: std::collections::HashMap<String, String> = state
.platform
.projects
.list_repo_git_status(&owner, &project)
.unwrap_or_default()
.into_iter()
.map(|item| (item.rel_path, item.code))
.collect();
let registry = if is_registry {
let current_registry_path = registry_path.unwrap_or("/");
match state.platform.projects.list_pipeline_registry(
&owner,
&project,
current_registry_path,
&route_base,
&editor_base,
) {
Ok(mut listing) => {
for item in &mut listing.pipelines {
item.git_status = registry_git_map.get(&item.file_rel_path).cloned();
}
for item in &mut listing.files {
item.git_status = registry_git_map.get(&item.rel_path).cloned();
}
let current_path = listing.current_path;
let breadcrumbs = listing.breadcrumbs;
let folders = listing.folders;
let template_files: Vec<Value> = listing
.files
.into_iter()
.map(|f| {
let template_path = f
.rel_path
.strip_prefix("pipelines/")
.unwrap_or(&f.rel_path)
.to_string();
let git_status = registry_git_map.get(&f.rel_path).cloned();
json!({
"name": f.name,
"rel_path": template_path,
"kind": f.kind,
"edit_href": f.edit_href,
"git_status": git_status,
})
})
.collect();
let pipelines = listing
.pipelines
.into_iter()
.map(|item| {
let file_id = item.file_rel_path.clone();
json!({
"name": item.name,
"title": item.title,
"description": item.description,
"trigger_kind": item.trigger_kind,
"file_rel_path": item.file_rel_path,
"is_active": item.is_active,
"has_draft": item.has_draft,
"git_status": item.git_status,
"edit_href": format!("/projects/{owner}/{project}/editor?type=pipeline&path={current_path}&file={file_id}")
})
})
.collect::<Vec<_>>();
let has_folders = !folders.is_empty();
let has_pipelines = !pipelines.is_empty();
let has_files = !template_files.is_empty();
let docs_items = state
.platform
.projects
.list_project_docs(&owner, &project)
.unwrap_or_default()
.into_iter()
.filter(|d| d.kind == "file")
.map(|d| {
let encoded = d.path.replace(' ', "%20");
let href = format!(
"/projects/{owner}/{project}/editor?type=doc&file={}",
encoded
);
json!({ "name": d.name, "path": d.path, "href": href })
})
.collect::<Vec<_>>();
json!({
"current_path": current_path,
"editor_href": format!("{editor_base}?path={current_path}"),
"breadcrumbs": breadcrumbs,
"folders": folders,
"pipelines": pipelines,
"files": template_files,
"docs": docs_items,
"has_folders": has_folders,
"has_pipelines": has_pipelines,
"has_files": has_files,
"api": {
"delete": format!("/api/projects/{owner}/{project}/pipelines/definition"),
"delete_template": format!("/api/projects/{owner}/{project}/templates/file"),
"git_status": format!("/api/projects/{owner}/{project}/templates/git-status"),
"git_commit": format!("/api/projects/{owner}/{project}/git/commit"),
}
})
}
Err(err) => return internal_error(err),
}
} else {
json!({
"current_path": "/",
"breadcrumbs": [],
"folders": [],
"pipelines": [],
"files": []
})
};
let editor_payload = if is_editor {
let all_rows = match state
.platform
.projects
.list_pipeline_meta_rows(&owner, &project)
{
Ok(rows) => rows,
Err(err) => return internal_error(err),
};
let wanted_id = editor_id
.map(str::trim)
.filter(|raw| !raw.is_empty())
.map(str::to_string)
.or_else(|| all_rows.first().map(|meta| meta.file_rel_path.clone()));
let selected_any = wanted_id
.as_deref()
.and_then(|id| all_rows.iter().find(|row| row.file_rel_path == id))
.cloned()
.or_else(|| all_rows.first().cloned());
let scope_path = registry_path
.map(crate::platform::model::normalize_virtual_path)
.unwrap_or_else(|| {
selected_any
.as_ref()
.map(|meta| {
crate::platform::model::normalize_virtual_path(&meta.virtual_path)
})
.unwrap_or_else(|| "/".to_string())
});
let rows = all_rows
.iter()
.filter(|meta| {
crate::platform::model::normalize_virtual_path(&meta.virtual_path)
== scope_path
})
.cloned()
.collect::<Vec<_>>();
let pipeline_logging_defaults = json!({
"max_invocations": state
.platform
.zebflow_cfg
.read_or_default(&owner, &project)
.configs
.pipelines
.logging
.effective_max_invocations()
});
let selected = wanted_id
.as_deref()
.and_then(|id| rows.iter().find(|row| row.file_rel_path == id))
.cloned()
.or_else(|| rows.first().cloned());
let selected_id_effective =
selected.as_ref().map(|meta| meta.file_rel_path.clone());
let mut lock_map = std::collections::HashMap::new();
for meta in &rows {
let locked = match state.platform.projects.read_pipeline_source(
&owner,
&project,
&meta.file_rel_path,
) {
Ok(source) => pipeline_source_is_locked(&source),
Err(_) => false,
};
lock_map.insert(meta.file_rel_path.clone(), locked);
}
let (source, graph_json, parse_error, hit_stats, selected_locked) =
if let Some(meta) = &selected {
let source = match state.platform.projects.read_pipeline_source(
&owner,
&project,
&meta.file_rel_path,
) {
Ok(source) => source,
Err(err) => return internal_error(err),
};
let (graph_json, parse_error) = match serde_json::from_str::<Value>(&source)
{
Ok(value) => (value, Value::Null),
Err(err) => (
Value::Null,
Value::String(format!("pipeline JSON parse error: {err}")),
),
};
let stats =
state
.platform
.pipeline_hits
.get(&owner, &project, &meta.file_rel_path);
let locked = lock_map
.get(&meta.file_rel_path)
.copied()
.unwrap_or_else(|| pipeline_source_is_locked(&source));
(
Value::String(source),
graph_json,
parse_error,
json!(stats),
Value::Bool(locked),
)
} else {
(
Value::Null,
Value::Null,
Value::Null,
Value::Null,
Value::Bool(false),
)
};
let node_catalog = state
.platform
.node_registry
.merged_definitions(&owner, &project)
.into_iter()
.map(|def| {
json!({
"kind": def.kind,
"title": def.title,
"description": def.description,
"input_pins": def.input_pins,
"output_pins": def.output_pins,
"input_schema": def.input_schema,
"output_schema": def.output_schema
})
})
.collect::<Vec<_>>();
let mut folder_counts = std::collections::BTreeMap::<String, usize>::new();
for meta in &all_rows {
let vpath = crate::platform::model::normalize_virtual_path(&meta.virtual_path);
*folder_counts.entry(vpath).or_insert(0) += 1;
}
if let Ok(workspace) = state
.platform
.projects
.list_template_workspace(&owner, &project)
{
for item in &workspace.items {
if item.kind == "file" && !item.rel_path.ends_with(".zf.json") {
let parent = std::path::Path::new(&item.rel_path)
.parent()
.and_then(|p| p.to_str())
.unwrap_or("");
let vpath = crate::platform::model::normalize_virtual_path(parent);
*folder_counts.entry(vpath).or_insert(0) += 1;
}
}
}
let scope_folders = folder_counts
.into_iter()
.map(|(vpath, count)| {
json!({
"virtual_path": vpath,
"count": count,
"href": format!("{editor_base}?path={vpath}")
})
})
.collect::<Vec<_>>();
let mut scope_hierarchy = vec![json!({
"name": "root",
"virtual_path": "/",
"href": format!("{editor_base}?path=/")
})];
if scope_path != "/" {
let mut accum = String::new();
for seg in scope_path.trim_start_matches('/').split('/') {
if seg.trim().is_empty() {
continue;
}
accum.push('/');
accum.push_str(seg);
scope_hierarchy.push(json!({
"name": seg,
"virtual_path": accum,
"href": format!("{editor_base}?path={accum}")
}));
}
}
let pipelines = rows
.iter()
.map(|meta| {
let file_id = meta.file_rel_path.clone();
let is_active = meta
.active_hash
.as_deref()
.map(|hash| hash == meta.hash)
.unwrap_or(false);
let has_draft = meta
.active_hash
.as_deref()
.map(|hash| hash != meta.hash)
.unwrap_or(false);
let locked = lock_map.get(&file_id).copied().unwrap_or(false);
json!({
"id": file_id,
"name": meta.name,
"title": meta.title,
"description": meta.description,
"trigger_kind": meta.trigger_kind,
"virtual_path": meta.virtual_path,
"file_rel_path": meta.file_rel_path,
"is_active": is_active,
"has_draft": has_draft,
"is_locked": locked,
"status_label": if is_active { "active" } else if has_draft { "draft" } else { "inactive" },
"editor_href": format!("{editor_base}?type=pipeline&path={scope_path}&file={file_id}")
})
})
.collect::<Vec<_>>();
let editor_template_files: Vec<Value> =
match state.platform.projects.list_pipeline_registry(
&owner,
&project,
&scope_path,
&route_base,
&editor_base,
) {
Ok(listing) => listing
.files
.into_iter()
.map(|f| {
let template_path = f
.rel_path
.strip_prefix("pipelines/")
.unwrap_or(&f.rel_path)
.to_string();
let git_status = registry_git_map.get(&f.rel_path).cloned();
json!({
"name": f.name,
"rel_path": template_path,
"kind": f.kind,
"template_path": template_path,
"git_status": git_status,
})
})
.collect(),
Err(_) => Vec::new(),
};
json!({
"scope_path": scope_path,
"scope_hierarchy": scope_hierarchy,
"scope_folders": scope_folders,
"selected_id": selected_id_effective,
"selected_locked": selected_locked,
"selected_meta": selected,
"selected_source": source,
"selected_graph": graph_json,
"parse_error": parse_error,
"hits": hit_stats,
"logging_defaults": pipeline_logging_defaults,
"pipelines": pipelines,
"template_files": editor_template_files,
"nodes": node_catalog,
"api": {
"registry": format!("/api/projects/{owner}/{project}/pipelines/registry"),
"list": format!("/api/projects/{owner}/{project}/pipelines"),
"by_id": format!("/api/projects/{owner}/{project}/pipelines/by-id"),
"definition": format!("/api/projects/{owner}/{project}/pipelines/definition"),
"activate": format!("/api/projects/{owner}/{project}/pipelines/activate"),
"deactivate": format!("/api/projects/{owner}/{project}/pipelines/deactivate"),
"execute": format!("/api/projects/{owner}/{project}/pipelines/execute"),
"hits": format!("/api/projects/{owner}/{project}/pipelines/hits"),
"invocations": format!("/api/projects/{owner}/{project}/pipelines/invocations"),
"nodes": format!("/api/projects/{owner}/{project}/nodes"),
"credentials": format!("/api/projects/{owner}/{project}/credentials"),
"templates_workspace": format!("/api/projects/{owner}/{project}/templates/workspace"),
"template_file": format!("/api/projects/{owner}/{project}/templates/file"),
"template_save": format!("/api/projects/{owner}/{project}/templates/file"),
"template_outline": format!("/api/projects/{owner}/{project}/templates/outline"),
},
"graphui": {
"runtime_src": "/assets/libraries/zeb/graphui/0.1/runtime/graphui.bundle.mjs",
"package_label": "zeb/graphui@0.1"
}
})
} else {
Value::Null
};
let input = json!({
"seo": {
"title": format!("{} - Pipelines", info.title),
"description": "Pipeline management"
},
"owner": info.owner,
"project": info.project,
"title": info.title,
"project_href": format!("/projects/{owner}/{project}"),
"current_menu": format!("Pipelines / {tab_title}"),
"page_title": tab_title,
"page_subtitle": tab_desc,
"pipeline_items": pipeline_items,
"is_registry": is_registry,
"is_editor": is_editor,
"is_non_registry": !is_registry,
"is_webhooks": tab_key == "webhooks",
"registry": registry,
"editor": editor_payload,
"nav": nav,
});
match render_page(&state, "platform-project-pipelines", &route, input) {
Ok(html) => Html(html).into_response(),
Err(err) => internal_error(err),
}
}
Ok(None) => (StatusCode::NOT_FOUND, Html("project not found".to_string())).into_response(),
Err(err) => internal_error(err),
}
}
async fn project_editor_page(
State(state): State<PlatformAppState>,
headers: HeaderMap,
uri: Uri,
Path((owner, project)): Path<(String, String)>,
Query(query): Query<UnifiedEditorQuery>,
) -> Response {
render_project_editor(state, headers, uri, owner, project, query, "editor").await
}
fn derive_scope_from_file_path(file: &str) -> String {
let stripped = file.strip_prefix("pipelines/").unwrap_or(file);
let parent = stripped.rsplit_once('/').map(|(dir, _)| dir).unwrap_or("");
if parent.is_empty() {
"/".to_string()
} else {
crate::platform::model::normalize_virtual_path(parent)
}
}
fn is_docs_virtual_path(path: &str) -> bool {
path == "/docs" || path.starts_with("/docs/")
}
fn docs_scope_rel_path(path: &str) -> Option<String> {
if path == "/docs" {
Some(String::new())
} else {
path.strip_prefix("/docs/")
.map(|rest| rest.trim_matches('/').to_string())
}
}
fn docs_parent_virtual_path(rel_path: &str) -> String {
let parent = std::path::Path::new(rel_path)
.parent()
.and_then(|p| p.to_str())
.unwrap_or("")
.trim_matches('/');
if parent.is_empty() {
"/docs".to_string()
} else {
format!("/docs/{parent}")
}
}
async fn render_project_editor(
state: PlatformAppState,
headers: HeaderMap,
uri: Uri,
owner: String,
project: String,
query: UnifiedEditorQuery,
nav_sub: &str,
) -> Response {
if let Err(response) = require_project_page_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesRead,
) {
return response;
}
match maybe_forward_project_page_to_worker(&state, &headers, &uri, &owner, &project).await {
Ok(Some(response)) => return response,
Ok(None) => {}
Err(err) => return internal_error(err),
}
let project_info = match state.platform.projects.get_project(&owner, &project) {
Ok(Some(info)) => info,
Ok(None) => {
return (StatusCode::NOT_FOUND, Html("project not found".to_string())).into_response();
}
Err(err) => return internal_error(err),
};
let editor_base = if nav_sub == "registry" {
format!("/projects/{owner}/{project}/pipelines/registry")
} else {
format!("/projects/{owner}/{project}/editor")
};
let route_base = format!("/projects/{owner}/{project}/pipelines/registry");
let route = editor_base.clone();
let editor_type = query.editor_type.as_deref().unwrap_or("").to_string();
let file_param = query
.file
.as_deref()
.filter(|s| !s.trim().is_empty())
.map(str::to_string);
let scope_path = if let Some(path) = query.path.as_deref().filter(|s| !s.trim().is_empty()) {
crate::platform::model::normalize_virtual_path(path)
} else if let Some(ref file) = file_param {
if editor_type == "doc" {
docs_parent_virtual_path(file)
} else {
derive_scope_from_file_path(file)
}
} else {
"/".to_string()
};
let is_docs_scope = is_docs_virtual_path(&scope_path);
let docs_scope_rel = docs_scope_rel_path(&scope_path).unwrap_or_default();
let docs_items = if is_docs_scope || scope_path == "/" {
match list_project_docs_for_page(&state, &owner, &project).await {
Ok(items) => items,
Err(err) => return internal_error(err),
}
} else {
vec![]
};
let git_map: std::collections::HashMap<String, String> = state
.platform
.projects
.list_repo_git_status(&owner, &project)
.unwrap_or_default()
.into_iter()
.map(|item| (item.rel_path, item.code))
.collect();
let listing = if !is_docs_scope {
match state.platform.projects.list_pipeline_registry(
&owner,
&project,
&scope_path,
&route_base,
&editor_base,
) {
Ok(l) => l,
Err(err) => return internal_error(err),
}
} else {
crate::platform::model::PipelineRegistryListing {
current_path: "/docs".to_string(),
breadcrumbs: vec![],
folders: vec![],
pipelines: vec![],
files: vec![],
}
};
let all_rows = match state
.platform
.projects
.list_pipeline_meta_rows(&owner, &project)
{
Ok(rows) => rows,
Err(err) => return internal_error(err),
};
let mut scope_hierarchy = vec![json!({
"name": "root",
"href": format!("{editor_base}?path=/")
})];
if scope_path != "/" {
let mut accum = String::new();
for seg in scope_path.trim_start_matches('/').split('/') {
if seg.trim().is_empty() {
continue;
}
accum.push('/');
accum.push_str(seg);
scope_hierarchy.push(json!({
"name": seg,
"href": format!("{editor_base}?path={accum}")
}));
}
}
let mut folder_counts = std::collections::BTreeMap::<String, usize>::new();
for meta in &all_rows {
let vpath = crate::platform::model::normalize_virtual_path(&meta.virtual_path);
*folder_counts.entry(vpath).or_insert(0) += 1;
}
if let Ok(workspace) = state
.platform
.projects
.list_template_workspace(&owner, &project)
{
for item in &workspace.items {
if item.kind == "file" && !item.rel_path.ends_with(".zf.json") {
let parent = std::path::Path::new(&item.rel_path)
.parent()
.and_then(|p| p.to_str())
.unwrap_or("");
let vpath = crate::platform::model::normalize_virtual_path(parent);
*folder_counts.entry(vpath).or_insert(0) += 1;
}
}
}
let scope_folders = folder_counts
.into_iter()
.map(|(vpath, count)| {
json!({
"virtual_path": vpath,
"count": count,
"href": format!("{editor_base}?path={vpath}")
})
})
.collect::<Vec<_>>();
let locked_templates = state
.platform
.zebflow_cfg
.get_locked_templates(&owner, &project)
.unwrap_or_default();
let pipeline_lock_map: std::collections::HashMap<String, bool> = {
let mut m = std::collections::HashMap::new();
for item in &listing.pipelines {
let locked = match state.platform.projects.read_pipeline_source(
&owner,
&project,
&item.file_rel_path,
) {
Ok(source) => pipeline_source_is_locked(&source),
Err(_) => false,
};
m.insert(item.file_rel_path.clone(), locked);
}
m
};
let sidebar_pipelines = listing
.pipelines
.iter()
.map(|item| {
let file_id = item.file_rel_path.clone();
let is_selected = file_param.as_deref() == Some(file_id.as_str());
let is_locked = pipeline_lock_map.get(&file_id).copied().unwrap_or(false);
json!({
"id": file_id,
"name": item.name,
"title": item.title,
"trigger_kind": item.trigger_kind,
"virtual_path": scope_path,
"is_active": item.is_active,
"has_draft": item.has_draft,
"is_selected": is_selected,
"is_locked": is_locked,
"status_label": if item.is_active { "active" } else if item.has_draft { "draft" } else { "inactive" },
"editor_href": format!("{editor_base}?type=pipeline&path={scope_path}&file={file_id}"),
"git_status": item.git_status,
})
})
.collect::<Vec<_>>();
let sidebar_template_files: Vec<Value> = if is_docs_scope {
docs_items
.iter()
.filter_map(|d| {
if d.kind != "file" {
return None;
}
let remainder = if docs_scope_rel.is_empty() {
d.path.clone()
} else {
d.path
.strip_prefix(&format!("{docs_scope_rel}/"))?
.to_string()
};
if remainder.contains('/') {
return None;
}
let is_selected = file_param.as_deref() == Some(d.path.as_str());
let editor_href = format!(
"{editor_base}?type=doc&path={}&file={}",
docs_parent_virtual_path(&d.path),
d.path
);
Some(json!({
"name": d.name,
"rel_path": format!("docs/{}", d.path),
"template_path": d.path,
"kind": "doc",
"git_status": null,
"is_selected": is_selected,
"editor_href": editor_href,
}))
})
.collect()
} else {
listing
.files
.iter()
.map(|f| {
let template_path = f
.rel_path
.strip_prefix("pipelines/")
.unwrap_or(&f.rel_path)
.to_string();
let git_status = git_map.get(&f.rel_path).cloned();
let is_selected = file_param.as_deref() == Some(template_path.as_str());
json!({
"name": f.name,
"rel_path": template_path,
"template_path": template_path,
"kind": f.kind,
"git_status": git_status,
"is_selected": is_selected,
"editor_href": format!("{editor_base}?type=template&path={scope_path}&file={template_path}"),
})
})
.collect()
};
let mut child_folders: Vec<Value> = listing
.folders
.iter()
.map(|folder| {
let virtual_path = if scope_path == "/" {
format!("/{}", folder.name)
} else {
format!("{}/{}", scope_path, folder.name)
};
json!({
"name": folder.name,
"virtual_path": virtual_path,
"href": format!("{editor_base}?path={virtual_path}"),
"count": 0,
})
})
.collect();
if is_docs_scope {
child_folders = docs_items
.iter()
.filter_map(|item| {
if item.kind != "folder" {
return None;
}
let remainder = if docs_scope_rel.is_empty() {
item.path.clone()
} else {
item.path
.strip_prefix(&format!("{docs_scope_rel}/"))?
.to_string()
};
if remainder.is_empty() || remainder.contains('/') {
return None;
}
let virtual_path = if docs_scope_rel.is_empty() {
format!("/docs/{remainder}")
} else {
format!("/docs/{docs_scope_rel}/{remainder}")
};
Some(json!({
"name": item.name,
"virtual_path": virtual_path,
"href": format!("{editor_base}?path={virtual_path}"),
"count": 0,
}))
})
.collect();
}
if scope_path == "/" {
let docs_count = docs_items.iter().filter(|d| d.kind == "file").count();
child_folders.push(json!({
"name": "docs",
"virtual_path": "/docs",
"href": format!("{editor_base}?path=/docs"),
"count": docs_count,
}));
}
let sidebar = json!({
"scope_path": scope_path,
"scope_hierarchy": scope_hierarchy,
"scope_folders": scope_folders,
"child_folders": child_folders,
"pipelines": sidebar_pipelines,
"template_files": sidebar_template_files,
});
let effective_type = match (editor_type.as_str(), file_param.as_deref()) {
("pipeline", Some(_)) => "pipeline",
("template", Some(_)) => "template",
("doc", Some(_)) => "doc",
_ => "folder",
};
let pipeline_payload = if effective_type == "pipeline" {
let file = file_param.as_deref().unwrap_or("");
let meta = all_rows.iter().find(|r| r.file_rel_path == file).cloned();
let (source, graph_json, parse_error, hit_stats, selected_locked) =
if let Some(ref meta) = meta {
let source = match state.platform.projects.read_pipeline_source(
&owner,
&project,
&meta.file_rel_path,
) {
Ok(s) => s,
Err(err) => return internal_error(err),
};
let locked = pipeline_source_is_locked(&source);
let (graph_json, parse_error) = match serde_json::from_str::<Value>(&source) {
Ok(v) => (v, Value::Null),
Err(err) => (
Value::Null,
Value::String(format!("pipeline JSON parse error: {err}")),
),
};
let stats = state
.platform
.pipeline_hits
.get(&owner, &project, &meta.file_rel_path);
(
Value::String(source),
graph_json,
parse_error,
json!(stats),
Value::Bool(locked),
)
} else {
(
Value::Null,
Value::Null,
Value::Null,
Value::Null,
Value::Bool(false),
)
};
let node_catalog = state
.platform
.node_registry
.merged_definitions(&owner, &project)
.into_iter()
.map(|def| {
json!({
"kind": def.kind,
"title": def.title,
"description": def.description,
"input_pins": def.input_pins,
"output_pins": def.output_pins,
"input_schema": def.input_schema,
"output_schema": def.output_schema
})
})
.collect::<Vec<_>>();
json!({
"selected_id": file,
"selected_meta": meta,
"selected_source": source,
"selected_graph": graph_json,
"parse_error": parse_error,
"hits": hit_stats,
"selected_locked": selected_locked,
"nodes": node_catalog,
"api": {
"by_id": format!("/api/projects/{owner}/{project}/pipelines/by-id"),
"definition": format!("/api/projects/{owner}/{project}/pipelines/definition"),
"activate": format!("/api/projects/{owner}/{project}/pipelines/activate"),
"deactivate": format!("/api/projects/{owner}/{project}/pipelines/deactivate"),
"execute": format!("/api/projects/{owner}/{project}/pipelines/execute"),
"hits": format!("/api/projects/{owner}/{project}/pipelines/hits"),
"invocations": format!("/api/projects/{owner}/{project}/pipelines/invocations"),
"nodes": format!("/api/projects/{owner}/{project}/nodes"),
"credentials": format!("/api/projects/{owner}/{project}/credentials"),
"templates_workspace": format!("/api/projects/{owner}/{project}/templates/workspace"),
"template_file": format!("/api/projects/{owner}/{project}/templates/file"),
"template_save": format!("/api/projects/{owner}/{project}/templates/file"),
"template_outline": format!("/api/projects/{owner}/{project}/templates/outline"),
},
"graphui": {
"runtime_src": "/assets/libraries/zeb/graphui/0.1/runtime/graphui.bundle.mjs",
"package_label": "zeb/graphui@0.1",
}
})
} else {
Value::Null
};
let template_payload = if effective_type == "template" {
let file = file_param.as_deref().unwrap_or("");
let file_data = match state
.platform
.projects
.read_template_payload(&owner, &project, file)
{
Ok(d) => d,
Err(_) => crate::platform::model::TemplateFilePayload {
rel_path: file.to_string(),
name: file.rsplit('/').next().unwrap_or(file).to_string(),
file_kind: "template".to_string(),
content: String::new(),
line_count: 0,
is_protected: false,
},
};
json!({
"name": file_data.name,
"rel_path": file_data.rel_path,
"file_kind": file_data.file_kind,
"content": file_data.content,
"line_count": file_data.line_count,
"is_protected": file_data.is_protected,
"api": {
"file": format!("/api/projects/{owner}/{project}/templates/file"),
"save": format!("/api/projects/{owner}/{project}/templates/file"),
"outline": format!("/api/projects/{owner}/{project}/templates/outline"),
}
})
} else {
Value::Null
};
let doc_payload = if effective_type == "doc" {
let file = file_param.as_deref().unwrap_or("");
let content = read_project_doc_for_page(&state, &owner, &project, file)
.await
.unwrap_or_default();
let name = file.rsplit('/').next().unwrap_or(file).to_string();
json!({
"name": name,
"path": file,
"rel_path": format!("docs/{}", file),
"parent_virtual_path": docs_parent_virtual_path(file),
"file_kind": "doc",
"content": content,
"api": {
"file": format!("/api/projects/{owner}/{project}/docs/file"),
"save": format!("/api/projects/{owner}/{project}/docs/file"),
"outline": format!("/api/projects/{owner}/{project}/templates/outline"),
}
})
} else {
Value::Null
};
let folder_payload = if effective_type == "folder" {
json!({
"child_folders": child_folders,
"pipelines": sidebar_pipelines,
"template_files": sidebar_template_files,
})
} else {
Value::Null
};
let selected_template_locked = if effective_type == "template" {
let file = file_param.as_deref().unwrap_or("");
crate::platform::services::project_config::is_template_path_locked(&locked_templates, file)
} else {
false
};
let (seo_title, current_menu) = if nav_sub == "registry" {
(
format!("{} - Pipelines", project_info.title),
"Pipelines / Registry",
)
} else {
(
format!("{} - Editor", project_info.title),
"Pipelines / Editor",
)
};
let input = json!({
"seo": {
"title": seo_title,
"description": "Unified pipeline and template editor"
},
"owner": project_info.owner,
"project": project_info.project,
"title": project_info.title,
"project_href": format!("/projects/{owner}/{project}"),
"current_menu": current_menu,
"editor_base": editor_base,
"editor_type": effective_type,
"selected_file": file_param,
"selected_line": query.line,
"sidebar": sidebar,
"pipeline": pipeline_payload,
"template": template_payload,
"doc": doc_payload,
"folder": folder_payload,
"locked_templates": locked_templates,
"selected_template_locked": selected_template_locked,
"assets": {
"api": format!("/api/projects/{owner}/{project}/assets"),
},
"nav": nav_classes(&owner, &project, "pipelines", Some(nav_sub)),
});
match render_page(&state, "platform-project-editor", &route, input) {
Ok(html) => Html(html).into_response(),
Err(err) => internal_error(err),
}
}
async fn project_dashboard_page(
State(state): State<PlatformAppState>,
headers: HeaderMap,
uri: Uri,
Path((owner, project)): Path<(String, String)>,
) -> Response {
if let Err(response) = require_project_page_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::ProjectRead,
) {
return response;
}
match maybe_forward_project_page_to_worker(&state, &headers, &uri, &owner, &project).await {
Ok(Some(response)) => return response,
Ok(None) => {}
Err(err) => return internal_error(err),
}
match state.platform.projects.get_project(&owner, &project) {
Ok(Some(info)) => {
let nav = nav_classes(&owner, &project, "dashboard", None);
let route = format!("/projects/{owner}/{project}/dashboard");
let input = json!({
"seo": {
"title": format!("{} - Dashboard", info.title),
"description": "Runtime health, resource usage, and installed capabilities."
},
"owner": info.owner,
"project": info.project,
"title": info.title,
"project_href": format!("/projects/{owner}/{project}"),
"nav": nav,
"api": {
"system_info": "/api/system/info"
}
});
match render_page(&state, "platform-project-dashboard", &route, input) {
Ok(html) => Html(html).into_response(),
Err(err) => internal_error(err),
}
}
Ok(None) => (StatusCode::NOT_FOUND, Html("project not found".to_string())).into_response(),
Err(err) => internal_error(err),
}
}
async fn project_marketplace_page(
State(state): State<PlatformAppState>,
headers: HeaderMap,
uri: Uri,
Path((owner, project)): Path<(String, String)>,
) -> Response {
project_marketplace_tab_page(
State(state),
headers,
uri,
Path((owner, project, "packs".to_string())),
)
.await
}
async fn project_marketplace_tab_page(
State(state): State<PlatformAppState>,
headers: HeaderMap,
uri: Uri,
Path((owner, project, raw_tab)): Path<(String, String, String)>,
) -> Response {
if let Err(response) = require_project_page_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesRead,
) {
return response;
}
match maybe_forward_project_page_to_worker(&state, &headers, &uri, &owner, &project).await {
Ok(Some(response)) => return response,
Ok(None) => {}
Err(err) => return internal_error(err),
}
match state.platform.projects.get_project(&owner, &project) {
Ok(Some(info)) => {
let tab = normalize_marketplace_tab(&raw_tab);
let route = if tab == "packs" {
format!("/projects/{owner}/{project}/marketplace")
} else {
format!("/projects/{owner}/{project}/marketplace/{tab}")
};
let nav = nav_classes(&owner, &project, "marketplace", None);
if let Err(err) = state
.platform
.marketplace
.ensure_default_project_repository(&owner, &project)
{
return internal_error(err);
}
let assets = match marketplace_asset_rows(&state, &owner, &project, false) {
Ok(items) => items,
Err(err) => return internal_error(err),
};
let my_assets = match marketplace_asset_rows(&state, &owner, &project, true) {
Ok(items) => items,
Err(err) => return internal_error(err),
};
let publish_sources = match marketplace_publish_sources(
&state,
&owner,
&project,
"pipeline_with_dependencies",
) {
Ok(items) => items,
Err(err) => return internal_error(err),
};
let input = json!({
"seo": {
"title": format!("{} - Marketplace", info.title),
"description": "Embedded asset marketplace for pipelines and reusable project materials."
},
"owner": info.owner,
"project": info.project,
"title": info.title,
"project_href": format!("/projects/{owner}/{project}"),
"nav": nav,
"marketplace_tabs": marketplace_tab_items(&owner, &project, tab),
"marketplace_producer": {
"enabled": true,
},
"tab_flags": {
"packs": tab == "packs",
"my_packs": tab == "my-packs",
"publish": tab == "publish",
},
"assets": assets,
"my_assets": my_assets,
"publish_sources": publish_sources,
"marketplace_api": {
"assets": format!("/api/projects/{owner}/{project}/marketplace/assets"),
"my_assets": format!("/api/projects/{owner}/{project}/marketplace/assets/mine"),
"publish_sources": format!("/api/projects/{owner}/{project}/marketplace/publish-sources"),
"publish_preview": format!("/api/projects/{owner}/{project}/marketplace/publish-preview"),
"publish_asset": format!("/api/projects/{owner}/{project}/marketplace/assets/publish"),
"repositories": format!("/api/projects/{owner}/{project}/marketplace/repositories"),
}
});
match render_page(&state, "platform-project-marketplace", &route, input) {
Ok(html) => Html(html).into_response(),
Err(err) => internal_error(err),
}
}
Ok(None) => (StatusCode::NOT_FOUND, Html("project not found".to_string())).into_response(),
Err(err) => internal_error(err),
}
}
async fn project_credentials_page(
State(state): State<PlatformAppState>,
headers: HeaderMap,
uri: Uri,
Path((owner, project)): Path<(String, String)>,
) -> Response {
if let Err(response) = require_project_page_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::CredentialsRead,
) {
return response;
}
match maybe_forward_project_page_to_worker(&state, &headers, &uri, &owner, &project).await {
Ok(Some(response)) => return response,
Ok(None) => {}
Err(err) => return internal_error(err),
}
match state.platform.projects.get_project(&owner, &project) {
Ok(Some(info)) => {
let nav = nav_classes(&owner, &project, "credentials", None);
let route = format!("/projects/{owner}/{project}/credentials");
let input = json!({
"seo": {
"title": format!("{} - Credentials", info.title),
"description": "Credential catalog and secret payload management"
},
"owner": info.owner,
"project": info.project,
"title": info.title,
"project_href": format!("/projects/{owner}/{project}"),
"credentials": {
"api": {
"list": format!("/api/projects/{owner}/{project}/credentials"),
"item_base": format!("/api/projects/{owner}/{project}/credentials"),
}
},
"nav": nav,
});
match render_page(&state, "platform-project-credentials", &route, input) {
Ok(html) => Html(html).into_response(),
Err(err) => internal_error(err),
}
}
Ok(None) => (StatusCode::NOT_FOUND, Html("project not found".to_string())).into_response(),
Err(err) => internal_error(err),
}
}
async fn project_db_connections_page(
State(state): State<PlatformAppState>,
headers: HeaderMap,
uri: Uri,
Path((owner, project)): Path<(String, String)>,
) -> Response {
if let Err(response) = require_project_page_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TablesRead,
) {
return response;
}
match maybe_forward_project_page_to_worker(&state, &headers, &uri, &owner, &project).await {
Ok(Some(response)) => return response,
Ok(None) => {}
Err(err) => return internal_error(err),
}
match state.platform.projects.get_project(&owner, &project) {
Ok(Some(info)) => {
let nav = nav_classes(&owner, &project, "databases", Some("connections"));
let route = format!("/projects/{owner}/{project}/db/connections");
let connections = match state
.platform
.db_connections
.list_project_connections(&owner, &project)
{
Ok(items) => items,
Err(err) => return internal_error(err),
};
let external_connection_cards = connections
.iter()
.map(|item| {
json!({
"connection_id": item.connection_id,
"slug": item.connection_slug,
"name": item.connection_label,
"kind": item.database_kind,
"icon_class": db_connection_icon_class(&item.database_kind),
"credential_id": item.credential_id,
"updated_at": item.updated_at,
"description": "Credential-backed external database connection.",
"path": format!(
"/projects/{owner}/{project}/db/{}/{}/tables",
item.database_kind,
item.connection_slug
)
})
})
.collect::<Vec<_>>();
let mut connection_cards = vec![json!({
"connection_id": "builtin:mapserver:default-mapserver",
"slug": "default-mapserver",
"name": "Default Mapserver",
"kind": "mapserver",
"icon_class": "i-devicon-mapserver",
"credential_id": "",
"updated_at": "",
"description": "Published GeoJSON-backed spatial layers for map serving.",
"path": format!("/projects/{owner}/{project}/db/mapserver/default-mapserver/layers")
})];
connection_cards.extend(external_connection_cards);
let input = json!({
"seo": {
"title": format!("{} - Databases", info.title),
"description": "Project database connections"
},
"owner": info.owner,
"project": info.project,
"title": info.title,
"project_href": format!("/projects/{owner}/{project}"),
"connections": connection_cards,
"db_connections": {
"api": {
"list": format!("/api/projects/{owner}/{project}/db/connections"),
"item_base": format!("/api/projects/{owner}/{project}/db/connections"),
"test": format!("/api/projects/{owner}/{project}/db/connections/test"),
"credentials_list": format!("/api/projects/{owner}/{project}/credentials"),
}
},
"nav": nav,
});
match render_page(&state, "platform-project-tables", &route, input) {
Ok(html) => Html(html).into_response(),
Err(err) => internal_error(err),
}
}
Ok(None) => (StatusCode::NOT_FOUND, Html("project not found".to_string())).into_response(),
Err(err) => internal_error(err),
}
}
async fn project_db_suite_redirect_page(
Path((owner, project, db_kind, connection)): Path<(String, String, String, String)>,
) -> Response {
Redirect::to(&format!(
"/projects/{owner}/{project}/db/{db_kind}/{connection}/tables"
))
.into_response()
}
async fn project_db_suite_page(
State(state): State<PlatformAppState>,
headers: HeaderMap,
uri: Uri,
Path((owner, project, db_kind, connection, tab)): Path<(
String,
String,
String,
String,
String,
)>,
Query(query): Query<DbSuiteQuery>,
) -> Response {
if let Err(response) = require_project_page_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TablesRead,
) {
return response;
}
match maybe_forward_project_page_to_worker(&state, &headers, &uri, &owner, &project).await {
Ok(Some(response)) => return response,
Ok(None) => {}
Err(err) => return internal_error(err),
}
let tab_key = match tab.as_str() {
"tables" | "query" | "schema" | "mart" => tab,
"layers" | "publish" | "test" => tab,
_ => {
return (StatusCode::NOT_FOUND, Html("db tab not found".to_string())).into_response();
}
};
match state.platform.projects.get_project(&owner, &project) {
Ok(Some(info)) => {
if db_kind == "mapserver" {
if connection != "default-mapserver" {
return (
StatusCode::NOT_FOUND,
Html("mapserver instance not found".to_string()),
)
.into_response();
}
if tab_key != "layers" && tab_key != "publish" && tab_key != "test" {
return (
StatusCode::NOT_FOUND,
Html("mapserver tab not found".to_string()),
)
.into_response();
}
let nav = nav_classes(&owner, &project, "databases", Some("connections"));
let route =
format!("/projects/{owner}/{project}/db/{db_kind}/{connection}/{tab_key}");
let base = format!("/projects/{owner}/{project}/db/{db_kind}/{connection}");
let suite_tabs = vec![
json!({
"label": "Layers",
"href": format!("{base}/layers"),
"classes": if tab_key == "layers" { "is-active" } else { "" },
}),
json!({
"label": "Publish",
"href": format!("{base}/publish"),
"classes": if tab_key == "publish" { "is-active" } else { "" },
}),
json!({
"label": "Test",
"href": format!("{base}/test"),
"classes": if tab_key == "test" { "is-active" } else { "" },
}),
];
let layers = match read_mapserver_layers(&state, &owner, &project, &connection) {
Ok(items) => items,
Err(err) => return internal_error(err),
};
let sources = match list_mapserver_source_files(&state, &owner, &project) {
Ok(items) => items,
Err(err) => return internal_error(err),
};
let input = json!({
"seo": {
"title": format!("{} - Mapserver {}", info.title, connection),
"description": "Published GeoJSON-backed spatial layers"
},
"owner": info.owner,
"project": info.project,
"title": info.title,
"project_href": format!("/projects/{owner}/{project}"),
"connection": {
"id": format!("builtin:mapserver:{connection}"),
"name": "Default Mapserver",
"kind": db_kind,
"slug": connection,
"icon_class": "i-devicon-mapserver",
"credential_id": "",
},
"suite_tabs": suite_tabs,
"tab_flags": {
"layers": tab_key == "layers",
"publish": tab_key == "publish",
"test": tab_key == "test",
},
"layers": layers,
"sources": sources,
"mapserver_api": {
"sources": format!("/api/projects/{owner}/{project}/mapserver/{connection}/sources"),
"layers": format!("/api/projects/{owner}/{project}/mapserver/{connection}/layers"),
"upload": format!("/api/projects/{owner}/{project}/files/upload?path=mapserver"),
"list_files": format!("/api/projects/{owner}/{project}/files/list?path=mapserver"),
"base_public": format!("/ms/{owner}/{project}"),
},
"nav": nav,
});
match render_page(
&state,
"platform-project-table-connection-mapserver",
&route,
input,
) {
Ok(html) => return Html(html).into_response(),
Err(err) => return internal_error(err),
}
}
let Some(connection_info) = (match state.platform.db_connections.get_project_connection(
&owner,
&project,
&connection,
) {
Ok(item) => item,
Err(err) => return internal_error(err),
}) else {
return (
StatusCode::NOT_FOUND,
Html("db connection not found".to_string()),
)
.into_response();
};
if connection_info.database_kind != db_kind {
return (
StatusCode::NOT_FOUND,
Html("db connection not found".to_string()),
)
.into_response();
}
let nav = nav_classes(&owner, &project, "databases", Some("connections"));
let route = format!("/projects/{owner}/{project}/db/{db_kind}/{connection}/{tab_key}");
let table_page_key = match connection_info.database_kind.as_str() {
"postgresql" => "platform-project-table-connection-postgresql",
"sekejap" => "platform-project-table-connection-sekejap",
_ => "platform-project-table-connection",
};
let requested = query.table.unwrap_or_default();
let selected_table = requested.trim().to_lowercase().replace(
|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-' && c != '.',
"-",
);
let query_example = match connection_info.database_kind.as_str() {
"sekejap" => "SHOW TABLES".to_string(),
_ => "-- Write SQL and click Run Query.".to_string(),
};
let table_query = if selected_table.is_empty() {
String::new()
} else {
format!("?table={selected_table}")
};
let base = format!("/projects/{owner}/{project}/db/{db_kind}/{connection}");
let suite_tabs = vec![
json!({
"label": "Tables",
"href": format!("{base}/tables{table_query}"),
"classes": if tab_key == "tables" { "is-active" } else { "" },
}),
json!({
"label": "Query",
"href": format!("{base}/query{table_query}"),
"classes": if tab_key == "query" { "is-active" } else { "" },
}),
json!({
"label": "Schema",
"href": format!("{base}/schema{table_query}"),
"classes": if tab_key == "schema" { "is-active" } else { "" },
}),
json!({
"label": "Mart",
"href": format!("{base}/mart{table_query}"),
"classes": if tab_key == "mart" { "is-active" } else { "" },
}),
];
let input = json!({
"seo": {
"title": format!("{} - DB {} / {}", info.title, db_kind, connection),
"description": "Database suite"
},
"owner": info.owner,
"project": info.project,
"title": info.title,
"project_href": format!("/projects/{owner}/{project}"),
"connection": {
"id": connection_info.connection_id,
"name": connection_info.connection_label,
"kind": db_kind,
"slug": connection,
"icon_class": db_connection_icon_class(&connection_info.database_kind),
"credential_id": connection_info.credential_id,
},
"db_runtime_api": {
"describe": format!("/api/projects/{owner}/{project}/db/connections/{}/describe", connection_info.connection_id),
"schemas": format!("/api/projects/{owner}/{project}/db/connections/{}/schemas", connection_info.connection_id),
"tables": format!("/api/projects/{owner}/{project}/db/connections/{}/tables", connection_info.connection_id),
"functions": format!("/api/projects/{owner}/{project}/db/connections/{}/functions", connection_info.connection_id),
"preview": format!("/api/projects/{owner}/{project}/db/connections/{}/table-preview", connection_info.connection_id),
"query": format!("/api/projects/{owner}/{project}/db/connections/{}/query", connection_info.connection_id),
},
"suite_tabs": suite_tabs,
"object_groups": Vec::<Value>::new(),
"tables": Vec::<Value>::new(),
"table_summary": "Loading tables...",
"preview": {
"columns": Vec::<String>::new(),
"rows": Vec::<Vec<String>>::new(),
"empty": true,
},
"query_example": query_example,
"schema_text": "{}",
"tab_flags": {
"tables": tab_key == "tables",
"query": tab_key == "query",
"schema": tab_key == "schema",
"mart": tab_key == "mart",
},
"nav": nav,
});
match render_page(&state, table_page_key, &route, input) {
Ok(html) => Html(html).into_response(),
Err(err) => internal_error(err),
}
}
Ok(None) => (StatusCode::NOT_FOUND, Html("project not found".to_string())).into_response(),
Err(err) => internal_error(err),
}
}
async fn project_files_page(
State(state): State<PlatformAppState>,
headers: HeaderMap,
uri: Uri,
Path((owner, project)): Path<(String, String)>,
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> Response {
let browse_path = params.get("path").cloned().unwrap_or_default();
render_files_page(
state,
headers,
uri,
owner,
project,
"storages",
None,
browse_path,
)
.await
}
async fn project_files_tab_page(
State(state): State<PlatformAppState>,
headers: HeaderMap,
uri: Uri,
Path((owner, project, tab)): Path<(String, String, String)>,
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> Response {
let selected_storage = slug_segment(tab.trim());
let selected_storage = if selected_storage.is_empty() {
None
} else {
Some(selected_storage)
};
let browse_path = params.get("path").cloned().unwrap_or_default();
render_files_page(
state,
headers,
uri,
owner,
project,
"explorer",
selected_storage,
browse_path,
)
.await
}
async fn render_files_page(
state: PlatformAppState,
headers: HeaderMap,
uri: Uri,
owner: String,
project: String,
tab: &str,
selected_storage: Option<String>,
browse_path: String,
) -> Response {
if let Err(response) = require_project_page_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::FilesRead,
) {
return response;
}
match maybe_forward_project_page_to_worker(&state, &headers, &uri, &owner, &project).await {
Ok(Some(response)) => return response,
Ok(None) => {}
Err(err) => return internal_error(err),
}
match state.platform.projects.get_project(&owner, &project) {
Ok(Some(info)) => {
let nav = nav_classes(&owner, &project, "files", None);
let route = format!("/projects/{owner}/{project}/files");
let selected_storage = selected_storage.unwrap_or_else(|| "default".to_string());
let active_tab = if tab == "explorer" {
"explorer"
} else {
"storages"
};
let (folders, files, rel_path) = if active_tab == "explorer" {
match state.platform.file.ensure_project_layout(&owner, &project) {
Ok(layout) => {
let rel = browse_path.trim().trim_start_matches('/').to_string();
let zebfs = crate::zebfs::LocalZebFs::new(layout.files_dir);
let mut fds: Vec<serde_json::Value> = Vec::new();
let mut fls: Vec<serde_json::Value> = Vec::new();
let rel_path = match zebfs.list(&rel) {
Ok(entries) => {
for entry in entries {
let name = entry.name;
let path = entry.path;
if matches!(entry.kind, crate::zebfs::ZebFsEntryKind::Prefix) {
fds.push(json!({
"name": name,
"path": path,
"protected": false,
}));
} else {
let modified = entry
.modified
.and_then(|t| {
t.duration_since(std::time::UNIX_EPOCH).ok()
})
.map(|d| d.as_secs())
.unwrap_or(0);
fls.push(json!({
"name": name,
"path": path.clone(),
"size": entry.size,
"modified": modified,
"url": format!("/fs/{owner}/{project}/{path}"),
}));
}
}
rel
}
Err(_) => String::new(),
};
(fds, fls, rel_path)
}
Err(_) => (vec![], vec![], String::new()),
}
} else {
(vec![], vec![], String::new())
};
let input = json!({
"seo": {
"title": format!("{} - Files", info.title),
"description": "Project file storage — public and private."
},
"owner": info.owner,
"project": info.project,
"title": info.title,
"project_href": format!("/projects/{owner}/{project}"),
"current_menu": "Files",
"active_tab": active_tab,
"selected_storage": selected_storage,
"storages": [
{
"name": "default",
"backend": "ZebFS local",
"namespace": format!("{owner}/{project}"),
"tags": ["default"],
"open_href": format!("/projects/{owner}/{project}/files/default")
}
],
"api": {
"list": format!("/api/projects/{owner}/{project}/files/list"),
"mkdir": format!("/api/projects/{owner}/{project}/files/mkdir"),
"upload": format!("/api/projects/{owner}/{project}/files/upload"),
"rm": format!("/api/projects/{owner}/{project}/files/rm"),
},
"browser": {
"path": rel_path,
"folders": folders,
"files": files,
},
"nav": nav,
});
match render_page(&state, "platform-project-section", &route, input) {
Ok(html) => Html(html).into_response(),
Err(err) => internal_error(err),
}
}
Ok(None) => (StatusCode::NOT_FOUND, Html("project not found".to_string())).into_response(),
Err(err) => internal_error(err),
}
}
async fn project_todo_page(
State(state): State<PlatformAppState>,
headers: HeaderMap,
uri: Uri,
Path((owner, project)): Path<(String, String)>,
) -> Response {
render_section_page(
state,
headers,
uri,
owner,
project,
"todo",
ProjectCapability::ProjectRead,
"Todo",
"Collaborative notes and task lists for project delivery.",
vec![
json!({"title":"Backlog","description":"Track pending improvements and fixes."}),
json!({"title":"Sprint Tasks","description":"Focus tasks tied to current release cycle."}),
],
)
.await
}
async fn project_settings_page(
State(state): State<PlatformAppState>,
headers: HeaderMap,
uri: Uri,
Path((owner, project)): Path<(String, String)>,
) -> Response {
render_settings_tab_page(state, headers, uri, owner, project, "general".to_string()).await
}
async fn project_settings_tab_page(
State(state): State<PlatformAppState>,
headers: HeaderMap,
uri: Uri,
Path((owner, project, tab)): Path<(String, String, String)>,
) -> Response {
render_settings_tab_page(state, headers, uri, owner, project, tab).await
}
async fn project_infrastructure_page(
State(state): State<PlatformAppState>,
headers: HeaderMap,
uri: Uri,
Path((owner, project)): Path<(String, String)>,
) -> Response {
if let Err(response) = require_project_page_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::SettingsRead,
) {
return response;
}
match maybe_forward_project_page_to_worker(&state, &headers, &uri, &owner, &project).await {
Ok(Some(response)) => return response,
Ok(None) => {}
Err(err) => return internal_error(err),
}
match state.platform.projects.get_project(&owner, &project) {
Ok(Some(info)) => {
let nav = nav_classes(&owner, &project, "settings", None);
let runtime = match cluster_runtime_summary(&state, &owner, &project) {
Ok(runtime) => runtime,
Err(err) => return internal_error(err),
};
match render_page(
&state,
"platform-project-infrastructure",
&format!("/projects/{owner}/{project}/infrastructure"),
json!({
"seo": {
"title": format!("{} - Infrastructure", info.title),
"description": "Cluster runtime control"
},
"project": {
"owner": owner,
"project": project,
"title": info.title,
},
"nav": nav,
"runtime": runtime,
}),
) {
Ok(html) => Html(html).into_response(),
Err(err) => internal_error(err),
}
}
Ok(None) => Redirect::to(HOME_PATH).into_response(),
Err(err) => internal_error(err),
}
}
async fn render_settings_tab_page(
state: PlatformAppState,
headers: HeaderMap,
uri: Uri,
owner: String,
project: String,
raw_tab: String,
) -> Response {
if let Err(response) = require_project_page_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::SettingsRead,
) {
return response;
}
match maybe_forward_project_page_to_worker(&state, &headers, &uri, &owner, &project).await {
Ok(Some(response)) => return response,
Ok(None) => {}
Err(err) => return internal_error(err),
}
let tab = normalize_settings_tab(&raw_tab);
let tab_title = settings_tab_title(tab);
let tab_subtitle = settings_tab_subtitle(tab);
match state.platform.projects.get_project(&owner, &project) {
Ok(Some(info)) => {
let nav = nav_classes(&owner, &project, "settings", None);
let route = if tab == "general" {
format!("/projects/{owner}/{project}/settings")
} else {
format!("/projects/{owner}/{project}/settings/{tab}")
};
let tabs = settings_tab_items(&owner, &project, tab);
let general_cards = settings_general_cards(&owner, &project);
let policy_cards = settings_policy_cards();
let (node_count, node_groups) = settings_nodes();
let rwe_libs = state
.platform
.zebflow_cfg
.get_rwe_libraries(&owner, &project)
.unwrap_or_default();
let libraries_available = state
.platform
.library
.list()
.map(|m| {
let enabled_entry = rwe_libs.get(&m.name);
json!({
"name": m.name,
"description": m.description,
"packed_version": m.packed_version(),
"packed_kind": m.packed_kind(),
"enabled": enabled_entry.is_some(),
"installed_version": enabled_entry.map(|e| e.version.clone()),
"source": enabled_entry.map(|e| e.source.clone())
})
})
.collect::<Vec<_>>();
let libraries_api = format!("/api/projects/{owner}/{project}/rwe/libraries");
let assistant_config = match state
.platform
.assistant_configs
.get_project_assistant_config(&owner, &project)
{
Ok(config) => config,
Err(err) => return internal_error(err),
};
let assistant_credentials = match state
.platform
.credentials
.list_project_credentials(&owner, &project)
{
Ok(items) => items
.into_iter()
.filter(|item| item.kind == "openai")
.map(|item| {
json!({
"credential_id": item.credential_id,
"title": item.title,
"kind": item.kind
})
})
.collect::<Vec<_>>(),
Err(err) => return internal_error(err),
};
let mcp_session = state
.platform
.mcp_sessions
.get_for_project(&owner, &project);
let transfer_operations = state
.platform
.project_operations
.list(&owner, &project, 10)
.unwrap_or_default();
let zebflow_cfg = state.platform.zebflow_cfg.read_or_default(&owner, &project);
let input = json!({
"seo": {
"title": format!("{} - Settings / {}", info.title, tab_title),
"description": tab_subtitle
},
"owner": info.owner,
"project": info.project,
"title": info.title,
"project_href": format!("/projects/{owner}/{project}"),
"current_menu": "Settings",
"settings_tabs": tabs,
"active_tab": tab,
"tab_flags": {
"general": tab == "general",
"policy": tab == "policy",
"automatons": tab == "automatons",
"libraries": tab == "libraries",
"nodes": tab == "nodes",
"files": tab == "files",
"logs": tab == "logs"
},
"page_title": tab_title,
"page_subtitle": tab_subtitle,
"cards_general": general_cards,
"cards_policy": policy_cards,
"libraries_available": libraries_available,
"libraries_api": libraries_api,
"node_count": node_count,
"node_groups": node_groups,
"assistant": {
"api": {
"config": format!("/api/projects/{owner}/{project}/assistant/config")
},
"config": assistant_config,
"credentials": assistant_credentials
},
"rwe": {
"api": format!("/api/projects/{owner}/{project}/settings/rwe"),
"config": zebflow_cfg.configs.rwe
},
"logging": {
"api": format!("/api/projects/{owner}/{project}/settings/logging"),
"invocations_api": format!("/api/projects/{owner}/{project}/settings/logs/invocations"),
"config": zebflow_cfg.configs.pipelines.logging
},
"git": {
"remote_api": format!("/api/projects/{owner}/{project}/git/remote"),
"config": zebflow_cfg.configs.git.remote,
"health_api": format!("/api/projects/{owner}/{project}/git/health"),
"repair_api": format!("/api/projects/{owner}/{project}/git/repair")
},
"assets": {
"api": format!("/api/projects/{owner}/{project}/assets"),
"settings_api": format!("/api/projects/{owner}/{project}/settings/assets"),
"config": {
"max_asset_size_mb": zebflow_cfg.configs.files.uploads.effective_max_asset_size_mb(),
"max_file_size_mb": zebflow_cfg.configs.files.uploads.effective_max_file_size_mb(),
"webhook_body_max_mb": zebflow_cfg.configs.files.uploads.effective_webhook_body_max_mb(),
"pipeline_node_timeout_secs": zebflow_cfg.configs.pipelines.effective_node_timeout_secs()
}
},
"reindex_api": format!("/api/projects/{owner}/{project}/reindex"),
"transfer": {
"api": {
"operations": format!("/api/projects/{owner}/{project}/transfer/operations"),
"export_bundle": format!("/api/projects/{owner}/{project}/transfer/export/bundle"),
"export_files": format!("/api/projects/{owner}/{project}/transfer/export/files"),
"import_bundle": format!("/api/projects/{owner}/{project}/transfer/import/bundle"),
"import_files": format!("/api/projects/{owner}/{project}/transfer/import/files"),
},
"operations": transfer_operations,
},
"mcp": {
"active": mcp_session.as_ref().map(|session| session.enabled).unwrap_or(false),
"status_label": if mcp_session.as_ref().map(|session| session.enabled).unwrap_or(false) { "active" } else { "inactive" },
"capabilities": mcp_session
.as_ref()
.map(|session| session.capabilities.iter().map(|cap| cap.key()).collect::<Vec<_>>())
.unwrap_or_default()
},
"nav": nav,
});
match render_page(&state, "platform-project-settings", &route, input) {
Ok(html) => Html(html).into_response(),
Err(err) => internal_error(err),
}
}
Ok(None) => (StatusCode::NOT_FOUND, Html("project not found".to_string())).into_response(),
Err(err) => internal_error(err),
}
}
fn normalize_settings_tab(raw: &str) -> &'static str {
match raw.trim().to_ascii_lowercase().as_str() {
"" | "general" => "general",
"policy" => "policy",
"automatons" => "automatons",
"libraries" => "libraries",
"nodes" => "nodes",
"files" => "files",
"logs" => "logs",
_ => "general",
}
}
fn settings_tab_title(tab: &str) -> &'static str {
match tab {
"policy" => "Policy",
"automatons" => "Automatons",
"libraries" => "Libraries",
"nodes" => "Nodes",
"files" => "Files",
"logs" => "Logs",
_ => "General",
}
}
fn settings_tab_subtitle(tab: &str) -> &'static str {
match tab {
"policy" => "Capability boundaries, runtime constraints, and session controls.",
"automatons" => "Assistant and automation runtime configuration per project.",
"libraries" => "Installed web libraries and runtime package contracts.",
"nodes" => "Live node contracts and script/tool availability.",
"files" => "External file storage backends — S3, R2, and compatible object stores.",
"logs" => "Project-owned invocation history, storage size, retention, and cleanup.",
_ => "Core project defaults and shared runtime switches.",
}
}
fn settings_tab_items(owner: &str, project: &str, active: &str) -> Vec<Value> {
let base = format!("/projects/{owner}/{project}/settings");
let entries = [
("general", "General"),
("policy", "Policy"),
("automatons", "Automatons"),
("libraries", "Libraries"),
("nodes", "Nodes"),
("files", "Files"),
("logs", "Logs"),
];
entries
.iter()
.map(|(key, label)| {
json!({
"key": *key,
"label": *label,
"href": if *key == "general" { base.clone() } else { format!("{base}/{key}") },
"classes": if *key == active { "is-active" } else { "" }
})
})
.collect::<Vec<_>>()
}
fn settings_general_cards(owner: &str, project: &str) -> Vec<Value> {
vec![
json!({
"title":"Runtime Defaults",
"description":"Project-wide defaults for execution cadence, retries, and production activation flow.",
"href": format!("/projects/{owner}/{project}/settings/automatons"),
"tag":"Core"
}),
json!({
"title":"Connected Services",
"description":"Credentials, DB connections, and tool-facing service contract usage.",
"href": format!("/projects/{owner}/{project}/settings/libraries"),
"tag":"Core"
}),
]
}
fn settings_policy_cards() -> Vec<Value> {
vec![
json!({
"title":"Capability Gate",
"description":"Subject capability checks enforced across REST, MCP, and assistant channels.",
"href":"#",
"tag":"Access"
}),
json!({
"title":"Request Boundary",
"description":"Input validation, payload size bounds, and deterministic error contracts.",
"href":"#",
"tag":"Runtime"
}),
json!({
"title":"Session Scope",
"description":"Project-scoped session constraints for remote control and internal assistant execution.",
"href":"#",
"tag":"Session"
}),
]
}
fn node_group_rank(kind: &str) -> u8 {
if kind.starts_with("n.trigger.") {
0
} else if kind == "n.script" || kind.starts_with("n.script.") {
1
} else if kind.starts_with("n.logic.") {
2
} else if kind.starts_with("n.ai.") {
3
} else {
4
}
}
fn node_group_prefix(kind: &str) -> &'static str {
if kind.starts_with("n.trigger.") {
"n.trigger"
} else if kind == "n.script" || kind.starts_with("n.script.") {
"n.script"
} else if kind.starts_with("n.logic.") {
"n.logic"
} else if kind.starts_with("n.ai.") {
"n.ai"
} else {
""
}
}
fn settings_nodes() -> (usize, Vec<Value>) {
let mut defs = crate::pipeline::nodes::builtin_node_definitions();
defs.extend(NodeRegistryService::embedded_official_definitions());
defs.sort_by(|a, b| {
node_group_rank(&a.kind)
.cmp(&node_group_rank(&b.kind))
.then_with(|| a.kind.cmp(&b.kind))
});
let total = defs.len();
let mut groups: Vec<Value> = Vec::new();
let mut current_prefix = String::new();
let mut current_nodes: Vec<Value> = Vec::new();
for def in defs {
let prefix = node_group_prefix(&def.kind).to_string();
if prefix != current_prefix && !current_nodes.is_empty() {
groups.push(json!({
"prefix": current_prefix,
"nodes": current_nodes.drain(..).collect::<Vec<_>>()
}));
}
current_prefix = prefix;
current_nodes.push(json!({
"kind": def.kind,
"title": def.title,
"description": def.description,
"script_available": def.script_available,
"ai_registered": def.ai_tool.registered,
"source": "built-in"
}));
}
if !current_nodes.is_empty() {
groups.push(json!({ "prefix": current_prefix, "nodes": current_nodes }));
}
(total, groups)
}
async fn render_section_page(
state: PlatformAppState,
headers: HeaderMap,
uri: Uri,
owner: String,
project: String,
section_key: &str,
capability: ProjectCapability,
section_title: &str,
section_desc: &str,
cards: Vec<Value>,
) -> Response {
if let Err(response) =
require_project_page_capability(&state, &headers, &owner, &project, capability)
{
return response;
}
match maybe_forward_project_page_to_worker(&state, &headers, &uri, &owner, &project).await {
Ok(Some(response)) => return response,
Ok(None) => {}
Err(err) => return internal_error(err),
}
match state.platform.projects.get_project(&owner, &project) {
Ok(Some(info)) => {
let nav = nav_classes(&owner, &project, section_key, None);
let route = format!("/projects/{owner}/{project}/{section_key}");
let input = json!({
"seo": {
"title": format!("{} - {}", info.title, section_title),
"description": section_desc
},
"owner": info.owner,
"project": info.project,
"title": info.title,
"project_href": format!("/projects/{owner}/{project}"),
"current_menu": section_title,
"page_title": section_title,
"page_subtitle": section_desc,
"cards": cards,
"nav": nav,
});
match render_page(&state, "platform-project-section", &route, input) {
Ok(html) => Html(html).into_response(),
Err(err) => internal_error(err),
}
}
Ok(None) => (StatusCode::NOT_FOUND, Html("project not found".to_string())).into_response(),
Err(err) => internal_error(err),
}
}
fn pipeline_tab_payload(
tab: &str,
) -> Option<(&'static str, &'static str, &'static str, Vec<Value>)> {
match tab {
"webhooks" => Some((
"webhooks",
"Webhook Pipelines",
"Inbound HTTP triggers mapped to project pipelines.",
Vec::new(),
)),
"schedules" => Some((
"schedules",
"Schedule Pipelines",
"Cron-based and interval-based recurring jobs.",
Vec::new(),
)),
"manual" => Some((
"manual",
"Manual Pipelines",
"Pipelines triggered explicitly from API/UI manual execute requests.",
Vec::new(),
)),
"functions" => Some((
"functions",
"Function Pipelines",
"Callable in-house functions for reuse across workflows.",
Vec::new(),
)),
_ => None,
}
}
fn project_nav_map(owner: &str, project: &str) -> String {
let b = format!("/projects/{owner}/{project}");
let pb = format!("{b}/pipelines");
format!(
" - Pipelines › Registry: {pb}/registry?path=/\n\
- Pipelines › Webhooks: {pb}/webhooks\n\
- Pipelines › Schedules: {pb}/schedules\n\
- Pipelines › Manual: {pb}/manual\n\
- Pipelines › Functions: {pb}/functions\n\
- Dashboard: {b}/dashboard\n\
- Credentials: {b}/credentials\n\
- Databases / Tables (lists all connections): {b}/db/connections\n\
- Files: {b}/files\n\
- Todo: {b}/todo\n\
- Settings: {b}/settings\n\
\n\
DB connection sub-pages (substitute actual db_kind and connection_id):\n\
- {b}/db/{{db_kind}}/{{connection_id}}/tables — browse tables\n\
- {b}/db/{{db_kind}}/{{connection_id}}/query — run SQL / query UI\n\
- {b}/db/{{db_kind}}/{{connection_id}}/schema — schema explorer"
)
}
fn nav_classes(owner: &str, project: &str, main: &str, pipeline_sub: Option<&str>) -> Value {
let pipelines_base = format!("/projects/{owner}/{project}/pipelines");
json!({
"title": "Project Menu",
"links": {
"pipelines_registry": format!("{pipelines_base}/registry?path=/"),
"pipelines_editor": format!("/projects/{owner}/{project}/editor"),
"pipelines_webhooks": format!("{pipelines_base}/webhooks"),
"pipelines_schedules": format!("{pipelines_base}/schedules"),
"pipelines_manual": format!("{pipelines_base}/manual"),
"pipelines_functions": format!("{pipelines_base}/functions"),
"marketplace": format!("/projects/{owner}/{project}/marketplace"),
"dashboard": format!("/projects/{owner}/{project}/dashboard"),
"credentials": format!("/projects/{owner}/{project}/credentials"),
"db_connections": format!("/projects/{owner}/{project}/db/connections"),
"tables_connections": format!("/projects/{owner}/{project}/db/connections"),
"files": format!("/projects/{owner}/{project}/files"),
"todo": format!("/projects/{owner}/{project}/todo"),
"settings": format!("/projects/{owner}/{project}/settings"),
},
"classes": {
"pipelines": if main == "pipelines" { "is-active" } else { "" },
"marketplace": if main == "marketplace" { "is-active" } else { "" },
"dashboard": if main == "dashboard" { "is-active" } else { "" },
"credentials": if main == "credentials" { "is-active" } else { "" },
"databases": if main == "databases" { "is-active" } else { "" },
"tables": if main == "databases" { "is-active" } else { "" },
"files": if main == "files" { "is-active" } else { "" },
"todo": if main == "todo" { "is-active" } else { "" },
"settings": if main == "settings" { "is-active" } else { "" },
"pipeline_registry": if pipeline_sub == Some("registry") { "is-active" } else { "" },
"pipeline_editor": if pipeline_sub == Some("editor") { "is-active" } else { "" },
"pipeline_webhooks": if pipeline_sub == Some("webhooks") { "is-active" } else { "" },
"pipeline_schedules": if pipeline_sub == Some("schedules") { "is-active" } else { "" },
"pipeline_manual": if pipeline_sub == Some("manual") { "is-active" } else { "" },
"pipeline_functions": if pipeline_sub == Some("functions") { "is-active" } else { "" },
"db_connections": if main == "databases" { "is-active" } else { "" },
"table_connections": if main == "databases" { "is-active" } else { "" },
}
})
}
fn normalize_marketplace_tab(raw: &str) -> &'static str {
match raw.trim() {
"" | "packs" | "assets" => "packs",
"my-packs" => "my-packs",
"publish" => "publish",
_ => "packs",
}
}
fn marketplace_tab_items(owner: &str, project: &str, active: &str) -> Vec<Value> {
let base = format!("/projects/{owner}/{project}/marketplace");
let tabs = vec!["packs", "my-packs", "publish"];
tabs.into_iter()
.map(|tab| {
let label = match tab {
"packs" => "Packs",
"my-packs" => "My Packs",
"publish" => "Publish",
_ => tab,
};
let href = if tab == "packs" {
base.clone()
} else {
format!("{base}/{tab}")
};
json!({
"label": label,
"href": href,
"classes": if active == tab { "is-active" } else { "" }
})
})
.collect()
}
fn marketplace_publish_sources(
state: &PlatformAppState,
owner: &str,
project: &str,
source_type: &str,
) -> Result<Vec<Value>, PlatformError> {
let mut rows = state
.platform
.marketplace
.list_publish_sources(owner, project, source_type)?
.into_iter()
.collect::<Vec<_>>();
rows.sort_by(|a, b| a.path.cmp(&b.path));
Ok(rows
.into_iter()
.filter_map(|item| serde_json::to_value(item).ok())
.collect())
}
fn marketplace_asset_rows(
state: &PlatformAppState,
owner: &str,
_project: &str,
only_mine: bool,
) -> Result<Vec<Value>, PlatformError> {
let packages = match if only_mine {
state
.platform
.marketplace
.list_asset_packages_by_owner(owner)
} else {
state.platform.marketplace.list_asset_packages()
} {
Ok(items) => items,
Err(err) if err.code == "MARKETPLACE_SERVICE_DISABLED" => Vec::new(),
Err(err) => return Err(err),
};
let mut rows = Vec::new();
for package in packages {
if !only_mine && package.visibility == "private" && package.publisher_owner != owner {
continue;
}
let latest_version = state
.platform
.marketplace
.list_asset_versions(&package.package_id)?
.into_iter()
.next()
.map(|item| item.version)
.unwrap_or_default();
rows.push(json!({
"package_id": package.package_id,
"publisher_owner": package.publisher_owner,
"publisher_id": package.publisher_id,
"publisher_display_name": package.publisher_display_name,
"publisher_url": package.publisher_url,
"publisher_email": package.publisher_email,
"asset_kind": package.asset_kind,
"title": package.title,
"description": package.description,
"visibility": package.visibility,
"tags": package.tags,
"latest_version": latest_version,
"updated_at": package.updated_at,
"source": "local",
"repository_id": "",
"repository_title": "Local",
}));
}
Ok(rows)
}
fn public_marketplace_asset_item_json(
state: &PlatformAppState,
package: crate::platform::model::MarketplaceAssetPackage,
) -> Value {
let latest_version = state
.platform
.marketplace
.list_asset_versions(&package.package_id)
.ok()
.and_then(|items| items.into_iter().next().map(|item| item.version))
.unwrap_or_default();
json!({
"package_id": package.package_id,
"publisher_id": package.publisher_id,
"publisher_display_name": package.publisher_display_name,
"publisher_url": package.publisher_url,
"asset_kind": package.asset_kind,
"title": package.title,
"description": package.description,
"visibility": package.visibility,
"tags": package.tags,
"latest_version": latest_version,
"updated_at": package.updated_at,
"service_instance_id": crate::platform::services::marketplace::DEFAULT_MARKETPLACE_SERVICE_INSTANCE_ID,
})
}
fn public_marketplace_version_json(
package: &crate::platform::model::MarketplaceAssetPackage,
version: &crate::platform::model::MarketplaceAssetVersion,
) -> Value {
json!({
"package_id": version.package_id,
"version": version.version,
"publisher_id": package.publisher_id,
"publisher_display_name": package.publisher_display_name,
"publisher_url": package.publisher_url,
"asset_kind": package.asset_kind,
"title": package.title,
"description": package.description,
"visibility": package.visibility,
"tags": package.tags,
"source_kind": version.source_kind,
"artifact_sha256": version.artifact_sha256,
"created_at": version.created_at,
"manifest": {
"asset_kind": package.asset_kind,
"title": package.title,
"description": package.description,
"visibility": package.visibility,
"tags": package.tags,
"publisher_id": package.publisher_id,
"publisher_display_name": package.publisher_display_name,
"publisher_url": package.publisher_url,
},
})
}
fn public_marketplace_artifact_json(mut artifact: Value) -> Value {
if let Some(object) = artifact.as_object_mut() {
object.remove("source_owner");
object.remove("source_project");
object.remove("source_ref");
object.remove("publisher_email");
object.remove("files");
}
artifact
}
fn raw_marketplace_artifact_response_json(
package: &crate::platform::model::MarketplaceAssetPackage,
version_row: &crate::platform::model::MarketplaceAssetVersion,
artifact: Value,
artifact_size_bytes: u64,
) -> Value {
json!({
"ok": true,
"version": public_marketplace_version_json(package, version_row),
"artifact_sha256": version_row.artifact_sha256,
"artifact_size_bytes": artifact_size_bytes,
"artifact": artifact,
})
}
fn require_marketplace_service_enabled(state: &PlatformAppState) -> Result<(), Response> {
match state.platform.marketplace.get_default_service_instance() {
Ok(Some(service)) if service.enabled => Ok(()),
Ok(_) => Err((
StatusCode::NOT_FOUND,
Json(json!({
"ok": false,
"error": {
"code": "MARKETPLACE_SERVICE_DISABLED",
"message": "marketplace service is not enabled"
}
})),
)
.into_response()),
Err(err) => Err(internal_error(err)),
}
}
fn marketplace_token_rows(
state: &PlatformAppState,
owner: &str,
project: &str,
) -> Result<Vec<Value>, PlatformError> {
Ok(state
.platform
.marketplace
.list_tokens(owner, project)?
.into_iter()
.map(marketplace_token_json)
.collect())
}
fn bearer_token_from_headers(headers: &HeaderMap) -> Option<String> {
let value = headers.get(axum::http::header::AUTHORIZATION)?;
let value = value.to_str().ok()?.trim();
let token = value.strip_prefix("Bearer ")?.trim();
if token.is_empty() {
None
} else {
Some(token.to_string())
}
}
fn redact_auth_urls(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(proto_end) = rest.find("://") {
let before = &rest[..proto_end + 3];
let after = &rest[proto_end + 3..];
if let Some(at_pos) = after.find('@') {
if !after[..at_pos].contains(|c: char| c.is_whitespace()) {
out.push_str(before);
out.push_str("[redacted]@");
rest = &after[at_pos + 1..];
continue;
}
}
out.push_str(before);
rest = after;
}
out.push_str(rest);
out
}
fn content_type_for_path(path: &FsPath) -> &'static str {
match path.extension().and_then(|ext| ext.to_str()) {
Some("mjs") | Some("js") => "text/javascript; charset=utf-8",
Some("json") => "application/json; charset=utf-8",
Some("geojson") => "application/geo+json; charset=utf-8",
Some("xml") => "application/xml; charset=utf-8",
Some("css") => "text/css; charset=utf-8",
Some("svg") => "image/svg+xml; charset=utf-8",
Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("ico") => "image/x-icon",
Some("pdf") => "application/pdf",
Some("woff") => "font/woff",
Some("woff2") => "font/woff2",
Some("ttf") => "font/ttf",
Some("mp4") => "video/mp4",
Some("mp3") => "audio/mpeg",
Some("csv") => "text/csv; charset=utf-8",
Some("md") => "text/markdown; charset=utf-8",
Some("yaml") | Some("yml") => "application/yaml; charset=utf-8",
Some("txt") => "text/plain; charset=utf-8",
_ => "application/octet-stream",
}
}
fn db_connection_icon_class(database_kind: &str) -> &'static str {
match database_kind {
"postgresql" => "devicon-postgresql-plain colored",
"mysql" => "devicon-mysql-plain colored",
"sqlite" => "devicon-sqlite-plain colored",
"redis" => "devicon-redis-plain colored",
"mongodb" => "devicon-mongodb-plain colored",
"qdrant" => "devicon-vectorlogozone-plain",
_ => "zf-icon-default-db",
}
}
fn project_web_assets_root(layout: &crate::platform::model::ProjectFileLayout) -> PathBuf {
layout.data_runtime_dir.join("web-assets")
}
#[derive(Debug, Clone, Copy)]
struct ProjectAssetLibrarySpec {
library: &'static str,
version: &'static str,
default_entry: &'static str,
vendor_rel_paths: &'static [&'static str],
npm_deps: &'static [(&'static str, &'static str)],
detect_markers: &'static [&'static str],
}
impl ProjectAssetLibrarySpec {
fn library_root(self, layout: &crate::platform::model::ProjectFileLayout) -> PathBuf {
let mut root = layout.repo_dir.join("libraries");
for segment in self.library.split('/') {
root = root.join(segment);
}
root.join(self.version)
}
}
const PROJECT_ASSET_LIBRARY_SPECS: &[ProjectAssetLibrarySpec] = &[
ProjectAssetLibrarySpec {
library: "zeb/threejs",
version: "0.1",
default_entry: "runtime/threejs.bundle.mjs",
vendor_rel_paths: &[
"library.json",
"exports.json",
"keywords.json",
"runtime/threejs.bundle.mjs",
"runtime/threejs.global.js",
"wrappers/ThreeScene.tsx",
],
npm_deps: &[("three", "0.183.2")],
detect_markers: &[
"/assets/libraries/zeb/threejs/",
"zeb/threejs",
"'three'",
"\"three\"",
],
},
ProjectAssetLibrarySpec {
library: "zeb/deckgl",
version: "0.1",
default_entry: "runtime/deckgl.patched.mjs",
vendor_rel_paths: &[
"library.json",
"exports.json",
"keywords.json",
"runtime/deckgl.bundle.mjs",
"runtime/deckgl.patched.mjs",
"wrappers/DeckMap.tsx",
],
npm_deps: &[
("deck.gl", "9.2.10"),
("@deck.gl/core", "9.2.10"),
("@deck.gl/layers", "9.2.10"),
],
detect_markers: &[
"/assets/libraries/zeb/deckgl/",
"zeb/deckgl",
"'deck.gl'",
"\"deck.gl\"",
"@deck.gl/",
],
},
ProjectAssetLibrarySpec {
library: "zeb/d3",
version: "0.1",
default_entry: "runtime/d3.bundle.mjs",
vendor_rel_paths: &[
"library.json",
"exports.json",
"keywords.json",
"runtime/d3.bundle.mjs",
"wrappers/D3Bars.tsx",
],
npm_deps: &[("d3", "7.9.0")],
detect_markers: &["/assets/libraries/zeb/d3/", "zeb/d3", "'d3'", "\"d3\""],
},
ProjectAssetLibrarySpec {
library: "zeb/livegeo",
version: "0.1",
default_entry: "runtime/livegeo.bundle.mjs",
vendor_rel_paths: &[
"library.json",
"exports.json",
"keywords.json",
"runtime/livegeo.bundle.mjs",
],
npm_deps: &[],
detect_markers: &[
"/assets/libraries/zeb/livegeo/",
"zeb/livegeo",
"usePlayback",
"useTrackPlayback",
],
},
ProjectAssetLibrarySpec {
library: "zeb/icons",
version: "0.1",
default_entry: "runtime/icons.bundle.mjs",
vendor_rel_paths: &[
"library.json",
"exports.json",
"keywords.json",
"runtime/icons.bundle.mjs",
"runtime/devicons.css",
],
npm_deps: &[],
detect_markers: &[
"/assets/libraries/zeb/icons/",
"zeb/icons",
"devicon-",
"zf-devicon",
],
},
ProjectAssetLibrarySpec {
library: "zeb/prosemirror",
version: "0.1",
default_entry: "runtime/prosemirror.bundle.mjs",
vendor_rel_paths: &[
"library.json",
"exports.json",
"keywords.json",
"runtime/prosemirror.bundle.mjs",
"wrappers/ProseEditor.tsx",
],
npm_deps: &[],
detect_markers: &[
"/assets/libraries/zeb/prosemirror/",
"zeb/prosemirror",
"mountProseEditor",
"ProseEditor",
],
},
ProjectAssetLibrarySpec {
library: "zeb/threejs-vrm",
version: "0.1",
default_entry: "runtime/threejs-vrm.bundle.mjs",
vendor_rel_paths: &[
"library.json",
"exports.json",
"keywords.json",
"runtime/threejs-vrm.bundle.mjs",
"wrappers/VrmViewer.tsx",
],
npm_deps: &[("three", "0.183.2"), ("@pixiv/three-vrm", "3.5.0")],
detect_markers: &[
"/assets/libraries/zeb/threejs-vrm/",
"zeb/threejs-vrm",
"@pixiv/three-vrm",
"GLTFLoader",
],
},
];
fn resolve_project_asset_library_spec(
library: &str,
version: &str,
) -> Option<ProjectAssetLibrarySpec> {
PROJECT_ASSET_LIBRARY_SPECS
.iter()
.copied()
.find(|spec| spec.library == library && spec.version == version)
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct InstalledNpmDependency {
package: String,
version: String,
store_path: String,
index_path: String,
linked_path: String,
}
fn library_store_root(data_root: &FsPath) -> PathBuf {
data_root.join("libraries")
}
fn encode_package_for_path(package: &str) -> String {
package
.replace('@', "_at_")
.replace('/', "__")
.replace('\\', "__")
}
fn run_process_capture_stdout(cmd: &mut std::process::Command) -> Result<String, PlatformError> {
let output = cmd.output().map_err(|err| {
PlatformError::new(
"PLATFORM_LIBRARY_PROCESS",
format!("failed spawning process: {err}"),
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
return Err(PlatformError::new(
"PLATFORM_LIBRARY_PROCESS",
format!(
"process exited with status {} stdout='{}' stderr='{}'",
output.status, stdout, stderr
),
));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
fn ensure_npm_packaged_dependency(
libraries_root: &FsPath,
package: &str,
version: &str,
) -> Result<(PathBuf, PathBuf), PlatformError> {
let encoded = encode_package_for_path(package);
let package_dir = libraries_root
.join("installed")
.join("external")
.join("js-registry")
.join(&encoded)
.join(version)
.join("package");
let index_path = libraries_root
.join("indexes")
.join("external")
.join("js-registry")
.join(&encoded)
.join(version)
.join("exports.json");
if package_dir.is_dir() && index_path.is_file() {
return Ok((package_dir, index_path));
}
let download_dir = libraries_root
.join("downloads")
.join("external")
.join("js-registry");
let tmp_root = libraries_root
.join("cache")
.join("external")
.join("js-registry")
.join("tmp");
std::fs::create_dir_all(package_dir.parent().ok_or_else(|| {
PlatformError::new(
"PLATFORM_LIBRARY_PATH",
"failed resolving library package parent directory",
)
})?)?;
std::fs::create_dir_all(&download_dir)?;
std::fs::create_dir_all(index_path.parent().ok_or_else(|| {
PlatformError::new(
"PLATFORM_LIBRARY_PATH",
"failed resolving library index parent directory",
)
})?)?;
std::fs::create_dir_all(&tmp_root)?;
let spec = format!("{package}@{version}");
let npm_path = std::process::Command::new("npm")
.arg("--version")
.output()
.map_err(|err| {
PlatformError::new(
"PLATFORM_LIBRARY_NPM_MISSING",
format!("npm is required but unavailable: {err}"),
)
})?;
if !npm_path.status.success() {
return Err(PlatformError::new(
"PLATFORM_LIBRARY_NPM_MISSING",
"npm command is unavailable",
));
}
let pack_stdout = run_process_capture_stdout(
std::process::Command::new("npm")
.arg("pack")
.arg(&spec)
.arg("--pack-destination")
.arg(&download_dir),
)?;
let tarball_name = pack_stdout
.lines()
.map(|line| line.trim())
.filter(|line| !line.is_empty())
.next_back()
.ok_or_else(|| {
PlatformError::new(
"PLATFORM_LIBRARY_NPM_PACK",
format!("npm pack produced empty output for '{spec}'"),
)
})?;
let tarball = download_dir.join(tarball_name);
if !tarball.is_file() {
return Err(PlatformError::new(
"PLATFORM_LIBRARY_NPM_PACK",
format!(
"npm pack output tarball missing for '{}' (expected '{}')",
spec,
tarball.display()
),
));
}
let tmp_extract_dir = tmp_root.join(format!(
"{}-{}-{}",
encoded,
version,
crate::platform::model::now_ts()
));
if tmp_extract_dir.exists() {
std::fs::remove_dir_all(&tmp_extract_dir)?;
}
std::fs::create_dir_all(&tmp_extract_dir)?;
let _ = run_process_capture_stdout(
std::process::Command::new("tar")
.arg("-xzf")
.arg(&tarball)
.arg("-C")
.arg(&tmp_extract_dir),
)?;
let extracted_package = tmp_extract_dir.join("package");
if !extracted_package.is_dir() {
return Err(PlatformError::new(
"PLATFORM_LIBRARY_NPM_PACK",
format!(
"tar extraction for '{}' missing 'package/' folder at '{}'",
spec,
extracted_package.display()
),
));
}
if package_dir.exists() {
std::fs::remove_dir_all(&package_dir)?;
}
if let Some(parent) = package_dir.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::rename(&extracted_package, &package_dir)?;
let _ = std::fs::remove_dir_all(&tmp_extract_dir);
build_package_declaration_index(&package_dir, &index_path, package, version)?;
Ok((package_dir, index_path))
}
fn collect_files_recursively_with_exts(
root: &FsPath,
exts: &[&str],
out: &mut Vec<PathBuf>,
) -> Result<(), PlatformError> {
if !root.exists() {
return Ok(());
}
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir)? {
let entry = entry?;
let path = entry.path();
let ft = entry.file_type()?;
if ft.is_symlink() {
continue;
}
if ft.is_dir() {
stack.push(path);
continue;
}
if ft.is_file() {
let ext = path
.extension()
.and_then(|value| value.to_str())
.unwrap_or("");
let filename = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("");
if exts.iter().any(|item| {
item.eq_ignore_ascii_case(ext)
|| filename
.to_ascii_lowercase()
.ends_with(&item.to_ascii_lowercase())
}) {
out.push(path);
}
}
}
}
Ok(())
}
fn extract_export_symbols_from_ts_declaration(source: &str) -> BTreeSet<String> {
let mut symbols = BTreeSet::new();
let keywords = [
"const",
"function",
"class",
"interface",
"type",
"enum",
"namespace",
"let",
"var",
];
for line in source.lines() {
let trimmed = line.trim();
if !trimmed.starts_with("export ") {
continue;
}
if let Some(rest) = trimmed.strip_prefix("export {") {
let block = rest.split('}').next().unwrap_or_default();
for item in block.split(',') {
let name = item.trim();
if name.is_empty() {
continue;
}
let alias = name.split_whitespace().next().unwrap_or_default();
if !alias.is_empty() {
symbols.insert(alias.to_string());
}
}
continue;
}
for keyword in keywords {
let needle = format!("export {keyword} ");
if let Some(rest) = trimmed.strip_prefix(&needle) {
let ident: String = rest
.chars()
.take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_' || *ch == '$')
.collect();
if !ident.is_empty() {
symbols.insert(ident);
}
}
let declare_needle = format!("export declare {keyword} ");
if let Some(rest) = trimmed.strip_prefix(&declare_needle) {
let ident: String = rest
.chars()
.take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_' || *ch == '$')
.collect();
if !ident.is_empty() {
symbols.insert(ident);
}
}
}
}
symbols
}
fn build_package_declaration_index(
package_dir: &FsPath,
index_path: &FsPath,
package: &str,
version: &str,
) -> Result<(), PlatformError> {
let mut declaration_files = Vec::new();
collect_files_recursively_with_exts(
package_dir,
&["d.ts", "d.mts", "d.cts"],
&mut declaration_files,
)?;
declaration_files.sort();
let mut all_symbols = BTreeSet::new();
let mut files_meta = Vec::new();
for file in declaration_files.iter().take(600) {
let rel = file
.strip_prefix(package_dir)
.unwrap_or(file)
.to_string_lossy()
.replace('\\', "/");
let source = std::fs::read_to_string(file).unwrap_or_default();
let symbols = extract_export_symbols_from_ts_declaration(&source);
for symbol in &symbols {
all_symbols.insert(symbol.clone());
}
files_meta.push(json!({
"path": rel,
"export_count": symbols.len()
}));
}
if let Some(parent) = index_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(
index_path,
serde_json::to_vec_pretty(&json!({
"schema_version": "0.1",
"package": package,
"version": version,
"generated_at": crate::platform::model::now_ts(),
"total_declaration_files": declaration_files.len(),
"total_exports": all_symbols.len(),
"exports": all_symbols.into_iter().collect::<Vec<_>>(),
"files": files_meta,
}))?,
)?;
Ok(())
}
fn link_dependency_into_project_node_modules(
layout: &crate::platform::model::ProjectFileLayout,
package: &str,
package_dir: &FsPath,
) -> Result<PathBuf, PlatformError> {
let node_modules = layout.repo_dir.join("node_modules");
std::fs::create_dir_all(&node_modules)?;
let mut dest = node_modules.clone();
for segment in package.split('/') {
dest = dest.join(segment);
}
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
if dest.exists() || std::fs::symlink_metadata(&dest).is_ok() {
if dest.is_dir() && !std::fs::symlink_metadata(&dest)?.file_type().is_symlink() {
std::fs::remove_dir_all(&dest)?;
} else {
std::fs::remove_file(&dest)?;
}
}
let target = std::fs::canonicalize(package_dir).unwrap_or_else(|_| package_dir.to_path_buf());
#[cfg(unix)]
std::os::unix::fs::symlink(&target, &dest).map_err(|err| {
PlatformError::new(
"PLATFORM_LIBRARY_LINK",
format!(
"failed linking '{}' -> '{}': {err}",
dest.display(),
target.display()
),
)
})?;
#[cfg(not(unix))]
{
return Err(PlatformError::new(
"PLATFORM_LIBRARY_LINK",
"symlink-based node_modules linking is only implemented for unix targets",
));
}
Ok(dest)
}
fn update_project_libraries_lock(
layout: &crate::platform::model::ProjectFileLayout,
spec: ProjectAssetLibrarySpec,
installed_deps: &[InstalledNpmDependency],
) -> Result<(), PlatformError> {
let lock_path = layout.repo_dir.join("libraries.lock.json");
let mut root = if lock_path.is_file() {
let raw = std::fs::read_to_string(&lock_path).unwrap_or_default();
serde_json::from_str::<serde_json::Value>(&raw).unwrap_or_else(|_| json!({}))
} else {
json!({})
};
let libraries_obj = root
.as_object_mut()
.map(|obj| obj.entry("libraries").or_insert_with(|| json!({})))
.and_then(|value| value.as_object_mut())
.ok_or_else(|| {
PlatformError::new(
"PLATFORM_LIBRARY_LOCK",
"libraries.lock.json has invalid shape",
)
})?;
let key = format!("{}@{}", spec.library, spec.version);
libraries_obj.insert(
key,
json!({
"library": spec.library,
"version": spec.version,
"updated_at": crate::platform::model::now_ts(),
"npm_deps": installed_deps,
}),
);
root["schema_version"] = json!("0.1");
root["updated_at"] = json!(crate::platform::model::now_ts());
std::fs::write(lock_path, serde_json::to_vec_pretty(&root)?)?;
Ok(())
}
fn ensure_library_npm_dependencies(
data_root: &FsPath,
layout: &crate::platform::model::ProjectFileLayout,
spec: ProjectAssetLibrarySpec,
) -> Result<Vec<InstalledNpmDependency>, PlatformError> {
let store_root = library_store_root(data_root);
std::fs::create_dir_all(&store_root)?;
let mut installed = Vec::new();
for (package, version) in spec.npm_deps {
let (package_dir, index_path) =
ensure_npm_packaged_dependency(&store_root, package, version)?;
let linked_path = link_dependency_into_project_node_modules(layout, package, &package_dir)?;
installed.push(InstalledNpmDependency {
package: (*package).to_string(),
version: (*version).to_string(),
store_path: package_dir.display().to_string(),
index_path: index_path.display().to_string(),
linked_path: linked_path.display().to_string(),
});
}
update_project_libraries_lock(layout, spec, &installed)?;
Ok(installed)
}
fn detect_required_project_library_specs(
templates_root: &FsPath,
) -> Result<Vec<ProjectAssetLibrarySpec>, PlatformError> {
let mut files = Vec::new();
collect_files_recursively_with_exts(
templates_root,
&["tsx", "ts", "jsx", "js", "mjs"],
&mut files,
)?;
let mut detected = BTreeSet::new();
for path in files {
let source = match std::fs::read_to_string(&path) {
Ok(source) => source,
Err(_) => continue,
};
for spec in PROJECT_ASSET_LIBRARY_SPECS {
if spec
.detect_markers
.iter()
.any(|marker| source.contains(marker))
{
detected.insert((spec.library, spec.version));
}
}
if let Ok(imports) = parse_module_imports_with_swc(&path, &source) {
for import in imports {
if import == "three" {
detected.insert(("zeb/threejs", "0.1"));
}
if import == "d3" || import.starts_with("d3-") {
detected.insert(("zeb/d3", "0.1"));
}
if import == "@pixiv/three-vrm" {
detected.insert(("zeb/threejs-vrm", "0.1"));
}
if import == "deck.gl" || import.starts_with("@deck.gl/") {
detected.insert(("zeb/deckgl", "0.1"));
}
}
}
}
Ok(detected
.into_iter()
.filter_map(|(library, version)| resolve_project_asset_library_spec(library, version))
.collect())
}
fn trigger_project_asset_prepare_on_template_save(
state: &PlatformAppState,
owner: &str,
project: &str,
layout: &crate::platform::model::ProjectFileLayout,
) -> Result<(), PlatformError> {
let detected_specs = detect_required_project_library_specs(&layout.repo_pipelines_dir)?;
for spec in detected_specs {
let _ = prepare_project_assets_manifest(
owner,
project,
&state.platform.config.data_root,
layout,
PrepareProjectAssetsRequest {
library: spec.library.to_string(),
version: spec.version.to_string(),
entries: Vec::new(),
},
)?;
}
Ok(())
}
fn prepare_project_assets_manifest(
owner: &str,
project: &str,
data_root: &FsPath,
layout: &crate::platform::model::ProjectFileLayout,
req: PrepareProjectAssetsRequest,
) -> Result<ProjectAssetManifest, PlatformError> {
let library = req.library.trim().to_string();
let version = req.version.trim().to_string();
let Some(spec) = resolve_project_asset_library_spec(&library, &version) else {
let supported = PROJECT_ASSET_LIBRARY_SPECS
.iter()
.map(|item| format!("{}@{}", item.library, item.version))
.collect::<Vec<_>>()
.join(", ");
return Err(PlatformError::new(
"PLATFORM_ASSET_PREPARE_UNSUPPORTED",
format!(
"library '{}' version '{}' is unsupported. supported: {supported}",
library, version
),
));
};
let entries = if req.entries.is_empty() {
vec![spec.default_entry.to_string()]
} else {
req.entries
};
let _installed_deps = ensure_library_npm_dependencies(data_root, layout, spec)?;
let library_root = spec.library_root(layout);
materialize_vendor_library(spec, &library_root)?;
let assets_root = project_web_assets_root(layout);
let rwe_assets_root = assets_root.join("rwe");
std::fs::create_dir_all(rwe_assets_root.join("chunks"))?;
let mut entry_items = Vec::new();
for entry in entries {
let rel = entry.trim().replace('\\', "/");
if rel.is_empty() || rel.contains("..") {
return Err(PlatformError::new(
"PLATFORM_ASSET_ENTRY_INVALID",
"entry path is invalid",
));
}
let entry_abs = library_root.join(&rel);
if !entry_abs.is_file() {
return Err(PlatformError::new(
"PLATFORM_ASSET_ENTRY_MISSING",
format!("entry '{}' not found in vendored library", rel),
));
}
let graph = build_module_graph_with_swc(&entry_abs, &library_root)?;
let mut chunk_source = String::new();
for module_rel in &graph.modules {
let module_abs = library_root.join(module_rel);
let source = std::fs::read_to_string(&module_abs)?;
chunk_source.push_str(&format!("// module: {module_rel}\n{source}\n"));
}
let chunk_source = compact_chunk_javascript(&chunk_source);
let chunk_id = stable_fnv64_hex(&chunk_source);
let chunk_rel = format!("rwe/chunks/{chunk_id}.mjs");
let chunk_abs = assets_root.join(&chunk_rel);
if let Some(parent) = chunk_abs.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&chunk_abs, chunk_source)?;
let chunk_url = format!("/assets/{owner}/{project}/{chunk_rel}");
let entry_item = ProjectAssetEntryItem {
entry: rel.clone(),
path: format!("app/libraries/{}/{}/{rel}", spec.library, spec.version),
url: chunk_url.clone(),
chunks: vec![ProjectAssetChunkItem {
chunk_id,
module_count: graph.modules.len(),
modules: graph.modules.clone(),
path: format!("data/runtime/web-assets/{chunk_rel}"),
url: chunk_url,
}],
imports: graph.imports,
};
entry_items.push(entry_item);
}
let manifest_abs = assets_root.join("rwe").join("manifest.json");
let mut manifest = match std::fs::read(&manifest_abs) {
Ok(bytes) => {
serde_json::from_slice::<ProjectAssetManifest>(&bytes).unwrap_or(ProjectAssetManifest {
schema_version: "0.1".to_string(),
owner: owner.to_string(),
project: project.to_string(),
generated_at: crate::platform::model::now_ts(),
strategy: "swc-import-graph-scaffold".to_string(),
libraries: Vec::new(),
})
}
Err(_) => ProjectAssetManifest {
schema_version: "0.1".to_string(),
owner: owner.to_string(),
project: project.to_string(),
generated_at: crate::platform::model::now_ts(),
strategy: "swc-import-graph-scaffold".to_string(),
libraries: Vec::new(),
},
};
manifest.schema_version = "0.1".to_string();
manifest.owner = owner.to_string();
manifest.project = project.to_string();
manifest.generated_at = crate::platform::model::now_ts();
manifest.strategy = "swc-import-graph-scaffold".to_string();
if let Some(item) = manifest
.libraries
.iter_mut()
.find(|item| item.library == library && item.version == version)
{
item.entries = entry_items;
} else {
manifest.libraries.push(ProjectAssetLibraryItem {
library,
version,
entries: entry_items,
});
}
manifest
.libraries
.sort_by(|a, b| (&a.library, &a.version).cmp(&(&b.library, &b.version)));
if let Some(parent) = manifest_abs.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&manifest_abs, serde_json::to_vec_pretty(&manifest)?)?;
Ok(manifest)
}
fn materialize_vendor_library(
spec: ProjectAssetLibrarySpec,
library_root: &FsPath,
) -> Result<(), PlatformError> {
for rel in spec.vendor_rel_paths {
let catalog = format!("{}/{}/{}", spec.library, spec.version, rel);
let Some(bytes) = platform_library_asset(&catalog) else {
return Err(PlatformError::new(
"PLATFORM_ASSET_VENDOR_SOURCE_MISSING",
format!("embedded library asset '{catalog}' not found"),
));
};
let abs = library_root.join(rel);
if let Some(parent) = abs.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(abs, bytes)?;
}
Ok(())
}
#[derive(Debug, Clone, Default)]
struct ModuleGraphSummary {
modules: Vec<String>,
imports: Vec<String>,
}
fn build_module_graph_with_swc(
entry_abs: &FsPath,
root: &FsPath,
) -> Result<ModuleGraphSummary, PlatformError> {
let mut queue = VecDeque::new();
let mut seen = BTreeSet::new();
let mut ordered_modules = Vec::new();
let mut imports = BTreeSet::new();
queue.push_back(entry_abs.to_path_buf());
while let Some(module_abs) = queue.pop_front() {
let Ok(rel) = module_abs.strip_prefix(root) else {
continue;
};
let rel_norm = rel.to_string_lossy().replace('\\', "/");
if !seen.insert(rel_norm.clone()) {
continue;
}
ordered_modules.push(rel_norm.clone());
let source = std::fs::read_to_string(&module_abs)?;
let specs = parse_module_imports_with_swc(&module_abs, &source)?;
for spec in specs {
if spec.starts_with("./") || spec.starts_with("../") {
if let Some(next) = resolve_relative_module(&module_abs, &spec) {
queue.push_back(next);
}
} else {
imports.insert(spec);
}
}
}
Ok(ModuleGraphSummary {
modules: ordered_modules,
imports: imports.into_iter().collect(),
})
}
fn parse_module_imports_with_swc(
path: &FsPath,
source: &str,
) -> Result<Vec<String>, PlatformError> {
let cm: Lrc<SourceMap> = Default::default();
let fm = cm.new_source_file(
FileName::Real(path.to_path_buf()).into(),
source.to_string(),
);
let lexer = Lexer::new(
Syntax::Typescript(TsSyntax {
tsx: true,
decorators: true,
..Default::default()
}),
Default::default(),
StringInput::from(&*fm),
None,
);
let mut parser = Parser::new_from(lexer);
let module = parser.parse_module().map_err(|err| {
PlatformError::new(
"PLATFORM_ASSET_SWC_PARSE",
format!("failed parsing '{}': {err:?}", path.display()),
)
})?;
let mut imports = Vec::new();
for item in module.body {
match item {
ModuleItem::ModuleDecl(decl) => match decl {
ModuleDecl::Import(import) => {
imports.push(import.src.value.to_string_lossy().to_string())
}
ModuleDecl::ExportAll(export) => {
imports.push(export.src.value.to_string_lossy().to_string())
}
ModuleDecl::ExportNamed(export) => {
if let Some(src) = export.src {
imports.push(src.value.to_string_lossy().to_string());
}
}
_ => {}
},
ModuleItem::Stmt(stmt) => {
if let swc_ecma_ast::Stmt::Expr(expr_stmt) = stmt
&& let Expr::Call(call) = *expr_stmt.expr
&& matches!(call.callee, Callee::Import(_))
&& let Some(first) = call.args.first()
&& let Expr::Lit(swc_ecma_ast::Lit::Str(s)) = &*first.expr
{
imports.push(s.value.to_string_lossy().to_string());
}
}
}
}
Ok(imports)
}
fn resolve_relative_module(base_abs: &FsPath, spec: &str) -> Option<PathBuf> {
let parent = base_abs.parent()?;
let stem = parent.join(spec);
let candidates = [
stem.clone(),
stem.with_extension("mjs"),
stem.with_extension("js"),
stem.with_extension("ts"),
stem.with_extension("tsx"),
stem.join("index.mjs"),
stem.join("index.js"),
stem.join("index.ts"),
stem.join("index.tsx"),
];
candidates.into_iter().find(|candidate| candidate.is_file())
}
fn stable_fnv64_hex(input: &str) -> String {
let mut h: u64 = 0xcbf29ce484222325;
for b in input.as_bytes() {
h ^= *b as u64;
h = h.wrapping_mul(0x00000100000001B3);
}
format!("{h:016x}")
}
fn compact_chunk_javascript(source: &str) -> String {
let mut out = String::new();
for line in source.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with("// module:") {
continue;
}
out.push_str(trimmed);
out.push('\n');
}
out
}
fn template_kind_from_rel(rel: &str) -> &'static str {
if rel.ends_with(".css") {
"style"
} else if rel.ends_with(".ts") {
"script"
} else if rel.contains("/pages/") || rel.starts_with("pages/") {
"page"
} else {
"component"
}
}
async fn api_meta(State(state): State<PlatformAppState>) -> Response {
Json(json!({
"ok": true,
"data_adapter": state.platform.data.id(),
"file_adapter": state.platform.file.id(),
"project_data_factory": state.platform.project_data.id(),
"project_data_engines": state.platform.project_data.enabled_engines(),
}))
.into_response()
}
async fn api_system_info(State(state): State<PlatformAppState>, headers: HeaderMap) -> Response {
if session_owner(&state, &headers).is_none() {
return StatusCode::UNAUTHORIZED.into_response();
}
let info = tokio::task::spawn_blocking(collect_system_info)
.await
.unwrap_or_else(|_| json!({"ok": false, "error": "collection failed"}));
Json(info).into_response()
}
fn collect_system_info() -> serde_json::Value {
use sysinfo::{Disks, Pid, System};
let mut sys = System::new_all();
sys.refresh_all();
sys.refresh_cpu_usage();
std::thread::sleep(std::time::Duration::from_millis(250));
sys.refresh_cpu_usage();
let os_name = System::name().unwrap_or_else(|| "Unknown".into());
let os_version = System::os_version().unwrap_or_default();
let kernel = System::kernel_version().unwrap_or_default();
let hostname = System::host_name().unwrap_or_default();
let arch = std::env::consts::ARCH;
let os_variant = detect_os_variant();
let cpu_count = sys.cpus().len();
let cpu_brand = sys
.cpus()
.first()
.map(|c| c.brand().to_string())
.unwrap_or_default();
let cpu_usage = sys.global_cpu_usage();
let total_mem = sys.total_memory();
let used_mem = sys.used_memory();
let avail_mem = sys.available_memory();
let mem_pct = if total_mem > 0 {
used_mem as f64 / total_mem as f64 * 100.0
} else {
0.0
};
let disks = Disks::new_with_refreshed_list();
let (total_disk, avail_disk) = disks.iter().fold((0u64, 0u64), |(t, a), d| {
(t + d.total_space(), a + d.available_space())
});
let used_disk = total_disk.saturating_sub(avail_disk);
let disk_pct = if total_disk > 0 {
used_disk as f64 / total_disk as f64 * 100.0
} else {
0.0
};
let pid = Pid::from_u32(std::process::id());
let (proc_cpu, proc_mem, proc_virt, proc_threads) = sys
.process(pid)
.map(|p| {
(
p.cpu_usage(),
p.memory(),
p.virtual_memory(),
p.tasks().map(|t| t.len()).unwrap_or(0),
)
})
.unwrap_or((0.0, 0, 0, 0));
let proc_uptime = sys.process(pid).map(|p| p.run_time()).unwrap_or(0);
let caps = collect_capabilities();
json!({
"ok": true,
"system": {
"os": {
"name": os_name,
"version": os_version,
"kernel": kernel,
"arch": arch,
"hostname": hostname,
"variant": os_variant,
"container": detect_container_context(),
},
"cpu": {
"cores": cpu_count,
"brand": cpu_brand,
"usage_pct": (cpu_usage * 10.0).round() / 10.0,
},
"memory": {
"total_bytes": total_mem,
"used_bytes": used_mem,
"available_bytes": avail_mem,
"usage_pct": (mem_pct * 10.0).round() / 10.0,
},
"disk": {
"total_bytes": total_disk,
"used_bytes": used_disk,
"available_bytes": avail_disk,
"usage_pct": (disk_pct * 10.0).round() / 10.0,
}
},
"process": {
"pid": std::process::id(),
"cpu_pct": (proc_cpu * 10.0).round() / 10.0,
"memory_bytes": proc_mem,
"virtual_memory_bytes": proc_virt,
"threads": proc_threads,
"uptime_seconds": proc_uptime,
},
"capabilities": caps,
})
}
fn detect_os_variant() -> &'static str {
#[cfg(target_os = "linux")]
if let Ok(info) = std::fs::read_to_string("/proc/cpuinfo") {
if info.contains("Raspberry Pi") || info.contains("BCM") {
return "raspberry-pi";
}
}
#[cfg(target_os = "linux")]
if let Ok(ver) = std::fs::read_to_string("/proc/version") {
let lower = ver.to_lowercase();
if lower.contains("microsoft") || lower.contains("wsl") {
return "wsl";
}
}
#[cfg(target_os = "macos")]
{
"macos"
}
#[cfg(target_os = "windows")]
{
"windows"
}
#[cfg(target_os = "linux")]
{
"linux"
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
"unknown"
}
}
fn collect_capabilities() -> serde_json::Value {
let home = dirs_next::home_dir().unwrap_or_default();
let zf_base = home.join(".zebflow");
let python_info = probe_python_system();
let managed_python = zf_base
.join("engines")
.join("python")
.join("bin")
.join("python3");
let python_managed_available = managed_python.exists();
let lp_path = zf_base.join("browsers").join("lightpanda");
let lp_installed = lp_path.exists();
let lp_version = if lp_installed {
probe_binary_version(&lp_path, "--version")
} else {
None
};
let chromium_dir = zf_base.join("browsers").join("chromium");
let chromium_installed = chromium_dir.exists()
&& chromium_dir
.read_dir()
.map(|mut d| d.next().is_some())
.unwrap_or(false);
let ollama_path = zf_base.join("engines").join("ollama");
let ollama_installed = ollama_path.exists();
let ollama_version = if ollama_installed {
probe_binary_version(&ollama_path, "--version")
} else {
probe_binary_version(std::path::Path::new("ollama"), "--version")
};
let searxng_installed = std::net::TcpStream::connect("127.0.0.1:8888").is_ok();
let vips_version = probe_binary_version(std::path::Path::new("vips"), "--version");
let nmap_version = probe_binary_version(std::path::Path::new("nmap"), "--version");
let nuclei_version = probe_binary_version(std::path::Path::new("nuclei"), "-version");
let httpx_version = probe_binary_version(std::path::Path::new("httpx"), "-version");
let trivy_version = probe_binary_version(std::path::Path::new("trivy"), "--version");
let masscan_version = probe_binary_version(std::path::Path::new("masscan"), "--version");
let ffuf_version = probe_binary_version(std::path::Path::new("ffuf"), "-V");
let sqlmap_version = probe_binary_version(std::path::Path::new("sqlmap"), "--version");
let nikto_version = probe_binary_version(std::path::Path::new("nikto"), "--version");
json!({
"python": python_info,
"python_managed": {
"available": python_managed_available,
"path": if python_managed_available { Some(managed_python.to_string_lossy().to_string()) } else { None },
},
"lightpanda": {
"installed": lp_installed,
"version": lp_version,
},
"chromium": {
"installed": chromium_installed,
},
"ollama": {
"installed": ollama_installed || ollama_version.is_some(),
"version": ollama_version,
},
"searxng": {
"installed": searxng_installed,
},
"vips": {
"installed": vips_version.is_some(),
"version": vips_version,
},
"security": {
"nmap": { "installed": nmap_version.is_some(), "version": nmap_version },
"nuclei": { "installed": nuclei_version.is_some(), "version": nuclei_version },
"httpx": { "installed": httpx_version.is_some(), "version": httpx_version },
"trivy": { "installed": trivy_version.is_some(), "version": trivy_version },
"masscan": { "installed": masscan_version.is_some(), "version": masscan_version },
"ffuf": { "installed": ffuf_version.is_some(), "version": ffuf_version },
"sqlmap": { "installed": sqlmap_version.is_some(), "version": sqlmap_version },
"nikto": { "installed": nikto_version.is_some(), "version": nikto_version },
},
})
}
fn detect_container_context() -> serde_json::Value {
let in_docker = std::path::Path::new("/.dockerenv").exists();
#[cfg(target_os = "linux")]
let host_gateway: Option<String> =
std::fs::read_to_string("/proc/net/route")
.ok()
.and_then(|content| {
content.lines().skip(1).find_map(|line| {
let cols: Vec<&str> = line.split_whitespace().collect();
if cols.len() >= 3 && cols[1] == "00000000" {
u32::from_str_radix(cols[2], 16).ok().map(|hex| {
let b = hex.to_le_bytes();
format!("{}.{}.{}.{}", b[0], b[1], b[2], b[3])
})
} else {
None
}
})
});
#[cfg(not(target_os = "linux"))]
let host_gateway: Option<String> = None;
json!({
"in_docker": in_docker,
"host_gateway": host_gateway,
})
}
fn probe_python_system() -> serde_json::Value {
for cmd in &["python3", "python"] {
if let Ok(out) = std::process::Command::new(cmd).arg("--version").output() {
if out.status.success() {
let raw = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let version = raw.trim().trim_start_matches("Python ").to_string();
return json!({ "available": true, "version": version, "cmd": cmd });
}
}
}
json!({ "available": false })
}
fn probe_binary_version(path: &std::path::Path, flag: &str) -> Option<String> {
std::process::Command::new(path)
.arg(flag)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| {
let raw = format!(
"{}{}",
String::from_utf8_lossy(&o.stdout),
String::from_utf8_lossy(&o.stderr)
);
raw.lines().next().unwrap_or("").trim().to_string()
})
}
fn node_credential_requirements_from_manifest(
item: &crate::pipeline::NodeContractItem,
manifest: &NodePackageManifest,
) -> Vec<crate::pipeline::NodeCredentialRequirement> {
let required_keys = item
.config_schema
.get("required")
.and_then(|value| value.as_array())
.map(|items| {
items
.iter()
.filter_map(|value| value.as_str().map(str::to_string))
.collect::<std::collections::HashSet<_>>()
})
.unwrap_or_default();
manifest
.credentials
.iter()
.filter(|credential| !credential.kind.trim().is_empty())
.map(|credential| {
let config_key = credential.config_key.clone();
let mut placeholder_names = credential.placeholders.keys().cloned().collect::<Vec<_>>();
placeholder_names.sort();
let required = !config_key.is_empty()
&& (required_keys.contains(&config_key)
|| item
.dsl_flags
.iter()
.any(|flag| flag.config_key == config_key && flag.required));
crate::pipeline::NodeCredentialRequirement {
kind: credential.kind.clone(),
title: credential.title.clone(),
description: credential.description.clone(),
config_key,
required,
placeholder_names,
}
})
.collect()
}
fn apply_manifest_metadata_to_node_contract(
item: &mut crate::pipeline::NodeContractItem,
manifest: Option<&NodePackageManifest>,
) {
if let Some(manifest) = manifest {
item.credential_requirements = node_credential_requirements_from_manifest(item, manifest);
}
}
async fn docs_node_contract() -> Response {
let mut items = crate::pipeline::nodes::builtin_node_definitions()
.into_iter()
.map(|def| {
let mut item = crate::pipeline::NodeContractItem::from(def);
item.tier = "official".to_string();
item
})
.collect::<Vec<_>>();
for manifest in NodeRegistryService::embedded_official_manifests() {
let mut item = crate::pipeline::NodeContractItem::from(manifest.definition.clone());
item.tier = "official".to_string();
apply_manifest_metadata_to_node_contract(&mut item, Some(&manifest));
items.push(item);
}
items.sort_by(|a, b| a.kind.cmp(&b.kind));
Json(crate::pipeline::NodeContractDocument {
ok: true,
schema_version: "0.1",
source: "pipeline::nodes + embedded_official_composites",
items,
})
.into_response()
}
async fn docs_operation_contract() -> Response {
Json(crate::platform::operations::OperationContractDocument {
ok: true,
schema_version: "0.1",
source: "platform::operations::OPERATIONS",
items: crate::platform::operations::operation_contract_items(),
})
.into_response()
}
async fn api_list_node_definitions(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::SettingsRead,
) {
return response;
}
let defs = state
.platform
.node_registry
.merged_definitions(&owner, &project);
let items: Vec<crate::pipeline::NodeContractItem> = defs
.into_iter()
.map(|def| {
let kind = def.kind.clone();
let mut item = crate::pipeline::NodeContractItem::from(def);
item.tier = if state.platform.node_registry.is_official(&kind) {
"official".to_string()
} else {
"community".to_string()
};
let manifest = state
.platform
.node_registry
.get_manifest(&owner, &project, &kind);
apply_manifest_metadata_to_node_contract(&mut item, manifest.as_ref());
let icon_bytes: Option<Vec<u8>> = state
.platform
.node_registry
.load_icon_any(&owner, &project, &kind)
.or_else(|| {
let path = format!("zebflow/{}.svg", kind);
platform_node_icon_asset(&path).map(|b| b.to_vec())
});
if let Some(bytes) = icon_bytes {
item.icon_url = format!("/api/projects/{}/{}/nodes/icon/{}", owner, project, kind);
use sha2::{Digest, Sha256};
let hash = Sha256::digest(&bytes);
item.icon_hash = format!(
"{:02x}{:02x}{:02x}{:02x}",
hash[0], hash[1], hash[2], hash[3]
);
}
item
})
.collect();
Json(json!({
"ok": true,
"items": items
}))
.into_response()
}
async fn api_get_node_definition(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, kind)): Path<(String, String, String)>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::SettingsRead,
) {
return response;
}
let merged = state
.platform
.node_registry
.merged_definitions(&owner, &project);
match merged.into_iter().find(|d| d.kind == kind) {
Some(def) => {
let mut item = crate::pipeline::NodeContractItem::from(def);
item.tier = if state.platform.node_registry.is_official(&kind) {
"official".to_string()
} else {
"community".to_string()
};
let manifest = state
.platform
.node_registry
.get_manifest(&owner, &project, &kind);
apply_manifest_metadata_to_node_contract(&mut item, manifest.as_ref());
Json(json!({ "ok": true, "item": item })).into_response()
}
None => Json(json!({
"ok": false,
"error": format!("node kind '{}' not found", kind)
}))
.into_response(),
}
}
async fn api_install_node_package(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
Json(body): Json<NodePackageInstallRequest>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::SettingsWrite,
) {
return response;
}
match state.platform.node_registry.install_package(
&owner,
&project,
&body.manifest,
body.pipeline_source.as_deref(),
body.icon_svg.as_deref(),
) {
Ok(pkg) => Json(json!({
"ok": true,
"kind": pkg.manifest.definition.kind,
"slug": pkg.slug,
}))
.into_response(),
Err(e) => Json(json!({
"ok": false,
"error": format!("{}: {}", e.code, e.message)
}))
.into_response(),
}
}
async fn api_uninstall_node_package(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, kind)): Path<(String, String, String)>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::SettingsWrite,
) {
return response;
}
if state.platform.node_registry.is_official(&kind) {
return Json(json!({
"ok": false,
"error": "cannot uninstall official node kinds"
}))
.into_response();
}
match state
.platform
.node_registry
.uninstall_package(&owner, &project, &kind)
{
Ok(()) => Json(json!({"ok": true})).into_response(),
Err(e) => Json(json!({
"ok": false,
"error": format!("{}: {}", e.code, e.message)
}))
.into_response(),
}
}
async fn api_node_icon(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, kind)): Path<(String, String, String)>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::SettingsRead,
) {
return response;
}
if let Some(svg_bytes) = state
.platform
.node_registry
.load_icon_any(&owner, &project, &kind)
{
return (
[
(CONTENT_TYPE, "image/svg+xml"),
(CACHE_CONTROL, "public, max-age=31536000, immutable"),
],
svg_bytes,
)
.into_response();
}
let icon_path = format!("zebflow/{}.svg", kind);
if let Some(bytes) = platform_node_icon_asset(&icon_path) {
return (
[
(CONTENT_TYPE, "image/svg+xml"),
(CACHE_CONTROL, "public, max-age=31536000, immutable"),
],
bytes.to_vec(),
)
.into_response();
}
StatusCode::NOT_FOUND.into_response()
}
#[derive(serde::Deserialize)]
struct NodePackageInstallRequest {
manifest: crate::platform::model::NodePackageManifest,
#[serde(default)]
pipeline_source: Option<String>,
#[serde(default)]
icon_svg: Option<String>,
}
fn require_superadmin(state: &PlatformAppState, headers: &HeaderMap) -> Result<(), Response> {
let Some(owner) = session_owner(state, headers) else {
return Err(StatusCode::UNAUTHORIZED.into_response());
};
if !is_superadmin_owner(state, &owner) {
return Err(StatusCode::FORBIDDEN.into_response());
}
Ok(())
}
fn is_superadmin_owner(state: &PlatformAppState, owner: &str) -> bool {
state
.platform
.users
.get_user(owner)
.ok()
.flatten()
.map(|u| u.role == "superadmin")
.unwrap_or(false)
}
fn platform_marketplace_source_owner(state: &PlatformAppState) -> Option<String> {
state
.platform
.users
.list_users()
.ok()?
.into_iter()
.find(|user| user.role == "superadmin")
.map(|user| user.owner)
}
fn visible_platform_marketplace_sources(
state: &PlatformAppState,
session_owner: &str,
) -> Result<
(
String,
Vec<crate::platform::model::PlatformMarketplaceRepository>,
),
PlatformError,
> {
let source_owner =
platform_marketplace_source_owner(state).unwrap_or_else(|| slug_segment(session_owner));
state
.platform
.marketplace
.ensure_default_platform_repository(&source_owner)?;
let mut items = state
.platform
.marketplace
.list_platform_repositories(&source_owner)?;
if !is_superadmin_owner(state, session_owner) {
items.retain(|item| item.visibility == "public");
}
Ok((source_owner, items))
}
fn require_owner_or_superadmin(
state: &PlatformAppState,
headers: &HeaderMap,
owner: &str,
) -> Result<String, Response> {
let Some(session) = session_owner(state, headers) else {
return Err(StatusCode::UNAUTHORIZED.into_response());
};
let session_slug = crate::platform::model::slug_segment(&session);
let owner_slug = crate::platform::model::slug_segment(owner);
if session_slug == owner_slug || is_superadmin_owner(state, &session) {
return Ok(session);
}
Err(StatusCode::FORBIDDEN.into_response())
}
async fn api_admin_db_list_collections(
State(state): State<PlatformAppState>,
headers: HeaderMap,
) -> Response {
if let Err(r) = require_superadmin(&state, &headers) {
return r;
}
match state.platform.data.admin_list_collections() {
Ok(collections) => Json(json!({
"ok": true,
"collections": collections.into_iter().map(|(name, count)| json!({"name": name, "count": count})).collect::<Vec<_>>()
}))
.into_response(),
Err(err) => internal_error(err),
}
}
#[derive(serde::Deserialize)]
struct AdminDbQueryRequest {
pipeline: serde_json::Value,
}
async fn api_admin_db_query(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Json(req): Json<AdminDbQueryRequest>,
) -> Response {
if let Err(r) = require_superadmin(&state, &headers) {
return r;
}
let q = json!({"pipeline": req.pipeline}).to_string();
match state.platform.data.admin_raw_query(&q) {
Ok(rows) => {
let count = rows.len();
Json(json!({"ok": true, "rows": rows, "count": count})).into_response()
}
Err(err) => internal_error(err),
}
}
async fn api_admin_db_get_node(
State(state): State<PlatformAppState>,
Path(slug): Path<String>,
headers: HeaderMap,
) -> Response {
if let Err(r) = require_superadmin(&state, &headers) {
return r;
}
match state.platform.data.admin_get_node(&slug) {
Ok(Some(raw)) => {
let v: serde_json::Value =
serde_json::from_str(&raw).unwrap_or(serde_json::Value::String(raw));
Json(json!({"ok": true, "slug": slug, "node": v})).into_response()
}
Ok(None) => (
StatusCode::NOT_FOUND,
Json(json!({"ok": false, "error": {"code": "NODE_NOT_FOUND", "message": format!("Node not found: {slug}")}})),
)
.into_response(),
Err(err) => internal_error(err),
}
}
async fn api_admin_db_delete_node(
State(state): State<PlatformAppState>,
Path(slug): Path<String>,
headers: HeaderMap,
) -> Response {
if let Err(r) = require_superadmin(&state, &headers) {
return r;
}
match state.platform.data.admin_delete_node(&slug) {
Ok(deleted) => Json(json!({"ok": true, "deleted": deleted, "slug": slug})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_list_users(State(state): State<PlatformAppState>, headers: HeaderMap) -> Response {
if let Err(response) = require_superadmin(&state, &headers) {
return response;
}
match state.platform.users.list_users() {
Ok(items) => Json(json!({"ok": true, "items": items})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_create_user(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Json(req): Json<CreateUserRequest>,
) -> Response {
if let Err(response) = require_superadmin(&state, &headers) {
return response;
}
match state.platform.users.create_user(&req) {
Ok(user) => Json(json!({"ok": true, "user": user})).into_response(),
Err(err) if err.code == "PLATFORM_USER_EXISTS" => (
StatusCode::CONFLICT,
Json(json!({"ok": false, "error": {"code": err.code, "message": err.message}})),
)
.into_response(),
Err(err) => internal_error(err),
}
}
async fn api_list_projects(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path(owner): Path<String>,
) -> Response {
if let Err(response) = require_owner_or_superadmin(&state, &headers, &owner) {
return response;
}
match state.platform.projects.list_projects(&owner) {
Ok(items) => Json(json!({"ok": true, "items": items})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_create_project(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path(owner): Path<String>,
Json(req): Json<CreateProjectRequest>,
) -> Response {
if let Err(response) = require_owner_or_superadmin(&state, &headers, &owner) {
return response;
}
let runtime = req.runtime.clone();
match state
.platform
.projects
.create_or_update_project(&owner, &req)
{
Ok((project, layout)) => match finalize_project_runtime_setup(
&state,
&owner,
&project.project,
&runtime,
)
.await
{
Ok(placement) => Json(
json!({"ok": true, "project": project, "layout": layout, "placement": placement}),
)
.into_response(),
Err(err) => internal_error(err),
},
Err(err) => internal_error(err),
}
}
async fn api_list_platform_marketplace_repositories(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path(owner): Path<String>,
) -> Response {
let Some(session) = session_owner(&state, &headers) else {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"ok": false, "error": "login required"})),
)
.into_response();
};
if session != owner {
return (
StatusCode::FORBIDDEN,
Json(json!({"ok": false, "error": "forbidden"})),
)
.into_response();
}
if let Err(err) = state
.platform
.marketplace
.ensure_default_platform_repository(&owner)
{
return internal_error(err);
}
match state
.platform
.marketplace
.list_platform_repositories(&owner)
{
Ok(items) => Json(json!({
"ok": true,
"items": items.into_iter().map(platform_marketplace_repository_json).collect::<Vec<_>>()
}))
.into_response(),
Err(err) => internal_error(err),
}
}
async fn api_list_platform_marketplace_sources(
State(state): State<PlatformAppState>,
headers: HeaderMap,
) -> Response {
let Some(session) = session_owner(&state, &headers) else {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"ok": false, "error": "login required"})),
)
.into_response();
};
match visible_platform_marketplace_sources(&state, &session) {
Ok((_source_owner, items)) => Json(json!({
"ok": true,
"items": items.into_iter().map(platform_marketplace_repository_json).collect::<Vec<_>>()
}))
.into_response(),
Err(err) => internal_error(err),
}
}
async fn api_get_platform_marketplace_service(
State(state): State<PlatformAppState>,
headers: HeaderMap,
) -> Response {
if let Err(response) = require_superadmin(&state, &headers) {
return response;
}
match state.platform.marketplace.get_default_service_instance() {
Ok(service) => Json(json!({"ok": true, "service": service})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_configure_platform_marketplace_service(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Json(req): Json<ConfigurePlatformMarketplaceServiceRequest>,
) -> Response {
let Some(session_owner) = session_owner(&state, &headers) else {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"ok": false, "error": "login required"})),
)
.into_response();
};
if !is_superadmin_owner(&state, &session_owner) {
return (
StatusCode::FORBIDDEN,
Json(json!({"ok": false, "error": "superadmin required"})),
)
.into_response();
}
match state
.platform
.users
.authenticate(&session_owner, &req.password)
{
Ok(true) => {}
Ok(false) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"ok": false, "error": "Incorrect password"})),
)
.into_response();
}
Err(err) => return internal_error(err),
}
match state.platform.marketplace.ensure_default_service_instance(
&req.host_office_id,
&req.public_base_url,
req.enabled,
) {
Ok(service) => Json(json!({"ok": true, "service": service})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_upsert_platform_marketplace_source(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Json(req): Json<UpsertMarketplaceRepositoryRequest>,
) -> Response {
if let Err(response) = require_superadmin(&state, &headers) {
return response;
}
let Some(source_owner) = platform_marketplace_source_owner(&state) else {
return internal_error(PlatformError::new(
"PLATFORM_USER_NOT_FOUND",
"superadmin user not found",
));
};
match state.platform.marketplace.upsert_platform_repository(
&source_owner,
&req.repository_id,
&req.title,
&req.base_url,
&req.remote_owner,
&req.remote_project,
&req.read_token,
&req.visibility,
req.enabled,
) {
Ok(repository) => Json(json!({
"ok": true,
"repository": platform_marketplace_repository_json(repository)
}))
.into_response(),
Err(err) => marketplace_api_error(err),
}
}
async fn api_upsert_platform_marketplace_repository(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path(owner): Path<String>,
Json(req): Json<UpsertMarketplaceRepositoryRequest>,
) -> Response {
let Some(session) = session_owner(&state, &headers) else {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"ok": false, "error": "login required"})),
)
.into_response();
};
if session != owner {
return (
StatusCode::FORBIDDEN,
Json(json!({"ok": false, "error": "forbidden"})),
)
.into_response();
}
match state.platform.marketplace.upsert_platform_repository(
&owner,
&req.repository_id,
&req.title,
&req.base_url,
&req.remote_owner,
&req.remote_project,
&req.read_token,
&req.visibility,
req.enabled,
) {
Ok(repository) => Json(json!({
"ok": true,
"repository": platform_marketplace_repository_json(repository)
}))
.into_response(),
Err(err) => marketplace_api_error(err),
}
}
async fn api_delete_platform_marketplace_source(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path(repository_id): Path<String>,
) -> Response {
if let Err(response) = require_superadmin(&state, &headers) {
return response;
}
let Some(source_owner) = platform_marketplace_source_owner(&state) else {
return internal_error(PlatformError::new(
"PLATFORM_USER_NOT_FOUND",
"superadmin user not found",
));
};
match state
.platform
.marketplace
.delete_platform_repository(&source_owner, &repository_id)
{
Ok(()) => Json(json!({"ok": true})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_delete_platform_marketplace_repository(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, repository_id)): Path<(String, String)>,
) -> Response {
let Some(session) = session_owner(&state, &headers) else {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"ok": false, "error": "login required"})),
)
.into_response();
};
if session != owner {
return (
StatusCode::FORBIDDEN,
Json(json!({"ok": false, "error": "forbidden"})),
)
.into_response();
}
match state
.platform
.marketplace
.delete_platform_repository(&owner, &repository_id)
{
Ok(()) => Json(json!({"ok": true})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_list_platform_marketplace_apps(
State(state): State<PlatformAppState>,
headers: HeaderMap,
) -> Response {
let Some(session) = session_owner(&state, &headers) else {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"ok": false, "error": "login required"})),
)
.into_response();
};
let (_source_owner, sources) = match visible_platform_marketplace_sources(&state, &session) {
Ok(value) => value,
Err(err) => return internal_error(err),
};
match state
.platform
.marketplace
.fetch_platform_remote_app_rows_from_repositories(&state.http_client, sources)
.await
{
Ok(items) => Json(json!({"ok": true, "items": items})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_list_platform_marketplace_assets(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path(owner): Path<String>,
) -> Response {
let Some(session) = session_owner(&state, &headers) else {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"ok": false, "error": "login required"})),
)
.into_response();
};
if session != owner {
return (
StatusCode::FORBIDDEN,
Json(json!({"ok": false, "error": "forbidden"})),
)
.into_response();
}
if let Err(err) = state
.platform
.marketplace
.ensure_default_platform_repository(&owner)
{
return internal_error(err);
}
match state
.platform
.marketplace
.fetch_platform_remote_app_rows(&state.http_client, &owner)
.await
{
Ok(items) => Json(json!({"ok": true, "items": items})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_install_platform_marketplace_app(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Json(req): Json<InstallPlatformMarketplaceProjectRequest>,
) -> Response {
let Some(session) = session_owner(&state, &headers) else {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"ok": false, "error": "login required"})),
)
.into_response();
};
let (source_owner, sources) = match visible_platform_marketplace_sources(&state, &session) {
Ok(value) => value,
Err(err) => return internal_error(err),
};
if !sources
.iter()
.any(|item| item.repository_id == req.repository_id && item.enabled)
{
return (
StatusCode::FORBIDDEN,
Json(json!({"ok": false, "error": "marketplace source is not visible"})),
)
.into_response();
}
match state
.platform
.marketplace
.install_remote_project_from_platform_source(
&state.http_client,
&source_owner,
&session,
&req.repository_id,
&req.package_id,
&req.version,
)
.await
{
Ok((installed_owner, installed_project)) => {
if let Err(err) = state
.platform
.cluster_runtime_sync
.refresh_local_repo_state(&installed_owner, &installed_project)
{
return internal_error(err);
}
match state
.platform
.projects
.get_project(&installed_owner, &installed_project)
{
Ok(Some(project)) => Json(json!({
"ok": true,
"owner": installed_owner,
"project": home_project_card_json(&state, &session, &project),
}))
.into_response(),
Ok(None) => Json(json!({
"ok": true,
"owner": installed_owner,
"project_slug": installed_project,
}))
.into_response(),
Err(err) => internal_error(err),
}
}
Err(err) => marketplace_api_error(err),
}
}
async fn api_install_platform_marketplace_project(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path(owner): Path<String>,
Json(req): Json<InstallPlatformMarketplaceProjectRequest>,
) -> Response {
let Some(session) = session_owner(&state, &headers) else {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"ok": false, "error": "login required"})),
)
.into_response();
};
if session != owner {
return (
StatusCode::FORBIDDEN,
Json(json!({"ok": false, "error": "forbidden"})),
)
.into_response();
}
match state
.platform
.marketplace
.install_remote_project_from_platform_repository(
&state.http_client,
&owner,
&req.repository_id,
&req.package_id,
&req.version,
)
.await
{
Ok((installed_owner, installed_project)) => {
if let Err(err) = state
.platform
.cluster_runtime_sync
.refresh_local_repo_state(&installed_owner, &installed_project)
{
return internal_error(err);
}
match state
.platform
.projects
.get_project(&installed_owner, &installed_project)
{
Ok(Some(project)) => Json(json!({
"ok": true,
"project": home_project_card_json(&state, &owner, &project)
}))
.into_response(),
Ok(None) => internal_error(PlatformError::new(
"MARKETPLACE_INSTALL",
"installed project missing after install",
)),
Err(err) => internal_error(err),
}
}
Err(err) => internal_error(err),
}
}
async fn api_list_platform_marketplace_publishers(
State(state): State<PlatformAppState>,
headers: HeaderMap,
) -> Response {
if let Err(response) = require_superadmin(&state, &headers) {
return response;
}
let Some(source_owner) = platform_marketplace_source_owner(&state) else {
return internal_error(PlatformError::new(
"PLATFORM_USER_NOT_FOUND",
"superadmin user not found",
));
};
match state
.platform
.marketplace
.list_publishers(&source_owner, "platform")
{
Ok(items) => Json(json!({
"ok": true,
"items": items.into_iter().map(marketplace_publisher_json).collect::<Vec<_>>()
}))
.into_response(),
Err(err) if err.code == "MARKETPLACE_SERVICE_DISABLED" => {
Json(json!({"ok": true, "items": []})).into_response()
}
Err(err) => internal_error(err),
}
}
async fn api_upsert_platform_marketplace_publisher(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Json(req): Json<UpsertMarketplacePublisherRequest>,
) -> Response {
if let Err(response) = require_superadmin(&state, &headers) {
return response;
}
let Some(source_owner) = platform_marketplace_source_owner(&state) else {
return internal_error(PlatformError::new(
"PLATFORM_USER_NOT_FOUND",
"superadmin user not found",
));
};
match state.platform.marketplace.upsert_publisher(
&source_owner,
"platform",
&req.publisher_id,
&req.display_name,
&req.publisher_url,
&req.email,
&req.description,
&req.icon_url,
&req.website_url,
req.enabled,
req.can_read,
req.can_publish,
req.can_manage,
req.max_packages,
req.max_package_bytes,
req.max_media_files,
req.max_image_bytes,
) {
Ok(item) => {
Json(json!({"ok": true, "publisher": marketplace_publisher_json(item)})).into_response()
}
Err(err) => internal_error(err),
}
}
async fn api_delete_platform_marketplace_publisher(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path(publisher_id): Path<String>,
) -> Response {
if let Err(response) = require_superadmin(&state, &headers) {
return response;
}
let Some(source_owner) = platform_marketplace_source_owner(&state) else {
return internal_error(PlatformError::new(
"PLATFORM_USER_NOT_FOUND",
"superadmin user not found",
));
};
match state
.platform
.marketplace
.delete_publisher(&source_owner, "platform", &publisher_id)
{
Ok(()) => Json(json!({"ok": true})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_list_platform_marketplace_tokens(
State(state): State<PlatformAppState>,
headers: HeaderMap,
) -> Response {
if let Err(response) = require_superadmin(&state, &headers) {
return response;
}
match state.platform.marketplace.list_all_tokens() {
Ok(items) => Json(json!({
"ok": true,
"items": items.into_iter().map(marketplace_token_json).collect::<Vec<_>>()
}))
.into_response(),
Err(err) if err.code == "MARKETPLACE_SERVICE_DISABLED" => {
Json(json!({"ok": true, "items": []})).into_response()
}
Err(err) => internal_error(err),
}
}
async fn api_create_platform_marketplace_token(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Json(req): Json<CreatePlatformMarketplaceTokenRequest>,
) -> Response {
if let Err(response) = require_superadmin(&state, &headers) {
return response;
}
let Some(source_owner) = platform_marketplace_source_owner(&state) else {
return internal_error(PlatformError::new(
"PLATFORM_USER_NOT_FOUND",
"superadmin user not found",
));
};
let owner = if req.owner.trim().is_empty() {
source_owner.clone()
} else {
req.owner
};
let project = if req.project.trim().is_empty() {
"platform".to_string()
} else {
req.project
};
match state.platform.marketplace.create_token(
&owner,
&project,
&CreateMarketplaceTokenRequest {
publisher_id: req.publisher_id,
title: req.title,
scopes: req.scopes,
expires_at: req.expires_at,
},
) {
Ok((token, token_value)) => Json(
json!({"ok": true, "token": marketplace_token_json(token), "token_value": token_value}),
)
.into_response(),
Err(err) => internal_error(err),
}
}
async fn api_delete_platform_marketplace_token(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path(token_id): Path<String>,
) -> Response {
if let Err(response) = require_superadmin(&state, &headers) {
return response;
}
match state.platform.marketplace.revoke_token_any(&token_id) {
Ok(()) => Json(json!({"ok": true})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_cluster_workers(
State(state): State<PlatformAppState>,
headers: HeaderMap,
) -> Response {
if let Err(response) = require_superadmin(&state, &headers) {
return response;
}
match state.platform.cluster_registry.snapshot() {
Ok(snapshot) => Json(json!({ "ok": true, "workers": snapshot.workers })).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_project_runtime_status(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::SettingsRead,
) {
return response;
}
let placement = match state.platform.cluster_placement.get(&owner, &project) {
Ok(value) => value,
Err(err) => return internal_error(err),
};
let workers = match state.platform.cluster_registry.snapshot() {
Ok(snapshot) => snapshot.workers,
Err(err) => return internal_error(err),
};
Json(json!({
"ok": true,
"placement": placement,
"summary": state.platform.cluster_placement.describe(placement.as_ref()),
"workers": workers,
}))
.into_response()
}
async fn api_project_runtime_sync(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::SettingsWrite,
) {
return response;
}
let placement = match state.platform.cluster_placement.get(&owner, &project) {
Ok(Some(placement)) => placement,
Ok(None) => {
return Json(json!({
"ok": true,
"placement": null,
"message": "project is local; no remote sync required"
}))
.into_response();
}
Err(err) => return internal_error(err),
};
if placement.target != ProjectRuntimePlacementTarget::Worker {
return Json(json!({
"ok": true,
"placement": placement,
"message": "project is local; no remote sync required"
}))
.into_response();
}
match sync_project_to_remote_worker(&state, &owner, &project, &placement).await {
Ok(_) => Json(json!({
"ok": true,
"placement": placement,
"message": "runtime synced to remote office"
}))
.into_response(),
Err(err) => internal_error(err),
}
}
async fn api_internal_cluster_register_worker(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Json(req): Json<ClusterWorkerRegisterRequest>,
) -> Response {
if let Err(response) = require_cluster_internal_token(&state, &headers) {
return response;
}
match state.platform.cluster_registry.register_worker(&req) {
Ok(worker) => Json(json!({"ok": true, "worker": worker})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_internal_cluster_worker_heartbeat(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Json(req): Json<ClusterWorkerHeartbeatRequest>,
) -> Response {
if let Err(response) = require_cluster_internal_token(&state, &headers) {
return response;
}
match state.platform.cluster_registry.heartbeat(&req) {
Ok(heartbeat) => Json(heartbeat).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_internal_runtime_materialize_project(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Json(req): Json<ProjectRuntimeMaterializationRequest>,
) -> Response {
if let Err(response) = require_cluster_internal_token(&state, &headers) {
return response;
}
match state
.platform
.cluster_runtime_sync
.apply_materialization_request(&req)
{
Ok(_) => Json(json!({"ok": true, "project": req.bundle.identity})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_internal_runtime_execute_pipeline(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
Json(req): Json<ExecutePipelineRequest>,
) -> Response {
if let Err(response) = require_cluster_internal_token(&state, &headers) {
return response;
}
execute_pipeline_local(&state, &owner, &project, &req).await
}
async fn api_internal_runtime_webhook(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, tail)): Path<(String, String, String)>,
method: Method,
uri: Uri,
body: Bytes,
) -> Response {
if let Err(response) = require_cluster_internal_token(&state, &headers) {
return response;
}
public_webhook_ingress(
State(state),
Path((owner, project, tail)),
method,
uri,
headers,
body,
)
.await
}
async fn api_internal_runtime_webhook_root(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
method: Method,
uri: Uri,
body: Bytes,
) -> Response {
if let Err(response) = require_cluster_internal_token(&state, &headers) {
return response;
}
public_webhook_ingress_root(
State(state),
Path((owner, project)),
method,
uri,
headers,
body,
)
.await
}
fn local_office_id(state: &PlatformAppState) -> Option<String> {
let node_id = state.platform.cluster_bootstrap.node_id();
if node_id.trim().is_empty() {
None
} else {
Some(node_id)
}
}
fn controller_id(state: &PlatformAppState) -> Option<String> {
if state.platform.cluster_bootstrap.is_worker() {
None
} else {
local_office_id(state)
}
}
fn project_transfer_operation_kind(
kind: ProjectTransferArtifactKind,
is_import: bool,
) -> ProjectOperationKind {
match (is_import, kind) {
(false, ProjectTransferArtifactKind::Bundle) => ProjectOperationKind::ExportBundle,
(false, ProjectTransferArtifactKind::Files) => ProjectOperationKind::ExportFiles,
(true, ProjectTransferArtifactKind::Bundle) => ProjectOperationKind::ImportBundle,
(true, ProjectTransferArtifactKind::Files) => ProjectOperationKind::ImportFiles,
}
}
fn refresh_local_project_workspace(
state: &PlatformAppState,
owner: &str,
project: &str,
) -> Result<(), PlatformError> {
state
.platform
.cluster_runtime_sync
.refresh_local_repo_state(owner, project)?;
state
.platform
.pipeline_runtime
.refresh_project(owner, project)?;
Ok(())
}
async fn api_internal_project_transfer_export(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, raw_kind)): Path<(String, String, String)>,
) -> Response {
if let Err(response) = require_cluster_internal_token(&state, &headers) {
return response;
}
let kind = match parse_project_transfer_kind(&raw_kind) {
Ok(kind) => kind,
Err(response) => return response,
};
let op_id = format!(
"internal-export-{}-{}",
kind.key(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
);
let output_path = state.platform.project_transfer.artifact_path(&op_id, kind);
let export = state.platform.project_transfer.export_project(
&owner,
&project,
kind,
local_office_id(&state).as_deref(),
controller_id(&state).as_deref(),
state
.platform
.cluster_placement
.get(&owner, &project)
.ok()
.flatten(),
&output_path,
);
match export {
Ok(_) => {}
Err(err) => return internal_error(err),
}
let bytes = match fs::read(&output_path) {
Ok(bytes) => bytes,
Err(err) => return internal_error(err.into()),
};
let _ = fs::remove_dir_all(state.platform.project_transfer.operation_dir(&op_id));
let mut response = Response::new(Body::from(bytes));
response
.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static("application/x-tar"));
if let Ok(value) = HeaderValue::from_str(kind.archive_name()) {
response
.headers_mut()
.insert(HeaderName::from_static("x-zebflow-transfer-archive"), value);
}
response
}
async fn api_internal_project_transfer_import(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, raw_kind)): Path<(String, String, String)>,
body: Bytes,
) -> Response {
if let Err(response) = require_cluster_internal_token(&state, &headers) {
return response;
}
let kind = match parse_project_transfer_kind(&raw_kind) {
Ok(kind) => kind,
Err(response) => return response,
};
let op_id = format!(
"internal-import-{}-{}",
kind.key(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
);
let archive_path = state.platform.project_transfer.artifact_path(&op_id, kind);
if let Some(parent) = archive_path.parent()
&& let Err(err) = fs::create_dir_all(parent)
{
return internal_error(err.into());
}
if let Err(err) = fs::write(&archive_path, &body) {
return internal_error(err.into());
}
let import =
state
.platform
.project_transfer
.import_project(&owner, &project, kind, &archive_path);
let manifest = match import {
Ok(manifest) => manifest,
Err(err) => return internal_error(err),
};
if let Err(err) = refresh_local_project_workspace(&state, &owner, &project) {
return internal_error(err);
}
let _ = fs::remove_dir_all(state.platform.project_transfer.operation_dir(&op_id));
Json(json!({"ok": true, "manifest": manifest})).into_response()
}
async fn api_project_transfer_operations(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::SettingsRead,
) {
return response;
}
match state.platform.project_operations.list(&owner, &project, 10) {
Ok(items) => Json(json!({"ok": true, "items": items})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_project_transfer_export(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, raw_kind)): Path<(String, String, String)>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::SettingsWrite,
) {
return response;
}
let kind = match parse_project_transfer_kind(&raw_kind) {
Ok(kind) => kind,
Err(response) => return response,
};
let remote_worker_id = match remote_project_worker_id(&state, &owner, &project) {
Ok(worker_id) => worker_id,
Err(err) => return internal_error(err),
};
let mut operation = match state.platform.project_operations.create(
&owner,
&project,
project_transfer_operation_kind(kind, false),
remote_worker_id.clone().or_else(|| local_office_id(&state)),
None,
) {
Ok(record) => record,
Err(err) => return internal_error(err),
};
operation = match state
.platform
.project_operations
.mark_running(&operation, "preparing export")
{
Ok(record) => record,
Err(err) => return internal_error(err),
};
let artifact_path = state
.platform
.project_transfer
.artifact_path(&operation.operation_id, kind);
let export_result = if let Some(worker_id) = remote_worker_id.as_deref() {
let worker = match state.platform.cluster_registry.get_worker(worker_id) {
Ok(Some(worker)) => worker,
Ok(None) => {
let _ = state.platform.project_operations.mark_failed(
&operation,
"locating office",
format!("office '{}' is not registered", worker_id),
);
return (
StatusCode::BAD_GATEWAY,
Json(json!({"ok": false, "error": {"code":"CLUSTER_WORKER_UNKNOWN","message":"office not registered"}})),
)
.into_response();
}
Err(err) => return internal_error(err),
};
let token = match cluster_internal_token_value(&state) {
Ok(token) => token,
Err(err) => return internal_error(err),
};
let url = format!(
"{}/api/internal/project-transfer/{}/{}/export/{}",
worker.base_url.trim_end_matches('/'),
owner,
project,
kind.key()
);
match state
.http_client
.post(url)
.header(INTERNAL_CLUSTER_TOKEN_HEADER, token)
.send()
.await
{
Ok(response) if response.status().is_success() => match response.bytes().await {
Ok(bytes) => fs::write(&artifact_path, &bytes).map_err(PlatformError::from),
Err(err) => Err(PlatformError::new(
"PROJECT_TRANSFER_EXPORT",
err.to_string(),
)),
},
Ok(response) => {
let status = response.status();
let body = response.text().await.unwrap_or_default();
Err(PlatformError::new(
"PROJECT_TRANSFER_EXPORT",
format!("remote office export failed with {status}: {body}"),
))
}
Err(err) => Err(PlatformError::new(
"PROJECT_TRANSFER_EXPORT",
err.to_string(),
)),
}
} else {
match state.platform.project_transfer.export_project(
&owner,
&project,
kind,
local_office_id(&state).as_deref(),
controller_id(&state).as_deref(),
state
.platform
.cluster_placement
.get(&owner, &project)
.ok()
.flatten(),
&artifact_path,
) {
Ok(_) => Ok(()),
Err(err) => Err(err),
}
};
if let Err(err) = export_result {
let _ = state.platform.project_operations.mark_failed(
&operation,
"export failed",
err.message.clone(),
);
return internal_error(err);
}
let sha256 = match state.platform.project_transfer.sha256_hex(&artifact_path) {
Ok(value) => value,
Err(err) => {
let _ = state.platform.project_operations.mark_failed(
&operation,
"hashing archive",
err.message.clone(),
);
return internal_error(err);
}
};
let artifact_bytes = fs::metadata(&artifact_path).ok().map(|meta| meta.len());
let operation = match state.platform.project_operations.mark_completed(
&operation,
"export completed",
Some(
state
.platform
.project_transfer
.artifact_rel_path(&operation.operation_id, kind),
),
Some(sha256),
artifact_bytes,
) {
Ok(record) => record,
Err(err) => return internal_error(err),
};
Json(json!({
"ok": true,
"operation": operation,
"download_url": format!(
"/api/projects/{}/{}/transfer/download/{}",
owner, project, operation.operation_id
)
}))
.into_response()
}
async fn api_project_transfer_import(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, raw_kind)): Path<(String, String, String)>,
mut multipart: Multipart,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::SettingsWrite,
) {
return response;
}
let kind = match parse_project_transfer_kind(&raw_kind) {
Ok(kind) => kind,
Err(response) => return response,
};
let remote_worker_id = match remote_project_worker_id(&state, &owner, &project) {
Ok(worker_id) => worker_id,
Err(err) => return internal_error(err),
};
let mut operation = match state.platform.project_operations.create(
&owner,
&project,
project_transfer_operation_kind(kind, true),
None,
remote_worker_id.clone().or_else(|| local_office_id(&state)),
) {
Ok(record) => record,
Err(err) => return internal_error(err),
};
operation = match state
.platform
.project_operations
.mark_running(&operation, "receiving upload")
{
Ok(record) => record,
Err(err) => return internal_error(err),
};
let field = match multipart.next_field().await {
Ok(Some(field)) => field,
Ok(None) => {
let _ = state.platform.project_operations.mark_failed(
&operation,
"receiving upload",
"no archive file in multipart body",
);
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": {"code":"PROJECT_TRANSFER_UPLOAD_MISSING","message":"no archive file in multipart body"}})),
)
.into_response();
}
Err(err) => {
let _ = state.platform.project_operations.mark_failed(
&operation,
"receiving upload",
err.to_string(),
);
return internal_error(PlatformError::new(
"PROJECT_TRANSFER_UPLOAD",
err.to_string(),
));
}
};
let bytes = match field.bytes().await {
Ok(bytes) => bytes,
Err(err) => {
let _ = state.platform.project_operations.mark_failed(
&operation,
"reading upload",
err.to_string(),
);
return internal_error(PlatformError::new(
"PROJECT_TRANSFER_UPLOAD",
err.to_string(),
));
}
};
let archive_path = state
.platform
.project_transfer
.artifact_path(&operation.operation_id, kind);
if let Some(parent) = archive_path.parent()
&& let Err(err) = fs::create_dir_all(parent)
{
let _ = state.platform.project_operations.mark_failed(
&operation,
"staging upload",
err.to_string(),
);
return internal_error(err.into());
}
if let Err(err) = fs::write(&archive_path, &bytes) {
let _ = state.platform.project_operations.mark_failed(
&operation,
"staging upload",
err.to_string(),
);
return internal_error(err.into());
}
let import_result = if let Some(worker_id) = remote_worker_id.as_deref() {
let worker = match state.platform.cluster_registry.get_worker(worker_id) {
Ok(Some(worker)) => worker,
Ok(None) => {
let _ = state.platform.project_operations.mark_failed(
&operation,
"locating office",
format!("office '{}' is not registered", worker_id),
);
return (
StatusCode::BAD_GATEWAY,
Json(json!({"ok": false, "error": {"code":"CLUSTER_WORKER_UNKNOWN","message":"office not registered"}})),
)
.into_response();
}
Err(err) => return internal_error(err),
};
let token = match cluster_internal_token_value(&state) {
Ok(token) => token,
Err(err) => return internal_error(err),
};
let url = format!(
"{}/api/internal/project-transfer/{}/{}/import/{}",
worker.base_url.trim_end_matches('/'),
owner,
project,
kind.key()
);
match state
.http_client
.post(url)
.header(INTERNAL_CLUSTER_TOKEN_HEADER, token)
.header(CONTENT_TYPE, "application/x-tar")
.body(bytes.clone())
.send()
.await
{
Ok(response) if response.status().is_success() => Ok(()),
Ok(response) => {
let status = response.status();
let body = response.text().await.unwrap_or_default();
Err(PlatformError::new(
"PROJECT_TRANSFER_IMPORT",
format!("remote office import failed with {status}: {body}"),
))
}
Err(err) => Err(PlatformError::new(
"PROJECT_TRANSFER_IMPORT",
err.to_string(),
)),
}
} else {
match state
.platform
.project_transfer
.import_project(&owner, &project, kind, &archive_path)
{
Ok(_) => refresh_local_project_workspace(&state, &owner, &project),
Err(err) => Err(err),
}
};
if let Err(err) = import_result {
let _ = state.platform.project_operations.mark_failed(
&operation,
"import failed",
err.message.clone(),
);
return internal_error(err);
}
let operation = match state.platform.project_operations.mark_completed(
&operation,
"import completed",
None,
None,
Some(bytes.len() as u64),
) {
Ok(record) => record,
Err(err) => return internal_error(err),
};
Json(json!({"ok": true, "operation": operation})).into_response()
}
async fn api_project_transfer_download(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, operation_id)): Path<(String, String, String)>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::SettingsWrite,
) {
return response;
}
let record = match state
.platform
.project_operations
.get(&owner, &project, &operation_id)
{
Ok(Some(record)) => record,
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
Err(err) => return internal_error(err),
};
let Some(rel_path) = record.artifact_rel_path.as_deref() else {
return StatusCode::NOT_FOUND.into_response();
};
let path = state
.platform
.config
.data_root
.join("platform")
.join("project-operations")
.join(rel_path);
let bytes = match fs::read(&path) {
Ok(bytes) => bytes,
Err(err) => return internal_error(err.into()),
};
let archive_name = match record.kind {
ProjectOperationKind::ExportBundle | ProjectOperationKind::ImportBundle => {
ProjectTransferArtifactKind::Bundle.archive_name()
}
ProjectOperationKind::ExportFiles | ProjectOperationKind::ImportFiles => {
ProjectTransferArtifactKind::Files.archive_name()
}
};
let mut response = Response::new(Body::from(bytes));
response
.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static("application/x-tar"));
if let Ok(disposition) =
HeaderValue::from_str(&format!("attachment; filename=\"{archive_name}\""))
{
response
.headers_mut()
.insert(HeaderName::from_static("content-disposition"), disposition);
}
response
}
async fn api_transfer_project_owner(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
Json(body): Json<serde_json::Value>,
) -> Response {
if let Err(response) = require_superadmin(&state, &headers) {
return response;
}
let new_owner = match body.get("new_owner").and_then(|v| v.as_str()) {
Some(v) if !v.trim().is_empty() => v.trim().to_string(),
_ => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": "new_owner is required"})),
)
.into_response();
}
};
if let Err(err) = state
.platform
.projects
.transfer_project_owner(&owner, &project, &new_owner)
{
return match err.code {
"PLATFORM_TRANSFER_INVALID" => (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": err.message})),
)
.into_response(),
"PLATFORM_TRANSFER_NOT_FOUND" => (
StatusCode::NOT_FOUND,
Json(json!({"ok": false, "error": err.message})),
)
.into_response(),
"PLATFORM_TRANSFER_CONFLICT" => (
StatusCode::CONFLICT,
Json(json!({"ok": false, "error": err.message})),
)
.into_response(),
_ => internal_error(err),
};
}
let _ = state
.platform
.pipeline_runtime
.refresh_project(&new_owner, &project);
if let Ok(mut cache) = state.template_cache.write() {
cache.clear();
}
let _ = state
.platform
.mcp_sessions
.revoke_for_project(&owner, &project);
Json(json!({
"ok": true,
"old_owner": owner,
"new_owner": new_owner,
"project": project,
}))
.into_response()
}
#[derive(serde::Deserialize)]
struct DeleteProjectRequest {
project_name: String,
password: String,
}
async fn api_delete_project(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
Json(req): Json<DeleteProjectRequest>,
) -> Response {
let Some(session_owner) = session_owner(&state, &headers) else {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"ok": false, "error": "Not authenticated"})),
)
.into_response();
};
let owner_slug = crate::platform::model::slug_segment(&owner);
let project_slug = crate::platform::model::slug_segment(&project);
if crate::platform::model::slug_segment(&session_owner) != owner_slug {
return (
StatusCode::FORBIDDEN,
Json(json!({"ok": false, "error": "Forbidden"})),
)
.into_response();
}
let confirmed_slug = crate::platform::model::slug_segment(&req.project_name);
if confirmed_slug != project_slug || project_slug.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": "Project name does not match"})),
)
.into_response();
}
match state
.platform
.users
.authenticate(&owner_slug, &req.password)
{
Ok(true) => {}
Ok(false) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"ok": false, "error": "Incorrect password"})),
)
.into_response();
}
Err(e) => return internal_error(e),
}
match state.platform.data.get_project(&owner_slug, &project_slug) {
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"ok": false, "error": "Project not found"})),
)
.into_response();
}
Err(e) => return internal_error(e),
Ok(Some(_)) => {}
}
if let Err(e) = state
.platform
.data
.delete_project(&owner_slug, &project_slug)
{
return internal_error(e);
}
let project_root = state
.platform
.config
.data_root
.join("users")
.join(&owner_slug)
.join(&project_slug);
if project_root.exists() {
if let Err(e) = std::fs::remove_dir_all(&project_root) {
eprintln!("WARN: Failed to remove project dir {:?}: {e}", project_root);
}
}
Json(json!({"ok": true})).into_response()
}
async fn api_prepare_project_assets(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
Json(req): Json<PrepareProjectAssetsRequest>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::LibrariesInstall,
) {
return response;
}
let owner_slug = crate::platform::model::slug_segment(&owner);
let project_slug = crate::platform::model::slug_segment(&project);
if owner_slug.is_empty() || project_slug.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": {"code":"PLATFORM_ASSET_SCOPE_INVALID","message":"owner/project must not be empty"}})),
)
.into_response();
}
let layout = match state
.platform
.file
.ensure_project_layout(&owner_slug, &project_slug)
{
Ok(layout) => layout,
Err(err) => return internal_error(err),
};
match prepare_project_assets_manifest(
&owner_slug,
&project_slug,
&state.platform.config.data_root,
&layout,
req,
) {
Ok(manifest) => Json(json!({
"ok": true,
"manifest": manifest,
"manifest_url": format!("/assets/{owner_slug}/{project_slug}/rwe/manifest.json")
}))
.into_response(),
Err(err) => internal_error(err),
}
}
async fn api_pipeline_registry(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Query(query): Query<PipelineRegistryQuery>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesRead,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::GET,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
let scope = match resolve_pipeline_registry_scope(&query) {
Ok(scope) => scope,
Err(response) => return response,
};
let base_route = format!("/projects/{owner}/{project}/pipelines/registry");
let editor_base = format!("/projects/{owner}/{project}/pipelines/registry");
let git_map: std::collections::HashMap<String, String> = state
.platform
.projects
.list_repo_git_status(&owner, &project)
.unwrap_or_default()
.into_iter()
.map(|item| (item.rel_path, item.code))
.collect();
match scope {
PipelineRegistryScope::Path => {
let current_path = query.path.as_deref().unwrap_or("/");
match state.platform.projects.list_pipeline_registry(
&owner,
&project,
current_path,
&base_route,
&editor_base,
) {
Ok(mut listing) => {
for item in &mut listing.pipelines {
item.git_status = git_map.get(&item.file_rel_path).cloned();
}
for item in &mut listing.files {
item.git_status = git_map.get(&item.rel_path).cloned();
}
Json(json!({"ok": true, "scope": "path", "listing": listing})).into_response()
}
Err(err) => internal_error(err),
}
}
PipelineRegistryScope::Project => match state
.platform
.projects
.list_pipeline_meta_rows(&owner, &project)
{
Ok(rows) => {
let items = rows
.into_iter()
.map(|meta| {
let is_active = meta
.active_hash
.as_deref()
.map(|h| !h.is_empty() && h == meta.hash)
.unwrap_or(false);
let has_draft = meta
.active_hash
.as_deref()
.map(|h| !h.is_empty() && h != meta.hash)
.unwrap_or(false);
let git_status = git_map.get(&meta.file_rel_path).cloned();
let file_rel_path = meta.file_rel_path.clone();
json!({
"id": file_rel_path,
"name": meta.name,
"title": meta.title,
"description": meta.description,
"trigger_kind": meta.trigger_kind,
"virtual_path": meta.virtual_path,
"file_rel_path": meta.file_rel_path,
"is_active": is_active,
"has_draft": has_draft,
"git_status": git_status,
})
})
.collect::<Vec<_>>();
let count = items.len();
Json(json!({
"ok": true,
"scope": "project",
"items": items,
"count": count
}))
.into_response()
}
Err(err) => internal_error(err),
},
}
}
async fn api_list_pipelines(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Query(query): Query<PipelineListQuery>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesRead,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::GET,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
let base_path =
crate::platform::model::normalize_virtual_path(query.path.as_deref().unwrap_or("/"));
let recursive = query.recursive.unwrap_or(false);
match state
.platform
.projects
.list_pipeline_meta_rows(&owner, &project)
{
Ok(rows) => {
let items = rows
.into_iter()
.filter(|meta| pipeline_path_matches(&base_path, &meta.virtual_path, recursive))
.map(|meta| {
json!({
"id": meta.file_rel_path,
"meta": meta
})
})
.collect::<Vec<_>>();
Json(json!({
"ok": true,
"path": base_path,
"recursive": recursive,
"items": items
}))
.into_response()
}
Err(err) => internal_error(err),
}
}
async fn api_get_pipeline_by_id(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Query(query): Query<PipelineByIdQuery>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesRead,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::GET,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
let Some(file_id) = query
.id
.as_deref()
.map(str::trim)
.filter(|id| !id.is_empty())
else {
return (
StatusCode::BAD_REQUEST,
Json(
json!({"ok": false, "error": {"code":"PLATFORM_PIPELINE_ID_MISSING", "message":"query.id is required"}}),
),
)
.into_response();
};
let meta = match state
.platform
.projects
.get_pipeline_meta_by_file_id(&owner, &project, file_id)
{
Ok(Some(meta)) => meta,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(
json!({"ok": false, "error": {"code":"PLATFORM_PIPELINE_MISSING", "message":"pipeline not found"}}),
),
)
.into_response();
}
Err(err) => return internal_error(err),
};
let include_source = query.include_source.unwrap_or(true);
let include_active_source = query.include_active_source.unwrap_or(false);
let source = if include_source {
match state
.platform
.projects
.read_pipeline_source(&owner, &project, &meta.file_rel_path)
{
Ok(source) => Some(source),
Err(err) => return internal_error(err),
}
} else {
None
};
let active_source = if include_active_source {
match state
.platform
.projects
.read_active_pipeline_source(&owner, &project, &meta)
{
Ok(source) => Some(source),
Err(err) if err.code == "PLATFORM_PIPELINE_NOT_ACTIVE" => None,
Err(err) => return internal_error(err),
}
} else {
None
};
let locked = if let Some(source_text) = source.as_deref() {
pipeline_source_is_locked(source_text)
} else {
match state
.platform
.projects
.read_pipeline_source(&owner, &project, &meta.file_rel_path)
{
Ok(source_text) => pipeline_source_is_locked(&source_text),
Err(_) => false,
}
};
Json(json!({
"ok": true,
"id": meta.file_rel_path,
"meta": meta,
"locked": locked,
"source": source,
"active_source": active_source,
"hits": state
.platform
.pipeline_hits
.get(&owner, &project, &meta.file_rel_path)
}))
.into_response()
}
async fn api_upsert_pipeline_definition(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Json(req): Json<UpsertPipelineDefinitionRequest>,
) -> Response {
if session_owner(&state, &headers).is_some()
&& let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesWrite,
)
{
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_json_request_to_worker(
&state,
&uri,
&headers,
Method::POST,
&req,
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
let self_file_rel_path = req.file_rel_path.clone();
if let Ok(graph) = serde_json::from_str::<crate::pipeline::PipelineGraph>(&req.source) {
if let Ok(conflicts) = state.platform.projects.check_webhook_path_conflict(
&owner,
&project,
&graph,
&self_file_rel_path,
) {
if !conflicts.is_empty() {
let msg = format!(
"{} {} is already registered by pipeline '{}'",
conflicts[0].method, conflicts[0].path, conflicts[0].pipeline_name
);
return (
StatusCode::CONFLICT,
Json(json!({
"ok": false,
"error": {
"code": "PLATFORM_PIPELINE_WEBHOOK_CONFLICT",
"message": msg,
"conflicts": conflicts
}
})),
)
.into_response();
}
}
}
let trigger_kind =
crate::platform::services::project::derive_trigger_kind_from_source(&req.source)
.unwrap_or_else(|| req.trigger_kind.clone());
match state.platform.projects.upsert_pipeline_definition(
&owner,
&project,
&req.file_rel_path,
&req.title,
&req.description,
&trigger_kind,
&req.source,
) {
Ok(meta) => Json(json!({"ok": true, "meta": meta})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_delete_pipeline_definition(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Json(req): Json<DeletePipelineRequest>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesWrite,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_json_request_to_worker(
&state,
&uri,
&headers,
Method::DELETE,
&req,
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
match state
.platform
.projects
.delete_pipeline(&owner, &project, &req.file_rel_path)
{
Ok(()) => {
state
.platform
.pipeline_runtime
.evict(&owner, &project, &req.file_rel_path);
Json(json!({"ok": true})).into_response()
}
Err(err) => internal_error(err),
}
}
#[derive(serde::Deserialize, serde::Serialize)]
struct PipelineLockToggleRequest {
file_rel_path: String,
locked: bool,
}
#[derive(serde::Deserialize, serde::Serialize)]
struct TemplateLockToggleRequest {
rel_path: String,
locked: bool,
}
async fn api_pipeline_lock_toggle(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Json(req): Json<PipelineLockToggleRequest>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesWrite,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_json_request_to_worker(
&state,
&uri,
&headers,
Method::POST,
&req,
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
let owner_slug = crate::platform::model::slug_segment(&owner);
let project_slug = crate::platform::model::slug_segment(&project);
let layout = match state
.platform
.file
.ensure_project_layout(&owner_slug, &project_slug)
{
Ok(l) => l,
Err(err) => return internal_error(err),
};
let pipeline_path = layout.repo_dir.join(&req.file_rel_path);
let source = match std::fs::read_to_string(&pipeline_path) {
Ok(s) => s,
Err(_) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"ok": false, "error": "pipeline not found"})),
)
.into_response();
}
};
let mut value: Value = match serde_json::from_str(&source) {
Ok(v) => v,
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": e.to_string()})),
)
.into_response();
}
};
value["metadata"]["locked"] = Value::Bool(req.locked);
let serialized = match serde_json::to_string_pretty(&value) {
Ok(s) => s,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"ok": false, "error": e.to_string()})),
)
.into_response();
}
};
if let Err(e) = std::fs::write(&pipeline_path, &serialized) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"ok": false, "error": e.to_string()})),
)
.into_response();
}
let name = req
.file_rel_path
.rsplit('/')
.next()
.unwrap_or(&req.file_rel_path)
.replace(".zf.json", "");
let verb = if req.locked { "lock" } else { "unlock" };
let commit_msg = format!("{verb}: pipeline {name}");
let actor_user = session_owner(&state, &headers);
let identity_args = git_identity_args(&state, actor_user.as_deref(), &project_slug);
let _ = {
let mut add_cmd = std::process::Command::new("git");
add_cmd
.arg("-C")
.arg(&layout.repo_dir)
.arg("add")
.arg("--")
.arg(&req.file_rel_path);
add_cmd.output()
};
let _ = {
let mut commit_cmd = std::process::Command::new("git");
for arg in &identity_args {
commit_cmd.arg(arg);
}
commit_cmd
.arg("-C")
.arg(&layout.repo_dir)
.arg("commit")
.arg("-m")
.arg(&commit_msg);
commit_cmd.output()
};
Json(json!({"ok": true, "is_locked": req.locked})).into_response()
}
async fn api_template_lock_toggle(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Json(req): Json<TemplateLockToggleRequest>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesWrite,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_json_request_to_worker(
&state,
&uri,
&headers,
Method::POST,
&req,
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
if let Err(err) =
state
.platform
.zebflow_cfg
.set_template_locked(&owner, &project, &req.rel_path, req.locked)
{
return internal_error(err);
}
let owner_slug = crate::platform::model::slug_segment(&owner);
let project_slug = crate::platform::model::slug_segment(&project);
let layout = match state
.platform
.file
.ensure_project_layout(&owner_slug, &project_slug)
{
Ok(l) => l,
Err(err) => return internal_error(err),
};
let verb = if req.locked { "lock" } else { "unlock" };
let commit_msg = format!("{verb}: template {}", req.rel_path);
let actor_user = session_owner(&state, &headers);
let identity_args = git_identity_args(&state, actor_user.as_deref(), &project_slug);
let _ = {
let mut add_cmd = std::process::Command::new("git");
add_cmd
.arg("-C")
.arg(&layout.repo_dir)
.arg("add")
.arg("--")
.arg("zebflow.json");
add_cmd.output()
};
let _ = {
let mut commit_cmd = std::process::Command::new("git");
for arg in &identity_args {
commit_cmd.arg(arg);
}
commit_cmd
.arg("-C")
.arg(&layout.repo_dir)
.arg("commit")
.arg("-m")
.arg(&commit_msg);
commit_cmd.output()
};
Json(json!({"ok": true, "is_locked": req.locked})).into_response()
}
async fn api_repo_git_status(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TemplatesRead,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::GET,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
match state
.platform
.projects
.list_repo_git_status(&owner, &project)
{
Ok(items) => {
let branch = state
.platform
.projects
.get_repo_git_branch(&owner, &project)
.unwrap_or_default();
Json(json!({ "branch": branch, "files": items })).into_response()
}
Err(err) => internal_error(err),
}
}
#[derive(serde::Deserialize, serde::Serialize)]
struct GitRepairRequest {
#[serde(default)]
mode: Option<crate::platform::services::project::ProjectGitRepairMode>,
}
async fn api_repo_git_health(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TemplatesRead,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::GET,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
match state
.platform
.projects
.get_repo_git_health(&owner, &project)
{
Ok(health) => Json(json!({ "ok": true, "health": health })).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_repo_git_repair(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Json(req): Json<GitRepairRequest>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesWrite,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_json_request_to_worker(
&state,
&uri,
&headers,
Method::POST,
&req,
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
let mode = req
.mode
.unwrap_or(crate::platform::services::project::ProjectGitRepairMode::Repair);
match state
.platform
.projects
.repair_repo_git(&owner, &project, mode)
{
Ok(health) => Json(json!({ "ok": true, "health": health })).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_git_list_branches(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TemplatesRead,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::GET,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
let current = state
.platform
.projects
.get_repo_git_branch(&owner, &project)
.unwrap_or_default();
match state
.platform
.projects
.list_repo_git_local_branches(&owner, &project)
{
Ok(branches) => Json(json!({ "current": current, "branches": branches })).into_response(),
Err(err) => internal_error(err),
}
}
#[derive(serde::Deserialize, serde::Serialize)]
struct GitCheckoutRequest {
branch: String,
#[serde(default)]
create: bool,
}
async fn api_git_checkout_branch(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Json(req): Json<GitCheckoutRequest>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesWrite,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_json_request_to_worker(
&state,
&uri,
&headers,
Method::POST,
&req,
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
match state.platform.projects.checkout_repo_git_branch(
&owner,
&project,
&req.branch,
req.create,
) {
Ok(()) => Json(json!({ "ok": true })).into_response(),
Err(err) => (
StatusCode::BAD_REQUEST,
Json(json!({ "ok": false, "error": err.to_string() })),
)
.into_response(),
}
}
fn git_identity_args(
state: &PlatformAppState,
actor_user: Option<&str>,
project_slug: &str,
) -> Vec<String> {
let resolved = state
.platform
.git_identity
.resolve_for_actor(actor_user, project_slug);
vec![
"-c".to_string(),
format!("user.name={}", resolved.name),
"-c".to_string(),
format!("user.email={}", resolved.email),
]
}
async fn api_git_commit(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Json(req): Json<GitCommitRequest>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesWrite,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_json_request_to_worker(
&state,
&uri,
&headers,
Method::POST,
&req,
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
if req.files.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": "no files specified"})),
)
.into_response();
}
if req.message.trim().is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": "commit message is required"})),
)
.into_response();
}
let owner_slug = crate::platform::model::slug_segment(&owner);
let project_slug = crate::platform::model::slug_segment(&project);
let layout = match state
.platform
.file
.ensure_project_layout(&owner_slug, &project_slug)
{
Ok(l) => l,
Err(err) => return internal_error(err),
};
let mut add_cmd = std::process::Command::new("git");
add_cmd.arg("-C").arg(&layout.repo_dir).arg("add").arg("--");
for f in &req.files {
add_cmd.arg(f);
}
let add_out = match add_cmd.output() {
Ok(o) => o,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"ok": false, "error": e.to_string()})),
)
.into_response();
}
};
if !add_out.status.success() {
let stderr = String::from_utf8_lossy(&add_out.stderr).to_string();
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"ok": false, "error": stderr})),
)
.into_response();
}
let actor_user = session_owner(&state, &headers);
let identity_args = git_identity_args(&state, actor_user.as_deref(), &project_slug);
let mut commit_cmd = std::process::Command::new("git");
for arg in &identity_args {
commit_cmd.arg(arg);
}
commit_cmd
.arg("-C")
.arg(&layout.repo_dir)
.arg("commit")
.arg("-m")
.arg(req.message.trim());
let commit_out = match commit_cmd.output() {
Ok(o) => o,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"ok": false, "error": e.to_string()})),
)
.into_response();
}
};
if !commit_out.status.success() {
let stderr = String::from_utf8_lossy(&commit_out.stderr).to_string();
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"ok": false, "error": stderr})),
)
.into_response();
}
if req.push {
let zebflow_cfg = state
.platform
.zebflow_cfg
.read_or_default(&owner_slug, &project_slug);
let (cred_id, repo_url, branch) = {
let remote_cfg = &zebflow_cfg.configs.git.remote;
let cid = req
.credential_id
.clone()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| remote_cfg.credential_id.clone());
let url = req
.repo_url
.clone()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| remote_cfg.repo_url.clone());
let br = req
.branch
.clone()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| {
if remote_cfg.branch.is_empty() {
"main".to_string()
} else {
remote_cfg.branch.clone()
}
});
(cid, url, br)
};
if cred_id.is_empty() || repo_url.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": "No remote configured. Connect a remote in the Git panel first."})),
)
.into_response();
}
let auth_url = {
let cred = state
.platform
.credentials
.get_project_credential(&owner, &project, &cred_id)
.ok()
.flatten();
let mut url_opt: Option<String> = None;
if let Some(c) = cred {
let username = c.secret["username"].as_str().unwrap_or("");
let token = c.secret["token"].as_str().unwrap_or("");
if !username.is_empty() && !token.is_empty() {
if let Ok(mut parsed) = reqwest::Url::parse(&repo_url) {
let _ = parsed.set_username(username);
let _ = parsed.set_password(Some(token));
url_opt = Some(parsed.to_string());
}
}
}
match url_opt {
Some(u) => u,
None => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": "Could not build authenticated push URL — check credential username/token."})),
)
.into_response();
}
}
};
let pull_out = std::process::Command::new("git")
.arg("-C")
.arg(&layout.repo_dir)
.arg("pull")
.arg("--rebase")
.arg(&auth_url)
.arg(&branch)
.output();
match pull_out {
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"ok": false, "error": format!("pull --rebase failed: {}", e)})),
)
.into_response();
}
Ok(ref o) if !o.status.success() => {
let _ = std::process::Command::new("git")
.arg("-C")
.arg(&layout.repo_dir)
.arg("rebase")
.arg("--abort")
.output();
let raw = String::from_utf8_lossy(&o.stderr);
let safe = redact_auth_urls(&raw);
return (
StatusCode::CONFLICT,
Json(json!({"ok": false, "error": format!("Rebase conflict — resolve locally and try again: {safe}")})),
)
.into_response();
}
Ok(_) => {}
}
let push_out = std::process::Command::new("git")
.arg("-C")
.arg(&layout.repo_dir)
.arg("push")
.arg("--set-upstream")
.arg(&auth_url)
.arg(format!("HEAD:{}", branch))
.output();
match push_out {
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"ok": false, "error": e.to_string()})),
)
.into_response();
}
Ok(ref o) if !o.status.success() => {
let raw = String::from_utf8_lossy(&o.stderr);
let safe = redact_auth_urls(&raw);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"ok": false, "error": safe})),
)
.into_response();
}
Ok(_) => {}
}
}
Json(json!({"ok": true})).into_response()
}
#[derive(serde::Deserialize, serde::Serialize)]
struct GitRemoteRequest {
#[serde(default)]
credential_id: String,
#[serde(default)]
repo_url: String,
#[serde(default)]
branch: String,
}
async fn api_git_get_remote(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
) -> Response {
if let Err(r) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TemplatesRead,
) {
return r;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::GET,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
let cfg = state.platform.zebflow_cfg.read_or_default(&owner, &project);
Json(json!({
"credential_id": cfg.configs.git.remote.credential_id,
"repo_url": cfg.configs.git.remote.repo_url,
"branch": cfg.configs.git.remote.branch,
}))
.into_response()
}
async fn api_git_put_remote(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Json(req): Json<GitRemoteRequest>,
) -> Response {
if let Err(r) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesWrite,
) {
return r;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_json_request_to_worker(
&state,
&uri,
&headers,
Method::PUT,
&req,
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
let branch = if req.branch.trim().is_empty() {
"main".to_string()
} else {
req.branch.trim().to_string()
};
match state.platform.zebflow_cfg.update(&owner, &project, |cfg| {
cfg.configs.git.remote.credential_id = req.credential_id.trim().to_string();
cfg.configs.git.remote.repo_url = req.repo_url.trim().to_string();
cfg.configs.git.remote.branch = branch;
}) {
Ok(_) => Json(json!({"ok": true})).into_response(),
Err(e) => internal_error(e),
}
}
async fn api_activate_pipeline_definition(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Json(req): Json<PipelineLocateRequest>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesWrite,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_json_request_to_worker(
&state,
&uri,
&headers,
Method::POST,
&req,
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
if let Ok(Some(meta)) =
state
.platform
.projects
.get_pipeline_meta_by_file_id(&owner, &project, &req.file_rel_path)
{
if let Ok(source) =
state
.platform
.projects
.read_pipeline_source(&owner, &project, &meta.file_rel_path)
{
if let Ok(graph) = serde_json::from_str::<crate::pipeline::PipelineGraph>(&source) {
if let Ok(conflicts) = state.platform.projects.check_webhook_path_conflict(
&owner,
&project,
&graph,
&meta.file_rel_path,
) {
if !conflicts.is_empty() {
let msg = format!(
"{} {} is already registered by pipeline '{}'",
conflicts[0].method, conflicts[0].path, conflicts[0].pipeline_name
);
return (
StatusCode::CONFLICT,
Json(json!({
"ok": false,
"error": {
"code": "PLATFORM_PIPELINE_WEBHOOK_CONFLICT",
"message": msg,
"conflicts": conflicts
}
})),
)
.into_response();
}
}
}
}
}
match state
.platform
.projects
.activate_pipeline_definition(&owner, &project, &req.file_rel_path)
{
Ok(meta) => {
if let Err(err) = state.platform.pipeline_runtime.refresh_pipeline(
&owner,
&project,
&req.file_rel_path,
) {
return internal_error(err);
}
state
.scheduler
.sync_pipeline(&owner, &project, &req.file_rel_path)
.await;
state
.kv_subscriber
.sync_pipeline(&owner, &project, &req.file_rel_path)
.await;
state
.ws_client_manager
.sync_pipeline(&owner, &project, &req.file_rel_path)
.await;
run_composite_lifecycle_hooks(
&state,
&owner,
&project,
&req.file_rel_path,
"on_activate",
)
.await;
Json(json!({"ok": true, "meta": meta})).into_response()
}
Err(err) => internal_error(err),
}
}
async fn api_deactivate_pipeline_definition(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Json(req): Json<PipelineLocateRequest>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesWrite,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_json_request_to_worker(
&state,
&uri,
&headers,
Method::POST,
&req,
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
match state.platform.projects.deactivate_pipeline_definition(
&owner,
&project,
&req.file_rel_path,
) {
Ok(meta) => {
run_composite_lifecycle_hooks(
&state,
&owner,
&project,
&req.file_rel_path,
"on_deactivate",
)
.await;
state
.platform
.pipeline_runtime
.evict(&owner, &project, &req.file_rel_path);
state
.scheduler
.sync_pipeline(&owner, &project, &req.file_rel_path)
.await;
state
.kv_subscriber
.sync_pipeline(&owner, &project, &req.file_rel_path)
.await;
state
.ws_client_manager
.sync_pipeline(&owner, &project, &req.file_rel_path)
.await;
Json(json!({"ok": true, "meta": meta})).into_response()
}
Err(err) => internal_error(err),
}
}
async fn run_composite_lifecycle_hooks(
state: &PlatformAppState,
owner: &str,
project: &str,
file_rel_path: &str,
hook: &str, ) {
let source = match state
.platform
.projects
.read_pipeline_source(owner, project, file_rel_path)
{
Ok(s) => s,
Err(_) => return,
};
let graph: crate::pipeline::PipelineGraph = match serde_json::from_str(&source) {
Ok(g) => g,
Err(_) => return,
};
for node in &graph.nodes {
if !node.kind.starts_with("n.c.") {
continue;
}
let manifest = match state
.platform
.node_registry
.get_manifest(owner, project, &node.kind)
{
Some(m) => m,
None => continue,
};
let lifecycle = match &manifest.lifecycle {
Some(lc) => lc,
None => continue,
};
let function_name = match hook {
"on_activate" => lifecycle.on_activate.as_deref(),
"on_deactivate" => lifecycle.on_deactivate.as_deref(),
_ => None,
};
let function_name = match function_name {
Some(f) => f,
None => continue,
};
let function_graph = match state.platform.node_registry.load_composite_function(
owner,
project,
&node.kind,
Some(function_name),
) {
Ok(g) => g,
Err(e) => {
eprintln!(
"composite_lifecycle: failed loading {} for '{}': {}",
hook, node.kind, e.message
);
continue;
}
};
let placeholder_map = crate::pipeline::build_composite_placeholder_map(
&node.kind,
&node.config,
owner,
project,
&state.platform,
);
let public_url = std::env::var("ZEBFLOW_PLATFORM_BASE_URL").unwrap_or_else(|_| {
let port = std::env::var("ZEBFLOW_PORT").unwrap_or_else(|_| "10611".to_string());
format!("http://localhost:{}", port)
});
let ctx = crate::pipeline::PipelineContext {
owner: owner.to_string(),
project: project.to_string(),
pipeline: function_graph.id.clone(),
request_id: format!(
"lifecycle-{}-{}-{}",
hook,
node.kind,
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
),
route: Default::default(),
input: serde_json::json!({
"node_id": node.id,
"node_kind": node.kind,
"config": node.config,
"hook": hook,
"pipeline": file_rel_path,
"owner": owner,
"project": project,
"platform": {
"public_url": public_url,
},
}),
trigger: None,
placeholder: if placeholder_map.is_empty() {
None
} else {
Some(serde_json::json!(placeholder_map))
},
};
let engine = crate::pipeline::BasicPipelineEngine::new(
std::sync::Arc::new(crate::language::DenoSandboxEngine::default()),
crate::rwe::resolve_engine_or_default(None),
Some(state.platform.credentials.clone()),
)
.with_platform(state.platform.clone())
.with_ws_hub(state.platform.ws_hub.clone())
.with_state_bus(state.platform.state_bus.clone())
.with_data_root(state.platform.config.data_root.clone());
match engine.execute_async(&function_graph, &ctx).await {
Ok(output) => {
eprintln!(
"composite_lifecycle: {} for '{}' in '{}' OK: {}",
hook,
node.kind,
file_rel_path,
serde_json::to_string(&output.value).unwrap_or_default()
);
}
Err(e) => {
eprintln!(
"composite_lifecycle: {} for '{}' in '{}' FAILED: {}",
hook, node.kind, file_rel_path, e.message
);
}
}
}
}
async fn api_execute_pipeline(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
Json(req): Json<ExecutePipelineRequest>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesExecute,
) {
return response;
}
if let Ok(Some(placement)) = state.platform.cluster_placement.get(&owner, &project) {
if placement.target == ProjectRuntimePlacementTarget::Worker {
if let Some(worker_id) = placement.worker_id.as_deref() {
return match forward_runtime_execute_to_worker(
&state, &owner, &project, &req, worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
}
}
execute_pipeline_local(&state, &owner, &project, &req).await
}
async fn execute_pipeline_local(
state: &PlatformAppState,
owner: &str,
project: &str,
req: &ExecutePipelineRequest,
) -> Response {
let exec_start = std::time::Instant::now();
let project_cfg = state.platform.zebflow_cfg.read_or_default(owner, project);
let project_retention = resolve_invocation_retention(&project_cfg, None);
let request_id = format!(
"pipeline-exec-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
);
let meta = match state.platform.projects.get_pipeline_meta_by_file_id(
owner,
project,
&req.file_rel_path,
) {
Ok(Some(meta)) => meta,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(
json!({"ok": false, "error": {"code":"PLATFORM_PIPELINE_MISSING", "message":"pipeline not found"}}),
),
)
.into_response();
}
Err(err) => return internal_error(err),
};
let source = match state
.platform
.projects
.read_active_pipeline_source(owner, project, &meta)
{
Ok(source) => source,
Err(err) if err.code == "PLATFORM_PIPELINE_NOT_ACTIVE" => {
state.platform.pipeline_hits.record_failure(
owner,
project,
&meta.file_rel_path,
"api.execute",
err.code,
"pipeline must be activated before execution",
);
let _ = state.platform.data.log_pipeline_invocation(
owner,
project,
&meta.file_rel_path,
&PipelineInvocationEntry {
run_id: request_id.clone(),
at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64,
duration_ms: exec_start.elapsed().as_millis() as u64,
status: "error".to_string(),
trigger: "manual".to_string(),
error: Some("pipeline must be activated before execution".to_string()),
trace: vec![],
},
project_retention.max_invocations,
project_retention.max_age_secs,
);
return (
StatusCode::CONFLICT,
Json(
json!({"ok": false, "error": {"code": err.code, "message":"pipeline must be activated before execution"}}),
),
)
.into_response();
}
Err(err) => return internal_error(err),
};
let mut graph: PipelineGraph = match serde_json::from_str(&source) {
Ok(graph) => graph,
Err(err) => {
state.platform.pipeline_hits.record_failure(
owner,
project,
&meta.file_rel_path,
"api.execute",
"PLATFORM_PIPELINE_PARSE",
&err.to_string(),
);
let _ = state.platform.data.log_pipeline_invocation(
owner,
project,
&meta.file_rel_path,
&PipelineInvocationEntry {
run_id: request_id.clone(),
at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64,
duration_ms: exec_start.elapsed().as_millis() as u64,
status: "error".to_string(),
trigger: "manual".to_string(),
error: Some(err.to_string()),
trace: vec![],
},
project_retention.max_invocations,
project_retention.max_age_secs,
);
return (
StatusCode::BAD_REQUEST,
Json(
json!({"ok": false, "error": {"code":"PLATFORM_PIPELINE_PARSE", "message": err.to_string()}}),
),
)
.into_response();
}
};
let retention = resolve_invocation_retention(&project_cfg, Some(&graph));
if let Err(err) = hydrate_template_markup(state, owner, project, &mut graph) {
state.platform.pipeline_hits.record_failure(
owner,
project,
&meta.file_rel_path,
"api.execute",
err.code,
&err.message,
);
let _ = state.platform.data.log_pipeline_invocation(
owner,
project,
&meta.file_rel_path,
&PipelineInvocationEntry {
run_id: request_id.clone(),
at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64,
duration_ms: exec_start.elapsed().as_millis() as u64,
status: "error".to_string(),
trigger: "manual".to_string(),
error: Some(err.message.clone()),
trace: vec![],
},
retention.max_invocations,
retention.max_age_secs,
);
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": {"code": err.code, "message": err.message}})),
)
.into_response();
}
apply_rwe_project_options(state, owner, project, &mut graph);
if let Err(message) = validate_execute_trigger(&graph, &req) {
state.platform.pipeline_hits.record_failure(
owner,
project,
&meta.file_rel_path,
"api.execute",
"PLATFORM_PIPELINE_TRIGGER_MISMATCH",
&message,
);
let _ = state.platform.data.log_pipeline_invocation(
owner,
project,
&meta.file_rel_path,
&PipelineInvocationEntry {
run_id: request_id.clone(),
at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64,
duration_ms: exec_start.elapsed().as_millis() as u64,
status: "error".to_string(),
trigger: "manual".to_string(),
error: Some(message.clone()),
trace: vec![],
},
retention.max_invocations,
retention.max_age_secs,
);
return (
StatusCode::BAD_REQUEST,
Json(
json!({"ok": false, "error": {"code":"PLATFORM_PIPELINE_TRIGGER_MISMATCH", "message": message}}),
),
)
.into_response();
}
let credentials = state.platform.credentials.clone();
let graph_for_run = graph.clone();
let ctx = PipelineContext {
owner: owner.to_string(),
project: project.to_string(),
pipeline: graph.id.clone(),
request_id: request_id.clone(),
route: Default::default(),
input: req.input.clone(),
trigger: None,
placeholder: None,
};
let engine = BasicPipelineEngine::new(
Arc::new(DenoSandboxEngine::default()),
state.frontend.rwe.clone(),
Some(credentials),
)
.with_platform(state.platform.clone())
.with_template_cache(state.template_cache.clone())
.with_template_root(
state
.platform
.projects
.get_project_template_root(owner, project)
.ok(),
)
.with_ws_hub(state.platform.ws_hub.clone())
.with_ws_client_manager(state.ws_client_manager.clone())
.with_state_bus(state.platform.state_bus.clone())
.with_data_root(state.platform.config.data_root.clone());
match engine.execute_async(&graph_for_run, &ctx).await {
Ok(output) => {
state
.platform
.pipeline_hits
.record_success(owner, project, &meta.file_rel_path);
let _ = state.platform.data.log_pipeline_invocation(
owner,
project,
&meta.file_rel_path,
&PipelineInvocationEntry {
run_id: request_id.clone(),
at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64,
duration_ms: exec_start.elapsed().as_millis() as u64,
status: "ok".to_string(),
trigger: "manual".to_string(),
error: None,
trace: output.node_trace.clone(),
},
retention.max_invocations,
retention.max_age_secs,
);
Json(json!({
"ok": true,
"run_id": request_id,
"meta": meta,
"output": output.value,
"trace": output.trace
}))
.into_response()
}
Err(err) => {
state.platform.pipeline_hits.record_failure(
owner,
project,
&meta.file_rel_path,
"api.execute",
err.code,
&err.message,
);
let _ = state.platform.data.log_pipeline_invocation(
owner,
project,
&meta.file_rel_path,
&PipelineInvocationEntry {
run_id: request_id.clone(),
at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64,
duration_ms: exec_start.elapsed().as_millis() as u64,
status: "error".to_string(),
trigger: "manual".to_string(),
error: Some(err.message.clone()),
trace: err.node_trace.clone(),
},
retention.max_invocations,
retention.max_age_secs,
);
(
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": {"code": err.code, "message": err.message}})),
)
.into_response()
}
}
}
async fn api_execute_pipeline_dsl(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Json(req): Json<crate::platform::shell::DslRequest>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesExecute,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_json_request_to_worker(
&state,
&uri,
&headers,
Method::POST,
&req,
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
let executor = crate::platform::shell::executor::DslExecutor::new(
state.platform.clone(),
&owner,
&project,
);
let output = executor.execute_dsl(&req.dsl).await;
let navigate = crate::platform::interaction::InteractionEngine::new(&owner, &project)
.match_dsl(&req.dsl, output.ok);
Json(json!({ "ok": output.ok, "lines": output.lines, "navigate": navigate })).into_response()
}
async fn api_pipeline_hits(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesRead,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::GET,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
match state
.platform
.projects
.list_pipeline_meta_rows(&owner, &project)
{
Ok(rows) => {
let items = rows
.into_iter()
.map(|meta| {
let file_id = meta.file_rel_path.clone();
json!({
"id": file_id,
"name": meta.name,
"virtual_path": meta.virtual_path,
"file_rel_path": meta.file_rel_path,
"stats": state
.platform
.pipeline_hits
.get(&owner, &project, &meta.file_rel_path)
})
})
.collect::<Vec<_>>();
Json(json!({
"ok": true,
"items": items,
"count": items.len()
}))
.into_response()
}
Err(err) => internal_error(err),
}
}
async fn api_pipeline_invocations(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesRead,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::GET,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
let Some(file_rel_path) = params.get("pipeline") else {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": {"code": "MISSING_PARAM", "message": "missing ?pipeline= query parameter"}})),
)
.into_response();
};
let project_cfg = state.platform.zebflow_cfg.read_or_default(&owner, &project);
let retention =
match state
.platform
.projects
.get_pipeline_meta_by_file_id(&owner, &project, file_rel_path)
{
Ok(Some(meta)) => {
let source = state
.platform
.projects
.read_active_pipeline_source(&owner, &project, &meta)
.or_else(|_| {
state.platform.projects.read_pipeline_source(
&owner,
&project,
file_rel_path,
)
})
.ok();
let graph = source.as_deref().and_then(|raw| {
serde_json::from_str::<crate::pipeline::PipelineGraph>(raw).ok()
});
resolve_invocation_retention(&project_cfg, graph.as_ref())
}
_ => resolve_invocation_retention(&project_cfg, None),
};
match state.platform.data.get_pipeline_invocations(
&owner,
&project,
file_rel_path,
retention.max_age_secs,
) {
Ok(entries) => Json(json!({
"ok": true,
"file_rel_path": file_rel_path,
"entries": entries,
"count": entries.len(),
}))
.into_response(),
Err(err) => internal_error(err),
}
}
async fn api_template_workspace(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TemplatesRead,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::GET,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
match state
.platform
.projects
.list_template_workspace(&owner, &project)
{
Ok(workspace) => Json(workspace).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_template_search(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Query(query): Query<TemplateSearchQuery>,
) -> Response {
if let Err(r) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TemplatesRead,
) {
return r;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::GET,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
let q = query.q.as_deref().unwrap_or("").trim().to_string();
if q.is_empty() {
return Json(json!({ "matches": [] })).into_response();
}
let scope = query.scope.as_deref().unwrap_or("all");
let glob = if scope == "pages" {
Some("pages/*.tsx")
} else {
None
};
match state
.platform
.projects
.search_template_files(&owner, &project, &q, glob, 0)
{
Ok(raw) => {
let matches: Vec<_> = raw
.into_iter()
.take(50)
.map(|(rel_path, line, snippet)| {
json!({ "rel_path": rel_path, "line": line, "snippet": snippet.trim() })
})
.collect();
Json(json!({ "matches": matches })).into_response()
}
Err(err) => internal_error(err),
}
}
#[derive(Debug, Deserialize)]
struct TemplateSearchQuery {
q: Option<String>,
scope: Option<String>,
}
async fn api_template_pages(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Query(query): Query<TemplatePathQuery>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TemplatesRead,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::GET,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
let path = query.path.as_deref();
match state
.platform
.projects
.list_template_pages(&owner, &project, path)
{
Ok(items) => Json(json!({
"ok": true,
"path": path.unwrap_or("/"),
"items": items,
}))
.into_response(),
Err(err) => internal_error(err),
}
}
async fn api_template_file(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Query(query): Query<TemplatePathQuery>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TemplatesRead,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::GET,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
let Some(path) = query.path.as_deref() else {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error":"missing path"})),
)
.into_response();
};
match state
.platform
.projects
.read_template_payload(&owner, &project, path)
{
Ok(file) => Json(file).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_template_outline(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Query(query): Query<TemplatePathQuery>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TemplatesRead,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::GET,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
let Some(path) = query.path.as_deref() else {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error":"missing path"})),
)
.into_response();
};
match state
.platform
.projects
.read_template_file(&owner, &project, path)
{
Ok(content) => {
let outline =
crate::platform::services::tsx_outline::extract_outline(&content, Some(path));
Json(json!({
"ok": true,
"rel_path": path,
"outline": outline,
}))
.into_response()
}
Err(err) => internal_error(err),
}
}
async fn api_template_save(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Json(req): Json<TemplateSaveRequest>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TemplatesWrite,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_json_request_to_worker(
&state,
&uri,
&headers,
Method::PUT,
&req,
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
match state
.platform
.projects
.write_template_file(&owner, &project, &req)
{
Ok(file) => {
let owner_slug = crate::platform::model::slug_segment(&owner);
let project_slug = crate::platform::model::slug_segment(&project);
if let Ok(layout) = state
.platform
.file
.ensure_project_layout(&owner_slug, &project_slug)
{
if let Ok(abs) = state.platform.projects.resolve_template_abs_path(
&owner_slug,
&project_slug,
&req.rel_path,
) {
crate::pipeline::engines::basic::evict_template_cache_by_path(
&state.template_cache,
&abs.to_string_lossy(),
);
}
if let Err(err) = trigger_project_asset_prepare_on_template_save(
&state,
&owner_slug,
&project_slug,
&layout,
) {
eprintln!(
"warning: template save asset prepare failed for {}/{}: {} ({})",
owner_slug, project_slug, err.code, err.message
);
}
}
Json(file).into_response()
}
Err(err) => internal_error(err),
}
}
async fn api_template_create(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Json(req): Json<TemplateCreateRequest>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TemplatesCreate,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_json_request_to_worker(
&state,
&uri,
&headers,
Method::POST,
&req,
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
match state
.platform
.projects
.create_template_entry(&owner, &project, &req)
{
Ok(file) => Json(file).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_template_move(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Json(req): Json<TemplateMoveRequest>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TemplatesMove,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_json_request_to_worker(
&state,
&uri,
&headers,
Method::POST,
&req,
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
match state
.platform
.projects
.move_template_entry(&owner, &project, &req)
{
Ok(rel_path) => Json(json!({ "rel_path": rel_path })).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_template_delete(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Query(query): Query<TemplatePathQuery>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TemplatesDelete,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::DELETE,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
let Some(path) = query.path.as_deref() else {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error":"missing path"})),
)
.into_response();
};
match state
.platform
.projects
.delete_template_entry(&owner, &project, path)
{
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(err) => internal_error(err),
}
}
async fn api_template_git_status(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TemplatesRead,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::GET,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
match state
.platform
.projects
.list_template_git_status(&owner, &project)
{
Ok(items) => Json(items).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_template_diagnostics(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Json(req): Json<TemplateCompileRequest>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TemplatesDiagnostics,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_json_request_to_worker(
&state,
&uri,
&headers,
Method::POST,
&req,
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
let owner = crate::platform::model::slug_segment(&owner);
let project = crate::platform::model::slug_segment(&project);
let layout = match state.platform.file.ensure_project_layout(&owner, &project) {
Ok(layout) => layout,
Err(err) => return internal_error(err),
};
let response = compile_template_buffer(&state, &layout.repo_pipelines_dir, &req);
Json(response).into_response()
}
async fn api_files_list(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> Response {
if let Err(r) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::FilesRead,
) {
return r;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::GET,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
let layout = match state.platform.file.ensure_project_layout(&owner, &project) {
Ok(l) => l,
Err(e) => return internal_error(e),
};
let rel = params
.get("path")
.map(|s| s.trim().trim_start_matches('/'))
.unwrap_or("")
.to_string();
let zebfs = crate::zebfs::LocalZebFs::new(layout.files_dir);
let mut folders: Vec<serde_json::Value> = Vec::new();
let mut files: Vec<serde_json::Value> = Vec::new();
let entries = match zebfs.list(&rel) {
Ok(entries) => entries,
Err(err) if err.code == "ZEBFS_INVALID_PATH" => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error":"invalid path"})),
)
.into_response();
}
Err(err) => return internal_error(PlatformError::new(err.code, err.message)),
};
for entry in entries {
let name = entry.name;
let path = entry.path;
if matches!(entry.kind, crate::zebfs::ZebFsEntryKind::Prefix) {
folders.push(json!({
"name": name,
"path": path,
"protected": false,
}));
} else {
let modified = entry
.modified
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
files.push(json!({
"name": name,
"path": path.clone(),
"size": entry.size,
"modified": modified,
"url": format!("/fs/{owner}/{project}/{}", path),
}));
}
}
Json(json!({ "path": rel, "folders": folders, "files": files })).into_response()
}
async fn api_files_mkdir(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Json(body): Json<serde_json::Value>,
) -> Response {
if let Err(r) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::FilesWrite,
) {
return r;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_json_request_to_worker(
&state,
&uri,
&headers,
Method::POST,
&body,
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
let layout = match state.platform.file.ensure_project_layout(&owner, &project) {
Ok(l) => l,
Err(e) => return internal_error(e),
};
let path_str = body
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.trim_start_matches('/')
.to_string();
let path_str = match crate::zebfs::normalize_object_path(&path_str) {
Ok(path) => path,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error":"invalid path"})),
)
.into_response();
}
};
if path_str.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error":"path is required"})),
)
.into_response();
}
let zebfs = crate::zebfs::LocalZebFs::new(layout.files_dir);
match zebfs.create_prefix(&path_str) {
Ok(_) => Json(json!({ "ok": true, "path": path_str })).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
)
.into_response(),
}
}
async fn api_files_upload(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Query(params): Query<std::collections::HashMap<String, String>>,
mut multipart: Multipart,
) -> Response {
if let Err(r) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::FilesWrite,
) {
return r;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
let field = match multipart.next_field().await {
Ok(Some(field)) => field,
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": "no file field in multipart body"})),
)
.into_response();
}
Err(err) => return internal_error(PlatformError::new("FILES_UPLOAD", err.to_string())),
};
let raw_filename = field
.file_name()
.map(|value| value.to_string())
.unwrap_or_else(|| "upload.bin".to_string());
let content_type = field.content_type().map(|value| value.to_string());
let bytes = match field.bytes().await {
Ok(bytes) => bytes,
Err(err) => return internal_error(PlatformError::new("FILES_UPLOAD", err.to_string())),
};
let worker = match state.platform.cluster_registry.get_worker(&worker_id) {
Ok(Some(worker)) => worker,
Ok(None) => {
return (
StatusCode::BAD_GATEWAY,
Json(json!({"ok": false, "error": "office not registered"})),
)
.into_response();
}
Err(err) => return internal_error(err),
};
let token = match cluster_internal_token_value(&state) {
Ok(token) => token,
Err(err) => return internal_error(err),
};
let mut url = format!("{}{}", worker.base_url.trim_end_matches('/'), uri.path());
if let Some(query) = uri.query() {
url.push('?');
url.push_str(query);
}
let part = match content_type {
Some(content_type) => reqwest::multipart::Part::bytes(bytes.to_vec())
.file_name(raw_filename.clone())
.mime_str(&content_type)
.unwrap_or_else(|_| {
reqwest::multipart::Part::bytes(bytes.to_vec()).file_name(raw_filename)
}),
None => reqwest::multipart::Part::bytes(bytes.to_vec()).file_name(raw_filename),
};
let form = reqwest::multipart::Form::new().part("file", part);
let response = match state
.http_client
.post(url)
.header(INTERNAL_CLUSTER_TOKEN_HEADER, token)
.multipart(form)
.send()
.await
{
Ok(response) => response,
Err(err) => return internal_error(PlatformError::new("FILES_UPLOAD", err.to_string())),
};
return reqwest_response_to_axum(response).await;
}
let layout = match state.platform.file.ensure_project_layout(&owner, &project) {
Ok(l) => l,
Err(e) => return internal_error(e),
};
let project_cfg = state.platform.zebflow_cfg.read_or_default(&owner, &project);
let max_file_size_mb = project_cfg
.configs
.files
.uploads
.effective_max_file_size_mb();
let max_bytes = (max_file_size_mb as u64) * 1024 * 1024;
let rel = params
.get("path")
.map(|s| s.trim().trim_start_matches('/'))
.unwrap_or("uploads")
.to_string();
let rel = match crate::zebfs::normalize_object_path(&rel) {
Ok(path) => path,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": "invalid path"})),
)
.into_response();
}
};
if rel.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": "path is required"})),
)
.into_response();
}
let field = match multipart.next_field().await {
Ok(Some(field)) => field,
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": "no file field in multipart body"})),
)
.into_response();
}
Err(err) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": err.to_string()})),
)
.into_response();
}
};
let raw_filename = field
.file_name()
.map(|s| s.to_string())
.or_else(|| field.name().map(|s| s.to_string()))
.unwrap_or_else(|| "upload.bin".to_string());
let content_type = field
.content_type()
.map(|value| value.to_string())
.unwrap_or_else(|| "application/octet-stream".to_string());
let filename = match sanitize_asset_filename(&raw_filename) {
Some(name) => name,
None => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": "invalid filename"})),
)
.into_response();
}
};
let bytes = match field.bytes().await {
Ok(bytes) => bytes,
Err(err) => return internal_error(PlatformError::new("FILES_UPLOAD", err.to_string())),
};
if bytes.len() as u64 > max_bytes {
return (
StatusCode::PAYLOAD_TOO_LARGE,
Json(json!({"ok": false, "error": format!("file size {} bytes exceeds limit of {} MB", bytes.len(), max_file_size_mb)})),
)
.into_response();
}
let entry_rel = format!("{rel}/{filename}");
let zebfs = crate::zebfs::LocalZebFs::new(layout.files_dir);
if let Err(err) = zebfs.put(&entry_rel, bytes.as_ref()) {
return internal_error(PlatformError::new("FILES_UPLOAD_WRITE", err.to_string()));
}
Json(json!({
"ok": true,
"__zf_type": crate::pipeline::nodes::basic::file_ref::FILE_REF_TYPE,
"backend": crate::pipeline::nodes::basic::file_ref::BACKEND_ZEBFS,
"ref": entry_rel,
"path": entry_rel,
"url": format!("/fs/{owner}/{project}/{entry_rel}"),
"filename": filename,
"name": filename,
"mime": content_type,
"content_type": content_type,
"size": bytes.len(),
"lifecycle": crate::pipeline::nodes::basic::file_ref::LIFECYCLE_DURABLE,
"origin": "project.files.upload",
"trust": "user"
}))
.into_response()
}
async fn api_files_rm(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Json(body): Json<serde_json::Value>,
) -> Response {
if let Err(r) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::FilesWrite,
) {
return r;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_json_request_to_worker(
&state,
&uri,
&headers,
Method::POST,
&body,
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
let layout = match state.platform.file.ensure_project_layout(&owner, &project) {
Ok(l) => l,
Err(e) => return internal_error(e),
};
let path_str = body
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.trim_start_matches('/')
.to_string();
let path_str = match crate::zebfs::normalize_object_path(&path_str) {
Ok(path) => path,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error":"invalid path"})),
)
.into_response();
}
};
if path_str.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error":"path is required"})),
)
.into_response();
}
let zebfs = crate::zebfs::LocalZebFs::new(layout.files_dir);
match zebfs.delete(&path_str) {
Ok(_) => Json(json!({ "ok": true })).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
)
.into_response(),
}
}
async fn api_mapserver_sources_list(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, _instance)): Path<(String, String, String)>,
uri: Uri,
) -> Response {
if let Err(r) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::FilesRead,
) {
return r;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::GET,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
match list_mapserver_source_files(&state, &owner, &project) {
Ok(items) => Json(json!({ "items": items })).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_mapserver_layers_list(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, instance)): Path<(String, String, String)>,
uri: Uri,
) -> Response {
if let Err(r) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::FilesRead,
) {
return r;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::GET,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
match read_mapserver_layers(&state, &owner, &project, &instance) {
Ok(items) => Json(json!({ "items": items })).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_mapserver_layers_publish(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, instance)): Path<(String, String, String)>,
uri: Uri,
Json(body): Json<serde_json::Value>,
) -> Response {
if let Err(r) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::FilesWrite,
) {
return r;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_json_request_to_worker(
&state,
&uri,
&headers,
Method::POST,
&body,
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
let layer_id = body
.get("layer_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("");
let path = body
.get("path")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("");
let source_path = body
.get("source_path")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("");
if layer_id.is_empty() || path.is_empty() || source_path.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": "layer_id, path, and source_path are required"})),
)
.into_response();
}
let source_path = match crate::zebfs::normalize_object_path(source_path) {
Ok(path) if path.starts_with("mapserver/") => path,
_ => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": "source_path must be under mapserver/"})),
)
.into_response();
}
};
let layout = match state.platform.file.ensure_project_layout(&owner, &project) {
Ok(layout) => layout,
Err(err) => return internal_error(err),
};
let source_abs_path = layout.files_dir.join(&source_path);
if !source_abs_path.exists() || !source_abs_path.is_file() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": "source_path file not found"})),
)
.into_response();
}
let allowed_properties = body
.get("allowed_properties")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(ToString::to_string))
.collect::<Vec<_>>()
})
.unwrap_or_default();
let min_zoom = body
.get("min_zoom")
.and_then(Value::as_u64)
.and_then(|v| u8::try_from(v).ok());
let max_zoom = body
.get("max_zoom")
.and_then(Value::as_u64)
.and_then(|v| u8::try_from(v).ok());
if let (Some(min_zoom), Some(max_zoom)) = (min_zoom, max_zoom) {
if min_zoom > max_zoom {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": "min_zoom must be <= max_zoom"})),
)
.into_response();
}
}
let artifact_root = match mapserver_artifacts_root(&state, &owner, &project, &instance) {
Ok(path) => path,
Err(err) => return internal_error(err),
};
let safe_layer_id = layer_id
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
ch
} else {
'_'
}
})
.collect::<String>();
let artifact_abs_dir = artifact_root.join(&safe_layer_id);
let artifact_rel_dir = format!("mapserver/.artifacts/{instance}/{safe_layer_id}");
let build = match crate::mapserver::publish::build::build_geojson_artifact(
&source_abs_path,
layer_id,
&artifact_abs_dir,
&artifact_rel_dir,
) {
Ok(build) => build,
Err(err) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": err})),
)
.into_response();
}
};
let mut items = match read_mapserver_layers(&state, &owner, &project, &instance) {
Ok(items) => items,
Err(err) => return internal_error(err),
};
let record = MapserverLayerRecord {
layer_id: layer_id.to_string(),
path: if path.starts_with('/') {
path.to_string()
} else {
format!("/{}", path)
},
source_path,
source_kind: "geojson_artifact".to_string(),
artifact_manifest_path: Some(build.manifest_rel_path.clone()),
mode: "features".to_string(),
min_zoom,
max_zoom,
bbox_required: body
.get("bbox_required")
.and_then(Value::as_bool)
.unwrap_or(true),
max_features: body
.get("max_features")
.and_then(Value::as_u64)
.map(|v| v as usize)
.unwrap_or(1000),
allowed_properties,
feature_count: Some(build.feature_count),
chunk_count: Some(build.chunk_count),
style: None,
filter: None,
function_slug: None,
cache_ttl_secs: None,
};
items.retain(|item| item.layer_id != record.layer_id);
items.push(record.clone());
items.sort_by(|a, b| a.layer_id.cmp(&b.layer_id));
if let Err(err) = write_mapserver_layers(&state, &owner, &project, &instance, &items) {
return internal_error(err);
}
Json(json!({
"ok": true,
"item": record,
"public_url": format!("/ms/{owner}/{project}{}", items.iter().find(|i| i.layer_id == layer_id).map(|i| i.path.clone()).unwrap_or_default())
}))
.into_response()
}
async fn api_mapserver_layers_delete(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, instance, layer_id)): Path<(String, String, String, String)>,
uri: Uri,
) -> Response {
if let Err(r) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::FilesWrite,
) {
return r;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::DELETE,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
let mut items = match read_mapserver_layers(&state, &owner, &project, &instance) {
Ok(items) => items,
Err(err) => return internal_error(err),
};
let removed = items.iter().find(|item| item.layer_id == layer_id).cloned();
let before = items.len();
items.retain(|item| item.layer_id != layer_id);
if before == items.len() {
return (
StatusCode::NOT_FOUND,
Json(json!({"ok": false, "error": "layer not found"})),
)
.into_response();
}
if let Err(err) = write_mapserver_layers(&state, &owner, &project, &instance, &items) {
return internal_error(err);
}
if let Some(removed) = removed {
if let Some(artifact_rel) = removed.artifact_manifest_path {
if let Ok(layout) = state.platform.file.ensure_project_layout(&owner, &project) {
let artifact_manifest = layout.files_dir.join(artifact_rel.trim_start_matches('/'));
if let Some(dir) = artifact_manifest.parent() {
let _ = std::fs::remove_dir_all(dir);
}
}
}
}
Json(json!({"ok": true})).into_response()
}
async fn api_list_credential_types(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::CredentialsRead,
) {
return response;
}
let mut types = crate::platform::services::credential::builtin_credential_types();
for ct in state
.platform
.node_registry
.all_package_credential_types(&owner, &project)
{
if !types.iter().any(|t| t.kind == ct.kind) {
types.push(ct);
}
}
Json(json!({"ok": true, "types": types})).into_response()
}
async fn api_list_credentials(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::CredentialsRead,
) {
return response;
}
match state
.platform
.credentials
.list_project_credentials(&owner, &project)
{
Ok(items) => Json(json!({"ok": true, "items": items})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_get_credential(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, credential_id)): Path<(String, String, String)>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::CredentialsRead,
) {
return response;
}
match state
.platform
.credentials
.get_project_credential(&owner, &project, &credential_id)
{
Ok(Some(credential)) => Json(json!({"ok": true, "credential": credential})).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, Json(json!({"ok": false, "error": {"code":"PLATFORM_CREDENTIAL_MISSING","message":"credential not found"}}))).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_upsert_credential(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
Json(req): Json<UpsertProjectCredentialRequest>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::CredentialsWrite,
) {
return response;
}
match state
.platform
.credentials
.upsert_project_credential(&owner, &project, &req)
{
Ok(credential) => Json(json!({"ok": true, "credential": credential})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_upsert_credential_by_path(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, credential_id)): Path<(String, String, String)>,
Json(mut req): Json<UpsertProjectCredentialRequest>,
) -> Response {
req.credential_id = credential_id;
api_upsert_credential(State(state), headers, Path((owner, project)), Json(req)).await
}
async fn api_delete_credential(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, credential_id)): Path<(String, String, String)>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::CredentialsWrite,
) {
return response;
}
match state
.platform
.credentials
.delete_project_credential(&owner, &project, &credential_id)
{
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(err) => internal_error(err),
}
}
async fn api_oauth2_authorize(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, credential_id)): Path<(String, String, String)>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::CredentialsWrite,
) {
return response;
}
let credential =
match state
.platform
.credentials
.get_project_credential(&owner, &project, &credential_id)
{
Ok(Some(c)) => c,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "credential not found"})),
)
.into_response();
}
Err(err) => return internal_error(err),
};
if credential.kind != "oauth2" {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "credential is not oauth2"})),
)
.into_response();
}
let secret = &credential.secret;
let authorize_url = secret
.get("authorize_url")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let client_id = secret
.get("client_id")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let scopes = secret
.get("scopes")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
if authorize_url.is_empty() || client_id.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "authorize_url and client_id are required"})),
)
.into_response();
}
let redirect_uri = secret
.get("redirect_uri")
.and_then(serde_json::Value::as_str)
.filter(|v| !v.trim().is_empty())
.map(|v| v.to_string())
.unwrap_or_else(|| {
let base = platform_base_url(&headers);
format!("{base}/oauth/callback")
});
let oauth_state = sign_oauth_state(&state.oauth_state_secret, &owner, &project, &credential_id);
let mut url = format!(
"{authorize_url}?client_id={}&redirect_uri={}&response_type=code&state={}",
url_query_encode(client_id),
url_query_encode(&redirect_uri),
url_query_encode(&oauth_state),
);
if !scopes.is_empty() {
url.push_str(&format!("&scope={}", url_query_encode(scopes)));
}
url.push_str("&access_type=offline&prompt=consent");
Json(serde_json::json!({ "redirect_url": url })).into_response()
}
#[derive(serde::Deserialize)]
struct OAuthCallbackQuery {
code: Option<String>,
state: Option<String>,
error: Option<String>,
#[allow(dead_code)]
error_description: Option<String>,
}
async fn oauth2_callback_handler(
State(state): State<PlatformAppState>,
headers: HeaderMap,
axum::extract::Query(query): axum::extract::Query<OAuthCallbackQuery>,
) -> Response {
if query.error.is_some() {
return axum::response::Redirect::to("/home?oauth=denied").into_response();
}
let Some(oauth_state) = &query.state else {
return axum::response::Redirect::to("/home?oauth=error").into_response();
};
let Some(code) = &query.code else {
return axum::response::Redirect::to("/home?oauth=error").into_response();
};
let (owner, project, credential_id) =
match verify_oauth_state(&state.oauth_state_secret, oauth_state) {
Ok(v) => v,
Err(_) => {
return axum::response::Redirect::to("/home?oauth=error").into_response();
}
};
let redirect_uri =
match state
.platform
.credentials
.get_project_credential(&owner, &project, &credential_id)
{
Ok(Some(cred)) => cred
.secret
.get("redirect_uri")
.and_then(serde_json::Value::as_str)
.filter(|v| !v.trim().is_empty())
.map(|v| v.to_string())
.unwrap_or_else(|| {
let base = platform_base_url(&headers);
format!("{base}/oauth/callback")
}),
_ => {
let base = platform_base_url(&headers);
format!("{base}/oauth/callback")
}
};
match state
.platform
.credentials
.exchange_oauth2_code(&owner, &project, &credential_id, code, &redirect_uri)
.await
{
Ok(()) => {
let target = format!("/projects/{owner}/{project}/credentials?oauth=success");
axum::response::Redirect::to(&target).into_response()
}
Err(_err) => {
let target = format!("/projects/{owner}/{project}/credentials?oauth=error");
axum::response::Redirect::to(&target).into_response()
}
}
}
fn sign_oauth_state(secret: &[u8; 32], owner: &str, project: &str, credential_id: &str) -> String {
use base64::Engine as _;
use hmac::{Hmac, Mac};
use sha2::Sha256;
let now = crate::platform::model::now_ts();
let payload = serde_json::json!({"o": owner, "p": project, "c": credential_id, "t": now});
let payload_b64 =
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload.to_string().as_bytes());
type HmacSha256 = Hmac<Sha256>;
let mut mac = HmacSha256::new_from_slice(secret).expect("hmac key size");
mac.update(payload_b64.as_bytes());
let sig = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
format!("{payload_b64}.{sig}")
}
fn verify_oauth_state(secret: &[u8; 32], state: &str) -> Result<(String, String, String), String> {
use base64::Engine as _;
use hmac::{Hmac, Mac};
use sha2::Sha256;
let parts: Vec<&str> = state.splitn(2, '.').collect();
if parts.len() != 2 {
return Err("invalid state format".to_string());
}
let (payload_b64, sig_b64) = (parts[0], parts[1]);
type HmacSha256 = Hmac<Sha256>;
let mut mac = HmacSha256::new_from_slice(secret).expect("hmac key size");
mac.update(payload_b64.as_bytes());
let sig_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(sig_b64)
.map_err(|e| format!("bad sig encoding: {e}"))?;
mac.verify_slice(&sig_bytes)
.map_err(|_| "invalid signature".to_string())?;
let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload_b64)
.map_err(|e| format!("bad payload encoding: {e}"))?;
let payload: serde_json::Value =
serde_json::from_slice(&payload_bytes).map_err(|e| format!("bad payload json: {e}"))?;
let owner = payload
.get("o")
.and_then(serde_json::Value::as_str)
.ok_or("missing owner")?
.to_string();
let project = payload
.get("p")
.and_then(serde_json::Value::as_str)
.ok_or("missing project")?
.to_string();
let credential_id = payload
.get("c")
.and_then(serde_json::Value::as_str)
.ok_or("missing credential_id")?
.to_string();
let timestamp = payload
.get("t")
.and_then(serde_json::Value::as_i64)
.ok_or("missing timestamp")?;
let now = crate::platform::model::now_ts();
if (now - timestamp).abs() > 600 {
return Err("state expired".to_string());
}
Ok((owner, project, credential_id))
}
async fn api_get_project_assistant_config(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::SettingsRead,
) {
return response;
}
match state
.platform
.assistant_configs
.get_project_assistant_config(&owner, &project)
{
Ok(config) => Json(json!({"ok": true, "config": config})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_upsert_project_assistant_config(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
Json(req): Json<UpsertProjectAssistantConfigRequest>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::SettingsWrite,
) {
return response;
}
match state
.platform
.assistant_configs
.upsert_project_assistant_config(&owner, &project, &req)
{
Ok(config) => Json(json!({"ok": true, "config": config})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_get_settings_section(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, section)): Path<(String, String, String)>,
uri: Uri,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::SettingsRead,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::GET,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
let cfg = state.platform.zebflow_cfg.read_or_default(&owner, &project);
match section.as_str() {
"rwe" => {
Json(json!({"ok": true, "section": "rwe", "data": cfg.configs.rwe})).into_response()
}
"logging" => {
Json(json!({"ok": true, "section": "logging", "data": cfg.configs.pipelines.logging}))
.into_response()
}
"assets" => Json(json!({
"ok": true,
"section": "assets",
"data": {
"max_asset_size_mb": cfg.configs.files.uploads.effective_max_asset_size_mb(),
"max_file_size_mb": cfg.configs.files.uploads.effective_max_file_size_mb(),
"webhook_body_max_mb": cfg.configs.files.uploads.effective_webhook_body_max_mb(),
"pipeline_node_timeout_secs": cfg.configs.pipelines.effective_node_timeout_secs()
}
}))
.into_response(),
_ => (
StatusCode::NOT_FOUND,
Json(json!({"ok": false, "error": format!("unknown settings section '{section}'")})),
)
.into_response(),
}
}
async fn api_project_invocation_log_stats(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::SettingsRead,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::GET,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
match state
.platform
.data
.get_pipeline_invocation_log_stats(&owner, &project)
{
Ok(stats) => Json(json!({"ok": true, "stats": stats})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_clear_project_invocation_logs(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::SettingsWrite,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::DELETE,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
let pipeline = params
.get("pipeline")
.map(|value| value.trim())
.filter(|value| !value.is_empty());
match state
.platform
.data
.clear_pipeline_invocation_logs(&owner, &project, pipeline)
{
Ok(deleted) => Json(json!({"ok": true, "deleted": deleted})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_upsert_settings_section(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, section)): Path<(String, String, String)>,
uri: Uri,
Json(req): Json<UpdateSettingsSectionRequest>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::SettingsWrite,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_json_request_to_worker(
&state,
&uri,
&headers,
Method::PUT,
&req,
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
if req.commit_message.trim().is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": "commit_message must not be empty"})),
)
.into_response();
}
let mut cfg = state.platform.zebflow_cfg.read_or_default(&owner, &project);
let section_data = match section.as_str() {
"rwe" => {
#[derive(serde::Deserialize)]
struct RwePayload {
#[serde(default)]
allow_list: Vec<String>,
#[serde(default)]
minify_html: bool,
#[serde(default = "crate::platform::model::default_rwe_strict_mode")]
strict_mode: bool,
#[serde(default)]
deployment_asset_base: Option<String>,
}
let payload: RwePayload = match serde_json::from_value(req.data.clone()) {
Ok(p) => p,
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": e.to_string()})),
)
.into_response();
}
};
cfg.configs.rwe.allow_list = payload.allow_list;
cfg.configs.rwe.minify_html = payload.minify_html;
cfg.configs.rwe.strict_mode = payload.strict_mode;
cfg.configs.rwe.deployment_asset_base = payload.deployment_asset_base.and_then(|s| {
if s.trim().is_empty() {
None
} else {
Some(s.trim().to_string())
}
});
json!(cfg.configs.rwe)
}
"logging" => {
let max_inv: Option<u32> = req
.data
.get("max_invocations")
.and_then(|v| v.as_u64())
.map(|v| v.min(1000) as u32);
cfg.configs.pipelines.logging.max_invocations = max_inv;
json!(cfg.configs.pipelines.logging)
}
"assets" => {
let max_mb = req
.data
.get("max_asset_size_mb")
.and_then(|v| v.as_u64())
.map(|v| v.clamp(5, crate::platform::model::MAX_UPLOAD_SIZE_MB as u64) as u32)
.unwrap_or(crate::platform::model::ZebflowJsonUploads::default().max_asset_size_mb);
let max_file_size_mb = req
.data
.get("max_file_size_mb")
.and_then(|v| v.as_u64())
.map(|v| v.clamp(5, crate::platform::model::MAX_UPLOAD_SIZE_MB as u64) as u32)
.unwrap_or_else(|| cfg.configs.files.uploads.effective_max_file_size_mb());
let webhook_body_max_mb = req
.data
.get("webhook_body_max_mb")
.and_then(|v| v.as_u64())
.map(|v| v.clamp(100, crate::platform::model::MAX_UPLOAD_SIZE_MB as u64) as u32)
.unwrap_or(
crate::platform::model::ZebflowJsonUploads::default().webhook_body_max_mb,
);
let node_timeout_secs = req
.data
.get("pipeline_node_timeout_secs")
.and_then(|v| v.as_u64())
.map(|v| v.clamp(5, 3600))
.unwrap_or_else(crate::platform::model::default_pipeline_node_timeout_secs);
cfg.configs.files.uploads.max_asset_size_mb = max_mb;
cfg.configs.files.uploads.max_file_size_mb = Some(max_file_size_mb);
cfg.configs.files.uploads.webhook_body_max_mb = webhook_body_max_mb;
cfg.configs.pipelines.node_timeout_secs = Some(node_timeout_secs);
json!({
"max_asset_size_mb": cfg.configs.files.uploads.effective_max_asset_size_mb(),
"max_file_size_mb": cfg.configs.files.uploads.effective_max_file_size_mb(),
"webhook_body_max_mb": cfg.configs.files.uploads.effective_webhook_body_max_mb(),
"pipeline_node_timeout_secs": cfg.configs.pipelines.effective_node_timeout_secs()
})
}
_ => {
return (
StatusCode::NOT_FOUND,
Json(
json!({"ok": false, "error": format!("unknown settings section '{section}'")}),
),
)
.into_response();
}
};
if let Err(err) = state.platform.zebflow_cfg.write(&owner, &project, &cfg) {
return internal_error(err);
}
let (committed, git_error) = {
let owner_slug = crate::platform::model::slug_segment(&owner);
let project_slug = crate::platform::model::slug_segment(&project);
let actor_user = session_owner(&state, &headers);
let identity_args = git_identity_args(&state, actor_user.as_deref(), &project_slug);
match state
.platform
.file
.ensure_project_layout(&owner_slug, &project_slug)
{
Err(_) => (false, Some("could not resolve project layout".to_string())),
Ok(layout) => {
let add_ok = std::process::Command::new("git")
.arg("-C")
.arg(&layout.repo_dir)
.arg("add")
.arg("zebflow.json")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !add_ok {
(false, Some("git add failed".to_string()))
} else {
let mut commit_cmd = std::process::Command::new("git");
for arg in &identity_args {
commit_cmd.arg(arg);
}
commit_cmd
.arg("-C")
.arg(&layout.repo_dir)
.arg("commit")
.arg("-m")
.arg(req.commit_message.trim());
let commit_out = commit_cmd.output();
match commit_out {
Err(e) => (false, Some(e.to_string())),
Ok(o) => {
if o.status.success() {
(true, None)
} else {
let msg = String::from_utf8_lossy(&o.stderr).to_string();
if msg.contains("nothing to commit") {
(false, None)
} else {
(false, Some(msg.trim().to_string()))
}
}
}
}
}
}
}
};
let mut resp = json!({
"ok": true,
"section": section,
"data": section_data,
"committed": committed
});
if let Some(err) = git_error {
resp["git_error"] = json!(err);
}
Json(resp).into_response()
}
async fn api_list_rwe_libraries(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::LibrariesRead,
) {
return response;
}
let rwe_libs = state
.platform
.zebflow_cfg
.get_rwe_libraries(&owner, &project)
.unwrap_or_default();
let items = state
.platform
.library
.list()
.map(|m| {
let enabled_entry = rwe_libs.get(&m.name);
json!({
"name": m.name,
"description": m.description,
"packed_version": m.packed_version(),
"packed_kind": m.packed_kind(),
"enabled": enabled_entry.is_some(),
"installed_version": enabled_entry.map(|e| e.version.clone()),
"source": enabled_entry.map(|e| e.source.clone())
})
})
.collect::<Vec<_>>();
Json(items).into_response()
}
#[derive(serde::Deserialize)]
struct EnableRweLibraryRequest {
name: String,
version: String,
source: String,
}
async fn api_enable_rwe_library(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
Json(req): Json<EnableRweLibraryRequest>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::LibrariesInstall,
) {
return response;
}
if req.name.trim().is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": "library name must not be empty"})),
)
.into_response();
}
if state.platform.library.get(req.name.trim()).is_none() {
return (
StatusCode::NOT_FOUND,
Json(
json!({"ok": false, "error": format!("library '{}' is not registered", req.name)}),
),
)
.into_response();
}
let entry = state
.platform
.library
.get(req.name.trim())
.and_then(|m| m.version(req.version.trim()))
.map(|v| v.entry.clone())
.unwrap_or_default();
if let Err(err) = state.platform.zebflow_cfg.enable_rwe_library(
&owner,
&project,
req.name.trim(),
req.version.trim(),
req.source.trim(),
) {
return internal_error(err);
}
if let Err(err) = state.platform.zeb_lock.add_entry(
&owner,
&project,
req.name.trim(),
crate::platform::model::ZebLockEntry {
version: req.version.trim().to_string(),
source: req.source.trim().to_string(),
entry,
integrity: None,
},
) {
return internal_error(err);
}
let actor_user = session_owner(&state, &headers);
let _ = rwe_library_git_commit(
&state,
actor_user.as_deref(),
&owner,
&project,
&format!("chore(rwe): enable library {}", req.name.trim()),
);
Json(json!({"ok": true})).into_response()
}
#[derive(serde::Deserialize)]
struct DisableRweLibraryQuery {
name: String,
}
async fn api_disable_rwe_library(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
Query(params): Query<DisableRweLibraryQuery>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::LibrariesRemove,
) {
return response;
}
if params.name.trim().is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": "library name must not be empty"})),
)
.into_response();
}
if let Err(err) =
state
.platform
.zebflow_cfg
.disable_rwe_library(&owner, &project, params.name.trim())
{
return internal_error(err);
}
if let Err(err) = state
.platform
.zeb_lock
.remove_entry(&owner, &project, params.name.trim())
{
return internal_error(err);
}
let actor_user = session_owner(&state, &headers);
let _ = rwe_library_git_commit(
&state,
actor_user.as_deref(),
&owner,
&project,
&format!("chore(rwe): disable library {}", params.name.trim()),
);
Json(json!({"ok": true})).into_response()
}
async fn api_rwe_cache_clear(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::SettingsWrite,
) {
return response;
}
state
.template_cache
.write()
.unwrap_or_else(|e| e.into_inner())
.clear();
Json(json!({"ok": true, "cleared": true})).into_response()
}
fn rwe_library_git_commit(
state: &PlatformAppState,
actor_user: Option<&str>,
owner: &str,
project: &str,
message: &str,
) -> Result<(), ()> {
let owner_slug = crate::platform::model::slug_segment(owner);
let project_slug = crate::platform::model::slug_segment(project);
let layout = state
.platform
.file
.ensure_project_layout(&owner_slug, &project_slug)
.map_err(|_| ())?;
let add_ok = std::process::Command::new("git")
.arg("-C")
.arg(&layout.repo_dir)
.arg("add")
.arg("zebflow.json")
.arg("zeb.lock")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !add_ok {
return Err(());
}
let identity_args = git_identity_args(state, actor_user, &project_slug);
let mut cmd = std::process::Command::new("git");
for arg in &identity_args {
cmd.arg(arg);
}
cmd.arg("-C")
.arg(&layout.repo_dir)
.arg("commit")
.arg("-m")
.arg(message);
cmd.output().map(|_| ()).map_err(|_| ())
}
fn apply_rwe_project_options(
state: &PlatformAppState,
owner: &str,
project: &str,
graph: &mut PipelineGraph,
) {
let cfg = state.platform.zebflow_cfg.read_or_default(owner, project);
let rwe = &cfg.configs.rwe;
let template_root_str = state
.platform
.projects
.get_project_template_root(owner, project)
.ok()
.map(|p| p.display().to_string());
for node in &mut graph.nodes {
if node.kind != "n.web.response" {
continue;
}
let node_load_scripts: Vec<String> = node
.config
.get("load_scripts")
.and_then(|v| v.as_str())
.unwrap_or("")
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.collect();
let options = json!({
"minify_html": rwe.minify_html,
"strict_mode": rwe.strict_mode,
"allow_list": {
"urls": rwe.allow_list,
"scripts": [],
"css": []
},
"load_scripts": node_load_scripts,
"templates": {
"template_root": template_root_str
}
});
if let Some(map) = node.config.as_object_mut() {
map.insert("options".to_string(), options);
}
}
}
fn load_chat_history(
file: &Arc<dyn crate::platform::adapters::file::FileAdapter>,
owner: &str,
project: &str,
) -> Vec<Value> {
let layout = match file.ensure_project_layout(owner, project) {
Ok(l) => l,
Err(_) => return vec![],
};
let path = layout.data_runtime_dir.join("chat_history.json");
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => return vec![],
};
serde_json::from_str::<Vec<Value>>(&content).unwrap_or_default()
}
fn save_chat_history(
file: &Arc<dyn crate::platform::adapters::file::FileAdapter>,
owner: &str,
project: &str,
user_msg: &str,
assistant_msg: &str,
max_pairs: usize,
) {
let layout = match file.ensure_project_layout(owner, project) {
Ok(l) => l,
Err(_) => return,
};
let path = layout.data_runtime_dir.join("chat_history.json");
let mut history: Vec<Value> = std::fs::read_to_string(&path)
.ok()
.and_then(|c| serde_json::from_str(&c).ok())
.unwrap_or_default();
history.push(json!({"role": "user", "content": user_msg}));
history.push(json!({"role": "assistant", "content": assistant_msg}));
let keep = max_pairs * 2;
if history.len() > keep {
history.drain(0..history.len() - keep);
}
if let Ok(json) = serde_json::to_string(&history) {
std::fs::create_dir_all(&layout.data_runtime_dir).ok();
std::fs::write(&path, json).ok();
}
}
async fn api_project_assistant_chat(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
Json(req): Json<AssistantChatRequest>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::ProjectRead,
) {
return response;
}
let message = req.message.trim().to_string();
if message.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"ok": false,
"error": {
"code": "ASSISTANT_MESSAGE_INVALID",
"message": "message must not be empty"
}
})),
)
.into_response();
}
let bundle = match load_project_assistant_llm(
state.platform.data.as_ref(),
state.platform.assistant_configs.as_ref(),
&owner,
&project,
) {
Ok(bundle) => bundle,
Err(err) => {
let status = match err.code {
"ASSISTANT_NOT_CONFIGURED"
| "ASSISTANT_DISABLED"
| "ASSISTANT_NO_LLM"
| "ASSISTANT_CREDENTIAL_MISSING"
| "ASSISTANT_CREDENTIAL_INVALID" => StatusCode::BAD_REQUEST,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
return (
status,
Json(json!({
"ok": false,
"error": { "code": err.code, "message": err.message }
})),
)
.into_response();
}
};
let tools = crate::platform::services::AssistantPlatformTools::new(
state.platform.clone(),
&owner,
&project,
);
let tool_defs = crate::platform::services::AssistantPlatformTools::tool_defs();
let mut messages: Vec<Value> = Vec::new();
{
let skills_text = crate::platform::help::format_for_system_prompt();
let page_context = req
.current_page
.as_deref()
.filter(|p| !p.is_empty())
.map(|p| format!("\nCurrently viewing: {p}"))
.unwrap_or_default();
let time_context = req
.client_time
.as_deref()
.filter(|t| !t.is_empty())
.map(|t| format!("\nUser local time: {t}"))
.unwrap_or_default();
let nav_map = project_nav_map(&owner, &project);
state
.platform
.projects
.ensure_agent_docs_defaults(&owner, &project)
.ok();
let memory = state
.platform
.projects
.read_agent_doc(&owner, &project, "MEMORY.md")
.unwrap_or_default();
let soul = state
.platform
.projects
.read_agent_doc(&owner, &project, "SOUL.md")
.unwrap_or_default();
let agents = state
.platform
.projects
.read_agent_doc(&owner, &project, "AGENTS.md")
.unwrap_or_default();
let readme = state
.platform
.projects
.read_project_doc(&owner, &project, "README.md")
.unwrap_or_default();
let readme_section = if readme.trim().is_empty() {
String::new()
} else {
format!("\n\n## Project README\n{readme}")
};
let system = format!(
"You are the Zebflow project operator — an autonomous assistant for project {owner}/{project}.{page_context}{time_context}\n\n\
## Your Memory\n{memory}\n\n\
## Your Soul\n{soul}\n\n\
## Project Context\n{agents}{readme_section}\n\n\
## Your Tools\n\
You have 33 native tools covering every project management operation:\n\
- **Orientation**: `start_here` — call at the start of every session for a live project overview\n\
- **Pipelines**: `pipeline_list`, `pipeline_get`, `pipeline_register`, `pipeline_describe`, `pipeline_patch`, `pipeline_activate`, `pipeline_deactivate`, `pipeline_execute`, `pipeline_run`\n\
- **Templates**: `template_list`, `template_get`, `template_create`, `template_write`\n\
- **Docs**: `docs_project_list`, `docs_project_read`, `docs_project_write`, `docs_agent_list`, `docs_agent_read`, `docs_agent_write`\n\
- **Database**: `connection_list`, `connection_describe` — then use `pipeline_run` with `pg.query` or `n.sqlite.query` nodes to execute queries\n\
- **Credentials**: `credential_list`\n\
- **Git**: `git_command` — subcommands: status, log, diff, add, commit\n\
- **UI Components**: `list_ui_catalog`, `install_ui_components`\n\
- **Knowledge**: `help_pipeline`, `help_web_engine`, `help_examples`, `help_nodes`, `help_search`, `skill_list`, `skill_read`\n\n\
## Workflow\n\
1. Call `start_here` to orient yourself when starting a new task\n\
2. Use `connection_describe` before writing any SQL queries\n\
3. Use `pipeline_run` with pipe-chained nodes to execute one-off queries or test pipelines\n\
4. After registering or patching pipelines, always call `pipeline_activate` to make them live\n\
5. Always commit changes with `git_command subcommand=add` then `git_command subcommand=commit`\n\n\
Available pages:\n\
{nav_map}\n\n\
## Zebflow Knowledge\n\n{skills_text}"
);
messages.push(json!({"role": "system", "content": system}));
}
let server_history = load_chat_history(&state.platform.file, &owner, &project);
let history_source: Box<dyn Iterator<Item = Value>> = if server_history.is_empty() {
Box::new(
req.history
.into_iter()
.filter(|item| !item.content.trim().is_empty())
.filter_map(|item| match item.role.as_str() {
"user" | "assistant" => {
Some(json!({"role": item.role, "content": item.content.trim()}))
}
_ => None,
})
.take(20),
)
} else {
Box::new(server_history.into_iter().take(20))
};
for item in history_source {
messages.push(item);
}
messages.push(json!({"role": "user", "content": message}));
let llm = if req.use_high_model {
bundle.high.clone()
} else {
bundle.general.clone()
};
let max_steps = bundle.max_steps;
let chat_history_pairs_for_save = bundle.chat_history_pairs;
let model_tier = if req.use_high_model {
"high"
} else {
"general"
};
let (step_tx, mut step_rx) = tokio::sync::mpsc::unbounded_channel::<AssistantStepEvent>();
let loop_task = tokio::spawn(async move {
run_assistant_loop(llm, &tools, tool_defs, messages, max_steps, &step_tx).await
});
let owner_clone = owner.clone();
let project_clone = project.clone();
let file_for_history = state.platform.file.clone();
let message_for_history = message.clone();
let sse_stream = async_stream::stream! {
yield Ok::<Event, Infallible>(
Event::default().event("start").data(
json!({
"ok": true,
"owner": owner_clone,
"project": project_clone,
"model_tier": model_tier,
"max_steps": max_steps,
})
.to_string(),
),
);
let mut budget_exhausted = false;
let mut steps_taken: u32 = 0;
let final_content;
loop {
match step_rx.recv().await {
Some(AssistantStepEvent::ToolCall { step, tool, args, thought }) => {
steps_taken = step;
yield Ok(Event::default().event("tool_call").data(
json!({
"step": step,
"tool": tool,
"args": args,
"thought": thought,
}).to_string()
));
}
Some(AssistantStepEvent::ToolResult { step, tool, result_preview }) => {
steps_taken = step;
yield Ok(Event::default().event("tool_result").data(
json!({
"step": step,
"tool": tool,
"result_preview": result_preview,
}).to_string()
));
}
Some(AssistantStepEvent::Navigate { url, label }) => {
yield Ok(Event::default().event("navigate").data(
json!({ "url": url, "label": label }).to_string()
));
}
Some(AssistantStepEvent::InteractionSequence { id, label, steps }) => {
yield Ok(Event::default().event("interaction_sequence").data(
json!({ "id": id, "label": label, "steps": steps }).to_string()
));
}
Some(AssistantStepEvent::BudgetExhausted) => {
budget_exhausted = true;
}
Some(AssistantStepEvent::Done(content)) => {
final_content = content;
break;
}
None => {
final_content = String::new();
break;
}
}
}
let _ = loop_task.await;
if !final_content.is_empty() {
save_chat_history(
&file_for_history,
&owner_clone,
&project_clone,
&message_for_history,
&final_content,
chat_history_pairs_for_save as usize,
);
}
let content_html = crate::rwe::processors::markdown::render_markdown_fragment(&final_content);
yield Ok(Event::default().event("message").data(
json!({"role":"assistant","content": final_content, "content_html": content_html}).to_string()
));
yield Ok(Event::default().event("done").data(
json!({"ok": true, "steps_taken": steps_taken, "budget_exhausted": budget_exhausted}).to_string()
));
};
Sse::new(sse_stream)
.keep_alive(
KeepAlive::new()
.interval(Duration::from_secs(15))
.text("keep-alive"),
)
.into_response()
}
enum AssistantStepEvent {
ToolCall {
step: u32,
tool: String,
args: Value,
thought: String,
},
ToolResult {
step: u32,
tool: String,
result_preview: String,
},
Navigate {
url: String,
label: String,
},
InteractionSequence {
id: String,
label: String,
steps: Value,
},
BudgetExhausted,
Done(String),
}
async fn run_assistant_loop(
llm: std::sync::Arc<dyn crate::automaton::infra::llm_interface::LlmCall>,
tools: &crate::platform::services::AssistantPlatformTools,
tool_defs: Vec<crate::automaton::infra::llm_interface::ToolDef>,
mut messages: Vec<Value>,
max_steps: u32,
step_tx: &tokio::sync::mpsc::UnboundedSender<AssistantStepEvent>,
) -> String {
use crate::automaton::infra::llm_interface::CallResult;
for step in 1..=max_steps {
let (result, _usage) = match llm.call_with_tools(messages.clone(), &tool_defs).await {
Ok(r) => r,
Err(err) => {
let content = format!("(LLM error: {err})");
let _ = step_tx.send(AssistantStepEvent::Done(content.clone()));
return content;
}
};
match result {
CallResult::Text(content) => {
let _ = step_tx.send(AssistantStepEvent::Done(content.clone()));
return content;
}
CallResult::ToolCalls(calls) => {
let tool_calls_json: Vec<Value> = calls
.iter()
.map(|tc| {
json!({
"id": tc.id,
"type": "function",
"function": { "name": tc.name, "arguments": tc.arguments }
})
})
.collect();
messages.push(json!({
"role": "assistant",
"content": null,
"tool_calls": tool_calls_json
}));
for tc in &calls {
let args: Value = serde_json::from_str(&tc.arguments).unwrap_or(json!({}));
let _ = step_tx.send(AssistantStepEvent::ToolCall {
step,
tool: tc.name.clone(),
args: args.clone(),
thought: String::new(),
});
let tool_result = tools.run_async(&tc.name, &args).await;
let result_str = tool_result.text.clone();
if let Some(seq) = tool_result.interaction {
let id = seq["id"].as_str().unwrap_or(&tc.id).to_string();
let label = seq["label"].as_str().unwrap_or(&tc.name).to_string();
let steps = seq["steps"].clone();
let _ = step_tx.send(AssistantStepEvent::InteractionSequence {
id,
label,
steps,
});
} else if let Some(url) = tool_result.navigate {
let _ = step_tx.send(AssistantStepEvent::Navigate {
label: url.clone(),
url,
});
}
let result_preview = result_str.chars().take(500).collect::<String>();
let _ = step_tx.send(AssistantStepEvent::ToolResult {
step,
tool: tc.name.clone(),
result_preview,
});
messages.push(json!({
"role": "tool",
"tool_call_id": tc.id,
"content": result_str
}));
}
}
}
}
let _ = step_tx.send(AssistantStepEvent::BudgetExhausted);
let content = "(max steps reached)".to_string();
let _ = step_tx.send(AssistantStepEvent::Done(content.clone()));
content
}
async fn api_list_db_connections(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TablesRead,
) {
return response;
}
match state
.platform
.db_connections
.list_project_connections(&owner, &project)
{
Ok(items) => {
let mut rows = vec![json!({
"connection_id": "builtin:mapserver:default-mapserver",
"connection_slug": "default-mapserver",
"connection_label": "Default Mapserver",
"database_kind": "mapserver",
"credential_id": "",
"config_json": "{}",
"created_at": 0,
"updated_at": 0,
"path": format!("/projects/{owner}/{project}/db/mapserver/default-mapserver/layers"),
"builtin": true
})];
rows.extend(
items
.into_iter()
.filter_map(|item| serde_json::to_value(item).ok()),
);
Json(json!({"ok": true, "items": rows})).into_response()
}
Err(err) => internal_error(err),
}
}
#[derive(Debug, Deserialize)]
struct PublishMarketplaceAssetRequest {
source_type: String,
source_ref: String,
package_id: String,
version: String,
#[serde(default)]
publisher_token: String,
#[serde(default)]
title: String,
#[serde(default)]
description: String,
#[serde(default)]
visibility: String,
#[serde(default)]
tags: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct MarketplacePublishQuery {
#[serde(default)]
source_type: String,
#[serde(default)]
source_ref: String,
}
#[derive(Debug, Deserialize)]
struct InstallMarketplaceAssetRequest {
#[serde(default)]
install_mode: String,
}
fn normalize_marketplace_install_mode(raw: &str) -> &'static str {
match raw.trim() {
"clone_as_folder" => "clone_as_folder",
_ => "add_to_current_project",
}
}
#[derive(Debug, Deserialize)]
struct UpsertMarketplaceRepositoryRequest {
repository_id: String,
title: String,
base_url: String,
remote_owner: String,
remote_project: String,
#[serde(default)]
read_token: String,
#[serde(default = "default_public_visibility")]
visibility: String,
#[serde(default = "default_true")]
enabled: bool,
}
fn default_public_visibility() -> String {
"public".to_string()
}
#[derive(Debug, Deserialize)]
struct CreatePlatformMarketplaceTokenRequest {
#[serde(default)]
owner: String,
#[serde(default)]
project: String,
publisher_id: String,
title: String,
#[serde(default)]
scopes: Vec<String>,
#[serde(default)]
expires_at: Option<i64>,
}
#[derive(Debug, Deserialize)]
struct UpsertMarketplacePublisherRequest {
publisher_id: String,
#[serde(default)]
display_name: String,
#[serde(default)]
publisher_url: String,
#[serde(default)]
email: String,
#[serde(default)]
description: String,
#[serde(default)]
icon_url: String,
#[serde(default)]
website_url: String,
#[serde(default = "default_true")]
enabled: bool,
#[serde(default = "default_true")]
can_read: bool,
#[serde(default = "default_true")]
can_publish: bool,
#[serde(default)]
can_manage: bool,
#[serde(default)]
max_packages: i64,
#[serde(default)]
max_package_bytes: i64,
#[serde(default)]
max_media_files: i64,
#[serde(default)]
max_image_bytes: i64,
}
#[derive(Debug, Deserialize)]
struct InstallPlatformMarketplaceProjectRequest {
repository_id: String,
package_id: String,
version: String,
}
#[derive(Debug, Deserialize)]
struct ConfigurePlatformMarketplaceServiceRequest {
host_office_id: String,
#[serde(default)]
public_base_url: String,
password: String,
enabled: bool,
}
#[derive(Debug, Deserialize)]
struct SetMarketplaceProducerRequest {
project_name: String,
password: String,
enabled: bool,
}
fn project_marketplace_repository_json(
item: crate::platform::model::ProjectMarketplaceRepository,
) -> Value {
json!({
"owner": item.owner,
"project": item.project,
"repository_id": item.repository_id,
"title": item.title,
"base_url": item.base_url,
"remote_owner": item.remote_owner,
"remote_project": item.remote_project,
"read_token": "",
"has_read_token": !item.read_token.trim().is_empty(),
"enabled": item.enabled,
"created_at": item.created_at,
"updated_at": item.updated_at,
})
}
fn platform_marketplace_repository_json(
item: crate::platform::model::PlatformMarketplaceRepository,
) -> Value {
json!({
"owner": item.owner,
"repository_id": item.repository_id,
"title": item.title,
"base_url": item.base_url,
"remote_owner": item.remote_owner,
"remote_project": item.remote_project,
"read_token": "",
"has_read_token": !item.read_token.trim().is_empty(),
"visibility": item.visibility,
"enabled": item.enabled,
"created_at": item.created_at,
"updated_at": item.updated_at,
})
}
fn marketplace_api_error(err: PlatformError) -> Response {
let status = if err.code == "FW_EGRESS_DENIED"
|| err.code == "FW_EGRESS_URL_INVALID"
|| err.code == "FW_EGRESS_DNS"
|| err.code == "MARKETPLACE_REPOSITORY_INVALID"
{
StatusCode::BAD_REQUEST
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
(
status,
Json(json!({"ok": false, "error": {"code": err.code, "message": err.message}})),
)
.into_response()
}
fn marketplace_token_json(item: crate::platform::model::MarketplaceToken) -> Value {
json!({
"token_id": item.token_id,
"owner": item.owner,
"project": item.project,
"publisher_id": item.publisher_id,
"publisher_display_name": item.publisher_display_name,
"publisher_url": item.publisher_url,
"publisher_email": item.publisher_email,
"title": item.title,
"scopes": item.scopes,
"expires_at": item.expires_at,
"last_used_at": item.last_used_at,
"revoked_at": item.revoked_at,
"created_at": item.created_at,
"updated_at": item.updated_at,
})
}
fn marketplace_publisher_json(item: crate::platform::model::MarketplacePublisher) -> Value {
json!({
"owner": item.owner,
"project": item.project,
"publisher_id": item.publisher_id,
"display_name": item.display_name,
"publisher_url": item.publisher_url,
"email": item.email,
"description": item.description,
"icon_url": item.icon_url,
"website_url": item.website_url,
"enabled": item.enabled,
"can_read": item.can_read,
"can_publish": item.can_publish,
"can_manage": item.can_manage,
"max_packages": item.max_packages,
"max_package_bytes": item.max_package_bytes,
"max_media_files": item.max_media_files,
"max_image_bytes": item.max_image_bytes,
"created_at": item.created_at,
"updated_at": item.updated_at,
})
}
fn default_true() -> bool {
true
}
async fn api_list_marketplace_assets(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesRead,
) {
return response;
}
let mut items = match marketplace_asset_rows(&state, &owner, &project, false) {
Ok(items) => items,
Err(err) => return internal_error(err),
};
match state
.platform
.marketplace
.fetch_remote_pack_rows(&state.http_client, &owner, &project)
.await
{
Ok(remote_rows) => {
items.extend(
remote_rows
.into_iter()
.filter_map(|item| serde_json::to_value(item).ok()),
);
Json(json!({"ok": true, "items": items})).into_response()
}
Err(err) => internal_error(err),
}
}
async fn api_get_project_help(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesRead,
) {
return response;
}
let ops =
crate::platform::services::ops::PlatformOps::new(state.platform.clone(), &owner, &project);
Json(json!({
"ok": true,
"sections": ops.help_dialog_sections(),
}))
.into_response()
}
async fn api_list_remote_marketplace_assets(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
) -> Response {
if let Err(response) = require_marketplace_service_enabled(&state) {
return response;
}
if let Err(response) = require_project_marketplace_producer(&state, &owner, &project) {
return response;
}
let token = bearer_token_from_headers(&headers);
let requester = if let Some(token_value) = token {
match state
.platform
.marketplace
.authenticate_token(&token_value, "marketplace:read")
{
Ok(token) => {
if token.owner != owner || token.project != project {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"ok": false, "error": "token does not belong to this marketplace", "code": "MARKETPLACE_TOKEN_FORBIDDEN"})),
)
.into_response();
}
Some(token.owner)
}
Err(err) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"ok": false, "error": err.message, "code": err.code})),
)
.into_response();
}
}
} else {
None
};
match state.platform.marketplace.list_asset_packages() {
Ok(packages) => {
let mut items = Vec::new();
for package in packages {
if package.visibility == "private"
&& requester.as_deref() != Some(package.publisher_owner.as_str())
{
continue;
}
let latest_version = state
.platform
.marketplace
.list_asset_versions(&package.package_id)
.ok()
.and_then(|items| items.into_iter().next().map(|v| v.version))
.unwrap_or_default();
let mut item = public_marketplace_asset_item_json(&state, package);
if let Some(object) = item.as_object_mut() {
object.insert("latest_version".to_string(), json!(latest_version));
}
items.push(item);
}
Json(json!({"ok": true, "items": items})).into_response()
}
Err(err) => internal_error(err),
}
}
async fn api_get_remote_marketplace_asset(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, package_id, version)): Path<(String, String, String, String)>,
) -> Response {
if let Err(response) = require_marketplace_service_enabled(&state) {
return response;
}
if let Err(response) = require_project_marketplace_producer(&state, &owner, &project) {
return response;
}
let token = bearer_token_from_headers(&headers);
let requester = if let Some(token_value) = token {
match state
.platform
.marketplace
.authenticate_token(&token_value, "marketplace:read")
{
Ok(token) => {
if token.owner != owner || token.project != project {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"ok": false, "error": "token does not belong to this marketplace", "code": "MARKETPLACE_TOKEN_FORBIDDEN"})),
)
.into_response();
}
Some(token.owner)
}
Err(err) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"ok": false, "error": err.message, "code": err.code})),
)
.into_response();
}
}
} else {
None
};
let Some(package) = state
.platform
.marketplace
.list_asset_packages()
.ok()
.and_then(|items| items.into_iter().find(|item| item.package_id == package_id))
else {
return (
StatusCode::NOT_FOUND,
Json(json!({"ok": false, "error": "package not found"})),
)
.into_response();
};
if package.visibility == "private"
&& requester.as_deref() != Some(package.publisher_owner.as_str())
{
return (
StatusCode::UNAUTHORIZED,
Json(json!({"ok": false, "error": "private package"})),
)
.into_response();
}
match state
.platform
.marketplace
.get_asset_version_artifact(&package_id, &version)
{
Ok((version_row, artifact)) => Json(json!({
"ok": true,
"version": public_marketplace_version_json(&package, &version_row),
"artifact": public_marketplace_artifact_json(artifact),
}))
.into_response(),
Err(err) => internal_error(err),
}
}
async fn api_get_remote_marketplace_artifact(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, package_id, version)): Path<(String, String, String, String)>,
) -> Response {
if let Err(response) = require_marketplace_service_enabled(&state) {
return response;
}
if let Err(response) = require_project_marketplace_producer(&state, &owner, &project) {
return response;
}
let token = bearer_token_from_headers(&headers);
let requester = if let Some(token_value) = token {
match state
.platform
.marketplace
.authenticate_token(&token_value, "marketplace:read")
{
Ok(token) => {
if token.owner != owner || token.project != project {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"ok": false, "error": "token does not belong to this marketplace", "code": "MARKETPLACE_TOKEN_FORBIDDEN"})),
)
.into_response();
}
Some(token.owner)
}
Err(err) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"ok": false, "error": err.message, "code": err.code})),
)
.into_response();
}
}
} else {
None
};
let Some(package) = state
.platform
.marketplace
.list_asset_packages()
.ok()
.and_then(|items| items.into_iter().find(|item| item.package_id == package_id))
else {
return (
StatusCode::NOT_FOUND,
Json(json!({"ok": false, "error": "package not found"})),
)
.into_response();
};
if package.visibility == "private"
&& requester.as_deref() != Some(package.publisher_owner.as_str())
{
return (
StatusCode::UNAUTHORIZED,
Json(json!({"ok": false, "error": "private package"})),
)
.into_response();
}
match state
.platform
.marketplace
.get_asset_version_install_artifact(&package_id, &version)
{
Ok((version_row, artifact, artifact_size_bytes)) => {
Json(raw_marketplace_artifact_response_json(
&package,
&version_row,
artifact,
artifact_size_bytes,
))
.into_response()
}
Err(err) => internal_error(err),
}
}
async fn api_list_public_marketplace_assets(State(state): State<PlatformAppState>) -> Response {
if let Err(response) = require_marketplace_service_enabled(&state) {
return response;
}
match state.platform.marketplace.list_asset_packages() {
Ok(packages) => {
let items = packages
.into_iter()
.filter(|package| package.visibility == "public")
.map(|package| public_marketplace_asset_item_json(&state, package))
.collect::<Vec<_>>();
Json(json!({"ok": true, "items": items})).into_response()
}
Err(err) => internal_error(err),
}
}
async fn api_get_public_marketplace_asset(
State(state): State<PlatformAppState>,
Path((package_id, version)): Path<(String, String)>,
) -> Response {
if let Err(response) = require_marketplace_service_enabled(&state) {
return response;
}
let Some(package) = state
.platform
.marketplace
.list_asset_packages()
.ok()
.and_then(|items| items.into_iter().find(|item| item.package_id == package_id))
else {
return (
StatusCode::NOT_FOUND,
Json(json!({"ok": false, "error": "package not found"})),
)
.into_response();
};
if package.visibility == "private" {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"ok": false, "error": "private package"})),
)
.into_response();
}
match state
.platform
.marketplace
.get_asset_version_artifact(&package_id, &version)
{
Ok((version_row, artifact)) => Json(json!({
"ok": true,
"version": public_marketplace_version_json(&package, &version_row),
"artifact": public_marketplace_artifact_json(artifact),
}))
.into_response(),
Err(err) => internal_error(err),
}
}
async fn api_get_public_marketplace_artifact(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((package_id, version)): Path<(String, String)>,
) -> Response {
if let Err(response) = require_marketplace_service_enabled(&state) {
return response;
}
let token = bearer_token_from_headers(&headers);
let requester = if let Some(token_value) = token {
match state
.platform
.marketplace
.authenticate_token(&token_value, "marketplace:read")
{
Ok(token) => Some(token.owner),
Err(err) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"ok": false, "error": err.message, "code": err.code})),
)
.into_response();
}
}
} else {
None
};
let Some(package) = state
.platform
.marketplace
.list_asset_packages()
.ok()
.and_then(|items| items.into_iter().find(|item| item.package_id == package_id))
else {
return (
StatusCode::NOT_FOUND,
Json(json!({"ok": false, "error": "package not found"})),
)
.into_response();
};
if package.visibility == "private"
&& requester.as_deref() != Some(package.publisher_owner.as_str())
{
return (
StatusCode::UNAUTHORIZED,
Json(json!({"ok": false, "error": "private package"})),
)
.into_response();
}
match state
.platform
.marketplace
.get_asset_version_install_artifact(&package_id, &version)
{
Ok((version_row, artifact, artifact_size_bytes)) => {
Json(raw_marketplace_artifact_response_json(
&package,
&version_row,
artifact,
artifact_size_bytes,
))
.into_response()
}
Err(err) => internal_error(err),
}
}
async fn api_list_my_marketplace_assets(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesRead,
) {
return response;
}
match marketplace_asset_rows(&state, &owner, &project, true) {
Ok(items) => Json(json!({"ok": true, "items": items})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_list_marketplace_publish_sources(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Query(query): Query<MarketplacePublishQuery>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesRead,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::GET,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
match marketplace_publish_sources(
&state,
&owner,
&project,
if query.source_type.trim().is_empty() {
"pipeline_with_dependencies"
} else {
&query.source_type
},
) {
Ok(items) => Json(json!({"ok": true, "items": items})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_preview_marketplace_publish_source(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Query(query): Query<MarketplacePublishQuery>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesRead,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::GET,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
match state.platform.marketplace.preview_publish_source(
&owner,
&project,
&query.source_type,
&query.source_ref,
) {
Ok(preview) => Json(json!({"ok": true, "preview": preview})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_publish_marketplace_asset(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
Json(req): Json<PublishMarketplaceAssetRequest>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesWrite,
) {
return response;
}
let token_value = req.publisher_token.trim().to_string();
let token_value = if token_value.is_empty() {
bearer_token_from_headers(&headers).unwrap_or_default()
} else {
token_value
};
if token_value.is_empty() {
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"ok": false,
"error": "publisher token is required",
"code": "MARKETPLACE_TOKEN_REQUIRED"
})),
)
.into_response();
}
let token = match state
.platform
.marketplace
.authenticate_token(&token_value, "marketplace:publish")
{
Ok(token) => token,
Err(err) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"ok": false, "error": err.message, "code": err.code})),
)
.into_response();
}
};
if token.owner != owner || token.project != project {
return (
StatusCode::FORBIDDEN,
Json(json!({
"ok": false,
"error": "publisher token does not belong to this project",
"code": "MARKETPLACE_TOKEN_FORBIDDEN"
})),
)
.into_response();
}
match state.platform.marketplace.publish_asset(
&owner,
&project,
&token.owner,
&token.publisher_id,
&token.publisher_display_name,
&token.publisher_url,
&token.publisher_email,
&owner,
&project,
&req.source_type,
&req.source_ref,
&req.package_id,
&req.version,
&req.title,
&req.description,
&req.visibility,
req.tags,
) {
Ok((package, version)) => {
Json(json!({"ok": true, "package": package, "version": version})).into_response()
}
Err(err) => internal_error(err),
}
}
async fn api_install_marketplace_asset(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, package_id, version)): Path<(String, String, String, String)>,
body: Option<Json<InstallMarketplaceAssetRequest>>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesWrite,
) {
return response;
}
let install_mode = normalize_marketplace_install_mode(
body.as_ref()
.map(|Json(req)| req.install_mode.as_str())
.unwrap_or_default(),
);
match state
.platform
.marketplace
.install_asset(&owner, &project, &package_id, &version)
{
Ok(result) => Json(json!({"ok": true, "install_mode": install_mode, "result": result}))
.into_response(),
Err(err) => internal_error(err),
}
}
async fn api_remote_publish_marketplace_asset(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
Json(req): Json<RemoteMarketplacePublishRequest>,
) -> Response {
let Some(token_value) = bearer_token_from_headers(&headers) else {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"ok": false, "error": "missing bearer token"})),
)
.into_response();
};
let token = match state
.platform
.marketplace
.authenticate_token(&token_value, "marketplace:publish")
{
Ok(token) => token,
Err(err) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"ok": false, "error": err.message, "code": err.code})),
)
.into_response();
}
};
if token.owner != owner || token.project != project {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"ok": false, "error": "token does not belong to this marketplace", "code": "MARKETPLACE_TOKEN_FORBIDDEN"})),
)
.into_response();
}
match state
.platform
.marketplace
.import_remote_asset(&owner, &project, &token, &req)
{
Ok((package, version)) => {
Json(json!({"ok": true, "package": package, "version": version})).into_response()
}
Err(err) => internal_error(err),
}
}
async fn api_list_marketplace_tokens(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesWrite,
) {
return response;
}
if let Err(response) = require_project_marketplace_producer(&state, &owner, &project) {
return response;
}
match marketplace_token_rows(&state, &owner, &project) {
Ok(items) => Json(json!({"ok": true, "items": items})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_create_marketplace_token(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
Json(req): Json<CreateMarketplaceTokenRequest>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesWrite,
) {
return response;
}
if let Err(response) = require_project_marketplace_producer(&state, &owner, &project) {
return response;
}
match state
.platform
.marketplace
.create_token(&owner, &project, &req)
{
Ok((token, token_value)) => Json(
json!({"ok": true, "token": marketplace_token_json(token), "token_value": token_value}),
)
.into_response(),
Err(err) => internal_error(err),
}
}
async fn api_delete_marketplace_token(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, token_id)): Path<(String, String, String)>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesWrite,
) {
return response;
}
if let Err(response) = require_project_marketplace_producer(&state, &owner, &project) {
return response;
}
match state
.platform
.marketplace
.revoke_token(&owner, &project, &token_id)
{
Ok(()) => Json(json!({"ok": true})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_list_marketplace_publishers(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesWrite,
) {
return response;
}
if let Err(response) = require_project_marketplace_producer(&state, &owner, &project) {
return response;
}
match state.platform.marketplace.list_publishers(&owner, &project) {
Ok(items) => Json(json!({
"ok": true,
"items": items.into_iter().map(marketplace_publisher_json).collect::<Vec<_>>()
}))
.into_response(),
Err(err) => internal_error(err),
}
}
async fn api_upsert_marketplace_publisher(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
Json(req): Json<UpsertMarketplacePublisherRequest>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesWrite,
) {
return response;
}
if let Err(response) = require_project_marketplace_producer(&state, &owner, &project) {
return response;
}
match state.platform.marketplace.upsert_publisher(
&owner,
&project,
&req.publisher_id,
&req.display_name,
&req.publisher_url,
&req.email,
&req.description,
&req.icon_url,
&req.website_url,
req.enabled,
req.can_read,
req.can_publish,
req.can_manage,
req.max_packages,
req.max_package_bytes,
req.max_media_files,
req.max_image_bytes,
) {
Ok(item) => {
Json(json!({"ok": true, "publisher": marketplace_publisher_json(item)})).into_response()
}
Err(err) => internal_error(err),
}
}
async fn api_delete_marketplace_publisher(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, publisher_id)): Path<(String, String, String)>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesWrite,
) {
return response;
}
if let Err(response) = require_project_marketplace_producer(&state, &owner, &project) {
return response;
}
match state
.platform
.marketplace
.delete_publisher(&owner, &project, &publisher_id)
{
Ok(()) => Json(json!({"ok": true})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_set_marketplace_producer_mode(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
Json(req): Json<SetMarketplaceProducerRequest>,
) -> Response {
let Some(session_owner) = session_owner(&state, &headers) else {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"ok": false, "error": "Not authenticated"})),
)
.into_response();
};
let owner_slug = crate::platform::model::slug_segment(&owner);
let project_slug = crate::platform::model::slug_segment(&project);
if crate::platform::model::slug_segment(&session_owner) != owner_slug {
return (
StatusCode::FORBIDDEN,
Json(json!({"ok": false, "error": "Forbidden"})),
)
.into_response();
}
if !can_manage_project_marketplace_producer(&state, &owner_slug) {
return (
StatusCode::FORBIDDEN,
Json(json!({"ok": false, "error": "Only curated superadmin projects can host a marketplace producer"})),
)
.into_response();
}
if crate::platform::model::slug_segment(&req.project_name) != project_slug
|| project_slug.is_empty()
{
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": "Project name does not match"})),
)
.into_response();
}
match state
.platform
.users
.authenticate(&owner_slug, &req.password)
{
Ok(true) => {}
Ok(false) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"ok": false, "error": "Incorrect password"})),
)
.into_response();
}
Err(err) => return internal_error(err),
}
let mut cfg = state
.platform
.zebflow_cfg
.get_marketplace_distribution(&owner_slug, &project_slug);
cfg.producer_enabled = req.enabled;
match state.platform.zebflow_cfg.set_marketplace_distribution(
&owner_slug,
&project_slug,
cfg.clone(),
) {
Ok(()) => match state.platform.marketplace.set_authority_enabled(
&owner_slug,
&project_slug,
req.enabled,
) {
Ok(authority) => Json(json!({"ok": true, "marketplace": cfg, "authority": authority}))
.into_response(),
Err(err) => internal_error(err),
},
Err(err) => internal_error(err),
}
}
async fn api_list_marketplace_repositories(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesRead,
) {
return response;
}
if let Err(err) = state
.platform
.marketplace
.ensure_default_project_repository(&owner, &project)
{
return internal_error(err);
}
match state
.platform
.marketplace
.list_repositories(&owner, &project)
{
Ok(items) => Json(json!({
"ok": true,
"items": items.into_iter().map(project_marketplace_repository_json).collect::<Vec<_>>()
}))
.into_response(),
Err(err) => internal_error(err),
}
}
async fn api_upsert_marketplace_repository(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
Json(req): Json<UpsertMarketplaceRepositoryRequest>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesWrite,
) {
return response;
}
match state.platform.marketplace.upsert_repository(
&owner,
&project,
&req.repository_id,
&req.title,
&req.base_url,
&req.remote_owner,
&req.remote_project,
&req.read_token,
req.enabled,
) {
Ok(item) => Json(json!({
"ok": true,
"repository": project_marketplace_repository_json(item)
}))
.into_response(),
Err(err) => marketplace_api_error(err),
}
}
async fn api_delete_marketplace_repository(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, repository_id)): Path<(String, String, String)>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesWrite,
) {
return response;
}
match state
.platform
.marketplace
.delete_repository(&owner, &project, &repository_id)
{
Ok(()) => Json(json!({"ok": true})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_install_remote_marketplace_pack(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, repository_id, package_id, version)): Path<(
String,
String,
String,
String,
String,
)>,
body: Option<Json<InstallMarketplaceAssetRequest>>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesWrite,
) {
return response;
}
let install_mode = normalize_marketplace_install_mode(
body.as_ref()
.map(|Json(req)| req.install_mode.as_str())
.unwrap_or_default(),
);
match state
.platform
.marketplace
.install_remote_pack_from_repository(
&state.http_client,
&owner,
&project,
&repository_id,
&package_id,
&version,
)
.await
{
Ok(result) => Json(json!({"ok": true, "install_mode": install_mode, "result": result}))
.into_response(),
Err(err) => internal_error(err),
}
}
async fn api_list_simple_tables(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TablesRead,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::GET,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
match sekejap::list_tables(&state.platform.config.data_root, &owner, &project) {
Ok(items) => Json(json!({"ok": true, "items": items})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_create_simple_table(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Json(req): Json<CreateSimpleTableRequest>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TablesWrite,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_json_request_to_worker(
&state,
&uri,
&headers,
Method::POST,
&req,
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
match sekejap::create_table(&state.platform.config.data_root, &owner, &project, &req) {
Ok(table) => Json(json!({"ok": true, "table": table})).into_response(),
Err(err)
if err.code == "PLATFORM_SEKEJAP_TABLE_INVALID"
|| err.code == "PLATFORM_SEKEJAP_TABLE_EXISTS" =>
{
(
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": {"code": err.code, "message": err.message}})),
)
.into_response()
}
Err(err) => internal_error(err),
}
}
async fn api_export_sekejap_schema(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TablesRead,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::GET,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
let export = match sekejap::export_schema(&state.platform.config.data_root, &owner, &project) {
Ok(export) => export,
Err(err) => return internal_error(err),
};
let body = match serde_json::to_string_pretty(&export) {
Ok(value) => value + "\n",
Err(err) => {
return internal_error(PlatformError::new("SEKEJAP_SCHEMA_EXPORT", err.to_string()));
}
};
let filename = format!("{project}-sekejap-schema.json");
(
[
(
CONTENT_TYPE,
HeaderValue::from_static("application/json; charset=utf-8"),
),
(
CONTENT_DISPOSITION,
HeaderValue::from_str(&format!("attachment; filename=\"{filename}\""))
.unwrap_or_else(|_| HeaderValue::from_static("attachment")),
),
],
body,
)
.into_response()
}
async fn api_sync_sekejap_schema(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TablesWrite,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::POST,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
match sekejap::sync_schema_to_repo(&state.platform.config.data_root, &owner, &project) {
Ok(report) => Json(json!({"ok": true, "sync": report})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_update_simple_table(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, table)): Path<(String, String, String)>,
Json(req): Json<UpdateSimpleTableRequest>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TablesWrite,
) {
return response;
}
match sekejap::update_table(
&state.platform.config.data_root,
&owner,
&project,
&table,
&req,
) {
Ok(updated) => Json(json!({"ok": true, "table": updated})).into_response(),
Err(err)
if err.code == "PLATFORM_SEKEJAP_TABLE_INVALID"
|| err.code == "PLATFORM_SEKEJAP_TABLE_NOT_FOUND" =>
{
(
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": {"code": err.code, "message": err.message}})),
)
.into_response()
}
Err(err) => internal_error(err),
}
}
async fn api_delete_simple_table(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, table)): Path<(String, String, String)>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TablesWrite,
) {
return response;
}
match sekejap::delete_table(&state.platform.config.data_root, &owner, &project, &table) {
Ok(()) => Json(json!({"ok": true})).into_response(),
Err(err)
if err.code == "PLATFORM_SEKEJAP_TABLE_INVALID"
|| err.code == "PLATFORM_SEKEJAP_TABLE_NOT_FOUND" =>
{
(
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": {"code": err.code, "message": err.message}})),
)
.into_response()
}
Err(err) => internal_error(err),
}
}
async fn api_get_db_connection(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, connection_slug)): Path<(String, String, String)>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TablesRead,
) {
return response;
}
match state
.platform
.db_connections
.get_project_connection(&owner, &project, &connection_slug)
{
Ok(Some(connection)) => Json(json!({"ok": true, "connection": connection})).into_response(),
Ok(None) => (
StatusCode::NOT_FOUND,
Json(json!({"ok": false, "error": {"code":"PLATFORM_DB_CONNECTION_MISSING","message":"db connection not found"}})),
)
.into_response(),
Err(err) => internal_error(err),
}
}
async fn api_upsert_db_connection(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
Json(req): Json<UpsertProjectDbConnectionRequest>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TablesWrite,
) {
return response;
}
match state
.platform
.db_connections
.upsert_project_connection(&owner, &project, &req)
{
Ok(connection) => Json(json!({"ok": true, "connection": connection})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_upsert_db_connection_by_path(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, connection_slug)): Path<(String, String, String)>,
Json(mut req): Json<UpsertProjectDbConnectionRequest>,
) -> Response {
req.connection_slug = connection_slug;
api_upsert_db_connection(State(state), headers, Path((owner, project)), Json(req)).await
}
async fn api_delete_db_connection(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, connection_slug)): Path<(String, String, String)>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TablesWrite,
) {
return response;
}
match state.platform.db_connections.delete_project_connection(
&owner,
&project,
&connection_slug,
) {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(err) => internal_error(err),
}
}
async fn api_test_db_connection(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
Json(req): Json<TestProjectDbConnectionRequest>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TablesRead,
) {
return response;
}
match state
.platform
.db_connections
.test_project_connection(&owner, &project, &req)
.await
{
Ok(result) => Json(json!({"ok": true, "result": result})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_describe_db_connection(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, connection_id)): Path<(String, String, String)>,
Query(query): Query<DbDescribeQuery>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TablesRead,
) {
return response;
}
let req = DescribeProjectDbConnectionRequest {
scope: query.scope,
schema: query.schema,
table: None,
include_system: query.include_system,
};
match state
.platform
.db_runtime
.describe_connection(&owner, &project, &connection_id, &req)
.await
{
Ok(result) => Json(json!({"ok": true, "result": result})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_list_db_connection_schemas(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, connection_id)): Path<(String, String, String)>,
Query(query): Query<DbObjectListQuery>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TablesRead,
) {
return response;
}
let req = DescribeProjectDbConnectionRequest {
scope: Some("schemas".to_string()),
schema: query.schema,
table: None,
include_system: query.include_system,
};
match state
.platform
.db_runtime
.describe_connection(&owner, &project, &connection_id, &req)
.await
{
Ok(result) => Json(json!({"ok": true, "result": result})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_list_db_connection_tables(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, connection_id)): Path<(String, String, String)>,
Query(query): Query<DbObjectListQuery>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TablesRead,
) {
return response;
}
let req = DescribeProjectDbConnectionRequest {
scope: Some("tables".to_string()),
schema: query.schema,
table: None,
include_system: query.include_system,
};
match state
.platform
.db_runtime
.describe_connection(&owner, &project, &connection_id, &req)
.await
{
Ok(result) => Json(json!({"ok": true, "result": result})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_list_db_connection_functions(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, connection_id)): Path<(String, String, String)>,
Query(query): Query<DbObjectListQuery>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TablesRead,
) {
return response;
}
let req = DescribeProjectDbConnectionRequest {
scope: Some("functions".to_string()),
schema: query.schema,
table: None,
include_system: query.include_system,
};
match state
.platform
.db_runtime
.describe_connection(&owner, &project, &connection_id, &req)
.await
{
Ok(result) => Json(json!({"ok": true, "result": result})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_preview_db_connection_table(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, connection_id)): Path<(String, String, String)>,
Query(query): Query<DbTablePreviewQuery>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TablesRead,
) {
return response;
}
let table = query.table.unwrap_or_default();
if table.trim().is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": {"code":"PLATFORM_DB_QUERY_INVALID","message":"query.table is required"}})),
)
.into_response();
}
let limit = query.limit.unwrap_or(120).clamp(1, 5000);
let database_kind = match state
.platform
.db_connections
.list_project_connections(&owner, &project)
{
Ok(items) => items
.into_iter()
.find(|item| item.connection_id == connection_id)
.map(|item| item.database_kind),
Err(err) => return internal_error(err),
};
let Some(database_kind) = database_kind else {
return (
StatusCode::NOT_FOUND,
Json(json!({"ok": false, "error": {"code":"PLATFORM_DB_CONNECTION_MISSING","message":"connection not found"}})),
)
.into_response();
};
let table_name = table.split('.').next_back().unwrap_or(&table).trim();
let sql = build_table_preview_sql(&database_kind, &table, table_name, limit);
let req = QueryProjectDbConnectionRequest {
table: Some(table_name.to_string()),
sql,
limit: Some(limit),
read_only: Some(true),
..Default::default()
};
match state
.platform
.db_runtime
.query_connection(&owner, &project, &connection_id, &req)
.await
{
Ok(result) => Json(json!({"ok": true, "result": result})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_query_db_connection(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, connection_id)): Path<(String, String, String)>,
Json(req): Json<QueryProjectDbConnectionRequest>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TablesRead,
) {
return response;
}
match state
.platform
.db_runtime
.query_connection(&owner, &project, &connection_id, &req)
.await
{
Ok(result) => Json(json!({"ok": true, "result": result})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_list_project_docs(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::ProjectRead,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::GET,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
match state.platform.projects.list_project_docs(&owner, &project) {
Ok(items) => Json(json!({"ok": true, "items": items})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_read_project_doc(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Query(query): Query<DocPathQuery>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::ProjectRead,
) {
return response;
}
let Some(path) = query
.path
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
else {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": {"code":"PLATFORM_DOC_PATH","message":"missing docs path"}})),
)
.into_response();
};
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::GET,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
match state
.platform
.projects
.read_project_doc(&owner, &project, path)
{
Ok(content) => Json(json!({
"ok": true,
"doc": {
"path": path,
"content": content
}
}))
.into_response(),
Err(err) if err.code == "PLATFORM_DOC_MISSING" => (
StatusCode::NOT_FOUND,
Json(json!({"ok": false, "error": {"code": err.code, "message": err.message}})),
)
.into_response(),
Err(err) => internal_error(err),
}
}
async fn api_upsert_project_doc(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Json(req): Json<UpsertProjectDocRequest>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::FilesWrite,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_json_request_to_worker(
&state,
&uri,
&headers,
Method::POST,
&req,
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
match state
.platform
.projects
.upsert_project_doc(&owner, &project, &req.path, &req.content)
{
Ok(item) => Json(json!({"ok": true, "doc": item})).into_response(),
Err(err) if err.code == "PLATFORM_DOC_PATH" => (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": {"code": err.code, "message": err.message}})),
)
.into_response(),
Err(err) => internal_error(err),
}
}
async fn api_upsert_project_doc_file(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Query(query): Query<DocPathQuery>,
body: Bytes,
) -> Response {
let Some(path) = query
.path
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
else {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": {"code":"PLATFORM_DOC_PATH","message":"missing docs path"}})),
)
.into_response();
};
let req = UpsertProjectDocRequest {
path: path.to_string(),
content: String::from_utf8(body.to_vec()).unwrap_or_default(),
};
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::PUT,
&headers,
body,
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
api_upsert_project_doc(
State(state),
headers,
Path((owner, project)),
uri,
Json(req),
)
.await
}
async fn api_delete_project_doc_file(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Query(query): Query<DocPathQuery>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::FilesWrite,
) {
return response;
}
let Some(path) = query
.path
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
else {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": {"code":"PLATFORM_DOC_PATH","message":"missing doc path"}})),
)
.into_response();
};
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::DELETE,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
match state
.platform
.projects
.delete_project_doc(&owner, &project, path)
{
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(err) => internal_error(err),
}
}
async fn api_create_project_doc_folder(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Json(req): Json<CreateProjectDocFolderRequest>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::FilesWrite,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_json_request_to_worker(
&state,
&uri,
&headers,
Method::POST,
&req,
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
match state
.platform
.projects
.create_project_doc_folder(&owner, &project, &req.path)
{
Ok(item) => Json(json!({"ok": true, "doc": item})).into_response(),
Err(err) if err.code == "PLATFORM_DOC_PATH" => (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": {"code": err.code, "message": err.message}})),
)
.into_response(),
Err(err) => internal_error(err),
}
}
async fn api_move_project_doc_entry(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Json(req): Json<ProjectDocMoveRequest>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::FilesWrite,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_json_request_to_worker(
&state,
&uri,
&headers,
Method::POST,
&req,
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
match state
.platform
.projects
.move_project_doc_entry(&owner, &project, &req)
{
Ok(path) => Json(json!({"ok": true, "path": path})).into_response(),
Err(err)
if matches!(
err.code,
"PLATFORM_DOC_PATH" | "PLATFORM_DOC_MOVE" | "PLATFORM_DOC_MISSING"
) =>
{
(
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": {"code": err.code, "message": err.message}})),
)
.into_response()
}
Err(err) => internal_error(err),
}
}
async fn api_delete_project_doc_entry(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Query(query): Query<DocPathQuery>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::FilesWrite,
) {
return response;
}
let Some(path) = query
.path
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
else {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": {"code":"PLATFORM_DOC_PATH","message":"missing doc path"}})),
)
.into_response();
};
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::DELETE,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
match state
.platform
.projects
.delete_project_doc_entry(&owner, &project, path)
{
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(err) => internal_error(err),
}
}
async fn api_list_agent_docs(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::ProjectRead,
) {
return response;
}
match state.platform.projects.list_agent_docs(&owner, &project) {
Ok(items) => Json(json!({"ok": true, "items": items})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_read_agent_doc(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
Query(query): Query<DocPathQuery>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::ProjectRead,
) {
return response;
}
let Some(name) = query
.path
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
else {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": {"code":"PLATFORM_AGENT_DOC_INVALID","message":"missing name query param"}})),
)
.into_response();
};
match state
.platform
.projects
.read_agent_doc(&owner, &project, name)
{
Ok(content) => {
Json(json!({"ok": true, "doc": {"name": name, "content": content}})).into_response()
}
Err(err) if err.code == "PLATFORM_AGENT_DOC_INVALID" => (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": {"code": err.code, "message": err.message}})),
)
.into_response(),
Err(err) => internal_error(err),
}
}
async fn api_upsert_agent_doc_file(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
Query(query): Query<DocPathQuery>,
body: Bytes,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::FilesWrite,
) {
return response;
}
let Some(name) = query
.path
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
else {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": {"code":"PLATFORM_AGENT_DOC_INVALID","message":"missing name query param"}})),
)
.into_response();
};
if name == "MEMORY.md" {
return (
StatusCode::FORBIDDEN,
Json(json!({"ok": false, "error": {"code":"PLATFORM_AGENT_DOC_READONLY","message":"MEMORY.md is managed by the assistant and cannot be written via REST"}})),
)
.into_response();
}
let content = String::from_utf8(body.to_vec()).unwrap_or_default();
match state
.platform
.projects
.upsert_agent_doc(&owner, &project, name, &content)
{
Ok(_) => Json(json!({"ok": true, "name": name})).into_response(),
Err(err) if err.code == "PLATFORM_AGENT_DOC_INVALID" => (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": {"code": err.code, "message": err.message}})),
)
.into_response(),
Err(err) => internal_error(err),
}
}
enum AuthError {
Unauthenticated {
message: String,
redirect_url: Option<String>,
},
Forbidden {
message: String,
redirect_url: Option<String>,
},
Internal(String),
}
fn is_page_navigation(headers: &HeaderMap) -> bool {
let mode = headers
.get("sec-fetch-mode")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let dest = headers
.get("sec-fetch-dest")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if !mode.is_empty() || !dest.is_empty() {
return mode == "navigate" && dest == "document";
}
headers
.get("accept")
.and_then(|v| v.to_str().ok())
.map(|a| a.contains("text/html"))
.unwrap_or(false)
}
fn wants_event_stream(headers: &HeaderMap) -> bool {
headers
.get("accept")
.and_then(|v| v.to_str().ok())
.map(|accept| accept.contains("text/event-stream"))
.unwrap_or(false)
}
fn verify_webhook_auth(
headers: &HeaderMap,
body: &Bytes,
auth_type: &str,
auth_credential: &str,
required_roles: &[String],
credentials: &crate::platform::services::CredentialService,
owner: &str,
project: &str,
) -> Result<Option<Value>, AuthError> {
if auth_type.is_empty() || auth_type == "none" {
return Ok(None);
}
if auth_credential.is_empty() {
return Err(AuthError::Internal(
"auth_type set but auth_credential is empty".to_string(),
));
}
let credential = credentials
.get_project_credential(owner, project, auth_credential)
.map_err(|e| AuthError::Internal(e.message))?
.ok_or_else(|| {
AuthError::Internal(format!("auth credential '{auth_credential}' not found"))
})?;
let auth_redirect = credential
.secret
.get("auth_redirect")
.and_then(|v| v.as_str())
.map(ToString::to_string);
let auth_forbidden_redirect = credential
.secret
.get("auth_forbidden_redirect")
.and_then(|v| v.as_str())
.map(ToString::to_string);
match auth_type {
"jwt" => {
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode};
let cookie_name = if auth_credential.is_empty() {
"zebflow_session"
} else {
"zebflow_session"
};
let token = headers
.get("Authorization")
.and_then(|h| h.to_str().ok())
.and_then(|h| h.strip_prefix("Bearer "))
.map(ToString::to_string)
.or_else(|| {
let cookie = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
cookie.split(';').map(str::trim).find_map(|part| {
part.strip_prefix(&format!("{cookie_name}="))
.map(ToString::to_string)
})
})
.ok_or_else(|| AuthError::Unauthenticated {
message: "missing Authorization: Bearer <token> or session cookie".to_string(),
redirect_url: auth_redirect.clone(),
})?;
let algo_str = credential
.secret
.get("algorithm")
.and_then(|v| v.as_str())
.unwrap_or("HS256");
let algorithm = match algo_str.to_ascii_uppercase().as_str() {
"HS256" => Algorithm::HS256,
"HS384" => Algorithm::HS384,
"HS512" => Algorithm::HS512,
"RS256" => Algorithm::RS256,
"RS384" => Algorithm::RS384,
"RS512" => Algorithm::RS512,
other => {
return Err(AuthError::Internal(format!(
"unsupported JWT algorithm '{other}'"
)));
}
};
let decoding_key = match algorithm {
Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512 => {
let secret = credential
.secret
.get("secret")
.and_then(|v| v.as_str())
.ok_or_else(|| {
AuthError::Internal(
"jwt_signing_key credential missing 'secret' field".to_string(),
)
})?;
DecodingKey::from_secret(secret.as_bytes())
}
_ => {
let pem = credential
.secret
.get("public_key")
.or_else(|| credential.secret.get("private_key"))
.and_then(|v| v.as_str())
.ok_or_else(|| {
AuthError::Internal(
"jwt_signing_key credential missing 'public_key'".to_string(),
)
})?;
DecodingKey::from_rsa_pem(pem.as_bytes())
.map_err(|e| AuthError::Internal(e.to_string()))?
}
};
let mut validation = Validation::new(algorithm);
validation.validate_exp = true;
let token_data = decode::<Value>(&token, &decoding_key, &validation).map_err(|e| {
AuthError::Unauthenticated {
message: format!("JWT invalid: {e}"),
redirect_url: auth_redirect.clone(),
}
})?;
let claims = token_data.claims;
if !required_roles.is_empty() {
let user_roles: Vec<&str> = claims
.get("roles")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default();
let authorized = required_roles
.iter()
.any(|r| user_roles.contains(&r.as_str()));
if !authorized {
return Err(AuthError::Forbidden {
message: format!("roles {:?} are not permitted for this route", user_roles),
redirect_url: auth_forbidden_redirect.clone(),
});
}
}
Ok(Some(claims))
}
"hmac" => {
use hmac::{Hmac, Mac};
let provider = credential
.secret
.get("provider")
.and_then(|v| v.as_str())
.unwrap_or("generic");
let sig_header_name = credential
.secret
.get("signature_header")
.and_then(|v| v.as_str())
.unwrap_or("X-Hub-Signature-256");
let sig_prefix = credential
.secret
.get("signature_prefix")
.and_then(|v| v.as_str())
.unwrap_or("");
let sig_encoding = credential
.secret
.get("signature_encoding")
.and_then(|v| v.as_str())
.unwrap_or("hex");
let algorithm = credential
.secret
.get("algorithm")
.and_then(|v| v.as_str())
.unwrap_or("sha256");
let replay_tolerance = credential
.secret
.get("replay_tolerance")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let secret = credential
.secret
.get("secret")
.and_then(|v| v.as_str())
.ok_or_else(|| {
AuthError::Internal("hmac credential missing 'secret' field".to_string())
})?;
let sig_raw = headers
.get(sig_header_name)
.and_then(|h| h.to_str().ok())
.ok_or_else(|| AuthError::Unauthenticated {
message: format!("missing HMAC signature header '{sig_header_name}'"),
redirect_url: None,
})?;
let (expected_sig_str, message_to_sign): (String, Vec<u8>) = match provider {
"stripe" => {
let parts: std::collections::HashMap<&str, &str> = sig_raw
.split(',')
.filter_map(|p| p.split_once('='))
.collect();
let timestamp = parts.get("t").ok_or_else(|| AuthError::Unauthenticated {
message: "Stripe signature missing 't' field".to_string(),
redirect_url: None,
})?;
let sig = parts.get("v1").ok_or_else(|| AuthError::Unauthenticated {
message: "Stripe signature missing 'v1' field".to_string(),
redirect_url: None,
})?;
if replay_tolerance > 0 {
let ts: i64 =
timestamp.parse().map_err(|_| AuthError::Unauthenticated {
message: "Stripe timestamp invalid".to_string(),
redirect_url: None,
})?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
if (now - ts).unsigned_abs() > replay_tolerance {
return Err(AuthError::Unauthenticated {
message: format!("replay tolerance exceeded ({replay_tolerance}s)"),
redirect_url: None,
});
}
}
let body_str = std::str::from_utf8(body.as_ref()).unwrap_or("");
let msg = format!("{timestamp}.{body_str}");
(sig.to_string(), msg.into_bytes())
}
"slack" => {
let timestamp = headers
.get("X-Slack-Request-Timestamp")
.and_then(|h| h.to_str().ok())
.ok_or_else(|| AuthError::Unauthenticated {
message: "missing X-Slack-Request-Timestamp header".to_string(),
redirect_url: None,
})?;
if replay_tolerance > 0 {
let ts: i64 =
timestamp.parse().map_err(|_| AuthError::Unauthenticated {
message: "Slack timestamp invalid".to_string(),
redirect_url: None,
})?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
if (now - ts).unsigned_abs() > replay_tolerance {
return Err(AuthError::Unauthenticated {
message: format!("replay tolerance exceeded ({replay_tolerance}s)"),
redirect_url: None,
});
}
}
let sig = if !sig_prefix.is_empty() {
sig_raw.strip_prefix(sig_prefix).unwrap_or(sig_raw)
} else {
sig_raw
};
let body_str = std::str::from_utf8(body.as_ref()).unwrap_or("");
let msg = format!("v0:{timestamp}:{body_str}");
(sig.to_string(), msg.into_bytes())
}
_ => {
let sig = if !sig_prefix.is_empty() {
sig_raw.strip_prefix(sig_prefix).unwrap_or(sig_raw)
} else {
sig_raw
};
(sig.to_string(), body.to_vec())
}
};
let expected_bytes = match sig_encoding {
"base64" => {
use base64::Engine as _;
base64::engine::general_purpose::STANDARD
.decode(expected_sig_str.as_bytes())
.map_err(|e| AuthError::Unauthenticated {
message: format!("invalid base64 in signature: {e}"),
redirect_url: None,
})?
}
_ => hex::decode(&expected_sig_str).map_err(|e| AuthError::Unauthenticated {
message: format!("invalid hex in signature: {e}"),
redirect_url: None,
})?,
};
match algorithm {
"sha1" => {
type HmacSha1 = Hmac<sha1::Sha1>;
let mut mac = HmacSha1::new_from_slice(secret.as_bytes())
.map_err(|e| AuthError::Internal(e.to_string()))?;
mac.update(&message_to_sign);
mac.verify_slice(&expected_bytes)
.map_err(|_| AuthError::Unauthenticated {
message: "HMAC signature mismatch".to_string(),
redirect_url: None,
})?;
}
_ => {
type HmacSha256 = Hmac<sha2::Sha256>;
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
.map_err(|e| AuthError::Internal(e.to_string()))?;
mac.update(&message_to_sign);
mac.verify_slice(&expected_bytes)
.map_err(|_| AuthError::Unauthenticated {
message: "HMAC signature mismatch".to_string(),
redirect_url: None,
})?;
}
}
Ok(None)
}
"api_key" => {
let provided = headers
.get("X-API-Key")
.or_else(|| headers.get("x-api-key"))
.and_then(|h| h.to_str().ok())
.or_else(|| {
headers
.get("Authorization")
.and_then(|h| h.to_str().ok())
.and_then(|h| h.strip_prefix("ApiKey "))
})
.ok_or_else(|| AuthError::Unauthenticated {
message: "missing API key (X-API-Key or Authorization: ApiKey <key>)"
.to_string(),
redirect_url: None,
})?;
let stored = credential
.secret
.get("key")
.and_then(|v| v.as_str())
.ok_or_else(|| {
AuthError::Internal("api_key credential missing 'key' field".to_string())
})?;
if provided != stored {
return Err(AuthError::Unauthenticated {
message: "invalid API key".to_string(),
redirect_url: None,
});
}
Ok(None)
}
other => Err(AuthError::Internal(format!(
"unknown auth_type '{other}'. Valid: jwt, hmac, api_key, none"
))),
}
}
async fn dispatch_weberror(
state: &PlatformAppState,
owner: &str,
project: &str,
error_code: u16,
error_payload: Value,
) -> Option<Response> {
use crate::pipeline::nodes::basic::trigger::weberror::match_specificity;
let mut best: Option<(
u8,
crate::platform::services::pipeline_runtime::CompiledPipeline,
)> = None;
for compiled in state.platform.pipeline_runtime.list_project(owner, project) {
for trigger in &compiled.weberror_triggers {
if let Some(spec) = match_specificity(&trigger.code, error_code) {
if best.as_ref().map_or(true, |(s, _)| spec > *s) {
best = Some((spec, compiled.clone()));
break;
}
}
}
}
let (_, compiled) = best?;
let credentials = state.platform.credentials.clone();
let engine = BasicPipelineEngine::new(
Arc::new(DenoSandboxEngine::default()),
state.frontend.rwe.clone(),
Some(credentials),
)
.with_platform(state.platform.clone())
.with_template_cache(state.template_cache.clone())
.with_template_root(
state
.platform
.projects
.get_project_template_root(owner, project)
.ok(),
)
.with_ws_hub(state.platform.ws_hub.clone())
.with_ws_client_manager(state.ws_client_manager.clone())
.with_state_bus(state.platform.state_bus.clone())
.with_data_root(state.platform.config.data_root.clone());
let ctx = PipelineContext {
owner: owner.to_string(),
project: project.to_string(),
pipeline: compiled.graph.id.clone(),
request_id: format!("weberror-{error_code}"),
route: Default::default(),
input: error_payload,
trigger: None,
placeholder: None,
};
let output = engine.execute_async(&compiled.graph, &ctx).await.ok()?;
let status = StatusCode::from_u16(error_code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
if let Some(html) = output.value.get("html").and_then(Value::as_str) {
let mut html = html.to_string();
if let Some(css) = output
.value
.get("hydration_payload")
.and_then(|hp| hp.get("css"))
.and_then(Value::as_str)
{
if !css.trim().is_empty() {
let style_block = format!("<style data-rwe-tw>{css}</style>");
if let Some(pos) = html.find("</head>") {
html.insert_str(pos, &style_block);
} else {
html = format!("{style_block}{html}");
}
}
}
let scripts = output
.value
.get("compiled_scripts")
.cloned()
.and_then(|v| serde_json::from_value::<Vec<CompiledScript>>(v).ok())
.unwrap_or_default();
let externalized = externalize_rwe_scripts(state, &html, &scripts, Some((owner, project)));
return Some((status, Html(externalized)).into_response());
}
Some((status, Json(output.value)).into_response())
}
async fn public_webhook_ingress(
State(state): State<PlatformAppState>,
Path((owner, project, tail)): Path<(String, String, String)>,
method: Method,
uri: Uri,
headers: HeaderMap,
body: Bytes,
) -> Response {
let owner = crate::platform::model::slug_segment(&owner);
let project = crate::platform::model::slug_segment(&project);
let path = format!("/{}", tail.trim_start_matches('/'));
let method_key = method.as_str().to_ascii_uppercase();
let exec_start = std::time::Instant::now();
let project_cfg = state.platform.zebflow_cfg.read_or_default(&owner, &project);
let webhook_body_max_mb = project_cfg
.configs
.files
.uploads
.effective_webhook_body_max_mb();
let webhook_body_limit_bytes = (webhook_body_max_mb as usize) * 1024 * 1024;
if body.len() > webhook_body_limit_bytes {
return (
StatusCode::PAYLOAD_TOO_LARGE,
Json(json!({
"ok": false,
"error": format!(
"webhook body {} bytes exceeds limit of {} MB",
body.len(),
webhook_body_max_mb
)
})),
)
.into_response();
}
let request_id = format!(
"webhook-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
);
if let Ok(Some(placement)) = state.platform.cluster_placement.get(&owner, &project) {
if placement.target == ProjectRuntimePlacementTarget::Worker {
if let Some(worker_id) = placement.worker_id.as_deref() {
return match forward_runtime_webhook_to_worker(
&state, &owner, &project, &tail, &method, &uri, &headers, &body, worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
}
}
struct Candidate {
compiled: crate::platform::services::pipeline_runtime::CompiledPipeline,
path_params: serde_json::Map<String, Value>,
static_segments: usize,
dynamic_segments: usize,
total_segments: usize,
auth_type: String,
auth_credential: String,
auth_required_role: Vec<String>,
}
let mut candidates = Vec::<Candidate>::new();
for compiled in state
.platform
.pipeline_runtime
.list_project(&owner, &project)
{
for trigger in &compiled.webhook_triggers {
if !trigger.method.eq_ignore_ascii_case(&method_key) {
continue;
}
let Some(path_match) = match_webhook_path(&trigger.path, &path) else {
continue;
};
candidates.push(Candidate {
auth_type: trigger.auth_type.clone(),
auth_credential: trigger.auth_credential.clone(),
auth_required_role: trigger.auth_required_role.clone(),
compiled: compiled.clone(),
path_params: path_match.params,
static_segments: path_match.static_segments,
dynamic_segments: path_match.dynamic_segments,
total_segments: path_match.total_segments,
});
}
}
candidates.sort_by(|a, b| {
b.static_segments
.cmp(&a.static_segments)
.then(a.dynamic_segments.cmp(&b.dynamic_segments))
.then(b.total_segments.cmp(&a.total_segments))
.then(a.compiled.file_rel_path.cmp(&b.compiled.file_rel_path))
});
let Some(selected) = candidates.into_iter().next() else {
if let Some(err_resp) = dispatch_weberror(
&state,
&owner,
&project,
404,
json!({"error": "not found", "path": path, "method": method_key}),
)
.await
{
return err_resp;
}
return (
StatusCode::NOT_FOUND,
Json(json!({"ok": false, "error": "not found"})),
)
.into_response();
};
let retention = resolve_invocation_retention(&project_cfg, Some(&selected.compiled.graph));
let auth_claims = if has_valid_cluster_token(&state, &headers) {
Ok(None)
} else {
verify_webhook_auth(
&headers,
&body,
&selected.auth_type,
&selected.auth_credential,
&selected.auth_required_role,
&state.platform.credentials,
&owner,
&project,
)
};
let auth_claims = match auth_claims {
Ok(claims) => claims,
Err(auth_err) => {
let (status, msg, redirect_url) = match auth_err {
AuthError::Unauthenticated {
message,
redirect_url,
} => (StatusCode::UNAUTHORIZED, message, redirect_url),
AuthError::Forbidden {
message,
redirect_url,
} => (StatusCode::FORBIDDEN, message, redirect_url),
AuthError::Internal(message) => (StatusCode::INTERNAL_SERVER_ERROR, message, None),
};
if is_page_navigation(&headers) {
if let Some(url) = redirect_url {
return axum::response::Redirect::to(&url).into_response();
}
}
if let Some(err_resp) = dispatch_weberror(
&state,
&owner,
&project,
status.as_u16(),
json!({"error": msg, "path": path, "method": method_key}),
)
.await
{
return err_resp;
}
return (status, Json(json!({"ok": false, "error": msg}))).into_response();
}
};
if let Ok(Some(placement)) = state.platform.cluster_placement.get(&owner, &project) {
if placement.target == ProjectRuntimePlacementTarget::Worker {
if let Some(worker_id) = placement.worker_id.as_deref() {
return match forward_runtime_webhook_to_worker(
&state, &owner, &project, &tail, &method, &uri, &headers, &body, worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
}
}
let mut graph = selected.compiled.graph.clone();
if let Err(err) = hydrate_template_markup(&state, &owner, &project, &mut graph) {
state.platform.pipeline_hits.record_failure(
&owner,
&project,
&selected.compiled.file_rel_path,
"webhook.ingress",
err.code,
&err.message,
);
let _ = state.platform.data.log_pipeline_invocation(
&owner,
&project,
&selected.compiled.file_rel_path,
&PipelineInvocationEntry {
run_id: request_id.clone(),
at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64,
duration_ms: exec_start.elapsed().as_millis() as u64,
status: "error".to_string(),
trigger: "webhook".to_string(),
error: Some(err.message.clone()),
trace: vec![],
},
retention.max_invocations,
retention.max_age_secs,
);
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": {"code": err.code, "message": err.message}})),
)
.into_response();
}
apply_rwe_project_options(&state, &owner, &project, &mut graph);
let mut input = match build_webhook_ingress_input(
&state.platform,
&owner,
&project,
&request_id,
&method,
&uri,
&headers,
&body,
&path,
&selected.path_params,
)
.await
{
Ok(input) => input,
Err(err) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"ok": false, "error": {"code": err.code, "message": err.message}})),
)
.into_response();
}
};
if let Some(claims) = auth_claims {
if let Value::Object(ref mut map) = input {
map.insert("auth".to_string(), claims);
}
}
let trigger = json!({
"auth": input.get("auth").cloned().unwrap_or(Value::Null),
"params": input.get("params").cloned().unwrap_or(json!({})),
"query": input.get("query").cloned().unwrap_or(json!({})),
"headers": safe_headers(&headers),
});
let credentials = state.platform.credentials.clone();
let graph_for_run = graph.clone();
let ctx = PipelineContext {
owner: owner.clone(),
project: project.clone(),
pipeline: graph.id.clone(),
request_id: request_id.clone(),
route: path.clone(),
input: input.clone(),
trigger: Some(trigger),
placeholder: None,
};
let file_rel_path = selected.compiled.file_rel_path.clone();
let engine = BasicPipelineEngine::new(
Arc::new(DenoSandboxEngine::default()),
state.frontend.rwe.clone(),
Some(credentials),
)
.with_platform(state.platform.clone())
.with_template_cache(state.template_cache.clone())
.with_template_root(
state
.platform
.projects
.get_project_template_root(&owner, &project)
.ok(),
)
.with_ws_hub(state.platform.ws_hub.clone())
.with_ws_client_manager(state.ws_client_manager.clone())
.with_state_bus(state.platform.state_bus.clone())
.with_data_root(state.platform.config.data_root.clone());
if wants_event_stream(&headers) {
let bus = std::sync::Arc::new(ExecutionBus::new(256));
let mut signal_rx = bus.subscribe();
let options = ExecuteOptions { bus: Some(bus) };
let platform_sse = state.platform.clone();
let file_rel_path_sse = file_rel_path.clone();
let request_id_sse = request_id.clone();
let owner_sse = owner.clone();
let project_sse = project.clone();
let (result_tx, mut result_rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
let result = engine
.execute_with_options_async(&graph_for_run, &ctx, &options)
.await;
let _ = result_tx.send(result);
});
let stream = async_stream::stream! {
loop {
tokio::select! {
biased;
sig = signal_rx.recv() => {
match sig {
Ok(signal) => {
let payload = serde_json::to_string(&signal)
.unwrap_or_else(|_| "{}".to_string());
yield Ok::<_, Infallible>(Event::default()
.event("signal")
.data(payload));
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
}
}
result = &mut result_rx => {
match result {
Ok(Ok(output)) => {
let done = json!({ "ok": true, "value": output.value });
yield Ok(Event::default().event("done").data(done.to_string()));
}
Ok(Err(err)) => {
let error = json!({
"ok": false,
"error": { "code": err.code, "message": err.message }
});
yield Ok(Event::default().event("error").data(error.to_string()));
}
Err(_) => {}
}
return;
}
}
}
loop {
match signal_rx.try_recv() {
Ok(signal) => {
let payload = serde_json::to_string(&signal)
.unwrap_or_else(|_| "{}".to_string());
yield Ok::<_, Infallible>(Event::default()
.event("signal")
.data(payload));
}
Err(_) => break,
}
}
match result_rx.await {
Ok(Ok(output)) => {
let elapsed_ms = exec_start.elapsed().as_millis() as u64;
platform_sse.pipeline_hits.record_success(
&owner_sse, &project_sse, &file_rel_path_sse,
);
let _ = platform_sse.data.log_pipeline_invocation(
&owner_sse, &project_sse, &file_rel_path_sse,
&PipelineInvocationEntry {
run_id: request_id_sse.clone(),
at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default().as_secs() as i64,
duration_ms: elapsed_ms,
status: "ok".to_string(),
trigger: "webhook".to_string(),
error: None,
trace: output.node_trace.clone(),
},
retention.max_invocations,
retention.max_age_secs,
);
let done = json!({ "ok": true, "value": output.value });
yield Ok(Event::default().event("done").data(done.to_string()));
}
Ok(Err(err)) => {
let elapsed_ms = exec_start.elapsed().as_millis() as u64;
platform_sse.pipeline_hits.record_failure(
&owner_sse, &project_sse, &file_rel_path_sse,
"webhook.ingress", err.code, &err.message,
);
let _ = platform_sse.data.log_pipeline_invocation(
&owner_sse, &project_sse, &file_rel_path_sse,
&PipelineInvocationEntry {
run_id: request_id_sse.clone(),
at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default().as_secs() as i64,
duration_ms: elapsed_ms,
status: "error".to_string(),
trigger: "webhook".to_string(),
error: Some(err.message.clone()),
trace: err.node_trace.clone(),
},
retention.max_invocations,
retention.max_age_secs,
);
let error = json!({
"ok": false,
"error": { "code": err.code, "message": err.message }
});
yield Ok(Event::default().event("error").data(error.to_string()));
}
Err(_) => {}
}
};
return Sse::new(stream)
.keep_alive(KeepAlive::default())
.into_response();
}
let output = match engine.execute_async(&graph_for_run, &ctx).await {
Ok(output) => output,
Err(err) => {
state.platform.pipeline_hits.record_failure(
&owner,
&project,
&file_rel_path,
"webhook.ingress",
err.code,
&err.message,
);
let _ = state.platform.data.log_pipeline_invocation(
&owner,
&project,
&file_rel_path,
&PipelineInvocationEntry {
run_id: request_id.clone(),
at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64,
duration_ms: exec_start.elapsed().as_millis() as u64,
status: "error".to_string(),
trigger: "webhook".to_string(),
error: Some(err.message.clone()),
trace: err.node_trace.clone(),
},
retention.max_invocations,
retention.max_age_secs,
);
if let Some(err_resp) = dispatch_weberror(
&state,
&owner,
&project,
500,
json!({"error": err.message, "code": err.code}),
)
.await
{
return err_resp;
}
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"ok": false, "error": {"code": err.code, "message": err.message}})),
)
.into_response();
}
};
state
.platform
.pipeline_hits
.record_success(&owner, &project, &file_rel_path);
let _ = state.platform.data.log_pipeline_invocation(
&owner,
&project,
&file_rel_path,
&PipelineInvocationEntry {
run_id: request_id.clone(),
at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64,
duration_ms: exec_start.elapsed().as_millis() as u64,
status: "ok".to_string(),
trigger: "webhook".to_string(),
error: None,
trace: output.node_trace.clone(),
},
retention.max_invocations,
retention.max_age_secs,
);
if let Some(resp_cfg) = output.value.get("__zf_response").cloned() {
let status = resp_cfg
.get("status")
.and_then(Value::as_u64)
.and_then(|c| StatusCode::from_u16(c as u16).ok())
.unwrap_or(StatusCode::OK);
let zf_cookie = build_zf_cookie(&resp_cfg);
let zf_headers = build_zf_headers(&resp_cfg);
if let Some(html) = resp_cfg.get("html").and_then(Value::as_str) {
let mut html = html.to_string();
if let Ok(tmpl_root) = state
.platform
.projects
.get_project_template_root(&owner, &project)
{
let main_css_path = tmpl_root.join("styles").join("main.css");
if let Ok(project_css) = std::fs::read_to_string(&main_css_path) {
if !project_css.trim().is_empty() {
let style_block =
format!("<style data-project-theme>{project_css}</style>");
html = insert_project_theme_block(html, &style_block);
}
}
}
if let Some(css) = resp_cfg
.get("hydration_payload")
.and_then(|hp| hp.get("css"))
.and_then(Value::as_str)
{
if !css.trim().is_empty() {
let style_block = format!("<style data-rwe-tw>{css}</style>");
if let Some(pos) = html.find("</head>") {
html.insert_str(pos, &style_block);
} else {
html = format!("{style_block}{html}");
}
}
}
let scripts = resp_cfg
.get("compiled_scripts")
.cloned()
.and_then(|v| serde_json::from_value::<Vec<CompiledScript>>(v).ok())
.unwrap_or_default();
let externalized =
externalize_rwe_scripts(&state, &html, &scripts, Some((&owner, &project)));
let mut resp = Html(externalized).into_response();
*resp.status_mut() = status;
apply_zf_extras(&mut resp, &zf_cookie, &zf_headers);
return resp;
}
if let Some(loc) = resp_cfg.get("location").and_then(Value::as_str) {
let mut resp = (status, "").into_response();
if let Ok(v) = HeaderValue::from_str(loc) {
resp.headers_mut().insert(LOCATION, v);
}
apply_zf_extras(&mut resp, &zf_cookie, &zf_headers);
return resp;
}
if let Some(msg) = resp_cfg.get("message").and_then(Value::as_str) {
let mut resp = (status, msg.to_string()).into_response();
apply_zf_extras(&mut resp, &zf_cookie, &zf_headers);
return resp;
}
if let Some(body) = resp_cfg.get("body") {
if !body.is_null() {
let mut resp = (status, Json(body.clone())).into_response();
apply_zf_extras(&mut resp, &zf_cookie, &zf_headers);
return resp;
}
}
let mut out = output.value.clone();
if let Value::Object(ref mut map) = out {
map.remove("__zf_response");
}
let mut resp = (status, Json(out)).into_response();
apply_zf_extras(&mut resp, &zf_cookie, &zf_headers);
return resp;
}
let set_cookie_header: Option<String> = output.value.get("_set_cookie").and_then(|sc| {
let name = sc.get("name")?.as_str()?;
let value = sc.get("value")?.as_str()?;
let max_age = sc.get("max_age").and_then(Value::as_i64).unwrap_or(900);
let path = sc.get("path").and_then(Value::as_str).unwrap_or("/");
let same_site = sc.get("same_site").and_then(Value::as_str).unwrap_or("Lax");
let http_only = sc.get("http_only").and_then(Value::as_bool).unwrap_or(true);
let mut parts = vec![
format!("{name}={value}"),
format!("Path={path}"),
format!("Max-Age={max_age}"),
format!("SameSite={same_site}"),
];
if http_only {
parts.push("HttpOnly".to_string());
}
Some(parts.join("; "))
});
if let Some(html) = output.value.get("html").and_then(Value::as_str) {
let mut html = html.to_string();
if let Some(css) = output
.value
.get("hydration_payload")
.and_then(|hp| hp.get("css"))
.and_then(Value::as_str)
{
if !css.trim().is_empty() {
let style_block = format!("<style data-rwe-tw>{css}</style>");
if let Some(pos) = html.find("</head>") {
html.insert_str(pos, &style_block);
} else {
html = format!("{style_block}{html}");
}
}
}
let scripts = output
.value
.get("compiled_scripts")
.cloned()
.and_then(|value| serde_json::from_value::<Vec<CompiledScript>>(value).ok())
.unwrap_or_default();
let externalized =
externalize_rwe_scripts(&state, &html, &scripts, Some((&owner, &project)));
let mut resp = Html(externalized).into_response();
if let Some(ref cookie) = set_cookie_header {
if let Ok(v) = HeaderValue::from_str(cookie) {
resp.headers_mut().insert(SET_COOKIE, v);
}
}
return resp;
}
if let Some(code) = output.value.get("_status").and_then(Value::as_u64) {
let status = StatusCode::from_u16(code as u16).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
if code >= 400 {
let mut error_body = output.value.clone();
if let Value::Object(ref mut map) = error_body {
map.remove("_status");
map.remove("_set_cookie");
}
if let Some(err_resp) =
dispatch_weberror(&state, &owner, &project, code as u16, error_body.clone()).await
{
return err_resp;
}
return (status, Json(error_body)).into_response();
}
let mut body = output.value.clone();
if let Value::Object(ref mut map) = body {
map.remove("_status");
map.remove("_set_cookie");
}
let mut resp = (status, Json(body)).into_response();
if let Some(ref cookie) = set_cookie_header {
if let Ok(v) = HeaderValue::from_str(cookie) {
resp.headers_mut().insert(SET_COOKIE, v);
}
}
return resp;
}
let mut out_body = output.value.clone();
if let Value::Object(ref mut map) = out_body {
map.remove("_set_cookie");
}
let mut resp = Json(out_body).into_response();
if let Some(ref cookie) = set_cookie_header {
if let Ok(v) = HeaderValue::from_str(cookie) {
resp.headers_mut().insert(SET_COOKIE, v);
}
}
resp
}
async fn public_webhook_ingress_root(
State(state): State<PlatformAppState>,
Path((owner, project)): Path<(String, String)>,
method: Method,
uri: Uri,
headers: HeaderMap,
body: Bytes,
) -> Response {
public_webhook_ingress(
State(state),
Path((owner, project, String::new())),
method,
uri,
headers,
body,
)
.await
}
async fn public_mapserver_ingress(
State(state): State<PlatformAppState>,
Path((owner, project, tail)): Path<(String, String, String)>,
uri: Uri,
) -> Response {
let owner = crate::platform::model::slug_segment(&owner);
let project = crate::platform::model::slug_segment(&project);
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::GET,
&HeaderMap::new(),
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
let raw_path = format!("/{}", tail.trim_start_matches('/'));
let params = uri
.query()
.map(|raw| serde_urlencoded::from_str::<std::collections::HashMap<String, String>>(raw))
.transpose();
let params = match params {
Ok(Some(map)) => map,
Ok(None) => std::collections::HashMap::new(),
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": "invalid query string"})),
)
.into_response();
}
};
struct ZxyTileMatch {
z: u8,
x: u32,
y: u32,
ext: String, layer_path: String, }
fn parse_zxy_tile_url(path: &str) -> Option<ZxyTileMatch> {
let trimmed = path.trim_end_matches('/');
let segments: Vec<&str> = trimmed.rsplitn(4, '/').collect();
if segments.len() < 4 {
return None;
}
let y_ext = segments[0];
let x_str = segments[1];
let z_str = segments[2];
let layer_rest = segments[3];
let dot_pos = y_ext.rfind('.')?;
let y_str = &y_ext[..dot_pos];
let ext = &y_ext[dot_pos + 1..];
let ext_lower = ext.to_ascii_lowercase();
if !matches!(ext_lower.as_str(), "mvt" | "pbf" | "png") {
return None;
}
let z: u8 = z_str.parse().ok()?;
let x: u32 = x_str.parse().ok()?;
let y: u32 = y_str.parse().ok()?;
let max_tile = 1u32.checked_shl(z as u32)?;
if x >= max_tile || y >= max_tile {
return None;
}
let layer_path = if layer_rest.is_empty() {
"/".to_string()
} else {
layer_rest.to_string()
};
Some(ZxyTileMatch {
z,
x,
y,
ext: ext_lower,
layer_path,
})
}
let zxy_match = parse_zxy_tile_url(&raw_path);
let is_stats_request =
zxy_match.is_none() && raw_path.trim_end_matches('/').ends_with("/stats");
let path = if let Some(ref zm) = zxy_match {
zm.layer_path.clone()
} else if is_stats_request {
let stripped = raw_path.trim_end_matches('/');
stripped[..stripped.len() - "/stats".len()].to_string()
} else {
raw_path.clone()
};
let manifest = match resolve_mapserver_manifest_for_path(&state, &owner, &project, &path) {
Ok(Some(m)) => m,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"ok": false, "error": "map layer not found"})),
)
.into_response();
}
Err(err) => return internal_error(err),
};
if is_stats_request {
match crate::mapserver::resolve::stats::compute_layer_stats(&manifest) {
Ok(stats_json) => {
let mut resp = (StatusCode::OK, Json(stats_json)).into_response();
resp.headers_mut().insert(
HeaderName::from_static("access-control-allow-origin"),
HeaderValue::from_static("*"),
);
return resp;
}
Err(err) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"ok": false, "error": format!("stats computation failed: {err}")})),
)
.into_response();
}
}
}
let layer_id = manifest.layer_id;
let layer_path = manifest.path;
let source_kind = manifest.source_kind;
let source_path = manifest.source_ref;
let mode = manifest.mode;
let min_zoom = manifest.min_zoom;
let max_zoom = manifest.max_zoom;
let bbox_required = manifest.bbox_required;
let max_features = manifest.max_features;
let allowed_properties = manifest.allowed_properties;
let layer_style_json = manifest.style;
let layer_default_filter = manifest.filter;
let function_slug = manifest.function_slug;
let cache_ttl_secs = manifest.cache_ttl_secs;
if !mode.eq_ignore_ascii_case("features") {
return (
StatusCode::NOT_IMPLEMENTED,
Json(json!({"ok": false, "error": "only features mode is supported in v1"})),
)
.into_response();
}
let point_query_lat = params
.get("lat")
.or_else(|| params.get("LAT"))
.or_else(|| params.get("y"))
.or_else(|| params.get("Y"))
.and_then(|s| s.parse::<f64>().ok());
let point_query_lon = params
.get("lon")
.or_else(|| params.get("LON"))
.or_else(|| params.get("x"))
.or_else(|| params.get("X"))
.and_then(|s| s.parse::<f64>().ok());
let (is_tile_request, is_mvt_request, bbox_override, zoom_override) =
if let Some(ref zm) = zxy_match {
let tile_bbox = crate::mapserver::resolve::tile::tile_to_bbox(zm.z, zm.x, zm.y);
let is_png = zm.ext == "png";
let is_mvt = zm.ext == "mvt" || zm.ext == "pbf";
(is_png, is_mvt, Some(tile_bbox), Some(zm.z))
} else {
(false, false, None, None)
};
let format_val = params.get("format").or_else(|| params.get("FORMAT"));
let is_tile_request = is_tile_request
|| format_val
.map(|f| f.eq_ignore_ascii_case("image/png") || f.eq_ignore_ascii_case("png"))
.unwrap_or(false)
|| params
.get("REQUEST")
.or(params.get("request"))
.map(|r| r.eq_ignore_ascii_case("GetMap"))
.unwrap_or(false);
let is_mvt_request = is_mvt_request
|| format_val
.map(|f| {
f.eq_ignore_ascii_case("mvt")
|| f.eq_ignore_ascii_case("pbf")
|| f.eq_ignore_ascii_case("application/vnd.mapbox-vector-tile")
})
.unwrap_or(false);
let point_query_bbox = if let (Some(lat), Some(lon)) = (point_query_lat, point_query_lon) {
let tolerance: f64 = params
.get("tolerance")
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.001); Some([
lon - tolerance,
lat - tolerance,
lon + tolerance,
lat + tolerance,
])
} else {
None
};
let bbox = if let Some(tb) = bbox_override {
Some(tb)
} else if let Some(pq) = point_query_bbox {
Some(pq)
} else {
match crate::mapserver::infra::http::parse_bbox_param(¶ms) {
Ok(v) => v,
Err(err) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": err})),
)
.into_response();
}
}
};
let limit = match crate::mapserver::infra::http::parse_limit_param(¶ms, max_features) {
Ok(v) => v.min(max_features), Err(err) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": err})),
)
.into_response();
}
};
let tile_limit: usize = if is_tile_request || is_mvt_request {
params
.get("limit")
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(500_000)
.min(500_000)
} else {
limit
};
let zoom = zoom_override.or_else(|| {
params
.get("zoom")
.or_else(|| params.get("ZOOM"))
.and_then(|s| s.parse::<u8>().ok())
});
let style_param = params.get("style").or_else(|| params.get("STYLE")).cloned();
let manifest = crate::mapserver::publish::registry::manifest_from_runtime(
layer_id,
layer_path,
source_kind,
source_path,
mode,
min_zoom,
max_zoom,
bbox_required,
max_features,
allowed_properties,
layer_style_json,
layer_default_filter,
function_slug,
cache_ttl_secs,
);
let filter_param = params.get("filter").cloned();
let effective_filter: Option<String> = filter_param.or_else(|| manifest.filter.clone());
if let Some(ref f) = effective_filter {
if let Err(e) = crate::mapserver::resolve::filter_dsl::parse_filter(f) {
return (
axum::http::StatusCode::BAD_REQUEST,
format!("invalid filter: {e}"),
)
.into_response();
}
}
let request = crate::mapserver::resolve::ResolveRequest {
layer_id: manifest.layer_id.clone(),
bbox,
zoom,
limit: Some(limit),
filter: effective_filter.clone(),
};
let tile_request = crate::mapserver::resolve::ResolveRequest {
layer_id: manifest.layer_id.clone(),
bbox,
zoom,
limit: Some(tile_limit),
filter: effective_filter.clone(),
};
let render_format = if is_mvt_request {
"mvt"
} else if is_tile_request {
"png"
} else {
"geojson"
};
let preloaded_features: Option<serde_json::Value> = if manifest.source_kind
== crate::mapserver::publish::manifest::SourceKind::GeoJsonFunction
{
let slug = manifest.function_slug.as_deref().unwrap_or("");
if slug.is_empty() {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"ok": false, "error": "geojson_function layer has no function_slug"})),
)
.into_response();
}
let cache_key =
crate::mapserver::resolve::function_cache::cache_key(&owner, &project, slug);
if let Some(cached) = crate::mapserver::resolve::function_cache::get_cached(&cache_key) {
Some(cached)
} else {
let fn_input = serde_json::json!({
"layer": {
"id": manifest.layer_id,
"path": manifest.path,
},
"request": {
"bbox": bbox,
"zoom": zoom,
"format": render_format,
"filter": effective_filter,
"limit": limit,
},
"owner": owner,
"project": project,
});
match state
.platform
.execute_function_pipeline(&owner, &project, slug, fn_input)
.await
{
Ok(result) => {
let ttl = manifest.cache_ttl_secs.unwrap_or(60);
crate::mapserver::resolve::function_cache::put_cached(&cache_key, &result, ttl);
Some(result)
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"ok": false, "error": format!("function pipeline error: {}", e.message)})),
)
.into_response();
}
}
}
} else {
None
};
if is_mvt_request {
let tile_bbox = match bbox {
Some(b) => b,
None => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": "bbox is required for MVT requests"})),
)
.into_response();
}
};
let mvt_layer_name = manifest.layer_id.clone();
let mvt_key = crate::mapserver::resolve::tile_cache::tile_cache_key(
&manifest.layer_id,
&tile_bbox,
4096,
4096,
zoom,
&manifest.source_ref,
None,
effective_filter.as_deref(),
"mvt",
);
if let Some(cached_mvt) = crate::mapserver::resolve::tile_cache::get_tile(&mvt_key) {
let mut resp = (StatusCode::OK, cached_mvt).into_response();
resp.headers_mut().insert(
CONTENT_TYPE,
HeaderValue::from_static("application/vnd.mapbox-vector-tile"),
);
resp.headers_mut().insert(
HeaderName::from_static("content-encoding"),
HeaderValue::from_static("gzip"),
);
resp.headers_mut().insert(
HeaderName::from_static("access-control-allow-origin"),
HeaderValue::from_static("*"),
);
return resp;
}
if manifest.source_kind == crate::mapserver::publish::manifest::SourceKind::GeoParquet {
let source_path = std::path::Path::new(&manifest.source_ref);
if source_path.exists() {
let parsed_filter = effective_filter
.as_deref()
.and_then(|f| crate::mapserver::resolve::filter_dsl::parse_filter(f).ok());
if let Ok(mvt_bytes) =
crate::mapserver::resolve::geoparquet_direct::render_mvt_direct(
source_path,
tile_bbox,
&mvt_layer_name,
zoom,
parsed_filter.as_ref(),
&manifest.allowed_properties,
tile_limit,
)
{
crate::mapserver::resolve::tile_cache::put_tile(
mvt_key,
mvt_bytes.clone(),
&manifest.source_ref,
);
let mut resp = (StatusCode::OK, mvt_bytes).into_response();
resp.headers_mut().insert(
CONTENT_TYPE,
HeaderValue::from_static("application/vnd.mapbox-vector-tile"),
);
resp.headers_mut().insert(
HeaderName::from_static("content-encoding"),
HeaderValue::from_static("gzip"),
);
resp.headers_mut().insert(
HeaderName::from_static("access-control-allow-origin"),
HeaderValue::from_static("*"),
);
return resp;
}
}
}
let resolved = match crate::mapserver::resolve::resolve_features(
&manifest,
&tile_request,
preloaded_features.as_ref(),
) {
Ok(v) => v,
Err(err) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": err})),
)
.into_response();
}
};
let mvt_bytes = crate::mapserver::resolve::mvt::features_to_mvt_gz(
&mvt_layer_name,
&resolved.features,
&tile_bbox,
&manifest.allowed_properties,
);
crate::mapserver::resolve::tile_cache::put_tile(
mvt_key,
mvt_bytes.clone(),
&manifest.source_ref,
);
let mut resp = (StatusCode::OK, mvt_bytes).into_response();
resp.headers_mut().insert(
CONTENT_TYPE,
HeaderValue::from_static("application/vnd.mapbox-vector-tile"),
);
resp.headers_mut().insert(
HeaderName::from_static("content-encoding"),
HeaderValue::from_static("gzip"),
);
resp.headers_mut().insert(
HeaderName::from_static("access-control-allow-origin"),
HeaderValue::from_static("*"),
);
return resp;
}
if is_tile_request {
let tile_bbox = match bbox {
Some(b) => b,
None => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": "bbox is required for tile requests"})),
)
.into_response();
}
};
let tile_width = params
.get("width")
.or_else(|| params.get("WIDTH"))
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(256)
.min(1024)
.max(1);
let tile_height = params
.get("height")
.or_else(|| params.get("HEIGHT"))
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(256)
.min(1024)
.max(1);
let style_dsl_str: Option<String> = style_param.clone().or_else(|| {
manifest
.style
.as_ref()
.and_then(|v| v.as_str().map(|s| s.to_string()))
});
let style_config: Option<crate::mapserver::resolve::style::LayerStyleConfig> =
if style_dsl_str.is_none() {
manifest
.style
.as_ref()
.and_then(|v| serde_json::from_value(v.clone()).ok())
} else {
None
};
let style =
crate::mapserver::resolve::style::resolve_layer_style(style_config.as_ref(), zoom);
let resolved_style: Option<crate::mapserver::resolve::style_dsl::ResolvedStyle> =
style_dsl_str.as_deref().and_then(|dsl| {
let def = crate::mapserver::resolve::style_dsl::parse_style_dsl(dsl).ok()?;
let source_path = std::path::Path::new(&manifest.source_ref);
let stats = if def.needs_stats() {
def.field_name().and_then(|fname| {
crate::mapserver::resolve::geoparquet_direct::get_field_stats(
source_path,
fname,
)
})
} else {
None
};
let distinct = if def.needs_distinct() {
def.field_name().and_then(|fname| {
crate::mapserver::resolve::geoparquet_direct::get_field_distinct(
source_path,
fname,
50,
)
})
} else {
None
};
crate::mapserver::resolve::style_dsl::resolve_style(
&def,
stats.as_ref(),
distinct.as_ref(),
zoom,
)
.ok()
});
let style =
if let Some(crate::mapserver::resolve::style_dsl::ResolvedStyle::Uniform(ref fs)) =
resolved_style
{
crate::mapserver::resolve::style::LayerStyle {
fill_color: fs.fill_color,
stroke_color: fs.stroke_color,
stroke_width: fs.stroke_width,
point_radius: fs.point_radius,
point_color: fs.point_color,
}
} else {
style
};
let tile_key = crate::mapserver::resolve::tile_cache::tile_cache_key(
&manifest.layer_id,
&tile_bbox,
tile_width,
tile_height,
zoom,
&manifest.source_ref,
style_dsl_str.as_deref(),
effective_filter.as_deref(),
"png",
);
if let Some(cached_png) = crate::mapserver::resolve::tile_cache::get_tile(&tile_key) {
let mut resp = (StatusCode::OK, cached_png).into_response();
resp.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static("image/png"));
resp.headers_mut().insert(
HeaderName::from_static("access-control-allow-origin"),
HeaderValue::from_static("*"),
);
return resp;
}
if manifest.source_kind == crate::mapserver::publish::manifest::SourceKind::GeoParquet {
let source_path = std::path::Path::new(&manifest.source_ref);
if source_path.exists() {
let parsed_filter = effective_filter
.as_deref()
.and_then(|f| crate::mapserver::resolve::filter_dsl::parse_filter(f).ok());
if let Ok(png_bytes) =
crate::mapserver::resolve::geoparquet_direct::render_tile_direct(
source_path,
tile_bbox,
tile_width,
tile_height,
zoom,
&style,
resolved_style.as_ref(),
parsed_filter.as_ref(),
tile_limit,
)
{
crate::mapserver::resolve::tile_cache::put_tile(
tile_key,
png_bytes.clone(),
&manifest.source_ref,
);
let mut resp = (StatusCode::OK, png_bytes).into_response();
resp.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static("image/png"));
resp.headers_mut().insert(
HeaderName::from_static("access-control-allow-origin"),
HeaderValue::from_static("*"),
);
return resp;
}
}
}
let use_metatile =
zoom.map(|z| z >= 8).unwrap_or(false) && tile_width == 256 && tile_height == 256;
if use_metatile {
const META_GRID: u32 = 4;
let meta_info =
crate::mapserver::resolve::tile::compute_metatile_info(&tile_bbox, META_GRID);
let meta_key = crate::mapserver::resolve::tile_cache::metatile_cache_key(
&manifest.layer_id,
&meta_info.bbox,
zoom,
&manifest.source_ref,
style_dsl_str.as_deref(),
effective_filter.as_deref(),
);
if let Some(png_bytes) = crate::mapserver::resolve::tile_cache::get_metatile_tile(
&meta_key,
&tile_bbox,
tile_width,
tile_height,
) {
crate::mapserver::resolve::tile_cache::put_tile(
tile_key,
png_bytes.clone(),
&manifest.source_ref,
);
let mut resp = (StatusCode::OK, png_bytes).into_response();
resp.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static("image/png"));
resp.headers_mut().insert(
HeaderName::from_static("access-control-allow-origin"),
HeaderValue::from_static("*"),
);
return resp;
}
if crate::mapserver::resolve::tile_cache::try_claim_metatile(&meta_key) {
let bg_manifest = manifest.clone();
let bg_style = style.clone();
let bg_resolved_style = resolved_style.clone();
let bg_meta_key = meta_key.clone();
let bg_meta_bbox = meta_info.bbox;
let bg_filter = effective_filter.clone();
let bg_preloaded = preloaded_features.clone();
tokio::task::spawn_blocking(move || {
std::thread::sleep(std::time::Duration::from_millis(500));
let meta_request = crate::mapserver::resolve::ResolveRequest {
layer_id: bg_manifest.layer_id.clone(),
bbox: Some(bg_meta_bbox),
zoom,
limit: Some(tile_limit),
filter: bg_filter,
};
let meta_resolved = match crate::mapserver::resolve::resolve_features(
&bg_manifest,
&meta_request,
bg_preloaded.as_ref(),
) {
Ok(v) => v,
Err(_) => {
crate::mapserver::resolve::tile_cache::release_metatile(&bg_meta_key);
return;
}
};
let meta_tile_req = crate::mapserver::resolve::tile::TileRenderRequest {
bbox: bg_meta_bbox,
width: tile_width * META_GRID,
height: tile_height * META_GRID,
zoom,
};
if let Ok(pm) = crate::mapserver::resolve::tile::render_features_to_pixmap(
&meta_resolved.features,
&meta_tile_req,
&bg_style,
bg_resolved_style.as_ref(),
) {
crate::mapserver::resolve::tile_cache::put_metatile_pixmap(
bg_meta_key.clone(),
pm.data().to_vec(),
pm.width(),
pm.height(),
bg_meta_bbox,
);
}
crate::mapserver::resolve::tile_cache::release_metatile(&bg_meta_key);
});
}
}
let resolved = match crate::mapserver::resolve::resolve_features(
&manifest,
&tile_request,
preloaded_features.as_ref(),
) {
Ok(v) => v,
Err(err) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": err})),
)
.into_response();
}
};
let tile_request = crate::mapserver::resolve::tile::TileRenderRequest {
bbox: tile_bbox,
width: tile_width,
height: tile_height,
zoom,
};
let png_bytes = match crate::mapserver::resolve::tile::render_features_to_png(
&resolved.features,
&tile_request,
&style,
resolved_style.as_ref(),
) {
Ok(bytes) => bytes,
Err(err) => {
return internal_error(PlatformError::new(
"MAPSERVER_TILE_RENDER",
format!("tile rendering failed: {err}"),
));
}
};
crate::mapserver::resolve::tile_cache::put_tile(
tile_key,
png_bytes.clone(),
&manifest.source_ref,
);
let mut resp = (StatusCode::OK, png_bytes).into_response();
resp.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static("image/png"));
resp.headers_mut().insert(
HeaderName::from_static("access-control-allow-origin"),
HeaderValue::from_static("*"),
);
return resp;
}
if let (Some(lat), Some(lon)) = (point_query_lat, point_query_lon) {
let pq_limit = params
.get("limit")
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(10)
.min(100);
let tolerance: f64 = params
.get("tolerance")
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.001);
let pq_request = crate::mapserver::resolve::ResolveRequest {
layer_id: manifest.layer_id.clone(),
bbox: Some([
lon - tolerance,
lat - tolerance,
lon + tolerance,
lat + tolerance,
]),
zoom: None,
limit: Some(pq_limit),
filter: effective_filter.clone(),
};
let resolved = match crate::mapserver::resolve::resolve_features(
&manifest,
&pq_request,
preloaded_features.as_ref(),
) {
Ok(v) => v,
Err(err) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": err})),
)
.into_response();
}
};
let payload = json!({
"type": "FeatureCollection",
"layer": resolved.layer,
"count": resolved.count,
"truncated": resolved.truncated,
"query": {
"lat": lat,
"lon": lon,
"tolerance": tolerance,
},
"features": resolved.features
});
let body = match serde_json::to_vec(&payload) {
Ok(body) => body,
Err(err) => {
return internal_error(PlatformError::new(
"MAPSERVER_RESPONSE_SERIALIZE",
format!("failed serializing point query payload: {err}"),
));
}
};
let mut resp = (StatusCode::OK, body).into_response();
resp.headers_mut().insert(
CONTENT_TYPE,
HeaderValue::from_static("application/geo+json; charset=utf-8"),
);
resp.headers_mut().insert(
HeaderName::from_static("access-control-allow-origin"),
HeaderValue::from_static("*"),
);
return resp;
}
let response_cache_key =
crate::mapserver::resolve::cache::response_cache_key(&manifest, &request);
if let Some(body) = crate::mapserver::resolve::cache::get_response_bytes(&response_cache_key) {
let mut resp = (StatusCode::OK, body).into_response();
resp.headers_mut().insert(
CONTENT_TYPE,
HeaderValue::from_static("application/geo+json; charset=utf-8"),
);
resp.headers_mut().insert(
HeaderName::from_static("access-control-allow-origin"),
HeaderValue::from_static("*"),
);
return resp;
}
let resolved = match crate::mapserver::resolve::resolve_features(
&manifest,
&request,
preloaded_features.as_ref(),
) {
Ok(v) => v,
Err(err) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": err})),
)
.into_response();
}
};
let payload = json!({
"type": "FeatureCollection",
"layer": resolved.layer,
"count": resolved.count,
"truncated": resolved.truncated,
"features": resolved.features
});
let body = match serde_json::to_vec(&payload) {
Ok(body) => body,
Err(err) => {
return internal_error(PlatformError::new(
"MAPSERVER_RESPONSE_SERIALIZE",
format!("failed serializing mapserver payload: {err}"),
));
}
};
crate::mapserver::resolve::cache::put_response_bytes(
response_cache_key,
body.clone(),
&manifest.source_ref,
);
let mut resp = (StatusCode::OK, body).into_response();
resp.headers_mut().insert(
CONTENT_TYPE,
HeaderValue::from_static("application/geo+json; charset=utf-8"),
);
resp.headers_mut().insert(
HeaderName::from_static("access-control-allow-origin"),
HeaderValue::from_static("*"),
);
resp
}
async fn public_mapserver_ingress_root(
State(state): State<PlatformAppState>,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
) -> Response {
public_mapserver_ingress(State(state), Path((owner, project, String::new())), uri).await
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct MapserverLayerRecord {
layer_id: String,
path: String,
source_path: String,
#[serde(default)]
source_kind: String,
#[serde(default)]
artifact_manifest_path: Option<String>,
mode: String,
#[serde(default)]
min_zoom: Option<u8>,
#[serde(default)]
max_zoom: Option<u8>,
bbox_required: bool,
max_features: usize,
allowed_properties: Vec<String>,
#[serde(default)]
feature_count: Option<usize>,
#[serde(default)]
chunk_count: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
style: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
filter: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
function_slug: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
cache_ttl_secs: Option<u64>,
}
fn mapserver_layers_manifest_path(
state: &PlatformAppState,
owner: &str,
project: &str,
instance: &str,
) -> Result<std::path::PathBuf, PlatformError> {
let layout = state.platform.file.ensure_project_layout(owner, project)?;
Ok(layout
.files_dir
.join("mapserver")
.join(format!("{instance}.layers.json")))
}
fn mapserver_artifacts_root(
state: &PlatformAppState,
owner: &str,
project: &str,
instance: &str,
) -> Result<std::path::PathBuf, PlatformError> {
let layout = state.platform.file.ensure_project_layout(owner, project)?;
Ok(layout
.files_dir
.join("mapserver")
.join(".artifacts")
.join(instance))
}
fn read_mapserver_layers(
state: &PlatformAppState,
owner: &str,
project: &str,
instance: &str,
) -> Result<Vec<MapserverLayerRecord>, PlatformError> {
let path = mapserver_layers_manifest_path(state, owner, project, instance)?;
if !path.exists() {
return Ok(Vec::new());
}
let raw = std::fs::read_to_string(&path)
.map_err(|err| PlatformError::new("MAPSERVER_READ", err.to_string()))?;
serde_json::from_str::<Vec<MapserverLayerRecord>>(&raw)
.map_err(|err| PlatformError::new("MAPSERVER_PARSE", err.to_string()))
}
fn write_mapserver_layers(
state: &PlatformAppState,
owner: &str,
project: &str,
instance: &str,
items: &[MapserverLayerRecord],
) -> Result<(), PlatformError> {
let path = mapserver_layers_manifest_path(state, owner, project, instance)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|err| PlatformError::new("MAPSERVER_WRITE", err.to_string()))?;
}
let raw = serde_json::to_string_pretty(items)
.map_err(|err| PlatformError::new("MAPSERVER_WRITE", err.to_string()))?;
std::fs::write(path, raw).map_err(|err| PlatformError::new("MAPSERVER_WRITE", err.to_string()))
}
fn list_mapserver_source_files(
state: &PlatformAppState,
owner: &str,
project: &str,
) -> Result<Vec<serde_json::Value>, PlatformError> {
let layout = state.platform.file.ensure_project_layout(owner, project)?;
let dir = layout.files_dir.join("mapserver");
std::fs::create_dir_all(&dir)
.map_err(|err| PlatformError::new("MAPSERVER_LIST", err.to_string()))?;
let mut out = Vec::new();
if let Ok(entries) = std::fs::read_dir(&dir) {
let mut entries: Vec<_> = entries.flatten().collect();
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let path = entry.path();
if !path.is_file() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
if name.ends_with(".layers.json") {
continue;
}
let lower = name.to_ascii_lowercase();
if !(lower.ends_with(".geojson")
|| lower.ends_with(".json")
|| lower.ends_with(".parquet"))
{
continue;
}
let rel = format!("mapserver/{name}");
let meta = path.metadata().ok();
out.push(json!({
"name": name,
"path": rel,
"size": meta.as_ref().map(|m| m.len()).unwrap_or(0),
"url": format!("/fs/{owner}/{project}/{rel}")
}));
}
}
Ok(out)
}
fn resolve_mapserver_manifest_for_path(
state: &PlatformAppState,
owner: &str,
project: &str,
path: &str,
) -> Result<Option<crate::mapserver::publish::manifest::PublishedLayerManifest>, PlatformError> {
let layers = read_mapserver_layers(state, owner, project, "default-mapserver")?;
let normalized = path.trim_start_matches('/').trim_end_matches('/');
let layer = layers.into_iter().find(|item| item.path == normalized);
let layout = state.platform.file.ensure_project_layout(owner, project)?;
Ok(layer.map(|item| {
let (source_kind, source_ref) =
if let Some(artifact_rel) = item.artifact_manifest_path.clone() {
(
crate::mapserver::publish::manifest::SourceKind::GeoJsonArtifact,
layout
.files_dir
.join(artifact_rel.trim_start_matches('/'))
.display()
.to_string(),
)
} else if item.source_kind == "geoparquet" {
(
crate::mapserver::publish::manifest::SourceKind::GeoParquet,
layout
.files_dir
.join(item.source_path.trim_start_matches('/'))
.display()
.to_string(),
)
} else if item.source_kind == "geojson_function" {
(
crate::mapserver::publish::manifest::SourceKind::GeoJsonFunction,
String::new(),
)
} else {
(
crate::mapserver::publish::manifest::SourceKind::GeoJsonFile,
layout
.files_dir
.join(item.source_path.trim_start_matches('/'))
.display()
.to_string(),
)
};
crate::mapserver::publish::registry::manifest_from_runtime(
item.layer_id,
item.path,
source_kind,
source_ref,
item.mode,
item.min_zoom,
item.max_zoom,
item.bbox_required,
item.max_features,
item.allowed_properties,
item.style,
item.filter,
item.function_slug,
item.cache_ttl_secs,
)
}))
}
fn safe_headers(headers: &HeaderMap) -> Value {
const SAFE: &[&str] = &[
"content-type",
"accept",
"user-agent",
"x-forwarded-for",
"x-real-ip",
"referer",
"origin",
"sec-fetch-mode",
"sec-fetch-dest",
];
let map: serde_json::Map<String, Value> = SAFE
.iter()
.filter_map(|k| {
headers
.get(*k)
.and_then(|v| v.to_str().ok())
.map(|v| (k.to_string(), Value::String(v.to_string())))
})
.collect();
Value::Object(map)
}
fn build_structured_payload(
body: Value,
query: &Value,
params: &Value,
path: &str,
method: &str,
files: Option<Value>,
) -> Value {
let mut obj = serde_json::Map::new();
obj.insert("body".to_string(), body);
obj.insert("query".to_string(), query.clone());
obj.insert("params".to_string(), params.clone());
obj.insert("path".to_string(), Value::String(path.to_string()));
obj.insert("method".to_string(), Value::String(method.to_string()));
if let Some(f) = files {
obj.insert("files".to_string(), f);
}
Value::Object(obj)
}
async fn build_webhook_ingress_input(
platform: &Arc<PlatformService>,
owner: &str,
project: &str,
request_id: &str,
method: &Method,
uri: &Uri,
headers: &HeaderMap,
body: &Bytes,
path: &str,
path_params: &serde_json::Map<String, Value>,
) -> Result<Value, crate::pipeline::PipelineError> {
let query = parse_query_to_json(uri.query());
let params = Value::Object(path_params.clone());
let method_str = method.as_str();
if method == Method::GET && body.is_empty() {
return Ok(build_structured_payload(
Value::Null,
&query,
¶ms,
path,
method_str,
None,
));
}
if body.is_empty() {
return Ok(build_structured_payload(
Value::Null,
&query,
¶ms,
path,
method_str,
None,
));
}
let content_type = headers
.get(CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let content_type_lc = content_type.to_ascii_lowercase();
if content_type_lc.contains("application/json")
&& let Ok(parsed) = serde_json::from_slice::<Value>(body)
{
return Ok(build_structured_payload(
parsed, &query, ¶ms, path, method_str, None,
));
}
if content_type_lc.contains("application/x-www-form-urlencoded") {
if let Ok(fields) = serde_urlencoded::from_bytes::<Vec<(String, String)>>(body) {
let mut form_obj = serde_json::Map::new();
for (k, v) in fields {
form_obj.insert(k, Value::String(v));
}
return Ok(build_structured_payload(
Value::Object(form_obj),
&query,
¶ms,
path,
method_str,
None,
));
}
}
if content_type_lc.contains("multipart/form-data") {
if let Ok(boundary) = multer::parse_boundary(&content_type) {
let body_clone = body.clone();
let stream = futures::stream::once(async move { Ok::<_, std::io::Error>(body_clone) });
let mut mp = multer::Multipart::new(stream, boundary);
let mut text_fields = serde_json::Map::new();
let mut files = serde_json::Map::new();
while let Ok(Some(mut field)) = mp.next_field().await {
let name = field.name().unwrap_or("").to_string();
let filename = field.file_name().map(|s| s.to_string());
let field_ct = field.content_type().map(|ct| ct.to_string());
let mut data: Vec<u8> = Vec::new();
while let Ok(Some(chunk)) = field.chunk().await {
data.extend_from_slice(&chunk);
}
if let Some(filename) = filename {
let mime = field_ct
.as_deref()
.filter(|value| !value.trim().is_empty())
.unwrap_or("application/octet-stream");
let file_ref = crate::pipeline::nodes::basic::file_ref::write_tmp_file_ref(
platform,
crate::pipeline::nodes::basic::file_ref::FileRefInput {
owner,
project,
request_id,
bytes: &data,
filename: Some(&filename),
mime: Some(mime),
origin: "webhook",
trust: "untrusted",
},
)?;
insert_multipart_value(&mut files, &name, file_ref);
} else {
let text = String::from_utf8_lossy(&data).to_string();
insert_multipart_value(&mut text_fields, &name, Value::String(text));
}
}
let files_val = if files.is_empty() {
None
} else {
Some(Value::Object(files))
};
return Ok(build_structured_payload(
Value::Object(text_fields),
&query,
¶ms,
path,
method_str,
files_val,
));
}
}
let body_text = String::from_utf8_lossy(body).to_string();
Ok(build_structured_payload(
Value::String(body_text),
&query,
¶ms,
path,
method_str,
None,
))
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct MultipartValueKey {
base: String,
index: Option<usize>,
force_array: bool,
}
fn parse_multipart_value_key(raw: &str) -> MultipartValueKey {
let name = raw.trim();
if let Some(base) = name.strip_suffix("[]").filter(|base| !base.is_empty()) {
return MultipartValueKey {
base: base.to_string(),
index: None,
force_array: true,
};
}
if let Some(open) = name.rfind('[')
&& name.ends_with(']')
&& open > 0
{
let inner = &name[open + 1..name.len() - 1];
if !inner.is_empty()
&& inner.chars().all(|ch| ch.is_ascii_digit())
&& let Ok(index) = inner.parse::<usize>()
{
return MultipartValueKey {
base: name[..open].to_string(),
index: Some(index),
force_array: true,
};
}
}
MultipartValueKey {
base: name.to_string(),
index: None,
force_array: false,
}
}
fn insert_multipart_value(map: &mut serde_json::Map<String, Value>, raw_name: &str, value: Value) {
let key = parse_multipart_value_key(raw_name);
if key.base.is_empty() {
return;
}
if let Some(index) = key.index {
let entry = map
.entry(key.base)
.or_insert_with(|| Value::Array(Vec::new()));
if !entry.is_array() {
let existing = std::mem::take(entry);
*entry = Value::Array(vec![existing]);
}
if let Some(items) = entry.as_array_mut() {
if items.len() <= index {
items.resize(index + 1, Value::Null);
}
items[index] = value;
}
return;
}
if key.force_array {
let entry = map
.entry(key.base)
.or_insert_with(|| Value::Array(Vec::new()));
if !entry.is_array() {
let existing = std::mem::take(entry);
*entry = Value::Array(vec![existing]);
}
if let Some(items) = entry.as_array_mut() {
items.push(value);
}
return;
}
match map.get_mut(&key.base) {
Some(existing) if existing.is_array() => {
if let Some(items) = existing.as_array_mut() {
items.push(value);
}
}
Some(existing) => {
let first = std::mem::take(existing);
*existing = Value::Array(vec![first, value]);
}
None => {
map.insert(key.base, value);
}
}
}
#[cfg(test)]
mod webhook_ingress_tests {
use serde_json::{Map, json};
use super::{insert_multipart_value, parse_multipart_value_key};
#[test]
fn multipart_repeated_plain_fields_become_arrays() {
let mut values = Map::new();
insert_multipart_value(&mut values, "photos", json!("a"));
insert_multipart_value(&mut values, "photos", json!("b"));
assert_eq!(values.get("photos"), Some(&json!(["a", "b"])));
}
#[test]
fn multipart_bracket_fields_become_arrays() {
let mut values = Map::new();
insert_multipart_value(&mut values, "photos[]", json!("a"));
insert_multipart_value(&mut values, "photos[]", json!("b"));
assert_eq!(values.get("photos"), Some(&json!(["a", "b"])));
}
#[test]
fn multipart_indexed_fields_keep_index_order() {
let mut values = Map::new();
insert_multipart_value(&mut values, "photos[1]", json!("b"));
insert_multipart_value(&mut values, "photos[0]", json!("a"));
assert_eq!(values.get("photos"), Some(&json!(["a", "b"])));
}
#[test]
fn multipart_non_numeric_brackets_remain_literal() {
let key = parse_multipart_value_key("meta[name]");
assert_eq!(key.base, "meta[name]");
assert_eq!(key.index, None);
assert!(!key.force_array);
}
}
fn parse_query_to_json(raw_query: Option<&str>) -> Value {
let mut map = serde_json::Map::new();
let Some(raw_query) = raw_query else {
return Value::Object(map);
};
for pair in raw_query.split('&') {
if pair.is_empty() {
continue;
}
let mut split = pair.splitn(2, '=');
let key = split.next().unwrap_or_default().trim();
if key.is_empty() {
continue;
}
let value = split.next().unwrap_or_default().trim();
map.insert(key.to_string(), Value::String(value.to_string()));
}
Value::Object(map)
}
fn quote_sql_identifier_path(raw: &str) -> String {
raw.split('.')
.filter(|part| !part.trim().is_empty())
.map(|part| format!("\"{}\"", part.trim().replace('\"', "\"\"")))
.collect::<Vec<_>>()
.join(".")
}
fn build_table_preview_sql(
database_kind: &str,
raw_table: &str,
bare_table: &str,
limit: usize,
) -> String {
match database_kind {
"sekejap" => format!("SELECT * FROM {} LIMIT {}", bare_table.trim(), limit),
_ => format!(
"SELECT * FROM {} LIMIT {}",
quote_sql_identifier_path(raw_table),
limit
),
}
}
#[cfg(test)]
mod preview_sql_tests {
use std::path::Path;
use super::{
build_table_preview_sql, content_type_for_path, file_content_disposition,
query_flag_enabled,
};
#[test]
fn builds_unquoted_preview_sql_for_sekejap() {
assert_eq!(
build_table_preview_sql("sekejap", "default.posts", "posts", 120),
"SELECT * FROM posts LIMIT 120"
);
}
#[test]
fn builds_quoted_preview_sql_for_sql_backends() {
assert_eq!(
build_table_preview_sql("postgresql", "public.posts", "posts", 50),
"SELECT * FROM \"public\".\"posts\" LIMIT 50"
);
}
#[test]
fn serves_geojson_with_inline_preview_headers_by_default() {
let path = Path::new("public/v2x-map/roads.geojson");
let content_type = content_type_for_path(path);
assert_eq!(content_type, "application/geo+json; charset=utf-8");
let disposition =
file_content_disposition(path, content_type, false).expect("inline disposition");
assert_eq!(
disposition.to_str().unwrap(),
"inline; filename=\"roads.geojson\""
);
}
#[test]
fn supports_explicit_download_flag_for_known_file_types() {
let path = Path::new("public/v2x-map/roads.geojson");
let content_type = content_type_for_path(path);
let disposition =
file_content_disposition(path, content_type, true).expect("attachment disposition");
assert_eq!(
disposition.to_str().unwrap(),
"attachment; filename=\"roads.geojson\""
);
assert!(query_flag_enabled(Some("1")));
assert!(query_flag_enabled(Some("true")));
assert!(query_flag_enabled(Some("yes")));
assert!(!query_flag_enabled(Some("0")));
assert!(!query_flag_enabled(None));
}
}
#[derive(Debug)]
struct WebhookPathMatch {
params: serde_json::Map<String, Value>,
static_segments: usize,
dynamic_segments: usize,
total_segments: usize,
}
fn match_webhook_path(pattern: &str, actual: &str) -> Option<WebhookPathMatch> {
let normalized_pattern = normalize_webhook_path(pattern);
let normalized_actual = normalize_webhook_path(actual);
let pattern_segments = split_webhook_segments(&normalized_pattern);
let actual_segments = split_webhook_segments(&normalized_actual);
if pattern_segments.len() != actual_segments.len() {
return None;
}
let mut params = serde_json::Map::new();
let mut static_segments = 0usize;
let mut dynamic_segments = 0usize;
for (pattern_seg, actual_seg) in pattern_segments.iter().zip(actual_segments.iter()) {
if let Some(name) = path_param_name(pattern_seg) {
dynamic_segments += 1;
params.insert(name.to_string(), Value::String((*actual_seg).to_string()));
continue;
}
if pattern_seg == actual_seg {
static_segments += 1;
continue;
}
return None;
}
Some(WebhookPathMatch {
params,
static_segments,
dynamic_segments,
total_segments: pattern_segments.len(),
})
}
fn normalize_webhook_path(raw: &str) -> String {
let raw = raw.trim();
if raw.is_empty() || raw == "/" {
return "/".to_string();
}
let mut out = String::from("/");
out.push_str(raw.trim_matches('/'));
out
}
fn split_webhook_segments(path: &str) -> Vec<&str> {
if path == "/" {
return Vec::new();
}
path.trim_start_matches('/')
.split('/')
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>()
}
fn path_param_name(segment: &str) -> Option<&str> {
if let Some(inner) = segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
let name = inner.trim();
if !name.is_empty() && !name.contains('/') && !name.contains('{') && !name.contains('}') {
return Some(name);
}
}
if let Some(name) = segment.strip_prefix(':') {
let name = name.trim();
if !name.is_empty() && !name.contains('/') && !name.contains(':') {
return Some(name);
}
}
None
}
fn pipeline_path_matches(base_path: &str, candidate_path: &str, recursive: bool) -> bool {
let base = crate::platform::model::normalize_virtual_path(base_path);
let candidate = crate::platform::model::normalize_virtual_path(candidate_path);
if !recursive {
return candidate == base;
}
if base == "/" {
return true;
}
candidate == base || candidate.starts_with(&(base + "/"))
}
fn pipeline_source_is_locked(source: &str) -> bool {
let Ok(value) = serde_json::from_str::<Value>(source) else {
return false;
};
value
.get("metadata")
.and_then(|metadata| metadata.get("locked"))
.and_then(Value::as_bool)
.or_else(|| value.get("locked").and_then(Value::as_bool))
.unwrap_or(false)
}
#[derive(Debug, Clone, Copy)]
struct EffectivePipelineInvocationRetention {
max_invocations: usize,
max_age_secs: Option<i64>,
}
fn resolve_invocation_retention(
project_cfg: &crate::platform::model::ZebflowJson,
graph: Option<&crate::pipeline::PipelineGraph>,
) -> EffectivePipelineInvocationRetention {
let project_max_invocations = project_cfg
.configs
.pipelines
.logging
.effective_max_invocations();
let pipeline_retention = graph
.and_then(|graph| graph.metadata.as_ref())
.and_then(|metadata| metadata.settings.invocation_retention.as_ref());
let max_invocations = pipeline_retention
.and_then(|retention| retention.max_invocations)
.map(|value| value.max(1) as usize)
.unwrap_or(project_max_invocations);
let max_age_secs = pipeline_retention
.and_then(|retention| retention.max_age_secs)
.map(|value| value.max(1) as i64);
EffectivePipelineInvocationRetention {
max_invocations,
max_age_secs,
}
}
fn resolve_pipeline_registry_scope(
query: &PipelineRegistryQuery,
) -> Result<PipelineRegistryScope, Response> {
match query
.scope
.as_deref()
.map(str::trim)
.filter(|raw| !raw.is_empty())
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("path") => Ok(PipelineRegistryScope::Path),
Some("project") => Ok(PipelineRegistryScope::Project),
Some(_) => Err((
StatusCode::BAD_REQUEST,
Json(json!({
"ok": false,
"error": {
"code": "PLATFORM_PIPELINE_REGISTRY_SCOPE_INVALID",
"message": "query.scope must be 'path' or 'project'"
}
})),
)
.into_response()),
None => {
if query.path.is_some() {
Ok(PipelineRegistryScope::Path)
} else {
Ok(PipelineRegistryScope::Project)
}
}
}
}
fn random_session_token() -> String {
let mut bytes = [0u8; 32];
rand::rng().fill(&mut bytes);
hex::encode(bytes)
}
fn issue_session(state: &PlatformAppState, owner: &str) -> String {
let token = random_session_token();
let expires_at = crate::platform::model::now_ts() + SESSION_TTL_SECS;
if let Ok(mut sessions) = state.sessions.lock() {
sessions.insert(
token.clone(),
PlatformWebSession {
owner: owner.to_string(),
expires_at,
},
);
}
token
}
fn session_cookie_header(token: &str, max_age: i64) -> String {
let secure = match std::env::var("ZEBFLOW_COOKIE_SECURE") {
Ok(value) => matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
),
Err(_) => !matches!(
std::env::var("ZEBFLOW_PLATFORM_HOST")
.unwrap_or_else(|_| "127.0.0.1".to_string())
.as_str(),
"127.0.0.1" | "localhost" | "::1"
),
};
let secure_attr = if secure { "; Secure" } else { "" };
format!(
"{SESSION_COOKIE_NAME}={token}; Path=/; HttpOnly; SameSite=Strict; Max-Age={max_age}{secure_attr}",
)
}
fn session_token(headers: &HeaderMap) -> Option<String> {
let cookie = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
cookie.split(';').map(str::trim).find_map(|part| {
part.strip_prefix(&format!("{SESSION_COOKIE_NAME}="))
.map(ToString::to_string)
})
}
fn session_owner(state: &PlatformAppState, headers: &HeaderMap) -> Option<String> {
let token = session_token(headers)?;
let now = crate::platform::model::now_ts();
let owner = {
let mut sessions = state.sessions.lock().ok()?;
let Some(session) = sessions.get(&token) else {
return None;
};
if session.expires_at <= now {
sessions.remove(&token);
return None;
}
session.owner.clone()
};
if state
.platform
.users
.get_user(&owner)
.ok()
.flatten()
.is_none()
{
if let Ok(mut sessions) = state.sessions.lock() {
sessions.remove(&token);
}
return None;
}
Some(owner)
}
fn require_project_page_capability(
state: &PlatformAppState,
headers: &HeaderMap,
owner: &str,
project: &str,
capability: ProjectCapability,
) -> Result<ProjectAccessSubject, Response> {
if has_valid_cluster_token(state, headers) {
return Ok(ProjectAccessSubject::user(owner));
}
let Some(session_owner) = session_owner(state, headers) else {
return Err(Redirect::to(LOGIN_PATH).into_response());
};
let subject = ProjectAccessSubject::user(&session_owner);
match state
.platform
.authz
.ensure_project_capability(&subject, owner, project, capability)
{
Ok(()) => Ok(subject),
Err(err) if err.code == "PLATFORM_PROJECT_MISSING" => {
Err((StatusCode::NOT_FOUND, Html("project not found".to_string())).into_response())
}
Err(err) if err.code == "PLATFORM_AUTHZ_FORBIDDEN" => {
Err((StatusCode::FORBIDDEN, Html("forbidden".to_string())).into_response())
}
Err(err) => Err(internal_error(err)),
}
}
fn require_project_api_capability(
state: &PlatformAppState,
headers: &HeaderMap,
owner: &str,
project: &str,
capability: ProjectCapability,
) -> Result<ProjectAccessSubject, Response> {
if has_valid_cluster_token(state, headers) {
return Ok(ProjectAccessSubject::user(owner));
}
let Some(session_owner) = session_owner(state, headers) else {
return Err(StatusCode::UNAUTHORIZED.into_response());
};
let subject = ProjectAccessSubject::user(&session_owner);
match state
.platform
.authz
.ensure_project_capability(&subject, owner, project, capability)
{
Ok(()) => Ok(subject),
Err(err) if err.code == "PLATFORM_PROJECT_MISSING" => {
Err(StatusCode::NOT_FOUND.into_response())
}
Err(err) if err.code == "PLATFORM_AUTHZ_FORBIDDEN" => {
Err(StatusCode::FORBIDDEN.into_response())
}
Err(err) => Err(internal_error(err)),
}
}
fn canonical_pipeline_node_kind(kind: &str) -> &str {
if let Some(stripped) = kind.strip_prefix("x.n.") {
return match stripped {
"trigger.webhook" => "n.trigger.webhook",
"trigger.schedule" => "n.trigger.schedule",
"trigger.manual" => "n.trigger.manual",
_ => kind,
};
}
kind
}
fn validate_execute_trigger(
graph: &PipelineGraph,
req: &ExecutePipelineRequest,
) -> Result<(), String> {
match req.trigger {
PipelineExecuteTrigger::Webhook => {
let wanted_path = req.webhook_path.as_deref().unwrap_or("/").trim();
let wanted_method = req
.webhook_method
.as_deref()
.unwrap_or("POST")
.trim()
.to_uppercase();
let matched = crate::platform::services::project::webhook_triggers_from_graph(graph)
.iter()
.any(|trigger| trigger.path == wanted_path && trigger.method == wanted_method);
if matched {
Ok(())
} else {
Err(format!(
"no webhook trigger matched path='{}' method='{}'",
wanted_path, wanted_method
))
}
}
PipelineExecuteTrigger::Schedule => {
let wanted_cron = req.schedule_cron.as_deref().map(str::trim);
let matched = graph.nodes.iter().any(|node| {
if canonical_pipeline_node_kind(&node.kind) != "n.trigger.schedule" {
return false;
}
match wanted_cron {
Some(cron) => {
node.config
.get("cron")
.and_then(Value::as_str)
.map(str::trim)
== Some(cron)
}
None => true,
}
});
if matched {
Ok(())
} else if let Some(cron) = wanted_cron {
Err(format!("no schedule trigger matched cron='{}'", cron))
} else {
Err("pipeline has no schedule trigger".to_string())
}
}
PipelineExecuteTrigger::Manual => {
let matched = graph
.nodes
.iter()
.any(|node| canonical_pipeline_node_kind(&node.kind) == "n.trigger.manual");
if matched {
Ok(())
} else {
Err("pipeline has no manual trigger".to_string())
}
}
}
}
fn hydrate_template_markup(
state: &PlatformAppState,
owner: &str,
project: &str,
graph: &mut PipelineGraph,
) -> Result<(), PlatformError> {
for node in &mut graph.nodes {
if node.kind != "n.web.response" {
continue;
}
let has_markup = node
.config
.get("markup")
.and_then(Value::as_str)
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
if has_markup {
continue;
}
let template_rel = node
.config
.get("template")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty());
let Some(template_rel) = template_rel else {
continue;
};
let markup = state
.platform
.projects
.read_template_file(owner, project, template_rel)?;
if let Some(map) = node.config.as_object_mut() {
map.insert("markup".to_string(), Value::String(markup));
}
}
Ok(())
}
fn build_zf_cookie(resp_cfg: &Value) -> Option<String> {
let sc = resp_cfg.get("set_cookie")?;
let name = sc.get("name")?.as_str()?;
let value = sc.get("value")?.as_str()?;
let max_age = sc.get("max_age").and_then(Value::as_i64).unwrap_or(900);
let path = sc.get("path").and_then(Value::as_str).unwrap_or("/");
let same_site = sc.get("same_site").and_then(Value::as_str).unwrap_or("Lax");
let http_only = sc.get("http_only").and_then(Value::as_bool).unwrap_or(true);
let secure = sc.get("secure").and_then(Value::as_bool).unwrap_or(false);
let mut parts = vec![
format!("{name}={value}"),
format!("Path={path}"),
format!("Max-Age={max_age}"),
format!("SameSite={same_site}"),
];
if http_only {
parts.push("HttpOnly".to_string());
}
if secure {
parts.push("Secure".to_string());
}
Some(parts.join("; "))
}
fn build_zf_headers(resp_cfg: &Value) -> Vec<(String, String)> {
resp_cfg
.get("headers")
.and_then(Value::as_object)
.map(|m| {
m.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect()
})
.unwrap_or_default()
}
fn apply_zf_extras(
resp: &mut axum::response::Response,
cookie: &Option<String>,
headers: &[(String, String)],
) {
if let Some(c) = cookie {
if let Ok(v) = HeaderValue::from_str(c) {
resp.headers_mut().insert(SET_COOKIE, v);
}
}
for (k, v) in headers {
if let (Ok(name), Ok(val)) = (
HeaderName::from_bytes(k.as_bytes()),
HeaderValue::from_str(v),
) {
resp.headers_mut().append(name, val);
}
}
}
fn compile_template_buffer(
state: &PlatformAppState,
template_root: &FsPath,
req: &TemplateCompileRequest,
) -> TemplateCompileResponse {
let rel = req.rel_path.trim();
if rel.is_empty() {
return TemplateCompileResponse {
ok: false,
diagnostics: vec![TemplateDiagnostic {
code: "template_path_missing".to_string(),
message: "template path must not be empty".to_string(),
severity: "error".to_string(),
from: Some(0),
to: Some(1),
}],
};
}
let kind = template_kind_from_rel(rel);
if kind == "script" || kind == "style" {
return TemplateCompileResponse {
ok: true,
diagnostics: Vec::new(),
};
}
let options = ReactiveWebOptions {
load_scripts: vec!["/assets/platform/*".to_string()],
allow_list: crate::rwe::ResourceAllowList {
scripts: vec!["/assets/platform/*".to_string()],
urls: vec!["/assets/platform/*".to_string()],
..Default::default()
},
templates: TemplateOptions {
template_root: Some(template_root.to_path_buf()),
style_entries: Vec::new(),
},
processors: vec!["tailwind".to_string()],
..Default::default()
};
let source = TemplateSource {
id: format!("platform.editor.{}", rel.replace('/', ".")),
source_path: Some(template_root.join(rel)),
markup: req.content.clone(),
};
match state
.frontend
.rwe
.compile_template(&source, state.frontend.language.as_ref(), &options)
{
Ok(compiled) => TemplateCompileResponse {
ok: true,
diagnostics: compiled
.diagnostics
.into_iter()
.map(|diag| TemplateDiagnostic {
code: diag.code,
message: diag.message,
severity: "warning".to_string(),
from: None,
to: None,
})
.collect(),
},
Err(err) => TemplateCompileResponse {
ok: false,
diagnostics: vec![TemplateDiagnostic {
code: err.code.to_string(),
message: err.message,
severity: "error".to_string(),
from: Some(0),
to: Some(1),
}],
},
}
}
async fn api_get_mcp_session(
State(state): State<PlatformAppState>,
Path((owner, project)): Path<(String, String)>,
headers: HeaderMap,
) -> Response {
if let Err(resp) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::McpSessionCreate,
) {
return resp;
}
let base_url = platform_base_url(&headers);
match state
.platform
.mcp_sessions
.get_for_project(&owner, &project)
{
Some(session) => {
let mcp_url = format!(
"{}/api/projects/{}/{}/mcp",
base_url.trim_end_matches('/'),
owner,
project
);
Json(json!({
"ok": true,
"session": {
"active": session.enabled,
"enabled": session.enabled,
"token": session.token,
"mcp_url": mcp_url,
"capabilities": session.capabilities.iter().map(|c| c.key()).collect::<Vec<_>>(),
"created_at": session.created_at,
"auto_reset_seconds": session.auto_reset_seconds,
"rotation_epoch": state.platform.mcp_sessions.min_created_at(),
}
}))
.into_response()
}
None => Json(json!({
"ok": true,
"session": {
"active": false,
"enabled": false,
"token": null,
"mcp_url": null,
"capabilities": Vec::<String>::new(),
"created_at": null,
"auto_reset_seconds": null,
"rotation_epoch": state.platform.mcp_sessions.min_created_at(),
}
}))
.into_response(),
}
}
async fn api_create_mcp_session(
State(state): State<PlatformAppState>,
Path((owner, project)): Path<(String, String)>,
headers: HeaderMap,
Json(req): Json<McpSessionCreateRequest>,
) -> Response {
if let Err(resp) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::McpSessionCreate,
) {
return resp;
}
let capabilities: Vec<ProjectCapability> = req
.capabilities
.iter()
.filter_map(|key| ProjectCapability::from_key(key))
.collect();
if capabilities.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"ok": false,
"error": {"code": "INVALID_REQUEST", "message": "At least one valid capability must be specified"}
})),
)
.into_response();
}
let base_url = platform_base_url(&headers);
match state.platform.mcp_sessions.create(
&owner,
&project,
capabilities,
&base_url,
req.auto_reset_seconds,
) {
Ok(response) => Json(json!({"ok": true, "session": response})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_toggle_mcp_session(
State(state): State<PlatformAppState>,
Path((owner, project)): Path<(String, String)>,
headers: HeaderMap,
Json(req): Json<McpSessionToggleRequest>,
) -> Response {
if let Err(resp) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::McpSessionCreate,
) {
return resp;
}
if req.enabled
&& state
.platform
.mcp_sessions
.get_for_project(&owner, &project)
.is_none()
{
return (
StatusCode::NOT_FOUND,
Json(json!({
"ok": false,
"error": {
"code": "MCP_SESSION_NOT_FOUND",
"message": "Create an MCP session before enabling it"
}
})),
)
.into_response();
}
match state
.platform
.mcp_sessions
.set_enabled(&owner, &project, req.enabled)
{
Ok(()) => Json(json!({"ok": true})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_reset_mcp_session_token(
State(state): State<PlatformAppState>,
Path((owner, project)): Path<(String, String)>,
headers: HeaderMap,
) -> Response {
if let Err(resp) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::McpSessionCreate,
) {
return resp;
}
let base_url = platform_base_url(&headers);
match state
.platform
.mcp_sessions
.reset_token(&owner, &project, &base_url)
{
Ok(response) => Json(json!({"ok": true, "session": response})).into_response(),
Err(err) => internal_error(err),
}
}
async fn api_revoke_mcp_session(
State(state): State<PlatformAppState>,
Path((owner, project)): Path<(String, String)>,
headers: HeaderMap,
) -> Response {
if let Err(resp) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::McpSessionRevoke,
) {
return resp;
}
match state
.platform
.mcp_sessions
.revoke_for_project(&owner, &project)
{
Ok(()) => Json(json!({"ok": true})).into_response(),
Err(err) => internal_error(err),
}
}
fn internal_error(err: PlatformError) -> Response {
let status = match &*err.code {
"PLATFORM_PIPELINE_LOCKED" | "PLATFORM_TEMPLATE_LOCKED" => StatusCode::LOCKED,
"MARKETPLACE_PUBLISHER_MISSING"
| "MARKETPLACE_ASSET_MISSING"
| "MARKETPLACE_PUBLISH_SOURCE_MISSING"
| "MARKETPLACE_SERVICE_DISABLED"
| "MCP_SESSION_NOT_FOUND" => StatusCode::NOT_FOUND,
"MARKETPLACE_TOKEN_FORBIDDEN"
| "MARKETPLACE_PUBLISHER_FORBIDDEN"
| "MARKETPLACE_PUBLISHER_SCOPE_DENIED"
| "MARKETPLACE_PACKAGE_FORBIDDEN" => StatusCode::FORBIDDEN,
"MARKETPLACE_PUBLISHER_DISABLED" => StatusCode::CONFLICT,
"MARKETPLACE_PUBLISHER_QUOTA_EXCEEDED" => StatusCode::CONFLICT,
"MARKETPLACE_TOKEN_INVALID"
| "MARKETPLACE_ARTIFACT_PATH_INVALID"
| "MARKETPLACE_PUBLISH_INVALID"
| "MARKETPLACE_REMOTE_INVALID"
| "MARKETPLACE_REMOTE_HASH_MISSING"
| "MARKETPLACE_REMOTE_HASH_MISMATCH"
| "MARKETPLACE_REPOSITORY_INVALID"
| "MARKETPLACE_TOKEN_SCOPE_INVALID"
| "MARKETPLACE_PUBLISH_EMPTY" => StatusCode::BAD_REQUEST,
"MARKETPLACE_ARTIFACT_TOO_LARGE" => StatusCode::PAYLOAD_TOO_LARGE,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
(
status,
Json(json!({"ok": false, "error": {"code": err.code, "message": err.message}})),
)
.into_response()
}
async fn ws_room_handler(
ws: WebSocketUpgrade,
Path((owner, project, room_id)): Path<(String, String, String)>,
State(state): State<PlatformAppState>,
headers: HeaderMap,
) -> impl IntoResponse {
let session_id = format!(
"ws-{:016x}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
ws.on_upgrade(move |socket| {
handle_ws_room(socket, owner, project, room_id, session_id, headers, state)
})
}
async fn handle_ws_room(
mut socket: WebSocket,
owner: String,
project: String,
room_id: String,
session_id: String,
connection_headers: HeaderMap,
state: PlatformAppState,
) {
let room_key = format!("{}/{}/{}", owner, project, room_id);
let room = state.platform.ws_hub.get_or_create_room(&room_key);
let mut broadcast_rx = room.subscribe();
let _guard = room.join_session();
let current_state = room.get_state();
let joined = serde_json::json!({
"type": "joined",
"session_id": session_id,
"room": room_id,
"state": current_state,
})
.to_string();
if socket.send(Message::Text(joined.into())).await.is_err() {
return;
}
loop {
tokio::select! {
broadcast = broadcast_rx.recv() => {
match broadcast {
Ok(msg) => {
if socket.send(Message::Text(msg.into())).await.is_err() {
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
continue;
}
}
}
incoming = socket.recv() => {
match incoming {
Some(Ok(Message::Text(text))) => {
let text_str = text.as_str();
if let Ok(val) = serde_json::from_str::<serde_json::Value>(text_str) {
let event = val
.get("event")
.and_then(|v| v.as_str())
.unwrap_or("message")
.to_string();
let payload = val
.get("payload")
.cloned()
.unwrap_or(serde_json::json!({}));
ws_dispatch_event(
&owner, &project, &room_id, &session_id,
&event, payload, &connection_headers, &state,
).await;
}
}
Some(Ok(Message::Close(_))) | None => break,
_ => {}
}
}
}
}
state.platform.ws_hub.remove_room(&room_key);
}
async fn ws_dispatch_event(
owner: &str,
project: &str,
room_id: &str,
session_id: &str,
event: &str,
payload: Value,
connection_headers: &HeaderMap,
state: &PlatformAppState,
) {
let pipelines = state.platform.pipeline_runtime.list_project(owner, project);
struct Match {
compiled: crate::platform::services::pipeline_runtime::CompiledPipeline,
auth_type: String,
auth_credential: String,
auth_required_role: Vec<String>,
}
let matching: Vec<Match> = pipelines
.into_iter()
.filter_map(|p| {
let trigger = p.ws_triggers.iter().find(|t| {
let room_match = t.room.is_empty() || t.room == room_id;
let event_match = t.event.is_empty() || t.event == event;
room_match && event_match
})?;
Some(Match {
auth_type: trigger.auth_type.clone(),
auth_credential: trigger.auth_credential.clone(),
auth_required_role: trigger.auth_required_role.clone(),
compiled: p,
})
})
.collect();
for m in matching {
let auth_claims = if m.auth_type.is_empty() || m.auth_type == "none" {
Ok(None)
} else if has_valid_cluster_token(state, connection_headers) {
Ok(None)
} else {
verify_webhook_auth(
connection_headers,
&axum::body::Bytes::new(), &m.auth_type,
&m.auth_credential,
&m.auth_required_role,
&state.platform.credentials,
owner,
project,
)
};
let auth_claims = match auth_claims {
Ok(claims) => claims,
Err(_) => {
continue;
}
};
let mut input = json!({
"room_id": room_id,
"session_id": session_id,
"event": event,
"payload": payload,
});
if let Some(claims) = &auth_claims {
if let Value::Object(ref mut map) = input {
map.insert("auth".to_string(), claims.clone());
}
}
let trigger = json!({
"auth": auth_claims.clone().unwrap_or(Value::Null),
});
let ctx = PipelineContext {
owner: owner.to_string(),
project: project.to_string(),
pipeline: m.compiled.graph.id.clone(),
request_id: format!(
"ws-{}-{}",
session_id,
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
),
route: Default::default(),
input,
trigger: Some(trigger),
placeholder: None,
};
let graph = m.compiled.graph.clone();
let credentials = state.platform.credentials.clone();
let rwe = state.frontend.rwe.clone();
let ws_hub = state.platform.ws_hub.clone();
let ws_client_mgr = state.ws_client_manager.clone();
let state_bus = state.platform.state_bus.clone();
let data_root = state.platform.config.data_root.clone();
let platform_clone = state.platform.clone();
tokio::spawn(async move {
let engine = crate::pipeline::BasicPipelineEngine::new(
std::sync::Arc::new(crate::language::DenoSandboxEngine::default()),
rwe,
Some(credentials),
)
.with_platform(platform_clone)
.with_ws_hub(ws_hub)
.with_ws_client_manager(ws_client_mgr)
.with_state_bus(state_bus)
.with_data_root(data_root);
let _ = engine.execute_async(&graph, &ctx).await;
});
}
}
async fn api_list_ui_catalog(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
) -> Response {
if let Err(r) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesRead,
) {
return r;
}
let layout = match state.platform.file.ensure_project_layout(&owner, &project) {
Ok(l) => l,
Err(e) => return internal_error(e),
};
let shared_ui_dir = layout.repo_pipelines_dir.join("shared").join("ui");
let entries = crate::platform::catalog::CatalogService::list_ui_with_presence(&shared_ui_dir);
Json(json!({ "ok": true, "components": entries })).into_response()
}
async fn api_install_ui_components(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
Json(req): Json<crate::platform::catalog::InstallUiRequest>,
) -> Response {
if let Err(r) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesWrite,
) {
return r;
}
let layout = match state.platform.file.ensure_project_layout(&owner, &project) {
Ok(l) => l,
Err(e) => return internal_error(e),
};
let shared_ui_dir = layout.repo_pipelines_dir.join("shared").join("ui");
match crate::platform::catalog::CatalogService::install_ui(
&req.names,
&shared_ui_dir,
req.overwrite,
) {
Ok(report) => Json(json!({ "ok": true, "report": report })).into_response(),
Err(e) => Json(json!({ "ok": false, "error": e })).into_response(),
}
}
async fn api_reindex_project(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
) -> Response {
if let Err(r) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::PipelinesWrite,
) {
return r;
}
let owner_slug = crate::platform::model::slug_segment(&owner);
let project_slug = crate::platform::model::slug_segment(&project);
let layout = match state
.platform
.file
.ensure_project_layout(&owner_slug, &project_slug)
{
Ok(l) => l,
Err(e) => return internal_error(e),
};
let repo_root = layout.repo_pipelines_dir.clone();
let mut pipelines_indexed: usize = 0;
let mut templates_indexed: usize = 0;
let mut assets_indexed: usize = 0;
let mut errors: Vec<String> = Vec::new();
let mut stack = vec![repo_root.clone()];
while let Some(dir) = stack.pop() {
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries.flatten() {
let path = entry.path();
let fname = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if fname.starts_with('.') {
continue;
}
if path.is_dir() {
stack.push(path);
} else if path.is_file() {
let rel = match path.strip_prefix(&repo_root) {
Ok(r) => r.to_string_lossy().replace('\\', "/"),
Err(_) => continue,
};
if rel.starts_with("assets/") {
assets_indexed += 1;
} else if rel.ends_with(".zf.json") {
match std::fs::read_to_string(&path) {
Ok(source) => {
let file_rel_path = format!("pipelines/{rel}");
let graph_description =
serde_json::from_str::<crate::pipeline::PipelineGraph>(&source)
.ok()
.and_then(|g| g.description)
.unwrap_or_default();
let reindex_trigger_kind =
crate::platform::services::project::derive_trigger_kind_from_source(&source)
.unwrap_or_default();
match state.platform.projects.upsert_pipeline_definition(
&owner_slug,
&project_slug,
&file_rel_path,
"",
&graph_description,
&reindex_trigger_kind,
&source,
) {
Ok(_) => pipelines_indexed += 1,
Err(e) => errors.push(format!("{rel}: {e}")),
}
}
Err(e) => errors.push(format!("{rel}: read error: {e}")),
}
} else if rel.ends_with(".tsx") || rel.ends_with(".ts") {
templates_indexed += 1;
}
}
}
}
Json(json!({
"ok": true,
"pipelines": pipelines_indexed,
"templates": templates_indexed,
"assets": assets_indexed,
"errors": errors
}))
.into_response()
}
async fn project_settings_clone_ui_preview_page(
State(state): State<PlatformAppState>,
headers: HeaderMap,
uri: Uri,
Path((owner, project)): Path<(String, String)>,
) -> Response {
if let Err(response) = require_project_page_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::SettingsRead,
) {
return response;
}
match maybe_forward_project_page_to_worker(&state, &headers, &uri, &owner, &project).await {
Ok(Some(response)) => return response,
Ok(None) => {}
Err(err) => return internal_error(err),
}
match state.platform.projects.get_project(&owner, &project) {
Ok(Some(info)) => {
let nav = nav_classes(&owner, &project, "settings", None);
let route = format!("/projects/{owner}/{project}/settings/clone/ui/preview");
let input = json!({
"seo": {
"title": format!("{} - UI catalog preview", info.title),
"description": "shadcn-compatible Zeb React components for this project",
},
"owner": info.owner,
"project": info.project,
"title": info.title,
"project_href": format!("/projects/{owner}/{project}"),
"components": crate::platform::catalog::CatalogService::list_ui(),
"nav": nav,
});
match render_page(
&state,
"platform-project-settings-clone-ui-preview",
&route,
input,
) {
Ok(html) => Html(html).into_response(),
Err(err) => internal_error(err),
}
}
Ok(None) => (StatusCode::NOT_FOUND, Html("project not found".to_string())).into_response(),
Err(err) => internal_error(err),
}
}
fn sanitize_asset_filename(raw: &str) -> Option<String> {
let name = raw.trim();
if name.is_empty() || name.len() > 200 {
return None;
}
if name.starts_with('.') || name.contains("..") || name.contains('/') || name.contains('\\') {
return None;
}
let replaced: String = name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' {
c
} else {
'-'
}
})
.collect();
let mut sanitized = String::with_capacity(replaced.len());
let mut prev_dash = false;
for c in replaced.chars() {
if c == '-' {
if !prev_dash {
sanitized.push(c);
}
prev_dash = true;
} else {
sanitized.push(c);
prev_dash = false;
}
}
let sanitized = sanitized.trim_matches('-').to_string();
if sanitized.is_empty() {
return None;
}
Some(sanitized)
}
fn sanitize_subfolder(s: &str) -> Option<String> {
let s = s.trim_matches('/');
if s.is_empty() {
return None;
}
if s.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_')
{
Some(s.to_string())
} else {
None
}
}
fn sanitize_asset_subpath(path: &str) -> Option<std::path::PathBuf> {
let trimmed = path.trim_matches('/');
if trimmed.is_empty() || trimmed.contains("..") {
return None;
}
let mut result = std::path::PathBuf::new();
for part in trimmed.split('/') {
if part.is_empty() || part.starts_with('.') {
return None;
}
if !part
.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'.' || c == b'-' || c == b'_')
{
return None;
}
result.push(part);
}
if result.components().count() == 0 {
return None;
}
Some(result)
}
#[derive(serde::Deserialize)]
struct AssetQuery {
subfolder: Option<String>,
}
async fn api_list_assets(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Query(query): Query<AssetQuery>,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TemplatesRead,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::GET,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
let owner_slug = crate::platform::model::slug_segment(&owner);
let project_slug = crate::platform::model::slug_segment(&project);
let layout = match state
.platform
.file
.ensure_project_layout(&owner_slug, &project_slug)
{
Ok(l) => l,
Err(err) => return internal_error(err),
};
let subfolder = query
.subfolder
.as_deref()
.and_then(sanitize_subfolder)
.unwrap_or_default();
let assets_dir = if subfolder.is_empty() {
layout.repo_pipelines_dir.join("assets")
} else {
layout.repo_pipelines_dir.join("assets").join(&subfolder)
};
if let Err(e) = std::fs::create_dir_all(&assets_dir) {
return internal_error(PlatformError::new("ASSET_DIR_CREATE", e.to_string()));
}
let entries = match std::fs::read_dir(&assets_dir) {
Ok(rd) => rd,
Err(e) => return internal_error(PlatformError::new("ASSET_DIR_READ", e.to_string())),
};
let mut files: Vec<Value> = Vec::new();
for entry in entries.flatten() {
let meta = match entry.metadata() {
Ok(m) => m,
Err(_) => continue,
};
if !meta.is_file() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
let size_bytes = meta.len();
let url = if subfolder.is_empty() {
format!("/assets/{owner_slug}/{project_slug}/{name}")
} else {
format!("/assets/{owner_slug}/{project_slug}/{subfolder}/{name}")
};
files.push(json!({ "name": name, "size_bytes": size_bytes, "url": url }));
}
files.sort_by(|a, b| {
a["name"]
.as_str()
.unwrap_or("")
.cmp(b["name"].as_str().unwrap_or(""))
});
Json(json!({ "ok": true, "files": files })).into_response()
}
async fn api_upload_asset(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
uri: Uri,
Query(query): Query<AssetQuery>,
mut multipart: Multipart,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TemplatesWrite,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
let field = match multipart.next_field().await {
Ok(Some(field)) => field,
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": "no file field in multipart body"})),
)
.into_response();
}
Err(err) => return internal_error(PlatformError::new("ASSET_UPLOAD", err.to_string())),
};
let raw_filename = field
.file_name()
.map(|value| value.to_string())
.unwrap_or_else(|| "upload.bin".to_string());
let content_type = field.content_type().map(|value| value.to_string());
let bytes = match field.bytes().await {
Ok(bytes) => bytes,
Err(err) => return internal_error(PlatformError::new("ASSET_UPLOAD", err.to_string())),
};
let worker = match state.platform.cluster_registry.get_worker(&worker_id) {
Ok(Some(worker)) => worker,
Ok(None) => {
return (
StatusCode::BAD_GATEWAY,
Json(json!({"ok": false, "error": "office not registered"})),
)
.into_response();
}
Err(err) => return internal_error(err),
};
let token = match cluster_internal_token_value(&state) {
Ok(token) => token,
Err(err) => return internal_error(err),
};
let mut url = format!("{}{}", worker.base_url.trim_end_matches('/'), uri.path());
if let Some(query) = uri.query() {
url.push('?');
url.push_str(query);
}
let part = match content_type {
Some(content_type) => reqwest::multipart::Part::bytes(bytes.to_vec())
.file_name(raw_filename.clone())
.mime_str(&content_type)
.unwrap_or_else(|_| {
reqwest::multipart::Part::bytes(bytes.to_vec()).file_name(raw_filename)
}),
None => reqwest::multipart::Part::bytes(bytes.to_vec()).file_name(raw_filename),
};
let form = reqwest::multipart::Form::new().part("file", part);
let response = match state
.http_client
.post(url)
.header(INTERNAL_CLUSTER_TOKEN_HEADER, token)
.multipart(form)
.send()
.await
{
Ok(response) => response,
Err(err) => return internal_error(PlatformError::new("ASSET_UPLOAD", err.to_string())),
};
return reqwest_response_to_axum(response).await;
}
let owner_slug = crate::platform::model::slug_segment(&owner);
let project_slug = crate::platform::model::slug_segment(&project);
let layout = match state
.platform
.file
.ensure_project_layout(&owner_slug, &project_slug)
{
Ok(l) => l,
Err(err) => return internal_error(err),
};
let cfg = state
.platform
.zebflow_cfg
.read_or_default(&owner_slug, &project_slug);
let max_asset_size_mb = cfg.configs.files.uploads.effective_max_asset_size_mb();
let max_bytes = (max_asset_size_mb as u64) * 1024 * 1024;
let subfolder = query
.subfolder
.as_deref()
.and_then(sanitize_subfolder)
.unwrap_or_default();
let assets_dir = if subfolder.is_empty() {
layout.repo_pipelines_dir.join("assets")
} else {
layout.repo_pipelines_dir.join("assets").join(&subfolder)
};
if let Err(e) = std::fs::create_dir_all(&assets_dir) {
return internal_error(PlatformError::new("ASSET_DIR_CREATE", e.to_string()));
}
let field = match multipart.next_field().await {
Ok(Some(f)) => f,
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": "no file field in multipart body"})),
)
.into_response();
}
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": e.to_string()})),
)
.into_response();
}
};
let raw_filename = field
.file_name()
.map(|s| s.to_string())
.or_else(|| field.name().map(|s| s.to_string()))
.unwrap_or_default();
let filename = match sanitize_asset_filename(&raw_filename) {
Some(n) => n,
None => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": format!("invalid filename '{raw_filename}': only [A-Za-z0-9._-] allowed, no leading dot, max 200 chars")})),
).into_response();
}
};
let bytes = match field.bytes().await {
Ok(b) => b,
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": e.to_string()})),
)
.into_response();
}
};
if bytes.len() as u64 > max_bytes {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": format!("file size {} bytes exceeds limit of {} MB", bytes.len(), max_asset_size_mb)})),
).into_response();
}
let dest = assets_dir.join(&filename);
if let Err(e) = std::fs::write(&dest, &bytes) {
return internal_error(PlatformError::new("ASSET_WRITE", e.to_string()));
}
let url = if subfolder.is_empty() {
format!("/assets/{owner_slug}/{project_slug}/{filename}")
} else {
format!("/assets/{owner_slug}/{project_slug}/{subfolder}/{filename}")
};
Json(json!({ "ok": true, "name": filename, "size_bytes": bytes.len(), "url": url }))
.into_response()
}
async fn api_delete_asset(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project, path)): Path<(String, String, String)>,
uri: Uri,
) -> Response {
if let Err(response) = require_project_api_capability(
&state,
&headers,
&owner,
&project,
ProjectCapability::TemplatesDelete,
) {
return response;
}
if let Ok(Some(worker_id)) = remote_project_worker_id(&state, &owner, &project) {
return match forward_project_api_request_to_worker(
&state,
&uri,
&Method::DELETE,
&headers,
Bytes::new(),
&worker_id,
)
.await
{
Ok(response) => response,
Err(err) => internal_error(err),
};
}
let subpath = match sanitize_asset_subpath(path.trim_start_matches('/')) {
Some(p) => p,
None => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": "invalid asset path"})),
)
.into_response();
}
};
let owner_slug = crate::platform::model::slug_segment(&owner);
let project_slug = crate::platform::model::slug_segment(&project);
let layout = match state
.platform
.file
.ensure_project_layout(&owner_slug, &project_slug)
{
Ok(l) => l,
Err(err) => return internal_error(err),
};
let assets_dir = layout.repo_pipelines_dir.join("assets");
let abs = assets_dir.join(&subpath);
if !abs.starts_with(&assets_dir) {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": "invalid asset path"})),
)
.into_response();
}
if !abs.is_file() {
return (
StatusCode::NOT_FOUND,
Json(json!({"ok": false, "error": "asset not found"})),
)
.into_response();
}
if let Err(e) = std::fs::remove_file(&abs) {
return internal_error(PlatformError::new("ASSET_DELETE", e.to_string()));
}
Json(json!({ "ok": true })).into_response()
}
#[derive(Deserialize)]
struct PreviewQuery {
file: Option<String>,
}
#[derive(Deserialize)]
struct PreviewToggleBody {
active: bool,
file: String,
}
fn preview_key(owner: &str, project: &str, file: &str) -> String {
format!("{owner}/{project}/{file}")
}
fn sanitize_preview_file(raw: &str) -> Option<String> {
let trimmed = raw.trim().trim_start_matches('/');
if trimmed.is_empty() || trimmed.contains("..") {
return None;
}
Some(trimmed.to_string())
}
async fn api_preview_toggle(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
Json(body): Json<PreviewToggleBody>,
) -> Response {
let Some(_session) = session_owner(&state, &headers) else {
return (StatusCode::UNAUTHORIZED, Json(json!({"ok": false}))).into_response();
};
let file = match sanitize_preview_file(&body.file) {
Some(f) => f,
None => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"ok": false, "error": "invalid file"})),
)
.into_response();
}
};
let key = preview_key(&owner, &project, &file);
let mut reg = state
.preview_registry
.lock()
.unwrap_or_else(|e| e.into_inner());
if body.active {
reg.insert(key);
} else {
reg.remove(&key);
}
Json(json!({ "ok": true, "active": body.active })).into_response()
}
async fn api_preview_status(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
Query(q): Query<PreviewQuery>,
) -> Response {
let Some(_session) = session_owner(&state, &headers) else {
return (StatusCode::UNAUTHORIZED, Json(json!({"active": false}))).into_response();
};
let file = q
.file
.as_deref()
.and_then(sanitize_preview_file)
.unwrap_or_default();
let key = preview_key(&owner, &project, &file);
let active = state
.preview_registry
.lock()
.unwrap_or_else(|e| e.into_inner())
.contains(&key);
Json(json!({ "active": active })).into_response()
}
async fn preview_page(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
Query(q): Query<PreviewQuery>,
) -> Response {
let Some(_session) = session_owner(&state, &headers) else {
return Redirect::to(LOGIN_PATH).into_response();
};
let file = match q.file.as_deref().and_then(sanitize_preview_file) {
Some(f) => f,
None => {
return (
StatusCode::BAD_REQUEST,
Html("<p>Missing ?file= param</p>".to_string()),
)
.into_response();
}
};
let key = preview_key(&owner, &project, &file);
let active = state
.preview_registry
.lock()
.unwrap_or_else(|e| e.into_inner())
.contains(&key);
if !active {
return (
StatusCode::FORBIDDEN,
Html("<html><body style='font-family:sans-serif;padding:2rem'><h2>Preview not active</h2><p>Enable Live Preview in the registry editor first.</p></body></html>".to_string()),
).into_response();
}
let owner_slug = crate::platform::model::slug_segment(&owner);
let project_slug = crate::platform::model::slug_segment(&project);
let layout = match state
.platform
.file
.ensure_project_layout(&owner_slug, &project_slug)
{
Ok(l) => l,
Err(err) => return internal_error(err),
};
let abs_path = layout.repo_pipelines_dir.join(&file);
let markup = match fs::read_to_string(&abs_path) {
Ok(m) => m,
Err(e) => {
return (
StatusCode::NOT_FOUND,
Html(format!("<p>Cannot read file: {e}</p>")),
)
.into_response();
}
};
let options = crate::rwe::ReactiveWebOptions {
templates: crate::rwe::TemplateOptions {
template_root: Some(layout.repo_pipelines_dir.clone()),
style_entries: Vec::new(),
},
processors: vec!["tailwind".to_string()],
..Default::default()
};
let compiled = match state.frontend.rwe.compile_template(
&crate::rwe::TemplateSource {
id: format!("preview/{owner}/{project}/{file}"),
source_path: Some(abs_path),
markup,
},
state.frontend.language.as_ref(),
&options,
) {
Ok(c) => c,
Err(e) => return (
StatusCode::UNPROCESSABLE_ENTITY,
Html(format!("<html><body style='font-family:monospace;padding:1rem;white-space:pre-wrap'><b>Compile error:</b>\n{e}</body></html>")),
).into_response(),
};
let ctx = crate::rwe::RenderContext {
route: format!("/preview/{owner}/{project}"),
request_id: "preview".to_string(),
metadata: json!({}),
enabled_libraries: Vec::new(),
};
let out = match state.frontend.rwe.render(&compiled, json!({}), state.frontend.language.as_ref(), &ctx) {
Ok(out) => out,
Err(e) => return (
StatusCode::INTERNAL_SERVER_ERROR,
Html(format!("<html><body style='font-family:monospace;padding:1rem;white-space:pre-wrap'><b>Render error:</b>\n{e}</body></html>")),
).into_response(),
};
let mut html = ensure_meta_charset(out.html);
if let Some(css) = out.hydration_payload.get("css").and_then(Value::as_str)
&& !css.trim().is_empty()
{
let style_block = format!("<style data-rwe-tw>{css}</style>");
if let Some(pos) = html.find("</head>") {
html.insert_str(pos, &style_block);
} else {
html = format!("{style_block}{html}");
}
}
html = ensure_stylesheet_link(html, "/assets/platform/main.css");
html = externalize_rwe_scripts(&state, &html, &out.compiled_scripts, None);
let ws_url = format!(
"/ws/preview/{owner}/{project}?file={}",
urlencoding_encode(&file)
);
let ws_script = format!(
r#"<script>
(function(){{
var url = (location.protocol === 'https:' ? 'wss' : 'ws') + '://' + location.host + '{ws_url}';
function connect() {{
var ws = new WebSocket(url);
ws.onmessage = function(e) {{
try {{ if (JSON.parse(e.data).type === 'reload') location.reload(); }} catch(_) {{}}
}};
ws.onclose = function() {{ setTimeout(connect, 2000); }};
}}
connect();
}})();
</script></body>"#
);
let html = if html.contains("</body>") {
html.replacen("</body>", &ws_script, 1)
} else {
format!("{html}{ws_script}")
};
(
StatusCode::OK,
[(CONTENT_TYPE, "text/html; charset=utf-8")],
html,
)
.into_response()
}
fn urlencoding_encode(s: &str) -> String {
s.chars()
.map(|c| match c {
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' | '/' => c.to_string(),
' ' => "%20".to_string(),
c => format!("%{:02X}", c as u32),
})
.collect()
}
fn url_query_encode(s: &str) -> String {
let mut out = String::new();
for byte in s.as_bytes() {
match *byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(*byte as char);
}
b' ' => out.push_str("%20"),
byte => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
async fn ws_preview_handler(
State(state): State<PlatformAppState>,
headers: HeaderMap,
Path((owner, project)): Path<(String, String)>,
Query(q): Query<PreviewQuery>,
ws: WebSocketUpgrade,
) -> Response {
let Some(_session) = session_owner(&state, &headers) else {
return (StatusCode::UNAUTHORIZED, "unauthorized").into_response();
};
let file = match q.file.as_deref().and_then(sanitize_preview_file) {
Some(f) => f,
None => return (StatusCode::BAD_REQUEST, "missing file param").into_response(),
};
let key = preview_key(&owner, &project, &file);
{
let reg = state
.preview_registry
.lock()
.unwrap_or_else(|e| e.into_inner());
if !reg.contains(&key) {
return (StatusCode::FORBIDDEN, "preview not active").into_response();
}
}
let owner_slug = crate::platform::model::slug_segment(&owner);
let project_slug = crate::platform::model::slug_segment(&project);
let layout = match state
.platform
.file
.ensure_project_layout(&owner_slug, &project_slug)
{
Ok(l) => l,
Err(err) => return internal_error(err),
};
let abs_path = layout.repo_pipelines_dir.join(&file);
let registry = state.preview_registry.clone();
ws.on_upgrade(move |socket| async move {
handle_preview_ws(socket, abs_path, key, registry).await;
})
}
async fn handle_preview_ws(
mut socket: WebSocket,
path: PathBuf,
_key: String,
_registry: Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
) {
let mut last_mtime = get_mtime(&path);
let mut interval = tokio::time::interval(Duration::from_secs(1));
loop {
tokio::select! {
_ = interval.tick() => {
let mtime = get_mtime(&path);
if mtime != last_mtime {
last_mtime = mtime;
let msg = Message::Text(r#"{"type":"reload"}"#.to_string().into());
if socket.send(msg).await.is_err() {
break;
}
}
}
msg = socket.recv() => {
match msg {
Some(Ok(_)) => {} _ => break, }
}
}
}
}
fn get_mtime(path: &FsPath) -> Option<std::time::SystemTime> {
fs::metadata(path).ok().and_then(|m| m.modified().ok())
}
#[cfg(test)]
mod webhook_sse_tests {
use axum::http::{HeaderMap, HeaderValue};
use super::wants_event_stream;
#[test]
fn detects_event_stream_accept_header() {
let mut headers = HeaderMap::new();
headers.insert("accept", HeaderValue::from_static("text/event-stream"));
assert!(wants_event_stream(&headers));
}
#[test]
fn detects_event_stream_among_multiple_types() {
let mut headers = HeaderMap::new();
headers.insert(
"accept",
HeaderValue::from_static("application/json, text/event-stream"),
);
assert!(wants_event_stream(&headers));
}
#[test]
fn normal_json_request_is_not_event_stream() {
let mut headers = HeaderMap::new();
headers.insert("accept", HeaderValue::from_static("application/json"));
assert!(!wants_event_stream(&headers));
}
#[test]
fn missing_accept_header_is_not_event_stream() {
let headers = HeaderMap::new();
assert!(!wants_event_stream(&headers));
}
#[test]
fn html_accept_is_not_event_stream() {
let mut headers = HeaderMap::new();
headers.insert("accept", HeaderValue::from_static("text/html"));
assert!(!wants_event_stream(&headers));
}
#[test]
fn signal_serializes_to_expected_json_shape() {
use crate::pipeline::model::Signal;
let sig = Signal {
kind: "thinking".to_string(),
message: "LLM reasoning about query".to_string(),
node_id: "n0".to_string(),
node_kind: "n.ai.agent".to_string(),
data: None,
at: "00:00:03".to_string(),
};
let s = serde_json::to_string(&sig).unwrap();
assert!(s.contains("\"kind\":\"thinking\""));
assert!(s.contains("\"message\":\"LLM reasoning about query\""));
assert!(s.contains("\"node_id\":\"n0\""));
assert!(s.contains("\"at\":\"00:00:03\""));
}
#[test]
fn done_event_envelope_shape() {
let output_value = serde_json::json!({"rows": [1, 2, 3]});
let done = serde_json::json!({ "ok": true, "value": output_value });
assert_eq!(done["ok"], true);
assert_eq!(done["value"]["rows"][0], 1);
}
#[test]
fn error_event_envelope_shape() {
let error = serde_json::json!({
"ok": false,
"error": { "code": "PG_QUERY_FAIL", "message": "connection refused" }
});
assert_eq!(error["ok"], false);
assert_eq!(error["error"]["code"], "PG_QUERY_FAIL");
assert_eq!(error["error"]["message"], "connection refused");
}
#[tokio::test]
async fn signals_forwarded_then_done() {
use crate::pipeline::model::{ExecutionBus, Signal};
use futures::StreamExt;
let bus = std::sync::Arc::new(ExecutionBus::new(64));
let mut signal_rx = bus.subscribe();
let (result_tx, mut result_rx) =
tokio::sync::oneshot::channel::<Result<serde_json::Value, String>>();
bus.emit(Signal {
kind: "query".to_string(),
message: "running SQL".to_string(),
node_id: "n0".to_string(),
node_kind: "n.db.query".to_string(),
data: None,
at: "00:00:01".to_string(),
});
bus.emit(Signal {
kind: "render".to_string(),
message: "rendering template".to_string(),
node_id: "n1".to_string(),
node_kind: "n.web.response".to_string(),
data: None,
at: "00:00:02".to_string(),
});
drop(bus); let _ = result_tx.send(Ok(serde_json::json!({"html": "<h1>hi</h1>"})));
let stream = async_stream::stream! {
loop {
tokio::select! {
biased;
sig = signal_rx.recv() => {
match sig {
Ok(s) => {
yield ("signal", serde_json::to_value(&s).unwrap());
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(_) => break,
}
}
result = &mut result_rx => {
match result {
Ok(Ok(val)) => yield ("done", serde_json::json!({"ok": true, "value": val})),
Ok(Err(msg)) => yield ("error", serde_json::json!({"ok": false, "error": msg})),
Err(_) => {}
}
return;
}
}
}
match result_rx.await {
Ok(Ok(val)) => yield ("done", serde_json::json!({"ok": true, "value": val})),
Ok(Err(msg)) => yield ("error", serde_json::json!({"ok": false, "error": msg})),
Err(_) => {}
}
};
tokio::pin!(stream);
let mut collected = Vec::new();
while let Some(item) = stream.next().await {
collected.push(item);
}
assert_eq!(collected.len(), 3);
assert_eq!(collected[0].0, "signal");
assert_eq!(collected[0].1["kind"], "query");
assert_eq!(collected[1].0, "signal");
assert_eq!(collected[1].1["kind"], "render");
assert_eq!(collected[2].0, "done");
assert_eq!(collected[2].1["ok"], true);
assert_eq!(collected[2].1["value"]["html"], "<h1>hi</h1>");
}
#[tokio::test]
async fn error_result_emits_error_event() {
use crate::pipeline::model::ExecutionBus;
use futures::StreamExt;
let bus = std::sync::Arc::new(ExecutionBus::new(64));
let mut signal_rx = bus.subscribe();
let (result_tx, mut result_rx) =
tokio::sync::oneshot::channel::<Result<serde_json::Value, String>>();
drop(bus);
let _ = result_tx.send(Err("connection refused".to_string()));
let stream = async_stream::stream! {
loop {
tokio::select! {
biased;
sig = signal_rx.recv() => {
match sig {
Ok(s) => {
yield ("signal", serde_json::to_value(&s).unwrap());
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(_) => break,
}
}
result = &mut result_rx => {
match result {
Ok(Ok(val)) => yield ("done", serde_json::json!({"ok": true, "value": val})),
Ok(Err(msg)) => yield ("error", serde_json::json!({"ok": false, "error": msg})),
Err(_) => {}
}
return;
}
}
}
match result_rx.await {
Ok(Ok(val)) => yield ("done", serde_json::json!({"ok": true, "value": val})),
Ok(Err(msg)) => yield ("error", serde_json::json!({"ok": false, "error": msg})),
Err(_) => {}
}
};
tokio::pin!(stream);
let mut collected = Vec::new();
while let Some(item) = stream.next().await {
collected.push(item);
}
assert_eq!(collected.len(), 1);
assert_eq!(collected[0].0, "error");
assert_eq!(collected[0].1["ok"], false);
assert!(
collected[0].1["error"]
.as_str()
.unwrap()
.contains("connection refused")
);
}
}