From fd6efd9a2cf47e208227e64870a82c82bb2b2a03 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 17 Sep 2026 07:36:45 -0500 Subject: [PATCH] =?UTF-8?q?feat(sidecar):=20protocol=203=20=E2=80=94=20the?= =?UTF-8?q?=20first=20route=20on=20this=20bridge=20that=20is=20not=20a=20G?= =?UTF-8?q?ET?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `POST /link/confirm` forwards a one-time link code to the plugin and hands back what it says. Everything before it was the website reading what the game had already told us; this is the website asking the game a question only the game can answer. **It is still a forwarder and holds no authority of its own.** It does not mint codes, does not store them, does not know what a website user is, and cannot tell a good code from a bad one. Putting the code table here would give the sidecar a credential and an opinion, and D2 and the bridge principles say it has neither. **A refused code is a 200.** `link.ok` and `link.error` are both answers, and the website has to tell "that code is wrong" from "the game never replied" to say the right thing to a player. The two transport failures keep the codes `respond` already gives them: 503 when the game is down, 504 when it is up and silent. `usable_code` is split out and tested because its two rejections are easy to get subtly wrong. It trims BEFORE it measures: a player pasting a code out of game chat brings whitespace with it, a field of nothing but spaces is empty rather than four characters long, and the length bound belongs on the trimmed value. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- sidecar/src/main.rs | 2 +- sidecar/src/web.rs | 88 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index acd9163..c5be3f6 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -73,7 +73,7 @@ use tracing_subscriber::EnvFilter; /// /// `docs/rust-link/PROTOCOL.md` §8 is the specification; this constant is one of its four /// declaration sites. -pub const PROTOCOL_VERSION: u32 = 2; +pub const PROTOCOL_VERSION: u32 = 3; fn main() -> anyhow::Result<()> { let args = match cli::parse(std::env::args().skip(1)) { diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index 969919f..3e72abb 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -25,7 +25,7 @@ use axum::{ http::{HeaderValue, StatusCode}, middleware::{self, Next}, response::{IntoResponse, Response}, - routing::get, + routing::{get, post}, Json, Router, }; use serde::Deserialize; @@ -76,6 +76,14 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { .route("/feed", get(feed)) // Live: a correlated round trip to the plugin. Fails when the game is down, by design. .route("/status", get(status)) + // **The first route on this sidecar that is not a GET** (protocol 3). Everything before it + // was the website reading what the game had already said; this is the website asking the + // game a question only the game can answer. + // + // It is still a forwarder and holds no authority of its own: it does not mint codes, does + // not store them, does not know what a website user is, and cannot tell a good code from a + // bad one. It moves one string to the plugin and one reply back. + .route("/link/confirm", post(link_confirm)) .route_layer(middleware::from_fn_with_state(state.clone(), gate)); let app = Router::new() @@ -255,6 +263,62 @@ async fn status(State(st): State) -> Response { respond(st.rpc.call(&st.game, command, &req_id).await) } +// ---- link confirm ---- + +/// What the website sends to redeem a link code. +#[derive(serde::Deserialize)] +struct LinkConfirm { + code: String, +} + +/// Longer than any code the plugin mints, short enough that nothing else gets forwarded. +const MAX_CODE_LEN: usize = 32; + +/// Redeem a one-time link code the plugin minted for a player who ran `/link`. +/// +/// **The sidecar validates nothing here beyond the shape**, deliberately. Only the game server +/// holds the pending codes, and only it knows which Steam id a code belongs to — so the whole of +/// this function is "forward it, hand back what came out". Putting the code table here instead +/// would give the sidecar a credential and an opinion, and it is designed to have neither. +/// +/// The reply is whatever the plugin said: `link.ok` carrying a `steamId`, or `link.error` carrying +/// a reason. **Both are 200s.** A refused code is an answer, not a transport failure, and the +/// website needs to tell "that code is wrong" from "the game never replied" to say the right thing +/// to a player. The two transport failures keep their own codes through [`respond`] — `503` when +/// the game is down, `504` when it is up and silent. +async fn link_confirm(State(st): State, Json(body): Json) -> Response { + let code = match usable_code(&body.code) { + Some(c) => c, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "a link code is required"})), + ) + .into_response() + } + }; + + let req_id = st.rpc.next_req_id(); + let command = json!({ "cmd": "link.confirm", "reqId": req_id, "code": code }); + respond(st.rpc.call(&st.game, command, &req_id).await) +} + +/// The submitted code, trimmed, or `None` when there is nothing worth forwarding. +/// +/// Split out so it can be tested without an [`AppState`], and because the two rejections are +/// easy to get subtly wrong. **Trim first, then measure**: a player pasting a code out of the +/// game chat brings whitespace with it, and a field of nothing but spaces is empty rather than +/// four characters long. The length bound is on the trimmed value for the same reason. +fn usable_code(raw: &str) -> Option<&str> { + let code = raw.trim(); + + if code.is_empty() || code.len() > MAX_CODE_LEN { + return None; + } + + Some(code) +} + /// Maps an RPC outcome onto a status code. /// /// The two failures are deliberately different codes. `NoPlugin` is `503`: the game is down and the @@ -466,6 +530,28 @@ mod tests { /// The two RPC failures must not collapse into one code: "the game is down" and "the game is up /// and slow" have different fixes, and the website's client branches on the status. + #[test] + fn a_submitted_code_is_trimmed_before_it_is_judged() { + // A player pastes out of game chat and brings whitespace with them. Trimming after the + // length check would forward the padding; checking emptiness before trimming would accept + // a field of spaces and send the plugin nothing to look up. + assert_eq!(usable_code(" ABC123 "), Some("ABC123")); + assert_eq!(usable_code("ABC123"), Some("ABC123")); + assert_eq!(usable_code(" "), None); + assert_eq!(usable_code(""), None); + } + + #[test] + fn a_code_longer_than_any_the_plugin_mints_is_refused_here() { + // The plugin's alphabet is six characters. A caller sending a megabyte does not have a + // code, and the game link should never carry the attempt. + let long = "A".repeat(MAX_CODE_LEN + 1); + assert_eq!(usable_code(&long), None); + + let at_bound = "A".repeat(MAX_CODE_LEN); + assert_eq!(usable_code(&at_bound), Some(at_bound.as_str())); + } + #[test] fn rpc_failures_map_to_distinct_codes() { assert_eq!( -- 2.49.1