From a168b3f93e2068d462bfd0f354a6429c0d893d42 Mon Sep 17 00:00:00 2001 From: scottbuscemi Date: Wed, 29 Jul 2026 14:01:20 -0500 Subject: [PATCH 1/9] feat: add object-cache purge API and cache:purge plugin capability Admins and sandboxed plugins can clear CMS object-cache namespaces (KV/memory) via GET/POST /_emdash/api/admin/cache/object and ctx.cache. Block Kit buttons gain optional disabled and title fields for clearer troubleshooting UI. --- .changeset/object-cache-purge-api.md | 10 + packages/admin/src/lib/api/marketplace.ts | 1 + packages/blocks/src/builders.ts | 4 + packages/blocks/src/elements/button.tsx | 41 +++- packages/blocks/src/types.ts | 4 + packages/blocks/src/validation.ts | 12 + packages/blocks/tests/renderer.test.tsx | 49 +++- packages/cloudflare/src/sandbox/bridge.ts | 43 +++- packages/cloudflare/src/sandbox/types.ts | 5 + packages/cloudflare/src/sandbox/wrapper.ts | 10 +- packages/core/src/api/handlers/index.ts | 12 + .../core/src/api/handlers/object-cache.ts | 218 ++++++++++++++++++ packages/core/src/astro/integration/routes.ts | 6 + .../astro/routes/api/admin/cache/object.ts | 50 ++++ packages/core/src/index.ts | 4 + packages/core/src/object-cache/index.ts | 9 +- packages/core/src/plugins/context.ts | 33 +++ packages/core/src/plugins/index.ts | 4 + packages/core/src/plugins/manifest-schema.ts | 1 + packages/core/src/plugins/types.ts | 26 +++ .../unit/api/object-cache-handlers.test.ts | 98 ++++++++ .../tests/unit/api/object-cache-route.test.ts | 100 ++++++++ packages/plugin-cli/src/manifest/schema.ts | 1 + packages/plugin-types/src/declared-access.ts | 1 + packages/plugin-types/src/index.ts | 5 + packages/plugin-types/src/manifest-schema.ts | 1 + .../workerd/src/sandbox/bridge-handler.ts | 31 ++- packages/workerd/src/sandbox/wrapper.ts | 11 +- 28 files changed, 775 insertions(+), 15 deletions(-) create mode 100644 .changeset/object-cache-purge-api.md create mode 100644 packages/core/src/api/handlers/object-cache.ts create mode 100644 packages/core/src/astro/routes/api/admin/cache/object.ts create mode 100644 packages/core/tests/unit/api/object-cache-handlers.test.ts create mode 100644 packages/core/tests/unit/api/object-cache-route.test.ts diff --git a/.changeset/object-cache-purge-api.md b/.changeset/object-cache-purge-api.md new file mode 100644 index 0000000000..09607d9524 --- /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 an admin API to purge the CMS object cache (`GET`/`POST /_emdash/api/admin/cache/object`) and a `cache:purge` plugin capability so sandboxed plugins can clear KV/memory object-cache namespaces via `ctx.cache`. 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..9ac6176db3 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`, "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/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..6154e0d6d9 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 }: any) => { + const trigger = render ?? ; + 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..973175a3ad 100644 --- a/packages/cloudflare/src/sandbox/bridge.ts +++ b/packages/cloudflare/src/sandbox/bridge.ts @@ -10,7 +10,12 @@ 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, + ulid, + PluginStorageRepository, +} from "emdash"; import { Kysely } from "kysely"; import { D1Dialect } from "kysely-d1"; @@ -1173,6 +1178,42 @@ 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; + } + // ========================================================================= // Logging // ========================================================================= diff --git a/packages/cloudflare/src/sandbox/types.ts b/packages/cloudflare/src/sandbox/types.ts index 8ffe7b6616..e6274a3965 100644 --- a/packages/cloudflare/src/sandbox/types.ts +++ b/packages/cloudflare/src/sandbox/types.ts @@ -212,6 +212,11 @@ export interface PluginBridgeBinding { ): Promise<{ status: number; headers: Record; text: string }>; // Email emailSend(message: { to: string; subject: string; text: string; html?: string }): Promise; + // Object cache (gated on cache:purge) + getObjectCacheStatus(): Promise<{ configured: boolean }>; + purgeObjectCache(options?: { + namespaces?: string[]; + }): Promise<{ configured: boolean; active: boolean; purged: 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..1cb35c176b 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,12 @@ function createContext(env) { const email = ${hasEmailSend} ? { send: (message) => bridge.emailSend(message) } : undefined; + + // Object-cache purge - proxies to bridge (capability enforced by bridge) + const cache = ${hasCachePurge} ? { + getObjectCacheStatus: () => bridge.getObjectCacheStatus(), + purgeObjectCache: (options) => bridge.purgeObjectCache(options) + } : undefined; return { plugin: { @@ -189,7 +196,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..ae21a11930 100644 --- a/packages/core/src/api/handlers/index.ts +++ b/packages/core/src/api/handlers/index.ts @@ -159,6 +159,18 @@ 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"; + // 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/astro/integration/routes.ts b/packages/core/src/astro/integration/routes.ts index 93f0efdfa3..8b8a077def 100644 --- a/packages/core/src/astro/integration/routes.ts +++ b/packages/core/src/astro/integration/routes.ts @@ -350,6 +350,12 @@ 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"), + }); + // Email settings route injectRoute({ pattern: "/_emdash/api/settings/email", 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/index.ts b/packages/core/src/index.ts index 488fe2789f..40e2916b85 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -73,6 +73,8 @@ export { handleRevisionList, handleRevisionGet, handleRevisionRestore, + handleObjectCachePurge, + handleObjectCacheStatus, generateManifest, } from "./api/index.js"; export type { @@ -202,6 +204,8 @@ 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 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..4e35894262 100644 --- a/packages/core/src/plugins/context.ts +++ b/packages/core/src/plugins/context.ts @@ -8,6 +8,7 @@ import type { Kysely } from "kysely"; import { ulid } from "ulidx"; +import { handleObjectCachePurge, handleObjectCacheStatus } from "../api/handlers/object-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 +37,7 @@ import type { KVAccess, CronAccess, EmailAccess, + CacheAccess, ContentAccess, ContentAccessWithWrite, MediaAccess, @@ -949,6 +951,30 @@ function toUserInfo(user: { * Create read-only user access for plugins. * Excludes sensitive fields (password hashes, sessions, passkeys, avatar URL, data). */ +/** + * Create object-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; + }, + }; +} + export function createUserAccess(db: Kysely): UserAccess { const userRepo = new UserRepository(db); @@ -1161,6 +1187,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 +1210,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..5e8383d90e 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, diff --git a/packages/core/src/plugins/manifest-schema.ts b/packages/core/src/plugins/manifest-schema.ts index 7bba3253f8..341f0416c3 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", diff --git a/packages/core/src/plugins/types.ts b/packages/core/src/plugins/types.ts index 7e3ad2bd9f..101bdad473 100644 --- a/packages/core/src/plugins/types.ts +++ b/packages/core/src/plugins/types.ts @@ -510,6 +510,32 @@ 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[]; + }>; } // ============================================================================= 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/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..ba56c50749 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", diff --git a/packages/workerd/src/sandbox/bridge-handler.ts b/packages/workerd/src/sandbox/bridge-handler.ts index 9562529985..f4df9063f9 100644 --- a/packages/workerd/src/sandbox/bridge-handler.ts +++ b/packages/workerd/src/sandbox/bridge-handler.ts @@ -14,7 +14,13 @@ * must produce same outputs, same return shapes, same error messages. */ -import { createHttpAccess, createUnrestrictedHttpAccess, PluginStorageRepository } from "emdash"; +import { + createHttpAccess, + createUnrestrictedHttpAccess, + handleObjectCachePurge, + handleObjectCacheStatus, + PluginStorageRepository, +} from "emdash"; import type { Database, SandboxEmailSendCallback } from "emdash"; import { sql, type Kysely, type RawBuilder } from "kysely"; @@ -256,6 +262,29 @@ async function dispatch( return null; } + // ── Object cache ──────────────────────────────────────────────── + 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; + } + // ── 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..4d6d4d926e 100644 --- a/packages/workerd/src/sandbox/wrapper.ts +++ b/packages/workerd/src/sandbox/wrapper.ts @@ -38,8 +38,11 @@ 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 +324,11 @@ function createContext() { send: (message) => bridgeCall("email/send", { message }), } : undefined; + const cache = ${hasCachePurge} ? { + getObjectCacheStatus: () => bridgeCall("cache/getObjectCacheStatus", {}), + purgeObjectCache: (options) => bridgeCall("cache/purgeObjectCache", options || {}), + } : undefined; + return { plugin: { id: ${JSON.stringify(manifest.id)}, @@ -337,6 +345,7 @@ function createContext() { url, users, email, + cache, }; } From 71b3599e440b09a26e03a43fbf201561b611bff9 Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Wed, 29 Jul 2026 19:03:12 +0000 Subject: [PATCH 2/9] style: format --- packages/workerd/src/sandbox/wrapper.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/workerd/src/sandbox/wrapper.ts b/packages/workerd/src/sandbox/wrapper.ts index 4d6d4d926e..9a57b3c6aa 100644 --- a/packages/workerd/src/sandbox/wrapper.ts +++ b/packages/workerd/src/sandbox/wrapper.ts @@ -39,8 +39,7 @@ 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"); + manifest.capabilities.includes("read:users") || manifest.capabilities.includes("users:read"); const hasEmailSend = manifest.capabilities.includes("email:send"); const hasCachePurge = manifest.capabilities.includes("cache:purge"); From 0445b6871859130b560855f2410b0acb705505df Mon Sep 17 00:00:00 2001 From: scottbuscemi Date: Wed, 29 Jul 2026 14:24:07 -0500 Subject: [PATCH 3/9] feat: add Workers Cache purge API alongside object cache Admins and plugins with cache:purge can clear edge-cached pages via GET/POST /_emdash/api/admin/cache/workers and ctx.cache.purgeWorkersCache() (Cloudflare purge_everything using CF_ZONE_ID + CF_CACHE_PURGE_TOKEN). --- .changeset/object-cache-purge-api.md | 2 +- packages/admin/src/lib/api/marketplace.ts | 2 +- packages/cloudflare/src/sandbox/bridge.ts | 26 +++ packages/cloudflare/src/sandbox/types.ts | 4 +- packages/cloudflare/src/sandbox/wrapper.ts | 6 +- packages/core/src/api/handlers/index.ts | 10 ++ .../core/src/api/handlers/workers-cache.ts | 161 ++++++++++++++++++ packages/core/src/astro/integration/routes.ts | 4 + .../astro/routes/api/admin/cache/workers.ts | 34 ++++ packages/core/src/index.ts | 2 + packages/core/src/plugins/context.ts | 20 ++- packages/core/src/plugins/types.ts | 19 ++- .../unit/api/workers-cache-handlers.test.ts | 78 +++++++++ .../unit/api/workers-cache-route.test.ts | 83 +++++++++ .../workerd/src/sandbox/bridge-handler.ts | 20 ++- packages/workerd/src/sandbox/wrapper.ts | 2 + 16 files changed, 462 insertions(+), 11 deletions(-) create mode 100644 packages/core/src/api/handlers/workers-cache.ts create mode 100644 packages/core/src/astro/routes/api/admin/cache/workers.ts create mode 100644 packages/core/tests/unit/api/workers-cache-handlers.test.ts create mode 100644 packages/core/tests/unit/api/workers-cache-route.test.ts diff --git a/.changeset/object-cache-purge-api.md b/.changeset/object-cache-purge-api.md index 09607d9524..42e3fa6562 100644 --- a/.changeset/object-cache-purge-api.md +++ b/.changeset/object-cache-purge-api.md @@ -7,4 +7,4 @@ "@emdash-cms/blocks": minor --- -Adds an admin API to purge the CMS object cache (`GET`/`POST /_emdash/api/admin/cache/object`) and a `cache:purge` plugin capability so sandboxed plugins can clear KV/memory object-cache namespaces via `ctx.cache`. Block Kit buttons also support optional `disabled` and `title` (tooltip) fields. +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 Workers Cache / edge pages (`GET`/`POST /_emdash/api/admin/cache/workers`, `ctx.cache.purgeWorkersCache`, Cloudflare `purge_everything` via `CF_ZONE_ID` + `CF_CACHE_PURGE_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 9ac6176db3..76a284b0fb 100644 --- a/packages/admin/src/lib/api/marketplace.ts +++ b/packages/admin/src/lib/api/marketplace.ts @@ -282,7 +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`, + "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/cloudflare/src/sandbox/bridge.ts b/packages/cloudflare/src/sandbox/bridge.ts index 973175a3ad..764e847a73 100644 --- a/packages/cloudflare/src/sandbox/bridge.ts +++ b/packages/cloudflare/src/sandbox/bridge.ts @@ -13,6 +13,8 @@ import type { SandboxEmailSendCallback } from "emdash"; import { handleObjectCachePurge, handleObjectCacheStatus, + handleWorkersCachePurge, + handleWorkersCacheStatus, ulid, PluginStorageRepository, } from "emdash"; @@ -1214,6 +1216,30 @@ 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 handleWorkersCacheStatus(); + if (!result.success) { + throw new Error(result.error.message); + } + return result.data; + } + + async purgeWorkersCache(): Promise<{ configured: boolean; purged: boolean }> { + const { capabilities } = this.ctx.props; + if (!capabilities.includes("cache:purge")) { + throw new Error("Missing capability: cache:purge"); + } + const result = await handleWorkersCachePurge(); + 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 e6274a3965..a5e4fa1e6d 100644 --- a/packages/cloudflare/src/sandbox/types.ts +++ b/packages/cloudflare/src/sandbox/types.ts @@ -212,11 +212,13 @@ export interface PluginBridgeBinding { ): Promise<{ status: number; headers: Record; text: string }>; // Email emailSend(message: { to: string; subject: string; text: string; html?: string }): Promise; - // Object cache (gated on cache:purge) + // 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(): Promise<{ configured: boolean; purged: boolean }>; // 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 1cb35c176b..0a3091c65f 100644 --- a/packages/cloudflare/src/sandbox/wrapper.ts +++ b/packages/cloudflare/src/sandbox/wrapper.ts @@ -175,10 +175,12 @@ function createContext(env) { send: (message) => bridge.emailSend(message) } : undefined; - // Object-cache purge - proxies to bridge (capability enforced by bridge) + // Cache purge - proxies to bridge (capability enforced by bridge) const cache = ${hasCachePurge} ? { getObjectCacheStatus: () => bridge.getObjectCacheStatus(), - purgeObjectCache: (options) => bridge.purgeObjectCache(options) + purgeObjectCache: (options) => bridge.purgeObjectCache(options), + getWorkersCacheStatus: () => bridge.getWorkersCacheStatus(), + purgeWorkersCache: () => bridge.purgeWorkersCache() } : undefined; return { diff --git a/packages/core/src/api/handlers/index.ts b/packages/core/src/api/handlers/index.ts index ae21a11930..948a33745d 100644 --- a/packages/core/src/api/handlers/index.ts +++ b/packages/core/src/api/handlers/index.ts @@ -171,6 +171,16 @@ export { type ObjectCacheStatus, } from "./object-cache.js"; +// Workers Cache (edge page cache) purge +export { + handleWorkersCachePurge, + handleWorkersCacheStatus, + resolveWorkersCacheCredentials, + type WorkersCacheCredentials, + type WorkersCachePurgeResult, + type WorkersCacheStatus, +} from "./workers-cache.js"; + // Taxonomy handlers export { handleTaxonomyList, 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..7def05e9ac --- /dev/null +++ b/packages/core/src/api/handlers/workers-cache.ts @@ -0,0 +1,161 @@ +/** + * Workers Cache (edge page cache) status + purge handlers. + * + * Uses the Cloudflare purge REST API (`purge_everything`) with the same + * credentials as `cloudflareCache()` (`CF_ZONE_ID` + `CF_CACHE_PURGE_TOKEN`). + * Safe when credentials are missing — status reports `configured: false`. + */ + +import type { ApiResult } from "../types.js"; + +/** Cloudflare purge API base */ +const CF_API_BASE = "https://api.cloudflare.com/client/v4"; + +const DEFAULT_ZONE_ID_ENV = "CF_ZONE_ID"; +const DEFAULT_API_TOKEN_ENV = "CF_CACHE_PURGE_TOKEN"; + +export interface WorkersCacheStatus { + /** Whether zone ID + purge token are available for edge cache purge. */ + configured: boolean; +} + +export interface WorkersCachePurgeResult { + /** Whether zone ID + purge token were available. */ + configured: boolean; + /** True when Cloudflare accepted the purge request. */ + purged: boolean; +} + +export interface WorkersCacheCredentials { + zoneId: string; + apiToken: string; +} + +function readString(env: Record | undefined, key: string): string | undefined { + if (!env) return undefined; + const value = env[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +/** + * Resolve Worker/Node env bindings for cache-purge credentials. + * Prefer `virtual:emdash/env` (Cloudflare bindings); fall back to + * `import.meta.env` / `process.env` for Node and tests. + */ +export async function resolveWorkersCacheCredentials( + overrides?: Partial & { + zoneIdEnvVar?: string; + apiTokenEnvVar?: string; + }, +): Promise { + if (overrides?.zoneId && overrides?.apiToken) { + return { zoneId: overrides.zoneId, apiToken: overrides.apiToken }; + } + + let envRecord: Record | undefined; + try { + // @ts-ignore - virtual module, generated by the Astro integration + const mod = (await import("virtual:emdash/env")) as { + env?: Record; + }; + envRecord = mod.env; + } catch { + // Outside Vite/Astro (unit tests, CLI) + } + + const metaEnv = + typeof import.meta !== "undefined" + ? // eslint-disable-next-line typescript/no-unsafe-type-assertion -- import.meta.env is a string bag at runtime + (import.meta.env as Record | undefined) + : undefined; + const processEnv = + typeof process !== "undefined" && process.env + ? // eslint-disable-next-line typescript/no-unsafe-type-assertion -- process.env values are string | undefined + (process.env as Record) + : undefined; + + const env = envRecord ?? metaEnv ?? processEnv; + const zoneKey = overrides?.zoneIdEnvVar ?? DEFAULT_ZONE_ID_ENV; + const tokenKey = overrides?.apiTokenEnvVar ?? DEFAULT_API_TOKEN_ENV; + + const zoneId = overrides?.zoneId ?? readString(env, zoneKey); + const apiToken = overrides?.apiToken ?? readString(env, tokenKey); + + if (!zoneId || !apiToken) return null; + return { zoneId, apiToken }; +} + +/** + * Report whether Workers Cache purge credentials are configured. + */ +export async function handleWorkersCacheStatus( + credentials?: WorkersCacheCredentials | null, +): Promise> { + try { + const resolved = + credentials === undefined ? await resolveWorkersCacheCredentials() : credentials; + 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 all edge-cached responses for the zone (`purge_everything`). + */ +export async function handleWorkersCachePurge( + credentials?: WorkersCacheCredentials | null, + fetchImpl: typeof fetch = fetch, +): Promise> { + try { + const resolved = + credentials === undefined ? await resolveWorkersCacheCredentials() : credentials; + + if (!resolved) { + return { + success: true, + data: { configured: false, purged: false }, + }; + } + + const response = await fetchImpl(`${CF_API_BASE}/zones/${resolved.zoneId}/purge_cache`, { + method: "POST", + headers: { + Authorization: `Bearer ${resolved.apiToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ purge_everything: true }), + }); + + if (!response.ok) { + const body = await response.text().catch(() => ""); + return { + success: false, + error: { + code: "WORKERS_CACHE_PURGE_ERROR", + message: `Workers Cache purge failed (${response.status})${body ? `: ${body}` : ""}`, + }, + }; + } + + return { + success: true, + data: { configured: true, purged: true }, + }; + } 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}`, + }, + }; + } +} diff --git a/packages/core/src/astro/integration/routes.ts b/packages/core/src/astro/integration/routes.ts index 8b8a077def..26359deb95 100644 --- a/packages/core/src/astro/integration/routes.ts +++ b/packages/core/src/astro/integration/routes.ts @@ -355,6 +355,10 @@ export function injectCoreRoutes( 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({ 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..5ebc6be9a8 --- /dev/null +++ b/packages/core/src/astro/routes/api/admin/cache/workers.ts @@ -0,0 +1,34 @@ +/** + * Workers Cache status + purge endpoint + * + * GET /_emdash/api/admin/cache/workers — whether purge credentials are configured + * POST /_emdash/api/admin/cache/workers — purge_everything via Cloudflare API + */ + +import type { APIRoute } from "astro"; + +import { requirePerm } from "#api/authorize.js"; +import { handleWorkersCachePurge, handleWorkersCacheStatus } from "#api/handlers/workers-cache.js"; +import { unwrapResult } from "#api/index.js"; + +export const prerender = false; + +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 ({ locals }) => { + const { user } = locals; + + const denied = requirePerm(user, "settings:manage"); + if (denied) return denied; + + const result = await handleWorkersCachePurge(); + return unwrapResult(result); +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 40e2916b85..405b3d3483 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -75,6 +75,8 @@ export { handleRevisionRestore, handleObjectCachePurge, handleObjectCacheStatus, + handleWorkersCachePurge, + handleWorkersCacheStatus, generateManifest, } from "./api/index.js"; export type { diff --git a/packages/core/src/plugins/context.ts b/packages/core/src/plugins/context.ts index 4e35894262..0a078558bc 100644 --- a/packages/core/src/plugins/context.ts +++ b/packages/core/src/plugins/context.ts @@ -9,6 +9,10 @@ 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"; @@ -952,7 +956,7 @@ function toUserInfo(user: { * Excludes sensitive fields (password hashes, sessions, passkeys, avatar URL, data). */ /** - * Create object-cache purge access for plugins with `cache:purge`. + * Create cache purge access for plugins with `cache:purge`. */ export function createCacheAccess(db: Kysely): CacheAccess { return { @@ -972,6 +976,20 @@ export function createCacheAccess(db: Kysely): CacheAccess { } return result.data; }, + async getWorkersCacheStatus() { + const result = await handleWorkersCacheStatus(); + if (!result.success) { + throw new Error(result.error.message); + } + return result.data; + }, + async purgeWorkersCache() { + const result = await handleWorkersCachePurge(); + if (!result.success) { + throw new Error(result.error.message); + } + return result.data; + }, }; } diff --git a/packages/core/src/plugins/types.ts b/packages/core/src/plugins/types.ts index 101bdad473..47286f347e 100644 --- a/packages/core/src/plugins/types.ts +++ b/packages/core/src/plugins/types.ts @@ -512,15 +512,14 @@ export interface PluginContext; + + /** + * Whether Workers Cache purge credentials are configured + * (`CF_ZONE_ID` + `CF_CACHE_PURGE_TOKEN`, same as `cloudflareCache()`). + */ + getWorkersCacheStatus(): Promise<{ configured: boolean }>; + + /** + * Purge all edge-cached pages for the zone (`purge_everything`). + * Returns `configured: false` when credentials are missing. + */ + purgeWorkersCache(): Promise<{ configured: boolean; purged: boolean }>; } // ============================================================================= 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..558f44ebd2 --- /dev/null +++ b/packages/core/tests/unit/api/workers-cache-handlers.test.ts @@ -0,0 +1,78 @@ +/** + * Workers Cache purge handlers. + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + handleWorkersCachePurge, + handleWorkersCacheStatus, +} from "../../../src/api/handlers/workers-cache.js"; + +describe("handleWorkersCacheStatus", () => { + it("reports configured:false when credentials are 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 credentials are provided", async () => { + const result = await handleWorkersCacheStatus({ + zoneId: "zone-1", + apiToken: "token-1", + }); + 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 Cloudflare when credentials missing", async () => { + const fetchImpl = vi.fn(); + const result = await handleWorkersCachePurge(null, fetchImpl as typeof fetch); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data).toEqual({ configured: false, purged: false }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("posts purge_everything when credentials are present", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify({ success: true }), { status: 200 })); + const result = await handleWorkersCachePurge( + { zoneId: "zone-abc", apiToken: "tok-xyz" }, + fetchImpl as typeof fetch, + ); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data).toEqual({ configured: true, purged: true }); + expect(fetchImpl).toHaveBeenCalledOnce(); + const [url, init] = fetchImpl.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://api.cloudflare.com/client/v4/zones/zone-abc/purge_cache"); + expect(init.method).toBe("POST"); + expect(init.headers).toMatchObject({ + Authorization: "Bearer tok-xyz", + "Content-Type": "application/json", + }); + expect(JSON.parse(String(init.body))).toEqual({ purge_everything: true }); + }); + + it("surfaces Cloudflare API errors", async () => { + const fetchImpl = vi.fn().mockResolvedValue(new Response("nope", { status: 403 })); + const result = await handleWorkersCachePurge( + { zoneId: "zone-abc", apiToken: "tok-xyz" }, + fetchImpl as typeof fetch, + ); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.code).toBe("WORKERS_CACHE_PURGE_ERROR"); + expect(result.error.message).toContain("403"); + }); +}); 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..d5fc44c27d --- /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); + // No CF credentials in unit tests + 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 without credentials", 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/workerd/src/sandbox/bridge-handler.ts b/packages/workerd/src/sandbox/bridge-handler.ts index f4df9063f9..2ef80aa932 100644 --- a/packages/workerd/src/sandbox/bridge-handler.ts +++ b/packages/workerd/src/sandbox/bridge-handler.ts @@ -19,6 +19,8 @@ import { createUnrestrictedHttpAccess, handleObjectCachePurge, handleObjectCacheStatus, + handleWorkersCachePurge, + handleWorkersCacheStatus, PluginStorageRepository, } from "emdash"; import type { Database, SandboxEmailSendCallback } from "emdash"; @@ -262,7 +264,7 @@ async function dispatch( return null; } - // ── Object cache ──────────────────────────────────────────────── + // ── Cache purge ───────────────────────────────────────────────── case "cache/getObjectCacheStatus": { requireCapability(opts, "cache:purge"); const status = await handleObjectCacheStatus(); @@ -284,6 +286,22 @@ async function dispatch( } 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 result = await handleWorkersCachePurge(); + if (!result.success) { + throw new Error(result.error.message); + } + return result.data; + } // ── Users ─────────────────────────────────────────────────────── case "users/get": diff --git a/packages/workerd/src/sandbox/wrapper.ts b/packages/workerd/src/sandbox/wrapper.ts index 9a57b3c6aa..4cc0083853 100644 --- a/packages/workerd/src/sandbox/wrapper.ts +++ b/packages/workerd/src/sandbox/wrapper.ts @@ -326,6 +326,8 @@ function createContext() { const cache = ${hasCachePurge} ? { getObjectCacheStatus: () => bridgeCall("cache/getObjectCacheStatus", {}), purgeObjectCache: (options) => bridgeCall("cache/purgeObjectCache", options || {}), + getWorkersCacheStatus: () => bridgeCall("cache/getWorkersCacheStatus", {}), + purgeWorkersCache: () => bridgeCall("cache/purgeWorkersCache", {}), } : undefined; return { From 08d165dddf4f746e7422ea291ba2050e67baddf8 Mon Sep 17 00:00:00 2001 From: scottbuscemi Date: Wed, 29 Jul 2026 16:07:32 -0500 Subject: [PATCH 4/9] feat: purge Workers Cache via native cache.purge() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace zone REST purge (CF_ZONE_ID + token) with cloudflare:workers cache.purge({ purgeEverything: true }). Status is configured when the native API is available — no secrets required. --- .changeset/object-cache-purge-api.md | 2 +- packages/core/src/api/handlers/index.ts | 6 +- .../core/src/api/handlers/workers-cache.ts | 148 ++++++++---------- packages/core/src/cloudflare-workers.d.ts | 17 ++ packages/core/src/plugins/types.ts | 9 +- .../unit/api/workers-cache-handlers.test.ts | 64 ++++---- .../unit/api/workers-cache-route.test.ts | 4 +- 7 files changed, 124 insertions(+), 126 deletions(-) create mode 100644 packages/core/src/cloudflare-workers.d.ts diff --git a/.changeset/object-cache-purge-api.md b/.changeset/object-cache-purge-api.md index 42e3fa6562..400bd11b89 100644 --- a/.changeset/object-cache-purge-api.md +++ b/.changeset/object-cache-purge-api.md @@ -7,4 +7,4 @@ "@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 Workers Cache / edge pages (`GET`/`POST /_emdash/api/admin/cache/workers`, `ctx.cache.purgeWorkersCache`, Cloudflare `purge_everything` via `CF_ZONE_ID` + `CF_CACHE_PURGE_TOKEN`). Block Kit buttons also support optional `disabled` and `title` (tooltip) fields. +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({ purgeEverything: true })` — no zone ID or API token). Block Kit buttons also support optional `disabled` and `title` (tooltip) fields. diff --git a/packages/core/src/api/handlers/index.ts b/packages/core/src/api/handlers/index.ts index 948a33745d..de6bc726c4 100644 --- a/packages/core/src/api/handlers/index.ts +++ b/packages/core/src/api/handlers/index.ts @@ -171,12 +171,12 @@ export { type ObjectCacheStatus, } from "./object-cache.js"; -// Workers Cache (edge page cache) purge +// Workers Cache (native Workers Caching) purge export { handleWorkersCachePurge, handleWorkersCacheStatus, - resolveWorkersCacheCredentials, - type WorkersCacheCredentials, + resolveWorkersCachePurgeApi, + type WorkersCachePurgeApi, type WorkersCachePurgeResult, type WorkersCacheStatus, } from "./workers-cache.js"; diff --git a/packages/core/src/api/handlers/workers-cache.ts b/packages/core/src/api/handlers/workers-cache.ts index 7def05e9ac..3769051554 100644 --- a/packages/core/src/api/handlers/workers-cache.ts +++ b/packages/core/src/api/handlers/workers-cache.ts @@ -1,99 +1,68 @@ /** - * Workers Cache (edge page cache) status + purge handlers. + * Workers Cache (native Workers Caching) status + purge handlers. * - * Uses the Cloudflare purge REST API (`purge_everything`) with the same - * credentials as `cloudflareCache()` (`CF_ZONE_ID` + `CF_CACHE_PURGE_TOKEN`). - * Safe when credentials are missing — status reports `configured: false`. + * Uses `cache.purge()` from `cloudflare:workers` — the 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"; -/** Cloudflare purge API base */ -const CF_API_BASE = "https://api.cloudflare.com/client/v4"; - -const DEFAULT_ZONE_ID_ENV = "CF_ZONE_ID"; -const DEFAULT_API_TOKEN_ENV = "CF_CACHE_PURGE_TOKEN"; - export interface WorkersCacheStatus { - /** Whether zone ID + purge token are available for edge cache purge. */ + /** Whether native `cache.purge` is available in this runtime. */ configured: boolean; } export interface WorkersCachePurgeResult { - /** Whether zone ID + purge token were available. */ + /** Whether native `cache.purge` was available. */ configured: boolean; - /** True when Cloudflare accepted the purge request. */ + /** True when purge ran and reported success. */ purged: boolean; } -export interface WorkersCacheCredentials { - zoneId: string; - apiToken: string; -} - -function readString(env: Record | undefined, key: string): string | undefined { - if (!env) return undefined; - const value = env[key]; - return typeof value === "string" && value.length > 0 ? value : undefined; +export interface WorkersCachePurgeApi { + purge(options: { + purgeEverything?: boolean; + tags?: string[]; + }): Promise<{ success?: boolean; errors?: { message?: string }[] } | unknown>; } /** - * Resolve Worker/Node env bindings for cache-purge credentials. - * Prefer `virtual:emdash/env` (Cloudflare bindings); fall back to - * `import.meta.env` / `process.env` for Node and tests. + * Resolve the native Workers Caching purge API. + * Injected in tests; at runtime loads `cloudflare:workers` when present. */ -export async function resolveWorkersCacheCredentials( - overrides?: Partial & { - zoneIdEnvVar?: string; - apiTokenEnvVar?: string; - }, -): Promise { - if (overrides?.zoneId && overrides?.apiToken) { - return { zoneId: overrides.zoneId, apiToken: overrides.apiToken }; - } +export async function resolveWorkersCachePurgeApi( + override?: WorkersCachePurgeApi | null, +): Promise { + if (override !== undefined) return override; - let envRecord: Record | undefined; try { - // @ts-ignore - virtual module, generated by the Astro integration - const mod = (await import("virtual:emdash/env")) as { - env?: Record; - }; - envRecord = mod.env; + // Dynamic import so core stays loadable on Node / unit tests. + const mod = await import("cloudflare:workers"); + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- optional newer export + const cache = (mod as { cache?: WorkersCachePurgeApi }).cache; + if (cache && typeof cache.purge === "function") { + return { + purge: (options) => cache.purge(options), + }; + } } catch { - // Outside Vite/Astro (unit tests, CLI) + // Module unavailable outside Workers } - - const metaEnv = - typeof import.meta !== "undefined" - ? // eslint-disable-next-line typescript/no-unsafe-type-assertion -- import.meta.env is a string bag at runtime - (import.meta.env as Record | undefined) - : undefined; - const processEnv = - typeof process !== "undefined" && process.env - ? // eslint-disable-next-line typescript/no-unsafe-type-assertion -- process.env values are string | undefined - (process.env as Record) - : undefined; - - const env = envRecord ?? metaEnv ?? processEnv; - const zoneKey = overrides?.zoneIdEnvVar ?? DEFAULT_ZONE_ID_ENV; - const tokenKey = overrides?.apiTokenEnvVar ?? DEFAULT_API_TOKEN_ENV; - - const zoneId = overrides?.zoneId ?? readString(env, zoneKey); - const apiToken = overrides?.apiToken ?? readString(env, tokenKey); - - if (!zoneId || !apiToken) return null; - return { zoneId, apiToken }; + return null; } /** - * Report whether Workers Cache purge credentials are configured. + * Report whether native Workers Cache purge is available. */ export async function handleWorkersCacheStatus( - credentials?: WorkersCacheCredentials | null, + api?: WorkersCachePurgeApi | null, ): Promise> { try { - const resolved = - credentials === undefined ? await resolveWorkersCacheCredentials() : credentials; + const resolved = api === undefined ? await resolveWorkersCachePurgeApi() : api; return { success: true, data: { configured: resolved !== null } }; } catch { return { @@ -107,15 +76,14 @@ export async function handleWorkersCacheStatus( } /** - * Purge all edge-cached responses for the zone (`purge_everything`). + * Purge all edge-cached responses for this Worker entrypoint + * (`purgeEverything: true`). */ export async function handleWorkersCachePurge( - credentials?: WorkersCacheCredentials | null, - fetchImpl: typeof fetch = fetch, + api?: WorkersCachePurgeApi | null, ): Promise> { try { - const resolved = - credentials === undefined ? await resolveWorkersCacheCredentials() : credentials; + const resolved = api === undefined ? await resolveWorkersCachePurgeApi() : api; if (!resolved) { return { @@ -124,22 +92,15 @@ export async function handleWorkersCachePurge( }; } - const response = await fetchImpl(`${CF_API_BASE}/zones/${resolved.zoneId}/purge_cache`, { - method: "POST", - headers: { - Authorization: `Bearer ${resolved.apiToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ purge_everything: true }), - }); + const result = await resolved.purge({ purgeEverything: true }); - if (!response.ok) { - const body = await response.text().catch(() => ""); + if (isPurgeFailure(result)) { + const detail = formatPurgeErrors(result); return { success: false, error: { code: "WORKERS_CACHE_PURGE_ERROR", - message: `Workers Cache purge failed (${response.status})${body ? `: ${body}` : ""}`, + message: detail ? `Workers Cache purge failed: ${detail}` : "Workers Cache purge failed", }, }; } @@ -159,3 +120,26 @@ export async function handleWorkersCachePurge( }; } } + +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/cloudflare-workers.d.ts b/packages/core/src/cloudflare-workers.d.ts new file mode 100644 index 0000000000..3f07d6326c --- /dev/null +++ b/packages/core/src/cloudflare-workers.d.ts @@ -0,0 +1,17 @@ +/** + * Minimal ambient types for optional `cloudflare:workers` APIs used by core. + * Full types live in @cloudflare/workers-types; this only covers what we call. + */ +declare module "cloudflare:workers" { + export const env: Record; + + export const cache: + | { + purge(options: { + purgeEverything?: boolean; + tags?: string[]; + pathPrefixes?: string[]; + }): Promise<{ success: boolean; errors: { code?: number; message: string }[] }>; + } + | undefined; +} diff --git a/packages/core/src/plugins/types.ts b/packages/core/src/plugins/types.ts index 47286f347e..f572940da0 100644 --- a/packages/core/src/plugins/types.ts +++ b/packages/core/src/plugins/types.ts @@ -537,14 +537,15 @@ export interface CacheAccess { }>; /** - * Whether Workers Cache purge credentials are configured - * (`CF_ZONE_ID` + `CF_CACHE_PURGE_TOKEN`, same as `cloudflareCache()`). + * Whether native Workers Caching purge is available + * (`cache.purge` from `cloudflare:workers`, with wrangler + * `"cache": { "enabled": true }`). */ getWorkersCacheStatus(): Promise<{ configured: boolean }>; /** - * Purge all edge-cached pages for the zone (`purge_everything`). - * Returns `configured: false` when credentials are missing. + * Purge all edge-cached pages for this Worker (`purgeEverything`). + * Returns `configured: false` when native purge is unavailable. */ purgeWorkersCache(): Promise<{ configured: boolean; purged: boolean }>; } diff --git a/packages/core/tests/unit/api/workers-cache-handlers.test.ts b/packages/core/tests/unit/api/workers-cache-handlers.test.ts index 558f44ebd2..dcf58af50e 100644 --- a/packages/core/tests/unit/api/workers-cache-handlers.test.ts +++ b/packages/core/tests/unit/api/workers-cache-handlers.test.ts @@ -1,5 +1,5 @@ /** - * Workers Cache purge handlers. + * Workers Cache purge handlers (native cache.purge). */ import { afterEach, describe, expect, it, vi } from "vitest"; @@ -7,21 +7,22 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { handleWorkersCachePurge, handleWorkersCacheStatus, + type WorkersCachePurgeApi, } from "../../../src/api/handlers/workers-cache.js"; describe("handleWorkersCacheStatus", () => { - it("reports configured:false when credentials are null", async () => { + 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 credentials are provided", async () => { - const result = await handleWorkersCacheStatus({ - zoneId: "zone-1", - apiToken: "token-1", - }); + 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); @@ -33,46 +34,41 @@ describe("handleWorkersCachePurge", () => { vi.restoreAllMocks(); }); - it("returns configured:false without calling Cloudflare when credentials missing", async () => { - const fetchImpl = vi.fn(); - const result = await handleWorkersCachePurge(null, fetchImpl as typeof fetch); + 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 }); - expect(fetchImpl).not.toHaveBeenCalled(); }); - it("posts purge_everything when credentials are present", async () => { - const fetchImpl = vi - .fn() - .mockResolvedValue(new Response(JSON.stringify({ success: true }), { status: 200 })); - const result = await handleWorkersCachePurge( - { zoneId: "zone-abc", apiToken: "tok-xyz" }, - fetchImpl as typeof fetch, - ); + it("calls purgeEverything when API is present", 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(fetchImpl).toHaveBeenCalledOnce(); - const [url, init] = fetchImpl.mock.calls[0] as [string, RequestInit]; - expect(url).toBe("https://api.cloudflare.com/client/v4/zones/zone-abc/purge_cache"); - expect(init.method).toBe("POST"); - expect(init.headers).toMatchObject({ - Authorization: "Bearer tok-xyz", - "Content-Type": "application/json", + expect(purge).toHaveBeenCalledOnce(); + expect(purge).toHaveBeenCalledWith({ purgeEverything: true }); + }); + + it("surfaces purge API failure results", async () => { + const purge = vi.fn().mockResolvedValue({ + success: false, + errors: [{ message: "rate limited" }], }); - expect(JSON.parse(String(init.body))).toEqual({ purge_everything: true }); + 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"); }); - it("surfaces Cloudflare API errors", async () => { - const fetchImpl = vi.fn().mockResolvedValue(new Response("nope", { status: 403 })); - const result = await handleWorkersCachePurge( - { zoneId: "zone-abc", apiToken: "tok-xyz" }, - fetchImpl as typeof fetch, - ); + it("surfaces thrown errors", async () => { + const purge = vi.fn().mockRejectedValue(new Error("boom")); + 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("403"); + expect(result.error.message).toContain("boom"); }); }); diff --git a/packages/core/tests/unit/api/workers-cache-route.test.ts b/packages/core/tests/unit/api/workers-cache-route.test.ts index d5fc44c27d..2d034e5b1d 100644 --- a/packages/core/tests/unit/api/workers-cache-route.test.ts +++ b/packages/core/tests/unit/api/workers-cache-route.test.ts @@ -56,7 +56,7 @@ describe("workers-cache purge route", () => { data: { configured: boolean }; }; expect(json.success).toBe(true); - // No CF credentials in unit tests + // Unit tests run outside Workers — native purge unavailable expect(json.data.configured).toBe(false); }); @@ -70,7 +70,7 @@ describe("workers-cache purge route", () => { expect(response.status).toBe(403); }); - it("POST returns configured:false for admins without credentials", async () => { + 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 { From 8d96fd905c9827c876cf8a0f1b9f35d860cab8ea Mon Sep 17 00:00:00 2001 From: scottbuscemi Date: Wed, 29 Jul 2026 16:10:01 -0500 Subject: [PATCH 5/9] fix(core): resolve Workers Cache purge via virtual module Dynamic import of cloudflare:workers from core failed under Vite. Expose cache through virtual:emdash/workers-cache (same pattern as env and waitUntil) so status/purge work on the Cloudflare adapter. --- .../core/src/api/handlers/workers-cache.ts | 18 ++++++++++-------- .../src/astro/integration/virtual-modules.ts | 18 ++++++++++++++++++ .../core/src/astro/integration/vite-config.ts | 11 +++++++++++ packages/core/src/cloudflare-workers.d.ts | 17 ----------------- packages/core/src/virtual-modules.d.ts | 17 +++++++++++++++++ .../astro/integration/virtual-modules.test.ts | 14 ++++++++++++++ 6 files changed, 70 insertions(+), 25 deletions(-) delete mode 100644 packages/core/src/cloudflare-workers.d.ts diff --git a/packages/core/src/api/handlers/workers-cache.ts b/packages/core/src/api/handlers/workers-cache.ts index 3769051554..d4e01c307d 100644 --- a/packages/core/src/api/handlers/workers-cache.ts +++ b/packages/core/src/api/handlers/workers-cache.ts @@ -1,8 +1,9 @@ /** * Workers Cache (native Workers Caching) status + purge handlers. * - * Uses `cache.purge()` from `cloudflare:workers` — the same platform API as - * wrangler `"cache": { "enabled": true }` / Astro `cacheCloudflare()`. + * 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): @@ -32,7 +33,7 @@ export interface WorkersCachePurgeApi { /** * Resolve the native Workers Caching purge API. - * Injected in tests; at runtime loads `cloudflare:workers` when present. + * Injected in tests; at runtime loads `virtual:emdash/workers-cache`. */ export async function resolveWorkersCachePurgeApi( override?: WorkersCachePurgeApi | null, @@ -40,17 +41,18 @@ export async function resolveWorkersCachePurgeApi( if (override !== undefined) return override; try { - // Dynamic import so core stays loadable on Node / unit tests. - const mod = await import("cloudflare:workers"); - // eslint-disable-next-line typescript/no-unsafe-type-assertion -- optional newer export - const cache = (mod as { cache?: WorkersCachePurgeApi }).cache; + // @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 { - // Module unavailable outside Workers + // Virtual module unavailable outside Vite/Astro } return null; } 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/cloudflare-workers.d.ts b/packages/core/src/cloudflare-workers.d.ts deleted file mode 100644 index 3f07d6326c..0000000000 --- a/packages/core/src/cloudflare-workers.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Minimal ambient types for optional `cloudflare:workers` APIs used by core. - * Full types live in @cloudflare/workers-types; this only covers what we call. - */ -declare module "cloudflare:workers" { - export const env: Record; - - export const cache: - | { - purge(options: { - purgeEverything?: boolean; - tags?: string[]; - pathPrefixes?: string[]; - }): Promise<{ success: boolean; errors: { code?: number; message: string }[] }>; - } - | undefined; -} 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/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 { From 7140df19cdd3d4137bcf0874d20d1a4f42e558d0 Mon Sep 17 00:00:00 2001 From: scottbuscemi Date: Wed, 29 Jul 2026 16:35:58 -0500 Subject: [PATCH 6/9] feat: Workers Cache path-prefix purge POST /admin/cache/workers and ctx.cache.purgeWorkersCache() accept optional pathPrefixes (paths or full URLs, normalized). Empty input still purges everything via cache.purge. --- .changeset/object-cache-purge-api.md | 2 +- packages/cloudflare/src/sandbox/bridge.ts | 8 +- packages/cloudflare/src/sandbox/types.ts | 4 +- packages/cloudflare/src/sandbox/wrapper.ts | 2 +- packages/core/src/api/handlers/index.ts | 2 + .../core/src/api/handlers/workers-cache.ts | 98 ++++++++++++++++++- .../astro/routes/api/admin/cache/workers.ts | 19 +++- packages/core/src/plugins/context.ts | 6 +- packages/core/src/plugins/types.ts | 10 +- .../unit/api/workers-cache-handlers.test.ts | 71 +++++++++++--- .../workerd/src/sandbox/bridge-handler.ts | 7 +- packages/workerd/src/sandbox/wrapper.ts | 2 +- 12 files changed, 198 insertions(+), 33 deletions(-) diff --git a/.changeset/object-cache-purge-api.md b/.changeset/object-cache-purge-api.md index 400bd11b89..b559bdfe1e 100644 --- a/.changeset/object-cache-purge-api.md +++ b/.changeset/object-cache-purge-api.md @@ -7,4 +7,4 @@ "@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({ purgeEverything: true })` — no zone ID or API token). Block Kit buttons also support optional `disabled` and `title` (tooltip) fields. +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/cloudflare/src/sandbox/bridge.ts b/packages/cloudflare/src/sandbox/bridge.ts index 764e847a73..7c24c63e3e 100644 --- a/packages/cloudflare/src/sandbox/bridge.ts +++ b/packages/cloudflare/src/sandbox/bridge.ts @@ -1228,12 +1228,16 @@ export class PluginBridge extends WorkerEntrypoint { + 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(); + const result = await handleWorkersCachePurge({ + pathPrefixes: options?.pathPrefixes, + }); if (!result.success) { throw new Error(result.error.message); } diff --git a/packages/cloudflare/src/sandbox/types.ts b/packages/cloudflare/src/sandbox/types.ts index a5e4fa1e6d..74b481319f 100644 --- a/packages/cloudflare/src/sandbox/types.ts +++ b/packages/cloudflare/src/sandbox/types.ts @@ -218,7 +218,9 @@ export interface PluginBridgeBinding { namespaces?: string[]; }): Promise<{ configured: boolean; active: boolean; purged: string[] }>; getWorkersCacheStatus(): Promise<{ configured: boolean }>; - purgeWorkersCache(): Promise<{ configured: boolean; purged: 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 0a3091c65f..28132b17c5 100644 --- a/packages/cloudflare/src/sandbox/wrapper.ts +++ b/packages/cloudflare/src/sandbox/wrapper.ts @@ -180,7 +180,7 @@ function createContext(env) { getObjectCacheStatus: () => bridge.getObjectCacheStatus(), purgeObjectCache: (options) => bridge.purgeObjectCache(options), getWorkersCacheStatus: () => bridge.getWorkersCacheStatus(), - purgeWorkersCache: () => bridge.purgeWorkersCache() + purgeWorkersCache: (options) => bridge.purgeWorkersCache(options) } : undefined; return { diff --git a/packages/core/src/api/handlers/index.ts b/packages/core/src/api/handlers/index.ts index de6bc726c4..c1c502ce2b 100644 --- a/packages/core/src/api/handlers/index.ts +++ b/packages/core/src/api/handlers/index.ts @@ -175,8 +175,10 @@ export { export { handleWorkersCachePurge, handleWorkersCacheStatus, + normalizeWorkersCachePathPrefix, resolveWorkersCachePurgeApi, type WorkersCachePurgeApi, + type WorkersCachePurgeInput, type WorkersCachePurgeResult, type WorkersCacheStatus, } from "./workers-cache.js"; diff --git a/packages/core/src/api/handlers/workers-cache.ts b/packages/core/src/api/handlers/workers-cache.ts index d4e01c307d..ec846e0c61 100644 --- a/packages/core/src/api/handlers/workers-cache.ts +++ b/packages/core/src/api/handlers/workers-cache.ts @@ -17,20 +17,72 @@ export interface WorkersCacheStatus { 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<{ success?: boolean; errors?: { message?: string }[] } | unknown>; } +/** + * 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 (/^[a-zA-Z][a-zA-Z+\-.]*:\/\//.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`. @@ -78,10 +130,11 @@ export async function handleWorkersCacheStatus( } /** - * Purge all edge-cached responses for this Worker entrypoint - * (`purgeEverything: true`). + * Purge Workers Caching for this entrypoint. + * With `pathPrefixes`, purges those prefixes; otherwise `purgeEverything`. */ export async function handleWorkersCachePurge( + input: WorkersCachePurgeInput = {}, api?: WorkersCachePurgeApi | null, ): Promise> { try { @@ -94,7 +147,40 @@ export async function handleWorkersCachePurge( }; } - const result = await resolved.purge({ purgeEverything: true }); + 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); @@ -109,7 +195,11 @@ export async function handleWorkersCachePurge( return { success: true, - data: { configured: true, purged: true }, + data: { + configured: true, + purged: true, + ...(normalized.length > 0 ? { pathPrefixes: normalized } : {}), + }, }; } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; diff --git a/packages/core/src/astro/routes/api/admin/cache/workers.ts b/packages/core/src/astro/routes/api/admin/cache/workers.ts index 5ebc6be9a8..fc364d253d 100644 --- a/packages/core/src/astro/routes/api/admin/cache/workers.ts +++ b/packages/core/src/astro/routes/api/admin/cache/workers.ts @@ -1,18 +1,24 @@ /** * Workers Cache status + purge endpoint * - * GET /_emdash/api/admin/cache/workers — whether purge credentials are configured - * POST /_emdash/api/admin/cache/workers — purge_everything via Cloudflare API + * 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; @@ -23,12 +29,17 @@ export const GET: APIRoute = async ({ locals }) => { return unwrapResult(result); }; -export const POST: APIRoute = async ({ locals }) => { +export const POST: APIRoute = async ({ request, locals }) => { const { user } = locals; const denied = requirePerm(user, "settings:manage"); if (denied) return denied; - const result = await handleWorkersCachePurge(); + 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/plugins/context.ts b/packages/core/src/plugins/context.ts index 0a078558bc..dae8f5ad51 100644 --- a/packages/core/src/plugins/context.ts +++ b/packages/core/src/plugins/context.ts @@ -983,8 +983,10 @@ export function createCacheAccess(db: Kysely): CacheAccess { } return result.data; }, - async purgeWorkersCache() { - const result = await handleWorkersCachePurge(); + async purgeWorkersCache(options) { + const result = await handleWorkersCachePurge({ + pathPrefixes: options?.pathPrefixes, + }); if (!result.success) { throw new Error(result.error.message); } diff --git a/packages/core/src/plugins/types.ts b/packages/core/src/plugins/types.ts index f572940da0..e002bb0dce 100644 --- a/packages/core/src/plugins/types.ts +++ b/packages/core/src/plugins/types.ts @@ -544,10 +544,16 @@ export interface CacheAccess { getWorkersCacheStatus(): Promise<{ configured: boolean }>; /** - * Purge all edge-cached pages for this Worker (`purgeEverything`). + * 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(): Promise<{ configured: boolean; purged: boolean }>; + purgeWorkersCache(options?: { pathPrefixes?: string[] }): Promise<{ + configured: boolean; + purged: boolean; + pathPrefixes?: string[]; + }>; } // ============================================================================= diff --git a/packages/core/tests/unit/api/workers-cache-handlers.test.ts b/packages/core/tests/unit/api/workers-cache-handlers.test.ts index dcf58af50e..986a07931c 100644 --- a/packages/core/tests/unit/api/workers-cache-handlers.test.ts +++ b/packages/core/tests/unit/api/workers-cache-handlers.test.ts @@ -7,9 +7,37 @@ 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); @@ -35,40 +63,55 @@ describe("handleWorkersCachePurge", () => { }); it("returns configured:false without calling purge when API missing", async () => { - const result = await handleWorkersCachePurge(null); + 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 API is present", async () => { + it("calls purgeEverything when no paths are given", async () => { const purge = vi.fn().mockResolvedValue({ success: true, errors: [] }); - const result = await handleWorkersCachePurge({ purge }); + const result = await handleWorkersCachePurge({}, { purge }); expect(result.success).toBe(true); if (!result.success) return; expect(result.data).toEqual({ configured: true, purged: true }); - expect(purge).toHaveBeenCalledOnce(); 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 }); + 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"); }); - - it("surfaces thrown errors", async () => { - const purge = vi.fn().mockRejectedValue(new Error("boom")); - 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("boom"); - }); }); diff --git a/packages/workerd/src/sandbox/bridge-handler.ts b/packages/workerd/src/sandbox/bridge-handler.ts index 2ef80aa932..fe1098dde8 100644 --- a/packages/workerd/src/sandbox/bridge-handler.ts +++ b/packages/workerd/src/sandbox/bridge-handler.ts @@ -296,7 +296,12 @@ async function dispatch( } case "cache/purgeWorkersCache": { requireCapability(opts, "cache:purge"); - const result = await handleWorkersCachePurge(); + 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); } diff --git a/packages/workerd/src/sandbox/wrapper.ts b/packages/workerd/src/sandbox/wrapper.ts index 4cc0083853..f96c561637 100644 --- a/packages/workerd/src/sandbox/wrapper.ts +++ b/packages/workerd/src/sandbox/wrapper.ts @@ -327,7 +327,7 @@ function createContext() { getObjectCacheStatus: () => bridgeCall("cache/getObjectCacheStatus", {}), purgeObjectCache: (options) => bridgeCall("cache/purgeObjectCache", options || {}), getWorkersCacheStatus: () => bridgeCall("cache/getWorkersCacheStatus", {}), - purgeWorkersCache: () => bridgeCall("cache/purgeWorkersCache", {}), + purgeWorkersCache: (options) => bridgeCall("cache/purgeWorkersCache", options || {}), } : undefined; return { From 65a03f5d4a4eb1a05d3cb332af7b7d597d2aa39d Mon Sep 17 00:00:00 2001 From: scottbuscemi Date: Wed, 29 Jul 2026 16:48:47 -0500 Subject: [PATCH 7/9] fix: lint workers-cache handlers and marketplace capability list Move URL regex to module scope, drop redundant unknown union, rename shadowed Tooltip render prop, and include cache:purge in CAPABILITY_LABELS contract test. --- packages/admin/tests/lib/marketplace.test.ts | 1 + packages/blocks/tests/renderer.test.tsx | 4 ++-- packages/core/src/api/handlers/workers-cache.ts | 7 +++++-- 3 files changed, 8 insertions(+), 4 deletions(-) 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/tests/renderer.test.tsx b/packages/blocks/tests/renderer.test.tsx index 6154e0d6d9..e22405b045 100644 --- a/packages/blocks/tests/renderer.test.tsx +++ b/packages/blocks/tests/renderer.test.tsx @@ -27,8 +27,8 @@ vi.mock("@cloudflare/kumo", () => ({ ), TooltipProvider: ({ children }: any) => <>{children}, - Tooltip: ({ content, children, render }: any) => { - const trigger = render ?? ; + Tooltip: ({ content, children, render: triggerRender }: any) => { + const trigger = triggerRender ?? ; return (
{React.isValidElement(trigger) diff --git a/packages/core/src/api/handlers/workers-cache.ts b/packages/core/src/api/handlers/workers-cache.ts index ec846e0c61..9f9590a63c 100644 --- a/packages/core/src/api/handlers/workers-cache.ts +++ b/packages/core/src/api/handlers/workers-cache.ts @@ -39,9 +39,12 @@ export interface WorkersCachePurgeApi { purgeEverything?: boolean; tags?: string[]; pathPrefixes?: string[]; - }): Promise<{ success?: boolean; errors?: { message?: string }[] } | unknown>; + }): 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. @@ -55,7 +58,7 @@ export function normalizeWorkersCachePathPrefix( } let path: string; - if (/^[a-zA-Z][a-zA-Z+\-.]*:\/\//.test(trimmed)) { + if (ABSOLUTE_URL_RE.test(trimmed)) { try { const url = new URL(trimmed); path = url.pathname || "/"; From 87e6c4718d71a1609d3c1e5184d1f8e23d3b112f Mon Sep 17 00:00:00 2001 From: scottbuscemi Date: Thu, 30 Jul 2026 16:17:01 -0500 Subject: [PATCH 8/9] fix: address emdashbot review on cache:purge trust contract Add cache.purge to declaredAccessSchema in core and plugin-types so reconcileManifestAccess does not strip cache:purge. Restore createUserAccess JSDoc. Reject protocol-relative URLs in path normalize. --- packages/core/src/api/handlers/workers-cache.ts | 3 +++ packages/core/src/plugins/context.ts | 8 ++++---- packages/core/src/plugins/manifest-schema.ts | 1 + .../core/tests/unit/api/workers-cache-handlers.test.ts | 7 +++++++ packages/plugin-types/src/manifest-schema.ts | 1 + 5 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/core/src/api/handlers/workers-cache.ts b/packages/core/src/api/handlers/workers-cache.ts index 9f9590a63c..e1b6a97798 100644 --- a/packages/core/src/api/handlers/workers-cache.ts +++ b/packages/core/src/api/handlers/workers-cache.ts @@ -53,6 +53,9 @@ 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" }; } diff --git a/packages/core/src/plugins/context.ts b/packages/core/src/plugins/context.ts index dae8f5ad51..863df93d6d 100644 --- a/packages/core/src/plugins/context.ts +++ b/packages/core/src/plugins/context.ts @@ -951,10 +951,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`. */ @@ -995,6 +991,10 @@ export function createCacheAccess(db: Kysely): CacheAccess { }; } +/** + * Create read-only user access for plugins. + * Excludes sensitive fields (password hashes, sessions, passkeys, avatar URL, data). + */ export function createUserAccess(db: Kysely): UserAccess { const userRepo = new UserRepository(db); diff --git a/packages/core/src/plugins/manifest-schema.ts b/packages/core/src/plugins/manifest-schema.ts index 341f0416c3..479439e5ce 100644 --- a/packages/core/src/plugins/manifest-schema.ts +++ b/packages/core/src/plugins/manifest-schema.ts @@ -292,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/tests/unit/api/workers-cache-handlers.test.ts b/packages/core/tests/unit/api/workers-cache-handlers.test.ts index 986a07931c..b07020b3b3 100644 --- a/packages/core/tests/unit/api/workers-cache-handlers.test.ts +++ b/packages/core/tests/unit/api/workers-cache-handlers.test.ts @@ -36,6 +36,13 @@ describe("normalizeWorkersCachePathPrefix", () => { 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", () => { diff --git a/packages/plugin-types/src/manifest-schema.ts b/packages/plugin-types/src/manifest-schema.ts index ba56c50749..55cfe8c469 100644 --- a/packages/plugin-types/src/manifest-schema.ts +++ b/packages/plugin-types/src/manifest-schema.ts @@ -267,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 ───────────────────────────────────────────────── From 3f6795f7a3ebc7e3d4cd0fa3cf12cabfeee90a83 Mon Sep 17 00:00:00 2001 From: scottbuscemi Date: Thu, 30 Jul 2026 16:19:54 -0500 Subject: [PATCH 9/9] fix: address emdashbot review on #2297 RFC 3986 absolute-URL scheme class (digits allowed), export CacheAccess from the emdash barrel, and clarify cache purge route comment. JSDoc orphan was already fixed. --- packages/core/src/api/handlers/workers-cache.ts | 4 ++-- packages/core/src/astro/integration/routes.ts | 2 +- packages/core/src/index.ts | 1 + packages/core/src/plugins/index.ts | 2 +- .../core/tests/unit/api/workers-cache-handlers.test.ts | 7 +++++++ 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/core/src/api/handlers/workers-cache.ts b/packages/core/src/api/handlers/workers-cache.ts index e1b6a97798..8ee1b3a1bc 100644 --- a/packages/core/src/api/handlers/workers-cache.ts +++ b/packages/core/src/api/handlers/workers-cache.ts @@ -42,8 +42,8 @@ export interface WorkersCachePurgeApi { }): Promise; } -/** Matches an absolute URL scheme prefix (e.g. `https://`). */ -const ABSOLUTE_URL_RE = /^[a-zA-Z][a-zA-Z+\-.]*:\/\//; +/** 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. diff --git a/packages/core/src/astro/integration/routes.ts b/packages/core/src/astro/integration/routes.ts index 26359deb95..5845723432 100644 --- a/packages/core/src/astro/integration/routes.ts +++ b/packages/core/src/astro/integration/routes.ts @@ -350,7 +350,7 @@ export function injectCoreRoutes( entrypoint: resolveRoute("api/settings.ts"), }); - // Object-cache purge (KV / memory CMS read cache) + // Cache purge routes (object cache + native Workers Caching) injectRoute({ pattern: "/_emdash/api/admin/cache/object", entrypoint: resolveRoute("api/admin/cache/object.ts"), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 405b3d3483..2d466e419f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -253,6 +253,7 @@ export type { MediaAccess, HttpAccess, LogAccess, + CacheAccess, TaxonomyAccess, TaxonomyDefInfo, TaxonomyTermInfo, diff --git a/packages/core/src/plugins/index.ts b/packages/core/src/plugins/index.ts index 5e8383d90e..948ca06c24 100644 --- a/packages/core/src/plugins/index.ts +++ b/packages/core/src/plugins/index.ts @@ -212,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/tests/unit/api/workers-cache-handlers.test.ts b/packages/core/tests/unit/api/workers-cache-handlers.test.ts index b07020b3b3..7c46ca838f 100644 --- a/packages/core/tests/unit/api/workers-cache-handlers.test.ts +++ b/packages/core/tests/unit/api/workers-cache-handlers.test.ts @@ -30,6 +30,13 @@ describe("normalizeWorkersCachePathPrefix", () => { }); }); + 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,