Feature: Updates part 1: signed releases, release feed and read-only update notification As the owner of a RenewCMS site I want to see when a newer signed release exists, and have admins see the same status read-only So that I know when to update, while downloading and restarting arrive in later slices Background: Given slices 1 to 3 of Updates ship in one release, so no released build shows the command "renewcms enable-browser-updates" before it exists (it is implemented in slice 3) And every release is published as a Gitea prerelease with the six binaries, checksums.txt and checksums.txt.sig And cmd/releasesign is a stdlib-only tool (crypto/ed25519, no new dependency) with the commands keygen [--pub-out ], sign [--key ] [--pub ], verify [--pub ] And keygen writes the private key to the path given and refuses a path inside the repository work tree And sign and verify use the embedded public key unless --pub is given; tests always pass --pub with a throwaway key And sign refuses to sign when the private key does not match the public key, naming the mismatch And the public key is internal/infrastructure/update/release.pub, embedded with go:embed And until the user runs task release:keygen and commits the real public key, release.pub holds the placeholder: the base64 of 32 zero bytes (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=); an all-zero key is recognised as the placeholder, and sign and verify refuse it with a clear message And the real private key is generated by the user outside the repo; the coder never generates or commits it And task release writes checksums.txt (SHA-256 of every binary) next to the binaries and signs it into checksums.txt.sig with the private key at the path in the env var RELEASE_SIGNING_KEY And task release fails clearly, before building anything, when RELEASE_SIGNING_KEY is unset, the file is missing, the embedded key is the placeholder, or the private key does not match the embedded public key And task release:keygen creates the one-time key pair and prints where to commit the public key And docs/release-notes.md gains the publish step (attach checksums.txt and checksums.txt.sig with the six binaries), docs/deployment.md and docs/configuration.md are updated, and nothing is published And the domain package internal/domain/update (stdlib only) holds Release, ReleaseNotes, version comparison, UpdateStatus, the ReleaseFeed interface, the StatusStore interface and the Err sentinels And the Gitea adapter lives under internal/infrastructure/update and implements update.ReleaseFeed with var _ update.ReleaseFeed = &X{}; it requests GET {UPDATE_FEED_URL}/api/v1/repos/fsylvain/RenewCMS/releases (the list, not "latest") And UPDATE_FEED_URL is in requiredEnvVars/envDefaults, .env.example and docs/configuration.md, defaulting to the repo base URL And a JSON file store implements update.StatusStore and keeps the last result and checked_at in update-status.json beside DB_FILE, shared by all owners and surviving a restart; no new env var is added And GetUpdateStatusUseCase and CheckForUpdateUseCase depend only on the domain interfaces; handlers in api/handlers/update receive them through NewHandler; api/dependecyInjection.go builds the adapters And NewSystemRouter is mounted at /v1/system behind jwtauth.Verifier, jwtauth.Authenticator, RequireCurrentToken and RequireVerifiedUser; POST /update/check also sits behind RequireOwner and a limiter of 5 checks per minute per user_id (constants, not env vars, separate from the auth limiter) that answers 429 with an integer Retry-After And GET /v1/system/update returns 200 with JSON of exactly these snake_case keys: current, latest, available, release_url, notes {version, released_at, items}, checked_at, can_apply, state, progress, error, started_at And release_url is the release's html_url, null when never checked; in slice 3 the "How to update" link is repointed at the "Browser updates" section of docs/deployment.md And in this slice can_apply is always false and state is always "idle"; never-checked values are latest "", available false, release_url null, notes null, checked_at null, progress null, error null, started_at null And versions compare numerically per segment, a final release beats a beta of the same number, and the running build "dev" never reports an update And release notes are the release body converted to a plain-text list of items And the API returns raw tags; the SPA shows the newer release without its leading "v" ("Version 2.3.0-beta") and the running version exactly as reported And the download and restart endpoints are not mounted; ReleaseFeed, StatusStore and UpdateStatus are shaped so slices 2 and 3 only add use cases And errors are plain text from http.Error Scenario Outline: Version comparison Given the running version is "" and the newest feed release is "" When the versions are compared Then available is Examples: | current | latest | available | | v2.2.0-beta | v2.3.0-beta | true | | v2.5.0-beta | v2.5.0 | true | | v2.5.0 | v2.5.0-beta | false | | v2.9.0 | v2.10.0 | true | | v2.5.0-beta | v2.5.0-beta | false | | v2.5.0 | v2.4.9 | false | | dev | v9.9.9 | false | Scenario: The newest release is chosen from the list regardless of feed order Given the feed list holds v2.4.0-beta, v2.5.0-beta and v2.3.0-beta, all marked prerelease When the feed is read Then the latest release is v2.5.0-beta Scenario: Drafts are ignored Given the feed list holds a draft release v9.0.0 and a published v2.5.0-beta When the feed is read Then the latest release is v2.5.0-beta Scenario: Release notes become a plain-text list Given a release body "## What's new\n- **Media** library\n- Fixed [caching](http://x) bug\n\n* Faster start" When the notes are built Then notes.items is ["Media library","Fixed caching bug","Faster start"] And notes.released_at is the release's published date Scenario: GET returns the idle shape, never applicable Given a verified user, running version "v2.2.0-beta", and a stored result with latest "v2.3.0-beta", html_url "https://git.example/fsylvain/RenewCMS/releases/tag/v2.3.0-beta" and checked_at 2026-09-30T09:41:00Z When the client sends GET /v1/system/update Then the status is 200 and the body has exactly the 12 keys And current is "v2.2.0-beta", latest is "v2.3.0-beta", available is true, can_apply is false, state is "idle" And release_url is the stored html_url and checked_at is "2026-09-30T09:41:00Z" And the feed is not contacted Scenario: GET before any check Given no stored result When a verified user sends GET /v1/system/update Then the status is 200, available is false, latest is "", release_url, notes and checked_at are null, state is "idle" Scenario Outline: GET is open to any verified user Given the caller is with a valid jwt cookie When the client sends GET /v1/system/update Then the status is 200 Examples: | role | | owner | | admin | Scenario: GET without a cookie When the client sends GET /v1/system/update with no jwt cookie Then the status is 401 Scenario: GET with a stale token Given the jwt cookie was minted with a token version older than the account's When the client sends GET /v1/system/update Then the status is 401 and the body is apperr.UnauthorizedErrorMessage Scenario: GET by an unverified account When an unverified user with a valid cookie sends GET /v1/system/update Then the status is 403 and the body is apperr.VerificationRequiredErrorMessage Scenario: The owner checks and the result is stored Given the fake feed returns v2.3.0-beta and the running version is "v2.2.0-beta" When the owner sends POST /v1/system/update/check Then the status is 200 with the same body shape as GET and available true And the fake store holds latest "v2.3.0-beta" and checked_at at the check time And a later GET returns the same result without contacting the feed Scenario: A check with nothing newer Given the feed returns v2.2.0-beta and the running version is "v2.2.0-beta" When the owner sends POST /v1/system/update/check Then the status is 200 and available is false Scenario: A dev build never reports an update Given the running version is "dev" and the feed returns v9.9.9 When the owner sends POST /v1/system/update/check Then the status is 200 and available is false Scenario: An admin cannot check Given the caller is an admin When the client sends POST /v1/system/update/check Then the status is 403 and the body is apperr.OwnerRequiredErrorMessage And the feed is not contacted Scenario: Feed failure keeps the last result Given the store holds latest "v2.3.0-beta" checked at 2026-09-30T09:41:00Z and the feed returns an error When the owner sends POST /v1/system/update/check Then the status is 500 And the store still holds the previous result and checked_at And no feed error detail appears in the body Scenario: Check is rate limited per user When the owner sends 6 checks within one minute Then the first 5 answer 200 and the 6th answers 429 with apperr.TooManyRequestsErrorMessage and an integer Retry-After And the feed is contacted 5 times And a different owner is not limited by the first owner's checks And the auth limiter's counters are untouched Scenario: The stored result survives a restart Given the JSON file store saved a result When a new store is opened on the same file Then the same latest, release_url and checked_at are returned And a missing or corrupt file reads as "no result" and is not fatal Scenario: Release signing round trip with a throwaway key Given a key pair made by keygen at a path outside the repo When sign is run on checksums.txt with that key and verify with its public key Then verify exits 0 And after one byte of checksums.txt changes, verify exits non-zero with a clear message And verify with a different public key exits non-zero Scenario: keygen refuses a path inside the repository When keygen is given a path inside the work tree Then it exits non-zero, writes nothing and says why Scenario: The placeholder public key is refused Given release.pub holds the placeholder (32 zero bytes, base64) When sign or verify uses the embedded key Then it exits non-zero with a message that the placeholder key has not been replaced Scenario: sign refuses a private key that does not match the public key Given a private key whose public half differs from the public key in use When sign is run Then it exits non-zero naming the mismatch and writes no .sig file Scenario Outline: task release refuses to sign Given When task release is run Then it fails before building any binary with a message naming And nothing is published Examples: | condition | what | | RELEASE_SIGNING_KEY is unset | RELEASE_SIGNING_KEY | | RELEASE_SIGNING_KEY points to a missing file | the missing path | | the embedded public key is the placeholder | the placeholder key | | the private key does not match the public key | the key mismatch | Scenario: Settings, owner, Up to date (route /settings) Given the session role is "owner" and GET /v1/system/update resolves available false with checked_at set When the owner opens /settings Then the Updates card shows the status block with icon circle-check (success), title "You're up to date", text "You have the latest version of RenewCMS." and a meta line "Last checked ..." built from checked_at And the only button is "Check for updates" (refresh-cw, secondary) And no Restart button, hint or progress is rendered Scenario: Settings, owner, Checking When the owner presses "Check for updates" Then the block shows icon loader-circle, "Checking for updates…" / "This usually takes a few seconds." in a polite live region And the button reads "Checking…" and is disabled And POST /v1/system/update/check is sent once Scenario: Settings, owner, Check failed Given POST /v1/system/update/check answers 500 or never reaches the server Then the block shows icon wifi-off (warning), "Couldn't check for updates" / "RenewCMS couldn't reach the update server. Check your connection and try again. Your site isn't affected." and the meta "Last checked ..." from the kept result And the only button is "Try again" (rotate-cw, primary) Scenario: Settings, owner, newer version, not prepared Given the session role is "owner" and GET /v1/system/update resolves available true, can_apply false, latest "v2.3.0-beta" When the owner opens /settings Then the block shows icon info, "Version 2.3.0-beta is available" / "This site can't update itself from the browser yet. Whoever manages your server can set it up once, or update RenewCMS for you." And "For whoever manages the server" shows the one-line code "renewcms enable-browser-updates" with a Copy button And the note "They run this once on the server. After that, Update and Restart appear here." shows And the link "How to update" (arrow-up-right) opens release_url in a new tab And no button other than Copy is rendered: not Update, not Restart, not even Check for updates Scenario: Settings, owner, newer version, notes open Given the same state with notes.items and notes.released_at 2026-09-24 When the owner activates "What's new in 2.3.0-beta" (a button with aria-expanded false then true) Then "Released 24 Sep 2026" and the plain-text list show, and activating it again collapses them And the notes are collapsed by default Scenario: Settings, command copied When the owner presses Copy Then the button reads "Copied" with a check for 2 s, is announced, then reads "Copy" again Scenario: Settings, admin view Given the session role is "admin" When GET /v1/system/update resolves available true with latest "v2.3.0-beta" Then the card shows "Version 2.3.0-beta is available" / "You have v2.2.0-beta. Only the owner can update RenewCMS. Ask the owner of this site." and no button When it resolves available false Then the card shows "You're up to date" / "Only the owner can update or restart the site." and no button Scenario: Settings, Role changed (403) Given an owner is on /settings and POST /v1/system/update/check answers 403 Then an error toast "Only the owner can do this" / "Ask the owner of this site to update RenewCMS." shows And the card reloads GET /v1/system/update and renders the admin view Scenario: Settings, Too many requests (429) Given POST /v1/system/update/check answers 429 with Retry-After 42 Then the block shows icon info "Too many tries" / "Wait a moment before trying again." And the button reads "Try again in 0:42", counts down each second and is re-enabled at zero Scenario: A check runs on the first open after 24 hours, for the owner only Given the owner opens /settings and checked_at is null or older than 24 hours Then POST /v1/system/update/check is sent once automatically and the Checking state shows When checked_at is under 24 hours old Then no automatic check is sent And an admin never sends one Scenario: Home banner for the owner, update available Given the owner is on /home and available is true with latest "v2.3.0-beta" Then a region labelled "Update available" shows above the content in info tone with icon circle-arrow-up, title "Version 2.3.0-beta is available", text "Update it from Settings. It takes about 2 minutes and the site stays online.", a "Go to Settings" button and a dismiss button When the owner dismisses it Then the banner is hidden and localStorage remembers the dismissed version And it shows again when latest changes to a newer version Scenario: Settings sidebar pill and mobile dot Given the owner and available is true Then the Settings sidebar item shows an "Update" pill, and on mobile the menu icon shows a dot read as "Update available" And the pill stays after the banner is dismissed Scenario: Admins and an up-to-date owner see no banner, pill or dot Then an admin, or an owner with available false, sees no banner, pill or dot Scenario: Pending states of slices 2 and 3 Then these states are listed as pending in the visual gate, never passing: Update available with its Update button (and its notes-open frame), Downloading, Verifying, Ready to install, Restart confirm, Restart request failed, Restarting, Restarting taking longer, Update done, Rolled back, Download failed, Verification failed, Plain restart confirm, restarting and done, Update already running (409), and the Home "ready to install" banner And the Update, Restart and progress elements are not rendered in any state of this slice