From 5719f42ce48f6c494b9e950a4935cb74d7cda8af Mon Sep 17 00:00:00 2001 From: ttmx Date: Wed, 9 Sep 2026 18:02:03 +0100 Subject: [PATCH 1/3] fix: share plugin route metadata across build and runtime contracts --- .changeset/shared-plugin-route-options.md | 7 +++ .../core/src/cli/commands/bundle-utils.ts | 20 ++------ packages/core/src/cli/commands/bundle.ts | 4 +- packages/core/src/plugin-types.ts | 12 ++--- .../core/src/plugins/adapt-sandbox-entry.ts | 34 +++---------- packages/core/src/plugins/manifest-schema.ts | 51 ++++--------------- packages/core/src/plugins/routes.ts | 15 ++---- packages/core/src/plugins/types.ts | 16 +----- .../tests/unit/plugins/route-contract.test.ts | 43 ++++++++++++++++ packages/plugin-cli/src/build/pipeline.ts | 5 +- packages/plugin-cli/src/build/probe-schema.ts | 13 ++--- packages/plugin-cli/src/bundle/types.ts | 10 +--- packages/plugin-cli/src/bundle/utils.ts | 9 ++-- packages/plugin-cli/tests/bundle.test.ts | 19 ++++++- packages/plugin-types/src/index.ts | 24 ++++----- packages/plugin-types/src/manifest-schema.ts | 36 ++----------- packages/plugin-types/src/routes.ts | 35 +++++++++++++ packages/plugin-types/tests/routes.test.ts | 7 +++ 18 files changed, 170 insertions(+), 190 deletions(-) create mode 100644 .changeset/shared-plugin-route-options.md create mode 100644 packages/core/tests/unit/plugins/route-contract.test.ts create mode 100644 packages/plugin-types/src/routes.ts create mode 100644 packages/plugin-types/tests/routes.test.ts 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/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/plugin-types.ts b/packages/core/src/plugin-types.ts index 814b5dab17..5a21de95a9 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"; @@ -210,18 +211,11 @@ export type RouteHandler = ( */ export type RouteEntry = | RouteHandler - | { + | (RouteOptions & { 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; - }; + }); 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..57cdf5202b 100644 --- a/packages/core/src/plugins/adapt-sandbox-entry.ts +++ b/packages/core/src/plugins/adapt-sandbox-entry.ts @@ -105,22 +105,13 @@ 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, +): 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 -- sandbox schemas are validated when the route is invoked input: entry.input as PluginRoute["input"], }; } @@ -211,18 +202,9 @@ 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; resolvedRoutes[routeName] = { - input: inputSchema, - public: publicFlag, - permission, - cacheControl, + ...options, handler: async (ctx) => { if (usesPublicRouteContext) { // The incoming ctx already IS the public RouteContext 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..ca7da44b2f 100644 --- a/packages/core/src/plugins/routes.ts +++ b/packages/core/src/plugins/routes.ts @@ -9,6 +9,7 @@ */ import { z } from "zod"; +import type { RouteOptions } from "@emdash-cms/plugin-types"; import { MediaUsageActivationWriteBlockedError } from "../api/media-usage-write-fence.js"; import { PluginContextFactory, type PluginContextFactoryOptions } from "./context.js"; @@ -55,14 +56,8 @@ 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; } /** @@ -70,11 +65,7 @@ export interface RouteMeta { * 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.permission !== undefined) meta.permission = route.permission; // Private responses are per-user and must never become cacheable, even if diff --git a/packages/core/src/plugins/types.ts b/packages/core/src/plugins/types.ts index 590dc3192e..2c9a7e0d68 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 @@ -1238,23 +1239,10 @@ export interface RouteContext extends PluginContext { /** * Route definition */ -export interface PluginRoute { +export interface PluginRoute extends RouteOptions { /** 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. */ 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 */ handler: (ctx: RouteContext) => Promise; } diff --git a/packages/core/tests/unit/plugins/route-contract.test.ts b/packages/core/tests/unit/plugins/route-contract.test.ts new file mode 100644 index 0000000000..703cc2cc6f --- /dev/null +++ b/packages/core/tests/unit/plugins/route-contract.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { extractManifest as extractCliManifest } from "../../../../plugin-cli/src/bundle/utils.js"; +import { pluginManifestSchema as sharedManifestSchema } from "../../../../plugin-types/src/manifest-schema.js"; +import { extractManifest as extractCoreManifest } from "../../../src/cli/commands/bundle-utils.js"; +import { pluginManifestSchema as coreManifestSchema } from "../../../src/plugins/manifest-schema.js"; + +const options = { + public: true, + permission: "content:create" as const, + cacheControl: "public, max-age=60", +}; + +describe.each([ + { name: "core", extract: extractCoreManifest }, + { name: "standalone CLI", extract: extractCliManifest }, +])("$name route manifest round trip", ({ extract }) => { + it.each([ + { name: "core", schema: coreManifestSchema }, + { name: "shared", schema: sharedManifestSchema }, + ])("preserves route options through the $name reader", ({ schema }) => { + const manifest = extract({ + id: "route-contract", + version: "1.0.0", + capabilities: [], + allowedHosts: [], + storage: {}, + hooks: {}, + routes: { + catalog: { ...options, handler: async () => ({ items: [] }) }, + private: { public: false, handler: async () => null }, + legacy: { handler: async () => null }, + }, + admin: {}, + }); + const parsed = schema.parse(JSON.parse(JSON.stringify(manifest))); + expect(parsed.routes).toEqual([ + { name: "catalog", ...options }, + { name: "private", public: false }, + "legacy", + ]); + }); +}); 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..41defe6bbe 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: { 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", 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..2727328a67 --- /dev/null +++ b/packages/plugin-types/src/routes.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; + +export const routeOptionsSchema = z.object({ + /** 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/plugin-types/tests/routes.test.ts b/packages/plugin-types/tests/routes.test.ts new file mode 100644 index 0000000000..0c1a6c7725 --- /dev/null +++ b/packages/plugin-types/tests/routes.test.ts @@ -0,0 +1,7 @@ +import { expect, it } from "vitest"; + +import { extractManifestRoute } from "../src/routes.js"; + +it("extracts metadata from a bare route handler", () => { + expect(extractManifestRoute("legacy", async () => ({ ok: true }))).toBe("legacy"); +}); From 2486ec4172c32b1bf8ea03529abf15dbcec7c696 Mon Sep 17 00:00:00 2001 From: ttmx Date: Wed, 9 Sep 2026 18:10:42 +0100 Subject: [PATCH 2/3] feat: support raw plugin HTTP requests and responses --- .changeset/raw-plugin-http.md | 17 ++ .../plugins/creating-plugins/api-routes.mdx | 99 +++++++- packages/cloudflare/src/sandbox/wrapper.ts | 11 +- .../api/plugins/[pluginId]/[...path].ts | 18 +- packages/core/src/emdash-runtime.ts | 15 +- packages/core/src/plugin-types.ts | 41 +-- .../core/src/plugins/adapt-sandbox-entry.ts | 2 + packages/core/src/plugins/define-plugin.ts | 3 +- packages/core/src/plugins/routes.ts | 19 +- packages/core/src/plugins/sandbox/types.ts | 9 +- packages/core/src/plugins/types.ts | 19 +- .../core/tests/fixtures/plugins/body-modes.ts | 24 ++ .../runtime/plugin-raw-routes.test.ts | 237 ++++++++++++++++++ .../unit/astro/plugin-api-route-cache.test.ts | 66 +++++ .../tests/unit/plugins/route-contract.test.ts | 1 + .../tests/unit/plugins/route-input.test-d.ts | 135 ++++++++++ .../core/tests/unit/plugins/routes.test.ts | 25 ++ packages/core/tests/utils/body-mode-plugin.ts | 24 ++ packages/core/tsconfig.type-tests.json | 6 + packages/core/vitest.config.ts | 1 + packages/plugin-cli/tests/bundle.test.ts | 4 +- packages/plugin-types/src/routes.ts | 2 + packages/workerd/src/sandbox/dev-runner.ts | 9 +- .../workerd/src/sandbox/route-response.ts | 44 ++++ packages/workerd/src/sandbox/runner.ts | 9 +- packages/workerd/src/sandbox/wrapper.ts | 22 +- packages/workerd/test/raw-routes.test.ts | 203 +++++++++++++++ 27 files changed, 1009 insertions(+), 56 deletions(-) create mode 100644 .changeset/raw-plugin-http.md create mode 100644 packages/core/tests/fixtures/plugins/body-modes.ts create mode 100644 packages/core/tests/integration/runtime/plugin-raw-routes.test.ts create mode 100644 packages/core/tests/unit/plugins/route-input.test-d.ts create mode 100644 packages/core/tests/utils/body-mode-plugin.ts create mode 100644 packages/core/tsconfig.type-tests.json create mode 100644 packages/workerd/src/sandbox/route-response.ts create mode 100644 packages/workerd/test/raw-routes.test.ts diff --git a/.changeset/raw-plugin-http.md b/.changeset/raw-plugin-http.md new file mode 100644 index 0000000000..f55ca7db7a --- /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 when no schema is declared. Omit the option to keep existing JSON and query-string decoding. + +An optional `input` schema validates or transforms the decoded value before the handler runs. Sandboxed routes also enforce their declared schemas: requests that previously bypassed validation now return HTTP 400 with `VALIDATION_ERROR` when invalid. Update callers to send input matching the declared schema, or remove a schema that is not intended to be enforced. For webhook signatures, use bytes mode without an input schema, verify the original bytes, then parse and validate the payload. Both body modes buffer the request body. + +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. Private routes, errors, and non-GET/HEAD responses retain `Cache-Control: private, no-store`. Public GET/HEAD responses honor the route's `cacheControl` option, then the response's header. 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/docs/src/content/docs/plugins/creating-plugins/api-routes.mdx b/docs/src/content/docs/plugins/creating-plugins/api-routes.mdx index ddfa630539..3c02d46447 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 public GET and HEAD requests, a route's `cacheControl` option overrides the response's `Cache-Control` header. When neither is set, the default is `private, no-store`. Private routes, error responses, and other methods always use `private, no-store`. + ## 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,62 @@ 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 before schema validation | +| --- | --- | +| 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 | + +An optional `input` schema validates or transforms the decoded value before the handler runs. Invalid input returns HTTP 400 with `VALIDATION_ERROR`. This applies to native and sandboxed routes. Without a schema, 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: omit the input schema, 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; +``` + +Store the webhook secret under `settings:webhookSecret` through the plugin's [settings UI](/plugins/creating-plugins/settings/). Match the signature format and signed message to the webhook provider's protocol; some providers include a timestamp or prefix in the signed message. Validate the payload after verifying its signature. Public routes skip EmDash authentication and CSRF checks, so the handler must verify each request before making changes. + ## Common patterns ### Settings and paginated data @@ -456,7 +545,7 @@ interface SandboxedRequest { } interface SandboxedRouteContext { - input: unknown; // validate inside the handler before use + input: unknown; // inferred as string or Uint8Array for a body mode without a schema request: SandboxedRequest; requestMeta?: unknown; user?: UserInfo; // authenticated caller on private routes; undefined on public routes diff --git a/packages/cloudflare/src/sandbox/wrapper.ts b/packages/cloudflare/src/sandbox/wrapper.ts index e2b4f1000c..562be48062 100644 --- a/packages/cloudflare/src/sandbox/wrapper.ts +++ b/packages/cloudflare/src/sandbox/wrapper.ts @@ -261,9 +261,18 @@ export default class PluginEntrypoint extends WorkerEntrypoint { // Execute the route handler with input, request metadata, the // authenticated caller (private routes only), and context try { + let validatedInput = input; + if (route.input) { + const parsed = route.input.safeParse(input); + if (!parsed.success) { + const error = { __emdashSandboxRouteError: true, error: { code: "VALIDATION_ERROR", message: "Invalid request body", status: 400 } }; + return error; + } + validatedInput = parsed.data; + } return await handler( { - input, + input: validatedInput, request: serializedRequest, requestMeta: serializedRequest.meta, user: serializedRequest.user, 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..50e86c7c3d 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,17 @@ 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 (routeMeta.public && response.ok && (method === "GET" || method === "HEAD")) { + response.headers.set( + "Cache-Control", + routeMeta.cacheControl ?? response.headers.get("Cache-Control") ?? "private, no-store", + ); + } else { + response.headers.set("Cache-Control", "private, no-store"); } return response; }; diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 50a35fad0f..85afc2ad72 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -3723,7 +3723,7 @@ 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, trustedPlugin.routes[routeKey]?.body); return routeRegistry.invoke(pluginId, routeKey, { request, body, user: caller }); } @@ -3731,7 +3731,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 +4244,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 +4255,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 5a21de95a9..62cb79d3bf 100644 --- a/packages/core/src/plugin-types.ts +++ b/packages/core/src/plugin-types.ts @@ -175,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, after applying the optional input schema. */ + input: TInput; request: SandboxedRequest; requestMeta?: unknown; /** @@ -197,11 +197,11 @@ 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; @@ -209,13 +209,24 @@ export type RouteHandler = ( * Route entry — either a bare handler or the config form with * `public`, `input` schema, and so on. The build probe accepts both. */ -export type RouteEntry = - | RouteHandler - | (RouteOptions & { - handler: RouteHandler; - 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> } + | { + body: NonNullable; + input: ZodType; + 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 57cdf5202b..e3abe57569 100644 --- a/packages/core/src/plugins/adapt-sandbox-entry.ts +++ b/packages/core/src/plugins/adapt-sandbox-entry.ts @@ -111,6 +111,8 @@ function normalizeRouteEntry( if (typeof entry === "function") return { handler: entry }; return { ...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"], }; 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/routes.ts b/packages/core/src/plugins/routes.ts index ca7da44b2f..dd6f89f2a1 100644 --- a/packages/core/src/plugins/routes.ts +++ b/packages/core/src/plugins/routes.ts @@ -14,7 +14,7 @@ import type { RouteOptions } from "@emdash-cms/plugin-types"; 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 @@ -41,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}().`, ); }; @@ -67,6 +67,7 @@ export interface RouteMeta extends RouteOptions { */ 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. @@ -91,7 +92,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(); @@ -223,9 +229,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, @@ -237,7 +240,7 @@ export class PluginRouteHandler { return { success: true, data: result, - status: 200, + status: result instanceof Response ? result.status : 200, }; } catch (error) { if (error instanceof MediaUsageActivationWriteBlockedError) { @@ -292,7 +295,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/sandbox/types.ts b/packages/core/src/plugins/sandbox/types.ts index 4c602c116f..5185f2fe17 100644 --- a/packages/core/src/plugins/sandbox/types.ts +++ b/packages/core/src/plugins/sandbox/types.ts @@ -148,6 +148,7 @@ export interface SerializedRequest { } const SANDBOX_ROUTE_ERROR_DEFINITIONS = { + VALIDATION_ERROR: { message: "Invalid request body", status: 400 }, MEDIA_USAGE_ACTIVATION_IN_PROGRESS: { message: "Media usage activation is in progress", status: 503, @@ -163,7 +164,7 @@ export type SandboxRouteErrorCode = keyof typeof SANDBOX_ROUTE_ERROR_DEFINITIONS export interface SandboxRouteErrorDetails { code: SandboxRouteErrorCode; message: string; - status: 503; + status: 400 | 503; } export interface SandboxRouteErrorEnvelope { @@ -188,7 +189,11 @@ export function getSandboxRouteErrorDetails(error: unknown): SandboxRouteErrorDe if (propertyCode && nameCode && propertyCode !== nameCode) return null; const code = propertyCode ?? nameCode; - if (!code || (error.status !== undefined && error.status !== 503)) return null; + if ( + !code || + (error.status !== undefined && error.status !== SANDBOX_ROUTE_ERROR_DEFINITIONS[code].status) + ) + return null; return { code, diff --git a/packages/core/src/plugins/types.ts b/packages/core/src/plugins/types.ts index 2c9a7e0d68..2fa993b33a 100644 --- a/packages/core/src/plugins/types.ts +++ b/packages/core/src/plugins/types.ts @@ -1216,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; @@ -1240,13 +1240,22 @@ export interface RouteContext extends PluginContext { * Route definition */ export interface PluginRoute extends RouteOptions { - /** Zod schema for input validation */ - input?: z.ZodType; permission?: Permission; - /** Route handler */ + /** Validate or transform the decoded 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 }) + | (PluginRoute & { + body: NonNullable; + input: z.ZodType; + }); + export interface PluginMcpToolDefinition { description: string; route: string; @@ -1438,7 +1447,7 @@ export interface PluginDefinition; + routes?: Record; /** Routes explicitly exposed as agent-callable MCP tools. */ mcp?: PluginMcpConfig; diff --git a/packages/core/tests/fixtures/plugins/body-modes.ts b/packages/core/tests/fixtures/plugins/body-modes.ts new file mode 100644 index 0000000000..98af15b005 --- /dev/null +++ b/packages/core/tests/fixtures/plugins/body-modes.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +export default { + routes: { + text: { + body: "text", + input: z.string().regex(/^\d+$/).transform(Number), + handler: async ({ input }: { input: number }) => input + 1, + }, + bytes: { + body: "bytes", + input: z + .instanceof(Uint8Array) + .refine((value) => value.length > 0) + .transform((value) => value.length), + handler: async ({ input }: { input: number }) => input + 1, + }, + json: { + input: z.object({ amount: z.number().int().positive() }), + handler: async ({ input }: { input: { amount: number } }) => input.amount + 1, + }, + echo: async ({ input }: { input: unknown }) => input, + }, +}; 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..5553ab60ef --- /dev/null +++ b/packages/core/tests/integration/runtime/plugin-raw-routes.test.ts @@ -0,0 +1,237 @@ +import { createHmac, randomUUID } from "node:crypto"; + +import Database from "better-sqlite3"; +import { SqliteDialect } from "kysely"; +import { afterEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +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": "Olá",\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.each(["", "not JSON\r\n"])("accepts raw text %j", async (body) => { + const response = await invoke( + { public: true, body: "text", handler: async (ctx) => ctx.input }, + body, + ); + expect(await response.json()).toEqual({ success: true, data: body }); + }); + + it("keeps JSON validation and the consumed-body guard", async () => { + const response = await invoke( + { + public: true, + input: z.object({ value: z.number() }), + handler: async (ctx) => { + expect(() => ctx.request.text()).toThrow("ctx.input"); + return ctx.input; + }, + }, + '{"value":7}', + ); + expect(await response.json()).toEqual({ success: true, data: { value: 7 } }); + }); + + 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"); + }); + + it("does not cache raw error responses", async () => { + const response = await invoke( + { + public: true, + cacheControl: "public, max-age=60", + handler: async () => + new Response("no", { status: 404, headers: { "Cache-Control": "public, max-age=60" } }), + }, + undefined, + "GET", + ); + expect(response.status).toBe(404); + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); + expect(await response.text()).toBe("no"); + }); +}); + +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 }); +}); + +it("validates and transforms text input before calling the handler", async () => { + const route: PluginRoute = { + public: true, + body: "text", + input: z.string().regex(/^\d+$/).transform(Number), + handler: async ({ input }) => input + 1, + }; + const valid = await invoke(route, "41"); + expect(await valid.json()).toEqual({ success: true, data: 42 }); + const invalid = await invoke(route, "not a number"); + expect(invalid.status).toBe(400); + expect(await invalid.json()).toMatchObject({ + success: false, + error: { code: "VALIDATION_ERROR" }, + }); +}); + +it("validates byte input before calling the handler", async () => { + const route: PluginRoute = { + public: true, + body: "bytes", + input: z.instanceof(Uint8Array).refine((value) => value.length > 0), + handler: async ({ input }) => input.length, + }; + expect((await invoke(route, new Uint8Array())).status).toBe(400); + const response = await invoke(route, new Uint8Array([255, 0])); + expect(await response.json()).toEqual({ success: true, data: 2 }); +}); 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..68c94baaab 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 @@ -6,6 +6,7 @@ * public routes — everything else keeps the API default `private, no-store`. */ +import { Role } from "@emdash-cms/auth"; import type { APIRoute } from "astro"; import { describe, expect, it, vi } from "vitest"; @@ -80,3 +81,68 @@ describe("plugin API catch-all Cache-Control", () => { expect(res.headers.get("Cache-Control")).toBe("private, no-store"); }); }); + +describe("plugin API raw responses", () => { + it("preserves custom HTTP output", async () => { + const { locals } = createLocals({ + result: { + success: true, + data: new Response("", { + status: 201, + headers: { "Content-Type": "application/xml" }, + }), + }, + }); + const res = await invoke(GET, "GET", locals); + expect(res.status).toBe(201); + expect(res.headers.get("Content-Type")).toBe("application/xml"); + expect(await res.text()).toBe(""); + }); +}); + +it("keeps private raw responses uncacheable", async () => { + const response = await GET({ + params: { pluginId: "demo", path: "export" }, + request: new Request("https://example.com/_emdash/api/plugins/demo/export", { + headers: { "X-EmDash-Request": "1" }, + }), + locals: { + user: { id: "admin", role: Role.ADMIN }, + emdash: { + getPluginRouteMeta: () => ({ public: false, cacheControl: CACHE_VALUE }), + handlePluginApiRoute: async () => ({ + success: true, + data: new Response("private export", { + headers: { "Cache-Control": CACHE_VALUE, "Content-Type": "text/csv" }, + }), + }), + }, + }, + } as never); + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); + expect(await response.text()).toBe("private export"); +}); + +it.each(["GET", "HEAD"])("honors response caching on a public %s", async (method) => { + const { locals } = createLocals({ + result: { + success: true, + data: new Response("feed", { headers: { "Cache-Control": CACHE_VALUE } }), + }, + }); + const response = await invoke(GET, method, 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/plugins/route-contract.test.ts b/packages/core/tests/unit/plugins/route-contract.test.ts index 703cc2cc6f..eac987a781 100644 --- a/packages/core/tests/unit/plugins/route-contract.test.ts +++ b/packages/core/tests/unit/plugins/route-contract.test.ts @@ -6,6 +6,7 @@ import { extractManifest as extractCoreManifest } from "../../../src/cli/command import { pluginManifestSchema as coreManifestSchema } from "../../../src/plugins/manifest-schema.js"; const options = { + body: "bytes" as const, public: true, permission: "content:create" as const, cacheControl: "public, max-age=60", 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..2fdcccbbc2 --- /dev/null +++ b/packages/core/tests/unit/plugins/route-input.test-d.ts @@ -0,0 +1,135 @@ +import { expectTypeOf, it } from "vitest"; +import { z } from "zod"; + +import type { SandboxedPlugin, RouteEntry } 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("supports schema output types for transformed input", () => { + const schema = z.string().transform(Number); + const native: PluginRoute = { + body: "text", + input: schema, + handler: async ({ input }) => input + 1, + }; + const sandboxed: RouteEntry = { + body: "text", + input: schema, + handler: async ({ input }) => input + 1, + }; + expectTypeOf(native.handler).parameter(0).toHaveProperty("input").toEqualTypeOf(); + expectTypeOf(sandboxed.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 whole-context and two-argument handlers", () => { + const plugin = { + routes: { + text: { + body: "text", + handler: async (routeCtx, ctx) => { + expectTypeOf(routeCtx.input).toEqualTypeOf(); + return ctx.plugin.id + routeCtx.input; + }, + }, + bytes: { + body: "bytes", + handler: async (routeCtx, ctx) => { + expectTypeOf(routeCtx.input).toEqualTypeOf>(); + return ctx.plugin.id + routeCtx.input.length; + }, + }, + json: { + handler: async (routeCtx, ctx) => { + expectTypeOf(routeCtx.input).toEqualTypeOf(); + return ctx.plugin.id; + }, + }, + bare: async (routeCtx, ctx) => { + expectTypeOf(routeCtx.input).toEqualTypeOf(); + return ctx.plugin.id; + }, + }, + } satisfies SandboxedPlugin; + expectTypeOf(plugin.routes.json.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/tests/unit/plugins/routes.test.ts b/packages/core/tests/unit/plugins/routes.test.ts index 6b062a83ef..da5f332be9 100644 --- a/packages/core/tests/unit/plugins/routes.test.ts +++ b/packages/core/tests/unit/plugins/routes.test.ts @@ -759,3 +759,28 @@ describe("parseRouteInput (#2146)", () => { expect(result.data).toEqual({ received: { limit: 20, q: "hello" } }); }); }); + +describe("raw route body", () => { + it("validates text using the input schema", async () => { + const handler = new PluginRouteHandler( + createTestPlugin({ + routes: { + webhook: { + body: "text", + input: z.string().transform((value) => value.toUpperCase()), + handler: async (ctx) => ctx.input, + }, + }, + }), + createMockFactoryOptions(), + ); + const result = await handler.invoke("webhook", { + request: new Request("https://example.com", { method: "POST" }), + body: "plain text\r\n", + }); + expect(result).toMatchObject({ + success: true, + data: "PLAIN TEXT\r\n", + }); + }); +}); diff --git a/packages/core/tests/utils/body-mode-plugin.ts b/packages/core/tests/utils/body-mode-plugin.ts new file mode 100644 index 0000000000..14da464413 --- /dev/null +++ b/packages/core/tests/utils/body-mode-plugin.ts @@ -0,0 +1,24 @@ +import { fileURLToPath } from "node:url"; + +import { build } from "tsdown"; + +export async function buildBodyModePlugin(): Promise { + const bundles = await build({ + config: false, + entry: [fileURLToPath(new URL("../fixtures/plugins/body-modes.ts", import.meta.url))], + platform: "neutral", + format: "esm", + noExternal: [/.*/], + write: false, + dts: false, + clean: false, + tsconfig: false, + }); + try { + const chunk = bundles[0]?.chunks.find((output) => output.type === "chunk" && output.isEntry); + if (!chunk || chunk.type !== "chunk") throw new Error("Missing bundled body-mode plugin"); + return chunk.code; + } finally { + await Promise.all(bundles.map((bundle) => bundle[Symbol.asyncDispose]())); + } +} 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/tests/bundle.test.ts b/packages/plugin-cli/tests/bundle.test.ts index 41defe6bbe..90099f801d 100644 --- a/packages/plugin-cli/tests/bundle.test.ts +++ b/packages/plugin-cli/tests/bundle.test.ts @@ -63,13 +63,13 @@ describe("bundlePlugin", () => { await writeFile( join(dir, "src/plugin.ts"), `export default { routes: { - catalog: { public: true, cacheControl: "public, max-age=60", handler: async () => ({ items: [] }) }, + 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", public: true, cacheControl: "public, max-age=60" }, + { name: "catalog", body: "text", public: true, cacheControl: "public, max-age=60" }, { name: "create", permission: "content:create" }, ]); }); diff --git a/packages/plugin-types/src/routes.ts b/packages/plugin-types/src/routes.ts index 2727328a67..2f40eae1ee 100644 --- a/packages/plugin-types/src/routes.ts +++ b/packages/plugin-types/src/routes.ts @@ -1,6 +1,8 @@ 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. */ diff --git a/packages/workerd/src/sandbox/dev-runner.ts b/packages/workerd/src/sandbox/dev-runner.ts index b34b2db524..949a5b27ac 100644 --- a/packages/workerd/src/sandbox/dev-runner.ts +++ b/packages/workerd/src/sandbox/dev-runner.ts @@ -29,6 +29,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 +298,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 ? [...input] : input, + inputEncoding: input instanceof Uint8Array ? "bytes" : undefined, + request, + }), }); if (!res.ok) { const text = await res.text(); @@ -312,7 +317,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..0ee9401f3d --- /dev/null +++ b/packages/workerd/src/sandbox/route-response.ts @@ -0,0 +1,44 @@ +/** 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: unknown = await response.json(); + if ( + typeof value !== "object" || + value === null || + !("status" in value) || + typeof value.status !== "number" || + !("statusText" in value) || + typeof value.statusText !== "string" || + !("headers" in value) || + !Array.isArray(value.headers) || + !("body" in value) || + (value.body !== null && !Array.isArray(value.body)) + ) { + throw new Error("Invalid sandbox route response"); + } + const headers = new Headers(); + for (const entry of value.headers) { + if ( + !Array.isArray(entry) || + entry.length !== 2 || + typeof entry[0] !== "string" || + typeof entry[1] !== "string" + ) { + throw new Error("Invalid sandbox route response headers"); + } + headers.append(entry[0], entry[1]); + } + let body: Uint8Array | null = null; + if (value.body !== null) { + const bytes: number[] = []; + for (const byte of value.body) { + if (typeof byte !== "number" || !Number.isInteger(byte) || byte < 0 || byte > 255) { + throw new Error("Invalid sandbox route response body"); + } + bytes.push(byte); + } + body = new Uint8Array(bytes); + } + return new Response(body, { status: value.status, statusText: value.statusText, headers }); +} diff --git a/packages/workerd/src/sandbox/runner.ts b/packages/workerd/src/sandbox/runner.ts index f72c75827f..0adefa8d95 100644 --- a/packages/workerd/src/sandbox/runner.ts +++ b/packages/workerd/src/sandbox/runner.ts @@ -47,6 +47,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 +1028,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 ? [...input] : input, + inputEncoding: input instanceof Uint8Array ? "bytes" : undefined, + request, + }), }); if (!res.ok) { const text = await res.text(); @@ -1042,7 +1047,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..8ec620cb12 100644 --- a/packages/workerd/src/sandbox/wrapper.ts +++ b/packages/workerd/src/sandbox/wrapper.ts @@ -447,7 +447,8 @@ 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(); + const input = inputEncoding === "bytes" ? new Uint8Array(encodedInput) : encodedInput; const ctx = createContext(); const route = routes[routeName]; @@ -461,17 +462,34 @@ export default { } try { + let validatedInput = input; + if (route.input) { + const parsed = route.input.safeParse(input); + if (!parsed.success) { + const error = { __emdashSandboxRouteError: true, error: { code: "VALIDATION_ERROR", message: "Invalid request body", status: 400 } }; + return Response.json(error, { status: 400 }); + } + validatedInput = parsed.data; + } // user: authenticated caller for private routes, resolved by // the host before dispatch. const result = await handler( { - input, + input: validatedInput, request: serializedRequest, requestMeta: serializedRequest?.meta, user: serializedRequest?.user, }, 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..21adc6299d --- /dev/null +++ b/packages/workerd/test/raw-routes.test.ts @@ -0,0 +1,203 @@ +import type { PluginManifest } from "emdash"; +import { Miniflare } from "miniflare"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { generatePluginWrapper as cloudflareWrapper } from "../../cloudflare/src/sandbox/wrapper.js"; +import { buildBodyModePlugin } from "../../core/tests/utils/body-mode-plugin.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 }), + redirect: async () => Response.redirect("https://example.com/feed", 307), + json: async ({ input }) => ({ input, body: "ordinary JSON", status: 201, headers: {} }), + raw: async ({ input }) => new Response(input, { headers: { "Content-Type": "text/plain" } }), +} };`; + +const requestMeta = { + url: "https://example.com/", + method: "POST", + headers: {}, + meta: { ip: null, userAgent: null, referer: null, geo: null }, +}; + +const runners = [ + { name: "Miniflare", Runner: MiniflareDevRunner }, + { name: "workerd", Runner: WorkerdSandboxRunner }, +]; + +describe("raw responses across sandbox transports", () => { + it.each(runners)( + "preserves binary, empty, redirect, text, 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 }); + const redirect = await plugin.invokeRoute("redirect", undefined, requestMeta); + if (!(redirect instanceof Response)) throw new Error("Expected a response"); + expect(redirect.status).toBe(307); + expect(redirect.headers.get("Location")).toBe("https://example.com/feed"); + const raw = await plugin.invokeRoute("raw", "Olá\r\n", requestMeta); + if (!(raw instanceof Response)) throw new Error("Expected a response"); + expect(await raw.text()).toBe("Olá\r\n"); + expect(await plugin.invokeRoute("json", { value: 7 }, requestMeta)).toEqual({ + input: { value: 7 }, + body: "ordinary JSON", + status: 201, + headers: {}, + }); + } finally { + await runner.terminateAll(); + } + }, + ); + + it("passes raw text and a native Response over Cloudflare Worker RPC", async () => { + const mf = new Miniflare({ + workers: [ + { + name: "host", + compatibilityDate: "2026-04-01", + modules: true, + serviceBindings: { PLUGIN: "plugin" }, + script: `export default { async fetch(request, env) { + return env.PLUGIN.invokeRoute("raw", await request.text(), { url: request.url, method: request.method, headers: {} }); + } };`, + }, + { + name: "plugin", + compatibilityDate: "2026-04-01", + modulesRoot: "/", + modules: [ + { type: "ESModule", path: "worker.js", contents: cloudflareWrapper(manifest) }, + { type: "ESModule", path: "sandbox-plugin.js", contents: code }, + ], + }, + ], + }); + try { + const response = await mf.dispatchFetch("https://example.com/", { + method: "POST", + body: "Olá\r\n", + }); + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("text/plain"); + expect(await response.text()).toBe("Olá\r\n"); + } finally { + await mf.dispose(); + } + }); +}); + +describe("sandbox body schema validation", () => { + let pluginCode: string; + beforeAll(async () => { + pluginCode = await buildBodyModePlugin(); + }); + + it.each(runners)( + "validates and transforms text, bytes, and JSON through $name", + async ({ Runner }) => { + const runner = new Runner({ db: null as never }); + try { + const plugin = await runner.load(manifest, pluginCode); + expect(await plugin.invokeRoute("text", "41", requestMeta)).toBe(42); + expect(await plugin.invokeRoute("bytes", new Uint8Array([0xff, 0]), requestMeta)).toBe(3); + expect(await plugin.invokeRoute("json", { amount: 41 }, requestMeta)).toBe(42); + for (const [name, input] of [ + ["text", "invalid"], + ["bytes", new Uint8Array()], + ["json", { amount: -1 }], + ] as const) { + await expect(plugin.invokeRoute(name, input, requestMeta)).rejects.toMatchObject({ + code: "VALIDATION_ERROR", + status: 400, + }); + } + const data = { inputEncoding: "bytes", input: [1, 2] }; + expect(await plugin.invokeRoute("echo", data, requestMeta)).toEqual(data); + } finally { + await runner.terminateAll(); + } + }, + ); + + it("validates and transforms text, bytes, and JSON over Cloudflare RPC", async () => { + const mf = new Miniflare({ + workers: [ + { + name: "host", + compatibilityDate: "2026-04-01", + modules: true, + serviceBindings: { PLUGIN: "plugin" }, + script: `export default { async fetch(request, env) { + const name = new URL(request.url).pathname.slice(1); + const input = name === "bytes" ? new Uint8Array(await request.arrayBuffer()) : name === "text" ? await request.text() : await request.json(); + return Response.json(await env.PLUGIN.invokeRoute(name, input, { url: request.url, method: request.method, headers: {} })); + } };`, + }, + { + name: "plugin", + compatibilityDate: "2026-04-01", + modulesRoot: "/", + modules: [ + { type: "ESModule", path: "worker.js", contents: cloudflareWrapper(manifest) }, + { type: "ESModule", path: "sandbox-plugin.js", contents: pluginCode }, + ], + }, + ], + }); + try { + const text = await mf.dispatchFetch("https://example.com/text", { + method: "POST", + body: "41", + }); + expect(await text.json()).toBe(42); + const bytes = await mf.dispatchFetch("https://example.com/bytes", { + method: "POST", + body: new Uint8Array([0xff, 0]), + }); + expect(await bytes.json()).toBe(3); + const json = await mf.dispatchFetch("https://example.com/json", { + method: "POST", + body: '{"amount":41}', + }); + expect(await json.json()).toBe(42); + for (const [name, body] of [ + ["text", "invalid"], + ["bytes", ""], + ["json", '{"amount":-1}'], + ]) { + const response = await mf.dispatchFetch("https://example.com/" + name, { + method: "POST", + body, + }); + expect(await response.json()).toMatchObject({ + __emdashSandboxRouteError: true, + error: { code: "VALIDATION_ERROR", status: 400 }, + }); + } + } finally { + await mf.dispose(); + } + }); +}); From cbc22c2307af588635e8f0ab85b70f0712882f29 Mon Sep 17 00:00:00 2001 From: ttmx Date: Fri, 11 Sep 2026 19:09:34 +0100 Subject: [PATCH 3/3] fix: narrow raw plugin route support --- .changeset/raw-plugin-http.md | 6 +- apps/release-action/dist/index.js | 21 +- .../plugins/creating-plugins/api-routes.mdx | 12 +- packages/cloudflare/src/sandbox/wrapper.ts | 11 +- .../api/plugins/[pluginId]/[...path].ts | 4 +- packages/core/src/emdash-runtime.ts | 5 +- packages/core/src/plugin-types.ts | 9 +- .../core/src/plugins/adapt-sandbox-entry.ts | 7 +- packages/core/src/plugins/routes.ts | 13 +- packages/core/src/plugins/sandbox/types.ts | 9 +- packages/core/src/plugins/types.ts | 17 +- .../core/tests/fixtures/plugins/body-modes.ts | 24 -- .../runtime/plugin-raw-routes.test.ts | 71 +----- .../unit/astro/plugin-api-route-cache.test.ts | 51 +---- .../core/tests/unit/cli/bundle-utils.test.ts | 16 +- .../unit/plugins/manifest-schema.test.ts | 11 +- .../tests/unit/plugins/route-contract.test.ts | 44 ---- .../tests/unit/plugins/route-input.test-d.ts | 38 +--- .../core/tests/unit/plugins/routes.test.ts | 25 --- packages/core/tests/utils/body-mode-plugin.ts | 24 -- packages/plugin-types/tests/routes.test.ts | 7 - packages/workerd/package.json | 3 +- packages/workerd/src/sandbox/dev-runner.ts | 5 +- .../workerd/src/sandbox/route-response.ts | 55 ++--- packages/workerd/src/sandbox/runner.ts | 5 +- packages/workerd/src/sandbox/wrapper.ts | 20 +- packages/workerd/test/raw-routes.test.ts | 212 +++++------------- packages/workerd/test/route-response.test.ts | 24 ++ pnpm-lock.yaml | 3 + 29 files changed, 190 insertions(+), 562 deletions(-) delete mode 100644 packages/core/tests/fixtures/plugins/body-modes.ts delete mode 100644 packages/core/tests/unit/plugins/route-contract.test.ts delete mode 100644 packages/core/tests/utils/body-mode-plugin.ts delete mode 100644 packages/plugin-types/tests/routes.test.ts create mode 100644 packages/workerd/test/route-response.test.ts diff --git a/.changeset/raw-plugin-http.md b/.changeset/raw-plugin-http.md index f55ca7db7a..b2a11605dc 100644 --- a/.changeset/raw-plugin-http.md +++ b/.changeset/raw-plugin-http.md @@ -8,10 +8,10 @@ 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 when no schema is declared. Omit the option to keep existing JSON and query-string decoding. +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. -An optional `input` schema validates or transforms the decoded value before the handler runs. Sandboxed routes also enforce their declared schemas: requests that previously bypassed validation now return HTTP 400 with `VALIDATION_ERROR` when invalid. Update callers to send input matching the declared schema, or remove a schema that is not intended to be enforced. For webhook signatures, use bytes mode without an input schema, verify the original bytes, then parse and validate the payload. Both body modes buffer the request body. +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. Private routes, errors, and non-GET/HEAD responses retain `Cache-Control: private, no-store`. Public GET/HEAD responses honor the route's `cacheControl` option, then the response's header. Route URLs remain under `/_emdash/api/plugins//`. +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/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 3c02d46447..bb517ca216 100644 --- a/docs/src/content/docs/plugins/creating-plugins/api-routes.mdx +++ b/docs/src/content/docs/plugins/creating-plugins/api-routes.mdx @@ -305,7 +305,7 @@ export default { 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 public GET and HEAD requests, a route's `cacheControl` option overrides the response's `Cache-Control` header. When neither is set, the default is `private, no-store`. Private routes, error responses, and other methods always use `private, no-store`. +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 @@ -387,15 +387,15 @@ handler: async (routeCtx, ctx) => { 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 before schema validation | +| 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 | -An optional `input` schema validates or transforms the decoded value before the handler runs. Invalid input returns HTTP 400 with `VALIDATION_ERROR`. This applies to native and sandboxed routes. Without a schema, text and byte routes infer `string` and `Uint8Array` handler input types respectively. +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: omit the input schema, verify the original bytes, then parse and validate the payload. +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: @@ -431,8 +431,6 @@ export default { } satisfies SandboxedPlugin; ``` -Store the webhook secret under `settings:webhookSecret` through the plugin's [settings UI](/plugins/creating-plugins/settings/). Match the signature format and signed message to the webhook provider's protocol; some providers include a timestamp or prefix in the signed message. Validate the payload after verifying its signature. Public routes skip EmDash authentication and CSRF checks, so the handler must verify each request before making changes. - ## Common patterns ### Settings and paginated data @@ -545,7 +543,7 @@ interface SandboxedRequest { } interface SandboxedRouteContext { - input: unknown; // inferred as string or Uint8Array for a body mode without a schema + 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/cloudflare/src/sandbox/wrapper.ts b/packages/cloudflare/src/sandbox/wrapper.ts index 562be48062..e2b4f1000c 100644 --- a/packages/cloudflare/src/sandbox/wrapper.ts +++ b/packages/cloudflare/src/sandbox/wrapper.ts @@ -261,18 +261,9 @@ export default class PluginEntrypoint extends WorkerEntrypoint { // Execute the route handler with input, request metadata, the // authenticated caller (private routes only), and context try { - let validatedInput = input; - if (route.input) { - const parsed = route.input.safeParse(input); - if (!parsed.success) { - const error = { __emdashSandboxRouteError: true, error: { code: "VALIDATION_ERROR", message: "Invalid request body", status: 400 } }; - return error; - } - validatedInput = parsed.data; - } return await handler( { - input: validatedInput, + input, request: serializedRequest, requestMeta: serializedRequest.meta, user: serializedRequest.user, 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 50e86c7c3d..d56d7e722d 100644 --- a/packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].ts +++ b/packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].ts @@ -99,13 +99,11 @@ const handleRequest: APIRoute = async ({ params, request, locals }) => { result.data instanceof Response ? new Response(result.data.body, result.data) : apiSuccess(result.data); - if (routeMeta.public && response.ok && (method === "GET" || method === "HEAD")) { + if (response.ok && (method === "GET" || method === "HEAD")) { response.headers.set( "Cache-Control", routeMeta.cacheControl ?? response.headers.get("Cache-Control") ?? "private, no-store", ); - } else { - response.headers.set("Cache-Control", "private, no-store"); } return response; }; diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 85afc2ad72..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, trustedPlugin.routes[routeKey]?.body); + const body = await parseRouteInput( + request, + buildRouteMeta(trustedPlugin.routes[routeKey] ?? {}).body, + ); return routeRegistry.invoke(pluginId, routeKey, { request, body, user: caller }); } diff --git a/packages/core/src/plugin-types.ts b/packages/core/src/plugin-types.ts index 62cb79d3bf..7fe15d5710 100644 --- a/packages/core/src/plugin-types.ts +++ b/packages/core/src/plugin-types.ts @@ -178,7 +178,7 @@ export interface SandboxedRequest { * Without a body mode or explicit type argument, input is unknown. */ export interface SandboxedRouteContext { - /** Decoded request input, after applying the optional input schema. */ + /** Decoded request input. */ input: TInput; request: SandboxedRequest; requestMeta?: unknown; @@ -207,7 +207,7 @@ export type RouteHandler = ( /** * 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. */ interface RouteEntryOptions extends RouteOptions { permission?: Permission; @@ -221,11 +221,6 @@ export type RouteEntry = | { body?: undefined; input?: unknown; handler: RouteHandler } | { body: "text"; input?: undefined; handler: RouteHandler } | { body: "bytes"; input?: undefined; handler: RouteHandler> } - | { - body: NonNullable; - input: ZodType; - handler: RouteHandler; - } )); export interface SandboxedMcpTool { diff --git a/packages/core/src/plugins/adapt-sandbox-entry.ts b/packages/core/src/plugins/adapt-sandbox-entry.ts index e3abe57569..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"; @@ -107,7 +109,7 @@ function resolveSandboxedHook(entry: AnyHookEntry, pluginId: string): ResolvedHo */ function normalizeRouteEntry( entry: RouteEntry, -): Omit & { handler: RouteHandler } { +): RouteOptions & Omit & { handler: RouteHandler } { if (typeof entry === "function") return { handler: entry }; return { ...entry, @@ -205,6 +207,7 @@ export function adaptSandboxEntry( for (const [routeName, rawEntry] of Object.entries(definition.routes)) { const normalized = normalizeRouteEntry(rawEntry); const { handler, ...options } = normalized; + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the resolved contract erases the authoring-only input specialization resolvedRoutes[routeName] = { ...options, handler: async (ctx) => { @@ -239,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/routes.ts b/packages/core/src/plugins/routes.ts index dd6f89f2a1..9ad80ae291 100644 --- a/packages/core/src/plugins/routes.ts +++ b/packages/core/src/plugins/routes.ts @@ -8,8 +8,8 @@ * */ -import { z } from "zod"; 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"; @@ -52,25 +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 extends RouteOptions { public: boolean; } -/** - * 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: 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; } diff --git a/packages/core/src/plugins/sandbox/types.ts b/packages/core/src/plugins/sandbox/types.ts index 5185f2fe17..4c602c116f 100644 --- a/packages/core/src/plugins/sandbox/types.ts +++ b/packages/core/src/plugins/sandbox/types.ts @@ -148,7 +148,6 @@ export interface SerializedRequest { } const SANDBOX_ROUTE_ERROR_DEFINITIONS = { - VALIDATION_ERROR: { message: "Invalid request body", status: 400 }, MEDIA_USAGE_ACTIVATION_IN_PROGRESS: { message: "Media usage activation is in progress", status: 503, @@ -164,7 +163,7 @@ export type SandboxRouteErrorCode = keyof typeof SANDBOX_ROUTE_ERROR_DEFINITIONS export interface SandboxRouteErrorDetails { code: SandboxRouteErrorCode; message: string; - status: 400 | 503; + status: 503; } export interface SandboxRouteErrorEnvelope { @@ -189,11 +188,7 @@ export function getSandboxRouteErrorDetails(error: unknown): SandboxRouteErrorDe if (propertyCode && nameCode && propertyCode !== nameCode) return null; const code = propertyCode ?? nameCode; - if ( - !code || - (error.status !== undefined && error.status !== SANDBOX_ROUTE_ERROR_DEFINITIONS[code].status) - ) - return null; + if (!code || (error.status !== undefined && error.status !== 503)) return null; return { code, diff --git a/packages/core/src/plugins/types.ts b/packages/core/src/plugins/types.ts index 2fa993b33a..fc6ae02e89 100644 --- a/packages/core/src/plugins/types.ts +++ b/packages/core/src/plugins/types.ts @@ -1239,9 +1239,16 @@ export interface RouteContext extends PluginContext { /** * Route definition */ -export interface PluginRoute extends RouteOptions { +type PluginRouteBody = [TInput] extends [string] + ? "text" + : [TInput] extends [Uint8Array] + ? "bytes" + : undefined; + +export interface PluginRoute extends Omit { + body?: PluginRouteBody; permission?: Permission; - /** Validate or transform the decoded input before invoking the 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; @@ -1250,11 +1257,7 @@ export interface PluginRoute extends RouteOptions { type PluginRouteDefinition = | (PluginRoute & { body?: undefined }) | (PluginRoute & { body: "text"; input?: undefined }) - | (PluginRoute> & { body: "bytes"; input?: undefined }) - | (PluginRoute & { - body: NonNullable; - input: z.ZodType; - }); + | (PluginRoute> & { body: "bytes"; input?: undefined }); export interface PluginMcpToolDefinition { description: string; diff --git a/packages/core/tests/fixtures/plugins/body-modes.ts b/packages/core/tests/fixtures/plugins/body-modes.ts deleted file mode 100644 index 98af15b005..0000000000 --- a/packages/core/tests/fixtures/plugins/body-modes.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { z } from "zod"; - -export default { - routes: { - text: { - body: "text", - input: z.string().regex(/^\d+$/).transform(Number), - handler: async ({ input }: { input: number }) => input + 1, - }, - bytes: { - body: "bytes", - input: z - .instanceof(Uint8Array) - .refine((value) => value.length > 0) - .transform((value) => value.length), - handler: async ({ input }: { input: number }) => input + 1, - }, - json: { - input: z.object({ amount: z.number().int().positive() }), - handler: async ({ input }: { input: { amount: number } }) => input.amount + 1, - }, - echo: async ({ input }: { input: unknown }) => input, - }, -}; diff --git a/packages/core/tests/integration/runtime/plugin-raw-routes.test.ts b/packages/core/tests/integration/runtime/plugin-raw-routes.test.ts index 5553ab60ef..e493c7efcf 100644 --- a/packages/core/tests/integration/runtime/plugin-raw-routes.test.ts +++ b/packages/core/tests/integration/runtime/plugin-raw-routes.test.ts @@ -3,7 +3,6 @@ import { createHmac, randomUUID } from "node:crypto"; import Database from "better-sqlite3"; import { SqliteDialect } from "kysely"; import { afterEach, describe, expect, it } from "vitest"; -import { z } from "zod"; import { MiniflareDevRunner } from "../../../../workerd/src/sandbox/dev-runner.js"; import { GET, POST } from "../../../src/astro/routes/api/plugins/[pluginId]/[...path].js"; @@ -43,7 +42,7 @@ async function invoke( describe("raw plugin routes", () => { it("verifies a signature over the original webhook text", async () => { - const body = '{ "message": "Olá",\r\n "amount": 1.00 }\n'; + const body = '{ "message": "Hi 👋",\r\n "amount": 1.00 }\n'; const signature = createHmac("sha256", "webhook-secret") .update(Buffer.from(body)) .digest("hex"); @@ -68,29 +67,6 @@ describe("raw plugin routes", () => { }); }); - it.each(["", "not JSON\r\n"])("accepts raw text %j", async (body) => { - const response = await invoke( - { public: true, body: "text", handler: async (ctx) => ctx.input }, - body, - ); - expect(await response.json()).toEqual({ success: true, data: body }); - }); - - it("keeps JSON validation and the consumed-body guard", async () => { - const response = await invoke( - { - public: true, - input: z.object({ value: z.number() }), - handler: async (ctx) => { - expect(() => ctx.request.text()).toThrow("ctx.input"); - return ctx.input; - }, - }, - '{"value":7}', - ); - expect(await response.json()).toEqual({ success: true, data: { value: 7 } }); - }); - it("serves a Response body, status, and headers without a JSON envelope", async () => { const response = await invoke( { @@ -119,22 +95,6 @@ describe("raw plugin routes", () => { expect(response.status).toBe(307); expect(response.headers.get("Location")).toBe("https://example.com/feed"); }); - - it("does not cache raw error responses", async () => { - const response = await invoke( - { - public: true, - cacheControl: "public, max-age=60", - handler: async () => - new Response("no", { status: 404, headers: { "Cache-Control": "public, max-age=60" } }), - }, - undefined, - "GET", - ); - expect(response.status).toBe(404); - expect(response.headers.get("Cache-Control")).toBe("private, no-store"); - expect(await response.text()).toBe("no"); - }); }); describe("sandboxed raw plugin routes", () => { @@ -206,32 +166,3 @@ it("preserves all request bytes, including a BOM and invalid UTF-8", async () => ); expect(await response.json()).toEqual({ success: true, data: signature }); }); - -it("validates and transforms text input before calling the handler", async () => { - const route: PluginRoute = { - public: true, - body: "text", - input: z.string().regex(/^\d+$/).transform(Number), - handler: async ({ input }) => input + 1, - }; - const valid = await invoke(route, "41"); - expect(await valid.json()).toEqual({ success: true, data: 42 }); - const invalid = await invoke(route, "not a number"); - expect(invalid.status).toBe(400); - expect(await invalid.json()).toMatchObject({ - success: false, - error: { code: "VALIDATION_ERROR" }, - }); -}); - -it("validates byte input before calling the handler", async () => { - const route: PluginRoute = { - public: true, - body: "bytes", - input: z.instanceof(Uint8Array).refine((value) => value.length > 0), - handler: async ({ input }) => input.length, - }; - expect((await invoke(route, new Uint8Array())).status).toBe(400); - const response = await invoke(route, new Uint8Array([255, 0])); - expect(await response.json()).toEqual({ success: true, data: 2 }); -}); 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 68c94baaab..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,11 +2,9 @@ * 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 { Role } from "@emdash-cms/auth"; import type { APIRoute } from "astro"; import { describe, expect, it, vi } from "vitest"; @@ -27,7 +25,6 @@ function createLocals({ user: null, emdash: { handlePluginApiRoute, - // Mirrors getRouteMeta: cacheControl is only ever present on public routes. getPluginRouteMeta: () => ({ public: true, cacheControl }), }, }, @@ -82,56 +79,14 @@ describe("plugin API catch-all Cache-Control", () => { }); }); -describe("plugin API raw responses", () => { - it("preserves custom HTTP output", async () => { - const { locals } = createLocals({ - result: { - success: true, - data: new Response("", { - status: 201, - headers: { "Content-Type": "application/xml" }, - }), - }, - }); - const res = await invoke(GET, "GET", locals); - expect(res.status).toBe(201); - expect(res.headers.get("Content-Type")).toBe("application/xml"); - expect(await res.text()).toBe(""); - }); -}); - -it("keeps private raw responses uncacheable", async () => { - const response = await GET({ - params: { pluginId: "demo", path: "export" }, - request: new Request("https://example.com/_emdash/api/plugins/demo/export", { - headers: { "X-EmDash-Request": "1" }, - }), - locals: { - user: { id: "admin", role: Role.ADMIN }, - emdash: { - getPluginRouteMeta: () => ({ public: false, cacheControl: CACHE_VALUE }), - handlePluginApiRoute: async () => ({ - success: true, - data: new Response("private export", { - headers: { "Cache-Control": CACHE_VALUE, "Content-Type": "text/csv" }, - }), - }), - }, - }, - } as never); - expect(response.status).toBe(200); - expect(response.headers.get("Cache-Control")).toBe("private, no-store"); - expect(await response.text()).toBe("private export"); -}); - -it.each(["GET", "HEAD"])("honors response caching on a public %s", async (method) => { +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, method, locals); + 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-contract.test.ts b/packages/core/tests/unit/plugins/route-contract.test.ts deleted file mode 100644 index eac987a781..0000000000 --- a/packages/core/tests/unit/plugins/route-contract.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { extractManifest as extractCliManifest } from "../../../../plugin-cli/src/bundle/utils.js"; -import { pluginManifestSchema as sharedManifestSchema } from "../../../../plugin-types/src/manifest-schema.js"; -import { extractManifest as extractCoreManifest } from "../../../src/cli/commands/bundle-utils.js"; -import { pluginManifestSchema as coreManifestSchema } from "../../../src/plugins/manifest-schema.js"; - -const options = { - body: "bytes" as const, - public: true, - permission: "content:create" as const, - cacheControl: "public, max-age=60", -}; - -describe.each([ - { name: "core", extract: extractCoreManifest }, - { name: "standalone CLI", extract: extractCliManifest }, -])("$name route manifest round trip", ({ extract }) => { - it.each([ - { name: "core", schema: coreManifestSchema }, - { name: "shared", schema: sharedManifestSchema }, - ])("preserves route options through the $name reader", ({ schema }) => { - const manifest = extract({ - id: "route-contract", - version: "1.0.0", - capabilities: [], - allowedHosts: [], - storage: {}, - hooks: {}, - routes: { - catalog: { ...options, handler: async () => ({ items: [] }) }, - private: { public: false, handler: async () => null }, - legacy: { handler: async () => null }, - }, - admin: {}, - }); - const parsed = schema.parse(JSON.parse(JSON.stringify(manifest))); - expect(parsed.routes).toEqual([ - { name: "catalog", ...options }, - { name: "private", public: false }, - "legacy", - ]); - }); -}); diff --git a/packages/core/tests/unit/plugins/route-input.test-d.ts b/packages/core/tests/unit/plugins/route-input.test-d.ts index 2fdcccbbc2..b5de7d5acb 100644 --- a/packages/core/tests/unit/plugins/route-input.test-d.ts +++ b/packages/core/tests/unit/plugins/route-input.test-d.ts @@ -1,7 +1,6 @@ import { expectTypeOf, it } from "vitest"; -import { z } from "zod"; -import type { SandboxedPlugin, RouteEntry } from "../../../src/plugin-types.js"; +import type { SandboxedPlugin } from "../../../src/plugin-types.js"; import { definePlugin } from "../../../src/plugins/define-plugin.js"; import type { PluginRoute } from "../../../src/plugins/types.js"; @@ -65,22 +64,6 @@ it("infers sandboxed route input from the body mode", () => { .toEqualTypeOf(); }); -it("supports schema output types for transformed input", () => { - const schema = z.string().transform(Number); - const native: PluginRoute = { - body: "text", - input: schema, - handler: async ({ input }) => input + 1, - }; - const sandboxed: RouteEntry = { - body: "text", - input: schema, - handler: async ({ input }) => input + 1, - }; - expectTypeOf(native.handler).parameter(0).toHaveProperty("input").toEqualTypeOf(); - expectTypeOf(sandboxed.handler).parameter(0).toHaveProperty("input").toEqualTypeOf(); -}); - it("accepts existing explicitly typed routes and interface extensions", () => { interface ExtendedRoute extends PluginRoute { label: string; @@ -89,7 +72,7 @@ it("accepts existing explicitly typed routes and interface extensions", () => { definePlugin({ id: "legacy-route", version: "1.0.0", routes: { legacy: route } }); }); -it("contextually types whole-context and two-argument handlers", () => { +it("contextually types configured and bare two-argument handlers", () => { const plugin = { routes: { text: { @@ -99,29 +82,16 @@ it("contextually types whole-context and two-argument handlers", () => { return ctx.plugin.id + routeCtx.input; }, }, - bytes: { - body: "bytes", - handler: async (routeCtx, ctx) => { - expectTypeOf(routeCtx.input).toEqualTypeOf>(); - return ctx.plugin.id + routeCtx.input.length; - }, - }, - json: { - handler: async (routeCtx, ctx) => { - expectTypeOf(routeCtx.input).toEqualTypeOf(); - return ctx.plugin.id; - }, - }, bare: async (routeCtx, ctx) => { expectTypeOf(routeCtx.input).toEqualTypeOf(); return ctx.plugin.id; }, }, } satisfies SandboxedPlugin; - expectTypeOf(plugin.routes.json.handler) + expectTypeOf(plugin.routes.text.handler) .parameter(0) .toHaveProperty("input") - .toEqualTypeOf(); + .toEqualTypeOf(); }); it("preserves the public resolved-route contract for existing consumers", () => { diff --git a/packages/core/tests/unit/plugins/routes.test.ts b/packages/core/tests/unit/plugins/routes.test.ts index da5f332be9..6b062a83ef 100644 --- a/packages/core/tests/unit/plugins/routes.test.ts +++ b/packages/core/tests/unit/plugins/routes.test.ts @@ -759,28 +759,3 @@ describe("parseRouteInput (#2146)", () => { expect(result.data).toEqual({ received: { limit: 20, q: "hello" } }); }); }); - -describe("raw route body", () => { - it("validates text using the input schema", async () => { - const handler = new PluginRouteHandler( - createTestPlugin({ - routes: { - webhook: { - body: "text", - input: z.string().transform((value) => value.toUpperCase()), - handler: async (ctx) => ctx.input, - }, - }, - }), - createMockFactoryOptions(), - ); - const result = await handler.invoke("webhook", { - request: new Request("https://example.com", { method: "POST" }), - body: "plain text\r\n", - }); - expect(result).toMatchObject({ - success: true, - data: "PLAIN TEXT\r\n", - }); - }); -}); diff --git a/packages/core/tests/utils/body-mode-plugin.ts b/packages/core/tests/utils/body-mode-plugin.ts deleted file mode 100644 index 14da464413..0000000000 --- a/packages/core/tests/utils/body-mode-plugin.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { fileURLToPath } from "node:url"; - -import { build } from "tsdown"; - -export async function buildBodyModePlugin(): Promise { - const bundles = await build({ - config: false, - entry: [fileURLToPath(new URL("../fixtures/plugins/body-modes.ts", import.meta.url))], - platform: "neutral", - format: "esm", - noExternal: [/.*/], - write: false, - dts: false, - clean: false, - tsconfig: false, - }); - try { - const chunk = bundles[0]?.chunks.find((output) => output.type === "chunk" && output.isEntry); - if (!chunk || chunk.type !== "chunk") throw new Error("Missing bundled body-mode plugin"); - return chunk.code; - } finally { - await Promise.all(bundles.map((bundle) => bundle[Symbol.asyncDispose]())); - } -} diff --git a/packages/plugin-types/tests/routes.test.ts b/packages/plugin-types/tests/routes.test.ts deleted file mode 100644 index 0c1a6c7725..0000000000 --- a/packages/plugin-types/tests/routes.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { expect, it } from "vitest"; - -import { extractManifestRoute } from "../src/routes.js"; - -it("extracts metadata from a bare route handler", () => { - expect(extractManifestRoute("legacy", async () => ({ ok: true }))).toBe("legacy"); -}); 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 949a5b27ac..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"; @@ -299,8 +300,8 @@ class MiniflareDevPlugin implements SandboxedPluginInstance { Authorization: `Bearer ${this.runner.invokeAuthToken}`, }, body: JSON.stringify({ - input: input instanceof Uint8Array ? [...input] : input, - inputEncoding: input instanceof Uint8Array ? "bytes" : undefined, + input: input instanceof Uint8Array ? Buffer.from(input).toString("base64") : input, + inputEncoding: input instanceof Uint8Array ? "base64" : undefined, request, }), }); diff --git a/packages/workerd/src/sandbox/route-response.ts b/packages/workerd/src/sandbox/route-response.ts index 0ee9401f3d..5441ccea8a 100644 --- a/packages/workerd/src/sandbox/route-response.ts +++ b/packages/workerd/src/sandbox/route-response.ts @@ -1,44 +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: unknown = await response.json(); - if ( - typeof value !== "object" || - value === null || - !("status" in value) || - typeof value.status !== "number" || - !("statusText" in value) || - typeof value.statusText !== "string" || - !("headers" in value) || - !Array.isArray(value.headers) || - !("body" in value) || - (value.body !== null && !Array.isArray(value.body)) - ) { - throw new Error("Invalid sandbox route response"); - } - const headers = new Headers(); - for (const entry of value.headers) { - if ( - !Array.isArray(entry) || - entry.length !== 2 || - typeof entry[0] !== "string" || - typeof entry[1] !== "string" - ) { - throw new Error("Invalid sandbox route response headers"); - } - headers.append(entry[0], entry[1]); - } - let body: Uint8Array | null = null; - if (value.body !== null) { - const bytes: number[] = []; - for (const byte of value.body) { - if (typeof byte !== "number" || !Number.isInteger(byte) || byte < 0 || byte > 255) { - throw new Error("Invalid sandbox route response body"); - } - bytes.push(byte); - } - body = new Uint8Array(bytes); - } - return new Response(body, { status: value.status, statusText: value.statusText, headers }); + 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 0adefa8d95..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"; @@ -1029,8 +1030,8 @@ class WorkerdSandboxedPlugin implements SandboxedPluginInstance { Authorization: `Bearer ${this.runner.invokeAuthToken}`, }, body: JSON.stringify({ - input: input instanceof Uint8Array ? [...input] : input, - inputEncoding: input instanceof Uint8Array ? "bytes" : undefined, + input: input instanceof Uint8Array ? Buffer.from(input).toString("base64") : input, + inputEncoding: input instanceof Uint8Array ? "base64" : undefined, request, }), }); diff --git a/packages/workerd/src/sandbox/wrapper.ts b/packages/workerd/src/sandbox/wrapper.ts index 8ec620cb12..034af6c855 100644 --- a/packages/workerd/src/sandbox/wrapper.ts +++ b/packages/workerd/src/sandbox/wrapper.ts @@ -448,7 +448,14 @@ export default { if (url.pathname.startsWith("/route/")) { const routeName = url.pathname.slice(7); // Remove "/route/" const { input: encodedInput, inputEncoding, request: serializedRequest } = await request.json(); - const input = inputEncoding === "bytes" ? new Uint8Array(encodedInput) : encodedInput; + 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]; @@ -462,20 +469,11 @@ export default { } try { - let validatedInput = input; - if (route.input) { - const parsed = route.input.safeParse(input); - if (!parsed.success) { - const error = { __emdashSandboxRouteError: true, error: { code: "VALIDATION_ERROR", message: "Invalid request body", status: 400 } }; - return Response.json(error, { status: 400 }); - } - validatedInput = parsed.data; - } // user: authenticated caller for private routes, resolved by // the host before dispatch. const result = await handler( { - input: validatedInput, + input, request: serializedRequest, requestMeta: serializedRequest?.meta, user: serializedRequest?.user, diff --git a/packages/workerd/test/raw-routes.test.ts b/packages/workerd/test/raw-routes.test.ts index 21adc6299d..c6e65c033c 100644 --- a/packages/workerd/test/raw-routes.test.ts +++ b/packages/workerd/test/raw-routes.test.ts @@ -1,9 +1,8 @@ import type { PluginManifest } from "emdash"; import { Miniflare } from "miniflare"; -import { beforeAll, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { generatePluginWrapper as cloudflareWrapper } from "../../cloudflare/src/sandbox/wrapper.js"; -import { buildBodyModePlugin } from "../../core/tests/utils/body-mode-plugin.js"; import { MiniflareDevRunner } from "../src/sandbox/dev-runner.js"; import { WorkerdSandboxRunner } from "../src/sandbox/runner.js"; @@ -20,9 +19,8 @@ const manifest: PluginManifest = { 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 }), - redirect: async () => Response.redirect("https://example.com/feed", 307), + bytes: async ({ input }) => ({ isBytes: input instanceof Uint8Array, body: [...input] }), json: async ({ input }) => ({ input, body: "ordinary JSON", status: 201, headers: {} }), - raw: async ({ input }) => new Response(input, { headers: { "Content-Type": "text/plain" } }), } };`; const requestMeta = { @@ -32,170 +30,74 @@ const requestMeta = { 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, redirect, text, 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 }); - const redirect = await plugin.invokeRoute("redirect", undefined, requestMeta); - if (!(redirect instanceof Response)) throw new Error("Expected a response"); - expect(redirect.status).toBe(307); - expect(redirect.headers.get("Location")).toBe("https://example.com/feed"); - const raw = await plugin.invokeRoute("raw", "Olá\r\n", requestMeta); - if (!(raw instanceof Response)) throw new Error("Expected a response"); - expect(await raw.text()).toBe("Olá\r\n"); - expect(await plugin.invokeRoute("json", { value: 7 }, requestMeta)).toEqual({ - input: { value: 7 }, - body: "ordinary JSON", - status: 201, - headers: {}, - }); - } finally { - await runner.terminateAll(); - } - }, - ); - - it("passes raw text and a native Response over Cloudflare Worker RPC", async () => { - const mf = new Miniflare({ - workers: [ - { - name: "host", - compatibilityDate: "2026-04-01", - modules: true, - serviceBindings: { PLUGIN: "plugin" }, - script: `export default { async fetch(request, env) { - return env.PLUGIN.invokeRoute("raw", await request.text(), { url: request.url, method: request.method, headers: {} }); - } };`, - }, - { - name: "plugin", - compatibilityDate: "2026-04-01", - modulesRoot: "/", - modules: [ - { type: "ESModule", path: "worker.js", contents: cloudflareWrapper(manifest) }, - { type: "ESModule", path: "sandbox-plugin.js", contents: code }, - ], - }, - ], - }); + it.each(runners)("preserves binary, empty, and JSON output through $name", async ({ Runner }) => { + const runner = new Runner({ db: null as never }); try { - const response = await mf.dispatchFetch("https://example.com/", { - method: "POST", - body: "Olá\r\n", + 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: {}, }); - expect(response.status).toBe(200); - expect(response.headers.get("Content-Type")).toBe("text/plain"); - expect(await response.text()).toBe("Olá\r\n"); } finally { - await mf.dispose(); + await runner.terminateAll(); } }); -}); - -describe("sandbox body schema validation", () => { - let pluginCode: string; - beforeAll(async () => { - pluginCode = await buildBodyModePlugin(); - }); - - it.each(runners)( - "validates and transforms text, bytes, and JSON through $name", - async ({ Runner }) => { - const runner = new Runner({ db: null as never }); - try { - const plugin = await runner.load(manifest, pluginCode); - expect(await plugin.invokeRoute("text", "41", requestMeta)).toBe(42); - expect(await plugin.invokeRoute("bytes", new Uint8Array([0xff, 0]), requestMeta)).toBe(3); - expect(await plugin.invokeRoute("json", { amount: 41 }, requestMeta)).toBe(42); - for (const [name, input] of [ - ["text", "invalid"], - ["bytes", new Uint8Array()], - ["json", { amount: -1 }], - ] as const) { - await expect(plugin.invokeRoute(name, input, requestMeta)).rejects.toMatchObject({ - code: "VALIDATION_ERROR", - status: 400, - }); - } - const data = { inputEncoding: "bytes", input: [1, 2] }; - expect(await plugin.invokeRoute("echo", data, requestMeta)).toEqual(data); - } finally { - await runner.terminateAll(); - } - }, - ); - it("validates and transforms text, bytes, and JSON over Cloudflare RPC", async () => { - const mf = new Miniflare({ - workers: [ - { - name: "host", - compatibilityDate: "2026-04-01", - modules: true, - serviceBindings: { PLUGIN: "plugin" }, - script: `export default { async fetch(request, env) { - const name = new URL(request.url).pathname.slice(1); - const input = name === "bytes" ? new Uint8Array(await request.arrayBuffer()) : name === "text" ? await request.text() : await request.json(); - return Response.json(await env.PLUGIN.invokeRoute(name, input, { url: request.url, method: request.method, headers: {} })); - } };`, - }, - { - name: "plugin", - compatibilityDate: "2026-04-01", - modulesRoot: "/", - modules: [ - { type: "ESModule", path: "worker.js", contents: cloudflareWrapper(manifest) }, - { type: "ESModule", path: "sandbox-plugin.js", contents: pluginCode }, - ], - }, - ], - }); + 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 text = await mf.dispatchFetch("https://example.com/text", { - method: "POST", - body: "41", - }); - expect(await text.json()).toBe(42); - const bytes = await mf.dispatchFetch("https://example.com/bytes", { - method: "POST", - body: new Uint8Array([0xff, 0]), - }); - expect(await bytes.json()).toBe(3); - const json = await mf.dispatchFetch("https://example.com/json", { - method: "POST", - body: '{"amount":41}', - }); - expect(await json.json()).toBe(42); - for (const [name, body] of [ - ["text", "invalid"], - ["bytes", ""], - ["json", '{"amount":-1}'], - ]) { - const response = await mf.dispatchFetch("https://example.com/" + name, { - method: "POST", - body, - }); - expect(await response.json()).toMatchObject({ - __emdashSandboxRouteError: true, - error: { code: "VALIDATION_ERROR", status: 400 }, - }); - } + 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