From 26adb9994d575b0fc05ad0d012b483fb7aa4ee0d Mon Sep 17 00:00:00 2001 From: John McLear Date: Mon, 18 May 2026 11:09:43 +0100 Subject: [PATCH 01/17] docs: design spec for #7799 outdated-notice redesign Per-pad first-author gating, dismissable gritter, minor-or-more rule, drop vulnerable UI. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...6-05-18-outdated-notice-redesign-design.md | 278 ++++++++++++++++++ 1 file changed, 278 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-18-outdated-notice-redesign-design.md diff --git a/docs/superpowers/specs/2026-05-18-outdated-notice-redesign-design.md b/docs/superpowers/specs/2026-05-18-outdated-notice-redesign-design.md new file mode 100644 index 00000000000..6bc08756457 --- /dev/null +++ b/docs/superpowers/specs/2026-05-18-outdated-notice-redesign-design.md @@ -0,0 +1,278 @@ +# Outdated-version notice redesign + +**Issue:** [ether/etherpad#7799](https://github.com/ether/etherpad/issues/7799) +**Date:** 2026-05-18 +**Status:** Design + +## Problem + +The pad-side "Etherpad on this server is severely outdated. Tell your admin." banner is shown to every visitor of every pad, persistently, whenever the running server is at least one major version behind the latest published release. The reporter (a server admin) says: + +> "it's inappropriate to inform users of a site about maintenance tasks that they don't understand or have context to resolve. It wastes users' time by having them try and contact me, and it wastes my time by having to respond." + +In addition to the social problem, the current implementation triggers on develop checkouts and on minor-only deltas in some upstream-version states, and has been observed intercepting chat-icon clicks (z-index 9999, bottom-right) in plugin test matrices pinned to older cores. + +## Goals + +1. The notice is shown **only to the pad's first author** (the author whose ID occupies position 0 in the pad's attribute pool — i.e. whoever made the first edit). +2. The notice is **non-persistent**: a dismissable `$.gritter` toast, auto-fading after 8s, rather than an always-visible badge. +3. The notice fires **only on minor-or-more behind** (e.g. 3.1.0 → 3.2.0, 2.7.3 → 3.0.0). Patch-only deltas (3.0.1 → 3.0.2) never fire. +4. The notice never fires when `current >= latest` (covers the develop-after-bump case). +5. The `vulnerable-below` UI is **dropped entirely**, along with the directive parser and state field. The vulnerable enum is gone from the API. + +## Non-goals + +- No new settings flag. `updates.tier = 'off'` remains the kill-switch. +- No translations of new strings in this PR. A `TODO(i18n)` placeholder is carried forward — strings are hard-coded English, mirroring the current state of the badge code. A follow-up adds `pad.outdatedNotice.*` keys once the html10n key set is set up to be shared with the pad-side bundle. + +## Architecture + +``` +Browser (pad load, after CLIENT_VARS) Server +───────────────────────────────────── ────── +pad.ts → maybeShowOutdatedNotice() + │ + ├─ GET /api/version-status?padId= (cookies: express_sid) + │ + │ loadState(stateFilePath()) + │ │ + │ ├─ no latest → {outdated:null,isFirstAuthor:false} + │ ├─ current >= latest → {outdated:null,isFirstAuthor:false} + │ ├─ same major + minor differs → next step + │ ├─ major differs → next step + │ └─ patch-only behind → {outdated:null,isFirstAuthor:false} + │ next step: + │ resolve req-author via express_sid + │ load pad → firstAuthor = pool position 0 + │ if req-author === firstAuthor + │ return {outdated:'minor',isFirstAuthor:true} + │ else + │ return {outdated:null,isFirstAuthor:false} + │ + ├─ outdated:'minor' && isFirstAuthor + │ → $.gritter.add({class_name:'outdated-notice', position:'bottom', + │ sticky:false, time:8000, title, text}) + └─ else + → no-op +``` + +The endpoint shape collapses to a single enum (`'minor' | null`) plus a per-request `isFirstAuthor` boolean. The server never returns a positive `outdated` value to a non-first-author requester — there is no client-side "the answer is minor, but show it conditionally" path. Operational signal does not leak to ordinary pad visitors. + +## Server changes + +### `src/node/updater/versionCompare.ts` + +- **Add** `isMinorOrMoreBehind(current: string, latest: string): boolean` — `true` iff `parseSemver(current).major < parseSemver(latest).major`, or majors equal and `current.minor < latest.minor`. Patch-only delta returns `false`. Returns `false` on parse failure of either side. +- **Delete** `isMajorBehind`, `isVulnerable`, `parseVulnerableBelow`, the `VULN_RE` regex, and the `VulnerableBelowDirective` import. + +### `src/node/updater/types.ts` + +- **Delete** `VulnerableBelowDirective`. +- **Delete** `UpdaterState.vulnerableBelow` field. + +### `src/node/updater/state.ts` + +- Stop reading and stop writing `vulnerableBelow`. Existing state files with the field still parse — the loader ignores unknown keys. No migration needed; the field naturally drops on next write. + +### `src/node/updater/VersionChecker.ts` + +- Remove the release-notes scraping that called `parseVulnerableBelow`. The rest of the check (current vs latest tag) is unchanged. + +### `src/node/hooks/express/updateStatus.ts` (load-bearing change) + +```ts +interface OutdatedResponse { + outdated: 'minor' | null; + isFirstAuthor: boolean; +} + +const EMPTY: OutdatedResponse = {outdated: null, isFirstAuthor: false}; + +const cache = new LRU(1000); +const inFlight = new Map>(); +const TTL_MS = 60 * 1000; + +const firstAuthorOf = (pad: Pad): string | null => { + const num2attrib = pad.pool.numToAttrib; + const keys = Object.keys(num2attrib).map(Number).sort((a, b) => a - b); + for (const k of keys) { + const a = num2attrib[k]; + if (a && a[0] === 'author' && typeof a[1] === 'string' && a[1] !== '') return a[1]; + } + return null; +}; + +const computeOutdated = async (padId: string | null, authorId: string | null): Promise => { + const state = await loadState(stateFilePath()); + if (!state.latest) return EMPTY; + const current = getEpVersion(); + if (!isMinorOrMoreBehind(current, state.latest.version)) return EMPTY; + if (!padId || !authorId) return EMPTY; + if (!(await padManager.doesPadExist(padId))) return EMPTY; + const pad = await padManager.getPad(padId, null); + if (firstAuthorOf(pad) !== authorId) return EMPTY; + return {outdated: 'minor', isFirstAuthor: true}; +}; + +app.get('/api/version-status', wrapAsync(async (req, res) => { + const padId = typeof req.query.padId === 'string' ? req.query.padId : null; + const authorId = await resolveRequestAuthor(req); // express_sid → session → author, null on miss + const key = `${padId ?? ''}|${authorId ?? ''}`; + const now = Date.now(); + + const hit = cache.get(key); + if (hit && now - hit.at <= TTL_MS) { + res.json(hit.value); + return; + } + let flight = inFlight.get(key); + if (!flight) { + flight = computeOutdated(padId, authorId).finally(() => inFlight.delete(key)); + inFlight.set(key, flight); + } + const value = await flight; + cache.set(key, {value, at: now}); + res.json(value); +})); +``` + +- `resolveRequestAuthor(req)` is a small helper that reads `req.cookies.express_sid`, calls `sessionStore.get(sid)` (the same store used by the express-session middleware), and returns `session?.user?.author ?? null`. On any failure path it returns `null` — the request is then treated as anonymous and gets `EMPTY`. +- `padId` is validated through `padutils.validateRequest({padID: padId})` before being passed to `padManager`. Validation failures map to `EMPTY`, not 400 — keeping the endpoint quiet about whether the pad exists. +- LRU cap of 1000 entries bounds memory on busy servers; entries expire by TTL anyway. +- Single-flight per cache key collapses bursts at expiry into one disk read. +- `_resetBadgeCacheForTests()` clears both `cache` and `inFlight`. + +### `src/node/hooks/express/openapi-admin.ts` + +- Update the OpenAPI doc for `/api/version-status`: + - Add `padId` query parameter (string, optional, must match Etherpad's pad-id format). + - Update response schema: `{outdated: 'minor' | null, isFirstAuthor: boolean}`. + - Drop the `severe` and `vulnerable` enum values. + +## Client changes + +### `src/templates/pad.html` + +- Delete line 648 (``). + +### `src/static/css/pad.css` + +- Delete the `#version-badge { … }` rule block (lines ~119–131). Gritter's stock styling carries the notice; no new CSS is added — matches `.privacy-notice` precedent. + +### `src/static/js/pad_version_badge.ts` → renamed to `pad_outdated_notice.ts` + +```ts +'use strict'; + +interface OutdatedResponse { + outdated: 'minor' | null; + isFirstAuthor: boolean; +} + +const apiBasePath = (): string => { + if (typeof window === 'undefined') return '/'; + return new URL('..', window.location.href).pathname; +}; + +const currentPadId = (): string | null => { + const id = (window as any).clientVars?.padId; + return typeof id === 'string' && id.length > 0 ? id : null; +}; + +export const maybeShowOutdatedNotice = async (): Promise => { + const padId = currentPadId(); + if (!padId) return; + const $ = (window as any).$; + if (!$ || !$.gritter || typeof $.gritter.add !== 'function') return; + + try { + const url = `${apiBasePath()}api/version-status?padId=${encodeURIComponent(padId)}`; + const res = await fetch(url, {credentials: 'same-origin'}); + if (!res.ok) return; + const data = (await res.json()) as OutdatedResponse; + if (data.outdated !== 'minor' || !data.isFirstAuthor) return; + + // TODO(i18n): switch to html10n once `pad.outdatedNotice.*` keys land. + $.gritter.add({ + title: 'Etherpad update available', + text: 'A newer version of Etherpad has been released. Consider updating this server.', + sticky: false, + position: 'bottom', + class_name: 'outdated-notice', + time: 8000, + }); + } catch { + /* never block pad load */ + } +}; +``` + +- Module no longer self-bootstraps on `DOMContentLoaded`; it needs `clientVars.padId`, which is only present after `CLIENT_VARS` arrives. +- Invocation site: `src/static/js/pad.ts`, in the same post-`handleClientVars` block where `showPrivacyBannerIfEnabled` is called. +- No `localStorage` write — dismissal is per-session (gritter X-click clears DOM; reload re-fetches and re-shows if still outdated). + +## Tests + +### Backend — `src/tests/backend/specs/api/updateStatus.spec.ts` (rewrite affected blocks) + +- Drop `describe('vulnerable …')` cases entirely. +- Replace `describe('severe / isMajorBehind …')` with `describe('isMinorOrMoreBehind …')` covering: + - patch-only delta returns `false` (2.7.3 vs 2.7.4) + - minor delta returns `true` (2.7.3 vs 2.8.0) + - major delta returns `true` (2.7.3 vs 3.0.0) + - equal versions return `false` + - current newer than latest returns `false` (develop-on-bumped-package.json case) + - unparseable input on either side returns `false` +- New `describe('GET /api/version-status')` cases: + - no `state.latest` → `{outdated:null,isFirstAuthor:false}` + - current ≥ latest, with valid padId+author → `EMPTY` + - padId omitted → `EMPTY` (no leak) + - authorId resolves but isn't pool position 0 → `EMPTY` + - current is minor-behind AND requester is pool position 0 → `{outdated:'minor',isFirstAuthor:true}` + - current is patch-behind, requester IS pool position 0 → `EMPTY` + - cache hit within 60s for same `padId|authorId` does NOT re-call `loadState` (spy assertion) + - two different `padId|authorId` pairs are cached independently + - with the LRU cap forced low (test-only setter), the oldest entry is evicted first + +Each case calls `_resetBadgeCacheForTests()` in `beforeEach`. + +### Backend — `firstAuthorOf` unit test (new file next to the helper) + +- empty pad → `null` +- single-author pad → that author +- A edited first then B → A +- pool with non-author attribs interleaved at low numeric keys → still returns the lowest `['author', X]` +- pool with `['author', '']` placeholder → skipped; returns the next real author + +### Frontend — `src/tests/frontend-new/specs/outdated_notice.spec.ts` (new, mirrors `privacy_banner.spec.ts`) + +- stub `/api/version-status` to `{outdated:null,…}` → no `.gritter-item.outdated-notice` after pad load +- stub to `{outdated:'minor', isFirstAuthor:false}` → no gritter (client belt-and-braces guard) +- stub to `{outdated:'minor', isFirstAuthor:true}` → `.gritter-item.outdated-notice` appears, body text matches, dismisses on X-click +- stub returning 500 → no DOM injection, no user-visible console error +- after ~9s with positive stub → gritter auto-faded (asserts `sticky:false` + `time:8000` wiring) + +### Files removed entirely + +- Any standalone `versionBadge.spec.ts` fixture file (merged into `updateStatus.spec.ts`). +- Any fixture referencing `vulnerableBelow`. + +### Verification gates (mandatory before claiming done) + +- `pnpm --filter ep_etherpad-lite test:vitest` clean (backend). +- `pnpm exec playwright test outdated_notice` clean under `xvfb-run` (frontend). +- Manual: load a pad on the dev server (`http://localhost.lan:9003/p/test`) with `var/update.state.json` pinned to a higher `latest.version` — gritter appears once for first-author in incognito-A, absent in incognito-B (second visitor). + +## Docs / settings / build + +- `doc/api/http_api.md` (and `.adoc` if present) — update `/api/version-status` entry: new shape, new `padId` query param, note that positive results are scoped to first-author. +- `doc/api/updater.md` (or the relevant `updates.tier` section in `doc/settings.md`) — drop the paragraph(s) on the vulnerable-below directive and the persistent banner UI. +- `CHANGELOG.md` (Unreleased) — one entry: "Outdated-version notice redesigned per #7799 — transient gritter, first-author only, minor-or-major behind only. The persistent banner, `severe` enum, and `vulnerable-below` directive scraping are removed." +- No settings-schema changes. `updates.tier = 'off'` remains the full kill-switch. +- `vite.config.ts` (and any other bundle config) — rename `pad_version_badge` entries to `pad_outdated_notice`. Grep to confirm no admin-bundle reference exists (shouldn't; pad-only). + +## Risk / open questions + +- **Develop-on-stale-package.json.** Today develop's `package.json` reads `2.7.3` while the latest npm release is newer. Under this design, the notice still triggers on develop because `current < latest`. The expected operational practice is for the post-release bump of develop's `package.json` to a higher pre-release identifier to short-circuit this naturally. Documented in the CHANGELOG entry. If maintainers want belt-and-braces, a follow-up can add a `.git`-presence short-circuit, but that is explicitly out-of-scope here per the design decision. +- **First-author churn on imported pads.** If a pad was created via `setText`/API by an admin script using a service-account author, the first-author signal points at that service account. Operationally fine — the notice just won't fire for anyone. Acceptable. +- **Anonymous browsers without express_sid.** First load of a pad with no prior session has no `express_sid` cookie until `socket.io` connects. The version-status request fires after `CLIENT_VARS`, which is after the socket handshake, so by then the cookie exists. If for any reason it doesn't, `resolveRequestAuthor` returns `null` and the response is `EMPTY` — fail-quiet. From 8abd04ae1956f8df699c47936b28571de1e79e79 Mon Sep 17 00:00:00 2001 From: John McLear Date: Mon, 18 May 2026 11:14:55 +0100 Subject: [PATCH 02/17] docs: implementation plan for #7799 outdated-notice redesign 12 bite-sized tasks, TDD-first where applicable; closes the spec end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-18-outdated-notice-redesign.md | 1200 +++++++++++++++++ 1 file changed, 1200 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-18-outdated-notice-redesign.md diff --git a/docs/superpowers/plans/2026-05-18-outdated-notice-redesign.md b/docs/superpowers/plans/2026-05-18-outdated-notice-redesign.md new file mode 100644 index 00000000000..efffd537f28 --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-outdated-notice-redesign.md @@ -0,0 +1,1200 @@ +# Outdated-Version Notice Redesign 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:** Replace the persistent, all-visitor "severely outdated" banner with a dismissable gritter shown only to a pad's first author when the running server is at least one minor version behind the latest published release; drop the `vulnerable-below` UI entirely. + +**Architecture:** Server-gated. `/api/version-status` becomes pad-aware (takes `?padId=`), resolves the requesting browser's authorID via the express session, returns `{outdated:'minor'|null, isFirstAuthor:boolean}` only after confirming pool-position-0 match. Client invokes the check once `clientVars` is populated and calls `$.gritter.add(...)` only on a positive answer. No localStorage. Dev-build suppression follows from `current >= latest`. + +**Tech Stack:** TypeScript, Express, vitest (backend), Playwright (frontend), `lru-cache` package (already a dep), `jquery.gritter` vendor lib (already wired into pad.ts). + +**Spec:** `docs/superpowers/specs/2026-05-18-outdated-notice-redesign-design.md` + +--- + +## File Structure + +**Server (modify):** +- `src/node/updater/versionCompare.ts` — add `isMinorOrMoreBehind`; delete `isMajorBehind`, `isVulnerable`, `parseVulnerableBelow`, `VULN_RE` +- `src/node/updater/types.ts` — drop `VulnerableBelowDirective`, drop `vulnerableBelow` field from `UpdaterState` +- `src/node/updater/state.ts` — stop reading/writing `vulnerableBelow` +- `src/node/updater/VersionChecker.ts` — drop release-notes vulnerable-below scrape +- `src/node/hooks/express/updateStatus.ts` — rewrite `/api/version-status` (pad-aware, first-author gating, per-key LRU cache); add `firstAuthorOf` and `resolveRequestAuthor` helpers +- `src/node/hooks/express/openapi-admin.ts` — update endpoint OpenAPI doc + +**Server (test):** +- `src/tests/backend/specs/updateStatus.spec.ts` — new file (no existing test for this module) +- `src/tests/backend/specs/versionCompare.spec.ts` — new file + +**Client (modify):** +- `src/templates/pad.html` — delete `#version-badge` div +- `src/static/css/pad.css` — delete `#version-badge` rules +- `src/static/js/pad_version_badge.ts` → renamed `pad_outdated_notice.ts` — rewrite +- `src/static/js/pad.ts` — swap import + invocation site + +**Client (test):** +- `src/tests/frontend-new/specs/outdated_notice.spec.ts` — new file + +**Build config:** +- Grep for `pad_version_badge` in `vite.config.ts` and any other bundler config; rename refs to `pad_outdated_notice` + +**Docs:** +- `doc/api/http_api.md` (and `.adoc` mirror if present) — update `/api/version-status` entry +- `CHANGELOG.md` — Unreleased section entry + +--- + +## Task 1: Add `isMinorOrMoreBehind` helper (test-first) + +**Files:** +- Test: `src/tests/backend/specs/versionCompare.spec.ts` (new) +- Modify: `src/node/updater/versionCompare.ts` + +- [ ] **Step 1: Write failing tests for the new helper** + +Create `src/tests/backend/specs/versionCompare.spec.ts`: + +```ts +import {describe, expect, it} from 'vitest'; +import {compareSemver, isMinorOrMoreBehind, parseSemver} from '../../../node/updater/versionCompare'; + +describe('parseSemver', () => { + it('parses standard semver', () => { + expect(parseSemver('1.2.3')).toEqual({major: 1, minor: 2, patch: 3}); + }); + it('accepts v-prefix and pre-release', () => { + expect(parseSemver('v2.7.3-rc.1')).toEqual({major: 2, minor: 7, patch: 3}); + }); + it('rejects garbage', () => { + expect(parseSemver('not-a-version')).toBeNull(); + expect(parseSemver('1.2')).toBeNull(); + expect(parseSemver('2.7.1.4')).toBeNull(); + }); +}); + +describe('compareSemver', () => { + it('returns -1, 0, 1', () => { + expect(compareSemver('1.2.3', '1.2.4')).toBe(-1); + expect(compareSemver('1.2.3', '1.2.3')).toBe(0); + expect(compareSemver('1.2.4', '1.2.3')).toBe(1); + }); +}); + +describe('isMinorOrMoreBehind', () => { + it('returns false for equal versions', () => { + expect(isMinorOrMoreBehind('3.0.0', '3.0.0')).toBe(false); + }); + it('returns false for current ahead of latest', () => { + expect(isMinorOrMoreBehind('3.1.0', '3.0.5')).toBe(false); + }); + it('returns false for patch-only delta', () => { + expect(isMinorOrMoreBehind('2.7.3', '2.7.4')).toBe(false); + expect(isMinorOrMoreBehind('3.0.1', '3.0.9')).toBe(false); + }); + it('returns true for minor delta', () => { + expect(isMinorOrMoreBehind('3.1.0', '3.2.0')).toBe(true); + expect(isMinorOrMoreBehind('3.1.5', '3.2.0')).toBe(true); + }); + it('returns true for major delta', () => { + expect(isMinorOrMoreBehind('2.7.3', '3.0.0')).toBe(true); + }); + it('returns false on unparseable input on either side', () => { + expect(isMinorOrMoreBehind('garbage', '3.0.0')).toBe(false); + expect(isMinorOrMoreBehind('3.0.0', 'garbage')).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm --filter ep_etherpad-lite exec vitest run src/tests/backend/specs/versionCompare.spec.ts` + +Expected: FAIL on the `isMinorOrMoreBehind` cases (symbol not exported). `parseSemver` and `compareSemver` cases pass (already exported). + +- [ ] **Step 3: Add `isMinorOrMoreBehind`, delete the dead helpers** + +Edit `src/node/updater/versionCompare.ts` so its final contents are exactly: + +```ts +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; +}; + +// True iff `current` is at least one minor version behind `latest`. +// Equivalent to: latest.major > current.major, OR same major and +// latest.minor > current.minor. Patch-only deltas return false, equal +// versions return false, current newer than latest returns false. +export const isMinorOrMoreBehind = (current: string, latest: string): boolean => { + const c = parseSemver(current); + const l = parseSemver(latest); + if (!c || !l) return false; + if (l.major !== c.major) return l.major > c.major; + return l.minor > c.minor; +}; +``` + +The deletions vs the existing file: drop the `VulnerableBelowDirective` import, `isMajorBehind`, `VULN_RE`, `parseVulnerableBelow`, and `isVulnerable`. Keep `parseSemver` and `compareSemver` unchanged. + +- [ ] **Step 4: Re-run tests, verify they pass** + +Run: `pnpm --filter ep_etherpad-lite exec vitest run src/tests/backend/specs/versionCompare.spec.ts` +Expected: PASS, all green. + +- [ ] **Step 5: Commit** + +```bash +git add src/node/updater/versionCompare.ts src/tests/backend/specs/versionCompare.spec.ts +git commit -m "feat(updater): add isMinorOrMoreBehind, drop major/vulnerable helpers" +``` + +--- + +## Task 2: Drop `vulnerableBelow` from updater types and state + +**Files:** +- Modify: `src/node/updater/types.ts` +- Modify: `src/node/updater/state.ts` +- Modify: `src/node/updater/VersionChecker.ts` + +- [ ] **Step 1: Strip `VulnerableBelowDirective` and `vulnerableBelow` from types** + +Open `src/node/updater/types.ts`. Locate the `VulnerableBelowDirective` interface (or type) — delete it and any export. In `UpdaterState`, delete the line: + +```ts +vulnerableBelow: VulnerableBelowDirective[]; +``` + +(Or similar — exact shape may be `readonly VulnerableBelowDirective[]`; remove either way.) + +- [ ] **Step 2: Strip read/write from `state.ts`** + +In `src/node/updater/state.ts`: +- In `loadState` (or its parser): delete any line that pulls `vulnerableBelow` out of the JSON. Existing state files with the field still parse — unknown keys are ignored. +- In any `saveState` / `writeState` / serializer: delete the line that writes `vulnerableBelow` back. The field will naturally drop on next write. +- If `state.ts` declares a default/empty `UpdaterState`, remove the `vulnerableBelow: []` line. + +- [ ] **Step 3: Strip the release-notes scrape from `VersionChecker.ts`** + +In `src/node/updater/VersionChecker.ts`: +- Find the call to `parseVulnerableBelow(releaseBody)` (or any reference to the symbol). Delete it. +- Delete the import of `parseVulnerableBelow` from `./versionCompare`. +- If the checker was assembling a `vulnerableBelow` array to pass to `saveState`, delete that whole branch. + +- [ ] **Step 4: Verify nothing else references the deleted symbols** + +Run: + +```bash +grep -rn "vulnerableBelow\|VulnerableBelowDirective\|parseVulnerableBelow\|isVulnerable\|isMajorBehind" src/node src/tests +``` + +Expected: NO matches. If any match remains, delete that line/branch too. The most likely stragglers are in `state.ts` defaults, in old test files, or in `updateStatus.ts` (which we rewrite in a later task — leave those references for now if you see them, they'll get cleaned up in Task 5). + +- [ ] **Step 5: Verify backend type-checks** + +Run: + +```bash +pnpm --filter ep_etherpad-lite exec tsc --noEmit +``` + +Expected: any pre-existing errors are unchanged. If you broke `updateStatus.ts` by removing `isMajorBehind`/`isVulnerable` — that's expected and is fixed in Task 5. To make this task self-contained you may temporarily comment out the broken imports in `updateStatus.ts` with `// FIXME(task-5): rewrite`, but do NOT change behavior. + +- [ ] **Step 6: Commit** + +```bash +git add src/node/updater/types.ts src/node/updater/state.ts src/node/updater/VersionChecker.ts src/node/hooks/express/updateStatus.ts +git commit -m "refactor(updater): drop vulnerable-below directive and state field" +``` + +--- + +## Task 3: Add `firstAuthorOf` helper (test-first) + +**Files:** +- Test: `src/tests/backend/specs/firstAuthorOf.spec.ts` (new) +- Modify: `src/node/hooks/express/updateStatus.ts` (add the helper export; full route rewrite happens in Task 5) + +- [ ] **Step 1: Write failing tests** + +Create `src/tests/backend/specs/firstAuthorOf.spec.ts`: + +```ts +import {describe, expect, it} from 'vitest'; +import {firstAuthorOf} from '../../../node/hooks/express/updateStatus'; + +// Minimal fake pad — only `pool.numToAttrib` matters to firstAuthorOf. +const makePad = (entries: Record): any => ({ + pool: {numToAttrib: entries}, +}); + +describe('firstAuthorOf', () => { + it('returns null for a pad with no attribs', () => { + expect(firstAuthorOf(makePad({}))).toBeNull(); + }); + + it('returns null when no author attribs exist', () => { + expect(firstAuthorOf(makePad({0: ['bold', 'true'], 1: ['italic', 'true']}))).toBeNull(); + }); + + it('returns the only author when there is one', () => { + expect(firstAuthorOf(makePad({0: ['author', 'a.alice']}))).toBe('a.alice'); + }); + + it('returns the lowest-numbered author when there are several', () => { + expect(firstAuthorOf(makePad({ + 0: ['bold', 'true'], + 1: ['author', 'a.alice'], + 2: ['author', 'a.bob'], + }))).toBe('a.alice'); + }); + + it('skips empty-string author placeholders', () => { + expect(firstAuthorOf(makePad({ + 0: ['author', ''], + 1: ['author', 'a.alice'], + }))).toBe('a.alice'); + }); + + it('walks keys in numeric order, not string order', () => { + expect(firstAuthorOf(makePad({ + 10: ['author', 'a.bob'], + 2: ['author', 'a.alice'], + }))).toBe('a.alice'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter ep_etherpad-lite exec vitest run src/tests/backend/specs/firstAuthorOf.spec.ts` +Expected: FAIL with "firstAuthorOf is not a function" (not exported yet). + +- [ ] **Step 3: Add the helper, export it** + +Open `src/node/hooks/express/updateStatus.ts`. At an appropriate location near the top of the file (after the imports), add: + +```ts +import type {PadType} from '../../db/PadType'; + +/** + * Returns the authorID of whoever first contributed to the pad — i.e. the + * `['author', X]` entry at the lowest numeric key in the pool, with empty-X + * placeholders skipped. Returns null for a pad with no real author attribs yet. + */ +export const firstAuthorOf = (pad: PadType): string | null => { + const num2attrib = (pad as any).pool?.numToAttrib; + if (!num2attrib) return null; + const keys = Object.keys(num2attrib).map(Number).sort((a, b) => a - b); + for (const k of keys) { + const a = num2attrib[k]; + if (Array.isArray(a) && a[0] === 'author' && typeof a[1] === 'string' && a[1] !== '') { + return a[1]; + } + } + return null; +}; +``` + +Note: if `PadType` isn't already a usable type, use `import type {PadType} from '../../db/Pad'` instead, or fall back to `any` and rely on the structural access. Confirm the import path that compiles by trying it. + +- [ ] **Step 4: Run test to verify pass** + +Run: `pnpm --filter ep_etherpad-lite exec vitest run src/tests/backend/specs/firstAuthorOf.spec.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/node/hooks/express/updateStatus.ts src/tests/backend/specs/firstAuthorOf.spec.ts +git commit -m "feat(updater): add firstAuthorOf helper" +``` + +--- + +## Task 4: Add `resolveRequestAuthor` helper + +**Files:** +- Modify: `src/node/hooks/express/updateStatus.ts` + +This helper reads the `express_sid` cookie, looks up the session, and returns the authorID (or null). It does NOT have its own unit test — it requires the live express-session store, so it's exercised end-to-end via the route tests in Task 5. + +- [ ] **Step 1: Add the helper** + +Open `src/node/hooks/express/updateStatus.ts`. Add this helper after the existing imports (and after `firstAuthorOf` from Task 3): + +```ts +import {sessionMiddleware} from '../express'; + +type SessionGetResult = {user?: {author?: string}} | null | undefined; + +/** + * Resolve the express-session author for a plain HTTP GET. The pad-side fetch + * is `credentials: 'same-origin'`, so the `express_sid` cookie comes along + * automatically. We re-enter the session middleware to populate `req.session` + * the same way express-session does for routed handlers; on any failure we + * return null and the caller treats the request as anonymous. + */ +export const resolveRequestAuthor = async (req: any): Promise => { + if (req.session && typeof req.session === 'object') { + const author = (req.session as any).user?.author; + return typeof author === 'string' && author !== '' ? author : null; + } + try { + await new Promise((resolve, reject) => { + sessionMiddleware(req, {} as any, (err?: unknown) => err ? reject(err) : resolve()); + }); + } catch { + return null; + } + const author = (req.session as SessionGetResult)?.user?.author; + return typeof author === 'string' && author !== '' ? author : null; +}; +``` + +Note: the import path for `sessionMiddleware` is whatever the existing express module exports. In Etherpad's tree this is `src/node/hooks/express.ts` exporting `exports.sessionMiddleware`. If TypeScript complains about the import shape, fall back to: + +```ts +import * as express from '../express'; +const sessionMiddleware = (express as any).sessionMiddleware; +``` + +inside the helper. + +- [ ] **Step 2: Type-check** + +Run: `pnpm --filter ep_etherpad-lite exec tsc --noEmit` +Expected: no new errors beyond pre-existing ones. + +- [ ] **Step 3: Commit** + +```bash +git add src/node/hooks/express/updateStatus.ts +git commit -m "feat(updater): add resolveRequestAuthor helper for HTTP GET" +``` + +--- + +## Task 5: Rewrite `/api/version-status` route + cache + +**Files:** +- Modify: `src/node/hooks/express/updateStatus.ts` + +- [ ] **Step 1: Replace the route, cache, and `computeOutdated` in one edit** + +Open `src/node/hooks/express/updateStatus.ts`. Replace the existing module-level cache section (`let badgeCache`, `let badgeInFlight`, `BADGE_CACHE_MS`), the old `computeOutdated`, the existing `app.get('/api/version-status', ...)` route, and `_resetBadgeCacheForTests` with the following. The `/admin/update/status` route below it stays as-is. (Inside the `/admin/update/status` handler, the existing `state.vulnerableBelow` reference also needs to be removed — see Step 2.) + +```ts +import {LRUCache} from 'lru-cache'; +import padManager from '../../db/PadManager'; +import {isMinorOrMoreBehind} from '../../updater/versionCompare'; +// (keep existing imports of loadState, stateFilePath, settings, getEpVersion, etc.) +// (firstAuthorOf and resolveRequestAuthor were added in Tasks 3-4 above) + +interface OutdatedResponse { + outdated: 'minor' | null; + isFirstAuthor: boolean; +} + +const EMPTY: OutdatedResponse = {outdated: null, isFirstAuthor: false}; + +const TTL_MS = 60 * 1000; +let cache = new LRUCache({max: 1000}); +const inFlight = new Map>(); + +/** Test-only setter so a spec can force a tiny cap and assert eviction. */ +export const _setBadgeCacheCapForTests = (max: number): void => { + cache = new LRUCache({max}); +}; + +export const _resetBadgeCacheForTests = (): void => { + cache.clear(); + inFlight.clear(); +}; + +const computeOutdated = async ( + padId: string | null, + authorId: string | null, +): Promise => { + const state = await loadState(stateFilePath()); + if (!state.latest) return EMPTY; + const current = getEpVersion(); + if (!isMinorOrMoreBehind(current, state.latest.version)) return EMPTY; + if (!padId || !authorId) return EMPTY; + if (!padManager.isValidPadId(padId)) return EMPTY; + if (!(await padManager.doesPadExist(padId))) return EMPTY; + const pad = await padManager.getPad(padId); + if (firstAuthorOf(pad) !== authorId) return EMPTY; + return {outdated: 'minor', isFirstAuthor: true}; +}; + +// In expressCreateServer, replace the existing version-status route: +app.get('/api/version-status', wrapAsync(async (req, res) => { + const padId = typeof req.query.padId === 'string' ? req.query.padId : null; + const authorId = await resolveRequestAuthor(req); + const key = `${padId ?? ''}|${authorId ?? ''}`; + const now = Date.now(); + + const hit = cache.get(key); + if (hit && now - hit.at <= TTL_MS) { + res.json(hit.value); + return; + } + + let flight = inFlight.get(key); + if (!flight) { + flight = computeOutdated(padId, authorId).finally(() => inFlight.delete(key)); + inFlight.set(key, flight); + } + const value = await flight; + cache.set(key, {value, at: now}); + res.json(value); +})); +``` + +- [ ] **Step 2: Strip `vulnerableBelow` from the `/admin/update/status` response** + +Still in `updateStatus.ts`, find the `res.json({...})` inside the `/admin/update/status` handler. Delete the `vulnerableBelow: state.vulnerableBelow,` line. The admin payload now reads: `currentVersion, latest, lastCheckAt, installMethod, tier, policy, execution, lastResult, lockHeld`. + +- [ ] **Step 3: Type-check** + +Run: `pnpm --filter ep_etherpad-lite exec tsc --noEmit` +Expected: clean. + +- [ ] **Step 4: Quick smoke compile** + +Run: `pnpm --filter ep_etherpad-lite run build` +Expected: build completes. (We have no behaviour assertions yet — those come in Task 6.) + +- [ ] **Step 5: Commit** + +```bash +git add src/node/hooks/express/updateStatus.ts +git commit -m "feat(updater): pad-aware /api/version-status with first-author gating" +``` + +--- + +## Task 6: End-to-end tests for `/api/version-status` + +**Files:** +- Test: `src/tests/backend/specs/updateStatus.spec.ts` (new) + +These tests boot a real Etherpad in-process (the same pattern existing api/* specs use), seed a state file, create a pad with two authors, and assert the route's behaviour. Reference existing specs like `src/tests/backend/specs/api/pad.ts` for the boot harness. + +- [ ] **Step 1: Write the full spec** + +Create `src/tests/backend/specs/updateStatus.spec.ts`: + +```ts +import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import request from 'supertest'; +import {_resetBadgeCacheForTests, _setBadgeCacheCapForTests} from '../../node/hooks/express/updateStatus'; +import * as stateMod from '../../node/updater/state'; +// Reuse the test harness that other api specs use: +import {init as initEtherpad, app as etherpadApp} from '../common'; // adjust if your harness differs + +const PAD_ID = 'outdated-notice-test-pad'; +const ALICE = 'a.alice'; +const BOB = 'a.bob'; + +const writeState = async (latestVersion: string | null) => { + // Stub loadState by spying — simpler than writing a real state.json fixture. + vi.spyOn(stateMod, 'loadState').mockResolvedValue({ + latest: latestVersion ? {version: latestVersion, releasedAt: '2026-01-01T00:00:00Z'} : null, + lastCheckAt: null, + execution: {status: 'idle'}, + lastResult: null, + } as any); +}; + +const seedPadWithAuthor = async (firstAuthor: string, secondAuthor?: string) => { + const padManager = (await import('../../node/db/PadManager')).default; + const pad = await padManager.getPad(PAD_ID, '', firstAuthor); + // Touching `pool.putAttrib` mirrors what real edits do; the first call + // tags `firstAuthor` as the first ['author', X] entry. The second author + // (if provided) ends up at a higher pool key. + pad.pool.putAttrib(['author', firstAuthor]); + if (secondAuthor) pad.pool.putAttrib(['author', secondAuthor]); + await pad.saveToDatabase?.(); +}; + +const loginAs = async (authorId: string): Promise => { + // Issue a session-bound cookie tied to authorId by hitting the pad's + // session-creation endpoint, then reuse the returned `express_sid` cookie. + // Implementation detail varies per Etherpad's test harness — adapt to the + // helper your tree provides. The shape we need is: + // 1. cookie that, on a fresh GET /api/version-status, makes + // req.session.user.author === authorId. + // 2. returns the full Cookie header value. + // For new harnesses, expose a `loginAs(authorId)` helper that fabricates + // an express-session row directly via sessionStore.set(). + throw new Error('TODO(plan-task-6): wire to your tree\'s test login helper'); + // Once wired, return the cookie string. +}; + +describe('GET /api/version-status', () => { + beforeAll(async () => { + await initEtherpad(); + }); + afterAll(() => { + vi.restoreAllMocks(); + }); + beforeEach(() => { + _resetBadgeCacheForTests(); + }); + + it('returns EMPTY when no latest is known', async () => { + await writeState(null); + const res = await request(etherpadApp).get('/api/version-status').query({padId: PAD_ID}); + expect(res.status).toBe(200); + expect(res.body).toEqual({outdated: null, isFirstAuthor: false}); + }); + + it('returns EMPTY when current >= latest', async () => { + await writeState('0.0.1'); // current is whatever package.json says, > 0.0.1 + await seedPadWithAuthor(ALICE); + const cookie = await loginAs(ALICE); + const res = await request(etherpadApp) + .get('/api/version-status') + .set('Cookie', cookie) + .query({padId: PAD_ID}); + expect(res.body).toEqual({outdated: null, isFirstAuthor: false}); + }); + + it('returns EMPTY when only patch-behind (no padId/author needed)', async () => { + const current = require('../../../package.json').version as string; // e.g. '2.7.3' + const [maj, min, patch] = current.split('.').map(Number); + await writeState(`${maj}.${min}.${patch + 1}`); + await seedPadWithAuthor(ALICE); + const cookie = await loginAs(ALICE); + const res = await request(etherpadApp) + .get('/api/version-status') + .set('Cookie', cookie) + .query({padId: PAD_ID}); + expect(res.body).toEqual({outdated: null, isFirstAuthor: false}); + }); + + it('returns EMPTY when padId omitted, even at minor-behind', async () => { + await writeState('999.0.0'); + const res = await request(etherpadApp).get('/api/version-status'); + expect(res.body).toEqual({outdated: null, isFirstAuthor: false}); + }); + + it('returns EMPTY when author is not pool position 0', async () => { + await writeState('999.0.0'); + await seedPadWithAuthor(ALICE, BOB); + const cookie = await loginAs(BOB); + const res = await request(etherpadApp) + .get('/api/version-status') + .set('Cookie', cookie) + .query({padId: PAD_ID}); + expect(res.body).toEqual({outdated: null, isFirstAuthor: false}); + }); + + it('returns {minor, true} for the first author when minor-behind', async () => { + await writeState('999.0.0'); + await seedPadWithAuthor(ALICE); + const cookie = await loginAs(ALICE); + const res = await request(etherpadApp) + .get('/api/version-status') + .set('Cookie', cookie) + .query({padId: PAD_ID}); + expect(res.body).toEqual({outdated: 'minor', isFirstAuthor: true}); + }); + + it('caches per (padId, authorId) for 60s', async () => { + await writeState('999.0.0'); + await seedPadWithAuthor(ALICE); + const cookie = await loginAs(ALICE); + const loadSpy = vi.spyOn(stateMod, 'loadState'); + loadSpy.mockClear(); + + await request(etherpadApp).get('/api/version-status').set('Cookie', cookie).query({padId: PAD_ID}); + await request(etherpadApp).get('/api/version-status').set('Cookie', cookie).query({padId: PAD_ID}); + + expect(loadSpy).toHaveBeenCalledTimes(1); + }); + + it('caches different (padId, authorId) pairs independently', async () => { + await writeState('999.0.0'); + await seedPadWithAuthor(ALICE); + const cookieA = await loginAs(ALICE); + const cookieB = await loginAs(BOB); + const loadSpy = vi.spyOn(stateMod, 'loadState'); + loadSpy.mockClear(); + + await request(etherpadApp).get('/api/version-status').set('Cookie', cookieA).query({padId: PAD_ID}); + await request(etherpadApp).get('/api/version-status').set('Cookie', cookieB).query({padId: PAD_ID}); + + expect(loadSpy).toHaveBeenCalledTimes(2); + }); + + it('evicts oldest entry when LRU cap is reached', async () => { + _setBadgeCacheCapForTests(2); + await writeState('999.0.0'); + await seedPadWithAuthor(ALICE); + const cookie = await loginAs(ALICE); + const loadSpy = vi.spyOn(stateMod, 'loadState'); + loadSpy.mockClear(); + + // 3 distinct keys; with cap=2 the 4th call (re-hitting key 1) must miss + // and re-call loadState. + await request(etherpadApp).get('/api/version-status').set('Cookie', cookie).query({padId: 'p1'}); + await request(etherpadApp).get('/api/version-status').set('Cookie', cookie).query({padId: 'p2'}); + await request(etherpadApp).get('/api/version-status').set('Cookie', cookie).query({padId: 'p3'}); + await request(etherpadApp).get('/api/version-status').set('Cookie', cookie).query({padId: 'p1'}); + + expect(loadSpy).toHaveBeenCalledTimes(4); + }); +}); +``` + +Note: the exact imports for the test harness (`../common`, `initEtherpad`, `etherpadApp`) and the `loginAs` helper depend on your Etherpad tree. Inspect a working api spec (`src/tests/backend/specs/api/pad.ts`) to find the correct names and adapt. If your tree has no `loginAs` helper, add one that does `sessionStore.set(sid, {user: {author}})` directly — that is the minimum surface required. + +- [ ] **Step 2: Wire `loginAs` correctly** + +Find the existing test harness's session/cookie helper. Likely candidates: + +```bash +grep -rn "sessionStore\|express_sid\|loginAs\|setSession" src/tests/backend 2>/dev/null | head +``` + +If no helper exists, add one in `src/tests/backend/specs/common.ts` (or whichever shared file your harness uses): + +```ts +import {sessionMiddleware} from '../../node/hooks/express'; +import crypto from 'node:crypto'; + +// Returns a Cookie header value bound to a session whose user.author === authorId. +export const loginAs = async (authorId: string): Promise => { + // Implementation: introspect sessionMiddleware to get the store, call store.set + // with a freshly generated sid, return `express_sid=s%3A...` cookie. + // If introspection is awkward, expose the express-session store from + // express.ts so tests can import it directly. + // ... (concrete implementation depends on the tree's session config) +}; +``` + +If wiring this proves load-bearing, raise it as a follow-up issue and downgrade Task 6's coverage to the cases that don't need a real cookie (the first three "EMPTY" cases plus the patch-only case can all run without a logged-in session — they assert pre-author short-circuit behaviour). + +- [ ] **Step 3: Run, expect pass** + +Run: `pnpm --filter ep_etherpad-lite exec vitest run src/tests/backend/specs/updateStatus.spec.ts` +Expected: all cases pass. Any failure here indicates a server bug — fix it inline in `updateStatus.ts`, re-run. + +- [ ] **Step 4: Commit** + +```bash +git add src/tests/backend/specs/updateStatus.spec.ts src/tests/backend/specs/common.ts +git commit -m "test(updater): end-to-end coverage for /api/version-status" +``` + +--- + +## Task 7: Update OpenAPI doc for `/api/version-status` + +**Files:** +- Modify: `src/node/hooks/express/openapi-admin.ts` + +- [ ] **Step 1: Locate the existing entry** + +Open `src/node/hooks/express/openapi-admin.ts`. Grep within the file for `version-status`. The existing entry will describe the path, parameters, and response schema. + +- [ ] **Step 2: Update the entry** + +Make the entry read (adapt the JS/TS object literal shape to whatever the file uses — usually a plain spec object): + +```ts +'/api/version-status': { + get: { + summary: 'Outdated-version notice signal for the pad UI', + description: 'Returns a non-null `outdated` value only to the first author of the supplied pad, and only when the running server is at least one minor version behind the latest published release. Result is cached per (padId, authorId) for 60s.', + parameters: [ + { + name: 'padId', + in: 'query', + required: false, + schema: {type: 'string'}, + description: 'Pad whose first-author membership is being checked. Omitted padId always yields a null result.', + }, + ], + responses: { + '200': { + description: 'Outdated-notice signal.', + content: { + 'application/json': { + schema: { + type: 'object', + required: ['outdated', 'isFirstAuthor'], + properties: { + outdated: {type: 'string', enum: ['minor'], nullable: true}, + isFirstAuthor: {type: 'boolean'}, + }, + }, + }, + }, + }, + }, + }, +}, +``` + +If the existing doc enumerated `severe` and `vulnerable`, those are gone. If it had an admin-only response shape for this route, that was wrong — `/api/version-status` is always public. + +- [ ] **Step 3: Verify nothing else references the deleted enum values** + +```bash +grep -rn "'severe'\|'vulnerable'" src/node src/static 2>/dev/null +``` + +Expected: zero matches (CSS rules removed in Task 9, client rewrite in Task 10). + +- [ ] **Step 4: Commit** + +```bash +git add src/node/hooks/express/openapi-admin.ts +git commit -m "docs(openapi): /api/version-status pad-aware shape and gating" +``` + +--- + +## Task 8: Delete `#version-badge` template + CSS + +**Files:** +- Modify: `src/templates/pad.html` +- Modify: `src/static/css/pad.css` + +- [ ] **Step 1: Delete the template div** + +Open `src/templates/pad.html`. At line ~648 there's: + +```html + +``` + +Delete this entire line. + +- [ ] **Step 2: Delete the CSS rules** + +Open `src/static/css/pad.css`. At line ~119 there's a `#version-badge { ... }` block, followed by `[data-level="severe"]` and `[data-level="vulnerable"]` variants at ~130-131. Delete all three rules (the entire `#version-badge` ruleset including the two data-level variants). + +- [ ] **Step 3: Sanity-check nothing else references the id** + +```bash +grep -rn "version-badge" src/ 2>/dev/null +``` + +Expected: zero matches (the JS module gets renamed in the next task). + +- [ ] **Step 4: Commit** + +```bash +git add src/templates/pad.html src/static/css/pad.css +git commit -m "chore(pad): remove unused #version-badge template and CSS" +``` + +--- + +## Task 9: Rename and rewrite the client module + +**Files:** +- Rename: `src/static/js/pad_version_badge.ts` → `src/static/js/pad_outdated_notice.ts` +- Modify: `src/static/js/pad.ts` + +- [ ] **Step 1: git mv the file** + +```bash +git mv src/static/js/pad_version_badge.ts src/static/js/pad_outdated_notice.ts +``` + +- [ ] **Step 2: Replace its contents wholesale** + +Write `src/static/js/pad_outdated_notice.ts` exactly: + +```ts +'use strict'; + +interface OutdatedResponse { + outdated: 'minor' | null; + isFirstAuthor: boolean; +} + +const apiBasePath = (): string => { + if (typeof window === 'undefined') return '/'; + return new URL('..', window.location.href).pathname; +}; + +const currentPadId = (): string | null => { + const id = (window as any).clientVars?.padId; + return typeof id === 'string' && id.length > 0 ? id : null; +}; + +export const maybeShowOutdatedNotice = async (): Promise => { + const padId = currentPadId(); + if (!padId) return; + const $ = (window as any).$; + if (!$ || !$.gritter || typeof $.gritter.add !== 'function') return; + + try { + const url = `${apiBasePath()}api/version-status?padId=${encodeURIComponent(padId)}`; + const res = await fetch(url, {credentials: 'same-origin'}); + if (!res.ok) return; + const data = (await res.json()) as OutdatedResponse; + if (data.outdated !== 'minor' || !data.isFirstAuthor) return; + + // TODO(i18n): switch to html10n once `pad.outdatedNotice.*` keys land. + $.gritter.add({ + title: 'Etherpad update available', + text: 'A newer version of Etherpad has been released. Consider updating this server.', + sticky: false, + position: 'bottom', + class_name: 'outdated-notice', + time: 8000, + }); + } catch { + /* never block pad load */ + } +}; +``` + +The auto-bootstrap-on-DOMContentLoaded block from the old file is GONE — invocation is now explicit, from pad.ts, after `clientVars` is populated. + +- [ ] **Step 3: Wire it into pad.ts** + +Open `src/static/js/pad.ts`. Two edits: + +1. Line ~57 already imports `showPrivacyBannerIfEnabled`. Add right after it (around line 58): + +```ts +import {maybeShowOutdatedNotice} from './pad_outdated_notice'; +``` + +2. Line 59 currently reads `import './pad_version_badge';` — delete this line entirely. Replace it with nothing (the explicit import in step 1 above is sufficient; we no longer want the self-bootstrapping side-effect import). + +3. Find the existing call site of `showPrivacyBannerIfEnabled` (line ~751). It looks like: + +```ts +showPrivacyBannerIfEnabled((clientVars as any).privacyBanner); +``` + +Add the outdated-notice call immediately after it: + +```ts +showPrivacyBannerIfEnabled((clientVars as any).privacyBanner); +void maybeShowOutdatedNotice(); +``` + +`void` because we don't await — the gritter render is fire-and-forget. + +- [ ] **Step 4: Grep for stale references** + +```bash +grep -rn "pad_version_badge\|renderVersionBadge" src/ 2>/dev/null +``` + +Expected: zero matches. + +- [ ] **Step 5: Bundler config grep + rename** + +```bash +grep -rn "pad_version_badge" vite.config.ts webpack.config.* rollup.config.* 2>/dev/null +``` + +If matches exist, rename to `pad_outdated_notice` in each. Most likely there are none — the pad bundle uses ESM imports rather than explicit entry-point lists. + +- [ ] **Step 6: Run the client build, confirm clean** + +```bash +pnpm --filter ep_etherpad-lite run build +``` + +Expected: build succeeds. If it fails on a missing entry, fix per step 5. + +- [ ] **Step 7: Commit** + +```bash +git add src/static/js/pad_outdated_notice.ts src/static/js/pad.ts +git commit -m "feat(pad): replace persistent badge with first-author outdated gritter" +``` + +--- + +## Task 10: Frontend Playwright spec for the outdated notice + +**Files:** +- Test: `src/tests/frontend-new/specs/outdated_notice.spec.ts` (new) + +Use `src/tests/frontend-new/specs/privacy_banner.spec.ts` as a template — it covers the same shape (gritter-rendered, server-config-driven, Playwright-friendly). + +- [ ] **Step 1: Write the spec** + +Create `src/tests/frontend-new/specs/outdated_notice.spec.ts`: + +```ts +import {test, expect} from '@playwright/test'; +import {randomPadName} from '../helper/randomPad'; // adapt to your tree's helper + +const stubVersionStatus = (page, payload: {outdated: 'minor' | null, isFirstAuthor: boolean} | 'error') => + page.route('**/api/version-status*', (route) => { + if (payload === 'error') return route.fulfill({status: 500, body: 'oops'}); + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(payload), + }); + }); + +test.describe('outdated notice gritter', () => { + test('not shown when outdated is null', async ({page}) => { + await stubVersionStatus(page, {outdated: null, isFirstAuthor: false}); + await page.goto(`/p/${randomPadName()}`); + await page.waitForSelector('iframe[name="ace_outer"]'); + await page.waitForTimeout(500); + await expect(page.locator('.gritter-item.outdated-notice')).toHaveCount(0); + }); + + test('not shown when not first author', async ({page}) => { + await stubVersionStatus(page, {outdated: 'minor', isFirstAuthor: false}); + await page.goto(`/p/${randomPadName()}`); + await page.waitForSelector('iframe[name="ace_outer"]'); + await page.waitForTimeout(500); + await expect(page.locator('.gritter-item.outdated-notice')).toHaveCount(0); + }); + + test('shown for first author when minor-behind', async ({page}) => { + await stubVersionStatus(page, {outdated: 'minor', isFirstAuthor: true}); + await page.goto(`/p/${randomPadName()}`); + await page.waitForSelector('iframe[name="ace_outer"]'); + const gritter = page.locator('.gritter-item.outdated-notice'); + await expect(gritter).toHaveCount(1); + await expect(gritter).toContainText('A newer version of Etherpad has been released'); + }); + + test('dismissable by user click', async ({page}) => { + await stubVersionStatus(page, {outdated: 'minor', isFirstAuthor: true}); + await page.goto(`/p/${randomPadName()}`); + await page.waitForSelector('.gritter-item.outdated-notice'); + await page.locator('.gritter-item.outdated-notice .gritter-close').click(); + await expect(page.locator('.gritter-item.outdated-notice')).toHaveCount(0); + }); + + test('survives a server 500', async ({page}) => { + await stubVersionStatus(page, 'error'); + await page.goto(`/p/${randomPadName()}`); + await page.waitForSelector('iframe[name="ace_outer"]'); + await page.waitForTimeout(500); + await expect(page.locator('.gritter-item.outdated-notice')).toHaveCount(0); + }); + + test('auto-fades after ~8s', async ({page}) => { + await stubVersionStatus(page, {outdated: 'minor', isFirstAuthor: true}); + await page.goto(`/p/${randomPadName()}`); + await page.waitForSelector('.gritter-item.outdated-notice'); + // sticky:false + time:8000 → gritter removes itself; allow generous slack + await page.waitForTimeout(9000); + await expect(page.locator('.gritter-item.outdated-notice')).toHaveCount(0); + }); +}); +``` + +- [ ] **Step 2: Start the dev server** + +In a separate terminal: + +```bash +pnpm --filter ep_etherpad-lite run dev -- --port 9003 +``` + +(Port 9003 per the `feedback_test_port_9003` rule.) + +- [ ] **Step 3: Run the spec under xvfb-run** + +```bash +xvfb-run pnpm --filter ep_etherpad-lite exec playwright test src/tests/frontend-new/specs/outdated_notice.spec.ts +``` + +Expected: all six tests pass. If `randomPadName` import path is wrong, adapt to your tree's helper — it might be inline `Math.random().toString(36)`. + +- [ ] **Step 4: Commit** + +```bash +git add src/tests/frontend-new/specs/outdated_notice.spec.ts +git commit -m "test(pad): playwright coverage for outdated notice gritter" +``` + +--- + +## Task 11: Docs + CHANGELOG + +**Files:** +- Modify: `doc/api/http_api.md` (and `doc/api/http_api.adoc` if it exists) +- Modify: `CHANGELOG.md` +- Possibly: `doc/api/updater.md` or `doc/settings.md` + +- [ ] **Step 1: Update `doc/api/http_api.md`** + +Search inside `doc/api/http_api.md` for any existing `/api/version-status` section. If present, replace it with: + +````markdown +#### `GET /api/version-status` + +Returns an outdated-version signal intended for the pad-side gritter. + +Query parameters: + +| name | type | required | description | +| ------- | ------ | -------- | --------------------------------------------------------------------------- | +| `padId` | string | no | Pad whose first-author membership is being checked. | + +Response (200, `application/json`): + +```json +{ + "outdated": "minor" | null, + "isFirstAuthor": true +} +``` + +`outdated` is `"minor"` only when the running server is at least one minor version behind the latest published release AND the request resolves to the pad's first author. Otherwise it is `null`. Result is cached per (`padId`, `authorId`) for 60s. The endpoint is disabled entirely when `updates.tier = 'off'`. + +```` + +If there is no `/api/version-status` section yet, add the above immediately after whichever public endpoint is most adjacent in the file (e.g. `/api/2/listAllPads`). + +- [ ] **Step 2: If `doc/api/http_api.adoc` exists, mirror the change** + +```bash +[ -f doc/api/http_api.adoc ] && $EDITOR doc/api/http_api.adoc +``` + +Convert the markdown above to asciidoc style if so. If the file doesn't exist, skip. + +- [ ] **Step 3: Drop vulnerable-below references from `doc/api/updater.md` / `doc/settings.md`** + +```bash +grep -rn "vulnerable-below\|vulnerableBelow" doc/ 2>/dev/null +``` + +For each match: open the file and delete the paragraph(s) that describe the directive or the persistent banner. The `updates.tier` documentation itself stays. + +- [ ] **Step 4: Add CHANGELOG entry** + +Open `CHANGELOG.md`. Under the existing "Unreleased" or top-of-file section, add: + +```markdown +- pad: Outdated-version notice redesigned per #7799. The persistent "severely outdated" banner is replaced by a dismissable gritter, shown only to a pad's first author, only when the server is at least one minor version behind the latest released version (patch-only deltas no longer fire). The `vulnerable-below` directive scraping, the `severe`/`vulnerable` enum values, and the `vulnerableBelow` state field have been removed. +``` + +- [ ] **Step 5: Commit** + +```bash +git add doc/ CHANGELOG.md +git commit -m "docs(pad): outdated-notice redesign + drop vulnerable-below docs" +``` + +--- + +## Task 12: Final verification + +- [ ] **Step 1: Run full backend test suite** + +```bash +pnpm --filter ep_etherpad-lite test:vitest +``` + +Expected: green. (Per the `feedback_always_run_backend_tests` rule — backend vitest catches source-lint + missing-dep failures that frontend tests don't.) + +- [ ] **Step 2: Run frontend Playwright suite** + +```bash +xvfb-run pnpm --filter ep_etherpad-lite exec playwright test +``` + +Expected: green. (`xvfb-run` per `feedback_e2e_xvfb`.) + +- [ ] **Step 3: Manual browser check** + +In `var/update.state.json`, pin `latest.version` to a value at least one minor ahead of `package.json`'s version (e.g. if package.json is `2.7.3`, set latest to `2.8.0`). + +```bash +pnpm --filter ep_etherpad-lite run dev -- --port 9003 +``` + +Open `http://localhost.lan:9003/p/manual-test-pad` in a fresh incognito window (window A). Type one character to register as the first author. Expect the gritter to appear once, bottom-position, with the "Etherpad update available" text. Dismiss with X. Refresh the page — gritter re-appears (per-session-only behaviour, matches the design). + +Open the same pad URL in a second incognito window (window B). Type a character. Expect no gritter — you're not pool position 0. + +- [ ] **Step 4: Open the PR** + +```bash +gh pr create --base develop --title "fix(pad): redesign outdated-version notice (#7799)" --body "$(cat <<'EOF' +## Summary + +- Replaces the persistent "severely outdated" banner with a dismissable gritter, shown only to a pad's first author, only when the server is at least one minor version behind the latest published release. +- Drops the `vulnerable-below` directive scraping, the `vulnerable` enum value, and the `vulnerableBelow` state field. +- Adds `isMinorOrMoreBehind`; removes `isMajorBehind` and `isVulnerable`. +- `/api/version-status` becomes pad-aware (`?padId=`) and returns `{outdated: 'minor' | null, isFirstAuthor: boolean}` with per-`(padId, authorId)` 60s LRU caching. + +Closes #7799. + +## Test plan + +- [x] Backend vitest suite green (`pnpm --filter ep_etherpad-lite test:vitest`) +- [x] Frontend Playwright suite green under xvfb (`xvfb-run pnpm exec playwright test`) +- [x] Manual: dev server with `state.json.latest.version` pinned higher than `package.json.version` — gritter appears once for the pad's first author, absent for second visitor + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +Per `feedback_qodo_pr_feedback`: fetch Qodo's review comments after the PR opens (`gh api repos/ether/etherpad/pulls//comments`) and address each one before declaring done. + +--- + +## Self-Review + +Run through the spec sections; every requirement maps to a task: + +- ✅ Server-gated single-enum response → Task 5 +- ✅ `isMinorOrMoreBehind` (new), drop major/vulnerable helpers → Task 1 +- ✅ Drop `vulnerableBelow` state/types/scraping → Task 2 +- ✅ `firstAuthorOf` (pool position 0, skip empty placeholders) → Task 3 +- ✅ `resolveRequestAuthor` (express_sid → session.user.author) → Task 4 +- ✅ Per-(padId, authorId) LRU cache + single-flight → Task 5 +- ✅ OpenAPI doc update → Task 7 +- ✅ Delete `#version-badge` template div + CSS → Task 8 +- ✅ Rename module, gritter rewrite, wire from pad.ts → Task 9 +- ✅ Backend route tests (cache, eviction, first-author, patch-vs-minor) → Task 6 +- ✅ Frontend Playwright (5 cases + auto-fade) → Task 10 +- ✅ Docs + CHANGELOG → Task 11 +- ✅ Verification gates (full vitest, full playwright, manual browser, port 9003) → Task 12 + +Type consistency check: `OutdatedResponse` shape is identical across Tasks 5, 6, 9 and 10. `firstAuthorOf` signature is identical between Tasks 3 and 5. `_resetBadgeCacheForTests` and `_setBadgeCacheCapForTests` are introduced in Task 5 and used in Task 6. Good. + +Placeholder scan: Task 6 step 1's `loginAs` helper has a `throw new Error('TODO...')` placeholder. This is intentional — the wiring depends on the tree's harness which is faster to inspect than to spec out — and step 2 of the same task contains the recipe for wiring it. Acceptable: it's an explicit instruction to inspect a known file pattern, not an unfilled requirement. From e418343dabee2a167653057b6ab106d350decba7 Mon Sep 17 00:00:00 2001 From: John McLear Date: Mon, 18 May 2026 11:18:11 +0100 Subject: [PATCH 03/17] feat(updater): add isMinorOrMoreBehind, drop major/vulnerable helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds isMinorOrMoreBehind(current, latest) which returns true only when the latest release is at least one minor version ahead (patch-only deltas return false). Removes isMajorBehind, parseVulnerableBelow, and isVulnerable from versionCompare.ts — callers in updateStatus.ts, VersionChecker.ts, and index.ts will be updated in subsequent tasks. Co-Authored-By: Claude Sonnet 4.6 --- src/node/updater/versionCompare.ts | 30 ++----- .../specs/updater/versionCompare.test.ts | 81 ++++++++----------- 2 files changed, 41 insertions(+), 70 deletions(-) diff --git a/src/node/updater/versionCompare.ts b/src/node/updater/versionCompare.ts index 270a17704f8..308bdaf51c7 100644 --- a/src/node/updater/versionCompare.ts +++ b/src/node/updater/versionCompare.ts @@ -1,5 +1,3 @@ -import type {VulnerableBelowDirective} from './types'; - export interface ParsedSemver { major: number; minor: number; @@ -26,28 +24,14 @@ export const compareSemver = (a: string, b: string): -1 | 0 | 1 => { return 0; }; -export const isMajorBehind = (current: string, latest: string): boolean => { +// True iff `current` is at least one minor version behind `latest`. +// Equivalent to: latest.major > current.major, OR same major and +// latest.minor > current.minor. Patch-only deltas return false, equal +// versions return false, current newer than latest returns false. +export const isMinorOrMoreBehind = (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 = //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; + if (l.major !== c.major) return l.major > c.major; + return l.minor > c.minor; }; diff --git a/src/tests/backend-new/specs/updater/versionCompare.test.ts b/src/tests/backend-new/specs/updater/versionCompare.test.ts index 11c3904f584..c1d8073c7f3 100644 --- a/src/tests/backend-new/specs/updater/versionCompare.test.ts +++ b/src/tests/backend-new/specs/updater/versionCompare.test.ts @@ -1,13 +1,13 @@ -import {describe, it, expect} from 'vitest'; -import { - parseSemver, - compareSemver, - isMajorBehind, - parseVulnerableBelow, - isVulnerable, -} from '../../../../node/updater/versionCompare'; +import {describe, expect, it} from 'vitest'; +import {compareSemver, isMinorOrMoreBehind, parseSemver} from '../../../../node/updater/versionCompare'; describe('parseSemver', () => { + it('parses standard semver', () => { + expect(parseSemver('1.2.3')).toEqual({major: 1, minor: 2, patch: 3}); + }); + it('accepts v-prefix and pre-release', () => { + expect(parseSemver('v2.7.3-rc.1')).toEqual({major: 2, minor: 7, patch: 3}); + }); it('parses a plain version', () => { expect(parseSemver('2.7.1')).toEqual({major: 2, minor: 7, patch: 1}); }); @@ -19,6 +19,11 @@ describe('parseSemver', () => { expect(parseSemver('')).toBeNull(); expect(parseSemver('2.7')).toBeNull(); }); + it('rejects garbage', () => { + expect(parseSemver('not-a-version')).toBeNull(); + expect(parseSemver('1.2')).toBeNull(); + expect(parseSemver('2.7.1.4')).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}); @@ -33,6 +38,11 @@ describe('parseSemver', () => { }); describe('compareSemver', () => { + it('returns -1, 0, 1', () => { + expect(compareSemver('1.2.3', '1.2.4')).toBe(-1); + expect(compareSemver('1.2.3', '1.2.3')).toBe(0); + expect(compareSemver('1.2.4', '1.2.3')).toBe(1); + }); it('orders correctly', () => { expect(compareSemver('2.7.1', '2.7.2')).toBe(-1); expect(compareSemver('2.7.2', '2.7.1')).toBe(1); @@ -44,49 +54,26 @@ describe('compareSemver', () => { }); }); -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); +describe('isMinorOrMoreBehind', () => { + it('returns false for equal versions', () => { + expect(isMinorOrMoreBehind('3.0.0', '3.0.0')).toBe(false); }); - 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); + it('returns false for current ahead of latest', () => { + expect(isMinorOrMoreBehind('3.1.0', '3.0.5')).toBe(false); }); -}); - -describe('parseVulnerableBelow', () => { - it('extracts directive from release body', () => { - const body = 'Fixes a few things.\n\nMore notes.'; - expect(parseVulnerableBelow(body)).toBe('2.6.4'); + it('returns false for patch-only delta', () => { + expect(isMinorOrMoreBehind('2.7.3', '2.7.4')).toBe(false); + expect(isMinorOrMoreBehind('3.0.1', '3.0.9')).toBe(false); }); - it('tolerates whitespace and casing', () => { - expect(parseVulnerableBelow('')).toBe('1.0.0'); - expect(parseVulnerableBelow('')).toBe('1.0.0'); + it('returns true for minor delta', () => { + expect(isMinorOrMoreBehind('3.1.0', '3.2.0')).toBe(true); + expect(isMinorOrMoreBehind('3.1.5', '3.2.0')).toBe(true); }); - it('returns null when absent or malformed', () => { - expect(parseVulnerableBelow('no directive here')).toBeNull(); - expect(parseVulnerableBelow('')).toBeNull(); + it('returns true for major delta', () => { + expect(isMinorOrMoreBehind('2.7.3', '3.0.0')).toBe(true); }); -}); - -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); + it('returns false on unparseable input on either side', () => { + expect(isMinorOrMoreBehind('garbage', '3.0.0')).toBe(false); + expect(isMinorOrMoreBehind('3.0.0', 'garbage')).toBe(false); }); }); From feb4bfb453963ce37b8bfe535ca82a2fee2ffc30 Mon Sep 17 00:00:00 2001 From: John McLear Date: Mon, 18 May 2026 11:25:04 +0100 Subject: [PATCH 04/17] refactor(updater): drop vulnerable-below directive and state field Remove VulnerableBelowDirective type, UpdateState.vulnerableBelow field, and all related scraping/checking logic (parseVulnerableBelow, isVulnerable imports). Clean up Notifier, OpenAPI schema, and all test fixtures to match. Co-Authored-By: Claude Sonnet 4.6 --- src/node/hooks/express/openapi-admin.ts | 14 +----- src/node/hooks/express/updateStatus.ts | 10 ++-- src/node/updater/Notifier.ts | 37 ++------------ src/node/updater/VersionChecker.ts | 12 ++--- src/node/updater/index.ts | 8 +-- src/node/updater/state.ts | 9 ---- src/node/updater/types.ts | 14 +----- .../specs/updater/Notifier.test.ts | 50 +------------------ .../specs/updater/VersionChecker.test.ts | 5 +- .../backend-new/specs/updater/state.test.ts | 14 ------ src/tests/backend/specs/openapi-admin.ts | 8 +-- src/tests/backend/specs/updateStatus.ts | 1 - .../admin-spec/update-banner.spec.ts | 4 +- .../admin-spec/update-page-actions.spec.ts | 1 - .../admin-spec/update-scheduled.spec.ts | 1 - 15 files changed, 21 insertions(+), 167 deletions(-) diff --git a/src/node/hooks/express/openapi-admin.ts b/src/node/hooks/express/openapi-admin.ts index 53b9a77a02e..fc2414ddfb9 100644 --- a/src/node/hooks/express/openapi-admin.ts +++ b/src/node/hooks/express/openapi-admin.ts @@ -102,17 +102,9 @@ export const generateAdminDefinition = (): any => ({ reason: {type: 'string'}, }, }, - VulnerableBelowDirective: { - type: 'object', - required: ['announcedBy', 'threshold'], - properties: { - announcedBy: {type: 'string'}, - threshold: {type: 'string'}, - }, - }, UpdateStatus: { type: 'object', - required: ['currentVersion', 'installMethod', 'tier', 'vulnerableBelow'], + required: ['currentVersion', 'installMethod', 'tier'], properties: { currentVersion: {type: 'string'}, latest: { @@ -132,10 +124,6 @@ export const generateAdminDefinition = (): any => ({ allOf: [{$ref: '#/components/schemas/PolicyResult'}], nullable: true, }, - vulnerableBelow: { - type: 'array', - items: {$ref: '#/components/schemas/VulnerableBelowDirective'}, - }, }, }, }, diff --git a/src/node/hooks/express/updateStatus.ts b/src/node/hooks/express/updateStatus.ts index 5fda647b0c3..66d1b35e82e 100644 --- a/src/node/hooks/express/updateStatus.ts +++ b/src/node/hooks/express/updateStatus.ts @@ -5,24 +5,23 @@ 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 {compareSemver, isMajorBehind} from '../../updater/versionCompare'; import {loadState} from '../../updater/state'; import {isHeld} from '../../updater/lock'; import {nextWindowStart, parseWindow} from '../../updater/MaintenanceWindow'; -let badgeCache: {value: 'severe' | 'vulnerable' | null; at: number} = {value: null, at: 0}; +let badgeCache: {value: 'severe' | 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; +let badgeInFlight: Promise<'severe' | null> | null = null; const BADGE_CACHE_MS = 60 * 1000; -const computeOutdated = async (): Promise<'severe' | 'vulnerable' | null> => { +const computeOutdated = async (): Promise<'severe' | 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; }; @@ -138,7 +137,6 @@ export const expressCreateServer = ( installMethod, tier: settings.updates.tier, policy, - vulnerableBelow: state.vulnerableBelow, // PR 2 additions: execution, lastResult, diff --git a/src/node/updater/Notifier.ts b/src/node/updater/Notifier.ts index af560ad90cd..6ee6b047279 100644 --- a/src/node/updater/Notifier.ts +++ b/src/node/updater/Notifier.ts @@ -1,13 +1,10 @@ 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; @@ -15,8 +12,6 @@ export interface NotifierInput { export type EmailKind = | 'severe' - | 'vulnerable' - | 'vulnerable-new-release' | 'grace-start' | 'update-preflight-failed' | 'update-rolled-back' @@ -35,7 +30,6 @@ export interface NotifierResult { 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; @@ -44,42 +38,17 @@ const sinceMs = (iso: string | null, now: Date): number => * 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. + * Cadence: severe repeats every 30 days. */ export const decideEmails = (input: NotifierInput): NotifierResult => { - const {adminEmail, current, latest, latestTag, isVulnerable, isSevere, state, now} = input; + const {adminEmail, current, latest, 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) { + if (isSevere) { const sinceSevere = sinceMs(state.severeAt, now); if (sinceSevere >= SEVERE_INTERVAL) { toSend.push({ diff --git a/src/node/updater/VersionChecker.ts b/src/node/updater/VersionChecker.ts index ff4b0f34a52..e7797fe8bf4 100644 --- a/src/node/updater/VersionChecker.ts +++ b/src/node/updater/VersionChecker.ts @@ -1,5 +1,4 @@ -import {ReleaseInfo, VulnerableBelowDirective} from './types'; -import {parseVulnerableBelow} from './versionCompare'; +import {ReleaseInfo} from './types'; import {isValidTag} from './refSafety'; export interface FetchResult { @@ -14,7 +13,7 @@ export type Fetcher = (url: string, etag: string | null) => Promise /** Discriminated union of every outcome the checker can return. */ export type CheckResult = - | {kind: 'updated'; release: ReleaseInfo; etag: string | null; vulnerableBelow: VulnerableBelowDirective[]} + | {kind: 'updated'; release: ReleaseInfo; etag: string | null} | {kind: 'notmodified'} | {kind: 'ratelimited'} | {kind: 'skipped-prerelease'; etag: string | null} @@ -72,12 +71,7 @@ export const checkLatestRelease = async ( htmlUrl: j.html_url, }; - const directiveThreshold = parseVulnerableBelow(body); - const vulnerableBelow: VulnerableBelowDirective[] = directiveThreshold - ? [{announcedBy: tag, threshold: directiveThreshold}] - : []; - - return {kind: 'updated', release, etag: res.etag, vulnerableBelow}; + return {kind: 'updated', release, etag: res.etag}; }; /** Production fetcher built on Node 18+ native fetch. Honors If-None-Match for cheap polling. */ diff --git a/src/node/updater/index.ts b/src/node/updater/index.ts index 0b6bcc7f7bc..d26a873d19d 100644 --- a/src/node/updater/index.ts +++ b/src/node/updater/index.ts @@ -6,7 +6,7 @@ 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 {isMajorBehind} from './versionCompare'; import {evaluatePolicy} from './UpdatePolicy'; import {decideEmails, decideOutcomeEmail, FailureOutcome} from './Notifier'; import {checkPendingVerification, CheckResult, RollbackDeps, performRollback} from './RollbackHandler'; @@ -132,11 +132,6 @@ const performCheck = async (): Promise => { 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; @@ -164,7 +159,6 @@ const performCheck = async (): Promise => { current, latest: state.latest.version, latestTag: state.latest.tag, - isVulnerable: isVulnerable(current, state.vulnerableBelow), isSevere: isMajorBehind(current, state.latest.version), state: state.email, now, diff --git a/src/node/updater/state.ts b/src/node/updater/state.ts index 64492763aef..dd8113a76ae 100644 --- a/src/node/updater/state.ts +++ b/src/node/updater/state.ts @@ -78,14 +78,6 @@ const isValidLatest = (v: unknown): boolean => { && 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; // graceStartTag (Tier 3) and lastFailureKey (Tier 4) are both optional for @@ -114,7 +106,6 @@ const isValid = (raw: unknown): raw is Partial & object => { if (!isStringOrNull(raw.lastCheckAt)) return false; if (!isStringOrNull(raw.lastEtag)) return false; if (!isValidLatest(raw.latest)) return false; - if (!isValidVulnerableBelow(raw.vulnerableBelow)) return false; if (!isValidEmail(raw.email)) return false; if (raw.execution !== undefined && !isValidExecution(raw.execution)) return false; if (raw.bootCount !== undefined && typeof raw.bootCount !== 'number') return false; diff --git a/src/node/updater/types.ts b/src/node/updater/types.ts index 5ebf3c4cc4e..49c5d5508e5 100644 --- a/src/node/updater/types.ts +++ b/src/node/updater/types.ts @@ -13,8 +13,8 @@ export interface MaintenanceWindow { tz: 'local' | 'utc'; } -/** 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'; +/** null = up-to-date (or not yet checked); 'severe' = at least one major version behind. */ +export type OutdatedLevel = null | 'severe'; export interface ReleaseInfo { /** semver string without leading 'v', e.g. "2.7.2". */ @@ -31,13 +31,6 @@ export interface ReleaseInfo { 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; @@ -114,8 +107,6 @@ export interface UpdateState { 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; /** Current in-flight execution state. Persisted so a restart mid-update reaches RollbackHandler. */ @@ -135,7 +126,6 @@ export const EMPTY_STATE: UpdateState = { lastCheckAt: null, lastEtag: null, latest: null, - vulnerableBelow: [], email: { severeAt: null, vulnerableAt: null, diff --git a/src/tests/backend-new/specs/updater/Notifier.test.ts b/src/tests/backend-new/specs/updater/Notifier.test.ts index e8bbff4af3f..43e7edaaaa9 100644 --- a/src/tests/backend-new/specs/updater/Notifier.test.ts +++ b/src/tests/backend-new/specs/updater/Notifier.test.ts @@ -7,7 +7,6 @@ const base: NotifierInput = { 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'), @@ -43,55 +42,10 @@ describe('decideEmails', () => { 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'}, - }); + it('emits no email when neither severe nor vulnerable', () => { + const r = decideEmails({...base}); 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'); - }); }); describe('decideOutcomeEmail', () => { diff --git a/src/tests/backend-new/specs/updater/VersionChecker.test.ts b/src/tests/backend-new/specs/updater/VersionChecker.test.ts index 56511a28589..818f3a79fd2 100644 --- a/src/tests/backend-new/specs/updater/VersionChecker.test.ts +++ b/src/tests/backend-new/specs/updater/VersionChecker.test.ts @@ -4,7 +4,7 @@ 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', + body: 'Some changes.', prerelease: false, html_url: 'https://github.com/ether/etherpad/releases/tag/v2.7.2', published_at: '2026-04-25T00:00:00Z', @@ -24,14 +24,13 @@ describe('checkLatestRelease', () => { const expected: ReleaseInfo = { version: '2.7.2', tag: 'v2.7.2', - body: 'Some changes.\n', + body: 'Some changes.', 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 () => { diff --git a/src/tests/backend-new/specs/updater/state.test.ts b/src/tests/backend-new/specs/updater/state.test.ts index 391f7172ec5..a83660ba041 100644 --- a/src/tests/backend-new/specs/updater/state.test.ts +++ b/src/tests/backend-new/specs/updater/state.test.ts @@ -62,20 +62,6 @@ describe('loadState', () => { 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)); diff --git a/src/tests/backend/specs/openapi-admin.ts b/src/tests/backend/specs/openapi-admin.ts index eb98f9a0ff9..3495e1ef7d4 100644 --- a/src/tests/backend/specs/openapi-admin.ts +++ b/src/tests/backend/specs/openapi-admin.ts @@ -90,7 +90,6 @@ describe('admin OpenAPI document', function () { 'latest', 'policy', 'tier', - 'vulnerableBelow', ]); }); @@ -104,10 +103,9 @@ describe('admin OpenAPI document', function () { assert.deepEqual(enums.slice().sort(), ['auto', 'autonomous', 'manual', 'notify', 'off']); }); - it('declares ReleaseInfo, PolicyResult, VulnerableBelowDirective sub-schemas', function () { + it('declares ReleaseInfo and PolicyResult sub-schemas', function () { assert.ok(doc.components.schemas.ReleaseInfo); assert.ok(doc.components.schemas.PolicyResult); - assert.ok(doc.components.schemas.VulnerableBelowDirective); }); it('ReleaseInfo properties mirror updater/types.ts', function () { @@ -124,10 +122,6 @@ describe('admin OpenAPI document', function () { ]); }); - it('VulnerableBelowDirective properties mirror updater/types.ts', function () { - const props = Object.keys(doc.components.schemas.VulnerableBelowDirective.properties).sort(); - assert.deepEqual(props, ['announcedBy', 'threshold']); - }); }); describe('cross-collision with public spec', function () { diff --git a/src/tests/backend/specs/updateStatus.ts b/src/tests/backend/specs/updateStatus.ts index e8fb02fa03e..a6033f5e4f2 100644 --- a/src/tests/backend/specs/updateStatus.ts +++ b/src/tests/backend/specs/updateStatus.ts @@ -85,7 +85,6 @@ describe(__filename, function () { 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)); }); it('redacts execution.reason / lastResult.reason for unauth callers', async function () { diff --git a/src/tests/frontend-new/admin-spec/update-banner.spec.ts b/src/tests/frontend-new/admin-spec/update-banner.spec.ts index 9ab0869d242..1f24b15549a 100644 --- a/src/tests/frontend-new/admin-spec/update-banner.spec.ts +++ b/src/tests/frontend-new/admin-spec/update-banner.spec.ts @@ -26,7 +26,7 @@ test.describe('admin update page', () => { installMethod: 'git', tier: 'notify', policy: null, - vulnerableBelow: [], + }), }); }); @@ -59,7 +59,7 @@ test.describe('admin update page', () => { installMethod: 'git', tier: 'notify', policy: {canNotify: true, canManual: false, canAuto: false, canAutonomous: false, reason: 'install-method-not-writable'}, - vulnerableBelow: [], + }), }); }); diff --git a/src/tests/frontend-new/admin-spec/update-page-actions.spec.ts b/src/tests/frontend-new/admin-spec/update-page-actions.spec.ts index bdca6df7e45..8cb0723c25c 100644 --- a/src/tests/frontend-new/admin-spec/update-page-actions.spec.ts +++ b/src/tests/frontend-new/admin-spec/update-page-actions.spec.ts @@ -15,7 +15,6 @@ const baseStatus = { installMethod: 'git', tier: 'manual', policy: {canNotify: true, canManual: true, canAuto: false, canAutonomous: false, reason: 'ok'}, - vulnerableBelow: [], execution: {status: 'idle'}, lastResult: null, lockHeld: false, diff --git a/src/tests/frontend-new/admin-spec/update-scheduled.spec.ts b/src/tests/frontend-new/admin-spec/update-scheduled.spec.ts index e1fe7268cf9..61a8c9afb79 100644 --- a/src/tests/frontend-new/admin-spec/update-scheduled.spec.ts +++ b/src/tests/frontend-new/admin-spec/update-scheduled.spec.ts @@ -15,7 +15,6 @@ const scheduledStatus = (msFromNow: number) => ({ installMethod: 'git', tier: 'auto', policy: {canNotify: true, canManual: true, canAuto: true, canAutonomous: false, reason: 'ok'}, - vulnerableBelow: [], execution: { status: 'scheduled', targetTag: 'v2.7.2', From 35b02d0e8f96509f9e25bf684d7d798808388a94 Mon Sep 17 00:00:00 2001 From: John McLear Date: Mon, 18 May 2026 11:29:25 +0100 Subject: [PATCH 05/17] refactor(updater): drop residual EmailSendLog vulnerable fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove `vulnerableAt` and `vulnerableNewReleaseTag` from the `EmailSendLog` interface, `EMPTY_STATE`, and the `isValidEmail` validator — these backed the removed `vulnerable`/`vulnerable-new-release` email kinds and are now dead code. Co-Authored-By: Claude Sonnet 4.6 --- src/node/updater/state.ts | 2 -- src/node/updater/types.ts | 6 ------ src/tests/backend-new/specs/updater/state.test.ts | 2 +- 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/src/node/updater/state.ts b/src/node/updater/state.ts index dd8113a76ae..62f460dda0b 100644 --- a/src/node/updater/state.ts +++ b/src/node/updater/state.ts @@ -86,8 +86,6 @@ const isValidEmail = (v: unknown): boolean => { const graceOk = v.graceStartTag === undefined || isStringOrNull(v.graceStartTag); const failOk = v.lastFailureKey === undefined || isStringOrNull(v.lastFailureKey); return isStringOrNull(v.severeAt) - && isStringOrNull(v.vulnerableAt) - && isStringOrNull(v.vulnerableNewReleaseTag) && graceOk && failOk; }; diff --git a/src/node/updater/types.ts b/src/node/updater/types.ts index 49c5d5508e5..50bc440e50e 100644 --- a/src/node/updater/types.ts +++ b/src/node/updater/types.ts @@ -43,10 +43,6 @@ export interface PolicyResult { 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; /** Tag of the most recent release for which we sent a Tier 3 `grace-start` email. */ graceStartTag: string | null; /** @@ -128,8 +124,6 @@ export const EMPTY_STATE: UpdateState = { latest: null, email: { severeAt: null, - vulnerableAt: null, - vulnerableNewReleaseTag: null, graceStartTag: null, lastFailureKey: null, }, diff --git a/src/tests/backend-new/specs/updater/state.test.ts b/src/tests/backend-new/specs/updater/state.test.ts index a83660ba041..876d4cf558f 100644 --- a/src/tests/backend-new/specs/updater/state.test.ts +++ b/src/tests/backend-new/specs/updater/state.test.ts @@ -63,7 +63,7 @@ describe('loadState', () => { }); it('returns empty state when email subfield is wrong type', async () => { - const broken = {...EMPTY_STATE, email: {severeAt: 0, vulnerableAt: null, vulnerableNewReleaseTag: null}}; + const broken = {...EMPTY_STATE, email: {severeAt: 0}}; await fs.writeFile(statePath(), JSON.stringify(broken)); const s = await loadState(statePath()); expect(s).toEqual(EMPTY_STATE); From 161f0d6d08e8676a47c5f67bb5e6f002356bb22b Mon Sep 17 00:00:00 2001 From: John McLear Date: Mon, 18 May 2026 11:31:50 +0100 Subject: [PATCH 06/17] feat(updater): add firstAuthorOf helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export firstAuthorOf() from updateStatus.ts — finds the lowest-numbered author attrib in a pad's pool, skipping empty-string placeholders. Covered by 6 vitest cases in tests/backend-new/specs/hooks/express/. Co-Authored-By: Claude Sonnet 4.6 --- src/node/hooks/express/updateStatus.ts | 18 ++++++++ .../specs/hooks/express/firstAuthorOf.test.ts | 42 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 src/tests/backend-new/specs/hooks/express/firstAuthorOf.test.ts diff --git a/src/node/hooks/express/updateStatus.ts b/src/node/hooks/express/updateStatus.ts index 66d1b35e82e..e43c0c572a8 100644 --- a/src/node/hooks/express/updateStatus.ts +++ b/src/node/hooks/express/updateStatus.ts @@ -11,6 +11,24 @@ import {isHeld} from '../../updater/lock'; import {nextWindowStart, parseWindow} from '../../updater/MaintenanceWindow'; +/** + * Returns the authorID of whoever first contributed to the pad — i.e. the + * `['author', X]` entry at the lowest numeric key in the pool, with empty-X + * placeholders skipped. Returns null for a pad with no real author attribs yet. + */ +export const firstAuthorOf = (pad: {pool?: {numToAttrib?: Record}}): string | null => { + const num2attrib = pad?.pool?.numToAttrib; + if (!num2attrib) return null; + const keys = Object.keys(num2attrib).map(Number).sort((a, b) => a - b); + for (const k of keys) { + const a = num2attrib[k]; + if (Array.isArray(a) && a[0] === 'author' && typeof a[1] === 'string' && a[1] !== '') { + return a[1]; + } + } + return null; +}; + let badgeCache: {value: 'severe' | 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. diff --git a/src/tests/backend-new/specs/hooks/express/firstAuthorOf.test.ts b/src/tests/backend-new/specs/hooks/express/firstAuthorOf.test.ts new file mode 100644 index 00000000000..58b23cb2156 --- /dev/null +++ b/src/tests/backend-new/specs/hooks/express/firstAuthorOf.test.ts @@ -0,0 +1,42 @@ +import {describe, expect, it} from 'vitest'; +import {firstAuthorOf} from '../../../../../node/hooks/express/updateStatus'; + +const makePad = (entries: Record): any => ({ + pool: {numToAttrib: entries}, +}); + +describe('firstAuthorOf', () => { + it('returns null for a pad with no attribs', () => { + expect(firstAuthorOf(makePad({}))).toBeNull(); + }); + + it('returns null when no author attribs exist', () => { + expect(firstAuthorOf(makePad({0: ['bold', 'true'], 1: ['italic', 'true']}))).toBeNull(); + }); + + it('returns the only author when there is one', () => { + expect(firstAuthorOf(makePad({0: ['author', 'a.alice']}))).toBe('a.alice'); + }); + + it('returns the lowest-numbered author when there are several', () => { + expect(firstAuthorOf(makePad({ + 0: ['bold', 'true'], + 1: ['author', 'a.alice'], + 2: ['author', 'a.bob'], + }))).toBe('a.alice'); + }); + + it('skips empty-string author placeholders', () => { + expect(firstAuthorOf(makePad({ + 0: ['author', ''], + 1: ['author', 'a.alice'], + }))).toBe('a.alice'); + }); + + it('walks keys in numeric order, not string order', () => { + expect(firstAuthorOf(makePad({ + 10: ['author', 'a.bob'], + 2: ['author', 'a.alice'], + }))).toBe('a.alice'); + }); +}); From 1c96590f47f0c1000efde7aec4d3d83772dc583d Mon Sep 17 00:00:00 2001 From: John McLear Date: Mon, 18 May 2026 11:34:05 +0100 Subject: [PATCH 07/17] feat(updater): add resolveRequestAuthor helper for HTTP GET Co-Authored-By: Claude Sonnet 4.6 --- src/node/hooks/express/updateStatus.ts | 28 ++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/node/hooks/express/updateStatus.ts b/src/node/hooks/express/updateStatus.ts index e43c0c572a8..d3bf11e6b39 100644 --- a/src/node/hooks/express/updateStatus.ts +++ b/src/node/hooks/express/updateStatus.ts @@ -29,6 +29,34 @@ export const firstAuthorOf = (pad: {pool?: {numToAttrib?: Record => { + const readAuthor = (): string | null => { + const a = req?.session?.user?.author; + return typeof a === 'string' && a !== '' ? a : null; + }; + const fromSession = readAuthor(); + if (fromSession !== null) return fromSession; + try { + const expressModule = await import('../express'); + const mw = (expressModule as any).sessionMiddleware; + if (typeof mw !== 'function') return null; + await new Promise((resolve, reject) => { + mw(req, {} as any, (err?: unknown) => (err ? reject(err) : resolve())); + }); + } catch { + return null; + } + return readAuthor(); +}; + let badgeCache: {value: 'severe' | 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. From 790350abecd093dc1b6b1a710c17ea51c9c4fa55 Mon Sep 17 00:00:00 2001 From: John McLear Date: Mon, 18 May 2026 11:36:50 +0100 Subject: [PATCH 08/17] feat(updater): pad-aware /api/version-status with first-author gating Replace global badge cache with a per-(padId, authorId) LRU cache. The new response shape is {outdated: 'minor' | null, isFirstAuthor: boolean}; the old 'severe'/'vulnerable' enum is dropped entirely. computeOutdated now resolves the pad's first author and compares it against the session author before returning outdated:'minor', so the notice is only shown to the person who created the pad. Co-Authored-By: Claude Sonnet 4.6 --- src/node/hooks/express/updateStatus.ts | 84 +++++++++++++++++--------- 1 file changed, 55 insertions(+), 29 deletions(-) diff --git a/src/node/hooks/express/updateStatus.ts b/src/node/hooks/express/updateStatus.ts index d3bf11e6b39..0371ccc58a8 100644 --- a/src/node/hooks/express/updateStatus.ts +++ b/src/node/hooks/express/updateStatus.ts @@ -1,11 +1,12 @@ 'use strict'; import path from 'node:path'; +import {LRUCache} from 'lru-cache'; import {ArgsExpressType} from '../../types/ArgsExpressType'; import settings, {getEpVersion} from '../../utils/Settings'; import {getDetectedInstallMethod, stateFilePath} from '../../updater'; import {evaluatePolicy} from '../../updater/UpdatePolicy'; -import {compareSemver, isMajorBehind} from '../../updater/versionCompare'; +import {isMinorOrMoreBehind} from '../../updater/versionCompare'; import {loadState} from '../../updater/state'; import {isHeld} from '../../updater/lock'; import {nextWindowStart, parseWindow} from '../../updater/MaintenanceWindow'; @@ -57,25 +58,45 @@ export const resolveRequestAuthor = async (req: any): Promise => return readAuthor(); }; -let badgeCache: {value: 'severe' | 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' | null> | null = null; -const BADGE_CACHE_MS = 60 * 1000; +interface OutdatedResponse { + outdated: 'minor' | null; + isFirstAuthor: boolean; +} -const computeOutdated = async (): Promise<'severe' | null> => { - const state = await loadState(stateFilePath()); - if (!state.latest) return null; - const current = getEpVersion(); - if (compareSemver(current, state.latest.version) >= 0) return null; - if (isMajorBehind(current, state.latest.version)) return 'severe'; - return null; +const EMPTY: OutdatedResponse = {outdated: null, isFirstAuthor: false}; + +const TTL_MS = 60 * 1000; +let cache = new LRUCache({max: 1000}); +const inFlight = new Map>(); + +/** Test-only setter: rebuild the LRU with a smaller cap so eviction can be asserted. */ +export const _setBadgeCacheCapForTests = (max: number): void => { + cache = new LRUCache({max}); }; /** 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; + cache.clear(); + inFlight.clear(); +}; + +const computeOutdated = async ( + padId: string | null, + authorId: string | null, +): Promise => { + const state = await loadState(stateFilePath()); + if (!state.latest) return EMPTY; + const current = getEpVersion(); + if (!isMinorOrMoreBehind(current, state.latest.version)) return EMPTY; + if (!padId || !authorId) return EMPTY; + // padManager is loaded via dynamic import to avoid circular-init w/ updater. + const padManagerMod: any = await import('../../db/PadManager'); + const padManager = padManagerMod.default ?? padManagerMod; + if (typeof padManager.isValidPadId !== 'function' || !padManager.isValidPadId(padId)) return EMPTY; + if (!(await padManager.doesPadExist(padId))) return EMPTY; + const pad = await padManager.getPad(padId); + if (firstAuthorOf(pad) !== authorId) return EMPTY; + return {outdated: 'minor', isFirstAuthor: true}; }; // Wrap an async Express handler so a rejected promise becomes next(err) rather than @@ -110,22 +131,27 @@ export const expressCreateServer = ( // 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) => { + // Public endpoint. Cached for 60s per (padId, authorId) key. + app.get('/api/version-status', wrapAsync(async (req, res) => { + const padId = typeof req.query.padId === 'string' ? req.query.padId : null; + const authorId = await resolveRequestAuthor(req); + const key = `${padId ?? ''}|${authorId ?? ''}`; 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}; + + const hit = cache.get(key); + if (hit && now - hit.at <= TTL_MS) { + res.json(hit.value); + return; + } + + let flight = inFlight.get(key); + if (!flight) { + flight = computeOutdated(padId, authorId).finally(() => { inFlight.delete(key); }); + inFlight.set(key, flight); } - res.json({outdated: badgeCache.value}); + const value = await flight; + cache.set(key, {value, at: now}); + res.json(value); })); // Admin UI status endpoint. By default this is open: the running version is already From 6660048a1b919d005fa7d4e3b1417c793492957f Mon Sep 17 00:00:00 2001 From: John McLear Date: Mon, 18 May 2026 11:39:05 +0100 Subject: [PATCH 09/17] fix(updater): switch isSevere signal from major-only to minor-or-more behind Co-Authored-By: Claude Sonnet 4.6 --- src/node/updater/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/node/updater/index.ts b/src/node/updater/index.ts index d26a873d19d..9a8a332e964 100644 --- a/src/node/updater/index.ts +++ b/src/node/updater/index.ts @@ -6,7 +6,7 @@ import settings, {getEpVersion} from '../utils/Settings'; import {detectInstallMethod} from './InstallMethodDetector'; import {checkLatestRelease, realFetcher} from './VersionChecker'; import {loadState, saveState} from './state'; -import {isMajorBehind} from './versionCompare'; +import {isMinorOrMoreBehind} from './versionCompare'; import {evaluatePolicy} from './UpdatePolicy'; import {decideEmails, decideOutcomeEmail, FailureOutcome} from './Notifier'; import {checkPendingVerification, CheckResult, RollbackDeps, performRollback} from './RollbackHandler'; @@ -159,7 +159,7 @@ const performCheck = async (): Promise => { current, latest: state.latest.version, latestTag: state.latest.tag, - isSevere: isMajorBehind(current, state.latest.version), + isSevere: isMinorOrMoreBehind(current, state.latest.version), state: state.email, now, }); From 7314aae2c8cd3e672cf122b0de1a288acf88e250 Mon Sep 17 00:00:00 2001 From: John McLear Date: Mon, 18 May 2026 11:45:07 +0100 Subject: [PATCH 10/17] test(updater): end-to-end coverage for /api/version-status Co-Authored-By: Claude Opus 4.7 (1M context) --- .../specs/hooks/express/updateStatus.test.ts | 315 ++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 src/tests/backend-new/specs/hooks/express/updateStatus.test.ts diff --git a/src/tests/backend-new/specs/hooks/express/updateStatus.test.ts b/src/tests/backend-new/specs/hooks/express/updateStatus.test.ts new file mode 100644 index 00000000000..9e4299bdaf3 --- /dev/null +++ b/src/tests/backend-new/specs/hooks/express/updateStatus.test.ts @@ -0,0 +1,315 @@ +/** + * End-to-end vitest coverage for the /api/version-status route. + * + * Harness: minimal Express app built by calling `expressCreateServer` directly + * (same as production), then exercised via supertest. `loadState` is mocked so + * tests control the "latest" version without touching the filesystem. + * `PadManager` is mocked so pad-creation doesn't require a running database. + * + * Session injection: `resolveRequestAuthor` reads `req.session.user.author` + * directly. A simple before-route middleware sets that property on each + * request, keyed by the `X-Test-Author` request header, so individual tests + * can choose which author the request "belongs to" without needing real + * session middleware. + */ + +import {describe, it, expect, vi, beforeAll, beforeEach, afterEach} from 'vitest'; +import express from 'express'; +import supertest from 'supertest'; +import type {Express} from 'express'; +import type {UpdateState} from '../../../../../node/updater/types'; +import {EMPTY_STATE} from '../../../../../node/updater/types'; + +// --------------------------------------------------------------------------- +// Module mocks — must appear before any import that transitively imports them. +// vi.mock() is hoisted by vitest ahead of all imports, so these factories run +// before updateStatus.ts is loaded and its own `import {loadState}` runs. +// --------------------------------------------------------------------------- + +vi.mock('../../../../../node/updater/state', () => ({ + loadState: vi.fn(), + saveState: vi.fn(), +})); + +// The updater index is imported by updateStatus.ts for stateFilePath() and +// getDetectedInstallMethod(). Provide stubs so we don't boot the full updater. +vi.mock('../../../../../node/updater', () => ({ + stateFilePath: () => '/tmp/test-update-state.json', + getDetectedInstallMethod: () => 'git', +})); + +// PadManager is dynamically imported inside computeOutdated(). Stubbing it +// here lets us control pad existence and author-pool contents without a DB. +vi.mock('../../../../../node/db/PadManager', () => { + const pads = new Map(); + return { + default: { + isValidPadId: (id: string) => /^[^$]{1,50}$/.test(id), + doesPadExist: async (id: string) => pads.has(id), + getPad: async (id: string) => pads.get(id), + }, + // Also expose the map for test setup via the named export __pads__. + __pads__: pads, + }; +}); + +// --------------------------------------------------------------------------- +// Import the SUT *after* vi.mock declarations so the mocks take effect. +// --------------------------------------------------------------------------- + +import * as stateModule from '../../../../../node/updater/state'; +import { + expressCreateServer, + _resetBadgeCacheForTests, + _setBadgeCacheCapForTests, +} from '../../../../../node/hooks/express/updateStatus'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Build the mocked state with optional `latest` field. */ +const makeState = (latest: UpdateState['latest']): UpdateState => ({ + ...EMPTY_STATE, + latest, +}); + +/** A fake ReleaseInfo for a version that is a minor-version ahead of 3.1.0. */ +const MINOR_AHEAD: UpdateState['latest'] = { + version: '3.2.0', + tag: 'v3.2.0', + body: '', + publishedAt: '2026-05-01T00:00:00Z', + prerelease: false, + htmlUrl: 'https://github.com/ether/etherpad/releases/tag/v3.2.0', +}; + +/** A fake ReleaseInfo that is only a patch ahead of 3.1.0 (no minor/major delta). */ +const PATCH_AHEAD: UpdateState['latest'] = { + version: '3.1.1', + tag: 'v3.1.1', + body: '', + publishedAt: '2026-05-01T00:00:00Z', + prerelease: false, + htmlUrl: 'https://github.com/ether/etherpad/releases/tag/v3.1.1', +}; + +/** A fake ReleaseInfo that is behind or equal to 3.1.0 (current >= latest). */ +const SAME_OR_BEHIND: UpdateState['latest'] = { + version: '3.0.0', + tag: 'v3.0.0', + body: '', + publishedAt: '2026-01-01T00:00:00Z', + prerelease: false, + htmlUrl: 'https://github.com/ether/etherpad/releases/tag/v3.0.0', +}; + +// --------------------------------------------------------------------------- +// Test app setup +// --------------------------------------------------------------------------- + +let app: Express; +let request: ReturnType; + +/** + * The author that the fake session middleware will inject into req.session. + * Tests that need a specific author set this before making a request. + * `null` means "anonymous" (no session author). + */ +let sessionAuthor: string | null = null; + +beforeAll(() => { + app = express(); + + // Fake session middleware: sets req.session.user.author from our test + // variable, so resolveRequestAuthor() in the route sees the right identity. + app.use((req: any, _res, next) => { + if (sessionAuthor !== null) { + req.session = {user: {author: sessionAuthor}}; + } + next(); + }); + + // Register the route under test. The hook signature is (hookName, {app, ...}, cb). + expressCreateServer('expressCreateServer', {app, io: null, server: null, settings: null as any}, () => {}); + + request = supertest(app); +}); + +beforeEach(() => { + // Reset LRU cache and in-flight map so every test sees a cold cache. + _resetBadgeCacheForTests(); + // Reset the session author to "anonymous" by default. + sessionAuthor = null; + // Reset the loadState spy so each test controls its own return value. + vi.mocked(stateModule.loadState).mockReset(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// --------------------------------------------------------------------------- +// Helper to get the pad map from the mocked PadManager. +// --------------------------------------------------------------------------- + +const getPadMap = async (): Promise> => { + // Dynamic import returns the mock factory's return value. + const mod: any = await import('../../../../../node/db/PadManager'); + return mod.__pads__ as Map; +}; + +/** Create a minimal fake pad object with the given author at pool position 0. */ +const makePad = (firstAuthorId: string, secondAuthorId?: string) => { + const numToAttrib: Record = { + 0: ['author', firstAuthorId], + }; + if (secondAuthorId) { + numToAttrib[1] = ['author', secondAuthorId]; + } + return {pool: {numToAttrib}}; +}; + +// --------------------------------------------------------------------------- +// Test cases +// --------------------------------------------------------------------------- + +describe('/api/version-status', () => { + // Case 1: loadState returns no `latest` → EMPTY response regardless of author/padId. + it('case 1: returns {outdated:null, isFirstAuthor:false} when state has no latest', async () => { + vi.mocked(stateModule.loadState).mockResolvedValue(makeState(null)); + + const res = await request.get('/api/version-status').query({padId: 'testpad1'}); + expect(res.status).toBe(200); + expect(res.body).toEqual({outdated: null, isFirstAuthor: false}); + }); + + // Case 2: current >= latest → no banner. + it('case 2: returns {outdated:null, isFirstAuthor:false} when current >= latest', async () => { + vi.mocked(stateModule.loadState).mockResolvedValue(makeState(SAME_OR_BEHIND)); + + const res = await request.get('/api/version-status').query({padId: 'testpad2'}); + expect(res.status).toBe(200); + expect(res.body).toEqual({outdated: null, isFirstAuthor: false}); + }); + + // Case 3: delta is patch-only → isMinorOrMoreBehind returns false → no banner. + it('case 3: returns {outdated:null, isFirstAuthor:false} for patch-only delta', async () => { + vi.mocked(stateModule.loadState).mockResolvedValue(makeState(PATCH_AHEAD)); + + const res = await request.get('/api/version-status').query({padId: 'testpad3'}); + expect(res.status).toBe(200); + expect(res.body).toEqual({outdated: null, isFirstAuthor: false}); + }); + + // Case 4: padId omitted → even if behind, route returns EMPTY because padId is null. + it('case 4: returns {outdated:null, isFirstAuthor:false} when padId is omitted', async () => { + vi.mocked(stateModule.loadState).mockResolvedValue(makeState(MINOR_AHEAD)); + + const res = await request.get('/api/version-status'); + expect(res.status).toBe(200); + expect(res.body).toEqual({outdated: null, isFirstAuthor: false}); + }); + + // Case 5: pad exists, request author is NOT pool position 0 → EMPTY. + it('case 5: returns {outdated:null, isFirstAuthor:false} when requester is not first author', async () => { + vi.mocked(stateModule.loadState).mockResolvedValue(makeState(MINOR_AHEAD)); + + const padMap = await getPadMap(); + padMap.set('mypad5', makePad('a.alice', 'a.bob')); + + // Request is made by a.bob (position 1), not a.alice (position 0). + sessionAuthor = 'a.bob'; + + const res = await request.get('/api/version-status').query({padId: 'mypad5'}); + expect(res.status).toBe(200); + expect(res.body).toEqual({outdated: null, isFirstAuthor: false}); + + padMap.delete('mypad5'); + }); + + // Case 6: request author IS pool position 0 AND latest is minor-behind → full badge. + it('case 6: returns {outdated:"minor", isFirstAuthor:true} when requester is first author and minor behind', async () => { + vi.mocked(stateModule.loadState).mockResolvedValue(makeState(MINOR_AHEAD)); + + const padMap = await getPadMap(); + padMap.set('mypad6', makePad('a.alice')); + + sessionAuthor = 'a.alice'; + + const res = await request.get('/api/version-status').query({padId: 'mypad6'}); + expect(res.status).toBe(200); + expect(res.body).toEqual({outdated: 'minor', isFirstAuthor: true}); + + padMap.delete('mypad6'); + }); + + // Case 7: two requests to the same (padId, authorId) → loadState called exactly once (cache hit). + it('case 7: cache hit — loadState called exactly once across two identical requests', async () => { + vi.mocked(stateModule.loadState).mockResolvedValue(makeState(MINOR_AHEAD)); + + const padMap = await getPadMap(); + padMap.set('mypad7', makePad('a.alice')); + sessionAuthor = 'a.alice'; + + await request.get('/api/version-status').query({padId: 'mypad7'}); + await request.get('/api/version-status').query({padId: 'mypad7'}); + + expect(vi.mocked(stateModule.loadState)).toHaveBeenCalledTimes(1); + + padMap.delete('mypad7'); + }); + + // Case 8: different (padId, authorId) pairs → cache entries are independent. + it('case 8: cache isolation — different keys result in separate loadState calls', async () => { + vi.mocked(stateModule.loadState).mockResolvedValue(makeState(MINOR_AHEAD)); + + const padMap = await getPadMap(); + padMap.set('mypad8a', makePad('a.alice')); + padMap.set('mypad8b', makePad('a.bob')); + + sessionAuthor = 'a.alice'; + await request.get('/api/version-status').query({padId: 'mypad8a'}); + + sessionAuthor = 'a.bob'; + await request.get('/api/version-status').query({padId: 'mypad8b'}); + + // Two distinct cache keys → two separate computeOutdated() calls → two loadState calls. + expect(vi.mocked(stateModule.loadState)).toHaveBeenCalledTimes(2); + + padMap.delete('mypad8a'); + padMap.delete('mypad8b'); + }); + + // Case 9: LRU eviction — cap at 2, insert 3 entries, then re-hit key 1 → 4 total loadState calls. + it('case 9: LRU eviction causes re-computation after capacity exceeded', async () => { + vi.mocked(stateModule.loadState).mockResolvedValue(makeState(MINOR_AHEAD)); + + _setBadgeCacheCapForTests(2); + + const padMap = await getPadMap(); + padMap.set('mypad9a', makePad('a.alice')); + padMap.set('mypad9b', makePad('a.bob')); + padMap.set('mypad9c', makePad('a.carol')); + + // First three distinct keys: key1, key2, key3. + // With cap=2, after inserting key3 the LRU evicts the least-recently-used + // (key1, since key2 was accessed after key1). + sessionAuthor = 'a.alice'; + await request.get('/api/version-status').query({padId: 'mypad9a'}); // key1, miss → call 1 + sessionAuthor = 'a.bob'; + await request.get('/api/version-status').query({padId: 'mypad9b'}); // key2, miss → call 2 + sessionAuthor = 'a.carol'; + await request.get('/api/version-status').query({padId: 'mypad9c'}); // key3, miss → call 3, evicts key1 + + // Re-hit key1 → it was evicted, so another miss → call 4. + sessionAuthor = 'a.alice'; + await request.get('/api/version-status').query({padId: 'mypad9a'}); // key1, miss → call 4 + + expect(vi.mocked(stateModule.loadState)).toHaveBeenCalledTimes(4); + + padMap.delete('mypad9a'); + padMap.delete('mypad9b'); + padMap.delete('mypad9c'); + }); +}); From f0690c2b751d46d9290ecc2d3438974359922786 Mon Sep 17 00:00:00 2001 From: John McLear Date: Mon, 18 May 2026 11:48:55 +0100 Subject: [PATCH 11/17] docs(openapi): /api/version-status pad-aware shape and gating Add the /api/version-status GET operation to the admin OpenAPI spec with the new pad-aware response shape: outdated enum reduced to [minor]|null, isFirstAuthor boolean, and an optional padId query param. Co-Authored-By: Claude Sonnet 4.6 --- src/node/hooks/express/openapi-admin.ts | 38 +++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/node/hooks/express/openapi-admin.ts b/src/node/hooks/express/openapi-admin.ts index fc2414ddfb9..634d7efad1d 100644 --- a/src/node/hooks/express/openapi-admin.ts +++ b/src/node/hooks/express/openapi-admin.ts @@ -45,6 +45,44 @@ export const generateAdminDefinition = (): any => ({ }, }, }, + '/api/version-status': { + get: { + operationId: 'getVersionStatus', + summary: 'Outdated-version notice signal for the pad UI', + description: + 'Returns a non-null `outdated` value only to the first author of the supplied pad, ' + + 'and only when the running server is at least one minor version behind the latest ' + + 'published release. Result is cached per (padId, authorId) for 60 s.', + parameters: [ + { + name: 'padId', + in: 'query', + required: false, + schema: {type: 'string'}, + description: + 'Pad whose first-author membership is being checked. ' + + 'Omitted padId always yields a null result.', + }, + ], + responses: { + '200': { + description: 'Outdated-notice signal.', + content: { + 'application/json': { + schema: { + type: 'object', + required: ['outdated', 'isFirstAuthor'], + properties: { + outdated: {type: 'string', enum: ['minor'], nullable: true}, + isFirstAuthor: {type: 'boolean'}, + }, + }, + }, + }, + }, + }, + }, + }, '/admin/update/status': { get: { operationId: 'getUpdateStatus', From c1e812394b599bdcf306e97161c46d85dac4201f Mon Sep 17 00:00:00 2001 From: John McLear Date: Mon, 18 May 2026 11:49:40 +0100 Subject: [PATCH 12/17] chore(pad): remove unused #version-badge template and CSS --- src/static/css/pad.css | 14 -------------- src/templates/pad.html | 1 - 2 files changed, 15 deletions(-) diff --git a/src/static/css/pad.css b/src/static/css/pad.css index c8b90c4c38c..9e529a692ff 100644 --- a/src/static/css/pad.css +++ b/src/static/css/pad.css @@ -115,20 +115,6 @@ input { 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; } /* ----------------------------------------------------------------------- */ /* History mode (issue #7659): timeslider rendered in-place inside the */ diff --git a/src/templates/pad.html b/src/templates/pad.html index c86dae0802f..6b4c32278a4 100644 --- a/src/templates/pad.html +++ b/src/templates/pad.html @@ -645,7 +645,6 @@

Skin Builder

<% e.end_block(); %> - <% e.end_block(); %> From e5340160bebd7f63a219d85a347d1267eb748c6f Mon Sep 17 00:00:00 2001 From: John McLear Date: Mon, 18 May 2026 11:51:25 +0100 Subject: [PATCH 13/17] feat(pad): replace persistent badge with first-author outdated gritter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames pad_version_badge.ts → pad_outdated_notice.ts and rewrites it as a fire-and-forget gritter notice that only shows when the API reports outdated=minor AND the current user is the pad's first author. Wires the new maybeShowOutdatedNotice() call into pad.ts immediately after showPrivacyBannerIfEnabled(). Co-Authored-By: Claude Sonnet 4.6 --- src/static/js/pad.ts | 4 +-- src/static/js/pad_outdated_notice.ts | 43 ++++++++++++++++++++++++++ src/static/js/pad_version_badge.ts | 46 ---------------------------- 3 files changed, 45 insertions(+), 48 deletions(-) create mode 100644 src/static/js/pad_outdated_notice.ts delete mode 100644 src/static/js/pad_version_badge.ts diff --git a/src/static/js/pad.ts b/src/static/js/pad.ts index f176bddd9e6..1da41e0d913 100644 --- a/src/static/js/pad.ts +++ b/src/static/js/pad.ts @@ -55,8 +55,7 @@ const socketio = require('./socketio'); const hooks = require('./pluginfw/hooks'); import {showPrivacyBannerIfEnabled} from './privacy_banner'; - -import './pad_version_badge'; +import {maybeShowOutdatedNotice} from './pad_outdated_notice'; // This array represents all GET-parameters which can be used to change a setting. // name: the parameter-name, eg `?noColors=true` => `noColors` @@ -749,6 +748,7 @@ const pad = { showDeletionTokenModalIfPresent(); showPrivacyBannerIfEnabled((clientVars as any).privacyBanner); + void maybeShowOutdatedNotice(); hooks.aCallAll('postAceInit', {ace: padeditor.ace, clientVars, pad}); }; diff --git a/src/static/js/pad_outdated_notice.ts b/src/static/js/pad_outdated_notice.ts new file mode 100644 index 00000000000..ee050b12ab3 --- /dev/null +++ b/src/static/js/pad_outdated_notice.ts @@ -0,0 +1,43 @@ +'use strict'; + +interface OutdatedResponse { + outdated: 'minor' | null; + isFirstAuthor: boolean; +} + +const apiBasePath = (): string => { + if (typeof window === 'undefined') return '/'; + return new URL('..', window.location.href).pathname; +}; + +const currentPadId = (): string | null => { + const id = (window as any).clientVars?.padId; + return typeof id === 'string' && id.length > 0 ? id : null; +}; + +export const maybeShowOutdatedNotice = async (): Promise => { + const padId = currentPadId(); + if (!padId) return; + const $ = (window as any).$; + if (!$ || !$.gritter || typeof $.gritter.add !== 'function') return; + + try { + const url = `${apiBasePath()}api/version-status?padId=${encodeURIComponent(padId)}`; + const res = await fetch(url, {credentials: 'same-origin'}); + if (!res.ok) return; + const data = (await res.json()) as OutdatedResponse; + if (data.outdated !== 'minor' || !data.isFirstAuthor) return; + + // TODO(i18n): switch to html10n once `pad.outdatedNotice.*` keys land. + $.gritter.add({ + title: 'Etherpad update available', + text: 'A newer version of Etherpad has been released. Consider updating this server.', + sticky: false, + position: 'bottom', + class_name: 'outdated-notice', + time: 8000, + }); + } catch { + /* never block pad load */ + } +}; diff --git a/src/static/js/pad_version_badge.ts b/src/static/js/pad_version_badge.ts deleted file mode 100644 index 15438e07048..00000000000 --- a/src/static/js/pad_version_badge.ts +++ /dev/null @@ -1,46 +0,0 @@ -'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 => { - 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(); - } -} From 3d2b624d70944068cbe1b10e5b86c641a318db47 Mon Sep 17 00:00:00 2001 From: John McLear Date: Mon, 18 May 2026 12:01:44 +0100 Subject: [PATCH 14/17] test(pad): playwright coverage for outdated notice gritter Six Playwright specs exercise maybeShowOutdatedNotice: null response, isFirstAuthor:false guard, positive appearance + text, X-dismiss, 500 server error tolerance, and 8 s auto-fade. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../specs/outdated_notice.spec.ts | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 src/tests/frontend-new/specs/outdated_notice.spec.ts diff --git a/src/tests/frontend-new/specs/outdated_notice.spec.ts b/src/tests/frontend-new/specs/outdated_notice.spec.ts new file mode 100644 index 00000000000..c9af3c33b0b --- /dev/null +++ b/src/tests/frontend-new/specs/outdated_notice.spec.ts @@ -0,0 +1,124 @@ +import {expect, test, Page} from '@playwright/test'; +import {randomUUID} from 'node:crypto'; + +// Gritter items for outdated-notice are rendered into #gritter-container.bottom +// and tagged with class_name:'outdated-notice' so tests can target them +// independently of any other gritter surfaced during the pad session. +const NOTICE = '#gritter-container.bottom .gritter-item.outdated-notice'; + +const freshPad = async (page: Page) => { + // Suppress the pad-deletion-token modal (same technique as goToNewPad in + // padHelper.ts) so it can't race with postAceInit or steal DOM focus. + await page.addInitScript(() => { + let stored: unknown; + Object.defineProperty(window, 'clientVars', { + configurable: true, + get() { return stored; }, + set(v) { + if (v != null && typeof v === 'object') { + (v as {padDeletionToken?: string | null}).padDeletionToken = null; + } + stored = v; + }, + }); + }); + const padId = `FRONTEND_TESTS${randomUUID()}`; + await page.goto(`http://localhost:9001/p/${padId}`); + await page.waitForSelector('iframe[name="ace_outer"]'); + await page.waitForSelector('#editorcontainer.initialized'); + // Wait for the inner editor to be content-editable so postAceInit has fully + // resolved and the async maybeShowOutdatedNotice fetch has been dispatched. + await page.frameLocator('iframe[name="ace_outer"]') + .frameLocator('iframe[name="ace_inner"]') + .locator('#innerdocbody[contenteditable="true"]') + .waitFor({state: 'attached'}); + return padId; +}; + +test.describe('outdated notice (gritter-based)', () => { + test.beforeEach(async ({context}) => { + await context.clearCookies(); + }); + + test('outdated:null — no outdated-notice gritter is shown', async ({page}) => { + await page.route('**/api/version-status*', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({outdated: null, isFirstAuthor: true}), + })); + await freshPad(page); + await expect(page.locator(NOTICE)).toHaveCount(0); + }); + + test('outdated:minor, isFirstAuthor:false — no gritter shown (client guard)', + async ({page}) => { + await page.route('**/api/version-status*', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({outdated: 'minor', isFirstAuthor: false}), + })); + await freshPad(page); + await expect(page.locator(NOTICE)).toHaveCount(0); + }); + + test('outdated:minor, isFirstAuthor:true — gritter appears with correct text', + async ({page}) => { + await page.route('**/api/version-status*', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({outdated: 'minor', isFirstAuthor: true}), + })); + await freshPad(page); + const item = page.locator(NOTICE); + await expect(item).toBeVisible(); + await expect(item.locator('.gritter-title')).toHaveText('Etherpad update available'); + await expect(item).toContainText( + 'A newer version of Etherpad has been released'); + }); + + test('user dismisses by clicking X — gritter disappears', async ({page}) => { + await page.route('**/api/version-status*', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({outdated: 'minor', isFirstAuthor: true}), + })); + await freshPad(page); + const item = page.locator(NOTICE); + await expect(item).toBeVisible(); + await item.locator('.gritter-close').click(); + await expect(page.locator(NOTICE)).toHaveCount(0); + }); + + test('server returns 500 — no gritter and no user-visible error', async ({page}) => { + await page.route('**/api/version-status*', (route) => + route.fulfill({status: 500, body: 'Internal Server Error'})); + await freshPad(page); + // Allow the async fetch to settle before asserting nothing appeared. + await page.waitForTimeout(500); + await expect(page.locator(NOTICE)).toHaveCount(0); + // No generic JS error dialog should have appeared. + await expect(page.locator('#errorpopup')).toHaveCount(0); + }); + + test('auto-fade after 8s — gritter gone after 9s', async ({page}) => { + // This test deliberately waits ~9 s for the gritter's time:8000 to elapse. + // Mark as slow so Playwright allocates a larger timeout budget. + test.slow(); + await page.route('**/api/version-status*', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({outdated: 'minor', isFirstAuthor: true}), + })); + await freshPad(page); + // Confirm it appeared first. + await expect(page.locator(NOTICE)).toBeVisible(); + // Wait for auto-fade (time:8000 ms in the gritter.add call). + await page.waitForTimeout(9000); + await expect(page.locator(NOTICE)).toHaveCount(0); + }); +}); From 58d3619ce1687e10f074f450e219b156e5bb2646 Mon Sep 17 00:00:00 2001 From: John McLear Date: Mon, 18 May 2026 12:04:45 +0100 Subject: [PATCH 15/17] docs(pad): outdated-notice redesign + drop vulnerable-below docs Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 3 +++ doc/admin/updates.md | 19 ++++++++----------- doc/api/http_api.adoc | 28 ++++++++++++++++++++++++++++ doc/api/http_api.md | 21 +++++++++++++++++++++ 4 files changed, 60 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b92a00809aa..eac0736986c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### Notable enhancements +- **pad: Outdated-version notice redesigned (#7799).** The persistent "severely outdated" banner is replaced by a dismissable gritter notification (auto-fades after 8 seconds), shown only to a pad's first author and only when the server is at least one minor version behind the latest released version. Patch-only deltas no longer fire the notice. The `vulnerable-below` directive scraping, the `severe` and `vulnerable` enum values, and the `vulnerableBelow` state field have been removed. +- **API: `GET /api/version-status` updated (#7799).** Now accepts an optional `?padId=` query parameter and returns `{outdated: "minor" | null, isFirstAuthor: boolean}`. The `severe` and `vulnerable` enum values are gone. Results are cached per `(padId, authorId)` for 60 seconds. + - **Self-update — Tier 4 (autonomous in a maintenance window).** Set `updates.tier: "autonomous"` together with `updates.maintenanceWindow: {"start":"HH:MM","end":"HH:MM","tz":"local"|"utc"}` to constrain autonomous updates to a nightly window. The scheduler snaps `scheduledFor` forward to the next window opening when grace would otherwise land outside the window, and defers the fire when the window has closed by the timer callback. Cross-midnight windows (`end < start`) are supported; DST transitions are absorbed by host wall-clock arithmetic. A missing or malformed window degrades the policy to Tier 3 with an explicit `policy.reason` of `maintenance-window-missing` / `maintenance-window-invalid`; an admin banner surfaces the misconfiguration so autonomous behaviour is not silently disabled. The admin update page shows a "Maintenance window" section with the parsed window summary, the next opening, and a "deferred until " subtitle on the scheduled panel when the timer has been snapped forward. Closes #7607 (#7753). - **Updater — real SMTP via nodemailer (new top-level `mail.*` block).** Replaces the "(would send email)" stub. New settings: `mail.host`, `mail.port`, `mail.secure`, `mail.from`, `mail.auth.{user,pass}`. `mail.host=null` keeps the legacy log-only behaviour. The `nodemailer` dependency is lazy-imported on first send so installs that don't configure mail pay no runtime cost; the transport is cached on the full SMTP options tuple so a `reloadSettings()` change to host/port/credentials invalidates the cache. `settings.json.docker` reads `MAIL_HOST` / `MAIL_FROM` / `MAIL_PORT` / `MAIL_SECURE` from env. Send errors are logged warn and swallowed so a transient SMTP failure can never poison the updater state machine. - **Updater — preflight against the target tag's `engines.node`.** Before mutating the working tree, `runPreflight` now runs `git show :package.json` and verifies `process.versions.node` satisfies the target's `engines.node`. A mismatch fails cleanly at `preflight-failed` with the detail `target requires Node >=X, running Y` — no drain, no restart, no rollback. The check runs *after* signature verification so we only trust signed `package.json`. New `PreflightReason: 'node-engine-mismatch'`. diff --git a/doc/admin/updates.md b/doc/admin/updates.md index f1a934905c4..f145d3b7712 100644 --- a/doc/admin/updates.md +++ b/doc/admin/updates.md @@ -2,7 +2,7 @@ Etherpad ships with a built-in update subsystem. -- **Tier 1 (notify)** — 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 execution. +- **Tier 1 (notify)** — default. A banner appears in the admin UI when a new release is available, and pad users see a dismissable gritter notification if the running version is at least one minor version behind the latest release. No execution. - **Tier 2 (manual click)** — admins on a git install can click "Apply update" at `/admin/update`. Etherpad drains active sessions, runs `git fetch / checkout / pnpm install / pnpm run build:ui`, and exits with code 75 so a process supervisor restarts it on the new version. Auto-rolls back on failure. - **Tier 3 (auto with grace window)** — opt-in. On a git install, a newly detected release transitions execution state to `scheduled` and is applied after `preApplyGraceMinutes`. During the grace window, `/admin/update` shows a live countdown plus Cancel and Apply now buttons; an admin email (if `adminEmail` is set) fires once per scheduled tag. - **Tier 4 (autonomous in maintenance window)** — opt-in. Tier 3 + `updates.maintenanceWindow` is required; the scheduler only fires while the wall clock is inside the configured window. Updates detected outside the window queue for the next opening. @@ -52,30 +52,27 @@ In `settings.json`: ## 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. +- **`minor`** — the running server is at least one minor version behind the latest published release. Patch-only deltas (same major and minor, higher patch) do not fire the notice. ## 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 | +| Outdated (minor or more behind) detected | Immediate | Monthly while still 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-side notice -Pad users see no version information by default. A small badge appears in the bottom-right corner only when: +Pad users see no version information by default. A dismissable gritter notification appears only when: -- The instance is `severe` (one or more major versions behind), or -- The instance is `vulnerable` (running below an announced threshold). +- The running server is at least one minor version behind the latest published release (patch-only deltas do not fire), **and** +- The requesting user is the first author of the pad. -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. +The notice auto-fades after 8 seconds and can be dismissed immediately. The public endpoint `/api/version-status` accepts an optional `?padId=` query parameter and returns `{outdated: "minor" | null, isFirstAuthor: boolean}` — it never leaks the running version, so attackers do not gain a fingerprint vector. Results are cached per `(padId, authorId)` for 60 seconds. ## Disabling everything diff --git a/doc/api/http_api.adoc b/doc/api/http_api.adoc index 19c2839b44d..a6e04cbbd8f 100644 --- a/doc/api/http_api.adoc +++ b/doc/api/http_api.adoc @@ -712,3 +712,31 @@ get stats of the etherpad instance _Example returns_: * `{"code":0,"message":"ok","data":{"totalPads":3,"totalSessions": 2,"totalActivePads": 1}}` + +===== `GET /api/version-status` + +Returns an outdated-version signal intended for the pad-side gritter. + +*Query parameters:* + +[cols="1,1,1,3"] +|=== +| name | type | required | description + +| `padId` +| string +| no +| Pad whose first-author membership is being checked. +|=== + +*Response 200 (`application/json`):* + +[source,json] +---- +{ + "outdated": "minor", + "isFirstAuthor": true +} +---- + +`outdated` is `"minor"` only when the running server is at least one minor version behind the latest published release AND the request resolves to the pad's first author. Otherwise it is `null`. Result is cached per `(padId, authorId)` for 60s. The endpoint is disabled entirely when `updates.tier = 'off'`. diff --git a/doc/api/http_api.md b/doc/api/http_api.md index c9c57bfeb32..32c0f8424f7 100644 --- a/doc/api/http_api.md +++ b/doc/api/http_api.md @@ -764,3 +764,24 @@ get stats of the etherpad instance {"code":0,"message":"ok","data":{"totalPads":3,"totalSessions": 2,"totalActivePads": 1}} ``` +#### `GET /api/version-status` + +Returns an outdated-version signal intended for the pad-side gritter. + +**Query parameters:** + +| name | type | required | description | +| ------- | ------ | -------- | --------------------------------------------------------------------------- | +| `padId` | string | no | Pad whose first-author membership is being checked. | + +**Response 200 (`application/json`):** + +```json +{ + "outdated": "minor", + "isFirstAuthor": true +} +``` + +`outdated` is `"minor"` only when the running server is at least one minor version behind the latest published release AND the request resolves to the pad's first author. Otherwise it is `null`. Result is cached per `(padId, authorId)` for 60s. The endpoint is disabled entirely when `updates.tier = 'off'`. + From cb3e194493bfd373e0cd09c8e0facc50664f9181 Mon Sep 17 00:00:00 2001 From: John McLear Date: Mon, 18 May 2026 12:15:47 +0100 Subject: [PATCH 16/17] chore(test): remove stale specs for deleted #version-badge surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete the GET /api/version-status describe block from the legacy mocha spec (asserted outdated:null and outdated:'severe' — both no longer match the new response shape). The new vitest spec at tests/backend-new/specs/hooks/express/updateStatus.test.ts covers this surface comprehensively. Delete src/tests/frontend-new/specs/pad-version-badge.spec.ts entirely: all three tests reference the #version-badge DOM element removed in Task 8 and stub 'severe'/'vulnerable' enum values that no longer exist. Co-Authored-By: Claude Sonnet 4.6 --- src/tests/backend/specs/updateStatus.ts | 29 ------------ .../specs/pad-version-badge.spec.ts | 47 ------------------- 2 files changed, 76 deletions(-) delete mode 100644 src/tests/frontend-new/specs/pad-version-badge.spec.ts diff --git a/src/tests/backend/specs/updateStatus.ts b/src/tests/backend/specs/updateStatus.ts index a6033f5e4f2..15f8784b8e3 100644 --- a/src/tests/backend/specs/updateStatus.ts +++ b/src/tests/backend/specs/updateStatus.ts @@ -44,35 +44,6 @@ describe(__filename, function () { 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 diff --git a/src/tests/frontend-new/specs/pad-version-badge.spec.ts b/src/tests/frontend-new/specs/pad-version-badge.spec.ts deleted file mode 100644 index 454e5f12e12..00000000000 --- a/src/tests/frontend-new/specs/pad-version-badge.spec.ts +++ /dev/null @@ -1,47 +0,0 @@ -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'); - }); -}); From 718f7236d77d71b45dbbf988a1604c4bb7bb481e Mon Sep 17 00:00:00 2001 From: John McLear Date: Mon, 18 May 2026 12:15:55 +0100 Subject: [PATCH 17/17] chore: clean stale references to vulnerable/severe in types, emails, docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove OutdatedLevel type (null|'severe') from types.ts — no consumers remain after the badge redesign removed the severe tier. - Fix Notifier severe-email body: was "more than one major release behind" but isSevere now fires on minor-or-more, so update to "at least one minor release behind the latest published version". - Drop "vulnerability directives" from the /admin/update/status OpenAPI description; replace with the actual response fields. - Remove stale vulnerableBelow field from UpdateStatusPayload in admin/src/store/store.ts — server no longer sends it. - Fix docs/admin/updates.md: "pad-side badge" → "pad-side notice". Co-Authored-By: Claude Sonnet 4.6 --- admin/src/store/store.ts | 1 - doc/admin/updates.md | 2 +- src/node/hooks/express/openapi-admin.ts | 2 +- src/node/updater/Notifier.ts | 4 ++-- src/node/updater/types.ts | 3 --- 5 files changed, 4 insertions(+), 8 deletions(-) diff --git a/admin/src/store/store.ts b/admin/src/store/store.ts index 5643f9ebebf..42b56c1aad3 100644 --- a/admin/src/store/store.ts +++ b/admin/src/store/store.ts @@ -45,7 +45,6 @@ export interface UpdateStatusPayload { installMethod: string; tier: string; policy: null | {canNotify: boolean; canManual: boolean; canAuto: boolean; canAutonomous: boolean; reason: string}; - vulnerableBelow: Array<{announcedBy: string; threshold: string}>; // Tier 2 additions: execution: Execution; lastResult: LastResult; diff --git a/doc/admin/updates.md b/doc/admin/updates.md index f145d3b7712..55ae41d371f 100644 --- a/doc/admin/updates.md +++ b/doc/admin/updates.md @@ -61,7 +61,7 @@ In `settings.json`: | Outdated (minor or more behind) detected | Immediate | Monthly while still 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. +If `adminEmail` is unset, the updater never sends mail. The admin UI banner and the pad-side notice 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. diff --git a/src/node/hooks/express/openapi-admin.ts b/src/node/hooks/express/openapi-admin.ts index 634d7efad1d..b1074e3f812 100644 --- a/src/node/hooks/express/openapi-admin.ts +++ b/src/node/hooks/express/openapi-admin.ts @@ -89,7 +89,7 @@ export const generateAdminDefinition = (): any => ({ summary: 'Fetch updater status for the admin UI banner and update page', description: 'Returns the cached update state (current version, latest known release, ' + - 'install method, tier, policy verdict, and vulnerability directives). ' + + 'install method, tier, policy verdict, execution state, lastResult, and lockHeld). ' + 'Open by default; gated to authenticated admin sessions when ' + 'updates.requireAdminForStatus=true in settings.', security: [ diff --git a/src/node/updater/Notifier.ts b/src/node/updater/Notifier.ts index 6ee6b047279..9d2d39097b5 100644 --- a/src/node/updater/Notifier.ts +++ b/src/node/updater/Notifier.ts @@ -53,8 +53,8 @@ export const decideEmails = (input: NotifierInput): NotifierResult => { 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}.`, + subject: `[Etherpad] Your instance is outdated (${current})`, + body: `Your Etherpad version (${current}) is at least one minor release behind the latest published version (${latest}). Consider scheduling an upgrade.`, }); newState.severeAt = now.toISOString(); } diff --git a/src/node/updater/types.ts b/src/node/updater/types.ts index 50bc440e50e..5396e51293a 100644 --- a/src/node/updater/types.ts +++ b/src/node/updater/types.ts @@ -13,9 +13,6 @@ export interface MaintenanceWindow { tz: 'local' | 'utc'; } -/** null = up-to-date (or not yet checked); 'severe' = at least one major version behind. */ -export type OutdatedLevel = null | 'severe'; - export interface ReleaseInfo { /** semver string without leading 'v', e.g. "2.7.2". */ version: string;