fix: harden v2 custom widgets and Workshop (#6480)
This commit is contained in:
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";
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}),
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user