diff --git a/.changeset/object-cache-purge-api.md b/.changeset/object-cache-purge-api.md new file mode 100644 index 0000000000..b559bdfe1e --- /dev/null +++ b/.changeset/object-cache-purge-api.md @@ -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. diff --git a/packages/admin/src/lib/api/marketplace.ts b/packages/admin/src/lib/api/marketplace.ts index 1ff306a7ff..76a284b0fb 100644 --- a/packages/admin/src/lib/api/marketplace.ts +++ b/packages/admin/src/lib/api/marketplace.ts @@ -282,6 +282,7 @@ export const CAPABILITY_LABELS: Record = { "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) diff --git a/packages/admin/tests/lib/marketplace.test.ts b/packages/admin/tests/lib/marketplace.test.ts index e5a1d98031..b86d28705c 100644 --- a/packages/admin/tests/lib/marketplace.test.ts +++ b/packages/admin/tests/lib/marketplace.test.ts @@ -312,6 +312,7 @@ describe("CAPABILITY_LABELS", () => { "media:read", "media:write", "users:read", + "cache:purge", "network:request", "network:request:unrestricted", // Legacy aliases diff --git a/packages/blocks/src/builders.ts b/packages/blocks/src/builders.ts index ce71b2d027..7e75affb9a 100644 --- a/packages/blocks/src/builders.ts +++ b/packages/blocks/src/builders.ts @@ -246,6 +246,8 @@ function button( style?: "primary" | "danger" | "secondary"; value?: unknown; confirm?: ConfirmDialog; + disabled?: boolean; + title?: string; }, ): ButtonElement { return { @@ -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 }), }; } diff --git a/packages/blocks/src/elements/button.tsx b/packages/blocks/src/elements/button.tsx index 692f937b43..caf567d8ae 100644 --- a/packages/blocks/src/elements/button.tsx +++ b/packages/blocks/src/elements/button.tsx @@ -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"; @@ -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); @@ -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 + ); + + const withTooltip = hasTitle ? ( + + } + > + {button} + + + ) : ( + button + ); + return ( <> - - {element.confirm && ( + {withTooltip} + {element.confirm && !isDisabled && (

{element.confirm.title}

diff --git a/packages/blocks/src/types.ts b/packages/blocks/src/types.ts index 5105a9734b..9f136af1f3 100644 --- a/packages/blocks/src/types.ts +++ b/packages/blocks/src/types.ts @@ -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 { diff --git a/packages/blocks/src/validation.ts b/packages/blocks/src/validation.ts index 9f39163b74..4afe6aaaeb 100644 --- a/packages/blocks/src/validation.ts +++ b/packages/blocks/src/validation.ts @@ -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); } diff --git a/packages/blocks/tests/renderer.test.tsx b/packages/blocks/tests/renderer.test.tsx index 78897719cb..e22405b045 100644 --- a/packages/blocks/tests/renderer.test.tsx +++ b/packages/blocks/tests/renderer.test.tsx @@ -15,11 +15,28 @@ const CollapsibleContext = React.createContext<{ }>({}); vi.mock("@cloudflare/kumo", () => ({ - Button: ({ children, onClick, variant, type }: any) => ( - ), + TooltipProvider: ({ children }: any) => <>{children}, + Tooltip: ({ content, children, render: triggerRender }: any) => { + const trigger = triggerRender ?? ; + return ( +
+ {React.isValidElement(trigger) + ? React.cloneElement(trigger as React.ReactElement, {}, children) + : children} +
+ ); + }, Badge: ({ children }: any) => {children}, Input: ({ label, value, defaultValue, onChange, onBlur, placeholder, type, min, max }: any) => (
@@ -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([ { diff --git a/packages/cloudflare/src/sandbox/bridge.ts b/packages/cloudflare/src/sandbox/bridge.ts index b169356e34..7c24c63e3e 100644 --- a/packages/cloudflare/src/sandbox/bridge.ts +++ b/packages/cloudflare/src/sandbox/bridge.ts @@ -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"; @@ -1173,6 +1180,70 @@ export class PluginBridge extends WorkerEntrypoint { + 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({ + 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 // ========================================================================= diff --git a/packages/cloudflare/src/sandbox/types.ts b/packages/cloudflare/src/sandbox/types.ts index 8ffe7b6616..74b481319f 100644 --- a/packages/cloudflare/src/sandbox/types.ts +++ b/packages/cloudflare/src/sandbox/types.ts @@ -212,6 +212,15 @@ export interface PluginBridgeBinding { ): Promise<{ status: number; headers: Record; text: string }>; // Email emailSend(message: { to: string; subject: string; text: string; html?: string }): Promise; + // 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; } diff --git a/packages/cloudflare/src/sandbox/wrapper.ts b/packages/cloudflare/src/sandbox/wrapper.ts index 320c3752e7..28132b17c5 100644 --- a/packages/cloudflare/src/sandbox/wrapper.ts +++ b/packages/cloudflare/src/sandbox/wrapper.ts @@ -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 ` // ============================================================================= @@ -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: { @@ -189,7 +198,8 @@ function createContext(env) { site, url, users, - email + email, + cache }; } diff --git a/packages/core/src/api/handlers/index.ts b/packages/core/src/api/handlers/index.ts index 2d0fadf48c..c1c502ce2b 100644 --- a/packages/core/src/api/handlers/index.ts +++ b/packages/core/src/api/handlers/index.ts @@ -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, diff --git a/packages/core/src/api/handlers/object-cache.ts b/packages/core/src/api/handlers/object-cache.ts new file mode 100644 index 0000000000..2dc9be2e54 --- /dev/null +++ b/packages/core/src/api/handlers/object-cache.ts @@ -0,0 +1,218 @@ +/** + * Object-cache purge handlers. + * + * Epoch-based invalidation for the optional distributed object cache + * (KV / memory). Safe when no object cache is configured — helpers no-op. + */ + +import type { Kysely } from "kysely"; + +import type { Database } from "../../database/types.js"; +import { + CacheNamespace, + contentNamespace, + invalidateBylineObjectCache, + invalidateCommentObjectCache, + invalidateMenuObjectCache, + invalidateObjectCache, + invalidateSchemaObjectCache, + invalidateTaxonomyObjectCache, + isObjectCacheConfigured, +} from "../../object-cache/index.js"; +import { SchemaRegistry } from "../../schema/registry.js"; +import { invalidateSiteSettingsCache } from "../../settings/index.js"; +import type { ApiResult } from "../types.js"; + +/** Fixed namespaces that are not collection-scoped content reads. */ +export const FIXED_OBJECT_CACHE_NAMESPACES = [ + CacheNamespace.SETTINGS, + CacheNamespace.MENUS, + CacheNamespace.TAXONOMIES, + CacheNamespace.BYLINES, + CacheNamespace.SCHEMA, + CacheNamespace.COMMENTS, +] as const; + +export type FixedObjectCacheNamespace = (typeof FIXED_OBJECT_CACHE_NAMESPACES)[number]; + +export interface ObjectCachePurgeInput { + /** + * Namespaces to invalidate. Omit or pass `["*"]` to purge every known + * namespace (fixed chrome + each content collection). + */ + namespaces?: string[]; +} + +export interface ObjectCacheStatus { + /** Whether an object-cache backend is configured for this site. */ + configured: boolean; +} + +export interface ObjectCachePurgeResult { + /** Whether an object-cache backend is configured for this site. */ + configured: boolean; + /** @deprecated Use {@link ObjectCachePurgeResult.configured}. */ + active: boolean; + /** Namespaces whose epochs were bumped. */ + purged: string[]; +} + +const ALL_SENTINEL = "*"; +const CONTENT_PREFIX = "content:"; +const COLLECTION_SLUG_RE = /^[a-z][a-z0-9_]*$/; + +function isFixedNamespace(value: string): value is FixedObjectCacheNamespace { + return (FIXED_OBJECT_CACHE_NAMESPACES as readonly string[]).includes(value); +} + +function purgeFixed(namespace: FixedObjectCacheNamespace): void { + switch (namespace) { + case CacheNamespace.SETTINGS: + // Also clears the isolate-local site-settings single-flight cache. + invalidateSiteSettingsCache(); + break; + case CacheNamespace.MENUS: + invalidateMenuObjectCache(); + break; + case CacheNamespace.TAXONOMIES: + invalidateTaxonomyObjectCache(); + break; + case CacheNamespace.BYLINES: + invalidateBylineObjectCache(); + break; + case CacheNamespace.SCHEMA: + invalidateSchemaObjectCache(); + break; + case CacheNamespace.COMMENTS: + invalidateCommentObjectCache(); + break; + } +} + +/** + * Resolve the set of namespaces to purge from the request body and DB. + */ +export async function resolveObjectCacheNamespaces( + db: Kysely, + input: ObjectCachePurgeInput = {}, +): Promise> { + const requested = input.namespaces; + const purgeAll = + requested === undefined || + requested.length === 0 || + requested.some((ns) => ns === ALL_SENTINEL); + + if (purgeAll) { + const collections = await new SchemaRegistry(db).listCollections(); + const contentNs = collections.map((c) => contentNamespace(c.slug)); + return { + success: true, + data: [...FIXED_OBJECT_CACHE_NAMESPACES, ...contentNs], + }; + } + + const resolved: string[] = []; + const seen = new Set(); + + for (const raw of requested) { + const ns = raw.trim(); + if (!ns || ns === ALL_SENTINEL) continue; + if (seen.has(ns)) continue; + + if (isFixedNamespace(ns)) { + seen.add(ns); + resolved.push(ns); + continue; + } + + if (ns.startsWith(CONTENT_PREFIX)) { + const collection = ns.slice(CONTENT_PREFIX.length); + if (!COLLECTION_SLUG_RE.test(collection)) { + return { + success: false, + error: { + code: "VALIDATION_ERROR", + message: `Invalid content namespace: ${ns}`, + }, + }; + } + seen.add(ns); + resolved.push(ns); + continue; + } + + // Bare collection slug → content:{slug} + if (COLLECTION_SLUG_RE.test(ns)) { + const full = contentNamespace(ns); + if (!seen.has(full)) { + seen.add(full); + resolved.push(full); + } + continue; + } + + return { + success: false, + error: { + code: "VALIDATION_ERROR", + message: `Unknown object-cache namespace: ${ns}`, + }, + }; + } + + return { success: true, data: resolved }; +} + +/** + * Bump epochs for the given namespaces (or every known namespace). + */ +export async function handleObjectCachePurge( + db: Kysely, + input: ObjectCachePurgeInput = {}, +): Promise> { + try { + const resolved = await resolveObjectCacheNamespaces(db, input); + if (!resolved.success) return resolved; + + const purged = resolved.data; + for (const ns of purged) { + if (isFixedNamespace(ns)) { + purgeFixed(ns); + } else { + invalidateObjectCache(ns); + } + } + + const configured = await isObjectCacheConfigured(); + return { + success: true, + data: { configured, active: configured, purged }, + }; + } catch { + return { + success: false, + error: { + code: "OBJECT_CACHE_PURGE_ERROR", + message: "Failed to purge object cache", + }, + }; + } +} + +/** + * Report whether an object-cache backend is configured. + */ +export async function handleObjectCacheStatus(): Promise> { + try { + const configured = await isObjectCacheConfigured(); + return { success: true, data: { configured } }; + } catch { + return { + success: false, + error: { + code: "OBJECT_CACHE_STATUS_ERROR", + message: "Failed to read object cache status", + }, + }; + } +} diff --git a/packages/core/src/api/handlers/workers-cache.ts b/packages/core/src/api/handlers/workers-cache.ts new file mode 100644 index 0000000000..8ee1b3a1bc --- /dev/null +++ b/packages/core/src/api/handlers/workers-cache.ts @@ -0,0 +1,243 @@ +/** + * Workers Cache (native Workers Caching) status + purge handlers. + * + * Uses `cache.purge()` from `cloudflare:workers` via the + * `virtual:emdash/workers-cache` module (same platform API as wrangler + * `"cache": { "enabled": true }` / Astro `cacheCloudflare()`). + * No zone ID or Cache Purge API token. + * + * Safe when the API is missing (Node, older runtimes, cache not enabled): + * status reports `configured: false` and purge is a no-op success. + */ + +import type { ApiResult } from "../types.js"; + +export interface WorkersCacheStatus { + /** Whether native `cache.purge` is available in this runtime. */ + configured: boolean; +} + +export interface WorkersCachePurgeInput { + /** + * Path prefixes to invalidate (e.g. `/posts/hello`). Omit or pass empty + * to purge everything for this Worker entrypoint. + */ + pathPrefixes?: string[]; +} + +export interface WorkersCachePurgeResult { + /** Whether native `cache.purge` was available. */ + configured: boolean; + /** True when purge ran and reported success. */ + purged: boolean; + /** Path prefixes that were purged (empty when purge-everything). */ + pathPrefixes?: string[]; +} + +export interface WorkersCachePurgeApi { + purge(options: { + purgeEverything?: boolean; + tags?: string[]; + pathPrefixes?: string[]; + }): Promise; +} + +/** Matches an absolute URL scheme prefix (RFC 3986, e.g. `https://`, `s3://`). */ +const ABSOLUTE_URL_RE = /^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//; + +/** + * Normalize a user-supplied path or full URL into a Workers Caching path prefix. + * Strips origin, query, and hash; ensures a leading slash. + */ +export function normalizeWorkersCachePathPrefix( + raw: string, +): { ok: true; path: string } | { ok: false; message: string } { + const trimmed = raw.trim(); + if (trimmed.startsWith("//")) { + return { ok: false, message: "Protocol-relative URLs are not allowed" }; + } + if (!trimmed) { + return { ok: false, message: "Path is required" }; + } + + let path: string; + if (ABSOLUTE_URL_RE.test(trimmed)) { + try { + const url = new URL(trimmed); + path = url.pathname || "/"; + } catch { + return { ok: false, message: "Invalid URL" }; + } + } else { + // Strip query/hash if pasted without a scheme + const withoutHash = trimmed.split("#")[0] ?? trimmed; + const withoutQuery = withoutHash.split("?")[0] ?? withoutHash; + path = withoutQuery; + } + + if (!path.startsWith("/")) { + path = `/${path}`; + } + + // Collapse duplicate slashes except we keep a single leading / + path = path.replace(/\/{2,}/g, "/"); + + if (path.length > 2048) { + return { ok: false, message: "Path is too long" }; + } + + return { ok: true, path }; +} + +/** + * Resolve the native Workers Caching purge API. + * Injected in tests; at runtime loads `virtual:emdash/workers-cache`. + */ +export async function resolveWorkersCachePurgeApi( + override?: WorkersCachePurgeApi | null, +): Promise { + if (override !== undefined) return override; + + try { + // @ts-ignore - virtual module, generated by the Astro integration + const mod = (await import("virtual:emdash/workers-cache")) as { + cache?: WorkersCachePurgeApi; + }; + const cache = mod.cache; + if (cache && typeof cache.purge === "function") { + return { + purge: (options) => cache.purge(options), + }; + } + } catch { + // Virtual module unavailable outside Vite/Astro + } + return null; +} + +/** + * Report whether native Workers Cache purge is available. + */ +export async function handleWorkersCacheStatus( + api?: WorkersCachePurgeApi | null, +): Promise> { + try { + const resolved = api === undefined ? await resolveWorkersCachePurgeApi() : api; + return { success: true, data: { configured: resolved !== null } }; + } catch { + return { + success: false, + error: { + code: "WORKERS_CACHE_STATUS_ERROR", + message: "Failed to read Workers Cache status", + }, + }; + } +} + +/** + * Purge Workers Caching for this entrypoint. + * With `pathPrefixes`, purges those prefixes; otherwise `purgeEverything`. + */ +export async function handleWorkersCachePurge( + input: WorkersCachePurgeInput = {}, + api?: WorkersCachePurgeApi | null, +): Promise> { + try { + const resolved = api === undefined ? await resolveWorkersCachePurgeApi() : api; + + if (!resolved) { + return { + success: true, + data: { configured: false, purged: false }, + }; + } + + const rawPrefixes = input.pathPrefixes ?? []; + const normalized: string[] = []; + const seen = new Set(); + + for (const raw of rawPrefixes) { + if (typeof raw !== "string") { + return { + success: false, + error: { + code: "VALIDATION_ERROR", + message: "Each path prefix must be a string", + }, + }; + } + const result = normalizeWorkersCachePathPrefix(raw); + if (!result.ok) { + return { + success: false, + error: { + code: "VALIDATION_ERROR", + message: result.message, + }, + }; + } + if (!seen.has(result.path)) { + seen.add(result.path); + normalized.push(result.path); + } + } + + const purgeOptions = + normalized.length > 0 ? { pathPrefixes: normalized } : { purgeEverything: true as const }; + + const result = await resolved.purge(purgeOptions); + + if (isPurgeFailure(result)) { + const detail = formatPurgeErrors(result); + return { + success: false, + error: { + code: "WORKERS_CACHE_PURGE_ERROR", + message: detail ? `Workers Cache purge failed: ${detail}` : "Workers Cache purge failed", + }, + }; + } + + return { + success: true, + data: { + configured: true, + purged: true, + ...(normalized.length > 0 ? { pathPrefixes: normalized } : {}), + }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + return { + success: false, + error: { + code: "WORKERS_CACHE_PURGE_ERROR", + message: `Failed to purge Workers Cache: ${message}`, + }, + }; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isPurgeFailure(value: unknown): boolean { + if (!isRecord(value) || typeof value.success !== "boolean") { + // Unknown shape — treat as success (older/no-result APIs) + return false; + } + return value.success === false; +} + +function formatPurgeErrors(value: unknown): string | undefined { + if (!isRecord(value) || !Array.isArray(value.errors)) return undefined; + const messages = value.errors + .map((error) => { + if (isRecord(error) && typeof error.message === "string") return error.message; + return String(error); + }) + .filter(Boolean); + return messages.length > 0 ? messages.join("; ") : undefined; +} diff --git a/packages/core/src/astro/integration/routes.ts b/packages/core/src/astro/integration/routes.ts index 93f0efdfa3..5845723432 100644 --- a/packages/core/src/astro/integration/routes.ts +++ b/packages/core/src/astro/integration/routes.ts @@ -350,6 +350,16 @@ export function injectCoreRoutes( entrypoint: resolveRoute("api/settings.ts"), }); + // Cache purge routes (object cache + native Workers Caching) + injectRoute({ + pattern: "/_emdash/api/admin/cache/object", + entrypoint: resolveRoute("api/admin/cache/object.ts"), + }); + injectRoute({ + pattern: "/_emdash/api/admin/cache/workers", + entrypoint: resolveRoute("api/admin/cache/workers.ts"), + }); + // Email settings route injectRoute({ pattern: "/_emdash/api/settings/email", diff --git a/packages/core/src/astro/integration/virtual-modules.ts b/packages/core/src/astro/integration/virtual-modules.ts index 4b26f3692f..b6b1352297 100644 --- a/packages/core/src/astro/integration/virtual-modules.ts +++ b/packages/core/src/astro/integration/virtual-modules.ts @@ -72,6 +72,9 @@ export const RESOLVED_VIRTUAL_SCHEDULER_ID = "\0" + VIRTUAL_SCHEDULER_ID; export const VIRTUAL_ENV_ID = "virtual:emdash/env"; export const RESOLVED_VIRTUAL_ENV_ID = "\0" + VIRTUAL_ENV_ID; +export const VIRTUAL_WORKERS_CACHE_ID = "virtual:emdash/workers-cache"; +export const RESOLVED_VIRTUAL_WORKERS_CACHE_ID = "\0" + VIRTUAL_WORKERS_CACHE_ID; + /** * Generates the config virtual module. */ @@ -497,6 +500,21 @@ export function generateEnvModule(adapterName: string | undefined): string { return `export const env = undefined;`; } +/** + * Generates the workers-cache virtual module. + * + * Under @astrojs/cloudflare, re-exports `cache` from `cloudflare:workers` so + * admin/plugin purge can call `cache.purge()` without a direct + * `cloudflare:workers` import in core (which fails under Node and can fail + * under Vite dynamic import). Other adapters get `undefined`. + */ +export function generateWorkersCacheModule(adapterName: string | undefined): string { + if (adapterName === "@astrojs/cloudflare") { + return `export { cache } from "cloudflare:workers";`; + } + return `export const cache = undefined;`; +} + /** * Generates the scheduler virtual module. * diff --git a/packages/core/src/astro/integration/vite-config.ts b/packages/core/src/astro/integration/vite-config.ts index 29266e6352..cc09774b4d 100644 --- a/packages/core/src/astro/integration/vite-config.ts +++ b/packages/core/src/astro/integration/vite-config.ts @@ -48,10 +48,13 @@ import { RESOLVED_VIRTUAL_SCHEDULER_ID, VIRTUAL_ENV_ID, RESOLVED_VIRTUAL_ENV_ID, + VIRTUAL_WORKERS_CACHE_ID, + RESOLVED_VIRTUAL_WORKERS_CACHE_ID, generateSeedModule, generateWaitUntilModule, generateSchedulerModule, generateEnvModule, + generateWorkersCacheModule, generateConfigModule, generateDialectModule, generateStorageModule, @@ -233,6 +236,9 @@ export function createVirtualModulesPlugin( if (id === VIRTUAL_ENV_ID) { return RESOLVED_VIRTUAL_ENV_ID; } + if (id === VIRTUAL_WORKERS_CACHE_ID) { + return RESOLVED_VIRTUAL_WORKERS_CACHE_ID; + } }, load(id: string) { if (id === RESOLVED_VIRTUAL_CONFIG_ID) { @@ -333,6 +339,11 @@ export function createVirtualModulesPlugin( if (id === RESOLVED_VIRTUAL_ENV_ID) { return generateEnvModule(astroConfig.adapter?.name); } + // Generate workers-cache module — re-exports cloudflare:workers' + // cache under the Cloudflare adapter for native cache.purge(). + if (id === RESOLVED_VIRTUAL_WORKERS_CACHE_ID) { + return generateWorkersCacheModule(astroConfig.adapter?.name); + } }, }; } diff --git a/packages/core/src/astro/routes/api/admin/cache/object.ts b/packages/core/src/astro/routes/api/admin/cache/object.ts new file mode 100644 index 0000000000..70cb1d0a09 --- /dev/null +++ b/packages/core/src/astro/routes/api/admin/cache/object.ts @@ -0,0 +1,50 @@ +/** + * Object-cache status + purge endpoint + * + * GET /_emdash/api/admin/cache/object — whether a backend is configured + * POST /_emdash/api/admin/cache/object — bump epochs for CMS object-cache namespaces + */ + +import type { APIRoute } from "astro"; +import { z } from "zod"; + +import { requirePerm } from "#api/authorize.js"; +import { apiError } from "#api/error.js"; +import { handleObjectCachePurge, handleObjectCacheStatus } from "#api/handlers/object-cache.js"; +import { unwrapResult } from "#api/index.js"; +import { isParseError, parseBody } from "#api/parse.js"; + +export const prerender = false; + +const purgeBody = z + .object({ + namespaces: z.array(z.string().min(1).max(128)).max(200).optional(), + }) + .default({}); + +export const GET: APIRoute = async ({ locals }) => { + const { user } = locals; + + const denied = requirePerm(user, "settings:manage"); + if (denied) return denied; + + const result = await handleObjectCacheStatus(); + return unwrapResult(result); +}; + +export const POST: APIRoute = async ({ request, locals }) => { + const { emdash, user } = locals; + + if (!emdash?.db) { + return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); + } + + const denied = requirePerm(user, "settings:manage"); + if (denied) return denied; + + const body = await parseBody(request, purgeBody); + if (isParseError(body)) return body; + + const result = await handleObjectCachePurge(emdash.db, body); + return unwrapResult(result); +}; diff --git a/packages/core/src/astro/routes/api/admin/cache/workers.ts b/packages/core/src/astro/routes/api/admin/cache/workers.ts new file mode 100644 index 0000000000..fc364d253d --- /dev/null +++ b/packages/core/src/astro/routes/api/admin/cache/workers.ts @@ -0,0 +1,45 @@ +/** + * Workers Cache status + purge endpoint + * + * GET /_emdash/api/admin/cache/workers — whether native purge is available + * POST /_emdash/api/admin/cache/workers — purgeEverything or pathPrefixes + */ + +import type { APIRoute } from "astro"; +import { z } from "zod"; + +import { requirePerm } from "#api/authorize.js"; +import { handleWorkersCachePurge, handleWorkersCacheStatus } from "#api/handlers/workers-cache.js"; +import { unwrapResult } from "#api/index.js"; +import { isParseError, parseOptionalBody } from "#api/parse.js"; + +export const prerender = false; + +const purgeBody = z.object({ + pathPrefixes: z.array(z.string().min(1).max(2048)).max(50).optional(), +}); + +export const GET: APIRoute = async ({ locals }) => { + const { user } = locals; + + const denied = requirePerm(user, "settings:manage"); + if (denied) return denied; + + const result = await handleWorkersCacheStatus(); + return unwrapResult(result); +}; + +export const POST: APIRoute = async ({ request, locals }) => { + const { user } = locals; + + const denied = requirePerm(user, "settings:manage"); + if (denied) return denied; + + const body = await parseOptionalBody(request, purgeBody, {}); + if (isParseError(body)) return body; + + const result = await handleWorkersCachePurge({ + pathPrefixes: body?.pathPrefixes, + }); + return unwrapResult(result); +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 488fe2789f..2d466e419f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -73,6 +73,10 @@ export { handleRevisionList, handleRevisionGet, handleRevisionRestore, + handleObjectCachePurge, + handleObjectCacheStatus, + handleWorkersCachePurge, + handleWorkersCacheStatus, generateManifest, } from "./api/index.js"; export type { @@ -202,6 +206,8 @@ export { invalidateMenuObjectCache, invalidateSchemaObjectCache, invalidateCommentObjectCache, + isObjectCacheActive, + isObjectCacheConfigured, contentNamespace, contentNamespaces, CacheNamespace, @@ -247,6 +253,7 @@ export type { MediaAccess, HttpAccess, LogAccess, + CacheAccess, TaxonomyAccess, TaxonomyDefInfo, TaxonomyTermInfo, diff --git a/packages/core/src/object-cache/index.ts b/packages/core/src/object-cache/index.ts index f025a8ea42..3c66cd7d23 100644 --- a/packages/core/src/object-cache/index.ts +++ b/packages/core/src/object-cache/index.ts @@ -463,10 +463,15 @@ export async function cachedQuery(options: CachedQueryOptions): Promise return value; } +/** Whether an object-cache backend is configured for this site. */ +export async function isObjectCacheConfigured(): Promise { + const backend = await getBackend(); + return backend !== null; +} + /** Whether object-cache reads are active for the current request. */ export async function isObjectCacheActive(): Promise { - const backend = await getBackend(); - return backend !== null && !shouldBypass(); + return (await isObjectCacheConfigured()) && !shouldBypass(); } /** diff --git a/packages/core/src/plugins/context.ts b/packages/core/src/plugins/context.ts index 233a6e16a5..863df93d6d 100644 --- a/packages/core/src/plugins/context.ts +++ b/packages/core/src/plugins/context.ts @@ -8,6 +8,11 @@ import type { Kysely } from "kysely"; import { ulid } from "ulidx"; +import { handleObjectCachePurge, handleObjectCacheStatus } from "../api/handlers/object-cache.js"; +import { + handleWorkersCachePurge, + handleWorkersCacheStatus, +} from "../api/handlers/workers-cache.js"; import { ContentRepository } from "../database/repositories/content.js"; import { MediaRepository } from "../database/repositories/media.js"; import { OptionsRepository } from "../database/repositories/options.js"; @@ -36,6 +41,7 @@ import type { KVAccess, CronAccess, EmailAccess, + CacheAccess, ContentAccess, ContentAccessWithWrite, MediaAccess, @@ -945,6 +951,46 @@ function toUserInfo(user: { }; } +/** + * Create cache purge access for plugins with `cache:purge`. + */ +export function createCacheAccess(db: Kysely): CacheAccess { + return { + async getObjectCacheStatus() { + const result = await handleObjectCacheStatus(); + if (!result.success) { + throw new Error(result.error.message); + } + return result.data; + }, + async purgeObjectCache(options) { + const result = await handleObjectCachePurge(db, { + namespaces: options?.namespaces, + }); + if (!result.success) { + throw new Error(result.error.message); + } + return result.data; + }, + async getWorkersCacheStatus() { + const result = await handleWorkersCacheStatus(); + if (!result.success) { + throw new Error(result.error.message); + } + return result.data; + }, + async purgeWorkersCache(options) { + const result = await handleWorkersCachePurge({ + pathPrefixes: options?.pathPrefixes, + }); + if (!result.success) { + throw new Error(result.error.message); + } + return result.data; + }, + }; +} + /** * Create read-only user access for plugins. * Excludes sensitive fields (password hashes, sessions, passkeys, avatar URL, data). @@ -1161,6 +1207,12 @@ export class PluginContextFactory { }; } + // Object-cache purge — requires cache:purge + let cache: CacheAccess | undefined; + if (capabilities.has("cache:purge")) { + cache = createCacheAccess(db); + } + return { plugin: { id: plugin.id, @@ -1178,6 +1230,7 @@ export class PluginContextFactory { users, cron, email, + cache, }; } } diff --git a/packages/core/src/plugins/index.ts b/packages/core/src/plugins/index.ts index 75b5879a4d..948ca06c24 100644 --- a/packages/core/src/plugins/index.ts +++ b/packages/core/src/plugins/index.ts @@ -36,6 +36,7 @@ export { createBlockedHttpAccess, createLogAccess, createUserAccess, + createCacheAccess, createUrlHelper, createSiteInfo, } from "./context.js"; @@ -140,6 +141,9 @@ export type { LifecycleEvent, UninstallEvent, + // Cache types + CacheAccess, + // Email types EmailAccess, EmailMessage, @@ -208,4 +212,4 @@ export { normalizeCapability, normalizeCapabilities, } from "./types.js"; -export type { CurrentPluginCapability, DeprecatedPluginCapability } from "./types.js"; +export type { CacheAccess, CurrentPluginCapability, DeprecatedPluginCapability } from "./types.js"; diff --git a/packages/core/src/plugins/manifest-schema.ts b/packages/core/src/plugins/manifest-schema.ts index 7bba3253f8..479439e5ce 100644 --- a/packages/core/src/plugins/manifest-schema.ts +++ b/packages/core/src/plugins/manifest-schema.ts @@ -32,6 +32,7 @@ export const CURRENT_PLUGIN_CAPABILITIES = [ "media:read", "media:write", "users:read", + "cache:purge", "email:send", "hooks.email-transport:register", "hooks.email-events:register", @@ -291,6 +292,7 @@ const declaredAccessSchema = z.object({ .optional(), page: z.object({ fragments: accessConstraints.optional() }).optional(), users: z.object({ read: accessConstraints.optional() }).optional(), + cache: z.object({ purge: accessConstraints.optional() }).optional(), }); // ── Main schema ───────────────────────────────────────────────── diff --git a/packages/core/src/plugins/types.ts b/packages/core/src/plugins/types.ts index 7e3ad2bd9f..e002bb0dce 100644 --- a/packages/core/src/plugins/types.ts +++ b/packages/core/src/plugins/types.ts @@ -510,6 +510,50 @@ export interface PluginContext; + + /** + * Purge object-cache namespaces. + * Omit `namespaces` (or pass `["*"]`) to purge every known namespace. + */ + purgeObjectCache(options?: { namespaces?: string[] }): Promise<{ + configured: boolean; + /** @deprecated Use {@link configured}. */ + active: boolean; + purged: string[]; + }>; + + /** + * Whether native Workers Caching purge is available + * (`cache.purge` from `cloudflare:workers`, with wrangler + * `"cache": { "enabled": true }`). + */ + getWorkersCacheStatus(): Promise<{ configured: boolean }>; + + /** + * Purge Workers Caching for this Worker. + * Omit options (or empty pathPrefixes) for purgeEverything; pass + * pathPrefixes to invalidate matching path prefixes only. + * Returns `configured: false` when native purge is unavailable. + */ + purgeWorkersCache(options?: { pathPrefixes?: string[] }): Promise<{ + configured: boolean; + purged: boolean; + pathPrefixes?: string[]; + }>; } // ============================================================================= diff --git a/packages/core/src/virtual-modules.d.ts b/packages/core/src/virtual-modules.d.ts index 9ce6519842..b853d4125c 100644 --- a/packages/core/src/virtual-modules.d.ts +++ b/packages/core/src/virtual-modules.d.ts @@ -170,6 +170,23 @@ declare module "virtual:emdash/env" { export const env: Record | undefined; } +declare module "virtual:emdash/workers-cache" { + /** + * Native Workers Caching API (`cache.purge`). Resolves to Cloudflare's + * `cache` export under @astrojs/cloudflare when available; `undefined` + * on Node or older runtimes without Workers Caching. + */ + export const cache: + | { + purge(options: { + purgeEverything?: boolean; + tags?: string[]; + pathPrefixes?: string[]; + }): Promise<{ success: boolean; errors: { code?: number; message: string }[] }>; + } + | undefined; +} + declare module "virtual:emdash/scheduler" { import type { CreateSchedulerFn } from "./emdash-runtime.js"; /** diff --git a/packages/core/tests/unit/api/object-cache-handlers.test.ts b/packages/core/tests/unit/api/object-cache-handlers.test.ts new file mode 100644 index 0000000000..aacaec05df --- /dev/null +++ b/packages/core/tests/unit/api/object-cache-handlers.test.ts @@ -0,0 +1,98 @@ +/** + * Object-cache purge handlers. + */ + +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + FIXED_OBJECT_CACHE_NAMESPACES, + handleObjectCachePurge, + resolveObjectCacheNamespaces, +} from "../../../src/api/handlers/object-cache.js"; +import type { Database } from "../../../src/database/types.js"; +import * as objectCache from "../../../src/object-cache/index.js"; +import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; + +describe("resolveObjectCacheNamespaces", () => { + let db: Kysely; + + beforeEach(async () => { + db = await setupTestDatabase(); + }); + + afterEach(async () => { + await teardownTestDatabase(db); + }); + + it("expands * to fixed namespaces when no collections exist", async () => { + const result = await resolveObjectCacheNamespaces(db, { namespaces: ["*"] }); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data).toEqual([...FIXED_OBJECT_CACHE_NAMESPACES]); + }); + + it("accepts fixed namespace names", async () => { + const result = await resolveObjectCacheNamespaces(db, { + namespaces: ["menus", "settings"], + }); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data).toEqual(["menus", "settings"]); + }); + + it("maps bare collection slugs to content: namespaces", async () => { + const result = await resolveObjectCacheNamespaces(db, { + namespaces: ["posts"], + }); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data).toEqual(["content:posts"]); + }); + + it("rejects unknown namespaces", async () => { + const result = await resolveObjectCacheNamespaces(db, { + namespaces: ["not-a-real-namespace!!"], + }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.code).toBe("VALIDATION_ERROR"); + }); +}); + +describe("handleObjectCachePurge", () => { + let db: Kysely; + + beforeEach(async () => { + db = await setupTestDatabase(); + }); + + afterEach(async () => { + await teardownTestDatabase(db); + vi.restoreAllMocks(); + }); + + it("bumps epochs for requested namespaces", async () => { + const invalidate = vi.spyOn(objectCache, "invalidateObjectCache"); + const menus = vi.spyOn(objectCache, "invalidateMenuObjectCache"); + + const result = await handleObjectCachePurge(db, { + namespaces: ["menus", "content:posts"], + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.purged).toEqual(["menus", "content:posts"]); + expect(menus).toHaveBeenCalledOnce(); + expect(invalidate).toHaveBeenCalledWith("content:posts"); + }); + + it("returns configured:false when no backend is configured", async () => { + const result = await handleObjectCachePurge(db, { namespaces: ["settings"] }); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.configured).toBe(false); + expect(result.data.active).toBe(false); + expect(result.data.purged).toContain("settings"); + }); +}); diff --git a/packages/core/tests/unit/api/object-cache-route.test.ts b/packages/core/tests/unit/api/object-cache-route.test.ts new file mode 100644 index 0000000000..da430ce299 --- /dev/null +++ b/packages/core/tests/unit/api/object-cache-route.test.ts @@ -0,0 +1,100 @@ +/** + * Object-cache purge route: registration and authorization. + */ + +import { Role } from "@emdash-cms/auth"; +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { injectCoreRoutes } from "../../../src/astro/integration/routes.js"; +import { + GET as statusGet, + POST as purgePost, +} from "../../../src/astro/routes/api/admin/cache/object.js"; +import type { Database } from "../../../src/database/types.js"; +import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; + +describe("object-cache purge route registration", () => { + it("registers /_emdash/api/admin/cache/object", () => { + const injectRoute = vi.fn(); + injectCoreRoutes(injectRoute); + const patterns = injectRoute.mock.calls.map((call) => (call[0] as { pattern: string }).pattern); + expect(patterns).toContain("/_emdash/api/admin/cache/object"); + }); +}); + +describe("object-cache purge route", () => { + let db: Kysely; + + beforeEach(async () => { + db = await setupTestDatabase(); + }); + + afterEach(async () => { + await teardownTestDatabase(db); + }); + + const makePostContext = (user: { id: string; role: number } | null, body?: unknown) => + ({ + request: new Request("http://localhost/_emdash/api/admin/cache/object", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-EmDash-Request": "1", + }, + body: JSON.stringify(body ?? {}), + }), + locals: { + emdash: { db }, + user, + }, + }) as unknown as Parameters[0]; + + const makeGetContext = (user: { id: string; role: number } | null) => + ({ + request: new Request("http://localhost/_emdash/api/admin/cache/object"), + locals: { + emdash: { db }, + user, + }, + }) as unknown as Parameters[0]; + + it("GET returns 401 for anonymous users", async () => { + const response = await statusGet(makeGetContext(null)); + expect(response.status).toBe(401); + }); + + it("GET returns configured status for admins", async () => { + const response = await statusGet(makeGetContext({ id: "u1", role: Role.ADMIN })); + expect(response.status).toBe(200); + const json = (await response.json()) as { + success: boolean; + data: { configured: boolean }; + }; + expect(json.success).toBe(true); + expect(json.data.configured).toBe(false); + }); + + it("POST returns 401 for anonymous users", async () => { + const response = await purgePost(makePostContext(null)); + expect(response.status).toBe(401); + }); + + it("POST returns 403 for editors (settings:manage is admin-only)", async () => { + const response = await purgePost(makePostContext({ id: "u1", role: Role.EDITOR })); + expect(response.status).toBe(403); + }); + + it("POST purges for admins", async () => { + const response = await purgePost( + makePostContext({ id: "u1", role: Role.ADMIN }, { namespaces: ["menus"] }), + ); + expect(response.status).toBe(200); + const json = (await response.json()) as { + success: boolean; + data: { configured: boolean; purged: string[] }; + }; + expect(json.success).toBe(true); + expect(json.data.purged).toEqual(["menus"]); + }); +}); diff --git a/packages/core/tests/unit/api/workers-cache-handlers.test.ts b/packages/core/tests/unit/api/workers-cache-handlers.test.ts new file mode 100644 index 0000000000..7c46ca838f --- /dev/null +++ b/packages/core/tests/unit/api/workers-cache-handlers.test.ts @@ -0,0 +1,131 @@ +/** + * Workers Cache purge handlers (native cache.purge). + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + handleWorkersCachePurge, + handleWorkersCacheStatus, + normalizeWorkersCachePathPrefix, + type WorkersCachePurgeApi, +} from "../../../src/api/handlers/workers-cache.js"; + +describe("normalizeWorkersCachePathPrefix", () => { + it("normalizes bare paths", () => { + expect(normalizeWorkersCachePathPrefix("/posts/foo")).toEqual({ + ok: true, + path: "/posts/foo", + }); + expect(normalizeWorkersCachePathPrefix("posts/foo")).toEqual({ + ok: true, + path: "/posts/foo", + }); + }); + + it("strips origin, query, and hash from full URLs", () => { + expect(normalizeWorkersCachePathPrefix("https://example.com/posts/foo?x=1#hash")).toEqual({ + ok: true, + path: "/posts/foo", + }); + }); + + it("treats digit-containing schemes as absolute URLs", () => { + expect(normalizeWorkersCachePathPrefix("s3://bucket/object")).toEqual({ + ok: true, + path: "/object", + }); + }); + + it("rejects empty input", () => { + expect(normalizeWorkersCachePathPrefix(" ")).toEqual({ + ok: false, + message: "Path is required", + }); + }); + + it("rejects protocol-relative URLs", () => { + expect(normalizeWorkersCachePathPrefix("//example.com/posts")).toEqual({ + ok: false, + message: "Protocol-relative URLs are not allowed", + }); + }); +}); + +describe("handleWorkersCacheStatus", () => { + it("reports configured:false when purge API is null", async () => { + const result = await handleWorkersCacheStatus(null); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.configured).toBe(false); + }); + + it("reports configured:true when purge API is provided", async () => { + const api: WorkersCachePurgeApi = { + purge: vi.fn(), + }; + const result = await handleWorkersCacheStatus(api); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.configured).toBe(true); + }); +}); + +describe("handleWorkersCachePurge", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns configured:false without calling purge when API missing", async () => { + const result = await handleWorkersCachePurge({}, null); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data).toEqual({ configured: false, purged: false }); + }); + + it("calls purgeEverything when no paths are given", async () => { + const purge = vi.fn().mockResolvedValue({ success: true, errors: [] }); + const result = await handleWorkersCachePurge({}, { purge }); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data).toEqual({ configured: true, purged: true }); + expect(purge).toHaveBeenCalledWith({ purgeEverything: true }); + }); + + it("calls pathPrefixes when paths are given", async () => { + const purge = vi.fn().mockResolvedValue({ success: true, errors: [] }); + const result = await handleWorkersCachePurge( + { pathPrefixes: ["https://example.com/posts/a", "/posts/b"] }, + { purge }, + ); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data).toEqual({ + configured: true, + purged: true, + pathPrefixes: ["/posts/a", "/posts/b"], + }); + expect(purge).toHaveBeenCalledWith({ pathPrefixes: ["/posts/a", "/posts/b"] }); + }); + + it("rejects invalid path input", async () => { + const purge = vi.fn(); + const result = await handleWorkersCachePurge({ pathPrefixes: [" "] }, { purge }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.code).toBe("VALIDATION_ERROR"); + expect(purge).not.toHaveBeenCalled(); + }); + + it("surfaces purge API failure results", async () => { + const purge = vi.fn().mockResolvedValue({ + success: false, + errors: [{ message: "rate limited" }], + }); + const result = await handleWorkersCachePurge({}, { purge }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.code).toBe("WORKERS_CACHE_PURGE_ERROR"); + expect(result.error.message).toContain("rate limited"); + }); +}); diff --git a/packages/core/tests/unit/api/workers-cache-route.test.ts b/packages/core/tests/unit/api/workers-cache-route.test.ts new file mode 100644 index 0000000000..2d034e5b1d --- /dev/null +++ b/packages/core/tests/unit/api/workers-cache-route.test.ts @@ -0,0 +1,83 @@ +/** + * Workers Cache admin route: registration and authorization. + */ + +import { Role } from "@emdash-cms/auth"; +import { describe, expect, it, vi } from "vitest"; + +import { injectCoreRoutes } from "../../../src/astro/integration/routes.js"; +import { + GET as statusGet, + POST as purgePost, +} from "../../../src/astro/routes/api/admin/cache/workers.js"; + +describe("workers-cache purge route registration", () => { + it("registers /_emdash/api/admin/cache/workers", () => { + const injectRoute = vi.fn(); + injectCoreRoutes(injectRoute); + const patterns = injectRoute.mock.calls.map((call) => (call[0] as { pattern: string }).pattern); + expect(patterns).toContain("/_emdash/api/admin/cache/workers"); + }); +}); + +describe("workers-cache purge route", () => { + const makeContext = (user: { id: string; role: number } | null) => + ({ + request: new Request("http://localhost/_emdash/api/admin/cache/workers", { + method: "GET", + headers: { "X-EmDash-Request": "1" }, + }), + locals: { + user, + }, + }) as unknown as Parameters[0]; + + const makePostContext = (user: { id: string; role: number } | null) => + ({ + request: new Request("http://localhost/_emdash/api/admin/cache/workers", { + method: "POST", + headers: { "X-EmDash-Request": "1" }, + }), + locals: { + user, + }, + }) as unknown as Parameters[0]; + + it("GET returns 401 for anonymous users", async () => { + const response = await statusGet(makeContext(null)); + expect(response.status).toBe(401); + }); + + it("GET returns configured status for admins", async () => { + const response = await statusGet(makeContext({ id: "u1", role: Role.ADMIN })); + expect(response.status).toBe(200); + const json = (await response.json()) as { + success: boolean; + data: { configured: boolean }; + }; + expect(json.success).toBe(true); + // Unit tests run outside Workers — native purge unavailable + expect(json.data.configured).toBe(false); + }); + + it("POST returns 401 for anonymous users", async () => { + const response = await purgePost(makePostContext(null)); + expect(response.status).toBe(401); + }); + + it("POST returns 403 for editors (settings:manage is admin-only)", async () => { + const response = await purgePost(makePostContext({ id: "u1", role: Role.EDITOR })); + expect(response.status).toBe(403); + }); + + it("POST returns configured:false for admins outside Workers", async () => { + const response = await purgePost(makePostContext({ id: "u1", role: Role.ADMIN })); + expect(response.status).toBe(200); + const json = (await response.json()) as { + success: boolean; + data: { configured: boolean; purged: boolean }; + }; + expect(json.success).toBe(true); + expect(json.data).toEqual({ configured: false, purged: false }); + }); +}); diff --git a/packages/core/tests/unit/astro/integration/virtual-modules.test.ts b/packages/core/tests/unit/astro/integration/virtual-modules.test.ts index 79b17db6d2..c8f8c6d206 100644 --- a/packages/core/tests/unit/astro/integration/virtual-modules.test.ts +++ b/packages/core/tests/unit/astro/integration/virtual-modules.test.ts @@ -13,6 +13,7 @@ import { generateEnvModule, generateSchedulerModule, generateSeedModule, + generateWorkersCacheModule, RESOLVED_VIRTUAL_SANDBOXED_PLUGINS_ID, RESOLVED_VIRTUAL_SCHEDULER_ID, } from "../../../../src/astro/integration/virtual-modules.js"; @@ -138,6 +139,19 @@ describe("generateEnvModule", () => { }); }); +describe("generateWorkersCacheModule", () => { + it("re-exports cloudflare:workers' cache under the Cloudflare adapter", () => { + const out = generateWorkersCacheModule("@astrojs/cloudflare"); + expect(out).toBe('export { cache } from "cloudflare:workers";'); + }); + + it("exports undefined for non-Cloudflare adapters", () => { + const out = generateWorkersCacheModule("@astrojs/node"); + expect(out).toBe("export const cache = undefined;"); + expect(out).not.toContain("cloudflare:workers"); + }); +}); + describe("createVirtualModulesPlugin scheduler wiring", () => { // Invoke a Vite plugin hook that may be a function or { handler } object. function callHook(hook: unknown, ...args: unknown[]): T { diff --git a/packages/plugin-cli/src/manifest/schema.ts b/packages/plugin-cli/src/manifest/schema.ts index 5bb08959e2..cd85e7f7e7 100644 --- a/packages/plugin-cli/src/manifest/schema.ts +++ b/packages/plugin-cli/src/manifest/schema.ts @@ -350,6 +350,7 @@ const CURRENT_CAPABILITIES = new Set([ "media:read", "media:write", "users:read", + "cache:purge", "email:send", "hooks.email-transport:register", "hooks.email-events:register", diff --git a/packages/plugin-types/src/declared-access.ts b/packages/plugin-types/src/declared-access.ts index 31a5f0888b..92055f264f 100644 --- a/packages/plugin-types/src/declared-access.ts +++ b/packages/plugin-types/src/declared-access.ts @@ -29,6 +29,7 @@ export interface CanonicalDeclaredAccess { }>; readonly page?: Readonly<{ fragments?: CanonicalAccessConstraints }>; readonly users?: Readonly<{ read?: CanonicalAccessConstraints }>; + readonly cache?: Readonly<{ purge?: CanonicalAccessConstraints }>; } export type AccessChangeKind = diff --git a/packages/plugin-types/src/index.ts b/packages/plugin-types/src/index.ts index 04adde7f38..3a2928d95d 100644 --- a/packages/plugin-types/src/index.ts +++ b/packages/plugin-types/src/index.ts @@ -55,6 +55,8 @@ export type PluginCapability = | "media:write" // Users | "users:read" + // Cache — purge CMS object-cache namespaces (KV / memory) + | "cache:purge" // Email | "email:send" // Hook registration @@ -189,6 +191,7 @@ export interface DeclaredAccess { email?: { send?: AccessConstraints; events?: AccessConstraints; transport?: AccessConstraints }; page?: { fragments?: AccessConstraints }; users?: { read?: AccessConstraints }; + cache?: { purge?: AccessConstraints }; } /** @@ -236,6 +239,7 @@ export function capabilitiesToDeclaredAccess( if (caps.has("hooks.email-transport:register")) (out.email ??= {}).transport = {}; if (caps.has("hooks.page-fragments:register")) out.page = { fragments: {} }; if (caps.has("users:read")) out.users = { read: {} }; + if (caps.has("cache:purge")) out.cache = { purge: {} }; return out; } @@ -284,6 +288,7 @@ export function declaredAccessToCapabilities(declaredAccess: DeclaredAccess): { if (declaredAccess.email?.transport) caps.add("hooks.email-transport:register"); if (declaredAccess.page?.fragments) caps.add("hooks.page-fragments:register"); if (declaredAccess.users?.read) caps.add("users:read"); + if (declaredAccess.cache?.purge) caps.add("cache:purge"); return { capabilities: [...caps], allowedHosts }; } diff --git a/packages/plugin-types/src/manifest-schema.ts b/packages/plugin-types/src/manifest-schema.ts index e7e6a49e1a..55cfe8c469 100644 --- a/packages/plugin-types/src/manifest-schema.ts +++ b/packages/plugin-types/src/manifest-schema.ts @@ -28,6 +28,7 @@ export const CURRENT_PLUGIN_CAPABILITIES = [ "media:read", "media:write", "users:read", + "cache:purge", "email:send", "hooks.email-transport:register", "hooks.email-events:register", @@ -266,6 +267,7 @@ const declaredAccessSchema = z.object({ .optional(), page: z.object({ fragments: accessConstraints.optional() }).optional(), users: z.object({ read: accessConstraints.optional() }).optional(), + cache: z.object({ purge: accessConstraints.optional() }).optional(), }); // ── Main schema ───────────────────────────────────────────────── diff --git a/packages/workerd/src/sandbox/bridge-handler.ts b/packages/workerd/src/sandbox/bridge-handler.ts index 9562529985..fe1098dde8 100644 --- a/packages/workerd/src/sandbox/bridge-handler.ts +++ b/packages/workerd/src/sandbox/bridge-handler.ts @@ -14,7 +14,15 @@ * must produce same outputs, same return shapes, same error messages. */ -import { createHttpAccess, createUnrestrictedHttpAccess, PluginStorageRepository } from "emdash"; +import { + createHttpAccess, + createUnrestrictedHttpAccess, + handleObjectCachePurge, + handleObjectCacheStatus, + handleWorkersCachePurge, + handleWorkersCacheStatus, + PluginStorageRepository, +} from "emdash"; import type { Database, SandboxEmailSendCallback } from "emdash"; import { sql, type Kysely, type RawBuilder } from "kysely"; @@ -256,6 +264,50 @@ async function dispatch( return null; } + // ── Cache purge ───────────────────────────────────────────────── + case "cache/getObjectCacheStatus": { + requireCapability(opts, "cache:purge"); + const status = await handleObjectCacheStatus(); + if (!status.success) { + throw new Error(status.error.message); + } + return status.data; + } + case "cache/purgeObjectCache": { + requireCapability(opts, "cache:purge"); + const namespaces = body.namespaces; + const result = await handleObjectCachePurge(db, { + namespaces: Array.isArray(namespaces) + ? namespaces.filter((n): n is string => typeof n === "string") + : undefined, + }); + if (!result.success) { + throw new Error(result.error.message); + } + return result.data; + } + case "cache/getWorkersCacheStatus": { + requireCapability(opts, "cache:purge"); + const status = await handleWorkersCacheStatus(); + if (!status.success) { + throw new Error(status.error.message); + } + return status.data; + } + case "cache/purgeWorkersCache": { + requireCapability(opts, "cache:purge"); + const pathPrefixes = body.pathPrefixes; + const result = await handleWorkersCachePurge({ + pathPrefixes: Array.isArray(pathPrefixes) + ? pathPrefixes.filter((p): p is string => typeof p === "string") + : undefined, + }); + if (!result.success) { + throw new Error(result.error.message); + } + return result.data; + } + // ── Users ─────────────────────────────────────────────────────── case "users/get": requireCapability(opts, "read:users"); diff --git a/packages/workerd/src/sandbox/wrapper.ts b/packages/workerd/src/sandbox/wrapper.ts index b45be3fde6..f96c561637 100644 --- a/packages/workerd/src/sandbox/wrapper.ts +++ b/packages/workerd/src/sandbox/wrapper.ts @@ -38,8 +38,10 @@ export interface WrapperOptions { export function generatePluginWrapper(manifest: PluginManifest, options: WrapperOptions): string { const site = options.site ?? { name: "", url: "", locale: "en" }; - const hasReadUsers = manifest.capabilities.includes("read:users"); + const hasReadUsers = + manifest.capabilities.includes("read:users") || manifest.capabilities.includes("users:read"); const hasEmailSend = manifest.capabilities.includes("email:send"); + const hasCachePurge = manifest.capabilities.includes("cache:purge"); return ` // ============================================================================= @@ -321,6 +323,13 @@ function createContext() { send: (message) => bridgeCall("email/send", { message }), } : undefined; + const cache = ${hasCachePurge} ? { + getObjectCacheStatus: () => bridgeCall("cache/getObjectCacheStatus", {}), + purgeObjectCache: (options) => bridgeCall("cache/purgeObjectCache", options || {}), + getWorkersCacheStatus: () => bridgeCall("cache/getWorkersCacheStatus", {}), + purgeWorkersCache: (options) => bridgeCall("cache/purgeWorkersCache", options || {}), + } : undefined; + return { plugin: { id: ${JSON.stringify(manifest.id)}, @@ -337,6 +346,7 @@ function createContext() { url, users, email, + cache, }; }