diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index edc0f3e..d7c5e1e 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -682,6 +682,24 @@ pub trait CoreAccess: Send + Sync { /// Replace the active squad's canonical slots + opaque extension atomically. fn replace_squad(&self, req: &CoreReplaceRequest) -> Result; + /// The owned instance id assigned as the active squad's **manager**, or + /// `None` (`GET /club/manager`). Default: `None` — a transport without the + /// endpoint simply projects no manager (non-fatal, like an absent + /// assignment). The production `HttpCoreClient` overrides it. + fn get_squad_manager(&self) -> Result, CoreError> { + Ok(None) + } + + /// Assign (`Some`) or clear (`None`) the active squad's **manager** + /// (`PUT /club/manager`). Default: unimplemented — the production + /// `HttpCoreClient` overrides it; a transport that cannot persist the + /// assignment MUST fail loudly rather than silently drop it. + fn set_squad_manager(&self, _owned_card_id: Option<&str>) -> Result<(), CoreError> { + Err(CoreError::Parse( + "Core squad manager write is not implemented".into(), + )) + } + fn list_sbcs(&self) -> Result, CoreError> { Err(CoreError::Parse( "Core SBC access is not implemented".into(), @@ -821,6 +839,41 @@ impl CoreAccess for HttpCoreClient { }) } + fn get_squad_manager(&self) -> Result, CoreError> { + let url = format!("{}/club/manager", self.base_url); + let resp = self + .client + .get(&url) + .header("X-OpenFUT-Game", &self.game) + .send() + .map_err(|e| CoreError::Http(e.to_string()))?; + let status = resp.status().as_u16(); + if !(200..300).contains(&status) { + return Err(CoreError::Status(status)); + } + let v: Value = resp.json().map_err(|e| CoreError::Parse(e.to_string()))?; + Ok(v.get("manager") + .and_then(|m| m.get("owned_card_id")) + .and_then(|x| x.as_str()) + .map(str::to_string)) + } + + fn set_squad_manager(&self, owned_card_id: Option<&str>) -> Result<(), CoreError> { + let url = format!("{}/club/manager", self.base_url); + let resp = self + .client + .put(&url) + .header("X-OpenFUT-Game", &self.game) + .json(&json!({ "owned_card_id": owned_card_id })) + .send() + .map_err(|e| CoreError::Http(e.to_string()))?; + let status = resp.status().as_u16(); + if !(200..300).contains(&status) { + return Err(CoreError::Status(status)); + } + Ok(()) + } + fn list_sbcs(&self) -> Result, CoreError> { let response = self .client @@ -1839,6 +1892,15 @@ fn project_active_squad(deps: &SquadDeps<'_>) -> HostProjection { is_on_bench: s.is_on_bench, }) .collect(); + // The ownership-backed manager assignment (Core `squad_managers`). A fetch + // error or absent endpoint yields no manager — non-fatal, a squad renders + // without one, never fabricated. + let manager = deps + .core + .get_squad_manager() + .ok() + .flatten() + .and_then(|id| owned_by_id.get(&id).cloned()); let input = SquadProjectionInput { fifa_squad_id: ACTIVE_SQUAD_WIRE_ID, name: read.name, @@ -1846,6 +1908,7 @@ fn project_active_squad(deps: &SquadDeps<'_>) -> HostProjection { slots, ext: SquadExtInput::Fresh(ext), owned: &owned_by_id, + manager, }; match project_squad(&input, deps.resolver, deps.entities) { Ok(SquadProjection::Projected(v)) => HostProjection::Squad(v), @@ -1932,6 +1995,18 @@ pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, Squ ); } } + // The manager is a resolved, owned assignment too: authorize it like a slot. + if let Some(mgr) = &build.canonical.manager_owned_card_id { + if !owned_set.contains(mgr) { + return ( + error_response(403, "not_owned"), + SquadLog { + outcome: "unauthorized_manager", + detail: mgr.clone(), + }, + ); + } + } // Commit canonical + extension atomically. No Python fallback on failure. let req = CoreReplaceRequest { name: build.canonical.name.clone(), @@ -1956,22 +2031,37 @@ pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, Squ ext_schema_version: EXT_SCHEMA_VERSION, ext_payload: build.extension.to_payload(), }; - match deps.core.replace_squad(&req) { - Ok(_) => ( - json_response(&save_ack(put.id)), - SquadLog { - outcome: "ok", - detail: String::new(), - }, - ), - Err(e) => ( + if let Err(e) = deps.core.replace_squad(&req) { + return ( error_response(502, "core_error"), SquadLog { outcome: "core_error", detail: e.to_string(), }, - ), + ); } + // Persist the ownership-backed manager assignment (migration 0023). It was + // authorized above and Core re-validates club ownership; fail loudly on a + // transport error rather than silently dropping the manager. + if let Err(e) = deps + .core + .set_squad_manager(build.canonical.manager_owned_card_id.as_deref()) + { + return ( + error_response(502, "core_error"), + SquadLog { + outcome: "manager_error", + detail: e.to_string(), + }, + ); + } + ( + json_response(&save_ack(put.id)), + SquadLog { + outcome: "ok", + detail: String::new(), + }, + ) } /// `GET …/squad/list` — served from Core + the ONE projector. Stale/Missing are