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

@@ -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",