Skip to main content

meerkat_mobkit/
http_flow_editor.rs

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
22/// Standalone HTTP surface for the MobKit Flow Editor.
23///
24/// This route plane deliberately serves only the editor shell and the
25/// mobpack-authoring JSON-RPC methods. Console runtime methods remain on the
26/// console RPC plane.
27pub 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
39/// Quiet-favicon route for the standalone Flow Editor server only. It must
40/// not be part of `flow_editor_frontend_router`: hosts that merge the Flow
41/// Editor with the console (for example `build_reference_app_router`) already
42/// register `/favicon.ico`, and axum panics on duplicate routes.
43fn 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
220/// Run the synchronous flow-editor RPC dispatcher on the blocking pool.
221///
222/// `mobkit/mobpacks/deploy` with `execute: true` (and `validate` via
223/// `rkat mob validate`) blocks on a child process for up to the deploy
224/// execution timeout; parking that wait on a tokio worker thread would
225/// starve the async runtime, so every HTTP entry point routes through
226/// `spawn_blocking` here.
227async 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    // Intersect capability advertisements with the caller's ABAC grants so
317    // the editor does not surface deploy affordances the caller can never
318    // use; per-call enforcement above remains authoritative.
319    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
340/// Map a flow-editor RPC method to the ABAC action it requires. Mirrors the
341/// console's `console_rpc_access_requirement`: `mobkit/capabilities` stays
342/// open (it only describes the method surface), every mobpack-authoring
343/// method requires `mobpack.author`, and a deploy with `execute: true`
344/// requires `mobpack.deploy` instead.
345fn 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        // Unknown methods fall through to the dispatcher's method-not-found.
351        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    // `validate` with `rkat_validate: true` writes a rendered mobpack archive
362    // to a caller-controlled path and spawns `rkat mob validate` — a
363    // host-mutating side effect, not a pure authoring read. Gate it behind
364    // the same deploy grant as host-executing deploys.
365    if method == "mobkit/mobpacks/validate" && validate_rkat_execution_requested(params) {
366        return Some(ACTION_MOBPACK_DEPLOY);
367    }
368    Some(ACTION_MOBPACK_AUTHOR)
369}
370
371/// True when a `mobkit/mobpacks/validate` request asks to render the pack and
372/// run `rkat mob validate` on the host (mirrors
373/// `mobpack::validate_with_rkat_requested`). This is the host-mutating arm:
374/// it writes a mobpack archive and spawns a child process.
375fn 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        // `validate` with `rkat_validate: true` is host-mutating (it writes a
501        // rendered mobpack archive to a caller-controlled path and spawns
502        // `rkat mob validate`). Without the host grant, strip the flag so the
503        // request degrades to pure structural validation instead of touching
504        // the host filesystem or spawning a process. Fail-closed: the default
505        // standalone surface (no `--allow-host-deploy`) and any caller lacking
506        // `mobpack.deploy` can never reach the file-write/exec sink.
507        "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                &params,
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        // `build_reference_app_router` merges both frontends into one app
768        // router; duplicate paths (such as /favicon.ico) make axum panic at
769        // startup, so the shared frontend router must stay collision-free.
770        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        // Single-token hyphen-safe `--prompt=<text>` form.
1020        assert!(
1021            argv.lines()
1022                .any(|line| line == "--prompt=Reply with exactly OK.")
1023        );
1024    }
1025
1026    /// Security regression: `mobkit/mobpacks/validate` with
1027    /// `rkat_validate: true` writes a rendered mobpack archive to a
1028    /// caller-controlled path and spawns `rkat mob validate`. On the default
1029    /// standalone surface (no `--allow-host-deploy`, `host_mutation_allowed`
1030    /// false) an unauthenticated client must NOT reach that file-write/exec
1031    /// sink: the request degrades to structural-only validation, leaving no
1032    /// archive on disk and spawning no process.
1033    #[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        // A `rkat` that records the fact it ran — it must never be invoked.
1051        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        // Attacker-controlled absolute pack path: it must never be written.
1069        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        // Structural validation still runs and succeeds …
1084        assert!(response["error"].is_null(), "{response:#?}");
1085        assert_eq!(response["result"]["ok"], json!(true), "{response:#?}");
1086        // … but it did NOT escalate to the host-executing rkat path.
1087        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    /// The ABAC access requirement classifies validate-with-rkat as a
1103    /// host-mutating operation: it requires `mobpack.deploy`, not just
1104    /// `mobpack.author`. A pure structural validate stays an authoring read.
1105    #[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        // A structural-only validate is authoring, not deploy: the author
1121        // grant is sufficient (no access-denied).
1122        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}