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); + }); +});