feat(custom-widgets): complete JSX v2
This commit is contained in:
263 files changed
+65503
-17816
No files matched your search
@@ -0,0 +1,17 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
interface CacheVersionDefinition {
|
||||
sources: string;
|
||||
requests: string;
|
||||
secrets: Array<{ sourceId: string; kind: string; encryptedValue: string }>;
|
||||
}
|
||||
|
||||
export function getCustomWidgetCacheVersion(definition: CacheVersionDefinition) {
|
||||
const secrets = definition.secrets
|
||||
.map(({ sourceId, kind, encryptedValue }) => ({ sourceId, kind, encryptedValue }))
|
||||
.toSorted((left, right) => `${left.sourceId}:${left.kind}`.localeCompare(`${right.sourceId}:${right.kind}`));
|
||||
return createHash("sha256")
|
||||
.update(JSON.stringify([definition.sources, definition.requests, secrets]))
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { stringify as stringifySuperJson } from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import { encryptSecret } from "@homarr/common/server";
|
||||
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
|
||||
import { eq } from "@homarr/db";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { and, eq, notInArray } from "@homarr/db";
|
||||
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
|
||||
import {
|
||||
customWidgetCreateSchema,
|
||||
customWidgetDefinitionSchema,
|
||||
customWidgetUpdateSchema,
|
||||
customJsxDisplayConfigV2Schema,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
|
||||
@@ -19,210 +18,156 @@ import { metadataProcedures } from "./metadata-procedures";
|
||||
import { previewActionProcedures } from "./preview-action-procedures";
|
||||
import { previewBaseProcedures } from "./preview-base-procedures";
|
||||
import { previewQueryProcedures } from "./preview-query-procedures";
|
||||
import { parseDisplayConfig } from "./parse-display-config";
|
||||
import { transferProcedures } from "./transfer-procedures";
|
||||
import { parseStoredCustomWidgetDefinition, serializeCustomWidgetDefinition } from "./stored-definition";
|
||||
import { templateProcedures } from "./template-procedures";
|
||||
import { transferProcedures } from "./transfer-procedures";
|
||||
import { assertSecretSources, requiredSecretKinds } from "./secret-policy";
|
||||
import { secretProcedures } from "./secret-procedures";
|
||||
|
||||
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
|
||||
|
||||
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
|
||||
const logger = createLogger({ module: "custom-widget" });
|
||||
|
||||
const updateFieldSerializers: Record<string, (value: unknown) => unknown> = {
|
||||
displayConfig: (value) => stringifySuperJson(value),
|
||||
};
|
||||
|
||||
export const customWidgetRouter = createTRPCRouter({
|
||||
...metadataProcedures,
|
||||
|
||||
...managementQueryProcedures,
|
||||
|
||||
create: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Create a validated custom widget. Admin only. Call customWidget_schema and customWidget_validate first. For Custom JSX use jsxApiVersion 2, named requests, a GET base method, and no inline credentials.",
|
||||
},
|
||||
})
|
||||
create: manageProcedure
|
||||
.meta({ mcp: { enabled: true, description: "Create one validated Custom JSX widget." } })
|
||||
.input(customWidgetCreateSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { secrets, ...candidate } = input;
|
||||
if (secrets.length > 0 && !ctx.session.user.permissions.includes("custom-widget-secret-write")) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Writing custom widget secrets requires dedicated permission",
|
||||
});
|
||||
}
|
||||
const definition = customWidgetDefinitionSchema.parse(candidate);
|
||||
assertSecretSources(definition.sources, secrets);
|
||||
const id = createId();
|
||||
|
||||
await ctx.db.insert(customWidgetDefinitions).values({
|
||||
id,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
iconUrl: input.iconUrl,
|
||||
url: input.url,
|
||||
authType: input.authType,
|
||||
headerName: input.headerName,
|
||||
method: input.method,
|
||||
requestBody: input.requestBody,
|
||||
displayType: input.displayType,
|
||||
displayConfig: stringifySuperJson(input.displayConfig),
|
||||
...serializeCustomWidgetDefinition(definition),
|
||||
creatorId: ctx.session.user.id,
|
||||
});
|
||||
|
||||
if (input.secrets.length > 0) {
|
||||
if (secrets.length > 0) {
|
||||
await ctx.db.insert(customWidgetSecrets).values(
|
||||
input.secrets.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
value: encryptSecret(secret.value),
|
||||
secrets.map((secret) => ({
|
||||
definitionId: id,
|
||||
sourceId: secret.sourceId,
|
||||
kind: secret.kind,
|
||||
encryptedValue: encryptSecret(secret.value),
|
||||
updatedAt: new Date(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("Created custom widget definition", { id, name: input.name });
|
||||
logger.info("Created custom widget definition", { id, name: definition.name });
|
||||
return { id };
|
||||
}),
|
||||
|
||||
update: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Update an existing custom widget. Admin only. REQUIRED: id. Read it with customWidget_byId, preserve unrelated fields, validate the resulting complete draft, then send the changed fields. Omit secrets to preserve stored credentials.",
|
||||
},
|
||||
})
|
||||
update: manageProcedure
|
||||
.meta({ mcp: { enabled: true, description: "Update one Custom JSX widget." } })
|
||||
.input(customWidgetUpdateSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND" });
|
||||
}
|
||||
|
||||
const effectiveDisplayConfig =
|
||||
input.displayConfig ??
|
||||
parseDisplayConfig(existing.displayConfig, input.id, logger, "Corrupt displayConfig in custom widget update");
|
||||
const effectiveMethod = input.method ?? existing.method;
|
||||
if (customJsxDisplayConfigV2Schema.safeParse(effectiveDisplayConfig).success && effectiveMethod !== "GET") {
|
||||
const current = parseStoredCustomWidgetDefinition(existing);
|
||||
const { id, secrets, ...changes } = input;
|
||||
if (secrets?.length && !ctx.session.user.permissions.includes("custom-widget-secret-write")) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Custom JSX v2 base data requests must use GET; mutations belong in named actions",
|
||||
code: "FORBIDDEN",
|
||||
message: "Writing custom widget secrets requires dedicated permission",
|
||||
});
|
||||
}
|
||||
const definition = customWidgetDefinitionSchema.parse({ ...current, ...changes });
|
||||
if (secrets) assertSecretSources(definition.sources, secrets);
|
||||
|
||||
const { id, secrets, ...updateFields } = input;
|
||||
const updateValues: Record<string, unknown> = { updatedAt: new Date() };
|
||||
await ctx.db
|
||||
.update(customWidgetDefinitions)
|
||||
.set({ ...serializeCustomWidgetDefinition(definition), updatedAt: new Date() })
|
||||
.where(eq(customWidgetDefinitions.id, id));
|
||||
|
||||
for (const [key, value] of Object.entries(updateFields)) {
|
||||
if (value === undefined) continue;
|
||||
const serialize = updateFieldSerializers[key];
|
||||
if (serialize) {
|
||||
updateValues[key] = serialize(value);
|
||||
} else {
|
||||
updateValues[key] = value;
|
||||
}
|
||||
await ctx.db.delete(customWidgetSecrets).where(
|
||||
and(
|
||||
eq(customWidgetSecrets.definitionId, id),
|
||||
notInArray(
|
||||
customWidgetSecrets.sourceId,
|
||||
definition.sources.map((source) => source.id),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
for (const source of definition.sources) {
|
||||
const kinds = [...requiredSecretKinds(source.auth.type)];
|
||||
const where = and(eq(customWidgetSecrets.definitionId, id), eq(customWidgetSecrets.sourceId, source.id));
|
||||
await ctx.db
|
||||
.delete(customWidgetSecrets)
|
||||
.where(kinds.length > 0 ? and(where, notInArray(customWidgetSecrets.kind, kinds)) : where);
|
||||
}
|
||||
|
||||
await ctx.db.update(customWidgetDefinitions).set(updateValues).where(eq(customWidgetDefinitions.id, id));
|
||||
|
||||
if (secrets !== undefined) {
|
||||
const effectiveAuthType = (updateFields.authType as string | undefined) ?? existing.authType;
|
||||
|
||||
if (secrets.length > 0) {
|
||||
await ctx.db.delete(customWidgetSecrets).where(eq(customWidgetSecrets.definitionId, id));
|
||||
await ctx.db.insert(customWidgetSecrets).values(
|
||||
secrets.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
value: encryptSecret(secret.value),
|
||||
definitionId: id,
|
||||
updatedAt: new Date(),
|
||||
})),
|
||||
);
|
||||
} else if (
|
||||
effectiveAuthType === "none" ||
|
||||
(typeof updateFields.authType === "string" && updateFields.authType !== existing.authType)
|
||||
) {
|
||||
await ctx.db.delete(customWidgetSecrets).where(eq(customWidgetSecrets.definitionId, id));
|
||||
for (const secret of secrets) {
|
||||
await ctx.db
|
||||
.delete(customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(customWidgetSecrets.definitionId, id),
|
||||
eq(customWidgetSecrets.sourceId, secret.sourceId),
|
||||
eq(customWidgetSecrets.kind, secret.kind),
|
||||
),
|
||||
);
|
||||
await ctx.db.insert(customWidgetSecrets).values({
|
||||
definitionId: id,
|
||||
sourceId: secret.sourceId,
|
||||
kind: secret.kind,
|
||||
encryptedValue: encryptSecret(secret.value),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("Updated custom widget definition", { id });
|
||||
}),
|
||||
|
||||
toggleEnabled: adminProcedure
|
||||
...secretProcedures,
|
||||
|
||||
toggleEnabled: manageProcedure
|
||||
.input(z.object({ id: z.string(), enabled: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND" });
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(customWidgetDefinitions)
|
||||
.set({ enabled: input.enabled, updatedAt: new Date() })
|
||||
.where(eq(customWidgetDefinitions.id, input.id));
|
||||
}),
|
||||
|
||||
delete: adminProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
delete: manageProcedure
|
||||
.meta({ mcp: { enabled: true, description: "Delete one Custom JSX widget." } })
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.db.delete(customWidgetDefinitions).where(eq(customWidgetDefinitions.id, input.id));
|
||||
logger.info("Deleted custom widget definition", { id: input.id });
|
||||
}),
|
||||
|
||||
duplicate: manageProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND" });
|
||||
}
|
||||
|
||||
await ctx.db.delete(customWidgetDefinitions).where(eq(customWidgetDefinitions.id, input.id));
|
||||
logger.info("Deleted custom widget definition", { id: input.id });
|
||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
const current = parseStoredCustomWidgetDefinition(existing);
|
||||
const id = createId();
|
||||
await ctx.db.insert(customWidgetDefinitions).values({
|
||||
id,
|
||||
...serializeCustomWidgetDefinition({ ...current, name: `${current.name} (copy)` }),
|
||||
creatorId: ctx.session.user.id,
|
||||
});
|
||||
return { id, name: `${current.name} (copy)` };
|
||||
}),
|
||||
|
||||
...templateProcedures,
|
||||
|
||||
...transferProcedures,
|
||||
|
||||
...previewBaseProcedures,
|
||||
...previewQueryProcedures,
|
||||
...previewActionProcedures,
|
||||
|
||||
duplicate: adminProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
with: { secrets: true },
|
||||
});
|
||||
|
||||
if (!definition) {
|
||||
throw new TRPCError({ code: "NOT_FOUND" });
|
||||
}
|
||||
|
||||
const newId = createId();
|
||||
await ctx.db.insert(customWidgetDefinitions).values({
|
||||
id: newId,
|
||||
name: `${definition.name} (copy)`,
|
||||
description: definition.description,
|
||||
iconUrl: definition.iconUrl,
|
||||
url: definition.url,
|
||||
authType: definition.authType,
|
||||
headerName: definition.headerName,
|
||||
method: definition.method,
|
||||
requestBody: definition.requestBody,
|
||||
displayType: definition.displayType,
|
||||
displayConfig: definition.displayConfig,
|
||||
enabled: definition.enabled,
|
||||
creatorId: ctx.session.user.id,
|
||||
});
|
||||
|
||||
if (definition.secrets.length > 0) {
|
||||
await ctx.db.insert(customWidgetSecrets).values(
|
||||
definition.secrets.map((s) => ({
|
||||
kind: s.kind,
|
||||
value: s.value,
|
||||
definitionId: newId,
|
||||
updatedAt: new Date(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("Duplicated custom widget definition", { sourceId: input.id, newId });
|
||||
return { id: newId, name: `${definition.name} (copy)` };
|
||||
}),
|
||||
});
|
||||
@@ -1,51 +1,51 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { decryptSecret } from "@homarr/common/server";
|
||||
import { eq, or } from "@homarr/db";
|
||||
import { boards, customWidgetDefinitions } from "@homarr/db/schema";
|
||||
|
||||
import { permissionRequiredProcedure, protectedProcedure } from "../../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "../board/board-access";
|
||||
import { parseDisplayConfig } from "./parse-display-config";
|
||||
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
|
||||
import { executeCustomWidgetRequest } from "./request-executor";
|
||||
import { hashRuntimeParams, renderRequestBody, renderRequestTarget } from "./request-manifest";
|
||||
import { acquireCustomWidgetRequestLimit } from "./request-limits";
|
||||
import { getCustomWidgetCacheVersion } from "./cache-version";
|
||||
|
||||
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
|
||||
const logger = createLogger({ module: "custom-widget" });
|
||||
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
|
||||
|
||||
export const managementQueryProcedures = {
|
||||
all: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"List all custom-widget definitions. Admin only. Use the returned ID to inspect or update a widget.",
|
||||
},
|
||||
})
|
||||
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),
|
||||
});
|
||||
return definitions.map((definition) => ({
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
iconUrl: definition.iconUrl,
|
||||
url: definition.url,
|
||||
method: definition.method,
|
||||
displayType: definition.displayType,
|
||||
authType: definition.authType,
|
||||
enabled: definition.enabled,
|
||||
}));
|
||||
return definitions.map((definition) => {
|
||||
const widget = parseStoredCustomWidgetDefinition(definition);
|
||||
return {
|
||||
id: definition.id,
|
||||
name: widget.name,
|
||||
description: widget.description,
|
||||
iconUrl: widget.iconUrl,
|
||||
sources: widget.sources.map(({ id, name, baseUrl, networkScope, auth }) => ({
|
||||
id,
|
||||
name,
|
||||
origin: new URL(baseUrl).origin,
|
||||
networkScope,
|
||||
authType: auth.type,
|
||||
})),
|
||||
requestCount: widget.requests.length,
|
||||
defaultOptions: widget.defaultOptions,
|
||||
updatedAt: definition.updatedAt,
|
||||
enabled: definition.enabled,
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
byId: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get one complete custom-widget definition for iterative editing. REQUIRED: id. Stored secret values are never returned; only their presence is reported.",
|
||||
},
|
||||
})
|
||||
get: manageProcedure
|
||||
.meta({ mcp: { enabled: true, description: "Get one Custom JSX widget without secret values." } })
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
@@ -54,14 +54,13 @@ export const managementQueryProcedures = {
|
||||
});
|
||||
if (!definition) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
|
||||
return {
|
||||
...definition,
|
||||
displayConfig: parseDisplayConfig(
|
||||
definition.displayConfig,
|
||||
input.id,
|
||||
logger,
|
||||
"Corrupt displayConfig in custom widget",
|
||||
),
|
||||
id: definition.id,
|
||||
...parseStoredCustomWidgetDefinition(definition),
|
||||
enabled: definition.enabled,
|
||||
createdAt: definition.createdAt,
|
||||
updatedAt: definition.updatedAt,
|
||||
secrets: definition.secrets.map((secret) => ({
|
||||
sourceId: secret.sourceId,
|
||||
kind: secret.kind,
|
||||
hasValue: true,
|
||||
updatedAt: secret.updatedAt,
|
||||
@@ -73,12 +72,114 @@ export const managementQueryProcedures = {
|
||||
.input(z.object({ boardId: z.string(), currentId: z.string().optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
|
||||
return ctx.db.query.customWidgetDefinitions.findMany({
|
||||
const definitions = await ctx.db.query.customWidgetDefinitions.findMany({
|
||||
where: input.currentId
|
||||
? or(eq(customWidgetDefinitions.enabled, true), eq(customWidgetDefinitions.id, input.currentId))
|
||||
: eq(customWidgetDefinitions.enabled, true),
|
||||
orderBy: (table, { asc }) => asc(table.name),
|
||||
columns: { id: true, name: true, description: true, iconUrl: true, displayType: true },
|
||||
});
|
||||
return definitions.map((definition) => {
|
||||
const widget = parseStoredCustomWidgetDefinition(definition);
|
||||
return {
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
iconUrl: definition.iconUrl,
|
||||
optionsSchema: widget.optionsSchema,
|
||||
defaultOptions: widget.defaultOptions,
|
||||
template: widget.template,
|
||||
stateSchema: widget.stateSchema ?? {},
|
||||
defaultState: widget.defaultState ?? {},
|
||||
sources: widget.sources.map(({ id, name, networkScope, auth }) => ({
|
||||
id,
|
||||
name,
|
||||
networkScope,
|
||||
authType: auth.type,
|
||||
})),
|
||||
requestCapabilities: widget.requests.map(
|
||||
({ id, kind, method, trigger, minimumBoardPermission, confirmation, invalidates }) => ({
|
||||
id,
|
||||
kind,
|
||||
method,
|
||||
trigger,
|
||||
minimumBoardPermission,
|
||||
confirmation,
|
||||
invalidates,
|
||||
}),
|
||||
),
|
||||
optionRequests: widget.requests
|
||||
.filter((request) => request.kind === "query")
|
||||
.map(({ id, parameters }) => ({ id, parameters })),
|
||||
updatedAt: definition.updatedAt,
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
optionRequest: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
boardId: z.string(),
|
||||
definitionId: z.string(),
|
||||
requestId: z.string(),
|
||||
params: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).default({}),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
|
||||
const stored = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.definitionId),
|
||||
with: { secrets: true },
|
||||
});
|
||||
if (!stored || !stored.enabled) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget not found" });
|
||||
const definition = parseStoredCustomWidgetDefinition(stored);
|
||||
const request = definition.requests.find(
|
||||
(candidate) => candidate.id === input.requestId && candidate.kind === "query",
|
||||
);
|
||||
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Option query not found" });
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), request.minimumBoardPermission);
|
||||
const source = definition.sources.find((candidate) => candidate.id === request.sourceId);
|
||||
if (!source) throw new TRPCError({ code: "NOT_FOUND", message: "Option query source not found" });
|
||||
const release = await acquireCustomWidgetRequestLimit({
|
||||
category: "query",
|
||||
userId: ctx.session.user.id,
|
||||
itemId: `configuration:${input.boardId}`,
|
||||
definitionId: input.definitionId,
|
||||
});
|
||||
try {
|
||||
const response = await executeCustomWidgetRequest({
|
||||
baseUrl: source.baseUrl,
|
||||
targetUrl: renderRequestTarget(source.baseUrl, request, input.params),
|
||||
method: request.method,
|
||||
body: renderRequestBody(request.bodyTemplate, input.params),
|
||||
staticHeaders: request.staticHeaders,
|
||||
auth:
|
||||
request.auth === "none" || source.auth.type === "none"
|
||||
? undefined
|
||||
: {
|
||||
type: source.auth.type,
|
||||
secrets: stored.secrets
|
||||
.filter((secret) => secret.sourceId === source.id)
|
||||
.map((secret) => ({ kind: secret.kind, value: decryptSecret(secret.encryptedValue) })),
|
||||
headerName:
|
||||
source.auth.type === "apiKeyHeader"
|
||||
? source.auth.headerName
|
||||
: source.auth.type === "apiKeyQuery"
|
||||
? source.auth.parameterName
|
||||
: undefined,
|
||||
},
|
||||
networkScope: source.networkScope,
|
||||
kind: "query",
|
||||
cacheKey: `custom-widget:options:${input.definitionId}:${getCustomWidgetCacheVersion(stored)}:${request.id}:${hashRuntimeParams(input.params)}`,
|
||||
cacheTtlSeconds: request.cacheTtlSeconds ?? 30,
|
||||
});
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
data: response.data,
|
||||
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
|
||||
};
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}),
|
||||
};
|
||||
@@ -1,41 +1,38 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
|
||||
import {
|
||||
CUSTOM_WIDGET_MCP_AUTHORING_PROMPT,
|
||||
customWidgetImportSchema,
|
||||
getCustomWidgetJsonSchema,
|
||||
getCustomWidgetSkill,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
|
||||
import { permissionRequiredProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
|
||||
let schemaCache: Record<string, unknown> | undefined;
|
||||
|
||||
function getImportJsonSchema() {
|
||||
schemaCache ??= {
|
||||
...z.toJSONSchema(customWidgetImportSchema),
|
||||
title: "Homarr Custom Widget",
|
||||
description:
|
||||
"Schema for importing/exporting custom widget definitions in Homarr. All jsonPath fields use JSONPath syntax. The displayConfig must match displayType. Secrets are configured separately after import.",
|
||||
};
|
||||
return schemaCache;
|
||||
}
|
||||
|
||||
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
|
||||
export const metadataProcedures = {
|
||||
schema: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get the JSON Schema for Homarr custom-widget imports. Use this before generating a widget and before calling validate, create, or update.",
|
||||
},
|
||||
})
|
||||
.query(() => getImportJsonSchema()),
|
||||
.meta({ mcp: { enabled: true, description: "Get the current Custom JSX widget JSON Schema." } })
|
||||
.query(() => getCustomWidgetJsonSchema()),
|
||||
|
||||
validate: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Validate a complete custom-widget JSON draft without saving or making network requests. Returns structured issue paths so an agent can correct and validate again.",
|
||||
},
|
||||
})
|
||||
getAuthoringPrompt: publicProcedure
|
||||
.meta({ mcp: { enabled: true, description: "Get the current Custom Widget authoring instructions." } })
|
||||
.query(() => ({
|
||||
version: 2,
|
||||
prompt: CUSTOM_WIDGET_MCP_AUTHORING_PROMPT,
|
||||
resources: [
|
||||
"homarr://custom-widgets/schema",
|
||||
"homarr://custom-widgets/components",
|
||||
"homarr://custom-widgets/skill",
|
||||
],
|
||||
})),
|
||||
|
||||
getSkill: publicProcedure
|
||||
.meta({ mcp: { enabled: true, description: "Get the portable Homarr Custom Widget skill." } })
|
||||
.query(() => getCustomWidgetSkill()),
|
||||
|
||||
validate: manageProcedure
|
||||
.meta({ mcp: { enabled: true, description: "Validate one Custom JSX widget without saving it." } })
|
||||
.input(z.object({ widget: z.unknown() }))
|
||||
.query(({ input }) => {
|
||||
const result = customWidgetImportSchema.safeParse(input.widget);
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { parse as parseSuperJson } from "superjson";
|
||||
|
||||
import type { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { displayConfigSchema } from "@homarr/custom-widgets/core";
|
||||
import type { DisplayConfig } from "@homarr/custom-widgets/core";
|
||||
|
||||
type Logger = ReturnType<typeof createLogger>;
|
||||
|
||||
export function parseDisplayConfig(raw: string, id: string, logger: Logger, context: string): DisplayConfig {
|
||||
try {
|
||||
return displayConfigSchema.parse(parseSuperJson(raw));
|
||||
} catch {
|
||||
logger.error(context, { id });
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Widget has corrupt display configuration" });
|
||||
}
|
||||
}
|
||||
@@ -2,50 +2,23 @@ import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
import { previewSessionRequestSchema, recordPreviewJournal } from "./preview-procedure-helpers";
|
||||
import { executeCustomWidgetRequest } from "./request-executor";
|
||||
import {
|
||||
getPreviewRequestSource,
|
||||
previewSessionRequestSchema,
|
||||
recordPreviewJournal,
|
||||
} from "./preview-procedure-helpers";
|
||||
import { executeCustomWidgetRequest, invalidateCustomWidgetResponseCache } from "./request-executor";
|
||||
import { renderRequestBody, renderRequestTarget } from "./request-manifest";
|
||||
import { acquireCustomWidgetRequestLimit } from "./request-limits";
|
||||
import { getPreviewSession, getPreviewSessionSecrets } from "./preview-sessions";
|
||||
import { getPreviewSession } from "./preview-sessions";
|
||||
|
||||
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
|
||||
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
|
||||
|
||||
export const previewActionProcedures = {
|
||||
simulatePreviewAction: adminProcedure
|
||||
previewAction: manageProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Validate and simulate one named custom-widget action without sending a network request. REQUIRED: sessionId from customWidget_preview, action requestId, and typed params. Confirms parameter substitution and records a simulated journal entry. Returns requiredPermission as the action's minimum board permission (view, modify, or full), not as an execution result. This MCP tool never enables or executes live actions.",
|
||||
},
|
||||
mcp: { enabled: true, description: "Simulate or run one named action in a custom widget preview session." },
|
||||
})
|
||||
.input(previewSessionRequestSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
|
||||
const request = session.requests.find(
|
||||
(candidate) => candidate.id === input.requestId && candidate.kind === "action",
|
||||
);
|
||||
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview action was not found" });
|
||||
renderRequestTarget(session.baseUrl, request, input.params);
|
||||
renderRequestBody(request.bodyTemplate, input.params);
|
||||
await recordPreviewJournal(session, {
|
||||
requestId: request.id,
|
||||
kind: "action",
|
||||
method: request.method,
|
||||
pathTemplate: request.pathTemplate,
|
||||
status: null,
|
||||
durationMs: 0,
|
||||
simulated: true,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
simulated: true as const,
|
||||
requestId: request.id,
|
||||
method: request.method,
|
||||
requiredPermission: request.minimumBoardPermission,
|
||||
};
|
||||
}),
|
||||
previewAction: adminProcedure
|
||||
.input(previewSessionRequestSchema.extend({ confirmed: z.boolean().optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
|
||||
@@ -53,7 +26,9 @@ export const previewActionProcedures = {
|
||||
(candidate) => candidate.id === input.requestId && candidate.kind === "action",
|
||||
);
|
||||
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview action was not found" });
|
||||
const targetUrl = renderRequestTarget(session.baseUrl, request, input.params);
|
||||
const resolved = getPreviewRequestSource(session, request.sourceId);
|
||||
if (!resolved) throw new TRPCError({ code: "NOT_FOUND", message: "Preview source was not found" });
|
||||
const targetUrl = renderRequestTarget(resolved.source.baseUrl, request, input.params);
|
||||
const body = renderRequestBody(request.bodyTemplate, input.params);
|
||||
if (!session.liveActions) {
|
||||
await recordPreviewJournal(session, {
|
||||
@@ -67,8 +42,8 @@ export const previewActionProcedures = {
|
||||
});
|
||||
return { ok: true, status: 0, statusText: "Simulated", data: null, simulated: true as const };
|
||||
}
|
||||
if (request.method === "DELETE" && input.confirmed !== true) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "DELETE actions require confirmation" });
|
||||
if ((request.confirmation || request.method === "DELETE") && input.confirmed !== true) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "This action requires confirmation" });
|
||||
}
|
||||
const release = await acquireCustomWidgetRequestLimit({
|
||||
category: request.method === "DELETE" ? "delete" : "action",
|
||||
@@ -79,22 +54,20 @@ export const previewActionProcedures = {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const response = await executeCustomWidgetRequest({
|
||||
baseUrl: session.baseUrl,
|
||||
baseUrl: resolved.source.baseUrl,
|
||||
targetUrl,
|
||||
method: request.method,
|
||||
body,
|
||||
staticHeaders: request.staticHeaders,
|
||||
auth:
|
||||
request.auth === "none"
|
||||
? undefined
|
||||
: {
|
||||
type: session.authType,
|
||||
secrets: getPreviewSessionSecrets(session),
|
||||
headerName: session.headerName,
|
||||
},
|
||||
networkScope: session.networkScope,
|
||||
auth: request.auth === "none" ? undefined : resolved.auth,
|
||||
networkScope: resolved.source.networkScope,
|
||||
kind: "action",
|
||||
});
|
||||
if (response.ok && request.invalidates?.length) {
|
||||
invalidateCustomWidgetResponseCache(
|
||||
request.invalidates.map((requestId) => `custom-jsx:preview:${session.id}:${requestId}:`),
|
||||
);
|
||||
}
|
||||
await recordPreviewJournal(session, {
|
||||
requestId: request.id,
|
||||
kind: "action",
|
||||
|
||||
@@ -2,158 +2,110 @@ import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { decryptSecret } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { customWidgetDefinitionSchema, customWidgetSecretsInputSchema } from "@homarr/custom-widgets/core";
|
||||
import { eq } from "@homarr/db";
|
||||
import { customWidgetDefinitions } from "@homarr/db/schema";
|
||||
import {
|
||||
customWidgetAuthTypes,
|
||||
customWidgetDisplayTypes,
|
||||
customWidgetMethods,
|
||||
customWidgetSecretKinds,
|
||||
customJsxDisplayConfigV2Schema,
|
||||
displayConfigSchema,
|
||||
extractActionButtonDisplay,
|
||||
extractDisplayDataWithFallback,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
import { recordPreviewJournal } from "./preview-procedure-helpers";
|
||||
import { executeCustomWidgetRequest } from "./request-executor";
|
||||
import { acquireCustomWidgetRequestLimit } from "./request-limits";
|
||||
import { createPreviewSession, getPreviewSession } from "./preview-sessions";
|
||||
|
||||
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
|
||||
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
|
||||
|
||||
const logger = createLogger({ module: "custom-widget" });
|
||||
const previewCreateInputSchema = z.object({
|
||||
definition: customWidgetDefinitionSchema,
|
||||
secrets: customWidgetSecretsInputSchema.default([]),
|
||||
definitionId: z.string().optional(),
|
||||
});
|
||||
|
||||
const previewCreateProcedure = manageProcedure
|
||||
.meta({ mcp: { enabled: true, description: "Create a short-lived preview session for one unsaved custom widget." } })
|
||||
.input(previewCreateInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.secrets.length > 0 && !ctx.session.user.permissions.includes("custom-widget-secret-write")) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Writing preview secrets requires dedicated permission" });
|
||||
}
|
||||
const secrets = [...input.secrets];
|
||||
if (input.definitionId) {
|
||||
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.definitionId),
|
||||
with: { secrets: true },
|
||||
});
|
||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
|
||||
for (const secret of existing.secrets) {
|
||||
if (!secrets.some((candidate) => candidate.sourceId === secret.sourceId && candidate.kind === secret.kind)) {
|
||||
secrets.push({ sourceId: secret.sourceId, kind: secret.kind, value: decryptSecret(secret.encryptedValue) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const invalid = secrets.find((secret) => {
|
||||
const source = input.definition.sources.find((candidate) => candidate.id === secret.sourceId);
|
||||
const kinds =
|
||||
source?.auth.type === "basic"
|
||||
? ["username", "password"]
|
||||
: source && ["bearer", "apiKeyHeader", "apiKeyQuery"].includes(source.auth.type)
|
||||
? ["apiKey"]
|
||||
: [];
|
||||
return !source || !kinds.includes(secret.kind);
|
||||
});
|
||||
if (invalid) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown secret source '${invalid.sourceId}'` });
|
||||
}
|
||||
|
||||
const previewSession = await createPreviewSession({
|
||||
userId: ctx.session.user.id,
|
||||
sources: input.definition.sources,
|
||||
requests: input.definition.requests,
|
||||
name: input.definition.name,
|
||||
template: input.definition.template,
|
||||
optionsSchema: input.definition.optionsSchema,
|
||||
defaultOptions: input.definition.defaultOptions,
|
||||
stateSchema: input.definition.stateSchema ?? {},
|
||||
defaultState: input.definition.defaultState ?? {},
|
||||
secrets,
|
||||
definitionId: input.definitionId,
|
||||
});
|
||||
return {
|
||||
success: true as const,
|
||||
previewSession,
|
||||
previewUrl: new URL(
|
||||
`/manage/custom-widgets/preview/${previewSession.id}`,
|
||||
ctx.baseUrl ?? "http://localhost",
|
||||
).toString(),
|
||||
definition: {
|
||||
template: input.definition.template,
|
||||
defaultOptions: input.definition.defaultOptions,
|
||||
defaultState: input.definition.defaultState ?? {},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const previewBaseProcedures = {
|
||||
preview: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Test a custom-widget draft through Homarr's hardened preview executor. Admin only. GET only; actions remain simulated. REQUIRED: url, method GET, authType, displayType, displayConfig. OPTIONAL: definitionId reuses that saved widget's stored credentials; omit secrets whenever possible. Returns sanitized response data, HTTP status, and a short-lived preview session for named query testing.",
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
url: z.string().url(),
|
||||
method: z.enum(customWidgetMethods),
|
||||
authType: z.enum(customWidgetAuthTypes),
|
||||
headerName: z.string().optional(),
|
||||
requestBody: z.string().optional(),
|
||||
displayType: z.enum(customWidgetDisplayTypes),
|
||||
displayConfig: displayConfigSchema,
|
||||
secrets: z.array(z.object({ kind: z.enum(customWidgetSecretKinds), value: z.string() })).default([]),
|
||||
definitionId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const secrets = [...input.secrets];
|
||||
|
||||
if (input.definitionId) {
|
||||
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.definitionId),
|
||||
with: { secrets: true },
|
||||
});
|
||||
if (existing) {
|
||||
for (const dbSecret of existing.secrets) {
|
||||
if (!secrets.some((s) => s.kind === dbSecret.kind)) {
|
||||
secrets.push({ kind: dbSecret.kind, value: decryptSecret(dbSecret.value) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const v2Config = customJsxDisplayConfigV2Schema.safeParse(input.displayConfig);
|
||||
const previewSession = v2Config.success
|
||||
? await createPreviewSession({
|
||||
userId: ctx.session.user.id,
|
||||
baseUrl: input.url,
|
||||
authType: input.authType,
|
||||
headerName: input.headerName,
|
||||
secrets,
|
||||
networkScope: v2Config.data.networkScope,
|
||||
requests: v2Config.data.requests,
|
||||
definitionId: input.definitionId,
|
||||
})
|
||||
: null;
|
||||
const storedPreviewSession = previewSession
|
||||
? await getPreviewSession(previewSession.id, ctx.session.user.id)
|
||||
: null;
|
||||
|
||||
if (input.method !== "GET") {
|
||||
return {
|
||||
success: true as const,
|
||||
simulated: true as const,
|
||||
responseInfo: null,
|
||||
rawResponse: null,
|
||||
displayData: input.displayType === "actionButton" ? extractActionButtonDisplay(input.displayConfig) : null,
|
||||
previewSession,
|
||||
};
|
||||
}
|
||||
|
||||
const release = await acquireCustomWidgetRequestLimit({
|
||||
category: "query",
|
||||
userId: ctx.session.user.id,
|
||||
itemId: `preview:${ctx.session.user.id}`,
|
||||
definitionId: input.definitionId ?? `preview:${ctx.session.user.id}`,
|
||||
});
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const response = await executeCustomWidgetRequest({
|
||||
baseUrl: input.url,
|
||||
method: input.method,
|
||||
body: undefined,
|
||||
auth: { type: input.authType, secrets, headerName: input.headerName },
|
||||
networkScope: v2Config.success ? v2Config.data.networkScope : "private",
|
||||
kind: "query",
|
||||
});
|
||||
if (storedPreviewSession) {
|
||||
await recordPreviewJournal(storedPreviewSession, {
|
||||
requestId: "base",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: new URL(input.url).pathname,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
simulated: false,
|
||||
});
|
||||
}
|
||||
const responseInfo = { status: response.status, statusText: response.statusText };
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false as const,
|
||||
error: `HTTP ${response.status}: ${response.statusText}`,
|
||||
responseInfo,
|
||||
rawResponse: typeof response.data === "string" ? response.data : JSON.stringify(response.data, null, 2),
|
||||
previewSession,
|
||||
};
|
||||
}
|
||||
|
||||
const displayData = extractDisplayDataWithFallback(response.data, input.displayType, input.displayConfig);
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
responseInfo,
|
||||
rawResponse: typeof response.data === "string" ? response.data : JSON.stringify(response.data, null, 2),
|
||||
displayData,
|
||||
previewSession,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
logger.error("Custom widget preview failed", {
|
||||
errorName: error instanceof Error ? error.name : "UnknownError",
|
||||
});
|
||||
return {
|
||||
success: false as const,
|
||||
error: error instanceof Error ? error.message : "Failed to fetch data",
|
||||
responseInfo: null,
|
||||
rawResponse: null,
|
||||
previewSession,
|
||||
};
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}),
|
||||
previewCreate: previewCreateProcedure,
|
||||
previewGet: manageProcedure.input(z.object({ sessionId: z.string() })).query(async ({ ctx, input }) => {
|
||||
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
|
||||
return {
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
expiresAt: session.expiresAt,
|
||||
template: session.template,
|
||||
optionsSchema: session.optionsSchema,
|
||||
defaultOptions: session.defaultOptions,
|
||||
stateSchema: session.stateSchema,
|
||||
defaultState: session.defaultState,
|
||||
requests: session.requests.map(
|
||||
({ id, kind, method, minimumBoardPermission, trigger, parameters, confirmation, invalidates }) => ({
|
||||
id,
|
||||
kind,
|
||||
method,
|
||||
minimumBoardPermission,
|
||||
trigger,
|
||||
parameters,
|
||||
confirmation,
|
||||
invalidates,
|
||||
}),
|
||||
),
|
||||
liveActions: session.liveActions,
|
||||
};
|
||||
}),
|
||||
};
|
||||
@@ -2,6 +2,8 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { appendPreviewJournal } from "./preview-sessions";
|
||||
import { getPreviewSessionSecrets } from "./preview-sessions";
|
||||
import type { CustomWidgetPreviewSession } from "./preview-sessions";
|
||||
|
||||
const logger = createLogger({ module: "custom-widget-preview" });
|
||||
|
||||
@@ -21,3 +23,22 @@ export const recordPreviewJournal = async (...args: Parameters<typeof appendPrev
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getPreviewRequestSource = (session: CustomWidgetPreviewSession, sourceId: string) => {
|
||||
const source = session.sources.find((candidate) => candidate.id === sourceId);
|
||||
if (!source) return null;
|
||||
const auth =
|
||||
source.auth.type === "none"
|
||||
? undefined
|
||||
: {
|
||||
type: source.auth.type,
|
||||
secrets: getPreviewSessionSecrets(session, source.id),
|
||||
headerName:
|
||||
source.auth.type === "apiKeyHeader"
|
||||
? source.auth.headerName
|
||||
: source.auth.type === "apiKeyQuery"
|
||||
? source.auth.parameterName
|
||||
: undefined,
|
||||
};
|
||||
return { source, auth };
|
||||
};
|
||||
@@ -2,26 +2,24 @@ import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
import { previewSessionRequestSchema, recordPreviewJournal } from "./preview-procedure-helpers";
|
||||
import { executeCustomWidgetRequest } from "./request-executor";
|
||||
import { hashRuntimeParams, renderRequestTarget } from "./request-manifest";
|
||||
import { acquireCustomWidgetRequestLimit } from "./request-limits";
|
||||
import {
|
||||
getPreviewJournal,
|
||||
getPreviewSession,
|
||||
getPreviewSessionSecrets,
|
||||
setPreviewSessionLiveActions,
|
||||
} from "./preview-sessions";
|
||||
getPreviewRequestSource,
|
||||
previewSessionRequestSchema,
|
||||
recordPreviewJournal,
|
||||
} from "./preview-procedure-helpers";
|
||||
import { executeCustomWidgetRequest } from "./request-executor";
|
||||
import { hashRuntimeParams, renderRequestBody, renderRequestTarget } from "./request-manifest";
|
||||
import { acquireCustomWidgetRequestLimit } from "./request-limits";
|
||||
import { getPreviewJournal, getPreviewSession, setPreviewSessionLiveActions } from "./preview-sessions";
|
||||
|
||||
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
|
||||
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
|
||||
|
||||
export const previewQueryProcedures = {
|
||||
previewQuery: adminProcedure
|
||||
previewQuery: manageProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Run one named GET query from a short-lived custom-widget preview session. REQUIRED: sessionId from customWidget_preview, requestId declared as a query, and typed params. Uses the hardened executor and returns sanitized response data for another validate/update iteration.",
|
||||
description: "Run one named query from a short-lived custom widget preview session.",
|
||||
},
|
||||
})
|
||||
.input(previewSessionRequestSchema)
|
||||
@@ -31,7 +29,10 @@ export const previewQueryProcedures = {
|
||||
(candidate) => candidate.id === input.requestId && candidate.kind === "query",
|
||||
);
|
||||
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview query was not found" });
|
||||
const targetUrl = renderRequestTarget(session.baseUrl, request, input.params);
|
||||
const resolved = getPreviewRequestSource(session, request.sourceId);
|
||||
if (!resolved) throw new TRPCError({ code: "NOT_FOUND", message: "Preview source was not found" });
|
||||
const targetUrl = renderRequestTarget(resolved.source.baseUrl, request, input.params);
|
||||
const body = renderRequestBody(request.bodyTemplate, input.params);
|
||||
const release = await acquireCustomWidgetRequestLimit({
|
||||
category: "query",
|
||||
userId: ctx.session.user.id,
|
||||
@@ -41,19 +42,13 @@ export const previewQueryProcedures = {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const response = await executeCustomWidgetRequest({
|
||||
baseUrl: session.baseUrl,
|
||||
baseUrl: resolved.source.baseUrl,
|
||||
targetUrl,
|
||||
method: "GET",
|
||||
method: request.method,
|
||||
body,
|
||||
staticHeaders: request.staticHeaders,
|
||||
auth:
|
||||
request.auth === "none"
|
||||
? undefined
|
||||
: {
|
||||
type: session.authType,
|
||||
secrets: getPreviewSessionSecrets(session),
|
||||
headerName: session.headerName,
|
||||
},
|
||||
networkScope: session.networkScope,
|
||||
auth: request.auth === "none" ? undefined : resolved.auth,
|
||||
networkScope: resolved.source.networkScope,
|
||||
kind: "query",
|
||||
cacheKey: `custom-jsx:preview:${session.id}:${request.id}:${hashRuntimeParams(input.params)}`,
|
||||
cacheTtlSeconds: request.cacheTtlSeconds,
|
||||
@@ -61,7 +56,7 @@ export const previewQueryProcedures = {
|
||||
await recordPreviewJournal(session, {
|
||||
requestId: request.id,
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
method: request.method,
|
||||
pathTemplate: request.pathTemplate,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
@@ -79,18 +74,17 @@ export const previewQueryProcedures = {
|
||||
}
|
||||
}),
|
||||
|
||||
setPreviewLiveActions: adminProcedure
|
||||
setPreviewLiveActions: manageProcedure
|
||||
.input(z.object({ sessionId: z.string().min(1), enabled: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
setPreviewSessionLiveActions(input.sessionId, ctx.session.user.id, input.enabled),
|
||||
),
|
||||
|
||||
previewJournal: adminProcedure
|
||||
previewJournal: manageProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Read the redacted request journal for a custom-widget preview session. REQUIRED: sessionId. Use it to inspect statuses and durations; credentials and parameter values are never returned.",
|
||||
description: "Read the redacted request journal for a custom widget preview session.",
|
||||
},
|
||||
})
|
||||
.input(z.object({ sessionId: z.string().min(1) }))
|
||||
|
||||
@@ -76,4 +76,7 @@ export const appendPreviewJournal = (
|
||||
entry: Omit<CustomWidgetPreviewJournalEntry, "id" | "timestamp">,
|
||||
) => call(() => getService().appendJournal(session, entry));
|
||||
export const getPreviewJournal = (id: string, userId: string) => call(() => getService().getJournal(id, userId));
|
||||
export const getPreviewSessionSecrets = (session: CustomWidgetPreviewSession) => getService().getSecrets(session);
|
||||
export const getPreviewSessionSecrets = (session: CustomWidgetPreviewSession, sourceId: string) =>
|
||||
getService().getSecrets(session, sourceId);
|
||||
export const setPreviewSessionSecrets = (id: string, userId: string, secrets: CreatePreviewSessionInput["secrets"]) =>
|
||||
call(() => getService().setSecrets(id, userId, secrets));
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
assertSafeStaticHeaders as assertDomainSafeStaticHeaders,
|
||||
classifyAddress,
|
||||
executeCustomWidgetRequest as executeDomainRequest,
|
||||
invalidateCustomWidgetResponseCache as invalidateDomainResponseCache,
|
||||
MAX_REQUEST_BODY_BYTES,
|
||||
MAX_RESPONSE_BODY_BYTES,
|
||||
MAX_RESPONSE_JSON_DEPTH,
|
||||
@@ -81,3 +82,6 @@ export async function executeCustomWidgetRequest(input: CustomWidgetHttpRequest)
|
||||
toTrpcError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export const invalidateCustomWidgetResponseCache = (prefixes: readonly string[]) =>
|
||||
invalidateDomainResponseCache(prefixes);
|
||||
@@ -0,0 +1,25 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
export function requiredSecretKinds(authType: string) {
|
||||
if (authType === "basic") return ["username", "password"] as const;
|
||||
if (authType === "bearer" || authType === "apiKeyHeader" || authType === "apiKeyQuery") {
|
||||
return ["apiKey"] as const;
|
||||
}
|
||||
return [] as const;
|
||||
}
|
||||
|
||||
export function assertSecretSources(
|
||||
sources: readonly { id: string; auth: { type: string } }[],
|
||||
secrets: readonly { sourceId: string; kind: "apiKey" | "username" | "password" }[],
|
||||
) {
|
||||
const invalid = secrets.find((secret) => {
|
||||
const source = sources.find((candidate) => candidate.id === secret.sourceId);
|
||||
return !source || !new Set<string>(requiredSecretKinds(source.auth.type)).has(secret.kind);
|
||||
});
|
||||
if (invalid) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Secret does not match source '${invalid.sourceId}' authentication`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { encryptSecret } from "@homarr/common/server";
|
||||
import { and, eq } from "@homarr/db";
|
||||
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
|
||||
import { customWidgetSecretInputSchema } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
import { createCustomWidgetSecretRequest, getCustomWidgetSecretRequestForUser } from "./secret-requests";
|
||||
import { assertSecretSources, requiredSecretKinds } from "./secret-policy";
|
||||
import { getPreviewSession } from "./preview-sessions";
|
||||
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
|
||||
|
||||
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
|
||||
const secretWriteProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-secret-write");
|
||||
|
||||
const secretRequestInputSchema = z
|
||||
.object({
|
||||
requestId: z.string().optional(),
|
||||
definitionId: z.string().optional(),
|
||||
previewSessionId: z.string().optional(),
|
||||
sourceId: z.string().optional(),
|
||||
})
|
||||
.superRefine((input, ctx) => {
|
||||
if (input.requestId) {
|
||||
if (input.definitionId || input.previewSessionId || input.sourceId) {
|
||||
ctx.addIssue({ code: "custom", message: "Status checks only accept requestId" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Boolean(input.definitionId) === Boolean(input.previewSessionId)) {
|
||||
ctx.addIssue({ code: "custom", message: "Provide exactly one definitionId or previewSessionId" });
|
||||
}
|
||||
if (!input.sourceId) ctx.addIssue({ code: "custom", message: "sourceId is required" });
|
||||
});
|
||||
|
||||
export const secretProcedures = {
|
||||
secretSet: secretWriteProcedure
|
||||
.meta({ mcp: { enabled: true, description: "Set one encrypted secret for a custom widget source." } })
|
||||
.input(z.object({ definitionId: z.string(), secret: customWidgetSecretInputSchema }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.definitionId),
|
||||
});
|
||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
const definition = parseStoredCustomWidgetDefinition(existing);
|
||||
assertSecretSources(definition.sources, [input.secret]);
|
||||
await ctx.db
|
||||
.delete(customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(customWidgetSecrets.definitionId, input.definitionId),
|
||||
eq(customWidgetSecrets.sourceId, input.secret.sourceId),
|
||||
eq(customWidgetSecrets.kind, input.secret.kind),
|
||||
),
|
||||
);
|
||||
await ctx.db.insert(customWidgetSecrets).values({
|
||||
definitionId: input.definitionId,
|
||||
sourceId: input.secret.sourceId,
|
||||
kind: input.secret.kind,
|
||||
encryptedValue: encryptSecret(input.secret.value),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
await ctx.db
|
||||
.update(customWidgetDefinitions)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(customWidgetDefinitions.id, input.definitionId));
|
||||
return { sourceId: input.secret.sourceId, kind: input.secret.kind, isSet: true };
|
||||
}),
|
||||
|
||||
secretRequestUser: manageProcedure
|
||||
.meta({ mcp: { enabled: true, description: "Create or check a short-lived user credential-entry request." } })
|
||||
.input(secretRequestInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.requestId) {
|
||||
const request = await getCustomWidgetSecretRequestForUser(input.requestId, ctx.session.user.id);
|
||||
if (!request) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
return { requestId: request.id, status: request.status, expiresAt: request.expiresAt };
|
||||
}
|
||||
|
||||
let widgetName: string;
|
||||
let source: { id: string; name: string; auth: { type: string } } | undefined;
|
||||
let target: { type: "definition"; id: string } | { type: "preview"; id: string };
|
||||
if (input.definitionId) {
|
||||
const stored = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.definitionId),
|
||||
});
|
||||
if (!stored) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
const definition = parseStoredCustomWidgetDefinition(stored);
|
||||
widgetName = definition.name;
|
||||
source = definition.sources.find((candidate) => candidate.id === input.sourceId);
|
||||
target = { type: "definition", id: input.definitionId };
|
||||
} else {
|
||||
const preview = await getPreviewSession(input.previewSessionId ?? "", ctx.session.user.id);
|
||||
widgetName = "Custom widget preview";
|
||||
source = preview.sources.find((candidate) => candidate.id === input.sourceId);
|
||||
target = { type: "preview", id: preview.id };
|
||||
}
|
||||
if (!source) throw new TRPCError({ code: "NOT_FOUND", message: "Widget source not found" });
|
||||
const kinds = [...requiredSecretKinds(source.auth.type)];
|
||||
if (kinds.length === 0) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "This source does not require a secret" });
|
||||
}
|
||||
const request = await createCustomWidgetSecretRequest({
|
||||
userId: ctx.session.user.id,
|
||||
target,
|
||||
widgetName,
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
kinds,
|
||||
});
|
||||
return {
|
||||
requestId: request.id,
|
||||
status: request.status,
|
||||
expiresAt: request.expiresAt,
|
||||
url: new URL(`/custom-widget-secret/${request.id}`, ctx.baseUrl ?? "http://localhost").toString(),
|
||||
};
|
||||
}),
|
||||
|
||||
secretClear: secretWriteProcedure
|
||||
.input(
|
||||
z.object({ definitionId: z.string(), sourceId: z.string(), kind: z.enum(["apiKey", "username", "password"]) }),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.definitionId),
|
||||
});
|
||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
await ctx.db
|
||||
.delete(customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(customWidgetSecrets.definitionId, input.definitionId),
|
||||
eq(customWidgetSecrets.sourceId, input.sourceId),
|
||||
eq(customWidgetSecrets.kind, input.kind),
|
||||
),
|
||||
);
|
||||
await ctx.db
|
||||
.update(customWidgetDefinitions)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(customWidgetDefinitions.id, input.definitionId));
|
||||
return { sourceId: input.sourceId, kind: input.kind, isSet: false };
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import { createId } from "@homarr/common";
|
||||
import { createRedisClient } from "@homarr/core/infrastructure/redis";
|
||||
import type { RedisClient } from "@homarr/core/infrastructure/redis";
|
||||
import type { CustomWidgetSecretKind } from "@homarr/custom-widgets/core";
|
||||
|
||||
const SECRET_REQUEST_TTL_MS = 10 * 60_000;
|
||||
const SECRET_REQUEST_PREFIX = "custom-widget:secret-request:";
|
||||
const SECRET_REQUEST_LOCK_PREFIX = "custom-widget:secret-request-lock:";
|
||||
|
||||
export interface CustomWidgetSecretRequest {
|
||||
id: string;
|
||||
userId: string;
|
||||
target: { type: "definition"; id: string } | { type: "preview"; id: string };
|
||||
widgetName: string;
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
kinds: CustomWidgetSecretKind[];
|
||||
expiresAt: number;
|
||||
status: "pending" | "completed";
|
||||
}
|
||||
|
||||
const localRequests = new Map<string, CustomWidgetSecretRequest>();
|
||||
const localLocks = new Set<string>();
|
||||
let redis: RedisClient | undefined;
|
||||
|
||||
function getRedis() {
|
||||
if (process.env.CI !== undefined || process.env.NODE_ENV === "test") return undefined;
|
||||
redis ??= createRedisClient();
|
||||
return redis;
|
||||
}
|
||||
|
||||
async function save(request: CustomWidgetSecretRequest) {
|
||||
const ttl = Math.max(1, request.expiresAt - Date.now());
|
||||
const client = getRedis();
|
||||
if (client) await client.set(`${SECRET_REQUEST_PREFIX}${request.id}`, JSON.stringify(request), "PX", ttl);
|
||||
else localRequests.set(request.id, request);
|
||||
}
|
||||
|
||||
export async function createCustomWidgetSecretRequest(
|
||||
input: Omit<CustomWidgetSecretRequest, "id" | "expiresAt" | "status">,
|
||||
) {
|
||||
const request: CustomWidgetSecretRequest = {
|
||||
...input,
|
||||
id: createId(),
|
||||
expiresAt: Date.now() + SECRET_REQUEST_TTL_MS,
|
||||
status: "pending",
|
||||
};
|
||||
await save(request);
|
||||
return request;
|
||||
}
|
||||
|
||||
export async function getCustomWidgetSecretRequest(id: string) {
|
||||
const client = getRedis();
|
||||
const candidate = client ? await client.get(`${SECRET_REQUEST_PREFIX}${id}`) : localRequests.get(id);
|
||||
if (!candidate) return null;
|
||||
try {
|
||||
const request = (typeof candidate === "string" ? JSON.parse(candidate) : candidate) as CustomWidgetSecretRequest;
|
||||
if (request.expiresAt <= Date.now()) {
|
||||
if (client) await client.del(`${SECRET_REQUEST_PREFIX}${id}`);
|
||||
else localRequests.delete(id);
|
||||
return null;
|
||||
}
|
||||
return request;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCustomWidgetSecretRequestForUser(id: string, userId: string) {
|
||||
const request = await getCustomWidgetSecretRequest(id);
|
||||
return request?.userId === userId ? request : null;
|
||||
}
|
||||
|
||||
export async function completeCustomWidgetSecretRequest(id: string) {
|
||||
const request = await getCustomWidgetSecretRequest(id);
|
||||
if (!request || request.status !== "pending") return null;
|
||||
const completed = { ...request, status: "completed" as const };
|
||||
await save(completed);
|
||||
await releaseCustomWidgetSecretRequest(id);
|
||||
return completed;
|
||||
}
|
||||
|
||||
export async function claimCustomWidgetSecretRequest(id: string) {
|
||||
const request = await getCustomWidgetSecretRequest(id);
|
||||
if (!request || request.status !== "pending") return null;
|
||||
const client = getRedis();
|
||||
if (client) {
|
||||
const claimed = await client.set(`${SECRET_REQUEST_LOCK_PREFIX}${id}`, "1", "PX", 60_000, "NX");
|
||||
return claimed === "OK" ? request : null;
|
||||
}
|
||||
if (localLocks.has(id)) return null;
|
||||
localLocks.add(id);
|
||||
return request;
|
||||
}
|
||||
|
||||
export async function releaseCustomWidgetSecretRequest(id: string) {
|
||||
const client = getRedis();
|
||||
if (client) await client.del(`${SECRET_REQUEST_LOCK_PREFIX}${id}`);
|
||||
else localLocks.delete(id);
|
||||
}
|
||||
|
||||
export { setPreviewSessionSecrets } from "./preview-sessions";
|
||||
@@ -0,0 +1,50 @@
|
||||
import { parse as parseSuperJson, stringify as stringifySuperJson } from "superjson";
|
||||
|
||||
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetDefinition } from "@homarr/db/schema";
|
||||
|
||||
export function serializeCustomWidgetDefinition(definition: HomarrCustomWidgetV2) {
|
||||
return {
|
||||
name: definition.name,
|
||||
description: definition.description ?? null,
|
||||
iconUrl: definition.iconUrl ?? null,
|
||||
sources: stringifySuperJson(definition.sources),
|
||||
requests: stringifySuperJson(definition.requests),
|
||||
optionsSchema: stringifySuperJson(definition.optionsSchema),
|
||||
defaultOptions: stringifySuperJson(definition.defaultOptions),
|
||||
stateSchema: definition.stateSchema ? stringifySuperJson(definition.stateSchema) : null,
|
||||
defaultState: definition.defaultState ? stringifySuperJson(definition.defaultState) : null,
|
||||
template: definition.template,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseStoredCustomWidgetDefinition(
|
||||
definition: Pick<
|
||||
CustomWidgetDefinition,
|
||||
| "name"
|
||||
| "description"
|
||||
| "iconUrl"
|
||||
| "sources"
|
||||
| "requests"
|
||||
| "optionsSchema"
|
||||
| "defaultOptions"
|
||||
| "stateSchema"
|
||||
| "defaultState"
|
||||
| "template"
|
||||
>,
|
||||
): HomarrCustomWidgetV2 {
|
||||
return customWidgetDefinitionSchema.parse({
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: definition.name,
|
||||
description: definition.description ?? undefined,
|
||||
iconUrl: definition.iconUrl ?? undefined,
|
||||
sources: parseSuperJson(definition.sources),
|
||||
requests: parseSuperJson(definition.requests),
|
||||
optionsSchema: parseSuperJson(definition.optionsSchema),
|
||||
defaultOptions: parseSuperJson(definition.defaultOptions),
|
||||
stateSchema: definition.stateSchema ? parseSuperJson(definition.stateSchema) : undefined,
|
||||
defaultState: definition.defaultState ? parseSuperJson(definition.defaultState) : undefined,
|
||||
template: definition.template,
|
||||
});
|
||||
}
|
||||
@@ -1,26 +1,26 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { stringify as stringifySuperJson } from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { displayConfigSchema } from "@homarr/custom-widgets/core";
|
||||
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
|
||||
import { eq } from "@homarr/db";
|
||||
import { customWidgetDefinitions } from "@homarr/db/schema";
|
||||
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
import { parseDisplayConfig } from "./parse-display-config";
|
||||
|
||||
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
|
||||
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
|
||||
|
||||
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
|
||||
const logger = createLogger({ module: "custom-widget" });
|
||||
|
||||
const getTemplateRevision = (template: string) => createHash("sha256").update(template).digest("hex").slice(0, 16);
|
||||
|
||||
const validateUpdatedTemplate = (displayConfig: Record<string, unknown>, template: string) => {
|
||||
displayConfig.template = template;
|
||||
const result = displayConfigSchema.safeParse(displayConfig);
|
||||
const validateTemplate = (definition: ReturnType<typeof parseStoredCustomWidgetDefinition>, template: string) => {
|
||||
if (template.trim().length === 0) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Template must not be empty" });
|
||||
}
|
||||
const result = customWidgetDefinitionSchema.safeParse({ ...definition, template });
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
@@ -29,67 +29,29 @@ const validateUpdatedTemplate = (displayConfig: Record<string, unknown>, templat
|
||||
.join("; "),
|
||||
});
|
||||
}
|
||||
return result.data;
|
||||
return result.data.template;
|
||||
};
|
||||
|
||||
export const templateProcedures = {
|
||||
readTemplate: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Read the JSX template of a custom widget definition as plain text. Returns the template string separately from the full widget config, making it easier to inspect and edit.",
|
||||
},
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
readTemplate: manageProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
if (!definition) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
|
||||
const template = parseStoredCustomWidgetDefinition(definition).template;
|
||||
return {
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
template,
|
||||
templateLines: template.split("\n"),
|
||||
revision: getTemplateRevision(template),
|
||||
};
|
||||
}),
|
||||
|
||||
if (!definition) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
|
||||
}
|
||||
|
||||
const config = parseDisplayConfig(
|
||||
definition.displayConfig,
|
||||
input.id,
|
||||
logger,
|
||||
"Corrupt displayConfig in custom widget readTemplate",
|
||||
);
|
||||
|
||||
if (config.type !== "customJsx") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Widget is not using customJsx display type",
|
||||
});
|
||||
}
|
||||
|
||||
const template = (config.template as string | undefined) ?? "";
|
||||
return {
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
template,
|
||||
templateLines: template.split("\n"),
|
||||
revision: getTemplateRevision(template),
|
||||
};
|
||||
}),
|
||||
|
||||
writeTemplate: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Update only the JSX template of a custom widget definition. Accepts either a single template string or templateLines (array of strings joined with newlines). Validates the template AST before saving. This avoids needing to send the full widget JSON for template-only edits.",
|
||||
},
|
||||
})
|
||||
writeTemplate: manageProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
id: z.string(),
|
||||
template: z.string().optional(),
|
||||
templateLines: z.array(z.string()).optional(),
|
||||
})
|
||||
.object({ id: z.string(), template: z.string().optional(), templateLines: z.array(z.string()).optional() })
|
||||
.refine((data) => data.template !== undefined || data.templateLines !== undefined, {
|
||||
message: "Provide either template or templateLines",
|
||||
})
|
||||
@@ -98,62 +60,22 @@ export const templateProcedures = {
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const resolvedTemplate =
|
||||
input.templateLines !== undefined ? input.templateLines.join("\n") : (input.template ?? "");
|
||||
|
||||
const resolved = input.templateLines?.join("\n") ?? input.template ?? "";
|
||||
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
|
||||
}
|
||||
|
||||
const displayConfig = parseDisplayConfig(
|
||||
existing.displayConfig,
|
||||
input.id,
|
||||
logger,
|
||||
"Corrupt displayConfig in custom widget writeTemplate",
|
||||
);
|
||||
|
||||
if (displayConfig.type !== "customJsx") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Widget is not using customJsx display type",
|
||||
});
|
||||
}
|
||||
|
||||
if (resolvedTemplate.trim().length === 0) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Template must not be empty" });
|
||||
}
|
||||
if (resolvedTemplate.length > 50_000) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Template exceeds the 50,000 character limit" });
|
||||
}
|
||||
|
||||
const validatedConfig = validateUpdatedTemplate(displayConfig, resolvedTemplate);
|
||||
|
||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
|
||||
const template = validateTemplate(parseStoredCustomWidgetDefinition(existing), resolved);
|
||||
await ctx.db
|
||||
.update(customWidgetDefinitions)
|
||||
.set({ displayConfig: stringifySuperJson(validatedConfig), updatedAt: new Date() })
|
||||
.set({ template, updatedAt: new Date() })
|
||||
.where(eq(customWidgetDefinitions.id, input.id));
|
||||
|
||||
logger.info("Updated custom widget template", { id: input.id });
|
||||
return {
|
||||
id: input.id,
|
||||
template: resolvedTemplate,
|
||||
templateLines: resolvedTemplate.split("\n"),
|
||||
revision: getTemplateRevision(resolvedTemplate),
|
||||
};
|
||||
return { id: input.id, template, templateLines: template.split("\n"), revision: getTemplateRevision(template) };
|
||||
}),
|
||||
|
||||
patchTemplate: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Patch selected lines of a Custom JSX template without rewriting the whole template. First call customWidget_readTemplate and pass its revision as expectedRevision. Each edit uses a 1-based startLine, deleteCount, and replacementLines. Edits are applied atomically, then the complete template and named-request references are validated before saving. A stale revision is rejected so the agent can re-read and retry safely.",
|
||||
},
|
||||
})
|
||||
templatePatch: manageProcedure
|
||||
.meta({ mcp: { enabled: true, description: "Patch selected JSX template lines using an optimistic revision." } })
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
@@ -175,26 +97,15 @@ export const templateProcedures = {
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
|
||||
|
||||
const displayConfig = parseDisplayConfig(
|
||||
existing.displayConfig,
|
||||
input.id,
|
||||
logger,
|
||||
"Corrupt displayConfig in custom widget patchTemplate",
|
||||
);
|
||||
if (displayConfig.type !== "customJsx") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Widget is not using customJsx display type" });
|
||||
}
|
||||
|
||||
const currentTemplate = (displayConfig.template as string | undefined) ?? "";
|
||||
if (getTemplateRevision(currentTemplate) !== input.expectedRevision) {
|
||||
const definition = parseStoredCustomWidgetDefinition(existing);
|
||||
if (getTemplateRevision(definition.template) !== input.expectedRevision) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "Template changed since it was read. Read the template again and retry the patch.",
|
||||
message: "Template changed since it was read. Read it again and retry.",
|
||||
});
|
||||
}
|
||||
|
||||
const lines = currentTemplate.split("\n");
|
||||
const lines = definition.template.split("\n");
|
||||
const edits = input.edits.toSorted((left, right) => right.startLine - left.startLine);
|
||||
let nextHigherStart = lines.length + 2;
|
||||
for (const edit of edits) {
|
||||
@@ -208,19 +119,12 @@ export const templateProcedures = {
|
||||
lines.splice(edit.startLine - 1, edit.deleteCount, ...edit.replacementLines);
|
||||
}
|
||||
|
||||
const template = lines.join("\n");
|
||||
const validatedConfig = validateUpdatedTemplate(displayConfig, template);
|
||||
const template = validateTemplate(definition, lines.join("\n"));
|
||||
await ctx.db
|
||||
.update(customWidgetDefinitions)
|
||||
.set({ displayConfig: stringifySuperJson(validatedConfig), updatedAt: new Date() })
|
||||
.set({ template, updatedAt: new Date() })
|
||||
.where(eq(customWidgetDefinitions.id, input.id));
|
||||
|
||||
logger.info("Patched custom widget template", { id: input.id, editCount: input.edits.length });
|
||||
return {
|
||||
id: input.id,
|
||||
template,
|
||||
templateLines: lines,
|
||||
revision: getTemplateRevision(template),
|
||||
};
|
||||
return { id: input.id, template, templateLines: lines, revision: getTemplateRevision(template) };
|
||||
}),
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { stringify as stringifySuperJson } from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
@@ -9,72 +8,28 @@ import { customWidgetDefinitions } from "@homarr/db/schema";
|
||||
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
import { parseDisplayConfig } from "./parse-display-config";
|
||||
import { parseStoredCustomWidgetDefinition, serializeCustomWidgetDefinition } from "./stored-definition";
|
||||
|
||||
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
|
||||
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
|
||||
const logger = createLogger({ module: "custom-widget" });
|
||||
|
||||
export const transferProcedures = {
|
||||
export: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Export one custom widget as a homarr-custom-widget-v3 object. REQUIRED: id. Secrets are excluded.",
|
||||
},
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
if (!definition) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
return {
|
||||
$schema: "homarr-custom-widget-v3" as const,
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
iconUrl: definition.iconUrl,
|
||||
url: definition.url,
|
||||
authType: definition.authType,
|
||||
headerName: definition.headerName,
|
||||
method: definition.method,
|
||||
requestBody: definition.requestBody,
|
||||
displayType: definition.displayType,
|
||||
displayConfig: parseDisplayConfig(
|
||||
definition.displayConfig,
|
||||
input.id,
|
||||
logger,
|
||||
"Corrupt displayConfig during export",
|
||||
),
|
||||
};
|
||||
}),
|
||||
export: manageProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
if (!definition) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
return parseStoredCustomWidgetDefinition(definition);
|
||||
}),
|
||||
|
||||
import: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Create a custom widget from a validated homarr-custom-widget-v3 import object. Secrets are omitted and configured separately.",
|
||||
},
|
||||
})
|
||||
.input(customWidgetImportSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const id = createId();
|
||||
await ctx.db.insert(customWidgetDefinitions).values({
|
||||
id,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
iconUrl: input.iconUrl,
|
||||
url: input.url,
|
||||
authType: input.authType,
|
||||
headerName: input.headerName,
|
||||
method: input.method,
|
||||
requestBody: input.requestBody,
|
||||
displayType: input.displayType,
|
||||
displayConfig: stringifySuperJson(input.displayConfig),
|
||||
creatorId: ctx.session.user.id,
|
||||
});
|
||||
logger.info("Imported custom widget definition", { id, name: input.name });
|
||||
return { id };
|
||||
}),
|
||||
import: manageProcedure.input(customWidgetImportSchema).mutation(async ({ ctx, input }) => {
|
||||
const id = createId();
|
||||
await ctx.db.insert(customWidgetDefinitions).values({
|
||||
id,
|
||||
...serializeCustomWidgetDefinition(input),
|
||||
creatorId: ctx.session.user.id,
|
||||
});
|
||||
logger.info("Imported custom widget definition", { id, name: input.name });
|
||||
return { id };
|
||||
}),
|
||||
};
|
||||
@@ -1,67 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
appendPreviewJournal,
|
||||
createPreviewSession,
|
||||
getPreviewJournal,
|
||||
getPreviewSession,
|
||||
getPreviewSessionSecrets,
|
||||
setPreviewSessionLiveActions,
|
||||
} from "../../custom-widget/preview-sessions";
|
||||
|
||||
const queryRequest = {
|
||||
id: "status",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: "/status/{id}",
|
||||
parameters: { id: "string" },
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "view",
|
||||
} as const;
|
||||
|
||||
describe("custom widget preview sessions", () => {
|
||||
test("stores encrypted credentials in a short-lived user-bound manifest", async () => {
|
||||
const created = await createPreviewSession({
|
||||
userId: "admin-1",
|
||||
baseUrl: "https://example.com/api",
|
||||
authType: "bearer",
|
||||
secrets: [{ kind: "apiKey", value: "plain-secret" }],
|
||||
networkScope: "public",
|
||||
requests: [queryRequest],
|
||||
});
|
||||
|
||||
const session = await getPreviewSession(created.id, "admin-1");
|
||||
|
||||
expect(session.secrets[0]?.value).not.toContain("plain-secret");
|
||||
expect(getPreviewSessionSecrets(session)).toEqual([{ kind: "apiKey", value: "plain-secret" }]);
|
||||
await expect(getPreviewSession(created.id, "admin-2")).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
test("keeps actions simulated until explicitly enabled", async () => {
|
||||
const created = await createPreviewSession({
|
||||
userId: "admin-1",
|
||||
baseUrl: "https://example.com/api",
|
||||
authType: "none",
|
||||
secrets: [],
|
||||
networkScope: "public",
|
||||
requests: [queryRequest],
|
||||
});
|
||||
|
||||
expect((await getPreviewSession(created.id, "admin-1")).liveActions).toBe(false);
|
||||
await setPreviewSessionLiveActions(created.id, "admin-1", true);
|
||||
const session = await getPreviewSession(created.id, "admin-1");
|
||||
expect(session.liveActions).toBe(true);
|
||||
await appendPreviewJournal(session, {
|
||||
requestId: "status",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: "/status/{id}",
|
||||
status: 200,
|
||||
durationMs: 12,
|
||||
simulated: false,
|
||||
});
|
||||
expect(await getPreviewJournal(created.id, "admin-1")).toEqual([
|
||||
expect.objectContaining({ requestId: "status", pathTemplate: "/status/{id}", durationMs: 12 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
claimCustomWidgetSecretRequest,
|
||||
completeCustomWidgetSecretRequest,
|
||||
createCustomWidgetSecretRequest,
|
||||
getCustomWidgetSecretRequestForUser,
|
||||
releaseCustomWidgetSecretRequest,
|
||||
} from "../../custom-widget/secret-requests";
|
||||
|
||||
describe("custom widget credential requests", () => {
|
||||
it("is user-scoped, one-time, and reports completion without a secret", async () => {
|
||||
const request = await createCustomWidgetSecretRequest({
|
||||
userId: "user-1",
|
||||
target: { type: "preview", id: "preview-1" },
|
||||
widgetName: "Printer",
|
||||
sourceId: "default",
|
||||
sourceName: "Printer API",
|
||||
kinds: ["apiKey"],
|
||||
});
|
||||
|
||||
expect(await getCustomWidgetSecretRequestForUser(request.id, "user-2")).toBeNull();
|
||||
expect(await claimCustomWidgetSecretRequest(request.id)).toMatchObject({ status: "pending" });
|
||||
expect(await claimCustomWidgetSecretRequest(request.id)).toBeNull();
|
||||
await releaseCustomWidgetSecretRequest(request.id);
|
||||
expect(await claimCustomWidgetSecretRequest(request.id)).not.toBeNull();
|
||||
await completeCustomWidgetSecretRequest(request.id);
|
||||
expect(await getCustomWidgetSecretRequestForUser(request.id, "user-1")).toMatchObject({ status: "completed" });
|
||||
expect(await claimCustomWidgetSecretRequest(request.id)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,224 +0,0 @@
|
||||
import { stringify as stringifySuperJson } from "superjson";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { eq } from "@homarr/db";
|
||||
import { boardUserPermissions, boards, customWidgetDefinitions, items, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
executeRequest: vi.fn(async () => ({ ok: true, status: 200, statusText: "OK", data: { value: 42 } })),
|
||||
}));
|
||||
|
||||
vi.mock("../../custom-widget/request-executor", () => ({
|
||||
executeCustomWidgetRequest: mocks.executeRequest,
|
||||
resolveSameOriginTarget: (_baseUrl: string, targetUrl: string | URL) => new URL(targetUrl),
|
||||
}));
|
||||
|
||||
vi.mock("../../custom-widget/request-limits", () => ({
|
||||
acquireCustomWidgetRequestLimit: vi.fn(async () => async () => undefined),
|
||||
}));
|
||||
|
||||
import { customApiRouter } from "../../widgets/custom-api";
|
||||
|
||||
const createSession = (userId: string): Session => ({
|
||||
user: { id: userId, permissions: [], colorScheme: "light" },
|
||||
expires: new Date(Date.now() + 60_000).toISOString(),
|
||||
});
|
||||
|
||||
const queryRequest = {
|
||||
id: "status",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: "/status/{name}",
|
||||
parameters: { name: "string" },
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "view",
|
||||
} as const;
|
||||
|
||||
const actionRequest = {
|
||||
id: "toggle",
|
||||
kind: "action",
|
||||
method: "POST",
|
||||
pathTemplate: "/toggle",
|
||||
parameters: { enabled: "boolean" },
|
||||
bodyTemplate: { enabled: { $param: "enabled" } },
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "modify",
|
||||
} as const;
|
||||
|
||||
const deleteRequest = {
|
||||
id: "delete",
|
||||
kind: "action",
|
||||
method: "DELETE",
|
||||
pathTemplate: "/resource",
|
||||
parameters: {},
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "full",
|
||||
} as const;
|
||||
|
||||
const setup = async ({
|
||||
isPublic = true,
|
||||
method = "GET",
|
||||
requests = [queryRequest, actionRequest, deleteRequest],
|
||||
}: {
|
||||
isPublic?: boolean;
|
||||
method?: "GET" | "POST";
|
||||
requests?: readonly Record<string, unknown>[];
|
||||
} = {}) => {
|
||||
const db = createDb();
|
||||
const ownerId = createId();
|
||||
const boardId = createId();
|
||||
const itemId = createId();
|
||||
const definitionId = createId();
|
||||
await db.insert(users).values({ id: ownerId });
|
||||
await db.insert(boards).values({ id: boardId, name: createId(), creatorId: ownerId, isPublic });
|
||||
await db.insert(customWidgetDefinitions).values({
|
||||
id: definitionId,
|
||||
name: "Custom JSX",
|
||||
url: "https://example.com/api",
|
||||
method,
|
||||
authType: "none",
|
||||
displayType: "customJsx",
|
||||
displayConfig: stringifySuperJson({
|
||||
type: "customJsx",
|
||||
jsxApiVersion: 2,
|
||||
template: "<Text>{data.value}</Text>",
|
||||
networkScope: "public",
|
||||
requests,
|
||||
}),
|
||||
creatorId: ownerId,
|
||||
});
|
||||
await db.insert(items).values({
|
||||
id: itemId,
|
||||
boardId,
|
||||
kind: "customApi",
|
||||
options: stringifySuperJson({ definitionId, refreshInterval: 30 }),
|
||||
});
|
||||
return { db, ownerId, boardId, itemId, definitionId };
|
||||
};
|
||||
|
||||
describe("customApiRouter v2", () => {
|
||||
beforeEach(() => mocks.executeRequest.mockClear());
|
||||
|
||||
test("does not expose the removed raw subFetch procedure", () => {
|
||||
expect(Object.hasOwn(customApiRouter._def.procedures, "subFetch")).toBe(false);
|
||||
});
|
||||
|
||||
test("allows anonymous queries only through a public board placement", async () => {
|
||||
const { db, itemId } = await setup();
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
const result = await caller.queryRequest({ itemId, requestId: "status", params: { name: "homarr" } });
|
||||
|
||||
expect(result).toMatchObject({ ok: true, status: 200, data: { value: 42 } });
|
||||
expect(mocks.executeRequest).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("does not expose a placed definition through an inaccessible board", async () => {
|
||||
const { db, itemId } = await setup({ isPublic: false });
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
await expect(caller.getData({ itemId })).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not execute non-GET base data requests automatically", async () => {
|
||||
const { db, itemId } = await setup({ method: "POST" });
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
await expect(caller.getData({ itemId })).resolves.toEqual({ type: "networkAccessNeedsReview" });
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("cannot select a request from a definition that is not attached to the item", async () => {
|
||||
const { db, itemId, ownerId } = await setup({ requests: [] });
|
||||
const otherDefinitionId = createId();
|
||||
await db.insert(customWidgetDefinitions).values({
|
||||
id: otherDefinitionId,
|
||||
name: "Other",
|
||||
url: "https://example.com/api",
|
||||
method: "GET",
|
||||
authType: "none",
|
||||
displayType: "customJsx",
|
||||
displayConfig: stringifySuperJson({
|
||||
type: "customJsx",
|
||||
jsxApiVersion: 2,
|
||||
template: "<Text />",
|
||||
networkScope: "public",
|
||||
requests: [queryRequest],
|
||||
}),
|
||||
creatorId: ownerId,
|
||||
});
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
await expect(
|
||||
caller.queryRequest({ itemId, requestId: "status", params: { name: "homarr" } }),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects mismatched request kinds and typed parameters", async () => {
|
||||
const { db, itemId } = await setup();
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
await expect(caller.queryRequest({ itemId, requestId: "toggle", params: { enabled: true } })).rejects.toMatchObject(
|
||||
{ code: "NOT_FOUND" },
|
||||
);
|
||||
await expect(caller.queryRequest({ itemId, requestId: "status", params: { name: 42 } })).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects disabled definitions before any network request", async () => {
|
||||
const { db, itemId, definitionId } = await setup();
|
||||
await db
|
||||
.update(customWidgetDefinitions)
|
||||
.set({ enabled: false })
|
||||
.where(eq(customWidgetDefinitions.id, definitionId));
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
await expect(caller.getData({ itemId })).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("requires modify permission for named actions", async () => {
|
||||
const { db, itemId } = await setup();
|
||||
const viewerId = createId();
|
||||
await db.insert(users).values({ id: viewerId });
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(viewerId) });
|
||||
|
||||
await expect(
|
||||
caller.executeAction({ itemId, requestId: "toggle", params: { enabled: true } }),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("executes an authorized named action once with typed body substitution", async () => {
|
||||
const { db, itemId, boardId } = await setup({ isPublic: false });
|
||||
const editorId = createId();
|
||||
await db.insert(users).values({ id: editorId });
|
||||
await db.insert(boardUserPermissions).values({ boardId, userId: editorId, permission: "modify" });
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(editorId) });
|
||||
|
||||
const result = await caller.executeAction({ itemId, requestId: "toggle", params: { enabled: true } });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(mocks.executeRequest).toHaveBeenCalledOnce();
|
||||
expect(mocks.executeRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: "POST", body: '{"enabled":true}' }),
|
||||
);
|
||||
});
|
||||
|
||||
test("requires explicit confirmation before DELETE", async () => {
|
||||
const { db, itemId, ownerId } = await setup({ isPublic: false });
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(ownerId) });
|
||||
|
||||
await expect(caller.executeAction({ itemId, requestId: "delete", params: {} })).rejects.toMatchObject({
|
||||
code: "PRECONDITION_FAILED",
|
||||
});
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -3,25 +3,19 @@ import { parse as parseSuperJson } from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { decryptSecret } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { eq } from "@homarr/db";
|
||||
import { boards, customWidgetDefinitions, items } from "@homarr/db/schema";
|
||||
import type { BoardPermission } from "@homarr/definitions";
|
||||
import {
|
||||
customJsxDisplayConfigV2Schema,
|
||||
displayConfigSchema,
|
||||
extractActionButtonDisplay,
|
||||
extractDisplayDataWithFallback,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import type { CustomJsxRequest, CustomWidgetMethod, DisplayConfig } from "@homarr/custom-widgets/core";
|
||||
import { validateCustomWidgetOptions } from "@homarr/custom-widgets/core";
|
||||
import type { CustomJsxRequest, CustomWidgetSource } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "../board/board-access";
|
||||
import { executeCustomWidgetRequest } from "../custom-widget/request-executor";
|
||||
import { executeCustomWidgetRequest, invalidateCustomWidgetResponseCache } from "../custom-widget/request-executor";
|
||||
import { hashRuntimeParams, renderRequestBody, renderRequestTarget } from "../custom-widget/request-manifest";
|
||||
import { acquireCustomWidgetRequestLimit } from "../custom-widget/request-limits";
|
||||
|
||||
const logger = createLogger({ module: "widget:customApi" });
|
||||
import { getCustomWidgetCacheVersion } from "../custom-widget/cache-version";
|
||||
import { parseStoredCustomWidgetDefinition } from "../custom-widget/stored-definition";
|
||||
|
||||
const runtimeParamsSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()]));
|
||||
const itemInputSchema = z.object({ itemId: z.string().min(1) });
|
||||
@@ -30,31 +24,39 @@ const namedRequestInputSchema = itemInputSchema.extend({
|
||||
params: runtimeParamsSchema.default({}),
|
||||
});
|
||||
|
||||
interface CustomWidgetItemOptions {
|
||||
definitionId: string;
|
||||
configuration: Record<string, unknown>;
|
||||
configurationVersion: number;
|
||||
refreshInterval?: number;
|
||||
}
|
||||
|
||||
type RouterContext = Parameters<typeof throwIfActionForbiddenAsync>[0];
|
||||
type ResolvedDefinition = Awaited<ReturnType<typeof resolvePlacedDefinitionAsync>>;
|
||||
|
||||
const parseItemOptions = (raw: string): { definitionId: string } => {
|
||||
const parseItemOptions = (raw: string): CustomWidgetItemOptions => {
|
||||
try {
|
||||
const options = parseSuperJson(raw) as Record<string, unknown>;
|
||||
if (typeof options.definitionId !== "string" || options.definitionId.length === 0) throw new Error();
|
||||
return { definitionId: options.definitionId };
|
||||
return {
|
||||
definitionId: options.definitionId,
|
||||
configuration:
|
||||
options.configuration !== null &&
|
||||
typeof options.configuration === "object" &&
|
||||
!Array.isArray(options.configuration)
|
||||
? (options.configuration as Record<string, unknown>)
|
||||
: {},
|
||||
configurationVersion:
|
||||
typeof options.configurationVersion === "number" && Number.isInteger(options.configurationVersion)
|
||||
? options.configurationVersion
|
||||
: 1,
|
||||
refreshInterval: typeof options.refreshInterval === "number" ? options.refreshInterval : undefined,
|
||||
};
|
||||
} catch {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget item not found" });
|
||||
}
|
||||
};
|
||||
|
||||
const parseDisplayConfig = (raw: string): DisplayConfig => {
|
||||
try {
|
||||
return displayConfigSchema.parse(parseSuperJson(raw));
|
||||
} catch (error) {
|
||||
logger.error("Invalid custom widget display configuration", { error });
|
||||
throw new TRPCError({
|
||||
code: "PRECONDITION_FAILED",
|
||||
message: "Custom widget network access needs review",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
async function resolvePlacedDefinitionAsync(ctx: RouterContext, itemId: string) {
|
||||
const item = await ctx.db.query.items.findFirst({
|
||||
where: eq(items.id, itemId),
|
||||
@@ -65,83 +67,68 @@ async function resolvePlacedDefinitionAsync(ctx: RouterContext, itemId: string)
|
||||
}
|
||||
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "view");
|
||||
const { definitionId } = parseItemOptions(item.options);
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, definitionId),
|
||||
const itemOptions = parseItemOptions(item.options);
|
||||
const stored = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, itemOptions.definitionId),
|
||||
with: { secrets: true },
|
||||
});
|
||||
if (!definition) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
|
||||
}
|
||||
if (!definition.enabled) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Widget is disabled" });
|
||||
}
|
||||
if (!stored) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget unavailable" });
|
||||
if (!stored.enabled) throw new TRPCError({ code: "FORBIDDEN", message: "Widget is disabled" });
|
||||
|
||||
return { item, definition, displayConfig: parseDisplayConfig(definition.displayConfig) };
|
||||
}
|
||||
|
||||
const getDecryptedSecrets = (resolved: ResolvedDefinition) =>
|
||||
resolved.definition.secrets.map((secret) => ({ kind: secret.kind, value: decryptSecret(secret.value) }));
|
||||
|
||||
const getAuth = (resolved: ResolvedDefinition, mode: "inherit" | "none" = "inherit") =>
|
||||
mode === "none"
|
||||
? undefined
|
||||
: {
|
||||
type: resolved.definition.authType,
|
||||
secrets: getDecryptedSecrets(resolved),
|
||||
headerName: resolved.definition.headerName,
|
||||
};
|
||||
|
||||
const getNetworkScope = (displayConfig: DisplayConfig) =>
|
||||
displayConfig.type === "customJsx" && "jsxApiVersion" in displayConfig && displayConfig.jsxApiVersion === 2
|
||||
? displayConfig.networkScope
|
||||
: ("private" as const);
|
||||
|
||||
const getV2Config = (resolved: ResolvedDefinition) => {
|
||||
const parsed = customJsxDisplayConfigV2Schema.safeParse(resolved.displayConfig);
|
||||
if (!parsed.success) {
|
||||
const definition = parseStoredCustomWidgetDefinition(stored);
|
||||
const configuration = { ...definition.defaultOptions, ...itemOptions.configuration };
|
||||
const issues = validateCustomWidgetOptions(definition.optionsSchema, configuration);
|
||||
if (issues.length > 0) {
|
||||
throw new TRPCError({
|
||||
code: "PRECONDITION_FAILED",
|
||||
message: "This template must be reviewed and migrated to named requests",
|
||||
message: `Custom widget configuration needs repair: ${issues[0]?.path} ${issues[0]?.message}`,
|
||||
});
|
||||
}
|
||||
return parsed.data;
|
||||
};
|
||||
return { item, stored, definition, itemOptions, configuration };
|
||||
}
|
||||
|
||||
const findNamedRequest = (resolved: ResolvedDefinition, requestId: string, kind: "query" | "action") => {
|
||||
const request = getV2Config(resolved).requests.find((candidate) => candidate.id === requestId);
|
||||
if (!request || request.kind !== kind) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Named request not found" });
|
||||
}
|
||||
const findRequest = (resolved: ResolvedDefinition, requestId: string, kind: "query" | "action") => {
|
||||
const request = resolved.definition.requests.find(
|
||||
(candidate) => candidate.id === requestId && candidate.kind === kind,
|
||||
);
|
||||
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Named request not found" });
|
||||
return request;
|
||||
};
|
||||
|
||||
const getCacheKey = (
|
||||
resolved: ResolvedDefinition,
|
||||
request: CustomJsxRequest,
|
||||
params: Record<string, string | number | boolean>,
|
||||
) => {
|
||||
const digest = hashRuntimeParams(params);
|
||||
return `custom-jsx:${resolved.item.id}:${request.id}:${digest}`;
|
||||
const findSource = (resolved: ResolvedDefinition, sourceId: string) => {
|
||||
const source = resolved.definition.sources.find((candidate) => candidate.id === sourceId);
|
||||
if (!source) throw new TRPCError({ code: "NOT_FOUND", message: "Widget API source not found" });
|
||||
return source;
|
||||
};
|
||||
|
||||
const assertUpstreamSuccess = (response: { ok: boolean; status: number; statusText: string }) => {
|
||||
if (!response.ok) {
|
||||
throw new TRPCError({ code: "BAD_GATEWAY", message: `API returned ${response.status}: ${response.statusText}` });
|
||||
}
|
||||
const getAuth = (resolved: ResolvedDefinition, source: CustomWidgetSource, mode: "inherit" | "none") => {
|
||||
if (mode === "none" || source.auth.type === "none") return undefined;
|
||||
const secrets = resolved.stored.secrets
|
||||
.filter((secret) => secret.sourceId === source.id)
|
||||
.map((secret) => ({ kind: secret.kind, value: decryptSecret(secret.encryptedValue) }));
|
||||
return {
|
||||
type: source.auth.type,
|
||||
secrets,
|
||||
headerName:
|
||||
source.auth.type === "apiKeyHeader"
|
||||
? source.auth.headerName
|
||||
: source.auth.type === "apiKeyQuery"
|
||||
? source.auth.parameterName
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const withRequestLimit = async <T>(
|
||||
ctx: RouterContext,
|
||||
resolved: ResolvedDefinition,
|
||||
category: "query" | "action" | "delete",
|
||||
request: CustomJsxRequest,
|
||||
callback: () => Promise<T>,
|
||||
) => {
|
||||
const release = await acquireCustomWidgetRequestLimit({
|
||||
category,
|
||||
category: request.kind === "query" ? "query" : request.method === "DELETE" ? "delete" : "action",
|
||||
userId: ctx.session?.user.id,
|
||||
itemId: resolved.item.id,
|
||||
definitionId: resolved.definition.id,
|
||||
definitionId: resolved.stored.id,
|
||||
});
|
||||
try {
|
||||
return await callback();
|
||||
@@ -150,52 +137,121 @@ const withRequestLimit = async <T>(
|
||||
}
|
||||
};
|
||||
|
||||
const getCacheKey = (
|
||||
resolved: ResolvedDefinition,
|
||||
request: CustomJsxRequest,
|
||||
params: Record<string, string | number | boolean>,
|
||||
) =>
|
||||
`custom-jsx:${resolved.item.id}:${getCustomWidgetCacheVersion(resolved.stored)}:${request.id}:${hashRuntimeParams(params)}`;
|
||||
|
||||
const executeRequest = async (
|
||||
ctx: RouterContext,
|
||||
resolved: ResolvedDefinition,
|
||||
request: CustomJsxRequest,
|
||||
params: Record<string, string | number | boolean>,
|
||||
) => {
|
||||
await throwIfActionForbiddenAsync(
|
||||
ctx,
|
||||
eq(boards.id, resolved.item.boardId),
|
||||
request.minimumBoardPermission as BoardPermission,
|
||||
);
|
||||
const source = findSource(resolved, request.sourceId);
|
||||
const targetUrl = renderRequestTarget(source.baseUrl, request, params);
|
||||
return withRequestLimit(ctx, resolved, request, () =>
|
||||
executeCustomWidgetRequest({
|
||||
baseUrl: source.baseUrl,
|
||||
targetUrl,
|
||||
method: request.method,
|
||||
body: renderRequestBody(request.bodyTemplate, params),
|
||||
staticHeaders: request.staticHeaders,
|
||||
auth: getAuth(resolved, source, request.auth),
|
||||
networkScope: source.networkScope,
|
||||
kind: request.kind,
|
||||
cacheKey: request.kind === "query" ? getCacheKey(resolved, request, params) : undefined,
|
||||
cacheTtlSeconds: request.cacheTtlSeconds,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const getLoadParams = (resolved: ResolvedDefinition, request: CustomJsxRequest) => {
|
||||
const params: Record<string, string | number | boolean> = {};
|
||||
for (const [name, type] of Object.entries(request.parameters)) {
|
||||
const value = resolved.configuration[name];
|
||||
if (typeof value !== type) {
|
||||
throw new TRPCError({
|
||||
code: "PRECONDITION_FAILED",
|
||||
message: `Load request '${request.id}' needs option '${name}' (${type})`,
|
||||
});
|
||||
}
|
||||
params[name] = value as string | number | boolean;
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
export const customApiRouter = createTRPCRouter({
|
||||
getData: publicProcedure.input(itemInputSchema).query(async ({ ctx, input }) => {
|
||||
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
|
||||
const { definition, displayConfig } = resolved;
|
||||
|
||||
if (definition.displayType === "actionButton") {
|
||||
return {
|
||||
...(extractActionButtonDisplay(displayConfig) as Record<string, unknown>),
|
||||
requiresConfirmation: definition.method === "DELETE",
|
||||
};
|
||||
}
|
||||
if (definition.method !== "GET") {
|
||||
return { type: "networkAccessNeedsReview" as const };
|
||||
}
|
||||
|
||||
const response = await withRequestLimit(ctx, resolved, "query", () =>
|
||||
executeCustomWidgetRequest({
|
||||
baseUrl: definition.url,
|
||||
method: "GET",
|
||||
auth: getAuth(resolved),
|
||||
networkScope: getNetworkScope(displayConfig),
|
||||
kind: "query",
|
||||
cacheKey: `custom-widget:base:${resolved.item.id}`,
|
||||
const loadRequests = resolved.definition.requests.filter(
|
||||
(request) => request.kind === "query" && request.trigger === "load",
|
||||
);
|
||||
const entries = await Promise.all(
|
||||
loadRequests.map(async (request) => {
|
||||
try {
|
||||
const response = await executeRequest(ctx, resolved, request, getLoadParams(resolved, request));
|
||||
return [
|
||||
request.id,
|
||||
{
|
||||
data: response.data,
|
||||
status: {
|
||||
loading: false,
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
} catch (error) {
|
||||
return [
|
||||
request.id,
|
||||
{
|
||||
data: null,
|
||||
status: {
|
||||
loading: false,
|
||||
ok: false,
|
||||
status: 0,
|
||||
error: error instanceof Error ? error.message : "Request failed",
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
}
|
||||
}),
|
||||
);
|
||||
assertUpstreamSuccess(response);
|
||||
return extractDisplayDataWithFallback(response.data, definition.displayType, displayConfig);
|
||||
|
||||
return {
|
||||
type: "customJsx" as const,
|
||||
template: resolved.definition.template,
|
||||
data: Object.fromEntries(entries.map(([id, result]) => [id, result.data])),
|
||||
status: Object.fromEntries(entries.map(([id, result]) => [id, result.status])),
|
||||
options: resolved.configuration,
|
||||
stateSchema: resolved.definition.stateSchema ?? {},
|
||||
defaultState: resolved.definition.defaultState ?? {},
|
||||
requestCapabilities: resolved.definition.requests.map((request) => ({
|
||||
id: request.id,
|
||||
kind: request.kind,
|
||||
method: request.method,
|
||||
trigger: request.trigger,
|
||||
minimumBoardPermission: request.minimumBoardPermission,
|
||||
confirmation: request.confirmation,
|
||||
invalidates: request.invalidates ?? [],
|
||||
})),
|
||||
};
|
||||
}),
|
||||
|
||||
queryRequest: publicProcedure.input(namedRequestInputSchema).query(async ({ ctx, input }) => {
|
||||
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
|
||||
const request = findNamedRequest(resolved, input.requestId, "query");
|
||||
const targetUrl = renderRequestTarget(resolved.definition.url, request, input.params);
|
||||
const response = await withRequestLimit(ctx, resolved, "query", () =>
|
||||
executeCustomWidgetRequest({
|
||||
baseUrl: resolved.definition.url,
|
||||
targetUrl,
|
||||
method: "GET",
|
||||
staticHeaders: request.staticHeaders,
|
||||
auth: getAuth(resolved, request.auth),
|
||||
networkScope: getV2Config(resolved).networkScope,
|
||||
kind: "query",
|
||||
cacheKey: getCacheKey(resolved, request, input.params),
|
||||
cacheTtlSeconds: request.cacheTtlSeconds,
|
||||
}),
|
||||
);
|
||||
const request = findRequest(resolved, input.requestId, "query");
|
||||
const response = await executeRequest(ctx, resolved, request, input.params);
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
@@ -205,71 +261,31 @@ export const customApiRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
|
||||
executeBaseAction: protectedProcedure
|
||||
.input(itemInputSchema.extend({ confirmed: z.boolean().optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
|
||||
const { definition } = resolved;
|
||||
if (definition.displayType !== "actionButton") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Only actionButton widgets can use this action" });
|
||||
}
|
||||
|
||||
const permission: BoardPermission = definition.method === "DELETE" ? "full" : "modify";
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, resolved.item.boardId), permission);
|
||||
if (definition.method === "DELETE" && input.confirmed !== true) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "DELETE actions require confirmation" });
|
||||
}
|
||||
|
||||
const response = await withRequestLimit(ctx, resolved, definition.method === "DELETE" ? "delete" : "action", () =>
|
||||
executeCustomWidgetRequest({
|
||||
baseUrl: definition.url,
|
||||
method: definition.method,
|
||||
body: definition.method === "GET" ? undefined : (definition.requestBody ?? undefined),
|
||||
auth: getAuth(resolved),
|
||||
networkScope: getNetworkScope(resolved.displayConfig),
|
||||
kind: definition.method === "GET" ? "query" : "action",
|
||||
}),
|
||||
);
|
||||
return {
|
||||
success: response.ok,
|
||||
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
|
||||
responseInfo: { status: response.status, statusText: response.statusText },
|
||||
};
|
||||
}),
|
||||
|
||||
executeAction: protectedProcedure
|
||||
.input(namedRequestInputSchema.extend({ confirmed: z.boolean().optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
|
||||
const request = findNamedRequest(resolved, input.requestId, "action");
|
||||
if (request.method === "DELETE" && input.confirmed !== true) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "DELETE actions require confirmation" });
|
||||
const request = findRequest(resolved, input.requestId, "action");
|
||||
const needsConfirmation = request.confirmation !== undefined || request.method === "DELETE";
|
||||
if (needsConfirmation && input.confirmed !== true) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "This action requires confirmation" });
|
||||
}
|
||||
const response = await executeRequest(ctx, resolved, request, input.params);
|
||||
if (response.ok && request.invalidates?.length) {
|
||||
invalidateCustomWidgetResponseCache(
|
||||
request.invalidates.flatMap((requestId) => [
|
||||
`custom-jsx:${resolved.item.id}:${getCustomWidgetCacheVersion(resolved.stored)}:${requestId}:`,
|
||||
`custom-widget:options:${resolved.stored.id}:${getCustomWidgetCacheVersion(resolved.stored)}:${requestId}:`,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
await throwIfActionForbiddenAsync(
|
||||
ctx,
|
||||
eq(boards.id, resolved.item.boardId),
|
||||
request.minimumBoardPermission as BoardPermission,
|
||||
);
|
||||
const targetUrl = renderRequestTarget(resolved.definition.url, request, input.params);
|
||||
const response = await withRequestLimit(ctx, resolved, request.method === "DELETE" ? "delete" : "action", () =>
|
||||
executeCustomWidgetRequest({
|
||||
baseUrl: resolved.definition.url,
|
||||
targetUrl,
|
||||
method: request.method as CustomWidgetMethod,
|
||||
body: renderRequestBody(request.bodyTemplate, input.params),
|
||||
staticHeaders: request.staticHeaders,
|
||||
auth: getAuth(resolved, request.auth),
|
||||
networkScope: getV2Config(resolved).networkScope,
|
||||
kind: "action",
|
||||
}),
|
||||
);
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
data: response.data,
|
||||
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
|
||||
invalidates: request.invalidates ?? [],
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -38,17 +38,23 @@ test("MCP tools should contain expected procedures", () => {
|
||||
expect(toolNames).toContain("board_duplicateBoard");
|
||||
expect(toolNames).toContain("customWidget_schema");
|
||||
expect(toolNames).toContain("customWidget_validate");
|
||||
expect(toolNames).toContain("customWidget_all");
|
||||
expect(toolNames).toContain("customWidget_byId");
|
||||
expect(toolNames).toContain("customWidget_readTemplate");
|
||||
expect(toolNames).toContain("customWidget_writeTemplate");
|
||||
expect(toolNames).toContain("customWidget_patchTemplate");
|
||||
expect(toolNames).toContain("customWidget_list");
|
||||
expect(toolNames).toContain("customWidget_get");
|
||||
expect(toolNames).toContain("customWidget_templatePatch");
|
||||
expect(toolNames).toContain("customWidget_create");
|
||||
expect(toolNames).toContain("customWidget_import");
|
||||
expect(toolNames).toContain("customWidget_update");
|
||||
expect(toolNames).toContain("customWidget_preview");
|
||||
expect(toolNames).toContain("customWidget_delete");
|
||||
expect(toolNames).toContain("customWidget_previewCreate");
|
||||
expect(toolNames).toContain("customWidget_previewQuery");
|
||||
expect(toolNames).toContain("customWidget_simulatePreviewAction");
|
||||
expect(toolNames).toContain("customWidget_previewAction");
|
||||
expect(toolNames).toContain("customWidget_previewJournal");
|
||||
expect(toolNames).toContain("customWidget_secretSet");
|
||||
expect(toolNames).toContain("customWidget_secretRequestUser");
|
||||
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");
|
||||
@@ -61,13 +67,17 @@ test("MCP tools should have descriptions", () => {
|
||||
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(Object.keys(customWidgetRouter["_def"].procedures)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"all",
|
||||
"byId",
|
||||
"list",
|
||||
"get",
|
||||
"available",
|
||||
"create",
|
||||
"update",
|
||||
@@ -80,13 +90,18 @@ test("custom widget public procedures remain available", () => {
|
||||
"validate",
|
||||
"readTemplate",
|
||||
"writeTemplate",
|
||||
"patchTemplate",
|
||||
"preview",
|
||||
"templatePatch",
|
||||
"previewCreate",
|
||||
"previewGet",
|
||||
"previewQuery",
|
||||
"previewAction",
|
||||
"simulatePreviewAction",
|
||||
"setPreviewLiveActions",
|
||||
"previewJournal",
|
||||
"secretSet",
|
||||
"secretRequestUser",
|
||||
"secretClear",
|
||||
"getAuthoringPrompt",
|
||||
"getSkill",
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -13,8 +13,9 @@ import type { OpenApiMeta } from "trpc-to-openapi";
|
||||
import { ZodError } from "zod/v4";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { FlattenError } from "@homarr/common";
|
||||
import { extractBaseUrlFromHeaders, FlattenError } from "@homarr/common";
|
||||
import { userAgent } from "@homarr/common/server";
|
||||
import type { DeviceType } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { db } from "@homarr/db";
|
||||
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
|
||||
@@ -36,7 +37,14 @@ const logger = createLogger({ module: "trpc" });
|
||||
*
|
||||
* @see https://trpc.io/docs/server/context
|
||||
*/
|
||||
export const createTRPCContext = (opts: { headers: Headers; session: Session | null }) => {
|
||||
interface ApiContext {
|
||||
session: Session | null;
|
||||
deviceType: DeviceType;
|
||||
baseUrl?: `${string}://${string}`;
|
||||
db: typeof db;
|
||||
}
|
||||
|
||||
export const createTRPCContext = (opts: { headers: Headers; session: Session | null }): ApiContext => {
|
||||
const session = opts.session;
|
||||
const source = opts.headers.get("x-trpc-source") ?? "unknown";
|
||||
|
||||
@@ -49,6 +57,7 @@ export const createTRPCContext = (opts: { headers: Headers; session: Session | n
|
||||
return {
|
||||
session,
|
||||
deviceType: userAgent(opts.headers).device.type,
|
||||
baseUrl: extractBaseUrlFromHeaders(opts.headers),
|
||||
db,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user