165 lines
6.6 KiB
TypeScript
165 lines
6.6 KiB
TypeScript
import { TRPCError } from "@trpc/server";
|
|
import { z } from "zod/v4";
|
|
|
|
import { decryptSecret } from "@homarr/common/server";
|
|
import {
|
|
collectCustomWidgetRequestReferences,
|
|
customWidgetAuthoringDefinitionSchema,
|
|
customWidgetSecretsInputSchema,
|
|
getCustomWidgetConfirmation,
|
|
getCustomWidgetDefaultOptions,
|
|
normalizeCustomWidgetAuthoringDefinition,
|
|
validateCustomWidgetOptions,
|
|
} from "@homarr/custom-widgets/core";
|
|
import { eq } from "@homarr/db";
|
|
import { customWidgetDefinitions } from "@homarr/db/schema";
|
|
|
|
import { permissionRequiredProcedure } from "../../trpc";
|
|
import { parseCustomWidgetAuthoringInput } from "./authoring-validation";
|
|
import { createPreviewSession, getPreviewSession } from "./preview-sessions";
|
|
import { hasSameSecretBinding, requiredSecretKinds } from "./secret-policy";
|
|
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
|
|
|
|
const previewCreateInputSchema = z.object({
|
|
definition: customWidgetAuthoringDefinitionSchema,
|
|
secrets: customWidgetSecretsInputSchema.default([]),
|
|
definitionId: z.string().optional(),
|
|
options: z.record(z.string(), z.unknown()).optional(),
|
|
});
|
|
|
|
const previewCreateProcedure = permissionRequiredProcedure
|
|
.requiresPermission("admin")
|
|
.meta({
|
|
mcp: {
|
|
enabled: true,
|
|
description:
|
|
"Create a short-lived preview for an unsaved custom widget after validation. Prefer templateLines for multiline JSX. The result lists every query that must be tested with customWidget_previewQuery before saving.",
|
|
},
|
|
})
|
|
.input(previewCreateInputSchema)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const definition = parseCustomWidgetAuthoringInput(() =>
|
|
normalizeCustomWidgetAuthoringDefinition(input.definition),
|
|
);
|
|
const options = input.options ?? getCustomWidgetDefaultOptions(definition.options);
|
|
const optionIssues = validateCustomWidgetOptions(definition.options, options);
|
|
if (optionIssues.length > 0) {
|
|
const issue = optionIssues[0];
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: issue ? `${issue.path}: ${issue.message}` : "Preview options are invalid",
|
|
});
|
|
}
|
|
|
|
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" });
|
|
const existingDefinition = parseStoredCustomWidgetDefinition(existing);
|
|
for (const [sourceId, existingSource] of Object.entries(existingDefinition.sources)) {
|
|
const submittedSource = definition.sources[sourceId];
|
|
const hasStoredSecrets = existing.secrets.some((secret) => secret.sourceId === sourceId);
|
|
if (!submittedSource || !hasStoredSecrets || hasSameSecretBinding(existingSource, submittedSource)) continue;
|
|
|
|
const authType = typeof submittedSource.auth === "string" ? submittedSource.auth : submittedSource.auth.type;
|
|
const missingReplacement = requiredSecretKinds(authType).some(
|
|
(kind) => !secrets.some((secret) => secret.sourceId === sourceId && secret.kind === kind),
|
|
);
|
|
if (missingReplacement) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "Source security settings changed; re-enter its credentials to preview this definition",
|
|
});
|
|
}
|
|
}
|
|
for (const secret of existing.secrets) {
|
|
const existingSource = existingDefinition.sources[secret.sourceId];
|
|
const submittedSource = definition.sources[secret.sourceId];
|
|
if (
|
|
existingSource &&
|
|
submittedSource &&
|
|
hasSameSecretBinding(existingSource, submittedSource) &&
|
|
!secrets.some((candidate) => candidate.sourceId === secret.sourceId && candidate.kind === secret.kind)
|
|
) {
|
|
secrets.push({ sourceId: secret.sourceId, kind: secret.kind, value: decryptSecret(secret.encryptedValue) });
|
|
}
|
|
}
|
|
}
|
|
|
|
const invalid = secrets.find((secret) => {
|
|
const source = definition.sources[secret.sourceId];
|
|
const authType = typeof source?.auth === "string" ? source.auth : source?.auth.type;
|
|
const kinds =
|
|
authType === "basic"
|
|
? ["username", "password"]
|
|
: source && authType && ["bearer", "apiKeyHeader", "apiKeyQuery"].includes(authType)
|
|
? ["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: definition.sources,
|
|
requests: definition.requests,
|
|
name: definition.name,
|
|
template: definition.template,
|
|
optionDefinitions: definition.options,
|
|
options,
|
|
secrets,
|
|
definitionId: input.definitionId,
|
|
});
|
|
const previewPath = `/manage/custom-widgets/preview/${previewSession.id}`;
|
|
return {
|
|
success: true as const,
|
|
previewSession,
|
|
previewPath,
|
|
previewUrl: new URL(previewPath, ctx.baseUrl ?? "http://localhost").toString(),
|
|
queries: Object.entries(definition.requests).flatMap(([requestId, request]) => {
|
|
if (request.kind !== "query") return [];
|
|
return [
|
|
{
|
|
requestId,
|
|
trigger: request.trigger,
|
|
parameterNames: [...collectCustomWidgetRequestReferences(request).params],
|
|
nextStep: `Call customWidget_previewQuery with sessionId '${previewSession.id}' and requestId '${requestId}'.`,
|
|
},
|
|
];
|
|
}),
|
|
};
|
|
});
|
|
|
|
export const previewBaseProcedures = {
|
|
previewCreate: previewCreateProcedure,
|
|
previewGet: permissionRequiredProcedure
|
|
.requiresPermission("admin")
|
|
.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,
|
|
optionDefinitions: session.optionDefinitions,
|
|
options: session.options,
|
|
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,
|
|
};
|
|
}),
|
|
};
|