fix(custom-widgets): address v2 merge review

This commit is contained in:
ajnart committed 2026-07-22 23:36:25 +02:00
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,
});
});
});
+14 -2
View File
@@ -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
View File
@@ -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" }));
});
});