Phase 7 (Bridge): response shaper, diff tool, expanded mapper

- shaper.rs: shape_response() dispatches on core_path to wrap Core
  JSON in FUT envelope format; shapes auth (/ut/auth → sid/pid/
  phishingToken/persona envelope), profile, club, squad, market,
  packs; unknown paths pass through unchanged
- proxy.rs: calls shape_response() on every successful Core response
  before returning to the FIFA 23 client
- mapper.rs: expanded from 6 to 18 speculative FUT endpoint mappings
  covering auth, profile/settings, club/usermassinfo, item/collection,
  squad (GET+PUT), packs+purchase, transfer market+watchlist+bid,
  objectives, events, squad battles, and match result submission
- admin.rs: GET /_bridge/captures/diff?a=<id>&b=<id> compares two
  captures; reports method/path/status changes, header diffs, and
  structured JSON body diffs (key-level for objects, length for others)
- main.rs: registers /_bridge/captures/diff route
- Unit tests: 4 shaper tests, 6 mapper tests (10 total, all passing)
- All 13 integration tests still passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-25 16:32:37 -07:00
parent 3b7a4928c9
commit b8e766e9dd
6 changed files with 510 additions and 14 deletions
+1
View File
@@ -4,4 +4,5 @@ pub mod error;
pub mod mapper; pub mod mapper;
pub mod proxy; pub mod proxy;
pub mod routes; pub mod routes;
pub mod shaper;
pub mod tls; pub mod tls;
+4
View File
@@ -48,6 +48,10 @@ async fn main() -> Result<()> {
get(routes::admin::get_unknown_endpoints), get(routes::admin::get_unknown_endpoints),
) )
.route("/_bridge/status", get(routes::admin::get_endpoint_status)) .route("/_bridge/status", get(routes::admin::get_endpoint_status))
.route(
"/_bridge/captures/diff",
get(routes::admin::get_capture_diff),
)
.fallback(any(openfut_bridge::proxy::catch_all_handler)) .fallback(any(openfut_bridge::proxy::catch_all_handler))
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive()) .layer(CorsLayer::permissive())
+169 -12
View File
@@ -11,43 +11,81 @@ pub struct CoreMapping {
/// Attempt to map an incoming FIFA 23 request to an OpenFUT Core call. /// Attempt to map an incoming FIFA 23 request to an OpenFUT Core call.
/// Returns Some(mapping) if the route is known, None otherwise. /// Returns Some(mapping) if the route is known, None otherwise.
///
/// Mappings are speculative — refined as real traffic captures come in.
pub fn map_to_core(method: &str, path: &str) -> Option<CoreMapping> { pub fn map_to_core(method: &str, path: &str) -> Option<CoreMapping> {
let m = method.to_uppercase(); let m = method.to_uppercase();
let p = path.trim_end_matches('/'); let p = path.trim_end_matches('/');
// These are speculative mappings based on common FUT API patterns.
// They will be refined as reverse engineering progresses.
let known: &[(&str, &str, CoreMapping)] = &[ let known: &[(&str, &str, CoreMapping)] = &[
// Auth // ── Auth ─────────────────────────────────────────────────────────────────
( (
"POST", "POST",
"/ut/auth", "/ut/auth",
CoreMapping { CoreMapping {
method: "POST", method: "POST",
core_path: "/auth/local", core_path: "/auth/local",
notes: "FUT auth → Core local auth", notes: "FUT login → Core local auth (shaped into EA envelope format)",
}, },
), ),
// ── Profile / Settings ───────────────────────────────────────────────────
( (
"GET", "GET",
"/ut/game/fut/user/settings", "/ut/game/fut/user/settings",
CoreMapping { CoreMapping {
method: "GET", method: "GET",
core_path: "/profile", core_path: "/profile",
notes: "FUT settings → Core profile", notes: "FUT user settings → Core profile",
}, },
), ),
// Club (
"GET",
"/ut/game/fut/user/accountinfo",
CoreMapping {
method: "GET",
core_path: "/profile",
notes: "FUT account info → Core profile",
},
),
// ── Club / Mass info ──────────────────────────────────────────────────────
( (
"GET", "GET",
"/ut/game/fut/usermassinfo", "/ut/game/fut/usermassinfo",
CoreMapping { CoreMapping {
method: "GET", method: "GET",
core_path: "/club", core_path: "/club",
notes: "FUT mass info → Core club", notes: "FUT mass info (club + coins) → Core club",
}, },
), ),
// Squad (
"GET",
"/ut/game/fut/club",
CoreMapping {
method: "GET",
core_path: "/club",
notes: "FUT club info → Core club",
},
),
// ── Cards / Collection ────────────────────────────────────────────────────
(
"GET",
"/ut/game/fut/item",
CoreMapping {
method: "GET",
core_path: "/cards/collection",
notes: "FUT collection → Core player card collection",
},
),
(
"GET",
"/ut/game/fut/club/stats",
CoreMapping {
method: "GET",
core_path: "/stats",
notes: "FUT club stats → Core statistics summary",
},
),
// ── Squad ─────────────────────────────────────────────────────────────────
( (
"GET", "GET",
"/ut/game/fut/squad/active", "/ut/game/fut/squad/active",
@@ -57,7 +95,16 @@ pub fn map_to_core(method: &str, path: &str) -> Option<CoreMapping> {
notes: "FUT active squad → Core squad", notes: "FUT active squad → Core squad",
}, },
), ),
// Packs (
"PUT",
"/ut/game/fut/squad/active",
CoreMapping {
method: "POST",
core_path: "/squad",
notes: "FUT save squad → Core save squad",
},
),
// ── Packs ─────────────────────────────────────────────────────────────────
( (
"GET", "GET",
"/ut/game/fut/store/packdetails", "/ut/game/fut/store/packdetails",
@@ -67,14 +114,80 @@ pub fn map_to_core(method: &str, path: &str) -> Option<CoreMapping> {
notes: "FUT pack store → Core pack list", notes: "FUT pack store → Core pack list",
}, },
), ),
// Transfer market (guessed) (
"POST",
"/ut/game/fut/store/purchase",
CoreMapping {
method: "POST",
core_path: "/packs/buy",
notes: "FUT pack purchase → Core pack buy (guess)",
},
),
// ── Transfer Market ───────────────────────────────────────────────────────
( (
"GET", "GET",
"/ut/game/fut/transfermarket", "/ut/game/fut/transfermarket",
CoreMapping { CoreMapping {
method: "GET", method: "GET",
core_path: "/market", core_path: "/market",
notes: "FUT transfer market → Core NPC market", notes: "FUT transfer market search → Core NPC market",
},
),
(
"POST",
"/ut/game/fut/trade/bid",
CoreMapping {
method: "POST",
core_path: "/market/buy",
notes: "FUT bid/buy now → Core market buy",
},
),
(
"GET",
"/ut/game/fut/trade/watchlist",
CoreMapping {
method: "GET",
core_path: "/market",
notes: "FUT watchlist → Core market (approximation)",
},
),
// ── Objectives ────────────────────────────────────────────────────────────
(
"GET",
"/ut/game/fut/objectives",
CoreMapping {
method: "GET",
core_path: "/objectives",
notes: "FUT objectives → Core objectives list",
},
),
// ── Events ────────────────────────────────────────────────────────────────
(
"GET",
"/ut/game/fut/events",
CoreMapping {
method: "GET",
core_path: "/events",
notes: "FUT events → Core events list",
},
),
// ── Matches / Squad Battles ───────────────────────────────────────────────
(
"GET",
"/ut/game/fut/squadbattle/opponent",
CoreMapping {
method: "GET",
core_path: "/matches/opponent",
notes: "FUT squad battles opponent → Core match opponent generator",
},
),
(
"POST",
"/ut/game/fut/result",
CoreMapping {
method: "POST",
core_path: "/matches",
notes: "FUT match result submit → Core match submission",
}, },
), ),
]; ];
@@ -89,7 +202,7 @@ pub fn map_to_core(method: &str, path: &str) -> Option<CoreMapping> {
} }
/// Return a safe placeholder response for unknown endpoints. /// Return a safe placeholder response for unknown endpoints.
/// This prevents the FIFA 23 client from crashing while we log traffic. /// Prevents the FIFA 23 client from crashing while we log traffic.
pub fn placeholder_response(method: &str, path: &str) -> serde_json::Value { pub fn placeholder_response(method: &str, path: &str) -> serde_json::Value {
tracing::warn!( tracing::warn!(
"UNKNOWN ENDPOINT: {} {} — returning placeholder", "UNKNOWN ENDPOINT: {} {} — returning placeholder",
@@ -103,3 +216,47 @@ pub fn placeholder_response(method: &str, path: &str) -> serde_json::Value {
"path": path, "path": path,
}) })
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_known_auth_maps() {
let m = map_to_core("POST", "/ut/auth");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/auth/local");
}
#[test]
fn test_known_market_maps() {
let m = map_to_core("GET", "/ut/game/fut/transfermarket");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/market");
}
#[test]
fn test_events_maps() {
let m = map_to_core("GET", "/ut/game/fut/events");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/events");
}
#[test]
fn test_squad_put_maps() {
let m = map_to_core("PUT", "/ut/game/fut/squad/active");
assert!(m.is_some());
assert_eq!(m.unwrap().method, "POST");
}
#[test]
fn test_unknown_returns_none() {
assert!(map_to_core("GET", "/ut/game/fut/some/unknown/path").is_none());
}
#[test]
fn test_trailing_slash_trimmed() {
let m = map_to_core("POST", "/ut/auth/");
assert!(m.is_some());
}
}
+2 -1
View File
@@ -18,6 +18,7 @@ use crate::{
config::Config, config::Config,
error::BridgeResult, error::BridgeResult,
mapper::{map_to_core, placeholder_response}, mapper::{map_to_core, placeholder_response},
shaper::shape_response,
}; };
const CAPTURE_DEDUP_SECS: u64 = 1; const CAPTURE_DEDUP_SECS: u64 = 1;
@@ -118,7 +119,7 @@ pub async fn catch_all_handler(
) )
.await .await
{ {
Ok((body, status)) => (body, status), Ok((body, status)) => (shape_response(mapping.core_path, body), status),
Err(e) => { Err(e) => {
tracing::error!("Core request failed: {e}"); tracing::error!("Core request failed: {e}");
(serde_json::json!({ "error": e.to_string() }), 502) (serde_json::json!({ "error": e.to_string() }), 502)
+136 -1
View File
@@ -1,11 +1,12 @@
use axum::{ use axum::{
extract::State, extract::{Query, State},
http::{header, StatusCode}, http::{header, StatusCode},
response::{ response::{
sse::{Event, KeepAlive, Sse}, sse::{Event, KeepAlive, Sse},
IntoResponse, Json, Response, IntoResponse, Json, Response,
}, },
}; };
use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::convert::Infallible; use std::convert::Infallible;
use tokio_stream::{wrappers::BroadcastStream, StreamExt}; use tokio_stream::{wrappers::BroadcastStream, StreamExt};
@@ -136,6 +137,140 @@ pub async fn get_endpoint_status(State(state): State<ProxyState>) -> BridgeResul
}))) })))
} }
/// Query params for the diff endpoint.
#[derive(Deserialize)]
pub struct DiffQuery {
pub a: String,
pub b: String,
}
/// Diff two captures by ID: shows what changed between them.
///
/// Usage: GET /_bridge/captures/diff?a=<uuid>&b=<uuid>
pub async fn get_capture_diff(
State(state): State<ProxyState>,
Query(params): Query<DiffQuery>,
) -> BridgeResult<Json<Value>> {
let captures = load_all_captures(&state.config.captures_dir).map_err(BridgeError::Internal)?;
let find = |id: &str| -> Option<&crate::capture::CapturedRequest> {
captures.iter().find(|c| c.id == id)
};
let cap_a = find(&params.a)
.ok_or_else(|| BridgeError::Internal(anyhow::anyhow!("capture '{}' not found", params.a)))?;
let cap_b = find(&params.b)
.ok_or_else(|| BridgeError::Internal(anyhow::anyhow!("capture '{}' not found", params.b)))?;
// Structural JSON diff of request bodies
let body_diff = diff_json_strings(cap_a.body.as_deref(), cap_b.body.as_deref());
let resp_diff = diff_json_strings(
cap_a.response_body.as_deref(),
cap_b.response_body.as_deref(),
);
// Header diff: keys present in one but not the other
let headers_a: std::collections::HashSet<String> =
cap_a.headers.iter().map(|(k, _)| k.clone()).collect();
let headers_b: std::collections::HashSet<String> =
cap_b.headers.iter().map(|(k, _)| k.clone()).collect();
let headers_only_in_a: Vec<_> = headers_a.difference(&headers_b).collect();
let headers_only_in_b: Vec<_> = headers_b.difference(&headers_a).collect();
// Headers present in both but with different values
let headers_changed: Vec<Value> = cap_a
.headers
.iter()
.filter_map(|(k, va)| {
cap_b.headers.iter().find(|(kb, _)| kb == k).and_then(|(_, vb)| {
if va != vb {
Some(json!({ "header": k, "a": va, "b": vb }))
} else {
None
}
})
})
.collect();
Ok(Json(json!({
"a": { "id": cap_a.id, "timestamp": cap_a.timestamp, "method": cap_a.method, "path": cap_a.path },
"b": { "id": cap_b.id, "timestamp": cap_b.timestamp, "method": cap_b.method, "path": cap_b.path },
"method_changed": cap_a.method != cap_b.method,
"path_changed": cap_a.path != cap_b.path,
"status_a": cap_a.response_status,
"status_b": cap_b.response_status,
"status_changed": cap_a.response_status != cap_b.response_status,
"headers": {
"only_in_a": headers_only_in_a,
"only_in_b": headers_only_in_b,
"changed": headers_changed,
},
"body_diff": body_diff,
"response_diff": resp_diff,
})))
}
/// Shallow JSON diff of two optional JSON strings.
///
/// If both parse as JSON objects, lists keys that differ or are absent.
/// Falls back to string comparison if either is not valid JSON.
fn diff_json_strings(a: Option<&str>, b: Option<&str>) -> Value {
match (a, b) {
(None, None) => json!({ "same": true, "note": "both empty" }),
(Some(_), None) => json!({ "same": false, "note": "b has no body" }),
(None, Some(_)) => json!({ "same": false, "note": "a has no body" }),
(Some(a_str), Some(b_str)) => {
if a_str == b_str {
return json!({ "same": true });
}
// Try to parse as JSON objects for a structured diff
let va: Option<serde_json::Map<String, Value>> =
serde_json::from_str(a_str).ok();
let vb: Option<serde_json::Map<String, Value>> =
serde_json::from_str(b_str).ok();
match (va, vb) {
(Some(ma), Some(mb)) => {
let keys_a: std::collections::HashSet<&str> =
ma.keys().map(|s| s.as_str()).collect();
let keys_b: std::collections::HashSet<&str> =
mb.keys().map(|s| s.as_str()).collect();
let only_in_a: Vec<&str> = keys_a.difference(&keys_b).copied().collect();
let only_in_b: Vec<&str> = keys_b.difference(&keys_a).copied().collect();
let changed: Vec<Value> = keys_a
.intersection(&keys_b)
.filter_map(|k| {
let va = ma.get(*k)?;
let vb = mb.get(*k)?;
if va != vb {
Some(json!({ "key": k, "a": va, "b": vb }))
} else {
None
}
})
.collect();
json!({
"same": false,
"type": "json_object_diff",
"keys_only_in_a": only_in_a,
"keys_only_in_b": only_in_b,
"keys_changed": changed,
})
}
_ => json!({
"same": false,
"type": "string_diff",
"a_len": a_str.len(),
"b_len": b_str.len(),
}),
}
}
}
}
/// Simple HTML admin dashboard. /// Simple HTML admin dashboard.
pub async fn get_admin_dashboard() -> impl IntoResponse { pub async fn get_admin_dashboard() -> impl IntoResponse {
let html = include_str!("../admin.html"); let html = include_str!("../admin.html");
+198
View File
@@ -0,0 +1,198 @@
//! Response shaper: transforms OpenFUT Core responses into the envelope format
//! FIFA 23 expects, based on the Core path that was called.
//!
//! These are best-guess wrappers derived from typical EA FUT API patterns.
//! They will be refined as real traffic captures come in.
use serde_json::{json, Value};
use uuid::Uuid;
/// Shape a Core JSON response into the format FIFA 23 expects for this path.
/// Returns the shaped response or the original unchanged if no shaping is defined.
pub fn shape_response(core_path: &str, core_response: Value) -> Value {
match core_path {
"/auth/local" => shape_auth_response(core_response),
"/profile" => shape_profile_response(core_response),
"/club" => shape_club_response(core_response),
"/squad" => shape_squad_response(core_response),
"/market" => shape_market_response(core_response),
"/packs" => shape_packs_response(core_response),
_ => core_response,
}
}
/// Wraps Core auth response in the FUT auth envelope format.
///
/// EA's `/ut/auth` returns a top-level object with a SID, PID, and persona.
/// The `sid` is used as a session token in subsequent `X-UT-SID` headers.
fn shape_auth_response(core: Value) -> Value {
let profile_id = core
.get("profile")
.and_then(|p| p.get("id"))
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let display_name = core
.get("profile")
.and_then(|p| p.get("username"))
.and_then(|v| v.as_str())
.unwrap_or("FUT Player");
let fake_sid = format!("ofut-sid-{}", Uuid::new_v4().simple());
let fake_pid = format!("1{}", &Uuid::new_v4().simple().to_string()[..9]);
let phishing_token = format!("ptkn-{}", Uuid::new_v4().simple());
json!({
"sid": fake_sid,
"pid": {
"pidId": fake_pid,
"email": format!("{}@openfut.local", display_name.to_lowercase().replace(' ', "_")),
"emailStatus": "VERIFIED",
"strength": "STRONG",
"dob": "1990-01-01",
"country": "US",
},
"phishingToken": phishing_token,
"persona": {
"personaId": profile_id,
"displayName": display_name,
"isEmailVerified": true,
},
"userAccountInfo": {
"userAccountInfoSummary": [{
"personaId": profile_id,
"displayName": display_name,
"clubs": [{ "clubType": "FUT", "supported": true }],
}],
},
"openfut": true,
})
}
/// Wraps Core profile response in the FUT user settings envelope.
fn shape_profile_response(core: Value) -> Value {
let profile = core.get("profile").cloned().unwrap_or(core.clone());
json!({
"settings": {
"personaId": profile.get("id").cloned().unwrap_or(json!("unknown")),
"displayName": profile.get("username").cloned().unwrap_or(json!("FUT Player")),
"level": profile.get("level").cloned().unwrap_or(json!(1)),
"xp": profile.get("xp").cloned().unwrap_or(json!(0)),
},
"openfut": true,
"_raw": profile,
})
}
/// Wraps Core club response in the FUT usermassinfo envelope.
fn shape_club_response(core: Value) -> Value {
let club = core.get("club").cloned().unwrap_or(core.clone());
json!({
"pilluserinfo": {
"clubId": club.get("id").cloned().unwrap_or(json!("unknown")),
"credits": club.get("coins").cloned().unwrap_or(json!(0)),
"purchased": 0,
"sold": 0,
},
"userInfo": {
"duplicationInfo": { "isDuplicate": false },
"purchasedPacksInfo": { "premium": 0, "standard": 0 },
},
"openfut": true,
"_raw": club,
})
}
/// Wraps Core squad response in the FUT squad envelope.
fn shape_squad_response(core: Value) -> Value {
let squad = core.get("squad").cloned().unwrap_or(core.clone());
let players = core.get("players").cloned().unwrap_or(json!([]));
json!({
"squad": {
"squadId": squad.get("id").cloned().unwrap_or(json!("unknown")),
"name": squad.get("name").cloned().unwrap_or(json!("My Squad")),
"formation": squad.get("formation").cloned().unwrap_or(json!("4-4-2")),
"players": players,
"rating": 0,
"chemistry": 0,
},
"openfut": true,
})
}
/// Wraps Core market response in the FUT transfer market envelope.
fn shape_market_response(core: Value) -> Value {
let listings = core.get("listings").cloned().unwrap_or(json!([]));
let total = core
.get("total")
.and_then(|v| v.as_u64())
.unwrap_or(0);
json!({
"auctionInfo": listings,
"bidTokens": {},
"credits": 0,
"currencies": [
{ "name": "COINS", "funds": 0, "finalFunds": 0 },
],
"duplicateItemIdList": [],
"itemData": [],
"total": total,
"openfut": true,
})
}
/// Wraps Core packs response in the FUT pack store envelope.
fn shape_packs_response(core: Value) -> Value {
let packs = core.get("packs").cloned().unwrap_or(json!([]));
json!({
"purchasedPacks": {
"packs": packs,
},
"openfut": true,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_shape_auth_response_has_required_fields() {
let core = json!({
"profile": { "id": "prof_001", "username": "TestPlayer" }
});
let shaped = shape_auth_response(core);
assert!(shaped.get("sid").is_some());
assert!(shaped.get("phishingToken").is_some());
assert!(shaped.get("persona").is_some());
assert_eq!(shaped["persona"]["displayName"], "TestPlayer");
assert_eq!(shaped["openfut"], true);
}
#[test]
fn test_shape_response_dispatches_correctly() {
let core = json!({ "profile": { "id": "x", "username": "u" } });
let shaped = shape_response("/auth/local", core);
assert!(shaped.get("sid").is_some());
}
#[test]
fn test_shape_response_passthrough_for_unknown_path() {
let core = json!({ "foo": "bar" });
let shaped = shape_response("/some/unknown/path", core.clone());
assert_eq!(shaped, core);
}
#[test]
fn test_shape_market_response_has_auction_info() {
let core = json!({ "listings": [{ "id": "l1" }], "total": 1 });
let shaped = shape_market_response(core);
assert!(shaped["auctionInfo"].as_array().is_some());
assert_eq!(shaped["total"], 1);
}
}