diff --git a/.changeset/core-update-notice.md b/.changeset/core-update-notice.md
new file mode 100644
index 0000000000..8a13c5a05a
--- /dev/null
+++ b/.changeset/core-update-notice.md
@@ -0,0 +1,7 @@
+---
+"emdash": minor
+"@emdash-cms/admin": minor
+"@emdash-cms/auth": minor
+---
+
+Adds a core update notice to the admin dashboard: when a newer EmDash version is published to npm, admins see a dismissible banner with a link to the release notes. The registry is checked at most once per day in the background and never blocks a request. Set `updateCheck: false` in your EmDash config to disable the check.
diff --git a/docs/src/content/docs/reference/configuration.mdx b/docs/src/content/docs/reference/configuration.mdx
index 957977688a..9547c9999a 100644
--- a/docs/src/content/docs/reference/configuration.mdx
+++ b/docs/src/content/docs/reference/configuration.mdx
@@ -367,6 +367,22 @@ emdash({
Uploads that exceed the configured limit are rejected with a `413 Payload Too Large` response on the direct upload path, or a `400 Validation Error` on the signed-URL path.
+### `updateCheck`
+
+**Optional.** Controls the core update notice in the admin dashboard. Defaults to `true`.
+
+When enabled, EmDash checks the public npm registry for the latest published `emdash` version — at most once per day, deferred after the response so it never slows down a request — and shows a dismissible banner in the admin dashboard when a newer version is available. Dismissing the banner hides it for that version; it reappears when the next version ships.
+
+The check is a plain GET to `https://registry.npmjs.org/emdash/latest` and carries no site data. Deployments without outbound internet access simply never show the banner. Set to `false` to disable the check entirely:
+
+```js
+emdash({
+ updateCheck: false,
+})
+```
+
+The banner is informational only — updating EmDash means bumping the `emdash` package in your project and redeploying, which the admin can't (and shouldn't) trigger.
+
### `toolbar`
**Optional.** Controls how the editor toolbar (the floating pill on public pages) is delivered. Defaults to `"server"`.
diff --git a/packages/admin/src/components/CoreUpdateBanner.tsx b/packages/admin/src/components/CoreUpdateBanner.tsx
new file mode 100644
index 0000000000..26670445ea
--- /dev/null
+++ b/packages/admin/src/components/CoreUpdateBanner.tsx
@@ -0,0 +1,78 @@
+import { Banner, Button, LinkButton } from "@cloudflare/kumo";
+import { useLingui } from "@lingui/react/macro";
+import { ArrowCircleUp } from "@phosphor-icons/react";
+import { useQuery } from "@tanstack/react-query";
+import { useState } from "react";
+
+import { fetchCoreUpdateStatus } from "../lib/api/core-update";
+import { useCurrentUser } from "../lib/api/current-user";
+
+/** Matches Role.ADMIN in @emdash-cms/auth (same constant as Sidebar). */
+const ROLE_ADMIN = 50;
+
+const DISMISS_KEY = "emdash:core-update-dismissed";
+
+function readDismissedVersion(): string | null {
+ try {
+ return localStorage.getItem(DISMISS_KEY);
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * WordPress-style "a new version is available" notice (Discussion #1889).
+ *
+ * Shown to admins on the dashboard when the npm registry reports a newer
+ * `emdash` version than the one running. Dismissible per version: hiding
+ * 0.24 re-arms the banner when 0.25 ships. The admin can't trigger the
+ * update itself — an EmDash update is an npm bump + redeploy — so the
+ * banner links to the release notes instead.
+ */
+export function CoreUpdateBanner() {
+ const { t } = useLingui();
+ const { data: user } = useCurrentUser();
+ const isAdmin = (user?.role ?? 0) >= ROLE_ADMIN;
+
+ const { data: status } = useQuery({
+ queryKey: ["core-update"],
+ queryFn: fetchCoreUpdateStatus,
+ enabled: isAdmin,
+ staleTime: 60 * 60 * 1000,
+ retry: false,
+ });
+
+ const [dismissedVersion, setDismissedVersion] = useState(readDismissedVersion);
+
+ if (!isAdmin || !status?.updateAvailable || !status.latest) return null;
+ if (dismissedVersion === status.latest) return null;
+
+ const latest = status.latest;
+ const dismiss = () => {
+ try {
+ localStorage.setItem(DISMISS_KEY, latest);
+ } catch {
+ // Storage unavailable (private mode) — hide for this render only.
+ }
+ setDismissedVersion(latest);
+ };
+
+ return (
+ }
+ title={t`EmDash ${latest} is available (you're running ${status.current})`}
+ description={t`Update the emdash package in your project and redeploy to get the latest fixes.`}
+ action={
+
+ {t`Release notes`}
+
+
+ }
+ />
+ );
+}
diff --git a/packages/admin/src/components/Dashboard.tsx b/packages/admin/src/components/Dashboard.tsx
index c1ff227e84..b80089d271 100644
--- a/packages/admin/src/components/Dashboard.tsx
+++ b/packages/admin/src/components/Dashboard.tsx
@@ -20,6 +20,7 @@ import { fetchDashboardStats } from "../lib/api/dashboard";
import { usePluginWidget } from "../lib/plugin-context";
import { formatRelativeTime } from "../lib/utils";
import { ArrowNext } from "./ArrowIcons";
+import { CoreUpdateBanner } from "./CoreUpdateBanner";
import { RouterLinkButton } from "./RouterLinkButton";
import { SandboxedPluginWidget } from "./SandboxedPluginWidget";
@@ -51,6 +52,8 @@ export function Dashboard({ manifest }: DashboardProps) {
+
+
{isError && }
{showDashboardData && (
diff --git a/packages/admin/src/lib/api/core-update.ts b/packages/admin/src/lib/api/core-update.ts
new file mode 100644
index 0000000000..0c4a8bf87c
--- /dev/null
+++ b/packages/admin/src/lib/api/core-update.ts
@@ -0,0 +1,27 @@
+/**
+ * Core update status API (Discussion #1889)
+ */
+
+import { i18n } from "@lingui/core";
+import { msg } from "@lingui/core/macro";
+
+import { API_BASE, apiFetch, parseApiResponse } from "./client.js";
+
+export interface CoreUpdateStatus {
+ /** The running EmDash version ("dev" in uncompiled dev runs). */
+ current: string;
+ /** Latest published version, or null when no check has completed yet. */
+ latest: string | null;
+ updateAvailable: boolean;
+ /** ISO timestamp of the last successful registry check, if any. */
+ checkedAt: string | null;
+}
+
+/**
+ * Fetch the cached core update status. The server defers the actual
+ * registry check, so this is always a fast local read.
+ */
+export async function fetchCoreUpdateStatus(): Promise {
+ const response = await apiFetch(`${API_BASE}/admin/core-update`);
+ return parseApiResponse(response, i18n._(msg`Failed to fetch update status`));
+}
diff --git a/packages/auth/src/rbac.ts b/packages/auth/src/rbac.ts
index 0d9333a025..1a3bf1345c 100644
--- a/packages/auth/src/rbac.ts
+++ b/packages/auth/src/rbac.ts
@@ -86,6 +86,9 @@ export const Permissions = {
// Import
"import:execute": Role.ADMIN,
+ // Core update notice (admins act on it; editors can't update anyway)
+ "updates:read": Role.ADMIN,
+
// Search
"search:read": Role.SUBSCRIBER,
"search:manage": Role.ADMIN,
diff --git a/packages/core/src/api/errors.ts b/packages/core/src/api/errors.ts
index 43e802c541..9391b33149 100644
--- a/packages/core/src/api/errors.ts
+++ b/packages/core/src/api/errors.ts
@@ -183,6 +183,9 @@ export const ErrorCode = {
DOMAIN_UPDATE_ERROR: "DOMAIN_UPDATE_ERROR",
DOMAIN_DELETE_ERROR: "DOMAIN_DELETE_ERROR",
+ // Core update check (Discussion #1889)
+ UPDATE_CHECK_ERROR: "UPDATE_CHECK_ERROR",
+
// Plugins / Marketplace
PLUGIN_LIST_ERROR: "PLUGIN_LIST_ERROR",
PLUGIN_GET_ERROR: "PLUGIN_GET_ERROR",
diff --git a/packages/core/src/api/handlers/index.ts b/packages/core/src/api/handlers/index.ts
index 181ec2cb68..3b0984e959 100644
--- a/packages/core/src/api/handlers/index.ts
+++ b/packages/core/src/api/handlers/index.ts
@@ -38,6 +38,9 @@ export {
type RecentItem,
} from "./dashboard.js";
+// Core update check (Discussion #1889)
+export { handleCoreUpdateStatus, type CoreUpdateStatus } from "./update-check.js";
+
// Manifest generation
export { generateManifest } from "./manifest.js";
diff --git a/packages/core/src/api/handlers/update-check.ts b/packages/core/src/api/handlers/update-check.ts
new file mode 100644
index 0000000000..fa2d5d5b47
--- /dev/null
+++ b/packages/core/src/api/handlers/update-check.ts
@@ -0,0 +1,174 @@
+/**
+ * Core update check (Discussion #1889).
+ *
+ * WordPress-style "a new version is available" awareness for the admin
+ * dashboard. The server knows its own version (`VERSION`); the latest
+ * published version comes from the public npm registry, fetched at most
+ * once per day and cached in the options table. The registry request is
+ * always deferred via `after()` so it never blocks a request — a stale
+ * (or missing) cache serves the previous result and refreshes in the
+ * background.
+ *
+ * Deliberately NOT here: an "update now" button. An EmDash update is an
+ * npm bump + rebuild + redeploy, which the admin cannot and should not
+ * trigger. The value is the awareness.
+ */
+
+import type { Kysely } from "kysely";
+
+import { after } from "../../after.js";
+import { OptionsRepository } from "../../database/repositories/options.js";
+import type { Database } from "../../database/types.js";
+import { VERSION } from "../../version.js";
+import { ErrorCode } from "../errors.js";
+import type { ApiResult } from "../types.js";
+
+/** Options-table key for the cached registry result. */
+export const CORE_UPDATE_OPTION = "emdash:core_update_check";
+
+/** Re-check the registry at most once per day. */
+const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
+
+/** npm registry endpoint for the latest published `emdash` version. */
+const REGISTRY_URL = "https://registry.npmjs.org/emdash/latest";
+
+const REGISTRY_TIMEOUT_MS = 10_000;
+
+/** Cached registry state, stored in the options table. */
+interface CoreUpdateCache {
+ /** Latest version the registry reported. */
+ latest: string;
+ /** ISO timestamp of the last successful registry check. */
+ checkedAt: string;
+}
+
+export interface CoreUpdateStatus {
+ /** The running EmDash version (`"dev"` in uncompiled dev/test runs). */
+ current: string;
+ /** Latest published version, or null when no check has completed yet. */
+ latest: string | null;
+ updateAvailable: boolean;
+ /** ISO timestamp of the last successful registry check, if any. */
+ checkedAt: string | null;
+}
+
+const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)(?:-.+)?$/;
+
+/**
+ * True when `latest` is a strictly newer release than `current`.
+ *
+ * Handles plain `major.minor.patch` versions (what npm's `latest` dist-tag
+ * carries). Anything unparsable — including the `"dev"` fallback version —
+ * compares as "not newer", so dev/test runs never show the banner.
+ * ponytail: prerelease identifiers are ignored (compared as their base
+ * release); fine for npm `latest`, which never points at a prerelease.
+ */
+export function isNewerVersion(latest: string, current: string): boolean {
+ const l = SEMVER_RE.exec(latest);
+ const c = SEMVER_RE.exec(current);
+ if (!l || !c) return false;
+ for (let i = 1; i <= 3; i++) {
+ const a = Number(l[i]);
+ const b = Number(c[i]);
+ if (a !== b) return a > b;
+ }
+ return false;
+}
+
+/**
+ * Fetch the latest version from the npm registry and cache it.
+ * Exported for tests; production callers go through
+ * `handleCoreUpdateStatus`, which defers this via `after()`.
+ */
+export async function refreshCoreUpdateCache(
+ db: Kysely,
+ fetchImpl: typeof fetch = fetch,
+): Promise {
+ const response = await fetchImpl(REGISTRY_URL, {
+ headers: { accept: "application/json" },
+ signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS),
+ });
+ if (!response.ok) {
+ throw new Error(`registry responded ${response.status}`);
+ }
+ const body: unknown = await response.json();
+ const latest =
+ typeof body === "object" && body !== null && "version" in body ? body.version : null;
+ if (typeof latest !== "string" || !SEMVER_RE.test(latest)) {
+ throw new Error("registry response missing a valid version");
+ }
+ const cache: CoreUpdateCache = { latest, checkedAt: new Date().toISOString() };
+ await new OptionsRepository(db).set(CORE_UPDATE_OPTION, cache);
+}
+
+function parseCache(value: unknown): CoreUpdateCache | null {
+ if (typeof value !== "object" || value === null) return null;
+ if (!("latest" in value) || !("checkedAt" in value)) return null;
+ const { latest, checkedAt } = value;
+ if (typeof latest !== "string" || typeof checkedAt !== "string") return null;
+ // An unparsable checkedAt would make the staleness math NaN (never
+ // stale), freezing a corrupt cache forever — treat it as no cache so
+ // the next request schedules a refresh that overwrites it.
+ if (Number.isNaN(Date.parse(checkedAt))) return null;
+ return { latest, checkedAt };
+}
+
+/**
+ * Report the cached update status and, when the cache is stale (or
+ * missing), kick a deferred registry refresh. Never blocks on the
+ * network: the first request after install/expiry reports the previous
+ * state and the next request sees the refreshed one.
+ *
+ * ponytail: concurrent stale reads can kick overlapping refreshes; both
+ * write the same idempotent option row, so no coordination is needed.
+ */
+export async function handleCoreUpdateStatus(
+ db: Kysely,
+ options?: { now?: Date; enabled?: boolean },
+): Promise> {
+ // Opt-out (`updateCheck: false` in config): report the current version
+ // only — no registry traffic, no banner, and no stale cache leaking
+ // through from before the check was disabled.
+ if (options?.enabled === false) {
+ return {
+ success: true,
+ data: { current: VERSION, latest: null, updateAvailable: false, checkedAt: null },
+ };
+ }
+
+ try {
+ const raw = await new OptionsRepository(db).get(CORE_UPDATE_OPTION);
+ const cache = parseCache(raw);
+
+ const now = options?.now ?? new Date();
+ const stale =
+ !cache || now.getTime() - new Date(cache.checkedAt).getTime() >= CHECK_INTERVAL_MS;
+ // A "dev" version can never compare as outdated, so skip the
+ // registry round-trip entirely in uncompiled dev/test runs.
+ if (stale && VERSION !== "dev") {
+ after(async () => {
+ try {
+ await refreshCoreUpdateCache(db);
+ } catch (error) {
+ console.warn("[update-check] registry refresh failed:", error);
+ }
+ });
+ }
+
+ return {
+ success: true,
+ data: {
+ current: VERSION,
+ latest: cache?.latest ?? null,
+ updateAvailable: cache ? isNewerVersion(cache.latest, VERSION) : false,
+ checkedAt: cache?.checkedAt ?? null,
+ },
+ };
+ } catch (error) {
+ console.error("Failed to read core update status:", error);
+ return {
+ success: false,
+ error: { code: ErrorCode.UPDATE_CHECK_ERROR, message: "Failed to read update status" },
+ };
+ }
+}
diff --git a/packages/core/src/astro/integration/index.ts b/packages/core/src/astro/integration/index.ts
index 47985fcab3..e4a878e88a 100644
--- a/packages/core/src/astro/integration/index.ts
+++ b/packages/core/src/astro/integration/index.ts
@@ -142,6 +142,31 @@ export function buildImageRemotePatterns(
return patterns;
}
+/**
+ * Build the config subset baked into `virtual:emdash/config` and exposed
+ * at runtime as `locals.emdash.config`. A config option that runtime code
+ * reads (routes, middleware) MUST be listed here — an option only on
+ * `EmDashConfig` is invisible at runtime and silently ignored.
+ *
+ * @internal Exported for unit testing.
+ */
+export function buildSerializableConfig(resolvedConfig: EmDashConfig): Record {
+ return {
+ database: resolvedConfig.database,
+ storage: resolvedConfig.storage,
+ auth: resolvedConfig.auth,
+ authProviders: resolvedConfig.authProviders,
+ marketplace: resolvedConfig.marketplace,
+ experimental: resolvedConfig.experimental,
+ siteUrl: resolvedConfig.siteUrl,
+ trustedProxyHeaders: resolvedConfig.trustedProxyHeaders,
+ maxUploadSize: resolvedConfig.maxUploadSize,
+ admin: resolvedConfig.admin,
+ toolbar: resolvedConfig.toolbar,
+ updateCheck: resolvedConfig.updateCheck,
+ };
+}
+
/**
* Stock image endpoints EmDash may safely replace with its storage-backed
* wrapper. Our wrapper delegates non-EmDash images to the platform's transform
@@ -339,19 +364,7 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration {
// Serialize config for virtual module (database/storage/auth - plugins handled separately)
// i18n is populated in astro:config:setup from astroConfig.i18n
- const serializableConfig: Record = {
- database: resolvedConfig.database,
- storage: resolvedConfig.storage,
- auth: resolvedConfig.auth,
- authProviders: resolvedConfig.authProviders,
- marketplace: resolvedConfig.marketplace,
- experimental: resolvedConfig.experimental,
- siteUrl: resolvedConfig.siteUrl,
- trustedProxyHeaders: resolvedConfig.trustedProxyHeaders,
- maxUploadSize: resolvedConfig.maxUploadSize,
- admin: resolvedConfig.admin,
- toolbar: resolvedConfig.toolbar,
- };
+ const serializableConfig = buildSerializableConfig(resolvedConfig);
// Determine auth mode for route injection
// Check if auth is an AuthDescriptor (has entrypoint) indicating external auth
diff --git a/packages/core/src/astro/integration/routes.ts b/packages/core/src/astro/integration/routes.ts
index 7b818cadba..640f359b52 100644
--- a/packages/core/src/astro/integration/routes.ts
+++ b/packages/core/src/astro/integration/routes.ts
@@ -446,6 +446,12 @@ export function injectCoreRoutes(
entrypoint: resolveRoute("api/admin/plugins/updates.ts"),
});
+ // Core update notice (Discussion #1889)
+ injectRoute({
+ pattern: "/_emdash/api/admin/core-update",
+ entrypoint: resolveRoute("api/admin/core-update.ts"),
+ });
+
// Exclusive hooks admin routes
injectRoute({
pattern: "/_emdash/api/admin/hooks/exclusive",
diff --git a/packages/core/src/astro/integration/runtime.ts b/packages/core/src/astro/integration/runtime.ts
index 1ad3866299..74e678c034 100644
--- a/packages/core/src/astro/integration/runtime.ts
+++ b/packages/core/src/astro/integration/runtime.ts
@@ -353,6 +353,23 @@ export interface EmDashConfig {
*/
marketplace?: string;
+ /**
+ * Core update notice in the admin dashboard.
+ *
+ * When enabled (the default), EmDash checks the public npm registry
+ * (`registry.npmjs.org`) at most once per day for the latest published
+ * `emdash` version and shows a dismissible banner in the admin
+ * dashboard when a newer version is available. The check is deferred
+ * after the response and carries no site data — it's a plain GET to
+ * the public registry. Sites without outbound internet simply never
+ * see the banner.
+ *
+ * Set to `false` to disable the check entirely.
+ *
+ * @default true
+ */
+ updateCheck?: boolean;
+
/**
* Experimental features.
*
diff --git a/packages/core/src/astro/routes/api/admin/core-update.ts b/packages/core/src/astro/routes/api/admin/core-update.ts
new file mode 100644
index 0000000000..d91a690ade
--- /dev/null
+++ b/packages/core/src/astro/routes/api/admin/core-update.ts
@@ -0,0 +1,32 @@
+/**
+ * Core update status endpoint (Discussion #1889)
+ *
+ * GET /_emdash/api/admin/core-update - Cached "is a newer EmDash
+ * version available?" status for the admin dashboard banner. Serves
+ * the options-table cache and defers the (at most daily) npm registry
+ * refresh via `after()`, so it never blocks on the network.
+ */
+
+import type { APIRoute } from "astro";
+
+import { requirePerm } from "#api/authorize.js";
+import { apiError, unwrapResult } from "#api/error.js";
+import { handleCoreUpdateStatus } from "#api/handlers/update-check.js";
+
+export const prerender = false;
+
+export const GET: APIRoute = async ({ locals }) => {
+ const { emdash, user } = locals;
+
+ if (!emdash?.db) {
+ return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
+ }
+
+ const denied = requirePerm(user, "updates:read");
+ if (denied) return denied;
+
+ const result = await handleCoreUpdateStatus(emdash.db, {
+ enabled: emdash.config.updateCheck !== false,
+ });
+ return unwrapResult(result);
+};
diff --git a/packages/core/tests/integration/api/update-check.test.ts b/packages/core/tests/integration/api/update-check.test.ts
new file mode 100644
index 0000000000..c24075b1dd
--- /dev/null
+++ b/packages/core/tests/integration/api/update-check.test.ts
@@ -0,0 +1,167 @@
+/**
+ * Core update check tests (Discussion #1889).
+ *
+ * The handler serves the options-table cache and never blocks on the
+ * registry; the registry fetch itself is tested through
+ * `refreshCoreUpdateCache` with a stubbed fetch. In tests VERSION is the
+ * "dev" fallback, which by design never compares as outdated.
+ */
+
+import type { Kysely } from "kysely";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import {
+ CORE_UPDATE_OPTION,
+ handleCoreUpdateStatus,
+ isNewerVersion,
+ refreshCoreUpdateCache,
+} from "../../../src/api/handlers/update-check.js";
+import { OptionsRepository } from "../../../src/database/repositories/options.js";
+import type { Database } from "../../../src/database/types.js";
+import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
+
+describe("isNewerVersion", () => {
+ it("compares major.minor.patch numerically", () => {
+ expect(isNewerVersion("0.24.0", "0.22.5")).toBe(true);
+ expect(isNewerVersion("0.22.5", "0.24.0")).toBe(false);
+ expect(isNewerVersion("0.22.10", "0.22.9")).toBe(true);
+ expect(isNewerVersion("1.0.0", "0.99.99")).toBe(true);
+ expect(isNewerVersion("0.24.0", "0.24.0")).toBe(false);
+ });
+
+ it("treats unparsable versions as not newer", () => {
+ expect(isNewerVersion("0.24.0", "dev")).toBe(false);
+ expect(isNewerVersion("not-a-version", "0.1.0")).toBe(false);
+ expect(isNewerVersion("", "0.1.0")).toBe(false);
+ });
+});
+
+describe("handleCoreUpdateStatus", () => {
+ let db: Kysely;
+
+ beforeEach(async () => {
+ db = await setupTestDatabase();
+ });
+
+ afterEach(async () => {
+ await teardownTestDatabase(db);
+ });
+
+ it("reports no update before any registry check has run", async () => {
+ const result = await handleCoreUpdateStatus(db);
+
+ expect(result.success).toBe(true);
+ if (!result.success) return;
+ expect(result.data.latest).toBeNull();
+ expect(result.data.updateAvailable).toBe(false);
+ expect(result.data.checkedAt).toBeNull();
+ });
+
+ it("serves the cached registry result", async () => {
+ const checkedAt = new Date().toISOString();
+ await new OptionsRepository(db).set(CORE_UPDATE_OPTION, { latest: "0.24.0", checkedAt });
+
+ const result = await handleCoreUpdateStatus(db);
+
+ expect(result.success).toBe(true);
+ if (!result.success) return;
+ expect(result.data.latest).toBe("0.24.0");
+ expect(result.data.checkedAt).toBe(checkedAt);
+ // VERSION is "dev" in tests, which never compares as outdated.
+ expect(result.data.updateAvailable).toBe(false);
+ });
+
+ it("ignores a malformed cache entry", async () => {
+ await new OptionsRepository(db).set(CORE_UPDATE_OPTION, { bogus: true });
+
+ const result = await handleCoreUpdateStatus(db);
+
+ expect(result.success).toBe(true);
+ if (!result.success) return;
+ expect(result.data.latest).toBeNull();
+ expect(result.data.updateAvailable).toBe(false);
+ });
+
+ it("treats an unparsable checkedAt as no cache (so a refresh can overwrite it)", async () => {
+ // With a garbage date the staleness math would be NaN >= interval
+ // (false) — the cache would never refresh. It must parse as absent.
+ await new OptionsRepository(db).set(CORE_UPDATE_OPTION, {
+ latest: "0.24.0",
+ checkedAt: "not-a-date",
+ });
+
+ const result = await handleCoreUpdateStatus(db);
+
+ expect(result.success).toBe(true);
+ if (!result.success) return;
+ expect(result.data.latest).toBeNull();
+ expect(result.data.checkedAt).toBeNull();
+ expect(result.data.updateAvailable).toBe(false);
+ });
+
+ it("reports nothing when the check is disabled, even with a cache", async () => {
+ await new OptionsRepository(db).set(CORE_UPDATE_OPTION, {
+ latest: "0.24.0",
+ checkedAt: new Date().toISOString(),
+ });
+
+ const result = await handleCoreUpdateStatus(db, { enabled: false });
+
+ expect(result.success).toBe(true);
+ if (!result.success) return;
+ expect(result.data.latest).toBeNull();
+ expect(result.data.updateAvailable).toBe(false);
+ expect(result.data.checkedAt).toBeNull();
+ });
+});
+
+describe("refreshCoreUpdateCache", () => {
+ let db: Kysely;
+
+ beforeEach(async () => {
+ db = await setupTestDatabase();
+ });
+
+ afterEach(async () => {
+ await teardownTestDatabase(db);
+ });
+
+ it("stores the registry's latest version in the options table", async () => {
+ const fetchStub = vi.fn().mockResolvedValue(Response.json({ version: "0.24.0" }));
+
+ await refreshCoreUpdateCache(db, fetchStub as unknown as typeof fetch);
+
+ expect(fetchStub).toHaveBeenCalledWith(
+ "https://registry.npmjs.org/emdash/latest",
+ expect.objectContaining({ headers: { accept: "application/json" } }),
+ );
+ const cached = await new OptionsRepository(db).get<{ latest: string; checkedAt: string }>(
+ CORE_UPDATE_OPTION,
+ );
+ expect(cached?.latest).toBe("0.24.0");
+ expect(cached?.checkedAt).toBeTruthy();
+
+ const status = await handleCoreUpdateStatus(db);
+ expect(status.success).toBe(true);
+ if (!status.success) return;
+ expect(status.data.latest).toBe("0.24.0");
+ });
+
+ it("throws on a non-OK registry response and leaves the cache alone", async () => {
+ const fetchStub = vi.fn().mockResolvedValue(new Response("nope", { status: 503 }));
+
+ await expect(refreshCoreUpdateCache(db, fetchStub as unknown as typeof fetch)).rejects.toThrow(
+ "503",
+ );
+ expect(await new OptionsRepository(db).get(CORE_UPDATE_OPTION)).toBeNull();
+ });
+
+ it("rejects a registry response without a valid version", async () => {
+ const fetchStub = vi.fn().mockResolvedValue(Response.json({ version: "latest" }));
+
+ await expect(refreshCoreUpdateCache(db, fetchStub as unknown as typeof fetch)).rejects.toThrow(
+ "valid version",
+ );
+ expect(await new OptionsRepository(db).get(CORE_UPDATE_OPTION)).toBeNull();
+ });
+});
diff --git a/packages/core/tests/unit/astro/integration/image-remote-patterns.test.ts b/packages/core/tests/unit/astro/integration/image-remote-patterns.test.ts
index 6fd165cd66..e8e692ba48 100644
--- a/packages/core/tests/unit/astro/integration/image-remote-patterns.test.ts
+++ b/packages/core/tests/unit/astro/integration/image-remote-patterns.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
buildImageRemotePatterns,
+ buildSerializableConfig,
resolveImageEndpoint,
} from "../../../../src/astro/integration/index.js";
@@ -118,3 +119,21 @@ describe("resolveImageEndpoint", () => {
expect(result.warn).toMatch(/custom image\.endpoint/);
});
});
+
+describe("buildSerializableConfig", () => {
+ it("carries runtime-read options into virtual:emdash/config", () => {
+ // Options that routes/middleware read via `locals.emdash.config` are
+ // only visible at runtime if they survive serialization. An option
+ // added to EmDashConfig but not serialized is silently ignored —
+ // exactly what happened to `updateCheck` (PR #1939 review).
+ const serialized = buildSerializableConfig({
+ siteUrl: "https://example.com",
+ marketplace: "https://marketplace.example.com",
+ updateCheck: false,
+ });
+
+ expect(serialized.siteUrl).toBe("https://example.com");
+ expect(serialized.marketplace).toBe("https://marketplace.example.com");
+ expect(serialized.updateCheck).toBe(false);
+ });
+});