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
5 changes: 5 additions & 0 deletions .changeset/plugin-route-raw-body.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": minor
---

Adds opt-in raw request body access for native plugin routes: set `rawBody: true` on a route to receive the unparsed body as `ctx.rawBody`, enabling webhook signature verification and non-JSON payloads.
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,29 @@ Key details of this configuration:
- **`id`, `version`, and `capabilities` appear twice.** Once on the descriptor, once on `definePlugin()`. They should match. The descriptor's copy is what `astro.config.mjs` sees at build time; the `definePlugin()` copy is what runs at request time.
- **Native route handlers take a single argument** — `(ctx: RouteContext)` where `ctx.input`, `ctx.request`, and `ctx.requestMeta` are merged with the regular `PluginContext` properties. This is the opposite of standard format's two-argument shape. See [API routes](/plugins/creating-plugins/api-routes/) for the full surface (everything else is identical).

## Raw request bodies (webhook signatures)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This says webhook providers sign the "exact raw bytes", but ctx.rawBody is a UTF-8 decoded string rather than bytes. In practice most webhook payloads are UTF-8 JSON and signature libraries accept a string, but the wording over-promises for binary or non-UTF8 deliveries. Consider changing to "exact raw text" or adding a note that ctx.rawBody is the UTF-8 decoded body.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already addressed — the comment and both doc sections now describe ctx.rawBody as the body "as a UTF-8 decoded string" and explicitly note that binary/non-UTF8 bodies are not preserved byte-exactly (with the TextEncoder re-encode path for signature libraries).

The dispatcher parses the request body before your handler runs and exposes it as `ctx.input`; the body stream is consumed, so `ctx.request.text()` is unavailable. That's fine for normal routes — but webhook providers (Stripe, GitHub, Svix, …) sign the payload _exactly as delivered_, and an HMAC computed over a re-serialized `ctx.input` will never match (whitespace and key order don't survive a parse/stringify round-trip).

Routes that need the unparsed body opt in with `rawBody: true`:

```typescript
routes: {
"webhooks/provider": {
public: true,
rawBody: true,
handler: async (ctx) => {
const signature = ctx.request.headers.get("X-Signature") ?? "";
await verifyHmac(ctx.rawBody ?? "", signature, secret); // throw on mismatch
const event = JSON.parse(ctx.rawBody ?? "{}");
// ...
},
},
},
```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Same wording issue as line 155: "exactly as received" implies the original byte stream, while ctx.rawBody is a decoded string. A small doc tweak would set the right expectation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already addressed — the comment and both doc sections now describe ctx.rawBody as the body "as a UTF-8 decoded string" and explicitly note that binary/non-UTF8 bodies are not preserved byte-exactly (with the TextEncoder re-encode path for signature libraries).

`ctx.rawBody` is the delivered body as a UTF-8 decoded string — webhook payloads are UTF-8 text in practice, so signature libraries that accept a string (or `new TextEncoder().encode(ctx.rawBody)`) work directly; binary bodies are not preserved byte-exactly. `ctx.input` still works as usual (parsed from the same buffer). Non-JSON payloads — e.g. form-encoded webhook deliveries — arrive with `ctx.input` undefined but `ctx.rawBody` intact, so the handler can parse them itself. The flag is native-format only; sandboxed plugins receive requests across a serialization boundary and don't support it yet.

## Plugin id rules

The `id` field must match `/^[a-z][a-z0-9_-]*$/` — start with a lowercase letter, then letters, digits, hyphens, or underscores. The id is used as a single path segment in plugin route URLs and as part of generated SQL identifiers for plugin storage indexes, so anything outside that pattern fails at runtime. The following values show which ids are accepted:
Expand Down
12 changes: 9 additions & 3 deletions packages/core/src/emdash-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3342,14 +3342,20 @@ export class EmDashRuntime {

const routeKey = path.replace(LEADING_SLASH_PATTERN, "");

// Buffer the body as text so routes with `rawBody: true` can see the
// payload exactly as delivered — as a UTF-8 decoded string, which is
// what webhook signature verification needs in practice; parse JSON
// from the same buffer for `ctx.input`.
let body: unknown = undefined;
let rawBody: string | undefined;
try {
body = await request.json();
rawBody = await request.text();
Comment on lines 3344 to +3352

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The new unit tests in routes.test.ts pass rawBody directly into PluginRouteHandler.invoke; they don't exercise this block, which is the real behavioral change of the PR. There is already an integration-test pattern for EmDashRuntime.handlePluginApiRoute (see packages/core/tests/integration/runtime/plugin-media-route.test.ts). Adding an integration test would verify that request.text() is read once, JSON is parsed into ctx.input, non-JSON payloads leave ctx.input undefined, and ctx.rawBody is only populated for opted-in routes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — added tests/integration/runtime/plugin-raw-body-route.test.ts exercising handlePluginApiRoute end to end: (1) JSON payload parsed into ctx.input while ctx.rawBody keeps the exact delivered text (whitespace/key order preserved), (2) non-JSON payload leaves ctx.input undefined with ctx.rawBody intact, (3) ctx.rawBody stays undefined for a route that did not opt in.

if (rawBody) body = JSON.parse(rawBody);
} catch {
// No body or not JSON
// No body or not JSON — rawBody (when read) is still passed through
}

return routeRegistry.invoke(pluginId, routeKey, { request, body });
return routeRegistry.invoke(pluginId, routeKey, { request, body, rawBody });
}

// Check sandboxed (marketplace) plugins second
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/plugins/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ function guardConsumedRequestBody(request: Request): Request {
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. ` +
`Read ctx.input instead of ctx.request.${prop}().`,
`Read ctx.input instead of ctx.request.${prop}() — or set rawBody: true ` +
`on the route and read ctx.rawBody if you need the unparsed body.`,
);
};
}
Expand Down Expand Up @@ -78,6 +79,8 @@ export interface InvokeRouteOptions {
request: Request;
/** Request body (already parsed) */
body?: unknown;
/** Unparsed request body; forwarded to the handler only for routes with `rawBody: true` */
rawBody?: string;
}

/**
Expand Down Expand Up @@ -141,6 +144,8 @@ export class PluginRouteHandler {
// (#1293). Metadata extraction uses the original request (headers only).
request: guardConsumedRequestBody(options.request),
requestMeta: extractRequestMeta(options.request, this.trustedProxyHeaders),
// Only routes that opt in see the raw body (signature verification).
rawBody: route.rawBody === true ? options.rawBody : undefined,
};

// Execute handler
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/plugins/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1160,6 +1160,15 @@ export interface RouteContext<TInput = unknown> extends PluginContext {
request: Request;
/** Normalized request metadata (IP, user agent, geo) */
requestMeta: RequestMeta;
/**
* The unparsed request body as a UTF-8 decoded string. Only populated when
* the route sets `rawBody: true` — needed to verify webhook signatures,
* which are computed over the delivered payload (a re-serialized
* `ctx.input` never matches, since whitespace and key order don't survive
* a parse/stringify round-trip). Webhook payloads are UTF-8 text in
* practice; binary bodies are not preserved byte-exactly.
*/
rawBody?: string;
}

/**
Expand All @@ -1173,6 +1182,12 @@ export interface PluginRoute<TInput = unknown> {
* Public routes skip session/token auth and CSRF checks.
*/
public?: boolean;
/**
* Expose the unparsed request body as `ctx.rawBody`, alongside the parsed
* `ctx.input`. Opt-in so the body string is only retained where a handler
* actually needs it (webhook signature verification, non-JSON payloads).
*/
rawBody?: boolean;
/** Route handler */
handler: (ctx: RouteContext<TInput>) => Promise<unknown>;
}
Expand Down
150 changes: 150 additions & 0 deletions packages/core/tests/integration/runtime/plugin-raw-body-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* Trusted plugin routes with `rawBody: true` must receive the delivered
* body as a UTF-8 string on `ctx.rawBody`, alongside the parsed `ctx.input`.
*
* The dispatcher reads `request.text()` once, parses JSON from the same
* buffer into `ctx.input`, and only surfaces `rawBody` to opted-in routes —
* the behavioral core of the raw-body feature that unit tests invoking
* `PluginRouteHandler.invoke` directly do not exercise.
*/

import { randomUUID } from "node:crypto";

import Database from "better-sqlite3";
import { SqliteDialect } from "kysely";
import { describe, expect, it } from "vitest";

import { EmDashRuntime } from "../../../src/emdash-runtime.js";
import type { RuntimeDependencies } from "../../../src/emdash-runtime.js";
import { definePlugin } from "../../../src/plugins/define-plugin.js";
import type { Storage } from "../../../src/storage/types.js";

const stubStorage: Storage = {
async upload() {
throw new Error("storage not used by this test");
},
async download() {
throw new Error("storage not used by this test");
},
async delete() {},
async exists() {
return false;
},
async list() {
return { items: [] };
},
async getSignedUploadUrl() {
throw new Error("storage not used by this test");
},
getPublicUrl: (key) => `/media/${key}`,
};

interface Captured {
hasRawBody: boolean;
rawBody: string | undefined;
input: unknown;
}

function createDeps(captured: Captured, rawBodyOptIn: boolean): RuntimeDependencies {
const entrypoint = `test-plugin-raw-body-${randomUUID()}`;
return {
config: {
database: { entrypoint, config: {}, type: "sqlite" },
storage: { entrypoint, config: {} },
},
plugins: [
definePlugin({
id: "webhook-sink",
version: "1.0.0",
capabilities: [],
routes: {
hook: {
rawBody: rawBodyOptIn,
handler: async (ctx) => {
captured.hasRawBody = "rawBody" in ctx && ctx.rawBody !== undefined;
captured.rawBody = ctx.rawBody;
captured.input = ctx.input;
return { ok: true };
},
},
},
}),
],
createDialect: () => new SqliteDialect({ database: new Database(":memory:") }),
createStorage: () => stubStorage,
sandboxEnabled: false,
sandboxedPluginEntries: [],
createSandboxRunner: null,
};
}

function post(json: string): Request {
return new Request("http://test.local/_emdash/api/plugin/webhook-sink/hook", {
method: "POST",
headers: { "content-type": "application/json" },
body: json,
});
}

describe("EmDashRuntime.handlePluginApiRoute — rawBody", () => {
it("populates ctx.rawBody with the exact delivered text and ctx.input with parsed JSON", async () => {
const captured: Captured = { hasRawBody: false, rawBody: undefined, input: undefined };
const runtime = await EmDashRuntime.create(createDeps(captured, true));
try {
// Whitespace and key order must survive in rawBody even though
// ctx.input is the parsed equivalent.
const payload = '{ "b": 1,\n "a": "x" }';
const result = await runtime.handlePluginApiRoute(
"webhook-sink",
"POST",
"/hook",
post(payload),
);

expect(result.success).toBe(true);
expect(captured.hasRawBody).toBe(true);
expect(captured.rawBody).toBe(payload);
expect(captured.input).toEqual({ a: "x", b: 1 });
} finally {
await runtime.stopCron();
}
});

it("leaves ctx.input undefined for non-JSON payloads but still exposes rawBody", async () => {
const captured: Captured = { hasRawBody: false, rawBody: undefined, input: undefined };
const runtime = await EmDashRuntime.create(createDeps(captured, true));
try {
const result = await runtime.handlePluginApiRoute(
"webhook-sink",
"POST",
"/hook",
post("not json at all"),
);

expect(result.success).toBe(true);
expect(captured.rawBody).toBe("not json at all");
expect(captured.input).toBeUndefined();
} finally {
await runtime.stopCron();
}
});

it("does not populate ctx.rawBody for routes that did not opt in", async () => {
const captured: Captured = { hasRawBody: false, rawBody: undefined, input: undefined };
const runtime = await EmDashRuntime.create(createDeps(captured, false));
try {
const result = await runtime.handlePluginApiRoute(
"webhook-sink",
"POST",
"/hook",
post('{"a":1}'),
);

expect(result.success).toBe(true);
expect(captured.hasRawBody).toBe(false);
expect(captured.input).toEqual({ a: 1 });
} finally {
await runtime.stopCron();
}
});
});
83 changes: 83 additions & 0 deletions packages/core/tests/unit/plugins/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,89 @@ describe("PluginRouteHandler", () => {
expect(result.data).toEqual({ hasEmail: true, hasSend: true });
});

it("exposes ctx.rawBody on routes that opt in with rawBody: true", async () => {
// Signature verification needs the payload exactly as delivered:
// whitespace and key order must survive, which a re-serialized
// ctx.input can't guarantee.
const raw = `{"b":2, "a":1}`;
let seen: { rawBody?: string; input?: unknown } = {};
const plugin = createTestPlugin({
routes: {
webhook: {
rawBody: true,
handler: async (ctx) => {
seen = { rawBody: ctx.rawBody, input: ctx.input };
return null;
},
},
},
});
const handler = new PluginRouteHandler(plugin, createMockFactoryOptions());

const result = await handler.invoke("webhook", {
request: new Request("http://test.com", { method: "POST", body: raw }),
body: JSON.parse(raw),
rawBody: raw,
});

expect(result.success).toBe(true);
expect(seen.rawBody).toBe(raw);
expect(seen.input).toEqual({ a: 1, b: 2 });
});

it("keeps ctx.rawBody undefined on routes without the rawBody flag", async () => {
let seenRawBody: string | undefined = "sentinel";
const plugin = createTestPlugin({
routes: {
normal: {
handler: async (ctx) => {
seenRawBody = ctx.rawBody;
return null;
},
},
},
});
const handler = new PluginRouteHandler(plugin, createMockFactoryOptions());

const result = await handler.invoke("normal", {
request: new Request("http://test.com", { method: "POST", body: "{}" }),
body: {},
rawBody: "{}",
});

expect(result.success).toBe(true);
expect(seenRawBody).toBeUndefined();
});

it("delivers non-JSON bodies to rawBody routes even though input is undefined", async () => {
// Form-encoded webhook deliveries parse to undefined today and are
// lost; with rawBody: true the handler can parse them itself.
const raw = "event=order.paid&id=42";
let seen: { rawBody?: string; input?: unknown } = {};
const plugin = createTestPlugin({
routes: {
webhook: {
rawBody: true,
handler: async (ctx) => {
seen = { rawBody: ctx.rawBody, input: ctx.input };
return null;
},
},
},
});
const handler = new PluginRouteHandler(plugin, createMockFactoryOptions());

const result = await handler.invoke("webhook", {
request: new Request("http://test.com", { method: "POST", body: raw }),
body: undefined,
rawBody: raw,
});

expect(result.success).toBe(true);
expect(seen.rawBody).toBe(raw);
expect(seen.input).toBeUndefined();
});

it("surfaces an actionable error when a handler reads the consumed request body (#1293)", async () => {
// EmDash parses the body once and exposes it as ctx.input; the same
// Request is then handed to the handler with its stream already spent.
Expand Down
Loading