From ca8f7d1ab2004fe36c5b9885dc11be955d7aa42a Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 4 Jun 2026 16:39:20 -0700 Subject: [PATCH] feat(hephd): CORS + optional static serving on the hub HTTP endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a permissive CORS middleware (answers the browser OPTIONS preflight and stamps Access-Control-* on every response) and an optional --web-root static file handler with an index.html SPA fallback. Together these let a browser surface — the forthcoming heph-pwa mobile app — call /rpc cross-origin or be hosted same-origin straight from the hub. No new crate dependencies; file reads run on the blocking pool. Covered by tests/web_serve.rs. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/hephd/src/main.rs | 11 ++- crates/hephd/src/sync.rs | 117 ++++++++++++++++++++++- crates/hephd/tests/web_serve.rs | 163 ++++++++++++++++++++++++++++++++ 3 files changed, 287 insertions(+), 4 deletions(-) create mode 100644 crates/hephd/tests/web_serve.rs diff --git a/crates/hephd/src/main.rs b/crates/hephd/src/main.rs index ad3517c..c8e4e25 100644 --- a/crates/hephd/src/main.rs +++ b/crates/hephd/src/main.rs @@ -60,6 +60,12 @@ struct Cli { #[arg(long)] http_addr: Option, + /// Directory of static files to serve for non-API paths (server mode). Point + /// this at the `heph-pwa/` shell to host the mobile app same-origin from the + /// hub. Unset: the hub serves only its API routes. + #[arg(long)] + web_root: Option, + /// Server to proxy to (client mode only; required there). #[arg(long)] server_url: Option, @@ -190,7 +196,10 @@ async fn main() -> Result<()> { anyhow::bail!("--oidc-issuer and --oidc-audience must be set together") } }; - let app = sync::router(daemon.store(), verifier); + if let Some(root) = cli.web_root.as_deref() { + tracing::info!(web_root = %root.display(), "hub serving static PWA shell"); + } + let app = sync::router_with_web(daemon.store(), verifier, cli.web_root.clone()); let http_listener = TcpListener::bind(&addr) .await .with_context(|| format!("binding hub HTTP endpoint {addr}"))?; diff --git a/crates/hephd/src/sync.rs b/crates/hephd/src/sync.rs index 266cae1..41e5524 100644 --- a/crates/hephd/src/sync.rs +++ b/crates/hephd/src/sync.rs @@ -10,6 +10,12 @@ //! - `POST /rpc` — the full daemon API ([`crate::rpc::dispatch`]) over HTTP, for //! a no-replica `client`-mode [`crate::remote::RemoteStore`] to proxy against. //! +//! All routes carry permissive CORS headers and answer the browser preflight +//! (`OPTIONS`), so a browser surface (the `heph-pwa` mobile app) can call `/rpc` +//! cross-origin. When the hub is given a `web_root`, unmatched paths fall back to +//! serving that directory's static files (the PWA shell), so the app can be +//! hosted same-origin straight from the hub. +//! //! Exchange is **incremental by HLC cursor** (`sync_state`, [`heph_core::SyncCursors`]): //! each side transfers only the tail it hasn't sent/seen. Merge is idempotent, //! so a re-pushed op the hub already has is a harmless no-op. When the hub is @@ -17,13 +23,14 @@ //! OIDC bearer token whose `sub` owns the hub (tech-spec §13); spokes attach //! that token via the `bearer` argument to [`sync_once`]. +use std::path::PathBuf; use std::sync::{Arc, Mutex}; use anyhow::Result; use axum::extract::{Query, Request, State}; -use axum::http::StatusCode; +use axum::http::{header, HeaderValue, Method, StatusCode, Uri}; use axum::middleware::{self, Next}; -use axum::response::Response as AxumResponse; +use axum::response::{IntoResponse, Response as AxumResponse}; use axum::routing::{get, post}; use axum::{Json, Router}; use serde::{Deserialize, Serialize}; @@ -44,6 +51,9 @@ pub type SharedStore = Arc>; struct HubState { store: SharedStore, verifier: Option>, + /// When set, unmatched paths serve static files from this directory (the + /// `heph-pwa` shell), so the app can be hosted same-origin from the hub. + web_root: Option, } /// A batch of ops in flight (push body / pull response). @@ -102,15 +112,116 @@ fn apply_batch( /// `verifier` is `Some`, every route requires a valid OIDC bearer token whose /// `sub` owns this hub (tech-spec §13); `None` leaves the hub open (local dev). pub fn router(store: SharedStore, verifier: Option>) -> Router { - let state = HubState { store, verifier }; + router_with_web(store, verifier, None) +} + +/// [`router`] plus an optional `web_root`: when `Some(dir)`, paths that don't +/// match an API route serve static files from `dir` (the `heph-pwa` shell), +/// with a `index.html` fallback so the single-page app can deep-link. Static +/// files are served without authentication — they are only the app shell; all +/// data still flows through the auth-gated `/rpc` and `/sync/*` routes. +pub fn router_with_web( + store: SharedStore, + verifier: Option>, + web_root: Option, +) -> Router { + let state = HubState { + store, + verifier, + web_root, + }; Router::new() .route("/sync/pull", get(pull)) .route("/sync/push", post(push)) .route("/rpc", post(rpc_call)) .route_layer(middleware::from_fn_with_state(state.clone(), require_auth)) + // The static shell is unauthenticated and lives behind the API routes. + .fallback(serve_static) + // Outermost: stamp CORS headers on every response and short-circuit the + // browser's `OPTIONS` preflight (before it reaches auth or routing). + .layer(middleware::from_fn(cors)) .with_state(state) } +/// Permissive-CORS middleware. Answers the browser preflight (`OPTIONS`) with a +/// 204 and stamps `Access-Control-*` headers on every response. The hub is a +/// personal endpoint guarded by bearer tokens (not cookies), so a wildcard +/// origin is safe — there are no ambient credentials for `*` to expose. +async fn cors(request: Request, next: Next) -> AxumResponse { + let is_preflight = request.method() == Method::OPTIONS; + let mut response = if is_preflight { + StatusCode::NO_CONTENT.into_response() + } else { + next.run(request).await + }; + let h = response.headers_mut(); + h.insert( + header::ACCESS_CONTROL_ALLOW_ORIGIN, + HeaderValue::from_static("*"), + ); + h.insert( + header::ACCESS_CONTROL_ALLOW_METHODS, + HeaderValue::from_static("GET, POST, OPTIONS"), + ); + h.insert( + header::ACCESS_CONTROL_ALLOW_HEADERS, + HeaderValue::from_static("authorization, content-type"), + ); + h.insert( + header::ACCESS_CONTROL_MAX_AGE, + HeaderValue::from_static("86400"), + ); + response +} + +/// Serve the PWA shell from `web_root` for any non-API path. Returns 404 when no +/// `web_root` is configured. Unknown paths fall back to `index.html` so the SPA +/// can own its own routing. Path traversal (`..`) is rejected. +async fn serve_static(State(state): State, uri: Uri) -> AxumResponse { + let Some(root) = state.web_root.as_ref() else { + return StatusCode::NOT_FOUND.into_response(); + }; + let rel = uri.path().trim_start_matches('/'); + if rel.split('/').any(|seg| seg == "..") { + return StatusCode::BAD_REQUEST.into_response(); + } + let rel = if rel.is_empty() { "index.html" } else { rel }; + + let direct = root.join(rel); + let index = root.join("index.html"); + // File reads run on the blocking pool (tokio's `fs` feature is off, and DB / + // disk I/O never runs on an async worker, tech-spec §3). + let read = tokio::task::spawn_blocking(move || { + match std::fs::read(&direct) { + Ok(bytes) => Some((content_type(&direct), bytes)), + // SPA fallback: serve index.html for unknown (extension-less) routes. + Err(_) => std::fs::read(&index) + .ok() + .map(|bytes| ("text/html; charset=utf-8", bytes)), + } + }) + .await; + match read { + Ok(Some((ctype, bytes))) => ([(header::CONTENT_TYPE, ctype)], bytes).into_response(), + _ => StatusCode::NOT_FOUND.into_response(), + } +} + +/// Best-effort content type from a file extension (the handful the PWA serves). +fn content_type(path: &std::path::Path) -> &'static str { + match path.extension().and_then(|e| e.to_str()) { + Some("html") => "text/html; charset=utf-8", + Some("js" | "mjs") => "text/javascript; charset=utf-8", + Some("css") => "text/css; charset=utf-8", + Some("json" | "webmanifest") => "application/json; charset=utf-8", + Some("svg") => "image/svg+xml", + Some("png") => "image/png", + Some("ico") => "image/x-icon", + Some("woff2") => "font/woff2", + _ => "application/octet-stream", + } +} + /// Reject any request lacking a valid bearer token whose `sub` owns this hub. /// A no-op when the hub has no verifier configured (open dev mode). async fn require_auth( diff --git a/crates/hephd/tests/web_serve.rs b/crates/hephd/tests/web_serve.rs new file mode 100644 index 0000000..b176137 --- /dev/null +++ b/crates/hephd/tests/web_serve.rs @@ -0,0 +1,163 @@ +//! The hub's browser-facing surface (for the `heph-pwa` mobile app): permissive +//! CORS on every response, an `OPTIONS` preflight answer, and—when a `web_root` +//! is configured—static serving of the app shell with an `index.html` SPA +//! fallback. A tiny raw-HTTP client keeps this dependency-free and lets us drive +//! arbitrary methods (`OPTIONS`) and inspect response headers directly. + +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::sync::mpsc; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +use heph_core::{FixedClock, LocalStore}; +use hephd::sync::{self, SharedStore}; + +const NOW: i64 = 1_704_067_200_000; // 2024-01-01T00:00:00Z + +/// One parsed HTTP response: status line code, lowercased headers, and body. +struct Resp { + status: u16, + headers: Vec<(String, String)>, + body: String, +} + +impl Resp { + fn header(&self, name: &str) -> Option<&str> { + let name = name.to_ascii_lowercase(); + self.headers + .iter() + .find(|(k, _)| *k == name) + .map(|(_, v)| v.as_str()) + } +} + +/// Issue one HTTP/1.1 request over a fresh connection (`Connection: close`, so +/// we can read the whole response to EOF) and parse the response. +fn request(addr: &str, method: &str, path: &str) -> Resp { + let mut stream = TcpStream::connect(addr).unwrap(); + let req = format!("{method} {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n"); + stream.write_all(req.as_bytes()).unwrap(); + let mut raw = String::new(); + stream.read_to_string(&mut raw).unwrap(); + + let (head, body) = raw.split_once("\r\n\r\n").unwrap_or((&raw, "")); + let mut lines = head.split("\r\n"); + let status = lines + .next() + .and_then(|l| l.split_whitespace().nth(1)) + .and_then(|c| c.parse().ok()) + .unwrap(); + let headers = lines + .filter_map(|l| l.split_once(": ")) + .map(|(k, v)| (k.to_ascii_lowercase(), v.to_string())) + .collect(); + Resp { + status, + headers, + body: body.to_string(), + } +} + +/// Start the hub router (with the given `web_root`) over a temp `LocalStore` on +/// an ephemeral port; return its `host:port`. The server thread + temp dirs live +/// for the test's duration. +fn start(web_root: Option) -> String { + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async move { + let dir = tempfile::tempdir().unwrap(); + let store = + LocalStore::open(dir.path().join("heph.db"), Box::new(FixedClock(NOW))).unwrap(); + let shared: SharedStore = Arc::new(Mutex::new(store)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + tx.send(listener.local_addr().unwrap()).unwrap(); + let _keep = dir; + let app = sync::router_with_web(shared, None, web_root); + axum::serve(listener, app).await.unwrap(); + }); + }); + rx.recv_timeout(Duration::from_secs(5)).unwrap().to_string() +} + +#[test] +fn cors_headers_on_rpc_and_preflight_answered() { + let addr = start(None); + + // The browser preflight gets a 204 with the CORS allowances, without auth. + let pre = request(&addr, "OPTIONS", "/rpc"); + assert_eq!(pre.status, 204); + assert_eq!(pre.header("access-control-allow-origin"), Some("*")); + assert!(pre + .header("access-control-allow-headers") + .unwrap() + .contains("authorization")); + assert!(pre + .header("access-control-allow-methods") + .unwrap() + .contains("POST")); + + // A regular GET also carries the origin header (so XHR can read the body). + let get = request(&addr, "GET", "/sync/pull"); + assert_eq!(get.header("access-control-allow-origin"), Some("*")); +} + +#[test] +fn serves_static_shell_with_index_fallback() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("index.html"), + "heph", + ) + .unwrap(); + std::fs::write(dir.path().join("app.js"), "export const x = 1;\n").unwrap(); + let addr = start(Some(dir.path().to_path_buf())); + + // Root serves index.html as HTML. + let root = request(&addr, "GET", "/"); + assert_eq!(root.status, 200); + assert!(root.body.contains("heph")); + assert_eq!( + root.header("content-type"), + Some("text/html; charset=utf-8") + ); + + // A real asset is served with a JS content type. + let js = request(&addr, "GET", "/app.js"); + assert_eq!(js.status, 200); + assert!(js.body.contains("export const x")); + assert_eq!( + js.header("content-type"), + Some("text/javascript; charset=utf-8") + ); + + // An unknown (extension-less) route falls back to index.html for the SPA. + let deep = request(&addr, "GET", "/inbox"); + assert_eq!(deep.status, 200); + assert!(deep.body.contains("heph")); + + // Path traversal never escapes web_root (whether the client/proxy normalizes + // the `..` away or our guard rejects it, the crate's Cargo.toml never leaks). + let escape = request(&addr, "GET", "/../../Cargo.toml"); + assert!( + !escape.body.contains("[package]"), + "must not serve files outside web_root" + ); + + // The temp dir must outlive the server thread's reads. + drop(dir); +} + +#[test] +fn no_web_root_yields_404_for_static_paths() { + let addr = start(None); + let resp = request(&addr, "GET", "/inbox"); + assert_eq!(resp.status, 404); + // Even the 404 carries CORS headers (it passed through the layer). + assert_eq!(resp.header("access-control-allow-origin"), Some("*")); +}