#[hemx::surface] pub mod ui {} use hemplate::Hemplate; use hemx::{push, Html, IntoEffect}; use hemx_axum::{ interactions, runtime_js_path, Form, HandlerErrorContext, HandlerFailure, IntoHandlerFailure, Registry, State, }; use std::convert::Infallible; use std::fmt::Display; use std::fs; use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; use ui::dashboard; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct SessionId(u64); #[derive(Clone, Debug, PartialEq, Eq)] pub struct Session { session_id: SessionId, user_id: UserId, email: String, csrf: CsrfToken, origin: String, bearer: String, } impl Session { pub fn demo() -> Self { Self { session_id: SessionId(1), user_id: UserId(42), email: "founder@example.com".to_owned(), csrf: CsrfToken("demo-csrf".to_owned()), origin: "http://127.0.0.1:3000".to_owned(), bearer: "Bearer demo-session".to_owned(), } } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct UserId(u64); #[derive(Clone, Debug, PartialEq, Eq)] pub struct CsrfToken(String); impl FromStr for CsrfToken { type Err = Infallible; fn from_str(value: &str) -> Result { Ok(Self(value.to_owned())) } } impl Display for CsrfToken { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.0) } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct ProjectName(String); impl ProjectName { fn as_str(&self) -> &str { &self.0 } } impl FromStr for ProjectName { type Err = Infallible; fn from_str(value: &str) -> Result { Ok(Self(value.trim().to_owned())) } } #[derive(Clone, Debug)] #[hemx::form("new_project")] pub struct NewProject { csrf: CsrfToken, name: ProjectName, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct ProjectId(u64); impl Display for ProjectId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct ProjectRecord { id: ProjectId, name: String, owner: String, } impl ProjectRecord { fn encode(&self) -> String { format!("{}\t{}\t{}\n", self.id.0, self.owner, self.name) } fn decode(line: &str) -> io::Result { let mut fields = line.splitn(3, '\t'); let id = fields .next() .and_then(|value| value.parse().ok()) .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid project id"))?; let owner = fields .next() .filter(|value| !value.is_empty()) .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid project owner"))?; let name = fields .next() .filter(|value| !value.is_empty() && !value.contains(['\n', '\r', '\t'])) .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid project name"))?; Ok(Self { id: ProjectId(id), name: name.to_owned(), owner: owner.to_owned(), }) } } #[derive(Clone, Default)] pub struct LocalProjectStore { projects: Arc>>, path: Option>, } impl LocalProjectStore { pub fn durable(path: impl Into) -> io::Result { let path = path.into(); let projects = match fs::read_to_string(&path) { Ok(contents) => contents .lines() .map(ProjectRecord::decode) .collect::>>()?, Err(error) if error.kind() == io::ErrorKind::NotFound => Vec::new(), Err(error) => return Err(error), }; Ok(Self { projects: Arc::new(Mutex::new(projects)), path: Some(Arc::new(path)), }) } pub fn insert(&self, name: ProjectName, session: &Session) -> Result { if name.as_str() == "fail-store" { return Err(AppError::StoreUnavailable); } let mut projects = self.projects.lock().unwrap(); let id = ProjectId(projects.last().map_or(1, |project| project.id.0 + 1)); let record = ProjectRecord { id, name: name.as_str().to_owned(), owner: session.email.clone(), }; let mut next = projects.clone(); next.push(record.clone()); if let Some(path) = self.path.as_deref() { persist_projects(path, &next).map_err(|_| AppError::StoreUnavailable)?; } *projects = next; Ok(record) } pub fn list(&self) -> Vec { self.projects.lock().unwrap().clone() } fn ready(&self) -> bool { let Some(path) = self.path.as_deref() else { return true; }; if path.exists() && !path.is_file() { return false; } path.parent().unwrap_or_else(|| Path::new(".")).is_dir() } } fn persist_projects(path: &Path, projects: &[ProjectRecord]) -> io::Result<()> { let parent = path.parent().unwrap_or_else(|| Path::new(".")); fs::create_dir_all(parent)?; let temporary = path.with_extension("tmp"); let mut file = fs::File::create(&temporary)?; for project in projects { file.write_all(project.encode().as_bytes())?; } file.sync_all()?; if let Err(error) = fs::rename(&temporary, path) { let _ = fs::remove_file(temporary); return Err(error); } #[cfg(unix)] fs::File::open(parent)?.sync_all()?; Ok(()) } #[derive(Clone, Debug, PartialEq, Eq)] pub struct RequestCorrelationId(String); impl Display for RequestCorrelationId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.0) } } #[derive(Clone)] pub struct MutationDiagnostic { pub request_id: RequestCorrelationId, pub session_id: SessionId, pub user_id: UserId, pub outcome: &'static str, pub duration_micros: u64, } pub trait DiagnosticSink: Send + Sync { fn record(&self, diagnostic: MutationDiagnostic); } struct StderrDiagnosticSink; impl DiagnosticSink for StderrDiagnosticSink { fn record(&self, diagnostic: MutationDiagnostic) { eprintln!( "event=saas.project_mutation request_id={} session_id={} user_id={} outcome={} duration_micros={}", diagnostic.request_id, diagnostic.session_id.0, diagnostic.user_id.0, diagnostic.outcome, diagnostic.duration_micros ); } } #[derive(Default)] struct MutationMetrics { attempts: AtomicU64, succeeded: AtomicU64, denied: AtomicU64, invalid: AtomicU64, mismatch: AtomicU64, failed: AtomicU64, duration_micros: AtomicU64, next_request_id: AtomicU64, } #[derive(Clone)] pub struct AppContext { session: Session, store: LocalProjectStore, metrics: Arc, diagnostics: Arc, } impl AppContext { pub fn demo() -> Self { Self { session: Session::demo(), store: LocalProjectStore::default(), metrics: Arc::default(), diagnostics: Arc::new(StderrDiagnosticSink), } } pub fn durable(path: impl Into, origin: impl Into) -> io::Result { let mut session = Session::demo(); session.origin = origin.into(); Ok(Self { session, store: LocalProjectStore::durable(path)?, metrics: Arc::default(), diagnostics: Arc::new(StderrDiagnosticSink), }) } pub fn authorize_mutation( &self, bearer: &str, csrf: &CsrfToken, origin: &str, ) -> Result<(), AppError> { if self.session.email.is_empty() || bearer != self.session.bearer { return Err(AppError::MissingSession); } if csrf != &self.session.csrf { return Err(AppError::CsrfRejected); } if origin != self.session.origin { return Err(AppError::OriginRejected); } Ok(()) } pub fn csrf(&self) -> &CsrfToken { &self.session.csrf } pub fn projects(&self) -> Vec { self.store.list() } pub fn ready(&self) -> bool { self.store.ready() } pub fn with_diagnostic_sink(mut self, diagnostics: Arc) -> Self { self.diagnostics = diagnostics; self } pub fn next_request_id(&self) -> RequestCorrelationId { let sequence = self .metrics .next_request_id .fetch_add(1, Ordering::Relaxed) .saturating_add(1); RequestCorrelationId(format!("req-{}-{sequence}", std::process::id())) } pub fn record_mutation( &self, request_id: RequestCorrelationId, outcome: &'static str, duration: Duration, ) { self.metrics.attempts.fetch_add(1, Ordering::Relaxed); match outcome { "succeeded" => &self.metrics.succeeded, "denied" => &self.metrics.denied, "invalid" => &self.metrics.invalid, "mismatch" => &self.metrics.mismatch, _ => &self.metrics.failed, } .fetch_add(1, Ordering::Relaxed); let duration_micros = duration.as_micros().min(u128::from(u64::MAX)) as u64; self.metrics .duration_micros .fetch_add(duration_micros, Ordering::Relaxed); self.diagnostics.record(MutationDiagnostic { request_id, session_id: self.session.session_id, user_id: self.session.user_id, outcome, duration_micros, }); } pub fn metrics_json(&self) -> String { format!( "{{\"project_mutation\":{{\"attempts\":{},\"succeeded\":{},\"denied\":{},\"invalid\":{},\"mismatch\":{},\"failed\":{},\"duration_micros\":{}}}}}", self.metrics.attempts.load(Ordering::Relaxed), self.metrics.succeeded.load(Ordering::Relaxed), self.metrics.denied.load(Ordering::Relaxed), self.metrics.invalid.load(Ordering::Relaxed), self.metrics.mismatch.load(Ordering::Relaxed), self.metrics.failed.load(Ordering::Relaxed), self.metrics.duration_micros.load(Ordering::Relaxed), ) } pub fn create_project_authorized( &self, name: &str, bearer: &str, csrf: &str, origin: &str, ) -> Result { let csrf = CsrfToken::from_str(csrf).expect("CSRF tokens are infallible strings"); self.authorize_mutation(bearer, &csrf, origin)?; self.create_project( ProjectName::from_str(name).expect("project names are infallible strings"), ) } fn create_project(&self, name: ProjectName) -> Result { if name.as_str().is_empty() { return Err(AppError::Validation("Project name required")); } if name.as_str().len() > 100 || name.as_str().contains(['\n', '\r', '\t']) { return Err(AppError::Validation("Project name is invalid")); } self.store.insert(name, &self.session) } } #[derive(Debug)] pub enum AppError { MissingSession, CsrfRejected, OriginRejected, StoreUnavailable, Validation(&'static str), } impl AppError { fn message(&self) -> &'static str { match self { Self::MissingSession => "Sign in to continue", Self::CsrfRejected => "Refresh the page before creating another project", Self::OriginRejected => "Origin verification failed", Self::StoreUnavailable => "Project storage is temporarily unavailable", Self::Validation(message) => message, } } } impl Display for AppError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.message()) } } impl std::error::Error for AppError {} impl IntoHandlerFailure for AppError { fn into_handler_failure(self, context: HandlerErrorContext) -> HandlerFailure { match self { Self::Validation(message) => HandlerFailure::effects( ( dashboard::new_project.error("name", message), dashboard::new_project.focus("name"), ), context, ), other => HandlerFailure::effects(dashboard::flash.set(other.message()), context), } } } #[derive(Hemplate)] pub struct Dashboard { csrf: CsrfToken, flash: String, summary: String, rows: Vec, project_count: usize, show_projects: bool, settings: SettingsPage, } impl Dashboard { pub fn from_context(ctx: &AppContext) -> Self { let projects = ctx.projects(); Self { csrf: ctx.csrf().clone(), flash: "Signed in with a demo session".to_owned(), summary: project_summary(projects.len()), project_count: projects.len(), show_projects: true, settings: SettingsPage::production_boundaries(), rows: projects.into_iter().map(ProjectRow::from).collect(), } } pub fn settings(ctx: &AppContext) -> Self { let mut dashboard = Self::from_context(ctx); dashboard.show_projects = false; dashboard.flash.clear(); dashboard } } #[derive(Hemplate)] #[hemplate = "partials"] pub struct ProjectRow { id: ProjectId, name: String, owner: String, } impl From for ProjectRow { fn from(record: ProjectRecord) -> Self { Self { id: record.id, name: record.name, owner: record.owner, } } } impl hemx::KeyedPartial for ProjectRow { fn hemx_key(&self) -> String { self.id.to_string() } } #[derive(Hemplate)] #[hemplate = "partials"] pub struct SettingsPage { message: &'static str, } impl SettingsPage { fn production_boundaries() -> Self { Self { message: "Auth, CSRF, persistence, metrics, and deploy stay explicit app integrations.", } } } #[derive(Hemplate)] pub struct AppShell { title: &'static str, runtime_src: &'static str, body: Html, } pub fn home_page(ctx: &AppContext) -> Html { ui::page(&AppShell { title: "hemx SaaS tutorial", runtime_src: runtime_js_path(), body: ui::page(&Dashboard::from_context(ctx)), }) } pub fn settings_page(ctx: &AppContext) -> Html { ui::page(&AppShell { title: "hemx SaaS tutorial settings", runtime_src: runtime_js_path(), body: ui::page(&Dashboard::settings(ctx)), }) } #[hemx::app(dashboard_handlers)] pub fn registry(ctx: AppContext) -> Registry { interactions(ui::BUILD_FINGERPRINT) } #[hemx::component("dashboard")] mod dashboard_handlers { use super::*; #[hemx::handler] pub async fn create_project( State(ctx): State, Form(form): Form, ) -> Result { if ctx.session.email.is_empty() { return Err(AppError::MissingSession); } if form.csrf != ctx.session.csrf { return Err(AppError::CsrfRejected); } let project = ctx.create_project(form.name)?; let total = ctx.projects().len(); Ok(( dashboard::project_row.append(ProjectRow::from(project)), dashboard::summary.set(project_summary(total)), dashboard::new_project.clear(), dashboard::flash.set("Project created"), dashboard::live_status.set(format!("{total} projects persisted locally")), )) } #[hemx::handler] pub async fn open_settings(State(_ctx): State) -> impl IntoEffect { ( dashboard::page_panel.put(&SettingsPage::production_boundaries()), dashboard::nav.set("Settings"), push("/settings"), ) } } pub fn live_status(projects: usize) -> impl IntoEffect { dashboard::live_status.set(format!("heartbeat: {projects} projects")) } fn project_summary(total: usize) -> String { match total { 0 => "No projects yet".to_owned(), 1 => "1 project".to_owned(), total => format!("{total} projects"), } } #[cfg(test)] mod tests { use super::*; use hemx_axum::{InteractionForm, InteractionRequest}; use hemx_test::{any_root_selector, inspect, inspect_batch, target_selector}; use scraper::{Html as ParsedHtml, Selector}; fn form(handle: hemx::Handle, fields: &[(&str, &str)]) -> InteractionForm { InteractionForm::for_handle( handle, fields .iter() .map(|(name, value)| ((*name).to_owned(), (*value).to_owned())), ) } fn selector(value: &str) -> Selector { Selector::parse(value).expect("test selector parses") } #[derive(Default)] struct RecordingDiagnostics(Mutex>); impl DiagnosticSink for RecordingDiagnostics { fn record(&self, diagnostic: MutationDiagnostic) { self.0.lock().unwrap().push(diagnostic); } } #[test] fn mutation_diagnostics_are_structured_and_cannot_carry_request_secrets() { // req: operations/003 req: operations/005 let diagnostics = Arc::new(RecordingDiagnostics::default()); let ctx = AppContext::demo().with_diagnostic_sink(diagnostics.clone()); let request_id = ctx.next_request_id(); ctx.record_mutation(request_id.clone(), "denied", Duration::from_micros(7)); let recorded = diagnostics.0.lock().unwrap(); assert_eq!(recorded.len(), 1); assert_eq!(recorded[0].request_id, request_id); assert_eq!(recorded[0].session_id, SessionId(1)); assert_eq!(recorded[0].user_id, UserId(42)); assert_eq!(recorded[0].outcome, "denied"); assert_eq!(recorded[0].duration_micros, 7); } #[test] fn home_page_documents_the_production_app_boundaries() { // req: examples/001 req: auth/001 req: auth/004 req: interop/003 let ctx = AppContext::demo(); let html = home_page(&ctx); let document = ParsedHtml::parse_document(html.as_str()); assert_eq!(document.select(&selector(any_root_selector())).count(), 1); assert_eq!( document .select(&selector(&format!( "form{}", target_selector(dashboard::new_project) ))) .count(), 1 ); assert_eq!(document.select(&selector("input[name='csrf']")).count(), 1); assert_eq!( document .select(&selector("[data-hemx-sse='/events']")) .count(), 1 ); assert_eq!( document .select(&selector("[data-hemx-island='metrics']")) .count(), 1 ); assert!(html.as_str().contains("/app.css")); assert!(html.as_str().contains("/metrics.js")); } #[test] fn settings_page_renders_the_full_page_fallback() { // req: examples/001 req: page_swap/002 let ctx = AppContext::demo(); let html = settings_page(&ctx); let document = ParsedHtml::parse_document(html.as_str()); assert_eq!(document.select(&selector(any_root_selector())).count(), 1); assert_eq!( document .select(&selector(&format!( "{} .settings-page", target_selector(dashboard::page_panel) ))) .count(), 1 ); assert!(html.as_str().contains("explicit app integrations")); assert!(!html .as_str() .contains("form data-hemx-handle=\"create_project\"")); } #[tokio::test] async fn create_project_is_auth_csrf_checked_and_persisted_locally() { // req: examples/001 req: auth/002 req: auth/004 req: form/001 req: failure/004 let ctx = AppContext::demo(); let rejected = inspect_batch( InteractionRequest::from(form( dashboard::create_project, &[("csrf", "stale"), ("name", "Launch checklist")], )) .dispatch_async(registry(ctx.clone())) .await .unwrap() .batch, ); assert!(ctx.projects().is_empty()); assert!(rejected.updates_text(dashboard::flash)); assert!(rejected.payload_contains("Refresh the page")); let validation = inspect_batch( InteractionRequest::from(form( dashboard::create_project, &[("csrf", "demo-csrf"), ("name", " ")], )) .dispatch_async(registry(ctx.clone())) .await .unwrap() .batch, ); assert!(ctx.projects().is_empty()); assert!(validation.payload_contains("Project name required")); let created = inspect_batch( InteractionRequest::from(form( dashboard::create_project, &[("csrf", "demo-csrf"), ("name", "Launch checklist")], )) .dispatch_async(registry(ctx.clone())) .await .unwrap() .batch, ); assert_eq!(ctx.projects()[0].name, "Launch checklist"); assert!(created.inserts_html_containing(dashboard::project_row, "1", "Launch checklist")); assert!(created.updates_text(dashboard::summary)); assert!(created.resets_form(dashboard::new_project)); assert!(created.updates_text(dashboard::live_status)); } #[tokio::test] async fn page_swap_and_push_shape_use_generated_targets() { // req: page_swap/002 req: push/003 req: examples/001 let ctx = AppContext::demo(); let settings = inspect_batch( InteractionRequest::from(form(dashboard::open_settings, &[])) .dispatch_async(registry(ctx.clone())) .await .unwrap() .batch, ); assert!( settings.updates_html_containing(dashboard::page_panel, "explicit app integrations") ); assert!(settings.updates_text(dashboard::nav)); assert!(settings.pushes_to("/settings")); let heartbeat = inspect(live_status(ctx.projects().len())); assert!(heartbeat.updates_text(dashboard::live_status)); assert!(heartbeat.payload_contains("heartbeat")); } }