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
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:
@@ -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)) {
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user