diff --git a/CHANGELOG.md b/CHANGELOG.md index 68cec156..30ec0c85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +**Music and artwork now need a credential on a server with a token** (ADR-0176 Phase 4). The apps +send one from 1.7 on, the web admin uses a cookie, and speakers get a link that expires. An iPhone +or Mac still on Familiar 1.5.1 or earlier stops playing from a server with a token after this +upgrade, so update the apps first. The server log names each device it refuses, once an hour, +with that reason. A server with no token is unaffected. + +### Changed + +- Media on a tokened server is refused without the token header, the media cookie or a + server-signed URL. It is served `Cache-Control: private`, so a shared cache cannot serve one + listener's library to another. + ## [0.2.0-beta24] - 2026-11-10 One migration (`20261109_embed_failure_reason` adds two columns to `track_analysis` and backfills diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 15adfc5a..4bbcccb5 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -30,6 +30,7 @@ from __future__ import annotations import hmac +import logging import re import secrets @@ -37,6 +38,8 @@ from app.api.exceptions import ErrorCode +logger = logging.getLogger(__name__) + TOKEN_HEADER = "X-Familiar-Token" #: Paths that never require a token, matched against the path with any trailing slash removed. @@ -83,7 +86,8 @@ def token_matches(presented: str | None, configured: str | None) -> bool: return hmac.compare_digest(presented, configured) -#: Media, readable without a token (ADR-0045, "Media is exempt from the gate, decided 2026-08-09"). +#: Media: outside the token gate (ADR-0045, "Media is exempt from the gate, decided 2026-08-09"), +#: but since ADR-0176 Phase 4 not open — see the end of this comment. #: #: **Recorded as decided for six weeks before it existed.** Until 2026-09-29 every `/api/` path was #: gated, so configuring a token broke `` artwork in the web admin, every stream a WiiM or @@ -97,9 +101,9 @@ def token_matches(presented: str | None, configured: str | None) -> bool: #: leak (ADR-0045, ADR-0134 point 3): an oracle over the collection. Since ADR-0176 Phase 1 a hash #: key is served only with this server's MAC (`app/services/artwork_keys.py`), on the image route and #: on `/artwork/status/{key}` (which the second pattern below also matches); a bare hash is a 404. -#: A media read is authorized by the header, a media cookie or a server-signed URL -#: (`app/api/media_credentials.py`, ADR-0176 Phase 2), but none is *required* until Phase 4 turns on -#: `MEDIA_ENFORCED`: the App Store builds 1.6 and 1.7 send no credential on media. +#: These shapes are not behind *the token*, but they are not open either: since ADR-0176 Phase 4 a +#: media read on a tokened server needs one of three credentials — the header, a media cookie or a +#: server-signed URL (`app/api/media_credentials.py`, `TokenAuthMiddleware._media_refused`). MEDIA_ROUTES = tuple( re.compile(pattern) for pattern in ( @@ -159,6 +163,36 @@ def extract_token(scope: Scope) -> str | None: "web admin for its media cookie, or use a URL this server signed." ) +#: How long a client that was refused media goes before it is named in the log again. +MEDIA_REFUSAL_LOG_INTERVAL_S = 3600.0 +_media_refusals_logged: dict[str, float] = {} + + +def _warn_media_refused(scope: Scope) -> None: + """Say in the server log why a listener's app went silent (ADR-0176 Phase 4). + + An app from before 1.7 sends no credential on media. It shows the listener nothing useful, so + the server log is where the person running the server can find out. Once per client an hour, + since a player asks for audio and artwork many times a minute. + """ + import time + + client = scope.get("client") + host = client[0] if client else "unknown" + now = time.monotonic() + last = _media_refusals_logged.get(host) + if last is not None and now - last < MEDIA_REFUSAL_LOG_INTERVAL_S: + return + if len(_media_refusals_logged) > 1024: + _media_refusals_logged.clear() + _media_refusals_logged[host] = now + logger.warning( + "Refused media to %s: it sent no credential. An iPhone or Mac app older than Familiar 1.7 " + "does this; update the app. Media on a server with a token needs one (ADR-0176).", + host, + ) + + #: What `/mcp` answers on an open server with no Host allow-list (ADR-0171 point 7). OPEN_MCP_DETAIL = ( "MCP is off on a server with no token, because nothing else would stop a web page from reaching " @@ -248,8 +282,8 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: async def _media_refused(scope: Scope, path: str, method: str, send: Send) -> bool: """ADR-0176: on a tokened server, a media read needs a header, cookie or signed URL. - Only once `MEDIA_ENFORCED` is on (Phase 4). Until then this returns False before reading - anything, and media stays exempt as `MEDIA_ROUTES` records. + On since Phase 4 (`MEDIA_ENFORCED`). Switched off, this returns False before reading + anything, and media is served to anyone, as it was before ADR-0176. """ from app.api import media_credentials as media @@ -260,6 +294,7 @@ async def _media_refused(scope: Scope, path: str, method: str, send: Send) -> bo configured = get_app_settings_service().get().access_token if not configured or media.presented_credential(scope, configured) is not None: return False + _warn_media_refused(scope) await TokenAuthMiddleware._refuse(scope, send, detail=MEDIA_CREDENTIAL_DETAIL) return True diff --git a/backend/app/api/media_credentials.py b/backend/app/api/media_credentials.py index dec46416..9c77ca93 100644 --- a/backend/app/api/media_credentials.py +++ b/backend/app/api/media_credentials.py @@ -12,10 +12,10 @@ invalidates cookies in the same moment it signs clients out (point 5). Nothing new is stored: with no token there is nothing to derive from, no URL is signed, and ADR-0171's exposure rules decide. -**Not enforced yet** (`MEDIA_ENFORCED`). Point 8 waits until no App Store build that omits the header -is still offered — 1.6 and 1.7 (build 78) send nothing on media. Until then the gate checks nothing on -media, and this module is exercised by its tests and by the clients that already send credentials. -Phase 4 is flipping `MEDIA_ENFORCED`, then making media `Cache-Control: private`. +**Enforced since Phase 4** (`MEDIA_ENFORCED`, 2026-11-11). Point 8's trigger was met when app 1.7 +(build 80), which sends the header, became the only App Store build offered on iOS and macOS: a +tokened server refuses a media read that carries none of the three. Media is served +`Cache-Control: private` so a shared cache cannot hand one listener's library to another (point 6). """ from __future__ import annotations @@ -33,8 +33,9 @@ # Signatures are kept out of logs by `app.logging_config.redact_credentials`, which the access log # applies to every line (point 6). -#: Phase 4 of ADR-0176. While False, a media request with no credential is still served. -MEDIA_ENFORCED = False +#: Phase 4 of ADR-0176: on a tokened server, a media read with no credential is refused. A module +#: constant rather than a setting, so a server cannot be talked back into the old exemption. +MEDIA_ENFORCED = True COOKIE_NAME = "familiar_media" #: The cookie is sent only to the API, and honoured only on media reads (`presented_credential`). diff --git a/backend/app/api/routes/artwork.py b/backend/app/api/routes/artwork.py index f6f51433..78b9ebb7 100644 --- a/backend/app/api/routes/artwork.py +++ b/backend/app/api/routes/artwork.py @@ -397,7 +397,7 @@ async def get_artwork_by_hash(album_hash: str, size: str) -> Any: return FileResponse( artwork_path, media_type="image/jpeg", - headers={"Cache-Control": "public, max-age=31536000"}, + headers={"Cache-Control": "private, max-age=31536000"}, ) diff --git a/backend/app/api/routes/library_artists.py b/backend/app/api/routes/library_artists.py index 4839f80e..e74d46c2 100644 --- a/backend/app/api/routes/library_artists.py +++ b/backend/app/api/routes/library_artists.py @@ -827,7 +827,7 @@ def is_valid_image(url: str | None) -> bool: if is_valid_image(artist.image_url): return RedirectResponse( url=artist.image_url, - headers={"Cache-Control": "public, max-age=86400"}, + headers={"Cache-Control": "private, max-age=86400"}, ) # Step 2: Try Last.fm. Promote to Artist.image_url on success so the @@ -856,7 +856,7 @@ def is_valid_image(url: str | None) -> bool: await db.commit() return RedirectResponse( url=image_url, - headers={"Cache-Control": "public, max-age=86400"}, + headers={"Cache-Control": "private, max-age=86400"}, ) except Exception as e: logger.debug(f"Last.fm artist image lookup failed for '{artist.name}': {e}") @@ -887,7 +887,7 @@ def stream_artwork(): return StreamingResponse( stream_artwork(), media_type="image/jpeg", - headers={"Cache-Control": "public, max-age=31536000"}, + headers={"Cache-Control": "private, max-age=31536000"}, ) file_path = Path(track.file_path) @@ -902,7 +902,7 @@ def stream_artwork(): return StreamingResponse( stream_artwork(), media_type="image/jpeg", - headers={"Cache-Control": "public, max-age=31536000"}, + headers={"Cache-Control": "private, max-age=31536000"}, ) raise NotFoundError("No artist image available") diff --git a/backend/app/api/routes/profiles.py b/backend/app/api/routes/profiles.py index ff34ca72..53829d4d 100644 --- a/backend/app/api/routes/profiles.py +++ b/backend/app/api/routes/profiles.py @@ -286,7 +286,7 @@ async def get_avatar( return FileResponse( avatar_file, media_type="image/jpeg", - headers={"Cache-Control": "public, max-age=86400"}, # Cache for 1 day + headers={"Cache-Control": "private, max-age=86400"}, # Cache for 1 day ) diff --git a/backend/app/api/routes/tracks/streaming.py b/backend/app/api/routes/tracks/streaming.py index 3c37e3d2..6cc22367 100644 --- a/backend/app/api/routes/tracks/streaming.py +++ b/backend/app/api/routes/tracks/streaming.py @@ -429,7 +429,7 @@ def stream_artwork() -> Iterator[bytes]: stream_artwork(), # type: ignore[no-untyped-call] media_type="image/jpeg", headers={ - "Cache-Control": "public, max-age=31536000", # Cache for 1 year + "Cache-Control": "private, max-age=31536000", # a year; private since ADR-0176 Phase 4 }, ) diff --git a/backend/app/api/routes/videos.py b/backend/app/api/routes/videos.py index 987ba4ec..82bfb219 100644 --- a/backend/app/api/routes/videos.py +++ b/backend/app/api/routes/videos.py @@ -356,7 +356,7 @@ async def get_video_poster(track_id: UUID) -> FileResponse: return FileResponse( poster_path, media_type="image/jpeg", - headers={"Cache-Control": "public, max-age=31536000"}, + headers={"Cache-Control": "private, max-age=31536000"}, ) diff --git a/backend/app/services/artwork_keys.py b/backend/app/services/artwork_keys.py index b390a5d8..40b8d56e 100644 --- a/backend/app/services/artwork_keys.py +++ b/backend/app/services/artwork_keys.py @@ -1,7 +1,9 @@ """Artwork keys a caller cannot derive (ADR-0176 Phase 1). -Artwork is served without the server token (`MEDIA_ROUTES`, ADR-0045), because an `` and a -casting device cannot send one. That is safe only for a key nobody can guess. Since ADR-0052 an +Artwork was served without any credential (`MEDIA_ROUTES`, ADR-0045), because an `` and a +casting device cannot send the token; since ADR-0176 Phase 4 it takes a cookie or a signed URL +instead. A tokenless server still serves it to whoever ADR-0171 lets in, so a key must stay one +nobody can guess. Since ADR-0052 an album's key is its `Album.id`, a UUID. But a track the album resolver could not place still files its artwork under `compute_album_hash(artist, album)`: sixteen hex digits anyone can compute for a guessed album. Asking `GET /api/v1/artwork//thumb` (or `/artwork/status/`) answered diff --git a/backend/tests/test_inbound_auth.py b/backend/tests/test_inbound_auth.py index ce83f2c3..a1ab6ff0 100644 --- a/backend/tests/test_inbound_auth.py +++ b/backend/tests/test_inbound_auth.py @@ -267,7 +267,10 @@ def test_masked_settings_do_not_contain_it(self, tmp_path): class TestMediaIsExempt: """ADR-0045's media exemption, which was recorded as decided on 2026-08-09 and did not exist until 2026-09-29. Without it, configuring a token broke `` artwork in the web admin, every - stream a WiiM or Sonos fetches for itself, and the Apple app's audio and artwork.""" + stream a WiiM or Sonos fetches for itself, and the Apple app's audio and artwork. + + Since ADR-0176 Phase 4 these shapes are outside the *token* gate only: each needs the header, a + media cookie or a signed URL, which the middleware checks separately (`_media_refused`).""" MEDIA = [ "/api/v1/tracks/3fa85f64-5717-4562-b3fc-2c963f66afa6/stream", @@ -319,8 +322,13 @@ def test_every_exempt_shape_is_a_real_route(self): assert any(pattern.match(p) for p in get_paths), f"no GET route matches {pattern.pattern}" @pytest.mark.asyncio - async def test_the_middleware_serves_media_to_a_client_with_no_token(self, monkeypatch): + async def test_the_middleware_refuses_media_with_no_credential(self, monkeypatch): + """ADR-0176 Phase 4: outside the token gate is not open. `test_media_credentials.py` covers + the cookie and the signed URL; this pins the bare request and the header.""" + path = "/api/v1/tracks/3fa85f64-5717-4562-b3fc-2c963f66afa6/stream" + passed, status = await TestTheMiddlewareGate._call(monkeypatch, "secret", {}, path) + assert not passed and status == 401 passed, status = await TestTheMiddlewareGate._call( - monkeypatch, "secret", {}, "/api/v1/tracks/3fa85f64-5717-4562-b3fc-2c963f66afa6/stream" + monkeypatch, "secret", {"X-Familiar-Token": "secret"}, path ) assert passed and status is None diff --git a/backend/tests/test_media_credentials.py b/backend/tests/test_media_credentials.py index cd1b7bed..f2058bed 100644 --- a/backend/tests/test_media_credentials.py +++ b/backend/tests/test_media_credentials.py @@ -1,8 +1,8 @@ """ADR-0176 Phase 2: media accepts a header, a cookie or a server-signed URL. -Nothing is *required* yet (`MEDIA_ENFORCED` is off until no App Store build lacks the header), so the -gate tests switch enforcement on: they are the exit condition Phase 4 will rely on, written while the -code is fresh. One test pins that, today, media is still served without a credential. +Phase 4 (2026-11-11) made them required on a tokened server: `MEDIA_ENFORCED` ships on, and the gate +tests below are the ADR's exit condition. One test pins that it ships on, and one that media is never +served `Cache-Control: public`. """ from __future__ import annotations @@ -44,7 +44,9 @@ async def _gate(monkeypatch, scope, *, token=TOKEN, enforced=True): monkeypatch.setattr(media, "MEDIA_ENFORCED", enforced) from app import exposure - exposure.record(exposure.Exposure.OPEN) # a tokenless server's own rules are ADR-0171's, not ours + exposure.record( + exposure.Exposure.OPEN + ) # a tokenless server's own rules are ADR-0171's, not ours passed, sent = False, [] async def downstream(scope, receive, send): @@ -59,7 +61,9 @@ async def send(message): def _signed_query(path=STREAM, token=TOKEN, lifetime_s=600, now=None): - return urlsplit(media.sign_url(f"http://nas:4400{path}", token, lifetime_s=lifetime_s, now=now)).query + return urlsplit( + media.sign_url(f"http://nas:4400{path}", token, lifetime_s=lifetime_s, now=now) + ).query # ── Signed URLs ───────────────────────────────────────────────────────────────────────────────── @@ -87,7 +91,9 @@ def test_a_moved_expiry_is_refused(): def test_signing_keeps_the_query_and_never_duplicates_exp_or_sig(): - url = media.sign_url(f"http://nas:4400{STREAM}?format=aac&exp=1&sig=x", TOKEN, lifetime_s=60, now=NOW) + url = media.sign_url( + f"http://nas:4400{STREAM}?format=aac&exp=1&sig=x", TOKEN, lifetime_s=60, now=NOW + ) params = parse_qs(urlsplit(url).query) assert params["format"] == ["aac"] assert len(params["exp"]) == len(params["sig"]) == 1 @@ -95,7 +101,9 @@ def test_signing_keeps_the_query_and_never_duplicates_exp_or_sig(): def test_a_url_on_a_proxy_prefix_still_verifies_at_the_route(): - query = urlsplit(media.sign_url(f"https://host/familiar{STREAM}", TOKEN, lifetime_s=60, now=NOW)).query + query = urlsplit( + media.sign_url(f"https://host/familiar{STREAM}", TOKEN, lifetime_s=60, now=NOW) + ).query assert media.signature_valid(STREAM, query, TOKEN, now=NOW) @@ -145,7 +153,14 @@ async def test_each_credential_reads_media(monkeypatch, credential): {"query": _signed_query(token="rotated").encode()}, {"query": _signed_query(lifetime_s=-1).encode()}, ], - ids=["none", "wrong token", "old cookie", "token as cookie", "old signature", "expired signature"], + ids=[ + "none", + "wrong token", + "old cookie", + "token as cookie", + "old signature", + "expired signature", + ], ) async def test_without_a_valid_credential_media_is_refused(monkeypatch, credential): passed, status = await _gate(monkeypatch, _scope(**credential)) @@ -177,15 +192,43 @@ async def test_a_tokenless_server_is_left_to_its_exposure_rules(monkeypatch): assert passed -# ── Today ─────────────────────────────────────────────────────────────────────────────────────── +# ── As shipped ────────────────────────────────────────────────────────────────────────────────── @pytest.mark.asyncio -async def test_until_phase_4_media_is_served_without_a_credential(monkeypatch): - """App 1.6 and 1.7 send nothing on media. Turning this on early silences every phone.""" - assert media.MEDIA_ENFORCED is False - passed, status = await _gate(monkeypatch, _scope(), enforced=False) - assert passed and status is None +async def test_phase_4_ships_enforced(monkeypatch): + """Since app 1.7 (build 80) is the only App Store build, a tokened server refuses bare media.""" + assert media.MEDIA_ENFORCED is True + _settings(monkeypatch, TOKEN) + from app import exposure + + exposure.record(exposure.Exposure.OPEN) + sent = [] + + async def downstream(scope, receive, send): + raise AssertionError("a bare media read reached the route") + + async def send(message): + sent.append(message) + + await TokenAuthMiddleware(downstream)(_scope(), None, send) + assert next(m["status"] for m in sent if m["type"] == "http.response.start") == 401 + + +def test_media_is_never_served_cache_control_public(): + """Point 6: a shared cache must not hand one listener's library to another.""" + from pathlib import Path + + import app as app_pkg + + root = Path(app_pkg.__file__).parent + offenders = [ + f"{path.relative_to(root)}:{n}" + for path in root.rglob("*.py") + for n, line in enumerate(path.read_text().splitlines(), 1) + if '"Cache-Control": "public' in line + ] + assert offenders == [] # ── The routes ────────────────────────────────────────────────────────────────────────────────── @@ -262,8 +305,13 @@ def test_a_signature_never_reaches_the_access_log(): signed = f"{STREAM}?{_signed_query()}" sig = parse_qs(urlsplit(signed).query)["sig"][0] record = logging.LogRecord( - "uvicorn.access", logging.INFO, __file__, 1, '%s - "%s %s HTTP/%s" %d', - ("192.168.1.30:50000", "GET", signed, "1.1", 206), None, + "uvicorn.access", + logging.INFO, + __file__, + 1, + '%s - "%s %s HTTP/%s" %d', + ("192.168.1.30:50000", "GET", signed, "1.1", 206), + None, ) assert RedactCredentialsFilter().filter(record) line = record.getMessage() @@ -283,3 +331,18 @@ def test_the_capability_is_advertised(): from app.config import SERVER_CAPABILITIES assert "media.credentials" in SERVER_CAPABILITIES + + +@pytest.mark.asyncio +async def test_a_refusal_is_named_in_the_log_once_per_client(monkeypatch, caplog): + """An app older than 1.7 goes silent; the server log is where its owner finds out why.""" + from app.api import auth + + monkeypatch.setattr(auth, "_media_refusals_logged", {}) + with caplog.at_level(logging.WARNING, logger="app.api.auth"): + for _ in range(3): + passed, status = await _gate(monkeypatch, _scope()) + assert not passed and status == 401 + warnings = [r for r in caplog.records if "Refused media" in r.getMessage()] + assert len(warnings) == 1 + assert "192.168.1.20" in warnings[0].getMessage() and "1.7" in warnings[0].getMessage() diff --git a/docs/HEALTH.md b/docs/HEALTH.md index 388f3987..97a42e2b 100644 --- a/docs/HEALTH.md +++ b/docs/HEALTH.md @@ -15,5 +15,4 @@ this replaced, and what was retired from it). | Backend | `SCANNER_THREADS` is read from the environment at import in `backend/app/services/scanner.py`; ADR-0130 point 6 says environment enters through `Settings` | ADR-0130 Implementation, point 6 | The library-sync domain migrates under ADR-0130 | | Web admin | The web app cannot set the library path or the API keys; both are environment variables. The Server → Health and Providers pages say so and name the variable | `backend/app/api/routes/settings.py` pops `music_library_paths`; ADR-0126 point 10 | A decision to accept `PUT /settings` for them, or an explicit decision that they stay environment-only | | Tests | Backend pure tests are not labelled, so every `pytest` run needs `TEST_DATABASE_URL` even for tests that never connect | ADR-0128 Implementation, point 7 | ADR-0130's factory makes "does not import the assembled app" checkable, and pure tests get a marker | -| Security | Media is exempt from the token gate (`MEDIA_ROUTES`, ADR-0045), so a stream or artwork URL works for anyone who holds it, for ever, and rotating the token does not stop it. The header, a media cookie and signed URLs are accepted and the clients send them (ADR-0176 Phases 2–3), but none is required while App Store builds that send nothing are offered. The derivable album-hash oracle is closed (Phase 1); the main API is protected by default (ADR-0171). Existing tokenless installations still serve everyone, and warn | `MEDIA_ROUTES` in `backend/app/api/auth.py`; `MEDIA_ENFORCED` in `backend/app/api/media_credentials.py`; `backend/app/exposure.py` | ADR-0176 Phase 4: once no offered App Store build lacks the media header, turn on `MEDIA_ENFORCED`, make media `Cache-Control: private`, remove this row | | Process | A clean-container pass reaches `make check` from CONTRIBUTING alone (ADR-0175, 2026-11-09), but no person unfamiliar with the project has yet set up from it | ADR-0175 point 7 | A developer new to the project reaches `make check` with no undocumented step | diff --git a/docs/decisions/ADR-0176-media-is-authorized-by-header-cookie-or-signed-url.md b/docs/decisions/ADR-0176-media-is-authorized-by-header-cookie-or-signed-url.md index 69ae8fee..15152152 100644 --- a/docs/decisions/ADR-0176-media-is-authorized-by-header-cookie-or-signed-url.md +++ b/docs/decisions/ADR-0176-media-is-authorized-by-header-cookie-or-signed-url.md @@ -55,8 +55,34 @@ Implementation: - **A background download queued before a rotation keeps the old token**, held by the system with the task's other headers. Once enforced it fails until retried; point 4 forbids the token only in a URL or `DownloadTaskDescription`, which this respects. -- **Not done:** Phase 4 (enforce, `Cache-Control: private`, remove the health-register entry) waits for - point 8's trigger: 1.6 and 1.7 (build 78) send no credential on media and are still offered. +- **Phase 4, 2026-11-11: enforced.** Point 8's trigger was checked, not assumed: app 1.7 (build 80) + was released by hand on iOS and macOS that day, and App Store Connect read `READY_FOR_DISTRIBUTION` + for both. Build 80 was built from `familiar-apple` `2649ca93`, which contains #267, and its + visualizer page was re-vendored for this ADR (`86e08d4`). 1.5.1, the build it replaced, had the + same minimum OS (iOS 17.0, macOS 14.4), so no device is offered it as a last compatible version. + 1.6 and build 78 only ever reached TestFlight. `MEDIA_ENFORCED` is now `True`. Every media route + answers `Cache-Control: private`: artwork, track artwork, artist images, video posters and + avatars had been `public`, and stream already was `private`. `test_media_is_never_served_cache_control_public` + scans `app/` so none can go back. The `docs/HEALTH.md` row is removed. Tests: + `test_phase_4_ships_enforced`, and `TestMediaIsExempt.test_the_middleware_refuses_media_with_no_credential`, + which replaces the test that pinned the exemption. All three fail against the Phase 3 code. + **It reaches a listener only when their server upgrades**, so an iPhone or Mac still on 1.5.1 or an + old TestFlight build loses audio and artwork from a tokened server at that upgrade, not before. + Such an app shows nothing useful, so the server logs a warning naming the client and the likely + cause (an app older than 1.7), once per client an hour (`_warn_media_refused` in `auth.py`; + `test_a_refusal_is_named_in_the_log_once_per_client`). The author's own iPhone and Mac were + checked on build 80 before this was written. +- **Found while building Phase 4, contradicting Phase 3's note:** *the speaker route is called.* + `familiar-apple`'s casting, `ServerCastOutputsSource.swift`, calls `POST /outputs/{id}/play` with + the stream URL, and that is how a WiiM or other UPnP speaker gets its URL. So the Phase 2 signing + is live, not idle, and the route must stay. The "nothing calls the speaker route" note above came + from searching for the web player, which was retired, and not for the generated + `outputsPlayToOutput`. Speaker metadata carries no artwork URL (`TrackMetadata.artwork_url` is + never set), so there is no unsigned URL in the DIDL either. +- **Not done:** the public site still says the music is served without sign-in + (`site/index.html`, "Listening away from home", and `site/faq.html`'s reverse-proxy answer). + That stays true for every released server up to v0.2.0-beta24. It changes in the release that + ships Phase 4 (ADR-0145 point 6's expiry, recorded in `docs/SITE-CLAIMS.md`). Extends [ADR-0171](ADR-0171-a-remotely-reachable-server-is-protected-by-default.md) and [ADR-0045](ADR-0045-familiar-authenticates-inbound-requests.md)