fix(custom-widgets): address v2 merge review
This commit is contained in:
1 parent
6aceb2fd1f
commit
48ce83806d
122 files changed
+8534
-1447
No files matched your search
@@ -26,7 +26,7 @@ import {
|
||||
} from "./stored-definition";
|
||||
import { templateProcedures } from "./template-procedures";
|
||||
import { transferProcedures } from "./transfer-procedures";
|
||||
import { assertSecretSources, requiredSecretKinds } from "./secret-policy";
|
||||
import { assertSecretSources, hasSameSecretBinding, requiredSecretKinds } from "./secret-policy";
|
||||
import { secretProcedures } from "./secret-procedures";
|
||||
import { workshopProcedures } from "./workshop-procedures";
|
||||
|
||||
@@ -83,6 +83,12 @@ export const customWidgetRouter = createTRPCRouter({
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
const sourceIds = Object.keys(definition.sources);
|
||||
const changedSecretBindings = new Set(
|
||||
Object.entries(definition.sources).flatMap(([sourceId, source]) => {
|
||||
const previous = current.sources[sourceId];
|
||||
return previous && !hasSameSecretBinding(previous, source) ? [sourceId] : [];
|
||||
}),
|
||||
);
|
||||
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
async handleAsync(db, schema) {
|
||||
@@ -101,6 +107,17 @@ export const customWidgetRouter = createTRPCRouter({
|
||||
),
|
||||
);
|
||||
|
||||
for (const sourceId of changedSecretBindings) {
|
||||
await transaction
|
||||
.delete(schema.customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.customWidgetSecrets.definitionId, id),
|
||||
eq(schema.customWidgetSecrets.sourceId, sourceId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (const [sourceId, source] of Object.entries(definition.sources)) {
|
||||
const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)];
|
||||
const where = and(
|
||||
@@ -139,6 +156,13 @@ export const customWidgetRouter = createTRPCRouter({
|
||||
.where(and(eq(customWidgetSecrets.definitionId, id), notInArray(customWidgetSecrets.sourceId, sourceIds)))
|
||||
.run();
|
||||
|
||||
for (const sourceId of changedSecretBindings) {
|
||||
transaction
|
||||
.delete(customWidgetSecrets)
|
||||
.where(and(eq(customWidgetSecrets.definitionId, id), eq(customWidgetSecrets.sourceId, sourceId)))
|
||||
.run();
|
||||
}
|
||||
|
||||
for (const [sourceId, source] of Object.entries(definition.sources)) {
|
||||
const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)];
|
||||
const where = and(eq(customWidgetSecrets.definitionId, id), eq(customWidgetSecrets.sourceId, sourceId));
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { parse as parseSuperJson } from "superjson";
|
||||
|
||||
import type { legacyCustomWidgetDefinitions } from "@homarr/db/schema";
|
||||
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt";
|
||||
|
||||
type LegacyCustomWidgetDefinition = typeof legacyCustomWidgetDefinitions.$inferSelect;
|
||||
|
||||
export function buildLegacyCustomWidgetMigrationPrompt(
|
||||
definition: LegacyCustomWidgetDefinition,
|
||||
configuredSecretKinds: readonly string[],
|
||||
) {
|
||||
const legacyDefinition = {
|
||||
$schema: "homarr-custom-widget-v1",
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
iconUrl: definition.iconUrl,
|
||||
url: redactLegacyUrl(definition.url),
|
||||
authType: definition.authType,
|
||||
headerName: definition.headerName,
|
||||
method: definition.method,
|
||||
requestBody: redactLegacyRequestBody(definition.requestBody),
|
||||
displayType: definition.displayType,
|
||||
displayConfig: parseLegacyDisplayConfig(definition.displayConfig),
|
||||
configuredSecretKinds,
|
||||
};
|
||||
return buildCustomWidgetAiPrompt(
|
||||
undefined,
|
||||
undefined,
|
||||
legacyDefinition,
|
||||
[
|
||||
"Migrate this preserved Homarr v1 Custom Widget to Custom JSX v2.",
|
||||
"Preserve its visible behavior and API intent, use a source named default, and return importable v2 JSON plus JSX.",
|
||||
"The URL and request-body values may be redacted; keep placeholders and never invent or include credentials.",
|
||||
"Credential kinds are informational only and must be configured separately in Homarr.",
|
||||
].join(" "),
|
||||
);
|
||||
}
|
||||
|
||||
function redactLegacyUrl(value: string) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
url.username = "";
|
||||
url.password = "";
|
||||
url.hash = "";
|
||||
url.pathname = url.pathname
|
||||
.split("/")
|
||||
.map((segment) => (segment ? "[REDACTED]" : segment))
|
||||
.join("/");
|
||||
for (const key of url.searchParams.keys()) url.searchParams.set(key, "[REDACTED]");
|
||||
return url.toString();
|
||||
} catch {
|
||||
return "[INVALID LEGACY URL — enter the API URL in Homarr]";
|
||||
}
|
||||
}
|
||||
|
||||
function redactLegacyRequestBody(value: string | null) {
|
||||
if (!value) return value;
|
||||
try {
|
||||
return JSON.stringify(redactAllValues(JSON.parse(value) as unknown));
|
||||
} catch {
|
||||
return "[REDACTED LEGACY REQUEST BODY]";
|
||||
}
|
||||
}
|
||||
|
||||
function redactAllValues(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(redactAllValues);
|
||||
if (value !== null && typeof value === "object")
|
||||
return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, redactAllValues(child)]));
|
||||
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) };
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,13 @@ import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { decryptSecret } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { eq, or } from "@homarr/db";
|
||||
import { boards, customWidgetDefinitions } from "@homarr/db/schema";
|
||||
import {
|
||||
getCustomWidgetConfirmation,
|
||||
getCustomWidgetDefaultOptions,
|
||||
getCustomWidgetSecretRequirements,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import { boards, customWidgetDefinitions, legacyCustomWidgetDefinitions } from "@homarr/db/schema";
|
||||
import { collectCustomWidgetRequestReferences } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { permissionRequiredProcedure, protectedProcedure } from "../../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "../board/board-access";
|
||||
import { parseStoredCustomWidgetDefinition, safeParseStoredCustomWidgetDefinition } from "./stored-definition";
|
||||
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
|
||||
import { executeCustomWidgetRequest } from "./request-executor";
|
||||
import {
|
||||
hashRuntimeParams,
|
||||
@@ -23,68 +18,51 @@ import {
|
||||
} from "./request-manifest";
|
||||
import { acquireCustomWidgetRequestLimit } from "./request-limits";
|
||||
import { getCustomWidgetCacheVersion } from "./cache-version";
|
||||
import { buildLegacyCustomWidgetMigrationPrompt } from "./legacy-migration";
|
||||
import {
|
||||
mapAvailableCustomWidget,
|
||||
mapCustomWidgetListItem,
|
||||
mapLegacyAvailableCustomWidget,
|
||||
mapLegacyCustomWidgetListItem,
|
||||
} from "./management-query-mappers";
|
||||
|
||||
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
|
||||
const logger = createLogger({ module: "custom-widget:management" });
|
||||
|
||||
export const managementQueryProcedures = {
|
||||
list: manageProcedure
|
||||
.meta({ mcp: { enabled: true, description: "List all Custom JSX widgets." } })
|
||||
.query(async ({ ctx }) => {
|
||||
const definitions = await ctx.db.query.customWidgetDefinitions.findMany({
|
||||
orderBy: (table, { asc }) => asc(table.name),
|
||||
with: { secrets: true },
|
||||
});
|
||||
return definitions.map((definition) => {
|
||||
const result = safeParseStoredCustomWidgetDefinition(definition);
|
||||
if (!result.success) {
|
||||
logger.warn("Skipped parsing invalid custom widget definition", {
|
||||
id: definition.id,
|
||||
issueCount: result.issues.length,
|
||||
});
|
||||
return {
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
description: definition.description ?? undefined,
|
||||
iconUrl: definition.iconUrl ?? undefined,
|
||||
sources: [],
|
||||
requestCount: 0,
|
||||
missingSecrets: [],
|
||||
options: {},
|
||||
updatedAt: definition.updatedAt,
|
||||
enabled: definition.enabled,
|
||||
valid: false as const,
|
||||
validationIssues: result.issues,
|
||||
};
|
||||
}
|
||||
|
||||
const widget = result.widget;
|
||||
const configuredSecrets = new Set(definition.secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
|
||||
return {
|
||||
id: definition.id,
|
||||
name: widget.name,
|
||||
description: widget.description,
|
||||
iconUrl: widget.iconUrl,
|
||||
sources: Object.entries(widget.sources).map(([id, { name, baseUrl, networkScope, auth }]) => ({
|
||||
id,
|
||||
name,
|
||||
origin: new URL(baseUrl).origin,
|
||||
networkScope,
|
||||
authType: typeof auth === "string" ? auth : auth.type,
|
||||
})),
|
||||
requestCount: Object.keys(widget.requests).length,
|
||||
missingSecrets: getCustomWidgetSecretRequirements(widget.sources).filter(
|
||||
(requirement) => !configuredSecrets.has(`${requirement.sourceId}:${requirement.kind}`),
|
||||
),
|
||||
options: widget.options,
|
||||
updatedAt: definition.updatedAt,
|
||||
enabled: definition.enabled,
|
||||
valid: true as const,
|
||||
validationIssues: [],
|
||||
};
|
||||
});
|
||||
const [definitions, legacyDefinitions] = await Promise.all([
|
||||
ctx.db.query.customWidgetDefinitions.findMany({
|
||||
orderBy: (table, { asc }) => asc(table.name),
|
||||
with: { secrets: true },
|
||||
}),
|
||||
ctx.db.query.legacyCustomWidgetDefinitions.findMany({
|
||||
orderBy: (table, { asc }) => asc(table.name),
|
||||
with: { secrets: true },
|
||||
}),
|
||||
]);
|
||||
const current = definitions.map(mapCustomWidgetListItem);
|
||||
const legacy = legacyDefinitions.map(mapLegacyCustomWidgetListItem);
|
||||
return [...current, ...legacy].toSorted((left, right) => left.name.localeCompare(right.name));
|
||||
}),
|
||||
|
||||
legacyMigrationPrompt: manageProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
|
||||
const definition = await ctx.db.query.legacyCustomWidgetDefinitions.findFirst({
|
||||
where: eq(legacyCustomWidgetDefinitions.id, input.id),
|
||||
with: { secrets: true },
|
||||
});
|
||||
if (!definition) throw new TRPCError({ code: "NOT_FOUND", message: "Legacy custom widget not found" });
|
||||
return {
|
||||
id: definition.id,
|
||||
version: 1 as const,
|
||||
prompt: buildLegacyCustomWidgetMigrationPrompt(
|
||||
definition,
|
||||
definition.secrets.map(({ kind }) => kind),
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
get: manageProcedure
|
||||
.meta({ mcp: { enabled: true, description: "Get one Custom JSX widget without secret values." } })
|
||||
.input(z.object({ id: z.string() }))
|
||||
@@ -119,45 +97,13 @@ export const managementQueryProcedures = {
|
||||
: eq(customWidgetDefinitions.enabled, true),
|
||||
orderBy: (table, { asc }) => asc(table.name),
|
||||
});
|
||||
return definitions.flatMap((definition) => {
|
||||
const result = safeParseStoredCustomWidgetDefinition(definition);
|
||||
if (!result.success) {
|
||||
logger.warn("Excluded invalid custom widget definition from board picker", { id: definition.id });
|
||||
return [];
|
||||
}
|
||||
|
||||
const widget = result.widget;
|
||||
return {
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
iconUrl: definition.iconUrl,
|
||||
options: widget.options,
|
||||
defaultOptions: getCustomWidgetDefaultOptions(widget.options),
|
||||
template: widget.template,
|
||||
sources: Object.entries(widget.sources).map(([id, { name, networkScope, auth }]) => ({
|
||||
id,
|
||||
name,
|
||||
networkScope,
|
||||
authType: typeof auth === "string" ? auth : auth.type,
|
||||
})),
|
||||
requestCapabilities: Object.entries(widget.requests).map(
|
||||
([id, { kind, method, trigger, permission, confirmation, invalidates }]) => ({
|
||||
id,
|
||||
kind,
|
||||
method,
|
||||
trigger,
|
||||
minimumBoardPermission: permission,
|
||||
confirmation: getCustomWidgetConfirmation({ method, confirmation }),
|
||||
invalidates,
|
||||
}),
|
||||
),
|
||||
optionRequests: Object.entries(widget.requests).flatMap(([id, request]) =>
|
||||
request.kind === "query" ? [{ id }] : [],
|
||||
),
|
||||
updatedAt: definition.updatedAt,
|
||||
};
|
||||
const available = definitions.flatMap(mapAvailableCustomWidget);
|
||||
if (!input.currentId || available.some(({ id }) => id === input.currentId)) return available;
|
||||
const legacy = await ctx.db.query.legacyCustomWidgetDefinitions.findFirst({
|
||||
where: eq(legacyCustomWidgetDefinitions.id, input.currentId),
|
||||
});
|
||||
if (!legacy) return available;
|
||||
return [...available, mapLegacyAvailableCustomWidget(legacy)];
|
||||
}),
|
||||
|
||||
optionRequest: protectedProcedure
|
||||
@@ -191,7 +137,7 @@ export const managementQueryProcedures = {
|
||||
definitionId: input.definitionId,
|
||||
});
|
||||
try {
|
||||
const values = resolveCustomWidgetRequestValues(request, input.params, {});
|
||||
const values = resolveOptionRequestValues(request, input.params);
|
||||
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
|
||||
const response = await executeCustomWidgetRequest({
|
||||
baseUrl: source.baseUrl,
|
||||
@@ -230,3 +176,21 @@ export const managementQueryProcedures = {
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
export function resolveOptionRequestValues(
|
||||
request: Parameters<typeof resolveCustomWidgetRequestValues>[0],
|
||||
configuration: NonNullable<Parameters<typeof resolveCustomWidgetRequestValues>[2]>,
|
||||
) {
|
||||
const references = collectCustomWidgetRequestReferences(request);
|
||||
const options: Record<string, unknown> = {};
|
||||
const params: typeof configuration = {};
|
||||
for (const name of references.options) {
|
||||
const value = configuration[name];
|
||||
if (value !== undefined) options[name] = value;
|
||||
}
|
||||
for (const name of references.params) {
|
||||
const value = configuration[name];
|
||||
if (value !== undefined) params[name] = value;
|
||||
}
|
||||
return resolveCustomWidgetRequestValues(request, options, params);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { customWidgetDefinitions, customWidgetSecrets, legacyCustomWidgetDefinitions } from "@homarr/db/schema";
|
||||
import {
|
||||
getCustomWidgetConfirmation,
|
||||
getCustomWidgetDefaultOptions,
|
||||
getCustomWidgetSecretRequirements,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
|
||||
import { safeParseStoredCustomWidgetDefinition } from "./stored-definition";
|
||||
|
||||
const logger = createLogger({ module: "custom-widget:management" });
|
||||
|
||||
type StoredDefinition = InferSelectModel<typeof customWidgetDefinitions>;
|
||||
type StoredSecret = InferSelectModel<typeof customWidgetSecrets>;
|
||||
type LegacyDefinition = InferSelectModel<typeof legacyCustomWidgetDefinitions>;
|
||||
|
||||
export function mapCustomWidgetListItem(definition: StoredDefinition & { secrets: readonly StoredSecret[] }) {
|
||||
const result = safeParseStoredCustomWidgetDefinition(definition);
|
||||
if (!result.success) {
|
||||
logger.warn("Skipped parsing invalid custom widget definition", {
|
||||
id: definition.id,
|
||||
issueCount: result.issues.length,
|
||||
});
|
||||
return {
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
description: definition.description ?? undefined,
|
||||
iconUrl: definition.iconUrl ?? undefined,
|
||||
sources: [],
|
||||
requestCount: 0,
|
||||
missingSecrets: [],
|
||||
options: {},
|
||||
updatedAt: definition.updatedAt,
|
||||
enabled: definition.enabled,
|
||||
valid: false as const,
|
||||
migrationRequired: false as const,
|
||||
validationIssues: result.issues,
|
||||
};
|
||||
}
|
||||
|
||||
const widget = result.widget;
|
||||
const configuredSecrets = new Set(definition.secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
|
||||
return {
|
||||
id: definition.id,
|
||||
name: widget.name,
|
||||
description: widget.description,
|
||||
iconUrl: widget.iconUrl,
|
||||
sources: Object.entries(widget.sources).map(([id, { name, baseUrl, networkScope, auth }]) => ({
|
||||
id,
|
||||
name,
|
||||
origin: new URL(baseUrl).origin,
|
||||
networkScope,
|
||||
authType: typeof auth === "string" ? auth : auth.type,
|
||||
})),
|
||||
requestCount: Object.keys(widget.requests).length,
|
||||
missingSecrets: getCustomWidgetSecretRequirements(widget.sources).filter(
|
||||
(requirement) => !configuredSecrets.has(`${requirement.sourceId}:${requirement.kind}`),
|
||||
),
|
||||
options: widget.options,
|
||||
updatedAt: definition.updatedAt,
|
||||
enabled: definition.enabled,
|
||||
valid: true as const,
|
||||
migrationRequired: false as const,
|
||||
validationIssues: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function mapLegacyCustomWidgetListItem(definition: LegacyDefinition) {
|
||||
return {
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
description: definition.description ?? undefined,
|
||||
iconUrl: definition.iconUrl ?? undefined,
|
||||
sources: [{ id: "default", name: "Legacy API", origin: safeOrigin(definition.url), authType: definition.authType }],
|
||||
requestCount: 1,
|
||||
missingSecrets: [],
|
||||
options: {},
|
||||
updatedAt: definition.updatedAt,
|
||||
enabled: false,
|
||||
valid: false as const,
|
||||
migrationRequired: true as const,
|
||||
validationIssues: [],
|
||||
};
|
||||
}
|
||||
|
||||
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 });
|
||||
return [];
|
||||
}
|
||||
|
||||
const widget = result.widget;
|
||||
return [
|
||||
{
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
iconUrl: definition.iconUrl,
|
||||
options: widget.options,
|
||||
defaultOptions: getCustomWidgetDefaultOptions(widget.options),
|
||||
template: widget.template,
|
||||
sources: Object.entries(widget.sources).map(([id, { name, networkScope, auth }]) => ({
|
||||
id,
|
||||
name,
|
||||
networkScope,
|
||||
authType: typeof auth === "string" ? auth : auth.type,
|
||||
})),
|
||||
requestCapabilities: Object.entries(widget.requests).map(
|
||||
([id, { kind, method, trigger, permission, confirmation, invalidates }]) => ({
|
||||
id,
|
||||
kind,
|
||||
method,
|
||||
trigger,
|
||||
minimumBoardPermission: permission,
|
||||
confirmation: getCustomWidgetConfirmation({ method, confirmation }),
|
||||
invalidates,
|
||||
}),
|
||||
),
|
||||
optionRequests: Object.entries(widget.requests).flatMap(([id, request]) =>
|
||||
request.kind === "query" ? [{ id }] : [],
|
||||
),
|
||||
updatedAt: definition.updatedAt,
|
||||
migrationRequired: false as const,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function mapLegacyAvailableCustomWidget(definition: LegacyDefinition) {
|
||||
return {
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
iconUrl: definition.iconUrl,
|
||||
options: {},
|
||||
defaultOptions: {},
|
||||
template: "",
|
||||
sources: [],
|
||||
requestCapabilities: [],
|
||||
optionRequests: [],
|
||||
updatedAt: definition.updatedAt,
|
||||
migrationRequired: true as const,
|
||||
};
|
||||
}
|
||||
|
||||
function safeOrigin(value: string) {
|
||||
try {
|
||||
return new URL(value).origin;
|
||||
} catch {
|
||||
return "Invalid legacy URL";
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import { customWidgetDefinitions } from "@homarr/db/schema";
|
||||
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
import { createPreviewSession, getPreviewSession } from "./preview-sessions";
|
||||
import { hasSameSecretBinding, requiredSecretKinds } from "./secret-policy";
|
||||
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
|
||||
|
||||
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
|
||||
|
||||
@@ -48,8 +50,32 @@ const previewCreateProcedure = manageProcedure
|
||||
with: { secrets: true },
|
||||
});
|
||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
|
||||
const existingDefinition = parseStoredCustomWidgetDefinition(existing);
|
||||
for (const [sourceId, existingSource] of Object.entries(existingDefinition.sources)) {
|
||||
const submittedSource = input.definition.sources[sourceId];
|
||||
const hasStoredSecrets = existing.secrets.some((secret) => secret.sourceId === sourceId);
|
||||
if (!submittedSource || !hasStoredSecrets || hasSameSecretBinding(existingSource, submittedSource)) continue;
|
||||
|
||||
const authType = typeof submittedSource.auth === "string" ? submittedSource.auth : submittedSource.auth.type;
|
||||
const missingReplacement = requiredSecretKinds(authType).some(
|
||||
(kind) => !secrets.some((secret) => secret.sourceId === sourceId && secret.kind === kind),
|
||||
);
|
||||
if (missingReplacement) {
|
||||
throw new TRPCError({
|
||||
code: ctx.session.user.permissions.includes("custom-widget-secret-write") ? "BAD_REQUEST" : "FORBIDDEN",
|
||||
message: "Source security settings changed; re-enter its credentials to preview this definition",
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const secret of existing.secrets) {
|
||||
if (!secrets.some((candidate) => candidate.sourceId === secret.sourceId && candidate.kind === secret.kind)) {
|
||||
const existingSource = existingDefinition.sources[secret.sourceId];
|
||||
const submittedSource = input.definition.sources[secret.sourceId];
|
||||
if (
|
||||
existingSource &&
|
||||
submittedSource &&
|
||||
hasSameSecretBinding(existingSource, submittedSource) &&
|
||||
!secrets.some((candidate) => candidate.sourceId === secret.sourceId && candidate.kind === secret.kind)
|
||||
) {
|
||||
secrets.push({ sourceId: secret.sourceId, kind: secret.kind, value: decryptSecret(secret.encryptedValue) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,14 @@ import { toTrpcError } from "./domain-error";
|
||||
|
||||
const SESSION_PREFIX = "custom-widget:preview-session:";
|
||||
const JOURNAL_PREFIX = "custom-widget:preview-journal:";
|
||||
const COMPARE_AND_SWAP_SESSION_SCRIPT = `
|
||||
local current = redis.call('GET', KEYS[1])
|
||||
if not current then return 0 end
|
||||
local ok, parsed = pcall(cjson.decode, current)
|
||||
if not ok or type(parsed) ~= 'table' or tonumber(parsed.revision) ~= tonumber(ARGV[1]) then return 0 end
|
||||
redis.call('SET', KEYS[1], ARGV[2], 'PX', ARGV[3])
|
||||
return 1
|
||||
`;
|
||||
|
||||
class RedisPreviewSessionStore implements PreviewSessionStore {
|
||||
public constructor(private readonly redis: RedisClient) {}
|
||||
@@ -25,6 +33,20 @@ class RedisPreviewSessionStore implements PreviewSessionStore {
|
||||
public getSession(id: string) {
|
||||
return this.redis.get(`${SESSION_PREFIX}${id}`);
|
||||
}
|
||||
public async compareAndSwapSession(id: string, expectedRevision: number, value: unknown, ttlMs: number) {
|
||||
return (
|
||||
Number(
|
||||
await this.redis.eval(
|
||||
COMPARE_AND_SWAP_SESSION_SCRIPT,
|
||||
1,
|
||||
`${SESSION_PREFIX}${id}`,
|
||||
expectedRevision,
|
||||
JSON.stringify(value),
|
||||
ttlMs,
|
||||
),
|
||||
) === 1
|
||||
);
|
||||
}
|
||||
public async deleteSession(id: string) {
|
||||
await this.redis.del(`${SESSION_PREFIX}${id}`);
|
||||
}
|
||||
|
||||
@@ -13,15 +13,16 @@ if count == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
|
||||
return { count, redis.call('PTTL', KEYS[1]) }
|
||||
`;
|
||||
const ACQUIRE_SCRIPT = `
|
||||
local count = redis.call('INCR', KEYS[1])
|
||||
if count == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[2]) end
|
||||
if count > tonumber(ARGV[1]) then redis.call('DECR', KEYS[1]); return 0 end
|
||||
return count
|
||||
local time = redis.call('TIME')
|
||||
local now = (tonumber(time[1]) * 1000) + math.floor(tonumber(time[2]) / 1000)
|
||||
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now)
|
||||
if redis.call('ZCARD', KEYS[1]) >= tonumber(ARGV[2]) then return 0 end
|
||||
redis.call('ZADD', KEYS[1], now + tonumber(ARGV[3]), ARGV[1])
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[3])
|
||||
return 1
|
||||
`;
|
||||
const RELEASE_SCRIPT = `
|
||||
local count = tonumber(redis.call('GET', KEYS[1]) or '0')
|
||||
if count <= 1 then redis.call('DEL', KEYS[1]); return 0 end
|
||||
return redis.call('DECR', KEYS[1])
|
||||
return redis.call('ZREM', KEYS[1], ARGV[1])
|
||||
`;
|
||||
|
||||
class RedisRequestLimitStore implements RequestLimitStore {
|
||||
@@ -32,12 +33,12 @@ class RedisRequestLimitStore implements RequestLimitStore {
|
||||
return { count: Number(count), retryAfterMs: Number(retryAfterMs) || windowMs };
|
||||
}
|
||||
|
||||
public async acquireConcurrency(key: string, limit: number, ttlMs: number) {
|
||||
return Number(await this.redis.eval(ACQUIRE_SCRIPT, 1, key, limit, ttlMs)) > 0;
|
||||
public async acquireConcurrency(key: string, ownerId: string, limit: number, ttlMs: number) {
|
||||
return Number(await this.redis.eval(ACQUIRE_SCRIPT, 1, key, ownerId, limit, ttlMs)) > 0;
|
||||
}
|
||||
|
||||
public async releaseConcurrency(key: string) {
|
||||
await this.redis.eval(RELEASE_SCRIPT, 1, key);
|
||||
public async releaseConcurrency(key: string, ownerId: string) {
|
||||
await this.redis.eval(RELEASE_SCRIPT, 1, key, ownerId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { getCustomWidgetRequiredSecretKinds } from "@homarr/custom-widgets/core";
|
||||
import {
|
||||
getCustomWidgetRequiredSecretKinds,
|
||||
hasSameCustomWidgetSourceAuthentication,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetSource } from "@homarr/custom-widgets/core";
|
||||
|
||||
export function requiredSecretKinds(authType: string) {
|
||||
return getCustomWidgetRequiredSecretKinds(authType as Parameters<typeof getCustomWidgetRequiredSecretKinds>[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Credentials may only survive edits that preserve their complete security
|
||||
* boundary. The path is intentionally excluded: credentials are scoped to an
|
||||
* origin, while the network scope and auth destination are part of the binding.
|
||||
*/
|
||||
export function hasSameSecretBinding(left: CustomWidgetSource, right: CustomWidgetSource) {
|
||||
return (
|
||||
new URL(left.baseUrl).origin === new URL(right.baseUrl).origin &&
|
||||
left.networkScope === right.networkScope &&
|
||||
hasSameCustomWidgetSourceAuthentication(left, right)
|
||||
);
|
||||
}
|
||||
|
||||
export function assertSecretSources(
|
||||
sources: Record<string, { auth: string | { type: string } }>,
|
||||
secrets: readonly { sourceId: string; kind: "apiKey" | "username" | "password" }[],
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
createCustomWidgetConfigurationRequest,
|
||||
getCustomWidgetConfigurationRequestForUser,
|
||||
} from "./configuration-requests";
|
||||
import { assertSecretSources, requiredSecretKinds } from "./secret-policy";
|
||||
import { assertSecretSources, hasSameSecretBinding, requiredSecretKinds } from "./secret-policy";
|
||||
import { getPreviewSession } from "./preview-sessions";
|
||||
import { parseStoredCustomWidgetDefinition, serializeCustomWidgetDefinition } from "./stored-definition";
|
||||
|
||||
@@ -107,6 +107,7 @@ export const secretProcedures = {
|
||||
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) => ({
|
||||
definitionId: input.definitionId,
|
||||
sourceId: input.sourceId,
|
||||
@@ -121,6 +122,16 @@ export const secretProcedures = {
|
||||
.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)
|
||||
@@ -142,6 +153,17 @@ export const secretProcedures = {
|
||||
.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)
|
||||
@@ -181,6 +203,12 @@ 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),
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { z } from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
|
||||
import { eq } from "@homarr/db";
|
||||
import { and, eq } from "@homarr/db";
|
||||
import { customWidgetDefinitions } from "@homarr/db/schema";
|
||||
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
@@ -120,11 +120,28 @@ export const templateProcedures = {
|
||||
}
|
||||
|
||||
const template = validateTemplate(definition, lines.join("\n"));
|
||||
await ctx.db
|
||||
const updateResult = (await ctx.db
|
||||
.update(customWidgetDefinitions)
|
||||
.set({ template, updatedAt: new Date() })
|
||||
.where(eq(customWidgetDefinitions.id, input.id));
|
||||
.where(
|
||||
and(eq(customWidgetDefinitions.id, input.id), eq(customWidgetDefinitions.template, definition.template)),
|
||||
)) as unknown;
|
||||
if (getAffectedRowCount(updateResult) === 0) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "Template changed since it was read. Read it again and retry.",
|
||||
});
|
||||
}
|
||||
logger.info("Patched custom widget template", { id: input.id, editCount: input.edits.length });
|
||||
return { id: input.id, template, templateLines: lines, revision: getTemplateRevision(template) };
|
||||
}),
|
||||
};
|
||||
|
||||
function getAffectedRowCount(result: unknown): number {
|
||||
if (Array.isArray(result)) return getAffectedRowCount(result[0]);
|
||||
if (!result || typeof result !== "object") return 0;
|
||||
if ("affectedRows" in result && typeof result.affectedRows === "number") return result.affectedRows;
|
||||
if ("rowCount" in result && typeof result.rowCount === "number") return result.rowCount;
|
||||
if ("changes" in result && typeof result.changes === "number") return result.changes;
|
||||
return 0;
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { encryptSecret } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { eq } from "@homarr/db";
|
||||
import { customWidgetDefinitions } from "@homarr/db/schema";
|
||||
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 { assertSecretSources } from "./secret-policy";
|
||||
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
|
||||
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" });
|
||||
@@ -37,4 +38,117 @@ export const transferProcedures = {
|
||||
logger.info("Imported custom widget definition", { id, name: input.widget.name });
|
||||
return { id };
|
||||
}),
|
||||
|
||||
migrateLegacy: manageProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
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 migrated widget secrets requires dedicated permission",
|
||||
});
|
||||
}
|
||||
assertSecretSources(input.widget.sources, input.secrets);
|
||||
const [legacy, current] = await Promise.all([
|
||||
ctx.db.query.legacyCustomWidgetDefinitions.findFirst({
|
||||
where: eq(legacyCustomWidgetDefinitions.id, input.id),
|
||||
with: { secrets: true },
|
||||
}),
|
||||
ctx.db.query.customWidgetDefinitions.findFirst({ where: eq(customWidgetDefinitions.id, input.id) }),
|
||||
]);
|
||||
if (!legacy) throw new TRPCError({ code: "NOT_FOUND", message: "Legacy custom widget not found" });
|
||||
if (current) throw new TRPCError({ code: "CONFLICT", message: "A v2 widget already uses this identifier" });
|
||||
|
||||
const explicitKeys = new Set(input.secrets.map(({ sourceId, kind }) => `${sourceId}:${kind}`));
|
||||
const preservableKinds = new Set<string>(requiredSecretKinds(getAuthType(input.widget.sources.default?.auth)));
|
||||
const preservedSecrets = canPreserveLegacySecrets(legacy, input.widget)
|
||||
? legacy.secrets.filter(({ kind }) => preservableKinds.has(kind) && !explicitKeys.has(`default:${kind}`))
|
||||
: [];
|
||||
const now = new Date();
|
||||
const definitionRow = {
|
||||
id: legacy.id,
|
||||
...serializeCustomWidgetDefinition(input.widget),
|
||||
enabled: legacy.enabled,
|
||||
createdAt: legacy.createdAt,
|
||||
updatedAt: now,
|
||||
creatorId: legacy.creatorId,
|
||||
};
|
||||
const secretRows = [
|
||||
...preservedSecrets.map(({ kind, encryptedValue }) => ({
|
||||
definitionId: legacy.id,
|
||||
sourceId: "default",
|
||||
kind,
|
||||
encryptedValue,
|
||||
updatedAt: now,
|
||||
})),
|
||||
...input.secrets.map(({ sourceId, kind, value }) => ({
|
||||
definitionId: legacy.id,
|
||||
sourceId,
|
||||
kind,
|
||||
encryptedValue: encryptSecret(value),
|
||||
updatedAt: now,
|
||||
})),
|
||||
];
|
||||
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
async handleAsync(database, schema) {
|
||||
await database.transaction(async (transaction) => {
|
||||
await transaction.insert(schema.customWidgetDefinitions).values(definitionRow);
|
||||
if (secretRows.length > 0) await transaction.insert(schema.customWidgetSecrets).values(secretRows);
|
||||
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", {
|
||||
id: legacy.id,
|
||||
preservedSecretCount: preservedSecrets.length,
|
||||
});
|
||||
return { id: legacy.id, preservedSecretCount: preservedSecrets.length };
|
||||
}),
|
||||
|
||||
deleteLegacy: manageProcedure.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(
|
||||
legacy: typeof legacyCustomWidgetDefinitions.$inferSelect,
|
||||
widget: z.infer<typeof customWidgetImportSchema>,
|
||||
) {
|
||||
const source = widget.sources.default;
|
||||
if (!source || getAuthType(source.auth) !== legacy.authType) return false;
|
||||
if (getOrigin(source.baseUrl) !== getOrigin(legacy.url)) return false;
|
||||
const headerName = typeof source.auth === "object" && "name" in source.auth ? source.auth.name : undefined;
|
||||
return headerName === (legacy.headerName ?? undefined);
|
||||
}
|
||||
|
||||
function getAuthType(auth: string | { type: string } | undefined) {
|
||||
return typeof auth === "string" ? auth : (auth?.type ?? "none");
|
||||
}
|
||||
|
||||
function getOrigin(value: string) {
|
||||
try {
|
||||
return new URL(value).origin.toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
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 {
|
||||
customWidgetDefinitions,
|
||||
customWidgetSecrets,
|
||||
legacyCustomWidgetDefinitions,
|
||||
legacyCustomWidgetSecrets,
|
||||
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";
|
||||
@@ -18,14 +24,28 @@ const session = {
|
||||
},
|
||||
expires: new Date().toISOString(),
|
||||
} satisfies Session;
|
||||
const manageOnlySession = {
|
||||
...session,
|
||||
user: { ...session.user, permissions: ["custom-widget-manage"] },
|
||||
} satisfies Session;
|
||||
|
||||
const jellyfin = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-jellyfin")?.widget;
|
||||
if (!jellyfin) throw new Error("Jellyfin bundled widget is missing");
|
||||
const jellyfinDefaultSource = jellyfin.sources.default;
|
||||
if (!jellyfinDefaultSource) throw new Error("Jellyfin default source is missing");
|
||||
|
||||
function createCaller(db: ReturnType<typeof createDb>) {
|
||||
return customWidgetRouter.createCaller({ db, deviceType: undefined, session });
|
||||
}
|
||||
|
||||
function createManageOnlyCaller(db: ReturnType<typeof createDb>) {
|
||||
return customWidgetRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: manageOnlySession,
|
||||
});
|
||||
}
|
||||
|
||||
async function prepareDatabase() {
|
||||
const db = createDb();
|
||||
await db.insert(users).values({ id: userId });
|
||||
@@ -42,9 +62,69 @@ function rejectSecretInserts(db: ReturnType<typeof createDb>) {
|
||||
);
|
||||
}
|
||||
|
||||
async function insertLegacyJellyfin(db: ReturnType<typeof createDb>) {
|
||||
const id = "legacy-jellyfin";
|
||||
const encryptedValue = "preserved.secret" as `${string}.${string}`;
|
||||
await db.insert(legacyCustomWidgetDefinitions).values({
|
||||
id,
|
||||
name: "Legacy Jellyfin",
|
||||
url: "http://jellyfin.local/Items/Counts",
|
||||
authType: "apiKeyHeader",
|
||||
headerName: "X-Emby-Token",
|
||||
method: "GET",
|
||||
displayType: "singleValue",
|
||||
enabled: false,
|
||||
creatorId: userId,
|
||||
});
|
||||
await db.insert(legacyCustomWidgetSecrets).values({
|
||||
definitionId: id,
|
||||
kind: "apiKey",
|
||||
encryptedValue,
|
||||
updatedAt: new Date(0),
|
||||
});
|
||||
return { id, encryptedValue };
|
||||
}
|
||||
|
||||
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 () => {
|
||||
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.customWidgetDefinitions.findFirst()).toMatchObject({
|
||||
id: legacy.id,
|
||||
enabled: false,
|
||||
creatorId: userId,
|
||||
});
|
||||
expect(await db.query.customWidgetSecrets.findFirst()).toMatchObject({
|
||||
definitionId: legacy.id,
|
||||
sourceId: "default",
|
||||
kind: "apiKey",
|
||||
encryptedValue: legacy.encryptedValue,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the legacy widget recoverable when confirmed replacement fails", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const legacy = await insertLegacyJellyfin(db);
|
||||
rejectSecretInserts(db);
|
||||
|
||||
await expect(createCaller(db).migrateLegacy({ id: legacy.id, widget: jellyfin, secrets: [] })).rejects.toThrow(
|
||||
"forced secret insert failure",
|
||||
);
|
||||
|
||||
expect(await db.query.legacyCustomWidgetDefinitions.findFirst()).toMatchObject({ id: legacy.id });
|
||||
expect(await db.query.legacyCustomWidgetSecrets.findFirst()).toMatchObject({
|
||||
definitionId: legacy.id,
|
||||
encryptedValue: legacy.encryptedValue,
|
||||
});
|
||||
expect(await db.query.customWidgetDefinitions.findMany()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("rolls back create when its secret cannot be stored", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
@@ -155,4 +235,99 @@ describe("custom widget definition persistence", () => {
|
||||
widgetName: "Living room Jellyfin",
|
||||
});
|
||||
});
|
||||
|
||||
test("does not inherit a stored credential after a preview source binding changes", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const created = await caller.create({ ...jellyfin, secrets: [secret] });
|
||||
const changedDefinition = {
|
||||
...jellyfin,
|
||||
sources: {
|
||||
...jellyfin.sources,
|
||||
default: { ...jellyfinDefaultSource, baseUrl: "https://attacker.example" },
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
createManageOnlyCaller(db).previewCreate({
|
||||
definitionId: created.id,
|
||||
definition: changedDefinition,
|
||||
secrets: [],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
await expect(
|
||||
caller.previewCreate({ definitionId: created.id, definition: changedDefinition, secrets: [secret] }),
|
||||
).resolves.toMatchObject({ success: true });
|
||||
});
|
||||
|
||||
test("clears persisted credentials when a manage-only source edit changes its binding", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const created = await createCaller(db).create({ ...jellyfin, secrets: [secret] });
|
||||
|
||||
await createManageOnlyCaller(db).sourceConfigure({
|
||||
definitionId: created.id,
|
||||
sourceId: "default",
|
||||
baseUrl: "https://other.example/api",
|
||||
networkScope: "public",
|
||||
secrets: [],
|
||||
});
|
||||
|
||||
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({
|
||||
id: created.id,
|
||||
sources: {
|
||||
...jellyfin.sources,
|
||||
default: { ...jellyfinDefaultSource, baseUrl: "https://other.example/api" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
await db.query.customWidgetSecrets.findMany({
|
||||
where: eq(customWidgetSecrets.definitionId, created.id),
|
||||
}),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("requires secret-write permission 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" }),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
|
||||
test("allows only one concurrent template patch for a revision", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const created = await caller.create({ ...jellyfin, template: "<Text>Original</Text>", secrets: [] });
|
||||
const read = await caller.readTemplate({ id: created.id });
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
caller.templatePatch({
|
||||
id: created.id,
|
||||
expectedRevision: read.revision,
|
||||
edits: [{ startLine: 1, deleteCount: 1, replacementLines: ["<Text>First</Text>"] }],
|
||||
}),
|
||||
caller.templatePatch({
|
||||
id: created.id,
|
||||
expectedRevision: read.revision,
|
||||
edits: [{ startLine: 1, deleteCount: 1, replacementLines: ["<Text>Second</Text>"] }],
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(results.filter(({ status }) => status === "fulfilled")).toHaveLength(1);
|
||||
expect(results.filter(({ status }) => status === "rejected")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { buildLegacyCustomWidgetMigrationPrompt } from "../../custom-widget/legacy-migration";
|
||||
|
||||
describe("legacy custom widget migration prompt", () => {
|
||||
test("describes the legacy widget without exposing URL or body credentials", () => {
|
||||
const prompt = buildLegacyCustomWidgetMigrationPrompt(
|
||||
{
|
||||
id: "legacy",
|
||||
name: "Private service",
|
||||
description: "Status",
|
||||
iconUrl: null,
|
||||
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" } }),
|
||||
enabled: true,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
creatorId: null,
|
||||
},
|
||||
["apiKey"],
|
||||
);
|
||||
|
||||
expect(prompt).toContain("homarr-custom-widget-v1");
|
||||
expect(prompt).toContain("homarr-custom-widget-v2");
|
||||
expect(prompt).toContain("configuredSecretKinds");
|
||||
expect(prompt).not.toContain("top-secret");
|
||||
expect(prompt).not.toContain("body-secret");
|
||||
expect(prompt).not.toContain("admin:password");
|
||||
expect(prompt).not.toContain("path-token");
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { resolvePreviewRequestParams } from "../../custom-widget/preview-procedure-helpers";
|
||||
import { resolveOptionRequestValues } from "../../custom-widget/management-queries";
|
||||
|
||||
const loadRequest: CustomJsxRequest = {
|
||||
source: "default",
|
||||
@@ -32,3 +33,17 @@ describe("preview request option bindings", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("configuration option request bindings", () => {
|
||||
it("resolves option configuration and runtime parameters in their own slots", () => {
|
||||
const request: CustomJsxRequest = {
|
||||
...loadRequest,
|
||||
path: "/environments/{option:environmentId}/containers/{param:containerId}",
|
||||
};
|
||||
|
||||
expect(resolveOptionRequestValues(request, { environmentId: 7, containerId: 12, unrelated: true })).toEqual({
|
||||
"option:environmentId": 7,
|
||||
containerId: 12,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { z } from "zod/v4";
|
||||
|
||||
import { decryptSecret } from "@homarr/common/server";
|
||||
import { eq } from "@homarr/db";
|
||||
import { boards, customWidgetDefinitions, items } from "@homarr/db/schema";
|
||||
import { boards, customWidgetDefinitions, items, legacyCustomWidgetDefinitions } from "@homarr/db/schema";
|
||||
import type { BoardPermission } from "@homarr/definitions";
|
||||
import {
|
||||
getCustomWidgetConfirmation,
|
||||
@@ -81,7 +81,19 @@ async function resolvePlacedDefinitionAsync(ctx: RouterContext, itemId: string)
|
||||
where: eq(customWidgetDefinitions.id, itemOptions.definitionId),
|
||||
with: { secrets: true },
|
||||
});
|
||||
if (!stored) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget unavailable" });
|
||||
if (!stored) {
|
||||
const legacy = await ctx.db.query.legacyCustomWidgetDefinitions.findFirst({
|
||||
where: eq(legacyCustomWidgetDefinitions.id, itemOptions.definitionId),
|
||||
columns: { id: true },
|
||||
});
|
||||
if (legacy) {
|
||||
throw new TRPCError({
|
||||
code: "PRECONDITION_FAILED",
|
||||
message: "LEGACY_CUSTOM_WIDGET_MIGRATION_REQUIRED",
|
||||
});
|
||||
}
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget unavailable" });
|
||||
}
|
||||
if (!stored.enabled) throw new TRPCError({ code: "FORBIDDEN", message: "Widget is disabled" });
|
||||
|
||||
const definition = parseStoredCustomWidgetDefinition(stored);
|
||||
|
||||
+200
-106
@@ -1,115 +1,209 @@
|
||||
import { expect, test, vi } from "vitest";
|
||||
import type { TRPCError } from "@trpc/server";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { extractToolsFromProcedures } from "trpc-to-mcp";
|
||||
|
||||
import { appRouter } from "../router/app";
|
||||
import { boardRouter } from "../router/board";
|
||||
import { customWidgetRouter } from "../router/custom-widget/custom-widget-router";
|
||||
import { infoRouter } from "../router/info";
|
||||
import { inviteRouter } from "../router/invite";
|
||||
import { serverSettingsRouter } from "../router/serverSettings";
|
||||
import { createTRPCRouter } from "../trpc";
|
||||
import { mcpRouter } from "../mcp";
|
||||
|
||||
vi.mock("@homarr/auth", () => ({}));
|
||||
|
||||
const mcpTestRouter = createTRPCRouter({
|
||||
app: appRouter,
|
||||
board: boardRouter,
|
||||
customWidget: customWidgetRouter,
|
||||
info: infoRouter,
|
||||
invite: inviteRouter,
|
||||
serverSettings: serverSettingsRouter,
|
||||
const MCP_TOOL_ALLOWLIST = {
|
||||
query: [
|
||||
"apiKeys_getAll",
|
||||
"app_all",
|
||||
"app_byId",
|
||||
"app_getPaginated",
|
||||
"app_search",
|
||||
"bazarr_getBadges",
|
||||
"beszel_getAlerts",
|
||||
"beszel_getSystems",
|
||||
"beszel_getSystemStats",
|
||||
"board_getAllBoards",
|
||||
"calendar_findAllEvents",
|
||||
"customWidget_get",
|
||||
"customWidget_getAuthoringPrompt",
|
||||
"customWidget_getSkill",
|
||||
"customWidget_list",
|
||||
"customWidget_previewJournal",
|
||||
"customWidget_previewQuery",
|
||||
"customWidget_schema",
|
||||
"customWidget_validate",
|
||||
"customWidget_workshopGet",
|
||||
"customWidget_workshopSearch",
|
||||
"dnsHole_summary",
|
||||
"docker_getContainers",
|
||||
"docker_logs",
|
||||
"downloads_getJobsAndStatuses",
|
||||
"healthMonitoring_getClusterHealthStatus",
|
||||
"healthMonitoring_getSystemHealthStatus",
|
||||
"healthMonitoring_listStorageVolumes",
|
||||
"icon_findIcons",
|
||||
"info_getInfo",
|
||||
"integration_all",
|
||||
"integration_byId",
|
||||
"integration_getKinds",
|
||||
"integration_getMediaRequestOptions",
|
||||
"integration_mediaRequestSearchTargets",
|
||||
"integration_search",
|
||||
"integration_searchInIntegration",
|
||||
"integration_searchMediaRequests",
|
||||
"invite_getAll",
|
||||
"mediaRequests_getLatestRequests",
|
||||
"mediaRequests_getStats",
|
||||
"mediaServer_getCurrentStreams",
|
||||
"patchmon_getStats",
|
||||
"serverSettings_getBoardSettings",
|
||||
"smartHome_entityState",
|
||||
"widgetSecrets_getConfiguredKinds",
|
||||
],
|
||||
mutation: [
|
||||
"app_create",
|
||||
"app_delete",
|
||||
"app_update",
|
||||
"board_addItem",
|
||||
"board_changeBoardVisibility",
|
||||
"board_createBoard",
|
||||
"board_deleteBoard",
|
||||
"board_duplicateBoard",
|
||||
"board_renameBoard",
|
||||
"board_savePartialBoardSettings",
|
||||
"board_setHomeBoard",
|
||||
"board_setMobileHomeBoard",
|
||||
"customWidget_delete",
|
||||
"customWidget_previewAction",
|
||||
"customWidget_templatePatch",
|
||||
"dnsHole_disable",
|
||||
"dnsHole_enable",
|
||||
"docker_removeAll",
|
||||
"docker_restartAll",
|
||||
"docker_startAll",
|
||||
"docker_stopAll",
|
||||
"downloads_pause",
|
||||
"downloads_resume",
|
||||
"integration_create",
|
||||
"integration_delete",
|
||||
"integration_requestMedia",
|
||||
"invite_createInvite",
|
||||
"invite_deleteInvite",
|
||||
"mediaRequests_answerRequest",
|
||||
"serverSettings_updateBoardSettings",
|
||||
"smartHome_executeAutomation",
|
||||
"smartHome_switchEntity",
|
||||
],
|
||||
secret: [
|
||||
"apiKeys_create",
|
||||
"apiKeys_delete",
|
||||
"customWidget_configurationRequestUser",
|
||||
"customWidget_create",
|
||||
"customWidget_previewCreate",
|
||||
"customWidget_secretSet",
|
||||
"customWidget_sourceConfigure",
|
||||
"customWidget_update",
|
||||
"customWidget_workshopInstall",
|
||||
"widgetSecrets_deleteSecret",
|
||||
"widgetSecrets_setSecret",
|
||||
],
|
||||
} as const;
|
||||
|
||||
type ToolClassification = keyof typeof MCP_TOOL_ALLOWLIST;
|
||||
|
||||
function expectedToolInventory() {
|
||||
return Object.entries(MCP_TOOL_ALLOWLIST)
|
||||
.flatMap(([classification, names]) =>
|
||||
names.map((name) => ({ name, classification: classification as ToolClassification })),
|
||||
)
|
||||
.toSorted((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
function actualToolInventory() {
|
||||
const secretTools = new Set<string>(MCP_TOOL_ALLOWLIST.secret);
|
||||
const procedures = mcpRouter["_def"].procedures as unknown as Record<
|
||||
string,
|
||||
{ _def?: { type?: "query" | "mutation" } }
|
||||
>;
|
||||
return extractToolsFromProcedures(mcpRouter)
|
||||
.map((tool) => {
|
||||
const procedure = procedures[tool.pathInRouter.join(".")];
|
||||
const type = procedure?.["_def"]?.type;
|
||||
if (type !== "query" && type !== "mutation") throw new Error(`Unable to classify MCP tool '${tool.name}'`);
|
||||
return {
|
||||
name: tool.name,
|
||||
classification: secretTools.has(tool.name) ? ("secret" as const) : type,
|
||||
};
|
||||
})
|
||||
.toSorted((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
describe("production MCP router", () => {
|
||||
test("matches the reviewed tool and permission inventory exactly", () => {
|
||||
expect(actualToolInventory()).toEqual(expectedToolInventory());
|
||||
});
|
||||
|
||||
test("keeps every secret-bearing tool behind a mutation procedure", () => {
|
||||
const tools = extractToolsFromProcedures(mcpRouter);
|
||||
const procedures = mcpRouter["_def"].procedures as unknown as Record<
|
||||
string,
|
||||
{ _def?: { type?: "query" | "mutation" } }
|
||||
>;
|
||||
for (const name of MCP_TOOL_ALLOWLIST.secret) {
|
||||
const tool = tools.find((candidate) => candidate.name === name);
|
||||
expect(tool, `Missing reviewed secret tool '${name}'`).toBeDefined();
|
||||
const procedure = tool ? procedures[tool.pathInRouter.join(".")] : undefined;
|
||||
expect(procedure?.["_def"]?.type, `Secret tool '${name}' must remain a mutation`).toBe("mutation");
|
||||
}
|
||||
});
|
||||
|
||||
test("gives every production tool a description", () => {
|
||||
const tools = extractToolsFromProcedures(mcpRouter);
|
||||
for (const tool of tools) expect(tool.description, `Tool ${tool.name} should have a description`).toBeTruthy();
|
||||
expect(tools.find((tool) => tool.name === "customWidget_getAuthoringPrompt")?.description).toBe(
|
||||
"Get the current Custom Widget authoring instructions.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("MCP tools should be extractable from the router", () => {
|
||||
const act = () => extractToolsFromProcedures(mcpTestRouter);
|
||||
describe("custom widget authoring procedure access", () => {
|
||||
const unauthenticatedCaller = mcpRouter.createCaller({
|
||||
db: null as never,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
});
|
||||
|
||||
expect(act).not.toThrow();
|
||||
});
|
||||
|
||||
test("MCP tools should contain expected procedures", () => {
|
||||
const tools = extractToolsFromProcedures(mcpTestRouter);
|
||||
const toolNames = tools.map((tool) => tool.name);
|
||||
|
||||
expect(tools.length).toBeGreaterThan(0);
|
||||
expect(toolNames).toContain("app_all");
|
||||
expect(toolNames).toContain("app_byId");
|
||||
expect(toolNames).toContain("app_create");
|
||||
expect(toolNames).toContain("board_savePartialBoardSettings");
|
||||
expect(toolNames).toContain("board_duplicateBoard");
|
||||
expect(toolNames).toContain("customWidget_schema");
|
||||
expect(toolNames).toContain("customWidget_validate");
|
||||
expect(toolNames).toContain("customWidget_list");
|
||||
expect(toolNames).toContain("customWidget_get");
|
||||
expect(toolNames).toContain("customWidget_templatePatch");
|
||||
expect(toolNames).toContain("customWidget_create");
|
||||
expect(toolNames).toContain("customWidget_update");
|
||||
expect(toolNames).toContain("customWidget_delete");
|
||||
expect(toolNames).toContain("customWidget_previewCreate");
|
||||
expect(toolNames).toContain("customWidget_previewQuery");
|
||||
expect(toolNames).toContain("customWidget_previewAction");
|
||||
expect(toolNames).toContain("customWidget_previewJournal");
|
||||
expect(toolNames).toContain("customWidget_secretSet");
|
||||
expect(toolNames).toContain("customWidget_sourceConfigure");
|
||||
expect(toolNames).toContain("customWidget_configurationRequestUser");
|
||||
expect(toolNames).toContain("customWidget_workshopSearch");
|
||||
expect(toolNames).toContain("customWidget_workshopGet");
|
||||
expect(toolNames).toContain("customWidget_workshopInstall");
|
||||
expect(toolNames).toContain("customWidget_getAuthoringPrompt");
|
||||
expect(toolNames).toContain("customWidget_getSkill");
|
||||
expect(toolNames).not.toContain("customWidget_import");
|
||||
expect(toolNames).not.toContain("customWidget_export");
|
||||
expect(toolNames).not.toContain("customWidget_simulatePreviewAction");
|
||||
expect(toolNames).toContain("info_getInfo");
|
||||
expect(toolNames).toContain("invite_getAll");
|
||||
expect(toolNames).toContain("serverSettings_getBoardSettings");
|
||||
expect(toolNames).toContain("serverSettings_updateBoardSettings");
|
||||
});
|
||||
|
||||
test("MCP tools should have descriptions", () => {
|
||||
const tools = extractToolsFromProcedures(mcpTestRouter);
|
||||
|
||||
for (const tool of tools) {
|
||||
expect(tool.description, `Tool ${tool.name} should have a description`).toBeTruthy();
|
||||
}
|
||||
|
||||
expect(tools.find((tool) => tool.name === "customWidget_getAuthoringPrompt")?.description).toBe(
|
||||
"Get the current Custom Widget authoring instructions.",
|
||||
);
|
||||
});
|
||||
|
||||
test("custom widget public procedures remain available", () => {
|
||||
expect(Object.keys(customWidgetRouter["_def"].procedures)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"list",
|
||||
"get",
|
||||
"available",
|
||||
"create",
|
||||
"update",
|
||||
"toggleEnabled",
|
||||
"delete",
|
||||
"duplicate",
|
||||
"export",
|
||||
"import",
|
||||
"schema",
|
||||
"validate",
|
||||
"readTemplate",
|
||||
"writeTemplate",
|
||||
"templatePatch",
|
||||
"previewCreate",
|
||||
"previewGet",
|
||||
"previewQuery",
|
||||
"previewAction",
|
||||
"setPreviewLiveActions",
|
||||
"previewJournal",
|
||||
"secretSet",
|
||||
"sourceConfigure",
|
||||
"configurationRequestUser",
|
||||
"workshopSearch",
|
||||
"workshopGet",
|
||||
"workshopInstall",
|
||||
"secretClear",
|
||||
"getAuthoringPrompt",
|
||||
"getSkill",
|
||||
]),
|
||||
);
|
||||
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 () => {
|
||||
const calls = [
|
||||
unauthenticatedCaller.customWidget.list(),
|
||||
unauthenticatedCaller.customWidget.validate({ widget: {} }),
|
||||
unauthenticatedCaller.customWidget.secretSet({
|
||||
definitionId: "widget",
|
||||
secret: { sourceId: "default", kind: "apiKey", value: "secret" },
|
||||
}),
|
||||
];
|
||||
for (const call of calls) {
|
||||
await expect(call).rejects.toEqual(expect.objectContaining<Partial<TRPCError>>({ code: "UNAUTHORIZED" }));
|
||||
}
|
||||
});
|
||||
|
||||
test("enforces the dedicated permission on secret tools", async () => {
|
||||
const callerWithoutSecretPermission = mcpRouter.createCaller({
|
||||
db: null as never,
|
||||
deviceType: undefined,
|
||||
session: { user: { id: "user-1", permissions: ["custom-widget-manage"] } } as never,
|
||||
});
|
||||
await expect(
|
||||
callerWithoutSecretPermission.customWidget.secretSet({
|
||||
definitionId: "widget",
|
||||
secret: { sourceId: "default", kind: "apiKey", value: "secret" },
|
||||
}),
|
||||
).rejects.toEqual(expect.objectContaining<Partial<TRPCError>>({ code: "FORBIDDEN" }));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user