feat(sidecar): protocol 3 — the first route on this bridge that is not a GET
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m30s

`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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
2026-09-17 07:36:45 -05:00
parent 3aabd2befe
commit fd6efd9a2c
2 changed files with 88 additions and 2 deletions

View File

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

View File

@@ -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<AppState>) -> 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<AppState>, Json(body): Json<LinkConfirm>) -> 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!(