From ca8f7d1ab2004fe36c5b9885dc11be955d7aa42a Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 4 Jun 2026 16:39:20 -0700 Subject: [PATCH 1/7] 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("*")); +} From c3111d498bc55882e1f3ce1fbcea88a4d4421062 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 4 Jun 2026 16:42:09 -0700 Subject: [PATCH 2/7] feat(heph-pwa): port quickadd + datespec parsers to JS (with parity tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Faithful JS ports of hephd's quickadd.rs / datespec.rs so the PWA's quick-add accepts the identical syntax (p1-4, #Project greedy match, today/+3d/fri/ISO, 'every …' recurrence) and produces the same RRULEs and local-midnight do-dates as the CLI/TUI. test/parsers.test.mjs replays the Rust unit cases under `node --test` (13/13 pass). Co-Authored-By: Claude Opus 4.8 (1M context) --- heph-pwa/src/datespec.js | 221 +++++++++++++++++++++++++++++++++ heph-pwa/src/quickadd.js | 113 +++++++++++++++++ heph-pwa/test/parsers.test.mjs | 125 +++++++++++++++++++ 3 files changed, 459 insertions(+) create mode 100644 heph-pwa/src/datespec.js create mode 100644 heph-pwa/src/quickadd.js create mode 100644 heph-pwa/test/parsers.test.mjs diff --git a/heph-pwa/src/datespec.js b/heph-pwa/src/datespec.js new file mode 100644 index 0000000..7c2986a --- /dev/null +++ b/heph-pwa/src/datespec.js @@ -0,0 +1,221 @@ +// Human-friendly date and recurrence parsing — a faithful JS port of hephd's +// `datespec.rs` (tech-spec §1, §8, §8.1) so the PWA's quick-add accepts the +// exact same forms as the CLI/TUI and produces identical RRULEs and do-dates. +// +// Dates are date-grained and stored as epoch ms at *local midnight* (matching +// `to_epoch_ms`). All pure functions take an explicit `today` so they stay +// deterministically testable; the thin wrappers read the local clock. + +/** A local-midnight Date for today (time component stripped). */ +export function today() { + const n = new Date(); + return new Date(n.getFullYear(), n.getMonth(), n.getDate()); +} + +/** Local-midnight epoch ms for a Date (the form do_date/late_on are stored in). */ +export function toEpochMs(date) { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); +} + +function addDays(date, n) { + return new Date(date.getFullYear(), date.getMonth(), date.getDate() + n); +} +function addMonths(date, n) { + return new Date(date.getFullYear(), date.getMonth() + n, date.getDate()); +} + +// JS getDay(): 0=Sun..6=Sat. +const WEEKDAYS = { + mon: 1, monday: 1, + tue: 2, tues: 2, tuesday: 2, + wed: 3, weds: 3, wednesday: 3, + thu: 4, thur: 4, thurs: 4, thursday: 4, + fri: 5, friday: 5, + sat: 6, saturday: 6, + sun: 0, sunday: 0, +}; +const BYDAY = { 0: "SU", 1: "MO", 2: "TU", 3: "WE", 4: "TH", 5: "FR", 6: "SA" }; + +/** Weekday name (full or common abbreviation) → JS day index, or null. */ +function parseWeekday(s) { + return Object.prototype.hasOwnProperty.call(WEEKDAYS, s) ? WEEKDAYS[s] : null; +} + +/** The soonest date on/after `today` whose weekday is `wd` (JS day index). */ +function soonestWeekday(today, wd) { + let d = today; + for (let i = 0; i < 7; i++) { + if (d.getDay() === wd) return d; + d = addDays(d, 1); + } + return today; +} + +function parseOffset(rest, today) { + rest = rest.trim(); + const m = rest.match(/^(\d+)\s*([a-z]*)$/); + if (!m) throw new Error(`not a relative date offset: +${rest}`); + const n = parseInt(m[1], 10); + switch (m[2]) { + case "": case "d": case "day": case "days": return addDays(today, n); + case "w": case "wk": case "week": case "weeks": return addDays(today, n * 7); + case "m": case "mo": case "month": case "months": return addMonths(today, n); + default: throw new Error(`unknown offset unit "${m[2]}" (use d, w, or m)`); + } +} + +/** + * Parse a human date spec relative to `today` (a local-midnight Date) into a + * local-midnight Date. Accepts: today/now, tomorrow/tom, yesterday; +Nd/+Nw/+Nm + * (bare +N = days); weekday names (soonest on/after today); ISO YYYY-MM-DD. + * Throws on anything unrecognized. + */ +export function parseDate(input, todayDate) { + const s = input.trim().toLowerCase(); + if (s === "") throw new Error("empty date"); + switch (s) { + case "today": case "now": return todayDate; + case "tomorrow": case "tom": return addDays(todayDate, 1); + case "yesterday": return addDays(todayDate, -1); + } + const wd = parseWeekday(s); + if (wd !== null) return soonestWeekday(todayDate, wd); + if (s.startsWith("+")) return parseOffset(s.slice(1), todayDate); + + // ISO YYYY-MM-DD (strict; construct as local midnight). + const iso = s.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (iso) { + const [, y, mo, d] = iso; + const date = new Date(Number(y), Number(mo) - 1, Number(d)); + if ( + date.getFullYear() === Number(y) && + date.getMonth() === Number(mo) - 1 && + date.getDate() === Number(d) + ) { + return date; + } + } + throw new Error( + `unrecognized date: "${input}" (try today, tomorrow, +3d, fri, or YYYY-MM-DD)`, + ); +} + +/** parseDate to epoch ms, or null if unparseable (convenience for quick-add). */ +export function parseDateMsOrNull(input, todayDate) { + try { + return toEpochMs(parseDate(input, todayDate)); + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Recurrence +// --------------------------------------------------------------------------- + +const MONTHS = { + jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, + jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12, +}; + +function parseMonthDay(s) { + const toks = s.split(/\s+/).filter(Boolean); + if (toks.length !== 2) return null; + const month = (t) => MONTHS[t.slice(0, 3)] ?? null; + const day = (t) => { + const m = t.match(/^(\d+)/); + return m ? parseInt(m[1], 10) : null; + }; + let m = month(toks[0]); + let d = day(toks[1]); + if (m !== null && d !== null) return [m, d]; + d = day(toks[0]); + m = month(toks[1]); + if (m !== null && d !== null) return [m, d]; + return null; +} + +function parseMonthdayOrdinal(s) { + const m = s.match(/^(\d+)(st|nd|rd|th)$/); + if (!m) return null; + const d = parseInt(m[1], 10); + return d >= 1 && d <= 31 ? d : null; +} + +function intervalForm(n, unit) { + const wd = parseWeekday(unit); + if (wd !== null) { + return n === 1 + ? `FREQ=WEEKLY;BYDAY=${BYDAY[wd]}` + : `FREQ=WEEKLY;INTERVAL=${n};BYDAY=${BYDAY[wd]}`; + } + let freq; + switch (unit) { + case "day": case "days": freq = "DAILY"; break; + case "week": case "weeks": freq = "WEEKLY"; break; + case "month": case "months": freq = "MONTHLY"; break; + case "year": case "years": freq = "YEARLY"; break; + default: + throw new Error( + `unrecognized recurrence "${unit}" (try daily/weekly/monthly/yearly, ` + + `'every 3 days', 'every fri', or a raw RRULE)`, + ); + } + return n === 1 ? `FREQ=${freq}` : `FREQ=${freq};INTERVAL=${n}`; +} + +/** + * Parse a recurrence spec into an RFC-5545 RRULE. Accepts a raw RRULE (anything + * containing FREQ=), presets (daily/weekly/monthly/yearly/weekdays), and the + * common natural-language forms (§6.2.1): every N (day|week|month|year)s, every + * , every other , every workday, every , + * every . A trailing "at