Skip to content

fix(plugin-forms): defer the submission webhook and log a failed response - #3181

Open
eisenbruch wants to merge 2 commits into
emdash-cms:mainfrom
eisenbruch:fix/forms-webhook-deferred
Open

eisenbruch wants to merge 2 commits into
emdash-cms:mainfrom
eisenbruch:fix/forms-webhook-deferred

Conversation

@eisenbruch

Copy link
Copy Markdown
Contributor

What does this PR do?

Closes #3136.

The submission webhook was a floating promise, and only a rejected one was logged. Two consequences, both silent:

  1. Nothing awaited it and nothing handed it to the host. submitHandler returned immediately after firing it, so on Cloudflare Workers the isolate could be torn down before the request went out. It now runs inside after(), which registers the promise with waitUntil where the host provides one.
  2. fetch only rejects on a transport error. A 4xx and a 5xx both resolve, and so does the sign-in page an authenticated endpoint redirects to, because createHttpAccess follows redirects and the plugin declares allowedHosts: ["*"]. Every one of those looked like a delivered webhook and wrote nothing to the log. The response is now inspected, and a 2xx that arrived by redirect is logged as well, since the body came from somewhere other than the configured URL.

We hit this on a live site with a webhook pointed at a route on the same host: submissions stored, notification emails sent, no webhook effect, nothing in the logs to say so.

The emails in steps 7 and 8 are already awaited, so they are left alone.

Type of change

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

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes (@emdash-cms/plugin-forms)
  • pnpm lint passes (oxlint --type-aware --deny-warnings, 0 diagnostics)
  • pnpm test passes (or targeted tests for my change): packages/plugins/forms, 5 files, 26 tests
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main.
  • I have added and reviewed the user-facing changeset (@emdash-cms/plugin-forms: patch)
  • New features link to an approved Discussion: https://github.com/emdash-cms/emdash/discussions/...
  • I have included screenshots below if this PR changes the UI

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 5 (Claude Code)

Screenshots / test output

Not applicable, no UI change.

packages/plugins/forms/tests/webhook.test.ts covers four cases: a 5xx, a redirect to a different URL, a transport error, and a clean success that logs nothing. The first two fail against main and pass with this change; the other two pass on both.

There is one thing I did not do, and it is worth a maintainer's opinion. A same-origin webhook on Workers still goes out over the public hostname, so it passes through whatever sits in front of the site — Cloudflare Access, in our case — and gets the sign-in page. A service binding back to the Worker avoids that, but it looks like a docs note rather than something the plugin should reach for, so this PR only makes the failure visible rather than trying to solve it.

…onse

The webhook was a floating promise. Nothing awaited it and nothing handed it
to the host's lifetime extender, so on Cloudflare Workers the isolate could be
torn down once the visitor had their confirmation and the call never left.
It now runs inside after(), which registers it with waitUntil where the host
provides one.

Only a rejected promise was logged, and fetch resolves on a 4xx or a 5xx. It
also resolves on the sign-in page an authenticated endpoint redirects to,
because http access follows redirects. Each of those looked like a delivered
webhook and left nothing in the log, which is how a webhook can fail on every
submission unnoticed. The response is now checked, and a 2xx that arrived by
redirect is logged too, since the body came from somewhere other than the
configured URL.

Closes emdash-cms#3136
@changeset-bot

changeset-bot Bot commented Sep 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e69a5df

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

This PR includes changesets to release 1 package
Name Type
@emdash-cms/plugin-forms 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

@eisenbruch eisenbruch changed the title fix(plugin-forms): defer the submission webhook and log a failed resp… fix(plugin-forms): defer the submission webhook and log a failed response Sep 17, 2026
@pkg-pr-new

pkg-pr-new Bot commented Sep 17, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/auth-atproto

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

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

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

emdash

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

create-emdash

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

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

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

@emdash-cms/plugin-cli

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

@emdash-cms/plugin-test

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-test@3181

@emdash-cms/plugin-types

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

@emdash-cms/registry-client

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

@emdash-cms/registry-lexicons

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

@emdash-cms/registry-loader

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-loader@3181

@emdash-cms/registry-moderation

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-moderation@3181

@emdash-cms/registry-verification

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-verification@3181

@emdash-cms/sandbox-workerd

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-field-kit

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: e69a5df

@eisenbruch
eisenbruch marked this pull request as ready for review September 17, 2026 18:10
@github-actions github-actions Bot added the review/needs-review No maintainer or bot review yet label Sep 17, 2026

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

Approach judgment: this is the right fix for the right problem. Moving the form submission webhook into after() matches EmDash’s deferred-task pattern and solves the Cloudflare Workers lifetime issue; inspecting the response for non-2xx statuses is also the right idea. I checked packages/core/src/after.ts, the virtual:emdash/wait-until wiring, and packages/core/src/plugins/context.ts (createHttpAccess / createUnrestrictedHttpAccess).

The implementation is mostly sound, but the redirect detection relies on a Response.redirected signal that the actual plugin HTTP wrapper never emits, because the wrapper follows redirects manually with redirect: "manual". As a result, the auth-wall redirect case described in the PR remains silently successful in production. The unit test passes only because it manually injects redirected: true on a constructed Response. Three related issues need fixing before this should land.

});
if (!response.ok) {
log.error("Webhook failed", { url, status: response.status });
} else if (response.redirected && response.url !== url) {

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.

[needs fixing] ctx.http.fetch is produced by createHttpAccess (or createUnrestrictedHttpAccess), which manually follows redirects using globalThis.fetch(..., { redirect: "manual" }) so it can strip credentials on cross-origin hops. Because each internal fetch uses manual redirect mode, the final Response it returns has redirected: false even when redirects were followed. The response.redirected guard therefore means this branch never fires in production: a webhook that redirects to a sign-in page and returns 200 is treated as success, exactly the silent failure the PR says it is fixing. Drop the response.redirected requirement (and normalize URL comparison if you want to avoid trailing-slash false positives) or surface redirect metadata from the HTTP wrapper itself.

Suggested change
} else if (response.redirected && response.url !== url) {
} else if (response.url !== url) {

Comment on lines +67 to +96
function response(init: { status?: number; redirected?: boolean; url?: string }): Response {
const res = new Response("", { status: init.status ?? 200 });
// `redirected` and `url` are read-only on a constructed Response; a real redirected fetch sets both.
Object.defineProperty(res, "redirected", { value: init.redirected ?? false });
Object.defineProperty(res, "url", { value: init.url ?? WEBHOOK });
return res;
}

describe("submission webhook", () => {
beforeEach(() => vi.clearAllMocks());

it("logs a 5xx, which fetch resolves rather than rejects", async () => {
const { ctx, log } = context(async () => response({ status: 500 }));
// eslint-disable-next-line typescript/no-unsafe-argument -- minimal RouteContext for this step
const result = await submitHandler(ctx as never);
await settle();

expect(result.success).toBe(true); // the visitor is never shown the webhook's problem
expect(log.error).toHaveBeenCalledWith("Webhook failed", { url: WEBHOOK, status: 500 });
});

it("logs a redirect to somewhere else, which an auth wall answers with a 200", async () => {
const { ctx, log } = context(async () =>
response({ status: 200, redirected: true, url: "https://example.test/login" }),
);
await submitHandler(ctx as never);
await settle();

expect(log.warn).toHaveBeenCalledWith("Webhook was redirected", {
url: WEBHOOK,

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.

[needs fixing] The response() helper fakes redirected: true and a different url, with a comment that "a real redirected fetch sets both." EmDash’s plugin HTTP fetch does not set redirected: true because it follows redirects manually. This test therefore exercises a code path that only exists in the fixture. A regression that broke real-world redirect detection would not fail this test, and a change that removed the response.redirected guard would still pass because the fixture sets it. Rewrite the redirect case against the actual createHttpAccess wrapper (or a mock that mirrors its manual-redirect behavior) so it reflects production semantics.

Comment thread .changeset/forms-webhook-deferred.md Outdated
"@emdash-cms/plugin-forms": patch
---

Fixes a form's webhook silently doing nothing. The call was never awaited or handed to the runtime, so on Cloudflare Workers it could be dropped once the visitor's confirmation had been sent; it now runs through `after()`, which registers it with the host so it is guaranteed to finish. A response that is not a success is also logged now: `fetch` only rejects on a transport error, so a 4xx, a 5xx, and the sign-in page an authenticated endpoint redirects to were all treated as if the webhook had worked, leaving no trace anywhere.

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.

[needs fixing] The entry tells readers that sign-in-page redirects are now logged, but the implementation in submit.ts cannot detect those redirects under the real ctx.http.fetch because the HTTP wrapper follows redirects manually and never sets response.redirected. Either fix the redirect detection (and the test), or revise the entry so it does not claim the redirect case is handled.

Suggested change
Fixes a form's webhook silently doing nothing. The call was never awaited or handed to the runtime, so on Cloudflare Workers it could be dropped once the visitor's confirmation had been sent; it now runs through `after()`, which registers it with the host so it is guaranteed to finish. A response that is not a success is also logged now: `fetch` only rejects on a transport error, so a 4xx, a 5xx, and the sign-in page an authenticated endpoint redirects to were all treated as if the webhook had worked, leaving no trace anywhere.
Fixes a form's webhook silently doing nothing. The call was never awaited or handed to the runtime, so on Cloudflare Workers it could be dropped once the visitor's confirmation had been sent; it now runs through `after()`, which registers it with the host so it is guaranteed to finish. A non-success response is also logged now: `fetch` only rejects on a transport error, so 4xx and 5xx responses were previously treated as delivered webhooks with no trace anywhere.

@emdashbot emdashbot Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-review No maintainer or bot review yet labels Sep 17, 2026
…s it

The review is right: plugin HTTP access follows redirects itself with
redirect: "manual" so it can strip credentials on a cross-origin hop, so the
response it returns always reports redirected: false however many hops it
took. Guarding on Response.redirected meant the auth-wall case never fired
in production, which is the failure the change exists to surface.

It now compares the final URL, which the wrapper does carry, ignoring a
trailing-slash difference so a webhook answered at its own address with a
slash is not reported as a redirect.

The test deserved the criticism more than the code did. It built a Response
and set redirected: true on it, so it passed against a fixture that does not
exist in production and would have passed with the guard deleted. It now
drives the wrapper's own manual-redirect loop over a stubbed global fetch,
so the handler sees the response shape production gives it. Confirmed by
putting the old guard back: the auth-wall case fails.

The real createHttpAccess is not used directly because it resolves the
hostname over DoH before every request, which a unit test cannot do offline;
the loop is reproduced instead, which is the alternative the review offered.

The changeset no longer claims more than the code does.
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review size/L and removed review/awaiting-author Reviewed; waiting on the author to respond size/M labels Sep 17, 2026
@eisenbruch

Copy link
Copy Markdown
Contributor Author

You are right on all three, and the test deserved the criticism more than the code did. Fixed in e69a5df.

The redirect guard. I confirmed it in createHttpAccess: the wrapper loops with globalThis.fetch(currentUrl, { redirect: "manual" }) and returns the last response, so redirected is always false however many hops it took. Guarding on it meant the auth-wall case never fired in production, which is the failure the PR exists to surface. It now compares the final URL, which the wrapper does carry, through a small sameTarget helper that ignores a trailing-slash difference so a webhook answered at its own address with a slash is not reported as a redirect. It also requires response.url to be non-empty, so a runtime that does not populate it stays quiet rather than warning on every delivery.

The test. This is the part I got wrong rather than merely incomplete. Setting redirected: true on a constructed Response meant it exercised a shape that does not exist in production and would have passed with the guard deleted, which is worse than no test. It now reproduces the wrapper's own manual-redirect loop over a stubbed globalThis.fetch, so the handler sees the response shape it really gets, and there is a fifth case covering the trailing-slash redirect that should stay quiet.

I did try driving the real createHttpAccess directly, which is what you suggested first. It resolves the hostname over DoH before every request, so the 5xx case failed with Could not resolve hostname: DoH lookup failed against a test host. Reproducing the loop is the alternative you offered; if you would rather the wrapper exposed redirect metadata so plugins need not reimplement this, say so and I will do that instead, since it would remove the duplication and let the test use the real thing.

Verified the way it should have been the first time: I put the old response.redirected guard back and confirmed the auth-wall case now fails, then restored the fix. Full forms suite green at 5 files and 27 tests, oxlint --type-aware --deny-warnings clean, typecheck clean, oxfmt run.

The changeset no longer claims more than the code does; it names the URL comparison and why Response.redirected cannot be used here.

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

Labels

area/plugins 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.

plugin-forms: webhook call is not awaited or passed to waitUntil, and a non-2xx response is never logged

1 participant