From 8f5440089c2e8b82c294138818c936792b7f3773 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 21 Sep 2026 18:27:54 -0500 Subject: [PATCH] =?UTF-8?q?feat(sidecar):=20protocol=204=20=E2=80=94=20two?= =?UTF-8?q?=20routes,=20and=20no=20opinion=20about=20either?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET /permissions/catalogue` and `POST /permissions/sync` (R2). The first pair that exists so the website can WRITE to the game, and the smallest change in this repository that a protocol bump has ever needed. That is the dumb-forwarder property paying for itself a second time: protocol 4 adds the largest command on the bridge and touches neither the store nor the feed. The sidecar does not know what a group is, which names are managed, or what the plugin will do with any of it. It puts an envelope on an object and forwards it. **The envelope is this side's.** `cmd` and `reqId` are inserted AFTER the caller's object is taken, so they overwrite anything a caller put there — no request can arrive claiming to be a different command, or aimed at a correlation id somebody else is waiting on. **A command larger than the game link's line cap is refused here**, with the limit in the body. Forwarded, it would be discarded silently by both ends (§3.1 — an over-long line is dropped, not buffered) and present to the caller as a `504`, which sends an operator to look at a game server that is working perfectly. Two tests, and both assert a refusal rather than a happy path. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM --- sidecar/src/main.rs | 25 ++++++++-- sidecar/src/web.rs | 108 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 3 deletions(-) diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index c5be3f6..fba16c5 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -71,9 +71,28 @@ use tracing_subscriber::EnvFilter; /// * **`GET /feed`** is the ingest cursor, oldest-first, separate from `/events` so that no /// caller can get the other ordering by forgetting a parameter. /// -/// `docs/rust-link/PROTOCOL.md` §8 is the specification; this constant is one of its four -/// declaration sites. -pub const PROTOCOL_VERSION: u32 = 3; +/// # Protocol 3 — identity +/// +/// `POST /link/confirm`, the first route here that is not a GET, and the first message on this +/// bridge the WEBSITE originates. It forwards a six-character code to the plugin and hands back +/// what the plugin said. The codes live in the game's memory and nowhere else: putting the table +/// here would give this process a credential and an opinion, and it is designed to have neither. +/// +/// # Protocol 4 — the permission mirror +/// +/// `GET /permissions/catalogue` and `POST /permissions/sync` (R2). The first command that WRITES +/// to the game: the website sends the whole permission set it authors for this server and the +/// plugin reconciles the store against it. +/// +/// Nothing about that shape is visible in this process beyond two routes, and that is the +/// dumb-forwarder property paying for itself a second time — protocol 4 adds the largest command +/// on the bridge and touches neither the store nor the feed. The one thing this side owns is the +/// envelope: `cmd` and `reqId` are written over whatever the caller sent, and a command that would +/// not fit on the game link is refused here rather than discarded silently at the other end. +/// +/// `docs/rust-link/PROTOCOL.md` is the specification — §8 the read path, §9 identity, §10 the +/// mirror; this constant is one of its four declaration sites. +pub const PROTOCOL_VERSION: u32 = 4; 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 3e72abb..ddca450 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -84,6 +84,17 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { // 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)) + // Protocol 4, and the first pair that exists so the website can WRITE to the game (R2). + // + // The catalogue is a live read of what this server's loaded plugins have registered — the + // option source the website's authoring form is built from, so a grant can only name a + // permission that will actually resolve. + .route("/permissions/catalogue", get(perm_catalogue)) + // The sync is the website's whole desired permission set for this server. This sidecar + // reads none of it: it does not know what a group is, which names are managed, or what the + // plugin will do with any of it. It puts `cmd` and `reqId` on the object and forwards it, + // exactly as it forwards a link code. + .route("/permissions/sync", post(perm_sync)) .route_layer(middleware::from_fn_with_state(state.clone(), gate)); let app = Router::new() @@ -319,6 +330,68 @@ fn usable_code(raw: &str) -> Option<&str> { Some(code) } +// ---- permissions (protocol 4) ---- + +/// The largest command this sidecar will put on the game link. +/// +/// Both ends discard an over-long line rather than buffering it (PROTOCOL.md §3.1), and a discarded +/// command is indistinguishable from a plugin that never answered: the caller waits out its whole +/// timeout and is told `504`, which sends an operator to look at the game server. Refusing here +/// costs one comparison and says which limit was actually hit. +/// +/// It is the game link's cap rather than a smaller number of our own, because the line this +/// function builds is the line that has to fit. +const MAX_COMMAND_BYTES: usize = 1024 * 1024; + +/// What this server's loaded plugins have registered, and the groups its store holds. +async fn perm_catalogue(State(st): State) -> Response { + let req_id = st.rpc.next_req_id(); + let command = json!({ "cmd": "perm.catalogue", "reqId": req_id }); + respond(st.rpc.call(&st.game, command, &req_id).await) +} + +/// Forward the website's desired permission set to the plugin, and hand back its report. +/// +/// **The body is opaque here.** The sidecar defines no schema for a frame's contents — that is the +/// dumb-forwarder property this whole bridge is built on, and it is why protocol 4 adds a shape +/// this large without touching the store or the feed. What this function does own is the envelope: +/// `cmd` and `reqId` are inserted **after** the caller's object is taken, so they overwrite +/// anything a caller put there and no request can arrive claiming to be a different command. +async fn perm_sync(State(st): State, Json(body): Json) -> Response { + let mut command = match body { + Value::Object(map) => map, + _ => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "a permission set must be an object"})), + ) + .into_response() + } + }; + + let req_id = st.rpc.next_req_id(); + command.insert("cmd".into(), Value::String("perm.sync".into())); + command.insert("reqId".into(), Value::String(req_id.clone())); + + let command = Value::Object(command); + let encoded = command.to_string(); + + if encoded.len() > MAX_COMMAND_BYTES { + warn!(bytes = encoded.len(), "permission sync refused: too large"); + return ( + StatusCode::PAYLOAD_TOO_LARGE, + Json(json!({ + "error": "the permission set is larger than the game link will carry", + "bytes": encoded.len(), + "limit": MAX_COMMAND_BYTES, + })), + ) + .into_response(); + } + + respond(st.rpc.call(&st.game, command, &req_id).await) +} + /// Maps an RPC outcome onto a status code. /// /// The two failures are deliberately different codes. `NoPlugin` is `503`: the game is down and the @@ -565,6 +638,41 @@ mod tests { assert_eq!(respond(Ok(json!({"ok": true}))).status(), StatusCode::OK); } + /// The envelope is this side's, not the caller's. A website that sent `cmd` or `reqId` in its + /// own body must not be able to aim the forwarded line at a different command, or at a + /// correlation id somebody else is waiting on. + #[test] + fn a_synced_body_cannot_choose_its_own_command_or_correlation_id() { + let body = json!({ "cmd": "link.confirm", "reqId": "r-1", "grants": [] }); + + let mut command = match body { + Value::Object(map) => map, + _ => unreachable!(), + }; + + // The two lines `perm_sync` runs, in its order. + command.insert("cmd".into(), Value::String("perm.sync".into())); + command.insert("reqId".into(), Value::String("r-42".into())); + + assert_eq!(command["cmd"], json!("perm.sync")); + assert_eq!(command["reqId"], json!("r-42")); + // And everything the caller actually meant is still there, untouched and unread. + assert_eq!(command["grants"], json!([])); + } + + /// A command larger than the game link's own line cap is refused here, where the caller learns + /// why. Forwarded, it would be discarded by both ends without a word and present as a `504`. + #[test] + fn the_command_cap_is_the_game_links_line_cap() { + assert_eq!(MAX_COMMAND_BYTES, 1024 * 1024); + + let big = json!({ "note": "x".repeat(MAX_COMMAND_BYTES) }).to_string(); + assert!(big.len() > MAX_COMMAND_BYTES); + + let small = json!({ "grants": [] }).to_string(); + assert!(small.len() <= MAX_COMMAND_BYTES); + } + #[test] fn uptime_reads_as_a_human_would_write_it() { assert_eq!(format_uptime(Duration::from_secs(90)), "1m");