fix(custom-widgets): require explicit request bindings
This commit is contained in:
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, {
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user