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
6 changes: 6 additions & 0 deletions .changeset/public-form-ssr-routes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"emdash": patch
"@emdash-cms/plugin-forms": patch
---

Fixes public form embeds during SSR by allowing frontend plugin components to call public plugin routes without self-fetching.
2 changes: 2 additions & 0 deletions packages/core/src/astro/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
runWithContext,
} from "../request-context.js";
import type { EmDashConfig } from "./integration/runtime.js";
import { createPublicPluginApiRouteHandler } from "./public-plugin-api-routes.js";
import type { EmDashHandlers } from "./types.js";

// Cached runtime instance (persists across requests within worker)
Expand Down Expand Up @@ -360,6 +361,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
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.

collectPageMetadata: runtime.collectPageMetadata.bind(runtime),
collectPageFragments: runtime.collectPageFragments.bind(runtime),
getPublicMediaUrl: createPublicMediaUrlResolver(runtime.storage),
Expand Down
41 changes: 41 additions & 0 deletions packages/core/src/astro/public-plugin-api-routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { HandlerResponse } from "./types.js";

export type PublicPluginApiRouteHandler = (
pluginId: string,
method: string,
path: string,
request: Request,
) => Promise<HandlerResponse>;

interface PublicPluginApiRouteRuntime {
getPluginRouteMeta(pluginId: string, path: string): { public: boolean } | null;
handlePluginApiRoute(
pluginId: string,
method: string,
path: string,
request: Request,
): Promise<HandlerResponse>;
}

function pluginRouteNotFound(): HandlerResponse {
return {
success: false,
error: {
code: "NOT_FOUND",
message: "Plugin route not found",
},
};
}

export function createPublicPluginApiRouteHandler(
runtime: PublicPluginApiRouteRuntime,
): PublicPluginApiRouteHandler {
return async (pluginId, method, path, request) => {
const meta = runtime.getPluginRouteMeta(pluginId, path);
if (meta?.public !== true) {
return pluginRouteNotFound();
}

return runtime.handlePluginApiRoute(pluginId, method, path, request);
};
}
68 changes: 68 additions & 0 deletions packages/core/tests/unit/astro/middleware-prerender.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,29 @@ const { DB_CONFIG_MARKER } = vi.hoisted(() => ({
DB_CONFIG_MARKER: { binding: "DB", session: "auto" },
}));

const { MOCK_RUNTIME } = vi.hoisted(() => ({
MOCK_RUNTIME: new Proxy(
{
storage: null,
db: {},
hooks: {},
email: null,
configuredPlugins: [],
},
{
get(target, prop) {
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 });
},
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 });

},
),
}));

vi.mock(
"virtual:emdash/config",
() => ({
Expand Down Expand Up @@ -45,6 +68,12 @@ vi.mock("virtual:emdash/sandboxed-plugins", () => ({ sandboxedPlugins: [] }), {
vi.mock("virtual:emdash/storage", () => ({ createStorage: null }), { virtual: true });
vi.mock("virtual:emdash/wait-until", () => ({ waitUntil: undefined }), { virtual: true });

vi.mock("../../../src/emdash-runtime.js", () => ({
EmDashRuntime: {
create: async () => MOCK_RUNTIME,
},
}));

vi.mock("../../../src/loader.js", () => ({
getDb: vi.fn(async () => ({
selectFrom: () => ({
Expand Down Expand Up @@ -167,6 +196,45 @@ describe("astro middleware anonymous session reads", () => {
expect(sessionGet).not.toHaveBeenCalled();
});

it("exposes only restricted public runtime helpers to anonymous public pages", async () => {
const cookies = {
get: vi.fn((name: string) => {
if (name === "astro-session") return undefined;
return undefined;
}),
set: vi.fn(),
};
const sessionGet = vi.fn(async () => null);
const astroSession = { get: sessionGet };
const locals: Record<string, unknown> = {};

const context: Record<string, unknown> = {
request: new Request("https://example.com/contact"),
url: new URL("https://example.com/contact"),
cookies,
locals,
redirect: vi.fn(),
isPrerendered: false,
session: astroSession,
};

const response = await onRequest(
context as Parameters<typeof onRequest>[0],
async () => new Response("ok"),
);

expect(response.status).toBe(200);
expect(sessionGet).not.toHaveBeenCalled();
const emdash = locals.emdash as Record<string, unknown>;
expect(typeof emdash.handlePluginApiRoute).toBe("function");
expect(typeof emdash.collectPageMetadata).toBe("function");
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.

expect("config" in emdash).toBe(false);
});

it("reads the Astro session when an astro-session cookie is present", async () => {
const cookies = {
get: vi.fn((name: string) => {
Expand Down
74 changes: 74 additions & 0 deletions packages/core/tests/unit/astro/public-plugin-api-routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from "vitest";

import { createPublicPluginApiRouteHandler } from "../../../src/astro/public-plugin-api-routes.js";

function createRuntime(meta: { public: boolean } | null) {
const result = { success: true, data: { ok: true } };
const handlePluginApiRoute = vi.fn(async () => result);
const getPluginRouteMeta = vi.fn(() => meta);

return {
runtime: {
getPluginRouteMeta,
handlePluginApiRoute,
},
getPluginRouteMeta,
handlePluginApiRoute,
result,
};
}

describe("createPublicPluginApiRouteHandler", () => {
it("delegates to the runtime when the plugin route is public", async () => {
const { runtime, getPluginRouteMeta, handlePluginApiRoute, result } = createRuntime({
public: true,
});
const request = new Request("https://example.com/_emdash/api/plugins/demo/definition", {
method: "POST",
body: "{}",
});

const handler = createPublicPluginApiRouteHandler(runtime);
const actual = await handler("demo", "POST", "/definition", request);

expect(getPluginRouteMeta).toHaveBeenCalledWith("demo", "/definition");
expect(handlePluginApiRoute).toHaveBeenCalledWith("demo", "POST", "/definition", request);
expect(actual).toBe(result);
});

it("returns not found without invoking private plugin routes", async () => {
const { runtime, handlePluginApiRoute } = createRuntime({ public: false });
const handler = createPublicPluginApiRouteHandler(runtime);

const result = await handler(
"demo",
"POST",
"/admin",
new Request("https://example.com/_emdash/api/plugins/demo/admin"),
);

expect(handlePluginApiRoute).not.toHaveBeenCalled();
expect(result).toEqual({
success: false,
error: {
code: "NOT_FOUND",
message: "Plugin route not found",
},
});
});

it("returns not found without invoking missing plugin routes", async () => {
const { runtime, handlePluginApiRoute } = createRuntime(null);
const handler = createPublicPluginApiRouteHandler(runtime);

const result = await handler(
"demo",
"POST",
"/missing",
new Request("https://example.com/_emdash/api/plugins/demo/missing"),
);

expect(handlePluginApiRoute).not.toHaveBeenCalled();
expect(result.error?.code).toBe("NOT_FOUND");
});
});
23 changes: 12 additions & 11 deletions packages/plugins/forms/src/astro/FormEmbed.astro
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
* Without JavaScript, all pages are visible as one long form.
* The client-side script enhances with multi-page navigation, AJAX, etc.
*/
import { parsePublicFormDefinitionResponse } from "../public-definition.js";
import {
loadPublicFormDefinition,
type PublicPluginApiRouteHandler,
} from "../public-definition.js";
import type { FormField, FormPage } from "../types.js";

interface Props {
Expand All @@ -16,17 +19,15 @@ interface Props {
const { node } = Astro.props;
const formId = node.formId;

// Fetch form definition server-side
const response = await fetch(
new URL("/_emdash/api/plugins/emdash-forms/definition", Astro.url),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: formId }),
}
);
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.


const form = await parsePublicFormDefinitionResponse(response);
const form = await loadPublicFormDefinition({
formId,
baseUrl: Astro.url,
handlePluginApiRoute,
});
if (!form) return;

const submitUrl = `/_emdash/api/plugins/emdash-forms/submit`;
Expand Down
77 changes: 74 additions & 3 deletions packages/plugins/forms/src/public-definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,47 @@ export interface PublicFormDefinition {
_turnstileSiteKey?: string | null;
}

export type PublicPluginApiRouteHandler = (
pluginId: string,
method: string,
path: string,
request: Request,
) => Promise<unknown>;

interface LoadPublicFormDefinitionOptions {
formId: string;
baseUrl: URL;
handlePluginApiRoute?: PublicPluginApiRouteHandler;
fetch?: (input: Request) => Promise<Response>;
}

function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

export function parsePublicFormDefinitionPayload(payload: unknown): PublicFormDefinition | null {
if (!isObject(payload)) {
return null;
}

if ("success" in payload) {
if (payload.success !== true) {
return null;
}
return parsePublicFormDefinitionPayload(payload.data);
}

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.


if (payload.status !== "active") {
return null;
}

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.


export async function parsePublicFormDefinitionResponse(
response: Response,
): Promise<PublicFormDefinition | null> {
Expand All @@ -22,9 +63,39 @@ export async function parsePublicFormDefinitionResponse(
}

const form = await parseApiResponse<PublicFormDefinition | undefined>(response);
if (!form || form.status !== "active") {
return null;
return parsePublicFormDefinitionPayload(form);
}

function createPublicFormDefinitionRequest(formId: string, baseUrl: URL): Request {
return new Request(new URL("/_emdash/api/plugins/emdash-forms/definition", baseUrl), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: formId }),
});
}

export async function loadPublicFormDefinition({
formId,
baseUrl,
handlePluginApiRoute,
fetch: fetchImpl = fetch,
}: LoadPublicFormDefinitionOptions): Promise<PublicFormDefinition | null> {
if (handlePluginApiRoute) {
try {
return parsePublicFormDefinitionPayload(
await handlePluginApiRoute(
"emdash-forms",
"POST",
"/definition",
createPublicFormDefinitionRequest(formId, baseUrl),
),
);
} 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);
}

}

return form;
return parsePublicFormDefinitionResponse(
await fetchImpl(createPublicFormDefinitionRequest(formId, baseUrl)),
);
}
Loading
Loading