feat: add custom widget flow workbench
This commit is contained in:
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 };
|
||||
}),
|
||||
};
|
||||
@@ -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])),
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user