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 @@ + +
diff --git a/server/internal/api/admin/pages/updates.js b/server/internal/api/admin/pages/updates.js index 35982e6..f876adb 100644 --- a/server/internal/api/admin/pages/updates.js +++ b/server/internal/api/admin/pages/updates.js @@ -5,16 +5,20 @@ Admin.onStatus((status) => { // "Required" is not a field of its own: it is the minimum and the latest being the same // version, which is what the client compares against. const required = Boolean(policy.minimumVersion) && policy.minimumVersion === policy.latestVersion; + const destructive = Boolean(policy.retireBelowVersion) && + policy.retireBelowVersion === policy.latestVersion; $('update-state').innerHTML = !policy.enabled ? ui.tag('off', 'idle') - : ui.tag((required ? 'required · ' : 'optional · ') + policy.latestVersion, - required ? 'warn' : 'ok'); + : ui.tag((destructive ? 'sign-out · ' : required ? 'required · ' : 'optional · ') + + policy.latestVersion, required ? 'warn' : 'ok'); Admin.fill($('update-version'), policy.latestVersion || ''); Admin.fill($('update-url'), policy.downloadUrl || ''); Admin.fill($('update-notes'), policy.notes || ''); + Admin.fill($('update-retire-below'), policy.retireBelowVersion || ''); Admin.check($('update-required'), required); + Admin.check($('update-destructive'), destructive); }); const body = (enabled) => JSON.stringify({ @@ -23,12 +27,25 @@ const body = (enabled) => JSON.stringify({ downloadUrl: $('update-url').value.trim(), notes: $('update-notes').value.trim(), required: $('update-required').checked, + destructive: $('update-destructive').checked, + retireBelowVersion: $('update-retire-below').value.trim(), }); Admin.ready(() => { + $('update-destructive').addEventListener('change', () => { + if ($('update-destructive').checked) { + $('update-required').checked = true; + $('update-retire-below').value = $('update-version').value.trim(); + } else if ($('update-retire-below').value.trim() === $('update-version').value.trim()) { + $('update-retire-below').value = ''; + } + }); $('update-save').addEventListener('click', () => { - if ($('update-required').checked && !confirm('Required updates block the home screen on ' + - 'every television below this version. Continue?')) return; + const destructive = $('update-destructive').checked; + const warning = destructive + ? 'This will delete sessions on every older television and force viewers to sign in again after updating. Continue?' + : 'Required updates block the home screen on every television below this version. Continue?'; + if ($('update-required').checked && !confirm(warning)) return; Admin.act(() => Admin.api('/admin/api/update-policy', { method: 'POST', body: body(true) })); }); $('update-disable').addEventListener('click', () => diff --git a/server/internal/api/api.go b/server/internal/api/api.go index 2ca2366..e2c79c8 100644 --- a/server/internal/api/api.go +++ b/server/internal/api/api.go @@ -22,6 +22,7 @@ import ( "sync" "time" + "github.com/ponzischeme89/memby/server/internal/appupdate" "github.com/ponzischeme89/memby/server/internal/bazarr" "github.com/ponzischeme89/memby/server/internal/cache" "github.com/ponzischeme89/memby/server/internal/config" @@ -269,8 +270,10 @@ func (s *Server) authed(h authedFunc) http.Handler { } sess = s.captureClientIdentity(r, sess) identify(r.Context(), sess) - decision := s.updateDecision(r) - if mustRetireForUpdate(decision, clientVersion(r)) { + policy := s.updatePolicy.get() + decision := appupdate.Decide(effectiveUpdatePolicy(policy), clientVersion(r)) + retireBelow := destructiveUpdateFloor(policy) + if mustRetireForUpdate(decision, clientVersion(r), retireBelow) { // Mirror an ordinary sign-out closely enough that this token cannot be restored // from either database or Redis. The 401 is intentional: every supported client // treats it as authoritative and removes the rejected local profile. @@ -282,7 +285,7 @@ func (s *Server) authed(h authedFunc) http.Handler { s.loggerFor(r.Context()).Info("signed out for required update", "device_id", sess.DeviceID, "from", clientLogValue(clientVersion(r)), - "minimum", forcedUpdateFloor, + "minimum", retireBelow, "to", decision.Version, ) w.Header().Set("X-Memby-Update-Required", decision.Version) diff --git a/server/internal/api/release.go b/server/internal/api/release.go index 1e958a2..a91a060 100644 --- a/server/internal/api/release.go +++ b/server/internal/api/release.go @@ -161,13 +161,14 @@ func (s *Server) handleReleasePublish(w http.ResponseWriter, r *http.Request) { } policy := appupdate.Policy{ - Enabled: true, - LatestVersion: version, - MinimumVersion: current.MinimumVersion, - DownloadURL: s.cfg.PublicURL + s.signedReleasePath(filename), - SHA256: actualSHA256, - SizeBytes: written, - Notes: strings.TrimSpace(r.FormValue("notes")), + Enabled: true, + LatestVersion: version, + MinimumVersion: current.MinimumVersion, + RetireBelowVersion: current.RetireBelowVersion, + DownloadURL: s.cfg.PublicURL + s.signedReleasePath(filename), + SHA256: actualSHA256, + SizeBytes: written, + Notes: strings.TrimSpace(r.FormValue("notes")), } if mandatory { // Setting the floor to the release being published makes every older client diff --git a/server/internal/api/update.go b/server/internal/api/update.go index 73b01e0..3bc46e5 100644 --- a/server/internal/api/update.go +++ b/server/internal/api/update.go @@ -17,12 +17,6 @@ import ( // speaks, next to the build's own version. const ProtocolVersion = 1 -// forcedUpdateFloor retires builds whose update behaviour is no longer reliable enough -// to leave optional. The floor only takes effect once an enabled policy points at this -// version (or a newer one) and carries a download URL, so deploying the gateway before -// publishing the APK cannot lock televisions out. -const forcedUpdateFloor = "0.2.44" - // updatePolicyCache keeps the policy in memory. It is read on every home request, and a // database round trip per home load to answer "nothing to say" would be wasteful. type updatePolicyCache struct { @@ -96,16 +90,30 @@ func compatibilityFor(r *http.Request) (bool, string) { return true, "" } -// effectiveUpdatePolicy applies the server-owned emergency floor without weakening a -// higher minimum the operator has already selected. +// destructiveUpdateFloor returns the operator-selected compatibility floor once an +// actionable release at or above it exists. This keeps a policy saved ahead of its APK +// from locking televisions out. +func destructiveUpdateFloor(policy appupdate.Policy) string { + if !policy.Enabled || strings.TrimSpace(policy.DownloadURL) == "" { + return "" + } + floor := strings.TrimSpace(policy.RetireBelowVersion) + if floor == "" || appupdate.CompareVersions(policy.LatestVersion, floor) < 0 { + return "" + } + return floor +} + +// effectiveUpdatePolicy applies the destructive floor without weakening a higher +// non-destructive minimum the operator has already selected. func effectiveUpdatePolicy(policy appupdate.Policy) appupdate.Policy { - if !policy.Enabled || strings.TrimSpace(policy.DownloadURL) == "" || - appupdate.CompareVersions(policy.LatestVersion, forcedUpdateFloor) < 0 { + floor := destructiveUpdateFloor(policy) + if floor == "" { return policy } if strings.TrimSpace(policy.MinimumVersion) == "" || - appupdate.CompareVersions(policy.MinimumVersion, forcedUpdateFloor) < 0 { - policy.MinimumVersion = forcedUpdateFloor + appupdate.CompareVersions(policy.MinimumVersion, floor) < 0 { + policy.MinimumVersion = floor } return policy } @@ -116,10 +124,10 @@ func (s *Server) updateDecision(r *http.Request) appupdate.Decision { // mustRetireForUpdate is narrower than "mandatory": an operator may temporarily force a // newer release without wanting every otherwise supported session destroyed. Only builds -// below the permanent compatibility floor are signed out. -func mustRetireForUpdate(decision appupdate.Decision, version string) bool { +// below the active destructive floor are signed out. +func mustRetireForUpdate(decision appupdate.Decision, version, floor string) bool { return decision.Status == appupdate.StatusMandatory && decision.DownloadURL != "" && - appupdate.CompareVersions(version, forcedUpdateFloor) < 0 + strings.TrimSpace(floor) != "" && appupdate.CompareVersions(version, floor) < 0 } // requireSupportedClient prevents a retired build from signing straight back in after @@ -127,8 +135,9 @@ func mustRetireForUpdate(decision appupdate.Decision, version string) bool { // available and will keep returning the actionable mandatory verdict. func (s *Server) requireSupportedClient(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - decision := s.updateDecision(r) - if mustRetireForUpdate(decision, clientVersion(r)) { + policy := s.updatePolicy.get() + decision := appupdate.Decide(effectiveUpdatePolicy(policy), clientVersion(r)) + if mustRetireForUpdate(decision, clientVersion(r), destructiveUpdateFloor(policy)) { w.Header().Set("X-Memby-Update-Required", decision.Version) writeJSON(w, http.StatusUpgradeRequired, decision) return diff --git a/server/internal/api/update_test.go b/server/internal/api/update_test.go index 6e0429f..91809a1 100644 --- a/server/internal/api/update_test.go +++ b/server/internal/api/update_test.go @@ -67,12 +67,13 @@ func TestUpdateOfferLogNamesTheAffectedViewer(t *testing.T) { } } -func TestEmergencyFloorForcesClientsBelow0244(t *testing.T) { +func TestConfiguredFloorForcesClientsBelow0244(t *testing.T) { server := testServer(config.Config{}) server.updatePolicy.set(appupdate.Policy{ - Enabled: true, - LatestVersion: "0.2.44", - DownloadURL: "/updates/memby-0.2.44.apk?token=signed", + Enabled: true, + LatestVersion: "0.2.44", + RetireBelowVersion: "0.2.44", + DownloadURL: "/updates/memby-0.2.44.apk?token=signed", }) for version, want := range map[string]string{ @@ -95,33 +96,72 @@ func TestEmergencyFloorForcesClientsBelow0244(t *testing.T) { } } -func TestEmergencyFloorWaitsForAnActionableRelease(t *testing.T) { +func TestConfiguredFloorWaitsForAnActionableRelease(t *testing.T) { for name, policy := range map[string]appupdate.Policy{ "disabled": { - LatestVersion: "0.2.44", DownloadURL: "/updates/memby-0.2.44.apk", + LatestVersion: "0.2.44", RetireBelowVersion: "0.2.44", + DownloadURL: "/updates/memby-0.2.44.apk", }, "missing download": { - Enabled: true, LatestVersion: "0.2.44", + Enabled: true, LatestVersion: "0.2.44", RetireBelowVersion: "0.2.44", }, "release too old": { - Enabled: true, LatestVersion: "0.2.43", DownloadURL: "/updates/memby-0.2.43.apk", + Enabled: true, LatestVersion: "0.2.43", RetireBelowVersion: "0.2.44", + DownloadURL: "/updates/memby-0.2.43.apk", }, } { t.Run(name, func(t *testing.T) { decision := appupdate.Decide(effectiveUpdatePolicy(policy), "0.2.43") - if mustRetireForUpdate(decision, "0.2.43") { - t.Fatal("client would be retired without an actionable 0.2.44-or-newer release") + if mustRetireForUpdate(decision, "0.2.43", destructiveUpdateFloor(policy)) { + t.Fatal("client would be retired without an actionable release at the configured floor") } }) } } +func TestSelectedDestructiveFloorRetiresClientsBelowRelease(t *testing.T) { + policy := appupdate.Policy{ + Enabled: true, + LatestVersion: "0.3.0", + RetireBelowVersion: "0.3.0", + DownloadURL: "/updates/memby-0.3.0.apk?token=signed", + } + + for version, want := range map[string]bool{ + "0.2.99": true, + "0.3.0": false, + "0.3.1": false, + } { + decision := appupdate.Decide(effectiveUpdatePolicy(policy), version) + if got := mustRetireForUpdate(decision, version, destructiveUpdateFloor(policy)); got != want { + t.Errorf("%s: retired = %t, want %t", version, got, want) + } + } +} + +func TestRequiredUpdateDoesNotRetireSupportedClient(t *testing.T) { + policy := appupdate.Policy{ + Enabled: true, + LatestVersion: "0.3.0", + MinimumVersion: "0.3.0", + DownloadURL: "/updates/memby-0.3.0.apk?token=signed", + } + decision := appupdate.Decide(effectiveUpdatePolicy(policy), "0.2.44") + if decision.Status != appupdate.StatusMandatory { + t.Fatalf("status = %q, want mandatory", decision.Status) + } + if mustRetireForUpdate(decision, "0.2.44", destructiveUpdateFloor(policy)) { + t.Fatal("ordinary required update retired a supported client") + } +} + func TestRetiredClientCannotSignBackIn(t *testing.T) { server := testServer(config.Config{}) server.updatePolicy.set(appupdate.Policy{ - Enabled: true, - LatestVersion: "0.2.44", - DownloadURL: "/updates/memby-0.2.44.apk?token=signed", + Enabled: true, + LatestVersion: "0.2.44", + RetireBelowVersion: "0.2.44", + DownloadURL: "/updates/memby-0.2.44.apk?token=signed", }) reached := false handler := server.requireSupportedClient(func(http.ResponseWriter, *http.Request) { diff --git a/server/internal/appupdate/policy.go b/server/internal/appupdate/policy.go index 6c4f9f7..080ed43 100644 --- a/server/internal/appupdate/policy.go +++ b/server/internal/appupdate/policy.go @@ -32,6 +32,10 @@ type Policy struct { // an old release) to keep updates optional; set it to LatestVersion to make the // current release mandatory for everyone. MinimumVersion string `json:"minimumVersion"` + // RetireBelowVersion is the destructive floor. Clients below it have their session + // removed before receiving the mandatory update screen. It is separate from + // MinimumVersion so an ordinary required update does not sign viewers out. + RetireBelowVersion string `json:"retireBelowVersion,omitempty"` // DownloadURL points at the APK — normally the same file the landing page serves. DownloadURL string `json:"downloadUrl"` // SHA256 and SizeBytes let the TV reject a truncated, stale or substituted download diff --git a/server/internal/buildinfo/VERSION b/server/internal/buildinfo/VERSION index db7a480..28d0075 100644 --- a/server/internal/buildinfo/VERSION +++ b/server/internal/buildinfo/VERSION @@ -1 +1 @@ -0.1.31 +0.1.32