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)