Skip to main content

max / makenotwork

A described document carries its own session token `Viewer::shell()` is the shell every described screen is drawn in, and it declares `X-CSRF-Token` through quasi 0.30's `Shell::sending`. The five renderers wrote `Shell::under("/static")` out by hand, which was five chances to forget the one thing a document owes a write, so a test forbids a screen building its own. The premise this was filed on is corrected in the task and in the wiki. Every described screen answers a fragment, and a fragment is the inside of an element in an Askama document, so what carries the token today is `base.html`'s meta and the core module's `htmx:configRequest` listener -- not the described screen. Described writes were not 403ing. The gap is the first screen that owns its document, which is what deleting htmx-glue.ts produces. Both directions are asserted now: `a_described_write_accepts_the_token_the_page_around_it_carries` walks the browser's path, and the existing tokenless test still refuses.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-18 23:34 UTC
Signed with PGP, not checked
Commit: 3096d0151fe1b174c6efe90785ba931e60466cf4
Parent: f0036a4
9 files changed, +197 insertions, -57 deletions
M server/Cargo.lock +33 -29
@@ -4817,7 +4817,7 @@
4817 4817 source = "registry+https://github.com/rust-lang/crates.io-index"
4818 4818 checksum = "82cb6a9f675da968c63b6208c641b9dca58fc0133ae53375736b1767b0cab8bd"
4819 4819 dependencies = [
4820 - "windows-sys 0.59.0",
4820 + "windows-sys 0.61.2",
4821 4821 ]
4822 4822
4823 4823 [[package]]
@@ -5712,7 +5712,7 @@
5712 5712 source = "registry+https://github.com/rust-lang/crates.io-index"
5713 5713 checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
5714 5714 dependencies = [
5715 - "windows-sys 0.59.0",
5715 + "windows-sys 0.61.2",
5716 5716 ]
5717 5717
5718 5718 [[package]]
@@ -6632,7 +6632,7 @@
6632 6632
6633 6633 [[package]]
6634 6634 name = "quasi-axum"
6635 - version = "0.29.1"
6635 + version = "0.30.0"
6636 6636 dependencies = [
6637 6637 "axum",
6638 6638 "http 1.4.2",
@@ -6643,7 +6643,7 @@
6643 6643
6644 6644 [[package]]
6645 6645 name = "quasi-basics"
6646 - version = "0.29.1"
6646 + version = "0.30.0"
6647 6647 dependencies = [
6648 6648 "makeover-layout",
6649 6649 "quasi-router",
@@ -6651,7 +6651,7 @@
6651 6651
6652 6652 [[package]]
6653 6653 name = "quasi-http"
6654 - version = "0.29.1"
6654 + version = "0.30.0"
6655 6655 dependencies = [
6656 6656 "form_urlencoded",
6657 6657 "http 1.4.2",
@@ -6660,7 +6660,7 @@
6660 6660
6661 6661 [[package]]
6662 6662 name = "quasi-router"
6663 - version = "0.29.1"
6663 + version = "0.30.0"
6664 6664 dependencies = [
6665 6665 "makeover-layout",
6666 6666 ]
@@ -6683,7 +6683,7 @@
6683 6683
6684 6684 [[package]]
6685 6685 name = "quasi-webview"
6686 - version = "0.29.1"
6686 + version = "0.30.0"
6687 6687 dependencies = [
6688 6688 "docengine",
6689 6689 "makeover-layout",
@@ -6759,7 +6759,7 @@
6759 6759 "once_cell",
6760 6760 "socket2",
6761 6761 "tracing",
6762 - "windows-sys 0.59.0",
6762 + "windows-sys 0.52.0",
6763 6763 ]
6764 6764
6765 6765 [[package]]
@@ -7322,7 +7322,7 @@
7322 7322 "errno",
7323 7323 "libc",
7324 7324 "linux-raw-sys 0.4.15",
7325 - "windows-sys 0.59.0",
7325 + "windows-sys 0.52.0",
7326 7326 ]
7327 7327
7328 7328 [[package]]
@@ -10768,6 +10768,30 @@
10768 10768 "pkg-config",
10769 10769 ]
10770 10770
10771 + [[patch.unused]]
10772 + name = "makeover-immediate"
10773 + version = "0.30.0"
10774 +
10775 + [[patch.unused]]
10776 + name = "makeover-tui"
10777 + version = "0.30.0"
10778 +
10779 + [[patch.unused]]
10780 + name = "quasi-immediate"
10781 + version = "0.30.0"
10782 +
10783 + [[patch.unused]]
10784 + name = "quasi-notifs"
10785 + version = "0.30.0"
10786 +
10787 + [[patch.unused]]
10788 + name = "quasi-store"
10789 + version = "0.1.0"
10790 +
10791 + [[patch.unused]]
10792 + name = "quasi-tauri"
10793 + version = "0.30.0"
10794 +
10771 10795 [[patch.unused]]
10772 10796 name = "synckit-client"
10773 10797 version = "0.8.0"
@@ -10787,23 +10811,3 @@
10787 10811 [[patch.unused]]
10788 10812 name = "painhours"
10789 10813 version = "0.1.0"
10790 -
10791 - [[patch.unused]]
10792 - name = "makeover-immediate"
10793 - version = "0.30.0"
10794 -
10795 - [[patch.unused]]
10796 - name = "makeover-tui"
10797 - version = "0.30.0"
10798 -
10799 - [[patch.unused]]
10800 - name = "quasi-immediate"
10801 - version = "0.29.1"
10802 -
10803 - [[patch.unused]]
10804 - name = "quasi-store"
10805 - version = "0.1.0"
10806 -
10807 - [[patch.unused]]
10808 - name = "quasi-tauri"
10809 - version = "0.29.1"
@@ -158,7 +158,7 @@
158 158 # through quasi beside its Askama equivalent, so the cost of describing a screen
159 159 # here is measured rather than argued. Not load-bearing for any shipped route.
160 160 # See wiki look-wave-2, tier G.
161 - quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.29" }
161 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.30" }
162 162 # The description vocabulary quasi's screen types are built from. Pinned here
163 163 # rather than reached through quasi-router's re-export because a described
164 164 # screen names FieldKind and Tone directly; it has to track what quasi-router
@@ -167,13 +167,13 @@
167 167 # For the request head the per-viewer state factory reads. axum re-exports it,
168 168 # but the factory's signature is quasi-axum's and names `http::request::Parts`.
169 169 http = "1.3.1"
170 - quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.29" }
171 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.29" }
170 + quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.30" }
171 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.30" }
172 172 # The first-party widget set. The carousel was proved here against three pages
173 173 # and then moved there; what is left in `quasi/widgets/carousel.rs` is the
174 174 # Askama glue. Not `makeover-basics`: a widget is an assembly of `Node`s, so it
175 175 # sits above quasi-router rather than inside the description suite.
176 - quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.29" }
176 + quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.30" }
177 177
178 178 # Git source browser
179 179 gix = { version = "0.86", default-features = false, features = ["blame", "mailmap", "revision", "sha1", "max-performance-safe"] }
@@ -38,7 +38,7 @@
38 38 use makeover_layout as layout;
39 39 use quasi_router::screen::{Cell, Cells, Column};
40 40 use quasi_router::{Action, Node, RegionKind, Request, Response, RouteError, Slot};
41 - use quasi_webview::{Shell, Webview};
41 + use quasi_webview::Webview;
42 42
43 43 use super::Viewer;
44 44 use crate::db;
@@ -173,8 +173,8 @@
173 173 }
174 174
175 175 /// The renderer this screen is drawn with.
176 - pub fn renderer(_viewer: &Viewer) -> Webview {
177 - Webview::new().with_shell(Shell::under("/static").layered(["base", "components", "responsive"]))
176 + pub fn renderer(viewer: &Viewer) -> Webview {
177 + Webview::new().with_shell(viewer.shell())
178 178 }
179 179
180 180 #[cfg(test)]
@@ -34,7 +34,7 @@
34 34 use makeover_layout as layout;
35 35 use quasi_router::screen::{Act, Cell, Cells, Column, Tag};
36 36 use quasi_router::{Action, Node, RegionKind, Request, Response, RouteError, Slot};
37 - use quasi_webview::{Shell, Webview};
37 + use quasi_webview::Webview;
38 38
39 39 use super::Viewer;
40 40
@@ -245,8 +245,8 @@
245 245 }
246 246
247 247 /// The renderer both screens are drawn with.
248 - pub fn renderer(_viewer: &Viewer) -> Webview {
249 - Webview::new().with_shell(Shell::under("/static").layered(["base", "components", "responsive"]))
248 + pub fn renderer(viewer: &Viewer) -> Webview {
249 + Webview::new().with_shell(viewer.shell())
250 250 }
251 251
252 252 #[cfg(test)]
@@ -30,7 +30,7 @@
30 30 use makeover_layout as layout;
31 31 use quasi_router::screen::{Act, Cell, Cells, Column};
32 32 use quasi_router::{Action, Method, Node, RegionKind, Request, Response, RouteError, Slot};
33 - use quasi_webview::{Shell, Webview};
33 + use quasi_webview::Webview;
34 34
35 35 use super::Viewer;
36 36 use crate::db;
@@ -266,8 +266,8 @@
266 266 }
267 267
268 268 /// The renderer this screen is drawn with.
269 - pub fn renderer(_viewer: &Viewer) -> Webview {
270 - Webview::new().with_shell(Shell::under("/static").layered(["base", "components", "responsive"]))
269 + pub fn renderer(viewer: &Viewer) -> Webview {
270 + Webview::new().with_shell(viewer.shell())
271 271 }
272 272
273 273 #[cfg(test)]
@@ -55,6 +55,14 @@
55 55 /// handler: `Handle::current` works on a blocking thread today, and
56 56 /// depending on that is depending on where quasi-axum happens to dispatch.
57 57 pub runtime: Handle,
58 + /// This session's CSRF token, for the shell to hand to the document.
59 + ///
60 + /// Resolved in the factory rather than in a renderer because minting one is
61 + /// an async session write and a renderer is sync. It is the same token the
62 + /// Askama pages carry: [`crate::csrf::get_or_create_token`] is
63 + /// get-or-create, so a described page and a templated one in the same
64 + /// session agree, and validation is one comparison either way.
65 + pub csrf: String,
58 66 /// Markup for the bespoke regions this request's screen describes.
59 67 ///
60 68 /// The seam between a handler and its renderer, and the reason it has to be
@@ -95,6 +103,27 @@
95 103 }
96 104 }
97 105
106 + /// The shell every described screen is drawn in.
107 + ///
108 + /// Here rather than in each screen's `renderer` because of what it carries:
109 + /// a described page whose shell does not declare the session token has
110 + /// every write on it refused, and the five screens that wrote
111 + /// `Shell::under("/static")` out by hand were five chances to forget. A
112 + /// screen that wants more says so on top of this; a screen that says
113 + /// nothing gets the token anyway.
114 + ///
115 + /// The layer order matches `crate::shell`, which is the Askama half of the
116 + /// same document: `makeover` is prepended by the renderer, and the site's
117 + /// own sheets live in `components`.
118 + #[must_use]
119 + pub fn shell(&self) -> quasi_webview::Shell {
120 + quasi_webview::Shell::under("/static")
121 + .layered(["base", "components", "responsive"])
122 + // Every described write is an htmx request, and htmx inherits this
123 + // from the body, so one declaration covers the whole document.
124 + .sending("X-CSRF-Token", &self.csrf)
125 + }
126 +
98 127 /// Everything the handler drew, for the renderer to mount.
99 128 pub fn drawn(&self) -> std::collections::HashMap<String, String> {
100 129 self.fills.lock().map(|f| f.clone()).unwrap_or_default()
@@ -123,12 +152,21 @@
123 152 return Err(quasi_router::RouteError::internal("no session layer"));
124 153 };
125 154 match crate::auth::authenticate(&session, &app).await {
126 - Ok(user) => Ok(Viewer {
127 - app,
128 - user,
129 - runtime,
130 - fills: std::sync::Mutex::default(),
131 - }),
155 + Ok(user) => {
156 + // Before the handler runs, because the write that mints a
157 + // token has to finish on the session this request holds.
158 + // A failure here is the session store, not the reader.
159 + let csrf = crate::csrf::get_or_create_token(&session)
160 + .await
161 + .map_err(|_| quasi_router::RouteError::internal("csrf token"))?;
162 + Ok(Viewer {
163 + app,
164 + user,
165 + runtime,
166 + csrf,
167 + fills: std::sync::Mutex::default(),
168 + })
169 + }
132 170 Err(_) => Err(quasi_router::RouteError::denied("sign in to continue")),
133 171 }
134 172 })
@@ -43,7 +43,7 @@
43 43 use makeover_layout as layout;
44 44 use quasi_router::screen::{Act, Cell, Cells, Choice, Column, Field};
45 45 use quasi_router::{Action, Method, Node, RegionKind, Request, Response, RouteError, Slot};
46 - use quasi_webview::{Shell, Webview};
46 + use quasi_webview::Webview;
47 47
48 48 use super::Viewer;
49 49 use crate::db;
@@ -399,11 +399,11 @@
399 399 /// Per request because `Adapter::per_viewer` builds one per request, and this
400 400 /// screen has nothing viewer-specific to say to it yet. It will when S2 puts the
401 401 /// site chrome here.
402 - pub fn renderer(_viewer: &Viewer) -> Webview {
402 + pub fn renderer(viewer: &Viewer) -> Webview {
403 403 // The fragment path never emits a document, so the shell's asset paths do
404 404 // not arise here. It is still the one the rest of the site uses, so a screen
405 405 // that later answers as a whole page cannot disagree with `crate::shell`.
406 - Webview::new().with_shell(Shell::under("/static").layered(["base", "components", "responsive"]))
406 + Webview::new().with_shell(viewer.shell())
407 407 }
408 408
409 409 #[cfg(test)]
@@ -47,7 +47,7 @@
47 47 use makeover_layout as layout;
48 48 use quasi_router::screen::{Cell, Cells, Column, Figure, Row, Tag};
49 49 use quasi_router::{Action, Node, RegionKind, Request, Response, RouteError, Slot};
50 - use quasi_webview::{Shell, Webview};
50 + use quasi_webview::Webview;
51 51
52 52 use super::Viewer;
53 53 use crate::db;
@@ -446,11 +446,7 @@
446 446 /// Mounts whatever the handler drew for its bespoke regions. The two see one
447 447 /// `Viewer`; see [`super::Viewer::fills`].
448 448 pub fn renderer(viewer: &Viewer) -> Webview {
449 - let mut webview = Webview::new().with_shell(Shell::under("/static").layered([
450 - "base",
451 - "components",
452 - "responsive",
453 - ]));
449 + let mut webview = Webview::new().with_shell(viewer.shell());
454 450 for (slot, markup) in viewer.drawn() {
455 451 webview = webview.with_fill(slot, markup);
456 452 }
@@ -349,3 +349,105 @@
349 349 resp.status
350 350 );
351 351 }
352 +
353 + /// A described write SUCCEEDS with the token the document around it carries.
354 + ///
355 + /// The other half, and the one that was missing. The test above asserts the
356 + /// enforcement works; nothing asserted a described write can be completed at
357 + /// all, so a surface where every write answered 403 would have read as covered.
358 + ///
359 + /// # Where the token comes from, measured 2026-08-18
360 + ///
361 + /// Every described screen answers a `Response::fragment`, so it is the inside of
362 + /// an element in an Askama document, and that document is what carries the
363 + /// token: `base.html` emits `<meta name="csrf-token">` and the core module
364 + /// attaches it to every htmx request on the page. So this walks the browser's
365 + /// path exactly -- read the page, take the meta, use it on the write -- with the
366 + /// test doing by hand what `htmx-glue.ts` does on `htmx:configRequest`.
367 + ///
368 + /// The document-owning case is the other half and no screen exercises it yet:
369 + /// see `a_described_screen_never_builds_its_own_shell` below and
370 + /// `Viewer::shell`.
371 + #[tokio::test]
372 + async fn a_described_write_accepts_the_token_the_page_around_it_carries() {
373 + let mut h = TestHarness::build(crate::harness::BuildOptions {
374 + quasi_screens: makenotwork::config::QuasiScreens::parse("library_contacts"),
375 + ..Default::default()
376 + })
377 + .await;
378 + h.signup("creator", "creator@example.com", "password123")
379 + .await;
380 + h.login("creator", "password123").await;
381 +
382 + // The document the tab lives in, not the tab: the tab is a fragment and a
383 + // fragment carries no head.
384 + let page = h.client.get("/library").await;
385 + assert_eq!(page.status, 200, "the library page did not render");
386 + let token = meta_csrf_token(&page.text)
387 + .expect("the page around a described screen carries no csrf-token meta");
388 +
389 + let resp = h
390 + .client
391 + .request_with_headers(
392 + "DELETE",
393 + "/library/tabs/contacts/revoke/00000000-0000-0000-0000-000000000000",
394 + None,
395 + &[("X-CSRF-Token", token.as_str()), ("HX-Request", "true")],
396 + )
397 + .await;
398 +
399 + // What the write answers about a contact that does not exist is the
400 + // handler's business. This asserts only that it reached the handler.
401 + assert_ne!(
402 + resp.status, 403,
403 + "a described write refused the token its own document supplied"
404 + );
405 + }
406 +
407 + /// The token out of `<meta name="csrf-token">`, the way the glue reads it.
408 + fn meta_csrf_token(html: &str) -> Option<String> {
409 + let at = html.split("name=\"csrf-token\"").nth(1)?;
410 + let at = at.split("content=\"").nth(1)?;
411 + Some(at.split('"').next()?.to_owned())
412 + }
413 +
414 + /// No described screen builds its own shell.
415 + ///
416 + /// The failure this forbids is silent and one line long: a screen that writes
417 + /// `Shell::under("/static")` by hand gets a document with no session token in
418 + /// it, and every write on that screen answers 403 the moment the screen owns
419 + /// its document rather than answering into an Askama page. Five screens wrote
420 + /// that line before `Viewer::shell` existed, which was five chances to forget.
421 + ///
422 + /// Source-level rather than behavioural because there is nothing to observe
423 + /// yet: every described screen answers a fragment today, and a fragment has no
424 + /// `<body>` to carry anything. The test that would catch it is the one that
425 + /// cannot be written until the mistake ships.
426 + #[test]
427 + fn a_described_screen_never_builds_its_own_shell() {
428 + for (name, source) in [
429 + ("ssh_keys", include_str!("../../src/quasi/ssh_keys.rs")),
430 + (
431 + "library_contacts",
432 + include_str!("../../src/quasi/library_contacts.rs"),
433 + ),
434 + (
435 + "buyer_contacts",
436 + include_str!("../../src/quasi/buyer_contacts.rs"),
437 + ),
438 + (
439 + "user_analytics",
440 + include_str!("../../src/quasi/user_analytics.rs"),
441 + ),
442 + (
443 + "forum_memberships",
444 + include_str!("../../src/quasi/forum_memberships.rs"),
445 + ),
446 + ] {
447 + assert!(
448 + !source.contains("Shell::under"),
449 + "{name} builds its own shell: use `viewer.shell()`, which carries \
450 + the session token every write on a described document needs"
451 + );
452 + }
453 + }