* docs(updater): add four-tier auto-update design spec
Four-tier opt-in self-update subsystem (off / notify / manual / auto / autonomous).
GitHub Releases as source of truth; install-method auto-detection with admin
override; in-process execution with supervisor restart; 60s drain + announce;
auto-rollback on health-check failure with crash-loop guard. Pad-side severe/
vulnerable badge that does not leak the running version. Top-level adminEmail
with escalating cadence (weekly while vulnerable, monthly while severe).
Refs: docs/superpowers/specs/2026-04-25-auto-update-design.md
* docs(updater): add PR 1 (Tier 1 notify) implementation plan
Bite-sized TDD task breakdown for shipping Tier 1 notify only:
- VersionChecker, InstallMethodDetector, UpdatePolicy, Notifier, state modules
- /admin/update/status (admin-auth) and /api/version-status (public, no version leak)
- Admin UI banner + read-only update page + nav link
- Pad-side severe/vulnerable footer badge
- Settings: updates.* block + top-level adminEmail
- Tests: vitest unit + mocha integration + Playwright admin/pad
- CHANGELOG + doc/admin/updates.md
PRs 2-4 (manual/auto/autonomous) get their own plans after PR 1 lands.
* feat(updater): add shared types for auto-update subsystem
* feat(updater): clarify OutdatedLevel and EMPTY_STATE doc, drop path header
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(updater): add semver helpers and vulnerable-below parser
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(updater): tighten semver regex to reject four-part versions
* feat(updater): add state persistence with schema validation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(updater): reject null email and array latest in state validation
typeof null === 'object' meant {email:null} passed the old isValid check,
which would crash downstream Notifier code reading email.severeAt. Likewise,
an array would pass the typeof latest === 'object' branch. Introduce
isPlainObject helper (null-safe, Array.isArray guard) and use it for both
fields. Adds two regression tests covering the exact broken inputs.
* feat(updater): add install-method detector with override
* feat(updater): add policy evaluator
* feat(updater): add GitHub Releases checker with ETag support
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(updater): validate release fields and preserve ETag on prerelease
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(updater): add email cadence decider
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(updater): tagChanged email fires regardless of cadence; drop unused field
* feat(settings): add updates.* and adminEmail settings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(updater): wire boot hook and periodic checker
Register expressCreateServer/shutdown hooks in ep.json and implement
the boot-wiring module that detects install method, starts the polling
interval and runs the notifier dedupe pass each tick.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(updater): add /admin/update/status and /api/version-status endpoints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* i18n(updater): add english strings for update banner, page, and pad badge
* feat(updater): add pad footer badge for severe/vulnerable status
* feat(admin-ui): add update banner, page, and nav link
Add UpdateStatusPayload to the zustand store, a persistent UpdateBanner
rendered in the App layout, a /update page showing version details and
changelog, and a Bell nav link — all wired to the /admin/update/status
endpoint added in Task 10.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(updater): add Playwright specs for admin banner/page and pad badge
* docs(updater): document tier 1 settings, badge, email cadence
* refactor(updater): dedupe helpers, fix misleading log, add banner styling
- Export stateFilePath from index.ts and import it in updateStatus.ts (removes local duplicate)
- Import getEpVersion from Settings.ts in both index.ts and updateStatus.ts (removes two local definitions)
- Fix misleading 'backing off' log message — no backoff is implemented, just retries at next interval
- Remove EMPTY_STATE_FOR_TESTS re-export from state.ts; state.test.ts now imports EMPTY_STATE directly from types.ts
- Add .update-banner and .update-page CSS rules to admin/src/index.css
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(updater): address review feedback — async wrap, tier=off skip, poll race, opt-in admin gate
- Wrap /api/version-status and /admin/update/status with a small async helper
so a rejected promise becomes next(err) instead of an unhandled rejection.
- Short-circuit route registration when updates.tier === 'off' so the heavier
opt-out also removes the HTTP surface (matches pre-PR behavior for that case).
- Add an in-flight guard around performCheck() so overlapping interval ticks
can't race on update-state.json writes or duplicate email decisions; track
the initial setTimeout handle and clear it in shutdown().
- Add updates.requireAdminForStatus (default false) so admins can lock
/admin/update/status to authenticated admin sessions without disabling the
updater. Default false preserves current behavior (the running version is
already exposed publicly via /health). Backend specs cover unauth → 401,
non-admin → 403, admin → 200.
- Bump admin troubleshooting menu count test 5 → 6 to account for the new
Update nav link.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(updater): address Qodo round-2 review feedback
Round 2 of Qodo review on #7601. Addressing the action-required items:
#1 Badge bypassed pad baseURL — derive basePath the same way
padBootstrap.js does (`new URL('..', window.location.href).pathname`)
and prefix the fetch with it. Subpath deployments now reach
/<prefix>/api/version-status instead of 404ing.
#2 Updater poller could get stuck — `getCurrentState()` is now inside
the try/finally so a one-time loadState() rejection can't leave
`checkInFlight=true` and permanently silence polling.
#3 Updates off hung admin page — UpdatePage now self-fetches and
renders explicit `disabled` (404), `unauthorized` (401/403), and
`error` states instead of staying on "Loading...". Banner-driven
prefetch is still honoured if it landed first.
#11 NaN polling interval — coerce `checkIntervalHours` to a number,
clamp to [1h, 168h], log a warning and fall back to 6h on
non-finite input. Math.max(1, NaN) === NaN previously meant a
malformed settings.json could turn the poller into a tight loop.
#13 State validation accepted broken subfields — `isValid()` now
inspects `latest.{version,tag,body,publishedAt,htmlUrl,prerelease}`,
`vulnerableBelow[].{announcedBy,threshold}`, and
`email.{severeAt,vulnerableAt,vulnerableNewReleaseTag}`. A
hand-edited file with a number where a string is expected is now
treated as corrupt and reset to EMPTY_STATE rather than crashing
later in semver parsing or email rendering.
#14 Badge cache stampede — wrap `computeOutdated()` in a single-flight
promise so concurrent requests at cache expiry await one shared
computation instead of fanning out into N redundant disk reads.
Plus six new state.test.ts cases covering each new validation guard.
Pushing back on the remaining items:
#4 `updates.tier` defaults to `notify` — intentional. The whole point
of tier 1 is to surface the "you are behind" signal to admins by
default. Opt-in defeats the purpose; the existing failure mode
(admin never hears about a security-relevant release) is exactly
what this PR is fixing.
#5/#8 Admin status endpoint admin-auth — `currentVersion` is already
public via `/health`, so wrapping the route in admin-auth doesn't
reduce the disclosure surface meaningfully. Operators who want it
gated set `updates.requireAdminForStatus=true` (already wired and
covered by the comment on the route handler).
#10 Plain `https://` URLs in planning doc — planning markdown is
viewed in editors and on GitHub where protocol-relative URLs would
either render literally or break entirely. Keeping `https://`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
4.8 KiB
Etherpad updates
Etherpad ships with a built-in update subsystem. Tier 1 (notify) is enabled by default: a banner appears in the admin UI when a new release is available, and pad users see a discreet badge if the running version is severely outdated or flagged as vulnerable. No automatic execution happens at this tier — admins are simply informed.
Tiers 2 (manual click), 3 (auto with grace window), and 4 (autonomous in maintenance window) are designed but not yet implemented. They will land in subsequent releases.
Settings
In settings.json:
{
"updates": {
"tier": "notify",
"source": "github",
"channel": "stable",
"installMethod": "auto",
"checkIntervalHours": 6,
"githubRepo": "ether/etherpad",
"requireAdminForStatus": false
},
"adminEmail": null
}
| Setting | Default | Notes |
|---|---|---|
updates.tier |
"notify" |
One of "off", "notify", "manual", "auto", "autonomous". Higher tiers are silently downgraded if the install method does not allow them. PR 1 only honors "notify" and "off". |
updates.source |
"github" |
Reserved for future alternative sources. Only "github" is implemented. |
updates.channel |
"stable" |
Reserved. Stable releases only. |
updates.installMethod |
"auto" |
One of "auto", "git", "docker", "npm", "managed". Auto-detects via filesystem heuristics. Set explicitly to override. |
updates.checkIntervalHours |
6 |
How often to poll GitHub Releases. |
updates.githubRepo |
"ether/etherpad" |
Override for forks. |
updates.requireAdminForStatus |
false |
Lock the /admin/update/status endpoint to authenticated admin sessions. Default false matches existing Etherpad behavior — /health already exposes releaseId publicly, and changelog data comes from a public GitHub release. Set true to hide the full update payload from non-admins without disabling the updater (tier: "off" is the heavier opt-out that removes the endpoints entirely). |
adminEmail |
null |
Top-level. Contact for admin notifications. Setting it enables the email nudges below. |
What "outdated" means
severe— running at least one major version behind the latest release.vulnerable— the running version is below avulnerable-belowthreshold announced in a recent release. Releases declare these via a<!-- updater: vulnerable-below X.Y.Z -->HTML comment in their body. The newest such directive wins.
Email cadence (when adminEmail is set)
| Trigger | First send | Repeat |
|---|---|---|
| Vulnerable status detected | Immediate | Weekly while still vulnerable |
| New release announced while still vulnerable | Immediate | n/a (one event per tag change) |
| Severely outdated detected | Immediate | Monthly while still severely outdated |
| Up to date | No email | — |
If adminEmail is unset, the updater never sends mail. The admin UI banner and the pad-side badge still work without it.
PR 1 ships the cadence machinery but does not yet wire a real SMTP transport — emails are logged with (would send email) until a future PR adds the transport. The dedupe state still advances correctly so admins are not bombarded once SMTP is wired.
Pad-side badge
Pad users see no version information by default. A small badge appears in the bottom-right corner only when:
- The instance is
severe(one or more major versions behind), or - The instance is
vulnerable(running below an announced threshold).
The public endpoint /api/version-status returns only {outdated: null|"severe"|"vulnerable"} — it never leaks the running version, so attackers do not gain a fingerprint vector.
Disabling everything
Set updates.tier to "off". No HTTP request will leave the instance and no banner or badge will render.
Privacy
The version check sends no telemetry. Etherpad fetches the public GitHub Releases API (api.github.com/repos/<repo>/releases/latest) with If-None-Match to be cache-friendly. The only metadata GitHub sees is the same as any other GitHub API client — your IP and a User-Agent: etherpad-self-update header. No instance ID, no version, no identifiers travel upstream.
How install method is detected
updates.installMethod defaults to "auto", which uses these heuristics in order:
/.dockerenvexists →"docker"..git/directory present and the install root is writable →"git".package-lock.jsonpresent and writable →"npm".- Otherwise →
"managed".
Set the value explicitly if the heuristics get it wrong (e.g., a docker container that bind-mounts a writable git checkout).
In PR 1 (notify only) the install method does not change behavior — every install method gets the banner. From PR 2 onward the install method gates whether the manual-click and automatic tiers can run; only "git" is initially supported for write tiers.