feat: add custom widget flow workbench

This commit is contained in:
ajnart committed 2026-09-12 22:51:46 +02:00
1 parent c32a5317ca
commit a6bdbbb4ea
259 files changed
+23875 -1293

No files matched your search

@@ -3,6 +3,7 @@ import { createHash } from "node:crypto";
interface CacheVersionDefinition {
sources: string;
requests: string;
extensions?: string | null;
secrets: Array<{ sourceId: string; kind: string; encryptedValue: string }>;
}
@@ -11,7 +12,7 @@ export function getCustomWidgetCacheVersion(definition: CacheVersionDefinition)
.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]))
.update(JSON.stringify([definition.sources, definition.requests, definition.extensions ?? null, secrets]))
.digest("hex")
.slice(0, 16);
}
@@ -0,0 +1,135 @@
import { createHash } from "node:crypto";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { customWidgetIdentifierSchema, isCustomWidgetPreferenceValue } from "@homarr/custom-widgets/core";
import { and, eq, inArray } from "@homarr/db";
import { boards, customWidgetContent } from "@homarr/db/schema";
import { protectedProcedure, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
import { resolvePlacedDefinitionAsync } from "./placed-definition";
import { assertCustomWidgetDefinitionChanged } from "./stored-definition-state";
import { acquireCustomWidgetRequestLimit } from "./request-limits";
const itemInput = z.object({ itemId: z.string().min(1).max(128) });
const contentValueSchema = z.union([
z.string().max(8192),
z.number().finite(),
z.boolean(),
z.array(z.string().max(512)).max(256),
z.array(z.number().finite()).max(256),
]);
const contentKey = (name: string) => createHash("sha256").update(name).digest("hex");
function readValue(serialized: string, type: string, fallback: z.infer<typeof contentValueSchema>) {
try {
const parsed = contentValueSchema.safeParse(JSON.parse(serialized));
if (parsed.success && isCustomWidgetPreferenceValue(type, parsed.data)) return parsed.data;
} catch {
/* A changed declaration falls back to its current default. */
}
return fallback;
}
function isDuplicate(error: unknown, depth = 0): boolean {
if (depth > 4 || !error || typeof error !== "object") return false;
if (
"code" in error &&
["SQLITE_CONSTRAINT_PRIMARYKEY", "SQLITE_CONSTRAINT_UNIQUE", "23505", "ER_DUP_ENTRY"].includes(String(error.code))
)
return true;
return "cause" in error && isDuplicate(error.cause, depth + 1);
}
export const contentProcedures = {
contentRead: publicProcedure.input(itemInput).query(async ({ ctx, input }) => {
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
const declarations = resolved.definition.extensions?.content ?? {};
if (Object.keys(declarations).length === 0) return { values: {} };
const rows = await ctx.db
.select()
.from(customWidgetContent)
.where(
and(
eq(customWidgetContent.itemId, input.itemId),
eq(customWidgetContent.definitionId, resolved.stored.id),
inArray(customWidgetContent.key, Object.keys(declarations).map(contentKey)),
),
);
const byKey = new Map(rows.map((row) => [row.key, row]));
return {
values: Object.fromEntries(
Object.entries(declarations).map(([name, declaration]) => {
const row = byKey.get(contentKey(name));
if (!row) return [name, { value: declaration.defaultValue, revision: 0 }];
return [
name,
{ value: readValue(row.value, declaration.type, declaration.defaultValue), revision: row.revision },
];
}),
),
};
}),
contentWrite: protectedProcedure
.input(
itemInput.extend({
name: customWidgetIdentifierSchema,
expectedRevision: z.number().int().min(0).max(2_147_483_646),
value: contentValueSchema,
}),
)
.mutation(async ({ ctx, input }) => {
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
const declaration = resolved.definition.extensions?.content?.[input.name];
if (!declaration)
throw new TRPCError({ code: "NOT_FOUND", message: "Shared content is not declared by this widget" });
await throwIfActionForbiddenAsync(ctx, eq(boards.id, resolved.item.boardId), declaration.permission);
if (!isCustomWidgetPreferenceValue(declaration.type, input.value)) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Shared content does not match its declared type" });
}
const revision = input.expectedRevision + 1;
const entry = {
itemId: input.itemId,
definitionId: resolved.stored.id,
key: contentKey(input.name),
name: input.name,
value: JSON.stringify(input.value),
revision,
updatedAt: new Date(),
};
const release = await acquireCustomWidgetRequestLimit({
category: "action",
userId: ctx.session.user.id,
itemId: input.itemId,
definitionId: resolved.stored.id,
});
try {
if (input.expectedRevision === 0) {
await ctx.db.insert(customWidgetContent).values(entry);
} else {
const result = await ctx.db
.update(customWidgetContent)
.set({ value: entry.value, revision, updatedAt: entry.updatedAt })
.where(
and(
eq(customWidgetContent.itemId, input.itemId),
eq(customWidgetContent.definitionId, resolved.stored.id),
eq(customWidgetContent.key, entry.key),
eq(customWidgetContent.revision, input.expectedRevision),
),
);
assertCustomWidgetDefinitionChanged(result);
}
} catch (error) {
if (isDuplicate(error) || (error instanceof TRPCError && error.code === "CONFLICT")) {
throw new TRPCError({ code: "CONFLICT", message: "Shared content changed. Reload it before saving again." });
}
throw error;
} finally {
await release();
}
return { name: input.name, value: input.value, revision };
}),
};
@@ -42,10 +42,10 @@ export const creationProcedures = {
})
.input(customWidgetCreateSchema)
.mutation(async ({ ctx, input }) => {
const { secrets, ...candidate } = input;
const { secrets, editorLayout, ...candidate } = input;
const definition = parseCustomWidgetAuthoringInput(() => normalizeCustomWidgetAuthoringDefinition(candidate));
assertSecretSources(definition.sources, secrets);
const id = await insertCustomWidgetDefinition(ctx.db, definition, ctx.session.user.id, secrets);
const id = await insertCustomWidgetDefinition(ctx.db, definition, ctx.session.user.id, secrets, editorLayout);
logger.info("Created custom widget definition", { id, name: definition.name });
return getCreatedCustomWidgetResult(id);
}),
@@ -100,9 +100,28 @@ export const creationProcedures = {
});
}
const unverifiedNative = Object.entries(session.extensions?.native ?? {})
.filter(
([id, capability]) =>
!evidence.some(
(entry) =>
entry.requestId === `native:${id}` &&
entry.kind === capability.kind &&
entry.sessionRevision === session.revision &&
(entry.simulated || (entry.status !== null && entry.status >= 200 && entry.status < 300)),
),
)
.map(([id]) => id);
if (unverifiedNative.length)
throw new TRPCError({
code: "BAD_REQUEST",
message: `Test every native capability before creating the widget: ${unverifiedNative.join(", ")}`,
});
const definition = parseCustomWidgetAuthoringInput(() =>
customWidgetDefinitionSchema.parse({
$schema: "homarr-custom-widget-v2",
$schema: session.extensions ? "homarr-custom-widget-v3" : "homarr-custom-widget-v2",
extensions: session.extensions,
name: session.name,
description: session.description,
iconUrl: session.iconUrl,
@@ -1,19 +1,15 @@
import { contentProcedures } from "./content-procedures";
import { nativeProcedures } from "./native-procedures";
import { workshopUpdateProcedures } from "./workshop-update-procedures";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { and, eq, handleTransactionsAsync, notInArray } from "@homarr/db";
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
import {
customWidgetDefinitionSchema,
customWidgetUpdateSchema,
normalizeCustomWidgetAuthoringUpdate,
} from "@homarr/custom-widgets/core";
import { eq } from "@homarr/db";
import { customWidgetDefinitions } from "@homarr/db/schema";
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
import { parseCustomWidgetAuthoringInput } from "./authoring-validation";
import { creationProcedures } from "./creation-procedures";
import { managementQueryProcedures } from "./management-queries";
import { metadataProcedures } from "./metadata-procedures";
@@ -27,160 +23,24 @@ import {
} from "./stored-definition";
import { templateProcedures } from "./template-procedures";
import { transferProcedures } from "./transfer-procedures";
import { assertSecretSources, hasSameSecretBinding, requiredSecretKinds } from "./secret-policy";
import { secretProcedures } from "./secret-procedures";
import { workshopProcedures } from "./workshop-procedures";
import { updateCustomWidgetProcedure } from "./definition-update-procedure";
const logger = createLogger({ module: "custom-widget" });
export const customWidgetRouter = createTRPCRouter({
...metadataProcedures,
...nativeProcedures,
...contentProcedures,
...managementQueryProcedures,
...creationProcedures,
update: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Update one Custom JSX widget. Prefer templateLines for multiline JSX changes. Returns a client-navigable edit link.",
},
})
.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" });
const current = parseStoredCustomWidgetDefinition(existing);
const { id, secrets, ...authoringChanges } = input;
const definition = parseCustomWidgetAuthoringInput(() => {
const changes = normalizeCustomWidgetAuthoringUpdate(authoringChanges);
return 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 changedSecretBindings = new Set(
Object.entries(definition.sources).flatMap(([sourceId, source]) => {
const previous = current.sources[sourceId];
return previous && !hasSameSecretBinding(previous, source) ? [sourceId] : [];
}),
);
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));
await transaction
.delete(schema.customWidgetSecrets)
.where(
and(
eq(schema.customWidgetSecrets.definitionId, id),
notInArray(schema.customWidgetSecrets.sourceId, sourceIds),
),
);
for (const sourceId of changedSecretBindings) {
await transaction
.delete(schema.customWidgetSecrets)
.where(
and(
eq(schema.customWidgetSecrets.definitionId, id),
eq(schema.customWidgetSecrets.sourceId, sourceId),
),
);
}
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);
}
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();
transaction
.delete(customWidgetSecrets)
.where(and(eq(customWidgetSecrets.definitionId, id), notInArray(customWidgetSecrets.sourceId, sourceIds)))
.run();
for (const sourceId of changedSecretBindings) {
transaction
.delete(customWidgetSecrets)
.where(and(eq(customWidgetSecrets.definitionId, id), eq(customWidgetSecrets.sourceId, sourceId)))
.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 });
return { id, managementPath: `/manage/custom-widgets/edit/${id}` };
}),
update: updateCustomWidgetProcedure,
...secretProcedures,
...workshopProcedures,
...workshopUpdateProcedures,
toggleEnabled: permissionRequiredProcedure
.requiresPermission("admin")
@@ -3,7 +3,12 @@ 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 type {
CustomWidgetCreateInput,
HomarrCustomWidgetV2,
CustomWidgetEditorLayout,
CustomWidgetWorkshopOrigin,
} from "@homarr/custom-widgets/core";
import { serializeCustomWidgetDefinition } from "./stored-definition";
@@ -12,9 +17,17 @@ export async function insertCustomWidgetDefinition(
definition: HomarrCustomWidgetV2,
creatorId: string,
secrets: CustomWidgetCreateInput["secrets"],
editorLayout?: CustomWidgetEditorLayout,
workshopOrigin?: CustomWidgetWorkshopOrigin,
) {
const id = createId();
const definitionRow = { id, ...serializeCustomWidgetDefinition(definition), creatorId };
const definitionRow = {
workshopOrigin: workshopOrigin ? JSON.stringify(workshopOrigin) : null,
id,
...serializeCustomWidgetDefinition(definition),
creatorId,
editorLayout: editorLayout ? JSON.stringify(editorLayout) : null,
};
const updatedAt = new Date();
const secretRows = secrets.map((secret) => ({
definitionId: id,
@@ -0,0 +1,234 @@
import { TRPCError } from "@trpc/server";
import { encryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { and, eq, handleTransactionsAsync, notInArray } from "@homarr/db";
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
import {
customWidgetDefinitionSchema,
customWidgetUpdateSchema,
normalizeCustomWidgetAuthoringUpdate,
} from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure } from "../../trpc";
import { parseCustomWidgetAuthoringInput } from "./authoring-validation";
import { parseStoredCustomWidgetDefinition, serializeCustomWidgetDefinition } from "./stored-definition";
import {
assertCustomWidgetDefinitionChanged,
customWidgetDefinitionMatch,
customWidgetSavedRevision,
} from "./stored-definition-state";
import { assertSecretSources, hasSameSecretBinding, requiredSecretKinds } from "./secret-policy";
import { prepareSourceSecretRenames } from "./source-secret-renames";
const logger = createLogger({ module: "custom-widget" });
export const updateCustomWidgetProcedure = permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Update one Custom JSX widget. Prefer templateLines for multiline JSX changes. Returns a client-navigable edit link.",
},
})
.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 (input.expectedSavedRevision && customWidgetSavedRevision(existing) !== input.expectedSavedRevision) {
throw new TRPCError({
code: "CONFLICT",
message: "The saved widget changed. Reload or review the saved version before saving your draft.",
});
}
const current = parseStoredCustomWidgetDefinition(existing);
const {
id,
secrets,
sourceRenames,
editorLayout,
expectedSavedRevision: _expectedSavedRevision,
...authoringChanges
} = input;
const definition = parseCustomWidgetAuthoringInput(() => {
const changes = normalizeCustomWidgetAuthoringUpdate(authoringChanges);
const candidate = { ...current, ...changes };
// An explicit return to v2 removes inherited extensions; supplied extensions still fail validation.
if (changes.$schema === "homarr-custom-widget-v2" && changes.extensions === undefined)
delete candidate.extensions;
return customWidgetDefinitionSchema.parse(candidate);
});
if (secrets) assertSecretSources(definition.sources, secrets);
const secretRenames = prepareSourceSecretRenames(current.sources, definition.sources, sourceRenames);
const renamedTargets = new Set(secretRenames.map((rename) => rename.to));
const definitionChanges = {
...serializeCustomWidgetDefinition(definition),
updatedAt: new Date(),
...(editorLayout ? { editorLayout: JSON.stringify(editorLayout) } : {}),
};
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 changedSecretBindings = new Set(
Object.entries(definition.sources).flatMap(([sourceId, source]) => {
const previous = current.sources[sourceId];
return previous && !renamedTargets.has(sourceId) && !hasSameSecretBinding(previous, source) ? [sourceId] : [];
}),
);
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
const result = await transaction
.update(schema.customWidgetDefinitions)
.set(definitionChanges)
.where(customWidgetDefinitionMatch(existing, Boolean(editorLayout)));
assertCustomWidgetDefinitionChanged(result);
for (const rename of secretRenames) {
await transaction
.update(schema.customWidgetSecrets)
.set({ sourceId: rename.temporary })
.where(
and(
eq(schema.customWidgetSecrets.definitionId, id),
eq(schema.customWidgetSecrets.sourceId, rename.from),
),
);
}
for (const rename of secretRenames) {
await transaction
.update(schema.customWidgetSecrets)
.set({ sourceId: rename.to })
.where(
and(
eq(schema.customWidgetSecrets.definitionId, id),
eq(schema.customWidgetSecrets.sourceId, rename.temporary),
),
);
}
await transaction
.delete(schema.customWidgetSecrets)
.where(
and(
eq(schema.customWidgetSecrets.definitionId, id),
notInArray(schema.customWidgetSecrets.sourceId, sourceIds),
),
);
for (const sourceId of changedSecretBindings) {
await transaction
.delete(schema.customWidgetSecrets)
.where(
and(eq(schema.customWidgetSecrets.definitionId, id), eq(schema.customWidgetSecrets.sourceId, sourceId)),
);
}
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);
}
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) => {
const result = transaction
.update(customWidgetDefinitions)
.set(definitionChanges)
.where(customWidgetDefinitionMatch(existing, Boolean(editorLayout)))
.run();
assertCustomWidgetDefinitionChanged(result);
for (const rename of secretRenames) {
transaction
.update(customWidgetSecrets)
.set({ sourceId: rename.temporary })
.where(and(eq(customWidgetSecrets.definitionId, id), eq(customWidgetSecrets.sourceId, rename.from)))
.run();
}
for (const rename of secretRenames) {
transaction
.update(customWidgetSecrets)
.set({ sourceId: rename.to })
.where(and(eq(customWidgetSecrets.definitionId, id), eq(customWidgetSecrets.sourceId, rename.temporary)))
.run();
}
transaction
.delete(customWidgetSecrets)
.where(and(eq(customWidgetSecrets.definitionId, id), notInArray(customWidgetSecrets.sourceId, sourceIds)))
.run();
for (const sourceId of changedSecretBindings) {
transaction
.delete(customWidgetSecrets)
.where(and(eq(customWidgetSecrets.definitionId, id), eq(customWidgetSecrets.sourceId, sourceId)))
.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 });
const secretPresence = await ctx.db
.select({ sourceId: customWidgetSecrets.sourceId, kind: customWidgetSecrets.kind })
.from(customWidgetSecrets)
.where(eq(customWidgetSecrets.definitionId, id));
return {
id,
secretPresence,
managementPath: `/manage/custom-widgets/edit/${id}`,
savedRevision: customWidgetSavedRevision({ ...existing, ...definitionChanges }),
};
});
@@ -1,3 +1,4 @@
import { customWidgetSavedRevision } from "./stored-definition-state";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
@@ -88,6 +89,9 @@ export const managementQueryProcedures = {
id: definition.id,
managementPath: `/manage/custom-widgets/edit/${definition.id}`,
...parseStoredCustomWidgetDefinition(definition),
editorLayout: definition.editorLayout,
savedRevision: customWidgetSavedRevision(definition),
workshopOrigin: definition.workshopOrigin,
enabled: definition.enabled,
createdAt: definition.createdAt,
updatedAt: definition.updatedAt,
@@ -0,0 +1,73 @@
import { z } from "zod/v4";
const id = z.string().min(1).max(512);
const empty = z.object({});
/** Explicit service contracts; authored code never chooses a procedure path. */
export const nativeCapabilities = {
"beszel.systems": { kind: "query", integration: "beszel", input: empty },
"beszel.history": {
kind: "query",
integration: "beszel",
input: z.object({
systemId: id,
timePeriod: z.enum(["1m", "1h", "12h", "24h", "1w", "30d"]).default("1h"),
includeDocker: z.boolean().default(true),
}),
},
"beszel.alerts": {
kind: "query",
integration: "beszel",
input: z.object({
includeHistory: z.boolean().default(true),
maxHistoryItems: z.number().int().min(1).max(100).default(10),
}),
},
"beszel.live": { kind: "query", integration: "beszel", input: z.object({ systemId: id }) },
"calendar.events": {
kind: "query",
integration: "calendar",
input: z.object({
year: z.number().int().min(1970).max(9999),
month: z.number().int().min(1).max(12),
releaseType: z
.array(z.enum(["inCinemas", "digitalRelease", "physicalRelease"]))
.default(["inCinemas", "digitalRelease", "physicalRelease"]),
showUnmonitored: z.boolean().default(false),
}),
},
"media.requests": {
kind: "query",
integration: "mediaRequest",
input: z.object({
statuses: z
.array(z.enum(["approved", "completed", "declined", "failed", "pending"]))
.min(1)
.default(["pending", "approved", "declined", "failed", "completed"]),
recentDays: z.number().int().min(0).max(365).default(30),
}),
},
"media.stats": { kind: "query", integration: "mediaRequest", input: empty },
"media.respond": {
kind: "action",
integration: "mediaRequest",
input: z.object({ requestId: z.number().int(), answer: z.enum(["approve", "decline"]) }),
},
"smartHome.state": { kind: "query", integration: "homeAssistant", input: z.object({ entityId: id }) },
"smartHome.details": { kind: "query", integration: "homeAssistant", input: z.object({ entityId: id }) },
"smartHome.switch": { kind: "action", integration: "homeAssistant", input: z.object({ entityId: id }) },
"smartHome.automation": { kind: "action", integration: "homeAssistant", input: z.object({ automationId: id }) },
"docker.containers": {
kind: "query",
integration: null,
input: z.object({ endpointIds: z.array(id).max(100).optional() }),
},
"docker.start": { kind: "action", integration: null, input: z.object({ endpointId: id, containerId: id }) },
"docker.stop": { kind: "action", integration: null, input: z.object({ endpointId: id, containerId: id }) },
"docker.restart": { kind: "action", integration: null, input: z.object({ endpointId: id, containerId: id }) },
} as const;
export type NativeCapabilityId = keyof typeof nativeCapabilities;
export function isNativeCapabilityId(value: string): value is NativeCapabilityId {
return Object.hasOwn(nativeCapabilities, value);
}
@@ -0,0 +1,101 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { isRecord } from "@homarr/common";
import { collectCustomWidgetRequestReferences } from "@homarr/custom-widgets/core";
import type { CustomWidgetNativeCapability } from "@homarr/custom-widgets/core";
import { eq } from "@homarr/db";
import { boards } from "@homarr/db/schema";
import type { createTRPCContext } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
import { resolvePlacedDefinitionAsync } from "./placed-definition";
import { getPreviewSession } from "./preview-sessions";
import { isNativeCapabilityId, nativeCapabilities } from "./native-catalog";
export type NativeContext = ReturnType<typeof createTRPCContext>;
export const nativeInputSchema = z
.object({
itemId: z.string().min(1).optional(),
previewSessionId: z.string().min(1).optional(),
nativeId: z.string().min(1).max(64),
params: z.record(z.string().max(64), z.union([z.string().max(8192), z.number().finite(), z.boolean()])).default({}),
})
.refine(
(value) => Boolean(value.itemId) !== Boolean(value.previewSessionId),
"Choose one widget item or preview session",
);
function resolveInput(
value: unknown,
options: Record<string, unknown>,
params: Record<string, unknown>,
depth = 0,
): unknown {
if (depth > 12) throw new TRPCError({ code: "BAD_REQUEST", message: "Native input is too deeply nested" });
if (Array.isArray(value)) return value.map((entry) => resolveInput(entry, options, params, depth + 1));
if (!isRecord(value)) return value;
if (typeof value.$option === "string") return options[value.$option];
if (typeof value.$param === "string") return params[value.$param];
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, resolveInput(entry, options, params, depth + 1)]),
);
}
export async function resolveNative(
ctx: NativeContext,
input: z.infer<typeof nativeInputSchema>,
kind: "query" | "action",
) {
const preview = input.previewSessionId;
let session;
let descriptor: CustomWidgetNativeCapability | undefined;
let options: Record<string, unknown>;
let definitionId: string;
if (preview) {
if (!ctx.session?.user.permissions.includes("admin")) throw new TRPCError({ code: "FORBIDDEN" });
session = await getPreviewSession(preview, ctx.session.user.id);
descriptor = session.extensions?.native?.[input.nativeId];
options = session.options;
definitionId = session.definitionId ?? `preview:${preview}`;
} else {
if (!input.itemId) throw new TRPCError({ code: "BAD_REQUEST", message: "Widget item is required" });
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
descriptor = resolved.definition.extensions?.native?.[input.nativeId];
options = resolved.configuration;
definitionId = resolved.stored.id;
if (descriptor) await throwIfActionForbiddenAsync(ctx, eq(boards.id, resolved.item.boardId), descriptor.permission);
}
if (!descriptor || descriptor.kind !== kind || !isNativeCapabilityId(descriptor.capability)) {
throw new TRPCError({ code: "NOT_FOUND", message: "Native capability is unavailable" });
}
const capability = descriptor.capability;
const contract = nativeCapabilities[capability];
if (contract.kind !== kind)
throw new TRPCError({ code: "BAD_REQUEST", message: "Native capability kind does not match" });
const integrationIds: string[] = [];
if (contract.integration) {
const configured = options[descriptor.integrationOption ?? ""];
if (typeof configured === "string" && configured) integrationIds.push(configured);
else if (Array.isArray(configured) && configured.every((id) => typeof id === "string" && id))
integrationIds.push(...configured);
if (!integrationIds.length || integrationIds.length > 16)
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "Configure the native capability's integration option",
});
}
const expectedParams = collectCustomWidgetRequestReferences({ path: "", body: descriptor.input }).params;
if (
Object.keys(input.params).some((name) => !expectedParams.has(name)) ||
[...expectedParams].some((name) => !Object.hasOwn(input.params, name))
) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Supply exactly the native capability's declared invocation parameters",
});
}
const values = contract.input.parse(resolveInput(descriptor.input, options, input.params));
return { capability, descriptor, values, integrationIds, definitionId, session };
}
export type ResolvedNative = Awaited<ReturnType<typeof resolveNative>>;
@@ -0,0 +1,88 @@
import { TRPCError } from "@trpc/server";
import { beszelRouter } from "../widgets/beszel";
import { calendarRouter } from "../widgets/calendar";
import { mediaRequestsRouter } from "../widgets/media-requests";
import { smartHomeRouter } from "../widgets/smart-home";
import { dockerRouter } from "../docker/docker-router";
import { nativeCapabilities as catalog } from "./native-catalog";
import type { NativeContext, ResolvedNative } from "./native-context";
export async function queryNative(ctx: NativeContext, resolved: ResolvedNative) {
const { capability, values, integrationIds } = resolved;
const integrationId = integrationIds[0] ?? "";
switch (capability) {
case "beszel.systems":
return beszelRouter.createCaller(ctx).getSystems({ integrationIds });
case "beszel.history":
return beszelRouter
.createCaller(ctx)
.getSystemStats({ integrationIds, ...catalog[capability].input.parse(values) });
case "beszel.live":
return beszelRouter.createCaller(ctx).getSystemStats({
integrationIds,
...catalog[capability].input.parse(values),
timePeriod: "1m",
includeDocker: true,
});
case "beszel.alerts":
return beszelRouter.createCaller(ctx).getAlerts({ integrationIds, ...catalog[capability].input.parse(values) });
case "calendar.events":
return calendarRouter
.createCaller(ctx)
.findAllEvents({ integrationIds, ...catalog[capability].input.parse(values) });
case "media.requests":
return mediaRequestsRouter
.createCaller(ctx)
.getLatestRequests({ integrationIds, ...catalog[capability].input.parse(values) });
case "media.stats":
return mediaRequestsRouter.createCaller(ctx).getStats({ integrationIds });
case "smartHome.state":
return smartHomeRouter
.createCaller(ctx)
.entityState({ integrationId, ...catalog[capability].input.parse(values) });
case "smartHome.details":
return smartHomeRouter
.createCaller(ctx)
.entityDetails({ integrationId, ...catalog[capability].input.parse(values) });
case "docker.containers":
return dockerRouter.createCaller(ctx).getContainers(catalog[capability].input.parse(values));
default:
throw new TRPCError({ code: "BAD_REQUEST", message: "Capability is not a query" });
}
}
export async function actionNative(ctx: NativeContext, resolved: ResolvedNative) {
const { capability, values, integrationIds } = resolved;
const integrationId = integrationIds[0] ?? "";
switch (capability) {
case "media.respond":
return mediaRequestsRouter
.createCaller(ctx)
.answerRequest({ integrationId, ...catalog[capability].input.parse(values) });
case "smartHome.switch":
return smartHomeRouter
.createCaller(ctx)
.switchEntity({ integrationId, ...catalog[capability].input.parse(values) });
case "smartHome.automation":
return smartHomeRouter
.createCaller(ctx)
.executeAutomation({ integrationId, ...catalog[capability].input.parse(values) });
case "docker.start":
case "docker.stop":
case "docker.restart": {
const { endpointId, containerId } = catalog[capability].input.parse(values);
const input = { targets: [{ endpointId, id: containerId }] };
const caller = dockerRouter.createCaller(ctx);
let results;
if (capability === "docker.start") results = await caller.startAll(input);
else if (capability === "docker.stop") results = await caller.stopAll(input);
else results = await caller.restartAll(input);
if (results.some((result) => !result.success))
throw new TRPCError({ code: "BAD_GATEWAY", message: "Docker operation failed" });
return results;
}
default:
throw new TRPCError({ code: "BAD_REQUEST", message: "Capability is not an action" });
}
}
@@ -0,0 +1,67 @@
import { TRPCError } from "@trpc/server";
import type { z } from "zod/v4";
import { isRecord } from "@homarr/common";
import { resolveNative } from "./native-context";
import type { nativeInputSchema, NativeContext, ResolvedNative } from "./native-context";
import { actionNative, queryNative } from "./native-dispatch";
import { acquireCustomWidgetRequestLimit } from "./request-limits";
import { appendPreviewJournal } from "./preview-sessions";
export async function recordNativePreview(
resolved: ResolvedNative,
nativeId: string,
startedAt: number,
status: number | null,
simulated = false,
) {
if (!resolved.session) return;
await appendPreviewJournal(resolved.session, {
requestId: `native:${nativeId}`,
kind: resolved.descriptor.kind,
method: resolved.descriptor.kind === "action" ? "POST" : "GET",
path: resolved.capability,
status,
durationMs: Date.now() - startedAt,
simulated,
});
}
export async function executeNative(
ctx: NativeContext,
input: z.infer<typeof nativeInputSchema>,
kind: "query" | "action",
confirmed = false,
) {
const resolved = await resolveNative(ctx, input, kind);
const startedAt = Date.now();
if (kind === "action" && resolved.session && !resolved.session.liveActions) {
await recordNativePreview(resolved, input.nativeId, startedAt, null, true);
return { ok: true, status: 0, data: null, simulated: true };
}
if (kind === "action" && !confirmed)
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "This action requires confirmation" });
const release = await acquireCustomWidgetRequestLimit({
category: kind,
userId: ctx.session?.user.id,
itemId: input.itemId ?? `preview:${input.previewSessionId}`,
definitionId: resolved.definitionId,
});
try {
const data = kind === "query" ? await queryNative(ctx, resolved) : await actionNative(ctx, resolved);
if (isRecord(data) && "error" in data && typeof data.error === "string") {
await recordNativePreview(resolved, input.nativeId, startedAt, 502);
return { ok: false, status: 502, data, error: data.error, simulated: false };
}
await recordNativePreview(resolved, input.nativeId, startedAt, 200);
const invalidates: string[] = [];
if (kind === "action") invalidates.push("*");
return { ok: true, status: 200, data, simulated: false, invalidates };
} catch (error) {
await recordNativePreview(resolved, input.nativeId, startedAt, 0);
throw error;
} finally {
await release();
}
}
@@ -0,0 +1,96 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
import { beszelRouter } from "../widgets/beszel";
import { nativeCapabilities } from "./native-catalog";
import { nativeInputSchema, resolveNative } from "./native-context";
import { acquireCustomWidgetRequestLimit } from "./request-limits";
import { executeNative, recordNativePreview } from "./native-execution";
export const nativeProcedures = {
nativeCapabilities: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Discover the curated native Custom Widget capabilities and their input schemas. These reuse existing integration permissions. Declare them in v3 extensions.native; select integrations through saved widget options.",
},
})
.query(() =>
Object.entries(nativeCapabilities).map(([id, capability]) => ({
id,
kind: capability.kind,
integration: capability.integration,
integrationKinds:
capability.integration === "calendar"
? getIntegrationKindsByCategory("calendar")
: capability.integration === "mediaRequest"
? getIntegrationKindsByCategory("mediaRequest")
: capability.integration
? [capability.integration]
: [],
inputSchema: z.toJSONSchema(capability.input, { io: "input" }),
})),
),
nativeQuery: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Run a declared v3 native query for a widget item or owned preview session. Use nativeCapabilities for discovery. Supply exactly one itemId or previewSessionId and the declared nativeId.",
},
})
.input(nativeInputSchema)
.query(({ ctx, input }) => executeNative(ctx, input, "query")),
nativeAction: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Run a declared native action for a widget item or owned preview session. Preview actions simulate unless live actions were explicitly enabled. Live actions require confirmed=true and existing board/integration permissions.",
},
})
.input(nativeInputSchema.safeExtend({ confirmed: z.boolean().default(false) }))
.mutation(({ ctx, input }) => executeNative(ctx, input, "action", input.confirmed)),
nativeSubscribe: publicProcedure.input(nativeInputSchema).subscription(async function* ({ ctx, input, signal }) {
const resolved = await resolveNative(ctx, input, "query");
if (resolved.capability !== "beszel.live")
throw new TRPCError({ code: "BAD_REQUEST", message: "This capability has no live stream" });
const controller = new AbortController();
const abort = () => controller.abort();
signal?.addEventListener("abort", abort, { once: true });
if (signal?.aborted) controller.abort();
// A preview cannot retain service access after its session expires.
const remaining = Math.min(50_000, (resolved.session?.expiresAt ?? Infinity) - Date.now());
const release = await acquireCustomWidgetRequestLimit({
category: "query",
userId: ctx.session?.user.id,
itemId: input.itemId ?? `preview:${input.previewSessionId}`,
definitionId: resolved.definitionId,
});
const timer = setTimeout(abort, Math.max(0, remaining));
const startedAt = Date.now();
try {
const stream = await beszelRouter.createCaller(ctx, { signal: controller.signal }).subscribeSystemStats({
integrationIds: resolved.integrationIds,
...nativeCapabilities["beszel.live"].input.parse(resolved.values),
});
let recorded = false;
for await (const event of stream) {
if (!recorded) {
await recordNativePreview(resolved, input.nativeId, startedAt, 200);
recorded = true;
}
yield { ok: true, status: 200, data: event };
}
} finally {
clearTimeout(timer);
signal?.removeEventListener("abort", abort);
controller.abort();
await release();
}
}),
};
@@ -0,0 +1,84 @@
import { TRPCError } from "@trpc/server";
import { parse as parseSuperJson } from "superjson";
import { isRecord } from "@homarr/common";
import { eq } from "@homarr/db";
import { boards, customWidgetDefinitions, items, legacyCustomWidgetDefinitions } from "@homarr/db/schema";
import {
getCustomWidgetDefaultOptions,
normalizeCustomWidgetOptions,
validateCustomWidgetOptions,
} from "@homarr/custom-widgets/core";
import { throwIfActionForbiddenAsync } from "../board/board-access";
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
interface CustomWidgetItemOptions {
definitionId: string;
configuration: Record<string, unknown>;
configurationVersion: number;
refreshInterval?: number;
}
type RouterContext = Parameters<typeof throwIfActionForbiddenAsync>[0];
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,
configuration: isRecord(options.configuration) ? options.configuration : {},
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" });
}
};
export async function resolvePlacedDefinitionAsync(ctx: RouterContext, itemId: string) {
const item = await ctx.db.query.items.findFirst({
where: eq(items.id, itemId),
columns: { id: true, boardId: true, kind: true, options: true },
});
if (!item || item.kind !== "customApi") {
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget item not found" });
}
await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "view");
const itemOptions = parseItemOptions(item.options);
const stored = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, itemOptions.definitionId),
with: { secrets: true },
});
if (!stored) {
const legacy = await ctx.db.query.legacyCustomWidgetDefinitions.findFirst({
where: eq(legacyCustomWidgetDefinitions.id, itemOptions.definitionId),
columns: { id: true },
});
if (legacy) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "LEGACY_CUSTOM_WIDGET_MIGRATION_REQUIRED",
});
}
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget unavailable" });
}
if (!stored.enabled) throw new TRPCError({ code: "FORBIDDEN", message: "Widget is disabled" });
const definition = parseStoredCustomWidgetDefinition(stored);
const configuration =
itemOptions.configurationVersion === stored.updatedAt.getTime()
? { ...getCustomWidgetDefaultOptions(definition.options), ...itemOptions.configuration }
: normalizeCustomWidgetOptions(definition.options, itemOptions.configuration);
const issues = validateCustomWidgetOptions(definition.options, configuration);
if (issues.length > 0) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: `Custom widget configuration needs repair: ${issues[0]?.path} ${issues[0]?.message}`,
});
}
return { item, stored, definition, itemOptions, configuration };
}
@@ -3,30 +3,32 @@ import { z } from "zod/v4";
import { decryptSecret } from "@homarr/common/server";
import {
collectCustomWidgetRequestReferences,
customWidgetAuthoringDefinitionSchema,
customJsxTemplateSchema,
customWidgetSecretsInputSchema,
customWidgetSourceRenamesSchema,
customWidgetTemplateLinesSchema,
getCustomWidgetConfirmation,
getCustomWidgetDefaultOptions,
normalizeCustomWidgetAuthoringDefinition,
validateCustomWidgetOptions,
} from "@homarr/custom-widgets/core";
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
import { eq } from "@homarr/db";
import { customWidgetDefinitions } from "@homarr/db/schema";
import { permissionRequiredProcedure } from "../../trpc";
import { getPreviewEvidenceChecklist } from "./preview-evidence-checklist";
import { parseCustomWidgetAuthoringInput } from "./authoring-validation";
import { createPreviewSession, getPreviewSession, revisePreviewSessionTemplate } from "./preview-sessions";
import { hasSameSecretBinding, requiredSecretKinds } from "./secret-policy";
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
import { prepareSourceSecretRenames } from "./source-secret-renames";
const previewCreateInputSchema = z.object({
definition: customWidgetAuthoringDefinitionSchema,
secrets: customWidgetSecretsInputSchema.default([]),
definitionId: z.string().optional(),
sourceRenames: customWidgetSourceRenamesSchema.default({}),
options: z.record(z.string(), z.unknown()).optional(),
});
@@ -50,34 +52,6 @@ const previewReviseTemplateInputSchema = z
}
});
const getPreviewEvidenceChecklist = (requests: Record<string, CustomJsxRequest>, sessionId: string) => ({
queries: Object.entries(requests).flatMap(([requestId, request]) => {
if (request.kind !== "query") return [];
return [
{
requestId,
trigger: request.trigger,
parameterNames: [...collectCustomWidgetRequestReferences(request).params],
nextStep: `Call customWidget_previewQuery with sessionId '${sessionId}' and requestId '${requestId}'.`,
},
];
}),
actions: Object.entries(requests).flatMap(([requestId, request]) => {
if (request.kind !== "action") return [];
return [
{
requestId,
method: request.method,
parameterNames: [...collectCustomWidgetRequestReferences(request).params],
minimumBoardPermission: request.permission,
confirmation: getCustomWidgetConfirmation(request),
invalidates: request.invalidates ?? [],
nextStep: `Call customWidget_previewAction with sessionId '${sessionId}' and requestId '${requestId}'. Actions are simulated unless live preview actions were explicitly enabled.`,
},
];
}),
});
const previewCreateProcedure = permissionRequiredProcedure
.requiresPermission("admin")
.meta({
@@ -103,6 +77,9 @@ const previewCreateProcedure = permissionRequiredProcedure
}
const secrets = [...input.secrets];
if (!input.definitionId && Object.keys(input.sourceRenames).length) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Source renames require a saved definition" });
}
if (input.definitionId) {
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.definitionId),
@@ -110,14 +87,16 @@ const previewCreateProcedure = permissionRequiredProcedure
});
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
const existingDefinition = parseStoredCustomWidgetDefinition(existing);
prepareSourceSecretRenames(existingDefinition.sources, definition.sources, input.sourceRenames);
for (const [sourceId, existingSource] of Object.entries(existingDefinition.sources)) {
const submittedSource = definition.sources[sourceId];
const destinationId = input.sourceRenames[sourceId] ?? sourceId;
const submittedSource = definition.sources[destinationId];
const hasStoredSecrets = existing.secrets.some((secret) => secret.sourceId === sourceId);
if (!submittedSource || !hasStoredSecrets || hasSameSecretBinding(existingSource, submittedSource)) continue;
const authType = typeof submittedSource.auth === "string" ? submittedSource.auth : submittedSource.auth.type;
const missingReplacement = requiredSecretKinds(authType).some(
(kind) => !secrets.some((secret) => secret.sourceId === sourceId && secret.kind === kind),
(kind) => !secrets.some((secret) => secret.sourceId === destinationId && secret.kind === kind),
);
if (missingReplacement) {
throw new TRPCError({
@@ -127,15 +106,16 @@ const previewCreateProcedure = permissionRequiredProcedure
}
}
for (const secret of existing.secrets) {
const destinationId = input.sourceRenames[secret.sourceId] ?? secret.sourceId;
const existingSource = existingDefinition.sources[secret.sourceId];
const submittedSource = definition.sources[secret.sourceId];
const submittedSource = definition.sources[destinationId];
if (
existingSource &&
submittedSource &&
hasSameSecretBinding(existingSource, submittedSource) &&
!secrets.some((candidate) => candidate.sourceId === secret.sourceId && candidate.kind === secret.kind)
!secrets.some((candidate) => candidate.sourceId === destinationId && candidate.kind === secret.kind)
) {
secrets.push({ sourceId: secret.sourceId, kind: secret.kind, value: decryptSecret(secret.encryptedValue) });
secrets.push({ sourceId: destinationId, kind: secret.kind, value: decryptSecret(secret.encryptedValue) });
}
}
}
@@ -163,6 +143,8 @@ const previewCreateProcedure = permissionRequiredProcedure
description: definition.description,
iconUrl: definition.iconUrl,
template: definition.template,
// Sessions use extensions to retain the schema version, including static v3 helpers.
extensions: definition.$schema === "homarr-custom-widget-v3" ? (definition.extensions ?? {}) : undefined,
optionDefinitions: definition.options,
options,
secrets,
@@ -174,7 +156,7 @@ const previewCreateProcedure = permissionRequiredProcedure
previewSession,
previewPath,
previewUrl: new URL(previewPath, ctx.baseUrl ?? "http://localhost").toString(),
...getPreviewEvidenceChecklist(definition.requests, previewSession.id),
...getPreviewEvidenceChecklist(definition.requests, previewSession.id, definition.extensions?.native),
};
});
@@ -214,7 +196,7 @@ export const previewBaseProcedures = {
},
previewPath,
previewUrl: new URL(previewPath, ctx.baseUrl ?? "http://localhost").toString(),
...getPreviewEvidenceChecklist(session.requests, session.id),
...getPreviewEvidenceChecklist(session.requests, session.id, session.extensions?.native),
};
}),
previewGet: permissionRequiredProcedure
@@ -230,6 +212,7 @@ export const previewBaseProcedures = {
iconUrl: session.iconUrl,
expiresAt: session.expiresAt,
template: session.template,
extensions: session.extensions,
optionDefinitions: session.optionDefinitions,
options: session.options,
requests: Object.entries(session.requests).map(([id, request]) => ({
@@ -0,0 +1,59 @@
import { collectCustomWidgetRequestReferences, getCustomWidgetConfirmation } from "@homarr/custom-widgets/core";
import type { CustomJsxRequest, CustomWidgetNativeCapability } from "@homarr/custom-widgets/core";
export const getPreviewEvidenceChecklist = (
requests: Record<string, CustomJsxRequest>,
sessionId: string,
native: Record<string, CustomWidgetNativeCapability> = {},
) => ({
queries: Object.entries(requests).flatMap(([requestId, request]) => {
if (request.kind !== "query") return [];
return [
{
requestId,
trigger: request.trigger,
parameterNames: [...collectCustomWidgetRequestReferences(request).params],
nextStep: `Call customWidget_previewQuery with sessionId '${sessionId}' and requestId '${requestId}'.`,
},
];
}),
actions: Object.entries(requests).flatMap(([requestId, request]) => {
if (request.kind !== "action") return [];
return [
{
requestId,
method: request.method,
parameterNames: [...collectCustomWidgetRequestReferences(request).params],
minimumBoardPermission: request.permission,
confirmation: getCustomWidgetConfirmation(request),
invalidates: request.invalidates ?? [],
nextStep: `Call customWidget_previewAction with sessionId '${sessionId}' and requestId '${requestId}'. Actions are simulated unless live preview actions were explicitly enabled.`,
},
];
}),
nativeQueries: Object.entries(native).flatMap(([nativeId, capability]) => {
if (capability.kind !== "query") return [];
return [
{
nativeId,
capability: capability.capability,
trigger: capability.trigger,
parameterNames: [...collectCustomWidgetRequestReferences({ path: "", body: capability.input }).params],
nextStep: `Call customWidget_nativeQuery with previewSessionId '${sessionId}' and nativeId '${nativeId}'.`,
},
];
}),
nativeActions: Object.entries(native).flatMap(([nativeId, capability]) => {
if (capability.kind !== "action") return [];
return [
{
nativeId,
capability: capability.capability,
parameterNames: [...collectCustomWidgetRequestReferences({ path: "", body: capability.input }).params],
minimumBoardPermission: capability.permission,
confirmation: capability.confirmation,
nextStep: `Call customWidget_nativeAction with previewSessionId '${sessionId}' and nativeId '${nativeId}'. Actions simulate unless live preview actions were explicitly enabled. Live execution requires confirmed=true.`,
},
];
}),
});
@@ -0,0 +1,39 @@
import { TRPCError } from "@trpc/server";
import { createId } from "@homarr/common";
import type { CustomWidgetSource } from "@homarr/custom-widgets/core";
import { hasSameSecretBinding } from "./secret-policy";
/** Move stored ciphertext in the save transaction; never read and later recreate
* a credential that another operation may have cleared in the meantime. */
export function prepareSourceSecretRenames(
previous: Record<string, CustomWidgetSource>,
incoming: Record<string, CustomWidgetSource>,
renames: Record<string, string> = {},
) {
const targets = new Set(Object.values(renames));
return Object.entries(renames).map(([from, to]) => {
const source = previous[from];
const destination = incoming[to];
if (
from === to ||
!source ||
!destination ||
(previous[to] && !Object.hasOwn(renames, to)) ||
(incoming[from] && !targets.has(from))
) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Source rename does not match the saved and submitted source identities",
});
}
if (!hasSameSecretBinding(source, destination)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Renamed source security settings changed; re-enter its credentials",
});
}
return { from, to, temporary: `rename-${createId()}` };
});
}
@@ -0,0 +1,66 @@
import { createHash } from "node:crypto";
import { TRPCError } from "@trpc/server";
import { and, eq, isNull, sql } from "@homarr/db";
import type { SQLWrapper } from "@homarr/db";
import { isMysql } from "@homarr/db/collection";
import { customWidgetDefinitions } from "@homarr/db/schema";
import type { CustomWidgetDefinition } from "@homarr/db/schema";
function exactText(column: SQLWrapper, value: string | null) {
if (value === null) return isNull(column);
// MySQL's default text collation ignores case and trailing whitespace. Both
// are meaningful in authored code, URLs, IDs, and fingerprints.
if (isMysql()) return sql`cast(${column} as binary) = cast(${value} as binary)`;
return sql`${column} = ${value}`;
}
/** Match the state read before an async edit, including Workshop lifecycle changes. */
export function customWidgetDefinitionMatch(row: CustomWidgetDefinition, includeLayout = false) {
const match = [
eq(customWidgetDefinitions.id, row.id),
exactText(customWidgetDefinitions.sources, row.sources),
exactText(customWidgetDefinitions.requests, row.requests),
exactText(customWidgetDefinitions.options, row.options),
exactText(customWidgetDefinitions.template, row.template),
exactText(customWidgetDefinitions.name, row.name),
exactText(customWidgetDefinitions.description, row.description),
exactText(customWidgetDefinitions.iconUrl, row.iconUrl),
exactText(customWidgetDefinitions.extensions, row.extensions),
exactText(customWidgetDefinitions.workshopOrigin, row.workshopOrigin),
exactText(customWidgetDefinitions.previousPackage, row.previousPackage),
];
if (includeLayout) match.push(exactText(customWidgetDefinitions.editorLayout, row.editorLayout));
return and(...match);
}
/** Read the mutation count from SQLite, MySQL2, and node-postgres results. */
export function assertCustomWidgetDefinitionChanged(result: unknown) {
let response = result;
if (Array.isArray(result)) response = result[0];
const count = response as { changes?: number; affectedRows?: number; rowCount?: number };
if ((count.changes ?? count.affectedRows ?? count.rowCount) !== 1) {
throw new TRPCError({ code: "CONFLICT", message: "The saved widget changed. Review it again before saving." });
}
}
/** Opaque saved-state precondition for workbench tabs; includes local layout. */
export function customWidgetSavedRevision(row: CustomWidgetDefinition) {
return createHash("sha256")
.update(
JSON.stringify([
row.name,
row.description,
row.iconUrl,
row.sources,
row.requests,
row.options,
row.template,
row.extensions,
row.editorLayout,
row.workshopOrigin,
row.previousPackage,
]),
)
.digest("hex");
}
@@ -6,7 +6,7 @@ import type { CustomWidgetDefinition } from "@homarr/db/schema";
type StoredCustomWidgetDefinition = Pick<
CustomWidgetDefinition,
"name" | "description" | "iconUrl" | "sources" | "requests" | "options" | "template"
"name" | "description" | "iconUrl" | "sources" | "requests" | "options" | "template" | "extensions"
>;
export interface StoredCustomWidgetIssue {
@@ -27,12 +27,14 @@ export function serializeCustomWidgetDefinition(definition: HomarrCustomWidgetV2
requests: stringifySuperJson(definition.requests),
options: stringifySuperJson(definition.options),
template: definition.template,
extensions: definition.$schema === "homarr-custom-widget-v3" ? JSON.stringify(definition.extensions ?? {}) : null,
};
}
export function parseStoredCustomWidgetDefinition(definition: StoredCustomWidgetDefinition): HomarrCustomWidgetV2 {
return customWidgetDefinitionSchema.parse({
$schema: "homarr-custom-widget-v2",
$schema: definition.extensions != null ? "homarr-custom-widget-v3" : "homarr-custom-widget-v2",
...(definition.extensions != null ? { extensions: JSON.parse(definition.extensions) } : {}),
name: definition.name,
description: definition.description ?? undefined,
iconUrl: definition.iconUrl ?? undefined,
@@ -6,7 +6,11 @@ import { encryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { eq, handleTransactionsAsync } from "@homarr/db";
import { customWidgetDefinitions, customWidgetSecrets, legacyCustomWidgetDefinitions } from "@homarr/db/schema";
import { customWidgetImportSchema, customWidgetSecretsInputSchema } from "@homarr/custom-widgets/core";
import {
customWidgetImportSchema,
customWidgetSecretsInputSchema,
toPortableCustomWidgetDefinition,
} from "@homarr/custom-widgets/core";
import { insertCustomWidgetDefinition } from "./definition-insert";
import { permissionRequiredProcedure } from "../../trpc";
@@ -24,7 +28,7 @@ export const transferProcedures = {
where: eq(customWidgetDefinitions.id, input.id),
});
if (!definition) throw new TRPCError({ code: "NOT_FOUND" });
return parseStoredCustomWidgetDefinition(definition);
return toPortableCustomWidgetDefinition(parseStoredCustomWidgetDefinition(definition));
}),
exportLegacy: permissionRequiredProcedure
@@ -0,0 +1,33 @@
import { TRPCError } from "@trpc/server";
import { eq } from "@homarr/db";
import type { Database } from "@homarr/db";
import { integrations } from "@homarr/db/schema";
import { getCustomWidgetIntegrationOptionIds } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
/** Called only from the administrator-owned installation procedure. Execution
* independently enforces the current board user's integration permissions. */
export async function configureWorkshopIntegrations(
db: Database,
widget: HomarrCustomWidgetV2,
values: Record<string, string>,
) {
const optionIds = getCustomWidgetIntegrationOptionIds(widget);
const options = { ...widget.options };
for (const [optionId, integrationId] of Object.entries(values)) {
const option = options[optionId];
if (!option || !optionIds.has(optionId)) {
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown integration option '${optionId}'` });
}
const integration = await db.query.integrations.findFirst({
where: eq(integrations.id, integrationId),
columns: { id: true, kind: true },
});
if (!integration || (option.integrationKinds && !option.integrationKinds.includes(integration.kind))) {
throw new TRPCError({ code: "BAD_REQUEST", message: `Integration is incompatible with option '${optionId}'` });
}
options[optionId] = { ...option, default: integration.id };
}
return options;
}
@@ -3,31 +3,22 @@ import { z } from "zod/v4";
import {
applyCustomWidgetSourceSetup,
CUSTOM_WIDGET_SCHEMA,
customWidgetDefinitionSchema,
customWidgetIdentifierSchema,
customWidgetSecretsInputSchema,
getCustomWidgetSecretRequirements,
getCustomWidgetSourceSetups,
toPortableCustomWidgetDefinition,
} from "@homarr/custom-widgets/core";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { WorkshopBackend } from "@homarr/workshop/backend";
import { resolveHomarrUrlConfig, validateWorkshopWidget } from "@homarr/workshop/schema";
import { env } from "../../env";
import { isSupportedWorkshopWidgetSchema } from "@homarr/workshop/schema";
import { workshop, getWorkshopSubmissionUrl, getWorkshopWidget, createWorkshopOrigin } from "./workshop-service";
import { permissionRequiredProcedure } from "../../trpc";
import { insertCustomWidgetDefinition } from "./definition-insert";
import { assertSecretSources } from "./secret-policy";
import { configureWorkshopIntegrations } from "./workshop-integration-setup";
const logger = createLogger({ module: "custom-widget:workshop" });
const workshopUrls = resolveHomarrUrlConfig({
homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
workshopApiUrl: env.WORKSHOP_API_URL,
workshopWebUrl: env.WORKSHOP_WEB_URL,
});
const workshop = new WorkshopBackend(workshopUrls.workshopApiUrl);
const getWorkshopSubmissionUrl = (submissionId: string) =>
`${workshopUrls.workshopWebUrl}/${encodeURIComponent(submissionId)}`;
function throwWorkshopUnavailable(
message: string,
@@ -43,26 +34,6 @@ function throwWorkshopUnavailable(
});
}
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;
throwWorkshopUnavailable("Workshop widget lookup failed", "workshop_widget_lookup_failed");
}
}
const workshopSearchInputSchema = z.object({
query: z.string().trim().min(1).max(200),
sort: z.enum(["top", "newest", "recent", "discussed"]).default("top"),
@@ -94,7 +65,7 @@ export const workshopProcedures = {
});
return {
items: result.items
.filter((item) => item.widgetSchema === CUSTOM_WIDGET_SCHEMA)
.filter((item) => isSupportedWorkshopWidgetSchema(item.widgetSchema))
.map((item) => ({
id: item.id,
url: getWorkshopSubmissionUrl(item.id),
@@ -115,9 +86,10 @@ export const workshopProcedures = {
workshopGet: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Get and validate one Workshop Custom JSX widget." } })
.input(z.object({ submissionId: z.string().min(1) }))
.input(z.object({ submissionId: z.string().min(1).max(128) }))
.query(async ({ input }) => {
const { submission, widget } = await getWorkshopWidget(input.submissionId);
const native = Object.values(widget.extensions?.native ?? {});
return {
submission: {
id: submission.id,
@@ -132,9 +104,10 @@ export const workshopProcedures = {
},
widget,
sourceSetup: getCustomWidgetSourceSetups(widget.sources),
hasActions: Object.values(widget.requests).some((request) => request.kind === "action"),
hasActions: [...Object.values(widget.requests), ...native].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))],
permissions: [...new Set([...Object.values(widget.requests), ...native].map((request) => request.permission))],
nativeCapabilities: native.map(({ capability, kind, permission }) => ({ capability, kind, permission })),
};
}),
@@ -143,20 +116,33 @@ export const workshopProcedures = {
.meta({ mcp: { enabled: true, description: "Install one validated Workshop Custom JSX widget." } })
.input(
z.object({
submissionId: z.string().min(1),
submissionId: z.string().min(1).max(128),
expectedRevision: z.number().int().positive().optional(),
name: z.string().trim().min(1).max(128).optional(),
sources: sourceOverridesSchema.default({}),
integrations: z
.record(customWidgetIdentifierSchema, z.string().min(1).max(128))
.refine((values) => Object.keys(values).length <= 64)
.default({}),
secrets: customWidgetSecretsInputSchema.default([]),
}),
)
.mutation(async ({ ctx, input }) => {
const { widget } = await getWorkshopWidget(input.submissionId);
const { widget, submission } = await getWorkshopWidget(input.submissionId);
if (input.expectedRevision !== undefined && submission.revision !== input.expectedRevision) {
throw new TRPCError({
code: "CONFLICT",
message: "Workshop revision changed. Review the current revision before installing.",
});
}
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 portable = toPortableCustomWidgetDefinition(widget);
const configured = customWidgetDefinitionSchema.parse({
...widget,
...portable,
options: await configureWorkshopIntegrations(ctx.db, portable, input.integrations),
name: input.name ?? widget.name,
sources: applyCustomWidgetSourceSetup(
widget.sources,
@@ -172,7 +158,14 @@ export const workshopProcedures = {
),
});
assertSecretSources(configured.sources, input.secrets);
const id = await insertCustomWidgetDefinition(ctx.db, configured, ctx.session.user.id, input.secrets);
const id = await insertCustomWidgetDefinition(
ctx.db,
configured,
ctx.session.user.id,
input.secrets,
undefined,
createWorkshopOrigin(submission, widget, configured),
);
const configuredSecrets = new Set(input.secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
return {
status: "installed" as const,
@@ -0,0 +1,88 @@
import { createHash } from "node:crypto";
import { TRPCError } from "@trpc/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { WorkshopBackend } from "@homarr/workshop/backend";
import {
isSupportedWorkshopWidgetSchema,
resolveHomarrUrlConfig,
validateWorkshopWidget,
} from "@homarr/workshop/schema";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { toPortableCustomWidgetDefinition } from "@homarr/custom-widgets/core";
import { env } from "../../env";
const logger = createLogger({ module: "custom-widget:workshop" });
export const workshopUrls = resolveHomarrUrlConfig({
homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
workshopApiUrl: env.WORKSHOP_API_URL,
workshopWebUrl: env.WORKSHOP_WEB_URL,
});
export const workshop = new WorkshopBackend(workshopUrls.workshopApiUrl);
export const getWorkshopSubmissionUrl = (id: string) => `${workshopUrls.workshopWebUrl}/${encodeURIComponent(id)}`;
export async function getWorkshopWidget(submissionId: string) {
try {
const submission = await workshop.get(submissionId);
if (submission.type !== "customWidget" || !isSupportedWorkshopWidgetSchema(submission.widgetSchema)) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Incompatible Workshop widget" });
}
const validation = validateWorkshopWidget(submission.content);
if (!validation.success) throw new TRPCError({ code: "BAD_REQUEST", message: validation.error });
if (validation.data.$schema !== submission.widgetSchema) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Workshop schema metadata does not match its content" });
}
return { submission, widget: validation.data };
} catch (error) {
if (error instanceof TRPCError) throw error;
logger.error("Workshop widget lookup failed", {
event: "workshop_widget_lookup_failed",
errorName: "WorkshopBackendError",
});
throw new TRPCError({ code: "BAD_GATEWAY", message: "Workshop is unavailable" });
}
}
function canonical(value: unknown): unknown {
if (Array.isArray(value)) return value.map(canonical);
if (value === null || typeof value !== "object") return value;
return Object.fromEntries(
Object.entries(value)
.toSorted(([left], [right]) => left.localeCompare(right))
.filter(([, child]) => child !== undefined)
.map(([key, child]) => [key, canonical(child)]),
);
}
export function widgetFingerprint(widget: HomarrCustomWidgetV2, portable = false) {
let value = widget;
if (widget.$schema === "homarr-custom-widget-v3") value = { ...widget, extensions: widget.extensions ?? {} };
if (portable) value = toPortableCustomWidgetDefinition(value);
if (portable)
value = {
...value,
sources: Object.fromEntries(
Object.entries(widget.sources).map(([id, source]) => [
id,
{ ...source, baseUrl: "https://local.invalid", networkScope: "public" as const },
]),
),
};
return createHash("sha256")
.update(JSON.stringify(canonical(value)))
.digest("hex");
}
export function createWorkshopOrigin(
submission: { id: string; revision: number },
original: HomarrCustomWidgetV2,
installed: HomarrCustomWidgetV2,
) {
return {
endpoint: workshopUrls.workshopApiUrl.replace(/\/+$/u, ""),
submissionId: submission.id,
revision: submission.revision,
fingerprint: widgetFingerprint(original),
installedFingerprint: widgetFingerprint(installed, true),
};
}
@@ -0,0 +1,54 @@
import { createHash } from "node:crypto";
import { TRPCError } from "@trpc/server";
import { eq } from "@homarr/db";
import type { Database } from "@homarr/db";
import { customWidgetDefinitions } from "@homarr/db/schema";
import type { CustomWidgetDefinition } from "@homarr/db/schema";
import { parseCustomWidgetWorkshopOrigin } from "@homarr/custom-widgets/core";
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
import { assertCustomWidgetDefinitionChanged, customWidgetDefinitionMatch } from "./stored-definition-state";
import { widgetFingerprint, workshopUrls } from "./workshop-service";
export async function getWorkshopInstalled(db: Database, id: string, requireConfiguredEndpoint = true) {
const row = await db.query.customWidgetDefinitions.findFirst({ where: eq(customWidgetDefinitions.id, id) });
if (!row) throw new TRPCError({ code: "NOT_FOUND" });
const origin = parseCustomWidgetWorkshopOrigin(row.workshopOrigin);
if (!origin || (requireConfiguredEndpoint && origin.endpoint !== workshopUrls.workshopApiUrl)) {
throw new TRPCError({ code: "BAD_REQUEST", message: "This widget is not linked to the configured Workshop" });
}
return { row, origin, widget: parseStoredCustomWidgetDefinition(row) };
}
/** Pin the reviewed package and its lifecycle metadata, excluding layout and credentials. */
export function workshopInstalledState(row: CustomWidgetDefinition) {
return createHash("sha256")
.update(
JSON.stringify([
widgetFingerprint(parseStoredCustomWidgetDefinition(row)),
row.workshopOrigin,
row.previousPackage,
]),
)
.digest("hex");
}
export function assertWorkshopReviewedState(row: CustomWidgetDefinition, expectedState: string) {
if (workshopInstalledState(row) !== expectedState) {
throw new TRPCError({ code: "CONFLICT", message: "The installed widget changed. Review it again." });
}
}
/** One SQL compare-and-swap covers the async fetch-to-write window on every driver. */
export async function replaceWorkshopInstalled(
db: Database,
row: CustomWidgetDefinition,
changes: Partial<CustomWidgetDefinition>,
) {
const result: unknown = await db
.update(customWidgetDefinitions)
.set({ ...changes, updatedAt: new Date() })
.where(customWidgetDefinitionMatch(row));
assertCustomWidgetDefinitionChanged(result);
}
@@ -0,0 +1,215 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { eq } from "@homarr/db";
import { customWidgetDefinitions } from "@homarr/db/schema";
import {
customWidgetDefinitionSchema,
customWidgetWorkshopOriginSchema,
hasSameCustomWidgetSourceAuthentication,
getCustomWidgetIntegrationOptionIds,
toPortableCustomWidgetDefinition,
} from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure } from "../../trpc";
import { parseStoredCustomWidgetDefinition, serializeCustomWidgetDefinition } from "./stored-definition";
import {
createWorkshopOrigin,
getWorkshopSubmissionUrl,
getWorkshopWidget,
widgetFingerprint,
} from "./workshop-service";
import {
assertWorkshopReviewedState,
getWorkshopInstalled,
replaceWorkshopInstalled,
workshopInstalledState,
} from "./workshop-storage";
function sameSourceBindings(left: HomarrCustomWidgetV2, right: HomarrCustomWidgetV2) {
if (Object.keys(left.sources).length !== Object.keys(right.sources).length) return false;
return Object.entries(left.sources).every(([id, source]) => {
const incoming = right.sources[id];
return incoming && hasSameCustomWidgetSourceAuthentication(source, incoming);
});
}
function withLocalSources(incoming: HomarrCustomWidgetV2, current: HomarrCustomWidgetV2) {
const integrationOptions = getCustomWidgetIntegrationOptionIds(incoming);
const portable = toPortableCustomWidgetDefinition(incoming);
return customWidgetDefinitionSchema.parse({
...portable,
options: Object.fromEntries(
Object.entries(portable.options).map(([id, option]) => {
const local = current.options[id];
if (!local || !integrationOptions.has(id)) return [id, option];
if (
local.control !== "integration" ||
option.control !== "integration" ||
JSON.stringify(local.integrationKinds?.toSorted() ?? []) !==
JSON.stringify(option.integrationKinds?.toSorted() ?? [])
) {
return [id, option];
}
return [id, { ...option, default: local.default }];
}),
),
sources: Object.fromEntries(
Object.entries(incoming.sources).map(([id, source]) => {
const local = current.sources[id];
if (!local) return [id, source];
return [id, { ...source, baseUrl: local.baseUrl, networkScope: local.networkScope }];
}),
),
});
}
const fingerprint = z.string().regex(/^[a-f0-9]{64}$/u);
const idInput = z.object({ id: z.string().min(1).max(128) });
const reviewedInput = idInput.extend({ expectedFingerprint: fingerprint, expectedState: fingerprint });
const previousPackageSchema = z.object({
widget: customWidgetDefinitionSchema,
origin: customWidgetWorkshopOriginSchema,
});
export const workshopUpdateProcedures = {
workshopRollbackReview: permissionRequiredProcedure
.requiresPermission("admin")
.input(idInput)
.query(async ({ ctx, input }) => {
const { row, widget } = await getWorkshopInstalled(ctx.db, input.id, false);
return {
rollbackAvailable: Boolean(row.previousPackage),
expectedFingerprint: widgetFingerprint(widget),
expectedState: workshopInstalledState(row),
};
}),
workshopUpdateReview: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Compare an installed widget with its latest Workshop revision without changing it. Requires admin access. Returns content and state fingerprints for workshopUpdateInstall.",
},
})
.input(idInput)
.query(async ({ ctx, input }) => {
const { row, origin, widget } = await getWorkshopInstalled(ctx.db, input.id);
const upstream = await getWorkshopWidget(origin.submissionId);
return {
origin,
url: getWorkshopSubmissionUrl(origin.submissionId),
revision: upstream.submission.revision,
current: widget,
upstream: upstream.widget,
incoming: withLocalSources(upstream.widget, widget),
expectedFingerprint: widgetFingerprint(widget),
expectedState: workshopInstalledState(row),
upstreamFingerprint: widgetFingerprint(upstream.widget),
locallyModified: widgetFingerprint(widget, true) !== origin.installedFingerprint,
sourceBindingChanged: !sameSourceBindings(widget, upstream.widget),
rollbackAvailable: Boolean(row.previousPackage),
changelog: upstream.submission.changelog,
};
}),
workshopUpdateInstall: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Install a reviewed Workshop update. Preserves local source URLs and credentials. Requires admin access, review content/state fingerprints, upstream fingerprint, and revision from workshopUpdateReview. Locally modified widgets require installing a separate copy.",
},
})
.input(reviewedInput.extend({ upstreamFingerprint: fingerprint, revision: z.number().int().positive() }))
.mutation(async ({ ctx, input }) => {
const { row, origin, widget } = await getWorkshopInstalled(ctx.db, input.id);
assertWorkshopReviewedState(row, input.expectedState);
if (
widgetFingerprint(widget) !== input.expectedFingerprint ||
widgetFingerprint(widget, true) !== origin.installedFingerprint
) {
throw new TRPCError({
code: "CONFLICT",
message: "Widget has local changes. Keep it or install the update as a separate copy.",
});
}
const upstream = await getWorkshopWidget(origin.submissionId);
if (
upstream.submission.revision !== input.revision ||
widgetFingerprint(upstream.widget) !== input.upstreamFingerprint
) {
throw new TRPCError({ code: "CONFLICT", message: "Workshop revision changed. Review it again." });
}
if (upstream.submission.revision <= origin.revision) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "No newer Workshop revision is available" });
}
if (!sameSourceBindings(widget, upstream.widget)) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "Source authentication changed. Install as a separate copy to configure sources.",
});
}
const configured = withLocalSources({ ...upstream.widget, name: widget.name }, widget);
await replaceWorkshopInstalled(ctx.db, row, {
...serializeCustomWidgetDefinition(configured),
workshopOrigin: JSON.stringify(createWorkshopOrigin(upstream.submission, upstream.widget, configured)),
previousPackage: JSON.stringify({ widget, origin }),
});
return { id: row.id };
}),
workshopRollback: permissionRequiredProcedure
.requiresPermission("admin")
.input(reviewedInput)
.mutation(async ({ ctx, input }) => {
const { row, widget, origin } = await getWorkshopInstalled(ctx.db, input.id, false);
assertWorkshopReviewedState(row, input.expectedState);
if (widgetFingerprint(widget) !== input.expectedFingerprint) throw new TRPCError({ code: "CONFLICT" });
if (!row.previousPackage) throw new TRPCError({ code: "NOT_FOUND" });
const previous = previousPackageSchema.parse(JSON.parse(row.previousPackage));
// Local credentials are never rolled back. Refuse rebinding them to a
// removed source or changed authentication destination.
if (
!sameSourceBindings(previous.widget, widget) ||
previous.origin.endpoint !== origin.endpoint ||
previous.origin.submissionId !== origin.submissionId
) {
throw new TRPCError({ code: "CONFLICT", message: "Source bindings changed since installation" });
}
const restored = withLocalSources(previous.widget, widget);
await replaceWorkshopInstalled(ctx.db, row, {
...serializeCustomWidgetDefinition(restored),
workshopOrigin: JSON.stringify({ ...previous.origin, installedFingerprint: widgetFingerprint(restored, true) }),
previousPackage: null,
});
return { id: row.id };
}),
workshopLink: permissionRequiredProcedure
.requiresPermission("admin")
.input(idInput.extend({ submissionId: z.string().min(1).max(128), expectedRevision: z.number().int().positive() }))
.mutation(async ({ ctx, input }) => {
const row = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!row) throw new TRPCError({ code: "NOT_FOUND" });
const current = parseStoredCustomWidgetDefinition(row);
const upstream = await getWorkshopWidget(input.submissionId);
if (
upstream.submission.revision !== input.expectedRevision ||
widgetFingerprint(toPortableCustomWidgetDefinition(current)) !== widgetFingerprint(upstream.widget)
) {
throw new TRPCError({ code: "CONFLICT", message: "Published content does not match the saved widget" });
}
await replaceWorkshopInstalled(ctx.db, row, {
workshopOrigin: JSON.stringify(createWorkshopOrigin(upstream.submission, upstream.widget, current)),
previousPackage: null,
});
return { id: row.id };
}),
};
+51 -80
View File
@@ -1,18 +1,11 @@
import { TRPCError } from "@trpc/server";
import { parse as parseSuperJson } from "superjson";
import { z } from "zod/v4";
import { isRecord } from "@homarr/common";
import { decryptSecret } from "@homarr/common/server";
import { eq } from "@homarr/db";
import { boards, customWidgetDefinitions, items, legacyCustomWidgetDefinitions } from "@homarr/db/schema";
import { boards } from "@homarr/db/schema";
import type { BoardPermission } from "@homarr/definitions";
import {
getCustomWidgetConfirmation,
getCustomWidgetDefaultOptions,
normalizeCustomWidgetOptions,
validateCustomWidgetOptions,
} from "@homarr/custom-widgets/core";
import { getCustomWidgetConfirmation } from "@homarr/custom-widgets/core";
import type { CustomJsxRequest, CustomWidgetSource } from "@homarr/custom-widgets/core";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
@@ -26,7 +19,8 @@ import {
} from "../custom-widget/request-manifest";
import { acquireCustomWidgetRequestLimit } from "../custom-widget/request-limits";
import { getCustomWidgetCacheVersion } from "../custom-widget/cache-version";
import { parseStoredCustomWidgetDefinition } from "../custom-widget/stored-definition";
import { resolvePlacedDefinitionAsync } from "../custom-widget/placed-definition";
import { executeNative } from "../custom-widget/native-execution";
const runtimeParamsSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()]));
const itemInputSchema = z.object({ itemId: z.string().min(1) });
@@ -34,79 +28,9 @@ const namedRequestInputSchema = itemInputSchema.extend({
requestId: z.string().min(1).max(64),
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): 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,
configuration: isRecord(options.configuration) ? options.configuration : {},
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" });
}
};
async function resolvePlacedDefinitionAsync(ctx: RouterContext, itemId: string) {
const item = await ctx.db.query.items.findFirst({
where: eq(items.id, itemId),
columns: { id: true, boardId: true, kind: true, options: true },
});
if (!item || item.kind !== "customApi") {
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget item not found" });
}
await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "view");
const itemOptions = parseItemOptions(item.options);
const stored = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, itemOptions.definitionId),
with: { secrets: true },
});
if (!stored) {
const legacy = await ctx.db.query.legacyCustomWidgetDefinitions.findFirst({
where: eq(legacyCustomWidgetDefinitions.id, itemOptions.definitionId),
columns: { id: true },
});
if (legacy) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "LEGACY_CUSTOM_WIDGET_MIGRATION_REQUIRED",
});
}
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget unavailable" });
}
if (!stored.enabled) throw new TRPCError({ code: "FORBIDDEN", message: "Widget is disabled" });
const definition = parseStoredCustomWidgetDefinition(stored);
const configuration =
itemOptions.configurationVersion === stored.updatedAt.getTime()
? { ...getCustomWidgetDefaultOptions(definition.options), ...itemOptions.configuration }
: normalizeCustomWidgetOptions(definition.options, itemOptions.configuration);
const issues = validateCustomWidgetOptions(definition.options, configuration);
if (issues.length > 0) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: `Custom widget configuration needs repair: ${issues[0]?.path} ${issues[0]?.message}`,
});
}
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" });
@@ -236,9 +160,56 @@ export const customApiRouter = createTRPCRouter({
}),
);
const nativeRequests = Object.entries(resolved.definition.extensions?.native ?? {}).filter(
([, entry]) => entry.kind === "query" && entry.trigger === "load",
);
let nextNativeIndex = 0;
const nativeEntries: typeof entries = [];
await Promise.all(
Array.from({ length: Math.min(4, nativeRequests.length) }, async () => {
while (true) {
const index = nextNativeIndex++;
const entry = nativeRequests[index];
if (!entry) return;
const [nativeId] = entry;
try {
const response = await executeNative(ctx, { itemId: input.itemId, nativeId, params: {} }, "query");
nativeEntries[index] = [
nativeId,
{
data: response.data,
status: {
loading: false,
ok: response.ok,
status: response.status,
statusText: "",
error: response.ok ? undefined : response.error,
},
},
];
} catch (error) {
nativeEntries[index] = [
nativeId,
{
data: null,
status: {
loading: false,
ok: false,
status: 0,
error: error instanceof Error ? error.message : "Native query failed",
},
},
];
}
}
}),
);
entries.push(...nativeEntries);
return {
type: "customJsx" as const,
template: resolved.definition.template,
extensions: resolved.definition.extensions,
queryCacheKey: `${getCustomWidgetCacheVersion(resolved.stored)}:${hashRuntimeParams(resolved.configuration)}`,
data: Object.fromEntries(entries.map(([id, result]) => [id, result.data])),
status: Object.fromEntries(entries.map(([id, result]) => [id, result.status])),
+5
View File
@@ -40,6 +40,9 @@ const MCP_TOOL_ALLOWLIST = {
"customWidget_legacyMigrationPrompt",
"customWidget_list",
"customWidget_previewJournal",
"customWidget_nativeCapabilities",
"customWidget_nativeQuery",
"customWidget_workshopUpdateReview",
"customWidget_previewQuery",
"customWidget_schema",
"customWidget_validate",
@@ -102,6 +105,7 @@ const MCP_TOOL_ALLOWLIST = {
"board_setMobileHomeBoard",
"customWidget_delete",
"customWidget_previewAction",
"customWidget_nativeAction",
"customWidget_previewReviseTemplate",
"customWidget_templatePatch",
"dnsHole_disable",
@@ -137,6 +141,7 @@ const MCP_TOOL_ALLOWLIST = {
"customWidget_sourceConfigure",
"customWidget_update",
"customWidget_workshopInstall",
"customWidget_workshopUpdateInstall",
"user_create",
"widgetSecrets_deleteSecret",
"widgetSecrets_setSecret",