feat(saas): prove durable authenticated mutation

req: auth/001

req: auth/002

req: auth/004

req: security/004

req: v1_release/003
This commit is contained in:
slhx agent
2026-07-14 00:40:46 +02:00
parent c793de8224
commit ef8e38adf8
5 changed files with 363 additions and 20 deletions
+137 -6
View File
@@ -9,6 +9,9 @@ use hemx_axum::{
};
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::{Arc, Mutex};
@@ -19,6 +22,8 @@ pub struct Session {
user_id: UserId,
email: String,
csrf: CsrfToken,
origin: String,
bearer: String,
}
impl Session {
@@ -27,6 +32,8 @@ impl Session {
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(),
}
}
}
@@ -91,12 +98,56 @@ pub struct ProjectRecord {
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<Self> {
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<Mutex<Vec<ProjectRecord>>>,
path: Option<Arc<PathBuf>>,
}
impl LocalProjectStore {
pub fn durable(path: impl Into<PathBuf>) -> io::Result<Self> {
let path = path.into();
let projects = match fs::read_to_string(&path) {
Ok(contents) => contents
.lines()
.map(ProjectRecord::decode)
.collect::<io::Result<Vec<_>>>()?,
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<ProjectRecord, AppError> {
if name.as_str() == "fail-store" {
return Err(AppError::StoreUnavailable);
@@ -109,7 +160,12 @@ impl LocalProjectStore {
name: name.as_str().to_owned(),
owner: session.email.clone(),
};
projects.push(record.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)
}
@@ -118,6 +174,24 @@ impl LocalProjectStore {
}
}
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)]
pub struct AppContext {
session: Session,
@@ -132,6 +206,33 @@ impl AppContext {
}
}
pub fn durable(path: impl Into<PathBuf>, origin: impl Into<String>) -> io::Result<Self> {
let mut session = Session::demo();
session.origin = origin.into();
Ok(Self {
session,
store: LocalProjectStore::durable(path)?,
})
}
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
}
@@ -139,12 +240,37 @@ impl AppContext {
pub fn projects(&self) -> Vec<ProjectRecord> {
self.store.list()
}
pub fn create_project_authorized(
&self,
name: &str,
bearer: &str,
csrf: &str,
origin: &str,
) -> Result<ProjectRecord, AppError> {
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<ProjectRecord, AppError> {
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),
}
@@ -154,12 +280,21 @@ impl AppError {
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 {
@@ -289,11 +424,7 @@ mod dashboard_handlers {
if form.csrf != ctx.session.csrf {
return Err(AppError::CsrfRejected);
}
if form.name.as_str().is_empty() {
return Err(AppError::Validation("Project name required"));
}
let project = ctx.store.insert(form.name, &ctx.session)?;
let project = ctx.create_project(form.name)?;
let total = ctx.projects().len();
Ok((
dashboard::project_row.append(ProjectRow::from(project)),
+46 -10
View File
@@ -1,7 +1,8 @@
use axum::body::Body;
use axum::extract::{Query, State};
use axum::extract::{DefaultBodyLimit, Form, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::routing::{get, post};
use axum::Router;
use futures_util::stream;
use hemx::IntoEffect;
@@ -9,26 +10,30 @@ use hemx_axum::{runtime_js, runtime_js_path, sse, EffectResponse, InteractionReq
use hemx_saas_example::{home_page, live_status, registry, settings_page, ui, AppContext};
use std::collections::BTreeMap;
use std::convert::Infallible;
use std::path::PathBuf;
#[tokio::main]
async fn main() {
let app = app(AppContext::demo());
let listener = tokio::net::TcpListener::bind("127.0.0.1:3003")
.await
.expect("bind saas tutorial example");
axum::serve(listener, app)
.await
.expect("serve saas tutorial example");
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let address = std::env::var("HEMX_SAAS_ADDR").unwrap_or_else(|_| "127.0.0.1:3003".to_owned());
let store = std::env::var_os("HEMX_SAAS_STORE")
.map(PathBuf::from)
.unwrap_or_else(|| std::env::temp_dir().join("hemx-saas-projects.tsv"));
let app = app(AppContext::durable(store, format!("http://{address}"))?);
let listener = tokio::net::TcpListener::bind(&address).await?;
axum::serve(listener, app).await?;
Ok(())
}
fn app(ctx: AppContext) -> Router {
Router::new()
.route("/", get(home).post(interact))
.route("/settings", get(settings))
.route("/projects", post(create_project))
.route("/events", get(events))
.route(runtime_js_path(), get(runtime))
.route("/app.css", get(css))
.route("/metrics.js", get(metrics_js))
.layer(DefaultBodyLimit::max(8 * 1024))
.with_state(ctx)
}
@@ -63,6 +68,37 @@ async fn events(
)]))
}
// req: auth/001 req: auth/002 req: auth/004
// req: security/004 req: v1_release/003
async fn create_project(
State(ctx): State<AppContext>,
headers: HeaderMap,
Form(form): Form<BTreeMap<String, String>>,
) -> Response {
let bearer = headers
.get("authorization")
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
let origin = headers
.get("origin")
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
let name = form.get("name").map(String::as_str).unwrap_or_default();
let csrf = form.get("csrf").map(String::as_str).unwrap_or_default();
match ctx.create_project_authorized(name, bearer, csrf, origin) {
Ok(_) => (StatusCode::SEE_OTHER, [("location", "/")], "").into_response(),
Err(
error @ (hemx_saas_example::AppError::MissingSession
| hemx_saas_example::AppError::CsrfRejected
| hemx_saas_example::AppError::OriginRejected),
) => (StatusCode::FORBIDDEN, error.to_string()).into_response(),
Err(error @ hemx_saas_example::AppError::Validation(_)) => {
(StatusCode::BAD_REQUEST, error.to_string()).into_response()
}
Err(error) => (StatusCode::SERVICE_UNAVAILABLE, error.to_string()).into_response(),
}
}
async fn runtime() -> impl IntoResponse {
runtime_js()
}