diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4c23547eb78..f3cc1381ff4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.1
### Notable enhancements and fixes
diff --git a/admin/src/App.tsx b/admin/src/App.tsx
index ae23ab3d340..27d5a2ae367 100644
--- a/admin/src/App.tsx
+++ b/admin/src/App.tsx
@@ -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 = () => {
+ );
+};
+
+export default UpdatePage;
diff --git a/admin/src/store/store.ts b/admin/src/store/store.ts
index 1ccc036f491..f3748f47cd4 100644
--- a/admin/src/store/store.ts
+++ b/admin/src/store/store.ts
@@ -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()((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}),
}));
diff --git a/doc/admin/updates.md b/doc/admin/updates.md
new file mode 100644
index 00000000000..74b133794c7
--- /dev/null
+++ b/doc/admin/updates.md
@@ -0,0 +1,81 @@
+# 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"
+ },
+ "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. |
+| `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 `` 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//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.
diff --git a/docs/superpowers/plans/2026-04-25-auto-update-pr1-notify.md b/docs/superpowers/plans/2026-04-25-auto-update-pr1-notify.md
new file mode 100644
index 00000000000..ac3aefc006f
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-25-auto-update-pr1-notify.md
@@ -0,0 +1,2335 @@
+# Auto-Update PR 1 — Tier 1 (Notify) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Ship Tier 1 of the four-tier auto-update feature: every Etherpad admin sees a banner on `/admin` when their instance is behind, pad users see a discreet badge only when severely outdated or running a flagged-vulnerable version, and the configured `adminEmail` receives an escalating-cadence nudge. **No execution code in this PR** — that lands in PR 2 (Tier 2 manual).
+
+**Architecture:** A new `src/node/updater/` subsystem. Periodic poll of GitHub Releases, in-memory + on-disk state at `var/update-state.json`, two HTTP endpoints (`GET /admin/update/status`, `GET /api/version-status`), an admin-UI banner + read-only update page, a pad-UI footer badge, and a `Notifier` that emails on first detection of severe/vulnerable status (escalating: weekly while vulnerable, monthly while severe). Settings additions: `updates.*` block (mainly `tier`, default `"notify"`) and a top-level `adminEmail`.
+
+**Tech Stack:** Node 18+ (native `fetch`), TypeScript, Express 5, vitest (unit), mocha (legacy backend integration), Playwright (UI), React + react-router + i18next (admin UI), pnpm monorepo.
+
+**Spec:** `/home/jose/etherpad/etherpad-lite/docs/superpowers/specs/2026-04-25-auto-update-design.md`
+
+**Conventions:**
+- All pushes land on `johnmclear/etherpad-lite` — never `ether/etherpad-lite` directly.
+- Working dir: `/home/jose/etherpad/etherpad-lite`.
+- Backend unit tests use **vitest** under `src/tests/backend-new/specs/`; integration / API tests use **mocha** under `src/tests/backend/specs/`. The differences matter: vitest uses `import {describe, it, expect} from 'vitest'`, mocha uses `describe`/`it` globals + `assert`.
+- Run unit tests: `cd src && pnpm test:vitest -- run tests/backend-new/specs/updater/`.
+- Run integration tests: `cd src && pnpm test -- --grep ""`.
+- Run admin Playwright: `cd src && pnpm test-admin`.
+- Run pad Playwright: `cd src && pnpm test-ui`.
+- Run type-check: `pnpm ts-check` from repo root.
+- Commit messages follow the existing style (e.g. `feat(updater): ...`, `test(updater): ...`).
+- Frequent commits: every passing test → commit.
+
+---
+
+## Task 0: Branch off fork
+
+**Files:** none.
+
+- [ ] **Step 1: Confirm clean working tree**
+
+```bash
+cd /home/jose/etherpad/etherpad-lite
+git status
+```
+
+Expected: working tree clean, current branch may be unrelated. If there are uncommitted changes other than the spec doc, stop and surface to the user.
+
+- [ ] **Step 2: Make sure `develop` is up-to-date from `origin` (ether)**
+
+```bash
+git fetch origin develop
+```
+
+- [ ] **Step 3: Create branch off origin/develop**
+
+```bash
+git checkout -b feat/auto-update-tier1 origin/develop
+```
+
+- [ ] **Step 4: Cherry-pick the design spec onto the new branch**
+
+```bash
+# The spec was written into the working tree but not committed.
+# It should still be present after the checkout because it's untracked.
+git status
+# Expect: "Untracked files: docs/superpowers/specs/2026-04-25-auto-update-design.md"
+git add docs/superpowers/specs/2026-04-25-auto-update-design.md
+git commit -m "docs(updater): add four-tier auto-update design spec"
+```
+
+If `git status` after step 3 doesn't show the spec as untracked (e.g., because checkout placed it at a different path or removed it), Read the file at `/home/jose/etherpad/etherpad-lite/docs/superpowers/specs/2026-04-25-auto-update-design.md` to verify it exists, then add and commit it.
+
+- [ ] **Step 5: Add this plan to the same first commit (amend)**
+
+```bash
+git add docs/superpowers/plans/2026-04-25-auto-update-pr1-notify.md
+git commit --amend --no-edit
+```
+
+- [ ] **Step 6: Push to fork**
+
+```bash
+git push -u fork feat/auto-update-tier1
+```
+
+---
+
+## Task 1: Shared types module
+
+Pure-types module. No tests needed (compiler is the test).
+
+**Files:**
+- Create: `src/node/updater/types.ts`
+
+> **Path note:** From the repo root `/home/jose/etherpad/etherpad-lite`, source files live under `src/node/`, `src/static/`, `src/locales/`, etc. Tests live under `src/tests/backend/`, `src/tests/backend-new/`, `src/tests/frontend-new/`. The `src/` directory IS the `ep_etherpad-lite` pnpm workspace package — when running test/dev/build scripts via pnpm, `cd src` first (or use `pnpm --filter ep_etherpad-lite run