feat: add update-available detection

This commit is contained in:
Linus Rath
2026-05-02 01:58:30 +02:00
parent 599fa66822
commit 5319562c94
16 changed files with 969 additions and 49 deletions
+82
View File
@@ -0,0 +1,82 @@
import { logger } from '@/lib/logger';
import type { UpdateStatus, UpdateSeverity } from './types';
const SEVERITIES: ReadonlySet<UpdateSeverity> = new Set([
'normal', 'security', 'deprecated', 'none', 'unknown',
]);
function isString(v: unknown): v is string {
return typeof v === 'string';
}
function isNullableString(v: unknown): v is string | null {
return v === null || typeof v === 'string';
}
// Validate the response from the version server before we trust it. Returns
// null on any malformed field so a hostile or buggy upstream can't poison the
// UI with arbitrary strings (the URL, in particular, is rendered in <a href>).
export function parseStatus(raw: unknown): UpdateStatus | null {
if (!raw || typeof raw !== 'object') return null;
const r = raw as Record<string, unknown>;
if (r.schema !== 1) return null;
if (!isString(r.current)) return null;
if (!isNullableString(r.latest)) return null;
if (typeof r.updateAvailable !== 'boolean') return null;
if (!isString(r.severity) || !SEVERITIES.has(r.severity as UpdateSeverity)) return null;
if (!isNullableString(r.url)) return null;
if (!isNullableString(r.advisory)) return null;
if (!isString(r.checkedAt)) return null;
// Only http(s) URLs are renderable; reject anything else so we don't end
// up with a javascript: link in the banner.
if (r.url && !/^https?:\/\//i.test(r.url)) return null;
return {
schema: 1,
current: r.current,
latest: r.latest,
updateAvailable: r.updateAvailable,
severity: r.severity as UpdateSeverity,
url: r.url,
advisory: r.advisory,
checkedAt: r.checkedAt,
};
}
export async function fetchStatus(
endpoint: string,
currentVersion: string,
): Promise<{ ok: true; status: UpdateStatus } | { ok: false; error: string }> {
if (!endpoint) return { ok: false, error: 'endpoint blank' };
if (!currentVersion) return { ok: false, error: 'current version blank' };
// Build the URL safely — never inject the version as a raw path component.
let url: URL;
try {
url = new URL(endpoint);
} catch {
return { ok: false, error: 'endpoint not a URL' };
}
url.searchParams.set('v', currentVersion);
try {
const res = await fetch(url, {
method: 'GET',
headers: { accept: 'application/json' },
cache: 'no-store',
signal: AbortSignal.timeout(5000),
});
if (!res.ok) {
return { ok: false, error: `HTTP ${res.status}` };
}
const body: unknown = await res.json();
const parsed = parseStatus(body);
if (!parsed) return { ok: false, error: 'malformed response' };
return { ok: true, status: parsed };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn('version-check: fetch failed', { error: msg });
return { ok: false, error: msg };
}
}
+7
View File
@@ -0,0 +1,7 @@
export { startScheduler, stopScheduler, checkOnce } from './sender';
export { loadState, saveState, effectiveEndpoint, disabledByEnv } from './state';
export { fetchStatus, parseStatus } from './fetcher';
export type {
UpdateStatus, UpdateSeverity, VersionCheckStateFile,
} from './types';
export { DEFAULT_VERSION_ENDPOINT } from './types';
+100
View File
@@ -0,0 +1,100 @@
import { logger } from '@/lib/logger';
import { disabledByEnv, effectiveEndpoint, loadState, saveState } from './state';
import { fetchStatus } from './fetcher';
import type { UpdateStatus } from './types';
const HOUR_MS = 60 * 60 * 1000;
const JITTER_MS = 5 * 60 * 1000; // ± 5 min, keeps containers from syncing
const FIRST_DELAY_MS = 30 * 1000; // first check ~30s after boot
const FAILURE_BACKOFF_MS = 15 * 60 * 1000; // after a failed fetch, retry in 15 min
let currentTimer: NodeJS.Timeout | null = null;
function jitteredDelay(base: number): number {
const j = (Math.random() * 2 - 1) * JITTER_MS;
return Math.max(60_000, base + j);
}
function getCurrentVersion(): string {
return (process.env.NEXT_PUBLIC_APP_VERSION || '').trim();
}
export async function checkOnce(opts?: { reason?: string }): Promise<{
ok: boolean;
status?: UpdateStatus;
error?: string;
}> {
if (disabledByEnv()) return { ok: false, error: 'disabled by env' };
const state = await loadState();
const endpoint = effectiveEndpoint(state);
if (!endpoint) return { ok: false, error: 'endpoint blank' };
const current = getCurrentVersion();
if (!current) return { ok: false, error: 'current version unset' };
const now = new Date().toISOString();
const result = await fetchStatus(endpoint, current);
const next = await loadState();
next.lastCheckedAt = now;
if (result.ok) {
next.lastSuccessAt = now;
next.status = result.status;
}
await saveState(next);
logger.info('version-check: ran', {
ok: result.ok,
severity: result.ok ? result.status.severity : null,
reason: opts?.reason ?? 'scheduled',
});
if (result.ok) return { ok: true, status: result.status };
return { ok: false, error: result.error };
}
async function scheduleNext(delayMs: number): Promise<void> {
if (currentTimer) clearTimeout(currentTimer);
const at = new Date(Date.now() + delayMs).toISOString();
const state = await loadState();
state.nextScheduledAt = at;
await saveState(state);
currentTimer = setTimeout(() => { void tick(); }, delayMs);
// Don't keep the process alive just for this.
currentTimer.unref?.();
}
async function tick(): Promise<void> {
const result = await checkOnce({ reason: 'scheduled' });
const delay = result.ok ? jitteredDelay(HOUR_MS) : FAILURE_BACKOFF_MS;
await scheduleNext(delay);
}
// Idempotent — safe to call from instrumentation hot-reload in dev.
export async function startScheduler(): Promise<void> {
if (disabledByEnv()) {
logger.info('version-check: scheduler not started (disabled by env)');
return;
}
const state = await loadState();
if (!effectiveEndpoint(state)) {
logger.info('version-check: scheduler not started (no endpoint)');
return;
}
// If a previous schedule was still in the future, honor it (don't blast on
// every restart). Cap at one hour so a wildly-in-the-future timestamp can't
// permanently silence the check.
let delay = FIRST_DELAY_MS;
if (state.nextScheduledAt) {
const remaining = new Date(state.nextScheduledAt).getTime() - Date.now();
if (remaining > 0) delay = Math.min(remaining, HOUR_MS + JITTER_MS);
}
await scheduleNext(delay);
logger.info('version-check: scheduler started', { nextInMs: delay });
}
export async function stopScheduler(): Promise<void> {
if (currentTimer) clearTimeout(currentTimer);
currentTimer = null;
}
+62
View File
@@ -0,0 +1,62 @@
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { logger } from '@/lib/logger';
import type { VersionCheckStateFile } from './types';
import { DEFAULT_VERSION_ENDPOINT } from './types';
function getDir(): string {
return process.env.VERSION_CHECK_DATA_DIR ||
path.join(process.cwd(), 'data', 'version-check');
}
function statePath(): string { return path.join(getDir(), 'state.json'); }
const DEFAULTS: VersionCheckStateFile = {
endpoint: DEFAULT_VERSION_ENDPOINT,
lastCheckedAt: null,
lastSuccessAt: null,
nextScheduledAt: null,
status: null,
};
export async function ensureDir(): Promise<void> {
if (!existsSync(getDir())) await mkdir(getDir(), { recursive: true });
}
export async function loadState(): Promise<VersionCheckStateFile> {
await ensureDir();
try {
const raw = await readFile(statePath(), 'utf8');
const parsed = JSON.parse(raw) as Partial<VersionCheckStateFile>;
return { ...DEFAULTS, ...parsed };
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.warn('version-check: state read failed', {
error: err instanceof Error ? err.message : String(err),
});
}
return { ...DEFAULTS };
}
}
export async function saveState(state: VersionCheckStateFile): Promise<void> {
await ensureDir();
const tmp = statePath() + '.tmp';
await writeFile(tmp, JSON.stringify(state, null, 2), 'utf8');
await rename(tmp, statePath());
}
export function disabledByEnv(): boolean {
const v = (process.env.BULWARK_UPDATE_CHECK ?? '').toLowerCase();
if (v === 'off' || v === 'false' || v === '0' || v === 'no') return true;
return false;
}
export function effectiveEndpoint(state: VersionCheckStateFile): string {
// Env var wins over state file so an operator can override at runtime
// without editing on-disk state. An explicit empty value disables the check.
const envUrl = process.env.BULWARK_UPDATE_CHECK_URL;
if (envUrl !== undefined) return envUrl.trim();
return state.endpoint || DEFAULT_VERSION_ENDPOINT;
}
+25
View File
@@ -0,0 +1,25 @@
// Update-status payload returned by the version server. Mirrors
// repos/dashboard/version-server/src/registry.ts LookupResult.
export type UpdateSeverity = 'normal' | 'security' | 'deprecated' | 'none' | 'unknown';
export interface UpdateStatus {
schema: 1;
current: string;
latest: string | null;
updateAvailable: boolean;
severity: UpdateSeverity;
url: string | null;
advisory: string | null;
checkedAt: string;
}
export interface VersionCheckStateFile {
endpoint: string;
lastCheckedAt: string | null;
lastSuccessAt: string | null;
nextScheduledAt: string | null;
status: UpdateStatus | null;
}
export const DEFAULT_VERSION_ENDPOINT = 'https://version.telemetry.bulwarkmail.org/';