feat(custom-widgets): add extensible widget platform

This commit is contained in:
ajnart committed 2026-09-12 13:54:05 +02:00
1 parent 6cca757493
commit 8f152f1929
317 files changed
+32326 -1788

No files matched your search

+4
View File
@@ -22,6 +22,8 @@ export const env = createEnv({
DEMO_MODE: createBooleanSchema(false),
DEMO_READ_ONLY: createBooleanSchema(true),
HOMARR_WEBSITE_URL: publicHttpUrl("HOMARR_WEBSITE_URL").default("https://homarr.dev"),
CUSTOM_WIDGET_PACKAGE_PATH: z.string().min(1).optional(),
CUSTOM_WIDGET_MAX_SERVER_PROCESSES: z.coerce.number().int().min(1).max(128).default(16),
WORKSHOP_API_URL: publicHttpUrl("WORKSHOP_API_URL").optional(),
WORKSHOP_WEB_URL: publicHttpUrl("WORKSHOP_WEB_URL").optional(),
},
@@ -30,6 +32,8 @@ export const env = createEnv({
DEMO_MODE: process.env.DEMO_MODE,
DEMO_READ_ONLY: process.env.DEMO_READ_ONLY,
HOMARR_WEBSITE_URL: process.env.HOMARR_WEBSITE_URL,
CUSTOM_WIDGET_PACKAGE_PATH: process.env.CUSTOM_WIDGET_PACKAGE_PATH,
CUSTOM_WIDGET_MAX_SERVER_PROCESSES: process.env.CUSTOM_WIDGET_MAX_SERVER_PROCESSES,
WORKSHOP_API_URL: process.env.WORKSHOP_API_URL,
WORKSHOP_WEB_URL: process.env.WORKSHOP_WEB_URL,
},
File diff suppressed because it is too large. Load diff
@@ -30,10 +30,14 @@ import { transferProcedures } from "./transfer-procedures";
import { assertSecretSources, hasSameSecretBinding, requiredSecretKinds } from "./secret-policy";
import { secretProcedures } from "./secret-procedures";
import { workshopProcedures } from "./workshop-procedures";
import { customWidgetPackageRouter } from "./package/management";
import { repairProcedures } from "./repair-procedures";
const logger = createLogger({ module: "custom-widget" });
export const customWidgetRouter = createTRPCRouter({
package: customWidgetPackageRouter,
...repairProcedures,
...metadataProcedures,
...managementQueryProcedures,
...creationProcedures,
@@ -3,9 +3,10 @@ import { z } from "zod/v4";
import { decryptSecret } from "@homarr/common/server";
import { eq, or } from "@homarr/db";
import { boards, customWidgetDefinitions, legacyCustomWidgetDefinitions } from "@homarr/db/schema";
import { collectCustomWidgetRequestReferences } from "@homarr/custom-widgets/core";
import { boards, customWidgetDefinitions, legacyCustomWidgetDefinitions, customWidgetInstallations } from "@homarr/db/schema";
import { collectCustomWidgetRequestReferences, getCustomWidgetDefaultOptions } from "@homarr/custom-widgets/core";
import { getArtifact, parseBindings } from "./package/records";
import { permissionRequiredProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
@@ -112,12 +113,28 @@ export const managementQueryProcedures = {
orderBy: (table, { asc }) => asc(table.name),
});
const available = definitions.flatMap(mapAvailableCustomWidget);
if (!input.currentId || available.some(({ id }) => id === input.currentId)) return available;
const installations = await ctx.db.query.customWidgetInstallations.findMany({
where: input.currentId
? or(eq(customWidgetInstallations.enabled, true), eq(customWidgetInstallations.id, input.currentId))
: eq(customWidgetInstallations.enabled, true),
});
const packages = await Promise.all(installations.flatMap((installation) => {
if (!installation.activeArtifactId) return [];
return [getArtifact(ctx, installation.activeArtifactId).then(({ source }) => ({
id: installation.id, name: installation.name, description: source.manifest.description ?? null,
iconUrl: source.manifest.icon ?? null, options: source.options, defaultOptions: getCustomWidgetDefaultOptions(source.options),
template: "", sources: [], requestCapabilities: [], optionRequests: [], updatedAt: installation.updatedAt,
migrationRequired: false as const, packageVersion: 3 as const, connections: source.connections,
installationBindings: parseBindings(installation.bindings),
})).catch(() => null)];
}));
const result = [...available, ...packages.filter((entry) => entry !== null)];
if (!input.currentId || result.some(({ id }) => id === input.currentId)) return result;
const legacy = await ctx.db.query.legacyCustomWidgetDefinitions.findFirst({
where: eq(legacyCustomWidgetDefinitions.id, input.currentId),
});
if (!legacy) return available;
return [...available, mapLegacyAvailableCustomWidget(legacy)];
if (!legacy) return result;
return [...result, mapLegacyAvailableCustomWidget(legacy)];
}),
optionRequest: permissionRequiredProcedure
@@ -0,0 +1,235 @@
import { TRPCError } from "@trpc/server";
import { and, eq, ne, notLike, or, handleTransactionsAsync } from "@homarr/db";
import { customWidgetInstallations, customWidgetStorage, items } from "@homarr/db/schema";
import type { CustomWidgetArtifact, CustomWidgetPackage } from "@homarr/custom-widgets/package";
import { assertWidgetArtifactIntegrity } from "@homarr/custom-widgets/package/server";
import { getArtifact, getInstallation, getPackagePlacements, parsePackagePlacement } from "./records";
import { persistPackageArtifact } from "./preview-store";
import { publishWidgetPackageChange } from "./events";
import { withWidgetInstallationLock } from "./coordination";
import { prepareWidgetMigration } from "./migrations";
import { getWidgetPackageSupervisor } from "./invocations";
import type { PackageContext } from "./types";
import { isWebhookStorage } from "./webhook-records";
export async function activateWidgetPackage(
ctx: PackageContext,
id: string,
source: CustomWidgetPackage,
artifact: CustomWidgetArtifact,
expectedDraft: string,
) {
return withWidgetInstallationLock(id, (signal) => activateLocked(ctx, id, source, artifact, expectedDraft, signal));
}
async function activateLocked(
ctx: PackageContext,
id: string,
source: CustomWidgetPackage,
artifact: CustomWidgetArtifact,
expectedDraft: string,
signal: AbortSignal,
) {
assertWidgetArtifactIntegrity(artifact, source);
const installation = await getInstallation(ctx, id);
if (installation.draft !== expectedDraft)
throw new TRPCError({ code: "CONFLICT", message: "Draft changed while the package was building" });
getWidgetPackageSupervisor().resetFailures(artifact.digest);
await getWidgetPackageSupervisor().preflight({ artifact });
const placements = await getPackagePlacements(ctx, id);
if (installation.activeArtifactId === artifact.digest) {
signal.throwIfAborted();
await ctx.db
.update(customWidgetInstallations)
.set({ enabled: true, updatedAt: new Date() })
.where(eq(customWidgetInstallations.id, id));
await publishWidgetPackageChange({
installationId: id,
itemIds: placements.map(({ id: itemId }) => itemId),
kind: "activation",
});
return { id, artifactDigest: artifact.digest, affectedPlacements: placements.length };
}
if (
installation.activeArtifactId &&
(await getArtifact(ctx, installation.activeArtifactId)).source.manifest.id !== source.manifest.id
) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Fork the package to change its identity" });
}
const storage = await ctx.db.query.customWidgetStorage.findMany({
where: and(
eq(customWidgetStorage.installationId, id),
or(ne(customWidgetStorage.scope, "system"), notLike(customWidgetStorage.key, "webhook:%")),
),
});
const migrated = await prepareWidgetMigration(ctx, installation, source, artifact, placements, storage, signal);
const snapshot = JSON.stringify({
bindings: installation.bindings,
storage: storage.filter((row) => !isWebhookStorage(row)),
placements: placements.map(({ id: itemId, options }) => ({ id: itemId, options })),
});
await persistPackageArtifact(ctx, source, artifact);
const changes = {
activeArtifactId: artifact.digest,
previousArtifactId: installation.activeArtifactId,
previousSnapshot: snapshot,
enabled: true,
updatedAt: new Date(),
};
signal.throwIfAborted();
await handleTransactionsAsync(ctx.db, {
async handleAsync(database, schema) {
await database.transaction(async (transaction) => {
for (const placement of migrated.placements)
await transaction
.update(schema.items)
.set({ options: placement.options })
.where(eq(schema.items.id, placement.id));
if (migrated.storage !== storage) {
await transaction
.delete(schema.customWidgetStorage)
.where(
and(
eq(schema.customWidgetStorage.installationId, id),
or(
ne(schema.customWidgetStorage.scope, "system"),
notLike(schema.customWidgetStorage.key, "webhook:%"),
),
),
);
if (migrated.storage.length) await transaction.insert(schema.customWidgetStorage).values(migrated.storage);
}
await transaction
.update(schema.customWidgetInstallations)
.set(changes)
.where(eq(schema.customWidgetInstallations.id, id));
});
},
handleSync(database) {
database.transaction((transaction) => {
for (const placement of migrated.placements)
transaction.update(items).set({ options: placement.options }).where(eq(items.id, placement.id)).run();
if (migrated.storage !== storage) {
transaction
.delete(customWidgetStorage)
.where(
and(
eq(customWidgetStorage.installationId, id),
or(ne(customWidgetStorage.scope, "system"), notLike(customWidgetStorage.key, "webhook:%")),
),
)
.run();
if (migrated.storage.length) transaction.insert(customWidgetStorage).values(migrated.storage).run();
}
transaction.update(customWidgetInstallations).set(changes).where(eq(customWidgetInstallations.id, id)).run();
});
},
});
await publishWidgetPackageChange({
installationId: id,
itemIds: placements.map(({ id: itemId }) => itemId),
kind: "activation",
});
return { id, artifactDigest: artifact.digest, affectedPlacements: placements.length };
}
interface RollbackSnapshot {
bindings: string;
storage: Array<typeof customWidgetStorage.$inferSelect>;
placements: Array<{ id: string; options: string }>;
}
export async function rollbackWidgetPackage(ctx: PackageContext, id: string) {
return withWidgetInstallationLock(id, (signal) => rollbackLocked(ctx, id, signal));
}
async function rollbackLocked(ctx: PackageContext, id: string, signal: AbortSignal) {
const installation = await getInstallation(ctx, id);
if (!installation.previousArtifactId || !installation.previousSnapshot) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "No previous activation is available" });
}
const snapshot = JSON.parse(installation.previousSnapshot) as RollbackSnapshot;
const previous = await getArtifact(ctx, installation.previousArtifactId);
assertWidgetArtifactIntegrity(previous.artifact, previous.source);
await getWidgetPackageSupervisor().preflight({ artifact: previous.artifact });
const placements = await getPackagePlacements(ctx, id);
const currentPlacements = new Set(placements.map((placement) => placement.id));
snapshot.placements = snapshot.placements.filter((placement) => currentPlacements.has(placement.id));
const snapshots = new Map(snapshot.placements.map((placement) => [placement.id, placement.options]));
const targets = placements.map((placement) => {
const options = snapshots.get(placement.id) ?? placement.options;
const packageOptions = parsePackagePlacement(options);
if (!packageOptions)
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "A rollback placement needs repair" });
return { ...placement, options, packageOptions };
});
// Check restored bindings and placements added after activation against the previous release.
const compatible = await prepareWidgetMigration(
ctx,
{ ...installation, activeArtifactId: null, bindings: snapshot.bindings },
previous.source,
previous.artifact,
targets,
[],
signal,
);
snapshot.placements = compatible.placements;
// Capability revocation is never undone by restoring a package's managed data.
const rows = snapshot.storage
.filter((row) => !isWebhookStorage(row))
.map((row) => ({ ...row, updatedAt: new Date(row.updatedAt) }));
const changes = {
activeArtifactId: installation.previousArtifactId,
previousArtifactId: null,
previousSnapshot: null,
bindings: snapshot.bindings,
enabled: true,
updatedAt: new Date(),
};
signal.throwIfAborted();
await handleTransactionsAsync(ctx.db, {
async handleAsync(database, schema) {
await database.transaction(async (transaction) => {
await transaction
.update(schema.customWidgetInstallations)
.set(changes)
.where(eq(schema.customWidgetInstallations.id, id));
await transaction
.delete(schema.customWidgetStorage)
.where(
and(
eq(schema.customWidgetStorage.installationId, id),
or(ne(schema.customWidgetStorage.scope, "system"), notLike(schema.customWidgetStorage.key, "webhook:%")),
),
);
if (rows.length) await transaction.insert(schema.customWidgetStorage).values(rows);
for (const placement of snapshot.placements)
await transaction
.update(schema.items)
.set({ options: placement.options })
.where(eq(schema.items.id, placement.id));
});
},
handleSync(database) {
database.transaction((transaction) => {
transaction.update(customWidgetInstallations).set(changes).where(eq(customWidgetInstallations.id, id)).run();
transaction
.delete(customWidgetStorage)
.where(
and(
eq(customWidgetStorage.installationId, id),
or(ne(customWidgetStorage.scope, "system"), notLike(customWidgetStorage.key, "webhook:%")),
),
)
.run();
if (rows.length) transaction.insert(customWidgetStorage).values(rows).run();
for (const placement of snapshot.placements)
transaction.update(items).set({ options: placement.options }).where(eq(items.id, placement.id)).run();
});
},
});
await publishWidgetPackageChange({ installationId: id, itemIds: [...currentPlacements], kind: "activation" });
return { id, artifactDigest: installation.previousArtifactId };
}
@@ -0,0 +1,15 @@
import { TRPCError } from "@trpc/server";
import { createSessionAsync } from "@homarr/auth/server";
import { eq } from "@homarr/db";
import { users } from "@homarr/db/schema";
import type { PackageContext } from "./types";
/** WebSocket contexts can live longer than their user's group membership or account. */
export async function refreshWidgetActor(ctx: PackageContext): Promise<PackageContext> {
if (!ctx.session) return ctx;
if (Date.parse(ctx.session.expires) <= Date.now()) throw new TRPCError({ code: "UNAUTHORIZED" });
const user = await ctx.db.query.users.findFirst({ where: eq(users.id, ctx.session.user.id) });
if (!user) throw new TRPCError({ code: "UNAUTHORIZED" });
const current = await createSessionAsync(ctx.db, user);
return { ...ctx, session: { ...ctx.session, user: { ...ctx.session.user, permissions: current.user.permissions } } };
}
@@ -0,0 +1,129 @@
import { readdir, rm, stat } from "node:fs/promises";
import { join } from "node:path";
import { z } from "zod/v4";
import { asc, eq } from "@homarr/db";
import { customWidgetArtifacts } from "@homarr/db/schema";
import { customWidgetPackageSchema } from "@homarr/custom-widgets/package";
import { hashWidgetArtifactContent } from "@homarr/custom-widgets/package/server";
import { hasWidgetArtifactLease, widgetArtifactGraceMs, withWidgetArtifactReferenceLock } from "./artifact-references";
import { getWidgetPackageDirectory } from "./paths";
import { widgetPackageAdminProcedure } from "./procedure";
import type { PackageContext } from "./types";
const cleanupInput = z.object({
dryRun: z.boolean().default(true),
maximumArtifacts: z.number().int().min(1).max(500).default(100),
maximumDirectories: z.number().int().min(1).max(100).default(20),
});
export const packageArtifactCleanupProcedures = {
cleanupArtifacts: widgetPackageAdminProcedure
.input(cleanupInput)
.mutation(({ ctx, input }) => cleanupWidgetArtifacts(ctx, input)),
};
/** Explicit bounded maintenance; keep immutable archives needed by disabled and never-activated drafts too. */
export async function cleanupWidgetArtifacts(ctx: PackageContext, input: z.infer<typeof cleanupInput>) {
return withWidgetArtifactReferenceLock(async (signal) => {
const cutoff = Date.now() - widgetArtifactGraceMs;
const installations = await ctx.db.query.customWidgetInstallations.findMany({
columns: { activeArtifactId: true, previousArtifactId: true, draft: true },
});
const retainedIds = new Set<string>();
const retainedSources = new Set<string>();
for (const installation of installations) {
if (installation.activeArtifactId) retainedIds.add(installation.activeArtifactId);
if (installation.previousArtifactId) retainedIds.add(installation.previousArtifactId);
const digest = sourceDigest(installation.draft);
if (digest) retainedSources.add(digest);
}
const candidates: string[] = [];
const diskIds = new Set(retainedIds);
const diskSources = new Set(retainedSources);
let offset = 0;
let scanned = 0;
let unknownSource = false;
// Small pages avoid loading every package's potentially large source into memory together.
while (true) {
signal.throwIfAborted();
const rows = await ctx.db.query.customWidgetArtifacts.findMany({
columns: { id: true, source: true, createdAt: true },
orderBy: asc(customWidgetArtifacts.id),
limit: 10,
offset,
});
if (rows.length === 0) break;
offset += rows.length;
scanned += rows.length;
for (const row of rows) {
const digest = sourceDigest(row.source);
if (!digest) unknownSource = true;
const retained =
!digest ||
retainedIds.has(row.id) ||
retainedSources.has(digest) ||
row.createdAt.getTime() >= cutoff ||
(await hasWidgetArtifactLease(row.id));
if (!retained && candidates.length < input.maximumArtifacts) candidates.push(row.id);
else {
diskIds.add(row.id);
if (digest) diskSources.add(digest);
}
}
}
// Scan before deletion so offset pagination cannot skip shifted records.
if (!input.dryRun)
for (const id of candidates) {
signal.throwIfAborted();
await ctx.db.delete(customWidgetArtifacts).where(eq(customWidgetArtifacts.id, id));
}
const directories: string[] = [];
for (const [kind, retained] of [
["runtime", diskIds],
["dependencies", diskSources],
] as const) {
if (kind === "dependencies" && unknownSource) continue;
// These directories are mutable runtime data, never deployment inputs.
const parent = join(/* turbopackIgnore: true */ getWidgetPackageDirectory(), kind);
for (const entry of await listDirectories(parent)) {
if (directories.length >= input.maximumDirectories) break;
if (!entry.isDirectory() || !/^[a-f0-9]{64}$/u.test(entry.name) || retained.has(entry.name)) continue;
const path = join(/* turbopackIgnore: true */ parent, entry.name);
const metadata = await stat(/* turbopackIgnore: true */ path);
if (metadata.mtimeMs >= cutoff) continue;
signal.throwIfAborted();
directories.push(`${kind}/${entry.name}`);
if (!input.dryRun) await rm(path, { recursive: true, force: true });
}
}
return {
dryRun: input.dryRun,
graceDays: 7,
scannedArtifacts: scanned,
artifactIds: candidates,
directories,
limited: candidates.length >= input.maximumArtifacts || directories.length >= input.maximumDirectories,
};
});
}
function sourceDigest(raw: string) {
try {
const source = customWidgetPackageSchema.safeParse(JSON.parse(raw));
if (source.success) return hashWidgetArtifactContent(source.data);
} catch {
// Damaged artifact rows are retained for recovery, never assumed to be disposable.
}
return null;
}
async function listDirectories(directory: string) {
try {
return await readdir(directory, { withFileTypes: true });
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") return [];
throw error;
}
}
@@ -0,0 +1,47 @@
import { mkdir, utimes } from "node:fs/promises";
import { join } from "node:path";
import { createRedisClient } from "@homarr/core/infrastructure/redis";
import { hashWidgetArtifactContent } from "@homarr/custom-widgets/package/server";
import type { CustomWidgetPackage } from "@homarr/custom-widgets/package";
import { withWidgetInstallationLock } from "./coordination";
import { getWidgetPackageDirectory } from "./paths";
export const widgetArtifactGraceMs = 7 * 24 * 60 * 60 * 1000;
const leases = new Map<string, number>();
let redis: ReturnType<typeof createRedisClient> | undefined;
/** Always acquire installation locks before this short reference lock, never the reverse. */
export function withWidgetArtifactReferenceLock<T>(operation: (signal: AbortSignal) => Promise<T>) {
return withWidgetInstallationLock("__artifact-references__", operation);
}
export async function leaseWidgetArtifact(id: string) {
if (process.env.NODE_ENV === "test" || process.env.CI !== undefined) {
leases.set(id, Date.now() + widgetArtifactGraceMs);
return;
}
redis ??= createRedisClient();
await redis.set(`custom-widget:artifact-lease:${id}`, "1", "PX", widgetArtifactGraceMs);
}
export async function hasWidgetArtifactLease(id: string) {
if (process.env.NODE_ENV === "test" || process.env.CI !== undefined) {
const expires = leases.get(id) ?? 0;
if (expires > Date.now()) return true;
leases.delete(id);
return false;
}
redis ??= createRedisClient();
return (await redis.exists(`custom-widget:artifact-lease:${id}`)) === 1;
}
/** Mark a dependency cache as in use before compiling outside the reference lock. */
export async function reserveWidgetBuild(source: CustomWidgetPackage) {
if (Object.keys(source.dependencies).length === 0) return;
const directory = join(getWidgetPackageDirectory(), "dependencies", hashWidgetArtifactContent(source));
await mkdir(directory, { recursive: true, mode: 0o700 });
const now = new Date();
await utimes(directory, now, now);
}
@@ -0,0 +1,54 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { resolvePackagePlacement } from "./records";
import { resolvePackagePreview } from "./preview-store";
import type { PackageContext } from "./types";
export async function widgetArtifactResponse(
ctx: PackageContext,
input: {
itemId?: string;
digest?: string;
previewId?: string;
surface: string;
css: boolean;
},
) {
try {
const surface = z.enum(["tile", "advanced", "configuration"]).parse(input.surface);
const resolved = input.previewId
? await resolvePackagePreview(ctx, input.previewId)
: await resolvePackagePlacement(ctx, input.itemId ?? "");
if (!input.previewId && resolved.artifact.digest !== input.digest) throw new TRPCError({ code: "NOT_FOUND" });
const entry = resolved.artifact.client[surface];
if (!entry) throw new TRPCError({ code: "NOT_FOUND" });
let content = entry.css;
if (resolved.artifact.manifest.styles !== "global") {
content = `@scope ([data-custom-widget-artifact="${resolved.artifact.digest}"]) {\n${content}\n}`;
}
let contentType = "text/css; charset=utf-8";
if (!input.css) {
contentType = "text/javascript; charset=utf-8";
content = `const registry=globalThis[Symbol.for('homarr.widget.modules.v1')];\nawait registry.prepare(${JSON.stringify(entry.hostModules)});\nconst require=registry.require;\nconst module={exports:{}};\nconst exports=module.exports;\n${entry.javascript}\nexport default module.exports.default ?? module.exports;\n`;
}
return new Response(content, {
headers: {
"content-type": contentType,
"cache-control": "private, no-store",
"x-content-type-options": "nosniff",
"cross-origin-resource-policy": "same-origin",
},
});
} catch (error) {
let status = 500;
if (error instanceof z.ZodError) status = 400;
if (error instanceof TRPCError) {
if (error.code === "NOT_FOUND") status = 404;
if (error.code === "UNAUTHORIZED") status = 401;
if (error.code === "FORBIDDEN") status = 403;
if (error.code === "PRECONDITION_FAILED") status = 412;
}
return new Response("Widget artifact unavailable", { status, headers: { "cache-control": "no-store" } });
}
}
@@ -0,0 +1,32 @@
import { z } from "zod/v4";
import { eq } from "@homarr/db";
import { customWidgetArtifacts } from "@homarr/db/schema";
import { customWidgetArtifactSchema, customWidgetPackageSchema } from "@homarr/custom-widgets/package";
import { assertWidgetArtifactIntegrity } from "@homarr/custom-widgets/package/server";
import { getArtifact, getInstallation, readPackageDraft } from "./records";
import { widgetPackageAdminProcedure } from "./procedure";
export const packageArtifactReviewProcedures = {
inspectArtifact: widgetPackageAdminProcedure
.input(z.object({ id: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const installation = await getInstallation(ctx, input.id);
const parsed = customWidgetPackageSchema.safeParse(readPackageDraft(installation.draft));
let artifact = null;
if (parsed.success) {
const row = await ctx.db.query.customWidgetArtifacts.findFirst({
where: eq(customWidgetArtifacts.source, JSON.stringify(parsed.data)),
});
if (row) {
artifact = customWidgetArtifactSchema.parse(JSON.parse(row.artifact));
assertWidgetArtifactIntegrity(artifact, parsed.data);
}
}
let activeArtifact = null;
if (installation.activeArtifactId)
activeArtifact = (await getArtifact(ctx, installation.activeArtifactId)).artifact;
return { artifact, activeArtifact };
}),
};
@@ -0,0 +1,60 @@
import { TRPCError } from "@trpc/server";
import { getBindingsDigest } from "./connections";
import { authorizePackageHandler } from "./permissions";
import { packageDigest, resolvePackagePlacement } from "./records";
import type { ResolvedPackagePlacement } from "./records";
import type { PackageContext } from "./types";
export { refreshWidgetActor } from "./actor";
import { refreshWidgetActor } from "./actor";
export async function observeWidgetInvocationAuthorization(
ctx: PackageContext,
resolved: ResolvedPackagePlacement,
operation: { name: string; input: unknown; kind: "query" | "action" | "subscription" },
outerSignal?: AbortSignal,
) {
const controller = new AbortController();
const bindingDigest = await getBindingsDigest(ctx, resolved.bindings);
let checking: Promise<void> | undefined;
const abort = () => controller.abort(outerSignal?.reason);
outerSignal?.addEventListener("abort", abort, { once: true });
if (outerSignal?.aborted) abort();
const check = () => {
checking ??= (async () => {
const actor = await refreshWidgetActor(ctx);
const current = await resolvePackagePlacement(actor, resolved.item.id);
await authorizePackageHandler(actor, current, operation.name, operation.kind, operation.input);
if (
current.installation.id !== resolved.installation.id ||
current.artifact.digest !== resolved.artifact.digest ||
packageDigest(current.configuration) !== packageDigest(resolved.configuration) ||
(await getBindingsDigest(actor, current.bindings)) !== bindingDigest
) {
throw new TRPCError({ code: "CONFLICT", message: "Widget placement changed; reload it before continuing" });
}
})()
.catch((error: unknown) => {
controller.abort(error);
throw error;
})
.finally(() => {
checking = undefined;
});
return checking;
};
const timer = setInterval(() => {
void check().catch(() => undefined);
}, 15_000);
timer.unref();
return {
signal: controller.signal,
check,
close: () => {
clearInterval(timer);
outerSignal?.removeEventListener("abort", abort);
controller.abort();
},
};
}
@@ -0,0 +1,59 @@
import { z } from "zod/v4";
import { eq } from "@homarr/db";
import { boards, customWidgetGuestGrants } from "@homarr/db/schema";
import { throwIfActionForbiddenAsync } from "../../board/board-access";
import { getBindingsDigest } from "./connections";
import { getPackageActionDigest } from "./permissions";
import { packageDigest, resolvePackagePlacement } from "./records";
import type { ResolvedPackagePlacement } from "./records";
import type { PackageContext } from "./types";
import { refreshWidgetActor } from "./actor";
export function getPreviewCapabilities(resolved: ResolvedPackagePlacement) {
return {
actions: Object.fromEntries(
Object.entries(resolved.artifact.manifest.handlers)
.filter(([, handler]) => handler.kind === "action")
.map(([name]) => [name, { allowed: true, simulated: !resolved.preview?.liveActions }]),
),
};
}
export async function getWidgetCapabilities(ctx: PackageContext, itemId: string) {
ctx = await refreshWidgetActor(ctx);
const resolved = await resolvePackagePlacement(ctx, itemId);
const actions: Record<string, { allowed: boolean; allowedInputs?: unknown[] }> = {};
const grants = await ctx.db.query.customWidgetGuestGrants.findMany({
where: eq(customWidgetGuestGrants.itemId, itemId),
});
const bindingsDigest = packageDigest({
connections: await getBindingsDigest(ctx, resolved.bindings),
options: resolved.configuration,
});
for (const [name, handler] of Object.entries(resolved.artifact.manifest.handlers)) {
if (handler.kind !== "action") continue;
if (ctx.session) {
let allowed = false;
try {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, resolved.item.boardId), handler.permission);
allowed = true;
} catch {
/* Return capability status; invocation performs the authoritative permission check. */
}
actions[name] = { allowed };
continue;
}
const grant = grants.find(
(candidate) =>
candidate.handler === name &&
candidate.artifactDigest === getPackageActionDigest(resolved) &&
candidate.bindingsDigest === bindingsDigest,
);
const allowed = handler.guestAccess && Boolean(grant);
actions[name] = { allowed };
if (allowed && grant) actions[name].allowedInputs = z.array(z.unknown()).parse(JSON.parse(grant.allowedInputs));
}
return { actions };
}
@@ -0,0 +1,38 @@
import { z } from "zod/v4";
export const widgetCollectorSchema = z.object({
id: z.string(),
itemId: z.string(),
installationId: z.string(),
userId: z.string(),
name: z.string().min(1).max(128),
handler: z.string().min(1),
input: z.unknown().default({}),
valuePath: z.string().max(256).default(""),
unit: z.string().max(64).default(""),
intervalSeconds: z.number().int().min(15).max(86_400).default(60),
retentionDays: z.number().int().min(1).max(365).default(7),
maximumPoints: z.number().int().min(10).max(10_000).default(2000),
enabled: z.boolean().default(false),
});
export type WidgetCollector = z.infer<typeof widgetCollectorSchema>;
export interface WidgetHistorySample {
timestamp: number;
value: unknown;
}
export interface WidgetHistory {
samples: WidgetHistorySample[];
sourceDigest: string;
unit: string;
lastAttempt: number;
lastError?: string;
}
export function selectCollectedValue(value: unknown, path: string): unknown {
if (!path) return value;
for (const key of path.split(".")) {
if (value === null || typeof value !== "object" || !Object.hasOwn(value, key)) return null;
value = (value as Record<string, unknown>)[key];
}
return value;
}
@@ -0,0 +1,81 @@
import { widgetPackageAdminProcedure } from "./procedure";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { and, eq, inArray } from "@homarr/db";
import { customWidgetStorage } from "@homarr/db/schema";
import { publicProcedure } from "../../../trpc";
import { widgetCollectorSchema } from "./collection-contracts";
import { collectorKey, historyKey, readCollectionRecord, writeCollectionRecord } from "./collection-storage";
import { withWidgetInstallationLock } from "./coordination";
import { validatePackageHandlerInput } from "./invocations";
import { resolvePackagePlacement } from "./records";
import { readPackageHistory, widgetHistoryInputSchema } from "./history";
const admin = widgetPackageAdminProcedure;
export const packageCollectionProcedures = {
collectors: admin.input(z.object({ itemId: z.string() })).query(async ({ ctx, input }) => {
const resolved = await resolvePackagePlacement(ctx, input.itemId);
const rows = await ctx.db.query.customWidgetStorage.findMany({
where: and(
eq(customWidgetStorage.installationId, resolved.installation.id),
eq(customWidgetStorage.scope, "system"),
eq(customWidgetStorage.ownerKey, input.itemId),
),
});
return rows.flatMap((row) => {
if (!row.key.startsWith("collector:")) return [];
const parsed = widgetCollectorSchema.safeParse(JSON.parse(row.value));
if (!parsed.success) return [];
return [parsed.data];
});
}),
saveCollector: admin
.input(
widgetCollectorSchema
.omit({ id: true, installationId: true, userId: true })
.extend({ id: z.string().optional() }),
)
.mutation(async ({ ctx, input }) => {
const resolved = await resolvePackagePlacement(ctx, input.itemId);
const handler = resolved.artifact.manifest.handlers[input.handler];
if (handler?.kind !== "query")
throw new TRPCError({ code: "BAD_REQUEST", message: "Choose a declared query handler" });
validatePackageHandlerInput(handler, input.input);
const id = input.id ?? createId();
const collector = { ...input, id, installationId: resolved.installation.id, userId: ctx.session.user.id };
await withWidgetInstallationLock(resolved.installation.id, async () => {
if (input.id) {
const previous = await readCollectionRecord(ctx, resolved.installation.id, collectorKey(id));
if (widgetCollectorSchema.parse(previous).itemId !== input.itemId) throw new TRPCError({ code: "FORBIDDEN" });
}
await writeCollectionRecord(ctx, resolved.installation.id, input.itemId, collectorKey(id), collector);
});
return { id };
}),
removeCollector: admin
.input(z.object({ itemId: z.string(), id: z.string(), discardHistory: z.literal(true) }))
.mutation(async ({ ctx, input }) => {
const resolved = await resolvePackagePlacement(ctx, input.itemId);
await withWidgetInstallationLock(resolved.installation.id, async () => {
await ctx.db
.delete(customWidgetStorage)
.where(
and(
eq(customWidgetStorage.installationId, resolved.installation.id),
eq(customWidgetStorage.ownerKey, input.itemId),
eq(customWidgetStorage.scope, "system"),
inArray(customWidgetStorage.key, [collectorKey(input.id), historyKey(input.id)]),
),
);
});
}),
};
export const packageHistoryProcedures = {
packageHistory: publicProcedure
.input(widgetHistoryInputSchema.extend({ itemId: z.string() }))
.query(async ({ ctx, input }) => readPackageHistory(ctx, await resolvePackagePlacement(ctx, input.itemId), input)),
};
@@ -0,0 +1,78 @@
import { TRPCError } from "@trpc/server";
import { and, eq, handleTransactionsAsync } from "@homarr/db";
import { customWidgetStorage } from "@homarr/db/schema";
import { getBindingsDigest } from "./connections";
import { packageDigest } from "./records";
import type { ResolvedPackagePlacement } from "./records";
import type { PackageContext } from "./types";
import type { WidgetCollector, WidgetHistory } from "./collection-contracts";
export const collectorKey = (id: string) => `collector:${id}`;
export const historyKey = (id: string) => `history:${id}`;
export async function writeCollectionRecord(
ctx: PackageContext,
installationId: string,
ownerKey: string,
key: string,
value: unknown,
) {
const id = packageDigest([installationId, "system", key]);
const serialized = JSON.stringify(value);
if (Buffer.byteLength(serialized) > 1_048_576)
throw new TRPCError({ code: "PAYLOAD_TOO_LARGE", message: "Collection record exceeds 1 MiB" });
const row = { id, installationId, ownerKey, scope: "system", key, value: serialized, updatedAt: new Date() };
await handleTransactionsAsync(ctx.db, {
async handleAsync(database, schema) {
await database.transaction(async (transaction) => {
await transaction.delete(schema.customWidgetStorage).where(eq(schema.customWidgetStorage.id, id));
await transaction.insert(schema.customWidgetStorage).values(row);
});
},
handleSync(database) {
database.transaction((transaction) => {
transaction.delete(customWidgetStorage).where(eq(customWidgetStorage.id, id)).run();
transaction.insert(customWidgetStorage).values(row).run();
});
},
});
}
export async function getCollectorSourceDigest(
ctx: PackageContext,
resolved: ResolvedPackagePlacement,
collector: WidgetCollector,
) {
return packageDigest({
connections: await getBindingsDigest(ctx, resolved.bindings),
options: resolved.configuration,
handler: collector.handler,
server: resolved.artifact.server,
descriptor: resolved.artifact.manifest.handlers[collector.handler],
dependencies: resolved.artifact.dependencyLock,
input: collector.input,
path: collector.valuePath,
});
}
export async function readCollectionRecord(ctx: PackageContext, installationId: string, key: string) {
const row = await ctx.db.query.customWidgetStorage.findFirst({
where: and(
eq(customWidgetStorage.installationId, installationId),
eq(customWidgetStorage.scope, "system"),
eq(customWidgetStorage.key, key),
),
});
if (!row) return null;
return JSON.parse(row.value) as unknown;
}
export function trimWidgetHistory(history: WidgetHistory, collector: WidgetCollector) {
const earliest = Date.now() - collector.retentionDays * 86_400_000;
history.samples = history.samples.filter(({ timestamp }) => timestamp >= earliest).slice(-collector.maximumPoints);
while (history.samples.length && Buffer.byteLength(JSON.stringify(history)) > 950_000)
history.samples.splice(0, Math.max(1, Math.floor(history.samples.length / 10)));
return history;
}
@@ -0,0 +1,136 @@
import { createSessionAsync } from "@homarr/auth/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createRedisClient } from "@homarr/core/infrastructure/redis";
import { db, and, eq, like } from "@homarr/db";
import { customWidgetStorage, users } from "@homarr/db/schema";
import { selectCollectedValue, widgetCollectorSchema } from "./collection-contracts";
import type { WidgetHistory } from "./collection-contracts";
import {
collectorKey,
getCollectorSourceDigest,
historyKey,
readCollectionRecord,
trimWidgetHistory,
writeCollectionRecord,
} from "./collection-storage";
import { withWidgetInstallationLock } from "./coordination";
import { invokePackageHandler } from "./invocations";
import { resolvePackagePlacement } from "./records";
const logger = createLogger({ module: "widget-collections" });
let timer: ReturnType<typeof setInterval> | undefined;
let running = false;
let lastRetentionSweep = 0;
let redis: ReturnType<typeof createRedisClient> | undefined;
/** Every replica may schedule this tick; Redis admits one collector per sampling slot. */
export async function collectWidgetHistory() {
if (running) return;
running = true;
try {
const rows = await db.query.customWidgetStorage.findMany({
where: and(eq(customWidgetStorage.scope, "system"), like(customWidgetStorage.key, "collector:%")),
});
const sweepRetention = Date.now() - lastRetentionSweep > 600_000;
if (sweepRetention) lastRetentionSweep = Date.now();
for (let offset = 0; offset < rows.length; offset += 4) {
await Promise.all(
rows
.slice(offset, offset + 4)
.map((row) =>
collectOne(row, sweepRetention).catch(() =>
logger.warn("Widget collection did not complete", { installationId: row.installationId }),
),
),
);
}
} finally {
running = false;
}
}
async function collectOne(row: typeof customWidgetStorage.$inferSelect, sweepRetention: boolean) {
const parsed = widgetCollectorSchema.safeParse(JSON.parse(row.value));
if (!parsed.success) return;
if (sweepRetention) {
await withWidgetInstallationLock(row.installationId, async () => {
const current = widgetCollectorSchema.safeParse(
await readCollectionRecord({ db, session: null }, row.installationId, collectorKey(parsed.data.id)),
);
if (!current.success) return;
const history = (await readCollectionRecord(
{ db, session: null },
row.installationId,
historyKey(parsed.data.id),
)) as WidgetHistory | null;
if (!history) return;
const before = history.samples.length;
trimWidgetHistory(history, current.data);
if (history.samples.length !== before)
await writeCollectionRecord(
{ db, session: null },
row.installationId,
row.ownerKey,
historyKey(parsed.data.id),
history,
);
});
}
if (!parsed.data.enabled) return;
const collector = parsed.data;
redis ??= createRedisClient();
const slot = Math.floor(Date.now() / (collector.intervalSeconds * 1000));
const key = `custom-widget:collection-slot:${collector.id}:${slot}`;
if ((await redis.set(key, "1", "EX", collector.intervalSeconds * 2, "NX")) !== "OK") return;
await withWidgetInstallationLock(collector.installationId, async (signal) => {
// An owner can pause/remove/rebind a collector while it waits for another managed operation.
const raw = await readCollectionRecord({ db, session: null }, collector.installationId, collectorKey(collector.id));
const current = widgetCollectorSchema.safeParse(raw);
if (!current.success || !current.data.enabled) return;
const configured = current.data;
const user = await db.query.users.findFirst({ where: eq(users.id, configured.userId) });
if (!user) return;
const session = await createSessionAsync(db, user);
if (!session.user.permissions.includes("admin")) return;
const ctx = { db, session };
const resolved = await resolvePackagePlacement(ctx, configured.itemId).catch(() => null);
if (!resolved || resolved.installation.id !== configured.installationId) return;
const sourceDigest = await getCollectorSourceDigest(ctx, resolved, configured);
const previous = (await readCollectionRecord(
ctx,
configured.installationId,
historyKey(configured.id),
)) as WidgetHistory | null;
let samples = previous?.samples ?? [];
if (previous?.sourceDigest !== sourceDigest) samples = [];
const history: WidgetHistory = { sourceDigest, samples, unit: configured.unit, lastAttempt: Date.now() };
try {
const result = await invokePackageHandler(
ctx,
{ itemId: configured.itemId, name: configured.handler, input: configured.input },
"query",
signal,
);
history.samples.push({ timestamp: Date.now(), value: selectCollectedValue(result, configured.valuePath) });
} catch {
history.lastError = "Collection failed. Check the connection and handler in the workbench.";
}
signal.throwIfAborted();
await writeCollectionRecord(
ctx,
configured.installationId,
configured.itemId,
historyKey(configured.id),
trimWidgetHistory(history, configured),
);
});
}
export function startWidgetCollections() {
if (timer) return;
timer = setInterval(() => {
void collectWidgetHistory().catch(() => logger.warn("Widget collection scheduler is unavailable"));
}, 5000);
timer.unref();
}
@@ -0,0 +1,46 @@
import { eq } from "@homarr/db";
import { items } from "@homarr/db/schema";
import { withWidgetInstallationLock } from "./coordination";
import { publishWidgetPackageChange } from "./events";
import { parseBindings, parsePackagePlacement } from "./records";
import type { PackageContext } from "./types";
/** A shared connection can change the target of several installations; let their actions finish first. */
export async function withWidgetConnectionChange<T>(
ctx: PackageContext,
connectionId: string,
change: () => Promise<T>,
) {
const installations = await ctx.db.query.customWidgetInstallations.findMany();
const placements = await ctx.db.query.items.findMany({ where: eq(items.kind, "customApi") });
const affected = new Set(
installations
.filter((row) => Object.values(parseBindings(row.bindings)).includes(connectionId))
.map(({ id }) => id),
);
const byInstallation = new Map<string, string[]>();
for (const placement of placements) {
const options = parsePackagePlacement(placement.options);
if (!options) continue;
const ids = byInstallation.get(options.definitionId) ?? [];
ids.push(placement.id);
byInstallation.set(options.definitionId, ids);
if (Object.values(options.connectionBindings).includes(connectionId)) affected.add(options.definitionId);
}
const ids = [...affected].toSorted();
async function lock(index: number, signal?: AbortSignal): Promise<T> {
const id = ids[index];
if (id) return withWidgetInstallationLock(id, (innerSignal) => lock(index + 1, innerSignal), signal);
signal?.throwIfAborted();
const result = await change();
for (const installationId of ids)
await publishWidgetPackageChange({
installationId,
itemIds: byInstallation.get(installationId) ?? [],
kind: "bindings",
});
return result;
}
return lock(0);
}
@@ -0,0 +1,164 @@
import { widgetPackageAdminProcedure } from "./procedure";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { decryptSecret, encryptSecret } from "@homarr/common/server";
import { eq } from "@homarr/db";
import { customWidgetConnections, integrations } from "@homarr/db/schema";
import { packageDigest } from "./records";
import type { PackageContext } from "./types";
import { withWidgetConnectionChange } from "./connection-changes";
export const connectionConfigurationSchema = z.object({
kind: z.enum(["http", "integration", "service"]).default("http"),
serviceType: z
.string()
.min(1)
.max(128)
.regex(/^[A-Za-z][A-Za-z0-9._-]*$/u)
.optional(),
settings: z.record(z.string(), z.json()).optional(),
baseUrl: z.string().url().optional(),
browserUrl: z.string().url().optional(),
auth: z.enum(["none", "basic", "bearer", "headers", "query", "cookie"]).default("none"),
headers: z.record(z.string(), z.string()).default({}),
tls: z
.object({ rejectUnauthorized: z.boolean().default(true), ca: z.string().optional(), cert: z.string().optional() })
.optional(),
maxResponseBytes: z
.number()
.int()
.min(1024)
.max(64 * 1024 * 1024)
.default(16 * 1024 * 1024),
timeoutMs: z.number().int().min(100).max(300_000).default(30_000),
});
export async function getBoundConnection(ctx: PackageContext, bindings: Record<string, string>, name: string) {
const id = bindings[name];
if (!id) throw new TRPCError({ code: "PRECONDITION_FAILED", message: `Connection '${name}' needs setup` });
const row = await ctx.db.query.customWidgetConnections.findFirst({ where: eq(customWidgetConnections.id, id) });
if (!row) throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Bound connection is unavailable" });
const configuration = connectionConfigurationSchema.parse(JSON.parse(row.configuration));
const secrets = z
.record(z.string(), z.string())
.parse(JSON.parse(decryptSecret(row.encryptedSecrets as `${string}.${string}`)));
return { row, configuration, secrets };
}
export async function getBindingsDigest(ctx: PackageContext, bindings: Record<string, string>) {
const rows = await Promise.all(
Object.entries(bindings)
.toSorted()
.map(async ([name, id]) => {
const row = await ctx.db.query.customWidgetConnections.findFirst({ where: eq(customWidgetConnections.id, id) });
let integration;
if (row?.integrationId)
integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, row.integrationId),
with: { secrets: true },
});
return {
name,
id,
configuration: row?.configuration,
encryptedSecrets: row?.encryptedSecrets,
integration: integration && {
id: integration.id,
url: integration.url,
kind: integration.kind,
secrets: integration.secrets.map(({ kind, value }) => ({ kind, value })),
},
};
}),
);
return packageDigest(rows);
}
const admin = widgetPackageAdminProcedure;
export const packageConnectionProcedures = {
connections: admin.query(async ({ ctx }) => {
const rows = await ctx.db.query.customWidgetConnections.findMany();
return rows.map(({ encryptedSecrets: _secrets, ...row }) => ({
...row,
configuration: JSON.parse(row.configuration) as unknown,
}));
}),
saveConnection: admin
.input(
z
.object({
id: z.string().optional(),
name: z.string().trim().min(1).max(128),
integrationId: z.string().nullable().default(null),
configuration: connectionConfigurationSchema,
secrets: z.record(z.string(), z.string()).optional(),
secretUpdates: z.record(z.string(), z.string()).optional(),
})
.refine((input) => !input.secretUpdates || (Boolean(input.id) && !input.secrets), {
message: "Patch existing credentials or replace them; do not supply both",
}),
)
.mutation(async ({ ctx, input }) => {
const id = input.id ?? createId();
const existing = input.id
? await ctx.db.query.customWidgetConnections.findFirst({ where: eq(customWidgetConnections.id, id) })
: null;
if (input.id && !existing) throw new TRPCError({ code: "NOT_FOUND" });
if (input.configuration.kind === "integration") {
if (!input.integrationId) throw new TRPCError({ code: "BAD_REQUEST", message: "Choose an integration" });
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, input.integrationId),
});
if (!integration) throw new TRPCError({ code: "NOT_FOUND", message: "Integration not found" });
} else if (input.configuration.kind === "service") {
if (!input.configuration.serviceType)
throw new TRPCError({
code: "BAD_REQUEST",
message: "Choose a service type, such as mqtt, sqlite, ssh or custom",
});
} else if (!input.configuration.baseUrl) {
throw new TRPCError({ code: "BAD_REQUEST", message: "A connection URL is required" });
}
const row = {
id,
name: input.name,
integrationId: input.configuration.kind === "integration" ? input.integrationId : null,
configuration: JSON.stringify(input.configuration),
encryptedSecrets: existing?.encryptedSecrets ?? encryptSecret("{}"),
updatedAt: new Date(),
};
if (existing) {
await withWidgetConnectionChange(ctx, id, async () => {
const current = await ctx.db.query.customWidgetConnections.findFirst({
where: eq(customWidgetConnections.id, id),
});
if (!current) throw new TRPCError({ code: "NOT_FOUND" });
row.encryptedSecrets = current.encryptedSecrets;
if (input.secrets) row.encryptedSecrets = encryptSecret(JSON.stringify(input.secrets));
if (input.secretUpdates) {
const savedConfiguration = connectionConfigurationSchema.parse(JSON.parse(current.configuration));
if (
savedConfiguration.kind !== input.configuration.kind ||
savedConfiguration.auth !== input.configuration.auth
)
throw new TRPCError({
code: "CONFLICT",
message: "Authentication changed; reload the connection before editing credentials",
});
const savedSecrets = z
.record(z.string(), z.string())
.parse(JSON.parse(decryptSecret(current.encryptedSecrets as `${string}.${string}`)));
row.encryptedSecrets = encryptSecret(JSON.stringify({ ...savedSecrets, ...input.secretUpdates }));
}
await ctx.db.update(customWidgetConnections).set(row).where(eq(customWidgetConnections.id, id));
});
} else {
if (input.secrets) row.encryptedSecrets = encryptSecret(JSON.stringify(input.secrets));
await ctx.db.insert(customWidgetConnections).values({ ...row, createdAt: new Date() });
}
return { id };
}),
};
@@ -0,0 +1,96 @@
import { TRPCError } from "@trpc/server";
import { parse, stringify } from "superjson";
import { z } from "zod/v4";
import { isRecord } from "@homarr/common";
import { and, eq } from "@homarr/db";
import { boards, customWidgetDefinitions, items } from "@homarr/db/schema";
import { getCustomWidgetDefaultOptions, validateCustomWidgetOptions } from "@homarr/custom-widgets/core";
import { throwIfActionForbiddenAsync } from "../../board/board-access";
import { parseStoredCustomWidgetDefinition } from "../stored-definition";
import { withWidgetInstallationLock } from "./coordination";
import { getArtifact, getInstallation, getPackagePlacements } from "./records";
import { widgetPackageAdminProcedure } from "./procedure";
import { publishWidgetPackageChange } from "./events";
export const packageConversionPlacementProcedures = {
legacyPlacements: widgetPackageAdminProcedure
.input(z.object({ definitionId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const placements = await getPackagePlacements(ctx, input.definitionId);
const boardRows = await ctx.db.query.boards.findMany();
return placements.map(({ id, boardId }) => ({
id,
boardId,
boardName: boardRows.find((board) => board.id === boardId)?.name ?? boardId,
}));
}),
migrateV2Placement: widgetPackageAdminProcedure
.input(z.object({ id: z.string().min(1), itemId: z.string().min(1), definitionId: z.string().min(1) }))
.mutation(({ ctx, input }) =>
withWidgetInstallationLock(input.id, async (signal) => {
const installation = await getInstallation(ctx, input.id);
if (!installation.enabled || !installation.activeArtifactId)
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "Preview, trust and activate the converted package first",
});
const origin = z
.object({ kind: z.literal("converted-v2"), definitionId: z.string() })
.safeParse(JSON.parse(installation.origin ?? "null"));
if (!origin.success || origin.data.definitionId !== input.definitionId)
throw new TRPCError({
code: "BAD_REQUEST",
message: "This package was not converted from the selected definition",
});
const item = await ctx.db.query.items.findFirst({ where: eq(items.id, input.itemId) });
if (!item || item.kind !== "customApi") throw new TRPCError({ code: "NOT_FOUND" });
await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "modify");
const previous: unknown = parse(item.options);
if (!isRecord(previous) || previous.definitionId !== input.definitionId)
throw new TRPCError({
code: "CONFLICT",
message: "The selected placement no longer uses the original definition",
});
const original = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.definitionId),
});
if (!original) throw new TRPCError({ code: "NOT_FOUND" });
const legacy = parseStoredCustomWidgetDefinition(original);
const { source } = await getArtifact(ctx, installation.activeArtifactId);
const configuration = {
...getCustomWidgetDefaultOptions(source.options),
...getCustomWidgetDefaultOptions(legacy.options),
...(isRecord(previous.configuration) ? previous.configuration : {}),
};
if (validateCustomWidgetOptions(source.options, configuration).length > 0)
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "The converted configuration needs repair before migration",
});
const options = stringify({
...previous,
definitionId: input.id,
configuration,
configurationVersion: source.manifest.configurationVersion,
});
signal.throwIfAborted();
const result: unknown = await ctx.db
.update(items)
.set({ options })
.where(and(eq(items.id, item.id), eq(items.options, item.options)));
const resultRow: unknown = Array.isArray(result) ? result[0] : result;
if (
isRecord(resultRow) &&
(resultRow.changes === 0 || resultRow.affectedRows === 0 || resultRow.rowCount === 0)
)
throw new TRPCError({
code: "CONFLICT",
message: "The placement changed while migrating; reload and try again",
});
await publishWidgetPackageChange({ installationId: input.id, itemIds: [item.id], kind: "activation" });
return { itemId: item.id, boardId: item.boardId };
}),
),
};
@@ -0,0 +1,108 @@
import { createHash } from "node:crypto";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import {
customJsxRequestSchema,
customWidgetSourceSchema,
getCustomWidgetConfirmation,
validateCustomWidgetOptions,
} from "@homarr/custom-widgets/core";
import {
executeCustomWidgetRequest,
invalidateCustomWidgetResponseCache,
renderRequestBody,
renderRequestTarget,
resolveCustomWidgetRequestValues,
} from "@homarr/custom-widgets/server";
import { getBoundConnection } from "./connections";
import type { ResolvedPackagePlacement } from "./records";
import type { PackageContext } from "./types";
const legacyRequestInput = z.object({
requestId: z.string(),
request: customJsxRequestSchema,
sourceAuth: customWidgetSourceSchema.shape.auth,
networkScope: customWidgetSourceSchema.shape.networkScope,
params: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).default({}),
confirmed: z.boolean().default(false),
optionValues: z.record(z.string(), z.unknown()).optional(),
});
export async function invokeLegacyWidgetRequest(
ctx: PackageContext,
resolved: ResolvedPackagePlacement,
kind: "query" | "action" | "subscription",
input: unknown,
signal?: AbortSignal,
) {
const parsed = legacyRequestInput.parse(input);
const { request, sourceAuth, params } = parsed;
if (request.kind === "action" && kind !== "action")
throw new TRPCError({ code: "FORBIDDEN", message: "Legacy actions require an action handler" });
if (request.kind === "action" && getCustomWidgetConfirmation(request) && !parsed.confirmed)
throw new TRPCError({ code: "BAD_REQUEST", message: "Confirm this action before continuing" });
signal?.throwIfAborted();
const connection = await getBoundConnection(ctx, resolved.bindings, request.source);
const baseUrl = connection.configuration.baseUrl;
if (!baseUrl || connection.configuration.kind !== "http")
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "The converted request needs an HTTP connection" });
let configuration = resolved.configuration;
if (parsed.optionValues) {
if (
request.kind !== "query" ||
!Object.values(resolved.source.options).some((option) => option.choicesFrom?.request === parsed.requestId)
)
throw new TRPCError({ code: "FORBIDDEN", message: "Only declared configuration queries accept unsaved options" });
configuration = { ...configuration, ...parsed.optionValues };
if (validateCustomWidgetOptions(resolved.source.options, configuration).length > 0)
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid widget configuration" });
}
const values = resolveCustomWidgetRequestValues(request, configuration, params);
const secrets = { ...connection.secrets };
if (sourceAuth === "bearer" && secrets.token) secrets.apiKey = secrets.token;
if (typeof sourceAuth === "object") {
const apiKey = secrets[sourceAuth.name];
if (apiKey) secrets.apiKey = apiKey;
}
let type = "none";
let headerName: string | undefined;
if (request.auth !== "none") {
if (typeof sourceAuth === "string") type = sourceAuth;
else {
type = sourceAuth.type;
headerName = sourceAuth.name;
}
}
const prefix = `converted:${resolved.installation.id}:${resolved.item.id}:${ctx.session?.user.id ?? "guest"}:`;
const digest = createHash("sha256")
.update(
JSON.stringify({
artifact: resolved.artifact.digest,
values,
configuration: connection.row.configuration,
secrets: connection.row.encryptedSecrets,
}),
)
.digest("hex");
const response = await executeCustomWidgetRequest({
baseUrl,
targetUrl: renderRequestTarget(baseUrl, request, values),
method: request.method,
body: renderRequestBody(request, values),
staticHeaders: request.headers,
auth: {
type,
headerName,
secrets: Object.entries(secrets).map(([secretKind, value]) => ({ kind: secretKind, value })),
},
networkScope: parsed.networkScope,
kind: request.kind,
cacheKey: `${prefix}${parsed.requestId}:${digest}`,
cacheTtlSeconds: request.cacheSeconds ?? 0,
signal,
});
if (request.kind === "action" && response.ok) invalidateCustomWidgetResponseCache([prefix]);
return { ...response, invalidates: request.invalidates };
}
@@ -0,0 +1,87 @@
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { collectCustomWidgetRequestReferences, getCustomWidgetConfirmation } from "@homarr/custom-widgets/core";
import { customWidgetPackageSchema } from "@homarr/custom-widgets/package";
/** Portable source carries request declarations, never installation IDs, URLs or credentials. */
export function convertLegacyWidgetSource(widget: HomarrCustomWidgetV2, packageId: string, name: string) {
const capabilities = Object.entries(widget.requests).map(([id, request]) => ({
id,
kind: request.kind,
method: request.method,
trigger: request.trigger,
minimumBoardPermission: request.permission,
confirmation: getCustomWidgetConfirmation(request),
invalidates: request.invalidates,
}));
const requests = Object.fromEntries(
Object.entries(widget.requests).map(([id, request]) => [
id,
{
request,
sourceAuth: widget.sources[request.source]?.auth ?? "none",
networkScope: widget.sources[request.source]?.networkScope ?? "private",
},
]),
);
const files: Record<string, string> = {
"parameters.json": JSON.stringify(
Object.fromEntries(
Object.entries(widget.requests).map(([id, request]) => [
id,
[...collectCustomWidgetRequestReferences(request).params],
]),
),
null,
2,
),
"legacy.json": JSON.stringify({ template: widget.template, requestCapabilities: capabilities }, null, 2),
"options.json": JSON.stringify(widget.options, null, 2),
"requests.json": JSON.stringify(requests, null, 2),
"tile.tsx": `import { LegacyWidget } from "@homarr/widget-sdk/legacy";\nimport legacy from "./legacy.json";\nexport default function Widget() { return <LegacyWidget {...legacy} />; }\n`,
"configuration.tsx": `import { LegacyConfiguration } from "@homarr/widget-sdk/legacy";\nimport schema from "./options.json";\nimport parameterNames from "./parameters.json";\nexport default function Configuration() { return <LegacyConfiguration schema={schema} parameterNames={parameterNames} />; }\n`,
"README.md": `# ${name}\n\nConverted from Custom JSX v2. The original definition and dashboard placements remain unchanged.\n\nThe tile preserves the original interpreter, supported components, inputs, load/manual requests, confirmations, invalidations, options, and configured refresh interval. Edit tile.tsx to progressively replace the compatibility component with unrestricted React and public SDK components. Requests are editable in requests.json and server.ts.\n\nNamed connections are configured locally. Exported source includes no connection URLs or saved credentials. Imported copies need connections configured before preview. Legacy API-key query authentication uses a query-auth connection whose encrypted secret key is the original query parameter name.\n\nGuest access is disabled by default. Review permissions, preview, and explicitly trust and activate this package before placing it on a dashboard.\n`,
};
const entrypoints: Record<string, string> = { tile: "tile.tsx", configuration: "configuration.tsx" };
if (capabilities.length > 0) {
entrypoints.server = "server.ts";
files["server.ts"] =
`import { defineWidgetServer } from "@homarr/widget-sdk/server";\nimport requests from "./requests.json";\nexport default defineWidgetServer(Object.fromEntries(Object.entries(requests).map(([name, specification]) => [name, async (input, context) => context.sdk.invoke("legacy.request", { ...specification, requestId: name, params: input?.params ?? {}, optionValues: input?.optionValues, confirmed: input?.confirmed === true })])));\n`;
}
return customWidgetPackageSchema.parse({
$schema: "homarr-widget-package-v3",
manifest: {
id: packageId,
version: "1.0.0",
sdkVersion: "1",
name,
description: widget.description,
icon: widget.iconUrl,
tags: ["converted", "custom-jsx"],
entrypoints,
handlers: Object.fromEntries(
Object.entries(widget.requests).map(([id, request]) => [
id,
{
kind: request.kind,
permission: request.permission,
guestAccess: false,
timeoutMs: 45_000,
},
]),
),
},
files,
dependencies: {},
connections: Object.fromEntries(
Object.entries(widget.sources).map(([id, source]) => [
id,
{
label: source.name ?? id,
kind: "http",
description: `API source used by the converted widget. Authentication: ${typeof source.auth === "string" ? source.auth : `${source.auth.type} (${source.auth.name})`}.`,
},
]),
),
options: widget.options,
});
}
@@ -0,0 +1,89 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { decryptSecret, encryptSecret } from "@homarr/common/server";
import { eq, handleTransactionsAsync } from "@homarr/db";
import { customWidgetConnections, customWidgetDefinitions, customWidgetInstallations } from "@homarr/db/schema";
import { parseStoredCustomWidgetDefinition } from "../stored-definition";
import { convertLegacyWidgetSource } from "./conversion-source";
import { widgetPackageAdminProcedure } from "./procedure";
import { packageConversionPlacementProcedures } from "./conversion-placement";
export const packageConversionProcedures = {
...packageConversionPlacementProcedures,
convertV2: widgetPackageAdminProcedure
.input(z.object({ definitionId: z.string().min(1), name: z.string().trim().min(1).max(128).optional() }))
.mutation(async ({ ctx, input }) => {
const original = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.definitionId),
with: { secrets: true },
});
if (!original) throw new TRPCError({ code: "NOT_FOUND", message: "Custom JSX definition not found" });
const widget = parseStoredCustomWidgetDefinition(original);
const id = createId();
const name = input.name ?? widget.name;
const source = convertLegacyWidgetSource(widget, `homarr.converted.${createId()}`, name);
const bindings: Record<string, string> = {};
const connections = Object.entries(widget.sources).map(([sourceId, entry]) => {
const connectionId = createId();
bindings[sourceId] = connectionId;
const originalSecrets = Object.fromEntries(
original.secrets
.filter((secret) => secret.sourceId === sourceId)
.map((secret) => [secret.kind, decryptSecret(secret.encryptedValue)]),
);
let secrets: Record<string, string> = {};
let auth = "none";
if (entry.auth === "basic") {
auth = "basic";
secrets = { username: originalSecrets.username ?? "", password: originalSecrets.password ?? "" };
}
if (entry.auth === "bearer") {
auth = "bearer";
secrets = { token: originalSecrets.apiKey ?? "" };
}
if (typeof entry.auth === "object") {
auth = "headers";
if (entry.auth.type === "apiKeyQuery") auth = "query";
secrets = { [entry.auth.name]: originalSecrets.apiKey ?? "" };
}
return {
id: connectionId,
name: `${name}: ${entry.name ?? sourceId}`.slice(0, 128),
integrationId: null,
configuration: JSON.stringify({ kind: "http", baseUrl: entry.baseUrl, auth, headers: {} }),
encryptedSecrets: encryptSecret(JSON.stringify(secrets)),
createdAt: new Date(),
updatedAt: new Date(),
};
});
const installation = {
id,
name,
draft: JSON.stringify(source),
bindings: JSON.stringify(bindings),
enabled: false,
origin: JSON.stringify({ kind: "converted-v2", definitionId: input.definitionId }),
creatorId: ctx.session.user.id,
createdAt: new Date(),
updatedAt: new Date(),
};
await handleTransactionsAsync(ctx.db, {
async handleAsync(database, schema) {
await database.transaction(async (tx) => {
if (connections.length > 0) await tx.insert(schema.customWidgetConnections).values(connections);
await tx.insert(schema.customWidgetInstallations).values(installation);
});
},
handleSync(database) {
database.transaction((tx) => {
if (connections.length > 0) tx.insert(customWidgetConnections).values(connections).run();
tx.insert(customWidgetInstallations).values(installation).run();
});
},
});
return { id, managementPath: `/manage/custom-widgets/packages/${id}` };
}),
};
@@ -0,0 +1,72 @@
import { setTimeout as delay } from "node:timers/promises";
import { TRPCError } from "@trpc/server";
import { createId } from "@homarr/common";
import { createRedisClient } from "@homarr/core/infrastructure/redis";
const localLocks = new Set<string>();
let redis: ReturnType<typeof createRedisClient> | undefined;
const leaseMs = 30_000;
const releaseScript = "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end return 0";
const extendScript =
"if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('PEXPIRE', KEYS[1], ARGV[2]) end return 0";
/** Serialize managed mutations and activation across replicas. Never retry the supplied operation. */
export async function withWidgetInstallationLock<T>(
id: string,
operation: (signal: AbortSignal) => Promise<T>,
signal?: AbortSignal,
): Promise<T> {
const local = process.env.CI !== undefined || process.env.NODE_ENV === "test";
const key = `custom-widget:installation-lock:${id}`;
const token = createId();
const controller = new AbortController();
const abort = () => controller.abort(signal?.reason);
signal?.addEventListener("abort", abort, { once: true });
if (signal?.aborted) abort();
const deadline = Date.now() + 30_000;
let acquired = false;
let heartbeat: ReturnType<typeof setInterval> | undefined;
try {
while (!acquired) {
controller.signal.throwIfAborted();
if (local) {
acquired = !localLocks.has(key);
if (acquired) localLocks.add(key);
} else {
redis ??= createRedisClient();
acquired = (await redis.set(key, token, "PX", leaseMs, "NX")) === "OK";
}
if (acquired) break;
if (Date.now() >= deadline)
throw new TRPCError({
code: "TIMEOUT",
message: "The widget is still finishing another managed change. Try again.",
});
await delay(100, undefined, { signal: controller.signal });
}
if (!local) {
const leaseClient = (redis ??= createRedisClient());
heartbeat = setInterval(() => {
void leaseClient
.eval(extendScript, 1, key, token, leaseMs)
.then((renewed) => {
if (!Number(renewed)) controller.abort(new Error("Widget mutation lease expired"));
})
.catch(() => controller.abort(new Error("Widget coordination unavailable")));
}, leaseMs / 3);
heartbeat.unref();
}
controller.signal.throwIfAborted();
// The operation checks the lease before committing. A completed mutation must not be reported as
// failed merely because cancellation arrives while its successful result is returning.
return await operation(controller.signal);
} finally {
if (heartbeat) clearInterval(heartbeat);
signal?.removeEventListener("abort", abort);
if (acquired) {
if (local) localLocks.delete(key);
else await redis?.eval(releaseScript, 1, key, token).catch(() => undefined);
}
}
}
@@ -0,0 +1,44 @@
import { TRPCError } from "@trpc/server";
import { eq } from "@homarr/db";
import { customWidgetInstallations } from "@homarr/db/schema";
import { withWidgetInstallationLock } from "./coordination";
import { publishWidgetPackageChange } from "./events";
import { getWidgetPackageSupervisor } from "./invocations";
import { getArtifact, getInstallation, getPackagePlacements } from "./records";
import type { PackageContext } from "./types";
export async function setWidgetPackageEnabled(ctx: PackageContext, id: string, enabled: boolean) {
await getInstallation(ctx, id);
const notify = async () =>
publishWidgetPackageChange({
installationId: id,
itemIds: [id, ...(await getPackagePlacements(ctx, id)).map(({ id: itemId }) => itemId)],
kind: enabled ? "activation" : "disabled",
});
if (!enabled) {
// Stop new dispatch and cancel running actions before waiting for their mutation lease.
await ctx.db
.update(customWidgetInstallations)
.set({ enabled: false, updatedAt: new Date() })
.where(eq(customWidgetInstallations.id, id));
await notify();
}
await withWidgetInstallationLock(id, async (signal) => {
const installation = await getInstallation(ctx, id);
if (enabled) {
if (!installation.activeArtifactId)
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Activate a package first" });
const { artifact } = await getArtifact(ctx, installation.activeArtifactId);
getWidgetPackageSupervisor().resetFailures(artifact.digest);
await getWidgetPackageSupervisor().preflight({ artifact });
}
signal.throwIfAborted();
await ctx.db
.update(customWidgetInstallations)
.set({ enabled, updatedAt: new Date() })
.where(eq(customWidgetInstallations.id, id));
await notify();
});
}
@@ -0,0 +1,38 @@
import { EventEmitter } from "node:events";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createSubPubChannel } from "@homarr/redis";
export interface WidgetPackageChange {
installationId: string;
itemIds: string[];
kind: "activation" | "disabled" | "bindings" | "storage";
}
const logger = createLogger({ module: "widget-packages" });
const local = new EventEmitter();
local.setMaxListeners(0);
let channel: ReturnType<typeof createSubPubChannel<WidgetPackageChange>> | undefined;
const useLocal = () => process.env.CI !== undefined || process.env.NODE_ENV === "test";
export function observeWidgetPackageChanges(callback: (change: WidgetPackageChange) => void) {
if (useLocal()) {
local.on("change", callback);
return () => {
local.off("change", callback);
};
}
channel ??= createSubPubChannel<WidgetPackageChange>("widget-package-changes", { persist: false });
return channel.subscribe(callback);
}
export async function publishWidgetPackageChange(change: WidgetPackageChange) {
if (useLocal()) {
local.emit("change", change);
return;
}
channel ??= createSubPubChannel<WidgetPackageChange>("widget-package-changes", { persist: false });
// The managed change is already committed. Never report a mutation failure that encourages replay.
await channel
.publishAsync(change)
.catch(() => logger.warn("Widget change notification unavailable", { installationId: change.installationId }));
}
@@ -0,0 +1,17 @@
import Ajv from "ajv";
import { TRPCError } from "@trpc/server";
import type { WidgetPackageHandler } from "@homarr/custom-widgets/package";
const validator = new Ajv({ strict: false, validateFormats: false });
export function validatePackageHandlerInput(handler: WidgetPackageHandler, input: unknown) {
if (!handler.inputSchema) return;
let valid: boolean;
try {
valid = validator.validate(handler.inputSchema, input) as boolean;
} catch {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Widget handler input schema is invalid" });
}
if (!valid) throw new TRPCError({ code: "BAD_REQUEST", message: validator.errorsText() });
}
@@ -0,0 +1,47 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { widgetCollectorSchema } from "./collection-contracts";
import type { WidgetHistory } from "./collection-contracts";
import { collectorKey, getCollectorSourceDigest, historyKey, readCollectionRecord } from "./collection-storage";
import { authorizePackageHandler } from "./permissions";
import type { ResolvedPackagePlacement } from "./records";
import type { PackageContext } from "./types";
export const widgetHistoryInputSchema = z.object({
collectorId: z.string(),
from: z.number().optional(),
to: z.number().optional(),
maximumPoints: z.number().int().min(2).max(10_000).default(1000),
});
export async function readPackageHistory(
ctx: PackageContext,
resolved: ResolvedPackagePlacement,
input: z.infer<typeof widgetHistoryInputSchema>,
) {
if (resolved.preview) return { samples: [], unit: "", lastAttempt: 0 };
const raw = await readCollectionRecord(ctx, resolved.installation.id, collectorKey(input.collectorId));
const collector = widgetCollectorSchema.parse(raw);
if (collector.itemId !== resolved.item.id) throw new TRPCError({ code: "FORBIDDEN" });
await authorizePackageHandler(ctx, resolved, collector.handler, "query", collector.input);
const history = (await readCollectionRecord(
ctx,
resolved.installation.id,
historyKey(collector.id),
)) as WidgetHistory | null;
if (!history || history.sourceDigest !== (await getCollectorSourceDigest(ctx, resolved, collector)))
return { samples: [], unit: collector.unit, lastAttempt: 0 };
const earliest = Math.max(input.from ?? 0, Date.now() - collector.retentionDays * 86_400_000);
const samples = history.samples
.filter(({ timestamp }) => timestamp >= earliest && timestamp <= (input.to ?? Date.now()))
.slice(-collector.maximumPoints);
if (samples.length <= input.maximumPoints) return { ...history, samples };
// Even sampling keeps the endpoints; authors needing aggregate buckets may query the full retained series.
return {
...history,
samples: Array.from(
{ length: input.maximumPoints },
(_, index) => samples[Math.floor((index * (samples.length - 1)) / (input.maximumPoints - 1))],
).filter((sample) => sample !== undefined),
};
}
@@ -0,0 +1,167 @@
import { lookup } from "node:dns/promises";
import { performance } from "node:perf_hooks";
import { Agent, Headers, fetch } from "undici";
import { z } from "zod/v4";
import { getBoundConnection } from "./connections";
import type { PackageContext } from "./types";
import { diagnoseWidgetService } from "./service-diagnostics";
export const widgetHttpInputSchema = z.object({
connection: z.string().min(1),
path: z.string().default("/"),
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"]).default("GET"),
query: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
headers: z.record(z.string(), z.string()).optional(),
body: z.unknown().optional(),
responseType: z.enum(["json", "text", "base64"]).default("json"),
});
export async function fetchWidgetConnection(
ctx: PackageContext,
bindings: Record<string, string>,
input: z.infer<typeof widgetHttpInputSchema>,
signal?: AbortSignal,
) {
const { configuration, secrets } = await getBoundConnection(ctx, bindings, input.connection);
if (configuration.kind !== "http" || !configuration.baseUrl)
throw new Error("This operation requires an HTTP connection; use getWidgetConnection for other services");
const baseUrl = new URL(configuration.baseUrl);
const url = new URL(input.path, `${configuration.baseUrl.replace(/\/$/u, "")}/`);
if (!["http:", "https:"].includes(url.protocol)) throw new Error("HTTP connections require HTTP or HTTPS");
if (url.origin !== baseUrl.origin) throw new Error("Use a separate connection for a different origin");
for (const [key, value] of Object.entries(input.query ?? {})) url.searchParams.set(key, String(value));
const headers = new Headers({ ...configuration.headers, ...input.headers });
if (configuration.auth === "bearer") headers.set("authorization", `Bearer ${secrets.token ?? ""}`);
if (configuration.auth === "basic")
headers.set(
"authorization",
`Basic ${Buffer.from(`${secrets.username ?? ""}:${secrets.password ?? ""}`).toString("base64")}`,
);
if (configuration.auth === "cookie") headers.set("cookie", secrets.cookie ?? "");
if (configuration.auth === "query") {
for (const [name, value] of Object.entries(secrets)) {
if (name !== "tlsKey") url.searchParams.set(name, value);
}
}
if (configuration.auth === "headers") {
for (const [name, value] of Object.entries(secrets)) {
if (name !== "tlsKey") headers.set(name, value);
}
}
let body: string | undefined;
if (input.body !== undefined) {
if (typeof input.body === "string") body = input.body;
else {
body = JSON.stringify(input.body);
if (!headers.has("content-type")) headers.set("content-type", "application/json");
}
}
const dispatcher = new Agent({ connect: { ...configuration.tls, key: secrets.tlsKey } });
const deadline = AbortSignal.timeout(configuration.timeoutMs);
const requestSignal = signal ? AbortSignal.any([signal, deadline]) : deadline;
const started = performance.now();
try {
const response = await fetch(url, {
method: input.method,
headers,
body,
dispatcher,
signal: requestSignal,
redirect: "manual",
});
const chunks: Uint8Array[] = [];
let length = 0;
if (response.body) {
for await (const chunk of response.body) {
length += chunk.byteLength;
if (length > configuration.maxResponseBytes) {
throw new Error(`Response exceeds the connection limit of ${configuration.maxResponseBytes} bytes`);
}
chunks.push(chunk);
}
}
const buffer = Buffer.concat(chunks, length);
let data: unknown = new TextDecoder().decode(buffer);
if (input.responseType === "base64") data = Buffer.from(buffer).toString("base64");
else if (input.responseType === "json" && buffer.byteLength > 0) data = JSON.parse(data as string);
return {
data,
ok: response.ok,
status: response.status,
contentType: response.headers.get("content-type"),
durationMs: Math.round(performance.now() - started),
updatedAt: new Date().toISOString(),
};
} finally {
await dispatcher.close();
}
}
export async function diagnoseWidgetConnection(ctx: PackageContext, id: string) {
const { configuration } = await getBoundConnection(ctx, { target: id }, "target");
if (configuration.kind === "service") return diagnoseWidgetService(configuration);
if (!configuration.baseUrl) return { status: "integration" as const, addresses: [] };
const url = new URL(configuration.baseUrl);
let addresses: Awaited<ReturnType<typeof lookup>>[] = [];
try {
addresses = await lookup(url.hostname.replace(/^\[|\]$/gu, ""), { all: true });
} catch (error) {
return {
status: "dnsError" as const,
stage: "dns",
hostname: url.hostname,
addresses,
error: diagnosticError(error),
};
}
let response;
try {
response = await fetchWidgetConnection(
ctx,
{ target: id },
{ connection: "target", path: url.href, method: "GET", responseType: "text" },
);
} catch (error) {
const details = diagnosticError(error);
let stage = "connection";
if (/CERT|TLS|SSL|SELF_SIGNED|UNABLE_TO_VERIFY/iu.test(details.code ?? "")) stage = "tls";
return { status: "unavailable" as const, stage, hostname: url.hostname, addresses, error: details };
}
let sampleFields: string[] = [];
if (typeof response.data === "string") {
try {
const sample: unknown = JSON.parse(response.data);
if (sample && typeof sample === "object" && !Array.isArray(sample))
sampleFields = Object.keys(sample).slice(0, 24);
} catch {
/* Non-JSON sources are valid connections. */
}
}
return {
status: connectionStatus(response.status),
stage: "response",
hostname: url.hostname,
addresses,
httpStatus: response.status,
contentType: response.contentType,
durationMs: response.durationMs,
sampleFields,
};
}
function diagnosticError(error: unknown) {
if (!(error instanceof Error)) return { message: "Connection failed", code: undefined };
let source = error;
if (error.cause instanceof Error) source = error.cause;
let code: string | undefined;
if ("code" in source && typeof source.code === "string") code = source.code;
return { message: source.message, code };
}
function connectionStatus(status: number) {
if (status === 401 || status === 403) return "credentialsRequired" as const;
if (status >= 400) return "httpError" as const;
if (status >= 300) return "redirect" as const;
return "connected" as const;
}
@@ -0,0 +1,186 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createId } from "@homarr/common";
import { customWidgetActivity } from "@homarr/db/schema";
import { CustomWidgetPackageSupervisor } from "@homarr/custom-widgets/package/server";
import { env } from "../../../env";
import { BoundedAsyncQueue } from "../../widgets/bounded-async-queue";
import { acquireCustomWidgetRequestLimit } from "../request-limits";
import { authorizePackageHandler } from "./permissions";
import { resolvePackagePlacement } from "./records";
import { createWidgetSdkBridge } from "./sdk-bridge";
import { observeWidgetPackageChanges } from "./events";
import { withWidgetInstallationLock } from "./coordination";
import { getWidgetPackageDirectory } from "./paths";
import type { PackageContext } from "./types";
import type { ResolvedPackagePlacement } from "./records";
import { observeWidgetInvocationAuthorization, refreshWidgetActor } from "./authorization";
const logger = createLogger({ module: "custom-widget-package" });
let supervisor: CustomWidgetPackageSupervisor | undefined;
export function getWidgetPackageSupervisor() {
if (!supervisor) {
const currentSupervisor = new CustomWidgetPackageSupervisor({
rootDirectory: getWidgetPackageDirectory(),
maximumProcesses: env.CUSTOM_WIDGET_MAX_SERVER_PROCESSES,
});
supervisor = currentSupervisor;
observeWidgetPackageChanges((change) => {
if (change.kind === "storage") return;
void currentSupervisor
.cancelInstances(change.itemIds, { actions: change.kind === "disabled" })
.catch(() => logger.warn("Widget retirement failed"));
});
}
return supervisor;
}
export const packageOperationInputSchema = z
.object({
itemId: z.string().min(1),
name: z.string().min(1).max(128),
input: z.unknown().default({}),
})
.refine((value) => (JSON.stringify(value.input) ?? "null").length <= 1_048_576, "Widget inputs exceed 1 MiB");
type OperationInput = z.infer<typeof packageOperationInputSchema>;
export { validatePackageHandlerInput } from "./handler-validation";
import { validatePackageHandlerInput } from "./handler-validation";
export async function invokePackageHandler(
ctx: PackageContext,
input: OperationInput,
kind: "query" | "action",
signal?: AbortSignal,
beforeInvoke?: (ctx: PackageContext, resolved: ResolvedPackagePlacement) => Promise<void>,
) {
const placement = await resolvePackagePlacement(ctx, input.itemId);
if (kind === "action")
return withWidgetInstallationLock(
placement.installation.id,
(operationSignal) => executePackageHandler(ctx, input, kind, operationSignal, beforeInvoke),
signal,
);
return executePackageHandler(ctx, input, kind, signal, beforeInvoke);
}
async function executePackageHandler(
ctx: PackageContext,
input: OperationInput,
kind: "query" | "action",
signal?: AbortSignal,
beforeInvoke?: (ctx: PackageContext, resolved: ResolvedPackagePlacement) => Promise<void>,
) {
ctx = await refreshWidgetActor(ctx);
const resolved = await resolvePackagePlacement(ctx, input.itemId);
await beforeInvoke?.(ctx, resolved);
const { handler, guest } = await authorizePackageHandler(ctx, resolved, input.name, kind, input.input);
validatePackageHandlerInput(handler, input.input);
let release: (() => Promise<void>) | undefined;
let status = "success";
const authorization = await observeWidgetInvocationAuthorization(ctx, resolved, { ...input, kind }, signal);
try {
if (guest)
release = await acquireCustomWidgetRequestLimit({
category: "action",
itemId: input.itemId,
definitionId: resolved.installation.id,
});
const result = await getWidgetPackageSupervisor().invoke({
artifact: resolved.artifact,
instanceId: resolved.item.id,
boardId: resolved.item.boardId,
userId: ctx.session?.user.id,
handler: input.name,
input: input.input,
signal: authorization.signal,
sdk: createWidgetSdkBridge(ctx, resolved, kind, guest, authorization.signal),
});
// A query may have been waiting upstream while its viewer lost access.
if (kind === "query") await authorization.check();
return result;
} catch (error) {
status = "failed";
throw normalizeWidgetInvocationError(error);
} finally {
authorization.close();
await release?.();
if (kind === "action") {
await ctx.db
.insert(customWidgetActivity)
.values({
id: createId(),
installationId: resolved.installation.id,
itemId: resolved.item.id,
userId: ctx.session?.user.id,
handler: input.name,
status,
createdAt: new Date(),
})
.catch(() =>
logger.warn("Widget action activity could not be recorded", {
installationId: resolved.installation.id,
handler: input.name,
}),
);
}
}
}
export async function* subscribePackageHandler(ctx: PackageContext, input: OperationInput, signal?: AbortSignal) {
ctx = await refreshWidgetActor(ctx);
const resolved = await resolvePackagePlacement(ctx, input.itemId);
const { handler } = await authorizePackageHandler(ctx, resolved, input.name, "subscription", input.input);
validatePackageHandlerInput(handler, input.input);
const queue = new BoundedAsyncQueue<unknown>(32);
const authorization = await observeWidgetInvocationAuthorization(
ctx,
resolved,
{ ...input, kind: "subscription" },
signal,
);
const controller = new AbortController();
const onAbort = () => {
controller.abort();
queue.fail(authorization.signal.reason ?? new Error("Widget subscription ended"));
};
authorization.signal.addEventListener("abort", onAbort, { once: true });
if (authorization.signal.aborted) onAbort();
let unsubscribe: (() => void) | undefined;
try {
unsubscribe = await getWidgetPackageSupervisor().subscribe({
artifact: resolved.artifact,
instanceId: resolved.item.id,
boardId: resolved.item.boardId,
userId: ctx.session?.user.id,
handler: input.name,
input: input.input,
signal: controller.signal,
sdk: createWidgetSdkBridge(ctx, resolved, "subscription", false, controller.signal),
onData: (data) => queue.push(data),
onError: (error) => queue.fail(normalizeWidgetInvocationError(error)),
onComplete: () => queue.close(),
});
yield* queue;
} finally {
authorization.signal.removeEventListener("abort", onAbort);
authorization.close();
controller.abort();
unsubscribe?.();
queue.close();
}
}
export function normalizeWidgetInvocationError(error: unknown) {
if (error instanceof TRPCError) return error;
if (error instanceof Error && "code" in error) {
const code = z
.enum(["FORBIDDEN", "UNAUTHORIZED", "NOT_FOUND", "PRECONDITION_FAILED", "BAD_REQUEST", "TOO_MANY_REQUESTS"])
.safeParse(error.code);
if (code.success) return new TRPCError({ code: code.data, message: error.message });
}
return error;
}
@@ -0,0 +1,51 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { eq } from "@homarr/db";
import { boards, items } from "@homarr/db/schema";
import { publicProcedure } from "../../../trpc";
import { throwIfActionForbiddenAsync } from "../../board/board-access";
import { BoundedAsyncQueue } from "../../widgets/bounded-async-queue";
import { observeWidgetPackageChanges } from "./events";
import { parsePackagePlacement } from "./records";
import { refreshWidgetActor } from "./actor";
export const packageLifecycleProcedures = {
packageLifecycle: publicProcedure.input(z.object({ itemId: z.string() })).subscription(async function* ({
ctx,
input,
signal,
}) {
ctx = { ...ctx, ...(await refreshWidgetActor(ctx)) };
const item = await ctx.db.query.items.findFirst({ where: eq(items.id, input.itemId) });
if (!item || item.kind !== "customApi") throw new TRPCError({ code: "NOT_FOUND" });
await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "view");
const options = parsePackagePlacement(item.options);
if (!options) throw new TRPCError({ code: "NOT_FOUND" });
const queue = new BoundedAsyncQueue<{ kind: string }>(1);
const unsubscribe = observeWidgetPackageChanges((change) => {
if (change.installationId === options.definitionId || change.itemIds.includes(item.id))
queue.push({ kind: change.kind });
});
const abort = () => queue.close();
signal?.addEventListener("abort", abort, { once: true });
if (signal?.aborted) abort();
// Board permission changes are independent of package updates; a periodic authorization check
// closes a previously authorized stream without polling upstream widget data.
const check = setInterval(() => {
void refreshWidgetActor(ctx)
.then((actor) => throwIfActionForbiddenAsync(actor, eq(boards.id, item.boardId), "view"))
.catch((error: unknown) => queue.fail(error));
}, 30_000);
check.unref();
try {
yield* queue;
} finally {
unsubscribe();
clearInterval(check);
signal?.removeEventListener("abort", abort);
queue.close();
}
}),
};
@@ -0,0 +1,217 @@
import { isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
import { widgetPackageAdminProcedure } from "./procedure";
import { TRPCError } from "@trpc/server";
import { nativeScaffoldProcedures } from "./native-scaffold";
import { packageCollectionProcedures } from "./collection-procedures";
import { packagePortableCollectionProcedures } from "./portable-collection-procedures";
import { packagePreviewProcedures } from "./preview-procedures";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { eq } from "@homarr/db";
import { customWidgetInstallations, items } from "@homarr/db/schema";
import { customWidgetPackageSchema } from "@homarr/custom-widgets/package";
import { createTRPCRouter } from "../../../trpc";
import { activateWidgetPackage, rollbackWidgetPackage } from "./activation";
import {
getArtifact,
getInstallation,
getPackagePlacements,
packageDigest,
parseBindings,
parsePackagePlacement,
readPackageDraft,
} from "./records";
import { getBoundConnection, packageConnectionProcedures } from "./connections";
import { diagnoseWidgetConnection } from "./http";
import { packageGrantProcedures } from "./permissions";
import { getOrBuildWidgetArtifact, packageTransferProcedures } from "./transfer";
import { withWidgetInstallationLock } from "./coordination";
import { publishWidgetPackageChange } from "./events";
import { packageWorkshopProcedures } from "./workshop";
import { setWidgetPackageEnabled } from "./enabled";
import { packageWebhookProcedures } from "./webhook-procedures";
import { packageArtifactReviewProcedures } from "./artifact-review";
import { packageConversionProcedures } from "./conversion";
import { withWidgetArtifactReferenceLock } from "./artifact-references";
import { packageArtifactCleanupProcedures } from "./artifact-cleanup";
const admin = widgetPackageAdminProcedure;
const idInput = z.object({ id: z.string().min(1) });
const trustedInput = idInput.extend({ trusted: z.literal(true) });
// Source syntax may be incomplete in a saved draft; execution validates the complete package.
const draftInput = z.object({
id: z.string().optional(),
name: z.string().trim().min(1).max(128),
source: z.record(z.string(), z.unknown()),
});
export const customWidgetPackageRouter = createTRPCRouter({
...packageArtifactReviewProcedures,
...packageArtifactCleanupProcedures,
...packageConversionProcedures,
...packageWebhookProcedures,
...packagePreviewProcedures,
...packageCollectionProcedures,
...packagePortableCollectionProcedures,
...nativeScaffoldProcedures,
...packageTransferProcedures,
...packageWorkshopProcedures,
list: admin
.meta({
mcp: {
enabled: true,
description: "List installed trusted widget packages and activation state. Does not execute code.",
},
})
.query(async ({ ctx }) => {
const [rows, placements] = await Promise.all([
ctx.db.query.customWidgetInstallations.findMany({ columns: { draft: false, previousSnapshot: false } }),
ctx.db.query.items.findMany({ where: eq(items.kind, "customApi"), columns: { options: true } }),
]);
const counts = new Map<string, number>();
for (const placement of placements) {
const options = parsePackagePlacement(placement.options);
if (options) counts.set(options.definitionId, (counts.get(options.definitionId) ?? 0) + 1);
}
return rows.map((row) => ({
...row,
bindings: parseBindings(row.bindings),
placementCount: counts.get(row.id) ?? 0,
}));
}),
get: admin
.meta({
mcp: {
enabled: true,
description:
"Read a trusted widget installation source draft, local binding IDs and placement IDs. Requires admin; obtain id from customWidget_package_list. Does not execute code.",
},
})
.input(idInput)
.query(async ({ ctx, input }) => {
const row = await getInstallation(ctx, input.id);
const { previousSnapshot: _snapshot, draft: _draft, ...installation } = row;
return {
...installation,
source: readPackageDraft(row.draft),
draftDigest: packageDigest(row.draft),
bindings: parseBindings(row.bindings),
placements: (await getPackagePlacements(ctx, row.id)).map(({ id, boardId }) => ({ id, boardId })),
};
}),
saveDraft: admin
.meta({
mcp: {
enabled: true,
description: "Save the current trusted widget source draft without compiling or activating it. Requires admin.",
},
})
.input(draftInput)
.mutation(async ({ ctx, input }) => {
const id = input.id ?? createId();
const draft = JSON.stringify(input.source);
if (Buffer.byteLength(draft) > 25_000_000)
throw new TRPCError({ code: "PAYLOAD_TOO_LARGE", message: "Widget package draft exceeds 25 MB" });
if (input.id) {
await withWidgetInstallationLock(id, async () => {
await getInstallation(ctx, id);
await withWidgetArtifactReferenceLock(async () =>
ctx.db
.update(customWidgetInstallations)
.set({ name: input.name, draft, updatedAt: new Date() })
.where(eq(customWidgetInstallations.id, id)),
);
});
} else {
await withWidgetArtifactReferenceLock(async () =>
ctx.db.insert(customWidgetInstallations).values({
id,
name: input.name,
draft,
bindings: "{}",
enabled: false,
creatorId: ctx.session.user.id,
createdAt: new Date(),
updatedAt: new Date(),
}),
);
}
return { id, managementPath: `/manage/custom-widgets/packages/${id}`, draftDigest: packageDigest(draft) };
}),
activate: admin
.meta({
mcp: {
enabled: true,
description:
"Compile and activate the saved widget package on every placement. Requires admin and trusted:true for Homarr-level code execution; preserves one rollback snapshot.",
},
})
.input(
trustedInput.extend({
expectedDraftDigest: z
.string()
.regex(/^[a-f0-9]{64}$/u)
.optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const installation = await getInstallation(ctx, input.id);
if (input.expectedDraftDigest && packageDigest(installation.draft) !== input.expectedDraftDigest)
throw new TRPCError({ code: "CONFLICT", message: "The saved source changed. Review it before activating." });
const source = customWidgetPackageSchema.parse(JSON.parse(installation.draft));
const artifact = await getOrBuildWidgetArtifact(ctx, source);
return activateWidgetPackage(ctx, input.id, source, artifact, installation.draft);
}),
rollback: admin
.input(idInput.extend({ discardNewerWidgetData: z.literal(true) }))
.mutation(({ ctx, input }) => rollbackWidgetPackage(ctx, input.id)),
setEnabled: admin
.meta({
mcp: {
enabled: true,
description:
"Enable or disable an already activated trusted widget installation. Requires admin; disabling stops its active handlers and placements.",
},
})
.input(idInput.extend({ enabled: z.boolean() }))
.mutation(async ({ ctx, input }) => {
await setWidgetPackageEnabled(ctx, input.id, input.enabled);
}),
setBindings: admin
.input(idInput.extend({ bindings: z.record(z.string(), z.string()) }))
.mutation(async ({ ctx, input }) => {
return withWidgetInstallationLock(input.id, async () => {
const installation = await getInstallation(ctx, input.id);
const draft = customWidgetPackageSchema.safeParse(JSON.parse(installation.draft));
for (const [name] of Object.entries(input.bindings)) {
const connection = await getBoundConnection(ctx, input.bindings, name);
const requirement = draft.success ? draft.data.connections[name] : undefined;
if (requirement && !isWidgetConnectionCompatible(requirement, connection.configuration))
throw new TRPCError({
code: "BAD_REQUEST",
message: `Connection '${name}' requires ${requirement.serviceType ?? requirement.kind}`,
});
}
await ctx.db
.update(customWidgetInstallations)
.set({ bindings: JSON.stringify(input.bindings), updatedAt: new Date() })
.where(eq(customWidgetInstallations.id, input.id));
await publishWidgetPackageChange({
installationId: input.id,
itemIds: (await getPackagePlacements(ctx, input.id)).map(({ id }) => id),
kind: "bindings",
});
});
}),
export: admin.input(idInput).query(async ({ ctx, input }) => {
const row = await getInstallation(ctx, input.id);
if (!row.activeArtifactId) return { source: JSON.parse(row.draft) as unknown };
const { source, artifact } = await getArtifact(ctx, row.activeArtifactId);
return { format: "homarr-widget-archive-v3" as const, source, artifact };
}),
diagnoseConnection: admin.input(idInput).mutation(({ ctx, input }) => diagnoseWidgetConnection(ctx, input.id)),
...packageConnectionProcedures,
...packageGrantProcedures,
});
@@ -0,0 +1,124 @@
import { isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
import { TRPCError } from "@trpc/server";
import { parse, stringify } from "superjson";
import { z } from "zod/v4";
import { isRecord } from "@homarr/common";
import { getCustomWidgetDefaultOptions, validateCustomWidgetOptions } from "@homarr/custom-widgets/core";
import type { CustomWidgetArtifact, CustomWidgetPackage } from "@homarr/custom-widgets/package";
import type { customWidgetStorage } from "@homarr/db/schema";
import { getBoundConnection } from "./connections";
import { getWidgetPackageSupervisor } from "./invocations";
import { getArtifact, parseBindings } from "./records";
import type { getPackagePlacements } from "./records";
import type { PackageContext, PackageInstallation } from "./types";
type StorageRow = typeof customWidgetStorage.$inferSelect;
export async function prepareWidgetMigration(
ctx: PackageContext,
installation: PackageInstallation,
source: CustomWidgetPackage,
artifact: CustomWidgetArtifact,
placements: Awaited<ReturnType<typeof getPackagePlacements>>,
storage: StorageRow[],
signal?: AbortSignal,
) {
const previous = installation.activeArtifactId ? await getArtifact(ctx, installation.activeArtifactId) : null;
const configurationChanged =
previous && previous.source.manifest.configurationVersion !== source.manifest.configurationVersion;
const storageChanged = previous && previous.source.manifest.storageVersion !== source.manifest.storageVersion;
const bindings = parseBindings(installation.bindings);
const changes = [];
for (const placement of placements) {
// Materialize existing defaults so a release cannot change a placement's saved choices implicitly.
let configuration: Record<string, unknown> = {
...getCustomWidgetDefaultOptions(source.options),
...getCustomWidgetDefaultOptions(previous?.source.options ?? {}),
...placement.packageOptions.configuration,
};
if (configurationChanged && source.manifest.migrations?.configuration) {
configuration = z.record(z.string(), z.unknown()).parse(
await getWidgetPackageSupervisor().invoke({
artifact,
instanceId: placement.id,
boardId: placement.boardId,
userId: ctx.session?.user.id,
signal,
handler: source.manifest.migrations.configuration,
input: {
fromVersion: previous.source.manifest.configurationVersion,
toVersion: source.manifest.configurationVersion,
configuration,
},
}),
);
}
const issues = validateCustomWidgetOptions(source.options, configuration);
if (issues.length)
throw new TRPCError({ code: "PRECONDITION_FAILED", message: `Placement ${placement.id}: ${issues[0]?.message}` });
const mergedBindings = { ...bindings, ...placement.packageOptions.connectionBindings };
for (const [name, requirement] of Object.entries(source.connections)) {
if (requirement.optional && !mergedBindings[name]) continue;
const connection = await getBoundConnection(ctx, mergedBindings, name);
if (!isWidgetConnectionCompatible(requirement, connection.configuration))
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: `Connection '${name}' requires ${requirement.serviceType ?? requirement.kind}`,
});
}
const raw: unknown = parse(placement.options);
if (!isRecord(raw)) throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Placement options are invalid" });
changes.push({ id: placement.id, options: stringify({ ...raw, configuration }) });
}
const managedStorage = storage.filter((row) => row.scope !== "system");
let migratedStorage = storage;
if (storageChanged && managedStorage.length) {
const handler = source.manifest.migrations?.storage;
if (!handler)
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "A storage migration is required for the new storage version",
});
const result = z.array(z.object({ id: z.string(), value: z.unknown() })).parse(
await getWidgetPackageSupervisor().invoke({
artifact,
instanceId: installation.id,
boardId: "migration",
userId: ctx.session?.user.id,
handler,
signal,
input: {
fromVersion: previous.source.manifest.storageVersion,
toVersion: source.manifest.storageVersion,
records: managedStorage.map(({ id, scope, ownerKey, key, value }) => ({
id,
scope,
ownerKey,
key,
value: JSON.parse(value) as unknown,
})),
},
}),
);
const byId = new Map(managedStorage.map((row) => [row.id, row]));
const seen = new Set<string>();
migratedStorage = result
.map(({ id, value }) => {
const row = byId.get(id);
if (!row || seen.has(id))
throw new TRPCError({
code: "BAD_REQUEST",
message: "Storage migration returned an unknown or duplicate record",
});
seen.add(id);
const serialized = JSON.stringify(value);
if (serialized === undefined || Buffer.byteLength(serialized) > 1_048_576)
throw new TRPCError({ code: "BAD_REQUEST", message: "Migrated storage exceeds 1 MiB per record" });
return { ...row, value: serialized, updatedAt: new Date() };
})
.concat(storage.filter((row) => row.scope === "system"));
}
return { placements: changes, storage: migratedStorage };
}
@@ -0,0 +1,178 @@
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { IntegrationKind, IntegrationKindByCategory } from "@homarr/definitions";
import type { IntegrationInput } from "@homarr/integrations";
import { downloadClientItemSchema } from "@homarr/integrations/downloads";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { LiveStatsEvent } from "@homarr/integrations/types";
import {
beszelAlertsRequestHandler,
beszelStatsRequestHandler,
beszelSystemsRequestHandler,
} from "@homarr/request-handler/beszel";
import { downloadClientRequestHandler } from "@homarr/request-handler/downloads";
import { mediaServerRequestHandler } from "@homarr/request-handler/media-server";
import { BoundedAsyncQueue } from "../../widgets/bounded-async-queue";
type NativeIntegration = IntegrationInput & { kind: IntegrationKind; appId?: string | null };
type CapabilityKind = "query" | "action" | "subscription";
export const NATIVE_WIDGET_CAPABILITIES_VERSION = 1;
export const nativeWidgetCapabilities = {
"beszel.systems": { kind: "query", integrations: ["beszel"] },
"beszel.stats": { kind: "query", integrations: ["beszel"] },
"beszel.containers": { kind: "query", integrations: ["beszel"] },
"beszel.alerts": { kind: "query", integrations: ["beszel"] },
"beszel.live": { kind: "subscription", integrations: ["beszel"] },
"downloads.queue": { kind: "query", integrations: getIntegrationKindsByCategory("downloadClient") },
"downloads.pause": { kind: "action", integrations: getIntegrationKindsByCategory("downloadClient") },
"downloads.resume": { kind: "action", integrations: getIntegrationKindsByCategory("downloadClient") },
"downloads.pauseItem": { kind: "action", integrations: getIntegrationKindsByCategory("downloadClient") },
"downloads.resumeItem": { kind: "action", integrations: getIntegrationKindsByCategory("downloadClient") },
"downloads.deleteItem": { kind: "action", integrations: getIntegrationKindsByCategory("downloadClient") },
"media.sessions": { kind: "query", integrations: getIntegrationKindsByCategory("mediaService") },
"sonarr.calendar": { kind: "query", integrations: ["sonarr"] },
"sonarr.missing": { kind: "query", integrations: ["sonarr"] },
"sonarr.queue": { kind: "query", integrations: ["sonarr"] },
"sonarr.searchEpisodes": { kind: "action", integrations: ["sonarr"] },
} as const satisfies Record<string, { kind: CapabilityKind; integrations: readonly IntegrationKind[] }>;
export type NativeWidgetCapability = keyof typeof nativeWidgetCapabilities;
export function getNativeWidgetCapability(capability: string) {
if (!Object.hasOwn(nativeWidgetCapabilities, capability))
throw new Error(`Unknown integration capability: ${capability}`);
return nativeWidgetCapabilities[capability as NativeWidgetCapability];
}
function assertCapability(integration: NativeIntegration, capability: string) {
const metadata = getNativeWidgetCapability(capability);
const kinds: readonly IntegrationKind[] = metadata.integrations;
if (!kinds.includes(integration.kind))
throw new Error(`Integration ${integration.kind} does not support ${capability}`);
return metadata;
}
const systemInput = z.object({ systemId: z.string().min(1) });
const statsInput = systemInput.extend({
timePeriod: z.enum(["1m", "1h", "12h", "24h", "1w", "30d"]).default("1h"),
includeDocker: z.boolean().default(true),
});
const alertsInput = z.object({
includeHistory: z.boolean().default(true),
maxHistoryItems: z.number().int().min(1).max(1000).default(50),
});
const queueInput = z.object({ limit: z.number().int().min(1).max(1000).default(50) });
const itemInput = z.object({ item: downloadClientItemSchema, fromDisk: z.boolean().default(false) });
/** Call after resolving a named connection and checking native query/interact access on the server. */
export async function invokeNativeWidgetCapability(
integration: NativeIntegration,
capability: string,
input: unknown,
signal?: AbortSignal,
): Promise<unknown> {
signal?.throwIfAborted();
const metadata = assertCapability(integration, capability);
if (metadata.kind === "subscription") throw new Error("Use the subscription bridge for streaming capabilities");
if (integration.kind === "beszel") {
const connection = { ...integration, appId: integration.appId ?? null, kind: "beszel" as const };
if (capability === "beszel.systems") return beszelSystemsRequestHandler.handler(connection, {}).getDataAsync();
if (capability === "beszel.stats")
return beszelStatsRequestHandler.handler(connection, statsInput.parse(input ?? {})).getDataAsync();
if (capability === "beszel.alerts")
return beszelAlertsRequestHandler.handler(connection, alertsInput.parse(input ?? {})).getDataAsync();
if (capability === "beszel.containers") {
const instance = await createIntegrationAsync(connection);
return instance.getContainersAsync(systemInput.parse(input).systemId);
}
}
if (capability === "media.sessions") {
const connection = { ...integration, appId: integration.appId ?? null } as IntegrationInput & {
kind: IntegrationKindByCategory<"mediaService">;
appId: string | null;
};
const values = z.object({ showOnlyPlaying: z.boolean().default(false) }).parse(input ?? {});
return mediaServerRequestHandler.handler(connection, values).getDataAsync();
}
if (integration.kind === "sonarr") {
const instance = await createIntegrationAsync({ ...integration, kind: "sonarr" });
if (capability === "sonarr.searchEpisodes") {
const { episodeIds } = z
.object({ episodeIds: z.array(z.number().int().positive()).min(1).max(100) })
.parse(input);
return instance.searchEpisodesAsync(episodeIds, signal);
}
if (capability === "sonarr.calendar") {
const values = z
.object({ start: z.coerce.date(), end: z.coerce.date(), includeUnmonitored: z.boolean().default(false) })
.parse(input);
if (values.start > values.end) throw new Error("Calendar start must precede the end");
return instance.getCalendarEventsAsync(values.start, values.end, values.includeUnmonitored);
}
const { limit } = queueInput.parse(input ?? {});
if (capability === "sonarr.missing") return instance.getMissingAsync(limit);
if (capability === "sonarr.queue") return instance.getMediaQueueAsync(limit);
}
if (capability.startsWith("downloads.")) {
const connection = { ...integration, appId: integration.appId ?? null } as IntegrationInput & {
kind: IntegrationKindByCategory<"downloadClient">;
appId: string | null;
};
if (capability === "downloads.queue")
return downloadClientRequestHandler.handler(connection, queueInput.parse(input ?? {})).getDataAsync();
const instance = await createIntegrationAsync(connection);
try {
if (capability === "downloads.pause") await instance.pauseQueueAsync();
else if (capability === "downloads.resume") await instance.resumeQueueAsync();
else {
const values = itemInput.parse(input);
if (capability === "downloads.pauseItem") await instance.pauseItemAsync(values.item);
else if (capability === "downloads.resumeItem") await instance.resumeItemAsync(values.item);
else if (capability === "downloads.deleteItem") await instance.deleteItemAsync(values.item, values.fromDisk);
}
return { ok: true };
} finally {
await downloadClientRequestHandler.invalidateCacheAsync([integration.id]);
}
}
throw new Error(`Unsupported integration capability: ${capability}`);
}
export async function* subscribeNativeWidgetCapability(
integration: NativeIntegration,
capability: string,
input: unknown,
signal: AbortSignal,
): AsyncGenerator<unknown> {
signal.throwIfAborted();
assertCapability(integration, capability);
if (integration.kind !== "beszel" || capability !== "beszel.live")
throw new Error(`Unsupported subscription capability: ${capability}`);
const { systemId } = systemInput.parse(input);
const instance = await createIntegrationAsync({ ...integration, kind: "beszel" });
const controller = new AbortController();
const queue = new BoundedAsyncQueue<LiveStatsEvent>(4);
const stop = () => {
controller.abort();
void queue.return();
};
signal.addEventListener("abort", stop, { once: true });
if (signal.aborted) stop();
const streaming = instance
.subscribeRealtimeMetrics(systemId, (event) => queue.push(event), controller.signal)
.then(
() => queue.close(),
(error: unknown) => {
if (!controller.signal.aborted) queue.fail(error);
},
);
try {
for await (const event of queue) yield event;
} finally {
stop();
signal.removeEventListener("abort", stop);
await streaming;
}
}
@@ -0,0 +1,88 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server";
import { eq, inArray } from "@homarr/db";
import { boards, customWidgetConnections, integrationItems, integrations, items } from "@homarr/db/schema";
import { customWidgetPackageSchema } from "@homarr/custom-widgets/package";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { IntegrationKind } from "@homarr/definitions";
import { widgetReferencePackages } from "@homarr/widget-sdk/examples";
import { permissionRequiredProcedure } from "../../../trpc";
import { throwIfActionForbiddenAsync } from "../../board/board-access";
import { connectionConfigurationSchema } from "./connections";
const starters = [
"clock",
"timer",
"beszel",
"downloads",
"sonarr",
"media",
"checklist",
"rack",
"backup",
"household",
"overview",
"sqlite",
"mqtt",
] as const;
function acceptedIntegrationKinds(starter: (typeof starters)[number], connection: string): readonly IntegrationKind[] {
if (starter === "downloads" || connection === "client") return getIntegrationKindsByCategory("downloadClient");
if (starter === "media" || connection === "media") return getIntegrationKindsByCategory("mediaService");
if (starter === "sonarr") return ["sonarr"];
if (starter === "beszel" || connection === "beszel") return ["beszel"];
return [];
}
export const nativeScaffoldProcedures = {
prepareNative: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ sourceItemId: z.string(), starter: z.enum(starters) }))
.mutation(async ({ ctx, input }) => {
const item = await ctx.db.query.items.findFirst({ where: eq(items.id, input.sourceItemId) });
if (!item) throw new TRPCError({ code: "NOT_FOUND" });
await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "modify");
const source = customWidgetPackageSchema.parse(widgetReferencePackages[input.starter]);
const attached = await ctx.db.query.integrationItems.findMany({ where: eq(integrationItems.itemId, item.id) });
const bound = await ctx.db.query.integrations.findMany({
where: inArray(
integrations.id,
attached.map(({ integrationId }) => integrationId),
),
});
const bindings: Record<string, string> = {};
const usedIntegrationIds = new Set<string>();
for (const [name, requirement] of Object.entries(source.connections)) {
if (requirement.kind !== "integration") continue;
const accepted = acceptedIntegrationKinds(input.starter, name);
const integration = bound.find(
(candidate) => accepted.includes(candidate.kind) && !usedIntegrationIds.has(candidate.id),
);
if (!integration) continue;
usedIntegrationIds.add(integration.id);
let connection = await ctx.db.query.customWidgetConnections.findFirst({
where: eq(customWidgetConnections.integrationId, integration.id),
});
if (!connection) {
const id = createId();
const row = {
id,
name: integration.name,
integrationId: integration.id,
configuration: JSON.stringify(connectionConfigurationSchema.parse({ kind: "integration" })),
encryptedSecrets: encryptSecret("{}"),
createdAt: new Date(),
updatedAt: new Date(),
};
await ctx.db.insert(customWidgetConnections).values(row);
connection = row;
}
bindings[name] = connection.id;
}
return { name: `${source.manifest.name} (custom)`, source, bindings, sourceBoardId: item.boardId };
}),
};
@@ -0,0 +1,8 @@
import { resolve } from "node:path";
import { env } from "../../../env";
export function getWidgetPackageDirectory() {
const defaultPath = process.env.NODE_ENV === "production" ? "/appdata/custom-widgets" : "./appdata/custom-widgets";
// Runtime archives and dependency caches live on the owner's writable data volume.
return resolve(/* turbopackIgnore: true */ env.CUSTOM_WIDGET_PACKAGE_PATH ?? defaultPath);
}
@@ -0,0 +1,142 @@
import { widgetPackageAdminProcedure } from "./procedure";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { and, eq } from "@homarr/db";
import { boards, customWidgetGuestGrants, customWidgetActivity } from "@homarr/db/schema";
import { throwIfActionForbiddenAsync } from "../../board/board-access";
import { packageDigest, resolvePackagePlacement, stableJson } from "./records";
import type { ResolvedPackagePlacement } from "./records";
import { getBindingsDigest } from "./connections";
import type { PackageContext } from "./types";
import { withWidgetInstallationLock } from "./coordination";
import { validatePackageHandlerInput } from "./handler-validation";
import { publishWidgetPackageChange } from "./events";
export function getPackageActionDigest(resolved: ResolvedPackagePlacement) {
return packageDigest({
server: resolved.artifact.server,
handlers: resolved.artifact.manifest.handlers,
dependencies: resolved.artifact.dependencyLock,
});
}
async function getPlacementBindingsDigest(ctx: PackageContext, resolved: ResolvedPackagePlacement) {
return packageDigest({
connections: await getBindingsDigest(ctx, resolved.bindings),
options: resolved.configuration,
});
}
export async function authorizePackageHandler(
ctx: PackageContext,
resolved: ResolvedPackagePlacement,
name: string,
kind: "query" | "action" | "subscription",
input: unknown,
) {
const handler = resolved.artifact.manifest.handlers[name];
if (!handler || handler.kind !== kind)
throw new TRPCError({ code: "NOT_FOUND", message: "Widget handler not found" });
if (kind !== "action" || ctx.session) {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, resolved.item.boardId), handler.permission);
return { handler, guest: false };
}
if (ctx.crossSiteRequest)
throw new TRPCError({ code: "FORBIDDEN", message: "Guest actions must originate from this Homarr instance" });
if (!handler.guestAccess) throw new TRPCError({ code: "UNAUTHORIZED" });
const grant = await ctx.db.query.customWidgetGuestGrants.findFirst({
where: and(eq(customWidgetGuestGrants.itemId, resolved.item.id), eq(customWidgetGuestGrants.handler, name)),
});
if (
!grant ||
grant.artifactDigest !== getPackageActionDigest(resolved) ||
grant.bindingsDigest !== (await getPlacementBindingsDigest(ctx, resolved))
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "The owner must enable this guest action for the current widget",
});
}
const allowed = z.array(z.unknown()).parse(JSON.parse(grant.allowedInputs));
if (!allowed.some((candidate) => stableJson(candidate) === stableJson(input))) {
throw new TRPCError({ code: "FORBIDDEN", message: "These action inputs are not enabled for guests" });
}
return { handler, guest: true };
}
const admin = widgetPackageAdminProcedure;
export const packageGrantProcedures = {
guestGrants: admin.input(z.object({ itemId: z.string() })).query(async ({ ctx, input }) => {
const resolved = await resolvePackagePlacement(ctx, input.itemId);
const grants = await ctx.db.query.customWidgetGuestGrants.findMany({
where: eq(customWidgetGuestGrants.itemId, input.itemId),
});
const bindingDigest = await getPlacementBindingsDigest(ctx, resolved);
return grants.map((grant) => ({
...grant,
allowedInputs: JSON.parse(grant.allowedInputs) as unknown[],
current: grant.artifactDigest === getPackageActionDigest(resolved) && grant.bindingsDigest === bindingDigest,
}));
}),
setGuestGrant: admin
.input(
z.object({
itemId: z.string(),
handler: z.string(),
enabled: z.boolean(),
allowedInputs: z.array(z.unknown()).min(1).max(100).default([{}]),
}),
)
.mutation(async ({ ctx, input }) => {
const placement = await resolvePackagePlacement(ctx, input.itemId);
const id = packageDigest([input.itemId, input.handler]);
return withWidgetInstallationLock(placement.installation.id, async (signal) => {
const resolved = await resolvePackagePlacement(ctx, input.itemId);
if (!input.enabled) {
signal.throwIfAborted();
await ctx.db.delete(customWidgetGuestGrants).where(eq(customWidgetGuestGrants.id, id));
await publishWidgetPackageChange({
installationId: resolved.installation.id,
itemIds: [input.itemId],
kind: "bindings",
});
return { enabled: false };
}
const handler = resolved.artifact.manifest.handlers[input.handler];
if (!handler || handler.kind !== "action" || !handler.guestAccess)
throw new TRPCError({ code: "BAD_REQUEST", message: "The package does not offer this action to guests" });
if (Buffer.byteLength(JSON.stringify(input.allowedInputs)) > 1_048_576)
throw new TRPCError({ code: "PAYLOAD_TOO_LARGE", message: "Guest grant inputs exceed 1 MiB" });
for (const value of input.allowedInputs) validatePackageHandlerInput(handler, value);
const bindingsDigest = await getPlacementBindingsDigest(ctx, resolved);
signal.throwIfAborted();
await ctx.db.delete(customWidgetGuestGrants).where(eq(customWidgetGuestGrants.id, id));
await ctx.db.insert(customWidgetGuestGrants).values({
id,
itemId: input.itemId,
handler: input.handler,
artifactDigest: getPackageActionDigest(resolved),
bindingsDigest,
allowedInputs: JSON.stringify(input.allowedInputs),
createdAt: new Date(),
});
await publishWidgetPackageChange({
installationId: resolved.installation.id,
itemIds: [input.itemId],
kind: "bindings",
});
return { enabled: true };
});
}),
activity: admin
.input(z.object({ id: z.string(), limit: z.number().int().min(1).max(100).default(30) }))
.query(({ ctx, input }) =>
ctx.db.query.customWidgetActivity.findMany({
where: eq(customWidgetActivity.installationId, input.id),
orderBy: (table, { desc }) => desc(table.createdAt),
limit: input.limit,
}),
),
};
@@ -0,0 +1,105 @@
import { TRPCError } from "@trpc/server";
import { parse } from "superjson";
import { isRecord } from "@homarr/common";
import { and, eq, inArray } from "@homarr/db";
import { customWidgetInstallations, items } from "@homarr/db/schema";
import { withWidgetInstallationLock } from "./coordination";
import { publishWidgetPackageChange } from "./events";
import type { PackageContext } from "./types";
interface PlacementChange {
boardId?: string;
itemId?: string;
submittedItems?: readonly { kind: string; options: unknown }[];
}
function installationId(options: unknown) {
if (typeof options === "string") {
try {
options = parse(options);
} catch {
return;
}
}
if (isRecord(options) && typeof options.definitionId === "string") return options.definitionId;
}
function placementSnapshot(rows: { id: string; options: string; boardId: string }[]) {
return JSON.stringify(rows.toSorted((a, b) => a.id.localeCompare(b.id)));
}
function placementConflict(): never {
throw new TRPCError({ code: "CONFLICT", message: "A custom widget changed while saving. Reload and try again." });
}
/** Placement writes and package migrations share the same lease, including writes from the board editor. */
export async function withWidgetPlacementChange<T>(
ctx: Pick<PackageContext, "db">,
change: PlacementChange,
operation: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
const readPlacements = async () => {
if (!change.boardId && !change.itemId) return [];
return ctx.db.query.items.findMany({
columns: { id: true, options: true, boardId: true },
where: and(
eq(items.kind, "customApi"),
change.boardId ? eq(items.boardId, change.boardId) : undefined,
change.itemId ? eq(items.id, change.itemId) : undefined,
),
});
};
const placements = await readPlacements();
const candidates = new Set<string>();
for (const item of [
...placements,
...(change.submittedItems ?? []).filter((submitted) => submitted.kind === "customApi"),
]) {
const id = installationId(item.options);
if (id) candidates.add(id);
}
if (!candidates.size) return operation(new AbortController().signal);
const installations = await ctx.db.query.customWidgetInstallations.findMany({
columns: { id: true, activeArtifactId: true },
where: inArray(customWidgetInstallations.id, [...candidates]),
});
// v2 definitions deliberately retain their existing mutation path.
if (!installations.length) return operation(new AbortController().signal);
const ids = installations.map(({ id }) => id).toSorted();
async function lock(index: number, signal?: AbortSignal): Promise<T> {
const nextId = ids[index];
if (nextId) return withWidgetInstallationLock(nextId, (nextSignal) => lock(index + 1, nextSignal), signal);
const current = await ctx.db.query.customWidgetInstallations.findMany({
columns: { id: true, activeArtifactId: true },
where: inArray(customWidgetInstallations.id, ids),
});
// The request's configuration may have been authored against the previous schema. Do not
// merge it over a migration that completed while this operation was waiting for its leases.
if (
current.length !== installations.length ||
installations.some(
(before) => current.find((after) => after.id === before.id)?.activeArtifactId !== before.activeArtifactId,
)
)
placementConflict();
if (placementSnapshot(await readPlacements()) !== placementSnapshot(placements)) placementConflict();
signal?.throwIfAborted();
const result = await operation(signal ?? new AbortController().signal);
const updated = await readPlacements();
for (const id of ids) {
const changedIds = new Set<string>();
for (const row of [...placements, ...updated]) {
if (installationId(row.options) !== id) continue;
const before = placements.find((previous) => previous.id === row.id);
const after = updated.find((next) => next.id === row.id);
if (before?.options !== after?.options) changedIds.add(row.id);
}
if (changedIds.size)
await publishWidgetPackageChange({ installationId: id, itemIds: [...changedIds], kind: "bindings" });
}
return result;
}
return lock(0);
}
@@ -0,0 +1,88 @@
import { TRPCError } from "@trpc/server";
import { eq, handleTransactionsAsync } from "@homarr/db";
import { customWidgetInstallations } from "@homarr/db/schema";
import { isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
import { publishWidgetPackageChange } from "./events";
import { getWidgetCollection } from "./portable-collection-records";
import {
mapCollectionBindings,
validateCollectionBindings,
withCollectionInstallationLocks,
} from "./portable-collection-transfer";
import { getBoundConnection } from "./connections";
import { getPackagePlacements } from "./records";
import type { PackageContext } from "./types";
export async function setWidgetCollectionBindings(
ctx: PackageContext,
importId: string,
bindings: Record<string, string>,
) {
const initial = await getWidgetCollection(ctx, importId);
return withCollectionInstallationLocks(
initial.entries.map(({ id }) => id),
async (signal) => {
const collection = await getWidgetCollection(ctx, importId);
await validateCollectionBindings(ctx, collection, bindings);
const changes: { id: string; bindings: string; itemIds: string[] }[] = [];
for (const entry of collection.entries) {
if (!entry.manifest)
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: `Widget '${entry.key}' has an invalid source draft. Repair it first.`,
});
const nextBindings = { ...entry.bindings };
for (const name of Object.keys(entry.mapping)) delete nextBindings[name];
Object.assign(nextBindings, mapCollectionBindings(entry.mapping, bindings));
for (const name of Object.keys(entry.mapping)) {
const requirement = entry.requirements[name];
if (!requirement)
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: `Widget '${entry.key}' no longer declares connection '${name}'. Review its package mapping.`,
});
if (!nextBindings[name]) continue;
const connection = await getBoundConnection(ctx, nextBindings, name);
if (!isWidgetConnectionCompatible(requirement, connection.configuration))
throw new TRPCError({
code: "BAD_REQUEST",
message: `Widget '${entry.key}' connection '${name}' is incompatible with the selected shared connection`,
});
}
changes.push({
id: entry.id,
bindings: JSON.stringify(nextBindings),
itemIds: (await getPackagePlacements(ctx, entry.id)).map(({ id }) => id),
});
}
const updatedAt = new Date();
await handleTransactionsAsync(ctx.db, {
async handleAsync(database, schema) {
await database.transaction(async (tx) => {
for (const change of changes)
await tx
.update(schema.customWidgetInstallations)
.set({ bindings: change.bindings, updatedAt })
.where(eq(schema.customWidgetInstallations.id, change.id));
signal.throwIfAborted();
});
},
handleSync(database) {
database.transaction((tx) => {
for (const change of changes)
tx.update(customWidgetInstallations)
.set({ bindings: change.bindings, updatedAt })
.where(eq(customWidgetInstallations.id, change.id))
.run();
signal.throwIfAborted();
});
},
});
for (const change of changes)
await publishWidgetPackageChange({ installationId: change.id, itemIds: change.itemIds, kind: "bindings" });
return getWidgetCollection(ctx, importId);
},
);
}
@@ -0,0 +1,64 @@
import { z } from "zod/v4";
import { isRecord } from "@homarr/common";
import {
widgetCollectionConnectionsSchema,
widgetCollectionKeySchema,
widgetCollectionManifestSchema,
widgetPortableOriginSchema,
} from "@homarr/custom-widgets/package";
import type { WidgetPortableOrigin } from "@homarr/custom-widgets/package";
export const installedWidgetCollectionSchema = z.strictObject({
importId: z.string(),
digest: z.string(),
manifest: widgetCollectionManifestSchema,
connections: widgetCollectionConnectionsSchema,
key: widgetCollectionKeySchema,
mapping: z.record(widgetCollectionKeySchema, widgetCollectionKeySchema),
options: z.record(widgetCollectionKeySchema, z.json()),
});
export type InstalledWidgetCollection = z.infer<typeof installedWidgetCollectionSchema>;
function readOrigin(raw: string | null): Record<string, unknown> | null {
if (!raw) return null;
try {
const value: unknown = JSON.parse(raw);
if (isRecord(value)) return value;
} catch {
/* Malformed provenance must not make management unavailable. */
}
return null;
}
export function readInstalledWidgetCollection(raw: string | null): InstalledWidgetCollection | null {
const parsed = installedWidgetCollectionSchema.safeParse(readOrigin(raw)?.collection);
if (!parsed.success) return null;
return parsed.data;
}
/** Keep the collection placement blueprint when a normal package update replaces upstream provenance. */
export function preserveWidgetCollectionOrigin(raw: string | null, nextOrigin: object) {
const collection = readInstalledWidgetCollection(raw);
if (!collection) return nextOrigin;
return { ...nextOrigin, collection };
}
/** Export only documented attribution fields, never arbitrary local origin metadata. */
export function readPortableWidgetOrigin(raw: string | null): WidgetPortableOrigin | undefined {
const value = readOrigin(raw);
if (!value) return undefined;
const { collection: _collection, ...origin } = value;
const direct = widgetPortableOriginSchema.safeParse(origin);
if (direct.success) return direct.data;
if (typeof value.forkedFrom !== "string") return undefined;
const upstream = readPortableWidgetOrigin(typeof value.origin === "string" ? value.origin : null);
const parsed = widgetPortableOriginSchema.safeParse({
kind: "fork",
packageId: value.forkedFrom,
artifactDigest: value.artifact ?? undefined,
upstream: upstream?.kind === "workshop" ? upstream : undefined,
});
if (parsed.success) return parsed.data;
return undefined;
}
@@ -0,0 +1,44 @@
import { z } from "zod/v4";
import { widgetCollectionKeySchema, widgetCollectionManifestSchema } from "@homarr/custom-widgets/package";
import { widgetPackageAdminProcedure } from "./procedure";
import { getWidgetCollection, listWidgetCollections } from "./portable-collection-records";
import { exportWidgetCollection, importWidgetCollection } from "./portable-collection-transfer";
import { setWidgetCollectionBindings } from "./portable-collection-bindings";
const admin = widgetPackageAdminProcedure;
export const packagePortableCollectionProcedures = {
collections: admin.query(({ ctx }) => listWidgetCollections(ctx)),
collection: admin
.input(z.object({ importId: z.string().min(1) }))
.query(({ ctx, input }) => getWidgetCollection(ctx, input.importId)),
importCollection: admin
.input(
z.object({
archive: z.unknown(),
bindings: z.record(widgetCollectionKeySchema, z.string().min(1)).default({}),
}),
)
.mutation(({ ctx, input }) => importWidgetCollection(ctx, input.archive, input.bindings)),
setCollectionBindings: admin
.input(
z.object({
importId: z.string().min(1),
bindings: z.record(widgetCollectionKeySchema, z.string().min(1)),
}),
)
.mutation(({ ctx, input }) => setWidgetCollectionBindings(ctx, input.importId, input.bindings)),
exportCollection: admin
.input(
z.object({
manifest: widgetCollectionManifestSchema,
installationIds: z
.array(z.string().min(1))
.min(1)
.max(32)
.refine((ids) => new Set(ids).size === ids.length, "Choose each installation only once"),
}),
)
.query(({ ctx, input }) => exportWidgetCollection(ctx, input.manifest, input.installationIds)),
};
@@ -0,0 +1,103 @@
import { TRPCError } from "@trpc/server";
import { getCustomWidgetDefaultOptions } from "@homarr/custom-widgets/core";
import { customWidgetPackageSchema } from "@homarr/custom-widgets/package";
import { getInstallation, packageDigest, parseBindings, readPackageDraft } from "./records";
import { readInstalledWidgetCollection } from "./portable-collection-origin";
import type { InstalledWidgetCollection } from "./portable-collection-origin";
import type { PackageContext } from "./types";
export async function listWidgetCollections(ctx: PackageContext) {
const rows = await ctx.db.query.customWidgetInstallations.findMany({
columns: { id: true, origin: true, enabled: true, updatedAt: true },
});
const groups = new Map<
string,
{
importId: string;
manifest: InstalledWidgetCollection["manifest"];
installationIds: string[];
enabledCount: number;
updatedAt: Date;
}
>();
for (const row of rows) {
const collection = readInstalledWidgetCollection(row.origin);
if (!collection) continue;
const group = groups.get(collection.importId) ?? {
importId: collection.importId,
manifest: collection.manifest,
installationIds: [],
enabledCount: 0,
updatedAt: row.updatedAt,
};
group.installationIds.push(row.id);
if (row.enabled) group.enabledCount++;
if (row.updatedAt > group.updatedAt) group.updatedAt = row.updatedAt;
groups.set(collection.importId, group);
}
return [...groups.values()];
}
export async function getWidgetCollection(ctx: PackageContext, importId: string) {
const candidates = await ctx.db.query.customWidgetInstallations.findMany({
columns: { id: true, origin: true },
});
const members = candidates.flatMap((row) => {
const collection = readInstalledWidgetCollection(row.origin);
if (collection?.importId !== importId) return [];
return [{ id: row.id, collection }];
});
const first = members[0]?.collection;
if (!first) throw new TRPCError({ code: "NOT_FOUND", message: "Widget collection import not found" });
const entries = await Promise.all(
members.map(async ({ id, collection }) => {
const row = await getInstallation(ctx, id);
const source = customWidgetPackageSchema.safeParse(readPackageDraft(row.draft));
let configuration: Record<string, unknown> = { ...collection.options };
if (source.success) configuration = { ...getCustomWidgetDefaultOptions(source.data.options), ...configuration };
return {
id,
key: collection.key,
name: row.name,
draftDigest: packageDigest(row.draft),
manifest: source.success ? source.data.manifest : null,
optionDefinitions: source.success ? source.data.options : {},
requirements: source.success ? source.data.connections : {},
configuration,
mapping: collection.mapping,
bindings: parseBindings(row.bindings),
enabled: row.enabled,
activeArtifactId: row.activeArtifactId,
managementPath: `/manage/custom-widgets/packages/${id}`,
};
}),
);
const values = new Map<string, Set<string>>();
for (const entry of entries) {
for (const [name, slot] of Object.entries(entry.mapping)) {
const bindings = values.get(slot) ?? new Set<string>();
bindings.add(entry.bindings[name] ?? "");
values.set(slot, bindings);
}
}
const bindings: Record<string, string> = {};
const bindingConflicts: string[] = [];
for (const [slot, ids] of values) {
if (ids.size > 1) bindingConflicts.push(slot);
else {
const id = [...ids][0];
if (id) bindings[slot] = id;
}
}
return {
importId,
digest: first.digest,
manifest: first.manifest,
connections: first.connections,
bindings,
bindingConflicts,
entries,
};
}
@@ -0,0 +1,170 @@
import { TRPCError } from "@trpc/server";
import { createId } from "@homarr/common";
import { handleTransactionsAsync } from "@homarr/db";
import { customWidgetInstallations } from "@homarr/db/schema";
import type {
CustomWidgetCollection,
WidgetCollectionContents,
WidgetCollectionManifest,
} from "@homarr/custom-widgets/package";
import { isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
import { createWidgetCollectionArchive, validateWidgetCollectionArchive } from "@homarr/custom-widgets/package/server";
import { withWidgetArtifactReferenceLock } from "./artifact-references";
import { getBoundConnection } from "./connections";
import { withWidgetInstallationLock } from "./coordination";
import { readPortableWidgetOrigin } from "./portable-collection-origin";
import { getWidgetCollection } from "./portable-collection-records";
import { persistPackageArtifact } from "./preview-store";
import { getArtifact, getInstallation, parseBindings } from "./records";
import type { PackageContext } from "./types";
export function parseWidgetCollection(input: unknown) {
try {
return validateWidgetCollectionArchive(input);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: error instanceof Error ? error.message : "Invalid collection archive",
});
}
}
export async function validateCollectionBindings(
ctx: PackageContext,
collection: Pick<CustomWidgetCollection, "connections">,
bindings: Record<string, string>,
) {
for (const [name] of Object.entries(bindings)) {
const requirement = collection.connections[name];
if (!requirement) throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown collection connection '${name}'` });
try {
const connection = await getBoundConnection(ctx, bindings, name);
if (!isWidgetConnectionCompatible(requirement, connection.configuration))
throw new Error(`requires a compatible ${requirement.kind} connection`);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Collection connection '${name}': ${error instanceof Error ? error.message : "invalid binding"}`,
});
}
}
}
export function mapCollectionBindings(mapping: Record<string, string>, bindings: Record<string, string>) {
return Object.fromEntries(
Object.entries(mapping).flatMap(([name, slot]) => {
const id = bindings[slot];
if (!id) return [];
return [[name, id]];
}),
);
}
export async function importWidgetCollection(ctx: PackageContext, input: unknown, bindings: Record<string, string>) {
const collection = parseWidgetCollection(input);
if (!ctx.session) throw new TRPCError({ code: "UNAUTHORIZED" });
await validateCollectionBindings(ctx, collection, bindings);
// Check every member before persisting anything. Immutable cache rows can survive a failed DB transaction;
// installation rows become visible together, and no package code or dependency installation runs here.
for (const entry of collection.widgets)
await persistPackageArtifact(ctx, entry.archive.source, entry.archive.artifact);
const importId = createId();
const now = new Date();
const installations = collection.widgets.map((entry) => ({
id: createId(),
name: entry.name ?? entry.archive.source.manifest.name,
draft: JSON.stringify(entry.archive.source),
bindings: JSON.stringify(mapCollectionBindings(entry.connections, bindings)),
enabled: false,
creatorId: ctx.session?.user.id,
origin: JSON.stringify({
...entry.origin,
collection: {
importId,
digest: collection.digest,
manifest: collection.manifest,
connections: collection.connections,
key: entry.key,
mapping: entry.connections,
options: entry.options,
},
}),
createdAt: now,
updatedAt: now,
}));
await withWidgetArtifactReferenceLock(async (signal) => {
await handleTransactionsAsync(ctx.db, {
async handleAsync(database, schema) {
await database.transaction(async (tx) => {
for (const row of installations) await tx.insert(schema.customWidgetInstallations).values(row);
signal.throwIfAborted();
});
},
handleSync(database) {
database.transaction((tx) => {
for (const row of installations) tx.insert(customWidgetInstallations).values(row).run();
signal.throwIfAborted();
});
},
});
});
return getWidgetCollection(ctx, importId);
}
export async function withCollectionInstallationLocks<T>(
ids: string[],
operation: (signal: AbortSignal) => Promise<T>,
) {
const ordered = [...new Set(ids)].toSorted();
const acquire = (index: number, signal?: AbortSignal): Promise<T> => {
const id = ordered[index];
if (!id) {
if (!signal) throw new Error("A collection requires at least one installation");
return operation(signal);
}
return withWidgetInstallationLock(id, (nextSignal) => acquire(index + 1, nextSignal), signal);
};
return acquire(0);
}
export async function exportWidgetCollection(ctx: PackageContext, manifest: WidgetCollectionManifest, ids: string[]) {
return withCollectionInstallationLocks(ids, async (signal) => {
const connections: CustomWidgetCollection["connections"] = {};
const shared = new Map<string, string>();
const widgets: WidgetCollectionContents["widgets"] = [];
for (const [index, id] of ids.entries()) {
const row = await getInstallation(ctx, id);
if (!row.activeArtifactId)
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: `Widget '${row.name}' must have an activated artifact before collection export`,
});
const { source, artifact } = await getArtifact(ctx, row.activeArtifactId);
const bindings = parseBindings(row.bindings);
const mapping: Record<string, string> = {};
for (const [name, requirement] of Object.entries(source.connections)) {
const identity = `${requirement.kind}:${requirement.serviceType ?? ""}:${bindings[name] ?? `${id}:${name}`}`;
let slot = shared.get(identity);
if (!slot) {
slot = `connection${shared.size + 1}`;
shared.set(identity, slot);
connections[slot] = { ...requirement };
} else {
const sharedRequirement = connections[slot];
if (!requirement.optional && sharedRequirement) sharedRequirement.optional = false;
}
mapping[name] = slot;
}
widgets.push({
key: `widget${index + 1}`,
archive: { format: "homarr-widget-archive-v3", source, artifact },
connections: mapping,
origin: readPortableWidgetOrigin(row.origin),
});
}
signal.throwIfAborted();
return createWidgetCollectionArchive({ format: "homarr-widget-collection-v1", manifest, connections, widgets });
});
}
@@ -0,0 +1,46 @@
import { setTimeout as delay } from "node:timers/promises";
import { TRPCError } from "@trpc/server";
import { observeWidgetPackageChanges } from "./events";
import { resolvePackagePreview } from "./preview-store";
import type { ResolvedPackagePlacement } from "./records";
import type { PackageContext } from "./types";
/** Scenario queries never invoke package server code or fall back to a configured connection. */
export async function executeWidgetPreviewFixture(
ctx: PackageContext,
resolved: ResolvedPackagePlacement,
name: string,
signal?: AbortSignal,
) {
const preview = resolved.preview;
const scenario = preview?.scenario;
if (!scenario) throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Choose a preview scenario first" });
const fixture = resolved.source.previewScenarios?.[scenario]?.queries[name];
if (!fixture)
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: `Preview scenario '${scenario}' has no fixture for query '${name}'`,
});
const controller = new AbortController();
const cancellation = controller.signal;
const combined = signal ? AbortSignal.any([signal, cancellation]) : cancellation;
const unsubscribe = observeWidgetPackageChanges((change) => {
if (change.kind === "disabled" && change.itemIds.includes(resolved.item.id))
controller.abort(new TRPCError({ code: "NOT_FOUND", message: "Preview session was discarded" }));
});
try {
combined.throwIfAborted();
if (fixture.delayMs) await delay(fixture.delayMs, undefined, { signal: combined });
combined.throwIfAborted();
await resolvePackagePreview(ctx, preview.id);
if ("error" in fixture)
throw new TRPCError({ code: fixture.error.code ?? "INTERNAL_SERVER_ERROR", message: fixture.error.message });
return fixture.data;
} catch (error) {
if (combined.aborted) throw combined.reason;
throw error;
} finally {
unsubscribe();
}
}
@@ -0,0 +1,178 @@
import { widgetPackageAdminProcedure } from "./procedure";
import { getPreviewCapabilities } from "./capabilities";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { customWidgetPackageSchema } from "@homarr/custom-widgets/package";
import { BoundedAsyncQueue } from "../../widgets/bounded-async-queue";
import { getWidgetPackageSupervisor, validatePackageHandlerInput } from "./invocations";
import { getOrBuildWidgetArtifact } from "./transfer";
import {
getPackagePreviewData,
persistPackageArtifact,
previewValue,
resolvePackagePreview,
savePackagePreview,
} from "./preview-store";
import { createWidgetSdkBridge } from "./sdk-bridge";
import { publishWidgetPackageChange } from "./events";
import { refreshWidgetActor } from "./authorization";
import { executeWidgetPreviewFixture } from "./preview-fixtures";
import { readWidgetStorage, writeWidgetStorage, widgetStorageInputSchema } from "./storage";
const admin = widgetPackageAdminProcedure;
const invocation = z.object({ previewId: z.string(), name: z.string(), input: z.unknown().default({}) });
const previewStorageInput = widgetStorageInputSchema.omit({ itemId: true }).extend({ previewId: z.string() });
export const packagePreviewProcedures = {
previewStorageGet: admin.input(previewStorageInput).query(async ({ ctx, input }) => {
const resolved = await resolvePackagePreview(ctx, input.previewId);
return readWidgetStorage(ctx, resolved, { ...input, itemId: resolved.item.id });
}),
previewStorageSet: admin
.input(previewStorageInput.extend({ value: z.unknown() }))
.mutation(async ({ ctx, input }) => {
const resolved = await resolvePackagePreview(ctx, input.previewId);
return writeWidgetStorage(ctx, resolved, { ...input, value: input.value, itemId: resolved.item.id });
}),
previewGet: admin.input(z.object({ previewId: z.string() })).query(async ({ ctx, input }) => {
const resolved = await resolvePackagePreview(ctx, input.previewId);
return { previewId: input.previewId, displayData: getPackagePreviewData(resolved) };
}),
preview: admin
.meta({
mcp: {
enabled: true,
description:
"Compile and preview explicit v3 source with named binding IDs. Requires admin and trusted:true for Homarr-level execution. Actions are simulated unless liveActions:true; does not activate dashboard placements.",
},
})
.input(
z.object({
id: z.string(),
source: z.record(z.string(), z.unknown()),
trusted: z.literal(true),
options: z.record(z.string(), z.unknown()).default({}),
bindings: z.record(z.string(), z.string()).default({}),
liveActions: z.boolean().default(false),
scenario: z.string().min(1).max(128).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const source = customWidgetPackageSchema.parse(input.source);
if (input.scenario && !source.previewScenarios?.[input.scenario])
throw new TRPCError({ code: "BAD_REQUEST", message: "Choose a declared package preview scenario" });
if (input.scenario && input.liveActions)
throw new TRPCError({ code: "BAD_REQUEST", message: "Fixture preview scenarios always simulate SDK actions" });
const artifact = await getOrBuildWidgetArtifact(ctx, source);
if (!input.scenario) await getWidgetPackageSupervisor().preflight({ artifact });
await persistPackageArtifact(ctx, source, artifact);
const previewId = createId();
const resolved = await savePackagePreview(ctx, {
id: previewId,
installationId: input.id,
artifactId: artifact.digest,
options: input.options,
bindings: input.bindings,
liveActions: input.liveActions,
scenario: input.scenario,
});
return { previewId, displayData: getPackagePreviewData(resolved) };
}),
discardPreview: admin.input(z.object({ previewId: z.string() })).mutation(async ({ ctx, input }) => {
const resolved = await resolvePackagePreview(ctx, input.previewId);
await previewValue(input.previewId, "null");
await publishWidgetPackageChange({
installationId: `preview-${input.previewId}`,
itemIds: [resolved.item.id],
kind: "disabled",
});
}),
previewQuery: admin.input(invocation).query(async ({ ctx, input, signal }) => {
const resolved = await resolvePackagePreview(ctx, input.previewId);
if (input.name === "$capabilities") return getPreviewCapabilities(resolved);
const handler = resolved.artifact.manifest.handlers[input.name];
if (handler?.kind !== "query") throw new TRPCError({ code: "NOT_FOUND" });
validatePackageHandlerInput(handler, input.input);
if (resolved.preview?.scenario) return executeWidgetPreviewFixture(ctx, resolved, input.name, signal);
return getWidgetPackageSupervisor().invoke({
artifact: resolved.artifact,
instanceId: resolved.item.id,
boardId: resolved.item.boardId,
userId: ctx.session.user.id,
handler: input.name,
input: input.input,
signal,
sdk: createWidgetSdkBridge(ctx, resolved, "query", false, signal),
});
}),
previewAction: admin.input(invocation).mutation(async ({ ctx, input, signal }) => {
const resolved = await resolvePackagePreview(ctx, input.previewId);
const handler = resolved.artifact.manifest.handlers[input.name];
if (handler?.kind !== "action") throw new TRPCError({ code: "NOT_FOUND" });
validatePackageHandlerInput(handler, input.input);
if (!resolved.preview?.liveActions) return { simulated: true, handler: input.name, input: input.input };
return getWidgetPackageSupervisor().invoke({
artifact: resolved.artifact,
instanceId: resolved.item.id,
boardId: resolved.item.boardId,
userId: ctx.session.user.id,
handler: input.name,
input: input.input,
signal,
sdk: createWidgetSdkBridge(ctx, resolved, "action", false, signal),
});
}),
previewSubscription: admin.input(invocation).subscription(async function* ({ ctx, input, signal }) {
const resolved = await resolvePackagePreview(ctx, input.previewId);
const handler = resolved.artifact.manifest.handlers[input.name];
if (handler?.kind !== "subscription") throw new TRPCError({ code: "NOT_FOUND" });
if (resolved.preview?.scenario)
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "Preview scenarios provide query fixtures; subscriptions are not connected in fixture mode",
});
validatePackageHandlerInput(handler, input.input);
const queue = new BoundedAsyncQueue<unknown>(32);
const controller = new AbortController();
const abort = () => {
controller.abort();
queue.close();
};
signal?.addEventListener("abort", abort, { once: true });
if (signal?.aborted) abort();
const authorization = setInterval(() => {
void refreshWidgetActor(ctx)
.then((actor) => resolvePackagePreview(actor, input.previewId))
.catch((error: unknown) => {
queue.fail(error);
controller.abort();
});
}, 15_000);
authorization.unref();
let unsubscribe: (() => void) | undefined;
try {
unsubscribe = await getWidgetPackageSupervisor().subscribe({
artifact: resolved.artifact,
instanceId: resolved.item.id,
boardId: resolved.item.boardId,
userId: ctx.session.user.id,
handler: input.name,
input: input.input,
signal: controller.signal,
sdk: createWidgetSdkBridge(ctx, resolved, "subscription", false, controller.signal),
onData: (data) => queue.push(data),
onError: (error) => queue.fail(error),
onComplete: () => queue.close(),
});
yield* queue;
} finally {
clearInterval(authorization);
unsubscribe?.();
abort();
signal?.removeEventListener("abort", abort);
}
}),
};
@@ -0,0 +1,129 @@
import { TRPCError } from "@trpc/server";
import { createRedisClient } from "@homarr/core/infrastructure/redis";
import { customWidgetArtifacts } from "@homarr/db/schema";
import { getCustomWidgetDefaultOptions, validateCustomWidgetOptions } from "@homarr/custom-widgets/core";
import type { CustomWidgetArtifact, CustomWidgetPackage } from "@homarr/custom-widgets/package";
import { assertWidgetArtifactIntegrity } from "@homarr/custom-widgets/package/server";
import { getArtifact, getInstallation, getPackageDisplayData, parseBindings } from "./records";
import type { ResolvedPackagePlacement } from "./records";
import type { PackageContext } from "./types";
import { refreshWidgetActor } from "./actor";
import { leaseWidgetArtifact, withWidgetArtifactReferenceLock } from "./artifact-references";
const prefix = "custom-widget:package-preview:";
const ttl = 60 * 60 * 1000;
const local = new Map<string, { value: string; expires: number }>();
let redis: ReturnType<typeof createRedisClient> | undefined;
export async function previewValue(key: string, value?: string) {
const localMode = process.env.NODE_ENV === "test" || process.env.CI !== undefined;
if (localMode) {
for (const [entryKey, entry] of local) if (entry.expires < Date.now()) local.delete(entryKey);
if (value !== undefined) local.set(key, { value, expires: Date.now() + ttl });
return local.get(key)?.value ?? null;
}
redis ??= createRedisClient();
if (value !== undefined) await redis.set(`${prefix}${key}`, value, "PX", ttl);
return redis.get(`${prefix}${key}`);
}
interface PreviewRecord {
id: string;
userId: string;
installationId: string;
artifactId: string;
options: Record<string, unknown>;
bindings: Record<string, string>;
liveActions: boolean;
scenario?: string;
}
export async function persistPackageArtifact(
ctx: PackageContext,
source: CustomWidgetPackage,
artifact: CustomWidgetArtifact,
) {
return withWidgetArtifactReferenceLock(async () => {
await leaseWidgetArtifact(artifact.digest);
await persistArtifactRecord(ctx, source, artifact);
});
}
async function persistArtifactRecord(ctx: PackageContext, source: CustomWidgetPackage, artifact: CustomWidgetArtifact) {
assertWidgetArtifactIntegrity(artifact, source);
try {
const existing = await getArtifact(ctx, artifact.digest);
assertWidgetArtifactIntegrity(existing.artifact, source);
return;
} catch (error) {
if (!(error instanceof TRPCError && error.code === "PRECONDITION_FAILED")) throw error;
}
try {
await ctx.db.insert(customWidgetArtifacts).values({
id: artifact.digest,
packageId: source.manifest.id,
version: source.manifest.version,
source: JSON.stringify(source),
artifact: JSON.stringify(artifact),
createdAt: new Date(),
});
} catch (error) {
// Two owners/replicas may import the same immutable artifact concurrently.
try {
const existing = await getArtifact(ctx, artifact.digest);
assertWidgetArtifactIntegrity(existing.artifact, source);
} catch {
throw error;
}
}
}
export async function savePackagePreview(ctx: PackageContext, record: Omit<PreviewRecord, "userId">) {
if (!ctx.session) throw new TRPCError({ code: "UNAUTHORIZED" });
await previewValue(record.id, JSON.stringify({ ...record, userId: ctx.session.user.id }));
return resolvePackagePreview(ctx, record.id);
}
export async function resolvePackagePreview(ctx: PackageContext, id: string): Promise<ResolvedPackagePlacement> {
ctx = await refreshWidgetActor(ctx);
if (!ctx.session?.user.permissions.includes("admin")) throw new TRPCError({ code: "FORBIDDEN" });
const raw = await previewValue(id);
if (!raw) throw new TRPCError({ code: "NOT_FOUND", message: "Preview expired. Preview the draft again." });
const record = JSON.parse(raw) as PreviewRecord | null;
if (!record) throw new TRPCError({ code: "NOT_FOUND", message: "Preview expired" });
if (record.userId !== ctx.session.user.id) throw new TRPCError({ code: "NOT_FOUND" });
const installation = await getInstallation(ctx, record.installationId);
const { artifact, source } = await getArtifact(ctx, record.artifactId);
if (record.scenario && !source.previewScenarios?.[record.scenario])
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "The selected preview scenario is unavailable" });
const configuration = { ...getCustomWidgetDefaultOptions(source.options), ...record.options };
const issues = validateCustomWidgetOptions(source.options, configuration);
if (issues.length) throw new TRPCError({ code: "BAD_REQUEST", message: issues[0]?.message });
return {
item: { id: `preview-${id}`, boardId: `preview-${id}` },
installation,
source,
artifact,
configuration,
bindings: { ...parseBindings(installation.bindings), ...record.bindings },
preview: { id, liveActions: record.liveActions && !record.scenario, scenario: record.scenario },
};
}
export function getPackagePreviewData(resolved: ResolvedPackagePlacement) {
const data = getPackageDisplayData(resolved);
for (const surface of ["tile", "advanced", "configuration"] as const) {
if (!resolved.artifact.client[surface]) continue;
const url = `/api/custom-widgets/previews/${resolved.preview?.id}/${surface}`;
data.surfaceUrls[surface] = url;
data.stylesheetUrls[surface] = `${url}?asset=css`;
}
let previewScenario: { id: string; label: string; description?: string } | undefined;
const scenarioId = resolved.preview?.scenario;
const scenario = scenarioId ? resolved.source.previewScenarios?.[scenarioId] : undefined;
if (scenario && scenarioId)
previewScenario = { id: scenarioId, label: scenario.label, description: scenario.description };
return { ...data, previewId: resolved.preview?.id, previewScenario };
}
@@ -0,0 +1,12 @@
import { TRPCError } from "@trpc/server";
import { permissionRequiredProcedure } from "../../../trpc";
import { refreshWidgetActor } from "./actor";
export const widgetPackageAdminProcedure = permissionRequiredProcedure
.requiresPermission("admin")
.use(async ({ ctx, next }) => {
const current = await refreshWidgetActor(ctx);
if (!current.session?.user.permissions.includes("admin")) throw new TRPCError({ code: "FORBIDDEN" });
return next({ ctx: { ...ctx, session: current.session } });
});
@@ -0,0 +1,140 @@
import { createHash } from "node:crypto";
import { TRPCError } from "@trpc/server";
import { parse } from "superjson";
import { isRecord } from "@homarr/common";
import { eq } from "@homarr/db";
import { boards, customWidgetArtifacts, customWidgetInstallations, items } from "@homarr/db/schema";
import { getCustomWidgetDefaultOptions, validateCustomWidgetOptions } from "@homarr/custom-widgets/core";
import { customWidgetArtifactSchema, customWidgetPackageSchema } from "@homarr/custom-widgets/package";
import type { CustomWidgetArtifact, CustomWidgetPackage } from "@homarr/custom-widgets/package";
import { throwIfActionForbiddenAsync } from "../../board/board-access";
import type { ConnectionBindings, PackageContext, PackagePlacementOptions } from "./types";
import { refreshWidgetActor } from "./actor";
export const packageDigest = (value: unknown) => createHash("sha256").update(stableJson(value)).digest("hex");
export function stableJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
if (isRecord(value)) {
return `{${Object.keys(value)
.toSorted()
.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
.join(",")}}`;
}
return JSON.stringify(value) ?? "null";
}
export function parseBindings(raw: string): ConnectionBindings {
let value: unknown;
try {
value = JSON.parse(raw);
} catch {
return {};
}
if (!isRecord(value)) return {};
return Object.fromEntries(
Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
);
}
/** Recovery and management must remain reachable even when stored source is malformed. */
export function readPackageDraft(raw: string): unknown {
try {
return JSON.parse(raw) as unknown;
} catch {
return { _draftMetadata: raw, files: {} };
}
}
export function parsePackagePlacement(raw: string): PackagePlacementOptions | null {
try {
const value: unknown = parse(raw);
if (!isRecord(value) || typeof value.definitionId !== "string") return null;
return {
definitionId: value.definitionId,
configuration: isRecord(value.configuration) ? value.configuration : {},
connectionBindings: parseBindings(JSON.stringify(value.connectionBindings ?? {})),
};
} catch {
return null;
}
}
export async function getInstallation(ctx: PackageContext, id: string) {
const record = await ctx.db.query.customWidgetInstallations.findFirst({
where: eq(customWidgetInstallations.id, id),
});
if (!record) throw new TRPCError({ code: "NOT_FOUND", message: "Widget installation not found" });
return record;
}
export async function getArtifact(ctx: PackageContext, id: string) {
const row = await ctx.db.query.customWidgetArtifacts.findFirst({ where: eq(customWidgetArtifacts.id, id) });
if (!row) throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Widget artifact is unavailable" });
return {
row,
artifact: customWidgetArtifactSchema.parse(JSON.parse(row.artifact)),
source: customWidgetPackageSchema.parse(JSON.parse(row.source)),
};
}
export async function getPackagePlacements(ctx: PackageContext, installationId: string) {
const candidates = await ctx.db.query.items.findMany({ where: eq(items.kind, "customApi") });
return candidates.flatMap((item) => {
const options = parsePackagePlacement(item.options);
if (options?.definitionId !== installationId) return [];
return [{ ...item, packageOptions: options }];
});
}
export async function resolvePackagePlacement(ctx: PackageContext, itemId: string): Promise<ResolvedPackagePlacement> {
ctx = await refreshWidgetActor(ctx);
const item = await ctx.db.query.items.findFirst({ where: eq(items.id, itemId) });
if (!item || item.kind !== "customApi") throw new TRPCError({ code: "NOT_FOUND" });
await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "view");
const options = parsePackagePlacement(item.options);
if (!options) throw new TRPCError({ code: "NOT_FOUND" });
const installation = await getInstallation(ctx, options.definitionId);
if (!installation.enabled || !installation.activeArtifactId) {
throw new TRPCError({ code: "FORBIDDEN", message: "Widget installation is disabled" });
}
const { artifact, source } = await getArtifact(ctx, installation.activeArtifactId);
const configuration = { ...getCustomWidgetDefaultOptions(source.options), ...options.configuration };
const issues = validateCustomWidgetOptions(source.options, configuration);
if (issues.length > 0)
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Widget configuration needs repair" });
const bindings = { ...parseBindings(installation.bindings), ...options.connectionBindings };
return { item, installation, artifact, source, configuration, bindings };
}
export interface ResolvedPackagePlacement {
item: { id: string; boardId: string };
installation: Awaited<ReturnType<typeof getInstallation>>;
artifact: CustomWidgetArtifact;
source: CustomWidgetPackage;
configuration: Record<string, unknown>;
bindings: ConnectionBindings;
preview?: { id: string; liveActions: boolean; scenario?: string };
}
export function getPackageDisplayData(resolved: ResolvedPackagePlacement) {
const surfaceUrls: { tile: string; advanced?: string; configuration?: string } = { tile: "" };
const stylesheetUrls: typeof surfaceUrls = { tile: "" };
for (const surface of ["tile", "advanced", "configuration"] as const) {
if (!resolved.artifact.client[surface]) continue;
const url = `/api/custom-widgets/artifacts/${encodeURIComponent(resolved.item.id)}/${resolved.artifact.digest}/${surface}`;
surfaceUrls[surface] = url;
stylesheetUrls[surface] = `${url}?asset=css`;
}
return {
type: "customWidgetV3" as const,
installationId: resolved.installation.id,
artifactDigest: resolved.artifact.digest,
manifest: resolved.artifact.manifest,
options: resolved.configuration,
surfaceUrls,
stylesheetUrls,
};
}
@@ -0,0 +1,160 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { hasQueryAccessToIntegrationsAsync } from "@homarr/auth/server";
import { constructIntegrationPermissions } from "@homarr/auth/shared";
import { decryptSecret } from "@homarr/common/server";
import { createId } from "@homarr/common";
import { eq, inArray } from "@homarr/db";
import { groupMembers, integrationGroupPermissions, integrations, integrationUserPermissions } from "@homarr/db/schema";
import type { WidgetSdkInvocation } from "@homarr/custom-widgets/package/server";
import { readPackageHistory, widgetHistoryInputSchema } from "./history";
import { getBoundConnection } from "./connections";
import { fetchWidgetConnection, widgetHttpInputSchema } from "./http";
import {
getNativeWidgetCapability,
invokeNativeWidgetCapability,
subscribeNativeWidgetCapability,
} from "./native-capabilities";
import type { ResolvedPackagePlacement } from "./records";
import { readWidgetStorage, writeWidgetStorage, widgetStorageInputSchema } from "./storage";
import type { PackageContext } from "./types";
import { refreshWidgetActor } from "./actor";
import { invokeLegacyWidgetRequest } from "./conversion-request";
const integrationCallSchema = z.object({
connection: z.string(),
capability: z.string(),
input: z.unknown().default({}),
});
export function createWidgetSdkBridge(
ctx: PackageContext,
resolved: ResolvedPackagePlacement,
kind: "query" | "action" | "subscription",
guest: boolean,
outerSignal?: AbortSignal,
) {
const subscriptions = new Map<string, AsyncGenerator<unknown>>();
const controller = new AbortController();
const signal = controller.signal;
const observed = new WeakSet<AbortSignal>();
const dispose = () => {
controller.abort();
for (const iterator of subscriptions.values()) void iterator.return(undefined).catch(() => undefined);
subscriptions.clear();
};
outerSignal?.addEventListener("abort", dispose, { once: true });
if (outerSignal?.aborted) dispose();
return async ({ operation, input, signal: invocationSignal }: WidgetSdkInvocation) => {
ctx = await refreshWidgetActor(ctx);
if (invocationSignal && !observed.has(invocationSignal)) {
observed.add(invocationSignal);
invocationSignal.addEventListener("abort", dispose, { once: true });
if (invocationSignal.aborted) dispose();
}
signal?.throwIfAborted();
if (operation === "legacy.request") return invokeLegacyWidgetRequest(ctx, resolved, kind, input, signal);
if (operation === "widget.context")
return {
options: resolved.configuration,
bindingNames: Object.keys(resolved.bindings),
installationId: resolved.installation.id,
isPreview: Boolean(resolved.preview),
};
if (operation === "integration.next" || operation === "integration.unsubscribe") {
const { id } = z.object({ id: z.string() }).parse(input);
const iterator = subscriptions.get(id);
if (!iterator) return { done: true };
if (operation === "integration.unsubscribe") {
subscriptions.delete(id);
await iterator.return(undefined);
return { done: true };
}
const result = await iterator.next();
if (result.done) subscriptions.delete(id);
return result;
}
if (operation === "history.get") return readPackageHistory(ctx, resolved, widgetHistoryInputSchema.parse(input));
if (operation === "storage.get") {
const parsed = widgetStorageInputSchema.omit({ itemId: true }).parse(input);
return readWidgetStorage(ctx, resolved, { ...parsed, itemId: resolved.item.id });
}
if (operation === "storage.set") {
if (kind !== "action")
throw new TRPCError({ code: "FORBIDDEN", message: "Storage writes require an action handler" });
const parsed = widgetStorageInputSchema.omit({ itemId: true }).extend({ value: z.unknown() }).parse(input);
return writeWidgetStorage(ctx, resolved, { ...parsed, value: parsed.value, itemId: resolved.item.id }, true);
}
if (operation === "connection.fetch")
return fetchWidgetConnection(ctx, resolved.bindings, widgetHttpInputSchema.parse(input), signal);
if (operation === "connection.get") {
const { connection } = z.object({ connection: z.string() }).parse(input);
const { configuration, secrets } = await getBoundConnection(ctx, resolved.bindings, connection);
return { configuration, secrets };
}
if (operation === "integration.call" || operation === "integration.subscribe") {
const parsed = integrationCallSchema.parse(input);
const descriptor = getNativeWidgetCapability(parsed.capability);
if (descriptor.kind === "action" && kind !== "action")
throw new TRPCError({ code: "FORBIDDEN", message: "Native actions require an action handler" });
const { row } = await getBoundConnection(ctx, resolved.bindings, parsed.connection);
if (!row.integrationId)
throw new TRPCError({ code: "BAD_REQUEST", message: "An integration connection is required" });
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, row.integrationId),
with: {
app: true,
secrets: true,
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
ctx.db
.select({ groupId: groupMembers.groupId })
.from(groupMembers)
.where(eq(groupMembers.userId, ctx.session?.user.id ?? "")),
),
},
userPermissions: {
where: eq(integrationUserPermissions.userId, ctx.session?.user.id ?? ""),
},
items: { with: { item: true } },
},
});
if (!integration) throw new TRPCError({ code: "NOT_FOUND", message: "Integration not found" });
// A named connection bound by the owner is the custom equivalent of a native integration_item.
// Include only this already authorized placement when applying the native board-view policy.
const permissionIntegration = {
...integration,
items: [...integration.items, { item: { boardId: resolved.item.boardId } }],
};
if (!guest) {
if (descriptor.kind === "action") {
if (!constructIntegrationPermissions(integration, ctx.session).hasInteractAccess)
throw new TRPCError({ code: "FORBIDDEN" });
} else if (!(await hasQueryAccessToIntegrationsAsync(ctx.db, [permissionIntegration], ctx.session)))
throw new TRPCError({ code: "FORBIDDEN" });
}
const nativeIntegration = {
...integration,
externalUrl: integration.app?.href ?? null,
decryptedSecrets: integration.secrets.map((secret) => ({ ...secret, value: decryptSecret(secret.value) })),
};
if (operation === "integration.subscribe") {
if (kind !== "subscription" || descriptor.kind !== "subscription" || !signal)
throw new TRPCError({ code: "BAD_REQUEST", message: "Live native data requires a subscription handler" });
if (subscriptions.size >= 16)
throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Too many native subscriptions in one handler" });
const id = createId();
subscriptions.set(
id,
subscribeNativeWidgetCapability(nativeIntegration, parsed.capability, parsed.input, signal),
);
return id;
}
return invokeNativeWidgetCapability(nativeIntegration, parsed.capability, parsed.input, signal);
}
throw new TRPCError({ code: "NOT_FOUND", message: `Unknown widget SDK operation '${operation}'` });
};
}
@@ -0,0 +1,72 @@
import { open } from "node:fs/promises";
import { constants } from "node:fs";
import { createConnection } from "node:net";
interface ServiceConfiguration {
serviceType?: string;
settings?: Record<string, unknown>;
timeoutMs: number;
}
/** Probe only the declared transport. Protocol authentication belongs to the widget's preview handler. */
export async function diagnoseWidgetService(configuration: ServiceConfiguration) {
const settings = configuration.settings ?? {};
const details = {
addresses: [] as { address: string; family: number }[],
serviceType: configuration.serviceType,
settingNames: Object.keys(settings),
};
try {
if (configuration.serviceType === "sqlite" || configuration.serviceType === "file") {
if (typeof settings.path !== "string" || !settings.path.trim())
throw new Error("Set the service's path to a file mounted in the Homarr server or container");
// An owner-selected mounted service file is not part of the application bundle.
const file = await open(/* turbopackIgnore: true */ settings.path, constants.O_RDONLY | constants.O_NONBLOCK);
try {
const stat = await file.stat();
if (!stat.isFile()) throw new Error("The configured path must point to a regular file");
return { ...details, status: "fileReadable" as const, bytes: stat.size, modifiedAt: stat.mtime.toISOString() };
} finally {
await file.close();
}
}
if (configuration.serviceType === "mqtt") {
if (typeof settings.url !== "string") throw new Error("Set url to mqtt://host:1883 or mqtts://host:8883");
const url = new URL(settings.url);
if (!["mqtt:", "mqtts:"].includes(url.protocol))
throw new Error("The MQTT transport probe supports mqtt:// and mqtts://; use preview for WebSocket brokers");
let port = 1883;
if (url.protocol === "mqtts:") port = 8883;
if (url.port) port = Number(url.port);
const hostname = url.hostname.replace(/^\[|\]$/gu, "");
const started = performance.now();
await new Promise<void>((resolve, reject) => {
const socket = createConnection({ host: hostname, port });
const finish = (error?: Error) => {
socket.destroy();
if (error) reject(error);
else resolve();
};
socket.setTimeout(Math.min(configuration.timeoutMs, 10_000), () =>
finish(new Error("TCP connection timed out")),
);
socket.once("connect", () => finish());
socket.once("error", finish);
});
return {
...details,
status: "transportReachable" as const,
hostname,
port,
durationMs: Math.round(performance.now() - started),
};
}
return { ...details, status: "serviceConfigured" as const };
} catch (error) {
return {
...details,
status: "serviceUnavailable" as const,
error: { message: error instanceof Error ? error.message : "The service probe failed" },
};
}
}
@@ -0,0 +1,91 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { and, eq, handleTransactionsAsync } from "@homarr/db";
import { boards, customWidgetStorage } from "@homarr/db/schema";
import { throwIfActionForbiddenAsync } from "../../board/board-access";
import { packageDigest } from "./records";
import type { ResolvedPackagePlacement } from "./records";
import { publishWidgetPackageChange } from "./events";
import { previewValue } from "./preview-store";
import type { PackageContext } from "./types";
export const widgetStorageInputSchema = z.object({
itemId: z.string().min(1),
scope: z.enum(["user", "instance", "installation"]),
key: z.string().min(1).max(128),
});
type StorageInput = z.infer<typeof widgetStorageInputSchema>;
function storageIdentity(ctx: PackageContext, resolved: ResolvedPackagePlacement, input: StorageInput) {
let ownerKey = resolved.installation.id;
if (input.scope === "instance") ownerKey = resolved.item.id;
if (input.scope === "user") {
if (!ctx.session) throw new TRPCError({ code: "UNAUTHORIZED" });
ownerKey = ctx.session.user.id;
}
return { ownerKey, id: packageDigest([resolved.installation.id, input.scope, ownerKey, input.key]) };
}
export async function readWidgetStorage(ctx: PackageContext, resolved: ResolvedPackagePlacement, input: StorageInput) {
const { id } = storageIdentity(ctx, resolved, input);
if (resolved.preview) {
const raw = await previewValue(`storage:${resolved.preview.id}:${id}`);
if (!raw) return null;
return JSON.parse(raw) as unknown;
}
const row = await ctx.db.query.customWidgetStorage.findFirst({
where: and(eq(customWidgetStorage.id, id), eq(customWidgetStorage.installationId, resolved.installation.id)),
});
if (!row) return null;
return JSON.parse(row.value) as unknown;
}
export async function writeWidgetStorage(
ctx: PackageContext,
resolved: ResolvedPackagePlacement,
input: StorageInput & { value: unknown },
authorizedHandler = false,
) {
if (!authorizedHandler && !resolved.preview && input.scope !== "user") {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, resolved.item.boardId), "modify");
}
const identity = storageIdentity(ctx, resolved, input);
const value = JSON.stringify(input.value);
if (value === undefined || Buffer.byteLength(value) > 1_048_576) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Widget storage values must be JSON up to 1 MiB" });
}
if (resolved.preview) {
await previewValue(`storage:${resolved.preview.id}:${identity.id}`, value);
return { updatedAt: new Date() };
}
const row = {
...identity,
installationId: resolved.installation.id,
scope: input.scope,
key: input.key,
value,
updatedAt: new Date(),
};
await handleTransactionsAsync(ctx.db, {
async handleAsync(database, schema) {
await database.transaction(async (transaction) => {
await transaction.delete(schema.customWidgetStorage).where(eq(schema.customWidgetStorage.id, identity.id));
await transaction.insert(schema.customWidgetStorage).values(row);
});
},
handleSync(database) {
database.transaction((transaction) => {
transaction.delete(customWidgetStorage).where(eq(customWidgetStorage.id, identity.id)).run();
transaction.insert(customWidgetStorage).values(row).run();
});
},
});
await publishWidgetPackageChange({
installationId: resolved.installation.id,
itemIds: [resolved.item.id],
kind: "storage",
});
return { updatedAt: row.updatedAt };
}
@@ -0,0 +1,130 @@
import { widgetPackageAdminProcedure } from "./procedure";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { eq } from "@homarr/db";
import { customWidgetArtifacts, customWidgetInstallations } from "@homarr/db/schema";
import { customWidgetArchiveSchema, customWidgetPackageSchema } from "@homarr/custom-widgets/package";
import { assertWidgetArtifactIntegrity, compileCustomWidgetPackage } from "@homarr/custom-widgets/package/server";
import type { CustomWidgetPackage } from "@homarr/custom-widgets/package";
import { getWidgetPackageDirectory } from "./paths";
import { persistPackageArtifact } from "./preview-store";
import { getArtifact, getInstallation, packageDigest } from "./records";
import type { PackageContext } from "./types";
import { withWidgetInstallationLock } from "./coordination";
import { leaseWidgetArtifact, reserveWidgetBuild, withWidgetArtifactReferenceLock } from "./artifact-references";
const admin = widgetPackageAdminProcedure;
export async function getOrBuildWidgetArtifact(ctx: PackageContext, source: CustomWidgetPackage) {
const cachedArtifact = await withWidgetArtifactReferenceLock(async () => {
const cached = await ctx.db.query.customWidgetArtifacts.findFirst({
where: eq(customWidgetArtifacts.source, JSON.stringify(source)),
});
if (cached) {
const { artifact } = await getArtifact(ctx, cached.id);
assertWidgetArtifactIntegrity(artifact, source);
await leaseWidgetArtifact(artifact.digest);
return artifact;
}
await reserveWidgetBuild(source);
return null;
});
if (cachedArtifact) return cachedArtifact;
return compileCustomWidgetPackage(source, {
rootDirectory: getWidgetPackageDirectory(),
allowDependencyInstall: true,
});
}
export const packageTransferProcedures = {
import: admin.input(z.object({ archive: customWidgetArchiveSchema })).mutation(async ({ ctx, input }) => {
assertWidgetArtifactIntegrity(input.archive.artifact, input.archive.source);
const { source, artifact } = input.archive;
await persistPackageArtifact(ctx, source, artifact);
const id = createId();
await ctx.db.insert(customWidgetInstallations).values({
id,
name: source.manifest.name,
draft: JSON.stringify(source),
bindings: "{}",
enabled: false,
creatorId: ctx.session.user.id,
createdAt: new Date(),
updatedAt: new Date(),
});
return { id, managementPath: `/manage/custom-widgets/packages/${id}` };
}),
stageUpdate: admin
.input(z.object({ id: z.string(), archive: customWidgetArchiveSchema, replaceLocalDraft: z.literal(true) }))
.mutation(async ({ ctx, input }) => {
return withWidgetInstallationLock(input.id, async () => {
const installation = await getInstallation(ctx, input.id);
if (!installation.activeArtifactId)
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Activate the installation first" });
const installed = await getArtifact(ctx, installation.activeArtifactId);
if (installed.source.manifest.id !== input.archive.source.manifest.id)
throw new TRPCError({
code: "BAD_REQUEST",
message: "The update belongs to a different package. Import it alongside this installation.",
});
assertWidgetArtifactIntegrity(input.archive.artifact, input.archive.source);
await persistPackageArtifact(ctx, input.archive.source, input.archive.artifact);
await ctx.db
.update(customWidgetInstallations)
.set({ draft: JSON.stringify(input.archive.source), updatedAt: new Date() })
.where(eq(customWidgetInstallations.id, input.id));
return { id: input.id, managementPath: `/manage/custom-widgets/packages/${input.id}` };
});
}),
fork: admin
.input(z.object({ id: z.string(), name: z.string().trim().min(1).max(128) }))
.mutation(async ({ ctx, input }) => {
const installation = await getInstallation(ctx, input.id);
const source = customWidgetPackageSchema.parse(JSON.parse(installation.draft));
const id = createId();
const forked = {
...source,
manifest: { ...source.manifest, id: `local.${id}`, name: input.name, version: "1.0.0" },
};
await ctx.db.insert(customWidgetInstallations).values({
id,
name: input.name,
draft: JSON.stringify(forked),
bindings: installation.bindings,
origin: JSON.stringify({
forkedFrom: source.manifest.id,
artifact: installation.activeArtifactId,
origin: installation.origin,
}),
enabled: false,
creatorId: ctx.session.user.id,
createdAt: new Date(),
updatedAt: new Date(),
});
return { id, managementPath: `/manage/custom-widgets/packages/${id}` };
}),
draftChanges: admin.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
const installation = await getInstallation(ctx, input.id);
const draft = customWidgetPackageSchema.safeParse(JSON.parse(installation.draft));
if (!draft.success) return { valid: false as const, issues: draft.error.issues };
const source = draft.data;
const previous = installation.activeArtifactId
? (await getArtifact(ctx, installation.activeArtifactId)).source
: null;
const names = new Set([...Object.keys(previous?.files ?? {}), ...Object.keys(source.files)]);
return {
valid: true as const,
files: [...names]
.filter((name) => previous?.files[name] !== source.files[name])
.map((name) => ({ name, before: previous?.files[name] ?? null, after: source.files[name] ?? null })),
dependenciesChanged: packageDigest(previous?.dependencies) !== packageDigest(source.dependencies),
connectionsChanged: packageDigest(previous?.connections) !== packageDigest(source.connections),
configurationChanged: packageDigest(previous?.options) !== packageDigest(source.options),
fromVersion: previous?.manifest.version,
toVersion: source.manifest.version,
};
}),
};
@@ -0,0 +1,19 @@
import type { Session } from "@homarr/auth";
import type { Database } from "@homarr/db";
import type { customWidgetArtifacts, customWidgetInstallations } from "@homarr/db/schema";
export interface PackageContext {
db: Database;
session: Session | null;
crossSiteRequest?: boolean;
}
export type PackageInstallation = typeof customWidgetInstallations.$inferSelect;
export type PackageArtifactRow = typeof customWidgetArtifacts.$inferSelect;
export type ConnectionBindings = Record<string, string>;
export interface PackagePlacementOptions {
definitionId: string;
configuration: Record<string, unknown>;
connectionBindings: ConnectionBindings;
}
@@ -0,0 +1,74 @@
import { randomBytes } from "node:crypto";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { and, eq } from "@homarr/db";
import { customWidgetStorage } from "@homarr/db/schema";
import { widgetPackageAdminProcedure as admin } from "./procedure";
import { packageDigest, resolvePackagePlacement } from "./records";
import { withWidgetInstallationLock } from "./coordination";
import {
getWebhookAuthorizationDigest,
getWebhookRecord,
isWebhookStorage,
webhookKey,
webhookRecordSchema,
} from "./webhook-records";
const placementInput = z.object({ itemId: z.string().min(1) });
export const packageWebhookProcedures = {
webhooks: admin.input(placementInput).query(async ({ ctx, input }) => {
const resolved = await resolvePackagePlacement(ctx, input.itemId);
const currentDigest = await getWebhookAuthorizationDigest(ctx, resolved);
const rows = await ctx.db.query.customWidgetStorage.findMany({
where: and(
eq(customWidgetStorage.installationId, resolved.installation.id),
eq(customWidgetStorage.ownerKey, input.itemId),
),
});
return rows.filter(isWebhookStorage).flatMap((row) => {
const parsed = webhookRecordSchema.safeParse(JSON.parse(row.value));
if (!parsed.success) return [];
const { tokenHash: _token, authorizationDigest, ...record } = parsed.data;
return [{ ...record, current: authorizationDigest === currentDigest }];
});
}),
createWebhook: admin.input(placementInput.extend({ handler: z.string().min(1) })).mutation(async ({ ctx, input }) => {
const placement = await resolvePackagePlacement(ctx, input.itemId);
return withWidgetInstallationLock(placement.installation.id, async () => {
const resolved = await resolvePackagePlacement(ctx, input.itemId);
const handler = resolved.artifact.manifest.handlers[input.handler];
if (!handler || handler.kind !== "action" || !handler.inputSchema)
throw new TRPCError({ code: "BAD_REQUEST", message: "Webhooks require a named action with an input schema" });
const id = createId();
const token = randomBytes(32).toString("base64url");
const record = {
id,
...input,
userId: ctx.session.user.id,
tokenHash: packageDigest(token),
authorizationDigest: await getWebhookAuthorizationDigest(ctx, resolved),
createdAt: new Date().toISOString(),
};
await ctx.db.insert(customWidgetStorage).values({
id: packageDigest([resolved.installation.id, webhookKey(id)]),
installationId: resolved.installation.id,
ownerKey: input.itemId,
scope: "system",
key: webhookKey(id),
value: JSON.stringify(record),
updatedAt: new Date(),
});
return { id, path: `/api/custom-widgets/webhooks/${input.itemId}/${id}`, token };
});
}),
revokeWebhook: admin.input(placementInput.extend({ id: z.string().min(1) })).mutation(async ({ ctx, input }) => {
await resolvePackagePlacement(ctx, input.itemId);
const record = await getWebhookRecord(ctx, input.itemId, input.id);
if (record) await ctx.db.delete(customWidgetStorage).where(eq(customWidgetStorage.id, record.row.id));
return { revoked: true };
}),
};
@@ -0,0 +1,52 @@
import { timingSafeEqual } from "node:crypto";
import { z } from "zod/v4";
import { and, eq } from "@homarr/db";
import { customWidgetStorage } from "@homarr/db/schema";
import { getBindingsDigest } from "./connections";
import { getPackageActionDigest } from "./permissions";
import { packageDigest } from "./records";
import type { ResolvedPackagePlacement } from "./records";
import type { PackageContext } from "./types";
export const webhookRecordSchema = z.object({
id: z.string(),
itemId: z.string(),
handler: z.string(),
userId: z.string(),
tokenHash: z.string(),
authorizationDigest: z.string(),
createdAt: z.string(),
});
export const webhookKey = (id: string) => `webhook:${id}`;
export const isWebhookStorage = (row: Pick<typeof customWidgetStorage.$inferSelect, "scope" | "key">) =>
row.scope === "system" && row.key.startsWith("webhook:");
export async function getWebhookAuthorizationDigest(ctx: PackageContext, resolved: ResolvedPackagePlacement) {
return packageDigest({
behavior: getPackageActionDigest(resolved),
connections: await getBindingsDigest(ctx, resolved.bindings),
options: resolved.configuration,
});
}
export async function getWebhookRecord(ctx: PackageContext, itemId: string, id: string) {
const row = await ctx.db.query.customWidgetStorage.findFirst({
where: and(
eq(customWidgetStorage.ownerKey, itemId),
eq(customWidgetStorage.scope, "system"),
eq(customWidgetStorage.key, webhookKey(id)),
),
});
if (!row) return null;
const record = webhookRecordSchema.safeParse(JSON.parse(row.value));
if (!record.success || record.data.itemId !== itemId || record.data.id !== id) return null;
return { row, record: record.data };
}
export function matchesWebhookToken(token: string, expected: string) {
const actual = Buffer.from(packageDigest(token), "hex");
const hash = Buffer.from(expected, "hex");
return actual.length === hash.length && timingSafeEqual(actual, hash);
}
@@ -0,0 +1,95 @@
import { TRPCError } from "@trpc/server";
import { createSessionAsync } from "@homarr/auth/server";
import { eq } from "@homarr/db";
import type { Database } from "@homarr/db";
import { users } from "@homarr/db/schema";
import { invokePackageHandler } from "./invocations";
import { acquireCustomWidgetRequestLimit } from "../request-limits";
import { getWebhookAuthorizationDigest, getWebhookRecord, matchesWebhookToken } from "./webhook-records";
export async function widgetWebhookResponse(db: Database, request: Request, params: { itemId: string; id: string }) {
let release: (() => Promise<void>) | undefined;
try {
const token = request.headers.get("authorization")?.replace(/^Bearer /iu, "") ?? "";
const stored = await getWebhookRecord({ db, session: null }, params.itemId, params.id);
if (!stored || !token || token.length > 256 || !matchesWebhookToken(token, stored.record.tokenHash))
return Response.json({ error: "Webhook is unavailable" }, { status: 401 });
const user = await db.query.users.findFirst({ where: eq(users.id, stored.record.userId) });
if (!user) throw new TRPCError({ code: "FORBIDDEN" });
const session = await createSessionAsync(db, user);
if (!session.user.permissions.includes("admin")) throw new TRPCError({ code: "FORBIDDEN" });
release = await acquireCustomWidgetRequestLimit({
category: "action",
itemId: params.itemId,
definitionId: stored.row.installationId,
});
const body = await readWebhookBody(request);
const result = await invokePackageHandler(
{ db, session },
{ itemId: params.itemId, name: stored.record.handler, input: body },
"action",
request.signal,
async (ctx, resolved) => {
if (!ctx.session?.user.permissions.includes("admin")) throw new TRPCError({ code: "FORBIDDEN" });
const current = await getWebhookRecord(ctx, params.itemId, params.id);
if (
!current ||
!matchesWebhookToken(token, current.record.tokenHash) ||
current.record.authorizationDigest !== (await getWebhookAuthorizationDigest(ctx, resolved))
)
throw new TRPCError({
code: "FORBIDDEN",
message: "The owner must register a new webhook after this widget changed",
});
},
);
return Response.json({ result: result ?? null }, { headers: { "Cache-Control": "no-store" } });
} catch (error) {
let status = 500;
let message = "Widget webhook failed";
if (error instanceof TRPCError) {
const statuses = {
FORBIDDEN: 403,
UNAUTHORIZED: 401,
NOT_FOUND: 404,
BAD_REQUEST: 400,
PAYLOAD_TOO_LARGE: 413,
TOO_MANY_REQUESTS: 429,
PRECONDITION_FAILED: 412,
};
status = statuses[error.code as keyof typeof statuses] ?? 500;
if (status !== 500) message = error.message;
}
return Response.json({ error: message }, { status, headers: { "Cache-Control": "no-store" } });
} finally {
await release?.();
}
}
async function readWebhookBody(request: Request) {
if (!request.headers.get("content-type")?.includes("application/json"))
throw new TRPCError({ code: "BAD_REQUEST", message: "Send a JSON body with Content-Type application/json" });
const reader = request.body?.getReader();
if (!reader) return {};
const chunks: Uint8Array[] = [];
let length = 0;
try {
while (true) {
const result = await reader.read();
if (result.done) break;
length += result.value.byteLength;
if (length > 1_048_576) throw new TRPCError({ code: "PAYLOAD_TOO_LARGE" });
chunks.push(result.value);
}
try {
return JSON.parse(Buffer.concat(chunks, length).toString("utf8")) as unknown;
} catch {
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid JSON body" });
}
} finally {
await reader.cancel().catch(() => undefined);
reader.releaseLock();
}
}
@@ -0,0 +1,38 @@
import { z } from "zod/v4";
import { publicProcedure } from "../../../trpc";
import { invokePackageHandler, packageOperationInputSchema, subscribePackageHandler } from "./invocations";
import { packageHistoryProcedures } from "./collection-procedures";
import { packageLifecycleProcedures } from "./lifecycle";
import { withWidgetInstallationLock } from "./coordination";
import { resolvePackagePlacement } from "./records";
import { readWidgetStorage, widgetStorageInputSchema, writeWidgetStorage } from "./storage";
import { getWidgetCapabilities } from "./capabilities";
export const packageWidgetProcedures = {
...packageLifecycleProcedures,
...packageHistoryProcedures,
packageQuery: publicProcedure.input(packageOperationInputSchema).query(({ ctx, input, signal }) => {
if (input.name === "$capabilities") return getWidgetCapabilities(ctx, input.itemId);
return invokePackageHandler(ctx, input, "query", signal);
}),
packageAction: publicProcedure
.input(packageOperationInputSchema)
.mutation(({ ctx, input, signal }) => invokePackageHandler(ctx, input, "action", signal)),
packageSubscription: publicProcedure
.input(packageOperationInputSchema)
.subscription(({ ctx, input, signal }) => subscribePackageHandler(ctx, input, signal)),
packageStorageGet: publicProcedure
.input(widgetStorageInputSchema)
.query(async ({ ctx, input }) => readWidgetStorage(ctx, await resolvePackagePlacement(ctx, input.itemId), input)),
packageStorageSet: publicProcedure
.input(widgetStorageInputSchema.extend({ value: z.unknown() }))
.mutation(async ({ ctx, input }) =>
(async () => {
const placement = await resolvePackagePlacement(ctx, input.itemId);
return withWidgetInstallationLock(placement.installation.id, async () =>
writeWidgetStorage(ctx, await resolvePackagePlacement(ctx, input.itemId), { ...input, value: input.value }),
);
})(),
),
};
@@ -0,0 +1,81 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { assertWidgetArtifactIntegrity } from "@homarr/custom-widgets/package/server";
import { parseWorkshopPackageRelease } from "@homarr/workshop/package-releases";
import { WorkshopBackend } from "@homarr/workshop/backend";
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
import { env } from "../../../env";
import { packageDigest } from "./records";
const urls = resolveHomarrUrlConfig({
homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
workshopApiUrl: env.WORKSHOP_API_URL,
workshopWebUrl: env.WORKSHOP_WEB_URL,
});
export const widgetWorkshop = new WorkshopBackend(urls.workshopApiUrl);
export const widgetWorkshopOriginSchema = z.object({
kind: z.literal("workshop"),
workshopApiUrl: z.string(),
submissionId: z.string(),
releaseId: z.string(),
version: z.string(),
sourceDigest: z.string(),
artifactDigest: z.string(),
author: z.string(),
forkedFrom: z.string(),
});
export function readWidgetWorkshopOrigin(raw: string | null) {
if (!raw) return null;
try {
const parsed = widgetWorkshopOriginSchema.safeParse(JSON.parse(raw));
if (parsed.success && parsed.data.workshopApiUrl === urls.workshopApiUrl) return parsed.data;
} catch {
/* Local forks and imported packages may have different provenance. */
}
return null;
}
export async function getWidgetWorkshopRelease(releaseId: string) {
return withWorkshopError(async () => {
const release = await widgetWorkshop.packages.get(releaseId, AbortSignal.timeout(15_000));
const parsed = parseWorkshopPackageRelease(release);
if (
packageDigest(parsed.source) !== release.sourceDigest ||
parsed.source.manifest.id !== release.packageId ||
parsed.source.manifest.version !== release.version
) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Workshop release metadata does not match its package" });
}
if (parsed.artifact) {
assertWidgetArtifactIntegrity(parsed.artifact, parsed.source);
if (parsed.artifact.digest !== release.artifactDigest) throw new Error("Release artifact checksum mismatch");
}
return {
...parsed,
release,
origin: { kind: "workshop" as const, workshopApiUrl: urls.workshopApiUrl, ...parsed.origin },
workshopUrl: `${urls.workshopWebUrl}/${encodeURIComponent(release.submission)}`,
};
});
}
export function authenticatedWidgetWorkshop(token: string) {
const backend = new WorkshopBackend(urls.workshopApiUrl);
backend.pocketBase.authStore.save(token);
return backend;
}
export async function withWorkshopError<T>(operation: () => Promise<T>): Promise<T> {
try {
return await operation();
} catch (error) {
if (error instanceof TRPCError) throw error;
throw new TRPCError({
code: "BAD_GATEWAY",
message: "Workshop request failed. Check your connection and Workshop account.",
});
}
}
@@ -0,0 +1,216 @@
import { widgetPackageAdminProcedure } from "./procedure";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { customWidgetPackageSchema } from "@homarr/custom-widgets/package";
import type { CustomWidgetArtifact } from "@homarr/custom-widgets/package";
import { and, eq } from "@homarr/db";
import { customWidgetInstallations } from "@homarr/db/schema";
import { preserveWidgetCollectionOrigin } from "./portable-collection-origin";
import { persistPackageArtifact } from "./preview-store";
import { withWidgetInstallationLock } from "./coordination";
import { withWidgetArtifactReferenceLock } from "./artifact-references";
import { getArtifact, getInstallation, packageDigest } from "./records";
import {
authenticatedWidgetWorkshop,
getWidgetWorkshopRelease,
readWidgetWorkshopOrigin,
widgetWorkshop,
withWorkshopError,
} from "./workshop-client";
const admin = widgetPackageAdminProcedure;
const idInput = z.object({ id: z.string().min(1) });
const releaseInput = z.object({ releaseId: z.string().min(1) });
export const packageWorkshopProcedures = {
workshopReleases: admin
.meta({
mcp: {
enabled: true,
description:
"List immutable v3 Workshop release metadata for a submission ID. Requires admin. Does not execute or install package code.",
},
})
.input(z.object({ submissionId: z.string().min(1) }))
.query(({ input }) =>
withWorkshopError(() => widgetWorkshop.packages.list(input.submissionId, AbortSignal.timeout(15_000))),
),
workshopRelease: admin
.meta({
mcp: {
enabled: true,
description:
"Read and validate an immutable v3 Workshop release source and artifact. Requires admin; choose releaseId from customWidget_package_workshopReleases. Does not execute code.",
},
})
.input(releaseInput)
.query(({ input }) => getWidgetWorkshopRelease(input.releaseId)),
installWorkshop: admin
.input(
releaseInput.extend({
name: z.string().trim().min(1).max(128).optional(),
bindings: z.record(z.string(), z.string()).default({}),
}),
)
.mutation(async ({ ctx, input }) => {
const { source, artifact, origin } = await getWidgetWorkshopRelease(input.releaseId);
if (artifact) await persistPackageArtifact(ctx, source, artifact);
const id = createId();
await withWidgetArtifactReferenceLock(async () =>
ctx.db.insert(customWidgetInstallations).values({
id,
name: input.name ?? source.manifest.name,
draft: JSON.stringify(source),
origin: JSON.stringify(origin),
bindings: JSON.stringify(input.bindings),
enabled: false,
creatorId: ctx.session.user.id,
createdAt: new Date(),
updatedAt: new Date(),
}),
);
return { id, managementPath: `/manage/custom-widgets/packages/${id}` };
}),
checkUpdate: admin
.meta({
mcp: {
enabled: true,
description:
"Check a Workshop-linked widget installation for newer releases and local draft changes. Requires admin; preserves source, bindings and active code.",
},
})
.input(idInput)
.query(async ({ ctx, input }) => {
const installation = await getInstallation(ctx, input.id);
const origin = readWidgetWorkshopOrigin(installation.origin);
if (!origin) return { available: false as const, reason: "local" as const };
const releases = await withWorkshopError(() =>
widgetWorkshop.packages.list(origin.submissionId, AbortSignal.timeout(15_000)),
);
const latest = releases[0];
const draft = customWidgetPackageSchema.safeParse(JSON.parse(installation.draft));
let activeVersion: string | null = null;
if (installation.activeArtifactId)
activeVersion = (await getArtifact(ctx, installation.activeArtifactId)).source.manifest.version;
return {
available: Boolean(latest && latest.id !== origin.releaseId),
origin,
latest: latest
? { id: latest.id, version: latest.version, changelog: latest.changelog, created: latest.created }
: null,
hasLocalChanges: !draft.success || packageDigest(draft.data) !== origin.sourceDigest,
draftDigest: packageDigest(installation.draft),
activeVersion,
};
}),
stageWorkshopUpdate: admin
.input(
idInput.extend({
releaseId: z.string().min(1),
replaceLocalDraft: z.literal(true),
expectedDraftDigest: z.string().min(1),
}),
)
.mutation(async ({ ctx, input }) => {
return withWidgetInstallationLock(input.id, async () => {
const installation = await getInstallation(ctx, input.id);
const origin = readWidgetWorkshopOrigin(installation.origin);
if (!origin)
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "This installation is not linked to Workshop" });
if (packageDigest(installation.draft) !== input.expectedDraftDigest)
throw new TRPCError({ code: "CONFLICT", message: "The local draft changed. Review it before replacing it." });
const incoming = await getWidgetWorkshopRelease(input.releaseId);
const current = customWidgetPackageSchema.parse(JSON.parse(installation.draft));
if (
incoming.origin.submissionId !== origin.submissionId ||
incoming.source.manifest.id !== current.manifest.id
) {
throw new TRPCError({ code: "BAD_REQUEST", message: "This release belongs to a different package" });
}
if (incoming.artifact) await persistPackageArtifact(ctx, incoming.source, incoming.artifact);
const draft = JSON.stringify(incoming.source);
await withWidgetArtifactReferenceLock(async () =>
ctx.db
.update(customWidgetInstallations)
.set({
draft,
origin: JSON.stringify(preserveWidgetCollectionOrigin(installation.origin, incoming.origin)),
updatedAt: new Date(),
})
.where(
and(eq(customWidgetInstallations.id, input.id), eq(customWidgetInstallations.draft, installation.draft)),
),
);
if ((await getInstallation(ctx, input.id)).draft !== draft)
throw new TRPCError({ code: "CONFLICT", message: "The local draft changed. Review it before replacing it." });
return { id: input.id, managementPath: `/manage/custom-widgets/packages/${input.id}` };
});
}),
publishWorkshop: admin
.input(
idInput.extend({
token: z.string().min(1).max(20_000),
mode: z.enum(["new", "update", "fork"]),
submissionId: z.string().optional(),
expectedRevision: z.number().int().positive().optional(),
forkedFrom: z.string().optional(),
changelog: z.string().max(2_000).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const installation = await getInstallation(ctx, input.id);
const source = customWidgetPackageSchema.parse(JSON.parse(installation.draft));
const origin = readWidgetWorkshopOrigin(installation.origin);
let submissionId: string | undefined;
let forkedFrom: string | undefined;
if (input.mode === "update") {
submissionId = input.submissionId ?? origin?.submissionId;
if (!submissionId || !input.expectedRevision)
throw new TRPCError({
code: "BAD_REQUEST",
message: "An update requires its Workshop submission and current revision",
});
} else if (input.mode === "fork") {
forkedFrom = input.forkedFrom ?? origin?.releaseId;
if (!forkedFrom)
throw new TRPCError({ code: "BAD_REQUEST", message: "Choose the original release to publish a fork" });
const original = await getWidgetWorkshopRelease(forkedFrom);
if (original.source.manifest.id === source.manifest.id)
throw new TRPCError({ code: "BAD_REQUEST", message: "A fork must have its own package identity" });
}
let artifact: CustomWidgetArtifact | undefined;
if (installation.activeArtifactId) {
const active = await getArtifact(ctx, installation.activeArtifactId);
if (packageDigest(active.source) === packageDigest(source)) artifact = active.artifact;
}
const release = await withWorkshopError(() =>
authenticatedWidgetWorkshop(input.token).packages.publish({
source,
artifact,
submissionId,
expectedRevision: input.expectedRevision,
forkedFrom,
changelog: input.changelog,
title: installation.name,
description: source.manifest.description,
}),
);
const published = await getWidgetWorkshopRelease(release.id);
await ctx.db
.update(customWidgetInstallations)
.set({
origin: JSON.stringify(preserveWidgetCollectionOrigin(installation.origin, published.origin)),
updatedAt: new Date(),
})
.where(eq(customWidgetInstallations.id, input.id));
return {
releaseId: release.id,
submissionId: release.submission,
version: release.version,
workshopUrl: published.workshopUrl,
};
}),
};
@@ -19,7 +19,7 @@ import { customWidgetDefinitions } from "@homarr/db/schema";
import { permissionRequiredProcedure } from "../../trpc";
import { parseCustomWidgetAuthoringInput } from "./authoring-validation";
import { createPreviewSession, getPreviewSession, revisePreviewSessionTemplate } from "./preview-sessions";
import { createPreviewSession, discardPreviewSession, getPreviewSession, revisePreviewSessionTemplate } from "./preview-sessions";
import { hasSameSecretBinding, requiredSecretKinds } from "./secret-policy";
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
@@ -179,6 +179,9 @@ const previewCreateProcedure = permissionRequiredProcedure
});
export const previewBaseProcedures = {
previewDiscard: permissionRequiredProcedure.requiresPermission("admin")
.input(z.object({ sessionId: z.string().min(1) }))
.mutation(({ ctx, input }) => discardPreviewSession(input.sessionId, ctx.session.user.id)),
previewCreate: previewCreateProcedure,
previewReviseTemplate: permissionRequiredProcedure
.requiresPermission("admin")
@@ -49,7 +49,7 @@ class RedisPreviewSessionStore implements PreviewSessionStore {
);
}
public async deleteSession(id: string) {
await this.redis.del(`${SESSION_PREFIX}${id}`);
await this.redis.del(`${SESSION_PREFIX}${id}`, `${JOURNAL_PREFIX}${id}`, `${EVIDENCE_PREFIX}${id}`);
}
public async appendJournal(id: string, value: unknown, maxEntries: number, ttlMs: number) {
@@ -99,6 +99,7 @@ export type { CreatePreviewSessionInput, CustomWidgetPreviewJournalEntry, Custom
export const createPreviewSession = (input: CreatePreviewSessionInput) => call(() => getService().create(input));
export const getPreviewSession = (id: string, userId: string) => call(() => getService().get(id, userId));
export const discardPreviewSession = (id: string, userId: string) => call(() => getService().discard(id, userId));
export const setPreviewSessionLiveActions = (id: string, userId: string, enabled: boolean) =>
call(() => getService().setLiveActions(id, userId, enabled));
export const revisePreviewSessionTemplate = (id: string, userId: string, template: string, expectedRevision?: number) =>
@@ -0,0 +1,70 @@
import { TRPCError } from "@trpc/server";
import { parse } from "superjson";
import { z } from "zod/v4";
import { isRecord } from "@homarr/common";
import { and, eq, handleTransactionsAsync } from "@homarr/db";
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure } from "../../trpc";
import { hasSameSecretBinding, requiredSecretKinds } from "./secret-policy";
import { safeParseStoredCustomWidgetDefinition, serializeCustomWidgetDefinition } from "./stored-definition";
const admin = permissionRequiredProcedure.requiresPermission("admin");
export const repairProcedures = {
getRaw: admin.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
const row = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id), with: { secrets: true },
});
if (!row) throw new TRPCError({ code: "NOT_FOUND" });
const result = safeParseStoredCustomWidgetDefinition(row);
return {
id: row.id, name: row.name, description: row.description, iconUrl: row.iconUrl,
sources: row.sources, requests: row.requests, options: row.options, template: row.template,
enabled: row.enabled, issues: result.success ? [] : result.issues,
secrets: row.secrets.map(({ sourceId, kind, updatedAt }) => ({ sourceId, kind, updatedAt, hasValue: true })),
};
}),
repair: admin.input(z.object({ id: z.string(), widget: customWidgetDefinitionSchema })).mutation(async ({ ctx, input }) => {
const row = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id), with: { secrets: true },
});
if (!row) throw new TRPCError({ code: "NOT_FOUND" });
let oldSources: Record<string, unknown> = {};
try {
const parsed: unknown = parse(row.sources);
if (isRecord(parsed)) oldSources = parsed;
} catch { /* Unreadable source bindings cannot safely retain credentials. */ }
const removedSources = new Set<string>();
const removedSecrets = row.secrets.filter((secret) => {
const next = input.widget.sources[secret.sourceId];
const previous = customWidgetDefinitionSchema.shape.sources.safeParse({ [secret.sourceId]: oldSources[secret.sourceId] });
let oldSource;
if (previous.success) oldSource = previous.data[secret.sourceId];
if (!next || !oldSource || !hasSameSecretBinding(oldSource, next)) {
removedSources.add(secret.sourceId);
return true;
}
const type = typeof next.auth === "string" ? next.auth : next.auth.type;
return !new Set<string>(requiredSecretKinds(type)).has(secret.kind);
});
const changes = { ...serializeCustomWidgetDefinition(input.widget), updatedAt: new Date() };
await handleTransactionsAsync(ctx.db, {
async handleAsync(database, schema) {
await database.transaction(async (transaction) => {
await transaction.update(schema.customWidgetDefinitions).set(changes).where(eq(schema.customWidgetDefinitions.id, input.id));
for (const secret of removedSecrets) await transaction.delete(schema.customWidgetSecrets).where(and(eq(schema.customWidgetSecrets.definitionId, input.id), eq(schema.customWidgetSecrets.sourceId, secret.sourceId), eq(schema.customWidgetSecrets.kind, secret.kind)));
});
},
handleSync(database) {
database.transaction((transaction) => {
transaction.update(customWidgetDefinitions).set(changes).where(eq(customWidgetDefinitions.id, input.id)).run();
for (const secret of removedSecrets) transaction.delete(customWidgetSecrets).where(and(eq(customWidgetSecrets.definitionId, input.id), eq(customWidgetSecrets.sourceId, secret.sourceId), eq(customWidgetSecrets.kind, secret.kind))).run();
});
},
});
return { id: input.id, managementPath: `/manage/custom-widgets/edit/${input.id}`, credentialsRequired: [...removedSources] };
}),
};
@@ -0,0 +1,18 @@
/** Start work in bounded batches so valid manifests do not exhaust their own request limiter. */
export async function mapCustomWidgetRequests<T, R>(
inputs: readonly T[],
concurrency: number,
execute: (input: T) => Promise<R>,
): Promise<R[]> {
const results = new Array<R>(inputs.length);
let cursor = 0;
const worker = async () => {
while (cursor < inputs.length) {
const index = cursor++;
const input = inputs[index];
if (input !== undefined) results[index] = await execute(input);
}
};
await Promise.all(Array.from({ length: Math.min(Math.max(1, concurrency), inputs.length) }, worker));
return results;
}
@@ -1,28 +1,12 @@
import { TRPCError } from "@trpc/server";
import {
getCustomWidgetRequiredSecretKinds,
hasSameCustomWidgetSourceAuthentication,
} from "@homarr/custom-widgets/core";
import type { CustomWidgetSource } from "@homarr/custom-widgets/core";
import { getCustomWidgetRequiredSecretKinds } from "@homarr/custom-widgets/core";
export { hasSameCustomWidgetSourceBinding as hasSameSecretBinding } from "@homarr/custom-widgets/core";
export function requiredSecretKinds(authType: string) {
return getCustomWidgetRequiredSecretKinds(authType as Parameters<typeof getCustomWidgetRequiredSecretKinds>[0]);
}
/**
* Credentials may only survive edits that preserve their complete security
* boundary. The path is intentionally excluded: credentials are scoped to an
* origin, while the network scope and auth destination are part of the binding.
*/
export function hasSameSecretBinding(left: CustomWidgetSource, right: CustomWidgetSource) {
return (
new URL(left.baseUrl).origin === new URL(right.baseUrl).origin &&
left.networkScope === right.networkScope &&
hasSameCustomWidgetSourceAuthentication(left, right)
);
}
export function assertSecretSources(
sources: Record<string, { auth: string | { type: string } }>,
secrets: readonly { sourceId: string; kind: "apiKey" | "username" | "password" }[],
+17 -3
View File
@@ -27,6 +27,9 @@ import {
import { acquireCustomWidgetRequestLimit } from "../custom-widget/request-limits";
import { getCustomWidgetCacheVersion } from "../custom-widget/cache-version";
import { parseStoredCustomWidgetDefinition } from "../custom-widget/stored-definition";
import { mapCustomWidgetRequests } from "../custom-widget/request-queue";
import { getPackageDisplayData, resolvePackagePlacement } from "../custom-widget/package/records";
import { packageWidgetProcedures } from "../custom-widget/package/widget-procedures";
const runtimeParamsSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()]));
const itemInputSchema = z.object({ itemId: z.string().min(1) });
@@ -190,6 +193,7 @@ const executeRequest = async (
};
export const customApiRouter = createTRPCRouter({
...packageWidgetProcedures,
refresh: publicProcedure.input(itemInputSchema).mutation(async ({ ctx, input }) => {
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
invalidateCustomWidgetResponseCache([
@@ -198,12 +202,21 @@ export const customApiRouter = createTRPCRouter({
}),
getData: publicProcedure.input(itemInputSchema).query(async ({ ctx, input }) => {
const packageItem = await ctx.db.query.items.findFirst({ where: eq(items.id, input.itemId) });
if (packageItem?.kind === "customApi") {
const packageOptions = parseItemOptions(packageItem.options);
const installation = await ctx.db.query.customWidgetInstallations.findFirst({
where: (table, { eq }) => eq(table.id, packageOptions.definitionId),
columns: { id: true },
});
if (installation) return getPackageDisplayData(await resolvePackagePlacement(ctx, input.itemId));
}
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
const loadRequests = Object.entries(resolved.definition.requests).filter(
([, request]) => request.kind === "query" && request.trigger === "load",
);
const entries = await Promise.all(
loadRequests.map(async ([requestId, request]) => {
const entries = await mapCustomWidgetRequests(
loadRequests, 4, async ([requestId, request]) => {
try {
const response = await executeRequest(ctx, resolved, { id: requestId, ...request }, {});
return [
@@ -233,11 +246,12 @@ export const customApiRouter = createTRPCRouter({
},
] as const;
}
}),
},
);
return {
type: "customJsx" as const,
hasLoadQueries: loadRequests.length > 0,
template: resolved.definition.template,
queryCacheKey: `${getCustomWidgetCacheVersion(resolved.stored)}:${hashRuntimeParams(resolved.configuration)}`,
data: Object.fromEntries(entries.map(([id, result]) => [id, result.data])),
+31 -23
View File
@@ -11,6 +11,7 @@ import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trp
import { throwIfActionForbiddenAsync } from "../board/board-access";
import { validateTimetableOptionsChangeAsync } from "./timetable";
import { throwIfCustomWidgetPlacementChangeForbidden } from "../board/custom-widget-placement-access";
import { withWidgetPlacementChange } from "../custom-widget/package/placement-coordination";
export const optionsRouter = createTRPCRouter({
getWidgetOptionSettings: publicProcedure.query(async ({ ctx }): Promise<WidgetOptionsSettings> => {
@@ -36,30 +37,37 @@ export const optionsRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
const item = await ctx.db.query.items.findFirst({
where: eq(items.id, input.itemId),
});
return withWidgetPlacementChange(
ctx,
{ itemId: input.itemId, submittedItems: [{ kind: "customApi", options: input.newOptions }] },
async (signal) => {
const item = await ctx.db.query.items.findFirst({
where: eq(items.id, input.itemId),
});
if (item?.boardId !== input.boardId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Specified item was not found",
});
}
if (item?.boardId !== input.boardId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Specified item was not found",
});
}
const previousOptions = SuperJSON.parse<Record<string, unknown>>(item.options);
const updatedOptions = { ...previousOptions, ...input.newOptions };
if (item.kind === "timetable") {
await validateTimetableOptionsChangeAsync(updatedOptions, previousOptions);
}
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: ctx.session.user.permissions.includes("admin"),
submittedItems: [{ id: item.id, kind: item.kind, options: updatedOptions }],
storedItems: [{ id: item.id, kind: item.kind, options: previousOptions }],
});
await ctx.db
.update(items)
.set({ options: SuperJSON.stringify(updatedOptions) })
.where(eq(items.id, input.itemId));
const previousOptions = SuperJSON.parse<Record<string, unknown>>(item.options);
const updatedOptions = { ...previousOptions, ...input.newOptions };
if (item.kind === "timetable") {
await validateTimetableOptionsChangeAsync(updatedOptions, previousOptions);
}
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: ctx.session.user.permissions.includes("admin"),
submittedItems: [{ id: item.id, kind: item.kind, options: updatedOptions }],
storedItems: [{ id: item.id, kind: item.kind, options: previousOptions }],
});
signal.throwIfAborted();
await ctx.db
.update(items)
.set({ options: SuperJSON.stringify(updatedOptions) })
.where(eq(items.id, input.itemId));
},
);
}),
});
+9
View File
@@ -39,6 +39,11 @@ const MCP_TOOL_ALLOWLIST = {
"customWidget_getSkill",
"customWidget_legacyMigrationPrompt",
"customWidget_list",
"customWidget_package_checkUpdate",
"customWidget_package_get",
"customWidget_package_list",
"customWidget_package_workshopRelease",
"customWidget_package_workshopReleases",
"customWidget_previewJournal",
"customWidget_previewQuery",
"customWidget_schema",
@@ -101,6 +106,10 @@ const MCP_TOOL_ALLOWLIST = {
"board_setHomeBoard",
"board_setMobileHomeBoard",
"customWidget_delete",
"customWidget_package_activate",
"customWidget_package_preview",
"customWidget_package_saveDraft",
"customWidget_package_setEnabled",
"customWidget_previewAction",
"customWidget_previewReviseTemplate",
"customWidget_templatePatch",
+6
View File
@@ -43,6 +43,7 @@ interface ApiContext {
deviceType: DeviceType;
baseUrl?: `${string}://${string}`;
onboardingClaimToken?: string;
crossSiteRequest?: boolean;
db: typeof db;
}
@@ -59,6 +60,11 @@ export const createTRPCContext = (opts: { headers: Headers; session: Session | n
return {
session,
deviceType: userAgent(opts.headers).device.type,
crossSiteRequest: opts.headers.get("sec-fetch-site") === "cross-site" || (() => {
const origin = opts.headers.get("origin");
if (!origin) return false;
return origin !== extractBaseUrlFromHeaders(opts.headers);
})(),
baseUrl: extractBaseUrlFromHeaders(opts.headers),
onboardingClaimToken: getOnboardingClaimTokenFromCookieHeader(opts.headers.get("cookie")),
db,