import { TRPCError } from "@trpc/server"; import { z } from "zod/v4"; import { decryptSecret } from "@homarr/common/server"; import { eq, or } from "@homarr/db"; import { boards, customWidgetDefinitions, legacyCustomWidgetDefinitions } from "@homarr/db/schema"; import { collectCustomWidgetRequestReferences } from "@homarr/custom-widgets/core"; import { permissionRequiredProcedure } from "../../trpc"; import { throwIfActionForbiddenAsync } from "../board/board-access"; import { parseStoredCustomWidgetDefinition } from "./stored-definition"; import { executeCustomWidgetRequest } from "./request-executor"; import { hashRuntimeParams, renderRequestBody, renderRequestTarget, resolveCustomWidgetRequestValues, } from "./request-manifest"; import { acquireCustomWidgetRequestLimit } from "./request-limits"; import { getCustomWidgetCacheVersion } from "./cache-version"; import { buildLegacyCustomWidgetMigrationPrompt } from "./legacy-migration"; import { mapAvailableCustomWidget, mapCustomWidgetListItem, mapLegacyAvailableCustomWidget, mapLegacyCustomWidgetListItem, } from "./management-query-mappers"; export const managementQueryProcedures = { list: permissionRequiredProcedure .requiresPermission("admin") .meta({ mcp: { enabled: true, description: "List all Custom JSX widgets." } }) .query(async ({ ctx }) => { const [definitions, legacyDefinitions] = await Promise.all([ ctx.db.query.customWidgetDefinitions.findMany({ orderBy: (table, { asc }) => asc(table.name), with: { secrets: true }, }), ctx.db.query.legacyCustomWidgetDefinitions.findMany({ orderBy: (table, { asc }) => asc(table.name), with: { secrets: true }, }), ]); const current = definitions.map(mapCustomWidgetListItem); const currentIds = new Set(definitions.map(({ id }) => id)); const legacy = legacyDefinitions.filter(({ id }) => !currentIds.has(id)).map(mapLegacyCustomWidgetListItem); return [...current, ...legacy].toSorted((left, right) => left.name.localeCompare(right.name)); }), legacyMigrationPrompt: permissionRequiredProcedure .requiresPermission("admin") .meta({ mcp: { enabled: true, description: "Get a redacted AI migration prompt for one preserved legacy Custom Widget. Credentials must be configured separately.", }, }) .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const definition = await ctx.db.query.legacyCustomWidgetDefinitions.findFirst({ where: eq(legacyCustomWidgetDefinitions.id, input.id), with: { secrets: true }, }); if (!definition) throw new TRPCError({ code: "NOT_FOUND", message: "Legacy custom widget not found" }); return { id: definition.id, version: 1 as const, prompt: buildLegacyCustomWidgetMigrationPrompt( definition, definition.secrets.map(({ kind }) => kind), ), }; }), get: permissionRequiredProcedure .requiresPermission("admin") .meta({ mcp: { enabled: true, description: "Get one Custom JSX widget without secret values." } }) .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const definition = await ctx.db.query.customWidgetDefinitions.findFirst({ where: eq(customWidgetDefinitions.id, input.id), with: { secrets: true }, }); if (!definition) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" }); return { id: definition.id, ...parseStoredCustomWidgetDefinition(definition), enabled: definition.enabled, createdAt: definition.createdAt, updatedAt: definition.updatedAt, secrets: definition.secrets.map((secret) => ({ sourceId: secret.sourceId, kind: secret.kind, hasValue: true, updatedAt: secret.updatedAt, })), }; }), available: permissionRequiredProcedure .requiresPermission("admin") .input(z.object({ boardId: z.string(), currentId: z.string().optional() })) .query(async ({ ctx, input }) => { await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify"); const definitions = await ctx.db.query.customWidgetDefinitions.findMany({ where: input.currentId ? or(eq(customWidgetDefinitions.enabled, true), eq(customWidgetDefinitions.id, input.currentId)) : eq(customWidgetDefinitions.enabled, true), orderBy: (table, { asc }) => asc(table.name), }); const available = definitions.flatMap(mapAvailableCustomWidget); if (!input.currentId || available.some(({ id }) => id === input.currentId)) return available; const legacy = await ctx.db.query.legacyCustomWidgetDefinitions.findFirst({ where: eq(legacyCustomWidgetDefinitions.id, input.currentId), }); if (!legacy) return available; return [...available, mapLegacyAvailableCustomWidget(legacy)]; }), optionRequest: permissionRequiredProcedure .requiresPermission("admin") .input( z.object({ boardId: z.string(), definitionId: z.string(), requestId: z.string(), params: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).default({}), }), ) .query(async ({ ctx, input }) => { await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify"); const stored = await ctx.db.query.customWidgetDefinitions.findFirst({ where: eq(customWidgetDefinitions.id, input.definitionId), with: { secrets: true }, }); if (!stored || !stored.enabled) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget not found" }); const definition = parseStoredCustomWidgetDefinition(stored); const requestDefinition = definition.requests[input.requestId]; if (requestDefinition?.kind !== "query") throw new TRPCError({ code: "NOT_FOUND", message: "Option query not found" }); const request = { id: input.requestId, ...requestDefinition }; await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), request.permission); const source = definition.sources[request.source]; if (!source) throw new TRPCError({ code: "NOT_FOUND", message: "Option query source not found" }); const release = await acquireCustomWidgetRequestLimit({ category: "query", userId: ctx.session.user.id, itemId: `configuration:${input.boardId}`, definitionId: input.definitionId, }); try { const values = resolveOptionRequestValues(request, input.params); const authType = typeof source.auth === "string" ? source.auth : source.auth.type; const response = await executeCustomWidgetRequest({ baseUrl: source.baseUrl, targetUrl: renderRequestTarget(source.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)}`, cacheTtlSeconds: request.cacheSeconds ?? 30, }); return { ok: response.ok, status: response.status, data: response.data, error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`, }; } finally { await release(); } }), }; export function resolveOptionRequestValues( request: Parameters[0], configuration: NonNullable[2]>, ) { const references = collectCustomWidgetRequestReferences(request); const options: Record = {}; const params: typeof configuration = {}; for (const name of references.options) { const value = configuration[name]; if (value !== undefined) options[name] = value; } for (const name of references.params) { const value = configuration[name]; if (value !== undefined) params[name] = value; } return resolveCustomWidgetRequestValues(request, options, params); }