Skip to content

Commit f3dbcb6

Browse files
author
Neo
committed
[reconciliation] accept the MTRX client's snake_case body key on /iap/verify + /auth/apple
The FINAL RECONCILIATION capstone caught a systemic client/server key-casing mismatch: the MTRX iOS client encodes EVERY request body with keyEncodingStrategy = .convertToSnakeCase, so it sends `signed_transaction` and `identity_token`, while both server routes read the camelCase `signedTransaction` / `identityToken` (matching the SDK + Apple's own webhook payloads). Result: /api/v1/iap/verify (this buildout) AND /api/v1/auth/apple (pre-existing, latent) would 400 on every real call from the app. Fix: a shared _body_key(body, *keys) helper reads the first present spelling. Both routes now accept camelCase (SDK/tests) OR snake_case (MTRX app). The ASN webhook's signedPayload stays camelCase-only — it comes from Apple, not the client. 3 tests: snake-case /iap/verify body accepted + the helper's prefer-camel-then-snake / blank-handling. Suite: 606 passed.
1 parent faacf9a commit f3dbcb6

3 files changed

Lines changed: 117 additions & 65 deletions

File tree

docs/ROUTES.md

Lines changed: 63 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,16 @@
88

99
| Method | Path | Handler | Source | Public |
1010
|---|---|---|---|---|
11-
| GET | `/` | `handle_landing` | server.py:2046 ||
12-
| POST | `/a2a/jobs` | `handle_a2a_submit_job` | server.py:2080 | |
13-
| GET | `/a2a/jobs/{job_id}` | `handle_a2a_get_job` | server.py:2081 | |
14-
| GET | `/a2a/services` | `handle_a2a_services` | server.py:2079 ||
11+
| GET | `/` | `handle_landing` | server.py:2066 ||
12+
| POST | `/a2a/jobs` | `handle_a2a_submit_job` | server.py:2100 | |
13+
| GET | `/a2a/jobs/{job_id}` | `handle_a2a_get_job` | server.py:2101 | |
14+
| GET | `/a2a/services` | `handle_a2a_services` | server.py:2099 ||
1515
| POST | `/api/v1/agent/register` | `_handle_agent_register` | service_routes.py:271 | |
1616
| POST | `/api/v1/ai/agent/register` | `_handle_ai_agent_register` | service_routes.py:366 | |
1717
| POST | `/api/v1/ai/model/trade` | `_handle_ai_model_trade` | service_routes.py:367 | |
1818
| GET | `/api/v1/attestation/verify/{uid}` | `_handle_attestation_verify` | service_routes.py:283 | |
19-
| DELETE | `/api/v1/auth/account` | `handle_account_delete` | server.py:2034 ||
20-
| POST | `/api/v1/auth/apple` | `handle_apple_auth` | server.py:2033 ||
19+
| DELETE | `/api/v1/auth/account` | `handle_account_delete` | server.py:2054 ||
20+
| POST | `/api/v1/auth/apple` | `handle_apple_auth` | server.py:2053 ||
2121
| POST | `/api/v1/batch` | `_handle_batch` | service_routes.py:389 | |
2222
| POST | `/api/v1/brand/campaign/create` | `_handle_brand_campaign_create` | service_routes.py:250 | |
2323
| GET | `/api/v1/capabilities` | `_handle_capabilities_list` | service_routes.py:383 | |
@@ -63,8 +63,8 @@
6363
| POST | `/api/v1/governance/snapshot/vote` | `_handle_snapshot_vote` | service_routes.py:347 | |
6464
| POST | `/api/v1/governance/treasury/transfer` | `_handle_treasury_transfer` | service_routes.py:348 | |
6565
| POST | `/api/v1/governance/vote` | `_handle_governance_vote` | service_routes.py:224 | |
66-
| POST | `/api/v1/iap/asn` | `handle_iap_asn` | server.py:2036 ||
67-
| POST | `/api/v1/iap/verify` | `handle_iap_verify` | server.py:2035 ||
66+
| POST | `/api/v1/iap/asn` | `handle_iap_asn` | server.py:2056 ||
67+
| POST | `/api/v1/iap/verify` | `handle_iap_verify` | server.py:2055 ||
6868
| POST | `/api/v1/identity/create` | `_handle_did_create` | service_routes.py:198 | |
6969
| POST | `/api/v1/identity/credential/issue` | `_handle_credential_issue` | service_routes.py:306 | |
7070
| POST | `/api/v1/identity/credential/verify` | `_handle_credential_verify` | service_routes.py:307 | |
@@ -131,17 +131,17 @@
131131
| POST | `/api/v1/supply-chain/provenance/log` | `_handle_provenance_log` | service_routes.py:370 | |
132132
| POST | `/api/v1/supply-chain/register` | `_handle_supply_chain_register` | service_routes.py:262 | |
133133
| POST | `/api/v1/supply-chain/verify` | `_handle_authenticity_verify` | service_routes.py:371 | |
134-
| GET | `/audit` | `handle_audit_page` | server.py:2048 ||
135-
| POST | `/audit/request` | `handle_audit_request` | server.py:2059 | |
136-
| GET | `/audit/{audit_id}` | `handle_audit_report` | server.py:2060 | |
137-
| POST | `/auth/nonce` | `handle_auth_nonce` | server.py:2031 ||
138-
| POST | `/auth/verify` | `handle_auth_verify` | server.py:2032 ||
139-
| POST | `/badge/issue` | `handle_badge_issue` | server.py:2100 | |
140-
| GET | `/badge/widget.js` | `handle_badge_widget_js` | server.py:2095 | |
141-
| GET | `/badge/{badge_id}` | `handle_badge_page` | server.py:2096 | |
142-
| GET | `/badge/{badge_id}/embed` | `handle_badge_embed` | server.py:2098 | |
143-
| GET | `/badge/{badge_id}/status` | `handle_badge_status` | server.py:2097 | |
144-
| GET | `/badges` | `handle_badges_list` | server.py:2099 ||
134+
| GET | `/audit` | `handle_audit_page` | server.py:2068 ||
135+
| POST | `/audit/request` | `handle_audit_request` | server.py:2079 | |
136+
| GET | `/audit/{audit_id}` | `handle_audit_report` | server.py:2080 | |
137+
| POST | `/auth/nonce` | `handle_auth_nonce` | server.py:2051 ||
138+
| POST | `/auth/verify` | `handle_auth_verify` | server.py:2052 ||
139+
| POST | `/badge/issue` | `handle_badge_issue` | server.py:2120 | |
140+
| GET | `/badge/widget.js` | `handle_badge_widget_js` | server.py:2115 | |
141+
| GET | `/badge/{badge_id}` | `handle_badge_page` | server.py:2116 | |
142+
| GET | `/badge/{badge_id}/embed` | `handle_badge_embed` | server.py:2118 | |
143+
| GET | `/badge/{badge_id}/status` | `handle_badge_status` | server.py:2117 | |
144+
| GET | `/badges` | `handle_badges_list` | server.py:2119 ||
145145
| POST | `/bridge/v1/action` | `execute_action` | bridge.py:540 | |
146146
| POST | `/bridge/v1/chat` | `chat` | bridge.py:537 | |
147147
| GET | `/bridge/v1/components` | `get_components` | bridge.py:557 | |
@@ -155,47 +155,47 @@
155155
| POST | `/bridge/v1/session/resume` | `resume_session` | bridge.py:534 | |
156156
| POST | `/bridge/v1/wallet/link` | `link_wallet` | bridge.py:543 | |
157157
| GET | `/bridge/v1/wallet/status` | `wallet_status` | bridge.py:544 | |
158-
| POST | `/certification/start` | `handle_cert_start` | server.py:2105 | |
159-
| POST | `/certification/submit` | `handle_cert_submit` | server.py:2106 | |
160-
| GET | `/certification/tracks` | `handle_cert_tracks` | server.py:2104 | |
161-
| GET | `/certification/{cert_id}` | `handle_cert_verify` | server.py:2107 | |
162-
| GET | `/chat` | `handle_chat_page` | server.py:2047 ||
163-
| POST | `/chat` | `handle_chat` | server.py:2024 ||
164-
| POST | `/chat/stream` | `handle_chat_stream` | server.py:2025 | |
165-
| GET | `/extensions/registry` | `handle_extensions_registry` | server.py:2055 ||
166-
| GET | `/extensions/registry/{component_id}` | `handle_extensions_component` | server.py:2056 | |
167-
| GET | `/glasswing` | `handle_glasswing_page` | server.py:2094 ||
168-
| GET | `/health` | `handle_health` | server.py:2027 ||
169-
| GET | `/learn` | `handle_learn_page` | server.py:2103 ||
170-
| GET | `/marketplace` | `handle_marketplace_page` | server.py:2049 ||
171-
| GET | `/marketplace/plugins` | `handle_marketplace_list` | server.py:2084 | |
172-
| POST | `/marketplace/plugins/submit` | `handle_marketplace_submit` | server.py:2087 | |
173-
| GET | `/marketplace/plugins/{plugin_id}` | `handle_marketplace_plugin` | server.py:2085 | |
174-
| POST | `/marketplace/plugins/{plugin_id}/purchase` | `handle_marketplace_purchase` | server.py:2086 | |
175-
| GET | `/marketplace/purchased` | `handle_marketplace_purchased` | server.py:2088 | |
176-
| POST | `/memory/read` | `handle_memory_read` | server.py:2029 | |
177-
| POST | `/memory/write` | `handle_memory_write` | server.py:2030 | |
178-
| GET | `/metrics` | `handle_metrics` | server.py:2042 | |
179-
| GET | `/metrics/prom` | `handle_metrics_prometheus` | server.py:2043 | |
180-
| GET | `/privacy` | `handle_privacy_page` | server.py:2051 ||
181-
| POST | `/security/appattest/attest` | `handle_appattest_attest` | server.py:2041 ||
182-
| GET | `/security/appattest/challenge` | `handle_appattest_challenge` | server.py:2040 ||
183-
| POST | `/security/owner/request` | `handle_owner_otp_request` | server.py:2039 | |
184-
| POST | `/security/phone/request` | `handle_otp_request` | server.py:2037 ||
185-
| POST | `/security/phone/verify` | `handle_otp_verify` | server.py:2038 ||
186-
| GET | `/services/conversion` | `handle_conversion_page` | server.py:2050 ||
187-
| GET | `/social` | `handle_social_feed_page` | server.py:2066 ||
188-
| GET | `/social/actor/{wallet}` | `handle_social_actor` | server.py:2070 | |
189-
| GET | `/social/feed` | `handle_social_feed` | server.py:2067 ||
190-
| GET | `/social/feed/stream` | `handle_social_feed_stream` | server.py:2068 ||
191-
| POST | `/social/follow` | `handle_social_follow` | server.py:2073 | |
192-
| POST | `/social/post` | `handle_social_post` | server.py:2063 | |
193-
| GET | `/social/stats` | `handle_social_stats` | server.py:2071 ||
194-
| GET | `/social/trending` | `handle_social_trending` | server.py:2069 ||
195-
| POST | `/social/unfollow` | `handle_social_unfollow` | server.py:2074 | |
196-
| GET | `/social/{address}/followers` | `handle_social_followers` | server.py:2075 | |
197-
| GET | `/social/{address}/following` | `handle_social_following` | server.py:2076 | |
198-
| GET | `/sponsor` | `handle_sponsor_redirect` | server.py:2091 ||
199-
| GET | `/status` | `handle_status` | server.py:2028 | |
200-
| GET | `/terms` | `handle_terms_page` | server.py:2052 ||
201-
| GET | `/ws` | `handle_websocket` | server.py:2026 ||
158+
| POST | `/certification/start` | `handle_cert_start` | server.py:2125 | |
159+
| POST | `/certification/submit` | `handle_cert_submit` | server.py:2126 | |
160+
| GET | `/certification/tracks` | `handle_cert_tracks` | server.py:2124 | |
161+
| GET | `/certification/{cert_id}` | `handle_cert_verify` | server.py:2127 | |
162+
| GET | `/chat` | `handle_chat_page` | server.py:2067 ||
163+
| POST | `/chat` | `handle_chat` | server.py:2044 ||
164+
| POST | `/chat/stream` | `handle_chat_stream` | server.py:2045 | |
165+
| GET | `/extensions/registry` | `handle_extensions_registry` | server.py:2075 ||
166+
| GET | `/extensions/registry/{component_id}` | `handle_extensions_component` | server.py:2076 | |
167+
| GET | `/glasswing` | `handle_glasswing_page` | server.py:2114 ||
168+
| GET | `/health` | `handle_health` | server.py:2047 ||
169+
| GET | `/learn` | `handle_learn_page` | server.py:2123 ||
170+
| GET | `/marketplace` | `handle_marketplace_page` | server.py:2069 ||
171+
| GET | `/marketplace/plugins` | `handle_marketplace_list` | server.py:2104 | |
172+
| POST | `/marketplace/plugins/submit` | `handle_marketplace_submit` | server.py:2107 | |
173+
| GET | `/marketplace/plugins/{plugin_id}` | `handle_marketplace_plugin` | server.py:2105 | |
174+
| POST | `/marketplace/plugins/{plugin_id}/purchase` | `handle_marketplace_purchase` | server.py:2106 | |
175+
| GET | `/marketplace/purchased` | `handle_marketplace_purchased` | server.py:2108 | |
176+
| POST | `/memory/read` | `handle_memory_read` | server.py:2049 | |
177+
| POST | `/memory/write` | `handle_memory_write` | server.py:2050 | |
178+
| GET | `/metrics` | `handle_metrics` | server.py:2062 | |
179+
| GET | `/metrics/prom` | `handle_metrics_prometheus` | server.py:2063 | |
180+
| GET | `/privacy` | `handle_privacy_page` | server.py:2071 ||
181+
| POST | `/security/appattest/attest` | `handle_appattest_attest` | server.py:2061 ||
182+
| GET | `/security/appattest/challenge` | `handle_appattest_challenge` | server.py:2060 ||
183+
| POST | `/security/owner/request` | `handle_owner_otp_request` | server.py:2059 | |
184+
| POST | `/security/phone/request` | `handle_otp_request` | server.py:2057 ||
185+
| POST | `/security/phone/verify` | `handle_otp_verify` | server.py:2058 ||
186+
| GET | `/services/conversion` | `handle_conversion_page` | server.py:2070 ||
187+
| GET | `/social` | `handle_social_feed_page` | server.py:2086 ||
188+
| GET | `/social/actor/{wallet}` | `handle_social_actor` | server.py:2090 | |
189+
| GET | `/social/feed` | `handle_social_feed` | server.py:2087 ||
190+
| GET | `/social/feed/stream` | `handle_social_feed_stream` | server.py:2088 ||
191+
| POST | `/social/follow` | `handle_social_follow` | server.py:2093 | |
192+
| POST | `/social/post` | `handle_social_post` | server.py:2083 | |
193+
| GET | `/social/stats` | `handle_social_stats` | server.py:2091 ||
194+
| GET | `/social/trending` | `handle_social_trending` | server.py:2089 ||
195+
| POST | `/social/unfollow` | `handle_social_unfollow` | server.py:2094 | |
196+
| GET | `/social/{address}/followers` | `handle_social_followers` | server.py:2095 | |
197+
| GET | `/social/{address}/following` | `handle_social_following` | server.py:2096 | |
198+
| GET | `/sponsor` | `handle_sponsor_redirect` | server.py:2111 ||
199+
| GET | `/status` | `handle_status` | server.py:2048 | |
200+
| GET | `/terms` | `handle_terms_page` | server.py:2072 ||
201+
| GET | `/ws` | `handle_websocket` | server.py:2046 ||

gateway/server.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,22 @@ def cleanup(self):
8888
del self._buckets[ip]
8989

9090

91+
def _body_key(body: dict, *keys: str) -> str:
92+
"""Return the first present key's value as a stripped string.
93+
94+
The MTRX iOS client encodes EVERY request body with
95+
``keyEncodingStrategy = .convertToSnakeCase``, so a camelCase field like
96+
``signedTransaction`` reaches the server as ``signed_transaction``. The
97+
SDK and Apple's own webhook payloads, by contrast, are camelCase. Routes
98+
that take a client-supplied key therefore accept BOTH spellings — pass the
99+
canonical camelCase first, then the snake_case fallback."""
100+
for k in keys:
101+
v = body.get(k)
102+
if v:
103+
return str(v).strip()
104+
return ""
105+
106+
91107
def _load_dotenv() -> None:
92108
"""Load environment variables from .env if python-dotenv is available."""
93109
try:
@@ -815,7 +831,9 @@ async def handle_apple_auth(self, request: web.Request) -> web.Response:
815831
except json.JSONDecodeError:
816832
return web.json_response({"error": "invalid JSON"}, status=400)
817833

818-
identity_token = str(body.get("identityToken", "")).strip()
834+
# The MTRX client's snake_case encoder sends identity_token; the SDK
835+
# sends identityToken. Accept either.
836+
identity_token = _body_key(body, "identityToken", "identity_token")
819837
if not identity_token:
820838
return web.json_response({"error": "identityToken required"}, status=400)
821839

@@ -934,7 +952,9 @@ async def handle_iap_verify(self, request: web.Request) -> web.Response:
934952
except json.JSONDecodeError:
935953
return web.json_response({"error": "invalid JSON"}, status=400)
936954

937-
jws = str(body.get("signedTransaction", "")).strip()
955+
# The MTRX client's snake_case encoder sends signed_transaction; the
956+
# SDK sends signedTransaction. Accept either.
957+
jws = _body_key(body, "signedTransaction", "signed_transaction")
938958
if not jws:
939959
return web.json_response(
940960
{"error": "signedTransaction required"}, status=400)

tests/test_iap_verify.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,26 @@ def test_garbage_rejected(self, iap_cfg):
211211
verify_signed_payload(bad, config=iap_cfg)
212212

213213

214+
class TestBodyKeyReconciliation:
215+
"""The shared _body_key helper reconciles the MTRX client's snake_case
216+
encoder with the camelCase server/SDK contract (used by /iap/verify AND
217+
/auth/apple)."""
218+
219+
def test_prefers_camel_then_snake(self):
220+
from gateway.server import _body_key
221+
assert _body_key({"signedTransaction": "A"}, "signedTransaction", "signed_transaction") == "A"
222+
assert _body_key({"signed_transaction": "B"}, "signedTransaction", "signed_transaction") == "B"
223+
# camelCase wins when both present (canonical first).
224+
assert _body_key({"signedTransaction": "A", "signed_transaction": "B"},
225+
"signedTransaction", "signed_transaction") == "A"
226+
227+
def test_absent_and_blank_yield_empty(self):
228+
from gateway.server import _body_key
229+
assert _body_key({}, "signedTransaction", "signed_transaction") == ""
230+
assert _body_key({"signed_transaction": ""}, "signedTransaction", "signed_transaction") == ""
231+
assert _body_key({"signed_transaction": " x "}, "signedTransaction", "signed_transaction") == "x"
232+
233+
214234
class TestBundleAndProducts:
215235
def test_wrong_bundle_rejected(self, iap_cfg):
216236
with pytest.raises(IAPError):
@@ -417,6 +437,18 @@ async def test_verify_consumable_records_no_tier(self, iap_client, chain):
417437
assert await iap_client._iap_store.transaction("t-redo-1") is not None
418438
assert await iap_client._iap_store.entitlement("t-redo-1") is None
419439

440+
@pytest.mark.asyncio
441+
async def test_verify_accepts_snake_case_body_key(self, iap_client, chain):
442+
# The MTRX iOS client's global snake_case encoder sends
443+
# `signed_transaction`; the route must accept it as well as the
444+
# camelCase `signedTransaction` the SDK sends. (Reconciliation P1.)
445+
jws = chain.sign(_tx_payload(transactionId="t-snake-1",
446+
originalTransactionId="o-snake-1"))
447+
resp = await iap_client.post("/api/v1/iap/verify",
448+
json={"signed_transaction": jws})
449+
assert resp.status == 200
450+
assert (await resp.json())["tier"] == "pro"
451+
420452
@pytest.mark.asyncio
421453
async def test_verify_wrong_bundle_401(self, iap_client, chain):
422454
jws = chain.sign(_tx_payload(bundleId="com.evil.other"))

0 commit comments

Comments
 (0)