From 52c82f72db9216bc61e7a1475d89a37c78ad4ac9 Mon Sep 17 00:00:00 2001 From: ajnart Date: Sat, 12 Sep 2026 20:17:07 +0200 Subject: [PATCH] feat: reuse integrations in custom widgets --- .../homarr-custom-widget/references/schema.md | 12 +- .../custom-widgets/requests-and-security.mdx | 22 ++- .../[token]/configuration-entry.tsx | 62 +++--- .../_custom-widget-source-field.tsx | 187 ++++++++++++------ .../_custom-widget-sources-editor.tsx | 8 +- .../workshop/[id]/_workshop-detail.tsx | 5 +- .../configuration-request/[token]/route.ts | 36 +++- .../configuration-request/body.ts | 16 +- .../custom-widget-import-dialog.tsx | 5 +- .../custom-widget-source-setup-dialog.tsx | 17 +- .../integration-source-select.tsx | 57 ++++++ .../custom-widgets/source-setup-panel.tsx | 26 +++ .../use-custom-widget-import.ts | 2 +- .../workshop/workshop-publish-definition.ts | 4 +- .../api/src/custom-widget-configuration.ts | 2 + .../custom-widget/creation-procedures.ts | 3 + .../custom-widget/custom-widget-router.ts | 7 +- .../custom-widget/management-queries.ts | 29 +-- .../custom-widget/management-query-mappers.ts | 19 +- .../preview-action-procedures.ts | 7 +- .../custom-widget/preview-base-procedures.ts | 7 +- .../preview-procedure-helpers.ts | 27 +-- .../custom-widget/preview-query-procedures.ts | 9 +- .../custom-widget/secret-persistence.ts | 19 +- .../src/router/custom-widget/secret-policy.ts | 5 +- .../router/custom-widget/secret-procedures.ts | 53 +++-- .../router/custom-widget/source-resolver.ts | 106 ++++++++++ .../custom-widget/transfer-procedures.ts | 8 +- .../custom-widget/workshop-procedures.ts | 9 +- .../definition-persistence.spec.ts | 9 +- packages/api/src/router/widgets/custom-api.ts | 43 ++-- .../custom-widgets/scripts/ai-evaluation.ts | 5 +- packages/custom-widgets/src/core/ai-prompt.ts | 2 + .../src/core/authoring-resources.ts | 15 +- .../src/core/custom-jsx-schema.ts | 11 ++ packages/custom-widgets/src/core/import.ts | 27 ++- .../custom-widgets/src/core/request-schema.ts | 24 ++- .../src/core/secret-requirements.ts | 3 +- .../custom-widgets/src/core/source-setup.ts | 40 +++- .../src/server/network-policy.ts | 41 +++- .../src/server/preview-session-template.ts | 7 + .../src/server/preview-sessions.ts | 6 +- .../src/server/request-executor.ts | 6 + .../src/test/authoring-resources.spec.ts | 2 +- .../src/workbench/source-setup.tsx | 20 +- packages/integrations/package.json | 1 + .../integrations/src/custom-widget-source.ts | 18 ++ packages/translation/src/lang/en.json | 10 +- packages/workshop/src/schema.ts | 3 +- 49 files changed, 793 insertions(+), 269 deletions(-) create mode 100644 apps/nextjs/src/components/custom-widgets/integration-source-select.tsx create mode 100644 apps/nextjs/src/components/custom-widgets/source-setup-panel.tsx create mode 100644 packages/api/src/router/custom-widget/source-resolver.ts create mode 100644 packages/integrations/src/custom-widget-source.ts diff --git a/.agents/skills/homarr-custom-widget/references/schema.md b/.agents/skills/homarr-custom-widget/references/schema.md index 2363462b6..ff28b9608 100644 --- a/.agents/skills/homarr-custom-widget/references/schema.md +++ b/.agents/skills/homarr-custom-widget/references/schema.md @@ -13,7 +13,7 @@ interface HomarrCustomWidgetV2 { } ``` -The object key `default` is the required source ID; `default` is not a property on a source. Source properties are `name?`, `baseUrl`, `networkScope`, and `auth?`: +`sources.default` is required. HTTP source properties: `name?`, `baseUrl`, `networkScope`, and `auth?`: ```json { @@ -40,10 +40,12 @@ The object key `default` is the required source ID; `default` is not a property } ``` -Auth is `none`, `bearer`, `basic`, `{ "type": "apiKeyHeader", "name": "X-Api-Key" }`, or `{ "type": "apiKeyQuery", "name": "api_key" }`. A request defaults to source `default`, kind `query`, method `GET`, trigger `load`, inherited auth, and view permission. Set `trigger: "manual"` for a parameterized query. An action defaults to manual and modify permission. DELETE uses full permission and confirmation. Do not use `load: false`. +Integration sources use `{"type":"integration","integrationKind":"sonarr"}` (Sonarr or Radarr). Bind a local `integrationId` from `integration_all`; exports omit it. Omit URL/auth fields. Paths append to the integration URL; non-GET requests must be actions. -Use stable real URLs for public APIs and clear suggested URLs for self-hosted services. Homarr collects the installer's server URL, network scope, and credentials as source setup; credentials remain outside the manifest. +Auth: `none`, `bearer`, `basic`, `{ "type": "apiKeyHeader", "name": "X-Api-Key" }`, or `{ "type": "apiKeyQuery", "name": "api_key" }`. Requests default to source `default`, kind `query`, method `GET`, trigger `load`, inherited auth, and view permission. Parameterized queries need `trigger: "manual"`. Actions default to manual/modify; DELETE requires full permission and confirmation. Do not use `load: false`. -Paths use `{option:name}` and `{param:name}`; query/body references use `{ "$option": "name" }` and `{ "$param": "name" }`. Constants stay primitive (`take: 10`); `$param` is only for manual helpers, never load queries. Names and types are inferred. +HTTP sources declare public URLs or self-hosted suggestions; installers configure their URL, network scope, and credentials separately. -Every option has `label`, `control`, and `default`. Optional fields are `description`, `choices`, `choicesFrom`, `min`, `max`, `step`, `advanced`, and `group`. +Paths bind `{option:name}` and `{param:name}`; query/body references bind `{ "$option": "name" }` and `{ "$param": "name" }`. Use primitive constants (`take: 10`). `$param` is only for manual requests. + +Options require `label`, `control`, and `default`. Optional fields: `description`, `choices`, `choicesFrom`, `min`, `max`, `step`, `advanced`, `group`. diff --git a/apps/docs/docs/management/custom-widgets/requests-and-security.mdx b/apps/docs/docs/management/custom-widgets/requests-and-security.mdx index f06362c8e..d70d7fddb 100644 --- a/apps/docs/docs/management/custom-widgets/requests-and-security.mdx +++ b/apps/docs/docs/management/custom-widgets/requests-and-security.mdx @@ -3,10 +3,30 @@ Most widgets use a single source named `default`; the manifest must contain it. Extra keyed sources are available for widgets that combine APIs. Source IDs are case-insensitively unique. Base URLs must use HTTP(S), contain no embedded credentials, query string, or fragment, and sources can be `public`, `private`, or `loopback`. Authentication is `none`, `bearer`, -`basic`, an API-key header, or an API-key query parameter. Credentials belong to the widget but remain outside its manifest. +`basic`, an API-key header, or an API-key query parameter. HTTP source credentials belong to the widget but remain outside its manifest. Source origins are fixed by the definition and source-setup flow; widget options and invocation parameters cannot redirect a request to a different origin. +## Existing integrations + +Sonarr and Radarr sources can reuse a configured integration: + +```json +{ + "sources": { "default": { "type": "integration", "integrationKind": "sonarr" } }, + "requests": { "queue": { "path": "/api/v3/queue" } } +} +``` + +Select the local instance during source setup. Homarr uses its current URL, API key, and trusted certificates; +paths append to the saved URL, including reverse-proxy subpaths. Changing the integration updates all sources bound to it. +Deleting it leaves the widget unconfigured until another instance is selected. The binding is shared by every placement +of that widget definition. Exports and Workshop submissions retain the required type and omit the local integration ID. + +Integration requests require integration `use` access for GET queries, `interact` for actions, and `full` for DELETE, +in addition to the request's board permission. Board access alone does not grant integration access. Methods other than +GET must be manual actions. Requests and redirects must stay within the configured integration's URL path. + ## Named requests Requests are keyed by ID. `source`, `kind`, `method`, `trigger`, `auth`, and `permission` have sensible defaults, so a load query can be as small as: diff --git a/apps/nextjs/src/app/[locale]/custom-widget-configuration/[token]/configuration-entry.tsx b/apps/nextjs/src/app/[locale]/custom-widget-configuration/[token]/configuration-entry.tsx index f8d8dda88..e439a773f 100644 --- a/apps/nextjs/src/app/[locale]/custom-widget-configuration/[token]/configuration-entry.tsx +++ b/apps/nextjs/src/app/[locale]/custom-widget-configuration/[token]/configuration-entry.tsx @@ -16,6 +16,9 @@ import { } from "@mantine/core"; import { IconCheck, IconKey, IconLock } from "@tabler/icons-react"; +import type { CustomWidgetSource } from "@homarr/custom-widgets/core"; +import { IntegrationSourceSelect } from "~/components/custom-widgets/integration-source-select"; + import { useI18n } from "@homarr/translation/client"; interface RequestDetails { @@ -24,10 +27,7 @@ interface RequestDetails { kinds: Array<"apiKey" | "username" | "password">; expiresAt: number; status: "pending" | "completed"; - source: { - baseUrl: string; - networkScope: "public" | "private" | "loopback"; - }; + source: CustomWidgetSource; } export function CustomWidgetConfigurationEntry({ token }: { token: string }) { @@ -35,6 +35,7 @@ export function CustomWidgetConfigurationEntry({ token }: { token: string }) { const tSecret = useI18n("customWidget.secret"); const [details, setDetails] = useState(null); const [values, setValues] = useState>({}); + const [integrationId, setIntegrationId] = useState(); const [baseUrl, setBaseUrl] = useState(""); const [networkScope, setNetworkScope] = useState<"public" | "private" | "loopback">("public"); const [error, setError] = useState(null); @@ -47,8 +48,9 @@ export function CustomWidgetConfigurationEntry({ token }: { token: string }) { const body = (await response.json()) as RequestDetails | { error: string }; if (!response.ok || "error" in body) throw new Error("error" in body ? body.error : t("unavailable")); setDetails(body); - setBaseUrl(body.source.baseUrl); - setNetworkScope(body.source.networkScope); + setBaseUrl(body.source.baseUrl ?? ""); + setNetworkScope(body.source.networkScope ?? "public"); + setIntegrationId(body.source.integrationId); }) .catch((cause: unknown) => setError(cause instanceof Error ? cause.message : t("unavailable"))) .finally(() => setLoading(false)); @@ -58,10 +60,12 @@ export function CustomWidgetConfigurationEntry({ token }: { token: string }) { setSaving(true); setError(null); try { + let configuration: unknown = { baseUrl, networkScope, secrets: values }; + if (details?.source.type === "integration") configuration = { integrationId, secrets: {} }; const response = await fetch(`/api/custom-widgets/configuration-request/${encodeURIComponent(token)}`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ baseUrl, networkScope, secrets: values }), + body: JSON.stringify(configuration), }); const body = (await response.json()) as { status?: string; error?: string }; if (!response.ok) throw new Error(body.error ?? t("saveError")); @@ -102,20 +106,31 @@ export function CustomWidgetConfigurationEntry({ token }: { token: string }) { {t("source", { name: details.sourceName })} - setBaseUrl(event.currentTarget.value)} - required - /> - value && setNetworkScope(value as typeof networkScope)} + /> + + )} {details.kinds.map((kind) => { const Input = kind === "username" ? TextInput : PasswordInput; return ( @@ -132,7 +147,10 @@ export function CustomWidgetConfigurationEntry({ token }: { token: string }) { })} - )} - - ); - })} {index > 0 && ( + )} + + ); + })} + + ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-sources-editor.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-sources-editor.tsx index a6188677f..f785b4392 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-sources-editor.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-sources-editor.tsx @@ -28,6 +28,12 @@ export function CustomWidgetSourcesEditor({ const clearSecretMutation = clientApi.customWidget.secretClear.useMutation(); const sources = parseSources(form.values.sources); const update = (index: number, changes: Partial & { id?: string }) => { + if (changes.type) { + form.setFieldValue( + "secrets", + form.values.secrets.filter((secret) => secret.sourceId !== sources[index]?.id), + ); + } const previousId = sources[index]?.id; const nextId = changes.id ?? previousId; if (nextId && sources.some((source, sourceIndex) => sourceIndex !== index && source.id === nextId)) { @@ -42,7 +48,7 @@ export function CustomWidgetSourcesEditor({ Object.fromEntries( sourceEntries.map(([id, source], i) => [ i === index ? nextId : id, - i === index ? { ...source, ...changes, id: undefined } : source, + i === index ? { ...(changes.type ? { name: source.name } : source), ...changes, id: undefined } : source, ]), ), null, diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/[id]/_workshop-detail.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/[id]/_workshop-detail.tsx index 77cd96cba..10fb4c7d3 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/[id]/_workshop-detail.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/[id]/_workshop-detail.tsx @@ -1,5 +1,6 @@ "use client"; +import { SourceSetupPanel } from "~/components/custom-widgets/source-setup-panel"; import { useRouter } from "next/navigation"; import { useMemo } from "react"; import { @@ -22,7 +23,7 @@ import { IconAlertTriangle, IconArrowLeft, IconDownload, IconExternalLink, IconF import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core"; import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core"; -import { CustomWidgetSourceSetupPanel, ImportReviewContent } from "@homarr/custom-widgets/workbench"; +import { ImportReviewContent } from "@homarr/custom-widgets/workbench"; import { useI18n } from "@homarr/translation/client"; import { Link } from "@homarr/ui"; import { useWorkshopReportSummariesQuery, useWorkshopSubmissionQuery } from "@homarr/workshop/backend"; @@ -190,7 +191,7 @@ export function WorkshopDetail({ id }: { id: string }) { - 0 + ) { + return NextResponse.json({ error: "Select an existing integration." }, { status: 400 }); + } + candidate = { ...pending.source, integrationId: body.integrationId }; + } else { + if (body.integrationId) + return NextResponse.json({ error: "HTTP sources cannot bind an integration." }, { status: 400 }); + candidate = { ...pending.source, baseUrl: body.baseUrl, networkScope: body.networkScope }; + } + const sourceResult = customWidgetSourceSchema.safeParse(candidate); if (!sourceResult.success) { return NextResponse.json( { error: sourceResult.error.issues[0]?.message ?? "Enter a valid server URL." }, { status: 400 }, ); } + try { + await assertCustomWidgetIntegrationBindings( + { db, session: await auth() }, + { [pending.sourceId]: sourceResult.data }, + ); + } catch { + return NextResponse.json( + { error: "The selected integration is unavailable or has a different type." }, + { status: 400 }, + ); + } const secrets = pending.kinds.flatMap((kind) => { const value = body.secrets[kind]; return typeof value === "string" && value.length > 0 && value.length <= 8192 @@ -95,6 +120,7 @@ const completeConfigurationRequest = async (request: NextRequest, context: Route const result = await configureCustomWidgetSourceFromRequest(db, { definitionId: claimed.target.id, sourceId: claimed.sourceId, + integrationId: sourceResult.data.integrationId, baseUrl: sourceResult.data.baseUrl, networkScope: sourceResult.data.networkScope, secrets, diff --git a/apps/nextjs/src/app/api/custom-widgets/configuration-request/body.ts b/apps/nextjs/src/app/api/custom-widgets/configuration-request/body.ts index dd061d1ec..1fc068e53 100644 --- a/apps/nextjs/src/app/api/custom-widgets/configuration-request/body.ts +++ b/apps/nextjs/src/app/api/custom-widgets/configuration-request/body.ts @@ -1,11 +1,17 @@ import { z } from "zod/v4"; export const MAX_CONFIGURATION_REQUEST_BODY_BYTES = 24 * 1024; -const configurationRequestBodySchema = z.strictObject({ - baseUrl: z.string(), - networkScope: z.enum(["public", "private", "loopback"]), - secrets: z.record(z.string(), z.string()), -}); +const configurationRequestBodySchema = z + .strictObject({ + baseUrl: z.string().optional(), + integrationId: z.string().min(1).max(100).optional(), + networkScope: z.enum(["public", "private", "loopback"]).optional(), + secrets: z.record(z.string(), z.string()), + }) + .refine( + (value) => Boolean(value.integrationId) || (value.baseUrl !== undefined && value.networkScope !== undefined), + "Provide an integration or HTTP connection", + ); export async function readConfigurationRequestBody( request: Request, diff --git a/apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx b/apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx index e8fa8fabf..5ece18b91 100644 --- a/apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx +++ b/apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx @@ -1,9 +1,10 @@ "use client"; +import { SourceSetupPanel } from "~/components/custom-widgets/source-setup-panel"; import type { ModalProps } from "@mantine/core"; import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core"; -import { CustomWidgetSourceSetupPanel, ImportReviewDialog } from "@homarr/custom-widgets/workbench"; +import { ImportReviewDialog } from "@homarr/custom-widgets/workbench"; import { useI18n } from "@homarr/translation/client"; import { useCustomWidgetImport } from "./use-custom-widget-import"; @@ -67,7 +68,7 @@ export function CustomWidgetImportDialog({ confirm: legacyId ? t("importReview.confirmMigration") : (labels?.confirm ?? t("importReview.confirm")), }} > - secret?.trim() ? [{ sourceId: setup.sourceId, kind: kind as CustomWidgetSecretKind, value: secret }] : [], ); + if (setup.integrationKind) { + await configureMutation.mutateAsync({ + definitionId, + sourceId: setup.sourceId, + integrationId: value.integrationId, + }); + continue; + } await configureMutation.mutateAsync({ definitionId, sourceId: setup.sourceId, @@ -109,7 +114,7 @@ export function CustomWidgetSourceSetupDialog({ definitionId, opened, onClose }: ) : ( - integration.kind === kind && integration.permissions.hasFullAccess, + ); + const unavailable = Boolean(integrationId && query.isSuccess && !integrations.some(({ id }) => id === integrationId)); + return ( + + {onKindChange && ( + ({ value: id, label: name }))} + value={integrationId ?? null} + searchable + clearable + disabled={query.isPending} + nothingFoundMessage={t("noIntegrations")} + error={unavailable && t("integrationUnavailable")} + onChange={(value) => onChange(value ?? undefined)} + /> + {query.error && {query.error.message}} + + {t("integrationDescription")} + + + ); +} diff --git a/apps/nextjs/src/components/custom-widgets/source-setup-panel.tsx b/apps/nextjs/src/components/custom-widgets/source-setup-panel.tsx new file mode 100644 index 000000000..17eab0cde --- /dev/null +++ b/apps/nextjs/src/components/custom-widgets/source-setup-panel.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { customWidgetIntegrationKinds } from "@homarr/custom-widgets/core"; +import { CustomWidgetSourceSetupPanel } from "@homarr/custom-widgets/workbench"; +import type { CustomWidgetSourceSetupPanelProps } from "@homarr/custom-widgets/workbench"; + +import { IntegrationSourceSelect } from "./integration-source-select"; + +export function SourceSetupPanel(props: CustomWidgetSourceSetupPanelProps) { + return ( + { + const kind = customWidgetIntegrationKinds.find((candidate) => candidate === setup.integrationKind); + if (!kind) return null; + return ( + props.onChange(setup.sourceId, { ...value, integrationId })} + /> + ); + }} + /> + ); +} diff --git a/apps/nextjs/src/components/custom-widgets/use-custom-widget-import.ts b/apps/nextjs/src/components/custom-widgets/use-custom-widget-import.ts index 834a5090b..f4ad5ece8 100644 --- a/apps/nextjs/src/components/custom-widgets/use-custom-widget-import.ts +++ b/apps/nextjs/src/components/custom-widgets/use-custom-widget-import.ts @@ -60,7 +60,7 @@ export function useCustomWidgetImport({ widget, legacyId, onImported }: UseCusto Object.fromEntries( Object.entries(values).map(([sourceId, value]) => [ sourceId, - { baseUrl: value.baseUrl, networkScope: value.networkScope }, + { baseUrl: value.baseUrl, networkScope: value.networkScope, integrationId: value.integrationId }, ]), ), ), diff --git a/apps/nextjs/src/components/workshop/workshop-publish-definition.ts b/apps/nextjs/src/components/workshop/workshop-publish-definition.ts index c17740854..8ca1b34d0 100644 --- a/apps/nextjs/src/components/workshop/workshop-publish-definition.ts +++ b/apps/nextjs/src/components/workshop/workshop-publish-definition.ts @@ -1,5 +1,5 @@ interface PublishableWorkshopDefinition { - sources: Record; + sources: Record; } export const serializeWorkshopDefinition = (definition: unknown): string => { @@ -29,5 +29,5 @@ export async function publishWorkshopDefinition({ export const getPrivateWorkshopSourceNames = (definition: PublishableWorkshopDefinition | undefined) => Object.entries(definition?.sources ?? {}).flatMap(([sourceId, source]) => - source.networkScope === "public" ? [] : [source.name ?? sourceId], + source.networkScope === undefined || source.networkScope === "public" ? [] : [source.name ?? sourceId], ); diff --git a/packages/api/src/custom-widget-configuration.ts b/packages/api/src/custom-widget-configuration.ts index 7e527dc99..3aec1a3eb 100644 --- a/packages/api/src/custom-widget-configuration.ts +++ b/packages/api/src/custom-widget-configuration.ts @@ -5,3 +5,5 @@ export { serializeCustomWidgetDefinition, } from "./router/custom-widget/stored-definition"; export { configureCustomWidgetSourceFromRequest } from "./router/custom-widget/secret-persistence"; + +export { assertCustomWidgetIntegrationBindings } from "./router/custom-widget/source-resolver"; diff --git a/packages/api/src/router/custom-widget/creation-procedures.ts b/packages/api/src/router/custom-widget/creation-procedures.ts index c962d9f7a..f116a5353 100644 --- a/packages/api/src/router/custom-widget/creation-procedures.ts +++ b/packages/api/src/router/custom-widget/creation-procedures.ts @@ -1,3 +1,4 @@ +import { assertCustomWidgetIntegrationBindings } from "./source-resolver"; import { TRPCError } from "@trpc/server"; import { z } from "zod/v4"; @@ -44,6 +45,7 @@ export const creationProcedures = { .mutation(async ({ ctx, input }) => { const { secrets, ...candidate } = input; const definition = parseCustomWidgetAuthoringInput(() => normalizeCustomWidgetAuthoringDefinition(candidate)); + await assertCustomWidgetIntegrationBindings(ctx, definition.sources); assertSecretSources(definition.sources, secrets); const id = await insertCustomWidgetDefinition(ctx.db, definition, ctx.session.user.id, secrets); logger.info("Created custom widget definition", { id, name: definition.name }); @@ -115,6 +117,7 @@ export const creationProcedures = { const secrets = Object.keys(session.sources).flatMap((sourceId) => getPreviewSessionSecrets(session, sourceId).map((secret) => ({ sourceId, ...secret })), ); + await assertCustomWidgetIntegrationBindings(ctx, definition.sources); assertSecretSources(definition.sources, secrets); const id = await insertCustomWidgetDefinition(ctx.db, definition, ctx.session.user.id, secrets); logger.info("Created custom widget definition from tested preview", { diff --git a/packages/api/src/router/custom-widget/custom-widget-router.ts b/packages/api/src/router/custom-widget/custom-widget-router.ts index 10ba02d28..8c5efcd6e 100644 --- a/packages/api/src/router/custom-widget/custom-widget-router.ts +++ b/packages/api/src/router/custom-widget/custom-widget-router.ts @@ -1,3 +1,5 @@ +import { assertCustomWidgetIntegrationBindings } from "./source-resolver"; +import { getCustomWidgetSourceAuthType } from "@homarr/custom-widgets/core"; import { TRPCError } from "@trpc/server"; import { z } from "zod/v4"; @@ -60,6 +62,7 @@ export const customWidgetRouter = createTRPCRouter({ const changes = normalizeCustomWidgetAuthoringUpdate(authoringChanges); return customWidgetDefinitionSchema.parse({ ...current, ...changes }); }); + await assertCustomWidgetIntegrationBindings(ctx, definition.sources); if (secrets) assertSecretSources(definition.sources, secrets); const definitionChanges = { ...serializeCustomWidgetDefinition(definition), updatedAt: new Date() }; const secretRows = secrets?.map((secret) => ({ @@ -106,7 +109,7 @@ export const customWidgetRouter = createTRPCRouter({ } for (const [sourceId, source] of Object.entries(definition.sources)) { - const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)]; + const kinds = [...requiredSecretKinds(getCustomWidgetSourceAuthType(source))]; const where = and( eq(schema.customWidgetSecrets.definitionId, id), eq(schema.customWidgetSecrets.sourceId, sourceId), @@ -151,7 +154,7 @@ export const customWidgetRouter = createTRPCRouter({ } for (const [sourceId, source] of Object.entries(definition.sources)) { - const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)]; + const kinds = [...requiredSecretKinds(getCustomWidgetSourceAuthType(source))]; const where = and(eq(customWidgetSecrets.definitionId, id), eq(customWidgetSecrets.sourceId, sourceId)); transaction .delete(customWidgetSecrets) diff --git a/packages/api/src/router/custom-widget/management-queries.ts b/packages/api/src/router/custom-widget/management-queries.ts index 7b41c1eac..c4f066b02 100644 --- a/packages/api/src/router/custom-widget/management-queries.ts +++ b/packages/api/src/router/custom-widget/management-queries.ts @@ -1,3 +1,4 @@ +import { resolveCustomWidgetSource } from "./source-resolver"; import { TRPCError } from "@trpc/server"; import { z } from "zod/v4"; @@ -153,31 +154,19 @@ export const managementQueryProcedures = { }); try { const values = resolveOptionRequestValues(request, input.params); - const authType = typeof source.auth === "string" ? source.auth : source.auth.type; + const connection = await resolveCustomWidgetSource(ctx, source, request, () => + stored.secrets + .filter((secret) => secret.sourceId === request.source) + .map((secret) => ({ kind: secret.kind, value: decryptSecret(secret.encryptedValue) })), + ); const response = await executeCustomWidgetRequest({ - baseUrl: source.baseUrl, - targetUrl: renderRequestTarget(source.baseUrl, request, values), + ...connection, + targetUrl: renderRequestTarget(connection.baseUrl, request, values), method: request.method, body: renderRequestBody(request, values), staticHeaders: request.headers, - auth: - request.auth === "none" || authType === "none" - ? undefined - : { - type: authType, - secrets: stored.secrets - .filter((secret) => secret.sourceId === request.source) - .map((secret) => ({ kind: secret.kind, value: decryptSecret(secret.encryptedValue) })), - headerName: - typeof source.auth === "object" && source.auth.type === "apiKeyHeader" - ? source.auth.name - : typeof source.auth === "object" && source.auth.type === "apiKeyQuery" - ? source.auth.name - : undefined, - }, - networkScope: source.networkScope, kind: "query", - cacheKey: `custom-widget:options:${input.definitionId}:${getCustomWidgetCacheVersion(stored)}:${request.id}:${hashRuntimeParams(values)}`, + cacheKey: `custom-widget:options:${input.definitionId}:${getCustomWidgetCacheVersion(stored)}:${request.id}:${hashRuntimeParams(values)}:${connection.cacheVersion}`, cacheTtlSeconds: request.cacheSeconds ?? 30, }); return { diff --git a/packages/api/src/router/custom-widget/management-query-mappers.ts b/packages/api/src/router/custom-widget/management-query-mappers.ts index 90cd96f44..eabb03fd0 100644 --- a/packages/api/src/router/custom-widget/management-query-mappers.ts +++ b/packages/api/src/router/custom-widget/management-query-mappers.ts @@ -1,3 +1,4 @@ +import { getCustomWidgetSourceAuthType } from "@homarr/custom-widgets/core"; import type { InferSelectModel } from "drizzle-orm"; import { createLogger } from "@homarr/core/infrastructure/logs"; @@ -50,12 +51,12 @@ export function mapCustomWidgetListItem(definition: StoredDefinition & { secrets name: widget.name, description: widget.description, iconUrl: widget.iconUrl, - sources: Object.entries(widget.sources).map(([id, { name, baseUrl, networkScope, auth }]) => ({ + sources: Object.entries(widget.sources).map(([id, source]) => ({ id, - name, - origin: new URL(baseUrl).origin, - networkScope, - authType: typeof auth === "string" ? auth : auth.type, + name: source.name, + origin: source.type === "integration" ? source.integrationKind : new URL(source.baseUrl).origin, + networkScope: source.networkScope, + authType: source.type === "integration" ? "integration" : getCustomWidgetSourceAuthType(source), })), requestCount: Object.keys(widget.requests).length, missingSecrets: getCustomWidgetSecretRequirements(widget.sources).filter( @@ -110,11 +111,11 @@ export function mapAvailableCustomWidget(definition: StoredDefinition) { options: widget.options, defaultOptions: getCustomWidgetDefaultOptions(widget.options), template: widget.template, - sources: Object.entries(widget.sources).map(([id, { name, networkScope, auth }]) => ({ + sources: Object.entries(widget.sources).map(([id, source]) => ({ id, - name, - networkScope, - authType: typeof auth === "string" ? auth : auth.type, + name: source.name, + networkScope: source.networkScope, + authType: source.type === "integration" ? "integration" : getCustomWidgetSourceAuthType(source), })), requestCapabilities: Object.entries(widget.requests).map( ([id, { kind, method, trigger, permission, confirmation, invalidates }]) => ({ diff --git a/packages/api/src/router/custom-widget/preview-action-procedures.ts b/packages/api/src/router/custom-widget/preview-action-procedures.ts index 8da3e728b..5cebc78da 100644 --- a/packages/api/src/router/custom-widget/preview-action-procedures.ts +++ b/packages/api/src/router/custom-widget/preview-action-procedures.ts @@ -29,10 +29,10 @@ export const previewActionProcedures = { if (definition?.kind !== "action") throw new TRPCError({ code: "NOT_FOUND", message: "Preview action was not found" }); const request = { id: input.requestId, ...definition }; - const resolved = getPreviewRequestSource(session, request.source); + const resolved = await getPreviewRequestSource(ctx, session, request); if (!resolved) throw new TRPCError({ code: "NOT_FOUND", message: "Preview source was not found" }); const params = resolveCustomWidgetRequestValues(request, session.options, input.params); - const targetUrl = renderRequestTarget(resolved.source.baseUrl, request, params); + const targetUrl = renderRequestTarget(resolved.baseUrl, request, params); const body = renderRequestBody(request, params); if (!session.liveActions) { await recordPreviewJournal(session, { @@ -66,13 +66,12 @@ export const previewActionProcedures = { const startedAt = Date.now(); try { const response = await executeCustomWidgetRequest({ - baseUrl: resolved.source.baseUrl, + ...resolved, targetUrl, method: request.method, body, staticHeaders: request.headers, auth: request.auth === "none" ? undefined : resolved.auth, - networkScope: resolved.source.networkScope, kind: "action", }); if (response.ok && request.invalidates?.length) { 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 84c172aa3..af15b6cfc 100644 --- a/packages/api/src/router/custom-widget/preview-base-procedures.ts +++ b/packages/api/src/router/custom-widget/preview-base-procedures.ts @@ -1,3 +1,5 @@ +import { assertCustomWidgetIntegrationBindings } from "./source-resolver"; +import { getCustomWidgetSourceAuthType } from "@homarr/custom-widgets/core"; import { TRPCError } from "@trpc/server"; import { z } from "zod/v4"; @@ -92,6 +94,7 @@ const previewCreateProcedure = permissionRequiredProcedure const definition = parseCustomWidgetAuthoringInput(() => normalizeCustomWidgetAuthoringDefinition(input.definition), ); + await assertCustomWidgetIntegrationBindings(ctx, definition.sources); const options = input.options ?? getCustomWidgetDefaultOptions(definition.options); const optionIssues = validateCustomWidgetOptions(definition.options, options); if (optionIssues.length > 0) { @@ -115,7 +118,7 @@ const previewCreateProcedure = permissionRequiredProcedure const hasStoredSecrets = existing.secrets.some((secret) => secret.sourceId === sourceId); if (!submittedSource || !hasStoredSecrets || hasSameSecretBinding(existingSource, submittedSource)) continue; - const authType = typeof submittedSource.auth === "string" ? submittedSource.auth : submittedSource.auth.type; + const authType = getCustomWidgetSourceAuthType(submittedSource); const missingReplacement = requiredSecretKinds(authType).some( (kind) => !secrets.some((secret) => secret.sourceId === sourceId && secret.kind === kind), ); @@ -142,7 +145,7 @@ const previewCreateProcedure = permissionRequiredProcedure const invalid = secrets.find((secret) => { const source = definition.sources[secret.sourceId]; - const authType = typeof source?.auth === "string" ? source.auth : source?.auth.type; + const authType = getCustomWidgetSourceAuthType(source); const kinds = authType === "basic" ? ["username", "password"] diff --git a/packages/api/src/router/custom-widget/preview-procedure-helpers.ts b/packages/api/src/router/custom-widget/preview-procedure-helpers.ts index 498b58374..8d83f2459 100644 --- a/packages/api/src/router/custom-widget/preview-procedure-helpers.ts +++ b/packages/api/src/router/custom-widget/preview-procedure-helpers.ts @@ -1,3 +1,4 @@ +import { resolveCustomWidgetSource } from "./source-resolver"; import { createLogger } from "@homarr/core/infrastructure/logs"; import type { CustomJsxRequest } from "@homarr/custom-widgets/core"; import { z } from "zod/v4"; @@ -33,25 +34,15 @@ export const recordPreviewJournal = async (...args: Parameters { - const source = session.sources[sourceId]; +export async function getPreviewRequestSource( + ctx: Parameters[0], + session: CustomWidgetPreviewSession, + request: CustomJsxRequest, +) { + const source = session.sources[request.source]; if (!source) return null; - const authType = typeof source.auth === "string" ? source.auth : source.auth.type; - const auth = - authType === "none" - ? undefined - : { - type: authType, - secrets: getPreviewSessionSecrets(session, sourceId), - headerName: - typeof source.auth === "object" && source.auth.type === "apiKeyHeader" - ? source.auth.name - : typeof source.auth === "object" && source.auth.type === "apiKeyQuery" - ? source.auth.name - : undefined, - }; - return { source, auth }; -}; + return resolveCustomWidgetSource(ctx, source, request, () => getPreviewSessionSecrets(session, request.source)); +} export function resolvePreviewRequestParams( request: CustomJsxRequest, 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 6b71e0368..9d0b6700d 100644 --- a/packages/api/src/router/custom-widget/preview-query-procedures.ts +++ b/packages/api/src/router/custom-widget/preview-query-procedures.ts @@ -38,10 +38,10 @@ export const previewQueryProcedures = { if (definition?.kind !== "query") throw new TRPCError({ code: "NOT_FOUND", message: "Preview query was not found" }); const request = { id: input.requestId, ...definition }; - const resolved = getPreviewRequestSource(session, request.source); + const resolved = await getPreviewRequestSource(ctx, session, request); if (!resolved) throw new TRPCError({ code: "NOT_FOUND", message: "Preview source was not found" }); const params = resolvePreviewRequestParams(request, session.options, input.params); - const targetUrl = renderRequestTarget(resolved.source.baseUrl, request, params); + const targetUrl = renderRequestTarget(resolved.baseUrl, request, params); const body = renderRequestBody(request, params); const release = await acquireCustomWidgetRequestLimit({ category: "query", @@ -52,15 +52,14 @@ export const previewQueryProcedures = { const startedAt = Date.now(); try { const response = await executeCustomWidgetRequest({ - baseUrl: resolved.source.baseUrl, + ...resolved, targetUrl, method: request.method, body, staticHeaders: request.headers, auth: request.auth === "none" ? undefined : resolved.auth, - networkScope: resolved.source.networkScope, kind: "query", - cacheKey: `custom-jsx:preview:${session.id}:${request.id}:${hashRuntimeParams(params)}`, + cacheKey: `custom-jsx:preview:${session.id}:${request.id}:${hashRuntimeParams(params)}:${resolved.cacheVersion}`, cacheTtlSeconds: request.cacheSeconds, }); await recordPreviewJournal(session, { diff --git a/packages/api/src/router/custom-widget/secret-persistence.ts b/packages/api/src/router/custom-widget/secret-persistence.ts index 73f570510..64c617361 100644 --- a/packages/api/src/router/custom-widget/secret-persistence.ts +++ b/packages/api/src/router/custom-widget/secret-persistence.ts @@ -15,7 +15,8 @@ type CustomWidgetSecretInput = CustomWidgetCreateInput["secrets"][number]; type ConfigureCustomWidgetSourceInput = { definitionId: string; sourceId: string; - baseUrl: string; + baseUrl?: string; + integrationId?: string; networkScope?: CustomWidgetSource["networkScope"]; secrets: CustomWidgetCreateInput["secrets"]; expectedSource?: CustomWidgetSource; @@ -220,11 +221,17 @@ function prepareSourceUpdate( if (input.expectedSource && !hasSameCustomWidgetSourceAuthentication(previousSource, input.expectedSource)) { throw new SourceConfigurationError("binding-changed"); } - const source = customWidgetSourceSchema.parse({ - ...previousSource, - baseUrl: input.baseUrl, - networkScope: input.networkScope ?? previousSource.networkScope, - }); + let configuration: unknown; + if (previousSource.type === "integration") { + configuration = { ...previousSource, integrationId: input.integrationId }; + } else { + configuration = { + ...previousSource, + baseUrl: input.baseUrl, + networkScope: input.networkScope ?? previousSource.networkScope, + }; + } + const source = customWidgetSourceSchema.parse(configuration); assertSecretSources({ [input.sourceId]: source }, input.secrets); return { source, diff --git a/packages/api/src/router/custom-widget/secret-policy.ts b/packages/api/src/router/custom-widget/secret-policy.ts index d6ed86e6b..11a781d1c 100644 --- a/packages/api/src/router/custom-widget/secret-policy.ts +++ b/packages/api/src/router/custom-widget/secret-policy.ts @@ -16,6 +16,7 @@ export function requiredSecretKinds(authType: string) { * origin, while the network scope and auth destination are part of the binding. */ export function hasSameSecretBinding(left: CustomWidgetSource, right: CustomWidgetSource) { + if (left.type === "integration" || right.type === "integration") return false; return ( new URL(left.baseUrl).origin === new URL(right.baseUrl).origin && left.networkScope === right.networkScope && @@ -24,12 +25,12 @@ export function hasSameSecretBinding(left: CustomWidgetSource, right: CustomWidg } export function assertSecretSources( - sources: Record, + sources: Record, secrets: readonly { sourceId: string; kind: "apiKey" | "username" | "password" }[], ) { const invalid = secrets.find((secret) => { const source = sources[secret.sourceId]; - const authType = typeof source?.auth === "string" ? source.auth : source?.auth.type; + const authType = typeof source?.auth === "string" ? source.auth : source?.auth?.type; return !source || !authType || !new Set(requiredSecretKinds(authType)).has(secret.kind); }); if (invalid) { diff --git a/packages/api/src/router/custom-widget/secret-procedures.ts b/packages/api/src/router/custom-widget/secret-procedures.ts index 8e0e6db58..42f3df2e4 100644 --- a/packages/api/src/router/custom-widget/secret-procedures.ts +++ b/packages/api/src/router/custom-widget/secret-procedures.ts @@ -1,3 +1,5 @@ +import { assertCustomWidgetIntegrationBindings } from "./source-resolver"; +import { getCustomWidgetSourceAuthType } from "@homarr/custom-widgets/core"; import { TRPCError } from "@trpc/server"; import { z } from "zod/v4"; @@ -58,12 +60,19 @@ export const secretProcedures = { sourceConfigure: permissionRequiredProcedure .requiresPermission("admin") - .meta({ mcp: { enabled: true, description: "Configure one custom widget API source and its credentials." } }) + .meta({ + mcp: { + enabled: true, + description: + "Configure one custom widget source. For an integration source, provide integrationId from integration_all (matching integrationKind); omit URL and secrets. Requires administrator permission.", + }, + }) .input( z.object({ definitionId: z.string(), sourceId: z.string(), - baseUrl: z.string(), + baseUrl: z.string().optional(), + integrationId: z.string().min(1).max(100).optional(), networkScope: z.enum(["public", "private", "loopback"]).optional(), secrets: customWidgetSecretsInputSchema.default([]), }), @@ -76,22 +85,34 @@ export const secretProcedures = { const definition = parseStoredCustomWidgetDefinition(stored); const current = definition.sources[input.sourceId]; if (!current) throw new TRPCError({ code: "NOT_FOUND", message: "Widget source not found" }); - const source = customWidgetSourceSchema.parse({ - ...current, - baseUrl: input.baseUrl, - networkScope: input.networkScope ?? current.networkScope, - }); + let configuration: unknown; + if (current.type === "integration") { + if ( + !input.integrationId || + input.baseUrl !== undefined || + input.networkScope !== undefined || + input.secrets.length > 0 + ) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Integration sources require only an integrationId" }); + } + configuration = { ...current, integrationId: input.integrationId }; + } else { + if (input.integrationId) + throw new TRPCError({ code: "BAD_REQUEST", message: "HTTP sources cannot bind an integration" }); + configuration = { + ...current, + baseUrl: input.baseUrl, + networkScope: input.networkScope ?? current.networkScope, + }; + } + const source = customWidgetSourceSchema.parse(configuration); + await assertCustomWidgetIntegrationBindings(ctx, { [input.sourceId]: source }); assertSecretSources({ [input.sourceId]: source }, input.secrets); - const configuredSource = await configureCustomWidgetSource(ctx.db, { - definitionId: input.definitionId, - sourceId: input.sourceId, - baseUrl: source.baseUrl, - networkScope: input.networkScope, - secrets: input.secrets, - }); + const configuredSource = await configureCustomWidgetSource(ctx.db, { ...input, expectedSource: current }); return { definitionId: input.definitionId, sourceId: input.sourceId, + integrationId: configuredSource.integrationId, baseUrl: configuredSource.baseUrl, networkScope: configuredSource.networkScope, configuredSecrets: input.secrets.map(({ kind }) => kind), @@ -110,7 +131,7 @@ export const secretProcedures = { } let widgetName: string; - let source: { id: string; name: string; auth: string | { type: string }; value: CustomWidgetSource } | undefined; + let source: { id: string; name: string; auth: CustomWidgetSource["auth"]; value: CustomWidgetSource } | undefined; let target: { type: "definition"; id: string } | { type: "preview"; id: string }; if (input.definitionId) { const stored = await ctx.db.query.customWidgetDefinitions.findFirst({ @@ -144,7 +165,7 @@ export const secretProcedures = { target = { type: "preview", id: preview.id }; } if (!source) throw new TRPCError({ code: "NOT_FOUND", message: "Widget source not found" }); - const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)]; + const kinds = [...requiredSecretKinds(getCustomWidgetSourceAuthType(source))]; const request = await createCustomWidgetConfigurationRequest({ userId: ctx.session.user.id, target, diff --git a/packages/api/src/router/custom-widget/source-resolver.ts b/packages/api/src/router/custom-widget/source-resolver.ts new file mode 100644 index 000000000..c81c297f7 --- /dev/null +++ b/packages/api/src/router/custom-widget/source-resolver.ts @@ -0,0 +1,106 @@ +import { createHash } from "node:crypto"; +import { TRPCError } from "@trpc/server"; + +import type { Session } from "@homarr/auth"; +import { decryptSecret } from "@homarr/common/server"; +import { + getAllTrustedCertificatesAsync, + getTrustedCertificateHostnamesAsync, +} from "@homarr/core/infrastructure/certificates"; +import { createCustomCheckServerIdentity } from "@homarr/core/infrastructure/http"; +import type { Database } from "@homarr/db"; +import { eq } from "@homarr/db"; +import { integrations } from "@homarr/db/schema"; +import type { CustomJsxRequest, CustomWidgetSource } from "@homarr/custom-widgets/core"; +import { getCustomWidgetSourceAuthType } from "@homarr/custom-widgets/core"; +import type { CustomWidgetHttpRequest } from "@homarr/custom-widgets/server"; +import { getCustomWidgetIntegrationConnection } from "@homarr/integrations/custom-widget-source"; + +import { throwIfActionForbiddenAsync } from "../integration/integration-access"; + +interface SourceContext { + db: Database; + session: Session | null; +} + +export async function assertCustomWidgetIntegrationBindings( + ctx: SourceContext, + sources: Record, +) { + for (const source of Object.values(sources)) { + if (source.type !== "integration" || !source.integrationId) continue; + await resolveIntegration(ctx, source, "full"); + } +} + +async function resolveIntegration( + ctx: SourceContext, + source: Extract, + permission: "use" | "interact" | "full", +) { + if (!source.integrationId) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Select an existing integration for this widget source", + }); + } + await throwIfActionForbiddenAsync(ctx, eq(integrations.id, source.integrationId), permission); + const integration = await ctx.db.query.integrations.findFirst({ + where: eq(integrations.id, source.integrationId), + with: { secrets: true }, + }); + if (!integration || integration.kind !== source.integrationKind) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "The selected integration is unavailable or has a different type", + }); + } + return integration; +} + +export async function resolveCustomWidgetSource( + ctx: SourceContext, + source: CustomWidgetSource, + request: Pick, + getSecrets: () => Array<{ kind: string; value: string }>, +): Promise< + Pick & { cacheVersion: string } +> { + if (source.type !== "integration") { + const authType = getCustomWidgetSourceAuthType(source); + let auth: CustomWidgetHttpRequest["auth"]; + if (request.auth !== "none" && authType !== "none") { + auth = { type: authType, secrets: getSecrets() }; + if (typeof source.auth === "object") auth.headerName = source.auth.name; + } + return { baseUrl: source.baseUrl, networkScope: source.networkScope, auth, cacheVersion: "" }; + } + let permission = "use" as "use" | "interact" | "full"; + if (request.kind === "action" || request.method !== "GET") permission = "interact"; + if (request.method === "DELETE") permission = "full"; + const integration = await resolveIntegration(ctx, source, permission); + const connection = getCustomWidgetIntegrationConnection({ + ...integration, + externalUrl: null, + decryptedSecrets: integration.secrets.map(({ kind, value }) => ({ kind, value: decryptSecret(value) })), + }); + let tls: CustomWidgetHttpRequest["tls"]; + if (new URL(connection.baseUrl).protocol === "https:") { + const [ca, hostnames] = await Promise.all([ + getAllTrustedCertificatesAsync(), + getTrustedCertificateHostnamesAsync(), + ]); + tls = { ca, checkServerIdentity: createCustomCheckServerIdentity(hostnames) }; + } + return { + ...connection, + auth: request.auth === "none" ? undefined : connection.auth, + networkScope: "loopback", + pathPrefix: new URL(connection.baseUrl).pathname, + tls, + cacheVersion: createHash("sha256") + .update(JSON.stringify([integration.id, integration.kind, integration.url, integration.secrets])) + .digest("hex") + .slice(0, 16), + }; +} diff --git a/packages/api/src/router/custom-widget/transfer-procedures.ts b/packages/api/src/router/custom-widget/transfer-procedures.ts index 7b295d639..fbc5e15a4 100644 --- a/packages/api/src/router/custom-widget/transfer-procedures.ts +++ b/packages/api/src/router/custom-widget/transfer-procedures.ts @@ -1,3 +1,5 @@ +import { exportCustomWidgetDefinition } from "@homarr/custom-widgets/core"; +import { assertCustomWidgetIntegrationBindings } from "./source-resolver"; import { TRPCError } from "@trpc/server"; import { parse as parseSuperJson } from "superjson"; import { z } from "zod/v4"; @@ -24,7 +26,7 @@ export const transferProcedures = { where: eq(customWidgetDefinitions.id, input.id), }); if (!definition) throw new TRPCError({ code: "NOT_FOUND" }); - return parseStoredCustomWidgetDefinition(definition); + return exportCustomWidgetDefinition(parseStoredCustomWidgetDefinition(definition)); }), exportLegacy: permissionRequiredProcedure @@ -57,6 +59,7 @@ export const transferProcedures = { .requiresPermission("admin") .input(z.object({ widget: customWidgetImportSchema, secrets: customWidgetSecretsInputSchema.default([]) })) .mutation(async ({ ctx, input }) => { + await assertCustomWidgetIntegrationBindings(ctx, input.widget.sources); assertSecretSources(input.widget.sources, input.secrets); const id = await insertCustomWidgetDefinition(ctx.db, input.widget, ctx.session.user.id, input.secrets); logger.info("Imported custom widget definition", { id, name: input.widget.name }); @@ -80,6 +83,7 @@ export const transferProcedures = { }), ) .mutation(async ({ ctx, input }) => { + await assertCustomWidgetIntegrationBindings(ctx, input.widget.sources); assertSecretSources(input.widget.sources, input.secrets); const [legacy, current] = await Promise.all([ ctx.db.query.legacyCustomWidgetDefinitions.findFirst({ @@ -157,7 +161,7 @@ function canPreserveLegacySecrets( widget: z.infer, ) { const source = widget.sources.default; - if (!source || getAuthType(source.auth) !== legacy.authType) return false; + if (!source || source.type === "integration" || getAuthType(source.auth) !== legacy.authType) return false; if (getOrigin(source.baseUrl) !== getOrigin(legacy.url)) return false; const headerName = typeof source.auth === "object" && "name" in source.auth ? source.auth.name : undefined; return headerName === (legacy.headerName ?? undefined); diff --git a/packages/api/src/router/custom-widget/workshop-procedures.ts b/packages/api/src/router/custom-widget/workshop-procedures.ts index aa5e3ee61..4c0f8a88b 100644 --- a/packages/api/src/router/custom-widget/workshop-procedures.ts +++ b/packages/api/src/router/custom-widget/workshop-procedures.ts @@ -1,3 +1,5 @@ +import { exportCustomWidgetDefinition } from "@homarr/custom-widgets/core"; +import { assertCustomWidgetIntegrationBindings } from "./source-resolver"; import { TRPCError } from "@trpc/server"; import { z } from "zod/v4"; @@ -56,7 +58,7 @@ async function getWorkshopWidget(submissionId: string) { if (!validation.success) { throw new TRPCError({ code: "BAD_REQUEST", message: validation.error }); } - return { submission, widget: validation.data }; + return { submission, widget: exportCustomWidgetDefinition(validation.data) }; } catch (error) { if (error instanceof TRPCError) throw error; throwWorkshopUnavailable("Workshop widget lookup failed", "workshop_widget_lookup_failed"); @@ -72,7 +74,8 @@ const workshopSearchInputSchema = z.object({ const sourceOverridesSchema = z.record( customWidgetIdentifierSchema, z.strictObject({ - baseUrl: z.string(), + baseUrl: z.string().optional(), + integrationId: z.string().min(1).max(100).optional(), networkScope: z.enum(["public", "private", "loopback"]).optional(), }), ); @@ -165,12 +168,14 @@ export const workshopProcedures = { sourceId, { baseUrl: source.baseUrl, + integrationId: source.integrationId, networkScope: source.networkScope ?? widget.sources[sourceId]?.networkScope ?? "public", }, ]), ), ), }); + await assertCustomWidgetIntegrationBindings(ctx, configured.sources); assertSecretSources(configured.sources, input.secrets); const id = await insertCustomWidgetDefinition(ctx.db, configured, ctx.session.user.id, input.secrets); const configuredSecrets = new Set(input.secrets.map((secret) => `${secret.sourceId}:${secret.kind}`)); diff --git a/packages/api/src/router/test/custom-widget/definition-persistence.spec.ts b/packages/api/src/router/test/custom-widget/definition-persistence.spec.ts index eb5e56c3a..24cd83803 100644 --- a/packages/api/src/router/test/custom-widget/definition-persistence.spec.ts +++ b/packages/api/src/router/test/custom-widget/definition-persistence.spec.ts @@ -42,7 +42,8 @@ if (!jellyfin) throw new Error("Jellyfin bundled widget is missing"); const pokedex = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-pokedex")?.widget; if (!pokedex) throw new Error("Pokédex bundled widget is missing"); const jellyfinDefaultSource = jellyfin.sources.default; -if (!jellyfinDefaultSource) throw new Error("Jellyfin default source is missing"); +if (!jellyfinDefaultSource || jellyfinDefaultSource.type === "integration") + throw new Error("Jellyfin default source is missing"); function createCaller(db: ReturnType) { return customWidgetRouter.createCaller({ db, deviceType: undefined, session }); @@ -181,7 +182,7 @@ describe("custom widget definition persistence", () => { } const defaultSource = previewSession.sources.default; - if (!defaultSource) throw new Error("Pokédex preview source is missing"); + if (!defaultSource || defaultSource.type === "integration") throw new Error("Pokédex preview source is missing"); await configurePreviewSessionSource(previewSession.id, userId, "default", { ...defaultSource }, []); await expect(caller.createFromPreview(createInput)).rejects.toThrow("Test every final preview query successfully"); @@ -675,7 +676,7 @@ describe("custom widget definition persistence", () => { const created = await createCaller(db).create({ ...jellyfin, secrets: [secret] }); const definition = customWidgetDefinitionSchema.parse(jellyfin); const defaultSource = definition.sources.default; - if (!defaultSource) throw new Error("Jellyfin default source is missing"); + if (!defaultSource || defaultSource.type === "integration") throw new Error("Jellyfin default source is missing"); await db .update(customWidgetDefinitions) .set( @@ -720,7 +721,7 @@ describe("custom widget definition persistence", () => { const created = await createCaller(db).create({ ...jellyfin, secrets: [] }); const definition = customWidgetDefinitionSchema.parse(jellyfin); const expectedSource = definition.sources.default; - if (!expectedSource || typeof expectedSource.auth === "string") { + if (!expectedSource || expectedSource.type === "integration" || typeof expectedSource.auth === "string") { throw new Error("Jellyfin default source must use header authentication"); } await db diff --git a/packages/api/src/router/widgets/custom-api.ts b/packages/api/src/router/widgets/custom-api.ts index 71f39b711..118083841 100644 --- a/packages/api/src/router/widgets/custom-api.ts +++ b/packages/api/src/router/widgets/custom-api.ts @@ -1,3 +1,4 @@ +import { resolveCustomWidgetSource } from "../custom-widget/source-resolver"; import { TRPCError } from "@trpc/server"; import { parse as parseSuperJson } from "superjson"; import { z } from "zod/v4"; @@ -13,7 +14,7 @@ import { normalizeCustomWidgetOptions, validateCustomWidgetOptions, } from "@homarr/custom-widgets/core"; -import type { CustomJsxRequest, CustomWidgetSource } from "@homarr/custom-widgets/core"; +import type { CustomJsxRequest } from "@homarr/custom-widgets/core"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; import { throwIfActionForbiddenAsync } from "../board/board-access"; @@ -121,25 +122,6 @@ const findSource = (resolved: ResolvedDefinition, sourceId: string) => { }; type IdentifiedRequest = CustomJsxRequest & { id: string }; -type IdentifiedSource = CustomWidgetSource & { id: string }; - -const getAuth = (resolved: ResolvedDefinition, source: IdentifiedSource, mode: "inherit" | "none") => { - const authType = typeof source.auth === "string" ? source.auth : source.auth.type; - if (mode === "none" || authType === "none") return undefined; - const secrets = resolved.stored.secrets - .filter((secret) => secret.sourceId === source.id) - .map((secret) => ({ kind: secret.kind, value: decryptSecret(secret.encryptedValue) })); - return { - type: authType, - secrets, - headerName: - typeof source.auth === "object" && source.auth.type === "apiKeyHeader" - ? source.auth.name - : typeof source.auth === "object" && source.auth.type === "apiKeyQuery" - ? source.auth.name - : undefined, - }; -}; const withRequestLimit = async ( ctx: RouterContext, @@ -172,21 +154,26 @@ const executeRequest = async ( await throwIfActionForbiddenAsync(ctx, eq(boards.id, resolved.item.boardId), request.permission as BoardPermission); const source = findSource(resolved, request.source); const values = resolveCustomWidgetRequestValues(request, resolved.configuration, params); - const targetUrl = renderRequestTarget(source.baseUrl, request, values); - return withRequestLimit(ctx, resolved, request, () => + const connection = await resolveCustomWidgetSource(ctx, source, request, () => + resolved.stored.secrets + .filter((secret) => secret.sourceId === source.id) + .map((secret) => ({ kind: secret.kind, value: decryptSecret(secret.encryptedValue) })), + ); + const targetUrl = renderRequestTarget(connection.baseUrl, request, values); + const response = await withRequestLimit(ctx, resolved, request, () => executeCustomWidgetRequest({ - baseUrl: source.baseUrl, + ...connection, targetUrl, method: request.method, body: renderRequestBody(request, values), staticHeaders: request.headers, - auth: getAuth(resolved, source, request.auth), - networkScope: source.networkScope, kind: request.kind, - cacheKey: request.kind === "query" ? getCacheKey(resolved, request, values) : undefined, + cacheKey: + request.kind === "query" ? `${getCacheKey(resolved, request, values)}:${connection.cacheVersion}` : undefined, cacheTtlSeconds: request.cacheSeconds, }), ); + return { ...response, connectionCacheVersion: connection.cacheVersion }; }; export const customApiRouter = createTRPCRouter({ @@ -210,6 +197,7 @@ export const customApiRouter = createTRPCRouter({ requestId, { data: response.data, + connectionCacheVersion: response.connectionCacheVersion, status: { loading: false, ok: response.ok, @@ -224,6 +212,7 @@ export const customApiRouter = createTRPCRouter({ requestId, { data: null, + connectionCacheVersion: "", status: { loading: false, ok: false, @@ -239,7 +228,7 @@ export const customApiRouter = createTRPCRouter({ return { type: "customJsx" as const, template: resolved.definition.template, - queryCacheKey: `${getCustomWidgetCacheVersion(resolved.stored)}:${hashRuntimeParams(resolved.configuration)}`, + queryCacheKey: `${getCustomWidgetCacheVersion(resolved.stored)}:${hashRuntimeParams(resolved.configuration)}:${entries.map(([, result]) => result.connectionCacheVersion).join(":")}`, data: Object.fromEntries(entries.map(([id, result]) => [id, result.data])), status: Object.fromEntries(entries.map(([id, result]) => [id, result.status])), options: resolved.configuration, diff --git a/packages/custom-widgets/scripts/ai-evaluation.ts b/packages/custom-widgets/scripts/ai-evaluation.ts index 8d6781bf4..f0c07b9ef 100644 --- a/packages/custom-widgets/scripts/ai-evaluation.ts +++ b/packages/custom-widgets/scripts/ai-evaluation.ts @@ -1,3 +1,4 @@ +import { getCustomWidgetSourceAuthType } from "../src/core/request-schema"; import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; @@ -279,7 +280,7 @@ export function getDeterministicEvaluationIssues( message: `Use the verified source URL ${expectations.sourceBaseUrl}.`, }); } - const authType = typeof source?.auth === "string" ? source.auth : source?.auth.type; + const authType = getCustomWidgetSourceAuthType(source); if (authType !== expectations.sourceAuth) { issues.push({ path: ["sources", "default", "auth"], @@ -467,7 +468,7 @@ const getJudgeRuntimeContext = (widget: HomarrCustomWidgetV2) => { const sections = [getCustomWidgetSkillReference("runtime").content]; const hasMutation = Object.values(widget.requests).some((request) => request.kind === "action"); const hasProtectedSource = Object.values(widget.sources).some((source) => { - const authType = typeof source.auth === "string" ? source.auth : source.auth.type; + const authType = getCustomWidgetSourceAuthType(source); return authType !== "none"; }); if (hasMutation || hasProtectedSource) sections.push(getCustomWidgetSkillReference("security").content); diff --git a/packages/custom-widgets/src/core/ai-prompt.ts b/packages/custom-widgets/src/core/ai-prompt.ts index e120bc68c..e85686103 100644 --- a/packages/custom-widgets/src/core/ai-prompt.ts +++ b/packages/custom-widgets/src/core/ai-prompt.ts @@ -72,6 +72,8 @@ ${leanShape} Sources are keyed by name and must include "default". Auth is "none", "bearer", "basic", {"type":"apiKeyHeader","name":"X-Api-Key"}, or {"type":"apiKeyQuery","name":"api_key"}. Use the stable public API URL for public services and a clear suggested URL for self-hosted services; Homarr asks the installer for their own server URL. Never put credentials in the manifest. +Existing Sonarr and Radarr integrations use {"type":"integration","integrationKind":"sonarr"} as a source, without baseUrl, networkScope, or auth. The installer selects their local integration. An MCP author can use integration_all to obtain a matching integrationId for preview/create. Integration URLs and credentials resolve server-side; exports remove integrationId. Integration methods other than GET must be manual actions. + Requests are keyed by ID. Defaults are source "default", kind "query", method "GET", query trigger "load", inherited auth, and permission "view" for queries or "modify" for actions. Actions are always manual. DELETE is valid only for actions, requires full permission, and receives confirmation automatically. Use {option:name} or {"$option":"name"} for saved options. Use {param:name} or {"$param":"name"} only for invocation-time params supplied by SubFetch, ActionButton, or ToggleSwitch. Load queries cannot use params. Values and primitive types are inferred from references; do not declare parameters or option bindings. Paths and query values must be primitive; JSON bodies may bind structured options. Options are keyed by name. Every option has label, control, and default. Controls: text, textarea, number, switch, select, multiSelect, slider, date, time, color, icon, url, duration, timeZone, json. Select choices use \`"choices": [{"label":"...","value":"..."}]\`. Dynamic choices must use \`"choicesFrom": {"request":"requestId","itemsPath":"optional.path","valuePath":"id","labelPath":"name"}\`, never an object under \`choices\`. Options are configured outside the widget and read through \`options.name\`; a bound control writes only to \`inputs.name\` and never changes an option. diff --git a/packages/custom-widgets/src/core/authoring-resources.ts b/packages/custom-widgets/src/core/authoring-resources.ts index e0658c010..4d9f6312a 100644 --- a/packages/custom-widgets/src/core/authoring-resources.ts +++ b/packages/custom-widgets/src/core/authoring-resources.ts @@ -13,7 +13,7 @@ export const CUSTOM_WIDGET_SKILL_SOURCE_URL = "https://github.com/homarr-labs/homarr/tree/HEAD/.agents/skills/homarr-custom-widget"; export const CUSTOM_WIDGET_SKILL_INSTALL_COMMAND = "npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget"; -export const CUSTOM_WIDGET_SKILL_VERSION = "2.9.0"; +export const CUSTOM_WIDGET_SKILL_VERSION = "2.10.0"; export const CUSTOM_WIDGET_SKILL_REFERENCE_NAMES = ["schema", "runtime", "security"] as const; export type CustomWidgetSkillReferenceName = (typeof CUSTOM_WIDGET_SKILL_REFERENCE_NAMES)[number]; @@ -58,7 +58,7 @@ interface HomarrCustomWidgetV2 { } \`\`\` -The object key \`default\` is the required source ID; \`default\` is not a property on a source. Source properties are \`name?\`, \`baseUrl\`, \`networkScope\`, and \`auth?\`: +\`sources.default\` is required. HTTP source properties: \`name?\`, \`baseUrl\`, \`networkScope\`, and \`auth?\`: \`\`\`json { @@ -85,14 +85,17 @@ The object key \`default\` is the required source ID; \`default\` is not a prope } \`\`\` -Auth is \`none\`, \`bearer\`, \`basic\`, \`{ "type": "apiKeyHeader", "name": "X-Api-Key" }\`, or \`{ "type": "apiKeyQuery", "name": "api_key" }\`. A request defaults to source \`default\`, kind \`query\`, method \`GET\`, trigger \`load\`, inherited auth, and view permission. Set \`trigger: "manual"\` for a parameterized query. An action defaults to manual and modify permission. DELETE uses full permission and confirmation. Do not use \`load: false\`. +Integration sources use \`{"type":"integration","integrationKind":"sonarr"}\` (Sonarr or Radarr). Bind a local \`integrationId\` from \`integration_all\`; exports omit it. Omit URL/auth fields. Paths append to the integration URL; non-GET requests must be actions. -Use stable real URLs for public APIs and clear suggested URLs for self-hosted services. Homarr collects the installer's server URL, network scope, and credentials as source setup; credentials remain outside the manifest. +Auth: \`none\`, \`bearer\`, \`basic\`, \`{ "type": "apiKeyHeader", "name": "X-Api-Key" }\`, or \`{ "type": "apiKeyQuery", "name": "api_key" }\`. Requests default to source \`default\`, kind \`query\`, method \`GET\`, trigger \`load\`, inherited auth, and view permission. Parameterized queries need \`trigger: "manual"\`. Actions default to manual/modify; DELETE requires full permission and confirmation. Do not use \`load: false\`. -Paths use \`{option:name}\` and \`{param:name}\`; query/body references use \`{ "$option": "name" }\` and \`{ "$param": "name" }\`. Constants stay primitive (\`take: 10\`); \`$param\` is only for manual helpers, never load queries. Names and types are inferred. +HTTP sources declare public URLs or self-hosted suggestions; installers configure their URL, network scope, and credentials separately. -Every option has \`label\`, \`control\`, and \`default\`. Optional fields are \`description\`, \`choices\`, \`choicesFrom\`, \`min\`, \`max\`, \`step\`, \`advanced\`, and \`group\`. +Paths bind \`{option:name}\` and \`{param:name}\`; query/body references bind \`{ "$option": "name" }\` and \`{ "$param": "name" }\`. Use primitive constants (\`take: 10\`). \`$param\` is only for manual requests. + +Options require \`label\`, \`control\`, and \`default\`. Optional fields: \`description\`, \`choices\`, \`choicesFrom\`, \`min\`, \`max\`, \`step\`, \`advanced\`, \`group\`. `, + "references/runtime.md": `# Runtime Templates read \`data.requestId\`, \`status.requestId\`, \`options.name\`, and temporary \`inputs.name\`. Status is \`{ loading, ok, status, statusText, error }\`. Render load queries directly from \`data\` and \`status\` with \`RefreshButton\`; never wrap them in \`SubFetch\`. diff --git a/packages/custom-widgets/src/core/custom-jsx-schema.ts b/packages/custom-widgets/src/core/custom-jsx-schema.ts index d386dfd1f..b34f89187 100644 --- a/packages/custom-widgets/src/core/custom-jsx-schema.ts +++ b/packages/custom-widgets/src/core/custom-jsx-schema.ts @@ -104,6 +104,17 @@ type Definition = z.infer; function validateRequests(definition: Definition, ctx: z.RefinementCtx) { for (const [requestId, request] of Object.entries(definition.requests)) { + if ( + definition.sources[request.source]?.type === "integration" && + request.method !== "GET" && + request.kind !== "action" + ) { + ctx.addIssue({ + code: "custom", + path: ["requests", requestId, "kind"], + message: "Integration requests with a method other than GET must be manual actions", + }); + } if (!definition.sources[request.source]) { ctx.addIssue({ code: "custom", diff --git a/packages/custom-widgets/src/core/import.ts b/packages/custom-widgets/src/core/import.ts index 0de898264..a4f62f754 100644 --- a/packages/custom-widgets/src/core/import.ts +++ b/packages/custom-widgets/src/core/import.ts @@ -1,3 +1,4 @@ +import { getCustomWidgetSourceAuthType } from "./request-schema"; import type { HomarrCustomWidgetV2 } from "./custom-jsx-schema"; import { customWidgetImportSchema } from "./custom-jsx-schema"; @@ -104,7 +105,7 @@ export function parseCustomWidgetClipboardDetailed(text: string): CustomWidgetPa if (removedIssues.length > 0) return { success: false, issues: removedIssues }; const parsed = customWidgetImportSchema.safeParse(widget); if (!parsed.success) return { success: false, issues: zodIssues(parsed.error) }; - return { success: true, widget: parsed.data, warnings: [] }; + return { success: true, widget: exportCustomWidgetDefinition(parsed.data), warnings: [] }; } export function parseCustomWidgetAiResponse(text: string): CustomWidgetParseResult { @@ -157,17 +158,35 @@ export function getImportReview(value: unknown): ImportReview | null { const widget = parsed.data; return { name: widget.name, - origins: Object.values(widget.sources).map((source) => new URL(source.baseUrl).origin), + origins: Object.values(widget.sources).map((source) => + source.type === "integration" ? source.integrationKind : new URL(source.baseUrl).origin, + ), authTypes: [ ...new Set( Object.values(widget.sources).map((source) => - typeof source.auth === "string" ? source.auth : source.auth.type, + source.type === "integration" ? "integration" : getCustomWidgetSourceAuthType(source), ), ), ], - networkScopes: [...new Set(Object.values(widget.sources).map((source) => source.networkScope))], + networkScopes: [ + ...new Set(Object.values(widget.sources).flatMap((source) => (source.networkScope ? [source.networkScope] : []))), + ], methods: [...new Set(Object.values(widget.requests).map((request) => request.method))], permissions: [...new Set(Object.values(widget.requests).map((request) => request.permission))], hasActions: Object.values(widget.requests).some((request) => request.kind === "action"), }; } + +/** Deployment bindings never travel with an exported or published widget. */ +export function exportCustomWidgetDefinition(widget: HomarrCustomWidgetV2): HomarrCustomWidgetV2 { + return { + ...widget, + sources: Object.fromEntries( + Object.entries(widget.sources).map(([id, source]) => { + if (source.type !== "integration") return [id, source]; + const { integrationId: _localBinding, ...portable } = source; + return [id, portable]; + }), + ), + }; +} diff --git a/packages/custom-widgets/src/core/request-schema.ts b/packages/custom-widgets/src/core/request-schema.ts index 7149dcc47..3d9a363db 100644 --- a/packages/custom-widgets/src/core/request-schema.ts +++ b/packages/custom-widgets/src/core/request-schema.ts @@ -41,7 +41,10 @@ const sourceUrlIssueMessages: Record = { queryOrFragment: "API source URLs cannot contain a query string or fragment", }; -export const customWidgetSourceSchema = z.strictObject({ +export const customWidgetHttpSourceSchema = z.strictObject({ + type: z.literal("http").optional(), + integrationKind: z.never().optional(), + integrationId: z.never().optional(), name: z.string().trim().min(1).max(128).optional(), baseUrl: z .string() @@ -54,7 +57,26 @@ export const customWidgetSourceSchema = z.strictObject({ networkScope: z.enum(customJsxNetworkScopes), auth: authSchema.default("none"), }); +export const customWidgetIntegrationKinds = ["sonarr", "radarr"] as const; +export const customWidgetIntegrationSourceSchema = z.strictObject({ + type: z.literal("integration"), + name: z.string().trim().min(1).max(128).optional(), + integrationKind: z.enum(customWidgetIntegrationKinds), + integrationId: z.string().min(1).max(100).optional(), + baseUrl: z.never().optional(), + networkScope: z.never().optional(), + auth: z.never().optional(), +}); +export const customWidgetSourceSchema = z.union([customWidgetHttpSourceSchema, customWidgetIntegrationSourceSchema]); export type CustomWidgetSource = z.infer; +export type CustomWidgetHttpSource = z.infer; +export type CustomWidgetIntegrationSource = z.infer; + +export function getCustomWidgetSourceAuthType(source: { auth?: CustomWidgetHttpSource["auth"] } | undefined) { + if (!source?.auth) return "none" as const; + if (typeof source.auth === "string") return source.auth; + return source.auth.type; +} export const customWidgetSourcesSchema = z .record(customWidgetIdentifierSchema, customWidgetSourceSchema) diff --git a/packages/custom-widgets/src/core/secret-requirements.ts b/packages/custom-widgets/src/core/secret-requirements.ts index bf40808fc..1439efe22 100644 --- a/packages/custom-widgets/src/core/secret-requirements.ts +++ b/packages/custom-widgets/src/core/secret-requirements.ts @@ -1,3 +1,4 @@ +import { getCustomWidgetSourceAuthType } from "./request-schema"; import type { CustomWidgetSource } from "./request-schema"; import type { CustomWidgetSecretKind } from "./schema-types"; @@ -21,7 +22,7 @@ export function getCustomWidgetSecretRequirements( sources: Record, ): CustomWidgetSecretRequirement[] { return Object.entries(sources).flatMap(([sourceId, source]) => { - const authType = typeof source.auth === "string" ? source.auth : source.auth.type; + const authType = getCustomWidgetSourceAuthType(source); return getCustomWidgetRequiredSecretKinds(authType).map((kind) => ({ sourceId, sourceName: source.name ?? sourceId, diff --git a/packages/custom-widgets/src/core/source-setup.ts b/packages/custom-widgets/src/core/source-setup.ts index a468ca7ba..0de1ae52d 100644 --- a/packages/custom-widgets/src/core/source-setup.ts +++ b/packages/custom-widgets/src/core/source-setup.ts @@ -1,4 +1,5 @@ -import type { CustomWidgetSource } from "./request-schema"; +import { getCustomWidgetSourceAuthType } from "./request-schema"; +import type { CustomJsxNetworkScope, CustomWidgetSource } from "./request-schema"; import { getCustomWidgetSourceUrlIssue } from "./request-schema"; import type { CustomWidgetSecretKind } from "./schema-types"; import { getCustomWidgetSecretRequirements } from "./secret-requirements"; @@ -15,7 +16,9 @@ export interface CustomWidgetSourceSetup { sourceName: string; suggestedBaseUrl: string; baseUrl: string; - networkScope: CustomWidgetSource["networkScope"]; + networkScope: CustomJsxNetworkScope; + integrationKind?: string; + integrationId?: string; authType: "none" | "bearer" | "basic" | "apiKeyHeader" | "apiKeyQuery"; credentialFields: Array<{ kind: CustomWidgetSecretKind; @@ -40,10 +43,18 @@ export function isCustomWidgetSourceUrlPlaceholder(baseUrl: string) { } export function customWidgetSourceRequiresUrlConfirmation(source: CustomWidgetSource) { + if (source.type === "integration") return !source.integrationId; return source.networkScope !== "public" || isCustomWidgetSourceUrlPlaceholder(source.baseUrl); } export function hasSameCustomWidgetSourceAuthentication(left: CustomWidgetSource, right: CustomWidgetSource) { + if (left.type === "integration" || right.type === "integration") { + return ( + left.type === right.type && + left.integrationKind === right.integrationKind && + left.integrationId === right.integrationId + ); + } if (typeof left.auth === "string" || typeof right.auth === "string") return left.auth === right.auth; return left.auth.type === right.auth.type && left.auth.name === right.auth.name; } @@ -54,13 +65,15 @@ export function getCustomWidgetSourceSetups( ): CustomWidgetSourceSetup[] { const requirements = getCustomWidgetSecretRequirements(sources); return Object.entries(sources).map(([sourceId, source]) => { - const authType = typeof source.auth === "string" ? source.auth : source.auth.type; + const authType = getCustomWidgetSourceAuthType(source); return { sourceId, sourceName: source.name ?? sourceId, - suggestedBaseUrl: source.baseUrl, - baseUrl: source.baseUrl, - networkScope: source.networkScope, + suggestedBaseUrl: source.baseUrl ?? "", + baseUrl: source.baseUrl ?? "", + networkScope: source.networkScope ?? "private", + integrationKind: source.integrationKind, + integrationId: source.integrationId, authType, credentialFields: requirements .filter((requirement) => requirement.sourceId === sourceId) @@ -81,12 +94,23 @@ export function getCustomWidgetSourceSetups( export function applyCustomWidgetSourceSetup( sources: Record, - setup: Record>, + setup: Record, ) { return Object.fromEntries( Object.entries(sources).map(([sourceId, source]) => { const configured = setup[sourceId]; - return [sourceId, configured ? { ...source, ...configured } : source]; + if (!configured) return [sourceId, source]; + if (source.type === "integration") { + return [sourceId, { ...source, integrationId: configured.integrationId }]; + } + return [ + sourceId, + { + ...source, + baseUrl: configured.baseUrl ?? source.baseUrl, + networkScope: configured.networkScope ?? source.networkScope, + }, + ]; }), ); } diff --git a/packages/custom-widgets/src/server/network-policy.ts b/packages/custom-widgets/src/server/network-policy.ts index 39064a7f5..0994ac7b4 100644 --- a/packages/custom-widgets/src/server/network-policy.ts +++ b/packages/custom-widgets/src/server/network-policy.ts @@ -1,4 +1,5 @@ import { lookup } from "node:dns/promises"; +import type { ConnectionOptions } from "node:tls"; import { BlockList, isIP } from "node:net"; import type { LookupFunction } from "node:net"; import { Agent } from "undici"; @@ -191,7 +192,11 @@ export function assertSafeStaticHeaders(headers: Record | undefi } } -export function createPinnedAgent(addresses: ResolvedAddress[], timeoutMs: number) { +export function createPinnedAgent( + addresses: ResolvedAddress[], + timeoutMs: number, + tls?: Pick, +) { const customLookup: LookupFunction = (_hostname, options, callback) => { const family = options.family === 4 || options.family === 6 ? options.family : undefined; const candidates = family ? addresses.filter((entry) => entry.family === family) : addresses; @@ -204,10 +209,42 @@ export function createPinnedAgent(addresses: ResolvedAddress[], timeoutMs: numbe else callback(null, selected.address, selected.family); }; return new Agent({ - connect: { lookup: customLookup }, + connect: { ...tls, lookup: customLookup }, connectTimeout: timeoutMs, headersTimeout: timeoutMs, bodyTimeout: timeoutMs, maxResponseSize: MAX_RESPONSE_BODY_BYTES, }); } + +export function assertCustomWidgetPathScope(url: URL, pathPrefix: string) { + const prefix = decodePath(pathPrefix).replace(/\/+$/u, ""); + const path = decodePath(url.pathname); + if ( + path.includes("\\") || + path.split("/").some((segment) => segment === "." || segment === "..") || + (prefix && path !== prefix && !path.startsWith(`${prefix}/`)) + ) { + throw new CustomWidgetDomainError({ + code: "FORBIDDEN", + message: "Request must remain within the selected integration URL", + }); + } +} + +function decodePath(value: string) { + let path = value; + for (let pass = 0; pass < 4; pass += 1) { + let decoded: string; + try { + decoded = decodeURIComponent(path); + } catch { + return path; + } + if (decoded === path) return path; + path = decoded; + } + if (/%[0-9a-f]{2}/iu.test(path)) + throw new CustomWidgetDomainError({ code: "FORBIDDEN", message: "Request path is excessively encoded" }); + return path; +} diff --git a/packages/custom-widgets/src/server/preview-session-template.ts b/packages/custom-widgets/src/server/preview-session-template.ts index e21859605..19af32064 100644 --- a/packages/custom-widgets/src/server/preview-session-template.ts +++ b/packages/custom-widgets/src/server/preview-session-template.ts @@ -1,3 +1,4 @@ +import { hasSameCustomWidgetSourceAuthentication } from "../core"; import { customWidgetDefinitionSchema } from "../core"; import type { CustomJsxRequest, CustomWidgetOptions, CustomWidgetSource } from "../core"; import { CustomWidgetDomainError } from "./errors"; @@ -36,3 +37,9 @@ export function validatePreviewTemplateRevision(current: PreviewTemplateContext, } return parsed.data.template; } + +export function canConfigurePreviewSource(current: CustomWidgetSource, source: CustomWidgetSource) { + if (current.type === "integration" && source.type === "integration") + return current.integrationKind === source.integrationKind; + return hasSameCustomWidgetSourceAuthentication(current, source); +} diff --git a/packages/custom-widgets/src/server/preview-sessions.ts b/packages/custom-widgets/src/server/preview-sessions.ts index 47a4c6536..68c2265cb 100644 --- a/packages/custom-widgets/src/server/preview-sessions.ts +++ b/packages/custom-widgets/src/server/preview-sessions.ts @@ -1,3 +1,4 @@ +import { canConfigurePreviewSource } from "./preview-session-template"; import { z } from "zod/v4"; import { @@ -5,7 +6,6 @@ import { customWidgetRequestsSchema, customWidgetSecretKinds, customWidgetSourcesSchema, - hasSameCustomWidgetSourceAuthentication, } from "../core"; import type { CustomJsxRequest, CustomWidgetOptions, CustomWidgetSource } from "../core"; import { CustomWidgetDomainError } from "./errors"; @@ -242,7 +242,7 @@ export class CustomWidgetPreviewSessionService { if (!currentSource) { throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview source was not found" }); } - if (!hasSameCustomWidgetSourceAuthentication(currentSource, source)) { + if (!canConfigurePreviewSource(currentSource, source)) { throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Preview source authentication changed; create a new configuration request", @@ -253,7 +253,7 @@ export class CustomWidgetPreviewSessionService { ...session, sources: { ...session.sources, - [sourceId]: { ...currentSource, baseUrl: source.baseUrl, networkScope: source.networkScope }, + [sourceId]: source, }, secrets: [ ...session.secrets.filter((secret) => !replacements.has(`${secret.sourceId}:${secret.kind}`)), diff --git a/packages/custom-widgets/src/server/request-executor.ts b/packages/custom-widgets/src/server/request-executor.ts index db7976761..da8629cf2 100644 --- a/packages/custom-widgets/src/server/request-executor.ts +++ b/packages/custom-widgets/src/server/request-executor.ts @@ -1,5 +1,6 @@ import { Buffer } from "node:buffer"; import { STATUS_CODES } from "node:http"; +import type { ConnectionOptions } from "node:tls"; import { Headers, Response } from "undici"; import type { CustomJsxNetworkScope, CustomWidgetMethod } from "../core"; @@ -7,6 +8,7 @@ import { applyAuth } from "./auth"; import { CustomWidgetDomainError } from "./errors"; import { assertSafeStaticHeaders, + assertCustomWidgetPathScope, createPinnedAgent, resolveAndValidateHost, resolveSameOriginTarget, @@ -44,6 +46,8 @@ export interface CustomWidgetAuthConfig { } export interface CustomWidgetHttpRequest { + tls?: Pick; + pathPrefix?: string; baseUrl: string; targetUrl?: string | URL; method: CustomWidgetMethod; @@ -110,9 +114,11 @@ async function performRequestWithinDeadline( let currentBody = input.body; const maxRedirects = input.kind === "query" ? MAX_QUERY_REDIRECTS : 0; for (let redirects = 0; ; redirects += 1) { + if (input.pathPrefix !== undefined) assertCustomWidgetPathScope(currentUrl, input.pathPrefix); const dispatcher = createPinnedAgent( await resolveAndValidateHost(currentUrl.hostname, input.networkScope, { signal: deadlineSignal }), REQUEST_TIMEOUT_MS, + input.tls, ); const headers = buildHeaders(input, currentUrl, currentBody); const controller = new AbortController(); diff --git a/packages/custom-widgets/src/test/authoring-resources.spec.ts b/packages/custom-widgets/src/test/authoring-resources.spec.ts index 3cdd619c4..569cd4df2 100644 --- a/packages/custom-widgets/src/test/authoring-resources.spec.ts +++ b/packages/custom-widgets/src/test/authoring-resources.spec.ts @@ -22,7 +22,7 @@ const skillSourceDirectory = path.resolve(import.meta.dirname, "../../../../.age describe("authoring resources", () => { it("returns the complete portable skill and keeps every bundled source file in sync", () => { const skill = getCustomWidgetSkill(); - expect(skill).toMatchObject({ name: "homarr-custom-widget", version: "2.9.0" }); + expect(skill).toMatchObject({ name: "homarr-custom-widget", version: "2.10.0" }); expect(skill.skillsShUrl).toContain("skills.sh/homarr-labs/homarr"); expect(skill.sourceUrl).toContain("/tree/HEAD/.agents/skills/homarr-custom-widget"); expect(skill.installCommand).toContain("--skill homarr-custom-widget"); diff --git a/packages/custom-widgets/src/workbench/source-setup.tsx b/packages/custom-widgets/src/workbench/source-setup.tsx index 6c2533a30..db69d0aca 100644 --- a/packages/custom-widgets/src/workbench/source-setup.tsx +++ b/packages/custom-widgets/src/workbench/source-setup.tsx @@ -1,9 +1,10 @@ +import type { ReactNode } from "react"; import { Alert, Badge, Checkbox, Group, PasswordInput, Select, Stack, Text, TextInput } from "@mantine/core"; import { IconCheck, IconKey, IconServer, IconX } from "@tabler/icons-react"; import type { CustomWidgetSecretKind, - CustomWidgetSource, + CustomJsxNetworkScope, CustomWidgetSourceSetup, CustomWidgetSourceUrlIssue, } from "../core"; @@ -11,7 +12,8 @@ import { getCustomWidgetSourceSetupIssue } from "../core"; export interface CustomWidgetSourceSetupValue { baseUrl: string; - networkScope: CustomWidgetSource["networkScope"]; + networkScope: CustomJsxNetworkScope; + integrationId?: string; urlConfirmed: boolean; secrets: Partial>; } @@ -38,6 +40,7 @@ export interface CustomWidgetSourceSetupPanelProps { values: Record; messages: CustomWidgetSourceSetupMessages; onChange(sourceId: string, value: CustomWidgetSourceSetupValue): void; + renderIntegrationSource?(setup: CustomWidgetSourceSetup, value: CustomWidgetSourceSetupValue): ReactNode; } export function createCustomWidgetSourceSetupValues( @@ -48,6 +51,7 @@ export function createCustomWidgetSourceSetupValues( setup.sourceId, { baseUrl: setup.baseUrl, + integrationId: setup.integrationId, networkScope: setup.networkScope, urlConfirmed: !setup.requiresUrlConfirmation, secrets: {} as Partial>, @@ -62,6 +66,7 @@ export function isCustomWidgetSourceSetupReady( ) { return setups.every((setup) => { const value = values[setup.sourceId]; + if (setup.integrationKind) return Boolean(value?.integrationId); return Boolean(value && !getCustomWidgetSourceSetupIssue(value) && value.urlConfirmed); }); } @@ -71,6 +76,7 @@ export function CustomWidgetSourceSetupPanel({ values, messages, onChange, + renderIntegrationSource, }: CustomWidgetSourceSetupPanelProps) { if (setups.length === 0) return null; return ( @@ -84,6 +90,14 @@ export function CustomWidgetSourceSetupPanel({ {setups.map((setup) => { const value = values[setup.sourceId] ?? createCustomWidgetSourceSetupValues([setup])[setup.sourceId]; if (!value) return null; + if (setup.integrationKind) { + return ( + + {setup.sourceName} + {renderIntegrationSource?.(setup, value)} + + ); + } const issue = getCustomWidgetSourceSetupIssue(value); const missingCredentials = setup.credentialFields.filter( (field) => !field.configured && !value.secrets[field.kind]?.trim(), @@ -143,7 +157,7 @@ export function CustomWidgetSourceSetupPanel({ networkScope && onChange(setup.sourceId, { ...value, - networkScope: networkScope as CustomWidgetSource["networkScope"], + networkScope: networkScope as CustomJsxNetworkScope, }) } /> diff --git a/packages/integrations/package.json b/packages/integrations/package.json index 37144edc8..c97455bd4 100644 --- a/packages/integrations/package.json +++ b/packages/integrations/package.json @@ -12,6 +12,7 @@ } }, "exports": { + "./custom-widget-source": "./src/custom-widget-source.ts", ".": "./index.ts", "./anchor": "./src/anchor/anchor-types.ts", "./downloads": "./src/interfaces/downloads/download-client-items.ts", diff --git a/packages/integrations/src/custom-widget-source.ts b/packages/integrations/src/custom-widget-source.ts new file mode 100644 index 000000000..130290e4e --- /dev/null +++ b/packages/integrations/src/custom-widget-source.ts @@ -0,0 +1,18 @@ +import type { IntegrationInput } from "./base/integration"; + +/** Only integrations with a supported arbitrary HTTP authentication contract belong here. */ +export function getCustomWidgetIntegrationConnection(integration: IntegrationInput) { + switch (integration.kind) { + case "sonarr": + case "radarr": { + const apiKey = integration.decryptedSecrets.find((secret) => secret.kind === "apiKey"); + if (!apiKey?.value) throw new Error("Integration API key is not configured"); + return { + baseUrl: integration.url, + auth: { type: "apiKeyHeader", headerName: "X-Api-Key", secrets: [apiKey] }, + }; + } + default: + throw new Error("Integration does not support custom widget requests"); + } +} diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 41d48d9a5..770016fb6 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1016,7 +1016,15 @@ "clear": "Clear", "remove": "Remove source and its requests", "advanced": "Advanced", - "add": "Add another API source" + "add": "Add another API source", + "sourceType": "Source", + "manualSource": "HTTP API", + "existingIntegration": "Existing integration", + "integrationType": "Integration type", + "selectIntegration": "Configured integration", + "noIntegrations": "No matching integrations available", + "integrationUnavailable": "The selected integration is no longer available. Select another integration.", + "integrationDescription": "Uses the saved integration URL and credentials. Changes to that integration apply automatically." }, "ai": { "title": "Create or fix with any AI", diff --git a/packages/workshop/src/schema.ts b/packages/workshop/src/schema.ts index d769594fd..7dbfcaef9 100644 --- a/packages/workshop/src/schema.ts +++ b/packages/workshop/src/schema.ts @@ -1,3 +1,4 @@ +import { exportCustomWidgetDefinition } from "@homarr/custom-widgets/core"; import { CUSTOM_WIDGET_SCHEMA, customWidgetImportSchema } from "@homarr/custom-widgets/core"; import { z } from "zod/v4"; @@ -257,7 +258,7 @@ export function validateWorkshopWidget(content: string): WorkshopWidgetValidatio .join("\n"); return { success: false, error: error || "Invalid widget" }; } - return { success: true, data: result.data }; + return { success: true, data: exportCustomWidgetDefinition(result.data) }; } catch { return { success: false, error: "Widget content is not valid JSON" }; }