Skip to content
Merged
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
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