feat(custom-widgets): complete Custom JSX v2 (#6390)

This commit is contained in:
Thomas authored and ajnart committed 2026-08-13 19:48:57 +02:00
1 parent 3a1245cbc5
commit acdfcbb18e
416 files changed
+37853 -18066

No files matched your search

@@ -0,0 +1,17 @@
import { createHash } from "node:crypto";
interface CacheVersionDefinition {
sources: string;
requests: string;
secrets: Array<{ sourceId: string; kind: string; encryptedValue: string }>;
}
export function getCustomWidgetCacheVersion(definition: CacheVersionDefinition) {
const secrets = definition.secrets
.map(({ sourceId, kind, encryptedValue }) => ({ sourceId, kind, encryptedValue }))
.toSorted((left, right) => `${left.sourceId}:${left.kind}`.localeCompare(`${right.sourceId}:${right.kind}`));
return createHash("sha256")
.update(JSON.stringify([definition.sources, definition.requests, secrets]))
.digest("hex")
.slice(0, 16);
}
@@ -0,0 +1,106 @@
import { createId } from "@homarr/common";
import { createRedisClient } from "@homarr/core/infrastructure/redis";
import type { RedisClient } from "@homarr/core/infrastructure/redis";
import type { CustomWidgetSecretKind } from "@homarr/custom-widgets/core";
import type { CustomWidgetSource } from "@homarr/custom-widgets/core";
const CONFIGURATION_REQUEST_TTL_MS = 10 * 60_000;
const CONFIGURATION_REQUEST_PREFIX = "custom-widget:configuration-request:";
const CONFIGURATION_REQUEST_LOCK_PREFIX = "custom-widget:configuration-request-lock:";
export interface CustomWidgetConfigurationRequest {
id: string;
userId: string;
target: { type: "definition"; id: string } | { type: "preview"; id: string };
widgetName: string;
sourceId: string;
sourceName: string;
source: CustomWidgetSource;
kinds: CustomWidgetSecretKind[];
expiresAt: number;
status: "pending" | "completed";
}
const localRequests = new Map<string, CustomWidgetConfigurationRequest>();
const localLocks = new Set<string>();
let redis: RedisClient | undefined;
function getRedis() {
if (process.env.CI !== undefined || process.env.NODE_ENV === "test") return undefined;
redis ??= createRedisClient();
return redis;
}
async function save(request: CustomWidgetConfigurationRequest) {
const ttl = Math.max(1, request.expiresAt - Date.now());
const client = getRedis();
if (client) await client.set(`${CONFIGURATION_REQUEST_PREFIX}${request.id}`, JSON.stringify(request), "PX", ttl);
else localRequests.set(request.id, request);
}
export async function createCustomWidgetConfigurationRequest(
input: Omit<CustomWidgetConfigurationRequest, "id" | "expiresAt" | "status">,
) {
const request: CustomWidgetConfigurationRequest = {
...input,
id: createId(),
expiresAt: Date.now() + CONFIGURATION_REQUEST_TTL_MS,
status: "pending",
};
await save(request);
return request;
}
export async function getCustomWidgetConfigurationRequest(id: string) {
const client = getRedis();
const candidate = client ? await client.get(`${CONFIGURATION_REQUEST_PREFIX}${id}`) : localRequests.get(id);
if (!candidate) return null;
try {
const request = (
typeof candidate === "string" ? JSON.parse(candidate) : candidate
) as CustomWidgetConfigurationRequest;
if (request.expiresAt <= Date.now()) {
if (client) await client.del(`${CONFIGURATION_REQUEST_PREFIX}${id}`);
else localRequests.delete(id);
return null;
}
return request;
} catch {
return null;
}
}
export async function getCustomWidgetConfigurationRequestForUser(id: string, userId: string) {
const request = await getCustomWidgetConfigurationRequest(id);
return request?.userId === userId ? request : null;
}
export async function completeCustomWidgetConfigurationRequest(id: string) {
const request = await getCustomWidgetConfigurationRequest(id);
if (!request || request.status !== "pending") return null;
const completed = { ...request, status: "completed" as const };
await save(completed);
await releaseCustomWidgetConfigurationRequest(id);
return completed;
}
export async function claimCustomWidgetConfigurationRequest(id: string) {
const request = await getCustomWidgetConfigurationRequest(id);
if (!request || request.status !== "pending") return null;
const client = getRedis();
if (client) {
const claimed = await client.set(`${CONFIGURATION_REQUEST_LOCK_PREFIX}${id}`, "1", "PX", 60_000, "NX");
return claimed === "OK" ? request : null;
}
if (localLocks.has(id)) return null;
localLocks.add(id);
return request;
}
export async function releaseCustomWidgetConfigurationRequest(id: string) {
const client = getRedis();
if (client) await client.del(`${CONFIGURATION_REQUEST_LOCK_PREFIX}${id}`);
else localLocks.delete(id);
}
export { setPreviewSessionSecrets } from "./preview-sessions";
@@ -1,160 +1,189 @@
import { TRPCError } from "@trpc/server";
import { stringify as stringifySuperJson } from "superjson";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server";
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
import { eq } from "@homarr/db";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { and, eq, handleTransactionsAsync, notInArray } from "@homarr/db";
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
import {
customWidgetCreateSchema,
customWidgetDefinitionSchema,
customWidgetUpdateSchema,
customJsxDisplayConfigV2Schema,
} from "@homarr/custom-widgets/core";
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
import { insertCustomWidgetDefinition } from "./definition-insert";
import { managementQueryProcedures } from "./management-queries";
import { metadataProcedures } from "./metadata-procedures";
import { previewActionProcedures } from "./preview-action-procedures";
import { previewBaseProcedures } from "./preview-base-procedures";
import { previewQueryProcedures } from "./preview-query-procedures";
import { parseDisplayConfig } from "./parse-display-config";
import { transferProcedures } from "./transfer-procedures";
import {
parseStoredCustomWidgetDefinition,
safeParseStoredCustomWidgetDefinition,
serializeCustomWidgetDefinition,
} from "./stored-definition";
import { templateProcedures } from "./template-procedures";
import { transferProcedures } from "./transfer-procedures";
import { assertSecretSources, requiredSecretKinds } from "./secret-policy";
import { secretProcedures } from "./secret-procedures";
import { workshopProcedures } from "./workshop-procedures";
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
const logger = createLogger({ module: "custom-widget" });
const updateFieldSerializers: Record<string, (value: unknown) => unknown> = {
displayConfig: (value) => stringifySuperJson(value),
};
export const customWidgetRouter = createTRPCRouter({
...metadataProcedures,
...managementQueryProcedures,
create: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Create a validated custom widget. Admin only. Call customWidget_schema and customWidget_validate first. For Custom JSX use jsxApiVersion 2, named requests, a GET base method, and no inline credentials.",
},
})
create: manageProcedure
.meta({ mcp: { enabled: true, description: "Create one validated Custom JSX widget." } })
.input(customWidgetCreateSchema)
.mutation(async ({ ctx, input }) => {
const id = createId();
await ctx.db.insert(customWidgetDefinitions).values({
id,
name: input.name,
description: input.description,
iconUrl: input.iconUrl,
url: input.url,
authType: input.authType,
headerName: input.headerName,
method: input.method,
requestBody: input.requestBody,
displayType: input.displayType,
displayConfig: stringifySuperJson(input.displayConfig),
creatorId: ctx.session.user.id,
});
if (input.secrets.length > 0) {
await ctx.db.insert(customWidgetSecrets).values(
input.secrets.map((secret) => ({
kind: secret.kind,
value: encryptSecret(secret.value),
definitionId: id,
updatedAt: new Date(),
})),
);
const { secrets, ...candidate } = input;
if (secrets.length > 0 && !ctx.session.user.permissions.includes("custom-widget-secret-write")) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Writing custom widget secrets requires dedicated permission",
});
}
logger.info("Created custom widget definition", { id, name: input.name });
const definition = customWidgetDefinitionSchema.parse(candidate);
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 });
return { id };
}),
update: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Update an existing custom widget. Admin only. REQUIRED: id. Read it with customWidget_byId, preserve unrelated fields, validate the resulting complete draft, then send the changed fields. Omit secrets to preserve stored credentials.",
},
})
update: manageProcedure
.meta({ mcp: { enabled: true, description: "Update one Custom JSX widget." } })
.input(customWidgetUpdateSchema)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!existing) throw new TRPCError({ code: "NOT_FOUND" });
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND" });
}
const effectiveDisplayConfig =
input.displayConfig ??
parseDisplayConfig(existing.displayConfig, input.id, logger, "Corrupt displayConfig in custom widget update");
const effectiveMethod = input.method ?? existing.method;
if (customJsxDisplayConfigV2Schema.safeParse(effectiveDisplayConfig).success && effectiveMethod !== "GET") {
const current = parseStoredCustomWidgetDefinition(existing);
const { id, secrets, ...changes } = input;
if (secrets?.length && !ctx.session.user.permissions.includes("custom-widget-secret-write")) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Custom JSX v2 base data requests must use GET; mutations belong in named actions",
code: "FORBIDDEN",
message: "Writing custom widget secrets requires dedicated permission",
});
}
const definition = customWidgetDefinitionSchema.parse({ ...current, ...changes });
if (secrets) assertSecretSources(definition.sources, secrets);
const definitionChanges = { ...serializeCustomWidgetDefinition(definition), updatedAt: new Date() };
const secretRows = secrets?.map((secret) => ({
definitionId: id,
sourceId: secret.sourceId,
kind: secret.kind,
encryptedValue: encryptSecret(secret.value),
updatedAt: new Date(),
}));
const sourceIds = Object.keys(definition.sources);
const { id, secrets, ...updateFields } = input;
const updateValues: Record<string, unknown> = { updatedAt: new Date() };
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
await transaction
.update(schema.customWidgetDefinitions)
.set(definitionChanges)
.where(eq(schema.customWidgetDefinitions.id, id));
for (const [key, value] of Object.entries(updateFields)) {
if (value === undefined) continue;
const serialize = updateFieldSerializers[key];
if (serialize) {
updateValues[key] = serialize(value);
} else {
updateValues[key] = value;
}
}
await transaction
.delete(schema.customWidgetSecrets)
.where(
and(
eq(schema.customWidgetSecrets.definitionId, id),
notInArray(schema.customWidgetSecrets.sourceId, sourceIds),
),
);
await ctx.db.update(customWidgetDefinitions).set(updateValues).where(eq(customWidgetDefinitions.id, id));
for (const [sourceId, source] of Object.entries(definition.sources)) {
const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)];
const where = and(
eq(schema.customWidgetSecrets.definitionId, id),
eq(schema.customWidgetSecrets.sourceId, sourceId),
);
await transaction
.delete(schema.customWidgetSecrets)
.where(kinds.length > 0 ? and(where, notInArray(schema.customWidgetSecrets.kind, kinds)) : where);
}
if (secrets !== undefined) {
const effectiveAuthType = (updateFields.authType as string | undefined) ?? existing.authType;
for (const secret of secretRows ?? []) {
await transaction
.delete(schema.customWidgetSecrets)
.where(
and(
eq(schema.customWidgetSecrets.definitionId, id),
eq(schema.customWidgetSecrets.sourceId, secret.sourceId),
eq(schema.customWidgetSecrets.kind, secret.kind),
),
);
await transaction.insert(schema.customWidgetSecrets).values(secret);
}
});
},
handleSync(db) {
db.transaction((transaction) => {
transaction
.update(customWidgetDefinitions)
.set(definitionChanges)
.where(eq(customWidgetDefinitions.id, id))
.run();
if (secrets.length > 0) {
await ctx.db.delete(customWidgetSecrets).where(eq(customWidgetSecrets.definitionId, id));
await ctx.db.insert(customWidgetSecrets).values(
secrets.map((secret) => ({
kind: secret.kind,
value: encryptSecret(secret.value),
definitionId: id,
updatedAt: new Date(),
})),
);
} else if (
effectiveAuthType === "none" ||
(typeof updateFields.authType === "string" && updateFields.authType !== existing.authType)
) {
await ctx.db.delete(customWidgetSecrets).where(eq(customWidgetSecrets.definitionId, id));
}
}
transaction
.delete(customWidgetSecrets)
.where(and(eq(customWidgetSecrets.definitionId, id), notInArray(customWidgetSecrets.sourceId, sourceIds)))
.run();
for (const [sourceId, source] of Object.entries(definition.sources)) {
const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)];
const where = and(eq(customWidgetSecrets.definitionId, id), eq(customWidgetSecrets.sourceId, sourceId));
transaction
.delete(customWidgetSecrets)
.where(kinds.length > 0 ? and(where, notInArray(customWidgetSecrets.kind, kinds)) : where)
.run();
}
for (const secret of secretRows ?? []) {
transaction
.delete(customWidgetSecrets)
.where(
and(
eq(customWidgetSecrets.definitionId, id),
eq(customWidgetSecrets.sourceId, secret.sourceId),
eq(customWidgetSecrets.kind, secret.kind),
),
)
.run();
transaction.insert(customWidgetSecrets).values(secret).run();
}
});
},
});
logger.info("Updated custom widget definition", { id });
}),
toggleEnabled: adminProcedure
...secretProcedures,
...workshopProcedures,
toggleEnabled: manageProcedure
.input(z.object({ id: z.string(), enabled: z.boolean() }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND" });
if (input.enabled) {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!definition) throw new TRPCError({ code: "NOT_FOUND" });
if (!safeParseStoredCustomWidgetDefinition(definition).success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid custom widget definition cannot be enabled",
});
}
}
await ctx.db
@@ -163,66 +192,32 @@ export const customWidgetRouter = createTRPCRouter({
.where(eq(customWidgetDefinitions.id, input.id));
}),
delete: adminProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
delete: manageProcedure
.meta({ mcp: { enabled: true, description: "Delete one Custom JSX widget." } })
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.db.delete(customWidgetDefinitions).where(eq(customWidgetDefinitions.id, input.id));
logger.info("Deleted custom widget definition", { id: input.id });
}),
duplicate: manageProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND" });
}
await ctx.db.delete(customWidgetDefinitions).where(eq(customWidgetDefinitions.id, input.id));
logger.info("Deleted custom widget definition", { id: input.id });
if (!existing) throw new TRPCError({ code: "NOT_FOUND" });
const current = parseStoredCustomWidgetDefinition(existing);
const id = createId();
await ctx.db.insert(customWidgetDefinitions).values({
id,
...serializeCustomWidgetDefinition({ ...current, name: `${current.name} (copy)` }),
creatorId: ctx.session.user.id,
});
return { id, name: `${current.name} (copy)` };
}),
...templateProcedures,
...transferProcedures,
...previewBaseProcedures,
...previewQueryProcedures,
...previewActionProcedures,
duplicate: adminProcedure.input(z.object({ id: z.string() })).mutation(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" });
}
const newId = createId();
await ctx.db.insert(customWidgetDefinitions).values({
id: newId,
name: `${definition.name} (copy)`,
description: definition.description,
iconUrl: definition.iconUrl,
url: definition.url,
authType: definition.authType,
headerName: definition.headerName,
method: definition.method,
requestBody: definition.requestBody,
displayType: definition.displayType,
displayConfig: definition.displayConfig,
enabled: definition.enabled,
creatorId: ctx.session.user.id,
});
if (definition.secrets.length > 0) {
await ctx.db.insert(customWidgetSecrets).values(
definition.secrets.map((s) => ({
kind: s.kind,
value: s.value,
definitionId: newId,
updatedAt: new Date(),
})),
);
}
logger.info("Duplicated custom widget definition", { sourceId: input.id, newId });
return { id: newId, name: `${definition.name} (copy)` };
}),
});
@@ -0,0 +1,43 @@
import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server";
import type { Database } from "@homarr/db";
import { handleTransactionsAsync } from "@homarr/db";
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
import type { CustomWidgetCreateInput, HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { serializeCustomWidgetDefinition } from "./stored-definition";
export async function insertCustomWidgetDefinition(
db: Database,
definition: HomarrCustomWidgetV2,
creatorId: string,
secrets: CustomWidgetCreateInput["secrets"],
) {
const id = createId();
const definitionRow = { id, ...serializeCustomWidgetDefinition(definition), creatorId };
const updatedAt = new Date();
const secretRows = secrets.map((secret) => ({
definitionId: id,
sourceId: secret.sourceId,
kind: secret.kind,
encryptedValue: encryptSecret(secret.value),
updatedAt,
}));
await handleTransactionsAsync(db, {
async handleAsync(database, schema) {
await database.transaction(async (transaction) => {
await transaction.insert(schema.customWidgetDefinitions).values(definitionRow);
if (secretRows.length > 0) await transaction.insert(schema.customWidgetSecrets).values(secretRows);
});
},
handleSync(database) {
database.transaction((transaction) => {
transaction.insert(customWidgetDefinitions).values(definitionRow).run();
if (secretRows.length > 0) transaction.insert(customWidgetSecrets).values(secretRows).run();
});
},
});
return id;
}
@@ -1,51 +1,92 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { decryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { eq, or } from "@homarr/db";
import { boards, customWidgetDefinitions } from "@homarr/db/schema";
import {
getCustomWidgetConfirmation,
getCustomWidgetDefaultOptions,
getCustomWidgetSecretRequirements,
} from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure, protectedProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
import { parseDisplayConfig } from "./parse-display-config";
import { parseStoredCustomWidgetDefinition, safeParseStoredCustomWidgetDefinition } 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";
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
const logger = createLogger({ module: "custom-widget" });
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
const logger = createLogger({ module: "custom-widget:management" });
export const managementQueryProcedures = {
all: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"List all custom-widget definitions. Admin only. Use the returned ID to inspect or update a widget.",
},
})
list: manageProcedure
.meta({ mcp: { enabled: true, description: "List all Custom JSX widgets." } })
.query(async ({ ctx }) => {
const definitions = await ctx.db.query.customWidgetDefinitions.findMany({
orderBy: (table, { asc }) => asc(table.name),
with: { secrets: true },
});
return definitions.map((definition) => {
const result = safeParseStoredCustomWidgetDefinition(definition);
if (!result.success) {
logger.warn("Skipped parsing invalid custom widget definition", {
id: definition.id,
issueCount: result.issues.length,
});
return {
id: definition.id,
name: definition.name,
description: definition.description ?? undefined,
iconUrl: definition.iconUrl ?? undefined,
sources: [],
requestCount: 0,
missingSecrets: [],
options: {},
updatedAt: definition.updatedAt,
enabled: definition.enabled,
valid: false as const,
validationIssues: result.issues,
};
}
const widget = result.widget;
const configuredSecrets = new Set(definition.secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
return {
id: definition.id,
name: widget.name,
description: widget.description,
iconUrl: widget.iconUrl,
sources: Object.entries(widget.sources).map(([id, { name, baseUrl, networkScope, auth }]) => ({
id,
name,
origin: new URL(baseUrl).origin,
networkScope,
authType: typeof auth === "string" ? auth : auth.type,
})),
requestCount: Object.keys(widget.requests).length,
missingSecrets: getCustomWidgetSecretRequirements(widget.sources).filter(
(requirement) => !configuredSecrets.has(`${requirement.sourceId}:${requirement.kind}`),
),
options: widget.options,
updatedAt: definition.updatedAt,
enabled: definition.enabled,
valid: true as const,
validationIssues: [],
};
});
return definitions.map((definition) => ({
id: definition.id,
name: definition.name,
description: definition.description,
iconUrl: definition.iconUrl,
url: definition.url,
method: definition.method,
displayType: definition.displayType,
authType: definition.authType,
enabled: definition.enabled,
}));
}),
byId: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Get one complete custom-widget definition for iterative editing. REQUIRED: id. Stored secret values are never returned; only their presence is reported.",
},
})
get: manageProcedure
.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({
@@ -54,14 +95,13 @@ export const managementQueryProcedures = {
});
if (!definition) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
return {
...definition,
displayConfig: parseDisplayConfig(
definition.displayConfig,
input.id,
logger,
"Corrupt displayConfig in custom widget",
),
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,
@@ -73,12 +113,120 @@ export const managementQueryProcedures = {
.input(z.object({ boardId: z.string(), currentId: z.string().optional() }))
.query(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
return ctx.db.query.customWidgetDefinitions.findMany({
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),
columns: { id: true, name: true, description: true, iconUrl: true, displayType: true },
});
return definitions.flatMap((definition) => {
const result = safeParseStoredCustomWidgetDefinition(definition);
if (!result.success) {
logger.warn("Excluded invalid custom widget definition from board picker", { id: definition.id });
return [];
}
const widget = result.widget;
return {
id: definition.id,
name: definition.name,
description: definition.description,
iconUrl: definition.iconUrl,
options: widget.options,
defaultOptions: getCustomWidgetDefaultOptions(widget.options),
template: widget.template,
sources: Object.entries(widget.sources).map(([id, { name, networkScope, auth }]) => ({
id,
name,
networkScope,
authType: typeof auth === "string" ? auth : auth.type,
})),
requestCapabilities: Object.entries(widget.requests).map(
([id, { kind, method, trigger, permission, confirmation, invalidates }]) => ({
id,
kind,
method,
trigger,
minimumBoardPermission: permission,
confirmation: getCustomWidgetConfirmation({ method, confirmation }),
invalidates,
}),
),
optionRequests: Object.entries(widget.requests).flatMap(([id, request]) =>
request.kind === "query" ? [{ id }] : [],
),
updatedAt: definition.updatedAt,
};
});
}),
optionRequest: protectedProcedure
.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 = resolveCustomWidgetRequestValues(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();
}
}),
};
@@ -1,41 +1,36 @@
import { z } from "zod/v4";
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
import { CUSTOM_WIDGET_MCP_AUTHORING_PROMPT } from "@homarr/custom-widgets/authoring-prompt";
import { getCustomWidgetSkill } from "@homarr/custom-widgets/authoring-resources";
import { customWidgetImportSchema, getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure, publicProcedure } from "../../trpc";
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
let schemaCache: Record<string, unknown> | undefined;
function getImportJsonSchema() {
schemaCache ??= {
...z.toJSONSchema(customWidgetImportSchema),
title: "Homarr Custom Widget",
description:
"Schema for importing/exporting custom widget definitions in Homarr. All jsonPath fields use JSONPath syntax. The displayConfig must match displayType. Secrets are configured separately after import.",
};
return schemaCache;
}
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
export const metadataProcedures = {
schema: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Get the JSON Schema for Homarr custom-widget imports. Use this before generating a widget and before calling validate, create, or update.",
},
})
.query(() => getImportJsonSchema()),
.meta({ mcp: { enabled: true, description: "Get the current Custom JSX widget JSON Schema." } })
.query(() => getCustomWidgetJsonSchema()),
validate: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Validate a complete custom-widget JSON draft without saving or making network requests. Returns structured issue paths so an agent can correct and validate again.",
},
})
getAuthoringPrompt: publicProcedure
.meta({ mcp: { enabled: true, description: "Get the current Custom Widget authoring instructions." } })
.query(() => ({
version: 2,
prompt: CUSTOM_WIDGET_MCP_AUTHORING_PROMPT,
resources: [
"homarr://custom-widgets/schema",
"homarr://custom-widgets/components",
"homarr://custom-widgets/skill",
],
httpResources: ["/api/custom-widgets/schema", "/api/custom-widgets/components", "/api/custom-widgets/skill"],
})),
getSkill: publicProcedure
.meta({ mcp: { enabled: true, description: "Get the portable Homarr Custom Widget skill." } })
.query(() => getCustomWidgetSkill()),
validate: manageProcedure
.meta({ mcp: { enabled: true, description: "Validate one Custom JSX widget without saving it." } })
.input(z.object({ widget: z.unknown() }))
.query(({ input }) => {
const result = customWidgetImportSchema.safeParse(input.widget);
@@ -1,17 +0,0 @@
import { TRPCError } from "@trpc/server";
import { parse as parseSuperJson } from "superjson";
import type { createLogger } from "@homarr/core/infrastructure/logs";
import { displayConfigSchema } from "@homarr/custom-widgets/core";
import type { DisplayConfig } from "@homarr/custom-widgets/core";
type Logger = ReturnType<typeof createLogger>;
export function parseDisplayConfig(raw: string, id: string, logger: Logger, context: string): DisplayConfig {
try {
return displayConfigSchema.parse(parseSuperJson(raw));
} catch {
logger.error(context, { id });
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Widget has corrupt display configuration" });
}
}
@@ -2,73 +2,49 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { permissionRequiredProcedure } from "../../trpc";
import { previewSessionRequestSchema, recordPreviewJournal } from "./preview-procedure-helpers";
import { executeCustomWidgetRequest } from "./request-executor";
import { renderRequestBody, renderRequestTarget } from "./request-manifest";
import {
getPreviewRequestSource,
previewSessionRequestSchema,
recordPreviewJournal,
} from "./preview-procedure-helpers";
import { executeCustomWidgetRequest, invalidateCustomWidgetResponseCache } from "./request-executor";
import { renderRequestBody, renderRequestTarget, resolveCustomWidgetRequestValues } from "./request-manifest";
import { acquireCustomWidgetRequestLimit } from "./request-limits";
import { getPreviewSession, getPreviewSessionSecrets } from "./preview-sessions";
import { getPreviewSession } from "./preview-sessions";
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
export const previewActionProcedures = {
simulatePreviewAction: adminProcedure
previewAction: manageProcedure
.meta({
mcp: {
enabled: true,
description:
"Validate and simulate one named custom-widget action without sending a network request. REQUIRED: sessionId from customWidget_preview, action requestId, and typed params. Confirms parameter substitution and records a simulated journal entry. Returns requiredPermission as the action's minimum board permission (view, modify, or full), not as an execution result. This MCP tool never enables or executes live actions.",
},
mcp: { enabled: true, description: "Simulate or run one named action in a custom widget preview session." },
})
.input(previewSessionRequestSchema)
.mutation(async ({ ctx, input }) => {
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
const request = session.requests.find(
(candidate) => candidate.id === input.requestId && candidate.kind === "action",
);
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview action was not found" });
renderRequestTarget(session.baseUrl, request, input.params);
renderRequestBody(request.bodyTemplate, input.params);
await recordPreviewJournal(session, {
requestId: request.id,
kind: "action",
method: request.method,
pathTemplate: request.pathTemplate,
status: null,
durationMs: 0,
simulated: true,
});
return {
ok: true,
simulated: true as const,
requestId: request.id,
method: request.method,
requiredPermission: request.minimumBoardPermission,
};
}),
previewAction: adminProcedure
.input(previewSessionRequestSchema.extend({ confirmed: z.boolean().optional() }))
.mutation(async ({ ctx, input }) => {
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
const request = session.requests.find(
(candidate) => candidate.id === input.requestId && candidate.kind === "action",
);
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview action was not found" });
const targetUrl = renderRequestTarget(session.baseUrl, request, input.params);
const body = renderRequestBody(request.bodyTemplate, input.params);
const definition = session.requests[input.requestId];
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);
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 body = renderRequestBody(request, params);
if (!session.liveActions) {
await recordPreviewJournal(session, {
requestId: request.id,
kind: "action",
method: request.method,
pathTemplate: request.pathTemplate,
path: request.path,
status: null,
durationMs: 0,
simulated: true,
});
return { ok: true, status: 0, statusText: "Simulated", data: null, simulated: true as const };
}
if (request.method === "DELETE" && input.confirmed !== true) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "DELETE actions require confirmation" });
if ((request.confirmation || request.method === "DELETE") && input.confirmed !== true) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "This action requires confirmation" });
}
const release = await acquireCustomWidgetRequestLimit({
category: request.method === "DELETE" ? "delete" : "action",
@@ -79,27 +55,25 @@ export const previewActionProcedures = {
const startedAt = Date.now();
try {
const response = await executeCustomWidgetRequest({
baseUrl: session.baseUrl,
baseUrl: resolved.source.baseUrl,
targetUrl,
method: request.method,
body,
staticHeaders: request.staticHeaders,
auth:
request.auth === "none"
? undefined
: {
type: session.authType,
secrets: getPreviewSessionSecrets(session),
headerName: session.headerName,
},
networkScope: session.networkScope,
staticHeaders: request.headers,
auth: request.auth === "none" ? undefined : resolved.auth,
networkScope: resolved.source.networkScope,
kind: "action",
});
if (response.ok && request.invalidates?.length) {
invalidateCustomWidgetResponseCache(
request.invalidates.map((requestId) => `custom-jsx:preview:${session.id}:${requestId}:`),
);
}
await recordPreviewJournal(session, {
requestId: request.id,
kind: "action",
method: request.method,
pathTemplate: request.pathTemplate,
path: request.path,
status: response.status,
durationMs: Date.now() - startedAt,
simulated: false,
@@ -2,158 +2,120 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { decryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import {
customWidgetDefinitionSchema,
customWidgetSecretsInputSchema,
getCustomWidgetConfirmation,
getCustomWidgetDefaultOptions,
validateCustomWidgetOptions,
} from "@homarr/custom-widgets/core";
import { eq } from "@homarr/db";
import { customWidgetDefinitions } from "@homarr/db/schema";
import {
customWidgetAuthTypes,
customWidgetDisplayTypes,
customWidgetMethods,
customWidgetSecretKinds,
customJsxDisplayConfigV2Schema,
displayConfigSchema,
extractActionButtonDisplay,
extractDisplayDataWithFallback,
} from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure } from "../../trpc";
import { recordPreviewJournal } from "./preview-procedure-helpers";
import { executeCustomWidgetRequest } from "./request-executor";
import { acquireCustomWidgetRequestLimit } from "./request-limits";
import { createPreviewSession, getPreviewSession } from "./preview-sessions";
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
const logger = createLogger({ module: "custom-widget" });
const previewCreateInputSchema = z.object({
definition: customWidgetDefinitionSchema,
secrets: customWidgetSecretsInputSchema.default([]),
definitionId: z.string().optional(),
options: z.record(z.string(), z.unknown()).optional(),
});
const previewCreateProcedure = manageProcedure
.meta({ mcp: { enabled: true, description: "Create a short-lived preview session for one unsaved custom widget." } })
.input(previewCreateInputSchema)
.mutation(async ({ ctx, input }) => {
if (input.secrets.length > 0 && !ctx.session.user.permissions.includes("custom-widget-secret-write")) {
throw new TRPCError({ code: "FORBIDDEN", message: "Writing preview secrets requires dedicated permission" });
}
const options = input.options ?? getCustomWidgetDefaultOptions(input.definition.options);
const optionIssues = validateCustomWidgetOptions(input.definition.options, options);
if (optionIssues.length > 0) {
const issue = optionIssues[0];
throw new TRPCError({
code: "BAD_REQUEST",
message: issue ? `${issue.path}: ${issue.message}` : "Preview options are invalid",
});
}
const secrets = [...input.secrets];
if (input.definitionId) {
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.definitionId),
with: { secrets: true },
});
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
for (const secret of existing.secrets) {
if (!secrets.some((candidate) => candidate.sourceId === secret.sourceId && candidate.kind === secret.kind)) {
secrets.push({ sourceId: secret.sourceId, kind: secret.kind, value: decryptSecret(secret.encryptedValue) });
}
}
}
const invalid = secrets.find((secret) => {
const source = input.definition.sources[secret.sourceId];
const authType = typeof source?.auth === "string" ? source.auth : source?.auth.type;
const kinds =
authType === "basic"
? ["username", "password"]
: source && authType && ["bearer", "apiKeyHeader", "apiKeyQuery"].includes(authType)
? ["apiKey"]
: [];
return !source || !kinds.includes(secret.kind);
});
if (invalid) {
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown secret source '${invalid.sourceId}'` });
}
const previewSession = await createPreviewSession({
userId: ctx.session.user.id,
sources: input.definition.sources,
requests: input.definition.requests,
name: input.definition.name,
template: input.definition.template,
optionDefinitions: input.definition.options,
options,
secrets,
definitionId: input.definitionId,
});
return {
success: true as const,
previewSession,
previewUrl: new URL(
`/manage/custom-widgets/preview/${previewSession.id}`,
ctx.baseUrl ?? "http://localhost",
).toString(),
definition: {
template: input.definition.template,
defaultOptions: getCustomWidgetDefaultOptions(input.definition.options),
},
};
});
export const previewBaseProcedures = {
preview: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Test a custom-widget draft through Homarr's hardened preview executor. Admin only. GET only; actions remain simulated. REQUIRED: url, method GET, authType, displayType, displayConfig. OPTIONAL: definitionId reuses that saved widget's stored credentials; omit secrets whenever possible. Returns sanitized response data, HTTP status, and a short-lived preview session for named query testing.",
},
})
.input(
z.object({
url: z.string().url(),
method: z.enum(customWidgetMethods),
authType: z.enum(customWidgetAuthTypes),
headerName: z.string().optional(),
requestBody: z.string().optional(),
displayType: z.enum(customWidgetDisplayTypes),
displayConfig: displayConfigSchema,
secrets: z.array(z.object({ kind: z.enum(customWidgetSecretKinds), value: z.string() })).default([]),
definitionId: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const secrets = [...input.secrets];
if (input.definitionId) {
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.definitionId),
with: { secrets: true },
});
if (existing) {
for (const dbSecret of existing.secrets) {
if (!secrets.some((s) => s.kind === dbSecret.kind)) {
secrets.push({ kind: dbSecret.kind, value: decryptSecret(dbSecret.value) });
}
}
}
}
const v2Config = customJsxDisplayConfigV2Schema.safeParse(input.displayConfig);
const previewSession = v2Config.success
? await createPreviewSession({
userId: ctx.session.user.id,
baseUrl: input.url,
authType: input.authType,
headerName: input.headerName,
secrets,
networkScope: v2Config.data.networkScope,
requests: v2Config.data.requests,
definitionId: input.definitionId,
})
: null;
const storedPreviewSession = previewSession
? await getPreviewSession(previewSession.id, ctx.session.user.id)
: null;
if (input.method !== "GET") {
return {
success: true as const,
simulated: true as const,
responseInfo: null,
rawResponse: null,
displayData: input.displayType === "actionButton" ? extractActionButtonDisplay(input.displayConfig) : null,
previewSession,
};
}
const release = await acquireCustomWidgetRequestLimit({
category: "query",
userId: ctx.session.user.id,
itemId: `preview:${ctx.session.user.id}`,
definitionId: input.definitionId ?? `preview:${ctx.session.user.id}`,
});
const startedAt = Date.now();
try {
const response = await executeCustomWidgetRequest({
baseUrl: input.url,
method: input.method,
body: undefined,
auth: { type: input.authType, secrets, headerName: input.headerName },
networkScope: v2Config.success ? v2Config.data.networkScope : "private",
kind: "query",
});
if (storedPreviewSession) {
await recordPreviewJournal(storedPreviewSession, {
requestId: "base",
kind: "query",
method: "GET",
pathTemplate: new URL(input.url).pathname,
status: response.status,
durationMs: Date.now() - startedAt,
simulated: false,
});
}
const responseInfo = { status: response.status, statusText: response.statusText };
if (!response.ok) {
return {
success: false as const,
error: `HTTP ${response.status}: ${response.statusText}`,
responseInfo,
rawResponse: typeof response.data === "string" ? response.data : JSON.stringify(response.data, null, 2),
previewSession,
};
}
const displayData = extractDisplayDataWithFallback(response.data, input.displayType, input.displayConfig);
return {
success: true as const,
responseInfo,
rawResponse: typeof response.data === "string" ? response.data : JSON.stringify(response.data, null, 2),
displayData,
previewSession,
};
} catch (error) {
if (error instanceof TRPCError) throw error;
logger.error("Custom widget preview failed", {
errorName: error instanceof Error ? error.name : "UnknownError",
});
return {
success: false as const,
error: error instanceof Error ? error.message : "Failed to fetch data",
responseInfo: null,
rawResponse: null,
previewSession,
};
} finally {
await release();
}
}),
previewCreate: previewCreateProcedure,
previewGet: manageProcedure.input(z.object({ sessionId: z.string() })).query(async ({ ctx, input }) => {
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
return {
id: session.id,
name: session.name,
expiresAt: session.expiresAt,
template: session.template,
optionDefinitions: session.optionDefinitions,
options: session.options,
requests: Object.entries(session.requests).map(([id, request]) => ({
id,
kind: request.kind,
method: request.method,
minimumBoardPermission: request.permission,
trigger: request.trigger,
confirmation: getCustomWidgetConfirmation(request),
invalidates: request.invalidates,
})),
liveActions: session.liveActions,
};
}),
};
@@ -1,7 +1,11 @@
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
import { z } from "zod/v4";
import { appendPreviewJournal } from "./preview-sessions";
import { getPreviewSessionSecrets } from "./preview-sessions";
import type { CustomWidgetPreviewSession } from "./preview-sessions";
import { resolveCustomWidgetRequestValues } from "./request-manifest";
const logger = createLogger({ module: "custom-widget-preview" });
@@ -21,3 +25,31 @@ export const recordPreviewJournal = async (...args: Parameters<typeof appendPrev
});
}
};
export const getPreviewRequestSource = (session: CustomWidgetPreviewSession, sourceId: string) => {
const source = session.sources[sourceId];
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 };
};
export function resolvePreviewRequestParams(
request: CustomJsxRequest,
options: Record<string, unknown>,
suppliedParams: Record<string, string | number | boolean>,
) {
return resolveCustomWidgetRequestValues(request, options, suppliedParams);
}
@@ -2,36 +2,39 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { permissionRequiredProcedure } from "../../trpc";
import { previewSessionRequestSchema, recordPreviewJournal } from "./preview-procedure-helpers";
import { executeCustomWidgetRequest } from "./request-executor";
import { hashRuntimeParams, renderRequestTarget } from "./request-manifest";
import { acquireCustomWidgetRequestLimit } from "./request-limits";
import {
getPreviewJournal,
getPreviewSession,
getPreviewSessionSecrets,
setPreviewSessionLiveActions,
} from "./preview-sessions";
getPreviewRequestSource,
previewSessionRequestSchema,
recordPreviewJournal,
resolvePreviewRequestParams,
} from "./preview-procedure-helpers";
import { executeCustomWidgetRequest } from "./request-executor";
import { hashRuntimeParams, renderRequestBody, renderRequestTarget } from "./request-manifest";
import { acquireCustomWidgetRequestLimit } from "./request-limits";
import { getPreviewJournal, getPreviewSession, setPreviewSessionLiveActions } from "./preview-sessions";
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
export const previewQueryProcedures = {
previewQuery: adminProcedure
previewQuery: manageProcedure
.meta({
mcp: {
enabled: true,
description:
"Run one named GET query from a short-lived custom-widget preview session. REQUIRED: sessionId from customWidget_preview, requestId declared as a query, and typed params. Uses the hardened executor and returns sanitized response data for another validate/update iteration.",
description: "Run one named query from a short-lived custom widget preview session.",
},
})
.input(previewSessionRequestSchema)
.query(async ({ ctx, input }) => {
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
const request = session.requests.find(
(candidate) => candidate.id === input.requestId && candidate.kind === "query",
);
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview query was not found" });
const targetUrl = renderRequestTarget(session.baseUrl, request, input.params);
const definition = session.requests[input.requestId];
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);
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 body = renderRequestBody(request, params);
const release = await acquireCustomWidgetRequestLimit({
category: "query",
userId: ctx.session.user.id,
@@ -41,28 +44,22 @@ export const previewQueryProcedures = {
const startedAt = Date.now();
try {
const response = await executeCustomWidgetRequest({
baseUrl: session.baseUrl,
baseUrl: resolved.source.baseUrl,
targetUrl,
method: "GET",
staticHeaders: request.staticHeaders,
auth:
request.auth === "none"
? undefined
: {
type: session.authType,
secrets: getPreviewSessionSecrets(session),
headerName: session.headerName,
},
networkScope: session.networkScope,
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(input.params)}`,
cacheTtlSeconds: request.cacheTtlSeconds,
cacheKey: `custom-jsx:preview:${session.id}:${request.id}:${hashRuntimeParams(params)}`,
cacheTtlSeconds: request.cacheSeconds,
});
await recordPreviewJournal(session, {
requestId: request.id,
kind: "query",
method: "GET",
pathTemplate: request.pathTemplate,
method: request.method,
path: request.path,
status: response.status,
durationMs: Date.now() - startedAt,
simulated: false,
@@ -79,18 +76,17 @@ export const previewQueryProcedures = {
}
}),
setPreviewLiveActions: adminProcedure
setPreviewLiveActions: manageProcedure
.input(z.object({ sessionId: z.string().min(1), enabled: z.boolean() }))
.mutation(async ({ ctx, input }) =>
setPreviewSessionLiveActions(input.sessionId, ctx.session.user.id, input.enabled),
),
previewJournal: adminProcedure
previewJournal: manageProcedure
.meta({
mcp: {
enabled: true,
description:
"Read the redacted request journal for a custom-widget preview session. REQUIRED: sessionId. Use it to inspect statuses and durations; credentials and parameter values are never returned.",
description: "Read the redacted request journal for a custom widget preview session.",
},
})
.input(z.object({ sessionId: z.string().min(1) }))
@@ -76,4 +76,14 @@ export const appendPreviewJournal = (
entry: Omit<CustomWidgetPreviewJournalEntry, "id" | "timestamp">,
) => call(() => getService().appendJournal(session, entry));
export const getPreviewJournal = (id: string, userId: string) => call(() => getService().getJournal(id, userId));
export const getPreviewSessionSecrets = (session: CustomWidgetPreviewSession) => getService().getSecrets(session);
export const getPreviewSessionSecrets = (session: CustomWidgetPreviewSession, sourceId: string) =>
getService().getSecrets(session, sourceId);
export const setPreviewSessionSecrets = (id: string, userId: string, secrets: CreatePreviewSessionInput["secrets"]) =>
call(() => getService().setSecrets(id, userId, secrets));
export const configurePreviewSessionSource = (
id: string,
userId: string,
sourceId: string,
source: CreatePreviewSessionInput["sources"][string],
secrets: CreatePreviewSessionInput["secrets"],
) => call(() => getService().configureSource(id, userId, sourceId, source, secrets));
@@ -4,6 +4,7 @@ import {
assertSafeStaticHeaders as assertDomainSafeStaticHeaders,
classifyAddress,
executeCustomWidgetRequest as executeDomainRequest,
invalidateCustomWidgetResponseCache as invalidateDomainResponseCache,
MAX_REQUEST_BODY_BYTES,
MAX_RESPONSE_BODY_BYTES,
MAX_RESPONSE_JSON_DEPTH,
@@ -81,3 +82,6 @@ export async function executeCustomWidgetRequest(input: CustomWidgetHttpRequest)
toTrpcError(error);
}
}
export const invalidateCustomWidgetResponseCache = (prefixes: readonly string[]) =>
invalidateDomainResponseCache(prefixes);
@@ -3,7 +3,7 @@ import {
hashRuntimeParams,
renderRequestBody as renderDomainBody,
renderRequestTarget as renderDomainTarget,
validateRuntimeParams as validateDomainParams,
resolveCustomWidgetRequestValues as resolveDomainValues,
} from "@homarr/custom-widgets/server";
import type { CustomJsxRuntimeParams } from "@homarr/custom-widgets/server";
@@ -12,26 +12,32 @@ import { toTrpcError } from "./domain-error";
export type { CustomJsxRuntimeParams } from "@homarr/custom-widgets/server";
export { hashRuntimeParams };
export function validateRuntimeParams(request: CustomJsxRequest, params: CustomJsxRuntimeParams): void {
type CustomJsxResolvedValues = ReturnType<typeof resolveDomainValues>;
export function resolveCustomWidgetRequestValues(
request: CustomJsxRequest,
options: Record<string, unknown>,
params: CustomJsxRuntimeParams = {},
) {
try {
validateDomainParams(request, params);
return resolveDomainValues(request, options, params);
} catch (error) {
toTrpcError(error);
return toTrpcError(error);
}
}
export function renderRequestBody(request: CustomJsxRequest["bodyTemplate"], params: CustomJsxRuntimeParams) {
export function renderRequestBody(request: CustomJsxRequest, params: CustomJsxResolvedValues) {
try {
return renderDomainBody(request, params);
} catch (error) {
toTrpcError(error);
return toTrpcError(error);
}
}
export function renderRequestTarget(baseUrl: string, request: CustomJsxRequest, params: CustomJsxRuntimeParams) {
export function renderRequestTarget(baseUrl: string, request: CustomJsxRequest, params: CustomJsxResolvedValues) {
try {
return renderDomainTarget(baseUrl, request, params);
} catch (error) {
toTrpcError(error);
return toTrpcError(error);
}
}
@@ -0,0 +1,24 @@
import { TRPCError } from "@trpc/server";
import { getCustomWidgetRequiredSecretKinds } from "@homarr/custom-widgets/core";
export function requiredSecretKinds(authType: string) {
return getCustomWidgetRequiredSecretKinds(authType as Parameters<typeof getCustomWidgetRequiredSecretKinds>[0]);
}
export function assertSecretSources(
sources: Record<string, { auth: 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;
return !source || !authType || !new Set<string>(requiredSecretKinds(authType)).has(secret.kind);
});
if (invalid) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Secret does not match source '${invalid.sourceId}' authentication`,
});
}
}
@@ -0,0 +1,257 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { encryptSecret } from "@homarr/common/server";
import { and, eq, handleTransactionsAsync } from "@homarr/db";
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
import {
customWidgetSecretInputSchema,
customWidgetSecretsInputSchema,
customWidgetSourceSchema,
} from "@homarr/custom-widgets/core";
import type { CustomWidgetSource } from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure } from "../../trpc";
import {
createCustomWidgetConfigurationRequest,
getCustomWidgetConfigurationRequestForUser,
} from "./configuration-requests";
import { assertSecretSources, requiredSecretKinds } from "./secret-policy";
import { getPreviewSession } from "./preview-sessions";
import { parseStoredCustomWidgetDefinition, serializeCustomWidgetDefinition } from "./stored-definition";
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
const secretWriteProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-secret-write");
const secretRequestInputSchema = z
.object({
requestId: z.string().optional(),
definitionId: z.string().optional(),
previewSessionId: z.string().optional(),
sourceId: z.string().optional(),
})
.superRefine((input, ctx) => {
if (input.requestId) {
if (input.definitionId || input.previewSessionId || input.sourceId) {
ctx.addIssue({ code: "custom", message: "Status checks only accept requestId" });
}
return;
}
if (Boolean(input.definitionId) === Boolean(input.previewSessionId)) {
ctx.addIssue({ code: "custom", message: "Provide exactly one definitionId or previewSessionId" });
}
if (!input.sourceId) ctx.addIssue({ code: "custom", message: "sourceId is required" });
});
export const secretProcedures = {
secretSet: secretWriteProcedure
.meta({ mcp: { enabled: true, description: "Set one encrypted secret for a custom widget source." } })
.input(z.object({ definitionId: z.string(), secret: customWidgetSecretInputSchema }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.definitionId),
});
if (!existing) throw new TRPCError({ code: "NOT_FOUND" });
const definition = parseStoredCustomWidgetDefinition(existing);
assertSecretSources(definition.sources, [input.secret]);
await ctx.db
.delete(customWidgetSecrets)
.where(
and(
eq(customWidgetSecrets.definitionId, input.definitionId),
eq(customWidgetSecrets.sourceId, input.secret.sourceId),
eq(customWidgetSecrets.kind, input.secret.kind),
),
);
await ctx.db.insert(customWidgetSecrets).values({
definitionId: input.definitionId,
sourceId: input.secret.sourceId,
kind: input.secret.kind,
encryptedValue: encryptSecret(input.secret.value),
updatedAt: new Date(),
});
await ctx.db
.update(customWidgetDefinitions)
.set({ updatedAt: new Date() })
.where(eq(customWidgetDefinitions.id, input.definitionId));
return { sourceId: input.secret.sourceId, kind: input.secret.kind, isSet: true };
}),
sourceConfigure: manageProcedure
.meta({ mcp: { enabled: true, description: "Configure one custom widget API source and its credentials." } })
.input(
z.object({
definitionId: z.string(),
sourceId: z.string(),
baseUrl: z.string(),
networkScope: z.enum(["public", "private", "loopback"]).optional(),
secrets: customWidgetSecretsInputSchema.default([]),
}),
)
.mutation(async ({ ctx, input }) => {
if (input.secrets.length > 0 && !ctx.session.user.permissions.includes("custom-widget-secret-write")) {
throw new TRPCError({ code: "FORBIDDEN", message: "Writing source credentials requires dedicated permission" });
}
const stored = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.definitionId),
});
if (!stored) throw new TRPCError({ code: "NOT_FOUND" });
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,
});
assertSecretSources({ [input.sourceId]: source }, input.secrets);
const updated = { ...definition, sources: { ...definition.sources, [input.sourceId]: source } };
const definitionChanges = { ...serializeCustomWidgetDefinition(updated), updatedAt: new Date() };
const secretRows = input.secrets.map((secret) => ({
definitionId: input.definitionId,
sourceId: input.sourceId,
kind: secret.kind,
encryptedValue: encryptSecret(secret.value),
updatedAt: new Date(),
}));
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
await transaction
.update(schema.customWidgetDefinitions)
.set(definitionChanges)
.where(eq(schema.customWidgetDefinitions.id, input.definitionId));
for (const secret of secretRows) {
await transaction
.delete(schema.customWidgetSecrets)
.where(
and(
eq(schema.customWidgetSecrets.definitionId, input.definitionId),
eq(schema.customWidgetSecrets.sourceId, input.sourceId),
eq(schema.customWidgetSecrets.kind, secret.kind),
),
);
await transaction.insert(schema.customWidgetSecrets).values(secret);
}
});
},
handleSync(db) {
db.transaction((transaction) => {
transaction
.update(customWidgetDefinitions)
.set(definitionChanges)
.where(eq(customWidgetDefinitions.id, input.definitionId))
.run();
for (const secret of secretRows) {
transaction
.delete(customWidgetSecrets)
.where(
and(
eq(customWidgetSecrets.definitionId, input.definitionId),
eq(customWidgetSecrets.sourceId, input.sourceId),
eq(customWidgetSecrets.kind, secret.kind),
),
)
.run();
transaction.insert(customWidgetSecrets).values(secret).run();
}
});
},
});
return {
definitionId: input.definitionId,
sourceId: input.sourceId,
baseUrl: source.baseUrl,
networkScope: source.networkScope,
configuredSecrets: input.secrets.map(({ kind }) => kind),
};
}),
configurationRequestUser: manageProcedure
.meta({ mcp: { enabled: true, description: "Create or check a short-lived user source-configuration request." } })
.input(secretRequestInputSchema)
.mutation(async ({ ctx, input }) => {
if (input.requestId) {
const request = await getCustomWidgetConfigurationRequestForUser(input.requestId, ctx.session.user.id);
if (!request) throw new TRPCError({ code: "NOT_FOUND" });
return { requestId: request.id, status: request.status, expiresAt: request.expiresAt };
}
let widgetName: string;
let source: { id: string; name: string; auth: string | { type: string }; value: CustomWidgetSource } | undefined;
let target: { type: "definition"; id: string } | { type: "preview"; id: string };
if (input.definitionId) {
const stored = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.definitionId),
});
if (!stored) throw new TRPCError({ code: "NOT_FOUND" });
const definition = parseStoredCustomWidgetDefinition(stored);
widgetName = definition.name;
const candidate = definition.sources[input.sourceId ?? ""];
source = candidate
? {
id: input.sourceId ?? "",
name: candidate.name ?? input.sourceId ?? "",
auth: candidate.auth,
value: candidate,
}
: undefined;
target = { type: "definition", id: input.definitionId };
} else {
const preview = await getPreviewSession(input.previewSessionId ?? "", ctx.session.user.id);
widgetName = preview.name;
const candidate = preview.sources[input.sourceId ?? ""];
source = candidate
? {
id: input.sourceId ?? "",
name: candidate.name ?? input.sourceId ?? "",
auth: candidate.auth,
value: candidate,
}
: undefined;
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 request = await createCustomWidgetConfigurationRequest({
userId: ctx.session.user.id,
target,
widgetName,
sourceId: source.id,
sourceName: source.name,
source: source.value,
kinds,
});
return {
requestId: request.id,
status: request.status,
expiresAt: request.expiresAt,
url: new URL(`/custom-widget-configuration/${request.id}`, ctx.baseUrl ?? "http://localhost").toString(),
};
}),
secretClear: secretWriteProcedure
.input(
z.object({ definitionId: z.string(), sourceId: z.string(), kind: z.enum(["apiKey", "username", "password"]) }),
)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.definitionId),
});
if (!existing) throw new TRPCError({ code: "NOT_FOUND" });
await ctx.db
.delete(customWidgetSecrets)
.where(
and(
eq(customWidgetSecrets.definitionId, input.definitionId),
eq(customWidgetSecrets.sourceId, input.sourceId),
eq(customWidgetSecrets.kind, input.kind),
),
);
await ctx.db
.update(customWidgetDefinitions)
.set({ updatedAt: new Date() })
.where(eq(customWidgetDefinitions.id, input.definitionId));
return { sourceId: input.sourceId, kind: input.kind, isSet: false };
}),
};
@@ -0,0 +1,67 @@
import { parse as parseSuperJson, stringify as stringifySuperJson } from "superjson";
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import type { CustomWidgetDefinition } from "@homarr/db/schema";
type StoredCustomWidgetDefinition = Pick<
CustomWidgetDefinition,
"name" | "description" | "iconUrl" | "sources" | "requests" | "options" | "template"
>;
export interface StoredCustomWidgetIssue {
path?: string;
message: string;
}
export type StoredCustomWidgetParseResult =
| { success: true; widget: HomarrCustomWidgetV2 }
| { success: false; issues: StoredCustomWidgetIssue[] };
export function serializeCustomWidgetDefinition(definition: HomarrCustomWidgetV2) {
return {
name: definition.name,
description: definition.description ?? null,
iconUrl: definition.iconUrl ?? null,
sources: stringifySuperJson(definition.sources),
requests: stringifySuperJson(definition.requests),
options: stringifySuperJson(definition.options),
template: definition.template,
};
}
export function parseStoredCustomWidgetDefinition(definition: StoredCustomWidgetDefinition): HomarrCustomWidgetV2 {
return customWidgetDefinitionSchema.parse({
$schema: "homarr-custom-widget-v2",
name: definition.name,
description: definition.description ?? undefined,
iconUrl: definition.iconUrl ?? undefined,
sources: parseSuperJson(definition.sources),
requests: parseSuperJson(definition.requests),
options: parseSuperJson(definition.options),
template: definition.template,
});
}
export function safeParseStoredCustomWidgetDefinition(
definition: StoredCustomWidgetDefinition,
): StoredCustomWidgetParseResult {
try {
return { success: true, widget: parseStoredCustomWidgetDefinition(definition) };
} catch (error) {
if (error instanceof Error && "issues" in error && Array.isArray(error.issues)) {
return {
success: false,
issues: error.issues.slice(0, 10).map((issue: { path?: PropertyKey[]; message?: unknown }) => ({
path: issue.path?.map(String).join("."),
message: typeof issue.message === "string" ? issue.message : "Invalid stored widget value",
})),
};
}
return {
success: false,
issues: [{ message: "Stored widget data could not be read" }],
};
}
}
@@ -1,26 +1,26 @@
import { createHash } from "node:crypto";
import { TRPCError } from "@trpc/server";
import { stringify as stringifySuperJson } from "superjson";
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { displayConfigSchema } from "@homarr/custom-widgets/core";
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import { eq } from "@homarr/db";
import { customWidgetDefinitions } from "@homarr/db/schema";
import { permissionRequiredProcedure } from "../../trpc";
import { parseDisplayConfig } from "./parse-display-config";
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
const logger = createLogger({ module: "custom-widget" });
const getTemplateRevision = (template: string) => createHash("sha256").update(template).digest("hex").slice(0, 16);
const validateUpdatedTemplate = (displayConfig: Record<string, unknown>, template: string) => {
displayConfig.template = template;
const result = displayConfigSchema.safeParse(displayConfig);
const validateTemplate = (definition: ReturnType<typeof parseStoredCustomWidgetDefinition>, template: string) => {
if (template.trim().length === 0) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Template must not be empty" });
}
const result = customWidgetDefinitionSchema.safeParse({ ...definition, template });
if (!result.success) {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -29,67 +29,29 @@ const validateUpdatedTemplate = (displayConfig: Record<string, unknown>, templat
.join("; "),
});
}
return result.data;
return result.data.template;
};
export const templateProcedures = {
readTemplate: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Read the JSX template of a custom widget definition as plain text. Returns the template string separately from the full widget config, making it easier to inspect and edit.",
},
})
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
readTemplate: manageProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!definition) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
const template = parseStoredCustomWidgetDefinition(definition).template;
return {
id: definition.id,
name: definition.name,
template,
templateLines: template.split("\n"),
revision: getTemplateRevision(template),
};
}),
if (!definition) {
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
}
const config = parseDisplayConfig(
definition.displayConfig,
input.id,
logger,
"Corrupt displayConfig in custom widget readTemplate",
);
if (config.type !== "customJsx") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Widget is not using customJsx display type",
});
}
const template = (config.template as string | undefined) ?? "";
return {
id: definition.id,
name: definition.name,
template,
templateLines: template.split("\n"),
revision: getTemplateRevision(template),
};
}),
writeTemplate: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Update only the JSX template of a custom widget definition. Accepts either a single template string or templateLines (array of strings joined with newlines). Validates the template AST before saving. This avoids needing to send the full widget JSON for template-only edits.",
},
})
writeTemplate: manageProcedure
.input(
z
.object({
id: z.string(),
template: z.string().optional(),
templateLines: z.array(z.string()).optional(),
})
.object({ id: z.string(), template: z.string().optional(), templateLines: z.array(z.string()).optional() })
.refine((data) => data.template !== undefined || data.templateLines !== undefined, {
message: "Provide either template or templateLines",
})
@@ -98,62 +60,22 @@ export const templateProcedures = {
}),
)
.mutation(async ({ ctx, input }) => {
const resolvedTemplate =
input.templateLines !== undefined ? input.templateLines.join("\n") : (input.template ?? "");
const resolved = input.templateLines?.join("\n") ?? input.template ?? "";
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
}
const displayConfig = parseDisplayConfig(
existing.displayConfig,
input.id,
logger,
"Corrupt displayConfig in custom widget writeTemplate",
);
if (displayConfig.type !== "customJsx") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Widget is not using customJsx display type",
});
}
if (resolvedTemplate.trim().length === 0) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Template must not be empty" });
}
if (resolvedTemplate.length > 50_000) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Template exceeds the 50,000 character limit" });
}
const validatedConfig = validateUpdatedTemplate(displayConfig, resolvedTemplate);
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
const template = validateTemplate(parseStoredCustomWidgetDefinition(existing), resolved);
await ctx.db
.update(customWidgetDefinitions)
.set({ displayConfig: stringifySuperJson(validatedConfig), updatedAt: new Date() })
.set({ template, updatedAt: new Date() })
.where(eq(customWidgetDefinitions.id, input.id));
logger.info("Updated custom widget template", { id: input.id });
return {
id: input.id,
template: resolvedTemplate,
templateLines: resolvedTemplate.split("\n"),
revision: getTemplateRevision(resolvedTemplate),
};
return { id: input.id, template, templateLines: template.split("\n"), revision: getTemplateRevision(template) };
}),
patchTemplate: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Patch selected lines of a Custom JSX template without rewriting the whole template. First call customWidget_readTemplate and pass its revision as expectedRevision. Each edit uses a 1-based startLine, deleteCount, and replacementLines. Edits are applied atomically, then the complete template and named-request references are validated before saving. A stale revision is rejected so the agent can re-read and retry safely.",
},
})
templatePatch: manageProcedure
.meta({ mcp: { enabled: true, description: "Patch selected JSX template lines using an optimistic revision." } })
.input(
z.object({
id: z.string(),
@@ -175,26 +97,15 @@ export const templateProcedures = {
where: eq(customWidgetDefinitions.id, input.id),
});
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
const displayConfig = parseDisplayConfig(
existing.displayConfig,
input.id,
logger,
"Corrupt displayConfig in custom widget patchTemplate",
);
if (displayConfig.type !== "customJsx") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Widget is not using customJsx display type" });
}
const currentTemplate = (displayConfig.template as string | undefined) ?? "";
if (getTemplateRevision(currentTemplate) !== input.expectedRevision) {
const definition = parseStoredCustomWidgetDefinition(existing);
if (getTemplateRevision(definition.template) !== input.expectedRevision) {
throw new TRPCError({
code: "CONFLICT",
message: "Template changed since it was read. Read the template again and retry the patch.",
message: "Template changed since it was read. Read it again and retry.",
});
}
const lines = currentTemplate.split("\n");
const lines = definition.template.split("\n");
const edits = input.edits.toSorted((left, right) => right.startLine - left.startLine);
let nextHigherStart = lines.length + 2;
for (const edit of edits) {
@@ -208,19 +119,12 @@ export const templateProcedures = {
lines.splice(edit.startLine - 1, edit.deleteCount, ...edit.replacementLines);
}
const template = lines.join("\n");
const validatedConfig = validateUpdatedTemplate(displayConfig, template);
const template = validateTemplate(definition, lines.join("\n"));
await ctx.db
.update(customWidgetDefinitions)
.set({ displayConfig: stringifySuperJson(validatedConfig), updatedAt: new Date() })
.set({ template, updatedAt: new Date() })
.where(eq(customWidgetDefinitions.id, input.id));
logger.info("Patched custom widget template", { id: input.id, editCount: input.edits.length });
return {
id: input.id,
template,
templateLines: lines,
revision: getTemplateRevision(template),
};
return { id: input.id, template, templateLines: lines, revision: getTemplateRevision(template) };
}),
};
@@ -1,80 +1,40 @@
import { TRPCError } from "@trpc/server";
import { stringify as stringifySuperJson } from "superjson";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { eq } from "@homarr/db";
import { customWidgetDefinitions } from "@homarr/db/schema";
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
import { customWidgetImportSchema, customWidgetSecretsInputSchema } from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure } from "../../trpc";
import { parseDisplayConfig } from "./parse-display-config";
import { insertCustomWidgetDefinition } from "./definition-insert";
import { assertSecretSources } from "./secret-policy";
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
const logger = createLogger({ module: "custom-widget" });
export const transferProcedures = {
export: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Export one custom widget as a homarr-custom-widget-v3 object. REQUIRED: id. Secrets are excluded.",
},
})
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!definition) throw new TRPCError({ code: "NOT_FOUND" });
return {
$schema: "homarr-custom-widget-v3" as const,
name: definition.name,
description: definition.description,
iconUrl: definition.iconUrl,
url: definition.url,
authType: definition.authType,
headerName: definition.headerName,
method: definition.method,
requestBody: definition.requestBody,
displayType: definition.displayType,
displayConfig: parseDisplayConfig(
definition.displayConfig,
input.id,
logger,
"Corrupt displayConfig during export",
),
};
}),
export: manageProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!definition) throw new TRPCError({ code: "NOT_FOUND" });
return parseStoredCustomWidgetDefinition(definition);
}),
import: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Create a custom widget from a validated homarr-custom-widget-v3 import object. Secrets are omitted and configured separately.",
},
})
.input(customWidgetImportSchema)
import: manageProcedure
.input(z.object({ widget: customWidgetImportSchema, secrets: customWidgetSecretsInputSchema.default([]) }))
.mutation(async ({ ctx, input }) => {
const id = createId();
await ctx.db.insert(customWidgetDefinitions).values({
id,
name: input.name,
description: input.description,
iconUrl: input.iconUrl,
url: input.url,
authType: input.authType,
headerName: input.headerName,
method: input.method,
requestBody: input.requestBody,
displayType: input.displayType,
displayConfig: stringifySuperJson(input.displayConfig),
creatorId: ctx.session.user.id,
});
logger.info("Imported custom widget definition", { id, name: input.name });
if (input.secrets.length > 0 && !ctx.session.user.permissions.includes("custom-widget-secret-write")) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Writing imported widget secrets requires dedicated permission",
});
}
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 });
return { id };
}),
};
@@ -0,0 +1,172 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import {
applyCustomWidgetSourceSetup,
CUSTOM_WIDGET_SCHEMA,
customWidgetDefinitionSchema,
customWidgetIdentifierSchema,
customWidgetSecretsInputSchema,
getCustomWidgetSecretRequirements,
getCustomWidgetSourceSetups,
} from "@homarr/custom-widgets/core";
import { WorkshopBackend } from "@homarr/workshop/backend";
import { validateWorkshopWidget, WORKSHOP_API_URL } from "@homarr/workshop/schema";
import { permissionRequiredProcedure } from "../../trpc";
import { insertCustomWidgetDefinition } from "./definition-insert";
import { assertSecretSources } from "./secret-policy";
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
const workshop = new WorkshopBackend(process.env.WORKSHOP_API_URL ?? WORKSHOP_API_URL);
async function getWorkshopWidget(submissionId: string) {
try {
const submission = await workshop.get(submissionId);
if (submission.type !== "customWidget" || submission.widgetSchema !== CUSTOM_WIDGET_SCHEMA) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Workshop submission is not a compatible Custom JSX widget",
});
}
const validation = validateWorkshopWidget(submission.content);
if (!validation.success) {
throw new TRPCError({ code: "BAD_REQUEST", message: validation.error });
}
return { submission, widget: validation.data };
} catch (error) {
if (error instanceof TRPCError) throw error;
throw new TRPCError({
code: "BAD_GATEWAY",
message: error instanceof Error ? error.message : "Workshop is unavailable",
cause: error,
});
}
}
const workshopSearchInputSchema = z.object({
query: z.string().trim().min(1).max(200),
sort: z.enum(["top", "newest", "recent", "discussed"]).default("top"),
limit: z.number().int().min(1).max(20).default(10),
});
const sourceOverridesSchema = z.record(
customWidgetIdentifierSchema,
z.strictObject({
baseUrl: z.string(),
networkScope: z.enum(["public", "private", "loopback"]).optional(),
}),
);
export const workshopProcedures = {
workshopSearch: manageProcedure
.meta({ mcp: { enabled: true, description: "Search Workshop for Custom JSX widgets." } })
.input(workshopSearchInputSchema)
.query(async ({ input }) => {
try {
const result = await workshop.list({
page: 1,
perPage: input.limit,
search: input.query,
sort: input.sort,
type: "customWidget",
includeOutdated: true,
});
return {
items: result.items
.filter((item) => item.widgetSchema === CUSTOM_WIDGET_SCHEMA)
.map((item) => ({
id: item.id,
title: item.title,
description: item.description,
authorName: item.authorName,
score: item.score,
revision: item.revision,
outdated: item.outdated,
reportCount: item.reportCount,
})),
};
} catch (error) {
throw new TRPCError({
code: "BAD_GATEWAY",
message: error instanceof Error ? error.message : "Workshop is unavailable",
cause: error,
});
}
}),
workshopGet: manageProcedure
.meta({ mcp: { enabled: true, description: "Get and validate one Workshop Custom JSX widget." } })
.input(z.object({ submissionId: z.string().min(1) }))
.query(async ({ input }) => {
const { submission, widget } = await getWorkshopWidget(input.submissionId);
return {
submission: {
id: submission.id,
title: submission.title,
description: submission.description,
authorName: submission.authorName,
revision: submission.revision,
score: submission.score,
outdated: submission.outdated,
reportCount: submission.reportCount,
},
widget,
sourceSetup: getCustomWidgetSourceSetups(widget.sources),
hasActions: Object.values(widget.requests).some((request) => request.kind === "action"),
methods: [...new Set(Object.values(widget.requests).map((request) => request.method))],
permissions: [...new Set(Object.values(widget.requests).map((request) => request.permission))],
};
}),
workshopInstall: manageProcedure
.meta({ mcp: { enabled: true, description: "Install one validated Workshop Custom JSX widget." } })
.input(
z.object({
submissionId: z.string().min(1),
name: z.string().trim().min(1).max(128).optional(),
sources: sourceOverridesSchema.default({}),
secrets: customWidgetSecretsInputSchema.default([]),
}),
)
.mutation(async ({ ctx, input }) => {
if (input.secrets.length > 0 && !ctx.session.user.permissions.includes("custom-widget-secret-write")) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Writing Workshop widget credentials requires dedicated permission",
});
}
const { widget } = await getWorkshopWidget(input.submissionId);
const unknownSource = Object.keys(input.sources).find((sourceId) => !widget.sources[sourceId]);
if (unknownSource) {
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown Workshop widget source '${unknownSource}'` });
}
const configured = customWidgetDefinitionSchema.parse({
...widget,
name: input.name ?? widget.name,
sources: applyCustomWidgetSourceSetup(
widget.sources,
Object.fromEntries(
Object.entries(input.sources).map(([sourceId, source]) => [
sourceId,
{
baseUrl: source.baseUrl,
networkScope: source.networkScope ?? widget.sources[sourceId]?.networkScope ?? "public",
},
]),
),
),
});
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}`));
return {
status: "installed" as const,
definitionId: id,
sourceSetup: getCustomWidgetSourceSetups(configured.sources, input.secrets),
missingCredentials: getCustomWidgetSecretRequirements(configured.sources)
.filter((requirement) => !configuredSecrets.has(`${requirement.sourceId}:${requirement.kind}`))
.map(({ sourceId, kind }) => ({ sourceId, kind })),
};
}),
};