Skip to main content

max / makenotwork

Exclude the two s3-storage oracle wrappers from mutation An oracle only speaks when the code under test is wrong, so replacing its body with () is invisible to every test that passes. The evidence the wrappers work is that all 34 mutants in MultipartPlan were caught, at these assertions. The bar for this file is impossibility, not cost. configure_cors and the listing truncation guard also survive and are NOT here: both are killable by a test nobody has written, so they stay visible survivors with tasks against them (infra a536db81, f7f13914).
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
Author: Max Johnson <me@maxj.phd> · 2026-08-31 14:05 UTC
Signed with PGP, not checked
Commit: ca1d384cbee6d410b8fe49e1a2be1203c4cce3bd
Parent: 6cd6598
17 files changed, +680 insertions, -283 deletions
M server/build.rs +4 -1
@@ -685,7 +685,9 @@
685 685 ///
686 686 /// **A divergence taken on purpose.** `.table-row { display }` is `grid` here
687 687 /// against makeover's `table-row`, which is the same build-time grid story
688 - /// goingson is on.
688 + /// goingson is on. `.table-head { display }` is the same divergence on the
689 + /// heading row: the feed's five column tracks have to be stated on both arms
690 + /// or the headings stop lining up with the cells under them.
689 691 ///
690 692 /// Do not restate a state makeover already names: tabs carry `chosen`, in the
691 693 /// templates and in `frontend/src/core/tabs.ts`, rather than a local
@@ -719,6 +721,7 @@
719 721 ("tab", "color"),
720 722 ("tab", "cursor"),
721 723 ("table-row", "display"),
724 + ("table-head", "display"),
722 725 ];
723 726
724 727 /// Bare element rules that reach a generated class and have been read and kept.
@@ -628,6 +628,38 @@
628 628 Self(self.0.nest_service(path, guarded))
629 629 }
630 630
631 + /// Mount a self-routing service at ONE exact address.
632 + ///
633 + /// [`nest_service`](Self::nest_service)'s sibling, for a described screen
634 + /// that owns a whole document rather than a panel. A nest claims
635 + /// `{path}/{*tail}`, and matchit refuses that beside a parameterised route
636 + /// already registered under the same prefix: `/feed` is a described screen
637 + /// and `/feed/{user_id}` is that user's RSS. An exact route claims neither,
638 + /// and a document screen has no sub-paths to serve.
639 + ///
640 + /// Same Auto posture, same wrapping order, and deliberately no posture
641 + /// argument, for the reasons stated on `nest_service`.
642 + #[must_use]
643 + pub fn route_service<T>(self, path: &str, service: T) -> Self
644 + where
645 + T: tower::Service<Request, Error = std::convert::Infallible>
646 + + Clone
647 + + Send
648 + + Sync
649 + + 'static,
650 + T::Response: IntoResponse + 'static,
651 + T::Future: Send + 'static,
652 + {
653 + record_route(path, CsrfPosture::Auto);
654 + let guarded = tower::ServiceBuilder::new()
655 + .layer(from_fn(|req: Request, next: Next| async move {
656 + let path = req.uri().path().to_string();
657 + validate_auto(req, next, &path).await
658 + }))
659 + .service(service);
660 + Self(self.0.route_service(path, guarded))
661 + }
662 +
631 663 #[must_use]
632 664 pub fn layer<L>(self, layer: L) -> Self
633 665 where
@@ -600,6 +600,15 @@
600 600 .fold(csrf_routes, |routes, (path, described)| {
601 601 routes.nest_service(path, described)
602 602 });
603 + // The screens that own their whole document. Same envelope and the same
604 + // factory; what differs is a session gate in front, so an address a person
605 + // can type answers the branded 401 rather than the adapter's bare 403.
606 + // See `quasi::document_mounts`.
607 + let csrf_routes = quasi::document_mounts(state)
608 + .into_iter()
609 + .fold(csrf_routes, |routes, (path, described)| {
610 + routes.route_service(path, described)
611 + });
603 612 // The public half of the description layer. Same envelope, different
604 613 // factory: these screens resolve no session. See `quasi::public_mounts`.
605 614 let csrf_routes = quasi::public_mounts(state)
@@ -160,6 +160,72 @@
160 160 )
161 161 }
162 162
163 + /// The site header, as every page on this site carries it.
164 + ///
165 + /// Called by `partials/site_header.html` for the templated pages and handed to
166 + /// [`quasi_webview::Shell::with_body_first`] by a screen that owns its own
167 + /// document, so the nav is written once. [`body_last`]'s argument one element
168 + /// up.
169 + ///
170 + /// Not a `Chrome`. `Chrome::nav` is emitted before `<main>` and a
171 + /// `Chrome::panel` after `</main>`, and this header's mobile menu is a checkbox
172 + /// styling its siblings (`style.css`, `.nav-toggle-checkbox:checked ~ .header-search`
173 + /// and `~ nav`), so the two halves have to be one element. Wiki note
174 + /// `mnw-server-conversion-plan`, the 2026-08-31 ruling.
175 + ///
176 + /// Nothing a reader wrote is emitted here: the only per-request values are
177 + /// `user.is_admin` and the session's CSRF token, and the token is escaped
178 + /// anyway rather than trusted for being ours.
179 + #[must_use]
180 + pub fn site_header(user: Option<&crate::auth::SessionUser>, csrf: Option<&str>) -> String {
181 + let mut out = String::with_capacity(1600);
182 + out.push_str(concat!(
183 + "<header class=\"site-header\" role=\"banner\">",
184 + "<a href=\"/\" class=\"site-logo\" aria-label=\"Makenotwork - Go to homepage\">",
185 + "Makenot<span class=\"dot\" aria-hidden=\"true\">.</span>work</a>",
186 + "<input type=\"checkbox\" id=\"nav-toggle\" class=\"nav-toggle-checkbox\" aria-hidden=\"true\">",
187 + "<label for=\"nav-toggle\" class=\"nav-toggle-label\" aria-label=\"Toggle navigation menu\">",
188 + "<span></span><span></span><span></span></label>",
189 + "<form action=\"/discover\" method=\"get\" class=\"header-search\" role=\"search\">",
190 + "<input type=\"search\" name=\"q\" id=\"header-search-input\" ",
191 + "placeholder=\"Search... (Cmd+K)\" autocomplete=\"off\" ",
192 + "aria-label=\"Search items and projects\"></form>",
193 + "<nav aria-label=\"Main navigation\"><div class=\"nav-links\">",
194 + "<a href=\"/discover\">Discover</a>",
195 + ));
196 + if let Some(user) = user {
197 + out.push_str(concat!(
198 + "<a href=\"/library\">Library</a>",
199 + "<a href=\"/dashboard\">Dashboard</a>",
200 + "<a href=\"/cart\" id=\"nav-cart-link\" class=\"hidden\">Cart",
201 + "<span id=\"cart-badge\" class=\"cart-badge-count\"></span></a>",
202 + ));
203 + if user.is_admin {
204 + out.push_str("<a href=\"/admin/waitlist\">Admin</a>");
205 + }
206 + out.push_str("<form action=\"/logout\" method=\"post\" class=\"nav-form\">");
207 + if let Some(token) = csrf {
208 + out.push_str("<input type=\"hidden\" name=\"_csrf\" value=\"");
209 + out.push_str(&crate::helpers::escape_html(token));
210 + out.push_str("\">");
211 + }
212 + out.push_str(concat!(
213 + "<button type=\"submit\" class=\"btn--link\" aria-label=\"Log out\">Log Out</button>",
214 + "</form>",
215 + ));
216 + } else {
217 + out.push_str(concat!(
218 + "<a href=\"/use-cases\">Use Cases</a>",
219 + "<a href=\"/docs\">Docs</a>",
220 + "<a href=\"/fan-plus\">Fan+</a>",
221 + "<a href=\"/login\">Login</a>",
222 + "<a href=\"/join\">Join</a>",
223 + ));
224 + }
225 + out.push_str("</div></nav></header>");
226 + out
227 + }
228 +
163 229 /// `<!doctype>` through the head's contents, without `</head>`.
164 230 ///
165 231 /// Called from `base.html`, which appends the title and the per-page head and
@@ -308,6 +374,75 @@
308 374 );
309 375 }
310 376
377 + /// A reader with no session is offered the two ways in and no way out.
378 + #[test]
379 + fn the_signed_out_header_offers_the_ways_in() {
380 + let html = site_header(None, None);
381 +
382 + assert!(html.contains("href=\"/login\""), "{html}");
383 + assert!(html.contains("href=\"/join\""), "{html}");
384 + assert!(html.contains("href=\"/discover\""), "{html}");
385 + assert!(!html.contains("/logout"), "{html}");
386 + assert!(!html.contains("/dashboard"), "{html}");
387 + }
388 +
389 + /// A signed-in reader gets the account nav, the logout form and the token
390 + /// that form needs. A logout with no `_csrf` is a logout the envelope
391 + /// refuses.
392 + #[test]
393 + fn the_signed_in_header_carries_the_logout_form_and_its_token() {
394 + let html = site_header(Some(&a_user(false)), Some("tok&en"));
395 +
396 + assert!(html.contains("action=\"/logout\""), "{html}");
397 + assert!(
398 + html.contains("name=\"_csrf\" value=\"tok&amp;en\""),
399 + "{html}"
400 + );
401 + assert!(html.contains("href=\"/library\""), "{html}");
402 + assert!(html.contains("href=\"/dashboard\""), "{html}");
403 + assert!(!html.contains("href=\"/login\""), "{html}");
404 + }
405 +
406 + /// The admin link is the one thing in the nav that is a permission rather
407 + /// than a session.
408 + #[test]
409 + fn only_an_admin_is_offered_the_admin_link() {
410 + assert!(site_header(Some(&a_user(true)), Some("t")).contains("/admin/waitlist"));
411 + assert!(!site_header(Some(&a_user(false)), Some("t")).contains("/admin/waitlist"));
412 + }
413 +
414 + /// The mobile menu is a checkbox styling its siblings, which is the whole
415 + /// reason this header is one element rather than a `Chrome`. The three have
416 + /// to stay siblings inside `<header>`, in this order.
417 + #[test]
418 + fn the_disclosure_stays_a_sibling_of_what_it_reveals() {
419 + let html = site_header(None, None);
420 +
421 + let toggle = html.find("nav-toggle-checkbox").expect("the checkbox");
422 + let search = html.find("header-search").expect("the search form");
423 + let nav = html.find("<nav").expect("the nav");
424 + assert!(toggle < search && search < nav, "{html}");
425 + assert!(html.ends_with("</header>"), "{html}");
426 + }
427 +
428 + fn a_user(is_admin: bool) -> crate::auth::SessionUser {
429 + crate::auth::SessionUser {
430 + id: crate::db::UserId::new(),
431 + username: crate::db::Username::from_trusted("areader".to_string()),
432 + email: "areader@example.com".to_string(),
433 + display_name: None,
434 + can_create_projects: false,
435 + suspended: false,
436 + is_admin,
437 + is_fan_plus: false,
438 + creator_tier: None,
439 + deactivated: false,
440 + is_sandbox: false,
441 + settlement_currency: crate::currency::SettlementCurrency::Usd,
442 + conversion_preference: crate::currency::ConversionChoice::AtCheckout,
443 + }
444 + }
445 +
311 446 #[test]
312 447 fn no_template_still_writes_a_layout_class_by_hand() {
313 448 // The done-condition, checked rather than remembered: the layout axis
@@ -406,7 +406,6 @@
406 406 .export-page .container { max-width: 800px; margin: 0 auto; }
407 407
408 408 .fan-plus-page .container,
409 - .feed-page .container,
410 409 .creators-page .container { max-width: 900px; margin: 0 auto; }
411 410
412 411 /* READING COLUMN, the 680px measure every long-form surface uses. Narrower
@@ -3539,12 +3538,28 @@
3539 3538 .purchase-page h2 { font-size: var(--text-head); }
3540 3539 }
3541 3540
3542 - /* Feed page (templates/pages/feed.html). Compact tabular layout of
3543 - items from followed users / projects / tags. */
3544 - .feed-page .page-title { font-size: var(--text-head); margin-bottom: var(--gap-section); }
3545 - .feed-page .feed-meta { font-size: var(--text-fine); opacity: 0.6; margin-bottom: var(--gap-section); }
3541 + /* Feed page (src/quasi/feeds.rs). Compact tabular layout of items from
3542 + followed users / projects / tags.
3546 3543
3547 - .feed-page .table-header {
3544 + The page is described rather than templated as of 2026-08-31, so the
3545 + selectors below name what quasi and makeover emit. It has no .container:
3546 + a described document is <body> > <main> > the screen's region, so the 900px
3547 + measure the container carried sits on <main> here. */
3548 + .feed-page main { max-width: 900px; margin: 0 auto; padding: var(--gap-page); }
3549 +
3550 + .feed-page main h1 { font-size: var(--text-head); margin-bottom: var(--gap-section); }
3551 +
3552 + /* The "Showing X-Y of N items" line, which the description states as a plain
3553 + text node above the table. */
3554 + .feed-page .pane > p.text {
3555 + font-size: var(--text-fine);
3556 + opacity: 0.6;
3557 + margin-bottom: var(--gap-section);
3558 + }
3559 +
3560 + .feed-page .table-head {
3561 + /* respec-ok: the same five-column grid the rows carry, on the heading row
3562 + that has to line up with them. */
3548 3563 display: grid;
3549 3564 grid-template-columns: 50px 1fr 100px 70px 70px;
3550 3565 gap: var(--gap-peer);
@@ -3556,11 +3571,8 @@
3556 3571 letter-spacing: 0.03em;
3557 3572 }
3558 3573
3559 - .feed-page .col-right { text-align: right; }
3560 -
3561 - .feed-page .results-table {
3574 + .feed-page .table {
3562 3575 border: 1px solid var(--border);
3563 - border-top: none;
3564 3576 }
3565 3577
3566 3578 .feed-page .table-row {
@@ -3582,20 +3594,25 @@
3582 3594 .feed-page .table-row:last-child { border-bottom: none; }
3583 3595 .feed-page .table-row:hover { background: var(--surface-sunken); }
3584 3596
3585 - .feed-page .item-name-cell {
3597 + /* The two-line name cell: `Cell::part` puts the creator under the name in one
3598 + cell, and each part is emitted as its own `.cell-value`. */
3599 + .feed-page .col-Name {
3586 3600 display: flex;
3587 3601 flex-direction: column;
3588 3602 gap: var(--gap-bound);
3589 3603 min-width: 0;
3590 3604 }
3591 3605
3592 - .feed-page .item-name {
3606 + .feed-page .table-row .col-Name .cell-value:first-child {
3593 3607 white-space: nowrap;
3594 3608 overflow: hidden;
3595 3609 text-overflow: ellipsis;
3596 3610 }
3597 3611
3598 - .feed-page .item-creator { font-size: var(--text-fine); opacity: 0.5; }
3612 + .feed-page .table-row .col-Name .cell-value:last-child {
3613 + font-size: var(--text-fine);
3614 + opacity: 0.5;
3615 + }
3599 3616
3600 3617 /* The feed's badges shout a little: caps and tracking. The sunken fill that
3601 3618 used to come with them went with the filled-badge form in B2. */
@@ -3604,49 +3621,19 @@
3604 3621 letter-spacing: 0.03em;
3605 3622 }
3606 3623
3607 - .feed-page .empty-state {
3608 - text-align: center;
3609 - padding: var(--gap-page) var(--gap-pane);
3610 - background: var(--surface-overlay);
3611 - border: 1px solid var(--border);
3612 - }
3613 -
3614 - .feed-page .empty-state h2 { font-size: var(--text-subhead); margin-bottom: var(--gap-peer); }
3615 - .feed-page .empty-state p { opacity: 0.7; margin-bottom: var(--gap-pane); }
3616 -
3617 - .feed-page .pagination {
3618 - display: flex;
3619 - gap: var(--gap-bound);
3620 - justify-content: center;
3621 - margin-top: var(--gap-section);
3622 - }
3623 -
3624 - .feed-page .pagination a,
3625 - .feed-page .pagination span {
3626 - padding: var(--gap-peer) var(--gap-section);
3627 - font-size: var(--text-note);
3628 - text-decoration: none;
3629 - color: var(--content);
3630 - border: 1px solid var(--border);
3631 - }
3632 -
3633 - .feed-page .pagination span.current {
3634 - background: var(--primary-dark);
3635 - color: var(--primary-light);
3636 - border-color: var(--primary-dark);
3637 - }
3638 -
3639 - .feed-page .pagination a:hover { background: var(--surface-sunken); }
3624 + /* The empty state and the numbered strip carry no page-specific rules any
3625 + more: `Node::empty` and `Rest` emit their own classes, styled once for every
3626 + described screen rather than again per page. */
3640 3627
3641 3628 @media (max-width: 599px) {
3642 - .feed-page .table-header,
3629 + .feed-page main { padding: var(--gap-section); }
3630 + .feed-page .table-head,
3643 3631 .feed-page .table-row { grid-template-columns: 1fr 70px; }
3644 - .feed-page .table-header span:nth-child(1),
3645 - .feed-page .table-row .badge:first-child,
3646 - .feed-page .table-header span:nth-child(3),
3647 - .feed-page .table-header span:nth-child(4),
3648 - .feed-page .table-row span:nth-child(3),
3649 - .feed-page .table-row span:nth-child(3),
3632 + /* Name and Date survive the narrow viewport, which is what the five-column
3633 + header dropped to before the conversion. */
3634 + .feed-page .col-Type,
3635 + .feed-page .col-Tag,
3636 + .feed-page .col-Price { display: none; }
3650 3637 }
3651 3638
3652 3639 /* (Stripe Connect disclaimer page rules defined in the canonical
@@ -57,14 +57,19 @@
57 57 //! strip would let this go, and that is a `quasi-webview` question rather than
58 58 //! this server's.
59 59 //!
60 - //! # What this does not convert
60 + //! # The page owns its document
61 61 //!
62 - //! `pages/feed.html` keeps its Askama shell: `base.html`, `site_header.html`,
63 - //! the page title, and the `body_attrs` that sets the Wide measure. Nothing
64 - //! described on this server has ever owned a public document, and the site
65 - //! header is not describable today. The page's *body* is this module and the
66 - //! template is the shell around it. Splitting the shell off is filed rather
67 - //! than widened into here, on `830b1661`'s own instruction.
62 + //! `b5cbb646`. `pages/feed.html` is gone and so is `landing`'s Askama route:
63 + //! [`screen`] describes the whole page and [`renderer`] draws the document it
64 + //! sits in. The site header is not described and is not going to be: it is one
65 + //! element in the assembly layer ([`crate::shell::site_header`]), handed to the
66 + //! shell as `body_first` here and included by 64 templates there. The ruling
67 + //! and its evidence are in wiki `mnw-server-conversion-plan`, dated 2026-08-31.
68 + //!
69 + //! One behaviour changed with the route. `ValidatedQuery<FeedQuery>` refused
70 + //! `?page=abc` with a 400; [`screen`] parses what it can and falls back to page
71 + //! one, which is what a reader who mangled a URL wants and what every other
72 + //! described screen already does with its carried values.
68 73 //!
69 74 //! The six other `page=` sites are Newer/Older or Previous/Next only and want
70 75 //! no `jumps`: `pages/git/{commits,explore,issues,file_log,notes}.html` and
@@ -78,7 +83,9 @@
78 83
79 84 use makeover_layout as layout;
80 85 use quasi_router::screen::{Act, Cell, Cells, Column, Rest, Tag};
81 - use quasi_router::{Action, Node, RegionKind, Slot};
86 + use quasi_router::{
87 + Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot,
88 + };
82 89 use quasi_webview::Webview;
83 90
84 91 use crate::constants;
@@ -91,21 +98,31 @@
91 98 pub const LIBRARY_REGION: &str = "library-feed";
92 99
93 100 /// The region the public page's body sits in.
94 - const PAGE_REGION: &str = "feed";
101 + ///
102 + /// Public so the pressed-screen table and the skip link name it rather than
103 + /// transcribe it.
104 + pub const PAGE_REGION: &str = "feed";
105 +
106 + /// This screen's name, the marker a tab strip reads.
107 + pub const SCREEN: &str = "feed";
108 +
109 + /// The address this screen answers, and the one the Askama route gave up.
110 + pub const PATH: &str = "/feed";
111 +
112 + /// How wide the page runs. `pages/feed.html` said this as
113 + /// `class="{{ shell::measure(Wide) }}"`; it is a described property now.
114 + const MEASURE: layout::Measure = layout::Measure::Wide;
95 115
96 116 /// The address the library panel is read from.
97 117 const LIBRARY_ROUTE: &str = "/library/tabs/feed";
98 118
99 - /// The address the public page is read from.
100 - const PAGE_ROUTE: &str = "/feed";
101 -
102 119 /// One page of a feed, as both handlers have already computed it.
103 120 ///
104 121 /// Every field here is what the two templates were handed, under the names they
105 - /// were handed them under. The arithmetic stays in the handlers: both clamp
106 - /// `?page=`, widen to `i64` before multiplying, and saturate the "showing"
107 - /// labels, and that is three overflow fixes this conversion has no business
108 - /// re-deriving.
122 + /// were handed them under. What owns the values is [`Loaded`], and the
123 + /// arithmetic that produces them is [`load`]: both handlers used to carry a
124 + /// verbatim copy of it, which is one clamp, one `i64` widening and two
125 + /// saturating labels duplicated three lines apart.
109 126 pub struct Page<'a> {
110 127 /// The rows, in the order they read.
111 128 pub items: &'a [DiscoverItem],
@@ -135,16 +152,139 @@
135 152 Webview::new().fragment(&Node::Region(slot))
136 153 }
137 154
138 - /// The public page's body, for `pages/feed.html` to drop into its shell.
139 - #[must_use]
140 - pub fn page_body(page: &Page<'_>) -> String {
141 - use quasi_axum::Serves as _;
155 + /// One page of a reader's feed, loaded. Owns what [`Page`] borrows.
156 + pub struct Loaded {
157 + items: Vec<DiscoverItem>,
158 + total_items: u32,
159 + current_page: u32,
160 + total_pages: u32,
161 + pagination_range: Vec<u32>,
162 + showing_start: u32,
163 + showing_end: u32,
164 + }
142 165
143 - let mut slot = Slot::new(PAGE_REGION, RegionKind::Pane);
144 - for node in body(page, Surface::Page) {
145 - slot = slot.with(node);
166 + impl Loaded {
167 + /// What was loaded, as the description reads it.
168 + #[must_use]
169 + pub fn page(&self) -> Page<'_> {
170 + Page {
171 + items: &self.items,
172 + total_items: self.total_items,
173 + current_page: self.current_page,
174 + total_pages: self.total_pages,
175 + pagination_range: &self.pagination_range,
176 + showing_start: self.showing_start,
177 + showing_end: self.showing_end,
178 + }
146 179 }
147 - Webview::new().fragment(&Node::Region(slot))
180 + }
181 +
182 + /// Read one page of a reader's feed.
183 + ///
184 + /// The clamp, the `i64` widening before the multiply and the saturating
185 + /// "showing" labels are three overflow fixes, moved here verbatim rather than
186 + /// re-derived. They lived in `routes::pages::public::feed` and in
187 + /// `landing::library_tab_feed` as byte-identical copies; there is one now, so
188 + /// the library panel and the page cannot page differently.
189 + ///
190 + /// Async, so the panel awaits it and the described screen reaches it through
191 + /// [`super::Viewer::block_on`].
192 + pub async fn load(
193 + db: &sqlx::PgPool,
194 + user: crate::db::UserId,
195 + page: Option<u32>,
196 + ) -> crate::error::Result<Loaded> {
197 + // Clamp the upper bound too (matches admin/git pagination); the i64
198 + // widening below already prevents the overflow panic, but an unbounded page
199 + // is a pointless huge offset (Run #2 UX MINOR).
200 + let page = page.unwrap_or(1).clamp(1, 1_000_000_000);
201 + // Widen to i64 BEFORE multiplying, `(page - 1) * FEED_PAGE_SIZE` in u32
202 + // overflows for a large `?page=` (garbage offset in release, panic in debug).
203 + let offset = (page as i64 - 1) * constants::FEED_PAGE_SIZE as i64;
204 +
205 + let total_items = crate::db::follows::count_followed_feed_items(db, user).await? as u32;
206 + let total_pages =
207 + (total_items + constants::FEED_PAGE_SIZE - 1) / constants::FEED_PAGE_SIZE.max(1);
208 +
209 + let db_items = crate::db::follows::get_followed_feed_items(
210 + db,
211 + user,
212 + constants::FEED_PAGE_SIZE as i64,
213 + offset,
214 + )
215 + .await?;
216 + let items: Vec<DiscoverItem> = db_items.into_iter().map(DiscoverItem::from).collect();
217 +
218 + // Compute the "showing X-Y" labels in i64 (saturating) to avoid the u32
219 + // overflow `offset as u32 + FEED_PAGE_SIZE` would hit for a large `?page=`.
220 + let showing_start = if total_items == 0 {
221 + 0
222 + } else {
223 + offset.saturating_add(1).clamp(0, u32::MAX as i64) as u32
224 + };
225 + let showing_end = offset
226 + .saturating_add(constants::FEED_PAGE_SIZE as i64)
227 + .min(total_items as i64)
228 + .clamp(0, u32::MAX as i64) as u32;
229 +
230 + Ok(Loaded {
231 + items,
232 + total_items,
233 + current_page: page,
234 + total_pages,
235 + pagination_range: crate::routes::pages::public::pagination::build_pagination_range(
236 + page,
237 + total_pages,
238 + ),
239 + showing_start,
240 + showing_end,
241 + })
242 + }
243 +
244 + /// The public feed page, described.
245 + pub fn screen(viewer: &super::Viewer, request: Request) -> Result<Response, RouteError> {
246 + // Moved out of the request rather than borrowed: the signature is quasi's,
247 + // so the request arrives owned and nothing else here reads it.
248 + let carried = request.carried;
249 + let asked = carried
250 + .get("page")
251 + .and_then(|value| value.trim().parse::<u32>().ok());
252 + let loaded = viewer
253 + .block_on(load(&viewer.app.db, viewer.user.id, asked))
254 + .map_err(|_| RouteError::internal("your feed could not be read"))?;
255 + Ok(page_screen(&loaded.page()).into())
256 + }
257 +
258 + /// The whole document: the title, the measure, the body.
259 + fn page_screen(page: &Page<'_>) -> Described {
260 + let mut pane = Slot::new(PAGE_REGION, RegionKind::Pane).with(Node::page("Your Feed"));
261 + for node in body(page, Surface::Page) {
262 + pane = pane.with(node);
263 + }
264 + Described::single("Feed - Makenotwork")
265 + .measured(MEASURE)
266 + // `padded-page feed-page`, which is what `pages/feed.html:4` rendered.
267 + // Composed rather than written out: `Document::classed` replaces, so a
268 + // screen naming only its own token would drop its measure (`2790e5c4`).
269 + .documented(Document::default().classed(crate::shell::body_class(MEASURE, &["feed-page"])))
270 + .summarised("Items from the users, projects and tags you follow.")
271 + .with(pane)
272 + }
273 +
274 + /// The document this screen is drawn in.
275 + ///
276 + /// The head, the tail and the token meta come off
277 + /// [`super::Viewer::document_shell`]. What is added here is what every page on
278 + /// this site opens with: the skip link, and the site header
279 + /// ([`crate::shell::site_header`]) which is markup in the assembly layer rather
280 + /// than anything a screen describes.
281 + #[must_use]
282 + pub fn renderer(viewer: &super::Viewer) -> Webview {
283 + Webview::new().with_shell(viewer.document_shell().with_body_first(format!(
284 + "{}{}",
285 + crate::shell::skip_link(PAGE_REGION),
286 + crate::shell::site_header(Some(&viewer.user), Some(&viewer.csrf)),
287 + )))
148 288 }
149 289
150 290 /// Which of the two surfaces is being drawn.
@@ -166,7 +306,7 @@
166 306 Self::Panel => Action::get(format!("{LIBRARY_ROUTE}?page={page}"))
167 307 .awaiting()
168 308 .replacing(LIBRARY_REGION),
169 - Self::Page => Action::get(format!("{PAGE_ROUTE}?page={page}")).navigating(),
309 + Self::Page => Action::get(format!("{PATH}?page={page}")).navigating(),
170 310 }
171 311 }
172 312 }
@@ -308,9 +448,14 @@
308 448 })
309 449 }
310 450
451 + /// The whole document the page answers with, minus the viewer-dependent
452 + /// half of the shell (the header and the token meta, which need a session).
453 + /// Everything the screen itself describes is here.
311 454 fn public(current: u32, total_pages: u32) -> String {
455 + use quasi_axum::Serves as _;
456 +
312 457 let (items, range) = page(total_pages);
313 - page_body(&Page {
458 + Webview::new().screen(&page_screen(&Page {
314 459 items: &items,
315 460 total_items: 40,
316 461 current_page: current,
@@ -318,7 +463,7 @@
318 463 pagination_range: &range,
319 464 showing_start: 1,
320 465 showing_end: 20,
321 - })
466 + }))
322 467 }
323 468
324 469 /// Both surfaces draw the same table out of the same description. The
@@ -425,7 +570,7 @@
425 570 /// out.
426 571 #[test]
427 572 fn an_empty_feed_offers_discover() {
428 - let empty = page_body(&Page {
573 + let empty = public_of(&Page {
429 574 items: &[],
430 575 total_items: 0,
431 576 current_page: 1,
@@ -441,6 +586,51 @@
441 586 assert!(!empty.contains("<table"), "{empty}");
442 587 }
443 588
589 + /// One page, rendered as the document the reader gets.
590 + fn public_of(page: &Page<'_>) -> String {
591 + use quasi_axum::Serves as _;
592 +
593 + Webview::new().screen(&page_screen(page))
594 + }
595 +
596 + /// The parity `2790e5c4` asks for, on the screen it was written against:
597 + /// the measure class and the page's own identity token, in that order, on
598 + /// `<body>`.
599 + #[test]
600 + fn the_document_carries_the_class_the_template_carried() {
601 + assert_eq!(
602 + page_screen(&Page {
603 + items: &[],
604 + total_items: 0,
605 + current_page: 1,
606 + total_pages: 0,
607 + pagination_range: &[],
608 + showing_start: 0,
609 + showing_end: 0,
610 + })
611 + .document
612 + .body_class
613 + .as_deref(),
614 + Some("padded-page feed-page")
615 + );
616 + assert!(public(1, 2).contains("class=\"padded-page feed-page\""));
617 + }
618 +
619 + /// The title `pages/feed.html` drew as `<h1 class="page-title">`, drawn by
620 + /// the description instead. The class moved with it, which is what
621 + /// `style.css` had to be retargeted for.
622 + #[test]
623 + fn the_page_names_itself() {
624 + let html = public(1, 2);
625 +
626 + assert!(
627 + html.contains("<h1 class=\"heading\">Your Feed</h1>"),
628 + "{html}"
629 + );
630 + assert!(html.contains("<title>Feed - Makenotwork</title>"), "{html}");
631 + assert!(!html.contains("page-title"), "{html}");
632 + }
633 +
444 634 /// No `?page=` grammar survives in a template: both addresses are built
445 635 /// here, from one function, on the surface that reads them.
446 636 #[test]
@@ -172,6 +172,32 @@
172 172 .sending("X-CSRF-Token", &self.csrf)
173 173 }
174 174
175 + /// The shell a described screen that owns its whole DOCUMENT is drawn in.
176 + ///
177 + /// [`shell`](Self::shell) is right for a fragment landing inside an Askama
178 + /// page, which already has the head, the tail and the token meta. A
179 + /// document owes all three itself: [`crate::shell::described`] is the same
180 + /// builder `base.html` renders through, [`crate::shell::body_last`] is the
181 + /// toast container and the classic shims, and the token meta is what the
182 + /// pre-module scripts read.
183 + ///
184 + /// The meta is not redundant with `Shell::sending`. That covers htmx, and
185 + /// `frontend/src/core/net.ts`, `frontend/src/core/htmx-glue.ts`,
186 + /// `static/passkey.js` and `static/project-sections.js` all read
187 + /// `meta[name=csrf-token]` instead. `/pricing` needed none of it because it
188 + /// holds no session.
189 + #[must_use]
190 + pub fn document_shell(&self) -> quasi_webview::Shell {
191 + crate::shell::described()
192 + .sending("X-CSRF-Token", &self.csrf)
193 + .with_body_last(crate::shell::body_last())
194 + .with_chrome(crate::quasi::shortcuts::chrome())
195 + .with_head(format!(
196 + "<meta name=\"csrf-token\" content=\"{}\">",
197 + crate::helpers::escape_html(&self.csrf)
198 + ))
199 + }
200 +
175 201 /// Everything the handler drew, for the renderer to mount.
176 202 pub fn drawn(&self) -> std::collections::HashMap<String, String> {
177 203 self.fills.lock().map(|f| f.clone()).unwrap_or_default()
@@ -264,6 +290,20 @@
264 290 forum_memberships::SETTINGS_PATH,
265 291 ];
266 292
293 + /// Every described screen that owns its whole document, with the address it
294 + /// answers.
295 + ///
296 + /// Its own list rather than an entry in [`PATHS`], for the same reason
297 + /// [`public_mounts`] keeps its own: what a nest answers with decides what a
298 + /// test can assert about it. A panel screen answers `Response::Fragment` and
299 + /// names the region it changed, which `tests/workflows/described_screens.rs`
300 + /// checks by reading `HX-Retarget` off every entry in [`PATHS`]. A document
301 + /// answers `Outcome::Screen`, which sets no such header and is not a defect.
302 + ///
303 + /// The CSRF probe reads [`PATHS`] as its skip list, and a document screen
304 + /// registers no mutating route, so it has nothing to skip here either.
305 + pub const DOCUMENT_PATHS: &[&str] = &[feeds::PATH];
306 +
267 307 /// Every screen's switch name, in the same order as [`PATHS`].
268 308 ///
269 309 /// Test-only: the switches themselves are read from each screen's own `SCREEN`
@@ -352,6 +392,75 @@
352 392 ]
353 393 }
354 394
395 + /// Every described screen that owns its whole document, mounted.
396 + ///
397 + /// See [`DOCUMENT_PATHS`] for why these are not in [`mounts`].
398 + pub fn document_mounts(app: &AppState) -> Vec<(&'static str, axum::Router)> {
399 + vec![(
400 + feeds::PATH,
401 + document_mount(app, feeds::PATH, feeds::screen, feeds::renderer),
402 + )]
403 + }
404 +
405 + /// A described screen a reader NAVIGATES to, rather than a panel htmx fetches.
406 + ///
407 + /// The difference is what a signed-out reader gets. [`viewer_factory`] refuses
408 + /// with `denied` and quasi-axum answers that as a bare 403 with no body: right
409 + /// for a panel fetched by a page that already checked, wrong for an address a
410 + /// person can type. `tests/workflows/pages.rs::unauthorized_page_offers_login_and_signup`
411 + /// is the shipped rule, so the gate runs [`crate::auth::authenticate`] in front
412 + /// and answers whatever that refuses with, which renders the branded 401 with
413 + /// its way back in. It is the same call the `AuthUser` extractor makes, so the
414 + /// two paths cannot disagree about who is signed in.
415 + ///
416 + /// It costs one extra session read on this nest: the gate resolves the session
417 + /// and the factory resolves it again. Stated rather than optimised, because the
418 + /// alternative is a viewer whose `user` is optional, which is the signed-out
419 + /// question the feed conversion deliberately did not answer.
420 + ///
421 + /// # The address is registered whole, not as `/`
422 + ///
423 + /// [`mount`]'s nests are mounted with `nest_service`, which strips the prefix
424 + /// before the adapter sees the request. A document screen is mounted with
425 + /// `CsrfRouter::route_service` instead (see there for why), which strips
426 + /// nothing, so the router inside answers the address the reader typed.
427 + fn document_mount(
428 + app: &AppState,
429 + path: &'static str,
430 + screen: Screen,
431 + renderer: fn(&Viewer) -> quasi_webview::Webview,
432 + ) -> axum::Router {
433 + let router = quasi_router::Router::<Viewer>::new().get(path, screen);
434 + quasi_axum::Adapter::per_viewer(router, viewer_factory(app.clone()), move |viewer, _, _| {
435 + renderer(viewer)
436 + })
437 + .into_router()
438 + .layer(axum::middleware::from_fn_with_state(app.clone(), signed_in))
439 + }
440 +
441 + /// The gate in front of every document nest: a reader or a branded refusal.
442 + async fn signed_in(
443 + axum::extract::State(app): axum::extract::State<AppState>,
444 + request: axum::extract::Request,
445 + next: axum::middleware::Next,
446 + ) -> axum::response::Response {
447 + use axum::response::IntoResponse as _;
448 +
449 + let Some(session) = request
450 + .extensions()
451 + .get::<tower_sessions::Session>()
452 + .cloned()
453 + else {
454 + // The session layer runs in front of this. Its absence is wiring.
455 + return crate::error::AppError::Internal(anyhow::anyhow!("no session layer"))
456 + .into_response();
457 + };
458 + match crate::auth::authenticate(&session, &app).await {
459 + Ok(_) => next.run(request).await,
460 + Err(refusal) => refusal.into_response(),
461 + }
462 + }
463 +
355 464 /// Every described screen a reader with no session can reach, mounted.
356 465 ///
357 466 /// Separate from [`mounts`] because of the factory, not because of the address:
@@ -541,11 +650,29 @@
541 650 );
542 651 }
543 652
653 + #[test]
654 + fn every_document_screen_is_listed_in_document_paths() {
655 + let source = include_str!("mod.rs");
656 + let mounted = source
657 + .split_once("pub fn document_mounts(")
658 + .expect("document_mounts exists")
659 + .1
660 + .split_once("\n}")
661 + .expect("document_mounts ends")
662 + .0;
663 + assert_eq!(
664 + mounted.matches("document_mount(").count(),
665 + DOCUMENT_PATHS.len(),
666 + "document_mounts and DOCUMENT_PATHS describe the same screens"
667 + );
668 + }
669 +
544 670 #[test]
545 671 fn a_path_is_claimed_by_exactly_one_screen() {
546 672 // Two screens on one address is an axum panic at startup, and the two
547 673 // forum-memberships screens are the near miss: one module, two paths.
548 674 let mut seen = PATHS.to_vec();
675 + seen.extend_from_slice(DOCUMENT_PATHS);
549 676 seen.sort_unstable();
550 677 let before = seen.len();
551 678 seen.dedup();