Skip to content

feat(plugins): opt-in raw request body for native plugin routes#1983

Open
swissky wants to merge 2 commits into
emdash-cms:mainfrom
swissky:feat/plugin-route-raw-body
Open

feat(plugins): opt-in raw request body for native plugin routes#1983
swissky wants to merge 2 commits into
emdash-cms:mainfrom
swissky:feat/plugin-route-raw-body

Conversation

@swissky

@swissky swissky commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds an opt-in rawBody: true flag for native plugin routes. Routes that set it receive the unparsed request body as ctx.rawBody, alongside the parsed ctx.input.

Why: webhook providers (Stripe, GitHub, Svix, …) sign the exact raw bytes of the delivery. Since the dispatcher consumes the body stream to build ctx.input (and ctx.request.text() is guarded per #1293), plugins currently have no way to verify those signatures — an HMAC over a re-serialized ctx.input never matches. Non-JSON payloads (form-encoded webhook deliveries) are also silently lost today.

How: the dispatcher already buffers the body to parse it. It now reads text() once, parses ctx.input from the same buffer, and forwards the string only to routes that opted in. No extra I/O, no behavior change for existing routes. The #1293 guard message now points at rawBody: true as the escape hatch. Native format only; sandboxed plugins cross a serialization boundary and would need separate plumbing.

Discussion: #1982

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Cursor + Claude. All changes reviewed and tested by me.

Screenshots / test output

tests/unit/plugins + plugin-api-route-auth: 625 passed (625)

New tests cover: raw body delivered on opted-in routes (byte-exact, alongside validated input), ctx.rawBody undefined without the flag, and non-JSON payloads reaching the handler with input undefined.

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-bot

changeset-bot Bot commented Jul 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4d62748

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
Name Type
emdash Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/admin Minor
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jul 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@1983

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@1983

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@1983

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@1983

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@1983

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@1983

emdash

npm i https://pkg.pr.new/emdash@1983

create-emdash

npm i https://pkg.pr.new/create-emdash@1983

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@1983

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@1983

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@1983

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@1983

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@1983

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@1983

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@1983

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@1983

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@1983

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@1983

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@1983

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@1983

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@1983

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@1983

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@1983

commit: d3c66df

@emdashbot emdashbot Bot left a comment

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.

This is a clean, additive feature that solves a real pain point: native plugin routes need the unparsed body to verify webhook signatures and to handle non-JSON deliveries. The approach is idiomatic (opt-in flag on the route, dispatcher reads text() once, handler conditionally exposes rawBody) and fits EmDash's existing route flow without changing behavior for existing routes.

I did not run the test suite, linter, or builds (no shell/runtime access), so I take the author's test/type-check claims as unverified. Code-wise the change is straightforward, but I spotted two gaps worth addressing before merge:

  1. The implementation in emdash-runtime.ts lacks an integration test. The new tests only exercise PluginRouteHandler.invoke and pass rawBody in manually; they don't verify that EmDashRuntime.handlePluginApiRoute actually reads request.text(), buffers the string, parses JSON when valid, and leaves ctx.input undefined for non-JSON payloads. The repo already has an integration test following this exact pattern (plugin-media-route.test.ts), so a matching plugin-raw-body-route.test.ts would close the gap.

  2. Docs/comments claim "exact raw bytes" while ctx.rawBody is a decoded string. Webhook signatures are HMACs over bytes, and request.text() / ctx.rawBody: string is a UTF-8 decoding step away from bytes. In practice Stripe/GitHub/Svix deliver UTF-8 JSON, so re-encoding with new TextEncoder() works, but the wording is technically incorrect and could mislead users handling binary or non-UTF8 payloads. Consider softening the claim or documenting the UTF-8 assumption.

Neither is a blocker, but both are worth a quick pass.

Comment on lines 3344 to +3351

// 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();

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.

Comment thread packages/core/src/emdash-runtime.ts Outdated
Comment on lines +3344 to +3346

// Buffer the body as text so routes with `rawBody: true` can see the
// exact bytes (webhook signature verification); parse JSON from the

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 comment says this buffers the body so routes can see the "exact bytes" for webhook signatures, but request.text() decodes the bytes to a UTF-8 string and ctx.rawBody is typed as string. For the common webhook case (UTF-8 JSON) this is fine — users can re-encode with new TextEncoder() — but for non-UTF8 or binary payloads it is not byte-exact. Consider softening the comment to "exact text" or documenting the UTF-8 assumption.

- **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

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.

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.
@swissky

swissky commented Jul 12, 2026

Copy link
Copy Markdown
Contributor Author

Both points addressed in 4d62748:

  1. Integration test: added tests/integration/runtime/plugin-raw-body-route.test.ts following the plugin-media-route.test.ts pattern — a real EmDashRuntime with a trusted plugin, invoked through handlePluginApiRoute. It verifies the raw string survives verbatim (whitespace/key order), ctx.input is parsed from the same buffer, non-JSON payloads arrive with input undefined, empty bodies stay empty, and routes without the flag never see rawBody.
  2. Wording: all "exact raw bytes" claims (JSDoc, runtime comment, docs, test comment) now say what's true — ctx.rawBody is the UTF-8 decoded body string, which is what webhook signature verification needs in practice; the docs explicitly note that binary payloads are not preserved byte-exactly and show the TextEncoder path.

@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Jul 12, 2026
@github-actions github-actions Bot added size/L review/needs-rereview Author pushed changes since the last review overlap and removed size/M review/needs-review No maintainer or bot review yet labels Jul 12, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core area/docs bot:review Trigger an emdashbot code review on this PR overlap review/needs-rereview Author pushed changes since the last review size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant