feat(custom-widgets): complete JSX v2

This commit is contained in:
Thomas Camlong committed 2026-07-18 15:33:25 +02:00
1 parent c0f1d6624c
commit eae48f50fd
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();
});
});
+186 -170
View File
@@ -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 ?? [],
};
}),
});
+29 -14
View File
@@ -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",
]),
);
});
+11 -2
View File
@@ -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,
};
};