Skip to content
10 changes: 10 additions & 0 deletions .changeset/object-cache-purge-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"emdash": minor
"@emdash-cms/cloudflare": minor
"@emdash-cms/sandbox-workerd": minor
"@emdash-cms/plugin-types": minor
"@emdash-cms/plugin-cli": patch
"@emdash-cms/blocks": minor
---

Adds admin APIs and a `cache:purge` plugin capability for clearing CMS caches: object cache (`GET`/`POST /_emdash/api/admin/cache/object`, `ctx.cache.purgeObjectCache`) and native Workers Caching (`GET`/`POST /_emdash/api/admin/cache/workers`, `ctx.cache.purgeWorkersCache` via `cache.purge` — purge everything or path prefixes; no zone ID or API token). Block Kit buttons also support optional `disabled` and `title` (tooltip) fields.
1 change: 1 addition & 0 deletions packages/admin/src/lib/api/marketplace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ export const CAPABILITY_LABELS: Record<string, MessageDescriptor> = {
"media:read": msg`Access your media library`,
"media:write": msg`Upload and manage media`,
"users:read": msg`Read user accounts`,
"cache:purge": msg`Clear the CMS object cache and Workers Cache`,
"network:request": msg`Make network requests`,
"network:request:unrestricted": msg`Make network requests to any host (unrestricted)`,
// Legacy aliases (still emitted by older installed manifests)
Expand Down
1 change: 1 addition & 0 deletions packages/admin/tests/lib/marketplace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@ describe("CAPABILITY_LABELS", () => {
"media:read",
"media:write",
"users:read",
"cache:purge",
"network:request",
"network:request:unrestricted",
// Legacy aliases
Expand Down
4 changes: 4 additions & 0 deletions packages/blocks/src/builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,8 @@ function button(
style?: "primary" | "danger" | "secondary";
value?: unknown;
confirm?: ConfirmDialog;
disabled?: boolean;
title?: string;
},
): ButtonElement {
return {
Expand All @@ -255,6 +257,8 @@ function button(
...(opts?.style !== undefined && { style: opts.style }),
...(opts?.value !== undefined && { value: opts.value }),
...(opts?.confirm !== undefined && { confirm: opts.confirm }),
...(opts?.disabled !== undefined && { disabled: opts.disabled }),
...(opts?.title !== undefined && { title: opts.title }),
};
}

Expand Down
41 changes: 34 additions & 7 deletions packages/blocks/src/elements/button.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Button, Dialog, DialogRoot } from "@cloudflare/kumo";
import { Button, Dialog, DialogRoot, Tooltip, TooltipProvider } from "@cloudflare/kumo";
import { useCallback, useState } from "react";

import type { BlockInteraction, ButtonElement } from "../types.js";
Expand All @@ -11,22 +11,26 @@ export function ButtonElementComponent({
onAction: (interaction: BlockInteraction) => void;
}) {
const [confirmOpen, setConfirmOpen] = useState(false);
const isDisabled = element.disabled === true;
const hasTitle = element.title !== undefined && element.title.length > 0;

const fireAction = useCallback(() => {
if (isDisabled) return;
onAction({
type: "block_action",
action_id: element.action_id,
value: element.value,
});
}, [onAction, element.action_id, element.value]);
}, [onAction, isDisabled, element.action_id, element.value]);

const handleClick = useCallback(() => {
if (isDisabled) return;
if (element.confirm) {
setConfirmOpen(true);
} else {
fireAction();
}
}, [element.confirm, fireAction]);
}, [isDisabled, element.confirm, fireAction]);

const handleConfirm = useCallback(() => {
setConfirmOpen(false);
Expand All @@ -40,12 +44,35 @@ export function ButtonElementComponent({
? ("destructive" as const)
: ("secondary" as const);

// Don't pass `title` into Kumo Button when disabled — that attaches the
// tooltip trigger to the disabled <button>, which never receives hover.
// Instead wrap a span (always hoverable) as the Tooltip trigger.
const button = (
<Button variant={variant} onClick={handleClick} disabled={isDisabled}>
{element.label}
</Button>
);

const withTooltip = hasTitle ? (
<TooltipProvider>
<Tooltip
content={element.title}
delay={200}
closeDelay={0}
// Span keeps pointer events when the inner button is disabled.
render={<span className="inline-flex max-w-max" />}
>
{button}
</Tooltip>
</TooltipProvider>
) : (
button
);

return (
<>
<Button variant={variant} onClick={handleClick}>
{element.label}
</Button>
{element.confirm && (
{withTooltip}
{element.confirm && !isDisabled && (
<DialogRoot open={confirmOpen} onOpenChange={setConfirmOpen}>
<Dialog>
<h3 className="text-lg font-semibold text-kumo-default">{element.confirm.title}</h3>
Expand Down
4 changes: 4 additions & 0 deletions packages/blocks/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ export interface ButtonElement {
style?: "primary" | "danger" | "secondary";
value?: unknown;
confirm?: ConfirmDialog;
/** When true, the button does not fire actions. */
disabled?: boolean;
/** Native tooltip shown on hover (e.g. why the button is disabled). */
title?: string;
}

export interface TextInputElement {
Expand Down
12 changes: 12 additions & 0 deletions packages/blocks/src/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,18 @@ function validateElement(value: unknown, path: string, errors: ValidationError[]
message: `Field 'style' must be one of: ${[...BUTTON_STYLES].join(", ")}`,
});
}
if (value.disabled !== undefined && typeof value.disabled !== "boolean") {
errors.push({
path: `${path}.disabled`,
message: "Field 'disabled' must be a boolean",
});
}
if (value.title !== undefined && typeof value.title !== "string") {
errors.push({
path: `${path}.title`,
message: "Field 'title' must be a string",
});
}
if (value.confirm !== undefined) {
validateConfirmDialog(value.confirm, `${path}.confirm`, errors);
}
Expand Down
49 changes: 47 additions & 2 deletions packages/blocks/tests/renderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,28 @@ const CollapsibleContext = React.createContext<{
}>({});

vi.mock("@cloudflare/kumo", () => ({
Button: ({ children, onClick, variant, type }: any) => (
<button onClick={onClick} data-variant={variant} type={type || "button"}>
Button: ({ children, onClick, variant, type, disabled, title }: any) => (
<button
onClick={onClick}
data-variant={variant}
type={type || "button"}
disabled={disabled}
title={typeof title === "string" ? title : undefined}
>
{children}
</button>
),
TooltipProvider: ({ children }: any) => <>{children}</>,
Tooltip: ({ content, children, render: triggerRender }: any) => {
const trigger = triggerRender ?? <span />;
return (
<div data-testid="tooltip" data-content={content}>
{React.isValidElement(trigger)
? React.cloneElement(trigger as React.ReactElement<any>, {}, children)
: children}
</div>
);
},
Badge: ({ children }: any) => <span data-testid="badge">{children}</span>,
Input: ({ label, value, defaultValue, onChange, onBlur, placeholder, type, min, max }: any) => (
<div>
Expand Down Expand Up @@ -393,6 +410,34 @@ describe("BlockRenderer", () => {
expect(screen.getByText("Cancel")).toBeTruthy();
});

it("disabled button with title wraps a tooltip and does not fire actions", () => {
const onAction = vi.fn();
renderBlocks(
[
{
type: "actions",
elements: [
{
type: "button",
action_id: "clear",
label: "Clear object cache",
disabled: true,
title: "Object Cache Not Configured",
},
],
},
],
onAction,
);
const btn = screen.getByText("Clear object cache") as HTMLButtonElement;
expect(btn.disabled).toBe(true);
expect(screen.getByTestId("tooltip").getAttribute("data-content")).toBe(
"Object Cache Not Configured",
);
fireEvent.click(btn);
expect(onAction).not.toHaveBeenCalled();
});

it("stats block renders stat cards with values", () => {
renderBlocks([
{
Expand Down
73 changes: 72 additions & 1 deletion packages/cloudflare/src/sandbox/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@
import type { D1Database } from "@cloudflare/workers-types";
import { WorkerEntrypoint } from "cloudflare:workers";
import type { SandboxEmailSendCallback } from "emdash";
import { ulid, PluginStorageRepository } from "emdash";
import {
handleObjectCachePurge,
handleObjectCacheStatus,
handleWorkersCachePurge,
handleWorkersCacheStatus,
ulid,
PluginStorageRepository,
} from "emdash";
import { Kysely } from "kysely";
import { D1Dialect } from "kysely-d1";

Expand Down Expand Up @@ -1173,6 +1180,70 @@ export class PluginBridge extends WorkerEntrypoint<PluginBridgeEnv, PluginBridge
await emailSendCallback(message, pluginId);
}

// =========================================================================
// Object cache — capability-gated (cache:purge)
// =========================================================================

async getObjectCacheStatus(): Promise<{ configured: boolean }> {
const { capabilities } = this.ctx.props;
if (!capabilities.includes("cache:purge")) {
throw new Error("Missing capability: cache:purge");
}
const result = await handleObjectCacheStatus();
if (!result.success) {
throw new Error(result.error.message);
}
return result.data;
}

async purgeObjectCache(options?: {
namespaces?: string[];
}): Promise<{ configured: boolean; active: boolean; purged: string[] }> {
const { capabilities } = this.ctx.props;
if (!capabilities.includes("cache:purge")) {
throw new Error("Missing capability: cache:purge");
}
const db = new Kysely<unknown>({
dialect: new D1Dialect({ database: this.env.DB }),
});
// eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- D1 dialect matches core handler db shape
const result = await handleObjectCachePurge(db as never, {
namespaces: options?.namespaces,
});
if (!result.success) {
throw new Error(result.error.message);
}
return result.data;
}

async getWorkersCacheStatus(): Promise<{ configured: boolean }> {
const { capabilities } = this.ctx.props;
if (!capabilities.includes("cache:purge")) {
throw new Error("Missing capability: cache:purge");
}
const result = await handleWorkersCacheStatus();
if (!result.success) {
throw new Error(result.error.message);
}
return result.data;
}

async purgeWorkersCache(options?: {
pathPrefixes?: string[];
}): Promise<{ configured: boolean; purged: boolean; pathPrefixes?: string[] }> {
const { capabilities } = this.ctx.props;
if (!capabilities.includes("cache:purge")) {
throw new Error("Missing capability: cache:purge");
}
const result = await handleWorkersCachePurge({
pathPrefixes: options?.pathPrefixes,
});
if (!result.success) {
throw new Error(result.error.message);
}
return result.data;
}

// =========================================================================
// Logging
// =========================================================================
Expand Down
9 changes: 9 additions & 0 deletions packages/cloudflare/src/sandbox/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,15 @@ export interface PluginBridgeBinding {
): Promise<{ status: number; headers: Record<string, string>; text: string }>;
// Email
emailSend(message: { to: string; subject: string; text: string; html?: string }): Promise<void>;
// Cache purge (gated on cache:purge)
getObjectCacheStatus(): Promise<{ configured: boolean }>;
purgeObjectCache(options?: {
namespaces?: string[];
}): Promise<{ configured: boolean; active: boolean; purged: string[] }>;
getWorkersCacheStatus(): Promise<{ configured: boolean }>;
purgeWorkersCache(options?: {
pathPrefixes?: string[];
}): Promise<{ configured: boolean; purged: boolean; pathPrefixes?: string[] }>;
// Logging
log(level: "debug" | "info" | "warn" | "error", msg: string, data?: unknown): void;
}
12 changes: 11 additions & 1 deletion packages/cloudflare/src/sandbox/wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export function generatePluginWrapper(manifest: PluginManifest, options?: Wrappe
const capabilities = normalizeCapabilities(manifest.capabilities ?? []);
const hasReadUsers = capabilities.includes("users:read");
const hasEmailSend = capabilities.includes("email:send");
const hasCachePurge = capabilities.includes("cache:purge");

return `
// =============================================================================
Expand Down Expand Up @@ -173,6 +174,14 @@ function createContext(env) {
const email = ${hasEmailSend} ? {
send: (message) => bridge.emailSend(message)
} : undefined;

// Cache purge - proxies to bridge (capability enforced by bridge)
const cache = ${hasCachePurge} ? {
getObjectCacheStatus: () => bridge.getObjectCacheStatus(),
purgeObjectCache: (options) => bridge.purgeObjectCache(options),
getWorkersCacheStatus: () => bridge.getWorkersCacheStatus(),
purgeWorkersCache: (options) => bridge.purgeWorkersCache(options)
} : undefined;

return {
plugin: {
Expand All @@ -189,7 +198,8 @@ function createContext(env) {
site,
url,
users,
email
email,
cache
};
}

Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/api/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,30 @@ export {
// Settings handlers
export { handleSettingsGet, handleSettingsUpdate } from "./settings.js";

// Object-cache purge
export {
FIXED_OBJECT_CACHE_NAMESPACES,
handleObjectCachePurge,
handleObjectCacheStatus,
resolveObjectCacheNamespaces,
type FixedObjectCacheNamespace,
type ObjectCachePurgeInput,
type ObjectCachePurgeResult,
type ObjectCacheStatus,
} from "./object-cache.js";

// Workers Cache (native Workers Caching) purge
export {
handleWorkersCachePurge,
handleWorkersCacheStatus,
normalizeWorkersCachePathPrefix,
resolveWorkersCachePurgeApi,
type WorkersCachePurgeApi,
type WorkersCachePurgeInput,
type WorkersCachePurgeResult,
type WorkersCacheStatus,
} from "./workers-cache.js";

// Taxonomy handlers
export {
handleTaxonomyList,
Expand Down
Loading
Loading