-

+

{urls.length > 1 && (
<>
+ )}
+
+
+ );
+}
diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-sources-editor.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-sources-editor.tsx
index 967737908..c3a2c44cc 100644
--- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-sources-editor.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-sources-editor.tsx
@@ -1,31 +1,18 @@
"use client";
-import { Accordion, Button, Fieldset, Group, PasswordInput, Select, Stack, Text, TextInput } from "@mantine/core";
-import { IconKey, IconPlus, IconTrash } from "@tabler/icons-react";
+import { Accordion, Button, List, Stack, Text } from "@mantine/core";
+import { IconPlus } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
-import { getCustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
-import type { CustomWidgetSource, CustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
+import type { CustomWidgetSource } from "@homarr/custom-widgets/core";
+import { useConfirmModal } from "@homarr/modals";
import { showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
-import { isRecord, parseJson, parseSources } from "./_custom-widget-form-utils";
+import { filterSecretsForSourceAuthentication, isRecord, parseJson, parseSources } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
-import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
-
-const secretFields: Record
> = {
- bearer: [{ kind: "apiKey" }],
- basic: [{ kind: "username" }, { kind: "password" }],
- apiKeyHeader: [{ kind: "apiKey" }],
- apiKeyQuery: [{ kind: "apiKey" }],
-};
-
-const sourceUrlErrorKeys: Record = {
- invalid: "baseUrlError.invalid",
- protocol: "baseUrlError.protocol",
- credentials: "baseUrlError.credentials",
- queryOrFragment: "baseUrlError.queryOrFragment",
-};
+import { getDependentRequestIds, removeDependentRequests } from "./_custom-widget-source-dependencies";
+import { CustomWidgetSourceField } from "./_custom-widget-source-field";
export function CustomWidgetSourcesEditor({
form,
@@ -35,6 +22,8 @@ export function CustomWidgetSourcesEditor({
definitionId?: string;
}) {
const t = useScopedI18n("customWidget.workbench.sources");
+ const w = useScopedI18n("customWidget.workbench");
+ const { openConfirmModal } = useConfirmModal();
const utils = clientApi.useUtils();
const clearSecretMutation = clientApi.customWidget.secretClear.useMutation();
const sources = parseSources(form.values.sources);
@@ -83,6 +72,14 @@ export function CustomWidgetSourcesEditor({
const auth =
type === "apiKeyHeader" ? { type, name: "X-API-Key" } : type === "apiKeyQuery" ? { type, name: "api_key" } : type;
update(index, { auth: auth as CustomWidgetSource["auth"] });
+ form.setFieldValue(
+ "secrets",
+ filterSecretsForSourceAuthentication(
+ form.values.secrets,
+ sources[index]?.id ?? "",
+ type as Parameters[2],
+ ),
+ );
};
const setSecret = (sourceId: string, kind: string, value: string) => {
const current = form.values.secrets.filter((secret) => !(secret.sourceId === sourceId && secret.kind === kind));
@@ -111,7 +108,7 @@ export function CustomWidgetSourcesEditor({
),
);
};
- const removeSource = (index: number) => {
+ const removeSourceAndDependents = (index: number) => {
const removedId = sources[index]?.id;
form.setFieldValue(
"sources",
@@ -122,25 +119,38 @@ export function CustomWidgetSourcesEditor({
),
);
if (!removedId) return;
- form.setFieldValue(
- "requests",
- JSON.stringify(
- Object.fromEntries(
- Object.entries(
- isRecord(parseJson(form.values.requests))
- ? (parseJson(form.values.requests) as Record)
- : {},
- ).filter(([, request]) => !isRecord(request) || request.source !== removedId),
- ),
- null,
- 2,
- ),
- );
+ form.setFieldValue("requests", JSON.stringify(removeDependentRequests(form.values.requests, removedId), null, 2));
form.setFieldValue(
"secrets",
form.values.secrets.filter((secret) => secret.sourceId !== removedId),
);
};
+ const removeSource = (index: number) => {
+ const removedId = sources[index]?.id;
+ if (!removedId) return;
+ const dependentRequestIds = getDependentRequestIds(form.values.requests, removedId);
+ if (dependentRequestIds.length === 0) {
+ removeSourceAndDependents(index);
+ return;
+ }
+ openConfirmModal({
+ title: t("remove"),
+ children: (
+
+
+ {w("requests.title")} ({dependentRequestIds.length})
+
+
+ {dependentRequestIds.map((requestId) => (
+ {requestId}
+ ))}
+
+
+ ),
+ confirmProps: { children: t("remove") },
+ onConfirm: () => removeSourceAndDependents(index),
+ });
+ };
const clearSecret = async (sourceId: string, kind: "apiKey" | "username" | "password") => {
if (!definitionId) return;
await clearSecretMutation.mutateAsync({ definitionId, sourceId, kind });
@@ -153,109 +163,20 @@ export function CustomWidgetSourcesEditor({
return (
{sources.map((source, index) => {
- const baseUrlIssue = getCustomWidgetSourceUrlIssue(source.baseUrl);
return (
-
+
);
})}
diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_import-custom-widget-button.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_import-custom-widget-button.tsx
new file mode 100644
index 000000000..654ed6d4b
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_import-custom-widget-button.tsx
@@ -0,0 +1,104 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useDisclosure } from "@mantine/hooks";
+import { IconUpload } from "@tabler/icons-react";
+
+import {
+ formatCustomWidgetImportIssues,
+ looksLikeCustomWidgetClipboard,
+ parseCustomWidgetClipboardDetailed,
+} from "@homarr/custom-widgets/core";
+import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
+import { showErrorNotification } from "@homarr/notifications";
+import { useScopedI18n } from "@homarr/translation/client";
+
+import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
+import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
+
+export const ImportCustomWidgetButton = () => {
+ const t = useScopedI18n("customWidget");
+ const fileInputRef = useRef(null);
+ const [pendingImport, setPendingImport] = useState(null);
+ const [reviewOpened, { open: openReview, close: closeReview }] = useDisclosure(false);
+ const queueImport = useCallback(
+ (value: HomarrCustomWidgetV2) => {
+ setPendingImport(value);
+ openReview();
+ },
+ [openReview],
+ );
+
+ useEffect(() => {
+ const handlePaste = (event: ClipboardEvent) => {
+ if (event.target instanceof Element && event.target.matches("input, textarea, [contenteditable='true']")) return;
+ const text = event.clipboardData?.getData("text/plain") ?? "";
+ if (!looksLikeCustomWidgetClipboard(text)) return;
+ event.preventDefault();
+ const result = parseCustomWidgetClipboardDetailed(text);
+ if (!result.success) {
+ showErrorNotification({
+ title: t("action.import"),
+ message: formatCustomWidgetImportIssues(result.issues),
+ });
+ return;
+ }
+ queueImport(result.widget);
+ };
+ window.addEventListener("paste", handlePaste);
+ return () => window.removeEventListener("paste", handlePaste);
+ }, [queueImport, t]);
+
+ const handleImport = (event: React.ChangeEvent) => {
+ const file = event.target.files?.[0];
+ if (!file) return;
+
+ const reader = new FileReader();
+ reader.addEventListener("load", (loadEvent) => {
+ try {
+ const result = parseCustomWidgetClipboardDetailed(loadEvent.target?.result as string);
+ if (!result.success) throw new Error(formatCustomWidgetImportIssues(result.issues));
+ queueImport(result.widget);
+ } catch (error) {
+ showErrorNotification({
+ title: t("action.import"),
+ message: error instanceof Error ? error.message : t("notification.importError"),
+ });
+ }
+ });
+ reader.addEventListener("error", () => {
+ showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
+ });
+ reader.readAsText(file);
+ event.target.value = "";
+ };
+
+ return (
+ <>
+ }
+ onClick={() => fileInputRef.current?.click()}
+ title={t("action.pasteImportHint")}
+ >
+ {t("action.import")}
+
+
+ {
+ closeReview();
+ setPendingImport(null);
+ }}
+ />
+ >
+ );
+};
diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_unsaved-changes-guard.ts b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_unsaved-changes-guard.ts
new file mode 100644
index 000000000..280138de8
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_unsaved-changes-guard.ts
@@ -0,0 +1,42 @@
+interface UnsavedChangesGuardOptions {
+ isDirty(): boolean;
+ confirmNavigation(href: string): void;
+}
+
+export function registerUnsavedChangesGuard({ isDirty, confirmNavigation }: UnsavedChangesGuardOptions) {
+ const handleClick = (event: MouseEvent) => {
+ if (!isDirty() || event.defaultPrevented || event.button !== 0) return;
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
+
+ const eventTarget = event.target instanceof Element ? event.target : null;
+ const anchor = eventTarget?.closest("a[href]");
+ if (!anchor || anchor.target === "_blank" || anchor.hasAttribute("download")) return;
+
+ const destination = new URL(anchor.href, window.location.href);
+ const current = new URL(window.location.href);
+ if (destination.origin !== current.origin) return;
+ if (
+ destination.pathname === current.pathname &&
+ destination.search === current.search &&
+ destination.hash !== current.hash
+ ) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ confirmNavigation(`${destination.pathname}${destination.search}${destination.hash}`);
+ };
+ const handleBeforeUnload = (event: BeforeUnloadEvent) => {
+ if (!isDirty()) return;
+ event.preventDefault();
+ event.returnValue = true;
+ };
+
+ document.addEventListener("click", handleClick, true);
+ window.addEventListener("beforeunload", handleBeforeUnload);
+ return () => {
+ document.removeEventListener("click", handleClick, true);
+ window.removeEventListener("beforeunload", handleBeforeUnload);
+ };
+}
diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-unsaved-changes-guard.spec.ts b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-unsaved-changes-guard.spec.ts
new file mode 100644
index 000000000..353806380
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-unsaved-changes-guard.spec.ts
@@ -0,0 +1,51 @@
+// @vitest-environment jsdom
+
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { registerUnsavedChangesGuard } from "./_unsaved-changes-guard";
+
+const cleanups: Array<() => void> = [];
+
+afterEach(() => {
+ cleanups.splice(0).forEach((cleanup) => cleanup());
+ document.body.replaceChildren();
+ window.history.replaceState(null, "", "/en/manage/custom-widgets/edit/widget-1");
+});
+
+describe("custom widget unsaved changes guard", () => {
+ it("blocks client-side link navigation and requests confirmation for a dirty form", () => {
+ window.history.replaceState(null, "", "/en/manage/custom-widgets/edit/widget-1");
+ const confirmNavigation = vi.fn();
+ cleanups.push(registerUnsavedChangesGuard({ isDirty: () => true, confirmNavigation }));
+ const link = document.createElement("a");
+ link.href = "/en/manage/custom-widgets";
+ link.textContent = "Custom widgets";
+ document.body.append(link);
+
+ const event = new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 });
+ link.dispatchEvent(event);
+
+ expect(event.defaultPrevented).toBe(true);
+ expect(confirmNavigation).toHaveBeenCalledWith("/en/manage/custom-widgets");
+ });
+
+ it("allows section links and navigation after the form is clean", () => {
+ window.history.replaceState(null, "", "/en/manage/custom-widgets/edit/widget-1");
+ let dirty = true;
+ const confirmNavigation = vi.fn();
+ cleanups.push(registerUnsavedChangesGuard({ isDirty: () => dirty, confirmNavigation }));
+ const sectionLink = document.createElement("a");
+ sectionLink.href = "#sources";
+ document.body.append(sectionLink);
+
+ expect(sectionLink.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }))).toBe(
+ true,
+ );
+ dirty = false;
+ const listLink = document.createElement("a");
+ listLink.href = "/en/manage/custom-widgets";
+ document.body.append(listLink);
+ expect(listLink.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }))).toBe(true);
+ expect(confirmNavigation).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-unsaved-changes-guard.ts b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-unsaved-changes-guard.ts
new file mode 100644
index 000000000..c69a845db
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-unsaved-changes-guard.ts
@@ -0,0 +1,34 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { useRouter } from "next/navigation";
+
+import { useConfirmModal } from "@homarr/modals";
+import { useI18n } from "@homarr/translation/client";
+import { registerUnsavedChangesGuard } from "./_unsaved-changes-guard";
+
+export function useUnsavedChangesGuard(isDirty: boolean) {
+ const t = useI18n();
+ const router = useRouter();
+ const { openConfirmModal } = useConfirmModal();
+ const dirtyRef = useRef(isDirty);
+ const confirmNavigationRef = useRef<(href: string) => void>(() => undefined);
+ dirtyRef.current = isDirty;
+ confirmNavigationRef.current = (href) => {
+ openConfirmModal({
+ title: t("board.action.edit.confirmLeave.title"),
+ children: t("board.action.edit.confirmLeave.message"),
+ confirmProps: { children: t("common.action.discard") },
+ onConfirm: () => router.push(href),
+ });
+ };
+
+ useEffect(
+ () =>
+ registerUnsavedChangesGuard({
+ isDirty: () => dirtyRef.current,
+ confirmNavigation: (href) => confirmNavigationRef.current(href),
+ }),
+ [],
+ );
+}
diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/page.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/page.tsx
index 8c71cf593..bcc20920b 100644
--- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/page.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/page.tsx
@@ -11,7 +11,7 @@ import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { CustomWidgetBetaBanner } from "./_beta-banner";
import { WorkshopInstallButton } from "~/components/workshop/workshop-install-button";
-import { ImportCustomWidgetButton } from "./_custom-widget-actions";
+import { ImportCustomWidgetButton } from "./_import-custom-widget-button";
import { CustomWidgetList } from "./_custom-widget-list";
export default async function CustomWidgetsPage() {
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 a3dd83c79..2d5e58d10 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
@@ -16,6 +16,8 @@ import { customWidgetSourceSchema, hasSameCustomWidgetSourceAuthentication } fro
import { and, db, eq, handleTransactionsAsync } from "@homarr/db";
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
+import { readConfigurationRequestBody } from "../body";
+
interface RouteContext {
params: Promise<{ token: string }>;
}
@@ -44,15 +46,18 @@ export async function POST(request: NextRequest, context: RouteContext) {
if (!pending || pending.status !== "pending") {
return NextResponse.json({ error: "This credential request is invalid, completed, or expired." }, { status: 404 });
}
- const body = (await request.json().catch(() => null)) as {
- baseUrl?: unknown;
- networkScope?: unknown;
- secrets?: Record;
- } | null;
+ const parsedBody = await readConfigurationRequestBody(request);
+ if (parsedBody.status === "too-large") {
+ return NextResponse.json({ error: "The configuration payload is too large." }, { status: 413 });
+ }
+ if (parsedBody.status !== "ok") {
+ return NextResponse.json({ error: "Enter a valid source configuration." }, { status: 400 });
+ }
+ const body = parsedBody.data;
const sourceResult = customWidgetSourceSchema.safeParse({
...pending.source,
- baseUrl: body?.baseUrl,
- networkScope: body?.networkScope,
+ baseUrl: body.baseUrl,
+ networkScope: body.networkScope,
});
if (!sourceResult.success) {
return NextResponse.json(
@@ -61,7 +66,7 @@ export async function POST(request: NextRequest, context: RouteContext) {
);
}
const secrets = pending.kinds.flatMap((kind) => {
- const value = body?.secrets?.[kind];
+ const value = body.secrets[kind];
return typeof value === "string" && value.length > 0 && value.length <= 8192
? [{ sourceId: pending.sourceId, kind, value }]
: [];
diff --git a/apps/nextjs/src/app/api/custom-widgets/configuration-request/body.spec.ts b/apps/nextjs/src/app/api/custom-widgets/configuration-request/body.spec.ts
new file mode 100644
index 000000000..a178774ba
--- /dev/null
+++ b/apps/nextjs/src/app/api/custom-widgets/configuration-request/body.spec.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from "vitest";
+
+import { MAX_CONFIGURATION_REQUEST_BODY_BYTES, readConfigurationRequestBody } from "./body";
+
+describe("custom widget configuration request body", () => {
+ it("rejects a streaming payload once it exceeds the byte budget", async () => {
+ const request = new Request("http://localhost/configure", {
+ method: "POST",
+ body: JSON.stringify({
+ baseUrl: "https://example.com",
+ networkScope: "public",
+ secrets: { apiKey: "x".repeat(MAX_CONFIGURATION_REQUEST_BODY_BYTES) },
+ }),
+ });
+
+ await expect(readConfigurationRequestBody(request)).resolves.toEqual({ status: "too-large" });
+ });
+
+ it("strictly parses a bounded configuration object", async () => {
+ const body = { baseUrl: "https://example.com", networkScope: "public", secrets: { apiKey: "secret" } };
+ await expect(
+ readConfigurationRequestBody(
+ new Request("http://localhost/configure", { method: "POST", body: JSON.stringify(body) }),
+ ),
+ ).resolves.toEqual({ status: "ok", data: body });
+
+ await expect(
+ readConfigurationRequestBody(
+ new Request("http://localhost/configure", {
+ method: "POST",
+ body: JSON.stringify({ ...body, unexpected: true }),
+ }),
+ ),
+ ).resolves.toEqual({ status: "invalid" });
+ });
+});
diff --git a/apps/nextjs/src/app/api/custom-widgets/configuration-request/body.ts b/apps/nextjs/src/app/api/custom-widgets/configuration-request/body.ts
new file mode 100644
index 000000000..dd061d1ec
--- /dev/null
+++ b/apps/nextjs/src/app/api/custom-widgets/configuration-request/body.ts
@@ -0,0 +1,52 @@
+import { z } from "zod/v4";
+
+export const MAX_CONFIGURATION_REQUEST_BODY_BYTES = 24 * 1024;
+const configurationRequestBodySchema = z.strictObject({
+ baseUrl: z.string(),
+ networkScope: z.enum(["public", "private", "loopback"]),
+ secrets: z.record(z.string(), z.string()),
+});
+
+export async function readConfigurationRequestBody(
+ request: Request,
+): Promise<
+ { status: "ok"; data: z.infer } | { status: "invalid" | "too-large" }
+> {
+ const contentLength = request.headers.get("content-length");
+ if (contentLength && Number(contentLength) > MAX_CONFIGURATION_REQUEST_BODY_BYTES) {
+ return { status: "too-large" };
+ }
+ if (!request.body) return { status: "invalid" };
+
+ const reader = request.body.getReader();
+ const chunks: Uint8Array[] = [];
+ let byteLength = 0;
+ try {
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ byteLength += value.byteLength;
+ if (byteLength > MAX_CONFIGURATION_REQUEST_BODY_BYTES) {
+ await reader.cancel();
+ return { status: "too-large" };
+ }
+ chunks.push(value);
+ }
+ } catch {
+ return { status: "invalid" };
+ }
+
+ const bytes = new Uint8Array(byteLength);
+ let offset = 0;
+ for (const chunk of chunks) {
+ bytes.set(chunk, offset);
+ offset += chunk.byteLength;
+ }
+ try {
+ const candidate = JSON.parse(new TextDecoder().decode(bytes)) as unknown;
+ const result = configurationRequestBodySchema.safeParse(candidate);
+ return result.success ? { status: "ok", data: result.data } : { status: "invalid" };
+ } catch {
+ return { status: "invalid" };
+ }
+}
diff --git a/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts b/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts
new file mode 100644
index 000000000..4621d0542
--- /dev/null
+++ b/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts
@@ -0,0 +1,196 @@
+// @vitest-environment node
+
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ authenticate: vi.fn(),
+ buildPrompt: vi.fn((request?: string, documentationUrl?: string) =>
+ [`Request: ${request ?? ""}`, `Documentation: ${documentationUrl ?? ""}`].join("\n"),
+ ),
+ getComponent: vi.fn((name: string) => (name === "Text Input" ? { name, safety: "allowed" } : null)),
+}));
+
+vi.mock("next/server", () => ({ userAgent: () => ({ ua: "MCP route test" }) }));
+vi.mock("@homarr/api/mcp", () => ({
+ createTRPCContext: vi.fn(() => ({})),
+ mcpRouter: { createCaller: vi.fn(() => ({})) },
+}));
+vi.mock("@homarr/auth/api-key", () => ({
+ API_KEY_HEADER_NAME: "ApiKey",
+ getSessionFromApiKeyAsync: mocks.authenticate,
+}));
+vi.mock("@homarr/common", () => ({ extractBaseUrlFromHeaders: () => "http://localhost" }));
+vi.mock("@homarr/common/server", () => ({ ipAddressFromHeaders: () => "127.0.0.1" }));
+vi.mock("@homarr/core/infrastructure/logs", () => ({
+ createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
+}));
+vi.mock("@homarr/custom-widgets/authoring-prompt", () => ({ buildCustomWidgetMcpPrompt: mocks.buildPrompt }));
+vi.mock("@homarr/custom-widgets/authoring-resources", () => ({
+ getCustomWidgetComponent: mocks.getComponent,
+ getCustomWidgetComponentCatalog: () => ({ components: [{ name: "Text Input" }] }),
+ getCustomWidgetSkillContent: () => "# Custom Widget Skill",
+}));
+vi.mock("@homarr/custom-widgets/core", () => ({
+ customJsxExamples: [{ id: "status-card", title: "Status card", template: "Status" }],
+ getCustomWidgetJsonSchema: () => ({ type: "object", title: "Custom Widget" }),
+}));
+vi.mock("@homarr/db", () => ({ db: {} }));
+vi.mock("~/versions/package-reader", () => ({ getPackageVersion: () => "test-version" }));
+vi.mock("../_extract-tools", () => ({ extractMcpTools: () => [] }));
+
+import { POST } from "./route";
+
+interface JsonRpcResponse {
+ jsonrpc: "2.0";
+ id: number | null;
+ result?: Record;
+ error?: { code: number; message: string };
+}
+
+beforeEach(() => {
+ mocks.authenticate.mockResolvedValue({ user: { id: "user-1" } });
+ mocks.buildPrompt.mockClear();
+ mocks.getComponent.mockClear();
+});
+
+async function callMcp(method: string, params: Record = {}, id = 1) {
+ const request = new Request("http://localhost/api/mcp/mcp", {
+ method: "POST",
+ headers: {
+ Accept: "application/json, text/event-stream",
+ ApiKey: "key.secret",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ jsonrpc: "2.0", id, method, params }),
+ });
+ const response = await POST(request as never);
+ const text = await response.text();
+ const data = response.headers.get("content-type")?.includes("text/event-stream")
+ ? text
+ .split("\n")
+ .find((line) => line.startsWith("data: "))
+ ?.slice("data: ".length)
+ : text;
+ if (!data) throw new Error(`MCP response did not contain JSON-RPC data: ${text}`);
+ return { response, body: JSON.parse(data) as JsonRpcResponse };
+}
+
+describe("authenticated MCP prompt protocol", () => {
+ test("initializes and advertises prompt and resource capabilities", async () => {
+ const { response, body } = await callMcp("initialize", {
+ protocolVersion: "2025-03-26",
+ capabilities: {},
+ clientInfo: { name: "route-test", version: "1.0.0" },
+ });
+
+ expect(response.status).toBe(200);
+ expect(body.error).toBeUndefined();
+ expect(body.result).toMatchObject({
+ protocolVersion: expect.any(String),
+ capabilities: { prompts: {}, resources: {} },
+ serverInfo: { name: "homarr", version: "test-version" },
+ });
+ expect(mocks.authenticate).toHaveBeenCalledWith({}, "key.secret", "127.0.0.1", "MCP route test");
+ });
+
+ test("lists and interpolates the custom-widget authoring prompt", async () => {
+ const listed = await callMcp("prompts/list");
+ expect(listed.body.result).toEqual({
+ prompts: [
+ {
+ name: "homarr-custom-widget-author",
+ description: "Author and iterate on one Homarr Custom JSX v2 widget.",
+ arguments: [
+ { name: "request", description: "The widget the user wants.", required: false },
+ { name: "documentationUrl", description: "External API documentation URL.", required: false },
+ ],
+ },
+ ],
+ });
+
+ const rendered = await callMcp("prompts/get", {
+ name: "homarr-custom-widget-author",
+ arguments: { request: "Build a status card", documentationUrl: "https://docs.example.test/api" },
+ });
+ expect(rendered.body.result).toMatchObject({
+ description: "Current Homarr Custom Widget authoring workflow.",
+ messages: [
+ {
+ role: "user",
+ content: {
+ type: "text",
+ text: "Request: Build a status card\nDocumentation: https://docs.example.test/api",
+ },
+ },
+ ],
+ });
+ expect(mocks.buildPrompt).toHaveBeenCalledWith("Build a status card", "https://docs.example.test/api");
+ });
+});
+
+describe("authenticated MCP resource protocol", () => {
+ test("lists concrete resources and URI templates with reviewed MIME types", async () => {
+ const listed = await callMcp("resources/list");
+ expect(listed.body.result).toEqual({
+ resources: [
+ { uri: "homarr://custom-widgets/schema", name: "Custom Widget schema", mimeType: "application/schema+json" },
+ { uri: "homarr://custom-widgets/components", name: "Custom Widget components", mimeType: "application/json" },
+ { uri: "homarr://custom-widgets/skill", name: "Custom Widget skill", mimeType: "text/markdown" },
+ {
+ uri: "homarr://custom-widgets/examples/status-card",
+ name: "Custom Widget example: Status card",
+ mimeType: "application/json",
+ },
+ ],
+ });
+
+ const templates = await callMcp("resources/templates/list");
+ expect(templates.body.result).toEqual({
+ resourceTemplates: [
+ expect.objectContaining({
+ uriTemplate: "homarr://custom-widgets/components/{name}",
+ mimeType: "application/json",
+ }),
+ expect.objectContaining({
+ uriTemplate: "homarr://custom-widgets/examples/{name}",
+ mimeType: "application/json",
+ }),
+ ],
+ });
+ });
+
+ test.each([
+ ["homarr://custom-widgets/schema", "application/schema+json", { type: "object", title: "Custom Widget" }],
+ ["homarr://custom-widgets/components", "application/json", { components: [{ name: "Text Input" }] }],
+ ["homarr://custom-widgets/skill", "text/markdown", "# Custom Widget Skill"],
+ [
+ "homarr://custom-widgets/examples/status-card",
+ "application/json",
+ { id: "status-card", title: "Status card", template: "Status" },
+ ],
+ ["homarr://custom-widgets/components/Text%20Input", "application/json", { name: "Text Input", safety: "allowed" }],
+ ])("dispatches %s with its MIME type", async (uri, mimeType, expected) => {
+ const { body } = await callMcp("resources/read", { uri });
+ const contents = body.result?.contents as Array<{ uri: string; mimeType: string; text: string }> | undefined;
+ const content = contents?.[0];
+ expect(content).toMatchObject({ uri, mimeType });
+ expect(mimeType === "text/markdown" ? content?.text : JSON.parse(content?.text ?? "null")).toEqual(expected);
+ });
+});
+
+describe("MCP protocol errors", () => {
+ test.each([
+ ["prompts/get", { name: "unknown" }],
+ ["resources/read", { uri: "homarr://custom-widgets/unknown" }],
+ ])("returns JSON-RPC errors for unknown %s targets", async (method, params) => {
+ const { body } = await callMcp(method, params);
+ expect(body.result).toBeUndefined();
+ expect(body.error).toEqual(expect.objectContaining({ code: expect.any(Number), message: expect.any(String) }));
+ });
+
+ test("returns a protocol error for a malformed resource request", async () => {
+ const { body } = await callMcp("resources/read", {});
+ expect(body.result).toBeUndefined();
+ expect(body.error).toEqual(expect.objectContaining({ code: -32603, message: expect.any(String) }));
+ });
+});
diff --git a/apps/nextjs/src/app/api/mcp/[transport]/route.ts b/apps/nextjs/src/app/api/mcp/[transport]/route.ts
index 0ab7ba73d..acfff14d1 100644
--- a/apps/nextjs/src/app/api/mcp/[transport]/route.ts
+++ b/apps/nextjs/src/app/api/mcp/[transport]/route.ts
@@ -249,8 +249,10 @@ const mcpHandler = createMcpHandler(
const { uri } = request.params;
let text: string;
let mimeType = "application/json";
- if (uri === "homarr://custom-widgets/schema") text = JSON.stringify(getCustomWidgetJsonSchema());
- else if (uri === "homarr://custom-widgets/components") text = JSON.stringify(getCustomWidgetComponentCatalog());
+ if (uri === "homarr://custom-widgets/schema") {
+ text = JSON.stringify(getCustomWidgetJsonSchema());
+ mimeType = "application/schema+json";
+ } else if (uri === "homarr://custom-widgets/components") text = JSON.stringify(getCustomWidgetComponentCatalog());
else if (uri === "homarr://custom-widgets/skill") {
text = getCustomWidgetSkillContent();
mimeType = "text/markdown";
diff --git a/apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx b/apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx
index 417cec9c6..f406a99be 100644
--- a/apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx
+++ b/apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx
@@ -24,6 +24,7 @@ import { useScopedI18n } from "@homarr/translation/client";
interface CustomWidgetImportDialogProps {
opened: boolean;
widget: HomarrCustomWidgetV2 | null;
+ legacyId?: string;
stackId?: string;
zIndex?: number;
onClose(): void;
@@ -33,6 +34,7 @@ interface CustomWidgetImportDialogProps {
export function CustomWidgetImportDialog({
opened,
widget,
+ legacyId,
stackId,
zIndex,
onClose,
@@ -59,19 +61,27 @@ export function CustomWidgetImportDialog({
};
}, [values, widget]);
const review = useMemo(() => getImportReview(configuredWidget), [configuredWidget]);
- const mutation = clientApi.customWidget.import.useMutation({
- onSuccess: (result) => {
- showSuccessNotification({ title: t("action.import"), message: t("notification.imported") });
- void utils.customWidget.list.invalidate();
- void utils.customWidget.available.invalidate();
- void revalidatePathActionAsync("/manage/custom-widgets").then(() => router.refresh());
- onImported?.(result);
- onClose();
- },
- onError: (error) => {
- showErrorNotification({ title: t("action.import"), message: error.message || t("notification.importError") });
- },
- });
+ const onSuccess = (result: { id: string }) => {
+ showSuccessNotification({
+ title: legacyId ? t("action.migrate") : t("action.import"),
+ message: legacyId ? t("notification.migrated") : t("notification.imported"),
+ });
+ void utils.customWidget.list.invalidate();
+ void utils.customWidget.available.invalidate();
+ void utils.widget.customApi.getData.invalidate();
+ void revalidatePathActionAsync("/manage/custom-widgets").then(() => router.refresh());
+ onImported?.(result);
+ onClose();
+ };
+ const onError = (error: { message?: string }) => {
+ showErrorNotification({
+ title: legacyId ? t("action.migrate") : t("action.import"),
+ message: error.message || (legacyId ? t("notification.migrationError") : t("notification.importError")),
+ });
+ };
+ const importMutation = clientApi.customWidget.import.useMutation({ onSuccess, onError });
+ const migrateMutation = clientApi.customWidget.migrateLegacy.useMutation({ onSuccess, onError });
+ const pending = importMutation.isPending || migrateMutation.isPending;
useEffect(() => {
if (opened) setValues(createCustomWidgetSourceSetupValues(setups));
@@ -84,7 +94,8 @@ export function CustomWidgetImportDialog({
secret?.trim() ? [{ sourceId, kind: kind as CustomWidgetSecretKind, value: secret }] : [],
),
);
- mutation.mutate({ widget: configuredWidget, secrets });
+ if (legacyId) migrateMutation.mutate({ id: legacyId, widget: configuredWidget, secrets });
+ else importMutation.mutate({ widget: configuredWidget, secrets });
};
return (
@@ -93,13 +104,13 @@ export function CustomWidgetImportDialog({
stackId={stackId}
zIndex={zIndex}
review={review}
- pending={mutation.isPending}
+ pending={pending}
confirmDisabled={!isCustomWidgetSourceSetupReady(setups, values)}
onClose={onClose}
onConfirm={importWidget}
messages={{
- title: t("importReview.title"),
- description: t("importReview.description"),
+ title: legacyId ? t("importReview.migrationTitle") : t("importReview.title"),
+ description: legacyId ? t("importReview.migrationDescription") : t("importReview.description"),
name: t("importReview.name"),
origin: t("importReview.origin"),
authentication: t("importReview.authentication"),
@@ -109,7 +120,7 @@ export function CustomWidgetImportDialog({
actionWarningTitle: t("importReview.actionWarning.title"),
actionWarningDescription: t("importReview.actionWarning.description"),
cancel: t("importReview.cancel"),
- confirm: t("importReview.confirm"),
+ confirm: legacyId ? t("importReview.confirmMigration") : t("importReview.confirm"),
permission: (permission) => t(`preview.request.permission.${permission}` as never),
}}
>
diff --git a/apps/nextjs/src/components/workshop/workshop-publish-definition.spec.ts b/apps/nextjs/src/components/workshop/workshop-publish-definition.spec.ts
new file mode 100644
index 000000000..34b134d3b
--- /dev/null
+++ b/apps/nextjs/src/components/workshop/workshop-publish-definition.spec.ts
@@ -0,0 +1,41 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+
+import {
+ getPrivateWorkshopSourceNames,
+ serializeWorkshopDefinition,
+ workshopDefinitionChanged,
+} from "./workshop-publish-definition";
+
+const definition = {
+ name: "Status",
+ sources: {
+ public: { name: "Public API", networkScope: "public" },
+ private: { name: "Home server", networkScope: "private" },
+ local: { networkScope: "loopback" },
+ },
+};
+
+describe("Workshop publish definition inspection", () => {
+ it("reports every source URL that requires review", () => {
+ expect(getPrivateWorkshopSourceNames(definition)).toEqual(["Home server", "local"]);
+ });
+
+ it("detects an export that changed after inspection", () => {
+ expect(workshopDefinitionChanged(definition, definition)).toBe(false);
+ expect(workshopDefinitionChanged(definition, { ...definition, name: "Updated" })).toBe(true);
+ });
+
+ it("submits the same refreshed export that passed the change check", () => {
+ const source = readFileSync(
+ `${process.cwd()}/apps/nextjs/src/components/workshop/workshop-publish-modal.tsx`,
+ "utf8",
+ );
+ expect(source).toContain("customWidget.export.useQuery");
+ expect(source).toContain("await definition.refetch()");
+ expect(source).toContain("workshopDefinitionChanged(inspectedDefinition, refreshed.data)");
+ expect(source).toContain("content: serializeWorkshopDefinition(refreshed.data)");
+ expect(source).toMatch(/disabled=\{[\s\S]*!definition\.data[\s\S]*definition\.isError/u);
+ expect(serializeWorkshopDefinition(definition)).toBe(JSON.stringify(definition));
+ });
+});
diff --git a/apps/nextjs/src/components/workshop/workshop-publish-definition.ts b/apps/nextjs/src/components/workshop/workshop-publish-definition.ts
new file mode 100644
index 000000000..583bfff00
--- /dev/null
+++ b/apps/nextjs/src/components/workshop/workshop-publish-definition.ts
@@ -0,0 +1,17 @@
+interface PublishableWorkshopDefinition {
+ sources: Record;
+}
+
+export const serializeWorkshopDefinition = (definition: unknown): string => {
+ const serialized = JSON.stringify(definition);
+ if (serialized === undefined) throw new Error("Workshop definition is not serializable");
+ return serialized;
+};
+
+export const workshopDefinitionChanged = (previous: unknown, current: unknown) =>
+ serializeWorkshopDefinition(previous) !== serializeWorkshopDefinition(current);
+
+export const getPrivateWorkshopSourceNames = (definition: PublishableWorkshopDefinition | undefined) =>
+ Object.entries(definition?.sources ?? {}).flatMap(([sourceId, source]) =>
+ source.networkScope === "public" ? [] : [source.name ?? sourceId],
+ );
diff --git a/apps/nextjs/src/components/workshop/workshop-publish-modal.tsx b/apps/nextjs/src/components/workshop/workshop-publish-modal.tsx
index c5b9a3662..dd612e0b9 100644
--- a/apps/nextjs/src/components/workshop/workshop-publish-modal.tsx
+++ b/apps/nextjs/src/components/workshop/workshop-publish-modal.tsx
@@ -10,6 +10,11 @@ import type { WorkshopUser } from "@homarr/workshop/schema";
import { useScopedI18n } from "@homarr/translation/client";
import { createWorkshopClient } from "./workshop-client";
+import {
+ getPrivateWorkshopSourceNames,
+ serializeWorkshopDefinition,
+ workshopDefinitionChanged,
+} from "./workshop-publish-definition";
export function WorkshopPublishModal({
opened,
@@ -22,7 +27,6 @@ export function WorkshopPublishModal({
}) {
const t = useScopedI18n("workshop");
const client = useMemo(createWorkshopClient, []);
- const utils = clientApi.useUtils();
const [user, setUser] = useState(null);
const [title, setTitle] = useState(widget.name);
const [description, setDescription] = useState("");
@@ -31,10 +35,9 @@ export function WorkshopPublishModal({
const [error, setError] = useState(null);
const [published, setPublished] = useState(false);
const [sourceUrlsReviewed, setSourceUrlsReviewed] = useState(false);
- const definition = clientApi.customWidget.get.useQuery({ id: widget.id }, { enabled: opened });
- const privateSources = Object.entries(definition.data?.sources ?? {}).filter(
- ([, source]) => source.networkScope !== "public",
- );
+ const definition = clientApi.customWidget.export.useQuery({ id: widget.id }, { enabled: opened });
+ const privateSourceNames = getPrivateWorkshopSourceNames(definition.data);
+ const definitionFingerprint = definition.data ? serializeWorkshopDefinition(definition.data) : null;
useEffect(() => {
const unsubscribe = client.subscribeToAuth(setUser);
@@ -50,13 +53,27 @@ export function WorkshopPublishModal({
setPublished(false);
setSourceUrlsReviewed(false);
}, [opened, widget.name]);
+ useEffect(() => setSourceUrlsReviewed(false), [definitionFingerprint]);
const publish = async () => {
setError(null);
try {
- const definition = await utils.customWidget.export.fetch({ id: widget.id });
+ if (!definition.data) throw new Error(t("publish.error"));
+ const inspectedDefinition = definition.data;
+ const refreshed = await definition.refetch();
+ if (!refreshed.data) throw new Error(t("publish.error"));
+ if (workshopDefinitionChanged(inspectedDefinition, refreshed.data)) {
+ setSourceUrlsReviewed(false);
+ setError(t("publish.error"));
+ return;
+ }
await createSubmission.mutateAsync({
- input: { type: "customWidget", title, description, content: JSON.stringify(definition) },
+ input: {
+ type: "customWidget",
+ title,
+ description,
+ content: serializeWorkshopDefinition(refreshed.data),
+ },
screenshots,
});
setPublished(true);
@@ -113,10 +130,10 @@ export function WorkshopPublishModal({
value={screenshots}
onChange={setScreenshots}
/>
- {privateSources.length > 0 && (
+ {privateSourceNames.length > 0 && (
{t("publish.privateSourceWarning", {
- sources: privateSources.map(([sourceId, source]) => source.name ?? sourceId).join(", "),
+ sources: privateSourceNames.join(", "),
})}
)}
+ {definition.isError && {t("publish.error")}}
{error && {error}}
0 && !sourceUrlsReviewed)}
+ loading={createSubmission.isPending || definition.isFetching}
+ disabled={
+ !user ||
+ !definition.data ||
+ definition.isError ||
+ title.trim().length < 3 ||
+ (privateSourceNames.length > 0 && !sourceUrlsReviewed)
+ }
onClick={() => void publish()}
>
{t("publish.action")}
diff --git a/apps/workshop/README.md b/apps/workshop/README.md
index 5414f521c..c939575e2 100644
--- a/apps/workshop/README.md
+++ b/apps/workshop/README.md
@@ -43,6 +43,9 @@ and SMTP transport in PocketBase. PocketBase emails the submission author about
Workshop administrator about a new report, and emails the author after an administrator deletes their submission. Mail
failures are logged and never reject a comment, report, or deletion.
+GitHub OAuth settings are synchronized from the environment on every PocketBase startup. Restart the Workshop after
+rotating either credential; removing one or both values disables the provider instead of retaining stale credentials.
+
Run the disposable service integration test from the repository root:
```sh
@@ -52,8 +55,9 @@ pnpm test:workshop
The test uses a separate Compose project and deletes its named volume on exit. It never writes fixture users or admin
roles into the normal `homarr-workshop_pb_data` volume.
-The Workshop does not maintain a second Custom Widget schema. Its frontend validates submissions with
-`customWidgetImportSchema` from `@homarr/custom-widgets`, and Homarr validates canonical content again before installation.
+The Workshop frontend validates submissions with `customWidgetImportSchema` from `@homarr/custom-widgets`, and Homarr
+validates canonical content again before installation. PocketBase independently enforces publication invariants for
+direct API requests, owns schema/revision fields, and rejects stale updates using the submitted expected revision.
The validated `$schema` is also stored as `widgetSchema` for listing compatibility badges; it must match the downloaded
manifest before Homarr enables installation.
PocketBase API rules enforce submission and comment ownership, one vote/report per user and submission, and
diff --git a/apps/workshop/pb_hooks/workshop-utils.js b/apps/workshop/pb_hooks/workshop-utils.js
new file mode 100644
index 000000000..849415efe
--- /dev/null
+++ b/apps/workshop/pb_hooks/workshop-utils.js
@@ -0,0 +1,93 @@
+const CUSTOM_WIDGET_SCHEMA = "homarr-custom-widget-v2";
+const CUSTOM_CSS_SCHEMA = "homarr-custom-css-v1";
+const MAX_CSS_LENGTH = 16_384;
+const MAX_CONTENT_LENGTH = 1_000_000;
+
+const rejectRequest = (message) => {
+ throw new BadRequestError(message);
+};
+
+const requestBodyValue = (event, name) => {
+ const body = event.requestInfo().body;
+ return body && typeof body.get === "function" ? body.get(name) : body ? body[name] : undefined;
+};
+
+const validateWidgetManifest = (content) => {
+ let widget;
+ try {
+ widget = JSON.parse(content);
+ } catch {
+ rejectRequest("Widget content must be valid JSON");
+ }
+ if (!widget || Array.isArray(widget) || typeof widget !== "object") rejectRequest("Widget must be a JSON object");
+ if (widget.$schema !== CUSTOM_WIDGET_SCHEMA) rejectRequest("Widget schema is not supported");
+ if (typeof widget.name !== "string" || !widget.name.trim() || widget.name.length > 100)
+ rejectRequest("Widget name is invalid");
+ if (!widget.sources || Array.isArray(widget.sources) || typeof widget.sources !== "object" || !widget.sources.default)
+ rejectRequest("Widget must define a default source");
+ if (Object.keys(widget.sources).length > 8) rejectRequest("Widget has too many sources");
+ for (const source of Object.values(widget.sources)) {
+ if (!source || Array.isArray(source) || typeof source !== "object") rejectRequest("Widget source is invalid");
+ if (typeof source.baseUrl !== "string" || !/^https?:\/\//.test(source.baseUrl))
+ rejectRequest("Widget source URL is invalid");
+ if (!["public", "private", "loopback"].includes(source.networkScope))
+ rejectRequest("Widget source network scope is invalid");
+ }
+ if (!widget.requests || Array.isArray(widget.requests) || typeof widget.requests !== "object")
+ rejectRequest("Widget requests are invalid");
+ if (!widget.options || Array.isArray(widget.options) || typeof widget.options !== "object")
+ rejectRequest("Widget options are invalid");
+ if (typeof widget.template !== "string" || !widget.template.trim()) rejectRequest("Widget template is invalid");
+ return JSON.stringify(widget);
+};
+
+const validateAndNormalizeSubmission = (record) => {
+ const type = record.getString("type");
+ const content = record.getString("content");
+ if (!content || content.length > MAX_CONTENT_LENGTH) rejectRequest("Submission content is invalid");
+ record.set("title", record.getString("title").trim());
+ record.set("description", record.getString("description").trim());
+ record.set("changelog", record.getString("changelog").trim());
+ if (type === "customCss") {
+ if (!content.trim() || content.length > MAX_CSS_LENGTH) rejectRequest("Custom CSS is empty or too large");
+ record.set("widgetSchema", CUSTOM_CSS_SCHEMA);
+ return;
+ }
+ if (type !== "customWidget") rejectRequest("Submission type is invalid");
+ record.set("content", validateWidgetManifest(content));
+ record.set("widgetSchema", CUSTOM_WIDGET_SCHEMA);
+};
+
+const escapeHtml = (value) =>
+ String(value)
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+
+const emailTemplate = (filename) => {
+ const hooksPath = $os.getenv("WORKSHOP_PB_HOOKS_DIR") || __hooks;
+ return $os.readFile($filepath.join(hooksPath, filename));
+};
+
+const sendEmail = (app, recipientEmail, subject, text, html) => {
+ const sender = app.settings().meta;
+ app.newMailClient().send(
+ new MailerMessage({
+ from: { address: sender.senderAddress, name: sender.senderName },
+ to: [{ address: recipientEmail }],
+ subject,
+ text,
+ html,
+ }),
+ );
+};
+
+module.exports = {
+ emailTemplate,
+ escapeHtml,
+ requestBodyValue,
+ sendEmail,
+ validateAndNormalizeSubmission,
+};
diff --git a/apps/workshop/pb_hooks/workshop.pb.js b/apps/workshop/pb_hooks/workshop.pb.js
index 11d975faa..64abb2821 100644
--- a/apps/workshop/pb_hooks/workshop.pb.js
+++ b/apps/workshop/pb_hooks/workshop.pb.js
@@ -1,5 +1,43 @@
///
+onBootstrap((event) => {
+ event.next();
+ const users = event.app.findCollectionByNameOrId("users");
+ const clientId = String($os.getenv("GITHUB_CLIENT_ID") || "").trim();
+ const clientSecret = String($os.getenv("GITHUB_CLIENT_SECRET") || "").trim();
+ const configured = Boolean(clientId && clientSecret);
+ users.oauth2.enabled = configured;
+ users.oauth2.providers = configured ? [{ name: "github", clientId, clientSecret }] : [];
+ event.app.save(users);
+});
+
+onRecordCreateRequest((event) => {
+ const { validateAndNormalizeSubmission } = require(`${__hooks}/workshop-utils.js`);
+ validateAndNormalizeSubmission(event.record);
+ event.record.set("revision", 1);
+ event.record.set("changelog", "");
+ event.record.set("outdated", false);
+ event.next();
+}, "submissions");
+
+onRecordUpdateRequest((event) => {
+ const { requestBodyValue, validateAndNormalizeSubmission } = require(`${__hooks}/workshop-utils.js`);
+ const original = event.record.original();
+ const currentRevision = original.getInt("revision");
+ const expectedRevision = Number(requestBodyValue(event, "expectedRevision"));
+ if (!Number.isSafeInteger(expectedRevision) || expectedRevision !== currentRevision) {
+ rejectRequest("Submission changed since it was read");
+ }
+ validateAndNormalizeSubmission(event.record);
+ event.record.set("revision", currentRevision + 1);
+ event.next();
+}, "submissions");
+
+onRecordCreateRequest((event) => {
+ event.record.set("status", "open");
+ event.next();
+}, "reports");
+
onRecordAfterCreateSuccess((event) => {
try {
const votes = event.app.findCollectionByNameOrId("votes");
@@ -14,34 +52,9 @@ onRecordAfterCreateSuccess((event) => {
event.next();
}, "submissions");
-const escapeHtml = (value) =>
- String(value)
- .replaceAll("&", "&")
- .replaceAll("<", "<")
- .replaceAll(">", ">")
- .replaceAll('"', """)
- .replaceAll("'", "'");
-
-const emailTemplate = (filename) => {
- const hooksPath = $os.getenv("WORKSHOP_PB_HOOKS_DIR") || $filepath.dirname(__filepath);
- return $os.readFile($filepath.join(hooksPath, filename));
-};
-
-const sendEmail = (app, recipientEmail, subject, text, html) => {
- const sender = app.settings().meta;
- app.newMailClient().send(
- new MailerMessage({
- from: { address: sender.senderAddress, name: sender.senderName },
- to: [{ address: recipientEmail }],
- subject,
- text,
- html,
- }),
- );
-};
-
onRecordAfterCreateSuccess((event) => {
try {
+ const { emailTemplate, escapeHtml, sendEmail } = require(`${__hooks}/workshop-utils.js`);
const submission = event.app.findRecordById("submissions", event.record.get("submission"));
const recipient = event.app.findRecordById("users", submission.get("author"));
const commenter = event.app.findRecordById("users", event.record.get("author"));
@@ -78,10 +91,11 @@ onRecordAfterCreateSuccess((event) => {
onRecordAfterCreateSuccess((event) => {
try {
+ const { emailTemplate, escapeHtml, sendEmail } = require(`${__hooks}/workshop-utils.js`);
const submission = event.app.findRecordById("submissions", event.record.get("submission"));
const reporter = event.app.findRecordById("users", event.record.get("reporter"));
const publicOrigin = $os.getenv("WORKSHOP_PUBLIC_ORIGIN").replace(/\/$/, "");
- const admins = event.app.findAllRecords("users").filter((user) => user.getBool("isAdmin") && Boolean(user.email()));
+ const admins = event.app.findRecordsByFilter("users", "isAdmin = true && email != ''", "", 100, 0);
if (!publicOrigin || admins.length === 0) {
event.next();
@@ -122,6 +136,7 @@ onRecordAfterCreateSuccess((event) => {
}, "reports");
onRecordDeleteRequest((event) => {
+ const { emailTemplate, escapeHtml, sendEmail } = require(`${__hooks}/workshop-utils.js`);
const submissionId = event.record.id;
const submissionTitle = event.record.getString("title");
const authorId = event.record.get("author");
diff --git a/apps/workshop/pb_migrations/1784240000_workshop_widgets.js b/apps/workshop/pb_migrations/1784240000_workshop_widgets.js
index 767948b5a..31e379c05 100644
--- a/apps/workshop/pb_migrations/1784240000_workshop_widgets.js
+++ b/apps/workshop/pb_migrations/1784240000_workshop_widgets.js
@@ -1,38 +1,66 @@
///
+const cloneJson = (value) => JSON.parse(JSON.stringify(value));
+
migrate(
(app) => {
+ let usersExisted = true;
let users;
try {
users = app.findCollectionByNameOrId("users");
} catch {
+ usersExisted = false;
users = new Collection({ type: "auth", name: "users" });
}
- users.passwordAuth = { enabled: false };
- if (!users.fields.getByName("displayName"))
- users.fields.add(new TextField({ name: "displayName", required: true, min: 1, max: 100 }));
- if (!users.fields.getByName("avatarUrl")) users.fields.add(new URLField({ name: "avatarUrl" }));
- if (!users.fields.getByName("avatar"))
- users.fields.add(
- new FileField({
- name: "avatar",
- maxSelect: 1,
- maxSize: 2_097_152,
- mimeTypes: ["image/png", "image/jpeg", "image/webp"],
- }),
- );
- if (!users.fields.getByName("githubUsername"))
- users.fields.add(new TextField({ name: "githubUsername", max: 100 }));
- if (!users.fields.getByName("githubProfileUrl")) users.fields.add(new URLField({ name: "githubProfileUrl" }));
- if (!users.fields.getByName("isAdmin")) users.fields.add(new BoolField({ name: "isAdmin" }));
+ const settings = app.settings();
+ const stateCollection = new Collection({
+ type: "base",
+ name: "workshop_migration_state",
+ listRule: null,
+ viewRule: null,
+ createRule: null,
+ updateRule: null,
+ deleteRule: null,
+ fields: [{ type: "text", name: "snapshot", required: true, max: 1_000_000 }],
+ });
+ app.save(stateCollection);
+ const state = {
+ usersExisted,
+ users: usersExisted
+ ? {
+ passwordAuth: cloneJson(users.passwordAuth),
+ oauth2: cloneJson(users.oauth2),
+ listRule: users.listRule,
+ viewRule: users.viewRule,
+ createRule: users.createRule,
+ updateRule: users.updateRule,
+ deleteRule: users.deleteRule,
+ }
+ : null,
+ addedUserFields: [],
+ rateLimits: cloneJson(settings.rateLimits),
+ };
+ const addUserField = (field) => {
+ if (users.fields.getByName(field.name)) return;
+ users.fields.add(field);
+ state.addedUserFields.push(field.name);
+ };
- const githubClientId = $os.getenv("GITHUB_CLIENT_ID");
- const githubClientSecret = $os.getenv("GITHUB_CLIENT_SECRET");
- if (githubClientId && githubClientSecret) {
- users.oauth2.enabled = true;
- users.oauth2.providers = [{ name: "github", clientId: githubClientId, clientSecret: githubClientSecret }];
- }
+ users.passwordAuth = { enabled: false };
+ addUserField(new TextField({ name: "displayName", required: true, min: 1, max: 100 }));
+ addUserField(new URLField({ name: "avatarUrl" }));
+ addUserField(
+ new FileField({
+ name: "avatar",
+ maxSelect: 1,
+ maxSize: 2_097_152,
+ mimeTypes: ["image/png", "image/jpeg", "image/webp"],
+ }),
+ );
+ addUserField(new TextField({ name: "githubUsername", max: 100 }));
+ addUserField(new URLField({ name: "githubProfileUrl" }));
+ addUserField(new BoolField({ name: "isAdmin" }));
const adminRule = "@request.auth.isAdmin = true";
users.listRule = "";
@@ -41,6 +69,9 @@ migrate(
users.updateRule = "id = @request.auth.id && @request.body.isAdmin:isset = false";
users.deleteRule = null;
app.save(users);
+ const stateRecord = new Record(stateCollection);
+ stateRecord.set("snapshot", JSON.stringify(state));
+ app.save(stateRecord);
const submissions = new Collection({
type: "base",
@@ -75,6 +106,18 @@ migrate(
submissions.addIndex("idx_submissions_author", false, "author", "");
submissions.addIndex("idx_submissions_created", false, "created", "");
app.save(submissions);
+ app
+ .db()
+ .newQuery(
+ `CREATE TRIGGER submissions_revision_cas
+ BEFORE UPDATE ON submissions
+ FOR EACH ROW
+ WHEN NEW.revision != OLD.revision + 1
+ BEGIN
+ SELECT RAISE(ABORT, 'submission revision must increment exactly once');
+ END`,
+ )
+ .execute();
const votes = new Collection({
type: "base",
@@ -195,7 +238,6 @@ migrate(
});
app.save(listings);
- const settings = app.settings();
settings.rateLimits.enabled = true;
settings.rateLimits.rules = [
...settings.rateLimits.rules.filter(
@@ -209,10 +251,43 @@ migrate(
app.save(settings);
},
(app) => {
+ let stateCollection;
+ let stateRecord;
+ try {
+ stateCollection = app.findCollectionByNameOrId("workshop_migration_state");
+ stateRecord = app.findFirstRecordByFilter(stateCollection.id, "");
+ } catch {
+ throw new Error("Workshop migration cannot be rolled back safely because its state snapshot is missing");
+ }
+ const state = JSON.parse(stateRecord.getString("snapshot"));
for (const name of ["workshop_listings", "reports", "comments", "votes", "submissions"]) {
try {
app.delete(app.findCollectionByNameOrId(name));
} catch {}
}
+
+ const settings = app.settings();
+ settings.rateLimits.enabled = state.rateLimits.enabled;
+ settings.rateLimits.rules = state.rateLimits.rules;
+ app.save(settings);
+
+ const users = app.findCollectionByNameOrId("users");
+ if (state.usersExisted) {
+ users.passwordAuth = state.users.passwordAuth;
+ users.oauth2 = state.users.oauth2;
+ users.listRule = state.users.listRule;
+ users.viewRule = state.users.viewRule;
+ users.createRule = state.users.createRule;
+ users.updateRule = state.users.updateRule;
+ users.deleteRule = state.users.deleteRule;
+ for (const name of state.addedUserFields) {
+ const field = users.fields.getByName(name);
+ if (field) users.fields.removeById(field.id);
+ }
+ app.save(users);
+ } else {
+ app.delete(users);
+ }
+ app.delete(stateCollection);
},
);
diff --git a/apps/workshop/test.sh b/apps/workshop/test.sh
index 901324b54..f2ab27a1a 100644
--- a/apps/workshop/test.sh
+++ b/apps/workshop/test.sh
@@ -4,6 +4,9 @@ set -eu
WORKSHOP_TEST_PORT=${WORKSHOP_TEST_PORT:-18090}
WORKSHOP_TEST_PROJECT="homarr-workshop-test-$$"
+node apps/workshop/tests/workshop-contracts.mjs
+node apps/workshop/tests/workshop-migration.mjs
+
cleanup() {
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
-f apps/workshop/docker-compose.yml down --volumes --remove-orphans >/dev/null 2>&1 || true
@@ -30,3 +33,28 @@ docker compose -p "$WORKSHOP_TEST_PROJECT" -f apps/workshop/docker-compose.yml e
workshop-test@example.invalid 'WorkshopLocalTest123!' --dir=/pb_data
WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_TEST_PORT" node apps/workshop/tests/workshop.integration.mjs
+
+for client_id in workshop-test-client workshop-rotated-client; do
+ GITHUB_CLIENT_ID="$client_id" GITHUB_CLIENT_SECRET="workshop-test-secret" PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" \
+ docker compose -p "$WORKSHOP_TEST_PROJECT" -f apps/workshop/docker-compose.yml up -d --force-recreate workshop
+ for attempt in $(seq 1 60); do
+ if curl --fail --silent "http://127.0.0.1:$WORKSHOP_TEST_PORT/api/health" >/dev/null; then
+ break
+ fi
+ if [ "$attempt" -eq 60 ]; then
+ exit 1
+ fi
+ sleep 1
+ done
+ WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_TEST_PORT" EXPECTED_GITHUB_CLIENT_ID="$client_id" \
+ node apps/workshop/tests/oauth-sync.integration.mjs
+done
+
+PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
+ -f apps/workshop/docker-compose.yml stop workshop
+printf 'y\n' | PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
+ -f apps/workshop/docker-compose.yml run --rm --no-deps --entrypoint pocketbase workshop \
+ migrate down 1 --dir=/pb_data --migrationsDir=/pb_migrations
+PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
+ -f apps/workshop/docker-compose.yml run --rm --no-deps --entrypoint pocketbase workshop \
+ migrate up --dir=/pb_data --migrationsDir=/pb_migrations
diff --git a/apps/workshop/tests/oauth-sync.integration.mjs b/apps/workshop/tests/oauth-sync.integration.mjs
new file mode 100644
index 000000000..7fb2c1f35
--- /dev/null
+++ b/apps/workshop/tests/oauth-sync.integration.mjs
@@ -0,0 +1,27 @@
+const baseUrl = process.env.WORKSHOP_TEST_URL ?? "http://127.0.0.1:18090";
+const expectedClientId = process.env.EXPECTED_GITHUB_CLIENT_ID;
+if (!expectedClientId) throw new Error("EXPECTED_GITHUB_CLIENT_ID is required");
+
+const request = async (path, init = {}) => {
+ const response = await fetch(`${baseUrl}${path}`, {
+ ...init,
+ headers: { "content-type": "application/json", ...init.headers },
+ });
+ const body = await response.json().catch(() => null);
+ if (!response.ok) throw new Error(`${response.status} ${path}: ${JSON.stringify(body)}`);
+ return body;
+};
+
+const root = await request("/api/collections/_superusers/auth-with-password", {
+ method: "POST",
+ body: JSON.stringify({ identity: "workshop-test@example.invalid", password: "WorkshopLocalTest123!" }),
+});
+const users = await request("/api/collections/users", {
+ headers: { authorization: `Bearer ${root.token}` },
+});
+const github = users.oauth2?.providers?.find((provider) => provider.name === "github");
+if (!users.oauth2?.enabled || github?.clientId !== expectedClientId) {
+ throw new Error(`GitHub OAuth was not synchronized for ${expectedClientId}`);
+}
+
+console.log(`Workshop OAuth synchronization passed for ${expectedClientId}`);
diff --git a/apps/workshop/tests/workshop-contracts.mjs b/apps/workshop/tests/workshop-contracts.mjs
new file mode 100644
index 000000000..2ecd77426
--- /dev/null
+++ b/apps/workshop/tests/workshop-contracts.mjs
@@ -0,0 +1,38 @@
+import { access, readFile } from "node:fs/promises";
+import { resolve } from "node:path";
+
+const read = (path) => readFile(resolve(path), "utf8");
+
+const packageJson = JSON.parse(await read("packages/workshop/package.json"));
+const clientExport = packageJson.exports?.["./client"];
+if (typeof clientExport !== "string") throw new Error("Workshop client export is missing");
+await access(resolve("packages/workshop", clientExport));
+
+const hook = await read("apps/workshop/pb_hooks/workshop.pb.js");
+await access(resolve("apps/workshop/pb_hooks/workshop-utils.js"));
+if (!hook.includes("require(`${__hooks}/workshop-utils.js`)")) {
+ throw new Error("Workshop handlers must load shared helpers inside their isolated PocketBase contexts");
+}
+if (!hook.includes("onBootstrap") || !hook.includes("users.oauth2.providers = configured")) {
+ throw new Error("Workshop OAuth settings must be synchronized at every bootstrap");
+}
+if (hook.includes('findAllRecords("users")') || !hook.includes('findRecordsByFilter("users"')) {
+ throw new Error("Workshop report notifications must use a filtered, bounded administrator query");
+}
+
+const migration = await read("apps/workshop/pb_migrations/1784240000_workshop_widgets.js");
+for (const required of [
+ "workshop_migration_state",
+ "addedUserFields",
+ "CREATE TRIGGER submissions_revision_cas",
+ "state.rateLimits.enabled",
+ "users.passwordAuth = state.users.passwordAuth",
+ "users.oauth2 = state.users.oauth2",
+]) {
+ if (!migration.includes(required)) throw new Error(`Workshop rollback is missing state restoration: ${required}`);
+}
+
+const workflow = await read(".github/workflows/workshop.yml");
+if (/\n\s+paths:/u.test(workflow)) throw new Error("Workshop workflow must not filter out Docker build inputs");
+
+console.log("Workshop static contracts passed");
diff --git a/apps/workshop/tests/workshop-migration.mjs b/apps/workshop/tests/workshop-migration.mjs
new file mode 100644
index 000000000..424eb640a
--- /dev/null
+++ b/apps/workshop/tests/workshop-migration.mjs
@@ -0,0 +1,148 @@
+import { readFile } from "node:fs/promises";
+import vm from "node:vm";
+
+// oxlint-disable-next-line typescript/no-extraneous-class -- Mirrors the PocketBase Field constructor API.
+class Field {
+ constructor(options) {
+ Object.assign(this, options);
+ this.id = `field-${options.name}`;
+ }
+}
+
+class Fields {
+ constructor(fields = []) {
+ this.values = fields.map((field) => (field instanceof Field ? field : new Field(field)));
+ }
+ getByName(name) {
+ return this.values.find((field) => field.name === name);
+ }
+ add(field) {
+ this.values.push(field);
+ }
+ removeById(id) {
+ this.values = this.values.filter((field) => field.id !== id);
+ }
+}
+
+class Collection {
+ constructor(options) {
+ Object.assign(this, options);
+ this.id = options.id ?? options.name;
+ this.fields = new Fields(options.fields);
+ }
+ addIndex() {}
+}
+
+let recordId = 0;
+class Record {
+ constructor(collection) {
+ this.collection = collection;
+ this.id = `record-${++recordId}`;
+ this.data = new Map();
+ }
+ set(name, value) {
+ this.data.set(name, value);
+ }
+ getString(name) {
+ return String(this.data.get(name) ?? "");
+ }
+}
+
+const original = {
+ passwordAuth: { enabled: true, identityFields: ["email"] },
+ oauth2: { enabled: true, providers: [{ name: "original", clientId: "before" }] },
+ rules: {
+ listRule: "original-list",
+ viewRule: "original-view",
+ createRule: "original-create",
+ updateRule: "original-update",
+ deleteRule: "original-delete",
+ },
+ rateLimits: { enabled: false, rules: [{ label: "original", audience: "", duration: 1, maxRequests: 1 }] },
+};
+const users = new Collection({ type: "auth", name: "users", fields: [{ type: "text", name: "existing" }] });
+users.passwordAuth = structuredClone(original.passwordAuth);
+users.oauth2 = structuredClone(original.oauth2);
+Object.assign(users, original.rules);
+const settings = { rateLimits: structuredClone(original.rateLimits) };
+const collections = new Map([[users.id, users]]);
+const records = new Map();
+const rawQueries = [];
+const app = {
+ db() {
+ return {
+ newQuery(sql) {
+ return {
+ execute() {
+ rawQueries.push(sql);
+ },
+ };
+ },
+ };
+ },
+ findCollectionByNameOrId(name) {
+ const collection = collections.get(name);
+ if (!collection) throw new Error(`Missing collection ${name}`);
+ return collection;
+ },
+ findFirstRecordByFilter(collectionId) {
+ const record = records.get(collectionId)?.[0];
+ if (!record) throw new Error(`Missing state record ${collectionId}`);
+ return record;
+ },
+ save(value) {
+ if (value instanceof Collection) collections.set(value.id, value);
+ else if (value instanceof Record)
+ records.set(value.collection.id, [...(records.get(value.collection.id) ?? []), value]);
+ },
+ delete(collection) {
+ collections.delete(collection.id);
+ records.delete(collection.id);
+ },
+ settings() {
+ return settings;
+ },
+};
+
+let migration;
+const sandbox = {
+ Collection,
+ Record,
+ TextField: Field,
+ URLField: Field,
+ FileField: Field,
+ BoolField: Field,
+ console,
+ migrate: (up, down) => {
+ migration = { up, down };
+ },
+};
+const source = await readFile("apps/workshop/pb_migrations/1784240000_workshop_widgets.js", "utf8");
+vm.runInNewContext(source, sandbox);
+if (!migration) throw new Error("Workshop migration did not register");
+
+migration.up(app);
+if (!collections.has("workshop_migration_state") || settings.rateLimits.enabled !== true) {
+ throw new Error("Workshop migration did not capture state before applying changes");
+}
+if (!rawQueries.some((query) => query.includes("CREATE TRIGGER submissions_revision_cas"))) {
+ throw new Error("Workshop migration did not install the atomic submission revision guard");
+}
+migration.down(app);
+
+if (JSON.stringify(users.passwordAuth) !== JSON.stringify(original.passwordAuth))
+ throw new Error("Password authentication was not restored");
+if (JSON.stringify(users.oauth2) !== JSON.stringify(original.oauth2))
+ throw new Error("OAuth settings were not restored");
+for (const [name, value] of Object.entries(original.rules)) {
+ if (users[name] !== value) throw new Error(`User rule ${name} was not restored`);
+}
+if (users.fields.values.map((field) => field.name).join(",") !== "existing")
+ throw new Error("Workshop user fields were not removed on rollback");
+if (JSON.stringify(settings.rateLimits) !== JSON.stringify(original.rateLimits))
+ throw new Error("Rate limits were not restored");
+for (const name of ["workshop_migration_state", "workshop_listings", "reports", "comments", "votes", "submissions"]) {
+ if (collections.has(name)) throw new Error(`Rollback left collection ${name}`);
+}
+
+console.log("Workshop migration up/down restoration passed");
diff --git a/apps/workshop/tests/workshop.integration.mjs b/apps/workshop/tests/workshop.integration.mjs
index 216f7d597..2bb173552 100644
--- a/apps/workshop/tests/workshop.integration.mjs
+++ b/apps/workshop/tests/workshop.integration.mjs
@@ -105,21 +105,54 @@ const submission = await request("/api/collections/submissions/records", {
type: "customWidget",
title: "Workshop runtime probe",
description: "PocketBase integration test",
- widgetSchema: widget.$schema,
+ widgetSchema: "homarr-custom-css-v1",
content: JSON.stringify(widget),
author: author.id,
- revision: 1,
- changelog: "Initial publication",
- outdated: false,
+ revision: 999,
+ changelog: "Forged publication metadata",
+ outdated: true,
}),
});
if (
submission.author !== author.id ||
submission.title !== "Workshop runtime probe" ||
- submission.widgetSchema !== widget.$schema
+ submission.widgetSchema !== widget.$schema ||
+ submission.revision !== 1 ||
+ submission.changelog !== "" ||
+ submission.outdated !== false
) {
- throw new Error("Submission publication failed");
+ throw new Error("Submission publication was not validated and normalized server-side");
}
+await expectStatus(
+ "/api/collections/submissions/records",
+ {
+ method: "POST",
+ headers: visitorSession.headers,
+ body: JSON.stringify({
+ type: "customCss",
+ title: "Oversized stylesheet",
+ content: "x".repeat(16_385),
+ widgetSchema: "homarr-custom-widget-v2",
+ author: visitor.id,
+ }),
+ },
+ 400,
+);
+await expectStatus(
+ "/api/collections/submissions/records",
+ {
+ method: "POST",
+ headers: visitorSession.headers,
+ body: JSON.stringify({
+ type: "customWidget",
+ title: "Invalid widget payload",
+ content: ".dashboard { color: red; }",
+ widgetSchema: "homarr-custom-widget-v2",
+ author: visitor.id,
+ }),
+ },
+ 400,
+);
const ownerVotes = await request("/api/collections/votes/records", { headers: authorSession.headers });
if (
ownerVotes.items.length !== 1 ||
@@ -132,17 +165,33 @@ if (
const updatedSubmission = await request(`/api/collections/submissions/records/${submission.id}`, {
method: "PATCH",
headers: authorSession.headers,
- body: JSON.stringify({ title: "Updated runtime probe" }),
+ body: JSON.stringify({ title: "Updated runtime probe", expectedRevision: 1, revision: 500 }),
});
-if (updatedSubmission.title !== "Updated runtime probe")
+if (updatedSubmission.title !== "Updated runtime probe" || updatedSubmission.revision !== 2)
throw new Error("Authors must be able to edit their submission");
const outdatedSubmission = await request(`/api/collections/submissions/records/${submission.id}`, {
method: "PATCH",
headers: authorSession.headers,
- body: JSON.stringify({ outdated: true, revision: 2, changelog: "Needs a newer API" }),
+ body: JSON.stringify({ outdated: true, expectedRevision: 2, revision: 900, changelog: "Needs a newer API" }),
});
-if (!outdatedSubmission.outdated || outdatedSubmission.revision !== 2) {
- throw new Error("Authors must be able to mark submissions outdated and increment revisions");
+if (!outdatedSubmission.outdated || outdatedSubmission.revision !== 3) {
+ throw new Error("PocketBase must own submission revision increments");
+}
+
+for (let attempt = 0; attempt < 5; attempt += 1) {
+ const expectedRevision = 3 + attempt;
+ const concurrentUpdates = await Promise.all(
+ ["Concurrent first", "Concurrent second"].map((title) =>
+ fetch(`${baseUrl}/api/collections/submissions/records/${submission.id}`, {
+ method: "PATCH",
+ headers: { "content-type": "application/json", ...authorSession.headers },
+ body: JSON.stringify({ title: `${title} ${attempt}`, expectedRevision }),
+ }),
+ ),
+ );
+ if (concurrentUpdates.filter((response) => response.ok).length !== 1) {
+ throw new Error("Exactly one update may consume an expected submission revision");
+ }
}
await expectStatus(
@@ -240,7 +289,7 @@ const report = await request("/api/collections/reports/records", {
reporter: visitor.id,
category: "other",
explanation: "Runtime moderation test",
- status: "open",
+ status: "dismissed",
}),
});
await expectStatus(`/api/collections/reports/records/${report.id}`, { headers: visitorSession.headers }, 404);
@@ -267,7 +316,7 @@ await expectStatus(
);
const reports = await request("/api/collections/reports/records", { headers: authorSession.headers });
-if (reports.items.length !== 1 || reports.items[0].reporter !== visitor.id) {
+if (reports.items.length !== 1 || reports.items[0].reporter !== visitor.id || reports.items[0].status !== "open") {
throw new Error("Workshop administrators must be able to review reports");
}
const dismissedReport = await request(`/api/collections/reports/records/${report.id}`, {
diff --git a/e2e/custom-widgets-custom-jsx-v2.spec.ts b/e2e/custom-widgets-custom-jsx-v2.spec.ts
index 26df6e9e9..dbd66d005 100644
--- a/e2e/custom-widgets-custom-jsx-v2.spec.ts
+++ b/e2e/custom-widgets-custom-jsx-v2.spec.ts
@@ -1,9 +1,12 @@
import { chromium, expect } from "@playwright/test";
+import type { Browser } from "@playwright/test";
+import type { StartedTestContainer } from "testcontainers";
import { describe, test } from "vitest";
import { createHomarrContainer } from "./shared/create-homarr-container";
import { createSqliteDbFileAsync } from "./shared/e2e-db";
-import { startMockApiContainerAsync } from "./shared/mock-api-container";
+import type { MockApiServer } from "./shared/mock-api-server";
+import { startMockApiServerAsync } from "./shared/mock-api-server";
import { seedAdminUserAsync } from "./shared/seed-admin-user";
const adminCredentials = { username: "admin", password: "Comp(exP4sswOrd" };
@@ -13,18 +16,22 @@ const updatedTemplate = "{data.status?.title}
describe("Custom JSX v2 workbench", () => {
test("creates, previews, edits, and immediately adds a widget to a board", async () => {
- const mockApi = await startMockApiContainerAsync({ title: "E2E Widget", status: "online", value: 42 });
- const { db, localMountPath } = await createSqliteDbFileAsync();
- await seedAdminUserAsync(db, adminCredentials);
- const homarrContainer = await createHomarrContainer({
- environment: { AUTH_PROVIDERS: "credentials" },
- mounts: { "/appdata": localMountPath },
- }).start();
- const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
- const browser = await chromium.launch();
- const page = await browser.newPage();
+ let mockApi: MockApiServer | undefined;
+ let homarrContainer: StartedTestContainer | undefined;
+ let browser: Browser | undefined;
try {
+ mockApi = await startMockApiServerAsync({ title: "E2E Widget", status: "online", value: 42 });
+ const { db, localMountPath } = await createSqliteDbFileAsync();
+ await seedAdminUserAsync(db, adminCredentials);
+ homarrContainer = await createHomarrContainer({
+ environment: { AUTH_PROVIDERS: "credentials" },
+ mounts: { "/appdata": localMountPath },
+ }).start();
+ const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
+ browser = await chromium.launch();
+ const page = await browser.newPage();
+
await page.goto(`${baseUrl}/auth/login`);
await page.waitForLoadState("networkidle");
await page.getByLabel("Username").fill(adminCredentials.username);
@@ -51,8 +58,8 @@ describe("Custom JSX v2 workbench", () => {
await request.getByRole("combobox", { name: "Option" }).first().click();
await page.getByRole("option", { name: "label" }).click();
await page.getByRole("button", { name: "Advanced manifest JSON" }).first().click();
- await expect(page.locator("#requests-editor-root")).toContainText('"$option": "label"');
- await page.locator("#jsx-editor-root").fill(initialTemplate);
+ await expect(page.locator("#requests-editor")).toContainText('"$option": "label"');
+ await page.locator("#jsx-editor").fill(initialTemplate);
await page.getByRole("button", { name: "Test and preview" }).last().click();
await expect(page.getByRole("complementary", { name: "Widget preview" }).getByText("E2E Widget")).toBeVisible({
@@ -63,7 +70,7 @@ describe("Custom JSX v2 workbench", () => {
await expect(page.getByText('Widget "E2E Custom JSX v2" created successfully.')).toBeVisible({ timeout: 15_000 });
await page.waitForURL("**/manage/custom-widgets/edit/**", { timeout: 15_000 });
- await page.locator("#jsx-editor-root").fill(updatedTemplate);
+ await page.locator("#jsx-editor").fill(updatedTemplate);
await page.getByRole("button", { name: "Save", exact: true }).last().click();
await expect(page.getByText('Widget "E2E Custom JSX v2" updated successfully.')).toBeVisible({ timeout: 15_000 });
@@ -84,9 +91,7 @@ describe("Custom JSX v2 workbench", () => {
await expect(page.getByText("E2E Widget")).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("42")).toBeVisible({ timeout: 15_000 });
} finally {
- await browser.close();
- await homarrContainer.stop();
- await mockApi.stop();
+ await Promise.allSettled([browser?.close(), homarrContainer?.stop(), mockApi?.close()]);
}
}, 180_000);
});
diff --git a/e2e/shared/mock-api-container.ts b/e2e/shared/mock-api-container.ts
deleted file mode 100644
index 4b4df3007..000000000
--- a/e2e/shared/mock-api-container.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { GenericContainer, Wait } from "testcontainers";
-
-import type { StartedTestContainer } from "testcontainers";
-
-export interface MockApiContainer {
- url: string;
- stop: () => Promise;
-}
-
-export async function startMockApiContainerAsync(responseBody: unknown): Promise {
- const body = JSON.stringify(responseBody);
- const script = [
- 'const { createServer } = require("node:http");',
- `const body = ${JSON.stringify(body)};`,
- 'createServer((_request, response) => { response.writeHead(200, { "content-type": "application/json" }); response.end(body); }).listen(8080, "0.0.0.0");',
- ].join("\n");
- const container: StartedTestContainer = await new GenericContainer("node:24-alpine")
- .withCommand(["node", "-e", script])
- .withExposedPorts(8080)
- .withWaitStrategy(Wait.forHttp("/status", 8080))
- .start();
-
- return {
- url: `http://${container.getIpAddress("bridge")}:8080`,
- stop: async () => void (await container.stop()),
- };
-}
diff --git a/e2e/shared/mock-api-server.ts b/e2e/shared/mock-api-server.ts
new file mode 100644
index 000000000..10ccbcaab
--- /dev/null
+++ b/e2e/shared/mock-api-server.ts
@@ -0,0 +1,50 @@
+import { createServer } from "node:http";
+import { getContainerRuntimeClient, TestContainers } from "testcontainers";
+
+import type { Server } from "node:http";
+
+const TESTCONTAINERS_SSHD_LABEL = "org.testcontainers.sshd";
+
+export interface MockApiServer {
+ url: string;
+ close: () => Promise;
+}
+
+export async function startMockApiServerAsync(responseBody: unknown): Promise {
+ const body = JSON.stringify(responseBody);
+ const server: Server = await new Promise((resolve) => {
+ const candidate = createServer((_request, response) => {
+ response.writeHead(200, { "content-type": "application/json" });
+ response.end(body);
+ });
+ candidate.listen(0, "0.0.0.0", () => resolve(candidate));
+ });
+ const address = server.address();
+ if (!address || typeof address === "string") {
+ server.close();
+ throw new Error("Failed to start mock API server");
+ }
+
+ try {
+ await TestContainers.exposeHostPorts(address.port);
+ const client = await getContainerRuntimeClient();
+ const forwarder = (await client.container.list()).find(
+ (container) => container.State === "running" && container.Labels[TESTCONTAINERS_SSHD_LABEL] === "true",
+ );
+ const forwarderAddress = forwarder
+ ? Object.values(forwarder.NetworkSettings.Networks).find((network) => network.IPAddress)?.IPAddress
+ : undefined;
+ if (!forwarderAddress) throw new Error("Failed to locate the Testcontainers host-port forwarder");
+
+ return {
+ url: `http://${forwarderAddress}:${address.port}`,
+ close: () =>
+ new Promise((resolve, reject) => {
+ server.close((error) => (error ? reject(error) : resolve()));
+ }),
+ };
+ } catch (error) {
+ server.close();
+ throw error;
+ }
+}
diff --git a/package.json b/package.json
index 0808e5262..00fd2c193 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,7 @@
"clean:workspaces": "turbo clean",
"check:custom-widgets": "pnpm --filter @homarr/custom-widgets check:architecture",
"check:custom-widget-bundle": "node ./scripts/check-custom-widget-bundle.mjs",
+ "check:openapi": "tsx ./scripts/check-openapi-schema.mts",
"ci:act:all": "bash scripts/ci/act.sh all",
"ci:act:cleanup": "bash scripts/ci/act.sh cleanup",
"ci:act:docker": "bash scripts/ci/act.sh docker",
@@ -35,7 +36,7 @@
"scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts",
"scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts",
"start": "concurrently \"pnpm with-env node apps/tasks/tasks.cjs\" \"pnpm with-env node apps/websocket/wssServer.cjs\" \"pnpm -F nextjs start\"",
- "test": "cross-env NODE_ENV=development CI=true vitest run --project api-node --project dom --coverage.enabled",
+ "test": "cross-env NODE_ENV=development CI=true vitest run --project api-node --project custom-widgets-node --project db-node --project dom --coverage.enabled",
"test:e2e": "cross-env NODE_ENV=development CI=true vitest run --project e2e",
"test:workshop": "sh apps/workshop/test.sh",
"test:ui": "cross-env NODE_ENV=development CI=true vitest --exclude e2e --ui --coverage.enabled",
diff --git a/packages/api/src/router/custom-widget/custom-widget-router.ts b/packages/api/src/router/custom-widget/custom-widget-router.ts
index ce963fc87..e39cc5bc1 100644
--- a/packages/api/src/router/custom-widget/custom-widget-router.ts
+++ b/packages/api/src/router/custom-widget/custom-widget-router.ts
@@ -26,7 +26,7 @@ import {
} from "./stored-definition";
import { templateProcedures } from "./template-procedures";
import { transferProcedures } from "./transfer-procedures";
-import { assertSecretSources, requiredSecretKinds } from "./secret-policy";
+import { assertSecretSources, hasSameSecretBinding, requiredSecretKinds } from "./secret-policy";
import { secretProcedures } from "./secret-procedures";
import { workshopProcedures } from "./workshop-procedures";
@@ -83,6 +83,12 @@ export const customWidgetRouter = createTRPCRouter({
updatedAt: new Date(),
}));
const sourceIds = Object.keys(definition.sources);
+ const changedSecretBindings = new Set(
+ Object.entries(definition.sources).flatMap(([sourceId, source]) => {
+ const previous = current.sources[sourceId];
+ return previous && !hasSameSecretBinding(previous, source) ? [sourceId] : [];
+ }),
+ );
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
@@ -101,6 +107,17 @@ export const customWidgetRouter = createTRPCRouter({
),
);
+ for (const sourceId of changedSecretBindings) {
+ await transaction
+ .delete(schema.customWidgetSecrets)
+ .where(
+ and(
+ eq(schema.customWidgetSecrets.definitionId, id),
+ eq(schema.customWidgetSecrets.sourceId, sourceId),
+ ),
+ );
+ }
+
for (const [sourceId, source] of Object.entries(definition.sources)) {
const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)];
const where = and(
@@ -139,6 +156,13 @@ export const customWidgetRouter = createTRPCRouter({
.where(and(eq(customWidgetSecrets.definitionId, id), notInArray(customWidgetSecrets.sourceId, sourceIds)))
.run();
+ for (const sourceId of changedSecretBindings) {
+ transaction
+ .delete(customWidgetSecrets)
+ .where(and(eq(customWidgetSecrets.definitionId, id), eq(customWidgetSecrets.sourceId, sourceId)))
+ .run();
+ }
+
for (const [sourceId, source] of Object.entries(definition.sources)) {
const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)];
const where = and(eq(customWidgetSecrets.definitionId, id), eq(customWidgetSecrets.sourceId, sourceId));
diff --git a/packages/api/src/router/custom-widget/legacy-migration.ts b/packages/api/src/router/custom-widget/legacy-migration.ts
new file mode 100644
index 000000000..48d7c70a2
--- /dev/null
+++ b/packages/api/src/router/custom-widget/legacy-migration.ts
@@ -0,0 +1,79 @@
+import { parse as parseSuperJson } from "superjson";
+
+import type { legacyCustomWidgetDefinitions } from "@homarr/db/schema";
+import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt";
+
+type LegacyCustomWidgetDefinition = typeof legacyCustomWidgetDefinitions.$inferSelect;
+
+export function buildLegacyCustomWidgetMigrationPrompt(
+ definition: LegacyCustomWidgetDefinition,
+ configuredSecretKinds: readonly string[],
+) {
+ const legacyDefinition = {
+ $schema: "homarr-custom-widget-v1",
+ name: definition.name,
+ description: definition.description,
+ iconUrl: definition.iconUrl,
+ url: redactLegacyUrl(definition.url),
+ authType: definition.authType,
+ headerName: definition.headerName,
+ method: definition.method,
+ requestBody: redactLegacyRequestBody(definition.requestBody),
+ displayType: definition.displayType,
+ displayConfig: parseLegacyDisplayConfig(definition.displayConfig),
+ configuredSecretKinds,
+ };
+ return buildCustomWidgetAiPrompt(
+ undefined,
+ undefined,
+ legacyDefinition,
+ [
+ "Migrate this preserved Homarr v1 Custom Widget to Custom JSX v2.",
+ "Preserve its visible behavior and API intent, use a source named default, and return importable v2 JSON plus JSX.",
+ "The URL and request-body values may be redacted; keep placeholders and never invent or include credentials.",
+ "Credential kinds are informational only and must be configured separately in Homarr.",
+ ].join(" "),
+ );
+}
+
+function redactLegacyUrl(value: string) {
+ try {
+ const url = new URL(value);
+ url.username = "";
+ url.password = "";
+ url.hash = "";
+ url.pathname = url.pathname
+ .split("/")
+ .map((segment) => (segment ? "[REDACTED]" : segment))
+ .join("/");
+ for (const key of url.searchParams.keys()) url.searchParams.set(key, "[REDACTED]");
+ return url.toString();
+ } catch {
+ return "[INVALID LEGACY URL — enter the API URL in Homarr]";
+ }
+}
+
+function redactLegacyRequestBody(value: string | null) {
+ if (!value) return value;
+ try {
+ return JSON.stringify(redactAllValues(JSON.parse(value) as unknown));
+ } catch {
+ return "[REDACTED LEGACY REQUEST BODY]";
+ }
+}
+
+function redactAllValues(value: unknown): unknown {
+ if (Array.isArray(value)) return value.map(redactAllValues);
+ if (value !== null && typeof value === "object")
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, redactAllValues(child)]));
+ if (value === null) return null;
+ return "[REDACTED VALUE]";
+}
+
+function parseLegacyDisplayConfig(value: string): unknown {
+ try {
+ return parseSuperJson(value) as unknown;
+ } catch {
+ return { migrationNote: "The stored v1 display configuration could not be decoded.", raw: value.slice(0, 4_000) };
+ }
+}
diff --git a/packages/api/src/router/custom-widget/management-queries.ts b/packages/api/src/router/custom-widget/management-queries.ts
index 8bec92bbc..ba8571154 100644
--- a/packages/api/src/router/custom-widget/management-queries.ts
+++ b/packages/api/src/router/custom-widget/management-queries.ts
@@ -2,18 +2,13 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { decryptSecret } from "@homarr/common/server";
-import { createLogger } from "@homarr/core/infrastructure/logs";
import { eq, or } from "@homarr/db";
-import { boards, customWidgetDefinitions } from "@homarr/db/schema";
-import {
- getCustomWidgetConfirmation,
- getCustomWidgetDefaultOptions,
- getCustomWidgetSecretRequirements,
-} from "@homarr/custom-widgets/core";
+import { boards, customWidgetDefinitions, legacyCustomWidgetDefinitions } from "@homarr/db/schema";
+import { collectCustomWidgetRequestReferences } from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure, protectedProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
-import { parseStoredCustomWidgetDefinition, safeParseStoredCustomWidgetDefinition } from "./stored-definition";
+import { parseStoredCustomWidgetDefinition } from "./stored-definition";
import { executeCustomWidgetRequest } from "./request-executor";
import {
hashRuntimeParams,
@@ -23,68 +18,51 @@ import {
} from "./request-manifest";
import { acquireCustomWidgetRequestLimit } from "./request-limits";
import { getCustomWidgetCacheVersion } from "./cache-version";
+import { buildLegacyCustomWidgetMigrationPrompt } from "./legacy-migration";
+import {
+ mapAvailableCustomWidget,
+ mapCustomWidgetListItem,
+ mapLegacyAvailableCustomWidget,
+ mapLegacyCustomWidgetListItem,
+} from "./management-query-mappers";
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
-const logger = createLogger({ module: "custom-widget:management" });
export const managementQueryProcedures = {
list: manageProcedure
.meta({ mcp: { enabled: true, description: "List all Custom JSX widgets." } })
.query(async ({ ctx }) => {
- const definitions = await ctx.db.query.customWidgetDefinitions.findMany({
- orderBy: (table, { asc }) => asc(table.name),
- with: { secrets: true },
- });
- return definitions.map((definition) => {
- const result = safeParseStoredCustomWidgetDefinition(definition);
- if (!result.success) {
- logger.warn("Skipped parsing invalid custom widget definition", {
- id: definition.id,
- issueCount: result.issues.length,
- });
- return {
- id: definition.id,
- name: definition.name,
- description: definition.description ?? undefined,
- iconUrl: definition.iconUrl ?? undefined,
- sources: [],
- requestCount: 0,
- missingSecrets: [],
- options: {},
- updatedAt: definition.updatedAt,
- enabled: definition.enabled,
- valid: false as const,
- validationIssues: result.issues,
- };
- }
-
- const widget = result.widget;
- const configuredSecrets = new Set(definition.secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
- return {
- id: definition.id,
- name: widget.name,
- description: widget.description,
- iconUrl: widget.iconUrl,
- sources: Object.entries(widget.sources).map(([id, { name, baseUrl, networkScope, auth }]) => ({
- id,
- name,
- origin: new URL(baseUrl).origin,
- networkScope,
- authType: typeof auth === "string" ? auth : auth.type,
- })),
- requestCount: Object.keys(widget.requests).length,
- missingSecrets: getCustomWidgetSecretRequirements(widget.sources).filter(
- (requirement) => !configuredSecrets.has(`${requirement.sourceId}:${requirement.kind}`),
- ),
- options: widget.options,
- updatedAt: definition.updatedAt,
- enabled: definition.enabled,
- valid: true as const,
- validationIssues: [],
- };
- });
+ const [definitions, legacyDefinitions] = await Promise.all([
+ ctx.db.query.customWidgetDefinitions.findMany({
+ orderBy: (table, { asc }) => asc(table.name),
+ with: { secrets: true },
+ }),
+ ctx.db.query.legacyCustomWidgetDefinitions.findMany({
+ orderBy: (table, { asc }) => asc(table.name),
+ with: { secrets: true },
+ }),
+ ]);
+ const current = definitions.map(mapCustomWidgetListItem);
+ const legacy = legacyDefinitions.map(mapLegacyCustomWidgetListItem);
+ return [...current, ...legacy].toSorted((left, right) => left.name.localeCompare(right.name));
}),
+ legacyMigrationPrompt: manageProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
+ const definition = await ctx.db.query.legacyCustomWidgetDefinitions.findFirst({
+ where: eq(legacyCustomWidgetDefinitions.id, input.id),
+ with: { secrets: true },
+ });
+ if (!definition) throw new TRPCError({ code: "NOT_FOUND", message: "Legacy custom widget not found" });
+ return {
+ id: definition.id,
+ version: 1 as const,
+ prompt: buildLegacyCustomWidgetMigrationPrompt(
+ definition,
+ definition.secrets.map(({ kind }) => kind),
+ ),
+ };
+ }),
+
get: manageProcedure
.meta({ mcp: { enabled: true, description: "Get one Custom JSX widget without secret values." } })
.input(z.object({ id: z.string() }))
@@ -119,45 +97,13 @@ export const managementQueryProcedures = {
: eq(customWidgetDefinitions.enabled, true),
orderBy: (table, { asc }) => asc(table.name),
});
- return definitions.flatMap((definition) => {
- const result = safeParseStoredCustomWidgetDefinition(definition);
- if (!result.success) {
- logger.warn("Excluded invalid custom widget definition from board picker", { id: definition.id });
- return [];
- }
-
- const widget = result.widget;
- return {
- id: definition.id,
- name: definition.name,
- description: definition.description,
- iconUrl: definition.iconUrl,
- options: widget.options,
- defaultOptions: getCustomWidgetDefaultOptions(widget.options),
- template: widget.template,
- sources: Object.entries(widget.sources).map(([id, { name, networkScope, auth }]) => ({
- id,
- name,
- networkScope,
- authType: typeof auth === "string" ? auth : auth.type,
- })),
- requestCapabilities: Object.entries(widget.requests).map(
- ([id, { kind, method, trigger, permission, confirmation, invalidates }]) => ({
- id,
- kind,
- method,
- trigger,
- minimumBoardPermission: permission,
- confirmation: getCustomWidgetConfirmation({ method, confirmation }),
- invalidates,
- }),
- ),
- optionRequests: Object.entries(widget.requests).flatMap(([id, request]) =>
- request.kind === "query" ? [{ id }] : [],
- ),
- updatedAt: definition.updatedAt,
- };
+ const available = definitions.flatMap(mapAvailableCustomWidget);
+ if (!input.currentId || available.some(({ id }) => id === input.currentId)) return available;
+ const legacy = await ctx.db.query.legacyCustomWidgetDefinitions.findFirst({
+ where: eq(legacyCustomWidgetDefinitions.id, input.currentId),
});
+ if (!legacy) return available;
+ return [...available, mapLegacyAvailableCustomWidget(legacy)];
}),
optionRequest: protectedProcedure
@@ -191,7 +137,7 @@ export const managementQueryProcedures = {
definitionId: input.definitionId,
});
try {
- const values = resolveCustomWidgetRequestValues(request, input.params, {});
+ const values = resolveOptionRequestValues(request, input.params);
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
const response = await executeCustomWidgetRequest({
baseUrl: source.baseUrl,
@@ -230,3 +176,21 @@ export const managementQueryProcedures = {
}
}),
};
+
+export function resolveOptionRequestValues(
+ request: Parameters[0],
+ configuration: NonNullable[2]>,
+) {
+ const references = collectCustomWidgetRequestReferences(request);
+ const options: Record = {};
+ const params: typeof configuration = {};
+ for (const name of references.options) {
+ const value = configuration[name];
+ if (value !== undefined) options[name] = value;
+ }
+ for (const name of references.params) {
+ const value = configuration[name];
+ if (value !== undefined) params[name] = value;
+ }
+ return resolveCustomWidgetRequestValues(request, options, params);
+}
diff --git a/packages/api/src/router/custom-widget/management-query-mappers.ts b/packages/api/src/router/custom-widget/management-query-mappers.ts
new file mode 100644
index 000000000..086cae565
--- /dev/null
+++ b/packages/api/src/router/custom-widget/management-query-mappers.ts
@@ -0,0 +1,154 @@
+import type { InferSelectModel } from "drizzle-orm";
+
+import { createLogger } from "@homarr/core/infrastructure/logs";
+import type { customWidgetDefinitions, customWidgetSecrets, legacyCustomWidgetDefinitions } from "@homarr/db/schema";
+import {
+ getCustomWidgetConfirmation,
+ getCustomWidgetDefaultOptions,
+ getCustomWidgetSecretRequirements,
+} from "@homarr/custom-widgets/core";
+
+import { safeParseStoredCustomWidgetDefinition } from "./stored-definition";
+
+const logger = createLogger({ module: "custom-widget:management" });
+
+type StoredDefinition = InferSelectModel;
+type StoredSecret = InferSelectModel;
+type LegacyDefinition = InferSelectModel;
+
+export function mapCustomWidgetListItem(definition: StoredDefinition & { secrets: readonly StoredSecret[] }) {
+ const result = safeParseStoredCustomWidgetDefinition(definition);
+ if (!result.success) {
+ logger.warn("Skipped parsing invalid custom widget definition", {
+ id: definition.id,
+ issueCount: result.issues.length,
+ });
+ return {
+ id: definition.id,
+ name: definition.name,
+ description: definition.description ?? undefined,
+ iconUrl: definition.iconUrl ?? undefined,
+ sources: [],
+ requestCount: 0,
+ missingSecrets: [],
+ options: {},
+ updatedAt: definition.updatedAt,
+ enabled: definition.enabled,
+ valid: false as const,
+ migrationRequired: false as const,
+ validationIssues: result.issues,
+ };
+ }
+
+ const widget = result.widget;
+ const configuredSecrets = new Set(definition.secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
+ return {
+ id: definition.id,
+ name: widget.name,
+ description: widget.description,
+ iconUrl: widget.iconUrl,
+ sources: Object.entries(widget.sources).map(([id, { name, baseUrl, networkScope, auth }]) => ({
+ id,
+ name,
+ origin: new URL(baseUrl).origin,
+ networkScope,
+ authType: typeof auth === "string" ? auth : auth.type,
+ })),
+ requestCount: Object.keys(widget.requests).length,
+ missingSecrets: getCustomWidgetSecretRequirements(widget.sources).filter(
+ (requirement) => !configuredSecrets.has(`${requirement.sourceId}:${requirement.kind}`),
+ ),
+ options: widget.options,
+ updatedAt: definition.updatedAt,
+ enabled: definition.enabled,
+ valid: true as const,
+ migrationRequired: false as const,
+ validationIssues: [],
+ };
+}
+
+export function mapLegacyCustomWidgetListItem(definition: LegacyDefinition) {
+ return {
+ id: definition.id,
+ name: definition.name,
+ description: definition.description ?? undefined,
+ iconUrl: definition.iconUrl ?? undefined,
+ sources: [{ id: "default", name: "Legacy API", origin: safeOrigin(definition.url), authType: definition.authType }],
+ requestCount: 1,
+ missingSecrets: [],
+ options: {},
+ updatedAt: definition.updatedAt,
+ enabled: false,
+ valid: false as const,
+ migrationRequired: true as const,
+ validationIssues: [],
+ };
+}
+
+export function mapAvailableCustomWidget(definition: StoredDefinition) {
+ const result = safeParseStoredCustomWidgetDefinition(definition);
+ if (!result.success) {
+ logger.warn("Excluded invalid custom widget definition from board picker", { id: definition.id });
+ return [];
+ }
+
+ const widget = result.widget;
+ return [
+ {
+ id: definition.id,
+ name: definition.name,
+ description: definition.description,
+ iconUrl: definition.iconUrl,
+ options: widget.options,
+ defaultOptions: getCustomWidgetDefaultOptions(widget.options),
+ template: widget.template,
+ sources: Object.entries(widget.sources).map(([id, { name, networkScope, auth }]) => ({
+ id,
+ name,
+ networkScope,
+ authType: typeof auth === "string" ? auth : auth.type,
+ })),
+ requestCapabilities: Object.entries(widget.requests).map(
+ ([id, { kind, method, trigger, permission, confirmation, invalidates }]) => ({
+ id,
+ kind,
+ method,
+ trigger,
+ minimumBoardPermission: permission,
+ confirmation: getCustomWidgetConfirmation({ method, confirmation }),
+ invalidates,
+ }),
+ ),
+ optionRequests: Object.entries(widget.requests).flatMap(([id, request]) =>
+ request.kind === "query" ? [{ id }] : [],
+ ),
+ updatedAt: definition.updatedAt,
+ migrationRequired: false as const,
+ },
+ ];
+}
+
+export function mapLegacyAvailableCustomWidget(definition: LegacyDefinition) {
+ return {
+ id: definition.id,
+ name: definition.name,
+ description: definition.description,
+ iconUrl: definition.iconUrl,
+ options: {},
+ defaultOptions: {},
+ template: "",
+ sources: [],
+ requestCapabilities: [],
+ optionRequests: [],
+ updatedAt: definition.updatedAt,
+ migrationRequired: true as const,
+ };
+}
+
+function safeOrigin(value: string) {
+ try {
+ return new URL(value).origin;
+ } catch {
+ return "Invalid legacy URL";
+ }
+}
diff --git a/packages/api/src/router/custom-widget/preview-base-procedures.ts b/packages/api/src/router/custom-widget/preview-base-procedures.ts
index 0b13254f0..7a0cc7b4a 100644
--- a/packages/api/src/router/custom-widget/preview-base-procedures.ts
+++ b/packages/api/src/router/custom-widget/preview-base-procedures.ts
@@ -14,6 +14,8 @@ import { customWidgetDefinitions } from "@homarr/db/schema";
import { permissionRequiredProcedure } from "../../trpc";
import { createPreviewSession, getPreviewSession } from "./preview-sessions";
+import { hasSameSecretBinding, requiredSecretKinds } from "./secret-policy";
+import { parseStoredCustomWidgetDefinition } from "./stored-definition";
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
@@ -48,8 +50,32 @@ const previewCreateProcedure = manageProcedure
with: { secrets: true },
});
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
+ const existingDefinition = parseStoredCustomWidgetDefinition(existing);
+ for (const [sourceId, existingSource] of Object.entries(existingDefinition.sources)) {
+ const submittedSource = input.definition.sources[sourceId];
+ const hasStoredSecrets = existing.secrets.some((secret) => secret.sourceId === sourceId);
+ if (!submittedSource || !hasStoredSecrets || hasSameSecretBinding(existingSource, submittedSource)) continue;
+
+ const authType = typeof submittedSource.auth === "string" ? submittedSource.auth : submittedSource.auth.type;
+ const missingReplacement = requiredSecretKinds(authType).some(
+ (kind) => !secrets.some((secret) => secret.sourceId === sourceId && secret.kind === kind),
+ );
+ if (missingReplacement) {
+ throw new TRPCError({
+ code: ctx.session.user.permissions.includes("custom-widget-secret-write") ? "BAD_REQUEST" : "FORBIDDEN",
+ message: "Source security settings changed; re-enter its credentials to preview this definition",
+ });
+ }
+ }
for (const secret of existing.secrets) {
- if (!secrets.some((candidate) => candidate.sourceId === secret.sourceId && candidate.kind === secret.kind)) {
+ const existingSource = existingDefinition.sources[secret.sourceId];
+ const submittedSource = input.definition.sources[secret.sourceId];
+ if (
+ existingSource &&
+ submittedSource &&
+ hasSameSecretBinding(existingSource, submittedSource) &&
+ !secrets.some((candidate) => candidate.sourceId === secret.sourceId && candidate.kind === secret.kind)
+ ) {
secrets.push({ sourceId: secret.sourceId, kind: secret.kind, value: decryptSecret(secret.encryptedValue) });
}
}
diff --git a/packages/api/src/router/custom-widget/preview-sessions.ts b/packages/api/src/router/custom-widget/preview-sessions.ts
index 7e2a92ea6..289247213 100644
--- a/packages/api/src/router/custom-widget/preview-sessions.ts
+++ b/packages/api/src/router/custom-widget/preview-sessions.ts
@@ -14,6 +14,14 @@ import { toTrpcError } from "./domain-error";
const SESSION_PREFIX = "custom-widget:preview-session:";
const JOURNAL_PREFIX = "custom-widget:preview-journal:";
+const COMPARE_AND_SWAP_SESSION_SCRIPT = `
+local current = redis.call('GET', KEYS[1])
+if not current then return 0 end
+local ok, parsed = pcall(cjson.decode, current)
+if not ok or type(parsed) ~= 'table' or tonumber(parsed.revision) ~= tonumber(ARGV[1]) then return 0 end
+redis.call('SET', KEYS[1], ARGV[2], 'PX', ARGV[3])
+return 1
+`;
class RedisPreviewSessionStore implements PreviewSessionStore {
public constructor(private readonly redis: RedisClient) {}
@@ -25,6 +33,20 @@ class RedisPreviewSessionStore implements PreviewSessionStore {
public getSession(id: string) {
return this.redis.get(`${SESSION_PREFIX}${id}`);
}
+ public async compareAndSwapSession(id: string, expectedRevision: number, value: unknown, ttlMs: number) {
+ return (
+ Number(
+ await this.redis.eval(
+ COMPARE_AND_SWAP_SESSION_SCRIPT,
+ 1,
+ `${SESSION_PREFIX}${id}`,
+ expectedRevision,
+ JSON.stringify(value),
+ ttlMs,
+ ),
+ ) === 1
+ );
+ }
public async deleteSession(id: string) {
await this.redis.del(`${SESSION_PREFIX}${id}`);
}
diff --git a/packages/api/src/router/custom-widget/request-limits.ts b/packages/api/src/router/custom-widget/request-limits.ts
index 8dfba3b9b..b92615f3e 100644
--- a/packages/api/src/router/custom-widget/request-limits.ts
+++ b/packages/api/src/router/custom-widget/request-limits.ts
@@ -13,15 +13,16 @@ if count == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
return { count, redis.call('PTTL', KEYS[1]) }
`;
const ACQUIRE_SCRIPT = `
-local count = redis.call('INCR', KEYS[1])
-if count == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[2]) end
-if count > tonumber(ARGV[1]) then redis.call('DECR', KEYS[1]); return 0 end
-return count
+local time = redis.call('TIME')
+local now = (tonumber(time[1]) * 1000) + math.floor(tonumber(time[2]) / 1000)
+redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now)
+if redis.call('ZCARD', KEYS[1]) >= tonumber(ARGV[2]) then return 0 end
+redis.call('ZADD', KEYS[1], now + tonumber(ARGV[3]), ARGV[1])
+redis.call('PEXPIRE', KEYS[1], ARGV[3])
+return 1
`;
const RELEASE_SCRIPT = `
-local count = tonumber(redis.call('GET', KEYS[1]) or '0')
-if count <= 1 then redis.call('DEL', KEYS[1]); return 0 end
-return redis.call('DECR', KEYS[1])
+return redis.call('ZREM', KEYS[1], ARGV[1])
`;
class RedisRequestLimitStore implements RequestLimitStore {
@@ -32,12 +33,12 @@ class RedisRequestLimitStore implements RequestLimitStore {
return { count: Number(count), retryAfterMs: Number(retryAfterMs) || windowMs };
}
- public async acquireConcurrency(key: string, limit: number, ttlMs: number) {
- return Number(await this.redis.eval(ACQUIRE_SCRIPT, 1, key, limit, ttlMs)) > 0;
+ public async acquireConcurrency(key: string, ownerId: string, limit: number, ttlMs: number) {
+ return Number(await this.redis.eval(ACQUIRE_SCRIPT, 1, key, ownerId, limit, ttlMs)) > 0;
}
- public async releaseConcurrency(key: string) {
- await this.redis.eval(RELEASE_SCRIPT, 1, key);
+ public async releaseConcurrency(key: string, ownerId: string) {
+ await this.redis.eval(RELEASE_SCRIPT, 1, key, ownerId);
}
}
diff --git a/packages/api/src/router/custom-widget/secret-policy.ts b/packages/api/src/router/custom-widget/secret-policy.ts
index 7f3752120..d6ed86e6b 100644
--- a/packages/api/src/router/custom-widget/secret-policy.ts
+++ b/packages/api/src/router/custom-widget/secret-policy.ts
@@ -1,11 +1,28 @@
import { TRPCError } from "@trpc/server";
-import { getCustomWidgetRequiredSecretKinds } from "@homarr/custom-widgets/core";
+import {
+ getCustomWidgetRequiredSecretKinds,
+ hasSameCustomWidgetSourceAuthentication,
+} from "@homarr/custom-widgets/core";
+import type { CustomWidgetSource } from "@homarr/custom-widgets/core";
export function requiredSecretKinds(authType: string) {
return getCustomWidgetRequiredSecretKinds(authType as Parameters[0]);
}
+/**
+ * Credentials may only survive edits that preserve their complete security
+ * boundary. The path is intentionally excluded: credentials are scoped to an
+ * origin, while the network scope and auth destination are part of the binding.
+ */
+export function hasSameSecretBinding(left: CustomWidgetSource, right: CustomWidgetSource) {
+ return (
+ new URL(left.baseUrl).origin === new URL(right.baseUrl).origin &&
+ left.networkScope === right.networkScope &&
+ hasSameCustomWidgetSourceAuthentication(left, right)
+ );
+}
+
export function assertSecretSources(
sources: Record,
secrets: readonly { sourceId: string; kind: "apiKey" | "username" | "password" }[],
diff --git a/packages/api/src/router/custom-widget/secret-procedures.ts b/packages/api/src/router/custom-widget/secret-procedures.ts
index ca7a8e880..6cb250682 100644
--- a/packages/api/src/router/custom-widget/secret-procedures.ts
+++ b/packages/api/src/router/custom-widget/secret-procedures.ts
@@ -16,7 +16,7 @@ import {
createCustomWidgetConfigurationRequest,
getCustomWidgetConfigurationRequestForUser,
} from "./configuration-requests";
-import { assertSecretSources, requiredSecretKinds } from "./secret-policy";
+import { assertSecretSources, hasSameSecretBinding, requiredSecretKinds } from "./secret-policy";
import { getPreviewSession } from "./preview-sessions";
import { parseStoredCustomWidgetDefinition, serializeCustomWidgetDefinition } from "./stored-definition";
@@ -107,6 +107,7 @@ export const secretProcedures = {
assertSecretSources({ [input.sourceId]: source }, input.secrets);
const updated = { ...definition, sources: { ...definition.sources, [input.sourceId]: source } };
const definitionChanges = { ...serializeCustomWidgetDefinition(updated), updatedAt: new Date() };
+ const bindingChanged = !hasSameSecretBinding(current, source);
const secretRows = input.secrets.map((secret) => ({
definitionId: input.definitionId,
sourceId: input.sourceId,
@@ -121,6 +122,16 @@ export const secretProcedures = {
.update(schema.customWidgetDefinitions)
.set(definitionChanges)
.where(eq(schema.customWidgetDefinitions.id, input.definitionId));
+ if (bindingChanged) {
+ await transaction
+ .delete(schema.customWidgetSecrets)
+ .where(
+ and(
+ eq(schema.customWidgetSecrets.definitionId, input.definitionId),
+ eq(schema.customWidgetSecrets.sourceId, input.sourceId),
+ ),
+ );
+ }
for (const secret of secretRows) {
await transaction
.delete(schema.customWidgetSecrets)
@@ -142,6 +153,17 @@ export const secretProcedures = {
.set(definitionChanges)
.where(eq(customWidgetDefinitions.id, input.definitionId))
.run();
+ if (bindingChanged) {
+ transaction
+ .delete(customWidgetSecrets)
+ .where(
+ and(
+ eq(customWidgetSecrets.definitionId, input.definitionId),
+ eq(customWidgetSecrets.sourceId, input.sourceId),
+ ),
+ )
+ .run();
+ }
for (const secret of secretRows) {
transaction
.delete(customWidgetSecrets)
@@ -181,6 +203,12 @@ export const secretProcedures = {
let source: { id: string; name: string; auth: string | { type: string }; value: CustomWidgetSource } | undefined;
let target: { type: "definition"; id: string } | { type: "preview"; id: string };
if (input.definitionId) {
+ if (!ctx.session.user.permissions.includes("custom-widget-secret-write")) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Creating a stored credential setup link requires dedicated permission",
+ });
+ }
const stored = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.definitionId),
});
diff --git a/packages/api/src/router/custom-widget/template-procedures.ts b/packages/api/src/router/custom-widget/template-procedures.ts
index 7478aff15..116340800 100644
--- a/packages/api/src/router/custom-widget/template-procedures.ts
+++ b/packages/api/src/router/custom-widget/template-procedures.ts
@@ -5,7 +5,7 @@ import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
-import { eq } from "@homarr/db";
+import { and, eq } from "@homarr/db";
import { customWidgetDefinitions } from "@homarr/db/schema";
import { permissionRequiredProcedure } from "../../trpc";
@@ -120,11 +120,28 @@ export const templateProcedures = {
}
const template = validateTemplate(definition, lines.join("\n"));
- await ctx.db
+ const updateResult = (await ctx.db
.update(customWidgetDefinitions)
.set({ template, updatedAt: new Date() })
- .where(eq(customWidgetDefinitions.id, input.id));
+ .where(
+ and(eq(customWidgetDefinitions.id, input.id), eq(customWidgetDefinitions.template, definition.template)),
+ )) as unknown;
+ if (getAffectedRowCount(updateResult) === 0) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: "Template changed since it was read. Read it again and retry.",
+ });
+ }
logger.info("Patched custom widget template", { id: input.id, editCount: input.edits.length });
return { id: input.id, template, templateLines: lines, revision: getTemplateRevision(template) };
}),
};
+
+function getAffectedRowCount(result: unknown): number {
+ if (Array.isArray(result)) return getAffectedRowCount(result[0]);
+ if (!result || typeof result !== "object") return 0;
+ if ("affectedRows" in result && typeof result.affectedRows === "number") return result.affectedRows;
+ if ("rowCount" in result && typeof result.rowCount === "number") return result.rowCount;
+ if ("changes" in result && typeof result.changes === "number") return result.changes;
+ return 0;
+}
diff --git a/packages/api/src/router/custom-widget/transfer-procedures.ts b/packages/api/src/router/custom-widget/transfer-procedures.ts
index 89d219fcc..28655da0a 100644
--- a/packages/api/src/router/custom-widget/transfer-procedures.ts
+++ b/packages/api/src/router/custom-widget/transfer-procedures.ts
@@ -1,15 +1,16 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
+import { encryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
-import { eq } from "@homarr/db";
-import { customWidgetDefinitions } from "@homarr/db/schema";
+import { eq, handleTransactionsAsync } from "@homarr/db";
+import { customWidgetDefinitions, customWidgetSecrets, legacyCustomWidgetDefinitions } from "@homarr/db/schema";
import { customWidgetImportSchema, customWidgetSecretsInputSchema } from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure } from "../../trpc";
import { insertCustomWidgetDefinition } from "./definition-insert";
-import { assertSecretSources } from "./secret-policy";
-import { parseStoredCustomWidgetDefinition } from "./stored-definition";
+import { assertSecretSources, requiredSecretKinds } from "./secret-policy";
+import { parseStoredCustomWidgetDefinition, serializeCustomWidgetDefinition } from "./stored-definition";
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
const logger = createLogger({ module: "custom-widget" });
@@ -37,4 +38,117 @@ export const transferProcedures = {
logger.info("Imported custom widget definition", { id, name: input.widget.name });
return { id };
}),
+
+ migrateLegacy: manageProcedure
+ .input(
+ z.object({
+ id: z.string(),
+ widget: customWidgetImportSchema,
+ secrets: customWidgetSecretsInputSchema.default([]),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ if (input.secrets.length > 0 && !ctx.session.user.permissions.includes("custom-widget-secret-write")) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Writing migrated widget secrets requires dedicated permission",
+ });
+ }
+ assertSecretSources(input.widget.sources, input.secrets);
+ const [legacy, current] = await Promise.all([
+ ctx.db.query.legacyCustomWidgetDefinitions.findFirst({
+ where: eq(legacyCustomWidgetDefinitions.id, input.id),
+ with: { secrets: true },
+ }),
+ ctx.db.query.customWidgetDefinitions.findFirst({ where: eq(customWidgetDefinitions.id, input.id) }),
+ ]);
+ if (!legacy) throw new TRPCError({ code: "NOT_FOUND", message: "Legacy custom widget not found" });
+ if (current) throw new TRPCError({ code: "CONFLICT", message: "A v2 widget already uses this identifier" });
+
+ const explicitKeys = new Set(input.secrets.map(({ sourceId, kind }) => `${sourceId}:${kind}`));
+ const preservableKinds = new Set(requiredSecretKinds(getAuthType(input.widget.sources.default?.auth)));
+ const preservedSecrets = canPreserveLegacySecrets(legacy, input.widget)
+ ? legacy.secrets.filter(({ kind }) => preservableKinds.has(kind) && !explicitKeys.has(`default:${kind}`))
+ : [];
+ const now = new Date();
+ const definitionRow = {
+ id: legacy.id,
+ ...serializeCustomWidgetDefinition(input.widget),
+ enabled: legacy.enabled,
+ createdAt: legacy.createdAt,
+ updatedAt: now,
+ creatorId: legacy.creatorId,
+ };
+ const secretRows = [
+ ...preservedSecrets.map(({ kind, encryptedValue }) => ({
+ definitionId: legacy.id,
+ sourceId: "default",
+ kind,
+ encryptedValue,
+ updatedAt: now,
+ })),
+ ...input.secrets.map(({ sourceId, kind, value }) => ({
+ definitionId: legacy.id,
+ sourceId,
+ kind,
+ encryptedValue: encryptSecret(value),
+ updatedAt: now,
+ })),
+ ];
+
+ await handleTransactionsAsync(ctx.db, {
+ async handleAsync(database, schema) {
+ await database.transaction(async (transaction) => {
+ await transaction.insert(schema.customWidgetDefinitions).values(definitionRow);
+ if (secretRows.length > 0) await transaction.insert(schema.customWidgetSecrets).values(secretRows);
+ await transaction
+ .delete(schema.legacyCustomWidgetDefinitions)
+ .where(eq(schema.legacyCustomWidgetDefinitions.id, legacy.id));
+ });
+ },
+ handleSync(database) {
+ database.transaction((transaction) => {
+ transaction.insert(customWidgetDefinitions).values(definitionRow).run();
+ if (secretRows.length > 0) transaction.insert(customWidgetSecrets).values(secretRows).run();
+ transaction
+ .delete(legacyCustomWidgetDefinitions)
+ .where(eq(legacyCustomWidgetDefinitions.id, legacy.id))
+ .run();
+ });
+ },
+ });
+ logger.info("Migrated legacy custom widget definition", {
+ id: legacy.id,
+ preservedSecretCount: preservedSecrets.length,
+ });
+ return { id: legacy.id, preservedSecretCount: preservedSecrets.length };
+ }),
+
+ deleteLegacy: manageProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
+ await ctx.db.delete(legacyCustomWidgetDefinitions).where(eq(legacyCustomWidgetDefinitions.id, input.id));
+ logger.info("Deleted legacy custom widget definition", { id: input.id });
+ }),
};
+
+function canPreserveLegacySecrets(
+ legacy: typeof legacyCustomWidgetDefinitions.$inferSelect,
+ widget: z.infer,
+) {
+ const source = widget.sources.default;
+ if (!source || getAuthType(source.auth) !== legacy.authType) return false;
+ if (getOrigin(source.baseUrl) !== getOrigin(legacy.url)) return false;
+ const headerName = typeof source.auth === "object" && "name" in source.auth ? source.auth.name : undefined;
+ return headerName === (legacy.headerName ?? undefined);
+}
+
+function getAuthType(auth: string | { type: string } | undefined) {
+ return typeof auth === "string" ? auth : (auth?.type ?? "none");
+}
+
+function getOrigin(value: string) {
+ try {
+ return new URL(value).origin.toLowerCase();
+ } catch {
+ return null;
+ }
+}
diff --git a/packages/api/src/router/test/custom-widget/definition-persistence.spec.ts b/packages/api/src/router/test/custom-widget/definition-persistence.spec.ts
index 6119726fe..e634cca89 100644
--- a/packages/api/src/router/test/custom-widget/definition-persistence.spec.ts
+++ b/packages/api/src/router/test/custom-widget/definition-persistence.spec.ts
@@ -1,7 +1,13 @@
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { eq, sql } from "@homarr/db";
-import { customWidgetDefinitions, customWidgetSecrets, users } from "@homarr/db/schema";
+import {
+ customWidgetDefinitions,
+ customWidgetSecrets,
+ legacyCustomWidgetDefinitions,
+ legacyCustomWidgetSecrets,
+ users,
+} from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { BUNDLED_CUSTOM_WIDGETS } from "@homarr/custom-widgets/core";
import { describe, expect, test } from "vitest";
@@ -18,14 +24,28 @@ const session = {
},
expires: new Date().toISOString(),
} satisfies Session;
+const manageOnlySession = {
+ ...session,
+ user: { ...session.user, permissions: ["custom-widget-manage"] },
+} satisfies Session;
const jellyfin = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-jellyfin")?.widget;
if (!jellyfin) throw new Error("Jellyfin bundled widget is missing");
+const jellyfinDefaultSource = jellyfin.sources.default;
+if (!jellyfinDefaultSource) throw new Error("Jellyfin default source is missing");
function createCaller(db: ReturnType) {
return customWidgetRouter.createCaller({ db, deviceType: undefined, session });
}
+function createManageOnlyCaller(db: ReturnType) {
+ return customWidgetRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: manageOnlySession,
+ });
+}
+
async function prepareDatabase() {
const db = createDb();
await db.insert(users).values({ id: userId });
@@ -42,9 +62,69 @@ function rejectSecretInserts(db: ReturnType) {
);
}
+async function insertLegacyJellyfin(db: ReturnType) {
+ const id = "legacy-jellyfin";
+ const encryptedValue = "preserved.secret" as `${string}.${string}`;
+ await db.insert(legacyCustomWidgetDefinitions).values({
+ id,
+ name: "Legacy Jellyfin",
+ url: "http://jellyfin.local/Items/Counts",
+ authType: "apiKeyHeader",
+ headerName: "X-Emby-Token",
+ method: "GET",
+ displayType: "singleValue",
+ enabled: false,
+ creatorId: userId,
+ });
+ await db.insert(legacyCustomWidgetSecrets).values({
+ definitionId: id,
+ kind: "apiKey",
+ encryptedValue,
+ updatedAt: new Date(0),
+ });
+ return { id, encryptedValue };
+}
+
const secret = { sourceId: "default", kind: "apiKey" as const, value: "test-secret" };
describe("custom widget definition persistence", () => {
+ test("atomically replaces a confirmed legacy widget while preserving its stable ID and credential", async () => {
+ const db = await prepareDatabase();
+ const legacy = await insertLegacyJellyfin(db);
+
+ await createCaller(db).migrateLegacy({ id: legacy.id, widget: jellyfin, secrets: [] });
+
+ expect(await db.query.legacyCustomWidgetDefinitions.findMany()).toHaveLength(0);
+ expect(await db.query.customWidgetDefinitions.findFirst()).toMatchObject({
+ id: legacy.id,
+ enabled: false,
+ creatorId: userId,
+ });
+ expect(await db.query.customWidgetSecrets.findFirst()).toMatchObject({
+ definitionId: legacy.id,
+ sourceId: "default",
+ kind: "apiKey",
+ encryptedValue: legacy.encryptedValue,
+ });
+ });
+
+ test("keeps the legacy widget recoverable when confirmed replacement fails", async () => {
+ const db = await prepareDatabase();
+ const legacy = await insertLegacyJellyfin(db);
+ rejectSecretInserts(db);
+
+ await expect(createCaller(db).migrateLegacy({ id: legacy.id, widget: jellyfin, secrets: [] })).rejects.toThrow(
+ "forced secret insert failure",
+ );
+
+ expect(await db.query.legacyCustomWidgetDefinitions.findFirst()).toMatchObject({ id: legacy.id });
+ expect(await db.query.legacyCustomWidgetSecrets.findFirst()).toMatchObject({
+ definitionId: legacy.id,
+ encryptedValue: legacy.encryptedValue,
+ });
+ expect(await db.query.customWidgetDefinitions.findMany()).toHaveLength(0);
+ });
+
test("rolls back create when its secret cannot be stored", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
@@ -155,4 +235,99 @@ describe("custom widget definition persistence", () => {
widgetName: "Living room Jellyfin",
});
});
+
+ test("does not inherit a stored credential after a preview source binding changes", async () => {
+ const db = await prepareDatabase();
+ const caller = createCaller(db);
+ const created = await caller.create({ ...jellyfin, secrets: [secret] });
+ const changedDefinition = {
+ ...jellyfin,
+ sources: {
+ ...jellyfin.sources,
+ default: { ...jellyfinDefaultSource, baseUrl: "https://attacker.example" },
+ },
+ };
+
+ await expect(
+ createManageOnlyCaller(db).previewCreate({
+ definitionId: created.id,
+ definition: changedDefinition,
+ secrets: [],
+ }),
+ ).rejects.toMatchObject({ code: "FORBIDDEN" });
+ await expect(
+ caller.previewCreate({ definitionId: created.id, definition: changedDefinition, secrets: [secret] }),
+ ).resolves.toMatchObject({ success: true });
+ });
+
+ test("clears persisted credentials when a manage-only source edit changes its binding", async () => {
+ const db = await prepareDatabase();
+ const created = await createCaller(db).create({ ...jellyfin, secrets: [secret] });
+
+ await createManageOnlyCaller(db).sourceConfigure({
+ definitionId: created.id,
+ sourceId: "default",
+ baseUrl: "https://other.example/api",
+ networkScope: "public",
+ secrets: [],
+ });
+
+ expect(
+ await db.query.customWidgetSecrets.findMany({
+ where: eq(customWidgetSecrets.definitionId, created.id),
+ }),
+ ).toHaveLength(0);
+ });
+
+ test("clears persisted credentials when a general definition update changes its binding", async () => {
+ const db = await prepareDatabase();
+ const caller = createCaller(db);
+ const created = await caller.create({ ...jellyfin, secrets: [secret] });
+
+ await createManageOnlyCaller(db).update({
+ id: created.id,
+ sources: {
+ ...jellyfin.sources,
+ default: { ...jellyfinDefaultSource, baseUrl: "https://other.example/api" },
+ },
+ });
+
+ expect(
+ await db.query.customWidgetSecrets.findMany({
+ where: eq(customWidgetSecrets.definitionId, created.id),
+ }),
+ ).toHaveLength(0);
+ });
+
+ test("requires secret-write permission to create a stored-definition setup link", async () => {
+ const db = await prepareDatabase();
+ const created = await createCaller(db).create({ ...jellyfin, secrets: [] });
+
+ await expect(
+ createManageOnlyCaller(db).configurationRequestUser({ definitionId: created.id, sourceId: "default" }),
+ ).rejects.toMatchObject({ code: "FORBIDDEN" });
+ });
+
+ test("allows only one concurrent template patch for a revision", async () => {
+ const db = await prepareDatabase();
+ const caller = createCaller(db);
+ const created = await caller.create({ ...jellyfin, template: "Original", secrets: [] });
+ const read = await caller.readTemplate({ id: created.id });
+
+ const results = await Promise.allSettled([
+ caller.templatePatch({
+ id: created.id,
+ expectedRevision: read.revision,
+ edits: [{ startLine: 1, deleteCount: 1, replacementLines: ["First"] }],
+ }),
+ caller.templatePatch({
+ id: created.id,
+ expectedRevision: read.revision,
+ edits: [{ startLine: 1, deleteCount: 1, replacementLines: ["Second"] }],
+ }),
+ ]);
+
+ expect(results.filter(({ status }) => status === "fulfilled")).toHaveLength(1);
+ expect(results.filter(({ status }) => status === "rejected")).toHaveLength(1);
+ });
});
diff --git a/packages/api/src/router/test/custom-widget/legacy-migration.spec.ts b/packages/api/src/router/test/custom-widget/legacy-migration.spec.ts
new file mode 100644
index 000000000..f2e7f3c4d
--- /dev/null
+++ b/packages/api/src/router/test/custom-widget/legacy-migration.spec.ts
@@ -0,0 +1,36 @@
+import { describe, expect, test } from "vitest";
+
+import { buildLegacyCustomWidgetMigrationPrompt } from "../../custom-widget/legacy-migration";
+
+describe("legacy custom widget migration prompt", () => {
+ test("describes the legacy widget without exposing URL or body credentials", () => {
+ const prompt = buildLegacyCustomWidgetMigrationPrompt(
+ {
+ id: "legacy",
+ name: "Private service",
+ description: "Status",
+ iconUrl: null,
+ url: "https://admin:password@example.test/hooks/path-token?api_key=top-secret&mode=full",
+ authType: "bearer",
+ headerName: null,
+ method: "POST",
+ requestBody: JSON.stringify({ token: "body-secret", limit: 5 }),
+ displayType: "singleValue",
+ displayConfig: JSON.stringify({ json: { type: "singleValue", jsonPath: "$.status" } }),
+ enabled: true,
+ createdAt: new Date(0),
+ updatedAt: new Date(0),
+ creatorId: null,
+ },
+ ["apiKey"],
+ );
+
+ expect(prompt).toContain("homarr-custom-widget-v1");
+ expect(prompt).toContain("homarr-custom-widget-v2");
+ expect(prompt).toContain("configuredSecretKinds");
+ expect(prompt).not.toContain("top-secret");
+ expect(prompt).not.toContain("body-secret");
+ expect(prompt).not.toContain("admin:password");
+ expect(prompt).not.toContain("path-token");
+ });
+});
diff --git a/packages/api/src/router/test/custom-widget/preview-options.spec.ts b/packages/api/src/router/test/custom-widget/preview-options.spec.ts
index e14ae7400..536e49861 100644
--- a/packages/api/src/router/test/custom-widget/preview-options.spec.ts
+++ b/packages/api/src/router/test/custom-widget/preview-options.spec.ts
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
import { resolvePreviewRequestParams } from "../../custom-widget/preview-procedure-helpers";
+import { resolveOptionRequestValues } from "../../custom-widget/management-queries";
const loadRequest: CustomJsxRequest = {
source: "default",
@@ -32,3 +33,17 @@ describe("preview request option bindings", () => {
});
});
});
+
+describe("configuration option request bindings", () => {
+ it("resolves option configuration and runtime parameters in their own slots", () => {
+ const request: CustomJsxRequest = {
+ ...loadRequest,
+ path: "/environments/{option:environmentId}/containers/{param:containerId}",
+ };
+
+ expect(resolveOptionRequestValues(request, { environmentId: 7, containerId: 12, unrelated: true })).toEqual({
+ "option:environmentId": 7,
+ containerId: 12,
+ });
+ });
+});
diff --git a/packages/api/src/router/widgets/custom-api.ts b/packages/api/src/router/widgets/custom-api.ts
index 55d8da0e9..ac8462a73 100644
--- a/packages/api/src/router/widgets/custom-api.ts
+++ b/packages/api/src/router/widgets/custom-api.ts
@@ -4,7 +4,7 @@ import { z } from "zod/v4";
import { decryptSecret } from "@homarr/common/server";
import { eq } from "@homarr/db";
-import { boards, customWidgetDefinitions, items } from "@homarr/db/schema";
+import { boards, customWidgetDefinitions, items, legacyCustomWidgetDefinitions } from "@homarr/db/schema";
import type { BoardPermission } from "@homarr/definitions";
import {
getCustomWidgetConfirmation,
@@ -81,7 +81,19 @@ async function resolvePlacedDefinitionAsync(ctx: RouterContext, itemId: string)
where: eq(customWidgetDefinitions.id, itemOptions.definitionId),
with: { secrets: true },
});
- if (!stored) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget unavailable" });
+ if (!stored) {
+ const legacy = await ctx.db.query.legacyCustomWidgetDefinitions.findFirst({
+ where: eq(legacyCustomWidgetDefinitions.id, itemOptions.definitionId),
+ columns: { id: true },
+ });
+ if (legacy) {
+ throw new TRPCError({
+ code: "PRECONDITION_FAILED",
+ message: "LEGACY_CUSTOM_WIDGET_MIGRATION_REQUIRED",
+ });
+ }
+ throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget unavailable" });
+ }
if (!stored.enabled) throw new TRPCError({ code: "FORBIDDEN", message: "Widget is disabled" });
const definition = parseStoredCustomWidgetDefinition(stored);
diff --git a/packages/api/src/test/mcp.spec.ts b/packages/api/src/test/mcp.spec.ts
index 05ed7b43d..511d638cd 100644
--- a/packages/api/src/test/mcp.spec.ts
+++ b/packages/api/src/test/mcp.spec.ts
@@ -1,115 +1,209 @@
-import { expect, test, vi } from "vitest";
+import type { TRPCError } from "@trpc/server";
+import { describe, expect, test, vi } from "vitest";
import { extractToolsFromProcedures } from "trpc-to-mcp";
-import { appRouter } from "../router/app";
-import { boardRouter } from "../router/board";
-import { customWidgetRouter } from "../router/custom-widget/custom-widget-router";
-import { infoRouter } from "../router/info";
-import { inviteRouter } from "../router/invite";
-import { serverSettingsRouter } from "../router/serverSettings";
-import { createTRPCRouter } from "../trpc";
+import { mcpRouter } from "../mcp";
vi.mock("@homarr/auth", () => ({}));
-const mcpTestRouter = createTRPCRouter({
- app: appRouter,
- board: boardRouter,
- customWidget: customWidgetRouter,
- info: infoRouter,
- invite: inviteRouter,
- serverSettings: serverSettingsRouter,
+const MCP_TOOL_ALLOWLIST = {
+ query: [
+ "apiKeys_getAll",
+ "app_all",
+ "app_byId",
+ "app_getPaginated",
+ "app_search",
+ "bazarr_getBadges",
+ "beszel_getAlerts",
+ "beszel_getSystems",
+ "beszel_getSystemStats",
+ "board_getAllBoards",
+ "calendar_findAllEvents",
+ "customWidget_get",
+ "customWidget_getAuthoringPrompt",
+ "customWidget_getSkill",
+ "customWidget_list",
+ "customWidget_previewJournal",
+ "customWidget_previewQuery",
+ "customWidget_schema",
+ "customWidget_validate",
+ "customWidget_workshopGet",
+ "customWidget_workshopSearch",
+ "dnsHole_summary",
+ "docker_getContainers",
+ "docker_logs",
+ "downloads_getJobsAndStatuses",
+ "healthMonitoring_getClusterHealthStatus",
+ "healthMonitoring_getSystemHealthStatus",
+ "healthMonitoring_listStorageVolumes",
+ "icon_findIcons",
+ "info_getInfo",
+ "integration_all",
+ "integration_byId",
+ "integration_getKinds",
+ "integration_getMediaRequestOptions",
+ "integration_mediaRequestSearchTargets",
+ "integration_search",
+ "integration_searchInIntegration",
+ "integration_searchMediaRequests",
+ "invite_getAll",
+ "mediaRequests_getLatestRequests",
+ "mediaRequests_getStats",
+ "mediaServer_getCurrentStreams",
+ "patchmon_getStats",
+ "serverSettings_getBoardSettings",
+ "smartHome_entityState",
+ "widgetSecrets_getConfiguredKinds",
+ ],
+ mutation: [
+ "app_create",
+ "app_delete",
+ "app_update",
+ "board_addItem",
+ "board_changeBoardVisibility",
+ "board_createBoard",
+ "board_deleteBoard",
+ "board_duplicateBoard",
+ "board_renameBoard",
+ "board_savePartialBoardSettings",
+ "board_setHomeBoard",
+ "board_setMobileHomeBoard",
+ "customWidget_delete",
+ "customWidget_previewAction",
+ "customWidget_templatePatch",
+ "dnsHole_disable",
+ "dnsHole_enable",
+ "docker_removeAll",
+ "docker_restartAll",
+ "docker_startAll",
+ "docker_stopAll",
+ "downloads_pause",
+ "downloads_resume",
+ "integration_create",
+ "integration_delete",
+ "integration_requestMedia",
+ "invite_createInvite",
+ "invite_deleteInvite",
+ "mediaRequests_answerRequest",
+ "serverSettings_updateBoardSettings",
+ "smartHome_executeAutomation",
+ "smartHome_switchEntity",
+ ],
+ secret: [
+ "apiKeys_create",
+ "apiKeys_delete",
+ "customWidget_configurationRequestUser",
+ "customWidget_create",
+ "customWidget_previewCreate",
+ "customWidget_secretSet",
+ "customWidget_sourceConfigure",
+ "customWidget_update",
+ "customWidget_workshopInstall",
+ "widgetSecrets_deleteSecret",
+ "widgetSecrets_setSecret",
+ ],
+} as const;
+
+type ToolClassification = keyof typeof MCP_TOOL_ALLOWLIST;
+
+function expectedToolInventory() {
+ return Object.entries(MCP_TOOL_ALLOWLIST)
+ .flatMap(([classification, names]) =>
+ names.map((name) => ({ name, classification: classification as ToolClassification })),
+ )
+ .toSorted((left, right) => left.name.localeCompare(right.name));
+}
+
+function actualToolInventory() {
+ const secretTools = new Set(MCP_TOOL_ALLOWLIST.secret);
+ const procedures = mcpRouter["_def"].procedures as unknown as Record<
+ string,
+ { _def?: { type?: "query" | "mutation" } }
+ >;
+ return extractToolsFromProcedures(mcpRouter)
+ .map((tool) => {
+ const procedure = procedures[tool.pathInRouter.join(".")];
+ const type = procedure?.["_def"]?.type;
+ if (type !== "query" && type !== "mutation") throw new Error(`Unable to classify MCP tool '${tool.name}'`);
+ return {
+ name: tool.name,
+ classification: secretTools.has(tool.name) ? ("secret" as const) : type,
+ };
+ })
+ .toSorted((left, right) => left.name.localeCompare(right.name));
+}
+
+describe("production MCP router", () => {
+ test("matches the reviewed tool and permission inventory exactly", () => {
+ expect(actualToolInventory()).toEqual(expectedToolInventory());
+ });
+
+ test("keeps every secret-bearing tool behind a mutation procedure", () => {
+ const tools = extractToolsFromProcedures(mcpRouter);
+ const procedures = mcpRouter["_def"].procedures as unknown as Record<
+ string,
+ { _def?: { type?: "query" | "mutation" } }
+ >;
+ for (const name of MCP_TOOL_ALLOWLIST.secret) {
+ const tool = tools.find((candidate) => candidate.name === name);
+ expect(tool, `Missing reviewed secret tool '${name}'`).toBeDefined();
+ const procedure = tool ? procedures[tool.pathInRouter.join(".")] : undefined;
+ expect(procedure?.["_def"]?.type, `Secret tool '${name}' must remain a mutation`).toBe("mutation");
+ }
+ });
+
+ test("gives every production tool a description", () => {
+ const tools = extractToolsFromProcedures(mcpRouter);
+ for (const tool of tools) expect(tool.description, `Tool ${tool.name} should have a description`).toBeTruthy();
+ expect(tools.find((tool) => tool.name === "customWidget_getAuthoringPrompt")?.description).toBe(
+ "Get the current Custom Widget authoring instructions.",
+ );
+ });
});
-test("MCP tools should be extractable from the router", () => {
- const act = () => extractToolsFromProcedures(mcpTestRouter);
+describe("custom widget authoring procedure access", () => {
+ const unauthenticatedCaller = mcpRouter.createCaller({
+ db: null as never,
+ deviceType: undefined,
+ session: null,
+ });
- expect(act).not.toThrow();
-});
-
-test("MCP tools should contain expected procedures", () => {
- const tools = extractToolsFromProcedures(mcpTestRouter);
- const toolNames = tools.map((tool) => tool.name);
-
- expect(tools.length).toBeGreaterThan(0);
- expect(toolNames).toContain("app_all");
- expect(toolNames).toContain("app_byId");
- expect(toolNames).toContain("app_create");
- expect(toolNames).toContain("board_savePartialBoardSettings");
- expect(toolNames).toContain("board_duplicateBoard");
- expect(toolNames).toContain("customWidget_schema");
- expect(toolNames).toContain("customWidget_validate");
- expect(toolNames).toContain("customWidget_list");
- expect(toolNames).toContain("customWidget_get");
- expect(toolNames).toContain("customWidget_templatePatch");
- expect(toolNames).toContain("customWidget_create");
- expect(toolNames).toContain("customWidget_update");
- expect(toolNames).toContain("customWidget_delete");
- expect(toolNames).toContain("customWidget_previewCreate");
- expect(toolNames).toContain("customWidget_previewQuery");
- expect(toolNames).toContain("customWidget_previewAction");
- expect(toolNames).toContain("customWidget_previewJournal");
- expect(toolNames).toContain("customWidget_secretSet");
- expect(toolNames).toContain("customWidget_sourceConfigure");
- expect(toolNames).toContain("customWidget_configurationRequestUser");
- expect(toolNames).toContain("customWidget_workshopSearch");
- expect(toolNames).toContain("customWidget_workshopGet");
- expect(toolNames).toContain("customWidget_workshopInstall");
- expect(toolNames).toContain("customWidget_getAuthoringPrompt");
- expect(toolNames).toContain("customWidget_getSkill");
- expect(toolNames).not.toContain("customWidget_import");
- expect(toolNames).not.toContain("customWidget_export");
- expect(toolNames).not.toContain("customWidget_simulatePreviewAction");
- expect(toolNames).toContain("info_getInfo");
- expect(toolNames).toContain("invite_getAll");
- expect(toolNames).toContain("serverSettings_getBoardSettings");
- expect(toolNames).toContain("serverSettings_updateBoardSettings");
-});
-
-test("MCP tools should have descriptions", () => {
- const tools = extractToolsFromProcedures(mcpTestRouter);
-
- for (const tool of tools) {
- expect(tool.description, `Tool ${tool.name} should have a description`).toBeTruthy();
- }
-
- expect(tools.find((tool) => tool.name === "customWidget_getAuthoringPrompt")?.description).toBe(
- "Get the current Custom Widget authoring instructions.",
- );
-});
-
-test("custom widget public procedures remain available", () => {
- expect(Object.keys(customWidgetRouter["_def"].procedures)).toEqual(
- expect.arrayContaining([
- "list",
- "get",
- "available",
- "create",
- "update",
- "toggleEnabled",
- "delete",
- "duplicate",
- "export",
- "import",
- "schema",
- "validate",
- "readTemplate",
- "writeTemplate",
- "templatePatch",
- "previewCreate",
- "previewGet",
- "previewQuery",
- "previewAction",
- "setPreviewLiveActions",
- "previewJournal",
- "secretSet",
- "sourceConfigure",
- "configurationRequestUser",
- "workshopSearch",
- "workshopGet",
- "workshopInstall",
- "secretClear",
- "getAuthoringPrompt",
- "getSkill",
- ]),
- );
+ test("executes promised public procedures without a session", async () => {
+ await expect(unauthenticatedCaller.customWidget.schema()).resolves.toMatchObject({ type: "object" });
+ await expect(unauthenticatedCaller.customWidget.getAuthoringPrompt()).resolves.toMatchObject({
+ version: 2,
+ prompt: expect.any(String),
+ });
+ await expect(unauthenticatedCaller.customWidget.getSkill()).resolves.toMatchObject({
+ name: expect.any(String),
+ });
+ });
+
+ test("rejects protected and permission-gated authoring procedures without a session", async () => {
+ const calls = [
+ unauthenticatedCaller.customWidget.list(),
+ unauthenticatedCaller.customWidget.validate({ widget: {} }),
+ unauthenticatedCaller.customWidget.secretSet({
+ definitionId: "widget",
+ secret: { sourceId: "default", kind: "apiKey", value: "secret" },
+ }),
+ ];
+ for (const call of calls) {
+ await expect(call).rejects.toEqual(expect.objectContaining>({ code: "UNAUTHORIZED" }));
+ }
+ });
+
+ test("enforces the dedicated permission on secret tools", async () => {
+ const callerWithoutSecretPermission = mcpRouter.createCaller({
+ db: null as never,
+ deviceType: undefined,
+ session: { user: { id: "user-1", permissions: ["custom-widget-manage"] } } as never,
+ });
+ await expect(
+ callerWithoutSecretPermission.customWidget.secretSet({
+ definitionId: "widget",
+ secret: { sourceId: "default", kind: "apiKey", value: "secret" },
+ }),
+ ).rejects.toEqual(expect.objectContaining>({ code: "FORBIDDEN" }));
+ });
});
diff --git a/packages/custom-widgets/src/core/custom-jsx-schema.ts b/packages/custom-widgets/src/core/custom-jsx-schema.ts
index 4e878a64e..b5529a298 100644
--- a/packages/custom-widgets/src/core/custom-jsx-schema.ts
+++ b/packages/custom-widgets/src/core/custom-jsx-schema.ts
@@ -1,6 +1,7 @@
import { z } from "zod/v4";
import { validateCustomJsxTemplate } from "../jsx/analyzer";
+import { CUSTOM_JSX_BINDING_IDENTIFIER_PATTERN } from "../jsx/policy";
import { validateCredentialFreeExport, validatePrototypeKeys } from "./definition-security";
import { customWidgetOptionsSchema } from "./options-schema";
import {
@@ -20,7 +21,10 @@ export const customWidgetBindingIdentifierSchema = z
.string()
.min(1)
.max(64)
- .regex(/^[A-Za-z][A-Za-z0-9_-]*$/u, "Binding names must start with a letter and contain letters, numbers, - or _");
+ .regex(
+ CUSTOM_JSX_BINDING_IDENTIFIER_PATTERN,
+ "Binding names must start with a letter and contain letters, numbers, - or _",
+ );
export const customJsxTemplateSchema = z
.string()
@@ -77,8 +81,8 @@ export const customWidgetDefinitionSchema = z
})
.superRefine((definition, ctx) => {
validateRequests(definition, ctx);
+ validateCaseInsensitiveSourceIds(definition.sources, ctx);
validateTemplateRequests(definition.template, definition.requests, ctx);
- validateTemplateBindings(definition.template, ctx);
validateCredentialFreeExport(definition, ctx);
validatePrototypeKeys(definition, ctx);
});
@@ -144,6 +148,23 @@ function validateRequests(definition: Definition, ctx: z.RefinementCtx) {
}
}
+function validateCaseInsensitiveSourceIds(sources: Definition["sources"], ctx: z.RefinementCtx) {
+ const seen = new Map();
+ for (const sourceId of Object.keys(sources)) {
+ const normalized = sourceId.toLowerCase();
+ const existing = seen.get(normalized);
+ if (existing) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["sources", sourceId],
+ message: `Source ID '${sourceId}' conflicts case-insensitively with '${existing}'`,
+ });
+ } else {
+ seen.set(normalized, sourceId);
+ }
+ }
+}
+
function validateTemplateRequests(template: string, requests: Record, ctx: z.RefinementCtx) {
for (const match of template.matchAll(/<(SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/gu)) {
const component = match[1] as "SubFetch" | "ActionButton" | "ToggleSwitch";
@@ -169,15 +190,6 @@ function validateTemplateRequests(template: string, requests: Record]*?)?\bbind\s*=\s*(?:"([^"]+)"|'([^']+)'|\{)/gu)) {
- const inputName = match[1] ?? match[2];
- if (!inputName) ctx.addIssue({ code: "custom", path: ["template"], message: "bind must use a literal input name" });
- else if (!customWidgetBindingIdentifierSchema.safeParse(inputName).success)
- ctx.addIssue({ code: "custom", path: ["template"], message: `Invalid bind input name '${inputName}'` });
- }
-}
-
export type HomarrCustomWidgetV2 = z.infer;
export type HomarrCustomWidgetV2Input = z.input;
export const customWidgetCreateSchema = customWidgetDefinitionSchema.safeExtend({
diff --git a/packages/custom-widgets/src/jsx/analyzer-jsx.ts b/packages/custom-widgets/src/jsx/analyzer-jsx.ts
index f03f20e7b..a68f045f7 100644
--- a/packages/custom-widgets/src/jsx/analyzer-jsx.ts
+++ b/packages/custom-widgets/src/jsx/analyzer-jsx.ts
@@ -7,7 +7,7 @@ import {
import type { AstNode } from "./analyzer-ast";
import { containsEscapingCallback, nodeOf, nodesOf } from "./analyzer-ast";
import { closestCustomJsxComponentName, customJsxTagName, isSafeLiteralCustomJsxUrl } from "./analyzer-language";
-import { CUSTOM_JSX_URL_PROPS, isBlockedCustomJsxProp } from "./policy";
+import { CUSTOM_JSX_BINDING_IDENTIFIER_PATTERN, CUSTOM_JSX_URL_PROPS, isBlockedCustomJsxProp } from "./policy";
import { getInvalidCustomJsxPropValueReason } from "./runtime-component-policy";
interface AnalyzerJsxContext {
@@ -75,6 +75,7 @@ export function analyzeCustomJsxElement(
} else if (resolvedName && attributeName === "bind" && !customJsxBindableComponentNames.has(resolvedName)) {
context.add(attribute, `BINDING_UNAVAILABLE: '${name}' does not have a declarative binding adapter`, "warning");
}
+ if (attributeName === "bind") analyzeBindAttribute(attribute, context);
analyzeAttributeValue(resolvedName, attributeName, attribute, depth, bindings, context);
}
@@ -88,6 +89,15 @@ export function analyzeCustomJsxElement(
});
}
+function analyzeBindAttribute(attribute: AstNode, context: AnalyzerJsxContext): void {
+ const value = nodeOf(attribute.value);
+ if (value?.type !== "Literal" || typeof value.value !== "string") {
+ context.add(attribute, "bind must use a literal input name");
+ } else if (!CUSTOM_JSX_BINDING_IDENTIFIER_PATTERN.test(value.value)) {
+ context.add(attribute, `Invalid bind input name '${value.value}'`);
+ }
+}
+
function analyzeAttributeValue(
componentName: string | null,
attributeName: string,
diff --git a/packages/custom-widgets/src/jsx/analyzer.ts b/packages/custom-widgets/src/jsx/analyzer.ts
index f48d291c0..bcbc09b66 100644
--- a/packages/custom-widgets/src/jsx/analyzer.ts
+++ b/packages/custom-widgets/src/jsx/analyzer.ts
@@ -23,6 +23,7 @@ import {
CUSTOM_JSX_CALLBACK_METHODS,
CUSTOM_JSX_SAFE_VALUE_METHODS,
} from "./safe-language-policy";
+import { isSafeRegexLiteral } from "./regex-policy";
export type { CustomJsxTemplateDiagnostic } from "./analyzer-diagnostics";
@@ -153,7 +154,7 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
case "Literal":
if (typeof node.value === "bigint") add(node, "BIGINT_NOT_SUPPORTED: Use Number for widget values");
if (node.regex !== undefined && !isSafeRegexLiteral(node.regex))
- add(node, "UNSAFE_REGEX: Use a bounded regex without lookbehind, backreferences, or nested quantifiers");
+ add(node, "UNSAFE_REGEX: Use a conservative regex without repeated alternatives or variable quantifiers");
return;
case "Identifier": {
const name = String(node.name ?? "");
@@ -232,7 +233,7 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
}
case "CallExpression": {
const callee = nodeOf(node.callee);
- const arguments_ = nodesOf(node.arguments);
+ const callArguments = nodesOf(node.arguments);
if (node.optional) {
add(node, "CALL_TARGET_NOT_ALLOWED: Optional calls are not supported");
}
@@ -251,7 +252,7 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
add(callee, "CALL_TARGET_NOT_ALLOWED: Only documented safe helpers and value methods can be called");
}
if (method && CUSTOM_JSX_CALLBACK_METHODS.has(method)) {
- const callback = arguments_[0];
+ const callback = callArguments[0];
if (!callback) {
add(node, `CALLBACK_VALUE_NOT_ALLOWED: '${method}' requires a callback`);
} else if (["reduce", "sort"].includes(method) && callback.type !== "ArrowFunctionExpression") {
@@ -260,7 +261,7 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
add(callback, `CALLBACK_VALUE_NOT_ALLOWED: '${method}' requires an inline arrow or safe helper callback`);
}
}
- arguments_.forEach((argument, argumentIndex) => {
+ callArguments.forEach((argument, argumentIndex) => {
if (argument.type === "SpreadElement") add(argument, "Spread call arguments are not supported");
else if (
argument.type === "ArrowFunctionExpression" &&
@@ -318,11 +319,3 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
return diagnostics;
}
-
-function isSafeRegexLiteral(regex: unknown): boolean {
- if (!regex || typeof regex !== "object") return false;
- const { pattern, flags } = regex as { pattern?: unknown; flags?: unknown };
- if (typeof pattern !== "string" || pattern.length > 128 || typeof flags !== "string" || /[^gimsu]/u.test(flags))
- return false;
- return !/\\[1-9]|\(\?<[=!]|(?:\+|\*|\{\d+(?:,\d*)?\})\)?(?:\+|\*|\{)/u.test(pattern);
-}
diff --git a/packages/custom-widgets/src/jsx/bindings.ts b/packages/custom-widgets/src/jsx/bindings.ts
index 216fe6fb9..6f54c31de 100644
--- a/packages/custom-widgets/src/jsx/bindings.ts
+++ b/packages/custom-widgets/src/jsx/bindings.ts
@@ -1,14 +1,85 @@
import { createSafeCallable } from "./safe-bindings";
+import { SafeJsxBudgetError, SafeJsxError } from "./interpreter-foundation";
+
+export const CUSTOM_JSX_DATA_LIMITS = Object.freeze({
+ depth: 32,
+ nodes: 50_000,
+ stringLength: 200_000,
+});
+
+interface SanitizeTask {
+ value: unknown;
+ depth: number;
+ assign(value: unknown): void;
+}
function sanitizeData(value: unknown): unknown {
- if (value === null || typeof value !== "object") return value;
- if (Array.isArray(value)) return value.map(sanitizeData);
- const safe: Record = Object.create(null);
- for (const [key, child] of Object.entries(value)) {
- if (["constructor", "__proto__", "prototype"].includes(key)) continue;
- safe[key] = sanitizeData(child);
+ let result: unknown;
+ let nodes = 0;
+ const seen = new WeakSet