diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index b6a9eeb..813a9bc 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -129,10 +129,19 @@ use tracing_subscriber::EnvFilter; /// plugin that never sends it — against protocol 6 it would read every raid as a base with no /// cupboard, and alert nobody while looking healthy. /// +/// # Protocol 8 — the leases +/// +/// `GET /lease`, `POST /lease` and `POST /lease/release`: an event borrowing a value and giving +/// it back (the module's PLAN.md §27). Three correlated round trips and one event, +/// `lease.expired`, which is filed like every other event. The allowlist, the bounds, the +/// seven-day ceiling and the deadline timer all live in the plugin; the ledger lives on the +/// website. This process moves lines between them and learns nothing about either, which is why +/// the whole protocol is three thin forwards here. +/// /// `docs/rust-link/PROTOCOL.md` is the specification — §8 the read path, §9 identity, §10 the -/// mirror, §11 configuration, §12 clans, §13 the raid frame; this constant is one of its four -/// declaration sites. -pub const PROTOCOL_VERSION: u32 = 7; +/// mirror, §11 configuration, §12 clans, §13 the raid frame, §14 the leases; this constant is one +/// of its four declaration sites. +pub const PROTOCOL_VERSION: u32 = 8; 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 66f05b2..f29c9cf 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -106,6 +106,12 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { // whose reply can take most of the RPC budget: the plugin holds it open across a reload // and, at worst, across a rollback as well. See `CONFIG_RELOAD_WINDOW`. .route("/config/write", post(config_write)) + // Protocol 8 (the module's PLAN.md §27): an event borrowing a value and giving it back. + // Three correlated round trips, and the sidecar knows nothing about any of them — not + // which keys exist, not their bounds, not what a deadline is. The plugin holds the + // allowlist, the ceiling and the timer; the website holds the ledger. This moves lines. + .route("/lease", get(lease_list).post(lease_apply)) + .route("/lease/release", post(lease_release)) .route_layer(middleware::from_fn_with_state(state.clone(), gate)); let app = Router::new() @@ -408,6 +414,92 @@ struct ConfigPathQuery { path: String, } +/// What `GET /lease` may narrow to. Both optional, both forwarded as they are. +#[derive(Debug, Deserialize)] +struct LeaseQuery { + key: Option, + target: Option, +} + +/// Everything this server lends, what it holds now, and every hold in force (protocol 8). +/// +/// A live round trip, never a cached board, for the reason `/permissions/catalogue` is one: the +/// website asks this immediately before taking a lease, to record the baseline it will later give +/// back, and a baseline that was a minute stale would be restored over whatever happened in that +/// minute. +async fn lease_list(State(st): State, Query(q): Query) -> Response { + let req_id = st.rpc.next_req_id(); + let mut command = json!({ "cmd": "lease.list", "reqId": req_id }); + + if let Some(key) = q.key { + command["key"] = Value::String(key); + } + if let Some(target) = q.target { + command["target"] = Value::String(target); + } + + respond(st.rpc.call(&st.game, command, &req_id).await) +} + +/// Take a value for a while. The plugin answers `lease.ok` or `lease.error` with a reason. +async fn lease_apply(State(st): State, Json(body): Json) -> Response { + forward_object(&st, body, "lease.apply", "a lease").await +} + +/// Give a value back. **A drifted value is a `200` carrying `lease.drifted`**, not an error: the +/// plugin did exactly what it was asked — it compared, and declined to overwrite somebody's +/// deliberate change — and the website records that as its own outcome. +async fn lease_release(State(st): State, Json(body): Json) -> Response { + forward_object(&st, body, "lease.release", "a lease release").await +} + +/// Put `cmd` and `reqId` on a caller's object, **after** it is taken, so they overwrite anything +/// the caller put there. `None` when the body is not an object. +fn stamp(body: Value, cmd: &str, req_id: &str) -> Option { + let mut map = match body { + Value::Object(map) => map, + _ => return None, + }; + + map.insert("cmd".into(), Value::String(cmd.into())); + map.insert("reqId".into(), Value::String(req_id.into())); + Some(Value::Object(map)) +} + +/// The opaque-object forward the lease routes share: stamp the envelope, refuse what the game +/// link cannot carry, and hand back whatever the plugin answered. +async fn forward_object(st: &AppState, body: Value, cmd: &str, what: &str) -> Response { + let req_id = st.rpc.next_req_id(); + + let command = match stamp(body, cmd, &req_id) { + Some(command) => command, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": format!("{what} must be an object") })), + ) + .into_response() + } + }; + + let encoded = command.to_string(); + + if encoded.len() > MAX_COMMAND_BYTES { + warn!(bytes = encoded.len(), cmd, "command refused: too large"); + return ( + StatusCode::PAYLOAD_TOO_LARGE, + Json(json!({ + "error": format!("{what} 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) +} + /// Every settings file on the game host, and every plugin loaded to reload one. /// /// The sidecar knows nothing about either: not where the tree is (the framework decides, and it is @@ -793,6 +885,27 @@ mod tests { ); } + /// Protocol 8's routes cannot choose their own command or correlation id either, and they keep + /// everything else the caller sent, unread. + #[test] + fn a_lease_cannot_choose_its_own_command() { + let body = + json!({ "cmd": "config.write", "reqId": "r-1", "key": "decay.scale", "value": "0" }); + + let command = stamp(body, "lease.apply", "r-9").expect("an object is stamped"); + + assert_eq!(command["cmd"], json!("lease.apply")); + assert_eq!(command["reqId"], json!("r-9")); + assert_eq!(command["key"], json!("decay.scale")); + assert_eq!(command["value"], json!("0")); + } + + #[test] + fn a_lease_that_is_not_an_object_is_not_stamped() { + assert!(stamp(json!(["decay.scale"]), "lease.apply", "r-1").is_none()); + assert!(stamp(json!("decay.scale"), "lease.release", "r-1").is_none()); + } + /// The same envelope rule as `perm_sync`, on the route that writes to a filesystem. #[test] fn a_configuration_write_cannot_choose_its_own_command() {