Skip to content

fix(forms): render public form embeds via SSR plugin routes#985

Open
ppppangu wants to merge 1 commit into
emdash-cms:mainfrom
ppppangu:fix-public-plugin-routes-ssr
Open

fix(forms): render public form embeds via SSR plugin routes#985
ppppangu wants to merge 1 commit into
emdash-cms:mainfrom
ppppangu:fix-public-plugin-routes-ssr

Conversation

@ppppangu
Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes public form embeds during SSR by giving anonymous public pages a restricted way to call public plugin routes. Form embeds now resolve their public definition through the core dispatcher when available, which avoids server-side self-fetching in Worker SSR while preserving the old fetch path for older runtimes or dispatcher failures.

The core helper gates every internal dispatch on getPluginRouteMeta(...).public === true, so private plugin routes still require the normal API route auth, scope, and CSRF path. The forms plugin only calls the fixed emdash-forms /definition route and continues to parse the existing { data: ... } API envelope.

Closes: none

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
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • 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 a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: https://github.com/emdash-cms/emdash/discussions/...

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: GPT-5 Codex

Screenshots / test output

No screenshots; this is an SSR/plugin route bug fix.

Verification run:

  • pnpm --filter emdash test -- public-plugin-api-routes middleware-prerender passed
  • pnpm --filter @emdash-cms/plugin-forms test -- public-definition passed
  • pnpm --filter @emdash-cms/plugin-forms typecheck passed
  • pnpm --filter emdash build passed
  • pnpm typecheck passed
  • pnpm lint exited with 0 errors; it reports existing warnings outside this change
  • pnpm exec prettier --check on the touched files passed

Notes:

  • I did not run full pnpm format because pnpm format:check reports broad existing formatting drift in this Windows checkout; I kept formatting validation scoped to touched files to avoid unrelated churn.
  • pnpm --filter emdash test currently has unrelated Windows-environment failures in tests/unit/astro/vite-config.test.ts (File URL path must be absolute) and tests/unit/cli/bundle-utils.test.ts (system tar listing CRLF). The targeted core tests for this change pass.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 11, 2026

🦋 Changeset detected

Latest commit: e02e87d

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

This PR includes changesets to release 14 packages
Name Type
emdash Patch
@emdash-cms/plugin-forms Patch
@emdash-cms/cloudflare Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@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
Copy link
Copy Markdown

pkg-pr-new Bot commented May 11, 2026

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

emdash

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

create-emdash

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

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

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: e02e87d

@ascorbic
Copy link
Copy Markdown
Collaborator

/review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Targeted fix for the Workers SSR self-fetch issue, with the wrapped helper correctly gated on getPluginRouteMeta(...).public === true and decent unit coverage on both sides. Two things worth addressing before merge — the silent fallback when the dispatcher throws hides exactly the failures this PR is meant to solve, and Astro.locals.emdash.handlePluginApiRoute now has materially different semantics on the anonymous vs. full middleware paths (public-only vs. raw runtime). A couple of smaller robustness notes inline as well. Nothing here is a blocker.

);
} catch {
// Fall back to HTTP fetch for older runtimes or unexpected dispatcher failures.
}
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.

The silent catch {} here swallows exactly the failures this PR is meant to fix. If the internal dispatcher throws on Workers SSR, we then fall through to fetchImpl(...) — which is a same-origin self-fetch, the very thing that's broken on Workers and what motivated the PR. The user sees a form that renders nothing with no log to grep for.

At minimum, log the error so it's grep-able in production. Consider also distinguishing "genuine fallback for older runtimes" (no dispatcher present at all, handled by the outer if (handlePluginApiRoute)) from "dispatcher present but threw" — the latter is a bug and probably shouldn't silently fall back to a path we know fails on Workers.

Suggested change
}
} catch (error) {
// Fall back to HTTP fetch for older runtimes or unexpected dispatcher
// failures. On Workers SSR the fetch path is the broken one this helper
// exists to avoid, so make the failure visible.
console.warn("[emdash-forms] public definition dispatcher failed, falling back to fetch:", error);
}

setupVerified = true;
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- partial object; getPageRuntime() only checks for the page-contribution methods
locals.emdash = {
handlePluginApiRoute: createPublicPluginApiRouteHandler(runtime),
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.

After this PR, Astro.locals.emdash.handlePluginApiRoute means two different things depending on which branch of the middleware runs:

  • Anonymous fast path (this line): the wrapped helper that only dispatches when getPluginRouteMeta(...).public === true.
  • Full path (line 490): runtime.handlePluginApiRoute.bind(runtime) — the raw method, which will happily invoke private plugin routes with no auth / CSRF / scope check.

That's a meaningful difference. A plugin author calling this from a public Astro page is going to discover the difference the hard way when an editor visits the same page and suddenly more routes are reachable. The PR description claims "the core helper gates every internal dispatch on getPluginRouteMeta(...).public === true" — that's only true on the anonymous path.

A couple of options, roughly in order of cost:

  1. Use the wrapped helper on both paths (since the only documented caller is FormEmbed, which only needs public routes anyway). That makes the property uniformly safe.
  2. Rename the anonymous-path field to something like dispatchPublicPluginRoute so the asymmetry is explicit in the type.
  3. Leave as-is and add a comment here + on line 490 documenting the asymmetry.

Option 1 seems cleanest unless there's a known consumer of the raw method on the full path.

}

return payload as unknown as PublicFormDefinition;
}
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.

parsePublicFormDefinitionPayload accepts any object with status === "active" as a valid PublicFormDefinition and returns it cast straight through. If the dispatcher (or a future API shape change) ever returns something like { status: "active" } with missing pages/settings, FormEmbed.astro will crash at render time on form.pages.map(...) rather than returning null and rendering nothing.

Not introduced by this PR (the old code did the same), but you're now reaching this path from a new entry point (loadPublicFormDefinition via the internal dispatcher) where the shape is less constrained than a typed HTTP response. Worth at least an Array.isArray(payload.pages) && isObject(payload.settings) guard before the final cast.


if ("data" in payload) {
return parsePublicFormDefinitionPayload(payload.data);
}
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.

Minor: the "data" in payload unwrap is structurally ambiguous — if a future PublicFormDefinition ever grows a top-level data field (e.g. arbitrary form metadata), it'll be silently over-unwrapped and the function will return null. Given the form definition currently has name/slug/pages/settings/status/_turnstileSiteKey this is theoretical, but since you're now sharing this parser between two different call shapes (HTTP envelope vs. HandlerResponse), it'd be more robust to dispatch on shape explicitly — e.g. require "success" in payload for the dispatcher case and only strip a single data layer for the response case, instead of recursing on any data field.

);
const handlePluginApiRoute = (Astro.locals.emdash as
| { handlePluginApiRoute?: PublicPluginApiRouteHandler }
| undefined)?.handlePluginApiRoute;
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.

Minor type-safety note: this cast (Astro.locals.emdash as { handlePluginApiRoute?: PublicPluginApiRouteHandler } | undefined) silently accepts any shape and degrades to the fetch fallback if handlePluginApiRoute is missing or has the wrong signature. If the core ever renames the field or changes its signature, this component won't fail to compile — it'll just quietly use the broken fetch path on Workers (the bug this PR is fixing).

Consider exporting a typed accessor from emdash/plugin-utils (or wherever the plugin-facing API lives) that returns PublicPluginApiRouteHandler | undefined and is checked at the core boundary, so a future rename produces a build failure instead of a silent regression.

expect(typeof emdash.collectPageFragments).toBe("function");
expect("getPluginRouteMeta" in emdash).toBe(false);
expect("handleContentList" in emdash).toBe(false);
expect("db" in emdash).toBe(false);
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.

Two small gaps in the assertions here:

  1. getPublicMediaUrl is assigned on the anonymous path (middleware.ts:367) — worth asserting typeof emdash.getPublicMediaUrl === "function" so a future refactor that drops it gets caught.
  2. The test doesn't actually exercise the wrapped helper end-to-end — it only checks shape. Given that the wrapping is the core security claim of this PR, a follow-up assertion like await emdash.handlePluginApiRoute("demo", "POST", "/private", req) returning NOT_FOUND (with the proxy returning { public: false } for that path) would tighten the contract considerably.

if (prop === "collectPageMetadata") return async () => [];
if (prop === "collectPageFragments") return async () => [];
return async () => ({ success: true });
},
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.

Minor: this Proxy returns an async function (async () => ({ success: true })) for any unknown property access, including symbols like Symbol.toPrimitive or Symbol.iterator. That's brittle — if the middleware ever does e.g. String(runtime) or for (const x of runtime) during init, the proxy returns an async function for the well-known symbol and you'll get a confusing error rather than the normal behavior. Returning undefined for any non-string prop (or any prop not in an explicit allowlist) would be safer:

Suggested change
},
if (typeof prop !== "string") return undefined;
if (prop === "then") return undefined;
if (prop in target) return target[prop as keyof typeof target];
if (prop === "getPluginRouteMeta") return () => ({ public: true });
if (prop === "handlePluginApiRoute") return async () => ({ success: true, data: {} });
if (prop === "collectPageMetadata") return async () => [];
if (prop === "collectPageFragments") return async () => [];
return async () => ({ success: true });

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 11, 2026

Review posted successfully (ID 4263518046, 7 inline comments). The review covers:

  1. Silent dispatcher fallback (public-definition.ts:95) — the catch {} falls back to a fetch path known to be broken on Workers, with no log.
  2. Asymmetric handlePluginApiRoute semantics (middleware.ts:364) — public-only on the anonymous path, raw runtime on the full path; the PR description's security claim only holds for the former.
  3. Permissive payload validation (public-definition.ts:56) — any { status: "active" } is cast to PublicFormDefinition without checking pages/settings, will crash render.
  4. Ambiguous data unwrap recursion (public-definition.ts:49) — fragile if a top-level data field is ever added to the form definition.
  5. Loose as cast in FormEmbed.astro — a future core rename would silently regress to the broken fetch path.
  6. Missing test coverage (middleware-prerender.test.ts:234) — shape-only assertions; the wrapped helper's gating isn't exercised end-to-end here.
  7. Brittle test mock proxy — returns async functions for any symbol/non-string property access.

github run

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants