From 5816d334ad370645da98871400dd2cc7f8a17fdc Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 6 Aug 2026 15:32:09 +0200 Subject: [PATCH] fix: the note form carried no session token, so every note was refused Tier S (a fix inside a boundary; chaos d8=5, no override). Reported by the maintainer: "I can't save notes, I get 'refused: no session token'." The form posted to a bare `/note`. Control 1 requires the token on EVERY request, so the guard refused all of them. WHY THE TESTS MISSED IT IS THE PART WORTH RECORDING. I verified the note channel over real HTTP and got 303 -- but I appended the token to the URL by hand. I tested the ENDPOINT and not the PATH A PLAYER TAKES, so the one thing standing between the feature and the user was the one thing not exercised. Same family as timing the wrong span and counting the wrong denominator: a correct measurement of the wrong subject. Fixed with Guard::note_endpoint(), so the form's action carries the token like every other request. The assertion now pins the token's PRESENCE rather than the bare path, so reverting the fix turns it red. Verified the way it should have been done first: read the form's `action` out of the SERVED page and POST to exactly that, nothing added by hand. 303. Clippy then flagged document_with_log at 8 arguments. It was right -- the signature had grown across three passes -- so the two endpoints are now one `Endpoints` struct rather than an #[allow]. They are one concept: the guarded surface this page may talk to, one channel that becomes commands and one that provably cannot. Co-Authored-By: Claude Opus 5 --- crates/cb-render-html/src/doc.rs | 39 +++++++++++++++++++++++++----- crates/cb-render-html/src/lib.rs | 24 ++++++++++++------ crates/cb-render-html/src/serve.rs | 11 +++++++++ tools/cb-play/src/hotseat.rs | 5 +++- 4 files changed, 65 insertions(+), 14 deletions(-) diff --git a/crates/cb-render-html/src/doc.rs b/crates/cb-render-html/src/doc.rs index 7995e81..cb3f9ca 100644 --- a/crates/cb-render-html/src/doc.rs +++ b/crates/cb-render-html/src/doc.rs @@ -698,19 +698,44 @@ pub fn document( seat: Option, may_pass: bool, ) -> String { - document_with_log(view, legal, endpoint, seat, may_pass, &[], &[]) + document_with_log( + view, + legal, + Endpoints { + command: endpoint, + note: "/note", + }, + seat, + may_pass, + &[], + &[], + ) } /// The table, plus the game log (CB-WP-0018 T02). +/// Where the page posts, both channels (ADR-0014 D1). +/// +/// One struct rather than two `&str` parameters because they are one +/// concept — the guarded surface this page may talk to — and because +/// clippy was right that the signature had grown across three passes. +#[derive(Debug, Clone, Copy)] +pub struct Endpoints<'a> { + /// Pointer facts. `resolve` turns these into commands. + pub command: &'a str, + /// Free text. Nothing turns these into commands. + pub note: &'a str, +} + pub fn document_with_log( view: &GroundView, legal: &[games_ground::GroundCommand], - endpoint: &str, + to: Endpoints<'_>, seat: Option, may_pass: bool, log: &[LogLine], meta: &[String], ) -> String { + let (endpoint, note_to) = (to.command, to.note); let mut s = String::with_capacity(8192); let _ = write!( s, @@ -743,7 +768,7 @@ pub fn document_with_log( body(&mut s, view); move_section(&mut s, legal, seat, may_pass); s.push_str("
"); - meta_section(&mut s, meta); + meta_section(&mut s, meta, note_to); log_section(&mut s, log); s.push_str("
"); let _ = write!( @@ -761,7 +786,7 @@ pub fn document_with_log( /// /// Empty is a legitimate state — a first game has no tally and may have no /// notes — and renders as nothing rather than as an empty heading. -fn meta_section(s: &mut String, meta: &[String]) { +fn meta_section(s: &mut String, meta: &[String], note_to: &str) { // CB-WP-0027 T03: the comment box. Always present — the panel's // purpose is that a player can say something at any moment, and a box // that appears only sometimes trains them not to look for it. @@ -770,12 +795,14 @@ fn meta_section(s: &mut String, meta: &[String]) { // The command channel needs JavaScript because a drag is not a form // submission; a comment is, and making it depend on the script would // add a failure mode for no gain. - s.push_str( + let _ = write!( + s, "

what are you thinking?

\ -
\ + \ \
", + note_to = esc(note_to), ); if meta.is_empty() { return; diff --git a/crates/cb-render-html/src/lib.rs b/crates/cb-render-html/src/lib.rs index a460ab4..2885d15 100644 --- a/crates/cb-render-html/src/lib.rs +++ b/crates/cb-render-html/src/lib.rs @@ -47,7 +47,14 @@ pub mod input; pub mod jsrun; pub mod serve; -pub use doc::{document, text_of}; +pub use doc::{document, text_of, Endpoints}; + +/// The endpoint pair every test in this crate posts to. +#[cfg(test)] +const TEST_ENDPOINTS: Endpoints<'static> = Endpoints { + command: "/command?t=x", + note: "/note?t=x", +}; pub use input::{resolve, Note, PointerFact}; pub use serve::{Guard, Refusal, Request}; @@ -186,7 +193,7 @@ mod coverage { text_of(&document( view, &[], - "/command?t=x", + crate::TEST_ENDPOINTS.command, Some(PlayerId(0)), false, )) @@ -595,7 +602,7 @@ mod gamelog { document_with_log( &crate::testfix::view(Some(PlayerId(0))), &[], - "/command?t=x", + crate::TEST_ENDPOINTS, Some(PlayerId(0)), false, log, @@ -779,7 +786,7 @@ mod notes { let html = document_with_log( &crate::testfix::view(Some(PlayerId(0))), &[], - "/command?t=x", + crate::TEST_ENDPOINTS, Some(PlayerId(0)), false, &[], @@ -807,13 +814,16 @@ mod notes { let html = document_with_log( &crate::testfix::view(Some(PlayerId(0))), &[], - "/command?t=x", + crate::TEST_ENDPOINTS, Some(PlayerId(0)), false, &[], &[], ); - assert!(html.contains("action=\"/note\""), "no comment box"); + assert!( + html.contains("action=\"/note?t=x\""), + "the form must carry the session token" + ); assert!( html.contains("method=\"post\""), "a plain form, so it works with the script disabled" @@ -832,7 +842,7 @@ mod two_columns { document_with_log( &crate::testfix::view(Some(PlayerId(0))), &[], - "/command?t=x", + crate::TEST_ENDPOINTS, Some(PlayerId(0)), false, &[], diff --git a/crates/cb-render-html/src/serve.rs b/crates/cb-render-html/src/serve.rs index dfe464d..e4f3169 100644 --- a/crates/cb-render-html/src/serve.rs +++ b/crates/cb-render-html/src/serve.rs @@ -158,6 +158,17 @@ impl Guard { format!("/command?t={}", self.token) } + /// The note channel's endpoint, token and all (CB-WP-0027). + /// + /// **A form's `action` must carry the token like every other + /// request.** The first version posted to a bare `/note` and was + /// refused with "no session token" — the endpoint had been tested + /// with a token appended by hand, so the test exercised the mechanism + /// and not the path a player takes. + pub fn note_endpoint(&self) -> String { + format!("/note?t={}", self.token) + } + pub fn page_url(&self) -> String { format!("{}/?t={}", self.origin, self.token) } diff --git a/tools/cb-play/src/hotseat.rs b/tools/cb-play/src/hotseat.rs index d853c3f..3513935 100644 --- a/tools/cb-play/src/hotseat.rs +++ b/tools/cb-play/src/hotseat.rs @@ -243,7 +243,10 @@ impl Server { let page = cb_render_html::doc::document_with_log( &view, legal, - &self.guard.endpoint(), + cb_render_html::Endpoints { + command: &self.guard.endpoint(), + note: &self.guard.note_endpoint(), + }, Some(seat), may_pass, &self.log_lines(),