diff --git a/.changeset/raw-plugin-http.md b/.changeset/raw-plugin-http.md new file mode 100644 index 0000000000..b2a11605dc --- /dev/null +++ b/.changeset/raw-plugin-http.md @@ -0,0 +1,17 @@ +--- +"emdash": minor +"@emdash-cms/plugin-types": minor +"@emdash-cms/plugin-cli": minor +"@emdash-cms/cloudflare": minor +"@emdash-cms/sandbox-workerd": minor +--- + +Adds raw request bodies and custom HTTP responses to the Plugin API for native and sandboxed routes. + +Set `body: "text"` or `body: "bytes"` on a route to receive a UTF-8 string or the original body bytes in `ctx.input` (`routeCtx.input` for sandboxed handlers). Body modes infer `string` or `Uint8Array` handler inputs. Omit the option to keep existing JSON and query-string decoding. + +Both body modes buffer the request body. For webhook signatures, use bytes mode, verify the original bytes, then parse and validate the payload. + +Return a Web API `Response` to serve text, XML, binary data, redirects, or custom status codes and headers without the JSON envelope. The Node sandbox buffers response bodies for transport. Successful GET/HEAD responses honor the route's `cacheControl` option, then an explicit response header, and default to `Cache-Control: private, no-store`. Route URLs remain under `/_emdash/api/plugins//`. + +Rebuild sandboxed plugins after setting a body mode so their generated manifests include the option. diff --git a/.changeset/shared-plugin-route-options.md b/.changeset/shared-plugin-route-options.md new file mode 100644 index 0000000000..718cc63fd9 --- /dev/null +++ b/.changeset/shared-plugin-route-options.md @@ -0,0 +1,7 @@ +--- +"emdash": patch +"@emdash-cms/plugin-cli": patch +"@emdash-cms/plugin-types": patch +--- + +Fixes plugin route options being lost during bundling and manifest validation. The standalone plugin CLI now preserves `cacheControl`, core's descriptor bundler preserves route permissions, and the shared manifest validator retains both fields. Rebuild affected plugin bundles to include options omitted by an older CLI. diff --git a/apps/release-action/dist/index.js b/apps/release-action/dist/index.js index 4ced05bfd7..99bdc1c845 100644 --- a/apps/release-action/dist/index.js +++ b/apps/release-action/dist/index.js @@ -7766,6 +7766,14 @@ const meta = meta$1; //#endregion //#region ../../packages/plugin-types/dist/index.js +const routeOptionsSchema = object({ + body: _enum(["text", "bytes"]).optional(), + public: boolean().optional(), + permission: string().optional(), + cacheControl: string().min(1).optional() +}); +const routeNameSchema = string().min(1).regex(/^[a-zA-Z0-9][a-zA-Z0-9_\-/]*$/, "Route name must be a safe path segment"); +const manifestRouteEntrySchema = routeOptionsSchema.extend({ name: routeNameSchema }); /** * Zod schema for PluginManifest validation * @@ -7874,17 +7882,6 @@ const manifestHookEntrySchema = object({ priority: number().int().optional(), timeout: number().int().positive().optional() }); -/** -* Structured route entry for manifest — name plus optional metadata. -* Both plain strings and objects are accepted; strings are normalized -* to `{ name }` objects via `normalizeManifestRoute()`. -*/ -/** Route names must be safe path segments — alphanumeric, hyphens, underscores, forward slashes */ -const routeNamePattern = /^[a-zA-Z0-9][a-zA-Z0-9_\-/]*$/; -const manifestRouteEntrySchema = object({ - name: string().min(1).regex(routeNamePattern, "Route name must be a safe path segment"), - public: boolean().optional() -}); /** Index field names must be valid identifiers to prevent SQL injection via JSON path expressions */ const indexFieldName = string().regex(/^[a-zA-Z][a-zA-Z0-9_]*$/); const storageCollectionSchema = object({ @@ -8018,7 +8015,7 @@ const pluginManifestSchema = object({ allowedHosts: array(string()), storage: record(string(), storageCollectionSchema), hooks: array(union([_enum(HOOK_NAMES), manifestHookEntrySchema])), - routes: array(union([string().min(1).regex(routeNamePattern, "Route name must be a safe path segment"), manifestRouteEntrySchema])), + routes: array(union([routeNameSchema, manifestRouteEntrySchema])), admin: pluginAdminConfigSchema }); /** 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 ddfa630539..bb517ca216 100644 --- a/docs/src/content/docs/plugins/creating-plugins/api-routes.mdx +++ b/docs/src/content/docs/plugins/creating-plugins/api-routes.mdx @@ -281,6 +281,32 @@ return { id: "abc", count: 42 }; // wrapped to { success: true, data: { id, cou return [1, 2, 3]; // wrapped to { success: true, data: [1, 2, 3] } ``` +### Raw responses + +Return a Web API `Response` to send XML, plain text, binary data, or a redirect. EmDash preserves its body, status, and headers and skips the JSON envelope. This works for native and sandboxed plugins. The Node sandbox buffers the response body for transport. + +The following route serves XML at `/_emdash/api/plugins//sitemap`: + +```typescript title="src/plugin.ts" +import type { SandboxedPlugin } from "emdash/plugin"; + +export default { + routes: { + sitemap: { + public: true, + handler: async () => new Response( + 'https://example.com/', + { headers: { "Content-Type": "application/xml; charset=utf-8" } }, + ), + }, + }, +} satisfies SandboxedPlugin; +``` + +Route names remain under the plugin API prefix. To expose a conventional root URL such as `/sitemap.xml`, add an Astro route that forwards the public plugin route's response. + +For successful GET and HEAD requests, a public route's `cacheControl` option overrides the response's `Cache-Control` header. Otherwise, EmDash preserves an explicit response header and defaults to `private, no-store` when none is set. + ## Errors Throw when a sandboxed route cannot complete. EmDash logs the exception and returns a `ROUTE_ERROR`. The thrown message may be included in that response, so never put credentials, personal data, internal paths, or stack traces in an exception message: @@ -296,9 +322,20 @@ handler: async (_routeCtx, ctx) => { }, ``` -Sandboxed plugin code cannot select an arbitrary HTTP status by throwing a `Response`; a `Response` does not cross every sandbox runner's boundary as a structured error. EmDash assigns statuses to authentication, authorization, CSRF, and missing-route failures before the handler runs. Return a JSON result for expected validation and domain outcomes, and reserve exceptions for unexpected failures. +For a specific status code and body, return a `Response`: -An expected error returned as JSON still uses the route's successful HTTP response and appears inside EmDash's outer `{ success: true, data: ... }` envelope. Include a stable application-level code so clients can distinguish that outcome. +```typescript +handler: async (routeCtx, ctx) => { + const item = await ctx.storage.items.get(routeCtx.input.id); + if (!item) { + return new Response(JSON.stringify({ error: "Not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + return item; +}, +``` ## HTTP methods @@ -319,7 +356,7 @@ routes: { await ctx.storage.items.delete(id); return { deleted: true }; default: - return { error: "METHOD_NOT_ALLOWED", allowed: ["GET", "DELETE"] }; + return new Response("Method not allowed", { status: 405 }); } }, }, @@ -340,10 +377,60 @@ handler: async (routeCtx, ctx) => { ctx.log.info("Request", { meta: requestMeta }); - if (request.method !== "POST") return { error: "POST_REQUIRED" }; + if (request.method !== "POST") { + return new Response("POST required", { status: 405 }); + } }, ``` +### Request body decoding + +Set `body` on a route to choose how EmDash decodes the request into `routeCtx.input`. Native handlers receive the value in `ctx.input`. + +| Route option | Input | +| --- | --- | +| Omitted | JSON for POST, PUT, and PATCH; query parameters for other methods | +| `body: "text"` | UTF-8 string | +| `body: "bytes"` | `Uint8Array` containing the original body bytes | + +Text and byte routes infer `string` and `Uint8Array` handler input types respectively. + +Both body modes read the whole body into memory. An empty body produces `""` in text mode and an empty `Uint8Array` in bytes mode. Text mode uses `Request.text()`, which decodes UTF-8 and may remove a byte order mark or replace invalid sequences. Use bytes mode for webhook signatures: verify the original bytes, then parse and validate the payload. + +The following route verifies a hex-encoded HMAC-SHA256 signature with a secret stored in plugin settings: + +```typescript title="src/plugin.ts" +import type { SandboxedPlugin } from "emdash/plugin"; + +export default { + routes: { + webhook: { + public: true, + body: "bytes", + handler: async (routeCtx, ctx) => { + const signature = routeCtx.request.headers["x-webhook-signature"] ?? ""; + const secret = await ctx.kv.get("settings:webhookSecret"); + if (!secret || !/^[a-f0-9]{64}$/i.test(signature)) { + return new Response("Invalid signature", { status: 401 }); + } + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"], + ); + const signatureBytes = Uint8Array.from( + signature.match(/../g)!, (pair) => Number.parseInt(pair, 16), + ); + const valid = await crypto.subtle.verify( + "HMAC", key, signatureBytes, routeCtx.input, + ); + if (!valid) return new Response("Invalid signature", { status: 401 }); + return { received: true }; + }, + }, + }, +} satisfies SandboxedPlugin; +``` + ## Common patterns ### Settings and paginated data @@ -456,7 +543,7 @@ interface SandboxedRequest { } interface SandboxedRouteContext { - input: unknown; // validate inside the handler before use + input: unknown; // inferred as string or Uint8Array when a body mode is set request: SandboxedRequest; requestMeta?: unknown; user?: UserInfo; // authenticated caller on private routes; undefined on public routes 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 ed69d2937a..d56d7e722d 100644 --- a/packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].ts +++ b/packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].ts @@ -95,13 +95,15 @@ const handleRequest: APIRoute = async ({ params, request, locals }) => { return apiError(code, message, status); } - 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); + const response = + result.data instanceof Response + ? new Response(result.data.body, result.data) + : apiSuccess(result.data); + if (response.ok && (method === "GET" || method === "HEAD")) { + response.headers.set( + "Cache-Control", + routeMeta.cacheControl ?? response.headers.get("Cache-Control") ?? "private, no-store", + ); } return response; }; diff --git a/packages/core/src/cli/commands/bundle-utils.ts b/packages/core/src/cli/commands/bundle-utils.ts index fccb182cba..23046c7f57 100644 --- a/packages/core/src/cli/commands/bundle-utils.ts +++ b/packages/core/src/cli/commands/bundle-utils.ts @@ -11,6 +11,7 @@ import { resolve, join } from "node:path"; import { pipeline } from "node:stream/promises"; import { pathToFileURL } from "node:url"; +import { extractManifestRoute } from "@emdash-cms/plugin-types"; import { imageSize } from "image-size"; import { packTar } from "modern-tar/fs"; import { z } from "zod"; @@ -22,7 +23,6 @@ import type { HookName, ManifestHookEntry, ManifestMcpTool, - ManifestRouteEntry, } from "../../plugins/types.js"; // ── Constants ──────────────────────────────────────────────────────────────── @@ -159,22 +159,8 @@ export function extractManifest(plugin: ResolvedPlugin): PluginManifest { } } - const routes: Array = Object.entries(plugin.routes).map( - ([name, route]) => { - if ( - route.public === undefined && - route.permission === undefined && - route.cacheControl === undefined - ) { - return name; - } - - const entry: ManifestRouteEntry = { name }; - if (route.public !== undefined) entry.public = route.public; - if (route.permission !== undefined) entry.permission = route.permission; - if (route.cacheControl !== undefined) entry.cacheControl = route.cacheControl; - return entry; - }, + const routes = Object.entries(plugin.routes).map(([name, route]) => + extractManifestRoute(name, route), ); const tools: ManifestMcpTool[] = Object.entries(plugin.mcp?.tools ?? {}).map(([name, tool]) => { if (!MCP_TOOL_NAME_PATTERN.test(name)) throw new Error(`Invalid MCP tool name "${name}"`); diff --git a/packages/core/src/cli/commands/bundle.ts b/packages/core/src/cli/commands/bundle.ts index cdacd14c17..6dfe5ce306 100644 --- a/packages/core/src/cli/commands/bundle.ts +++ b/packages/core/src/cli/commands/bundle.ts @@ -17,6 +17,7 @@ import { createHash } from "node:crypto"; import { readFile, stat, mkdir, writeFile, rm, copyFile, symlink, readdir } from "node:fs/promises"; import { resolve, join, extname, basename } from "node:path"; +import { extractRouteOptions } from "@emdash-cms/plugin-types"; import { defineCommand } from "citty"; import consola from "consola"; @@ -305,8 +306,7 @@ export const bundleCommand = defineCommand({ const routeObj = route as Record; (resolvedPlugin.routes as Record)[name] = { handler: routeObj.handler, - public: routeObj.public, - cacheControl: routeObj.cacheControl, + ...extractRouteOptions(routeObj), }; } } diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 50a35fad0f..45940ee194 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -3723,7 +3723,10 @@ export class EmDashRuntime { const routeKey = path.replace(LEADING_SLASH_PATTERN, ""); // Body methods parse JSON; GET/HEAD/DELETE parse the query string (#2146). - const body = await parseRouteInput(request); + const body = await parseRouteInput( + request, + buildRouteMeta(trustedPlugin.routes[routeKey] ?? {}).body, + ); return routeRegistry.invoke(pluginId, routeKey, { request, body, user: caller }); } @@ -3731,7 +3734,13 @@ export class EmDashRuntime { // Check sandboxed (marketplace) plugins second const sandboxedPlugin = this.findSandboxedPlugin(pluginId); if (sandboxedPlugin) { - return this.handleSandboxedRoute(sandboxedPlugin, path, request, caller); + return this.handleSandboxedRoute( + sandboxedPlugin, + path, + request, + caller, + this.getPluginRouteMeta(pluginId, path)?.body, + ); } return { @@ -4238,7 +4247,8 @@ export class EmDashRuntime { plugin: SandboxedPluginInstance, path: string, request: Request, - user?: UserInfo, + user: UserInfo | undefined, + bodyMode?: RouteMeta["body"], ): Promise<{ success: boolean; data?: unknown; @@ -4248,7 +4258,7 @@ export class EmDashRuntime { const routeName = path.replace(LEADING_SLASH_PATTERN, ""); // Body methods parse JSON; GET/HEAD/DELETE parse the query string (#2146). - const body = await parseRouteInput(request); + const body = await parseRouteInput(request, bodyMode); try { const headers = sanitizeHeadersForSandbox(request.headers); diff --git a/packages/core/src/plugin-types.ts b/packages/core/src/plugin-types.ts index 814b5dab17..7fe15d5710 100644 --- a/packages/core/src/plugin-types.ts +++ b/packages/core/src/plugin-types.ts @@ -39,6 +39,7 @@ */ import type { Permission } from "@emdash-cms/auth"; +import type { RouteOptions } from "@emdash-cms/plugin-types"; import type { ZodType } from "zod"; import type { SandboxHookErrorEnvelope } from "./plugins/sandbox/hook-result.js"; @@ -174,11 +175,11 @@ export interface SandboxedRequest { * argument with the call-site input + the originating request, in * addition to the standard `PluginContext`. * - * `input` is `unknown` because plugins validate it themselves — no - * central schema for route payloads. + * Without a body mode or explicit type argument, input is unknown. */ -export interface SandboxedRouteContext { - input: unknown; +export interface SandboxedRouteContext { + /** Decoded request input. */ + input: TInput; request: SandboxedRequest; requestMeta?: unknown; /** @@ -196,32 +197,31 @@ export interface SandboxedRouteContext { * native plugins, where routes take a single context with the input * merged in. * - * Return type is `unknown` because routes serialise their return value - * to JSON for the caller; authors define their own response shape. + * Return a Response for custom HTTP output, or a JSON-serializable value + * for the standard API envelope. */ -export type RouteHandler = ( - routeCtx: SandboxedRouteContext, +export type RouteHandler = ( + routeCtx: SandboxedRouteContext, ctx: PluginContext, ) => Promise; /** * Route entry — either a bare handler or the config form with - * `public`, `input` schema, and so on. The build probe accepts both. + * `public`, `input`, and so on. The build probe accepts both. */ -export type RouteEntry = - | RouteHandler - | { - 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; - permission?: Permission; - }; +interface RouteEntryOptions extends RouteOptions { + permission?: Permission; +} + +export type RouteEntry = + // Optional discriminants preserve contextual typing for object-route handlers. + | (RouteHandler & { body?: undefined; input?: undefined }) + | (RouteEntryOptions & + ( + | { body?: undefined; input?: unknown; handler: RouteHandler } + | { body: "text"; input?: undefined; handler: RouteHandler } + | { body: "bytes"; input?: undefined; handler: RouteHandler> } + )); export interface SandboxedMcpTool { description: string; diff --git a/packages/core/src/plugins/adapt-sandbox-entry.ts b/packages/core/src/plugins/adapt-sandbox-entry.ts index 9422d1ad30..3e5dc528f2 100644 --- a/packages/core/src/plugins/adapt-sandbox-entry.ts +++ b/packages/core/src/plugins/adapt-sandbox-entry.ts @@ -10,6 +10,8 @@ * */ +import type { RouteOptions } from "@emdash-cms/plugin-types"; + import type { PluginDescriptor } from "../astro/integration/runtime.js"; import type { RouteEntry, RouteHandler, SandboxedPlugin } from "../plugin-types.js"; import { PLUGIN_CAPABILITIES, HOOK_NAMES } from "./manifest-schema.js"; @@ -105,22 +107,15 @@ function resolveSandboxedHook(entry: AnyHookEntry, pluginId: string): ResolvedHo * The wider type flows through to the runtime which validates at * invocation time. */ -function normalizeRouteEntry(entry: RouteEntry): { - handler: RouteHandler; - public?: boolean; - cacheControl?: string; - input?: PluginRoute["input"]; - permission?: PluginRoute["permission"]; -} { - if (typeof entry === "function") { - return { handler: entry }; - } +function normalizeRouteEntry( + entry: RouteEntry, +): RouteOptions & Omit & { handler: RouteHandler } { + if (typeof entry === "function") return { handler: entry }; return { - handler: entry.handler, - public: entry.public, - permission: entry.permission, - 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 + ...entry, + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- decoding and schema validation establish the handler input before invocation + handler: entry.handler as RouteHandler, + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- sandbox schemas are validated when the route is invoked input: entry.input as PluginRoute["input"], }; } @@ -211,18 +206,10 @@ export function adaptSandboxEntry( if (definition.routes) { for (const [routeName, rawEntry] of Object.entries(definition.routes)) { const normalized = normalizeRouteEntry(rawEntry); - const { - handler, - public: publicFlag, - cacheControl, - input: inputSchema, - permission, - } = normalized; + const { handler, ...options } = normalized; + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the resolved contract erases the authoring-only input specialization resolvedRoutes[routeName] = { - input: inputSchema, - public: publicFlag, - permission, - cacheControl, + ...options, handler: async (ctx) => { if (usesPublicRouteContext) { // The incoming ctx already IS the public RouteContext @@ -255,7 +242,7 @@ export function adaptSandboxEntry( const { input: _, request: __, requestMeta: ___, user: ____, ...pluginCtx } = ctx; return handler(routeCtx, pluginCtx); }, - }; + } as PluginRoute; } } diff --git a/packages/core/src/plugins/define-plugin.ts b/packages/core/src/plugins/define-plugin.ts index 60d9d6419e..fad42ef7f6 100644 --- a/packages/core/src/plugins/define-plugin.ts +++ b/packages/core/src/plugins/define-plugin.ts @@ -216,7 +216,8 @@ function defineNativePlugin( allowedHosts, storage, hooks: resolvedHooks, - routes, + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- the dispatcher decodes and validates input before invoking these handlers + routes: routes as ResolvedPlugin["routes"], mcp, admin, }; diff --git a/packages/core/src/plugins/manifest-schema.ts b/packages/core/src/plugins/manifest-schema.ts index 4aaed4b232..dbf55c7057 100644 --- a/packages/core/src/plugins/manifest-schema.ts +++ b/packages/core/src/plugins/manifest-schema.ts @@ -13,7 +13,12 @@ import { capabilitiesToDeclaredAccess, declaredAccessToCapabilities, } from "@emdash-cms/plugin-types"; +import { + manifestRouteEntrySchema as sharedManifestRouteEntrySchema, + routeNameSchema, +} from "@emdash-cms/plugin-types"; import { z } from "zod"; +export { normalizeManifestRoute } from "@emdash-cms/plugin-types"; import type { PluginManifest } from "./types.js"; @@ -127,22 +132,10 @@ const manifestHookEntrySchema = z.object({ timeout: z.number().int().positive().optional(), }); -/** - * Structured route entry for manifest — name plus optional metadata. - * Both plain strings and objects are accepted; strings are normalized - * to `{ name }` objects via `normalizeManifestRoute()`. - */ -/** Route names must be safe path segments — alphanumeric, hyphens, underscores, forward slashes */ -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(), - permission: z - .string() - .refine((permission) => permission in Permissions) - .optional(), - cacheControl: z.string().min(1).optional(), +const manifestRouteEntrySchema = sharedManifestRouteEntrySchema.extend({ + permission: sharedManifestRouteEntrySchema.shape.permission.refine( + (permission) => permission === undefined || permission in Permissions, + ), }); const pluginJsonSchema = z.record(z.string(), z.unknown()); @@ -152,7 +145,7 @@ const pluginMcpConfigSchema = z.object({ z.object({ name: z.string().min(1).max(64).regex(mcpToolNamePattern, "Invalid MCP tool name"), description: z.string().min(1), - route: z.string().min(1).regex(routeNamePattern, "Route name must be a safe path segment"), + route: routeNameSchema, permission: z.string().refine((permission) => permission in Permissions), destructive: z.boolean(), inputSchema: pluginJsonSchema, @@ -323,12 +316,7 @@ export const pluginManifestSchema = z.object({ * structured objects with public metadata. * Plain strings are normalized to `{ name }` objects after parsing. */ - routes: z.array( - z.union([ - z.string().min(1).regex(routeNamePattern, "Route name must be a safe path segment"), - manifestRouteEntrySchema, - ]), - ), + routes: z.array(z.union([routeNameSchema, manifestRouteEntrySchema])), mcp: pluginMcpConfigSchema.optional(), admin: pluginAdminConfigSchema, }); @@ -368,20 +356,3 @@ export function normalizeManifestHook( } return entry; } - -/** - * Normalize a manifest route entry — plain strings become `{ name }` objects. - */ -export function normalizeManifestRoute( - entry: string | { name: string; public?: boolean; permission?: string; cacheControl?: string }, -): { - name: string; - public?: boolean; - permission?: string; - cacheControl?: string; -} { - if (typeof entry === "string") { - return { name: entry }; - } - return entry; -} diff --git a/packages/core/src/plugins/routes.ts b/packages/core/src/plugins/routes.ts index 0abcd3cea9..9ad80ae291 100644 --- a/packages/core/src/plugins/routes.ts +++ b/packages/core/src/plugins/routes.ts @@ -8,12 +8,13 @@ * */ +import type { RouteOptions } from "@emdash-cms/plugin-types"; import { z } from "zod"; import { MediaUsageActivationWriteBlockedError } from "../api/media-usage-write-fence.js"; import { PluginContextFactory, type PluginContextFactoryOptions } from "./context.js"; import { extractRequestMeta } from "./request-meta.js"; -import type { ResolvedPlugin, RouteContext, PluginRoute, UserInfo } from "./types.js"; +import type { ResolvedPlugin, RouteContext, UserInfo } from "./types.js"; /** * Body-reading methods on `Request`. EmDash parses the request body once before @@ -40,7 +41,7 @@ function guardConsumedRequestBody(request: Request): Request { return () => { throw new Error( `[emdash] ctx.request.${prop}() is not available inside a plugin route handler: ` + - `EmDash has already parsed the request body and exposes it as ctx.input. ` + + `EmDash has already read the request body and exposes it as ctx.input. ` + `Read ctx.input instead of ctx.request.${prop}().`, ); }; @@ -51,34 +52,14 @@ function guardConsumedRequestBody(request: Request): Request { }); } -/** - * Route metadata (public flag) without the handler. - * Used by the catch-all route to decide auth before dispatch. - */ -export interface RouteMeta { +export interface RouteMeta extends RouteOptions { public: boolean; - permission?: string; - /** - * 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; - permission?: string; - cacheControl?: string; -}): RouteMeta { +export function buildRouteMeta(route: RouteOptions): RouteMeta { const meta: RouteMeta = { public: route.public === true }; + if (route.body !== undefined) meta.body = route.body; if (route.permission !== undefined) meta.permission = route.permission; - // 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; } @@ -100,7 +81,12 @@ const BODY_METHODS = new Set(["POST", "PUT", "PATCH"]); * an object instead. Repeated keys (`?tag=a&tag=b`) become an array so array * schemas work; a single key stays a scalar. */ -export async function parseRouteInput(request: Request): Promise { +export async function parseRouteInput( + request: Request, + body?: RouteOptions["body"], +): Promise { + if (body === "text") return request.text(); + if (body === "bytes") return new Uint8Array(await request.arrayBuffer()); if (BODY_METHODS.has(request.method.toUpperCase())) { try { return await request.json(); @@ -232,9 +218,6 @@ export class PluginRouteHandler { const routeContext: RouteContext = { ...baseContext, input: validatedInput, - // The body is already parsed into `input`; guard `ctx.request`'s - // body-reading methods so a re-read fails with an actionable message - // (#1293). Metadata extraction uses the original request (headers only). request: guardConsumedRequestBody(options.request), requestMeta: extractRequestMeta(options.request, this.trustedProxyHeaders), user: options.user, @@ -246,7 +229,7 @@ export class PluginRouteHandler { return { success: true, data: result, - status: 200, + status: result instanceof Response ? result.status : 200, }; } catch (error) { if (error instanceof MediaUsageActivationWriteBlockedError) { @@ -301,7 +284,7 @@ export class PluginRouteHandler { * Returns null if the route doesn't exist. */ getRouteMeta(name: string): RouteMeta | null { - const route: PluginRoute | undefined = this.plugin.routes[name]; + const route = this.plugin.routes[name]; if (!route) return null; return buildRouteMeta(route); } diff --git a/packages/core/src/plugins/types.ts b/packages/core/src/plugins/types.ts index 590dc3192e..fc6ae02e89 100644 --- a/packages/core/src/plugins/types.ts +++ b/packages/core/src/plugins/types.ts @@ -11,6 +11,7 @@ import type { Permission } from "@emdash-cms/auth"; import type { Element } from "@emdash-cms/blocks"; +import type { RouteOptions } from "@emdash-cms/plugin-types"; // The plugin capability vocabulary, the legacy-rename map, and the manifest // shape are authored once in @emdash-cms/plugin-types and shared between core // (the manifest reader at install/runtime) and @emdash-cms/plugin-cli (the @@ -1215,7 +1216,7 @@ export interface RequestMeta { * Route handler context extends plugin context with request-specific data */ export interface RouteContext extends PluginContext { - /** Validated input from request body */ + /** Decoded request input, after applying the optional input schema. */ input: TInput; /** Original request */ request: Request; @@ -1238,27 +1239,26 @@ export interface RouteContext extends PluginContext { /** * Route definition */ -export interface PluginRoute { - /** Zod schema for input validation */ - input?: z.ZodType; - /** - * Mark this route as publicly accessible (no authentication required). - * Public routes skip session/token auth and CSRF checks. - */ - public?: boolean; - /** RBAC permission required to invoke the route. Legacy routes default to plugins:manage. */ +type PluginRouteBody = [TInput] extends [string] + ? "text" + : [TInput] extends [Uint8Array] + ? "bytes" + : undefined; + +export interface PluginRoute extends Omit { + body?: PluginRouteBody; permission?: Permission; - /** - * `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 */ + /** Validate or transform JSON or query input before invoking the handler. */ + input?: z.ZodType; + /** Return a Response for custom HTTP output, or a value for the JSON envelope. */ handler: (ctx: RouteContext) => Promise; } +type PluginRouteDefinition = + | (PluginRoute & { body?: undefined }) + | (PluginRoute & { body: "text"; input?: undefined }) + | (PluginRoute> & { body: "bytes"; input?: undefined }); + export interface PluginMcpToolDefinition { description: string; route: string; @@ -1450,7 +1450,7 @@ export interface PluginDefinition; + routes?: Record; /** Routes explicitly exposed as agent-callable MCP tools. */ mcp?: PluginMcpConfig; diff --git a/packages/core/tests/integration/runtime/plugin-raw-routes.test.ts b/packages/core/tests/integration/runtime/plugin-raw-routes.test.ts new file mode 100644 index 0000000000..e493c7efcf --- /dev/null +++ b/packages/core/tests/integration/runtime/plugin-raw-routes.test.ts @@ -0,0 +1,168 @@ +import { createHmac, randomUUID } from "node:crypto"; + +import Database from "better-sqlite3"; +import { SqliteDialect } from "kysely"; +import { afterEach, describe, expect, it } from "vitest"; + +import { MiniflareDevRunner } from "../../../../workerd/src/sandbox/dev-runner.js"; +import { GET, POST } from "../../../src/astro/routes/api/plugins/[pluginId]/[...path].js"; +import { EmDashRuntime } from "../../../src/emdash-runtime.js"; +import { definePlugin } from "../../../src/plugins/define-plugin.js"; +import type { PluginRoute } from "../../../src/plugins/types.js"; + +const runtimes: EmDashRuntime[] = []; +afterEach(async () => { + await Promise.all(runtimes.splice(0).map((runtime) => runtime.stopCron())); +}); + +async function invoke( + route: PluginRoute, + body?: string | Uint8Array, + method = "POST", +) { + const runtime = await EmDashRuntime.create({ + config: { database: { entrypoint: randomUUID(), config: {}, type: "sqlite" } }, + plugins: [definePlugin({ id: "raw-demo", version: "1.0.0", routes: { test: route } })], + createDialect: () => new SqliteDialect({ database: new Database(":memory:") }), + createStorage: null, + sandboxEnabled: false, + sandboxedPluginEntries: [], + createSandboxRunner: null, + }); + runtimes.push(runtime); + return (method === "POST" ? POST : GET)({ + params: { pluginId: "raw-demo", path: "test" }, + request: new Request("https://example.com/_emdash/api/plugins/raw-demo/test", { + method, + ...(body === undefined ? {} : { body }), + }), + locals: { emdash: runtime, user: null }, + } as never); +} + +describe("raw plugin routes", () => { + it("verifies a signature over the original webhook text", async () => { + const body = '{ "message": "Hi 👋",\r\n "amount": 1.00 }\n'; + const signature = createHmac("sha256", "webhook-secret") + .update(Buffer.from(body)) + .digest("hex"); + const response = await invoke( + { + public: true, + body: "text", + handler: async (ctx) => { + if (typeof ctx.input !== "string") throw new Error("Expected raw request text"); + expect(() => ctx.request.text()).toThrow("ctx.input"); + return { + signature: createHmac("sha256", "webhook-secret").update(ctx.input).digest("hex"), + }; + }, + }, + body, + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + success: true, + data: { signature }, + }); + }); + + it("serves a Response body, status, and headers without a JSON envelope", async () => { + const response = await invoke( + { + public: true, + handler: async () => + new Response("", { + status: 201, + headers: { "Content-Type": "application/xml", "X-Plugin": "demo" }, + }), + }, + undefined, + "GET", + ); + expect(response.status).toBe(201); + expect(response.headers.get("Content-Type")).toBe("application/xml"); + expect(response.headers.get("X-Plugin")).toBe("demo"); + expect(await response.text()).toBe(""); + }); + + it("preserves redirect responses with immutable headers", async () => { + const response = await invoke( + { public: true, handler: async () => Response.redirect("https://example.com/feed", 307) }, + undefined, + "GET", + ); + expect(response.status).toBe(307); + expect(response.headers.get("Location")).toBe("https://example.com/feed"); + }); +}); + +describe("sandboxed raw plugin routes", () => { + it("verifies a webhook signature inside a real isolate", async () => { + const body = new Uint8Array([0xef, 0xbb, 0xbf, 0xff, 0, 13, 10, 128]); + const signature = createHmac("sha256", "webhook-secret") + .update(Buffer.from(body)) + .digest("hex"); + const runner = new MiniflareDevRunner({ db: null as never }); + try { + const runtime = await EmDashRuntime.create({ + config: { database: { entrypoint: randomUUID(), config: {}, type: "sqlite" } }, + plugins: [], + createDialect: () => new SqliteDialect({ database: new Database(":memory:") }), + createStorage: null, + sandboxEnabled: true, + createSandboxRunner: () => runner, + sandboxedPluginEntries: [ + { + id: "raw-sandbox", + version: "1.0.0", + options: {}, + capabilities: [], + allowedHosts: [], + storage: {}, + routes: [{ name: "webhook", public: true, body: "bytes" }], + code: `export default { routes: { webhook: { body: "bytes", handler: async (ctx) => { + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey("raw", encoder.encode("webhook-secret"), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); + const digest = await crypto.subtle.sign("HMAC", key, ctx.input); + const signature = Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, "0")).join(""); + return new Response(signature, { status: ctx.input instanceof Uint8Array ? 202 : 400, headers: { "Content-Type": "text/plain", "X-Webhook": "verified" } }); + } } } };`, + }, + ], + }); + runtimes.push(runtime); + const response = await POST({ + params: { pluginId: "raw-sandbox", path: "webhook" }, + request: new Request("https://example.com/_emdash/api/plugins/raw-sandbox/webhook", { + method: "POST", + body, + }), + locals: { emdash: runtime, user: null }, + } as never); + expect(response.status).toBe(202); + expect(response.headers.get("Content-Type")).toBe("text/plain"); + expect(response.headers.get("X-Webhook")).toBe("verified"); + expect(await response.text()).toBe(signature); + } finally { + await runner.terminateAll(); + } + }); +}); + +it("preserves all request bytes, including a BOM and invalid UTF-8", async () => { + const body = new Uint8Array([0xef, 0xbb, 0xbf, 0xff, 0, 13, 10, 128]); + const signature = createHmac("sha256", "secret").update(body).digest("hex"); + const response = await invoke( + { + public: true, + body: "bytes", + handler: async ({ input }) => { + expect(input).toBeInstanceOf(Uint8Array); + return createHmac("sha256", "secret").update(input).digest("hex"); + }, + }, + body, + ); + expect(await response.json()).toEqual({ success: true, data: signature }); +}); 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 index e9fbb1cf0b..a2a28e4e04 100644 --- a/packages/core/tests/unit/astro/plugin-api-route-cache.test.ts +++ b/packages/core/tests/unit/astro/plugin-api-route-cache.test.ts @@ -2,8 +2,7 @@ * 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`. + * definition. Successful GET/HEAD responses may also set the header directly. */ import type { APIRoute } from "astro"; @@ -26,7 +25,6 @@ function createLocals({ user: null, emdash: { handlePluginApiRoute, - // Mirrors getRouteMeta: cacheControl is only ever present on public routes. getPluginRouteMeta: () => ({ public: true, cacheControl }), }, }, @@ -80,3 +78,26 @@ describe("plugin API catch-all Cache-Control", () => { expect(res.headers.get("Cache-Control")).toBe("private, no-store"); }); }); + +it("honors caching declared by a public raw response", async () => { + const { locals } = createLocals({ + result: { + success: true, + data: new Response("feed", { headers: { "Cache-Control": CACHE_VALUE } }), + }, + }); + const response = await invoke(GET, "GET", locals); + expect(response.headers.get("Cache-Control")).toBe(CACHE_VALUE); +}); + +it("lets the route cache option override raw response headers", async () => { + const { locals } = createLocals({ + cacheControl: CACHE_VALUE, + result: { + success: true, + data: new Response("feed", { headers: { "Cache-Control": "public, max-age=1" } }), + }, + }); + const response = await invoke(GET, "GET", locals); + expect(response.headers.get("Cache-Control")).toBe(CACHE_VALUE); +}); diff --git a/packages/core/tests/unit/cli/bundle-utils.test.ts b/packages/core/tests/unit/cli/bundle-utils.test.ts index 63067f607a..2229988fd0 100644 --- a/packages/core/tests/unit/cli/bundle-utils.test.ts +++ b/packages/core/tests/unit/cli/bundle-utils.test.ts @@ -86,12 +86,17 @@ describe("extractManifest", () => { expect(manifest.routes).toEqual(["sync", "webhook"]); }); - it("emits structured route entries for public and cacheControl metadata", () => { + it("emits structured route entries with route 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" }, + catalog: { + handler: vi.fn(), + body: "bytes", + public: true, + cacheControl: "public, max-age=60", + }, }, }); @@ -99,7 +104,12 @@ describe("extractManifest", () => { expect(manifest.routes).toEqual([ "sync", { name: "webhook", public: true }, - { name: "catalog", public: true, cacheControl: "public, max-age=60" }, + { + name: "catalog", + body: "bytes", + public: true, + cacheControl: "public, max-age=60", + }, ]); }); diff --git a/packages/core/tests/unit/plugins/manifest-schema.test.ts b/packages/core/tests/unit/plugins/manifest-schema.test.ts index 3643103a8e..bee03a282d 100644 --- a/packages/core/tests/unit/plugins/manifest-schema.test.ts +++ b/packages/core/tests/unit/plugins/manifest-schema.test.ts @@ -72,10 +72,17 @@ describe("pluginManifestSchema — route entries", () => { expect(result.success).toBe(true); }); - it("should accept route objects with cacheControl", () => { + it("should accept route objects with metadata", () => { const result = pluginManifestSchema.safeParse({ ...makeManifest({}), - routes: [{ name: "catalog", public: true, cacheControl: "public, max-age=60" }], + routes: [ + { + name: "catalog", + body: "bytes", + public: true, + cacheControl: "public, max-age=60", + }, + ], }); expect(result.success).toBe(true); }); diff --git a/packages/core/tests/unit/plugins/route-input.test-d.ts b/packages/core/tests/unit/plugins/route-input.test-d.ts new file mode 100644 index 0000000000..b5de7d5acb --- /dev/null +++ b/packages/core/tests/unit/plugins/route-input.test-d.ts @@ -0,0 +1,105 @@ +import { expectTypeOf, it } from "vitest"; + +import type { SandboxedPlugin } from "../../../src/plugin-types.js"; +import { definePlugin } from "../../../src/plugins/define-plugin.js"; +import type { PluginRoute } from "../../../src/plugins/types.js"; + +it("infers native route input from the body mode", () => { + definePlugin({ + id: "typed-body", + version: "1.0.0", + routes: { + text: { + body: "text", + handler: async ({ input }) => { + expectTypeOf(input).toEqualTypeOf(); + return input.toUpperCase(); + }, + }, + bytes: { + body: "bytes", + handler: async ({ input }) => { + expectTypeOf(input).toEqualTypeOf>(); + return input.byteLength; + }, + }, + json: { + handler: async ({ input }) => { + expectTypeOf(input).toEqualTypeOf(); + return input; + }, + }, + }, + }); +}); + +it("infers sandboxed route input from the body mode", () => { + const plugin = { + routes: { + text: { + body: "text", + handler: async ({ input }) => { + expectTypeOf(input).toEqualTypeOf(); + return input.toUpperCase(); + }, + }, + bytes: { + body: "bytes", + handler: async ({ input }) => { + expectTypeOf(input).toEqualTypeOf>(); + return input.byteLength; + }, + }, + json: { + handler: async ({ input }) => { + expectTypeOf(input).toEqualTypeOf(); + return input; + }, + }, + }, + } satisfies SandboxedPlugin; + expectTypeOf(plugin.routes.text.handler) + .parameter(0) + .toHaveProperty("input") + .toEqualTypeOf(); +}); + +it("accepts existing explicitly typed routes and interface extensions", () => { + interface ExtendedRoute extends PluginRoute { + label: string; + } + const route: ExtendedRoute = { label: "Legacy", handler: async ({ input }) => input }; + definePlugin({ id: "legacy-route", version: "1.0.0", routes: { legacy: route } }); +}); + +it("contextually types configured and bare two-argument handlers", () => { + const plugin = { + routes: { + text: { + body: "text", + handler: async (routeCtx, ctx) => { + expectTypeOf(routeCtx.input).toEqualTypeOf(); + return ctx.plugin.id + routeCtx.input; + }, + }, + bare: async (routeCtx, ctx) => { + expectTypeOf(routeCtx.input).toEqualTypeOf(); + return ctx.plugin.id; + }, + }, + } satisfies SandboxedPlugin; + expectTypeOf(plugin.routes.text.handler) + .parameter(0) + .toHaveProperty("input") + .toEqualTypeOf(); +}); + +it("preserves the public resolved-route contract for existing consumers", () => { + const plugin = definePlugin({ + id: "legacy-consumer", + version: "1.0.0", + routes: { hello: { handler: async () => "hello" } }, + }); + const route: PluginRoute = plugin.routes.hello!; + expectTypeOf(route).toEqualTypeOf(); +}); diff --git a/packages/core/tsconfig.type-tests.json b/packages/core/tsconfig.type-tests.json new file mode 100644 index 0000000000..eace9abd52 --- /dev/null +++ b/packages/core/tsconfig.type-tests.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "rootDir": "." }, + "include": ["tests/**/*.test-d.ts"], + "exclude": [] +} diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index c842e35717..e8241bd879 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -40,6 +40,7 @@ export default defineConfig({ }, ], test: { + typecheck: { enabled: true, tsconfig: "tsconfig.type-tests.json" }, globals: true, environment: "node", include: ["tests/**/*.test.ts"], diff --git a/packages/plugin-cli/src/build/pipeline.ts b/packages/plugin-cli/src/build/pipeline.ts index c8c38f502b..fbbf1bac78 100644 --- a/packages/plugin-cli/src/build/pipeline.ts +++ b/packages/plugin-cli/src/build/pipeline.ts @@ -38,6 +38,8 @@ import { copyFile, mkdir, readFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; +import { extractRouteOptions } from "@emdash-cms/plugin-types"; + import type { ResolvedPlugin } from "../bundle/types.js"; import { fileExists } from "../bundle/utils.js"; import { @@ -481,8 +483,7 @@ function assembleHook(entry: ProbedHookEntry, pluginId: string): ResolvedPlugin[ function assembleRoute(entry: ProbedRouteEntry): ResolvedPlugin["routes"][string] { return { handler: entry.handler, - public: entry.public, - permission: entry.permission, + ...extractRouteOptions(entry), }; } diff --git a/packages/plugin-cli/src/build/probe-schema.ts b/packages/plugin-cli/src/build/probe-schema.ts index d9239dd75a..756f53ab0f 100644 --- a/packages/plugin-cli/src/build/probe-schema.ts +++ b/packages/plugin-cli/src/build/probe-schema.ts @@ -24,6 +24,7 @@ * schema only has to validate one shape per entry. */ +import { routeOptionsSchema } from "@emdash-cms/plugin-types"; import { z } from "zod"; /** A function reference; the probe doesn't introspect signatures. */ @@ -86,12 +87,12 @@ const HookEntryConfigSchema = z.looseObject({ export const HookEntrySchema = z.preprocess(normaliseEntry, HookEntryConfigSchema); -const RouteEntryConfigSchema = z.looseObject({ - handler: FunctionSchema, - public: z.boolean().optional(), - input: z.unknown().optional(), - permission: z.string().optional(), -}); +const RouteEntryConfigSchema = routeOptionsSchema + .extend({ + handler: FunctionSchema, + input: z.unknown().optional(), + }) + .loose(); export const RouteEntrySchema = z.preprocess(normaliseEntry, RouteEntryConfigSchema); diff --git a/packages/plugin-cli/src/bundle/types.ts b/packages/plugin-cli/src/bundle/types.ts index 025e4241f6..e13d2d3b5a 100644 --- a/packages/plugin-cli/src/bundle/types.ts +++ b/packages/plugin-cli/src/bundle/types.ts @@ -33,6 +33,7 @@ export { } from "@emdash-cms/plugin-types"; import type { + RouteOptions, PluginAdminConfig, PluginCapability, PluginStorageConfig, @@ -71,14 +72,7 @@ export interface ResolvedPlugin { pluginId?: string; } >; - routes: Record< - string, - { - handler?: unknown; - public?: boolean; - permission?: string; - } - >; + routes: Record; mcp?: { tools: Record }; admin: PluginAdminConfig; } diff --git a/packages/plugin-cli/src/bundle/utils.ts b/packages/plugin-cli/src/bundle/utils.ts index e049db607d..014197b858 100644 --- a/packages/plugin-cli/src/bundle/utils.ts +++ b/packages/plugin-cli/src/bundle/utils.ts @@ -12,6 +12,7 @@ import { access, readdir, stat } from "node:fs/promises"; import { join, resolve } from "node:path"; import { pipeline } from "node:stream/promises"; +import { extractManifestRoute } from "@emdash-cms/plugin-types"; import { imageSize } from "image-size"; import { packTar } from "modern-tar/fs"; import { z } from "zod"; @@ -20,7 +21,6 @@ import { capabilitiesToDeclaredAccess } from "./types.js"; import type { ManifestHookEntry, ManifestMcpTool, - ManifestRouteEntry, PluginManifest, ResolvedPlugin, } from "./types.js"; @@ -153,11 +153,8 @@ export function extractManifest(plugin: ResolvedPlugin): PluginManifest { } } - const routes: Array = Object.entries(plugin.routes).map( - ([name, route]) => - route.public !== undefined || route.permission !== undefined - ? { name, public: route.public, permission: route.permission } - : name, + const routes = Object.entries(plugin.routes).map(([name, route]) => + extractManifestRoute(name, route), ); const tools: ManifestMcpTool[] = Object.entries(plugin.mcp?.tools ?? {}).map(([name, tool]) => { if (!MCP_TOOL_NAME_PATTERN.test(name)) throw new Error(`Invalid MCP tool name "${name}"`); diff --git a/packages/plugin-cli/tests/bundle.test.ts b/packages/plugin-cli/tests/bundle.test.ts index 61efc2d51c..90099f801d 100644 --- a/packages/plugin-cli/tests/bundle.test.ts +++ b/packages/plugin-cli/tests/bundle.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, rm, rmdir, writeFile } from "node:fs/promises"; +import { cp, mkdir, mkdtemp, readFile, rm, rmdir, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -57,6 +57,23 @@ describe("bundlePlugin", () => { expect(manifest.routes).toContain("admin"); }); + it("preserves route options from plugin source in the bundled manifest", async () => { + const dir = join(outDir, "plugin"); + await cp(FIXTURE, dir, { recursive: true }); + await writeFile( + join(dir, "src/plugin.ts"), + `export default { routes: { + catalog: { body: "text", public: true, cacheControl: "public, max-age=60", handler: async () => ({ items: [] }) }, + create: { permission: "content:create", handler: async () => ({ created: true }) }, + } };`, + ); + const result = await bundlePlugin({ dir, outDir: join(outDir, "bundle") }); + expect(result.manifest.routes).toEqual([ + { name: "catalog", body: "text", public: true, cacheControl: "public, max-age=60" }, + { name: "create", permission: "content:create" }, + ]); + }); + it("validateOnly returns the manifest but writes no tarball", async () => { const result = await bundlePlugin({ dir: FIXTURE, diff --git a/packages/plugin-types/src/index.ts b/packages/plugin-types/src/index.ts index 04adde7f38..40e3cea4e0 100644 --- a/packages/plugin-types/src/index.ts +++ b/packages/plugin-types/src/index.ts @@ -305,21 +305,15 @@ export interface ManifestHookEntry { timeout?: number; } -/** - * Route entry in a plugin manifest. Either a plain route name or a structured - * entry with the `public` flag set. - */ -export interface ManifestRouteEntry { - name: string; - public?: boolean; - /** RBAC permission required to invoke this route. */ - permission?: string; - /** - * Cache-Control value for successful GET responses. Only honored on - * routes that are also `public: true`. - */ - cacheControl?: string; -} +export { + extractManifestRoute, + extractRouteOptions, + manifestRouteEntrySchema, + routeNameSchema, + routeOptionsSchema, +} from "./routes.js"; +export type { ManifestRouteEntry, RouteOptions } from "./routes.js"; +import type { ManifestRouteEntry } from "./routes.js"; /** JSON Schema persisted in plugin manifests for cross-isolate discovery. */ export type PluginJsonSchema = Record; diff --git a/packages/plugin-types/src/manifest-schema.ts b/packages/plugin-types/src/manifest-schema.ts index 78b8bfaeff..23aaa38938 100644 --- a/packages/plugin-types/src/manifest-schema.ts +++ b/packages/plugin-types/src/manifest-schema.ts @@ -10,6 +10,9 @@ import { z } from "zod"; +import { manifestRouteEntrySchema, routeNameSchema } from "./routes.js"; +export { normalizeManifestRoute } from "./routes.js"; + import { capabilitiesToDeclaredAccess, declaredAccessToCapabilities } from "./index.js"; import type { PluginManifest } from "./index.js"; @@ -123,19 +126,6 @@ const manifestHookEntrySchema = z.object({ timeout: z.number().int().positive().optional(), }); -/** - * Structured route entry for manifest — name plus optional metadata. - * Both plain strings and objects are accepted; strings are normalized - * to `{ name }` objects via `normalizeManifestRoute()`. - */ -/** Route names must be safe path segments — alphanumeric, hyphens, underscores, forward slashes */ -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(), -}); - // ── Sub-schemas ───────────────────────────────────────────────── /** Index field names must be valid identifiers to prevent SQL injection via JSON path expressions */ @@ -298,12 +288,7 @@ export const pluginManifestSchema = z.object({ * structured objects with public metadata. * Plain strings are normalized to `{ name }` objects after parsing. */ - routes: z.array( - z.union([ - z.string().min(1).regex(routeNamePattern, "Route name must be a safe path segment"), - manifestRouteEntrySchema, - ]), - ), + routes: z.array(z.union([routeNameSchema, manifestRouteEntrySchema])), admin: pluginAdminConfigSchema, }); @@ -338,16 +323,3 @@ export function normalizeManifestHook( } return entry; } - -/** - * Normalize a manifest route entry — plain strings become `{ name }` objects. - */ -export function normalizeManifestRoute(entry: string | { name: string; public?: boolean }): { - name: string; - public?: boolean; -} { - if (typeof entry === "string") { - return { name: entry }; - } - return entry; -} diff --git a/packages/plugin-types/src/routes.ts b/packages/plugin-types/src/routes.ts new file mode 100644 index 0000000000..2f40eae1ee --- /dev/null +++ b/packages/plugin-types/src/routes.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; + +export const routeOptionsSchema = z.object({ + /** Decode the request body as UTF-8 text or preserve its original bytes. */ + body: z.enum(["text", "bytes"]).optional(), + /** Skip authentication and CSRF checks for this route. */ + public: z.boolean().optional(), + /** RBAC permission required to invoke the route. */ + permission: z.string().optional(), + /** Cache-Control for successful public GET/HEAD responses. */ + cacheControl: z.string().min(1).optional(), +}); + +export type RouteOptions = z.infer; + +export const routeNameSchema = z + .string() + .min(1) + .regex(/^[a-zA-Z0-9][a-zA-Z0-9_\-/]*$/, "Route name must be a safe path segment"); + +export const manifestRouteEntrySchema = routeOptionsSchema.extend({ name: routeNameSchema }); + +export type ManifestRouteEntry = z.infer; + +export function extractRouteOptions(route: unknown): RouteOptions { + return routeOptionsSchema.parse(typeof route === "function" ? {} : route); +} + +export function extractManifestRoute(name: string, route: unknown): ManifestRouteEntry | string { + const options = extractRouteOptions(route); + if (Object.values(options).every((value) => value === undefined)) return name; + return { name, ...options }; +} + +export function normalizeManifestRoute(entry: string | ManifestRouteEntry): ManifestRouteEntry { + return typeof entry === "string" ? { name: entry } : entry; +} diff --git a/packages/workerd/package.json b/packages/workerd/package.json index ac6063bcaa..1541f67cc5 100644 --- a/packages/workerd/package.json +++ b/packages/workerd/package.json @@ -26,7 +26,8 @@ }, "dependencies": { "emdash": "workspace:*", - "ulidx": "^2.4.1" + "ulidx": "^2.4.1", + "zod": "catalog:" }, "peerDependencies": { "kysely": ">=0.29.0", diff --git a/packages/workerd/src/sandbox/dev-runner.ts b/packages/workerd/src/sandbox/dev-runner.ts index b34b2db524..99a1737d11 100644 --- a/packages/workerd/src/sandbox/dev-runner.ts +++ b/packages/workerd/src/sandbox/dev-runner.ts @@ -13,6 +13,7 @@ * - Faster startup */ +import { Buffer } from "node:buffer"; import { randomBytes } from "node:crypto"; import { createRequire } from "node:module"; @@ -29,6 +30,7 @@ const DEFAULT_WALL_TIME_MS = 30_000; import type { PluginManifest } from "emdash"; import { createBridgeHandler } from "./bridge-handler.js"; +import { readRouteResponse } from "./route-response.js"; import { generatePluginWrapper } from "./wrapper.js"; function isRecord(value: unknown): value is Record { @@ -297,7 +299,11 @@ class MiniflareDevPlugin implements SandboxedPluginInstance { "Content-Type": "application/json", Authorization: `Bearer ${this.runner.invokeAuthToken}`, }, - body: JSON.stringify({ input, request }), + body: JSON.stringify({ + input: input instanceof Uint8Array ? Buffer.from(input).toString("base64") : input, + inputEncoding: input instanceof Uint8Array ? "base64" : undefined, + request, + }), }); if (!res.ok) { const text = await res.text(); @@ -312,7 +318,7 @@ class MiniflareDevPlugin implements SandboxedPluginInstance { } throw new Error(`Plugin ${this.id} route ${routeName} failed: ${text}`); } - return res.json(); + return readRouteResponse(res); }); } diff --git a/packages/workerd/src/sandbox/route-response.ts b/packages/workerd/src/sandbox/route-response.ts new file mode 100644 index 0000000000..5441ccea8a --- /dev/null +++ b/packages/workerd/src/sandbox/route-response.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; + +const routeResponseSchema = z.object({ + status: z.number(), + statusText: z.string(), + headers: z.array(z.tuple([z.string(), z.string()])), + body: z.array(z.number().int().min(0).max(255)).nullable(), +}); + +/** Decode the HTTP transport used by the workerd route wrapper. */ +export async function readRouteResponse(response: Response): Promise { + if (response.headers.get("X-EmDash-Raw-Response") !== "1") return response.json(); + + const value = routeResponseSchema.parse(await response.json()); + const body = value.body === null ? null : new Uint8Array(value.body); + return new Response(body, { + status: value.status, + statusText: value.statusText, + headers: value.headers, + }); +} diff --git a/packages/workerd/src/sandbox/runner.ts b/packages/workerd/src/sandbox/runner.ts index f72c75827f..5b5b18a59e 100644 --- a/packages/workerd/src/sandbox/runner.ts +++ b/packages/workerd/src/sandbox/runner.ts @@ -17,6 +17,7 @@ * auth token that encodes its ID and capabilities. */ +import { Buffer } from "node:buffer"; import { execFileSync, spawn } from "node:child_process"; import type { ChildProcess } from "node:child_process"; import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; @@ -47,6 +48,7 @@ import { createBackingServiceHandler } from "./backing-service.js"; import type { BackingServiceHandler } from "./backing-service.js"; import { generateCapnpConfig } from "./capnp.js"; import { MiniflareDevRunner } from "./dev-runner.js"; +import { readRouteResponse } from "./route-response.js"; import { generatePluginWrapper } from "./wrapper.js"; /** Replace non-alphanumeric chars for safe file/worker names */ @@ -1027,7 +1029,11 @@ class WorkerdSandboxedPlugin implements SandboxedPluginInstance { "Content-Type": "application/json", Authorization: `Bearer ${this.runner.invokeAuthToken}`, }, - body: JSON.stringify({ input, request }), + body: JSON.stringify({ + input: input instanceof Uint8Array ? Buffer.from(input).toString("base64") : input, + inputEncoding: input instanceof Uint8Array ? "base64" : undefined, + request, + }), }); if (!res.ok) { const text = await res.text(); @@ -1042,7 +1048,7 @@ class WorkerdSandboxedPlugin implements SandboxedPluginInstance { } throw new Error(`Plugin ${this.id} route ${routeName} failed: ${text}`); } - return res.json(); + return readRouteResponse(res); }); } diff --git a/packages/workerd/src/sandbox/wrapper.ts b/packages/workerd/src/sandbox/wrapper.ts index c935f34fa9..034af6c855 100644 --- a/packages/workerd/src/sandbox/wrapper.ts +++ b/packages/workerd/src/sandbox/wrapper.ts @@ -447,7 +447,15 @@ export default { // Route invocation: POST /route/{routeName} if (url.pathname.startsWith("/route/")) { const routeName = url.pathname.slice(7); // Remove "/route/" - const { input, request: serializedRequest } = await request.json(); + const { input: encodedInput, inputEncoding, request: serializedRequest } = await request.json(); + let input = encodedInput; + if (inputEncoding === "base64") { + const binaryString = atob(encodedInput); + input = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + input[i] = binaryString.charCodeAt(i); + } + } const ctx = createContext(); const route = routes[routeName]; @@ -472,6 +480,14 @@ export default { }, ctx, ); + if (result instanceof Response) { + return Response.json({ + status: result.status, + statusText: result.statusText, + headers: [...result.headers.entries()], + body: result.body === null ? null : Array.from(new Uint8Array(await result.arrayBuffer())), + }, { headers: { "X-EmDash-Raw-Response": "1" } }); + } return Response.json(result); } catch (err) { const sandboxError = sandboxRouteErrorResponse(err); diff --git a/packages/workerd/test/raw-routes.test.ts b/packages/workerd/test/raw-routes.test.ts new file mode 100644 index 0000000000..c6e65c033c --- /dev/null +++ b/packages/workerd/test/raw-routes.test.ts @@ -0,0 +1,105 @@ +import type { PluginManifest } from "emdash"; +import { Miniflare } from "miniflare"; +import { describe, expect, it } from "vitest"; + +import { generatePluginWrapper as cloudflareWrapper } from "../../cloudflare/src/sandbox/wrapper.js"; +import { MiniflareDevRunner } from "../src/sandbox/dev-runner.js"; +import { WorkerdSandboxRunner } from "../src/sandbox/runner.js"; + +const manifest: PluginManifest = { + id: "raw-http", + version: "1.0.0", + capabilities: [], + allowedHosts: [], + storage: {}, + hooks: [], + routes: [], + admin: {}, +}; +const code = `export default { routes: { + binary: async () => new Response(new Uint8Array([0, 255, 128, 10]), { status: 206, headers: { "Content-Type": "application/octet-stream", "Content-Disposition": 'attachment; filename="data.bin"' } }), + empty: async () => new Response(null, { status: 204 }), + bytes: async ({ input }) => ({ isBytes: input instanceof Uint8Array, body: [...input] }), + json: async ({ input }) => ({ input, body: "ordinary JSON", status: 201, headers: {} }), +} };`; + +const requestMeta = { + url: "https://example.com/", + method: "POST", + headers: {}, + meta: { ip: null, userAgent: null, referer: null, geo: null }, +}; + +function createCloudflareRpcFixture(pluginCode: string, hostCode: string) { + return new Miniflare({ + workers: [ + { + name: "host", + compatibilityDate: "2026-04-01", + modules: true, + serviceBindings: { PLUGIN: "plugin" }, + script: hostCode, + }, + { + name: "plugin", + compatibilityDate: "2026-04-01", + modulesRoot: "/", + modules: [ + { type: "ESModule", path: "worker.js", contents: cloudflareWrapper(manifest) }, + { type: "ESModule", path: "sandbox-plugin.js", contents: pluginCode }, + ], + }, + ], + }); +} + +const runners = [ + { name: "Miniflare", Runner: MiniflareDevRunner }, + { name: "workerd", Runner: WorkerdSandboxRunner }, +]; + +describe("raw responses across sandbox transports", () => { + it.each(runners)("preserves binary, empty, and JSON output through $name", async ({ Runner }) => { + const runner = new Runner({ db: null as never }); + try { + const plugin = await runner.load(manifest, code); + const binary = await plugin.invokeRoute("binary", undefined, requestMeta); + expect(binary).toBeInstanceOf(Response); + if (!(binary instanceof Response)) throw new Error("Expected a response"); + expect(binary.status).toBe(206); + expect(binary.headers.get("Content-Disposition")).toBe('attachment; filename="data.bin"'); + expect([...new Uint8Array(await binary.arrayBuffer())]).toEqual([0, 255, 128, 10]); + const empty = await plugin.invokeRoute("empty", undefined, requestMeta); + expect(empty).toMatchObject({ status: 204, body: null }); + expect(await plugin.invokeRoute("bytes", new Uint8Array([0, 255]), requestMeta)).toEqual({ + isBytes: true, + body: [0, 255], + }); + expect(await plugin.invokeRoute("json", { value: 7 }, requestMeta)).toEqual({ + input: { value: 7 }, + body: "ordinary JSON", + status: 201, + headers: {}, + }); + } finally { + await runner.terminateAll(); + } + }); + + it("passes a binary Response over Cloudflare Worker RPC", async () => { + const mf = createCloudflareRpcFixture( + code, + `export default { async fetch(request, env) { + return env.PLUGIN.invokeRoute("binary", undefined, { url: request.url, method: request.method, headers: {} }); + } };`, + ); + try { + const response = await mf.dispatchFetch("https://example.com/"); + expect(response.status).toBe(206); + expect(response.headers.get("Content-Type")).toBe("application/octet-stream"); + expect([...new Uint8Array(await response.arrayBuffer())]).toEqual([0, 255, 128, 10]); + } finally { + await mf.dispose(); + } + }); +}); diff --git a/packages/workerd/test/route-response.test.ts b/packages/workerd/test/route-response.test.ts new file mode 100644 index 0000000000..508c3f5fe1 --- /dev/null +++ b/packages/workerd/test/route-response.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { readRouteResponse } from "../src/sandbox/route-response.js"; + +const validEnvelope = { + status: 200, + statusText: "OK", + headers: [["Content-Type", "text/plain"]], + body: [111, 107], +}; + +function transportResponse(value: unknown) { + return Response.json(value, { headers: { "X-EmDash-Raw-Response": "1" } }); +} + +describe("readRouteResponse", () => { + it.each([ + ["envelope", { ...validEnvelope, status: "200" }], + ["headers", { ...validEnvelope, headers: [["Content-Type"]] }], + ["body", { ...validEnvelope, body: [256] }], + ])("rejects malformed %s data", async (_name, value) => { + await expect(readRouteResponse(transportResponse(value))).rejects.toThrow(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0047eb3a50..590a4ca367 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2795,6 +2795,9 @@ importers: ulidx: specifier: ^2.4.1 version: 2.4.1 + zod: + specifier: 'catalog:' + version: 4.5.4 workerd: specifier: '>=1.0.0' version: 1.20260507.1