feat(host): capture unclaimed request bodies; record the consumable-apply wire
Milestone 2: the consumable-apply protocol is now LIVE_PROVEN.
Adds opt-in passthrough BODY logging (OPENFUT_FIFA17_LOG_PASSTHROUGH_BODY=1,
default off, capped at 512 bytes) because a body is what names an unknown
mutation's operands, while also being the one place a request could carry
something that should not reach a log. Staging probe only.
With it, one operator apply captured the whole thing:
POST /ut/game/fifa17/item/resource/5001004
{"apply":[{"id":100000003}]}
source consumable : resource 5001004 (player contract, subtype 201) -- in the PATH
target item(s) : wire 100000003 (squad slot 0 GK, resourceId 200389) -- body apply[]
verb : POST
There is NO /apply endpoint, exactly as the static route work concluded. The
apply re-uses `ut/%s/item/resource`, which we already serve for GET (definition
lookup); the POST verb on that path is the mutation and nothing claimed it. This
is the wire form of the ApplyCardByRes task (id 0x0e), which is why the source is
a definition id rather than an instance id. `apply` is an array, so one resource
can name several targets.
Fail-closed verified: with the upstream dead the request 502s and Core is left
exactly unchanged -- coins 29,843,976, owned 1993, consumables 17, source card
still owned. No partial mutation.
NOT implemented: the response shape is unobserved and the EFFECT is unreversed.
Our catalog carries contract:7 for 5001004, documented as the matches granted,
but that is observed profile data (INFERRED), so no effect is written on it.
Bonus, caught by the same logging: the client really does request
`club/consumables/development`, which has no arm in
consumable_families_for_category and is served empty. Recorded, not guessed.
Host 120 lib tests, fmt clean.
This commit is contained in:
@@ -136,3 +136,56 @@ host now names every unclaimed request:
|
||||
```
|
||||
utas-host owner=PYTHON route=passthrough method=GET path=/ut/... body_len=N
|
||||
```
|
||||
|
||||
## CONSUMABLE APPLY — LIVE_PROVEN (2026-08-21)
|
||||
|
||||
Captured end to end on staging, operator applying a bronze player contract:
|
||||
|
||||
```
|
||||
POST /ut/game/fifa17/item/resource/5001004
|
||||
{"apply":[{"id":100000003}]}
|
||||
```
|
||||
|
||||
| element | value | where |
|
||||
|---|---|---|
|
||||
| source consumable | resource id `5001004` (player contract, subtype 201) | **path** |
|
||||
| target item(s) | wire instance `100000003` (= squad slot 0 GK, resourceId 200389) | **body**, `apply[]` |
|
||||
| verb | `POST` | |
|
||||
|
||||
**There is no `/apply` endpoint** — the apply re-uses `ut/%s/item/resource`, which
|
||||
we already serve for **GET** (item-definition lookup). The **POST** verb on that
|
||||
path is the mutation, and nothing claimed it, so it fell through to Python. This
|
||||
is the wire form of the `ApplyCardByRes` task (id `0x0e`) -- "apply card **by
|
||||
res**ource" -- which is why the source is a definition id rather than an instance
|
||||
id.
|
||||
|
||||
`apply` is an ARRAY, so one consumable resource can name several targets in a
|
||||
single request. Whether the client ever batches is unobserved.
|
||||
|
||||
Corroborating UI evidence from the same session: applying to a PLAYER offered
|
||||
only the subtype-201 card and withheld both subtype-202 manager contracts,
|
||||
independently confirming the `201 = player_contract / 202 = manager_contract`
|
||||
split.
|
||||
|
||||
Fail-closed confirmed: with the upstream dead the request 502s and Core is left
|
||||
EXACTLY unchanged (coins, owned count, and the source card all identical).
|
||||
|
||||
### Not yet known
|
||||
* the **response shape** the client expects on success;
|
||||
* the **effect** -- how many matches a contract grants. Our own catalog carries
|
||||
`contract: 7` for `5001004`, documented as "the number of matches the card
|
||||
grants", but that is observed profile data, i.e. INFERRED, not reversed. No
|
||||
effect is implemented on that basis.
|
||||
|
||||
## Consumables category `development` is unmapped (client really asks)
|
||||
|
||||
The new passthrough/route logging caught the client requesting
|
||||
|
||||
```
|
||||
GET /ut/game/fifa17/club/consumables/development -> outcome=unknown_category emitted=0
|
||||
```
|
||||
|
||||
`consumable_families_for_category` has no `development` arm, so the screen is
|
||||
served empty. The client demonstrably asks for it, which is exactly the condition
|
||||
that function's own doc says should add an arm. Which families it should map to
|
||||
is NOT guessed here.
|
||||
|
||||
@@ -4025,12 +4025,23 @@ impl Server {
|
||||
// Name the request BEFORE forwarding. On staging the upstream is
|
||||
// deliberately dead, so this line is the only record of what the
|
||||
// client asked for -- which is exactly how an unclaimed route is
|
||||
// discovered (see docs/CLIENT_ROUTE_SURFACE.md). Bodies are not
|
||||
// logged; the path and method are enough to identify a route.
|
||||
// discovered (see docs/CLIENT_ROUTE_SURFACE.md).
|
||||
eprintln!(
|
||||
"utas-host owner=PYTHON route=passthrough method={method} path={target} body_len={}",
|
||||
body.len()
|
||||
);
|
||||
// The BODY is what identifies an unknown mutation's operands, but
|
||||
// it is also the one place a request can carry something we should
|
||||
// not write to a log, so it is opt-in and capped. Staging probe
|
||||
// only: OPENFUT_FIFA17_LOG_PASSTHROUGH_BODY=1.
|
||||
if passthrough_body_logging() && !body.is_empty() {
|
||||
let cap = body.len().min(512);
|
||||
eprintln!(
|
||||
"utas-host PASSTHROUGH-BODY path={target} bytes={} body={}",
|
||||
body.len(),
|
||||
String::from_utf8_lossy(&body[..cap])
|
||||
);
|
||||
}
|
||||
let resp = match self.pass.forward(method, target, headers, body) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
@@ -4865,6 +4876,19 @@ fn commerce_settings_enabled() -> bool {
|
||||
*ENABLED.get_or_init(|| std::env::var("OPENFUT_FIFA17_COMMERCE_SETTINGS").as_deref() == Ok("1"))
|
||||
}
|
||||
|
||||
/// Whether to log unclaimed (passthrough) request BODIES.
|
||||
///
|
||||
/// OFF unless `OPENFUT_FIFA17_LOG_PASSTHROUGH_BODY=1`, and capped at 512 bytes.
|
||||
/// A body is what names an unknown mutation's operands -- it is how the
|
||||
/// consumable-apply payload was recovered -- but it is also the one place a
|
||||
/// request could carry something that should not reach a log, so it is opt-in
|
||||
/// and intended for staging probes only.
|
||||
fn passthrough_body_logging() -> bool {
|
||||
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ENABLED
|
||||
.get_or_init(|| std::env::var("OPENFUT_FIFA17_LOG_PASSTHROUGH_BODY").as_deref() == Ok("1"))
|
||||
}
|
||||
|
||||
/// Whether quick-sell pricing uses the client's own `fcc_discardcoins` table
|
||||
/// instead of the legacy rating-only ladder.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user