mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
docs(architecture): add API route layout and refactoring plan (refs #451)
Architectural reference for the staged API refactoring that precedes the soundtouch-web / soundtouch-service merge: - Route classification by client audience and what pins each path (frozen firmware contract vs externally-pinned OAuth callbacks vs our movable admin/control surface), with service + web route tables. - Actors model (speaker / app / cloud) and deployment topologies; speaker-direct vs data-plane reachability. - deployment-mode parameter (private/shared/public), trust tiers, auth posture (opt-none -> opt-in -> opt-out?), and auth mechanisms (Marge as one auth provider like EntraID; native/headless clients via RFC 8252 loopback or a headless token; identity in logs). - /app/* single role-gated app with code-splitting for on-device size. - Versioning policy: no path versioning; semver with 0.x dual-routing and a 1.x cutover that removes obsolete routes. - Staged migration (add+alias, fold in web, deprecate the binary, observable old-route warnings) with a "before 1.x" definition of done. - Regression safety: contract tests from the frozen recordings, building on the existing tests/integration/http-client suite. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
4f561944a3
commit
b4b23a3716
@@ -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`<br>`/oauth/*/token/cs`<br>`/oauth/*/token/cs1`<br>`/oauth/*/token/cs3` | (1a) frozen | Speaker (music tokens) | No |
|
||||
| `/custom/v1/playback/*` | (1a) frozen | Speaker (LOCAL_INTERNET_RADIO / ding) | No |
|
||||
| `/bmx-icons/*`<br>`/media/*`<br>`/media/aftertouch-ding.wav`<br>`/media/tts/*` | (1a) frozen | Speaker (advertised base) | No |
|
||||
| `/streaming/resources/api_versions.xml`<br>`/streaming/software/update/*`<br>`/updates/soundtouch` | (1a) frozen | Speaker (SW update) | No |
|
||||
| `/v1/auth`<br>`/v1/blacklist/*`<br>`/v1/scmudc/*`<br>`/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`<br>`/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 |
|
||||
| `/`<br>`/docs/*`<br>`/favicon.ico`<br>`/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`<br>`/static/*`<br>`/ws` | (Infra) | `/health` and `/ws` collide with the service at merge time |
|
||||
| `/`<br>`/device/*`<br>`/devices`<br>`/playurl`<br>`/radiobrowser`<br>`/tts`<br>`/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)<br>`/setup/*`<br>`/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:<port>` 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).
|
||||
Reference in New Issue
Block a user