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