fix: harden v2 custom widgets and Workshop (#6480)

This commit is contained in:
Thomas authored and GitHub committed 2026-07-31 13:28:53 +02:00
1 parent e8a2c87c2d
commit 557ebe0a2f
138 files changed
+5151 -1976

No files matched your search

@@ -4,3 +4,4 @@ export {
parseStoredCustomWidgetDefinition,
serializeCustomWidgetDefinition,
} from "./router/custom-widget/stored-definition";
export { configureCustomWidgetSourceFromRequest } from "./router/custom-widget/secret-persistence";
+18
View File
@@ -1,16 +1,34 @@
import { z } from "zod/v4";
import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env";
import { normalizeHttpUrl } from "@homarr/workshop/schema";
const publicHttpUrl = (variableName: string) =>
z.string().transform((value, context) => {
try {
return normalizeHttpUrl(value, variableName);
} catch (error) {
context.addIssue({
code: "custom",
message: error instanceof Error ? error.message : `${variableName} is invalid`,
});
return z.NEVER;
}
});
export const env = createEnv({
server: {
KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(),
DEMO_MODE: createBooleanSchema(false),
DEMO_READ_ONLY: createBooleanSchema(true),
HOMARR_WEBSITE_URL: publicHttpUrl("HOMARR_WEBSITE_URL").default("https://homarr.dev"),
WORKSHOP_API_URL: publicHttpUrl("WORKSHOP_API_URL").optional(),
},
runtimeEnv: {
KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME,
DEMO_MODE: process.env.DEMO_MODE,
DEMO_READ_ONLY: process.env.DEMO_READ_ONLY,
HOMARR_WEBSITE_URL: process.env.HOMARR_WEBSITE_URL,
WORKSHOP_API_URL: process.env.WORKSHOP_API_URL,
},
});
+15
View File
@@ -61,6 +61,10 @@ import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access";
import {
throwIfCustomWidgetBoardDuplicationForbidden,
throwIfCustomWidgetPlacementChangeForbidden,
} from "./board/custom-widget-placement-access";
import { generateResponsiveGridFor } from "./board/grid-algorithm";
export const boardRouter = createTRPCRouter({
@@ -372,6 +376,7 @@ export const boardRouter = createTRPCRouter({
}
const { sections: boardSections, items: boardItems, layouts: boardLayouts, ...boardProps } = board;
throwIfCustomWidgetBoardDuplicationForbidden(ctx.session.user.permissions.includes("admin"), boardItems);
const newBoardId = createId();
@@ -855,6 +860,11 @@ export const boardRouter = createTRPCRouter({
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
const dbBoard = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: ctx.session.user.permissions.includes("admin"),
submittedItems: input.items,
storedItems: dbBoard.items,
});
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
@@ -1440,6 +1450,11 @@ export const boardRouter = createTRPCRouter({
.output(z.object({ itemId: z.string() }))
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: ctx.session.user.permissions.includes("admin"),
submittedItems: [{ id: "", kind: input.kind, options: input.options }],
storedItems: [],
});
if (input.integrationIds.length > 0) {
const existing = await ctx.db.query.integrations.findMany({
@@ -0,0 +1,85 @@
import { isDeepStrictEqual } from "node:util";
import { TRPCError } from "@trpc/server";
interface BoardPlacement {
id: string;
kind: string;
options: Record<string, unknown>;
}
interface CustomWidgetPlacementGuardInput {
isAdmin: boolean;
submittedItems: readonly BoardPlacement[];
storedItems: readonly BoardPlacement[];
}
const throwAdminRequired = () => {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only administrators can add or configure Custom Widgets",
});
};
const assertAuthoringAllowed = (isAdmin: boolean) => {
if (!isAdmin) throwAdminRequired();
};
/**
* Protects the security-sensitive part of Custom Widget placements while
* leaving ordinary board operations (layout, presentation and removal) to the
* existing board permission checks.
*/
export const throwIfCustomWidgetPlacementChangeForbidden = ({
isAdmin,
submittedItems,
storedItems,
}: CustomWidgetPlacementGuardInput) => {
const storedById = new Map(storedItems.map((item) => [item.id, item]));
for (const submitted of submittedItems) {
const stored = storedById.get(submitted.id);
if (!stored) {
if (submitted.kind === "customApi") assertAuthoringAllowed(isAdmin);
continue;
}
const wasCustomWidget = stored.kind === "customApi";
const isCustomWidget = submitted.kind === "customApi";
if (wasCustomWidget !== isCustomWidget) {
if (isCustomWidget) assertAuthoringAllowed(isAdmin);
else if (!isAdmin) throwAdminRequired();
continue;
}
if (
wasCustomWidget &&
!isDeepStrictEqual(normalizeCustomWidgetOptions(submitted.options), normalizeCustomWidgetOptions(stored.options))
) {
assertAuthoringAllowed(isAdmin);
}
}
};
export const throwIfCustomWidgetBoardDuplicationForbidden = (
isAdmin: boolean,
items: readonly Pick<BoardPlacement, "kind">[],
) => {
if (items.some((item) => item.kind === "customApi")) assertAuthoringAllowed(isAdmin);
};
function normalizeCustomWidgetOptions(options: Record<string, unknown>) {
return {
...options,
configuration:
options.configuration !== null &&
typeof options.configuration === "object" &&
!Array.isArray(options.configuration)
? options.configuration
: {},
configurationVersion:
typeof options.configurationVersion === "number" && Number.isInteger(options.configurationVersion)
? options.configurationVersion
: 1,
refreshInterval: typeof options.refreshInterval === "number" ? options.refreshInterval : 30,
};
}
@@ -0,0 +1,109 @@
import { describe, expect, test } from "vitest";
import {
throwIfCustomWidgetBoardDuplicationForbidden,
throwIfCustomWidgetPlacementChangeForbidden,
} from "../custom-widget-placement-access";
const placement = {
id: "widget-1",
kind: "customApi",
options: {
definitionId: "definition-1",
refreshInterval: 30,
configuration: { server: "primary" },
},
};
describe("Custom Widget placement access", () => {
test("non-admins cannot add a Custom Widget placement", () => {
expect(() =>
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: false,
submittedItems: [{ ...placement, options: { ...placement.options, configurationVersion: 1 } }],
storedItems: [],
}),
).toThrowError("Only administrators can add or configure Custom Widgets");
});
test.each([
["definition", { ...placement, options: { ...placement.options, definitionId: "definition-2" } }],
["refresh interval", { ...placement, options: { ...placement.options, refreshInterval: 60 } }],
[
"option configuration",
{ ...placement, options: { ...placement.options, configuration: { server: "secondary" } } },
],
["widget kind", { ...placement, kind: "weather" }],
])("non-admins cannot change an existing Custom Widget %s", (_label, submitted) => {
expect(() =>
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: false,
submittedItems: [submitted],
storedItems: [placement],
}),
).toThrowError("Only administrators can add or configure Custom Widgets");
});
test("non-admins may move, resize and change presentation for an existing placement", () => {
const submitted = {
...placement,
layouts: [{ xOffset: 2, yOffset: 4, width: 6, height: 3 }],
advancedOptions: { title: "Status", customCssClasses: ["wide"], borderColor: "#fff" },
};
expect(() =>
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: false,
submittedItems: [submitted],
storedItems: [placement],
}),
).not.toThrow();
});
test("non-admins may remove an existing placement", () => {
expect(() =>
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: false,
submittedItems: [],
storedItems: [placement],
}),
).not.toThrow();
});
test("admins may create and reconfigure placements", () => {
expect(() =>
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: true,
submittedItems: [{ ...placement, options: { definitionId: "definition-2" } }],
storedItems: [],
}),
).not.toThrow();
});
test("duplicating a board with Custom Widgets requires admin", () => {
expect(() => throwIfCustomWidgetBoardDuplicationForbidden(false, [placement])).toThrowError(
"Only administrators can add or configure Custom Widgets",
);
expect(() => throwIfCustomWidgetBoardDuplicationForbidden(true, [placement])).not.toThrow();
expect(() => throwIfCustomWidgetBoardDuplicationForbidden(false, [{ kind: "weather" }])).not.toThrow();
});
test("treats missing persisted v2 defaults as equivalent during presentation-only edits", () => {
expect(() =>
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: false,
submittedItems: [placement],
storedItems: [
{
id: placement.id,
kind: placement.kind,
options: {
definitionId: placement.options.definitionId,
refreshInterval: placement.options.refreshInterval,
configuration: placement.options.configuration,
},
},
],
}),
).not.toThrow();
});
});
@@ -30,24 +30,18 @@ import { assertSecretSources, hasSameSecretBinding, requiredSecretKinds } from "
import { secretProcedures } from "./secret-procedures";
import { workshopProcedures } from "./workshop-procedures";
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
const logger = createLogger({ module: "custom-widget" });
export const customWidgetRouter = createTRPCRouter({
...metadataProcedures,
...managementQueryProcedures,
create: manageProcedure
create: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Create one validated Custom JSX widget." } })
.input(customWidgetCreateSchema)
.mutation(async ({ ctx, input }) => {
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",
});
}
const definition = customWidgetDefinitionSchema.parse(candidate);
assertSecretSources(definition.sources, secrets);
const id = await insertCustomWidgetDefinition(ctx.db, definition, ctx.session.user.id, secrets);
@@ -55,7 +49,8 @@ export const customWidgetRouter = createTRPCRouter({
return { id };
}),
update: manageProcedure
update: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Update one Custom JSX widget." } })
.input(customWidgetUpdateSchema)
.mutation(async ({ ctx, input }) => {
@@ -66,12 +61,6 @@ export const customWidgetRouter = createTRPCRouter({
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: "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() };
@@ -194,7 +183,8 @@ export const customWidgetRouter = createTRPCRouter({
...secretProcedures,
...workshopProcedures,
toggleEnabled: manageProcedure
toggleEnabled: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ id: z.string(), enabled: z.boolean() }))
.mutation(async ({ ctx, input }) => {
if (input.enabled) {
@@ -216,7 +206,8 @@ export const customWidgetRouter = createTRPCRouter({
.where(eq(customWidgetDefinitions.id, input.id));
}),
delete: manageProcedure
delete: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Delete one Custom JSX widget." } })
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
@@ -224,20 +215,23 @@ export const customWidgetRouter = createTRPCRouter({
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" });
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)` };
}),
duplicate: permissionRequiredProcedure
.requiresPermission("admin")
.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" });
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,
@@ -1,5 +1,3 @@
import { parse as parseSuperJson } from "superjson";
import type { legacyCustomWidgetDefinitions } from "@homarr/db/schema";
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt";
@@ -13,14 +11,17 @@ export function buildLegacyCustomWidgetMigrationPrompt(
$schema: "homarr-custom-widget-v1",
name: definition.name,
description: definition.description,
iconUrl: definition.iconUrl,
iconUrl: definition.iconUrl ? redactLegacyUrl(definition.iconUrl) : null,
url: redactLegacyUrl(definition.url),
authType: definition.authType,
headerName: definition.headerName,
method: definition.method,
requestBody: redactLegacyRequestBody(definition.requestBody),
displayType: definition.displayType,
displayConfig: parseLegacyDisplayConfig(definition.displayConfig),
displayConfig: {
migrationNote:
"The preserved v1 display configuration was intentionally omitted because legacy free-text fields may contain credentials. Reconstruct it from the display type and visible behavior.",
},
configuredSecretKinds,
};
return buildCustomWidgetAiPrompt(
@@ -69,11 +70,3 @@ function redactAllValues(value: unknown): unknown {
if (value === null) return null;
return "[REDACTED VALUE]";
}
function parseLegacyDisplayConfig(value: string): unknown {
try {
return parseSuperJson(value) as unknown;
} catch {
return { migrationNote: "The stored v1 display configuration could not be decoded.", raw: value.slice(0, 4_000) };
}
}
@@ -6,7 +6,7 @@ import { eq, or } from "@homarr/db";
import { boards, customWidgetDefinitions, legacyCustomWidgetDefinitions } from "@homarr/db/schema";
import { collectCustomWidgetRequestReferences } from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure, protectedProcedure } from "../../trpc";
import { permissionRequiredProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
import { executeCustomWidgetRequest } from "./request-executor";
@@ -26,10 +26,9 @@ import {
mapLegacyCustomWidgetListItem,
} from "./management-query-mappers";
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
export const managementQueryProcedures = {
list: manageProcedure
list: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "List all Custom JSX widgets." } })
.query(async ({ ctx }) => {
const [definitions, legacyDefinitions] = await Promise.all([
@@ -43,11 +42,13 @@ export const managementQueryProcedures = {
}),
]);
const current = definitions.map(mapCustomWidgetListItem);
const legacy = legacyDefinitions.map(mapLegacyCustomWidgetListItem);
const currentIds = new Set(definitions.map(({ id }) => id));
const legacy = legacyDefinitions.filter(({ id }) => !currentIds.has(id)).map(mapLegacyCustomWidgetListItem);
return [...current, ...legacy].toSorted((left, right) => left.name.localeCompare(right.name));
}),
legacyMigrationPrompt: manageProcedure
legacyMigrationPrompt: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
@@ -72,7 +73,8 @@ export const managementQueryProcedures = {
};
}),
get: manageProcedure
get: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Get one Custom JSX widget without secret values." } })
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
@@ -96,7 +98,8 @@ export const managementQueryProcedures = {
};
}),
available: protectedProcedure
available: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ boardId: z.string(), currentId: z.string().optional() }))
.query(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
@@ -115,7 +118,8 @@ export const managementQueryProcedures = {
return [...available, mapLegacyAvailableCustomWidget(legacy)];
}),
optionRequest: protectedProcedure
optionRequest: permissionRequiredProcedure
.requiresPermission("admin")
.input(
z.object({
boardId: z.string(),
@@ -20,6 +20,7 @@ export function mapCustomWidgetListItem(definition: StoredDefinition & { secrets
const result = safeParseStoredCustomWidgetDefinition(definition);
if (!result.success) {
logger.warn("Skipped parsing invalid custom widget definition", {
event: "custom_widget_definition_invalid",
id: definition.id,
issueCount: result.issues.length,
});
@@ -88,7 +89,11 @@ export function mapLegacyCustomWidgetListItem(definition: LegacyDefinition) {
export function mapAvailableCustomWidget(definition: StoredDefinition) {
const result = safeParseStoredCustomWidgetDefinition(definition);
if (!result.success) {
logger.warn("Excluded invalid custom widget definition from board picker", { id: definition.id });
logger.warn("Excluded invalid custom widget definition from board picker", {
event: "custom_widget_definition_invalid",
id: definition.id,
surface: "board-picker",
});
return [];
}
@@ -4,15 +4,16 @@ import { CUSTOM_WIDGET_MCP_AUTHORING_PROMPT } from "@homarr/custom-widgets/autho
import { getCustomWidgetSkill } from "@homarr/custom-widgets/authoring-resources";
import { customWidgetImportSchema, getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure, publicProcedure } from "../../trpc";
import { permissionRequiredProcedure } from "../../trpc";
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
export const metadataProcedures = {
schema: publicProcedure
schema: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Get the current Custom JSX widget JSON Schema." } })
.query(() => getCustomWidgetJsonSchema()),
getAuthoringPrompt: publicProcedure
getAuthoringPrompt: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Get the current Custom Widget authoring instructions." } })
.query(() => ({
version: 2,
@@ -25,11 +26,13 @@ export const metadataProcedures = {
httpResources: ["/api/custom-widgets/schema", "/api/custom-widgets/components", "/api/custom-widgets/skill"],
})),
getSkill: publicProcedure
getSkill: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Get the portable Homarr Custom Widget skill." } })
.query(() => getCustomWidgetSkill()),
validate: manageProcedure
validate: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Validate one Custom JSX widget without saving it." } })
.input(z.object({ widget: z.unknown() }))
.query(({ input }) => {
@@ -12,10 +12,9 @@ import { renderRequestBody, renderRequestTarget, resolveCustomWidgetRequestValue
import { acquireCustomWidgetRequestLimit } from "./request-limits";
import { getPreviewSession } from "./preview-sessions";
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
export const previewActionProcedures = {
previewAction: manageProcedure
previewAction: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: { enabled: true, description: "Simulate or run one named action in a custom widget preview session." },
})
@@ -17,8 +17,6 @@ import { createPreviewSession, getPreviewSession } from "./preview-sessions";
import { hasSameSecretBinding, requiredSecretKinds } from "./secret-policy";
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
const previewCreateInputSchema = z.object({
definition: customWidgetDefinitionSchema,
secrets: customWidgetSecretsInputSchema.default([]),
@@ -26,13 +24,11 @@ const previewCreateInputSchema = z.object({
options: z.record(z.string(), z.unknown()).optional(),
});
const previewCreateProcedure = manageProcedure
const previewCreateProcedure = permissionRequiredProcedure
.requiresPermission("admin")
.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) {
@@ -62,7 +58,7 @@ const previewCreateProcedure = manageProcedure
);
if (missingReplacement) {
throw new TRPCError({
code: ctx.session.user.permissions.includes("custom-widget-secret-write") ? "BAD_REQUEST" : "FORBIDDEN",
code: "BAD_REQUEST",
message: "Source security settings changed; re-enter its credentials to preview this definition",
});
}
@@ -123,25 +119,28 @@ const previewCreateProcedure = manageProcedure
export const previewBaseProcedures = {
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,
};
}),
previewGet: permissionRequiredProcedure
.requiresPermission("admin")
.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,
};
}),
};
@@ -13,10 +13,9 @@ import { hashRuntimeParams, renderRequestBody, renderRequestTarget } from "./req
import { acquireCustomWidgetRequestLimit } from "./request-limits";
import { getPreviewJournal, getPreviewSession, setPreviewSessionLiveActions } from "./preview-sessions";
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
export const previewQueryProcedures = {
previewQuery: manageProcedure
previewQuery: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
@@ -76,13 +75,15 @@ export const previewQueryProcedures = {
}
}),
setPreviewLiveActions: manageProcedure
setPreviewLiveActions: permissionRequiredProcedure
.requiresPermission("admin")
.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: manageProcedure
previewJournal: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
@@ -0,0 +1,289 @@
import { TRPCError } from "@trpc/server";
import { encryptSecret } from "@homarr/common/server";
import type { Database } from "@homarr/db";
import { and, eq, handleTransactionsAsync } from "@homarr/db";
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
import type { CustomWidgetCreateInput, CustomWidgetSecretKind, CustomWidgetSource } from "@homarr/custom-widgets/core";
import { customWidgetSourceSchema, hasSameCustomWidgetSourceAuthentication } from "@homarr/custom-widgets/core";
import { assertSecretSources, hasSameSecretBinding } from "./secret-policy";
import { parseStoredCustomWidgetDefinition, serializeCustomWidgetDefinition } from "./stored-definition";
type CustomWidgetSecretInput = CustomWidgetCreateInput["secrets"][number];
type ConfigureCustomWidgetSourceInput = {
definitionId: string;
sourceId: string;
baseUrl: string;
networkScope?: CustomWidgetSource["networkScope"];
secrets: CustomWidgetCreateInput["secrets"];
expectedSource?: CustomWidgetSource;
};
type SourceConfigurationIssue = "definition-not-found" | "source-not-found" | "binding-changed";
class SourceConfigurationError extends Error {
constructor(readonly issue: SourceConfigurationIssue) {
super(`Custom widget source configuration failed: ${issue}`);
this.name = "SourceConfigurationError";
}
}
export async function setCustomWidgetSecret(db: Database, definitionId: string, secret: CustomWidgetSecretInput) {
const updatedAt = new Date();
const secretRow = {
definitionId,
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
.delete(schema.customWidgetSecrets)
.where(
and(
eq(schema.customWidgetSecrets.definitionId, definitionId),
eq(schema.customWidgetSecrets.sourceId, secret.sourceId),
eq(schema.customWidgetSecrets.kind, secret.kind),
),
);
await transaction.insert(schema.customWidgetSecrets).values(secretRow);
await transaction
.update(schema.customWidgetDefinitions)
.set({ updatedAt })
.where(eq(schema.customWidgetDefinitions.id, definitionId));
});
},
handleSync(database) {
database.transaction((transaction) => {
transaction
.delete(customWidgetSecrets)
.where(
and(
eq(customWidgetSecrets.definitionId, definitionId),
eq(customWidgetSecrets.sourceId, secret.sourceId),
eq(customWidgetSecrets.kind, secret.kind),
),
)
.run();
transaction.insert(customWidgetSecrets).values(secretRow).run();
transaction
.update(customWidgetDefinitions)
.set({ updatedAt })
.where(eq(customWidgetDefinitions.id, definitionId))
.run();
});
},
});
}
export async function configureCustomWidgetSource(db: Database, input: ConfigureCustomWidgetSourceInput) {
try {
return await persistCustomWidgetSourceConfiguration(db, input);
} catch (error) {
if (!(error instanceof SourceConfigurationError)) throw error;
if (error.issue === "definition-not-found") throw new TRPCError({ code: "NOT_FOUND" });
if (error.issue === "source-not-found") {
throw new TRPCError({ code: "NOT_FOUND", message: "Widget source not found" });
}
throw new TRPCError({
code: "CONFLICT",
message: "Widget source authentication changed while it was being configured",
});
}
}
export async function configureCustomWidgetSourceFromRequest(
db: Database,
input: ConfigureCustomWidgetSourceInput & { expectedSource: CustomWidgetSource },
) {
try {
return {
status: "configured" as const,
source: await persistCustomWidgetSourceConfiguration(db, input),
};
} catch (error) {
if (!(error instanceof SourceConfigurationError)) throw error;
return { status: error.issue } as const;
}
}
async function persistCustomWidgetSourceConfiguration(db: Database, input: ConfigureCustomWidgetSourceInput) {
let configuredSource: CustomWidgetSource | undefined;
const secretRows = input.secrets.map((secret) => ({
definitionId: input.definitionId,
sourceId: input.sourceId,
kind: secret.kind,
encryptedValue: encryptSecret(secret.value),
updatedAt: new Date(),
}));
await handleTransactionsAsync(db, {
async handleAsync(database, schema) {
await database.transaction(async (transaction) => {
const [stored] = await transaction
.select()
.from(schema.customWidgetDefinitions)
.where(eq(schema.customWidgetDefinitions.id, input.definitionId))
.limit(1)
.for("update");
const sourceUpdate = prepareSourceUpdate(stored, input);
configuredSource = sourceUpdate.source;
await transaction
.update(schema.customWidgetDefinitions)
.set(sourceUpdate.definitionChanges)
.where(eq(schema.customWidgetDefinitions.id, input.definitionId));
if (sourceUpdate.bindingChanged) {
await transaction
.delete(schema.customWidgetSecrets)
.where(
and(
eq(schema.customWidgetSecrets.definitionId, input.definitionId),
eq(schema.customWidgetSecrets.sourceId, input.sourceId),
),
);
}
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(database) {
database.transaction(
(transaction) => {
const stored = transaction
.select()
.from(customWidgetDefinitions)
.where(eq(customWidgetDefinitions.id, input.definitionId))
.limit(1)
.get();
const sourceUpdate = prepareSourceUpdate(stored, input);
configuredSource = sourceUpdate.source;
transaction
.update(customWidgetDefinitions)
.set(sourceUpdate.definitionChanges)
.where(eq(customWidgetDefinitions.id, input.definitionId))
.run();
if (sourceUpdate.bindingChanged) {
transaction
.delete(customWidgetSecrets)
.where(
and(
eq(customWidgetSecrets.definitionId, input.definitionId),
eq(customWidgetSecrets.sourceId, input.sourceId),
),
)
.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();
}
},
{ behavior: "immediate" },
);
},
});
if (!configuredSource) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
return configuredSource;
}
function prepareSourceUpdate(
stored: Parameters<typeof parseStoredCustomWidgetDefinition>[0] | undefined,
input: ConfigureCustomWidgetSourceInput,
) {
if (!stored) throw new SourceConfigurationError("definition-not-found");
const definition = parseStoredCustomWidgetDefinition(stored);
const previousSource = definition.sources[input.sourceId];
if (!previousSource) throw new SourceConfigurationError("source-not-found");
if (input.expectedSource && !hasSameCustomWidgetSourceAuthentication(previousSource, input.expectedSource)) {
throw new SourceConfigurationError("binding-changed");
}
const source = customWidgetSourceSchema.parse({
...previousSource,
baseUrl: input.baseUrl,
networkScope: input.networkScope ?? previousSource.networkScope,
});
assertSecretSources({ [input.sourceId]: source }, input.secrets);
return {
source,
bindingChanged: !hasSameSecretBinding(previousSource, source),
definitionChanges: {
...serializeCustomWidgetDefinition({
...definition,
sources: { ...definition.sources, [input.sourceId]: source },
}),
updatedAt: new Date(),
},
};
}
export async function clearCustomWidgetSecret(
db: Database,
input: {
definitionId: string;
sourceId: string;
kind: CustomWidgetSecretKind;
},
) {
const updatedAt = new Date();
await handleTransactionsAsync(db, {
async handleAsync(database, schema) {
await database.transaction(async (transaction) => {
await transaction
.delete(schema.customWidgetSecrets)
.where(
and(
eq(schema.customWidgetSecrets.definitionId, input.definitionId),
eq(schema.customWidgetSecrets.sourceId, input.sourceId),
eq(schema.customWidgetSecrets.kind, input.kind),
),
);
await transaction
.update(schema.customWidgetDefinitions)
.set({ updatedAt })
.where(eq(schema.customWidgetDefinitions.id, input.definitionId));
});
},
handleSync(database) {
database.transaction((transaction) => {
transaction
.delete(customWidgetSecrets)
.where(
and(
eq(customWidgetSecrets.definitionId, input.definitionId),
eq(customWidgetSecrets.sourceId, input.sourceId),
eq(customWidgetSecrets.kind, input.kind),
),
)
.run();
transaction
.update(customWidgetDefinitions)
.set({ updatedAt })
.where(eq(customWidgetDefinitions.id, input.definitionId))
.run();
});
},
});
}
@@ -1,9 +1,8 @@
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 { eq } from "@homarr/db";
import { customWidgetDefinitions } from "@homarr/db/schema";
import {
customWidgetSecretInputSchema,
customWidgetSecretsInputSchema,
@@ -16,12 +15,10 @@ import {
createCustomWidgetConfigurationRequest,
getCustomWidgetConfigurationRequestForUser,
} from "./configuration-requests";
import { assertSecretSources, hasSameSecretBinding, requiredSecretKinds } from "./secret-policy";
import { assertSecretSources, requiredSecretKinds } from "./secret-policy";
import { clearCustomWidgetSecret, configureCustomWidgetSource, setCustomWidgetSecret } from "./secret-persistence";
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");
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
const secretRequestInputSchema = z
.object({
@@ -44,7 +41,8 @@ const secretRequestInputSchema = z
});
export const secretProcedures = {
secretSet: secretWriteProcedure
secretSet: permissionRequiredProcedure
.requiresPermission("admin")
.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 }) => {
@@ -54,30 +52,12 @@ export const secretProcedures = {
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));
await setCustomWidgetSecret(ctx.db, input.definitionId, input.secret);
return { sourceId: input.secret.sourceId, kind: input.secret.kind, isSet: true };
}),
sourceConfigure: manageProcedure
sourceConfigure: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Configure one custom widget API source and its credentials." } })
.input(
z.object({
@@ -89,9 +69,6 @@ export const secretProcedures = {
}),
)
.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),
});
@@ -105,91 +82,24 @@ export const secretProcedures = {
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 bindingChanged = !hasSameSecretBinding(current, source);
const secretRows = input.secrets.map((secret) => ({
const configuredSource = await configureCustomWidgetSource(ctx.db, {
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));
if (bindingChanged) {
await transaction
.delete(schema.customWidgetSecrets)
.where(
and(
eq(schema.customWidgetSecrets.definitionId, input.definitionId),
eq(schema.customWidgetSecrets.sourceId, input.sourceId),
),
);
}
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();
if (bindingChanged) {
transaction
.delete(customWidgetSecrets)
.where(
and(
eq(customWidgetSecrets.definitionId, input.definitionId),
eq(customWidgetSecrets.sourceId, input.sourceId),
),
)
.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();
}
});
},
baseUrl: source.baseUrl,
networkScope: input.networkScope,
secrets: input.secrets,
});
return {
definitionId: input.definitionId,
sourceId: input.sourceId,
baseUrl: source.baseUrl,
networkScope: source.networkScope,
baseUrl: configuredSource.baseUrl,
networkScope: configuredSource.networkScope,
configuredSecrets: input.secrets.map(({ kind }) => kind),
};
}),
configurationRequestUser: manageProcedure
configurationRequestUser: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Create or check a short-lived user source-configuration request." } })
.input(secretRequestInputSchema)
.mutation(async ({ ctx, input }) => {
@@ -203,12 +113,6 @@ export const secretProcedures = {
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) {
if (!ctx.session.user.permissions.includes("custom-widget-secret-write")) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Creating a stored credential setup link requires dedicated permission",
});
}
const stored = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.definitionId),
});
@@ -258,7 +162,8 @@ export const secretProcedures = {
};
}),
secretClear: secretWriteProcedure
secretClear: permissionRequiredProcedure
.requiresPermission("admin")
.input(
z.object({ definitionId: z.string(), sourceId: z.string(), kind: z.enum(["apiKey", "username", "password"]) }),
)
@@ -267,19 +172,7 @@ export const secretProcedures = {
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));
await clearCustomWidgetSecret(ctx.db, input);
return { sourceId: input.sourceId, kind: input.kind, isSet: false };
}),
};
@@ -11,7 +11,6 @@ import { customWidgetDefinitions } from "@homarr/db/schema";
import { permissionRequiredProcedure } from "../../trpc";
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);
@@ -33,22 +32,26 @@ const validateTemplate = (definition: ReturnType<typeof parseStoredCustomWidgetD
};
export const templateProcedures = {
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),
};
}),
readTemplate: permissionRequiredProcedure
.requiresPermission("admin")
.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),
};
}),
writeTemplate: manageProcedure
writeTemplate: permissionRequiredProcedure
.requiresPermission("admin")
.input(
z
.object({ id: z.string(), template: z.string().optional(), templateLines: z.array(z.string()).optional() })
@@ -74,7 +77,8 @@ export const templateProcedures = {
return { id: input.id, template, templateLines: template.split("\n"), revision: getTemplateRevision(template) };
}),
templatePatch: manageProcedure
templatePatch: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Patch selected JSX template lines using an optimistic revision." } })
.input(
z.object({
@@ -7,39 +7,37 @@ import { eq, handleTransactionsAsync } from "@homarr/db";
import { customWidgetDefinitions, customWidgetSecrets, legacyCustomWidgetDefinitions } from "@homarr/db/schema";
import { customWidgetImportSchema, customWidgetSecretsInputSchema } from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure } from "../../trpc";
import { insertCustomWidgetDefinition } from "./definition-insert";
import { permissionRequiredProcedure } from "../../trpc";
import { assertSecretSources, requiredSecretKinds } from "./secret-policy";
import { parseStoredCustomWidgetDefinition, serializeCustomWidgetDefinition } from "./stored-definition";
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
const logger = createLogger({ module: "custom-widget" });
export const transferProcedures = {
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);
}),
export: permissionRequiredProcedure
.requiresPermission("admin")
.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: manageProcedure
import: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ widget: customWidgetImportSchema, 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 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 };
}),
migrateLegacy: manageProcedure
migrateLegacy: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
@@ -55,12 +53,6 @@ export const transferProcedures = {
}),
)
.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 migrated widget secrets requires dedicated permission",
});
}
assertSecretSources(input.widget.sources, input.secrets);
const [legacy, current] = await Promise.all([
ctx.db.query.legacyCustomWidgetDefinitions.findFirst({
@@ -108,41 +100,21 @@ export const transferProcedures = {
await database.transaction(async (transaction) => {
await transaction.insert(schema.customWidgetDefinitions).values(definitionRow);
if (secretRows.length > 0) await transaction.insert(schema.customWidgetSecrets).values(secretRows);
await transaction
.delete(schema.legacyCustomWidgetDefinitions)
.where(eq(schema.legacyCustomWidgetDefinitions.id, legacy.id));
});
},
handleSync(database) {
database.transaction((transaction) => {
transaction.insert(customWidgetDefinitions).values(definitionRow).run();
if (secretRows.length > 0) transaction.insert(customWidgetSecrets).values(secretRows).run();
transaction
.delete(legacyCustomWidgetDefinitions)
.where(eq(legacyCustomWidgetDefinitions.id, legacy.id))
.run();
});
},
});
logger.info("Migrated legacy custom widget definition", {
logger.info("Created v2 replacement for preserved legacy custom widget definition", {
id: legacy.id,
preservedSecretCount: preservedSecrets.length,
});
return { id: legacy.id, preservedSecretCount: preservedSecrets.length };
}),
deleteLegacy: manageProcedure
.meta({
mcp: {
enabled: true,
description: "Permanently delete one preserved legacy Custom Widget that will not be migrated.",
},
})
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.db.delete(legacyCustomWidgetDefinitions).where(eq(legacyCustomWidgetDefinitions.id, input.id));
logger.info("Deleted legacy custom widget definition", { id: input.id });
}),
};
function canPreserveLegacySecrets(
@@ -10,15 +10,35 @@ import {
getCustomWidgetSecretRequirements,
getCustomWidgetSourceSetups,
} from "@homarr/custom-widgets/core";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { WorkshopBackend } from "@homarr/workshop/backend";
import { validateWorkshopWidget, WORKSHOP_API_URL } from "@homarr/workshop/schema";
import { resolveHomarrUrlConfig, validateWorkshopWidget } from "@homarr/workshop/schema";
import { env } from "../../env";
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);
const logger = createLogger({ module: "custom-widget:workshop" });
const workshopUrls = resolveHomarrUrlConfig({
homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
workshopApiUrl: env.WORKSHOP_API_URL,
});
const workshop = new WorkshopBackend(workshopUrls.workshopApiUrl);
function throwWorkshopUnavailable(
message: string,
event: "workshop_widget_lookup_failed" | "workshop_widget_search_failed",
): never {
logger.error(message, {
event,
errorName: "WorkshopBackendError",
});
throw new TRPCError({
code: "BAD_GATEWAY",
message: "Workshop is unavailable",
});
}
async function getWorkshopWidget(submissionId: string) {
try {
@@ -36,11 +56,7 @@ async function getWorkshopWidget(submissionId: string) {
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,
});
throwWorkshopUnavailable("Workshop widget lookup failed", "workshop_widget_lookup_failed");
}
}
@@ -59,7 +75,8 @@ const sourceOverridesSchema = z.record(
);
export const workshopProcedures = {
workshopSearch: manageProcedure
workshopSearch: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Search Workshop for Custom JSX widgets." } })
.input(workshopSearchInputSchema)
.query(async ({ input }) => {
@@ -86,16 +103,13 @@ export const workshopProcedures = {
reportCount: item.reportCount,
})),
};
} catch (error) {
throw new TRPCError({
code: "BAD_GATEWAY",
message: error instanceof Error ? error.message : "Workshop is unavailable",
cause: error,
});
} catch {
throwWorkshopUnavailable("Workshop widget search failed", "workshop_widget_search_failed");
}
}),
workshopGet: manageProcedure
workshopGet: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Get and validate one Workshop Custom JSX widget." } })
.input(z.object({ submissionId: z.string().min(1) }))
.query(async ({ input }) => {
@@ -119,7 +133,8 @@ export const workshopProcedures = {
};
}),
workshopInstall: manageProcedure
workshopInstall: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Install one validated Workshop Custom JSX widget." } })
.input(
z.object({
@@ -130,12 +145,6 @@ export const workshopProcedures = {
}),
)
.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) {
@@ -1483,6 +1483,63 @@ describe("saveLayouts should save layout changes", () => {
});
});
describe("Custom Widget placement permissions", () => {
test("rejects a direct addItem request from a non-admin board modifier", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId } = await createFullBoardAsync(db, "custom-widget-add");
await expect(
caller.addItem({
boardId,
kind: "customApi",
options: {
definitionId: "definition-1",
refreshInterval: 30,
configuration: {},
},
integrationIds: [],
}),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
test("rejects a direct saveBoard request that adds a Custom Widget for a non-admin", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, sectionId, layoutId } = await createFullBoardAsync(db, "custom-widget-save");
await expect(
caller.saveBoard({
id: boardId,
sections: [{ id: sectionId, kind: "empty", yOffset: 0, xOffset: 0 }],
items: [
{
id: createId(),
kind: "customApi",
options: {
definitionId: "definition-1",
refreshInterval: 30,
configuration: {},
},
integrationIds: [],
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
layouts: [
{
layoutId,
sectionId,
height: 1,
width: 1,
xOffset: 0,
yOffset: 0,
},
],
},
],
}),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
});
const expectInputToBeFullBoardWithName = (
input: RouterOutputs["board"]["getHomeBoard"],
props: { name: string } & Awaited<ReturnType<typeof createFullBoardAsync>>,
@@ -9,24 +9,26 @@ import {
users,
} from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { BUNDLED_CUSTOM_WIDGETS } from "@homarr/custom-widgets/core";
import { BUNDLED_CUSTOM_WIDGETS, customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import { describe, expect, test } from "vitest";
import { customWidgetRouter } from "../../custom-widget/custom-widget-router";
import { getCustomWidgetConfigurationRequestForUser } from "../../custom-widget/configuration-requests";
import { configureCustomWidgetSourceFromRequest } from "../../custom-widget/secret-persistence";
import { serializeCustomWidgetDefinition } from "../../custom-widget/stored-definition";
const userId = createId();
const session = {
user: {
id: userId,
permissions: ["custom-widget-manage", "custom-widget-secret-write"],
permissions: ["admin"],
colorScheme: "light",
},
expires: new Date().toISOString(),
} satisfies Session;
const manageOnlySession = {
const nonAdminSession = {
...session,
user: { ...session.user, permissions: ["custom-widget-manage"] },
user: { ...session.user, permissions: ["board-modify-all"] },
} satisfies Session;
const jellyfin = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-jellyfin")?.widget;
@@ -38,11 +40,11 @@ function createCaller(db: ReturnType<typeof createDb>) {
return customWidgetRouter.createCaller({ db, deviceType: undefined, session });
}
function createManageOnlyCaller(db: ReturnType<typeof createDb>) {
function createNonAdminCaller(db: ReturnType<typeof createDb>) {
return customWidgetRouter.createCaller({
db,
deviceType: undefined,
session: manageOnlySession,
session: nonAdminSession,
});
}
@@ -54,14 +56,24 @@ async function prepareDatabase() {
function rejectSecretInserts(db: ReturnType<typeof createDb>) {
db.run(
sql.raw(`CREATE TRIGGER reject_custom_widget_secret_insert
BEFORE INSERT ON custom_widget_secret
sql.raw(`CREATE TRIGGER reject_custom_widget_v2_secret_insert
BEFORE INSERT ON custom_widget_v2_secret
BEGIN
SELECT RAISE(ABORT, 'forced secret insert failure');
END`),
);
}
function rejectDefinitionUpdates(db: ReturnType<typeof createDb>) {
db.run(
sql.raw(`CREATE TRIGGER reject_custom_widget_v2_definition_update
BEFORE UPDATE ON custom_widget_v2_definition
BEGIN
SELECT RAISE(ABORT, 'forced definition update failure');
END`),
);
}
async function insertLegacyJellyfin(db: ReturnType<typeof createDb>) {
const id = "legacy-jellyfin";
const encryptedValue = "preserved.secret" as `${string}.${string}`;
@@ -88,13 +100,21 @@ async function insertLegacyJellyfin(db: ReturnType<typeof createDb>) {
const secret = { sourceId: "default", kind: "apiKey" as const, value: "test-secret" };
describe("custom widget definition persistence", () => {
test("atomically replaces a confirmed legacy widget while preserving its stable ID and credential", async () => {
test("atomically creates a v2 replacement while preserving the legacy widget and its stable ID", async () => {
const db = await prepareDatabase();
const legacy = await insertLegacyJellyfin(db);
await createCaller(db).migrateLegacy({ id: legacy.id, widget: jellyfin, secrets: [] });
expect(await db.query.legacyCustomWidgetDefinitions.findMany()).toHaveLength(0);
expect(await db.query.legacyCustomWidgetDefinitions.findFirst()).toMatchObject({
id: legacy.id,
name: "Legacy Jellyfin",
enabled: false,
});
expect(await db.query.legacyCustomWidgetSecrets.findFirst()).toMatchObject({
definitionId: legacy.id,
encryptedValue: legacy.encryptedValue,
});
expect(await db.query.customWidgetDefinitions.findFirst()).toMatchObject({
id: legacy.id,
enabled: false,
@@ -106,6 +126,20 @@ describe("custom widget definition persistence", () => {
kind: "apiKey",
encryptedValue: legacy.encryptedValue,
});
const migratedList = await createCaller(db).list();
expect(migratedList).toHaveLength(1);
expect(migratedList[0]).toMatchObject({ id: legacy.id, migrationRequired: false });
await createCaller(db).delete({ id: legacy.id });
const fallbackList = await createCaller(db).list();
expect(fallbackList).toHaveLength(1);
expect(fallbackList[0]).toMatchObject({ id: legacy.id, migrationRequired: true });
expect(await db.query.legacyCustomWidgetSecrets.findFirst()).toMatchObject({
definitionId: legacy.id,
encryptedValue: legacy.encryptedValue,
});
});
test("keeps the legacy widget recoverable when confirmed replacement fails", async () => {
@@ -149,6 +183,30 @@ describe("custom widget definition persistence", () => {
expect(await db.query.customWidgetSecrets.findMany()).toHaveLength(0);
});
test("refuses to export a stored definition containing embedded credentials", async () => {
const db = await prepareDatabase();
const definition = customWidgetDefinitionSchema.parse(jellyfin);
const counts = definition.requests.counts;
if (!counts) throw new Error("Jellyfin counts request is missing");
const id = createId();
await db.insert(customWidgetDefinitions).values({
id,
...serializeCustomWidgetDefinition({
...definition,
requests: {
...definition.requests,
counts: {
...counts,
headers: { ...counts.headers, "X-Auth": "Bearer sk-secret-123456" },
},
},
}),
creatorId: userId,
});
await expect(createCaller(db).export({ id })).rejects.toThrow("Credentials must use source authentication");
});
test("rolls back definition and secret changes when a replacement secret cannot be stored", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
@@ -180,6 +238,69 @@ describe("custom widget definition persistence", () => {
expect(storedSecret).toEqual(originalSecret);
});
test("keeps the existing secret when setting its replacement fails", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
const created = await caller.create({ ...jellyfin, secrets: [secret] });
const originalDefinition = await db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, created.id),
});
const originalSecret = await db.query.customWidgetSecrets.findFirst({
where: eq(customWidgetSecrets.definitionId, created.id),
});
rejectSecretInserts(db);
await expect(
caller.secretSet({
definitionId: created.id,
secret: { ...secret, value: "replacement-secret" },
}),
).rejects.toThrow("forced secret insert failure");
expect(
await db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, created.id),
}),
).toEqual(originalDefinition);
expect(
await db.query.customWidgetSecrets.findFirst({
where: eq(customWidgetSecrets.definitionId, created.id),
}),
).toEqual(originalSecret);
});
test("keeps the existing secret when clearing it cannot update the definition revision", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
const created = await caller.create({ ...jellyfin, secrets: [secret] });
const originalDefinition = await db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, created.id),
});
const originalSecret = await db.query.customWidgetSecrets.findFirst({
where: eq(customWidgetSecrets.definitionId, created.id),
});
rejectDefinitionUpdates(db);
await expect(
caller.secretClear({
definitionId: created.id,
sourceId: secret.sourceId,
kind: secret.kind,
}),
).rejects.toThrow("forced definition update failure");
expect(
await db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, created.id),
}),
).toEqual(originalDefinition);
expect(
await db.query.customWidgetSecrets.findFirst({
where: eq(customWidgetSecrets.definitionId, created.id),
}),
).toEqual(originalSecret);
});
test("configures a source URL and credential together", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
@@ -249,7 +370,7 @@ describe("custom widget definition persistence", () => {
};
await expect(
createManageOnlyCaller(db).previewCreate({
createNonAdminCaller(db).previewCreate({
definitionId: created.id,
definition: changedDefinition,
secrets: [],
@@ -260,11 +381,11 @@ describe("custom widget definition persistence", () => {
).resolves.toMatchObject({ success: true });
});
test("clears persisted credentials when a manage-only source edit changes its binding", async () => {
test("clears persisted credentials when an admin source edit changes its binding", async () => {
const db = await prepareDatabase();
const created = await createCaller(db).create({ ...jellyfin, secrets: [secret] });
await createManageOnlyCaller(db).sourceConfigure({
await createCaller(db).sourceConfigure({
definitionId: created.id,
sourceId: "default",
baseUrl: "https://other.example/api",
@@ -279,12 +400,109 @@ describe("custom widget definition persistence", () => {
).toHaveLength(0);
});
test("uses the persisted source binding from inside the configuration transaction", async () => {
const db = await prepareDatabase();
const created = await createCaller(db).create({ ...jellyfin, secrets: [secret] });
const definition = customWidgetDefinitionSchema.parse(jellyfin);
const defaultSource = definition.sources.default;
if (!defaultSource) throw new Error("Jellyfin default source is missing");
await db
.update(customWidgetDefinitions)
.set(
serializeCustomWidgetDefinition({
...definition,
name: "Concurrent definition edit",
sources: {
...definition.sources,
default: { ...defaultSource, baseUrl: "https://changed.example/api" },
},
}),
)
.where(eq(customWidgetDefinitions.id, created.id));
await expect(
configureCustomWidgetSourceFromRequest(db, {
definitionId: created.id,
sourceId: "default",
baseUrl: defaultSource.baseUrl,
networkScope: defaultSource.networkScope,
secrets: [],
expectedSource: defaultSource,
}),
).resolves.toMatchObject({
status: "configured",
source: { baseUrl: defaultSource.baseUrl },
});
expect(await createCaller(db).get({ id: created.id })).toMatchObject({
name: "Concurrent definition edit",
sources: { default: { baseUrl: defaultSource.baseUrl } },
});
expect(
await db.query.customWidgetSecrets.findMany({
where: eq(customWidgetSecrets.definitionId, created.id),
}),
).toHaveLength(0);
});
test("rejects stale setup-link credentials after a concurrent authentication binding change", async () => {
const db = await prepareDatabase();
const created = await createCaller(db).create({ ...jellyfin, secrets: [] });
const definition = customWidgetDefinitionSchema.parse(jellyfin);
const expectedSource = definition.sources.default;
if (!expectedSource || typeof expectedSource.auth === "string") {
throw new Error("Jellyfin default source must use header authentication");
}
await db
.update(customWidgetDefinitions)
.set(
serializeCustomWidgetDefinition({
...definition,
name: "Concurrent definition edit",
sources: {
...definition.sources,
default: {
...expectedSource,
auth: { ...expectedSource.auth, name: "X-Replaced-Credential" },
},
},
}),
)
.where(eq(customWidgetDefinitions.id, created.id));
await expect(
configureCustomWidgetSourceFromRequest(db, {
definitionId: created.id,
sourceId: "default",
baseUrl: "https://configured.example/api",
networkScope: "public",
secrets: [secret],
expectedSource,
}),
).resolves.toEqual({ status: "binding-changed" });
expect(await createCaller(db).get({ id: created.id })).toMatchObject({
name: "Concurrent definition edit",
sources: {
default: {
baseUrl: expectedSource.baseUrl,
auth: { name: "X-Replaced-Credential" },
},
},
});
expect(
await db.query.customWidgetSecrets.findMany({
where: eq(customWidgetSecrets.definitionId, created.id),
}),
).toHaveLength(0);
});
test("clears persisted credentials when a general definition update changes its binding", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
const created = await caller.create({ ...jellyfin, secrets: [secret] });
await createManageOnlyCaller(db).update({
await createCaller(db).update({
id: created.id,
sources: {
...jellyfin.sources,
@@ -299,12 +517,12 @@ describe("custom widget definition persistence", () => {
).toHaveLength(0);
});
test("requires secret-write permission to create a stored-definition setup link", async () => {
test("requires an administrator to create a stored-definition setup link", async () => {
const db = await prepareDatabase();
const created = await createCaller(db).create({ ...jellyfin, secrets: [] });
await expect(
createManageOnlyCaller(db).configurationRequestUser({ definitionId: created.id, sourceId: "default" }),
createNonAdminCaller(db).configurationRequestUser({ definitionId: created.id, sourceId: "default" }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
@@ -1,4 +1,5 @@
import { describe, expect, test } from "vitest";
import { stringify as stringifySuperJson } from "superjson";
import { buildLegacyCustomWidgetMigrationPrompt } from "../../custom-widget/legacy-migration";
@@ -9,14 +10,14 @@ describe("legacy custom widget migration prompt", () => {
id: "legacy",
name: "Private service",
description: "Status",
iconUrl: null,
iconUrl: "https://icons:password@example.test/private/icon.svg?access_token=icon-secret",
url: "https://admin:password@example.test/hooks/path-token?api_key=top-secret&mode=full",
authType: "bearer",
headerName: null,
method: "POST",
requestBody: JSON.stringify({ token: "body-secret", limit: 5 }),
displayType: "singleValue",
displayConfig: JSON.stringify({ json: { type: "singleValue", jsonPath: "$.status" } }),
displayConfig: "corrupted-config-with-display-secret",
enabled: true,
createdAt: new Date(0),
updatedAt: new Date(0),
@@ -32,5 +33,40 @@ describe("legacy custom widget migration prompt", () => {
expect(prompt).not.toContain("body-secret");
expect(prompt).not.toContain("admin:password");
expect(prompt).not.toContain("path-token");
expect(prompt).not.toContain("icon-secret");
expect(prompt).not.toContain("icons:password");
expect(prompt).not.toContain("private/icon.svg");
expect(prompt).not.toContain("display-secret");
});
test("omits valid free-text display configuration from the migration prompt", () => {
const prompt = buildLegacyCustomWidgetMigrationPrompt(
{
id: "legacy-custom-jsx",
name: "Private service",
description: "Status",
iconUrl: null,
url: "https://example.test/api/status",
authType: "bearer",
headerName: null,
method: "GET",
requestBody: null,
displayType: "customJsx",
displayConfig: stringifySuperJson({
type: "customJsx",
template: "<Text>LEGACY_PRIVATE_MARKER Bearer sk-secret-123456</Text>",
}),
enabled: true,
createdAt: new Date(0),
updatedAt: new Date(0),
creatorId: null,
},
["apiKey"],
);
expect(prompt).toContain("display configuration was intentionally omitted");
expect(prompt).not.toContain("LEGACY_PRIVATE_MARKER");
expect(prompt).not.toContain("Bearer sk-secret-123456");
expect(prompt).not.toContain("sk-secret-123456");
});
});
@@ -0,0 +1,113 @@
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { createDb } from "@homarr/db/test";
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
get: vi.fn(async (_id: string): Promise<unknown> => undefined),
list: vi.fn(async (_options: unknown): Promise<unknown> => undefined),
logger: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
}));
vi.mock("@homarr/core/infrastructure/logs", () => ({
createLogger: () => mocks.logger,
}));
vi.mock("@homarr/workshop/backend", () => ({
WorkshopBackend: class {
public get(id: string) {
return mocks.get(id);
}
public list(options: unknown) {
return mocks.list(options);
}
},
}));
import { customWidgetRouter } from "../../custom-widget/custom-widget-router";
const session = {
user: {
id: createId(),
permissions: ["admin"],
colorScheme: "light",
},
expires: new Date(Date.now() + 60_000).toISOString(),
} satisfies Session;
const createCaller = () =>
customWidgetRouter.createCaller({
db: createDb(),
deviceType: undefined,
session,
});
const createTokenizedFailure = (apiKey: string) => {
const tokenizedUrl = `https://workshop.example/api/submissions?access_token=${apiKey}`;
const error = new Error(`Workshop request failed for ${tokenizedUrl}`);
error.name = `WorkshopTransportError:${tokenizedUrl}`;
return error;
};
const expectSafeFailure = (
error: unknown,
apiKey: string,
logMessage: string,
event: "workshop_widget_lookup_failed" | "workshop_widget_search_failed",
) => {
expect(error).toMatchObject({
code: "BAD_GATEWAY",
message: "Workshop is unavailable",
});
expect((error as Error).cause).toBeUndefined();
expect(String(error)).not.toContain(apiKey);
expect(String(error)).not.toContain("access_token");
expect(mocks.logger.error).toHaveBeenCalledWith(logMessage, {
event,
errorName: "WorkshopBackendError",
});
expect(JSON.stringify(mocks.logger.error.mock.calls)).not.toContain(apiKey);
expect(JSON.stringify(mocks.logger.error.mock.calls)).not.toContain("access_token");
};
describe("Workshop backend error privacy", () => {
beforeEach(() => {
mocks.get.mockReset();
mocks.list.mockReset();
mocks.logger.error.mockClear();
});
test("redacts backend lookup failures from TRPC output and logs", async () => {
const apiKey = "workshop-lookup-secret";
mocks.get.mockRejectedValueOnce(createTokenizedFailure(apiKey));
const error = await createCaller()
.workshopGet({ submissionId: "submission-id" })
.then(
() => undefined,
(cause: unknown) => cause,
);
expectSafeFailure(error, apiKey, "Workshop widget lookup failed", "workshop_widget_lookup_failed");
});
test("redacts backend search failures from TRPC output and logs", async () => {
const apiKey = "workshop-search-secret";
mocks.list.mockRejectedValueOnce(createTokenizedFailure(apiKey));
const error = await createCaller()
.workshopSearch({ query: "weather" })
.then(
() => undefined,
(cause: unknown) => cause,
);
expectSafeFailure(error, apiKey, "Workshop widget search failed", "workshop_widget_search_failed");
});
});
@@ -0,0 +1,107 @@
import SuperJSON from "superjson";
import { describe, expect, test } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { eq } from "@homarr/db";
import { boards, items, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { optionsRouter } from "../../widgets/options";
const createSession = (userId: string, isAdmin = false): Session => ({
user: {
id: userId,
permissions: isAdmin ? ["admin"] : [],
colorScheme: "light",
},
expires: new Date(Date.now() + 60_000).toISOString(),
});
async function setup(kind: "customApi" | "weather") {
const db = createDb();
const userId = createId();
const boardId = createId();
const itemId = createId();
await db.insert(users).values({ id: userId });
await db.insert(boards).values({ id: boardId, name: createId(), creatorId: userId });
await db.insert(items).values({
id: itemId,
boardId,
kind,
options: SuperJSON.stringify(
kind === "customApi"
? { definitionId: "definition-1", refreshInterval: 30, configuration: { server: "primary" } }
: { location: "Paris" },
),
});
return { db, userId, boardId, itemId };
}
describe("saveItemOptions Custom Widget access", () => {
test("rejects direct Custom Widget reconfiguration by a non-admin board modifier", async () => {
const { db, userId, boardId, itemId } = await setup("customApi");
const caller = optionsRouter.createCaller({ db, deviceType: undefined, session: createSession(userId) });
await expect(
caller.saveItemOptions({
boardId,
itemId,
newOptions: {
definitionId: "definition-2",
refreshInterval: 1,
configuration: { server: "attacker" },
},
}),
).rejects.toMatchObject({ code: "FORBIDDEN" });
const stored = await db.query.items.findFirst({ where: eq(items.id, itemId) });
expect(SuperJSON.parse(stored?.options ?? "")).toMatchObject({
definitionId: "definition-1",
refreshInterval: 30,
configuration: { server: "primary" },
});
});
test("allows a Homarr admin to reconfigure a Custom Widget placement", async () => {
const { db, userId, boardId, itemId } = await setup("customApi");
const caller = optionsRouter.createCaller({ db, deviceType: undefined, session: createSession(userId, true) });
await expect(
caller.saveItemOptions({ boardId, itemId, newOptions: { refreshInterval: 60 } }),
).resolves.toBeUndefined();
const stored = await db.query.items.findFirst({ where: eq(items.id, itemId) });
expect(SuperJSON.parse(stored?.options ?? "")).toMatchObject({ refreshInterval: 60 });
});
test("allows normalized missing defaults without treating them as reconfiguration", async () => {
const { db, userId, boardId, itemId } = await setup("customApi");
const stored = await db.query.items.findFirst({ where: eq(items.id, itemId) });
const options = SuperJSON.parse<Record<string, unknown>>(stored?.options ?? "");
delete options.configuration;
delete options.configurationVersion;
await db
.update(items)
.set({ options: SuperJSON.stringify(options) })
.where(eq(items.id, itemId));
const caller = optionsRouter.createCaller({ db, deviceType: undefined, session: createSession(userId) });
await expect(
caller.saveItemOptions({
boardId,
itemId,
newOptions: { configuration: {}, configurationVersion: 1 },
}),
).resolves.toBeUndefined();
});
test("preserves ordinary board-modifier option updates for other widgets", async () => {
const { db, userId, boardId, itemId } = await setup("weather");
const caller = optionsRouter.createCaller({ db, deviceType: undefined, session: createSession(userId) });
await expect(
caller.saveItemOptions({ boardId, itemId, newOptions: { location: "Lyon" } }),
).resolves.toBeUndefined();
});
});
+8 -2
View File
@@ -9,6 +9,7 @@ import { boards, items } from "@homarr/db/schema";
import type { WidgetOptionsSettings } from "../../../../widgets/src";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
import { throwIfCustomWidgetPlacementChangeForbidden } from "../board/custom-widget-placement-access";
export const optionsRouter = createTRPCRouter({
getWidgetOptionSettings: publicProcedure.query(async ({ ctx }): Promise<WidgetOptionsSettings> => {
@@ -46,10 +47,15 @@ export const optionsRouter = createTRPCRouter({
}
const options = SuperJSON.parse<Record<string, unknown>>(item.options);
Object.assign(options, input.newOptions);
const updatedOptions = { ...options, ...input.newOptions };
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: ctx.session.user.permissions.includes("admin"),
submittedItems: [{ id: item.id, kind: item.kind, options: updatedOptions }],
storedItems: [{ id: item.id, kind: item.kind, options }],
});
await ctx.db
.update(items)
.set({ options: SuperJSON.stringify(options) })
.set({ options: SuperJSON.stringify(updatedOptions) })
.where(eq(items.id, input.itemId));
}),
});
+32 -19
View File
@@ -70,7 +70,6 @@ const MCP_TOOL_ALLOWLIST = {
"board_setHomeBoard",
"board_setMobileHomeBoard",
"customWidget_delete",
"customWidget_deleteLegacy",
"customWidget_previewAction",
"customWidget_templatePatch",
"dnsHole_disable",
@@ -171,19 +170,11 @@ describe("custom widget authoring procedure access", () => {
session: null,
});
test("executes promised public procedures without a session", async () => {
await expect(unauthenticatedCaller.customWidget.schema()).resolves.toMatchObject({ type: "object" });
await expect(unauthenticatedCaller.customWidget.getAuthoringPrompt()).resolves.toMatchObject({
version: 2,
prompt: expect.any(String),
});
await expect(unauthenticatedCaller.customWidget.getSkill()).resolves.toMatchObject({
name: expect.any(String),
});
});
test("rejects protected and permission-gated authoring procedures without a session", async () => {
test("rejects every authoring procedure without a session", async () => {
const calls = [
unauthenticatedCaller.customWidget.schema(),
unauthenticatedCaller.customWidget.getAuthoringPrompt(),
unauthenticatedCaller.customWidget.getSkill(),
unauthenticatedCaller.customWidget.list(),
unauthenticatedCaller.customWidget.validate({ widget: {} }),
unauthenticatedCaller.customWidget.secretSet({
@@ -196,17 +187,39 @@ describe("custom widget authoring procedure access", () => {
}
});
test("enforces the dedicated permission on secret tools", async () => {
const callerWithoutSecretPermission = mcpRouter.createCaller({
test("rejects authoring and secret tools for authenticated non-admins", async () => {
const nonAdminCaller = mcpRouter.createCaller({
db: null as never,
deviceType: undefined,
session: { user: { id: "user-1", permissions: ["custom-widget-manage"] } } as never,
session: { user: { id: "user-1", permissions: ["board-modify-all"] } } as never,
});
await expect(
callerWithoutSecretPermission.customWidget.secretSet({
const calls = [
nonAdminCaller.customWidget.schema(),
nonAdminCaller.customWidget.getAuthoringPrompt(),
nonAdminCaller.customWidget.getSkill(),
nonAdminCaller.customWidget.secretSet({
definitionId: "widget",
secret: { sourceId: "default", kind: "apiKey", value: "secret" },
}),
).rejects.toEqual(expect.objectContaining<Partial<TRPCError>>({ code: "FORBIDDEN" }));
];
for (const call of calls) {
await expect(call).rejects.toEqual(expect.objectContaining<Partial<TRPCError>>({ code: "FORBIDDEN" }));
}
});
test("allows admins to read authoring resources", async () => {
const adminCaller = mcpRouter.createCaller({
db: null as never,
deviceType: undefined,
session: { user: { id: "admin-1", permissions: ["admin"] } } as never,
});
await expect(adminCaller.customWidget.schema()).resolves.toMatchObject({ type: "object" });
await expect(adminCaller.customWidget.getAuthoringPrompt()).resolves.toMatchObject({
version: 2,
prompt: expect.any(String),
});
await expect(adminCaller.customWidget.getSkill()).resolves.toMatchObject({
name: expect.any(String),
});
});
});