merge: stack assistant on mobile boards

This commit is contained in:
Thomas Camlong committed 2026-08-08 21:27:56 +02:00
commit 9fd7a8a05b
1326 files changed
+131355 -46380

No files matched your search

+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,
},
});
+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
@@ -25,7 +25,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)),
File diff suppressed because it is too large. Load diff
@@ -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,
};
}
@@ -1,186 +0,0 @@
export interface GridAlgorithmItem {
id: string;
type: "item" | "section";
width: number;
height: number;
xOffset: number;
yOffset: number;
sectionId: string;
}
interface GridAlgorithmInput {
items: GridAlgorithmItem[];
width: number;
previousWidth: number;
sectionId: string;
}
interface GridAlgorithmOutput {
height: number;
items: GridAlgorithmItem[];
}
export const generateResponsiveGridFor = ({
items,
previousWidth,
width,
sectionId,
}: GridAlgorithmInput): GridAlgorithmOutput => {
const itemsOfCurrentSection = items
.filter((item) => item.sectionId === sectionId)
.sort((itemA, itemB) =>
itemA.yOffset === itemB.yOffset ? itemA.xOffset - itemB.xOffset : itemA.yOffset - itemB.yOffset,
);
const normalizedItems = normalizeItems(itemsOfCurrentSection, width);
if (itemsOfCurrentSection.length === 0) {
return {
height: 0,
items: [],
};
}
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) {
const result = generateResponsiveGridFor({
items,
previousWidth: dynamicSection.previousWidth,
width: dynamicSection.width,
sectionId: dynamicSection.id,
});
newItems.push(...result.items);
dynamicSectionHeightMap.set(dynamicSection.id, result.height);
}
// Return same positions for items in the current section
if (width >= previousWidth) {
return {
height: Math.max(...itemsOfCurrentSection.map((item) => item.yOffset + item.height)),
items: newItems.concat(normalizedItems),
};
}
const occupied2d: boolean[][] = [];
for (const item of normalizedItems) {
const itemWithHeight = {
...item,
height: item.type === "section" ? Math.max(dynamicSectionHeightMap.get(item.id) ?? 1, item.height) : item.height,
};
const position = nextFreeSpot(occupied2d, itemWithHeight, width);
if (!position) throw new Error("No free spot available");
addItemToOccupied(occupied2d, itemWithHeight, position, width);
newItems.push({
...itemWithHeight,
xOffset: position.x,
yOffset: position.y,
});
}
return {
height: occupied2d.length,
items: newItems,
};
};
/**
* Reduces the width of the items to fit the new column count.
* @param items items to normalize
* @param columnCount new column count
*/
const normalizeItems = (items: GridAlgorithmItem[], columnCount: number) => {
return items.map((item) => ({ ...item, previousWidth: item.width, width: Math.min(columnCount, item.width) }));
};
/**
* Adds the item to the occupied spots.
* @param occupied2d array of occupied spots
* @param item item to place
* @param position position to place the item
*/
const addItemToOccupied = (
occupied2d: boolean[][],
item: GridAlgorithmItem,
position: { x: number; y: number },
columnCount: number,
) => {
for (let yOffset = 0; yOffset < item.height; yOffset++) {
let row = occupied2d[position.y + yOffset];
if (!row) {
addRow(occupied2d, columnCount);
// After adding it, it must exist
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
row = occupied2d[position.y + yOffset]!;
}
for (let xOffset = 0; xOffset < item.width; xOffset++) {
row[position.x + xOffset] = true;
}
}
};
/**
* Adds a new row to the grid.
* @param occupied2d array of occupied spots
* @param columnCount column count of section
*/
const addRow = (occupied2d: boolean[][], columnCount: number) => {
occupied2d.push(new Array<boolean>(columnCount).fill(false));
};
/**
* Searches for the next free spot in the grid.
* @param occupied2d array of occupied spots
* @param item item to place
* @param columnCount column count of section
* @returns the position of the next free spot or null if no spot is available
*/
const nextFreeSpot = (occupied2d: boolean[][], item: GridAlgorithmItem, columnCount: number) => {
for (let offsetY = 0; offsetY < 99999; offsetY++) {
for (let offsetX = 0; offsetX < columnCount; offsetX++) {
if (hasHorizontalSpace(columnCount, item, offsetX) && isFree(occupied2d, item, { x: offsetX, y: offsetY })) {
return { x: offsetX, y: offsetY };
}
}
}
return null;
};
/**
* Check if the item fits into the grid horizontally.
* @param columnCount available width
* @param item item to place
* @param offsetX current x position
* @returns true if the item fits horizontally
*/
const hasHorizontalSpace = (columnCount: number, item: GridAlgorithmItem, offsetX: number) => {
return offsetX + item.width <= columnCount;
};
/**
* Check if the spot is free.
* @param occupied2d array of occupied spots
* @param item item to place
* @param position position to check
* @returns true if the spot is free
*/
const isFree = (occupied2d: boolean[][], item: GridAlgorithmItem, position: { x: number; y: number }) => {
for (let yOffset = 0; yOffset < item.height; yOffset++) {
const row = occupied2d[position.y + yOffset];
if (!row) return true; // Empty row is free
for (let xOffset = 0; xOffset < item.width; xOffset++) {
if (row[position.x + xOffset]) {
return false;
}
}
}
return true;
};
@@ -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();
});
});
@@ -1,8 +1,8 @@
import { createId } from "@paralleldrive/cuid2";
import { describe, expect, test } from "vitest";
import type { GridAlgorithmItem } from "../grid-algorithm";
import { generateResponsiveGridFor } from "../grid-algorithm";
import { generateResponsiveGridFor } from "@homarr/common";
import type { GridAlgorithmItem } from "@homarr/common";
const ROOT_SECTION_ID = "section";
@@ -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(
@@ -78,6 +78,26 @@ describe("Grid Algorithm", () => {
);
},
);
test("stops projecting a corrupted recursive section graph", () => {
const recursiveSection = algoItem({ id: "recursive", type: "section", width: 3 });
const duplicateRecursiveLayout = algoItem({
id: "recursive",
type: "section",
width: 3,
sectionId: "recursive",
});
const result = generateResponsiveGridFor({
items: [recursiveSection, duplicateRecursiveLayout],
width: 3,
previousWidth: 12,
sectionId: ROOT_SECTION_ID,
});
expect(result.items).toContainEqual(expect.objectContaining({ id: "recursive", sectionId: ROOT_SECTION_ID }));
expect(result.items).toHaveLength(2);
});
});
const algoItem = (item: Partial<GridAlgorithmItem>): GridAlgorithmItem => ({
@@ -192,7 +212,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";
@@ -0,0 +1,45 @@
import { TRPCError } from "@trpc/server";
import { parse } from "superjson";
import { z } from "zod/v4";
import type { Session } from "@homarr/auth";
import type { Database } from "@homarr/db";
import { and, eq } from "@homarr/db";
import { boards, items } from "@homarr/db/schema";
import type { BoardPermission } from "@homarr/definitions";
import { throwIfActionForbiddenAsync } from "../board/board-access";
export const customApiItemInputSchema = z.object({
boardId: z.string().min(1).max(100),
itemId: z.string().min(1).max(100),
definitionId: z.string().min(1).max(100),
});
const customApiOptionsSchema = z.object({ definitionId: z.string() }).passthrough();
export async function assertCustomApiItemBindingAsync(
ctx: { db: Database; session: Session | null },
input: z.infer<typeof customApiItemInputSchema>,
permission?: BoardPermission,
) {
const item = await ctx.db.query.items.findFirst({
columns: { options: true },
where: and(eq(items.id, input.itemId), eq(items.boardId, input.boardId), eq(items.kind, "customApi")),
});
if (!item) throwCustomApiWidgetNotFound();
if (permission) await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), permission);
try {
const options = customApiOptionsSchema.parse(parse<unknown>(item.options));
if (options.definitionId !== input.definitionId) throwCustomApiWidgetNotFound();
} catch (error) {
if (error instanceof TRPCError) throw error;
throwCustomApiWidgetNotFound();
}
}
function throwCustomApiWidgetNotFound(): never {
throw new TRPCError({ code: "NOT_FOUND", message: "Custom API widget not found" });
}
@@ -1,228 +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 { 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 { applyAuth } from "./auth";
import { extractDisplayDataWithFallback } from "./display-data";
import { parseDisplayConfig } from "./parse-display-config";
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
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 {
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 FETCH_TIMEOUT_MS = 10_000;
const validateUrl = (urlString: string): URL => new URL(urlString);
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: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"List custom widget definitions. Returns IDs, names, endpoints, request methods, display types, authentication types, and enabled states.",
},
})
.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,
}));
}),
byId: protectedProcedure
.input(z.object({ id: z.string() }))
.meta({
mcp: {
enabled: true,
description:
"Get one custom widget definition by ID, including its display configuration and flags indicating which secrets are configured. Secret values are never returned.",
},
})
.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
create: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Create one validated Custom JSX widget." } })
.input(customWidgetCreateSchema)
.meta({
mcp: {
enabled: true,
description:
"Create a custom widget definition. Admin only. Validate that displayConfig.type matches displayType. Pass secrets as an empty array unless the user explicitly provided new secret values.",
},
})
.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 });
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 };
}),
update: adminProcedure
update: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Update one Custom JSX widget." } })
.input(customWidgetUpdateSchema)
.meta({
mcp: {
enabled: true,
description:
"Update a custom widget definition by ID. Admin only. Call customWidget_byId first, send only the fields that should change, and omit secrets to preserve stored secret values.",
},
})
.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] : [];
}),
);
const { id, secrets, ...updateFields } = input;
const updateValues: Record<string, unknown> = { updatedAt: new Date() };
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));
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 transaction
.delete(schema.customWidgetSecrets)
.where(
and(
eq(schema.customWidgetSecrets.definitionId, id),
notInArray(schema.customWidgetSecrets.sourceId, sourceIds),
),
);
await ctx.db.update(customWidgetDefinitions).set(updateValues).where(eq(customWidgetDefinitions.id, id));
for (const sourceId of changedSecretBindings) {
await transaction
.delete(schema.customWidgetSecrets)
.where(
and(
eq(schema.customWidgetSecrets.definitionId, id),
eq(schema.customWidgetSecrets.sourceId, sourceId),
),
);
}
if (secrets !== undefined) {
const effectiveAuthType = (updateFields.authType as string | undefined) ?? existing.authType;
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);
}
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));
}
}
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 });
}),
toggleEnabled: adminProcedure
...secretProcedures,
...workshopProcedures,
toggleEnabled: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ id: z.string(), enabled: z.boolean() }))
.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 (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
@@ -231,261 +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 }) => {
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 = validateUrl(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);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(url.toString(), {
method: input.method,
headers,
body: input.method !== "GET" ? input.requestBody : undefined,
redirect: "follow",
signal: controller.signal,
});
const responseInfo = { status: response.status, statusText: response.statusText };
if (!response.ok) {
const body = await response.text().catch(() => "");
return {
success: false as const,
error: `HTTP ${response.status}: ${response.statusText}`,
responseInfo,
rawResponse: body,
};
}
const json: unknown = await response.json();
const displayData = extractDisplayDataWithFallback(json, input.displayType, input.displayConfig);
return {
success: true as const,
responseInfo,
rawResponse: JSON.stringify(json, null, 2),
displayData,
};
} catch (error) {
if (error instanceof TRPCError) throw error;
logger.error("Custom widget preview failed", { error });
return {
success: false as const,
error: error instanceof Error ? error.message : "Failed to fetch data",
responseInfo: null,
rawResponse: null,
};
} finally {
clearTimeout(timeout);
}
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 },
});
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: protectedProcedure.input(z.object({ definitionId: z.string() })).mutation(async ({ 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" });
}
const decryptedSecrets = definition.secrets.map((s) => ({
kind: s.kind,
value: decryptSecret(s.value),
}));
const url = validateUrl(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);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(url.toString(), {
method: definition.method,
headers,
body: definition.method !== "GET" ? definition.requestBody : undefined,
redirect: "follow",
signal: controller.signal,
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)` };
}),
const responseInfo = { status: response.status, statusText: response.statusText };
if (!response.ok) {
return { success: false as const, error: `HTTP ${response.status}: ${response.statusText}`, responseInfo };
}
logger.info("Executed custom widget action", { definitionId: input.definitionId, status: response.status });
return { success: true as const, responseInfo };
} catch (error) {
if (error instanceof TRPCError) throw error;
logger.error("Custom widget execute failed", { definitionId: input.definitionId, error });
return {
success: false as const,
error: error instanceof Error ? error.message : "Request failed",
responseInfo: null,
};
} finally {
clearTimeout(timeout);
}
}),
...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,145 +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) => ({
type: "actionButton",
buttonLabel: c.buttonLabel ?? "Execute",
buttonColor: c.buttonColor ?? "blue",
confirmText: c.confirmText ?? "",
successMessage: c.successMessage ?? "",
}),
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 function extractActionButtonDisplay(displayConfig: Config): unknown {
return extractors.actionButton?.(null, displayConfig);
}
@@ -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 })),
};
}),
};
+3 -4
View File
@@ -37,11 +37,10 @@ export const locationRouter = createTRPCRouter({
.input(locationSearchCityInput)
.output(locationSearchCityOutput)
.query(async ({ input }) => {
const url = new URL("https://geocoding-api.open-meteo.com/v1/search");
url.searchParams.set("name", input.query);
const res = await withTimeoutAsync(async (signal) => {
return await fetchWithTrustedCertificatesAsync(
`https://geocoding-api.open-meteo.com/v1/search?name=${input.query}`,
{ signal },
);
return await fetchWithTrustedCertificatesAsync(url.toString(), { signal });
});
return (await res.json()) as z.infer<typeof locationSearchCityOutput>;
}),
@@ -294,9 +294,7 @@ export const onboardRouter = createTRPCRouter({
}
const section = board.sections.find((boardSection) => boardSection.kind === "empty");
const layout = board.layouts[0];
if (section && layout) {
if (section && board.layouts.length > 0) {
const existingItems = await db.query.items.findMany({
where: eq(items.boardId, board.id),
});
@@ -310,12 +308,12 @@ export const onboardRouter = createTRPCRouter({
await placeAllWidgetsAsync(
db,
{
board.layouts.map((layout) => ({
boardId: board.id,
sectionId: section.id,
layoutId: layout.id,
columnCount: layout.columnCount,
},
})),
allIntegrations,
allApps,
);
-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,
+618 -101
View File
@@ -4,7 +4,7 @@ import { describe, expect, it, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import type { Database, InferInsertModel } from "@homarr/db";
import { and, eq, not } from "@homarr/db";
import { and, eq } from "@homarr/db";
import {
boardGroupPermissions,
boards,
@@ -325,10 +325,13 @@ describe("createBoard should create a new board", () => {
expect(firstSection?.xOffset).toBe(0);
expect(firstSection?.yOffset).toBe(0);
expect(dbBoard?.layouts.length).toBe(1);
const firstLayout = dbBoard?.layouts.at(0);
expect(firstLayout?.columnCount).toBe(24);
expect(firstLayout?.breakpoint).toBe(0);
expect(dbBoard?.layouts.length).toBe(2);
expect(dbBoard?.layouts).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "Mobile", role: "mobile", columnCount: 3, breakpoint: 0 }),
expect.objectContaining({ name: "Base", role: "base", columnCount: 24, breakpoint: 768 }),
]),
);
});
test("should throw error when user has no board-create permission", async () => {
@@ -344,6 +347,36 @@ describe("createBoard should create a new board", () => {
});
});
describe("duplicateBoard should preserve protected layout invariants", () => {
test("normalizes a legacy single-layout board while preserving Base positions", async () => {
const db = createDb();
const session = {
...defaultSession,
user: {
...defaultSession.user,
permissions: ["board-create"] satisfies GroupPermissionKey[],
},
};
const caller = boardRouter.createCaller({ db, deviceType: undefined, session });
const { boardId } = await createFullBoardAsync(db, "source");
const result = await caller.duplicateBoard({ id: boardId, name: "copy" });
const copiedBoard = await db.query.boards.findFirst({
where: eq(boards.id, result.boardId),
with: { layouts: true, items: { with: { layouts: true } } },
});
expect(copiedBoard?.layouts).toHaveLength(2);
expect(copiedBoard?.layouts).toEqual(
expect.arrayContaining([
expect.objectContaining({ role: "mobile", columnCount: 3, breakpoint: 0 }),
expect.objectContaining({ role: "base", columnCount: 10, breakpoint: 768 }),
]),
);
expect(copiedBoard?.items.at(0)?.layouts).toHaveLength(2);
});
});
describe("rename board should rename board", () => {
test("should rename board", async () => {
// Arrange
@@ -569,9 +602,48 @@ describe("getBoardByName should return board by name", () => {
name,
...fullBoardProps,
});
expect(result.layouts).toEqual(
expect.arrayContaining([
expect.objectContaining({ role: "mobile", breakpoint: 0, columnCount: 3 }),
expect.objectContaining({ id: fullBoardProps.layoutId, role: "base", breakpoint: 768 }),
]),
);
expect(result.items.at(0)?.layouts).toHaveLength(2);
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();
@@ -850,60 +922,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();
@@ -1049,15 +1255,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({
@@ -1065,19 +1278,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: [],
@@ -1097,11 +1323,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");
@@ -1160,12 +1392,12 @@ describe("saveBoard should save full board", () => {
expect(firstItem.id).toBe(itemId);
expect(firstItem.kind).toBe("clock");
expect(SuperJSON.parse<{ is24HourFormat: boolean }>(firstItem.options).is24HourFormat).toBe(false);
const firstLayout = expectToBeDefined(firstItem.layouts[0]);
expect(firstLayout.sectionId).toBe(sectionId);
expect(firstLayout.height).toBe(3);
expect(firstLayout.width).toBe(2);
expect(firstLayout.xOffset).toBe(7);
expect(firstLayout.yOffset).toBe(5);
const updatedLayout = expectToBeDefined(firstItem.layouts.find((layout) => layout.layoutId === layoutId));
expect(updatedLayout.sectionId).toBe(sectionId);
expect(updatedLayout.height).toBe(3);
expect(updatedLayout.width).toBe(2);
expect(updatedLayout.xOffset).toBe(7);
expect(updatedLayout.yOffset).toBe(5);
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
});
it("should fail when board not found", async () => {
@@ -1350,14 +1582,43 @@ const createExistingLayout = (id: string) => ({
id,
name: "Base",
columnCount: 10,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 768,
role: "base" as const,
});
const createMobileLayout = (id: string) => ({
id,
name: "Mobile",
columnCount: 3,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 0,
role: "mobile" as const,
});
const createNewLayout = (columnCount: number) => ({
id: createId(),
name: "New layout",
columnCount,
breakpoint: 1400,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 480,
role: "custom" as const,
});
const addMobileLayoutAsync = async (db: Database, boardId: string) => {
const mobileLayoutId = createId();
await db.insert(layouts).values({
id: mobileLayoutId,
name: "Mobile",
columnCount: 3,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 0,
role: "mobile",
boardId,
});
return mobileLayoutId;
};
describe("saveLayouts should save layout changes", () => {
test("should add layout when not present in database", async () => {
// Arrange
@@ -1365,30 +1626,34 @@ describe("saveLayouts should save layout changes", () => {
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
const newLayout = createNewLayout(12);
// Act
await caller.saveLayouts({
const savedLayouts = await caller.saveLayouts({
id: boardId,
layouts: [createExistingLayout(layoutId), newLayout],
layouts: [createMobileLayout(mobileLayoutId), createExistingLayout(layoutId), newLayout],
});
// Assert
const layout = await db.query.layouts.findFirst({
where: not(eq(layouts.id, layoutId)),
where: eq(layouts.role, "custom"),
});
const definedLayout = expectToBeDefined(layout);
expect(definedLayout.name).toBe(newLayout.name);
expect(definedLayout.columnCount).toBe(newLayout.columnCount);
expect(definedLayout.breakpoint).toBe(newLayout.breakpoint);
expect(definedLayout.role).toBe("custom");
expect(savedLayouts.find((candidate) => candidate.role === "custom")?.id).toBe(definedLayout.id);
});
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 });
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default");
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
const assignments = await createItemsAndSectionsAsync(db, {
boardId,
layoutId,
@@ -1399,12 +1664,12 @@ describe("saveLayouts should save layout changes", () => {
// Act
await caller.saveLayouts({
id: boardId,
layouts: [createExistingLayout(layoutId), newLayout],
layouts: [createMobileLayout(mobileLayoutId), createExistingLayout(layoutId), newLayout],
});
// Assert
const layout = await db.query.layouts.findFirst({
where: not(eq(layouts.id, layoutId)),
where: eq(layouts.role, "custom"),
});
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await expectLayoutForRootLayoutAsync(db, sectionId, layout!.id, {
@@ -1412,7 +1677,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
@@ -1420,6 +1685,7 @@ describe("saveLayouts should save layout changes", () => {
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
const updatedLayout = createExistingLayout(layoutId);
updatedLayout.breakpoint = 1400;
updatedLayout.name = "Updated layout";
@@ -1427,7 +1693,7 @@ describe("saveLayouts should save layout changes", () => {
// Act
await caller.saveLayouts({
id: boardId,
layouts: [updatedLayout],
layouts: [createMobileLayout(mobileLayoutId), updatedLayout],
});
// Assert
@@ -1446,6 +1712,7 @@ describe("saveLayouts should save layout changes", () => {
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default");
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
const assignments = await createItemsAndSectionsAsync(db, {
boardId,
layoutId,
@@ -1457,7 +1724,7 @@ describe("saveLayouts should save layout changes", () => {
// Act
await caller.saveLayouts({
id: boardId,
layouts: [updatedLayout],
layouts: [createMobileLayout(mobileLayoutId), updatedLayout],
});
// Assert
@@ -1465,7 +1732,126 @@ 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");
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
await db
.update(itemLayouts)
.set({
xOffset: 9,
})
.where(and(eq(itemLayouts.itemId, itemId), eq(itemLayouts.layoutId, layoutId)));
await caller.saveLayouts({
id: boardId,
layouts: [
createMobileLayout(mobileLayoutId),
{
...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: [createMobileLayout(mobileLayoutId), 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 mobileLayoutId = await addMobileLayoutAsync(db, boardId);
const input = {
id: boardId,
layouts: [
createMobileLayout(mobileLayoutId),
{
...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 project a new custom layout from the resized Base layout", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default");
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
const secondItemId = createId();
await db.update(itemLayouts).set({ width: 4, xOffset: 0 }).where(eq(itemLayouts.itemId, itemId));
await db.insert(items).values({
id: secondItemId,
kind: "clock",
boardId,
options: SuperJSON.stringify({ is24HourFormat: true }),
});
await db.insert(itemLayouts).values({
itemId: secondItemId,
layoutId,
sectionId,
width: 4,
height: 1,
xOffset: 6,
yOffset: 0,
});
const resizedBase = { ...createExistingLayout(layoutId), columnCount: 6 };
const savedLayouts = await caller.saveLayouts({
id: boardId,
layouts: [createMobileLayout(mobileLayoutId), createNewLayout(8), resizedBase],
});
const customLayoutId = expectToBeDefined(savedLayouts.find((layout) => layout.role === "custom")).id;
const basePosition = await db.query.itemLayouts.findFirst({
where: and(eq(itemLayouts.itemId, secondItemId), eq(itemLayouts.layoutId, layoutId)),
});
const customPosition = await db.query.itemLayouts.findFirst({
where: and(eq(itemLayouts.itemId, secondItemId), eq(itemLayouts.layoutId, customLayoutId)),
});
expect(basePosition).toMatchObject({ xOffset: 0, yOffset: 1, width: 4, height: 1, sectionId });
expect(customPosition).toMatchObject({ xOffset: 0, yOffset: 1, width: 4, height: 1, sectionId });
});
test("should remove layout when not present in input", async () => {
// Arrange
@@ -1473,16 +1859,26 @@ describe("saveLayouts should save layout changes", () => {
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
const customLayoutId = createId();
await db.insert(layouts).values({
id: customLayoutId,
name: "Custom",
columnCount: 6,
breakpoint: 480,
role: "custom",
boardId,
});
// Act
await caller.saveLayouts({
id: boardId,
layouts: [createNewLayout(12)],
layouts: [createMobileLayout(mobileLayoutId), createExistingLayout(layoutId)],
});
// Assert
const layout = await db.query.layouts.findFirst({
where: eq(layouts.id, layoutId),
where: eq(layouts.id, customLayoutId),
});
expect(layout).toBeUndefined();
});
@@ -1497,12 +1893,130 @@ describe("saveLayouts should save layout changes", () => {
const actAsync = async () =>
await caller.saveLayouts({
id: createId(),
layouts: [createExistingLayout(layoutId)],
layouts: [createMobileLayout(createId()), createExistingLayout(layoutId)],
});
// Assert
await expect(actAsync()).rejects.toThrowError("Board not found");
});
test("should reject changing protected layout roles", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
const actAsync = async () =>
await caller.saveLayouts({
id: boardId,
layouts: [
{ ...createExistingLayout(layoutId), role: "mobile", breakpoint: 0 },
{ ...createMobileLayout(mobileLayoutId), role: "base", breakpoint: 768 },
],
});
await expect(actAsync()).rejects.toThrowError("Layout roles cannot be changed");
});
});
describe("resetLayout should rebuild a layout from Base", () => {
test("should replace target item positions and preserve layout metadata", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, sectionId, itemId } = await createFullBoardAsync(db, "default");
await addMobileLayoutAsync(db, boardId);
const customLayoutId = createId();
await db.insert(layouts).values({
id: customLayoutId,
name: "Tablet",
columnCount: 6,
breakpoint: 480,
role: "custom",
boardId,
});
await db.insert(itemLayouts).values({
itemId,
sectionId,
layoutId: customLayoutId,
width: 2,
height: 2,
xOffset: 3,
yOffset: 4,
});
await caller.resetLayout({ boardId, layoutId: customLayoutId });
const resetPosition = await db.query.itemLayouts.findFirst({
where: and(eq(itemLayouts.itemId, itemId), eq(itemLayouts.layoutId, customLayoutId)),
});
expect(resetPosition).toMatchObject({ width: 1, height: 1, xOffset: 0, yOffset: 0, sectionId });
const customLayout = await db.query.layouts.findFirst({ where: eq(layouts.id, customLayoutId) });
expect(customLayout).toMatchObject({ name: "Tablet", columnCount: 6, breakpoint: 480, role: "custom" });
});
test("should reject resetting Base", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
await expect(caller.resetLayout({ boardId, layoutId })).rejects.toThrowError("Base layout cannot be reset");
});
});
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 = (
@@ -1543,7 +2057,8 @@ const createFullBoardAsync = async (db: Database, name: string) => {
id: layoutId,
name: "Base",
columnCount: 10,
breakpoint: 0,
breakpoint: 768,
role: "base",
boardId,
});
@@ -1623,19 +2138,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,
@@ -1677,7 +2194,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,
@@ -1686,7 +2203,7 @@ const createItemsAndSectionsAsync = async (
boardId,
layoutId,
});
const sectionG = await addDynamicSectionAsync(db, {
const sectionG = await addContainerAsync(db, {
yOffset: 2,
xOffset: 6,
width: 2,
@@ -1717,7 +2234,7 @@ const createItemsAndSectionsAsync = async (
f: sectionF,
g: sectionG,
},
inDynamicSection: {
inContainer: {
h: itemH,
i: itemI,
j: itemJ,
@@ -1753,7 +2270,7 @@ gg`,
);
};
const expectLayoutForDynamicSectionAsync = async (
const expectLayoutForContainerAsync = async (
db: Database,
sectionId: string,
layoutId: string,
@@ -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,36 @@
// @vitest-environment node
import { beforeEach, describe, expect, test, vi } from "vitest";
import { createDb } from "@homarr/db/test";
import { locationRouter } from "../location";
const mocks = vi.hoisted(() => ({ fetch: vi.fn() }));
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: mocks.fetch,
}));
vi.mock("@homarr/core/infrastructure/http/timeout", () => ({
withTimeoutAsync: async (callback: (signal: AbortSignal) => Promise<Response>) =>
await callback(new AbortController().signal),
}));
beforeEach(() => {
vi.clearAllMocks();
mocks.fetch.mockResolvedValue(new Response(JSON.stringify({ results: [] })));
});
describe("locationRouter", () => {
test("encodes the complete search text as one name parameter", async () => {
const caller = locationRouter.createCaller({ db: createDb(), deviceType: undefined, session: null });
await caller.searchCity({ query: "Paris&language=xx&token=secret" });
const requestedUrl = new URL(mocks.fetch.mock.calls[0]?.[0] as string);
expect(requestedUrl.searchParams.get("name")).toBe("Paris&language=xx&token=secret");
expect(requestedUrl.searchParams.has("language")).toBe(false);
expect(requestedUrl.searchParams.has("token")).toBe(false);
});
});
@@ -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,76 @@
// @vitest-environment node
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import type { Database } from "@homarr/db";
import { integrations, integrationUserPermissions, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { immichRouter } from "../../widgets/immich";
vi.hoisted(() => {
process.env.SKIP_ENV_VALIDATION = "true";
});
const mocks = vi.hoisted(() => ({
albumsHandler: vi.fn(),
}));
vi.mock("@homarr/request-handler/immich", () => ({
immichAlbumRequestHandler: { handler: vi.fn() },
immichStatsRequestHandler: { handler: vi.fn() },
immichAlbumsRequestHandler: {
handler: (_integration: unknown, input: unknown) => {
mocks.albumsHandler(input);
return { getDataAsync: async () => ({ data: [], timestamp: new Date() }) };
},
},
}));
const createSession = (userId: string): Session => ({
user: { id: userId, permissions: [], colorScheme: "light" },
expires: new Date().toISOString(),
});
const createCallerAsync = async () => {
const db = createDb();
const userId = createId();
const integrationId = createId();
await db.insert(users).values({ id: userId });
await db.insert(integrations).values({
id: integrationId,
kind: "immich",
name: "Immich",
url: "https://immich.example.com",
});
await db.insert(integrationUserPermissions).values({ integrationId, userId, permission: "use" });
return {
integrationId,
caller: immichRouter.createCaller({ db: db as Database, deviceType: undefined, session: createSession(userId) }),
};
};
describe("immichRouter.getAlbums", () => {
beforeEach(() => vi.clearAllMocks());
test("passes a validated limit to the cached request handler", async () => {
const { caller, integrationId } = await createCallerAsync();
await caller.getAlbums({ integrationId, limit: 50 });
expect(mocks.albumsHandler).toHaveBeenCalledWith({ limit: 50 });
await expect(caller.getAlbums({ integrationId, limit: 0 })).rejects.toMatchObject({ code: "BAD_REQUEST" });
await expect(caller.getAlbums({ integrationId, limit: 501 })).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
test("keeps the limit optional for unbounded callers", async () => {
const { caller, integrationId } = await createCallerAsync();
await caller.getAlbums({ integrationId });
expect(mocks.albumsHandler).toHaveBeenCalledWith({ limit: undefined });
});
});
@@ -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();
});
});
@@ -0,0 +1,284 @@
// @vitest-environment node
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import type { Database } from "@homarr/db";
import { integrations, integrationUserPermissions, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import type { IntegrationKind } from "@homarr/definitions";
import type { MediaRequest, MediaRequestStats } from "@homarr/integrations/types";
import type * as MediaRequestListHandlerModule from "@homarr/request-handler/media-request-list";
import { calendarRouter } from "../../widgets/calendar";
import { beszelRouter } from "../../widgets/beszel";
import { firewallRouter } from "../../widgets/firewall";
import { indexerManagerRouter } from "../../widgets/indexer-manager";
import { mediaOrganizerRouter } from "../../widgets/media-organizer";
import { mediaRequestsRouter } from "../../widgets/media-requests";
import { mediaServerRouter } from "../../widgets/media-server";
import { notificationsRouter } from "../../widgets/notifications";
import { vpnRouter } from "../../widgets/vpn";
vi.hoisted(() => {
process.env.SKIP_ENV_VALIDATION = "true";
});
const createHandler = <T>(integration: { name: string }, data: T) => ({
getDataAsync: async () => {
if (integration.name === "Offline") {
throw new Error(
"GET http://admin:password@internal.example/private/path?token=secret returned\nprivate response body",
);
}
return { data, timestamp: new Date() };
},
});
vi.mock("@homarr/request-handler/calendar", () => ({
calendarMonthRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
}));
vi.mock("@homarr/request-handler/media-server", () => ({
mediaServerRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
}));
vi.mock("@homarr/request-handler/media-organizer", () => ({
mediaOrganizerRequestHandler: {
handler: (integration: { name: string }) =>
createHandler(integration, { missing: [], missingCount: 0, queued: [], queuedCount: 0 }),
},
}));
vi.mock("@homarr/request-handler/beszel", () => ({
beszelSystemsRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
}));
vi.mock("@homarr/request-handler/firewall", () => ({
firewallCpuRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, { total: 1 }) },
firewallInterfacesRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
firewallMemoryRequestHandler: {
handler: (integration: { name: string }) => createHandler(integration, { used: 1, total: 2, percent: 50 }),
},
firewallVersionRequestHandler: {
handler: (integration: { name: string }) => createHandler(integration, { version: "1.0" }),
},
}));
vi.mock("@homarr/request-handler/indexer-manager", () => ({
indexerManagerRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
}));
vi.mock("@homarr/request-handler/notifications", () => ({
notificationsRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
}));
vi.mock("@homarr/request-handler/vpn", () => ({
vpnSummaryHandler: { handler: (integration: { name: string }) => createHandler(integration, { connected: true }) },
}));
vi.mock("@homarr/request-handler/media-request-list", async (importOriginal) => {
const actual = await importOriginal<typeof MediaRequestListHandlerModule>();
return {
...actual,
mediaRequestListRequestHandler: {
handler: (integration: { name: string }) =>
createHandler(integration, [
{
id: 42,
name: "Healthy request",
type: "movie",
backdropImageUrl: "https://example.com/backdrop.jpg",
posterImagePath: "https://example.com/poster.jpg",
href: "https://example.com/request/42",
createdAt: new Date("2026-08-01T12:00:00Z"),
status: "pending",
availability: "requested",
} satisfies MediaRequest,
]),
},
};
});
vi.mock("@homarr/request-handler/media-request-stats", () => ({
mediaRequestStatsRequestHandler: {
handler: (integration: { name: string }) =>
createHandler(integration, {
stats: { total: 0, movie: 0, tv: 0, pending: 0, approved: 0, declined: 0, processing: 0, available: 0 },
users: [
{
id: 7,
displayName: "Healthy user",
avatar: "https://example.com/avatar.jpg",
requestCount: 3,
link: "https://example.com/user/7",
},
],
} satisfies MediaRequestStats),
},
}));
const createSession = (userId: string): Session => ({
user: { id: userId, permissions: [], colorScheme: "light" },
expires: new Date().toISOString(),
});
const setupAsync = async (kind: IntegrationKind) => {
const db = createDb();
const userId = createId();
const onlineId = createId();
const offlineId = createId();
await db.insert(users).values({ id: userId });
await db.insert(integrations).values([
{ id: onlineId, kind, name: "Online", url: "https://online.example.com" },
{ id: offlineId, kind, name: "Offline", url: "https://offline.example.com" },
]);
await db.insert(integrationUserPermissions).values([
{ integrationId: onlineId, userId, permission: "use" },
{ integrationId: offlineId, userId, permission: "use" },
]);
return {
db: db as Database,
session: createSession(userId),
integrationIds: [onlineId, offlineId],
onlineId,
offlineId,
};
};
beforeEach(() => vi.clearAllMocks());
describe("partial integration failures", () => {
test("calendar and media server retain a failed integration record", async () => {
const calendar = await setupAsync("sonarr");
const calendarResults = await calendarRouter
.createCaller({ db: calendar.db, deviceType: undefined, session: calendar.session })
.findAllEvents({
integrationIds: calendar.integrationIds,
year: 2026,
month: 7,
releaseType: [],
showUnmonitored: false,
});
expect(calendarResults).toEqual(
expect.arrayContaining([
expect.objectContaining({
integration: expect.objectContaining({ id: calendar.offlineId }),
error: "INTEGRATION_REQUEST_FAILED",
}),
]),
);
expect(JSON.stringify(calendarResults)).not.toContain("secret");
const mediaServer = await setupAsync("plex");
const streamResults = await mediaServerRouter
.createCaller({ db: mediaServer.db, deviceType: undefined, session: mediaServer.session })
.getCurrentStreams({ integrationIds: mediaServer.integrationIds, showOnlyPlaying: false });
expect(streamResults).toEqual(
expect.arrayContaining([
expect.objectContaining({
integrationId: mediaServer.offlineId,
sessions: [],
error: "INTEGRATION_REQUEST_FAILED",
}),
]),
);
expect(JSON.stringify(streamResults)).not.toContain("secret");
});
test("media request responses expose failed integrations without dropping successful data", async () => {
const mediaRequests = await setupAsync("overseerr");
const caller = mediaRequestsRouter.createCaller({
db: mediaRequests.db,
deviceType: undefined,
session: mediaRequests.session,
});
const latest = await caller.getLatestRequests({
integrationIds: mediaRequests.integrationIds,
statuses: ["pending"],
recentDays: 0,
});
expect(latest.requests).toEqual([
expect.objectContaining({
id: 42,
name: "Healthy request",
integrationId: mediaRequests.onlineId,
integration: expect.objectContaining({ id: mediaRequests.onlineId }),
}),
]);
expect(latest.failedIntegrations).toEqual([
expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "INTEGRATION_REQUEST_FAILED" }),
]);
expect(JSON.stringify(latest)).not.toContain("secret");
const stats = await caller.getStats({ integrationIds: mediaRequests.integrationIds });
expect(stats.failedIntegrations).toEqual([
expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "INTEGRATION_REQUEST_FAILED" }),
]);
expect(JSON.stringify(stats)).not.toContain("secret");
expect(stats.stats).toHaveLength(1);
expect(stats.users).toEqual([
expect.objectContaining({
id: 7,
displayName: "Healthy user",
integration: expect.objectContaining({ id: mediaRequests.onlineId }),
}),
]);
});
test("aggregate widget payloads never expose sensitive partial-failure details", async () => {
const cases = [
{
kind: "sonarr" as const,
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
await mediaOrganizerRouter
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
.getData({ integrationIds: setup.integrationIds, pageSize: 10 }),
},
{
kind: "beszel" as const,
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
await beszelRouter
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
.getSystems({ integrationIds: setup.integrationIds }),
},
{
kind: "opnsense" as const,
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
await firewallRouter
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
.getFirewallCpuStatus({ integrationIds: setup.integrationIds }),
},
{
kind: "prowlarr" as const,
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
await indexerManagerRouter
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
.getIndexersStatus({ integrationIds: setup.integrationIds }),
},
{
kind: "ntfy" as const,
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
await notificationsRouter
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
.getNotifications({ integrationIds: setup.integrationIds }),
},
{
kind: "gluetun" as const,
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
await vpnRouter
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
.getSummaries({ integrationIds: setup.integrationIds }),
},
];
for (const testCase of cases) {
const setup = await setupAsync(testCase.kind);
const payload = JSON.stringify(await testCase.run(setup));
expect(payload).toContain("INTEGRATION_REQUEST_FAILED");
expect(payload).not.toMatch(/internal\.example|token=secret|\/private\/|response body|password/i);
}
});
});
@@ -101,9 +101,7 @@ describe("patchmonRouter.getStats access control", () => {
});
const caller = createCaller(db, null);
await expect(caller.getStats({ integrationId })).rejects.toThrow(
new TRPCError({ code: "UNAUTHORIZED" }),
);
await expect(caller.getStats({ integrationId })).rejects.toThrow(new TRPCError({ code: "UNAUTHORIZED" }));
});
test("should throw FORBIDDEN for authenticated users without board or integration access", async () => {
@@ -1,5 +1,5 @@
import SuperJSON from "superjson";
import { describe, expect, test } from "vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
@@ -17,7 +17,18 @@ import { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions";
import type { WidgetComponentProps } from "../../../../../widgets/src/definition";
import { canAccessAllFeedsAsync, restrictUrlsAsync } from "../../widgets/rssFeed";
import { canAccessAllFeedsAsync, restrictUrlsAsync, rssFeedRouter } from "../../widgets/rssFeed";
const mocks = vi.hoisted(() => ({ logger: { warn: vi.fn() }, getFeed: vi.fn() }));
vi.mock("@homarr/core/infrastructure/logs", () => ({ createLogger: () => mocks.logger }));
vi.mock("@homarr/request-handler/rss-feeds", () => ({
rssFeedsRequestHandler: {
handler: (input: unknown) => ({ getDataAsync: async () => await mocks.getFeed(input) }),
},
}));
beforeEach(() => vi.clearAllMocks());
const createSession = (permissions: GroupPermissionKey[] = []): Session => ({
user: {
@@ -29,6 +40,53 @@ const createSession = (permissions: GroupPermissionKey[] = []): Session => ({
});
describe("rssFeedRouter", () => {
describe("getFeeds", () => {
test("redacts feed secrets from logs and returns a generic error when every feed fails", async () => {
const feedUrl = "https://user:password@example.com/feed?token=secret";
mocks.getFeed.mockRejectedValue(new Error(`Failed to fetch ${feedUrl}`));
const caller = rssFeedRouter.createCaller({
db: createDb(),
deviceType: undefined,
session: createSession(["board-create"]),
});
await expect(caller.getFeeds({ urls: [feedUrl], maximumAmountPosts: 5 })).rejects.toMatchObject({
code: "BAD_GATEWAY",
message: "All RSS feed requests failed",
});
expect(mocks.logger.warn).toHaveBeenCalledWith("RSS feed fetch failed", {
feedIndex: 0,
origin: "https://example.com",
errorType: "Error",
errorMessage: "Failed to fetch [redacted URL]",
});
const logged = JSON.stringify(mocks.logger.warn.mock.calls);
expect(logged).not.toContain("password");
expect(logged).not.toContain("token");
expect(logged).not.toContain("secret");
});
test("returns successful entries and only a failure count when some feeds fail", async () => {
const failedUrl = "https://user:password@example.com/feed?token=secret";
mocks.getFeed.mockImplementation(async (input: { url: string }) => {
if (input.url === failedUrl) throw new Error(`Failed to fetch ${failedUrl}`);
return { data: { entries: [{ id: "entry" }] } };
});
const caller = rssFeedRouter.createCaller({
db: createDb(),
deviceType: undefined,
session: createSession(["board-create"]),
});
const result = await caller.getFeeds({ urls: ["https://example.com/feed", failedUrl], maximumAmountPosts: 5 });
expect(result).toEqual({ entries: [{ id: "entry" }], failedFeedCount: 1 });
expect(JSON.stringify(result)).not.toContain("password");
expect(JSON.stringify(result)).not.toContain("token");
expect(JSON.stringify(result)).not.toContain("secret");
});
});
describe("canAccessAllFeedsAsync", () => {
test("should return false for unauthenticated users", async () => {
// Arrange
@@ -0,0 +1,381 @@
// @vitest-environment node
import { parse, stringify } from "superjson";
import { 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, items, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { boardRouter } from "../../board";
import { optionsRouter } from "../../widgets/options";
import { timetableRouter } from "../../widgets/timetable";
const mocks = vi.hoisted(() => ({ getTimetable: vi.fn(), searchStations: vi.fn(), lookup: vi.fn() }));
vi.mock("node:dns/promises", () => ({ default: { lookup: mocks.lookup }, lookup: mocks.lookup }));
vi.mock("@homarr/request-handler/timetable", () => ({
timetableGetTimetableRequestHandler: {
handler: (input: unknown) => ({
getDataAsync: async () => {
mocks.getTimetable(input);
return { data: { stationId: "station", timestamp: new Date(0), entries: [] } };
},
}),
},
timetableSearchStationsRequestHandler: {
handler: (input: unknown) => ({
getDataAsync: async () => {
mocks.searchStations(input);
return { data: [] };
},
}),
},
}));
beforeEach(() => {
vi.clearAllMocks();
mocks.lookup.mockResolvedValue([{ address: "8.8.8.8", family: 4 }]);
});
const createSession = (userId: string): Session => ({
user: { id: userId, permissions: [], colorScheme: "light" },
expires: new Date().toISOString(),
});
const createCaller = (db: Database, session: Session | null) =>
timetableRouter.createCaller({ db, deviceType: undefined, session });
const createTimetableItemAsync = async (
db: Database,
{ baseUrl, isPublic = true }: { baseUrl: string; isPublic?: boolean },
) => {
const ownerId = createId();
const boardId = createId();
const itemId = createId();
await db.insert(users).values({ id: ownerId });
await db.insert(boards).values({ id: boardId, name: `board-${boardId}`, creatorId: ownerId, isPublic });
await db.insert(items).values({
id: itemId,
boardId,
kind: "timetable",
options: stringify({ baseUrl }),
});
return { boardId, itemId, ownerId };
};
describe("timetableRouter source authorization", () => {
it("allows itemless setup only for the fixed default provider", async () => {
const caller = createCaller(createDb(), null);
await expect(caller.searchStations({ baseUrl: "https://search.ch", query: "Bern" })).resolves.toEqual([]);
await expect(caller.searchStations({ baseUrl: "http://127.0.0.1:8080", query: "Bern" })).rejects.toMatchObject({
code: "FORBIDDEN",
});
});
it("reports invalid timetable URLs as bad requests", async () => {
const caller = createCaller(createDb(), null);
await expect(
caller.searchStations({ baseUrl: "https://search.ch?redirect=/admin", query: "Bern" }),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
it("rejects a persisted private endpoint at runtime even through a viewable timetable item", async () => {
const db = createDb();
const baseUrl = "http://timetable.internal:8080";
const { itemId } = await createTimetableItemAsync(db, { baseUrl });
const caller = createCaller(db, null);
await expect(caller.getTimetable({ baseUrl, itemId, stationId: "station", limit: 10 })).rejects.toMatchObject({
code: "BAD_REQUEST",
});
expect(mocks.getTimetable).not.toHaveBeenCalled();
await expect(
caller.getTimetable({ baseUrl: "http://127.0.0.1:8080", itemId, stationId: "station", limit: 10 }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("rejects a persisted endpoint when its board is not viewable", async () => {
const db = createDb();
const baseUrl = "http://timetable.internal:8080";
const { itemId } = await createTimetableItemAsync(db, { baseUrl, isPublic: false });
await expect(
createCaller(db, null).getTimetable({ baseUrl, itemId, stationId: "station", limit: 10 }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("allows board modifiers to search an unsaved public endpoint", async () => {
const db = createDb();
const baseUrl = "https://timetable.example.com";
const { boardId, ownerId } = await createTimetableItemAsync(db, { baseUrl: "https://search.ch", isPublic: false });
const caller = createCaller(db, createSession(ownerId));
await caller.searchStations({ baseUrl, boardId, query: "Bern" });
expect(mocks.lookup).toHaveBeenCalledWith("timetable.example.com", { all: true, verbatim: true });
expect(mocks.searchStations).toHaveBeenLastCalledWith(
expect.objectContaining({ baseUrl, pinnedAddresses: [{ address: "8.8.8.8", family: 4 }] }),
);
});
it.each([
"http://127.0.0.1:8080",
"http://timetable.internal:8080",
"http://[::1]:8080",
"http://[::ffff:127.0.0.1]:8080",
"https://[fd00::1]:8443",
"https://192.88.99.1",
"http://[64:ff9b::7f00:1]:8080",
"http://[2002:7f00:1::]:8080",
])("blocks an unsaved private endpoint %s even for board modifiers", async (baseUrl) => {
const db = createDb();
const { boardId, ownerId } = await createTimetableItemAsync(db, {
baseUrl: "https://search.ch",
isPublic: false,
});
const caller = createCaller(db, createSession(ownerId));
await expect(caller.searchStations({ baseUrl, boardId, query: "Bern" })).rejects.toMatchObject({
code: "BAD_REQUEST",
});
expect(mocks.searchStations).not.toHaveBeenCalled();
});
it("blocks an RFC 2765 IPv4-translated loopback address", async () => {
const db = createDb();
const { boardId, ownerId } = await createTimetableItemAsync(db, {
baseUrl: "https://search.ch",
isPublic: false,
});
const caller = createCaller(db, createSession(ownerId));
await expect(
caller.searchStations({ baseUrl: "http://[::ffff:0:7f00:1]:8080", boardId, query: "Bern" }),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(mocks.searchStations).not.toHaveBeenCalled();
});
it("blocks an unsaved public hostname that resolves to a private address", async () => {
mocks.lookup.mockResolvedValueOnce([{ address: "10.0.0.8", family: 4 }]);
const db = createDb();
const { boardId, ownerId } = await createTimetableItemAsync(db, {
baseUrl: "https://search.ch",
isPublic: false,
});
const caller = createCaller(db, createSession(ownerId));
await expect(
caller.searchStations({ baseUrl: "https://timetable.example.com", boardId, query: "Bern" }),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(mocks.searchStations).not.toHaveBeenCalled();
});
it("rejects the exact saved private endpoint at runtime", async () => {
const db = createDb();
const baseUrl = "http://timetable.internal:8080";
const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, { baseUrl, isPublic: false });
await expect(
createCaller(db, createSession(ownerId)).searchStations({ baseUrl, boardId, itemId, query: "Bern" }),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(mocks.lookup).not.toHaveBeenCalled();
expect(mocks.searchStations).not.toHaveBeenCalled();
});
it("re-resolves and pins a saved public endpoint for searches and timetable requests", async () => {
const db = createDb();
const baseUrl = "https://timetable.example.com";
const pinnedAddresses = [
{ address: "93.184.216.34", family: 4 },
{ address: "93.184.216.35", family: 4 },
{ address: "2606:2800:220:1:248:1893:25c8:1946", family: 6 },
];
mocks.lookup.mockResolvedValue([pinnedAddresses[2], pinnedAddresses[1], pinnedAddresses[0]]);
const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, { baseUrl, isPublic: false });
const caller = createCaller(db, createSession(ownerId));
await caller.searchStations({ baseUrl, boardId, itemId, query: "Bern" });
await caller.getTimetable({ baseUrl, itemId, stationId: "station", limit: 10 });
expect(mocks.lookup).toHaveBeenCalledTimes(2);
expect(mocks.searchStations).toHaveBeenLastCalledWith(expect.objectContaining({ baseUrl, pinnedAddresses }));
expect(mocks.getTimetable).toHaveBeenLastCalledWith(expect.objectContaining({ baseUrl, pinnedAddresses }));
});
it("rejects a saved public hostname when it resolves to a private address at request time", async () => {
const db = createDb();
const baseUrl = "https://timetable.example.com";
mocks.lookup.mockResolvedValue([{ address: "10.0.0.8", family: 4 }]);
const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, { baseUrl, isPublic: false });
const caller = createCaller(db, createSession(ownerId));
await expect(caller.searchStations({ baseUrl, boardId, itemId, query: "Bern" })).rejects.toMatchObject({
code: "BAD_REQUEST",
});
await expect(caller.getTimetable({ baseUrl, itemId, stationId: "station", limit: 10 })).rejects.toMatchObject({
code: "BAD_REQUEST",
});
expect(mocks.searchStations).not.toHaveBeenCalled();
expect(mocks.getTimetable).not.toHaveBeenCalled();
});
it("blocks changing a saved timetable to a private endpoint through item options", async () => {
const db = createDb();
const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, {
baseUrl: "https://search.ch",
isPublic: false,
});
const caller = optionsRouter.createCaller({ db, deviceType: undefined, session: createSession(ownerId) });
await expect(
caller.saveItemOptions({ boardId, itemId, newOptions: { baseUrl: "http://127.0.0.1:8080" } }),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
const item = await db.query.items.findFirst({ where: eq(items.id, itemId) });
expect(parse<Record<string, unknown>>(item?.options ?? "{}")).toMatchObject({ baseUrl: "https://search.ch" });
});
it("blocks changing a saved timetable to a private endpoint through a board save", async () => {
const db = createDb();
const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, {
baseUrl: "https://search.ch",
isPublic: false,
});
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: createSession(ownerId) });
await expect(
caller.saveBoard({
id: boardId,
sections: [],
items: [
{
id: itemId,
kind: "timetable",
options: { baseUrl: "http://127.0.0.1:8080" },
integrationIds: [],
layouts: [],
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
},
],
}),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
it("blocks adding a timetable with a private endpoint", async () => {
const db = createDb();
const { boardId, ownerId } = await createTimetableItemAsync(db, {
baseUrl: "https://search.ch",
isPublic: false,
});
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: createSession(ownerId) });
await expect(
caller.addItem({
boardId,
kind: "timetable",
options: { baseUrl: "http://127.0.0.1:8080" },
integrationIds: [],
}),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
it("rejects an item ID that does not belong to the requested board", async () => {
const db = createDb();
const first = await createTimetableItemAsync(db, { baseUrl: "http://timetable.internal:8080", isPublic: false });
const second = await createTimetableItemAsync(db, { baseUrl: "https://search.ch", isPublic: false });
await expect(
createCaller(db, createSession(second.ownerId)).searchStations({
baseUrl: "http://timetable.internal:8080",
boardId: second.boardId,
itemId: first.itemId,
query: "Bern",
}),
).rejects.toMatchObject({ code: "NOT_FOUND" });
expect(mocks.searchStations).not.toHaveBeenCalled();
});
it("blocks a changed private endpoint for an existing item", async () => {
const db = createDb();
const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, {
baseUrl: "http://timetable.internal:8080",
isPublic: false,
});
await expect(
createCaller(db, createSession(ownerId)).searchStations({
baseUrl: "http://127.0.0.1:8080",
boardId,
itemId,
query: "Bern",
}),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(mocks.searchStations).not.toHaveBeenCalled();
});
it("allows a changed public endpoint for an existing item and pins its address", async () => {
const db = createDb();
const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, {
baseUrl: "http://timetable.internal:8080",
isPublic: false,
});
const baseUrl = "https://timetable.example.com";
await createCaller(db, createSession(ownerId)).searchStations({ baseUrl, boardId, itemId, query: "Bern" });
expect(mocks.searchStations).toHaveBeenLastCalledWith(
expect.objectContaining({ baseUrl, pinnedAddresses: [{ address: "8.8.8.8", family: 4 }] }),
);
});
it("reports malformed saved widget options as server data corruption", async () => {
const db = createDb();
const { itemId } = await createTimetableItemAsync(db, { baseUrl: "https://search.ch" });
await db.update(items).set({ options: "{" }).where(eq(items.id, itemId));
await expect(
createCaller(db, null).getTimetable({
baseUrl: "https://search.ch",
itemId,
stationId: "station",
limit: 10,
}),
).rejects.toMatchObject({ code: "INTERNAL_SERVER_ERROR", message: "Timetable widget configuration is invalid" });
});
it("reports an invalid saved timetable URL as server data corruption", async () => {
const db = createDb();
const { itemId } = await createTimetableItemAsync(db, {
baseUrl: "https://search.ch?redirect=/admin",
});
await expect(
createCaller(db, null).getTimetable({
baseUrl: "https://search.ch",
itemId,
stationId: "station",
limit: 10,
}),
).rejects.toMatchObject({ code: "INTERNAL_SERVER_ERROR", message: "Timetable widget configuration is invalid" });
});
it("does not treat public board view access as configuration access", async () => {
const db = createDb();
const baseUrl = "http://timetable.internal:8080";
const { boardId, itemId } = await createTimetableItemAsync(db, { baseUrl });
await expect(createCaller(db, null).searchStations({ baseUrl, boardId, query: "Bern" })).rejects.toMatchObject({
code: "NOT_FOUND",
});
await expect(createCaller(db, null).searchStations({ baseUrl, itemId, query: "Bern" })).rejects.toMatchObject({
code: "FORBIDDEN",
});
});
});
+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,
};
@@ -0,0 +1,20 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { readBoundedJsonResponseAsync, validateCustomApiUrl } from "../custom-api-security";
describe("custom API request hardening", () => {
it("accepts HTTP endpoints without embedded credentials", () => {
expect(validateCustomApiUrl("https://api.example.com/v1/data").hostname).toBe("api.example.com");
});
it.each(["file:///etc/passwd", "https://user:secret@example.com/data"])("rejects unsafe URL %s", (url) => {
expect(() => validateCustomApiUrl(url)).toThrow();
});
it("rejects a response once its byte budget is exceeded", async () => {
const response = new Response(JSON.stringify({ value: "x".repeat(100) }));
await expect(readBoundedJsonResponseAsync(response, 32)).rejects.toThrow("exceeds 32 bytes");
});
});
+12 -15
View File
@@ -10,7 +10,7 @@ import {
beszelSystemsRequestHandler,
} from "@homarr/request-handler/beszel";
import { settleIntegrationQueries } from "../../settle-integrations";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { BoundedAsyncQueue } from "./bounded-async-queue";
@@ -18,8 +18,6 @@ import { BoundedAsyncQueue } from "./bounded-async-queue";
const logger = createLogger({ module: "beszelRouter" });
const MAX_PENDING_LIVE_EVENTS = 4;
const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error));
export const beszelRouter = createTRPCRouter({
getSystems: publicProcedure
.meta({
@@ -41,7 +39,6 @@ export const beszelRouter = createTRPCRouter({
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
systems: data,
updatedAt: timestamp,
};
@@ -50,11 +47,11 @@ export const beszelRouter = createTRPCRouter({
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
systems: [],
updatedAt: new Date(0),
error: errorMessage(error),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
);
logger.debug("getSystems completed", {
@@ -126,8 +123,9 @@ export const beszelRouter = createTRPCRouter({
history: [],
systemNameMap: {},
updatedAt: new Date(0),
error: errorMessage(error),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
);
logger.debug("getAlerts completed", {
@@ -192,7 +190,7 @@ export const beszelRouter = createTRPCRouter({
systemStats: [],
containerStats: [],
updatedAt: new Date(0),
error: errorMessage(error),
error: toPublicIntegrationError(error),
};
}
}),
@@ -269,15 +267,14 @@ export const beszelRouter = createTRPCRouter({
integrationId: integration.id,
systemId: input.systemId,
emittedEventCount,
error: errorMessage(error),
error,
});
queue.fail(
error instanceof TRPCError
? error
: new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: error instanceof Error ? error.message : String(error),
}),
new TRPCError({
code: "BAD_GATEWAY",
message: "Live integration request failed",
cause: error,
}),
);
}
})();
+18 -37
View File
@@ -1,16 +1,13 @@
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { radarrReleaseTypes } from "@homarr/integrations/types";
import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "calendarRouter" });
export const calendarRouter = createTRPCRouter({
findAllEvents: publicProcedure
.meta({
@@ -30,8 +27,9 @@ export const calendarRouter = createTRPCRouter({
)
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
.query(async ({ ctx, input }) => {
const settled = await Promise.allSettled(
ctx.integrations.map(async (integration) => {
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { integrationIds: _integrationIds, ...handlerInput } = input;
const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput);
const { data } = await innerHandler.getDataAsync();
@@ -46,38 +44,21 @@ export const calendarRouter = createTRPCRouter({
name: integration.name,
kind: integration.kind,
},
error: undefined as string | undefined,
};
}),
);
return settled.flatMap((result, index) => {
if (result.status === "fulfilled") {
return [result.value];
}
const integration = ctx.integrations[index];
logger.warn(
new ErrorWithMetadata(
"Calendar integration request failed; skipping events for this integration",
{
integrationId: integration?.id,
integrationKind: integration?.kind,
},
{
fallback: (integration) => ({
events: [],
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
},
{ cause: result.reason },
),
);
return integration
? [
{
events: [],
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
},
error: "Calendar events could not be loaded from this integration.",
},
]
: [];
});
error: PUBLIC_INTEGRATION_ERROR,
}),
throwOnAllFailures: true,
},
);
}),
});
@@ -0,0 +1,67 @@
import { readBoundedJsonResponseAsync as readBoundedJsonResponse } from "@homarr/common/server";
export const MAX_CUSTOM_API_RESPONSE_BYTES = 2 * 1024 * 1024;
export const CUSTOM_API_FETCH_TIMEOUT_MS = 10_000;
export function validateCustomApiUrl(urlString: string): URL {
if (urlString.length > 2_048) throw new Error("Custom API URL is too long");
const url = new URL(urlString);
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("Custom API URL must use HTTP or HTTPS");
}
if (url.username || url.password) throw new Error("Custom API URL must not contain credentials");
return url;
}
export async function readBoundedJsonResponseAsync(
response: Response,
maxBytes = MAX_CUSTOM_API_RESPONSE_BYTES,
): Promise<unknown> {
return await readBoundedJsonResponse(response, maxBytes, "Custom API response");
}
export async function consumeBoundedResponseAsync(
response: Response,
maxBytes = MAX_CUSTOM_API_RESPONSE_BYTES,
): Promise<void> {
const declaredLength = Number(response.headers.get("content-length"));
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
await response.body?.cancel();
throw new Error(`Custom API response exceeds ${maxBytes} bytes`);
}
if (!response.body) return;
const reader = response.body.getReader();
let byteLength = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) return;
byteLength += value?.byteLength ?? 0;
if (byteLength > maxBytes) {
await reader.cancel();
throw new Error(`Custom API response exceeds ${maxBytes} bytes`);
}
}
}
export async function withCustomApiResponseAsync<TResult>(
url: URL,
init: Omit<RequestInit, "redirect" | "signal">,
consumeResponseAsync: (response: Response) => Promise<TResult>,
timeoutMs = CUSTOM_API_FETCH_TIMEOUT_MS,
): Promise<TResult> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url.toString(), {
...init,
redirect: "error",
signal: controller.signal,
});
return await consumeResponseAsync(response);
} finally {
clearTimeout(timeout);
}
}
+280 -78
View File
@@ -1,91 +1,293 @@
import { TRPCError } from "@trpc/server";
import superjson from "superjson";
import { parse as parseSuperJson } from "superjson";
import { z } from "zod/v4";
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 { extractActionButtonDisplay, extractDisplayDataWithFallback } from "../custom-widget/display-data";
import { boards, customWidgetDefinitions, items, legacyCustomWidgetDefinitions } from "@homarr/db/schema";
import type { BoardPermission } from "@homarr/definitions";
import {
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";
const FETCH_TIMEOUT_MS = 10_000;
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;
}
const validateUrl = (urlString: string): URL => new URL(urlString);
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 (legacy) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "LEGACY_CUSTOM_WIDGET_MIGRATION_REQUIRED",
});
}
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget unavailable" });
}
if (!stored.enabled) throw new TRPCError({ code: "FORBIDDEN", message: "Widget is disabled" });
const definition = parseStoredCustomWidgetDefinition(stored);
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: protectedProcedure.input(z.object({ definitionId: z.string() })).query(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.definitionId),
with: { secrets: true },
});
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;
}
}),
);
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 = superjson.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") {
return extractActionButtonDisplay(displayConfig);
}
const decryptedSecrets = definition.secrets.map((s) => ({
kind: s.kind,
value: decryptSecret(s.value),
}));
const url = validateUrl(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);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(url.toString(), {
method: definition.method,
headers,
body: definition.method !== "GET" ? definition.requestBody : undefined,
redirect: "follow",
signal: controller.signal,
});
if (!response.ok) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `API returned ${response.status}: ${response.statusText}`,
});
}
const json: unknown = await response.json();
return extractDisplayDataWithFallback(json, definition.displayType, displayConfig);
} catch (error) {
if (error instanceof TRPCError) throw error;
logger.error("Failed to fetch custom API data", { definitionId: input.definitionId, error });
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: error instanceof Error ? error.message : "Failed to fetch data from external API",
});
} finally {
clearTimeout(timeout);
}
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 ?? [],
};
}),
});
+25 -12
View File
@@ -7,7 +7,7 @@ import {
} from "@homarr/request-handler/firewall";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const firewallMiddleware = createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall"));
@@ -18,20 +18,33 @@ const queryFirewall = <
},
>(
handler: THandler,
createFallback: () => Awaited<ReturnType<ReturnType<THandler["handler"]>["getDataAsync"]>>["data"],
) =>
publicProcedure.concat(firewallMiddleware).query(async ({ ctx }) =>
settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data, timestamp } = await handler.handler(integration, {}).getDataAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
summary: data as Awaited<ReturnType<ReturnType<THandler["handler"]>["getDataAsync"]>>["data"],
};
}),
settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { data, timestamp } = await handler.handler(integration, {}).getDataAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
summary: data as Awaited<ReturnType<ReturnType<THandler["handler"]>["getDataAsync"]>>["data"],
error: undefined as string | undefined,
};
},
{
fallback: (integration, error) => ({
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: new Date(0) },
summary: createFallback(),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
),
);
export const firewallRouter = createTRPCRouter({
getFirewallCpuStatus: queryFirewall(firewallCpuRequestHandler),
getFirewallInterfacesStatus: queryFirewall(firewallInterfacesRequestHandler),
getFirewallVersionStatus: queryFirewall(firewallVersionRequestHandler),
getFirewallMemoryStatus: queryFirewall(firewallMemoryRequestHandler),
getFirewallCpuStatus: queryFirewall(firewallCpuRequestHandler, () => ({ total: 0 })),
getFirewallInterfacesStatus: queryFirewall(firewallInterfacesRequestHandler, () => []),
getFirewallVersionStatus: queryFirewall(firewallVersionRequestHandler, () => ({ version: "Unknown" })),
getFirewallMemoryStatus: queryFirewall(firewallMemoryRequestHandler, () => ({ used: 0, total: 0, percent: 0 })),
});
+12 -5
View File
@@ -34,9 +34,16 @@ export const immichRouter = createTRPCRouter({
return data.data;
}),
getAlbums: publicProcedure.concat(createImmichIntegrationMiddleware("query")).query(async ({ ctx }) => {
const innerHandler = immichAlbumsRequestHandler.handler(ctx.integration, {});
const data = await innerHandler.getDataAsync();
return data.data;
}),
getAlbums: publicProcedure
.input(
z.object({
limit: z.number().int().min(1).max(500).optional(),
}),
)
.concat(createImmichIntegrationMiddleware("query"))
.query(async ({ ctx, input }) => {
const innerHandler = immichAlbumsRequestHandler.handler(ctx.integration, { limit: input.limit });
const data = await innerHandler.getDataAsync();
return data.data;
}),
});
@@ -6,7 +6,7 @@ import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-ma
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) =>
@@ -16,15 +16,29 @@ export const indexerManagerRouter = createTRPCRouter({
getIndexersStatus: publicProcedure
.concat(createIndexerManagerIntegrationMiddleware("query"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = indexerManagerRequestHandler.handler(integration, {});
const { data: indexers } = await innerHandler.getDataAsync();
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = indexerManagerRequestHandler.handler(integration, {});
const { data: indexers } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
indexers,
};
});
return {
integrationId: integration.id,
integrationName: integration.name,
indexers,
error: undefined as string | undefined,
};
},
{
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
indexers: [],
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
);
}),
testAllIndexers: protectedProcedure
.concat(createIndexerManagerIntegrationMiddleware("interact"))
@@ -4,6 +4,7 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaOrganizerRequestHandler } from "@homarr/request-handler/media-organizer";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const mediaOrganizerRouter = createTRPCRouter({
@@ -18,16 +19,30 @@ export const mediaOrganizerRouter = createTRPCRouter({
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaOrganizer")))
.input(z.object({ pageSize: z.number().min(1).max(50).default(10) }))
.query(async ({ ctx, input }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = mediaOrganizerRequestHandler.handler(integration, { pageSize: input.pageSize });
const { data } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
...data,
error: undefined as string | undefined,
};
}),
},
{
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
missing: [],
missingCount: 0,
queued: [],
queuedCount: 0,
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
);
return results;
}),
});
@@ -1,7 +1,9 @@
import { z } from "zod/v4";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { MediaRequest, MediaRequestStats } from "@homarr/integrations/types";
import { mediaRequestStatusConfiguration } from "@homarr/integrations/types";
import {
mediaRequestListInputSchema,
@@ -10,9 +12,15 @@ import {
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
interface IntegrationQueryResult<TData> {
integration: { id: string; name: string; kind: IntegrationKindByCategory<"mediaRequest"> };
data: TData;
error?: string;
}
export const mediaRequestsRouter = createTRPCRouter({
getLatestRequests: publicProcedure
.meta({
@@ -25,15 +33,31 @@ export const mediaRequestsRouter = createTRPCRouter({
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.input(mediaRequestListInputSchema)
.query(async ({ ctx, input }) => {
const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data } = await mediaRequestListRequestHandler.handler(integration, input).getDataAsync();
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
});
return results
const results: IntegrationQueryResult<MediaRequest[]>[] = await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { data } = await mediaRequestListRequestHandler.handler(integration, input).getDataAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind },
data,
error: undefined as string | undefined,
};
},
{
fallback: (integration) => ({
integration: { id: integration.id, name: integration.name, kind: integration.kind },
data: [],
error: PUBLIC_INTEGRATION_ERROR,
}),
throwOnAllFailures: true,
},
);
const requests = results
.flatMap(({ data, integration }) =>
data.map((request) => ({
...request,
integrationId: integration.id,
integration,
})),
)
.toSorted((dataA, dataB) => {
@@ -46,6 +70,12 @@ export const mediaRequestsRouter = createTRPCRouter({
mediaRequestStatusConfiguration[dataB.status].position
);
});
return {
requests,
failedIntegrations: results.flatMap(({ integration, error }) =>
error ? [{ integrationId: integration.id, integrationName: integration.name, error }] : [],
),
};
}),
getStats: publicProcedure
.meta({
@@ -57,12 +87,30 @@ export const mediaRequestsRouter = createTRPCRouter({
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.query(async ({ ctx }) => {
const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync();
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
});
const results: IntegrationQueryResult<{
stats: MediaRequestStats["stats"] | null;
users: MediaRequestStats["users"];
}>[] = await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind },
data: { ...data, stats: data.stats as typeof data.stats | null },
error: undefined as string | undefined,
};
},
{
fallback: (integration) => ({
integration: { id: integration.id, name: integration.name, kind: integration.kind },
data: { stats: null, users: [] },
error: PUBLIC_INTEGRATION_ERROR,
}),
throwOnAllFailures: true,
},
);
return {
stats: results.flatMap((result) => result.data.stats),
stats: results.flatMap((result) => (result.data.stats ? [result.data.stats] : [])),
users: results
.map((result) =>
result.data.users.map((user) => ({
@@ -73,6 +121,9 @@ export const mediaRequestsRouter = createTRPCRouter({
.flat()
.toSorted(({ requestCount: countA }, { requestCount: countB }) => countB - countA),
integrations: results.map((result) => result.integration),
failedIntegrations: results.flatMap(({ integration, error }) =>
error ? [{ integrationId: integration.id, integrationName: integration.name, error }] : [],
),
};
}),
answerRequest: protectedProcedure
@@ -99,5 +150,6 @@ export const mediaRequestsRouter = createTRPCRouter({
} as const;
await answerActions[input.answer](input.requestId);
return { success: true };
}),
});
@@ -5,7 +5,7 @@ import { mediaServerRequestHandler } from "@homarr/request-handler/media-server"
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createMediaServerIntegrationMiddleware = (action: IntegrationAction) =>
@@ -23,11 +23,30 @@ export const mediaServerRouter = createTRPCRouter({
.concat(createMediaServerIntegrationMiddleware("query"))
.input(z.object({ showOnlyPlaying: z.boolean() }))
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data } = await mediaServerRequestHandler
.handler(integration, { showOnlyPlaying: input.showOnlyPlaying })
.getDataAsync();
return { integrationId: integration.id, integrationKind: integration.kind, sessions: data };
});
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { data } = await mediaServerRequestHandler
.handler(integration, { showOnlyPlaying: input.showOnlyPlaying })
.getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
integrationKind: integration.kind,
sessions: data,
error: undefined as string | undefined,
};
},
{
fallback: (integration) => ({
integrationId: integration.id,
integrationName: integration.name,
integrationKind: integration.kind,
sessions: [],
error: PUBLIC_INTEGRATION_ERROR,
}),
throwOnAllFailures: true,
},
);
}),
});
@@ -3,7 +3,7 @@ import { notificationsRequestHandler } from "@homarr/request-handler/notificatio
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createNotificationsIntegrationMiddleware = (action: IntegrationAction) =>
@@ -13,19 +13,36 @@ export const notificationsRouter = createTRPCRouter({
getNotifications: publicProcedure
.unstable_concat(createNotificationsIntegrationMiddleware("query"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = notificationsRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = notificationsRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
data,
};
});
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
data,
error: undefined as string | undefined,
};
},
{
fallback: (integration, error) => ({
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: new Date(0),
},
data: [],
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
);
}),
});
+13 -3
View File
@@ -9,6 +9,8 @@ import { boards, items } from "@homarr/db/schema";
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> => {
@@ -45,11 +47,19 @@ export const optionsRouter = createTRPCRouter({
});
}
const options = SuperJSON.parse<Record<string, unknown>>(item.options);
Object.assign(options, input.newOptions);
const previousOptions = SuperJSON.parse<Record<string, unknown>>(item.options);
const updatedOptions = { ...previousOptions, ...input.newOptions };
if (item.kind === "timetable") {
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));
}),
});
+22 -2
View File
@@ -13,6 +13,7 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
const logger = createLogger({ module: "releasesRouter" });
const PUBLIC_RELEASE_ERROR_MESSAGE: string = "Release provider request failed";
const formatVersionFilterRegex = (versionFilter: z.infer<typeof releaseVersionFilterSchema> | undefined) => {
if (!versionFilter) return undefined;
@@ -100,19 +101,38 @@ export const releasesRouter = createTRPCRouter({
})
.getDataAsync();
const data = response.data.success
? response.data
: {
success: false as const,
error: { code: response.data.error.code, message: PUBLIC_RELEASE_ERROR_MESSAGE },
};
if (!response.data.success) {
logger.warn("Release provider request failed", {
repositoryId,
provider: repository.provider,
error: response.data.error,
});
}
return {
id: repositoryId,
provider: repository.provider,
timestamp: response.timestamp,
...response.data,
...data,
};
} catch (error) {
logger.warn("Release provider request failed", {
repositoryId,
provider: repository.provider,
error,
});
return {
id: repositoryId,
provider: repository.provider,
timestamp: new Date(),
success: false as const,
error: { code: "unexpected" as const, message: String(error) },
error: { code: "unexpected" as const, message: PUBLIC_RELEASE_ERROR_MESSAGE },
};
}
}),
+45 -12
View File
@@ -1,7 +1,9 @@
import { TRPCError } from "@trpc/server";
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";
@@ -13,6 +15,16 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "rssFeed" });
const safeFeedLogContext = (url: string, feedIndex: number) => {
try {
return { feedIndex, origin: new URL(url).origin };
} catch {
return { feedIndex };
}
};
const redactFeedFailureMessage = (message: string) => message.replace(/https?:\/\/\S+/gi, "[redacted URL]");
const feedsInput = z.object({
urls: z.array(z.string()).max(100),
maximumAmountPosts: z.number(),
@@ -20,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);
@@ -34,18 +48,37 @@ export const rssFeedRouter = createTRPCRouter({
}),
);
return settled
.flatMap((result, index) => {
if (result.status === "fulfilled") return result.value.data.entries;
logger.warn("RSS feed fetch failed", { url: urls[index], error: result.reason });
return [];
})
.toSorted((entryA, entryB) => {
return entryA.published && entryB.published
? new Date(entryB.published).getTime() - new Date(entryA.published).getTime()
: 0;
})
.slice(0, input.maximumAmountPosts);
const failures = settled.filter((result) => result.status === "rejected");
settled.forEach((result, index) => {
if (result.status === "rejected") {
logger.warn("RSS feed fetch failed", {
...safeFeedLogContext(urls[index] ?? "", index),
errorType: result.reason instanceof Error ? result.reason.name : typeof result.reason,
errorMessage: result.reason instanceof Error ? redactFeedFailureMessage(result.reason.message) : undefined,
});
}
});
if (settled.length > 0 && failures.length === settled.length) {
throw new TRPCError({
code: "BAD_GATEWAY",
message: "All RSS feed requests failed",
});
}
return {
entries: settled
.flatMap((result) => {
if (result.status === "fulfilled") return result.value.data.entries;
return [];
})
.toSorted((entryA, entryB) => {
return entryA.published && entryB.published
? new Date(entryB.published).getTime() - new Date(entryA.published).getTime()
: 0;
})
.slice(0, input.maximumAmountPosts),
failedFeedCount: failures.length,
};
}),
});
+30 -5
View File
@@ -1,8 +1,12 @@
import { z } from "zod/v4";
import { TRPCError } from "@trpc/server";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { smartHomeEntityStateRequestHandler } from "@homarr/request-handler/smart-home-entity-state";
import {
smartHomeEntityStateRequestHandler,
toSafeEntityDetails,
} from "@homarr/request-handler/smart-home-entity-state";
import type { IntegrationAction } from "../../middlewares/integration";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
@@ -25,7 +29,22 @@ export const smartHomeRouter = createTRPCRouter({
.query(async ({ ctx: { integration }, input }) => {
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
const { data } = await innerHandler.getDataAsync();
return data;
return data.state;
}),
entityDetails: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Get the state, attributes, and update timestamps for a Home Assistant entity. REQUIRED: integrationId (Home Assistant integration ID), entityId (for example 'sensor.temperature')",
},
})
.input(z.object({ entityId: z.string() }))
.concat(createSmartHomeIntegrationMiddleware("query"))
.query(async ({ ctx: { integration }, input }) => {
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
const { data } = await innerHandler.getDataAsync();
return toSafeEntityDetails(data);
}),
switchEntity: protectedProcedure
.meta({
@@ -40,8 +59,10 @@ export const smartHomeRouter = createTRPCRouter({
.mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration);
const success = await client.triggerToggleAsync(input.entityId);
return success;
if (!success) {
throw new TRPCError({ code: "BAD_GATEWAY", message: "Home Assistant did not toggle the entity" });
}
return true;
}),
executeAutomation: protectedProcedure
.meta({
@@ -55,6 +76,10 @@ export const smartHomeRouter = createTRPCRouter({
.input(z.object({ automationId: z.string() }))
.mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration);
await client.triggerAutomationAsync(input.automationId);
const success = await client.triggerAutomationAsync(input.automationId);
if (!success) {
throw new TRPCError({ code: "BAD_GATEWAY", message: "Home Assistant did not execute the automation" });
}
return true;
}),
});
+256 -11
View File
@@ -1,36 +1,281 @@
import { lookup } from "node:dns/promises";
import { BlockList, isIP } from "node:net";
import { TRPCError } from "@trpc/server";
import { parse } from "superjson";
import z from "zod";
import type { Session } from "@homarr/auth";
import type { Database } from "@homarr/db";
import { and, eq } from "@homarr/db";
import { boards, items } from "@homarr/db/schema";
import type { TimetableResolvedAddress } from "@homarr/request-handler/timetable";
import {
timetableGetTimetableRequestHandler,
timetableSearchStationsRequestHandler,
} from "@homarr/request-handler/timetable";
import { DEFAULT_TIMETABLE_BASE_URL, normalizeTimetableBaseUrl } from "@homarr/request-handler/timetable-url";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
const baseUrlSchema = z.string().url();
const baseUrlSchema = z.string().url().max(2_048);
const timetableSourceSchema = z.object({
baseUrl: baseUrlSchema,
itemId: z.string().max(100).optional(),
boardId: z.string().max(100).optional(),
});
const timetableOptionsSchema = z.object({ baseUrl: z.string().optional() }).passthrough();
const blockedTimetableIpv4Addresses = new BlockList();
const blockedTimetableIpv6Addresses = new BlockList();
const blockedTimetableSubnets = [
["0.0.0.0", 8, "ipv4"],
["10.0.0.0", 8, "ipv4"],
["100.64.0.0", 10, "ipv4"],
["127.0.0.0", 8, "ipv4"],
["169.254.0.0", 16, "ipv4"],
["172.16.0.0", 12, "ipv4"],
["192.0.0.0", 24, "ipv4"],
["192.0.2.0", 24, "ipv4"],
["192.88.99.0", 24, "ipv4"],
["192.168.0.0", 16, "ipv4"],
["198.18.0.0", 15, "ipv4"],
["198.51.100.0", 24, "ipv4"],
["203.0.113.0", 24, "ipv4"],
["224.0.0.0", 4, "ipv4"],
["240.0.0.0", 4, "ipv4"],
["::", 96, "ipv6"],
["::ffff:0:0", 96, "ipv6"],
["::ffff:0:0:0", 96, "ipv6"],
["64:ff9b::", 96, "ipv6"],
["64:ff9b:1::", 48, "ipv6"],
["100::", 64, "ipv6"],
["2001::", 32, "ipv6"],
["2001:2::", 48, "ipv6"],
["2001:10::", 28, "ipv6"],
["2001:db8::", 32, "ipv6"],
["2002::", 16, "ipv6"],
["fc00::", 7, "ipv6"],
["fe80::", 10, "ipv6"],
["fec0::", 10, "ipv6"],
["ff00::", 8, "ipv6"],
] as const;
for (const [network, prefix, family] of blockedTimetableSubnets) {
const blockList = family === "ipv4" ? blockedTimetableIpv4Addresses : blockedTimetableIpv6Addresses;
blockList.addSubnet(network, prefix, family);
}
const normalizeBaseUrlOrThrowBadRequest = (baseUrl: string) => {
try {
return normalizeTimetableBaseUrl(baseUrl);
} catch (cause) {
throw new TRPCError({
code: "BAD_REQUEST",
message: cause instanceof Error ? cause.message : "Invalid timetable base URL",
cause,
});
}
};
const throwInvalidSavedTimetableConfiguration = (cause: unknown): never => {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Timetable widget configuration is invalid",
cause,
});
};
const parseSavedTimetableOptions = (serializedOptions: string) => {
try {
return timetableOptionsSchema.parse(parse<unknown>(serializedOptions));
} catch (cause) {
return throwInvalidSavedTimetableConfiguration(cause);
}
};
const normalizeSavedTimetableBaseUrl = (baseUrl: string) => {
try {
return normalizeTimetableBaseUrl(baseUrl);
} catch (cause) {
return throwInvalidSavedTimetableConfiguration(cause);
}
};
const normalizeHostname = (hostname: string) => {
const withoutBrackets = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
return withoutBrackets.toLowerCase().replace(/\.$/, "");
};
const isBlockedTimetableAddress = (address: string, family: number) => {
return family === 6
? blockedTimetableIpv6Addresses.check(address, "ipv6")
: blockedTimetableIpv4Addresses.check(address, "ipv4");
};
const resolvePublicTimetableAddressesAsync = async (baseUrl: string): Promise<TimetableResolvedAddress[]> => {
const hostname = normalizeHostname(new URL(baseUrl).hostname);
const hostnameFamily = isIP(hostname);
const isInternalHostname =
hostnameFamily === 0 &&
(!hostname.includes(".") ||
hostname === "localhost" ||
hostname.endsWith(".localhost") ||
hostname.endsWith(".local") ||
hostname.endsWith(".internal") ||
hostname.endsWith(".lan") ||
hostname.endsWith(".home.arpa"));
if (isInternalHostname) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Timetable base URL must use a public host" });
}
let addresses: TimetableResolvedAddress[];
try {
const resolvedAddresses =
hostnameFamily === 0
? await lookup(hostname, { all: true, verbatim: true })
: [{ address: hostname, family: hostnameFamily }];
addresses = resolvedAddresses.filter(
(entry): entry is TimetableResolvedAddress => entry.family === 4 || entry.family === 6,
);
} catch (cause) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Timetable base URL host could not be resolved",
cause,
});
}
if (addresses.length === 0 || addresses.some(({ address, family }) => isBlockedTimetableAddress(address, family))) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Timetable base URL must not target a private or internal address",
});
}
return addresses.toSorted((left, right) => left.family - right.family || left.address.localeCompare(right.address));
};
const normalizeTimetableOptionsBaseUrlOrThrowBadRequest = (options: Record<string, unknown>) => {
const parsedOptions = timetableOptionsSchema.safeParse(options);
if (!parsedOptions.success) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid timetable widget options" });
}
return normalizeBaseUrlOrThrowBadRequest(parsedOptions.data.baseUrl ?? DEFAULT_TIMETABLE_BASE_URL);
};
export const validateTimetableOptionsChangeAsync = async (
nextOptions: Record<string, unknown>,
previousOptions?: Record<string, unknown>,
) => {
const nextBaseUrl = normalizeTimetableOptionsBaseUrlOrThrowBadRequest(nextOptions);
if (nextBaseUrl === DEFAULT_TIMETABLE_BASE_URL) return;
let previousBaseUrl: string | undefined;
if (previousOptions) {
try {
previousBaseUrl = normalizeTimetableOptionsBaseUrlOrThrowBadRequest(previousOptions);
} catch {
// Invalid legacy options must not prevent replacing them with a valid configuration.
}
}
if (nextBaseUrl === previousBaseUrl) return;
await resolvePublicTimetableAddressesAsync(nextBaseUrl);
};
const getSavedTimetableBaseUrl = (serializedOptions: string) => {
const options = parseSavedTimetableOptions(serializedOptions);
return normalizeSavedTimetableBaseUrl(options.baseUrl ?? DEFAULT_TIMETABLE_BASE_URL);
};
interface ResolvedTimetableSource {
baseUrl: string;
pinnedAddresses?: TimetableResolvedAddress[];
}
const resolveTimetableBaseUrlAsync = async (
ctx: { db: Database; session: Session | null },
input: z.infer<typeof timetableSourceSchema>,
allowBoardConfiguration = false,
): Promise<ResolvedTimetableSource> => {
const requestedBaseUrl = normalizeBaseUrlOrThrowBadRequest(input.baseUrl);
if (allowBoardConfiguration) {
if (input.boardId) {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
if (input.itemId) {
const item = await ctx.db.query.items.findFirst({
columns: { id: true },
where: and(eq(items.id, input.itemId), eq(items.kind, "timetable"), eq(items.boardId, input.boardId)),
});
if (!item) throw new TRPCError({ code: "NOT_FOUND", message: "Timetable widget not found" });
}
if (requestedBaseUrl === DEFAULT_TIMETABLE_BASE_URL) {
return { baseUrl: requestedBaseUrl };
}
// Resolve immediately before every custom request. A URL that was public when saved can later resolve to a
// private address, so matching the persisted value is not sufficient protection against DNS rebinding.
const pinnedAddresses = await resolvePublicTimetableAddressesAsync(requestedBaseUrl);
return { baseUrl: requestedBaseUrl, pinnedAddresses };
}
if (requestedBaseUrl !== DEFAULT_TIMETABLE_BASE_URL) {
throw new TRPCError({ code: "FORBIDDEN", message: "Board modify access is required for this endpoint" });
}
return { baseUrl: DEFAULT_TIMETABLE_BASE_URL };
}
if (!input.itemId) {
if (requestedBaseUrl !== DEFAULT_TIMETABLE_BASE_URL) {
throw new TRPCError({ code: "FORBIDDEN", message: "A saved timetable widget is required for this endpoint" });
}
return { baseUrl: DEFAULT_TIMETABLE_BASE_URL };
}
const item = await ctx.db.query.items.findFirst({
where: and(eq(items.id, input.itemId), eq(items.kind, "timetable")),
});
if (!item) throw new TRPCError({ code: "NOT_FOUND", message: "Timetable widget not found" });
await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "view");
const savedBaseUrl = getSavedTimetableBaseUrl(item.options);
if (requestedBaseUrl !== savedBaseUrl) {
throw new TRPCError({ code: "FORBIDDEN", message: "Timetable URL does not match the saved widget" });
}
if (savedBaseUrl === DEFAULT_TIMETABLE_BASE_URL) return { baseUrl: savedBaseUrl };
const pinnedAddresses = await resolvePublicTimetableAddressesAsync(savedBaseUrl);
return { baseUrl: savedBaseUrl, pinnedAddresses };
};
export const timetableRouter = createTRPCRouter({
getTimetable: publicProcedure
.input(
z.object({
baseUrl: baseUrlSchema,
stationId: z.string(),
timetableSourceSchema.extend({
stationId: z.string().min(1).max(200),
limit: z.number().int().min(1).max(100),
}),
)
.query(async ({ input }) => {
const { data } = await timetableGetTimetableRequestHandler.handler(input).getDataAsync();
.query(async ({ ctx, input }) => {
const { baseUrl, pinnedAddresses } = await resolveTimetableBaseUrlAsync(ctx, input);
const { itemId: _itemId, boardId: _boardId, ...handlerInput } = input;
const { data } = await timetableGetTimetableRequestHandler
.handler({ ...handlerInput, baseUrl, pinnedAddresses })
.getDataAsync();
return data;
}),
searchStations: publicProcedure
.input(
z.object({
baseUrl: baseUrlSchema,
query: z.string(),
timetableSourceSchema.extend({
query: z.string().max(200),
}),
)
.query(async ({ input }) => {
const { data } = await timetableSearchStationsRequestHandler.handler(input).getDataAsync();
.query(async ({ ctx, input }) => {
const { baseUrl, pinnedAddresses } = await resolveTimetableBaseUrlAsync(ctx, input, true);
const { itemId: _itemId, boardId: _boardId, ...handlerInput } = input;
const { data } = await timetableSearchStationsRequestHandler
.handler({ ...handlerInput, baseUrl, pinnedAddresses })
.getDataAsync();
return data;
}),
});
+30 -13
View File
@@ -2,24 +2,41 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { vpnSummaryHandler } from "@homarr/request-handler/vpn";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const vpnRouter = createTRPCRouter({
getSummaries: publicProcedure
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn")))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data, timestamp } = await vpnSummaryHandler.handler(integration, {}).getDataAsync();
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
summary: data,
};
});
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { data, timestamp } = await vpnSummaryHandler.handler(integration, {}).getDataAsync();
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
summary: data as typeof data | null,
error: undefined as string | undefined,
};
},
{
fallback: (integration, error) => ({
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: new Date(0),
},
summary: null,
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
);
}),
});
@@ -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);
+18 -4
View File
@@ -1,8 +1,14 @@
import { TRPCError } from "@trpc/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
const logger = createLogger({ module: "settleIntegrations" });
export const PUBLIC_INTEGRATION_ERROR: string = "INTEGRATION_REQUEST_FAILED";
export const toPublicIntegrationError = (_error: unknown): string => PUBLIC_INTEGRATION_ERROR;
interface IntegrationLike {
id: string;
name: string;
@@ -11,6 +17,7 @@ interface IntegrationLike {
interface Options<TIntegration extends IntegrationLike, TResult> {
fallback?: (integration: TIntegration, error: unknown) => TResult;
throwOnAllFailures?: boolean;
}
export async function settleIntegrationQueries<TIntegration extends IntegrationLike, TResult>(
@@ -37,16 +44,23 @@ export async function settleIntegrationQueries<TIntegration extends IntegrationL
),
);
errors.push(result.reason);
if (options?.fallback && integration) {
results.push(options.fallback(integration, result.reason));
return;
}
errors.push(result.reason);
});
if (results.length === 0 && errors.length > 0) {
throw errors[0];
if (
errors.length > 0 &&
(results.length === 0 || (options?.throwOnAllFailures === true && errors.length === integrations.length))
) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "All integration queries failed",
cause: errors[0],
});
}
return results;
+219 -64
View File
@@ -1,73 +1,228 @@
// @vitest-environment node
import { expect, test, vi } from "vitest";
import type { TRPCError } from "@trpc/server";
import { describe, expect, test, vi } from "vitest";
import { extractToolsFromProcedures } from "trpc-to-mcp";
import { appRouter } from "../router/app";
import { boardRouter } from "../router/board";
import { customWidgetRouter } from "../router/custom-widget/custom-widget-router";
import { iconsRouter } from "../router/icons";
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", () => ({}));
vi.mock("@homarr/common/server", () => ({
decryptSecret: vi.fn(),
encryptSecret: vi.fn(),
}));
vi.mock("@homarr/core/infrastructure/logs", () => ({
createLogger: () => ({
error: vi.fn(),
}),
}));
vi.mock("@homarr/core/infrastructure/db/env", () => ({
dbEnv: { DRIVER: "better-sqlite3" },
}));
const mcpTestRouter = createTRPCRouter({
app: appRouter,
board: boardRouter,
customWidget: customWidgetRouter,
icon: iconsRouter,
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",
"board_getBoardSettings",
"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_entityDetails",
"smartHome_entityState",
"widgetSecrets_getConfiguredKinds",
],
mutation: [
"app_create",
"app_delete",
"app_update",
"board_addItem",
"board_changeBoardVisibility",
"board_createBoard",
"board_deleteBoard",
"board_duplicateBoard",
"board_renameBoard",
"board_resetLayout",
"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_getBoardSettings");
expect(toolNames).toContain("board_duplicateBoard");
expect(toolNames).toContain("customWidget_all");
expect(toolNames).toContain("customWidget_byId");
expect(toolNames).toContain("customWidget_create");
expect(toolNames).toContain("customWidget_update");
expect(toolNames).toContain("icon_findIcons");
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,
},
]);
});
});
@@ -1,6 +1,6 @@
import { describe, expect, test, vi } from "vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { settleIntegrationQueries } from "../settle-integrations";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries, toPublicIntegrationError } from "../settle-integrations";
const mocks = vi.hoisted(() => ({
logger: { warn: vi.fn() },
@@ -19,7 +19,20 @@ const integrations = [
{ id: "second", name: "Second", kind: "jellyfin" },
];
beforeEach(() => vi.clearAllMocks());
describe("settleIntegrationQueries", () => {
test("maps sensitive integration failures to a stable public code", () => {
const error = new Error(
"GET http://admin:password@nas.internal.local/private/path?token=secret returned\nprivate response body",
);
const publicError = toPublicIntegrationError(error);
expect(publicError).toBe(PUBLIC_INTEGRATION_ERROR);
expect(publicError).not.toMatch(/admin|password|nas\.internal|private|token|secret|response body/i);
});
test("returns an empty result when no integrations are configured", async () => {
await expect(settleIntegrationQueries([], vi.fn())).resolves.toEqual([]);
});
@@ -35,9 +48,14 @@ describe("settleIntegrationQueries", () => {
});
test("rejects when every integration fails", async () => {
const error = new Error("Integration unavailable");
const error = new Error("Integration unavailable with token=secret");
await expect(settleIntegrationQueries(integrations, () => Promise.reject(error))).rejects.toBe(error);
await expect(settleIntegrationQueries(integrations, () => Promise.reject(error))).rejects.toMatchObject({
code: "INTERNAL_SERVER_ERROR",
message: "All integration queries failed",
cause: error,
});
expect(mocks.logger.warn).toHaveBeenCalledTimes(integrations.length);
});
test("uses configured fallbacks when every integration fails", async () => {
@@ -47,4 +65,25 @@ describe("settleIntegrationQueries", () => {
}),
).resolves.toEqual(["First", "Second"]);
});
test("can retain partial fallbacks while rejecting total failure", async () => {
const error = new Error("Integration unavailable");
const options = {
fallback: (integration: (typeof integrations)[number]) => integration.name,
throwOnAllFailures: true,
};
await expect(
settleIntegrationQueries(
integrations,
(integration) => (integration.id === "first" ? Promise.reject(error) : Promise.resolve(integration.name)),
options,
),
).resolves.toEqual(["First", "Second"]);
await expect(settleIntegrationQueries(integrations, () => Promise.reject(error), options)).rejects.toMatchObject({
code: "INTERNAL_SERVER_ERROR",
message: "All integration queries failed",
cause: error,
});
});
});
+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),
}),
);