feat(hephd): CORS + optional static serving on the hub HTTP endpoint

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) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-04 16:39:20 -07:00
commit ca8f7d1ab2
3 changed files with 287 additions and 4 deletions

View file

@ -60,6 +60,12 @@ struct Cli {
#[arg(long)]
http_addr: Option<String>,
/// 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<PathBuf>,
/// Server to proxy to (client mode only; required there).
#[arg(long)]
server_url: Option<String>,
@ -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}"))?;

View file

@ -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<Mutex<dyn Store + Send>>;
struct HubState {
store: SharedStore,
verifier: Option<Arc<dyn TokenVerifier>>,
/// 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<PathBuf>,
}
/// 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<Arc<dyn TokenVerifier>>) -> 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<Arc<dyn TokenVerifier>>,
web_root: Option<PathBuf>,
) -> 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<HubState>, 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(

View file

@ -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<std::path::PathBuf>) -> 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"),
"<!doctype html><title>heph</title>",
)
.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("<title>heph</title>"));
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("<title>heph</title>"));
// 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("*"));
}