feat(sidecar): protocol 4 — two routes, and no opinion about either
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m38s

`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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
This commit is contained in:
2026-09-21 18:27:54 -05:00
parent 47ffc9a78c
commit 8f5440089c
2 changed files with 130 additions and 3 deletions

View File

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

View File

@@ -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<AppState>) -> 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<AppState>, Json(body): Json<Value>) -> 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");