Skip to main content

max / makenotwork

Enforce price cap on JSON path and stop wizard input loss (ultra-fuzz Run 10 UX) - S1: the JSON PATCH-project pricing path now routes price and pwyw_min through PriceCents::new, so the $10k cap the form wizard enforces can no longer be bypassed via the API (a creator could otherwise set up to ~$21.4M). - S2: the wizard create handlers (step_basics_create, step_type_create) route validation errors through a new shared wizard_validation_toast guard that emits an inline toast with HX-Reswap: none, instead of swapping the full-page 422 into #wizard-step and wiping everything the user typed. This restores the guard the step_save handlers already carried. - M3: a present-but-invalid ai_tier is now rejected as a validation error at both wizard sites instead of silently coercing to "handmade"; absent/empty still defaults to handmade. UX N1 (display_name accepts zero-width/bidi chars) deferred by decision (product call on Unicode names) — tracked to revisit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-30 14:38 UTC
Commit: 947d6f656b211f646fffe8de6d130d0d2f449e41
Parent: c906608
4 files changed, +77 insertions, -4 deletions
@@ -272,6 +272,10 @@ pub(super) async fn update_project(
272 272 // `(dollars * 100.0).round() as i32` form silently turns NaN into 0
273 273 // and saturates large values to i32::MAX.
274 274 let cents = crate::pricing::validate_dollars_f64("price_dollars", dollars)?;
275 + // Enforce the $10k cap (and non-negative) the same way the form
276 + // wizard does — without PriceCents::new the JSON API bypasses the cap
277 + // and a creator can set a price up to ~$21.4M (ultra-fuzz Run 10 UX S1).
278 + let cents = db::PriceCents::new(cents)?.as_i32();
275 279 if cents < 50 {
276 280 return Err(AppError::validation(
277 281 "price_dollars must be at least 0.50",
@@ -284,7 +288,8 @@ pub(super) async fn update_project(
284 288
285 289 let pwyw_min_cents = if kind == db::PricingKind::Pwyw {
286 290 let dollars = req.pwyw_min_dollars.unwrap_or(0.0);
287 - Some(crate::pricing::validate_dollars_f64("pwyw_min_dollars", dollars)?)
291 + let cents = crate::pricing::validate_dollars_f64("pwyw_min_dollars", dollars)?;
292 + Some(db::PriceCents::new(cents)?.as_i32())
288 293 } else {
289 294 None
290 295 };
@@ -166,8 +166,25 @@ pub async fn step_type_create(
166 166 session: Session,
167 167 AuthUser(user): AuthUser,
168 168 Path(slug): Path<String>,
169 + headers: HeaderMap,
169 170 Form(form): Form<TypeForm>,
170 171 ) -> Result<Response> {
172 + // Surface validation errors as an inline toast (HX-Reswap: none) instead of
173 + // swapping the full-page 422 into #wizard-step and wiping the selection
174 + // (ultra-fuzz Run 10 UX S2).
175 + match step_type_create_inner(state, session, user, slug, form).await {
176 + Ok(resp) => Ok(resp),
177 + Err(e) => super::wizard_validation_toast(&headers, e),
178 + }
179 + }
180 +
181 + async fn step_type_create_inner(
182 + state: AppState,
183 + session: Session,
184 + user: crate::auth::SessionUser,
185 + slug: String,
186 + form: TypeForm,
187 + ) -> Result<Response> {
171 188 user.check_not_suspended()?;
172 189
173 190 let slug_val = Slug::new(&slug).map_err(|_| AppError::NotFound)?;
@@ -8,12 +8,34 @@
8 8 pub mod item;
9 9 pub mod project;
10 10
11 + use axum::http::{HeaderMap, HeaderValue, StatusCode};
12 + use axum::response::{IntoResponse, Response};
11 13 use axum::routing::get;
12 14 use crate::{
13 15 csrf::{post_csrf, with_csrf, CsrfRouter},
16 + error::{AppError, Result},
14 17 AppState,
15 18 };
16 19
20 + /// Convert a validation error on an HTMX wizard-step submit into an inline toast
21 + /// with `HX-Reswap: none`, so a failed submit doesn't swap the full-page 422
22 + /// template into `#wizard-step` and wipe the user's typed input. Non-validation
23 + /// errors and non-HTMX requests propagate unchanged. Shared by every step
24 + /// handler — the create handlers regressed by omitting it (ultra-fuzz Run 10 UX S2).
25 + pub(crate) fn wizard_validation_toast(headers: &HeaderMap, e: AppError) -> Result<Response> {
26 + if crate::helpers::is_htmx_request(headers)
27 + && let AppError::Validation(ref v) = e
28 + {
29 + let mut resp = StatusCode::OK.into_response();
30 + resp.headers_mut()
31 + .insert("HX-Trigger", crate::helpers::hx_toast(&v.to_string(), "error"));
32 + resp.headers_mut()
33 + .insert("HX-Reswap", HeaderValue::from_static("none"));
34 + return Ok(resp);
35 + }
36 + Err(e)
37 + }
38 +
17 39 /// Step navigation helpers shared by both wizards.
18 40 pub fn next_step<'a>(steps: &'a [&str], current: &str) -> Option<&'a str> {
19 41 steps
@@ -105,8 +105,25 @@ pub async fn step_basics_create(
105 105 State(state): State<AppState>,
106 106 session: Session,
107 107 AuthUser(user): AuthUser,
108 + headers: HeaderMap,
108 109 ValidatedHtmlForm(form): ValidatedHtmlForm<BasicsForm>,
109 110 ) -> Result<Response> {
111 + // Surface validation errors as an inline toast (HX-Reswap: none) instead of
112 + // swapping the full-page 422 into #wizard-step and wiping typed input — the
113 + // guard the step_save handlers carry but this create handler had dropped
114 + // (ultra-fuzz Run 10 UX S2).
115 + match step_basics_create_inner(state, session, user, form).await {
116 + Ok(resp) => Ok(resp),
117 + Err(e) => super::wizard_validation_toast(&headers, e),
118 + }
119 + }
120 +
121 + async fn step_basics_create_inner(
122 + state: AppState,
123 + session: Session,
124 + user: crate::auth::SessionUser,
125 + form: BasicsForm,
126 + ) -> Result<Response> {
110 127 user.check_not_suspended()?;
111 128 if !user.can_create_projects {
112 129 return Err(AppError::Forbidden);
@@ -151,7 +168,14 @@ pub async fn step_basics_create(
151 168 }
152 169
153 170 // Save AI tier
154 - let ai_tier = form.ai_tier.as_deref().unwrap_or("handmade").parse::<db::AiTier>().unwrap_or(db::AiTier::Handmade);
171 + // A present-but-invalid ai_tier is a client error, not silently "handmade";
172 + // absent/empty defaults to handmade (ultra-fuzz Run 10 UX M3).
173 + let ai_tier = match form.ai_tier.as_deref() {
174 + None | Some("") => db::AiTier::Handmade,
175 + Some(s) => s
176 + .parse::<db::AiTier>()
177 + .map_err(|_| AppError::validation("Invalid AI tier".to_string()))?,
178 + };
155 179 let ai_disclosure = if ai_tier == db::AiTier::Assisted { form.ai_disclosure.as_deref() } else { None };
156 180 db::projects::update_project_ai_tier(&state.db, project.id, user.id, ai_tier, ai_disclosure).await?;
157 181
@@ -239,8 +263,13 @@ async fn save_basics(
239 263 project: &db::DbProject,
240 264 form: &HashMap<String, String>,
241 265 ) -> Result<()> {
242 - let ai_tier = form.get("ai_tier").map(|s| s.as_str()).unwrap_or("handmade")
243 - .parse::<db::AiTier>().unwrap_or(db::AiTier::Handmade);
266 + // Present-but-invalid ai_tier is a client error, not silently "handmade".
267 + let ai_tier = match form.get("ai_tier").map(|s| s.as_str()) {
268 + None | Some("") => db::AiTier::Handmade,
269 + Some(s) => s
270 + .parse::<db::AiTier>()
271 + .map_err(|_| AppError::validation("Invalid AI tier".to_string()))?,
272 + };
244 273 let ai_disclosure = if ai_tier == db::AiTier::Assisted {
245 274 form.get("ai_disclosure").map(|s| s.as_str())
246 275 } else {