Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/raw-plugin-http.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"emdash": minor
"@emdash-cms/plugin-types": minor
"@emdash-cms/plugin-cli": minor
"@emdash-cms/cloudflare": minor
"@emdash-cms/sandbox-workerd": minor
---

Adds raw request bodies and custom HTTP responses to the Plugin API for native and sandboxed routes.

Set `body: "text"` or `body: "bytes"` on a route to receive a UTF-8 string or the original body bytes in `ctx.input` (`routeCtx.input` for sandboxed handlers). Body modes infer `string` or `Uint8Array` handler inputs. Omit the option to keep existing JSON and query-string decoding.

Both body modes buffer the request body. For webhook signatures, use bytes mode, verify the original bytes, then parse and validate the payload.

Return a Web API `Response` to serve text, XML, binary data, redirects, or custom status codes and headers without the JSON envelope. The Node sandbox buffers response bodies for transport. Successful GET/HEAD responses honor the route's `cacheControl` option, then an explicit response header, and default to `Cache-Control: private, no-store`. Route URLs remain under `/_emdash/api/plugins/<slug>/`.

Rebuild sandboxed plugins after setting a body mode so their generated manifests include the option.
7 changes: 7 additions & 0 deletions .changeset/shared-plugin-route-options.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 9 additions & 12 deletions apps/release-action/dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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
});
/**
Expand Down
97 changes: 92 additions & 5 deletions docs/src/content/docs/plugins/creating-plugins/api-routes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>/sitemap`:

```typescript title="src/plugin.ts"
import type { SandboxedPlugin } from "emdash/plugin";

export default {
routes: {
sitemap: {
public: true,
handler: async () => new Response(
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://example.com/</loc></url></urlset>',
{ headers: { "Content-Type": "application/xml; charset=utf-8" } },
),
},
},
} satisfies SandboxedPlugin;
```

Route names remain under the plugin API prefix. To expose a conventional root URL such as `/sitemap.xml`, add an Astro route that forwards the public plugin route's response.

For successful GET and HEAD requests, a public route's `cacheControl` option overrides the response's `Cache-Control` header. Otherwise, EmDash preserves an explicit response header and defaults to `private, no-store` when none is set.

## Errors

Throw when a sandboxed route cannot complete. EmDash logs the exception and returns a `ROUTE_ERROR`. The thrown message may be included in that response, so never put credentials, personal data, internal paths, or stack traces in an exception message:
Expand All @@ -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

Expand All @@ -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 });
}
},
},
Expand All @@ -340,10 +377,60 @@ handler: async (routeCtx, ctx) => {

ctx.log.info("Request", { meta: requestMeta });

if (request.method !== "POST") return { error: "POST_REQUIRED" };
if (request.method !== "POST") {
return new Response("POST required", { status: 405 });
}
},
```

### Request body decoding

Set `body` on a route to choose how EmDash decodes the request into `routeCtx.input`. Native handlers receive the value in `ctx.input`.

| Route option | Input |
| --- | --- |
| Omitted | JSON for POST, PUT, and PATCH; query parameters for other methods |
| `body: "text"` | UTF-8 string |
| `body: "bytes"` | `Uint8Array` containing the original body bytes |

Text and byte routes infer `string` and `Uint8Array` handler input types respectively.

Both body modes read the whole body into memory. An empty body produces `""` in text mode and an empty `Uint8Array` in bytes mode. Text mode uses `Request.text()`, which decodes UTF-8 and may remove a byte order mark or replace invalid sequences. Use bytes mode for webhook signatures: verify the original bytes, then parse and validate the payload.

The following route verifies a hex-encoded HMAC-SHA256 signature with a secret stored in plugin settings:

```typescript title="src/plugin.ts"
import type { SandboxedPlugin } from "emdash/plugin";

export default {
routes: {
webhook: {
public: true,
body: "bytes",
handler: async (routeCtx, ctx) => {
const signature = routeCtx.request.headers["x-webhook-signature"] ?? "";
const secret = await ctx.kv.get<string>("settings:webhookSecret");
if (!secret || !/^[a-f0-9]{64}$/i.test(signature)) {
return new Response("Invalid signature", { status: 401 });
}
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"],
);
const signatureBytes = Uint8Array.from(
signature.match(/../g)!, (pair) => Number.parseInt(pair, 16),
);
const valid = await crypto.subtle.verify(
"HMAC", key, signatureBytes, routeCtx.input,
);
if (!valid) return new Response("Invalid signature", { status: 401 });
return { received: true };
},
},
},
} satisfies SandboxedPlugin;
```

## Common patterns

### Settings and paginated data
Expand Down Expand Up @@ -456,7 +543,7 @@ interface SandboxedRequest {
}

interface SandboxedRouteContext {
input: unknown; // validate inside the handler before use
input: unknown; // inferred as string or Uint8Array when a body mode is set
request: SandboxedRequest;
requestMeta?: unknown;
user?: UserInfo; // authenticated caller on private routes; undefined on public routes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,15 @@ const handleRequest: APIRoute = async ({ params, request, locals }) => {
return apiError(code, message, status);
}

const response = apiSuccess(result.data);
// Public routes may opt in to CDN/browser caching for GET responses.
// getRouteMeta only ever exposes cacheControl on public routes, and errors
// above keep the default private, no-store. Astro serves HEAD via this GET
// export, which is fine: same headers, no body.
if (routeMeta.cacheControl && (method === "GET" || method === "HEAD")) {
response.headers.set("Cache-Control", routeMeta.cacheControl);
const response =
result.data instanceof Response
? new Response(result.data.body, result.data)
: apiSuccess(result.data);
if (response.ok && (method === "GET" || method === "HEAD")) {
response.headers.set(
"Cache-Control",
routeMeta.cacheControl ?? response.headers.get("Cache-Control") ?? "private, no-store",
);
}
return response;
};
Expand Down
20 changes: 3 additions & 17 deletions packages/core/src/cli/commands/bundle-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -22,7 +23,6 @@ import type {
HookName,
ManifestHookEntry,
ManifestMcpTool,
ManifestRouteEntry,
} from "../../plugins/types.js";

// ── Constants ────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -159,22 +159,8 @@ export function extractManifest(plugin: ResolvedPlugin): PluginManifest {
}
}

const routes: Array<ManifestRouteEntry | string> = 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}"`);
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/cli/commands/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -305,8 +306,7 @@ export const bundleCommand = defineCommand({
const routeObj = route as Record<string, unknown>;
(resolvedPlugin.routes as Record<string, unknown>)[name] = {
handler: routeObj.handler,
public: routeObj.public,
cacheControl: routeObj.cacheControl,
...extractRouteOptions(routeObj),
};
}
}
Expand Down
18 changes: 14 additions & 4 deletions packages/core/src/emdash-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3723,15 +3723,24 @@ export class EmDashRuntime {
const routeKey = path.replace(LEADING_SLASH_PATTERN, "");

// Body methods parse JSON; GET/HEAD/DELETE parse the query string (#2146).
const body = await parseRouteInput(request);
const body = await parseRouteInput(
request,
buildRouteMeta(trustedPlugin.routes[routeKey] ?? {}).body,
);

return routeRegistry.invoke(pluginId, routeKey, { request, body, user: caller });
}

// 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 {
Expand Down Expand Up @@ -4238,7 +4247,8 @@ export class EmDashRuntime {
plugin: SandboxedPluginInstance,
path: string,
request: Request,
user?: UserInfo,
user: UserInfo | undefined,
bodyMode?: RouteMeta["body"],
): Promise<{
success: boolean;
data?: unknown;
Expand All @@ -4248,7 +4258,7 @@ export class EmDashRuntime {
const routeName = path.replace(LEADING_SLASH_PATTERN, "");

// Body methods parse JSON; GET/HEAD/DELETE parse the query string (#2146).
const body = await parseRouteInput(request);
const body = await parseRouteInput(request, bodyMode);

try {
const headers = sanitizeHeadersForSandbox(request.headers);
Expand Down
Loading
Loading