fix: share integration transport and enforce access

This commit is contained in:
ajnart committed 2026-09-12 20:26:39 +02:00
1 parent 52c82f72db
commit c2a587bda7
16 files changed
+284 -117

No files matched your search

@@ -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`.
@@ -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
@@ -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<string, CustomWidgetSource>,
) {
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<CustomWidgetSource, { type: "integration" }>,
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<CustomJsxRequest, "kind" | "method" | "auth">,
getSecrets: () => Array<{ kind: string; value: string }>,
): Promise<
Pick<CustomWidgetHttpRequest, "baseUrl" | "networkScope" | "auth" | "tls" | "pathPrefix"> & { cacheVersion: string }
Pick<CustomWidgetHttpRequest, "baseUrl" | "networkScope" | "auth" | "tls" | "pathPrefix" | "redactSecrets"> & {
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<string, CustomWidgetSource>,
) {
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`),
];
}),
);
}
@@ -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<ReturnType<typeof getIntegrationForHttpRequest>>;
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" });
}
}
@@ -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,
@@ -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.
@@ -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\`.
@@ -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(),
@@ -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";
@@ -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<ConnectionOptions, "ca" | "checkServerIdentity">;
pathPrefix?: string;
baseUrl: string;
targetUrl?: string | URL;
method: CustomWidgetMethod;
body?: string;
staticHeaders?: Record<string, string>;
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<string, { expiresAt: number; response: CustomWidgetHttpResponse }>();
const inFlight = new Map<string, Promise<CustomWidgetHttpResponse>>();
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 {
@@ -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<ConnectionOptions, "ca" | "checkServerIdentity">;
pathPrefix?: string;
redactSecrets?: CustomWidgetAuthConfig["secrets"];
baseUrl: string;
targetUrl?: string | URL;
method: CustomWidgetMethod;
body?: string;
staticHeaders?: Record<string, string>;
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;
}
@@ -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);
}
+1 -1
View File
@@ -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",
@@ -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");
}
}
+56
View File
@@ -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<Record<IntegrationKind, string>>;
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 };
}
+1 -1
View File
@@ -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",