diff --git a/Cargo.lock b/Cargo.lock index f9ebf26..f74fbee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -499,6 +499,7 @@ dependencies = [ "hemx-js", "scraper", "tokio", + "tower", ] [[package]] diff --git a/PLAN.md b/PLAN.md index cb432c2..9f85ba6 100644 --- a/PLAN.md +++ b/PLAN.md @@ -46,7 +46,7 @@ encryption, retention, backup, and deployment policy remain host concerns. - **Build:** materialize `hemx-sync` over an integration transport with idempotent server command processing, snapshot/change cursor, durable acknowledgements, bounded ordered replay, current auth checks, rejection/conflict results, canonical replacement, reconnect jitter/backoff, multi-tab coordination, and redacted diagnostics. - **Refusals:** no default CRDT, transport in core, cached enqueue-time permission, unbounded queue, or silent last-write-wins policy. - **Requirements:** `sync/001-023`, `operations/001-005`, `security/002-005`, `performance/004-005`. -- **Proof:** `cargo test -p hemx-wasm --test browser flat_patch_persists_offline_then_uploads_with_same_operation_identity -- --exact` proves `sync/002` and `sync/003` through generated real WASM: `SyncEffect::send_patch` travels beside ordinary DOM effects, the strict flat scalar schema is accepted, the offline interaction performs zero patch requests while remaining durable, and reconnect uploads with identical opaque operation/idempotency identity before matching acknowledgement removes it. `cargo test -p hemx-wasm --test browser kanban_public_api_offline_sync_journey_converges_without_duplicate_replay -- --exact` proves `sync/023` through the public generated client handler and real WASM: offline durable mutation, unavailable-origin reload projection, reconnect upload, canonical convergence, duplicate idempotency without a second event, conflicting duplicate rejection, invalid-command rejection, and an empty local queue. `cargo test -p hemx-kanban-example --test browser_e2e idempotent_server_command_is_acknowledged_after_reconnect -- --exact` proves duplicate POST delivery yields one identical canonical acknowledgement/sequence, conflicting id reuse is rejected, EventSource reconnects after a server-closed first stream, the acknowledgement is delivered once with its sequence as event id, and a page reload shows the authoritative card in the canonical column. `cargo test -p hemx-kanban-example --test browser_e2e pending_local_command_uploads_with_bounded_retry_and_is_removed_on_ack -- --exact` proves automatic platform-store upload, one explicit transient failure, bounded exponential backoff plus jitter, reconnect acknowledgement, pending-before-ack ordering, acknowledged removal, canonical board convergence, and non-retried 409 rejection remaining durable with a visible reason. `cargo test -p hemx-kanban-example --test browser_e2e canonical_acknowledgement_survives_server_restart -- --exact` proves the versioned store is materialized before success, a real process restart reloads the same idempotent acknowledgement/sequence, EventSource replays it by id, and canonical board state is rebuilt. `cargo test -p hemx-kanban-example --test browser_e2e exhausted_offline_retries_keep_command_until_later_reconnect -- --exact` proves three bounded retries exhaust into visible offline/manual-recovery state while the command remains durable, then a later retry acknowledges/removes it and converges canonically. `cargo test -p hemx-kanban-example --test browser_e2e missing_history_rebase_and_user_conflict_resolution_preserve_suffix -- --exact` proves retained-history gap detection, typed/versioned snapshot fallback, deterministic already-canonical convergence, atomic snapshot/cursor commit, then a divergent canonical update producing explicit conflict with a two-command queue; the user’s canonical-state action removes only the conflicted head, retains and resumes the suffix, and reaches canonical convergence with an empty queue. `cargo test -p hemx-kanban-example --test browser_e2e keep_local_retry_preserves_conflicted_command_and_suffix_order -- --exact` proves the keep-local action derives one resolution identity; a permanent rejection preserves order and re-enables the choice; three later transport failures still preserve the original conflicted head plus suffix and expose manual recovery; final acceptance removes only the head and converges the untouched suffix without duplication or loss. `cargo test -p hemx-kanban-example --test browser_e2e redacted_sync_diagnostics_are_bounded_and_leak_no_sensitive_material -- --exact` proves a 15-second-old queue is bucketed rather than timestamped, cursor and acknowledgement latency advance after recovery, diagnostics stay under a fixed small readout, and command id/payload/actor/session/cookie material never reaches the diagnostics surface; the conflict and keep-local tests additionally assert conflict/rejection counters at their actual failure boundaries. `cargo test -p hemx-kanban-example --test browser_e2e two_tabs_coordinate_single_uploader_and_takeover_without_duplicate_application -- --exact` proves one retry-exhausted leader/one explicit standby, zero follower upload before takeover, lease takeover after the leader closes, one canonical sequence/event, one queue removal, and one board application. `cargo test -p hemx-kanban-example --test browser_e2e upload_backpressure_keeps_pending_work_visible_and_recoverable -- --exact` proves one in-flight upload, a two-acknowledgement activation limit, one retained durable command with visible recovery state, and explicit retry draining the final command without loss. `cargo test -p hemx-kanban-example --test browser_e2e mixed_queue_removes_accepted_prefix_and_retains_rejected_tail -- --exact` proves an accepted prefix is canonically applied and removed once before a permanent rejection stops processing, exposes its typed HTTP/server cause, disables blind retry, and leaves both the rejected command and untouched suffix durable. `cargo test -p hemx-kanban-example --test browser_e2e schema_upgrade_preserves_queued_order_and_local_intent -- --exact` proves a three-command v1 queue migrates atomically to the explicit-target v2 schema, remains byte-for-intent ordered after interrupted upload, then receives canonical sequences 1–3 in original order and drains without loss. `cargo test -p hemx-kanban-example --test browser_e2e account_partition_hides_replay_and_export_until_owner_returns -- --exact` proves a beta editor and alpha viewer enumerate zero commands and issue no replay for an alpha owner queue, signed-out startup cannot open a partition, no foreign id/export surface leaks, and only the returning alpha owner can export the intact command then resume it exactly once. `cargo test -p hemx-kanban-example --test browser_e2e canonical_snapshot_and_history_are_tenant_scoped -- --exact` proves alpha and beta sessions receive only their own canonical cards and acknowledgement events with independent sequence 1 cursors, while signed-out snapshot/history reads receive 401 and expose no stream. `cargo test -p hemx-kanban-example --test browser_e2e identical_sync_inputs_reconcile_deterministically -- --exact` proves `sync/022` through a real browser and live sync endpoints: one accepted server result and canonical snapshot plus an ordered command sequence produce byte-equivalent structured reconciliation records on independent cloned inputs, do not mutate inputs, and select the explicit server-authoritative model. `cargo test -p hemx-kanban-example --test browser_e2e sync_requests_timeout_and_cancel_on_pagehide -- --exact` proves the sync request boundary enforces a named finite timeout and reports `TimeoutError`, while page teardown cancels a pending request with `AbortError`, closes the acknowledgement stream, and clears retry/lease timers. Per-requirement audit: every `sync/001-023` row now has concrete browser proof; existing live-browser proofs also cover `operations/001-002`, `operations/005`, `security/002`, `security/004`, `performance/004`, and `performance/005`, while this slice closes the sync request/stream leg of `operations/003`. `cargo test -p hemx-kanban-example --test browser_e2e adversarial_wire_inputs_are_rejected_before_partial_application -- --exact` proves malformed, oversized, truncated, trailing, unknown-version, and invalid-kind batch/state/command inputs fail without UI mutation; `cargo test -p hemx-wasm --test browser flat_patch_persists_offline_then_uploads_with_same_operation_identity -- --exact` additionally proves unknown-version and oversized event/state inputs are rejected at the generated real-WASM handler boundary before application code. The runtime now bounds total payloads, fields, vectors, varints, UTF-8, enums, and option discriminants before allocation or dispatch, and the durable queue validates every loaded command before replay, closing `security/005`. Slice 4 remains open on `operations/004` bounded liveness/slow-consumer proof, the non-sync handler/background-replay legs of `operations/003`, and `security/003` host enforcement plus request media/size boundaries. The next coherent slice is `security/003`: enforce and prove a host-configured request body limit and media-type rejection at the mutation boundary. +- **Proof:** `cargo test -p hemx-wasm --test browser flat_patch_persists_offline_then_uploads_with_same_operation_identity -- --exact` proves `sync/002` and `sync/003` through generated real WASM: `SyncEffect::send_patch` travels beside ordinary DOM effects, the strict flat scalar schema is accepted, the offline interaction performs zero patch requests while remaining durable, and reconnect uploads with identical opaque operation/idempotency identity before matching acknowledgement removes it. `cargo test -p hemx-wasm --test browser kanban_public_api_offline_sync_journey_converges_without_duplicate_replay -- --exact` proves `sync/023` through the public generated client handler and real WASM: offline durable mutation, unavailable-origin reload projection, reconnect upload, canonical convergence, duplicate idempotency without a second event, conflicting duplicate rejection, invalid-command rejection, and an empty local queue. `cargo test -p hemx-kanban-example --test browser_e2e idempotent_server_command_is_acknowledged_after_reconnect -- --exact` proves duplicate POST delivery yields one identical canonical acknowledgement/sequence, conflicting id reuse is rejected, EventSource reconnects after a server-closed first stream, the acknowledgement is delivered once with its sequence as event id, and a page reload shows the authoritative card in the canonical column. `cargo test -p hemx-kanban-example --test browser_e2e pending_local_command_uploads_with_bounded_retry_and_is_removed_on_ack -- --exact` proves automatic platform-store upload, one explicit transient failure, bounded exponential backoff plus jitter, reconnect acknowledgement, pending-before-ack ordering, acknowledged removal, canonical board convergence, and non-retried 409 rejection remaining durable with a visible reason. `cargo test -p hemx-kanban-example --test browser_e2e canonical_acknowledgement_survives_server_restart -- --exact` proves the versioned store is materialized before success, a real process restart reloads the same idempotent acknowledgement/sequence, EventSource replays it by id, and canonical board state is rebuilt. `cargo test -p hemx-kanban-example --test browser_e2e exhausted_offline_retries_keep_command_until_later_reconnect -- --exact` proves three bounded retries exhaust into visible offline/manual-recovery state while the command remains durable, then a later retry acknowledges/removes it and converges canonically. `cargo test -p hemx-kanban-example --test browser_e2e missing_history_rebase_and_user_conflict_resolution_preserve_suffix -- --exact` proves retained-history gap detection, typed/versioned snapshot fallback, deterministic already-canonical convergence, atomic snapshot/cursor commit, then a divergent canonical update producing explicit conflict with a two-command queue; the user’s canonical-state action removes only the conflicted head, retains and resumes the suffix, and reaches canonical convergence with an empty queue. `cargo test -p hemx-kanban-example --test browser_e2e keep_local_retry_preserves_conflicted_command_and_suffix_order -- --exact` proves the keep-local action derives one resolution identity; a permanent rejection preserves order and re-enables the choice; three later transport failures still preserve the original conflicted head plus suffix and expose manual recovery; final acceptance removes only the head and converges the untouched suffix without duplication or loss. `cargo test -p hemx-kanban-example --test browser_e2e redacted_sync_diagnostics_are_bounded_and_leak_no_sensitive_material -- --exact` proves a 15-second-old queue is bucketed rather than timestamped, cursor and acknowledgement latency advance after recovery, diagnostics stay under a fixed small readout, and command id/payload/actor/session/cookie material never reaches the diagnostics surface; the conflict and keep-local tests additionally assert conflict/rejection counters at their actual failure boundaries. `cargo test -p hemx-kanban-example --test browser_e2e two_tabs_coordinate_single_uploader_and_takeover_without_duplicate_application -- --exact` proves one retry-exhausted leader/one explicit standby, zero follower upload before takeover, lease takeover after the leader closes, one canonical sequence/event, one queue removal, and one board application. `cargo test -p hemx-kanban-example --test browser_e2e upload_backpressure_keeps_pending_work_visible_and_recoverable -- --exact` proves one in-flight upload, a two-acknowledgement activation limit, one retained durable command with visible recovery state, and explicit retry draining the final command without loss. `cargo test -p hemx-kanban-example --test browser_e2e mixed_queue_removes_accepted_prefix_and_retains_rejected_tail -- --exact` proves an accepted prefix is canonically applied and removed once before a permanent rejection stops processing, exposes its typed HTTP/server cause, disables blind retry, and leaves both the rejected command and untouched suffix durable. `cargo test -p hemx-kanban-example --test browser_e2e schema_upgrade_preserves_queued_order_and_local_intent -- --exact` proves a three-command v1 queue migrates atomically to the explicit-target v2 schema, remains byte-for-intent ordered after interrupted upload, then receives canonical sequences 1–3 in original order and drains without loss. `cargo test -p hemx-kanban-example --test browser_e2e account_partition_hides_replay_and_export_until_owner_returns -- --exact` proves a beta editor and alpha viewer enumerate zero commands and issue no replay for an alpha owner queue, signed-out startup cannot open a partition, no foreign id/export surface leaks, and only the returning alpha owner can export the intact command then resume it exactly once. `cargo test -p hemx-kanban-example --test browser_e2e canonical_snapshot_and_history_are_tenant_scoped -- --exact` proves alpha and beta sessions receive only their own canonical cards and acknowledgement events with independent sequence 1 cursors, while signed-out snapshot/history reads receive 401 and expose no stream. `cargo test -p hemx-kanban-example --test browser_e2e identical_sync_inputs_reconcile_deterministically -- --exact` proves `sync/022` through a real browser and live sync endpoints: one accepted server result and canonical snapshot plus an ordered command sequence produce byte-equivalent structured reconciliation records on independent cloned inputs, do not mutate inputs, and select the explicit server-authoritative model. `cargo test -p hemx-kanban-example --test browser_e2e sync_requests_timeout_and_cancel_on_pagehide -- --exact` proves the sync request boundary enforces a named finite timeout and reports `TimeoutError`, while page teardown cancels a pending request with `AbortError`, closes the acknowledgement stream, and clears retry/lease timers. Per-requirement audit: every `sync/001-023` row now has concrete browser proof; existing live-browser proofs also cover `operations/001-002`, `operations/005`, `security/002`, `security/004`, `performance/004`, and `performance/005`, while this slice closes the sync request/stream leg of `operations/003`. `cargo test -p hemx-kanban-example --test browser_e2e adversarial_wire_inputs_are_rejected_before_partial_application -- --exact` proves malformed, oversized, truncated, trailing, unknown-version, and invalid-kind batch/state/command inputs fail without UI mutation; `cargo test -p hemx-wasm --test browser flat_patch_persists_offline_then_uploads_with_same_operation_identity -- --exact` additionally proves unknown-version and oversized event/state inputs are rejected at the generated real-WASM handler boundary before application code. The runtime now bounds total payloads, fields, vectors, varints, UTF-8, enums, and option discriminants before allocation or dispatch, and the durable queue validates every loaded command before replay, closing `security/005`. `cargo test -p hemx-axum --test response interaction_boundary_honors_media_type_and_host_body_limit -- --exact` proves the mutation extractor rejects missing/unexpected media types with 415 and an Axum host-configured oversized body with 413 before handler dispatch, while a valid bounded form dispatches exactly once; the extractor documentation names `DefaultBodyLimit`, and CSRF remains the existing documented application/middleware policy, closing `security/003`. Slice 4 remains open on `operations/004` bounded liveness/slow-consumer proof and the non-sync handler/background-replay legs of `operations/003`. The next coherent slice is `operations/004`: bound and prove heartbeat, reconnect/backoff, slow-consumer buffering, and cancellation behavior for the acknowledgement stream. ## Slice 5 — local-first multiplayer Kanban milestone diff --git a/docs/recipes/auth-session-csrf.md b/docs/recipes/auth-session-csrf.md index 4461cbf..21be46c 100644 --- a/docs/recipes/auth-session-csrf.md +++ b/docs/recipes/auth-session-csrf.md @@ -116,6 +116,10 @@ pub struct NewProject { The browser submits the same form with or without the hemx runtime. Cookies, SameSite behavior, and credential inclusion remain browser/framework concerns. +`hemx_axum::InteractionRequest` accepts only URL-encoded and multipart forms; +apply Axum's `DefaultBodyLimit` (or a compatible host limit) to every mutation +route. Media-type and size checks run before dispatch, while CSRF remains the +explicit application or middleware check shown below. req: security/003 ## Mutation handler diff --git a/hemx-axum/Cargo.toml b/hemx-axum/Cargo.toml index 764b9c3..4ef922a 100644 --- a/hemx-axum/Cargo.toml +++ b/hemx-axum/Cargo.toml @@ -15,3 +15,4 @@ hemx-js = { path = "../hemx-js" } [dev-dependencies] scraper = "0.23" tokio = { version = "1", features = ["macros", "rt"] } +tower = { version = "0.5", features = ["util"] } diff --git a/hemx-axum/src/lib.rs b/hemx-axum/src/lib.rs index 7f08654..93420d8 100644 --- a/hemx-axum/src/lib.rs +++ b/hemx-axum/src/lib.rs @@ -1,4 +1,4 @@ -use axum::body::{to_bytes, Body}; +use axum::body::{Body, Bytes}; pub use axum::extract::State; use axum::extract::{FromRequest, FromRequestParts, Multipart}; use axum::http::{header, request::Parts, HeaderMap, HeaderValue, Request, Response, StatusCode}; @@ -151,6 +151,12 @@ pub struct InteractionForm { files: Vec, } +/// A validated hemx mutation request. +/// +/// Only `application/x-www-form-urlencoded` and `multipart/form-data` are +/// accepted. Body size is intentionally host policy: apply Axum's +/// [`axum::extract::DefaultBodyLimit`] (or a compatible request-body limit) +/// to the mutation route; limit rejections become HTTP 413 before dispatch. #[derive(Clone, Debug, Eq, PartialEq)] pub struct InteractionRequest { form: InteractionForm, @@ -232,6 +238,8 @@ pub struct StateHandlerRegistry { #[derive(Clone, Debug, Eq, PartialEq)] pub enum InteractionFormRejection { + UnsupportedMediaType, + BodyTooLarge, InvalidBody, MissingHandle, InvalidHandle, @@ -1227,6 +1235,11 @@ impl DispatchRegistry for HandlerRegistry { impl IntoResponse for InteractionFormRejection { fn into_response(self) -> axum::response::Response { let (status, message) = match self { + Self::UnsupportedMediaType => ( + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "hemx interactions require application/x-www-form-urlencoded or multipart/form-data", + ), + Self::BodyTooLarge => (StatusCode::PAYLOAD_TOO_LARGE, "hemx interaction body exceeds the host limit"), Self::InvalidBody => (StatusCode::BAD_REQUEST, "invalid hemx form body"), Self::MissingHandle => (StatusCode::BAD_REQUEST, "missing __h hemx handle field"), Self::InvalidHandle => (StatusCode::BAD_REQUEST, "invalid __h hemx handle field"), @@ -1254,31 +1267,56 @@ where { type Rejection = InteractionFormRejection; - async fn from_request(req: Request, _state: &S) -> Result { - if is_multipart(req.headers()) { - let multipart = Multipart::from_request(req, _state) - .await - .map_err(|_| InteractionFormRejection::InvalidBody)?; - return Self::parse_multipart(multipart).await; + async fn from_request(req: Request, state: &S) -> Result { + match interaction_media_type(req.headers())? { + InteractionMediaType::Multipart => { + let multipart = Multipart::from_request(req, state) + .await + .map_err(extractor_rejection)?; + Self::parse_multipart(multipart).await + } + InteractionMediaType::UrlEncoded => { + let bytes = Bytes::from_request(req, state) + .await + .map_err(extractor_rejection)?; + Self::parse_urlencoded(&bytes) + } } - - let bytes = to_bytes(req.into_body(), 1024 * 1024) - .await - .map_err(|_| InteractionFormRejection::InvalidBody)?; - Self::parse_urlencoded(&bytes) } } -fn is_multipart(headers: &HeaderMap) -> bool { - headers +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum InteractionMediaType { + Multipart, + UrlEncoded, +} + +fn interaction_media_type( + headers: &HeaderMap, +) -> Result { + let content_type = headers .get(header::CONTENT_TYPE) .and_then(|value| value.to_str().ok()) - .is_some_and(|content_type| { - content_type - .split(';') - .next() - .is_some_and(|mime| mime.trim().eq_ignore_ascii_case("multipart/form-data")) - }) + .ok_or(InteractionFormRejection::UnsupportedMediaType)?; + match content_type + .split(';') + .next() + .map(str::trim) + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("multipart/form-data") => Ok(InteractionMediaType::Multipart), + Some("application/x-www-form-urlencoded") => Ok(InteractionMediaType::UrlEncoded), + _ => Err(InteractionFormRejection::UnsupportedMediaType), + } +} + +fn extractor_rejection(rejection: impl IntoResponse) -> InteractionFormRejection { + if rejection.into_response().status() == StatusCode::PAYLOAD_TOO_LARGE { + InteractionFormRejection::BodyTooLarge + } else { + InteractionFormRejection::InvalidBody + } } impl PageMode { diff --git a/hemx-axum/tests/response.rs b/hemx-axum/tests/response.rs index 47b2569..0a1e478 100644 --- a/hemx-axum/tests/response.rs +++ b/hemx-axum/tests/response.rs @@ -1,6 +1,7 @@ -use axum::extract::State; -use axum::http::{header, HeaderMap}; +use axum::extract::{DefaultBodyLimit, State}; +use axum::http::{header, HeaderMap, Request, StatusCode}; use axum::response::IntoResponse; +use axum::{body::Body, routing::post, Router}; use hemx_axum::{ interactions, runtime_js, runtime_js_hash, runtime_js_path, runtime_js_route_path, runtime_js_script_src, runtime_js_source, DispatchRejection, EffectResponse, Form, @@ -10,6 +11,65 @@ use hemx_axum::{ }; use hemx_core::{push, BuildFingerprint, Handle, IntoEffect, SafeHtml, Slot}; use scraper::{Html, Selector}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use tower::ServiceExt; + +static MUTATION_CALLS: AtomicUsize = AtomicUsize::new(0); + +async fn bounded_mutation(_: InteractionRequest) -> StatusCode { + MUTATION_CALLS.fetch_add(1, Ordering::SeqCst); + StatusCode::NO_CONTENT +} + +#[tokio::test] +async fn interaction_boundary_honors_media_type_and_host_body_limit() { + // test req: security/003 + MUTATION_CALLS.store(0, Ordering::SeqCst); + let app = Router::new() + .route("/mutate", post(bounded_mutation)) + .layer(DefaultBodyLimit::max(32)); + + let unsupported = app + .clone() + .oneshot( + Request::post("/mutate") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"__h":"1"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(unsupported.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + assert_eq!(MUTATION_CALLS.load(Ordering::SeqCst), 0); + + let oversized = app + .clone() + .oneshot( + Request::post("/mutate") + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(Body::from(format!("__h=1&value={}", "x".repeat(64)))) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(oversized.status(), StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!(MUTATION_CALLS.load(Ordering::SeqCst), 0); + + let accepted = app + .oneshot( + Request::post("/mutate") + .header( + header::CONTENT_TYPE, + "application/x-www-form-urlencoded; charset=utf-8", + ) + .body(Body::from("__h=1")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(accepted.status(), StatusCode::NO_CONTENT); + assert_eq!(MUTATION_CALLS.load(Ordering::SeqCst), 1); +} fn selector(value: &str) -> Selector { Selector::parse(value).expect("test selector parses")