diff --git a/apps/nextjs/src/app/api/custom-widgets/[resource]/route.ts b/apps/nextjs/src/app/api/custom-widgets/[resource]/route.ts new file mode 100644 index 000000000..c608a54c6 --- /dev/null +++ b/apps/nextjs/src/app/api/custom-widgets/[resource]/route.ts @@ -0,0 +1,42 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +import { CUSTOM_WIDGET_MCP_AUTHORING_PROMPT } from "@homarr/custom-widgets/authoring-prompt"; +import { getCustomWidgetComponentCatalog, getCustomWidgetSkill } from "@homarr/custom-widgets/authoring-resources"; +import { getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core"; + +import { adminRoute } from "../admin"; + +interface RouteContext { + params: Promise<{ resource: string }>; +} + +const resources = new Map unknown>([ + ["schema", getCustomWidgetJsonSchema], + ["components", getCustomWidgetComponentCatalog], + ["skill", getCustomWidgetSkill], + [ + "authoring-prompt", + () => ({ + version: 2, + prompt: CUSTOM_WIDGET_MCP_AUTHORING_PROMPT, + resources: [ + "homarr://custom-widgets/schema", + "homarr://custom-widgets/components", + "homarr://custom-widgets/skill", + ], + httpResources: ["/api/custom-widgets/schema", "/api/custom-widgets/components", "/api/custom-widgets/skill"], + }), + ], +]); + +const getAuthoringResource = async (_request: NextRequest, context: RouteContext): Promise => { + const { resource } = await context.params; + const loadResource = resources.get(resource); + if (!loadResource) { + return NextResponse.json({ error: "Custom Widget authoring resource not found." }, { status: 404 }); + } + return NextResponse.json(loadResource()); +}; + +export const GET = adminRoute(getAuthoringResource); diff --git a/apps/nextjs/src/app/api/custom-widgets/admin.spec.ts b/apps/nextjs/src/app/api/custom-widgets/admin.spec.ts index 81e4e62c3..d19b27aca 100644 --- a/apps/nextjs/src/app/api/custom-widgets/admin.spec.ts +++ b/apps/nextjs/src/app/api/custom-widgets/admin.spec.ts @@ -8,7 +8,7 @@ const mocks = vi.hoisted(() => ({ vi.mock("@homarr/auth/next", () => ({ auth: mocks.auth })); -import { requireCustomWidgetAdmin } from "./admin"; +import { adminRoute } from "./admin"; beforeEach(() => { mocks.auth.mockResolvedValue({ user: { permissions: ["admin"] } }); @@ -16,7 +16,11 @@ beforeEach(() => { describe("Custom Widget HTTP resource access", () => { test("allows authenticated administrators", async () => { - await expect(requireCustomWidgetAdmin()).resolves.toBeNull(); + const handler = vi.fn(async () => Response.json({ ok: true })); + const response = await adminRoute(handler)(); + + expect(response.status).toBe(200); + expect(handler).toHaveBeenCalledOnce(); }); test.each([ @@ -24,7 +28,10 @@ describe("Custom Widget HTTP resource access", () => { ["authenticated non-admins", { user: { permissions: ["board-modify-all"] } }], ])("denies %s", async (_label, session) => { mocks.auth.mockResolvedValue(session); - const response = await requireCustomWidgetAdmin(); - expect(response?.status).toBe(403); + const handler = vi.fn(async () => Response.json({ ok: true })); + const response = await adminRoute(handler)(); + + expect(response.status).toBe(403); + expect(handler).not.toHaveBeenCalled(); }); }); diff --git a/apps/nextjs/src/app/api/custom-widgets/admin.ts b/apps/nextjs/src/app/api/custom-widgets/admin.ts index da6df6eb8..054b0fcf1 100644 --- a/apps/nextjs/src/app/api/custom-widgets/admin.ts +++ b/apps/nextjs/src/app/api/custom-widgets/admin.ts @@ -2,10 +2,14 @@ import { NextResponse } from "next/server"; import { auth } from "@homarr/auth/next"; -export const requireCustomWidgetAdmin = async (): Promise => { - const session = await auth(); - if (!session?.user.permissions.includes("admin")) { - return NextResponse.json({ error: "Administrator access is required." }, { status: 403 }); - } - return null; -}; +type RouteHandler = (...args: Arguments) => Response | Promise; + +export const adminRoute = + (handler: RouteHandler) => + async (...args: Arguments): Promise => { + const session = await auth(); + if (!session?.user.permissions.includes("admin")) { + return NextResponse.json({ error: "Administrator access is required." }, { status: 403 }); + } + return handler(...args); + }; diff --git a/apps/nextjs/src/app/api/custom-widgets/authoring-prompt/route.ts b/apps/nextjs/src/app/api/custom-widgets/authoring-prompt/route.ts deleted file mode 100644 index f2199ba35..000000000 --- a/apps/nextjs/src/app/api/custom-widgets/authoring-prompt/route.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { NextResponse } from "next/server"; - -import { CUSTOM_WIDGET_MCP_AUTHORING_PROMPT } from "@homarr/custom-widgets/authoring-prompt"; - -import { requireCustomWidgetAdmin } from "../admin"; - -export async function GET() { - const denied = await requireCustomWidgetAdmin(); - if (denied) return denied; - return NextResponse.json({ - version: 2, - prompt: CUSTOM_WIDGET_MCP_AUTHORING_PROMPT, - resources: [ - "homarr://custom-widgets/schema", - "homarr://custom-widgets/components", - "homarr://custom-widgets/skill", - ], - httpResources: ["/api/custom-widgets/schema", "/api/custom-widgets/components", "/api/custom-widgets/skill"], - }); -} diff --git a/apps/nextjs/src/app/api/custom-widgets/components/route.ts b/apps/nextjs/src/app/api/custom-widgets/components/route.ts deleted file mode 100644 index 700004e46..000000000 --- a/apps/nextjs/src/app/api/custom-widgets/components/route.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { NextResponse } from "next/server"; - -import { getCustomWidgetComponentCatalog } from "@homarr/custom-widgets/authoring-resources"; - -import { requireCustomWidgetAdmin } from "../admin"; - -export async function GET() { - const denied = await requireCustomWidgetAdmin(); - if (denied) return denied; - return NextResponse.json(getCustomWidgetComponentCatalog()); -} diff --git a/apps/nextjs/src/app/api/custom-widgets/configuration-request/[token]/route.ts b/apps/nextjs/src/app/api/custom-widgets/configuration-request/[token]/route.ts index 7a294ebf4..2e60d4b33 100644 --- a/apps/nextjs/src/app/api/custom-widgets/configuration-request/[token]/route.ts +++ b/apps/nextjs/src/app/api/custom-widgets/configuration-request/[token]/route.ts @@ -13,16 +13,14 @@ import { customWidgetSourceSchema } from "@homarr/custom-widgets/core"; import { invalidateCustomWidgetResponseCache } from "@homarr/custom-widgets/server"; import { db } from "@homarr/db"; -import { requireCustomWidgetAdmin } from "../../admin"; +import { adminRoute } from "../../admin"; import { readConfigurationRequestBody } from "../body"; interface RouteContext { params: Promise<{ token: string }>; } -export async function GET(_request: NextRequest, context: RouteContext) { - const denied = await requireCustomWidgetAdmin(); - if (denied) return denied; +const getConfigurationRequest = async (_request: NextRequest, context: RouteContext): Promise => { const { token } = await context.params; const request = await getCustomWidgetConfigurationRequest(token); if (!request) @@ -38,11 +36,9 @@ export async function GET(_request: NextRequest, context: RouteContext) { }, { headers: { "Cache-Control": "no-store" } }, ); -} +}; -export async function POST(request: NextRequest, context: RouteContext) { - const denied = await requireCustomWidgetAdmin(); - if (denied) return denied; +const completeConfigurationRequest = async (request: NextRequest, context: RouteContext): Promise => { const { token } = await context.params; const pending = await getCustomWidgetConfigurationRequest(token); if (!pending || pending.status !== "pending") { @@ -128,4 +124,7 @@ export async function POST(request: NextRequest, context: RouteContext) { } finally { await releaseCustomWidgetConfigurationRequest(token); } -} +}; + +export const GET = adminRoute(getConfigurationRequest); +export const POST = adminRoute(completeConfigurationRequest); diff --git a/apps/nextjs/src/app/api/custom-widgets/schema/route.ts b/apps/nextjs/src/app/api/custom-widgets/schema/route.ts deleted file mode 100644 index 57e90b77c..000000000 --- a/apps/nextjs/src/app/api/custom-widgets/schema/route.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { NextResponse } from "next/server"; - -import { getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core"; - -import { requireCustomWidgetAdmin } from "../admin"; - -export async function GET() { - const denied = await requireCustomWidgetAdmin(); - if (denied) return denied; - return NextResponse.json(getCustomWidgetJsonSchema()); -} diff --git a/apps/nextjs/src/app/api/custom-widgets/skill/route.ts b/apps/nextjs/src/app/api/custom-widgets/skill/route.ts deleted file mode 100644 index 8dc577b8c..000000000 --- a/apps/nextjs/src/app/api/custom-widgets/skill/route.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { NextResponse } from "next/server"; - -import { getCustomWidgetSkill } from "@homarr/custom-widgets/authoring-resources"; - -import { requireCustomWidgetAdmin } from "../admin"; - -export async function GET() { - const denied = await requireCustomWidgetAdmin(); - if (denied) return denied; - return NextResponse.json(getCustomWidgetSkill()); -}