1use axum::{
2 Json, Router,
3 extract::State,
4 http::{HeaderMap, StatusCode, Uri, header},
5 response::IntoResponse,
6 routing::{get, post},
7};
8use serde_json::Value;
9
10use crate::access::{ACTION_MOBPACK_AUTHOR, ACTION_MOBPACK_DEPLOY, AccessController, AccessView};
11use crate::http_console::ACCESS_DENIED_RPC_CODE;
12use crate::http_sse::sse_access_context;
13use crate::mobpack::MobpackRuntimeCatalogState;
14use crate::rpc::{JSONRPC_VERSION, JsonRpcError, JsonRpcRequest, JsonRpcResponse};
15use crate::runtime::RuntimeDecisionState;
16
17const FLOW_EDITOR_FRONTEND_INDEX_HTML: &str = include_str!("../flow-editor-dist/index.html");
18const FLOW_EDITOR_FRONTEND_VENDOR_JS: &str = include_str!("../flow-editor-dist/react-globals.js");
19const FLOW_EDITOR_FRONTEND_APP_JS: &str = include_str!("../flow-editor-dist/flow-editor.js");
20const FLOW_EDITOR_FRONTEND_APP_CSS: &str = include_str!("../flow-editor-dist/flow-editor.css");
21
22pub fn flow_editor_router() -> Router {
28 standalone_favicon_router::<()>()
29 .merge(flow_editor_frontend_router::<()>())
30 .merge(flow_editor_rpc_router::<()>())
31}
32
33pub fn flow_editor_router_with_host_deploy() -> Router {
34 standalone_favicon_router::<()>()
35 .merge(flow_editor_frontend_router::<()>())
36 .merge(flow_editor_rpc_router_allowing_host_deploy::<()>())
37}
38
39fn standalone_favicon_router<S>() -> Router<S>
44where
45 S: Clone + Send + Sync + 'static,
46{
47 Router::new().route("/favicon.ico", get(|| async { StatusCode::NO_CONTENT }))
48}
49
50pub fn protected_flow_editor_router(decisions: RuntimeDecisionState) -> Router {
51 flow_editor_frontend_router::<()>().merge(flow_editor_rpc_router_with_decisions(decisions))
52}
53
54pub(crate) fn protected_flow_editor_router_with_runtime_catalog(
55 decisions: RuntimeDecisionState,
56 runtime_catalog: MobpackRuntimeCatalogState,
57 access: Option<AccessController>,
58) -> Router {
59 flow_editor_frontend_router::<()>().merge(flow_editor_rpc_router_with_runtime_catalog(
60 decisions,
61 Some(runtime_catalog),
62 access,
63 ))
64}
65
66pub fn flow_editor_frontend_router<S>() -> Router<S>
67where
68 S: Clone + Send + Sync + 'static,
69{
70 Router::new()
71 .route("/flow-editor", get(flow_editor_frontend_index_handler))
72 .route("/flow-editor/", get(flow_editor_frontend_index_handler))
73 .route(
74 "/flow-editor/assets/react-globals.js",
75 get(flow_editor_frontend_vendor_js_handler),
76 )
77 .route(
78 "/flow-editor/assets/flow-editor.js",
79 get(flow_editor_frontend_app_js_handler),
80 )
81 .route(
82 "/flow-editor/assets/flow-editor.css",
83 get(flow_editor_frontend_app_css_handler),
84 )
85}
86
87pub fn flow_editor_rpc_router<S>() -> Router<S>
88where
89 S: Clone + Send + Sync + 'static,
90{
91 Router::new().route("/flow-editor/rpc", post(flow_editor_rpc_handler))
92}
93
94pub fn flow_editor_rpc_router_allowing_host_deploy<S>() -> Router<S>
95where
96 S: Clone + Send + Sync + 'static,
97{
98 Router::new().route(
99 "/flow-editor/rpc",
100 post(flow_editor_rpc_handler_allowing_host_deploy),
101 )
102}
103
104pub fn flow_editor_rpc_router_with_decisions(decisions: RuntimeDecisionState) -> Router {
105 flow_editor_rpc_router_with_runtime_catalog(decisions, None, None)
106}
107
108fn flow_editor_rpc_router_with_runtime_catalog(
109 decisions: RuntimeDecisionState,
110 runtime_catalog: Option<MobpackRuntimeCatalogState>,
111 access: Option<AccessController>,
112) -> Router {
113 Router::new()
114 .route("/flow-editor/rpc", post(protected_flow_editor_rpc_handler))
115 .with_state(FlowEditorRpcState {
116 decisions,
117 runtime_catalog,
118 access,
119 })
120}
121
122pub async fn flow_editor_frontend_index_handler() -> impl IntoResponse {
123 (
124 [
125 (header::CONTENT_TYPE, "text/html; charset=utf-8"),
126 (header::CACHE_CONTROL, "no-store"),
127 ],
128 FLOW_EDITOR_FRONTEND_INDEX_HTML,
129 )
130}
131
132pub async fn flow_editor_frontend_vendor_js_handler() -> impl IntoResponse {
133 (
134 [
135 (
136 header::CONTENT_TYPE,
137 "application/javascript; charset=utf-8",
138 ),
139 (header::CACHE_CONTROL, "no-store"),
140 ],
141 FLOW_EDITOR_FRONTEND_VENDOR_JS,
142 )
143}
144
145pub async fn flow_editor_frontend_app_js_handler() -> impl IntoResponse {
146 (
147 [
148 (
149 header::CONTENT_TYPE,
150 "application/javascript; charset=utf-8",
151 ),
152 (header::CACHE_CONTROL, "no-store"),
153 ],
154 FLOW_EDITOR_FRONTEND_APP_JS,
155 )
156}
157
158pub async fn flow_editor_frontend_app_css_handler() -> impl IntoResponse {
159 (
160 [
161 (header::CONTENT_TYPE, "text/css; charset=utf-8"),
162 (header::CACHE_CONTROL, "no-store"),
163 ],
164 FLOW_EDITOR_FRONTEND_APP_CSS,
165 )
166}
167
168pub async fn flow_editor_rpc_handler(Json(request): Json<Value>) -> impl IntoResponse {
169 flow_editor_rpc_handler_with_policy(request, false).await
170}
171
172pub async fn flow_editor_rpc_handler_allowing_host_deploy(
173 Json(request): Json<Value>,
174) -> impl IntoResponse {
175 flow_editor_rpc_handler_with_policy(request, true).await
176}
177
178async fn flow_editor_rpc_handler_with_policy(
179 request: Value,
180 allow_host_deploy: bool,
181) -> impl IntoResponse {
182 let parsed_request = match serde_json::from_value::<JsonRpcRequest>(request) {
183 Ok(req) => req,
184 Err(_) => {
185 return (
186 StatusCode::OK,
187 Json::<Value>(serde_json::json!({
188 "jsonrpc": JSONRPC_VERSION,
189 "id": Value::Null,
190 "error": { "code": -32600, "message": "Invalid Request" }
191 })),
192 );
193 }
194 };
195 let response_id = parsed_request.id.clone().unwrap_or(Value::Null);
196 let response = dispatch_flow_editor_rpc_blocking(
197 parsed_request,
198 FlowEditorAuthReport {
199 authenticated: false,
200 mode: if allow_host_deploy {
201 "standalone_host_deploy"
202 } else {
203 "none"
204 },
205 reason: if allow_host_deploy {
206 "standalone Flow Editor authoring server with explicit host deploy opt-in"
207 } else {
208 "standalone Flow Editor authoring server"
209 },
210 host_mutation_allowed: allow_host_deploy,
211 deploy_execute_allowed: allow_host_deploy,
212 },
213 None,
214 response_id,
215 )
216 .await;
217 (StatusCode::OK, Json::<Value>(response))
218}
219
220async fn dispatch_flow_editor_rpc_blocking(
228 request: JsonRpcRequest,
229 auth: FlowEditorAuthReport,
230 runtime_catalog: Option<MobpackRuntimeCatalogState>,
231 response_id: Value,
232) -> Value {
233 match tokio::task::spawn_blocking(move || {
234 handle_flow_editor_rpc_with_auth(request, auth, runtime_catalog.as_ref())
235 })
236 .await
237 {
238 Ok(response) => response,
239 Err(err) => serde_json::json!({
240 "jsonrpc": JSONRPC_VERSION,
241 "id": response_id,
242 "error": {
243 "code": -32603,
244 "message": format!("flow editor rpc task failed: {err}"),
245 }
246 }),
247 }
248}
249
250#[derive(Clone)]
251struct FlowEditorRpcState {
252 decisions: RuntimeDecisionState,
253 runtime_catalog: Option<MobpackRuntimeCatalogState>,
254 access: Option<AccessController>,
255}
256
257#[derive(Clone, Copy)]
258struct FlowEditorAuthReport {
259 authenticated: bool,
260 mode: &'static str,
261 reason: &'static str,
262 host_mutation_allowed: bool,
263 deploy_execute_allowed: bool,
264}
265
266async fn protected_flow_editor_rpc_handler(
267 State(state): State<FlowEditorRpcState>,
268 headers: HeaderMap,
269 uri: Uri,
270 Json(request): Json<Value>,
271) -> impl IntoResponse {
272 let parsed_request = match serde_json::from_value::<JsonRpcRequest>(request) {
273 Ok(req) => req,
274 Err(_) => {
275 return (
276 StatusCode::OK,
277 Json::<Value>(serde_json::json!({
278 "jsonrpc": JSONRPC_VERSION,
279 "id": Value::Null,
280 "error": { "code": -32600, "message": "Invalid Request" }
281 })),
282 );
283 }
284 };
285 let access_view = match sse_access_context(
286 Some(&state.decisions),
287 state.access.as_ref(),
288 &headers,
289 &uri,
290 ) {
291 Ok(view) => view,
292 Err(()) => {
293 return (
294 StatusCode::UNAUTHORIZED,
295 Json::<Value>(serde_json::json!({
296 "jsonrpc": JSONRPC_VERSION,
297 "id": parsed_request.id.unwrap_or(Value::Null),
298 "error": {
299 "code": -32600,
300 "message": "unauthorized: flow editor rpc requires a valid auth token",
301 }
302 })),
303 );
304 }
305 };
306 if let Some(error) = flow_editor_rpc_access_violation(access_view.as_ref(), &parsed_request) {
307 return (
308 StatusCode::OK,
309 Json::<Value>(serde_json::json!({
310 "jsonrpc": JSONRPC_VERSION,
311 "id": parsed_request.id.unwrap_or(Value::Null),
312 "error": error,
313 })),
314 );
315 }
316 let deploy_grant = access_view
320 .as_ref()
321 .filter(|view| view.enforced())
322 .is_none_or(|view| view.may_perform_anywhere(ACTION_MOBPACK_DEPLOY));
323 let response_id = parsed_request.id.clone().unwrap_or(Value::Null);
324 let response = dispatch_flow_editor_rpc_blocking(
325 parsed_request,
326 FlowEditorAuthReport {
327 authenticated: true,
328 mode: "reference_app",
329 reason: "reference app Flow Editor authoring server",
330 host_mutation_allowed: deploy_grant,
331 deploy_execute_allowed: deploy_grant,
332 },
333 state.runtime_catalog.clone(),
334 response_id,
335 )
336 .await;
337 (StatusCode::OK, Json::<Value>(response))
338}
339
340fn flow_editor_rpc_access_requirement(method: &str, params: &Value) -> Option<&'static str> {
346 if method == "mobkit/capabilities" {
347 return None;
348 }
349 if !crate::rpc::MOBPACK_AUTHORING_METHODS.contains(&method) {
350 return None;
352 }
353 if method == "mobkit/mobpacks/deploy"
354 && params
355 .get("execute")
356 .and_then(Value::as_bool)
357 .unwrap_or(false)
358 {
359 return Some(ACTION_MOBPACK_DEPLOY);
360 }
361 if method == "mobkit/mobpacks/validate" && validate_rkat_execution_requested(params) {
366 return Some(ACTION_MOBPACK_DEPLOY);
367 }
368 Some(ACTION_MOBPACK_AUTHOR)
369}
370
371fn validate_rkat_execution_requested(params: &Value) -> bool {
376 params
377 .get("rkat_validate")
378 .or_else(|| params.get("rkatValidate"))
379 .and_then(Value::as_bool)
380 .unwrap_or(false)
381}
382
383fn flow_editor_rpc_access_violation(
384 view: Option<&AccessView>,
385 request: &JsonRpcRequest,
386) -> Option<Value> {
387 let view = view.filter(|view| view.enforced())?;
388 let action = flow_editor_rpc_access_requirement(request.method.as_str(), &request.params)?;
389 if view.allows(action) {
390 return None;
391 }
392 Some(serde_json::json!({
393 "code": ACCESS_DENIED_RPC_CODE,
394 "message": format!("access denied: {action}"),
395 "data": { "kind": "access_denied", "action": action },
396 }))
397}
398
399pub fn handle_flow_editor_rpc(request: JsonRpcRequest) -> Value {
400 handle_flow_editor_rpc_with_auth(
401 request,
402 FlowEditorAuthReport {
403 authenticated: false,
404 mode: "none",
405 reason: "standalone Flow Editor authoring server",
406 host_mutation_allowed: false,
407 deploy_execute_allowed: false,
408 },
409 None,
410 )
411}
412
413fn handle_flow_editor_rpc_with_auth(
414 request: JsonRpcRequest,
415 auth: FlowEditorAuthReport,
416 runtime_catalog: Option<&MobpackRuntimeCatalogState>,
417) -> Value {
418 let response_id = request.id.clone().unwrap_or(Value::Null);
419 match request.method.as_str() {
420 "mobkit/capabilities" => {
421 let mut methods = vec!["mobkit/capabilities"];
422 methods.extend_from_slice(crate::rpc::MOBPACK_AUTHORING_METHODS);
423 let mut authoring_capabilities = crate::rpc::mobpack_authoring_capabilities();
424 authoring_capabilities["host_mutation_allowed"] =
425 serde_json::json!(auth.host_mutation_allowed);
426 authoring_capabilities["deploy_execute_allowed"] =
427 serde_json::json!(auth.deploy_execute_allowed);
428 authoring_capabilities["runtime_backed_catalogs"] =
429 serde_json::json!(runtime_catalog.is_some());
430 response_value(
431 response_id,
432 Some(serde_json::json!({
433 "methods": methods,
434 "authenticated": auth.authenticated,
435 "auth": {
436 "mode": auth.mode,
437 "reason": auth.reason
438 },
439 "features": {
440 "flow_editor": true,
441 "mobpack_authoring": true,
442 },
443 "authoring_capabilities": authoring_capabilities,
444 })),
445 None,
446 )
447 }
448 "mobkit/mobpacks/catalogs" => response_value(
449 response_id,
450 Some(crate::mobpack::mobpack_catalogs_response_with_runtime(
451 runtime_catalog,
452 )),
453 None,
454 ),
455 "mobkit/tools/catalog" => response_value(
456 response_id,
457 Some(crate::mobpack::mobpack_tools_catalog_response_with_runtime(
458 runtime_catalog,
459 )),
460 None,
461 ),
462 "mobkit/skills/catalog" => response_value(
463 response_id,
464 Some(crate::mobpack::mobpack_skills_catalog_response_with_runtime(
465 runtime_catalog,
466 )),
467 None,
468 ),
469 "mobkit/agent_definitions/list" => response_value(
470 response_id,
471 Some(
472 crate::mobpack::mobpack_agent_definitions_response_with_runtime(runtime_catalog),
473 ),
474 None,
475 ),
476 "mobkit/mobpacks/templates" => response_value(
477 response_id,
478 Some(crate::mobpack::mobpack_templates_response_with_runtime(
479 runtime_catalog,
480 )),
481 None,
482 ),
483 "mobkit/mobpacks/deploy"
484 if !auth.deploy_execute_allowed && deploy_execute_requested(&request.params) =>
485 {
486 response_value(
487 response_id,
488 None,
489 Some(JsonRpcError {
490 code: -32602,
491 message: "standalone Flow Editor RPC cannot execute host deploys; use deploy planning or run rkat mob run manually".to_string(),
492 data: Some(serde_json::json!({
493 "method": "mobkit/mobpacks/deploy",
494 "execute": true,
495 "deploy_command": "rkat mob run"
496 })),
497 }),
498 )
499 }
500 "mobkit/mobpacks/validate"
508 if !auth.host_mutation_allowed
509 && validate_rkat_execution_requested(&request.params) =>
510 {
511 let mut params = request.params.clone();
512 if let Some(object) = params.as_object_mut() {
513 object.remove("rkat_validate");
514 object.remove("rkatValidate");
515 }
516 match crate::rpc::handle_mobpack_authoring_rpc_with_runtime(
517 "mobkit/mobpacks/validate",
518 ¶ms,
519 response_id.clone(),
520 runtime_catalog,
521 ) {
522 Some(response) => serde_json::to_value(response).unwrap_or_else(|_| {
523 serde_json::json!({
524 "jsonrpc": JSONRPC_VERSION,
525 "id": Value::Null,
526 "error": { "code": -32603, "message": "serialization failed" }
527 })
528 }),
529 None => response_value(
530 response_id,
531 None,
532 Some(JsonRpcError {
533 code: -32601,
534 message: "method not found on flow editor rpc: mobkit/mobpacks/validate"
535 .to_string(),
536 data: None,
537 }),
538 ),
539 }
540 }
541 method if crate::rpc::MOBPACK_AUTHORING_METHODS.contains(&method) => {
542 match crate::rpc::handle_mobpack_authoring_rpc_with_runtime(
543 method,
544 &request.params,
545 response_id.clone(),
546 runtime_catalog,
547 ) {
548 Some(response) => serde_json::to_value(response).unwrap_or_else(|_| {
549 serde_json::json!({
550 "jsonrpc": JSONRPC_VERSION,
551 "id": Value::Null,
552 "error": {
553 "code": -32603,
554 "message": "serialization failed",
555 }
556 })
557 }),
558 None => response_value(
559 response_id,
560 None,
561 Some(JsonRpcError {
562 code: -32601,
563 message: format!("method not found on flow editor rpc: {method}"),
564 data: None,
565 }),
566 ),
567 }
568 }
569 other => response_value(
570 response_id,
571 None,
572 Some(JsonRpcError {
573 code: -32601,
574 message: format!("method not found on flow editor rpc: {other}"),
575 data: None,
576 }),
577 ),
578 }
579}
580
581fn deploy_execute_requested(params: &Value) -> bool {
582 params
583 .get("execute")
584 .and_then(Value::as_bool)
585 .unwrap_or(false)
586}
587
588fn response_value(id: Value, result: Option<Value>, error: Option<JsonRpcError>) -> Value {
589 serde_json::to_value(JsonRpcResponse {
590 jsonrpc: JSONRPC_VERSION.to_string(),
591 id,
592 result,
593 error,
594 })
595 .unwrap_or_else(|_| {
596 serde_json::json!({
597 "jsonrpc": JSONRPC_VERSION,
598 "id": Value::Null,
599 "error": {
600 "code": -32603,
601 "message": "serialization failed",
602 }
603 })
604 })
605}
606
607#[cfg(test)]
608#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
609mod tests {
610 use axum::{body::Body, http::Request};
611 use serde_json::{Value, json};
612 use tower::ServiceExt;
613
614 use crate::rpc::JsonRpcRequest;
615
616 #[tokio::test]
617 async fn standalone_flow_editor_serves_empty_favicon_response() {
618 let response = super::flow_editor_router()
619 .oneshot(
620 Request::builder()
621 .uri("/favicon.ico")
622 .body(Body::empty())
623 .expect("request"),
624 )
625 .await
626 .expect("favicon response");
627
628 assert_eq!(response.status(), axum::http::StatusCode::NO_CONTENT);
629 }
630
631 fn authoring_access_controller(rules: Vec<crate::access::AccessRule>) -> AccessController {
632 AccessController::new(crate::access::AccessControlConfig {
633 enabled: true,
634 admins: vec!["root@example.test".to_string()],
635 groups: std::collections::BTreeMap::new(),
636 rules,
637 })
638 .expect("valid access config")
639 }
640
641 fn allow_everyone(id: &str, actions: &[&str]) -> crate::access::AccessRule {
642 crate::access::AccessRule {
643 id: id.to_string(),
644 actions: actions.iter().map(ToString::to_string).collect(),
645 ..crate::access::AccessRule::default()
646 }
647 }
648
649 fn open_decision_state() -> crate::runtime::RuntimeDecisionState {
650 crate::runtime::RuntimeDecisionState {
651 bigquery: crate::decisions::BigQueryNaming {
652 dataset: "flow_editor_dataset".to_string(),
653 table: "flow_editor_table".to_string(),
654 },
655 modules: vec![],
656 auth: crate::decisions::AuthPolicy::default(),
657 trusted_oidc: crate::runtime::TrustedOidcRuntimeConfig {
658 discovery_json: r#"{"issuer":"https://noop.example.com"}"#.to_string(),
659 jwks_json: r#"{"keys":[]}"#.to_string(),
660 audience: "flow-editor-tests".to_string(),
661 },
662 console: crate::decisions::ConsolePolicy {
663 require_app_auth: false,
664 ..crate::decisions::ConsolePolicy::default()
665 },
666 ops: crate::decisions::RuntimeOpsPolicy::default(),
667 release_metadata: crate::decisions::ReleaseMetadata {
668 targets: vec!["crates.io".to_string()],
669 support_matrix: "lts".to_string(),
670 },
671 }
672 }
673
674 async fn protected_rpc_response(access: Option<AccessController>, body: Value) -> Value {
675 let router =
676 super::flow_editor_rpc_router_with_runtime_catalog(open_decision_state(), None, access);
677 let response = router
678 .oneshot(
679 Request::builder()
680 .method("POST")
681 .uri("/flow-editor/rpc")
682 .header("content-type", "application/json")
683 .body(Body::from(body.to_string()))
684 .expect("request"),
685 )
686 .await
687 .expect("rpc response");
688 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
689 .await
690 .expect("body");
691 serde_json::from_slice(&bytes).expect("json body")
692 }
693
694 fn rpc_body(method: &str, params: Value) -> Value {
695 json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params })
696 }
697
698 #[tokio::test]
699 async fn access_control_denies_authoring_without_a_mobpack_author_grant() {
700 let access = authoring_access_controller(Vec::new());
701 let response =
702 protected_rpc_response(Some(access), rpc_body("mobkit/mobpacks/schema", json!({})))
703 .await;
704 assert_eq!(response["error"]["code"], json!(ACCESS_DENIED_RPC_CODE));
705 assert_eq!(response["error"]["data"]["action"], json!("mobpack.author"));
706 }
707
708 #[tokio::test]
709 async fn access_control_allows_authoring_with_a_mobpack_author_grant() {
710 let access =
711 authoring_access_controller(vec![allow_everyone("authors", &["mobpack.author"])]);
712 let response =
713 protected_rpc_response(Some(access), rpc_body("mobkit/mobpacks/schema", json!({})))
714 .await;
715 assert!(response["error"].is_null(), "{response:#?}");
716 assert!(response["result"].is_object(), "{response:#?}");
717 }
718
719 #[tokio::test]
720 async fn access_control_requires_mobpack_deploy_for_deploy_execute() {
721 let access =
722 authoring_access_controller(vec![allow_everyone("authors", &["mobpack.author"])]);
723 let response = protected_rpc_response(
724 Some(access),
725 rpc_body("mobkit/mobpacks/deploy", json!({ "execute": true })),
726 )
727 .await;
728 assert_eq!(response["error"]["code"], json!(ACCESS_DENIED_RPC_CODE));
729 assert_eq!(response["error"]["data"]["action"], json!("mobpack.deploy"));
730 }
731
732 #[tokio::test]
733 async fn access_control_capabilities_intersect_deploy_grants() {
734 let access =
735 authoring_access_controller(vec![allow_everyone("authors", &["mobpack.author"])]);
736 let response =
737 protected_rpc_response(Some(access), rpc_body("mobkit/capabilities", Value::Null))
738 .await;
739 let capabilities = &response["result"]["authoring_capabilities"];
740 assert_eq!(capabilities["deploy_execute_allowed"], json!(false));
741 assert_eq!(capabilities["host_mutation_allowed"], json!(false));
742
743 let access = authoring_access_controller(vec![allow_everyone(
744 "operators",
745 &["mobpack.author", "mobpack.deploy"],
746 )]);
747 let response =
748 protected_rpc_response(Some(access), rpc_body("mobkit/capabilities", Value::Null))
749 .await;
750 let capabilities = &response["result"]["authoring_capabilities"];
751 assert_eq!(capabilities["deploy_execute_allowed"], json!(true));
752 assert_eq!(capabilities["host_mutation_allowed"], json!(true));
753 }
754
755 #[tokio::test]
756 async fn access_control_disabled_leaves_authoring_open() {
757 let response =
758 protected_rpc_response(None, rpc_body("mobkit/mobpacks/schema", json!({}))).await;
759 assert!(response["error"].is_null(), "{response:#?}");
760 }
761
762 use super::AccessController;
763 use crate::http_console::ACCESS_DENIED_RPC_CODE;
764
765 #[test]
766 fn flow_editor_frontend_router_merges_with_console_frontend_router() {
767 let _ = crate::http_console::console_frontend_router()
771 .merge(super::flow_editor_frontend_router::<()>());
772 }
773
774 #[test]
775 fn flow_editor_rpc_exposes_only_mobpack_authoring_methods() {
776 let response = super::handle_flow_editor_rpc(JsonRpcRequest {
777 jsonrpc: "2.0".to_string(),
778 id: Some(json!(1)),
779 method: "mobkit/capabilities".to_string(),
780 params: Value::Null,
781 });
782 let methods = response["result"]["methods"]
783 .as_array()
784 .expect("methods array")
785 .iter()
786 .filter_map(Value::as_str)
787 .collect::<Vec<_>>();
788 let mut expected_methods = vec!["mobkit/capabilities"];
789 expected_methods.extend_from_slice(crate::rpc::MOBPACK_AUTHORING_METHODS);
790 assert_eq!(methods, expected_methods);
791 assert!(!methods.contains(&"mobkit/console/send"));
792 assert_eq!(response["result"]["authenticated"], json!(false));
793 assert_eq!(response["result"]["auth"]["mode"], json!("none"));
794 assert_eq!(
795 response["result"]["authoring_capabilities"]["domain"],
796 json!("mobpack_authoring")
797 );
798 assert_eq!(
799 response["result"]["authoring_capabilities"]["runtime_mutation"],
800 json!(false)
801 );
802 assert_eq!(
803 response["result"]["authoring_capabilities"]["host_mutation_allowed"],
804 json!(false)
805 );
806 assert_eq!(
807 response["result"]["authoring_capabilities"]["deploy_execute_allowed"],
808 json!(false)
809 );
810 assert_eq!(
811 response["result"]["authoring_capabilities"]["deploy_command"],
812 json!("rkat mob run")
813 );
814 assert_eq!(
815 response["result"]["authoring_capabilities"]["methods"]
816 .as_array()
817 .expect("authoring methods")
818 .iter()
819 .filter_map(Value::as_str)
820 .collect::<Vec<_>>(),
821 crate::rpc::MOBPACK_AUTHORING_METHODS
822 );
823 }
824
825 #[test]
826 fn standalone_flow_editor_rpc_rejects_host_deploy_execution() {
827 let catalogs = super::handle_flow_editor_rpc(JsonRpcRequest {
828 jsonrpc: "2.0".to_string(),
829 id: Some(json!(1)),
830 method: "mobkit/mobpacks/catalogs".to_string(),
831 params: Value::Null,
832 });
833 assert_eq!(catalogs["result"]["runtime_backed"], json!(false));
834 assert_eq!(
835 catalogs["result"]["authoring_provider"]["runtime_binding"],
836 json!("unbound")
837 );
838 let sample = catalogs["result"]["sample_mobpacks"]
839 .as_array()
840 .expect("sample mobpacks")
841 .iter()
842 .find(|sample| sample["id"] == "sample_docs_only")
843 .expect("docs sample");
844
845 let response = super::handle_flow_editor_rpc(JsonRpcRequest {
846 jsonrpc: "2.0".to_string(),
847 id: Some(json!(2)),
848 method: "mobkit/mobpacks/deploy".to_string(),
849 params: json!({
850 "document": sample["document"].clone(),
851 "prompt": "Reply with exactly OK.",
852 "execute": true
853 }),
854 });
855
856 assert_eq!(response["error"]["code"], json!(-32602));
857 assert!(
858 response["error"]["message"]
859 .as_str()
860 .is_some_and(|message| message.contains("cannot execute host deploys")),
861 "{response:#?}"
862 );
863 assert_eq!(
864 response["error"]["data"]["deploy_command"],
865 json!("rkat mob run")
866 );
867 }
868
869 #[test]
870 fn protected_flow_editor_rpc_returns_runtime_bound_catalogs_when_state_is_available() {
871 let response = super::handle_flow_editor_rpc_with_auth(
872 JsonRpcRequest {
873 jsonrpc: "2.0".to_string(),
874 id: Some(json!(1)),
875 method: "mobkit/mobpacks/catalogs".to_string(),
876 params: Value::Null,
877 },
878 super::FlowEditorAuthReport {
879 authenticated: true,
880 mode: "reference_app",
881 reason: "reference app Flow Editor authoring server",
882 host_mutation_allowed: true,
883 deploy_execute_allowed: true,
884 },
885 Some(&crate::mobpack::MobpackRuntimeCatalogState {
886 loaded_modules: vec!["worker".to_string()],
887 runtime_methods: vec!["mobkit/mobpacks/deploy".to_string()],
888 has_contact_directory: true,
889 has_peer_mob_handles: false,
890 has_inproc_contacts: false,
891 runtime_flow_rows: Vec::new(),
892 runtime_agent_definition_sources: Vec::new(),
893 runtime_skill_realms: Vec::new(),
894 }),
895 );
896
897 assert!(response["error"].is_null(), "{response:#?}");
898 assert_eq!(response["result"]["runtime_backed"], json!(true));
899 assert_eq!(
900 response["result"]["authoring_provider"]["id"],
901 json!("unified_runtime")
902 );
903 assert_eq!(
904 response["result"]["authoring_provider"]["runtime_binding"],
905 json!("bound")
906 );
907 assert_eq!(
908 response["result"]["authoring_provider"]["loaded_modules"],
909 json!(["worker"])
910 );
911 assert_eq!(
912 response["result"]["authoring_provider"]["cross_mob"]["contact_directory"],
913 json!(true)
914 );
915 assert_eq!(
916 response["result"]["catalog_snapshot"]["runtime_backed"],
917 json!(true)
918 );
919 }
920
921 #[cfg(unix)]
922 #[test]
923 fn standalone_flow_editor_rpc_executes_host_deploy_when_explicitly_enabled() {
924 let catalogs = super::handle_flow_editor_rpc(JsonRpcRequest {
925 jsonrpc: "2.0".to_string(),
926 id: Some(json!(1)),
927 method: "mobkit/mobpacks/catalogs".to_string(),
928 params: Value::Null,
929 });
930 let sample = catalogs["result"]["sample_mobpacks"]
931 .as_array()
932 .expect("sample mobpacks")
933 .iter()
934 .find(|sample| sample["id"] == "sample_docs_only")
935 .expect("docs sample");
936 let dir = tempfile::tempdir().expect("tempdir");
937 let fake_rkat = dir.path().join("rkat");
938 let args_file = dir.path().join("rkat.args");
939 std::fs::write(
940 &fake_rkat,
941 format!(
942 "#!/bin/sh\n\
943 printf '%s\\n' \"$@\" > {}\n\
944 echo flow-editor-rkat-ok\n\
945 printf 'run\\tmob=docs\\tflow=main\\trun_id=run-1\\tstatus=completed\\n'\n\
946 printf 'result\\t{{\"reply\":\"OK\"}}\\n'\n",
947 args_file.to_string_lossy()
948 ),
949 )
950 .expect("write fake rkat");
951 let mut permissions = std::fs::metadata(&fake_rkat)
952 .expect("fake rkat metadata")
953 .permissions();
954 use std::os::unix::fs::PermissionsExt;
955 permissions.set_mode(0o755);
956 std::fs::set_permissions(&fake_rkat, permissions).expect("chmod fake rkat");
957
958 let capabilities = super::handle_flow_editor_rpc_with_auth(
959 JsonRpcRequest {
960 jsonrpc: "2.0".to_string(),
961 id: Some(json!(2)),
962 method: "mobkit/capabilities".to_string(),
963 params: Value::Null,
964 },
965 super::FlowEditorAuthReport {
966 authenticated: false,
967 mode: "standalone_host_deploy",
968 reason: "standalone Flow Editor authoring server with explicit host deploy opt-in",
969 host_mutation_allowed: true,
970 deploy_execute_allowed: true,
971 },
972 None,
973 );
974 assert_eq!(
975 capabilities["result"]["authoring_capabilities"]["host_mutation_allowed"],
976 json!(true)
977 );
978 assert_eq!(
979 capabilities["result"]["authoring_capabilities"]["deploy_execute_allowed"],
980 json!(true)
981 );
982
983 let response = super::handle_flow_editor_rpc_with_auth(
984 JsonRpcRequest {
985 jsonrpc: "2.0".to_string(),
986 id: Some(json!(3)),
987 method: "mobkit/mobpacks/deploy".to_string(),
988 params: json!({
989 "document": sample["document"].clone(),
990 "output_dir": dir.path(),
991 "prompt": "Reply with exactly OK.",
992 "rkat_bin": fake_rkat,
993 "execute": true
994 }),
995 },
996 super::FlowEditorAuthReport {
997 authenticated: false,
998 mode: "standalone_host_deploy",
999 reason: "standalone Flow Editor authoring server with explicit host deploy opt-in",
1000 host_mutation_allowed: true,
1001 deploy_execute_allowed: true,
1002 },
1003 None,
1004 );
1005
1006 assert!(response["error"].is_null(), "{response:#?}");
1007 assert_eq!(response["result"]["executed"], json!(true));
1008 assert_eq!(response["result"]["success"], json!(true));
1009 assert_eq!(response["result"]["status_code"], json!(0));
1010 assert!(
1011 response["result"]["stdout"]
1012 .as_str()
1013 .is_some_and(|stdout| stdout.contains("flow-editor-rkat-ok")),
1014 "{response:#?}"
1015 );
1016 let argv = std::fs::read_to_string(args_file).expect("recorded fake rkat args");
1017 assert!(argv.lines().any(|line| line == "mob"));
1018 assert!(argv.lines().any(|line| line == "run"));
1019 assert!(
1021 argv.lines()
1022 .any(|line| line == "--prompt=Reply with exactly OK.")
1023 );
1024 }
1025
1026 #[cfg(unix)]
1034 #[test]
1035 fn standalone_flow_editor_validate_cannot_write_files_or_spawn_rkat_without_host_grant() {
1036 let catalogs = super::handle_flow_editor_rpc(JsonRpcRequest {
1037 jsonrpc: "2.0".to_string(),
1038 id: Some(json!(1)),
1039 method: "mobkit/mobpacks/catalogs".to_string(),
1040 params: Value::Null,
1041 });
1042 let sample = catalogs["result"]["sample_mobpacks"]
1043 .as_array()
1044 .expect("sample mobpacks")
1045 .iter()
1046 .find(|sample| sample["id"] == "sample_docs_only")
1047 .expect("docs sample");
1048
1049 let dir = tempfile::tempdir().expect("tempdir");
1050 let fake_rkat = dir.path().join("rkat");
1052 let spawn_marker = dir.path().join("rkat.ran");
1053 std::fs::write(
1054 &fake_rkat,
1055 format!(
1056 "#!/bin/sh\ntouch {}\necho should-not-run\n",
1057 spawn_marker.to_string_lossy()
1058 ),
1059 )
1060 .expect("write fake rkat");
1061 use std::os::unix::fs::PermissionsExt;
1062 let mut permissions = std::fs::metadata(&fake_rkat)
1063 .expect("fake rkat metadata")
1064 .permissions();
1065 permissions.set_mode(0o755);
1066 std::fs::set_permissions(&fake_rkat, permissions).expect("chmod fake rkat");
1067
1068 let attacker_pack_path = dir.path().join("attacker-controlled.mobpack");
1070
1071 let response = super::handle_flow_editor_rpc(JsonRpcRequest {
1072 jsonrpc: "2.0".to_string(),
1073 id: Some(json!(2)),
1074 method: "mobkit/mobpacks/validate".to_string(),
1075 params: json!({
1076 "document": sample["document"].clone(),
1077 "rkat_validate": true,
1078 "rkat_bin": fake_rkat,
1079 "validation_pack_path": attacker_pack_path,
1080 }),
1081 });
1082
1083 assert!(response["error"].is_null(), "{response:#?}");
1085 assert_eq!(response["result"]["ok"], json!(true), "{response:#?}");
1086 assert_ne!(
1088 response["result"]["validation_source"],
1089 json!("rkat mob validate"),
1090 "rkat validate must not run without the host grant: {response:#?}"
1091 );
1092 assert!(
1093 !attacker_pack_path.exists(),
1094 "validate must not write a caller-controlled archive without the host grant"
1095 );
1096 assert!(
1097 !spawn_marker.exists(),
1098 "validate must not spawn rkat without the host grant"
1099 );
1100 }
1101
1102 #[tokio::test]
1106 async fn access_control_requires_mobpack_deploy_for_validate_with_rkat() {
1107 let access =
1108 authoring_access_controller(vec![allow_everyone("authors", &["mobpack.author"])]);
1109 let response = protected_rpc_response(
1110 Some(access),
1111 rpc_body(
1112 "mobkit/mobpacks/validate",
1113 json!({ "document": json!({}), "rkat_validate": true }),
1114 ),
1115 )
1116 .await;
1117 assert_eq!(response["error"]["code"], json!(ACCESS_DENIED_RPC_CODE));
1118 assert_eq!(response["error"]["data"]["action"], json!("mobpack.deploy"));
1119
1120 let access =
1123 authoring_access_controller(vec![allow_everyone("authors", &["mobpack.author"])]);
1124 let response = protected_rpc_response(
1125 Some(access),
1126 rpc_body("mobkit/mobpacks/validate", json!({ "document": json!({}) })),
1127 )
1128 .await;
1129 assert_ne!(
1130 response["error"]["code"],
1131 json!(ACCESS_DENIED_RPC_CODE),
1132 "structural validate must remain an authoring read: {response:#?}"
1133 );
1134 }
1135
1136 #[test]
1137 fn flow_editor_rpc_plans_real_sample_mobpack_deploy() {
1138 let catalogs = super::handle_flow_editor_rpc(JsonRpcRequest {
1139 jsonrpc: "2.0".to_string(),
1140 id: Some(json!(1)),
1141 method: "mobkit/mobpacks/catalogs".to_string(),
1142 params: Value::Null,
1143 });
1144 let sample = catalogs["result"]["sample_mobpacks"]
1145 .as_array()
1146 .expect("sample mobpacks")
1147 .iter()
1148 .find(|sample| sample["id"] == "sample_docs_only")
1149 .expect("docs sample");
1150
1151 let response = super::handle_flow_editor_rpc(JsonRpcRequest {
1152 jsonrpc: "2.0".to_string(),
1153 id: Some(json!(2)),
1154 method: "mobkit/mobpacks/deploy".to_string(),
1155 params: json!({
1156 "document": sample["document"].clone(),
1157 "prompt": "Reply with exactly OK."
1158 }),
1159 });
1160
1161 assert!(response["error"].is_null(), "{response:#?}");
1162 assert_eq!(
1163 &response["result"]["argv"].as_array().expect("argv")[0..3],
1164 [json!("rkat"), json!("mob"), json!("run")]
1165 );
1166 assert!(
1167 response["result"]["plan_trace"]
1168 .as_array()
1169 .expect("plan trace")
1170 .iter()
1171 .any(|row| row["head"]
1172 .as_str()
1173 .is_some_and(|head| head.starts_with("PROFILE ·"))),
1174 "{response:#?}"
1175 );
1176 }
1177
1178 #[test]
1179 fn flow_editor_rpc_previews_document_backed_deploy_command() {
1180 let catalogs = super::handle_flow_editor_rpc(JsonRpcRequest {
1181 jsonrpc: "2.0".to_string(),
1182 id: Some(json!(1)),
1183 method: "mobkit/mobpacks/catalogs".to_string(),
1184 params: Value::Null,
1185 });
1186 let sample = catalogs["result"]["sample_mobpacks"]
1187 .as_array()
1188 .expect("sample mobpacks")
1189 .iter()
1190 .find(|sample| sample["id"] == "sample_docs_only")
1191 .expect("docs sample");
1192 let response = super::handle_flow_editor_rpc(JsonRpcRequest {
1193 jsonrpc: "2.0".to_string(),
1194 id: Some(json!(2)),
1195 method: "mobkit/mobpacks/deploy_command".to_string(),
1196 params: json!({
1197 "document": sample["document"].clone(),
1198 "prompt": "Preview prompt."
1199 }),
1200 });
1201
1202 assert!(response["error"].is_null(), "{response:#?}");
1203 assert_eq!(
1204 &response["result"]["argv"].as_array().expect("argv")[0..3],
1205 [json!("rkat"), json!("mob"), json!("run")]
1206 );
1207 assert_eq!(
1208 response["result"]["source"],
1209 json!("meerkat_mobkit::mobpack::deploy_argv")
1210 );
1211 assert_eq!(response["result"]["filename"], json!("docs-only.mobpack"));
1212 assert_eq!(response["result"]["validation"]["ok"], json!(true));
1213 assert!(
1214 response["result"]["command"]
1215 .as_str()
1216 .is_some_and(|command| command.contains("docs-only.mobpack")
1217 && command.contains("Preview prompt.")),
1218 "{response:#?}"
1219 );
1220
1221 let rejected = super::handle_flow_editor_rpc(JsonRpcRequest {
1222 jsonrpc: "2.0".to_string(),
1223 id: Some(json!(3)),
1224 method: "mobkit/mobpacks/deploy_command".to_string(),
1225 params: json!({
1226 "deploy": { "command": "rkat mob run" },
1227 "pack_path": "<pack.mobpack>"
1228 }),
1229 });
1230 assert!(
1231 rejected["error"]["message"]
1232 .as_str()
1233 .is_some_and(|message| message.contains("requires document")),
1234 "{rejected:#?}"
1235 );
1236 }
1237
1238 #[test]
1239 fn flow_editor_rpc_previews_source_without_exporting_archive_payload() {
1240 let catalogs = super::handle_flow_editor_rpc(JsonRpcRequest {
1241 jsonrpc: "2.0".to_string(),
1242 id: Some(json!(1)),
1243 method: "mobkit/mobpacks/catalogs".to_string(),
1244 params: Value::Null,
1245 });
1246 let sample = catalogs["result"]["sample_mobpacks"]
1247 .as_array()
1248 .expect("sample mobpacks")
1249 .iter()
1250 .find(|sample| sample["id"] == "sample_docs_only")
1251 .expect("docs sample");
1252
1253 let response = super::handle_flow_editor_rpc(JsonRpcRequest {
1254 jsonrpc: "2.0".to_string(),
1255 id: Some(json!(2)),
1256 method: "mobkit/mobpacks/source".to_string(),
1257 params: json!({ "document": sample["document"].clone() }),
1258 });
1259
1260 assert!(response["error"].is_null(), "{response:#?}");
1261 assert_eq!(
1262 response["result"]["source"],
1263 json!("mobkit/mobpacks/source")
1264 );
1265 assert!(
1266 response["result"].get("content_base64").is_none(),
1267 "{response:#?}"
1268 );
1269 let source_files = response["result"]["source_files"]
1270 .as_array()
1271 .expect("source files");
1272 assert!(
1273 source_files
1274 .iter()
1275 .any(|file| file["path"] == "mobkit/mob.toml"
1276 && file["text"]
1277 .as_str()
1278 .is_some_and(|text| text.contains("[mob]"))),
1279 "{response:#?}"
1280 );
1281 }
1282}