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..0ec49008d 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) @@ -368,8 +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 = { + handlePublicPluginApiRoute, collectPageMetadata: runtime.collectPageMetadata.bind(runtime), collectPageFragments: runtime.collectPageFragments.bind(runtime), getPublicMediaUrl: createPublicMediaUrlResolver(runtime.storage), @@ -496,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/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/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 dbe96fbad..292505c8a 100644 --- a/packages/core/tests/unit/astro/middleware-prerender.test.ts +++ b/packages/core/tests/unit/astro/middleware-prerender.test.ts @@ -11,6 +11,79 @@ const { DB_CONFIG_MARKER } = vi.hoisted(() => ({ DB_CONFIG_MARKER: { binding: "DB", session: "auto" }, })); +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, + }, + PUBLIC_PLUGIN_RESULT: publicPluginResult, + mockGetPluginRouteMeta: getPluginRouteMeta, + mockHandlePluginApiRoute: handlePluginApiRoute, + mockGetPublicUrl: getPublicUrl, + }; +}); + vi.mock( "virtual:emdash/config", () => ({ @@ -37,6 +110,7 @@ vi.mock( "virtual:emdash/sandbox-runner", () => ({ createSandboxRunner: null, + sandboxBypassed: false, sandboxEnabled: false, }), { virtual: true }, @@ -45,6 +119,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: () => ({ @@ -62,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, }; @@ -93,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 () => { @@ -131,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 () => { @@ -167,6 +283,96 @@ describe("astro middleware anonymous session reads", () => { expect(sessionGet).not.toHaveBeenCalled(); }); + it("exposes only restricted public runtime helpers to anonymous public pages", async () => { + const locals: Record = {}; + const { context, sessionGet } = createAnonymousPublicPageContext(locals); + + 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.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) => { @@ -203,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/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/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 b5dfbff5d..cd3f5d30d 100644 --- a/packages/plugins/forms/src/astro/FormEmbed.astro +++ b/packages/plugins/forms/src/astro/FormEmbed.astro @@ -6,7 +6,9 @@ * 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 { getPublicPluginApiRouteHandler } from "emdash/plugin-utils"; + +import { loadPublicFormDefinition } from "../public-definition.js"; import type { FormField, FormPage } from "../types.js"; interface Props { @@ -16,17 +18,13 @@ 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 handlePublicPluginApiRoute = getPublicPluginApiRouteHandler(Astro.locals); -const form = await parsePublicFormDefinitionResponse(response); +const form = await loadPublicFormDefinition({ + formId, + baseUrl: Astro.url, + handlePublicPluginApiRoute, +}); 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..926bbb698 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,6 +14,130 @@ export interface PublicFormDefinition { _turnstileSiteKey?: string | null; } +interface LoadPublicFormDefinitionOptions { + formId: string; + baseUrl: URL; + handlePublicPluginApiRoute?: PublicPluginApiRouteHandler; + fetch?: (input: Request) => Promise; +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +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; + } + + 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; + } + + 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 { @@ -21,10 +145,41 @@ export async function parsePublicFormDefinitionResponse( return null; } - const form = await parseApiResponse(response); - if (!form || form.status !== "active") { - return null; + const form = await parseApiResponse(response); + return parsePublicFormDefinitionData(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, + handlePublicPluginApiRoute, + fetch: fetchImpl = fetch, +}: LoadPublicFormDefinitionOptions): Promise { + if (handlePublicPluginApiRoute) { + try { + return parsePublicFormDefinitionPayload( + await handlePublicPluginApiRoute( + "emdash-forms", + "POST", + "/definition", + createPublicFormDefinitionRequest(formId, baseUrl), + ), + ); + } catch (error) { + console.warn("[emdash-forms] public definition dispatcher failed:", error); + return null; + } } - 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..6fb117657 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", @@ -59,4 +63,199 @@ 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", () => { + 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(); + }); + + 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 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"), + handlePublicPluginApiRoute, + fetch, + }), + ).resolves.toEqual(activeForm); + + expect(handlePublicPluginApiRoute).toHaveBeenCalledWith( + "emdash-forms", + "POST", + "/definition", + expect.any(Request), + ); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("treats internal missing or inactive results as definitive", async () => { + const handlePublicPluginApiRoute = 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"), + handlePublicPluginApiRoute, + fetch, + }), + ).resolves.toBeNull(); + + expect(fetch).not.toHaveBeenCalled(); + }); + + 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.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("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(); + } + }); });