CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit

Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat
play, at a measured marginal AM-4a cost of zero.

  games-ground shipped:  23 third-party crates
  cb-render-html:        23 third-party crates
  new crates introduced:  0

Measured, not asserted — the survey's own lesson. AM-4a is unmoved at
246,250; own source is 7,636 -> 9,652.

What shipped:
  crates/cb-render-html  doc.rs (HTML/SVG emission, incl. the relationship
                         graph), input.rs (pointer facts -> commands),
                         serve.rs (Guard, Request, loopback bind)
  tools/cb-play          hotseat.rs + `--serve PORT`

Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null.
The renderer targets the existing Project trait; the port waits for
stage 2's wgpu implementation to be its second use.

The six controls, all live, all mutation-checked (8 mutations, each red
for its stated reason):

  1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind
  4   a token-less request is refused, in the unit AND over a real socket
  5   JS may not construct commands — the page reports pointer facts, Rust
      resolves them against the legal list the aggregate already offered,
      and a test asserts the emitted script contains no game vocabulary
  6   the coverage gate crosses the language boundary: it walks the
      serialized view for leaf paths and requires each token to appear in
      the PARSED emitted document, with a test that the parse really is a
      parse (script/style contents must not count as rendered)

The gate fired on its author again, on its first run: ground_choices.*.
choice, ground_choices.*.problem and players.*.blame_from were in neither
list. The last is the one worth keeping — an EMPTY vector is a leaf path
of its own, and it now renders as an explicit absence.

Also, a mutation that did not go red: removing the Sec-Fetch-Site arm
alone left the cross-site test green, because the Origin check caught it
independently. Both had to be removed before the control bit. Recorded
because a control that passes for a reason you did not intend has not
been demonstrated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-02 04:27:25 +02:00
parent 883b608f36
commit 84d688688d
14 changed files with 2058 additions and 2 deletions

10
Cargo.lock generated
View file

@ -81,11 +81,21 @@ dependencies = [
"cb-events",
"cb-game-runtime",
"cb-kernel",
"cb-render-html",
"games-ground",
"serde_json",
"serde_yaml",
]
[[package]]
name = "cb-render-html"
version = "0.1.0"
dependencies = [
"cb-kernel",
"games-ground",
"serde_json",
]
[[package]]
name = "cb-sim"
version = "0.1.0"

View file

@ -4,6 +4,7 @@ members = [
"crates/cb-kernel",
"crates/cb-events",
"crates/cb-game-runtime",
"crates/cb-render-html",
"games/ground",
"tools/cb-sim",
"tools/cb-play",
@ -19,6 +20,7 @@ cb-kernel = { path = "crates/cb-kernel" }
cb-events = { path = "crates/cb-events" }
cb-game-runtime = { path = "crates/cb-game-runtime", default-features = false }
games-ground = { path = "games/ground", default-features = false }
cb-render-html = { path = "crates/cb-render-html" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"

View file

@ -0,0 +1,19 @@
[package]
name = "cb-render-html"
edition.workspace = true
version.workspace = true
license-file.workspace = true
# ADR-0007 Decision 1: the browser is the renderer, so this crate has
# **no third-party dependencies at all** beyond what the game already
# carries. Adding one here needs an argument against ADR-0007 §Decision 3.
[dependencies]
cb-kernel.workspace = true
games-ground.workspace = true
[lints]
workspace = true
[dev-dependencies]
# The coverage gate walks the serialized view; nothing else needs it.
serde_json.workspace = true

View file

@ -0,0 +1,544 @@
//! The emitted document: HTML shell, inline SVG table, inline JavaScript.
//!
//! ADR-0007 Decision 1. Marginal AM-4a cost zero — this is string
//! formatting, and the browser draws it.
//!
//! ## What the JavaScript is allowed to be
//!
//! ADR-0007 Decision 5 bars it from constructing commands. [`SCRIPT`] is
//! therefore the whole of it, it is a constant, and it does one thing:
//! record which element the pointer went down on, which it came up on, and
//! POST the pair. It contains no game vocabulary — no action names, no
//! seats, no rules. If a rule ever needs to appear in it, the decision is
//! wrong and ADR-0007 says to revisit it rather than widen the control.
use std::fmt::Write as _;
use cb_kernel::PlayerId;
use games_ground::view::{GroundView, PlayerView, ProblemView, SelectionView};
use crate::input::action_id;
/// Escape for text and attribute contexts alike.
///
/// Everything interpolated into the document goes through this. Card
/// suits and seat numbers cannot currently carry a `<`, but "cannot
/// currently" is how injection bugs are written.
pub fn esc(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&#39;"),
_ => out.push(c),
}
}
out
}
fn seat_name(p: PlayerId) -> String {
format!("P{}", p.0 + 1)
}
fn cards(list: &[games_ground::SolutionCard]) -> String {
if list.is_empty() {
return "none".to_string();
}
list.iter()
.map(|c| format!("{:?}", c.suit))
.collect::<Vec<_>>()
.join(" ")
}
/// The only JavaScript in the project. See the module docs.
pub const SCRIPT: &str = r#"
(function () {
var down = null;
function id(e) {
var n = e.target;
while (n && !n.id) { n = n.parentNode; }
return n ? n.id : null;
}
document.addEventListener('pointerdown', function (e) { down = id(e); });
document.addEventListener('pointerup', function (e) {
var up = id(e);
if (!down || !up) { down = null; return; }
var body = 'down=' + encodeURIComponent(down) + '&up=' + encodeURIComponent(up);
down = null;
fetch(window.CB_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body
}).then(function (r) { return r.text(); }).then(function (t) {
document.getElementById('cb-status').textContent = t;
if (t.indexOf('ok') === 0) { window.location.reload(); }
});
});
})();
"#;
const STYLE: &str = "
body{font:14px/1.5 ui-monospace,monospace;margin:1.5rem;background:#12141a;color:#dde}
h1,h2{font-size:1rem;margin:1.2rem 0 .4rem;color:#9cf}
.row{display:flex;flex-wrap:wrap;gap:.5rem;align-items:flex-start}
.card{border:1px solid #445;border-radius:6px;padding:.5rem .7rem;background:#1b1e26}
.card[data-viewer=true]{border-color:#9cf}
.act{cursor:grab;user-select:none;background:#243;border-color:#5a7}
.btn{cursor:pointer;background:#332a3a;border-color:#a7d}
.k{color:#89a}
#cb-status{margin-top:1rem;color:#fc9;min-height:1.2em}
svg{background:#1b1e26;border:1px solid #445;border-radius:6px}
";
fn problem_svg(out: &mut String, priority: u32, p: &ProblemView, x: i32) {
let (label, sub, fill) = match p {
ProblemView::FaceDown => ("face down".to_string(), String::new(), "#2a2f3a"),
ProblemView::FaceUp {
suit,
value,
denied,
claimed_by,
protected_this_round,
} => {
let mut sub = String::new();
if *denied {
sub.push_str("denied ");
}
if *protected_this_round {
sub.push_str("protected ");
}
if let Some(c) = claimed_by {
let _ = write!(sub, "claimed by {}", seat_name(*c));
}
(
format!("{suit:?} {value}"),
sub.trim_end().to_string(),
"#26303a",
)
}
};
let _ = write!(
out,
"<g id=\"problem-{priority}\"><rect x=\"{x}\" y=\"10\" width=\"120\" height=\"78\" rx=\"8\" \
fill=\"{fill}\" stroke=\"#5a6b7a\"/>\
<text x=\"{tx}\" y=\"36\" fill=\"#dde\" font-size=\"13\">{label}</text>\
<text x=\"{tx}\" y=\"56\" fill=\"#89a\" font-size=\"11\">priority {priority}</text>\
<text x=\"{tx}\" y=\"74\" fill=\"#fc9\" font-size=\"11\">{sub}</text></g>",
x = x,
tx = x + 10,
label = esc(&label),
sub = esc(&sub),
);
}
/// The relationship graph — the one element every Rust 2D toolkit would
/// have left us to hand-roll, and the reason SVG earns its place here
/// rather than merely fitting the budget (CB-RES-0006 §5).
fn relations_svg(view: &GroundView) -> String {
let n = view.players.len().max(1);
let (cx, cy, r) = (200.0f64, 150.0f64, 110.0f64);
let pos: Vec<(PlayerId, f64, f64)> = view
.players
.keys()
.enumerate()
.map(|(i, p)| {
let a = std::f64::consts::TAU * (i as f64) / (n as f64) - std::f64::consts::FRAC_PI_2;
(*p, cx + r * a.cos(), cy + r * a.sin())
})
.collect();
let find = |p: PlayerId| {
pos.iter()
.find(|(q, _, _)| *q == p)
.map(|(_, x, y)| (*x, *y))
};
let mut s = String::from(
"<svg width=\"400\" height=\"300\" role=\"img\" aria-label=\"relationship graph\">",
);
for (pair, rel) in &view.relations {
if let (Some((x1, y1)), Some((x2, y2))) = (find(pair.0), find(pair.1)) {
let colour = match rel {
games_ground::Relation::Bond => "#5c9",
games_ground::Relation::Rivalry => "#c66",
};
let _ = write!(
s,
"<line x1=\"{x1:.0}\" y1=\"{y1:.0}\" x2=\"{x2:.0}\" y2=\"{y2:.0}\" \
stroke=\"{colour}\" stroke-width=\"2\"/>\
<text x=\"{mx:.0}\" y=\"{my:.0}\" fill=\"{colour}\" font-size=\"10\">{rel:?}</text>",
mx = (x1 + x2) / 2.0,
my = (y1 + y2) / 2.0 - 3.0,
);
}
}
for (p, x, y) in &pos {
let is_viewer = view.viewer == Some(*p);
let focus = view
.focus
.get(p)
.map(|f| format!(" \u{2192}{}", seat_name(*f)));
let _ = write!(
s,
"<g id=\"seat-{raw}\"><circle cx=\"{x:.0}\" cy=\"{y:.0}\" r=\"26\" fill=\"#1b1e26\" \
stroke=\"{stroke}\" stroke-width=\"2\"/>\
<text x=\"{x:.0}\" y=\"{ty:.0}\" fill=\"#dde\" font-size=\"12\" \
text-anchor=\"middle\">{name}</text>\
<text x=\"{x:.0}\" y=\"{ty2:.0}\" fill=\"#89a\" font-size=\"10\" \
text-anchor=\"middle\">{focus}</text></g>",
raw = p.0,
stroke = if is_viewer { "#9cf" } else { "#5a6b7a" },
ty = y + 2.0,
ty2 = y + 16.0,
name = seat_name(*p),
focus = esc(focus.as_deref().unwrap_or("")),
);
}
s.push_str("</svg>");
s
}
fn player_card(out: &mut String, id: PlayerId, p: &PlayerView, view: &GroundView) {
let is_viewer = view.viewer == Some(id);
let _ = write!(
out,
"<div class=\"card\" data-viewer=\"{is_viewer}\"><b>{name}</b>{you}<br>",
name = seat_name(id),
you = if is_viewer { " (you)" } else { "" },
);
let _ = write!(
out,
"<span class=\"k\">stress</span> {} <span class=\"k\">protect</span> {} \
<span class=\"k\">darvo</span> {:?}<br>",
p.stress, p.protection, p.darvo
);
let _ = write!(
out,
"<span id=\"freedom-{raw}\" class=\"act\"><span class=\"k\">freedom</span> \
{ready}{lifted}</span><br>",
raw = id.0,
ready = if p.freedom_ready { "READY" } else { "spent" },
lifted = if p.freedom_gate_lifted {
" gate lifted"
} else {
""
},
);
let blame = if p.blame_from.is_empty() {
"none".to_string()
} else {
p.blame_from
.iter()
.map(|b| seat_name(*b))
.collect::<Vec<_>>()
.join(" ")
};
let _ = write!(
out,
"<span class=\"k\">blamed by</span> {}<br>",
esc(&blame)
);
match &p.hand {
Some(h) => {
let _ = write!(
out,
"<span class=\"k\">hand</span> {} ({} cards)<br>",
esc(&cards(h)),
p.hand_size
);
}
None => {
let _ = write!(
out,
"<span class=\"k\">hand</span> {} card(s), hidden<br>",
p.hand_size
);
}
}
if let Some(sel) = view.selections.get(&id) {
let _ = write!(
out,
"<span class=\"k\">selected</span> {}<br>",
match sel {
SelectionView::Hidden => "face down".to_string(),
SelectionView::Shown(s) => esc(&format!("{s:?}")),
}
);
}
if let Some(m) = view.ground_modes.get(&id) {
let _ = write!(out, "<span class=\"k\">ground mode</span> {m:?}<br>");
}
if let Some(c) = view.ground_choices.get(&id) {
let _ = write!(out, "<span class=\"k\">ground choice</span> {c:?}<br>");
}
if let Some(r) = view.support_responses.get(&id) {
let _ = write!(out, "<span class=\"k\">support</span> {r:?}<br>");
}
if let Some(t) = view.darvo_targets.get(&id) {
let _ = write!(out, "<span class=\"k\">darvo target</span> {t:?}<br>");
}
out.push_str("</div>");
}
/// Render the whole table as a standalone document.
///
/// `may_pass` adds the one affordance that is not a command: declining to
/// act where the driver allows it. Like every other element it carries an
/// id and nothing else — the page still reports only that the pointer went
/// down and up on `pass`.
///
/// `legal` is the list the aggregate offered; the buttons carry indices
/// into it and nothing else (ADR-0007 control 5). `endpoint` carries the
/// per-process token (control 1) — the page cannot mint one.
pub fn document(
view: &GroundView,
legal: &[games_ground::GroundCommand],
endpoint: &str,
seat: Option<PlayerId>,
may_pass: bool,
) -> String {
let mut s = String::with_capacity(8192);
let _ = write!(
s,
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
<title>GROUND \u{2014} round {round}</title><style>{STYLE}</style></head><body>",
round = view.round
);
let _ = write!(
s,
"<h1>GROUND \u{2014} round {round}, step {step:?}</h1>\
<div><span class=\"k\">lead</span> {lead} \
<span class=\"k\">scoring</span> {mode:?} \
<span class=\"k\">viewing as</span> {who}</div>",
round = view.round,
step = view.step,
lead = seat_name(view.lead),
mode = view.mode,
who = match view.viewer {
Some(p) => format!("{} (their hand only)", seat_name(p)),
None => "a spectator (no hands)".to_string(),
},
);
s.push_str(
"<h2>problems</h2><svg width=\"760\" height=\"100\" role=\"img\" aria-label=\"problems\">",
);
if view.problems.is_empty() {
s.push_str(
"<text x=\"12\" y=\"52\" fill=\"#89a\" font-size=\"12\">no problems in play</text>",
);
}
for (i, (priority, p)) in view.problems.iter().enumerate() {
problem_svg(&mut s, *priority, p, 10 + (i as i32) * 130);
}
s.push_str("</svg>");
s.push_str("<h2>relationships</h2>");
s.push_str(&relations_svg(view));
s.push_str("<h2>seats</h2><div class=\"row\">");
for (id, p) in &view.players {
player_card(&mut s, *id, p, view);
}
s.push_str("</div>");
let _ = write!(
s,
"<h2>solutions</h2><div><span class=\"k\">deck</span> {} remaining \
<span class=\"k\">discard</span> {}</div>",
view.solution_deck_len,
esc(&cards(&view.solution_discard)),
);
if let Some(o) = &view.outcome {
let personal = o
.personal
.iter()
.map(|(p, v)| format!("{} {v:+}", seat_name(*p)))
.collect::<Vec<_>>()
.join(" ");
let winners = if o.winners.is_empty() {
"nobody".to_string()
} else {
o.winners
.iter()
.map(|w| seat_name(*w))
.collect::<Vec<_>>()
.join(" ")
};
let coalitions = if o.coalitions.is_empty() {
"none".to_string()
} else {
o.coalitions
.iter()
.map(|c| format!("{c:?}"))
.collect::<Vec<_>>()
.join(" ")
};
let _ = write!(
s,
"<h2>outcome</h2><div class=\"card\">\
<span class=\"k\">total</span> {total} of {threshold} \
<span class=\"k\">group</span> {group}<br>\
<span class=\"k\">personal</span> {personal}<br>\
<span class=\"k\">coalitions</span> {coalitions}<br>\
<span class=\"k\">mastery</span> {mastery}<br>\
<span class=\"k\">winners</span> {winners}</div>",
total = o.total,
threshold = o.threshold,
group = if o.group_success {
"success"
} else {
"failure"
},
personal = esc(&personal),
coalitions = esc(&coalitions),
mastery = match o.mastery {
Some(m) => format!("{m:+}"),
None => "none".to_string(),
},
winners = esc(&winners),
);
}
if !legal.is_empty() {
s.push_str("<h2>your move</h2><div class=\"row\">");
for a in [
games_ground::Action::Investigate,
games_ground::Action::Solve,
games_ground::Action::Support,
games_ground::Action::Attack,
games_ground::Action::Ground,
] {
let offered = seat.is_some_and(|seat| {
legal.iter().any(|c| {
crate::input::affordance(c, seat).is_some_and(|(f, _)| f == action_id(a))
})
});
if offered {
let _ = write!(
s,
"<div class=\"card act\" id=\"{id}\">drag {a:?} onto a seat, a problem, \
or the table</div>",
id = action_id(a),
);
}
}
s.push_str("</div><div class=\"row\">");
for (i, c) in legal.iter().enumerate() {
let spatial = seat.is_some_and(|seat| crate::input::affordance(c, seat).is_some());
if !spatial {
let _ = write!(
s,
"<div class=\"card btn\" id=\"cmd-{i}\">{}</div>",
esc(&format!("{c:?}"))
);
}
}
s.push_str(
"</div><div class=\"card\" id=\"table\">the table \u{2014} drop here for an \
untargeted action</div>",
);
}
if may_pass {
s.push_str("<div class=\"card btn\" id=\"pass\">pass \u{2014} decline to act</div>");
}
let _ = write!(
s,
"<div id=\"cb-status\">ready</div>\
<script>window.CB_ENDPOINT={};</script><script>{SCRIPT}</script></body></html>",
json_string(endpoint),
);
s
}
/// Quote a string as a JSON literal, so a token can never end the script.
fn json_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'<' => out.push_str("\\u003c"),
'>' => out.push_str("\\u003e"),
'&' => out.push_str("\\u0026"),
c if (c as u32) < 0x20 => {
let _ = write!(out, "\\u{:04x}", c as u32);
}
c => out.push(c),
}
}
out.push('"');
out
}
/// Extract the document's visible text and element ids.
///
/// ADR-0007 control 6 requires the coverage gate to assert over the
/// **parsed emitted document**, not over the Rust that emits it. This is
/// that parse: it drops markup and returns what a reader would see, plus
/// the ids a pointer can address. A substring search over the raw source
/// would happily find a token inside a comment or a style rule.
pub fn text_of(html: &str) -> String {
let mut out = String::with_capacity(html.len() / 2);
let bytes: Vec<char> = html.chars().collect();
let mut i = 0;
let mut skip_to: Option<&str> = None;
while i < bytes.len() {
if bytes[i] == '<' {
// Find the tag name.
let start = i + 1;
let mut j = start;
while j < bytes.len() && bytes[j] != '>' {
j += 1;
}
let tag: String = bytes[start..j.min(bytes.len())].iter().collect();
let lower = tag.to_ascii_lowercase();
let name = lower
.trim_start_matches('/')
.split([' ', '\t', '\n', '>'])
.next()
.unwrap_or("")
.to_string();
if let Some(want) = skip_to {
if lower.starts_with('/') && name == want {
skip_to = None;
}
} else if name == "script" || name == "style" {
// Their contents are not text a reader sees.
if !lower.starts_with('/') && !lower.ends_with('/') {
skip_to = Some(if name == "script" { "script" } else { "style" });
}
} else {
// Ids are addressable surface, so they count as rendered.
if let Some(k) = lower.find("id=\"") {
let rest = &tag[k + 4..];
if let Some(end) = rest.find('"') {
out.push(' ');
out.push_str(&rest[..end]);
}
}
}
i = j + 1;
continue;
}
if skip_to.is_none() {
out.push(bytes[i]);
}
i += 1;
}
// Unescape the entities esc() introduced, so a test looks for the text
// a reader sees rather than its encoding.
out.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&amp;", "&")
}

View file

@ -0,0 +1,270 @@
//! ADR-0007 Decision 5: **JavaScript may not construct commands.**
//!
//! The emitted page reports raw pointer facts — "the pointer went down on
//! element `a` and came up on element `b`" — and nothing else. This module
//! is where Rust decides what command that means.
//!
//! The rule exists because CB-WP-0011 established that a renderer's defect
//! class is silent omission, and ADR-0007 moves the interactive half of
//! stage 1 into a language `cargo test`, `clippy` and `M-D1-MUT` cannot
//! reach. Confining JavaScript to input transport keeps the decision on
//! this side of the boundary, where it can be tested against synthetic
//! events with no browser in the loop — which is exactly what the tests
//! below do.
//!
//! K13 is unaffected: resolving a drag produces an **index into the legal
//! list the aggregate already offered**. A drag can never name a command
//! the aggregate did not enumerate, so the projection cannot feed back
//! into validation.
use games_ground::{Action, GroundCommand};
/// What the browser is permitted to tell us: two element ids.
///
/// Deliberately not "a move", "a command", or anything carrying game
/// meaning. If this struct ever grows a field with a rule in it, control 5
/// has been breached and ADR-0007 says to revisit the decision rather than
/// widen the control.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PointerFact {
pub down: String,
pub up: String,
}
impl PointerFact {
pub fn new(down: &str, up: &str) -> Self {
Self {
down: down.to_string(),
up: up.to_string(),
}
}
/// Parse the wire form `down=<id>&up=<id>`. Anything else is refused.
pub fn parse(body: &str) -> Result<Self, String> {
let (mut down, mut up) = (None, None);
for pair in body.split('&') {
match pair.split_once('=') {
Some(("down", v)) => down = Some(v.to_string()),
Some(("up", v)) => up = Some(v.to_string()),
_ => return Err(format!("unrecognised field in pointer fact: {pair:?}")),
}
}
match (down, up) {
(Some(down), Some(up)) if !down.is_empty() && !up.is_empty() => Ok(Self { down, up }),
_ => Err("a pointer fact needs a non-empty down and up".to_string()),
}
}
}
pub fn action_id(a: Action) -> &'static str {
match a {
Action::Investigate => "action-investigate",
Action::Solve => "action-solve",
Action::Support => "action-support",
Action::Attack => "action-attack",
Action::Ground => "action-ground",
}
}
/// The element pair that offers this command, if it has a spatial one.
///
/// Commands with no natural drag — choosing a GROUND mode, answering a
/// Support, naming a DARVO target — are offered as buttons instead, and
/// resolve through the `cmd-<i>` form below.
pub fn affordance(command: &GroundCommand, seat: cb_kernel::PlayerId) -> Option<(String, String)> {
match command {
GroundCommand::SelectAction {
action,
target,
problem,
} => {
let from = action_id(*action).to_string();
let to = match (target, problem) {
(Some(p), None) => format!("seat-{}", p.0),
(None, Some(n)) => format!("problem-{n}"),
(None, None) => "table".to_string(),
// Both set is not a shape the aggregate produces; refusing
// to invent an affordance is better than guessing one.
(Some(_), Some(_)) => return None,
};
Some((from, to))
}
GroundCommand::SpendFreedom => {
Some((format!("freedom-{}", seat.0), format!("freedom-{}", seat.0)))
}
_ => None,
}
}
/// Resolve a pointer fact to an index into the legal list.
///
/// Returns `Err` rather than a default when nothing matches: a drag that
/// means nothing must mean *nothing*, not "the first legal move". A
/// renderer that silently substitutes a command is the presentation-layer
/// form of the defect this whole pass is about.
pub fn resolve(
fact: &PointerFact,
legal: &[GroundCommand],
seat: cb_kernel::PlayerId,
) -> Result<usize, String> {
// The button form: an explicit index, which still has to be in range.
if let Some(rest) = fact.down.strip_prefix("cmd-") {
if fact.down != fact.up {
return Err(format!(
"a button press must start and end on the same element ({} then {})",
fact.down, fact.up
));
}
let i: usize = rest
.parse()
.map_err(|_| format!("not a command index: {:?}", fact.down))?;
return if i < legal.len() {
Ok(i)
} else {
Err(format!("no legal command {i} (there are {})", legal.len()))
};
}
let mut hits = legal
.iter()
.enumerate()
.filter(|(_, c)| affordance(c, seat).is_some_and(|(f, t)| f == fact.down && t == fact.up));
match (hits.next(), hits.next()) {
(Some((i, _)), None) => Ok(i),
(Some(_), Some(_)) => Err(format!(
"{} -> {} is offered by more than one legal command; the page is ambiguous",
fact.down, fact.up
)),
(None, _) => Err(format!(
"{} -> {} is not a legal move here",
fact.down, fact.up
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use cb_kernel::PlayerId;
use games_ground::{GroundMode, SupportResponse};
fn seat(n: u8) -> PlayerId {
PlayerId(n)
}
fn select(action: Action, target: Option<u8>, problem: Option<u32>) -> GroundCommand {
GroundCommand::SelectAction {
action,
target: target.map(seat),
problem,
}
}
#[test]
fn a_drag_resolves_to_the_command_it_offers() {
let legal = vec![
select(Action::Attack, Some(1), None),
select(Action::Solve, None, Some(7)),
select(Action::Ground, None, None),
];
assert_eq!(
resolve(
&PointerFact::new("action-solve", "problem-7"),
&legal,
seat(0)
),
Ok(1)
);
assert_eq!(
resolve(
&PointerFact::new("action-attack", "seat-1"),
&legal,
seat(0)
),
Ok(0)
);
assert_eq!(
resolve(&PointerFact::new("action-ground", "table"), &legal, seat(0)),
Ok(2)
);
}
/// The control that matters: a drag the aggregate did not offer must
/// resolve to nothing at all, not to a nearby or default command.
#[test]
fn a_drag_that_means_nothing_resolves_to_nothing() {
let legal = vec![select(Action::Solve, None, Some(7))];
// Right action, wrong problem.
assert!(resolve(
&PointerFact::new("action-solve", "problem-9"),
&legal,
seat(0)
)
.is_err());
// An action that is legal in general but not offered now.
assert!(resolve(
&PointerFact::new("action-attack", "seat-1"),
&legal,
seat(0)
)
.is_err());
// Elements that exist on the page but are not an affordance.
assert!(resolve(&PointerFact::new("seat-1", "seat-2"), &legal, seat(0)).is_err());
// Nonsense.
assert!(resolve(&PointerFact::new("", ""), &legal, seat(0)).is_err());
}
/// A command index arriving from the page is still bounds-checked
/// against the list the aggregate offered.
#[test]
fn a_button_index_is_bounds_checked() {
let legal = vec![
GroundCommand::RespondToSupport {
response: SupportResponse::AcceptBond,
},
GroundCommand::ChooseGroundMode {
mode: GroundMode::Gr,
choice: None,
},
];
assert_eq!(
resolve(&PointerFact::new("cmd-1", "cmd-1"), &legal, seat(0)),
Ok(1)
);
assert!(resolve(&PointerFact::new("cmd-2", "cmd-2"), &legal, seat(0)).is_err());
assert!(resolve(&PointerFact::new("cmd-x", "cmd-x"), &legal, seat(0)).is_err());
// A press that starts on one button and ends on another is not a
// press, and must not be read as one.
assert!(resolve(&PointerFact::new("cmd-0", "cmd-1"), &legal, seat(0)).is_err());
}
/// If two legal commands offered the same drag, picking either would
/// be a coin flip the player cannot see. Refuse instead.
#[test]
fn an_ambiguous_affordance_is_refused_rather_than_guessed() {
let legal = vec![
select(Action::Solve, None, Some(7)),
select(Action::Solve, None, Some(7)),
];
let e = resolve(
&PointerFact::new("action-solve", "problem-7"),
&legal,
seat(0),
)
.unwrap_err();
assert!(e.contains("more than one"), "{e}");
}
#[test]
fn pointer_facts_parse_and_bad_ones_are_refused() {
assert_eq!(
PointerFact::parse("down=action-solve&up=problem-7"),
Ok(PointerFact::new("action-solve", "problem-7"))
);
// A field carrying game meaning is exactly what control 5 forbids.
assert!(PointerFact::parse("down=a&up=b&command=Solve").is_err());
assert!(PointerFact::parse("down=a").is_err());
assert!(PointerFact::parse("down=&up=b").is_err());
assert!(PointerFact::parse("").is_err());
}
}

View file

@ -0,0 +1,333 @@
//! `cb-render-html` — stage 1's renderer (ADR-0007).
//!
//! Emits HTML with inline SVG and one small piece of inline JavaScript;
//! the browser draws it. **Marginal AM-4a cost: zero** — this crate has no
//! third-party dependencies at all, and adding one requires an argument
//! against ADR-0007 §Decision 3.
//!
//! ## What this is not
//!
//! It is **not** `cb-render-api`, and there is no `cb-render-null`.
//! ADR-0007 Decision 2 withdrew the port: a capability port designed
//! against one implementation that emits whole documents acquires a
//! document's shape, and stage 2's `wgpu` renderer would find it
//! unimplementable. INTENT's rule is the one that binds —
//!
//! > *No concept becomes canonical merely because it looks general. It
//! > becomes canonical after surviving a second concrete use.*
//!
//! — so this renders against the existing [`cb_game_runtime::Project`]
//! trait, which is a real interface with real implementations, and the
//! port is declared at stage 2 when there are two.
//!
//! ## The controls, and why they exist
//!
//! CB-WP-0011 established that a renderer's defect class is **silent
//! omission**: every assertion a renderer test naturally makes is
//! satisfied by a renderer showing a third of the state. This crate moves
//! the interactive half of stage 1 into a language `cargo test`,
//! `clippy` and `M-D1-MUT` cannot reach, so two controls carry that
//! finding across the boundary:
//!
//! * [`input`] — **JavaScript may not construct commands.** The page
//! reports raw pointer facts; Rust decides what they mean, against the
//! legal list the aggregate already offered.
//! * the coverage gate below — asserts over the **parsed emitted
//! document**, not over the Rust that emits it. Asserting over the
//! emitting code would reproduce CB-WP-0011's original defect one layer
//! up.
//!
//! And [`serve`] carries the four that make a loopback listener safe to
//! have, of which the load-bearing one is a test that a token-less request
//! is refused.
pub mod doc;
pub mod input;
pub mod serve;
pub use doc::{document, text_of};
pub use input::{resolve, PointerFact};
pub use serve::{Guard, Refusal, Request};
#[cfg(test)]
mod coverage {
//! **ADR-0007 control 6.** The HTML counterpart of `cb-play`'s
//! `every_view_field_is_classified`.
//!
//! Same shape as CB-WP-0011's gate, one layer further out: walk the
//! serialized `GroundView` for every leaf *path*, and require each to
//! be either rendered — with a token that must appear in the **parsed
//! document** — or omitted with a stated reason. A new field on
//! `GroundView` that is in neither list fails the build.
//!
//! Paths, not keys: `problem` occurs under a DARVO target, a GROUND
//! choice and a Selection, and a key-set walk would let one vouch for
//! the other two.
use cb_kernel::PlayerId;
use games_ground::view::GroundView;
use crate::doc::{document, text_of};
/// Every leaf path in the serialized view.
fn paths(v: &serde_json::Value, prefix: &str, out: &mut Vec<String>) {
match v {
serde_json::Value::Object(m) if !m.is_empty() => {
for (k, val) in m {
let p = if prefix.is_empty() {
k.clone()
} else {
format!("{prefix}.{k}")
};
paths(val, &p, out);
}
}
serde_json::Value::Array(a) if !a.is_empty() => {
for item in a {
paths(item, &format!("{prefix}.*"), out);
}
}
_ => out.push(prefix.to_string()),
}
}
/// Collapse map keys to `*` so the classification is over fields, not
/// over whichever seats and priorities the fixture happens to hold.
fn normalize(path: &str) -> String {
const MAPS: &[&str] = &[
"players",
"relations",
"problems",
"focus",
"selections",
"ground_modes",
"ground_choices",
"support_responses",
"darvo_targets",
"personal",
];
let mut parts: Vec<String> = Vec::new();
let mut prev_is_map = false;
for seg in path.split('.') {
if prev_is_map && seg != "*" {
parts.push("*".to_string());
} else {
parts.push(seg.to_string());
}
prev_is_map = MAPS.contains(&seg);
}
parts.join(".")
}
/// `(leaf path, a token the parsed document must contain)`.
const RENDERED: &[(&str, &str)] = &[
("round", "round 3"),
("lead", "lead P2"),
("step", "step Select"),
("mode", "scoring BondedCoalitions"),
("viewer", "viewing as P1"),
("solution_deck_len", "17 remaining"),
("solution_discard.*.suit", "discard Repair Change"),
("players.*.stress", "stress 5"),
("players.*.protection", "protect 2"),
("players.*.darvo", "darvo Reverse"),
("players.*.freedom_ready", "READY"),
("players.*.freedom_gate_lifted", "gate lifted"),
("players.*.blame_from.*", "blamed by P3"),
// The empty case is a leaf path of its own, and renders as an
// explicit absence rather than as nothing at all.
("players.*.blame_from", "blamed by none"),
("players.*.hand.*.suit", "hand Clarify Boundary"),
("players.*.hand_size", "cards)"),
("relations.*", "Rivalry"),
("problems.*.state", "face down"),
("problems.*.suit", "Change 6"),
("problems.*.value", "Change 6"),
("problems.*.denied", "denied"),
("problems.*.claimed_by", "claimed by P2"),
("problems.*.protected_this_round", "protected"),
("focus.*", "\u{2192}P3"),
("selections.*.state", "face down"),
("selections.*.action", "action: Attack"),
("selections.*.target", "target: Some(PlayerId(1))"),
("selections.*.problem", "problem: Some(7)"),
("ground_modes.*", "ground mode Gr"),
("ground_choices.*.choice", "ProtectProblem"),
("ground_choices.*.problem", "problem: 7 }"),
("support_responses.*", "support AcceptBond"),
("darvo_targets.*.problem", "problem: Some(1)"),
("darvo_targets.*.player", "player: Some(PlayerId(1))"),
("outcome.total", "total 9"),
("outcome.threshold", "of 12"),
("outcome.group_success", "failure"),
("outcome.personal.*", "P1 +3"),
("outcome.coalitions.*.members.*", "members: [PlayerId(1)"),
("outcome.coalitions.*.score", "score: 4"),
("outcome.mastery", "mastery +1"),
("outcome.winners.*", "winners P1"),
];
/// Deliberate omissions, each with a reason.
const OMITTED: &[(&str, &str)] = &[(
"players.*.hand",
"null for a non-viewer seat; the absence is rendered as a count",
)];
fn fixture() -> GroundView {
crate::testfix::view(Some(PlayerId(0)))
}
fn rendered_document(view: &GroundView) -> String {
text_of(&document(
view,
&[],
"/command?t=x",
Some(PlayerId(0)),
false,
))
}
#[test]
fn every_view_field_is_classified_in_the_emitted_document() {
let view = fixture();
let json = serde_json::to_value(&view).expect("view serializes");
let mut raw = Vec::new();
paths(&json, "", &mut raw);
let all: std::collections::BTreeSet<String> = raw.iter().map(|p| normalize(p)).collect();
// EXPECT-VACUOUS floor. A coverage test over zero paths passes
// trivially, and that is precisely how this check would rot.
assert!(
all.len() >= 30,
"the walk found {} leaf path(s) — it is not walking the view",
all.len()
);
let claimed: std::collections::BTreeSet<&str> = RENDERED
.iter()
.map(|(p, _)| *p)
.chain(OMITTED.iter().map(|(p, _)| *p))
.collect();
let unclassified: Vec<&String> = all
.iter()
.filter(|p| !claimed.contains(p.as_str()))
.collect();
assert!(
unclassified.is_empty(),
"unclassified path(s) in GroundView: {unclassified:?} \
add each to RENDERED with a token, or to OMITTED with a reason"
);
let stale: Vec<&&str> = claimed.iter().filter(|p| !all.contains(**p)).collect();
assert!(
stale.is_empty(),
"classified path(s) no longer exist in GroundView: {stale:?}"
);
// The half that makes it a rendering gate rather than a bookkeeping
// one: the token must be in the *parsed document*.
let text = rendered_document(&view);
let missing: Vec<&str> = RENDERED
.iter()
.filter(|(_, tok)| !text.contains(tok))
.map(|(p, _)| *p)
.collect();
assert!(
missing.is_empty(),
"claimed rendered, but absent from the emitted document: {missing:?}"
);
}
/// The parse must be a parse. If `text_of` returned the raw source, a
/// token hiding in a comment, a style rule or the script would count
/// as rendered — which is the loophole control 6 exists to close.
#[test]
fn the_parse_does_not_see_script_or_style_contents() {
let html = "<style>.x{content:'STYLETOKEN'}</style><p id=\"pid\">seen</p>\
<script>var s='SCRIPTTOKEN';</script>";
let text = text_of(html);
assert!(text.contains("seen"));
assert!(text.contains("pid"), "element ids are addressable surface");
assert!(
!text.contains("STYLETOKEN"),
"style contents leaked into the text"
);
assert!(
!text.contains("SCRIPTTOKEN"),
"script contents leaked into the text"
);
}
/// Control 5, asserted at the document level: the emitted JavaScript
/// must contain no game vocabulary. If a rule ever needs to live in
/// the page, ADR-0007 says revisit the decision, not widen this.
#[test]
fn the_emitted_javascript_contains_no_game_vocabulary() {
let doc = document(&fixture(), &[], "/command?t=x", Some(PlayerId(0)), false);
let script = doc
.split_once("<script>")
.and_then(|(_, r)| r.rsplit_once("</script>"))
.map(|(s, _)| s.to_string())
.expect("the document carries a script");
for word in [
"Investigate",
"Solve",
"Support",
"Attack",
"Ground",
"darvo",
"DARVO",
"stress",
"freedom",
"problem",
"coalition",
"reveal",
"resolve",
] {
assert!(
!script.contains(word),
"the emitted JavaScript mentions {word:?} — control 5 is breached"
);
}
// And it must still be doing its one job.
assert!(script.contains("pointerdown") && script.contains("pointerup"));
}
/// The renderer must not invent a hand it was never given.
///
/// Scoped deliberately: the *projection's* hiding rules are asserted
/// where they live, and re-asserting them here would be a duplicated
/// fact that drifts. What this checks is the renderer's own failure
/// mode — that `hand: None` renders as an absence, for every seat, and
/// that a spectator's document contains no open hand at all.
#[test]
fn the_renderer_never_invents_a_hand_it_was_not_given() {
for seat in [0u8, 1, 2] {
let view = crate::testfix::view(Some(PlayerId(seat)));
let text = rendered_document(&view);
let shown = text.matches("cards)").count();
assert_eq!(
shown,
1,
"P{} sees {shown} open hands in the document; it was given exactly one",
seat + 1
);
assert_eq!(
text.matches("card(s), hidden").count(),
2,
"the other two seats' hands must render as an absence with a count"
);
}
let text = rendered_document(&crate::testfix::view(None));
assert_eq!(
text.matches("cards)").count(),
0,
"a spectator document shows an open hand"
);
assert!(text.contains("a spectator (no hands)"));
}
}
#[cfg(test)]
mod testfix;

View file

@ -0,0 +1,323 @@
//! The loopback listener, and the controls that make it safe to have one.
//!
//! ADR-0007 Decision 5, controls 14. The threat the review named is not
//! hypothetical:
//!
//! > A loopback listener is reachable by any process on the machine **and
//! > by any web page the user visits**. The browser running the table is
//! > the same browser reading the internet — that is the whole premise of
//! > the option. An unauthenticated endpoint accepting `POST /command` and
//! > mutating authoritative game state is a remote-controlled game from
//! > any tab the user has open.
//!
//! So: a per-process token no page can guess, an `Origin` /
//! `Sec-Fetch-Site` check that rejects by default, an explicit
//! `127.0.0.1` bind, and — the control that makes the other three
//! evidence rather than claims — a test that a token-less request is
//! refused, with a mutation that turns it red.
//!
//! [`Guard::admit`] is a pure function of a parsed request, so all of that
//! is testable with no socket, no browser, and no timing.
use std::io::Read;
/// Why a request was refused. Each variant is a control that fired.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Refusal {
/// Control 1: no token at all.
MissingToken,
/// Control 1: a token, but not ours.
BadToken,
/// Control 2: the request came from somewhere else's page.
CrossSite(String),
/// Not a shape this endpoint serves.
BadRequest(String),
}
impl std::fmt::Display for Refusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Refusal::MissingToken => write!(f, "refused: no session token"),
Refusal::BadToken => write!(f, "refused: wrong session token"),
Refusal::CrossSite(o) => write!(f, "refused: cross-site request from {o}"),
Refusal::BadRequest(m) => write!(f, "refused: {m}"),
}
}
}
/// A parsed HTTP/1.1 request, reduced to the fields the controls need.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Request {
pub method: String,
pub path: String,
pub token: Option<String>,
pub origin: Option<String>,
pub sec_fetch_site: Option<String>,
pub body: String,
}
impl Request {
/// Parse a raw request. Deliberately strict: this is hand-rolled HTTP
/// on a socket a hostile page can reach, so anything unexpected is a
/// refusal rather than a best effort.
pub fn parse(raw: &str) -> Result<Self, Refusal> {
let (head, body) = raw.split_once("\r\n\r\n").unwrap_or((raw, ""));
let mut lines = head.split("\r\n");
let start = lines
.next()
.ok_or_else(|| Refusal::BadRequest("empty request".into()))?;
let mut parts = start.split(' ');
let method = parts
.next()
.ok_or_else(|| Refusal::BadRequest("no method".into()))?
.to_string();
let target = parts
.next()
.ok_or_else(|| Refusal::BadRequest("no target".into()))?;
let (path, query) = target.split_once('?').unwrap_or((target, ""));
let token = query.split('&').find_map(|kv| {
kv.strip_prefix("t=")
.filter(|v| !v.is_empty())
.map(str::to_string)
});
let mut req = Request {
method,
path: path.to_string(),
token,
body: body.to_string(),
..Default::default()
};
for line in lines {
if let Some((k, v)) = line.split_once(':') {
let v = v.trim().to_string();
match k.to_ascii_lowercase().as_str() {
"origin" => req.origin = Some(v),
"sec-fetch-site" => req.sec_fetch_site = Some(v),
_ => {}
}
}
}
Ok(req)
}
}
/// Holds the per-process token and applies controls 1 and 2.
#[derive(Debug, Clone)]
pub struct Guard {
token: String,
origin: String,
}
impl Guard {
pub fn new(token: impl Into<String>, port: u16) -> Self {
Self {
token: token.into(),
origin: format!("http://127.0.0.1:{port}"),
}
}
/// Mint an unguessable token.
///
/// `/dev/urandom` where it exists; otherwise `RandomState`, whose
/// per-process seed the OS randomises. The fallback is weaker and is
/// named rather than hidden — a token that silently degrades to
/// something predictable is worse than no token, because the controls
/// would still report themselves as passing.
pub fn mint(port: u16) -> Self {
let mut buf = [0u8; 24];
let strong = std::fs::File::open("/dev/urandom")
.and_then(|mut f| f.read_exact(&mut buf))
.is_ok();
if !strong {
use std::hash::{BuildHasher, Hasher};
let s = std::collections::hash_map::RandomState::new();
for chunk in buf.chunks_mut(8) {
let mut h = s.build_hasher();
h.write_usize(std::process::id() as usize);
h.write_u128(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0),
);
chunk.copy_from_slice(&h.finish().to_le_bytes()[..chunk.len()]);
}
}
let token: String = buf.iter().map(|b| format!("{b:02x}")).collect();
Self::new(token, port)
}
pub fn token(&self) -> &str {
&self.token
}
/// The URL the page is told to POST to. The page cannot mint one.
pub fn endpoint(&self) -> String {
format!("/command?t={}", self.token)
}
pub fn page_url(&self) -> String {
format!("{}/?t={}", self.origin, self.token)
}
/// Apply controls 1 and 2. Rejects by default.
pub fn admit(&self, req: &Request) -> Result<(), Refusal> {
// Control 2 first: a cross-site request should be refused before
// its token is even considered, so a leaked token is not enough on
// its own.
match req.sec_fetch_site.as_deref() {
// Sent by every modern browser. Anything but same-origin is
// not our page.
Some("same-origin") | Some("none") => {}
Some(other) => return Err(Refusal::CrossSite(other.to_string())),
None => {
// Older or non-browser clients omit it; fall back to
// Origin, and require it to be ours when present.
if let Some(o) = &req.origin {
if o != &self.origin {
return Err(Refusal::CrossSite(o.clone()));
}
}
}
}
if let Some(o) = &req.origin {
if o != &self.origin {
return Err(Refusal::CrossSite(o.clone()));
}
}
// Control 1.
match &req.token {
None => Err(Refusal::MissingToken),
Some(t) if constant_eq(t, &self.token) => Ok(()),
Some(_) => Err(Refusal::BadToken),
}
}
}
/// Compare without an early return on the first differing byte.
///
/// The endpoint is loopback and the token is 192 bits, so a timing oracle
/// is not the realistic attack here. It costs one line.
fn constant_eq(a: &str, b: &str) -> bool {
if a.len() != b.len() {
return false;
}
a.bytes()
.zip(b.bytes())
.fold(0u8, |acc, (x, y)| acc | (x ^ y))
== 0
}
/// Control 3: bind explicitly to loopback, never `0.0.0.0`.
pub fn bind(port: u16) -> std::io::Result<std::net::TcpListener> {
std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, port))
}
#[cfg(test)]
mod tests {
use super::*;
fn guard() -> Guard {
Guard::new("s3cr3t", 8731)
}
fn req(raw: &str) -> Request {
Request::parse(raw).expect("parses")
}
/// **ADR-0007 control 4.** The one that makes controls 13 evidence
/// rather than claims.
///
/// M-D1-MUT: delete the `None => Err(Refusal::MissingToken)` arm in
/// `admit` (return `Ok(())` instead) and this goes red with
/// `a token-less request was admitted`. Run 2026-08-02.
#[test]
fn a_token_less_request_is_refused() {
let g = guard();
let r = req("POST /command HTTP/1.1\r\nSec-Fetch-Site: same-origin\r\n\r\ndown=a&up=b");
assert_eq!(
g.admit(&r),
Err(Refusal::MissingToken),
"a token-less request was admitted"
);
}
#[test]
fn a_wrong_token_is_refused() {
let g = guard();
let r = req("POST /command?t=guess HTTP/1.1\r\nSec-Fetch-Site: same-origin\r\n\r\n");
assert_eq!(g.admit(&r), Err(Refusal::BadToken));
// A prefix of the real token must not be admitted either.
let r = req("POST /command?t=s3c HTTP/1.1\r\nSec-Fetch-Site: same-origin\r\n\r\n");
assert_eq!(g.admit(&r), Err(Refusal::BadToken));
}
#[test]
fn the_right_token_from_our_own_page_is_admitted() {
let g = guard();
let r = req(
"POST /command?t=s3cr3t HTTP/1.1\r\nSec-Fetch-Site: same-origin\r\n\
Origin: http://127.0.0.1:8731\r\n\r\ndown=a&up=b",
);
assert_eq!(g.admit(&r), Ok(()));
}
/// The attack the review named: a page on the open internet POSTs to
/// `127.0.0.1`. Even holding a leaked token, it must not be admitted.
#[test]
fn a_cross_site_request_is_refused_even_with_the_right_token() {
let g = guard();
let r = req(
"POST /command?t=s3cr3t HTTP/1.1\r\nSec-Fetch-Site: cross-site\r\n\
Origin: https://evil.example\r\n\r\ndown=a&up=b",
);
assert!(
matches!(g.admit(&r), Err(Refusal::CrossSite(_))),
"{:?}",
g.admit(&r)
);
// A client that omits Sec-Fetch-Site but sends a foreign Origin is
// the same attack with an older browser.
let r = req(
"POST /command?t=s3cr3t HTTP/1.1\r\nOrigin: https://evil.example\r\n\r\ndown=a&up=b",
);
assert!(matches!(g.admit(&r), Err(Refusal::CrossSite(_))));
}
#[test]
fn requests_parse_and_carry_what_the_controls_need() {
let r = req("POST /command?t=abc HTTP/1.1\r\nHost: 127.0.0.1:8731\r\n\
Origin: http://127.0.0.1:8731\r\nSec-Fetch-Site: same-origin\r\n\
Content-Length: 9\r\n\r\ndown=a&up=b");
assert_eq!(r.method, "POST");
assert_eq!(r.path, "/command");
assert_eq!(r.token.as_deref(), Some("abc"));
assert_eq!(r.origin.as_deref(), Some("http://127.0.0.1:8731"));
assert_eq!(r.sec_fetch_site.as_deref(), Some("same-origin"));
assert_eq!(r.body, "down=a&up=b");
// An empty t= is no token, not a token that happens to be empty.
let r = req("GET /?t= HTTP/1.1\r\n\r\n");
assert_eq!(r.token, None);
}
/// A minted token must not be a constant, and must not be short.
#[test]
fn a_minted_token_is_long_and_not_reused() {
let a = Guard::mint(1).token().to_string();
let b = Guard::mint(1).token().to_string();
assert_eq!(a.len(), 48, "token is {} hex chars", a.len());
assert_ne!(a, b, "two mints produced the same token");
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
}
/// Control 3, asserted rather than commented.
#[test]
fn the_listener_binds_loopback_only() {
let l = bind(0).expect("bind");
assert_eq!(l.local_addr().unwrap().ip().to_string(), "127.0.0.1");
}
}

View file

@ -0,0 +1,118 @@
//! A populated `GroundView`, built for the coverage gate.
//!
//! Mid-DARVO, mid-GROUND, scored — **not** a fresh deal. Most of the
//! fields this gate exists to cover are empty at deal time, and a coverage
//! test run against a state where the fields are absent is the same lie in
//! a different costume (CB-WP-0011 T01).
//!
//! Built directly rather than projected from a `GroundState`. The
//! projection's own hiding rules are already asserted by
//! `games_ground::view` and by `cb-play`'s
//! `the_inspector_never_widens_the_projection`; re-asserting them here
//! would be a duplicated fact that drifts. What *this* crate must not do
//! is invent a hand it was never given, and that is what the renderer test
//! checks — with `hand: None` supplied deliberately.
use std::collections::BTreeMap;
use cb_kernel::PlayerId;
use games_ground::view::{GroundView, OutcomeView, PlayerView, ProblemView, SelectionView};
use games_ground::{
Action, Coalition, DarvoStage, DarvoTarget, GroundChoice, GroundMode, Pair, Relation,
RoundStep, ScoringMode, Selection, SolutionCard, Suit, SupportResponse,
};
pub fn seats() -> (PlayerId, PlayerId, PlayerId) {
(PlayerId(0), PlayerId(1), PlayerId(2))
}
/// The fixture, seen by `viewer` (`None` for a spectator).
pub fn view(viewer: Option<PlayerId>) -> GroundView {
let (p1, p2, p3) = seats();
let card = |suit| SolutionCard { suit };
let mut players = BTreeMap::new();
for (id, stress, protection, darvo, ready, lifted, blame) in [
(p1, 5u8, 2u8, DarvoStage::Reverse, true, true, vec![p3]),
(p2, 1, 0, DarvoStage::Off, false, false, vec![]),
(p3, 0, 0, DarvoStage::Deny, true, false, vec![]),
] {
let own = viewer == Some(id);
players.insert(
id,
PlayerView {
stress,
freedom_ready: ready,
freedom_gate_lifted: lifted,
darvo,
protection,
blame_from: blame,
hand: own.then(|| vec![card(Suit::Clarify), card(Suit::Boundary)]),
hand_size: 2,
},
);
}
let mut problems = BTreeMap::new();
problems.insert(1, ProblemView::FaceDown);
problems.insert(
7,
ProblemView::FaceUp {
suit: Suit::Change,
value: 6,
denied: true,
claimed_by: Some(p2),
protected_this_round: true,
},
);
GroundView {
viewer,
round: 3,
lead: p2,
step: RoundStep::Select,
mode: ScoringMode::BondedCoalitions,
players,
relations: BTreeMap::from([
(Pair::new(p1, p2), Relation::Rivalry),
(Pair::new(p2, p3), Relation::Bond),
]),
problems,
focus: BTreeMap::from([(p1, p3)]),
selections: BTreeMap::from([
(
p1,
SelectionView::Shown(Selection {
action: Action::Attack,
target: Some(p2),
problem: Some(7),
}),
),
(p2, SelectionView::Hidden),
]),
ground_modes: BTreeMap::from([(p3, GroundMode::Gr)]),
ground_choices: BTreeMap::from([(p3, GroundChoice::ProtectProblem { problem: 7 })]),
support_responses: BTreeMap::from([(p2, SupportResponse::AcceptBond)]),
darvo_targets: BTreeMap::from([(
p3,
DarvoTarget {
problem: Some(1),
player: Some(p2),
},
)]),
solution_deck_len: 17,
solution_discard: vec![card(Suit::Repair), card(Suit::Change)],
outcome: Some(OutcomeView {
total: 9,
threshold: 12,
group_success: false,
personal: BTreeMap::from([(p1, 3), (p2, -1), (p3, 0)]),
coalitions: vec![Coalition {
members: vec![p2, p3],
score: 4,
}],
mastery: Some(1),
winners: vec![p1],
}),
}
}

View file

@ -1,5 +1,13 @@
# CB-RES-0006 — the render port, and what a 2D table costs
capability: render.table.2d
status: approved (v2, after adversarial review — v1 was **not approvable**,
with four conceded findings; see §2, §4, §5, §7)
tier: L (structural L — the declaration was made when this pass would
create a capability port; chaos d4=1 → no override. Review then removed
the port, and the tier was deliberately **not** re-derived: a tier that
changes because review shrank the work is a function of the outcome)
CB-WP-0012 T01. Step 1 of the inner loop. Measured 2026-08-02 at
`331e7e9`.

View file

@ -7,6 +7,7 @@ license-file.workspace = true
[dependencies]
cb-kernel.workspace = true
cb-events.workspace = true
cb-render-html.workspace = true
cb-game-runtime = { workspace = true, features = ["scenarios"] }
games-ground = { workspace = true, features = ["scenarios"] }
serde_json.workspace = true

View file

@ -0,0 +1,373 @@
//! Hot-seat play in a browser (ADR-0007, INTENT stage 1).
//!
//! One loopback listener, one tab, seats taking turns. The server blocks
//! inside [`Policy::choose`] until the page reports a pointer fact that
//! resolves to a legal command — so the game loop is the same
//! `games_ground::bot::play` loop the CLI and the bots run through, and a
//! browser seat is indistinguishable from a CLI seat to the driver.
//!
//! Every control in ADR-0007 §Decision 5 lives in `cb-render-html`; this
//! module is the socket and the turn-taking. It deliberately holds no
//! game logic beyond "which seat is being asked" — [`cb_render_html`]
//! decides what a drag means, and the aggregate decides whether the
//! result is legal.
use std::cell::RefCell;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::rc::Rc;
use cb_game_runtime::{Project, Viewer};
use cb_kernel::PlayerId;
use cb_render_html::{document, resolve, Guard, PointerFact, Request};
use games_ground::bot::{Choice, Policy};
use games_ground::{GroundCommand, GroundState};
/// The shared listener. One per process; every human seat borrows it.
pub struct Server {
listener: TcpListener,
guard: Guard,
log: RefCell<Vec<String>>,
}
impl Server {
pub fn bind(port: u16) -> Result<Self, String> {
let listener = cb_render_html::serve::bind(port).map_err(|e| format!("bind: {e}"))?;
let port = listener
.local_addr()
.map_err(|e| format!("local_addr: {e}"))?
.port();
Ok(Self {
listener,
guard: Guard::mint(port),
log: RefCell::new(Vec::new()),
})
}
/// The URL to open, token and all. Printed once at start; the page
/// cannot mint one for itself.
pub fn url(&self) -> String {
self.guard.page_url()
}
/// What was refused, for the evidence a session leaves behind.
pub fn refusals(&self) -> Vec<String> {
self.log.borrow().clone()
}
/// Serve until the page reports something that resolves to a move.
///
/// Requests that fail a control are answered and the loop continues —
/// a refused request must not end someone's game, and must not be
/// silently indistinguishable from one that did nothing.
pub fn next_choice(
&self,
state: &GroundState,
seat: PlayerId,
legal: &[GroundCommand],
may_pass: bool,
) -> Result<Choice, String> {
let view = state.project(Viewer::Player(seat));
loop {
let (mut stream, _) = self.listener.accept().map_err(|e| format!("accept: {e}"))?;
let raw = match read_request(&mut stream) {
Ok(r) => r,
Err(e) => {
respond(&mut stream, 400, "text/plain", &e);
continue;
}
};
let req = match Request::parse(&raw) {
Ok(r) => r,
Err(refusal) => {
self.log.borrow_mut().push(refusal.to_string());
respond(&mut stream, 400, "text/plain", &refusal.to_string());
continue;
}
};
if let Err(refusal) = self.guard.admit(&req) {
self.log.borrow_mut().push(refusal.to_string());
respond(&mut stream, 403, "text/plain", &refusal.to_string());
continue;
}
match (req.method.as_str(), req.path.as_str()) {
("GET", "/") => {
let page = document(&view, legal, &self.guard.endpoint(), Some(seat), may_pass);
respond(&mut stream, 200, "text/html; charset=utf-8", &page);
}
("POST", "/command") => {
let fact = match PointerFact::parse(&req.body) {
Ok(f) => f,
Err(e) => {
respond(&mut stream, 400, "text/plain", &e);
continue;
}
};
if may_pass && fact.down == "pass" && fact.up == "pass" {
respond(&mut stream, 200, "text/plain", "ok: passed");
return Ok(Choice::Pass);
}
match resolve(&fact, legal, seat) {
Ok(i) => {
respond(&mut stream, 200, "text/plain", "ok");
return Ok(Choice::Command(i));
}
// Not an error: the player dragged somewhere that
// means nothing. Say so and keep the turn.
Err(why) => respond(&mut stream, 200, "text/plain", &why),
}
}
_ => respond(&mut stream, 404, "text/plain", "no such thing here"),
}
}
}
}
/// One seat's view of the shared server.
pub struct SeatPolicy {
server: Rc<Server>,
failure: Rc<RefCell<Option<String>>>,
}
impl SeatPolicy {
pub fn new(server: Rc<Server>, failure: Rc<RefCell<Option<String>>>) -> Self {
Self { server, failure }
}
}
impl Policy for SeatPolicy {
fn name(&self) -> &'static str {
"browser"
}
fn choose(
&mut self,
state: &GroundState,
seat: PlayerId,
legal: &[GroundCommand],
may_pass: bool,
) -> Choice {
match self.server.next_choice(state, seat, legal, may_pass) {
Ok(c) => c,
Err(e) => {
// Same shape as the CLI's out-of-input path: record the
// real reason and return something the driver will
// reject, rather than inventing a move.
*self.failure.borrow_mut() = Some(e);
Choice::Command(usize::MAX)
}
}
}
}
fn read_request(stream: &mut TcpStream) -> Result<String, String> {
let mut buf = Vec::new();
let mut chunk = [0u8; 1024];
loop {
let n = stream.read(&mut chunk).map_err(|e| format!("read: {e}"))?;
if n == 0 {
break;
}
buf.extend_from_slice(&chunk[..n]);
// Once the head is in, read exactly the declared body and stop —
// otherwise a keep-alive connection blocks here forever.
if let Some(head_end) = find(&buf, b"\r\n\r\n") {
let head = String::from_utf8_lossy(&buf[..head_end]).to_string();
let want: usize = head
.split("\r\n")
.find_map(|l| {
let (k, v) = l.split_once(':')?;
k.eq_ignore_ascii_case("content-length")
.then(|| v.trim().parse().ok())?
})
.unwrap_or(0);
if buf.len() >= head_end + 4 + want {
break;
}
}
if buf.len() > 64 * 1024 {
return Err("request too large".to_string());
}
}
String::from_utf8(buf).map_err(|_| "request is not UTF-8".to_string())
}
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).position(|w| w == needle)
}
fn respond(stream: &mut TcpStream, status: u16, content_type: &str, body: &str) {
let reason = match status {
200 => "OK",
400 => "Bad Request",
403 => "Forbidden",
_ => "Not Found",
};
let head = format!(
"HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\n\
Content-Length: {}\r\nConnection: close\r\n\
X-Content-Type-Options: nosniff\r\n\r\n",
body.len()
);
let _ = stream.write_all(head.as_bytes());
let _ = stream.write_all(body.as_bytes());
let _ = stream.flush();
}
#[cfg(test)]
mod tests {
use super::*;
use games_ground::Action;
/// One client thread issuing requests **in order**, each on its own
/// `Connection: close` socket and each fully read before the next is
/// sent. Two concurrent clients would race the server's `accept`, and
/// a test whose outcome depends on which socket wins is worse than no
/// test.
fn converse(port: u16, requests: Vec<String>) -> std::thread::JoinHandle<Vec<String>> {
std::thread::spawn(move || {
requests
.into_iter()
.map(|raw| {
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect");
s.write_all(raw.as_bytes()).expect("write");
let mut out = String::new();
let _ = s.read_to_string(&mut out);
out
})
.collect()
})
}
fn get(token: &str) -> String {
format!(
"GET /?t={token} HTTP/1.1\r\nHost: 127.0.0.1\r\n\
Sec-Fetch-Site: same-origin\r\n\r\n"
)
}
fn post(token: &str, body: &str) -> String {
format!(
"POST /command?t={token} HTTP/1.1\r\nHost: 127.0.0.1\r\n\
Sec-Fetch-Site: same-origin\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
)
}
fn ground_only() -> Vec<GroundCommand> {
vec![GroundCommand::SelectAction {
action: Action::Ground,
target: None,
problem: None,
}]
}
fn state() -> GroundState {
<GroundState as cb_game_runtime::ScenarioGame>::setup(
&cb_game_runtime::Setup {
players: 3,
preset: "standard-3p".to_string(),
patch: Default::default(),
},
7,
)
.expect("setup")
}
/// The whole loop, with a real socket and no browser: the page is
/// served, a pointer fact is posted, and the seat's choice comes back.
#[test]
fn a_drag_posted_over_the_socket_becomes_a_choice() {
let server = Server::bind(0).expect("bind");
let port = server.listener.local_addr().unwrap().port();
let token = server.url().rsplit("t=").next().unwrap().to_string();
let client = converse(
port,
vec![get(&token), post(&token, "down=action-ground&up=table")],
);
let choice = server
.next_choice(&state(), PlayerId(0), &ground_only(), false)
.expect("a choice");
assert_eq!(choice, Choice::Command(0));
let replies = client.join().expect("client thread");
assert!(replies[0].contains("200 OK"), "{}", replies[0]);
assert!(replies[0].contains("GROUND"), "the page was not the table");
assert!(
replies[0].contains("id=\"action-ground\""),
"the offered action was not on the page"
);
assert!(replies[1].contains("200 OK"));
assert!(server.refusals().is_empty());
}
/// ADR-0007 control 1, end to end rather than in the unit: a page
/// without the token gets nothing, and the seat keeps its turn.
///
/// M-D1-MUT: make `admit` return `Ok(())` unconditionally and this
/// goes red — the token-less request is served the table. Run
/// 2026-08-02.
#[test]
fn a_token_less_request_over_the_socket_is_refused_and_the_turn_continues() {
let server = Server::bind(0).expect("bind");
let port = server.listener.local_addr().unwrap().port();
let token = server.url().rsplit("t=").next().unwrap().to_string();
let client = converse(
port,
vec![
"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nSec-Fetch-Site: same-origin\r\n\r\n"
.to_string(),
post(&token, "down=action-ground&up=table"),
],
);
// The turn survives the refusal and is decided by the good request.
let choice = server
.next_choice(&state(), PlayerId(0), &ground_only(), false)
.expect("a choice");
assert_eq!(choice, Choice::Command(0));
let replies = client.join().expect("client thread");
assert!(replies[0].contains("403 Forbidden"), "{}", replies[0]);
assert!(
!replies[0].contains("GROUND"),
"the table leaked to a token-less request"
);
assert!(replies[1].contains("200 OK"));
assert_eq!(
server.refusals(),
vec!["refused: no session token".to_string()]
);
}
/// A drag that means nothing must not end the turn, and must say so.
#[test]
fn a_meaningless_drag_keeps_the_turn() {
let server = Server::bind(0).expect("bind");
let port = server.listener.local_addr().unwrap().port();
let token = server.url().rsplit("t=").next().unwrap().to_string();
let client = converse(
port,
vec![
post(&token, "down=seat-1&up=seat-2"),
post(&token, "down=action-ground&up=table"),
],
);
let choice = server
.next_choice(&state(), PlayerId(0), &ground_only(), false)
.expect("a choice");
assert_eq!(choice, Choice::Command(0));
let replies = client.join().expect("client thread");
assert!(replies[0].contains("200 OK"));
assert!(
replies[0].contains("not a legal move"),
"a meaningless drag must be told it meant nothing: {}",
replies[0]
);
assert!(replies[1].contains("200 OK"));
}
}

View file

@ -761,6 +761,7 @@ mod tests {
bot: "greedy".into(),
replay_dir: Some(dir.clone()),
record: Some(dir.join("session.yaml")),
serve: None,
};
let mut sink: Vec<u8> = Vec::new();
let summary =

View file

@ -9,6 +9,7 @@
//! success — a session that ends without an outcome is a failure, and
//! saying otherwise is how a stalled game passes for a played one.
mod hotseat;
mod inspect;
mod table;
@ -16,7 +17,7 @@ use table::Config;
const USAGE: &str = "\
usage: cb-play [--seed N] [--players N] [--seat N]... [--bot greedy|random]
[--replay DIR] [--all-bots] [--record FILE]
[--replay DIR] [--all-bots] [--record FILE] [--serve PORT]
cb-play --inspect PATH [--as SEAT|spectator]
play:
@ -27,6 +28,10 @@ play:
--bot KIND policy for every other seat: greedy (default) or random
--replay DIR write a .cbreplay bundle of the finished game to DIR
--record FILE write the finished game as a scenario YAML
--serve PORT play human seats in a browser on 127.0.0.1:PORT instead
of on the terminal; 0 lets the OS pick. Prints a URL
carrying a per-process token without it the page is
refused (ADR-0007).
inspect a recorded game a .cbreplay bundle directory or a scenario YAML:
--inspect PATH render the table after every recorded step
@ -104,6 +109,15 @@ fn parse_args(argv: &[String]) -> Result<Mode, String> {
config.replay_dir = Some(value(i, argv, flag)?.into());
i += 2;
}
"--serve" => {
play_flags.push(flag.into());
config.serve = Some(
value(i, argv, flag)?
.parse()
.map_err(|e| format!("--serve: {e}"))?,
);
i += 2;
}
"--all-bots" => {
all_bots = true;
play_flags.push(flag.into());
@ -245,6 +259,7 @@ mod tests {
bot: "greedy".into(),
replay_dir: None,
record: None,
serve: None,
};
let script = "0\n".repeat(400);
let mut out: Vec<u8> = Vec::new();
@ -283,6 +298,7 @@ mod tests {
bot: "greedy".into(),
replay_dir: None,
record: None,
serve: None,
};
let mut out: Vec<u8> = Vec::new();
table::play(&config, "0\n".repeat(200).as_bytes(), &mut out).expect("game");
@ -344,6 +360,7 @@ mod tests {
bot: "random".into(),
replay_dir: None,
record: None,
serve: None,
};
let mut out: Vec<u8> = Vec::new();
let summary = table::play(&config, "".as_bytes(), &mut out).expect("bot game");
@ -403,6 +420,7 @@ mod tests {
bot: "greedy".into(),
replay_dir: Some(dir.clone()),
record: Some(dir.join("session.yaml")),
serve: None,
};
let mut out: Vec<u8> = Vec::new();
let summary = table::play(&config, "".as_bytes(), &mut out).expect("game");

View file

@ -32,6 +32,9 @@ pub struct Config {
/// Where to write the finished game as a scenario file. A session
/// somebody played becomes a regression test.
pub record: Option<std::path::PathBuf>,
/// Serve human seats in a browser instead of on the terminal
/// (ADR-0007). `Some(0)` lets the OS pick the port.
pub serve: Option<u16>,
}
impl Default for Config {
@ -43,6 +46,7 @@ impl Default for Config {
bot: "greedy".into(),
replay_dir: None,
record: None,
serve: None,
}
}
}
@ -213,10 +217,28 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>(
// Human seats and bot seats fill one policy vector; the driver does
// not know which is which, which is the point.
let failure: Failure = Default::default();
// ADR-0007: a browser seat and a CLI seat are both just a Policy, so
// the driver cannot tell them apart — which is the property that lets
// a browser game replay as a scenario like any other.
let server = match config.serve {
Some(port) => {
let s = std::rc::Rc::new(crate::hotseat::Server::bind(port)?);
let _ = writeln!(out.borrow_mut(), " open {}", s.url());
let _ = out.borrow_mut().flush();
Some(s)
}
None => None,
};
let mut policies: Vec<Box<dyn Policy + 'a>> = Vec::new();
for seat in 0..config.players {
if config.human_seats.contains(&seat) {
policies.push(Box::new(HumanPolicy::new(input, out, failure.clone())));
match &server {
Some(s) => policies.push(Box::new(crate::hotseat::SeatPolicy::new(
s.clone(),
failure.clone(),
))),
None => policies.push(Box::new(HumanPolicy::new(input, out, failure.clone()))),
}
} else {
policies.push(bot_policy(&config.bot, config.seed + u64::from(seat))?);
}
@ -248,6 +270,20 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>(
game.commands,
&end_hash[..12]
);
// ADR-0007 control 1 leaves evidence rather than only a 403: a
// session that was probed says so, so a player finds out from the
// transcript rather than from nothing at all.
if let Some(s) = &server {
let refusals = s.refusals();
if refusals.is_empty() {
let _ = writeln!(w, " no requests were refused this session");
} else {
let _ = writeln!(w, " {} request(s) refused:", refusals.len());
for r in &refusals {
let _ = writeln!(w, " {r}");
}
}
}
let _ = w.flush();
drop(w);