fix(custom-widgets): harden v2 authoring

This commit is contained in:
Thomas Camlong committed 2026-07-20 22:16:18 +02:00
1 parent ee832ad3c6
commit 7c66c912e7
171 files changed
+20311 -50527

No files matched your search

@@ -4,7 +4,7 @@ import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { and, eq, notInArray } from "@homarr/db";
import { and, eq, handleTransactionsAsync, notInArray } from "@homarr/db";
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
import {
customWidgetCreateSchema,
@@ -49,22 +49,34 @@ export const customWidgetRouter = createTRPCRouter({
const definition = customWidgetDefinitionSchema.parse(candidate);
assertSecretSources(definition.sources, secrets);
const id = createId();
await ctx.db.insert(customWidgetDefinitions).values({
const definitionRow = {
id,
...serializeCustomWidgetDefinition(definition),
creatorId: ctx.session.user.id,
};
const updatedAt = new Date();
const secretRows = secrets.map((secret) => ({
definitionId: id,
sourceId: secret.sourceId,
kind: secret.kind,
encryptedValue: encryptSecret(secret.value),
updatedAt,
}));
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
await transaction.insert(schema.customWidgetDefinitions).values(definitionRow);
if (secretRows.length > 0) await transaction.insert(schema.customWidgetSecrets).values(secretRows);
});
},
handleSync(db) {
db.transaction((transaction) => {
transaction.insert(customWidgetDefinitions).values(definitionRow).run();
if (secretRows.length > 0) transaction.insert(customWidgetSecrets).values(secretRows).run();
});
},
});
if (secrets.length > 0) {
await ctx.db.insert(customWidgetSecrets).values(
secrets.map((secret) => ({
definitionId: id,
sourceId: secret.sourceId,
kind: secret.kind,
encryptedValue: encryptSecret(secret.value),
updatedAt: new Date(),
})),
);
}
logger.info("Created custom widget definition", { id, name: definition.name });
return { id };
}),
@@ -88,50 +100,96 @@ export const customWidgetRouter = createTRPCRouter({
}
const definition = customWidgetDefinitionSchema.parse({ ...current, ...changes });
if (secrets) assertSecretSources(definition.sources, secrets);
const definitionChanges = { ...serializeCustomWidgetDefinition(definition), updatedAt: new Date() };
const secretRows = secrets?.map((secret) => ({
definitionId: id,
sourceId: secret.sourceId,
kind: secret.kind,
encryptedValue: encryptSecret(secret.value),
updatedAt: new Date(),
}));
const sourceIds = definition.sources.map((source) => source.id);
await ctx.db
.update(customWidgetDefinitions)
.set({ ...serializeCustomWidgetDefinition(definition), updatedAt: new Date() })
.where(eq(customWidgetDefinitions.id, id));
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
await transaction
.update(schema.customWidgetDefinitions)
.set(definitionChanges)
.where(eq(schema.customWidgetDefinitions.id, id));
await ctx.db.delete(customWidgetSecrets).where(
and(
eq(customWidgetSecrets.definitionId, id),
notInArray(
customWidgetSecrets.sourceId,
definition.sources.map((source) => source.id),
),
),
);
await transaction
.delete(schema.customWidgetSecrets)
.where(
and(
eq(schema.customWidgetSecrets.definitionId, id),
notInArray(schema.customWidgetSecrets.sourceId, sourceIds),
),
);
for (const source of definition.sources) {
const kinds = [...requiredSecretKinds(source.auth.type)];
const where = and(eq(customWidgetSecrets.definitionId, id), eq(customWidgetSecrets.sourceId, source.id));
await ctx.db
.delete(customWidgetSecrets)
.where(kinds.length > 0 ? and(where, notInArray(customWidgetSecrets.kind, kinds)) : where);
}
for (const source of definition.sources) {
const kinds = [...requiredSecretKinds(source.auth.type)];
const where = and(
eq(schema.customWidgetSecrets.definitionId, id),
eq(schema.customWidgetSecrets.sourceId, source.id),
);
await transaction
.delete(schema.customWidgetSecrets)
.where(kinds.length > 0 ? and(where, notInArray(schema.customWidgetSecrets.kind, kinds)) : where);
}
if (secrets !== undefined) {
for (const secret of secrets) {
await ctx.db
.delete(customWidgetSecrets)
.where(
and(
eq(customWidgetSecrets.definitionId, id),
eq(customWidgetSecrets.sourceId, secret.sourceId),
eq(customWidgetSecrets.kind, secret.kind),
),
);
await ctx.db.insert(customWidgetSecrets).values({
definitionId: id,
sourceId: secret.sourceId,
kind: secret.kind,
encryptedValue: encryptSecret(secret.value),
updatedAt: new Date(),
for (const secret of secretRows ?? []) {
await transaction
.delete(schema.customWidgetSecrets)
.where(
and(
eq(schema.customWidgetSecrets.definitionId, id),
eq(schema.customWidgetSecrets.sourceId, secret.sourceId),
eq(schema.customWidgetSecrets.kind, secret.kind),
),
);
await transaction.insert(schema.customWidgetSecrets).values(secret);
}
});
}
}
},
handleSync(db) {
db.transaction((transaction) => {
transaction
.update(customWidgetDefinitions)
.set(definitionChanges)
.where(eq(customWidgetDefinitions.id, id))
.run();
transaction
.delete(customWidgetSecrets)
.where(and(eq(customWidgetSecrets.definitionId, id), notInArray(customWidgetSecrets.sourceId, sourceIds)))
.run();
for (const source of definition.sources) {
const kinds = [...requiredSecretKinds(source.auth.type)];
const where = and(eq(customWidgetSecrets.definitionId, id), eq(customWidgetSecrets.sourceId, source.id));
transaction
.delete(customWidgetSecrets)
.where(kinds.length > 0 ? and(where, notInArray(customWidgetSecrets.kind, kinds)) : where)
.run();
}
for (const secret of secretRows ?? []) {
transaction
.delete(customWidgetSecrets)
.where(
and(
eq(customWidgetSecrets.definitionId, id),
eq(customWidgetSecrets.sourceId, secret.sourceId),
eq(customWidgetSecrets.kind, secret.kind),
),
)
.run();
transaction.insert(customWidgetSecrets).values(secret).run();
}
});
},
});
logger.info("Updated custom widget definition", { id });
}),
@@ -1,11 +1,8 @@
import { z } from "zod/v4";
import {
CUSTOM_WIDGET_MCP_AUTHORING_PROMPT,
customWidgetImportSchema,
getCustomWidgetJsonSchema,
getCustomWidgetSkill,
} from "@homarr/custom-widgets/core";
import { CUSTOM_WIDGET_MCP_AUTHORING_PROMPT } from "@homarr/custom-widgets/authoring-prompt";
import { getCustomWidgetSkill } from "@homarr/custom-widgets/authoring-resources";
import { customWidgetImportSchema, getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure, publicProcedure } from "../../trpc";
@@ -2,7 +2,11 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { decryptSecret } from "@homarr/common/server";
import { customWidgetDefinitionSchema, customWidgetSecretsInputSchema } from "@homarr/custom-widgets/core";
import {
customWidgetDefinitionSchema,
customWidgetSecretsInputSchema,
validateCustomWidgetOptions,
} from "@homarr/custom-widgets/core";
import { eq } from "@homarr/db";
import { customWidgetDefinitions } from "@homarr/db/schema";
@@ -15,6 +19,7 @@ const previewCreateInputSchema = z.object({
definition: customWidgetDefinitionSchema,
secrets: customWidgetSecretsInputSchema.default([]),
definitionId: z.string().optional(),
options: z.record(z.string(), z.unknown()).optional(),
});
const previewCreateProcedure = manageProcedure
@@ -24,6 +29,16 @@ const previewCreateProcedure = manageProcedure
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 ?? input.definition.defaultOptions;
const optionIssues = validateCustomWidgetOptions(input.definition.optionsSchema, options);
if (optionIssues.length > 0) {
const issue = optionIssues[0];
throw new TRPCError({
code: "BAD_REQUEST",
message: issue ? `${issue.path}: ${issue.message}` : "Preview options are invalid",
});
}
const secrets = [...input.secrets];
if (input.definitionId) {
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
@@ -59,7 +74,7 @@ const previewCreateProcedure = manageProcedure
name: input.definition.name,
template: input.definition.template,
optionsSchema: input.definition.optionsSchema,
defaultOptions: input.definition.defaultOptions,
options,
secrets,
definitionId: input.definitionId,
});
@@ -87,7 +102,7 @@ export const previewBaseProcedures = {
expiresAt: session.expiresAt,
template: session.template,
optionsSchema: session.optionsSchema,
defaultOptions: session.defaultOptions,
options: session.options,
requests: session.requests.map(
({
id,
@@ -1,4 +1,6 @@
import { createLogger } from "@homarr/core/infrastructure/logs";
import { resolveCustomWidgetOptionsBinding } from "@homarr/custom-widgets/core";
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
import { z } from "zod/v4";
import { appendPreviewJournal } from "./preview-sessions";
@@ -42,3 +44,11 @@ export const getPreviewRequestSource = (session: CustomWidgetPreviewSession, sou
};
return { source, auth };
};
export function resolvePreviewRequestParams(
request: CustomJsxRequest,
options: Record<string, unknown>,
suppliedParams: Record<string, string | number | boolean>,
) {
return request.trigger === "load" ? resolveCustomWidgetOptionsBinding(request, options) : suppliedParams;
}
@@ -1,13 +1,12 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { resolveCustomWidgetOptionsBinding } from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure } from "../../trpc";
import {
getPreviewRequestSource,
previewSessionRequestSchema,
recordPreviewJournal,
resolvePreviewRequestParams,
} from "./preview-procedure-helpers";
import { executeCustomWidgetRequest } from "./request-executor";
import { hashRuntimeParams, renderRequestBody, renderRequestTarget } from "./request-manifest";
@@ -33,8 +32,7 @@ export const previewQueryProcedures = {
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview query was not found" });
const resolved = getPreviewRequestSource(session, request.sourceId);
if (!resolved) throw new TRPCError({ code: "NOT_FOUND", message: "Preview source was not found" });
const params =
request.trigger === "load" ? resolveCustomWidgetOptionsBinding(request, session.defaultOptions) : input.params;
const params = resolvePreviewRequestParams(request, session.options, input.params);
const targetUrl = renderRequestTarget(resolved.source.baseUrl, request, params);
const body = renderRequestBody(request.bodyTemplate, params);
const release = await acquireCustomWidgetRequestLimit({
@@ -4,7 +4,7 @@ import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { eq } from "@homarr/db";
import { eq, handleTransactionsAsync } from "@homarr/db";
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
import { customWidgetImportSchema, customWidgetSecretsInputSchema } from "@homarr/custom-widgets/core";
@@ -35,22 +35,34 @@ export const transferProcedures = {
}
assertSecretSources(input.widget.sources, input.secrets);
const id = createId();
await ctx.db.insert(customWidgetDefinitions).values({
const definitionRow = {
id,
...serializeCustomWidgetDefinition(input.widget),
creatorId: ctx.session.user.id,
};
const updatedAt = new Date();
const secretRows = input.secrets.map((secret) => ({
definitionId: id,
sourceId: secret.sourceId,
kind: secret.kind,
encryptedValue: encryptSecret(secret.value),
updatedAt,
}));
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
await transaction.insert(schema.customWidgetDefinitions).values(definitionRow);
if (secretRows.length > 0) await transaction.insert(schema.customWidgetSecrets).values(secretRows);
});
},
handleSync(db) {
db.transaction((transaction) => {
transaction.insert(customWidgetDefinitions).values(definitionRow).run();
if (secretRows.length > 0) transaction.insert(customWidgetSecrets).values(secretRows).run();
});
},
});
if (input.secrets.length > 0) {
await ctx.db.insert(customWidgetSecrets).values(
input.secrets.map((secret) => ({
definitionId: id,
sourceId: secret.sourceId,
kind: secret.kind,
encryptedValue: encryptSecret(secret.value),
updatedAt: new Date(),
})),
);
}
logger.info("Imported custom widget definition", { id, name: input.widget.name });
return { id };
}),
@@ -0,0 +1,101 @@
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { eq, sql } from "@homarr/db";
import { customWidgetDefinitions, customWidgetSecrets, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { BUNDLED_CUSTOM_WIDGETS } from "@homarr/custom-widgets/core";
import { describe, expect, test } from "vitest";
import { customWidgetRouter } from "../../custom-widget/custom-widget-router";
const userId = createId();
const session = {
user: {
id: userId,
permissions: ["custom-widget-manage", "custom-widget-secret-write"],
colorScheme: "light",
},
expires: new Date().toISOString(),
} satisfies Session;
const jellyfin = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-jellyfin")?.widget;
if (!jellyfin) throw new Error("Jellyfin bundled widget is missing");
function createCaller(db: ReturnType<typeof createDb>) {
return customWidgetRouter.createCaller({ db, deviceType: undefined, session });
}
async function prepareDatabase() {
const db = createDb();
await db.insert(users).values({ id: userId });
return db;
}
function rejectSecretInserts(db: ReturnType<typeof createDb>) {
db.run(
sql.raw(`CREATE TRIGGER reject_custom_widget_secret_insert
BEFORE INSERT ON custom_widget_secret
BEGIN
SELECT RAISE(ABORT, 'forced secret insert failure');
END`),
);
}
const secret = { sourceId: "default", kind: "apiKey" as const, value: "test-secret" };
describe("custom widget definition persistence", () => {
test("rolls back create when its secret cannot be stored", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
rejectSecretInserts(db);
await expect(caller.create({ ...jellyfin, secrets: [secret] })).rejects.toThrow("forced secret insert failure");
expect(await db.query.customWidgetDefinitions.findMany()).toHaveLength(0);
expect(await db.query.customWidgetSecrets.findMany()).toHaveLength(0);
});
test("rolls back import when its secret cannot be stored", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
rejectSecretInserts(db);
await expect(caller.import({ widget: jellyfin, secrets: [secret] })).rejects.toThrow(
"forced secret insert failure",
);
expect(await db.query.customWidgetDefinitions.findMany()).toHaveLength(0);
expect(await db.query.customWidgetSecrets.findMany()).toHaveLength(0);
});
test("rolls back definition and secret changes when a replacement secret cannot be stored", 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.update({
id: created.id,
name: "Changed Jellyfin Library",
secrets: [{ ...secret, value: "replacement-secret" }],
}),
).rejects.toThrow("forced secret insert failure");
const storedDefinition = await db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, created.id),
});
const storedSecret = await db.query.customWidgetSecrets.findFirst({
where: eq(customWidgetSecrets.definitionId, created.id),
});
expect(storedDefinition?.name).toBe(originalDefinition?.name);
expect(storedDefinition?.updatedAt).toEqual(originalDefinition?.updatedAt);
expect(storedSecret).toEqual(originalSecret);
});
});
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
import { resolvePreviewRequestParams } from "../../custom-widget/preview-procedure-helpers";
const loadRequest: CustomJsxRequest = {
id: "containers",
sourceId: "default",
kind: "query",
method: "GET",
pathTemplate: "/endpoints/{endpointId}/containers",
parameters: { endpointId: "number" },
optionsBinding: { endpointId: { $option: "environmentId" } },
auth: "inherit",
minimumBoardPermission: "view",
trigger: "load",
};
describe("preview request option bindings", () => {
it("resolves load request parameters from the selected preview options", () => {
expect(resolvePreviewRequestParams(loadRequest, { environmentId: 7 }, { endpointId: 1 })).toEqual({
endpointId: 7,
});
});
it("preserves invoking component parameters for manual requests", () => {
const manualRequest = { ...loadRequest, trigger: "manual" as const, optionsBinding: undefined };
expect(resolvePreviewRequestParams(manualRequest, { environmentId: 7 }, { endpointId: 12 })).toEqual({
endpointId: 12,
});
});
});