feat(updater): tier 1 — notify admin and pad users of available updates (#7601)

* 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>
This commit is contained in:
John McLear 2026-05-01 20:02:12 +08:00 committed by GitHub
parent 85f9a5f2f5
commit e39dbde887
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 4636 additions and 4 deletions

View File

@ -4,6 +4,16 @@
- **Minimum required Node.js version is now 22.** Node.js 20 is reaching end-of-life (see https://nodejs.org/en/about/previous-releases). The CI matrix now targets Node 22, 24, and 25. Upgrading should be straightforward — install a current Node.js release before updating Etherpad.
### Notable enhancements
- New built-in self-update subsystem (Tier 1: notify).
- Periodic check against the GitHub Releases API for the configured repo (default `ether/etherpad`). Configurable via the new `updates.*` settings block, default tier `"notify"`. Set `updates.tier` to `"off"` to disable entirely.
- The admin UI shows a banner and a dedicated "Etherpad updates" page with the current version, latest version, install method, and changelog.
- Pad users see a discreet footer badge **only** when the running version is severely outdated (one or more major versions behind) or flagged as vulnerable in a recent release manifest. The public endpoint that drives this never leaks the version string itself.
- New top-level `adminEmail` setting. When set, the updater emails the admin on first detection of severe / vulnerable status, with escalating cadence (weekly while vulnerable, monthly while severely outdated). PR 1 ships the dedupe + cadence logic; real SMTP wiring lands in a follow-up PR.
- Tier 1 ships in this release. Tiers 2 (manual click), 3 (auto with grace window) and 4 (autonomous in maintenance window) are designed and will land in subsequent releases.
- See `doc/admin/updates.md` for full configuration.
# 2.7.2
### Notable enhancements and fixes

View File

@ -6,7 +6,8 @@ import {NavLink, Outlet, useNavigate} from "react-router-dom";
import {useStore} from "./store/store.ts";
import {LoadingScreen} from "./utils/LoadingScreen.tsx";
import {Trans, useTranslation} from "react-i18next";
import {Cable, Construction, Crown, NotepadText, Wrench, PhoneCall, LucideMenu} from "lucide-react";
import {Cable, Construction, Crown, NotepadText, Wrench, PhoneCall, LucideMenu, Bell} from "lucide-react";
import {UpdateBanner} from "./components/UpdateBanner";
const WS_URL = import.meta.env.DEV ? 'http://localhost:9001' : ''
export const App = () => {
@ -105,6 +106,7 @@ export const App = () => {
<li><NavLink to={"/pads"}><NotepadText/><Trans
i18nKey="ep_admin_pads:ep_adminpads2_manage-pads"/></NavLink></li>
<li><NavLink to={"/shout"}><PhoneCall/>Communication</NavLink></li>
<li><NavLink to={"/update"}><Bell/><Trans i18nKey="update.page.title"/></NavLink></li>
</ul>
</div>
</div>
@ -112,6 +114,7 @@ export const App = () => {
setSidebarOpen(!sidebarOpen)
}}><LucideMenu/></button>
<div className="innerwrapper">
<UpdateBanner/>
<Outlet/>
</div>
</div>

View File

@ -0,0 +1,35 @@
import {useEffect} from 'react';
import {Link} from 'react-router-dom';
import {Trans, useTranslation} from 'react-i18next';
import {useStore} from '../store/store';
export const UpdateBanner = () => {
const {t} = useTranslation();
const updateStatus = useStore((s) => s.updateStatus);
const setUpdateStatus = useStore((s) => s.setUpdateStatus);
useEffect(() => {
let cancelled = false;
fetch('/admin/update/status', {credentials: 'same-origin'})
.then((r) => r.ok ? r.json() : null)
.then((data) => { if (data && !cancelled) setUpdateStatus(data); })
.catch(() => {});
return () => { cancelled = true; };
}, [setUpdateStatus]);
if (!updateStatus || !updateStatus.latest) return null;
if (updateStatus.currentVersion === updateStatus.latest.version) return null;
return (
<div className="update-banner" role="status">
<strong><Trans i18nKey="update.banner.title"/></strong>{' '}
<span>
<Trans
i18nKey="update.banner.body"
values={{latest: updateStatus.latest.version, current: updateStatus.currentVersion}}
/>
</span>{' '}
<Link to="/update">{t('update.banner.cta')}</Link>
</div>
);
};

View File

@ -895,3 +895,30 @@ input, button, select, optgroup, textarea {
.manage-pads-header {
display: flex;
}
/* Update banner — shown on every admin page when a new version is available. */
.update-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
margin: 0 0 12px 0;
background: #fff3cd;
color: #664d03;
border: 1px solid #ffe69c;
border-radius: 4px;
font-size: 14px;
}
.update-banner a {
color: inherit;
text-decoration: underline;
font-weight: 500;
}
/* Update page layout. */
.update-page { padding: 16px 0; }
.update-page h1 { margin-bottom: 16px; }
.update-page dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0 0 24px; }
.update-page dt { font-weight: 600; color: #555; }
.update-page dd { margin: 0; }
.update-page pre { background: #f6f8fa; border: 1px solid #d0d7de; border-radius: 4px; padding: 12px; font-size: 13px; max-height: 400px; overflow: auto; }

View File

@ -13,6 +13,7 @@ import i18n from "./localization/i18n.ts";
import {PadPage} from "./pages/PadPage.tsx";
import {ToastDialog} from "./utils/Toast.tsx";
import {ShoutPage} from "./pages/ShoutPage.tsx";
import {UpdatePage} from "./pages/UpdatePage.tsx";
const router = createBrowserRouter(createRoutesFromElements(
<><Route element={<App/>}>
@ -22,6 +23,7 @@ const router = createBrowserRouter(createRoutesFromElements(
<Route path="/help" element={<HelpPage/>}/>
<Route path="/pads" element={<PadPage/>}/>
<Route path="/shout" element={<ShoutPage/>}/>
<Route path="/update" element={<UpdatePage/>}/>
</Route><Route path="/login">
<Route index element={<LoginScreen/>}/>
</Route></>

View File

@ -0,0 +1,103 @@
import {useEffect, useState} from 'react';
import {Trans, useTranslation} from 'react-i18next';
import {useStore} from '../store/store';
type FetchState =
| {kind: 'loading'}
| {kind: 'disabled'}
| {kind: 'unauthorized'}
| {kind: 'error', status: number}
| {kind: 'ok'};
export const UpdatePage = () => {
const {t} = useTranslation();
const us = useStore((s) => s.updateStatus);
const setUpdateStatus = useStore((s) => s.setUpdateStatus);
// Self-fetch so the page renders an explicit state even if UpdateBanner's
// best-effort fetch never landed (route returns 404 when tier=off, 401/403
// if requireAdminForStatus is set, or a transient network error).
const [fetchState, setFetchState] = useState<FetchState>(us ? {kind: 'ok'} : {kind: 'loading'});
useEffect(() => {
let cancelled = false;
fetch('/admin/update/status', {credentials: 'same-origin'})
.then(async (r) => {
if (cancelled) return;
if (r.ok) {
const data = await r.json();
setUpdateStatus(data);
setFetchState({kind: 'ok'});
} else if (r.status === 404) {
setFetchState({kind: 'disabled'});
} else if (r.status === 401 || r.status === 403) {
setFetchState({kind: 'unauthorized'});
} else {
setFetchState({kind: 'error', status: r.status});
}
})
.catch(() => {
if (!cancelled) setFetchState({kind: 'error', status: 0});
});
return () => { cancelled = true; };
}, [setUpdateStatus]);
if (fetchState.kind === 'loading') {
return <div>{t('admin.loading', {defaultValue: 'Loading...'})}</div>;
}
if (fetchState.kind === 'disabled') {
return (
<div className="update-page">
<h1><Trans i18nKey="update.page.title"/></h1>
<p>{t('update.page.disabled', {defaultValue: 'Update checks are disabled (updates.tier = "off").'})}</p>
</div>
);
}
if (fetchState.kind === 'unauthorized') {
return (
<div className="update-page">
<h1><Trans i18nKey="update.page.title"/></h1>
<p>{t('update.page.unauthorized', {defaultValue: 'You are not authorised to view update status.'})}</p>
</div>
);
}
if (fetchState.kind === 'error' || !us) {
const status = fetchState.kind === 'error' ? fetchState.status : 0;
return (
<div className="update-page">
<h1><Trans i18nKey="update.page.title"/></h1>
<p>{t('update.page.error', {defaultValue: 'Could not load update status (status {{status}}).', status})}</p>
</div>
);
}
const upToDate = !us.latest || us.currentVersion === us.latest.version;
return (
<div className="update-page">
<h1><Trans i18nKey="update.page.title"/></h1>
<dl>
<dt><Trans i18nKey="update.page.current"/></dt>
<dd>{us.currentVersion}</dd>
<dt><Trans i18nKey="update.page.latest"/></dt>
<dd>{us.latest ? us.latest.version : '—'}</dd>
<dt><Trans i18nKey="update.page.last_check"/></dt>
<dd>{us.lastCheckAt ?? '—'}</dd>
<dt><Trans i18nKey="update.page.install_method"/></dt>
<dd>{us.installMethod}</dd>
<dt><Trans i18nKey="update.page.tier"/></dt>
<dd>{us.tier}</dd>
</dl>
{upToDate ? (
<p><Trans i18nKey="update.page.up_to_date"/></p>
) : us.latest ? (
<>
<h2><Trans i18nKey="update.page.changelog"/></h2>
<pre style={{whiteSpace: 'pre-wrap'}}>{us.latest.body}</pre>
<p><a href={us.latest.htmlUrl} rel="noreferrer noopener" target="_blank">{us.latest.htmlUrl}</a></p>
</>
) : null}
</div>
);
};
export default UpdatePage;

View File

@ -3,6 +3,23 @@ import {Socket} from "socket.io-client";
import {PadSearchResult} from "../utils/PadSearch.ts";
import {InstalledPlugin} from "../pages/Plugin.ts";
export interface UpdateStatusPayload {
currentVersion: string;
latest: null | {
version: string;
tag: string;
body: string;
publishedAt: string;
prerelease: boolean;
htmlUrl: string;
};
lastCheckAt: string | null;
installMethod: string;
tier: string;
policy: null | {canNotify: boolean; canManual: boolean; canAuto: boolean; canAutonomous: boolean; reason: string};
vulnerableBelow: Array<{announcedBy: string; threshold: string}>;
}
type ToastState = {
description?:string,
title: string,
@ -25,7 +42,9 @@ type StoreState = {
pads: PadSearchResult|undefined,
setPads: (pads: PadSearchResult)=>void,
installedPlugins: InstalledPlugin[],
setInstalledPlugins: (plugins: InstalledPlugin[])=>void
setInstalledPlugins: (plugins: InstalledPlugin[])=>void,
updateStatus: UpdateStatusPayload | null,
setUpdateStatus: (s: UpdateStatusPayload) => void,
}
@ -48,5 +67,7 @@ export const useStore = create<StoreState>()((set) => ({
pads: undefined,
setPads: (pads)=>set({pads}),
installedPlugins: [],
setInstalledPlugins: (plugins)=>set({installedPlugins: plugins})
setInstalledPlugins: (plugins)=>set({installedPlugins: plugins}),
updateStatus: null,
setUpdateStatus: (s) => set({updateStatus: s}),
}));

83
doc/admin/updates.md Normal file
View File

@ -0,0 +1,83 @@
# 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`:
```jsonc
{
"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 a `vulnerable-below` threshold 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:
1. `/.dockerenv` exists → `"docker"`.
2. `.git/` directory present and the install root is writable → `"git"`.
3. `package-lock.json` present and writable → `"npm"`.
4. 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.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,350 @@
# Etherpad Auto-Update — Design Spec
**Date:** 2026-04-25
**Author:** John McLear (johnmclear)
**Status:** Approved for planning
**Related:** none yet
## Problem
Etherpad has no built-in mechanism to tell an admin a new version exists, no in-product update flow, and no automatic patching. The result: many public Etherpad instances run unpatched versions for months or years, and CVEs land on installs whose admins are not even aware an update shipped.
## Goal
Add a four-tier self-update subsystem to Etherpad core. Each tier is opt-in via a single `updates.tier` setting. Higher tiers subsume lower ones.
| Tier | Setting | Behavior |
|---|---|---|
| 0 | `off` | No version checks, no banner, no badge. |
| 1 | `notify` | Default. Periodic version check, admin banner, severe/vulnerable pad badge. No execution. |
| 2 | `manual` | Tier 1 + admin can click "Apply now" to update from the UI. |
| 3 | `auto` | Tier 2 + new releases are scheduled automatically after a configurable grace window during which the admin can cancel. |
| 4 | `autonomous` | Tier 3 + scheduling is gated to an admin-defined maintenance window. |
Tiers above what the install method allows are silently downgraded with a logged warning. A docker install will refuse to enable tier 2+ even if the admin sets `tier: "autonomous"`.
## Non-goals
- Updating plugins. The admin already has a plugin manager. The design preserves a `target: 'core' | 'plugins'` seam, but plugin updates are out of scope for this spec.
- Updating Etherpad in environments where the filesystem is ephemeral or read-only (Docker, snap, apt/brew). Those installs stay on tier 1.
- Telemetry of any kind. The GitHub poll uses no auth, no instance identifiers, no version reporting upstream.
- DB schema or `settings.json` schema migration logic. Etherpad's existing on-boot migration runs after restart. If a migration fails, the new version fails its post-update health check and we roll back.
## Decisions
These were settled during brainstorming and are load-bearing for the rest of the spec.
- **Update source:** GitHub Releases API (`api.github.com/repos/ether/etherpad/releases/latest`). Configurable via `updates.githubRepo` for forks.
- **Install-method detection:** auto-detect at boot with admin override. Heuristics: `/.dockerenv` → docker; `.git/HEAD` + writable tree → git; writable `node_modules` + lockfile → npm; else `managed`. Override via `updates.installMethod`.
- **Execution model:** in-process. Etherpad spawns the update steps (git fetch, git checkout, pnpm install, build:ui) as child processes, then exits with code `75`. Etherpad must be run under a process supervisor (systemd, pm2, docker restart-policy, etc.) — that is best practice anyway.
- **Tier 4 scope:** all releases (not just security/patch). Restricted only by maintenance window.
- **Rollback:** on every update we snapshot the git SHA and copy `pnpm-lock.yaml` to `var/update-backup/`. After restart, a 60s health-check timer fires; on failure we restore SHA + lockfile, run `pnpm install`, and exit again. A boot-count guard catches crash loops.
- **Active sessions:** 60-second drain. We broadcast a system message at T-60, T-30, T-10 to every connected pad, refuse new connections during the drain, then exit at T=0. Best-effort: we do not wait for client acks past T=0.
- **Pad-user visibility:** pads see nothing about updates by default. A discreet badge appears only when the running version is `severe` (one or more major versions behind) or `vulnerable` (matched by a `vulnerable-below` directive in a recent release manifest). The badge endpoint never returns the running version string.
## Architecture
A new self-update subsystem lives at `src/node/updater/`. Each unit has one purpose, communicates through narrow interfaces, and is independently testable.
### Components
- **`VersionChecker`** — periodic poller. Hits `api.github.com/repos/ether/etherpad/releases/latest` with `If-None-Match` ETag. Default interval 6h. Caches latest release in memory and on disk at `var/update-state.json`. Parses `vulnerable-below <semver>` directives from the bodies of the most recent N releases to build a runtime `KNOWN_VULNERABLE` set. On 403/rate-limit responses, backs off exponentially. Exposes `getUpdateStatus()`.
- **`InstallMethodDetector`** — runs once at boot. Caches result for the process lifetime.
- **`UpdatePolicy`** — pure function over `(installMethod, tier, currentVersion, latestVersion, settings, now)``{canNotify, canManual, canAuto, canAutonomous, reason}`. Single source of truth for "what is allowed." No I/O. Easy to unit-test.
- **`UpdateExecutor`** — performs the update for `git` installs. Records pre-state, runs the update steps as child processes, streams to `var/log/update.log`, exits 75. Held by `var/update.lock` (PID-based, stale locks reaped on boot).
- **`RollbackHandler`** — runs on every boot. Reads `var/update-state.json`. If status is `pending-verification`, arms the health-check timer and increments `bootCount`. If `bootCount > 2`, forces rollback (crash-loop guard). On rollback failure, transitions to terminal `rollback-failed` state which disables auto/autonomous until an admin acknowledges.
- **`SessionDrainer`** — coordinates the 60s drain. Hooks `PadMessageHandler` to broadcast at T-60/-30/-10, sets a "no new connections" flag in the express middleware, signals the executor at T=0.
- **`Scheduler`** (PR 3+) — listens to `VersionChecker` events, evaluates `UpdatePolicy.canAuto/canAutonomous`, applies pre-apply grace and (tier 4) maintenance-window checks. Persists pending update info so a restart during the grace window doesn't drop the schedule.
- **`MaintenanceWindow`** (PR 4) — pure function over `(now, window)`. Handles cross-midnight, DST.
### API surface
Three admin endpoints (auth + CSRF identical to existing `/admin/*`):
- `GET /admin/update/status` — current version, latest, policy result, last update result, in-flight state.
- `POST /admin/update/apply` — manual trigger. Refuses if `UpdatePolicy.canManual` is false or if the lock is held. Permitted in `rollback-failed` (an admin clicking "Apply" *is* the intervention that state requires); the call implicitly acknowledges the prior failure.
- `POST /admin/update/cancel` — works any time before `UpdateExecutor` starts the `git checkout`. Once filesystem changes have begun, returns 409 (we either complete or rollback).
- `POST /admin/update/acknowledge` — clears terminal states (`rollback-failed`, `preflight-failed`, etc.) so future attempts are allowed.
- `GET /admin/update/log` — streams the last 200 lines of `var/log/update.log` for the in-progress UI.
One public endpoint:
- `GET /api/version-status` — returns `{outdated: null | "severe" | "vulnerable"}`. No version string. Memory-cached, max one underlying state read per minute. Public so pad clients can fetch without auth.
### Admin UI
- `admin/src/components/UpdateBanner.tsx` — visible on every admin page when an update exists or last update terminated abnormally.
- `admin/src/pages/UpdatePage.tsx` — full status, changelog (rendered from release body), Apply/Cancel/Acknowledge buttons, log stream view, maintenance-window picker (PR 4).
- New i18n keys under `updater.*`.
### Pad UI
- A small footer badge component fetches `/api/version-status` once on pad load. Renders nothing on `null`, a discreet icon on `severe`, a more prominent indicator on `vulnerable`.
## Settings
```jsonc
{
"updates": {
"tier": "notify", // "off" | "notify" | "manual" | "auto" | "autonomous"
"source": "github", // future: "manifest"
"channel": "stable", // future: "beta" | "lts"
"installMethod": "auto", // "auto" | "git" | "docker" | "npm" | "managed"
"checkIntervalHours": 6,
"maintenanceWindow": null, // {"start":"03:00","end":"05:00","tz":"local"}
"preApplyGraceMinutes": 15,
"drainSeconds": 60,
"rollbackHealthCheckSeconds": 60,
"diskSpaceMinMB": 500,
"githubRepo": "ether/etherpad",
"trustedKeysPath": null // override default trusted-key set for forks
},
"adminEmail": null // top-level. Contact address for admin notifications
// (updates, future security advisories, etc.). Used by
// the updater; reusable by other features later.
}
```
Shipped defaults:
- `settings.json.template`: `tier: "notify"`. Fresh installs get the banner with no manual config.
- `settings.json.docker`: `tier: "notify"`, `installMethod: "docker"` (explicit, even though detector would catch it — clearer in policy logs).
The whole `updates` block is optional. Existing installs upgrading to the version that ships PR 1 will start showing the banner with no config change. This is called out in `CHANGELOG.md` and the release notes; admins who want the old behavior set `tier: "off"`.
### Email notifications (`adminEmail`)
`adminEmail` is a top-level setting (not under `updates`) so other features — security advisories, plugin alerts, future operational notifications — can reuse it. The updater is the first consumer.
If `adminEmail` is unset, the updater never sends mail; banners and logs still work. If set, the existing SMTP path Etherpad already uses for invite/notification plugins delivers the message.
Triggers and cadence (deduped state lives in `var/update-state.json` under `email.lastSentFor`):
| Trigger | First send | Repeat |
|---|---|---|
| New release detected while running a `vulnerable` version | immediate | weekly while still `vulnerable` |
| Instance enters `severe` (>= 1 major behind) | immediate | monthly while still `severe` |
| Tier 3 grace window starts | every grace start | n/a (one event per scheduled update) |
| `rollback-failed` terminal state entered | immediate | n/a (one event per entry) |
Successful updates do not generate email — that is noise. The admin UI banner is sufficient for non-urgent state.
Cadence is per-status, not per-tick: if a `severe` instance also becomes `vulnerable`, the vulnerable cadence applies until vulnerability clears, then the severe cadence resumes.
## Data flow
### Boot sequence (every tier)
1. `InstallMethodDetector.detect()` — caches result.
2. `RollbackHandler.checkPendingVerification()` — if previous boot was an update, arm the 60s health-check timer; on success mark `verified`; on timeout/failure trigger rollback and exit 75. Increment `bootCount`; if it exceeds 2, force rollback regardless of timer.
3. `VersionChecker.start()` — immediate first check, then interval.
### Tier 1 — notify
`VersionChecker` updates `var/update-state.json`. `GET /admin/update/status` reads it. `GET /api/version-status` reads it. No execution path.
### Tier 2 — manual click
```
admin click
→ POST /admin/update/apply (admin auth + CSRF)
→ UpdatePolicy.canManual() — abort if false
→ SessionDrainer.start() (broadcast at T-60/-30/-10, refuse new connections)
→ UpdateExecutor.run()
├─ snapshot SHA + pnpm-lock.yaml to var/update-backup/
├─ verify release tag signature
├─ git fetch, git checkout <tag>
├─ pnpm install --frozen-lockfile
├─ pnpm run build:ui
├─ write update-state.json: status=pending-verification, from=<sha>, to=<tag>, bootCount=0
└─ exit 75
→ supervisor restarts → boot sequence runs RollbackHandler
```
### Tier 3 — auto (admin-opted-in)
Same pipeline as tier 2, but the trigger is `VersionChecker` detecting a new release while `UpdatePolicy.canAuto()` returns true. Before the drain starts, a `preApplyGraceMinutes` window opens during which the admin can cancel via `/admin/update/cancel`. Pending-update info is persisted so a restart during the grace window doesn't lose the schedule. Optional email notification at grace start.
### Tier 4 — autonomous
Same as tier 3, but `Scheduler` only schedules when `now()` is inside `maintenanceWindow`. If the window closes while an update is mid-grace, the update is deferred to the next window (drain does not start outside the window).
## Error handling
### Pre-flight checks
Run before `UpdateExecutor` modifies anything. Any failure aborts cleanly.
- `installMethod` allows execution.
- Git working tree clean — admin patches are not silently clobbered.
- Git remote `origin` reachable and target tag exists.
- Target tag's signature verifies against trusted-key set.
- Free disk space ≥ `diskSpaceMinMB`.
- `pnpm` resolvable on `PATH`.
- `var/update.lock` not held (or stale).
- Tier 4 only: currently inside maintenance window.
On failure: write `update-state.json` with `status: "preflight-failed"`, log to `update.log`, surface in admin UI banner. No rollback needed because nothing changed.
### Failure modes during execution
| Stage | Failure | Behavior |
|---|---|---|
| `git fetch` | network | abort, no state change, status = `preflight-failed` |
| `git checkout` | conflict / dirty tree | abort, status = `preflight-failed` |
| `pnpm install` | resolver/network/disk | rollback: restore SHA + lockfile, retry `pnpm install`. Status = `rolled-back-install-failed` |
| `pnpm run build:ui` | build error | same rollback. Status = `rolled-back-build-failed` |
| `exit 75` | — | success path. Status = `pending-verification` |
| Boot crash loop | new version crashes repeatedly | RollbackHandler sees `bootCount > 2`, forces rollback. Status = `rolled-back-crash-loop` |
| Health check fails in 60s | new version starts but `/health` doesn't 200 | RollbackHandler timer fires, restores prior state. Status = `rolled-back-health-check` |
| Rollback itself fails | restore-time `pnpm install` errors | terminal state. Status = `rollback-failed`. Big red banner, refuse further auto/autonomous attempts until admin acknowledges. Email if SMTP configured. |
### State machine
```
idle
│ (admin click / autonomous trigger)
preflight ──fail──► preflight-failed ──ack──► idle
draining ──cancel──► idle
executing ──install/build fail──► rolling-back ──► rolled-back-* ──ack──► idle
│ │
▼ └─fail──► rollback-failed (terminal until ack)
pending-verification ──health-check fail──┘
▼ verified by health check
verified ──► idle
```
`rollback-failed` is the only state that disables auto/autonomous attempts globally until an admin POSTs `/admin/update/acknowledge`. Manual updates remain allowed because an admin can intervene directly.
### Logging
- All updater activity → `var/log/update.log` (rotated, 10MB × 5).
- `GET /admin/update/log` streams the last 200 lines for the in-progress UI.
- Important state transitions also written to log4js category `updater` at INFO so they appear in normal Etherpad logs.
## Security
- Admin endpoints share existing auth path (`webaccess.ts`, basic auth + admin role). State-changing endpoints require CSRF tokens.
- Tag signature verification before checkout. Trusted-key set ships in `src/node/updater/trusted-keys.ts`. Forks override via `updates.trustedKeysPath`. Failure → `preflight-failed: signature`.
- Update execution runs as Etherpad's OS user. No privilege escalation. Pre-flight permissions probe catches setups where `pnpm install` would need root.
- `GET /api/version-status` deliberately does not return the running version. Returning `severe` or `vulnerable` to attackers without confirming exact version makes fingerprinting strictly harder than it is today, where a `/static/js/...` path or response header may already leak it.
- Concurrent-update prevention via PID-based `var/update.lock`.
- No telemetry. The only outbound traffic is to `api.github.com` (or the configured `updates.githubRepo` host). No instance ID, no version, no identifiers — just the IP-level metadata GitHub already sees.
- Public `/api/version-status` rate-limited by an in-memory cache refreshed at most once per minute.
## Testing
### Unit (`src/tests/backend/specs/updater/`, vitest, no I/O)
- `UpdatePolicy.test.ts` — full `(installMethod × tier × current/latest)` matrix.
- `VersionChecker.test.ts` — mocked `fetch`. ETag, backoff, parsing of `vulnerable-below` directives, `prerelease` filtering.
- `InstallMethodDetector.test.ts` — fake fs.
- `RollbackHandler.test.ts` — fake state file + clock + spawn. State-machine transitions, crash-loop guard, terminal `rollback-failed`.
- `MaintenanceWindow.test.ts` — cross-midnight, DST.
### Integration (`src/tests/backend/specs/updater-integration.test.ts`)
- Tmp git repo as the "Etherpad install."
- Local HTTP server impersonating GitHub Releases.
- Cases: happy path; install-fail rollback; build-fail rollback; health-check timeout rollback; crash-loop rollback (force `bootCount` to 3); `rollback-failed` terminal blocks auto/autonomous but allows manual.
### API tests
- `GET /admin/update/status` — auth required, schema, expected fields.
- `POST /admin/update/apply` — admin-only, CSRF, refuses on lock/policy denial.
- `POST /admin/update/cancel` — works during pre-execute, 409 during execute.
- `POST /admin/update/acknowledge` — clears terminal state.
- `GET /api/version-status` — public, never leaks version string.
### Playwright (`src/tests/frontend-new/specs/`, headless)
- Update banner appears on `/admin` when an update exists.
- UpdatePage shows version, changelog, Apply button.
- Click triggers `POST /admin/update/apply`, log stream visible.
- Banner copy correct for each terminal state.
- Maintenance-window picker validates inputs.
- Pad footer badge: invisible on `null`, discreet on `severe`, prominent on `vulnerable`.
- Drain announcement appears in pad chat at T-60, T-30, T-10.
### Out of CI (manual smoke)
- Real-network GitHub calls.
- Real process restart with a real supervisor.
- Real `pnpm install` of a different version.
These are covered by:
- A manual smoke runbook in `docs/superpowers/specs/2026-04-25-auto-update-runbook.md` (created during PR 2 implementation), run before each tier ships, against a disposable VM.
- A canary instance running `tier: "auto"` against a beta channel for ≥ 2 weeks before tier 4 ships.
### Test coverage gates per PR
- **PR 1:** VersionChecker, InstallMethodDetector, UpdatePolicy, Notifier unit + status endpoint API + banner Playwright + pad badge Playwright.
- **PR 2:** + UpdateExecutor + RollbackHandler unit + integration (all rollback paths) + apply/cancel/acknowledge API + UpdatePage Playwright. Runbook smoke completed by a human on a disposable VM.
- **PR 3:** + Scheduler unit + grace-window integration + cancel-during-grace test.
- **PR 4:** + MaintenanceWindow unit + window-boundary integration. Canary on beta channel for 2 weeks before merge.
## Phased rollout
Each PR is independently shippable, independently revertable, and gated by `updates.tier`.
### PR 1 — Tier 1: Notify
- `src/node/updater/{VersionChecker,InstallMethodDetector,UpdatePolicy,state}.ts`
- `src/node/updater/Notifier.ts` — single entry point for updater emails. Reads top-level `adminEmail`. Implements the cadence table (immediate-then-weekly for vulnerable, immediate-then-monthly for severe). Persists `email.lastSentFor` in `var/update-state.json` to dedupe. No-op if `adminEmail` unset.
- `src/node/hooks/express/updateStatus.ts` registering `GET /admin/update/status` and `GET /api/version-status`.
- Settings additions in `settings.json.template` and `settings.json.docker`, including new top-level `adminEmail`.
- Admin UI: `UpdatePage.tsx` (read-only), `UpdateBanner.tsx`, route entry, i18n.
- Pad UI: footer badge.
- Tests per PR 1 row above, plus `Notifier.test.ts` (cadence math, dedupe, no-op when `adminEmail` unset).
- `CHANGELOG.md` entry.
**Ship gate:** unit + API + Playwright pass; manual smoke confirms banner appears when version is patched downward.
### PR 2 — Tier 2: Manual click
- `src/node/updater/{UpdateExecutor,RollbackHandler,SessionDrainer,lock,trusted-keys}.ts`
- Endpoints: `POST /admin/update/apply`, `POST /admin/update/cancel`, `POST /admin/update/acknowledge`, `GET /admin/update/log`.
- `UpdatePolicy` flips `canManual` on for `git` install method.
- Admin UI: Apply button, log stream, terminal-state banners, Cancel during pre-execute, Acknowledge on terminal.
- Drain announcement i18n.
- Tests per PR 2 row.
- Manual smoke runbook updated and run end-to-end on a disposable VM, including a deliberately broken-lockfile rollback.
**Ship gate:** integration tests pass for all rollback paths; runbook smoke completed by a human.
### PR 3 — Tier 3: Auto
- `src/node/updater/Scheduler.ts` — listens to `VersionChecker` events, applies grace window, persists pending-update info.
- `UpdatePolicy.canAuto` flips on for `git` + `tier: "auto"`.
- Email notification at grace start (existing SMTP, only if `adminEmail` is set).
- Admin UI: countdown + cancel during grace.
- Tests per PR 3 row.
**Ship gate:** scheduler tests pass; canary running `tier: "auto"` against a beta channel for 2 weeks.
### PR 4 — Tier 4: Autonomous
- `src/node/updater/MaintenanceWindow.ts`.
- `Scheduler` learns to gate on the window. Updates outside the window queue for the next opening.
- `UpdatePolicy.canAutonomous` flips on for `git` + `tier: "autonomous"` + valid window.
- Admin UI: window picker, validation, "next window opens at..." preview.
- Tests per PR 4 row.
**Ship gate:** window unit tests pass; canary switched to `tier: "autonomous"` on the beta channel for 2 weeks.
### Cross-cutting
- **Plugin seam:** `UpdatePolicy` and `VersionChecker` take a `target: 'core' | 'plugins'` parameter from PR 1. Plugin support is not implemented in this spec but the API does not paint us into a corner.
- **Telemetry:** none. Stated explicitly here so it is not silently added later.
- **Docs:** PR 1 introduces `doc/admin/updates.md`; subsequent PRs extend it.
## Open questions
None at spec time. Concrete questions that may surface during implementation are expected to land in PR review, not here.

View File

@ -192,6 +192,28 @@
*/
"enableMetrics": "${ENABLE_METRICS:true}",
/*
* Self-update subsystem.
* tier: "off" | "notify" | "manual" | "auto" | "autonomous"
* Default "notify" shows a banner when an update is available.
* Docker installs are read-only — tiers above "notify" are not applied even if requested.
*/
"updates": {
"tier": "notify",
"source": "github",
"channel": "stable",
"installMethod": "docker",
"checkIntervalHours": 6,
"githubRepo": "ether/etherpad",
"requireAdminForStatus": false
},
/*
* Contact address for admin notifications (updates, security advisories, future features).
* Set to null to disable outbound mail from the updater.
*/
"adminEmail": null,
/*
* Settings for cleanup of pads
*/

View File

@ -190,6 +190,32 @@
*/
"enableMetrics": "${ENABLE_METRICS:true}",
/*
* Self-update subsystem.
* tier: "off" | "notify" | "manual" | "auto" | "autonomous"
* Default "notify" shows a banner when an update is available. "off" disables the version check.
*/
"updates": {
"tier": "notify",
"source": "github",
"channel": "stable",
"installMethod": "auto",
"checkIntervalHours": 6,
"githubRepo": "ether/etherpad",
/*
* Lock /admin/update/status to authenticated admins. Default false keeps the
* endpoint open (the version is already public via /health). Set true to hide
* full update detail from non-admins without turning the updater off.
*/
"requireAdminForStatus": false
},
/*
* Contact address for admin notifications (updates, security advisories, future features).
* Set to null to disable outbound mail from the updater.
*/
"adminEmail": null,
/*
* Settings for cleanup of pads
*/

View File

@ -102,6 +102,20 @@
"socketio": "ep_etherpad-lite/node/handler/PadMessageHandler"
}
},
{
"name": "updater",
"hooks": {
"expressCreateServer": "ep_etherpad-lite/node/updater/index",
"shutdown": "ep_etherpad-lite/node/updater/index"
}
},
{
"name": "updateStatus",
"post": ["ep_etherpad-lite/admin"],
"hooks": {
"expressCreateServer": "ep_etherpad-lite/node/hooks/express/updateStatus"
}
},
{
"name": "admin",
"hooks": {

View File

@ -33,6 +33,20 @@
"admin_settings.current_save.value": "Save Settings",
"admin_settings.page-title": "Settings - Etherpad",
"update.banner.title": "Update available",
"update.banner.body": "Etherpad {{latest}} is available (you are running {{current}}).",
"update.banner.cta": "View update",
"update.page.title": "Etherpad updates",
"update.page.current": "Current version",
"update.page.latest": "Latest version",
"update.page.last_check": "Last checked",
"update.page.install_method": "Install method",
"update.page.tier": "Update tier",
"update.page.changelog": "Changelog",
"update.page.up_to_date": "You are running the latest version.",
"update.badge.severe": "Etherpad on this server is severely outdated. Tell your admin.",
"update.badge.vulnerable": "Etherpad on this server is running a version with known security issues. Tell your admin.",
"index.newPad": "New Pad",
"index.settings": "Settings",
"index.transferSessionTitle": "Transfer session",

View File

@ -0,0 +1,94 @@
'use strict';
import {ArgsExpressType} from '../../types/ArgsExpressType';
import settings, {getEpVersion} from '../../utils/Settings';
import {getDetectedInstallMethod, stateFilePath} from '../../updater';
import {evaluatePolicy} from '../../updater/UpdatePolicy';
import {compareSemver, isMajorBehind, isVulnerable} from '../../updater/versionCompare';
import {loadState} from '../../updater/state';
let badgeCache: {value: 'severe' | 'vulnerable' | null; at: number} = {value: null, at: 0};
// Coalesce concurrent computeOutdated() calls during a cache-miss so a burst of
// requests at expiry doesn't fan out into N redundant disk reads.
let badgeInFlight: Promise<'severe' | 'vulnerable' | null> | null = null;
const BADGE_CACHE_MS = 60 * 1000;
const computeOutdated = async (): Promise<'severe' | 'vulnerable' | null> => {
const state = await loadState(stateFilePath());
if (!state.latest) return null;
const current = getEpVersion();
if (compareSemver(current, state.latest.version) >= 0) return null;
if (isVulnerable(current, state.vulnerableBelow)) return 'vulnerable';
if (isMajorBehind(current, state.latest.version)) return 'severe';
return null;
};
/** Test-only: clear the in-memory badge cache so integration tests see fresh state. */
export const _resetBadgeCacheForTests = (): void => {
badgeCache = {value: null, at: 0};
badgeInFlight = null;
};
// Wrap an async Express handler so a rejected promise becomes next(err) rather than
// an unhandled rejection. Mirrors the .catch(next) pattern used elsewhere in the repo.
const wrapAsync = (fn: (req: any, res: any, next: Function) => Promise<unknown>) =>
(req: any, res: any, next: Function) => {
Promise.resolve(fn(req, res, next)).catch((err) => next(err));
};
export const expressCreateServer = (
_hookName: string,
{app}: ArgsExpressType,
cb: Function,
): void => {
// Tier "off" disables the entire updater feature, including its HTTP surface.
if (settings.updates.tier === 'off') return cb();
// Public endpoint. Cached for 60s. Returns only an enum — no version string.
app.get('/api/version-status', wrapAsync(async (_req, res) => {
const now = Date.now();
if (now - badgeCache.at > BADGE_CACHE_MS) {
// Single-flight: if another request is already computing, await its
// promise instead of starting a second one. The first to land seeds
// the cache; the rest read it.
if (!badgeInFlight) {
badgeInFlight = computeOutdated().finally(() => { badgeInFlight = null; });
}
const value = await badgeInFlight;
// Only the request that observed the original miss writes the cache;
// followers may race on the assignment but write the same value.
badgeCache = {value, at: now};
}
res.json({outdated: badgeCache.value});
}));
// Admin UI status endpoint. By default this is open: the running version is already
// exposed publicly via /health, and latest/changelog come from a public GitHub
// release. Admins who want the endpoint gated to authenticated admin sessions —
// without disabling the updater entirely — set updates.requireAdminForStatus=true.
app.get('/admin/update/status', wrapAsync(async (req, res) => {
if (settings.updates.requireAdminForStatus) {
const user = req.session?.user;
if (!user) return res.status(401).send('Authentication required');
if (!user.is_admin) return res.status(403).send('Forbidden');
}
const state = await loadState(stateFilePath());
const current = getEpVersion();
const installMethod = getDetectedInstallMethod();
const policy = state.latest
? evaluatePolicy({installMethod, tier: settings.updates.tier, current, latest: state.latest.version})
: null;
res.json({
currentVersion: current,
latest: state.latest,
lastCheckAt: state.lastCheckAt,
installMethod,
tier: settings.updates.tier,
policy,
vulnerableBelow: state.vulnerableBelow,
});
}));
cb();
};

View File

@ -0,0 +1,46 @@
import fs from 'node:fs/promises';
import {constants as fsConstants} from 'node:fs';
import path from 'node:path';
import {InstallMethod} from './types';
export interface DetectOptions {
/** Setting from settings.json. "auto" means detect; anything else is forced. */
override: InstallMethod;
/** Root directory of the Etherpad install. */
rootDir: string;
/** Path to /.dockerenv (overridable for tests). */
dockerEnvPath?: string;
}
const exists = async (p: string): Promise<boolean> => {
try { await fs.access(p, fsConstants.F_OK); return true; } catch { return false; }
};
const writable = async (p: string): Promise<boolean> => {
try { await fs.access(p, fsConstants.W_OK); return true; } catch { return false; }
};
/**
* Detect how Etherpad was installed. Returns 'docker' | 'git' | 'npm' | 'managed'.
* - If `opts.override` is anything other than 'auto', that value is returned unchanged.
* - 'docker' is checked first via `/.dockerenv` (overridable for tests).
* - 'git' requires both a `.git` dir AND a writable rootDir (so we don't try to update read-only checkouts).
* - 'npm' requires a writable `package-lock.json`.
* - 'managed' is the catch-all for installs we can't safely modify.
*/
export const detectInstallMethod = async (
opts: DetectOptions,
): Promise<Exclude<InstallMethod, 'auto'>> => {
if (opts.override !== 'auto') return opts.override;
const dockerEnv = opts.dockerEnvPath ?? '/.dockerenv';
if (await exists(dockerEnv)) return 'docker';
const gitDir = path.join(opts.rootDir, '.git');
if (await exists(gitDir) && await writable(opts.rootDir)) return 'git';
const lockfile = path.join(opts.rootDir, 'package-lock.json');
if (await exists(lockfile) && await writable(lockfile)) return 'npm';
return 'managed';
};

View File

@ -0,0 +1,88 @@
import {EmailSendLog} from './types';
// TODO(future): surface the threshold version in email bodies so admins know which version
// clears the vulnerability. Requires extending NotifierInput with the relevant directive(s).
export interface NotifierInput {
adminEmail: string | null;
current: string;
latest: string;
latestTag: string;
isVulnerable: boolean;
isSevere: boolean;
state: EmailSendLog;
now: Date;
}
export type EmailKind = 'severe' | 'vulnerable' | 'vulnerable-new-release';
export interface PlannedEmail {
kind: EmailKind;
subject: string;
body: string;
}
export interface NotifierResult {
toSend: PlannedEmail[];
newState: EmailSendLog;
}
const DAY = 24 * 60 * 60 * 1000;
const SEVERE_INTERVAL = 30 * DAY;
const VULNERABLE_INTERVAL = 7 * DAY;
const sinceMs = (iso: string | null, now: Date): number =>
iso ? now.getTime() - new Date(iso).getTime() : Infinity;
/**
* Decide which emails to send and what the new dedupe-log state should be.
* Pure function: returns plans + new state, does not actually send.
*
* Cadence: vulnerable beats severe; vulnerable repeats every 7 days; severe every 30.
* If vulnerable AND the release tag changed since last send, fire `vulnerable-new-release`
* even within the 7-day window so admins learn of the fixed release.
*/
export const decideEmails = (input: NotifierInput): NotifierResult => {
const {adminEmail, current, latest, latestTag, isVulnerable, isSevere, state, now} = input;
if (!adminEmail) return {toSend: [], newState: state};
const toSend: PlannedEmail[] = [];
const newState: EmailSendLog = {...state};
if (isVulnerable) {
const sinceVuln = sinceMs(state.vulnerableAt, now);
const tagChanged = state.vulnerableNewReleaseTag !== null && state.vulnerableNewReleaseTag !== latestTag;
if (tagChanged) {
// A new release shipped while the instance is still vulnerable. Fire regardless
// of the 7-day cadence: the admin needs to know a fix exists.
toSend.push({
kind: 'vulnerable-new-release',
subject: `[Etherpad] New release available — ${latest} (your version is vulnerable)`,
body: `A new Etherpad release (${latestTag}) is available. Your version (${current}) is flagged as vulnerable. Please update.`,
});
newState.vulnerableNewReleaseTag = latestTag;
// Also reset the periodic clock so we don't immediately re-nag on next tick.
newState.vulnerableAt = now.toISOString();
} else if (sinceVuln >= VULNERABLE_INTERVAL) {
toSend.push({
kind: 'vulnerable',
subject: `[Etherpad] Your instance is running a vulnerable version (${current})`,
body: `Your Etherpad version (${current}) is below the security threshold. Latest is ${latest}.`,
});
newState.vulnerableAt = now.toISOString();
newState.vulnerableNewReleaseTag = latestTag;
}
} else if (isSevere) {
const sinceSevere = sinceMs(state.severeAt, now);
if (sinceSevere >= SEVERE_INTERVAL) {
toSend.push({
kind: 'severe',
subject: `[Etherpad] Your instance is severely outdated (${current})`,
body: `Your Etherpad version (${current}) is more than one major release behind ${latest}.`,
});
newState.severeAt = now.toISOString();
}
}
return {toSend, newState};
};

View File

@ -0,0 +1,42 @@
import {compareSemver} from './versionCompare';
import {InstallMethod, PolicyResult, Tier} from './types';
// For PR 1 (notify only) the writable list contains only 'git'.
// PR 2+ may add 'npm' here as the executor learns to handle that path.
const WRITABLE_METHODS: ReadonlySet<Exclude<InstallMethod, 'auto'>> = new Set(['git']);
export interface PolicyInput {
installMethod: Exclude<InstallMethod, 'auto'>;
tier: Tier;
current: string;
latest: string;
}
/**
* Decide which update tiers are allowed under the given (installMethod, tier, current, latest).
* Pure function no I/O. The single source of truth for "what's allowed in this environment."
* `reason` is one of: 'tier-off' | 'up-to-date' | 'install-method-not-writable' | 'ok'.
*/
export const evaluatePolicy = ({installMethod, tier, current, latest}: PolicyInput): PolicyResult => {
if (tier === 'off') {
return {canNotify: false, canManual: false, canAuto: false, canAutonomous: false, reason: 'tier-off'};
}
if (compareSemver(current, latest) >= 0) {
return {canNotify: false, canManual: false, canAuto: false, canAutonomous: false, reason: 'up-to-date'};
}
const canNotify = true;
const writable = WRITABLE_METHODS.has(installMethod);
if (!writable) {
return {canNotify, canManual: false, canAuto: false, canAutonomous: false, reason: 'install-method-not-writable'};
}
return {
canNotify,
canManual: tier === 'manual' || tier === 'auto' || tier === 'autonomous',
canAuto: tier === 'auto' || tier === 'autonomous',
canAutonomous: tier === 'autonomous',
reason: 'ok',
};
};

View File

@ -0,0 +1,87 @@
import {ReleaseInfo, VulnerableBelowDirective} from './types';
import {parseVulnerableBelow} from './versionCompare';
export interface FetchResult {
status: number;
etag: string | null;
/** Parsed JSON body on 200, otherwise null. */
json: any;
}
/** Adapter so tests can stub the network. Maps URL+ETag to a FetchResult. */
export type Fetcher = (url: string, etag: string | null) => Promise<FetchResult>;
/** Discriminated union of every outcome the checker can return. */
export type CheckResult =
| {kind: 'updated'; release: ReleaseInfo; etag: string | null; vulnerableBelow: VulnerableBelowDirective[]}
| {kind: 'notmodified'}
| {kind: 'ratelimited'}
| {kind: 'skipped-prerelease'; etag: string | null}
| {kind: 'error'; status: number};
export interface CheckOptions {
fetcher: Fetcher;
prevEtag: string | null;
/** GitHub repo as `owner/name`, e.g. `ether/etherpad`. */
repo: string;
}
/**
* Hit `/repos/{repo}/releases/latest` on GitHub. Pass the previous ETag for `If-None-Match`.
* Returns one of: 'updated' | 'notmodified' | 'ratelimited' | 'skipped-prerelease' | 'error'.
*/
export const checkLatestRelease = async (
{fetcher, prevEtag, repo}: CheckOptions,
): Promise<CheckResult> => {
const url = `https://api.github.com/repos/${repo}/releases/latest`;
const res = await fetcher(url, prevEtag);
if (res.status === 304) return {kind: 'notmodified'};
if (res.status === 403 || res.status === 429) return {kind: 'ratelimited'};
if (res.status !== 200 || !res.json) return {kind: 'error', status: res.status};
const j = res.json;
if (j.prerelease) return {kind: 'skipped-prerelease', etag: res.etag};
if (typeof j.tag_name !== 'string' ||
typeof j.html_url !== 'string' ||
typeof j.published_at !== 'string') {
return {kind: 'error', status: 200};
}
const tag = j.tag_name;
const version = tag.replace(/^v/, '');
const body: string = typeof j.body === 'string' ? j.body : '';
const release: ReleaseInfo = {
version,
tag,
body,
publishedAt: j.published_at,
prerelease: false,
htmlUrl: j.html_url,
};
const directiveThreshold = parseVulnerableBelow(body);
const vulnerableBelow: VulnerableBelowDirective[] = directiveThreshold
? [{announcedBy: tag, threshold: directiveThreshold}]
: [];
return {kind: 'updated', release, etag: res.etag, vulnerableBelow};
};
/** Production fetcher built on Node 18+ native fetch. Honors If-None-Match for cheap polling. */
export const realFetcher: Fetcher = async (url, etag) => {
const headers: Record<string, string> = {
'Accept': 'application/vnd.github+json',
'User-Agent': 'etherpad-self-update',
};
if (etag) headers['If-None-Match'] = etag;
const r = await fetch(url, {headers});
const newEtag = r.headers.get('etag');
let json: any = null;
if (r.status === 200) {
try { json = await r.json(); } catch { json = null; }
}
return {status: r.status, etag: newEtag, json};
};

149
src/node/updater/index.ts Normal file
View File

@ -0,0 +1,149 @@
import path from 'node:path';
import log4js from 'log4js';
import settings, {getEpVersion} from '../utils/Settings';
import {detectInstallMethod} from './InstallMethodDetector';
import {checkLatestRelease, realFetcher} from './VersionChecker';
import {loadState, saveState} from './state';
import {isMajorBehind, isVulnerable} from './versionCompare';
import {evaluatePolicy} from './UpdatePolicy';
import {decideEmails} from './Notifier';
import {InstallMethod, UpdateState} from './types';
const logger = log4js.getLogger('updater');
let detectedMethod: Exclude<InstallMethod, 'auto'> = 'managed';
let timer: NodeJS.Timeout | null = null;
let initialTimer: NodeJS.Timeout | null = null;
let checkInFlight = false;
let inMemoryState: UpdateState | null = null;
export const stateFilePath = () => path.join(settings.root, 'var', 'update-state.json');
/** Returns the current state from memory; loads on first call. */
export const getCurrentState = async (): Promise<UpdateState> => {
if (inMemoryState) return inMemoryState;
inMemoryState = await loadState(stateFilePath());
return inMemoryState;
};
export const getDetectedInstallMethod = () => detectedMethod;
const sendEmailViaSmtp = async (to: string, subject: string, body: string): Promise<void> => {
// Etherpad core has no built-in SMTP. PR 1 ships the dedupe machinery without an actual sender;
// subsequent PRs can wire nodemailer or rely on a notification plugin.
logger.info(`(would send email) to=${to} subject="${subject}"`);
void body;
};
const performCheck = async (): Promise<void> => {
if (settings.updates.tier === 'off') return;
// Coalesce overlapping ticks. performCheck mutates shared in-memory state and writes
// it to disk; concurrent runs would race on saveState() and could double-send emails.
if (checkInFlight) return;
checkInFlight = true;
try {
// getCurrentState() can throw on a non-ENOENT fs error from loadState();
// it must run inside the try/finally so checkInFlight is always cleared,
// otherwise a one-time permission error permanently disables polling.
const state = await getCurrentState();
const result = await checkLatestRelease({
fetcher: realFetcher,
prevEtag: state.lastEtag,
repo: settings.updates.githubRepo,
});
const now = new Date();
state.lastCheckAt = now.toISOString();
if (result.kind === 'updated') {
state.latest = result.release;
state.lastEtag = result.etag;
// Union new directives with existing — same announcedBy is a no-op.
const existingTags = new Set(state.vulnerableBelow.map((v) => v.announcedBy));
for (const v of result.vulnerableBelow) {
if (!existingTags.has(v.announcedBy)) state.vulnerableBelow.push(v);
}
} else if (result.kind === 'skipped-prerelease') {
// Preserve ETag so we don't re-fetch an unchanged prerelease body next tick.
state.lastEtag = result.etag;
} else if (result.kind === 'notmodified') {
// 304 — no state change.
} else if (result.kind === 'ratelimited') {
logger.warn('GitHub rate-limited; will retry at next interval');
} else if (result.kind === 'error') {
logger.warn(`GitHub fetch error status=${result.status}`);
}
// Notifier pass: only when we have a known latest, an admin email, and the policy allows notify.
if (state.latest && settings.adminEmail) {
const current = getEpVersion();
const policy = evaluatePolicy({
installMethod: detectedMethod,
tier: settings.updates.tier,
current,
latest: state.latest.version,
});
if (policy.canNotify) {
const decision = decideEmails({
adminEmail: settings.adminEmail,
current,
latest: state.latest.version,
latestTag: state.latest.tag,
isVulnerable: isVulnerable(current, state.vulnerableBelow),
isSevere: isMajorBehind(current, state.latest.version),
state: state.email,
now,
});
for (const email of decision.toSend) {
await sendEmailViaSmtp(settings.adminEmail, email.subject, email.body);
}
state.email = decision.newState;
}
}
await saveState(stateFilePath(), state);
} catch (err) {
logger.warn(`Updater check failed: ${(err as Error).message}`);
} finally {
checkInFlight = false;
}
};
const startPolling = (): void => {
// Coerce in case settings.json carries a non-number (Math.max(1, NaN) === NaN,
// which becomes a tight setInterval loop). Clamp to a sane window: at least 1h
// (don't hammer GitHub) and at most a week (don't silently stop checking).
const rawHours = Number(settings.updates.checkIntervalHours);
const safeHours = Number.isFinite(rawHours) ? Math.min(168, Math.max(1, rawHours)) : 6;
if (safeHours !== rawHours) {
logger.warn(`updates.checkIntervalHours invalid (${settings.updates.checkIntervalHours}); using ${safeHours}h`);
}
const intervalMs = safeHours * 60 * 60 * 1000;
if (timer) clearInterval(timer);
if (initialTimer) clearTimeout(initialTimer);
timer = setInterval(() => { void performCheck(); }, intervalMs);
// Run an immediate first check, but don't block boot. Track the handle so shutdown()
// can cancel it before it fires.
initialTimer = setTimeout(() => { initialTimer = null; void performCheck(); }, 5000);
};
/** Hook entry point — called by ep.json on createServer. */
export const expressCreateServer = async (): Promise<void> => {
detectedMethod = await detectInstallMethod({
override: settings.updates.installMethod,
rootDir: settings.root,
});
logger.info(`updater: install method = ${detectedMethod}, tier = ${settings.updates.tier}`);
if (settings.updates.tier !== 'off') startPolling();
};
/** Shutdown hook. */
export const shutdown = async (): Promise<void> => {
if (timer) { clearInterval(timer); timer = null; }
if (initialTimer) { clearTimeout(initialTimer); initialTimer = null; }
};
/** Exposed for tests / route handlers. */
export const _internal = {
performCheck,
stateFilePath,
};

77
src/node/updater/state.ts Normal file
View File

@ -0,0 +1,77 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import {EMPTY_STATE, UpdateState} from './types';
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
v !== null && typeof v === 'object' && !Array.isArray(v);
const isStringOrNull = (v: unknown): v is string | null =>
v === null || typeof v === 'string';
const isValidLatest = (v: unknown): boolean => {
if (v === null) return true;
if (!isPlainObject(v)) return false;
// Subfields are read into semver parsing and email rendering; if any is
// missing or wrong-type the file is treated as corrupt and reset.
return typeof v.version === 'string'
&& typeof v.tag === 'string'
&& typeof v.body === 'string'
&& typeof v.publishedAt === 'string'
&& typeof v.htmlUrl === 'string'
&& typeof v.prerelease === 'boolean';
};
const isValidVulnerableBelow = (v: unknown): boolean => {
if (!Array.isArray(v)) return false;
return v.every((entry) =>
isPlainObject(entry)
&& typeof entry.announcedBy === 'string'
&& typeof entry.threshold === 'string');
};
const isValidEmail = (v: unknown): boolean => {
if (!isPlainObject(v)) return false;
return isStringOrNull(v.severeAt)
&& isStringOrNull(v.vulnerableAt)
&& isStringOrNull(v.vulnerableNewReleaseTag);
};
// Validate the full shape so loadState() actually delivers on its "safely
// reset on malformed input" contract. Downstream code calls .trim() / semver
// parsing on these subfields and would crash on a hand-edited file otherwise.
const isValid = (raw: unknown): raw is UpdateState => {
if (!isPlainObject(raw)) return false;
return raw.schemaVersion === 1
&& isStringOrNull(raw.lastCheckAt)
&& isStringOrNull(raw.lastEtag)
&& isValidLatest(raw.latest)
&& isValidVulnerableBelow(raw.vulnerableBelow)
&& isValidEmail(raw.email);
};
/** Reads the on-disk state. Returns a fresh empty-state clone when the file is missing, malformed, or has an unknown schemaVersion. Never throws on parse errors. */
export const loadState = async (filePath: string): Promise<UpdateState> => {
let raw: string;
try {
raw = await fs.readFile(filePath, 'utf8');
} catch (err: any) {
if (err.code === 'ENOENT') return structuredClone(EMPTY_STATE);
throw err;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return structuredClone(EMPTY_STATE);
}
if (!isValid(parsed)) return structuredClone(EMPTY_STATE);
return parsed;
};
/** Atomic write via tmp-then-rename. Creates parent directories as needed. */
export const saveState = async (filePath: string, state: UpdateState): Promise<void> => {
await fs.mkdir(path.dirname(filePath), {recursive: true});
const tmp = `${filePath}.tmp`;
await fs.writeFile(tmp, JSON.stringify(state, null, 2));
await fs.rename(tmp, filePath);
};

75
src/node/updater/types.ts Normal file
View File

@ -0,0 +1,75 @@
export type InstallMethod = 'auto' | 'git' | 'docker' | 'npm' | 'managed';
export type Tier = 'off' | 'notify' | 'manual' | 'auto' | 'autonomous';
/** null = up-to-date (or not yet checked); 'severe' = at least one major version behind; 'vulnerable' = matched a vulnerable-below directive. */
export type OutdatedLevel = null | 'severe' | 'vulnerable';
export interface ReleaseInfo {
/** semver string without leading 'v', e.g. "2.7.2". */
version: string;
/** Original GitHub `tag_name`, e.g. "v2.7.2". */
tag: string;
/** Markdown body of the release. */
body: string;
/** ISO-8601 timestamp from GitHub. */
publishedAt: string;
/** True if GitHub flagged it as a prerelease. */
prerelease: boolean;
/** GitHub HTML URL for the release page. */
htmlUrl: string;
}
export interface VulnerableBelowDirective {
/** The release that *announced* the vulnerability (latest release wins on conflict). */
announcedBy: string;
/** Versions strictly below this string are considered vulnerable. */
threshold: string;
}
export interface PolicyResult {
canNotify: boolean;
canManual: boolean;
canAuto: boolean;
canAutonomous: boolean;
/** Human-readable string explaining the most-restrictive denial, or "ok". */
reason: string;
}
export interface EmailSendLog {
/** Last time we emailed about being severely-outdated, ISO-8601. */
severeAt: string | null;
/** Last time we emailed about being vulnerable, ISO-8601. */
vulnerableAt: string | null;
/** Tag of the release the last "new release while vulnerable" email referenced. */
vulnerableNewReleaseTag: string | null;
}
export interface UpdateState {
/** Schema version of this file. Increment when fields change. */
schemaVersion: 1;
/** Last time VersionChecker successfully fetched, ISO-8601. */
lastCheckAt: string | null;
/** Last ETag returned by GitHub, used for If-None-Match. */
lastEtag: string | null;
/** Cached release info, or null if we've never successfully fetched. */
latest: ReleaseInfo | null;
/** Vulnerable-below directives parsed from the most recent N releases. */
vulnerableBelow: VulnerableBelowDirective[];
/** Email send dedupe state. */
email: EmailSendLog;
}
/** Zero-value initial state. Treat as immutable — spread before mutating: `{...EMPTY_STATE, lastCheckAt: x}`. */
export const EMPTY_STATE: UpdateState = {
schemaVersion: 1,
lastCheckAt: null,
lastEtag: null,
latest: null,
vulnerableBelow: [],
email: {
severeAt: null,
vulnerableAt: null,
vulnerableNewReleaseTag: null,
},
};

View File

@ -0,0 +1,53 @@
import type {VulnerableBelowDirective} from './types';
export interface ParsedSemver {
major: number;
minor: number;
patch: number;
}
// Accepts optional prerelease (e.g. -rc.1) and build-metadata (e.g. +build.123).
// Four-part versions like 2.7.1.4 are rejected — use standard semver only.
const SEMVER_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/;
export const parseSemver = (s: string): ParsedSemver | null => {
const m = SEMVER_RE.exec(s.trim());
if (!m) return null;
return {major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3])};
};
export const compareSemver = (a: string, b: string): -1 | 0 | 1 => {
const pa = parseSemver(a);
const pb = parseSemver(b);
if (!pa || !pb) return 0;
for (const k of ['major', 'minor', 'patch'] as const) {
if (pa[k] !== pb[k]) return pa[k] < pb[k] ? -1 : 1;
}
return 0;
};
export const isMajorBehind = (current: string, latest: string): boolean => {
const c = parseSemver(current);
const l = parseSemver(latest);
if (!c || !l) return false;
return l.major - c.major >= 1;
};
const VULN_RE = /<!--\s*updater\s*:\s*vulnerable-below\s+([^\s-][^\s]*)\s*-->/i;
export const parseVulnerableBelow = (body: string): string | null => {
const m = VULN_RE.exec(body);
if (!m) return null;
if (!parseSemver(m[1])) return null;
return m[1];
};
export const isVulnerable = (
current: string,
directives: readonly VulnerableBelowDirective[],
): boolean => {
for (const d of directives) {
if (compareSemver(current, d.threshold) < 0) return true;
}
return false;
};

View File

@ -299,6 +299,16 @@ export type SettingsType = {
lowerCasePadIds: boolean,
randomVersionString: string,
gitVersion: string
updates: {
tier: 'off' | 'notify' | 'manual' | 'auto' | 'autonomous',
source: 'github',
channel: 'stable',
installMethod: 'auto' | 'git' | 'docker' | 'npm' | 'managed',
checkIntervalHours: number,
githubRepo: string,
requireAdminForStatus: boolean,
},
adminEmail: string | null,
getPublicSettings: () => Pick<SettingsType, "title" | "skinVariants"|"randomVersionString"|"skinName"|"toolbar"| "exposeVersion"| "gitVersion" | "enablePadWideSettings">,
}
@ -431,6 +441,28 @@ const settings: SettingsType = {
* Wether to enable the /stats endpoint. The functionality in the admin menu is untouched for this.
*/
enableMetrics: true,
/**
* Self-update subsystem (PR 1: tier 1 only).
* Tier "off" disables the version check entirely. Default "notify" shows a banner when behind.
*/
updates: {
tier: 'notify',
source: 'github',
channel: 'stable',
installMethod: 'auto',
checkIntervalHours: 6,
githubRepo: 'ether/etherpad',
// The /admin/update/status endpoint returns full info including currentVersion.
// Default false matches existing behavior: the version is already exposed via /health.
// Set true to require an authenticated admin session for the endpoint without
// disabling the updater itself.
requireAdminForStatus: false,
},
/**
* Contact address for admin notifications (updates, future security advisories).
* Null disables outbound mail from the updater.
*/
adminEmail: null,
/**
* Whether certain shortcut keys are enabled for a user in the pad
*/

View File

@ -92,3 +92,18 @@ input {
margin-left:auto;
margin-right:auto;
}
/* Auto-update version badge — only visible when /api/version-status reports severe or vulnerable. */
#version-badge {
position: fixed;
bottom: 8px;
right: 8px;
padding: 6px 10px;
font-size: 12px;
border-radius: 4px;
z-index: 9999;
pointer-events: auto;
max-width: 320px;
}
#version-badge[data-level="severe"] { background: #fff3cd; color: #664d03; border: 1px solid #ffe69c; }
#version-badge[data-level="vulnerable"] { background: #f8d7da; color: #58151c; border: 1px solid #f1aeb5; }

View File

@ -54,6 +54,8 @@ const socketio = require('./socketio');
const hooks = require('./pluginfw/hooks');
import './pad_version_badge';
// This array represents all GET-parameters which can be used to change a setting.
// name: the parameter-name, eg `?noColors=true` => `noColors`
// checkVal: the callback is only executed when

View File

@ -0,0 +1,46 @@
'use strict';
interface BadgeResponse { outdated: 'severe' | 'vulnerable' | null }
// TODO(i18n): switch to html10n once a `pad.update.badge.*` key set is added there.
// (Strings are deliberately not pulled from /locales/en.json yet — that file is
// consumed by the admin UI's i18next, not the pad's html10n. Cross-wiring is
// a separate piece of work.)
const TEXT_BY_LEVEL: Record<'severe' | 'vulnerable', string> = {
severe: 'Etherpad on this server is severely outdated. Tell your admin.',
vulnerable: 'Etherpad on this server is running a version with known security issues. Tell your admin.',
};
// padBootstrap.js derives basePath from window.location ('..' relative to the
// pad URL) so deployments hosted under a subpath route requests through the
// same prefix. We replicate that here rather than importing pad.ts (which
// would reintroduce the badge↔pad circular initialisation).
const apiBasePath = (): string => {
if (typeof window === 'undefined') return '/';
return new URL('..', window.location.href).pathname;
};
export const renderVersionBadge = async (): Promise<void> => {
const el = document.getElementById('version-badge');
if (!el) return;
try {
const res = await fetch(`${apiBasePath()}api/version-status`, {credentials: 'same-origin'});
if (!res.ok) return;
const data = (await res.json()) as BadgeResponse;
if (!data.outdated) { el.style.display = 'none'; return; }
el.textContent = TEXT_BY_LEVEL[data.outdated];
el.dataset.level = data.outdated;
el.style.display = '';
} catch {
// Quiet failure — never block the pad load.
}
};
// Auto-render once DOM is ready.
if (typeof window !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => { void renderVersionBadge(); });
} else {
void renderVersionBadge();
}
}

View File

@ -516,6 +516,7 @@
<% e.end_block(); %>
<div id="version-badge" role="status" aria-live="polite" style="display:none"></div>
</div> <!-- End of #editorcontainerbox -->
<% e.end_block(); %>

View File

@ -0,0 +1,50 @@
import {describe, it, expect, beforeEach} from 'vitest';
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import {detectInstallMethod} from '../../../../node/updater/InstallMethodDetector';
let dir: string;
beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'detector-'));
});
const opts = (override?: 'auto' | 'git' | 'docker' | 'npm' | 'managed') => ({
override: override ?? 'auto',
rootDir: dir,
dockerEnvPath: path.join(dir, '.dockerenv'),
});
describe('detectInstallMethod', () => {
it('honors a non-auto override', async () => {
expect(await detectInstallMethod(opts('git'))).toBe('git');
expect(await detectInstallMethod(opts('docker'))).toBe('docker');
expect(await detectInstallMethod(opts('managed'))).toBe('managed');
});
it('returns docker when /.dockerenv exists', async () => {
await fs.writeFile(opts().dockerEnvPath, '');
expect(await detectInstallMethod(opts())).toBe('docker');
});
it('returns git when .git is present and root is writable', async () => {
await fs.mkdir(path.join(dir, '.git'));
expect(await detectInstallMethod(opts())).toBe('git');
});
it('returns npm when package-lock.json is present and writable', async () => {
await fs.writeFile(path.join(dir, 'package-lock.json'), '{}');
expect(await detectInstallMethod(opts())).toBe('npm');
});
it('returns managed when nothing matches', async () => {
expect(await detectInstallMethod(opts())).toBe('managed');
});
it('docker takes precedence over git', async () => {
await fs.writeFile(opts().dockerEnvPath, '');
await fs.mkdir(path.join(dir, '.git'));
expect(await detectInstallMethod(opts())).toBe('docker');
});
});

View File

@ -0,0 +1,95 @@
import {describe, it, expect} from 'vitest';
import {decideEmails, NotifierInput} from '../../../../node/updater/Notifier';
import {EMPTY_STATE} from '../../../../node/updater/types';
const base: NotifierInput = {
adminEmail: 'admin@example.com',
current: '2.0.0',
latest: '2.7.2',
latestTag: 'v2.7.2',
isVulnerable: false,
isSevere: false,
state: EMPTY_STATE.email,
now: new Date('2026-04-25T12:00:00Z'),
};
describe('decideEmails', () => {
it('emits no email if adminEmail is unset', () => {
const r = decideEmails({...base, adminEmail: null, isSevere: true});
expect(r.toSend).toEqual([]);
});
it('emits severe email on first detection', () => {
const r = decideEmails({...base, isSevere: true});
expect(r.toSend.map(e => e.kind)).toEqual(['severe']);
expect(r.newState.severeAt).toBe('2026-04-25T12:00:00.000Z');
});
it('does not re-emit severe within 30 days', () => {
const r = decideEmails({
...base,
isSevere: true,
state: {...base.state, severeAt: '2026-04-10T12:00:00.000Z'},
});
expect(r.toSend).toEqual([]);
});
it('re-emits severe after 30 days', () => {
const r = decideEmails({
...base,
isSevere: true,
state: {...base.state, severeAt: '2026-03-20T12:00:00.000Z'},
});
expect(r.toSend.map(e => e.kind)).toEqual(['severe']);
});
it('emits vulnerable email on first detection', () => {
const r = decideEmails({...base, isVulnerable: true});
expect(r.toSend.map(e => e.kind)).toEqual(['vulnerable']);
expect(r.newState.vulnerableAt).toBe('2026-04-25T12:00:00.000Z');
});
it('does not re-emit vulnerable within 7 days', () => {
const r = decideEmails({
...base,
isVulnerable: true,
state: {...base.state, vulnerableAt: '2026-04-22T12:00:00.000Z'},
});
expect(r.toSend).toEqual([]);
});
it('re-emits vulnerable after 7 days', () => {
const r = decideEmails({
...base,
isVulnerable: true,
state: {...base.state, vulnerableAt: '2026-04-15T12:00:00.000Z'},
});
expect(r.toSend.map(e => e.kind)).toEqual(['vulnerable']);
});
it('emits new-release-while-vulnerable when latest tag changes', () => {
const r = decideEmails({
...base,
isVulnerable: true,
state: {...base.state, vulnerableAt: '2026-04-25T11:59:00.000Z', vulnerableNewReleaseTag: 'v2.7.1'},
});
expect(r.toSend.map(e => e.kind)).toEqual(['vulnerable-new-release']);
});
it('vulnerable wins over severe in the same tick', () => {
const r = decideEmails({...base, isSevere: true, isVulnerable: true});
expect(r.toSend.map(e => e.kind)).toEqual(['vulnerable']);
});
it('emits new-release-while-vulnerable even after the 7-day window has passed', () => {
// Regression: tagChanged should fire regardless of cadence; admin must learn of the fix.
const r = decideEmails({
...base,
isVulnerable: true,
state: {...base.state, vulnerableAt: '2026-04-01T12:00:00.000Z', vulnerableNewReleaseTag: 'v2.7.1'},
});
expect(r.toSend.map(e => e.kind)).toEqual(['vulnerable-new-release']);
expect(r.newState.vulnerableNewReleaseTag).toBe('v2.7.2');
expect(r.newState.vulnerableAt).toBe('2026-04-25T12:00:00.000Z');
});
});

View File

@ -0,0 +1,64 @@
import {describe, it, expect} from 'vitest';
import {evaluatePolicy} from '../../../../node/updater/UpdatePolicy';
import {InstallMethod, Tier} from '../../../../node/updater/types';
const baseInput = {
installMethod: 'git' as Exclude<InstallMethod, 'auto'>,
tier: 'manual' as Tier,
current: '2.7.1',
latest: '2.7.2',
};
describe('evaluatePolicy', () => {
it('off tier denies everything', () => {
const r = evaluatePolicy({...baseInput, tier: 'off'});
expect(r).toEqual({canNotify: false, canManual: false, canAuto: false, canAutonomous: false, reason: 'tier-off'});
});
it('notify tier allows only notify', () => {
const r = evaluatePolicy({...baseInput, tier: 'notify'});
expect(r.canNotify).toBe(true);
expect(r.canManual).toBe(false);
expect(r.canAuto).toBe(false);
expect(r.canAutonomous).toBe(false);
});
it('manual tier allows notify+manual on git', () => {
const r = evaluatePolicy({...baseInput, tier: 'manual'});
expect(r.canManual).toBe(true);
expect(r.canAuto).toBe(false);
});
it('manual tier denies manual on docker', () => {
const r = evaluatePolicy({...baseInput, tier: 'manual', installMethod: 'docker'});
expect(r.canNotify).toBe(true);
expect(r.canManual).toBe(false);
expect(r.reason).toBe('install-method-not-writable');
});
it('autonomous tier allows everything on git', () => {
const r = evaluatePolicy({...baseInput, tier: 'autonomous'});
expect(r).toEqual({canNotify: true, canManual: true, canAuto: true, canAutonomous: true, reason: 'ok'});
});
it('autonomous on managed install denies write tiers', () => {
const r = evaluatePolicy({...baseInput, tier: 'autonomous', installMethod: 'managed'});
expect(r.canNotify).toBe(true);
expect(r.canManual).toBe(false);
expect(r.canAuto).toBe(false);
expect(r.canAutonomous).toBe(false);
});
it('current === latest denies all (nothing to do)', () => {
const r = evaluatePolicy({...baseInput, tier: 'autonomous', current: '2.7.2', latest: '2.7.2'});
expect(r.canNotify).toBe(false);
expect(r.canManual).toBe(false);
expect(r.reason).toBe('up-to-date');
});
it('current > latest (dev build) denies all', () => {
const r = evaluatePolicy({...baseInput, tier: 'autonomous', current: '3.0.0', latest: '2.7.2'});
expect(r.canNotify).toBe(false);
expect(r.reason).toBe('up-to-date');
});
});

View File

@ -0,0 +1,96 @@
import {describe, it, expect} from 'vitest';
import {checkLatestRelease, FetchResult} from '../../../../node/updater/VersionChecker';
import {ReleaseInfo} from '../../../../node/updater/types';
const ghBody = (overrides: Partial<{tag_name: string; body: string; prerelease: boolean; html_url: string; published_at: string}> = {}) => ({
tag_name: 'v2.7.2',
body: 'Some changes.\n<!-- updater: vulnerable-below 2.6.4 -->',
prerelease: false,
html_url: 'https://github.com/ether/etherpad/releases/tag/v2.7.2',
published_at: '2026-04-25T00:00:00Z',
...overrides,
});
describe('checkLatestRelease', () => {
it('returns parsed release on 200', async () => {
const fetcher = async () => ({
status: 200,
etag: 'abc',
json: ghBody(),
} as FetchResult);
const r = await checkLatestRelease({fetcher, prevEtag: null, repo: 'ether/etherpad'});
expect(r.kind).toBe('updated');
if (r.kind !== 'updated') return;
const expected: ReleaseInfo = {
version: '2.7.2',
tag: 'v2.7.2',
body: 'Some changes.\n<!-- updater: vulnerable-below 2.6.4 -->',
publishedAt: '2026-04-25T00:00:00Z',
prerelease: false,
htmlUrl: 'https://github.com/ether/etherpad/releases/tag/v2.7.2',
};
expect(r.release).toEqual(expected);
expect(r.etag).toBe('abc');
expect(r.vulnerableBelow).toEqual([{announcedBy: 'v2.7.2', threshold: '2.6.4'}]);
});
it('returns notmodified on 304', async () => {
const fetcher = async () => ({status: 304, etag: 'abc', json: null} as FetchResult);
const r = await checkLatestRelease({fetcher, prevEtag: 'abc', repo: 'ether/etherpad'});
expect(r.kind).toBe('notmodified');
});
it('returns ratelimited on 403', async () => {
const fetcher = async () => ({status: 403, etag: null, json: null} as FetchResult);
const r = await checkLatestRelease({fetcher, prevEtag: null, repo: 'ether/etherpad'});
expect(r.kind).toBe('ratelimited');
});
it('skips prereleases but preserves ETag', async () => {
const fetcher = async () => ({
status: 200, etag: 'pre-etag', json: ghBody({prerelease: true}),
} as FetchResult);
const r = await checkLatestRelease({fetcher, prevEtag: null, repo: 'ether/etherpad'});
expect(r.kind).toBe('skipped-prerelease');
if (r.kind !== 'skipped-prerelease') return;
expect(r.etag).toBe('pre-etag');
});
it('returns error on unexpected status', async () => {
const fetcher = async () => ({status: 500, etag: null, json: null} as FetchResult);
const r = await checkLatestRelease({fetcher, prevEtag: null, repo: 'ether/etherpad'});
expect(r.kind).toBe('error');
});
it('passes prevEtag to fetcher', async () => {
let observed: string | null = '';
const fetcher = async (_url: string, etag: string | null) => {
observed = etag;
return {status: 304, etag: 'abc', json: null} as FetchResult;
};
await checkLatestRelease({fetcher, prevEtag: 'old', repo: 'ether/etherpad'});
expect(observed).toBe('old');
});
it('returns error when required fields are missing', async () => {
const fetcher = async () => ({
status: 200,
etag: 'abc',
json: {body: 'no tag_name here'},
} as FetchResult);
const r = await checkLatestRelease({fetcher, prevEtag: null, repo: 'ether/etherpad'});
expect(r.kind).toBe('error');
if (r.kind !== 'error') return;
expect(r.status).toBe(200);
});
it('returns error when tag_name is null', async () => {
const fetcher = async () => ({
status: 200,
etag: 'abc',
json: {tag_name: null, html_url: 'x', published_at: 'y'},
} as FetchResult);
const r = await checkLatestRelease({fetcher, prevEtag: null, repo: 'ether/etherpad'});
expect(r.kind).toBe('error');
});
});

View File

@ -0,0 +1,119 @@
import {describe, it, expect, beforeEach} from 'vitest';
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import {loadState, saveState} from '../../../../node/updater/state';
import {EMPTY_STATE} from '../../../../node/updater/types';
let dir: string;
const statePath = () => path.join(dir, 'update-state.json');
beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'updater-state-'));
});
describe('loadState', () => {
it('returns empty state when file does not exist', async () => {
const s = await loadState(statePath());
expect(s).toEqual(EMPTY_STATE);
});
it('round-trips a saved state', async () => {
const s = {...EMPTY_STATE, lastCheckAt: '2026-04-25T00:00:00Z'};
await saveState(statePath(), s);
const loaded = await loadState(statePath());
expect(loaded.lastCheckAt).toBe('2026-04-25T00:00:00Z');
});
it('returns empty state when file is corrupt', async () => {
await fs.writeFile(statePath(), 'not json');
const s = await loadState(statePath());
expect(s).toEqual(EMPTY_STATE);
});
it('returns empty state when schemaVersion is unknown', async () => {
await fs.writeFile(statePath(), JSON.stringify({schemaVersion: 999}));
const s = await loadState(statePath());
expect(s).toEqual(EMPTY_STATE);
});
it('returns empty state when email is null', async () => {
// Regression: typeof null === 'object', so a hand-edited file with email:null
// would have passed an earlier shape check and crashed downstream consumers.
const broken = {...EMPTY_STATE, email: null};
await fs.writeFile(statePath(), JSON.stringify(broken));
const s = await loadState(statePath());
expect(s).toEqual(EMPTY_STATE);
});
it('returns empty state when latest is an array', async () => {
const broken = {...EMPTY_STATE, latest: []};
await fs.writeFile(statePath(), JSON.stringify(broken));
const s = await loadState(statePath());
expect(s).toEqual(EMPTY_STATE);
});
it('returns empty state when latest is missing required subfields', async () => {
// Regression: top-level shape passed earlier validation but downstream code
// calls .trim() / semver parsing on latest.version → crash on bad input.
const broken = {...EMPTY_STATE, latest: {version: 1}};
await fs.writeFile(statePath(), JSON.stringify(broken));
const s = await loadState(statePath());
expect(s).toEqual(EMPTY_STATE);
});
it('returns empty state when vulnerableBelow entries miss threshold', async () => {
const broken = {...EMPTY_STATE, vulnerableBelow: [{announcedBy: 'v1.0.0'}]};
await fs.writeFile(statePath(), JSON.stringify(broken));
const s = await loadState(statePath());
expect(s).toEqual(EMPTY_STATE);
});
it('returns empty state when vulnerableBelow.threshold is non-string', async () => {
const broken = {...EMPTY_STATE, vulnerableBelow: [{announcedBy: 'v1', threshold: 123}]};
await fs.writeFile(statePath(), JSON.stringify(broken));
const s = await loadState(statePath());
expect(s).toEqual(EMPTY_STATE);
});
it('returns empty state when email subfield is wrong type', async () => {
const broken = {...EMPTY_STATE, email: {severeAt: 0, vulnerableAt: null, vulnerableNewReleaseTag: null}};
await fs.writeFile(statePath(), JSON.stringify(broken));
const s = await loadState(statePath());
expect(s).toEqual(EMPTY_STATE);
});
it('accepts a fully-typed latest payload', async () => {
const good = {
...EMPTY_STATE,
latest: {
version: '2.7.2',
tag: 'v2.7.2',
body: 'release notes',
publishedAt: '2026-04-25T00:00:00Z',
htmlUrl: 'https://example.invalid/r/v2.7.2',
prerelease: false,
},
};
await fs.writeFile(statePath(), JSON.stringify(good));
const s = await loadState(statePath());
expect(s.latest?.version).toBe('2.7.2');
});
});
describe('saveState', () => {
it('writes atomically (no partial file on crash simulation)', async () => {
// We cannot easily simulate a crash, but we can verify the write went via a tmp file
// by checking only one file ends up in the dir.
await saveState(statePath(), EMPTY_STATE);
const entries = await fs.readdir(dir);
expect(entries).toEqual(['update-state.json']);
});
it('creates the directory if missing', async () => {
const nested = path.join(dir, 'nested', 'deep', 'update-state.json');
await saveState(nested, EMPTY_STATE);
const data = JSON.parse(await fs.readFile(nested, 'utf8'));
expect(data.schemaVersion).toBe(1);
});
});

View File

@ -0,0 +1,92 @@
import {describe, it, expect} from 'vitest';
import {
parseSemver,
compareSemver,
isMajorBehind,
parseVulnerableBelow,
isVulnerable,
} from '../../../../node/updater/versionCompare';
describe('parseSemver', () => {
it('parses a plain version', () => {
expect(parseSemver('2.7.1')).toEqual({major: 2, minor: 7, patch: 1});
});
it('strips leading v', () => {
expect(parseSemver('v2.7.1')).toEqual({major: 2, minor: 7, patch: 1});
});
it('returns null for garbage', () => {
expect(parseSemver('garbage')).toBeNull();
expect(parseSemver('')).toBeNull();
expect(parseSemver('2.7')).toBeNull();
});
it('strips prerelease suffix', () => {
expect(parseSemver('2.7.1-rc.1')).toEqual({major: 2, minor: 7, patch: 1});
expect(parseSemver('v2.7.1-beta')).toEqual({major: 2, minor: 7, patch: 1});
});
it('strips build metadata', () => {
expect(parseSemver('2.7.1+build.123')).toEqual({major: 2, minor: 7, patch: 1});
});
it('rejects four-part versions', () => {
expect(parseSemver('2.7.1.4')).toBeNull();
expect(parseSemver('2.7.1.foo')).toBeNull();
});
});
describe('compareSemver', () => {
it('orders correctly', () => {
expect(compareSemver('2.7.1', '2.7.2')).toBe(-1);
expect(compareSemver('2.7.2', '2.7.1')).toBe(1);
expect(compareSemver('2.7.2', '2.7.2')).toBe(0);
expect(compareSemver('3.0.0', '2.99.99')).toBe(1);
});
it('returns 0 if either is unparsable', () => {
expect(compareSemver('garbage', '2.7.1')).toBe(0);
});
});
describe('isMajorBehind', () => {
it('true when at least one major behind', () => {
expect(isMajorBehind('2.7.1', '3.0.0')).toBe(true);
expect(isMajorBehind('2.7.1', '4.0.0')).toBe(true);
});
it('false otherwise', () => {
expect(isMajorBehind('2.7.1', '2.99.99')).toBe(false);
expect(isMajorBehind('3.0.0', '3.0.0')).toBe(false);
expect(isMajorBehind('3.0.0', '2.7.1')).toBe(false);
});
});
describe('parseVulnerableBelow', () => {
it('extracts directive from release body', () => {
const body = 'Fixes a few things.\n<!-- updater: vulnerable-below 2.6.4 -->\nMore notes.';
expect(parseVulnerableBelow(body)).toBe('2.6.4');
});
it('tolerates whitespace and casing', () => {
expect(parseVulnerableBelow('<!--updater:vulnerable-below 1.0.0-->')).toBe('1.0.0');
expect(parseVulnerableBelow('<!-- UPDATER: VULNERABLE-BELOW 1.0.0 -->')).toBe('1.0.0');
});
it('returns null when absent or malformed', () => {
expect(parseVulnerableBelow('no directive here')).toBeNull();
expect(parseVulnerableBelow('<!-- updater: vulnerable-below garbage -->')).toBeNull();
});
});
describe('isVulnerable', () => {
it('true if current strictly below any directive threshold', () => {
expect(isVulnerable('2.6.3', [
{announcedBy: 'v2.7.0', threshold: '2.6.4'},
])).toBe(true);
});
it('false at or above all thresholds', () => {
expect(isVulnerable('2.6.4', [
{announcedBy: 'v2.7.0', threshold: '2.6.4'},
])).toBe(false);
expect(isVulnerable('2.7.0', [])).toBe(false);
});
it('handles multiple directives', () => {
expect(isVulnerable('1.5.0', [
{announcedBy: 'v2.0.0', threshold: '2.0.0'},
{announcedBy: 'v3.0.0', threshold: '1.9.0'},
])).toBe(true);
});
});

View File

@ -0,0 +1,144 @@
'use strict';
const assert = require('assert').strict;
const common = require('../common');
const plugins = require('../../../static/js/pluginfw/plugin_defs');
import settings from '../../../node/utils/Settings';
import {saveState} from '../../../node/updater/state';
import {EMPTY_STATE} from '../../../node/updater/types';
import path from 'node:path';
const statePath = () => path.join(settings.root, 'var', 'update-state.json');
// Hook names that plugins can register to influence auth decisions.
const authHookNames = ['preAuthorize', 'authenticate', 'authorize'];
const failHookNames = ['preAuthzFailure', 'authnFailure', 'authzFailure', 'authFailure'];
describe(__filename, function () {
let agent: any;
const backups: Record<string, any> = {};
before(async function () {
agent = await common.init();
});
beforeEach(async function () {
// Reset the route module's badge cache so each test sees fresh state.
const mod = require('../../../node/hooks/express/updateStatus');
if (typeof mod._resetBadgeCacheForTests === 'function') {
mod._resetBadgeCacheForTests();
}
// Save auth settings and hooks so we can restore after each test.
backups.hooks = {};
for (const hookName of authHookNames.concat(failHookNames)) {
backups.hooks[hookName] = plugins.hooks[hookName];
}
backups.settings = {};
for (const key of ['requireAuthentication', 'requireAuthorization', 'users']) {
backups.settings[key] = (settings as any)[key];
}
});
afterEach(async function () {
Object.assign(plugins.hooks, backups.hooks);
Object.assign(settings, backups.settings);
});
describe('GET /api/version-status', function () {
it('returns null when no state', async function () {
await saveState(statePath(), {...EMPTY_STATE});
const res = await agent.get('/api/version-status').expect(200);
assert.deepEqual(res.body, {outdated: null});
});
it('does not leak the running version', async function () {
const res = await agent.get('/api/version-status').expect(200);
assert.ok(!('version' in res.body), 'response leaks version field');
assert.ok(!('latest' in res.body), 'response leaks latest field');
assert.ok(!('currentVersion' in res.body), 'response leaks currentVersion field');
});
it('returns severe when running > 1 major behind', async function () {
// Force "latest" to be 99.0.0 so our running version is severely outdated.
await saveState(statePath(), {
...EMPTY_STATE,
latest: {
version: '99.0.0', tag: 'v99.0.0', body: '',
publishedAt: '2099-01-01T00:00:00Z', prerelease: false,
htmlUrl: 'https://example/',
},
});
const res = await agent.get('/api/version-status').expect(200);
assert.equal(res.body.outdated, 'severe');
});
});
describe('GET /admin/update/status', function () {
// Auth on this endpoint is intentionally loose: the running version is already
// exposed publicly via /health (releaseId), and latest/changelog come from a
// public GitHub release. Admins who want the endpoint gone set updates.tier=off,
// which removes route registration entirely (covered by the unit test for the
// hook). Here we just assert the basic shape.
it('returns the expected shape', async function () {
await saveState(statePath(), {...EMPTY_STATE});
const res = await agent.get('/admin/update/status').expect(200);
assert.ok(typeof res.body.currentVersion === 'string');
assert.equal(res.body.latest, null);
assert.equal(res.body.tier, settings.updates.tier);
assert.ok(Array.isArray(res.body.vulnerableBelow));
});
describe('when updates.requireAdminForStatus = true', function () {
const restore: Record<string, any> = {};
beforeEach(function () {
restore.requireAdminForStatus = settings.updates.requireAdminForStatus;
settings.updates.requireAdminForStatus = true;
});
afterEach(function () {
settings.updates.requireAdminForStatus = restore.requireAdminForStatus;
});
it('rejects unauthenticated requests with 401', async function () {
await agent.get('/admin/update/status').expect(401);
});
it('rejects authenticated non-admin sessions with 403', async function () {
// Inject a session via authenticate hook: any request becomes user "guest" (not admin).
for (const hookName of authHookNames.concat(failHookNames)) {
plugins.hooks[hookName] = [];
}
plugins.hooks.authenticate = [{
hook_fn: (_hookName: string, ctx: any, cb: Function) => {
ctx.req.session.user = {is_admin: false};
cb([true]);
},
}];
(settings as any).requireAuthentication = true;
(settings as any).requireAuthorization = false;
(settings as any).users = {guest: {password: 'guest-password'}};
await agent.get('/admin/update/status')
.auth('guest', 'guest-password')
.expect(403);
});
it('admits authenticated admin sessions', async function () {
for (const hookName of authHookNames.concat(failHookNames)) {
plugins.hooks[hookName] = [];
}
plugins.hooks.authenticate = [{
hook_fn: (_hookName: string, ctx: any, cb: Function) => {
ctx.req.session.user = {is_admin: true};
cb([true]);
},
}];
(settings as any).requireAuthentication = true;
(settings as any).requireAuthorization = false;
(settings as any).users = {admin: {password: 'admin-password', is_admin: true}};
await saveState(statePath(), {...EMPTY_STATE});
await agent.get('/admin/update/status')
.auth('admin', 'admin-password')
.expect(200);
});
});
});
});

View File

@ -14,7 +14,8 @@ test('Shows troubleshooting page manager', async ({page}) => {
await page.goto('http://localhost:9001/admin/help')
await page.waitForSelector('.menu')
const menu = page.locator('.menu');
await expect(menu.locator('li')).toHaveCount(5);
// Sidebar nav: plugins, settings, help, pads, shout, update.
await expect(menu.locator('li')).toHaveCount(6);
})
test('Shows a version number', async function ({page}) {

View File

@ -0,0 +1,72 @@
import {expect, test} from "@playwright/test";
import {loginToAdmin} from "../helper/adminhelper";
test.describe('admin update page', () => {
test.beforeEach(async ({page}) => {
await loginToAdmin(page, 'admin', 'changeme1');
});
test('exposes the update nav link', async ({page}) => {
await page.goto('http://localhost:9001/admin/');
// Bell-icon link with i18nKey "update.page.title" → label "Etherpad updates".
const link = page.getByRole('link', {name: /etherpad updates/i});
await expect(link).toBeVisible({timeout: 30000});
});
test('update page renders current version when status fetch returns valid payload', async ({page}) => {
// Stub the status endpoint so the test does not depend on real GitHub state.
await page.route('**/admin/update/status', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
currentVersion: '2.7.1',
latest: null,
lastCheckAt: null,
installMethod: 'git',
tier: 'notify',
policy: null,
vulnerableBelow: [],
}),
});
});
await page.goto('http://localhost:9001/admin/update');
// h1 from <Trans i18nKey="update.page.title"/> → "Etherpad updates"
await expect(page.getByRole('heading', {name: /etherpad updates/i})).toBeVisible({timeout: 30000});
// Current-version <dd> shows 2.7.1
await expect(page.getByText('2.7.1').first()).toBeVisible();
// up-to-date message because latest is null
await expect(page.getByText(/running the latest version/i)).toBeVisible();
});
test('banner appears when latest > current', async ({page}) => {
await page.route('**/admin/update/status', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
currentVersion: '2.7.1',
latest: {
version: '2.7.2',
tag: 'v2.7.2',
body: 'Some changes.',
publishedAt: '2026-04-25T00:00:00Z',
prerelease: false,
htmlUrl: 'https://github.com/ether/etherpad/releases/tag/v2.7.2',
},
lastCheckAt: '2026-04-25T00:00:00Z',
installMethod: 'git',
tier: 'notify',
policy: {canNotify: true, canManual: false, canAuto: false, canAutonomous: false, reason: 'install-method-not-writable'},
vulnerableBelow: [],
}),
});
});
await page.goto('http://localhost:9001/admin/');
// Banner copy: "Update available" + "Etherpad 2.7.2 is available (you are running 2.7.1)."
await expect(page.getByText(/update available/i).first()).toBeVisible({timeout: 30000});
await expect(page.getByText(/2\.7\.2/).first()).toBeVisible();
});
});

View File

@ -0,0 +1,47 @@
import {expect, test} from '@playwright/test';
const padUrl = (id = `test-${Date.now()}-${Math.floor(Math.random() * 1e6)}`) =>
`http://localhost:9001/p/${id}`;
test.describe('pad version badge', () => {
test('hidden when /api/version-status returns outdated:null', async ({page}) => {
await page.route('**/api/version-status', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({outdated: null}),
}));
await page.goto(padUrl());
const badge = page.locator('#version-badge');
// The badge is rendered hidden (display:none) and stays hidden.
await expect(badge).toBeHidden({timeout: 30000});
});
test('shows severe text when outdated=severe', async ({page}) => {
await page.route('**/api/version-status', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({outdated: 'severe'}),
}));
await page.goto(padUrl());
const badge = page.locator('#version-badge');
await expect(badge).toBeVisible({timeout: 30000});
await expect(badge).toContainText(/severely outdated/i);
await expect(badge).toHaveAttribute('data-level', 'severe');
});
test('shows vulnerable text when outdated=vulnerable', async ({page}) => {
await page.route('**/api/version-status', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({outdated: 'vulnerable'}),
}));
await page.goto(padUrl());
const badge = page.locator('#version-badge');
await expect(badge).toBeVisible({timeout: 30000});
await expect(badge).toContainText(/security issues/i);
await expect(badge).toHaveAttribute('data-level', 'vulnerable');
});
});