max / makenotwork
| 1 | //! The description layer: screens served through quasi rather than Askama. |
| 2 | //! |
| 3 | //! Wiki note `mnw-server-conversion-plan`, step S3 onward. `crate::shell` |
| 4 | //! already put the document head under quasi's renderer (S1); this is where |
| 5 | //! screens themselves start moving. |
| 6 | //! |
| 7 | //! Each of the seven converted screens shipped one at a time behind a |
| 8 | //! `QUASI_SCREENS` switch, so a conversion went out when its own test was green |
| 9 | //! and reverted by editing an env var. All seven are flipped and the switch is |
| 10 | //! deleted (`64b33b26`): [`mounts`] is unconditional, the route tables register |
| 11 | //! no Askama counterpart, and there is nothing left to revert to. A screen added |
| 12 | //! here is live the moment it is mounted. |
| 13 | //! |
| 14 | //! # Why a per-request state and not a per-request handler argument |
| 15 | //! |
| 16 | //! `quasi_router::Handler<S> = fn(&S, Request)`. `S` is the app, and on every |
| 17 | //! other host in the tree there is one app and one viewer for the life of the |
| 18 | //! process. A server has one process and many viewers, and that is the only |
| 19 | //! assumption it breaks, so the fix is that the adapter builds `S` per request |
| 20 | //! rather than that the handler grows a parameter. `Adapter::per_viewer` is |
| 21 | //! that: the factory runs in async context, where the session lookup already |
| 22 | //! lives, and hands the sync router a [`Viewer`] with the answer already in it. |
| 23 | //! |
| 24 | //! # The cost this exists to measure |
| 25 | //! |
| 26 | //! The router is sync (quasi's decision 6, taken for hosts with no runtime), so |
| 27 | //! quasi-axum dispatches on `spawn_blocking` and a handler reaching sqlx does it |
| 28 | //! through `Handle::block_on`. Every described request therefore holds a |
| 29 | //! blocking-pool thread for the length of its database round trips. That is |
| 30 | //! fine on a desktop app over rusqlite and is an open question on the one host |
| 31 | //! in the tree with many concurrent readers. It is not arguable, only |
| 32 | //! measurable: see `tests/load` and the S3 numbers in the wiki note. |
| 33 | |
| 34 | use Handle; |
| 35 | |
| 36 | use crateAppState; |
| 37 | use crateSessionUser; |
| 38 | |
| 39 | |
| 40 | |
| 41 | |
| 42 | |
| 43 | |
| 44 | |
| 45 | |
| 46 | |
| 47 | |
| 48 | |
| 49 | |
| 50 | |
| 51 | |
| 52 | |
| 53 | |
| 54 | |
| 55 | |
| 56 | |
| 57 | |
| 58 | |
| 59 | |
| 60 | |
| 61 | |
| 62 | |
| 63 | |
| 64 | |
| 65 | |
| 66 | |
| 67 | |
| 68 | /// The state one request is answered against. |
| 69 | /// |
| 70 | /// Built per request by the adapter's factory, which is the whole of quasi's |
| 71 | /// answer to a server: everything resolvable from the request head is loaded |
| 72 | /// before the sync router runs, and a handler reads it off `&S` the way every |
| 73 | /// other host's handler reads its app. |
| 74 | |
| 75 | /// The long-lived application state. Cloning it clones handles, not data. |
| 76 | pub app: AppState, |
| 77 | /// Who is asking. Resolved and revocation-checked by |
| 78 | /// [`crate::auth::authenticate`], the same path the extractor takes. |
| 79 | pub user: SessionUser, |
| 80 | /// The runtime the request arrived on, so a sync handler can reach the |
| 81 | /// async database. Captured in the factory rather than read inside the |
| 82 | /// handler: `Handle::current` works on a blocking thread today, and |
| 83 | /// depending on that is depending on where quasi-axum happens to dispatch. |
| 84 | pub runtime: Handle, |
| 85 | /// This session's CSRF token, for the shell to hand to the document. |
| 86 | /// |
| 87 | /// Resolved in the factory rather than in a renderer because minting one is |
| 88 | /// an async session write and a renderer is sync. It is the same token the |
| 89 | /// Askama pages carry: [`crate::csrf::get_or_create_token`] is |
| 90 | /// get-or-create, so a described page and a templated one in the same |
| 91 | /// session agree, and validation is one comparison either way. |
| 92 | pub csrf: String, |
| 93 | /// This request's session-tracking id, when it has one. |
| 94 | /// |
| 95 | /// The same class of fact as [`csrf`](Self::csrf) and resolved the same |
| 96 | /// way: reading it is an async session lookup, so the factory does it and |
| 97 | /// the sync handler reads the answer off `&S`. It is what lets a screen |
| 98 | /// tell the reader's own row apart from the rest, which |
| 99 | /// `user_sessions` needs twice over: the `Current` badge, and the one row |
| 100 | /// that offers no `Sign out`. |
| 101 | /// |
| 102 | /// `None` is a real state rather than a failure. A session predating |
| 103 | /// `crate::auth::SESSION_TRACKING_KEY` carries no tracking id, and a |
| 104 | /// screen answering for one marks no row as current. |
| 105 | pub session_id: , |
| 106 | /// Markup for the bespoke regions this request's screen describes. |
| 107 | /// |
| 108 | /// The seam between a handler and its renderer, and the reason it has to be |
| 109 | /// here rather than in either of them: a `Region::Bespoke` is filled on the |
| 110 | /// [`Webview`](quasi_webview::Webview), which quasi-axum builds *after* the |
| 111 | /// handler has answered, and the renderer factory is handed `&S` and the |
| 112 | /// answer. So the handler writes what it drew here and the renderer reads |
| 113 | /// it back off the same state. Both see one instance: the adapter builds a |
| 114 | /// single `Arc<Viewer>` per request and passes it to the router and then to |
| 115 | /// the factory. |
| 116 | /// |
| 117 | /// Behind a lock because the handler holds `&Viewer` and runs on a blocking |
| 118 | /// thread. Uncontended in practice: one request writes it, then one |
| 119 | /// renderer reads it, never at once. |
| 120 | fills: , |
| 121 | |
| 122 | |
| 123 | |
| 124 | /// Run a database future from inside a sync handler. |
| 125 | /// |
| 126 | /// The blocking hop, named in one place so the thing being measured is |
| 127 | /// countable rather than spread across every handler. Every call holds this |
| 128 | /// blocking thread until the query answers. |
| 129 | |
| 130 | self.runtime.block_on |
| 131 | |
| 132 | |
| 133 | /// Hand the renderer the markup for one bespoke region. |
| 134 | /// |
| 135 | /// Called by a handler while it builds its description, keyed by the slot |
| 136 | /// id the description gives that region. Markup, not text: a bespoke region |
| 137 | /// is the app's own and is not escaped, which is the whole of what makes it |
| 138 | /// bespoke and the whole of why a handler must not put a reader's string in |
| 139 | /// one without escaping it first. |
| 140 | |
| 141 | if let Ok = self.fills.lock |
| 142 | fills.insert; |
| 143 | |
| 144 | |
| 145 | |
| 146 | /// The shell every described screen is drawn in. |
| 147 | /// |
| 148 | /// Here rather than in each screen's `renderer` because of what it carries: |
| 149 | /// a described page whose shell does not declare the session token has |
| 150 | /// every write on it refused, and the five screens that wrote |
| 151 | /// `Shell::under("/static")` out by hand were five chances to forget. A |
| 152 | /// screen that wants more says so on top of this; a screen that says |
| 153 | /// nothing gets the token anyway. |
| 154 | /// |
| 155 | /// The layer order matches `crate::shell`, which is the Askama half of the |
| 156 | /// same document: `makeover` is prepended by the renderer, and the site's |
| 157 | /// own sheets live in `components`. |
| 158 | |
| 159 | |
| 160 | under |
| 161 | .layered |
| 162 | // Every described write is an htmx request, and htmx inherits this |
| 163 | // from the body, so one declaration covers the whole document. |
| 164 | .sending |
| 165 | |
| 166 | |
| 167 | /// Everything the handler drew, for the renderer to mount. |
| 168 | |
| 169 | self.fills.lock.map.unwrap_or_default |
| 170 | |
| 171 | |
| 172 | |
| 173 | /// Build the state factory the adapter calls per request. |
| 174 | /// |
| 175 | /// Refuses with `Unauthorized` when there is no session to resolve, which the |
| 176 | /// adapter turns into a bare status with no body. That is the right shape here |
| 177 | /// and not a shortcut: a store that will not answer is not a signed-out reader, |
| 178 | /// and rendering a sign-in notice would need the renderer that is built from |
| 179 | /// the state that could not be resolved. |
| 180 | |
| 181 | app: AppState, |
| 182 | + Send + Sync + 'static |
| 183 | move |parts| |
| 184 | let app = app.clone; |
| 185 | let runtime = current; |
| 186 | // Taken out of the head first, so the future owns what it needs. |
| 187 | let session = parts.extensions..cloned; |
| 188 | Boxpin |
| 189 | let Some = session else |
| 190 | // The session layer runs in front of this. Its absence is a |
| 191 | // wiring mistake rather than a signed-out reader. |
| 192 | return Err; |
| 193 | ; |
| 194 | match crateauthenticate.await |
| 195 | Ok => |
| 196 | // Before the handler runs, because the write that mints a |
| 197 | // token has to finish on the session this request holds. |
| 198 | // A failure here is the session store, not the reader. |
| 199 | let csrf = crateget_or_create_token |
| 200 | .await |
| 201 | .map_err?; |
| 202 | // Same reason as `csrf`: an async read a sync handler |
| 203 | // cannot do. Absent on a legacy session, which is a state |
| 204 | // the screens describe rather than an error. |
| 205 | let session_id = session |
| 206 | . |
| 207 | .await |
| 208 | .ok |
| 209 | .flatten; |
| 210 | Ok |
| 211 | app, |
| 212 | user, |
| 213 | runtime, |
| 214 | csrf, |
| 215 | session_id, |
| 216 | fills: default, |
| 217 | |
| 218 | |
| 219 | Err => Err, |
| 220 | |
| 221 | |
| 222 | |
| 223 | |
| 224 | |
| 225 | /// Every converted screen that is switched on, with the address it answers. |
| 226 | /// |
| 227 | /// One mount per screen rather than one router for all of them, because axum |
| 228 | /// strips a nest's prefix before the inner service sees the request: a single |
| 229 | /// nest covering both would have to sit at a prefix the Askama routes also live |
| 230 | /// under, and matchit refuses to hold a wildcard beside the parameterised routes |
| 231 | /// already there. Measured, not assumed: nesting at `/dashboard/project` panics |
| 232 | /// at startup against `/dashboard/project/{slug}/tabs/overview`. |
| 233 | /// |
| 234 | /// The list is empty when nothing is switched on, so the caller registers its |
| 235 | /// Askama routes exactly as before and the adapter is not in the stack at all. A |
| 236 | /// conversion is a startup-time choice: config is read once, and a per-request |
| 237 | /// branch would pay for a switch that never moves. |
| 238 | /// Every address a described screen can claim, switched on or not. |
| 239 | /// |
| 240 | /// `mounts` returns only what is currently on, which depends on config. This is |
| 241 | /// the whole set, and it exists for the CSRF coverage test: the manifest that |
| 242 | /// test reads is a process-global, so a test that switches a screen on leaves an |
| 243 | /// entry behind for a path the default router does not serve, and the probe |
| 244 | /// reads that as a route that lost its protection. The list lets it skip exactly |
| 245 | /// those and nothing else. |
| 246 | /// |
| 247 | /// Checked against `mounts` below rather than trusted, since a screen added to |
| 248 | /// one and not the other is the obvious way for this to rot. |
| 249 | pub const PATHS: & = & |
| 250 | PATH, |
| 251 | PATH, |
| 252 | PATH, |
| 253 | PATH, |
| 254 | PATH, |
| 255 | LIBRARY_PATH, |
| 256 | SETTINGS_PATH, |
| 257 | ]; |
| 258 | |
| 259 | /// Every screen's switch name, in the same order as [`PATHS`]. |
| 260 | /// |
| 261 | /// Test-only: the switches themselves are read from each screen's own `SCREEN` |
| 262 | /// in `mounts`, and this exists so the consistency check below has both halves |
| 263 | /// to compare. Kept beside `PATHS` rather than inside the test module, because |
| 264 | /// the pairing is the thing being asserted and splitting them is how they drift. |
| 265 | |
| 266 | const SCREENS: & = & |
| 267 | SCREEN, |
| 268 | SCREEN, |
| 269 | SCREEN, |
| 270 | SCREEN, |
| 271 | SCREEN, |
| 272 | LIBRARY_SCREEN, |
| 273 | SETTINGS_SCREEN, |
| 274 | ]; |
| 275 | |
| 276 | /// Every described screen, mounted. |
| 277 | /// |
| 278 | /// Unconditional since `64b33b26`. Each entry used to be gated on |
| 279 | /// `described(app, ..)` reading `QUASI_SCREENS`, so a screen could be switched |
| 280 | /// off and its Askama rendering registered instead. There is no Askama |
| 281 | /// rendering to fall back to any more, and the route tables no longer register |
| 282 | /// one, so a screen missing from this list is an address nothing answers. |
| 283 | |
| 284 | vec! |
| 285 | |
| 286 | PATH, |
| 287 | mount, |
| 288 | , |
| 289 | |
| 290 | PATH, |
| 291 | mount |
| 292 | app, |
| 293 | screen, |
| 294 | WRITES, |
| 295 | renderer, |
| 296 | , |
| 297 | , |
| 298 | // One module, two screens: the library tab and the settings section are |
| 299 | // the same table under different chrome. |
| 300 | |
| 301 | LIBRARY_PATH, |
| 302 | mount |
| 303 | app, |
| 304 | library_screen, |
| 305 | &, |
| 306 | renderer, |
| 307 | , |
| 308 | , |
| 309 | |
| 310 | PATH, |
| 311 | mount, |
| 312 | , |
| 313 | |
| 314 | PATH, |
| 315 | mount, |
| 316 | , |
| 317 | |
| 318 | PATH, |
| 319 | mount, |
| 320 | , |
| 321 | // A nest that answers no address of its own: the Members panel is read |
| 322 | // through its Askama route, which keeps a conditional GET, and only its |
| 323 | // writes are described. `03c0977b`; see `writes_only`. |
| 324 | |
| 325 | NEST, |
| 326 | writes_only, |
| 327 | , |
| 328 | // The item dashboard's Sales panel: a parameterized read address, so |
| 329 | // the read stays on Askama and only the Refund write is described. |
| 330 | // `b25dd957`; see `writes_only`. |
| 331 | |
| 332 | NEST, |
| 333 | writes_only, |
| 334 | , |
| 335 | |
| 336 | SETTINGS_PATH, |
| 337 | mount |
| 338 | app, |
| 339 | settings_screen, |
| 340 | &, |
| 341 | renderer, |
| 342 | , |
| 343 | , |
| 344 | ] |
| 345 | |
| 346 | |
| 347 | /// A handler, spelled once so the screens and the mount agree about it. |
| 348 | pub type Screen = |
| 349 | fn ; |
| 350 | |
| 351 | /// One screen behind the adapter, answering the root of its own nest. |
| 352 | /// |
| 353 | /// The tab is `/` because the nest has already taken the address off: a screen |
| 354 | /// mounted at its own tab endpoint sees one route and never has to agree with |
| 355 | /// the prefix twice. |
| 356 | /// |
| 357 | /// # Why a screen serves its own writes |
| 358 | /// |
| 359 | /// `writes` registers routes under the same nest, and a destructive control on |
| 360 | /// a described screen should address one of them rather than the API route the |
| 361 | /// Askama version used. Decision 7 is the reason: a described route answers with |
| 362 | /// a `Response::Fragment` naming the region it changed, so the answer lands where |
| 363 | /// it belongs and carries the screen's own markup. |
| 364 | /// |
| 365 | /// An API route can do neither, and both described screens that called one were |
| 366 | /// wrong in different ways. `DELETE /api/users/me/ssh-keys/{id}` answers an htmx |
| 367 | /// request with the whole re-rendered Askama list, and with no target htmx put |
| 368 | /// that table inside the button that was pressed. `DELETE /api/contacts/{id}` |
| 369 | /// answers 204, which htmx is configured never to swap, so the row stayed on |
| 370 | /// screen after a successful revoke. Both found 2026-08-11 by reading what the |
| 371 | /// endpoints return; both were invisible to tests that check the address exists. |
| 372 | /// |
| 373 | /// The write still goes through the same CSRF envelope: `crate::csrf` nests this |
| 374 | /// service under an Auto posture, and the core module attaches the token to |
| 375 | /// every htmx request on the page. |
| 376 | |
| 377 | app: &AppState, |
| 378 | screen: Screen, |
| 379 | writes: &[], |
| 380 | renderer: fn , |
| 381 | |
| 382 | nest |
| 383 | |
| 384 | |
| 385 | /// A nest that serves writes and answers no address of its own. |
| 386 | /// |
| 387 | /// `03c0977b`, ruled 2026-08-26 (Max), option (a). The hole it fills: a panel |
| 388 | /// whose route answers a conditional GET cannot be a mounted screen, because |
| 389 | /// [`mount`] has no way to say "304 if the cache generation has not moved". So |
| 390 | /// it stays a fill on its Askama handler -- and a fill has no nest, so it had |
| 391 | /// nowhere to put its writes, so its controls kept addressing API routes that |
| 392 | /// answer 200 or 204 and cannot name the region they changed. The patch for |
| 393 | /// that was `data-after`, the private dispatcher vocabulary in |
| 394 | /// `frontend/src/core/dispatch.ts` that this conversion exists to retire. |
| 395 | /// |
| 396 | /// This is the other half of such a panel: the read keeps its Askama route and |
| 397 | /// its ETag, and the writes get described routes that answer |
| 398 | /// `Response::Fragment` naming the panel's region, exactly as a mounted |
| 399 | /// screen's do. |
| 400 | /// |
| 401 | /// # The cost, stated once rather than per panel |
| 402 | /// |
| 403 | /// One panel is then served by two routers, and the read and the write are no |
| 404 | /// longer visible in one place. That is the trade: the alternative was nine |
| 405 | /// tabs converting their markup while keeping their JS, which lowers no seal |
| 406 | /// and is not what S4 is for. |
| 407 | /// |
| 408 | /// # The address is a fixed prefix, and the ids go inside it |
| 409 | /// |
| 410 | /// A nest is mounted at a fixed path, which is also why `project_analytics` is |
| 411 | /// a fill rather than a mounted screen. Path parameters live in the inner |
| 412 | /// router, which does support them: `ssh_keys` already registers `/keys/{id}` |
| 413 | /// and `/tokens/{id}` under its own nest. So a writes-only nest carries its ids |
| 414 | /// in the inner paths and does not reuse the API route's address. That API |
| 415 | /// route stays for API consumers, exactly as `/api/users/me/ssh-keys/{id}` did |
| 416 | /// when `ssh_keys` moved its controls off it. |
| 417 | |
| 418 | app: &AppState, |
| 419 | writes: &[], |
| 420 | renderer: fn , |
| 421 | |
| 422 | nest |
| 423 | |
| 424 | |
| 425 | /// The router both of the above build, with or without a root GET. |
| 426 | |
| 427 | app: &AppState, |
| 428 | screen: , |
| 429 | writes: &[], |
| 430 | renderer: fn , |
| 431 | |
| 432 | let mut quasi = new; |
| 433 | if let Some = screen |
| 434 | quasi = quasi.get; |
| 435 | |
| 436 | for in writes |
| 437 | quasi = match method |
| 438 | Delete => quasi.delete, |
| 439 | Put => quasi.put, |
| 440 | Get => quasi.get, |
| 441 | Post => quasi.post, |
| 442 | ; |
| 443 | |
| 444 | per_viewer |
| 445 | renderer |
| 446 | |
| 447 | .into_router |
| 448 | |
| 449 | |
| 450 | |
| 451 | |
| 452 | use *; |
| 453 | |
| 454 | |
| 455 | |
| 456 | // The two lists are written by hand and read by two different things, |
| 457 | // so the check is that adding a screen to `mounts` and forgetting |
| 458 | // `PATHS` fails here rather than silently weakening the CSRF coverage |
| 459 | // probe's skip list. |
| 460 | assert_eq! |
| 461 | PATHS.len, |
| 462 | SCREENS.len, |
| 463 | "PATHS and SCREENS describe the same screens" |
| 464 | ; |
| 465 | |
| 466 | let source = include_str!; |
| 467 | let mounted = source |
| 468 | .split_once |
| 469 | .expect |
| 470 | .1 |
| 471 | .split_once |
| 472 | .expect |
| 473 | .0; |
| 474 | // Counted as `mount(` since `64b33b26`. `mounts` used to push |
| 475 | // conditionally into a Vec, one `mounted.push((` per screen the switch |
| 476 | // had on; it returns a `vec![..]` literal now because every screen is |
| 477 | // mounted unconditionally, so the thing to count is the adapter call |
| 478 | // each entry makes. Not `mount(app,`: rustfmt breaks the longer entries |
| 479 | // across lines and that token then finds three of the six. |
| 480 | let registered = mounted.matches.count; |
| 481 | assert_eq! |
| 482 | registered, |
| 483 | PATHS.len, |
| 484 | "mounts registers {registered} screens, PATHS lists {}", |
| 485 | PATHS.len |
| 486 | ; |
| 487 | |
| 488 | |
| 489 | |
| 490 | |
| 491 | // Two screens on one address is an axum panic at startup, and the two |
| 492 | // forum-memberships screens are the near miss: one module, two paths. |
| 493 | let mut seen = PATHS.to_vec; |
| 494 | seen.sort_unstable; |
| 495 | let before = seen.len; |
| 496 | seen.dedup; |
| 497 | assert_eq! |
| 498 | before, |
| 499 | seen.len, |
| 500 | "two screens claim one address: {seen:?}" |
| 501 | ; |
| 502 | |
| 503 | |
| 504 |