feat(custom-widgets): add secure v2 authoring

This commit is contained in:
Thomas Camlong committed 2026-07-12 14:35:35 +02:00
1 parent d37545c98e
commit 46add018bf
56 files changed
+12613 -1726

No files matched your search

+2
View File
@@ -23,6 +23,8 @@
"dependencies": {
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"acorn": "catalog:",
"acorn-jsx": "catalog:",
"zod": "catalog:",
"zod-form-data": "catalog:"
},
@@ -0,0 +1,306 @@
import { Parser } from "acorn";
import jsx from "acorn-jsx";
import { customJsxComponentByName, customJsxSupportedPropsByName } from "@homarr/definitions";
interface AstNode {
type: string;
start?: number;
loc?: { start?: { line?: number; column?: number } };
[key: string]: unknown;
}
export interface CustomJsxTemplateDiagnostic {
severity: "error" | "warning";
message: string;
index: number;
line: number;
column: number;
}
const JsxParser = Parser.extend(jsx());
const MAX_AST_DEPTH = 64;
const MAX_OPERATIONS = 25_000;
const rootBindings = new Set([
"Array",
"Boolean",
"Date",
"Infinity",
"JSON",
"Math",
"NaN",
"Number",
"Object",
"String",
"data",
"decodeURIComponent",
"encodeURIComponent",
"isFinite",
"isNaN",
"parseFloat",
"parseInt",
"undefined",
]);
const blockedProperties = new Set([
"__proto__",
"apply",
"arguments",
"bind",
"call",
"callee",
"caller",
"constructor",
"prototype",
]);
const blockedProps = new Set([
"children",
"className",
"classNames",
"classes",
"component",
"dangerouslySetInnerHTML",
"innerRef",
"portalProps",
"ref",
"renderRoot",
"styles",
"unstyled",
"withinPortal",
]);
const nodeOf = (value: unknown): AstNode | null =>
value !== null && typeof value === "object" && typeof (value as { type?: unknown }).type === "string"
? (value as AstNode)
: null;
const nodesOf = (value: unknown): AstNode[] =>
Array.isArray(value) ? value.map(nodeOf).filter((node): node is AstNode => node !== null) : [];
const normalizedProperty = (value: unknown): string =>
String(value ?? "")
.normalize("NFKC")
.toLowerCase();
export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDiagnostic[] {
const diagnostics: CustomJsxTemplateDiagnostic[] = [];
let operations = 0;
const add = (node: AstNode, message: string, severity: "error" | "warning" = "error") => {
const line = node.loc?.start?.line ?? 1;
diagnostics.push({
severity,
message,
index: Math.max(0, (node.start ?? 2) - 2),
line,
column: Math.max(1, (node.loc?.start?.column ?? 0) + 1 - (line === 1 ? 2 : 0)),
});
};
const checkBudget = (node: AstNode, depth: number) => {
operations += 1;
if (depth > MAX_AST_DEPTH) add(node, `Template exceeds the AST depth limit (${MAX_AST_DEPTH})`);
if (operations === MAX_OPERATIONS + 1) {
add(node, `Template exceeds the operation limit (${MAX_OPERATIONS})`);
}
return depth <= MAX_AST_DEPTH && operations <= MAX_OPERATIONS;
};
const tagName = (node: AstNode): string | null => {
if (node.type === "JSXIdentifier") return String(node.name ?? "");
if (node.type !== "JSXMemberExpression") return null;
const object = nodeOf(node.object);
const property = nodeOf(node.property);
const left = object ? tagName(object) : null;
const right = property ? tagName(property) : null;
return left && right ? `${left}.${right}` : null;
};
const visit = (node: AstNode, depth: number, bindings: ReadonlySet<string>): void => {
if (!checkBudget(node, depth)) return;
switch (node.type) {
case "Program":
nodesOf(node.body).forEach((child) => visit(child, depth + 1, bindings));
return;
case "ExpressionStatement": {
const expression = nodeOf(node.expression);
if (expression) visit(expression, depth + 1, bindings);
return;
}
case "JSXFragment":
nodesOf(node.children).forEach((child) => visit(child, depth + 1, bindings));
return;
case "JSXElement": {
const opening = nodeOf(node.openingElement);
if (!opening) {
add(node, "Invalid JSX element");
return;
}
const nameNode = nodeOf(opening.name);
const name = nameNode ? tagName(nameNode) : null;
const descriptor = name ? customJsxComponentByName.get(name) : undefined;
if (!descriptor || descriptor.safety === "denied") {
add(opening, name ? `Component '${name}' is not available` : "Invalid JSX component name");
}
for (const attribute of nodesOf(opening.attributes)) {
if (attribute.type === "JSXSpreadAttribute") {
const argument = nodeOf(attribute.argument);
if (argument) visit(argument, depth + 1, bindings);
continue;
}
if (attribute.type !== "JSXAttribute") {
add(attribute, `Unsupported JSX attribute '${attribute.type}'`);
continue;
}
const attributeNameNode = nodeOf(attribute.name);
const attributeName = attributeNameNode?.type === "JSXIdentifier" ? String(attributeNameNode.name) : "";
const supportedProps = name ? customJsxSupportedPropsByName.get(name) : undefined;
if ((/^on/i.test(attributeName) && !supportedProps?.has(attributeName)) || blockedProps.has(attributeName)) {
add(attribute, `Prop '${attributeName}' is not allowed`);
} else if (name && !supportedProps?.has(attributeName)) {
add(attribute, `Prop '${attributeName}' is not supported by ${name} and will be ignored`, "warning");
}
const value = nodeOf(attribute.value);
if (value?.type === "JSXExpressionContainer") {
const expression = nodeOf(value.expression);
if (expression && expression.type !== "JSXEmptyExpression") visit(expression, depth + 1, bindings);
}
}
nodesOf(node.children).forEach((child) => visit(child, depth + 1, bindings));
return;
}
case "JSXText":
case "JSXEmptyExpression":
case "TemplateElement":
return;
case "JSXExpressionContainer": {
const expression = nodeOf(node.expression);
if (expression && expression.type !== "JSXEmptyExpression") visit(expression, depth + 1, bindings);
return;
}
case "Literal":
if (node.regex !== undefined || typeof node.value === "bigint") {
add(node, "Regular expressions and bigint literals are not supported");
}
return;
case "Identifier": {
const name = String(node.name ?? "");
if (!bindings.has(name)) add(node, `Unknown binding '${name}'`);
return;
}
case "ArrayExpression":
nodesOf(node.elements).forEach((child) => {
const argument = child.type === "SpreadElement" ? nodeOf(child.argument) : child;
if (argument) visit(argument, depth + 1, bindings);
});
return;
case "ObjectExpression":
nodesOf(node.properties).forEach((property) => {
if (property.type === "SpreadElement") {
const argument = nodeOf(property.argument);
if (argument) visit(argument, depth + 1, bindings);
return;
}
if (property.type !== "Property" || property.kind !== "init" || property.method || property.shorthand) {
add(property, "Only explicit object properties are supported");
return;
}
const key = nodeOf(property.key);
if (property.computed && key) visit(key, depth + 1, bindings);
if (!property.computed && key && blockedProperties.has(normalizedProperty(key.name ?? key.value))) {
add(key, "Reflective object properties are not allowed");
}
const value = nodeOf(property.value);
if (value) visit(value, depth + 1, bindings);
});
return;
case "UnaryExpression":
if (!["!", "+", "-", "typeof"].includes(String(node.operator))) {
add(node, `Unary operator '${String(node.operator)}' is not supported`);
}
if (nodeOf(node.argument)) visit(node.argument as AstNode, depth + 1, bindings);
return;
case "BinaryExpression":
case "LogicalExpression": {
const left = nodeOf(node.left);
const right = nodeOf(node.right);
if (left) visit(left, depth + 1, bindings);
if (right) visit(right, depth + 1, bindings);
return;
}
case "ConditionalExpression": {
[node.test, node.consequent, node.alternate].map(nodeOf).forEach((child) => {
if (child) visit(child, depth + 1, bindings);
});
return;
}
case "MemberExpression": {
const object = nodeOf(node.object);
const property = nodeOf(node.property);
if (object) visit(object, depth + 1, bindings);
if (node.computed && property) visit(property, depth + 1, bindings);
if (property && blockedProperties.has(normalizedProperty(property.name ?? property.value))) {
add(property, "Reflective property access is not allowed");
}
return;
}
case "CallExpression": {
const callee = nodeOf(node.callee);
if (callee) visit(callee, depth + 1, bindings);
nodesOf(node.arguments).forEach((argument) => {
if (argument.type === "SpreadElement") add(argument, "Spread call arguments are not supported");
else visit(argument, depth + 1, bindings);
});
return;
}
case "ArrowFunctionExpression": {
if (node.async || node.generator || node.expression === false) {
add(node, "Only synchronous expression-bodied arrow callbacks are supported");
return;
}
const callbackBindings = new Set(bindings);
for (const parameter of nodesOf(node.params)) {
if (parameter.type !== "Identifier") add(parameter, "Callback parameters must be identifiers");
else callbackBindings.add(String(parameter.name));
}
const body = nodeOf(node.body);
if (body) visit(body, depth + 1, callbackBindings);
return;
}
case "TemplateLiteral":
nodesOf(node.expressions).forEach((expression) => visit(expression, depth + 1, bindings));
return;
case "ChainExpression": {
const expression = nodeOf(node.expression);
if (expression) visit(expression, depth + 1, bindings);
return;
}
default:
add(node, `Unsupported expression '${node.type}'`);
}
};
try {
const program = JsxParser.parse(`<>${template}</>`, {
ecmaVersion: "latest",
sourceType: "module",
locations: true,
}) as unknown as AstNode;
visit(program, 0, rootBindings);
} catch (error) {
const parseError = error as Error & { pos?: number; loc?: { line?: number; column?: number } };
diagnostics.push({
severity: "error",
message: parseError.message,
index: Math.max(0, (parseError.pos ?? 2) - 2),
line: parseError.loc?.line ?? 1,
column: Math.max(1, (parseError.loc?.column ?? 2) - 1),
});
}
return diagnostics;
}
@@ -0,0 +1,168 @@
import { describe, expect, test } from "vitest";
import { customJsxExamples } from "@homarr/definitions";
import { customJsxDisplayConfigV2Schema, customJsxRequestSchema, displayConfigSchema } from "./custom-widget";
import { validateCustomJsxTemplate } from "./custom-jsx-template";
describe("custom JSX v2 validation", () => {
test.each(customJsxExamples)("validates the $id shared example", (example) => {
expect(() =>
customJsxDisplayConfigV2Schema.parse({
type: "customJsx",
jsxApiVersion: 2,
template: example.template,
networkScope: "public",
requests: [...example.requests],
}),
).not.toThrow();
});
test("retains display-only v1 compatibility", () => {
expect(displayConfigSchema.parse({ type: "customJsx", template: "<Text>{data.value}</Text>" })).toEqual({
type: "customJsx",
template: "<Text>{data.value}</Text>",
});
});
test("accepts benign text containing interpreter-reserved words", () => {
expect(() =>
customJsxDisplayConfigV2Schema.parse({
type: "customJsx",
jsxApiVersion: 2,
template: "<Text>constructor fetch prototype</Text>",
networkScope: "public",
requests: [],
}),
).not.toThrow();
});
test("accepts supported Tabs, ScrollArea, and SubData image props without warnings", () => {
const diagnostics = validateCustomJsxTemplate(
'<Tabs.List grow><ScrollArea offsetScrollbars><SubData as="Image" fit="contain" alt="Artwork" /></ScrollArea></Tabs.List>',
);
expect(diagnostics).toEqual([]);
});
test("rejects malformed JSX and direct global fetch while allowing declarative onParams", () => {
const config = {
type: "customJsx" as const,
jsxApiVersion: 2 as const,
networkScope: "public" as const,
requests: [],
};
expect(customJsxDisplayConfigV2Schema.safeParse({ ...config, template: "<Text>" }).success).toBe(false);
expect(
customJsxDisplayConfigV2Schema.safeParse({ ...config, template: '<Text>{fetch("/private")}</Text>' }).success,
).toBe(false);
expect(
customJsxDisplayConfigV2Schema.safeParse({
...config,
template: '<ToggleSwitch requestId="toggle" onParams={{ enabled: true }} offParams={{ enabled: false }} />',
requests: [
{
id: "toggle",
kind: "action",
method: "POST",
pathTemplate: "/toggle",
parameters: { enabled: "boolean" },
bodyTemplate: { enabled: { $param: "enabled" } },
auth: "none",
minimumBoardPermission: "modify",
},
],
}).success,
).toBe(true);
});
test("rejects reserved manifest headers and mismatched request component kinds", () => {
expect(
customJsxRequestSchema.safeParse({
id: "status",
kind: "query",
method: "GET",
pathTemplate: "/status",
parameters: {},
staticHeaders: { Authorization: "secret" },
auth: "none",
minimumBoardPermission: "view",
}).success,
).toBe(false);
expect(
customJsxDisplayConfigV2Schema.safeParse({
type: "customJsx",
jsxApiVersion: 2,
networkScope: "public",
template: '<ActionButton requestId="status" label="Run" />',
requests: [
{
id: "status",
kind: "query",
method: "GET",
pathTemplate: "/status",
parameters: {},
auth: "none",
minimumBoardPermission: "view",
},
],
}).success,
).toBe(false);
});
test("accepts 50,000 characters and rejects 50,001", () => {
const config = {
type: "customJsx" as const,
jsxApiVersion: 2 as const,
networkScope: "public" as const,
requests: [],
};
expect(customJsxDisplayConfigV2Schema.safeParse({ ...config, template: "x".repeat(50_000) }).success).toBe(true);
expect(customJsxDisplayConfigV2Schema.safeParse({ ...config, template: "x".repeat(50_001) }).success).toBe(false);
});
test("rejects duplicate request IDs", () => {
const request = {
id: "status",
kind: "query" as const,
method: "GET" as const,
pathTemplate: "/status",
parameters: {},
auth: "none" as const,
minimumBoardPermission: "view" as const,
};
const result = customJsxDisplayConfigV2Schema.safeParse({
type: "customJsx",
jsxApiVersion: 2,
template: "<Text />",
networkScope: "public",
requests: [request, request],
});
expect(result.success).toBe(false);
});
test("rejects mutation-shaped queries and weak DELETE permissions", () => {
expect(
customJsxRequestSchema.safeParse({
id: "mutating-query",
kind: "query",
method: "POST",
pathTemplate: "/status",
parameters: {},
auth: "none",
minimumBoardPermission: "view",
}).success,
).toBe(false);
expect(
customJsxRequestSchema.safeParse({
id: "delete",
kind: "action",
method: "DELETE",
pathTemplate: "/resource",
parameters: {},
auth: "inherit",
minimumBoardPermission: "modify",
}).success,
).toBe(false);
});
});
+287 -23
View File
@@ -1,5 +1,7 @@
import { z } from "zod/v4";
import { validateCustomJsxTemplate } from "./custom-jsx-template";
export const customWidgetAuthTypes = ["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"] as const;
export type CustomWidgetAuthType = (typeof customWidgetAuthTypes)[number];
@@ -257,26 +259,265 @@ const rawDisplayConfigSchema = z.object({
.describe("Maximum height of the raw JSON display area in pixels (50–1000, default: 300)"),
});
const FORBIDDEN_TEMPLATE_PATTERN =
/\bconstructor\b|\b__proto__\b|\bprototype\b|\beval\b|\bFunction\b|\bimport\s*\(|\brequire\b|\bglobalThis\b|\bwindow\b|\bdocument\b|\bXMLHttpRequest\b/i;
export const customJsxNetworkScopes = ["public", "private", "loopback"] as const;
export type CustomJsxNetworkScope = (typeof customJsxNetworkScopes)[number];
const customJsxDisplayConfigSchema = z.object({
export const customJsxRequestParameterTypes = ["string", "number", "boolean"] as const;
export type CustomJsxRequestParameterType = (typeof customJsxRequestParameterTypes)[number];
const requestIdSchema = z
.string()
.min(1)
.max(64)
.regex(
/^[a-z][a-z0-9_-]*$/,
"Request IDs must start with a letter and contain only lowercase letters, numbers, - or _",
);
const requestParametersSchema = z
.record(requestIdSchema, z.enum(customJsxRequestParameterTypes))
.refine((parameters) => Object.keys(parameters).length <= 32, "A request can declare at most 32 parameters");
const reservedRequestHeaders = new Set([
"authorization",
"connection",
"content-length",
"cookie",
"expect",
"forwarded",
"host",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"via",
]);
const staticHeadersSchema = z
.record(z.string().min(1).max(128), z.string().max(8192))
.refine((headers) => Object.keys(headers).length <= 32, "A request can declare at most 32 static headers")
.refine(
(headers) =>
Object.keys(headers).every((name) => {
const normalized = name.trim().toLowerCase();
return (
!reservedRequestHeaders.has(normalized) &&
!normalized.startsWith("proxy-") &&
!normalized.startsWith("sec-") &&
!normalized.startsWith("x-forwarded-")
);
}),
"Static headers cannot override authentication, cookies, routing, forwarding, proxy, or hop-by-hop headers",
);
const collectBodyParameterNames = (value: unknown, result = new Set<string>()): Set<string> => {
if (Array.isArray(value)) {
value.forEach((entry) => collectBodyParameterNames(entry, result));
return result;
}
if (value === null || typeof value !== "object") return result;
const entries = Object.entries(value);
if (entries.length === 1 && entries[0]?.[0] === "$param" && typeof entries[0][1] === "string") {
result.add(entries[0][1]);
return result;
}
entries.forEach(([, entry]) => collectBodyParameterNames(entry, result));
return result;
};
export const customJsxRequestSchema = z
.object({
id: requestIdSchema,
kind: z.enum(["query", "action"]),
method: z.enum(customWidgetMethods),
pathTemplate: z
.string()
.min(1)
.max(2048)
.startsWith("/")
.refine((path) => !path.startsWith("//"), "Path templates must be same-origin paths")
.refine((path) => !path.includes("\\"), "Path templates cannot contain backslashes")
.refine((path) => !path.includes("#"), "Path templates cannot contain URL fragments"),
parameters: requestParametersSchema,
bodyTemplate: z.json().optional(),
staticHeaders: staticHeadersSchema.optional(),
auth: z.enum(["inherit", "none"]),
minimumBoardPermission: z.enum(["view", "modify", "full"]),
cacheTtlSeconds: z.number().int().min(0).max(3600).optional(),
allowViewerExecution: z.literal(true).optional(),
})
.superRefine((request, ctx) => {
if (request.kind === "query" && request.method !== "GET") {
ctx.addIssue({
code: "custom",
path: ["method"],
message: "Query requests must use GET",
});
}
if (request.kind === "action" && request.method === "GET") {
ctx.addIssue({
code: "custom",
path: ["method"],
message: "Action requests must use POST, PUT, PATCH, or DELETE",
});
}
if (request.kind === "query" && request.minimumBoardPermission !== "view") {
ctx.addIssue({
code: "custom",
path: ["minimumBoardPermission"],
message: "Query requests use view permission",
});
}
if (request.method === "DELETE" && request.minimumBoardPermission !== "full") {
ctx.addIssue({
code: "custom",
path: ["minimumBoardPermission"],
message: "DELETE actions require full board permission",
});
}
if (
request.kind === "action" &&
request.method !== "DELETE" &&
request.minimumBoardPermission === "view" &&
request.allowViewerExecution !== true
) {
ctx.addIssue({
code: "custom",
path: ["allowViewerExecution"],
message: "Viewer actions require explicit allowViewerExecution approval",
});
}
if (request.kind === "query" && request.bodyTemplate !== undefined) {
ctx.addIssue({
code: "custom",
path: ["bodyTemplate"],
message: "GET query requests cannot define a request body",
});
}
if (request.kind === "action" && request.cacheTtlSeconds !== undefined && request.cacheTtlSeconds !== 0) {
ctx.addIssue({
code: "custom",
path: ["cacheTtlSeconds"],
message: "Actions cannot be cached",
});
}
const declaredParameters = new Set(Object.keys(request.parameters));
const pathParameters = [...request.pathTemplate.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1] ?? "");
for (const parameter of [...pathParameters, ...collectBodyParameterNames(request.bodyTemplate)]) {
if (!declaredParameters.has(parameter)) {
ctx.addIssue({
code: "custom",
path: [request.bodyTemplate === undefined ? "pathTemplate" : "parameters"],
message: `Placeholder '${parameter}' is not declared in request parameters`,
});
}
}
});
export type CustomJsxRequest = z.infer<typeof customJsxRequestSchema>;
const customJsxTemplateSchema = z
.string()
.min(1)
.max(50000)
.superRefine((template, ctx) => {
for (const diagnostic of validateCustomJsxTemplate(template)) {
if (diagnostic.severity === "error") {
ctx.addIssue({
code: "custom",
message: `${diagnostic.message} (line ${diagnostic.line}, column ${diagnostic.column})`,
});
}
}
})
.describe(
"JSX template string using whitelisted Mantine components. Access API data via {data.fieldName}. Direct network access is unavailable; v2 templates reference named requests.",
);
const customJsxDisplayConfigV1Schema = z.object({
type: z
.literal("customJsx")
.describe("Display type discriminator — must be 'customJsx' when displayType is customJsx"),
template: z
.string()
.min(1)
.max(50000)
.refine((t) => !FORBIDDEN_TEMPLATE_PATTERN.test(t), {
message:
"Template contains forbidden keywords (constructor, __proto__, eval, Function, import, require, globalThis, window, document)",
})
.describe(
"JSX template string using whitelisted Mantine components. Access API data via {data.fieldName}. Use SubFetch for in-widget HTTP requests. Forbidden keywords: constructor, __proto__, eval, Function, import, require, globalThis, window, document",
),
template: customJsxTemplateSchema,
jsxApiVersion: z.never().optional(),
networkScope: z.never().optional(),
requests: z.never().optional(),
});
export const customJsxDisplayConfigV2Schema = z
.object({
type: z.literal("customJsx"),
jsxApiVersion: z.literal(2),
template: customJsxTemplateSchema,
networkScope: z.enum(customJsxNetworkScopes),
requests: z.array(customJsxRequestSchema).max(64),
})
.superRefine((config, ctx) => {
const requestsById = new Map<string, CustomJsxRequest>();
for (const [index, request] of config.requests.entries()) {
if (requestsById.has(request.id)) {
ctx.addIssue({
code: "custom",
path: ["requests", index, "id"],
message: `Duplicate request ID: ${request.id}`,
});
}
requestsById.set(request.id, request);
}
const networkComponentPattern = /<(SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/g;
for (const match of config.template.matchAll(networkComponentPattern)) {
const component = match[1] as "SubFetch" | "ActionButton" | "ToggleSwitch";
const attributes = match[2] ?? "";
const idMatch = attributes.match(/\brequestId\s*=\s*(?:"([^"]+)"|'([^']+)')/);
const requestId = idMatch?.[1] ?? idMatch?.[2];
if (!requestId) {
ctx.addIssue({
code: "custom",
path: ["template"],
message: `${component} must use a literal requestId`,
});
continue;
}
const request = requestsById.get(requestId);
const expectedKind = component === "SubFetch" ? "query" : "action";
if (!request) {
ctx.addIssue({
code: "custom",
path: ["template"],
message: `${component} references unknown request '${requestId}'`,
});
} else if (request.kind !== expectedKind) {
ctx.addIssue({
code: "custom",
path: ["template"],
message: `${component} requires a ${expectedKind} request, but '${requestId}' is ${request.kind}`,
});
}
if (/\b(?:url|method|headers|body)\s*=/.test(attributes)) {
ctx.addIssue({
code: "custom",
path: ["template"],
message: `${component} cannot declare inline network settings`,
});
}
}
});
export type CustomJsxDisplayConfigV2 = z.infer<typeof customJsxDisplayConfigV2Schema>;
const actionButtonDisplayConfigSchema = z.object({
type: z
.literal("actionButton")
@@ -302,7 +543,7 @@ const actionButtonDisplayConfigSchema = z.object({
});
export const displayConfigSchema = z
.discriminatedUnion("type", [
.union([
singleValueDisplayConfigSchema,
keyValueDisplayConfigSchema,
tableDisplayConfigSchema,
@@ -312,7 +553,8 @@ export const displayConfigSchema = z
countGridDisplayConfigSchema,
rawDisplayConfigSchema,
actionButtonDisplayConfigSchema,
customJsxDisplayConfigSchema,
customJsxDisplayConfigV2Schema,
customJsxDisplayConfigV1Schema,
])
.describe(
"Configuration for how API response data is displayed. The 'type' field MUST match the top-level 'displayType' field exactly. Choose the variant that matches your displayType.",
@@ -326,13 +568,35 @@ const displayTypesMatch = (displayType?: string, configType?: string): boolean =
return displayType === configType;
};
const displayTypeMatchRefinement = (d: { displayType: string; displayConfig: { type: string } }) =>
displayTypesMatch(d.displayType, d.displayConfig.type);
const displayTypeMatchMessage = {
message: "displayType must match displayConfig.type",
path: ["displayConfig", "type"],
};
const isCustomJsxV2 = (config: DisplayConfig | undefined): config is CustomJsxDisplayConfigV2 =>
config?.type === "customJsx" && "jsxApiVersion" in config && config.jsxApiVersion === 2;
const validateDefinitionDisplay = (
definition: { method?: CustomWidgetMethod; displayType?: string; displayConfig?: DisplayConfig },
ctx: z.RefinementCtx,
) => {
if (!displayTypesMatch(definition.displayType, definition.displayConfig?.type)) {
ctx.addIssue({
code: "custom",
message: displayTypeMatchMessage.message,
path: displayTypeMatchMessage.path,
});
}
if (isCustomJsxV2(definition.displayConfig) && definition.method !== undefined && definition.method !== "GET") {
ctx.addIssue({
code: "custom",
message: "Custom JSX v2 base data requests must use GET; mutations belong in named actions",
path: ["method"],
});
}
};
const baseDefinitionSchema = z.object({
name: z
.string()
@@ -404,12 +668,12 @@ const secretsInputSchema = z.array(
export const customWidgetCreateSchema = baseDefinitionSchema
.extend({ secrets: secretsInputSchema })
.refine(displayTypeMatchRefinement, displayTypeMatchMessage);
.superRefine(validateDefinitionDisplay);
export const customWidgetUpdateSchema = baseDefinitionSchema
.partial()
.extend({ id: z.string(), secrets: secretsInputSchema.optional() })
.refine((d) => displayTypesMatch(d.displayType, d.displayConfig?.type), displayTypeMatchMessage);
.superRefine(validateDefinitionDisplay);
const customWidgetImportFieldsSchema = z.object({
name: z
@@ -456,9 +720,9 @@ const customWidgetImportFieldsSchema = z.object({
export const customWidgetImportSchema = customWidgetImportFieldsSchema
.extend({
$schema: z
.literal("homarr-custom-widget-v2")
.enum(["homarr-custom-widget-v2", "homarr-custom-widget-v3"])
.optional()
.describe("Schema version identifier. Should be 'homarr-custom-widget-v2' for current format."),
.describe("Schema version identifier. New exports use 'homarr-custom-widget-v3'; v2 remains importable."),
url: z
.string()
.min(1)
@@ -466,6 +730,6 @@ export const customWidgetImportSchema = customWidgetImportFieldsSchema
"Full URL to the API endpoint to fetch data from (e.g. https://myapp.local/api/stats). Must include protocol.",
),
})
.refine(displayTypeMatchRefinement, displayTypeMatchMessage);
.superRefine(validateDefinitionDisplay);
export type CustomWidgetImport = z.infer<typeof customWidgetImportSchema>;