import { logger } from '@/lib/logger'; import type { UpdateStatus, UpdateSeverity } from './types'; const SEVERITIES: ReadonlySet = 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 ). export function parseStatus(raw: unknown): UpdateStatus | null { if (!raw || typeof raw !== 'object') return null; const r = raw as Record; 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 }; } }