Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
49ed39d
docs(updater): add four-tier auto-update design spec
JohnMcLear Apr 25, 2026
dcc5ba3
docs(updater): add PR 1 (Tier 1 notify) implementation plan
JohnMcLear Apr 25, 2026
babc4fc
feat(updater): add shared types for auto-update subsystem
JohnMcLear Apr 25, 2026
afb8f06
feat(updater): clarify OutdatedLevel and EMPTY_STATE doc, drop path h…
JohnMcLear Apr 25, 2026
ace84c1
feat(updater): add semver helpers and vulnerable-below parser
JohnMcLear Apr 25, 2026
288b63e
fix(updater): tighten semver regex to reject four-part versions
JohnMcLear Apr 25, 2026
ade42e4
feat(updater): add state persistence with schema validation
JohnMcLear Apr 25, 2026
9abb899
fix(updater): reject null email and array latest in state validation
JohnMcLear Apr 25, 2026
bc70f1b
feat(updater): add install-method detector with override
JohnMcLear Apr 25, 2026
a892b6e
feat(updater): add policy evaluator
JohnMcLear Apr 25, 2026
8ddf14c
feat(updater): add GitHub Releases checker with ETag support
JohnMcLear Apr 25, 2026
dac75fd
fix(updater): validate release fields and preserve ETag on prerelease
JohnMcLear Apr 25, 2026
7798653
feat(updater): add email cadence decider
JohnMcLear Apr 25, 2026
a448875
fix(updater): tagChanged email fires regardless of cadence; drop unus…
JohnMcLear Apr 25, 2026
66919b0
feat(settings): add updates.* and adminEmail settings
JohnMcLear Apr 25, 2026
eeb81d2
feat(updater): wire boot hook and periodic checker
JohnMcLear Apr 25, 2026
dd841ee
feat(updater): add /admin/update/status and /api/version-status endpo…
JohnMcLear Apr 25, 2026
cf65dd7
i18n(updater): add english strings for update banner, page, and pad b…
JohnMcLear Apr 25, 2026
7415d23
feat(updater): add pad footer badge for severe/vulnerable status
JohnMcLear Apr 25, 2026
04a15e8
feat(admin-ui): add update banner, page, and nav link
JohnMcLear Apr 25, 2026
190584c
test(updater): add Playwright specs for admin banner/page and pad badge
JohnMcLear Apr 25, 2026
26d7a0b
docs(updater): document tier 1 settings, badge, email cadence
JohnMcLear Apr 25, 2026
b196bf7
refactor(updater): dedupe helpers, fix misleading log, add banner sty…
JohnMcLear Apr 25, 2026
110bddd
fix(updater): address review feedback — async wrap, tier=off skip, po…
JohnMcLear Apr 27, 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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
# Unreleased

### 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'})
.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>
);
};
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
40 changes: 40 additions & 0 deletions admin/src/pages/UpdatePage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {Trans, useTranslation} from 'react-i18next';
import {useStore} from '../store/store';

export const UpdatePage = () => {
const {t} = useTranslation();
const us = useStore((s) => s.updateStatus);

if (!us) return <div>{t('admin.loading', {defaultValue: 'Loading...'})}</div>;

const upToDate = !us.latest || us.currentVersion === us.latest.version;
Comment on lines +4 to +10
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