Merge pull request 'feat(sidecar): protocol 8 — three lease forwards (phase 12)' (#8) from feat/phase-12-leases into edge

Reviewed-on: #8
This commit is contained in:
2026-09-24 04:03:12 +00:00
2 changed files with 125 additions and 3 deletions

View File

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

View File

@@ -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<String>,
target: Option<String>,
}
/// 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<AppState>, Query(q): Query<LeaseQuery>) -> 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<AppState>, Json(body): Json<Value>) -> 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<AppState>, Json(body): Json<Value>) -> 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<Value> {
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() {