From e39dbde887ae7691d097eeab648cfebb7f9628b0 Mon Sep 17 00:00:00 2001 From: John McLear Date: Fri, 1 May 2026 20:02:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(updater):=20tier=201=20=E2=80=94=20notify?= =?UTF-8?q?=20admin=20and=20pad=20users=20of=20available=20updates=20(#760?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * feat(updater): add semver helpers and vulnerable-below parser Co-Authored-By: Claude Sonnet 4.6 * 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 * 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 * fix(updater): validate release fields and preserve ETag on prerelease Co-Authored-By: Claude Sonnet 4.6 * feat(updater): add email cadence decider Co-Authored-By: Claude Sonnet 4.6 * 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 * 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 * feat(updater): add /admin/update/status and /api/version-status endpoints Co-Authored-By: Claude Sonnet 4.6 * 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 * 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 * 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) * 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 //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) --------- Co-authored-by: Claude Sonnet 4.6 --- CHANGELOG.md | 10 + admin/src/App.tsx | 5 +- admin/src/components/UpdateBanner.tsx | 35 + admin/src/index.css | 27 + admin/src/main.tsx | 2 + admin/src/pages/UpdatePage.tsx | 103 + admin/src/store/store.ts | 25 +- doc/admin/updates.md | 83 + .../2026-04-25-auto-update-pr1-notify.md | 2335 +++++++++++++++++ .../specs/2026-04-25-auto-update-design.md | 350 +++ settings.json.docker | 22 + settings.json.template | 26 + src/ep.json | 14 + src/locales/en.json | 14 + src/node/hooks/express/updateStatus.ts | 94 + src/node/updater/InstallMethodDetector.ts | 46 + src/node/updater/Notifier.ts | 88 + src/node/updater/UpdatePolicy.ts | 42 + src/node/updater/VersionChecker.ts | 87 + src/node/updater/index.ts | 149 ++ src/node/updater/state.ts | 77 + src/node/updater/types.ts | 75 + src/node/updater/versionCompare.ts | 53 + src/node/utils/Settings.ts | 32 + src/static/css/pad.css | 15 + src/static/js/pad.ts | 2 + src/static/js/pad_version_badge.ts | 46 + src/templates/pad.html | 1 + .../updater/InstallMethodDetector.test.ts | 50 + .../specs/updater/Notifier.test.ts | 95 + .../specs/updater/UpdatePolicy.test.ts | 64 + .../specs/updater/VersionChecker.test.ts | 96 + .../backend-new/specs/updater/state.test.ts | 119 + .../specs/updater/versionCompare.test.ts | 92 + src/tests/backend/specs/updateStatus.ts | 144 + .../admin-spec/admintroubleshooting.spec.ts | 3 +- .../admin-spec/update-banner.spec.ts | 72 + .../specs/pad-version-badge.spec.ts | 47 + 38 files changed, 4636 insertions(+), 4 deletions(-) create mode 100644 admin/src/components/UpdateBanner.tsx create mode 100644 admin/src/pages/UpdatePage.tsx create mode 100644 doc/admin/updates.md create mode 100644 docs/superpowers/plans/2026-04-25-auto-update-pr1-notify.md create mode 100644 docs/superpowers/specs/2026-04-25-auto-update-design.md create mode 100644 src/node/hooks/express/updateStatus.ts create mode 100644 src/node/updater/InstallMethodDetector.ts create mode 100644 src/node/updater/Notifier.ts create mode 100644 src/node/updater/UpdatePolicy.ts create mode 100644 src/node/updater/VersionChecker.ts create mode 100644 src/node/updater/index.ts create mode 100644 src/node/updater/state.ts create mode 100644 src/node/updater/types.ts create mode 100644 src/node/updater/versionCompare.ts create mode 100644 src/static/js/pad_version_badge.ts create mode 100644 src/tests/backend-new/specs/updater/InstallMethodDetector.test.ts create mode 100644 src/tests/backend-new/specs/updater/Notifier.test.ts create mode 100644 src/tests/backend-new/specs/updater/UpdatePolicy.test.ts create mode 100644 src/tests/backend-new/specs/updater/VersionChecker.test.ts create mode 100644 src/tests/backend-new/specs/updater/state.test.ts create mode 100644 src/tests/backend-new/specs/updater/versionCompare.test.ts create mode 100644 src/tests/backend/specs/updateStatus.ts create mode 100644 src/tests/frontend-new/admin-spec/update-banner.spec.ts create mode 100644 src/tests/frontend-new/specs/pad-version-badge.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7869f0a94..f652e6157 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/admin/src/App.tsx b/admin/src/App.tsx index ae23ab3d3..27d5a2ae3 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 = () => {
  • Communication
  • +
  • @@ -112,6 +114,7 @@ export const App = () => { setSidebarOpen(!sidebarOpen) }}>
    +
    diff --git a/admin/src/components/UpdateBanner.tsx b/admin/src/components/UpdateBanner.tsx new file mode 100644 index 000000000..36f1faddc --- /dev/null +++ b/admin/src/components/UpdateBanner.tsx @@ -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 ( +
    + {' '} + + + {' '} + {t('update.banner.cta')} +
    + ); +}; diff --git a/admin/src/index.css b/admin/src/index.css index 3190b153d..64eae3ccc 100644 --- a/admin/src/index.css +++ b/admin/src/index.css @@ -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; } diff --git a/admin/src/main.tsx b/admin/src/main.tsx index 5efc26de6..c7dcc456b 100644 --- a/admin/src/main.tsx +++ b/admin/src/main.tsx @@ -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( <>}> @@ -22,6 +23,7 @@ const router = createBrowserRouter(createRoutesFromElements( }/> }/> }/> + }/> }/> diff --git a/admin/src/pages/UpdatePage.tsx b/admin/src/pages/UpdatePage.tsx new file mode 100644 index 000000000..0d669a446 --- /dev/null +++ b/admin/src/pages/UpdatePage.tsx @@ -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(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
    {t('admin.loading', {defaultValue: 'Loading...'})}
    ; + } + if (fetchState.kind === 'disabled') { + return ( +
    +

    +

    {t('update.page.disabled', {defaultValue: 'Update checks are disabled (updates.tier = "off").'})}

    +
    + ); + } + if (fetchState.kind === 'unauthorized') { + return ( +
    +

    +

    {t('update.page.unauthorized', {defaultValue: 'You are not authorised to view update status.'})}

    +
    + ); + } + if (fetchState.kind === 'error' || !us) { + const status = fetchState.kind === 'error' ? fetchState.status : 0; + return ( +
    +

    +

    {t('update.page.error', {defaultValue: 'Could not load update status (status {{status}}).', status})}

    +
    + ); + } + + const upToDate = !us.latest || us.currentVersion === us.latest.version; + + return ( +
    +

    +
    +
    +
    {us.currentVersion}
    +
    +
    {us.latest ? us.latest.version : '—'}
    +
    +
    {us.lastCheckAt ?? '—'}
    +
    +
    {us.installMethod}
    +
    +
    {us.tier}
    +
    + {upToDate ? ( +

    + ) : us.latest ? ( + <> +

    +
    {us.latest.body}
    +

    {us.latest.htmlUrl}

    + + ) : null} +
    + ); +}; + +export default UpdatePage; diff --git a/admin/src/store/store.ts b/admin/src/store/store.ts index 1ccc036f4..f3748f47c 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 000000000..852912de3 --- /dev/null +++ b/doc/admin/updates.md @@ -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 `` 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 000000000..ac3aefc00 --- /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