Closes the 404 gap for legacy `/superset/<canonical>` bookmarks after
`Superset.route_base = ""` collapsed the historical `/superset` prefix off
every view. Adds an outermost WSGI shim that 308-redirects enumerated legacy
paths to their canonical equivalents (or 410s POSTs against GET-only
canonicals, where a 308 would have the client retry-POST into a 405).
Layering invariant
------------------
The shim wraps `app.wsgi_app` in `create_app()` *after* `init_app()` returns,
so it sits outside AppRootMiddleware / ProxyFix / ChunkedEncodingFix /
ADDITIONAL_MIDDLEWARE and sees the raw inbound `PATH_INFO`. `Location` is
built from the `app_root` captured at construction time + the canonical
path — never from `environ["SCRIPT_NAME"]` (unset at this outer layer) and
never from `X-Forwarded-*`. See module docstring + PLAN.md "WSGI layering
invariant" for the full ordering rationale.
AF-4 coupling closure
---------------------
`SqlaTable.sql_url` previously used naive `?table_name=…` concatenation,
which produced double-`?` URLs when `Database.sql_url` already carried a
query string (Database.sql_url was reshaped in Slice 3 to
`/sqllab/?dbid=<id>`). Switch to `urlsplit` / `parse_qsl` / `urlencode`
with `quote_via=quote` so query strings compose correctly and names with
special characters (`/`, `&`, `+`, …) are properly encoded.
Closed-set discipline
---------------------
`LEGACY_REDIRECT_MAP` is a 16-row closed table, each row verified against
the canonical endpoint's `@expose` decorator at HEAD. `/superset/sql/<id>/`
is intentionally absent — `Database.sql_url` changed shape (path → query
string) so no 1:1 mapping exists; the hard re-bookmark break is documented
in UPDATING.md. A snapshot regression test pins the keyset.
Oracle results (all RED-against-HEAD, reverted)
-----------------------------------------------
5-a remove shim wrap-site in app.py → 17 GETs fail
5-b flip `path_info.startswith` → exact-only → tail-bearing rows red
5-c drop allowed-methods gate → POST-410 rows red
5-d emit 302 instead of 308 → status assertion red
5-e read SCRIPT_NAME instead of captured app_root → contamination guard red
5-f read X-Forwarded-Prefix → contamination guard red
5-g `_LEGACY_PREFIX = ""` → pass-through guard red
5-h drop query-string passthrough → QS preservation red
5-i swap exact/longest-prefix order in _match → /dashboard/p/ row red
5-j AF-4: revert sql_url to naive concat → double-`?` test red
Round-3/4/6 review closures
---------------------------
* round-3 [High] prefix-source closure: `app_root_prefix` captured once at
construction; never re-read from environ.
* round-4 [High] wrap-site closure: shim installed at the single sanctioned
outermost wrap site in `create_app()`; pinned by `test_wrap_order_*`.
* round-6 `quote(safe=…)` pin: `Location` is %-encoded with a safe set that
preserves header-safe URL bytes while sanitising raw control bytes from
`PATH_INFO`.
* round-6 outermost-only-within-`create_app()` invariant: unconditional
wrap (independent of `app_root != "/"`) — legacy bookmarks exist under
root deployments too.
* H2 closure: 410 (not 308) for POST-against-GET-only avoids the 308→retry
→ 405 trap.
Tests
-----
Test file lives at `tests/unit_tests/middleware/` (not `integration_tests/`
per PLAN) because the local docker-light env can't service `/login/` POST.
Coverage is preserved via `werkzeug.test.Client` driving the middleware
around a sentinel inner WSGI app, plus `create_app()` + patched
`init_app()` for wrap-order assertions, plus live-route shadow pins.
* `tests/unit_tests/middleware/test_legacy_prefix_redirect.py` — 72 tests
(path rewriter, query-string preservation, SCRIPT_NAME/X-Forwarded
contamination guards, closed-set snapshot, wrap-order, live-route shadow
pins, AF-4 query encoding).
* `tests/unit_tests/initialization_test.py` — `_unwrap_to_app_root` helper
threads the new outermost layer so existing `TestCreateAppRoot` cases
keep passing.
UPDATING.md
-----------
Documents (a) legacy `/superset/*` 308 shim with EOL at 5.0.0, (b) hard
re-bookmark break for `/superset/sql/<database_id>/`, (c) `SqlaTable.sql_url`
query-string format change (AF-4 fix).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>