etherpad-lite/src/node/updater/VersionChecker.ts
John McLear 29dac6bfcc
fix(pad): redesign outdated-version notice (#7799) (#7804)
* docs: design spec for #7799 outdated-notice redesign

Per-pad first-author gating, dismissable gritter, minor-or-more rule, drop vulnerable UI.

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

* docs: implementation plan for #7799 outdated-notice redesign

12 bite-sized tasks, TDD-first where applicable; closes the spec end-to-end.

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

* feat(updater): add isMinorOrMoreBehind, drop major/vulnerable helpers

Adds isMinorOrMoreBehind(current, latest) which returns true only when
the latest release is at least one minor version ahead (patch-only deltas
return false). Removes isMajorBehind, parseVulnerableBelow, and
isVulnerable from versionCompare.ts — callers in updateStatus.ts,
VersionChecker.ts, and index.ts will be updated in subsequent tasks.

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

* refactor(updater): drop vulnerable-below directive and state field

Remove VulnerableBelowDirective type, UpdateState.vulnerableBelow field, and
all related scraping/checking logic (parseVulnerableBelow, isVulnerable imports).
Clean up Notifier, OpenAPI schema, and all test fixtures to match.

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

* refactor(updater): drop residual EmailSendLog vulnerable fields

Remove `vulnerableAt` and `vulnerableNewReleaseTag` from the
`EmailSendLog` interface, `EMPTY_STATE`, and the `isValidEmail`
validator — these backed the removed `vulnerable`/`vulnerable-new-release`
email kinds and are now dead code.

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

* feat(updater): add firstAuthorOf helper

Export firstAuthorOf() from updateStatus.ts — finds the lowest-numbered
author attrib in a pad's pool, skipping empty-string placeholders.
Covered by 6 vitest cases in tests/backend-new/specs/hooks/express/.

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

* feat(updater): add resolveRequestAuthor helper for HTTP GET

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

* feat(updater): pad-aware /api/version-status with first-author gating

Replace global badge cache with a per-(padId, authorId) LRU cache. The
new response shape is {outdated: 'minor' | null, isFirstAuthor: boolean};
the old 'severe'/'vulnerable' enum is dropped entirely. computeOutdated
now resolves the pad's first author and compares it against the session
author before returning outdated:'minor', so the notice is only shown to
the person who created the pad.

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

* fix(updater): switch isSevere signal from major-only to minor-or-more behind

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

* test(updater): end-to-end coverage for /api/version-status

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

* docs(openapi): /api/version-status pad-aware shape and gating

Add the /api/version-status GET operation to the admin OpenAPI spec with
the new pad-aware response shape: outdated enum reduced to [minor]|null,
isFirstAuthor boolean, and an optional padId query param.

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

* chore(pad): remove unused #version-badge template and CSS

* feat(pad): replace persistent badge with first-author outdated gritter

Renames pad_version_badge.ts → pad_outdated_notice.ts and rewrites it
as a fire-and-forget gritter notice that only shows when the API reports
outdated=minor AND the current user is the pad's first author.  Wires
the new maybeShowOutdatedNotice() call into pad.ts immediately after
showPrivacyBannerIfEnabled().

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

* test(pad): playwright coverage for outdated notice gritter

Six Playwright specs exercise maybeShowOutdatedNotice: null response,
isFirstAuthor:false guard, positive appearance + text, X-dismiss,
500 server error tolerance, and 8 s auto-fade.

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

* docs(pad): outdated-notice redesign + drop vulnerable-below docs

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

* chore(test): remove stale specs for deleted #version-badge surface

Delete the GET /api/version-status describe block from the legacy mocha
spec (asserted outdated:null and outdated:'severe' — both no longer match
the new response shape). The new vitest spec at
tests/backend-new/specs/hooks/express/updateStatus.test.ts covers this
surface comprehensively.

Delete src/tests/frontend-new/specs/pad-version-badge.spec.ts entirely:
all three tests reference the #version-badge DOM element removed in Task 8
and stub 'severe'/'vulnerable' enum values that no longer exist.

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

* chore: clean stale references to vulnerable/severe in types, emails, docs

- Remove OutdatedLevel type (null|'severe') from types.ts — no consumers
  remain after the badge redesign removed the severe tier.
- Fix Notifier severe-email body: was "more than one major release behind"
  but isSevere now fires on minor-or-more, so update to "at least one
  minor release behind the latest published version".
- Drop "vulnerability directives" from the /admin/update/status OpenAPI
  description; replace with the actual response fields.
- Remove stale vulnerableBelow field from UpdateStatusPayload in
  admin/src/store/store.ts — server no longer sends it.
- Fix docs/admin/updates.md: "pad-side badge" → "pad-side notice".

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 12:23:40 +01:00

91 lines
3 KiB
TypeScript

import {ReleaseInfo} from './types';
import {isValidTag} from './refSafety';
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}
| {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};
}
// Reject any tag that would be unsafe to hand to git later. Validating at
// the persistence boundary (rather than only at the executor) means a
// malformed tag_name from a misconfigured fork-as-github-repo never lands
// in update-state.json. Treated as a fetch error so the polling loop will
// try again next interval.
if (!isValidTag(j.tag_name)) {
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,
};
return {kind: 'updated', release, etag: res.etag};
};
/** 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};
};