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
+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()
}