From 11e52174ed8e483f1fd2228d4c476895a1b481d8 Mon Sep 17 00:00:00 2001 From: meidici911 Date: Mon, 11 May 2026 10:02:56 +0100 Subject: [PATCH 1/3] fix(forms): render public form embeds via SSR plugin routes --- .changeset/public-form-ssr-routes.md | 6 + packages/core/src/astro/middleware.ts | 2 + .../src/astro/public-plugin-api-routes.ts | 41 +++++++ .../unit/astro/middleware-prerender.test.ts | 68 +++++++++++ .../astro/public-plugin-api-routes.test.ts | 74 ++++++++++++ .../plugins/forms/src/astro/FormEmbed.astro | 23 ++-- .../plugins/forms/src/public-definition.ts | 77 +++++++++++- .../forms/tests/public-definition.test.ts | 110 +++++++++++++++++- 8 files changed, 385 insertions(+), 16 deletions(-) create mode 100644 .changeset/public-form-ssr-routes.md create mode 100644 packages/core/src/astro/public-plugin-api-routes.ts create mode 100644 packages/core/tests/unit/astro/public-plugin-api-routes.test.ts 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 3b695ad28..49e2bc65c 100644 --- a/packages/core/src/astro/middleware.ts +++ b/packages/core/src/astro/middleware.ts @@ -51,6 +51,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) @@ -370,6 +371,7 @@ export const onRequest = defineMiddleware(async (context, next) => { setupVerified = true; // eslint-disable-next-line typescript/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); + }); +}); From 87f7825b7b8dcbf93aecf2bc9bcc2a71c0ef81eb Mon Sep 17 00:00:00 2001 From: meidici911 Date: Wed, 27 May 2026 09:34:26 +0800 Subject: [PATCH 2/3] Address public form SSR review feedback --- packages/core/src/astro/middleware.ts | 4 +- packages/core/src/astro/types.ts | 8 + packages/core/src/plugin-utils.ts | 23 ++ .../unit/astro/middleware-prerender.test.ts | 217 +++++++++++++++--- packages/core/tests/utils/mcp-runtime.ts | 3 + .../plugins/forms/src/astro/FormEmbed.astro | 13 +- .../plugins/forms/src/public-definition.ts | 140 ++++++++--- .../forms/tests/public-definition.test.ts | 119 ++++++++-- 8 files changed, 440 insertions(+), 87 deletions(-) diff --git a/packages/core/src/astro/middleware.ts b/packages/core/src/astro/middleware.ts index 49e2bc65c..0ec49008d 100644 --- a/packages/core/src/astro/middleware.ts +++ b/packages/core/src/astro/middleware.ts @@ -369,9 +369,10 @@ export const onRequest = defineMiddleware(async (context, next) => { try { const runtime = await getRuntime(config, initSubTimings); setupVerified = true; + const handlePublicPluginApiRoute = createPublicPluginApiRouteHandler(runtime); // eslint-disable-next-line typescript/no-unsafe-type-assertion -- partial object; getPageRuntime() only checks for the page-contribution methods locals.emdash = { - handlePluginApiRoute: createPublicPluginApiRouteHandler(runtime), + handlePublicPluginApiRoute, collectPageMetadata: runtime.collectPageMetadata.bind(runtime), collectPageFragments: runtime.collectPageFragments.bind(runtime), getPublicMediaUrl: createPublicMediaUrlResolver(runtime.storage), @@ -498,6 +499,7 @@ export const onRequest = defineMiddleware(async (context, next) => { // Plugin routes handlePluginApiRoute: runtime.handlePluginApiRoute.bind(runtime), + handlePublicPluginApiRoute: createPublicPluginApiRouteHandler(runtime), getPluginRouteMeta: runtime.getPluginRouteMeta.bind(runtime), // Media provider methods diff --git a/packages/core/src/astro/types.ts b/packages/core/src/astro/types.ts index 50027cac8..0ac391ddc 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -385,6 +385,14 @@ export interface EmDashHandlers { request: Request, ) => Promise; + // Public-only plugin API route handler for SSR page components. + handlePublicPluginApiRoute: ( + pluginId: string, + method: string, + path: string, + request: Request, + ) => Promise; + // Plugin route metadata (for auth decisions before dispatch) getPluginRouteMeta: (pluginId: string, path: string) => { public: boolean } | null; diff --git a/packages/core/src/plugin-utils.ts b/packages/core/src/plugin-utils.ts index c161e03cf..3fa03c8ca 100644 --- a/packages/core/src/plugin-utils.ts +++ b/packages/core/src/plugin-utils.ts @@ -8,6 +8,16 @@ * Import as: `import { apiFetch, parseApiResponse, isRecord } from "emdash/plugin-utils";` */ +import type { EmDashHandlers } from "./astro/types.js"; + +export type PublicPluginApiRouteHandler = EmDashHandlers["handlePublicPluginApiRoute"]; + +export interface PublicPluginRuntimeLocals { + emdash?: { + handlePublicPluginApiRoute?: PublicPluginApiRouteHandler; + }; +} + /** * Fetch wrapper that adds the `X-EmDash-Request` CSRF protection header. * @@ -20,6 +30,19 @@ export function apiFetch(input: string | URL | Request, init?: RequestInit): Pro return fetch(input, { ...init, headers }); } +/** + * Get the public-only plugin route dispatcher exposed to SSR page components. + * + * This intentionally reads `handlePublicPluginApiRoute`, not the raw + * `handlePluginApiRoute` used by core's authenticated plugin API route. + */ +export function getPublicPluginApiRouteHandler( + locals: PublicPluginRuntimeLocals | null | undefined, +): PublicPluginApiRouteHandler | undefined { + const handler = locals?.emdash?.handlePublicPluginApiRoute; + return typeof handler === "function" ? handler : undefined; +} + /** * Parse an API response, unwrapping the `{ data: T }` envelope. * diff --git a/packages/core/tests/unit/astro/middleware-prerender.test.ts b/packages/core/tests/unit/astro/middleware-prerender.test.ts index f1a4bd00b..292505c8a 100644 --- a/packages/core/tests/unit/astro/middleware-prerender.test.ts +++ b/packages/core/tests/unit/astro/middleware-prerender.test.ts @@ -11,28 +11,78 @@ const { DB_CONFIG_MARKER } = vi.hoisted(() => ({ DB_CONFIG_MARKER: { binding: "DB", session: "auto" }, })); -const { MOCK_RUNTIME } = vi.hoisted(() => ({ - MOCK_RUNTIME: new Proxy( - { - storage: null, +const { + MOCK_RUNTIME, + PUBLIC_PLUGIN_RESULT, + mockGetPluginRouteMeta, + mockHandlePluginApiRoute, + mockGetPublicUrl, +} = vi.hoisted(() => { + const publicPluginResult = { success: true, data: { ok: true } }; + const ok = async () => ({ success: true }); + const getPublicUrl = vi.fn((key: string) => `https://media.example.com/${key}`); + const getPluginRouteMeta = vi.fn((pluginId: string, path: string) => { + if (pluginId !== "emdash-forms") return null; + if (path === "/definition") return { public: true }; + if (path === "/private") return { public: false }; + return null; + }); + const handlePluginApiRoute = vi.fn(async () => publicPluginResult); + + return { + MOCK_RUNTIME: { + storage: { getPublicUrl }, db: {}, hooks: {}, email: null, configuredPlugins: [], + handleContentList: ok, + handleContentGet: ok, + handleContentCreate: ok, + handleContentUpdate: ok, + handleContentDelete: ok, + handleContentListTrashed: ok, + handleContentRestore: ok, + handleContentPermanentDelete: ok, + handleContentCountTrashed: ok, + handleContentGetIncludingTrashed: ok, + handleContentDuplicate: ok, + handleContentPublish: ok, + handleContentUnpublish: ok, + handleContentSchedule: ok, + handleContentUnschedule: ok, + handleContentCountScheduled: ok, + handleContentDiscardDraft: ok, + handleContentCompare: ok, + handleContentTranslations: ok, + handleMediaList: ok, + handleMediaGet: ok, + handleMediaCreate: ok, + handleMediaUpdate: ok, + handleMediaDelete: ok, + handleRevisionList: ok, + handleRevisionGet: ok, + handleRevisionRestore: ok, + getPluginRouteMeta, + handlePluginApiRoute, + getMediaProvider: () => undefined, + getMediaProviderList: () => [], + collectPageMetadata: async () => [], + collectPageFragments: async () => [], + ensureSearchHealthy: async () => undefined, + getManifest: async () => ({}), + getSandboxRunner: () => null, + isSandboxBypassed: () => false, + syncMarketplacePlugins: async () => undefined, + syncRegistryPlugins: async () => undefined, + setPluginStatus: async () => undefined, }, - { - 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 }); - }, - }, - ), -})); + PUBLIC_PLUGIN_RESULT: publicPluginResult, + mockGetPluginRouteMeta: getPluginRouteMeta, + mockHandlePluginApiRoute: handlePluginApiRoute, + mockGetPublicUrl: getPublicUrl, + }; +}); vi.mock( "virtual:emdash/config", @@ -60,6 +110,7 @@ vi.mock( "virtual:emdash/sandbox-runner", () => ({ createSandboxRunner: null, + sandboxBypassed: false, sandboxEnabled: false, }), { virtual: true }, @@ -91,21 +142,51 @@ import { createRequestScopedDb } from "virtual:emdash/dialect"; import onRequest from "../../../src/astro/middleware.js"; import { getRequestContext } from "../../../src/request-context.js"; +function createAnonymousPublicPageContext(locals: Record = {}) { + 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 }; + + return { + context: { + request: new Request("https://example.com/contact"), + url: new URL("https://example.com/contact"), + cookies, + locals, + redirect: vi.fn(), + isPrerendered: false, + session: astroSession, + } as Record, + cookies, + sessionGet, + }; +} + describe("astro middleware prerendered routes", () => { beforeEach(() => { vi.mocked(createRequestScopedDb).mockReset().mockReturnValue(null); + mockGetPluginRouteMeta.mockClear(); + mockHandlePluginApiRoute.mockClear(); + mockGetPublicUrl.mockClear(); }); it("does not access context.session on prerendered public runtime routes", async () => { const cookies = { get: vi.fn(() => undefined), }; + const locals: Record = {}; const context: Record = { request: new Request("https://example.com/robots.txt"), url: new URL("https://example.com/robots.txt"), cookies, - locals: {}, + locals, redirect: vi.fn(), isPrerendered: true, }; @@ -122,6 +203,9 @@ describe("astro middleware prerendered routes", () => { ); expect(response.status).toBe(200); + const emdash = locals.emdash as Record; + expect(typeof emdash.handlePluginApiRoute).toBe("function"); + expect(typeof emdash.handlePublicPluginApiRoute).toBe("function"); }); it("does not access context.session when prerendering public pages", async () => { @@ -160,6 +244,9 @@ describe("astro middleware prerendered routes", () => { describe("astro middleware anonymous session reads", () => { beforeEach(() => { vi.mocked(createRequestScopedDb).mockReset().mockReturnValue(null); + mockGetPluginRouteMeta.mockClear(); + mockHandlePluginApiRoute.mockClear(); + mockGetPublicUrl.mockClear(); }); it("does not read the Astro session when no astro-session cookie is present", async () => { @@ -197,26 +284,8 @@ describe("astro middleware anonymous session reads", () => { }); 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 { context, sessionGet } = createAnonymousPublicPageContext(locals); const response = await onRequest( context as Parameters[0], @@ -226,15 +295,84 @@ describe("astro middleware anonymous session reads", () => { expect(response.status).toBe(200); expect(sessionGet).not.toHaveBeenCalled(); const emdash = locals.emdash as Record; - expect(typeof emdash.handlePluginApiRoute).toBe("function"); + expect(typeof emdash.handlePublicPluginApiRoute).toBe("function"); expect(typeof emdash.collectPageMetadata).toBe("function"); expect(typeof emdash.collectPageFragments).toBe("function"); + expect(typeof emdash.getPublicMediaUrl).toBe("function"); + expect((emdash.getPublicMediaUrl as (key: string) => string)("01ABC.jpg")).toBe( + "https://media.example.com/01ABC.jpg", + ); + expect(mockGetPublicUrl).toHaveBeenCalledWith("01ABC.jpg"); + expect("handlePluginApiRoute" in emdash).toBe(false); 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("dispatches public plugin API routes through the anonymous public-page helper", async () => { + const locals: Record = {}; + const { context } = createAnonymousPublicPageContext(locals); + + await onRequest(context as Parameters[0], async () => new Response("ok")); + + const emdash = locals.emdash as Record; + const request = new Request("https://example.com/_emdash/api/plugins/emdash-forms/definition", { + method: "POST", + body: "{}", + }); + + await expect( + ( + emdash.handlePublicPluginApiRoute as ( + pluginId: string, + method: string, + path: string, + request: Request, + ) => Promise + )("emdash-forms", "POST", "/definition", request), + ).resolves.toBe(PUBLIC_PLUGIN_RESULT); + + expect(mockGetPluginRouteMeta).toHaveBeenCalledWith("emdash-forms", "/definition"); + expect(mockHandlePluginApiRoute).toHaveBeenCalledWith( + "emdash-forms", + "POST", + "/definition", + request, + ); + }); + + it("does not dispatch private plugin API routes through the anonymous public-page helper", async () => { + const locals: Record = {}; + const { context } = createAnonymousPublicPageContext(locals); + + await onRequest(context as Parameters[0], async () => new Response("ok")); + + const emdash = locals.emdash as Record; + + await expect( + ( + emdash.handlePublicPluginApiRoute as ( + pluginId: string, + method: string, + path: string, + request: Request, + ) => Promise + )( + "emdash-forms", + "POST", + "/private", + new Request("https://example.com/_emdash/api/plugins/emdash-forms/private"), + ), + ).resolves.toEqual({ + success: false, + error: { code: "NOT_FOUND", message: "Plugin route not found" }, + }); + + expect(mockGetPluginRouteMeta).toHaveBeenCalledWith("emdash-forms", "/private"); + expect(mockHandlePluginApiRoute).not.toHaveBeenCalled(); + }); + it("reads the Astro session when an astro-session cookie is present", async () => { const cookies = { get: vi.fn((name: string) => { @@ -271,6 +409,9 @@ describe("astro middleware anonymous session reads", () => { describe("astro middleware request-scoped db", () => { beforeEach(() => { vi.mocked(createRequestScopedDb).mockReset().mockReturnValue(null); + mockGetPluginRouteMeta.mockClear(); + mockHandlePluginApiRoute.mockClear(); + mockGetPublicUrl.mockClear(); }); it("asks the adapter for a scoped db on anonymous public pages and exposes it via ALS", async () => { diff --git a/packages/core/tests/utils/mcp-runtime.ts b/packages/core/tests/utils/mcp-runtime.ts index e7cb96dbb..eedacf3b8 100644 --- a/packages/core/tests/utils/mcp-runtime.ts +++ b/packages/core/tests/utils/mcp-runtime.ts @@ -212,6 +212,9 @@ export function handlersFromRuntime(runtime: EmDashRuntime): EmDashHandlers { handlePluginApiRoute: () => { throw new Error("handlePluginApiRoute not implemented in test runtime"); }, + handlePublicPluginApiRoute: () => { + throw new Error("handlePublicPluginApiRoute not implemented in test runtime"); + }, getPluginRouteMeta: () => null, getMediaProvider: runtime.getMediaProvider.bind(runtime), getMediaProviderList: runtime.getMediaProviderList.bind(runtime), diff --git a/packages/plugins/forms/src/astro/FormEmbed.astro b/packages/plugins/forms/src/astro/FormEmbed.astro index 4b6b867cb..cd3f5d30d 100644 --- a/packages/plugins/forms/src/astro/FormEmbed.astro +++ b/packages/plugins/forms/src/astro/FormEmbed.astro @@ -6,10 +6,9 @@ * Without JavaScript, all pages are visible as one long form. * The client-side script enhances with multi-page navigation, AJAX, etc. */ -import { - loadPublicFormDefinition, - type PublicPluginApiRouteHandler, -} from "../public-definition.js"; +import { getPublicPluginApiRouteHandler } from "emdash/plugin-utils"; + +import { loadPublicFormDefinition } from "../public-definition.js"; import type { FormField, FormPage } from "../types.js"; interface Props { @@ -19,14 +18,12 @@ interface Props { const { node } = Astro.props; const formId = node.formId; -const handlePluginApiRoute = (Astro.locals.emdash as - | { handlePluginApiRoute?: PublicPluginApiRouteHandler } - | undefined)?.handlePluginApiRoute; +const handlePublicPluginApiRoute = getPublicPluginApiRouteHandler(Astro.locals); const form = await loadPublicFormDefinition({ formId, baseUrl: Astro.url, - handlePluginApiRoute, + handlePublicPluginApiRoute, }); if (!form) return; diff --git a/packages/plugins/forms/src/public-definition.ts b/packages/plugins/forms/src/public-definition.ts index 21740a145..df93c22a3 100644 --- a/packages/plugins/forms/src/public-definition.ts +++ b/packages/plugins/forms/src/public-definition.ts @@ -1,4 +1,4 @@ -import { parseApiResponse } from "emdash/plugin-utils"; +import { parseApiResponse, type PublicPluginApiRouteHandler } from "emdash/plugin-utils"; import type { FormDefinition } from "./types.js"; @@ -14,47 +14,132 @@ 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; + handlePublicPluginApiRoute?: PublicPluginApiRouteHandler; fetch?: (input: Request) => Promise; } function isObject(value: unknown): value is Record { - return typeof value === "object" && value !== null; + return typeof value === "object" && value !== null && !Array.isArray(value); } -export function parsePublicFormDefinitionPayload(payload: unknown): PublicFormDefinition | null { - if (!isObject(payload)) { - return null; +function isOptionalString(value: unknown): boolean { + return value === undefined || typeof value === "string"; +} + +function isOptionalStringOrNull(value: unknown): boolean { + return value === undefined || value === null || typeof value === "string"; +} + +const VALID_FIELD_TYPES = new Set([ + "text", + "email", + "textarea", + "number", + "tel", + "url", + "date", + "select", + "radio", + "checkbox", + "checkbox-group", + "file", + "hidden", +]); + +const VALID_SPAM_PROTECTION = new Set(["none", "honeypot", "turnstile"]); + +function hasValidOptions(value: unknown): boolean { + return ( + value === undefined || + (Array.isArray(value) && + value.every( + (option) => + isObject(option) && + typeof option.label === "string" && + typeof option.value === "string", + )) + ); +} + +function isPublicFormField(value: unknown): boolean { + if (!isObject(value)) { + return false; } - if ("success" in payload) { - if (payload.success !== true) { - return null; - } - return parsePublicFormDefinitionPayload(payload.data); + return ( + typeof value.id === "string" && + typeof value.type === "string" && + VALID_FIELD_TYPES.has(value.type) && + typeof value.label === "string" && + typeof value.name === "string" && + typeof value.required === "boolean" && + (value.width === "full" || value.width === "half") && + isOptionalString(value.placeholder) && + isOptionalString(value.helpText) && + isOptionalString(value.defaultValue) && + hasValidOptions(value.options) && + (value.validation === undefined || isObject(value.validation)) && + (value.condition === undefined || isObject(value.condition)) + ); +} + +function isPublicFormPage(value: unknown): boolean { + return ( + isObject(value) && + isOptionalString(value.title) && + Array.isArray(value.fields) && + value.fields.every(isPublicFormField) + ); +} + +function isPublicFormSettings(value: unknown): boolean { + if (!isObject(value)) { + return false; } - if ("data" in payload) { - return parsePublicFormDefinitionPayload(payload.data); + return ( + typeof value.spamProtection === "string" && + VALID_SPAM_PROTECTION.has(value.spamProtection) && + typeof value.submitLabel === "string" && + isOptionalString(value.nextLabel) && + isOptionalString(value.prevLabel) + ); +} + +function parsePublicFormDefinitionData(payload: unknown): PublicFormDefinition | null { + if (!isObject(payload)) { + return null; } if (payload.status !== "active") { return null; } + if ( + typeof payload.name !== "string" || + typeof payload.slug !== "string" || + !Array.isArray(payload.pages) || + !payload.pages.every(isPublicFormPage) || + !isPublicFormSettings(payload.settings) || + !isOptionalStringOrNull(payload._turnstileSiteKey) + ) { + return null; + } + return payload as unknown as PublicFormDefinition; } +export function parsePublicFormDefinitionPayload(payload: unknown): PublicFormDefinition | null { + if (!isObject(payload) || payload.success !== true) { + return null; + } + + return parsePublicFormDefinitionData(payload.data); +} + export async function parsePublicFormDefinitionResponse( response: Response, ): Promise { @@ -62,8 +147,8 @@ export async function parsePublicFormDefinitionResponse( return null; } - const form = await parseApiResponse(response); - return parsePublicFormDefinitionPayload(form); + const form = await parseApiResponse(response); + return parsePublicFormDefinitionData(form); } function createPublicFormDefinitionRequest(formId: string, baseUrl: URL): Request { @@ -77,21 +162,22 @@ function createPublicFormDefinitionRequest(formId: string, baseUrl: URL): Reques export async function loadPublicFormDefinition({ formId, baseUrl, - handlePluginApiRoute, + handlePublicPluginApiRoute, fetch: fetchImpl = fetch, }: LoadPublicFormDefinitionOptions): Promise { - if (handlePluginApiRoute) { + if (handlePublicPluginApiRoute) { try { return parsePublicFormDefinitionPayload( - await handlePluginApiRoute( + await handlePublicPluginApiRoute( "emdash-forms", "POST", "/definition", createPublicFormDefinitionRequest(formId, baseUrl), ), ); - } catch { - // Fall back to HTTP fetch for older runtimes or unexpected dispatcher failures. + } catch (error) { + console.warn("[emdash-forms] public definition dispatcher failed:", error); + return null; } } diff --git a/packages/plugins/forms/tests/public-definition.test.ts b/packages/plugins/forms/tests/public-definition.test.ts index 74e10e67c..6fb117657 100644 --- a/packages/plugins/forms/tests/public-definition.test.ts +++ b/packages/plugins/forms/tests/public-definition.test.ts @@ -63,6 +63,17 @@ describe("parsePublicFormDefinitionResponse", () => { parsePublicFormDefinitionResponse(jsonResponse({ error: { message: "Not found" } }, 404)), ).resolves.toBeNull(); }); + + it("does not recursively unwrap nested response envelopes", async () => { + await expect( + parsePublicFormDefinitionResponse(jsonResponse({ data: { data: activeForm } })), + ).resolves.toBeNull(); + await expect( + parsePublicFormDefinitionResponse( + jsonResponse({ data: { success: true, data: activeForm } }), + ), + ).resolves.toBeNull(); + }); }); describe("parsePublicFormDefinitionPayload", () => { @@ -90,23 +101,77 @@ describe("parsePublicFormDefinitionPayload", () => { }), ).toBeNull(); }); + + it("does not recursively unwrap nested handler result data", () => { + expect( + parsePublicFormDefinitionPayload({ + success: true, + data: { data: activeForm }, + }), + ).toBeNull(); + expect( + parsePublicFormDefinitionPayload({ + success: true, + data: { success: true, data: activeForm }, + }), + ).toBeNull(); + }); + + it("returns null for malformed active form data", () => { + expect( + parsePublicFormDefinitionPayload({ + success: true, + data: { status: "active" }, + }), + ).toBeNull(); + expect( + parsePublicFormDefinitionPayload({ + success: true, + data: { ...activeForm, pages: undefined }, + }), + ).toBeNull(); + expect( + parsePublicFormDefinitionPayload({ + success: true, + data: { ...activeForm, settings: undefined }, + }), + ).toBeNull(); + expect( + parsePublicFormDefinitionPayload({ + success: true, + data: { + ...activeForm, + pages: [{ fields: [{ ...activeForm.pages[0]!.fields[0]!, type: "unknown" }] }], + }, + }), + ).toBeNull(); + expect( + parsePublicFormDefinitionPayload({ + success: true, + data: { + ...activeForm, + pages: [{ fields: [{ ...activeForm.pages[0]!.fields[0]!, options: {} }] }], + }, + }), + ).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 handlePublicPluginApiRoute = 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, + handlePublicPluginApiRoute, fetch, }), ).resolves.toEqual(activeForm); - expect(handlePluginApiRoute).toHaveBeenCalledWith( + expect(handlePublicPluginApiRoute).toHaveBeenCalledWith( "emdash-forms", "POST", "/definition", @@ -116,7 +181,7 @@ describe("loadPublicFormDefinition", () => { }); it("treats internal missing or inactive results as definitive", async () => { - const handlePluginApiRoute = vi.fn(async () => ({ + const handlePublicPluginApiRoute = vi.fn(async () => ({ success: false, error: { code: "NOT_FOUND", message: "Form not found" }, })); @@ -126,7 +191,7 @@ describe("loadPublicFormDefinition", () => { loadPublicFormDefinition({ formId: "missing", baseUrl: new URL("https://example.com/contact"), - handlePluginApiRoute, + handlePublicPluginApiRoute, fetch, }), ).resolves.toBeNull(); @@ -134,35 +199,63 @@ describe("loadPublicFormDefinition", () => { expect(fetch).not.toHaveBeenCalled(); }); - it("falls back to fetching the public definition route when no handler is available", async () => { + it("treats malformed internal results as definitive", async () => { + const handlePublicPluginApiRoute = vi.fn(async () => ({ + success: true, + data: { status: "active" }, + })); const fetch = vi.fn(async () => jsonResponse({ data: activeForm })); await expect( loadPublicFormDefinition({ formId: "contact", baseUrl: new URL("https://example.com/contact"), + handlePublicPluginApiRoute, fetch, }), - ).resolves.toEqual(activeForm); + ).resolves.toBeNull(); - expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).not.toHaveBeenCalled(); }); - it("falls back to fetching when the internal handler throws", async () => { - const handlePluginApiRoute = vi.fn(async () => { - throw new Error("dispatcher unavailable"); - }); + 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"), - handlePluginApiRoute, fetch, }), ).resolves.toEqual(activeForm); expect(fetch).toHaveBeenCalledTimes(1); }); + + it("does not fall back to fetching when the internal handler throws", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const handlePublicPluginApiRoute = vi.fn(async () => { + throw new Error("dispatcher unavailable"); + }); + const fetch = vi.fn(async () => jsonResponse({ data: activeForm })); + + try { + await expect( + loadPublicFormDefinition({ + formId: "contact", + baseUrl: new URL("https://example.com/contact"), + handlePublicPluginApiRoute, + fetch, + }), + ).resolves.toBeNull(); + + expect(fetch).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith( + "[emdash-forms] public definition dispatcher failed:", + expect.any(Error), + ); + } finally { + warn.mockRestore(); + } + }); }); From ec13951f54a0a07f7e24710c2e42ae59ced186fb Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Wed, 27 May 2026 01:34:57 +0000 Subject: [PATCH 3/3] style: format --- packages/plugins/forms/src/public-definition.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/plugins/forms/src/public-definition.ts b/packages/plugins/forms/src/public-definition.ts index df93c22a3..926bbb698 100644 --- a/packages/plugins/forms/src/public-definition.ts +++ b/packages/plugins/forms/src/public-definition.ts @@ -57,9 +57,7 @@ function hasValidOptions(value: unknown): boolean { (Array.isArray(value) && value.every( (option) => - isObject(option) && - typeof option.label === "string" && - typeof option.value === "string", + isObject(option) && typeof option.label === "string" && typeof option.value === "string", )) ); }