diff --git a/.changeset/object-cache-purge-api.md b/.changeset/object-cache-purge-api.md deleted file mode 100644 index b559bdfe1e..0000000000 --- a/.changeset/object-cache-purge-api.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"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 76a284b0fb..1ff306a7ff 100644 --- a/packages/admin/src/lib/api/marketplace.ts +++ b/packages/admin/src/lib/api/marketplace.ts @@ -282,7 +282,6 @@ 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 b86d28705c..e5a1d98031 100644 --- a/packages/admin/tests/lib/marketplace.test.ts +++ b/packages/admin/tests/lib/marketplace.test.ts @@ -312,7 +312,6 @@ 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 7e75affb9a..ce71b2d027 100644 --- a/packages/blocks/src/builders.ts +++ b/packages/blocks/src/builders.ts @@ -246,8 +246,6 @@ function button( style?: "primary" | "danger" | "secondary"; value?: unknown; confirm?: ConfirmDialog; - disabled?: boolean; - title?: string; }, ): ButtonElement { return { @@ -257,8 +255,6 @@ 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 caf567d8ae..692f937b43 100644 --- a/packages/blocks/src/elements/button.tsx +++ b/packages/blocks/src/elements/button.tsx @@ -1,4 +1,4 @@ -import { Button, Dialog, DialogRoot, Tooltip, TooltipProvider } from "@cloudflare/kumo"; +import { Button, Dialog, DialogRoot } from "@cloudflare/kumo"; import { useCallback, useState } from "react"; import type { BlockInteraction, ButtonElement } from "../types.js"; @@ -11,26 +11,22 @@ 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, isDisabled, element.action_id, element.value]); + }, [onAction, element.action_id, element.value]); const handleClick = useCallback(() => { - if (isDisabled) return; if (element.confirm) { setConfirmOpen(true); } else { fireAction(); } - }, [isDisabled, element.confirm, fireAction]); + }, [element.confirm, fireAction]); const handleConfirm = useCallback(() => { setConfirmOpen(false); @@ -44,35 +40,12 @@ 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 ( <> - {withTooltip} - {element.confirm && !isDisabled && ( + + {element.confirm && (

{element.confirm.title}

diff --git a/packages/blocks/src/types.ts b/packages/blocks/src/types.ts index 9f136af1f3..5105a9734b 100644 --- a/packages/blocks/src/types.ts +++ b/packages/blocks/src/types.ts @@ -17,10 +17,6 @@ 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 4afe6aaaeb..9f39163b74 100644 --- a/packages/blocks/src/validation.ts +++ b/packages/blocks/src/validation.ts @@ -176,18 +176,6 @@ 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 e22405b045..78897719cb 100644 --- a/packages/blocks/tests/renderer.test.tsx +++ b/packages/blocks/tests/renderer.test.tsx @@ -15,28 +15,11 @@ const CollapsibleContext = React.createContext<{ }>({}); vi.mock("@cloudflare/kumo", () => ({ - Button: ({ children, onClick, variant, type, disabled, title }: 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) => (
@@ -410,34 +393,6 @@ 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 7c24c63e3e..b169356e34 100644 --- a/packages/cloudflare/src/sandbox/bridge.ts +++ b/packages/cloudflare/src/sandbox/bridge.ts @@ -10,14 +10,7 @@ import type { D1Database } from "@cloudflare/workers-types"; import { WorkerEntrypoint } from "cloudflare:workers"; import type { SandboxEmailSendCallback } from "emdash"; -import { - handleObjectCachePurge, - handleObjectCacheStatus, - handleWorkersCachePurge, - handleWorkersCacheStatus, - ulid, - PluginStorageRepository, -} from "emdash"; +import { ulid, PluginStorageRepository } from "emdash"; import { Kysely } from "kysely"; import { D1Dialect } from "kysely-d1"; @@ -1180,70 +1173,6 @@ 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 74b481319f..8ffe7b6616 100644 --- a/packages/cloudflare/src/sandbox/types.ts +++ b/packages/cloudflare/src/sandbox/types.ts @@ -212,15 +212,6 @@ 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 28132b17c5..320c3752e7 100644 --- a/packages/cloudflare/src/sandbox/wrapper.ts +++ b/packages/cloudflare/src/sandbox/wrapper.ts @@ -43,7 +43,6 @@ 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 ` // ============================================================================= @@ -174,14 +173,6 @@ 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: { @@ -198,8 +189,7 @@ function createContext(env) { site, url, users, - email, - cache + email }; } diff --git a/packages/core/src/api/handlers/index.ts b/packages/core/src/api/handlers/index.ts index c1c502ce2b..2d0fadf48c 100644 --- a/packages/core/src/api/handlers/index.ts +++ b/packages/core/src/api/handlers/index.ts @@ -159,30 +159,6 @@ 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 deleted file mode 100644 index 2dc9be2e54..0000000000 --- a/packages/core/src/api/handlers/object-cache.ts +++ /dev/null @@ -1,218 +0,0 @@ -/** - * 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 deleted file mode 100644 index 9f9590a63c..0000000000 --- a/packages/core/src/api/handlers/workers-cache.ts +++ /dev/null @@ -1,240 +0,0 @@ -/** - * 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 (e.g. `https://`). */ -const ABSOLUTE_URL_RE = /^[a-zA-Z][a-zA-Z+\-.]*:\/\//; - -/** - * 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) { - 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 a235d20555..1c0b98cd14 100644 --- a/packages/core/src/astro/integration/routes.ts +++ b/packages/core/src/astro/integration/routes.ts @@ -345,16 +345,6 @@ export function injectCoreRoutes( entrypoint: resolveRoute("api/settings.ts"), }); - // Object-cache purge (KV / memory CMS read cache) - 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 b6b1352297..4b26f3692f 100644 --- a/packages/core/src/astro/integration/virtual-modules.ts +++ b/packages/core/src/astro/integration/virtual-modules.ts @@ -72,9 +72,6 @@ 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. */ @@ -500,21 +497,6 @@ 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 cc09774b4d..29266e6352 100644 --- a/packages/core/src/astro/integration/vite-config.ts +++ b/packages/core/src/astro/integration/vite-config.ts @@ -48,13 +48,10 @@ 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, @@ -236,9 +233,6 @@ 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) { @@ -339,11 +333,6 @@ 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 deleted file mode 100644 index 70cb1d0a09..0000000000 --- a/packages/core/src/astro/routes/api/admin/cache/object.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * 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 deleted file mode 100644 index fc364d253d..0000000000 --- a/packages/core/src/astro/routes/api/admin/cache/workers.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * 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 405b3d3483..488fe2789f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -73,10 +73,6 @@ export { handleRevisionList, handleRevisionGet, handleRevisionRestore, - handleObjectCachePurge, - handleObjectCacheStatus, - handleWorkersCachePurge, - handleWorkersCacheStatus, generateManifest, } from "./api/index.js"; export type { @@ -206,8 +202,6 @@ export { invalidateMenuObjectCache, invalidateSchemaObjectCache, invalidateCommentObjectCache, - isObjectCacheActive, - isObjectCacheConfigured, contentNamespace, contentNamespaces, CacheNamespace, diff --git a/packages/core/src/object-cache/index.ts b/packages/core/src/object-cache/index.ts index 3c66cd7d23..f025a8ea42 100644 --- a/packages/core/src/object-cache/index.ts +++ b/packages/core/src/object-cache/index.ts @@ -463,15 +463,10 @@ 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 { - return (await isObjectCacheConfigured()) && !shouldBypass(); + const backend = await getBackend(); + return backend !== null && !shouldBypass(); } /** diff --git a/packages/core/src/plugins/context.ts b/packages/core/src/plugins/context.ts index dae8f5ad51..233a6e16a5 100644 --- a/packages/core/src/plugins/context.ts +++ b/packages/core/src/plugins/context.ts @@ -8,11 +8,6 @@ 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"; @@ -41,7 +36,6 @@ import type { KVAccess, CronAccess, EmailAccess, - CacheAccess, ContentAccess, ContentAccessWithWrite, MediaAccess, @@ -955,46 +949,6 @@ function toUserInfo(user: { * Create read-only user access for plugins. * Excludes sensitive fields (password hashes, sessions, passkeys, avatar URL, data). */ -/** - * 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; - }, - }; -} - export function createUserAccess(db: Kysely): UserAccess { const userRepo = new UserRepository(db); @@ -1207,12 +1161,6 @@ 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, @@ -1230,7 +1178,6 @@ export class PluginContextFactory { users, cron, email, - cache, }; } } diff --git a/packages/core/src/plugins/index.ts b/packages/core/src/plugins/index.ts index 5e8383d90e..75b5879a4d 100644 --- a/packages/core/src/plugins/index.ts +++ b/packages/core/src/plugins/index.ts @@ -36,7 +36,6 @@ export { createBlockedHttpAccess, createLogAccess, createUserAccess, - createCacheAccess, createUrlHelper, createSiteInfo, } from "./context.js"; @@ -141,9 +140,6 @@ export type { LifecycleEvent, UninstallEvent, - // Cache types - CacheAccess, - // Email types EmailAccess, EmailMessage, diff --git a/packages/core/src/plugins/manifest-schema.ts b/packages/core/src/plugins/manifest-schema.ts index 341f0416c3..7bba3253f8 100644 --- a/packages/core/src/plugins/manifest-schema.ts +++ b/packages/core/src/plugins/manifest-schema.ts @@ -32,7 +32,6 @@ export const CURRENT_PLUGIN_CAPABILITIES = [ "media:read", "media:write", "users:read", - "cache:purge", "email:send", "hooks.email-transport:register", "hooks.email-events:register", diff --git a/packages/core/src/plugins/types.ts b/packages/core/src/plugins/types.ts index e002bb0dce..7e3ad2bd9f 100644 --- a/packages/core/src/plugins/types.ts +++ b/packages/core/src/plugins/types.ts @@ -510,50 +510,6 @@ 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 b853d4125c..9ce6519842 100644 --- a/packages/core/src/virtual-modules.d.ts +++ b/packages/core/src/virtual-modules.d.ts @@ -170,23 +170,6 @@ 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 deleted file mode 100644 index aacaec05df..0000000000 --- a/packages/core/tests/unit/api/object-cache-handlers.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * 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 deleted file mode 100644 index da430ce299..0000000000 --- a/packages/core/tests/unit/api/object-cache-route.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * 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 deleted file mode 100644 index 986a07931c..0000000000 --- a/packages/core/tests/unit/api/workers-cache-handlers.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * 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("rejects empty input", () => { - expect(normalizeWorkersCachePathPrefix(" ")).toEqual({ - ok: false, - message: "Path is required", - }); - }); -}); - -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 deleted file mode 100644 index 2d034e5b1d..0000000000 --- a/packages/core/tests/unit/api/workers-cache-route.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * 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 c8f8c6d206..79b17db6d2 100644 --- a/packages/core/tests/unit/astro/integration/virtual-modules.test.ts +++ b/packages/core/tests/unit/astro/integration/virtual-modules.test.ts @@ -13,7 +13,6 @@ import { generateEnvModule, generateSchedulerModule, generateSeedModule, - generateWorkersCacheModule, RESOLVED_VIRTUAL_SANDBOXED_PLUGINS_ID, RESOLVED_VIRTUAL_SCHEDULER_ID, } from "../../../../src/astro/integration/virtual-modules.js"; @@ -139,19 +138,6 @@ 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 cd85e7f7e7..5bb08959e2 100644 --- a/packages/plugin-cli/src/manifest/schema.ts +++ b/packages/plugin-cli/src/manifest/schema.ts @@ -350,7 +350,6 @@ 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 92055f264f..31a5f0888b 100644 --- a/packages/plugin-types/src/declared-access.ts +++ b/packages/plugin-types/src/declared-access.ts @@ -29,7 +29,6 @@ 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 3a2928d95d..04adde7f38 100644 --- a/packages/plugin-types/src/index.ts +++ b/packages/plugin-types/src/index.ts @@ -55,8 +55,6 @@ export type PluginCapability = | "media:write" // Users | "users:read" - // Cache — purge CMS object-cache namespaces (KV / memory) - | "cache:purge" // Email | "email:send" // Hook registration @@ -191,7 +189,6 @@ export interface DeclaredAccess { email?: { send?: AccessConstraints; events?: AccessConstraints; transport?: AccessConstraints }; page?: { fragments?: AccessConstraints }; users?: { read?: AccessConstraints }; - cache?: { purge?: AccessConstraints }; } /** @@ -239,7 +236,6 @@ 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; } @@ -288,7 +284,6 @@ 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 ba56c50749..e7e6a49e1a 100644 --- a/packages/plugin-types/src/manifest-schema.ts +++ b/packages/plugin-types/src/manifest-schema.ts @@ -28,7 +28,6 @@ export const CURRENT_PLUGIN_CAPABILITIES = [ "media:read", "media:write", "users:read", - "cache:purge", "email:send", "hooks.email-transport:register", "hooks.email-events:register", diff --git a/packages/workerd/src/sandbox/bridge-handler.ts b/packages/workerd/src/sandbox/bridge-handler.ts index fe1098dde8..9562529985 100644 --- a/packages/workerd/src/sandbox/bridge-handler.ts +++ b/packages/workerd/src/sandbox/bridge-handler.ts @@ -14,15 +14,7 @@ * must produce same outputs, same return shapes, same error messages. */ -import { - createHttpAccess, - createUnrestrictedHttpAccess, - handleObjectCachePurge, - handleObjectCacheStatus, - handleWorkersCachePurge, - handleWorkersCacheStatus, - PluginStorageRepository, -} from "emdash"; +import { createHttpAccess, createUnrestrictedHttpAccess, PluginStorageRepository } from "emdash"; import type { Database, SandboxEmailSendCallback } from "emdash"; import { sql, type Kysely, type RawBuilder } from "kysely"; @@ -264,50 +256,6 @@ 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 f96c561637..b45be3fde6 100644 --- a/packages/workerd/src/sandbox/wrapper.ts +++ b/packages/workerd/src/sandbox/wrapper.ts @@ -38,10 +38,8 @@ 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") || manifest.capabilities.includes("users:read"); + const hasReadUsers = manifest.capabilities.includes("read:users"); const hasEmailSend = manifest.capabilities.includes("email:send"); - const hasCachePurge = manifest.capabilities.includes("cache:purge"); return ` // ============================================================================= @@ -323,13 +321,6 @@ 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)}, @@ -346,7 +337,6 @@ function createContext() { url, users, email, - cache, }; }