feat(fifa17): own UTAS auth/capability/purchasegroup session vertical in Rust

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<Mutex<SessionStore>> + 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.
This commit is contained in:
funman300
2026-08-13 17:38:59 +00:00
parent c7609252d2
commit 40ebf7c1e7
+351 -4
View File
@@ -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<Mutex<SessionStore>>,
/// 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::<Value>(&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<String> {
serde_json::from_slice::<Value>(body)
.ok()?
.get("sid")?
.as_str()
.map(str::to_string)
}
/// A validated capability registration request.
struct CapabilityRequest {
persona_id: Option<i64>,
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<CapabilityRequest, ()> {
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<u64> = 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);
}
}