From 40ebf7c1e703313855767ec685f5322a11b76138 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 13 Aug 2026 17:38:59 +0000 Subject: [PATCH] feat(fifa17): own UTAS auth/capability/purchasegroup session vertical in Rust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openfut-utas-host now classifies and owns three routes, wiring the landed adapter store_session state machine while keeping the Store economy Python's: - POST /ut/auth: proxy to Python (which mints X-UT-SID, adopts persona, refreshes save), OBSERVE the returned sid, and open a Rust session bound to peer IP + configured persona. Account/economy authority stays Python. - POST /openfut/fifa17/capability: Rust-owned, no proxy — validate + register into SessionStore (bound/pending/ignored-late); fail-closed 400 on unsupported. - GET .../store/purchasegroup: proxy to Python for the authoritative economy body, then overlay ONLY the empty-My-Packs topology from the frozen session mode — strip the 65534 sentinel for a verified clean-v1 SID, keep it otherwise. Rust never writes economy state. Session state (Arc> + monotonic clock) lives on Server; new() and from_config() initialise it (signatures unchanged). handle() gains a peer-IP variant (handle_with_ip) threaded from handle_conn. Strict never-both routing is preserved. Pure helpers (observe_sid, parse_capability_request, overlay_empty_mypacks) + classifier are unit-tested; adapter+host tests + Python A-R oracle all pass. STOP-GATE: Store BUY / coins / unopenedPackIds NOT migrated — Rust has no authoritative FIFA17 economy-mutation path (Python fut_profile.json is the source; Core's economy is separate/unwired), so moving BUY would split store authority. That cluster migration is the remaining R2 gap. No production deployment. --- openfut-utas-host/src/lib.rs | 355 ++++++++++++++++++++++++++++++++++- 1 file changed, 351 insertions(+), 4 deletions(-) diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index 74aaf38..877a070 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -38,7 +38,8 @@ pub mod config; use std::io::{BufRead, BufReader, Write}; use std::net::{TcpListener, TcpStream}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; +use std::time::Instant; use openfut_adapter_fifa17::fut::catalog::{Fifa17CardCatalog, Fifa17WireItemIdPolicy}; use openfut_adapter_fifa17::fut::club_response::{ @@ -56,6 +57,9 @@ use openfut_adapter_fifa17::fut::squad_projection::{ project_squad, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput, SquadProjection, SquadProjectionInput, }; +use openfut_adapter_fifa17::fut::store_session::{ + validate_capability, SessionStore, StoreMode, SENTINEL_PACK_ID, +}; use openfut_identity::ExternalIdentityStore; use serde_json::{json, Value}; @@ -76,6 +80,16 @@ pub enum Route { SquadActive, /// `GET …/userMassInfo` — proxied to Python, with only `.squad` overlaid. UserMassInfo, + /// `POST /ut/auth` — proxied to Python (persona adoption + SID mint); the + /// returned `X-UT-SID` is observed to open a Rust session. + Auth, + /// `POST /openfut/fifa17/capability` — launcher capability registration, + /// owned entirely in Rust (no economy, no proxy). + Capability, + /// `GET …/store/purchasegroup…` — proxied to Python for the authoritative + /// economy body, with the empty-My-Packs topology overlaid from the Rust + /// session mode (the 65534 sentinel is stripped for a verified clean-v1 SID). + StorePurchaseGroup, /// Anything else — proxied verbatim to the Python oracle. Passthrough, } @@ -93,6 +107,14 @@ pub enum Route { pub fn classify(method: &str, path: &str) -> Route { let get = method.eq_ignore_ascii_case("GET"); let put = method.eq_ignore_ascii_case("PUT"); + let post = method.eq_ignore_ascii_case("POST"); + // Session/capability vertical (Rust session authority; economy stays Python). + if post && path.starts_with("/ut/auth") { + return Route::Auth; + } + if post && path == "/openfut/fifa17/capability" { + return Route::Capability; + } if get && is_exact_club_path(path) { return Route::Club; } @@ -100,6 +122,7 @@ pub fn classify(method: &str, path: &str) -> Route { Some("squad/list") if get => Route::SquadList, Some("squad/active") if get => Route::SquadActive, Some("userMassInfo") if get => Route::UserMassInfo, + Some(tail) if get && tail.starts_with("store/purchasegroup") => Route::StorePurchaseGroup, Some(tail) if put && is_numeric_squad_tail(tail) => Route::SquadReplace, _ => Route::Passthrough, } @@ -1270,6 +1293,12 @@ pub struct Server { /// used to stamp `personaId` on the Core-backed `GET /squad/active` object. /// Never baked in — it must match the persona LSX/Blaze/POW/UTAS agree on. persona_id: i64, + /// Per-login FIFA session/capability authority (empty-My-Packs topology). + /// The economy (coins, unopened packs, BUY) stays Python's; this owns only + /// session/capability state — Rust reads Python's live body, never writes it. + sessions: Arc>, + /// Monotonic clock origin for the session/pending TTLs. + start: Instant, } impl Server { @@ -1287,6 +1316,8 @@ impl Server { resolver, pass, persona_id, + sessions: Arc::new(Mutex::new(SessionStore::new())), + start: Instant::now(), } } @@ -1311,6 +1342,8 @@ impl Server { resolver, pass: Arc::new(PassClient::new(cfg.python_upstream.clone())), persona_id: cfg.persona_id, + sessions: Arc::new(Mutex::new(SessionStore::new())), + start: Instant::now(), }) } @@ -1324,14 +1357,28 @@ impl Server { } } - /// Route one request to a response. Classification happens here, once, - /// before either branch runs. + /// 4-arg entrypoint (tests + callers without a peer address). Session-bound + /// routes fall back to a `None` client IP. pub fn handle( &self, method: &str, target: &str, headers: &[(String, String)], body: &[u8], + ) -> WireResponse { + self.handle_with_ip(method, target, headers, body, None) + } + + /// Route one request to a response. Classification happens here, once, before + /// either branch runs. `client_ip` is the peer address used to bind FIFA + /// session capability (auxiliary to the authoritative X-UT-SID). + pub fn handle_with_ip( + &self, + method: &str, + target: &str, + headers: &[(String, String)], + body: &[u8], + client_ip: Option<&str>, ) -> WireResponse { let path = target.split('?').next().unwrap_or(target); match classify(method, path) { @@ -1386,6 +1433,11 @@ impl Server { ); resp } + Route::Auth => self.handle_auth(method, target, headers, body, client_ip), + Route::Capability => self.handle_capability(body, client_ip), + Route::StorePurchaseGroup => { + self.handle_store_purchasegroup(method, target, headers, body, client_ip) + } Route::Passthrough => { let resp = match self.pass.forward(method, target, headers, body) { Ok(r) => r, @@ -1410,6 +1462,120 @@ impl Server { } } + /// Monotonic seconds since server start — the clock for session/pending TTLs. + fn now(&self) -> f64 { + self.start.elapsed().as_secs_f64() + } + + /// `POST /ut/auth` — proxy to Python (which mints the X-UT-SID, adopts the + /// persona and refreshes its save), then OBSERVE the returned SID to open a + /// Rust session bound to the peer IP + configured persona. Account/economy + /// stays Python-authoritative; Rust only tracks the session. Python's response + /// is returned byte-for-byte. + fn handle_auth( + &self, + method: &str, + target: &str, + headers: &[(String, String)], + body: &[u8], + client_ip: Option<&str>, + ) -> WireResponse { + let resp = match self.pass.forward(method, target, headers, body) { + Ok(r) => r, + Err(e) => { + eprintln!("utas-host ERROR auth proxy to Python failed: {e}"); + return error_response(502, "upstream_unavailable"); + } + }; + let mut outcome = "no_sid"; + if (200..300).contains(&resp.status) { + if let Some(sid) = observe_sid(&resp.body) { + self.sessions.lock().unwrap().open_session( + &sid, + client_ip.map(|s| s.to_string()), + self.persona_id, + self.now(), + ); + outcome = "session_opened"; + } + } + eprintln!( + "utas-host owner=RUST_OBSERVE route=auth status={} ip={:?} outcome={}", + resp.status, client_ip, outcome + ); + resp + } + + /// `POST /openfut/fifa17/capability` — Rust-owned launcher registration (no + /// economy, no proxy). Fail-closed: an unsupported name/version is a 400 that + /// records nothing, so the session stays on the sentinel fallback. + fn handle_capability(&self, body: &[u8], client_ip: Option<&str>) -> WireResponse { + let req = match parse_capability_request(body) { + Ok(r) => r, + Err(()) => { + eprintln!("utas-host owner=RUST route=capability status=400 outcome=unsupported"); + return json_status(400, &json!({"error": "unsupported capability"})); + } + }; + let persona = req.persona_id.unwrap_or(self.persona_id); + let outcome = self.sessions.lock().unwrap().register_capability( + client_ip.map(|s| s.to_string()), + persona, + req.version, + self.now(), + ); + eprintln!( + "utas-host owner=RUST route=capability status=200 ip={:?} persona={} -> {:?}", + client_ip, persona, outcome + ); + json_status(200, &json!({"status": "OK"})) + } + + /// `GET …/store/purchasegroup…` — proxy to Python for the authoritative economy + /// body (catalogue + owned packs + coins), then overlay ONLY the empty-My-Packs + /// topology from the Rust session mode. Python (which does not know the Rust + /// capability) always emits the 65534 sentinel when My Packs is empty; for a + /// verified clean-v1 SID we strip it. Rust never writes economy state. + fn handle_store_purchasegroup( + &self, + method: &str, + target: &str, + headers: &[(String, String)], + body: &[u8], + client_ip: Option<&str>, + ) -> WireResponse { + let mut resp = match self.pass.forward(method, target, headers, body) { + Ok(r) => r, + Err(e) => { + eprintln!("utas-host ERROR purchasegroup proxy to Python failed: {e}"); + return error_response(502, "upstream_unavailable"); + } + }; + let sid = header(headers, "x-ut-sid").unwrap_or(""); + // Freeze the session's empty-My-Packs mode at this first store request. + let mode = self + .sessions + .lock() + .unwrap() + .empty_mypacks_mode(sid, client_ip, self.now()); + let mut stripped = 0usize; + if (200..300).contains(&resp.status) { + if let Ok(mut root) = serde_json::from_slice::(&resp.body) { + stripped = overlay_empty_mypacks(&mut root, mode); + if stripped > 0 { + if let Ok(new_body) = serde_json::to_vec(&root) { + set_json_body(&mut resp, new_body); + } + } + } + } + eprintln!( + "utas-host owner=RUST_OVERLAY route=purchasegroup status={} sid={} mode={} sentinel_stripped={}", + resp.status, fifa17_sidlog(sid), mode.as_str(), stripped + ); + resp + } + /// Serve forever on `addr` (thread-per-connection, HTTP/1.1 keep-alive). pub fn serve(&self, addr: &str) -> std::io::Result<()> { let listener = TcpListener::bind(addr)?; @@ -1437,10 +1603,17 @@ impl Server { Err(_) => return, }); let mut writer = stream; + let peer_ip = writer.peer_addr().ok().map(|a| a.ip().to_string()); loop { match read_request(&mut reader) { Ok(Some(req)) => { - let resp = self.handle(&req.method, &req.target, &req.headers, &req.body); + let resp = self.handle_with_ip( + &req.method, + &req.target, + &req.headers, + &req.body, + peer_ip.as_deref(), + ); if write_response(&mut writer, &resp).is_err() { return; } @@ -1455,6 +1628,96 @@ impl Server { } } +// ─────────────────────── Session/capability route helpers ─────────────────── + +/// Case-insensitive header lookup. +fn header<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> { + headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(name)) + .map(|(_, v)| v.as_str()) +} + +/// A short, non-secret tag for correlating a session in logs (last 6 chars). +fn fifa17_sidlog(sid: &str) -> String { + if sid.is_empty() { + "-".to_string() + } else { + format!("\u{2026}{}", &sid[sid.len().saturating_sub(6)..]) + } +} + +/// A JSON response with an explicit status. +fn json_status(status: u16, v: &Value) -> WireResponse { + let body = serde_json::to_vec(v).unwrap_or_default(); + WireResponse { + status, + headers: vec![ + ("Content-Type".to_string(), "application/json".to_string()), + ("Content-Length".to_string(), body.len().to_string()), + ], + body, + } +} + +/// Observe the minted `sid` from Python's `/ut/auth` JSON response body. +fn observe_sid(body: &[u8]) -> Option { + serde_json::from_slice::(body) + .ok()? + .get("sid")? + .as_str() + .map(str::to_string) +} + +/// A validated capability registration request. +struct CapabilityRequest { + persona_id: Option, + version: u32, +} + +/// Parse + validate a capability POST body. Mirrors the Python route: anything but +/// `capability == "empty_mypacks_resolver"` at the supported version is rejected. +fn parse_capability_request(body: &[u8]) -> Result { + let v: Value = serde_json::from_slice(body).map_err(|_| ())?; + let obj = v.as_object().ok_or(())?; + let name = obj.get("capability").and_then(|x| x.as_str()).ok_or(())?; + let version = obj + .get("version") + .and_then(|x| x.as_u64()) + .and_then(|n| u32::try_from(n).ok()) + .ok_or(())?; + validate_capability(name, version).map_err(|_| ())?; + let persona_id = obj.get("personaId").and_then(|x| x.as_i64()); + Ok(CapabilityRequest { + persona_id, + version, + }) +} + +/// Overlay the empty-My-Packs topology on Python's `purchasegroup` body. Python +/// always emits the synthetic 65534 sentinel when My Packs is empty (it does not +/// know the Rust capability); for a verified clean-v1 session we remove it so the +/// client's resolver guard routes to Browse. Sentinel mode leaves it in place, and +/// real owned packs (no 65534) are untouched in either mode. Returns the count +/// removed. Pure — the topology decision is unit-testable. +fn overlay_empty_mypacks(root: &mut Value, mode: StoreMode) -> usize { + if mode != StoreMode::CleanV1 { + return 0; + } + let Some(arr) = root.get_mut("purchase").and_then(|p| p.as_array_mut()) else { + return 0; + }; + let before = arr.len(); + arr.retain(|entry| { + entry + .get("id") + .and_then(|x| x.as_u64()) + .map(|id| id != SENTINEL_PACK_ID) + .unwrap_or(true) + }); + before - arr.len() +} + // ───────────────────────────── HTTP/1.1 request reader ────────────────────── /// A parsed request. `target` is the raw request target (path + optional query). @@ -1748,4 +2011,88 @@ mod tests { ); let _ = std::fs::remove_file(&p); } + // ── Session/capability vertical (this slice) ──────────────────────────── + + #[test] + fn classify_routes_session_vertical() { + assert_eq!(classify("POST", "/ut/auth"), Route::Auth); + assert_eq!(classify("POST", "/ut/auth/"), Route::Auth); + // Only POST is auth; a GET falls through to Python. + assert_eq!(classify("GET", "/ut/auth"), Route::Passthrough); + assert_eq!( + classify("POST", "/openfut/fifa17/capability"), + Route::Capability + ); + assert_eq!( + classify("GET", "/ut/game/fifa17/store/purchasegroup/all"), + Route::StorePurchaseGroup + ); + assert_eq!( + classify("GET", "/ut/game/fifa17/store/purchasegroup/all?ppInfo=true"), + Route::StorePurchaseGroup + ); + // A store MUTATION stays on Python (never Rust): economy is not ours. + assert_eq!( + classify("PUT", "/ut/game/fifa17/store/transaction/0"), + Route::Passthrough + ); + assert_eq!( + classify("POST", "/openfut/account/sync"), + Route::Passthrough + ); + } + + #[test] + fn observe_sid_extracts_minted_sid() { + let body = br#"{"protocol":1,"sid":"OPENFUT-SID-42C15A6F78DC6E74","serverTime":"x"}"#; + assert_eq!( + observe_sid(body).as_deref(), + Some("OPENFUT-SID-42C15A6F78DC6E74") + ); + assert_eq!(observe_sid(b"{}"), None); + assert_eq!(observe_sid(b"not json"), None); + } + + #[test] + fn parse_capability_request_validates() { + let ok = br#"{"capability":"empty_mypacks_resolver","version":1,"personaId":33068179,"fifaPid":42}"#; + let r = parse_capability_request(ok).expect("valid"); + assert_eq!(r.version, 1); + assert_eq!(r.persona_id, Some(33068179)); + assert!(parse_capability_request( + br#"{"capability":"empty_mypacks_resolver","version":2}"# + ) + .is_err()); + assert!(parse_capability_request(br#"{"capability":"other","version":1}"#).is_err()); + assert!(parse_capability_request(b"[]").is_err()); + assert!(parse_capability_request(b"nope").is_err()); + } + + #[test] + fn overlay_strips_sentinel_only_for_clean_v1() { + let base = serde_json::json!({ + "purchase": [ + {"id": 1, "packType": "BRONZE"}, + {"id": 65534, "packType": "GOLD"}, + {"id": 5, "packType": "GOLD"} + ] + }); + // clean-v1: the 65534 sentinel is stripped; real packs remain. + let mut clean = base.clone(); + assert_eq!(overlay_empty_mypacks(&mut clean, StoreMode::CleanV1), 1); + let ids: Vec = clean["purchase"] + .as_array() + .unwrap() + .iter() + .map(|e| e["id"].as_u64().unwrap()) + .collect(); + assert_eq!(ids, vec![1, 5]); + // sentinel mode: unchanged (the compatibility shim is kept). + let mut sent = base.clone(); + assert_eq!(overlay_empty_mypacks(&mut sent, StoreMode::Sentinel), 0); + assert_eq!(sent["purchase"].as_array().unwrap().len(), 3); + // no purchase array -> no-op. + let mut other = serde_json::json!({"other": 1}); + assert_eq!(overlay_empty_mypacks(&mut other, StoreMode::CleanV1), 0); + } }