diff --git a/.changeset/plugin-route-cache-control.md b/.changeset/plugin-route-cache-control.md new file mode 100644 index 0000000000..b8bec8036c --- /dev/null +++ b/.changeset/plugin-route-cache-control.md @@ -0,0 +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. 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 e8d182fd96..b2ef074249 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`: + +```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/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/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/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 24b02a58df..5a0f6c8590 100644 --- a/packages/core/src/plugins/routes.ts +++ b/packages/core/src/plugins/routes.ts @@ -54,6 +54,26 @@ 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; +} + +/** + * 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; } /** @@ -199,7 +219,7 @@ 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 }; + return buildRouteMeta(route); } } 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/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/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/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/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", () => { 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; } /**