Skip to content

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

Open
JohnMcLear wants to merge 23 commits intoether:developfrom
JohnMcLear:feat/auto-update-tier1
Open

feat(updater): tier 1 — notify admin and pad users of available updates#7601
JohnMcLear wants to merge 23 commits intoether:developfrom
JohnMcLear:feat/auto-update-tier1

Conversation

@JohnMcLear
Copy link
Copy Markdown
Member

Summary

Ships Tier 1 (notify) of the four-tier auto-update design at docs/superpowers/specs/2026-04-25-auto-update-design.md.

  • Periodic poll of GitHub Releases (default 6h, ETag-cached) writes to var/update-state.json.
  • Admin UI banner on every admin page + a dedicated /admin/update page (read-only) with version, install method, tier, changelog.
  • Pad-side discreet footer badge — only renders when severely outdated or running a flagged-vulnerable version. Public endpoint /api/version-status never leaks the running version string.
  • Optional escalating email nudges via new top-level adminEmail setting (weekly while vulnerable, monthly while severely outdated). Real SMTP transport deferred to a follow-up; PR 1 ships the cadence/dedupe machinery.
  • New updates.* settings block with a default of tier: "notify". Set tier: "off" to disable entirely.
  • Tier 1 contains no execution code. PRs 2 (manual click), 3 (auto with grace window) and 4 (autonomous in maintenance window) are designed and will land in subsequent releases.

See doc/admin/updates.md for full configuration.

Architecture

  • src/node/updater/ — pure modules: types, versionCompare, state (atomic write + schema validation), InstallMethodDetector, UpdatePolicy, VersionChecker (fetcher abstraction), Notifier (cadence decider) + boot wiring (index.ts).
  • src/node/hooks/express/updateStatus.ts — public /api/version-status (cached 60s, no version leak) and admin /admin/update/status.
  • Settings in src/node/utils/Settings.ts + settings.json.template + settings.json.docker.
  • Admin UI: admin/src/components/UpdateBanner.tsx, admin/src/pages/UpdatePage.tsx, store slice, route, nav link, CSS.
  • Pad UI: src/static/js/pad_version_badge.ts, src/templates/pad.html, src/static/css/pad.css.
  • i18n: 13 keys in src/locales/en.json.
  • Docs: doc/admin/updates.md (new), CHANGELOG.md (Unreleased section).

Test plan

  • vitest unit tests for versionCompare, state, InstallMethodDetector, UpdatePolicy, VersionChecker, Notifier
  • mocha integration tests for /admin/update/status (admin auth, shape) and /api/version-status (no version leak, severe state)
  • Playwright admin spec — banner render, page render, nav link
  • Playwright pad spec — badge visibility on null / severe / vulnerable
  • Manual smoke (real boot, real curl, browser-tested admin login + banner + page + pad badge with seeded state)
  • pnpm ts-check clean, pnpm run build:ui succeeds

Notes

  • Email transport is intentionally not wired in PR 1 — Notifier logs (would send email). Follow-up PR will add nodemailer or rely on a notification plugin.
  • Pad badge strings are hardcoded English (TODO comment); switching to html10n is a separate PR (the pad uses html10n, the admin uses i18next).
  • All 23 commits on this branch include corresponding tests for the changes they make. Four fix(updater) commits address bugs surfaced during code review (semver regex four-part rejection, null-email/array schema validation, ETag preservation on prerelease, tagChanged email cadence).

🤖 Generated with Claude Code

JohnMcLear and others added 23 commits April 25, 2026 04:38
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
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.
…eader

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
…ints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
…ling

- 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>
@JohnMcLear
Copy link
Copy Markdown
Member Author

/review

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented Apr 25, 2026

Code Review by Qodo

🐞 Bugs (7) 📘 Rule violations (2) 📎 Requirement gaps (0)

Grey Divider


Action required

1. updates.tier defaults to notify 📘 Rule violation ☼ Reliability ⭐ New
Description
The new updater subsystem is enabled by default because updates.tier defaults to notify, which
starts background polling on boot. This violates the requirement that new features be
feature-flagged and disabled by default to avoid changing behavior for existing deployments.
Code

src/node/utils/Settings.ts[R427-438]

+  /**
+   * 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',
+  },
Evidence
PR Compliance ID 5 requires new features to be disabled by default. The default settings set
updates.tier to notify, the updater hook is registered in src/ep.json, and the updater starts
polling whenever the tier is not off, so the feature is active unless a deployer explicitly
disables it.

src/node/utils/Settings.ts[427-438]
settings.json.template[178-190]
src/ep.json[105-118]
src/node/updater/index.ts[108-116]
Best Practice: Repository guidelines

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The updater subsystem is a new feature but is enabled by default because `updates.tier` defaults to `notify`, which starts polling on boot.

## Issue Context
Compliance requires new features to be behind a feature flag and disabled by default. The code currently supports disabling via `updates.tier: "off"`, but the default value is `notify` in both runtime defaults and the settings templates.

## Fix Focus Areas
- src/node/utils/Settings.ts[427-438]
- settings.json.template[178-190]
- settings.json.docker[187-200]
- src/node/updater/index.ts[108-116]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Admin status endpoint public 🐞 Bug ⛨ Security ⭐ New
Description
GET /admin/update/status does not verify req.session.user.is_admin, and webaccess treats only
/admin-auth* as admin-protected, so with default requireAuthentication=false the endpoint can be
accessed unauthenticated and leaks currentVersion, latest, and vulnerableBelow. The included
test only forces auth by flipping requireAuthentication=true, which masks the default behavior.
Code

src/node/hooks/express/updateStatus.ts[R43-60]

+  // Admin-protected. webaccess.ts already gates /admin/* with admin auth.
+  app.get('/admin/update/status', async (_req: any, res: any) => {
+    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,
+    });
+  });
Evidence
The route handler returns sensitive version and vulnerability data with no auth check. Global auth
middleware (webaccess.ts) only sets requireAdmin for paths starting with /admin-auth, and
otherwise allows access when settings.requireAuthentication is false (the default), so
/admin/update/status does not trigger an auth challenge. The integration test explicitly enables
requireAuthentication to make the request fail, indicating the route is not inherently protected.

src/node/hooks/express/updateStatus.ts[43-60]
src/node/hooks/express/webaccess.ts[58-135]
src/node/utils/Settings.ts[570-577]
src/tests/backend/specs/updateStatus.ts[76-87]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`/admin/update/status` is currently reachable without admin authentication under the default configuration (because webaccess only treats `/admin-auth*` as admin-protected and `requireAuthentication` defaults to false). This leaks operational/security-relevant information.

### Issue Context
Admin auth in this codebase is triggered by requesting `/admin-auth/*` (see `webaccess.ts`). New admin-only JSON endpoints should either:
- live under `/admin-auth/...` so the global middleware enforces admin auth, and/or
- explicitly check `req.session.user?.is_admin` and reject otherwise.

### Fix Focus Areas
- src/node/hooks/express/updateStatus.ts[43-60]
- admin/src/components/UpdateBanner.tsx[11-16]
- src/tests/backend/specs/updateStatus.ts[76-87]

### Expected change
1. Move the endpoint to `/admin-auth/update/status` **or** add an explicit admin check in the handler (prefer doing both for defense-in-depth).
2. Update the admin UI fetch URL accordingly.
3. Update/add an integration test that asserts the endpoint is unauthorized when `requireAuthentication` is left at its default (false) and no admin session is present.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Updater checks can overlap 🐞 Bug ☼ Reliability ⭐ New
Description
startPolling() schedules performCheck() via both setInterval and an initial setTimeout
without any in-flight guard, so slow GitHub fetches can cause concurrent performCheck() executions
that mutate the shared cached state and race on writing update-state.json. shutdown() clears
only the interval, not the initial timeout, so a check can still fire after shutdown has begun.
Code

src/node/updater/index.ts[R100-106]

+const startPolling = (): void => {
+  const intervalMs = Math.max(1, settings.updates.checkIntervalHours) * 60 * 60 * 1000;
+  if (timer) clearInterval(timer);
+  timer = setInterval(() => { void performCheck(); }, intervalMs);
+  // Run an immediate first check, but don't block boot.
+  setTimeout(() => { void performCheck(); }, 5000);
+};
Evidence
performCheck() mutates the singleton in-memory UpdateState returned by getCurrentState() and
persists it via saveState(). Because the poller triggers performCheck() without awaiting and
without locking, multiple invocations can interleave and write state out-of-order; additionally, the
initial timeout is not tracked so shutdown cannot cancel it.

src/node/updater/index.ts[20-25]
src/node/updater/index.ts[36-97]
src/node/updater/index.ts[100-121]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The updater poll loop can execute multiple `performCheck()` runs concurrently (interval tick + initial timeout, plus any later overlap if a check runs longer than the interval). Because `performCheck()` mutates shared in-memory state and writes it to disk, concurrent runs can cause state corruption/out-of-order writes and duplicate email decisions.

### Issue Context
`performCheck()` uses `getCurrentState()` which caches a singleton `inMemoryState`, then mutates it and calls `saveState()`.

### Fix Focus Areas
- src/node/updater/index.ts[36-106]
- src/node/updater/index.ts[118-121]

### Expected change
1. Add an in-flight guard (e.g., `let checkInFlight = false;`) so overlapping ticks return early or coalesce.
2. Store the `setTimeout()` handle and clear it in `shutdown()`.
3. Consider changing to a self-scheduling loop (`setTimeout` scheduled *after* an awaited `performCheck()` completes) to guarantee serialization.
4. (Optional) Add a unit/integration test that simulates a slow fetcher to ensure `performCheck()` is not entered concurrently.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (3)
4. Update status routes ignore off 📘 Rule violation ☼ Reliability
Description
The new /api/version-status and /admin/update/status routes are always registered, so behavior
changes even if updates.tier is set to off. This violates the requirement that disabling the
feature flag preserves prior behavior.
Code

src/node/hooks/express/updateStatus.ts[R29-60]

+export const expressCreateServer = (
+  _hookName: string,
+  {app}: ArgsExpressType,
+  cb: Function,
+): void => {
+  // Public endpoint. Cached for 60s. Returns only an enum — no version string.
+  app.get('/api/version-status', async (_req: any, res: any) => {
+    const now = Date.now();
+    if (now - badgeCache.at > BADGE_CACHE_MS) {
+      badgeCache = {value: await computeOutdated(), at: now};
+    }
+    res.json({outdated: badgeCache.value});
+  });
+
+  // Admin-protected. webaccess.ts already gates /admin/* with admin auth.
+  app.get('/admin/update/status', async (_req: any, res: any) => {
+    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,
+    });
+  });
Evidence
PR Compliance ID 5 requires that when the feature flag is disabled, behavior matches what it was
before. The updateStatus hook unconditionally registers new routes, and ep.json unconditionally
enables the hook, so the new API/admin endpoints exist even when updates.tier is off.

src/node/hooks/express/updateStatus.ts[29-60]
src/ep.json[105-118]
Best Practice: Repository guidelines

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`/api/version-status` and `/admin/update/status` are registered regardless of `settings.updates.tier`, so the updater feature cannot be fully disabled.
## Issue Context
Even if `updates.tier` is configured to `off`, the server still exposes new endpoints (a behavior change vs pre-PR). To meet the feature-flag requirement, the route-registration hook should no-op when `tier === 'off'`.
## Fix Focus Areas
- src/node/hooks/express/updateStatus.ts[29-60]
- src/ep.json[105-118]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Admin status leaks version 🐞 Bug ⛨ Security
Description
GET /admin/update/status is not guaranteed to be admin-only because webaccess only treats
/admin-auth* as “admin” and the default requireAuthentication=false allows the request through,
leaking currentVersion publicly. Even with authentication enabled, any authenticated non-admin can
hit the endpoint because requireAdmin is false for this path.
Code

src/node/hooks/express/updateStatus.ts[R43-59]

+  // Admin-protected. webaccess.ts already gates /admin/* with admin auth.
+  app.get('/admin/update/status', async (_req: any, res: any) => {
+    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,
+    });
Evidence
The route returns currentVersion and other internal update metadata. The global auth middleware
only sets requireAdmin when the path starts with /admin-auth, and if authentication is not
required (the default) the middleware grants access to all paths; therefore /admin/update/status
is accessible without admin auth and can leak the running version.

src/node/hooks/express/updateStatus.ts[34-59]
src/node/hooks/express/webaccess.ts[58-60]
src/node/hooks/express/webaccess.ts[127-134]
src/node/utils/Settings.ts[570-577]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`/admin/update/status` currently relies on a comment assumption (“webaccess gates /admin/*”) that is not true with the current `webaccess.ts` logic. As a result, the endpoint can be reachable by unauthenticated users when `requireAuthentication=false` (default) and by non-admin authenticated users when `requireAuthentication=true`, leaking `currentVersion`.
### Issue Context
- `webaccess.ts` uses `requireAdmin = req.path.toLowerCase().startsWith('/admin-auth')`, so `/admin/update/status` is not treated as admin-only.
- The endpoint returns sensitive-ish info (`currentVersion`) that the PR explicitly tries not to expose publicly.
### Fix Focus Areas
- src/node/hooks/express/updateStatus.ts[29-60]
- src/node/hooks/express/webaccess.ts[58-60]
- src/node/hooks/express/webaccess.ts[127-134]
- src/tests/backend/specs/updateStatus.ts[76-87]
### Suggested fix
- In the `/admin/update/status` handler, require `req.session?.user?.is_admin === true`.
- If no session user: respond `401`.
- If session user exists but not admin: respond `403`.
- Add/extend integration tests to cover:
- default `requireAuthentication=false` still denies access
- authenticated non-admin user denied
- admin user allowed

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Async handlers unprotected 🐞 Bug ☼ Reliability
Description
The new Express routes use async handlers directly, so exceptions (for example, loadState()
throwing on EPERM/IO errors) will not be forwarded to Express error handling in this codebase’s
established pattern. This can cause unhandled promise rejections and/or hung requests.
Code

src/node/hooks/express/updateStatus.ts[R35-41]

+  app.get('/api/version-status', async (_req: any, res: any) => {
+    const now = Date.now();
+    if (now - badgeCache.at > BADGE_CACHE_MS) {
+      badgeCache = {value: await computeOutdated(), at: now};
+    }
+    res.json({outdated: badgeCache.value});
+  });
Evidence
Other Express hooks in this repo wrap async logic in an IIFE and .catch(next) to safely propagate
errors; the new routes do not, and they call async functions that can throw (e.g., fs.readFile
failures in loadState).

src/node/hooks/express/updateStatus.ts[14-60]
src/node/hooks/express/importexport.ts[27-71]
src/node/updater/state.ts[22-29]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`updateStatus.ts` registers `async` Express handlers without wrapping them to forward rejections to `next(err)`. In this repo, other handlers explicitly `.catch(next)`, implying Express is not expected to handle async rejections automatically.
### Issue Context
`loadState()` can throw for non-ENOENT read failures (permissions, IO), and those errors would currently bypass Express error handling.
### Fix Focus Areas
- src/node/hooks/express/updateStatus.ts[29-60]
- src/node/hooks/express/importexport.ts[27-71]
### Suggested fix
- Change both routes to the established pattern:
- `app.get(path, (req, res, next) => { (async () => { ... })().catch((err) => next(err || new Error(err))); });`
- Optionally add a small helper (e.g. `wrapAsync`) local to the file to reduce duplication.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

7. Overlapping poll races state 🐞 Bug ☼ Reliability
Description
startPolling() schedules performCheck() via setInterval(() => { void performCheck(); }),
allowing overlapping checks when a run is slow. Concurrent runs can race on the shared in-memory
state object and collide on saveState()’s fixed filePath.tmp, risking state corruption and
email dedupe errors.
Code

src/node/updater/index.ts[R100-106]

+const startPolling = (): void => {
+  const intervalMs = Math.max(1, settings.updates.checkIntervalHours) * 60 * 60 * 1000;
+  if (timer) clearInterval(timer);
+  timer = setInterval(() => { void performCheck(); }, intervalMs);
+  // Run an immediate first check, but don't block boot.
+  setTimeout(() => { void performCheck(); }, 5000);
+};
Evidence
The polling loop does not guard against a previous check still running. The state writer always uses
the same tmp filename, so concurrent saves to the same target path are not safe.

src/node/updater/index.ts[36-106]
src/node/updater/state.ts[40-46]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The updater poller can run multiple `performCheck()` executions concurrently because the interval callback does not await completion and there is no mutex/in-progress flag. This can lead to concurrent mutation of the same `UpdateState` object and concurrent writes that share a fixed `${filePath}.tmp` path.
### Issue Context
Even though the default interval is 6h, slow/hung network requests or filesystem stalls can overlap with the next tick (and the initial `setTimeout` run can also overlap with the first interval tick).
### Fix Focus Areas
- src/node/updater/index.ts[36-106]
- src/node/updater/state.ts[40-46]
### Suggested fix
- Add a module-level `let inProgress = false;` guard in `performCheck()`:
- if `inProgress` return early
- set `inProgress = true` at start
- `finally { inProgress = false; }`
- Optionally:
- use a unique tmp filename (pid + timestamp) or a per-write random suffix to make `saveState()` robust even if concurrency happens again in the future.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. State validation can still crash 🐞 Bug ☼ Reliability
Description
loadState() claims to return empty state for malformed files, but isValid() only checks
top-level types and can accept values that will later throw (e.g., vulnerableBelow: ["x"] or
latest: {}), crashing compareSemver()/isVulnerable() call sites. This makes
/api/version-status and notifier logic fragile to partially corrupted or hand-edited
var/update-state.json.
Code

src/node/updater/state.ts[R8-18]

+// NOTE: We validate top-level shape only. Subfields of `email` and `latest` are
+// trusted because EMPTY_STATE always provides them and only this module writes the file.
+// If a future consumer hand-edits the file, malformed subfields will surface at use site.
+const isValid = (raw: unknown): raw is UpdateState => {
+  if (!isPlainObject(raw)) return false;
+  return raw.schemaVersion === 1
+    && (raw.lastCheckAt === null || typeof raw.lastCheckAt === 'string')
+    && (raw.lastEtag === null || typeof raw.lastEtag === 'string')
+    && (raw.latest === null || isPlainObject(raw.latest))
+    && Array.isArray(raw.vulnerableBelow)
+    && isPlainObject(raw.email);
Evidence
isValid() allows any array for vulnerableBelow and any object for latest, but downstream code
assumes d.threshold and latest.version are strings. parseSemver() calls .trim() on its
input, which will throw if given undefined from a malformed state object that passed validation.

src/node/updater/state.ts[8-18]
src/node/updater/versionCompare.ts[13-27]
src/node/updater/versionCompare.ts[45-52]
src/node/hooks/express/updateStatus.ts[14-21]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`isValid()` is too permissive for fields that are later assumed to be well-typed, which undermines `loadState()`’s documented guarantee to safely reset malformed state.
### Issue Context
Downstream uses:
- `state.latest.version` passed into semver parsing
- `state.vulnerableBelow[].threshold` accessed for comparisons
### Fix Focus Areas
- src/node/updater/state.ts[5-19]
- src/node/updater/versionCompare.ts[13-52]
### Suggested fix
- Extend `isValid()` to validate:
- `latest` object shape when non-null (`version/tag/body/publishedAt/htmlUrl` strings; `prerelease` boolean)
- each `vulnerableBelow` entry is an object with `announcedBy` and `threshold` strings
- `email` object has `severeAt/vulnerableAt/vulnerableNewReleaseTag` as `string|null`
- If any subfield fails validation, treat the file as invalid and return `structuredClone(EMPTY_STATE)`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Advisory comments

9. Version badge cache stampede 🐞 Bug ➹ Performance ⭐ New
Description
At cache expiry, concurrent /api/version-status requests can all run computeOutdated() because
badgeCache.at is only updated after awaiting, causing redundant disk reads. This is read-only but
avoidable work on busy instances.
Code

src/node/hooks/express/updateStatus.ts[R35-40]

+  app.get('/api/version-status', async (_req: any, res: any) => {
+    const now = Date.now();
+    if (now - badgeCache.at > BADGE_CACHE_MS) {
+      badgeCache = {value: await computeOutdated(), at: now};
+    }
+    res.json({outdated: badgeCache.value});
Evidence
The cache timestamp is only updated after await computeOutdated(), so other requests arriving
before it resolves will also see an expired cache and recompute.

src/node/hooks/express/updateStatus.ts[35-40]
src/node/hooks/express/updateStatus.ts[14-22]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
Multiple concurrent requests can all recompute the badge value when the cache expires because cache state is only updated after awaiting.

### Issue Context
This is a read-only endpoint, so the impact is redundant `loadState()` calls.

### Fix Focus Areas
- src/node/hooks/express/updateStatus.ts[11-40]

### Expected change
Implement coalescing, e.g.:
- Set `badgeCache.at = now` before awaiting and keep a separate `inFlight` promise, or
- Maintain `let badgeInFlight: Promise<...> | null` and await it when present.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

Review Summary by Qodo

feat(updater): Tier 1 — notify admin and pad users of available updates

✨ Enhancement 🧪 Tests 📝 Documentation

Grey Divider

Walkthroughs

Description
• Implements **Tier 1 (notify)** of the four-tier auto-update design: periodic GitHub Releases
  polling with ETag caching writes to var/update-state.json
• **Backend modules** (src/node/updater/): pure functions for semantic version comparison, GitHub
  API polling, install method detection, update policy evaluation, and email notification
  cadence/deduplication
• **Admin UI**: banner on every admin page + dedicated /admin/update page displaying
  current/latest versions, install method, tier, and changelog
• **Pad UI**: discreet footer badge (hidden when up-to-date, shows text when severely outdated or
  vulnerable) via public /api/version-status endpoint that never leaks version strings
• **Email notifications**: optional escalating cadence (weekly while vulnerable, monthly while
  severely outdated) via new adminEmail setting; real SMTP transport deferred to follow-up PR
• **Settings**: new updates.* block with tier, source, channel, installMethod, checkIntervalHours,
  githubRepo; defaults to tier: "notify" (set to "off" to disable)
• **Comprehensive test coverage**: vitest unit tests for all pure modules, mocha integration tests
  for endpoints, Playwright tests for admin/pad UI
• **Documentation**: admin guide (doc/admin/updates.md), design specification, implementation
  plan, and i18n (13 keys in en.json)
• Tier 1 contains **no execution code**; PRs 2–4 (manual click, auto with grace window, autonomous
  in maintenance window) are designed and will land in subsequent releases
Diagram
flowchart LR
  GitHub["GitHub Releases API"]
  Poller["VersionChecker<br/>ETag-cached polling"]
  State["update-state.json<br/>Atomic persistence"]
  Notifier["Notifier<br/>Cadence & dedup"]
  AdminUI["Admin UI<br/>Banner + Page"]
  PadUI["Pad UI<br/>Footer badge"]
  PublicAPI["GET /api/version-status<br/>No version leak"]
  AdminAPI["GET /admin/update/status<br/>Full payload"]
  Email["Email notifications<br/>Logged, not sent"]
  
  GitHub -->|fetch| Poller
  Poller -->|write| State
  State -->|read| Notifier
  Notifier -->|decide| Email
  State -->|read| AdminAPI
  State -->|read| PublicAPI
  AdminAPI -->|fetch| AdminUI
  PublicAPI -->|fetch| PadUI
Loading

Grey Divider

File Changes

1. src/node/updater/index.ts ✨ Enhancement +127/-0

Updater boot wiring and periodic check orchestration

• Main entry point for the updater subsystem; orchestrates periodic GitHub release polling with
 configurable intervals
• Manages in-memory state caching and atomic file persistence via loadState/saveState
• Implements email notification cadence logic by calling decideEmails and logging (would-send)
 placeholders
• Registers Express hooks for boot (expressCreateServer) and shutdown lifecycle

src/node/updater/index.ts


2. src/node/updater/VersionChecker.ts ✨ Enhancement +87/-0

GitHub Releases API polling with ETag caching

• Fetches GitHub Releases API with ETag-based caching to minimize bandwidth
• Parses release metadata and extracts vulnerable-below directives from release bodies
• Returns discriminated union of outcomes: updated, notmodified, ratelimited,
 skipped-prerelease, error
• Provides realFetcher using native Node 18+ fetch with proper headers

src/node/updater/VersionChecker.ts


3. src/node/updater/versionCompare.ts ✨ Enhancement +53/-0

Semantic version parsing and comparison utilities

• Parses semantic versions (strips leading v, prerelease, build metadata; rejects four-part
 versions)
• Compares versions lexicographically across major/minor/patch
• Detects severe outdatedness (≥1 major version behind) and vulnerability (below announced
 thresholds)
• Extracts vulnerable-below directives from HTML comments in release bodies

src/node/updater/versionCompare.ts


View more (34)
4. src/node/updater/Notifier.ts ✨ Enhancement +88/-0

Email notification cadence and deduplication logic

• Pure function that decides which emails to send based on vulnerability/severity state and cadence
 rules
• Implements deduplication: vulnerable repeats weekly, severe repeats monthly
• Fires vulnerable-new-release email when a fix ships while instance is still vulnerable
• Returns planned emails and updated dedupe state without actually sending

src/node/updater/Notifier.ts


5. src/node/updater/state.ts ✨ Enhancement +46/-0

Atomic state file persistence with validation

• Loads and validates update-state.json with schema version checking; returns EMPTY_STATE on
 corruption
• Atomically writes state via tmp-then-rename to prevent partial writes on crash
• Creates parent directories as needed; validates top-level shape (schemaVersion, timestamps,
 latest, email)

src/node/updater/state.ts


6. src/node/updater/types.ts ✨ Enhancement +75/-0

Core type definitions for updater subsystem

• Defines core types: InstallMethod, Tier, OutdatedLevel, ReleaseInfo,
 VulnerableBelowDirective
• Defines UpdateState schema (schemaVersion 1) with email dedupe log and cached release info
• Exports EMPTY_STATE constant for initialization and testing
• Defines PolicyResult for tier capability decisions

src/node/updater/types.ts


7. src/node/updater/InstallMethodDetector.ts ✨ Enhancement +46/-0

Install method auto-detection with filesystem heuristics

• Detects installation method via filesystem heuristics: /.dockerenv → docker, .git + writable →
 git, writable package-lock.json → npm, else managed
• Honors explicit override from settings; respects read-only checkouts (does not return git if root
 is not writable)
• Runs once at boot and caches result for process lifetime

src/node/updater/InstallMethodDetector.ts


8. src/node/updater/UpdatePolicy.ts ✨ Enhancement +42/-0

Update tier capability evaluation policy

• Pure function that evaluates which update tiers are allowed given install method, tier setting,
 and version comparison
• Returns PolicyResult with canNotify, canManual, canAuto, canAutonomous flags and
 human-readable reason
• Denies write tiers (manual+) on non-writable install methods; denies all when up-to-date or tier
 is off

src/node/updater/UpdatePolicy.ts


9. src/node/hooks/express/updateStatus.ts ✨ Enhancement +63/-0

Express routes for version status endpoints

• Registers public /api/version-status endpoint (no auth, 60s cache, returns only `{outdated:
 null|"severe"|"vulnerable"}`)
• Registers admin /admin/update/status endpoint (auth required, returns full status payload)
• Computes outdated level by comparing current vs latest and checking vulnerability directives
• Includes test-only cache reset function for integration tests

src/node/hooks/express/updateStatus.ts


10. src/node/utils/Settings.ts ⚙️ Configuration changes +26/-0

Settings schema for updater configuration

• Adds updates settings block with tier, source, channel, installMethod, checkIntervalHours,
 githubRepo
• Adds top-level adminEmail setting for notification contact
• Defaults: tier "notify", installMethod "auto", checkIntervalHours 6, githubRepo
 "ether/etherpad", adminEmail null

src/node/utils/Settings.ts


11. src/static/js/pad_version_badge.ts ✨ Enhancement +37/-0

Pad-side version status badge renderer

• Fetches /api/version-status on pad load and renders a footer badge based on outdated level
• Shows nothing on null, discreet text on severe, prominent text on vulnerable
• Hardcoded English strings (TODO comment for future i18n via html10n)
• Auto-invokes on DOM ready; silently fails if fetch errors

src/static/js/pad_version_badge.ts


12. src/static/js/pad.ts ✨ Enhancement +2/-0

Pad initialization with badge module import

• Imports the new pad_version_badge module to enable badge rendering on every pad

src/static/js/pad.ts


13. src/templates/pad.html ✨ Enhancement +1/-0

Pad template with version badge container

• Adds <div id="version-badge"> container with ARIA attributes for status announcement
• Positioned as a fixed footer element, initially hidden

src/templates/pad.html


14. src/static/css/pad.css ✨ Enhancement +15/-0

Pad CSS styling for version badge

• Styles #version-badge as a fixed bottom-right badge with conditional colors for severe and
 vulnerable states
• Uses warning (yellow) colors for severe, danger (red) colors for vulnerable

src/static/css/pad.css


15. admin/src/index.css ✨ Enhancement +27/-0

Admin UI CSS for update banner and page

• Adds .update-banner styles (yellow warning box with flex layout, link styling)
• Adds .update-page layout styles (definition list grid, pre-formatted changelog display)

admin/src/index.css


16. admin/src/components/UpdateBanner.tsx ✨ Enhancement +35/-0

Admin update banner component

• React component that fetches /admin/update/status on mount and displays a banner when an update
 is available
• Shows current vs latest version with a link to the update page
• Uses i18n for banner text; hides when up-to-date

admin/src/components/UpdateBanner.tsx


17. admin/src/pages/UpdatePage.tsx ✨ Enhancement +40/-0

Admin update status page component

• Full-page component displaying update status: current version, latest, last check time, install
 method, tier
• Shows changelog (release body) and link to GitHub release when an update is available
• Shows "up-to-date" message when running latest

admin/src/pages/UpdatePage.tsx


18. admin/src/store/store.ts ✨ Enhancement +23/-2

Zustand store with update status state

• Adds UpdateStatusPayload interface matching /admin/update/status response schema
• Adds updateStatus state and setUpdateStatus setter to Zustand store

admin/src/store/store.ts


19. admin/src/App.tsx ✨ Enhancement +4/-1
 Admin app navigation and banner integration

admin/src/App.tsx


20. admin/src/main.tsx ✨ Enhancement +2/-0

Admin router configuration for update page

• Imports UpdatePage component and registers /update route

admin/src/main.tsx


21. src/locales/en.json 📝 Documentation +14/-0

English localization for update UI

• Adds 13 i18n keys under update.* namespace: banner title/body/cta, page fields, changelog
 heading, up-to-date message, badge texts

src/locales/en.json


22. src/ep.json ⚙️ Configuration changes +14/-0

Plugin registration for updater subsystem

• Registers updater plugin with expressCreateServer and shutdown hooks pointing to
 src/node/updater/index
• Registers updateStatus plugin (post-admin) with expressCreateServer hook for the status
 endpoints

src/ep.json


23. settings.json.template ⚙️ Configuration changes +20/-0

Default settings template for updater

• Adds updates block with default tier "notify", installMethod "auto", checkIntervalHours 6
• Adds top-level adminEmail setting (default null)

settings.json.template


24. settings.json.docker ⚙️ Configuration changes +21/-0

Docker settings template for updater

• Adds updates block with tier "notify", installMethod explicitly set to "docker"
• Adds top-level adminEmail setting (default null)

settings.json.docker


25. doc/admin/updates.md 📝 Documentation +81/-0

Admin documentation for update subsystem

• Comprehensive admin documentation for the update subsystem: settings reference, outdated
 definitions, email cadence table
• Explains pad-side badge visibility rules, privacy guarantees, install method detection heuristics
• Notes that PR 1 ships notify tier only; tiers 2–4 are designed but not yet implemented

doc/admin/updates.md


26. docs/superpowers/specs/2026-04-25-auto-update-design.md 📝 Documentation +350/-0

Complete auto-update design specification

• Complete design specification for four-tier auto-update system: architecture, components, API
 surface, settings, data flow, error handling, state machine, security, testing strategy, phased
 rollout plan
• Defines tier 1 (notify) scope and subsequent tiers 2–4 (manual, auto, autonomous)
• Covers non-goals (plugins, ephemeral filesystems, telemetry), decisions (GitHub source, in-process
 execution, rollback strategy), and test coverage gates per PR

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


27. CHANGELOG.md 📝 Documentation +12/-0

Changelog entry for tier 1 update feature

• Documents tier 1 (notify) as a new notable enhancement: periodic GitHub polling, admin banner, pad
 badge, email cadence, settings block
• Notes that tiers 2–4 are designed and will land in subsequent releases
• References doc/admin/updates.md for full configuration

CHANGELOG.md


28. src/tests/backend-new/specs/updater/versionCompare.test.ts 🧪 Tests +92/-0

Version comparison unit tests

• Unit tests for parseSemver: plain versions, leading v, prerelease/build stripping, four-part
 rejection, garbage handling
• Unit tests for compareSemver, isMajorBehind, parseVulnerableBelow, isVulnerable with
 multiple directives

src/tests/backend-new/specs/updater/versionCompare.test.ts


29. src/tests/backend-new/specs/updater/VersionChecker.test.ts 🧪 Tests +96/-0

Version checker unit tests

• Tests for checkLatestRelease: 200 with parsed release, 304 not-modified, 403 rate-limit,
 prerelease skipping with ETag preservation, error on missing fields
• Verifies ETag is passed to fetcher and vulnerable-below directives are extracted

src/tests/backend-new/specs/updater/VersionChecker.test.ts


30. src/tests/backend-new/specs/updater/Notifier.test.ts 🧪 Tests +95/-0

Notifier cadence and deduplication tests

• Tests for decideEmails: no-op when adminEmail unset, severe/vulnerable cadence (30d/7d),
 tag-change detection, vulnerable-new-release email, cadence priority
• Regression test: tag-changed fires even after cadence window expires

src/tests/backend-new/specs/updater/Notifier.test.ts


31. src/tests/backend-new/specs/updater/state.test.ts 🧪 Tests +72/-0

State persistence unit tests

• Tests for loadState: missing file returns empty state, round-trip persistence, corrupt JSON,
 unknown schemaVersion, null email, array latest
• Tests for saveState: atomic write via tmp file, directory creation

src/tests/backend-new/specs/updater/state.test.ts


32. src/tests/backend-new/specs/updater/InstallMethodDetector.test.ts 🧪 Tests +50/-0

Install method detection unit tests

• Tests for detectInstallMethod: override honor, docker detection, git with writable root, npm
 with writable lockfile, managed fallback, docker precedence

src/tests/backend-new/specs/updater/InstallMethodDetector.test.ts


33. src/tests/backend-new/specs/updater/UpdatePolicy.test.ts 🧪 Tests +64/-0

Update policy evaluation unit tests

• Tests for evaluatePolicy: tier-off denies all, notify allows only notify, manual on git, manual
 denied on docker, autonomous on git, managed denies write tiers
• Tests for up-to-date and dev-build (current > latest) denials

src/tests/backend-new/specs/updater/UpdatePolicy.test.ts


34. src/tests/backend/specs/updateStatus.ts 🧪 Tests +89/-0

Update status endpoint integration tests

• Integration tests for /api/version-status: returns null when no state, does not leak version,
 returns severe when >1 major behind
• Integration tests for /admin/update/status: requires admin auth
• Includes auth hook/setting backup/restore and badge cache reset for test isolation

src/tests/backend/specs/updateStatus.ts


35. src/tests/frontend-new/admin-spec/update-banner.spec.ts 🧪 Tests +72/-0

Admin update UI Playwright tests

• Playwright tests for admin update UI: nav link visibility, page renders with stubbed status
 endpoint, banner appears when update available
• Tests version display and up-to-date messaging

src/tests/frontend-new/admin-spec/update-banner.spec.ts


36. src/tests/frontend-new/specs/pad-version-badge.spec.ts 🧪 Tests +47/-0

Pad version badge Playwright tests

• Playwright tests for pad badge: hidden on outdated: null, shows severe text with data-level
 attribute, shows vulnerable text with data-level attribute

src/tests/frontend-new/specs/pad-version-badge.spec.ts


37. docs/superpowers/plans/2026-04-25-auto-update-pr1-notify.md 📝 Documentation +2335/-0

Complete task-by-task plan for auto-update Tier 1 implementation

• Comprehensive implementation plan for Tier 1 (notify) of the four-tier auto-update feature
• 18 sequential tasks covering backend modules, HTTP endpoints, admin/pad UI, tests, and
 documentation
• Detailed step-by-step instructions with code snippets, test cases, and verification commands
• Covers pure modules (types.ts, versionCompare.ts, state.ts, InstallMethodDetector.ts,
 UpdatePolicy.ts, VersionChecker.ts, Notifier.ts), boot wiring, HTTP routes, settings
 extensions, i18n, and Playwright tests

docs/superpowers/plans/2026-04-25-auto-update-pr1-notify.md


Grey Divider

Qodo Logo

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented Apr 25, 2026

Code Review by Qodo

🐞 Bugs (4) 📘 Rule violations (2) 📎 Requirement gaps (0)

Grey Divider


Action required

1. updates.tier defaults to notify 📘 Rule violation ☼ Reliability
Description
The new updater subsystem is enabled by default via updates.tier: "notify", so existing installs
get new behavior without explicit opt-in. This violates the requirement that new features be
feature-flagged and disabled by default.
Code

src/node/utils/Settings.ts[R427-438]

+  /**
+   * 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',
+  },
Evidence
PR Compliance ID 5 requires new features to be disabled by default. The default settings added in
code and config templates set updates.tier to notify, enabling the updater feature unless users
explicitly turn it off.

src/node/utils/Settings.ts[427-438]
settings.json.template[178-190]
settings.json.docker[187-200]
Best Practice: Repository guidelines

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The updater feature is enabled by default because `updates.tier` defaults to `notify`, which violates the requirement that new features be disabled by default.

## Issue Context
This PR introduces a new updater subsystem (polling + UI/API surfacing). To comply with the feature-flag rule, the default configuration must keep the feature off unless explicitly enabled.

## Fix Focus Areas
- src/node/utils/Settings.ts[427-438]
- settings.json.template[178-190]
- settings.json.docker[187-200]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Update status routes ignore off 📘 Rule violation ☼ Reliability
Description
The new /api/version-status and /admin/update/status routes are always registered, so behavior
changes even if updates.tier is set to off. This violates the requirement that disabling the
feature flag preserves prior behavior.
Code

src/node/hooks/express/updateStatus.ts[R29-60]

+export const expressCreateServer = (
+  _hookName: string,
+  {app}: ArgsExpressType,
+  cb: Function,
+): void => {
+  // Public endpoint. Cached for 60s. Returns only an enum — no version string.
+  app.get('/api/version-status', async (_req: any, res: any) => {
+    const now = Date.now();
+    if (now - badgeCache.at > BADGE_CACHE_MS) {
+      badgeCache = {value: await computeOutdated(), at: now};
+    }
+    res.json({outdated: badgeCache.value});
+  });
+
+  // Admin-protected. webaccess.ts already gates /admin/* with admin auth.
+  app.get('/admin/update/status', async (_req: any, res: any) => {
+    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,
+    });
+  });
Evidence
PR Compliance ID 5 requires that when the feature flag is disabled, behavior matches what it was
before. The updateStatus hook unconditionally registers new routes, and ep.json unconditionally
enables the hook, so the new API/admin endpoints exist even when updates.tier is off.

src/node/hooks/express/updateStatus.ts[29-60]
src/ep.json[105-118]
Best Practice: Repository guidelines

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`/api/version-status` and `/admin/update/status` are registered regardless of `settings.updates.tier`, so the updater feature cannot be fully disabled.

## Issue Context
Even if `updates.tier` is configured to `off`, the server still exposes new endpoints (a behavior change vs pre-PR). To meet the feature-flag requirement, the route-registration hook should no-op when `tier === 'off'`.

## Fix Focus Areas
- src/node/hooks/express/updateStatus.ts[29-60]
- src/ep.json[105-118]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Admin status leaks version 🐞 Bug ⛨ Security
Description
GET /admin/update/status is not guaranteed to be admin-only because webaccess only treats
/admin-auth* as “admin” and the default requireAuthentication=false allows the request through,
leaking currentVersion publicly. Even with authentication enabled, any authenticated non-admin can
hit the endpoint because requireAdmin is false for this path.
Code

src/node/hooks/express/updateStatus.ts[R43-59]

+  // Admin-protected. webaccess.ts already gates /admin/* with admin auth.
+  app.get('/admin/update/status', async (_req: any, res: any) => {
+    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,
+    });
Evidence
The route returns currentVersion and other internal update metadata. The global auth middleware
only sets requireAdmin when the path starts with /admin-auth, and if authentication is not
required (the default) the middleware grants access to all paths; therefore /admin/update/status
is accessible without admin auth and can leak the running version.

src/node/hooks/express/updateStatus.ts[34-59]
src/node/hooks/express/webaccess.ts[58-60]
src/node/hooks/express/webaccess.ts[127-134]
src/node/utils/Settings.ts[570-577]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`/admin/update/status` currently relies on a comment assumption (“webaccess gates /admin/*”) that is not true with the current `webaccess.ts` logic. As a result, the endpoint can be reachable by unauthenticated users when `requireAuthentication=false` (default) and by non-admin authenticated users when `requireAuthentication=true`, leaking `currentVersion`.

### Issue Context
- `webaccess.ts` uses `requireAdmin = req.path.toLowerCase().startsWith('/admin-auth')`, so `/admin/update/status` is not treated as admin-only.
- The endpoint returns sensitive-ish info (`currentVersion`) that the PR explicitly tries not to expose publicly.

### Fix Focus Areas
- src/node/hooks/express/updateStatus.ts[29-60]
- src/node/hooks/express/webaccess.ts[58-60]
- src/node/hooks/express/webaccess.ts[127-134]
- src/tests/backend/specs/updateStatus.ts[76-87]

### Suggested fix
- In the `/admin/update/status` handler, require `req.session?.user?.is_admin === true`.
 - If no session user: respond `401`.
 - If session user exists but not admin: respond `403`.
- Add/extend integration tests to cover:
 - default `requireAuthentication=false` still denies access
 - authenticated non-admin user denied
 - admin user allowed

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
4. Async handlers unprotected 🐞 Bug ☼ Reliability
Description
The new Express routes use async handlers directly, so exceptions (for example, loadState()
throwing on EPERM/IO errors) will not be forwarded to Express error handling in this codebase’s
established pattern. This can cause unhandled promise rejections and/or hung requests.
Code

src/node/hooks/express/updateStatus.ts[R35-41]

+  app.get('/api/version-status', async (_req: any, res: any) => {
+    const now = Date.now();
+    if (now - badgeCache.at > BADGE_CACHE_MS) {
+      badgeCache = {value: await computeOutdated(), at: now};
+    }
+    res.json({outdated: badgeCache.value});
+  });
Evidence
Other Express hooks in this repo wrap async logic in an IIFE and .catch(next) to safely propagate
errors; the new routes do not, and they call async functions that can throw (e.g., fs.readFile
failures in loadState).

src/node/hooks/express/updateStatus.ts[14-60]
src/node/hooks/express/importexport.ts[27-71]
src/node/updater/state.ts[22-29]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`updateStatus.ts` registers `async` Express handlers without wrapping them to forward rejections to `next(err)`. In this repo, other handlers explicitly `.catch(next)`, implying Express is not expected to handle async rejections automatically.

### Issue Context
`loadState()` can throw for non-ENOENT read failures (permissions, IO), and those errors would currently bypass Express error handling.

### Fix Focus Areas
- src/node/hooks/express/updateStatus.ts[29-60]
- src/node/hooks/express/importexport.ts[27-71]

### Suggested fix
- Change both routes to the established pattern:
 - `app.get(path, (req, res, next) => { (async () => { ... })().catch((err) => next(err || new Error(err))); });`
- Optionally add a small helper (e.g. `wrapAsync`) local to the file to reduce duplication.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

5. Overlapping poll races state 🐞 Bug ☼ Reliability
Description
startPolling() schedules performCheck() via setInterval(() => { void performCheck(); }),
allowing overlapping checks when a run is slow. Concurrent runs can race on the shared in-memory
state object and collide on saveState()’s fixed filePath.tmp, risking state corruption and
email dedupe errors.
Code

src/node/updater/index.ts[R100-106]

+const startPolling = (): void => {
+  const intervalMs = Math.max(1, settings.updates.checkIntervalHours) * 60 * 60 * 1000;
+  if (timer) clearInterval(timer);
+  timer = setInterval(() => { void performCheck(); }, intervalMs);
+  // Run an immediate first check, but don't block boot.
+  setTimeout(() => { void performCheck(); }, 5000);
+};
Evidence
The polling loop does not guard against a previous check still running. The state writer always uses
the same tmp filename, so concurrent saves to the same target path are not safe.

src/node/updater/index.ts[36-106]
src/node/updater/state.ts[40-46]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The updater poller can run multiple `performCheck()` executions concurrently because the interval callback does not await completion and there is no mutex/in-progress flag. This can lead to concurrent mutation of the same `UpdateState` object and concurrent writes that share a fixed `${filePath}.tmp` path.

### Issue Context
Even though the default interval is 6h, slow/hung network requests or filesystem stalls can overlap with the next tick (and the initial `setTimeout` run can also overlap with the first interval tick).

### Fix Focus Areas
- src/node/updater/index.ts[36-106]
- src/node/updater/state.ts[40-46]

### Suggested fix
- Add a module-level `let inProgress = false;` guard in `performCheck()`:
 - if `inProgress` return early
 - set `inProgress = true` at start
 - `finally { inProgress = false; }`
- Optionally:
 - use a unique tmp filename (pid + timestamp) or a per-write random suffix to make `saveState()` robust even if concurrency happens again in the future.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. State validation can still crash 🐞 Bug ☼ Reliability
Description
loadState() claims to return empty state for malformed files, but isValid() only checks
top-level types and can accept values that will later throw (e.g., vulnerableBelow: ["x"] or
latest: {}), crashing compareSemver()/isVulnerable() call sites. This makes
/api/version-status and notifier logic fragile to partially corrupted or hand-edited
var/update-state.json.
Code

src/node/updater/state.ts[R8-18]

+// NOTE: We validate top-level shape only. Subfields of `email` and `latest` are
+// trusted because EMPTY_STATE always provides them and only this module writes the file.
+// If a future consumer hand-edits the file, malformed subfields will surface at use site.
+const isValid = (raw: unknown): raw is UpdateState => {
+  if (!isPlainObject(raw)) return false;
+  return raw.schemaVersion === 1
+    && (raw.lastCheckAt === null || typeof raw.lastCheckAt === 'string')
+    && (raw.lastEtag === null || typeof raw.lastEtag === 'string')
+    && (raw.latest === null || isPlainObject(raw.latest))
+    && Array.isArray(raw.vulnerableBelow)
+    && isPlainObject(raw.email);
Evidence
isValid() allows any array for vulnerableBelow and any object for latest, but downstream code
assumes d.threshold and latest.version are strings. parseSemver() calls .trim() on its
input, which will throw if given undefined from a malformed state object that passed validation.

src/node/updater/state.ts[8-18]
src/node/updater/versionCompare.ts[13-27]
src/node/updater/versionCompare.ts[45-52]
src/node/hooks/express/updateStatus.ts[14-21]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`isValid()` is too permissive for fields that are later assumed to be well-typed, which undermines `loadState()`’s documented guarantee to safely reset malformed state.

### Issue Context
Downstream uses:
- `state.latest.version` passed into semver parsing
- `state.vulnerableBelow[].threshold` accessed for comparisons

### Fix Focus Areas
- src/node/updater/state.ts[5-19]
- src/node/updater/versionCompare.ts[13-52]

### Suggested fix
- Extend `isValid()` to validate:
 - `latest` object shape when non-null (`version/tag/body/publishedAt/htmlUrl` strings; `prerelease` boolean)
 - each `vulnerableBelow` entry is an object with `announcedBy` and `threshold` strings
 - `email` object has `severeAt/vulnerableAt/vulnerableNewReleaseTag` as `string|null`
- If any subfield fails validation, treat the file as invalid and return `structuredClone(EMPTY_STATE)`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment on lines +427 to +438
/**
* 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',
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. updates.tier defaults to notify 📘 Rule violation ☼ Reliability

The new updater subsystem is enabled by default via updates.tier: "notify", so existing installs
get new behavior without explicit opt-in. This violates the requirement that new features be
feature-flagged and disabled by default.
Agent Prompt
## Issue description
The updater feature is enabled by default because `updates.tier` defaults to `notify`, which violates the requirement that new features be disabled by default.

## Issue Context
This PR introduces a new updater subsystem (polling + UI/API surfacing). To comply with the feature-flag rule, the default configuration must keep the feature off unless explicitly enabled.

## Fix Focus Areas
- src/node/utils/Settings.ts[427-438]
- settings.json.template[178-190]
- settings.json.docker[187-200]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +29 to +60
export const expressCreateServer = (
_hookName: string,
{app}: ArgsExpressType,
cb: Function,
): void => {
// Public endpoint. Cached for 60s. Returns only an enum — no version string.
app.get('/api/version-status', async (_req: any, res: any) => {
const now = Date.now();
if (now - badgeCache.at > BADGE_CACHE_MS) {
badgeCache = {value: await computeOutdated(), at: now};
}
res.json({outdated: badgeCache.value});
});

// Admin-protected. webaccess.ts already gates /admin/* with admin auth.
app.get('/admin/update/status', async (_req: any, res: any) => {
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,
});
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Update status routes ignore off 📘 Rule violation ☼ Reliability

The new /api/version-status and /admin/update/status routes are always registered, so behavior
changes even if updates.tier is set to off. This violates the requirement that disabling the
feature flag preserves prior behavior.
Agent Prompt
## Issue description
`/api/version-status` and `/admin/update/status` are registered regardless of `settings.updates.tier`, so the updater feature cannot be fully disabled.

## Issue Context
Even if `updates.tier` is configured to `off`, the server still exposes new endpoints (a behavior change vs pre-PR). To meet the feature-flag requirement, the route-registration hook should no-op when `tier === 'off'`.

## Fix Focus Areas
- src/node/hooks/express/updateStatus.ts[29-60]
- src/ep.json[105-118]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +43 to +59
// Admin-protected. webaccess.ts already gates /admin/* with admin auth.
app.get('/admin/update/status', async (_req: any, res: any) => {
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,
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. Admin status leaks version 🐞 Bug ⛨ Security

GET /admin/update/status is not guaranteed to be admin-only because webaccess only treats
/admin-auth* as “admin” and the default requireAuthentication=false allows the request through,
leaking currentVersion publicly. Even with authentication enabled, any authenticated non-admin can
hit the endpoint because requireAdmin is false for this path.
Agent Prompt
### Issue description
`/admin/update/status` currently relies on a comment assumption (“webaccess gates /admin/*”) that is not true with the current `webaccess.ts` logic. As a result, the endpoint can be reachable by unauthenticated users when `requireAuthentication=false` (default) and by non-admin authenticated users when `requireAuthentication=true`, leaking `currentVersion`.

### Issue Context
- `webaccess.ts` uses `requireAdmin = req.path.toLowerCase().startsWith('/admin-auth')`, so `/admin/update/status` is not treated as admin-only.
- The endpoint returns sensitive-ish info (`currentVersion`) that the PR explicitly tries not to expose publicly.

### Fix Focus Areas
- src/node/hooks/express/updateStatus.ts[29-60]
- src/node/hooks/express/webaccess.ts[58-60]
- src/node/hooks/express/webaccess.ts[127-134]
- src/tests/backend/specs/updateStatus.ts[76-87]

### Suggested fix
- In the `/admin/update/status` handler, require `req.session?.user?.is_admin === true`.
  - If no session user: respond `401`.
  - If session user exists but not admin: respond `403`.
- Add/extend integration tests to cover:
  - default `requireAuthentication=false` still denies access
  - authenticated non-admin user denied
  - admin user allowed

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +35 to +41
app.get('/api/version-status', async (_req: any, res: any) => {
const now = Date.now();
if (now - badgeCache.at > BADGE_CACHE_MS) {
badgeCache = {value: await computeOutdated(), at: now};
}
res.json({outdated: badgeCache.value});
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

4. Async handlers unprotected 🐞 Bug ☼ Reliability

The new Express routes use async handlers directly, so exceptions (for example, loadState()
throwing on EPERM/IO errors) will not be forwarded to Express error handling in this codebase’s
established pattern. This can cause unhandled promise rejections and/or hung requests.
Agent Prompt
### Issue description
`updateStatus.ts` registers `async` Express handlers without wrapping them to forward rejections to `next(err)`. In this repo, other handlers explicitly `.catch(next)`, implying Express is not expected to handle async rejections automatically.

### Issue Context
`loadState()` can throw for non-ENOENT read failures (permissions, IO), and those errors would currently bypass Express error handling.

### Fix Focus Areas
- src/node/hooks/express/updateStatus.ts[29-60]
- src/node/hooks/express/importexport.ts[27-71]

### Suggested fix
- Change both routes to the established pattern:
  - `app.get(path, (req, res, next) => { (async () => { ... })().catch((err) => next(err || new Error(err))); });`
- Optionally add a small helper (e.g. `wrapAsync`) local to the file to reduce duplication.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +427 to +438
/**
* 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',
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. updates.tier defaults to notify 📘 Rule violation ☼ Reliability

The new updater subsystem is enabled by default because updates.tier defaults to notify, which
starts background polling on boot. This violates the requirement that new features be
feature-flagged and disabled by default to avoid changing behavior for existing deployments.
Agent Prompt
## Issue description
The updater subsystem is a new feature but is enabled by default because `updates.tier` defaults to `notify`, which starts polling on boot.

## Issue Context
Compliance requires new features to be behind a feature flag and disabled by default. The code currently supports disabling via `updates.tier: "off"`, but the default value is `notify` in both runtime defaults and the settings templates.

## Fix Focus Areas
- src/node/utils/Settings.ts[427-438]
- settings.json.template[178-190]
- settings.json.docker[187-200]
- src/node/updater/index.ts[108-116]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +43 to +60
// Admin-protected. webaccess.ts already gates /admin/* with admin auth.
app.get('/admin/update/status', async (_req: any, res: any) => {
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,
});
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Admin status endpoint public 🐞 Bug ⛨ Security

GET /admin/update/status does not verify req.session.user.is_admin, and webaccess treats only
/admin-auth* as admin-protected, so with default requireAuthentication=false the endpoint can be
accessed unauthenticated and leaks currentVersion, latest, and vulnerableBelow. The included
test only forces auth by flipping requireAuthentication=true, which masks the default behavior.
Agent Prompt
### Issue description
`/admin/update/status` is currently reachable without admin authentication under the default configuration (because webaccess only treats `/admin-auth*` as admin-protected and `requireAuthentication` defaults to false). This leaks operational/security-relevant information.

### Issue Context
Admin auth in this codebase is triggered by requesting `/admin-auth/*` (see `webaccess.ts`). New admin-only JSON endpoints should either:
- live under `/admin-auth/...` so the global middleware enforces admin auth, and/or
- explicitly check `req.session.user?.is_admin` and reject otherwise.

### Fix Focus Areas
- src/node/hooks/express/updateStatus.ts[43-60]
- admin/src/components/UpdateBanner.tsx[11-16]
- src/tests/backend/specs/updateStatus.ts[76-87]

### Expected change
1. Move the endpoint to `/admin-auth/update/status` **or** add an explicit admin check in the handler (prefer doing both for defense-in-depth).
2. Update the admin UI fetch URL accordingly.
3. Update/add an integration test that asserts the endpoint is unauthorized when `requireAuthentication` is left at its default (false) and no admin session is present.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread src/node/updater/index.ts
Comment on lines +100 to +106
const startPolling = (): void => {
const intervalMs = Math.max(1, settings.updates.checkIntervalHours) * 60 * 60 * 1000;
if (timer) clearInterval(timer);
timer = setInterval(() => { void performCheck(); }, intervalMs);
// Run an immediate first check, but don't block boot.
setTimeout(() => { void performCheck(); }, 5000);
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. Updater checks can overlap 🐞 Bug ☼ Reliability

startPolling() schedules performCheck() via both setInterval and an initial setTimeout
without any in-flight guard, so slow GitHub fetches can cause concurrent performCheck() executions
that mutate the shared cached state and race on writing update-state.json. shutdown() clears
only the interval, not the initial timeout, so a check can still fire after shutdown has begun.
Agent Prompt
### Issue description
The updater poll loop can execute multiple `performCheck()` runs concurrently (interval tick + initial timeout, plus any later overlap if a check runs longer than the interval). Because `performCheck()` mutates shared in-memory state and writes it to disk, concurrent runs can cause state corruption/out-of-order writes and duplicate email decisions.

### Issue Context
`performCheck()` uses `getCurrentState()` which caches a singleton `inMemoryState`, then mutates it and calls `saveState()`.

### Fix Focus Areas
- src/node/updater/index.ts[36-106]
- src/node/updater/index.ts[118-121]

### Expected change
1. Add an in-flight guard (e.g., `let checkInFlight = false;`) so overlapping ticks return early or coalesce.
2. Store the `setTimeout()` handle and clear it in `shutdown()`.
3. Consider changing to a self-scheduling loop (`setTimeout` scheduled *after* an awaited `performCheck()` completes) to guarantee serialization.
4. (Optional) Add a unit/integration test that simulates a slow fetcher to ensure `performCheck()` is not entered concurrently.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant