Skip to Content
Internal docs are powered by Nextra Docs Theme.
SystemsPlatformAPI2Rails to API2 migrationPackagesPublic route ownership contract

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.ts is 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 /v1 API-key routes) plus requests that must stay unowned. Unowned requests declare their expected boundary: forwarded for Rails compatibility families (/conversation_images/*, /webhooks/unit21/*, /rails/active_storage/*, /trmnl/data), rejected for unknown or scanner paths API2 answers itself (PUT /mcp, unknown /v1 paths, /, /.env, /wp-admin/*, /xmlrpc.php, /.git/config), and blocked for /internal/* and /up.
  • rulebase-web/rulebase-api2/src/app.route-ownership.test.ts runs the manifest against the final composed app with the fallback enabled. For each contract it proves a method-specific handler matches, the wildcard fallback never fetches Rails, proxyLegacyRailsRequest is not invoked, no legacy_rails_gateway event is logged, and the declared native event (for example mcp.auth_failed, native_public_media) is emitted. Preflight contracts must be answered natively with 204. Unowned requests must reach the gateway and produce exactly their declared forwarded, rejected, or blocked outcome; rejected and blocked requests must never fetch Rails and rejected must 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-gateway

Post-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.

  1. 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/mcp

    Expect 401 for MCP and API-key routes, 200 for both discovery documents, 403 for the unsigned Outreach webhook (native_outreach_webhook with outcome=forbidden_signature), 404 for tampered media, 400 then 401 for the Zendesk sidebar (production verifies tokens; the manifest’s 200 applies to the non-production composed app), and 204 for the preflight.

  2. 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_failed for /mcp, native_public_media with outcome=invalid_signed_id for media, native_zendesk_sidebar with outcome=missing_token then malformed_token for the sidebar) and zero legacy_rails_gateway events whose path is any manifest path. A legacy_rails_gateway event with outcome=forwarded for a manifest path means ownership regressed: roll back the deploy before investigating.

  3. Confirm the negative boundary still behaves: GET $H/up and GET $H/internal/organizations return 404 with legacy_rails_gateway outcome=blocked, an unknown /v1/... path and GET $H/.env return 404 with outcome=rejected and no upstream origin, and GET $H/trmnl/data still logs outcome=forwarded. If a documented Rails path unexpectedly returns outcome=rejected, set LEGACY_RAILS_FALLBACK_UNKNOWN_PATHS=forward on API2 to restore wildcard forwarding, then add the missing prefix to rails-public-route-prefixes.json.

Repeat steps 1-3 with H=https://eu.api.rulebase.co before declaring the deploy healthy.

Last updated on