mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-18 17:13:58 +00:00
* 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>
94 lines
3.9 KiB
TypeScript
94 lines
3.9 KiB
TypeScript
'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();
|
|
};
|