diff --git a/.agents/skills/homarr-custom-widget/references/schema.md b/.agents/skills/homarr-custom-widget/references/schema.md index ff28b9608..ded1fee2b 100644 --- a/.agents/skills/homarr-custom-widget/references/schema.md +++ b/.agents/skills/homarr-custom-widget/references/schema.md @@ -40,7 +40,7 @@ interface HomarrCustomWidgetV2 { } ``` -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. +Integration sources use `{"type":"integration","integrationKind":"sonarr"}`. Bind `integrationId` from `integration_all`; exports omit it. Calls require full integration access. Omit URL/auth fields. Paths append to the integration URL; non-GET requests must be actions. 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`. 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 d70d7fddb..aed3c2c7e 100644 --- a/apps/docs/docs/management/custom-widgets/requests-and-security.mdx +++ b/apps/docs/docs/management/custom-widgets/requests-and-security.mdx @@ -9,7 +9,7 @@ request to a different origin. ## Existing integrations -Sonarr and Radarr sources can reuse a configured integration: +Sources can reuse configured Sonarr, Radarr, Lidarr, Readarr, Prowlarr, Overseerr, Jellyseerr, Seerr, Immich, slskd, Home Assistant, Coolify, Audiobookshelf, Speedtest Tracker, AdGuard Home, and Nextcloud integrations: ```json { @@ -23,9 +23,7 @@ paths append to the saved URL, including reverse-proxy subpaths. Changing the in 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. +Arbitrary integration requests require integration `full` access, including GET requests: service APIs can expose credentials or change state through reads. Board permissions still apply separately. Saved credentials are redacted from upstream responses. Non-GET integration requests must be actions. Requests and redirects stay within the configured URL path. ## Named requests diff --git a/packages/api/src/router/custom-widget/source-resolver.ts b/packages/api/src/router/custom-widget/source-resolver.ts index c81c297f7..ff79edc7a 100644 --- a/packages/api/src/router/custom-widget/source-resolver.ts +++ b/packages/api/src/router/custom-widget/source-resolver.ts @@ -1,42 +1,33 @@ -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 { customWidgetIntegrationKinds, getCustomWidgetSourceAuthType } from "@homarr/custom-widgets/core"; import type { CustomWidgetHttpRequest } from "@homarr/custom-widgets/server"; -import { getCustomWidgetIntegrationConnection } from "@homarr/integrations/custom-widget-source"; +import type { integrationHttpAuth } from "@homarr/integrations/http-auth"; -import { throwIfActionForbiddenAsync } from "../integration/integration-access"; +import type { IntegrationHttpContext } from "../integration/integration-http"; +import { + getIntegrationForHttpRequest, + getIntegrationHttpCacheVersion, + getIntegrationHttpConnection, +} from "../integration/integration-http"; -interface SourceContext { - db: Database; - session: Session | null; -} +// Keep the portable schema's supported types within the server authentication adapters. +customWidgetIntegrationKinds satisfies readonly (keyof typeof integrationHttpAuth)[]; export async function assertCustomWidgetIntegrationBindings( - ctx: SourceContext, + ctx: IntegrationHttpContext, sources: Record, ) { for (const source of Object.values(sources)) { if (source.type !== "integration" || !source.integrationId) continue; - await resolveIntegration(ctx, source, "full"); + await resolveIntegration(ctx, source); } } async function resolveIntegration( - ctx: SourceContext, + ctx: IntegrationHttpContext, source: Extract, - permission: "use" | "interact" | "full", ) { if (!source.integrationId) { throw new TRPCError({ @@ -44,11 +35,7 @@ async function resolveIntegration( 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 }, - }); + const integration = await getIntegrationForHttpRequest(ctx, source.integrationId); if (!integration || integration.kind !== source.integrationKind) { throw new TRPCError({ code: "PRECONDITION_FAILED", @@ -59,12 +46,14 @@ async function resolveIntegration( } export async function resolveCustomWidgetSource( - ctx: SourceContext, + ctx: IntegrationHttpContext, source: CustomWidgetSource, request: Pick, getSecrets: () => Array<{ kind: string; value: string }>, ): Promise< - Pick & { cacheVersion: string } + Pick & { + cacheVersion: string; + } > { if (source.type !== "integration") { const authType = getCustomWidgetSourceAuthType(source); @@ -75,32 +64,26 @@ export async function resolveCustomWidgetSource( } 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) }; - } + const integration = await resolveIntegration(ctx, source); + const connection = await getIntegrationHttpConnection(integration); 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), }; } + +export async function getCustomWidgetIntegrationCacheVersions( + ctx: IntegrationHttpContext, + sources: Record, +) { + return Promise.all( + Object.entries(sources).flatMap(([sourceId, source]) => { + if (source.type !== "integration") return []; + return [ + resolveIntegration(ctx, source) + .then((integration) => `${sourceId}:${getIntegrationHttpCacheVersion(integration)}`) + .catch(() => `${sourceId}:unavailable`), + ]; + }), + ); +} diff --git a/packages/api/src/router/integration/integration-http.ts b/packages/api/src/router/integration/integration-http.ts new file mode 100644 index 000000000..02517da78 --- /dev/null +++ b/packages/api/src/router/integration/integration-http.ts @@ -0,0 +1,84 @@ +import { createHash } from "node:crypto"; +import { TRPCError } from "@trpc/server"; + +import type { Session } from "@homarr/auth"; +import { constructIntegrationPermissions } from "@homarr/auth/shared"; +import { decryptSecret } from "@homarr/common/server"; +import { + getAllTrustedCertificatesAsync, + getTrustedCertificateHostnamesAsync, +} from "@homarr/core/infrastructure/certificates"; +import { createCustomCheckServerIdentity } from "@homarr/core/infrastructure/http"; +import type { CustomWidgetHttpRequest } from "@homarr/custom-widgets/server"; +import type { Database } from "@homarr/db"; +import { eq, inArray } from "@homarr/db"; +import { groupMembers, integrations, integrationGroupPermissions, integrationUserPermissions } from "@homarr/db/schema"; +import { IntegrationHttpAuthError, resolveIntegrationHttpAuth } from "@homarr/integrations/http-auth"; + +export interface IntegrationHttpContext { + db: Database; + session: Session | null; +} + +export async function getIntegrationForHttpRequest(ctx: IntegrationHttpContext, integrationId: string) { + const userId = ctx.session?.user.id ?? ""; + const groups = await ctx.db.query.groupMembers.findMany({ where: eq(groupMembers.userId, userId) }); + const integration = await ctx.db.query.integrations.findFirst({ + where: eq(integrations.id, integrationId), + with: { + secrets: true, + userPermissions: { where: eq(integrationUserPermissions.userId, userId) }, + groupPermissions: { + where: inArray(integrationGroupPermissions.groupId, groups.map(({ groupId }) => groupId).concat("")), + }, + }, + }); + // An arbitrary read can expose service credentials or perform a mutation. Native + // integration use/interact permissions authorize only Homarr's bounded operations. + if (!integration || !constructIntegrationPermissions(integration, ctx.session).hasFullAccess) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Arbitrary integration requests require full integration access", + }); + } + return integration; +} + +type HttpIntegration = Awaited>; + +export function getIntegrationHttpCacheVersion(integration: HttpIntegration) { + return createHash("sha256") + .update(JSON.stringify([integration.id, integration.kind, integration.url, integration.secrets])) + .digest("hex") + .slice(0, 16); +} + +export async function getIntegrationHttpConnection(integration: HttpIntegration) { + try { + const secrets = integration.secrets.map(({ kind, value }) => ({ kind, value: decryptSecret(value) })); + const auth = resolveIntegrationHttpAuth(integration.kind, secrets); + const url = new URL(integration.url); + let tls: CustomWidgetHttpRequest["tls"]; + if (url.protocol === "https:") { + const [ca, hostnames] = await Promise.all([ + getAllTrustedCertificatesAsync(), + getTrustedCertificateHostnamesAsync(), + ]); + tls = { ca, checkServerIdentity: createCustomCheckServerIdentity(hostnames) }; + } + return { + baseUrl: integration.url, + auth, + tls, + networkScope: "loopback" as const, + pathPrefix: url.pathname, + redactSecrets: secrets, + cacheVersion: getIntegrationHttpCacheVersion(integration), + }; + } catch (error) { + if (error instanceof IntegrationHttpAuthError) { + throw new TRPCError({ code: error.code, message: error.message }); + } + throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Integration connection could not be resolved" }); + } +} diff --git a/packages/api/src/router/widgets/custom-api.ts b/packages/api/src/router/widgets/custom-api.ts index 118083841..72eb1f369 100644 --- a/packages/api/src/router/widgets/custom-api.ts +++ b/packages/api/src/router/widgets/custom-api.ts @@ -1,4 +1,4 @@ -import { resolveCustomWidgetSource } from "../custom-widget/source-resolver"; +import { getCustomWidgetIntegrationCacheVersions, resolveCustomWidgetSource } from "../custom-widget/source-resolver"; import { TRPCError } from "@trpc/server"; import { parse as parseSuperJson } from "superjson"; import { z } from "zod/v4"; @@ -173,7 +173,7 @@ const executeRequest = async ( cacheTtlSeconds: request.cacheSeconds, }), ); - return { ...response, connectionCacheVersion: connection.cacheVersion }; + return response; }; export const customApiRouter = createTRPCRouter({ @@ -186,6 +186,7 @@ export const customApiRouter = createTRPCRouter({ getData: publicProcedure.input(itemInputSchema).query(async ({ ctx, input }) => { const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId); + const integrationVersions = await getCustomWidgetIntegrationCacheVersions(ctx, resolved.definition.sources); const loadRequests = Object.entries(resolved.definition.requests).filter( ([, request]) => request.kind === "query" && request.trigger === "load", ); @@ -197,7 +198,6 @@ export const customApiRouter = createTRPCRouter({ requestId, { data: response.data, - connectionCacheVersion: response.connectionCacheVersion, status: { loading: false, ok: response.ok, @@ -212,7 +212,6 @@ export const customApiRouter = createTRPCRouter({ requestId, { data: null, - connectionCacheVersion: "", status: { loading: false, ok: false, @@ -228,7 +227,7 @@ export const customApiRouter = createTRPCRouter({ return { type: "customJsx" as const, template: resolved.definition.template, - queryCacheKey: `${getCustomWidgetCacheVersion(resolved.stored)}:${hashRuntimeParams(resolved.configuration)}:${entries.map(([, result]) => result.connectionCacheVersion).join(":")}`, + queryCacheKey: `${getCustomWidgetCacheVersion(resolved.stored)}:${hashRuntimeParams(resolved.configuration)}:${integrationVersions.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/src/core/ai-prompt.ts b/packages/custom-widgets/src/core/ai-prompt.ts index e85686103..bb3a1db27 100644 --- a/packages/custom-widgets/src/core/ai-prompt.ts +++ b/packages/custom-widgets/src/core/ai-prompt.ts @@ -72,7 +72,7 @@ ${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. +Supported existing 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. All integration calls require full integration access. 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. diff --git a/packages/custom-widgets/src/core/authoring-resources.ts b/packages/custom-widgets/src/core/authoring-resources.ts index 4d9f6312a..7e23c5448 100644 --- a/packages/custom-widgets/src/core/authoring-resources.ts +++ b/packages/custom-widgets/src/core/authoring-resources.ts @@ -85,7 +85,7 @@ interface HomarrCustomWidgetV2 { } \`\`\` -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. +Integration sources use \`{"type":"integration","integrationKind":"sonarr"}\`. Bind \`integrationId\` from \`integration_all\`; exports omit it. Calls require full integration access. Omit URL/auth fields. Paths append to the integration URL; non-GET requests must be actions. 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\`. diff --git a/packages/custom-widgets/src/core/request-schema.ts b/packages/custom-widgets/src/core/request-schema.ts index 3d9a363db..69efe65b7 100644 --- a/packages/custom-widgets/src/core/request-schema.ts +++ b/packages/custom-widgets/src/core/request-schema.ts @@ -57,7 +57,24 @@ export const customWidgetHttpSourceSchema = z.strictObject({ networkScope: z.enum(customJsxNetworkScopes), auth: authSchema.default("none"), }); -export const customWidgetIntegrationKinds = ["sonarr", "radarr"] as const; +export const customWidgetIntegrationKinds = [ + "sonarr", + "radarr", + "lidarr", + "readarr", + "prowlarr", + "overseerr", + "jellyseerr", + "seerr", + "immich", + "slskd", + "homeAssistant", + "coolify", + "audiobookshelf", + "speedtestTracker", + "adGuardHome", + "nextcloud", +] as const; export const customWidgetIntegrationSourceSchema = z.strictObject({ type: z.literal("integration"), name: z.string().trim().min(1).max(128).optional(), diff --git a/packages/custom-widgets/src/server/index.ts b/packages/custom-widgets/src/server/index.ts index 227aa28fa..3301b0cbf 100644 --- a/packages/custom-widgets/src/server/index.ts +++ b/packages/custom-widgets/src/server/index.ts @@ -39,3 +39,7 @@ export { resolveCustomWidgetRequestValues, } from "./request-manifest"; export type { CustomJsxRuntimeParams } from "./request-manifest"; + +export { redactResponseSecrets } from "./response-redaction"; + +export { assertCustomWidgetPathScope } from "./network-policy"; diff --git a/packages/custom-widgets/src/server/request-executor.ts b/packages/custom-widgets/src/server/request-executor.ts index da8629cf2..6bf511974 100644 --- a/packages/custom-widgets/src/server/request-executor.ts +++ b/packages/custom-widgets/src/server/request-executor.ts @@ -1,9 +1,10 @@ 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"; +import type { CustomWidgetMethod } from "../core"; +import type { CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "./request-types"; +export type { CustomWidgetAuthConfig, CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "./request-types"; import { applyAuth } from "./auth"; import { CustomWidgetDomainError } from "./errors"; import { @@ -16,6 +17,7 @@ import { } from "./network-policy"; import { closeDispatcher } from "./request-dispatcher-lifecycle"; import { parseResponseBody } from "./response"; +import { redactResponseSecrets } from "./response-redaction"; export { assertSafeStaticHeaders, @@ -39,34 +41,6 @@ export const MAX_REQUEST_DURATION_MS = 45_000; const TIMEOUT_ERROR_CODES = new Set(["UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]); const RESPONSE_TOO_LARGE_ERROR_CODE = "UND_ERR_RES_EXCEEDED_MAX_SIZE"; -export interface CustomWidgetAuthConfig { - type: string; - secrets: Array<{ kind: string; value: string }>; - headerName?: string | null; -} - -export interface CustomWidgetHttpRequest { - tls?: Pick; - pathPrefix?: string; - baseUrl: string; - targetUrl?: string | URL; - method: CustomWidgetMethod; - body?: string; - staticHeaders?: Record; - auth?: CustomWidgetAuthConfig; - networkScope: CustomJsxNetworkScope; - kind: "query" | "action"; - cacheKey?: string; - cacheTtlSeconds?: number; - logError?: (event: { origin: string; method: CustomWidgetMethod; errorName: string; reason?: "timeout" }) => void; -} - -export interface CustomWidgetHttpResponse { - ok: boolean; - status: number; - statusText: string; - data: unknown; -} const cache = new Map(); const inFlight = new Map>(); let cacheEpoch = 0; @@ -147,7 +121,7 @@ async function performRequestWithinDeadline( ok: response.ok, status: response.status, statusText: response.statusText, - data: await parseResponseBody(response), + data: redactResponseSecrets(await parseResponseBody(response), input.redactSecrets ?? []), }, }; } else { diff --git a/packages/custom-widgets/src/server/request-types.ts b/packages/custom-widgets/src/server/request-types.ts new file mode 100644 index 000000000..9332813de --- /dev/null +++ b/packages/custom-widgets/src/server/request-types.ts @@ -0,0 +1,33 @@ +import type { ConnectionOptions } from "node:tls"; + +import type { CustomJsxNetworkScope, CustomWidgetMethod } from "../core"; + +export interface CustomWidgetAuthConfig { + type: string; + secrets: Array<{ kind: string; value: string }>; + headerName?: string | null; +} + +export interface CustomWidgetHttpRequest { + tls?: Pick; + pathPrefix?: string; + redactSecrets?: CustomWidgetAuthConfig["secrets"]; + baseUrl: string; + targetUrl?: string | URL; + method: CustomWidgetMethod; + body?: string; + staticHeaders?: Record; + auth?: CustomWidgetAuthConfig; + networkScope: CustomJsxNetworkScope; + kind: "query" | "action"; + cacheKey?: string; + cacheTtlSeconds?: number; + logError?: (event: { origin: string; method: CustomWidgetMethod; errorName: string; reason?: "timeout" }) => void; +} + +export interface CustomWidgetHttpResponse { + ok: boolean; + status: number; + statusText: string; + data: unknown; +} diff --git a/packages/custom-widgets/src/server/response-redaction.ts b/packages/custom-widgets/src/server/response-redaction.ts new file mode 100644 index 000000000..90c4ece89 --- /dev/null +++ b/packages/custom-widgets/src/server/response-redaction.ts @@ -0,0 +1,37 @@ +import { Buffer } from "node:buffer"; + +export function redactResponseSecrets(data: unknown, secrets: Array<{ kind: string; value: string }>): unknown { + if (secrets.length === 0) return data; + const values = secrets.map(({ value }) => value).filter(Boolean); + const username = secrets.find(({ kind }) => kind === "username")?.value; + const password = secrets.find(({ kind }) => kind === "password")?.value; + if (username && password) values.push(`${username}:${password}`); + const sensitive = [ + ...new Set( + values.flatMap((value) => [ + value, + JSON.stringify(value).slice(1, -1), + encodeURIComponent(value), + Buffer.from(value).toString("base64"), + ]), + ), + ].toSorted((a, b) => b.length - a.length); + const redact = (value: string) => { + for (const secret of sensitive) value = value.replaceAll(secret, "[REDACTED]"); + return value; + }; + const visit = (value: unknown): unknown => { + if (typeof value === "string") return redact(value); + if ( + (typeof value === "number" || typeof value === "boolean" || value === null) && + redact(String(value)) !== String(value) + ) + return "[REDACTED]"; + if (Array.isArray(value)) return value.map(visit); + if (value !== null && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([key, child]) => [redact(key), visit(child)])); + } + return value; + }; + return visit(data); +} diff --git a/packages/integrations/package.json b/packages/integrations/package.json index c97455bd4..05f997907 100644 --- a/packages/integrations/package.json +++ b/packages/integrations/package.json @@ -12,7 +12,7 @@ } }, "exports": { - "./custom-widget-source": "./src/custom-widget-source.ts", + "./http-auth": "./src/http-auth.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 deleted file mode 100644 index 130290e4e..000000000 --- a/packages/integrations/src/custom-widget-source.ts +++ /dev/null @@ -1,18 +0,0 @@ -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/integrations/src/http-auth.ts b/packages/integrations/src/http-auth.ts new file mode 100644 index 000000000..94dae0695 --- /dev/null +++ b/packages/integrations/src/http-auth.ts @@ -0,0 +1,56 @@ +import type { IntegrationKind } from "@homarr/definitions"; + +export interface IntegrationHttpAuth { + type: "apiKeyHeader" | "bearer" | "basic"; + secrets: Array<{ kind: string; value: string }>; +} + +export class IntegrationHttpAuthError extends Error { + readonly code: "BAD_REQUEST" | "PRECONDITION_FAILED"; + constructor(input: { code: "BAD_REQUEST" | "PRECONDITION_FAILED"; message: string }) { + super(input.message); + this.code = input.code; + } +} + +// Match the existing adapters; never guess authentication from secret names alone. +export const integrationHttpAuth = { + sonarr: "apiKeyHeader", + radarr: "apiKeyHeader", + lidarr: "apiKeyHeader", + readarr: "apiKeyHeader", + prowlarr: "apiKeyHeader", + overseerr: "apiKeyHeader", + jellyseerr: "apiKeyHeader", + seerr: "apiKeyHeader", + immich: "apiKeyHeader", + slskd: "apiKeyHeader", + homeAssistant: "bearer", + coolify: "bearer", + audiobookshelf: "bearer", + speedtestTracker: "bearer", + adGuardHome: "basic", + nextcloud: "basic", +} as const satisfies Partial>; + +export function resolveIntegrationHttpAuth( + kind: IntegrationKind, + secrets: IntegrationHttpAuth["secrets"], +): IntegrationHttpAuth { + if (!(kind in integrationHttpAuth)) { + throw new IntegrationHttpAuthError({ + code: "BAD_REQUEST", + message: "This integration's authentication is not supported by HTTP requests", + }); + } + const type = integrationHttpAuth[kind as keyof typeof integrationHttpAuth]; + let required = ["apiKey"]; + if (type === "basic") required = ["username", "password"]; + if (required.some((key) => !secrets.some((secret) => secret.kind === key && secret.value))) { + throw new IntegrationHttpAuthError({ + code: "PRECONDITION_FAILED", + message: "Integration credentials are incomplete", + }); + } + return { type, secrets }; +} diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 770016fb6..a2018f9f5 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1024,7 +1024,7 @@ "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." + "integrationDescription": "Uses the saved integration URL and credentials. Changes apply automatically. Full integration access is required to run requests." }, "ai": { "title": "Create or fix with any AI",