From d3c66df35ff21ccbabdbe32123d20e0fbb372830 Mon Sep 17 00:00:00 2001 From: swissky <30409887+swissky@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:04:47 +0200 Subject: [PATCH 1/3] feat(plugins): opt-in raw request body for native plugin routes Routes that set rawBody: true receive the unparsed request body as ctx.rawBody, alongside the parsed ctx.input. Enables webhook signature verification (HMAC over exact raw bytes) and non-JSON payloads, which were impossible since the dispatcher consumes the body stream. The dispatcher already buffers the body; it now reads text() once and parses input from the same buffer, so there is no extra I/O and no behavior change for existing routes. --- .changeset/plugin-route-raw-body.md | 5 ++ .../your-first-native-plugin.mdx | 23 ++++++ packages/core/src/emdash-runtime.ts | 11 ++- packages/core/src/plugins/routes.ts | 7 +- packages/core/src/plugins/types.ts | 12 +++ .../core/tests/unit/plugins/routes.test.ts | 82 +++++++++++++++++++ 6 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 .changeset/plugin-route-raw-body.md diff --git a/.changeset/plugin-route-raw-body.md b/.changeset/plugin-route-raw-body.md new file mode 100644 index 0000000000..e469672f89 --- /dev/null +++ b/.changeset/plugin-route-raw-body.md @@ -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. diff --git a/docs/src/content/docs/plugins/creating-native-plugins/your-first-native-plugin.mdx b/docs/src/content/docs/plugins/creating-native-plugins/your-first-native-plugin.mdx index e5d42847fa..59750bb6ba 100644 --- a/docs/src/content/docs/plugins/creating-native-plugins/your-first-native-plugin.mdx +++ b/docs/src/content/docs/plugins/creating-native-plugins/your-first-native-plugin.mdx @@ -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) + +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 _exact raw bytes_ of the delivery, and an HMAC computed over a re-serialized `ctx.input` will never match. + +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 ?? "{}"); + // ... + }, + }, +}, +``` + +`ctx.rawBody` is the body exactly as received. `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: diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index ecf6fa0c82..a6728b5e55 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -3342,14 +3342,19 @@ export class EmDashRuntime { const routeKey = path.replace(LEADING_SLASH_PATTERN, ""); + // Buffer the body as text so routes with `rawBody: true` can see the + // exact bytes (webhook signature verification); 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(); + 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 diff --git a/packages/core/src/plugins/routes.ts b/packages/core/src/plugins/routes.ts index 24b02a58df..3969c20cee 100644 --- a/packages/core/src/plugins/routes.ts +++ b/packages/core/src/plugins/routes.ts @@ -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.`, ); }; } @@ -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; } /** @@ -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 diff --git a/packages/core/src/plugins/types.ts b/packages/core/src/plugins/types.ts index 981172f0c1..9b114a9be9 100644 --- a/packages/core/src/plugins/types.ts +++ b/packages/core/src/plugins/types.ts @@ -1160,6 +1160,12 @@ export interface RouteContext extends PluginContext { request: Request; /** Normalized request metadata (IP, user agent, geo) */ requestMeta: RequestMeta; + /** + * The unparsed request body, exactly as received. Only populated when the + * route sets `rawBody: true` — needed to verify webhook signatures, which + * are HMACs over the raw bytes (a re-serialized `ctx.input` never matches). + */ + rawBody?: string; } /** @@ -1173,6 +1179,12 @@ export interface PluginRoute { * 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) => Promise; } diff --git a/packages/core/tests/unit/plugins/routes.test.ts b/packages/core/tests/unit/plugins/routes.test.ts index 1187cd54ef..3e3fb92a7d 100644 --- a/packages/core/tests/unit/plugins/routes.test.ts +++ b/packages/core/tests/unit/plugins/routes.test.ts @@ -334,6 +334,88 @@ 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 exact raw bytes: 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. From 4d62748ce4fdd7625d45bff361fc040ceacdf05a Mon Sep 17 00:00:00 2001 From: swissky <30409887+swissky@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:59:22 +0200 Subject: [PATCH 2/3] test(plugins): cover rawBody through the real runtime dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up: adds an integration test exercising the actual EmDashRuntime.handlePluginApiRoute path (buffer text() once, parse input from the same buffer, forward rawBody only to opted-in routes), and corrects the "exact raw bytes" wording — ctx.rawBody is the UTF-8 decoded body string, which is what webhook signatures need in practice; binary payloads are not byte-exact. --- .../your-first-native-plugin.mdx | 4 +- packages/core/src/emdash-runtime.ts | 5 +- packages/core/src/plugins/types.ts | 9 +- .../runtime/plugin-raw-body-route.test.ts | 118 ++++++++++++++++++ .../core/tests/unit/plugins/routes.test.ts | 5 +- 5 files changed, 132 insertions(+), 9 deletions(-) create mode 100644 packages/core/tests/integration/runtime/plugin-raw-body-route.test.ts diff --git a/docs/src/content/docs/plugins/creating-native-plugins/your-first-native-plugin.mdx b/docs/src/content/docs/plugins/creating-native-plugins/your-first-native-plugin.mdx index 59750bb6ba..3738cd3f01 100644 --- a/docs/src/content/docs/plugins/creating-native-plugins/your-first-native-plugin.mdx +++ b/docs/src/content/docs/plugins/creating-native-plugins/your-first-native-plugin.mdx @@ -153,7 +153,7 @@ Key details of this configuration: ## Raw request bodies (webhook signatures) -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 _exact raw bytes_ of the delivery, and an HMAC computed over a re-serialized `ctx.input` will never match. +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`: @@ -172,7 +172,7 @@ routes: { }, ``` -`ctx.rawBody` is the body exactly as received. `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. +`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 diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index a6728b5e55..91ea195ab2 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -3343,8 +3343,9 @@ export class EmDashRuntime { const routeKey = path.replace(LEADING_SLASH_PATTERN, ""); // Buffer the body as text so routes with `rawBody: true` can see the - // exact bytes (webhook signature verification); parse JSON from the - // same buffer for `ctx.input`. + // 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 { diff --git a/packages/core/src/plugins/types.ts b/packages/core/src/plugins/types.ts index 9b114a9be9..d4366f3a0b 100644 --- a/packages/core/src/plugins/types.ts +++ b/packages/core/src/plugins/types.ts @@ -1161,9 +1161,12 @@ export interface RouteContext extends PluginContext { /** Normalized request metadata (IP, user agent, geo) */ requestMeta: RequestMeta; /** - * The unparsed request body, exactly as received. Only populated when the - * route sets `rawBody: true` — needed to verify webhook signatures, which - * are HMACs over the raw bytes (a re-serialized `ctx.input` never matches). + * 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; } diff --git a/packages/core/tests/integration/runtime/plugin-raw-body-route.test.ts b/packages/core/tests/integration/runtime/plugin-raw-body-route.test.ts new file mode 100644 index 0000000000..91b375c14e --- /dev/null +++ b/packages/core/tests/integration/runtime/plugin-raw-body-route.test.ts @@ -0,0 +1,118 @@ +/** + * End-to-end wiring for plugin-route `rawBody`. + * + * The route-layer unit tests pass `rawBody` into `PluginRouteHandler.invoke` + * manually; this test exercises the real path — `EmDashRuntime. + * handlePluginApiRoute` reading the request stream once, parsing `ctx.input` + * from the same buffer, and forwarding the raw string only to routes that + * opted in with `rawBody: true`. + */ + +import { randomUUID } from "node:crypto"; + +import Database from "better-sqlite3"; +import { SqliteDialect } from "kysely"; +import { afterAll, beforeAll, 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"; + +/** What the handler saw for the last invocation, keyed by route name. */ +const seen = new Map(); + +function createDeps(): RuntimeDependencies { + const entrypoint = `test-plugin-raw-body-${randomUUID()}`; + return { + config: { + database: { entrypoint, config: {}, type: "sqlite" }, + storage: { entrypoint, config: {} }, + }, + plugins: [ + definePlugin({ + id: "webhook-demo", + version: "1.0.0", + capabilities: [], + routes: { + webhook: { + rawBody: true, + handler: async (ctx) => { + seen.set("webhook", { rawBody: ctx.rawBody, input: ctx.input }); + return { ok: true }; + }, + }, + normal: { + handler: async (ctx) => { + seen.set("normal", { rawBody: ctx.rawBody, input: ctx.input }); + return { ok: true }; + }, + }, + }, + }), + ], + createDialect: () => new SqliteDialect({ database: new Database(":memory:") }), + createStorage: null, + sandboxEnabled: false, + sandboxedPluginEntries: [], + createSandboxRunner: null, + }; +} + +function post(runtime: EmDashRuntime, route: string, body?: string) { + return runtime.handlePluginApiRoute( + "webhook-demo", + "POST", + `/${route}`, + new Request(`http://test.local/_emdash/api/plugins/webhook-demo/${route}`, { + method: "POST", + body, + }), + ); +} + +describe("EmDashRuntime.handlePluginApiRoute — rawBody", () => { + let runtime: EmDashRuntime; + + beforeAll(async () => { + runtime = await EmDashRuntime.create(createDeps()); + }); + + afterAll(async () => { + await runtime.stopCron(); + }); + + it("delivers the unparsed body string and the parsed input from the same buffer", async () => { + // Whitespace and key order must survive: a signature computed over a + // re-serialized ctx.input would not match this string. + const raw = `{"b": 2, "a":1}`; + const result = await post(runtime, "webhook", raw); + + expect(result.success).toBe(true); + expect(seen.get("webhook")).toEqual({ rawBody: raw, input: { a: 1, b: 2 } }); + }); + + it("delivers non-JSON bodies with input undefined", async () => { + const raw = "event=order.paid&id=42"; + const result = await post(runtime, "webhook", raw); + + expect(result.success).toBe(true); + expect(seen.get("webhook")).toEqual({ rawBody: raw, input: undefined }); + }); + + it("leaves rawBody undefined without a request body", async () => { + const result = await post(runtime, "webhook"); + + expect(result.success).toBe(true); + const call = seen.get("webhook"); + expect(call?.rawBody).toBeFalsy(); + expect(call?.input).toBeUndefined(); + }); + + it("does not expose rawBody to routes without the flag", async () => { + const raw = `{"a":1}`; + const result = await post(runtime, "normal", raw); + + expect(result.success).toBe(true); + expect(seen.get("normal")).toEqual({ rawBody: undefined, input: { a: 1 } }); + }); +}); diff --git a/packages/core/tests/unit/plugins/routes.test.ts b/packages/core/tests/unit/plugins/routes.test.ts index 3e3fb92a7d..25ea212a50 100644 --- a/packages/core/tests/unit/plugins/routes.test.ts +++ b/packages/core/tests/unit/plugins/routes.test.ts @@ -335,8 +335,9 @@ describe("PluginRouteHandler", () => { }); it("exposes ctx.rawBody on routes that opt in with rawBody: true", async () => { - // Signature verification needs the exact raw bytes: whitespace and key - // order must survive, which a re-serialized ctx.input can't guarantee. + // 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({ From 4f6dbfe3948a2f88411aa8e87865eab1b341ee16 Mon Sep 17 00:00:00 2001 From: swissky <30409887+swissky@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:50:22 +0200 Subject: [PATCH 3/3] test(plugins): cover rawBody dispatcher path via handlePluginApiRoute The unit tests invoked PluginRouteHandler.invoke directly and never exercised the request.text() buffering block. Add integration tests for the three behaviors: JSON parsed into ctx.input with rawBody preserved verbatim, non-JSON payloads leaving ctx.input undefined, and rawBody staying undefined for routes that did not opt in (emdashbot). --- .../runtime/plugin-raw-body-route.test.ts | 174 +++++++++++------- 1 file changed, 103 insertions(+), 71 deletions(-) diff --git a/packages/core/tests/integration/runtime/plugin-raw-body-route.test.ts b/packages/core/tests/integration/runtime/plugin-raw-body-route.test.ts index 91b375c14e..cffbd85161 100644 --- a/packages/core/tests/integration/runtime/plugin-raw-body-route.test.ts +++ b/packages/core/tests/integration/runtime/plugin-raw-body-route.test.ts @@ -1,27 +1,51 @@ /** - * End-to-end wiring for plugin-route `rawBody`. + * 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 route-layer unit tests pass `rawBody` into `PluginRouteHandler.invoke` - * manually; this test exercises the real path — `EmDashRuntime. - * handlePluginApiRoute` reading the request stream once, parsing `ctx.input` - * from the same buffer, and forwarding the raw string only to routes that - * opted in with `rawBody: true`. + * 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 { afterAll, beforeAll, describe, expect, it } from "vitest"; +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; +} -/** What the handler saw for the last invocation, keyed by route name. */ -const seen = new Map(); - -function createDeps(): RuntimeDependencies { +function createDeps(captured: Captured, rawBodyOptIn: boolean): RuntimeDependencies { const entrypoint = `test-plugin-raw-body-${randomUUID()}`; return { config: { @@ -30,20 +54,16 @@ function createDeps(): RuntimeDependencies { }, plugins: [ definePlugin({ - id: "webhook-demo", + id: "webhook-sink", version: "1.0.0", capabilities: [], routes: { - webhook: { - rawBody: true, + hook: { + rawBody: rawBodyOptIn, handler: async (ctx) => { - seen.set("webhook", { rawBody: ctx.rawBody, input: ctx.input }); - return { ok: true }; - }, - }, - normal: { - handler: async (ctx) => { - seen.set("normal", { rawBody: ctx.rawBody, input: ctx.input }); + captured.hasRawBody = "rawBody" in ctx && ctx.rawBody !== undefined; + captured.rawBody = ctx.rawBody; + captured.input = ctx.input; return { ok: true }; }, }, @@ -51,68 +71,80 @@ function createDeps(): RuntimeDependencies { }), ], createDialect: () => new SqliteDialect({ database: new Database(":memory:") }), - createStorage: null, + createStorage: () => stubStorage, sandboxEnabled: false, sandboxedPluginEntries: [], createSandboxRunner: null, }; } -function post(runtime: EmDashRuntime, route: string, body?: string) { - return runtime.handlePluginApiRoute( - "webhook-demo", - "POST", - `/${route}`, - new Request(`http://test.local/_emdash/api/plugins/webhook-demo/${route}`, { - method: "POST", - body, - }), - ); +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", () => { - let runtime: EmDashRuntime; - - beforeAll(async () => { - runtime = await EmDashRuntime.create(createDeps()); - }); - - afterAll(async () => { - await runtime.stopCron(); + 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("delivers the unparsed body string and the parsed input from the same buffer", async () => { - // Whitespace and key order must survive: a signature computed over a - // re-serialized ctx.input would not match this string. - const raw = `{"b": 2, "a":1}`; - const result = await post(runtime, "webhook", raw); - - expect(result.success).toBe(true); - expect(seen.get("webhook")).toEqual({ rawBody: raw, input: { a: 1, b: 2 } }); - }); - - it("delivers non-JSON bodies with input undefined", async () => { - const raw = "event=order.paid&id=42"; - const result = await post(runtime, "webhook", raw); - - expect(result.success).toBe(true); - expect(seen.get("webhook")).toEqual({ rawBody: raw, input: undefined }); + 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("leaves rawBody undefined without a request body", async () => { - const result = await post(runtime, "webhook"); - - expect(result.success).toBe(true); - const call = seen.get("webhook"); - expect(call?.rawBody).toBeFalsy(); - expect(call?.input).toBeUndefined(); - }); - - it("does not expose rawBody to routes without the flag", async () => { - const raw = `{"a":1}`; - const result = await post(runtime, "normal", raw); - - expect(result.success).toBe(true); - expect(seen.get("normal")).toEqual({ rawBody: undefined, input: { a: 1 } }); + 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(); + } }); });