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