diff --git a/.agents/skills/homarr-custom-widget/SKILL.md b/.agents/skills/homarr-custom-widget/SKILL.md index 9869523fa..7643c4524 100644 --- a/.agents/skills/homarr-custom-widget/SKILL.md +++ b/.agents/skills/homarr-custom-widget/SKILL.md @@ -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. diff --git a/.agents/skills/homarr-custom-widget/references/runtime.md b/.agents/skills/homarr-custom-widget/references/runtime.md index 143f5d091..4755efc40 100644 --- a/.agents/skills/homarr-custom-widget/references/runtime.md +++ b/.agents/skills/homarr-custom-widget/references/runtime.md @@ -8,6 +8,27 @@ Render icons as ``. 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 + +Restart +``` + +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 diff --git a/.agents/skills/homarr-custom-widget/references/schema.md b/.agents/skills/homarr-custom-widget/references/schema.md index 213192283..2a22ff5f2 100644 --- a/.agents/skills/homarr-custom-widget/references/schema.md +++ b/.agents/skills/homarr-custom-widget/references/schema.md @@ -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. diff --git a/apps/docs/docs/management/custom-widgets/authoring.mdx b/apps/docs/docs/management/custom-widgets/authoring.mdx index e061ee08c..8cc9f395b 100644 --- a/apps/docs/docs/management/custom-widgets/authoring.mdx +++ b/apps/docs/docs/management/custom-widgets/authoring.mdx @@ -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. = {}; const status: Record = {}; 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 }; diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/preview/[id]/page.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/preview/[id]/page.tsx index e94b48e86..e811abc75 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/preview/[id]/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/preview/[id]/page.tsx @@ -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, diff --git a/packages/api/src/router/custom-widget/management-queries.ts b/packages/api/src/router/custom-widget/management-queries.ts index 2692661dc..4bb07469e 100644 --- a/packages/api/src/router/custom-widget/management-queries.ts +++ b/packages/api/src/router/custom-widget/management-queries.ts @@ -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, }; }); diff --git a/packages/api/src/router/custom-widget/preview-base-procedures.ts b/packages/api/src/router/custom-widget/preview-base-procedures.ts index 2393989aa..6c6d1b1ca 100644 --- a/packages/api/src/router/custom-widget/preview-base-procedures.ts +++ b/packages/api/src/router/custom-widget/preview-base-procedures.ts @@ -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, }), diff --git a/packages/api/src/router/custom-widget/preview-query-procedures.ts b/packages/api/src/router/custom-widget/preview-query-procedures.ts index d6d79e723..8e3c7e760 100644 --- a/packages/api/src/router/custom-widget/preview-query-procedures.ts +++ b/packages/api/src/router/custom-widget/preview-query-procedures.ts @@ -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, { diff --git a/packages/api/src/router/widgets/custom-api.ts b/packages/api/src/router/widgets/custom-api.ts index 25bca9b49..85b191aee 100644 --- a/packages/api/src/router/widgets/custom-api.ts +++ b/packages/api/src/router/widgets/custom-api.ts @@ -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 = {}; - 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, diff --git a/packages/custom-widgets/src/core/ai-prompt.ts b/packages/custom-widgets/src/core/ai-prompt.ts index d69cb39d0..8ea3d7ae8 100644 --- a/packages/custom-widgets/src/core/ai-prompt.ts +++ b/packages/custom-widgets/src/core/ai-prompt.ts @@ -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.. +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.. 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]; diff --git a/packages/custom-widgets/src/core/authoring-resources.ts b/packages/custom-widgets/src/core/authoring-resources.ts index a4a1c7278..3ffe8e01b 100644 --- a/packages/custom-widgets/src/core/authoring-resources.ts +++ b/packages/custom-widgets/src/core/authoring-resources.ts @@ -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. diff --git a/packages/custom-widgets/src/core/bundled-widgets.ts b/packages/custom-widgets/src/core/bundled-widgets.ts index 0653bd04d..7a1b29bb9 100644 --- a/packages/custom-widgets/src/core/bundled-widgets.ts +++ b/packages/custom-widgets/src/core/bundled-widgets.ts @@ -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", diff --git a/packages/custom-widgets/src/core/custom-jsx-schema.ts b/packages/custom-widgets/src/core/custom-jsx-schema.ts index edb5656de..74331647d 100644 --- a/packages/custom-widgets/src/core/custom-jsx-schema.ts +++ b/packages/custom-widgets/src/core/custom-jsx-schema.ts @@ -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, 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).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 0 && !/\bparams\s*=/u.test(match[2] ?? "")) { + ctx.addIssue({ + code: "custom", + path: ["template"], + message: `${component} must supply params for request '${requestId}'`, + }); } } } diff --git a/packages/custom-widgets/src/core/definition-security.ts b/packages/custom-widgets/src/core/definition-security.ts index c68cc0ce6..6205c5b7a 100644 --- a/packages/custom-widgets/src/core/definition-security.ts +++ b/packages/custom-widgets/src/core/definition-security.ts @@ -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]); }); diff --git a/packages/custom-widgets/src/core/request-schema.ts b/packages/custom-widgets/src/core/request-schema.ts index 93b485ed2..6f776ccd8 100644 --- a/packages/custom-widgets/src/core/request-schema.ts +++ b/packages/custom-widgets/src/core/request-schema.ts @@ -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; +export type CustomJsxRequestOptionsBinding = NonNullable; + +export function resolveCustomWidgetOptionsBinding( + request: Pick, + options: Record, +): Record { + const bindings = request.optionsBinding ?? {}; + const params: Record = {}; + 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; +} diff --git a/packages/custom-widgets/src/core/schema-references.ts b/packages/custom-widgets/src/core/schema-references.ts index d9986c45d..84c7845f1 100644 --- a/packages/custom-widgets/src/core/schema-references.ts +++ b/packages/custom-widgets/src/core/schema-references.ts @@ -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", diff --git a/packages/custom-widgets/src/test/ai-prompt.spec.ts b/packages/custom-widgets/src/test/ai-prompt.spec.ts index 31bdb82fb..9a4e5b525 100644 --- a/packages/custom-widgets/src/test/ai-prompt.spec.ts +++ b/packages/custom-widgets/src/test/ai-prompt.spec.ts @@ -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); }); diff --git a/packages/custom-widgets/src/test/request-manifest.spec.ts b/packages/custom-widgets/src/test/request-manifest.spec.ts index d716f905d..09f139cad 100644 --- a/packages/custom-widgets/src/test/request-manifest.spec.ts +++ b/packages/custom-widgets/src/test/request-manifest.spec.ts @@ -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'", + ); + }); }); diff --git a/packages/custom-widgets/src/test/schema.spec.ts b/packages/custom-widgets/src/test/schema.spec.ts index 3f0cb7282..9a5df6de5 100644 --- a/packages/custom-widgets/src/test/schema.spec.ts +++ b/packages/custom-widgets/src/test/schema.spec.ts @@ -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: '', + }).success, + ).toBe(false); + expect( + customWidgetDefinitionSchema.safeParse({ + ...CUSTOM_WIDGET_STARTER, + requests: [request], + template: '', + }).success, + ).toBe(true); + expect( + customWidgetDefinitionSchema.safeParse({ + ...CUSTOM_WIDGET_STARTER, + requests: [ + { + ...request, + trigger: "load", + optionsBinding: { id: "fixed" }, + }, + ], + template: '', + }).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", diff --git a/packages/widgets/src/_inputs/widget-custom-widget-configuration-input.tsx b/packages/widgets/src/_inputs/widget-custom-widget-configuration-input.tsx index 7ba1d8fe1..c97da7065 100644 --- a/packages/widgets/src/_inputs/widget-custom-widget-configuration-input.tsx +++ b/packages/widgets/src/_inputs/widget-custom-widget-configuration-input.tsx @@ -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";