Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/core-update-notice.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions docs/src/content/docs/reference/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"`.
Expand Down
78 changes: 78 additions & 0 deletions packages/admin/src/components/CoreUpdateBanner.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Banner
variant="default"
icon={<ArrowCircleUp />}
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={
<div className="flex items-center gap-2">
<LinkButton
size="sm"
href="https://github.com/emdash-cms/emdash/releases"
external
>{t`Release notes`}</LinkButton>
<Button size="sm" variant="ghost" onClick={dismiss}>{t`Dismiss`}</Button>
</div>
}
/>
);
}
3 changes: 3 additions & 0 deletions packages/admin/src/components/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -51,6 +52,8 @@ export function Dashboard({ manifest }: DashboardProps) {
<QuickActions manifest={manifest} />
</div>

<CoreUpdateBanner />

{isError && <DashboardDataError />}

{showDashboardData && (
Expand Down
27 changes: 27 additions & 0 deletions packages/admin/src/lib/api/core-update.ts
Original file line number Diff line number Diff line change
@@ -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<CoreUpdateStatus> {
const response = await apiFetch(`${API_BASE}/admin/core-update`);
return parseApiResponse<CoreUpdateStatus>(response, i18n._(msg`Failed to fetch update status`));
}
3 changes: 3 additions & 0 deletions packages/auth/src/rbac.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/api/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/api/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
174 changes: 174 additions & 0 deletions packages/core/src/api/handlers/update-check.ts
Original file line number Diff line number Diff line change
@@ -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<Database>,
fetchImpl: typeof fetch = fetch,
): Promise<void> {
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<Database>,
options?: { now?: Date; enabled?: boolean },
): Promise<ApiResult<CoreUpdateStatus>> {
// 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" },
};
}
}
Loading
Loading