zebflow 0.11.14

Reactive web automation platform — pipelines, templates, and real-time collaboration in a single binary.
Documentation
//! What of a page's state may reach the browser.
//!
//! The rule: **the browser never receives the request.** A rendered page's
//! hydration state (`<script id="__rwe_payload">`) holds no request headers —
//! the visitor's address, a proxy's internal address chain, `host`, `cookie`,
//! the user agent — no cookies, and no trigger envelope's `auth`. Server-side
//! rendering still reads all of it; a page that wants a request value in the
//! browser copies it into a key of its own upstream, where the pipeline says
//! so. A page generated by `web.site.generate` is not an answer to a request
//! at all, and its whole input passes through the same function.
//!
//! The root `auth` stays: it is already cut to the claims the token marks
//! `_zf_public` before a page renders, which is how a token publishes a claim
//! on purpose.

use serde_json::Value;

/// The trigger envelopes a payload carries, by their answer key — every
/// source `pipeline::nodes::basic::trigger::SOURCE_KEYS` lists (a test there
/// keeps the two lists one).
pub const TRIGGER_SOURCES: [&str; 9] = ["webhook", "manual", "schedule", "function", "mcp", "error", "room", "socket", "topic"];

/// The parts of a trigger envelope that belong to the request, never to a page.
pub const REQUEST_ONLY_KEYS: [&str; 3] = ["headers", "cookies", "auth"];

/// The keys a renderer injects at the root for server rendering that are the
/// request's own (`headers`), and a cookie jar wherever it was put.
const REQUEST_ONLY_ROOT_KEYS: [&str; 2] = ["headers", "cookies"];

/// `state` without the request: every trigger envelope loses its headers,
/// cookies and auth, and the root its injected headers and cookies. A node's
/// answer (`query`, `record`, …) is the author's to render and is untouched.
pub fn browser_state(state: &Value) -> Value {
    let mut state = state.clone();
    if let Some(map) = state.as_object_mut() {
        for key in REQUEST_ONLY_ROOT_KEYS {
            map.remove(key);
        }
        for source in TRIGGER_SOURCES {
            if let Some(Value::Object(envelope)) = map.get_mut(source) {
                for key in REQUEST_ONLY_KEYS {
                    envelope.remove(key);
                }
            }
        }
    }
    state
}

#[cfg(test)]
mod tests {
    use serde_json::json;

    use super::browser_state;

    #[test]
    fn the_request_never_reaches_the_browser_and_the_page_data_does() {
        let state = json!({
            "headers": { "x-forwarded-for": "203.0.113.7, 10.1.2.3", "host": "site-a.example" },
            "cookies": { "session": "s1" },
            "auth": { "sub": "user-1" },
            "route": "/book",
            "webhook": { "body": { "title": "Demo" }, "query": { "q": "1" }, "headers": { "x-real-ip": "10.1.2.3" }, "cookies": { "s": "x" }, "auth": { "sub": "user-1", "email": "a@example.com" } },
            "room": { "message": "hi", "auth": { "sub": "user-2" } },
            "query": { "rows": [{ "headers": "a column, not a request" }] },
        });
        let browser = browser_state(&state);
        assert!(browser.get("headers").is_none() && browser.get("cookies").is_none(), "{browser}");
        assert_eq!(browser["webhook"], json!({ "body": { "title": "Demo" }, "query": { "q": "1" } }));
        assert_eq!(browser["room"], json!({ "message": "hi" }));
        assert_eq!(browser["query"], state["query"], "a node's answer is the author's to render");
        assert_eq!(browser["auth"], json!({ "sub": "user-1" }), "the root auth is already cut to its public claims");
        assert_eq!(browser["route"], "/book");
        assert_eq!(state["headers"]["host"], "site-a.example", "the server's copy is untouched");
    }
}