Skip to main content

max / makenotwork

Surface HTMX errors on every route and de-panic redirects (ultra-fuzz Run 11 UX) Drive the UX Wiring axis to A+ (F1, the wizard price-cap bypass, was fixed structurally in the Payments axis via the typed update_project_pricing): - Every AppError response now carries an HX-Error header with the user message, and the global toast reads it first. Page-route 500s (HTML body) previously fell through to a generic "An error occurred"; now they show the real message like /api routes do. - The two HX-Redirect builders fall back to /dashboard instead of .expect()-panicking the worker if a path ever fails to parse. - Project price/pwyw inputs get a client-side max (server cap already enforced by the typed writer). Regression test asserts an over-cap wizard price is rejected and never persisted. - Carries the pre-existing .fork-actions layout tweak. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-30 18:46 UTC
Commit: f361c2f8721641a44b0eea23389d7870f18f18ff
Parent: f801710
7 files changed, +103 insertions, -29 deletions
@@ -2,7 +2,7 @@
2 2
3 3 use askama::Template;
4 4 use axum::{
5 - http::StatusCode,
5 + http::{HeaderValue, StatusCode},
6 6 response::{Html, IntoResponse, Response},
7 7 };
8 8
@@ -216,12 +216,31 @@ impl IntoResponse for AppError {
216 216 };
217 217
218 218 // Stash the message so json_error_layer can swap HTML → JSON on API routes
219 - response.extensions_mut().insert(ApiErrorMessage(message));
219 + response.extensions_mut().insert(ApiErrorMessage(message.clone()));
220 +
221 + // Also expose the message as an `HX-Error` header so the global HTMX error
222 + // toast can surface it on EVERY route, not just /api routes where the body
223 + // is parseable JSON — a page route returns an HTML error body the toast
224 + // can't read, so it would otherwise show a generic string (Run 11 UX MINOR).
225 + if let Ok(value) = HeaderValue::from_str(&header_safe(&message)) {
226 + response.headers_mut().insert("HX-Error", value);
227 + }
220 228
221 229 response
222 230 }
223 231 }
224 232
233 + /// Reduce a user message to a single-line, visible-ASCII header value (header
234 + /// values reject control bytes and non-ASCII). Truncated so a long validation
235 + /// message can't bloat the response headers.
236 + fn header_safe(message: &str) -> String {
237 + message
238 + .chars()
239 + .map(|c| if (' '..='~').contains(&c) { c } else { ' ' })
240 + .take(256)
241 + .collect()
242 + }
243 +
225 244 /// Error page template
226 245 #[derive(Template)]
227 246 #[template(path = "pages/error.html")]
@@ -311,12 +311,12 @@ pub(super) async fn save_preview(
311 311 _ => {} // draft -- leave as is
312 312 }
313 313
314 + // item.id is a UUID so the parse can't fail, but fall back rather than
315 + // panic the worker for consistency with the other HX-Redirect sites (Run 11 UX MINOR).
314 316 let mut response = Response::new(axum::body::Body::empty());
315 - response.headers_mut().insert(
316 - "HX-Redirect",
317 - format!("/dashboard/item/{}", item.id)
318 - .parse()
319 - .expect("redirect path is valid"),
320 - );
317 + let redirect = format!("/dashboard/item/{}", item.id)
318 + .parse()
319 + .unwrap_or_else(|_| axum::http::HeaderValue::from_static("/dashboard"));
320 + response.headers_mut().insert("HX-Redirect", redirect);
321 321 Ok(response)
322 322 }
@@ -470,14 +470,14 @@ async fn save_preview(
470 470 }
471 471 }
472 472
473 - // Redirect to the project dashboard
473 + // Redirect to the project dashboard. The slug is validated, so the parse
474 + // should always succeed — but fall back to the dashboard root rather than
475 + // panicking the worker if a pathological value ever slips through (Run 11 UX MINOR).
474 476 let mut response = Response::new(axum::body::Body::empty());
475 - response.headers_mut().insert(
476 - "HX-Redirect",
477 - format!("/dashboard/project/{}", project.slug)
478 - .parse()
479 - .expect("redirect path is valid"),
480 - );
477 + let redirect = format!("/dashboard/project/{}", project.slug)
478 + .parse()
479 + .unwrap_or_else(|_| axum::http::HeaderValue::from_static("/dashboard"));
480 + response.headers_mut().insert("HX-Redirect", redirect);
481 481 Ok(response)
482 482 }
483 483
@@ -272,18 +272,26 @@ document.body.addEventListener('htmx:responseError', function(evt) {
272 272 var toast = document.createElement('div');
273 273 toast.className = 'toast toast-error';
274 274 var msg = document.createElement('span');
275 - // Surface the server's specific message. API errors come back as
276 - // {"error": "..."} (json_error_layer); fall back to a generic string when the
277 - // body isn't that shape so we never render raw JSON or "[object Object]".
275 + // Surface the server's specific message. Every AppError response carries an
276 + // `HX-Error` header with the user message, so this works on page routes too
277 + // (whose body is an HTML error page, not JSON). API routes additionally return
278 + // {"error": "..."}; fall back to that, then to a generic string, so we never
279 + // render raw JSON or "[object Object]".
278 280 var text = 'An error occurred.';
279 - var body = evt.detail && evt.detail.xhr && evt.detail.xhr.responseText;
280 - if (body) {
281 - try {
282 - var parsed = JSON.parse(body);
283 - if (parsed && typeof parsed.error === 'string' && parsed.error) {
284 - text = parsed.error;
285 - }
286 - } catch (e) { /* non-JSON body (e.g. an HTML error page); keep the fallback */ }
281 + var xhr = evt.detail && evt.detail.xhr;
282 + var headerMsg = xhr && xhr.getResponseHeader && xhr.getResponseHeader('HX-Error');
283 + if (headerMsg) {
284 + text = headerMsg;
285 + } else {
286 + var body = xhr && xhr.responseText;
287 + if (body) {
288 + try {
289 + var parsed = JSON.parse(body);
290 + if (parsed && typeof parsed.error === 'string' && parsed.error) {
291 + text = parsed.error;
292 + }
293 + } catch (e) { /* non-JSON body (e.g. an HTML error page); keep the fallback */ }
294 + }
287 295 }
288 296 msg.textContent = text;
289 297 toast.appendChild(msg);
@@ -5802,7 +5802,8 @@ textarea:focus-visible {
5802 5802 }
5803 5803
5804 5804 .fork-actions {
5805 - margin-top: 1.25rem;
5805 + margin-top: auto;
5806 + padding-top: 1.25rem;
5806 5807 display: flex;
5807 5808 flex-direction: column;
5808 5809 align-items: center;
@@ -28,7 +28,7 @@
28 28 <div id="buy-once-fields" class="hidden">
29 29 <div class="form-group">
30 30 <label for="price_dollars">Price ($)</label>
31 - <input type="number" name="price_dollars" id="price_dollars" min="0.50" step="0.01"
31 + <input type="number" name="price_dollars" id="price_dollars" min="0.50" max="10000" step="0.01"
32 32 placeholder="9.99" value="{{ price_dollars }}" disabled>
33 33 </div>
34 34 </div>
@@ -36,7 +36,7 @@
36 36 <div id="pwyw-fields" class="hidden">
37 37 <div class="form-group">
38 38 <label for="pwyw_min_dollars">Minimum price ($, 0 for no minimum)</label>
39 - <input type="number" name="pwyw_min_dollars" id="pwyw_min_dollars" min="0" step="0.01"
39 + <input type="number" name="pwyw_min_dollars" id="pwyw_min_dollars" min="0" max="10000" step="0.01"
40 40 placeholder="0.00" value="{{ pwyw_min_dollars }}" disabled>
41 41 </div>
42 42 </div>
@@ -123,6 +123,52 @@ async fn project_wizard_full_flow() {
123 123 assert!(is_public, "Project should be published");
124 124 }
125 125
126 + /// Regression (ultra-fuzz Run 11 UX F1): the $10k price cap must hold on the
127 + /// creation-wizard monetization step, not just the JSON API. A buy-once price
128 + /// above the cap is rejected, and no over-cap price is persisted.
129 + #[tokio::test]
130 + async fn project_wizard_buy_once_price_cap_enforced() {
131 + let mut h = TestHarness::new().await;
132 + let _user_id = h.create_creator("capwiz").await;
133 +
134 + h.client
135 + .post_form(
136 + "/dashboard/new-project/step/basics",
137 + "title=Cap+Test&slug=cap-test&project_type=blog&description=A+test",
138 + )
139 + .await;
140 + h.client
141 + .post_form("/dashboard/new-project/cap-test/step/appearance", "")
142 + .await;
143 +
144 + // $20,000 — well over the $10,000 cap.
145 + let resp = h
146 + .client
147 + .post_form(
148 + "/dashboard/new-project/cap-test/step/monetization",
149 + "pricing_model=buy_once&price_dollars=20000",
150 + )
151 + .await;
152 + assert!(
153 + !resp.status.is_success() || resp.text.to_lowercase().contains("10,000")
154 + || resp.text.to_lowercase().contains("exceed"),
155 + "over-cap wizard price should be rejected: {} {}",
156 + resp.status,
157 + resp.text
158 + );
159 +
160 + // Nothing over the cap was persisted.
161 + let price: Option<i32> =
162 + sqlx::query_scalar("SELECT price_cents FROM projects WHERE slug = 'cap-test'")
163 + .fetch_one(&h.db)
164 + .await
165 + .unwrap();
166 + assert!(
167 + price.unwrap_or(0) <= 1_000_000,
168 + "persisted price_cents {price:?} must not exceed the $10k cap"
169 + );
170 + }
171 +
126 172 #[tokio::test]
127 173 async fn project_wizard_save_as_draft() {
128 174 let mut h = TestHarness::new().await;