Skip to main content

max / makenotwork

Describe the team page, and answer the signed-out question for public documents /team is the first described document served to a reader who may or may not hold a session. Viewer::user becomes an Option, Audience decides whether a missing session ends the request, and public_document_mount is the mount that renders to a visitor rather than refusing one. Every remaining pages/ template needs exactly this: they are public, they carry the site header, and the header reads differently once somebody signs in. Gated screens read Viewer::reader() and keep their guarantee; it returns a refusal rather than panicking, because the guarantee lives in the mount. The wordmark ten templates wrote out by hand moves to crate::shell::wordmark, beside site_header and body_last, with a test that keeps it there. /team resolved its user through MaybeUserUnverified before, which never validates the tracking row; the factory calls auth::authenticate, so a revoked session now renders as signed out. Deletes templates/pages/team.html, TeamTemplate, landing::team_page and 12 of the 13 .team-* CSS rules; the measure moved onto .team-page main.
Author: Max Johnson <me@maxj.phd> · 2026-09-01 00:45 UTC
Signed with PGP, not checked
Commit: 4239540d67934f6e45527c895044d142abe8f00b
Parent: 1783786
29 files changed, +319 insertions, -180 deletions
@@ -609,6 +609,14 @@
609 609 .fold(csrf_routes, |routes, (path, described)| {
610 610 routes.route_service(path, described)
611 611 });
612 + // The public documents: an exact address like the gated ones, and no
613 + // session gate in front, so a visitor is rendered to rather than refused.
614 + // See `quasi::public_document_mounts`.
615 + let csrf_routes = quasi::public_document_mounts(state)
616 + .into_iter()
617 + .fold(csrf_routes, |routes, (path, described)| {
618 + routes.route_service(path, described)
619 + });
612 620 // The public half of the description layer. Same envelope, different
613 621 // factory: these screens resolve no session. See `quasi::public_mounts`.
614 622 let csrf_routes = quasi::public_mounts(state)
@@ -139,6 +139,26 @@
139 139 format!("<a href=\"#{target}\" class=\"skip-to-main\">Skip to main content</a>")
140 140 }
141 141
142 + /// The wordmark the sessionless pages open with.
143 + ///
144 + /// Ten templates wrote this same `<h1>` out by hand -- `login`, `two_factor`,
145 + /// `forgot_password`, `reset_password`, `confirm_delete`, `acknowledge`,
146 + /// `oauth_authorize`, `sandbox`, `purchase` and `index` -- which is ten places
147 + /// for the dot to move. Here for the same reason [`site_header`] is: it is one
148 + /// element of the assembly layer, called from the templates and handed to
149 + /// [`quasi_webview::Shell::with_body_first`] by a screen that owns its own
150 + /// document.
151 + ///
152 + /// Not [`site_header`]'s logo and not a substitute for it. The pages that carry
153 + /// this one carry no header at all: they are the screens a reader reaches
154 + /// without a session, where the nav would offer a Library and a Dashboard the
155 + /// reader cannot open. The dot is `aria-hidden`, matching the logo, so a screen
156 + /// reader hears the name rather than the punctuation.
157 + #[must_use]
158 + pub fn wordmark() -> &'static str {
159 + "<h1 class=\"brand-h1\">Makenot<span class=\"dot\" aria-hidden=\"true\">.</span>work</h1>"
160 + }
161 +
142 162 /// What every page on this site ends with: the toast container and the classic
143 163 /// script shims the `data-action` dispatcher resolves through.
144 164 ///
@@ -443,6 +463,38 @@
443 463 }
444 464 }
445 465
466 + /// One spelling of the wordmark, enforced the way the layout class is.
467 + ///
468 + /// Ten templates wrote the same `<h1>` before [`wordmark`] existed, so the
469 + /// obvious failure is an eleventh pasted from one of them. The three
470 + /// `brand-h1` headings that remain say something else -- "Account Deleted",
471 + /// "Email preferences", and the email-result title -- and are not the
472 + /// wordmark, so the check is for the wordmark's own text.
473 + #[test]
474 + fn no_template_still_writes_the_wordmark_by_hand() {
475 + let mut offenders = Vec::new();
476 + for entry in walk("templates", "html") {
477 + let source = std::fs::read_to_string(&entry).expect("a template reads");
478 + for (at, line) in source.lines().enumerate() {
479 + if line.contains("brand-h1") && line.contains("Makenot") {
480 + offenders.push(format!("{}:{}", entry.display(), at + 1));
481 + }
482 + }
483 + }
484 + assert!(
485 + offenders.is_empty(),
486 + "call crate::shell::wordmark instead: {offenders:?}"
487 + );
488 + }
489 +
490 + #[test]
491 + fn the_wordmark_hides_the_dot_from_a_screen_reader() {
492 + // Matching the header's logo, which has always done this: a reader
493 + // hears the name rather than the punctuation inside it.
494 + assert!(wordmark().contains(r#"<span class="dot" aria-hidden="true">"#));
495 + assert!(site_header(None, None).contains(r#"class="dot" aria-hidden="true""#));
496 + }
497 +
446 498 #[test]
447 499 fn no_template_still_writes_a_layout_class_by_hand() {
448 500 // The done-condition, checked rather than remembered: the layout axis
@@ -11118,47 +11118,14 @@
11118 11118
11119 11119 /* ===== Team page ===== */
11120 11120
11121 - .team-page { max-width: 56rem; margin: 0 auto; }
11122 - .team-intro {
11123 - font-size: var(--text-body);
11124 - line-height: 1.55;
11125 - margin: 0 0 var(--gap-page);
11126 - opacity: 0.85;
11127 - }
11128 - .team-section { margin-top: var(--gap-page); }
11129 - .team-section + .team-section { margin-top: var(--gap-page); }
11130 - .team-card-experience {
11131 - margin: var(--gap-section) 0 var(--gap-peer);
11132 - line-height: 1.5;
11133 - }
11134 - .team-card-links {
11135 - margin: var(--gap-peer) 0;
11136 - font-size: var(--text-body);
11137 - }
11138 - .team-card-link { font-weight: 500; }
11139 - .team-card-bio {
11140 - margin-top: var(--gap-section);
11141 - border-top: 1px solid var(--border);
11142 - padding-top: var(--gap-section);
11143 - }
11144 - .team-card-bio > summary {
11145 - cursor: pointer;
11146 - font-weight: 500;
11147 - font-size: var(--text-body);
11148 - list-style: none;
11149 - }
11150 - .team-card-bio > summary::-webkit-details-marker { display: none; }
11151 - .team-card-bio > summary::before {
11152 - content: "+ ";
11153 - display: inline-block;
11154 - width: 1.2em;
11155 - opacity: 0.6;
11156 - }
11157 - .team-card-bio[open] > summary::before { content: "− "; }
11158 - .team-card-bio > p {
11159 - margin-top: var(--gap-section);
11160 - line-height: 1.6;
11161 - }
11121 + /* The whole block below this line went with `templates/pages/team.html` when
11122 + `/team` moved to the description layer. What is left is the measure, which a
11123 + described document carries on <main> rather than on a container div: the
11124 + screen has no `.container`, exactly as `.feed-page main` found. The intro
11125 + paragraph, the section spacing, the card's parts and the bio disclosure are
11126 + all drawn by the design system now, off `Node::text`, `Node::section`,
11127 + `Node::list` and a selective region. */
11128 + .team-page main { max-width: 56rem; margin: 0 auto; }
11162 11129
11163 11130 /* ── SyncKit billing panel ──
11164 11131 Per-app accordion shown inside the SyncKit dashboard tab (both user and
@@ -70,20 +70,18 @@
70 70 let contacts = viewer
71 71 .block_on(db::transactions::get_seller_contacts(
72 72 &viewer.app.db,
73 - viewer.user.id,
73 + viewer.reader()?.id,
74 74 ))
75 75 .map_err(|_| RouteError::internal("your contacts could not be read"))?;
76 76
77 + let currency = viewer.reader()?.settlement_currency;
77 78 let buyers: Vec<BuyerView> = contacts
78 79 .into_iter()
79 80 .map(|contact| BuyerView {
80 81 username: contact.username,
81 82 email: contact.email,
82 83 purchases: contact.total_purchases.to_string(),
83 - spent: crate::formatting::format_revenue(
84 - contact.total_spent_cents,
85 - viewer.user.settlement_currency,
86 - ),
84 + spent: crate::formatting::format_revenue(contact.total_spent_cents, currency),
87 85 last_purchase: contact.last_purchase_at.format("%b %-d, %Y").to_string(),
88 86 })
89 87 .collect();
@@ -164,7 +164,7 @@
164 164
165 165 pub fn screen(viewer: &super::Viewer, _request: Request) -> Result<Response, RouteError> {
166 166 let page = viewer
167 - .block_on(load(&viewer.app.db, viewer.user.id))
167 + .block_on(load(&viewer.app.db, viewer.reader()?.id))
168 168 .map_err(|_| RouteError::internal("your exports could not be read"))?;
169 169 Ok(page_screen(&page).into())
170 170 }
@@ -253,7 +253,7 @@
253 253 Webview::new().with_shell(viewer.document_shell().with_body_first(format!(
254 254 "{}{}",
255 255 crate::shell::skip_link(PAGE_REGION),
256 - crate::shell::site_header(Some(&viewer.user), Some(&viewer.csrf)),
256 + crate::shell::site_header(viewer.user.as_ref(), Some(&viewer.csrf)),
257 257 )))
258 258 }
259 259
@@ -250,7 +250,7 @@
250 250 .get("page")
251 251 .and_then(|value| value.trim().parse::<u32>().ok());
252 252 let loaded = viewer
253 - .block_on(load(&viewer.app.db, viewer.user.id, asked))
253 + .block_on(load(&viewer.app.db, viewer.reader()?.id, asked))
254 254 .map_err(|_| RouteError::internal("your feed could not be read"))?;
255 255 Ok(page_screen(&loaded.page()).into())
256 256 }
@@ -283,7 +283,7 @@
283 283 Webview::new().with_shell(viewer.document_shell().with_body_first(format!(
284 284 "{}{}",
285 285 crate::shell::skip_link(PAGE_REGION),
286 - crate::shell::site_header(Some(&viewer.user), Some(&viewer.csrf)),
286 + crate::shell::site_header(viewer.user.as_ref(), Some(&viewer.csrf)),
287 287 )))
288 288 }
289 289
@@ -82,7 +82,7 @@
82 82 let Some(base) = configured_base(viewer) else {
83 83 return Ok(Response::fragment(LIBRARY_REGION, library_pane(&[], "")));
84 84 };
85 - let memberships = fetch(viewer, &base);
85 + let memberships = fetch(viewer, viewer.reader()?, &base);
86 86 Ok(Response::fragment(
87 87 LIBRARY_REGION,
88 88 library_pane(&memberships, &base),
@@ -98,7 +98,7 @@
98 98 pub fn settings_screen(viewer: &Viewer, _request: Request) -> Result<Response, RouteError> {
99 99 let base = configured_base(viewer)
100 100 .ok_or_else(|| RouteError::not_found("forums are not configured"))?;
101 - let memberships = fetch(viewer, &base);
101 + let memberships = fetch(viewer, viewer.reader()?, &base);
102 102 Ok(Response::fragment(
103 103 SETTINGS_REGION,
104 104 settings_pane(&memberships, &base),
@@ -116,9 +116,9 @@
116 116 /// cannot reach an optional integration should say the reader has no
117 117 /// memberships, not replace the settings pane with a stack trace, and both
118 118 /// Askama handlers already made that choice.
119 - fn fetch(viewer: &Viewer, base: &str) -> Vec<MembershipView> {
120 - let url = format!("{base}/api/user/{}/summary", viewer.user.id);
121 - let username = viewer.user.username.as_ref();
119 + fn fetch(viewer: &Viewer, reader: &crate::auth::SessionUser, base: &str) -> Vec<MembershipView> {
120 + let url = format!("{base}/api/user/{}/summary", reader.id);
121 + let username = reader.username.as_ref();
122 122
123 123 // The blocking hop, and the long one. See the module header.
124 124 let body = viewer.block_on(async {
@@ -207,7 +207,7 @@
207 207 .block_on(crate::payments::refund::refund(
208 208 &viewer.app.db,
209 209 viewer.app.payment_caps.refundable.as_ref(),
210 - &viewer.user,
210 + viewer.reader()?,
211 211 item,
212 212 transaction,
213 213 ))
@@ -241,7 +241,7 @@
241 241 .block_on(db::transactions::get_sales_by_item(
242 242 &viewer.app.db,
243 243 item,
244 - viewer.user.id,
244 + viewer.reader()?.id,
245 245 ))
246 246 .map_err(|_| RouteError::internal("the sales could not be read"))?;
247 247
@@ -69,7 +69,7 @@
69 69
70 70 /// The tab.
71 71 pub fn screen(viewer: &Viewer, _request: Request) -> Result<Response, RouteError> {
72 - let user_id = viewer.user.id;
72 + let user_id = viewer.reader()?.id;
73 73
74 74 let shared = viewer
75 75 .block_on(db::transactions::get_shared_creators(
@@ -97,16 +97,14 @@
97 97 Vec::new()
98 98 };
99 99
100 + let currency = viewer.reader()?.settlement_currency;
100 101 let buyers: Vec<BuyerView> = buyers
101 102 .into_iter()
102 103 .map(|c| BuyerView {
103 104 username: c.username,
104 105 email: c.email,
105 106 purchases: c.total_purchases.to_string(),
106 - spent: crate::formatting::format_revenue(
107 - c.total_spent_cents,
108 - viewer.user.settlement_currency,
109 - ),
107 + spent: crate::formatting::format_revenue(c.total_spent_cents, currency),
110 108 last_purchase: c.last_purchase_at.format("%b %d, %Y").to_string(),
111 109 })
112 110 .collect();
@@ -144,7 +142,7 @@
144 142 viewer
145 143 .block_on(db::transactions::revoke_contact_sharing(
146 144 &viewer.app.db,
147 - viewer.user.id,
145 + viewer.reader()?.id,
148 146 seller,
149 147 ))
150 148 .map_err(|_| RouteError::internal("that sharing could not be revoked"))?;
@@ -66,6 +66,7 @@
66 66 pub mod settings_tabs;
67 67 pub mod shortcuts;
68 68 pub mod ssh_keys;
69 + pub mod team;
69 70 pub mod upload_field;
70 71 pub mod user_analytics;
71 72 pub mod user_projects;
@@ -83,9 +84,24 @@
83 84 pub struct Viewer {
84 85 /// The long-lived application state. Cloning it clones handles, not data.
85 86 pub app: AppState,
86 - /// Who is asking. Resolved and revocation-checked by
87 - /// [`crate::auth::authenticate`], the same path the extractor takes.
88 - pub user: SessionUser,
87 + /// Who is asking, when anybody is.
88 + ///
89 + /// Resolved and revocation-checked by [`crate::auth::authenticate`], the
90 + /// same path the extractor takes.
91 + ///
92 + /// `None` only on a mount built with [`Audience::Anyone`], which is the
93 + /// public documents: a screen a reader can reach with no session, whose
94 + /// header and controls differ by whether one is held. Every other mount
95 + /// refuses before the handler runs, so a screen behind [`mount`],
96 + /// [`writes_only`] or [`document_mount`] can read it through
97 + /// [`reader`](Self::reader) and never see the refusal that method can
98 + /// return.
99 + ///
100 + /// The field is `pub` and the accessor exists beside it because both
101 + /// readings are legitimate: a gated screen wants the user and treats
102 + /// absence as impossible, and a public screen wants the option and treats
103 + /// absence as an ordinary state.
104 + pub user: Option<SessionUser>,
89 105 /// The runtime the request arrived on, so a sync handler can reach the
90 106 /// async database. Captured in the factory rather than read inside the
91 107 /// handler: `Handle::current` works on a blocking thread today, and
@@ -139,6 +155,22 @@
139 155 self.runtime.block_on(future)
140 156 }
141 157
158 + /// Who is asking, on a mount that guarantees somebody is.
159 + ///
160 + /// The gated mounts resolve the session before the handler runs and refuse
161 + /// without one, so this cannot fail there. It returns a `Result` rather
162 + /// than unwrapping because that guarantee lives in the mount rather than in
163 + /// the type: if a screen is ever moved onto a public mount, the failure is
164 + /// a refusal the reader can read instead of a panic in a blocking thread.
165 + ///
166 + /// A screen that genuinely serves both audiences reads
167 + /// [`user`](Self::user) directly instead.
168 + pub fn reader(&self) -> Result<&SessionUser, quasi_router::RouteError> {
169 + self.user
170 + .as_ref()
171 + .ok_or_else(|| quasi_router::RouteError::denied("sign in to continue"))
172 + }
173 +
142 174 /// Hand the renderer the markup for one bespoke region.
143 175 ///
144 176 /// Called by a handler while it builds its description, keyed by the slot
@@ -205,15 +237,48 @@
205 237 }
206 238 }
207 239
240 + /// Who a mount is willing to answer.
241 + ///
242 + /// The signed-out question `b5cbb646` left open, answered here rather than by a
243 + /// second state type. Two mounts, one `Viewer`: what differs between a panel
244 + /// behind a login and a public page is whether a missing session ends the
245 + /// request, and that is one branch in the factory rather than a parallel
246 + /// hierarchy of states, factories and renderer signatures.
247 + #[derive(Clone, Copy, PartialEq, Eq)]
248 + enum Audience {
249 + /// A session is required, and its absence ends the request.
250 + ///
251 + /// Every panel and every document behind a login. The screens built on this
252 + /// read [`Viewer::reader`] and never see it fail.
253 + Reader,
254 + /// A session is read when there is one, and its absence is an ordinary
255 + /// state the screen describes.
256 + ///
257 + /// The public documents: `/team` and the rest of the `pages/` screens that
258 + /// read the same to a visitor and to a reader, and differ only in the
259 + /// header they carry. A screen here still gets a CSRF token, because the
260 + /// header's own controls post.
261 + Anyone,
262 + }
263 +
208 264 /// Build the state factory the adapter calls per request.
209 265 ///
210 - /// Refuses with `Unauthorized` when there is no session to resolve, which the
211 - /// adapter turns into a bare status with no body. That is the right shape here
212 - /// and not a shortcut: a store that will not answer is not a signed-out reader,
213 - /// and rendering a sign-in notice would need the renderer that is built from
214 - /// the state that could not be resolved.
266 + /// On [`Audience::Reader`] it refuses with `Unauthorized` when there is no
267 + /// session to resolve, which the adapter turns into a bare status with no body.
268 + /// That is the right shape there and not a shortcut: a store that will not
269 + /// answer is not a signed-out reader, and rendering a sign-in notice would need
270 + /// the renderer that is built from the state that could not be resolved.
271 + ///
272 + /// On [`Audience::Anyone`] a failed `authenticate` is not a refusal, it is a
273 + /// visitor: the viewer is built with `user: None` and the request goes on. The
274 + /// CSRF token is minted either way, since it is the session's rather than the
275 + /// user's and a sessionless form still needs one.
276 + ///
277 + /// The absent session layer stays an internal error on both, because that is
278 + /// wiring rather than an audience.
215 279 fn viewer_factory(
216 280 app: AppState,
281 + audience: Audience,
217 282 ) -> impl Fn(&http::request::Parts) -> quasi_axum::StateFuture<Viewer> + Send + Sync + 'static {
218 283 move |parts| {
219 284 let app = app.clone();
@@ -226,33 +291,35 @@
226 291 // wiring mistake rather than a signed-out reader.
227 292 return Err(quasi_router::RouteError::internal("no session layer"));
228 293 };
229 - match crate::auth::authenticate(&session, &app).await {
230 - Ok(user) => {
231 - // Before the handler runs, because the write that mints a
232 - // token has to finish on the session this request holds.
233 - // A failure here is the session store, not the reader.
234 - let csrf = crate::csrf::get_or_create_token(&session)
235 - .await
236 - .map_err(|_| quasi_router::RouteError::internal("csrf token"))?;
237 - // Same reason as `csrf`: an async read a sync handler
238 - // cannot do. Absent on a legacy session, which is a state
239 - // the screens describe rather than an error.
240 - let session_id = session
241 - .get::<crate::db::UserSessionId>(crate::auth::SESSION_TRACKING_KEY)
242 - .await
243 - .ok()
244 - .flatten();
245 - Ok(Viewer {
246 - app,
247 - user,
248 - runtime,
249 - csrf,
250 - session_id,
251 - fills: std::sync::Mutex::default(),
252 - })
294 + let user = match crate::auth::authenticate(&session, &app).await {
295 + Ok(user) => Some(user),
296 + Err(_) if audience == Audience::Anyone => None,
297 + Err(_) => {
298 + return Err(quasi_router::RouteError::denied("sign in to continue"));
253 299 }
254 - Err(_) => Err(quasi_router::RouteError::denied("sign in to continue")),
255 - }
300 + };
301 + // Before the handler runs, because the write that mints a token has
302 + // to finish on the session this request holds. A failure here is
303 + // the session store, not the reader.
304 + let csrf = crate::csrf::get_or_create_token(&session)
305 + .await
306 + .map_err(|_| quasi_router::RouteError::internal("csrf token"))?;
307 + // Same reason as `csrf`: an async read a sync handler cannot do.
308 + // Absent on a legacy session, which is a state the screens describe
309 + // rather than an error.
310 + let session_id = session
311 + .get::<crate::db::UserSessionId>(crate::auth::SESSION_TRACKING_KEY)
312 + .await
313 + .ok()
314 + .flatten();
315 + Ok(Viewer {
316 + app,
317 + user,
318 + runtime,
319 + csrf,
320 + session_id,
321 + fills: std::sync::Mutex::default(),
322 + })
256 323 })
257 324 }
258 325 }
@@ -305,6 +372,21 @@
305 372 /// registers no mutating route, so it has nothing to skip here either.
306 373 pub const DOCUMENT_PATHS: &[&str] = &[feeds::PATH, export_portal::PATH];
307 374
375 + /// Every described document a reader with no session can reach, with the
376 + /// address it answers.
377 + ///
378 + /// Its own list beside [`DOCUMENT_PATHS`] for the same reason that one sits
379 + /// beside [`PATHS`]: what a mount answers with decides what a test can assert
380 + /// about it. These answer `Outcome::Screen` like the gated documents, and
381 + /// differ in that a signed-out request is a render rather than a 401, which is
382 + /// what `tests/workflows/pages.rs` presses them for.
383 + ///
384 + /// Not in [`public_mounts`], which is the other public list and a different
385 + /// mechanism: those screens resolve nothing per request and take a state built
386 + /// once at startup. These resolve a session when there is one, so they carry a
387 + /// per-request viewer and can mint a CSRF token for the form on them.
388 + pub const PUBLIC_DOCUMENT_PATHS: &[&str] = &[team::PATH];
389 +
308 390 /// Every screen's switch name, in the same order as [`PATHS`].
309 391 ///
310 392 /// Test-only: the switches themselves are read from each screen's own `SCREEN`
@@ -443,9 +525,11 @@
443 525 renderer: fn(&Viewer) -> quasi_webview::Webview,
444 526 ) -> axum::Router {
445 527 let router = quasi_router::Router::<Viewer>::new().get(path, screen);
446 - quasi_axum::Adapter::per_viewer(router, viewer_factory(app.clone()), move |viewer, _, _| {
447 - renderer(viewer)
448 - })
528 + quasi_axum::Adapter::per_viewer(
529 + router,
530 + viewer_factory(app.clone(), Audience::Reader),
531 + move |viewer, _, _| renderer(viewer),
532 + )
449 533 .into_router()
450 534 .layer(axum::middleware::from_fn_with_state(app.clone(), signed_in))
451 535 }
@@ -473,6 +557,45 @@
473 557 }
474 558 }
475 559
560 + /// Every described document a reader with no session can reach, mounted.
561 + ///
562 + /// See [`PUBLIC_DOCUMENT_PATHS`] for why these are neither in
563 + /// [`document_mounts`] nor in [`public_mounts`].
564 + pub fn public_document_mounts(app: &AppState) -> Vec<(&'static str, axum::Router)> {
565 + vec![(
566 + team::PATH,
567 + public_document_mount(app, team::PATH, team::screen, team::renderer),
568 + )]
569 + }
570 +
571 + /// A described document a reader NAVIGATES to with or without a session.
572 + ///
573 + /// [`document_mount`] with the two things that make it gated removed: the
574 + /// factory is built on [`Audience::Anyone`], so a signed-out request builds a
575 + /// viewer rather than being refused, and there is no [`signed_in`] layer in
576 + /// front of it, so nothing turns that into a 401. What is left is identical,
577 + /// including the exact-address registration -- see [`document_mount`] for why a
578 + /// document is not mounted as a nest.
579 + ///
580 + /// The screens here are the sessionless pages: the ones whose whole purpose is
581 + /// to be reachable by somebody who cannot sign in. A page that merely *reads*
582 + /// better when signed in is still gated; the test is whether refusing a visitor
583 + /// is the right answer.
584 + fn public_document_mount(
585 + app: &AppState,
586 + path: &'static str,
587 + screen: Screen,
588 + renderer: fn(&Viewer) -> quasi_webview::Webview,
589 + ) -> axum::Router {
590 + let router = quasi_router::Router::<Viewer>::new().get(path, screen);
591 + quasi_axum::Adapter::per_viewer(
592 + router,
593 + viewer_factory(app.clone(), Audience::Anyone),
594 + move |viewer, _, _| renderer(viewer),
595 + )
596 + .into_router()
597 + }
598 +
476 599 /// Every described screen a reader with no session can reach, mounted.
477 600 ///
478 601 /// Separate from [`mounts`] because of the factory, not because of the address:
@@ -617,9 +740,11 @@
617 740 quasi_router::Method::Post => quasi.post(path, *handler),
618 741 };
619 742 }
620 - quasi_axum::Adapter::per_viewer(quasi, viewer_factory(app.clone()), move |viewer, _, _| {
621 - renderer(viewer)
622 - })
743 + quasi_axum::Adapter::per_viewer(
744 + quasi,
745 + viewer_factory(app.clone(), Audience::Reader),
746 + move |viewer, _, _| renderer(viewer),
747 + )
623 748 .into_router()
624 749 }
625 750
@@ -679,12 +804,44 @@
679 804 );
680 805 }
681 806
807 + #[test]
808 + fn every_public_document_is_listed_in_public_document_paths() {
809 + let source = include_str!("mod.rs");
810 + let mounted = source
811 + .split_once("pub fn public_document_mounts(")
812 + .expect("public_document_mounts exists")
813 + .1
814 + .split_once("\n}")
815 + .expect("public_document_mounts ends")
816 + .0;
817 + assert_eq!(
818 + mounted.matches("public_document_mount(").count(),
819 + PUBLIC_DOCUMENT_PATHS.len(),
820 + "public_document_mounts and PUBLIC_DOCUMENT_PATHS describe the same screens"
821 + );
822 + }
823 +
824 + /// The two document lists differ by exactly one thing, and it is the one
825 + /// that matters: a gated document refuses a visitor, a public one renders to
826 + /// them. Nothing else about the mount changes, so a screen in the wrong list
827 + /// is a page that 401s or a page that leaks, depending on the direction.
828 + #[test]
829 + fn no_document_is_in_both_lists() {
830 + for path in PUBLIC_DOCUMENT_PATHS {
831 + assert!(
832 + !DOCUMENT_PATHS.contains(path),
833 + "{path} is mounted both gated and public"
834 + );
835 + }
836 + }
837 +
682 838 #[test]
683 839 fn a_path_is_claimed_by_exactly_one_screen() {
684 840 // Two screens on one address is an axum panic at startup, and the two
685 841 // forum-memberships screens are the near miss: one module, two paths.
686 842 let mut seen = PATHS.to_vec();
687 843 seen.extend_from_slice(DOCUMENT_PATHS);
844 + seen.extend_from_slice(PUBLIC_DOCUMENT_PATHS);
688 845 seen.sort_unstable();
689 846 let before = seen.len();
690 847 seen.dedup();
@@ -83,7 +83,10 @@
83 83 /// The card.
84 84 pub fn screen(viewer: &Viewer, _request: Request) -> Result<Response, RouteError> {
85 85 let user = viewer
86 - .block_on(db::users::get_user_by_id(&viewer.app.db, viewer.user.id))
86 + .block_on(db::users::get_user_by_id(
87 + &viewer.app.db,
88 + viewer.reader()?.id,
89 + ))
87 90 .map_err(|_| RouteError::internal("your account could not be read"))?
88 91 .ok_or_else(|| RouteError::not_found("that account is gone"))?;
89 92
@@ -284,7 +284,7 @@
284 284 .map_err(|_| RouteError::internal("that project could not be read"))?
285 285 .ok_or_else(|| RouteError::not_found("no such project"))?;
286 286
287 - if owned.user_id != viewer.user.id {
287 + if owned.user_id != viewer.reader()?.id {
288 288 return Err(RouteError::not_found("no such project"));
289 289 }
290 290 Ok(project)
@@ -355,7 +355,7 @@
355 355 .map_err(|_| RouteError::internal("that user could not be read"))?
356 356 .ok_or_else(|| RouteError::conflict("no user by that name"))?;
357 357
358 - if member.id == viewer.user.id {
358 + if member.id == viewer.reader()?.id {
359 359 return Err(RouteError::conflict("You are already the project owner"));
360 360 }
361 361
@@ -372,7 +372,7 @@
372 372 member.id,
373 373 role,
374 374 split,
375 - viewer.user.id,
375 + viewer.reader()?.id,
376 376 ))
377 377 .map_err(|_| RouteError::internal("that collaborator could not be added"))?;
378 378
@@ -380,15 +380,23 @@
380 380 .block_on(db::projects::bump_cache_generation(&viewer.app.db, project))
381 381 .map_err(|_| RouteError::internal("the project could not be marked changed"))?;
382 382
383 - answer(viewer, project, Some(&invited(viewer, &member, split)))
383 + let owner_currency = viewer.reader()?.settlement_currency;
384 + answer(
385 + viewer,
386 + project,
387 + Some(&invited(owner_currency, &member, split)),
388 + )
384 389 }
385 390
386 391 /// What the owner is told, which depends on whose money crosses a currency.
387 392 ///
388 393 /// The sentence `add_project_member` answered with, kept rather than lost with
389 394 /// the endpoint. See the module header.
390 - fn invited(viewer: &Viewer, member: &db::DbUser, split: i16) -> String {
391 - let owner_currency = viewer.user.settlement_currency;
395 + fn invited(
396 + owner_currency: crate::currency::SettlementCurrency,
397 + member: &db::DbUser,
398 + split: i16,
399 + ) -> String {
392 400 if member.settlement_currency == owner_currency {
393 401 format!(
394 402 "Invited @{} to a {split}% split. Their share starts when they accept.",