fix(custom-widgets): require explicit request bindings

This commit is contained in:
Thomas Camlong committed 2026-07-20 17:02:59 +02:00
1 parent 4c69ad90df
commit a26846fa31
23 files changed
+363 -52

No files matched your search

@@ -25,6 +25,7 @@ Create one widget at a time. Repeat the workflow when the user asks for several
- Use inline sources, named requests, definition-owned options, temporary bound inputs, and one safe JSX template.
- Use `kind`, not the HTTP method, to distinguish reads from user-triggered actions.
- Give every declared request parameter an explicit source. Load queries use `optionsBinding` with an option reference or primitive literal; manual queries and actions receive values only from the invoking component's `params` prop. Never infer bindings from matching names.
- Never put a credential in JSX, options, headers, URLs, examples, exports, logs, or chat output.
- Use declarative `bind` controls and Homarr request components; never emit imports, hooks, callbacks, browser requests, or arbitrary JavaScript.
- Include responsive loading, empty, error, and success states.
@@ -8,6 +8,27 @@ Render icons as `<TablerIcon name="server" size={18} />`. When connected, retrie
Use `SubFetch` for a manual query, `ActionButton` or `ToggleSwitch` for an action, and `RefreshButton` to refresh data. A query with `trigger: "load"` populates its data automatically and follows the widget refresh interval.
Manual requests receive values only from the invoking component:
```jsx
<SubFetch requestId="search" params={{ query: inputs.search }} />
<ActionButton requestId="restart" params={{ id: container.Id }}>Restart</ActionButton>
```
Loop variables and other JSX expressions can flow into a request only through that `params` prop. A load query has no invoking component, so every declared parameter must have an `optionsBinding` entry:
```json
{
"parameters": { "endpointId": "string", "showAll": "boolean" },
"optionsBinding": {
"endpointId": { "$option": "endpointId" },
"showAll": false
}
}
```
Do not rely on parameter and option names matching. For an invalid-placeholder or unresolved-parameter error on a load query, check missing `optionsBinding` entries before changing placeholder syntax.
Bind local controls declaratively:
```jsx
@@ -18,7 +18,11 @@ interface HomarrCustomWidgetV2 {
A source has a stable lowercase `id`, display `name`, `baseUrl`, `networkScope` (`public`, `private`, or `loopback`), and one auth mode: `none`, `bearer`, `basic`, `apiKeyHeader`, or `apiKeyQuery`.
A request has `id`, `sourceId`, `kind`, any supported HTTP `method`, same-origin `pathTemplate`, declared primitive `parameters`, optional `queryTemplate` and `bodyTemplate`, optional safe `staticHeaders`, `auth`, `minimumBoardPermission`, optional `trigger`, caching, confirmation, and invalidation. Parameter names may use camelCase. Bind a declared parameter with `{name}` in a path or `{ "$param": "name" }` in structured query/body data.
A request has `id`, `sourceId`, `kind`, any supported HTTP `method`, same-origin `pathTemplate`, declared primitive `parameters`, optional `optionsBinding`, `queryTemplate`, and `bodyTemplate`, optional safe `staticHeaders`, `auth`, `minimumBoardPermission`, optional `trigger`, caching, confirmation, and invalidation. Parameter names may use camelCase. Reference a declared parameter with `{name}` in a path or `{ "$param": "name" }` in structured query/body data.
Every declared parameter needs an explicit source. For a load query, bind each parameter in `optionsBinding` with `{ "$option": "optionName" }` or a primitive literal of the declared type. The referenced option must exist in `optionsSchema`. For manual queries and actions, the invoking `SubFetch`, `ActionButton`, or `ToggleSwitch` supplies the complete parameter object through `params`. Never infer a source because a parameter, option, or temporary input has the same name.
Dynamic option requests have no JSX invoker and therefore also require `optionsBinding` for every parameter they declare.
Options use a restricted object JSON Schema. Set `additionalProperties: false`. Presentation metadata lives under `x-homarr`; dynamic selects use `optionsSource.requestId`, optional `itemsPath` for wrapped arrays, `valuePath`, and `labelPath`. Default options must validate against the schema.
@@ -39,7 +39,25 @@ Dynamic selects use `x-homarr.optionsSource` and a named query. Add `itemsPath`
### Named requests and parameter bindings
Request parameters are a map from parameter name to `string`, `number`, or `boolean`. Query and body templates reference them with `{ "$param": "name" }`. Load queries receive matching widget options automatically; manual queries and actions receive `params` from their runtime component.
Request parameters are a map from parameter name to `string`, `number`, or `boolean`. Query and body templates reference them with `{ "$param": "name" }`. Declaring a parameter does not give it a value, even when an option or temporary input has the same name.
For a load query, bind every parameter explicitly in `optionsBinding`. Each entry is either `{ "$option": "optionName" }` or a primitive literal matching the declared parameter type:
```json
{
"pathTemplate": "/api/endpoints/{endpointId}/docker/containers/json",
"parameters": { "endpointId": "string", "showAll": "boolean" },
"queryTemplate": { "all": { "$param": "showAll" } },
"optionsBinding": {
"endpointId": { "$option": "endpointId" },
"showAll": false
}
}
```
For manual queries and actions, `SubFetch`, `ActionButton`, or `ToggleSwitch` supplies the complete parameter object through its `params` prop. This is the only route from JSX values such as `inputs.search` or a loop variable into a request.
Dynamic option requests also run without a JSX invoker. If they declare parameters, give them explicit `optionsBinding` entries so the configuration form can resolve those values.
<CanonicalCustomWidgetExample
id="named-widget-requests"
@@ -13,7 +13,7 @@ Queries and actions support GET, POST, PUT, PATCH, and DELETE. `kind`, not metho
Successful actions evict the declared queries from both the browser and server response cache before refetching them.
Path parameters use `{name}`. Structured query/body templates use `{ "$param": "name" }`. Runtime values can come from validated options, temporary `inputs`, and explicit action parameters.
Path parameters use `{name}`. Structured query/body templates use `{ "$param": "name" }`. Every declared parameter has an explicit source: load queries use `optionsBinding` entries containing `{ "$option": "optionName" }` or a primitive literal, while manual queries and actions receive the full parameter object from the invoking component's `params` prop. Homarr never matches parameter names to option or input names implicitly.
## Protections
@@ -10,7 +10,7 @@ Use the first schema/JSX diagnostic and its path. Ensure source/request IDs are
## Request fails
Check the source network scope, base URL, credentials, request method/path/body, runtime parameters, and board permission. The preview journal shows redacted method, request ID, path template, status, and duration without credentials.
Check the source network scope, base URL, credentials, request method/path/body, runtime parameters, and board permission. For “invalid placeholder” or an unresolved parameter on a load query, first verify that every declared parameter has an explicit `optionsBinding` option reference or literal. Matching parameter and option names are not a binding. The preview journal shows redacted method, request ID, path template, status, and duration without credentials.
## Mantine prop warning
@@ -1,5 +1,5 @@
import { fetchApi } from "@homarr/api/client";
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import { customWidgetDefinitionSchema, resolveCustomWidgetOptionsBinding } from "@homarr/custom-widgets/core";
import type { CustomWidgetSource, HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import type { UseFormReturnType } from "@mantine/form";
@@ -70,13 +70,8 @@ export async function loadPreviewQueries(definition: HomarrCustomWidgetV2, sessi
const data: Record<string, unknown> = {};
const status: Record<string, unknown> = {};
for (const request of definition.requests.filter((entry) => entry.kind === "query" && entry.trigger === "load")) {
const params = Object.fromEntries(
Object.entries(request.parameters).flatMap(([name, type]) => {
const value = definition.defaultOptions[name];
return typeof value === type ? [[name, value as string | number | boolean]] : [];
}),
);
try {
const params = resolveCustomWidgetOptionsBinding(request, definition.defaultOptions);
const result = await fetchApi.customWidget.previewQuery.query({ sessionId, requestId: request.id, params });
data[request.id] = result.data;
status[request.id] = { loading: false, ...result };
@@ -3,6 +3,7 @@ import { Badge, Box, Container, Group, Paper, Stack, Text, Title } from "@mantin
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { resolveCustomWidgetOptionsBinding } from "@homarr/custom-widgets/core";
import CustomJsxDisplay from "@homarr/widgets/custom-api/custom-jsx-display";
export default async function CustomWidgetPreviewPage({ params }: { params: Promise<{ id: string }> }) {
@@ -15,14 +16,11 @@ export default async function CustomWidgetPreviewPage({ params }: { params: Prom
const loadRequests = preview.requests.filter((request) => request.kind === "query" && request.trigger === "load");
const results = await Promise.all(
loadRequests.map(async (request) => {
const requestParams = Object.fromEntries(
Object.entries(request.parameters).flatMap(([name, type]) => {
const value = preview.defaultOptions[name];
return typeof value === type ? [[name, value as string | number | boolean]] : [];
}),
);
const result = await api.customWidget
.previewQuery({ sessionId: id, requestId: request.id, params: requestParams })
const result = await Promise.resolve()
.then(() => resolveCustomWidgetOptionsBinding(request, preview.defaultOptions))
.then((requestParams) =>
api.customWidget.previewQuery({ sessionId: id, requestId: request.id, params: requestParams }),
)
.catch((cause: unknown) => ({
ok: false,
status: 0,
@@ -107,7 +107,7 @@ export const managementQueryProcedures = {
),
optionRequests: widget.requests
.filter((request) => request.kind === "query")
.map(({ id, parameters }) => ({ id, parameters })),
.map(({ id, parameters, optionsBinding }) => ({ id, parameters, optionsBinding })),
updatedAt: definition.updatedAt,
};
});
@@ -89,13 +89,24 @@ export const previewBaseProcedures = {
optionsSchema: session.optionsSchema,
defaultOptions: session.defaultOptions,
requests: session.requests.map(
({ id, kind, method, minimumBoardPermission, trigger, parameters, confirmation, invalidates }) => ({
({
id,
kind,
method,
minimumBoardPermission,
trigger,
parameters,
optionsBinding,
confirmation,
invalidates,
}) => ({
id,
kind,
method,
minimumBoardPermission,
trigger,
parameters,
optionsBinding,
confirmation,
invalidates,
}),
@@ -1,6 +1,8 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { resolveCustomWidgetOptionsBinding } from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure } from "../../trpc";
import {
getPreviewRequestSource,
@@ -31,8 +33,10 @@ export const previewQueryProcedures = {
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview query was not found" });
const resolved = getPreviewRequestSource(session, request.sourceId);
if (!resolved) throw new TRPCError({ code: "NOT_FOUND", message: "Preview source was not found" });
const targetUrl = renderRequestTarget(resolved.source.baseUrl, request, input.params);
const body = renderRequestBody(request.bodyTemplate, input.params);
const params =
request.trigger === "load" ? resolveCustomWidgetOptionsBinding(request, session.defaultOptions) : input.params;
const targetUrl = renderRequestTarget(resolved.source.baseUrl, request, params);
const body = renderRequestBody(request.bodyTemplate, params);
const release = await acquireCustomWidgetRequestLimit({
category: "query",
userId: ctx.session.user.id,
@@ -50,7 +54,7 @@ export const previewQueryProcedures = {
auth: request.auth === "none" ? undefined : resolved.auth,
networkScope: resolved.source.networkScope,
kind: "query",
cacheKey: `custom-jsx:preview:${session.id}:${request.id}:${hashRuntimeParams(input.params)}`,
cacheKey: `custom-jsx:preview:${session.id}:${request.id}:${hashRuntimeParams(params)}`,
cacheTtlSeconds: request.cacheTtlSeconds,
});
await recordPreviewJournal(session, {
+6 -16
View File
@@ -7,6 +7,7 @@ import { eq } from "@homarr/db";
import { boards, customWidgetDefinitions, items } from "@homarr/db/schema";
import type { BoardPermission } from "@homarr/definitions";
import { validateCustomWidgetOptions } from "@homarr/custom-widgets/core";
import { resolveCustomWidgetOptionsBinding } from "@homarr/custom-widgets/core";
import type { CustomJsxRequest, CustomWidgetSource } from "@homarr/custom-widgets/core";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
@@ -173,21 +174,6 @@ const executeRequest = async (
);
};
const getLoadParams = (resolved: ResolvedDefinition, request: CustomJsxRequest) => {
const params: Record<string, string | number | boolean> = {};
for (const [name, type] of Object.entries(request.parameters)) {
const value = resolved.configuration[name];
if (typeof value !== type) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: `Load request '${request.id}' needs option '${name}' (${type})`,
});
}
params[name] = value as string | number | boolean;
}
return params;
};
export const customApiRouter = createTRPCRouter({
getData: publicProcedure.input(itemInputSchema).query(async ({ ctx, input }) => {
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
@@ -197,7 +183,8 @@ export const customApiRouter = createTRPCRouter({
const entries = await Promise.all(
loadRequests.map(async (request) => {
try {
const response = await executeRequest(ctx, resolved, request, getLoadParams(resolved, request));
const params = resolveCustomWidgetOptionsBinding(request, resolved.configuration);
const response = await executeRequest(ctx, resolved, request, params);
return [
request.id,
{
@@ -249,6 +236,9 @@ export const customApiRouter = createTRPCRouter({
queryRequest: publicProcedure.input(namedRequestInputSchema).query(async ({ ctx, input }) => {
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
const request = findRequest(resolved, input.requestId, "query");
if (request.trigger !== "manual") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Load queries cannot be invoked manually" });
}
const response = await executeRequest(ctx, resolved, request, input.params);
return {
ok: response.ok,
@@ -8,7 +8,7 @@ Use $schema "homarr-custom-widget-v2". A widget contains metadata, inline API so
This Homarr release uses Mantine ${CUSTOM_WIDGET_MANTINE_VERSION}. When connected, read homarr://custom-widgets/schema and homarr://custom-widgets/components instead of guessing the installed surface.
Queries and actions may use GET, POST, PUT, PATCH, or DELETE. Set kind to query for reads and action for user-triggered changes. Actions never use trigger "load". Declare every path, query, or body parameter. Use {name} in paths and {"$param":"name"} in query/body templates. Use SubFetch for manual queries, ActionButton or ToggleSwitch for actions, and load-triggered queries through data.<requestId>.
Queries and actions may use GET, POST, PUT, PATCH, or DELETE. Set kind to query for reads and action for user-triggered changes. Actions never use trigger "load". Declare every path, query, or body parameter. Use {name} in paths and {"$param":"name"} in query/body templates. Every declared parameter needs an explicit value source: SubFetch, ActionButton, and ToggleSwitch supply manual request values through their params prop; every load-query parameter must have an optionsBinding entry containing {"$option":"optionName"} or a primitive literal. Never infer a binding from matching names. Use load-triggered query results through data.<requestId>.
Source authentication is exactly one of {"type":"none"}, {"type":"bearer"}, {"type":"basic"}, {"type":"apiKeyHeader","headerName":"X-API-Key"}, or {"type":"apiKeyQuery","parameterName":"api_key"}. Credentials are configured separately by the user.
@@ -17,7 +17,7 @@ Templates can read data, status, options, and inputs. Bind supported Mantine con
Options use a restricted object JSON Schema. x-homarr controls include text, textarea, number, switch, select, multi-select, slider, date, time, color, icon, url, duration, timeZone, and json. Dynamic selects use x-homarr.optionsSource with requestId, optional itemsPath for wrapped arrays, valuePath, and labelPath.
Widget shape:
{ "$schema":"homarr-custom-widget-v2", "name":"...", "description":"...", "iconUrl":"https://...", "sources":[{ "id":"default", "name":"API", "baseUrl":"https://host", "networkScope":"public|private|loopback", "auth":{ "type":"none|bearer|basic|apiKeyHeader|apiKeyQuery" } }], "requests":[{ "id":"data", "sourceId":"default", "kind":"query|action", "method":"GET|POST|PUT|PATCH|DELETE", "pathTemplate":"/api/{id}", "parameters":{ "id":"string", "limit":"number" }, "queryTemplate":{ "limit":{ "$param":"limit" } }, "auth":"inherit|none", "minimumBoardPermission":"view|modify|full", "trigger":"load|manual", "confirmation":{ "title":"...", "message":"...", "destructive":false }, "invalidates":[] }], "optionsSchema":{ "type":"object", "properties":{}, "additionalProperties":false }, "defaultOptions":{}, "template":"__HOMARR_TEMPLATE__" }
{ "$schema":"homarr-custom-widget-v2", "name":"...", "description":"...", "iconUrl":"https://...", "sources":[{ "id":"default", "name":"API", "baseUrl":"https://host", "networkScope":"public|private|loopback", "auth":{ "type":"none|bearer|basic|apiKeyHeader|apiKeyQuery" } }], "requests":[{ "id":"data", "sourceId":"default", "kind":"query", "method":"GET", "pathTemplate":"/api/{id}", "parameters":{ "id":"string", "limit":"number" }, "optionsBinding":{ "id":{ "$option":"resourceId" }, "limit":20 }, "queryTemplate":{ "limit":{ "$param":"limit" } }, "auth":"inherit", "minimumBoardPermission":"view", "trigger":"load" }], "optionsSchema":{ "type":"object", "properties":{ "resourceId":{ "type":"string" } }, "required":["resourceId"], "additionalProperties":false }, "defaultOptions":{ "resourceId":"example" }, "template":"__HOMARR_TEMPLATE__" }
Keep the result concise, accessible, responsive, and valid. Preserve unrelated fields when fixing an existing widget.`;
@@ -33,7 +33,7 @@ export const CUSTOM_WIDGET_MCP_AUTHORING_PROMPT = `Author one Homarr Custom JSX
6. Use customWidget_secretSet only for a credential the user supplied and only with dedicated permission. Otherwise call customWidget_secretRequestUser and poll its completion status; never receive or repeat the plaintext.
7. Call customWidget_create only after validation, request tests, action simulation, and visual inspection pass.
Use inline sources, named queries/actions, restricted JSON Schema options, temporary declarative bind controls exposed through inputs, and safe Mantine JSX. Use kind, not method, to distinguish reads from user-triggered changes. Never include credentials, imports, hooks, refs, callbacks, browser requests, arbitrary JavaScript, npm packages, polymorphic roots, or arbitrary portals. Make the widget responsive, accessible, loading-aware, empty-aware, and error-aware. The live schema and component resources are authoritative for the installed Homarr release.`;
Use inline sources, named queries/actions, restricted JSON Schema options, temporary declarative bind controls exposed through inputs, and safe Mantine JSX. Every load-query parameter needs an explicit optionsBinding option reference or literal; manual queries and actions receive all parameters from the invoking component's params prop. Never infer sources from matching names. Use kind, not method, to distinguish reads from user-triggered changes. Never include credentials, imports, hooks, refs, callbacks, browser requests, arbitrary JavaScript, npm packages, polymorphic roots, or arbitrary portals. Make the widget responsive, accessible, loading-aware, empty-aware, and error-aware. The live schema and component resources are authoritative for the installed Homarr release.`;
export function buildCustomWidgetMcpPrompt(request?: string | null, documentationUrl?: string | null) {
const sections = [CUSTOM_WIDGET_MCP_AUTHORING_PROMPT];
@@ -40,6 +40,7 @@ Create one widget at a time. Repeat the workflow when the user asks for several
- Use inline sources, named requests, definition-owned options, temporary bound inputs, and one safe JSX template.
- Use \`kind\`, not the HTTP method, to distinguish reads from user-triggered actions.
- Give every declared request parameter an explicit source. Load queries use \`optionsBinding\` with an option reference or primitive literal; manual queries and actions receive values only from the invoking component's \`params\` prop. Never infer bindings from matching names.
- Never put a credential in JSX, options, headers, URLs, examples, exports, logs, or chat output.
- Use declarative \`bind\` controls and Homarr request components; never emit imports, hooks, callbacks, browser requests, or arbitrary JavaScript.
- Include responsive loading, empty, error, and success states.
@@ -71,6 +71,11 @@ export const BUNDLED_CUSTOM_WIDGETS: readonly BundledCustomWidget[] = [
method: "GET",
pathTemplate: "/v1/latest",
parameters: { from: "string", to: "string", amount: "number" },
optionsBinding: {
from: { $option: "from" },
to: { $option: "to" },
amount: { $option: "amount" },
},
queryTemplate: {
from: { $param: "from" },
to: { $param: "to" },
@@ -167,6 +172,7 @@ export const BUNDLED_CUSTOM_WIDGETS: readonly BundledCustomWidget[] = [
method: "GET",
pathTemplate: "/api/v2/pokemon",
parameters: { limit: "number" },
optionsBinding: { limit: { $option: "limit" } },
queryTemplate: { limit: { $param: "limit" }, offset: 0 },
auth: "inherit",
minimumBoardPermission: "view",
@@ -121,6 +121,16 @@ function validateDynamicOptions(
path: [...path, "x-homarr", "optionsSource", "requestId"],
message: `Dynamic options require a query request named '${String(requestId)}'`,
});
} else {
for (const parameterName of Object.keys(request.parameters)) {
if (!Object.hasOwn(request.optionsBinding ?? {}, parameterName)) {
ctx.addIssue({
code: "custom",
path: [...path, "x-homarr", "optionsSource", "requestId"],
message: `Dynamic option request '${request.id}' parameter '${parameterName}' requires an explicit optionsBinding entry`,
});
}
}
}
}
}
@@ -167,6 +177,29 @@ function validateRequests(definition: Definition, sourceIds: Set<string>, ctx: z
});
}
}
for (const [parameterName, binding] of Object.entries(request.optionsBinding ?? {})) {
if (typeof binding !== "object") continue;
const optionName = binding.$option;
const optionSchema = definition.optionsSchema.properties?.[optionName];
if (optionSchema === null || typeof optionSchema !== "object" || Array.isArray(optionSchema)) {
ctx.addIssue({
code: "custom",
path: ["requests", index, "optionsBinding", parameterName, "$option"],
message: `Unknown widget option '${optionName}'`,
});
continue;
}
const parameterType = request.parameters[parameterName];
const declaredOptionType = (optionSchema as Record<string, unknown>).type;
const optionType = declaredOptionType === "integer" ? "number" : declaredOptionType;
if (parameterType !== undefined && optionType !== parameterType) {
ctx.addIssue({
code: "custom",
path: ["requests", index, "optionsBinding", parameterName, "$option"],
message: `Option '${optionName}' must be ${parameterType} to supply parameter '${parameterName}'`,
});
}
}
requests.set(request.id, request);
});
definition.requests.forEach((request, index) => {
@@ -207,6 +240,18 @@ function validateTemplateRequests(template: string, requests: Map<string, Custom
path: ["template"],
message: `${component} requires a ${expectedKind} request, but '${requestId}' is ${request.kind}`,
});
} else if (component === "SubFetch" && request.trigger !== "manual") {
ctx.addIssue({
code: "custom",
path: ["template"],
message: `SubFetch requires a manual query, but '${requestId}' is load-triggered`,
});
} else if (Object.keys(request.parameters).length > 0 && !/\bparams\s*=/u.test(match[2] ?? "")) {
ctx.addIssue({
code: "custom",
path: ["template"],
message: `${component} must supply params for request '${requestId}'`,
});
}
}
}
@@ -27,6 +27,7 @@ export function validateCredentialFreeExport(definition: SecurityDefinition, ctx
}
definition.requests.forEach((request, index) => {
if (
containsCredentialLikeValue(request.optionsBinding) ||
containsCredentialLikeValue(request.queryTemplate) ||
containsCredentialLikeValue(request.bodyTemplate) ||
Object.entries(request.staticHeaders ?? {}).some(
@@ -66,6 +67,7 @@ export function validatePrototypeKeys(definition: SecurityDefinition, ctx: Refin
[["defaultOptions"], definition.defaultOptions],
];
definition.requests.forEach((request, index) => {
values.push([["requests", index, "optionsBinding"], request.optionsBinding]);
values.push([["requests", index, "queryTemplate"], request.queryTemplate]);
values.push([["requests", index, "bodyTemplate"], request.bodyTemplate]);
});
@@ -21,6 +21,11 @@ export const customWidgetParameterIdentifierSchema = z
const requestParametersSchema = z
.record(customWidgetParameterIdentifierSchema, z.enum(customJsxRequestParameterTypes))
.refine((parameters) => Object.keys(parameters).length <= 32, "A request can declare at most 32 parameters");
const optionReferenceSchema = z.object({ $option: customWidgetParameterIdentifierSchema }).strict();
const requestOptionBindingValueSchema = z.union([z.string(), z.number().finite(), z.boolean(), optionReferenceSchema]);
const requestOptionsBindingSchema = z
.record(customWidgetParameterIdentifierSchema, requestOptionBindingValueSchema)
.refine((bindings) => Object.keys(bindings).length <= 32, "A request can bind at most 32 parameters");
const reservedRequestHeaders = new Set([
"authorization",
"connection",
@@ -154,6 +159,7 @@ export const customJsxRequestSchema = z
.refine((path) => !path.includes("\\"), "Path templates cannot contain backslashes")
.refine((path) => !path.includes("#"), "Path templates cannot contain URL fragments"),
parameters: requestParametersSchema,
optionsBinding: requestOptionsBindingSchema.optional(),
queryTemplate: z.record(z.string().min(1).max(256), z.json()).optional(),
bodyTemplate: z.json().optional(),
staticHeaders: staticHeadersSchema.optional(),
@@ -192,6 +198,34 @@ export const customJsxRequestSchema = z
});
}
const declared = new Set(Object.keys(request.parameters));
const optionBindings = request.optionsBinding ?? {};
for (const [name, binding] of Object.entries(optionBindings)) {
const parameterType = request.parameters[name];
if (!parameterType) {
ctx.addIssue({
code: "custom",
path: ["optionsBinding", name],
message: `Option binding '${name}' does not match a declared parameter`,
});
} else if ((typeof binding !== "object" || binding === null) && typeof binding !== parameterType) {
ctx.addIssue({
code: "custom",
path: ["optionsBinding", name],
message: `Literal binding for '${name}' must be ${parameterType}`,
});
}
}
if (request.kind === "query" && request.trigger === "load") {
for (const name of declared) {
if (!Object.hasOwn(optionBindings, name)) {
ctx.addIssue({
code: "custom",
path: ["optionsBinding", name],
message: `Load request parameter '${name}' requires an explicit option reference or literal value`,
});
}
}
}
const used = [...request.pathTemplate.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1] ?? "");
collectParameterNames(request.queryTemplate).forEach((name) => used.push(name));
collectParameterNames(request.bodyTemplate).forEach((name) => used.push(name));
@@ -202,3 +236,26 @@ export const customJsxRequestSchema = z
}
});
export type CustomJsxRequest = z.infer<typeof customJsxRequestSchema>;
export type CustomJsxRequestOptionsBinding = NonNullable<CustomJsxRequest["optionsBinding"]>;
export function resolveCustomWidgetOptionsBinding(
request: Pick<CustomJsxRequest, "id" | "parameters" | "optionsBinding">,
options: Record<string, unknown>,
): Record<string, string | number | boolean> {
const bindings = request.optionsBinding ?? {};
const params: Record<string, string | number | boolean> = {};
for (const [name, expectedType] of Object.entries(request.parameters)) {
const binding = bindings[name];
if (binding === undefined) {
throw new Error(`Request '${request.id}' parameter '${name}' has no explicit option binding`);
}
const optionName = typeof binding === "object" ? binding.$option : undefined;
const value = optionName === undefined ? binding : options[optionName];
if (typeof value !== expectedType) {
const source = optionName === undefined ? "literal binding" : `option '${optionName}'`;
throw new Error(`Request '${request.id}' parameter '${name}' requires ${source} to be ${expectedType}`);
}
params[name] = value as string | number | boolean;
}
return params;
}
@@ -59,6 +59,23 @@ export const CUSTOM_WIDGET_REQUEST_EXAMPLES = {
trigger: "load",
cacheTtlSeconds: 60,
},
{
id: "summary",
sourceId: "default",
kind: "query",
method: "GET",
pathTemplate: "/api/environments/{environmentId}/summary",
parameters: { environmentId: "number", includeStopped: "boolean" },
optionsBinding: {
environmentId: { $option: "environmentId" },
includeStopped: false,
},
queryTemplate: { includeStopped: { $param: "includeStopped" } },
auth: "inherit",
minimumBoardPermission: "view",
trigger: "load",
cacheTtlSeconds: 60,
},
{
id: "search",
sourceId: "default",
@@ -32,6 +32,9 @@ describe("buildCustomWidgetAiPrompt", () => {
expect(prompt).toContain("homarr-custom-widget-v2");
expect(prompt).toContain("Return exactly two fenced blocks");
expect(prompt).toContain("Never include credentials");
expect(prompt).toContain("Every declared parameter needs an explicit value source");
expect(prompt).toContain('"optionsBinding"');
expect(prompt).toContain("Never infer a binding from matching names");
expect(prompt).toContain("Create a Portainer widget");
expect(prompt.length).toBeLessThanOrEqual(8_000);
});
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { resolveCustomWidgetOptionsBinding } from "../core";
import type { CustomJsxRequest } from "../core";
import { hashRuntimeParams, renderRequestBody, renderRequestTarget, validateRuntimeParams } from "../server";
@@ -58,4 +59,28 @@ describe("named request manifest rendering", () => {
it("hashes parameters independently of object insertion order", () => {
expect(hashRuntimeParams({ a: 1, b: "two" })).toBe(hashRuntimeParams({ b: "two", a: 1 }));
});
it("resolves only explicit option and literal bindings for automatic requests", () => {
const loadRequest: CustomJsxRequest = {
...request,
kind: "query",
trigger: "load",
optionsBinding: {
device: { $option: "selectedDevice" },
enabled: true,
level: { $option: "volume" },
},
};
expect(resolveCustomWidgetOptionsBinding(loadRequest, { selectedDevice: "lamp", volume: 5 })).toEqual({
device: "lamp",
enabled: true,
level: 5,
});
expect(() => resolveCustomWidgetOptionsBinding({ ...loadRequest, optionsBinding: undefined }, {})).toThrow(
"has no explicit option binding",
);
expect(() => resolveCustomWidgetOptionsBinding(loadRequest, { device: "lamp", level: 5 })).toThrow(
"option 'selectedDevice'",
);
});
});
@@ -83,6 +83,116 @@ describe("Custom JSX v2 validation", () => {
).toBe(true);
});
test("requires explicit parameter sources for load queries", () => {
const optionsSchema = {
type: "object" as const,
properties: {
endpointId: { type: "string" },
wrongType: { type: "boolean" },
},
required: ["endpointId"],
additionalProperties: false as const,
};
const request = {
id: "containers",
sourceId: "default",
kind: "query" as const,
method: "GET" as const,
pathTemplate: "/endpoints/{endpointId}/containers",
parameters: { endpointId: "string" as const, showAll: "boolean" as const },
queryTemplate: { all: { $param: "showAll" } },
auth: "none" as const,
minimumBoardPermission: "view" as const,
trigger: "load" as const,
};
const missing = customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
requests: [request],
optionsSchema,
defaultOptions: { endpointId: "local" },
});
expect(missing.success).toBe(false);
if (missing.success) throw new Error("Expected missing bindings to fail");
expect(missing.error.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: ["requests", 0, "optionsBinding", "endpointId"],
message: expect.stringContaining("explicit option reference or literal"),
}),
expect.objectContaining({ path: ["requests", 0, "optionsBinding", "showAll"] }),
]),
);
expect(
customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
requests: [
{
...request,
optionsBinding: { endpointId: { $option: "endpointId" }, showAll: false },
},
],
optionsSchema,
defaultOptions: { endpointId: "local" },
}).success,
).toBe(true);
expect(
customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
requests: [
{
...request,
optionsBinding: { endpointId: { $option: "wrongType" }, showAll: false },
},
],
optionsSchema,
defaultOptions: { endpointId: "local" },
}).success,
).toBe(false);
});
test("requires invoking components to provide params for parameterized manual requests", () => {
const request = {
id: "detail",
sourceId: "default",
kind: "query" as const,
method: "GET" as const,
pathTemplate: "/items/{id}",
parameters: { id: "string" as const },
auth: "none" as const,
minimumBoardPermission: "view" as const,
trigger: "manual" as const,
};
expect(
customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
requests: [request],
template: '<SubFetch requestId="detail" />',
}).success,
).toBe(false);
expect(
customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
requests: [request],
template: '<SubFetch requestId="detail" params={{ id: inputs.selectedId }} />',
}).success,
).toBe(true);
expect(
customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
requests: [
{
...request,
trigger: "load",
optionsBinding: { id: "fixed" },
},
],
template: '<SubFetch requestId="detail" params={{ id: inputs.selectedId }} />',
}).success,
).toBe(false);
});
test("rejects reserved headers, duplicate IDs, and unknown sources", () => {
const request = {
id: "status",
@@ -150,6 +260,7 @@ describe("Custom JSX v2 validation", () => {
method: "POST",
pathTemplate: "/api/v3/calendar",
parameters: { start: "string" },
optionsBinding: { start: { $option: "start" } },
bodyTemplate: { start: { $param: "start" } },
auth: "inherit",
minimumBoardPermission: "view",
@@ -23,7 +23,7 @@ import { IconAlertTriangle, IconBraces } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useOptionalBoard } from "@homarr/boards/context";
import { validateCustomWidgetOptions } from "@homarr/custom-widgets/core";
import { resolveCustomWidgetOptionsBinding, validateCustomWidgetOptions } from "@homarr/custom-widgets/core";
import { IconPicker } from "@homarr/forms-collection";
import { useScopedI18n } from "@homarr/translation/client";
@@ -336,15 +336,17 @@ function DynamicOptionsSelect({
const definition = available.data?.find((candidate) => candidate.id === definitionId);
const requestId = typeof source.requestId === "string" ? source.requestId : "";
const request = definition?.optionRequests.find((candidate) => candidate.id === requestId);
const params = Object.fromEntries(
Object.entries(request?.parameters ?? {}).flatMap(([name, type]) => {
const value = configuration[name];
return typeof value === type ? [[name, value as string | number | boolean]] : [];
}),
);
const params = (() => {
if (!request) return null;
try {
return resolveCustomWidgetOptionsBinding(request, configuration);
} catch {
return null;
}
})();
const query = clientApi.customWidget.optionRequest.useQuery(
{ boardId: board?.id ?? "", definitionId, requestId, params },
{ enabled: Boolean(board?.id && definitionId && requestId && request) },
{ boardId: board?.id ?? "", definitionId, requestId, params: params ?? {} },
{ enabled: Boolean(board?.id && definitionId && requestId && request && params) },
);
const valuePath = typeof source.valuePath === "string" ? source.valuePath : "$.value";
const labelPath = typeof source.labelPath === "string" ? source.labelPath : "$.label";