diff --git a/.changeset/public-form-ssr-routes.md b/.changeset/public-form-ssr-routes.md new file mode 100644 index 000000000..061c96139 --- /dev/null +++ b/.changeset/public-form-ssr-routes.md @@ -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. diff --git a/packages/core/src/astro/middleware.ts b/packages/core/src/astro/middleware.ts index 488bf91d6..86f747d56 100644 --- a/packages/core/src/astro/middleware.ts +++ b/packages/core/src/astro/middleware.ts @@ -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) @@ -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), collectPageMetadata: runtime.collectPageMetadata.bind(runtime), collectPageFragments: runtime.collectPageFragments.bind(runtime), getPublicMediaUrl: createPublicMediaUrlResolver(runtime.storage), diff --git a/packages/core/src/astro/public-plugin-api-routes.ts b/packages/core/src/astro/public-plugin-api-routes.ts new file mode 100644 index 000000000..2fa17ca39 --- /dev/null +++ b/packages/core/src/astro/public-plugin-api-routes.ts @@ -0,0 +1,41 @@ +import type { HandlerResponse } from "./types.js"; + +export type PublicPluginApiRouteHandler = ( + pluginId: string, + method: string, + path: string, + request: Request, +) => Promise; + +interface PublicPluginApiRouteRuntime { + getPluginRouteMeta(pluginId: string, path: string): { public: boolean } | null; + handlePluginApiRoute( + pluginId: string, + method: string, + path: string, + request: Request, + ): Promise; +} + +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); + }; +} diff --git a/packages/core/tests/unit/astro/middleware-prerender.test.ts b/packages/core/tests/unit/astro/middleware-prerender.test.ts index dbe96fbad..f1a4bd00b 100644 --- a/packages/core/tests/unit/astro/middleware-prerender.test.ts +++ b/packages/core/tests/unit/astro/middleware-prerender.test.ts @@ -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 }); + }, + }, + ), +})); + vi.mock( "virtual:emdash/config", () => ({ @@ -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: () => ({ @@ -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 = {}; + + const context: Record = { + 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[0], + async () => new Response("ok"), + ); + + expect(response.status).toBe(200); + expect(sessionGet).not.toHaveBeenCalled(); + const emdash = locals.emdash as Record; + 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); + 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) => { diff --git a/packages/core/tests/unit/astro/public-plugin-api-routes.test.ts b/packages/core/tests/unit/astro/public-plugin-api-routes.test.ts new file mode 100644 index 000000000..49e40505c --- /dev/null +++ b/packages/core/tests/unit/astro/public-plugin-api-routes.test.ts @@ -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"); + }); +}); diff --git a/packages/plugins/forms/src/astro/FormEmbed.astro b/packages/plugins/forms/src/astro/FormEmbed.astro index b5dfbff5d..4b6b867cb 100644 --- a/packages/plugins/forms/src/astro/FormEmbed.astro +++ b/packages/plugins/forms/src/astro/FormEmbed.astro @@ -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 { @@ -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; -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`; diff --git a/packages/plugins/forms/src/public-definition.ts b/packages/plugins/forms/src/public-definition.ts index 97edb87d9..21740a145 100644 --- a/packages/plugins/forms/src/public-definition.ts +++ b/packages/plugins/forms/src/public-definition.ts @@ -14,6 +14,47 @@ export interface PublicFormDefinition { _turnstileSiteKey?: string | null; } +export type PublicPluginApiRouteHandler = ( + pluginId: string, + method: string, + path: string, + request: Request, +) => Promise; + +interface LoadPublicFormDefinitionOptions { + formId: string; + baseUrl: URL; + handlePluginApiRoute?: PublicPluginApiRouteHandler; + fetch?: (input: Request) => Promise; +} + +function isObject(value: unknown): value is Record { + 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); + } + + if (payload.status !== "active") { + return null; + } + + return payload as unknown as PublicFormDefinition; +} + export async function parsePublicFormDefinitionResponse( response: Response, ): Promise { @@ -22,9 +63,39 @@ export async function parsePublicFormDefinitionResponse( } const form = await parseApiResponse(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 { + 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. + } } - return form; + return parsePublicFormDefinitionResponse( + await fetchImpl(createPublicFormDefinitionRequest(formId, baseUrl)), + ); } diff --git a/packages/plugins/forms/tests/public-definition.test.ts b/packages/plugins/forms/tests/public-definition.test.ts index e1cd9bcbd..74e10e67c 100644 --- a/packages/plugins/forms/tests/public-definition.test.ts +++ b/packages/plugins/forms/tests/public-definition.test.ts @@ -1,7 +1,11 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { PublicFormDefinition } from "../src/public-definition.js"; -import { parsePublicFormDefinitionResponse } from "../src/public-definition.js"; +import { + loadPublicFormDefinition, + parsePublicFormDefinitionPayload, + parsePublicFormDefinitionResponse, +} from "../src/public-definition.js"; const activeForm: PublicFormDefinition = { name: "Contact", @@ -60,3 +64,105 @@ describe("parsePublicFormDefinitionResponse", () => { ).resolves.toBeNull(); }); }); + +describe("parsePublicFormDefinitionPayload", () => { + it("unwraps successful handler results with an active form", () => { + expect(parsePublicFormDefinitionPayload({ success: true, data: activeForm })).toEqual( + activeForm, + ); + }); + + it("returns null for missing or inactive handler result data", () => { + expect(parsePublicFormDefinitionPayload({ success: true, data: undefined })).toBeNull(); + expect( + parsePublicFormDefinitionPayload({ + success: true, + data: { ...activeForm, status: "paused" }, + }), + ).toBeNull(); + }); + + it("returns null for failed handler results", () => { + expect( + parsePublicFormDefinitionPayload({ + success: false, + error: { code: "NOT_FOUND", message: "Form not found" }, + }), + ).toBeNull(); + }); +}); + +describe("loadPublicFormDefinition", () => { + it("loads active forms through the internal public plugin route handler", async () => { + const handlePluginApiRoute = vi.fn(async () => ({ success: true, data: activeForm })); + const fetch = vi.fn(async () => jsonResponse({ data: activeForm })); + + await expect( + loadPublicFormDefinition({ + formId: "contact", + baseUrl: new URL("https://example.com/contact"), + handlePluginApiRoute, + fetch, + }), + ).resolves.toEqual(activeForm); + + expect(handlePluginApiRoute).toHaveBeenCalledWith( + "emdash-forms", + "POST", + "/definition", + expect.any(Request), + ); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("treats internal missing or inactive results as definitive", async () => { + const handlePluginApiRoute = vi.fn(async () => ({ + success: false, + error: { code: "NOT_FOUND", message: "Form not found" }, + })); + const fetch = vi.fn(async () => jsonResponse({ data: activeForm })); + + await expect( + loadPublicFormDefinition({ + formId: "missing", + baseUrl: new URL("https://example.com/contact"), + handlePluginApiRoute, + fetch, + }), + ).resolves.toBeNull(); + + expect(fetch).not.toHaveBeenCalled(); + }); + + it("falls back to fetching the public definition route when no handler is available", async () => { + const fetch = vi.fn(async () => jsonResponse({ data: activeForm })); + + await expect( + loadPublicFormDefinition({ + formId: "contact", + baseUrl: new URL("https://example.com/contact"), + fetch, + }), + ).resolves.toEqual(activeForm); + + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("falls back to fetching when the internal handler throws", async () => { + const handlePluginApiRoute = vi.fn(async () => { + throw new Error("dispatcher unavailable"); + }); + const fetch = vi.fn(async () => jsonResponse({ data: activeForm })); + + await expect( + loadPublicFormDefinition({ + formId: "contact", + baseUrl: new URL("https://example.com/contact"), + handlePluginApiRoute, + fetch, + }), + ).resolves.toEqual(activeForm); + + expect(fetch).toHaveBeenCalledTimes(1); + }); +});