diff --git a/VERSION b/VERSION
index dc1e644a..bc80560f 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.6.0
+1.5.0
diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx
index 4b5590e2..6385f1ee 100644
--- a/app/[locale]/login/page.tsx
+++ b/app/[locale]/login/page.tsx
@@ -16,6 +16,7 @@ import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor,
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
+import { useUpdateStore, selectBanner } from "@/stores/update-store";
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
@@ -28,7 +29,13 @@ const THEME_OPTIONS = [
function VersionBadge() {
const [copied, setCopied] = useState(false);
- const versionInfo = `Version: ${APP_VERSION}\nBuild: ${GIT_COMMIT}`;
+ const banner = useUpdateStore(useShallow(selectBanner));
+ const dismiss = useUpdateStore((s) => s.dismiss);
+ const startPolling = useUpdateStore((s) => s.startPolling);
+
+ useEffect(() => { startPolling(); }, [startPolling]);
+
+ const versionInfo = `Version: ${APP_VERSION}\nBuild: ${GIT_COMMIT}${banner?.latest ? `\nLatest: ${banner.latest}` : ""}`;
const handleCopy = () => {
navigator.clipboard.writeText(versionInfo).then(() => {
@@ -37,16 +44,49 @@ function VersionBadge() {
});
};
+ const isRed = banner?.variant === "red";
+ const triggerText = !banner
+ ? `v${APP_VERSION}`
+ : banner.severity === "security"
+ ? "Security update available"
+ : banner.severity === "deprecated"
+ ? "Version no longer supported"
+ : "New version available";
+
+ const triggerColor = !banner
+ ? "text-muted-foreground/40"
+ : isRed
+ ? "text-red-600/80 dark:text-red-400/80 hover:text-red-600 dark:hover:text-red-400"
+ : "text-amber-600/80 dark:text-amber-400/80 hover:text-amber-600 dark:hover:text-amber-400";
+
+ const triggerClass = cn(
+ "peer text-center text-xs transition-colors",
+ triggerColor,
+ banner?.url ? "cursor-pointer underline-offset-2 hover:underline" : "cursor-default",
+ );
+
+ const trigger = banner?.url ? (
+
+ {triggerText}
+
+ ) : (
+
{triggerText}
+ );
+
return (
-
- v{APP_VERSION}
-
+ {trigger}
Version: {APP_VERSION}
Build: {GIT_COMMIT}
+ {banner?.latest && (
+
Latest: {banner.latest}
+ )}
+ {banner?.advisory && (
+
{banner.advisory}
+ )}
+ {banner?.dismissible && (
+
+ )}
diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx
index 6924a22c..48737ae3 100644
--- a/app/admin/layout.tsx
+++ b/app/admin/layout.tsx
@@ -15,6 +15,7 @@ import {
Puzzle,
SwatchBook,
Activity,
+ Package,
Mail,
Calendar,
BookUser,
@@ -30,6 +31,7 @@ import { useThemeStore } from '@/stores/theme-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { useAuthStore } from '@/stores/auth-store';
+import { useUpdateStore, selectHasUpdate } from '@/stores/update-store';
import { apiFetch } from '@/lib/browser-navigation';
const NAV_GROUPS = [
@@ -59,6 +61,7 @@ const NAV_GROUPS = [
{
label: 'System',
items: [
+ { href: '/admin/version', label: 'Version', icon: Package },
{ href: '/admin/telemetry', label: 'Telemetry', icon: Activity },
{ href: '/admin/logs', label: 'Audit Log', icon: ScrollText },
],
@@ -78,6 +81,13 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
+ // Match the navigation rail: red for security/deprecated, amber for normal.
+ const hasUpdate = useUpdateStore(selectHasUpdate);
+ const updateSeverity = useUpdateStore((s) => s.status?.severity);
+ const startUpdatePolling = useUpdateStore((s) => s.startPolling);
+ useEffect(() => { startUpdatePolling(); }, [startUpdatePolling]);
+ const updateImportant = updateSeverity === 'security' || updateSeverity === 'deprecated';
+
useEffect(() => {
setMobileNavOpen(false);
}, [pathname]);
@@ -170,6 +180,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
{group.items.map(({ href, label, icon: Icon }) => {
const active = href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
+ const showDot = href === '/admin/version' && hasUpdate;
return (
-
+
+
+ {showDot && (
+
+ )}
+
{label}
);
diff --git a/app/admin/version/page.tsx b/app/admin/version/page.tsx
new file mode 100644
index 00000000..398aba28
--- /dev/null
+++ b/app/admin/version/page.tsx
@@ -0,0 +1,237 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import {
+ Loader2,
+ RefreshCw,
+ CheckCircle2,
+ AlertTriangle,
+ ShieldAlert,
+ ExternalLink,
+} from 'lucide-react';
+import { SettingsSection, SettingItem } from '@/components/settings/settings-section';
+import { apiFetch } from '@/lib/browser-navigation';
+import type { UpdateStatus, UpdateSeverity } from '@/lib/version-check/types';
+
+interface VersionAdminStatus {
+ current: string;
+ build: string;
+ endpoint: string;
+ defaultEndpoint: string;
+ disabledByEnv: boolean;
+ lastCheckedAt: string | null;
+ lastSuccessAt: string | null;
+ nextScheduledAt: string | null;
+ status: UpdateStatus | null;
+}
+
+function timeAgo(iso: string | null): string {
+ if (!iso) return 'never';
+ const d = Date.now() - new Date(iso).getTime();
+ if (d < 0) return new Date(iso).toLocaleString();
+ const m = Math.floor(d / 60000);
+ if (m < 1) return 'just now';
+ if (m < 60) return `${m} min ago`;
+ const h = Math.floor(m / 60);
+ if (h < 48) return `${h} hours ago`;
+ return `${Math.floor(h / 24)} days ago`;
+}
+
+function severityChip(severity: UpdateSeverity) {
+ switch (severity) {
+ case 'security':
+ return {
+ label: 'Security update',
+ className: 'bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/30',
+ Icon: ShieldAlert,
+ };
+ case 'deprecated':
+ return {
+ label: 'Deprecated',
+ className: 'bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/30',
+ Icon: ShieldAlert,
+ };
+ case 'normal':
+ return {
+ label: 'Update available',
+ className: 'bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-500/30',
+ Icon: AlertTriangle,
+ };
+ case 'unknown':
+ return {
+ label: 'Unknown',
+ className: 'bg-muted text-muted-foreground border-border',
+ Icon: AlertTriangle,
+ };
+ case 'none':
+ default:
+ return {
+ label: 'Up to date',
+ className: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 border-emerald-500/30',
+ Icon: CheckCircle2,
+ };
+ }
+}
+
+export default function AdminVersionPage() {
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [checking, setChecking] = useState(false);
+ const [checkResult, setCheckResult] = useState<{ ok: boolean; msg: string } | null>(null);
+
+ async function refresh(): Promise {
+ setLoading(true);
+ try {
+ const r = await apiFetch('/api/admin/version');
+ if (!r.ok) throw new Error('failed to load');
+ setData((await r.json()) as VersionAdminStatus);
+ } catch (err) {
+ console.error(err);
+ } finally {
+ setLoading(false);
+ }
+ }
+ useEffect(() => { void refresh(); }, []);
+
+ async function checkNow(): Promise {
+ setChecking(true);
+ setCheckResult(null);
+ try {
+ const r = await apiFetch('/api/admin/version', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ action: 'check-now' }),
+ });
+ const j = (await r.json().catch(() => ({}))) as { ok?: boolean; error?: string };
+ setCheckResult({
+ ok: !!j.ok,
+ msg: j.ok ? 'Update check completed.' : `Failed: ${j.error ?? 'unknown'}`,
+ });
+ await refresh();
+ } finally {
+ setChecking(false);
+ }
+ }
+
+ if (loading || !data) {
+ return (
+
+ loading…
+
+ );
+ }
+
+ const status = data.status;
+ const chip = severityChip(status?.severity ?? 'none');
+ const ChipIcon = chip.Icon;
+ const releaseUrl = status?.url ?? null;
+ const newer = status?.latest && status.latest !== data.current ? status.latest : null;
+
+ return (
+
+
+
+
Version
+
+ Hourly check against the Bulwark version server. Severity is decided server-side and
+ disable with BULWARK_UPDATE_CHECK=off.
+
+
+
+
+
+ {checkResult && (
+
+ {checkResult.msg}
+
+ )}
+
+
+
+
+
+ {chip.label}
+
+
+
+ {data.current}
+
+ {newer && (
+
+ {releaseUrl ? (
+
+ {newer}
+
+ ) : (
+ {newer}
+ )}
+
+ )}
+ {status?.advisory && (
+
+ {status.advisory}
+
+ )}
+
+
+
+
+ {timeAgo(data.lastCheckedAt)}
+
+
+ {timeAgo(data.lastSuccessAt)}
+
+
+ {timeAgo(data.nextScheduledAt)}
+
+ {status?.checkedAt && (
+
+ {new Date(status.checkedAt).toLocaleString()}
+
+ )}
+
+
+
+
+
+ {data.endpoint}
+
+
+
+
+ {data.disabledByEnv ? 'Yes' : 'No'}
+
+
+
+
+ );
+}
diff --git a/app/api/admin/version/route.ts b/app/api/admin/version/route.ts
new file mode 100644
index 00000000..813beeb2
--- /dev/null
+++ b/app/api/admin/version/route.ts
@@ -0,0 +1,66 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { requireAdminAuth } from '@/lib/admin/session';
+import { logger } from '@/lib/logger';
+import {
+ loadState,
+ checkOnce,
+ effectiveEndpoint,
+ disabledByEnv,
+ DEFAULT_VERSION_ENDPOINT,
+} from '@/lib/version-check';
+
+/**
+ * GET /api/admin/version
+ * Returns the cached update status, last check times, and effective config.
+ */
+export async function GET() {
+ try {
+ const auth = await requireAdminAuth();
+ if ('error' in auth) return auth.error;
+
+ const state = await loadState();
+ return NextResponse.json(
+ {
+ current: process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0',
+ build: process.env.NEXT_PUBLIC_GIT_COMMIT || 'unknown',
+ endpoint: effectiveEndpoint(state),
+ defaultEndpoint: DEFAULT_VERSION_ENDPOINT,
+ disabledByEnv: disabledByEnv(),
+ lastCheckedAt: state.lastCheckedAt,
+ lastSuccessAt: state.lastSuccessAt,
+ nextScheduledAt: state.nextScheduledAt,
+ status: state.status,
+ },
+ { headers: { 'Cache-Control': 'no-store' } },
+ );
+ } catch (err) {
+ logger.error('version admin GET error', {
+ error: err instanceof Error ? err.message : 'unknown',
+ });
+ return NextResponse.json({ error: 'failed' }, { status: 500 });
+ }
+}
+
+/**
+ * POST /api/admin/version
+ * { action: 'check-now' } — force a fresh upstream fetch.
+ */
+export async function POST(req: NextRequest) {
+ try {
+ const auth = await requireAdminAuth();
+ if ('error' in auth) return auth.error;
+
+ const body = (await req.json().catch(() => null)) as { action?: string } | null;
+ if (!body || body.action !== 'check-now') {
+ return NextResponse.json({ error: 'unknown action' }, { status: 400 });
+ }
+
+ const result = await checkOnce({ reason: 'admin-trigger' });
+ return NextResponse.json(result);
+ } catch (err) {
+ logger.error('version admin POST error', {
+ error: err instanceof Error ? err.message : 'unknown',
+ });
+ return NextResponse.json({ error: 'failed' }, { status: 500 });
+ }
+}
diff --git a/app/api/system/update-status/route.ts b/app/api/system/update-status/route.ts
new file mode 100644
index 00000000..d3af2131
--- /dev/null
+++ b/app/api/system/update-status/route.ts
@@ -0,0 +1,22 @@
+import { NextResponse } from 'next/server';
+import { loadState } from '@/lib/version-check';
+
+// Public endpoint that returns the latest cached update status. Fed by the
+// background scheduler started in instrumentation.node.ts; we never trigger
+// a fresh upstream fetch from this route, so an unauthenticated client cannot
+// use it to amplify traffic to the version server.
+export async function GET() {
+ const state = await loadState();
+ return NextResponse.json(
+ {
+ status: state.status,
+ lastCheckedAt: state.lastCheckedAt,
+ lastSuccessAt: state.lastSuccessAt,
+ },
+ {
+ headers: {
+ 'Cache-Control': 'no-store',
+ },
+ },
+ );
+}
diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx
index 11ecedbc..ea94803f 100644
--- a/components/layout/navigation-rail.tsx
+++ b/components/layout/navigation-rail.tsx
@@ -16,6 +16,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { usePolicyStore } from "@/stores/policy-store";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
+import { useUpdateStore, selectHasUpdate } from "@/stores/update-store";
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
import { getInitials, MAX_ACCOUNTS } from "@/lib/account-utils";
import { cn, formatFileSize } from "@/lib/utils";
@@ -175,6 +176,11 @@ export function NavigationRail({
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
+ const hasUpdate = useUpdateStore(selectHasUpdate);
+ const updateSeverity = useUpdateStore((s) => s.status?.severity);
+ const startUpdatePolling = useUpdateStore((s) => s.startPolling);
+ useEffect(() => { startUpdatePolling(); }, [startUpdatePolling]);
+ const updateImportant = updateSeverity === 'security' || updateSeverity === 'deprecated';
// Account list for rail
const accounts = useAccountStore((s) => s.accounts);
@@ -345,7 +351,18 @@ export function NavigationRail({
"text-muted-foreground hover:text-foreground"
)}
>
-
+
+
+ {hasUpdate && (
+
+ )}
+
{t("admin") || "Admin"}
)}
@@ -515,10 +532,19 @@ export function NavigationRail({
{isStalwartAdmin && (
+ {hasUpdate && (
+
+ )}
)}
diff --git a/components/settings/about-data-settings.tsx b/components/settings/about-data-settings.tsx
index 6997fcc1..6488d1cb 100644
--- a/components/settings/about-data-settings.tsx
+++ b/components/settings/about-data-settings.tsx
@@ -1,18 +1,51 @@
"use client";
-import { useState, useRef } from 'react';
+import { useState, useRef, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store';
import { useConfig } from '@/hooks/use-config';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { Button } from '@/components/ui/button';
import { usePolicyStore } from '@/stores/policy-store';
+import { useUpdateStore } from '@/stores/update-store';
import { ExternalLink } from 'lucide-react';
+import { cn } from '@/lib/utils';
import { SpamSiegeGame } from './spam-siege-game';
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
+function VersionUpdateTag() {
+ const status = useUpdateStore((s) => s.status);
+ const startPolling = useUpdateStore((s) => s.startPolling);
+
+ useEffect(() => {
+ startPolling();
+ }, [startPolling]);
+
+ if (!status?.updateAvailable) return null;
+ if (status.severity === 'unknown' || status.severity === 'none') return null;
+
+ const important = status.severity === 'security' || status.severity === 'deprecated';
+ const label =
+ status.severity === 'security' ? 'security'
+ : status.severity === 'deprecated' ? 'deprecated'
+ : status.latest ?? 'update';
+
+ return (
+
+ {important ? label : `update: ${label}`}
+
+ );
+}
+
export function AboutDataSettings() {
const t = useTranslations('settings.advanced');
const tCommon = useTranslations('common');
@@ -106,6 +139,7 @@ export function AboutDataSettings() {
v{APP_VERSION} ({GIT_COMMIT})
+
diff --git a/components/system/update-banner.tsx b/components/system/update-banner.tsx
new file mode 100644
index 00000000..5080957a
--- /dev/null
+++ b/components/system/update-banner.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import { useEffect } from "react";
+import { useShallow } from "zustand/react/shallow";
+import { AlertTriangle, X } from "lucide-react";
+import { useUpdateStore, selectBanner } from "@/stores/update-store";
+import { cn } from "@/lib/utils";
+
+// Single-line, low-key update notice. Lives next to the version badge on the
+// login screen — deliberately understated so it doesn't distract from the
+// auth flow. Red variants (security / deprecated) still use red text but
+// stay the same compact shape.
+export function UpdateNotice({ className }: { className?: string }) {
+ const banner = useUpdateStore(useShallow(selectBanner));
+ const dismiss = useUpdateStore((s) => s.dismiss);
+ const startPolling = useUpdateStore((s) => s.startPolling);
+
+ useEffect(() => {
+ startPolling();
+ }, [startPolling]);
+
+ if (!banner) return null;
+
+ const isRed = banner.variant === "red";
+
+ const text =
+ banner.severity === "security"
+ ? `Security update${banner.latest ? `: ${banner.latest}` : ""}`
+ : banner.severity === "deprecated"
+ ? "Version no longer supported"
+ : `Update available: ${banner.latest ?? ""}`;
+
+ return (
+
+ {isRed &&
}
+ {banner.url ? (
+
+ {text}
+
+ ) : (
+
{text}
+ )}
+ {banner.dismissible && (
+
+ )}
+
+ );
+}
diff --git a/instrumentation.node.ts b/instrumentation.node.ts
index 3546ba4c..d4e1525e 100644
--- a/instrumentation.node.ts
+++ b/instrumentation.node.ts
@@ -2,49 +2,12 @@ import { readFileSync } from "fs";
import { configManager } from "./lib/admin/config-manager";
import { initAdminPassword } from "./lib/admin/password";
-const VERSION_CHECK_URL =
- "https://raw.githubusercontent.com/bulwarkmail/webmail/main/VERSION";
-
-const SEMVER_RE = /^\d+\.\d+\.\d+$/;
-
-function compareVersions(current: string, remote: string): number {
- const a = current.split(".").map(Number);
- const b = remote.split(".").map(Number);
- for (let i = 0; i < 3; i++) {
- if ((b[i] ?? 0) > (a[i] ?? 0)) return 1;
- if ((b[i] ?? 0) < (a[i] ?? 0)) return -1;
- }
- return 0;
-}
-
const pkg = JSON.parse(
readFileSync(`${process.cwd()}/package.json`, "utf-8")
);
const current: string = pkg.version ?? "0.0.0";
console.info(`Bulwark Webmail v${current}`);
-if (process.env.NODE_ENV === "production") {
- fetch(VERSION_CHECK_URL, {
- cache: "no-store",
- signal: AbortSignal.timeout(5000),
- })
- .then((res) => {
- if (!res.ok) return;
- return res.text();
- })
- .then((text) => {
- if (!text) return;
- const remote = text.trim();
- if (!SEMVER_RE.test(remote)) return;
- if (compareVersions(current, remote) > 0) {
- console.info(
- `Update available: v${remote} - https://github.com/bulwarkmail/webmail`
- );
- }
- })
- .catch(() => {});
-}
-
// Initialize admin config and password bootstrap
configManager.load()
.then(() => initAdminPassword())
@@ -59,6 +22,13 @@ configManager.load()
markProcessStart();
await startScheduler();
})
+ .then(async () => {
+ // Hourly check against version.telemetry.bulwarkmail.org. Disable with
+ // BULWARK_UPDATE_CHECK=off or override the endpoint with
+ // BULWARK_UPDATE_CHECK_URL.
+ const { startScheduler } = await import("./lib/version-check");
+ await startScheduler();
+ })
.catch((err) => {
console.warn("Admin dashboard init skipped:", err instanceof Error ? err.message : err);
});
diff --git a/lib/version-check/fetcher.ts b/lib/version-check/fetcher.ts
new file mode 100644
index 00000000..cc125b06
--- /dev/null
+++ b/lib/version-check/fetcher.ts
@@ -0,0 +1,82 @@
+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 };
+ }
+}
diff --git a/lib/version-check/index.ts b/lib/version-check/index.ts
new file mode 100644
index 00000000..9b7ec084
--- /dev/null
+++ b/lib/version-check/index.ts
@@ -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';
diff --git a/lib/version-check/sender.ts b/lib/version-check/sender.ts
new file mode 100644
index 00000000..326cc096
--- /dev/null
+++ b/lib/version-check/sender.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ if (currentTimer) clearTimeout(currentTimer);
+ currentTimer = null;
+}
diff --git a/lib/version-check/state.ts b/lib/version-check/state.ts
new file mode 100644
index 00000000..b6de0037
--- /dev/null
+++ b/lib/version-check/state.ts
@@ -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 {
+ if (!existsSync(getDir())) await mkdir(getDir(), { recursive: true });
+}
+
+export async function loadState(): Promise {
+ await ensureDir();
+ try {
+ const raw = await readFile(statePath(), 'utf8');
+ const parsed = JSON.parse(raw) as Partial;
+ 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 {
+ 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;
+}
diff --git a/lib/version-check/types.ts b/lib/version-check/types.ts
new file mode 100644
index 00000000..b624626c
--- /dev/null
+++ b/lib/version-check/types.ts
@@ -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/';
diff --git a/stores/update-store.ts b/stores/update-store.ts
new file mode 100644
index 00000000..1cb3d014
--- /dev/null
+++ b/stores/update-store.ts
@@ -0,0 +1,148 @@
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+import { apiFetch } from '@/lib/browser-navigation';
+import type { UpdateStatus, UpdateSeverity } from '@/lib/version-check/types';
+
+const POLL_INTERVAL_MS = 15 * 60 * 1000;
+
+interface UpdateState {
+ status: UpdateStatus | null;
+ loading: boolean;
+ lastFetchedAt: number | null;
+ // Latest version the user has dismissed the amber banner for. A newer
+ // release re-shows the banner. Persisted in localStorage.
+ dismissedVersion: string | null;
+
+ fetchStatus: () => Promise;
+ startPolling: () => void;
+ stopPolling: () => void;
+ dismiss: () => void;
+}
+
+let pollTimer: ReturnType | null = null;
+let inFlight: Promise | null = null;
+
+interface ApiResponse {
+ status: UpdateStatus | null;
+ lastCheckedAt: string | null;
+ lastSuccessAt: string | null;
+}
+
+export const useUpdateStore = create()(
+ persist(
+ (set, get) => ({
+ status: null,
+ loading: false,
+ lastFetchedAt: null,
+ dismissedVersion: null,
+
+ fetchStatus: async () => {
+ if (inFlight) return inFlight;
+ set({ loading: true });
+ inFlight = (async () => {
+ try {
+ const res = await apiFetch('/api/system/update-status');
+ if (!res.ok) return;
+ const body = (await res.json()) as ApiResponse;
+ set({
+ status: body.status,
+ lastFetchedAt: Date.now(),
+ });
+ } catch {
+ // Silent — banner just won't appear, no need to disrupt the UI.
+ } finally {
+ set({ loading: false });
+ inFlight = null;
+ }
+ })();
+ return inFlight;
+ },
+
+ startPolling: () => {
+ if (pollTimer) return;
+ void get().fetchStatus();
+ pollTimer = setInterval(() => {
+ void get().fetchStatus();
+ }, POLL_INTERVAL_MS);
+ },
+
+ stopPolling: () => {
+ if (pollTimer) {
+ clearInterval(pollTimer);
+ pollTimer = null;
+ }
+ },
+
+ dismiss: () => {
+ const { status } = get();
+ const target = status?.latest ?? status?.current;
+ if (!target) return;
+ set({ dismissedVersion: target });
+ },
+ }),
+ {
+ name: 'bulwark-update-dismissed',
+ // Only persist the dismissal — status is fetched fresh per session.
+ partialize: (s) => ({ dismissedVersion: s.dismissedVersion }),
+ },
+ ),
+);
+
+// Selectors. Keep them outside the store creator so components subscribing
+// to a single derived value don't re-render on unrelated state changes.
+
+export type BannerVariant = 'amber' | 'red';
+
+export interface BannerInfo {
+ variant: BannerVariant;
+ severity: UpdateSeverity;
+ latest: string | null;
+ url: string | null;
+ advisory: string | null;
+ dismissible: boolean;
+}
+
+export function selectBanner(s: UpdateState): BannerInfo | null {
+ const st = s.status;
+ if (!st || !st.updateAvailable) return null;
+ if (st.severity === 'none' || st.severity === 'unknown') return null;
+
+ if (st.severity === 'security') {
+ return {
+ variant: 'red',
+ severity: 'security',
+ latest: st.latest,
+ url: st.url,
+ advisory: st.advisory,
+ dismissible: false,
+ };
+ }
+ if (st.severity === 'deprecated') {
+ return {
+ variant: 'red',
+ severity: 'deprecated',
+ latest: st.latest,
+ url: st.url,
+ advisory: null,
+ dismissible: false,
+ };
+ }
+ // normal
+ const target = st.latest ?? st.current;
+ if (s.dismissedVersion === target) return null;
+ return {
+ variant: 'amber',
+ severity: 'normal',
+ latest: st.latest,
+ url: st.url,
+ advisory: null,
+ dismissible: true,
+ };
+}
+
+// Used by the admin shield to show a dot regardless of dismissal state. We
+// always want admins to see that an update is needed, even if the amber
+// banner has been dismissed.
+export function selectHasUpdate(s: UpdateState): boolean {
+ return !!s.status?.updateAvailable && s.status.severity !== 'unknown';
+}