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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user