feat(custom-widgets): complete Custom JSX v2 (#6390)
This commit is contained in:
1 parent
3a1245cbc5
commit
acdfcbb18e
416 files changed
+37853
-18066
No files matched your search
@@ -0,0 +1,6 @@
|
||||
export * from "./router/custom-widget/configuration-requests";
|
||||
export { configurePreviewSessionSource } from "./router/custom-widget/preview-sessions";
|
||||
export {
|
||||
parseStoredCustomWidgetDefinition,
|
||||
serializeCustomWidgetDefinition,
|
||||
} from "./router/custom-widget/stored-definition";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
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";
|
||||
import type { CustomWidgetSource } from "@homarr/custom-widgets/core";
|
||||
|
||||
const CONFIGURATION_REQUEST_TTL_MS = 10 * 60_000;
|
||||
const CONFIGURATION_REQUEST_PREFIX = "custom-widget:configuration-request:";
|
||||
const CONFIGURATION_REQUEST_LOCK_PREFIX = "custom-widget:configuration-request-lock:";
|
||||
|
||||
export interface CustomWidgetConfigurationRequest {
|
||||
id: string;
|
||||
userId: string;
|
||||
target: { type: "definition"; id: string } | { type: "preview"; id: string };
|
||||
widgetName: string;
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
source: CustomWidgetSource;
|
||||
kinds: CustomWidgetSecretKind[];
|
||||
expiresAt: number;
|
||||
status: "pending" | "completed";
|
||||
}
|
||||
|
||||
const localRequests = new Map<string, CustomWidgetConfigurationRequest>();
|
||||
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: CustomWidgetConfigurationRequest) {
|
||||
const ttl = Math.max(1, request.expiresAt - Date.now());
|
||||
const client = getRedis();
|
||||
if (client) await client.set(`${CONFIGURATION_REQUEST_PREFIX}${request.id}`, JSON.stringify(request), "PX", ttl);
|
||||
else localRequests.set(request.id, request);
|
||||
}
|
||||
|
||||
export async function createCustomWidgetConfigurationRequest(
|
||||
input: Omit<CustomWidgetConfigurationRequest, "id" | "expiresAt" | "status">,
|
||||
) {
|
||||
const request: CustomWidgetConfigurationRequest = {
|
||||
...input,
|
||||
id: createId(),
|
||||
expiresAt: Date.now() + CONFIGURATION_REQUEST_TTL_MS,
|
||||
status: "pending",
|
||||
};
|
||||
await save(request);
|
||||
return request;
|
||||
}
|
||||
|
||||
export async function getCustomWidgetConfigurationRequest(id: string) {
|
||||
const client = getRedis();
|
||||
const candidate = client ? await client.get(`${CONFIGURATION_REQUEST_PREFIX}${id}`) : localRequests.get(id);
|
||||
if (!candidate) return null;
|
||||
try {
|
||||
const request = (
|
||||
typeof candidate === "string" ? JSON.parse(candidate) : candidate
|
||||
) as CustomWidgetConfigurationRequest;
|
||||
if (request.expiresAt <= Date.now()) {
|
||||
if (client) await client.del(`${CONFIGURATION_REQUEST_PREFIX}${id}`);
|
||||
else localRequests.delete(id);
|
||||
return null;
|
||||
}
|
||||
return request;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCustomWidgetConfigurationRequestForUser(id: string, userId: string) {
|
||||
const request = await getCustomWidgetConfigurationRequest(id);
|
||||
return request?.userId === userId ? request : null;
|
||||
}
|
||||
|
||||
export async function completeCustomWidgetConfigurationRequest(id: string) {
|
||||
const request = await getCustomWidgetConfigurationRequest(id);
|
||||
if (!request || request.status !== "pending") return null;
|
||||
const completed = { ...request, status: "completed" as const };
|
||||
await save(completed);
|
||||
await releaseCustomWidgetConfigurationRequest(id);
|
||||
return completed;
|
||||
}
|
||||
|
||||
export async function claimCustomWidgetConfigurationRequest(id: string) {
|
||||
const request = await getCustomWidgetConfigurationRequest(id);
|
||||
if (!request || request.status !== "pending") return null;
|
||||
const client = getRedis();
|
||||
if (client) {
|
||||
const claimed = await client.set(`${CONFIGURATION_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 releaseCustomWidgetConfigurationRequest(id: string) {
|
||||
const client = getRedis();
|
||||
if (client) await client.del(`${CONFIGURATION_REQUEST_LOCK_PREFIX}${id}`);
|
||||
else localLocks.delete(id);
|
||||
}
|
||||
|
||||
export { setPreviewSessionSecrets } from "./preview-sessions";
|
||||
@@ -1,160 +1,189 @@
|
||||
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, handleTransactionsAsync, 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";
|
||||
import { insertCustomWidgetDefinition } from "./definition-insert";
|
||||
import { managementQueryProcedures } from "./management-queries";
|
||||
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,
|
||||
safeParseStoredCustomWidgetDefinition,
|
||||
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";
|
||||
import { workshopProcedures } from "./workshop-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 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,
|
||||
});
|
||||
|
||||
if (input.secrets.length > 0) {
|
||||
await ctx.db.insert(customWidgetSecrets).values(
|
||||
input.secrets.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
value: encryptSecret(secret.value),
|
||||
definitionId: id,
|
||||
updatedAt: new Date(),
|
||||
})),
|
||||
);
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("Created custom widget definition", { id, name: input.name });
|
||||
const definition = customWidgetDefinitionSchema.parse(candidate);
|
||||
assertSecretSources(definition.sources, secrets);
|
||||
const id = await insertCustomWidgetDefinition(ctx.db, definition, ctx.session.user.id, secrets);
|
||||
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 definitionChanges = { ...serializeCustomWidgetDefinition(definition), updatedAt: new Date() };
|
||||
const secretRows = secrets?.map((secret) => ({
|
||||
definitionId: id,
|
||||
sourceId: secret.sourceId,
|
||||
kind: secret.kind,
|
||||
encryptedValue: encryptSecret(secret.value),
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
const sourceIds = Object.keys(definition.sources);
|
||||
|
||||
const { id, secrets, ...updateFields } = input;
|
||||
const updateValues: Record<string, unknown> = { updatedAt: new Date() };
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
async handleAsync(db, schema) {
|
||||
await db.transaction(async (transaction) => {
|
||||
await transaction
|
||||
.update(schema.customWidgetDefinitions)
|
||||
.set(definitionChanges)
|
||||
.where(eq(schema.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 transaction
|
||||
.delete(schema.customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.customWidgetSecrets.definitionId, id),
|
||||
notInArray(schema.customWidgetSecrets.sourceId, sourceIds),
|
||||
),
|
||||
);
|
||||
|
||||
await ctx.db.update(customWidgetDefinitions).set(updateValues).where(eq(customWidgetDefinitions.id, 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(schema.customWidgetSecrets.definitionId, id),
|
||||
eq(schema.customWidgetSecrets.sourceId, sourceId),
|
||||
);
|
||||
await transaction
|
||||
.delete(schema.customWidgetSecrets)
|
||||
.where(kinds.length > 0 ? and(where, notInArray(schema.customWidgetSecrets.kind, kinds)) : where);
|
||||
}
|
||||
|
||||
if (secrets !== undefined) {
|
||||
const effectiveAuthType = (updateFields.authType as string | undefined) ?? existing.authType;
|
||||
for (const secret of secretRows ?? []) {
|
||||
await transaction
|
||||
.delete(schema.customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.customWidgetSecrets.definitionId, id),
|
||||
eq(schema.customWidgetSecrets.sourceId, secret.sourceId),
|
||||
eq(schema.customWidgetSecrets.kind, secret.kind),
|
||||
),
|
||||
);
|
||||
await transaction.insert(schema.customWidgetSecrets).values(secret);
|
||||
}
|
||||
});
|
||||
},
|
||||
handleSync(db) {
|
||||
db.transaction((transaction) => {
|
||||
transaction
|
||||
.update(customWidgetDefinitions)
|
||||
.set(definitionChanges)
|
||||
.where(eq(customWidgetDefinitions.id, id))
|
||||
.run();
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
transaction
|
||||
.delete(customWidgetSecrets)
|
||||
.where(and(eq(customWidgetSecrets.definitionId, id), notInArray(customWidgetSecrets.sourceId, sourceIds)))
|
||||
.run();
|
||||
|
||||
for (const [sourceId, source] of Object.entries(definition.sources)) {
|
||||
const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)];
|
||||
const where = and(eq(customWidgetSecrets.definitionId, id), eq(customWidgetSecrets.sourceId, sourceId));
|
||||
transaction
|
||||
.delete(customWidgetSecrets)
|
||||
.where(kinds.length > 0 ? and(where, notInArray(customWidgetSecrets.kind, kinds)) : where)
|
||||
.run();
|
||||
}
|
||||
|
||||
for (const secret of secretRows ?? []) {
|
||||
transaction
|
||||
.delete(customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(customWidgetSecrets.definitionId, id),
|
||||
eq(customWidgetSecrets.sourceId, secret.sourceId),
|
||||
eq(customWidgetSecrets.kind, secret.kind),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
transaction.insert(customWidgetSecrets).values(secret).run();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
logger.info("Updated custom widget definition", { id });
|
||||
}),
|
||||
|
||||
toggleEnabled: adminProcedure
|
||||
...secretProcedures,
|
||||
...workshopProcedures,
|
||||
|
||||
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" });
|
||||
if (input.enabled) {
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
if (!definition) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
if (!safeParseStoredCustomWidgetDefinition(definition).success) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Invalid custom widget definition cannot be enabled",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
@@ -163,66 +192,32 @@ export const customWidgetRouter = createTRPCRouter({
|
||||
.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)` };
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createId } from "@homarr/common";
|
||||
import { encryptSecret } from "@homarr/common/server";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { handleTransactionsAsync } from "@homarr/db";
|
||||
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
|
||||
import type { CustomWidgetCreateInput, HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { serializeCustomWidgetDefinition } from "./stored-definition";
|
||||
|
||||
export async function insertCustomWidgetDefinition(
|
||||
db: Database,
|
||||
definition: HomarrCustomWidgetV2,
|
||||
creatorId: string,
|
||||
secrets: CustomWidgetCreateInput["secrets"],
|
||||
) {
|
||||
const id = createId();
|
||||
const definitionRow = { id, ...serializeCustomWidgetDefinition(definition), creatorId };
|
||||
const updatedAt = new Date();
|
||||
const secretRows = secrets.map((secret) => ({
|
||||
definitionId: id,
|
||||
sourceId: secret.sourceId,
|
||||
kind: secret.kind,
|
||||
encryptedValue: encryptSecret(secret.value),
|
||||
updatedAt,
|
||||
}));
|
||||
|
||||
await handleTransactionsAsync(db, {
|
||||
async handleAsync(database, schema) {
|
||||
await database.transaction(async (transaction) => {
|
||||
await transaction.insert(schema.customWidgetDefinitions).values(definitionRow);
|
||||
if (secretRows.length > 0) await transaction.insert(schema.customWidgetSecrets).values(secretRows);
|
||||
});
|
||||
},
|
||||
handleSync(database) {
|
||||
database.transaction((transaction) => {
|
||||
transaction.insert(customWidgetDefinitions).values(definitionRow).run();
|
||||
if (secretRows.length > 0) transaction.insert(customWidgetSecrets).values(secretRows).run();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return id;
|
||||
}
|
||||
@@ -1,51 +1,92 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { decryptSecret } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { eq, or } from "@homarr/db";
|
||||
import { boards, customWidgetDefinitions } from "@homarr/db/schema";
|
||||
import {
|
||||
getCustomWidgetConfirmation,
|
||||
getCustomWidgetDefaultOptions,
|
||||
getCustomWidgetSecretRequirements,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
|
||||
import { permissionRequiredProcedure, protectedProcedure } from "../../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "../board/board-access";
|
||||
import { parseDisplayConfig } from "./parse-display-config";
|
||||
import { parseStoredCustomWidgetDefinition, safeParseStoredCustomWidgetDefinition } from "./stored-definition";
|
||||
import { executeCustomWidgetRequest } from "./request-executor";
|
||||
import {
|
||||
hashRuntimeParams,
|
||||
renderRequestBody,
|
||||
renderRequestTarget,
|
||||
resolveCustomWidgetRequestValues,
|
||||
} 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");
|
||||
const logger = createLogger({ module: "custom-widget:management" });
|
||||
|
||||
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),
|
||||
with: { secrets: true },
|
||||
});
|
||||
return definitions.map((definition) => {
|
||||
const result = safeParseStoredCustomWidgetDefinition(definition);
|
||||
if (!result.success) {
|
||||
logger.warn("Skipped parsing invalid custom widget definition", {
|
||||
id: definition.id,
|
||||
issueCount: result.issues.length,
|
||||
});
|
||||
return {
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
description: definition.description ?? undefined,
|
||||
iconUrl: definition.iconUrl ?? undefined,
|
||||
sources: [],
|
||||
requestCount: 0,
|
||||
missingSecrets: [],
|
||||
options: {},
|
||||
updatedAt: definition.updatedAt,
|
||||
enabled: definition.enabled,
|
||||
valid: false as const,
|
||||
validationIssues: result.issues,
|
||||
};
|
||||
}
|
||||
|
||||
const widget = result.widget;
|
||||
const configuredSecrets = new Set(definition.secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
|
||||
return {
|
||||
id: definition.id,
|
||||
name: widget.name,
|
||||
description: widget.description,
|
||||
iconUrl: widget.iconUrl,
|
||||
sources: Object.entries(widget.sources).map(([id, { name, baseUrl, networkScope, auth }]) => ({
|
||||
id,
|
||||
name,
|
||||
origin: new URL(baseUrl).origin,
|
||||
networkScope,
|
||||
authType: typeof auth === "string" ? auth : auth.type,
|
||||
})),
|
||||
requestCount: Object.keys(widget.requests).length,
|
||||
missingSecrets: getCustomWidgetSecretRequirements(widget.sources).filter(
|
||||
(requirement) => !configuredSecrets.has(`${requirement.sourceId}:${requirement.kind}`),
|
||||
),
|
||||
options: widget.options,
|
||||
updatedAt: definition.updatedAt,
|
||||
enabled: definition.enabled,
|
||||
valid: true as const,
|
||||
validationIssues: [],
|
||||
};
|
||||
});
|
||||
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,
|
||||
}));
|
||||
}),
|
||||
|
||||
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 +95,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 +113,120 @@ 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.flatMap((definition) => {
|
||||
const result = safeParseStoredCustomWidgetDefinition(definition);
|
||||
if (!result.success) {
|
||||
logger.warn("Excluded invalid custom widget definition from board picker", { id: definition.id });
|
||||
return [];
|
||||
}
|
||||
|
||||
const widget = result.widget;
|
||||
return {
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
iconUrl: definition.iconUrl,
|
||||
options: widget.options,
|
||||
defaultOptions: getCustomWidgetDefaultOptions(widget.options),
|
||||
template: widget.template,
|
||||
sources: Object.entries(widget.sources).map(([id, { name, networkScope, auth }]) => ({
|
||||
id,
|
||||
name,
|
||||
networkScope,
|
||||
authType: typeof auth === "string" ? auth : auth.type,
|
||||
})),
|
||||
requestCapabilities: Object.entries(widget.requests).map(
|
||||
([id, { kind, method, trigger, permission, confirmation, invalidates }]) => ({
|
||||
id,
|
||||
kind,
|
||||
method,
|
||||
trigger,
|
||||
minimumBoardPermission: permission,
|
||||
confirmation: getCustomWidgetConfirmation({ method, confirmation }),
|
||||
invalidates,
|
||||
}),
|
||||
),
|
||||
optionRequests: Object.entries(widget.requests).flatMap(([id, request]) =>
|
||||
request.kind === "query" ? [{ id }] : [],
|
||||
),
|
||||
updatedAt: definition.updatedAt,
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
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 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",
|
||||
userId: ctx.session.user.id,
|
||||
itemId: `configuration:${input.boardId}`,
|
||||
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, values),
|
||||
method: request.method,
|
||||
body: renderRequestBody(request, values),
|
||||
staticHeaders: request.headers,
|
||||
auth:
|
||||
request.auth === "none" || authType === "none"
|
||||
? undefined
|
||||
: {
|
||||
type: authType,
|
||||
secrets: stored.secrets
|
||||
.filter((secret) => secret.sourceId === request.source)
|
||||
.map((secret) => ({ kind: secret.kind, value: decryptSecret(secret.encryptedValue) })),
|
||||
headerName:
|
||||
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(values)}`,
|
||||
cacheTtlSeconds: request.cacheSeconds ?? 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,36 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
|
||||
import { CUSTOM_WIDGET_MCP_AUTHORING_PROMPT } from "@homarr/custom-widgets/authoring-prompt";
|
||||
import { getCustomWidgetSkill } from "@homarr/custom-widgets/authoring-resources";
|
||||
import { customWidgetImportSchema, getCustomWidgetJsonSchema } 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",
|
||||
],
|
||||
httpResources: ["/api/custom-widgets/schema", "/api/custom-widgets/components", "/api/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,73 +2,49 @@ 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 { renderRequestBody, renderRequestTarget } from "./request-manifest";
|
||||
import {
|
||||
getPreviewRequestSource,
|
||||
previewSessionRequestSchema,
|
||||
recordPreviewJournal,
|
||||
} from "./preview-procedure-helpers";
|
||||
import { executeCustomWidgetRequest, invalidateCustomWidgetResponseCache } from "./request-executor";
|
||||
import { renderRequestBody, renderRequestTarget, resolveCustomWidgetRequestValues } 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);
|
||||
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 targetUrl = renderRequestTarget(session.baseUrl, request, input.params);
|
||||
const body = renderRequestBody(request.bodyTemplate, input.params);
|
||||
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 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,
|
||||
});
|
||||
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,27 +55,25 @@ 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,
|
||||
staticHeaders: request.headers,
|
||||
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",
|
||||
method: request.method,
|
||||
pathTemplate: request.pathTemplate,
|
||||
path: request.path,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
simulated: false,
|
||||
|
||||
@@ -2,158 +2,120 @@ 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,
|
||||
getCustomWidgetConfirmation,
|
||||
getCustomWidgetDefaultOptions,
|
||||
validateCustomWidgetOptions,
|
||||
} 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(),
|
||||
options: z.record(z.string(), z.unknown()).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 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({
|
||||
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" });
|
||||
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[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: input.definition.sources,
|
||||
requests: input.definition.requests,
|
||||
name: input.definition.name,
|
||||
template: input.definition.template,
|
||||
optionDefinitions: input.definition.options,
|
||||
options,
|
||||
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: getCustomWidgetDefaultOptions(input.definition.options),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
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,
|
||||
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,
|
||||
};
|
||||
}),
|
||||
};
|
||||
@@ -1,7 +1,11 @@
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
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" });
|
||||
|
||||
@@ -21,3 +25,31 @@ export const recordPreviewJournal = async (...args: Parameters<typeof appendPrev
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getPreviewRequestSource = (session: CustomWidgetPreviewSession, sourceId: string) => {
|
||||
const source = session.sources[sourceId];
|
||||
if (!source) return null;
|
||||
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
|
||||
const auth =
|
||||
authType === "none"
|
||||
? undefined
|
||||
: {
|
||||
type: authType,
|
||||
secrets: getPreviewSessionSecrets(session, sourceId),
|
||||
headerName:
|
||||
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 };
|
||||
};
|
||||
|
||||
export function resolvePreviewRequestParams(
|
||||
request: CustomJsxRequest,
|
||||
options: Record<string, unknown>,
|
||||
suppliedParams: Record<string, string | number | boolean>,
|
||||
) {
|
||||
return resolveCustomWidgetRequestValues(request, options, suppliedParams);
|
||||
}
|
||||
@@ -2,36 +2,39 @@ 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,
|
||||
resolvePreviewRequestParams,
|
||||
} 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)
|
||||
.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 targetUrl = renderRequestTarget(session.baseUrl, request, input.params);
|
||||
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, params);
|
||||
const release = await acquireCustomWidgetRequestLimit({
|
||||
category: "query",
|
||||
userId: ctx.session.user.id,
|
||||
@@ -41,28 +44,22 @@ export const previewQueryProcedures = {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const response = await executeCustomWidgetRequest({
|
||||
baseUrl: session.baseUrl,
|
||||
baseUrl: resolved.source.baseUrl,
|
||||
targetUrl,
|
||||
method: "GET",
|
||||
staticHeaders: request.staticHeaders,
|
||||
auth:
|
||||
request.auth === "none"
|
||||
? undefined
|
||||
: {
|
||||
type: session.authType,
|
||||
secrets: getPreviewSessionSecrets(session),
|
||||
headerName: session.headerName,
|
||||
},
|
||||
networkScope: session.networkScope,
|
||||
method: request.method,
|
||||
body,
|
||||
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(input.params)}`,
|
||||
cacheTtlSeconds: request.cacheTtlSeconds,
|
||||
cacheKey: `custom-jsx:preview:${session.id}:${request.id}:${hashRuntimeParams(params)}`,
|
||||
cacheTtlSeconds: request.cacheSeconds,
|
||||
});
|
||||
await recordPreviewJournal(session, {
|
||||
requestId: request.id,
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: request.pathTemplate,
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
simulated: false,
|
||||
@@ -79,18 +76,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,14 @@ 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));
|
||||
export const configurePreviewSessionSource = (
|
||||
id: string,
|
||||
userId: string,
|
||||
sourceId: string,
|
||||
source: CreatePreviewSessionInput["sources"][string],
|
||||
secrets: CreatePreviewSessionInput["secrets"],
|
||||
) => call(() => getService().configureSource(id, userId, sourceId, source, 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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { getCustomWidgetRequiredSecretKinds } from "@homarr/custom-widgets/core";
|
||||
|
||||
export function requiredSecretKinds(authType: string) {
|
||||
return getCustomWidgetRequiredSecretKinds(authType as Parameters<typeof getCustomWidgetRequiredSecretKinds>[0]);
|
||||
}
|
||||
|
||||
export function assertSecretSources(
|
||||
sources: Record<string, { auth: string | { type: string } }>,
|
||||
secrets: readonly { sourceId: string; kind: "apiKey" | "username" | "password" }[],
|
||||
) {
|
||||
const invalid = secrets.find((secret) => {
|
||||
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({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Secret does not match source '${invalid.sourceId}' authentication`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { encryptSecret } from "@homarr/common/server";
|
||||
import { and, eq, handleTransactionsAsync } from "@homarr/db";
|
||||
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
|
||||
import {
|
||||
customWidgetSecretInputSchema,
|
||||
customWidgetSecretsInputSchema,
|
||||
customWidgetSourceSchema,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetSource } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
import {
|
||||
createCustomWidgetConfigurationRequest,
|
||||
getCustomWidgetConfigurationRequestForUser,
|
||||
} from "./configuration-requests";
|
||||
import { assertSecretSources, requiredSecretKinds } from "./secret-policy";
|
||||
import { getPreviewSession } from "./preview-sessions";
|
||||
import { parseStoredCustomWidgetDefinition, serializeCustomWidgetDefinition } 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 };
|
||||
}),
|
||||
|
||||
sourceConfigure: manageProcedure
|
||||
.meta({ mcp: { enabled: true, description: "Configure one custom widget API source and its credentials." } })
|
||||
.input(
|
||||
z.object({
|
||||
definitionId: z.string(),
|
||||
sourceId: z.string(),
|
||||
baseUrl: z.string(),
|
||||
networkScope: z.enum(["public", "private", "loopback"]).optional(),
|
||||
secrets: customWidgetSecretsInputSchema.default([]),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.secrets.length > 0 && !ctx.session.user.permissions.includes("custom-widget-secret-write")) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Writing source credentials requires dedicated permission" });
|
||||
}
|
||||
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);
|
||||
const current = definition.sources[input.sourceId];
|
||||
if (!current) throw new TRPCError({ code: "NOT_FOUND", message: "Widget source not found" });
|
||||
const source = customWidgetSourceSchema.parse({
|
||||
...current,
|
||||
baseUrl: input.baseUrl,
|
||||
networkScope: input.networkScope ?? current.networkScope,
|
||||
});
|
||||
assertSecretSources({ [input.sourceId]: source }, input.secrets);
|
||||
const updated = { ...definition, sources: { ...definition.sources, [input.sourceId]: source } };
|
||||
const definitionChanges = { ...serializeCustomWidgetDefinition(updated), updatedAt: new Date() };
|
||||
const secretRows = input.secrets.map((secret) => ({
|
||||
definitionId: input.definitionId,
|
||||
sourceId: input.sourceId,
|
||||
kind: secret.kind,
|
||||
encryptedValue: encryptSecret(secret.value),
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
async handleAsync(db, schema) {
|
||||
await db.transaction(async (transaction) => {
|
||||
await transaction
|
||||
.update(schema.customWidgetDefinitions)
|
||||
.set(definitionChanges)
|
||||
.where(eq(schema.customWidgetDefinitions.id, input.definitionId));
|
||||
for (const secret of secretRows) {
|
||||
await transaction
|
||||
.delete(schema.customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.customWidgetSecrets.definitionId, input.definitionId),
|
||||
eq(schema.customWidgetSecrets.sourceId, input.sourceId),
|
||||
eq(schema.customWidgetSecrets.kind, secret.kind),
|
||||
),
|
||||
);
|
||||
await transaction.insert(schema.customWidgetSecrets).values(secret);
|
||||
}
|
||||
});
|
||||
},
|
||||
handleSync(db) {
|
||||
db.transaction((transaction) => {
|
||||
transaction
|
||||
.update(customWidgetDefinitions)
|
||||
.set(definitionChanges)
|
||||
.where(eq(customWidgetDefinitions.id, input.definitionId))
|
||||
.run();
|
||||
for (const secret of secretRows) {
|
||||
transaction
|
||||
.delete(customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(customWidgetSecrets.definitionId, input.definitionId),
|
||||
eq(customWidgetSecrets.sourceId, input.sourceId),
|
||||
eq(customWidgetSecrets.kind, secret.kind),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
transaction.insert(customWidgetSecrets).values(secret).run();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
return {
|
||||
definitionId: input.definitionId,
|
||||
sourceId: input.sourceId,
|
||||
baseUrl: source.baseUrl,
|
||||
networkScope: source.networkScope,
|
||||
configuredSecrets: input.secrets.map(({ kind }) => kind),
|
||||
};
|
||||
}),
|
||||
|
||||
configurationRequestUser: manageProcedure
|
||||
.meta({ mcp: { enabled: true, description: "Create or check a short-lived user source-configuration request." } })
|
||||
.input(secretRequestInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.requestId) {
|
||||
const request = await getCustomWidgetConfigurationRequestForUser(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: string | { type: string }; value: CustomWidgetSource } | 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;
|
||||
const candidate = definition.sources[input.sourceId ?? ""];
|
||||
source = candidate
|
||||
? {
|
||||
id: input.sourceId ?? "",
|
||||
name: candidate.name ?? input.sourceId ?? "",
|
||||
auth: candidate.auth,
|
||||
value: candidate,
|
||||
}
|
||||
: undefined;
|
||||
target = { type: "definition", id: input.definitionId };
|
||||
} else {
|
||||
const preview = await getPreviewSession(input.previewSessionId ?? "", ctx.session.user.id);
|
||||
widgetName = preview.name;
|
||||
const candidate = preview.sources[input.sourceId ?? ""];
|
||||
source = candidate
|
||||
? {
|
||||
id: input.sourceId ?? "",
|
||||
name: candidate.name ?? input.sourceId ?? "",
|
||||
auth: candidate.auth,
|
||||
value: candidate,
|
||||
}
|
||||
: undefined;
|
||||
target = { type: "preview", id: preview.id };
|
||||
}
|
||||
if (!source) throw new TRPCError({ code: "NOT_FOUND", message: "Widget source not found" });
|
||||
const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)];
|
||||
const request = await createCustomWidgetConfigurationRequest({
|
||||
userId: ctx.session.user.id,
|
||||
target,
|
||||
widgetName,
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
source: source.value,
|
||||
kinds,
|
||||
});
|
||||
return {
|
||||
requestId: request.id,
|
||||
status: request.status,
|
||||
expiresAt: request.expiresAt,
|
||||
url: new URL(`/custom-widget-configuration/${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,67 @@
|
||||
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";
|
||||
|
||||
type StoredCustomWidgetDefinition = Pick<
|
||||
CustomWidgetDefinition,
|
||||
"name" | "description" | "iconUrl" | "sources" | "requests" | "options" | "template"
|
||||
>;
|
||||
|
||||
export interface StoredCustomWidgetIssue {
|
||||
path?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type StoredCustomWidgetParseResult =
|
||||
| { success: true; widget: HomarrCustomWidgetV2 }
|
||||
| { success: false; issues: StoredCustomWidgetIssue[] };
|
||||
|
||||
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),
|
||||
options: stringifySuperJson(definition.options),
|
||||
template: definition.template,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseStoredCustomWidgetDefinition(definition: StoredCustomWidgetDefinition): 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),
|
||||
options: parseSuperJson(definition.options),
|
||||
template: definition.template,
|
||||
});
|
||||
}
|
||||
|
||||
export function safeParseStoredCustomWidgetDefinition(
|
||||
definition: StoredCustomWidgetDefinition,
|
||||
): StoredCustomWidgetParseResult {
|
||||
try {
|
||||
return { success: true, widget: parseStoredCustomWidgetDefinition(definition) };
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "issues" in error && Array.isArray(error.issues)) {
|
||||
return {
|
||||
success: false,
|
||||
issues: error.issues.slice(0, 10).map((issue: { path?: PropertyKey[]; message?: unknown }) => ({
|
||||
path: issue.path?.map(String).join("."),
|
||||
message: typeof issue.message === "string" ? issue.message : "Invalid stored widget value",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
issues: [{ message: "Stored widget data could not be read" }],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,80 +1,40 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { stringify as stringifySuperJson } from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { eq } from "@homarr/db";
|
||||
import { customWidgetDefinitions } from "@homarr/db/schema";
|
||||
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
|
||||
import { customWidgetImportSchema, customWidgetSecretsInputSchema } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
import { parseDisplayConfig } from "./parse-display-config";
|
||||
import { insertCustomWidgetDefinition } from "./definition-insert";
|
||||
import { assertSecretSources } from "./secret-policy";
|
||||
import { parseStoredCustomWidgetDefinition } 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)
|
||||
import: manageProcedure
|
||||
.input(z.object({ widget: customWidgetImportSchema, secrets: customWidgetSecretsInputSchema.default([]) }))
|
||||
.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 });
|
||||
if (input.secrets.length > 0 && !ctx.session.user.permissions.includes("custom-widget-secret-write")) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Writing imported widget secrets requires dedicated permission",
|
||||
});
|
||||
}
|
||||
assertSecretSources(input.widget.sources, input.secrets);
|
||||
const id = await insertCustomWidgetDefinition(ctx.db, input.widget, ctx.session.user.id, input.secrets);
|
||||
logger.info("Imported custom widget definition", { id, name: input.widget.name });
|
||||
return { id };
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
applyCustomWidgetSourceSetup,
|
||||
CUSTOM_WIDGET_SCHEMA,
|
||||
customWidgetDefinitionSchema,
|
||||
customWidgetIdentifierSchema,
|
||||
customWidgetSecretsInputSchema,
|
||||
getCustomWidgetSecretRequirements,
|
||||
getCustomWidgetSourceSetups,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import { WorkshopBackend } from "@homarr/workshop/backend";
|
||||
import { validateWorkshopWidget, WORKSHOP_API_URL } from "@homarr/workshop/schema";
|
||||
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
import { insertCustomWidgetDefinition } from "./definition-insert";
|
||||
import { assertSecretSources } from "./secret-policy";
|
||||
|
||||
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
|
||||
const workshop = new WorkshopBackend(process.env.WORKSHOP_API_URL ?? WORKSHOP_API_URL);
|
||||
|
||||
async function getWorkshopWidget(submissionId: string) {
|
||||
try {
|
||||
const submission = await workshop.get(submissionId);
|
||||
if (submission.type !== "customWidget" || submission.widgetSchema !== CUSTOM_WIDGET_SCHEMA) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Workshop submission is not a compatible Custom JSX widget",
|
||||
});
|
||||
}
|
||||
const validation = validateWorkshopWidget(submission.content);
|
||||
if (!validation.success) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: validation.error });
|
||||
}
|
||||
return { submission, widget: validation.data };
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
throw new TRPCError({
|
||||
code: "BAD_GATEWAY",
|
||||
message: error instanceof Error ? error.message : "Workshop is unavailable",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const workshopSearchInputSchema = z.object({
|
||||
query: z.string().trim().min(1).max(200),
|
||||
sort: z.enum(["top", "newest", "recent", "discussed"]).default("top"),
|
||||
limit: z.number().int().min(1).max(20).default(10),
|
||||
});
|
||||
|
||||
const sourceOverridesSchema = z.record(
|
||||
customWidgetIdentifierSchema,
|
||||
z.strictObject({
|
||||
baseUrl: z.string(),
|
||||
networkScope: z.enum(["public", "private", "loopback"]).optional(),
|
||||
}),
|
||||
);
|
||||
|
||||
export const workshopProcedures = {
|
||||
workshopSearch: manageProcedure
|
||||
.meta({ mcp: { enabled: true, description: "Search Workshop for Custom JSX widgets." } })
|
||||
.input(workshopSearchInputSchema)
|
||||
.query(async ({ input }) => {
|
||||
try {
|
||||
const result = await workshop.list({
|
||||
page: 1,
|
||||
perPage: input.limit,
|
||||
search: input.query,
|
||||
sort: input.sort,
|
||||
type: "customWidget",
|
||||
includeOutdated: true,
|
||||
});
|
||||
return {
|
||||
items: result.items
|
||||
.filter((item) => item.widgetSchema === CUSTOM_WIDGET_SCHEMA)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
authorName: item.authorName,
|
||||
score: item.score,
|
||||
revision: item.revision,
|
||||
outdated: item.outdated,
|
||||
reportCount: item.reportCount,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_GATEWAY",
|
||||
message: error instanceof Error ? error.message : "Workshop is unavailable",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
workshopGet: manageProcedure
|
||||
.meta({ mcp: { enabled: true, description: "Get and validate one Workshop Custom JSX widget." } })
|
||||
.input(z.object({ submissionId: z.string().min(1) }))
|
||||
.query(async ({ input }) => {
|
||||
const { submission, widget } = await getWorkshopWidget(input.submissionId);
|
||||
return {
|
||||
submission: {
|
||||
id: submission.id,
|
||||
title: submission.title,
|
||||
description: submission.description,
|
||||
authorName: submission.authorName,
|
||||
revision: submission.revision,
|
||||
score: submission.score,
|
||||
outdated: submission.outdated,
|
||||
reportCount: submission.reportCount,
|
||||
},
|
||||
widget,
|
||||
sourceSetup: getCustomWidgetSourceSetups(widget.sources),
|
||||
hasActions: Object.values(widget.requests).some((request) => request.kind === "action"),
|
||||
methods: [...new Set(Object.values(widget.requests).map((request) => request.method))],
|
||||
permissions: [...new Set(Object.values(widget.requests).map((request) => request.permission))],
|
||||
};
|
||||
}),
|
||||
|
||||
workshopInstall: manageProcedure
|
||||
.meta({ mcp: { enabled: true, description: "Install one validated Workshop Custom JSX widget." } })
|
||||
.input(
|
||||
z.object({
|
||||
submissionId: z.string().min(1),
|
||||
name: z.string().trim().min(1).max(128).optional(),
|
||||
sources: sourceOverridesSchema.default({}),
|
||||
secrets: customWidgetSecretsInputSchema.default([]),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.secrets.length > 0 && !ctx.session.user.permissions.includes("custom-widget-secret-write")) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Writing Workshop widget credentials requires dedicated permission",
|
||||
});
|
||||
}
|
||||
const { widget } = await getWorkshopWidget(input.submissionId);
|
||||
const unknownSource = Object.keys(input.sources).find((sourceId) => !widget.sources[sourceId]);
|
||||
if (unknownSource) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown Workshop widget source '${unknownSource}'` });
|
||||
}
|
||||
const configured = customWidgetDefinitionSchema.parse({
|
||||
...widget,
|
||||
name: input.name ?? widget.name,
|
||||
sources: applyCustomWidgetSourceSetup(
|
||||
widget.sources,
|
||||
Object.fromEntries(
|
||||
Object.entries(input.sources).map(([sourceId, source]) => [
|
||||
sourceId,
|
||||
{
|
||||
baseUrl: source.baseUrl,
|
||||
networkScope: source.networkScope ?? widget.sources[sourceId]?.networkScope ?? "public",
|
||||
},
|
||||
]),
|
||||
),
|
||||
),
|
||||
});
|
||||
assertSecretSources(configured.sources, input.secrets);
|
||||
const id = await insertCustomWidgetDefinition(ctx.db, configured, ctx.session.user.id, input.secrets);
|
||||
const configuredSecrets = new Set(input.secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
|
||||
return {
|
||||
status: "installed" as const,
|
||||
definitionId: id,
|
||||
sourceSetup: getCustomWidgetSourceSetups(configured.sources, input.secrets),
|
||||
missingCredentials: getCustomWidgetSecretRequirements(configured.sources)
|
||||
.filter((requirement) => !configuredSecrets.has(`${requirement.sourceId}:${requirement.kind}`))
|
||||
.map(({ sourceId, kind }) => ({ sourceId, kind })),
|
||||
};
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
claimCustomWidgetConfigurationRequest,
|
||||
completeCustomWidgetConfigurationRequest,
|
||||
createCustomWidgetConfigurationRequest,
|
||||
getCustomWidgetConfigurationRequestForUser,
|
||||
releaseCustomWidgetConfigurationRequest,
|
||||
} from "../../custom-widget/configuration-requests";
|
||||
|
||||
describe("custom widget source configuration requests", () => {
|
||||
it("is user-scoped, one-time, and reports completion without returning configuration", async () => {
|
||||
const request = await createCustomWidgetConfigurationRequest({
|
||||
userId: "user-1",
|
||||
target: { type: "preview", id: "preview-1" },
|
||||
widgetName: "Printer",
|
||||
sourceId: "default",
|
||||
sourceName: "Printer API",
|
||||
source: { baseUrl: "http://printer.local", networkScope: "private", auth: "bearer" },
|
||||
kinds: ["apiKey"],
|
||||
});
|
||||
|
||||
expect(await getCustomWidgetConfigurationRequestForUser(request.id, "user-2")).toBeNull();
|
||||
expect(await claimCustomWidgetConfigurationRequest(request.id)).toMatchObject({ status: "pending" });
|
||||
expect(await claimCustomWidgetConfigurationRequest(request.id)).toBeNull();
|
||||
await releaseCustomWidgetConfigurationRequest(request.id);
|
||||
expect(await claimCustomWidgetConfigurationRequest(request.id)).not.toBeNull();
|
||||
await completeCustomWidgetConfigurationRequest(request.id);
|
||||
expect(await getCustomWidgetConfigurationRequestForUser(request.id, "user-1")).toMatchObject({
|
||||
status: "completed",
|
||||
});
|
||||
expect(await claimCustomWidgetConfigurationRequest(request.id)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { eq, sql } from "@homarr/db";
|
||||
import { customWidgetDefinitions, customWidgetSecrets, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import { BUNDLED_CUSTOM_WIDGETS } from "@homarr/custom-widgets/core";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { customWidgetRouter } from "../../custom-widget/custom-widget-router";
|
||||
import { getCustomWidgetConfigurationRequestForUser } from "../../custom-widget/configuration-requests";
|
||||
|
||||
const userId = createId();
|
||||
const session = {
|
||||
user: {
|
||||
id: userId,
|
||||
permissions: ["custom-widget-manage", "custom-widget-secret-write"],
|
||||
colorScheme: "light",
|
||||
},
|
||||
expires: new Date().toISOString(),
|
||||
} satisfies Session;
|
||||
|
||||
const jellyfin = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-jellyfin")?.widget;
|
||||
if (!jellyfin) throw new Error("Jellyfin bundled widget is missing");
|
||||
|
||||
function createCaller(db: ReturnType<typeof createDb>) {
|
||||
return customWidgetRouter.createCaller({ db, deviceType: undefined, session });
|
||||
}
|
||||
|
||||
async function prepareDatabase() {
|
||||
const db = createDb();
|
||||
await db.insert(users).values({ id: userId });
|
||||
return db;
|
||||
}
|
||||
|
||||
function rejectSecretInserts(db: ReturnType<typeof createDb>) {
|
||||
db.run(
|
||||
sql.raw(`CREATE TRIGGER reject_custom_widget_secret_insert
|
||||
BEFORE INSERT ON custom_widget_secret
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced secret insert failure');
|
||||
END`),
|
||||
);
|
||||
}
|
||||
|
||||
const secret = { sourceId: "default", kind: "apiKey" as const, value: "test-secret" };
|
||||
|
||||
describe("custom widget definition persistence", () => {
|
||||
test("rolls back create when its secret cannot be stored", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
rejectSecretInserts(db);
|
||||
|
||||
await expect(caller.create({ ...jellyfin, secrets: [secret] })).rejects.toThrow("forced secret insert failure");
|
||||
|
||||
expect(await db.query.customWidgetDefinitions.findMany()).toHaveLength(0);
|
||||
expect(await db.query.customWidgetSecrets.findMany()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("rolls back import when its secret cannot be stored", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
rejectSecretInserts(db);
|
||||
|
||||
await expect(caller.import({ widget: jellyfin, secrets: [secret] })).rejects.toThrow(
|
||||
"forced secret insert failure",
|
||||
);
|
||||
|
||||
expect(await db.query.customWidgetDefinitions.findMany()).toHaveLength(0);
|
||||
expect(await db.query.customWidgetSecrets.findMany()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("rolls back definition and secret changes when a replacement secret cannot be stored", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const created = await caller.create({ ...jellyfin, secrets: [secret] });
|
||||
const originalDefinition = await db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, created.id),
|
||||
});
|
||||
const originalSecret = await db.query.customWidgetSecrets.findFirst({
|
||||
where: eq(customWidgetSecrets.definitionId, created.id),
|
||||
});
|
||||
rejectSecretInserts(db);
|
||||
|
||||
await expect(
|
||||
caller.update({
|
||||
id: created.id,
|
||||
name: "Changed Jellyfin Library",
|
||||
secrets: [{ ...secret, value: "replacement-secret" }],
|
||||
}),
|
||||
).rejects.toThrow("forced secret insert failure");
|
||||
|
||||
const storedDefinition = await db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, created.id),
|
||||
});
|
||||
const storedSecret = await db.query.customWidgetSecrets.findFirst({
|
||||
where: eq(customWidgetSecrets.definitionId, created.id),
|
||||
});
|
||||
expect(storedDefinition?.name).toBe(originalDefinition?.name);
|
||||
expect(storedDefinition?.updatedAt).toEqual(originalDefinition?.updatedAt);
|
||||
expect(storedSecret).toEqual(originalSecret);
|
||||
});
|
||||
|
||||
test("configures a source URL and credential together", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const created = await caller.create({ ...jellyfin, secrets: [] });
|
||||
|
||||
await caller.sourceConfigure({
|
||||
definitionId: created.id,
|
||||
sourceId: "default",
|
||||
baseUrl: "http://jellyfin.local:8096",
|
||||
networkScope: "private",
|
||||
secrets: [secret],
|
||||
});
|
||||
|
||||
const definition = await caller.get({ id: created.id });
|
||||
expect(definition.sources.default?.baseUrl).toBe("http://jellyfin.local:8096");
|
||||
expect(definition.secrets).toMatchObject([{ sourceId: "default", kind: "apiKey", hasValue: true }]);
|
||||
});
|
||||
|
||||
test("rolls back a source URL when its credential cannot be stored", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const created = await caller.create({ ...jellyfin, secrets: [] });
|
||||
const before = await caller.get({ id: created.id });
|
||||
rejectSecretInserts(db);
|
||||
|
||||
await expect(
|
||||
caller.sourceConfigure({
|
||||
definitionId: created.id,
|
||||
sourceId: "default",
|
||||
baseUrl: "http://jellyfin.local:8096",
|
||||
networkScope: "private",
|
||||
secrets: [secret],
|
||||
}),
|
||||
).rejects.toThrow("forced secret insert failure");
|
||||
|
||||
expect((await caller.get({ id: created.id })).sources.default?.baseUrl).toBe(before.sources.default?.baseUrl);
|
||||
});
|
||||
|
||||
test("uses the preview widget name in a user configuration request", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const preview = await caller.previewCreate({
|
||||
definition: { ...jellyfin, name: "Living room Jellyfin" },
|
||||
secrets: [],
|
||||
});
|
||||
|
||||
const request = await caller.configurationRequestUser({
|
||||
previewSessionId: preview.previewSession.id,
|
||||
sourceId: "default",
|
||||
});
|
||||
|
||||
expect(await getCustomWidgetConfigurationRequestForUser(request.requestId, userId)).toMatchObject({
|
||||
widgetName: "Living room Jellyfin",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { resolvePreviewRequestParams } from "../../custom-widget/preview-procedure-helpers";
|
||||
|
||||
const loadRequest: CustomJsxRequest = {
|
||||
source: "default",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
path: "/endpoints/{option:environmentId}/containers",
|
||||
auth: "inherit",
|
||||
permission: "view",
|
||||
trigger: "load",
|
||||
};
|
||||
|
||||
describe("preview request option bindings", () => {
|
||||
it("resolves load request parameters from the selected preview options", () => {
|
||||
expect(resolvePreviewRequestParams(loadRequest, { environmentId: 7 }, {})).toEqual({
|
||||
"option:environmentId": 7,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves invoking component parameters for manual requests", () => {
|
||||
const manualRequest = {
|
||||
...loadRequest,
|
||||
trigger: "manual" as const,
|
||||
path: "/endpoints/{param:endpointId}/containers",
|
||||
};
|
||||
expect(resolvePreviewRequestParams(manualRequest, { environmentId: 7 }, { endpointId: 12 })).toEqual({
|
||||
endpointId: 12,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,48 @@
|
||||
import { stringify as stringifySuperJson } from "superjson";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
BUNDLED_CUSTOM_WIDGETS,
|
||||
CUSTOM_WIDGET_STARTER,
|
||||
customWidgetDefinitionSchema,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
|
||||
import {
|
||||
safeParseStoredCustomWidgetDefinition,
|
||||
serializeCustomWidgetDefinition,
|
||||
} from "../../custom-widget/stored-definition";
|
||||
|
||||
describe("stored custom widget definitions", () => {
|
||||
test("parses a valid stored definition", () => {
|
||||
const result = safeParseStoredCustomWidgetDefinition(
|
||||
serializeCustomWidgetDefinition(customWidgetDefinitionSchema.parse(CUSTOM_WIDGET_STARTER)),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ success: true, widget: { $schema: "homarr-custom-widget-v2" } });
|
||||
});
|
||||
|
||||
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(customWidgetDefinitionSchema.parse(currency.widget));
|
||||
const result = safeParseStoredCustomWidgetDefinition({
|
||||
...stored,
|
||||
requests: stringifySuperJson({ rates: { path: "/latest/{option:missing}" } }),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
issues: expect.arrayContaining([expect.objectContaining({ path: "requests.rates" })]),
|
||||
});
|
||||
});
|
||||
|
||||
test("contains malformed serialized data", () => {
|
||||
const stored = serializeCustomWidgetDefinition(customWidgetDefinitionSchema.parse(CUSTOM_WIDGET_STARTER));
|
||||
|
||||
expect(safeParseStoredCustomWidgetDefinition({ ...stored, requests: "not-json" })).toEqual({
|
||||
success: false,
|
||||
issues: [{ message: "Stored widget data could not be read" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
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";
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
|
||||
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,
|
||||
invalidateCustomWidgetResponseCache: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../custom-widget/request-limits", () => ({
|
||||
acquireCustomWidgetRequestLimit: vi.fn(async () => async () => undefined),
|
||||
}));
|
||||
|
||||
import { serializeCustomWidgetDefinition } from "../../custom-widget/stored-definition";
|
||||
import { customApiRouter } from "../../widgets/custom-api";
|
||||
|
||||
const definition: HomarrCustomWidgetV2 = {
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: "Router test widget",
|
||||
sources: {
|
||||
default: {
|
||||
name: "API",
|
||||
baseUrl: "https://example.com",
|
||||
networkScope: "public",
|
||||
auth: "none",
|
||||
},
|
||||
},
|
||||
requests: {
|
||||
"load-status": {
|
||||
source: "default",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
path: "/status",
|
||||
auth: "inherit",
|
||||
permission: "view",
|
||||
trigger: "load",
|
||||
},
|
||||
status: {
|
||||
source: "default",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
path: "/status/{param:name}",
|
||||
auth: "inherit",
|
||||
permission: "view",
|
||||
trigger: "manual",
|
||||
},
|
||||
toggle: {
|
||||
source: "default",
|
||||
kind: "action",
|
||||
method: "POST",
|
||||
path: "/toggle",
|
||||
body: { enabled: { $param: "enabled" } },
|
||||
auth: "inherit",
|
||||
permission: "modify",
|
||||
trigger: "manual",
|
||||
},
|
||||
delete: {
|
||||
source: "default",
|
||||
kind: "action",
|
||||
method: "DELETE",
|
||||
path: "/resource",
|
||||
auth: "inherit",
|
||||
permission: "full",
|
||||
trigger: "manual",
|
||||
confirmation: { title: "Delete resource", message: "Delete this resource?", destructive: true },
|
||||
},
|
||||
},
|
||||
options: {},
|
||||
template: "<Text>{data['load-status']?.value}</Text>",
|
||||
};
|
||||
|
||||
const createSession = (userId: string): Session => ({
|
||||
user: { id: userId, permissions: [], colorScheme: "light" },
|
||||
expires: new Date(Date.now() + 60_000).toISOString(),
|
||||
});
|
||||
|
||||
async function setup(isPublic = true) {
|
||||
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,
|
||||
...serializeCustomWidgetDefinition(definition),
|
||||
creatorId: ownerId,
|
||||
});
|
||||
await db.insert(items).values({
|
||||
id: itemId,
|
||||
boardId,
|
||||
kind: "customApi",
|
||||
options: stringifySuperJson({ definitionId, configuration: {}, configurationVersion: 1 }),
|
||||
});
|
||||
return { db, ownerId, boardId, itemId, definitionId };
|
||||
}
|
||||
|
||||
describe("Custom JSX v2 board router", () => {
|
||||
beforeEach(() => mocks.executeRequest.mockClear());
|
||||
|
||||
test("allows public queries only through the definition placed on an accessible board", async () => {
|
||||
const { db, itemId } = await setup();
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
await expect(caller.queryRequest({ itemId, requestId: "status", params: { name: "homarr" } })).resolves.toEqual(
|
||||
expect.objectContaining({ ok: true, data: { value: 42 } }),
|
||||
);
|
||||
await expect(caller.queryRequest({ itemId, requestId: "missing", params: {} })).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
expect(mocks.executeRequest).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("hides private boards and disabled definitions before executing a request", async () => {
|
||||
const privateSetup = await setup(false);
|
||||
const anonymous = customApiRouter.createCaller({
|
||||
db: privateSetup.db,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
});
|
||||
await expect(
|
||||
anonymous.queryRequest({ itemId: privateSetup.itemId, requestId: "status", params: { name: "homarr" } }),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
|
||||
const enabledSetup = await setup();
|
||||
await enabledSetup.db
|
||||
.update(customWidgetDefinitions)
|
||||
.set({ enabled: false })
|
||||
.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();
|
||||
});
|
||||
|
||||
test("rejects request-kind and parameter mismatches", async () => {
|
||||
const { db, itemId, ownerId } = await setup();
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(ownerId) });
|
||||
|
||||
await expect(caller.queryRequest({ itemId, requestId: "toggle", params: { enabled: true } })).rejects.toMatchObject(
|
||||
{ code: "NOT_FOUND" },
|
||||
);
|
||||
await expect(caller.queryRequest({ itemId, requestId: "status", params: {} })).rejects.toThrow();
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("enforces modify permission and renders an authorized action body", async () => {
|
||||
const { db, itemId, boardId } = await setup(false);
|
||||
const editorId = createId();
|
||||
await db.insert(users).values({ id: editorId });
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(editorId) });
|
||||
|
||||
await expect(
|
||||
caller.executeAction({ itemId, requestId: "toggle", params: { enabled: true } }),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
await db.insert(boardUserPermissions).values({ boardId, userId: editorId, permission: "modify" });
|
||||
await expect(caller.executeAction({ itemId, requestId: "toggle", params: { enabled: true } })).resolves.toEqual(
|
||||
expect.objectContaining({ ok: true }),
|
||||
);
|
||||
expect(mocks.executeRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: "POST", body: '{"enabled":true}' }),
|
||||
);
|
||||
});
|
||||
|
||||
test("requires confirmation and full permission for DELETE actions", async () => {
|
||||
const { db, itemId, boardId, ownerId } = await setup(false);
|
||||
const owner = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(ownerId) });
|
||||
await expect(owner.executeAction({ itemId, requestId: "delete", params: {} })).rejects.toMatchObject({
|
||||
code: "PRECONDITION_FAILED",
|
||||
});
|
||||
|
||||
const editorId = createId();
|
||||
await db.insert(users).values({ id: editorId });
|
||||
await db.insert(boardUserPermissions).values({ boardId, userId: editorId, permission: "modify" });
|
||||
const editor = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(editorId) });
|
||||
await expect(
|
||||
editor.executeAction({ itemId, requestId: "delete", params: {}, confirmed: true }),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
await expect(owner.executeAction({ itemId, requestId: "delete", params: {}, confirmed: true })).resolves.toEqual(
|
||||
expect.objectContaining({ ok: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,224 +0,0 @@
|
||||
import { stringify as stringifySuperJson } from "superjson";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { eq } from "@homarr/db";
|
||||
import { boardUserPermissions, boards, customWidgetDefinitions, items, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
executeRequest: vi.fn(async () => ({ ok: true, status: 200, statusText: "OK", data: { value: 42 } })),
|
||||
}));
|
||||
|
||||
vi.mock("../../custom-widget/request-executor", () => ({
|
||||
executeCustomWidgetRequest: mocks.executeRequest,
|
||||
resolveSameOriginTarget: (_baseUrl: string, targetUrl: string | URL) => new URL(targetUrl),
|
||||
}));
|
||||
|
||||
vi.mock("../../custom-widget/request-limits", () => ({
|
||||
acquireCustomWidgetRequestLimit: vi.fn(async () => async () => undefined),
|
||||
}));
|
||||
|
||||
import { customApiRouter } from "../../widgets/custom-api";
|
||||
|
||||
const createSession = (userId: string): Session => ({
|
||||
user: { id: userId, permissions: [], colorScheme: "light" },
|
||||
expires: new Date(Date.now() + 60_000).toISOString(),
|
||||
});
|
||||
|
||||
const queryRequest = {
|
||||
id: "status",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: "/status/{name}",
|
||||
parameters: { name: "string" },
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "view",
|
||||
} as const;
|
||||
|
||||
const actionRequest = {
|
||||
id: "toggle",
|
||||
kind: "action",
|
||||
method: "POST",
|
||||
pathTemplate: "/toggle",
|
||||
parameters: { enabled: "boolean" },
|
||||
bodyTemplate: { enabled: { $param: "enabled" } },
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "modify",
|
||||
} as const;
|
||||
|
||||
const deleteRequest = {
|
||||
id: "delete",
|
||||
kind: "action",
|
||||
method: "DELETE",
|
||||
pathTemplate: "/resource",
|
||||
parameters: {},
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "full",
|
||||
} as const;
|
||||
|
||||
const setup = async ({
|
||||
isPublic = true,
|
||||
method = "GET",
|
||||
requests = [queryRequest, actionRequest, deleteRequest],
|
||||
}: {
|
||||
isPublic?: boolean;
|
||||
method?: "GET" | "POST";
|
||||
requests?: readonly Record<string, unknown>[];
|
||||
} = {}) => {
|
||||
const db = createDb();
|
||||
const ownerId = createId();
|
||||
const boardId = createId();
|
||||
const itemId = createId();
|
||||
const definitionId = createId();
|
||||
await db.insert(users).values({ id: ownerId });
|
||||
await db.insert(boards).values({ id: boardId, name: createId(), creatorId: ownerId, isPublic });
|
||||
await db.insert(customWidgetDefinitions).values({
|
||||
id: definitionId,
|
||||
name: "Custom JSX",
|
||||
url: "https://example.com/api",
|
||||
method,
|
||||
authType: "none",
|
||||
displayType: "customJsx",
|
||||
displayConfig: stringifySuperJson({
|
||||
type: "customJsx",
|
||||
jsxApiVersion: 2,
|
||||
template: "<Text>{data.value}</Text>",
|
||||
networkScope: "public",
|
||||
requests,
|
||||
}),
|
||||
creatorId: ownerId,
|
||||
});
|
||||
await db.insert(items).values({
|
||||
id: itemId,
|
||||
boardId,
|
||||
kind: "customApi",
|
||||
options: stringifySuperJson({ definitionId, refreshInterval: 30 }),
|
||||
});
|
||||
return { db, ownerId, boardId, itemId, definitionId };
|
||||
};
|
||||
|
||||
describe("customApiRouter v2", () => {
|
||||
beforeEach(() => mocks.executeRequest.mockClear());
|
||||
|
||||
test("does not expose the removed raw subFetch procedure", () => {
|
||||
expect(Object.hasOwn(customApiRouter._def.procedures, "subFetch")).toBe(false);
|
||||
});
|
||||
|
||||
test("allows anonymous queries only through a public board placement", async () => {
|
||||
const { db, itemId } = await setup();
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
const result = await caller.queryRequest({ itemId, requestId: "status", params: { name: "homarr" } });
|
||||
|
||||
expect(result).toMatchObject({ ok: true, status: 200, data: { value: 42 } });
|
||||
expect(mocks.executeRequest).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("does not expose a placed definition through an inaccessible board", async () => {
|
||||
const { db, itemId } = await setup({ isPublic: false });
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
await expect(caller.getData({ itemId })).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not execute non-GET base data requests automatically", async () => {
|
||||
const { db, itemId } = await setup({ method: "POST" });
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
await expect(caller.getData({ itemId })).resolves.toEqual({ type: "networkAccessNeedsReview" });
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("cannot select a request from a definition that is not attached to the item", async () => {
|
||||
const { db, itemId, ownerId } = await setup({ requests: [] });
|
||||
const otherDefinitionId = createId();
|
||||
await db.insert(customWidgetDefinitions).values({
|
||||
id: otherDefinitionId,
|
||||
name: "Other",
|
||||
url: "https://example.com/api",
|
||||
method: "GET",
|
||||
authType: "none",
|
||||
displayType: "customJsx",
|
||||
displayConfig: stringifySuperJson({
|
||||
type: "customJsx",
|
||||
jsxApiVersion: 2,
|
||||
template: "<Text />",
|
||||
networkScope: "public",
|
||||
requests: [queryRequest],
|
||||
}),
|
||||
creatorId: ownerId,
|
||||
});
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
await expect(
|
||||
caller.queryRequest({ itemId, requestId: "status", params: { name: "homarr" } }),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects mismatched request kinds and typed parameters", async () => {
|
||||
const { db, itemId } = await setup();
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
await expect(caller.queryRequest({ itemId, requestId: "toggle", params: { enabled: true } })).rejects.toMatchObject(
|
||||
{ code: "NOT_FOUND" },
|
||||
);
|
||||
await expect(caller.queryRequest({ itemId, requestId: "status", params: { name: 42 } })).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects disabled definitions before any network request", async () => {
|
||||
const { db, itemId, definitionId } = await setup();
|
||||
await db
|
||||
.update(customWidgetDefinitions)
|
||||
.set({ enabled: false })
|
||||
.where(eq(customWidgetDefinitions.id, definitionId));
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
await expect(caller.getData({ itemId })).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("requires modify permission for named actions", async () => {
|
||||
const { db, itemId } = await setup();
|
||||
const viewerId = createId();
|
||||
await db.insert(users).values({ id: viewerId });
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(viewerId) });
|
||||
|
||||
await expect(
|
||||
caller.executeAction({ itemId, requestId: "toggle", params: { enabled: true } }),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("executes an authorized named action once with typed body substitution", async () => {
|
||||
const { db, itemId, boardId } = await setup({ isPublic: false });
|
||||
const editorId = createId();
|
||||
await db.insert(users).values({ id: editorId });
|
||||
await db.insert(boardUserPermissions).values({ boardId, userId: editorId, permission: "modify" });
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(editorId) });
|
||||
|
||||
const result = await caller.executeAction({ itemId, requestId: "toggle", params: { enabled: true } });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(mocks.executeRequest).toHaveBeenCalledOnce();
|
||||
expect(mocks.executeRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: "POST", body: '{"enabled":true}' }),
|
||||
);
|
||||
});
|
||||
|
||||
test("requires explicit confirmation before DELETE", async () => {
|
||||
const { db, itemId, ownerId } = await setup({ isPublic: false });
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(ownerId) });
|
||||
|
||||
await expect(caller.executeAction({ itemId, requestId: "delete", params: {} })).rejects.toMatchObject({
|
||||
code: "PRECONDITION_FAILED",
|
||||
});
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -3,25 +3,28 @@ 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,
|
||||
getCustomWidgetConfirmation,
|
||||
getCustomWidgetDefaultOptions,
|
||||
validateCustomWidgetOptions,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import type { CustomJsxRequest, CustomWidgetMethod, DisplayConfig } 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 { hashRuntimeParams, renderRequestBody, renderRequestTarget } from "../custom-widget/request-manifest";
|
||||
import { executeCustomWidgetRequest, invalidateCustomWidgetResponseCache } from "../custom-widget/request-executor";
|
||||
import {
|
||||
hashRuntimeParams,
|
||||
renderRequestBody,
|
||||
renderRequestTarget,
|
||||
resolveCustomWidgetRequestValues,
|
||||
} 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 +33,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 +76,71 @@ 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 = { ...getCustomWidgetDefaultOptions(definition.options), ...itemOptions.configuration };
|
||||
const issues = validateCustomWidgetOptions(definition.options, 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 findRequest = (resolved: ResolvedDefinition, requestId: string, kind: "query" | "action") => {
|
||||
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 { id: requestId, ...request };
|
||||
};
|
||||
|
||||
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" });
|
||||
}
|
||||
return request;
|
||||
const findSource = (resolved: ResolvedDefinition, sourceId: string) => {
|
||||
const source = resolved.definition.sources[sourceId];
|
||||
if (!source) throw new TRPCError({ code: "NOT_FOUND", message: "Widget API source not found" });
|
||||
return { id: sourceId, ...source };
|
||||
};
|
||||
|
||||
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}`;
|
||||
};
|
||||
type IdentifiedRequest = CustomJsxRequest & { id: string };
|
||||
type IdentifiedSource = CustomWidgetSource & { id: string };
|
||||
|
||||
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: 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: authType,
|
||||
secrets,
|
||||
headerName:
|
||||
typeof source.auth === "object" && source.auth.type === "apiKeyHeader"
|
||||
? source.auth.name
|
||||
: typeof source.auth === "object" && source.auth.type === "apiKeyQuery"
|
||||
? source.auth.name
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const withRequestLimit = async <T>(
|
||||
ctx: RouterContext,
|
||||
resolved: ResolvedDefinition,
|
||||
category: "query" | "action" | "delete",
|
||||
request: IdentifiedRequest,
|
||||
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 +149,100 @@ const withRequestLimit = async <T>(
|
||||
}
|
||||
};
|
||||
|
||||
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: IdentifiedRequest,
|
||||
params: Record<string, string | number | boolean>,
|
||||
) => {
|
||||
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, values),
|
||||
staticHeaders: request.headers,
|
||||
auth: getAuth(resolved, source, request.auth),
|
||||
networkScope: source.networkScope,
|
||||
kind: request.kind,
|
||||
cacheKey: request.kind === "query" ? getCacheKey(resolved, request, values) : undefined,
|
||||
cacheTtlSeconds: request.cacheSeconds,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
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 = Object.entries(resolved.definition.requests).filter(
|
||||
([, request]) => request.kind === "query" && request.trigger === "load",
|
||||
);
|
||||
const entries = await Promise.all(
|
||||
loadRequests.map(async ([requestId, request]) => {
|
||||
try {
|
||||
const response = await executeRequest(ctx, resolved, { id: requestId, ...request }, {});
|
||||
return [
|
||||
requestId,
|
||||
{
|
||||
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 [
|
||||
requestId,
|
||||
{
|
||||
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,
|
||||
requestCapabilities: Object.entries(resolved.definition.requests).map(([id, request]) => ({
|
||||
id,
|
||||
kind: request.kind,
|
||||
method: request.method,
|
||||
trigger: request.trigger,
|
||||
minimumBoardPermission: request.permission,
|
||||
confirmation: getCustomWidgetConfirmation(request),
|
||||
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");
|
||||
if (request.trigger !== "manual") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Load queries cannot be invoked manually" });
|
||||
}
|
||||
const response = await executeRequest(ctx, resolved, request, input.params);
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
@@ -205,71 +252,31 @@ export const customApiRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
|
||||
executeBaseAction: protectedProcedure
|
||||
.input(itemInputSchema.extend({ confirmed: z.boolean().optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
|
||||
const { definition } = resolved;
|
||||
if (definition.displayType !== "actionButton") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Only actionButton widgets can use this action" });
|
||||
}
|
||||
|
||||
const permission: BoardPermission = definition.method === "DELETE" ? "full" : "modify";
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, resolved.item.boardId), permission);
|
||||
if (definition.method === "DELETE" && input.confirmed !== true) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "DELETE actions require confirmation" });
|
||||
}
|
||||
|
||||
const response = await withRequestLimit(ctx, resolved, definition.method === "DELETE" ? "delete" : "action", () =>
|
||||
executeCustomWidgetRequest({
|
||||
baseUrl: definition.url,
|
||||
method: definition.method,
|
||||
body: definition.method === "GET" ? undefined : (definition.requestBody ?? undefined),
|
||||
auth: getAuth(resolved),
|
||||
networkScope: getNetworkScope(resolved.displayConfig),
|
||||
kind: definition.method === "GET" ? "query" : "action",
|
||||
}),
|
||||
);
|
||||
return {
|
||||
success: response.ok,
|
||||
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
|
||||
responseInfo: { status: response.status, statusText: response.statusText },
|
||||
};
|
||||
}),
|
||||
|
||||
executeAction: protectedProcedure
|
||||
.input(namedRequestInputSchema.extend({ confirmed: z.boolean().optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
|
||||
const request = findNamedRequest(resolved, input.requestId, "action");
|
||||
if (request.method === "DELETE" && input.confirmed !== true) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "DELETE actions require confirmation" });
|
||||
const request = findRequest(resolved, input.requestId, "action");
|
||||
const needsConfirmation = request.confirmation !== undefined || request.method === "DELETE";
|
||||
if (needsConfirmation && input.confirmed !== true) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "This action requires confirmation" });
|
||||
}
|
||||
const response = await executeRequest(ctx, resolved, request, input.params);
|
||||
if (response.ok && request.invalidates?.length) {
|
||||
invalidateCustomWidgetResponseCache(
|
||||
request.invalidates.flatMap((requestId) => [
|
||||
`custom-jsx:${resolved.item.id}:${getCustomWidgetCacheVersion(resolved.stored)}:${requestId}:`,
|
||||
`custom-widget:options:${resolved.stored.id}:${getCustomWidgetCacheVersion(resolved.stored)}:${requestId}:`,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
await throwIfActionForbiddenAsync(
|
||||
ctx,
|
||||
eq(boards.id, resolved.item.boardId),
|
||||
request.minimumBoardPermission as BoardPermission,
|
||||
);
|
||||
const targetUrl = renderRequestTarget(resolved.definition.url, request, input.params);
|
||||
const response = await withRequestLimit(ctx, resolved, request.method === "DELETE" ? "delete" : "action", () =>
|
||||
executeCustomWidgetRequest({
|
||||
baseUrl: resolved.definition.url,
|
||||
targetUrl,
|
||||
method: request.method as CustomWidgetMethod,
|
||||
body: renderRequestBody(request.bodyTemplate, input.params),
|
||||
staticHeaders: request.staticHeaders,
|
||||
auth: getAuth(resolved, request.auth),
|
||||
networkScope: getV2Config(resolved).networkScope,
|
||||
kind: "action",
|
||||
}),
|
||||
);
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
data: response.data,
|
||||
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
|
||||
invalidates: request.invalidates ?? [],
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -38,17 +38,27 @@ 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_sourceConfigure");
|
||||
expect(toolNames).toContain("customWidget_configurationRequestUser");
|
||||
expect(toolNames).toContain("customWidget_workshopSearch");
|
||||
expect(toolNames).toContain("customWidget_workshopGet");
|
||||
expect(toolNames).toContain("customWidget_workshopInstall");
|
||||
expect(toolNames).toContain("customWidget_getAuthoringPrompt");
|
||||
expect(toolNames).toContain("customWidget_getSkill");
|
||||
expect(toolNames).not.toContain("customWidget_import");
|
||||
expect(toolNames).not.toContain("customWidget_export");
|
||||
expect(toolNames).not.toContain("customWidget_simulatePreviewAction");
|
||||
expect(toolNames).toContain("info_getInfo");
|
||||
expect(toolNames).toContain("invite_getAll");
|
||||
expect(toolNames).toContain("serverSettings_getBoardSettings");
|
||||
@@ -61,13 +71,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 +94,22 @@ test("custom widget public procedures remain available", () => {
|
||||
"validate",
|
||||
"readTemplate",
|
||||
"writeTemplate",
|
||||
"patchTemplate",
|
||||
"preview",
|
||||
"templatePatch",
|
||||
"previewCreate",
|
||||
"previewGet",
|
||||
"previewQuery",
|
||||
"previewAction",
|
||||
"simulatePreviewAction",
|
||||
"setPreviewLiveActions",
|
||||
"previewJournal",
|
||||
"secretSet",
|
||||
"sourceConfigure",
|
||||
"configurationRequestUser",
|
||||
"workshopSearch",
|
||||
"workshopGet",
|
||||
"workshopInstall",
|
||||
"secretClear",
|
||||
"getAuthoringPrompt",
|
||||
"getSkill",
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -13,8 +13,9 @@ import type { OpenApiMeta } from "trpc-to-openapi";
|
||||
import { ZodError } from "zod/v4";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { FlattenError } from "@homarr/common";
|
||||
import { extractBaseUrlFromHeaders, FlattenError } from "@homarr/common";
|
||||
import { userAgent } from "@homarr/common/server";
|
||||
import type { DeviceType } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { db } from "@homarr/db";
|
||||
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
|
||||
@@ -36,7 +37,14 @@ const logger = createLogger({ module: "trpc" });
|
||||
*
|
||||
* @see https://trpc.io/docs/server/context
|
||||
*/
|
||||
export const createTRPCContext = (opts: { headers: Headers; session: Session | null }) => {
|
||||
interface ApiContext {
|
||||
session: Session | null;
|
||||
deviceType: DeviceType;
|
||||
baseUrl?: `${string}://${string}`;
|
||||
db: typeof db;
|
||||
}
|
||||
|
||||
export const createTRPCContext = (opts: { headers: Headers; session: Session | null }): ApiContext => {
|
||||
const session = opts.session;
|
||||
const source = opts.headers.get("x-trpc-source") ?? "unknown";
|
||||
|
||||
@@ -49,6 +57,7 @@ export const createTRPCContext = (opts: { headers: Headers; session: Session | n
|
||||
return {
|
||||
session,
|
||||
deviceType: userAgent(opts.headers).device.type,
|
||||
baseUrl: extractBaseUrlFromHeaders(opts.headers),
|
||||
db,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user