diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a7c383..369dfd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,6 @@ ## 0.2.45 - 2026-08-10 - Bug fixes -## Server 0.1.31 — 2026-08-10 -- Added: The admin console now has a Journeys page for visit health, feature use, significant actions, common paths and per-profile event history. - ## 0.2.43 — 2026-08-10 - Fixed: App no longer crashes. - Fixed: Manual surround-sound choices now reliably override the automatically detected audio output. diff --git a/server/README.md b/server/README.md index fab7808..f819dd2 100644 --- a/server/README.md +++ b/server/README.md @@ -424,6 +424,13 @@ while left open; the verdict is `none`, `optional` or `mandatory`. This endpoint deliberately public and remains available during maintenance: update policy is checked before login and never reads, validates, or mutates a viewer session. +The App updates page separates a required update from a destructive one. Required covers +the home screen until the APK is installed but keeps the viewer's session. “Set the +destructive floor to this update” records the release as the destructive floor: the next request +from an older build deletes its session, and signing in again is refused until that build +has updated. “Sign out builds below” lets the operator set that floor to an exact version +instead; leaving it blank disables destructive retirement. + ### First-time TV installation The gateway hosts a public bootstrap page at: @@ -522,7 +529,8 @@ For a server-only emergency deployment, explicitly opt out: ``` Set it on the admin page: **latest version**, **APK URL** (normally the same file the -landing page serves), release notes, and a **Require this update** toggle. +landing page serves), release notes, the required-update toggle, and an optional +destructive compatibility floor. - *Optional* — a dismissable prompt. Dismissal lasts for that session only. - *Required* — a full-screen panel over the home screen with no way past it. Back is @@ -533,13 +541,12 @@ landing page serves), release notes, and a **Require this update** toggle. `minimumVersion` can also be set directly for a staged rollout where the forced floor is older than the newest build. -Builds below 0.2.44 are permanently retired once the enabled policy points at an -actionable 0.2.44-or-newer release. On their next authenticated request the gateway -deletes the session and returns 401, which makes the TV remove the rejected local profile; -the public update check continues to return the mandatory update screen. The gateway also -refuses a new login from a retired build, so signing in again cannot bypass the update. -This floor remains dormant when the policy has no download URL or its latest release is -older than 0.2.44. +When **Sign out builds below** is set, a build below that version is retired on its next +authenticated request: the gateway deletes the session and returns 401, which makes the TV +remove the rejected local profile. The public update check continues to return the +mandatory update screen, and the gateway refuses a new login from the retired build, so +signing in again cannot bypass the update. The floor remains dormant when the policy has no +download URL or its latest release is older than the selected floor. Two deliberate safeguards, both tested in `internal/appupdate`: diff --git a/server/internal/api/admin.go b/server/internal/api/admin.go index 7916f85..46da9da 100644 --- a/server/internal/api/admin.go +++ b/server/internal/api/admin.go @@ -521,6 +521,12 @@ type updatePolicyRequest struct { // toggle rather than exposing "minimum version" directly, because "force this // update" is the decision an operator actually wants to make. Required bool `json:"required"` + // Destructive removes sessions for clients below this release. It implies Required, + // but remains separate so a required update can keep viewers signed in. + Destructive bool `json:"destructive"` + // RetireBelowVersion exposes the exact destructive compatibility floor for releases + // where the operator needs to retire only part of the installed fleet. + RetireBelowVersion string `json:"retireBelowVersion"` // MinimumVersion is honoured when set explicitly, for staged rollouts where the // forced floor is older than the latest build. MinimumVersion string `json:"minimumVersion"` @@ -533,14 +539,15 @@ func (s *Server) handleAdminUpdatePolicy(w http.ResponseWriter, r *http.Request) return } - policy := appupdate.Policy{ - Enabled: req.Enabled, - LatestVersion: strings.TrimSpace(req.LatestVersion), - MinimumVersion: strings.TrimSpace(req.MinimumVersion), - DownloadURL: strings.TrimSpace(req.DownloadURL), - Notes: strings.TrimSpace(req.Notes), - } current := s.updatePolicy.get() + policy := appupdate.Policy{ + Enabled: req.Enabled, + LatestVersion: strings.TrimSpace(req.LatestVersion), + MinimumVersion: strings.TrimSpace(req.MinimumVersion), + RetireBelowVersion: strings.TrimSpace(req.RetireBelowVersion), + DownloadURL: strings.TrimSpace(req.DownloadURL), + Notes: strings.TrimSpace(req.Notes), + } if policy.LatestVersion == current.LatestVersion && policy.DownloadURL == current.DownloadURL { // Changing "required" or release notes must not silently discard integrity // metadata added by the signed release publisher. @@ -554,6 +561,10 @@ func (s *Server) handleAdminUpdatePolicy(w http.ResponseWriter, r *http.Request) // Un-ticking the box must actually release the floor. policy.MinimumVersion = "" } + if req.Destructive { + policy.MinimumVersion = policy.LatestVersion + policy.RetireBelowVersion = policy.LatestVersion + } if policy.Enabled && policy.LatestVersion == "" { writeError(w, http.StatusBadRequest, "set the latest version before enabling update prompts") @@ -563,6 +574,15 @@ func (s *Server) handleAdminUpdatePolicy(w http.ResponseWriter, r *http.Request) writeError(w, http.StatusBadRequest, "set the APK download URL before enabling update prompts") return } + if policy.RetireBelowVersion != "" && !releaseVersionPattern.MatchString(policy.RetireBelowVersion) { + writeError(w, http.StatusBadRequest, "the destructive update floor must look like 0.2.44") + return + } + if policy.Enabled && policy.RetireBelowVersion != "" && + appupdate.CompareVersions(policy.RetireBelowVersion, policy.LatestVersion) > 0 { + writeError(w, http.StatusBadRequest, "the destructive update floor cannot be newer than the latest version") + return + } if err := s.store.SetUpdatePolicy(r.Context(), policy); err != nil { s.loggerFor(r.Context()).Error("update policy write failed", "error", err) diff --git a/server/internal/api/admin/pages/updates.html b/server/internal/api/admin/pages/updates.html index f19f7f1..208d162 100644 --- a/server/internal/api/admin/pages/updates.html +++ b/server/internal/api/admin/pages/updates.html @@ -17,11 +17,20 @@ + +