feat(custom-widgets): Full Mantine v9 + SubFetch support for custom JSX (#6304)

This commit is contained in:
Thomas authored and GitHub committed 2026-07-15 14:06:02 +02:00
1 parent 0a0b3a12c9
commit 3b2fc2527e
181 files changed
+18472 -4364

No files matched your search

+2
View File
@@ -13,6 +13,7 @@ import { bazarrRouter } from "./router/widgets/bazarr";
import { serverSettingsRouter } from "./router/serverSettings";
import { beszelRouter } from "./router/widgets/beszel";
import { calendarRouter } from "./router/widgets/calendar";
import { customWidgetRouter } from "./router/custom-widget/custom-widget-router";
import { dnsHoleRouter } from "./router/widgets/dns-hole";
import { downloadsRouter } from "./router/widgets/downloads";
import { healthMonitoringRouter } from "./router/widgets/health-monitoring";
@@ -35,6 +36,7 @@ export const mcpRouter = createTRPCRouter({
serverSettings: serverSettingsRouter,
beszel: beszelRouter,
calendar: calendarRouter,
customWidget: customWidgetRouter,
dnsHole: dnsHoleRouter,
downloads: downloadsRouter,
healthMonitoring: healthMonitoringRouter,
+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 }>,
@@ -1,183 +1,150 @@
import { TRPCError } from "@trpc/server";
import superjson from "superjson";
import { stringify as stringifySuperJson } from "superjson";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { decryptSecret, encryptSecret } from "@homarr/common/server";
import { encryptSecret } from "@homarr/common/server";
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
import { eq } from "@homarr/db";
import { createLogger } from "@homarr/core/infrastructure/logs";
import {
customWidgetAuthTypes,
customWidgetCreateSchema,
customWidgetDisplayTypes,
customWidgetImportSchema,
customWidgetMethods,
customWidgetSecretKinds,
customWidgetUpdateSchema,
displayConfigSchema,
} from "@homarr/validation/custom-widget";
customJsxDisplayConfigV2Schema,
} from "@homarr/custom-widgets/core";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
import { applyAuth } from "./auth";
import { extractDisplayDataWithFallback } from "./display-data";
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
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 { parseDisplayConfig } from "./parse-display-config";
import { transferProcedures } from "./transfer-procedures";
import { templateProcedures } from "./template-procedures";
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
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),
displayConfig: (value) => stringifySuperJson(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,
all: protectedProcedure.query(async ({ ctx }) => {
const definitions = await ctx.db.query.customWidgetDefinitions.findMany({
orderBy: (table, { asc }) => asc(table.name),
});
...managementQueryProcedures,
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,
}));
}),
create: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Create a validated custom widget. Admin only. Call customWidget_schema and customWidget_validate first. For Custom JSX use jsxApiVersion 2, named requests, a GET base method, and no inline credentials.",
},
})
.input(customWidgetCreateSchema)
.mutation(async ({ ctx, input }) => {
const id = createId();
byId: protectedProcedure.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 },
});
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: stringifySuperJson(input.displayConfig),
creatorId: ctx.session.user.id,
});
if (!definition) {
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
}
const displayConfig = parseDisplayConfig(
definition.displayConfig,
input.id,
logger,
"Corrupt displayConfig in custom widget",
);
return {
...definition,
enabled: definition.enabled,
displayConfig,
secrets: definition.secrets.map((s) => ({
kind: s.kind,
hasValue: true,
updatedAt: s.updatedAt,
})),
};
}),
create: adminProcedure.input(customWidgetCreateSchema).mutation(async ({ ctx, input }) => {
const id = createId();
await ctx.db.insert(customWidgetDefinitions).values({
id,
name: input.name,
description: input.description,
iconUrl: input.iconUrl,
url: input.url,
authType: input.authType,
headerName: input.headerName,
method: input.method,
requestBody: input.requestBody,
displayType: input.displayType,
displayConfig: superjson.stringify(input.displayConfig),
creatorId: ctx.session.user.id,
});
if (input.secrets.length > 0) {
await ctx.db.insert(customWidgetSecrets).values(
input.secrets.map((secret) => ({
kind: secret.kind,
value: encryptSecret(secret.value),
definitionId: id,
updatedAt: new Date(),
})),
);
}
logger.info("Created custom widget definition", { id, name: input.name });
return { id };
}),
update: adminProcedure.input(customWidgetUpdateSchema).mutation(async ({ ctx, input }) => {
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND" });
}
const { id, secrets, ...updateFields } = input;
const updateValues: Record<string, unknown> = { updatedAt: new Date() };
for (const [key, value] of Object.entries(updateFields)) {
if (value === undefined) continue;
const serialize = updateFieldSerializers[key];
if (serialize) {
updateValues[key] = serialize(value);
} else {
updateValues[key] = value;
}
}
await ctx.db.update(customWidgetDefinitions).set(updateValues).where(eq(customWidgetDefinitions.id, id));
if (secrets !== undefined) {
const effectiveAuthType = (updateFields.authType as string | undefined) ?? existing.authType;
if (secrets.length > 0) {
await ctx.db.delete(customWidgetSecrets).where(eq(customWidgetSecrets.definitionId, id));
if (input.secrets.length > 0) {
await ctx.db.insert(customWidgetSecrets).values(
secrets.map((secret) => ({
input.secrets.map((secret) => ({
kind: secret.kind,
value: encryptSecret(secret.value),
definitionId: id,
updatedAt: new Date(),
})),
);
} else if (effectiveAuthType === "none") {
await ctx.db.delete(customWidgetSecrets).where(eq(customWidgetSecrets.definitionId, id));
}
}
logger.info("Updated custom widget definition", { id });
}),
logger.info("Created custom widget definition", { id, name: input.name });
return { id };
}),
update: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Update an existing custom widget. Admin only. REQUIRED: id. Read it with customWidget_byId, preserve unrelated fields, validate the resulting complete draft, then send the changed fields. Omit secrets to preserve stored credentials.",
},
})
.input(customWidgetUpdateSchema)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND" });
}
const effectiveDisplayConfig =
input.displayConfig ??
parseDisplayConfig(existing.displayConfig, input.id, logger, "Corrupt displayConfig in custom widget update");
const effectiveMethod = input.method ?? existing.method;
if (customJsxDisplayConfigV2Schema.safeParse(effectiveDisplayConfig).success && effectiveMethod !== "GET") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Custom JSX v2 base data requests must use GET; mutations belong in named actions",
});
}
const { id, secrets, ...updateFields } = input;
const updateValues: Record<string, unknown> = { updatedAt: new Date() };
for (const [key, value] of Object.entries(updateFields)) {
if (value === undefined) continue;
const serialize = updateFieldSerializers[key];
if (serialize) {
updateValues[key] = serialize(value);
} else {
updateValues[key] = value;
}
}
await ctx.db.update(customWidgetDefinitions).set(updateValues).where(eq(customWidgetDefinitions.id, id));
if (secrets !== undefined) {
const effectiveAuthType = (updateFields.authType as string | undefined) ?? existing.authType;
if (secrets.length > 0) {
await ctx.db.delete(customWidgetSecrets).where(eq(customWidgetSecrets.definitionId, id));
await ctx.db.insert(customWidgetSecrets).values(
secrets.map((secret) => ({
kind: secret.kind,
value: encryptSecret(secret.value),
definitionId: id,
updatedAt: new Date(),
})),
);
} else if (
effectiveAuthType === "none" ||
(typeof updateFields.authType === "string" && updateFields.authType !== existing.authType)
) {
await ctx.db.delete(customWidgetSecrets).where(eq(customWidgetSecrets.definitionId, id));
}
}
logger.info("Updated custom widget definition", { id });
}),
toggleEnabled: adminProcedure
.input(z.object({ id: z.string(), enabled: z.boolean() }))
@@ -209,143 +176,13 @@ export const customWidgetRouter = createTRPCRouter({
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),
});
...templateProcedures,
if (!definition) {
throw new TRPCError({ code: "NOT_FOUND" });
}
...transferProcedures,
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(),
}),
)
.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);
}
}),
...previewBaseProcedures,
...previewQueryProcedures,
...previewActionProcedures,
duplicate: adminProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
@@ -388,69 +225,4 @@ export const customWidgetRouter = createTRPCRouter({
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,
});
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);
}
}),
});
@@ -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,84 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { eq, or } from "@homarr/db";
import { boards, customWidgetDefinitions } from "@homarr/db/schema";
import { permissionRequiredProcedure, protectedProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
import { parseDisplayConfig } from "./parse-display-config";
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
const logger = createLogger({ module: "custom-widget" });
export const managementQueryProcedures = {
all: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"List all custom-widget definitions. Admin only. Use the returned ID to inspect or update a widget.",
},
})
.query(async ({ ctx }) => {
const definitions = await ctx.db.query.customWidgetDefinitions.findMany({
orderBy: (table, { asc }) => asc(table.name),
});
return definitions.map((definition) => ({
id: definition.id,
name: definition.name,
description: definition.description,
iconUrl: definition.iconUrl,
url: definition.url,
method: definition.method,
displayType: definition.displayType,
authType: definition.authType,
enabled: definition.enabled,
}));
}),
byId: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Get one complete custom-widget definition for iterative editing. REQUIRED: id. Stored secret values are never returned; only their presence is reported.",
},
})
.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 {
...definition,
displayConfig: parseDisplayConfig(
definition.displayConfig,
input.id,
logger,
"Corrupt displayConfig in custom widget",
),
secrets: definition.secrets.map((secret) => ({
kind: secret.kind,
hasValue: true,
updatedAt: secret.updatedAt,
})),
};
}),
available: protectedProcedure
.input(z.object({ boardId: z.string(), currentId: z.string().optional() }))
.query(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
return 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),
columns: { id: true, name: true, description: true, iconUrl: true, displayType: true },
});
}),
};
@@ -0,0 +1,54 @@
import { z } from "zod/v4";
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure, publicProcedure } from "../../trpc";
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
let schemaCache: Record<string, unknown> | undefined;
function getImportJsonSchema() {
schemaCache ??= {
...z.toJSONSchema(customWidgetImportSchema),
title: "Homarr Custom Widget",
description:
"Schema for importing/exporting custom widget definitions in Homarr. All jsonPath fields use JSONPath syntax. The displayConfig must match displayType. Secrets are configured separately after import.",
};
return schemaCache;
}
export const metadataProcedures = {
schema: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Get the JSON Schema for Homarr custom-widget imports. Use this before generating a widget and before calling validate, create, or update.",
},
})
.query(() => getImportJsonSchema()),
validate: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Validate a complete custom-widget JSON draft without saving or making network requests. Returns structured issue paths so an agent can correct and validate again.",
},
})
.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,13 +1,15 @@
import { TRPCError } from "@trpc/server";
import superjson from "superjson";
import { parse as parseSuperJson } from "superjson";
import type { createLogger } from "@homarr/core/infrastructure/logs";
import { displayConfigSchema } from "@homarr/custom-widgets/core";
import type { DisplayConfig } from "@homarr/custom-widgets/core";
type Logger = ReturnType<typeof createLogger>;
export function parseDisplayConfig(raw: string, id: string, logger: Logger, context: string): Record<string, unknown> {
export function parseDisplayConfig(raw: string, id: string, logger: Logger, context: string): DisplayConfig {
try {
return superjson.parse(raw) as Record<string, unknown>;
return displayConfigSchema.parse(parseSuperJson(raw));
} catch {
logger.error(context, { id });
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Widget has corrupt display configuration" });
@@ -0,0 +1,119 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { permissionRequiredProcedure } from "../../trpc";
import { previewSessionRequestSchema, recordPreviewJournal } from "./preview-procedure-helpers";
import { executeCustomWidgetRequest } from "./request-executor";
import { renderRequestBody, renderRequestTarget } from "./request-manifest";
import { acquireCustomWidgetRequestLimit } from "./request-limits";
import { getPreviewSession, getPreviewSessionSecrets } from "./preview-sessions";
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
export const previewActionProcedures = {
simulatePreviewAction: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Validate and simulate one named custom-widget action without sending a network request. REQUIRED: sessionId from customWidget_preview, action requestId, and typed params. Confirms parameter substitution and records a simulated journal entry. Returns requiredPermission as the action's minimum board permission (view, modify, or full), not as an execution result. This MCP tool never enables or executes live actions.",
},
})
.input(previewSessionRequestSchema)
.mutation(async ({ ctx, input }) => {
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
const request = session.requests.find(
(candidate) => candidate.id === input.requestId && candidate.kind === "action",
);
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview action was not found" });
renderRequestTarget(session.baseUrl, request, input.params);
renderRequestBody(request.bodyTemplate, input.params);
await recordPreviewJournal(session, {
requestId: request.id,
kind: "action",
method: request.method,
pathTemplate: request.pathTemplate,
status: null,
durationMs: 0,
simulated: true,
});
return {
ok: true,
simulated: true as const,
requestId: request.id,
method: request.method,
requiredPermission: request.minimumBoardPermission,
};
}),
previewAction: adminProcedure
.input(previewSessionRequestSchema.extend({ confirmed: z.boolean().optional() }))
.mutation(async ({ ctx, input }) => {
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
const request = session.requests.find(
(candidate) => candidate.id === input.requestId && candidate.kind === "action",
);
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview action was not found" });
const targetUrl = renderRequestTarget(session.baseUrl, request, input.params);
const body = renderRequestBody(request.bodyTemplate, input.params);
if (!session.liveActions) {
await recordPreviewJournal(session, {
requestId: request.id,
kind: "action",
method: request.method,
pathTemplate: request.pathTemplate,
status: null,
durationMs: 0,
simulated: true,
});
return { ok: true, status: 0, statusText: "Simulated", data: null, simulated: true as const };
}
if (request.method === "DELETE" && input.confirmed !== true) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "DELETE actions require 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: session.baseUrl,
targetUrl,
method: request.method,
body,
staticHeaders: request.staticHeaders,
auth:
request.auth === "none"
? undefined
: {
type: session.authType,
secrets: getPreviewSessionSecrets(session),
headerName: session.headerName,
},
networkScope: session.networkScope,
kind: "action",
});
await recordPreviewJournal(session, {
requestId: request.id,
kind: "action",
method: request.method,
pathTemplate: request.pathTemplate,
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,159 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { decryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { eq } from "@homarr/db";
import { customWidgetDefinitions } from "@homarr/db/schema";
import {
customWidgetAuthTypes,
customWidgetDisplayTypes,
customWidgetMethods,
customWidgetSecretKinds,
customJsxDisplayConfigV2Schema,
displayConfigSchema,
extractActionButtonDisplay,
extractDisplayDataWithFallback,
} from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure } from "../../trpc";
import { recordPreviewJournal } from "./preview-procedure-helpers";
import { executeCustomWidgetRequest } from "./request-executor";
import { acquireCustomWidgetRequestLimit } from "./request-limits";
import { createPreviewSession, getPreviewSession } from "./preview-sessions";
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
const logger = createLogger({ module: "custom-widget" });
export const previewBaseProcedures = {
preview: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Test a custom-widget draft through Homarr's hardened preview executor. Admin only. GET only; actions remain simulated. REQUIRED: url, method GET, authType, displayType, displayConfig. OPTIONAL: definitionId reuses that saved widget's stored credentials; omit secrets whenever possible. Returns sanitized response data, HTTP status, and a short-lived preview session for named query testing.",
},
})
.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() })).default([]),
definitionId: z.string().optional(),
}),
)
.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 v2Config = customJsxDisplayConfigV2Schema.safeParse(input.displayConfig);
const previewSession = v2Config.success
? await createPreviewSession({
userId: ctx.session.user.id,
baseUrl: input.url,
authType: input.authType,
headerName: input.headerName,
secrets,
networkScope: v2Config.data.networkScope,
requests: v2Config.data.requests,
definitionId: input.definitionId,
})
: null;
const storedPreviewSession = previewSession
? await getPreviewSession(previewSession.id, ctx.session.user.id)
: null;
if (input.method !== "GET") {
return {
success: true as const,
simulated: true as const,
responseInfo: null,
rawResponse: null,
displayData: input.displayType === "actionButton" ? extractActionButtonDisplay(input.displayConfig) : null,
previewSession,
};
}
const release = await acquireCustomWidgetRequestLimit({
category: "query",
userId: ctx.session.user.id,
itemId: `preview:${ctx.session.user.id}`,
definitionId: input.definitionId ?? `preview:${ctx.session.user.id}`,
});
const startedAt = Date.now();
try {
const response = await executeCustomWidgetRequest({
baseUrl: input.url,
method: input.method,
body: undefined,
auth: { type: input.authType, secrets, headerName: input.headerName },
networkScope: v2Config.success ? v2Config.data.networkScope : "private",
kind: "query",
});
if (storedPreviewSession) {
await recordPreviewJournal(storedPreviewSession, {
requestId: "base",
kind: "query",
method: "GET",
pathTemplate: new URL(input.url).pathname,
status: response.status,
durationMs: Date.now() - startedAt,
simulated: false,
});
}
const responseInfo = { status: response.status, statusText: response.statusText };
if (!response.ok) {
return {
success: false as const,
error: `HTTP ${response.status}: ${response.statusText}`,
responseInfo,
rawResponse: typeof response.data === "string" ? response.data : JSON.stringify(response.data, null, 2),
previewSession,
};
}
const displayData = extractDisplayDataWithFallback(response.data, input.displayType, input.displayConfig);
return {
success: true as const,
responseInfo,
rawResponse: typeof response.data === "string" ? response.data : JSON.stringify(response.data, null, 2),
displayData,
previewSession,
};
} catch (error) {
if (error instanceof TRPCError) throw error;
logger.error("Custom widget preview failed", {
errorName: error instanceof Error ? error.name : "UnknownError",
});
return {
success: false as const,
error: error instanceof Error ? error.message : "Failed to fetch data",
responseInfo: null,
rawResponse: null,
previewSession,
};
} finally {
await release();
}
}),
};
@@ -0,0 +1,23 @@
import { createLogger } from "@homarr/core/infrastructure/logs";
import { z } from "zod/v4";
import { appendPreviewJournal } from "./preview-sessions";
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",
});
}
};
@@ -0,0 +1,98 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { permissionRequiredProcedure } from "../../trpc";
import { previewSessionRequestSchema, recordPreviewJournal } from "./preview-procedure-helpers";
import { executeCustomWidgetRequest } from "./request-executor";
import { hashRuntimeParams, renderRequestTarget } from "./request-manifest";
import { acquireCustomWidgetRequestLimit } from "./request-limits";
import {
getPreviewJournal,
getPreviewSession,
getPreviewSessionSecrets,
setPreviewSessionLiveActions,
} from "./preview-sessions";
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
export const previewQueryProcedures = {
previewQuery: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Run one named GET query from a short-lived custom-widget preview session. REQUIRED: sessionId from customWidget_preview, requestId declared as a query, and typed params. Uses the hardened executor and returns sanitized response data for another validate/update iteration.",
},
})
.input(previewSessionRequestSchema)
.query(async ({ ctx, input }) => {
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
const request = session.requests.find(
(candidate) => candidate.id === input.requestId && candidate.kind === "query",
);
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview query was not found" });
const targetUrl = renderRequestTarget(session.baseUrl, request, input.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: session.baseUrl,
targetUrl,
method: "GET",
staticHeaders: request.staticHeaders,
auth:
request.auth === "none"
? undefined
: {
type: session.authType,
secrets: getPreviewSessionSecrets(session),
headerName: session.headerName,
},
networkScope: session.networkScope,
kind: "query",
cacheKey: `custom-jsx:preview:${session.id}:${request.id}:${hashRuntimeParams(input.params)}`,
cacheTtlSeconds: request.cacheTtlSeconds,
});
await recordPreviewJournal(session, {
requestId: request.id,
kind: "query",
method: "GET",
pathTemplate: request.pathTemplate,
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: adminProcedure
.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: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Read the redacted request journal for a custom-widget preview session. REQUIRED: sessionId. Use it to inspect statuses and durations; credentials and parameter values are never returned.",
},
})
.input(z.object({ sessionId: z.string().min(1) }))
.query(async ({ ctx, input }) => getPreviewJournal(input.sessionId, ctx.session.user.id)),
};
@@ -0,0 +1,79 @@
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:";
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 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) => getService().getSecrets(session);
@@ -0,0 +1,83 @@
import { createLogger } from "@homarr/core/infrastructure/logs";
import {
assertJsonBudget as assertDomainJsonBudget,
assertSafeStaticHeaders as assertDomainSafeStaticHeaders,
classifyAddress,
executeCustomWidgetRequest as executeDomainRequest,
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);
}
}
@@ -0,0 +1,74 @@
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 count = redis.call('INCR', KEYS[1])
if count == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[2]) end
if count > tonumber(ARGV[1]) then redis.call('DECR', KEYS[1]); return 0 end
return count
`;
const RELEASE_SCRIPT = `
local count = tonumber(redis.call('GET', KEYS[1]) or '0')
if count <= 1 then redis.call('DEL', KEYS[1]); return 0 end
return redis.call('DECR', KEYS[1])
`;
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, limit: number, ttlMs: number) {
return Number(await this.redis.eval(ACQUIRE_SCRIPT, 1, key, limit, ttlMs)) > 0;
}
public async releaseConcurrency(key: string) {
await this.redis.eval(RELEASE_SCRIPT, 1, key);
}
}
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,37 @@
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
import {
hashRuntimeParams,
renderRequestBody as renderDomainBody,
renderRequestTarget as renderDomainTarget,
validateRuntimeParams as validateDomainParams,
} 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 };
export function validateRuntimeParams(request: CustomJsxRequest, params: CustomJsxRuntimeParams): void {
try {
validateDomainParams(request, params);
} catch (error) {
toTrpcError(error);
}
}
export function renderRequestBody(request: CustomJsxRequest["bodyTemplate"], params: CustomJsxRuntimeParams) {
try {
return renderDomainBody(request, params);
} catch (error) {
toTrpcError(error);
}
}
export function renderRequestTarget(baseUrl: string, request: CustomJsxRequest, params: CustomJsxRuntimeParams) {
try {
return renderDomainTarget(baseUrl, request, params);
} catch (error) {
toTrpcError(error);
}
}
@@ -0,0 +1,226 @@
import { createHash } from "node:crypto";
import { TRPCError } from "@trpc/server";
import { stringify as stringifySuperJson } from "superjson";
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { displayConfigSchema } from "@homarr/custom-widgets/core";
import { eq } from "@homarr/db";
import { customWidgetDefinitions } from "@homarr/db/schema";
import { permissionRequiredProcedure } from "../../trpc";
import { parseDisplayConfig } from "./parse-display-config";
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
const logger = createLogger({ module: "custom-widget" });
const getTemplateRevision = (template: string) => createHash("sha256").update(template).digest("hex").slice(0, 16);
const validateUpdatedTemplate = (displayConfig: Record<string, unknown>, template: string) => {
displayConfig.template = template;
const result = displayConfigSchema.safeParse(displayConfig);
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;
};
export const templateProcedures = {
readTemplate: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Read the JSX template of a custom widget definition as plain text. Returns the template string separately from the full widget config, making it easier to inspect and edit.",
},
})
.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 config = parseDisplayConfig(
definition.displayConfig,
input.id,
logger,
"Corrupt displayConfig in custom widget readTemplate",
);
if (config.type !== "customJsx") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Widget is not using customJsx display type",
});
}
const template = (config.template as string | undefined) ?? "";
return {
id: definition.id,
name: definition.name,
template,
templateLines: template.split("\n"),
revision: getTemplateRevision(template),
};
}),
writeTemplate: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Update only the JSX template of a custom widget definition. Accepts either a single template string or templateLines (array of strings joined with newlines). Validates the template AST before saving. This avoids needing to send the full widget JSON for template-only edits.",
},
})
.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 resolvedTemplate =
input.templateLines !== undefined ? 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 displayConfig = parseDisplayConfig(
existing.displayConfig,
input.id,
logger,
"Corrupt displayConfig in custom widget writeTemplate",
);
if (displayConfig.type !== "customJsx") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Widget is not using customJsx display type",
});
}
if (resolvedTemplate.trim().length === 0) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Template must not be empty" });
}
if (resolvedTemplate.length > 50_000) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Template exceeds the 50,000 character limit" });
}
const validatedConfig = validateUpdatedTemplate(displayConfig, resolvedTemplate);
await ctx.db
.update(customWidgetDefinitions)
.set({ displayConfig: stringifySuperJson(validatedConfig), updatedAt: new Date() })
.where(eq(customWidgetDefinitions.id, input.id));
logger.info("Updated custom widget template", { id: input.id });
return {
id: input.id,
template: resolvedTemplate,
templateLines: resolvedTemplate.split("\n"),
revision: getTemplateRevision(resolvedTemplate),
};
}),
patchTemplate: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Patch selected lines of a Custom JSX template without rewriting the whole template. First call customWidget_readTemplate and pass its revision as expectedRevision. Each edit uses a 1-based startLine, deleteCount, and replacementLines. Edits are applied atomically, then the complete template and named-request references are validated before saving. A stale revision is rejected so the agent can re-read and retry safely.",
},
})
.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 displayConfig = parseDisplayConfig(
existing.displayConfig,
input.id,
logger,
"Corrupt displayConfig in custom widget patchTemplate",
);
if (displayConfig.type !== "customJsx") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Widget is not using customJsx display type" });
}
const currentTemplate = (displayConfig.template as string | undefined) ?? "";
if (getTemplateRevision(currentTemplate) !== input.expectedRevision) {
throw new TRPCError({
code: "CONFLICT",
message: "Template changed since it was read. Read the template again and retry the patch.",
});
}
const lines = currentTemplate.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 = lines.join("\n");
const validatedConfig = validateUpdatedTemplate(displayConfig, template);
await ctx.db
.update(customWidgetDefinitions)
.set({ displayConfig: stringifySuperJson(validatedConfig), updatedAt: new Date() })
.where(eq(customWidgetDefinitions.id, input.id));
logger.info("Patched custom widget template", { id: input.id, editCount: input.edits.length });
return {
id: input.id,
template,
templateLines: lines,
revision: getTemplateRevision(template),
};
}),
};
@@ -0,0 +1,80 @@
import { TRPCError } from "@trpc/server";
import { stringify as stringifySuperJson } from "superjson";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { eq } from "@homarr/db";
import { customWidgetDefinitions } from "@homarr/db/schema";
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure } from "../../trpc";
import { parseDisplayConfig } from "./parse-display-config";
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
const logger = createLogger({ module: "custom-widget" });
export const transferProcedures = {
export: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Export one custom widget as a homarr-custom-widget-v3 object. REQUIRED: id. Secrets are excluded.",
},
})
.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-v3" 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
.meta({
mcp: {
enabled: true,
description:
"Create a custom widget from a validated homarr-custom-widget-v3 import object. Secrets are omitted and configured separately.",
},
})
.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: stringifySuperJson(input.displayConfig),
creatorId: ctx.session.user.id,
});
logger.info("Imported custom widget definition", { id, name: input.name });
return { id };
}),
};
@@ -0,0 +1,67 @@
import { describe, expect, test } from "vitest";
import {
appendPreviewJournal,
createPreviewSession,
getPreviewJournal,
getPreviewSession,
getPreviewSessionSecrets,
setPreviewSessionLiveActions,
} from "../../custom-widget/preview-sessions";
const queryRequest = {
id: "status",
kind: "query",
method: "GET",
pathTemplate: "/status/{id}",
parameters: { id: "string" },
auth: "inherit",
minimumBoardPermission: "view",
} as const;
describe("custom widget preview sessions", () => {
test("stores encrypted credentials in a short-lived user-bound manifest", async () => {
const created = await createPreviewSession({
userId: "admin-1",
baseUrl: "https://example.com/api",
authType: "bearer",
secrets: [{ kind: "apiKey", value: "plain-secret" }],
networkScope: "public",
requests: [queryRequest],
});
const session = await getPreviewSession(created.id, "admin-1");
expect(session.secrets[0]?.value).not.toContain("plain-secret");
expect(getPreviewSessionSecrets(session)).toEqual([{ kind: "apiKey", value: "plain-secret" }]);
await expect(getPreviewSession(created.id, "admin-2")).rejects.toMatchObject({ code: "NOT_FOUND" });
});
test("keeps actions simulated until explicitly enabled", async () => {
const created = await createPreviewSession({
userId: "admin-1",
baseUrl: "https://example.com/api",
authType: "none",
secrets: [],
networkScope: "public",
requests: [queryRequest],
});
expect((await getPreviewSession(created.id, "admin-1")).liveActions).toBe(false);
await setPreviewSessionLiveActions(created.id, "admin-1", true);
const session = await getPreviewSession(created.id, "admin-1");
expect(session.liveActions).toBe(true);
await appendPreviewJournal(session, {
requestId: "status",
kind: "query",
method: "GET",
pathTemplate: "/status/{id}",
status: 200,
durationMs: 12,
simulated: false,
});
expect(await getPreviewJournal(created.id, "admin-1")).toEqual([
expect.objectContaining({ requestId: "status", pathTemplate: "/status/{id}", durationMs: 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,224 @@
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";
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,
resolveSameOriginTarget: (_baseUrl: string, targetUrl: string | URL) => new URL(targetUrl),
}));
vi.mock("../../custom-widget/request-limits", () => ({
acquireCustomWidgetRequestLimit: vi.fn(async () => async () => undefined),
}));
import { customApiRouter } from "../../widgets/custom-api";
const createSession = (userId: string): Session => ({
user: { id: userId, permissions: [], colorScheme: "light" },
expires: new Date(Date.now() + 60_000).toISOString(),
});
const queryRequest = {
id: "status",
kind: "query",
method: "GET",
pathTemplate: "/status/{name}",
parameters: { name: "string" },
auth: "inherit",
minimumBoardPermission: "view",
} as const;
const actionRequest = {
id: "toggle",
kind: "action",
method: "POST",
pathTemplate: "/toggle",
parameters: { enabled: "boolean" },
bodyTemplate: { enabled: { $param: "enabled" } },
auth: "inherit",
minimumBoardPermission: "modify",
} as const;
const deleteRequest = {
id: "delete",
kind: "action",
method: "DELETE",
pathTemplate: "/resource",
parameters: {},
auth: "inherit",
minimumBoardPermission: "full",
} as const;
const setup = async ({
isPublic = true,
method = "GET",
requests = [queryRequest, actionRequest, deleteRequest],
}: {
isPublic?: boolean;
method?: "GET" | "POST";
requests?: readonly Record<string, unknown>[];
} = {}) => {
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,
name: "Custom JSX",
url: "https://example.com/api",
method,
authType: "none",
displayType: "customJsx",
displayConfig: stringifySuperJson({
type: "customJsx",
jsxApiVersion: 2,
template: "<Text>{data.value}</Text>",
networkScope: "public",
requests,
}),
creatorId: ownerId,
});
await db.insert(items).values({
id: itemId,
boardId,
kind: "customApi",
options: stringifySuperJson({ definitionId, refreshInterval: 30 }),
});
return { db, ownerId, boardId, itemId, definitionId };
};
describe("customApiRouter v2", () => {
beforeEach(() => mocks.executeRequest.mockClear());
test("does not expose the removed raw subFetch procedure", () => {
expect(Object.hasOwn(customApiRouter._def.procedures, "subFetch")).toBe(false);
});
test("allows anonymous queries only through a public board placement", async () => {
const { db, itemId } = await setup();
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
const result = await caller.queryRequest({ itemId, requestId: "status", params: { name: "homarr" } });
expect(result).toMatchObject({ ok: true, status: 200, data: { value: 42 } });
expect(mocks.executeRequest).toHaveBeenCalledOnce();
});
test("does not expose a placed definition through an inaccessible board", async () => {
const { db, itemId } = await setup({ isPublic: false });
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
await expect(caller.getData({ itemId })).rejects.toMatchObject({ code: "NOT_FOUND" });
expect(mocks.executeRequest).not.toHaveBeenCalled();
});
test("does not execute non-GET base data requests automatically", async () => {
const { db, itemId } = await setup({ method: "POST" });
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
await expect(caller.getData({ itemId })).resolves.toEqual({ type: "networkAccessNeedsReview" });
expect(mocks.executeRequest).not.toHaveBeenCalled();
});
test("cannot select a request from a definition that is not attached to the item", async () => {
const { db, itemId, ownerId } = await setup({ requests: [] });
const otherDefinitionId = createId();
await db.insert(customWidgetDefinitions).values({
id: otherDefinitionId,
name: "Other",
url: "https://example.com/api",
method: "GET",
authType: "none",
displayType: "customJsx",
displayConfig: stringifySuperJson({
type: "customJsx",
jsxApiVersion: 2,
template: "<Text />",
networkScope: "public",
requests: [queryRequest],
}),
creatorId: ownerId,
});
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
await expect(
caller.queryRequest({ itemId, requestId: "status", params: { name: "homarr" } }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
expect(mocks.executeRequest).not.toHaveBeenCalled();
});
test("rejects mismatched request kinds and typed parameters", async () => {
const { db, itemId } = await setup();
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
await expect(caller.queryRequest({ itemId, requestId: "toggle", params: { enabled: true } })).rejects.toMatchObject(
{ code: "NOT_FOUND" },
);
await expect(caller.queryRequest({ itemId, requestId: "status", params: { name: 42 } })).rejects.toMatchObject({
code: "BAD_REQUEST",
});
expect(mocks.executeRequest).not.toHaveBeenCalled();
});
test("rejects disabled definitions before any network request", async () => {
const { db, itemId, definitionId } = await setup();
await db
.update(customWidgetDefinitions)
.set({ enabled: false })
.where(eq(customWidgetDefinitions.id, definitionId));
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
await expect(caller.getData({ itemId })).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(mocks.executeRequest).not.toHaveBeenCalled();
});
test("requires modify permission for named actions", async () => {
const { db, itemId } = await setup();
const viewerId = createId();
await db.insert(users).values({ id: viewerId });
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(viewerId) });
await expect(
caller.executeAction({ itemId, requestId: "toggle", params: { enabled: true } }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
expect(mocks.executeRequest).not.toHaveBeenCalled();
});
test("executes an authorized named action once with typed body substitution", async () => {
const { db, itemId, boardId } = await setup({ isPublic: false });
const editorId = createId();
await db.insert(users).values({ id: editorId });
await db.insert(boardUserPermissions).values({ boardId, userId: editorId, permission: "modify" });
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(editorId) });
const result = await caller.executeAction({ itemId, requestId: "toggle", params: { enabled: true } });
expect(result.ok).toBe(true);
expect(mocks.executeRequest).toHaveBeenCalledOnce();
expect(mocks.executeRequest).toHaveBeenCalledWith(
expect.objectContaining({ method: "POST", body: '{"enabled":true}' }),
);
});
test("requires explicit confirmation before DELETE", async () => {
const { db, itemId, ownerId } = await setup({ isPublic: false });
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(ownerId) });
await expect(caller.executeAction({ itemId, requestId: "delete", params: {} })).rejects.toMatchObject({
code: "PRECONDITION_FAILED",
});
expect(mocks.executeRequest).not.toHaveBeenCalled();
});
});
@@ -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,
};
+255 -71
View File
@@ -1,91 +1,275 @@
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 { eq } from "@homarr/db";
import { boards, customWidgetDefinitions, items } from "@homarr/db/schema";
import type { BoardPermission } from "@homarr/definitions";
import {
customJsxDisplayConfigV2Schema,
displayConfigSchema,
extractActionButtonDisplay,
extractDisplayDataWithFallback,
} from "@homarr/custom-widgets/core";
import type { CustomJsxRequest, CustomWidgetMethod, DisplayConfig } from "@homarr/custom-widgets/core";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
import { executeCustomWidgetRequest } from "../custom-widget/request-executor";
import { hashRuntimeParams, renderRequestBody, renderRequestTarget } from "../custom-widget/request-manifest";
import { acquireCustomWidgetRequestLimit } from "../custom-widget/request-limits";
const logger = createLogger({ module: "widget:customApi" });
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({}),
});
const validateUrl = (urlString: string): URL => new URL(urlString);
type RouterContext = Parameters<typeof throwIfActionForbiddenAsync>[0];
type ResolvedDefinition = Awaited<ReturnType<typeof resolvePlacedDefinitionAsync>>;
const parseItemOptions = (raw: string): { definitionId: string } => {
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 };
} catch {
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget item not found" });
}
};
const parseDisplayConfig = (raw: string): DisplayConfig => {
try {
return displayConfigSchema.parse(parseSuperJson(raw));
} catch (error) {
logger.error("Invalid custom widget display configuration", { error });
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "Custom widget network access needs review",
});
}
};
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 { definitionId } = parseItemOptions(item.options);
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, 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" });
}
return { item, definition, displayConfig: parseDisplayConfig(definition.displayConfig) };
}
const getDecryptedSecrets = (resolved: ResolvedDefinition) =>
resolved.definition.secrets.map((secret) => ({ kind: secret.kind, value: decryptSecret(secret.value) }));
const getAuth = (resolved: ResolvedDefinition, mode: "inherit" | "none" = "inherit") =>
mode === "none"
? undefined
: {
type: resolved.definition.authType,
secrets: getDecryptedSecrets(resolved),
headerName: resolved.definition.headerName,
};
const getNetworkScope = (displayConfig: DisplayConfig) =>
displayConfig.type === "customJsx" && "jsxApiVersion" in displayConfig && displayConfig.jsxApiVersion === 2
? displayConfig.networkScope
: ("private" as const);
const getV2Config = (resolved: ResolvedDefinition) => {
const parsed = customJsxDisplayConfigV2Schema.safeParse(resolved.displayConfig);
if (!parsed.success) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "This template must be reviewed and migrated to named requests",
});
}
return parsed.data;
};
const findNamedRequest = (resolved: ResolvedDefinition, requestId: string, kind: "query" | "action") => {
const request = getV2Config(resolved).requests.find((candidate) => candidate.id === requestId);
if (!request || request.kind !== kind) {
throw new TRPCError({ code: "NOT_FOUND", message: "Named request not found" });
}
return request;
};
const getCacheKey = (
resolved: ResolvedDefinition,
request: CustomJsxRequest,
params: Record<string, string | number | boolean>,
) => {
const digest = hashRuntimeParams(params);
return `custom-jsx:${resolved.item.id}:${request.id}:${digest}`;
};
const assertUpstreamSuccess = (response: { ok: boolean; status: number; statusText: string }) => {
if (!response.ok) {
throw new TRPCError({ code: "BAD_GATEWAY", message: `API returned ${response.status}: ${response.statusText}` });
}
};
const withRequestLimit = async <T>(
ctx: RouterContext,
resolved: ResolvedDefinition,
category: "query" | "action" | "delete",
callback: () => Promise<T>,
) => {
const release = await acquireCustomWidgetRequestLimit({
category,
userId: ctx.session?.user.id,
itemId: resolved.item.id,
definitionId: resolved.definition.id,
});
try {
return await callback();
} finally {
await release();
}
};
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 },
});
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" });
}
getData: publicProcedure.input(itemInputSchema).query(async ({ ctx, input }) => {
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
const { definition, displayConfig } = resolved;
if (definition.displayType === "actionButton") {
return extractActionButtonDisplay(displayConfig);
return {
...(extractActionButtonDisplay(displayConfig) as Record<string, unknown>),
requiresConfirmation: definition.method === "DELETE",
};
}
if (definition.method !== "GET") {
return { type: "networkAccessNeedsReview" as const };
}
const decryptedSecrets = definition.secrets.map((s) => ({
kind: s.kind,
value: decryptSecret(s.value),
}));
const response = await withRequestLimit(ctx, resolved, "query", () =>
executeCustomWidgetRequest({
baseUrl: definition.url,
method: "GET",
auth: getAuth(resolved),
networkScope: getNetworkScope(displayConfig),
kind: "query",
cacheKey: `custom-widget:base:${resolved.item.id}`,
}),
);
assertUpstreamSuccess(response);
return extractDisplayDataWithFallback(response.data, definition.displayType, displayConfig);
}),
const url = validateUrl(definition.url);
const headers = new Headers({ Accept: "application/json" });
queryRequest: publicProcedure.input(namedRequestInputSchema).query(async ({ ctx, input }) => {
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
const request = findNamedRequest(resolved, input.requestId, "query");
const targetUrl = renderRequestTarget(resolved.definition.url, request, input.params);
const response = await withRequestLimit(ctx, resolved, "query", () =>
executeCustomWidgetRequest({
baseUrl: resolved.definition.url,
targetUrl,
method: "GET",
staticHeaders: request.staticHeaders,
auth: getAuth(resolved, request.auth),
networkScope: getV2Config(resolved).networkScope,
kind: "query",
cacheKey: getCacheKey(resolved, request, input.params),
cacheTtlSeconds: request.cacheTtlSeconds,
}),
);
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
data: response.data,
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
};
}),
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}`,
});
executeBaseAction: protectedProcedure
.input(itemInputSchema.extend({ confirmed: z.boolean().optional() }))
.mutation(async ({ ctx, input }) => {
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
const { definition } = resolved;
if (definition.displayType !== "actionButton") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Only actionButton widgets can use this action" });
}
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);
}
}),
const permission: BoardPermission = definition.method === "DELETE" ? "full" : "modify";
await throwIfActionForbiddenAsync(ctx, eq(boards.id, resolved.item.boardId), permission);
if (definition.method === "DELETE" && input.confirmed !== true) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "DELETE actions require confirmation" });
}
const response = await withRequestLimit(ctx, resolved, definition.method === "DELETE" ? "delete" : "action", () =>
executeCustomWidgetRequest({
baseUrl: definition.url,
method: definition.method,
body: definition.method === "GET" ? undefined : (definition.requestBody ?? undefined),
auth: getAuth(resolved),
networkScope: getNetworkScope(resolved.displayConfig),
kind: definition.method === "GET" ? "query" : "action",
}),
);
return {
success: response.ok,
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
responseInfo: { status: response.status, statusText: response.statusText },
};
}),
executeAction: protectedProcedure
.input(namedRequestInputSchema.extend({ confirmed: z.boolean().optional() }))
.mutation(async ({ ctx, input }) => {
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
const request = findNamedRequest(resolved, input.requestId, "action");
if (request.method === "DELETE" && input.confirmed !== true) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "DELETE actions require confirmation" });
}
await throwIfActionForbiddenAsync(
ctx,
eq(boards.id, resolved.item.boardId),
request.minimumBoardPermission as BoardPermission,
);
const targetUrl = renderRequestTarget(resolved.definition.url, request, input.params);
const response = await withRequestLimit(ctx, resolved, request.method === "DELETE" ? "delete" : "action", () =>
executeCustomWidgetRequest({
baseUrl: resolved.definition.url,
targetUrl,
method: request.method as CustomWidgetMethod,
body: renderRequestBody(request.bodyTemplate, input.params),
staticHeaders: request.staticHeaders,
auth: getAuth(resolved, request.auth),
networkScope: getV2Config(resolved).networkScope,
kind: "action",
}),
);
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
data: response.data,
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
};
}),
});
+43
View File
@@ -3,6 +3,7 @@ import { extractToolsFromProcedures } from "trpc-to-mcp";
import { appRouter } from "../router/app";
import { boardRouter } from "../router/board";
import { customWidgetRouter } from "../router/custom-widget/custom-widget-router";
import { infoRouter } from "../router/info";
import { inviteRouter } from "../router/invite";
import { serverSettingsRouter } from "../router/serverSettings";
@@ -13,6 +14,7 @@ vi.mock("@homarr/auth", () => ({}));
const mcpTestRouter = createTRPCRouter({
app: appRouter,
board: boardRouter,
customWidget: customWidgetRouter,
info: infoRouter,
invite: inviteRouter,
serverSettings: serverSettingsRouter,
@@ -34,6 +36,19 @@ test("MCP tools should contain expected procedures", () => {
expect(toolNames).toContain("app_create");
expect(toolNames).toContain("board_savePartialBoardSettings");
expect(toolNames).toContain("board_duplicateBoard");
expect(toolNames).toContain("customWidget_schema");
expect(toolNames).toContain("customWidget_validate");
expect(toolNames).toContain("customWidget_all");
expect(toolNames).toContain("customWidget_byId");
expect(toolNames).toContain("customWidget_readTemplate");
expect(toolNames).toContain("customWidget_writeTemplate");
expect(toolNames).toContain("customWidget_patchTemplate");
expect(toolNames).toContain("customWidget_create");
expect(toolNames).toContain("customWidget_import");
expect(toolNames).toContain("customWidget_update");
expect(toolNames).toContain("customWidget_preview");
expect(toolNames).toContain("customWidget_previewQuery");
expect(toolNames).toContain("customWidget_simulatePreviewAction");
expect(toolNames).toContain("info_getInfo");
expect(toolNames).toContain("invite_getAll");
expect(toolNames).toContain("serverSettings_getBoardSettings");
@@ -47,3 +62,31 @@ test("MCP tools should have descriptions", () => {
expect(tool.description, `Tool ${tool.name} should have a description`).toBeTruthy();
}
});
test("custom widget public procedures remain available", () => {
expect(Object.keys(customWidgetRouter._def.procedures)).toEqual(
expect.arrayContaining([
"all",
"byId",
"available",
"create",
"update",
"toggleEnabled",
"delete",
"duplicate",
"export",
"import",
"schema",
"validate",
"readTemplate",
"writeTemplate",
"patchTemplate",
"preview",
"previewQuery",
"previewAction",
"simulatePreviewAction",
"setPreviewLiveActions",
"previewJournal",
]),
);
});