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 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-06 15:32:09 +02:00
parent ac57ce2011
commit 5816d334ad
4 changed files with 65 additions and 14 deletions

View file

@ -698,19 +698,44 @@ pub fn document(
seat: Option<PlayerId>,
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<PlayerId>,
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("</div><div class=\"cb-meta\">");
meta_section(&mut s, meta);
meta_section(&mut s, meta, note_to);
log_section(&mut s, log);
s.push_str("</div></div>");
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,
"<h2>what are you thinking?</h2>\
<form class=\"card\" method=\"post\" action=\"/note\" id=\"cb-note\">\
<form class=\"card\" method=\"post\" action=\"{note_to}\" id=\"cb-note\">\
<textarea name=\"note\" rows=\"4\" placeholder=\"why this move, what is \
unclear, what is annoying \u{2014} bound to this position\"></textarea>\
<button type=\"submit\">note it</button></form>",
note_to = esc(note_to),
);
if meta.is_empty() {
return;

View file

@ -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,
&[],

View file

@ -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)
}

View file

@ -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(),