diff --git a/docs/content/docs/architecture/API-ROUTE-LAYOUT.md b/docs/content/docs/architecture/API-ROUTE-LAYOUT.md
new file mode 100644
index 0000000..b8611ab
--- /dev/null
+++ b/docs/content/docs/architecture/API-ROUTE-LAYOUT.md
@@ -0,0 +1,599 @@
+---
+title: "API Route Layout and Refactoring Plan"
+---
+
+> **Tracking issue:** [#451 "Merge soundtouch-web into soundtouch-service"](https://github.com/gesellix/Bose-SoundTouch/issues/451).
+> This document is the architectural reference for the staged API refactoring
+> that precedes (and enables) that merge.
+
+## Why this exists
+
+`soundtouch-service` and `soundtouch-web` are two binaries with two routers.
+We want to:
+
+1. Restructure our own routes into a layout that can stay stable.
+2. Eventually fold `soundtouch-web` into `soundtouch-service` (one binary).
+3. Stop leaking frontend (SPA) routes into the backend API.
+4. Make **cloud / remote-host a first-class, clean deployment**, not just LAN /
+ on-device. This is a primary motivation: we consolidate the API *in a way
+ that* closes the trust and auth gaps a public deployment exposes, rather than
+ just merging two binaries. Enforced auth is therefore a real requirement, not
+ an afterthought.
+
+Before moving anything, every route has to be classified by **whether we are
+free to move it**, and that depends on **who the client is**. A route the
+speaker firmware calls is frozen forever; an internal admin route is ours to
+reshape.
+
+## Classification criteria
+
+Classify by client audience, then by what pins the path:
+
+| Category | Client | Free to move? |
+|------------------------------------|-----------------------------------|----------------------------------------------------------------------------------------------------------------|
+| **(1a) Frozen, firmware-pinned** | Speaker firmware | No, ever. The path is hardcoded in the speaker (or relative to a base it fetches from us). |
+| **(1b) Frozen, externally-pinned** | OAuth providers (Spotify/Amazon) | Only with provider re-registration + device re-priming. Treat as frozen unless that cost is paid deliberately. |
+| **(2) Service-internal** | The admin/setup UI | Yes, freely. These are ours. |
+| **(3) Web/control** | The control UI (soundtouch-web) | Yes, freely. |
+| **(4) Frontend (SPA)** | Browser, client-side routing | Should not be enumerated in the backend at all (see `/app/*` below). |
+| **(Infra)** | Humans, monitoring, the SPA shell | Conventionally stable; collision-prone at merge time. |
+
+Two refinements that matter in practice:
+
+- **"Must stay" is not one thing.** (1a) is immovable; (1b) is movable but
+ coordinated. Do not lump OAuth callbacks in with firmware paths.
+- **There is a merge-collision bucket.** `/`, `/health`, and `/ws` are defined
+ in *both* routers today and must be reconciled when the binaries merge. TuneIn
+ is **not** in this bucket: `/bmx/tunein/*` (speaker <-> BMX integration, frozen)
+ and `/api/tunein/*` (the player's generalized radio search/play, ours to
+ change) are two different layers, not a collision.
+
+## What pins the frozen routes (evidence)
+
+- The speaker fetches BMX content, marge/streaming data, software updates, and
+ CED config from hostnames it has hardcoded (or from a base URL we hand it).
+ `/ced/*` mirrors `downloads.bose.com/ced/soundtouch/...`; `/bmx`, `/core02`,
+ `/streaming`, `/accounts`, `/customer`, `/oauth`, `/v1` mirror the Bose cloud
+ contract.
+- Persisted device data embeds absolute service URLs. Presets store
+ `LOCAL_INTERNET_RADIO`/Orion locations like
+ `https://.../core02/svc-bmx-adapter-orion/prod/orion/station?data=...`, and
+ the BMX registry advertises `{MEDIA_SERVER}/media` and `/bmx-icons`. So
+ `/media`, `/bmx-icons`, `/custom`, and `/core02` are effectively part of the
+ firmware-facing contract: a speaker that stored a preset will replay that
+ exact URL later. They cannot move without rewriting persisted state on every
+ device.
+
+## Service routes (`soundtouch-service`)
+
+Grouped by prefix. The authoritative enumerated list is the router golden file
+`cmd/soundtouch-service/testdata/router_routes.txt`.
+
+| Prefix | Category | Client | Movable? |
+|----------------------------------------------------------------------------------------------------|-----------------------|---------------------------------------|------------------------------------------|
+| `/streaming/*` | (1a) frozen | Speaker (marge / streaming.bose.com) | No |
+| `/accounts/*` | (1a) frozen | Speaker (marge, alternate paths) | No |
+| `/customer/account/*` | (1a) frozen | Speaker | No |
+| `/bmx/*` (registry + tunein) | (1a) frozen | Speaker (BMX) | No |
+| `/core02/svc-bmx-adapter-*` (Orion, SiriusXM) | (1a) frozen | Speaker (BMX adapters) | No |
+| `/oauth/*/token`
`/oauth/*/token/cs`
`/oauth/*/token/cs1`
`/oauth/*/token/cs3` | (1a) frozen | Speaker (music tokens) | No |
+| `/custom/v1/playback/*` | (1a) frozen | Speaker (LOCAL_INTERNET_RADIO / ding) | No |
+| `/bmx-icons/*`
`/media/*`
`/media/aftertouch-ding.wav`
`/media/tts/*` | (1a) frozen | Speaker (advertised base) | No |
+| `/streaming/resources/api_versions.xml`
`/streaming/software/update/*`
`/updates/soundtouch` | (1a) frozen | Speaker (SW update) | No |
+| `/v1/auth`
`/v1/blacklist/*`
`/v1/scmudc/*`
`/v1/stapp/*` | (1a) frozen | Speaker | No |
+| `/alexa/certificate` | (1a) frozen | Speaker / AWS | No |
+| `/ced/*` | (1a) frozen | Speaker (mirrors downloads.bose.com) | No |
+| `/mgmt/amazon/callback`
`/mgmt/spotify/callback` | (1b) frozen, external | OAuth providers | Only with re-registration |
+| `/setup/*` (~40 routes) | (2) service-internal | Admin UI | Yes |
+| `/mgmt/*` (except the callbacks above) | (2) service-internal | Admin UI | Yes |
+| `/web/*` (`HandleWeb`) | (4) frontend | Browser (admin SPA) | Yes; already the clean catch-all pattern |
+| `/`
`/docs/*`
`/favicon.ico`
`/health` | (Infra) | Humans / monitoring | Keep stable by convention |
+
+## Web routes (`soundtouch-web`)
+
+Defined in `pkg/service/soundtouchweb/mount.go`. Not currently mounted inside
+the service; it is a separate binary.
+
+| Group | Category | Note |
+|------------------------------------------------------------------------------------------|-----------------|--------------------------------------------------------------------------------------|
+| `/api/*` (devices, control, tunein, zone, radiobrowser, play-url, device-speak) | (3) web/control | Freely restructurable |
+| `/health`
`/static/*`
`/ws` | (Infra) | `/health` and `/ws` collide with the service at merge time |
+| `/`
`/device/*`
`/devices`
`/playurl`
`/radiobrowser`
`/tts`
`/tunein` | (4) frontend | The anti-pattern: each SPA route enumerated in the backend, all serving `index.html` |
+
+## Deployment scenarios, reachability, and trust boundaries
+
+The client-audience axis tells you *who* calls a route. The deployment tells you
+whether that caller can actually reach it and whether the surrounding network
+can be trusted. AfterTouch runs in materially different places, and that decides
+which routes are even *meaningful* and what the trust boundary is.
+
+### Actors (the original Bose model)
+
+The original Bose architecture had three actors, and our route surface still
+reflects all three:
+
+| Actor | Where | Role |
+|---------|----------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------|
+| Speaker | Local (the device) | Calls the cloud for its data-plane (`/full`, presets, sources, software update, tokens) and is provisioned by the app. |
+| App | Local (phone / desktop), **in-between** | Creates the account, adds a speaker to an account, and teaches the speaker its cloud/marge credentials. **Authenticates itself** to the cloud. |
+| Cloud | External / public (what AfterTouch replaces) | Serves the speaker data-plane and the app's account/provisioning calls. |
+
+Two things matter for our design:
+
+- **The app is deployment-agnostic.** It does not care whether the cloud (our
+ service) runs locally or in a datacenter; it talks to whatever cloud endpoint
+ it is pointed at. So the **deployment modes below are about where the *cloud*
+ role runs**, orthogonal to the app actor.
+- **AfterTouch's own tooling currently plays the app's role.** Account creation
+ and "teach the speaker its marge account" are done by our migration tooling
+ (today via the speaker's local WebSocket `setMargeAccount`), i.e. we are the
+ provisioning agent. But the app-facing *cloud* endpoints still exist in the
+ surface (account create/login, add device, profile, password, groups), and a
+ real app pointed at us would use them. They are part of the frozen contract,
+ but their caller and trust story differ from the speaker's data-plane (see
+ below).
+
+### Deployment topologies (where the cloud role runs)
+
+This is descriptive (where it runs), distinct from the `deployment-mode`
+*parameter* below (the security posture). They correlate but are kept separate so
+an operator is not locked into one because of the other.
+
+| Topology | Where | Reaches speakers directly? | Speaker reaches it? |
+|---------------------|-------------------------------------------|----------------------------|-------------------------------------------|
+| On-device | On the speaker itself | Itself only | Yes (loopback / LAN) |
+| LAN host | Raspberry Pi / Docker on the home network | Yes (same LAN) | Yes |
+| Cloud / remote host | External host, not on the speaker LAN | No | Yes (speaker calls out over the internet) |
+
+### Two planes: speaker-direct vs data-plane
+
+Routes fall into two reachability planes that behave very differently across
+deployments:
+
+- **Speaker-direct (control plane):** the service opens a connection *to* the
+ speaker's local API (`:8090`) right now. Discovery, migration, reboot,
+ test-connection, peer-probe, and the entire `soundtouch-web`
+ control/zone/volume/key/TTS-to-speaker surface. These only work where the host
+ shares the LAN with the speaker. **In a cloud deployment they are dead weight**,
+ and any UI that shows them is misleading.
+- **Data-plane (cloud replacement):** something calls the *service*, which works
+ in every deployment because the caller reaches in. Two callers live here:
+ - **Speaker-polled:** the speaker fetches its own data (`/full`, sources,
+ presets, recents, provider/device settings, software update, streaming
+ token, stats). No user auth; the speaker is identified by account/device.
+ - **App / provisioning-called:** the app (or, today, our own tooling acting as
+ the app) creates accounts, logs in, adds/updates/removes devices, edits the
+ profile/password, and manages groups. In the original model the app
+ **authenticates itself** here, so these endpoints carry an auth dimension the
+ speaker's polling does not. They are deployment-agnostic: the app reaches the
+ cloud wherever it runs.
+
+So a cloud deployment is essentially the data-plane (both callers) plus
+server-side state management (accounts, presets, provider credentials,
+diagnostics of stored data). The interactive "do something to a speaker now"
+features (both the player and migration) need LAN proximity.
+
+Consequence for the migration tooling (ref the #451 discussion): migration is
+**recurring**, not one-shot (you add a speaker later too), and it is
+**LAN-bound**. That argues for migration as a local mode/tool you run on the LAN
+when needed, rather than always-on code in a cloud binary that could never use
+it.
+
+### Trust zones and the current state
+
+The trust zones, mapped to the actors above, and today barely any is guarded:
+
+| Zone | Routes | Client auth today | Should be |
+|--------------------|-------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------|
+| Speaker contract | frozen (1a), speaker-polled | None (no user login; the app_key is validated but is not user auth) | None, but network-segmentable; in cloud these are necessarily public so the speaker can reach them |
+| App / provisioning | account create/login, add/update/remove device, profile, password, groups (`/streaming/account*`, `/customer/account*`) | None enforced (we accept; the app's self-auth from the original model is not required) | Authenticated in cloud: an open provisioning surface lets anyone create accounts or attach devices |
+| Admin / setup | `/mgmt/*` (non-callback)
`/setup/*`
`/web/*` | `/mgmt/*` has single-credential HTTP Basic Auth; **`/setup/*` has none** (explicit "LAN-trust" premise); the Basic Auth even leaks behind a proxy (#419) | Authenticated always; mandatory in cloud |
+| Control / player | `/api/control/*` (post-merge) | None | Optional auth; low blast radius |
+
+The "LAN-trust" premise is defensible on a home LAN but **invalid in the cloud**:
+`/setup/*` (migration, DNS redirect, trust roots / cert state, account data,
+diagnostics, recovery) is wide open, and so is the app/provisioning surface
+(anyone could create an account or attach a device). On a public host both are a
+real exposure. Closing these gaps is a prerequisite for treating cloud as a
+supported deployment.
+
+### Requirements this drives
+
+- **Authentication** on everything user-facing, actually enforced (not
+ bypassable behind a proxy, see #419). Mandatory for cloud; offered and
+ recommended for LAN.
+- **Authorization tiers** by blast radius (the "authority boundary" from the
+ #451 landing-page note): a low-privilege user may open the player, while
+ setup/mgmt (trust roots, migration, accounts) require admin. The landing page
+ is where that boundary is made explicit.
+- **Deployment-aware surface:** in cloud mode, hide/disable the speaker-direct
+ features (they cannot work) and require auth on the rest; in LAN/on-device
+ mode, expose the full surface.
+
+These requirements are why the `/api/*` split below is grouped by trust tier:
+applying an auth (and later authz) middleware to a whole group is a one-liner,
+whereas per-route auth is what produced today's patchy coverage.
+
+### The `deployment-mode` parameter (private / shared / public)
+
+The security posture is an explicit parameter, **default `private`**. It is a
+preset over the per-tier auth machinery, not separate architecture: each value
+just sets which trust tiers require auth.
+
+| Tier (caller) | private | shared | public |
+|------------------------------|---------------|-----------------------|-----------------|
+| Speaker contract | none (frozen) | none | none |
+| Control / player | open | open | **auth** |
+| Admin / setup + provisioning | open (opt-in) | **auth (min. Basic)** | **auth** |
+| Speaker-direct features | on | on | hidden/disabled |
+
+- **private** (default): free-for-all, maximum insecurity, security is opt-in.
+ Matches a trusted single-owner LAN or on-device.
+- **public**: opt-out of security. Everything user-facing requires auth; the
+ surrounding network is untrusted (cloud / internet). Speaker-direct features
+ are hidden (they cannot work off-LAN anyway).
+- **shared**: at least Basic Auth on the structural / admin routes, while the
+ player stays open. The multi-user trusted-LAN case (guests, kids, roommates):
+ daily playback without a login, but infrastructure is protected.
+
+The **Speaker-direct features** row is UI gating, not auth. "Speaker-direct"
+means actions that reach a speaker on the LAN (discover, migrate, reboot,
+volume/play, zone). In `public` the UI hides or disables them because they cannot
+work off-LAN, so we do not show buttons that only fail; in `private` / `shared`
+they are shown. The UI derives this from the mode.
+
+**Provisioning is treated like admin**, not like the player: creating accounts
+and attaching devices is structural / high-blast-radius, so it shares the admin
+trust tier (protected in `shared` and `public`). The mechanism may still be Marge
+self-auth, but the *requirement* matches admin.
+
+It is a **monotone ladder**: private -> shared adds admin (+ provisioning) auth;
+shared -> public additionally locks the player.
+
+**Is `shared` necessary, or just `public`?** It is necessary and distinct. The
+only difference between shared and public is the **player tier**: shared keeps it
+open (trusted network, frictionless household use), public locks it (untrusted
+network). Collapsing them forces either a password on the daily-use player at
+home, or an open player on the internet. The cost of keeping `shared` is near
+zero once tier-based auth exists (it is just "admin required, player optional"),
+so it earns its place as the trusted-LAN-with-privilege-split preset.
+
+Note this 3-value enum compresses two orthogonal axes, network trust (private /
+shared = trusted; public = untrusted) and the player/admin privilege split. That
+is a deliberate usability simplification over a toggle matrix; if the presets
+ever feel too coarse, the underlying per-tier toggles are the escape hatch.
+
+**Configuration and lockout-safety.** `deployment-mode` is set like the other
+config: CLI flag, env var, and persisted setting, with the same precedence as
+the rest (e.g. like `server-url`). Because of that, the host operator always has
+an out-of-band path: even if a mode change in the UI would lock them out, they
+can reset it via env / flag / the settings file on the host. The service must
+also not let a setting strand its owner: if a mode requires auth but no
+credential / provider is configured yet, warn and keep a way in (refuse to apply,
+fall back, or allow a loopback/on-host admin bypass) rather than hard-locking the
+admin surface.
+
+### Auth posture: opt-none -> opt-in -> opt-out?
+
+The maturity path over releases, which the `deployment-mode` parameter then
+expresses per posture:
+
+- **Today: "opt-none".** Auth is not even opt-in. `/mgmt` has a single Basic-Auth
+ credential (and it leaks behind a proxy, #419); `/setup` and the
+ app/provisioning surface have nothing. There is effectively no usable way to
+ turn real auth on. This is `private` before `private` is even a choice.
+- **0.x: prepare opt-in.** Make auth something an operator *can* enable
+ (enforced, not proxy-bypassable; covering the whole admin tier, ideally the
+ provisioning surface too) and introduce the `deployment-mode` parameter so
+ `shared` / `public` become selectable. The default stays `private` (security
+ off) so existing LAN setups are undisturbed.
+- **1.x: default still `private`?** The parameter exists, but whether the
+ shipped default should ever move off `private` is the open call. A cloud-first
+ stance argues for stricter defaults; the home-LAN majority argues for keeping
+ `private`. Because the posture is now an explicit parameter, the default can
+ stay `private` while operators opt into `shared` / `public`, so there is no
+ need for a hard global flip.
+
+### Auth mechanisms
+
+Three identities, three mechanisms, only the last two are ours to shape:
+
+- **Speaker -> data-plane: fixed, not ours to change.** The speaker authenticates
+ with a long-lived **Marge account token**, provisioned as an account ID + auth
+ token (`SetMargeAccount(accountID, authToken)`,
+ `pkg/service/setup/init_plan.go`); it is *not* given an email/password. This is
+ part of the frozen contract, so no new auth mechanism can be imposed on the
+ speaker.
+- **Admin -> admin UI: HTTP Basic Auth to start, pluggable later.** We begin with
+ Basic Auth as the single admin mechanism, but structure it behind one boundary
+ so additional providers (OIDC, etc.) are easy to add. None of this ever reaches
+ a speaker; it is purely our app's auth.
+- **User -> web app: Marge auth, delegated.** A human (not a speaker) signs into
+ the player/control UI with their Marge account, and that authentication
+ **delegates to the existing Marge routes** (`/streaming/account/login` and the
+ app/provisioning surface). "User auth" thus reuses the same account the speaker
+ belongs to, rather than a separate user store.
+- **Native / non-browser clients (CLI, desktop or mobile app, automation) ->
+ service.** A whole client class, not just the CLI. Talking to a *speaker's*
+ local API needs no service auth; talking to *our service*
+ (cloud/admin/provisioning routes) makes them authenticated clients. Interactive
+ native clients do OIDC the standard way (RFC 8252, "OAuth 2.0 for Native
+ Apps"): a loopback `localhost:` redirect (CLI / desktop) or a private-use
+ URI-scheme redirect (`app://callback`, mobile); the system browser runs the
+ flow and the client exchanges the code for a token. The case that still needs a
+ **non-interactive** credential (issued token / API key, or a device-code /
+ client-credentials grant) is **headless** automation: CI, scripts, no browser.
+ Requirements on the provider abstraction: (a) support both an interactive path
+ (browser, including native loopback / custom-scheme redirects) and a headless
+ token path, and (b) allow registering those redirect URIs (the same
+ externally-pinned concern as the Spotify/Amazon callbacks).
+
+**Mental model: Marge is an auth provider, like EntraID would be.** The UI auth
+sits behind one provider abstraction, and Marge is simply one provider
+implementation (the built-in / legacy one) alongside Basic Auth and future OIDC
+providers (EntraID, Google, ...). "Sign in with your Marge account" is the same
+pattern as "Sign in with EntraID": the app delegates to the provider. Basic Auth,
+Marge, and any OIDC provider all implement the same interface, so they are
+interchangeable and additive.
+
+Design rule: keep the UI auth pluggable behind that single provider boundary so
+new providers slot in without touching the speaker contract (which is not a
+provider and never changes) or the Marge delegation.
+
+### Identity in logs
+
+Request logs should carry the resolved caller identity as context, **but only
+where the request actually exposes one** (do not fabricate an id the protocol did
+not send):
+
+- **Authenticated UI / native / headless clients:** once auth lands, log the
+ principal (provider subject / username / client id).
+- **Speakers:** there is no single speaker login, so it depends on the route.
+ Many marge/streaming routes embed `{account}` / `{device}` in the path (also
+ `/v1/scmudc/{deviceId}`, `/v1/stapp/{deviceId}`), so the device/account is
+ available and worth logging. Others (BMX content like `/bmx/tunein/...`,
+ `/v1/auth`) carry only a token / app_key or nothing identifying; log what is
+ present and otherwise leave it blank rather than guessing.
+- **Unauthenticated:** mark as anonymous.
+
+Caveats: sanitise the value before logging (the existing log-injection guard,
+`sanitizeLog` / `sanitizeErr`), and remember these ids (account / device /
+principal) are sensitive, so they must follow the existing log redaction on
+diagnostic export, not leak into shared bundles.
+
+## Target layout
+
+```
+# Frozen compat layer (top-level, never reshape):
+/streaming /accounts /customer /bmx /core02 /oauth /custom
+/media /bmx-icons /updates /v1 /alexa /ced
+
+# Our JSON API (everything movable lives here, grouped BY TRUST TIER so
+# auth/authz middleware applies per group, not per route):
+/api/setup/* (today: /setup/*) -> admin tier: auth required
+/api/mgmt/* (today: /mgmt/*, no callbacks) -> admin tier: auth required
+/api/control/* (today: soundtouch-web /api/*) -> player tier: auth optional
+/api/devices ...
+
+# OAuth provider callbacks (externally-pinned; freeze in place,
+# or move only with provider re-registration):
+/mgmt/spotify/callback, /mgmt/amazon/callback
+
+# Frontend (one role-gated app, single catch-all, no per-route registration):
+/app/* (the unified app; role/auth decides Player vs Setup visibility)
+/web/* (legacy admin UI; retired once /app/* subsumes it)
+
+# Infra:
+/health /metrics /ws
+```
+
+### The `/app/*` pattern
+
+The service's admin UI already does the right thing: `/web/*` is one catch-all
+(`HandleWeb`), not one route per page. The `soundtouch-web` SPA routes
+(`mount.go`, the `/`, `/devices`, `/tunein`, ... block) are the legacy
+anti-pattern. The target:
+
+- **`/app/*`** is a single catch-all that returns `index.html`. The browser does
+ client-side routing within `/app/`. No frontend path appears in the backend
+ router.
+- **`/api/*`** serves data only.
+- Static assets live under a fixed prefix (e.g. `/app/static/*`).
+
+This keeps the backend API free of frontend routes while still avoiding any
+need for server-side SPA routing config.
+
+### One app, role-gated (not two apps)
+
+Decision: converge to a **single app** under `/app/*`; role/auth decides what a
+user sees (Player vs Setup are views of one app, not separate apps). This is the
+natural expression of the trust tiers, removes the duplicated shell / device
+handling the two frontends carry today, and lets them share device list and
+state (the data-sharing win from the #451 discussion). `/web/*` is retired once
+`/app/*` subsumes it.
+
+Two things make this safe:
+
+- **Size (the on-device concern): "one app" is not "one eager bundle."**
+ Code-split the heavy Setup/Admin surface (migration, certs, DNS, diagnostics,
+ the ~4.8k-line `script.js`) into a **lazily loaded chunk** that loads only when
+ an admin navigates there, so the Player path stays light. If size ever gets
+ tight on-device, a **build tag / flag** can produce a player-only variant that
+ does not embed the Setup chunk at all. The combined *embedded* size is likely
+ to *drop*, not grow, since two separate apps duplicate more than one modular
+ app does; the only real risk is naive eager bundling. Guard it with a
+ bundle-size / route-count acceptance check (per the #451 discussion): measure
+ first.
+- **Role-gating is UX, not security.** Hiding the Setup views from non-admins is
+ convenience only. The real boundary stays the **server-side auth middleware**
+ on the admin / provisioning tiers, otherwise someone just loads the chunk and
+ calls the routes directly.
+
+## Regression safety: contract tests from the frozen recordings
+
+Build the regression net **before** touching routes. We already record
+interactions (`RECORD_INTERACTIONS`) and have a large collection; frozen and
+sanitised, that collection becomes a contract suite that proves the refactor
+preserves behavior. It is stronger than the router golden file
+(`router_routes.txt`), which only checks that routes are registered, not what
+they return.
+
+Two directions, matching the two consumers:
+
+- **Speaker contract (highest value): provider-side replay.** The speaker is a
+ consumer we do *not* control (it is Bose firmware), so this is not classic
+ consumer-driven Pact: the speaker's real recorded traffic *is* the contract.
+ Replay each recorded request against the service and assert the response still
+ matches (body and headers). This pins category-1 byte-for-byte, exactly the
+ invariant the refactor must not break, and it catches subtle wire details a
+ route reshuffle could disturb (for example the case-sensitive `ETag` header).
+ It aligns with the existing parity tests (local vs official Bose recordings).
+- **CLI / `/api/*` contract (optional): consumer-driven Pact.** The CLI is a
+ consumer we *do* control, so real Pact fits: the CLI declares expectations and
+ the service verifies them. Most useful once the new `/api/*` shape exists, and
+ to assert **dual-routing equivalence** (old and new path satisfy the same
+ contract). Lower priority, since this surface is intentionally changing in 0.x.
+
+We are not starting from zero: the existing `tests/integration/http-client/*.http`
+suite (run in CI via `make test-http-client` against the service plus the
+spotify/amazon mocks) is already a near-consumer-driven contract from the
+speaker's perspective. The requests carry the firmware user-agent
+(`Bose_Lisa/27.0.6`) and assert status, content-type, and XML structure of the
+marge/streaming/BMX routes. It is not literally Pact (no consumer/provider broker
+or generated pacts), but it is functionally the speaker contract, and it already
+asserts structure and invariants rather than raw bytes, which is exactly the
+matcher approach that keeps contracts non-flaky. The natural path is to treat
+this suite as the seed and broaden it with the frozen recordings, rather than
+inventing a new harness.
+
+How it de-risks the rebuild:
+
+- Pins the frozen speaker contract so a route reshuffle cannot silently alter the
+ wire.
+- During dual-routing, runs the same contract against both old and new paths to
+ prove the alias is faithful.
+- Becomes the gate: the refactor lands only when the contract suite is green.
+
+Caveats:
+
+- **Sanitise before freezing.** Recordings carry real IPs, MACs, account /
+ device ids, and tokens; per the repo rules they must be anonymised (the
+ existing testdata anonymisation / rotation) before they become committed
+ fixtures.
+- **Match, do not byte-compare blindly.** Legitimately dynamic fields
+ (timestamps, tokens, generated ids, ETag *values*) need normalisation /
+ matchers, or the contracts go flaky. Freeze structure and invariants, not the
+ volatile bits.
+
+## Staged migration
+
+Everything below happens **within 0.x**. 1.x is only the cutover (removal). The
+frozen speaker/app contract routes (category 1) are out of scope throughout: they
+never move, so none of the aliasing / redirect / deprecation machinery touches
+them.
+
+### Route-transition track (0.x)
+
+1. **Add the new routes, switch the service admin UI to them, alias the old
+ paths.** Mount `/setup/*` and `/mgmt/*` under the new `/api/*` grouping (chi
+ `Route`/`Mount`; carve it so `/api/control/*` fits later) and point
+ `script.js` at the new paths.
+ - **Use aliasing (dual-mount), not HTTP redirects, for our own routes:**
+ register the same handler at both the old and new path. It avoids the
+ client-following and method/body pitfalls of redirects (a redirect would
+ have to be 307/308 to keep a POST body) and is a no-break upgrade for any
+ lagging client.
+ - **Does this work for speaker/legacy routes? No, and it is not needed.** We
+ never move frozen routes, and a fixed speaker firmware cannot be assumed to
+ follow a redirect on its marge/BMX calls (untested; do not rely on it). This
+ step is about our movable routes only.
+ - **Exclude** `/mgmt/spotify/callback` and `/mgmt/amazon/callback` (1b):
+ freeze, or move only with a deliberate provider re-registration.
+2. **Fold `soundtouch-web` into the service.** Bring the control API in as
+ `/api/control/*` and the UI under `/app/*` (one role-gated app, see above).
+ Reconcile the `/`, `/health`, `/ws` collisions (the web socket as e.g.
+ `/api/control/ws` or one shared `/ws`; the service keeps `/` and `/health`).
+ Keep the two TuneIn layers separate (frozen `/bmx/tunein/*` vs the player's
+ `/api/control/*` radio feature). Add the landing page / authority boundary at
+ `/`.
+3. **Deprecate the `soundtouch-web` binary.** It keeps working in 0.x but prints
+ a startup deprecation warning (along the lines of "this binary is removed in
+ 1.x, use soundtouch-service") so its removal is no surprise.
+4. **Warn on old-route hits in the service, observably.** When a deprecated path
+ is called, log a deprecation warning **and** count it (a metric / signal), so
+ the 1.x removal is data-driven: a route is only cut once it has gone quiet
+ across real deployments, not on a guess.
+
+### Auth track (0.x, parallel)
+
+- Group `/setup/*` + `/mgmt/*` (+ provisioning) into one admin tier and apply a
+ single auth middleware, replacing today's per-route gap (`/mgmt` has Basic
+ Auth, `/setup` has none).
+- Make auth enforceable behind a reverse proxy (close #419), not dependent on a
+ header a proxy can strip.
+- Land the `deployment-mode` parameter (private / shared / public) with its
+ lockout-safety, and the speaker-direct UI gating.
+- Authorization (player vs admin tiers) can follow authentication; design the
+ groups now so it slots in without another reshuffle.
+
+### Before 1.x: definition of done
+
+1.x removes the old routes and the deprecated binary, so all of this must be true
+in a 0.x release first:
+
+- **Auth / `deployment-mode` actually shipped** and opt-in works. This is the
+ cloud-first motivation; without it 1.x has no payoff.
+- **Every client we ship moved off the old paths:** the admin UI, the merged
+ app, the **CLI**, the **HTTP-client integration tests**, **docs and examples**,
+ any reverse-proxy guide. The 0.x dual-routing is their migration window, but
+ someone has to actually move them.
+- **Old-route usage has gone quiet** in the step-4 signal (do not remove blind).
+- **A deprecation window of at least one release** where the warnings were live.
+- **A user-facing migration note / changelog entry.**
+- The router golden file (`router_routes.txt`) and the contract suite (above)
+ kept green throughout; they are the regression guards.
+
+### 1.x cutover
+
+Remove the obsolete routes and retire `soundtouch-web`. Per the versioning
+section, this is the only point where anything is removed; the frozen
+speaker/app routes stay.
+
+## Versioning and the 1.x cutover
+
+We do **not** version our own API in the path (`/api/v1/...`). In practice path
+versioning buys little; its one real benefit is explicitness, and it can be
+retrofitted later if a hard break ever forces it. Either way, a `/v1` -> `/v2`
+bump does not remove the need to be careful when changing or breaking a route.
+
+(The frozen `/v1/*` routes in the tables above are Bose's firmware contract, not
+our versioning. They are unrelated.)
+
+Versioning lives at the **release level (semver)** instead:
+
+- **0.x (now):** the API may evolve. When a route moves, the **old and new paths
+ stay live at the same time** (the alias/redirect layer from step 1). Every
+ release stays a no-break upgrade, which gives users time to follow.
+- **1.x (the cutover):** the release where we settle on the better API. At 1.x we
+ **remove the obsolete routes**. That is the only point where an old route
+ disappears.
+
+Why this is low-risk: the service and the frontend(s) it serves ship in **one
+binary**. A user updates the service and reloads the browser tab; the reloaded
+SPA is the client for the new API, so the two always match, with no window where
+an old frontend talks to a new backend.
+
+Caveat: this holds for the clients we ship (the bundled UIs). Out-of-band callers
+that hardcode paths (the CLI, user scripts, reverse-proxy rules, the HTTP-client
+integration tests) must follow by 1.x as well; the 0.x dual-routing is precisely
+the window that lets them. The frozen speaker/app contract routes are never
+removed, 1.x included.
+
+## Open questions
+
+- Lockout-safety mechanism: which of refuse-to-apply / fall-back / loopback-on-
+ host bypass we use when a mode requires auth but none is configured yet.
+- The form of the non-interactive credential for native / headless clients:
+ issued token, API key, device-code, or client-credentials grant.
+- The shipped default at 1.x: stay `private`, or move to a stricter default
+ (the parameter lets operators opt in regardless, so no hard flip is forced).