merge: stack widget modernization on dnd kit

This commit is contained in:
Thomas Camlong committed 2026-08-08 20:40:26 +02:00
commit 9edeba5cd2
940 files changed
+104475 -33649

No files matched your search

+8 -3
View File
@@ -9,13 +9,16 @@
"exports": {
".": "./src/index.ts",
"./client": "./src/client.ts",
"./board-server": "./src/board-server.ts",
"./query-cache": "./src/query-cache.ts",
"./server-settings-server": "./src/server-settings-server.ts",
"./user-server": "./src/user-server.ts",
"./server": "./src/server.ts",
"./websocket": "./src/websocket.ts",
"./shared": "./src/shared.ts",
"./open-api": "./src/open-api.ts",
"./custom-widget/display-data": "./src/router/custom-widget/display-data.ts",
"./mcp": "./src/mcp.ts"
"./mcp": "./src/mcp.ts",
"./custom-widget-configuration": "./src/custom-widget-configuration.ts"
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
@@ -30,6 +33,7 @@
"@homarr/core": "workspace:^0.1.0",
"@homarr/cron-job-status": "workspace:^0.1.0",
"@homarr/cron-jobs": "workspace:^0.1.0",
"@homarr/custom-widgets": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/docker": "workspace:^0.1.0",
@@ -43,13 +47,13 @@
"@homarr/server-settings": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/workshop": "workspace:^0.1.0",
"@kubernetes/client-node": "catalog:",
"@tanstack/react-query": "catalog:",
"@trpc/client": "catalog:",
"@trpc/react-query": "catalog:",
"@trpc/server": "catalog:",
"@trpc/tanstack-react-query": "catalog:",
"jsonpath-plus": "catalog:",
"lodash.clonedeep": "catalog:",
"next": "catalog:",
"react": "catalog:",
@@ -57,6 +61,7 @@
"superjson": "catalog:",
"trpc-to-mcp": "catalog:",
"trpc-to-openapi": "catalog:",
"undici": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
+16
View File
@@ -0,0 +1,16 @@
import { cache } from "react";
import { createRscTrpcContext } from "./rsc-context";
import { boardRouter } from "./router/board";
const createBoardCallerAsync = async () => boardRouter.createCaller(await createRscTrpcContext());
export const getBoardByNameAsync = cache(async (name: string) => {
const caller = await createBoardCallerAsync();
return await caller.getBoardByName({ name });
});
export const getHomeBoardAsync = cache(async () => {
const caller = await createBoardCallerAsync();
return await caller.getHomeBoard();
});
@@ -0,0 +1,7 @@
export * from "./router/custom-widget/configuration-requests";
export { configurePreviewSessionSource } from "./router/custom-widget/preview-sessions";
export {
parseStoredCustomWidgetDefinition,
serializeCustomWidgetDefinition,
} from "./router/custom-widget/stored-definition";
export { configureCustomWidgetSourceFromRequest } from "./router/custom-widget/secret-persistence";
+18
View File
@@ -1,16 +1,34 @@
import { z } from "zod/v4";
import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env";
import { normalizeHttpUrl } from "@homarr/workshop/schema";
const publicHttpUrl = (variableName: string) =>
z.string().transform((value, context) => {
try {
return normalizeHttpUrl(value, variableName);
} catch (error) {
context.addIssue({
code: "custom",
message: error instanceof Error ? error.message : `${variableName} is invalid`,
});
return z.NEVER;
}
});
export const env = createEnv({
server: {
KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(),
DEMO_MODE: createBooleanSchema(false),
DEMO_READ_ONLY: createBooleanSchema(true),
HOMARR_WEBSITE_URL: publicHttpUrl("HOMARR_WEBSITE_URL").default("https://homarr.dev"),
WORKSHOP_API_URL: publicHttpUrl("WORKSHOP_API_URL").optional(),
},
runtimeEnv: {
KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME,
DEMO_MODE: process.env.DEMO_MODE,
DEMO_READ_ONLY: process.env.DEMO_READ_ONLY,
HOMARR_WEBSITE_URL: process.env.HOMARR_WEBSITE_URL,
WORKSHOP_API_URL: process.env.WORKSHOP_API_URL,
},
});
+2
View File
@@ -13,6 +13,7 @@ import { bazarrRouter } from "./router/widgets/bazarr";
import { serverSettingsRouter } from "./router/serverSettings";
import { beszelRouter } from "./router/widgets/beszel";
import { calendarRouter } from "./router/widgets/calendar";
import { customWidgetRouter } from "./router/custom-widget/custom-widget-router";
import { dnsHoleRouter } from "./router/widgets/dns-hole";
import { downloadsRouter } from "./router/widgets/downloads";
import { healthMonitoringRouter } from "./router/widgets/health-monitoring";
@@ -35,6 +36,7 @@ export const mcpRouter = createTRPCRouter({
serverSettings: serverSettingsRouter,
beszel: beszelRouter,
calendar: calendarRouter,
customWidget: customWidgetRouter,
dnsHole: dnsHoleRouter,
downloads: downloadsRouter,
healthMonitoring: healthMonitoringRouter,
+46 -16
View File
@@ -1,22 +1,54 @@
import type { QueryKey } from "@tanstack/react-query";
export const queryCacheDefaultStaleTimeMs = 0;
// RSC-prefetched data must survive the hydration pass without an immediate
// duplicate fetch. Widget-specific intervals still control live refreshes.
export const queryCacheDefaultStaleTimeMs = 30_000;
export const queryCacheDefaultRefetchIntervalMs = 30_000;
export const queryCacheDefaultGcTimeMs = 1000 * 60 * 60 * 24;
export const queryCacheMaxValueBytes = 1024 * 1024;
export const queryCacheStoragePrefix = "homarr-widget-query";
export const queryCacheBuster = "v2-superjson";
export const queryCacheDefaultGcTimeMs = 1000 * 60 * 5;
export const queryCacheMetadataStaleTimeMs = 1000 * 60 * 5;
let activeQueryCacheBoardId: string | null = null;
const widgetDataQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
export const setActiveQueryCacheBoardId = (boardId: string | null) => {
activeQueryCacheBoardId = boardId;
export const isWidgetDataTrpcPath = (path: string) => {
if (path.startsWith("widget.")) return !excludedWidgetPaths.has(path);
return widgetDataQueryPaths.has(path);
};
export const getActiveQueryCacheBoardId = () => activeQueryCacheBoardId;
const persistableQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
// These supporting queries can be numerous on a dashboard. Give them a
// predictable refresh policy instead of refetching all of them after every tab
// focus/reconnect event. Metadata can refresh after a bounded stale window;
// Docker state remains live on a single shared timer.
export const dashboardSupportingQueryPolicies = [
{
queryKey: [["app", "byId"]],
refetchInterval: false,
staleTime: queryCacheMetadataStaleTimeMs,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
},
{
queryKey: [["app", "byIds"]],
refetchInterval: false,
staleTime: queryCacheMetadataStaleTimeMs,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
},
{
queryKey: [["integration", "byIds"]],
refetchInterval: false,
staleTime: queryCacheMetadataStaleTimeMs,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
},
{
queryKey: [["docker", "getContainers"]],
refetchInterval: queryCacheDefaultRefetchIntervalMs,
staleTime: queryCacheDefaultStaleTimeMs,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
] as const;
const getTrpcPathFromQueryKey = (queryKey: QueryKey) => {
const first = queryKey[0];
@@ -26,11 +58,9 @@ const getTrpcPathFromQueryKey = (queryKey: QueryKey) => {
return parts.length > 0 ? parts : null;
};
export const isPersistableWidgetQueryKey = (queryKey: QueryKey) => {
export const isWidgetDataQueryKey = (queryKey: QueryKey) => {
const path = getTrpcPathFromQueryKey(queryKey);
if (!path) return false;
if (path[0] === "widget") return !excludedWidgetPaths.has(path.join("."));
return persistableQueryPaths.has(path.join("."));
return isWidgetDataTrpcPath(path.join("."));
};
-1
View File
@@ -24,7 +24,6 @@ export const appRouter = createTRPCRouter({
cronJobs: lazy(() => import("./router/cron-jobs").then((mod) => mod.cronJobsRouter)),
apiKeys: lazy(() => import("./router/apiKeys").then((mod) => mod.apiKeysRouter)),
media: lazy(() => import("./router/medias/media-router").then((mod) => mod.mediaRouter)),
queryCache: lazy(() => import("./router/query-cache").then((mod) => mod.queryCacheRouter)),
updateChecker: lazy(() => import("./router/update-checker").then((mod) => mod.updateCheckerRouter)),
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)),
+1 -1
View File
@@ -173,7 +173,7 @@ export const appRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
"Create a new app (bookmark/shortcut to a service). REQUIRED: name (string), iconUrl (icon URL string), href (app URL, http/https or blank). OPTIONAL: description (string or null), pingUrl (URL to check reachability, or empty string)",
"Create a new app (bookmark/shortcut to a service). REQUIRED: name (string), iconUrl (icon URL string), href (app URL or custom URI scheme such as vscode://, or blank). OPTIONAL: description (string or null), pingUrl (URL to check reachability, or empty string)",
},
})
.mutation(async ({ ctx, input }) => {
+286 -144
View File
@@ -29,12 +29,16 @@ import {
sections,
users,
} from "@homarr/db/schema";
import type { WidgetKind } from "@homarr/definitions";
import type { BoardLane, WidgetKind } from "@homarr/definitions";
import {
boardLanes,
emptySuperJSON,
everyoneGroup,
getBoardLaneColumnCount,
getRootSectionLane,
getPermissionsWithChildren,
getPermissionsWithParents,
rootSectionOffsets,
widgetDefaultSizes,
widgetKinds,
} from "@homarr/definitions";
@@ -61,6 +65,10 @@ import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access";
import {
throwIfCustomWidgetBoardDuplicationForbidden,
throwIfCustomWidgetPlacementChangeForbidden,
} from "./board/custom-widget-placement-access";
import { generateResponsiveGridFor } from "./board/grid-algorithm";
import { validateTimetableOptionsChangeAsync } from "./widgets/timetable";
@@ -318,6 +326,8 @@ export const boardRouter = createTRPCRouter({
id: createId(),
name: "Base",
columnCount: input.columnCount,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 0,
boardId,
});
@@ -373,6 +383,7 @@ export const boardRouter = createTRPCRouter({
}
const { sections: boardSections, items: boardItems, layouts: boardLayouts, ...boardProps } = board;
throwIfCustomWidgetBoardDuplicationForbidden(ctx.session.user.permissions.includes("admin"), boardItems);
const newBoardId = createId();
@@ -686,6 +697,11 @@ export const boardRouter = createTRPCRouter({
saveLayouts: protectedProcedure.input(boardSaveLayoutsSchema).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
const requiredGutterRoots = (["left", "right"] as const).filter((lane) =>
input.layouts.some((layout) => getBoardLaneColumnCount(layout, lane) > 0),
);
await ensureGutterRootSectionsAsync(ctx.db, input.id, requiredGutterRoots);
const board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
const addedLayouts = filterAddedItems(input.layouts, board.layouts);
@@ -697,14 +713,6 @@ export const boardRouter = createTRPCRouter({
for (const addedLayout of addedLayouts) {
const layoutId = createId();
layoutsToInsert.push({
id: layoutId,
name: addedLayout.name,
columnCount: addedLayout.columnCount,
breakpoint: addedLayout.breakpoint,
boardId: board.id,
});
const sortedLayouts = board.layouts.toSorted((layoutA, layoutB) => layoutA.columnCount - layoutB.columnCount);
// Fallback to biggest if none exists with columnCount bigger than addedLayout.columnCount
const layoutToClone =
@@ -715,13 +723,27 @@ export const boardRouter = createTRPCRouter({
previous: {
layoutId: layoutToClone.id,
columnCount: layoutToClone.columnCount,
leftGutterColumnCount: layoutToClone.leftGutterColumnCount,
rightGutterColumnCount: layoutToClone.rightGutterColumnCount,
},
current: {
layoutId,
columnCount: addedLayout.columnCount,
leftGutterColumnCount: addedLayout.leftGutterColumnCount,
rightGutterColumnCount: addedLayout.rightGutterColumnCount,
},
});
layoutsToInsert.push({
id: layoutId,
name: addedLayout.name,
columnCount: addedLayout.columnCount,
leftGutterColumnCount: addedLayout.leftGutterColumnCount,
rightGutterColumnCount: addedLayout.rightGutterColumnCount,
breakpoint: addedLayout.breakpoint,
boardId: board.id,
});
itemSectionLayoutsToInsert.push(...updatedBoardLayout.itemSectionLayouts);
sectionLayoutsToInsert.push(...updatedBoardLayout.sectionLayouts);
}
@@ -742,16 +764,25 @@ export const boardRouter = createTRPCRouter({
for (const updatedLayout of updatedLayouts) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const dbLayout = board.layouts.find((layout) => layout.id === updatedLayout.id)!;
let updatedBoardLayout: ReturnType<typeof getUpdatedBoardLayout> | undefined;
if (dbLayout.columnCount !== updatedLayout.columnCount) {
const updatedBoardLayout = getUpdatedBoardLayout(board, {
if (
dbLayout.columnCount !== updatedLayout.columnCount ||
dbLayout.leftGutterColumnCount !== updatedLayout.leftGutterColumnCount ||
dbLayout.rightGutterColumnCount !== updatedLayout.rightGutterColumnCount
) {
updatedBoardLayout = getUpdatedBoardLayout(board, {
previous: {
layoutId: dbLayout.id,
columnCount: dbLayout.columnCount,
leftGutterColumnCount: dbLayout.leftGutterColumnCount,
rightGutterColumnCount: dbLayout.rightGutterColumnCount,
},
current: {
layoutId: dbLayout.id,
columnCount: updatedLayout.columnCount,
leftGutterColumnCount: updatedLayout.leftGutterColumnCount,
rightGutterColumnCount: updatedLayout.rightGutterColumnCount,
},
});
@@ -797,6 +828,8 @@ export const boardRouter = createTRPCRouter({
.set({
name: updatedLayout.name,
columnCount: updatedLayout.columnCount,
leftGutterColumnCount: updatedLayout.leftGutterColumnCount,
rightGutterColumnCount: updatedLayout.rightGutterColumnCount,
breakpoint: updatedLayout.breakpoint,
})
.where(eq(layouts.id, updatedLayout.id));
@@ -856,6 +889,11 @@ export const boardRouter = createTRPCRouter({
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
const dbBoard = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: ctx.session.user.permissions.includes("admin"),
submittedItems: input.items,
storedItems: dbBoard.items,
});
for (const item of input.items) {
if (item.kind !== "timetable") continue;
@@ -876,32 +914,32 @@ export const boardRouter = createTRPCRouter({
addedSections.map((section) => ({
id: section.id,
kind: section.kind,
yOffset: section.kind !== "dynamic" ? section.yOffset : null,
xOffset: section.kind === "dynamic" ? null : 0,
options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
name: "name" in section ? section.name : null,
yOffset: section.kind === "empty" ? section.yOffset : null,
xOffset: section.kind === "empty" ? section.xOffset : null,
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
name: null,
boardId: dbBoard.id,
})),
);
if (addedSections.some((section) => section.kind === "dynamic")) {
await transaction.insert(schema.sectionLayouts).values(
addedSections
.filter((section) => section.kind === "dynamic")
.flatMap((section) =>
section.layouts.map(
(sectionLayout): InferInsertModel<typeof schema.sectionLayouts> => ({
layoutId: sectionLayout.layoutId,
sectionId: section.id,
parentSectionId: sectionLayout.parentSectionId,
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
}),
),
),
const sectionLayoutsToInsert = addedSections
.filter((section) => section.kind === "container")
.flatMap((section) =>
section.layouts.map(
(sectionLayout): InferInsertModel<typeof schema.sectionLayouts> => ({
layoutId: sectionLayout.layoutId,
sectionId: section.id,
parentSectionId: sectionLayout.parentSectionId,
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
}),
),
);
if (sectionLayoutsToInsert.length > 0) {
await transaction.insert(schema.sectionLayouts).values(sectionLayoutsToInsert);
}
}
@@ -998,18 +1036,17 @@ export const boardRouter = createTRPCRouter({
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
for (const section of updatedSections) {
const prev = dbBoard.sections.find((dbSection) => dbSection.id === section.id);
await transaction
.update(schema.sections)
.set({
yOffset: prev?.kind !== "dynamic" && "yOffset" in section ? section.yOffset : null,
xOffset: prev?.kind !== "dynamic" && "yOffset" in section ? 0 : null,
options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
name: prev?.kind === "category" && "name" in section ? section.name : null,
yOffset: section.kind === "empty" ? section.yOffset : null,
xOffset: section.kind === "empty" ? section.xOffset : null,
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
name: null,
})
.where(eq(schema.sections.id, section.id));
if (section.kind !== "dynamic") continue;
if (section.kind !== "container") continue;
for (const sectionLayout of section.layouts) {
await transaction
@@ -1076,36 +1113,33 @@ export const boardRouter = createTRPCRouter({
addedSections.map((section) => ({
id: section.id,
kind: section.kind,
yOffset: section.kind !== "dynamic" ? section.yOffset : null,
xOffset: section.kind === "dynamic" ? null : 0,
options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
name: "name" in section ? section.name : null,
yOffset: section.kind === "empty" ? section.yOffset : null,
xOffset: section.kind === "empty" ? section.xOffset : null,
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
name: null,
boardId: dbBoard.id,
})),
)
.run();
if (addedSections.some((section) => section.kind === "dynamic")) {
transaction
.insert(sectionLayouts)
.values(
addedSections
.filter((section) => section.kind === "dynamic")
.flatMap((section) =>
section.layouts.map(
(sectionLayout): InferInsertModel<typeof sectionLayouts> => ({
layoutId: sectionLayout.layoutId,
sectionId: section.id,
parentSectionId: sectionLayout.parentSectionId,
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
}),
),
),
)
.run();
const sectionLayoutsToInsert = addedSections
.filter((section) => section.kind === "container")
.flatMap((section) =>
section.layouts.map(
(sectionLayout): InferInsertModel<typeof sectionLayouts> => ({
layoutId: sectionLayout.layoutId,
sectionId: section.id,
parentSectionId: sectionLayout.parentSectionId,
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
}),
),
);
if (sectionLayoutsToInsert.length > 0) {
transaction.insert(sectionLayouts).values(sectionLayoutsToInsert).run();
}
}
@@ -1208,19 +1242,18 @@ export const boardRouter = createTRPCRouter({
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
for (const section of updatedSections) {
const prev = dbBoard.sections.find((dbSection) => dbSection.id === section.id);
transaction
.update(sections)
.set({
yOffset: prev?.kind !== "dynamic" && "yOffset" in section ? section.yOffset : null,
xOffset: prev?.kind !== "dynamic" && "yOffset" in section ? 0 : null,
options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
name: prev?.kind === "category" && "name" in section ? section.name : null,
yOffset: section.kind === "empty" ? section.yOffset : null,
xOffset: section.kind === "empty" ? section.xOffset : null,
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
name: null,
})
.where(eq(sections.id, section.id))
.run();
if (section.kind !== "dynamic") continue;
if (section.kind !== "container") continue;
for (const sectionLayout of section.layouts) {
transaction
@@ -1450,6 +1483,11 @@ export const boardRouter = createTRPCRouter({
.output(z.object({ itemId: z.string() }))
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: ctx.session.user.permissions.includes("admin"),
submittedItems: [{ id: "", kind: input.kind, options: input.options }],
storedItems: [],
});
if (input.kind === "timetable") {
await validateTimetableOptionsChangeAsync(input.options);
@@ -1656,77 +1694,98 @@ const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredId
const getUpdatedBoardLayout = (
board: Awaited<ReturnType<typeof getFullBoardWithWhereAsync>>,
options: {
previous: {
layoutId: string;
columnCount: number;
};
current: {
layoutId: string;
columnCount: number;
};
previous: BoardLayoutGeometry;
current: BoardLayoutGeometry;
},
) => {
const itemSectionLayoutsCollection: InferInsertModel<typeof itemLayouts>[] = [];
const sectionLayoutsCollection: InferInsertModel<typeof sectionLayouts>[] = [];
const elements = getElementsForLayout(board, options.previous.layoutId);
const rootSections = board.sections.filter((section) => section.kind !== "dynamic");
for (const rootSection of rootSections) {
const result = generateResponsiveGridFor({
items: elements,
previousWidth: options.previous.columnCount,
width: options.current.columnCount,
sectionId: rootSection.id,
});
itemSectionLayoutsCollection.push(
...board.items
.map((item): InferInsertModel<typeof itemLayouts> | null => {
const currentElement = result.items.find((element) => element.type === "item" && element.id === item.id);
if (!currentElement) {
return null;
}
return {
itemId: item.id,
layoutId: options.current.layoutId,
sectionId: currentElement.sectionId,
height: currentElement.height,
width: currentElement.width,
xOffset: currentElement.xOffset,
yOffset: currentElement.yOffset,
};
})
.filter((item) => item !== null),
);
sectionLayoutsCollection.push(
...board.sections
.filter((section) => section.kind === "dynamic")
.map((section): InferInsertModel<typeof sectionLayouts> | null => {
const currentElement = result.items.find(
(element) => element.type === "section" && element.id === section.id,
);
if (!currentElement) {
return null;
}
return {
layoutId: options.current.layoutId,
sectionId: section.id,
parentSectionId: currentElement.sectionId,
height: currentElement.height,
width: currentElement.width,
xOffset: currentElement.xOffset,
yOffset: currentElement.yOffset,
};
})
.filter((section) => section !== null),
);
const emptyRoots = board.sections
.filter((section) => section.kind === "empty")
.toSorted((first, second) => (first.yOffset ?? 0) - (second.yOffset ?? 0) || first.id.localeCompare(second.id));
const rootByLane = new Map<BoardLane, (typeof emptyRoots)[number]>();
for (const root of emptyRoots) {
const lane = getRootSectionLane(root.xOffset);
if (!rootByLane.has(lane)) rootByLane.set(lane, root);
}
const mainRoot = rootByLane.get("main");
if (!mainRoot) throw new Error(`Board "${board.id}" has no main canvas root`);
const sourceRootById = new Map(
emptyRoots.map((section) => [section.id, getRootSectionLane(section.xOffset)] as const),
);
const targetLaneBySourceLane = new Map<BoardLane, BoardLane>(
boardLanes.map((lane) => [
lane,
lane !== "main" && getBoardLaneColumnCount(options.current, lane) === 0 ? "main" : lane,
]),
);
const remappedElements = elements.map((element) => {
const sourceLane = sourceRootById.get(element.sectionId);
if (!sourceLane) return element;
const targetLane = targetLaneBySourceLane.get(sourceLane) ?? "main";
const targetRoot = rootByLane.get(targetLane) ?? mainRoot;
return {
...element,
sectionId: targetRoot.id,
};
});
const results = boardLanes.flatMap((lane) => {
const root = rootByLane.get(lane);
const width = getBoardLaneColumnCount(options.current, lane);
if (!root || width === 0) return [];
const sourceLanes = boardLanes.filter((sourceLane) => targetLaneBySourceLane.get(sourceLane) === lane);
const previousWidth =
sourceLanes.length === 1
? getBoardLaneColumnCount(options.previous, sourceLanes[0] ?? "main")
: Number.MAX_SAFE_INTEGER;
return [
generateResponsiveGridFor({
items: remappedElements,
previousWidth,
width,
sectionId: root.id,
}),
];
});
const updatedElements = results.flatMap((result) => result.items);
const updatedElementById = new Map(updatedElements.map((element) => [element.id, element]));
const itemSectionLayoutsCollection = board.items.flatMap((item): InferInsertModel<typeof itemLayouts>[] => {
const currentElement = updatedElementById.get(item.id);
if (!currentElement || currentElement.type !== "item") return [];
return [
{
itemId: item.id,
layoutId: options.current.layoutId,
sectionId: currentElement.sectionId,
height: currentElement.height,
width: currentElement.width,
xOffset: currentElement.xOffset,
yOffset: currentElement.yOffset,
},
];
});
const sectionLayoutsCollection = board.sections.flatMap((section): InferInsertModel<typeof sectionLayouts>[] => {
if (section.kind !== "container") return [];
const currentElement = updatedElementById.get(section.id);
if (!currentElement || currentElement.type !== "section") return [];
return [
{
layoutId: options.current.layoutId,
sectionId: section.id,
parentSectionId: currentElement.sectionId,
height: currentElement.height,
width: currentElement.width,
xOffset: currentElement.xOffset,
yOffset: currentElement.yOffset,
},
];
});
return {
itemSectionLayouts: itemSectionLayoutsCollection,
@@ -1734,9 +1793,89 @@ const getUpdatedBoardLayout = (
};
};
interface BoardLayoutGeometry {
layoutId: string;
columnCount: number;
leftGutterColumnCount: number;
rightGutterColumnCount: number;
}
type GutterLane = Exclude<BoardLane, "main">;
const ensureGutterRootSectionsAsync = async (db: Database, boardId: string, lanes: readonly GutterLane[]) => {
if (lanes.length === 0) return;
const getMissingLanes = (existingOffsets: readonly (number | null)[]) =>
lanes.filter((lane) => {
const rootCount = existingOffsets.filter((offset) => offset === rootSectionOffsets[lane]).length;
if (rootCount > 1) throw new Error(`Board "${boardId}" has multiple ${lane} canvas roots`);
return rootCount === 0;
});
const getRows = (missingLanes: readonly GutterLane[]) =>
missingLanes.map((lane) => ({
id: createId(),
boardId,
kind: "empty" as const,
xOffset: rootSectionOffsets[lane],
yOffset: 0,
options: emptySuperJSON,
}));
await handleTransactionsAsync(db, {
async handleAsync(database, schema) {
await database.transaction(async (transaction) => {
await transaction
.select({ id: schema.boards.id })
.from(schema.boards)
.where(eq(schema.boards.id, boardId))
.for("update");
const existingRoots = await transaction
.select({ xOffset: schema.sections.xOffset })
.from(schema.sections)
.where(
and(
eq(schema.sections.boardId, boardId),
eq(schema.sections.kind, "empty"),
inArray(
schema.sections.xOffset,
lanes.map((lane) => rootSectionOffsets[lane]),
),
),
);
const rows = getRows(getMissingLanes(existingRoots.map(({ xOffset }) => xOffset)));
if (rows.length > 0) await transaction.insert(schema.sections).values(rows);
});
},
handleSync(database) {
database.transaction(
(transaction) => {
const existingRoots = transaction
.select({ xOffset: sections.xOffset })
.from(sections)
.where(
and(
eq(sections.boardId, boardId),
eq(sections.kind, "empty"),
inArray(
sections.xOffset,
lanes.map((lane) => rootSectionOffsets[lane]),
),
),
)
.all();
const rows = getRows(getMissingLanes(existingRoots.map(({ xOffset }) => xOffset)));
if (rows.length > 0) transaction.insert(sections).values(rows).run();
},
{ behavior: "immediate" },
);
},
});
};
const getElementsForLayout = (board: Awaited<ReturnType<typeof getFullBoardWithWhereAsync>>, layoutId: string) => {
const sectionElements = board.sections
.filter((section) => section.kind === "dynamic")
.filter((section) => section.kind === "container")
.map((section) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const clonedLayout = section.layouts.find((sectionLayout) => sectionLayout.layoutId === layoutId)!;
@@ -1771,9 +1910,12 @@ const getElementsForLayout = (board: Awaited<ReturnType<typeof getFullBoardWithW
};
const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, userId: string | null) => {
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, userId ?? ""),
});
const groupPermissionWhere = userId
? inArray(
boardGroupPermissions.groupId,
db.select({ groupId: groupMembers.groupId }).from(groupMembers).where(eq(groupMembers.userId, userId)),
)
: eq(boardGroupPermissions.groupId, "");
const board = await db.query.boards.findFirst({
where,
with: {
@@ -1796,8 +1938,8 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
items: {
with: {
integrations: {
with: {
integration: true,
columns: {
integrationId: true,
},
},
layouts: true,
@@ -1811,7 +1953,7 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
},
},
groupPermissions: {
where: inArray(boardGroupPermissions.groupId, groupsOfCurrentUser.map((group) => group.groupId).concat("")),
where: groupPermissionWhere,
},
},
});
@@ -1859,7 +2001,7 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
layoutId: layout.layoutId,
sectionId: layout.sectionId,
})),
integrationIds: itemIntegrations.map((item) => item.integration.id),
integrationIds: itemIntegrations.map((item) => item.integrationId),
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
options: superjson.parse<Record<string, unknown>>(item.options),
}),
@@ -19,9 +19,12 @@ export const throwIfActionForbiddenAsync = async (
permission: BoardPermission,
) => {
const { db, session } = ctx;
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, session?.user.id ?? ""),
});
const groupPermissionWhere = session
? inArray(
boardGroupPermissions.groupId,
db.select({ groupId: groupMembers.groupId }).from(groupMembers).where(eq(groupMembers.userId, session.user.id)),
)
: eq(boardGroupPermissions.groupId, "");
const board = await db.query.boards.findFirst({
where: boardWhere,
columns: {
@@ -34,7 +37,7 @@ export const throwIfActionForbiddenAsync = async (
where: eq(boardUserPermissions.userId, session?.user.id ?? ""),
},
groupPermissions: {
where: inArray(boardGroupPermissions.groupId, groupsOfCurrentUser.map((group) => group.groupId).concat("")),
where: groupPermissionWhere,
},
},
});
@@ -0,0 +1,85 @@
import { isDeepStrictEqual } from "node:util";
import { TRPCError } from "@trpc/server";
interface BoardPlacement {
id: string;
kind: string;
options: Record<string, unknown>;
}
interface CustomWidgetPlacementGuardInput {
isAdmin: boolean;
submittedItems: readonly BoardPlacement[];
storedItems: readonly BoardPlacement[];
}
const throwAdminRequired = () => {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only administrators can add or configure Custom Widgets",
});
};
const assertAuthoringAllowed = (isAdmin: boolean) => {
if (!isAdmin) throwAdminRequired();
};
/**
* Protects the security-sensitive part of Custom Widget placements while
* leaving ordinary board operations (layout, presentation and removal) to the
* existing board permission checks.
*/
export const throwIfCustomWidgetPlacementChangeForbidden = ({
isAdmin,
submittedItems,
storedItems,
}: CustomWidgetPlacementGuardInput) => {
const storedById = new Map(storedItems.map((item) => [item.id, item]));
for (const submitted of submittedItems) {
const stored = storedById.get(submitted.id);
if (!stored) {
if (submitted.kind === "customApi") assertAuthoringAllowed(isAdmin);
continue;
}
const wasCustomWidget = stored.kind === "customApi";
const isCustomWidget = submitted.kind === "customApi";
if (wasCustomWidget !== isCustomWidget) {
if (isCustomWidget) assertAuthoringAllowed(isAdmin);
else if (!isAdmin) throwAdminRequired();
continue;
}
if (
wasCustomWidget &&
!isDeepStrictEqual(normalizeCustomWidgetOptions(submitted.options), normalizeCustomWidgetOptions(stored.options))
) {
assertAuthoringAllowed(isAdmin);
}
}
};
export const throwIfCustomWidgetBoardDuplicationForbidden = (
isAdmin: boolean,
items: readonly Pick<BoardPlacement, "kind">[],
) => {
if (items.some((item) => item.kind === "customApi")) assertAuthoringAllowed(isAdmin);
};
function normalizeCustomWidgetOptions(options: Record<string, unknown>) {
return {
...options,
configuration:
options.configuration !== null &&
typeof options.configuration === "object" &&
!Array.isArray(options.configuration)
? options.configuration
: {},
configurationVersion:
typeof options.configurationVersion === "number" && Number.isInteger(options.configurationVersion)
? options.configurationVersion
: 1,
refreshInterval: typeof options.refreshInterval === "number" ? options.refreshInterval : 30,
};
}
+10 -10
View File
@@ -28,7 +28,7 @@ export const generateResponsiveGridFor = ({
}: GridAlgorithmInput): GridAlgorithmOutput => {
const itemsOfCurrentSection = items
.filter((item) => item.sectionId === sectionId)
.sort((itemA, itemB) =>
.toSorted((itemA, itemB) =>
itemA.yOffset === itemB.yOffset ? itemA.xOffset - itemB.xOffset : itemA.yOffset - itemB.yOffset,
);
const normalizedItems = normalizeItems(itemsOfCurrentSection, width);
@@ -42,18 +42,18 @@ export const generateResponsiveGridFor = ({
const newItems: GridAlgorithmItem[] = [];
// Fix height of dynamic sections
const dynamicSectionHeightMap = new Map<string, number>();
const dynamicSectionsOfCurrentSection = normalizedItems.filter((item) => item.type === "section");
for (const dynamicSection of dynamicSectionsOfCurrentSection) {
// A container must remain tall enough for its recursively reflowed contents.
const containerHeightMap = new Map<string, number>();
const containersOfCurrentSection = normalizedItems.filter((item) => item.type === "section");
for (const container of containersOfCurrentSection) {
const result = generateResponsiveGridFor({
items,
previousWidth: dynamicSection.previousWidth,
width: dynamicSection.width,
sectionId: dynamicSection.id,
previousWidth: container.previousWidth,
width: container.width,
sectionId: container.id,
});
newItems.push(...result.items);
dynamicSectionHeightMap.set(dynamicSection.id, result.height);
containerHeightMap.set(container.id, result.height);
}
// Return same positions for items in the current section
@@ -69,7 +69,7 @@ export const generateResponsiveGridFor = ({
for (const item of normalizedItems) {
const itemWithHeight = {
...item,
height: item.type === "section" ? Math.max(dynamicSectionHeightMap.get(item.id) ?? 1, item.height) : item.height,
height: item.type === "section" ? Math.max(containerHeightMap.get(item.id) ?? 1, item.height) : item.height,
};
const position = nextFreeSpot(occupied2d, itemWithHeight, width);
if (!position) throw new Error("No free spot available");
@@ -0,0 +1,109 @@
import { describe, expect, test } from "vitest";
import {
throwIfCustomWidgetBoardDuplicationForbidden,
throwIfCustomWidgetPlacementChangeForbidden,
} from "../custom-widget-placement-access";
const placement = {
id: "widget-1",
kind: "customApi",
options: {
definitionId: "definition-1",
refreshInterval: 30,
configuration: { server: "primary" },
},
};
describe("Custom Widget placement access", () => {
test("non-admins cannot add a Custom Widget placement", () => {
expect(() =>
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: false,
submittedItems: [{ ...placement, options: { ...placement.options, configurationVersion: 1 } }],
storedItems: [],
}),
).toThrowError("Only administrators can add or configure Custom Widgets");
});
test.each([
["definition", { ...placement, options: { ...placement.options, definitionId: "definition-2" } }],
["refresh interval", { ...placement, options: { ...placement.options, refreshInterval: 60 } }],
[
"option configuration",
{ ...placement, options: { ...placement.options, configuration: { server: "secondary" } } },
],
["widget kind", { ...placement, kind: "weather" }],
])("non-admins cannot change an existing Custom Widget %s", (_label, submitted) => {
expect(() =>
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: false,
submittedItems: [submitted],
storedItems: [placement],
}),
).toThrowError("Only administrators can add or configure Custom Widgets");
});
test("non-admins may move, resize and change presentation for an existing placement", () => {
const submitted = {
...placement,
layouts: [{ xOffset: 2, yOffset: 4, width: 6, height: 3 }],
advancedOptions: { title: "Status", customCssClasses: ["wide"], borderColor: "#fff" },
};
expect(() =>
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: false,
submittedItems: [submitted],
storedItems: [placement],
}),
).not.toThrow();
});
test("non-admins may remove an existing placement", () => {
expect(() =>
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: false,
submittedItems: [],
storedItems: [placement],
}),
).not.toThrow();
});
test("admins may create and reconfigure placements", () => {
expect(() =>
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: true,
submittedItems: [{ ...placement, options: { definitionId: "definition-2" } }],
storedItems: [],
}),
).not.toThrow();
});
test("duplicating a board with Custom Widgets requires admin", () => {
expect(() => throwIfCustomWidgetBoardDuplicationForbidden(false, [placement])).toThrowError(
"Only administrators can add or configure Custom Widgets",
);
expect(() => throwIfCustomWidgetBoardDuplicationForbidden(true, [placement])).not.toThrow();
expect(() => throwIfCustomWidgetBoardDuplicationForbidden(false, [{ kind: "weather" }])).not.toThrow();
});
test("treats missing persisted v2 defaults as equivalent during presentation-only edits", () => {
expect(() =>
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: false,
submittedItems: [placement],
storedItems: [
{
id: placement.id,
kind: placement.kind,
options: {
definitionId: placement.options.definitionId,
refreshInterval: placement.options.refreshInterval,
configuration: placement.options.configuration,
},
},
],
}),
).not.toThrow();
});
});
@@ -25,34 +25,34 @@ describe("Grid Algorithm", () => {
expect(output).toBe(item.output);
});
test.each(dynamicSectionTests)(
"should convert a grid with dynamic sections from 16 columns to %i columns",
test.each(containerTests)(
"should convert a grid with nested containers from 16 columns to %i columns",
(_, testInput) => {
const outerDynamicSectionId = "b";
const innerDynamicSectionId = "f";
const outerContainerId = "b";
const innerContainerId = "f";
const items = [
algoItem({ id: "a", width: 2, height: 2 }),
algoItem({ id: outerDynamicSectionId, type: "section", width: 12, height: 3, yOffset: 2 }),
algoItem({ id: "a", width: 2, sectionId: outerDynamicSectionId }),
algoItem({ id: "b", width: 4, sectionId: outerDynamicSectionId, xOffset: 2 }),
algoItem({ id: "c", width: 2, sectionId: outerDynamicSectionId, xOffset: 6 }),
algoItem({ id: "d", width: 1, sectionId: outerDynamicSectionId, xOffset: 8 }),
algoItem({ id: "e", width: 3, sectionId: outerDynamicSectionId, xOffset: 9 }),
algoItem({ id: outerContainerId, type: "section", width: 12, height: 3, yOffset: 2 }),
algoItem({ id: "a", width: 2, sectionId: outerContainerId }),
algoItem({ id: "b", width: 4, sectionId: outerContainerId, xOffset: 2 }),
algoItem({ id: "c", width: 2, sectionId: outerContainerId, xOffset: 6 }),
algoItem({ id: "d", width: 1, sectionId: outerContainerId, xOffset: 8 }),
algoItem({ id: "e", width: 3, sectionId: outerContainerId, xOffset: 9 }),
algoItem({
id: innerDynamicSectionId,
id: innerContainerId,
type: "section",
width: 8,
height: 2,
yOffset: 1,
sectionId: outerDynamicSectionId,
sectionId: outerContainerId,
}),
algoItem({ id: "a", width: 2, sectionId: innerDynamicSectionId }),
algoItem({ id: "b", width: 5, xOffset: 2, sectionId: innerDynamicSectionId }),
algoItem({ id: "c", width: 1, height: 2, xOffset: 7, sectionId: innerDynamicSectionId }),
algoItem({ id: "d", width: 7, yOffset: 1, sectionId: innerDynamicSectionId }),
algoItem({ id: "g", width: 4, yOffset: 1, sectionId: outerDynamicSectionId, xOffset: 8 }),
algoItem({ id: "h", width: 3, yOffset: 2, sectionId: outerDynamicSectionId, xOffset: 8 }),
algoItem({ id: "i", width: 1, yOffset: 2, sectionId: outerDynamicSectionId, xOffset: 11 }),
algoItem({ id: "a", width: 2, sectionId: innerContainerId }),
algoItem({ id: "b", width: 5, xOffset: 2, sectionId: innerContainerId }),
algoItem({ id: "c", width: 1, height: 2, xOffset: 7, sectionId: innerContainerId }),
algoItem({ id: "d", width: 7, yOffset: 1, sectionId: innerContainerId }),
algoItem({ id: "g", width: 4, yOffset: 1, sectionId: outerContainerId, xOffset: 8 }),
algoItem({ id: "h", width: 3, yOffset: 2, sectionId: outerContainerId, xOffset: 8 }),
algoItem({ id: "i", width: 1, yOffset: 2, sectionId: outerContainerId, xOffset: 11 }),
algoItem({ id: "c", width: 5, yOffset: 5 }),
];
@@ -64,10 +64,10 @@ describe("Grid Algorithm", () => {
});
const rootItems = newItems.items.filter((item) => item.sectionId === ROOT_SECTION_ID);
const outerSection = items.find((item) => item.id === outerDynamicSectionId);
const outerItems = newItems.items.filter((item) => item.sectionId === outerDynamicSectionId);
const innerSection = items.find((item) => item.id === innerDynamicSectionId);
const innerItems = newItems.items.filter((item) => item.sectionId === innerDynamicSectionId);
const outerSection = items.find((item) => item.id === outerContainerId);
const outerItems = newItems.items.filter((item) => item.sectionId === outerContainerId);
const innerSection = items.find((item) => item.id === innerContainerId);
const innerItems = newItems.items.filter((item) => item.sectionId === innerContainerId);
expect(generateOutputText(rootItems, testInput.outputColumns)).toBe(testInput.root);
expect(generateOutputText(outerItems, Math.min(testInput.outputColumns, outerSection?.width ?? 999))).toBe(
@@ -192,7 +192,7 @@ const itemTests = [
},
].map((item) => [item.inputColumnCount, item.outputColumnCount, item] as const);
const dynamicSectionTests = [
const containerTests = [
{
outputColumns: 16,
root: `
+17 -13
View File
@@ -1,15 +1,19 @@
export const AUTH_HANDLERS: Record<string, (headers: Headers, url: URL, apiKey: string, headerName?: string) => void> =
{
bearer: (headers, _url, apiKey) => {
headers.set("Authorization", `Bearer ${apiKey}`);
},
apiKeyHeader: (headers, _url, apiKey, headerName) => {
headers.set(headerName ?? "X-API-Key", apiKey);
},
apiKeyQuery: (_headers, url, apiKey, headerName) => {
url.searchParams.set(headerName ?? "api_key", apiKey);
},
};
type HeaderSetter = Pick<Headers, "set">;
export const AUTH_HANDLERS: Record<
string,
(headers: HeaderSetter, url: URL, apiKey: string, headerName?: string) => void
> = {
bearer: (headers, _url, apiKey) => {
headers.set("Authorization", `Bearer ${apiKey}`);
},
apiKeyHeader: (headers, _url, apiKey, headerName) => {
headers.set(headerName ?? "X-API-Key", apiKey);
},
apiKeyQuery: (_headers, url, apiKey, headerName) => {
url.searchParams.set(headerName ?? "api_key", apiKey);
},
};
export function buildSecretMap(secrets: Array<{ kind: string; value: string }>): Record<string, string> {
const map: Record<string, string> = {};
@@ -20,7 +24,7 @@ export function buildSecretMap(secrets: Array<{ kind: string; value: string }>):
}
export function applyAuth(
headers: Headers,
headers: HeaderSetter,
url: URL,
authType: string,
secrets: Array<{ kind: string; value: string }>,
@@ -0,0 +1,17 @@
import { createHash } from "node:crypto";
interface CacheVersionDefinition {
sources: string;
requests: string;
secrets: Array<{ sourceId: string; kind: string; encryptedValue: string }>;
}
export function getCustomWidgetCacheVersion(definition: CacheVersionDefinition) {
const secrets = definition.secrets
.map(({ sourceId, kind, encryptedValue }) => ({ sourceId, kind, encryptedValue }))
.toSorted((left, right) => `${left.sourceId}:${left.kind}`.localeCompare(`${right.sourceId}:${right.kind}`));
return createHash("sha256")
.update(JSON.stringify([definition.sources, definition.requests, secrets]))
.digest("hex")
.slice(0, 16);
}
@@ -0,0 +1,106 @@
import { createId } from "@homarr/common";
import { createRedisClient } from "@homarr/core/infrastructure/redis";
import type { RedisClient } from "@homarr/core/infrastructure/redis";
import type { CustomWidgetSecretKind } from "@homarr/custom-widgets/core";
import type { CustomWidgetSource } from "@homarr/custom-widgets/core";
const CONFIGURATION_REQUEST_TTL_MS = 10 * 60_000;
const CONFIGURATION_REQUEST_PREFIX = "custom-widget:configuration-request:";
const CONFIGURATION_REQUEST_LOCK_PREFIX = "custom-widget:configuration-request-lock:";
export interface CustomWidgetConfigurationRequest {
id: string;
userId: string;
target: { type: "definition"; id: string } | { type: "preview"; id: string };
widgetName: string;
sourceId: string;
sourceName: string;
source: CustomWidgetSource;
kinds: CustomWidgetSecretKind[];
expiresAt: number;
status: "pending" | "completed";
}
const localRequests = new Map<string, CustomWidgetConfigurationRequest>();
const localLocks = new Set<string>();
let redis: RedisClient | undefined;
function getRedis() {
if (process.env.CI !== undefined || process.env.NODE_ENV === "test") return undefined;
redis ??= createRedisClient();
return redis;
}
async function save(request: CustomWidgetConfigurationRequest) {
const ttl = Math.max(1, request.expiresAt - Date.now());
const client = getRedis();
if (client) await client.set(`${CONFIGURATION_REQUEST_PREFIX}${request.id}`, JSON.stringify(request), "PX", ttl);
else localRequests.set(request.id, request);
}
export async function createCustomWidgetConfigurationRequest(
input: Omit<CustomWidgetConfigurationRequest, "id" | "expiresAt" | "status">,
) {
const request: CustomWidgetConfigurationRequest = {
...input,
id: createId(),
expiresAt: Date.now() + CONFIGURATION_REQUEST_TTL_MS,
status: "pending",
};
await save(request);
return request;
}
export async function getCustomWidgetConfigurationRequest(id: string) {
const client = getRedis();
const candidate = client ? await client.get(`${CONFIGURATION_REQUEST_PREFIX}${id}`) : localRequests.get(id);
if (!candidate) return null;
try {
const request = (
typeof candidate === "string" ? JSON.parse(candidate) : candidate
) as CustomWidgetConfigurationRequest;
if (request.expiresAt <= Date.now()) {
if (client) await client.del(`${CONFIGURATION_REQUEST_PREFIX}${id}`);
else localRequests.delete(id);
return null;
}
return request;
} catch {
return null;
}
}
export async function getCustomWidgetConfigurationRequestForUser(id: string, userId: string) {
const request = await getCustomWidgetConfigurationRequest(id);
return request?.userId === userId ? request : null;
}
export async function completeCustomWidgetConfigurationRequest(id: string) {
const request = await getCustomWidgetConfigurationRequest(id);
if (!request || request.status !== "pending") return null;
const completed = { ...request, status: "completed" as const };
await save(completed);
await releaseCustomWidgetConfigurationRequest(id);
return completed;
}
export async function claimCustomWidgetConfigurationRequest(id: string) {
const request = await getCustomWidgetConfigurationRequest(id);
if (!request || request.status !== "pending") return null;
const client = getRedis();
if (client) {
const claimed = await client.set(`${CONFIGURATION_REQUEST_LOCK_PREFIX}${id}`, "1", "PX", 60_000, "NX");
return claimed === "OK" ? request : null;
}
if (localLocks.has(id)) return null;
localLocks.add(id);
return request;
}
export async function releaseCustomWidgetConfigurationRequest(id: string) {
const client = getRedis();
if (client) await client.del(`${CONFIGURATION_REQUEST_LOCK_PREFIX}${id}`);
else localLocks.delete(id);
}
export { setPreviewSessionSecrets } from "./preview-sessions";
@@ -1,216 +1,203 @@
import { TRPCError } from "@trpc/server";
import superjson from "superjson";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { decryptSecret, encryptSecret } from "@homarr/common/server";
import { boards, customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
import { eq } from "@homarr/db";
import { encryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { and, eq, handleTransactionsAsync, notInArray } from "@homarr/db";
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
import {
customWidgetAuthTypes,
customWidgetCreateSchema,
customWidgetDisplayTypes,
customWidgetImportSchema,
customWidgetMethods,
customWidgetSecretKinds,
customWidgetDefinitionSchema,
customWidgetUpdateSchema,
displayConfigSchema,
} from "@homarr/validation/custom-widget";
} from "@homarr/custom-widgets/core";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
import { assertCustomApiItemBindingAsync, customApiItemInputSchema } from "./custom-widget-access";
import { applyAuth } from "./auth";
import { extractDisplayDataWithFallback } from "./display-data";
import { parseDisplayConfig } from "./parse-display-config";
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
import { insertCustomWidgetDefinition } from "./definition-insert";
import { managementQueryProcedures } from "./management-queries";
import { metadataProcedures } from "./metadata-procedures";
import { previewActionProcedures } from "./preview-action-procedures";
import { previewBaseProcedures } from "./preview-base-procedures";
import { previewQueryProcedures } from "./preview-query-procedures";
import {
consumeBoundedResponseAsync,
readBoundedJsonResponseAsync,
validateCustomApiUrl,
withCustomApiResponseAsync,
} from "../widgets/custom-api-security";
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
parseStoredCustomWidgetDefinition,
safeParseStoredCustomWidgetDefinition,
serializeCustomWidgetDefinition,
} from "./stored-definition";
import { templateProcedures } from "./template-procedures";
import { transferProcedures } from "./transfer-procedures";
import { assertSecretSources, hasSameSecretBinding, requiredSecretKinds } from "./secret-policy";
import { secretProcedures } from "./secret-procedures";
import { workshopProcedures } from "./workshop-procedures";
const logger = createLogger({ module: "custom-widget" });
const PUBLIC_PREVIEW_ERROR = "Custom API preview failed";
const updateFieldSerializers: Record<string, (value: unknown) => unknown> = {
displayConfig: (value) => superjson.stringify(value),
};
let _importJsonSchema: Record<string, unknown> | null = null;
function getImportJsonSchema() {
if (!_importJsonSchema) {
_importJsonSchema = {
...z.toJSONSchema(customWidgetImportSchema),
title: "Homarr Custom Widget",
description:
"Schema for importing/exporting custom widget definitions in Homarr. " +
"All jsonPath fields use JSONPath syntax (e.g. $.data.count, $.items[0].name). " +
"The displayConfig must match the chosen displayType. " +
"Secrets (API keys, passwords) are not included in exports and must be configured separately after import.",
};
}
return _importJsonSchema;
}
export const customWidgetRouter = createTRPCRouter({
schema: publicProcedure.query(() => getImportJsonSchema()),
...metadataProcedures,
...managementQueryProcedures,
all: adminProcedure.query(async ({ ctx }) => {
const definitions = await ctx.db.query.customWidgetDefinitions.findMany({
orderBy: (table, { asc }) => asc(table.name),
});
return definitions.map((def) => ({
id: def.id,
name: def.name,
description: def.description,
iconUrl: def.iconUrl,
url: def.url,
method: def.method,
displayType: def.displayType,
authType: def.authType,
enabled: def.enabled,
}));
}),
available: protectedProcedure
.input(z.object({ boardId: z.string().min(1).max(100) }))
.query(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
return await ctx.db.query.customWidgetDefinitions.findMany({
columns: {
id: true,
name: true,
description: true,
iconUrl: true,
displayType: true,
enabled: true,
},
orderBy: (table, { asc }) => asc(table.name),
});
create: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Create one validated Custom JSX widget." } })
.input(customWidgetCreateSchema)
.mutation(async ({ ctx, input }) => {
const { secrets, ...candidate } = input;
const definition = customWidgetDefinitionSchema.parse(candidate);
assertSecretSources(definition.sources, secrets);
const id = await insertCustomWidgetDefinition(ctx.db, definition, ctx.session.user.id, secrets);
logger.info("Created custom widget definition", { id, name: definition.name });
return { id };
}),
byId: adminProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
with: { secrets: true },
});
if (!definition) {
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
}
const displayConfig = parseDisplayConfig(
definition.displayConfig,
input.id,
logger,
"Corrupt displayConfig in custom widget",
);
return {
...definition,
enabled: definition.enabled,
displayConfig,
secrets: definition.secrets.map((s) => ({
kind: s.kind,
hasValue: true,
updatedAt: s.updatedAt,
})),
};
}),
create: adminProcedure.input(customWidgetCreateSchema).mutation(async ({ ctx, input }) => {
const id = createId();
await ctx.db.insert(customWidgetDefinitions).values({
id,
name: input.name,
description: input.description,
iconUrl: input.iconUrl,
url: input.url,
authType: input.authType,
headerName: input.headerName,
method: input.method,
requestBody: input.requestBody,
displayType: input.displayType,
displayConfig: superjson.stringify(input.displayConfig),
creatorId: ctx.session.user.id,
});
if (input.secrets.length > 0) {
await ctx.db.insert(customWidgetSecrets).values(
input.secrets.map((secret) => ({
kind: secret.kind,
value: encryptSecret(secret.value),
definitionId: id,
updatedAt: new Date(),
})),
);
}
logger.info("Created custom widget definition", { id, name: input.name });
return { id };
}),
update: adminProcedure.input(customWidgetUpdateSchema).mutation(async ({ ctx, input }) => {
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND" });
}
const { id, secrets, ...updateFields } = input;
const updateValues: Record<string, unknown> = { updatedAt: new Date() };
for (const [key, value] of Object.entries(updateFields)) {
if (value === undefined) continue;
const serialize = updateFieldSerializers[key];
if (serialize) {
updateValues[key] = serialize(value);
} else {
updateValues[key] = value;
}
}
await ctx.db.update(customWidgetDefinitions).set(updateValues).where(eq(customWidgetDefinitions.id, id));
if (secrets !== undefined) {
const effectiveAuthType = (updateFields.authType as string | undefined) ?? existing.authType;
if (secrets.length > 0) {
await ctx.db.delete(customWidgetSecrets).where(eq(customWidgetSecrets.definitionId, id));
await ctx.db.insert(customWidgetSecrets).values(
secrets.map((secret) => ({
kind: secret.kind,
value: encryptSecret(secret.value),
definitionId: id,
updatedAt: new Date(),
})),
);
} else if (effectiveAuthType === "none") {
await ctx.db.delete(customWidgetSecrets).where(eq(customWidgetSecrets.definitionId, id));
}
}
logger.info("Updated custom widget definition", { id });
}),
toggleEnabled: adminProcedure
.input(z.object({ id: z.string(), enabled: z.boolean() }))
update: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Update one Custom JSX widget." } })
.input(customWidgetUpdateSchema)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!existing) throw new TRPCError({ code: "NOT_FOUND" });
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND" });
const current = parseStoredCustomWidgetDefinition(existing);
const { id, secrets, ...changes } = input;
const definition = customWidgetDefinitionSchema.parse({ ...current, ...changes });
if (secrets) assertSecretSources(definition.sources, secrets);
const definitionChanges = { ...serializeCustomWidgetDefinition(definition), updatedAt: new Date() };
const secretRows = secrets?.map((secret) => ({
definitionId: id,
sourceId: secret.sourceId,
kind: secret.kind,
encryptedValue: encryptSecret(secret.value),
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) {
await db.transaction(async (transaction) => {
await transaction
.update(schema.customWidgetDefinitions)
.set(definitionChanges)
.where(eq(schema.customWidgetDefinitions.id, id));
await transaction
.delete(schema.customWidgetSecrets)
.where(
and(
eq(schema.customWidgetSecrets.definitionId, id),
notInArray(schema.customWidgetSecrets.sourceId, sourceIds),
),
);
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(
eq(schema.customWidgetSecrets.definitionId, id),
eq(schema.customWidgetSecrets.sourceId, sourceId),
);
await transaction
.delete(schema.customWidgetSecrets)
.where(kinds.length > 0 ? and(where, notInArray(schema.customWidgetSecrets.kind, kinds)) : where);
}
for (const secret of secretRows ?? []) {
await transaction
.delete(schema.customWidgetSecrets)
.where(
and(
eq(schema.customWidgetSecrets.definitionId, id),
eq(schema.customWidgetSecrets.sourceId, secret.sourceId),
eq(schema.customWidgetSecrets.kind, secret.kind),
),
);
await transaction.insert(schema.customWidgetSecrets).values(secret);
}
});
},
handleSync(db) {
db.transaction((transaction) => {
transaction
.update(customWidgetDefinitions)
.set(definitionChanges)
.where(eq(customWidgetDefinitions.id, id))
.run();
transaction
.delete(customWidgetSecrets)
.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));
transaction
.delete(customWidgetSecrets)
.where(kinds.length > 0 ? and(where, notInArray(customWidgetSecrets.kind, kinds)) : where)
.run();
}
for (const secret of secretRows ?? []) {
transaction
.delete(customWidgetSecrets)
.where(
and(
eq(customWidgetSecrets.definitionId, id),
eq(customWidgetSecrets.sourceId, secret.sourceId),
eq(customWidgetSecrets.kind, secret.kind),
),
)
.run();
transaction.insert(customWidgetSecrets).values(secret).run();
}
});
},
});
logger.info("Updated custom widget definition", { id });
}),
...secretProcedures,
...workshopProcedures,
toggleEnabled: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ id: z.string(), enabled: z.boolean() }))
.mutation(async ({ ctx, input }) => {
if (input.enabled) {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!definition) throw new TRPCError({ code: "NOT_FOUND" });
if (!safeParseStoredCustomWidgetDefinition(definition).success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid custom widget definition cannot be enabled",
});
}
}
await ctx.db
@@ -219,243 +206,36 @@ export const customWidgetRouter = createTRPCRouter({
.where(eq(customWidgetDefinitions.id, input.id));
}),
delete: adminProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND" });
}
await ctx.db.delete(customWidgetDefinitions).where(eq(customWidgetDefinitions.id, input.id));
logger.info("Deleted custom widget definition", { id: input.id });
}),
export: adminProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!definition) {
throw new TRPCError({ code: "NOT_FOUND" });
}
return {
$schema: "homarr-custom-widget-v2" as const,
name: definition.name,
description: definition.description,
iconUrl: definition.iconUrl,
url: definition.url,
authType: definition.authType,
headerName: definition.headerName,
method: definition.method,
requestBody: definition.requestBody,
displayType: definition.displayType,
displayConfig: parseDisplayConfig(
definition.displayConfig,
input.id,
logger,
"Corrupt displayConfig during export",
),
};
}),
import: adminProcedure.input(customWidgetImportSchema).mutation(async ({ ctx, input }) => {
const id = createId();
await ctx.db.insert(customWidgetDefinitions).values({
id,
name: input.name,
description: input.description,
iconUrl: input.iconUrl,
url: input.url,
authType: input.authType,
headerName: input.headerName,
method: input.method,
requestBody: input.requestBody,
displayType: input.displayType,
displayConfig: superjson.stringify(input.displayConfig),
creatorId: ctx.session.user.id,
});
logger.info("Imported custom widget definition", { id, name: input.name });
return { id };
}),
preview: adminProcedure
.input(
z.object({
url: z.string().url(),
method: z.enum(customWidgetMethods),
authType: z.enum(customWidgetAuthTypes),
headerName: z.string().optional(),
requestBody: z.string().optional(),
displayType: z.enum(customWidgetDisplayTypes),
displayConfig: displayConfigSchema,
secrets: z.array(z.object({ kind: z.enum(customWidgetSecretKinds), value: z.string() })),
definitionId: z.string().optional(),
}),
)
delete: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Delete one Custom JSX widget." } })
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
try {
const secrets = [...input.secrets];
if (input.definitionId) {
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.definitionId),
with: { secrets: true },
});
if (existing) {
for (const dbSecret of existing.secrets) {
if (!secrets.some((s) => s.kind === dbSecret.kind)) {
secrets.push({ kind: dbSecret.kind, value: decryptSecret(dbSecret.value) });
}
}
}
}
const url = validateCustomApiUrl(input.url);
const headers = new Headers({ Accept: "application/json" });
if (input.method !== "GET" && input.requestBody) headers.set("Content-Type", "application/json");
applyAuth(headers, url, input.authType, secrets, input.headerName);
return await withCustomApiResponseAsync(
url,
{
method: input.method,
headers,
body: input.method !== "GET" ? input.requestBody : undefined,
},
async (response) => {
const responseInfo = { status: response.status };
if (!response.ok) {
await consumeBoundedResponseAsync(response);
return {
success: false as const,
error: PUBLIC_PREVIEW_ERROR,
responseInfo,
rawResponse: null,
};
}
const json = await readBoundedJsonResponseAsync(response);
const displayData = extractDisplayDataWithFallback(json, input.displayType, input.displayConfig);
return {
success: true as const,
responseInfo,
rawResponse: JSON.stringify(json, null, 2),
displayData,
};
},
);
} catch (error) {
const errorId = createId();
logger.error("Custom widget preview failed", { errorId, error });
return {
success: false as const,
error: PUBLIC_PREVIEW_ERROR,
responseInfo: null,
rawResponse: null,
};
}
await ctx.db.delete(customWidgetDefinitions).where(eq(customWidgetDefinitions.id, input.id));
logger.info("Deleted custom widget definition", { id: input.id });
}),
duplicate: adminProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
with: { secrets: true },
});
duplicate: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!existing) throw new TRPCError({ code: "NOT_FOUND" });
const current = parseStoredCustomWidgetDefinition(existing);
const id = createId();
await ctx.db.insert(customWidgetDefinitions).values({
id,
...serializeCustomWidgetDefinition({ ...current, name: `${current.name} (copy)` }),
creatorId: ctx.session.user.id,
});
return { id, name: `${current.name} (copy)` };
}),
if (!definition) {
throw new TRPCError({ code: "NOT_FOUND" });
}
const newId = createId();
await ctx.db.insert(customWidgetDefinitions).values({
id: newId,
name: `${definition.name} (copy)`,
description: definition.description,
iconUrl: definition.iconUrl,
url: definition.url,
authType: definition.authType,
headerName: definition.headerName,
method: definition.method,
requestBody: definition.requestBody,
displayType: definition.displayType,
displayConfig: definition.displayConfig,
enabled: definition.enabled,
creatorId: ctx.session.user.id,
});
if (definition.secrets.length > 0) {
await ctx.db.insert(customWidgetSecrets).values(
definition.secrets.map((s) => ({
kind: s.kind,
value: s.value,
definitionId: newId,
updatedAt: new Date(),
})),
);
}
logger.info("Duplicated custom widget definition", { sourceId: input.id, newId });
return { id: newId, name: `${definition.name} (copy)` };
}),
execute: adminProcedure.input(customApiItemInputSchema).mutation(async ({ ctx, input }) => {
await assertCustomApiItemBindingAsync(ctx, input);
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.definitionId),
with: { secrets: true },
});
if (!definition) {
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
}
if (!definition.enabled) {
throw new TRPCError({ code: "FORBIDDEN", message: "Widget is disabled" });
}
if (definition.displayType !== "actionButton") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Only actionButton widgets can be executed" });
}
try {
const decryptedSecrets = definition.secrets.map((s) => ({
kind: s.kind,
value: decryptSecret(s.value),
}));
const url = validateCustomApiUrl(definition.url);
const headers = new Headers({ Accept: "application/json" });
if (definition.method !== "GET" && definition.requestBody) headers.set("Content-Type", "application/json");
applyAuth(headers, url, definition.authType, decryptedSecrets, definition.headerName);
return await withCustomApiResponseAsync(
url,
{
method: definition.method,
headers,
body: definition.method !== "GET" ? definition.requestBody : undefined,
},
async (response) => {
await consumeBoundedResponseAsync(response);
const responseInfo = { status: response.status };
if (!response.ok) {
return { success: false as const, errorCode: "UPSTREAM_REJECTED" as const, responseInfo };
}
logger.info("Executed custom widget action", { definitionId: input.definitionId, status: response.status });
return { success: true as const, responseInfo };
},
);
} catch (error) {
const errorId = createId();
logger.error("Custom widget execute failed", { errorId, definitionId: input.definitionId, error });
return {
success: false as const,
errorCode: "REQUEST_FAILED" as const,
responseInfo: null,
};
}
}),
...templateProcedures,
...transferProcedures,
...previewBaseProcedures,
...previewQueryProcedures,
...previewActionProcedures,
});
@@ -0,0 +1,43 @@
import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server";
import type { Database } from "@homarr/db";
import { handleTransactionsAsync } from "@homarr/db";
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
import type { CustomWidgetCreateInput, HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { serializeCustomWidgetDefinition } from "./stored-definition";
export async function insertCustomWidgetDefinition(
db: Database,
definition: HomarrCustomWidgetV2,
creatorId: string,
secrets: CustomWidgetCreateInput["secrets"],
) {
const id = createId();
const definitionRow = { id, ...serializeCustomWidgetDefinition(definition), creatorId };
const updatedAt = new Date();
const secretRows = secrets.map((secret) => ({
definitionId: id,
sourceId: secret.sourceId,
kind: secret.kind,
encryptedValue: encryptSecret(secret.value),
updatedAt,
}));
await handleTransactionsAsync(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);
});
},
handleSync(database) {
database.transaction((transaction) => {
transaction.insert(customWidgetDefinitions).values(definitionRow).run();
if (secretRows.length > 0) transaction.insert(customWidgetSecrets).values(secretRows).run();
});
},
});
return id;
}
@@ -1,153 +0,0 @@
import { JSONPath } from "jsonpath-plus";
type Config = Record<string, unknown>;
type Extractor = (json: unknown, config: Config) => unknown;
const query = (json: unknown, path: string) => JSONPath({ path, json: json as object, wrap: false });
const queryRows = (json: unknown, path: string): unknown[] => {
const rows = JSONPath({ path, json: json as object, wrap: true }) as unknown[];
const first = rows[0];
if (Array.isArray(first)) return first as unknown[];
return rows;
};
const toNumber = (value: unknown, fallback: number): number => {
const num = Number(value);
if (num) return num;
return fallback;
};
const optionalMax = (max: unknown): number | undefined => {
if (max === undefined) return undefined;
return toNumber(max, 100);
};
const isGoodValue = (value: unknown, goodValues: string[]): boolean =>
goodValues.some((gv) => String(value).toLowerCase() === gv.toLowerCase());
const extractors: Record<string, Extractor> = {
singleValue: (json, c) => ({
type: "singleValue",
label: (c.label as string) ?? "",
unit: (c.unit as string) ?? "",
value: query(json, (c.jsonPath as string) ?? "$"),
valueSize: c.valueSize ?? "lg",
labelPosition: c.labelPosition ?? "below",
}),
keyValue: (json, c) => ({
type: "keyValue",
entries: ((c.mappings as Array<{ label: string; jsonPath: string; unit: string }>) ?? []).map((m) => ({
label: m.label,
unit: m.unit,
value: query(json, m.jsonPath),
})),
layout: c.layout ?? "list",
columns: c.columns ?? 2,
}),
table: (json, c) => {
const columns = (c.columns as Array<{ header: string; jsonPath: string }>) ?? [];
const flatRows = queryRows(json, (c.tablePath as string) ?? "$");
return {
type: "table",
columns: columns.map((col) => col.header),
rows: flatRows.map((row) => columns.map((col) => query(row, col.jsonPath))),
striped: c.striped ?? true,
compact: c.compact ?? false,
};
},
statGrid: (json, c) => ({
type: "statGrid",
items: ((c.items as Array<{ label: string; jsonPath: string; unit: string; color?: string }>) ?? []).map(
(item) => ({
label: item.label,
unit: item.unit,
color: item.color ?? "blue",
value: query(json, item.jsonPath),
}),
),
columns: c.columns ?? 2,
cardStyle: c.cardStyle ?? "filled",
}),
progressBars: (json, c) => ({
type: "progressBars",
bars: (
(c.bars as Array<{ label: string; valuePath: string; maxPath?: string; unit: string; color?: string }>) ?? []
).map((bar) => {
const value = query(json, bar.valuePath);
const max = bar.maxPath ? query(json, bar.maxPath) : undefined;
return {
label: bar.label,
unit: bar.unit,
color: bar.color ?? "blue",
value: toNumber(value, 0),
max: optionalMax(max),
};
}),
showPercentage: c.showPercentage ?? true,
barSize: c.barSize ?? "md",
}),
statusIndicator: (json, c) => ({
type: "statusIndicator",
items: ((c.items as Array<{ label: string; jsonPath: string; goodValues: string[] }>) ?? []).map((item) => {
const value = query(json, item.jsonPath);
return { label: item.label, value: String(value ?? "unknown"), isGood: isGoodValue(value, item.goodValues) };
}),
layout: c.layout ?? "list",
dotSize: c.dotSize ?? "md",
}),
countGrid: (json, c) => ({
type: "countGrid",
items: ((c.items as Array<{ label: string; jsonPath: string; unit: string }>) ?? []).map((item) => ({
label: item.label,
unit: item.unit,
value: query(json, item.jsonPath),
})),
columns: c.columns ?? 2,
valueSize: c.valueSize ?? "md",
}),
raw: (json, c) => ({
type: "raw",
data: query(json, (c.jsonPath as string) ?? "$"),
maxHeight: c.maxHeight ?? 300,
}),
actionButton: (_json, c) => extractActionButtonDisplay(c),
customJsx: (json, config) => ({
type: "customJsx" as const,
template: config.template as string,
data: json,
}),
};
export function extractDisplayData(json: unknown, displayType: string, displayConfig: Config): unknown {
const type = (displayConfig.type as string) ?? displayType;
const extractor = extractors[type];
if (!extractor) {
throw new Error(`Unknown display type: ${type}`);
}
return extractor(json, displayConfig);
}
export function extractDisplayDataWithFallback(json: unknown, displayType: string, displayConfig: Config): unknown {
const type = (displayConfig.type as string) ?? displayType;
const extractor = extractors[type] ?? extractors.singleValue;
return extractor?.(json, displayConfig);
}
export interface ActionButtonDisplayData {
type: "actionButton";
buttonLabel: string;
buttonColor: string;
confirmText: string;
successMessage: string;
}
export function extractActionButtonDisplay(displayConfig: Config): ActionButtonDisplayData {
return {
type: "actionButton",
buttonLabel: typeof displayConfig.buttonLabel === "string" ? displayConfig.buttonLabel : "Execute",
buttonColor: typeof displayConfig.buttonColor === "string" ? displayConfig.buttonColor : "blue",
confirmText: typeof displayConfig.confirmText === "string" ? displayConfig.confirmText : "",
successMessage: typeof displayConfig.successMessage === "string" ? displayConfig.successMessage : "",
};
}
@@ -0,0 +1,16 @@
import { TRPCError } from "@trpc/server";
import { FlattenError } from "@homarr/common";
import { CustomWidgetDomainError } from "@homarr/custom-widgets/server";
class RequestLimitTransportError extends FlattenError {}
export function toTrpcError(error: unknown): never {
if (error instanceof CustomWidgetDomainError) {
const cause = error.retryAfterMs
? new RequestLimitTransportError(error.message, { retryAfterMs: error.retryAfterMs })
: error;
throw new TRPCError({ code: error.code, message: error.message, cause });
}
throw error;
}
@@ -0,0 +1,72 @@
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 ? redactLegacyUrl(definition.iconUrl) : null,
url: redactLegacyUrl(definition.url),
authType: definition.authType,
headerName: definition.headerName,
method: definition.method,
requestBody: redactLegacyRequestBody(definition.requestBody),
displayType: definition.displayType,
displayConfig: {
migrationNote:
"The preserved v1 display configuration was intentionally omitted because legacy free-text fields may contain credentials. Reconstruct it from the display type and visible behavior.",
},
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]";
}
@@ -0,0 +1,209 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { decryptSecret } from "@homarr/common/server";
import { eq, or } from "@homarr/db";
import { boards, customWidgetDefinitions, legacyCustomWidgetDefinitions } from "@homarr/db/schema";
import { collectCustomWidgetRequestReferences } from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
import { executeCustomWidgetRequest } from "./request-executor";
import {
hashRuntimeParams,
renderRequestBody,
renderRequestTarget,
resolveCustomWidgetRequestValues,
} 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";
export const managementQueryProcedures = {
list: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "List all Custom JSX widgets." } })
.query(async ({ ctx }) => {
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 currentIds = new Set(definitions.map(({ id }) => id));
const legacy = legacyDefinitions.filter(({ id }) => !currentIds.has(id)).map(mapLegacyCustomWidgetListItem);
return [...current, ...legacy].toSorted((left, right) => left.name.localeCompare(right.name));
}),
legacyMigrationPrompt: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Get a redacted AI migration prompt for one preserved legacy Custom Widget. Credentials must be configured separately.",
},
})
.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: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Get one Custom JSX widget without secret values." } })
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
with: { secrets: true },
});
if (!definition) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
return {
id: definition.id,
...parseStoredCustomWidgetDefinition(definition),
enabled: definition.enabled,
createdAt: definition.createdAt,
updatedAt: definition.updatedAt,
secrets: definition.secrets.map((secret) => ({
sourceId: secret.sourceId,
kind: secret.kind,
hasValue: true,
updatedAt: secret.updatedAt,
})),
};
}),
available: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ boardId: z.string(), currentId: z.string().optional() }))
.query(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
const definitions = await ctx.db.query.customWidgetDefinitions.findMany({
where: input.currentId
? or(eq(customWidgetDefinitions.enabled, true), eq(customWidgetDefinitions.id, input.currentId))
: eq(customWidgetDefinitions.enabled, true),
orderBy: (table, { asc }) => asc(table.name),
});
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: permissionRequiredProcedure
.requiresPermission("admin")
.input(
z.object({
boardId: z.string(),
definitionId: z.string(),
requestId: z.string(),
params: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).default({}),
}),
)
.query(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
const stored = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.definitionId),
with: { secrets: true },
});
if (!stored || !stored.enabled) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget not found" });
const definition = parseStoredCustomWidgetDefinition(stored);
const requestDefinition = definition.requests[input.requestId];
if (requestDefinition?.kind !== "query")
throw new TRPCError({ code: "NOT_FOUND", message: "Option query not found" });
const request = { id: input.requestId, ...requestDefinition };
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), request.permission);
const source = definition.sources[request.source];
if (!source) throw new TRPCError({ code: "NOT_FOUND", message: "Option query source not found" });
const release = await acquireCustomWidgetRequestLimit({
category: "query",
userId: ctx.session.user.id,
itemId: `configuration:${input.boardId}`,
definitionId: input.definitionId,
});
try {
const values = resolveOptionRequestValues(request, input.params);
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
const response = await executeCustomWidgetRequest({
baseUrl: source.baseUrl,
targetUrl: renderRequestTarget(source.baseUrl, request, values),
method: request.method,
body: renderRequestBody(request, values),
staticHeaders: request.headers,
auth:
request.auth === "none" || authType === "none"
? undefined
: {
type: authType,
secrets: stored.secrets
.filter((secret) => secret.sourceId === request.source)
.map((secret) => ({ kind: secret.kind, value: decryptSecret(secret.encryptedValue) })),
headerName:
typeof source.auth === "object" && source.auth.type === "apiKeyHeader"
? source.auth.name
: typeof source.auth === "object" && source.auth.type === "apiKeyQuery"
? source.auth.name
: undefined,
},
networkScope: source.networkScope,
kind: "query",
cacheKey: `custom-widget:options:${input.definitionId}:${getCustomWidgetCacheVersion(stored)}:${request.id}:${hashRuntimeParams(values)}`,
cacheTtlSeconds: request.cacheSeconds ?? 30,
});
return {
ok: response.ok,
status: response.status,
data: response.data,
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
};
} finally {
await release();
}
}),
};
export function resolveOptionRequestValues(
request: Parameters<typeof resolveCustomWidgetRequestValues>[0],
configuration: NonNullable<Parameters<typeof resolveCustomWidgetRequestValues>[2]>,
) {
const references = collectCustomWidgetRequestReferences(request);
const options: Record<string, unknown> = {};
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);
}
@@ -0,0 +1,159 @@
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<typeof customWidgetDefinitions>;
type StoredSecret = InferSelectModel<typeof customWidgetSecrets>;
type LegacyDefinition = InferSelectModel<typeof legacyCustomWidgetDefinitions>;
export function mapCustomWidgetListItem(definition: StoredDefinition & { secrets: readonly StoredSecret[] }) {
const result = safeParseStoredCustomWidgetDefinition(definition);
if (!result.success) {
logger.warn("Skipped parsing invalid custom widget definition", {
event: "custom_widget_definition_invalid",
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", {
event: "custom_widget_definition_invalid",
id: definition.id,
surface: "board-picker",
});
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";
}
}
@@ -0,0 +1,52 @@
import { z } from "zod/v4";
import { CUSTOM_WIDGET_MCP_AUTHORING_PROMPT } from "@homarr/custom-widgets/authoring-prompt";
import { getCustomWidgetSkill } from "@homarr/custom-widgets/authoring-resources";
import { customWidgetImportSchema, getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure } from "../../trpc";
export const metadataProcedures = {
schema: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Get the current Custom JSX widget JSON Schema." } })
.query(() => getCustomWidgetJsonSchema()),
getAuthoringPrompt: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Get the current Custom Widget authoring instructions." } })
.query(() => ({
version: 2,
prompt: CUSTOM_WIDGET_MCP_AUTHORING_PROMPT,
resources: [
"homarr://custom-widgets/schema",
"homarr://custom-widgets/components",
"homarr://custom-widgets/skill",
],
httpResources: ["/api/custom-widgets/schema", "/api/custom-widgets/components", "/api/custom-widgets/skill"],
})),
getSkill: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Get the portable Homarr Custom Widget skill." } })
.query(() => getCustomWidgetSkill()),
validate: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Validate one Custom JSX widget without saving it." } })
.input(z.object({ widget: z.unknown() }))
.query(({ input }) => {
const result = customWidgetImportSchema.safeParse(input.widget);
if (!result.success) {
return {
valid: false as const,
issues: result.error.issues.map((issue) => ({
path: issue.path.map(String).join("."),
code: issue.code,
message: issue.message,
})),
};
}
return { valid: true as const, issues: [], widget: result.data };
}),
};
@@ -1,15 +0,0 @@
import { TRPCError } from "@trpc/server";
import superjson from "superjson";
import type { createLogger } from "@homarr/core/infrastructure/logs";
type Logger = ReturnType<typeof createLogger>;
export function parseDisplayConfig(raw: string, id: string, logger: Logger, context: string): Record<string, unknown> {
try {
return superjson.parse(raw) as Record<string, unknown>;
} catch {
logger.error(context, { id });
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Widget has corrupt display configuration" });
}
}
@@ -0,0 +1,92 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { permissionRequiredProcedure } from "../../trpc";
import {
getPreviewRequestSource,
previewSessionRequestSchema,
recordPreviewJournal,
} from "./preview-procedure-helpers";
import { executeCustomWidgetRequest, invalidateCustomWidgetResponseCache } from "./request-executor";
import { renderRequestBody, renderRequestTarget, resolveCustomWidgetRequestValues } from "./request-manifest";
import { acquireCustomWidgetRequestLimit } from "./request-limits";
import { getPreviewSession } from "./preview-sessions";
export const previewActionProcedures = {
previewAction: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: { enabled: true, description: "Simulate or run one named action in a custom widget preview session." },
})
.input(previewSessionRequestSchema.extend({ confirmed: z.boolean().optional() }))
.mutation(async ({ ctx, input }) => {
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
const definition = session.requests[input.requestId];
if (definition?.kind !== "action")
throw new TRPCError({ code: "NOT_FOUND", message: "Preview action was not found" });
const request = { id: input.requestId, ...definition };
const resolved = getPreviewRequestSource(session, request.source);
if (!resolved) throw new TRPCError({ code: "NOT_FOUND", message: "Preview source was not found" });
const params = resolveCustomWidgetRequestValues(request, session.options, input.params);
const targetUrl = renderRequestTarget(resolved.source.baseUrl, request, params);
const body = renderRequestBody(request, params);
if (!session.liveActions) {
await recordPreviewJournal(session, {
requestId: request.id,
kind: "action",
method: request.method,
path: request.path,
status: null,
durationMs: 0,
simulated: true,
});
return { ok: true, status: 0, statusText: "Simulated", data: null, simulated: true as const };
}
if ((request.confirmation || request.method === "DELETE") && input.confirmed !== true) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "This action requires confirmation" });
}
const release = await acquireCustomWidgetRequestLimit({
category: request.method === "DELETE" ? "delete" : "action",
userId: ctx.session.user.id,
itemId: `preview:${session.id}`,
definitionId: session.definitionId ?? `preview:${session.id}`,
});
const startedAt = Date.now();
try {
const response = await executeCustomWidgetRequest({
baseUrl: resolved.source.baseUrl,
targetUrl,
method: request.method,
body,
staticHeaders: request.headers,
auth: request.auth === "none" ? undefined : resolved.auth,
networkScope: resolved.source.networkScope,
kind: "action",
});
if (response.ok && request.invalidates?.length) {
invalidateCustomWidgetResponseCache(
request.invalidates.map((requestId) => `custom-jsx:preview:${session.id}:${requestId}:`),
);
}
await recordPreviewJournal(session, {
requestId: request.id,
kind: "action",
method: request.method,
path: request.path,
status: response.status,
durationMs: Date.now() - startedAt,
simulated: false,
});
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
data: response.data,
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
simulated: false as const,
};
} finally {
await release();
}
}),
};
@@ -0,0 +1,146 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { decryptSecret } from "@homarr/common/server";
import {
customWidgetDefinitionSchema,
customWidgetSecretsInputSchema,
getCustomWidgetConfirmation,
getCustomWidgetDefaultOptions,
validateCustomWidgetOptions,
} from "@homarr/custom-widgets/core";
import { eq } from "@homarr/db";
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 previewCreateInputSchema = z.object({
definition: customWidgetDefinitionSchema,
secrets: customWidgetSecretsInputSchema.default([]),
definitionId: z.string().optional(),
options: z.record(z.string(), z.unknown()).optional(),
});
const previewCreateProcedure = permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Create a short-lived preview session for one unsaved custom widget." } })
.input(previewCreateInputSchema)
.mutation(async ({ ctx, input }) => {
const options = input.options ?? getCustomWidgetDefaultOptions(input.definition.options);
const optionIssues = validateCustomWidgetOptions(input.definition.options, options);
if (optionIssues.length > 0) {
const issue = optionIssues[0];
throw new TRPCError({
code: "BAD_REQUEST",
message: issue ? `${issue.path}: ${issue.message}` : "Preview options are invalid",
});
}
const secrets = [...input.secrets];
if (input.definitionId) {
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.definitionId),
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: "BAD_REQUEST",
message: "Source security settings changed; re-enter its credentials to preview this definition",
});
}
}
for (const secret of existing.secrets) {
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) });
}
}
}
const invalid = secrets.find((secret) => {
const source = input.definition.sources[secret.sourceId];
const authType = typeof source?.auth === "string" ? source.auth : source?.auth.type;
const kinds =
authType === "basic"
? ["username", "password"]
: source && authType && ["bearer", "apiKeyHeader", "apiKeyQuery"].includes(authType)
? ["apiKey"]
: [];
return !source || !kinds.includes(secret.kind);
});
if (invalid) {
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown secret source '${invalid.sourceId}'` });
}
const previewSession = await createPreviewSession({
userId: ctx.session.user.id,
sources: input.definition.sources,
requests: input.definition.requests,
name: input.definition.name,
template: input.definition.template,
optionDefinitions: input.definition.options,
options,
secrets,
definitionId: input.definitionId,
});
return {
success: true as const,
previewSession,
previewUrl: new URL(
`/manage/custom-widgets/preview/${previewSession.id}`,
ctx.baseUrl ?? "http://localhost",
).toString(),
definition: {
template: input.definition.template,
defaultOptions: getCustomWidgetDefaultOptions(input.definition.options),
},
};
});
export const previewBaseProcedures = {
previewCreate: previewCreateProcedure,
previewGet: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ sessionId: z.string() }))
.query(async ({ ctx, input }) => {
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
return {
id: session.id,
name: session.name,
expiresAt: session.expiresAt,
template: session.template,
optionDefinitions: session.optionDefinitions,
options: session.options,
requests: Object.entries(session.requests).map(([id, request]) => ({
id,
kind: request.kind,
method: request.method,
minimumBoardPermission: request.permission,
trigger: request.trigger,
confirmation: getCustomWidgetConfirmation(request),
invalidates: request.invalidates,
})),
liveActions: session.liveActions,
};
}),
};
@@ -0,0 +1,55 @@
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
import { z } from "zod/v4";
import { appendPreviewJournal } from "./preview-sessions";
import { getPreviewSessionSecrets } from "./preview-sessions";
import type { CustomWidgetPreviewSession } from "./preview-sessions";
import { resolveCustomWidgetRequestValues } from "./request-manifest";
const logger = createLogger({ module: "custom-widget-preview" });
export const previewSessionRequestSchema = z.object({
sessionId: z.string().min(1),
requestId: z.string().min(1).max(64),
params: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).default({}),
});
export const recordPreviewJournal = async (...args: Parameters<typeof appendPreviewJournal>) => {
try {
await appendPreviewJournal(...args);
} catch (error) {
logger.warn("Failed to append custom widget preview journal", {
sessionId: args[0].id,
errorName: error instanceof Error ? error.name : "UnknownError",
});
}
};
export const getPreviewRequestSource = (session: CustomWidgetPreviewSession, sourceId: string) => {
const source = session.sources[sourceId];
if (!source) return null;
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
const auth =
authType === "none"
? undefined
: {
type: authType,
secrets: getPreviewSessionSecrets(session, sourceId),
headerName:
typeof source.auth === "object" && source.auth.type === "apiKeyHeader"
? source.auth.name
: typeof source.auth === "object" && source.auth.type === "apiKeyQuery"
? source.auth.name
: undefined,
};
return { source, auth };
};
export function resolvePreviewRequestParams(
request: CustomJsxRequest,
options: Record<string, unknown>,
suppliedParams: Record<string, string | number | boolean>,
) {
return resolveCustomWidgetRequestValues(request, options, suppliedParams);
}
@@ -0,0 +1,95 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { permissionRequiredProcedure } from "../../trpc";
import {
getPreviewRequestSource,
previewSessionRequestSchema,
recordPreviewJournal,
resolvePreviewRequestParams,
} from "./preview-procedure-helpers";
import { executeCustomWidgetRequest } from "./request-executor";
import { hashRuntimeParams, renderRequestBody, renderRequestTarget } from "./request-manifest";
import { acquireCustomWidgetRequestLimit } from "./request-limits";
import { getPreviewJournal, getPreviewSession, setPreviewSessionLiveActions } from "./preview-sessions";
export const previewQueryProcedures = {
previewQuery: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description: "Run one named query from a short-lived custom widget preview session.",
},
})
.input(previewSessionRequestSchema)
.query(async ({ ctx, input }) => {
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
const definition = session.requests[input.requestId];
if (definition?.kind !== "query")
throw new TRPCError({ code: "NOT_FOUND", message: "Preview query was not found" });
const request = { id: input.requestId, ...definition };
const resolved = getPreviewRequestSource(session, request.source);
if (!resolved) throw new TRPCError({ code: "NOT_FOUND", message: "Preview source was not found" });
const params = resolvePreviewRequestParams(request, session.options, input.params);
const targetUrl = renderRequestTarget(resolved.source.baseUrl, request, params);
const body = renderRequestBody(request, params);
const release = await acquireCustomWidgetRequestLimit({
category: "query",
userId: ctx.session.user.id,
itemId: `preview:${session.id}`,
definitionId: session.definitionId ?? `preview:${session.id}`,
});
const startedAt = Date.now();
try {
const response = await executeCustomWidgetRequest({
baseUrl: resolved.source.baseUrl,
targetUrl,
method: request.method,
body,
staticHeaders: request.headers,
auth: request.auth === "none" ? undefined : resolved.auth,
networkScope: resolved.source.networkScope,
kind: "query",
cacheKey: `custom-jsx:preview:${session.id}:${request.id}:${hashRuntimeParams(params)}`,
cacheTtlSeconds: request.cacheSeconds,
});
await recordPreviewJournal(session, {
requestId: request.id,
kind: "query",
method: request.method,
path: request.path,
status: response.status,
durationMs: Date.now() - startedAt,
simulated: false,
});
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
data: response.data,
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
};
} finally {
await release();
}
}),
setPreviewLiveActions: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ sessionId: z.string().min(1), enabled: z.boolean() }))
.mutation(async ({ ctx, input }) =>
setPreviewSessionLiveActions(input.sessionId, ctx.session.user.id, input.enabled),
),
previewJournal: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description: "Read the redacted request journal for a custom widget preview session.",
},
})
.input(z.object({ sessionId: z.string().min(1) }))
.query(async ({ ctx, input }) => getPreviewJournal(input.sessionId, ctx.session.user.id)),
};
@@ -0,0 +1,111 @@
import { createId } from "@homarr/common";
import { decryptSecret, encryptSecret } from "@homarr/common/server";
import { createRedisClient } from "@homarr/core/infrastructure/redis";
import type { RedisClient } from "@homarr/core/infrastructure/redis";
import { CustomWidgetPreviewSessionService } from "@homarr/custom-widgets/server";
import type {
CreatePreviewSessionInput,
CustomWidgetPreviewJournalEntry,
CustomWidgetPreviewSession,
PreviewSessionStore,
} from "@homarr/custom-widgets/server";
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) {}
public async saveSession(id: string, value: unknown, ttlMs: number) {
await this.redis.set(`${SESSION_PREFIX}${id}`, JSON.stringify(value), "PX", ttlMs);
}
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}`);
}
public async appendJournal(id: string, value: unknown, maxEntries: number, ttlMs: number) {
const key = `${JOURNAL_PREFIX}${id}`;
await this.redis
.multi()
.lpush(key, JSON.stringify(value))
.ltrim(key, 0, maxEntries - 1)
.pexpire(key, ttlMs)
.exec();
}
public getJournal(id: string, maxEntries: number) {
return this.redis.lrange(`${JOURNAL_PREFIX}${id}`, 0, maxEntries - 1);
}
}
let service: CustomWidgetPreviewSessionService | undefined;
function getService() {
if (service) return service;
const useLocal = process.env.CI !== undefined || process.env.NODE_ENV === "test";
service = new CustomWidgetPreviewSessionService({
createId,
encrypt: encryptSecret,
decrypt: (value) => decryptSecret(value as `${string}.${string}`),
store: useLocal ? undefined : new RedisPreviewSessionStore(createRedisClient()),
});
return service;
}
async function call<T>(operation: () => Promise<T>): Promise<T> {
try {
return await operation();
} catch (error) {
toTrpcError(error);
}
}
export type { CreatePreviewSessionInput, CustomWidgetPreviewJournalEntry, CustomWidgetPreviewSession };
export const createPreviewSession = (input: CreatePreviewSessionInput) => call(() => getService().create(input));
export const getPreviewSession = (id: string, userId: string) => call(() => getService().get(id, userId));
export const setPreviewSessionLiveActions = (id: string, userId: string, enabled: boolean) =>
call(() => getService().setLiveActions(id, userId, enabled));
export const appendPreviewJournal = (
session: CustomWidgetPreviewSession,
entry: Omit<CustomWidgetPreviewJournalEntry, "id" | "timestamp">,
) => call(() => getService().appendJournal(session, entry));
export const getPreviewJournal = (id: string, userId: string) => call(() => getService().getJournal(id, userId));
export const getPreviewSessionSecrets = (session: CustomWidgetPreviewSession, sourceId: string) =>
getService().getSecrets(session, sourceId);
export const setPreviewSessionSecrets = (id: string, userId: string, secrets: CreatePreviewSessionInput["secrets"]) =>
call(() => getService().setSecrets(id, userId, secrets));
export const configurePreviewSessionSource = (
id: string,
userId: string,
sourceId: string,
source: CreatePreviewSessionInput["sources"][string],
secrets: CreatePreviewSessionInput["secrets"],
) => call(() => getService().configureSource(id, userId, sourceId, source, secrets));
@@ -0,0 +1,87 @@
import { createLogger } from "@homarr/core/infrastructure/logs";
import {
assertJsonBudget as assertDomainJsonBudget,
assertSafeStaticHeaders as assertDomainSafeStaticHeaders,
classifyAddress,
executeCustomWidgetRequest as executeDomainRequest,
invalidateCustomWidgetResponseCache as invalidateDomainResponseCache,
MAX_REQUEST_BODY_BYTES,
MAX_RESPONSE_BODY_BYTES,
MAX_RESPONSE_JSON_DEPTH,
MAX_RESPONSE_JSON_NODES,
resolveAndValidateHost as resolveDomainHost,
resolveSameOriginTarget as resolveDomainTarget,
validateCustomWidgetUrl as validateDomainUrl,
} from "@homarr/custom-widgets/server";
import type { CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "@homarr/custom-widgets/server";
import { toTrpcError } from "./domain-error";
export type {
CustomWidgetAuthConfig,
CustomWidgetHttpRequest,
CustomWidgetHttpResponse,
} from "@homarr/custom-widgets/server";
export {
classifyAddress,
MAX_REQUEST_BODY_BYTES,
MAX_RESPONSE_BODY_BYTES,
MAX_RESPONSE_JSON_DEPTH,
MAX_RESPONSE_JSON_NODES,
};
const logger = createLogger({ module: "custom-widget:http" });
export function assertJsonBudget(value: unknown): void {
try {
assertDomainJsonBudget(value);
} catch (error) {
toTrpcError(error);
}
}
export function assertSafeStaticHeaders(headers: Record<string, string> | undefined): void {
try {
assertDomainSafeStaticHeaders(headers);
} catch (error) {
toTrpcError(error);
}
}
export function validateCustomWidgetUrl(value: string | URL): URL {
try {
return validateDomainUrl(value);
} catch (error) {
toTrpcError(error);
}
}
export function resolveSameOriginTarget(baseUrl: string, target?: string | URL): URL {
try {
return resolveDomainTarget(baseUrl, target);
} catch (error) {
toTrpcError(error);
}
}
export async function resolveAndValidateHost(hostname: string, scope: CustomWidgetHttpRequest["networkScope"]) {
try {
return await resolveDomainHost(hostname, scope);
} catch (error) {
toTrpcError(error);
}
}
export async function executeCustomWidgetRequest(input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> {
try {
return await executeDomainRequest({
...input,
logError: (event) => logger.error("Custom widget request failed", event),
});
} catch (error) {
toTrpcError(error);
}
}
export const invalidateCustomWidgetResponseCache = (prefixes: readonly string[]) =>
invalidateDomainResponseCache(prefixes);
@@ -0,0 +1,75 @@
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createRedisClient } from "@homarr/core/infrastructure/redis";
import type { RedisClient } from "@homarr/core/infrastructure/redis";
import { CustomWidgetRequestLimiter } from "@homarr/custom-widgets/server";
import type { RequestLimitInput, RequestLimitStore } from "@homarr/custom-widgets/server";
import { toTrpcError } from "./domain-error";
const logger = createLogger({ module: "custom-widget:limits" });
const RATE_SCRIPT = `
local count = redis.call('INCR', KEYS[1])
if count == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
return { count, redis.call('PTTL', KEYS[1]) }
`;
const ACQUIRE_SCRIPT = `
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 = `
return redis.call('ZREM', KEYS[1], ARGV[1])
`;
class RedisRequestLimitStore implements RequestLimitStore {
public constructor(private readonly redis: RedisClient) {}
public async incrementRate(key: string, windowMs: number) {
const [count, retryAfterMs] = (await this.redis.eval(RATE_SCRIPT, 1, key, windowMs)) as [number, number];
return { count: Number(count), retryAfterMs: Number(retryAfterMs) || windowMs };
}
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, ownerId: string) {
await this.redis.eval(RELEASE_SCRIPT, 1, key, ownerId);
}
}
let limiter: CustomWidgetRequestLimiter | undefined;
function getLimiter() {
if (limiter) return limiter;
const useLocal = process.env.CI !== undefined || process.env.NODE_ENV === "test";
const store = useLocal ? undefined : new RedisRequestLimitStore(createRedisClient());
limiter = new CustomWidgetRequestLimiter({
store,
onStoreError: (error) =>
logger.error("Custom widget rate limiter is unavailable", {
errorName: error instanceof Error ? error.name : "UnknownError",
}),
});
return limiter;
}
export async function acquireCustomWidgetRequestLimit(input: RequestLimitInput): Promise<() => Promise<void>> {
try {
const release = await getLimiter().acquire(input);
return async () => {
try {
await release();
} catch (error) {
logger.error("Failed to release custom widget request capacity", {
errorName: error instanceof Error ? error.name : "UnknownError",
});
}
};
} catch (error) {
toTrpcError(error);
}
}
@@ -0,0 +1,43 @@
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
import {
hashRuntimeParams,
renderRequestBody as renderDomainBody,
renderRequestTarget as renderDomainTarget,
resolveCustomWidgetRequestValues as resolveDomainValues,
} from "@homarr/custom-widgets/server";
import type { CustomJsxRuntimeParams } from "@homarr/custom-widgets/server";
import { toTrpcError } from "./domain-error";
export type { CustomJsxRuntimeParams } from "@homarr/custom-widgets/server";
export { hashRuntimeParams };
type CustomJsxResolvedValues = ReturnType<typeof resolveDomainValues>;
export function resolveCustomWidgetRequestValues(
request: CustomJsxRequest,
options: Record<string, unknown>,
params: CustomJsxRuntimeParams = {},
) {
try {
return resolveDomainValues(request, options, params);
} catch (error) {
return toTrpcError(error);
}
}
export function renderRequestBody(request: CustomJsxRequest, params: CustomJsxResolvedValues) {
try {
return renderDomainBody(request, params);
} catch (error) {
return toTrpcError(error);
}
}
export function renderRequestTarget(baseUrl: string, request: CustomJsxRequest, params: CustomJsxResolvedValues) {
try {
return renderDomainTarget(baseUrl, request, params);
} catch (error) {
return toTrpcError(error);
}
}
@@ -0,0 +1,289 @@
import { TRPCError } from "@trpc/server";
import { encryptSecret } from "@homarr/common/server";
import type { Database } from "@homarr/db";
import { and, eq, handleTransactionsAsync } from "@homarr/db";
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
import type { CustomWidgetCreateInput, CustomWidgetSecretKind, CustomWidgetSource } from "@homarr/custom-widgets/core";
import { customWidgetSourceSchema, hasSameCustomWidgetSourceAuthentication } from "@homarr/custom-widgets/core";
import { assertSecretSources, hasSameSecretBinding } from "./secret-policy";
import { parseStoredCustomWidgetDefinition, serializeCustomWidgetDefinition } from "./stored-definition";
type CustomWidgetSecretInput = CustomWidgetCreateInput["secrets"][number];
type ConfigureCustomWidgetSourceInput = {
definitionId: string;
sourceId: string;
baseUrl: string;
networkScope?: CustomWidgetSource["networkScope"];
secrets: CustomWidgetCreateInput["secrets"];
expectedSource?: CustomWidgetSource;
};
type SourceConfigurationIssue = "definition-not-found" | "source-not-found" | "binding-changed";
class SourceConfigurationError extends Error {
constructor(readonly issue: SourceConfigurationIssue) {
super(`Custom widget source configuration failed: ${issue}`);
this.name = "SourceConfigurationError";
}
}
export async function setCustomWidgetSecret(db: Database, definitionId: string, secret: CustomWidgetSecretInput) {
const updatedAt = new Date();
const secretRow = {
definitionId,
sourceId: secret.sourceId,
kind: secret.kind,
encryptedValue: encryptSecret(secret.value),
updatedAt,
};
await handleTransactionsAsync(db, {
async handleAsync(database, schema) {
await database.transaction(async (transaction) => {
await transaction
.delete(schema.customWidgetSecrets)
.where(
and(
eq(schema.customWidgetSecrets.definitionId, definitionId),
eq(schema.customWidgetSecrets.sourceId, secret.sourceId),
eq(schema.customWidgetSecrets.kind, secret.kind),
),
);
await transaction.insert(schema.customWidgetSecrets).values(secretRow);
await transaction
.update(schema.customWidgetDefinitions)
.set({ updatedAt })
.where(eq(schema.customWidgetDefinitions.id, definitionId));
});
},
handleSync(database) {
database.transaction((transaction) => {
transaction
.delete(customWidgetSecrets)
.where(
and(
eq(customWidgetSecrets.definitionId, definitionId),
eq(customWidgetSecrets.sourceId, secret.sourceId),
eq(customWidgetSecrets.kind, secret.kind),
),
)
.run();
transaction.insert(customWidgetSecrets).values(secretRow).run();
transaction
.update(customWidgetDefinitions)
.set({ updatedAt })
.where(eq(customWidgetDefinitions.id, definitionId))
.run();
});
},
});
}
export async function configureCustomWidgetSource(db: Database, input: ConfigureCustomWidgetSourceInput) {
try {
return await persistCustomWidgetSourceConfiguration(db, input);
} catch (error) {
if (!(error instanceof SourceConfigurationError)) throw error;
if (error.issue === "definition-not-found") throw new TRPCError({ code: "NOT_FOUND" });
if (error.issue === "source-not-found") {
throw new TRPCError({ code: "NOT_FOUND", message: "Widget source not found" });
}
throw new TRPCError({
code: "CONFLICT",
message: "Widget source authentication changed while it was being configured",
});
}
}
export async function configureCustomWidgetSourceFromRequest(
db: Database,
input: ConfigureCustomWidgetSourceInput & { expectedSource: CustomWidgetSource },
) {
try {
return {
status: "configured" as const,
source: await persistCustomWidgetSourceConfiguration(db, input),
};
} catch (error) {
if (!(error instanceof SourceConfigurationError)) throw error;
return { status: error.issue } as const;
}
}
async function persistCustomWidgetSourceConfiguration(db: Database, input: ConfigureCustomWidgetSourceInput) {
let configuredSource: CustomWidgetSource | undefined;
const secretRows = input.secrets.map((secret) => ({
definitionId: input.definitionId,
sourceId: input.sourceId,
kind: secret.kind,
encryptedValue: encryptSecret(secret.value),
updatedAt: new Date(),
}));
await handleTransactionsAsync(db, {
async handleAsync(database, schema) {
await database.transaction(async (transaction) => {
const [stored] = await transaction
.select()
.from(schema.customWidgetDefinitions)
.where(eq(schema.customWidgetDefinitions.id, input.definitionId))
.limit(1)
.for("update");
const sourceUpdate = prepareSourceUpdate(stored, input);
configuredSource = sourceUpdate.source;
await transaction
.update(schema.customWidgetDefinitions)
.set(sourceUpdate.definitionChanges)
.where(eq(schema.customWidgetDefinitions.id, input.definitionId));
if (sourceUpdate.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)
.where(
and(
eq(schema.customWidgetSecrets.definitionId, input.definitionId),
eq(schema.customWidgetSecrets.sourceId, input.sourceId),
eq(schema.customWidgetSecrets.kind, secret.kind),
),
);
await transaction.insert(schema.customWidgetSecrets).values(secret);
}
});
},
handleSync(database) {
database.transaction(
(transaction) => {
const stored = transaction
.select()
.from(customWidgetDefinitions)
.where(eq(customWidgetDefinitions.id, input.definitionId))
.limit(1)
.get();
const sourceUpdate = prepareSourceUpdate(stored, input);
configuredSource = sourceUpdate.source;
transaction
.update(customWidgetDefinitions)
.set(sourceUpdate.definitionChanges)
.where(eq(customWidgetDefinitions.id, input.definitionId))
.run();
if (sourceUpdate.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)
.where(
and(
eq(customWidgetSecrets.definitionId, input.definitionId),
eq(customWidgetSecrets.sourceId, input.sourceId),
eq(customWidgetSecrets.kind, secret.kind),
),
)
.run();
transaction.insert(customWidgetSecrets).values(secret).run();
}
},
{ behavior: "immediate" },
);
},
});
if (!configuredSource) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
return configuredSource;
}
function prepareSourceUpdate(
stored: Parameters<typeof parseStoredCustomWidgetDefinition>[0] | undefined,
input: ConfigureCustomWidgetSourceInput,
) {
if (!stored) throw new SourceConfigurationError("definition-not-found");
const definition = parseStoredCustomWidgetDefinition(stored);
const previousSource = definition.sources[input.sourceId];
if (!previousSource) throw new SourceConfigurationError("source-not-found");
if (input.expectedSource && !hasSameCustomWidgetSourceAuthentication(previousSource, input.expectedSource)) {
throw new SourceConfigurationError("binding-changed");
}
const source = customWidgetSourceSchema.parse({
...previousSource,
baseUrl: input.baseUrl,
networkScope: input.networkScope ?? previousSource.networkScope,
});
assertSecretSources({ [input.sourceId]: source }, input.secrets);
return {
source,
bindingChanged: !hasSameSecretBinding(previousSource, source),
definitionChanges: {
...serializeCustomWidgetDefinition({
...definition,
sources: { ...definition.sources, [input.sourceId]: source },
}),
updatedAt: new Date(),
},
};
}
export async function clearCustomWidgetSecret(
db: Database,
input: {
definitionId: string;
sourceId: string;
kind: CustomWidgetSecretKind;
},
) {
const updatedAt = new Date();
await handleTransactionsAsync(db, {
async handleAsync(database, schema) {
await database.transaction(async (transaction) => {
await transaction
.delete(schema.customWidgetSecrets)
.where(
and(
eq(schema.customWidgetSecrets.definitionId, input.definitionId),
eq(schema.customWidgetSecrets.sourceId, input.sourceId),
eq(schema.customWidgetSecrets.kind, input.kind),
),
);
await transaction
.update(schema.customWidgetDefinitions)
.set({ updatedAt })
.where(eq(schema.customWidgetDefinitions.id, input.definitionId));
});
},
handleSync(database) {
database.transaction((transaction) => {
transaction
.delete(customWidgetSecrets)
.where(
and(
eq(customWidgetSecrets.definitionId, input.definitionId),
eq(customWidgetSecrets.sourceId, input.sourceId),
eq(customWidgetSecrets.kind, input.kind),
),
)
.run();
transaction
.update(customWidgetDefinitions)
.set({ updatedAt })
.where(eq(customWidgetDefinitions.id, input.definitionId))
.run();
});
},
});
}
@@ -0,0 +1,41 @@
import { TRPCError } from "@trpc/server";
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<typeof getCustomWidgetRequiredSecretKinds>[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<string, { auth: string | { type: string } }>,
secrets: readonly { sourceId: string; kind: "apiKey" | "username" | "password" }[],
) {
const invalid = secrets.find((secret) => {
const source = sources[secret.sourceId];
const authType = typeof source?.auth === "string" ? source.auth : source?.auth.type;
return !source || !authType || !new Set<string>(requiredSecretKinds(authType)).has(secret.kind);
});
if (invalid) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Secret does not match source '${invalid.sourceId}' authentication`,
});
}
}
@@ -0,0 +1,178 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { eq } from "@homarr/db";
import { customWidgetDefinitions } from "@homarr/db/schema";
import {
customWidgetSecretInputSchema,
customWidgetSecretsInputSchema,
customWidgetSourceSchema,
} from "@homarr/custom-widgets/core";
import type { CustomWidgetSource } from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure } from "../../trpc";
import {
createCustomWidgetConfigurationRequest,
getCustomWidgetConfigurationRequestForUser,
} from "./configuration-requests";
import { assertSecretSources, requiredSecretKinds } from "./secret-policy";
import { clearCustomWidgetSecret, configureCustomWidgetSource, setCustomWidgetSecret } from "./secret-persistence";
import { getPreviewSession } from "./preview-sessions";
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
const secretRequestInputSchema = z
.object({
requestId: z.string().optional(),
definitionId: z.string().optional(),
previewSessionId: z.string().optional(),
sourceId: z.string().optional(),
})
.superRefine((input, ctx) => {
if (input.requestId) {
if (input.definitionId || input.previewSessionId || input.sourceId) {
ctx.addIssue({ code: "custom", message: "Status checks only accept requestId" });
}
return;
}
if (Boolean(input.definitionId) === Boolean(input.previewSessionId)) {
ctx.addIssue({ code: "custom", message: "Provide exactly one definitionId or previewSessionId" });
}
if (!input.sourceId) ctx.addIssue({ code: "custom", message: "sourceId is required" });
});
export const secretProcedures = {
secretSet: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Set one encrypted secret for a custom widget source." } })
.input(z.object({ definitionId: z.string(), secret: customWidgetSecretInputSchema }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.definitionId),
});
if (!existing) throw new TRPCError({ code: "NOT_FOUND" });
const definition = parseStoredCustomWidgetDefinition(existing);
assertSecretSources(definition.sources, [input.secret]);
await setCustomWidgetSecret(ctx.db, input.definitionId, input.secret);
return { sourceId: input.secret.sourceId, kind: input.secret.kind, isSet: true };
}),
sourceConfigure: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Configure one custom widget API source and its credentials." } })
.input(
z.object({
definitionId: z.string(),
sourceId: z.string(),
baseUrl: z.string(),
networkScope: z.enum(["public", "private", "loopback"]).optional(),
secrets: customWidgetSecretsInputSchema.default([]),
}),
)
.mutation(async ({ ctx, input }) => {
const stored = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.definitionId),
});
if (!stored) throw new TRPCError({ code: "NOT_FOUND" });
const definition = parseStoredCustomWidgetDefinition(stored);
const current = definition.sources[input.sourceId];
if (!current) throw new TRPCError({ code: "NOT_FOUND", message: "Widget source not found" });
const source = customWidgetSourceSchema.parse({
...current,
baseUrl: input.baseUrl,
networkScope: input.networkScope ?? current.networkScope,
});
assertSecretSources({ [input.sourceId]: source }, input.secrets);
const configuredSource = await configureCustomWidgetSource(ctx.db, {
definitionId: input.definitionId,
sourceId: input.sourceId,
baseUrl: source.baseUrl,
networkScope: input.networkScope,
secrets: input.secrets,
});
return {
definitionId: input.definitionId,
sourceId: input.sourceId,
baseUrl: configuredSource.baseUrl,
networkScope: configuredSource.networkScope,
configuredSecrets: input.secrets.map(({ kind }) => kind),
};
}),
configurationRequestUser: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Create or check a short-lived user source-configuration request." } })
.input(secretRequestInputSchema)
.mutation(async ({ ctx, input }) => {
if (input.requestId) {
const request = await getCustomWidgetConfigurationRequestForUser(input.requestId, ctx.session.user.id);
if (!request) throw new TRPCError({ code: "NOT_FOUND" });
return { requestId: request.id, status: request.status, expiresAt: request.expiresAt };
}
let widgetName: string;
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) {
const stored = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.definitionId),
});
if (!stored) throw new TRPCError({ code: "NOT_FOUND" });
const definition = parseStoredCustomWidgetDefinition(stored);
widgetName = definition.name;
const candidate = definition.sources[input.sourceId ?? ""];
source = candidate
? {
id: input.sourceId ?? "",
name: candidate.name ?? input.sourceId ?? "",
auth: candidate.auth,
value: candidate,
}
: undefined;
target = { type: "definition", id: input.definitionId };
} else {
const preview = await getPreviewSession(input.previewSessionId ?? "", ctx.session.user.id);
widgetName = preview.name;
const candidate = preview.sources[input.sourceId ?? ""];
source = candidate
? {
id: input.sourceId ?? "",
name: candidate.name ?? input.sourceId ?? "",
auth: candidate.auth,
value: candidate,
}
: undefined;
target = { type: "preview", id: preview.id };
}
if (!source) throw new TRPCError({ code: "NOT_FOUND", message: "Widget source not found" });
const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)];
const request = await createCustomWidgetConfigurationRequest({
userId: ctx.session.user.id,
target,
widgetName,
sourceId: source.id,
sourceName: source.name,
source: source.value,
kinds,
});
return {
requestId: request.id,
status: request.status,
expiresAt: request.expiresAt,
url: new URL(`/custom-widget-configuration/${request.id}`, ctx.baseUrl ?? "http://localhost").toString(),
};
}),
secretClear: permissionRequiredProcedure
.requiresPermission("admin")
.input(
z.object({ definitionId: z.string(), sourceId: z.string(), kind: z.enum(["apiKey", "username", "password"]) }),
)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.definitionId),
});
if (!existing) throw new TRPCError({ code: "NOT_FOUND" });
await clearCustomWidgetSecret(ctx.db, input);
return { sourceId: input.sourceId, kind: input.kind, isSet: false };
}),
};
@@ -0,0 +1,67 @@
import { parse as parseSuperJson, stringify as stringifySuperJson } from "superjson";
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import type { CustomWidgetDefinition } from "@homarr/db/schema";
type StoredCustomWidgetDefinition = Pick<
CustomWidgetDefinition,
"name" | "description" | "iconUrl" | "sources" | "requests" | "options" | "template"
>;
export interface StoredCustomWidgetIssue {
path?: string;
message: string;
}
export type StoredCustomWidgetParseResult =
| { success: true; widget: HomarrCustomWidgetV2 }
| { success: false; issues: StoredCustomWidgetIssue[] };
export function serializeCustomWidgetDefinition(definition: HomarrCustomWidgetV2) {
return {
name: definition.name,
description: definition.description ?? null,
iconUrl: definition.iconUrl ?? null,
sources: stringifySuperJson(definition.sources),
requests: stringifySuperJson(definition.requests),
options: stringifySuperJson(definition.options),
template: definition.template,
};
}
export function parseStoredCustomWidgetDefinition(definition: StoredCustomWidgetDefinition): HomarrCustomWidgetV2 {
return customWidgetDefinitionSchema.parse({
$schema: "homarr-custom-widget-v2",
name: definition.name,
description: definition.description ?? undefined,
iconUrl: definition.iconUrl ?? undefined,
sources: parseSuperJson(definition.sources),
requests: parseSuperJson(definition.requests),
options: parseSuperJson(definition.options),
template: definition.template,
});
}
export function safeParseStoredCustomWidgetDefinition(
definition: StoredCustomWidgetDefinition,
): StoredCustomWidgetParseResult {
try {
return { success: true, widget: parseStoredCustomWidgetDefinition(definition) };
} catch (error) {
if (error instanceof Error && "issues" in error && Array.isArray(error.issues)) {
return {
success: false,
issues: error.issues.slice(0, 10).map((issue: { path?: PropertyKey[]; message?: unknown }) => ({
path: issue.path?.map(String).join("."),
message: typeof issue.message === "string" ? issue.message : "Invalid stored widget value",
})),
};
}
return {
success: false,
issues: [{ message: "Stored widget data could not be read" }],
};
}
}
@@ -0,0 +1,151 @@
import { createHash } from "node:crypto";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import { and, eq } from "@homarr/db";
import { customWidgetDefinitions } from "@homarr/db/schema";
import { permissionRequiredProcedure } from "../../trpc";
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
const logger = createLogger({ module: "custom-widget" });
const getTemplateRevision = (template: string) => createHash("sha256").update(template).digest("hex").slice(0, 16);
const validateTemplate = (definition: ReturnType<typeof parseStoredCustomWidgetDefinition>, template: string) => {
if (template.trim().length === 0) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Template must not be empty" });
}
const result = customWidgetDefinitionSchema.safeParse({ ...definition, template });
if (!result.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: result.error.issues
.map((issue) => `${issue.path.map(String).join(".") || "template"}: ${issue.message}`)
.join("; "),
});
}
return result.data.template;
};
export const templateProcedures = {
readTemplate: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!definition) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
const template = parseStoredCustomWidgetDefinition(definition).template;
return {
id: definition.id,
name: definition.name,
template,
templateLines: template.split("\n"),
revision: getTemplateRevision(template),
};
}),
writeTemplate: permissionRequiredProcedure
.requiresPermission("admin")
.input(
z
.object({ id: z.string(), template: z.string().optional(), templateLines: z.array(z.string()).optional() })
.refine((data) => data.template !== undefined || data.templateLines !== undefined, {
message: "Provide either template or templateLines",
})
.refine((data) => !(data.template !== undefined && data.templateLines !== undefined), {
message: "Provide template or templateLines, not both",
}),
)
.mutation(async ({ ctx, input }) => {
const resolved = input.templateLines?.join("\n") ?? input.template ?? "";
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
const template = validateTemplate(parseStoredCustomWidgetDefinition(existing), resolved);
await ctx.db
.update(customWidgetDefinitions)
.set({ template, updatedAt: new Date() })
.where(eq(customWidgetDefinitions.id, input.id));
logger.info("Updated custom widget template", { id: input.id });
return { id: input.id, template, templateLines: template.split("\n"), revision: getTemplateRevision(template) };
}),
templatePatch: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Patch selected JSX template lines using an optimistic revision." } })
.input(
z.object({
id: z.string(),
expectedRevision: z.string().length(16),
edits: z
.array(
z.object({
startLine: z.number().int().min(1),
deleteCount: z.number().int().min(0),
replacementLines: z.array(z.string()),
}),
)
.min(1)
.max(100),
}),
)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
const definition = parseStoredCustomWidgetDefinition(existing);
if (getTemplateRevision(definition.template) !== input.expectedRevision) {
throw new TRPCError({
code: "CONFLICT",
message: "Template changed since it was read. Read it again and retry.",
});
}
const lines = definition.template.split("\n");
const edits = input.edits.toSorted((left, right) => right.startLine - left.startLine);
let nextHigherStart = lines.length + 2;
for (const edit of edits) {
if (edit.startLine > lines.length + 1 || edit.startLine + edit.deleteCount - 1 > lines.length) {
throw new TRPCError({ code: "BAD_REQUEST", message: `Edit at line ${edit.startLine} is out of range` });
}
if (edit.startLine + Math.max(edit.deleteCount, 1) > nextHigherStart) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Template patch edits must not overlap" });
}
nextHigherStart = edit.startLine;
lines.splice(edit.startLine - 1, edit.deleteCount, ...edit.replacementLines);
}
const template = validateTemplate(definition, lines.join("\n"));
const updateResult = (await ctx.db
.update(customWidgetDefinitions)
.set({ template, updatedAt: new Date() })
.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;
}
@@ -0,0 +1,141 @@
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, handleTransactionsAsync } from "@homarr/db";
import { customWidgetDefinitions, customWidgetSecrets, legacyCustomWidgetDefinitions } from "@homarr/db/schema";
import { customWidgetImportSchema, customWidgetSecretsInputSchema } from "@homarr/custom-widgets/core";
import { insertCustomWidgetDefinition } from "./definition-insert";
import { permissionRequiredProcedure } from "../../trpc";
import { assertSecretSources, requiredSecretKinds } from "./secret-policy";
import { parseStoredCustomWidgetDefinition, serializeCustomWidgetDefinition } from "./stored-definition";
const logger = createLogger({ module: "custom-widget" });
export const transferProcedures = {
export: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!definition) throw new TRPCError({ code: "NOT_FOUND" });
return parseStoredCustomWidgetDefinition(definition);
}),
import: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ widget: customWidgetImportSchema, secrets: customWidgetSecretsInputSchema.default([]) }))
.mutation(async ({ ctx, input }) => {
assertSecretSources(input.widget.sources, input.secrets);
const id = await insertCustomWidgetDefinition(ctx.db, input.widget, ctx.session.user.id, input.secrets);
logger.info("Imported custom widget definition", { id, name: input.widget.name });
return { id };
}),
migrateLegacy: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Replace one preserved legacy Custom Widget with a validated v2 definition while retaining compatible encrypted credentials.",
},
})
.input(
z.object({
id: z.string(),
widget: customWidgetImportSchema,
secrets: customWidgetSecretsInputSchema.default([]),
}),
)
.mutation(async ({ ctx, input }) => {
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<string>(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);
});
},
handleSync(database) {
database.transaction((transaction) => {
transaction.insert(customWidgetDefinitions).values(definitionRow).run();
if (secretRows.length > 0) transaction.insert(customWidgetSecrets).values(secretRows).run();
});
},
});
logger.info("Created v2 replacement for preserved legacy custom widget definition", {
id: legacy.id,
preservedSecretCount: preservedSecrets.length,
});
return { id: legacy.id, preservedSecretCount: preservedSecrets.length };
}),
};
function canPreserveLegacySecrets(
legacy: typeof legacyCustomWidgetDefinitions.$inferSelect,
widget: z.infer<typeof customWidgetImportSchema>,
) {
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;
}
}
@@ -0,0 +1,181 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import {
applyCustomWidgetSourceSetup,
CUSTOM_WIDGET_SCHEMA,
customWidgetDefinitionSchema,
customWidgetIdentifierSchema,
customWidgetSecretsInputSchema,
getCustomWidgetSecretRequirements,
getCustomWidgetSourceSetups,
} from "@homarr/custom-widgets/core";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { WorkshopBackend } from "@homarr/workshop/backend";
import { resolveHomarrUrlConfig, validateWorkshopWidget } from "@homarr/workshop/schema";
import { env } from "../../env";
import { permissionRequiredProcedure } from "../../trpc";
import { insertCustomWidgetDefinition } from "./definition-insert";
import { assertSecretSources } from "./secret-policy";
const logger = createLogger({ module: "custom-widget:workshop" });
const workshopUrls = resolveHomarrUrlConfig({
homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
workshopApiUrl: env.WORKSHOP_API_URL,
});
const workshop = new WorkshopBackend(workshopUrls.workshopApiUrl);
function throwWorkshopUnavailable(
message: string,
event: "workshop_widget_lookup_failed" | "workshop_widget_search_failed",
): never {
logger.error(message, {
event,
errorName: "WorkshopBackendError",
});
throw new TRPCError({
code: "BAD_GATEWAY",
message: "Workshop is unavailable",
});
}
async function getWorkshopWidget(submissionId: string) {
try {
const submission = await workshop.get(submissionId);
if (submission.type !== "customWidget" || submission.widgetSchema !== CUSTOM_WIDGET_SCHEMA) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Workshop submission is not a compatible Custom JSX widget",
});
}
const validation = validateWorkshopWidget(submission.content);
if (!validation.success) {
throw new TRPCError({ code: "BAD_REQUEST", message: validation.error });
}
return { submission, widget: validation.data };
} catch (error) {
if (error instanceof TRPCError) throw error;
throwWorkshopUnavailable("Workshop widget lookup failed", "workshop_widget_lookup_failed");
}
}
const workshopSearchInputSchema = z.object({
query: z.string().trim().min(1).max(200),
sort: z.enum(["top", "newest", "recent", "discussed"]).default("top"),
limit: z.number().int().min(1).max(20).default(10),
});
const sourceOverridesSchema = z.record(
customWidgetIdentifierSchema,
z.strictObject({
baseUrl: z.string(),
networkScope: z.enum(["public", "private", "loopback"]).optional(),
}),
);
export const workshopProcedures = {
workshopSearch: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Search Workshop for Custom JSX widgets." } })
.input(workshopSearchInputSchema)
.query(async ({ input }) => {
try {
const result = await workshop.list({
page: 1,
perPage: input.limit,
search: input.query,
sort: input.sort,
type: "customWidget",
includeOutdated: true,
});
return {
items: result.items
.filter((item) => item.widgetSchema === CUSTOM_WIDGET_SCHEMA)
.map((item) => ({
id: item.id,
title: item.title,
description: item.description,
authorName: item.authorName,
score: item.score,
revision: item.revision,
outdated: item.outdated,
reportCount: item.reportCount,
})),
};
} catch {
throwWorkshopUnavailable("Workshop widget search failed", "workshop_widget_search_failed");
}
}),
workshopGet: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Get and validate one Workshop Custom JSX widget." } })
.input(z.object({ submissionId: z.string().min(1) }))
.query(async ({ input }) => {
const { submission, widget } = await getWorkshopWidget(input.submissionId);
return {
submission: {
id: submission.id,
title: submission.title,
description: submission.description,
authorName: submission.authorName,
revision: submission.revision,
score: submission.score,
outdated: submission.outdated,
reportCount: submission.reportCount,
},
widget,
sourceSetup: getCustomWidgetSourceSetups(widget.sources),
hasActions: Object.values(widget.requests).some((request) => request.kind === "action"),
methods: [...new Set(Object.values(widget.requests).map((request) => request.method))],
permissions: [...new Set(Object.values(widget.requests).map((request) => request.permission))],
};
}),
workshopInstall: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Install one validated Workshop Custom JSX widget." } })
.input(
z.object({
submissionId: z.string().min(1),
name: z.string().trim().min(1).max(128).optional(),
sources: sourceOverridesSchema.default({}),
secrets: customWidgetSecretsInputSchema.default([]),
}),
)
.mutation(async ({ ctx, input }) => {
const { widget } = await getWorkshopWidget(input.submissionId);
const unknownSource = Object.keys(input.sources).find((sourceId) => !widget.sources[sourceId]);
if (unknownSource) {
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown Workshop widget source '${unknownSource}'` });
}
const configured = customWidgetDefinitionSchema.parse({
...widget,
name: input.name ?? widget.name,
sources: applyCustomWidgetSourceSetup(
widget.sources,
Object.fromEntries(
Object.entries(input.sources).map(([sourceId, source]) => [
sourceId,
{
baseUrl: source.baseUrl,
networkScope: source.networkScope ?? widget.sources[sourceId]?.networkScope ?? "public",
},
]),
),
),
});
assertSecretSources(configured.sources, input.secrets);
const id = await insertCustomWidgetDefinition(ctx.db, configured, ctx.session.user.id, input.secrets);
const configuredSecrets = new Set(input.secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
return {
status: "installed" as const,
definitionId: id,
sourceSetup: getCustomWidgetSourceSetups(configured.sources, input.secrets),
missingCredentials: getCustomWidgetSecretRequirements(configured.sources)
.filter((requirement) => !configuredSecrets.has(`${requirement.sourceId}:${requirement.kind}`))
.map(({ sourceId, kind }) => ({ sourceId, kind })),
};
}),
};
-42
View File
@@ -1,42 +0,0 @@
import { z } from "zod/v4";
import { eq } from "@homarr/db";
import { boards } from "@homarr/db/schema";
import { removeQueryCacheAsync, setQueryCacheAsync } from "@homarr/redis";
import { queryCacheDefaultGcTimeMs, queryCacheMaxValueBytes } from "../query-cache";
import { createTRPCRouter, internalProcedure } from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access";
const queryCacheBoardInput = z.object({
boardId: z.string().min(1),
});
const queryCacheSetInput = queryCacheBoardInput.extend({
value: z.string().max(queryCacheMaxValueBytes * 2),
});
const validateBoardAccessAsync = async (ctx: Parameters<typeof throwIfActionForbiddenAsync>[0], boardId: string) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, boardId), "view");
};
export const queryCacheRouter = createTRPCRouter({
setItem: internalProcedure.input(queryCacheSetInput).mutation(async ({ ctx, input }) => {
await validateBoardAccessAsync(ctx, input.boardId);
const userId = ctx.session?.user.id ?? "anonymous";
return {
stored: await setQueryCacheAsync(
userId,
input.boardId,
input.value,
queryCacheDefaultGcTimeMs,
queryCacheMaxValueBytes,
),
};
}),
removeItem: internalProcedure.input(queryCacheBoardInput).mutation(async ({ ctx, input }) => {
await validateBoardAccessAsync(ctx, input.boardId);
const userId = ctx.session?.user.id ?? "anonymous";
await removeQueryCacheAsync(userId, input.boardId);
}),
});
@@ -1,8 +1,11 @@
import { TRPCError } from "@trpc/server";
import superjson from "superjson";
import { z } from "zod/v4";
import { and, eq } from "@homarr/db";
import { sectionCollapseStates, sections } from "@homarr/db/schema";
import { emptySuperJSON } from "@homarr/definitions";
import { containerSectionOptionsSchema } from "@homarr/validation/shared";
import { createTRPCRouter, protectedProcedure } from "../../trpc";
@@ -16,7 +19,7 @@ export const sectionRouter = createTRPCRouter({
)
.mutation(async ({ ctx, input }) => {
const section = await ctx.db.query.sections.findFirst({
where: and(eq(sections.id, input.sectionId), eq(sections.kind, "category")),
where: eq(sections.id, input.sectionId),
with: {
collapseStates: {
where: eq(sectionCollapseStates.userId, ctx.session.user.id),
@@ -31,6 +34,23 @@ export const sectionRouter = createTRPCRouter({
});
}
if (section.kind !== "container") {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Section cannot be collapsed id=${input.sectionId}`,
});
}
const rawOptions = superjson.parse(section.options ?? emptySuperJSON);
const parsedOptions = containerSectionOptionsSchema.safeParse(rawOptions);
if (!parsedOptions.success || !parsedOptions.data.collapsible) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Section cannot be collapsed id=${input.sectionId}`,
});
}
if (section.collapseStates.length === 0) {
await ctx.db.insert(sectionCollapseStates).values({
sectionId: section.id,
+407 -78
View File
@@ -572,6 +572,38 @@ describe("getBoardByName should return board by name", () => {
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view");
});
it("returns configured container behavior", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, sectionId: rootSectionId, layoutId } = await createFullBoardAsync(db, "default");
const sectionId = await addContainerAsync(db, {
boardId,
layoutId,
parentSectionId: rootSectionId,
options: {
title: "Media",
showLabel: false,
collapsible: true,
showOpenAll: true,
},
});
const result = await caller.getBoardByName({ name: "default" });
const section = expectToBeDefined(result.sections.find((candidate) => candidate.id === sectionId));
expect(result.id).toBe(boardId);
expect(section.kind).toBe("container");
if (section.kind === "container") {
expect(section.collapsed).toBe(false);
expect(section.options).toMatchObject({
title: "Media",
showLabel: false,
collapsible: true,
showOpenAll: true,
});
}
});
it("should throw error when not present", async () => {
// Arrange
const db = createDb();
@@ -828,60 +860,194 @@ describe("saveBoard should save full board", () => {
expect(integration).toBeUndefined();
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
});
it.each([[{ kind: "empty" as const }], [{ kind: "category" as const, collapsed: false, name: "My first category" }]])(
"should add section when present in input",
async (partialSection) => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
it("should add root section when present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
const newSectionId = createId();
await caller.saveBoard({
id: boardId,
sections: [
{
id: newSectionId,
xOffset: 0,
yOffset: 1,
...partialSection,
},
{
id: sectionId,
kind: "empty",
xOffset: 0,
yOffset: 0,
},
],
items: [],
});
const board = await db.query.boards.findFirst({
where: eq(boards.id, boardId),
with: {
sections: true,
const newSectionId = createId();
await caller.saveBoard({
id: boardId,
sections: [
{
id: newSectionId,
kind: "empty",
xOffset: 0,
yOffset: 1,
},
});
{
id: sectionId,
kind: "empty",
xOffset: 0,
yOffset: 0,
},
],
items: [],
});
const section = await db.query.sections.findFirst({
where: eq(sections.id, newSectionId),
});
const board = await db.query.boards.findFirst({
where: eq(boards.id, boardId),
with: {
sections: true,
},
});
const definedBoard = expectToBeDefined(board);
expect(definedBoard.sections.length).toBe(2);
const addedSection = expectToBeDefined(definedBoard.sections.find((section) => section.id === newSectionId));
expect(addedSection).toBeDefined();
expect(addedSection.id).toBe(newSectionId);
expect(addedSection.kind).toBe(partialSection.kind);
expect(addedSection.yOffset).toBe(1);
if ("name" in partialSection) {
expect(addedSection.name).toBe(partialSection.name);
}
expect(section).toBeDefined();
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
},
);
const section = await db.query.sections.findFirst({
where: eq(sections.id, newSectionId),
});
const definedBoard = expectToBeDefined(board);
expect(definedBoard.sections.length).toBe(2);
const addedSection = expectToBeDefined(definedBoard.sections.find((section) => section.id === newSectionId));
expect(addedSection).toBeDefined();
expect(addedSection.id).toBe(newSectionId);
expect(addedSection.kind).toBe("empty");
expect(addedSection.yOffset).toBe(1);
expect(addedSection.name).toBeNull();
expect(section).toBeDefined();
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
});
it("should add and update container behavior options", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId, sectionId: rootSectionId } = await createFullBoardAsync(db, "default");
const containerId = createId();
await caller.saveBoard({
id: boardId,
sections: [
{
id: rootSectionId,
kind: "empty",
xOffset: 0,
yOffset: 0,
},
{
id: containerId,
kind: "container",
collapsed: false,
options: {
title: "Navigation",
customCssClasses: ["pinned"],
borderColor: "#123456",
showLabel: true,
collapsible: true,
showOpenAll: true,
},
layouts: [
{
layoutId,
parentSectionId: rootSectionId,
xOffset: 1,
yOffset: 2,
width: 3,
height: 4,
},
],
},
],
items: [],
});
await caller.saveBoard({
id: boardId,
sections: [
{
id: rootSectionId,
kind: "empty",
xOffset: 0,
yOffset: 0,
},
{
id: containerId,
kind: "container",
collapsed: false,
options: {
title: "Operations",
customCssClasses: ["dense"],
borderColor: "#654321",
showLabel: false,
collapsible: false,
showOpenAll: false,
},
layouts: [
{
layoutId,
parentSectionId: rootSectionId,
xOffset: 2,
yOffset: 3,
width: 4,
height: 5,
},
],
},
],
items: [],
});
const section = await db.query.sections.findFirst({
where: eq(sections.id, containerId),
});
expect(SuperJSON.parse(section?.options ?? "")).toEqual({
title: "Operations",
customCssClasses: ["dense"],
borderColor: "#654321",
showLabel: false,
collapsible: false,
showOpenAll: false,
});
expect(
await db.query.sectionLayouts.findFirst({
where: and(eq(sectionLayouts.sectionId, containerId), eq(sectionLayouts.layoutId, layoutId)),
}),
).toMatchObject({
parentSectionId: rootSectionId,
xOffset: 2,
yOffset: 3,
width: 4,
height: 5,
});
});
it("should add a container without layouts", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, sectionId: rootSectionId } = await createFullBoardAsync(db, "default");
const containerId = createId();
await caller.saveBoard({
id: boardId,
sections: [
{
id: rootSectionId,
kind: "empty",
xOffset: 0,
yOffset: 0,
},
{
id: containerId,
kind: "container",
collapsed: false,
options: {},
layouts: [],
},
],
items: [],
});
expect(
await db.query.sections.findFirst({
where: eq(sections.id, containerId),
}),
).toMatchObject({ id: containerId, kind: "container" });
expect(
await db.query.sectionLayouts.findMany({
where: eq(sectionLayouts.sectionId, containerId),
}),
).toEqual([]);
});
it("should add item when present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
@@ -1027,15 +1193,22 @@ describe("saveBoard should save full board", () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
const newSectionId = createId();
const { boardId, sectionId, layoutId } = await createFullBoardAsync(db, "default");
const containerId = createId();
await db.insert(sections).values({
id: newSectionId,
kind: "category",
name: "Before",
yOffset: 1,
xOffset: 0,
id: containerId,
kind: "container",
boardId,
options: SuperJSON.stringify({ title: "Before" }),
});
await db.insert(sectionLayouts).values({
sectionId: containerId,
layoutId,
parentSectionId: sectionId,
xOffset: 0,
yOffset: 0,
width: 2,
height: 2,
});
await caller.saveBoard({
@@ -1043,19 +1216,32 @@ describe("saveBoard should save full board", () => {
sections: [
{
id: sectionId,
kind: "category",
kind: "empty",
yOffset: 1,
xOffset: 0,
name: "Test",
collapsed: true,
},
{
id: newSectionId,
kind: "category",
name: "After",
yOffset: 0,
xOffset: 0,
id: containerId,
kind: "container",
collapsed: false,
options: {
title: "After",
customCssClasses: ["dense"],
borderColor: "#123456",
showLabel: false,
collapsible: true,
showOpenAll: true,
},
layouts: [
{
layoutId,
parentSectionId: sectionId,
xOffset: 1,
yOffset: 2,
width: 3,
height: 4,
},
],
},
],
items: [],
@@ -1075,11 +1261,17 @@ describe("saveBoard should save full board", () => {
expect(firstSection.kind).toBe("empty");
expect(firstSection.yOffset).toBe(1);
expect(firstSection.name).toBe(null);
const secondSection = expectToBeDefined(definedBoard.sections.find((section) => section.id === newSectionId));
expect(secondSection.id).toBe(newSectionId);
expect(secondSection.kind).toBe("category");
expect(secondSection.yOffset).toBe(0);
expect(secondSection.name).toBe("After");
const secondSection = expectToBeDefined(definedBoard.sections.find((section) => section.id === containerId));
expect(secondSection.id).toBe(containerId);
expect(secondSection.kind).toBe("container");
expect(secondSection.yOffset).toBeNull();
expect(secondSection.name).toBeNull();
expect(SuperJSON.parse(secondSection.options ?? "")).toMatchObject({ title: "After", collapsible: true });
expect(
await db.query.sectionLayouts.findFirst({
where: and(eq(sectionLayouts.sectionId, containerId), eq(sectionLayouts.layoutId, layoutId)),
}),
).toMatchObject({ xOffset: 1, yOffset: 2, width: 3, height: 4, parentSectionId: sectionId });
});
it("should update item when present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
@@ -1328,12 +1520,16 @@ const createExistingLayout = (id: string) => ({
id,
name: "Base",
columnCount: 10,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 0,
});
const createNewLayout = (columnCount: number) => ({
id: createId(),
name: "New layout",
columnCount,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 1400,
});
describe("saveLayouts should save layout changes", () => {
@@ -1361,7 +1557,7 @@ describe("saveLayouts should save layout changes", () => {
expect(definedLayout.columnCount).toBe(newLayout.columnCount);
expect(definedLayout.breakpoint).toBe(newLayout.breakpoint);
});
test("should add items and dynamic sections generated from grid-algorithm when new layout is added", async () => {
test("should add items and containers generated from grid-algorithm when new layout is added", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
@@ -1390,7 +1586,7 @@ describe("saveLayouts should save layout changes", () => {
a: itemId,
});
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layout!.id, assignments.inDynamicSection);
await expectLayoutForContainerAsync(db, assignments.inRoot.f, layout!.id, assignments.inContainer);
});
test("should update layout when present in input", async () => {
// Arrange
@@ -1443,7 +1639,81 @@ describe("saveLayouts should save layout changes", () => {
...assignments.inRoot,
a: itemId,
});
await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layoutId, assignments.inDynamicSection);
await expectLayoutForContainerAsync(db, assignments.inRoot.f, layoutId, assignments.inContainer);
});
test("should reserve gutter columns and return gutter contents to the canvas when disabled", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "gutters");
await db
.update(itemLayouts)
.set({
xOffset: 9,
})
.where(and(eq(itemLayouts.itemId, itemId), eq(itemLayouts.layoutId, layoutId)));
await caller.saveLayouts({
id: boardId,
layouts: [
{
...createExistingLayout(layoutId),
leftGutterColumnCount: 2,
},
],
});
const enabledLayout = expectToBeDefined(await db.query.layouts.findFirst({ where: eq(layouts.id, layoutId) }));
expect(enabledLayout.leftGutterColumnCount).toBe(2);
const leftRoot = expectToBeDefined(
await db.query.sections.findFirst({
where: and(eq(sections.boardId, boardId), eq(sections.kind, "empty"), eq(sections.xOffset, -1)),
}),
);
const reflowedMainItem = expectToBeDefined(
await db.query.itemLayouts.findFirst({
where: and(eq(itemLayouts.itemId, itemId), eq(itemLayouts.layoutId, layoutId)),
}),
);
expect(reflowedMainItem.xOffset + reflowedMainItem.width).toBeLessThanOrEqual(8);
const gutterItemId = await addItemAsync(db, {
boardId,
layoutId,
sectionId: leftRoot.id,
});
await caller.saveLayouts({
id: boardId,
layouts: [createExistingLayout(layoutId)],
});
const returnedItem = expectToBeDefined(
await db.query.itemLayouts.findFirst({
where: and(eq(itemLayouts.itemId, gutterItemId), eq(itemLayouts.layoutId, layoutId)),
}),
);
expect(returnedItem.sectionId).toBe(sectionId);
});
test("should create one gutter root when layouts are saved concurrently", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "concurrent-gutters");
const input = {
id: boardId,
layouts: [
{
...createExistingLayout(layoutId),
leftGutterColumnCount: 2,
},
],
};
await Promise.all([caller.saveLayouts(input), caller.saveLayouts(input)]);
const leftRoots = await db.query.sections.findMany({
where: and(eq(sections.boardId, boardId), eq(sections.kind, "empty"), eq(sections.xOffset, -1)),
});
expect(leftRoots).toHaveLength(1);
});
test("should remove layout when not present in input", async () => {
// Arrange
@@ -1483,6 +1753,63 @@ describe("saveLayouts should save layout changes", () => {
});
});
describe("Custom Widget placement permissions", () => {
test("rejects a direct addItem request from a non-admin board modifier", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId } = await createFullBoardAsync(db, "custom-widget-add");
await expect(
caller.addItem({
boardId,
kind: "customApi",
options: {
definitionId: "definition-1",
refreshInterval: 30,
configuration: {},
},
integrationIds: [],
}),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
test("rejects a direct saveBoard request that adds a Custom Widget for a non-admin", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, sectionId, layoutId } = await createFullBoardAsync(db, "custom-widget-save");
await expect(
caller.saveBoard({
id: boardId,
sections: [{ id: sectionId, kind: "empty", yOffset: 0, xOffset: 0 }],
items: [
{
id: createId(),
kind: "customApi",
options: {
definitionId: "definition-1",
refreshInterval: 30,
configuration: {},
},
integrationIds: [],
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
layouts: [
{
layoutId,
sectionId,
height: 1,
width: 1,
xOffset: 0,
yOffset: 0,
},
],
},
],
}),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
});
const expectInputToBeFullBoardWithName = (
input: RouterOutputs["board"]["getHomeBoard"],
props: { name: string } & Awaited<ReturnType<typeof createFullBoardAsync>>,
@@ -1601,19 +1928,21 @@ const addItemAsync = async (
return itemId;
};
const addDynamicSectionAsync = async (
const addContainerAsync = async (
db: Database,
section: Partial<Pick<InferInsertModel<typeof sectionLayouts>, "xOffset" | "yOffset" | "width" | "height">> & {
parentSectionId: string;
boardId: string;
layoutId: string;
options?: Record<string, unknown>;
},
) => {
const sectionId = createId();
await db.insert(sections).values({
id: sectionId,
kind: "dynamic",
kind: "container",
boardId: section.boardId,
options: SuperJSON.stringify(section.options ?? {}),
});
await db.insert(sectionLayouts).values({
parentSectionId: section.parentSectionId,
@@ -1655,7 +1984,7 @@ const createItemsAndSectionsAsync = async (
const itemC = await addItemAsync(db, { boardId, layoutId, sectionId, xOffset: 6, width: 2, height: 2 });
const itemD = await addItemAsync(db, { boardId, layoutId, sectionId, xOffset: 8, width: 2, height: 3 });
const itemE = await addItemAsync(db, { boardId, layoutId, sectionId, yOffset: 1, height: 3 });
const sectionF = await addDynamicSectionAsync(db, {
const sectionF = await addContainerAsync(db, {
yOffset: 1,
xOffset: 1,
width: 5,
@@ -1664,7 +1993,7 @@ const createItemsAndSectionsAsync = async (
boardId,
layoutId,
});
const sectionG = await addDynamicSectionAsync(db, {
const sectionG = await addContainerAsync(db, {
yOffset: 2,
xOffset: 6,
width: 2,
@@ -1695,7 +2024,7 @@ const createItemsAndSectionsAsync = async (
f: sectionF,
g: sectionG,
},
inDynamicSection: {
inContainer: {
h: itemH,
i: itemI,
j: itemJ,
@@ -1731,7 +2060,7 @@ gg`,
);
};
const expectLayoutForDynamicSectionAsync = async (
const expectLayoutForContainerAsync = async (
db: Database,
sectionId: string,
layoutId: string,
@@ -1,376 +0,0 @@
// @vitest-environment node
import { stringify } from "superjson";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import type { Database } from "@homarr/db";
import { eq } from "@homarr/db";
import { boards, boardUserPermissions, customWidgetDefinitions, items, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions";
import { customWidgetRouter } from "../custom-widget/custom-widget-router";
import { customApiRouter } from "../widgets/custom-api";
const createSession = (userId: string, permissions: GroupPermissionKey[] = []): Session => ({
user: { id: userId, permissions, colorScheme: "light" },
expires: new Date().toISOString(),
});
const createCustomWidgetCaller = (db: Database, session: Session | null) =>
customWidgetRouter.createCaller({ db, deviceType: undefined, session });
const createCustomApiCaller = (db: Database, session: Session | null) =>
customApiRouter.createCaller({ db, deviceType: undefined, session });
async function createFixtureAsync(db: Database) {
const ownerId = createId();
const viewerId = createId();
const modifierId = createId();
const unrelatedId = createId();
const adminId = createId();
const boardId = createId();
const itemId = createId();
const definitionId = createId();
await db.insert(users).values(
[ownerId, viewerId, modifierId, unrelatedId, adminId].map((id) => ({
id,
name: id,
})),
);
await db.insert(boards).values({ id: boardId, name: `board-${boardId}`, creatorId: ownerId, isPublic: false });
await db.insert(boardUserPermissions).values([
{ boardId, userId: viewerId, permission: "view" },
{ boardId, userId: modifierId, permission: "modify" },
]);
await db.insert(customWidgetDefinitions).values({
id: definitionId,
name: "Restart service",
description: "Restarts a service",
url: "https://api.example.com/restart",
method: "POST",
displayType: "actionButton",
displayConfig: stringify({
type: "actionButton",
buttonLabel: "Restart",
confirmText: "Continue?",
}),
creatorId: adminId,
});
await db.insert(items).values({
id: itemId,
boardId,
kind: "customApi",
options: stringify({ definitionId, refreshInterval: 30 }),
});
return { ownerId, viewerId, modifierId, unrelatedId, adminId, boardId, itemId, definitionId };
}
beforeEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
describe("custom widget definition authorization", () => {
it("restricts the full definition list to administrators", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
await expect(createCustomWidgetCaller(db, null).all()).rejects.toMatchObject({ code: "UNAUTHORIZED" });
for (const userId of [fixture.ownerId, fixture.viewerId, fixture.modifierId, fixture.unrelatedId]) {
await expect(createCustomWidgetCaller(db, createSession(userId)).all()).rejects.toMatchObject({
code: "FORBIDDEN",
});
}
await expect(createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).all()).resolves.toEqual([
expect.objectContaining({ id: fixture.definitionId, url: "https://api.example.com/restart" }),
]);
});
it("returns only safe definition summaries to board modifiers", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
const result = await createCustomWidgetCaller(db, createSession(fixture.modifierId)).available({
boardId: fixture.boardId,
});
expect(result).toEqual([
{
id: fixture.definitionId,
name: "Restart service",
description: "Restarts a service",
iconUrl: null,
displayType: "actionButton",
enabled: true,
},
]);
expect(JSON.stringify(result)).not.toContain("api.example.com");
for (const userId of [fixture.viewerId, fixture.unrelatedId]) {
await expect(
createCustomWidgetCaller(db, createSession(userId)).available({ boardId: fixture.boardId }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
}
});
});
describe("custom widget action authorization", () => {
it("requires an administrator even for users who can view or modify the board", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
const input = {
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
};
await expect(createCustomWidgetCaller(db, null).execute(input)).rejects.toMatchObject({ code: "UNAUTHORIZED" });
for (const userId of [fixture.ownerId, fixture.viewerId, fixture.modifierId, fixture.unrelatedId]) {
await expect(createCustomWidgetCaller(db, createSession(userId)).execute(input)).rejects.toMatchObject({
code: "FORBIDDEN",
});
}
});
it("executes for an administrator only when board, item, and definition are bound", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal("fetch", fetchMock);
const caller = createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"]));
await expect(
caller.execute({
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
}),
).resolves.toEqual({ success: true, responseInfo: { status: 204 } });
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/restart",
expect.objectContaining({ method: "POST", redirect: "error", signal: expect.any(AbortSignal) }),
);
});
it.each(["board", "item", "definition"] as const)("rejects a mismatched %s binding", async (mismatch) => {
const db = createDb();
const fixture = await createFixtureAsync(db);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const input = {
boardId: mismatch === "board" ? createId() : fixture.boardId,
itemId: mismatch === "item" ? createId() : fixture.itemId,
definitionId: mismatch === "definition" ? createId() : fixture.definitionId,
};
await expect(
createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute(input),
).rejects.toMatchObject({ code: "NOT_FOUND" });
expect(fetchMock).not.toHaveBeenCalled();
});
it("rejects disabled definitions before sending a request", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
await db
.update(customWidgetDefinitions)
.set({ enabled: false })
.where(eq(customWidgetDefinitions.id, fixture.definitionId));
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(
createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
}),
).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe("custom API outbound request hardening", () => {
it("rejects credential-bearing action URLs without exposing the credential", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
await db
.update(customWidgetDefinitions)
.set({ url: "https://operator:super-secret@private.example.com/restart" })
.where(eq(customWidgetDefinitions.id, fixture.definitionId));
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const result = await createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
});
expect(result).toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null });
expect(JSON.stringify(result)).not.toMatch(/super-secret|private\.example\.com/);
expect(fetchMock).not.toHaveBeenCalled();
});
it("rejects redirects and redacts fetch errors", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
const fetchMock = vi
.fn()
.mockRejectedValue(
new TypeError("Redirect to http://token:secret@internal.example.local/admin?apiKey=top-secret was rejected"),
);
vi.stubGlobal("fetch", fetchMock);
const result = await createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
});
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/restart",
expect.objectContaining({ redirect: "error" }),
);
expect(result).toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null });
expect(JSON.stringify(result)).not.toMatch(/top-secret|internal\.example\.local|apiKey/);
});
it("aborts timed-out action requests and returns a stable failure", async () => {
vi.useFakeTimers();
const db = createDb();
const fixture = await createFixtureAsync(db);
const started = Promise.withResolvers<void>();
const fetchMock = vi.fn((_url: string, init: RequestInit) => {
started.resolve();
return new Promise<Response>((_resolve, reject) => {
init.signal?.addEventListener("abort", () => {
reject(new Error("Timed out calling http://internal.example.local?secret=timeout-secret"));
});
});
});
vi.stubGlobal("fetch", fetchMock);
const request = createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
});
await started.promise;
await vi.advanceTimersByTimeAsync(10_000);
const result = await request;
expect(result).toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null });
expect(JSON.stringify(result)).not.toMatch(/timeout-secret|internal\.example\.local/);
});
it("bounds action responses before reporting success", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("x".repeat(2 * 1024 * 1024 + 1))));
await expect(
createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
}),
).resolves.toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null });
});
it("binds data requests to a viewable item and never exposes backend error text", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
await db
.update(customWidgetDefinitions)
.set({
displayType: "singleValue",
method: "GET",
displayConfig: stringify({ type: "singleValue", jsonPath: "$.value" }),
})
.where(eq(customWidgetDefinitions.id, fixture.definitionId));
vi.stubGlobal(
"fetch",
vi.fn().mockRejectedValue(new Error("Backend http://private.example.local/path?token=super-secret\nstack trace")),
);
const input = {
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
};
const error = await createCustomApiCaller(db, createSession(fixture.viewerId))
.getData(input)
.catch((caught: unknown) => caught);
expect(error).toMatchObject({ code: "INTERNAL_SERVER_ERROR", message: "Custom API request failed" });
expect(JSON.stringify(error)).not.toMatch(/super-secret|private\.example\.local|stack trace/);
await expect(createCustomApiCaller(db, createSession(fixture.unrelatedId)).getData(input)).rejects.toMatchObject({
code: "NOT_FOUND",
});
await expect(
createCustomApiCaller(db, createSession(fixture.viewerId)).getData({ ...input, definitionId: createId() }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("bounds and disposes non-success data responses", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
await db
.update(customWidgetDefinitions)
.set({
displayType: "singleValue",
method: "GET",
displayConfig: stringify({ type: "singleValue", jsonPath: "$.value" }),
})
.where(eq(customWidgetDefinitions.id, fixture.definitionId));
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("x".repeat(2 * 1024 * 1024 + 1), { status: 502 })));
const error = await createCustomApiCaller(db, createSession(fixture.viewerId))
.getData({
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
})
.catch((caught: unknown) => caught);
expect(error).toMatchObject({ code: "INTERNAL_SERVER_ERROR", message: "Custom API request failed" });
expect(JSON.stringify(error)).not.toContain("x".repeat(100));
});
it("redacts unsafe preview failures and rejects redirects", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
const fetchMock = vi.fn().mockRejectedValue(new Error("redirected to http://private.local?secret=value"));
vi.stubGlobal("fetch", fetchMock);
const caller = createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"]));
const result = await caller.preview({
url: "https://api.example.com/data",
method: "GET",
authType: "none",
displayType: "singleValue",
displayConfig: { type: "singleValue", jsonPath: "$.value", label: "Value", unit: "" },
secrets: [],
});
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/data",
expect.objectContaining({ redirect: "error" }),
);
expect(result).toEqual({
success: false,
error: "Custom API preview failed",
responseInfo: null,
rawResponse: null,
});
expect(JSON.stringify(result)).not.toMatch(/private\.local|secret=value/);
});
});
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import {
claimCustomWidgetConfigurationRequest,
completeCustomWidgetConfigurationRequest,
createCustomWidgetConfigurationRequest,
getCustomWidgetConfigurationRequestForUser,
releaseCustomWidgetConfigurationRequest,
} from "../../custom-widget/configuration-requests";
describe("custom widget source configuration requests", () => {
it("is user-scoped, one-time, and reports completion without returning configuration", async () => {
const request = await createCustomWidgetConfigurationRequest({
userId: "user-1",
target: { type: "preview", id: "preview-1" },
widgetName: "Printer",
sourceId: "default",
sourceName: "Printer API",
source: { baseUrl: "http://printer.local", networkScope: "private", auth: "bearer" },
kinds: ["apiKey"],
});
expect(await getCustomWidgetConfigurationRequestForUser(request.id, "user-2")).toBeNull();
expect(await claimCustomWidgetConfigurationRequest(request.id)).toMatchObject({ status: "pending" });
expect(await claimCustomWidgetConfigurationRequest(request.id)).toBeNull();
await releaseCustomWidgetConfigurationRequest(request.id);
expect(await claimCustomWidgetConfigurationRequest(request.id)).not.toBeNull();
await completeCustomWidgetConfigurationRequest(request.id);
expect(await getCustomWidgetConfigurationRequestForUser(request.id, "user-1")).toMatchObject({
status: "completed",
});
expect(await claimCustomWidgetConfigurationRequest(request.id)).toBeNull();
});
});
@@ -0,0 +1,551 @@
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { eq, sql } from "@homarr/db";
import {
customWidgetDefinitions,
customWidgetSecrets,
legacyCustomWidgetDefinitions,
legacyCustomWidgetSecrets,
users,
} from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { BUNDLED_CUSTOM_WIDGETS, customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import { describe, expect, test } from "vitest";
import { customWidgetRouter } from "../../custom-widget/custom-widget-router";
import { getCustomWidgetConfigurationRequestForUser } from "../../custom-widget/configuration-requests";
import { configureCustomWidgetSourceFromRequest } from "../../custom-widget/secret-persistence";
import { serializeCustomWidgetDefinition } from "../../custom-widget/stored-definition";
const userId = createId();
const session = {
user: {
id: userId,
permissions: ["admin"],
colorScheme: "light",
},
expires: new Date().toISOString(),
} satisfies Session;
const nonAdminSession = {
...session,
user: { ...session.user, permissions: ["board-modify-all"] },
} 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<typeof createDb>) {
return customWidgetRouter.createCaller({ db, deviceType: undefined, session });
}
function createNonAdminCaller(db: ReturnType<typeof createDb>) {
return customWidgetRouter.createCaller({
db,
deviceType: undefined,
session: nonAdminSession,
});
}
async function prepareDatabase() {
const db = createDb();
await db.insert(users).values({ id: userId });
return db;
}
function rejectSecretInserts(db: ReturnType<typeof createDb>) {
db.run(
sql.raw(`CREATE TRIGGER reject_custom_widget_v2_secret_insert
BEFORE INSERT ON custom_widget_v2_secret
BEGIN
SELECT RAISE(ABORT, 'forced secret insert failure');
END`),
);
}
function rejectDefinitionUpdates(db: ReturnType<typeof createDb>) {
db.run(
sql.raw(`CREATE TRIGGER reject_custom_widget_v2_definition_update
BEFORE UPDATE ON custom_widget_v2_definition
BEGIN
SELECT RAISE(ABORT, 'forced definition update failure');
END`),
);
}
async function insertLegacyJellyfin(db: ReturnType<typeof createDb>) {
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 creates a v2 replacement while preserving the legacy widget and its stable ID", 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.findFirst()).toMatchObject({
id: legacy.id,
name: "Legacy Jellyfin",
enabled: false,
});
expect(await db.query.legacyCustomWidgetSecrets.findFirst()).toMatchObject({
definitionId: legacy.id,
encryptedValue: legacy.encryptedValue,
});
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,
});
const migratedList = await createCaller(db).list();
expect(migratedList).toHaveLength(1);
expect(migratedList[0]).toMatchObject({ id: legacy.id, migrationRequired: false });
await createCaller(db).delete({ id: legacy.id });
const fallbackList = await createCaller(db).list();
expect(fallbackList).toHaveLength(1);
expect(fallbackList[0]).toMatchObject({ id: legacy.id, migrationRequired: true });
expect(await db.query.legacyCustomWidgetSecrets.findFirst()).toMatchObject({
definitionId: legacy.id,
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);
rejectSecretInserts(db);
await expect(caller.create({ ...jellyfin, secrets: [secret] })).rejects.toThrow("forced secret insert failure");
expect(await db.query.customWidgetDefinitions.findMany()).toHaveLength(0);
expect(await db.query.customWidgetSecrets.findMany()).toHaveLength(0);
});
test("rolls back import when its secret cannot be stored", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
rejectSecretInserts(db);
await expect(caller.import({ widget: jellyfin, secrets: [secret] })).rejects.toThrow(
"forced secret insert failure",
);
expect(await db.query.customWidgetDefinitions.findMany()).toHaveLength(0);
expect(await db.query.customWidgetSecrets.findMany()).toHaveLength(0);
});
test("refuses to export a stored definition containing embedded credentials", async () => {
const db = await prepareDatabase();
const definition = customWidgetDefinitionSchema.parse(jellyfin);
const counts = definition.requests.counts;
if (!counts) throw new Error("Jellyfin counts request is missing");
const id = createId();
await db.insert(customWidgetDefinitions).values({
id,
...serializeCustomWidgetDefinition({
...definition,
requests: {
...definition.requests,
counts: {
...counts,
headers: { ...counts.headers, "X-Auth": "Bearer sk-secret-123456" },
},
},
}),
creatorId: userId,
});
await expect(createCaller(db).export({ id })).rejects.toThrow("Credentials must use source authentication");
});
test("rolls back definition and secret changes when a replacement secret cannot be stored", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
const created = await caller.create({ ...jellyfin, secrets: [secret] });
const originalDefinition = await db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, created.id),
});
const originalSecret = await db.query.customWidgetSecrets.findFirst({
where: eq(customWidgetSecrets.definitionId, created.id),
});
rejectSecretInserts(db);
await expect(
caller.update({
id: created.id,
name: "Changed Jellyfin Library",
secrets: [{ ...secret, value: "replacement-secret" }],
}),
).rejects.toThrow("forced secret insert failure");
const storedDefinition = await db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, created.id),
});
const storedSecret = await db.query.customWidgetSecrets.findFirst({
where: eq(customWidgetSecrets.definitionId, created.id),
});
expect(storedDefinition?.name).toBe(originalDefinition?.name);
expect(storedDefinition?.updatedAt).toEqual(originalDefinition?.updatedAt);
expect(storedSecret).toEqual(originalSecret);
});
test("keeps the existing secret when setting its replacement fails", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
const created = await caller.create({ ...jellyfin, secrets: [secret] });
const originalDefinition = await db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, created.id),
});
const originalSecret = await db.query.customWidgetSecrets.findFirst({
where: eq(customWidgetSecrets.definitionId, created.id),
});
rejectSecretInserts(db);
await expect(
caller.secretSet({
definitionId: created.id,
secret: { ...secret, value: "replacement-secret" },
}),
).rejects.toThrow("forced secret insert failure");
expect(
await db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, created.id),
}),
).toEqual(originalDefinition);
expect(
await db.query.customWidgetSecrets.findFirst({
where: eq(customWidgetSecrets.definitionId, created.id),
}),
).toEqual(originalSecret);
});
test("keeps the existing secret when clearing it cannot update the definition revision", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
const created = await caller.create({ ...jellyfin, secrets: [secret] });
const originalDefinition = await db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, created.id),
});
const originalSecret = await db.query.customWidgetSecrets.findFirst({
where: eq(customWidgetSecrets.definitionId, created.id),
});
rejectDefinitionUpdates(db);
await expect(
caller.secretClear({
definitionId: created.id,
sourceId: secret.sourceId,
kind: secret.kind,
}),
).rejects.toThrow("forced definition update failure");
expect(
await db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, created.id),
}),
).toEqual(originalDefinition);
expect(
await db.query.customWidgetSecrets.findFirst({
where: eq(customWidgetSecrets.definitionId, created.id),
}),
).toEqual(originalSecret);
});
test("configures a source URL and credential together", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
const created = await caller.create({ ...jellyfin, secrets: [] });
await caller.sourceConfigure({
definitionId: created.id,
sourceId: "default",
baseUrl: "http://jellyfin.local:8096",
networkScope: "private",
secrets: [secret],
});
const definition = await caller.get({ id: created.id });
expect(definition.sources.default?.baseUrl).toBe("http://jellyfin.local:8096");
expect(definition.secrets).toMatchObject([{ sourceId: "default", kind: "apiKey", hasValue: true }]);
});
test("rolls back a source URL when its credential cannot be stored", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
const created = await caller.create({ ...jellyfin, secrets: [] });
const before = await caller.get({ id: created.id });
rejectSecretInserts(db);
await expect(
caller.sourceConfigure({
definitionId: created.id,
sourceId: "default",
baseUrl: "http://jellyfin.local:8096",
networkScope: "private",
secrets: [secret],
}),
).rejects.toThrow("forced secret insert failure");
expect((await caller.get({ id: created.id })).sources.default?.baseUrl).toBe(before.sources.default?.baseUrl);
});
test("uses the preview widget name in a user configuration request", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
const preview = await caller.previewCreate({
definition: { ...jellyfin, name: "Living room Jellyfin" },
secrets: [],
});
const request = await caller.configurationRequestUser({
previewSessionId: preview.previewSession.id,
sourceId: "default",
});
expect(await getCustomWidgetConfigurationRequestForUser(request.requestId, userId)).toMatchObject({
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(
createNonAdminCaller(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 an admin source edit changes its binding", async () => {
const db = await prepareDatabase();
const created = await createCaller(db).create({ ...jellyfin, secrets: [secret] });
await createCaller(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("uses the persisted source binding from inside the configuration transaction", async () => {
const db = await prepareDatabase();
const created = await createCaller(db).create({ ...jellyfin, secrets: [secret] });
const definition = customWidgetDefinitionSchema.parse(jellyfin);
const defaultSource = definition.sources.default;
if (!defaultSource) throw new Error("Jellyfin default source is missing");
await db
.update(customWidgetDefinitions)
.set(
serializeCustomWidgetDefinition({
...definition,
name: "Concurrent definition edit",
sources: {
...definition.sources,
default: { ...defaultSource, baseUrl: "https://changed.example/api" },
},
}),
)
.where(eq(customWidgetDefinitions.id, created.id));
await expect(
configureCustomWidgetSourceFromRequest(db, {
definitionId: created.id,
sourceId: "default",
baseUrl: defaultSource.baseUrl,
networkScope: defaultSource.networkScope,
secrets: [],
expectedSource: defaultSource,
}),
).resolves.toMatchObject({
status: "configured",
source: { baseUrl: defaultSource.baseUrl },
});
expect(await createCaller(db).get({ id: created.id })).toMatchObject({
name: "Concurrent definition edit",
sources: { default: { baseUrl: defaultSource.baseUrl } },
});
expect(
await db.query.customWidgetSecrets.findMany({
where: eq(customWidgetSecrets.definitionId, created.id),
}),
).toHaveLength(0);
});
test("rejects stale setup-link credentials after a concurrent authentication binding change", async () => {
const db = await prepareDatabase();
const created = await createCaller(db).create({ ...jellyfin, secrets: [] });
const definition = customWidgetDefinitionSchema.parse(jellyfin);
const expectedSource = definition.sources.default;
if (!expectedSource || typeof expectedSource.auth === "string") {
throw new Error("Jellyfin default source must use header authentication");
}
await db
.update(customWidgetDefinitions)
.set(
serializeCustomWidgetDefinition({
...definition,
name: "Concurrent definition edit",
sources: {
...definition.sources,
default: {
...expectedSource,
auth: { ...expectedSource.auth, name: "X-Replaced-Credential" },
},
},
}),
)
.where(eq(customWidgetDefinitions.id, created.id));
await expect(
configureCustomWidgetSourceFromRequest(db, {
definitionId: created.id,
sourceId: "default",
baseUrl: "https://configured.example/api",
networkScope: "public",
secrets: [secret],
expectedSource,
}),
).resolves.toEqual({ status: "binding-changed" });
expect(await createCaller(db).get({ id: created.id })).toMatchObject({
name: "Concurrent definition edit",
sources: {
default: {
baseUrl: expectedSource.baseUrl,
auth: { name: "X-Replaced-Credential" },
},
},
});
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 createCaller(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 an administrator to create a stored-definition setup link", async () => {
const db = await prepareDatabase();
const created = await createCaller(db).create({ ...jellyfin, secrets: [] });
await expect(
createNonAdminCaller(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: "<Text>Original</Text>", 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: ["<Text>First</Text>"] }],
}),
caller.templatePatch({
id: created.id,
expectedRevision: read.revision,
edits: [{ startLine: 1, deleteCount: 1, replacementLines: ["<Text>Second</Text>"] }],
}),
]);
expect(results.filter(({ status }) => status === "fulfilled")).toHaveLength(1);
expect(results.filter(({ status }) => status === "rejected")).toHaveLength(1);
});
});
@@ -0,0 +1,72 @@
import { describe, expect, test } from "vitest";
import { stringify as stringifySuperJson } from "superjson";
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: "https://icons:password@example.test/private/icon.svg?access_token=icon-secret",
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: "corrupted-config-with-display-secret",
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");
expect(prompt).not.toContain("icon-secret");
expect(prompt).not.toContain("icons:password");
expect(prompt).not.toContain("private/icon.svg");
expect(prompt).not.toContain("display-secret");
});
test("omits valid free-text display configuration from the migration prompt", () => {
const prompt = buildLegacyCustomWidgetMigrationPrompt(
{
id: "legacy-custom-jsx",
name: "Private service",
description: "Status",
iconUrl: null,
url: "https://example.test/api/status",
authType: "bearer",
headerName: null,
method: "GET",
requestBody: null,
displayType: "customJsx",
displayConfig: stringifySuperJson({
type: "customJsx",
template: "<Text>LEGACY_PRIVATE_MARKER Bearer sk-secret-123456</Text>",
}),
enabled: true,
createdAt: new Date(0),
updatedAt: new Date(0),
creatorId: null,
},
["apiKey"],
);
expect(prompt).toContain("display configuration was intentionally omitted");
expect(prompt).not.toContain("LEGACY_PRIVATE_MARKER");
expect(prompt).not.toContain("Bearer sk-secret-123456");
expect(prompt).not.toContain("sk-secret-123456");
});
});
@@ -0,0 +1,49 @@
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",
kind: "query",
method: "GET",
path: "/endpoints/{option:environmentId}/containers",
auth: "inherit",
permission: "view",
trigger: "load",
};
describe("preview request option bindings", () => {
it("resolves load request parameters from the selected preview options", () => {
expect(resolvePreviewRequestParams(loadRequest, { environmentId: 7 }, {})).toEqual({
"option:environmentId": 7,
});
});
it("preserves invoking component parameters for manual requests", () => {
const manualRequest = {
...loadRequest,
trigger: "manual" as const,
path: "/endpoints/{param:endpointId}/containers",
};
expect(resolvePreviewRequestParams(manualRequest, { environmentId: 7 }, { endpointId: 12 })).toEqual({
endpointId: 12,
});
});
});
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,
});
});
});
@@ -0,0 +1,65 @@
import { describe, expect, test } from "vitest";
import {
assertJsonBudget,
assertSafeStaticHeaders,
classifyAddress,
resolveAndValidateHost,
resolveSameOriginTarget,
validateCustomWidgetUrl,
} from "../../custom-widget/request-executor";
describe("custom widget request executor", () => {
test.each([
["8.8.8.8", "public"],
["10.0.0.1", "private"],
["127.0.0.1", "loopback"],
["169.254.169.254", "blocked"],
["224.0.0.1", "blocked"],
["::ffff:127.0.0.1", "blocked"],
["fe80::1", "blocked"],
] as const)("classifies %s as %s", (address, expected) => {
expect(classifyAddress(address)).toBe(expected);
});
test("enforces configured address scopes", async () => {
await expect(resolveAndValidateHost("10.0.0.1", "public")).rejects.toMatchObject({ code: "FORBIDDEN" });
await expect(resolveAndValidateHost("10.0.0.1", "private")).resolves.toHaveLength(1);
await expect(resolveAndValidateHost("127.0.0.1", "private")).rejects.toMatchObject({ code: "FORBIDDEN" });
await expect(resolveAndValidateHost("127.0.0.1", "loopback")).resolves.toHaveLength(1);
await expect(resolveAndValidateHost("169.254.169.254", "loopback")).rejects.toMatchObject({
code: "FORBIDDEN",
});
});
test("allows only credential-free HTTP(S) URLs without fragments", () => {
expect(validateCustomWidgetUrl("https://example.com/api").href).toBe("https://example.com/api");
expect(() => validateCustomWidgetUrl("file:///etc/passwd")).toThrow("Only HTTP and HTTPS");
expect(() => validateCustomWidgetUrl("https://user:password@example.com/api")).toThrow("credentials");
expect(() => validateCustomWidgetUrl("https://example.com/api#secret")).toThrow("fragments");
});
test("rejects cross-origin named request targets", () => {
expect(resolveSameOriginTarget("https://example.com/base", "https://example.com/status").pathname).toBe("/status");
expect(() => resolveSameOriginTarget("https://example.com/base", "https://attacker.example/status")).toThrow(
"origin",
);
});
test.each(["Authorization", "Cookie", "Host", "Proxy-Authorization", "Sec-Fetch-Site", "X-Forwarded-For"])(
"rejects the reserved %s header",
(header) => {
expect(() => assertSafeStaticHeaders({ [header]: "value" })).toThrow("reserved");
},
);
test("rejects response JSON that exceeds the depth budget", () => {
let nested: Record<string, unknown> = {};
const root = nested;
for (let index = 0; index < 40; index += 1) {
nested.child = {};
nested = nested.child as Record<string, unknown>;
}
expect(() => assertJsonBudget(root)).toThrow("deeply nested");
});
});
@@ -0,0 +1,30 @@
import { expect, test } from "vitest";
import { createId } from "@homarr/common";
import { acquireCustomWidgetRequestLimit } from "../../custom-widget/request-limits";
test("enforces the server-side DELETE rate limit with retry metadata", async () => {
const input = { category: "delete" as const, userId: createId(), itemId: createId(), definitionId: createId() };
for (let count = 0; count < 3; count += 1) {
const release = await acquireCustomWidgetRequestLimit(input);
await release();
}
try {
await acquireCustomWidgetRequestLimit(input);
expect.unreachable("the fourth DELETE request should be rate limited");
} catch (error) {
expect(error).toMatchObject({ code: "TOO_MANY_REQUESTS" });
const cause = (error as { cause?: { flatten?: () => Record<string, unknown> } }).cause;
expect(cause?.flatten?.()).toMatchObject({ retryAfterMs: expect.any(Number) });
}
});
test("enforces four concurrent requests per user and item", async () => {
const input = { category: "query" as const, userId: createId(), itemId: createId(), definitionId: createId() };
const releases = await Promise.all(Array.from({ length: 4 }, () => acquireCustomWidgetRequestLimit(input)));
await expect(acquireCustomWidgetRequestLimit(input)).rejects.toMatchObject({ code: "TOO_MANY_REQUESTS" });
await Promise.all(releases.map((release) => release()));
});
@@ -0,0 +1,48 @@
import { stringify as stringifySuperJson } from "superjson";
import { describe, expect, test } from "vitest";
import {
BUNDLED_CUSTOM_WIDGETS,
CUSTOM_WIDGET_STARTER,
customWidgetDefinitionSchema,
} from "@homarr/custom-widgets/core";
import {
safeParseStoredCustomWidgetDefinition,
serializeCustomWidgetDefinition,
} from "../../custom-widget/stored-definition";
describe("stored custom widget definitions", () => {
test("parses a valid stored definition", () => {
const result = safeParseStoredCustomWidgetDefinition(
serializeCustomWidgetDefinition(customWidgetDefinitionSchema.parse(CUSTOM_WIDGET_STARTER)),
);
expect(result).toMatchObject({ success: true, widget: { $schema: "homarr-custom-widget-v2" } });
});
test("returns useful issues for an invalid stored request instead of throwing", () => {
const currency = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-currency-exchange");
if (!currency) throw new Error("Currency Exchange bundled widget is missing");
const stored = serializeCustomWidgetDefinition(customWidgetDefinitionSchema.parse(currency.widget));
const result = safeParseStoredCustomWidgetDefinition({
...stored,
requests: stringifySuperJson({ rates: { path: "/latest/{option:missing}" } }),
});
expect(result).toEqual({
success: false,
issues: expect.arrayContaining([expect.objectContaining({ path: "requests.rates" })]),
});
});
test("contains malformed serialized data", () => {
const stored = serializeCustomWidgetDefinition(customWidgetDefinitionSchema.parse(CUSTOM_WIDGET_STARTER));
expect(safeParseStoredCustomWidgetDefinition({ ...stored, requests: "not-json" })).toEqual({
success: false,
issues: [{ message: "Stored widget data could not be read" }],
});
});
});
@@ -0,0 +1,113 @@
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { createDb } from "@homarr/db/test";
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
get: vi.fn(async (_id: string): Promise<unknown> => undefined),
list: vi.fn(async (_options: unknown): Promise<unknown> => undefined),
logger: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
}));
vi.mock("@homarr/core/infrastructure/logs", () => ({
createLogger: () => mocks.logger,
}));
vi.mock("@homarr/workshop/backend", () => ({
WorkshopBackend: class {
public get(id: string) {
return mocks.get(id);
}
public list(options: unknown) {
return mocks.list(options);
}
},
}));
import { customWidgetRouter } from "../../custom-widget/custom-widget-router";
const session = {
user: {
id: createId(),
permissions: ["admin"],
colorScheme: "light",
},
expires: new Date(Date.now() + 60_000).toISOString(),
} satisfies Session;
const createCaller = () =>
customWidgetRouter.createCaller({
db: createDb(),
deviceType: undefined,
session,
});
const createTokenizedFailure = (apiKey: string) => {
const tokenizedUrl = `https://workshop.example/api/submissions?access_token=${apiKey}`;
const error = new Error(`Workshop request failed for ${tokenizedUrl}`);
error.name = `WorkshopTransportError:${tokenizedUrl}`;
return error;
};
const expectSafeFailure = (
error: unknown,
apiKey: string,
logMessage: string,
event: "workshop_widget_lookup_failed" | "workshop_widget_search_failed",
) => {
expect(error).toMatchObject({
code: "BAD_GATEWAY",
message: "Workshop is unavailable",
});
expect((error as Error).cause).toBeUndefined();
expect(String(error)).not.toContain(apiKey);
expect(String(error)).not.toContain("access_token");
expect(mocks.logger.error).toHaveBeenCalledWith(logMessage, {
event,
errorName: "WorkshopBackendError",
});
expect(JSON.stringify(mocks.logger.error.mock.calls)).not.toContain(apiKey);
expect(JSON.stringify(mocks.logger.error.mock.calls)).not.toContain("access_token");
};
describe("Workshop backend error privacy", () => {
beforeEach(() => {
mocks.get.mockReset();
mocks.list.mockReset();
mocks.logger.error.mockClear();
});
test("redacts backend lookup failures from TRPC output and logs", async () => {
const apiKey = "workshop-lookup-secret";
mocks.get.mockRejectedValueOnce(createTokenizedFailure(apiKey));
const error = await createCaller()
.workshopGet({ submissionId: "submission-id" })
.then(
() => undefined,
(cause: unknown) => cause,
);
expectSafeFailure(error, apiKey, "Workshop widget lookup failed", "workshop_widget_lookup_failed");
});
test("redacts backend search failures from TRPC output and logs", async () => {
const apiKey = "workshop-search-secret";
mocks.list.mockRejectedValueOnce(createTokenizedFailure(apiKey));
const error = await createCaller()
.workshopSearch({ query: "weather" })
.then(
() => undefined,
(cause: unknown) => cause,
);
expectSafeFailure(error, apiKey, "Workshop widget search failed", "workshop_widget_search_failed");
});
});
@@ -0,0 +1,90 @@
import { stringify } from "superjson";
import { describe, expect, it } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { eq } from "@homarr/db";
import { boards, sectionCollapseStates, sections, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { sectionRouter } from "../section/section-router";
const userId = createId();
const session = {
user: {
id: userId,
permissions: [],
colorScheme: "light",
},
expires: new Date().toISOString(),
} satisfies Session;
const createCallerAsync = async () => {
const db = createDb();
const boardId = createId();
await db.insert(users).values({ id: userId });
await db.insert(boards).values({
id: boardId,
name: createId(),
creatorId: userId,
});
return {
boardId,
db,
caller: sectionRouter.createCaller({
db,
deviceType: undefined,
session,
}),
};
};
describe("changeCollapsed", () => {
it("rejects a root section", async () => {
const { boardId, caller, db } = await createCallerAsync();
const sectionId = createId();
await db.insert(sections).values({
id: sectionId,
boardId,
kind: "empty",
xOffset: 0,
yOffset: 0,
});
await expect(caller.changeCollapsed({ sectionId, collapsed: true })).rejects.toThrow("Section cannot be collapsed");
});
it("creates and updates collapse state for an enabled container", async () => {
const { boardId, caller, db } = await createCallerAsync();
const sectionId = createId();
await db.insert(sections).values({
id: sectionId,
boardId,
kind: "container",
options: stringify({ collapsible: true }),
});
await caller.changeCollapsed({ sectionId, collapsed: true });
await caller.changeCollapsed({ sectionId, collapsed: false });
const states = await db.query.sectionCollapseStates.findMany({
where: eq(sectionCollapseStates.sectionId, sectionId),
});
expect(states).toHaveLength(1);
expect(states[0]?.collapsed).toBe(false);
});
it("rejects a container when collapsing is disabled", async () => {
const { boardId, caller, db } = await createCallerAsync();
const sectionId = createId();
await db.insert(sections).values({
id: sectionId,
boardId,
kind: "container",
});
await expect(caller.changeCollapsed({ sectionId, collapsed: true })).rejects.toThrow("Section cannot be collapsed");
});
});
@@ -0,0 +1,58 @@
import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import type { Database } from "@homarr/db";
import type { GroupPermissionKey } from "@homarr/definitions";
import { updateCheckerRouter } from "../update-checker";
const mocks = vi.hoisted(() => ({
getDataAsync: vi.fn(async () => ({
data: { availableUpdates: [{ tagName: "v99.0.0" }] },
timestamp: new Date(),
})),
}));
vi.mock("@homarr/request-handler/update-checker", () => ({
updateCheckerRequestHandler: {
handler: () => ({ getDataAsync: mocks.getDataAsync }),
},
}));
const createSession = (permissions: GroupPermissionKey[]): Session => ({
user: {
id: "user",
permissions,
colorScheme: "light",
},
expires: new Date().toISOString(),
});
const createCaller = (session: Session | null) =>
updateCheckerRouter.createCaller({
db: null as unknown as Database,
deviceType: undefined,
session,
});
describe("update checker authorization", () => {
test("allows administrators to read the cached result", async () => {
await expect(createCaller(createSession(["admin"])).getAvailableUpdates()).resolves.toEqual([
{ tagName: "v99.0.0" },
]);
});
test("rejects authenticated users without admin permission", async () => {
await expect(createCaller(createSession([])).getAvailableUpdates()).rejects.toThrow("Permission denied");
});
test("rejects anonymous users", async () => {
await expect(createCaller(null).getAvailableUpdates()).rejects.toThrow("UNAUTHORIZED");
});
test("returns an empty result when the cache reader fails", async () => {
mocks.getDataAsync.mockRejectedValueOnce(new Error("Redis unavailable"));
await expect(createCaller(createSession(["admin"])).getAvailableUpdates()).resolves.toEqual([]);
});
});
@@ -0,0 +1,204 @@
import { stringify as stringifySuperJson } from "superjson";
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { eq } from "@homarr/db";
import { boardUserPermissions, boards, customWidgetDefinitions, items, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
const mocks = vi.hoisted(() => ({
executeRequest: vi.fn(async () => ({ ok: true, status: 200, statusText: "OK", data: { value: 42 } })),
}));
vi.mock("../../custom-widget/request-executor", () => ({
executeCustomWidgetRequest: mocks.executeRequest,
invalidateCustomWidgetResponseCache: vi.fn(),
}));
vi.mock("../../custom-widget/request-limits", () => ({
acquireCustomWidgetRequestLimit: vi.fn(async () => async () => undefined),
}));
import { serializeCustomWidgetDefinition } from "../../custom-widget/stored-definition";
import { customApiRouter } from "../../widgets/custom-api";
const definition: HomarrCustomWidgetV2 = {
$schema: "homarr-custom-widget-v2",
name: "Router test widget",
sources: {
default: {
name: "API",
baseUrl: "https://example.com",
networkScope: "public",
auth: "none",
},
},
requests: {
"load-status": {
source: "default",
kind: "query",
method: "GET",
path: "/status",
auth: "inherit",
permission: "view",
trigger: "load",
},
status: {
source: "default",
kind: "query",
method: "GET",
path: "/status/{param:name}",
auth: "inherit",
permission: "view",
trigger: "manual",
},
toggle: {
source: "default",
kind: "action",
method: "POST",
path: "/toggle",
body: { enabled: { $param: "enabled" } },
auth: "inherit",
permission: "modify",
trigger: "manual",
},
delete: {
source: "default",
kind: "action",
method: "DELETE",
path: "/resource",
auth: "inherit",
permission: "full",
trigger: "manual",
confirmation: { title: "Delete resource", message: "Delete this resource?", destructive: true },
},
},
options: {},
template: "<Text>{data['load-status']?.value}</Text>",
};
const createSession = (userId: string): Session => ({
user: { id: userId, permissions: [], colorScheme: "light" },
expires: new Date(Date.now() + 60_000).toISOString(),
});
async function setup(isPublic = true) {
const db = createDb();
const ownerId = createId();
const boardId = createId();
const itemId = createId();
const definitionId = createId();
await db.insert(users).values({ id: ownerId });
await db.insert(boards).values({ id: boardId, name: createId(), creatorId: ownerId, isPublic });
await db.insert(customWidgetDefinitions).values({
id: definitionId,
...serializeCustomWidgetDefinition(definition),
creatorId: ownerId,
});
await db.insert(items).values({
id: itemId,
boardId,
kind: "customApi",
options: stringifySuperJson({ definitionId, configuration: {}, configurationVersion: 1 }),
});
return { db, ownerId, boardId, itemId, definitionId };
}
describe("Custom JSX v2 board router", () => {
beforeEach(() => mocks.executeRequest.mockClear());
test("allows public queries only through the definition placed on an accessible board", async () => {
const { db, itemId } = await setup();
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
await expect(caller.queryRequest({ itemId, requestId: "status", params: { name: "homarr" } })).resolves.toEqual(
expect.objectContaining({ ok: true, data: { value: 42 } }),
);
await expect(caller.queryRequest({ itemId, requestId: "missing", params: {} })).rejects.toMatchObject({
code: "NOT_FOUND",
});
expect(mocks.executeRequest).toHaveBeenCalledOnce();
});
test("hides private boards and disabled definitions before executing a request", async () => {
const privateSetup = await setup(false);
const anonymous = customApiRouter.createCaller({
db: privateSetup.db,
deviceType: undefined,
session: null,
});
await expect(
anonymous.queryRequest({ itemId: privateSetup.itemId, requestId: "status", params: { name: "homarr" } }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
const enabledSetup = await setup();
await enabledSetup.db
.update(customWidgetDefinitions)
.set({ enabled: false })
.where(eq(customWidgetDefinitions.id, enabledSetup.definitionId));
const publicCaller = customApiRouter.createCaller({ db: enabledSetup.db, deviceType: undefined, session: null });
await expect(publicCaller.getData({ itemId: enabledSetup.itemId })).rejects.toMatchObject({ code: "FORBIDDEN" });
await expect(
publicCaller.queryRequest({ itemId: enabledSetup.itemId, requestId: "status", params: { name: "homarr" } }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
const ownerCaller = customApiRouter.createCaller({
db: enabledSetup.db,
deviceType: undefined,
session: createSession(enabledSetup.ownerId),
});
await expect(
ownerCaller.executeAction({ itemId: enabledSetup.itemId, requestId: "toggle", params: { enabled: true } }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(mocks.executeRequest).not.toHaveBeenCalled();
});
test("rejects request-kind and parameter mismatches", async () => {
const { db, itemId, ownerId } = await setup();
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(ownerId) });
await expect(caller.queryRequest({ itemId, requestId: "toggle", params: { enabled: true } })).rejects.toMatchObject(
{ code: "NOT_FOUND" },
);
await expect(caller.queryRequest({ itemId, requestId: "status", params: {} })).rejects.toThrow();
expect(mocks.executeRequest).not.toHaveBeenCalled();
});
test("enforces modify permission and renders an authorized action body", async () => {
const { db, itemId, boardId } = await setup(false);
const editorId = createId();
await db.insert(users).values({ id: editorId });
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(editorId) });
await expect(
caller.executeAction({ itemId, requestId: "toggle", params: { enabled: true } }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
await db.insert(boardUserPermissions).values({ boardId, userId: editorId, permission: "modify" });
await expect(caller.executeAction({ itemId, requestId: "toggle", params: { enabled: true } })).resolves.toEqual(
expect.objectContaining({ ok: true }),
);
expect(mocks.executeRequest).toHaveBeenCalledWith(
expect.objectContaining({ method: "POST", body: '{"enabled":true}' }),
);
});
test("requires confirmation and full permission for DELETE actions", async () => {
const { db, itemId, boardId, ownerId } = await setup(false);
const owner = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(ownerId) });
await expect(owner.executeAction({ itemId, requestId: "delete", params: {} })).rejects.toMatchObject({
code: "PRECONDITION_FAILED",
});
const editorId = createId();
await db.insert(users).values({ id: editorId });
await db.insert(boardUserPermissions).values({ boardId, userId: editorId, permission: "modify" });
const editor = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(editorId) });
await expect(
editor.executeAction({ itemId, requestId: "delete", params: {}, confirmed: true }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
await expect(owner.executeAction({ itemId, requestId: "delete", params: {}, confirmed: true })).resolves.toEqual(
expect.objectContaining({ ok: true }),
);
});
});
@@ -0,0 +1,107 @@
import SuperJSON from "superjson";
import { describe, expect, test } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { eq } from "@homarr/db";
import { boards, items, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { optionsRouter } from "../../widgets/options";
const createSession = (userId: string, isAdmin = false): Session => ({
user: {
id: userId,
permissions: isAdmin ? ["admin"] : [],
colorScheme: "light",
},
expires: new Date(Date.now() + 60_000).toISOString(),
});
async function setup(kind: "customApi" | "weather") {
const db = createDb();
const userId = createId();
const boardId = createId();
const itemId = createId();
await db.insert(users).values({ id: userId });
await db.insert(boards).values({ id: boardId, name: createId(), creatorId: userId });
await db.insert(items).values({
id: itemId,
boardId,
kind,
options: SuperJSON.stringify(
kind === "customApi"
? { definitionId: "definition-1", refreshInterval: 30, configuration: { server: "primary" } }
: { location: "Paris" },
),
});
return { db, userId, boardId, itemId };
}
describe("saveItemOptions Custom Widget access", () => {
test("rejects direct Custom Widget reconfiguration by a non-admin board modifier", async () => {
const { db, userId, boardId, itemId } = await setup("customApi");
const caller = optionsRouter.createCaller({ db, deviceType: undefined, session: createSession(userId) });
await expect(
caller.saveItemOptions({
boardId,
itemId,
newOptions: {
definitionId: "definition-2",
refreshInterval: 1,
configuration: { server: "attacker" },
},
}),
).rejects.toMatchObject({ code: "FORBIDDEN" });
const stored = await db.query.items.findFirst({ where: eq(items.id, itemId) });
expect(SuperJSON.parse(stored?.options ?? "")).toMatchObject({
definitionId: "definition-1",
refreshInterval: 30,
configuration: { server: "primary" },
});
});
test("allows a Homarr admin to reconfigure a Custom Widget placement", async () => {
const { db, userId, boardId, itemId } = await setup("customApi");
const caller = optionsRouter.createCaller({ db, deviceType: undefined, session: createSession(userId, true) });
await expect(
caller.saveItemOptions({ boardId, itemId, newOptions: { refreshInterval: 60 } }),
).resolves.toBeUndefined();
const stored = await db.query.items.findFirst({ where: eq(items.id, itemId) });
expect(SuperJSON.parse(stored?.options ?? "")).toMatchObject({ refreshInterval: 60 });
});
test("allows normalized missing defaults without treating them as reconfiguration", async () => {
const { db, userId, boardId, itemId } = await setup("customApi");
const stored = await db.query.items.findFirst({ where: eq(items.id, itemId) });
const options = SuperJSON.parse<Record<string, unknown>>(stored?.options ?? "");
delete options.configuration;
delete options.configurationVersion;
await db
.update(items)
.set({ options: SuperJSON.stringify(options) })
.where(eq(items.id, itemId));
const caller = optionsRouter.createCaller({ db, deviceType: undefined, session: createSession(userId) });
await expect(
caller.saveItemOptions({
boardId,
itemId,
newOptions: { configuration: {}, configurationVersion: 1 },
}),
).resolves.toBeUndefined();
});
test("preserves ordinary board-modifier option updates for other widgets", async () => {
const { db, userId, boardId, itemId } = await setup("weather");
const caller = optionsRouter.createCaller({ db, deviceType: undefined, session: createSession(userId) });
await expect(
caller.saveItemOptions({ boardId, itemId, newOptions: { location: "Lyon" } }),
).resolves.toBeUndefined();
});
});
+2 -2
View File
@@ -12,8 +12,8 @@ export const updateCheckerRouter = createTRPCRouter({
const data = await handler.getDataAsync();
return data.data.availableUpdates;
} catch (error) {
logger.error(new Error("Failed to get available updates", { cause: error }));
return undefined; // We return undefined to not show the indicator in the UI
logger.error(new Error("Failed to read the cached update check", { cause: error }));
return []; // An empty list hides the indicator without violating TanStack Query's data contract.
}
}),
});
@@ -1,252 +0,0 @@
import { describe, expect, it } from "vitest";
import { JSONPath } from "jsonpath-plus";
import {
sonarrResponse,
sonarrStatGridConfig,
proxmoxResponse,
proxmoxProgressBarsConfig,
diskUsageResponse,
diskTableConfig,
piholeResponse,
piholeStatusIndicatorConfig,
piholeCountGridConfig,
radarrResponse,
radarrStatGridConfig,
jellyfinItemCountsResponse,
jellyfinCountGridConfig,
} from "./custom-api-fixtures";
const extractors: Record<string, (json: unknown, config: Record<string, unknown>) => unknown> = {
singleValue: (json, c) => ({
type: "singleValue",
label: (c.label as string) ?? "",
unit: (c.unit as string) ?? "",
value: JSONPath({ path: (c.jsonPath as string) ?? "$", json: json as object, wrap: false }),
valueSize: c.valueSize ?? "lg",
labelPosition: c.labelPosition ?? "below",
}),
keyValue: (json, c) => ({
type: "keyValue",
entries: ((c.mappings as Array<{ label: string; jsonPath: string; unit: string }>) ?? []).map((m) => ({
label: m.label,
unit: m.unit,
value: JSONPath({ path: m.jsonPath, json: json as object, wrap: false }),
})),
layout: c.layout ?? "list",
columns: c.columns ?? 2,
}),
table: (json, c) => {
const tablePath = (c.tablePath as string) ?? "$";
const columns = (c.columns as Array<{ header: string; jsonPath: string }>) ?? [];
const rows = JSONPath({ path: tablePath, json: json as object, wrap: true }) as unknown[];
const flatRows = Array.isArray(rows[0]) ? (rows[0] as unknown[]) : rows;
return {
type: "table",
columns: columns.map((col) => col.header),
rows: flatRows.map((row) =>
columns.map((col) => JSONPath({ path: col.jsonPath, json: row as object, wrap: false })),
),
striped: c.striped ?? true,
compact: c.compact ?? false,
};
},
statGrid: (json, c) => ({
type: "statGrid",
items: ((c.items as Array<{ label: string; jsonPath: string; unit: string; color?: string }>) ?? []).map(
(item) => ({
label: item.label,
unit: item.unit,
color: item.color ?? "blue",
value: JSONPath({ path: item.jsonPath, json: json as object, wrap: false }),
}),
),
columns: c.columns ?? 2,
cardStyle: c.cardStyle ?? "filled",
}),
progressBars: (json, c) => ({
type: "progressBars",
bars: (
(c.bars as Array<{ label: string; valuePath: string; maxPath?: string; unit: string; color?: string }>) ?? []
).map((bar) => {
const value = JSONPath({ path: bar.valuePath, json: json as object, wrap: false });
const max = bar.maxPath ? JSONPath({ path: bar.maxPath, json: json as object, wrap: false }) : undefined;
return {
label: bar.label,
unit: bar.unit,
color: bar.color ?? "blue",
value: Number(value) || 0,
max: max !== undefined ? Number(max) || 100 : undefined,
};
}),
showPercentage: c.showPercentage ?? true,
barSize: c.barSize ?? "md",
}),
statusIndicator: (json, c) => ({
type: "statusIndicator",
items: ((c.items as Array<{ label: string; jsonPath: string; goodValues: string[] }>) ?? []).map((item) => {
const value = JSONPath({ path: item.jsonPath, json: json as object, wrap: false });
const isGood = item.goodValues.some((gv) => String(value).toLowerCase() === gv.toLowerCase());
return { label: item.label, value: String(value ?? "unknown"), isGood };
}),
layout: c.layout ?? "list",
dotSize: c.dotSize ?? "md",
}),
countGrid: (json, c) => ({
type: "countGrid",
items: ((c.items as Array<{ label: string; jsonPath: string; unit: string }>) ?? []).map((item) => ({
label: item.label,
unit: item.unit,
value: JSONPath({ path: item.jsonPath, json: json as object, wrap: false }),
})),
columns: c.columns ?? 2,
valueSize: c.valueSize ?? "md",
}),
raw: (json, c) => ({
type: "raw",
data: JSONPath({ path: (c.jsonPath as string) ?? "$", json: json as object, wrap: false }),
maxHeight: c.maxHeight ?? 300,
}),
customJsx: (json, config) => ({
type: "customJsx" as const,
template: config.template as string,
data: json,
}),
};
function extract(type: string, json: unknown, config: Record<string, unknown>) {
const fn = extractors[type];
if (!fn) throw new Error(`No extractor for type: ${type}`);
return fn(json, config);
}
describe("custom-api extractors", () => {
it("sonarr statGrid extracts missing and queue counts", () => {
const result = extract("statGrid", sonarrResponse, sonarrStatGridConfig) as {
type: string;
items: Array<{ label: string; value: unknown; color: string }>;
};
expect(result.type).toBe("statGrid");
expect(result.items).toHaveLength(2);
expect(result.items[0]?.value).toBe(42);
expect(result.items[0]?.label).toBe("Missing");
expect(result.items[0]?.color).toBe("red");
expect(result.items[1]?.value).toBe(5);
});
it("proxmox progressBars extracts memory and disk usage", () => {
const result = extract("progressBars", proxmoxResponse, proxmoxProgressBarsConfig) as {
type: string;
bars: Array<{ label: string; value: number; max: number }>;
};
expect(result.type).toBe("progressBars");
expect(result.bars).toHaveLength(2);
expect(result.bars[0]?.value).toBe(12_884_901_888);
expect(result.bars[0]?.max).toBe(34_359_738_368);
expect(result.bars[1]?.label).toBe("Disk");
});
it("disk table extracts filesystem rows", () => {
const result = extract("table", diskUsageResponse, diskTableConfig) as {
type: string;
columns: string[];
rows: unknown[][];
};
expect(result.type).toBe("table");
expect(result.columns).toEqual(["Mount", "Size", "Used"]);
expect(result.rows).toHaveLength(3);
expect(result.rows[0]?.[0]).toBe("/");
expect(result.rows[2]?.[2]).toBe("95%");
});
it("pihole statusIndicator detects enabled state", () => {
const result = extract("statusIndicator", piholeResponse, piholeStatusIndicatorConfig) as {
type: string;
items: Array<{ label: string; value: string; isGood: boolean }>;
};
expect(result.type).toBe("statusIndicator");
expect(result.items).toHaveLength(1);
expect(result.items[0]?.isGood).toBe(true);
expect(result.items[0]?.value).toBe("enabled");
});
it("pihole countGrid extracts all 4 stats", () => {
const result = extract("countGrid", piholeResponse, piholeCountGridConfig) as {
type: string;
items: Array<{ label: string; value: unknown; unit: string }>;
};
expect(result.type).toBe("countGrid");
expect(result.items).toHaveLength(4);
expect(result.items[0]?.value).toBe(12_847);
expect(result.items[1]?.value).toBe(48_291);
expect(result.items[2]?.value).toBe(26.6);
expect(result.items[2]?.unit).toBe("%");
expect(result.items[3]?.value).toBe(14);
});
it("radarr statGrid with outline card style", () => {
const result = extract("statGrid", radarrResponse, radarrStatGridConfig) as {
type: string;
items: Array<{ label: string; value: unknown }>;
cardStyle: string;
};
expect(result.type).toBe("statGrid");
expect(result.cardStyle).toBe("outline");
expect(result.items[0]?.value).toBe(15);
expect(result.items[1]?.value).toBe(3);
});
it("raw extractor returns raw data at path", () => {
const result = extract("raw", piholeResponse, { type: "raw", jsonPath: "$.status", maxHeight: 200 }) as {
type: string;
data: unknown;
maxHeight: number;
};
expect(result.type).toBe("raw");
expect(result.data).toBe("enabled");
expect(result.maxHeight).toBe(200);
});
it("customJsx passes full JSON and template through", () => {
const json = { title: "Status", items: [{ name: "CPU", value: 42 }] };
const config = {
type: "customJsx" as const,
template: "<Stack><Title>{data.title}</Title></Stack>",
};
const result = extract("customJsx", json, config) as {
type: string;
template: string;
data: typeof json;
};
expect(result.type).toBe("customJsx");
expect(result.template).toBe(config.template);
expect(result.data).toEqual(json);
});
it("jellyfin countGrid extracts movies/series/episodes/songs in 4-column grid", () => {
const result = extract("countGrid", jellyfinItemCountsResponse, jellyfinCountGridConfig) as {
type: string;
items: Array<{ label: string; value: unknown; unit: string }>;
columns: number;
valueSize: string;
};
expect(result.type).toBe("countGrid");
expect(result.columns).toBe(4);
expect(result.valueSize).toBe("lg");
expect(result.items).toHaveLength(4);
expect(result.items[0]).toEqual({ label: "Movies", value: 185, unit: "" });
expect(result.items[1]).toEqual({ label: "Series", value: 38, unit: "" });
expect(result.items[2]).toEqual({ label: "Episodes", value: 770, unit: "" });
expect(result.items[3]).toEqual({ label: "Songs", value: 0, unit: "" });
});
});
@@ -1,142 +0,0 @@
export const sonarrResponse = {
wanted: { totalRecords: 42 },
queue: { totalRecords: 5 },
series: [
{ title: "Breaking Bad", seasons: 5, episodeCount: 62, episodeFileCount: 60 },
{ title: "The Wire", seasons: 5, episodeCount: 60, episodeFileCount: 55 },
],
diskspace: [
{ path: "/tv", label: "TV Shows", freeSpace: 150_000_000_000, totalSpace: 500_000_000_000 },
{ path: "/movies", label: "Movies", freeSpace: 80_000_000_000, totalSpace: 250_000_000_000 },
],
};
export const sonarrStatGridConfig = {
type: "statGrid" as const,
items: [
{ label: "Missing", jsonPath: "$.wanted.totalRecords", unit: "", color: "red" },
{ label: "Queue", jsonPath: "$.queue.totalRecords", unit: "", color: "blue" },
],
columns: 2,
cardStyle: "filled" as const,
};
export const proxmoxResponse = {
data: {
cpu: 0.23,
memory: { used: 12_884_901_888, total: 34_359_738_368 },
rootfs: { used: 42_949_672_960, total: 107_374_182_400 },
uptime: 864_000,
status: "running",
maxcpu: 8,
maxmem: 34_359_738_368,
name: "proxmox-node1",
},
};
export const proxmoxProgressBarsConfig = {
type: "progressBars" as const,
bars: [
{ label: "Memory", valuePath: "$.data.memory.used", maxPath: "$.data.memory.total", unit: "B", color: "blue" },
{ label: "Disk", valuePath: "$.data.rootfs.used", maxPath: "$.data.rootfs.total", unit: "B", color: "orange" },
],
showPercentage: true,
barSize: "md" as const,
};
export const diskUsageResponse = {
filesystems: [
{ filesystem: "/dev/sda1", size: "500G", used: "350G", avail: "150G", usePct: "70%", mountedOn: "/" },
{ filesystem: "/dev/sdb1", size: "2T", used: "1.2T", avail: "800G", usePct: "60%", mountedOn: "/data" },
{ filesystem: "/dev/sdc1", size: "4T", used: "3.8T", avail: "200G", usePct: "95%", mountedOn: "/media" },
],
};
export const diskTableConfig = {
type: "table" as const,
tablePath: "$.filesystems",
columns: [
{ header: "Mount", jsonPath: "$.mountedOn" },
{ header: "Size", jsonPath: "$.size" },
{ header: "Used", jsonPath: "$.usePct" },
],
striped: true,
compact: false,
};
export const piholeResponse = {
domains_being_blocked: 125_432,
dns_queries_today: 48_291,
ads_blocked_today: 12_847,
ads_percentage_today: 26.6,
unique_clients: 14,
status: "enabled",
gravity_last_updated: { relative: { days: 2, hours: 3 } },
};
export const piholeStatusIndicatorConfig = {
type: "statusIndicator" as const,
items: [{ label: "Pi-Hole", jsonPath: "$.status", goodValues: ["enabled"] }],
layout: "list" as const,
dotSize: "md" as const,
};
export const piholeCountGridConfig = {
type: "countGrid" as const,
items: [
{ label: "Blocked Today", jsonPath: "$.ads_blocked_today", unit: "" },
{ label: "DNS Queries", jsonPath: "$.dns_queries_today", unit: "" },
{ label: "Block Rate", jsonPath: "$.ads_percentage_today", unit: "%" },
{ label: "Clients", jsonPath: "$.unique_clients", unit: "" },
],
columns: 2,
valueSize: "md" as const,
};
export const radarrResponse = {
wanted: { totalRecords: 15 },
queue: { totalRecords: 3 },
movie: [
{ title: "Inception", hasFile: true, monitored: true, runtime: 148, year: 2010 },
{ title: "Interstellar", hasFile: false, monitored: true, runtime: 169, year: 2014 },
{ title: "The Dark Knight", hasFile: true, monitored: false, runtime: 152, year: 2008 },
],
rootfolder: [{ path: "/movies", freeSpace: 200_000_000_000, totalSpace: 1_000_000_000_000 }],
};
export const radarrStatGridConfig = {
type: "statGrid" as const,
items: [
{ label: "Missing", jsonPath: "$.wanted.totalRecords", unit: "", color: "red" },
{ label: "Queue", jsonPath: "$.queue.totalRecords", unit: "", color: "teal" },
],
columns: 2,
cardStyle: "outline" as const,
};
export const jellyfinItemCountsResponse = {
MovieCount: 185,
SeriesCount: 38,
EpisodeCount: 770,
ArtistCount: 0,
ProgramCount: 0,
TrailerCount: 0,
SongCount: 0,
AlbumCount: 0,
MusicVideoCount: 0,
BoxSetCount: 12,
BookCount: 0,
ItemCount: 0,
};
export const jellyfinCountGridConfig = {
type: "countGrid" as const,
items: [
{ label: "Movies", jsonPath: "$.MovieCount", unit: "" },
{ label: "Series", jsonPath: "$.SeriesCount", unit: "" },
{ label: "Episodes", jsonPath: "$.EpisodeCount", unit: "" },
{ label: "Songs", jsonPath: "$.SongCount", unit: "" },
],
columns: 4,
valueSize: "lg" as const,
};
+280 -75
View File
@@ -1,88 +1,293 @@
import { TRPCError } from "@trpc/server";
import { parse } from "superjson";
import { parse as parseSuperJson } from "superjson";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { decryptSecret } from "@homarr/common/server";
import { customWidgetDefinitions } from "@homarr/db/schema";
import { eq } from "@homarr/db";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createTRPCRouter, protectedProcedure } from "../../trpc";
import { applyAuth } from "../custom-widget/auth";
import { assertCustomApiItemBindingAsync, customApiItemInputSchema } from "../custom-widget/custom-widget-access";
import { extractActionButtonDisplay, extractDisplayDataWithFallback } from "../custom-widget/display-data";
import { boards, customWidgetDefinitions, items, legacyCustomWidgetDefinitions } from "@homarr/db/schema";
import type { BoardPermission } from "@homarr/definitions";
import {
consumeBoundedResponseAsync,
readBoundedJsonResponseAsync,
validateCustomApiUrl,
withCustomApiResponseAsync,
} from "./custom-api-security";
getCustomWidgetConfirmation,
getCustomWidgetDefaultOptions,
validateCustomWidgetOptions,
} from "@homarr/custom-widgets/core";
import type { CustomJsxRequest, CustomWidgetSource } from "@homarr/custom-widgets/core";
const logger = createLogger({ module: "widget:customApi" });
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
import { executeCustomWidgetRequest, invalidateCustomWidgetResponseCache } from "../custom-widget/request-executor";
import {
hashRuntimeParams,
renderRequestBody,
renderRequestTarget,
resolveCustomWidgetRequestValues,
} from "../custom-widget/request-manifest";
import { acquireCustomWidgetRequestLimit } from "../custom-widget/request-limits";
import { getCustomWidgetCacheVersion } from "../custom-widget/cache-version";
import { parseStoredCustomWidgetDefinition } from "../custom-widget/stored-definition";
export const customApiRouter = createTRPCRouter({
getData: protectedProcedure.input(customApiItemInputSchema).query(async ({ ctx, input }) => {
await assertCustomApiItemBindingAsync(ctx, input, "view");
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.definitionId),
with: { secrets: true },
const runtimeParamsSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()]));
const itemInputSchema = z.object({ itemId: z.string().min(1) });
const namedRequestInputSchema = itemInputSchema.extend({
requestId: z.string().min(1).max(64),
params: runtimeParamsSchema.default({}),
});
interface CustomWidgetItemOptions {
definitionId: string;
configuration: Record<string, unknown>;
configurationVersion: number;
refreshInterval?: number;
}
type RouterContext = Parameters<typeof throwIfActionForbiddenAsync>[0];
type ResolvedDefinition = Awaited<ReturnType<typeof resolvePlacedDefinitionAsync>>;
const parseItemOptions = (raw: string): CustomWidgetItemOptions => {
try {
const options = parseSuperJson(raw) as Record<string, unknown>;
if (typeof options.definitionId !== "string" || options.definitionId.length === 0) throw new Error();
return {
definitionId: options.definitionId,
configuration:
options.configuration !== null &&
typeof options.configuration === "object" &&
!Array.isArray(options.configuration)
? (options.configuration as Record<string, unknown>)
: {},
configurationVersion:
typeof options.configurationVersion === "number" && Number.isInteger(options.configurationVersion)
? options.configurationVersion
: 1,
refreshInterval: typeof options.refreshInterval === "number" ? options.refreshInterval : undefined,
};
} catch {
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget item not found" });
}
};
async function resolvePlacedDefinitionAsync(ctx: RouterContext, itemId: string) {
const item = await ctx.db.query.items.findFirst({
where: eq(items.id, itemId),
columns: { id: true, boardId: true, kind: true, options: true },
});
if (!item || item.kind !== "customApi") {
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget item not found" });
}
await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "view");
const itemOptions = parseItemOptions(item.options);
const stored = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, itemOptions.definitionId),
with: { secrets: true },
});
if (!stored) {
const legacy = await ctx.db.query.legacyCustomWidgetDefinitions.findFirst({
where: eq(legacyCustomWidgetDefinitions.id, itemOptions.definitionId),
columns: { id: true },
});
if (!definition) {
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
}
if (!definition.enabled) {
return { type: "disabled" };
}
let displayConfig: Record<string, unknown>;
try {
displayConfig = parse(definition.displayConfig) as Record<string, unknown>;
} catch {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Widget has corrupt display configuration" });
}
if (definition.displayType === "actionButton") {
const actionDisplay = extractActionButtonDisplay(displayConfig);
return {
...actionDisplay,
canExecute: ctx.session.user.permissions.includes("admin"),
};
}
try {
const decryptedSecrets = definition.secrets.map((s) => ({
kind: s.kind,
value: decryptSecret(s.value),
}));
const url = validateCustomApiUrl(definition.url);
const headers = new Headers({ Accept: "application/json" });
if (definition.method !== "GET" && definition.requestBody) headers.set("Content-Type", "application/json");
applyAuth(headers, url, definition.authType, decryptedSecrets, definition.headerName);
return await withCustomApiResponseAsync(
url,
{
method: definition.method,
headers,
body: definition.method !== "GET" ? definition.requestBody : undefined,
},
async (response) => {
if (!response.ok) {
await consumeBoundedResponseAsync(response);
throw new Error(`Custom API returned HTTP ${response.status}`);
}
const json = await readBoundedJsonResponseAsync(response);
return extractDisplayDataWithFallback(json, definition.displayType, displayConfig);
},
);
} catch (error) {
const errorId = createId();
logger.error("Failed to fetch custom API data", { errorId, definitionId: input.definitionId, error });
if (legacy) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Custom API request failed",
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);
const configuration = { ...getCustomWidgetDefaultOptions(definition.options), ...itemOptions.configuration };
const issues = validateCustomWidgetOptions(definition.options, configuration);
if (issues.length > 0) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: `Custom widget configuration needs repair: ${issues[0]?.path} ${issues[0]?.message}`,
});
}
return { item, stored, definition, itemOptions, configuration };
}
const findRequest = (resolved: ResolvedDefinition, requestId: string, kind: "query" | "action") => {
const request = resolved.definition.requests[requestId];
if (request?.kind !== kind) throw new TRPCError({ code: "NOT_FOUND", message: "Named request not found" });
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Named request not found" });
return { id: requestId, ...request };
};
const findSource = (resolved: ResolvedDefinition, sourceId: string) => {
const source = resolved.definition.sources[sourceId];
if (!source) throw new TRPCError({ code: "NOT_FOUND", message: "Widget API source not found" });
return { id: sourceId, ...source };
};
type IdentifiedRequest = CustomJsxRequest & { id: string };
type IdentifiedSource = CustomWidgetSource & { id: string };
const getAuth = (resolved: ResolvedDefinition, source: IdentifiedSource, mode: "inherit" | "none") => {
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
if (mode === "none" || authType === "none") return undefined;
const secrets = resolved.stored.secrets
.filter((secret) => secret.sourceId === source.id)
.map((secret) => ({ kind: secret.kind, value: decryptSecret(secret.encryptedValue) }));
return {
type: authType,
secrets,
headerName:
typeof source.auth === "object" && source.auth.type === "apiKeyHeader"
? source.auth.name
: typeof source.auth === "object" && source.auth.type === "apiKeyQuery"
? source.auth.name
: undefined,
};
};
const withRequestLimit = async <T>(
ctx: RouterContext,
resolved: ResolvedDefinition,
request: IdentifiedRequest,
callback: () => Promise<T>,
) => {
const release = await acquireCustomWidgetRequestLimit({
category: request.kind === "query" ? "query" : request.method === "DELETE" ? "delete" : "action",
userId: ctx.session?.user.id,
itemId: resolved.item.id,
definitionId: resolved.stored.id,
});
try {
return await callback();
} finally {
await release();
}
};
const getCacheKey = (resolved: ResolvedDefinition, request: IdentifiedRequest, params: Record<string, unknown>) =>
`custom-jsx:${resolved.item.id}:${getCustomWidgetCacheVersion(resolved.stored)}:${request.id}:${hashRuntimeParams(params)}`;
const executeRequest = async (
ctx: RouterContext,
resolved: ResolvedDefinition,
request: IdentifiedRequest,
params: Record<string, string | number | boolean>,
) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, resolved.item.boardId), request.permission as BoardPermission);
const source = findSource(resolved, request.source);
const values = resolveCustomWidgetRequestValues(request, resolved.configuration, params);
const targetUrl = renderRequestTarget(source.baseUrl, request, values);
return withRequestLimit(ctx, resolved, request, () =>
executeCustomWidgetRequest({
baseUrl: source.baseUrl,
targetUrl,
method: request.method,
body: renderRequestBody(request, values),
staticHeaders: request.headers,
auth: getAuth(resolved, source, request.auth),
networkScope: source.networkScope,
kind: request.kind,
cacheKey: request.kind === "query" ? getCacheKey(resolved, request, values) : undefined,
cacheTtlSeconds: request.cacheSeconds,
}),
);
};
export const customApiRouter = createTRPCRouter({
getData: publicProcedure.input(itemInputSchema).query(async ({ ctx, input }) => {
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
const loadRequests = Object.entries(resolved.definition.requests).filter(
([, request]) => request.kind === "query" && request.trigger === "load",
);
const entries = await Promise.all(
loadRequests.map(async ([requestId, request]) => {
try {
const response = await executeRequest(ctx, resolved, { id: requestId, ...request }, {});
return [
requestId,
{
data: response.data,
status: {
loading: false,
ok: response.ok,
status: response.status,
statusText: response.statusText,
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
},
},
] as const;
} catch (error) {
return [
requestId,
{
data: null,
status: {
loading: false,
ok: false,
status: 0,
error: error instanceof Error ? error.message : "Request failed",
},
},
] as const;
}
}),
);
return {
type: "customJsx" as const,
template: resolved.definition.template,
data: Object.fromEntries(entries.map(([id, result]) => [id, result.data])),
status: Object.fromEntries(entries.map(([id, result]) => [id, result.status])),
options: resolved.configuration,
requestCapabilities: Object.entries(resolved.definition.requests).map(([id, request]) => ({
id,
kind: request.kind,
method: request.method,
trigger: request.trigger,
minimumBoardPermission: request.permission,
confirmation: getCustomWidgetConfirmation(request),
invalidates: request.invalidates ?? [],
})),
};
}),
queryRequest: publicProcedure.input(namedRequestInputSchema).query(async ({ ctx, input }) => {
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
const request = findRequest(resolved, input.requestId, "query");
if (request.trigger !== "manual") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Load queries cannot be invoked manually" });
}
const response = await executeRequest(ctx, resolved, request, input.params);
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
data: response.data,
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
};
}),
executeAction: protectedProcedure
.input(namedRequestInputSchema.extend({ confirmed: z.boolean().optional() }))
.mutation(async ({ ctx, input }) => {
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
const request = findRequest(resolved, input.requestId, "action");
const needsConfirmation = request.confirmation !== undefined || request.method === "DELETE";
if (needsConfirmation && input.confirmed !== true) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "This action requires confirmation" });
}
const response = await executeRequest(ctx, resolved, request, input.params);
if (response.ok && request.invalidates?.length) {
invalidateCustomWidgetResponseCache(
request.invalidates.flatMap((requestId) => [
`custom-jsx:${resolved.item.id}:${getCustomWidgetCacheVersion(resolved.stored)}:${requestId}:`,
`custom-widget:options:${resolved.stored.id}:${getCustomWidgetCacheVersion(resolved.stored)}:${requestId}:`,
]),
);
}
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
data: response.data,
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
invalidates: request.invalidates ?? [],
};
}),
});
+9 -3
View File
@@ -10,6 +10,7 @@ import type { WidgetOptionsSettings } from "../../../../widgets/src";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
import { validateTimetableOptionsChangeAsync } from "./timetable";
import { throwIfCustomWidgetPlacementChangeForbidden } from "../board/custom-widget-placement-access";
export const optionsRouter = createTRPCRouter({
getWidgetOptionSettings: publicProcedure.query(async ({ ctx }): Promise<WidgetOptionsSettings> => {
@@ -47,13 +48,18 @@ export const optionsRouter = createTRPCRouter({
}
const previousOptions = SuperJSON.parse<Record<string, unknown>>(item.options);
const options = { ...previousOptions, ...input.newOptions };
const updatedOptions = { ...previousOptions, ...input.newOptions };
if (item.kind === "timetable") {
await validateTimetableOptionsChangeAsync(options, previousOptions);
await validateTimetableOptionsChangeAsync(updatedOptions, previousOptions);
}
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: ctx.session.user.permissions.includes("admin"),
submittedItems: [{ id: item.id, kind: item.kind, options: updatedOptions }],
storedItems: [{ id: item.id, kind: item.kind, options: previousOptions }],
});
await ctx.db
.update(items)
.set({ options: SuperJSON.stringify(options) })
.set({ options: SuperJSON.stringify(updatedOptions) })
.where(eq(items.id, input.itemId));
}),
});
@@ -3,6 +3,7 @@ import SuperJSON from "superjson";
import { z } from "zod/v4";
import type { Session } from "@homarr/auth";
import { env } from "@homarr/common/env";
import type { Database } from "@homarr/db";
import { eq } from "@homarr/db";
import { items, users } from "@homarr/db/schema";
@@ -31,6 +32,8 @@ const feedsInput = z.object({
export const rssFeedRouter = createTRPCRouter({
getFeeds: publicProcedure.input(feedsInput).query(async ({ ctx, input }) => {
if (env.NO_EXTERNAL_CONNECTION) return { entries: [], failedFeedCount: 0 };
const urls = (await canAccessAllFeedsAsync(ctx.db, ctx.session))
? input.urls
: await restrictUrlsAsync(ctx.db, input.urls);
@@ -1,5 +1,6 @@
import { z } from "zod/v4";
import { env } from "@homarr/common/env";
import { weatherRequestHandler } from "@homarr/request-handler/weather";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -11,6 +12,8 @@ const atLocationInput = z.object({
export const weatherRouter = createTRPCRouter({
atLocation: publicProcedure.input(atLocationInput).query(async ({ input }) => {
if (env.NO_EXTERNAL_CONNECTION) return null;
const handler = weatherRequestHandler.handler(input);
return await handler.getDataAsync().then((result) => result.data);
}),
+16
View File
@@ -0,0 +1,16 @@
import { cache } from "react";
import { headers } from "next/headers";
import { auth } from "@homarr/auth/next";
import { createTRPCContext } from "./trpc";
export const createRscTrpcContext = cache(async () => {
const requestHeaders = new Headers(await headers());
requestHeaders.set("x-trpc-source", "rsc");
return createTRPCContext({
session: await auth(),
headers: requestHeaders,
});
});
@@ -0,0 +1,8 @@
import { cache } from "react";
import { db } from "@homarr/db";
import { getServerSettingsAsync } from "@homarr/db/queries";
// One settings snapshot per RSC request, shared by the root layout, theme, and
// widget prefetching. React invalidates this cache between requests.
export const getRscServerSettingsAsync = cache(async () => await getServerSettingsAsync(db));
+3 -12
View File
@@ -1,25 +1,16 @@
import { cache } from "react";
import { headers } from "next/headers";
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
import { appRouter, createCaller, createTRPCContext } from "@homarr/api";
import { auth } from "@homarr/auth/next";
import { appRouter, createCaller } from "@homarr/api";
import { createRscTrpcContext } from "./rsc-context";
import { makeQueryClient } from "./shared";
/**
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
* handling a tRPC call from a React Server Component.
*/
const createContext = cache(async () => {
const heads = new Headers(await headers());
heads.set("x-trpc-source", "rsc");
return createTRPCContext({
session: await auth(),
headers: heads,
});
});
const createContext = () => createRscTrpcContext();
export const api = createCaller(createContext);
+216 -40
View File
@@ -1,49 +1,225 @@
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 { 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,
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_legacyMigrationPrompt",
"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_migrateLegacy",
"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<string>(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("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();
}
test("rejects every authoring procedure without a session", async () => {
const calls = [
unauthenticatedCaller.customWidget.schema(),
unauthenticatedCaller.customWidget.getAuthoringPrompt(),
unauthenticatedCaller.customWidget.getSkill(),
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<Partial<TRPCError>>({ code: "UNAUTHORIZED" }));
}
});
test("rejects authoring and secret tools for authenticated non-admins", async () => {
const nonAdminCaller = mcpRouter.createCaller({
db: null as never,
deviceType: undefined,
session: { user: { id: "user-1", permissions: ["board-modify-all"] } } as never,
});
const calls = [
nonAdminCaller.customWidget.schema(),
nonAdminCaller.customWidget.getAuthoringPrompt(),
nonAdminCaller.customWidget.getSkill(),
nonAdminCaller.customWidget.secretSet({
definitionId: "widget",
secret: { sourceId: "default", kind: "apiKey", value: "secret" },
}),
];
for (const call of calls) {
await expect(call).rejects.toEqual(expect.objectContaining<Partial<TRPCError>>({ code: "FORBIDDEN" }));
}
});
test("allows admins to read authoring resources", async () => {
const adminCaller = mcpRouter.createCaller({
db: null as never,
deviceType: undefined,
session: { user: { id: "admin-1", permissions: ["admin"] } } as never,
});
await expect(adminCaller.customWidget.schema()).resolves.toMatchObject({ type: "object" });
await expect(adminCaller.customWidget.getAuthoringPrompt()).resolves.toMatchObject({
version: 2,
prompt: expect.any(String),
});
await expect(adminCaller.customWidget.getSkill()).resolves.toMatchObject({
name: expect.any(String),
});
});
});
+54 -119
View File
@@ -1,65 +1,20 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import type { Database } from "@homarr/db";
import { boards, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import {
isPersistableWidgetQueryKey,
dashboardSupportingQueryPolicies,
isWidgetDataQueryKey,
isWidgetDataTrpcPath,
queryCacheDefaultRefetchIntervalMs,
queryCacheDefaultStaleTimeMs,
queryCacheMaxValueBytes,
queryCacheMetadataStaleTimeMs,
} from "@homarr/api/query-cache";
import { queryCacheRouter } from "../router/query-cache";
const redisMock = vi.hoisted(() => {
const values = new Map<string, string>();
return {
values,
setQueryCacheAsync: vi.fn(
async (_userId: string, _boardId: string, value: string, _ttlMs: number, maxValueBytes: number) => {
if (Buffer.byteLength(value, "utf8") > maxValueBytes) return false;
values.set(`${_userId}:${_boardId}`, value);
return true;
},
),
removeQueryCacheAsync: vi.fn(async (_userId: string, _boardId: string) => {
values.delete(`${_userId}:${_boardId}`);
}),
};
});
vi.mock("@homarr/redis", () => ({
setQueryCacheAsync: redisMock.setQueryCacheAsync,
removeQueryCacheAsync: redisMock.removeQueryCacheAsync,
}));
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
afterEach(() => {
vi.useRealTimers();
redisMock.values.clear();
redisMock.setQueryCacheAsync.mockClear();
redisMock.removeQueryCacheAsync.mockClear();
});
const createSession = (userId = createId()): Session => ({
user: {
id: userId,
permissions: [],
colorScheme: "light",
},
expires: new Date().toISOString(),
});
const insertUserAsync = async (db: Database, id: string) => {
await db.insert(users).values({ id });
};
describe("isPersistableWidgetQueryKey", () => {
describe("isWidgetDataQueryKey", () => {
test.each([
[[["widget", "calendar", "findAllEvents"], { type: "query" }], true],
[[["app", "byId"], { type: "query" }], true],
@@ -72,77 +27,22 @@ describe("isPersistableWidgetQueryKey", () => {
[[["app", "selectable"], { type: "query" }], false],
[[["board", "getBoardByName"], { type: "query" }], false],
[["widget"], false],
])("matches persist filter for %j", (queryKey, expected) => {
expect(isPersistableWidgetQueryKey(queryKey)).toBe(expected);
])("matches widget data queries for %j", (queryKey, expected) => {
expect(isWidgetDataQueryKey(queryKey)).toBe(expected);
});
});
describe("queryCacheRouter", () => {
test("stores and removes data for a board viewer", async () => {
const db = createDb();
const session = createSession();
await insertUserAsync(db, session.user.id);
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: session.user.id, isPublic: false });
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
await expect(caller.setItem({ boardId: "board-1", value: "cached-value" })).resolves.toEqual({
stored: true,
});
expect(redisMock.setQueryCacheAsync).toHaveBeenCalledWith(
session.user.id,
"board-1",
"cached-value",
expect.any(Number),
queryCacheMaxValueBytes,
);
await caller.removeItem({ boardId: "board-1" });
expect(redisMock.removeQueryCacheAsync).toHaveBeenCalledWith(session.user.id, "board-1");
});
test("does not touch Redis when board access is denied", async () => {
const db = createDb();
await insertUserAsync(db, "owner");
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: false });
redisMock.setQueryCacheAsync.mockClear();
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session: createSession("other-user") });
await expect(caller.setItem({ boardId: "board-1", value: "cached-value" })).rejects.toThrow("Board not found");
expect(redisMock.setQueryCacheAsync).not.toHaveBeenCalled();
});
test("scopes anonymous public-board cache entries by anonymous user", async () => {
const db = createDb();
await insertUserAsync(db, "owner");
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: true });
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session: null });
await caller.setItem({ boardId: "board-1", value: "cached-value" });
expect(redisMock.setQueryCacheAsync).toHaveBeenCalledWith(
"anonymous",
"board-1",
"cached-value",
expect.any(Number),
queryCacheMaxValueBytes,
);
});
test("rejects oversized values", async () => {
const db = createDb();
const session = createSession("user-1");
await insertUserAsync(db, session.user.id);
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: session.user.id, isPublic: false });
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
const value = "x".repeat(queryCacheMaxValueBytes + 1);
await expect(caller.setItem({ boardId: "board-1", value })).resolves.toEqual({
stored: false,
});
describe("isWidgetDataTrpcPath", () => {
test.each([
["widget.calendar.findAllEvents", true],
["app.byIds", true],
["integration.byIds", true],
["docker.getContainers", true],
["widget.app.ping", false],
["widget.beszel.getSystemStats", false],
["board.getBoardByName", false],
])("classifies dashboard data path %s", (path, expected) => {
expect(isWidgetDataTrpcPath(path)).toBe(expected);
});
});
@@ -176,3 +76,38 @@ describe("query cache stale handling", () => {
expect(queryFn).toHaveBeenCalledOnce();
});
});
describe("dashboard supporting query policies", () => {
test("bounds metadata staleness without adding per-widget timers", () => {
expect(dashboardSupportingQueryPolicies).toEqual([
{
queryKey: [["app", "byId"]],
refetchInterval: false,
staleTime: queryCacheMetadataStaleTimeMs,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
},
{
queryKey: [["app", "byIds"]],
refetchInterval: false,
staleTime: queryCacheMetadataStaleTimeMs,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
},
{
queryKey: [["integration", "byIds"]],
refetchInterval: false,
staleTime: queryCacheMetadataStaleTimeMs,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
},
{
queryKey: [["docker", "getContainers"]],
refetchInterval: queryCacheDefaultRefetchIntervalMs,
staleTime: queryCacheDefaultStaleTimeMs,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
]);
});
});
+11 -2
View File
@@ -13,8 +13,9 @@ import type { OpenApiMeta } from "trpc-to-openapi";
import { ZodError } from "zod/v4";
import type { Session } from "@homarr/auth";
import { FlattenError } from "@homarr/common";
import { extractBaseUrlFromHeaders, FlattenError } from "@homarr/common";
import { userAgent } from "@homarr/common/server";
import type { DeviceType } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { db } from "@homarr/db";
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
@@ -36,7 +37,14 @@ const logger = createLogger({ module: "trpc" });
*
* @see https://trpc.io/docs/server/context
*/
export const createTRPCContext = (opts: { headers: Headers; session: Session | null }) => {
interface ApiContext {
session: Session | null;
deviceType: DeviceType;
baseUrl?: `${string}://${string}`;
db: typeof db;
}
export const createTRPCContext = (opts: { headers: Headers; session: Session | null }): ApiContext => {
const session = opts.session;
const source = opts.headers.get("x-trpc-source") ?? "unknown";
@@ -49,6 +57,7 @@ export const createTRPCContext = (opts: { headers: Headers; session: Session | n
return {
session,
deviceType: userAgent(opts.headers).device.type,
baseUrl: extractBaseUrlFromHeaders(opts.headers),
db,
};
};
+24
View File
@@ -0,0 +1,24 @@
import { cache } from "react";
import { db, eq } from "@homarr/db";
import { users } from "@homarr/db/schema";
// Keep the authenticated user lookup shared across nested RSC layouts. React
// clears this memo between requests, so authorization changes cannot leak.
export const getRscUserSettingsAsync = cache(async (userId: string) =>
db.query.users.findFirst({
columns: {
homeBoardId: true,
mobileHomeBoardId: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
enableRightClickOnWidgets: true,
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,
completedManageTour: true,
completedBoardTour: true,
},
where: eq(users.id, userId),
}),
);
+13 -16
View File
@@ -3,24 +3,18 @@ import type { NextAuthConfig } from "next-auth";
import type { Session } from "@homarr/auth";
import type { Database } from "@homarr/db";
import { eq, inArray } from "@homarr/db";
import { eq } from "@homarr/db";
import { groupMembers, groupPermissions, users } from "@homarr/db/schema";
import { getPermissionsWithChildren } from "@homarr/definitions";
export const getCurrentUserPermissionsAsync = async (db: Database, userId: string) => {
const dbGroupMembers = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, userId),
});
const groupIds = dbGroupMembers.map((groupMember) => groupMember.groupId);
if (groupIds.length === 0) return [];
const dbGroupPermissions = await db
.selectDistinct({
permission: groupPermissions.permission,
})
.from(groupPermissions)
.where(inArray(groupPermissions.groupId, groupIds));
.innerJoin(groupMembers, eq(groupPermissions.groupId, groupMembers.groupId))
.where(eq(groupMembers.userId, userId));
const permissionKeys = dbGroupPermissions.map(({ permission }) => permission);
return getPermissionsWithChildren(permissionKeys);
@@ -43,12 +37,15 @@ export const createSessionAsync = async (
export const createSessionCallback = (db: Database): NextAuthCallbackOf<"session"> => {
return async ({ session, user }) => {
const additionalProperties = await db.query.users.findFirst({
where: eq(users.id, user.id),
columns: {
colorScheme: true,
},
});
const [additionalProperties, permissions] = await Promise.all([
db.query.users.findFirst({
where: eq(users.id, user.id),
columns: {
colorScheme: true,
},
}),
getCurrentUserPermissionsAsync(db, user.id),
]);
return {
...session,
@@ -57,7 +54,7 @@ export const createSessionCallback = (db: Database): NextAuthCallbackOf<"session
...additionalProperties,
id: user.id,
name: user.name,
permissions: await getCurrentUserPermissionsAsync(db, user.id),
permissions,
},
};
};
@@ -3,14 +3,9 @@
import type { PropsWithChildren } from "react";
import { createContext, useContext } from "react";
import type { IntegrationKind } from "@homarr/definitions";
interface IntegrationContextProps {
integrations: {
id: string;
name: string;
url: string;
kind: IntegrationKind;
permissions: {
hasFullAccess: boolean;
hasInteractAccess: boolean;
@@ -25,32 +20,27 @@ export const IntegrationProvider = ({ integrations, children }: PropsWithChildre
return <IntegrationContext.Provider value={{ integrations }}>{children}</IntegrationContext.Provider>;
};
export const useIntegrationsWithUseAccess = () => {
const useIntegrationContext = () => {
const context = useContext(IntegrationContext);
if (!context) {
throw new Error("useIntegrationsWithUseAccess must be used within an IntegrationProvider");
throw new Error("Integration hooks must be used within an IntegrationProvider");
}
return context.integrations.filter((integration) => integration.permissions.hasUseAccess);
return context;
};
export const useIntegrations = () => useIntegrationContext().integrations;
export const useIntegrationsWithUseAccess = () =>
useIntegrationContext().integrations.filter((integration) => integration.permissions.hasUseAccess);
export const useIntegrationsWithInteractAccess = () => {
const context = useContext(IntegrationContext);
if (!context) {
throw new Error("useIntegrationsWithInteractAccess must be used within an IntegrationProvider");
}
const context = useIntegrationContext();
return context.integrations.filter((integration) => integration.permissions.hasInteractAccess);
};
export const useIntegrationsWithFullAccess = () => {
const context = useContext(IntegrationContext);
if (!context) {
throw new Error("useIntegrationsWithFullAccess must be used within an IntegrationProvider");
}
const context = useIntegrationContext();
return context.integrations.filter((integration) => integration.permissions.hasFullAccess);
};
@@ -6,31 +6,28 @@ import { groupMembers, integrationGroupPermissions, integrationUserPermissions }
import { constructIntegrationPermissions } from "./integration-permissions";
export const getIntegrationsWithPermissionsAsync = async (session: Session | null) => {
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, session?.user.id ?? ""),
});
const integrations = await db.query.integrations.findMany({
const groupPermissionWhere = session
? inArray(
integrationGroupPermissions.groupId,
db.select({ groupId: groupMembers.groupId }).from(groupMembers).where(eq(groupMembers.userId, session.user.id)),
)
: eq(integrationGroupPermissions.groupId, "");
const integrationRows = await db.query.integrations.findMany({
columns: {
id: true,
name: true,
url: true,
kind: true,
},
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, session?.user.id ?? ""),
},
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId),
),
where: groupPermissionWhere,
},
},
});
return integrations.map(({ userPermissions, groupPermissions, ...integration }) => ({
...integration,
return integrationRows.map(({ id, userPermissions, groupPermissions }) => ({
id,
permissions: constructIntegrationPermissions({ userPermissions, groupPermissions }, session),
}));
};
+1
View File
@@ -13,6 +13,7 @@
},
"exports": {
"./context": "./src/context.tsx",
"./layout-selection": "./src/layout-selection.ts",
"./updater": "./src/updater.ts",
"./edit-mode": "./src/edit-mode.tsx"
},
+68 -33
View File
@@ -1,26 +1,66 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { createContext, useContext, useEffect, useSyncExternalStore } from "react";
import { usePathname } from "next/navigation";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { getActiveQueryCacheBoardId, setActiveQueryCacheBoardId } from "@homarr/api/query-cache";
import { boardViewportWidthCookieName, getLayoutIdForViewportWidth } from "./layout-selection";
import { updateBoardName } from "./updater";
const BoardContext = createContext<{
board: RouterOutputs["board"]["getBoardByName"];
currentLayout: string;
initialViewportWidth: number;
} | null>(null);
const subscribeToViewport = (onStoreChange: () => void) => {
if (typeof window === "undefined") return () => undefined;
let animationFrame = 0;
const handleResize = () => {
if (animationFrame !== 0) return;
animationFrame = window.requestAnimationFrame(() => {
animationFrame = 0;
persistViewportWidth(getViewportWidth());
onStoreChange();
});
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
window.cancelAnimationFrame(animationFrame);
};
};
export const BoardProvider = ({
children,
initialBoard,
initialLayoutId,
initialViewportWidth,
}: PropsWithChildren<{
initialBoard: RouterOutputs["board"]["getBoardByName"];
initialLayoutId: string;
initialViewportWidth: number;
}>) => {
const { data } = clientApi.board.getBoardByName.useQuery({ name: initialBoard.name }, { initialData: initialBoard });
const { data } = clientApi.board.getBoardByName.useQuery(
{ name: initialBoard.name },
{
initialData: initialBoard,
// The route already fetched this board, so keep it fresh long enough to
// avoid an immediate duplicate request while still surfacing remote edits
// when a user later returns to the board.
staleTime: 30_000,
},
);
const currentLayout = useSyncExternalStore(
subscribeToViewport,
() => getCurrentLayout(data),
() => initialLayoutId,
);
// Update the board name so it can be used within updateBoard method
updateBoardName(initialBoard.name);
@@ -37,19 +77,14 @@ export const BoardProvider = ({
}, [pathname, utils, initialBoard.name]);
useEffect(() => {
setActiveQueryCacheBoardId(data.id);
return () => {
if (getActiveQueryCacheBoardId() === data.id) {
setActiveQueryCacheBoardId(null);
}
};
}, [data.id]);
persistViewportWidth(getViewportWidth());
}, []);
return (
<BoardContext.Provider
value={{
board: data,
currentLayout,
initialViewportWidth,
}}
>
{children}
@@ -74,34 +109,28 @@ export const useOptionalBoard = () => {
};
export const getCurrentLayout = (board: RouterOutputs["board"]["getBoardByName"]) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (typeof window === "undefined") return board.layouts.at(0)!.id;
const sortedLayouts = board.layouts.toSorted((layoutA, layoutB) => layoutB.breakpoint - layoutA.breakpoint);
// Fallback to smallest if none exists with breakpoint smaller than window width
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return sortedLayouts.find((layout) => layout.breakpoint <= window.innerWidth)?.id ?? sortedLayouts.at(0)!.id;
const viewportWidth = typeof window === "undefined" ? 0 : getViewportWidth();
return getLayoutIdForViewportWidth(board.layouts, viewportWidth);
};
export const useCurrentLayout = () => {
const board = useRequiredBoard();
const [currentLayout, setCurrentLayout] = useState(getCurrentLayout(board));
const context = useContext(BoardContext);
const onResize = useCallback(() => {
setCurrentLayout(getCurrentLayout(board));
}, [board]);
if (!context) {
throw new Error("Board is required");
}
useEffect(() => {
if (typeof window === "undefined") return;
window.addEventListener("resize", onResize);
return context.currentLayout;
};
return () => {
window.removeEventListener("resize", onResize);
};
}, [onResize]);
export const useInitialViewportWidth = () => {
const context = useContext(BoardContext);
return currentLayout;
if (!context) {
throw new Error("Board is required");
}
return context.initialViewportWidth;
};
export const getBoardLayouts = (board: RouterOutputs["board"]["getBoardByName"]) =>
@@ -112,3 +141,9 @@ export const useLayouts = () => {
return getBoardLayouts(board);
};
const persistViewportWidth = (viewportWidth: number) => {
document.cookie = `${boardViewportWidthCookieName}=${Math.round(viewportWidth)}; Path=/; Max-Age=31536000; SameSite=Lax`;
};
const getViewportWidth = () => document.documentElement.clientWidth || window.innerWidth;
@@ -0,0 +1,28 @@
import { describe, expect, test } from "vitest";
import { getLayoutIdForViewportWidth } from "./layout-selection";
const layouts = [
{ id: "mobile", breakpoint: 0 },
{ id: "tablet", breakpoint: 768 },
{ id: "desktop", breakpoint: 1280 },
];
describe("getLayoutIdForViewportWidth", () => {
test.each([
[390, "mobile"],
[768, "tablet"],
[1279, "tablet"],
[1920, "desktop"],
])("selects the responsive layout for %ipx", (width, expected) => {
expect(getLayoutIdForViewportWidth(layouts, width)).toBe(expected);
});
test("falls back to the smallest layout for invalid widths", () => {
expect(getLayoutIdForViewportWidth(layouts, Number.NaN)).toBe("mobile");
});
test("rejects boards without layouts", () => {
expect(() => getLayoutIdForViewportWidth([], 1280)).toThrow("Expected the board to contain a layout");
});
});
+21
View File
@@ -0,0 +1,21 @@
export const boardViewportWidthCookieName = "homarr-viewport-width";
interface ResponsiveLayout {
id: string;
breakpoint: number;
}
export const getLayoutIdForViewportWidth = (layouts: readonly ResponsiveLayout[], viewportWidth: number): string => {
if (layouts.length === 0) {
throw new Error("Expected the board to contain a layout");
}
const normalizedWidth = Number.isFinite(viewportWidth) ? Math.max(0, viewportWidth) : 0;
const sortedLayouts = layouts.toSorted((layoutA, layoutB) => layoutB.breakpoint - layoutA.breakpoint);
const selected = sortedLayouts.find((layout) => layout.breakpoint <= normalizedWidth) ?? sortedLayouts.at(-1);
if (!selected) {
throw new Error("Expected the board to contain a layout");
}
return selected.id;
};
+73
View File
@@ -0,0 +1,73 @@
{
"name": "@homarr/custom-widgets",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"exports": {
"./authoring-prompt": "./src/core/ai-prompt.ts",
"./authoring-resources": "./src/core/authoring-resources.ts",
"./catalog": "./src/core/catalog.ts",
"./catalog-example": "./src/core/component-example.ts",
"./core": "./src/core/index.ts",
"./jsx": "./src/jsx/index.ts",
"./runtime": "./src/runtime/index.ts",
"./server": "./src/server/index.ts",
"./workbench": "./src/workbench/index.ts"
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"check:architecture": "node ./scripts/check-architecture.mjs",
"generate:catalog": "tsx ./scripts/generate-component-metadata.ts",
"generate:components": "pnpm generate:catalog",
"generate": "pnpm generate:catalog",
"evaluate:ai": "bun ./scripts/evaluate-ai-authoring.ts",
"test:ai": "bun ./scripts/evaluate-ai-authoring.ts --ci",
"test:reference-apis": "tsx ./scripts/verify-reference-apis.ts",
"format": "oxfmt .",
"lint": "oxlint",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@codemirror/autocomplete": "catalog:",
"@codemirror/commands": "catalog:",
"@codemirror/lang-css": "catalog:",
"@codemirror/lang-javascript": "catalog:",
"@codemirror/lang-json": "catalog:",
"@codemirror/lint": "catalog:",
"@codemirror/state": "catalog:",
"@codemirror/theme-one-dark": "catalog:",
"@codemirror/view": "catalog:",
"@mantine/charts": "catalog:",
"@mantine/core": "catalog:",
"@mantine/dates": "catalog:",
"@mantine/hooks": "catalog:",
"@tabler/icons-react": "catalog:",
"@tanstack/react-query": "catalog:",
"acorn": "catalog:",
"acorn-jsx": "catalog:",
"codemirror": "catalog:",
"jsonpath-plus": "catalog:",
"react": "catalog:",
"undici": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"jsdom": "catalog:",
"react-dom": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
}
}
@@ -0,0 +1,49 @@
export interface CustomWidgetAiEvaluationCase {
id: string;
request: string;
documentationUrl: string;
apiNotes: string;
}
export const CUSTOM_WIDGET_AI_EVALUATION_CASES: readonly CustomWidgetAiEvaluationCase[] = [
{
id: "pokedex",
documentationUrl: "https://pokeapi.co/docs/v2",
request:
"Create a polished Pokédex browser with searchable Pokémon, a responsive result grid, manual detail loading, sprites, types, abilities, base-stat progress bars, and clear loading, empty, and failure states.",
apiNotes:
"Use GET /api/v2/pokemon?limit=<option>&offset=<option> for the list and GET /api/v2/pokemon/{param:name} as a manual detail query. PokeAPI needs no authentication.",
},
{
id: "portainer-containers",
documentationUrl: "https://docs.portainer.io/api/examples",
request:
"Create an excellent Portainer container dashboard: environment option, running/stopped summary, responsive container list, health/status badges, and explicit start, stop, and restart actions with confirmation and refresh after success.",
apiNotes:
"Use X-API-Key auth. List containers with GET /api/endpoints/{option:endpointId}/docker/containers/json?all=true. Actions are POST /api/endpoints/{option:endpointId}/docker/containers/{param:id}/start, /stop, and /restart and invalidate the list query.",
},
{
id: "tautulli-activity",
documentationUrl: "https://github.com/Tautulli/Tautulli/wiki/Tautulli-API-Reference",
request:
"Create a beautiful Tautulli activity widget with active stream cards, user/player details, progress, transcode/direct-play badges, bandwidth summary, and a refresh control that works in narrow and wide tiles.",
apiNotes:
"Use an apiKeyQuery source with parameter name apikey. Query GET /api/v2 with query cmd=get_activity. The response payload is under response.data and sessions is an array.",
},
{
id: "bambubuddy-printer",
documentationUrl: "https://wiki.bambuddy.cool/reference/api/",
request:
"Create a premium BambuBuddy printer status widget with printer selector, current job, progress, remaining time, nozzle and bed temperatures, connection state, and safe pause/resume/stop controls when supported.",
apiNotes:
"Use X-API-Key auth and base path /api/v1. GET /printers lists printers and GET /printers/{id}/status returns state, progress, remaining_time, temperatures.nozzle, temperatures.bed, and hms_status. The official reference does not document pause, resume, or stop endpoints, so omit those actions. Configure the selected printer with choicesFrom on a widget option and use that option in the status path.",
},
{
id: "home-assistant-control",
documentationUrl: "https://developers.home-assistant.io/docs/api/rest/",
request:
"Create a refined Home Assistant room widget with temperature and humidity readings, light status, a room/entity option, and an actionable light toggle. Use calm hierarchy and responsive controls, not a pile of nested cards.",
apiNotes:
"Use bearer auth. GET /api/states/{option:sensorEntity} and /api/states/{option:lightEntity} load entity state. POST /api/services/light/turn_on and /turn_off accept a body with entity_id from an option and should invalidate the light query.",
},
] as const;
@@ -0,0 +1,304 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { z } from "zod/v4";
import { buildCustomWidgetAiPrompt } from "../src/core/ai-prompt";
import { customWidgetDefinitionSchema } from "../src/core/custom-jsx-schema";
import type { HomarrCustomWidgetV2 } from "../src/core/custom-jsx-schema";
import { formatCustomWidgetImportIssues, parseCustomWidgetAiResponse } from "../src/core/import";
import type { CustomWidgetAiEvaluationCase } from "./ai-evaluation-cases";
export const DEFAULT_GENERATOR_MODEL = "deepseek/deepseek-v4-pro";
export const DEFAULT_JUDGE_MODEL = "deepseek/deepseek-v4-flash";
export const MAX_AI_EVALUATION_LOOPS = 10;
const scoreSchema = z.number().int().min(0).max(100);
const judgeCategoriesSchema = z.strictObject({
schemaAndBindings: scoreSchema,
apiAndRequestDesign: scoreSchema,
runtimeCompatibility: scoreSchema,
goalFulfillment: scoreSchema,
visualQuality: scoreSchema,
responsiveAndTheme: scoreSchema,
loadingEmptyErrorSuccess: scoreSchema,
dailyUsefulness: scoreSchema,
complexityDiscipline: scoreSchema,
accessibility: scoreSchema,
actionSafety: scoreSchema,
});
const categoryReasonSchema = z.string().min(1).max(600);
const judgeResultSchema = z.strictObject({
total: scoreSchema,
verdict: z.enum(["pass", "fail"]),
dailyUseDecision: z.enum(["would-use-daily", "promising-but-not-daily", "not-practical"]),
categories: judgeCategoriesSchema,
categoryReasons: z.strictObject({
schemaAndBindings: categoryReasonSchema,
apiAndRequestDesign: categoryReasonSchema,
runtimeCompatibility: categoryReasonSchema,
goalFulfillment: categoryReasonSchema,
visualQuality: categoryReasonSchema,
responsiveAndTheme: categoryReasonSchema,
loadingEmptyErrorSuccess: categoryReasonSchema,
dailyUsefulness: categoryReasonSchema,
complexityDiscipline: categoryReasonSchema,
accessibility: categoryReasonSchema,
actionSafety: categoryReasonSchema,
}),
strengths: z.array(z.string().min(1).max(400)).max(6),
problems: z.array(z.string().min(1).max(500)).max(12),
fatalProblems: z.array(z.string().min(1).max(500)).max(8),
highestImpactFixes: z.array(z.string().min(1).max(500)).max(6),
});
const categoryWeights = {
schemaAndBindings: 8,
apiAndRequestDesign: 10,
runtimeCompatibility: 8,
goalFulfillment: 15,
visualQuality: 15,
responsiveAndTheme: 8,
loadingEmptyErrorSuccess: 8,
dailyUsefulness: 12,
complexityDiscipline: 8,
accessibility: 4,
actionSafety: 4,
} as const satisfies Record<keyof z.infer<typeof judgeCategoriesSchema>, number>;
export type CustomWidgetJudgeResult = z.infer<typeof judgeResultSchema>;
export interface AiEvaluationResult {
caseId: string;
attempts: number;
widget: HomarrCustomWidgetV2 | null;
judge: CustomWidgetJudgeResult | null;
outputDirectory: string;
errors: string[];
}
interface OpenRouterResponse {
choices?: Array<{ message?: { content?: string | null } }>;
error?: { message?: string };
}
export function buildEvaluationPrompt(testCase: CustomWidgetAiEvaluationCase): string {
return buildCustomWidgetAiPrompt(
undefined,
null,
null,
`${testCase.request}\n\nVerified API notes:\n${testCase.apiNotes}`,
testCase.documentationUrl,
);
}
export function buildRepairPrompt(
originalPrompt: string,
previousResponse: string,
issues: readonly { path?: Array<string | number>; message: string }[],
): string {
const diagnostics = issues
.map((issue) => `${issue.path?.length ? `${issue.path.join(".")}: ` : ""}${issue.message}`)
.join("\n");
return `${originalPrompt}\n\nYour previous response did not validate. Correct only the generic contract/runtime problems below while preserving a polished design.\n\nDiagnostics:\n${diagnostics}\n\nPrevious response:\n${previousResponse}`;
}
export function buildJudgePrompt(testCase: CustomWidgetAiEvaluationCase, widget: HomarrCustomWidgetV2): string {
return `You are a hostile-but-fair product review panel evaluating a safe dashboard widget. Most competent drafts should score 55-75, not 90. Judge only evidence present in the manifest and JSX. Never reward unsupported capabilities, invented API routes, aspirational claims, or code that merely validates.
Request:
${testCase.request}
Verified API notes:
${testCase.apiNotes}
Validated widget:
${JSON.stringify(widget, null, 2)}
Scoring calibration:
- 95-100: exceptional, purpose-built quality; complete, beautiful, restrained, and something a demanding user would choose every day. Almost never award this.
- 85-94: excellent with only small, specific defects. It must fully achieve the request and feel deliberately designed.
- 70-84: good prototype or useful widget with visible compromises, generic design, missing polish, or avoidable complexity.
- 50-69: functional but incomplete, awkward, visually ordinary, overbuilt, or impractical for repeated use.
- 0-49: broken, misleading, unsafe, substantially incomplete, or incompatible.
Required review behavior:
- Compare every requested capability with concrete manifest/JSX evidence. A missing or invented core capability is fatal and caps total at 79.
- Judge whether the API design can actually reach the stated goal, including response paths, bindings, invalidation, and action safety.
- Judge visual quality, not component count: hierarchy, density, whitespace, typography, restrained color, scanability, and avoidance of repetitive nested cards.
- Judge daily usefulness: information priority, interaction cost, refresh behavior, narrow-tile usability, and whether the widget is pleasant rather than demo-like.
- Judge complexity discipline: penalize duplicate requests/options, unnecessary controls, excessive JSX, cleverness, and UI chrome that does not help the goal. Complexity must earn its place.
- A visually generic but valid widget should normally score below 75 for visualQuality. A widget that is attractive but inconvenient should score below 75 for dailyUsefulness.
- Give a concrete evidence sentence for every category. List all score-capping issues under fatalProblems.
Homarr computes the weighted total and final verdict itself. Your total and verdict are advisory, but must be internally honest. A pass requires a weighted total of at least 90, every category at least 75, goalFulfillment/visualQuality/dailyUsefulness at least 85, complexityDiscipline at least 80, no fatal problem, and dailyUseDecision="would-use-daily". Return only the requested structured object.`;
}
export function judgePasses(result: CustomWidgetJudgeResult): boolean {
return (
result.total >= 90 &&
Object.values(result.categories).every((score) => score >= 75) &&
result.categories.goalFulfillment >= 85 &&
result.categories.visualQuality >= 85 &&
result.categories.dailyUsefulness >= 85 &&
result.categories.complexityDiscipline >= 80 &&
result.dailyUseDecision === "would-use-daily" &&
result.fatalProblems.length === 0
);
}
export function getJudgeResponseFormat() {
return {
type: "json_schema" as const,
json_schema: {
name: "homarr_custom_widget_review",
strict: true,
schema: z.toJSONSchema(judgeResultSchema, { io: "output" }),
},
};
}
export async function evaluateCustomWidgetCase(args: {
testCase: CustomWidgetAiEvaluationCase;
apiKey: string;
outputRoot: string;
maxLoops: number;
generatorModel?: string;
judgeModel?: string;
}): Promise<AiEvaluationResult> {
const caseDirectory = path.join(args.outputRoot, args.testCase.id);
await mkdir(caseDirectory, { recursive: true });
const originalPrompt = buildEvaluationPrompt(args.testCase);
await writeFile(path.join(caseDirectory, "prompt.md"), originalPrompt, "utf8");
let prompt = originalPrompt;
const errors: string[] = [];
let bestWidget: HomarrCustomWidgetV2 | null = null;
let bestJudge: CustomWidgetJudgeResult | null = null;
for (let attempt = 1; attempt <= Math.min(args.maxLoops, MAX_AI_EVALUATION_LOOPS); attempt += 1) {
let response: string;
try {
response = await callOpenRouter({
apiKey: args.apiKey,
model: args.generatorModel ?? DEFAULT_GENERATOR_MODEL,
prompt,
purpose: "generation",
});
} catch (error) {
errors.push(
`Attempt ${attempt}: generator request failed — ${error instanceof Error ? error.message : "Unknown error"}`,
);
continue;
}
await writeFile(path.join(caseDirectory, `attempt-${attempt}.md`), response, "utf8");
const parsed = parseCustomWidgetAiResponse(response);
if (!parsed.success) {
const message = formatCustomWidgetImportIssues(parsed.issues);
errors.push(`Attempt ${attempt}: ${message}`);
prompt = buildRepairPrompt(originalPrompt, response, parsed.issues);
continue;
}
const canonical = customWidgetDefinitionSchema.parse(parsed.widget);
await writeWidgetFiles(caseDirectory, canonical, `attempt-${attempt}`);
let judge: CustomWidgetJudgeResult;
try {
const judgeRaw = await callOpenRouter({
apiKey: args.apiKey,
model: args.judgeModel ?? DEFAULT_JUDGE_MODEL,
prompt: buildJudgePrompt(args.testCase, canonical),
purpose: "judge",
});
await writeFile(path.join(caseDirectory, `judge-${attempt}.json`), judgeRaw, "utf8");
judge = parseJudgeResult(judgeRaw);
} catch (error) {
errors.push(
`Attempt ${attempt}: judge response invalid — ${error instanceof Error ? error.message : "Unknown error"}`,
);
continue;
}
if (!bestJudge || judge.total > bestJudge.total) {
bestJudge = judge;
bestWidget = canonical;
await writeWidgetFiles(caseDirectory, canonical, "best");
await writeFile(path.join(caseDirectory, "best-judge.json"), JSON.stringify(judge, null, 2), "utf8");
}
if (judgePasses(judge)) {
await writeFile(path.join(caseDirectory, "result.json"), JSON.stringify(judge, null, 2), "utf8");
return {
caseId: args.testCase.id,
attempts: attempt,
widget: canonical,
judge,
outputDirectory: caseDirectory,
errors,
};
}
errors.push(`Attempt ${attempt}: judge ${judge.total}/100 — ${judge.highestImpactFixes.join("; ")}`);
prompt = buildRepairPrompt(
originalPrompt,
response,
judge.highestImpactFixes.map((message) => ({ message })),
);
}
return {
caseId: args.testCase.id,
attempts: Math.min(args.maxLoops, MAX_AI_EVALUATION_LOOPS),
widget: bestWidget,
judge: bestJudge,
outputDirectory: caseDirectory,
errors,
};
}
export function parseJudgeResult(raw: string): CustomWidgetJudgeResult {
const parsed: unknown = JSON.parse(raw);
const result = judgeResultSchema.parse(parsed);
const weightedTotal = Object.entries(categoryWeights).reduce(
(sum, [category, weight]) => sum + result.categories[category as keyof typeof categoryWeights] * weight,
0,
);
const normalized = {
...result,
total: Math.round(weightedTotal / 100),
};
return { ...normalized, verdict: judgePasses(normalized) ? "pass" : "fail" };
}
async function writeWidgetFiles(directory: string, widget: HomarrCustomWidgetV2, basename: string) {
await writeFile(path.join(directory, `${basename}.widget.json`), JSON.stringify(widget, null, 2), "utf8");
}
async function callOpenRouter(args: {
apiKey: string;
model: string;
prompt: string;
purpose: "generation" | "judge";
}): Promise<string> {
const isJudge = args.purpose === "judge";
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${args.apiKey}`,
"Content-Type": "application/json",
"HTTP-Referer": "https://homarr.dev",
"X-Title": "Homarr Custom Widget AI Evaluation",
},
body: JSON.stringify({
model: args.model,
messages: [{ role: "user", content: args.prompt }],
temperature: isJudge ? 0 : 0.2,
max_tokens: isJudge ? 8_000 : 20_000,
reasoning: { effort: "xhigh", exclude: true },
...(isJudge ? { response_format: getJudgeResponseFormat() } : {}),
}),
signal: AbortSignal.timeout(180_000),
});
const payload = (await response.json()) as OpenRouterResponse;
if (!response.ok)
throw new Error(`OpenRouter request failed (${response.status}): ${payload.error?.message ?? "Unknown error"}`);
const content = payload.choices?.[0]?.message?.content;
if (!content) throw new Error("OpenRouter returned no message content");
return content;
}
@@ -0,0 +1,108 @@
import { readdir, readFile } from "node:fs/promises";
import { extname, join, relative, resolve } from "node:path";
const packageRoot = resolve(import.meta.dirname, "..");
const repositoryRoot = resolve(packageRoot, "../..");
const sourceRoots = [
join(packageRoot, "src"),
join(repositoryRoot, "packages/api/src/router/custom-widget"),
join(repositoryRoot, "packages/widgets/src/custom-api"),
join(repositoryRoot, "apps/nextjs/src/app/[locale]/manage/custom-widgets"),
];
const productionFiles = [];
async function collect(directory) {
for (const entry of await readdir(directory, { withFileTypes: true })) {
const path = join(directory, entry.name);
if (entry.isDirectory()) {
if (entry.name !== "test") await collect(path);
} else if ([".ts", ".tsx"].includes(extname(entry.name)) && !/\.spec\.tsx?$/u.test(entry.name)) {
productionFiles.push(path);
}
}
}
for (const sourceRoot of sourceRoots) await collect(sourceRoot);
const failures = [];
const graph = new Map();
const algorithmicExceptions = new Map([
["packages/custom-widgets/src/jsx/analyzer.ts", 400],
["packages/custom-widgets/src/jsx/interpreter.tsx", 400],
]);
for (const file of productionFiles) {
const source = await readFile(file, "utf8");
const name = relative(repositoryRoot, file);
const lineCount = source.trimEnd().split("\n").length;
const limit = algorithmicExceptions.get(name) ?? 300;
if (lineCount > limit) failures.push(`${name} has ${lineCount} lines (limit ${limit})`);
if (file.startsWith(join(packageRoot, "src")) && /from\s+["']@homarr\/(?:api|widgets)(?:\/|["'])/u.test(source)) {
failures.push(`${name} imports a forbidden adapter package`);
}
if (extname(file) === ".tsx" && /(?:runtime|workbench|custom-widgets|custom-api)/u.test(name)) {
for (const line of source.split("\n")) {
const match = line.match(/>\s*([A-Za-z][A-Za-z0-9 .,!?'-]*)\s*</u);
if (match) failures.push(`${name} contains untranslated JSX text: ${JSON.stringify(match[1]?.trim())}`);
}
if (/\b(?:aria-label|description|label|placeholder|title)=["'][A-Za-z]/u.test(source)) {
failures.push(`${name} contains an untranslated user-facing attribute`);
}
}
const dependencies = [];
for (const match of source.matchAll(/from\s+["'](\.[^"']+)["']/gu)) {
const candidate = resolve(file, "..", match[1]);
const resolved = productionFiles.find(
(item) =>
item === candidate ||
item === `${candidate}.ts` ||
item === `${candidate}.tsx` ||
item === join(candidate, "index.ts"),
);
if (resolved) dependencies.push(resolved);
}
graph.set(file, dependencies);
}
const visiting = new Set();
const visited = new Set();
function visit(file, path = []) {
if (visiting.has(file)) {
failures.push(`circular dependency: ${[...path, file].map((item) => relative(repositoryRoot, item)).join(" -> ")}`);
return;
}
if (visited.has(file)) return;
visiting.add(file);
for (const dependency of graph.get(file) ?? []) visit(dependency, [...path, file]);
visiting.delete(file);
visited.add(file);
}
for (const file of productionFiles) visit(file);
const widgetAdapter = await readFile(join(repositoryRoot, "packages/widgets/src/custom-api/component.tsx"), "utf8");
if (!/dynamic\(\(\)\s*=>\s*import\(["']\.\/custom-jsx-display["']\)/u.test(widgetAdapter)) {
failures.push("Custom JSX board rendering must remain dynamically loaded");
}
const editorAdapter = await readFile(
join(repositoryRoot, "packages/custom-widgets/src/workbench/code-editor.tsx"),
"utf8",
);
if (!/lazy\(\(\)\s*=>\s*import\(["'].\/direct-code-mirror["']\)\)/u.test(editorAdapter)) {
failures.push("CodeMirror must remain dynamically loaded after the client mounts");
}
const componentRegistry = await readFile(
join(repositoryRoot, "packages/custom-widgets/src/core/component-registry.ts"),
"utf8",
);
if (!/from\s+["'].\/component-catalog["']/u.test(componentRegistry)) {
failures.push("The Custom JSX runtime registry must use the canonical authoring catalog");
}
if (/component-runtime\.generated\.json/u.test(componentRegistry)) {
failures.push("The Custom JSX runtime registry must not use a second generated component catalog");
}
if (failures.length > 0) {
console.error(failures.join("\n"));
process.exitCode = 1;
} else {
console.log(`Custom Widgets architecture checks passed for ${productionFiles.length} production modules.`);
}
@@ -0,0 +1,66 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { CUSTOM_WIDGET_AI_EVALUATION_CASES } from "./ai-evaluation-cases";
import { DEFAULT_GENERATOR_MODEL, DEFAULT_JUDGE_MODEL, evaluateCustomWidgetCase } from "./ai-evaluation";
import type { AiEvaluationResult } from "./ai-evaluation";
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) throw new Error("OPENROUTER_API_KEY is required for the live Custom Widget AI evaluation");
const requestedCase = process.argv.find((value) => value.startsWith("--case="))?.slice("--case=".length);
const configuredLoops = Number(process.env.CUSTOM_WIDGET_AI_MAX_LOOPS ?? 10);
const totalLoopBudget = Number.isInteger(configuredLoops) && configuredLoops > 0 ? Math.min(configuredLoops, 10) : 10;
const selectedCases = requestedCase
? CUSTOM_WIDGET_AI_EVALUATION_CASES.filter((testCase) => testCase.id === requestedCase)
: CUSTOM_WIDGET_AI_EVALUATION_CASES;
if (selectedCases.length === 0) throw new Error(`Unknown AI evaluation case '${requestedCase}'`);
if (totalLoopBudget < selectedCases.length) {
throw new Error(`CUSTOM_WIDGET_AI_MAX_LOOPS must be at least ${selectedCases.length} for this run`);
}
const runId = new Date().toISOString().replaceAll(/[:.]/gu, "-");
const outputRoot = path.resolve(process.cwd(), ".ai-evaluations", runId);
await mkdir(outputRoot, { recursive: true });
const results: AiEvaluationResult[] = [];
let remainingLoopBudget = totalLoopBudget;
for (const testCase of selectedCases) {
const remainingCases = selectedCases.length - results.length;
const maxLoops = Math.max(1, Math.floor(remainingLoopBudget / remainingCases));
process.stdout.write(`Evaluating ${testCase.id}...\n`);
const result = await evaluateCustomWidgetCase({
testCase,
apiKey,
outputRoot,
maxLoops,
generatorModel: process.env.OPENROUTER_GENERATOR_MODEL ?? DEFAULT_GENERATOR_MODEL,
judgeModel: process.env.OPENROUTER_JUDGE_MODEL ?? DEFAULT_JUDGE_MODEL,
});
results.push(result);
remainingLoopBudget -= result.attempts;
process.stdout.write(
result.judge
? ` ${result.judge.total}/100 after ${result.attempts} attempt(s)\n`
: ` failed after ${result.attempts} attempt(s)\n`,
);
}
const summary = {
generatedAt: new Date().toISOString(),
generatorModel: process.env.OPENROUTER_GENERATOR_MODEL ?? DEFAULT_GENERATOR_MODEL,
judgeModel: process.env.OPENROUTER_JUDGE_MODEL ?? DEFAULT_JUDGE_MODEL,
results: results.map((result) => ({
caseId: result.caseId,
attempts: result.attempts,
score: result.judge?.total ?? null,
verdict: result.judge?.verdict ?? "fail",
categories: result.judge?.categories ?? null,
errors: result.errors,
outputDirectory: path.relative(process.cwd(), result.outputDirectory),
})),
};
await writeFile(path.join(outputRoot, "summary.json"), JSON.stringify(summary, null, 2), "utf8");
process.stdout.write(`Results saved to ${path.relative(process.cwd(), outputRoot)}\n`);
if (results.some((result) => result.judge?.verdict !== "pass")) process.exitCode = 1;
@@ -0,0 +1,732 @@
import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import * as Charts from "@mantine/charts";
import * as Core from "@mantine/core";
import * as Dates from "@mantine/dates";
import ts from "typescript";
import {
CUSTOM_JSX_AUTHORING_CATALOG_SCHEMA_VERSION,
CUSTOM_WIDGET_AUTHORING_VERSION,
} from "../src/core/component-catalog-types";
import type {
CustomJsxAuthoringCatalog,
CustomJsxBlockedCapability,
CustomJsxCatalogBindingType,
CustomJsxComponentApi,
CustomJsxLiteralValue,
CustomJsxPropDescriptor,
CustomJsxPropSource,
} from "../src/core/component-catalog-types";
import { getCatalogBlockedComponentProps, getCatalogDeniedComponentReason } from "../src/core/component-catalog-policy";
import { commonSafeProps } from "../src/core/component-props";
import { accessibilityByCategory, documentationUrl, subcomponentsByName } from "../src/core/component-descriptor";
import { discoveredMantineComponents, discoveredSubcomponentsByName } from "../src/core/component-discovery";
import type {
CustomJsxComponentCategory,
CustomJsxComponentPackage,
CustomJsxComponentSafety,
} from "../src/core/component-types";
import {
CUSTOM_JSX_BLOCKED_PROPERTIES,
CUSTOM_JSX_BLOCKED_PROPS,
CUSTOM_JSX_BLOCKED_STYLE_KEYS,
isBlockedCustomJsxProp,
} from "../src/jsx/policy";
void [Core, Dates, Charts];
const packageRoot = resolve(import.meta.dirname, "..");
const repositoryRoot = resolve(packageRoot, "../..");
const configPath = resolve(packageRoot, "tsconfig.json");
const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
if (configFile.error) throw new Error(ts.flattenDiagnosticMessageText(configFile.error.messageText, "\n"));
const config = ts.parseJsonConfigFileContent(configFile.config, ts.sys, packageRoot);
const program = ts.createProgram({ rootNames: [...config.fileNames, import.meta.filename], options: config.options });
const checker = program.getTypeChecker();
const source = program.getSourceFile(import.meta.filename);
if (!source) throw new Error("Unable to inspect the component catalog generator");
const moduleSymbols = new Map<string, ts.Symbol>();
for (const declaration of source.statements) {
if (!ts.isImportDeclaration(declaration) || !ts.isStringLiteral(declaration.moduleSpecifier)) continue;
const moduleName = declaration.moduleSpecifier.text;
if (!moduleName.startsWith("@mantine/")) continue;
const symbol = checker.getSymbolAtLocation(declaration.moduleSpecifier);
if (symbol) moduleSymbols.set(moduleName, symbol);
}
type UninternedPropDescriptor = Omit<CustomJsxPropDescriptor, "typeRef"> & { type: string };
type UninternedComponentApi = Omit<CustomJsxComponentApi, "props"> & { props: UninternedPropDescriptor[] };
interface InspectedProp extends UninternedPropDescriptor {
global: boolean;
}
interface InspectedComponent {
description?: string;
props: InspectedProp[];
}
interface CatalogSourceComponent {
name: string;
package: CustomJsxComponentPackage;
category: CustomJsxComponentCategory;
safety: CustomJsxComponentSafety;
subcomponents: readonly string[];
accessibilityRequirements: readonly string[];
documentationUrl: string;
reason?: string;
}
const commonPropNames = new Set<string>(commonSafeProps);
const inspectedComponents = new Map<string, InspectedComponent>();
const globalCandidates = new Map<string, UninternedPropDescriptor[]>();
async function generateCatalog() {
const sourceComponents = createSourceComponents();
for (const component of sourceComponents) {
if (component.package === "@homarr/widgets" || component.safety === "denied") continue;
const inspected = inspectMantineComponent(component);
inspectedComponents.set(component.name, inspected);
for (const prop of inspected.props) {
if (!prop.global) continue;
const candidates = globalCandidates.get(prop.name) ?? [];
const { global: _global, ...descriptor } = prop;
candidates.push({ ...descriptor, source: "global" });
globalCandidates.set(prop.name, candidates);
}
}
for (const name of commonSafeProps) {
const candidates = globalCandidates.get(name) ?? [];
if (candidates.length === 0) candidates.push(createFallbackProp(name, "global"));
globalCandidates.set(name, candidates);
}
const uninternedGlobalProps = [...globalCandidates.entries()]
.map(([name, candidates]) => mergeGlobalProp(name, candidates))
.toSorted((left, right) => compareStrings(left.name, right.name));
const globalPropsByName = new Map(uninternedGlobalProps.map((prop) => [prop.name, prop]));
const uninternedComponents = sourceComponents
.map((component) => buildComponentApi(component, globalPropsByName))
.toSorted((left, right) => compareStrings(left.name, right.name));
const types = [
...new Set([
...uninternedGlobalProps.map(({ type }) => type),
...uninternedComponents.flatMap(({ props }) => props.map(({ type }) => type)),
]),
].toSorted(compareStrings);
const typeReferences = new Map(types.map((type, index) => [type, index]));
const internProp = ({ type, ...prop }: UninternedPropDescriptor): CustomJsxPropDescriptor => {
const typeRef = typeReferences.get(type);
if (typeRef === undefined) throw new Error(`Unable to intern prop type '${type}'`);
return { ...prop, typeRef };
};
const globalProps = uninternedGlobalProps.map(internProp);
const components = uninternedComponents.map(
(component): CustomJsxComponentApi => ({ ...component, props: component.props.map(internProp) }),
);
const mantineVersions = await Promise.all(
["core", "dates", "charts"].map((packageName) =>
readPackageVersion(resolve(repositoryRoot, `node_modules/@mantine/${packageName}/package.json`)),
),
);
if (new Set(mantineVersions).size !== 1) {
throw new Error(`Mantine package versions must match: ${mantineVersions.join(", ")}`);
}
const mantineVersion = mantineVersions[0];
if (!mantineVersion) throw new Error("Unable to determine the installed Mantine version");
const catalog: CustomJsxAuthoringCatalog = {
schemaVersion: CUSTOM_JSX_AUTHORING_CATALOG_SCHEMA_VERSION,
mantineVersion,
customWidgetVersion: CUSTOM_WIDGET_AUTHORING_VERSION,
types,
globalProps,
blockedCapabilities: createBlockedCapabilities(),
components,
};
const serialized = `${JSON.stringify(catalog)}\n`;
await Promise.all([
writeFile(resolve(packageRoot, "src/core/component-catalog.generated.json"), serialized),
writeFile(resolve(repositoryRoot, "apps/docs/static/custom-widgets/component-catalog-v1.json"), serialized),
]);
}
function inspectMantineComponent(component: CatalogSourceComponent): InspectedComponent {
const moduleSymbol = moduleSymbols.get(component.package);
const rootName = component.name.split(".")[0];
let symbol =
moduleSymbol && checker.getExportsOfModule(moduleSymbol).find((candidate) => candidate.name === rootName);
let location = symbol?.valueDeclaration ?? symbol?.declarations?.[0] ?? source;
let type = symbol && checker.getTypeOfSymbolAtLocation(symbol, location);
for (const segment of component.name.split(".").slice(1)) {
symbol = type?.getProperty(segment);
if (!symbol) break;
location = symbol.valueDeclaration ?? symbol.declarations?.[0] ?? location;
type = checker.getTypeOfSymbolAtLocation(symbol, location);
}
const signature = type?.getCallSignatures()[0] ?? type?.getConstructSignatures()[0];
const parameter = signature?.parameters[0];
const propType = parameter && checker.getTypeOfSymbolAtLocation(parameter, parameter.valueDeclaration ?? location);
const props = propType
? checker
.getPropertiesOfType(propType)
.flatMap((prop): InspectedProp[] => {
const declaration = prop.valueDeclaration ?? prop.declarations?.[0] ?? location;
const resolvedType = checker.getTypeOfSymbolAtLocation(prop, declaration);
if (prop.name === "bind" || isBlockedCustomJsxProp(prop.name) || isCallableType(resolvedType)) return [];
const global = isGlobalProp(prop);
return [createPropDescriptor(prop, resolvedType, declaration, global ? "global" : "component", global)];
})
.toSorted((left, right) => compareStrings(left.name, right.name))
: [];
return {
description: normalizeDescription(symbol && ts.displayPartsToString(symbol.getDocumentationComment(checker))),
props,
};
}
function createSourceComponents(): CatalogSourceComponent[] {
const mantineComponents = discoveredMantineComponents.map((component): CatalogSourceComponent => {
const reason = getCatalogDeniedComponentReason(component.name);
const category = reason ? ("blocked" as const) : component.category;
return {
name: component.name,
package: component.package,
category,
safety: reason ? "denied" : "wrapped",
subcomponents: discoveredSubcomponentsByName[component.name] ?? subcomponentsByName[component.name] ?? [],
accessibilityRequirements: accessibilityByCategory[category],
documentationUrl: documentationUrl(component.package, component.name),
...(reason ? { reason } : {}),
};
});
const homarrComponents = HOMARR_COMPONENT_DEFINITIONS.map(
({ name, category, safety }): CatalogSourceComponent => ({
name,
package: "@homarr/widgets",
category,
safety,
subcomponents: [],
accessibilityRequirements: accessibilityByCategory[category],
documentationUrl: documentationUrl("@homarr/widgets", name),
}),
);
return [...mantineComponents, ...homarrComponents];
}
function buildComponentApi(
component: CatalogSourceComponent,
globalPropsByName: ReadonlyMap<string, UninternedPropDescriptor>,
): UninternedComponentApi {
const blockedProps = getCatalogBlockedComponentProps(component.name);
const blockedPropNames = new Set(blockedProps.map(({ name }) => name));
const globalNames = new Set(globalPropsByName.keys());
const inspected = inspectedComponents.get(component.name);
const componentProps = new Map<string, UninternedPropDescriptor>();
for (const prop of inspected?.props ?? []) {
if (blockedPropNames.has(prop.name)) continue;
const { global: _global, ...descriptor } = prop;
const componentDescriptor = { ...descriptor, source: "component" as const };
const globalProp = globalNames.has(prop.name) ? globalPropsByName.get(prop.name) : undefined;
if (globalProp && samePropMetadata(globalProp, componentDescriptor)) continue;
componentProps.set(prop.name, componentDescriptor);
}
if (component.safety !== "denied" && component.package === "@homarr/widgets") {
const homarrProps = HOMARR_COMPONENT_PROPS[component.name];
if (!homarrProps) throw new Error(`Homarr component '${component.name}' requires explicit catalog prop metadata`);
for (const [propName, metadata] of Object.entries(homarrProps)) {
const globalProp = globalPropsByName.get(propName);
if (globalProp && samePropMetadata(globalProp, metadata)) continue;
if (isBlockedCustomJsxProp(propName)) continue;
componentProps.set(propName, { name: propName, source: "component", ...metadata });
}
}
const bindType = component.safety !== "denied" ? CUSTOM_JSX_BINDING_TYPES[component.name] : undefined;
const description = inspected?.description ?? HOMARR_COMPONENT_DESCRIPTIONS[component.name];
return {
name: component.name,
package: component.package,
category: component.category,
safety: component.safety,
...(description ? { description } : {}),
documentationUrl: component.documentationUrl,
props: [...componentProps.values()].toSorted((left, right) => compareStrings(left.name, right.name)),
blockedProps: [...blockedProps].toSorted((left, right) => compareStrings(left.name, right.name)),
subcomponents: [...component.subcomponents].toSorted(),
...(bindType
? {
bind: {
type: bindType,
initialProp: bindType === "boolean" ? ("defaultChecked" as const) : ("defaultValue" as const),
},
}
: {}),
accessibilityRequirements: [...component.accessibilityRequirements],
...(component.safety === "denied" && component.reason ? { deniedReason: component.reason } : {}),
};
}
function createPropDescriptor(
prop: ts.Symbol,
type: ts.Type,
declaration: ts.Node,
sourceKind: CustomJsxPropSource,
global: boolean,
): InspectedProp {
const literalValues = getLiteralValues(type);
const description = normalizeDescription(ts.displayPartsToString(prop.getDocumentationComment(checker)));
return {
name: prop.name,
type: simplifyType(type, declaration),
required: (prop.flags & ts.SymbolFlags.Optional) === 0 && !containsUndefined(type),
source: sourceKind,
...(literalValues ? { literalValues } : {}),
...(description ? { description } : {}),
global,
};
}
function createFallbackProp(name: string, sourceKind: CustomJsxPropSource): UninternedPropDescriptor {
const literalValues = FALLBACK_LITERAL_VALUES[name];
return {
name,
type: FALLBACK_PROP_TYPES[name] ?? inferFallbackType(name),
required: false,
source: sourceKind,
...(literalValues ? { literalValues: [...literalValues] } : {}),
...(FALLBACK_PROP_DESCRIPTIONS[name] ? { description: FALLBACK_PROP_DESCRIPTIONS[name] } : {}),
};
}
function mergeGlobalProp(name: string, candidates: UninternedPropDescriptor[]): UninternedPropDescriptor {
const fallback = createFallbackProp(name, "global");
const type = mostFrequent(candidates.map((candidate) => candidate.type)) ?? fallback.type;
const descriptions = candidates.flatMap(({ description }) => (description ? [description] : []));
const literalValues = uniqueLiterals(candidates.flatMap(({ literalValues }) => literalValues ?? []));
return {
name,
type,
required: false,
source: "global",
...(literalValues.length > 0 ? { literalValues } : {}),
...((mostFrequent(descriptions) ?? fallback.description)
? { description: mostFrequent(descriptions) ?? fallback.description }
: {}),
};
}
function samePropMetadata(globalProp: UninternedPropDescriptor, componentProp: HomarrPropMetadata): boolean {
return (
globalProp.type === componentProp.type &&
globalProp.required === componentProp.required &&
JSON.stringify(globalProp.literalValues ?? []) === JSON.stringify(componentProp.literalValues ?? [])
);
}
function isCallableType(type: ts.Type): boolean {
if (type.getCallSignatures().length > 0) return true;
return type.isUnionOrIntersection() && type.types.some(isCallableType);
}
function isGlobalProp(prop: ts.Symbol): boolean {
if (commonPropNames.has(prop.name)) return true;
return (prop.declarations ?? []).some((declaration) => {
const path = declaration.getSourceFile().fileName.replaceAll("\\", "/");
return path.includes("/node_modules/@types/react/") || path.includes("/@mantine/core/lib/core/Box/");
});
}
function containsUndefined(type: ts.Type): boolean {
if ((type.flags & ts.TypeFlags.Undefined) !== 0) return true;
return type.isUnion() && type.types.some(containsUndefined);
}
function simplifyType(type: ts.Type, declaration: ts.Node): string {
const withoutUndefined = type.isUnion()
? type.types.filter((part) => (part.flags & ts.TypeFlags.Undefined) === 0)
: [type];
const simplified = withoutUndefined.map(simpleTypeName);
if (simplified.every((value): value is string => value !== null)) return [...new Set(simplified)].join(" | ");
const rendered = checker
.typeToString(type, declaration, ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope)
.replace(/\s+/gu, " ")
.replace(/\s*\|\s*undefined\b/gu, "")
.trim();
if (rendered.length <= 240) return rendered === "any" ? "unknown" : rendered;
const alias = type.aliasSymbol?.name;
if (alias && !alias.startsWith("__")) return alias;
return inferWideType(type);
}
function simpleTypeName(type: ts.Type): string | null {
if ((type.flags & ts.TypeFlags.String) !== 0) return "string";
if ((type.flags & ts.TypeFlags.Number) !== 0) return "number";
if ((type.flags & ts.TypeFlags.Boolean) !== 0) return "boolean";
if ((type.flags & ts.TypeFlags.Null) !== 0) return "null";
if ((type.flags & ts.TypeFlags.StringLiteral) !== 0) return JSON.stringify((type as ts.StringLiteralType).value);
if ((type.flags & ts.TypeFlags.NumberLiteral) !== 0) return String((type as ts.NumberLiteralType).value);
if ((type.flags & ts.TypeFlags.BooleanLiteral) !== 0)
return (type as ts.Type & { intrinsicName?: string }).intrinsicName ?? "boolean";
return null;
}
function inferWideType(type: ts.Type): string {
const parts = type.isUnionOrIntersection() ? type.types : [type];
const kinds = new Set(
parts.map((part) => {
if ((part.flags & (ts.TypeFlags.String | ts.TypeFlags.StringLiteral)) !== 0) return "string";
if ((part.flags & (ts.TypeFlags.Number | ts.TypeFlags.NumberLiteral)) !== 0) return "number";
if ((part.flags & (ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral)) !== 0) return "boolean";
if ((part.flags & ts.TypeFlags.Null) !== 0) return "null";
return "object";
}),
);
return [...kinds].toSorted().join(" | ") || "unknown";
}
function getLiteralValues(type: ts.Type): CustomJsxLiteralValue[] | undefined {
const values: CustomJsxLiteralValue[] = [];
let hasNonLiteralValue = false;
const visit = (candidate: ts.Type) => {
if (candidate.isUnion()) {
candidate.types.forEach(visit);
return;
}
if ((candidate.flags & ts.TypeFlags.StringLiteral) !== 0) values.push((candidate as ts.StringLiteralType).value);
else if ((candidate.flags & ts.TypeFlags.NumberLiteral) !== 0)
values.push((candidate as ts.NumberLiteralType).value);
else if ((candidate.flags & ts.TypeFlags.BooleanLiteral) !== 0)
values.push((candidate as ts.Type & { intrinsicName?: string }).intrinsicName === "true");
else if ((candidate.flags & ts.TypeFlags.Null) !== 0) values.push(null);
else if ((candidate.flags & ts.TypeFlags.Undefined) === 0) hasNonLiteralValue = true;
};
visit(type);
const unique = uniqueLiterals(values);
if (hasNonLiteralValue && unique.every((value) => value === null || typeof value === "boolean")) return undefined;
return unique.length > 0 && unique.length <= 64 ? unique : undefined;
}
function uniqueLiterals(values: CustomJsxLiteralValue[]): CustomJsxLiteralValue[] {
return [...new Map(values.map((value) => [`${typeof value}:${String(value)}`, value])).values()].toSorted(
(left, right) => compareStrings(String(left), String(right)),
);
}
function normalizeDescription(value: string | undefined): string | undefined {
const normalized = value?.replace(/\s+/gu, " ").trim();
return normalized || undefined;
}
function mostFrequent(values: string[]): string | undefined {
const counts = new Map<string, number>();
for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
return [...counts.entries()].toSorted(
([leftValue, leftCount], [rightValue, rightCount]) =>
rightCount - leftCount || leftValue.length - rightValue.length || compareStrings(leftValue, rightValue),
)[0]?.[0];
}
function compareStrings(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
function inferFallbackType(name: string): string {
if (/^(?:is|has|with|allow|disabled|readOnly|loading|opened|checked|multiple|grow|wrap|striped|animated)/u.test(name))
return "boolean";
if (/^(?:count|index|max|min|offset|order|page|pageSize|size|span|step|total|value)$/u.test(name)) return "number";
if (/^(?:colors|values|sections|data)$/u.test(name)) return "unknown[]";
if (/^(?:children|description|fallback|icon|label|leftSection|rightSection|title)$/u.test(name)) return "ReactNode";
if (/^(?:params|enabledParams|disabledParams|style|styles)$/u.test(name)) return "Record<string, unknown>";
return "string | number | boolean";
}
function createBlockedCapabilities(): CustomJsxBlockedCapability[] {
const exactNames = new Set([
...CUSTOM_JSX_BLOCKED_PROPS,
...CUSTOM_JSX_BLOCKED_STYLE_KEYS,
...[...CUSTOM_JSX_BLOCKED_PROPERTIES].filter((name) => name !== "bind"),
]);
return [
...[...exactNames].toSorted().map(
(name): CustomJsxBlockedCapability => ({
kind: "prop",
name,
reason: blockedPropReason(name),
}),
),
{
kind: "prop-pattern",
name: "on*",
reason: "Authored event callbacks can execute code outside the declarative runtime.",
},
{
kind: "prop-pattern",
name: "*Ref",
reason: "Refs expose underlying React components and DOM nodes.",
},
{
kind: "prop-pattern",
name: "__*",
reason: "Private and prototype-related properties are outside the supported authoring surface.",
},
];
}
function blockedPropReason(name: string): string {
if (["component", "renderRoot"].includes(name))
return "Polymorphic roots can replace the Homarr-owned element boundary.";
if (["portalProps", "popoverTarget", "popoverTargetAction", "withinPortal"].includes(name))
return "Portal targeting can render or interact outside the widget root.";
if (["dangerouslySetInnerHTML", "srcDoc"].includes(name))
return "Raw HTML injection is not available to authored widgets.";
if (["className", "classNames", "classes"].includes(name))
return "Unscoped class injection can escape widget CSS isolation.";
if (name === "children") return "Children must be expressed as interpreted JSX, not passed as a raw prop.";
if (name.toLowerCase().includes("ref")) return "Refs expose underlying React components and DOM nodes.";
if (name.startsWith("form")) return "External form submission is outside the named-request security boundary.";
return "This capability is blocked by the Custom JSX safety boundary.";
}
async function readPackageVersion(path: string): Promise<string> {
const parsed = JSON.parse(await readFile(path, "utf8")) as { version?: unknown };
if (typeof parsed.version !== "string") throw new Error(`Package at ${path} does not declare a version`);
return parsed.version;
}
const HOMARR_COMPONENT_DEFINITIONS = [
{ name: "PaginatedList", category: "interaction", safety: "allowed" },
{ name: "TabsContainer", category: "interaction", safety: "allowed" },
{ name: "TabPanel", category: "interaction", safety: "allowed" },
{ name: "Collapsible", category: "interaction", safety: "allowed" },
{ name: "StatBar", category: "interaction", safety: "allowed" },
{ name: "TypeBadge", category: "interaction", safety: "allowed" },
{ name: "SubFetch", category: "network", safety: "wrapped" },
{ name: "SubData", category: "network", safety: "wrapped" },
{ name: "ActionButton", category: "network", safety: "wrapped" },
{ name: "ToggleSwitch", category: "network", safety: "wrapped" },
{ name: "RefreshButton", category: "network", safety: "wrapped" },
{ name: "TablerIcon", category: "content", safety: "wrapped" },
] as const satisfies readonly {
name: string;
category: CustomJsxComponentCategory;
safety: CustomJsxComponentSafety;
}[];
const stringBindingComponents = [
"Accordion",
"Autocomplete",
"Chip.Group",
"ChipGroup",
"ColorInput",
"ColorPicker",
"DateInput",
"DatePicker",
"DatePickerInput",
"DateTimePicker",
"InlineDateTimePicker",
"JsonInput",
"MaskInput",
"MiniCalendar",
"MonthPicker",
"MonthPickerInput",
"NativeSelect",
"PasswordInput",
"PinInput",
"Radio.Group",
"RadioGroup",
"SegmentedControl",
"Select",
"Tabs",
"TextInput",
"Textarea",
"TimeInput",
"TimePicker",
"TreeSelect",
"YearPicker",
"YearPickerInput",
] as const;
const CUSTOM_JSX_BINDING_TYPES: Readonly<Record<string, CustomJsxCatalogBindingType>> = {
...Object.fromEntries(stringBindingComponents.map((name) => [name, "string"] as const)),
Checkbox: "boolean",
Menu: "boolean",
Popover: "boolean",
Switch: "boolean",
NumberInput: "number",
Pagination: "number",
Rating: "number",
Slider: "number",
Stepper: "number",
"Checkbox.Group": "string[]",
CheckboxGroup: "string[]",
MultiSelect: "string[]",
"Switch.Group": "string[]",
SwitchGroup: "string[]",
TagsInput: "string[]",
RangeSlider: "number[]",
};
const HOMARR_COMPONENT_DESCRIPTIONS: Readonly<Record<string, string>> = {
PaginatedList: "Renders a bounded page of collection items.",
TabsContainer: "Renders a declarative tab container.",
TabPanel: "Renders content for one declarative tab.",
Collapsible: "Shows and hides a bounded content section.",
StatBar: "Displays a compact labeled statistic and progress value.",
TypeBadge: "Displays a compact semantic type badge.",
SubFetch: "Runs a named manual query with invocation-time parameters.",
SubData: "Reads a nested value from a named manual-query result.",
ActionButton: "Runs a named user-triggered action.",
ToggleSwitch: "Runs named actions for the enabled and disabled states.",
RefreshButton: "Refreshes load-triggered widget queries.",
TablerIcon: "Resolves an installed Tabler icon by its safe name.",
};
type HomarrPropMetadata = Omit<UninternedPropDescriptor, "name" | "source">;
const optionalProp = (
type: string,
literalValues?: CustomJsxLiteralValue[],
description?: string,
): HomarrPropMetadata => ({
type,
required: false,
...(literalValues ? { literalValues } : {}),
...(description ? { description } : {}),
});
const requiredProp = (
type: string,
literalValues?: CustomJsxLiteralValue[],
description?: string,
): HomarrPropMetadata => ({
type,
required: true,
...(literalValues ? { literalValues } : {}),
...(description ? { description } : {}),
});
const HOMARR_COMPONENT_PROPS: Readonly<Record<string, Readonly<Record<string, HomarrPropMetadata>>>> = {
PaginatedList: { pageSize: optionalProp("number") },
TabsContainer: { defaultTab: optionalProp("string") },
TabPanel: { value: requiredProp("string"), label: optionalProp("string") },
Collapsible: { title: requiredProp("string"), defaultOpen: optionalProp("boolean") },
StatBar: {
value: requiredProp("number"),
max: optionalProp("number"),
label: optionalProp("string"),
color: optionalProp("string"),
},
TypeBadge: {
type: requiredProp("string"),
size: optionalProp("'xs' | 'sm' | 'md' | 'lg' | 'xl'", ["xs", "sm", "md", "lg", "xl"]),
},
SubFetch: {
requestId: optionalProp("string", undefined, "Identifier of the named Custom Widget query."),
params: optionalProp("Record<string, string | number | boolean>"),
refreshInterval: optionalProp("number"),
loadingLabel: optionalProp("string"),
errorMessage: optionalProp("string"),
fallback: optionalProp("ReactNode"),
triggerContent: optionalProp("ReactNode", undefined, "Custom accessible trigger shown before a manual query runs."),
triggerAriaLabel: optionalProp("string", undefined, "Accessible label for a custom manual-query trigger."),
path: optionalProp("string"),
as: optionalProp("'json' | 'text'", ["json", "text"]),
trigger: optionalProp("'auto' | 'manual'", ["auto", "manual"]),
},
SubData: {
path: optionalProp("string"),
as: optionalProp("'Text' | 'Title' | 'Badge' | 'Code' | 'Image'", ["Text", "Title", "Badge", "Code", "Image"]),
order: optionalProp("1 | 2 | 3 | 4 | 5 | 6", [1, 2, 3, 4, 5, 6]),
size: optionalProp("string"),
color: optionalProp("string"),
variant: optionalProp("string"),
fw: optionalProp("number"),
c: optionalProp("string"),
alt: optionalProp("string"),
fit: optionalProp("string"),
w: optionalProp("string | number"),
h: optionalProp("string | number"),
radius: optionalProp("string | number"),
},
ActionButton: {
requestId: optionalProp("string", undefined, "Identifier of the named Custom Widget action."),
params: optionalProp("Record<string, string | number | boolean>"),
label: optionalProp("string"),
color: optionalProp("string"),
variant: optionalProp("string"),
size: optionalProp("string"),
confirmMessage: optionalProp("string"),
successMessage: optionalProp("string"),
errorMessage: optionalProp("string"),
icon: optionalProp("string"),
disabled: optionalProp("boolean | string"),
fullWidth: optionalProp("boolean | string"),
},
ToggleSwitch: {
requestId: optionalProp("string", undefined, "Identifier of the named Custom Widget action."),
enabledParams: optionalProp("Record<string, string | number | boolean>"),
disabledParams: optionalProp("Record<string, string | number | boolean>"),
initialValue: optionalProp("boolean | string"),
label: optionalProp("string"),
color: optionalProp("string"),
size: optionalProp("string"),
errorMessage: optionalProp("string"),
disabled: optionalProp("boolean | string"),
},
RefreshButton: {
label: optionalProp("string"),
color: optionalProp("string"),
variant: optionalProp("string"),
size: optionalProp("string"),
},
TablerIcon: {
name: requiredProp("string"),
size: optionalProp("string | number"),
color: optionalProp("string"),
stroke: optionalProp("string | number"),
},
};
const FALLBACK_PROP_TYPES: Readonly<Record<string, string>> = {
key: "string | number",
id: "string",
style: "CSSProperties",
title: "string",
role: "AriaRole",
"aria-label": "string",
"aria-describedby": "string",
"aria-hidden": "boolean | 'true' | 'false'",
tabIndex: "number",
requestId: "string",
refreshInterval: "number",
path: "string",
name: "string",
color: "MantineColor",
radius: "MantineRadius",
variant: "string",
size: "MantineSize | string | number",
defaultValue: "unknown",
defaultChecked: "boolean",
};
const FALLBACK_LITERAL_VALUES: Readonly<Record<string, readonly CustomJsxLiteralValue[]>> = {
target: ["_blank", "_parent", "_self", "_top"],
orientation: ["horizontal", "vertical"],
};
const FALLBACK_PROP_DESCRIPTIONS: Readonly<Record<string, string>> = {
key: "Stable identity used by React when rendering collections.",
requestId: "Identifier of the named Custom Widget request.",
bind: "Name of the temporary in-memory input value.",
};
await generateCatalog();
@@ -0,0 +1,138 @@
import { customJsxRequestSchema } from "../src/core/request-schema";
import { executeCustomWidgetRequest } from "../src/server/request-executor";
import { renderRequestTarget, resolveCustomWidgetRequestValues } from "../src/server/request-manifest";
const pokemonListRequest = customJsxRequestSchema.parse({
path: "/api/v2/pokemon",
query: { limit: { $option: "limit" }, offset: 0 },
});
const pokemonDetailRequest = customJsxRequestSchema.parse({
path: "/api/v2/pokemon/{param:name}",
trigger: "manual",
});
const listTarget = renderRequestTarget(
"https://pokeapi.co",
pokemonListRequest,
resolveCustomWidgetRequestValues(pokemonListRequest, { limit: 3 }),
);
const list = await fetchJson(listTarget, {});
if (!isRecord(list) || !Array.isArray(list.results) || list.results.length !== 3) {
throw new Error("PokeAPI list response did not match the expected shape");
}
const firstName = isRecord(list.results[0]) && typeof list.results[0].name === "string" ? list.results[0].name : null;
if (!firstName) throw new Error("PokeAPI list did not contain a Pokémon name");
const detailTarget = renderRequestTarget(
"https://pokeapi.co",
pokemonDetailRequest,
resolveCustomWidgetRequestValues(pokemonDetailRequest, {}, { name: firstName }),
);
const detail = await fetchJson(detailTarget, {});
if (!isRecord(detail) || detail.name !== firstName || !Array.isArray(detail.stats)) {
throw new Error("PokeAPI detail response did not match the expected shape");
}
process.stdout.write("PokeAPI list and detail requests passed\n");
const portainerBaseUrl = process.env.PORTAINER_BASE_URL;
const portainerApiKey = process.env.PORTAINER_API_KEY;
if (!portainerBaseUrl || !portainerApiKey) {
if (process.argv.includes("--pokeapi-only")) {
process.stdout.write("Portainer check skipped (--pokeapi-only)\n");
process.exit(0);
}
throw new Error("PORTAINER_BASE_URL and PORTAINER_API_KEY are required for the Portainer reference check");
}
const endpointId = process.env.PORTAINER_ENDPOINT_ID ?? "2";
const containersRequest = customJsxRequestSchema.parse({
path: "/api/endpoints/{option:endpointId}/docker/containers/json",
query: { all: true },
});
const containersTarget = renderRequestTarget(
portainerBaseUrl,
containersRequest,
resolveCustomWidgetRequestValues(containersRequest, { endpointId }),
);
const containers = await fetchJson(containersTarget, { "X-API-Key": portainerApiKey });
if (!Array.isArray(containers)) throw new Error("Portainer containers response was not an array");
const actionRequests = ["start", "stop", "restart"].map((action) =>
customJsxRequestSchema.parse({
kind: "action",
method: "POST",
path: `/api/endpoints/{option:endpointId}/docker/containers/{param:id}/${action}`,
confirmation: `${action} this container?`,
invalidates: ["containers"],
}),
);
for (const request of actionRequests) {
const target = renderRequestTarget(
portainerBaseUrl,
request,
resolveCustomWidgetRequestValues(request, { endpointId }, { id: "container/id" }),
);
if (!target.pathname.includes("container%2Fid")) throw new Error("Portainer action target did not encode its ID");
}
process.stdout.write(
`Portainer container query passed (${containers.length} containers); start/stop/restart targets passed\n`,
);
const mutationContainerId = process.env.PORTAINER_TEST_CONTAINER_ID;
if (mutationContainerId) {
if (process.env.PORTAINER_ALLOW_MUTATIONS !== "1") {
throw new Error("Set PORTAINER_ALLOW_MUTATIONS=1 to test actions against PORTAINER_TEST_CONTAINER_ID");
}
const container = containers.find(
(candidate) =>
isRecord(candidate) && (candidate.Id === mutationContainerId || candidate.ID === mutationContainerId),
);
if (!isRecord(container)) throw new Error("PORTAINER_TEST_CONTAINER_ID was not returned by the container query");
const initiallyRunning =
container.State === "running" || (isRecord(container.State) && container.State.Running === true);
const actionOrder = initiallyRunning ? ["restart", "stop", "start"] : ["start", "restart", "stop"];
try {
for (const action of actionOrder) await runPortainerAction(action, mutationContainerId);
} finally {
await runPortainerAction(initiallyRunning ? "start" : "stop", mutationContainerId, true);
}
process.stdout.write(
`Portainer ${actionOrder.join("/")} actions passed and the original running state was restored\n`,
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
async function fetchJson(target: URL, headers: Record<string, string>): Promise<unknown> {
const response = await fetch(target, { headers, signal: AbortSignal.timeout(15_000) });
if (!response.ok) throw new Error(`Reference API returned HTTP ${response.status}`);
return response.json() as Promise<unknown>;
}
async function runPortainerAction(action: string, containerId: string, ignoreConflict = false) {
const request = customJsxRequestSchema.parse({
kind: "action",
method: "POST",
path: `/api/endpoints/{option:endpointId}/docker/containers/{param:id}/${action}`,
});
const target = renderRequestTarget(
portainerBaseUrl,
request,
resolveCustomWidgetRequestValues(request, { endpointId }, { id: containerId }),
);
const response = await executeCustomWidgetRequest({
baseUrl: portainerBaseUrl,
targetUrl: target,
method: "POST",
networkScope: "private",
kind: "action",
auth: {
type: "apiKeyHeader",
headerName: "X-API-Key",
secrets: [{ kind: "apiKey", value: portainerApiKey }],
},
});
if (!response.ok && !(ignoreConflict && response.status === 304)) {
throw new Error(`Portainer ${action} returned HTTP ${response.status}`);
}
}
@@ -0,0 +1,259 @@
import type { HomarrCustomWidgetV2 } from "./custom-jsx-schema";
import { customJsxAuthoringCatalog } from "./component-catalog";
import {
getCustomWidgetCredentialKeyRisk,
isHarmlessCustomWidgetCredentialSetting,
redactCustomWidgetCredentialLiterals,
} from "./definition-security";
import { customJsxExamples } from "./examples";
export const CUSTOM_WIDGET_MANTINE_VERSION = customJsxAuthoringCatalog.mantineVersion;
const CUSTOM_WIDGET_AI_PROMPT_LIMIT = 12_000;
export const CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION =
"Return exactly one complete `json` fenced block containing the entire widget. Put the complete JSX source directly in the `template` string and JSON-escape it correctly. Do not include prose or additional code blocks.";
const RECOMMENDED_COMPONENTS = [
"Stack",
"Group",
"SimpleGrid",
"Grid",
"Box",
"Center",
"Paper",
"Card",
"Card.Section",
"ScrollArea",
"Text",
"Title",
"Badge",
"Alert",
"Progress",
"RingProgress",
"ThemeIcon",
"Indicator",
"Avatar",
"Image",
"Divider",
"Skeleton",
"Loader",
"Table",
"Tabs",
"Tabs.List",
"Tabs.Tab",
"Tabs.Panel",
"Accordion",
"TextInput",
"NumberInput",
"Select",
"MultiSelect",
"Switch",
"Checkbox",
"Radio",
"Radio.Group",
"Radio.Card",
"Radio.Indicator",
"Slider",
"SegmentedControl",
"Button",
"ActionIcon",
"Tooltip",
"Popover",
"Calendar",
"AreaChart",
"BarChart",
"LineChart",
"DonutChart",
] as const;
const leanShape = `{
"$schema": "homarr-custom-widget-v2",
"name": "Widget name",
"description": "Optional summary",
"sources": {
"default": { "name": "API", "baseUrl": "https://api.example.com", "networkScope": "public", "auth": "none" }
},
"requests": {
"items": { "path": "/items/{option:category}", "query": { "limit": { "$option": "limit" } } },
"update": { "kind": "action", "method": "POST", "path": "/items/{param:id}", "invalidates": ["items"] }
},
"options": {
"category": { "label": "Category", "control": "text", "default": "all" },
"limit": { "label": "Result limit", "control": "number", "default": 20, "min": 1, "max": 100 }
},
"template": "<Stack><Text>{data.items?.name}</Text></Stack>"
}`;
function compactExample(index: number) {
const example = customJsxExamples[index];
if (!example) return "";
return `Example — ${example.title}:\n\n\`\`\`json\n${JSON.stringify(example.widget, null, 2)}\n\`\`\``;
}
const AUTHORING_PROMPT = `You are writing one safe Homarr Custom JSX v2 dashboard widget for Mantine ${CUSTOM_WIDGET_MANTINE_VERSION}.
Manifest contract:
${leanShape}
Sources are keyed by name and must include "default". Auth is "none", "bearer", "basic", {"type":"apiKeyHeader","name":"X-Api-Key"}, or {"type":"apiKeyQuery","name":"api_key"}. Use the stable public API URL for public services and a clear suggested URL for self-hosted services; Homarr asks the installer for their own server URL. Never put credentials in the manifest.
Requests are keyed by ID. Defaults are source "default", kind "query", method "GET", query trigger "load", inherited auth, and permission "view" for queries or "modify" for actions. Actions are always manual. DELETE is valid only for actions, requires full permission, and receives confirmation automatically. Use {option:name} or {"$option":"name"} for saved options. Use {param:name} or {"$param":"name"} only for invocation-time params supplied by SubFetch, ActionButton, or ToggleSwitch. Load queries cannot use params. Values and primitive types are inferred from references; do not declare parameters or option bindings. Paths and query values must be primitive; JSON bodies may bind structured options.
Options are keyed by name. Every option has label, control, and default. Controls: text, textarea, number, switch, select, multiSelect, slider, date, time, color, icon, url, duration, timeZone, json. Select choices use \`"choices": [{"label":"...","value":"..."}]\`. Dynamic choices must use \`"choicesFrom": {"request":"requestId","itemsPath":"optional.path","valuePath":"id","labelPath":"name"}\`, never an object under \`choices\`. Options are configured outside the widget and read through \`options.name\`; a bound control writes only to \`inputs.name\` and never changes an option.
JSX reads data.requestId, status.requestId, options.name, and temporary inputs.name. A status has loading, ok, status, statusText, and error. Use bind="search" on supported controls and inputs.search in params. SubFetch invokes a manual query. With trigger="manual", it renders its own load button; pass a card or image as triggerContent with triggerAriaLabel to make that content launch the request. Never author onClick or a fetch callback. Its child callback is (result, meta), where meta has ok, status, statusText, loading=false, and no error because SubFetch renders loading/error states itself. Use expression callbacks for map, filter, sort, and SubFetch. Do not use imports, hooks, refs, raw HTML, event callbacks, fetch, eval, bigint, npm packages, authored const blocks, IIFEs, or recursion. Regex is only for bounded string matching/replacement. Do not embed secrets.
Hard syntax rule: never write \`=> {\` anywhere. Every callback must be one concise expression, for example \`items.map(item => <Card key={item.id}>...</Card>)\`. Inline derived values directly, even when that repeats a short expression. Never use an IIFE to create local variables or branch; use JSX ternaries instead. Callback parameter names must not shadow the reserved roots \`data\`, \`status\`, \`options\`, or \`inputs\`.
Use only API routes grounded in the user request, documentation, or verified API notes. When a requested mutation is undocumented, omit it and explain the limitation through the widget design rather than inventing an endpoint.
Recommended components: ${RECOMMENDED_COMPONENTS.join(", ")}. This list is not exhaustive. Standard Mantine compound names are encouraged. Runtime helpers: RefreshButton, SubFetch, ActionButton, ToggleSwitch, and <Icon name="tabler-icon-name" />.
Make the result genuinely attractive: establish clear visual hierarchy, use deliberate spacing, restrained semantic color, responsive layouts, and theme-safe colors. Prefer one strong primary surface over excessive nested cards. Include useful loading, empty, error, and success states. Make narrow and wide tiles both work.
Visual quality bar:
- Give the widget a purposeful header with title, useful context, and its primary status or action.
- When the data supports it, lead with 2–4 scannable summary metrics before detailed rows.
- Use responsive SimpleGrid/Grid column objects and let long content wrap on narrow tiles.
- Use theme tokens and semantic Mantine colors; avoid hard-coded light backgrounds and decorative gradients.
${compactExample(0)}
${compactExample(1)}
Output one complete JSON manifest. Put the complete JSX directly in its template string so the user can copy one code block and paste it into Homarr once.`;
export const CUSTOM_WIDGET_AUTHORING_PROMPT = AUTHORING_PROMPT;
export const CUSTOM_WIDGET_MCP_AUTHORING_PROMPT = `Author one Homarr Custom JSX v2 widget at a time.
Read the live schema and only the component resources needed for the design. Construct a credential-free widget, validate it, create a preview, test queries and simulated actions, visually inspect it, then create it. Configure deployment-specific source URLs and request credentials through Homarr when needed; never repeat plaintext. The live resources are authoritative for this Homarr release.
Use the lean keyed sources, requests, and options contract. Bind saved values directly with $option and invocation values with $param. Load queries cannot use $param. Keep the design responsive, accessible, theme-safe, loading-aware, empty-aware, and error-aware.
Use Workshop search/get/install when the user asks for an existing community widget. After installation, configure self-hosted source URLs and credentials with the dedicated source configuration tool or a secure user configuration request.`;
export function buildCustomWidgetMcpPrompt(request?: string | null, documentationUrl?: string | null) {
const sections = [CUSTOM_WIDGET_MCP_AUTHORING_PROMPT];
if (documentationUrl) sections.push(`API documentation: ${redactUrl(documentationUrl)}`);
if (request?.trim()) sections.push(`User request: ${redactText(request.trim())}`);
return sections.join("\n\n");
}
export function buildCustomWidgetAiPrompt(
_jsonSchema?: unknown,
rawResponse?: string | null,
currentConfig?: Partial<HomarrCustomWidgetV2> | Record<string, unknown> | null,
request?: string | null,
documentationUrl?: string | null,
) {
const sections = [
`Create this Homarr Custom JSX v2 widget:\n\n${truncatePromptText(redactText(request?.trim() || "Describe the widget you want to create."), 1_500)}`,
];
if (documentationUrl) sections.push(`API documentation: ${truncatePromptText(redactUrl(documentationUrl), 500)}`);
sections.push(AUTHORING_PROMPT);
const optionalSections = [
...(rawResponse ? [{ label: "Sample API response", content: redactResponse(rawResponse) }] : []),
...(currentConfig
? [{ label: "Current widget", content: JSON.stringify(redactValue(currentConfig), null, 2) }]
: []),
];
for (const section of optionalSections) {
const remaining =
CUSTOM_WIDGET_AI_PROMPT_LIMIT - sections.join("\n\n").length - CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION.length - 8;
const formatted = formatBudgetedCodeSection(section.label, section.content, remaining);
if (formatted) sections.push(formatted);
}
const footer = `\n\n${CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION}`;
return `${truncatePromptText(sections.join("\n\n"), CUSTOM_WIDGET_AI_PROMPT_LIMIT - footer.length)}${footer}`;
}
function redactValue(value: unknown, path: Array<string | number> = []): unknown {
if (Array.isArray(value)) return value.map((entry, index) => redactValue(entry, [...path, index]));
if (value !== null && typeof value === "object")
return Object.fromEntries(
Object.entries(value).map(([entryKey, entry]) => {
const entryPath = [...path, entryKey];
const risk = getCustomWidgetCredentialKeyRisk(entryKey);
if (
risk === "strong" &&
!isSchemaAuthenticationControl(entryPath) &&
!isHarmlessCustomWidgetCredentialSetting(entry)
) {
return [entryKey, "[REDACTED]"];
}
if (
risk === "ambiguous" &&
typeof entry === "string" &&
redactCustomWidgetCredentialLiterals(entry) !== entry
) {
return [entryKey, "[REDACTED]"];
}
return [entryKey, redactValue(entry, entryPath)];
}),
);
if (typeof value === "string") {
const key = typeof path.at(-1) === "string" ? (path.at(-1) as string) : "";
if (["sources", "requests", "options"].includes(key)) {
try {
return redactValue(JSON.parse(value) as unknown, path);
} catch {
/* Keep invalid editor JSON useful. */
}
}
return key === "baseUrl" || key === "iconUrl"
? redactCustomWidgetCredentialLiterals(redactUrl(value))
: redactText(value);
}
return value;
}
function redactResponse(value: string) {
try {
return JSON.stringify(redactValue(JSON.parse(value) as unknown), null, 2);
} catch {
return truncatePromptText(redactText(value), 1_500);
}
}
function redactUrl(value: string): string {
try {
const url = new URL(value);
url.username = "";
url.password = "";
url.search = "";
url.hash = "";
return url.toString();
} catch {
return "[REDACTED_URL]";
}
}
function redactText(value: string): string {
return redactCustomWidgetCredentialLiterals(
value.replace(/\bhttps?:\/\/[^\s"'<>]+/giu, (candidate) => redactUrl(candidate)),
);
}
function isSchemaAuthenticationControl(path: Array<string | number>): boolean {
return (
path.length === 3 &&
path[2] === "auth" &&
(path[0] === "sources" || path[0] === "requests") &&
typeof path[1] === "string"
);
}
function formatBudgetedCodeSection(label: string, content: string, budget: number): string | null {
const prefix = `${label}:\n\n\`\`\`json\n`;
const suffix = "\n```";
if (budget <= prefix.length + suffix.length + 80) return null;
return `${prefix}${truncatePromptText(content, budget - prefix.length - suffix.length)}${suffix}`;
}
function truncatePromptText(value: string, limit: number): string {
if (value.length <= limit) return value;
const marker = "\n... [content omitted to fit the prompt budget]";
return `${value.slice(0, Math.max(0, limit - marker.length))}${marker}`;
}
@@ -0,0 +1,87 @@
import { customJsxAuthoringCatalog, getCustomJsxComponentProps } from "./component-catalog";
import { customJsxExamples } from "./examples";
import { customJsxTablerIconNames } from "./tabler-icons";
export const CUSTOM_WIDGET_SKILLS_SH_URL = "https://www.skills.sh/homarr-labs/homarr/homarr-custom-widget";
export const CUSTOM_WIDGET_SKILL_SOURCE_URL =
"https://github.com/homarr-labs/homarr/tree/HEAD/.agents/skills/homarr-custom-widget";
export const CUSTOM_WIDGET_SKILL_INSTALL_COMMAND =
"npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget";
export const CUSTOM_WIDGET_SKILL_VERSION = "2.1.0";
export const CUSTOM_WIDGET_SKILL_MD = `---
name: homarr-custom-widget
description: Author safe Homarr Custom JSX v2 widgets from API documentation.
---
# Homarr Custom Widget
Author one widget at a time. When connected to Homarr, retrieve \`homarr://custom-widgets/schema\` and only the component resources needed for the design. The live resources are authoritative for the installed release.
1. Read the API documentation.
2. Create one credential-free widget with keyed \`sources\`, keyed \`requests\`, optional keyed \`options\`, and safe JSX \`template\`.
3. Validate, preview, test queries and simulated actions, visually inspect, and iterate.
4. Configure deployment-specific source URLs and request credentials through Homarr; never repeat plaintext.
5. Save only after narrow/wide, light/dark, loading, empty, error, and success states work.
Use \`{option:name}\` or \`$option\` for saved options. Use \`{param:name}\` or \`$param\` for values supplied by \`SubFetch\`, \`ActionButton\`, or \`ToggleSwitch\`. Load queries cannot use invocation parameters. Templates read \`data\`, \`status\`, \`options\`, and temporary \`inputs\`.
\`SubFetch\` with \`trigger="manual"\` renders its own load button. Pass a card or image through \`triggerContent\` with \`triggerAriaLabel\` when that content should launch the request. Its callback is \`(result, meta)\`; never author \`onClick\` or a fetch callback.
Use standard Mantine compound names and deliberate visual hierarchy, spacing, restrained semantic color, and responsive layouts. Avoid excessive nested cards.
Do not use imports, hooks, refs, raw HTML, raw event callbacks, browser requests, arbitrary functions, eval, bigint, npm packages, authored statement blocks, IIFEs, or recursion. Do not pretend MCP tools exist in an offline chat session.
To install an existing widget, use \`customWidget_workshopSearch\`, inspect it with \`customWidget_workshopGet\`, then use \`customWidget_workshopInstall\`. Configure each self-hosted source afterward with \`customWidget_sourceConfigure\`, or call \`customWidget_configurationRequestUser\` so the user can enter the server URL and credentials directly in Homarr.
Preview-scoped configuration expires with the preview. To persist user-entered values for a new widget, create the widget first, request configuration for its definition ID, then create a final preview with that definition ID so Homarr loads the stored credentials.
When this skill is installed from the repository, optional offline details are in \`references/schema.md\`, \`references/runtime.md\`, and \`references/security.md\`. MCP-only clients should use the live Homarr resources instead.
`;
export function getCustomWidgetSkill() {
return {
name: "homarr-custom-widget",
version: CUSTOM_WIDGET_SKILL_VERSION,
skillMd: CUSTOM_WIDGET_SKILL_MD,
skillsShUrl: CUSTOM_WIDGET_SKILLS_SH_URL,
sourceUrl: CUSTOM_WIDGET_SKILL_SOURCE_URL,
installCommand: CUSTOM_WIDGET_SKILL_INSTALL_COMMAND,
};
}
export function getCustomWidgetSkillContent() {
return CUSTOM_WIDGET_SKILL_MD;
}
export function getCustomWidgetComponentCatalog() {
return customJsxAuthoringCatalog;
}
export function getCustomWidgetComponent(name: string) {
const canonicalName = name === "Icon" ? "TablerIcon" : name;
const component = customJsxAuthoringCatalog.components.find((candidate) => candidate.name === canonicalName);
if (!component) return null;
return {
schemaVersion: customJsxAuthoringCatalog.schemaVersion,
mantineVersion: customJsxAuthoringCatalog.mantineVersion,
customWidgetVersion: customJsxAuthoringCatalog.customWidgetVersion,
name: component.name,
package: component.package,
category: component.category,
safety: component.safety,
description: component.description,
props: getCustomJsxComponentProps(canonicalName),
blockedProps: component.blockedProps,
bind: component.bind,
subcomponents: component.subcomponents,
accessibilityRequirements: component.accessibilityRequirements,
documentationUrl: component.documentationUrl,
knownValues: component.name === "TablerIcon" ? { name: customJsxTablerIconNames } : undefined,
deniedReason: component.deniedReason,
};
}
export function getCustomWidgetExample(name: string) {
return customJsxExamples.find((example) => example.id === name) ?? null;
}
@@ -0,0 +1,140 @@
import type { HomarrCustomWidgetV2Input } from "./custom-jsx-schema";
export interface BundledCustomWidget {
id: "seed-dog-facts" | "seed-currency-exchange" | "seed-jellyfin" | "seed-pokedex" | "seed-tautulli-activity";
widget: HomarrCustomWidgetV2Input;
}
export const BUNDLED_CUSTOM_WIDGETS: readonly BundledCustomWidget[] = [
{
id: "seed-dog-facts",
widget: {
$schema: "homarr-custom-widget-v2",
name: "Random Dog Fact",
description: "Displays a random fun fact about dogs.",
sources: { default: { name: "Dog API", baseUrl: "https://dogapi.dog", networkScope: "public", auth: "none" } },
requests: { fact: { path: "/api/v2/facts", cacheSeconds: 30 } },
options: {},
template: `<Stack gap="sm" p="sm" h="100%" justify="center">
<Group justify="space-between"><Text fw={700}>Dog fact</Text><RefreshButton /></Group>
{status.fact?.loading ? <Skeleton height={72} radius="md" /> : status.fact?.error ? <Alert color="red">{status.fact.error}</Alert> : <Text size="sm">{data.fact?.data?.[0]?.attributes?.body ?? "No dog fact was returned."}</Text>}
</Stack>`,
},
},
{
id: "seed-currency-exchange",
widget: {
$schema: "homarr-custom-widget-v2",
name: "Currency Exchange",
description: "Converts an amount using European Central Bank exchange rates.",
sources: {
default: { name: "Frankfurter", baseUrl: "https://api.frankfurter.dev", networkScope: "public", auth: "none" },
},
requests: {
rates: {
path: "/v1/latest",
query: { from: { $option: "from" }, to: { $option: "to" }, amount: { $option: "amount" } },
cacheSeconds: 300,
},
},
options: {
from: { label: "From currency", control: "text", default: "JPY" },
to: {
label: "Target currencies",
description: "Comma-separated currency codes",
control: "text",
default: "EUR,USD",
},
amount: { label: "Amount", control: "number", default: 50, min: 0 },
},
template: `<Stack gap="sm" p="sm">
<Group justify="space-between"><Text fw={700}>{options.amount} {options.from}</Text><Badge>{data.rates?.date ?? "Latest"}</Badge></Group>
{status.rates?.loading ? <Skeleton height={80} radius="md" /> : status.rates?.error ? <Alert color="red">{status.rates.error}</Alert> : <SimpleGrid cols={{ base: 1, xs: 2 }}>{Object.entries(data.rates?.rates ?? {}).map(entry => <Paper key={entry[0]} withBorder p="sm"><Text size="xs" c="dimmed">{entry[0]}</Text><Text fw={700} size="xl">{entry[1]}</Text></Paper>)}</SimpleGrid>}
</Stack>`,
},
},
{
id: "seed-jellyfin",
widget: {
$schema: "homarr-custom-widget-v2",
name: "Jellyfin Library",
description: "Counts movies, series, episodes, and songs in a Jellyfin library.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg",
sources: {
default: {
name: "Jellyfin",
baseUrl: "http://jellyfin.local",
networkScope: "private",
auth: { type: "apiKeyHeader", name: "X-Emby-Token" },
},
},
requests: { counts: { path: "/Items/Counts", cacheSeconds: 60 } },
options: {},
template: `<Stack gap="sm" p="sm">
<Group justify="space-between"><Text fw={700}>Jellyfin library</Text><RefreshButton /></Group>
{status.counts?.loading ? <Skeleton height={96} radius="md" /> : status.counts?.error ? <Alert color="red">{status.counts.error}</Alert> : <SimpleGrid cols={{ base: 2, sm: 4 }}>{[{ label: "Movies", value: data.counts?.MovieCount }, { label: "Series", value: data.counts?.SeriesCount }, { label: "Episodes", value: data.counts?.EpisodeCount }, { label: "Songs", value: data.counts?.SongCount }].map(item => <Paper key={item.label} withBorder p="sm"><Text size="xs" c="dimmed">{item.label}</Text><Text fw={700} size="xl">{item.value ?? 0}</Text></Paper>)}</SimpleGrid>}
</Stack>`,
},
},
{
id: "seed-pokedex",
widget: {
$schema: "homarr-custom-widget-v2",
name: "Pokédex",
description: "Browse Pokémon and open a responsive detail view.",
iconUrl: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/poke-ball.png",
sources: { default: { name: "PokeAPI", baseUrl: "https://pokeapi.co", networkScope: "public", auth: "none" } },
requests: {
pokemon: {
path: "/api/v2/pokemon",
query: { limit: { $option: "limit" }, offset: { $option: "offset" } },
cacheSeconds: 300,
},
detail: { path: "/api/v2/pokemon/{param:name}", trigger: "manual", cacheSeconds: 300 },
},
options: {
limit: { label: "Pokémon to load", control: "number", default: 24, min: 1, max: 100 },
offset: { label: "Start at Pokédex number", control: "number", default: 0, min: 0, advanced: true },
accent: { label: "Accent color", control: "color", default: "red" },
},
template: `<Stack gap="md" p="md" h="100%">
<Group justify="space-between" align="flex-start">
<Stack gap={2}><Group gap="xs"><ThemeIcon color={options.accent} variant="light" radius="xl"><Icon name="pokeball" /></ThemeIcon><Title order={3}>Pokédex</Title></Group><Text size="xs" c="dimmed">Explore species, types, abilities, and base stats</Text></Stack>
<Group gap="xs"><Badge color={options.accent} variant="light">{data.pokemon?.count?.toLocaleString() ?? "—"} species</Badge><RefreshButton /></Group>
</Group>
<TextInput bind="search" label="Filter loaded Pokémon" placeholder="Search by name…" />
{status.pokemon?.loading ? <Stack gap="xs">{[1, 2, 3, 4].map(item => <Skeleton key={item} height={82} radius="md" />)}</Stack> : status.pokemon?.error ? <Alert color="red" title="The Pokédex could not be loaded">{status.pokemon.error}</Alert> : (data.pokemon?.results ?? []).filter(pokemon => pokemon.name.toLowerCase().includes(String(inputs.search ?? "").toLowerCase())).length === 0 ? <Alert color="gray" title="No Pokémon found">Try another name or increase the result limit in widget options.</Alert> : <ScrollArea h={390} type="auto"><PaginatedList pageSize={8}>{(data.pokemon?.results ?? []).filter(pokemon => pokemon.name.toLowerCase().includes(String(inputs.search ?? "").toLowerCase())).map(pokemon => <SubFetch key={pokemon.name} requestId="detail" params={{ name: pokemon.name }} trigger="manual" triggerAriaLabel={"Open details for " + pokemon.name} loadingLabel="Loading Pokémon…" fallback={<Card withBorder radius="md" p="xs" mb="xs"><Skeleton height={64} radius="sm" /></Card>} triggerContent={<Card withBorder radius="md" p="xs" mb="xs"><Group wrap="nowrap" justify="space-between"><Group wrap="nowrap" gap="sm"><Image src={"https://cdn.jsdelivr.net/gh/PokeAPI/sprites@master/sprites/pokemon/" + pokemon.url.split("/").slice(-2, -1)[0] + ".png"} alt={pokemon.name} w={64} h={64} fit="contain" radius="sm" fallbackSrc="https://cdn.jsdelivr.net/gh/PokeAPI/sprites@master/sprites/items/poke-ball.png" /><Stack gap={0}><Text fw={700} tt="capitalize">{pokemon.name}</Text><Text size="xs" c="dimmed">#{String(pokemon.url.split("/").slice(-2, -1)[0]).padStart(3, "0")}</Text></Stack></Group><Badge color={options.accent} variant="light">Details</Badge></Group></Card>}>{(detail) => <Card withBorder radius="md" p="sm" mb="xs"><Stack gap="sm"><Group align="flex-start" wrap="nowrap"><Image src={"https://cdn.jsdelivr.net/gh/PokeAPI/sprites@master/sprites/pokemon/other/official-artwork/" + detail.id + ".png"} alt={detail.name} w={80} h={80} fit="contain" radius="md" fallbackSrc="https://cdn.jsdelivr.net/gh/PokeAPI/sprites@master/sprites/items/poke-ball.png" /><Stack gap="xs" style={{ flex: 1 }}><Group justify="space-between"><Text fw={750} size="lg" tt="capitalize">{detail.name}</Text><Group gap={6}>{(detail.types ?? []).map(type => <Badge key={type.type?.name} variant="light" tt="capitalize">{type.type?.name}</Badge>)}</Group></Group><Text size="xs" c="dimmed">Height {detail.height / 10} m · Weight {detail.weight / 10} kg</Text><Text size="xs"><Text span fw={600}>Abilities: </Text>{(detail.abilities ?? []).map(ability => ability.ability?.name?.replace("-", " ")).join(", ")}</Text></Stack></Group><Divider /><SimpleGrid cols={{ base: 1, xs: 2 }} spacing="xs">{(detail.stats ?? []).map(stat => <Stack key={stat.stat?.name} gap={3}><Group justify="space-between"><Text size="xs" tt="capitalize">{stat.stat?.name?.replace("-", " ")}</Text><Text size="xs" fw={700}>{stat.base_stat}</Text></Group><Progress value={Math.min(100, stat.base_stat / 2)} color={options.accent} size="sm" /></Stack>)}</SimpleGrid></Stack></Card>}</SubFetch>)}</PaginatedList></ScrollArea>}
</Stack>`,
},
},
{
id: "seed-tautulli-activity",
widget: {
$schema: "homarr-custom-widget-v2",
name: "Tautulli Activity",
description: "Shows active Plex streams from Tautulli.",
sources: {
default: {
name: "Tautulli API",
baseUrl: "http://tautulli.local",
networkScope: "private",
auth: { type: "apiKeyQuery", name: "apikey" },
},
},
requests: { activity: { path: "/api/v2", query: { cmd: "get_activity" }, cacheSeconds: 15 } },
options: {},
template: `<Stack gap="md" p="md">
<Group justify="space-between"><Stack gap={2}><Text fw={700}>Tautulli Activity</Text><Text size="xs" c="dimmed">Live streaming sessions</Text></Stack><RefreshButton /></Group>
{status.activity?.loading ? <Stack gap="sm"><Skeleton height={80} radius="md" /><Skeleton height={80} radius="md" /></Stack> : status.activity?.error ? <Alert color="red" title="Could not load activity">{status.activity.error}</Alert> : (data.activity?.response?.data?.sessions ?? []).length === 0 ? <Alert color="gray" title="No active streams">All quiet.</Alert> : <>
<SimpleGrid cols={{ base: 2, xs: 4 }}>
<Paper withBorder p="sm" radius="md"><Text size="xs" c="dimmed">Active streams</Text><Text size="xl" fw={700}>{(data.activity?.response?.data?.sessions ?? []).length}</Text></Paper>
<Paper withBorder p="sm" radius="md"><Text size="xs" c="dimmed">Total bandwidth</Text><Text size="xl" fw={700}>{((data.activity?.response?.data?.sessions ?? []).reduce((sum, session) => sum + (session.bandwidth || 0), 0) / 1000000).toFixed(1)}<Text span size="xs" c="dimmed"> Mbps</Text></Text></Paper>
<Paper withBorder p="sm" radius="md"><Text size="xs" c="dimmed">Direct play</Text><Text size="xl" fw={700}>{(data.activity?.response?.data?.sessions ?? []).filter(session => session.transcode_decision === "direct play" || session.transcode_decision === "copy").length}</Text></Paper>
<Paper withBorder p="sm" radius="md"><Text size="xs" c="dimmed">Transcodes</Text><Text size="xl" fw={700}>{(data.activity?.response?.data?.sessions ?? []).filter(session => session.transcode_decision === "transcode").length}</Text></Paper>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 2 }}>{(data.activity?.response?.data?.sessions ?? []).map(session => <Paper key={session.session_key} withBorder p="sm" radius="md"><Stack gap="xs"><Group justify="space-between"><Group gap="xs"><Text size="sm" fw={600}>{session.user}</Text><Badge variant="light" color={session.state === "playing" ? "green" : session.state === "paused" ? "yellow" : "orange"}>{session.state}</Badge></Group><Badge variant="light" color="gray">{session.player}</Badge></Group><Text size="sm" lineClamp={1}>{session.full_title}</Text><Progress value={session.progress_percent} size="sm" color="blue" /><Group justify="space-between"><Group gap="xs"><Badge variant="light" color={session.transcode_decision === "transcode" ? "red" : session.transcode_decision === "copy" ? "orange" : "teal"}>{session.transcode_decision}</Badge><Badge variant="light" color="gray">{session.bandwidth ? (session.bandwidth / 1000000).toFixed(1) + " Mbps" : "—"}</Badge></Group><Badge variant="outline" color={session.location === "lan" ? "blue" : "yellow"}>{session.location}</Badge></Group></Stack></Paper>)}</SimpleGrid>
</>}
</Stack>`,
},
},
] as const;
@@ -0,0 +1,2 @@
export * from "./component-catalog";
export * from "./component-registry";
@@ -0,0 +1,55 @@
const exactDeniedComponentReasons = new Map<string, string>([
["Affix", "Escapes the widget layout boundary"],
["ColorSchemeScript", "Writes global document state"],
["Dialog", "Escapes the widget layout and focus boundary"],
["FloatingWindow", "Creates a separate browser window"],
["InlineStyles", "Writes unscoped style rules"],
["RemoveScroll", "Changes global page scrolling"],
["TableOfContents", "Observes headings and scrolling outside the widget root"],
["Transition", "Requires an authored render callback"],
["AreaGradient", "Low-level chart implementation helper"],
["HiddenDatesInput", "Internal date-input primitive"],
["PickerInputBase", "Internal date-input primitive"],
]);
const deniedComponentFamilies: ReadonlyArray<[RegExp, string]> = [
[
/^(?:AppShell|Drawer|FloatingWindow|Modal|RemoveScroll)/u,
"Escapes or replaces the widget layout, focus, scrolling, or overlay boundary",
],
[
/^(?:Combobox|Input|Menubar|OptionsDropdown|PillsInput)/u,
"Low-level composition primitive that requires authored callbacks",
],
[
/^(?:DatesProvider|DirectionProvider|HeadlessMantineProvider|MantineProvider|MantineThemeProvider)/u,
"Replaces a Homarr-owned provider boundary",
],
[/(?:Context|Provider)$/u, "Exposes or replaces an internal React context boundary"],
[/(?:Portal)$/u, "Can render outside the widget root"],
[/^(?:FileButton|FileInput)/u, "Requests local file-system access"],
[/^(?:FocusTrap)/u, "Can capture focus outside the widget interaction flow"],
];
const blockedComponentProps: Readonly<Record<string, readonly { name: string; reason: string }[]>> = {
Tooltip: [{ name: "target", reason: "String targets can select elements outside the widget root" }],
DatePickerInput: pickerModalProps(),
DateTimePicker: pickerModalProps(),
MonthPickerInput: pickerModalProps(),
YearPickerInput: pickerModalProps(),
};
function pickerModalProps() {
return [
{ name: "dropdownType", reason: "Modal picker mode escapes the widget overlay boundary" },
{ name: "modalProps", reason: "Modal picker configuration escapes the widget overlay boundary" },
] as const;
}
export function getCatalogDeniedComponentReason(name: string): string | undefined {
return exactDeniedComponentReasons.get(name) ?? deniedComponentFamilies.find(([pattern]) => pattern.test(name))?.[1];
}
export function getCatalogBlockedComponentProps(name: string) {
return blockedComponentProps[name] ?? [];
}
@@ -0,0 +1,62 @@
import type {
CustomJsxComponentCategory,
CustomJsxComponentPackage,
CustomJsxComponentSafety,
} from "./component-types";
export const CUSTOM_JSX_AUTHORING_CATALOG_SCHEMA_VERSION = 1 as const;
export const CUSTOM_WIDGET_AUTHORING_VERSION = "2.0.0";
export type CustomJsxPropSource = "global" | "component";
export type CustomJsxLiteralValue = string | number | boolean | null;
export type CustomJsxCatalogBindingType = "string" | "number" | "boolean" | "string[]" | "number[]";
export interface CustomJsxPropDescriptor {
name: string;
typeRef: number;
required: boolean;
source: CustomJsxPropSource;
literalValues?: CustomJsxLiteralValue[];
description?: string;
}
export interface CustomJsxBindingDescriptor {
type: CustomJsxCatalogBindingType;
initialProp: "defaultValue" | "defaultChecked";
}
export interface CustomJsxBlockedCapability {
kind: "prop" | "prop-pattern";
name: string;
reason: string;
}
export interface CustomJsxComponentBlockedProp {
name: string;
reason: string;
}
export interface CustomJsxComponentApi {
name: string;
package: CustomJsxComponentPackage;
category: CustomJsxComponentCategory;
safety: CustomJsxComponentSafety;
description?: string;
documentationUrl: string;
props: CustomJsxPropDescriptor[];
blockedProps: CustomJsxComponentBlockedProp[];
subcomponents: string[];
bind?: CustomJsxBindingDescriptor;
accessibilityRequirements: string[];
deniedReason?: string;
}
export interface CustomJsxAuthoringCatalog {
schemaVersion: typeof CUSTOM_JSX_AUTHORING_CATALOG_SCHEMA_VERSION;
mantineVersion: string;
customWidgetVersion: string;
types: string[];
globalProps: CustomJsxPropDescriptor[];
blockedCapabilities: CustomJsxBlockedCapability[];
components: CustomJsxComponentApi[];
}
@@ -0,0 +1 @@
{"schemaVersion":1,"mantineVersion":"9.4.1","customWidgetVersion":"2.0.0","types":["\"\" | \"anonymous\" | \"use-credentials\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"auto\" | \"fixed\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"auto\" | \"all\" | \"fill\" | \"stroke\" | \"painted\" | \"visible\" | \"visibleFill\" | \"visiblePainted\" | \"visibleStroke\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"block\" | \"inline\" | \"both\" | \"horizontal\" | \"vertical\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"contain\" | \"cover\" | \"fill\" | \"scale-down\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"wrap\" | \"nowrap\" | \"wrap-reverse\"","\"12h\" | \"24h\"","\"activity\" | \"display-none\"","\"all\" | \"parent\" | \"child\"","\"all\" | \"segment\"","\"always\" | \"hover\" | \"not-hover\" | \"never\"","\"area\" | \"radial\" | \"scatter\"","\"auto\" | \"scroll\" | \"always\" | \"hover\" | \"never\"","\"auto-fit\" | \"auto-fill\"","\"block\" | \"grid\"","\"both\" | \"rightSection\" | \"clear\"","\"bottom\" | \"top\"","\"bottom\" | \"top\" | \"baseline\" | \"middle\"","\"bottom\" | \"top\" | \"middle\"","\"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\" | \"bottom-center\" | \"top-center\" | \"middle-center\" | \"middle-end\" | \"middle-start\"","\"button\" | \"submit\" | \"reset\"","\"center\" | \"bottom\"","\"center\" | \"left\" | \"right\"","\"center\" | \"left\" | \"right\" | \"justify\" | \"char\"","\"center\" | \"side\" | \"merge\"","\"checkbox\" | \"radio\"","\"dashed\" | \"dotted\" | \"solid\"","\"default\" | \"gradient\"","\"default\" | \"responsive\"","\"default\" | \"stacked\" | \"percent\" | \"split\"","\"default\" | \"stacked\" | \"percent\" | \"waterfall\"","\"div\" | \"label\"","\"end\" | \"start\"","\"filled\" | \"light\"","\"fixed\" | \"absolute\"","\"hex\" | \"hexa\" | \"rgba\" | \"rgb\" | \"hsl\" | \"hsla\"","\"horizontal\" | \"vertical\"","\"hover\" | \"click\" | \"click-hover\"","\"inherit\" | \"auto\" | \"baseline\" | \"before-edge\" | \"text-before-edge\" | \"middle\" | \"central\" | \"after-edge\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\"","\"inherit\" | \"auto\" | \"sRGB\" | \"linearRGB\"","\"inherit\" | \"auto\" | \"text-before-edge\" | \"middle\" | \"central\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\" | \"use-script\" | \"no-change\" | \"reset-size\"","\"inherit\" | \"end\" | \"start\" | \"middle\"","\"inherit\" | \"nonzero\" | \"evenodd\"","\"inherit\" | \"round\" | \"butt\" | \"square\"","\"inherit\" | \"round\" | \"miter\" | \"bevel\"","\"initial\" | \"medial\" | \"terminal\" | \"isolated\"","\"inside\" | \"outside\"","\"left\" | \"right\"","\"left\" | \"right\" | \"bottom\" | \"top\" | \"left-end\" | \"left-start\" | \"right-end\" | \"right-start\" | \"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\"","\"left\" | \"right\" | \"inside\"","\"link\" | \"none\" | \"copy\" | \"execute\" | \"move\" | \"popup\"","\"media\" | \"container\"","\"month\" | \"year\" | \"decade\"","\"native\" | \"scrollarea\"","\"none\" | \"all\" | \"rows\" | \"groups\" | \"columns\"","\"none\" | \"all\" | \"with-indicators\"","\"none\" | \"ascending\" | \"descending\" | \"other\"","\"none\" | \"inline\" | \"list\" | \"both\"","\"none\" | \"strict\" | \"blur\"","\"none\" | \"sum\"","\"none\" | \"thousand\" | \"lakh\" | \"wan\"","\"none\" | \"x\" | \"y\" | \"xy\"","\"number\" | RegExp | \"alphanumeric\"","\"off\" | \"assertive\" | \"polite\"","\"off\" | \"on\"","\"ordered\" | \"unordered\"","\"right-to-left\" | \"left-to-right\"","\"search\" | \"enter\" | \"done\" | \"go\" | \"next\" | \"previous\" | \"send\"","\"search\" | \"text\" | \"none\" | \"tel\" | \"url\" | \"email\" | \"numeric\" | \"decimal\"","\"step\" | \"bump\" | \"linear\" | \"natural\" | \"monotone\" | \"stepBefore\" | \"stepAfter\"","\"stepInactive\" | \"stepProgress\" | \"stepCompleted\"","\"sum\" | \"replace\"","\"td\" | \"th\"","\"text\" | \"additions\" | \"additions removals\" | \"additions text\" | \"all\" | \"removals\" | \"removals additions\" | \"removals text\" | \"text additions\" | \"text removals\"","\"text\" | \"tel\" | \"password\"","\"time\" | \"duration\"","\"up\" | \"down\"","\"value\" | \"name\" | \"percent\"","\"viewport\"","\"wrap\" | \"nowrap\" | \"balance\" | \"pretty\" | \"stable\"","\"x\" | \"y\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\" | (string & {})","\"yes\" | \"no\"","'Text' | 'Title' | 'Badge' | 'Code' | 'Image'","'auto' | 'manual'","'json' | 'text'","'xs' | 'sm' | 'md' | 'lg' | 'xl'","(\"input\" | \"label\" | \"description\" | \"error\")[]","(\"next\" | \"previLine truncated
@@ -0,0 +1,53 @@
import catalog from "./component-catalog.generated.json";
import type { CustomJsxAuthoringCatalog, CustomJsxPropDescriptor } from "./component-catalog-types";
export type * from "./component-catalog-types";
export const customJsxAuthoringCatalog = catalog as unknown as Readonly<CustomJsxAuthoringCatalog>;
export const customJsxCatalogComponentByName = new Map(
customJsxAuthoringCatalog.components.map((component) => [component.name, component]),
);
export const customJsxCatalogGlobalPropByName = new Map(
customJsxAuthoringCatalog.globalProps.map((prop) => [prop.name, prop]),
);
export interface ResolvedCustomJsxPropDescriptor extends Omit<CustomJsxPropDescriptor, "typeRef"> {
type: string;
}
export function getCustomJsxPropType(
prop: Pick<CustomJsxPropDescriptor, "typeRef">,
catalogValue: Readonly<CustomJsxAuthoringCatalog> = customJsxAuthoringCatalog,
): string {
return catalogValue.types[prop.typeRef] ?? "unknown";
}
export function resolveCustomJsxPropDescriptor(
prop: CustomJsxPropDescriptor,
catalogValue: Readonly<CustomJsxAuthoringCatalog> = customJsxAuthoringCatalog,
): ResolvedCustomJsxPropDescriptor {
const { typeRef, ...metadata } = prop;
return { ...metadata, type: catalogValue.types[typeRef] ?? "unknown" };
}
export function getCustomJsxComponentProps(name: string): ResolvedCustomJsxPropDescriptor[] {
const component = customJsxCatalogComponentByName.get(name);
if (!component || component.safety === "denied") return [];
const blockedProps = new Set(component.blockedProps.map(({ name: propName }) => propName));
const props = new Map(
customJsxAuthoringCatalog.globalProps
.filter((prop) => !blockedProps.has(prop.name))
.map((prop) => [prop.name, resolveCustomJsxPropDescriptor(prop)]),
);
for (const prop of component.props) props.set(prop.name, resolveCustomJsxPropDescriptor(prop));
if (component.bind) {
props.set("bind", {
name: "bind",
type: "string",
required: false,
source: "component",
description: `Temporary in-memory ${component.bind.type} input binding initialized by ${component.bind.initialProp}.`,
});
}
return [...props.values()];
}
@@ -0,0 +1,53 @@
import type {
CustomJsxComponentCategory,
CustomJsxComponentPackage,
CustomJsxComponentSafety,
} from "./component-types";
export type * from "./component-types";
export interface CustomJsxComponentDescriptor {
name: string;
package: CustomJsxComponentPackage;
safety: CustomJsxComponentSafety;
supportedProps: readonly string[];
blockedProps: readonly { name: string; reason: string }[];
reason?: string;
}
export const subcomponentsByName: Readonly<Record<string, readonly string[]>> = {
Accordion: ["Accordion.Item", "Accordion.Control", "Accordion.Panel"],
Avatar: ["Avatar.Group"],
Card: ["Card.Section"],
Chip: ["Chip.Group"],
DataList: ["DataList.Item", "DataList.ItemLabel", "DataList.ItemValue"],
EmptyState: ["EmptyState.Indicator", "EmptyState.Title", "EmptyState.Description", "EmptyState.Actions"],
Grid: ["Grid.Col"],
List: ["List.Item"],
Menu: ["Menu.Target", "Menu.Dropdown", "Menu.Item", "Menu.Label", "Menu.Divider"],
Progress: ["Progress.Section"],
Table: ["Table.Thead", "Table.Tbody", "Table.Tfoot", "Table.Caption", "Table.Tr", "Table.Th", "Table.Td"],
Tabs: ["Tabs.List", "Tabs.Tab", "Tabs.Panel"],
Timeline: ["Timeline.Item"],
};
export const accessibilityByCategory: Record<CustomJsxComponentCategory, readonly string[]> = {
layout: ["Keep visual order consistent with reading order."],
content: ["Provide meaningful text alternatives for non-text content."],
feedback: ["Do not rely on color alone to communicate status."],
navigation: ["Provide an accessible label for controls without visible text."],
charts: ["Include a nearby text summary of the chart data."],
dates: ["Include a textual date when the visual calendar carries meaning."],
interaction: ["Provide a visible label or aria-label."],
network: ["Action labels must describe their effect; destructive actions require confirmation."],
blocked: [],
};
export function documentationUrl(packageName: CustomJsxComponentPackage, name: string): string {
if (packageName === "@homarr/widgets") return "https://homarr.dev/docs/management/custom-widgets/";
const section = packageName === "@mantine/core" ? "core" : packageName === "@mantine/charts" ? "charts" : "dates";
const slug = (name.split(".")[0] ?? "")
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
.toLowerCase();
return `https://mantine.dev/${section}/${slug}/`;
}
@@ -0,0 +1,92 @@
import * as Charts from "@mantine/charts";
import * as Core from "@mantine/core";
import * as Dates from "@mantine/dates";
import { subcomponentsByName } from "./component-descriptor";
import type { CustomJsxComponentCategory, CustomJsxComponentPackage } from "./component-types";
type Namespace = Readonly<Record<string, unknown>>;
const packageDefinitions: ReadonlyArray<{
package: CustomJsxComponentPackage;
namespace: Namespace;
}> = [
{ package: "@mantine/core", namespace: Core as Namespace },
{ package: "@mantine/dates", namespace: Dates as Namespace },
{ package: "@mantine/charts", namespace: Charts as Namespace },
];
function looksLikePublicComponent(name: string, value: unknown) {
if (!/^[A-Z][A-Za-z0-9]*$/u.test(name) || !/[a-z]/u.test(name)) return false;
if (typeof value === "function") return true;
if (value === null || typeof value !== "object") return false;
const reactType = (value as { $$typeof?: unknown }).$$typeof;
return reactType === Symbol.for("react.forward_ref") || reactType === Symbol.for("react.memo");
}
function inferCoreCategory(name: string): CustomJsxComponentCategory {
if (
/^(?:Alert|Loader|LoadingOverlay|Notification|Overlay|Progress|RingProgress|SemiCircleProgress|Skeleton)/u.test(
name,
)
) {
return "feedback";
}
if (
/^(?:Accordion|Anchor|Breadcrumbs|Collapse|HoverCard|Menu|NavLink|Popover|Spoiler|Stepper|Tabs|Tooltip|Transition|Tree)/u.test(
name,
)
) {
return "navigation";
}
if (
/(?:Button|Checkbox|Chip|Input|Pagination|Picker|Radio|Rating|Select|Slider|Switch)$/u.test(name) ||
/^(?:ActionIcon|Autocomplete|Burger|CloseButton|CopyButton|MultiSelect|NumberInput|PasswordInput|PinInput|SegmentedControl|TagsInput|TextInput|Textarea)/u.test(
name,
)
) {
return "interaction";
}
if (
/^(?:AspectRatio|Box|Card|Center|Container|Divider|Fieldset|Flex|FloatingIndicator|Grid|Group|Paper|ScrollArea|SimpleGrid|Space|Splitter|Stack|Typography)/u.test(
name,
)
) {
return "layout";
}
return "content";
}
export interface DiscoveredMantineComponent {
name: string;
package: CustomJsxComponentPackage;
category: CustomJsxComponentCategory;
}
export const discoveredSubcomponentsByName: Readonly<Record<string, readonly string[]>> = {};
export const discoveredMantineComponents: readonly DiscoveredMantineComponent[] = packageDefinitions.flatMap(
({ package: packageName, namespace }) =>
Object.entries(namespace).flatMap(([name, value]) => {
if (!looksLikePublicComponent(name, value)) return [];
const category =
packageName === "@mantine/charts"
? "charts"
: packageName === "@mantine/dates"
? "dates"
: inferCoreCategory(name);
const reflectedSubcomponents =
value && (typeof value === "object" || typeof value === "function")
? Object.entries(value).flatMap(([property, child]) =>
looksLikePublicComponent(property, child) ? [`${name}.${property}`] : [],
)
: [];
const subcomponents = [...new Set([...(subcomponentsByName[name] ?? []), ...reflectedSubcomponents])];
(discoveredSubcomponentsByName as Record<string, readonly string[]>)[name] = subcomponents;
return [name, ...subcomponents].map((componentName) => ({
name: componentName,
package: packageName,
category,
}));
}),
);
@@ -0,0 +1,46 @@
import type {
CustomJsxAuthoringCatalog,
CustomJsxCatalogBindingType,
CustomJsxComponentApi,
CustomJsxPropDescriptor,
} from "./component-catalog-types";
export function buildCustomJsxComponentUsageExample(
component: CustomJsxComponentApi,
catalog: Pick<CustomJsxAuthoringCatalog, "types">,
) {
const props = component.props
.filter((prop) => prop.required)
.slice(0, 4)
.map((prop) => `${prop.name}=${examplePropValue(prop, catalog.types[prop.typeRef] ?? "unknown")}`);
if (component.bind) {
props.unshift(
`bind="value"`,
`${component.bind.initialProp}=${bindingInitialValue(component.bind.type, component.bind.initialProp)}`,
);
}
const propText = props.length > 0 ? ` ${props.join(" ")}` : "";
return `<${component.name}${propText} />`;
}
function bindingInitialValue(type: CustomJsxCatalogBindingType, initialProp: "defaultValue" | "defaultChecked") {
if (initialProp === "defaultChecked") return "{false}";
if (type === "string") return '""';
if (type === "number") return "{0}";
if (type === "boolean") return "{false}";
return "{[]}";
}
function examplePropValue(prop: CustomJsxPropDescriptor, type: string) {
const literal = prop.literalValues?.[0];
if (literal !== undefined) return `{${formatLiteral(literal)}}`;
if (type.includes("string")) return `"${prop.name}"`;
if (type.includes("boolean")) return "{true}";
if (type.includes("number")) return "{0}";
if (type.includes("[]") || type.includes("Array<")) return "{[]}";
return `{options.${prop.name}}`;
}
function formatLiteral(value: string | number | boolean | null) {
return typeof value === "string" ? JSON.stringify(value) : String(value);
}
@@ -0,0 +1,54 @@
export const commonSafeProps = [
"key",
"id",
"style",
"styles",
"title",
"role",
"aria-label",
"aria-describedby",
"aria-hidden",
"tabIndex",
"w",
"h",
"miw",
"maw",
"mih",
"mah",
"m",
"mx",
"my",
"mt",
"mr",
"mb",
"ml",
"p",
"px",
"py",
"pt",
"pr",
"pb",
"pl",
"bg",
"c",
"opacity",
"display",
"hiddenFrom",
"visibleFrom",
"bd",
"bdrs",
"flex",
"fz",
"fw",
"lh",
"lts",
"ta",
"td",
"tt",
"radius",
"shadow",
"variant",
"color",
"size",
"withBorder",
] as const;
@@ -0,0 +1,78 @@
import { customJsxAuthoringCatalog } from "./component-catalog";
import type { CustomJsxCatalogBindingType } from "./component-catalog-types";
import type { CustomJsxComponentDescriptor } from "./component-descriptor";
const globalPropNames = customJsxAuthoringCatalog.globalProps.map(({ name }) => name);
export const customJsxComponentRegistry: readonly CustomJsxComponentDescriptor[] =
customJsxAuthoringCatalog.components.map((component) => ({
name: component.name,
package: component.package,
safety: component.safety,
supportedProps:
component.safety === "denied"
? []
: [
...new Set([
...globalPropNames.filter(
(propName) => !component.blockedProps.some((blockedProp) => blockedProp.name === propName),
),
...component.props.map(({ name }) => name),
...(component.bind ? ["bind"] : []),
]),
],
blockedProps: component.blockedProps,
...(component.deniedReason ? { reason: component.deniedReason } : {}),
}));
export const enabledCustomJsxComponents = customJsxComponentRegistry.filter(
(component) => component.safety !== "denied",
);
export const customJsxComponentByName = new Map(customJsxComponentRegistry.map((entry) => [entry.name, entry]));
export const customJsxSupportedPropsByName = new Map(
customJsxComponentRegistry.map((entry) => [entry.name, new Set(entry.supportedProps)]),
);
export function resolveCustomJsxComponentName(name: string): string {
if (name === "Icon") return "TablerIcon";
if (customJsxComponentByName.has(name)) return name;
const flat = name.replaceAll(".", "");
return customJsxComponentByName.has(flat) ? flat : name;
}
const catalogComponentsByName = new Map(
customJsxAuthoringCatalog.components.map((component) => [component.name, component]),
);
const multipleDateBindingComponents = new Set([
"DatePicker",
"DatePickerInput",
"MonthPicker",
"MonthPickerInput",
"YearPicker",
"YearPickerInput",
]);
const rangeDateBindingComponents = new Set(["DateTimePicker", "InlineDateTimePicker"]);
export const customJsxBindableComponentNames: ReadonlySet<string> = new Set(
customJsxAuthoringCatalog.components.flatMap((component) => (component.bind ? [component.name] : [])),
);
export type CustomJsxBindingType = CustomJsxCatalogBindingType;
export function getCustomJsxBindingType(
componentName: string,
props: Readonly<Record<string, unknown>> = {},
): CustomJsxBindingType | null {
componentName = resolveCustomJsxComponentName(componentName);
const type = catalogComponentsByName.get(componentName)?.bind?.type;
if (!type) return null;
if (multipleDateBindingComponents.has(componentName) && ["multiple", "range"].includes(String(props.type))) {
return "string[]";
}
if (rangeDateBindingComponents.has(componentName) && props.type === "range") return "string[]";
if (componentName === "Accordion" && props.multiple === true) return "string[]";
if (componentName === "TreeSelect" && ["multiple", "checkbox"].includes(String(props.mode))) return "string[]";
if (["Chip.Group", "ChipGroup"].includes(componentName) && props.multiple === true) return "string[]";
return type;
}
@@ -0,0 +1,12 @@
export type CustomJsxComponentPackage = "@mantine/core" | "@mantine/charts" | "@mantine/dates" | "@homarr/widgets";
export type CustomJsxComponentSafety = "allowed" | "wrapped" | "denied";
export type CustomJsxComponentCategory =
| "layout"
| "content"
| "feedback"
| "navigation"
| "charts"
| "dates"
| "interaction"
| "network"
| "blocked";
@@ -0,0 +1,206 @@
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 {
collectCustomWidgetRequestReferences,
customWidgetIdentifierSchema,
customWidgetRequestsSchema,
customWidgetSourcesSchema,
} from "./request-schema";
import type { CustomJsxRequest } from "./request-schema";
import { customWidgetSecretKinds } from "./schema-types";
import { getCustomWidgetHttpUrlIssue } from "./url-policy";
export * from "./options-schema";
export * from "./request-schema";
export const CUSTOM_WIDGET_SCHEMA = "homarr-custom-widget-v2";
export const customWidgetBindingIdentifierSchema = z
.string()
.min(1)
.max(64)
.regex(
CUSTOM_JSX_BINDING_IDENTIFIER_PATTERN,
"Binding names must start with a letter and contain letters, numbers, - or _",
);
export const customJsxTemplateSchema = z
.string()
.min(1)
.max(50_000)
.superRefine((template, ctx) => {
for (const diagnostic of validateCustomJsxTemplate(template)) {
if (diagnostic.severity === "error") {
ctx.addIssue({
code: "custom",
message: `${diagnostic.message} (line ${diagnostic.line}, column ${diagnostic.column})`,
});
}
}
});
export const customWidgetSecretInputSchema = z.strictObject({
sourceId: customWidgetIdentifierSchema,
kind: z.enum(customWidgetSecretKinds),
value: z.string().min(1).max(8_192),
});
export const customWidgetSecretsInputSchema = z
.array(customWidgetSecretInputSchema)
.max(24)
.superRefine((secrets, ctx) => {
const identities = new Set<string>();
secrets.forEach((secret, index) => {
const identity = `${secret.sourceId}:${secret.kind}`;
if (identities.has(identity))
ctx.addIssue({ code: "custom", path: [index], message: "A widget secret can only be supplied once" });
identities.add(identity);
});
});
export const customWidgetDefinitionSchema = z
.strictObject({
$schema: z.literal(CUSTOM_WIDGET_SCHEMA),
name: z.string().trim().min(1).max(128),
description: z.string().max(512).optional(),
iconUrl: z
.string()
.refine(
(value) => getCustomWidgetHttpUrlIssue(value) === null,
"Widget icons must use an unambiguous HTTP or HTTPS URL without embedded credentials",
)
.url()
.max(2048)
.optional(),
sources: customWidgetSourcesSchema,
requests: customWidgetRequestsSchema,
options: customWidgetOptionsSchema,
template: customJsxTemplateSchema,
})
.superRefine((definition, ctx) => {
validateRequests(definition, ctx);
validateCaseInsensitiveSourceIds(definition.sources, ctx);
validateTemplateRequests(definition.template, definition.requests, ctx);
validateCredentialFreeExport(definition, ctx);
validatePrototypeKeys(definition, ctx);
});
type Definition = z.infer<typeof customWidgetDefinitionSchema>;
function validateRequests(definition: Definition, ctx: z.RefinementCtx) {
for (const [requestId, request] of Object.entries(definition.requests)) {
if (!definition.sources[request.source]) {
ctx.addIssue({
code: "custom",
path: ["requests", requestId, "source"],
message: `Unknown source '${request.source}'`,
});
}
const references = collectCustomWidgetRequestReferences(request);
for (const optionName of references.options) {
if (!definition.options[optionName]) {
ctx.addIssue({
code: "custom",
path: ["requests", requestId],
message: `Unknown widget option '${optionName}'`,
});
}
}
if (request.trigger === "load" && references.params.size > 0) {
ctx.addIssue({
code: "custom",
path: ["requests", requestId],
message: `Load requests cannot use invocation parameters: ${[...references.params].join(", ")}`,
});
}
request.invalidates?.forEach((target, index) => {
if (definition.requests[target]?.kind !== "query") {
ctx.addIssue({
code: "custom",
path: ["requests", requestId, "invalidates", index],
message: `Invalidation target '${target}' must be a query`,
});
}
});
}
for (const [optionName, option] of Object.entries(definition.options)) {
const requestId = option.choicesFrom?.request;
if (requestId && definition.requests[requestId]?.kind !== "query") {
ctx.addIssue({
code: "custom",
path: ["options", optionName, "choicesFrom", "request"],
message: `Dynamic choices require a query named '${requestId}'`,
});
}
if (requestId) {
const request = definition.requests[requestId];
const references = request ? collectCustomWidgetRequestReferences(request) : null;
if (request && (request.trigger !== "load" || (references?.params.size ?? 0) > 0)) {
ctx.addIssue({
code: "custom",
path: ["options", optionName, "choicesFrom", "request"],
message: "Dynamic choices require a load query without invocation parameters",
});
}
}
}
}
function validateCaseInsensitiveSourceIds(sources: Definition["sources"], ctx: z.RefinementCtx) {
const seen = new Map<string, string>();
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<string, CustomJsxRequest>, ctx: z.RefinementCtx) {
for (const match of template.matchAll(/<(SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/gu)) {
const component = match[1] as "SubFetch" | "ActionButton" | "ToggleSwitch";
const attributes = match[2] ?? "";
const idMatch = attributes.match(/\brequestId\s*=\s*(?:"([^"]+)"|'([^']+)')/u);
const requestId = idMatch?.[1] ?? idMatch?.[2];
if (!requestId) {
ctx.addIssue({ code: "custom", path: ["template"], message: `${component} must use a literal requestId` });
continue;
}
const request = requests[requestId];
const expectedKind = component === "SubFetch" ? "query" : "action";
if (!request)
ctx.addIssue({
code: "custom",
path: ["template"],
message: `${component} references unknown request '${requestId}'`,
});
else if (request.kind !== expectedKind)
ctx.addIssue({ code: "custom", path: ["template"], message: `${component} requires a ${expectedKind} request` });
else if (component === "SubFetch" && request.trigger !== "manual")
ctx.addIssue({ code: "custom", path: ["template"], message: `SubFetch requires '${requestId}' to be manual` });
}
}
export type HomarrCustomWidgetV2 = z.infer<typeof customWidgetDefinitionSchema>;
export type HomarrCustomWidgetV2Input = z.input<typeof customWidgetDefinitionSchema>;
export const customWidgetCreateSchema = customWidgetDefinitionSchema.safeExtend({
secrets: customWidgetSecretsInputSchema.default([]),
});
export const customWidgetUpdateSchema = z
.strictObject(customWidgetDefinitionSchema.shape)
.partial()
.extend({ id: z.string().min(1), secrets: customWidgetSecretsInputSchema.optional() });
export const customWidgetImportSchema = customWidgetDefinitionSchema;
export type CustomWidgetCreateInput = z.infer<typeof customWidgetCreateSchema>;
export type CustomWidgetUpdateInput = z.infer<typeof customWidgetUpdateSchema>;
export type CustomWidgetImport = z.infer<typeof customWidgetImportSchema>;
Loaded 100 of 940 files, more files were not shown because too many files have changed in this diff. Show more