From e7f37a458ea677916dfbc90552bc94f6c4908c67 Mon Sep 17 00:00:00 2001 From: swissky <30409887+swissky@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:10:18 +0200 Subject: [PATCH 1/2] feat(plugins): opt-in Cache-Control for public plugin routes Public routes can set cacheControl to have successful GET/HEAD responses carry that Cache-Control value, enabling CDN/browser caching for endpoints that serve identical data to every visitor. Guardrails: route metadata only exposes cacheControl for public routes, so authenticated responses always keep private, no-store; errors and non-GET methods keep the default. --- .changeset/plugin-route-cache-control.md | 5 ++ .../plugins/creating-plugins/api-routes.mdx | 16 ++++ .../api/plugins/[pluginId]/[...path].ts | 10 ++- packages/core/src/plugins/routes.ts | 13 ++- packages/core/src/plugins/types.ts | 7 ++ .../unit/astro/plugin-api-route-cache.test.ts | 82 +++++++++++++++++++ .../core/tests/unit/plugins/routes.test.ts | 27 ++++++ 7 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 .changeset/plugin-route-cache-control.md create mode 100644 packages/core/tests/unit/astro/plugin-api-route-cache.test.ts diff --git a/.changeset/plugin-route-cache-control.md b/.changeset/plugin-route-cache-control.md new file mode 100644 index 0000000000..a7b99f6a2a --- /dev/null +++ b/.changeset/plugin-route-cache-control.md @@ -0,0 +1,5 @@ +--- +"emdash": minor +--- + +Adds a `cacheControl` option for public plugin routes: successful GET responses carry the configured `Cache-Control` header, enabling CDN and browser caching for public plugin endpoints. Private routes and errors keep the `private, no-store` default. diff --git a/docs/src/content/docs/plugins/creating-plugins/api-routes.mdx b/docs/src/content/docs/plugins/creating-plugins/api-routes.mdx index e8d182fd96..4e704c97d1 100644 --- a/docs/src/content/docs/plugins/creating-plugins/api-routes.mdx +++ b/docs/src/content/docs/plugins/creating-plugins/api-routes.mdx @@ -95,6 +95,22 @@ routes: { Public routes skip authentication, scope, and CSRF entirely. Anyone on the internet can call them. Use `public: true` only for endpoints that need to accept external traffic — webhooks, public-facing search endpoints — and validate the input carefully. +### Caching public responses + +API responses default to `Cache-Control: private, no-store`. For public routes that serve the same data to everyone — a product catalog, a public search index — that means every page view pays a full round-trip to the origin. Public routes can opt in to CDN/browser caching with `cacheControl` (native format only): + +```typescript +routes: { + catalog: { + public: true, + cacheControl: "public, max-age=60, stale-while-revalidate=300", + handler: async (ctx) => listProducts(ctx), + }, +}, +``` + +The header is applied only to **successful GET responses of public routes**. Errors are never cached, other methods keep the default, and setting `cacheControl` on a private route has no effect — authenticated responses always stay `private, no-store`. + ## Input validation `input` accepts a Zod schema. The dispatcher parses the request body (POST/PUT/PATCH) or query string (GET/DELETE), validates it, and passes the typed result to your handler as `routeCtx.input`. Invalid input returns a 400 before your handler runs. diff --git a/packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].ts b/packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].ts index bdd852aa82..c062fcbfef 100644 --- a/packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].ts +++ b/packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].ts @@ -83,7 +83,15 @@ const handleRequest: APIRoute = async ({ params, request, locals }) => { return apiError(code, message, status); } - return apiSuccess(result.data); + const response = apiSuccess(result.data); + // Public routes may opt in to CDN/browser caching for GET responses. + // getRouteMeta only ever exposes cacheControl on public routes, and errors + // above keep the default private, no-store. Astro serves HEAD via this GET + // export, which is fine: same headers, no body. + if (routeMeta.cacheControl && (method === "GET" || method === "HEAD")) { + response.headers.set("Cache-Control", routeMeta.cacheControl); + } + return response; }; // Export handlers for all HTTP methods diff --git a/packages/core/src/plugins/routes.ts b/packages/core/src/plugins/routes.ts index 24b02a58df..a2bcb71846 100644 --- a/packages/core/src/plugins/routes.ts +++ b/packages/core/src/plugins/routes.ts @@ -54,6 +54,11 @@ function guardConsumedRequestBody(request: Request): Request { */ export interface RouteMeta { public: boolean; + /** + * Cache-Control value for successful GET responses. Only ever set for + * public routes — authenticated responses must stay `private, no-store`. + */ + cacheControl?: string; } /** @@ -199,7 +204,13 @@ export class PluginRouteHandler { getRouteMeta(name: string): RouteMeta | null { const route: PluginRoute | undefined = this.plugin.routes[name]; if (!route) return null; - return { public: route.public === true }; + const meta: RouteMeta = { public: route.public === true }; + // Expose cacheControl only for public routes: private responses are + // per-user and must never become cacheable, even if a route sets both. + if (meta.public && typeof route.cacheControl === "string" && route.cacheControl.length > 0) { + meta.cacheControl = route.cacheControl; + } + return meta; } } diff --git a/packages/core/src/plugins/types.ts b/packages/core/src/plugins/types.ts index 981172f0c1..7cf7989ee6 100644 --- a/packages/core/src/plugins/types.ts +++ b/packages/core/src/plugins/types.ts @@ -1173,6 +1173,13 @@ export interface PluginRoute { * Public routes skip session/token auth and CSRF checks. */ public?: boolean; + /** + * `Cache-Control` header value for successful GET responses, e.g. + * `"public, max-age=60, stale-while-revalidate=300"`. Only honored on + * routes that are also `public: true` — authenticated responses always + * keep the default `private, no-store`. Errors are never cached. + */ + cacheControl?: string; /** Route handler */ handler: (ctx: RouteContext) => Promise; } diff --git a/packages/core/tests/unit/astro/plugin-api-route-cache.test.ts b/packages/core/tests/unit/astro/plugin-api-route-cache.test.ts new file mode 100644 index 0000000000..e9fbb1cf0b --- /dev/null +++ b/packages/core/tests/unit/astro/plugin-api-route-cache.test.ts @@ -0,0 +1,82 @@ +/** + * Cache-Control for the plugin API catch-all (`/_emdash/api/plugins/{id}/*`). + * + * Public routes may opt in to caching via `cacheControl` on the route + * definition. The header must only appear on successful GET/HEAD responses of + * public routes — everything else keeps the API default `private, no-store`. + */ + +import type { APIRoute } from "astro"; +import { describe, expect, it, vi } from "vitest"; + +import { GET, POST } from "../../../src/astro/routes/api/plugins/[pluginId]/[...path].js"; + +const CACHE_VALUE = "public, max-age=60, stale-while-revalidate=300"; + +function createLocals({ + cacheControl, + result = { success: true, data: { ok: true } }, +}: { + cacheControl?: string; + result?: unknown; +} = {}) { + const handlePluginApiRoute = vi.fn(async () => result); + return { + locals: { + user: null, + emdash: { + handlePluginApiRoute, + // Mirrors getRouteMeta: cacheControl is only ever present on public routes. + getPluginRouteMeta: () => ({ public: true, cacheControl }), + }, + }, + handlePluginApiRoute, + }; +} + +function invoke(handler: APIRoute, method: string, locals: unknown) { + const request = new Request("https://example.com/_emdash/api/plugins/demo/catalog", { method }); + return handler({ + params: { pluginId: "demo", path: "catalog" }, + request, + locals, + } as never); +} + +describe("plugin API catch-all Cache-Control", () => { + it("sets the route's Cache-Control on a successful public GET", async () => { + const { locals } = createLocals({ cacheControl: CACHE_VALUE }); + const res = await invoke(GET, "GET", locals); + expect(res.status).toBe(200); + expect(res.headers.get("Cache-Control")).toBe(CACHE_VALUE); + }); + + it("sets the route's Cache-Control on HEAD (dispatched to the GET export)", async () => { + const { locals } = createLocals({ cacheControl: CACHE_VALUE }); + const res = await invoke(GET, "HEAD", locals); + expect(res.status).toBe(200); + expect(res.headers.get("Cache-Control")).toBe(CACHE_VALUE); + }); + + it("keeps the private, no-store default when the route sets no cacheControl", async () => { + const { locals } = createLocals(); + const res = await invoke(GET, "GET", locals); + expect(res.headers.get("Cache-Control")).toBe("private, no-store"); + }); + + it("keeps the private, no-store default on POST even when cacheControl is set", async () => { + const { locals } = createLocals({ cacheControl: CACHE_VALUE }); + const res = await invoke(POST, "POST", locals); + expect(res.headers.get("Cache-Control")).toBe("private, no-store"); + }); + + it("never caches error responses", async () => { + const { locals } = createLocals({ + cacheControl: CACHE_VALUE, + result: { success: false, status: 404, error: { code: "NOT_FOUND", message: "nope" } }, + }); + const res = await invoke(GET, "GET", locals); + expect(res.status).toBe(404); + expect(res.headers.get("Cache-Control")).toBe("private, no-store"); + }); +}); diff --git a/packages/core/tests/unit/plugins/routes.test.ts b/packages/core/tests/unit/plugins/routes.test.ts index 1187cd54ef..13b6d83946 100644 --- a/packages/core/tests/unit/plugins/routes.test.ts +++ b/packages/core/tests/unit/plugins/routes.test.ts @@ -173,6 +173,33 @@ describe("PluginRouteHandler", () => { const meta = handler.getRouteMeta("admin"); expect(meta).toEqual({ public: false }); }); + + it("exposes cacheControl for public routes", () => { + const plugin = createTestPlugin({ + routes: { + catalog: { public: true, cacheControl: "public, max-age=60", handler: vi.fn() }, + }, + }); + const handler = new PluginRouteHandler(plugin, createMockFactoryOptions()); + + expect(handler.getRouteMeta("catalog")).toEqual({ + public: true, + cacheControl: "public, max-age=60", + }); + }); + + it("never exposes cacheControl for private routes", () => { + // Private responses are per-user; a route that sets both flags must + // not become cacheable. + const plugin = createTestPlugin({ + routes: { + admin: { cacheControl: "public, max-age=60", handler: vi.fn() }, + }, + }); + const handler = new PluginRouteHandler(plugin, createMockFactoryOptions()); + + expect(handler.getRouteMeta("admin")).toEqual({ public: false }); + }); }); describe("getRouteNames", () => { From 08d848fd8cd9dc1827da13e7d96a1db2c2b1c174 Mon Sep 17 00:00:00 2001 From: swissky <30409887+swissky@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:56:58 +0200 Subject: [PATCH 2/2] fix(plugins): wire cacheControl through runtime meta and all plugin formats Review follow-up: EmDashRuntime.getPluginRouteMeta rebuilt metadata inline and dropped cacheControl, so the header was never applied. Extracted buildRouteMeta() as the single source of the public-only invariant and used it for trusted routes and all four sandboxed route-meta cache sites. Extends the surface to every plugin format: manifest schema + ManifestRouteEntry carry cacheControl, extractManifest emits structured entries, adaptSandboxEntry threads the field, and the bundle CLI probe preserves it. Fixes two stale getPluginRouteMeta return types. Adds an integration test exercising the real runtime path (ResolvedPlugin -> runtime.getPluginRouteMeta -> catch-all handler) that fails without the runtime fix. --- .changeset/plugin-route-cache-control.md | 3 +- .../plugins/creating-plugins/api-routes.mdx | 2 +- .../src/astro/public-plugin-api-routes.ts | 3 +- packages/core/src/astro/types.ts | 6 +- .../core/src/cli/commands/bundle-utils.ts | 11 +- packages/core/src/cli/commands/bundle.ts | 1 + packages/core/src/emdash-runtime.ts | 12 +- packages/core/src/plugin-types.ts | 6 + .../core/src/plugins/adapt-sandbox-entry.ts | 5 +- packages/core/src/plugins/manifest-schema.ts | 6 +- packages/core/src/plugins/routes.ts | 23 ++-- .../plugin-route-cache-control.test.ts | 114 ++++++++++++++++++ .../core/tests/unit/cli/bundle-utils.test.ts | 17 +++ .../unit/plugins/adapt-sandbox-entry.test.ts | 17 +++ .../unit/plugins/manifest-schema.test.ts | 16 +++ packages/plugin-types/src/index.ts | 5 + 16 files changed, 226 insertions(+), 21 deletions(-) create mode 100644 packages/core/tests/integration/runtime/plugin-route-cache-control.test.ts diff --git a/.changeset/plugin-route-cache-control.md b/.changeset/plugin-route-cache-control.md index a7b99f6a2a..b8bec8036c 100644 --- a/.changeset/plugin-route-cache-control.md +++ b/.changeset/plugin-route-cache-control.md @@ -1,5 +1,6 @@ --- "emdash": minor +"@emdash-cms/plugin-types": minor --- -Adds a `cacheControl` option for public plugin routes: successful GET responses carry the configured `Cache-Control` header, enabling CDN and browser caching for public plugin endpoints. Private routes and errors keep the `private, no-store` default. +Adds a `cacheControl` option for public plugin routes: successful GET responses carry the configured `Cache-Control` header, enabling CDN and browser caching for public plugin endpoints. Works for native, standard, and marketplace plugin formats. Private routes and errors keep the `private, no-store` default. diff --git a/docs/src/content/docs/plugins/creating-plugins/api-routes.mdx b/docs/src/content/docs/plugins/creating-plugins/api-routes.mdx index 4e704c97d1..b2ef074249 100644 --- a/docs/src/content/docs/plugins/creating-plugins/api-routes.mdx +++ b/docs/src/content/docs/plugins/creating-plugins/api-routes.mdx @@ -97,7 +97,7 @@ routes: { ### Caching public responses -API responses default to `Cache-Control: private, no-store`. For public routes that serve the same data to everyone — a product catalog, a public search index — that means every page view pays a full round-trip to the origin. Public routes can opt in to CDN/browser caching with `cacheControl` (native format only): +API responses default to `Cache-Control: private, no-store`. For public routes that serve the same data to everyone — a product catalog, a public search index — that means every page view pays a full round-trip to the origin. Public routes can opt in to CDN/browser caching with `cacheControl`: ```typescript routes: { diff --git a/packages/core/src/astro/public-plugin-api-routes.ts b/packages/core/src/astro/public-plugin-api-routes.ts index 2fa17ca39b..0d1a0a0f28 100644 --- a/packages/core/src/astro/public-plugin-api-routes.ts +++ b/packages/core/src/astro/public-plugin-api-routes.ts @@ -1,3 +1,4 @@ +import type { RouteMeta } from "../plugins/routes.js"; import type { HandlerResponse } from "./types.js"; export type PublicPluginApiRouteHandler = ( @@ -8,7 +9,7 @@ export type PublicPluginApiRouteHandler = ( ) => Promise; interface PublicPluginApiRouteRuntime { - getPluginRouteMeta(pluginId: string, path: string): { public: boolean } | null; + getPluginRouteMeta(pluginId: string, path: string): RouteMeta | null; handlePluginApiRoute( pluginId: string, method: string, diff --git a/packages/core/src/astro/types.ts b/packages/core/src/astro/types.ts index f3cd045a5e..5d4f83b3c9 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -8,6 +8,8 @@ import type { Element } from "@emdash-cms/blocks"; import type { Kysely } from "kysely"; +import type { RouteMeta } from "../plugins/routes.js"; + // Re-export core types export type { ContentItem, @@ -410,8 +412,8 @@ export interface EmDashHandlers { request: Request, ) => Promise; - // Plugin route metadata (for auth decisions before dispatch) - getPluginRouteMeta: (pluginId: string, path: string) => { public: boolean } | null; + // Plugin route metadata (for auth/caching decisions before dispatch) + getPluginRouteMeta: (pluginId: string, path: string) => RouteMeta | null; // Media provider handlers getMediaProvider: (providerId: string) => import("../media/types.js").MediaProvider | undefined; diff --git a/packages/core/src/cli/commands/bundle-utils.ts b/packages/core/src/cli/commands/bundle-utils.ts index 21b45031ef..a8afee84f7 100644 --- a/packages/core/src/cli/commands/bundle-utils.ts +++ b/packages/core/src/cli/commands/bundle-utils.ts @@ -19,6 +19,7 @@ import type { ResolvedPlugin, HookName, ManifestHookEntry, + ManifestRouteEntry, } from "../../plugins/types.js"; // ── Constants ──────────────────────────────────────────────────────────────── @@ -157,7 +158,15 @@ export function extractManifest(plugin: ResolvedPlugin): PluginManifest { allowedHosts: plugin.allowedHosts, storage: plugin.storage, hooks, - routes: Object.keys(plugin.routes), + // Emit structured entries when a route carries metadata the host needs + // for auth/caching decisions (public, cacheControl); plain names otherwise. + routes: Object.entries(plugin.routes).map(([name, route]) => { + if (!route.public && !route.cacheControl) return name; + const entry: ManifestRouteEntry = { name }; + if (route.public) entry.public = true; + if (route.cacheControl) entry.cacheControl = route.cacheControl; + return entry; + }), admin: { // Omit entry (it's a module specifier for the host, not relevant in bundles) settingsSchema: plugin.admin.settingsSchema, diff --git a/packages/core/src/cli/commands/bundle.ts b/packages/core/src/cli/commands/bundle.ts index 0ab19e16d6..51d10da4d5 100644 --- a/packages/core/src/cli/commands/bundle.ts +++ b/packages/core/src/cli/commands/bundle.ts @@ -300,6 +300,7 @@ export const bundleCommand = defineCommand({ (resolvedPlugin.routes as Record)[name] = { handler: routeObj.handler, public: routeObj.public, + cacheControl: routeObj.cacheControl, }; } } diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index ecf6fa0c82..c68e4e91d9 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -175,7 +175,7 @@ import { } from "./plugins/hooks.js"; import { normalizeManifestRoute } from "./plugins/manifest-schema.js"; import { extractRequestMeta, sanitizeHeadersForSandbox } from "./plugins/request-meta.js"; -import { PluginRouteRegistry, type RouteMeta } from "./plugins/routes.js"; +import { buildRouteMeta, PluginRouteRegistry, type RouteMeta } from "./plugins/routes.js"; import type { CronScheduler } from "./plugins/scheduler/types.js"; import { PluginStateRepository } from "./plugins/state.js"; import { normalizeRegistryConfig } from "./registry/config.js"; @@ -877,7 +877,7 @@ export class EmDashRuntime { const routeMetaMap = new Map(); for (const entry of bundle.manifest.routes) { const normalized = normalizeManifestRoute(entry); - routeMetaMap.set(normalized.name, { public: normalized.public === true }); + routeMetaMap.set(normalized.name, buildRouteMeta(normalized)); } sandboxedRouteMetaCache.set(pluginId, routeMetaMap); } else { @@ -989,7 +989,7 @@ export class EmDashRuntime { const routeMetaMap = new Map(); for (const entry of bundle.manifest.routes) { const normalized = normalizeManifestRoute(entry); - routeMetaMap.set(normalized.name, { public: normalized.public === true }); + routeMetaMap.set(normalized.name, buildRouteMeta(normalized)); } sandboxedRouteMetaCache.set(pluginId, routeMetaMap); } else { @@ -2010,7 +2010,7 @@ export class EmDashRuntime { const routeMeta = new Map(); for (const entry of bundle.manifest.routes) { const normalized = normalizeManifestRoute(entry); - routeMeta.set(normalized.name, { public: normalized.public === true }); + routeMeta.set(normalized.name, buildRouteMeta(normalized)); } sandboxedRouteMetaCache.set(plugin.pluginId, routeMeta); } @@ -2079,7 +2079,7 @@ export class EmDashRuntime { const routeMeta = new Map(); for (const entry of bundle.manifest.routes) { const normalized = normalizeManifestRoute(entry); - routeMeta.set(normalized.name, { public: normalized.public === true }); + routeMeta.set(normalized.name, buildRouteMeta(normalized)); } sandboxedRouteMetaCache.set(plugin.pluginId, routeMeta); } @@ -3286,7 +3286,7 @@ export class EmDashRuntime { if (trustedPlugin) { const route = trustedPlugin.routes[routeKey]; if (!route) return null; - return { public: route.public === true }; + return buildRouteMeta(route); } // Check sandboxed plugin route metadata cache diff --git a/packages/core/src/plugin-types.ts b/packages/core/src/plugin-types.ts index 2950f0194c..2343e3c537 100644 --- a/packages/core/src/plugin-types.ts +++ b/packages/core/src/plugin-types.ts @@ -200,6 +200,12 @@ export type RouteEntry = | { handler: RouteHandler; public?: boolean; + /** + * Cache-Control value for successful GET responses. Only honored on + * routes that are also `public: true` — authenticated responses + * always keep `private, no-store`. + */ + cacheControl?: string; input?: unknown; }; diff --git a/packages/core/src/plugins/adapt-sandbox-entry.ts b/packages/core/src/plugins/adapt-sandbox-entry.ts index 760b4e42c0..38f6c51060 100644 --- a/packages/core/src/plugins/adapt-sandbox-entry.ts +++ b/packages/core/src/plugins/adapt-sandbox-entry.ts @@ -108,6 +108,7 @@ function resolveSandboxedHook(entry: AnyHookEntry, pluginId: string): ResolvedHo function normalizeRouteEntry(entry: RouteEntry): { handler: RouteHandler; public?: boolean; + cacheControl?: string; input?: PluginRoute["input"]; } { if (typeof entry === "function") { @@ -116,6 +117,7 @@ function normalizeRouteEntry(entry: RouteEntry): { return { handler: entry.handler, public: entry.public, + cacheControl: entry.cacheControl, // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- RouteEntry.input is intentionally `unknown` (sandboxed plugins) and validated by the runtime at invocation time input: entry.input as PluginRoute["input"], }; @@ -196,10 +198,11 @@ export function adaptSandboxEntry( if (definition.routes) { for (const [routeName, rawEntry] of Object.entries(definition.routes)) { const normalized = normalizeRouteEntry(rawEntry); - const { handler, public: publicFlag, input: inputSchema } = normalized; + const { handler, public: publicFlag, cacheControl, input: inputSchema } = normalized; resolvedRoutes[routeName] = { input: inputSchema, public: publicFlag, + cacheControl, handler: async (ctx) => { // `ctx.request` is a real WHATWG `Request` (this is the // in-process adapter; the worker-sandbox adapter handles diff --git a/packages/core/src/plugins/manifest-schema.ts b/packages/core/src/plugins/manifest-schema.ts index da6716ecf6..7f3741014a 100644 --- a/packages/core/src/plugins/manifest-schema.ts +++ b/packages/core/src/plugins/manifest-schema.ts @@ -137,6 +137,7 @@ const routeNamePattern = /^[a-zA-Z0-9][a-zA-Z0-9_\-/]*$/; const manifestRouteEntrySchema = z.object({ name: z.string().min(1).regex(routeNamePattern, "Route name must be a safe path segment"), public: z.boolean().optional(), + cacheControl: z.string().min(1).optional(), }); // ── Sub-schemas ───────────────────────────────────────────────── @@ -349,9 +350,12 @@ export function normalizeManifestHook( /** * Normalize a manifest route entry — plain strings become `{ name }` objects. */ -export function normalizeManifestRoute(entry: string | { name: string; public?: boolean }): { +export function normalizeManifestRoute( + entry: string | { name: string; public?: boolean; cacheControl?: string }, +): { name: string; public?: boolean; + cacheControl?: string; } { if (typeof entry === "string") { return { name: entry }; diff --git a/packages/core/src/plugins/routes.ts b/packages/core/src/plugins/routes.ts index a2bcb71846..5a0f6c8590 100644 --- a/packages/core/src/plugins/routes.ts +++ b/packages/core/src/plugins/routes.ts @@ -61,6 +61,21 @@ export interface RouteMeta { cacheControl?: string; } +/** + * Build RouteMeta from a route's `public`/`cacheControl` flags. Single source + * of truth for the "cacheControl is only ever exposed on public routes" + * invariant — used for trusted routes and manifest-declared sandboxed routes. + */ +export function buildRouteMeta(route: { public?: boolean; cacheControl?: string }): RouteMeta { + const meta: RouteMeta = { public: route.public === true }; + // Private responses are per-user and must never become cacheable, even if + // a route sets both flags. + if (meta.public && typeof route.cacheControl === "string" && route.cacheControl.length > 0) { + meta.cacheControl = route.cacheControl; + } + return meta; +} + /** * Result from a route invocation */ @@ -204,13 +219,7 @@ export class PluginRouteHandler { getRouteMeta(name: string): RouteMeta | null { const route: PluginRoute | undefined = this.plugin.routes[name]; if (!route) return null; - const meta: RouteMeta = { public: route.public === true }; - // Expose cacheControl only for public routes: private responses are - // per-user and must never become cacheable, even if a route sets both. - if (meta.public && typeof route.cacheControl === "string" && route.cacheControl.length > 0) { - meta.cacheControl = route.cacheControl; - } - return meta; + return buildRouteMeta(route); } } diff --git a/packages/core/tests/integration/runtime/plugin-route-cache-control.test.ts b/packages/core/tests/integration/runtime/plugin-route-cache-control.test.ts new file mode 100644 index 0000000000..f9cba19d70 --- /dev/null +++ b/packages/core/tests/integration/runtime/plugin-route-cache-control.test.ts @@ -0,0 +1,114 @@ +/** + * End-to-end wiring for plugin-route `cacheControl`. + * + * The route-layer unit tests mock `getPluginRouteMeta`; this test exercises + * the real path — a `ResolvedPlugin` with `cacheControl` registered on + * `EmDashRuntime`, metadata resolved via `runtime.getPluginRouteMeta`, and the + * catch-all handler applying the header — so a runtime that drops the field + * (the original review finding) fails here. + */ + +import { randomUUID } from "node:crypto"; + +import Database from "better-sqlite3"; +import { SqliteDialect } from "kysely"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { GET, POST } from "../../../src/astro/routes/api/plugins/[pluginId]/[...path].js"; +import { EmDashRuntime } from "../../../src/emdash-runtime.js"; +import type { RuntimeDependencies } from "../../../src/emdash-runtime.js"; +import { definePlugin } from "../../../src/plugins/define-plugin.js"; + +const CACHE_VALUE = "public, max-age=60, stale-while-revalidate=300"; + +function createDeps(): RuntimeDependencies { + const entrypoint = `test-plugin-cache-control-${randomUUID()}`; + return { + config: { + database: { entrypoint, config: {}, type: "sqlite" }, + storage: { entrypoint, config: {} }, + }, + plugins: [ + definePlugin({ + id: "cache-demo", + version: "1.0.0", + capabilities: [], + routes: { + catalog: { + public: true, + cacheControl: CACHE_VALUE, + handler: async () => ({ items: [] }), + }, + uncached: { + public: true, + handler: async () => ({ items: [] }), + }, + // Misconfigured on purpose: cacheControl on a private route + // must never surface. + admin: { + cacheControl: CACHE_VALUE, + handler: async () => ({ secret: true }), + }, + }, + }), + ], + createDialect: () => new SqliteDialect({ database: new Database(":memory:") }), + createStorage: null, + sandboxEnabled: false, + sandboxedPluginEntries: [], + createSandboxRunner: null, + }; +} + +function invokeCatchAll(runtime: EmDashRuntime, routeName: string, method: string) { + const request = new Request(`http://test.local/_emdash/api/plugins/cache-demo/${routeName}`, { + method, + }); + const handler = method === "POST" ? POST : GET; + return handler({ + params: { pluginId: "cache-demo", path: routeName }, + request, + locals: { user: null, emdash: runtime }, + } as never); +} + +describe("plugin route cacheControl — runtime wiring", () => { + let runtime: EmDashRuntime; + + beforeAll(async () => { + runtime = await EmDashRuntime.create(createDeps()); + }); + + afterAll(async () => { + await runtime.stopCron(); + }); + + it("runtime.getPluginRouteMeta carries cacheControl for public trusted routes", () => { + expect(runtime.getPluginRouteMeta("cache-demo", "/catalog")).toEqual({ + public: true, + cacheControl: CACHE_VALUE, + }); + }); + + it("runtime.getPluginRouteMeta never exposes cacheControl for private routes", () => { + expect(runtime.getPluginRouteMeta("cache-demo", "/admin")).toEqual({ public: false }); + }); + + it("applies the header end-to-end on a public GET through the catch-all", async () => { + const res = await invokeCatchAll(runtime, "catalog", "GET"); + expect(res.status).toBe(200); + expect(res.headers.get("Cache-Control")).toBe(CACHE_VALUE); + }); + + it("keeps the private, no-store default without the option", async () => { + const res = await invokeCatchAll(runtime, "uncached", "GET"); + expect(res.status).toBe(200); + expect(res.headers.get("Cache-Control")).toBe("private, no-store"); + }); + + it("keeps the default on POST to a cached route", async () => { + const res = await invokeCatchAll(runtime, "catalog", "POST"); + expect(res.status).toBe(200); + expect(res.headers.get("Cache-Control")).toBe("private, no-store"); + }); +}); diff --git a/packages/core/tests/unit/cli/bundle-utils.test.ts b/packages/core/tests/unit/cli/bundle-utils.test.ts index 3f50ca5c51..7a236d7fcf 100644 --- a/packages/core/tests/unit/cli/bundle-utils.test.ts +++ b/packages/core/tests/unit/cli/bundle-utils.test.ts @@ -85,6 +85,23 @@ describe("extractManifest", () => { expect(manifest.routes).toEqual(["sync", "webhook"]); }); + it("emits structured route entries for public and cacheControl metadata", () => { + const plugin = mockPlugin({ + routes: { + sync: { handler: vi.fn() }, + webhook: { handler: vi.fn(), public: true }, + catalog: { handler: vi.fn(), public: true, cacheControl: "public, max-age=60" }, + }, + }); + + const manifest = extractManifest(plugin); + expect(manifest.routes).toEqual([ + "sync", + { name: "webhook", public: true }, + { name: "catalog", public: true, cacheControl: "public, max-age=60" }, + ]); + }); + it("strips admin.entry (host-only concern, not in bundles)", () => { const plugin = mockPlugin({ admin: { diff --git a/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts b/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts index d0451f39a3..6e11ff522e 100644 --- a/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts +++ b/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts @@ -382,6 +382,23 @@ describe("adaptSandboxEntry", () => { expect(result.routes.webhook.public).toBe(true); }); + it("preserves cacheControl on routes", () => { + const def: SandboxedPlugin = { + routes: { + catalog: { + handler: vi.fn(), + public: true, + cacheControl: "public, max-age=60", + }, + }, + }; + const descriptor = createDescriptor(); + + const result = adaptSandboxEntry(def, descriptor); + + expect(result.routes.catalog.cacheControl).toBe("public, max-age=60"); + }); + it("adapts multiple routes", () => { const def: SandboxedPlugin = { routes: { diff --git a/packages/core/tests/unit/plugins/manifest-schema.test.ts b/packages/core/tests/unit/plugins/manifest-schema.test.ts index 5b9c6cda34..57c678dcb2 100644 --- a/packages/core/tests/unit/plugins/manifest-schema.test.ts +++ b/packages/core/tests/unit/plugins/manifest-schema.test.ts @@ -72,6 +72,22 @@ describe("pluginManifestSchema — route entries", () => { expect(result.success).toBe(true); }); + it("should accept route objects with cacheControl", () => { + const result = pluginManifestSchema.safeParse({ + ...makeManifest({}), + routes: [{ name: "catalog", public: true, cacheControl: "public, max-age=60" }], + }); + expect(result.success).toBe(true); + }); + + it("should reject route objects with empty cacheControl", () => { + const result = pluginManifestSchema.safeParse({ + ...makeManifest({}), + routes: [{ name: "catalog", public: true, cacheControl: "" }], + }); + expect(result.success).toBe(false); + }); + it("should accept route names with slashes and hyphens", () => { const result = pluginManifestSchema.safeParse({ ...makeManifest({}), diff --git a/packages/plugin-types/src/index.ts b/packages/plugin-types/src/index.ts index 6d934382a6..6947a5ac13 100644 --- a/packages/plugin-types/src/index.ts +++ b/packages/plugin-types/src/index.ts @@ -312,6 +312,11 @@ export interface ManifestHookEntry { export interface ManifestRouteEntry { name: string; public?: boolean; + /** + * Cache-Control value for successful GET responses. Only honored on + * routes that are also `public: true`. + */ + cacheControl?: string; } /**