Public route ownership contract
POST /mcp fell through the canonical gateway to Rails (ENG-3184) because
the MCP router registered ALL /mcp and the gateway only treats
method-specific handlers as ownership. Route-level tests passed because they
never exercised the composed application with LEGACY_RAILS_FALLBACK_ENABLED.
The contract closes that gap:
rulebase-web/rulebase-api2/src/lib/legacy-rails-gateway/public-route-ownership.tsis a small manifest of exact method/path pairs for critical public contracts (MCP transport and OAuth discovery, native media, canonical and deprecated conversation uploads, identity, Outreach webhook ingress, representative/v1API-key routes) plus requests that must stay unowned. Unowned requests declare their expected boundary:forwardedfor Rails compatibility families (/conversation_images/*,/webhooks/unit21/*,/rails/active_storage/*,/trmnl/data),rejectedfor unknown or scanner paths API2 answers itself (PUT /mcp, unknown/v1paths,/,/.env,/wp-admin/*,/xmlrpc.php,/.git/config), andblockedfor/internal/*and/up.rulebase-web/rulebase-api2/src/app.route-ownership.test.tsruns the manifest against the final composedappwith the fallback enabled. For each contract it proves a method-specific handler matches, the wildcard fallback never fetches Rails,proxyLegacyRailsRequestis not invoked, nolegacy_rails_gatewayevent is logged, and the declared native event (for examplemcp.auth_failed,native_public_media) is emitted. Preflight contracts must be answered natively with204. Unowned requests must reach the gateway and produce exactly their declaredforwarded,rejected, orblockedoutcome;rejectedandblockedrequests must never fetch Rails andrejectedmust return Rails’{"status":404,"error":"Not Found"}body.
Extending the manifest
Every migration package that makes API2 the owner of a public route adds its
representative method/path pairs to PUBLIC_ROUTE_OWNERSHIP_CONTRACTS in the
same PR, with the anonymous status and the structured event the handler logs.
Register routes with explicit methods (router.get, router.on([...])),
never router.all; the contract fails on an ALL registration.
cd rulebase-web/rulebase-api2
pnpm vitest run src/app.route-ownership.test.ts src/app.test.ts src/lib/legacy-rails-gatewayPost-deploy external canaries
Run once per region against https://api.rulebase.co and
https://eu.api.rulebase.co after any deploy touching src/app.ts, the
gateway, or a manifest family. The canaries are anonymous and mutate nothing.
-
Send each manifest request and compare with
unauthenticatedStatus:H=https://api.rulebase.co curl -s -o /dev/null -w '%{http_code} POST /mcp\n' -X POST $H/mcp curl -s -o /dev/null -w '%{http_code} GET /mcp\n' $H/mcp curl -s -o /dev/null -w '%{http_code} DELETE /mcp\n' -X DELETE $H/mcp curl -s -o /dev/null -w '%{http_code} oauth-protected-resource\n' $H/.well-known/oauth-protected-resource curl -s -o /dev/null -w '%{http_code} oauth-authorization-server\n' $H/.well-known/oauth-authorization-server curl -s -o /dev/null -w '%{http_code} GET redirect\n' $H/rails/active_storage/blobs/redirect/tampered/avatar.png curl -s -o /dev/null -w '%{http_code} HEAD image\n' -I $H/conversation_images/tampered curl -s -o /dev/null -w '%{http_code} GET sidebar\n' $H/zendesk-app/sidebar curl -s -o /dev/null -w '%{http_code} POST sidebar\n' -X POST -d token=tampered $H/zendesk-app/sidebar curl -s -o /dev/null -w '%{http_code} POST /v1/conversation_uploads\n' -X POST $H/v1/conversation_uploads curl -s -o /dev/null -w '%{http_code} POST /conversations/upload\n' -X POST $H/conversations/upload curl -s -o /dev/null -w '%{http_code} POST /webhooks/outreach\n' -X POST -H 'Content-Type: application/json' -d '{}' $H/webhooks/outreach curl -s -o /dev/null -w '%{http_code} GET /v1/identity\n' $H/v1/identity curl -s -o /dev/null -w '%{http_code} GET /v1/work_items\n' $H/v1/work_items curl -s -o /dev/null -w '%{http_code} preflight POST /mcp\n' -X OPTIONS \ -H 'Origin: https://app.rulebase.co' -H 'Access-Control-Request-Method: POST' $H/mcpExpect
401for MCP and API-key routes,200for both discovery documents,403for the unsigned Outreach webhook (native_outreach_webhookwithoutcome=forbidden_signature),404for tampered media,400then401for the Zendesk sidebar (production verifies tokens; the manifest’s200applies to the non-production composed app), and204for the preflight. -
In the API2 logs for that region, filter by the canary window and request IDs. Expect the native structured event for each family (
mcp.auth_failedfor/mcp,native_public_mediawithoutcome=invalid_signed_idfor media,native_zendesk_sidebarwithoutcome=missing_tokenthenmalformed_tokenfor the sidebar) and zerolegacy_rails_gatewayevents whosepathis any manifest path. Alegacy_rails_gatewayevent withoutcome=forwardedfor a manifest path means ownership regressed: roll back the deploy before investigating. -
Confirm the negative boundary still behaves:
GET $H/upandGET $H/internal/organizationsreturn404withlegacy_rails_gateway outcome=blocked, an unknown/v1/...path andGET $H/.envreturn404withoutcome=rejectedand no upstream origin, andGET $H/trmnl/datastill logsoutcome=forwarded. If a documented Rails path unexpectedly returnsoutcome=rejected, setLEGACY_RAILS_FALLBACK_UNKNOWN_PATHS=forwardon API2 to restore wildcard forwarding, then add the missing prefix torails-public-route-prefixes.json.
Repeat steps 1-3 with H=https://eu.api.rulebase.co before declaring the
deploy healthy.