feat(updater): tier 1 — notify admin and pad users of available updates (#7601)

* docs(updater): add four-tier auto-update design spec

Four-tier opt-in self-update subsystem (off / notify / manual / auto / autonomous).
GitHub Releases as source of truth; install-method auto-detection with admin
override; in-process execution with supervisor restart; 60s drain + announce;
auto-rollback on health-check failure with crash-loop guard. Pad-side severe/
vulnerable badge that does not leak the running version. Top-level adminEmail
with escalating cadence (weekly while vulnerable, monthly while severe).

Refs: docs/superpowers/specs/2026-04-25-auto-update-design.md

* docs(updater): add PR 1 (Tier 1 notify) implementation plan

Bite-sized TDD task breakdown for shipping Tier 1 notify only:
- VersionChecker, InstallMethodDetector, UpdatePolicy, Notifier, state modules
- /admin/update/status (admin-auth) and /api/version-status (public, no version leak)
- Admin UI banner + read-only update page + nav link
- Pad-side severe/vulnerable footer badge
- Settings: updates.* block + top-level adminEmail
- Tests: vitest unit + mocha integration + Playwright admin/pad
- CHANGELOG + doc/admin/updates.md

PRs 2-4 (manual/auto/autonomous) get their own plans after PR 1 lands.

* feat(updater): add shared types for auto-update subsystem

* feat(updater): clarify OutdatedLevel and EMPTY_STATE doc, drop path header

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(updater): add semver helpers and vulnerable-below parser

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(updater): tighten semver regex to reject four-part versions

* feat(updater): add state persistence with schema validation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(updater): reject null email and array latest in state validation

typeof null === 'object' meant {email:null} passed the old isValid check,
which would crash downstream Notifier code reading email.severeAt. Likewise,
an array would pass the typeof latest === 'object' branch. Introduce
isPlainObject helper (null-safe, Array.isArray guard) and use it for both
fields. Adds two regression tests covering the exact broken inputs.

* feat(updater): add install-method detector with override

* feat(updater): add policy evaluator

* feat(updater): add GitHub Releases checker with ETag support

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(updater): validate release fields and preserve ETag on prerelease

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(updater): add email cadence decider

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(updater): tagChanged email fires regardless of cadence; drop unused field

* feat(settings): add updates.* and adminEmail settings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(updater): wire boot hook and periodic checker

Register expressCreateServer/shutdown hooks in ep.json and implement
the boot-wiring module that detects install method, starts the polling
interval and runs the notifier dedupe pass each tick.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(updater): add /admin/update/status and /api/version-status endpoints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* i18n(updater): add english strings for update banner, page, and pad badge

* feat(updater): add pad footer badge for severe/vulnerable status

* feat(admin-ui): add update banner, page, and nav link

Add UpdateStatusPayload to the zustand store, a persistent UpdateBanner
rendered in the App layout, a /update page showing version details and
changelog, and a Bell nav link — all wired to the /admin/update/status
endpoint added in Task 10.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(updater): add Playwright specs for admin banner/page and pad badge

* docs(updater): document tier 1 settings, badge, email cadence

* refactor(updater): dedupe helpers, fix misleading log, add banner styling

- Export stateFilePath from index.ts and import it in updateStatus.ts (removes local duplicate)
- Import getEpVersion from Settings.ts in both index.ts and updateStatus.ts (removes two local definitions)
- Fix misleading 'backing off' log message — no backoff is implemented, just retries at next interval
- Remove EMPTY_STATE_FOR_TESTS re-export from state.ts; state.test.ts now imports EMPTY_STATE directly from types.ts
- Add .update-banner and .update-page CSS rules to admin/src/index.css

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(updater): address review feedback — async wrap, tier=off skip, poll race, opt-in admin gate

- Wrap /api/version-status and /admin/update/status with a small async helper
  so a rejected promise becomes next(err) instead of an unhandled rejection.
- Short-circuit route registration when updates.tier === 'off' so the heavier
  opt-out also removes the HTTP surface (matches pre-PR behavior for that case).
- Add an in-flight guard around performCheck() so overlapping interval ticks
  can't race on update-state.json writes or duplicate email decisions; track
  the initial setTimeout handle and clear it in shutdown().
- Add updates.requireAdminForStatus (default false) so admins can lock
  /admin/update/status to authenticated admin sessions without disabling the
  updater. Default false preserves current behavior (the running version is
  already exposed publicly via /health). Backend specs cover unauth → 401,
  non-admin → 403, admin → 200.
- Bump admin troubleshooting menu count test 5 → 6 to account for the new
  Update nav link.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(updater): address Qodo round-2 review feedback

Round 2 of Qodo review on #7601. Addressing the action-required items:

#1 Badge bypassed pad baseURL — derive basePath the same way
   padBootstrap.js does (`new URL('..', window.location.href).pathname`)
   and prefix the fetch with it. Subpath deployments now reach
   /<prefix>/api/version-status instead of 404ing.

#2 Updater poller could get stuck — `getCurrentState()` is now inside
   the try/finally so a one-time loadState() rejection can't leave
   `checkInFlight=true` and permanently silence polling.

#3 Updates off hung admin page — UpdatePage now self-fetches and
   renders explicit `disabled` (404), `unauthorized` (401/403), and
   `error` states instead of staying on "Loading...". Banner-driven
   prefetch is still honoured if it landed first.

#11 NaN polling interval — coerce `checkIntervalHours` to a number,
   clamp to [1h, 168h], log a warning and fall back to 6h on
   non-finite input. Math.max(1, NaN) === NaN previously meant a
   malformed settings.json could turn the poller into a tight loop.

#13 State validation accepted broken subfields — `isValid()` now
   inspects `latest.{version,tag,body,publishedAt,htmlUrl,prerelease}`,
   `vulnerableBelow[].{announcedBy,threshold}`, and
   `email.{severeAt,vulnerableAt,vulnerableNewReleaseTag}`. A
   hand-edited file with a number where a string is expected is now
   treated as corrupt and reset to EMPTY_STATE rather than crashing
   later in semver parsing or email rendering.

#14 Badge cache stampede — wrap `computeOutdated()` in a single-flight
   promise so concurrent requests at cache expiry await one shared
   computation instead of fanning out into N redundant disk reads.

Plus six new state.test.ts cases covering each new validation guard.

Pushing back on the remaining items:

#4 `updates.tier` defaults to `notify` — intentional. The whole point
   of tier 1 is to surface the "you are behind" signal to admins by
   default. Opt-in defeats the purpose; the existing failure mode
   (admin never hears about a security-relevant release) is exactly
   what this PR is fixing.

#5/#8 Admin status endpoint admin-auth — `currentVersion` is already
   public via `/health`, so wrapping the route in admin-auth doesn't
   reduce the disclosure surface meaningfully. Operators who want it
   gated set `updates.requireAdminForStatus=true` (already wired and
   covered by the comment on the route handler).

#10 Plain `https://` URLs in planning doc — planning markdown is
   viewed in editors and on GitHub where protocol-relative URLs would
   either render literally or break entirely. Keeping `https://`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
John McLear 2026-05-01 20:02:12 +08:00 committed by GitHub
parent 85f9a5f2f5
commit e39dbde887
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 4636 additions and 4 deletions

View file

@ -0,0 +1,94 @@
'use strict';
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 {loadState} from '../../updater/state';
let badgeCache: {value: 'severe' | 'vulnerable' | 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;
const BADGE_CACHE_MS = 60 * 1000;
const computeOutdated = async (): Promise<'severe' | 'vulnerable' | 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;
};
/** 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;
};
// Wrap an async Express handler so a rejected promise becomes next(err) rather than
// an unhandled rejection. Mirrors the .catch(next) pattern used elsewhere in the repo.
const wrapAsync = (fn: (req: any, res: any, next: Function) => Promise<unknown>) =>
(req: any, res: any, next: Function) => {
Promise.resolve(fn(req, res, next)).catch((err) => next(err));
};
export const expressCreateServer = (
_hookName: string,
{app}: ArgsExpressType,
cb: Function,
): void => {
// 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) => {
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};
}
res.json({outdated: badgeCache.value});
}));
// Admin UI status endpoint. By default this is open: the running version is already
// exposed publicly via /health, and latest/changelog come from a public GitHub
// release. Admins who want the endpoint gated to authenticated admin sessions —
// without disabling the updater entirely — set updates.requireAdminForStatus=true.
app.get('/admin/update/status', wrapAsync(async (req, res) => {
if (settings.updates.requireAdminForStatus) {
const user = req.session?.user;
if (!user) return res.status(401).send('Authentication required');
if (!user.is_admin) return res.status(403).send('Forbidden');
}
const state = await loadState(stateFilePath());
const current = getEpVersion();
const installMethod = getDetectedInstallMethod();
const policy = state.latest
? evaluatePolicy({installMethod, tier: settings.updates.tier, current, latest: state.latest.version})
: null;
res.json({
currentVersion: current,
latest: state.latest,
lastCheckAt: state.lastCheckAt,
installMethod,
tier: settings.updates.tier,
policy,
vulnerableBelow: state.vulnerableBelow,
});
}));
cb();
};

View file

@ -0,0 +1,46 @@
import fs from 'node:fs/promises';
import {constants as fsConstants} from 'node:fs';
import path from 'node:path';
import {InstallMethod} from './types';
export interface DetectOptions {
/** Setting from settings.json. "auto" means detect; anything else is forced. */
override: InstallMethod;
/** Root directory of the Etherpad install. */
rootDir: string;
/** Path to /.dockerenv (overridable for tests). */
dockerEnvPath?: string;
}
const exists = async (p: string): Promise<boolean> => {
try { await fs.access(p, fsConstants.F_OK); return true; } catch { return false; }
};
const writable = async (p: string): Promise<boolean> => {
try { await fs.access(p, fsConstants.W_OK); return true; } catch { return false; }
};
/**
* Detect how Etherpad was installed. Returns 'docker' | 'git' | 'npm' | 'managed'.
* - If `opts.override` is anything other than 'auto', that value is returned unchanged.
* - 'docker' is checked first via `/.dockerenv` (overridable for tests).
* - 'git' requires both a `.git` dir AND a writable rootDir (so we don't try to update read-only checkouts).
* - 'npm' requires a writable `package-lock.json`.
* - 'managed' is the catch-all for installs we can't safely modify.
*/
export const detectInstallMethod = async (
opts: DetectOptions,
): Promise<Exclude<InstallMethod, 'auto'>> => {
if (opts.override !== 'auto') return opts.override;
const dockerEnv = opts.dockerEnvPath ?? '/.dockerenv';
if (await exists(dockerEnv)) return 'docker';
const gitDir = path.join(opts.rootDir, '.git');
if (await exists(gitDir) && await writable(opts.rootDir)) return 'git';
const lockfile = path.join(opts.rootDir, 'package-lock.json');
if (await exists(lockfile) && await writable(lockfile)) return 'npm';
return 'managed';
};

View file

@ -0,0 +1,88 @@
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;
}
export type EmailKind = 'severe' | 'vulnerable' | 'vulnerable-new-release';
export interface PlannedEmail {
kind: EmailKind;
subject: string;
body: string;
}
export interface NotifierResult {
toSend: PlannedEmail[];
newState: EmailSendLog;
}
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;
/**
* 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.
*/
export const decideEmails = (input: NotifierInput): NotifierResult => {
const {adminEmail, current, latest, latestTag, isVulnerable, 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) {
const sinceSevere = sinceMs(state.severeAt, now);
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}.`,
});
newState.severeAt = now.toISOString();
}
}
return {toSend, newState};
};

View file

@ -0,0 +1,42 @@
import {compareSemver} from './versionCompare';
import {InstallMethod, PolicyResult, Tier} from './types';
// For PR 1 (notify only) the writable list contains only 'git'.
// PR 2+ may add 'npm' here as the executor learns to handle that path.
const WRITABLE_METHODS: ReadonlySet<Exclude<InstallMethod, 'auto'>> = new Set(['git']);
export interface PolicyInput {
installMethod: Exclude<InstallMethod, 'auto'>;
tier: Tier;
current: string;
latest: string;
}
/**
* Decide which update tiers are allowed under the given (installMethod, tier, current, latest).
* Pure function no I/O. The single source of truth for "what's allowed in this environment."
* `reason` is one of: 'tier-off' | 'up-to-date' | 'install-method-not-writable' | 'ok'.
*/
export const evaluatePolicy = ({installMethod, tier, current, latest}: PolicyInput): PolicyResult => {
if (tier === 'off') {
return {canNotify: false, canManual: false, canAuto: false, canAutonomous: false, reason: 'tier-off'};
}
if (compareSemver(current, latest) >= 0) {
return {canNotify: false, canManual: false, canAuto: false, canAutonomous: false, reason: 'up-to-date'};
}
const canNotify = true;
const writable = WRITABLE_METHODS.has(installMethod);
if (!writable) {
return {canNotify, canManual: false, canAuto: false, canAutonomous: false, reason: 'install-method-not-writable'};
}
return {
canNotify,
canManual: tier === 'manual' || tier === 'auto' || tier === 'autonomous',
canAuto: tier === 'auto' || tier === 'autonomous',
canAutonomous: tier === 'autonomous',
reason: 'ok',
};
};

View file

@ -0,0 +1,87 @@
import {ReleaseInfo, VulnerableBelowDirective} from './types';
import {parseVulnerableBelow} from './versionCompare';
export interface FetchResult {
status: number;
etag: string | null;
/** Parsed JSON body on 200, otherwise null. */
json: any;
}
/** Adapter so tests can stub the network. Maps URL+ETag to a FetchResult. */
export type Fetcher = (url: string, etag: string | null) => Promise<FetchResult>;
/** Discriminated union of every outcome the checker can return. */
export type CheckResult =
| {kind: 'updated'; release: ReleaseInfo; etag: string | null; vulnerableBelow: VulnerableBelowDirective[]}
| {kind: 'notmodified'}
| {kind: 'ratelimited'}
| {kind: 'skipped-prerelease'; etag: string | null}
| {kind: 'error'; status: number};
export interface CheckOptions {
fetcher: Fetcher;
prevEtag: string | null;
/** GitHub repo as `owner/name`, e.g. `ether/etherpad`. */
repo: string;
}
/**
* Hit `/repos/{repo}/releases/latest` on GitHub. Pass the previous ETag for `If-None-Match`.
* Returns one of: 'updated' | 'notmodified' | 'ratelimited' | 'skipped-prerelease' | 'error'.
*/
export const checkLatestRelease = async (
{fetcher, prevEtag, repo}: CheckOptions,
): Promise<CheckResult> => {
const url = `https://api.github.com/repos/${repo}/releases/latest`;
const res = await fetcher(url, prevEtag);
if (res.status === 304) return {kind: 'notmodified'};
if (res.status === 403 || res.status === 429) return {kind: 'ratelimited'};
if (res.status !== 200 || !res.json) return {kind: 'error', status: res.status};
const j = res.json;
if (j.prerelease) return {kind: 'skipped-prerelease', etag: res.etag};
if (typeof j.tag_name !== 'string' ||
typeof j.html_url !== 'string' ||
typeof j.published_at !== 'string') {
return {kind: 'error', status: 200};
}
const tag = j.tag_name;
const version = tag.replace(/^v/, '');
const body: string = typeof j.body === 'string' ? j.body : '';
const release: ReleaseInfo = {
version,
tag,
body,
publishedAt: j.published_at,
prerelease: false,
htmlUrl: j.html_url,
};
const directiveThreshold = parseVulnerableBelow(body);
const vulnerableBelow: VulnerableBelowDirective[] = directiveThreshold
? [{announcedBy: tag, threshold: directiveThreshold}]
: [];
return {kind: 'updated', release, etag: res.etag, vulnerableBelow};
};
/** Production fetcher built on Node 18+ native fetch. Honors If-None-Match for cheap polling. */
export const realFetcher: Fetcher = async (url, etag) => {
const headers: Record<string, string> = {
'Accept': 'application/vnd.github+json',
'User-Agent': 'etherpad-self-update',
};
if (etag) headers['If-None-Match'] = etag;
const r = await fetch(url, {headers});
const newEtag = r.headers.get('etag');
let json: any = null;
if (r.status === 200) {
try { json = await r.json(); } catch { json = null; }
}
return {status: r.status, etag: newEtag, json};
};

149
src/node/updater/index.ts Normal file
View file

@ -0,0 +1,149 @@
import path from 'node:path';
import log4js from 'log4js';
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 {evaluatePolicy} from './UpdatePolicy';
import {decideEmails} from './Notifier';
import {InstallMethod, UpdateState} from './types';
const logger = log4js.getLogger('updater');
let detectedMethod: Exclude<InstallMethod, 'auto'> = 'managed';
let timer: NodeJS.Timeout | null = null;
let initialTimer: NodeJS.Timeout | null = null;
let checkInFlight = false;
let inMemoryState: UpdateState | null = null;
export const stateFilePath = () => path.join(settings.root, 'var', 'update-state.json');
/** Returns the current state from memory; loads on first call. */
export const getCurrentState = async (): Promise<UpdateState> => {
if (inMemoryState) return inMemoryState;
inMemoryState = await loadState(stateFilePath());
return inMemoryState;
};
export const getDetectedInstallMethod = () => detectedMethod;
const sendEmailViaSmtp = async (to: string, subject: string, body: string): Promise<void> => {
// Etherpad core has no built-in SMTP. PR 1 ships the dedupe machinery without an actual sender;
// subsequent PRs can wire nodemailer or rely on a notification plugin.
logger.info(`(would send email) to=${to} subject="${subject}"`);
void body;
};
const performCheck = async (): Promise<void> => {
if (settings.updates.tier === 'off') return;
// Coalesce overlapping ticks. performCheck mutates shared in-memory state and writes
// it to disk; concurrent runs would race on saveState() and could double-send emails.
if (checkInFlight) return;
checkInFlight = true;
try {
// getCurrentState() can throw on a non-ENOENT fs error from loadState();
// it must run inside the try/finally so checkInFlight is always cleared,
// otherwise a one-time permission error permanently disables polling.
const state = await getCurrentState();
const result = await checkLatestRelease({
fetcher: realFetcher,
prevEtag: state.lastEtag,
repo: settings.updates.githubRepo,
});
const now = new Date();
state.lastCheckAt = now.toISOString();
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;
} else if (result.kind === 'notmodified') {
// 304 — no state change.
} else if (result.kind === 'ratelimited') {
logger.warn('GitHub rate-limited; will retry at next interval');
} else if (result.kind === 'error') {
logger.warn(`GitHub fetch error status=${result.status}`);
}
// Notifier pass: only when we have a known latest, an admin email, and the policy allows notify.
if (state.latest && settings.adminEmail) {
const current = getEpVersion();
const policy = evaluatePolicy({
installMethod: detectedMethod,
tier: settings.updates.tier,
current,
latest: state.latest.version,
});
if (policy.canNotify) {
const decision = decideEmails({
adminEmail: settings.adminEmail,
current,
latest: state.latest.version,
latestTag: state.latest.tag,
isVulnerable: isVulnerable(current, state.vulnerableBelow),
isSevere: isMajorBehind(current, state.latest.version),
state: state.email,
now,
});
for (const email of decision.toSend) {
await sendEmailViaSmtp(settings.adminEmail, email.subject, email.body);
}
state.email = decision.newState;
}
}
await saveState(stateFilePath(), state);
} catch (err) {
logger.warn(`Updater check failed: ${(err as Error).message}`);
} finally {
checkInFlight = false;
}
};
const startPolling = (): void => {
// Coerce in case settings.json carries a non-number (Math.max(1, NaN) === NaN,
// which becomes a tight setInterval loop). Clamp to a sane window: at least 1h
// (don't hammer GitHub) and at most a week (don't silently stop checking).
const rawHours = Number(settings.updates.checkIntervalHours);
const safeHours = Number.isFinite(rawHours) ? Math.min(168, Math.max(1, rawHours)) : 6;
if (safeHours !== rawHours) {
logger.warn(`updates.checkIntervalHours invalid (${settings.updates.checkIntervalHours}); using ${safeHours}h`);
}
const intervalMs = safeHours * 60 * 60 * 1000;
if (timer) clearInterval(timer);
if (initialTimer) clearTimeout(initialTimer);
timer = setInterval(() => { void performCheck(); }, intervalMs);
// Run an immediate first check, but don't block boot. Track the handle so shutdown()
// can cancel it before it fires.
initialTimer = setTimeout(() => { initialTimer = null; void performCheck(); }, 5000);
};
/** Hook entry point — called by ep.json on createServer. */
export const expressCreateServer = async (): Promise<void> => {
detectedMethod = await detectInstallMethod({
override: settings.updates.installMethod,
rootDir: settings.root,
});
logger.info(`updater: install method = ${detectedMethod}, tier = ${settings.updates.tier}`);
if (settings.updates.tier !== 'off') startPolling();
};
/** Shutdown hook. */
export const shutdown = async (): Promise<void> => {
if (timer) { clearInterval(timer); timer = null; }
if (initialTimer) { clearTimeout(initialTimer); initialTimer = null; }
};
/** Exposed for tests / route handlers. */
export const _internal = {
performCheck,
stateFilePath,
};

77
src/node/updater/state.ts Normal file
View file

@ -0,0 +1,77 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import {EMPTY_STATE, UpdateState} from './types';
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
v !== null && typeof v === 'object' && !Array.isArray(v);
const isStringOrNull = (v: unknown): v is string | null =>
v === null || typeof v === 'string';
const isValidLatest = (v: unknown): boolean => {
if (v === null) return true;
if (!isPlainObject(v)) return false;
// Subfields are read into semver parsing and email rendering; if any is
// missing or wrong-type the file is treated as corrupt and reset.
return typeof v.version === 'string'
&& typeof v.tag === 'string'
&& typeof v.body === 'string'
&& typeof v.publishedAt === 'string'
&& typeof v.htmlUrl === 'string'
&& 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;
return isStringOrNull(v.severeAt)
&& isStringOrNull(v.vulnerableAt)
&& isStringOrNull(v.vulnerableNewReleaseTag);
};
// Validate the full shape so loadState() actually delivers on its "safely
// reset on malformed input" contract. Downstream code calls .trim() / semver
// parsing on these subfields and would crash on a hand-edited file otherwise.
const isValid = (raw: unknown): raw is UpdateState => {
if (!isPlainObject(raw)) return false;
return raw.schemaVersion === 1
&& isStringOrNull(raw.lastCheckAt)
&& isStringOrNull(raw.lastEtag)
&& isValidLatest(raw.latest)
&& isValidVulnerableBelow(raw.vulnerableBelow)
&& isValidEmail(raw.email);
};
/** Reads the on-disk state. Returns a fresh empty-state clone when the file is missing, malformed, or has an unknown schemaVersion. Never throws on parse errors. */
export const loadState = async (filePath: string): Promise<UpdateState> => {
let raw: string;
try {
raw = await fs.readFile(filePath, 'utf8');
} catch (err: any) {
if (err.code === 'ENOENT') return structuredClone(EMPTY_STATE);
throw err;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return structuredClone(EMPTY_STATE);
}
if (!isValid(parsed)) return structuredClone(EMPTY_STATE);
return parsed;
};
/** Atomic write via tmp-then-rename. Creates parent directories as needed. */
export const saveState = async (filePath: string, state: UpdateState): Promise<void> => {
await fs.mkdir(path.dirname(filePath), {recursive: true});
const tmp = `${filePath}.tmp`;
await fs.writeFile(tmp, JSON.stringify(state, null, 2));
await fs.rename(tmp, filePath);
};

75
src/node/updater/types.ts Normal file
View file

@ -0,0 +1,75 @@
export type InstallMethod = 'auto' | 'git' | 'docker' | 'npm' | 'managed';
export type Tier = 'off' | 'notify' | 'manual' | 'auto' | 'autonomous';
/** 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';
export interface ReleaseInfo {
/** semver string without leading 'v', e.g. "2.7.2". */
version: string;
/** Original GitHub `tag_name`, e.g. "v2.7.2". */
tag: string;
/** Markdown body of the release. */
body: string;
/** ISO-8601 timestamp from GitHub. */
publishedAt: string;
/** True if GitHub flagged it as a prerelease. */
prerelease: boolean;
/** GitHub HTML URL for the release page. */
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;
canAuto: boolean;
canAutonomous: boolean;
/** Human-readable string explaining the most-restrictive denial, or "ok". */
reason: string;
}
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;
}
export interface UpdateState {
/** Schema version of this file. Increment when fields change. */
schemaVersion: 1;
/** Last time VersionChecker successfully fetched, ISO-8601. */
lastCheckAt: string | null;
/** Last ETag returned by GitHub, used for If-None-Match. */
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;
}
/** Zero-value initial state. Treat as immutable — spread before mutating: `{...EMPTY_STATE, lastCheckAt: x}`. */
export const EMPTY_STATE: UpdateState = {
schemaVersion: 1,
lastCheckAt: null,
lastEtag: null,
latest: null,
vulnerableBelow: [],
email: {
severeAt: null,
vulnerableAt: null,
vulnerableNewReleaseTag: null,
},
};

View file

@ -0,0 +1,53 @@
import type {VulnerableBelowDirective} from './types';
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;
};
export const isMajorBehind = (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 = /<!--\s*updater\s*:\s*vulnerable-below\s+([^\s-][^\s]*)\s*-->/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;
};

View file

@ -299,6 +299,16 @@ export type SettingsType = {
lowerCasePadIds: boolean,
randomVersionString: string,
gitVersion: string
updates: {
tier: 'off' | 'notify' | 'manual' | 'auto' | 'autonomous',
source: 'github',
channel: 'stable',
installMethod: 'auto' | 'git' | 'docker' | 'npm' | 'managed',
checkIntervalHours: number,
githubRepo: string,
requireAdminForStatus: boolean,
},
adminEmail: string | null,
getPublicSettings: () => Pick<SettingsType, "title" | "skinVariants"|"randomVersionString"|"skinName"|"toolbar"| "exposeVersion"| "gitVersion" | "enablePadWideSettings">,
}
@ -431,6 +441,28 @@ const settings: SettingsType = {
* Wether to enable the /stats endpoint. The functionality in the admin menu is untouched for this.
*/
enableMetrics: true,
/**
* Self-update subsystem (PR 1: tier 1 only).
* Tier "off" disables the version check entirely. Default "notify" shows a banner when behind.
*/
updates: {
tier: 'notify',
source: 'github',
channel: 'stable',
installMethod: 'auto',
checkIntervalHours: 6,
githubRepo: 'ether/etherpad',
// The /admin/update/status endpoint returns full info including currentVersion.
// Default false matches existing behavior: the version is already exposed via /health.
// Set true to require an authenticated admin session for the endpoint without
// disabling the updater itself.
requireAdminForStatus: false,
},
/**
* Contact address for admin notifications (updates, future security advisories).
* Null disables outbound mail from the updater.
*/
adminEmail: null,
/**
* Whether certain shortcut keys are enabled for a user in the pad
*/