Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
07e75b1
docs(updater): add four-tier auto-update design spec
JohnMcLear Apr 25, 2026
db2fcd8
docs(updater): add PR 1 (Tier 1 notify) implementation plan
JohnMcLear Apr 25, 2026
3ba019d
feat(updater): add shared types for auto-update subsystem
JohnMcLear Apr 25, 2026
7bbda2a
feat(updater): clarify OutdatedLevel and EMPTY_STATE doc, drop path h…
JohnMcLear Apr 25, 2026
59c96b9
feat(updater): add semver helpers and vulnerable-below parser
JohnMcLear Apr 25, 2026
39c3820
fix(updater): tighten semver regex to reject four-part versions
JohnMcLear Apr 25, 2026
7610f2a
feat(updater): add state persistence with schema validation
JohnMcLear Apr 25, 2026
dc14c0e
fix(updater): reject null email and array latest in state validation
JohnMcLear Apr 25, 2026
892490e
feat(updater): add install-method detector with override
JohnMcLear Apr 25, 2026
fe8978f
feat(updater): add policy evaluator
JohnMcLear Apr 25, 2026
a43a31e
feat(updater): add GitHub Releases checker with ETag support
JohnMcLear Apr 25, 2026
dac1b03
fix(updater): validate release fields and preserve ETag on prerelease
JohnMcLear Apr 25, 2026
1d7e68c
feat(updater): add email cadence decider
JohnMcLear Apr 25, 2026
571d648
fix(updater): tagChanged email fires regardless of cadence; drop unus…
JohnMcLear Apr 25, 2026
b695d01
feat(settings): add updates.* and adminEmail settings
JohnMcLear Apr 25, 2026
732894c
feat(updater): wire boot hook and periodic checker
JohnMcLear Apr 25, 2026
70a04f8
feat(updater): add /admin/update/status and /api/version-status endpo…
JohnMcLear Apr 25, 2026
abc9a05
i18n(updater): add english strings for update banner, page, and pad b…
JohnMcLear Apr 25, 2026
46b6129
feat(updater): add pad footer badge for severe/vulnerable status
JohnMcLear Apr 25, 2026
fa97f20
feat(admin-ui): add update banner, page, and nav link
JohnMcLear Apr 25, 2026
fd13ebd
test(updater): add Playwright specs for admin banner/page and pad badge
JohnMcLear Apr 25, 2026
6327502
docs(updater): document tier 1 settings, badge, email cadence
JohnMcLear Apr 25, 2026
6cac3a5
refactor(updater): dedupe helpers, fix misleading log, add banner sty…
JohnMcLear Apr 25, 2026
0f9f6af
fix(updater): address review feedback — async wrap, tier=off skip, po…
JohnMcLear Apr 27, 2026
774fca7
fix(updater): address Qodo round-2 review feedback
JohnMcLear Apr 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion admin/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -105,13 +106,15 @@ 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>
<button id="icon-button" onClick={() => {
setSidebarOpen(!sidebarOpen)
}}><LucideMenu/></button>
<div className="innerwrapper">
<UpdateBanner/>
<Outlet/>
</div>
</div>
Expand Down
35 changes: 35 additions & 0 deletions admin/src/components/UpdateBanner.tsx
Original file line number Diff line number Diff line change
@@ -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'})
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Best would be to use tanstack query. If you use fetch in a call it is mostly always wrong see https://react.dev/reference/react/useEffect#fetching-data-with-effects

.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;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you useSuspense Query you can save the manual return null and show with suspense boundary that the ui is loading e.g. with a loading spinner

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>
);
};
27 changes: 27 additions & 0 deletions admin/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
2 changes: 2 additions & 0 deletions admin/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/>}>
Expand All @@ -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></>
Expand Down
103 changes: 103 additions & 0 deletions admin/src/pages/UpdatePage.tsx
Original file line number Diff line number Diff line change
@@ -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'});
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as this :)


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;
Comment on lines +12 to +73
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Updates off hangs admin page 🐞 Bug ≡ Correctness

If the status endpoint is missing or returns non-OK (notably when updates.tier='off' disables route
registration), the admin UpdatePage never initiates a fetch and stays stuck on "Loading..."
indefinitely.
Agent Prompt
## Issue description
The admin Update page can become unusable (permanent "Loading...") if `/admin/update/status` is not available or not OK (404 when `updates.tier === 'off'`, 401/403 when gated, network errors).

## Issue Context
- Server disables updater HTTP surface when tier is off.
- UI relies on UpdateBanner's background fetch to populate global store and does nothing on non-OK responses.

## Fix Focus Areas
- admin/src/pages/UpdatePage.tsx[4-12]
- admin/src/components/UpdateBanner.tsx[11-18]
- admin/src/App.tsx[103-118]
- src/node/hooks/express/updateStatus.ts[36-78]

## Implementation notes
Choose one coherent approach:
1) **UI-driven resilience (recommended):**
   - Make `UpdatePage` fetch `/admin/update/status` itself (and set store) and render explicit states for 404 (updates disabled), 401/403 (not authorized), and generic errors.
   - Update `UpdateBanner` to either (a) set an explicit error/disabled state on non-OK, or (b) keep it best-effort but ensure `/update` page is self-sufficient.

2) **Server-driven contract:**
   - Keep the route registered even when tier is off, but return a minimal payload indicating `tier: 'off'` (and perhaps `policy: null`) so the UI can render a disabled message without hanging.

Also consider hiding the Update nav link once the UI knows updates are disabled (or not authorized).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


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;
25 changes: 23 additions & 2 deletions admin/src/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
}


Expand All @@ -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 changes: 83 additions & 0 deletions doc/admin/updates.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading