Simplify Custom JSX v2 authoring

This commit is contained in:
Thomas Camlong committed 2026-07-21 08:19:22 +02:00
1 parent 1faaf29c3b
commit 05dbad8069
177 files changed
+4775 -43426

No files matched your search

@@ -81,7 +81,7 @@ export const customWidgetRouter = createTRPCRouter({
encryptedValue: encryptSecret(secret.value),
updatedAt: new Date(),
}));
const sourceIds = definition.sources.map((source) => source.id);
const sourceIds = Object.keys(definition.sources);
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
@@ -100,11 +100,11 @@ export const customWidgetRouter = createTRPCRouter({
),
);
for (const source of definition.sources) {
const kinds = [...requiredSecretKinds(source.auth.type)];
for (const [sourceId, source] of Object.entries(definition.sources)) {
const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)];
const where = and(
eq(schema.customWidgetSecrets.definitionId, id),
eq(schema.customWidgetSecrets.sourceId, source.id),
eq(schema.customWidgetSecrets.sourceId, sourceId),
);
await transaction
.delete(schema.customWidgetSecrets)
@@ -138,9 +138,9 @@ export const customWidgetRouter = createTRPCRouter({
.where(and(eq(customWidgetSecrets.definitionId, id), notInArray(customWidgetSecrets.sourceId, sourceIds)))
.run();
for (const source of definition.sources) {
const kinds = [...requiredSecretKinds(source.auth.type)];
const where = and(eq(customWidgetSecrets.definitionId, id), eq(customWidgetSecrets.sourceId, source.id));
for (const [sourceId, source] of Object.entries(definition.sources)) {
const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)];
const where = and(eq(customWidgetSecrets.definitionId, id), eq(customWidgetSecrets.sourceId, sourceId));
transaction
.delete(customWidgetSecrets)
.where(kinds.length > 0 ? and(where, notInArray(customWidgetSecrets.kind, kinds)) : where)
@@ -5,13 +5,22 @@ import { decryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { eq, or } from "@homarr/db";
import { boards, customWidgetDefinitions } from "@homarr/db/schema";
import { getCustomWidgetSecretRequirements } from "@homarr/custom-widgets/core";
import {
getCustomWidgetConfirmation,
getCustomWidgetDefaultOptions,
getCustomWidgetSecretRequirements,
} from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure, protectedProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
import { parseStoredCustomWidgetDefinition, safeParseStoredCustomWidgetDefinition } from "./stored-definition";
import { executeCustomWidgetRequest } from "./request-executor";
import { hashRuntimeParams, renderRequestBody, renderRequestTarget } from "./request-manifest";
import {
hashRuntimeParams,
renderRequestBody,
renderRequestTarget,
resolveCustomWidgetRequestValues,
} from "./request-manifest";
import { acquireCustomWidgetRequestLimit } from "./request-limits";
import { getCustomWidgetCacheVersion } from "./cache-version";
@@ -41,7 +50,7 @@ export const managementQueryProcedures = {
sources: [],
requestCount: 0,
missingSecrets: [],
defaultOptions: {},
options: {},
updatedAt: definition.updatedAt,
enabled: definition.enabled,
valid: false as const,
@@ -56,18 +65,18 @@ export const managementQueryProcedures = {
name: widget.name,
description: widget.description,
iconUrl: widget.iconUrl,
sources: widget.sources.map(({ id, name, baseUrl, networkScope, auth }) => ({
sources: Object.entries(widget.sources).map(([id, { name, baseUrl, networkScope, auth }]) => ({
id,
name,
origin: new URL(baseUrl).origin,
networkScope,
authType: auth.type,
authType: typeof auth === "string" ? auth : auth.type,
})),
requestCount: widget.requests.length,
requestCount: Object.keys(widget.requests).length,
missingSecrets: getCustomWidgetSecretRequirements(widget.sources).filter(
(requirement) => !configuredSecrets.has(`${requirement.sourceId}:${requirement.kind}`),
),
defaultOptions: widget.defaultOptions,
options: widget.options,
updatedAt: definition.updatedAt,
enabled: definition.enabled,
valid: true as const,
@@ -123,29 +132,29 @@ export const managementQueryProcedures = {
name: definition.name,
description: definition.description,
iconUrl: definition.iconUrl,
optionsSchema: widget.optionsSchema,
defaultOptions: widget.defaultOptions,
options: widget.options,
defaultOptions: getCustomWidgetDefaultOptions(widget.options),
template: widget.template,
sources: widget.sources.map(({ id, name, networkScope, auth }) => ({
sources: Object.entries(widget.sources).map(([id, { name, networkScope, auth }]) => ({
id,
name,
networkScope,
authType: auth.type,
authType: typeof auth === "string" ? auth : auth.type,
})),
requestCapabilities: widget.requests.map(
({ id, kind, method, trigger, minimumBoardPermission, confirmation, invalidates }) => ({
requestCapabilities: Object.entries(widget.requests).map(
([id, { kind, method, trigger, permission, confirmation, invalidates }]) => ({
id,
kind,
method,
trigger,
minimumBoardPermission,
confirmation,
minimumBoardPermission: permission,
confirmation: getCustomWidgetConfirmation({ method, confirmation }),
invalidates,
}),
),
optionRequests: widget.requests
.filter((request) => request.kind === "query")
.map(({ id, parameters, optionsBinding }) => ({ id, parameters, optionsBinding })),
optionRequests: Object.entries(widget.requests).flatMap(([id, request]) =>
request.kind === "query" ? [{ id }] : [],
),
updatedAt: definition.updatedAt,
};
});
@@ -168,12 +177,12 @@ export const managementQueryProcedures = {
});
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);
const requestDefinition = definition.requests[input.requestId];
if (requestDefinition?.kind !== "query")
throw new TRPCError({ code: "NOT_FOUND", message: "Option query not found" });
const request = { id: input.requestId, ...requestDefinition };
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), request.permission);
const source = definition.sources[request.source];
if (!source) throw new TRPCError({ code: "NOT_FOUND", message: "Option query source not found" });
const release = await acquireCustomWidgetRequestLimit({
category: "query",
@@ -182,31 +191,33 @@ export const managementQueryProcedures = {
definitionId: input.definitionId,
});
try {
const values = resolveCustomWidgetRequestValues(request, input.params, {});
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
const response = await executeCustomWidgetRequest({
baseUrl: source.baseUrl,
targetUrl: renderRequestTarget(source.baseUrl, request, input.params),
targetUrl: renderRequestTarget(source.baseUrl, request, values),
method: request.method,
body: renderRequestBody(request.bodyTemplate, input.params),
staticHeaders: request.staticHeaders,
body: renderRequestBody(request, values),
staticHeaders: request.headers,
auth:
request.auth === "none" || source.auth.type === "none"
request.auth === "none" || authType === "none"
? undefined
: {
type: source.auth.type,
type: authType,
secrets: stored.secrets
.filter((secret) => secret.sourceId === source.id)
.filter((secret) => secret.sourceId === request.source)
.map((secret) => ({ kind: secret.kind, value: decryptSecret(secret.encryptedValue) })),
headerName:
source.auth.type === "apiKeyHeader"
? source.auth.headerName
: source.auth.type === "apiKeyQuery"
? source.auth.parameterName
typeof source.auth === "object" && source.auth.type === "apiKeyHeader"
? source.auth.name
: typeof source.auth === "object" && source.auth.type === "apiKeyQuery"
? source.auth.name
: undefined,
},
networkScope: source.networkScope,
kind: "query",
cacheKey: `custom-widget:options:${input.definitionId}:${getCustomWidgetCacheVersion(stored)}:${request.id}:${hashRuntimeParams(input.params)}`,
cacheTtlSeconds: request.cacheTtlSeconds ?? 30,
cacheKey: `custom-widget:options:${input.definitionId}:${getCustomWidgetCacheVersion(stored)}:${request.id}:${hashRuntimeParams(values)}`,
cacheTtlSeconds: request.cacheSeconds ?? 30,
});
return {
ok: response.ok,
@@ -8,7 +8,7 @@ import {
recordPreviewJournal,
} from "./preview-procedure-helpers";
import { executeCustomWidgetRequest, invalidateCustomWidgetResponseCache } from "./request-executor";
import { renderRequestBody, renderRequestTarget } from "./request-manifest";
import { renderRequestBody, renderRequestTarget, resolveCustomWidgetRequestValues } from "./request-manifest";
import { acquireCustomWidgetRequestLimit } from "./request-limits";
import { getPreviewSession } from "./preview-sessions";
@@ -22,20 +22,21 @@ export const previewActionProcedures = {
.input(previewSessionRequestSchema.extend({ confirmed: z.boolean().optional() }))
.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" });
const resolved = getPreviewRequestSource(session, request.sourceId);
const definition = session.requests[input.requestId];
if (definition?.kind !== "action")
throw new TRPCError({ code: "NOT_FOUND", message: "Preview action was not found" });
const request = { id: input.requestId, ...definition };
const resolved = getPreviewRequestSource(session, request.source);
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 params = resolveCustomWidgetRequestValues(request, session.options, input.params);
const targetUrl = renderRequestTarget(resolved.source.baseUrl, request, params);
const body = renderRequestBody(request, params);
if (!session.liveActions) {
await recordPreviewJournal(session, {
requestId: request.id,
kind: "action",
method: request.method,
pathTemplate: request.pathTemplate,
path: request.path,
status: null,
durationMs: 0,
simulated: true,
@@ -58,7 +59,7 @@ export const previewActionProcedures = {
targetUrl,
method: request.method,
body,
staticHeaders: request.staticHeaders,
staticHeaders: request.headers,
auth: request.auth === "none" ? undefined : resolved.auth,
networkScope: resolved.source.networkScope,
kind: "action",
@@ -72,7 +73,7 @@ export const previewActionProcedures = {
requestId: request.id,
kind: "action",
method: request.method,
pathTemplate: request.pathTemplate,
path: request.path,
status: response.status,
durationMs: Date.now() - startedAt,
simulated: false,
@@ -5,6 +5,8 @@ import { decryptSecret } from "@homarr/common/server";
import {
customWidgetDefinitionSchema,
customWidgetSecretsInputSchema,
getCustomWidgetConfirmation,
getCustomWidgetDefaultOptions,
validateCustomWidgetOptions,
} from "@homarr/custom-widgets/core";
import { eq } from "@homarr/db";
@@ -29,8 +31,8 @@ const previewCreateProcedure = manageProcedure
if (input.secrets.length > 0 && !ctx.session.user.permissions.includes("custom-widget-secret-write")) {
throw new TRPCError({ code: "FORBIDDEN", message: "Writing preview secrets requires dedicated permission" });
}
const options = input.options ?? input.definition.defaultOptions;
const optionIssues = validateCustomWidgetOptions(input.definition.optionsSchema, options);
const options = input.options ?? getCustomWidgetDefaultOptions(input.definition.options);
const optionIssues = validateCustomWidgetOptions(input.definition.options, options);
if (optionIssues.length > 0) {
const issue = optionIssues[0];
throw new TRPCError({
@@ -54,11 +56,12 @@ const previewCreateProcedure = manageProcedure
}
const invalid = secrets.find((secret) => {
const source = input.definition.sources.find((candidate) => candidate.id === secret.sourceId);
const source = input.definition.sources[secret.sourceId];
const authType = typeof source?.auth === "string" ? source.auth : source?.auth.type;
const kinds =
source?.auth.type === "basic"
authType === "basic"
? ["username", "password"]
: source && ["bearer", "apiKeyHeader", "apiKeyQuery"].includes(source.auth.type)
: source && authType && ["bearer", "apiKeyHeader", "apiKeyQuery"].includes(authType)
? ["apiKey"]
: [];
return !source || !kinds.includes(secret.kind);
@@ -73,7 +76,7 @@ const previewCreateProcedure = manageProcedure
requests: input.definition.requests,
name: input.definition.name,
template: input.definition.template,
optionsSchema: input.definition.optionsSchema,
optionDefinitions: input.definition.options,
options,
secrets,
definitionId: input.definitionId,
@@ -87,7 +90,7 @@ const previewCreateProcedure = manageProcedure
).toString(),
definition: {
template: input.definition.template,
defaultOptions: input.definition.defaultOptions,
defaultOptions: getCustomWidgetDefaultOptions(input.definition.options),
},
};
});
@@ -101,31 +104,17 @@ export const previewBaseProcedures = {
name: session.name,
expiresAt: session.expiresAt,
template: session.template,
optionsSchema: session.optionsSchema,
optionDefinitions: session.optionDefinitions,
options: session.options,
requests: session.requests.map(
({
id,
kind,
method,
minimumBoardPermission,
trigger,
parameters,
optionsBinding,
confirmation,
invalidates,
}) => ({
id,
kind,
method,
minimumBoardPermission,
trigger,
parameters,
optionsBinding,
confirmation,
invalidates,
}),
),
requests: Object.entries(session.requests).map(([id, request]) => ({
id,
kind: request.kind,
method: request.method,
minimumBoardPermission: request.permission,
trigger: request.trigger,
confirmation: getCustomWidgetConfirmation(request),
invalidates: request.invalidates,
})),
liveActions: session.liveActions,
};
}),
@@ -1,11 +1,11 @@
import { createLogger } from "@homarr/core/infrastructure/logs";
import { resolveCustomWidgetOptionsBinding } from "@homarr/custom-widgets/core";
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
import { z } from "zod/v4";
import { appendPreviewJournal } from "./preview-sessions";
import { getPreviewSessionSecrets } from "./preview-sessions";
import type { CustomWidgetPreviewSession } from "./preview-sessions";
import { resolveCustomWidgetRequestValues } from "./request-manifest";
const logger = createLogger({ module: "custom-widget-preview" });
@@ -27,19 +27,20 @@ export const recordPreviewJournal = async (...args: Parameters<typeof appendPrev
};
export const getPreviewRequestSource = (session: CustomWidgetPreviewSession, sourceId: string) => {
const source = session.sources.find((candidate) => candidate.id === sourceId);
const source = session.sources[sourceId];
if (!source) return null;
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
const auth =
source.auth.type === "none"
authType === "none"
? undefined
: {
type: source.auth.type,
secrets: getPreviewSessionSecrets(session, source.id),
type: authType,
secrets: getPreviewSessionSecrets(session, sourceId),
headerName:
source.auth.type === "apiKeyHeader"
? source.auth.headerName
: source.auth.type === "apiKeyQuery"
? source.auth.parameterName
typeof source.auth === "object" && source.auth.type === "apiKeyHeader"
? source.auth.name
: typeof source.auth === "object" && source.auth.type === "apiKeyQuery"
? source.auth.name
: undefined,
};
return { source, auth };
@@ -50,5 +51,5 @@ export function resolvePreviewRequestParams(
options: Record<string, unknown>,
suppliedParams: Record<string, string | number | boolean>,
) {
return request.trigger === "load" ? resolveCustomWidgetOptionsBinding(request, options) : suppliedParams;
return resolveCustomWidgetRequestValues(request, options, suppliedParams);
}
@@ -26,15 +26,15 @@ export const previewQueryProcedures = {
.input(previewSessionRequestSchema)
.query(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 === "query",
);
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview query was not found" });
const resolved = getPreviewRequestSource(session, request.sourceId);
const definition = session.requests[input.requestId];
if (definition?.kind !== "query")
throw new TRPCError({ code: "NOT_FOUND", message: "Preview query was not found" });
const request = { id: input.requestId, ...definition };
const resolved = getPreviewRequestSource(session, request.source);
if (!resolved) throw new TRPCError({ code: "NOT_FOUND", message: "Preview source was not found" });
const params = resolvePreviewRequestParams(request, session.options, input.params);
const targetUrl = renderRequestTarget(resolved.source.baseUrl, request, params);
const body = renderRequestBody(request.bodyTemplate, params);
const body = renderRequestBody(request, params);
const release = await acquireCustomWidgetRequestLimit({
category: "query",
userId: ctx.session.user.id,
@@ -48,18 +48,18 @@ export const previewQueryProcedures = {
targetUrl,
method: request.method,
body,
staticHeaders: request.staticHeaders,
staticHeaders: request.headers,
auth: request.auth === "none" ? undefined : resolved.auth,
networkScope: resolved.source.networkScope,
kind: "query",
cacheKey: `custom-jsx:preview:${session.id}:${request.id}:${hashRuntimeParams(params)}`,
cacheTtlSeconds: request.cacheTtlSeconds,
cacheTtlSeconds: request.cacheSeconds,
});
await recordPreviewJournal(session, {
requestId: request.id,
kind: "query",
method: request.method,
pathTemplate: request.pathTemplate,
path: request.path,
status: response.status,
durationMs: Date.now() - startedAt,
simulated: false,
@@ -3,7 +3,7 @@ import {
hashRuntimeParams,
renderRequestBody as renderDomainBody,
renderRequestTarget as renderDomainTarget,
validateRuntimeParams as validateDomainParams,
resolveCustomWidgetRequestValues as resolveDomainValues,
} from "@homarr/custom-widgets/server";
import type { CustomJsxRuntimeParams } from "@homarr/custom-widgets/server";
@@ -12,26 +12,32 @@ import { toTrpcError } from "./domain-error";
export type { CustomJsxRuntimeParams } from "@homarr/custom-widgets/server";
export { hashRuntimeParams };
export function validateRuntimeParams(request: CustomJsxRequest, params: CustomJsxRuntimeParams): void {
type CustomJsxResolvedValues = ReturnType<typeof resolveDomainValues>;
export function resolveCustomWidgetRequestValues(
request: CustomJsxRequest,
options: Record<string, unknown>,
params: CustomJsxRuntimeParams = {},
) {
try {
validateDomainParams(request, params);
return resolveDomainValues(request, options, params);
} catch (error) {
toTrpcError(error);
return toTrpcError(error);
}
}
export function renderRequestBody(request: CustomJsxRequest["bodyTemplate"], params: CustomJsxRuntimeParams) {
export function renderRequestBody(request: CustomJsxRequest, params: CustomJsxResolvedValues) {
try {
return renderDomainBody(request, params);
} catch (error) {
toTrpcError(error);
return toTrpcError(error);
}
}
export function renderRequestTarget(baseUrl: string, request: CustomJsxRequest, params: CustomJsxRuntimeParams) {
export function renderRequestTarget(baseUrl: string, request: CustomJsxRequest, params: CustomJsxResolvedValues) {
try {
return renderDomainTarget(baseUrl, request, params);
} catch (error) {
toTrpcError(error);
return toTrpcError(error);
}
}
@@ -7,12 +7,13 @@ export function requiredSecretKinds(authType: string) {
}
export function assertSecretSources(
sources: readonly { id: string; auth: { type: string } }[],
sources: Record<string, { auth: string | { 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);
const source = sources[secret.sourceId];
const authType = typeof source?.auth === "string" ? source.auth : source?.auth.type;
return !source || !authType || !new Set<string>(requiredSecretKinds(authType)).has(secret.kind);
});
if (invalid) {
throw new TRPCError({
@@ -80,7 +80,7 @@ export const secretProcedures = {
}
let widgetName: string;
let source: { id: string; name: string; auth: { type: string } } | undefined;
let source: { id: string; name: string; auth: string | { type: string } } | undefined;
let target: { type: "definition"; id: string } | { type: "preview"; id: string };
if (input.definitionId) {
const stored = await ctx.db.query.customWidgetDefinitions.findFirst({
@@ -89,16 +89,22 @@ export const secretProcedures = {
if (!stored) throw new TRPCError({ code: "NOT_FOUND" });
const definition = parseStoredCustomWidgetDefinition(stored);
widgetName = definition.name;
source = definition.sources.find((candidate) => candidate.id === input.sourceId);
const candidate = definition.sources[input.sourceId ?? ""];
source = candidate
? { id: input.sourceId ?? "", name: candidate.name ?? input.sourceId ?? "", auth: candidate.auth }
: undefined;
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);
const candidate = preview.sources[input.sourceId ?? ""];
source = candidate
? { id: input.sourceId ?? "", name: candidate.name ?? input.sourceId ?? "", auth: candidate.auth }
: undefined;
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)];
const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)];
if (kinds.length === 0) {
throw new TRPCError({ code: "BAD_REQUEST", message: "This source does not require a secret" });
}
@@ -6,7 +6,7 @@ import type { CustomWidgetDefinition } from "@homarr/db/schema";
type StoredCustomWidgetDefinition = Pick<
CustomWidgetDefinition,
"name" | "description" | "iconUrl" | "sources" | "requests" | "optionsSchema" | "defaultOptions" | "template"
"name" | "description" | "iconUrl" | "sources" | "requests" | "options" | "template"
>;
export interface StoredCustomWidgetIssue {
@@ -25,8 +25,7 @@ export function serializeCustomWidgetDefinition(definition: HomarrCustomWidgetV2
iconUrl: definition.iconUrl ?? null,
sources: stringifySuperJson(definition.sources),
requests: stringifySuperJson(definition.requests),
optionsSchema: stringifySuperJson(definition.optionsSchema),
defaultOptions: stringifySuperJson(definition.defaultOptions),
options: stringifySuperJson(definition.options),
template: definition.template,
};
}
@@ -39,8 +38,7 @@ export function parseStoredCustomWidgetDefinition(definition: StoredCustomWidget
iconUrl: definition.iconUrl ?? undefined,
sources: parseSuperJson(definition.sources),
requests: parseSuperJson(definition.requests),
optionsSchema: parseSuperJson(definition.optionsSchema),
defaultOptions: parseSuperJson(definition.defaultOptions),
options: parseSuperJson(definition.options),
template: definition.template,
});
}
@@ -5,27 +5,28 @@ import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
import { resolvePreviewRequestParams } from "../../custom-widget/preview-procedure-helpers";
const loadRequest: CustomJsxRequest = {
id: "containers",
sourceId: "default",
source: "default",
kind: "query",
method: "GET",
pathTemplate: "/endpoints/{endpointId}/containers",
parameters: { endpointId: "number" },
optionsBinding: { endpointId: { $option: "environmentId" } },
path: "/endpoints/{option:environmentId}/containers",
auth: "inherit",
minimumBoardPermission: "view",
permission: "view",
trigger: "load",
};
describe("preview request option bindings", () => {
it("resolves load request parameters from the selected preview options", () => {
expect(resolvePreviewRequestParams(loadRequest, { environmentId: 7 }, { endpointId: 1 })).toEqual({
endpointId: 7,
expect(resolvePreviewRequestParams(loadRequest, { environmentId: 7 }, {})).toEqual({
"option:environmentId": 7,
});
});
it("preserves invoking component parameters for manual requests", () => {
const manualRequest = { ...loadRequest, trigger: "manual" as const, optionsBinding: undefined };
const manualRequest = {
...loadRequest,
trigger: "manual" as const,
path: "/endpoints/{param:endpointId}/containers",
};
expect(resolvePreviewRequestParams(manualRequest, { environmentId: 7 }, { endpointId: 12 })).toEqual({
endpointId: 12,
});
@@ -1,7 +1,11 @@
import { parse as parseSuperJson, stringify as stringifySuperJson } from "superjson";
import { stringify as stringifySuperJson } from "superjson";
import { describe, expect, test } from "vitest";
import { BUNDLED_CUSTOM_WIDGETS, CUSTOM_WIDGET_STARTER } from "@homarr/custom-widgets/core";
import {
BUNDLED_CUSTOM_WIDGETS,
CUSTOM_WIDGET_STARTER,
customWidgetDefinitionSchema,
} from "@homarr/custom-widgets/core";
import {
safeParseStoredCustomWidgetDefinition,
@@ -10,36 +14,31 @@ import {
describe("stored custom widget definitions", () => {
test("parses a valid stored definition", () => {
const result = safeParseStoredCustomWidgetDefinition(serializeCustomWidgetDefinition(CUSTOM_WIDGET_STARTER));
const result = safeParseStoredCustomWidgetDefinition(
serializeCustomWidgetDefinition(customWidgetDefinitionSchema.parse(CUSTOM_WIDGET_STARTER)),
);
expect(result).toMatchObject({ success: true, widget: { $schema: "homarr-custom-widget-v2" } });
});
test("returns all useful issues for a stale load request instead of throwing", () => {
test("returns useful issues for an invalid stored request instead of throwing", () => {
const currency = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-currency-exchange");
if (!currency) throw new Error("Currency Exchange bundled widget is missing");
const stored = serializeCustomWidgetDefinition(currency.widget);
const requests = parseSuperJson(stored.requests) as Array<Record<string, unknown>>;
delete requests[0]?.optionsBinding;
const stored = serializeCustomWidgetDefinition(customWidgetDefinitionSchema.parse(currency.widget));
const result = safeParseStoredCustomWidgetDefinition({
...stored,
requests: stringifySuperJson(requests),
requests: stringifySuperJson({ rates: { path: "/latest/{option:missing}" } }),
});
expect(result).toEqual({
success: false,
issues: expect.arrayContaining([
expect.objectContaining({ path: "requests.0.optionsBinding.from" }),
expect.objectContaining({ path: "requests.0.optionsBinding.to" }),
expect.objectContaining({ path: "requests.0.optionsBinding.amount" }),
]),
issues: expect.arrayContaining([expect.objectContaining({ path: "requests.rates" })]),
});
});
test("contains malformed serialized data", () => {
const stored = serializeCustomWidgetDefinition(CUSTOM_WIDGET_STARTER);
const stored = serializeCustomWidgetDefinition(customWidgetDefinitionSchema.parse(CUSTOM_WIDGET_STARTER));
expect(safeParseStoredCustomWidgetDefinition({ ...stored, requests: "not-json" })).toEqual({
success: false,
@@ -27,64 +27,55 @@ import { customApiRouter } from "../../widgets/custom-api";
const definition: HomarrCustomWidgetV2 = {
$schema: "homarr-custom-widget-v2",
name: "Router test widget",
sources: [
{
id: "default",
sources: {
default: {
name: "API",
baseUrl: "https://example.com",
networkScope: "public",
auth: { type: "none" },
auth: "none",
},
],
requests: [
{
id: "load-status",
sourceId: "default",
},
requests: {
"load-status": {
source: "default",
kind: "query",
method: "GET",
pathTemplate: "/status",
parameters: {},
path: "/status",
auth: "inherit",
minimumBoardPermission: "view",
permission: "view",
trigger: "load",
},
{
id: "status",
sourceId: "default",
status: {
source: "default",
kind: "query",
method: "GET",
pathTemplate: "/status/{name}",
parameters: { name: "string" },
path: "/status/{param:name}",
auth: "inherit",
minimumBoardPermission: "view",
permission: "view",
trigger: "manual",
},
{
id: "toggle",
sourceId: "default",
toggle: {
source: "default",
kind: "action",
method: "POST",
pathTemplate: "/toggle",
parameters: { enabled: "boolean" },
bodyTemplate: { enabled: { $param: "enabled" } },
path: "/toggle",
body: { enabled: { $param: "enabled" } },
auth: "inherit",
minimumBoardPermission: "modify",
permission: "modify",
trigger: "manual",
},
{
id: "delete",
sourceId: "default",
delete: {
source: "default",
kind: "action",
method: "DELETE",
pathTemplate: "/resource",
parameters: {},
path: "/resource",
auth: "inherit",
minimumBoardPermission: "full",
permission: "full",
trigger: "manual",
confirmation: { title: "Delete resource", message: "Delete this resource?", destructive: true },
},
],
optionsSchema: { type: "object", properties: {}, additionalProperties: false },
defaultOptions: {},
},
options: {},
template: "<Text>{data['load-status']?.value}</Text>",
};
@@ -149,6 +140,17 @@ describe("Custom JSX v2 board router", () => {
.where(eq(customWidgetDefinitions.id, enabledSetup.definitionId));
const publicCaller = customApiRouter.createCaller({ db: enabledSetup.db, deviceType: undefined, session: null });
await expect(publicCaller.getData({ itemId: enabledSetup.itemId })).rejects.toMatchObject({ code: "FORBIDDEN" });
await expect(
publicCaller.queryRequest({ itemId: enabledSetup.itemId, requestId: "status", params: { name: "homarr" } }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
const ownerCaller = customApiRouter.createCaller({
db: enabledSetup.db,
deviceType: undefined,
session: createSession(enabledSetup.ownerId),
});
await expect(
ownerCaller.executeAction({ itemId: enabledSetup.itemId, requestId: "toggle", params: { enabled: true } }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(mocks.executeRequest).not.toHaveBeenCalled();
});
@@ -159,7 +161,7 @@ describe("Custom JSX v2 board router", () => {
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.toThrow();
await expect(caller.queryRequest({ itemId, requestId: "status", params: {} })).rejects.toThrow();
expect(mocks.executeRequest).not.toHaveBeenCalled();
});
+50 -47
View File
@@ -6,14 +6,22 @@ import { decryptSecret } from "@homarr/common/server";
import { eq } from "@homarr/db";
import { boards, customWidgetDefinitions, items } from "@homarr/db/schema";
import type { BoardPermission } from "@homarr/definitions";
import { validateCustomWidgetOptions } from "@homarr/custom-widgets/core";
import { resolveCustomWidgetOptionsBinding } from "@homarr/custom-widgets/core";
import {
getCustomWidgetConfirmation,
getCustomWidgetDefaultOptions,
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, invalidateCustomWidgetResponseCache } from "../custom-widget/request-executor";
import { hashRuntimeParams, renderRequestBody, renderRequestTarget } from "../custom-widget/request-manifest";
import {
hashRuntimeParams,
renderRequestBody,
renderRequestTarget,
resolveCustomWidgetRequestValues,
} from "../custom-widget/request-manifest";
import { acquireCustomWidgetRequestLimit } from "../custom-widget/request-limits";
import { getCustomWidgetCacheVersion } from "../custom-widget/cache-version";
import { parseStoredCustomWidgetDefinition } from "../custom-widget/stored-definition";
@@ -77,8 +85,8 @@ async function resolvePlacedDefinitionAsync(ctx: RouterContext, itemId: string)
if (!stored.enabled) throw new TRPCError({ code: "FORBIDDEN", message: "Widget is disabled" });
const definition = parseStoredCustomWidgetDefinition(stored);
const configuration = { ...definition.defaultOptions, ...itemOptions.configuration };
const issues = validateCustomWidgetOptions(definition.optionsSchema, configuration);
const configuration = { ...getCustomWidgetDefaultOptions(definition.options), ...itemOptions.configuration };
const issues = validateCustomWidgetOptions(definition.options, configuration);
if (issues.length > 0) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
@@ -89,32 +97,35 @@ async function resolvePlacedDefinitionAsync(ctx: RouterContext, itemId: string)
}
const findRequest = (resolved: ResolvedDefinition, requestId: string, kind: "query" | "action") => {
const request = resolved.definition.requests.find(
(candidate) => candidate.id === requestId && candidate.kind === kind,
);
const request = resolved.definition.requests[requestId];
if (request?.kind !== kind) throw new TRPCError({ code: "NOT_FOUND", message: "Named request not found" });
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Named request not found" });
return request;
return { id: requestId, ...request };
};
const findSource = (resolved: ResolvedDefinition, sourceId: string) => {
const source = resolved.definition.sources.find((candidate) => candidate.id === sourceId);
const source = resolved.definition.sources[sourceId];
if (!source) throw new TRPCError({ code: "NOT_FOUND", message: "Widget API source not found" });
return source;
return { id: sourceId, ...source };
};
const getAuth = (resolved: ResolvedDefinition, source: CustomWidgetSource, mode: "inherit" | "none") => {
if (mode === "none" || source.auth.type === "none") return undefined;
type IdentifiedRequest = CustomJsxRequest & { id: string };
type IdentifiedSource = CustomWidgetSource & { id: string };
const getAuth = (resolved: ResolvedDefinition, source: IdentifiedSource, mode: "inherit" | "none") => {
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
if (mode === "none" || authType === "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,
type: authType,
secrets,
headerName:
source.auth.type === "apiKeyHeader"
? source.auth.headerName
: source.auth.type === "apiKeyQuery"
? source.auth.parameterName
typeof source.auth === "object" && source.auth.type === "apiKeyHeader"
? source.auth.name
: typeof source.auth === "object" && source.auth.type === "apiKeyQuery"
? source.auth.name
: undefined,
};
};
@@ -122,7 +133,7 @@ const getAuth = (resolved: ResolvedDefinition, source: CustomWidgetSource, mode:
const withRequestLimit = async <T>(
ctx: RouterContext,
resolved: ResolvedDefinition,
request: CustomJsxRequest,
request: IdentifiedRequest,
callback: () => Promise<T>,
) => {
const release = await acquireCustomWidgetRequestLimit({
@@ -138,38 +149,31 @@ const withRequestLimit = async <T>(
}
};
const getCacheKey = (
resolved: ResolvedDefinition,
request: CustomJsxRequest,
params: Record<string, string | number | boolean>,
) =>
const getCacheKey = (resolved: ResolvedDefinition, request: IdentifiedRequest, params: Record<string, unknown>) =>
`custom-jsx:${resolved.item.id}:${getCustomWidgetCacheVersion(resolved.stored)}:${request.id}:${hashRuntimeParams(params)}`;
const executeRequest = async (
ctx: RouterContext,
resolved: ResolvedDefinition,
request: CustomJsxRequest,
request: IdentifiedRequest,
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);
await throwIfActionForbiddenAsync(ctx, eq(boards.id, resolved.item.boardId), request.permission as BoardPermission);
const source = findSource(resolved, request.source);
const values = resolveCustomWidgetRequestValues(request, resolved.configuration, params);
const targetUrl = renderRequestTarget(source.baseUrl, request, values);
return withRequestLimit(ctx, resolved, request, () =>
executeCustomWidgetRequest({
baseUrl: source.baseUrl,
targetUrl,
method: request.method,
body: renderRequestBody(request.bodyTemplate, params),
staticHeaders: request.staticHeaders,
body: renderRequestBody(request, values),
staticHeaders: request.headers,
auth: getAuth(resolved, source, request.auth),
networkScope: source.networkScope,
kind: request.kind,
cacheKey: request.kind === "query" ? getCacheKey(resolved, request, params) : undefined,
cacheTtlSeconds: request.cacheTtlSeconds,
cacheKey: request.kind === "query" ? getCacheKey(resolved, request, values) : undefined,
cacheTtlSeconds: request.cacheSeconds,
}),
);
};
@@ -177,16 +181,15 @@ const executeRequest = async (
export const customApiRouter = createTRPCRouter({
getData: publicProcedure.input(itemInputSchema).query(async ({ ctx, input }) => {
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
const loadRequests = resolved.definition.requests.filter(
(request) => request.kind === "query" && request.trigger === "load",
const loadRequests = Object.entries(resolved.definition.requests).filter(
([, request]) => request.kind === "query" && request.trigger === "load",
);
const entries = await Promise.all(
loadRequests.map(async (request) => {
loadRequests.map(async ([requestId, request]) => {
try {
const params = resolveCustomWidgetOptionsBinding(request, resolved.configuration);
const response = await executeRequest(ctx, resolved, request, params);
const response = await executeRequest(ctx, resolved, { id: requestId, ...request }, {});
return [
request.id,
requestId,
{
data: response.data,
status: {
@@ -200,7 +203,7 @@ export const customApiRouter = createTRPCRouter({
] as const;
} catch (error) {
return [
request.id,
requestId,
{
data: null,
status: {
@@ -221,13 +224,13 @@ export const customApiRouter = createTRPCRouter({
data: Object.fromEntries(entries.map(([id, result]) => [id, result.data])),
status: Object.fromEntries(entries.map(([id, result]) => [id, result.status])),
options: resolved.configuration,
requestCapabilities: resolved.definition.requests.map((request) => ({
id: request.id,
requestCapabilities: Object.entries(resolved.definition.requests).map(([id, request]) => ({
id,
kind: request.kind,
method: request.method,
trigger: request.trigger,
minimumBoardPermission: request.minimumBoardPermission,
confirmation: request.confirmation,
minimumBoardPermission: request.permission,
confirmation: getCustomWidgetConfirmation(request),
invalidates: request.invalidates ?? [],
})),
};