feat: reuse integrations in custom widgets

This commit is contained in:
ajnart committed 2026-09-12 20:17:07 +02:00
1 parent c32a5317ca
commit 52c82f72db
49 files changed
+793 -269

No files matched your search

@@ -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";
@@ -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", {
@@ -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)
@@ -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 {
@@ -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 }]) => ({
@@ -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) {
@@ -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"]
@@ -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<typeof appendPrev
}
};
export const getPreviewRequestSource = (session: CustomWidgetPreviewSession, sourceId: string) => {
const source = session.sources[sourceId];
export async function getPreviewRequestSource(
ctx: Parameters<typeof resolveCustomWidgetSource>[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,
@@ -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, {
@@ -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,
@@ -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<string, { auth: string | { type: string } }>,
sources: Record<string, { auth?: string | { type: string }; type?: string }>,
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<string>(requiredSecretKinds(authType)).has(secret.kind);
});
if (invalid) {
@@ -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,
@@ -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<string, CustomWidgetSource>,
) {
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<CustomWidgetSource, { type: "integration" }>,
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<CustomJsxRequest, "kind" | "method" | "auth">,
getSecrets: () => Array<{ kind: string; value: string }>,
): Promise<
Pick<CustomWidgetHttpRequest, "baseUrl" | "networkScope" | "auth" | "tls" | "pathPrefix"> & { 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),
};
}
@@ -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<typeof customWidgetImportSchema>,
) {
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);
@@ -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}`));
@@ -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<typeof createDb>) {
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
+16 -27
View File
@@ -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 <T>(
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,