refactor: simplify widget and integration architecture

This commit is contained in:
ajnart committed 2026-08-24 00:57:36 +02:00
1 parent c988c35b29
commit 8f9efd0074
179 files changed
+1509 -4187

No files matched your search

+34 -15
View File
@@ -1,4 +1,4 @@
import type { AnyRootTypes, Router, RouterRecord } from "@trpc/server/unstable-core-do-not-import";
import type { AnyProcedure, AnyRootTypes, Router, RouterRecord } from "@trpc/server/unstable-core-do-not-import";
import { z } from "zod/v4";
export interface McpMeta {
@@ -12,6 +12,7 @@ export interface McpMeta {
export interface McpTool {
name: string;
description: string;
type: "query" | "mutation";
pathInRouter: string[];
inputSchema: z.core.JSONSchema.JSONSchema;
inputValidator: z.ZodObject;
@@ -19,8 +20,32 @@ export interface McpTool {
const emptyInput = z.object({});
const isProcedure = (value: AnyProcedure | RouterRecord): value is AnyProcedure => typeof value === "function";
const mergeInputs = (inputs: z.ZodObject[]) => inputs.reduce((merged, input) => merged.extend(input.shape), emptyInput);
const getMcpMetadata = (meta: unknown): McpMeta["mcp"] => {
if (!meta || typeof meta !== "object" || !("mcp" in meta)) return undefined;
const metadata = meta.mcp;
if (!metadata || typeof metadata !== "object" || !("enabled" in metadata)) return undefined;
if (typeof metadata.enabled !== "boolean") return undefined;
const result: NonNullable<McpMeta["mcp"]> = { enabled: metadata.enabled };
if ("name" in metadata && typeof metadata.name === "string") result.name = metadata.name;
if ("description" in metadata && typeof metadata.description === "string") {
result.description = metadata.description;
}
return result;
};
const getProcedureInput = (inputs: z.ZodType[]) => {
if (inputs.length === 0) return emptyInput;
if (inputs.length === 1) return inputs[0] ?? emptyInput;
if (inputs.every((input): input is z.ZodObject => input instanceof z.ZodObject)) return mergeInputs(inputs);
return null;
};
const normalizeJsonSchema = (schema: z.core.JSONSchema.JSONSchema): z.core.JSONSchema.JSONSchema => {
const properties = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
const required = ((schema.required as string[] | undefined) ?? []).filter(
@@ -41,22 +66,15 @@ export function extractMcpToolsFromProcedures<TRoot extends AnyRootTypes, TRecor
const tools: McpTool[] = [];
for (const [path, procedure] of Object.entries(router["_def"].procedures)) {
const definition = procedure["_def"] as unknown as {
inputs?: z.ZodType[];
meta?: McpMeta;
};
const metadata = definition.meta?.mcp;
if (!isProcedure(procedure)) continue;
const definition = procedure["_def"];
if (definition.type === "subscription") continue;
const metadata = getMcpMetadata(definition.meta);
if (!metadata?.enabled) continue;
const inputs = (definition.inputs ?? []).filter((input): input is z.ZodType => input !== undefined);
const procedureInput =
inputs.length === 0
? emptyInput
: inputs.length === 1
? (inputs[0] ?? emptyInput)
: inputs.every((input): input is z.ZodObject => input instanceof z.ZodObject)
? mergeInputs(inputs)
: null;
const inputs = definition.inputs.filter((input): input is z.ZodType => input instanceof z.ZodType);
const procedureInput = getProcedureInput(inputs);
if (
procedureInput === null ||
@@ -69,6 +87,7 @@ export function extractMcpToolsFromProcedures<TRoot extends AnyRootTypes, TRecor
tools.push({
name: metadata.name ?? path.replaceAll(".", "_"),
description: metadata.description ?? "",
type: definition.type,
pathInRouter: path.split("."),
inputValidator,
inputSchema: normalizeJsonSchema(
+36 -49
View File
@@ -2,9 +2,6 @@ export { createTRPCContext } from "./trpc";
export { extractMcpToolsFromProcedures } from "./mcp-tools";
export type { McpTool } from "./mcp-tools";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { createTRPCRouter } from "./trpc";
import { appRouter as appRouterForApps } from "./router/app";
import { apiKeysRouter } from "./router/apiKeys";
@@ -15,51 +12,41 @@ import { iconsRouter } from "./router/icons";
import { infoRouter } from "./router/info";
import { integrationRouter } from "./router/integration/integration-router";
import { inviteRouter } from "./router/invite";
import { airQualityRouter } from "./router/widgets/air-quality";
import { bazarrRouter } from "./router/widgets/bazarr";
import { serverSettingsRouter } from "./router/serverSettings";
import { loadMcpWidgetRoutersAsync } from "./router/widgets/registry";
import { beszelRouter } from "./router/widgets/beszel";
import { calendarRouter } from "./router/widgets/calendar";
import { dnsHoleRouter } from "./router/widgets/dns-hole";
import { downloadsRouter } from "./router/widgets/downloads";
import { healthMonitoringRouter } from "./router/widgets/health-monitoring";
import { mediaRequestsRouter } from "./router/widgets/media-requests";
import { mediaServerRouter } from "./router/widgets/media-server";
import { patchmonRouter } from "./router/widgets/patchmon";
import { smartHomeRouter } from "./router/widgets/smart-home";
import { widgetSecretsRouter } from "./router/widgets/widget-secrets";
const logger = createLogger({ module: "mcpRouter" });
const createMcpRouterAsync = async () => {
const startedAt = Date.now();
const widgetRouters = await loadMcpWidgetRoutersAsync();
const router = createTRPCRouter({
app: appRouterForApps,
apiKeys: apiKeysRouter,
board: boardRouter,
customWidget: customWidgetRouter,
docker: dockerRouter,
icon: iconsRouter,
info: infoRouter,
integration: integrationRouter,
invite: inviteRouter,
serverSettings: serverSettingsRouter,
...widgetRouters,
});
logger.info("Materialized MCP router", {
durationMs: Date.now() - startedAt,
widgetRouters: Object.keys(widgetRouters),
});
return router;
};
export type McpRouter = Awaited<ReturnType<typeof createMcpRouterAsync>>;
let mcpRouterPromise: Promise<McpRouter> | null = null;
export async function getMcpRouterAsync(): Promise<McpRouter> {
if (mcpRouterPromise) return await mcpRouterPromise;
const loadPromise = createMcpRouterAsync();
mcpRouterPromise = loadPromise;
try {
return await loadPromise;
} catch (error) {
if (mcpRouterPromise === loadPromise) mcpRouterPromise = null;
logger.error(new ErrorWithMetadata("Failed to materialize MCP router", {}, { cause: error }));
throw error;
}
}
export const mcpRouter = createTRPCRouter({
app: appRouterForApps,
apiKeys: apiKeysRouter,
board: boardRouter,
customWidget: customWidgetRouter,
docker: dockerRouter,
icon: iconsRouter,
info: infoRouter,
integration: integrationRouter,
invite: inviteRouter,
airQuality: airQualityRouter,
bazarr: bazarrRouter,
serverSettings: serverSettingsRouter,
beszel: beszelRouter,
calendar: calendarRouter,
dnsHole: dnsHoleRouter,
downloads: downloadsRouter,
healthMonitoring: healthMonitoringRouter,
mediaRequests: mediaRequestsRouter,
mediaServer: mediaServerRouter,
patchmon: patchmonRouter,
smartHome: smartHomeRouter,
widgetSecrets: widgetSecretsRouter,
});
+2 -2
View File
@@ -2,10 +2,10 @@ import { TRPCError } from "@trpc/server";
import { env } from "@homarr/docker/env";
import { isDemoMode, middlewareProcedure } from "../trpc";
import { isDemoMode, publicProcedure } from "../trpc";
export const dockerMiddleware = () => {
return middlewareProcedure.use(async ({ next }) => {
return publicProcedure.use(async ({ next }) => {
if (env.ENABLE_DOCKER || isDemoMode) {
return await next();
}
+74 -201
View File
@@ -10,10 +10,10 @@ import type { Database } from "@homarr/db";
import { and, eq, inArray } from "@homarr/db";
import { integrations } from "@homarr/db/schema";
import type { IntegrationKind, WidgetIntegrationKind, WidgetKindWithIntegration } from "@homarr/definitions";
import { getWidgetFeatureIntegrationKinds } from "@homarr/definitions";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { middlewareProcedure } from "../trpc";
import { publicProcedure } from "../trpc";
export type IntegrationAction = "query" | "interact";
@@ -32,51 +32,22 @@ interface IntegrationAccessMetadata {
allowedIntegrationKinds: readonly IntegrationKind[];
}
interface WidgetIntegrationAvailability {
widgetKinds: readonly [WidgetKindWithIntegration, ...WidgetKindWithIntegration[]];
}
const getCompatibleIntegrationKindsOrThrow = <TKind extends IntegrationKind>(
kinds: readonly TKind[],
widgetAvailability?: WidgetIntegrationAvailability,
) => {
if (!widgetAvailability) return kinds;
const widgetIntegrationKinds = new Set(
widgetAvailability.widgetKinds.flatMap((widgetKind) => getWidgetFeatureIntegrationKinds(widgetKind)),
);
const compatibleKinds = kinds.filter((kind) => widgetIntegrationKinds.has(kind));
if (compatibleKinds.length >= 1) return compatibleKinds;
logger.warn("No compatible integration kinds for widget route", {
widgetKinds: widgetAvailability.widgetKinds,
configuredIntegrationKinds: kinds,
reason: "no-compatible-widget-integration-kinds",
});
throw new TRPCError({
code: "NOT_FOUND",
message: "No integration kind is compatible with the consuming widget",
});
};
const createOneIntegrationMiddlewareForKinds = <TKind extends IntegrationKind>(
action: IntegrationAction,
kinds: readonly TKind[],
widgetAvailability?: WidgetIntegrationAvailability,
) => {
return middlewareProcedure.input(z.object({ integrationId: z.string() })).use(async ({ input, ctx, next }) => {
const compatibleKinds = getCompatibleIntegrationKindsOrThrow(kinds, widgetAvailability);
return publicProcedure.input(z.object({ integrationId: z.string() })).use(async ({ input, ctx, next }) => {
const accessMetadata: IntegrationAccessMetadata = {
action,
cardinality: "one",
userId: ctx.session?.user.id,
requestedIntegrationIds: [input.integrationId],
allowedIntegrationKinds: compatibleKinds,
allowedIntegrationKinds: kinds,
};
logger.debug("Resolving integration access", accessMetadata);
const integration = await ctx.db.query.integrations.findFirst({
where: and(eq(integrations.id, input.integrationId), inArray(integrations.kind, compatibleKinds)),
where: and(eq(integrations.id, input.integrationId), inArray(integrations.kind, kinds)),
with: {
app: true,
secrets: true,
@@ -94,7 +65,7 @@ const createOneIntegrationMiddlewareForKinds = <TKind extends IntegrationKind>(
logger.warn("Integration access target was not found", accessMetadata);
throw new TRPCError({
code: "NOT_FOUND",
message: `Integration with id ${input.integrationId} not found or not of kinds ${compatibleKinds.join(",")}`,
message: `Integration with id ${input.integrationId} not found or not of kinds ${kinds.join(",")}`,
});
}
@@ -148,80 +119,76 @@ export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
const createManyIntegrationMiddlewareForKinds = <TKind extends IntegrationKind>(
action: IntegrationAction,
kinds: readonly TKind[],
widgetAvailability?: WidgetIntegrationAvailability,
) => {
return middlewareProcedure
.input(z.object({ integrationIds: z.array(z.string()) }))
.use(async ({ ctx, input, next }) => {
const compatibleKinds = getCompatibleIntegrationKindsOrThrow(kinds, widgetAvailability);
const accessMetadata: IntegrationAccessMetadata = {
action,
cardinality: "many",
userId: ctx.session?.user.id,
requestedIntegrationIds: input.integrationIds,
allowedIntegrationKinds: compatibleKinds,
};
logger.debug("Resolving integration access", accessMetadata);
return publicProcedure.input(z.object({ integrationIds: z.array(z.string()) })).use(async ({ ctx, input, next }) => {
const accessMetadata: IntegrationAccessMetadata = {
action,
cardinality: "many",
userId: ctx.session?.user.id,
requestedIntegrationIds: input.integrationIds,
allowedIntegrationKinds: kinds,
};
logger.debug("Resolving integration access", accessMetadata);
const findIntegrations = async () => {
if (input.integrationIds.length === 0) return [];
const findIntegrations = async () => {
if (input.integrationIds.length === 0) return [];
return await ctx.db.query.integrations.findMany({
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, compatibleKinds)),
with: {
app: true,
secrets: true,
items: {
with: {
item: true,
},
return await ctx.db.query.integrations.findMany({
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
with: {
app: true,
secrets: true,
items: {
with: {
item: true,
},
userPermissions: true,
groupPermissions: true,
},
});
};
const dbIntegrations = await findIntegrations();
const offset = input.integrationIds.length - dbIntegrations.length;
if (offset !== 0) {
logger.warn("Integration access targets were not found", {
...accessMetadata,
missingIntegrationCount: offset,
resolvedIntegrationIds: dbIntegrations.map(({ id }) => id),
resolvedIntegrationKinds: dbIntegrations.map(({ kind }) => kind),
});
throw new TRPCError({
code: "NOT_FOUND",
message: `${offset} of the specified integrations not found or not of kinds ${compatibleKinds.join(",")}: ([${input.integrationIds.join(",")}] compared to [${dbIntegrations.map(({ id, kind }) => `${kind}:${id}`).join(",")}])`,
});
}
if (dbIntegrations.length >= 1) {
await throwIfActionIsNotAllowedAsync(action, ctx.db, dbIntegrations, ctx.session);
}
logger.debug("Integration access granted", {
...accessMetadata,
resolvedIntegrationKinds: dbIntegrations.map(({ kind }) => kind),
});
return next({
ctx: {
integrations: dbIntegrations.map(
({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => ({
...rest,
externalUrl: rest.app?.href ?? null,
kind: kind as TKind,
decryptedSecrets: secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
})),
}),
),
userPermissions: true,
groupPermissions: true,
},
});
};
const dbIntegrations = await findIntegrations();
const offset = input.integrationIds.length - dbIntegrations.length;
if (offset !== 0) {
logger.warn("Integration access targets were not found", {
...accessMetadata,
missingIntegrationCount: offset,
resolvedIntegrationIds: dbIntegrations.map(({ id }) => id),
resolvedIntegrationKinds: dbIntegrations.map(({ kind }) => kind),
});
throw new TRPCError({
code: "NOT_FOUND",
message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}: ([${input.integrationIds.join(",")}] compared to [${dbIntegrations.map(({ id, kind }) => `${kind}:${id}`).join(",")}])`,
});
}
if (dbIntegrations.length >= 1) {
await throwIfActionIsNotAllowedAsync(action, ctx.db, dbIntegrations, ctx.session);
}
logger.debug("Integration access granted", {
...accessMetadata,
resolvedIntegrationKinds: dbIntegrations.map(({ kind }) => kind),
});
return next({
ctx: {
integrations: dbIntegrations.map(
({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => ({
...rest,
externalUrl: rest.app?.href ?? null,
kind: kind as TKind,
decryptedSecrets: secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
})),
}),
),
},
});
});
};
/**
@@ -241,111 +208,17 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
return createManyIntegrationMiddlewareForKinds(action, kinds);
};
/**
* Widget procedures should use these adapters so their server-side permission
* guards stay aligned with the feature catalog automatically.
*/
export function createOneWidgetIntegrationMiddleware<TWidgetKind extends WidgetKindWithIntegration>(
export const createOneWidgetIntegrationMiddleware = <TWidgetKind extends WidgetKindWithIntegration>(
action: IntegrationAction,
widgetKind: TWidgetKind,
): ReturnType<typeof createOneIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>>;
export function createOneWidgetIntegrationMiddleware<
TWidgetKind extends WidgetKindWithIntegration,
TIntegrationKind extends WidgetIntegrationKind<TWidgetKind>,
>(
action: IntegrationAction,
widgetKind: TWidgetKind,
integrationKinds: readonly [TIntegrationKind, ...TIntegrationKind[]],
): ReturnType<typeof createOneIntegrationMiddlewareForKinds<TIntegrationKind>>;
export function createOneWidgetIntegrationMiddleware(
action: IntegrationAction,
widgetKind: WidgetKindWithIntegration,
integrationKinds?: readonly IntegrationKind[],
) {
const configuredIntegrationKinds = integrationKinds ?? getWidgetFeatureIntegrationKinds(widgetKind);
return createOneIntegrationMiddlewareForKinds(action, configuredIntegrationKinds, { widgetKinds: [widgetKind] });
}
): ReturnType<typeof createOneIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>> =>
createOneIntegrationMiddlewareForKinds(action, getWidgetIntegrationConfig(widgetKind).supportedIntegrations);
export function createOneSharedWidgetIntegrationMiddleware<
TContractWidgetKind extends WidgetKindWithIntegration,
const TAdditionalWidgetKinds extends readonly [WidgetKindWithIntegration, ...WidgetKindWithIntegration[]],
>(
action: IntegrationAction,
contractWidgetKind: TContractWidgetKind,
additionalWidgetKinds: TAdditionalWidgetKinds,
): ReturnType<typeof createOneIntegrationMiddlewareForKinds<WidgetIntegrationKind<TContractWidgetKind>>>;
export function createOneSharedWidgetIntegrationMiddleware<
TContractWidgetKind extends WidgetKindWithIntegration,
const TAdditionalWidgetKinds extends readonly [WidgetKindWithIntegration, ...WidgetKindWithIntegration[]],
TIntegrationKind extends WidgetIntegrationKind<TContractWidgetKind>,
>(
action: IntegrationAction,
contractWidgetKind: TContractWidgetKind,
additionalWidgetKinds: TAdditionalWidgetKinds,
integrationKinds: readonly [TIntegrationKind, ...TIntegrationKind[]],
): ReturnType<typeof createOneIntegrationMiddlewareForKinds<TIntegrationKind>>;
export function createOneSharedWidgetIntegrationMiddleware(
action: IntegrationAction,
contractWidgetKind: WidgetKindWithIntegration,
additionalWidgetKinds: readonly [WidgetKindWithIntegration, ...WidgetKindWithIntegration[]],
integrationKinds?: readonly IntegrationKind[],
) {
const configuredIntegrationKinds = integrationKinds ?? getWidgetFeatureIntegrationKinds(contractWidgetKind);
return createOneIntegrationMiddlewareForKinds(action, configuredIntegrationKinds, {
widgetKinds: [contractWidgetKind, ...additionalWidgetKinds],
});
}
export function createManyWidgetIntegrationMiddleware<TWidgetKind extends WidgetKindWithIntegration>(
export const createManyWidgetIntegrationMiddleware = <TWidgetKind extends WidgetKindWithIntegration>(
action: IntegrationAction,
widgetKind: TWidgetKind,
): ReturnType<typeof createManyIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>>;
export function createManyWidgetIntegrationMiddleware<
TWidgetKind extends WidgetKindWithIntegration,
TIntegrationKind extends WidgetIntegrationKind<TWidgetKind>,
>(
action: IntegrationAction,
widgetKind: TWidgetKind,
integrationKinds: readonly [TIntegrationKind, ...TIntegrationKind[]],
): ReturnType<typeof createManyIntegrationMiddlewareForKinds<TIntegrationKind>>;
export function createManyWidgetIntegrationMiddleware(
action: IntegrationAction,
widgetKind: WidgetKindWithIntegration,
integrationKinds?: readonly IntegrationKind[],
) {
const configuredIntegrationKinds = integrationKinds ?? getWidgetFeatureIntegrationKinds(widgetKind);
return createManyIntegrationMiddlewareForKinds(action, configuredIntegrationKinds, { widgetKinds: [widgetKind] });
}
export function createManySharedWidgetIntegrationMiddleware<
TContractWidgetKind extends WidgetKindWithIntegration,
const TAdditionalWidgetKinds extends readonly [WidgetKindWithIntegration, ...WidgetKindWithIntegration[]],
>(
action: IntegrationAction,
contractWidgetKind: TContractWidgetKind,
additionalWidgetKinds: TAdditionalWidgetKinds,
): ReturnType<typeof createManyIntegrationMiddlewareForKinds<WidgetIntegrationKind<TContractWidgetKind>>>;
export function createManySharedWidgetIntegrationMiddleware<
TContractWidgetKind extends WidgetKindWithIntegration,
const TAdditionalWidgetKinds extends readonly [WidgetKindWithIntegration, ...WidgetKindWithIntegration[]],
TIntegrationKind extends WidgetIntegrationKind<TContractWidgetKind>,
>(
action: IntegrationAction,
contractWidgetKind: TContractWidgetKind,
additionalWidgetKinds: TAdditionalWidgetKinds,
integrationKinds: readonly [TIntegrationKind, ...TIntegrationKind[]],
): ReturnType<typeof createManyIntegrationMiddlewareForKinds<TIntegrationKind>>;
export function createManySharedWidgetIntegrationMiddleware(
action: IntegrationAction,
contractWidgetKind: WidgetKindWithIntegration,
additionalWidgetKinds: readonly [WidgetKindWithIntegration, ...WidgetKindWithIntegration[]],
integrationKinds?: readonly IntegrationKind[],
) {
const configuredIntegrationKinds = integrationKinds ?? getWidgetFeatureIntegrationKinds(contractWidgetKind);
return createManyIntegrationMiddlewareForKinds(action, configuredIntegrationKinds, {
widgetKinds: [contractWidgetKind, ...additionalWidgetKinds],
});
}
): ReturnType<typeof createManyIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>> =>
createManyIntegrationMiddlewareForKinds(action, getWidgetIntegrationConfig(widgetKind).supportedIntegrations);
/**
* Throws a TRPCError FORBIDDEN if the user does not have permission to perform the specified action on at least one of the specified integrations
+2 -2
View File
@@ -5,10 +5,10 @@ import { and, eq } from "@homarr/db";
import { items } from "@homarr/db/schema";
import type { WidgetKind } from "@homarr/definitions";
import { middlewareProcedure } from "../trpc";
import { publicProcedure } from "../trpc";
export const createOneItemMiddleware = (kind: WidgetKind) => {
return middlewareProcedure.input(z.object({ itemId: z.string() })).use(async ({ input, ctx, next }) => {
return publicProcedure.input(z.object({ itemId: z.string() })).use(async ({ input, ctx, next }) => {
const item = await ctx.db.query.items.findFirst({
where: and(eq(items.id, input.itemId), eq(items.kind, kind)),
});
+2 -2
View File
@@ -2,10 +2,10 @@ import { TRPCError } from "@trpc/server";
import { env } from "@homarr/docker/env";
import { middlewareProcedure } from "../trpc";
import { publicProcedure } from "../trpc";
export const kubernetesMiddleware = () => {
return middlewareProcedure.use(async ({ next }) => {
return publicProcedure.use(async ({ next }) => {
if (env.ENABLE_KUBERNETES) {
return await next();
}
-9
View File
@@ -1,7 +1,5 @@
import type { QueryKey } from "@tanstack/react-query";
import { generatedWidgetOperationPolicies } from "@homarr/definitions";
// RSC-prefetched data must survive the hydration pass without an immediate
// duplicate fetch. Widget-specific intervals still control live refreshes.
export const queryCacheDefaultStaleTimeMs = 30_000;
@@ -13,15 +11,8 @@ const widgetDataQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContai
// Beszel system stats are large time-series payloads and were deliberately kept out of
// the persisted cache in #6289; live stats come from an SSE subscription instead.
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
const generatedWidgetPersistencePolicies = new Map(
generatedWidgetOperationPolicies
.filter((policy) => policy.kind === "query")
.map((policy) => [policy.path.join("."), policy.persist] as const),
);
export const isWidgetDataTrpcPath = (path: string) => {
const generatedPolicy = generatedWidgetPersistencePolicies.get(path);
if (generatedPolicy !== undefined) return generatedPolicy;
if (path.startsWith("widget.")) return !excludedWidgetPaths.has(path);
return widgetDataQueryPaths.has(path);
};
+3 -9
View File
@@ -25,9 +25,7 @@ import {
assistantProviderPresets,
assistantProviderRequiresApiKey,
getIconUrl,
integrationKinds,
resolveAssistantModelId,
widgetKinds,
} from "@homarr/definitions";
import type { createTRPCContext } from "../trpc";
@@ -338,7 +336,6 @@ export const getAssistantContextEntitiesAsync = async (ctx: AssistantContext): P
boardRouter.createCaller(ctx).getAllBoards(),
ctx.db.query.integrations.findMany({
columns: { id: true, name: true, kind: true },
where: inArray(integrations.kind, integrationKinds),
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, ctx.session.user.id),
@@ -367,12 +364,9 @@ export const getAssistantContextEntitiesAsync = async (ctx: AssistantContext): P
? []
: await ctx.db.query.items.findMany({
columns: { id: true, boardId: true, kind: true },
where: and(
inArray(
items.boardId,
availableBoards.map((board) => board.id),
),
inArray(items.kind, widgetKinds),
where: inArray(
items.boardId,
availableBoards.map((board) => board.id),
),
orderBy: asc(items.kind),
limit: 500,
+19 -29
View File
@@ -120,27 +120,22 @@ const findFirstAvailableBoardItemPosition = (
return null;
};
interface WidgetPlacementRecord {
interface WidgetConfiguration {
id: string;
kind: WidgetKind;
}
interface WidgetConfigurationRecord extends WidgetPlacementRecord {
integrationIds: readonly string[];
}
interface IntegrationKindRecord {
id: string;
kind: IntegrationKind;
}
const haveSameIntegrationIds = (left: readonly string[], right: readonly string[]) =>
left.length === right.length && left.every((integrationId) => right.includes(integrationId));
const haveSameIntegrationIds = (left: readonly string[], right: readonly string[]) => {
if (left.length !== right.length) return false;
const sortedRight = right.toSorted();
return left.toSorted().every((integrationId, index) => integrationId === sortedRight[index]);
};
const validateWidgetConfigurationsAsync = async (
ctx: Parameters<typeof throwIfIntegrationActionsForbiddenAsync>[0],
submittedItems: readonly WidgetConfigurationRecord[],
storedItems: readonly WidgetConfigurationRecord[],
submittedItems: readonly WidgetConfiguration[],
storedItems: readonly WidgetConfiguration[] = [],
) => {
const storedItemsById = new Map(storedItems.map((item) => [item.id, item]));
const changedItems = submittedItems.filter((item) => {
@@ -151,7 +146,7 @@ const validateWidgetConfigurationsAsync = async (
if (changedItems.length === 0) return;
const selectedIntegrationIds = [...new Set(changedItems.flatMap(({ integrationIds }) => integrationIds))];
let integrationRecords: IntegrationKindRecord[] = [];
let integrationRecords: { id: string; kind: IntegrationKind }[] = [];
if (selectedIntegrationIds.length > 0) {
integrationRecords = await ctx.db.query.integrations.findMany({
columns: { id: true, kind: true },
@@ -181,10 +176,9 @@ const validateWidgetConfigurationsAsync = async (
await throwIfIntegrationActionsForbiddenAsync(ctx, addedOrReconfiguredIntegrationIds, "use");
for (const item of changedItems) {
const selectedIntegrationKinds = item.integrationIds.flatMap((integrationId) => {
const integration = integrationRecordsById.get(integrationId);
return integration ? [integration.kind] : [];
});
const selectedIntegrationKinds = item.integrationIds.flatMap(
(integrationId) => integrationRecordsById.get(integrationId)?.kind ?? [],
);
const integrationIssue = getWidgetIntegrationIssue(item.kind, selectedIntegrationKinds);
if (!integrationIssue) continue;
@@ -705,7 +699,7 @@ export const boardRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
"Duplicate an existing board into a new board. Requires board-create permission, view permission on the source board, and use permission for every linked integration. Disabled or invalid widget configurations must be removed before duplication. REQUIRED: id (source board ID), name (unique name for the new board). Returns { boardId }",
"Duplicate an existing board into a new board. Requires board-create permission, view permission on the source board, and use permission for every linked integration. Every widget-integration configuration must be valid. REQUIRED: id (source board ID), name (unique name for the new board). Returns { boardId }",
},
})
.input(boardDuplicateSchema)
@@ -749,7 +743,6 @@ export const boardRouter = createTRPCRouter({
kind: item.kind,
integrationIds: item.integrations.map(({ integrationId }) => integrationId),
})),
[],
);
const newBoardId = createId();
@@ -1563,11 +1556,10 @@ export const boardRouter = createTRPCRouter({
for (const item of input.items) {
if (item.kind !== "timetable") continue;
const previousItem = dbBoard.items.find((dbItem) => dbItem.id === item.id);
let previousOptions: Record<string, unknown> | undefined;
if (previousItem?.kind === "timetable") previousOptions = previousItem.options;
const { validateTimetableOptionsChangeAsync } = await import("./timetable-options-validation");
await validateTimetableOptionsChangeAsync(
item.options,
previousItem?.kind === "timetable" ? previousItem.options : undefined,
);
await validateTimetableOptionsChangeAsync(item.options, previousOptions);
}
await handleTransactionsAsync(ctx.db, {
@@ -2142,11 +2134,9 @@ export const boardRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
await validateWidgetConfigurationsAsync(
ctx,
[{ id: "", kind: input.kind, integrationIds: input.integrationIds }],
[],
);
await validateWidgetConfigurationsAsync(ctx, [
{ id: "", kind: input.kind, integrationIds: input.integrationIds },
]);
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: ctx.session.user.permissions.includes("admin"),
submittedItems: [{ id: "", kind: input.kind, options: input.options }],
@@ -245,8 +245,8 @@ type DockerContainerAction = "start" | "stop" | "restart" | "remove";
export const performDockerContainerActionsAsync = async (
targets: DockerContainerTarget[],
action: DockerContainerAction,
) =>
await Promise.all(
) => {
const results = await Promise.all(
targets.map(async (target) => {
try {
const capability = action === "remove" ? "remove" : "lifecycle";
@@ -271,6 +271,13 @@ export const performDockerContainerActionsAsync = async (
}),
);
if (results.some(({ success }) => success)) {
dockerContainersRequestHandler.invalidateCache();
}
return results;
};
const assertDockerEndpointCapability = (
endpointId: string,
capability: "inventory" | "logs" | "lifecycle" | "remove",
+3 -3
View File
@@ -19,12 +19,12 @@ import {
import { getAppManagementAccess, getIntegrationManagementAccess } from "@homarr/definitions";
import { createTRPCRouter, publicProcedure } from "../trpc";
const getFullAccessIntegrationIdsAsync = async (database: Database, userId: string) => {
const groupsOfCurrentUser = await database.query.groupMembers.findMany({
const getFullAccessIntegrationIdsAsync = async (db: Database, userId: string) => {
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, userId),
});
const accessibleIntegrations = await database.query.integrations.findMany({
const accessibleIntegrations = await db.query.integrations.findMany({
columns: {
id: true,
},
@@ -1,89 +1,37 @@
import { TRPCError } from "@trpc/server";
import type { Session } from "@homarr/auth";
import type { IntegrationPermissionsProps } from "@homarr/auth/shared";
import { constructIntegrationPermissions } from "@homarr/auth/shared";
import type { Database, SQL } from "@homarr/db";
import { eq, inArray } from "@homarr/db";
import { groupMembers, integrationGroupPermissions, integrations, integrationUserPermissions } from "@homarr/db/schema";
import type { IntegrationPermission } from "@homarr/definitions";
interface IntegrationPermissionRecord {
userPermissions: { permission: IntegrationPermission }[];
groupPermissions: { permission: IntegrationPermission }[];
}
const hasIntegrationPermission = (
integration: IntegrationPermissionRecord,
integration: IntegrationPermissionsProps,
session: Session | null,
permission: IntegrationPermission,
) => {
const { hasUseAccess, hasInteractAccess, hasFullAccess } = constructIntegrationPermissions(integration, session);
if (hasFullAccess) return true;
if (permission === "interact") return hasInteractAccess;
if (permission === "use") return hasUseAccess;
if (permission === "use") return hasUseAccess || hasInteractAccess;
return false;
};
/**
* Throws NOT_FOUND if user is not allowed to perform action on integration
* @param ctx trpc router context
* @param integrationWhere where clause for the integration
* @param permission permission required to perform action on integration
*/
export const throwIfActionForbiddenAsync = async (
const throwIfActionsForbiddenAsync = async (
ctx: { db: Database; session: Session | null },
integrationWhere: SQL<unknown>,
expectedCount: number,
permission: IntegrationPermission,
) => {
const { db, session } = ctx;
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, session?.user.id ?? ""),
});
const integration = await db.query.integrations.findFirst({
where: integrationWhere,
columns: {
id: true,
},
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, session?.user.id ?? ""),
},
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId).concat(""),
),
},
},
});
if (!integration) {
notAllowed();
}
if (hasIntegrationPermission(integration, session, permission)) return;
notAllowed();
};
/**
* Batched variant for feature configuration flows. It applies the same direct
* user, group, and global permission rules without issuing one query per ID.
*/
export const throwIfIntegrationActionsForbiddenAsync = async (
ctx: { db: Database; session: Session | null },
integrationIds: readonly string[],
permission: IntegrationPermission,
) => {
const uniqueIntegrationIds = [...new Set(integrationIds)];
if (uniqueIntegrationIds.length === 0) return;
const { db, session } = ctx;
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, session?.user.id ?? ""),
});
const integrationRecords = await db.query.integrations.findMany({
where: inArray(integrations.id, uniqueIntegrationIds),
where: integrationWhere,
columns: { id: true },
with: {
userPermissions: {
@@ -99,13 +47,43 @@ export const throwIfIntegrationActionsForbiddenAsync = async (
});
if (
integrationRecords.length !== uniqueIntegrationIds.length ||
integrationRecords.length !== expectedCount ||
!integrationRecords.every((integration) => hasIntegrationPermission(integration, session, permission))
) {
notAllowed();
}
};
/**
* Throws NOT_FOUND if user is not allowed to perform action on integration
* @param ctx trpc router context
* @param integrationWhere where clause for the integration
* @param permission permission required to perform action on integration
*/
export const throwIfActionForbiddenAsync = async (
ctx: { db: Database; session: Session | null },
integrationWhere: SQL<unknown>,
permission: IntegrationPermission,
) => {
await throwIfActionsForbiddenAsync(ctx, integrationWhere, 1, permission);
};
export const throwIfIntegrationActionsForbiddenAsync = async (
ctx: { db: Database; session: Session | null },
integrationIds: readonly string[],
permission: IntegrationPermission,
) => {
const uniqueIntegrationIds = [...new Set(integrationIds)];
if (uniqueIntegrationIds.length === 0) return;
await throwIfActionsForbiddenAsync(
ctx,
inArray(integrations.id, uniqueIntegrationIds),
uniqueIntegrationIds.length,
permission,
);
};
/**
* This method returns NOT_FOUND to prevent snooping on board existence
* A function is used to use the method without return statement
@@ -29,6 +29,8 @@ import {
} from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import { invalidateIntegrationCacheAsync } from "@homarr/redis";
import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
import { byIdSchema } from "@homarr/validation/common";
import {
integrationCreateSchema,
@@ -712,7 +714,10 @@ export const integrationRouter = createTRPCRouter({
.input(mediaRequestRequestSchema)
.mutation(async ({ ctx, input }) => {
const integration = await createIntegrationAsync(ctx.integration);
return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
const result = await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
mediaRequestListRequestHandler.invalidateCache();
await mediaRequestStatsRequestHandler.invalidateCacheAsync([ctx.integration.id]);
return result;
}),
});
+16 -7
View File
@@ -41,6 +41,14 @@ const defaultSession = {
expires: new Date().toISOString(),
} satisfies Session;
const integrationUseSession = {
...defaultSession,
user: {
...defaultSession.user,
permissions: ["integration-use-all"],
},
} satisfies Session;
// Mock the auth module to return an empty session
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
@@ -698,7 +706,8 @@ describe("duplicateBoard should preserve protected layout invariants", () => {
},
};
const caller = boardRouter.createCaller({ db, deviceType: undefined, session });
const { boardId } = await createFullBoardAsync(db, "source");
const { boardId, integrationId } = await createFullBoardAsync(db, "source");
await db.delete(integrationItems).where(eq(integrationItems.integrationId, integrationId));
const result = await caller.duplicateBoard({ id: boardId, name: "copy" });
@@ -1195,7 +1204,7 @@ describe("saveBoard should save full board", () => {
it("should remove integration reference when not present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: integrationUseSession });
const anotherIntegration = {
id: createId(),
kind: "adGuardHome",
@@ -1219,8 +1228,8 @@ describe("saveBoard should save full board", () => {
items: [
{
id: itemId,
kind: "clock",
options: { is24HourFormat: true },
kind: "dnsHoleSummary",
options: { usePiHoleColors: true, layout: "grid" },
integrationIds: [anotherIntegration.id],
layouts: [
{
@@ -1526,7 +1535,7 @@ describe("saveBoard should save full board", () => {
it("should add integration reference when present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: integrationUseSession });
const integration = {
id: createId(),
kind: "plex",
@@ -1550,8 +1559,8 @@ describe("saveBoard should save full board", () => {
items: [
{
id: itemId,
kind: "clock",
options: { is24HourFormat: true },
kind: "mediaServer",
options: { showOnlyPlaying: true, showBitrate: true, showLocation: true },
integrationIds: [integration.id],
layouts: [
{
@@ -9,8 +9,7 @@ import { DEFAULT_TIMETABLE_BASE_URL, normalizeTimetableBaseUrl } from "@homarr/r
const timetableOptionsSchema = z.object({ baseUrl: z.string().optional() }).passthrough();
const blockedTimetableIpv4Addresses = new BlockList();
const blockedTimetableIpv6Addresses = new BlockList();
const blockedTimetableAddresses = new BlockList();
const blockedTimetableSubnets = [
["0.0.0.0", 8, "ipv4"],
["10.0.0.0", 8, "ipv4"],
@@ -45,8 +44,7 @@ const blockedTimetableSubnets = [
] as const;
for (const [network, prefix, family] of blockedTimetableSubnets) {
const blockList = family === "ipv4" ? blockedTimetableIpv4Addresses : blockedTimetableIpv6Addresses;
blockList.addSubnet(network, prefix, family);
blockedTimetableAddresses.addSubnet(network, prefix, family);
}
export const normalizeTimetableBaseUrlOrThrowBadRequest = (baseUrl: string) => {
@@ -61,39 +59,28 @@ export const normalizeTimetableBaseUrlOrThrowBadRequest = (baseUrl: string) => {
}
};
const throwInvalidSavedTimetableConfiguration = (cause: unknown): never => {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Timetable widget configuration is invalid",
cause,
});
};
export const parseSavedTimetableOptions = (serializedOptions: string) => {
export const getSavedTimetableBaseUrl = (serializedOptions: string) => {
try {
return timetableOptionsSchema.parse(parse<unknown>(serializedOptions));
const options = timetableOptionsSchema.parse(parse<unknown>(serializedOptions));
return normalizeTimetableBaseUrl(options.baseUrl ?? DEFAULT_TIMETABLE_BASE_URL);
} catch (cause) {
return throwInvalidSavedTimetableConfiguration(cause);
}
};
export const normalizeSavedTimetableBaseUrl = (baseUrl: string) => {
try {
return normalizeTimetableBaseUrl(baseUrl);
} catch (cause) {
return throwInvalidSavedTimetableConfiguration(cause);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Timetable widget configuration is invalid",
cause,
});
}
};
const normalizeHostname = (hostname: string) => {
const withoutBrackets = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
let withoutBrackets = hostname;
if (hostname.startsWith("[") && hostname.endsWith("]")) withoutBrackets = hostname.slice(1, -1);
return withoutBrackets.toLowerCase().replace(/\.$/, "");
};
const isBlockedTimetableAddress = (address: string, family: number) => {
return family === 6
? blockedTimetableIpv6Addresses.check(address, "ipv6")
: blockedTimetableIpv4Addresses.check(address, "ipv4");
if (family === 6) return blockedTimetableAddresses.check(address, "ipv6");
return blockedTimetableAddresses.check(address, "ipv4");
};
export const resolvePublicTimetableAddressesAsync = async (baseUrl: string): Promise<TimetableResolvedAddress[]> => {
@@ -115,10 +102,8 @@ export const resolvePublicTimetableAddressesAsync = async (baseUrl: string): Pro
let addresses: TimetableResolvedAddress[];
try {
const resolvedAddresses =
hostnameFamily === 0
? await lookup(hostname, { all: true, verbatim: true })
: [{ address: hostname, family: hostnameFamily }];
let resolvedAddresses = [{ address: hostname, family: hostnameFamily }];
if (hostnameFamily === 0) resolvedAddresses = await lookup(hostname, { all: true, verbatim: true });
addresses = resolvedAddresses.filter(
(entry): entry is TimetableResolvedAddress => entry.family === 4 || entry.family === 6,
);
@@ -75,6 +75,8 @@ export const anchorNotesRouter = createTRPCRouter({
...(input.title !== undefined ? { title: input.title } : {}),
...(normalizedContent !== undefined ? { content: normalizedContent } : {}),
});
anchorNotesListRequestHandler.invalidateCache();
await anchorNoteRequestHandler.invalidateCacheAsync([ctx.integration.id]);
return updatedNote;
} catch (error) {
+4 -22
View File
@@ -11,10 +11,7 @@ import {
} from "@homarr/request-handler/beszel";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import {
createManySharedWidgetIntegrationMiddleware,
createManyWidgetIntegrationMiddleware,
} from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { BoundedAsyncQueue } from "./bounded-async-queue";
@@ -30,12 +27,7 @@ export const beszelRouter = createTRPCRouter({
"Get all Beszel-monitored systems with CPU, memory, disk, GPU, network, temperature, and status. REQUIRED: integrationIds (array of Beszel integration IDs from integration_all)",
},
})
.concat(
createManySharedWidgetIntegrationMiddleware("query", "beszelSystemGrid", [
"beszelSystemTable",
"beszelSystemStats",
]),
)
.concat(createManyWidgetIntegrationMiddleware("query", "beszelSystemGrid"))
.query(async ({ ctx }) => {
const integrationIds = ctx.integrations.map((i) => i.id);
logger.debug("getSystems called", { userId: ctx.session?.user?.id, integrationIds });
@@ -153,12 +145,7 @@ export const beszelRouter = createTRPCRouter({
"Get historical Beszel system metrics (CPU, memory, disk, network, temperature) and optional Docker container stats. REQUIRED: integrationIds (pass the single integrationId from the beszel_getSystems entry containing the target system — only the first ID is used), systemId (from beszel_getSystems). OPTIONAL: timePeriod (1m/1h/12h/24h/1w/30d, default 1h), includeDocker (default true)",
},
})
.concat(
createManySharedWidgetIntegrationMiddleware("query", "beszelSystemStats", [
"beszelSystemTable",
"beszelSystemGrid",
]),
)
.concat(createManyWidgetIntegrationMiddleware("query", "beszelSystemStats"))
.input(
z.object({
systemId: z.string(),
@@ -209,12 +196,7 @@ export const beszelRouter = createTRPCRouter({
}),
subscribeSystemStats: publicProcedure
.concat(
createManySharedWidgetIntegrationMiddleware("query", "beszelSystemStats", [
"beszelSystemTable",
"beszelSystemGrid",
]),
)
.concat(createManyWidgetIntegrationMiddleware("query", "beszelSystemStats"))
.input(
z.object({
systemId: z.string(),
+4 -2
View File
@@ -6,7 +6,7 @@ import type { DnsHoleSummary } from "@homarr/integrations/types";
import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole";
import {
createManySharedWidgetIntegrationMiddleware,
createManyWidgetIntegrationMiddleware,
createOneWidgetIntegrationMiddleware,
} from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
@@ -34,7 +34,7 @@ export const dnsHoleRouter = createTRPCRouter({
"Get DNS blocking statistics from Pi-hole/AdGuard (queries, blocked, percentage). REQUIRED: integrationIds (array of Pi-hole/AdGuard integration IDs from integration_all)",
},
})
.concat(createManySharedWidgetIntegrationMiddleware("query", "dnsHoleSummary", ["dnsHoleControls"]))
.concat(createManyWidgetIntegrationMiddleware("query", "dnsHoleSummary"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries<(typeof ctx.integrations)[number], DnsHoleSummaryResult>(
ctx.integrations,
@@ -72,6 +72,7 @@ export const dnsHoleRouter = createTRPCRouter({
.mutation(async ({ ctx: { integration } }) => {
const client = await createIntegrationAsync(integration);
await client.enableAsync();
await dnsHoleRequestHandler.invalidateCacheAsync([integration.id]);
}),
disable: protectedProcedure
@@ -91,5 +92,6 @@ export const dnsHoleRouter = createTRPCRouter({
.mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration);
await client.disableAsync(input.duration);
await dnsHoleRequestHandler.invalidateCacheAsync([integration.id]);
}),
});
+18 -10
View File
@@ -38,6 +38,14 @@ type DownloadClientQueryResult = DownloadClientQueryResultBase &
}
);
const runDownloadMutationsAsync = async (mutations: Promise<void>[], integrationIds: string[]) => {
const results = await Promise.allSettled(mutations);
await downloadClientRequestHandler.invalidateCacheAsync(integrationIds);
const failure = results.find((result) => result.status === "rejected");
if (failure) throw failure.reason;
};
export const downloadsRouter = createTRPCRouter({
getJobsAndStatuses: publicProcedure
.meta({
@@ -84,25 +92,25 @@ export const downloadsRouter = createTRPCRouter({
})
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
.mutation(async ({ ctx }) => {
await Promise.all(
await runDownloadMutationsAsync(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.pauseQueueAsync();
}),
ctx.integrations.map(({ id }) => id),
);
downloadClientRequestHandler.invalidateCache();
}),
pauseItem: protectedProcedure
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
.input(z.object({ item: downloadClientItemSchema }))
.mutation(async ({ ctx, input }) => {
await Promise.all(
await runDownloadMutationsAsync(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.pauseItemAsync(input.item);
}),
ctx.integrations.map(({ id }) => id),
);
downloadClientRequestHandler.invalidateCache();
}),
resume: protectedProcedure
.meta({
@@ -114,36 +122,36 @@ export const downloadsRouter = createTRPCRouter({
})
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
.mutation(async ({ ctx }) => {
await Promise.all(
await runDownloadMutationsAsync(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.resumeQueueAsync();
}),
ctx.integrations.map(({ id }) => id),
);
downloadClientRequestHandler.invalidateCache();
}),
resumeItem: protectedProcedure
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
.input(z.object({ item: downloadClientItemSchema }))
.mutation(async ({ ctx, input }) => {
await Promise.all(
await runDownloadMutationsAsync(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.resumeItemAsync(input.item);
}),
ctx.integrations.map(({ id }) => id),
);
downloadClientRequestHandler.invalidateCache();
}),
deleteItem: protectedProcedure
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
.input(z.object({ item: downloadClientItemSchema, fromDisk: z.boolean() }))
.mutation(async ({ ctx, input }) => {
await Promise.all(
await runDownloadMutationsAsync(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.deleteItemAsync(input.item, input.fromDisk);
}),
ctx.integrations.map(({ id }) => id),
);
downloadClientRequestHandler.invalidateCache();
}),
});
@@ -1,24 +0,0 @@
// This file is generated by pnpm widgets:sync. Do not edit it directly.
import type { AnyTRPCRouter } from "@trpc/server";
import type { WidgetKind } from "@homarr/definitions";
const registerGeneratedWidgetRouter = <TRouter extends AnyTRPCRouter>(
widgetKinds: readonly [WidgetKind, ...WidgetKind[]],
loadRouter: () => Promise<TRouter>,
mcp: false | true | string,
) => ({ widgetKinds, loadRouter, mcp });
export const generatedWidgetRouterRegistry = {
downloads: registerGeneratedWidgetRouter(
["downloads"],
() => import("./downloads").then((module) => module.downloadsRouter),
true,
),
weather: registerGeneratedWidgetRouter(
["clock", "weather"],
() => import("./weather").then((module) => module.weatherRouter),
false,
),
};
@@ -2,11 +2,7 @@ import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { SystemHealthMonitoring } from "@homarr/integrations";
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
import {
createManySharedWidgetIntegrationMiddleware,
createOneSharedWidgetIntegrationMiddleware,
createOneWidgetIntegrationMiddleware,
} from "../../middlewares/integration";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -39,14 +35,7 @@ export const healthMonitoringRouter = createTRPCRouter({
"Get system health status (CPU, memory, disk, network) from NAS/server monitoring integrations. REQUIRED: integrationIds (array of TrueNAS/Synology/Unraid/Glances/OpenMediaVault/DashDot integration IDs from integration_all)",
},
})
.concat(
createManySharedWidgetIntegrationMiddleware(
"query",
"healthMonitoring",
["systemResources", "systemDisks"],
healthMonitoringIntegrationKinds,
),
)
.concat(createManyIntegrationMiddleware("query", ...healthMonitoringIntegrationKinds))
.query(async ({ ctx }) => {
return await settleIntegrationQueries<(typeof ctx.integrations)[number], SystemHealthQueryResult>(
ctx.integrations,
@@ -78,7 +67,7 @@ export const healthMonitoringRouter = createTRPCRouter({
"List storage volumes from a Synology DiskStation integration for widget configuration. REQUIRED: integrationId from integration_all (Synology integration only)",
},
})
.concat(createOneSharedWidgetIntegrationMiddleware("query", "healthMonitoring", ["systemDisks"], ["synology"]))
.concat(createOneIntegrationMiddleware("query", "synology"))
.query(async ({ ctx }) => {
const integrationInstance = await createIntegrationAsync(ctx.integration);
return await integrationInstance.listStorageVolumesAsync();
@@ -91,7 +80,7 @@ export const healthMonitoringRouter = createTRPCRouter({
"Get Proxmox cluster health status including nodes, VMs, and resource usage. REQUIRED: integrationId (single Proxmox integration ID from integration_all)",
},
})
.concat(createOneWidgetIntegrationMiddleware("query", "healthMonitoring", ["proxmox", "mock"]))
.concat(createOneIntegrationMiddleware("query", "proxmox", "mock"))
.query(async ({ ctx }) => {
const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
const { data } = await innerHandler.getDataAsync();
+2 -5
View File
@@ -6,10 +6,7 @@ import {
immichStatsRequestHandler,
} from "@homarr/request-handler/immich";
import {
createOneSharedWidgetIntegrationMiddleware,
createOneWidgetIntegrationMiddleware,
} from "../../middlewares/integration";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const immichRouter = createTRPCRouter({
@@ -40,7 +37,7 @@ export const immichRouter = createTRPCRouter({
limit: z.number().int().min(1).max(500).optional(),
}),
)
.concat(createOneSharedWidgetIntegrationMiddleware("query", "immich-albumCarousel", ["immich-serverStats"]))
.concat(createOneWidgetIntegrationMiddleware("query", "immich-albumCarousel"))
.query(async ({ ctx, input }) => {
const innerHandler = immichAlbumsRequestHandler.handler(ctx.integration, { limit: input.limit });
const data = await innerHandler.getDataAsync();
+42 -23
View File
@@ -1,30 +1,49 @@
import type { AnyTRPCRouter } from "@trpc/server";
import { lazy } from "@trpc/server";
import { createTRPCRouter } from "../../trpc";
import { widgetRouterRegistry } from "./registry";
import type { WidgetRouterName } from "./registry";
const createLazyWidgetRouter = <TRouter extends AnyTRPCRouter>(loadRouter: () => Promise<TRouter>) => lazy(loadRouter);
const lazyWidgetRouters = Object.fromEntries(
Object.entries(widgetRouterRegistry).map(([name, registration]) => [
name,
createLazyWidgetRouter(registration.loadRouter as () => Promise<AnyTRPCRouter>),
]),
) as unknown as {
[TName in WidgetRouterName]: ReturnType<
typeof createLazyWidgetRouter<
(typeof widgetRouterRegistry)[TName] extends { loadRouter: () => Promise<infer TRouter> }
? TRouter extends AnyTRPCRouter
? TRouter
: never
: never
>
>;
};
export const widgetRouter = createTRPCRouter({
...lazyWidgetRouters,
archiveTeamWarrior: lazy(() => import("./archive-team-warrior").then((mod) => mod.archiveTeamWarriorRouter)),
beszel: lazy(() => import("./beszel").then((mod) => mod.beszelRouter)),
anchorNotes: lazy(() => import("./anchor-notes").then((mod) => mod.anchorNotesRouter)),
coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)),
immich: lazy(() => import("./immich").then((mod) => mod.immichRouter)),
paperlessNgx: lazy(() => import("./paperless-ngx").then((mod) => mod.paperlessNgxRouter)),
patchmon: lazy(() => import("./patchmon").then((mod) => mod.patchmonRouter)),
bazarr: lazy(() => import("./bazarr").then((mod) => mod.bazarrRouter)),
notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)),
weather: lazy(() => import("./weather").then((mod) => mod.weatherRouter)),
airQuality: lazy(() => import("./air-quality").then((mod) => mod.airQualityRouter)),
app: lazy(() => import("./app").then((mod) => mod.appRouter)),
dnsHole: lazy(() => import("./dns-hole").then((mod) => mod.dnsHoleRouter)),
smartHome: lazy(() => import("./smart-home").then((mod) => mod.smartHomeRouter)),
stockPrice: lazy(() => import("./stocks").then((mod) => mod.stockPriceRouter)),
mediaServer: lazy(() => import("./media-server").then((mod) => mod.mediaServerRouter)),
mediaRelease: lazy(() => import("./media-release").then((mod) => mod.mediaReleaseRouter)),
calendar: lazy(() => import("./calendar").then((mod) => mod.calendarRouter)),
downloads: lazy(() => import("./downloads").then((mod) => mod.downloadsRouter)),
mediaRequests: lazy(() => import("./media-requests").then((mod) => mod.mediaRequestsRouter)),
mediaOrganizer: lazy(() => import("./media-organizer").then((mod) => mod.mediaOrganizerRouter)),
rssFeed: lazy(() => import("./rssFeed").then((mod) => mod.rssFeedRouter)),
indexerManager: lazy(() => import("./indexer-manager").then((mod) => mod.indexerManagerRouter)),
healthMonitoring: lazy(() => import("./health-monitoring").then((mod) => mod.healthMonitoringRouter)),
mediaTranscoding: lazy(() => import("./media-transcoding").then((mod) => mod.mediaTranscodingRouter)),
minecraft: lazy(() => import("./minecraft").then((mod) => mod.minecraftRouter)),
options: lazy(() => import("./options").then((mod) => mod.optionsRouter)),
releases: lazy(() => import("./releases").then((mod) => mod.releasesRouter)),
networkController: lazy(() => import("./network-controller").then((mod) => mod.networkControllerRouter)),
firewall: lazy(() => import("./firewall").then((mod) => mod.firewallRouter)),
notifications: lazy(() => import("./notifications").then((mod) => mod.notificationsRouter)),
timetable: lazy(() => import("./timetable").then((mod) => mod.timetableRouter)),
tracearr: lazy(() => import("./tracearr").then((mod) => mod.tracearrRouter)),
speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)),
uptimeKuma: lazy(() => import("./uptime-kuma").then((mod) => mod.uptimeKumaRouter)),
audioStats: lazy(() => import("./audio-stats").then((mod) => mod.audioStatsRouter)),
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
vpn: lazy(() => import("./vpn").then((mod) => mod.vpnRouter)),
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
traefik: lazy(() => import("./traefik").then((mod) => mod.traefikRouter)),
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)),
wud: lazy(() => import("./wud").then((mod) => mod.wudRouter)),
});
@@ -152,6 +152,8 @@ export const mediaRequestsRouter = createTRPCRouter({
} as const;
await answerActions[input.answer](input.requestId);
mediaRequestListRequestHandler.invalidateCache();
await mediaRequestStatsRequestHandler.invalidateCacheAsync([integration.id]);
return { success: true };
}),
});
@@ -2,7 +2,7 @@ import { z } from "zod/v4";
import { mediaServerRequestHandler } from "@homarr/request-handler/media-server";
import { createManySharedWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -15,7 +15,7 @@ export const mediaServerRouter = createTRPCRouter({
"Get currently active streams from Plex/Jellyfin/Emby/Navidrome media servers. REQUIRED: integrationIds (array of media server integration IDs from integration_all), showOnlyPlaying (boolean — true to filter to actively playing streams only)",
},
})
.concat(createManySharedWidgetIntegrationMiddleware("query", "mediaServer", ["audioStats"]))
.concat(createManyWidgetIntegrationMiddleware("query", "mediaServer"))
.input(z.object({ showOnlyPlaying: z.boolean() }))
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries(
@@ -2,7 +2,7 @@ import type { IntegrationKindByCategory } from "@homarr/definitions";
import type { NetworkControllerSummary } from "@homarr/integrations/types";
import { networkControllerRequestHandler } from "@homarr/request-handler/network-controller";
import { createManySharedWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -21,9 +21,7 @@ interface NetworkControllerQueryResult {
export const networkControllerRouter = createTRPCRouter({
summary: publicProcedure
.concat(
createManySharedWidgetIntegrationMiddleware("query", "networkControllerSummary", ["networkControllerStatus"]),
)
.concat(createManyWidgetIntegrationMiddleware("query", "networkControllerSummary"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries<(typeof ctx.integrations)[number], NetworkControllerQueryResult>(
ctx.integrations,
-225
View File
@@ -1,225 +0,0 @@
import type { AnyTRPCRouter } from "@trpc/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { WidgetKind } from "@homarr/definitions";
import { generatedWidgetRouterRegistry } from "./generated-registry";
const {
weather: generatedWeatherRouter,
downloads: generatedDownloadsRouter,
...additionalGeneratedWidgetRouters
} = generatedWidgetRouterRegistry;
const logger = createLogger({ module: "widgetRouterRegistry" });
type McpRouterNamespace = false | true | string;
type WidgetRouterRegistration<TRouter extends AnyTRPCRouter, TMcpNamespace extends McpRouterNamespace> = {
widgetKinds: readonly [WidgetKind, ...WidgetKind[]];
loadRouter: () => Promise<TRouter>;
mcp: TMcpNamespace;
};
const registerWidgetRouter = <TRouter extends AnyTRPCRouter, const TMcpNamespace extends McpRouterNamespace = false>(
widgetKinds: readonly [WidgetKind, ...WidgetKind[]],
loadRouter: () => Promise<TRouter>,
options?: { mcp?: TMcpNamespace },
): WidgetRouterRegistration<TRouter, TMcpNamespace> => ({
widgetKinds,
loadRouter,
mcp: (options?.mcp ?? false) as TMcpNamespace,
});
/**
* Shared namespace-to-feature contract for widget tRPC consumers.
*
* Keep import paths explicit so Next.js can split every implementation while
* consumers share one widget-to-router association.
*/
export const widgetRouterRegistry = {
archiveTeamWarrior: registerWidgetRouter(["archiveTeamWarrior"], () =>
import("./archive-team-warrior").then((module) => module.archiveTeamWarriorRouter),
),
beszel: registerWidgetRouter(
["beszelSystemTable", "beszelSystemGrid", "beszelAlerts", "beszelSystemStats"],
() => import("./beszel").then((module) => module.beszelRouter),
{ mcp: true },
),
anchorNotes: registerWidgetRouter(["anchorNote"], () =>
import("./anchor-notes").then((module) => module.anchorNotesRouter),
),
coolify: registerWidgetRouter(["coolify"], () => import("./coolify").then((module) => module.coolifyRouter)),
immich: registerWidgetRouter(["immich-serverStats", "immich-albumCarousel"], () =>
import("./immich").then((module) => module.immichRouter),
),
paperlessNgx: registerWidgetRouter(["paperlessNgx"], () =>
import("./paperless-ngx").then((module) => module.paperlessNgxRouter),
),
patchmon: registerWidgetRouter(["patchmon"], () => import("./patchmon").then((module) => module.patchmonRouter), {
mcp: true,
}),
bazarr: registerWidgetRouter(["bazarr"], () => import("./bazarr").then((module) => module.bazarrRouter), {
mcp: true,
}),
notebook: registerWidgetRouter(["notebook"], () => import("./notebook").then((module) => module.notebookRouter)),
weather: generatedWeatherRouter,
airQuality: registerWidgetRouter(
["airQuality"],
() => import("./air-quality").then((module) => module.airQualityRouter),
{ mcp: true },
),
app: registerWidgetRouter(["app"], () => import("./app").then((module) => module.appRouter)),
dnsHole: registerWidgetRouter(
["dnsHoleSummary", "dnsHoleControls"],
() => import("./dns-hole").then((module) => module.dnsHoleRouter),
{ mcp: true },
),
smartHome: registerWidgetRouter(
["smartHome-entityState", "smartHome-executeAutomation"],
() => import("./smart-home").then((module) => module.smartHomeRouter),
{ mcp: true },
),
stockPrice: registerWidgetRouter(["stockPrice"], () => import("./stocks").then((module) => module.stockPriceRouter)),
mediaServer: registerWidgetRouter(
["mediaServer", "audioStats"],
() => import("./media-server").then((module) => module.mediaServerRouter),
{ mcp: true },
),
mediaRelease: registerWidgetRouter(["mediaReleases"], () =>
import("./media-release").then((module) => module.mediaReleaseRouter),
),
calendar: registerWidgetRouter(["calendar"], () => import("./calendar").then((module) => module.calendarRouter), {
mcp: true,
}),
downloads: generatedDownloadsRouter,
mediaRequests: registerWidgetRouter(
["mediaRequests-requestList", "mediaRequests-requestStats"],
() => import("./media-requests").then((module) => module.mediaRequestsRouter),
{ mcp: true },
),
mediaOrganizer: registerWidgetRouter(["mediaMissing"], () =>
import("./media-organizer").then((module) => module.mediaOrganizerRouter),
),
rssFeed: registerWidgetRouter(["rssFeed"], () => import("./rssFeed").then((module) => module.rssFeedRouter)),
indexerManager: registerWidgetRouter(["indexerManager"], () =>
import("./indexer-manager").then((module) => module.indexerManagerRouter),
),
healthMonitoring: registerWidgetRouter(
["healthMonitoring", "systemResources", "systemDisks"],
() => import("./health-monitoring").then((module) => module.healthMonitoringRouter),
{ mcp: true },
),
mediaTranscoding: registerWidgetRouter(["mediaTranscoding"], () =>
import("./media-transcoding").then((module) => module.mediaTranscodingRouter),
),
minecraft: registerWidgetRouter(["minecraftServerStatus"], () =>
import("./minecraft").then((module) => module.minecraftRouter),
),
releases: registerWidgetRouter(["releases"], () => import("./releases").then((module) => module.releasesRouter)),
networkController: registerWidgetRouter(["networkControllerSummary", "networkControllerStatus"], () =>
import("./network-controller").then((module) => module.networkControllerRouter),
),
firewall: registerWidgetRouter(["firewall"], () => import("./firewall").then((module) => module.firewallRouter)),
notifications: registerWidgetRouter(["notifications"], () =>
import("./notifications").then((module) => module.notificationsRouter),
),
timetable: registerWidgetRouter(["timetable"], () => import("./timetable").then((module) => module.timetableRouter)),
tracearr: registerWidgetRouter(["tracearr"], () => import("./tracearr").then((module) => module.tracearrRouter)),
speedtestTracker: registerWidgetRouter(["speedtestTracker"], () =>
import("./speedtest-tracker").then((module) => module.speedtestTrackerRouter),
),
uptimeKuma: registerWidgetRouter(["uptimeKuma"], () =>
import("./uptime-kuma").then((module) => module.uptimeKumaRouter),
),
audioStats: registerWidgetRouter(["audioStats"], () =>
import("./audio-stats").then((module) => module.audioStatsRouter),
),
umami: registerWidgetRouter(["umami"], () => import("./umami").then((module) => module.umamiRouter)),
vpn: registerWidgetRouter(["vpn"], () => import("./vpn").then((module) => module.vpnRouter)),
ups: registerWidgetRouter(["ups"], () => import("./ups").then((module) => module.upsRouter)),
traefik: registerWidgetRouter(["traefik"], () => import("./traefik").then((module) => module.traefikRouter)),
customApi: registerWidgetRouter(["customApi"], () => import("./custom-api").then((module) => module.customApiRouter)),
secrets: registerWidgetRouter(
["releases"],
() => import("./widget-secrets").then((module) => module.widgetSecretsRouter),
{ mcp: "widgetSecrets" },
),
wud: registerWidgetRouter(["wud"], () => import("./wud").then((module) => module.wudRouter)),
...additionalGeneratedWidgetRouters,
};
export type WidgetRouterName = keyof typeof widgetRouterRegistry;
type LoadedWidgetRouterRecord = {
[TName in WidgetRouterName]: Awaited<ReturnType<(typeof widgetRouterRegistry)[TName]["loadRouter"]>>;
};
interface McpWidgetRouterSelection {
name: WidgetRouterName;
namespace: string;
}
export const mcpWidgetRouterSelections: readonly McpWidgetRouterSelection[] = Object.entries(
widgetRouterRegistry,
).flatMap(([name, registration]) => {
if (!registration.mcp) return [];
let namespace = name;
if (typeof registration.mcp === "string") namespace = registration.mcp;
return [{ name: name as WidgetRouterName, namespace }];
});
export const mcpWidgetRouterNames: readonly WidgetRouterName[] = mcpWidgetRouterSelections.map(
(selection) => selection.name,
);
export async function loadWidgetRoutersAsync<const TNames extends readonly WidgetRouterName[]>(
names: TNames,
consumer: string,
): Promise<Pick<LoadedWidgetRouterRecord, TNames[number]>> {
const loadedRouters: Partial<Record<WidgetRouterName, AnyTRPCRouter>> = {};
await Promise.all(
names.map(async (name) => {
const registration = widgetRouterRegistry[name];
const startedAt = Date.now();
try {
loadedRouters[name] = await registration.loadRouter();
logger.debug("Loaded widget router", {
consumer,
routerName: name,
widgetKinds: registration.widgetKinds,
durationMs: Date.now() - startedAt,
});
} catch (error) {
logger.error(
new ErrorWithMetadata(
"Failed to load widget router",
{
consumer,
routerName: name,
widgetKinds: registration.widgetKinds,
durationMs: Date.now() - startedAt,
},
{ cause: error },
),
);
throw error;
}
}),
);
return loadedRouters as Pick<LoadedWidgetRouterRecord, TNames[number]>;
}
export async function loadMcpWidgetRoutersAsync(): Promise<Record<string, AnyTRPCRouter>> {
const loadedRouters = await loadWidgetRoutersAsync(mcpWidgetRouterNames, "mcp");
const mcpRouters: Record<string, AnyTRPCRouter> = {};
for (const selection of mcpWidgetRouterSelections) {
mcpRouters[selection.namespace] = loadedRouters[selection.name];
}
return mcpRouters;
}
@@ -57,6 +57,7 @@ export const smartHomeRouter = createTRPCRouter({
if (!success) {
throw new TRPCError({ code: "BAD_GATEWAY", message: "Home Assistant did not toggle the entity" });
}
await smartHomeEntityStateRequestHandler.invalidateCacheAsync([integration.id]);
return true;
}),
executeAutomation: protectedProcedure
@@ -75,6 +76,7 @@ export const smartHomeRouter = createTRPCRouter({
if (!success) {
throw new TRPCError({ code: "BAD_GATEWAY", message: "Home Assistant did not execute the automation" });
}
await smartHomeEntityStateRequestHandler.invalidateCacheAsync([integration.id]);
return true;
}),
});
+1 -7
View File
@@ -15,9 +15,8 @@ import { DEFAULT_TIMETABLE_BASE_URL } from "@homarr/request-handler/timetable-ur
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
import {
normalizeSavedTimetableBaseUrl,
getSavedTimetableBaseUrl,
normalizeTimetableBaseUrlOrThrowBadRequest,
parseSavedTimetableOptions,
resolvePublicTimetableAddressesAsync,
} from "../timetable-options-validation";
@@ -27,11 +26,6 @@ const timetableSourceSchema = z.object({
itemId: z.string().max(100).optional(),
boardId: z.string().max(100).optional(),
});
const getSavedTimetableBaseUrl = (serializedOptions: string) => {
const options = parseSavedTimetableOptions(serializedOptions);
return normalizeSavedTimetableBaseUrl(options.baseUrl ?? DEFAULT_TIMETABLE_BASE_URL);
};
interface ResolvedTimetableSource {
baseUrl: string;
pinnedAddresses?: TimetableResolvedAddress[];
+1 -3
View File
@@ -2,13 +2,11 @@ import type { TRPCError } from "@trpc/server";
import { describe, expect, test, vi } from "vitest";
import { z } from "zod/v4";
import { getMcpRouterAsync } from "../mcp";
import { mcpRouter } from "../mcp";
import { extractMcpToolsFromProcedures } from "../mcp-tools";
vi.mock("@homarr/auth", () => ({}));
const mcpRouter = await getMcpRouterAsync();
const MCP_TOOL_ALLOWLIST = {
query: [
"airQuality_atLocation",
+2 -50
View File
@@ -16,7 +16,6 @@ import { extractBaseUrlFromHeaders, FlattenError } from "@homarr/common";
import { userAgent } from "@homarr/common/server";
import type { DeviceType } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { db } from "@homarr/db";
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
@@ -106,13 +105,6 @@ export const createCallerFactory = t.createCallerFactory;
*/
export const createTRPCRouter = t.router;
/**
* Unadorned procedure builder for middleware fragments that are concatenated
* onto a public, protected, or internal procedure. Global middleware belongs
* on the outer procedure so observability and policy checks run exactly once.
*/
export const middlewareProcedure = t.procedure;
export const isDemoMode = env.DEMO_MODE;
// The public "real demo" (demo + mock integrations) stays read-only, while preview
@@ -129,47 +121,7 @@ const enforceDemoModeReadOnly = t.middleware(({ ctx, next, type }) => {
return next({ ctx });
});
const observeProcedure = t.middleware(async ({ ctx, next, path, type }) => {
const startedAt = Date.now();
const metadata = {
path,
type,
userId: ctx.session?.user.id,
};
logger.debug("tRPC procedure started", metadata);
const result = await next();
const durationMs = Date.now() - startedAt;
if (result.ok) {
logger.debug("tRPC procedure completed", { ...metadata, durationMs });
return result;
}
const safeError = new Error(result.error.message);
safeError.name = result.error.name;
safeError.stack = result.error.stack;
logger.warn(
new ErrorWithMetadata(
"tRPC procedure failed",
{
...metadata,
durationMs,
errorCode: result.error.code,
causeType: result.error.cause instanceof Error ? result.error.cause.name : undefined,
},
{ cause: safeError },
),
);
return result;
});
let baseProcedure = t.procedure.use(observeProcedure);
if (isDemoReadOnly) {
baseProcedure = baseProcedure.use(enforceDemoModeReadOnly);
}
const baseProcedure = isDemoReadOnly ? t.procedure.use(enforceDemoModeReadOnly) : t.procedure;
/**
* Public (unauthed) procedure
@@ -193,7 +145,7 @@ const enforceOnboardingAccess = t.middleware(async ({ ctx, next }) => {
export const onboardingClaimedProcedure = baseProcedure.use(enforceOnboardingAccess);
export const internalProcedure = t.procedure.use(observeProcedure);
export const internalProcedure = t.procedure;
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) {