From bc84e95d5bcc54349a18dbe6199594fe19ec8423 Mon Sep 17 00:00:00 2001 From: Connor Black Date: Thu, 13 Aug 2026 02:39:59 -0400 Subject: [PATCH 1/3] fix: expose published-file routes publicly --- src/index.ts | 5 ++ test/public-routes.test.ts | 97 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 test/public-routes.test.ts diff --git a/src/index.ts b/src/index.ts index 4203cd5..834b0a9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -78,12 +78,17 @@ export function createPlugin() { }, }, "indexnow/key": { + // The key file is public ownership proof, not a secret. Sites proxy this + // read-only route to /.txt for IndexNow to verify anonymously. + public: true, handler: async (ctx: RouteContext) => { const key = await getOrCreateIndexNowKey(ctx); return { key, keyFile: await getKeyFileBody(ctx) }; }, }, "llms/txt": { + // llms.txt is a published index intended for anonymous crawlers. + public: true, handler: async (ctx: RouteContext) => { const body = await generateLlmsTxt(ctx); return { enabled: body !== null, body: body ?? "" }; diff --git a/test/public-routes.test.ts b/test/public-routes.test.ts new file mode 100644 index 0000000..d3d8537 --- /dev/null +++ b/test/public-routes.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createPlugin } from "../src/index.js"; + +interface AnonymousRouteResponse { + success: boolean; + data?: unknown; + error?: { code: string; message: string }; +} + +interface PublicRouteRuntime { + getPluginRouteMeta(pluginId: string, path: string): { public: boolean } | null; + handlePluginApiRoute( + pluginId: string, + method: string, + path: string, + request: Request, + ): Promise; +} + +type PublicRouteHandler = ( + pluginId: string, + method: string, + path: string, + request: Request, +) => Promise; + +type CreatePublicRouteHandler = (runtime: PublicRouteRuntime) => PublicRouteHandler; + +function isPublicRouteModule(value: unknown): value is { + createPublicPluginApiRouteHandler: CreatePublicRouteHandler; +} { + return ( + typeof value === "object" && + value !== null && + "createPublicPluginApiRouteHandler" in value && + typeof value.createPublicPluginApiRouteHandler === "function" + ); +} + +const publicRouteModulePath = "../node_modules/emdash/src/astro/public-plugin-api-routes.js"; +const publicRouteModule: unknown = await import(/* @vite-ignore */ publicRouteModulePath); +if (!isPublicRouteModule(publicRouteModule)) { + throw new Error("EmDash public plugin route handler is unavailable"); +} +const { createPublicPluginApiRouteHandler } = publicRouteModule; + +function createAnonymousDispatcher() { + const plugin = createPlugin(); + const dispatch = vi.fn(async (_pluginId: string, _method: string, path: string) => ({ + success: true, + data: { path }, + })); + const handler = createPublicPluginApiRouteHandler({ + getPluginRouteMeta(pluginId, path) { + const route = pluginId === plugin.id ? plugin.routes[path.replace(/^\//, "")] : undefined; + return route ? { public: route.public === true } : null; + }, + handlePluginApiRoute: dispatch, + }); + + return { dispatch, handler, plugin }; +} + +describe("published-file route access", () => { + it.each(["indexnow/key", "llms/txt"])("allows anonymous GET access to %s", async (path) => { + const { dispatch, handler } = createAnonymousDispatcher(); + const request = new Request(`https://example.com/${path}`); + + await expect(handler("seo", "GET", path, request)).resolves.toEqual({ + success: true, + data: { path }, + }); + expect(dispatch).toHaveBeenCalledOnce(); + }); + + it.each(["settings", "settings/save"])("keeps %s protected", async (path) => { + const { dispatch, handler } = createAnonymousDispatcher(); + const request = new Request(`https://example.com/${path}`); + + await expect(handler("seo", "GET", path, request)).resolves.toMatchObject({ + success: false, + error: { code: "NOT_FOUND" }, + }); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it("does not broaden public access beyond published read routes", () => { + const { plugin } = createAnonymousDispatcher(); + const publicRoutes = Object.entries(plugin.routes) + .filter(([, route]) => route.public === true) + .map(([path]) => path) + .sort(); + + expect(publicRoutes).toEqual(["indexnow/key", "llms/txt", "schema/map"]); + }); +}); From 6854e3cd7afccd6ffc23e589fccd37cd5db7fc94 Mon Sep 17 00:00:00 2001 From: Connor Black Date: Thu, 13 Aug 2026 02:41:50 -0400 Subject: [PATCH 2/3] test: cover protected mutation route --- test/public-routes.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/public-routes.test.ts b/test/public-routes.test.ts index d3d8537..35f8b20 100644 --- a/test/public-routes.test.ts +++ b/test/public-routes.test.ts @@ -74,11 +74,14 @@ describe("published-file route access", () => { expect(dispatch).toHaveBeenCalledOnce(); }); - it.each(["settings", "settings/save"])("keeps %s protected", async (path) => { + it.each([ + ["GET", "settings"], + ["POST", "settings/save"], + ])("keeps %s %s protected", async (method, path) => { const { dispatch, handler } = createAnonymousDispatcher(); const request = new Request(`https://example.com/${path}`); - await expect(handler("seo", "GET", path, request)).resolves.toMatchObject({ + await expect(handler("seo", method, path, request)).resolves.toMatchObject({ success: false, error: { code: "NOT_FOUND" }, }); From 5a24394e91bea17db0151f417df17d15c2a28395 Mon Sep 17 00:00:00 2001 From: Connor Black Date: Thu, 13 Aug 2026 05:41:21 -0400 Subject: [PATCH 3/3] fix: restrict public file routes to GET --- src/index.ts | 10 +++++- test/public-routes.test.ts | 70 +++++++++++++++++++++++++++++++++----- 2 files changed, 71 insertions(+), 9 deletions(-) diff --git a/src/index.ts b/src/index.ts index 834b0a9..d9555b0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import { definePlugin } from "emdash"; +import { definePlugin, PluginRouteError } from "emdash"; import type { PluginDescriptor, RouteContext } from "emdash"; import { metadataHandler } from "./metadata.js"; import { @@ -26,6 +26,12 @@ export function seoPlugin(): PluginDescriptor { }; } +function requireGet(ctx: RouteContext): void { + if (ctx.request.method !== "GET") { + throw new PluginRouteError("METHOD_NOT_ALLOWED", "Method not allowed", 405); + } +} + export function createPlugin() { return definePlugin({ id: "seo", @@ -82,6 +88,7 @@ export function createPlugin() { // read-only route to /.txt for IndexNow to verify anonymously. public: true, handler: async (ctx: RouteContext) => { + requireGet(ctx); const key = await getOrCreateIndexNowKey(ctx); return { key, keyFile: await getKeyFileBody(ctx) }; }, @@ -90,6 +97,7 @@ export function createPlugin() { // llms.txt is a published index intended for anonymous crawlers. public: true, handler: async (ctx: RouteContext) => { + requireGet(ctx); const body = await generateLlmsTxt(ctx); return { enabled: body !== null, body: body ?? "" }; }, diff --git a/test/public-routes.test.ts b/test/public-routes.test.ts index 35f8b20..6d3451c 100644 --- a/test/public-routes.test.ts +++ b/test/public-routes.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it, vi } from "vitest"; +import { PluginRouteError } from "emdash"; +import type { RouteContext } from "emdash"; import { createPlugin } from "../src/index.js"; @@ -6,6 +8,7 @@ interface AnonymousRouteResponse { success: boolean; data?: unknown; error?: { code: string; message: string }; + status?: number; } interface PublicRouteRuntime { @@ -47,10 +50,42 @@ const { createPublicPluginApiRouteHandler } = publicRouteModule; function createAnonymousDispatcher() { const plugin = createPlugin(); - const dispatch = vi.fn(async (_pluginId: string, _method: string, path: string) => ({ - success: true, - data: { path }, - })); + const store = new Map(); + const kv = { + get: vi.fn(async (key: string) => store.get(key)), + set: vi.fn(async (key: string, value: unknown) => { + store.set(key, value); + }), + delete: vi.fn(async (key: string) => store.delete(key)), + list: vi.fn(async (prefix?: string) => + Array.from(store.entries()) + .filter(([key]) => !prefix || key.startsWith(prefix)) + .map(([key, value]) => ({ key, value })), + ), + }; + const dispatch = vi.fn(async (_pluginId: string, _method: string, path: string, request: Request) => { + const route = plugin.routes[path.replace(/^\//, "")]; + if (!route) { + return { + success: false, + error: { code: "NOT_FOUND", message: "Plugin route not found" }, + }; + } + + try { + const data = await route.handler({ request, kv } as unknown as RouteContext); + return { success: true, data }; + } catch (error) { + if (error instanceof PluginRouteError) { + return { + success: false, + error: { code: error.code, message: error.message }, + status: error.status, + }; + } + throw error; + } + }); const handler = createPublicPluginApiRouteHandler({ getPluginRouteMeta(pluginId, path) { const route = pluginId === plugin.id ? plugin.routes[path.replace(/^\//, "")] : undefined; @@ -59,21 +94,40 @@ function createAnonymousDispatcher() { handlePluginApiRoute: dispatch, }); - return { dispatch, handler, plugin }; + return { dispatch, handler, kv, plugin }; } +const publishedFileRoutes = ["indexnow/key", "llms/txt"] as const; +const nonGetMethods = ["POST", "PUT", "PATCH", "DELETE"] as const; + describe("published-file route access", () => { - it.each(["indexnow/key", "llms/txt"])("allows anonymous GET access to %s", async (path) => { + it.each(publishedFileRoutes)("allows anonymous GET access to %s", async (path) => { const { dispatch, handler } = createAnonymousDispatcher(); const request = new Request(`https://example.com/${path}`); - await expect(handler("seo", "GET", path, request)).resolves.toEqual({ + await expect(handler("seo", "GET", path, request)).resolves.toMatchObject({ success: true, - data: { path }, }); expect(dispatch).toHaveBeenCalledOnce(); }); + it.each( + publishedFileRoutes.flatMap((path) => nonGetMethods.map((method) => [method, path] as const)), + )("rejects anonymous %s access to %s before route side effects", async (method, path) => { + const { dispatch, handler, kv } = createAnonymousDispatcher(); + const request = new Request(`https://example.com/${path}`, { method }); + + await expect(handler("seo", method, path, request)).resolves.toMatchObject({ + success: false, + error: { code: "METHOD_NOT_ALLOWED" }, + status: 405, + }); + expect(dispatch).toHaveBeenCalledOnce(); + expect(kv.get).not.toHaveBeenCalled(); + expect(kv.set).not.toHaveBeenCalled(); + expect(kv.list).not.toHaveBeenCalled(); + }); + it.each([ ["GET", "settings"], ["POST", "settings/save"],