Skip to content
API Route Layout and Refactoring Plan

API Route Layout and Refactoring Plan

Tracking issue: #451 “Merge soundtouch-player into soundtouch-service”. This document is the architectural reference for the staged API refactoring that precedes (and enables) that merge.

Why this exists

soundtouch-service and soundtouch-player 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-player 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:

CategoryClientFree to move?
(1a) Frozen, firmware-pinnedSpeaker firmwareNo, ever. The path is hardcoded in the speaker (or relative to a base it fetches from us).
(1b) Frozen, externally-pinnedOAuth providers (Spotify/Amazon)Only with provider re-registration + device re-priming. Treat as frozen unless that cost is paid deliberately.
(2) Service-internalThe admin/setup UIYes, freely. These are ours.
(3) Web/controlThe control UI (soundtouch-player)Yes, freely.
(4) Frontend (SPA)Browser, client-side routingShould not be enumerated in the backend at all (see /app/* below).
(Infra)Humans, monitoring, the SPA shellConventionally 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.

  • The merge-overlap bucket is smaller than it looks. Verified against the two routers, only / is a true collision (service HandleRoot vs the web app’s serveIndex); resolve it with a small landing page at / that lets the user pick Admin/Setup (service) or the App (web). /health is a merge, not a clash (both define it; standardise on the service’s richer body, which carries version + timestamp, and confirm nothing depends on the web’s {"status":"ok","version"} shape). /ws and /static/* do not collide at all — the service registers neither, so bringing the web’s in is purely additive. TuneIn is not in this bucket either: /bmx/tunein/* (speaker <-> BMX integration, frozen) and /api/tunein/* (the player’s generalized radio search/play, ours to change) are two different layers.

    Resolve overlaps structurally, before merging, not behind a flag. A conditional “only register the web routes when opt-in is on” does not fix a collision — it just hides it while the flag is off, and the double-registration returns when it’s on. Do not rely on chi to detect or warn about it. Clean up / (and the /health merge) up front so the merged router is unambiguous regardless of the flag. The opt-in (below) exists only to let people optionally run the merged variant and give feedback, not as a collision guard.

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.

PrefixCategoryClientMovable?
/streaming/*(1a) frozenSpeaker (marge / streaming.bose.com)No
/accounts/*(1a) frozenSpeaker (marge, alternate paths)No
/customer/account/*(1a) frozenSpeakerNo
/bmx/* (registry + tunein)(1a) frozenSpeaker (BMX)No
/core02/svc-bmx-adapter-* (Orion, SiriusXM)(1a) frozenSpeaker (BMX adapters)No
/oauth/*/token
/oauth/*/token/cs
/oauth/*/token/cs1
/oauth/*/token/cs3
(1a) frozenSpeaker (music tokens)No
/custom/v1/playback/*(1a) frozenSpeaker (LOCAL_INTERNET_RADIO / ding)No
/bmx-icons/*
/media/*
/media/aftertouch-ding.wav
/media/tts/*
(1a) frozenSpeaker (advertised base)No
/streaming/resources/api_versions.xml
/streaming/software/update/*
/updates/soundtouch
(1a) frozenSpeaker (SW update)No
/v1/auth
/v1/blacklist/*
/v1/scmudc/*
/v1/stapp/*
(1a) frozenSpeakerNo
/alexa/certificate(1a) frozenSpeaker / AWSNo
/ced/*(1a) frozenSpeaker (mirrors downloads.bose.com)No
/mgmt/amazon/callback
/mgmt/spotify/callback
(1b) frozen, externalOAuth providersOnly with re-registration
/setup/* (~40 routes)(2) service-internalAdmin UIYes
/mgmt/* (except the callbacks above)(2) service-internalAdmin UIYes
/web/* (HandleWeb)(4) frontendBrowser (admin SPA)Yes; already the clean catch-all pattern
/
/docs/*
/favicon.ico
/health
(Infra)Humans / monitoringKeep stable by convention

Web routes (soundtouch-player)

Defined in pkg/service/soundtouchweb/mount.go. Not currently mounted inside the service; it is a separate binary.

GroupCategoryNote
/api/* (devices, control, tunein, zone, radiobrowser, play-url, device-speak)(3) web/controlFreely restructurable
/health
/static/*
/ws
(Infra)/health is a merge (standardise on the service’s body); /static/* and /ws are additive (the service registers neither)
/
/device/*
/devices
/playurl
/radiobrowser
/tts
/tunein
(4) frontend/ is the one true collision (-> landing page); the rest move under /app/*. 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:

ActorWhereRole
SpeakerLocal (the device)Calls the cloud for its data-plane (/full, presets, sources, software update, tokens) and is provisioned by the app.
AppLocal (phone / desktop), in-betweenCreates the account, adds a speaker to an account, and teaches the speaker its cloud/marge credentials. Authenticates itself to the cloud.
CloudExternal / 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.

TopologyWhereReaches speakers directly?Speaker reaches it?
On-deviceOn the speaker itselfItself onlyYes (loopback / LAN)
LAN hostRaspberry Pi / Docker on the home networkYes (same LAN)Yes
Cloud / remote hostExternal host, not on the speaker LANNoYes (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-player 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:

ZoneRoutesClient auth todayShould be
Speaker contractfrozen (1a), speaker-polledNone (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 / provisioningaccount 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)NoneOptional 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)privatesharedpublic
Speaker contractnone (frozen)nonenone
Control / playeropenopenauth
Admin / setup + provisioningopen (opt-in)auth (min. Basic)auth
Speaker-direct featuresononhidden/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-player /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-player 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. First, migrate soundtouch-player in place to the target API shape. Before touching the service, restructure the standalone -web binary’s own routes to what they should be after the merge: the control API under /api/control/* and the SPA under /app/* (with /ws as e.g. /api/control/ws). Unlike the service, this is a direct migration, not a dual-mount, and with no deprecation signal: -web’s only client is its own bundled frontend, served and reloaded from the same binary, so there are no out-of-band callers to keep compatible — restructure the routes and update the frontend in lockstep, in small commits, and a stale tab is fixed by a reload. (The careful add-alias-then-deprecate dance is reserved for -service, which is central and serves callers we do not control.) The payoff: by the time we merge, -web’s routes already match the target and don’t overlap the service’s namespaces, so the merge below is a near-additive mount.

  3. Fold soundtouch-player into the service. Bring the (already target-shaped) control API in as /api/control/* and the UI under /app/* (one role-gated app, see above). The actual overlap to clean up (verified) is small: only / truly collides, so replace the two competing root handlers with a landing page that routes the user to Admin/Setup or the App; /health is a merge (keep the service’s richer body); /ws and /static/* are additive (the service registers neither, so no collision). Do this cleanup structurally and verify it (a test that builds the merged router and asserts no double-registration) rather than hiding overlaps behind the opt-in flag. Keep the two TuneIn layers separate (frozen /bmx/tunein/* vs the player’s /api/control/* radio feature).

    • Ship the merged variant behind an opt-in flag (default off). Its sole purpose is to let people optionally run the combined binary and give feedback; it is not a collision guard and not a security boundary on its own. Until the auth track lands, default-off keeps the merged app/control surface from being exposed unless an operator deliberately enables it. The flag follows the same CLI/env/persisted precedence as server-url, and is the seam the deployment-mode parameter later subsumes.
  4. Deprecate the soundtouch-player 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.

  5. 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. (Done for the /setup and /mgmt legacy paths via DeprecatedRouteMiddleware; extends to any future aliased route.)

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-player. 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).
Last updated on