refactor(service): stub the unused /accounts/* mirror with a 501 "report it" handler (refs #451)

Shrink the route surface the #451 refactor must preserve by retiring the
/accounts/{account}/* compatibility mirror. Across the full recording corpus
(all _/backup/*, _/mitm, _/i195, _/issue-94, captures + data/ + tests/, 139k+
.http files) no speaker or app uses the /accounts prefix, and every operation it
offered is served by the /streaming/account/* paths real clients actually use.

- New HandleUnsupported: returns 501 and logs the full request + client IP + a
  "please report this" message, so any real-world use surfaces instead of being
  silently dropped, and the prefix becomes a clean removal candidate.
- Re-point every /accounts/* route to it. The frozen /streaming/* contract is
  left entirely on its real handlers (those stay even where our corpus didn't
  exercise them — absence of capture is not proof of disuse).
- Migrate the integration tests off the /accounts mirror onto their recorded
  /streaming/account/* equivalents (register/unregister/spotify_full_flow), then
  pin the mirror's 501 contract in unsupported_routes.http.
- Router + frozen-route-coverage golden files updated accordingly.

make test-http-client: 91 requests, 0 failed. go test + golangci-lint clean.

Note for release time: call out the intentional /accounts/* 501 breakage in the
release notes' Noteworthy section (use /streaming/account/* instead).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-06-06 19:11:24 +02:00
co-authored by Claude Opus 4.8
parent 44f3ccfc18
commit ea6ee3e097
9 changed files with 263 additions and 69 deletions
+1
View File
@@ -183,6 +183,7 @@ test-http-client:
/workdir/get_speaker_auth.http \
/workdir/get_blacklist.http \
/workdir/post_alexa_certificate.http \
/workdir/unsupported_routes.http \
/workdir/spotify_full_flow.http \
/workdir/customer_support.http \
/workdir/power_on.http \
+29 -19
View File
@@ -1225,6 +1225,9 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
r.Get("/{device}/group/member", server.HandleMargeDeviceGroupMember)
})
// Speakers POST to /group/ (with trailing slash) when forwarding
// the addGroup payload to Marge during stereo-pair formation --
// see issue #252. Register both forms so chi accepts either.
// Speakers POST to /group/ (with trailing slash) when forwarding
// the addGroup payload to Marge during stereo-pair formation --
// see issue #252. Register both forms so chi accepts either.
@@ -1266,31 +1269,38 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
r.Get("/resources/api_versions.xml", server.HandleMargeAPIVersions)
})
// The /accounts/* group mirrored /streaming/account/* for compatibility, but
// no speaker or app was ever observed using this prefix in the recording
// corpus (the integration tests that exercised it were migrated onto the
// /streaming equivalents). The whole mirror is therefore treated as unused
// and stubbed (HandleUnsupported): it logs + 501s so any real-world use
// surfaces instead of being silently dropped, leaving the prefix a clean
// removal candidate for the #451 refactor.
r.Route("/accounts", func(r chi.Router) {
r.Route("/{account}", func(r chi.Router) {
r.Get("/full", server.HandleMargeAccountFull)
r.Get("/sources", server.HandleMargeAccountSources)
r.Get("/devices", server.HandleMargeAccountDevices)
r.Get("/full", server.HandleUnsupported)
r.Get("/sources", server.HandleUnsupported)
r.Get("/devices", server.HandleUnsupported)
r.Post("/devices", server.HandleMargeAddDevice)
r.Post("/devices", server.HandleUnsupported)
r.Delete("/devices/{device}", server.HandleMargeRemoveDevice)
r.Get("/devices/{device}/group", server.HandleMargeDeviceGroup)
r.Get("/devices/{device}/group/", server.HandleMargeDeviceGroup)
r.Get("/devices/{device}/group/server", server.HandleMargeDeviceGroupServer)
r.Get("/devices/{device}/group/member", server.HandleMargeDeviceGroupMember)
r.Delete("/devices/{device}", server.HandleUnsupported)
r.Get("/devices/{device}/group", server.HandleUnsupported)
r.Get("/devices/{device}/group/", server.HandleUnsupported)
r.Get("/devices/{device}/group/server", server.HandleUnsupported)
r.Get("/devices/{device}/group/member", server.HandleUnsupported)
r.Post("/group", server.HandleMargeAddGroup)
r.Post("/group/", server.HandleMargeAddGroup)
r.Post("/group/{groupId}", server.HandleMargeModifyGroup)
r.Delete("/group/{groupId}", server.HandleMargeDeleteGroup)
r.Delete("/group", server.HandleMargeDeleteAccountGroups)
r.Delete("/group/", server.HandleMargeDeleteAccountGroups)
r.Get("/devices/{device}/presets", server.HandleMargePresets)
r.Get("/devices/{device}/recents", server.HandleMargeRecents)
r.Post("/group", server.HandleUnsupported)
r.Post("/group/", server.HandleUnsupported)
r.Post("/group/{groupId}", server.HandleUnsupported)
r.Delete("/group/{groupId}", server.HandleUnsupported)
r.Delete("/group", server.HandleUnsupported)
r.Delete("/group/", server.HandleUnsupported)
r.Get("/devices/{device}/presets", server.HandleUnsupported)
r.Get("/devices/{device}/recents", server.HandleUnsupported)
r.Post("/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
r.Post("/devices/{device}/recents", server.HandleMargeAddRecent)
r.Post("/devices/{device}/presets/{presetNumber}", server.HandleUnsupported)
r.Post("/devices/{device}/recents", server.HandleUnsupported)
})
})
@@ -1,16 +1,6 @@
DELETE /accounts/{account}/group
DELETE /accounts/{account}/group/
DELETE /accounts/{account}/group/{groupId}
DELETE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter
DELETE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/*
DELETE /streaming/account/{account}/group
GET /accounts/{account}/devices
GET /accounts/{account}/devices/{device}/group
GET /accounts/{account}/devices/{device}/group/
GET /accounts/{account}/devices/{device}/group/member
GET /accounts/{account}/devices/{device}/group/server
GET /accounts/{account}/devices/{device}/recents
GET /accounts/{account}/full
GET /bmx-icons/*
GET /bmx/tunein/v1/navigate
GET /bmx/tunein/v1/navigate/*
@@ -27,10 +17,6 @@ GET /streaming/account/{account}/device/{device}/group/server
GET /streaming/account/{account}/device/{device}/recent
GET /streaming/account/{account}/presets
GET /streaming/device_setting/account/{account}/device/{device}/device_settings
POST /accounts/{account}/devices/{device}/recents
POST /accounts/{account}/group
POST /accounts/{account}/group/
POST /accounts/{account}/group/{groupId}
POST /core02/svc-bmx-adapter-orion/prod/orion/token
POST /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter
POST /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/*
+19 -19
View File
@@ -1,9 +1,9 @@
CONNECT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
CONNECT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
DELETE /accounts/{account}/devices/{device} handlers.(*Server).HandleMargeRemoveDevice-fm
DELETE /accounts/{account}/group handlers.(*Server).HandleMargeDeleteAccountGroups-fm
DELETE /accounts/{account}/group/ handlers.(*Server).HandleMargeDeleteAccountGroups-fm
DELETE /accounts/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
DELETE /accounts/{account}/devices/{device} handlers.(*Server).HandleUnsupported-fm
DELETE /accounts/{account}/group handlers.(*Server).HandleUnsupported-fm
DELETE /accounts/{account}/group/ handlers.(*Server).HandleUnsupported-fm
DELETE /accounts/{account}/group/{groupId} handlers.(*Server).HandleUnsupported-fm
DELETE /bmx/tunein/v1/favorite/{stationID} handlers.(*Server).HandleTuneInDeleteFavorite-fm
DELETE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
DELETE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
@@ -18,15 +18,15 @@ DELETE /streaming/account/{account}/group handlers.(
DELETE /streaming/account/{account}/group/ handlers.(*Server).HandleMargeDeleteAccountGroups-fm
DELETE /streaming/account/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
GET / handlers.(*Server).HandleRoot-fm
GET /accounts/{account}/devices handlers.(*Server).HandleMargeAccountDevices-fm
GET /accounts/{account}/devices/{device}/group handlers.(*Server).HandleMargeDeviceGroup-fm
GET /accounts/{account}/devices/{device}/group/ handlers.(*Server).HandleMargeDeviceGroup-fm
GET /accounts/{account}/devices/{device}/group/member handlers.(*Server).HandleMargeDeviceGroupMember-fm
GET /accounts/{account}/devices/{device}/group/server handlers.(*Server).HandleMargeDeviceGroupServer-fm
GET /accounts/{account}/devices/{device}/presets handlers.(*Server).HandleMargePresets-fm
GET /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleMargeRecents-fm
GET /accounts/{account}/full handlers.(*Server).HandleMargeAccountFull-fm
GET /accounts/{account}/sources handlers.(*Server).HandleMargeAccountSources-fm
GET /accounts/{account}/devices handlers.(*Server).HandleUnsupported-fm
GET /accounts/{account}/devices/{device}/group handlers.(*Server).HandleUnsupported-fm
GET /accounts/{account}/devices/{device}/group/ handlers.(*Server).HandleUnsupported-fm
GET /accounts/{account}/devices/{device}/group/member handlers.(*Server).HandleUnsupported-fm
GET /accounts/{account}/devices/{device}/group/server handlers.(*Server).HandleUnsupported-fm
GET /accounts/{account}/devices/{device}/presets handlers.(*Server).HandleUnsupported-fm
GET /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleUnsupported-fm
GET /accounts/{account}/full handlers.(*Server).HandleUnsupported-fm
GET /accounts/{account}/sources handlers.(*Server).HandleUnsupported-fm
GET /bmx-icons/* handlers.(*Server).HandleBmxIcons
GET /bmx/registry/v1/services handlers.(*Server).HandleBMXRegistry-fm
GET /bmx/registry/v1/servicesAvailability handlers.(*Server).HandleBMXServicesAvailability-fm
@@ -109,12 +109,12 @@ OPTIONS /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handler
OPTIONS /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
PATCH /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
PATCH /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
POST /accounts/{account}/devices handlers.(*Server).HandleMargeAddDevice-fm
POST /accounts/{account}/devices/{device}/presets/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
POST /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleMargeAddRecent-fm
POST /accounts/{account}/group handlers.(*Server).HandleMargeAddGroup-fm
POST /accounts/{account}/group/ handlers.(*Server).HandleMargeAddGroup-fm
POST /accounts/{account}/group/{groupId} handlers.(*Server).HandleMargeModifyGroup-fm
POST /accounts/{account}/devices handlers.(*Server).HandleUnsupported-fm
POST /accounts/{account}/devices/{device}/presets/{presetNumber} handlers.(*Server).HandleUnsupported-fm
POST /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleUnsupported-fm
POST /accounts/{account}/group handlers.(*Server).HandleUnsupported-fm
POST /accounts/{account}/group/ handlers.(*Server).HandleUnsupported-fm
POST /accounts/{account}/group/{groupId} handlers.(*Server).HandleUnsupported-fm
POST /alexa/certificate handlers.(*Server).HandleAlexaCertificate-fm
POST /bmx/tunein/v1/favorite/{stationID} handlers.(*Server).HandleTuneInFavorite-fm
POST /bmx/tunein/v1/report handlers.(*Server).HandleTuneInReport-fm
@@ -0,0 +1,60 @@
package handlers
import (
"io"
"log"
"net/http"
)
// unsupportedReportURL is where we ask operators to report a real client that
// depends on an endpoint we treat as unused.
const unsupportedReportURL = "https://github.com/gesellix/Bose-SoundTouch/issues"
// maxUnsupportedBodyLog caps how much of an unexpected request body we log.
const maxUnsupportedBodyLog = 2048
// HandleUnsupported is wired to frozen routes that no real speaker or app was
// ever observed using (verified against the recorded interaction corpus). It
// exists so the API surface the refactor (issue #451) has to preserve stays as
// small as possible: these routes are candidates for removal, but rather than
// drop them blind we make them fail loudly and observably.
//
// It returns 501 Not Implemented and logs the full request (method, path,
// query, client IP, user-agent and a capped, sanitized body) plus a message
// asking the operator to report it, so that if some device or old app actually
// relies on the route we find out and restore it instead of silently breaking
// it during the refactor.
func (s *Server) HandleUnsupported(w http.ResponseWriter, r *http.Request) {
client := clientHostFromRemoteAddr(r.RemoteAddr)
var body []byte
if r.Body != nil {
body, _ = io.ReadAll(io.LimitReader(r.Body, maxUnsupportedBodyLog))
}
log.Printf("[unsupported] %s %s%s called by client=%s ua=%q — this endpoint is treated as unused and returns 501. "+
"If a speaker or app you rely on needs it, please report it at %s so we can keep it. body=%q",
sanitizeLog(r.Method),
sanitizeLog(r.URL.Path),
sanitizeLog(querySuffix(r)),
sanitizeLog(client),
sanitizeLog(r.UserAgent()),
unsupportedReportURL,
sanitizeLog(string(body)),
)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotImplemented)
_, _ = w.Write([]byte(`{"error":"not_implemented","message":"This endpoint is not implemented by AfterTouch. ` +
`If your speaker or app depends on it, please report it at ` + unsupportedReportURL + `"}`))
}
// querySuffix returns "?<rawquery>" or "" so the log line shows the query
// without an empty trailing "?".
func querySuffix(r *http.Request) string {
if r.URL.RawQuery == "" {
return ""
}
return "?" + r.URL.RawQuery
}
@@ -1,5 +1,5 @@
### POST /{{accountId}}/devices (Register Device)
POST {{host}}/accounts/{{accountId}}/devices
### POST /streaming/account/{{accountId}}/device/ (Register Device)
POST {{host}}/streaming/account/{{accountId}}/device/
Content-Type: application/vnd.bose.streaming-v1.2+xml
Authorization: Bearer {{token}}
@@ -40,7 +40,7 @@ Authorization: Basic admin change_me!
# 4. Verify Marge Source Registration and extract Spotify Source ID
# @name Get Marge Sources
GET {{host}}/accounts/{{accountId}}/sources
GET {{host}}/streaming/account/{{accountId}}/sources
> {%
client.test("Spotify source registered in Marge", function() {
@@ -94,7 +94,7 @@ GET {{host}}/accounts/{{accountId}}/sources
# 5. Add a Spotify Preset
# @name Update Preset
POST {{host}}/accounts/{{accountId}}/devices/{{deviceId}}/presets/1
PUT {{host}}/streaming/account/{{accountId}}/device/{{deviceId}}/preset/1
Content-Type: application/xml
<preset id="1">
@@ -114,7 +114,7 @@ Content-Type: application/xml
# 6. Verify Preset in Marge
# @name Get Presets
GET {{host}}/accounts/{{accountId}}/devices/{{deviceId}}/presets
GET {{host}}/streaming/account/{{accountId}}/device/{{deviceId}}/presets
> {%
client.test("Verify preset content", function() {
@@ -1,5 +1,5 @@
### DELETE /{{accountId}}/devices/{{deviceId}} (Unregister Device)
DELETE {{host}}/accounts/{{accountId}}/devices/{{deviceId}}
### DELETE /streaming/account/{{accountId}}/device/{{deviceId}} (Unregister Device)
DELETE {{host}}/streaming/account/{{accountId}}/device/{{deviceId}}
Authorization: Bearer {{token}}
> {%
@@ -7,13 +7,3 @@ Authorization: Bearer {{token}}
client.assert(response.status === 200, "Response status is not 200");
});
%}
### DELETE /streaming/account/{{accountId}}/device/{{deviceId}} (Unregister Device Variant)
DELETE {{host}}/streaming/account/{{accountId}}/device/{{deviceId}}
Authorization: Bearer {{token}}
> {%
client.test("Device unregistered successfully (variant)", function() {
client.assert(response.status === 200, "Response status is not 200");
});
%}
@@ -0,0 +1,147 @@
### Unsupported (zero-usage) /accounts/* mirror -> 501
###
### The /accounts/* group mirrored /streaming/account/* for compatibility, but no
### speaker or app was ever observed using this prefix across the full recording
### corpus, and every operation it offers is provably served via the
### /streaming/account/* paths that real clients DO use (the integration tests
### were migrated onto those). The whole mirror is therefore wired to
### HandleUnsupported, which logs the full request + client IP + a "please report
### this" message and returns 501, so any real-world use surfaces instead of
### being silently broken and the prefix becomes a clean removal candidate for
### the #451 refactor. These tests pin that 501 contract.
###
### NOTE: the real /streaming/account/* (and /streaming/...) routes are NOT
### stubbed — those are the frozen Bose contract and stay on their real handlers
### even where our corpus didn't exercise them.
### GET /accounts/{a}/full
GET {{host}}/accounts/{{accountId}}/full
User-Agent: Bose_Lisa/27.0.6
> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %}
### GET /accounts/{a}/sources (real clients use /streaming/account/{a}/sources)
GET {{host}}/accounts/{{accountId}}/sources
User-Agent: Bose_Lisa/27.0.6
> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %}
### GET /accounts/{a}/devices
GET {{host}}/accounts/{{accountId}}/devices
User-Agent: Bose_Lisa/27.0.6
> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %}
### POST /accounts/{a}/devices (real clients use POST /streaming/account/{a}/device/)
POST {{host}}/accounts/{{accountId}}/devices
User-Agent: Bose_Lisa/27.0.6
Content-Type: application/xml
<device/>
> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %}
### DELETE /accounts/{a}/devices/{deviceId} (real clients use DELETE /streaming/account/{a}/device/{d})
DELETE {{host}}/accounts/{{accountId}}/devices/{{deviceId}}
User-Agent: Bose_Lisa/27.0.6
> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %}
### GET /accounts/{a}/devices/{deviceId}/group
GET {{host}}/accounts/{{accountId}}/devices/{{deviceId}}/group
User-Agent: Bose_Lisa/27.0.6
> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %}
### GET /accounts/{a}/devices/{deviceId}/group/
GET {{host}}/accounts/{{accountId}}/devices/{{deviceId}}/group/
User-Agent: Bose_Lisa/27.0.6
> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %}
### GET /accounts/{a}/devices/{deviceId}/group/server
GET {{host}}/accounts/{{accountId}}/devices/{{deviceId}}/group/server
User-Agent: Bose_Lisa/27.0.6
> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %}
### GET /accounts/{a}/devices/{deviceId}/group/member
GET {{host}}/accounts/{{accountId}}/devices/{{deviceId}}/group/member
User-Agent: Bose_Lisa/27.0.6
> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %}
### GET /accounts/{a}/devices/{deviceId}/presets (real clients use /streaming/account/{a}/device/{d}/presets)
GET {{host}}/accounts/{{accountId}}/devices/{{deviceId}}/presets
User-Agent: Bose_Lisa/27.0.6
> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %}
### GET /accounts/{a}/devices/{deviceId}/recents
GET {{host}}/accounts/{{accountId}}/devices/{{deviceId}}/recents
User-Agent: Bose_Lisa/27.0.6
> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %}
### POST /accounts/{a}/devices/{deviceId}/recents
POST {{host}}/accounts/{{accountId}}/devices/{{deviceId}}/recents
User-Agent: Bose_Lisa/27.0.6
Content-Type: application/xml
<recent/>
> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %}
### POST /accounts/{a}/devices/{deviceId}/presets/{n} (real clients use PUT /streaming/account/{a}/device/{d}/preset/{n})
POST {{host}}/accounts/{{accountId}}/devices/{{deviceId}}/presets/1
User-Agent: Bose_Lisa/27.0.6
Content-Type: application/xml
<preset/>
> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %}
### POST /accounts/{a}/group
POST {{host}}/accounts/{{accountId}}/group
User-Agent: Bose_Lisa/27.0.6
Content-Type: application/xml
<group/>
> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %}
### POST /accounts/{a}/group/
POST {{host}}/accounts/{{accountId}}/group/
User-Agent: Bose_Lisa/27.0.6
Content-Type: application/xml
<group/>
> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %}
### POST /accounts/{a}/group/{groupId}
POST {{host}}/accounts/{{accountId}}/group/1
User-Agent: Bose_Lisa/27.0.6
Content-Type: application/xml
<group/>
> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %}
### DELETE /accounts/{a}/group
DELETE {{host}}/accounts/{{accountId}}/group
User-Agent: Bose_Lisa/27.0.6
> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %}
### DELETE /accounts/{a}/group/
DELETE {{host}}/accounts/{{accountId}}/group/
User-Agent: Bose_Lisa/27.0.6
> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %}
### DELETE /accounts/{a}/group/{groupId}
DELETE {{host}}/accounts/{{accountId}}/group/1
User-Agent: Bose_Lisa/27.0.6
> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %}